karaoke-gen 0.90.1__py3-none-any.whl → 0.99.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. backend/.coveragerc +20 -0
  2. backend/.gitignore +37 -0
  3. backend/Dockerfile +43 -0
  4. backend/Dockerfile.base +74 -0
  5. backend/README.md +242 -0
  6. backend/__init__.py +0 -0
  7. backend/api/__init__.py +0 -0
  8. backend/api/dependencies.py +457 -0
  9. backend/api/routes/__init__.py +0 -0
  10. backend/api/routes/admin.py +835 -0
  11. backend/api/routes/audio_search.py +913 -0
  12. backend/api/routes/auth.py +348 -0
  13. backend/api/routes/file_upload.py +2112 -0
  14. backend/api/routes/health.py +409 -0
  15. backend/api/routes/internal.py +435 -0
  16. backend/api/routes/jobs.py +1629 -0
  17. backend/api/routes/review.py +652 -0
  18. backend/api/routes/themes.py +162 -0
  19. backend/api/routes/users.py +1513 -0
  20. backend/config.py +172 -0
  21. backend/main.py +157 -0
  22. backend/middleware/__init__.py +5 -0
  23. backend/middleware/audit_logging.py +124 -0
  24. backend/models/__init__.py +0 -0
  25. backend/models/job.py +519 -0
  26. backend/models/requests.py +123 -0
  27. backend/models/theme.py +153 -0
  28. backend/models/user.py +254 -0
  29. backend/models/worker_log.py +164 -0
  30. backend/pyproject.toml +29 -0
  31. backend/quick-check.sh +93 -0
  32. backend/requirements.txt +29 -0
  33. backend/run_tests.sh +60 -0
  34. backend/services/__init__.py +0 -0
  35. backend/services/audio_analysis_service.py +243 -0
  36. backend/services/audio_editing_service.py +278 -0
  37. backend/services/audio_search_service.py +702 -0
  38. backend/services/auth_service.py +630 -0
  39. backend/services/credential_manager.py +792 -0
  40. backend/services/discord_service.py +172 -0
  41. backend/services/dropbox_service.py +301 -0
  42. backend/services/email_service.py +1093 -0
  43. backend/services/encoding_interface.py +454 -0
  44. backend/services/encoding_service.py +502 -0
  45. backend/services/firestore_service.py +512 -0
  46. backend/services/flacfetch_client.py +573 -0
  47. backend/services/gce_encoding/README.md +72 -0
  48. backend/services/gce_encoding/__init__.py +22 -0
  49. backend/services/gce_encoding/main.py +589 -0
  50. backend/services/gce_encoding/requirements.txt +16 -0
  51. backend/services/gdrive_service.py +356 -0
  52. backend/services/job_logging.py +258 -0
  53. backend/services/job_manager.py +853 -0
  54. backend/services/job_notification_service.py +271 -0
  55. backend/services/langfuse_preloader.py +98 -0
  56. backend/services/local_encoding_service.py +590 -0
  57. backend/services/local_preview_encoding_service.py +407 -0
  58. backend/services/lyrics_cache_service.py +216 -0
  59. backend/services/metrics.py +413 -0
  60. backend/services/nltk_preloader.py +122 -0
  61. backend/services/packaging_service.py +287 -0
  62. backend/services/rclone_service.py +106 -0
  63. backend/services/spacy_preloader.py +65 -0
  64. backend/services/storage_service.py +209 -0
  65. backend/services/stripe_service.py +371 -0
  66. backend/services/structured_logging.py +254 -0
  67. backend/services/template_service.py +330 -0
  68. backend/services/theme_service.py +469 -0
  69. backend/services/tracing.py +543 -0
  70. backend/services/user_service.py +721 -0
  71. backend/services/worker_service.py +558 -0
  72. backend/services/youtube_service.py +112 -0
  73. backend/services/youtube_upload_service.py +445 -0
  74. backend/tests/__init__.py +4 -0
  75. backend/tests/conftest.py +224 -0
  76. backend/tests/emulator/__init__.py +7 -0
  77. backend/tests/emulator/conftest.py +109 -0
  78. backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
  79. backend/tests/emulator/test_emulator_integration.py +356 -0
  80. backend/tests/emulator/test_style_loading_direct.py +436 -0
  81. backend/tests/emulator/test_worker_logs_direct.py +229 -0
  82. backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
  83. backend/tests/requirements-test.txt +10 -0
  84. backend/tests/requirements.txt +6 -0
  85. backend/tests/test_admin_email_endpoints.py +411 -0
  86. backend/tests/test_api_integration.py +460 -0
  87. backend/tests/test_api_routes.py +93 -0
  88. backend/tests/test_audio_analysis_service.py +294 -0
  89. backend/tests/test_audio_editing_service.py +386 -0
  90. backend/tests/test_audio_search.py +1398 -0
  91. backend/tests/test_audio_services.py +378 -0
  92. backend/tests/test_auth_firestore.py +231 -0
  93. backend/tests/test_config_extended.py +68 -0
  94. backend/tests/test_credential_manager.py +377 -0
  95. backend/tests/test_dependencies.py +54 -0
  96. backend/tests/test_discord_service.py +244 -0
  97. backend/tests/test_distribution_services.py +820 -0
  98. backend/tests/test_dropbox_service.py +472 -0
  99. backend/tests/test_email_service.py +492 -0
  100. backend/tests/test_emulator_integration.py +322 -0
  101. backend/tests/test_encoding_interface.py +412 -0
  102. backend/tests/test_file_upload.py +1739 -0
  103. backend/tests/test_flacfetch_client.py +632 -0
  104. backend/tests/test_gdrive_service.py +524 -0
  105. backend/tests/test_instrumental_api.py +431 -0
  106. backend/tests/test_internal_api.py +343 -0
  107. backend/tests/test_job_creation_regression.py +583 -0
  108. backend/tests/test_job_manager.py +356 -0
  109. backend/tests/test_job_manager_notifications.py +329 -0
  110. backend/tests/test_job_notification_service.py +443 -0
  111. backend/tests/test_jobs_api.py +283 -0
  112. backend/tests/test_local_encoding_service.py +423 -0
  113. backend/tests/test_local_preview_encoding_service.py +567 -0
  114. backend/tests/test_main.py +87 -0
  115. backend/tests/test_models.py +918 -0
  116. backend/tests/test_packaging_service.py +382 -0
  117. backend/tests/test_requests.py +201 -0
  118. backend/tests/test_routes_jobs.py +282 -0
  119. backend/tests/test_routes_review.py +337 -0
  120. backend/tests/test_services.py +556 -0
  121. backend/tests/test_services_extended.py +112 -0
  122. backend/tests/test_spacy_preloader.py +119 -0
  123. backend/tests/test_storage_service.py +448 -0
  124. backend/tests/test_style_upload.py +261 -0
  125. backend/tests/test_template_service.py +295 -0
  126. backend/tests/test_theme_service.py +516 -0
  127. backend/tests/test_unicode_sanitization.py +522 -0
  128. backend/tests/test_upload_api.py +256 -0
  129. backend/tests/test_validate.py +156 -0
  130. backend/tests/test_video_worker_orchestrator.py +847 -0
  131. backend/tests/test_worker_log_subcollection.py +509 -0
  132. backend/tests/test_worker_logging.py +365 -0
  133. backend/tests/test_workers.py +1116 -0
  134. backend/tests/test_workers_extended.py +178 -0
  135. backend/tests/test_youtube_service.py +247 -0
  136. backend/tests/test_youtube_upload_service.py +568 -0
  137. backend/utils/test_data.py +27 -0
  138. backend/validate.py +173 -0
  139. backend/version.py +27 -0
  140. backend/workers/README.md +597 -0
  141. backend/workers/__init__.py +11 -0
  142. backend/workers/audio_worker.py +618 -0
  143. backend/workers/lyrics_worker.py +683 -0
  144. backend/workers/render_video_worker.py +483 -0
  145. backend/workers/screens_worker.py +535 -0
  146. backend/workers/style_helper.py +198 -0
  147. backend/workers/video_worker.py +1277 -0
  148. backend/workers/video_worker_orchestrator.py +701 -0
  149. backend/workers/worker_logging.py +278 -0
  150. karaoke_gen/instrumental_review/static/index.html +7 -4
  151. karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
  152. karaoke_gen/utils/__init__.py +163 -8
  153. karaoke_gen/video_background_processor.py +9 -4
  154. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/METADATA +1 -1
  155. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/RECORD +196 -46
  156. lyrics_transcriber/correction/agentic/agent.py +17 -6
  157. lyrics_transcriber/correction/agentic/providers/config.py +9 -5
  158. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -93
  159. lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
  160. lyrics_transcriber/correction/anchor_sequence.py +151 -37
  161. lyrics_transcriber/correction/corrector.py +192 -130
  162. lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
  163. lyrics_transcriber/correction/operations.py +24 -9
  164. lyrics_transcriber/correction/phrase_analyzer.py +18 -0
  165. lyrics_transcriber/frontend/package-lock.json +2 -2
  166. lyrics_transcriber/frontend/package.json +1 -1
  167. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
  168. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
  169. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
  170. lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
  171. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
  172. lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
  173. lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
  174. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
  175. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
  176. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
  177. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
  178. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  179. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
  180. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
  181. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  182. lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
  183. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
  184. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
  185. lyrics_transcriber/frontend/src/theme.ts +42 -15
  186. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  187. lyrics_transcriber/frontend/vite.config.js +5 -0
  188. lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
  189. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
  190. lyrics_transcriber/frontend/web_assets/index.html +6 -2
  191. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
  192. lyrics_transcriber/output/generator.py +17 -3
  193. lyrics_transcriber/output/video.py +60 -95
  194. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
  195. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/WHEEL +0 -0
  196. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/entry_points.txt +0 -0
  197. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1093 @@
1
+ """
2
+ Email service for sending transactional emails.
3
+
4
+ Supports multiple providers:
5
+ - SendGrid (recommended for production)
6
+ - Console logging (for development/testing)
7
+
8
+ Future providers can be added:
9
+ - Mailgun
10
+ - AWS SES
11
+ - Postmark
12
+ """
13
+ import html
14
+ import logging
15
+ import os
16
+ from abc import ABC, abstractmethod
17
+ from typing import Optional, List
18
+
19
+ from backend.config import get_settings
20
+ from karaoke_gen.utils import sanitize_filename
21
+
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class EmailProvider(ABC):
27
+ """Abstract base class for email providers."""
28
+
29
+ @abstractmethod
30
+ def send_email(
31
+ self,
32
+ to_email: str,
33
+ subject: str,
34
+ html_content: str,
35
+ text_content: Optional[str] = None,
36
+ cc_emails: Optional[List[str]] = None,
37
+ ) -> bool:
38
+ """Send an email. Returns True if successful."""
39
+ pass
40
+
41
+
42
+ class ConsoleEmailProvider(EmailProvider):
43
+ """
44
+ Development email provider that logs to console.
45
+
46
+ Useful for local development and testing.
47
+ """
48
+
49
+ def send_email(
50
+ self,
51
+ to_email: str,
52
+ subject: str,
53
+ html_content: str,
54
+ text_content: Optional[str] = None,
55
+ cc_emails: Optional[List[str]] = None,
56
+ ) -> bool:
57
+ logger.info("=" * 60)
58
+ logger.info(f"EMAIL TO: {to_email}")
59
+ if cc_emails:
60
+ logger.info(f"CC: {', '.join(cc_emails)}")
61
+ logger.info(f"SUBJECT: {subject}")
62
+ logger.info("-" * 60)
63
+ logger.info(text_content or html_content)
64
+ logger.info("=" * 60)
65
+ return True
66
+
67
+
68
+ class PreviewEmailProvider(EmailProvider):
69
+ """
70
+ Email provider that captures HTML content for previewing.
71
+
72
+ Instead of sending emails, stores the HTML content for later retrieval.
73
+ Useful for generating email previews.
74
+ """
75
+
76
+ def __init__(self):
77
+ self.last_html: Optional[str] = None
78
+ self.last_subject: Optional[str] = None
79
+ self.last_to_email: Optional[str] = None
80
+
81
+ def send_email(
82
+ self,
83
+ to_email: str,
84
+ subject: str,
85
+ html_content: str,
86
+ text_content: Optional[str] = None,
87
+ cc_emails: Optional[List[str]] = None,
88
+ ) -> bool:
89
+ self.last_to_email = to_email
90
+ self.last_subject = subject
91
+ self.last_html = html_content
92
+ return True
93
+
94
+ def get_last_html(self) -> Optional[str]:
95
+ """Get the HTML content from the last 'sent' email."""
96
+ return self.last_html
97
+
98
+
99
+ class SendGridEmailProvider(EmailProvider):
100
+ """
101
+ SendGrid email provider for production.
102
+
103
+ Requires SENDGRID_API_KEY environment variable.
104
+ """
105
+
106
+ def __init__(self, api_key: str, from_email: str, from_name: str = "Nomad Karaoke"):
107
+ self.api_key = api_key
108
+ self.from_email = from_email
109
+ self.from_name = from_name
110
+
111
+ def send_email(
112
+ self,
113
+ to_email: str,
114
+ subject: str,
115
+ html_content: str,
116
+ text_content: Optional[str] = None,
117
+ cc_emails: Optional[List[str]] = None,
118
+ ) -> bool:
119
+ try:
120
+ # Import here to avoid requiring sendgrid in all environments
121
+ from sendgrid import SendGridAPIClient
122
+ from sendgrid.helpers.mail import Mail, Email, To, Content, Cc
123
+
124
+ sg = SendGridAPIClient(api_key=self.api_key)
125
+
126
+ message = Mail(
127
+ from_email=Email(self.from_email, self.from_name),
128
+ to_emails=To(to_email),
129
+ subject=subject,
130
+ html_content=Content("text/html", html_content)
131
+ )
132
+
133
+ # Add CC recipients if provided (deduplicated and normalized)
134
+ if cc_emails:
135
+ # Normalize emails (lowercase, strip whitespace) and deduplicate
136
+ seen = set()
137
+ unique_cc_emails = []
138
+ for cc_email in cc_emails:
139
+ normalized = cc_email.strip().lower()
140
+ if normalized and normalized not in seen:
141
+ seen.add(normalized)
142
+ unique_cc_emails.append(cc_email.strip())
143
+
144
+ for cc_email in unique_cc_emails:
145
+ message.add_cc(Cc(cc_email))
146
+
147
+ if text_content:
148
+ message.add_content(Content("text/plain", text_content))
149
+
150
+ response = sg.send(message)
151
+
152
+ if response.status_code >= 200 and response.status_code < 300:
153
+ cc_info = f" (CC: {', '.join(cc_emails)})" if cc_emails else ""
154
+ logger.info(f"Email sent to {to_email}{cc_info} via SendGrid")
155
+ return True
156
+ else:
157
+ logger.error(f"SendGrid returned status {response.status_code}")
158
+ return False
159
+
160
+ except Exception:
161
+ logger.exception("Failed to send email via SendGrid")
162
+ return False
163
+
164
+
165
+ class EmailService:
166
+ """
167
+ High-level email service for sending transactional emails.
168
+
169
+ Automatically selects the appropriate provider based on configuration.
170
+ """
171
+
172
+ # Brand colors and assets for consistent email styling
173
+ BRAND_PRIMARY = "#ff7acc" # Pink
174
+ BRAND_PRIMARY_HOVER = "#e066b3" # Darker pink for hover states
175
+ BRAND_SECONDARY = "#ffdf6b" # Yellow (accent)
176
+ BRAND_SUCCESS = "#22c55e" # Green for success states (Tailwind green-500)
177
+ LOGO_URL = "https://beveradb.github.io/public-images/Nomad-Karaoke-Logo-small-indexed-websafe-rectangle.gif"
178
+
179
+ def __init__(self):
180
+ self.settings = get_settings()
181
+ self.provider = self._get_provider()
182
+ self.frontend_url = os.getenv("FRONTEND_URL", "https://gen.nomadkaraoke.com")
183
+ # After consolidation, buy URL is the same as frontend URL
184
+ self.buy_url = os.getenv("BUY_URL", self.frontend_url)
185
+
186
+ def _get_provider(self) -> EmailProvider:
187
+ """Get the configured email provider."""
188
+ sendgrid_api_key = os.getenv("SENDGRID_API_KEY")
189
+ from_email = os.getenv("EMAIL_FROM", "gen@nomadkaraoke.com")
190
+ from_name = os.getenv("EMAIL_FROM_NAME", "Nomad Karaoke")
191
+
192
+ if sendgrid_api_key:
193
+ logger.info("Using SendGrid email provider")
194
+ return SendGridEmailProvider(sendgrid_api_key, from_email, from_name)
195
+ else:
196
+ logger.warning("No email provider configured, using console logging")
197
+ return ConsoleEmailProvider()
198
+
199
+ def is_configured(self) -> bool:
200
+ """Check if a real email provider is configured (not just console logging)."""
201
+ return isinstance(self.provider, SendGridEmailProvider)
202
+
203
+ def send_magic_link(self, email: str, token: str) -> bool:
204
+ """
205
+ Send a magic link email for authentication.
206
+
207
+ Args:
208
+ email: User's email address
209
+ token: Magic link token
210
+
211
+ Returns:
212
+ True if email was sent successfully
213
+ """
214
+ magic_link_url = f"{self.frontend_url}/auth/verify?token={token}"
215
+
216
+ subject = "Sign in to Nomad Karaoke"
217
+
218
+ extra_styles = """
219
+ .warning {
220
+ background-color: #fef3c7;
221
+ border: 1px solid #fcd34d;
222
+ border-radius: 4px;
223
+ padding: 12px;
224
+ margin: 20px 0;
225
+ font-size: 14px;
226
+ }
227
+ """
228
+
229
+ content = f"""
230
+ <p>Hi there,</p>
231
+
232
+ <p>Click the button below to sign in to Nomad Karaoke:</p>
233
+
234
+ <p style="text-align: center;">
235
+ <a href="{magic_link_url}" class="button">Sign In</a>
236
+ </p>
237
+
238
+ <div class="warning">
239
+ ⏰ This link expires in 15 minutes and can only be used once.
240
+ </div>
241
+
242
+ <p>If the button doesn't work, copy and paste this link into your browser:</p>
243
+ <p style="word-break: break-all; font-size: 14px; color: #666;">
244
+ {magic_link_url}
245
+ </p>
246
+
247
+ <p>If you didn't request this email, you can safely ignore it.</p>
248
+ """
249
+
250
+ html_content = self._build_email_html(content, extra_styles)
251
+
252
+ text_content = f"""
253
+ Sign in to Nomad Karaoke
254
+ ========================
255
+
256
+ Click this link to sign in:
257
+ {magic_link_url}
258
+
259
+ This link expires in 15 minutes and can only be used once.
260
+
261
+ If you didn't request this email, you can safely ignore it.
262
+
263
+ ---
264
+ © {self._get_year()} Nomad Karaoke
265
+ """
266
+
267
+ return self.provider.send_email(email, subject, html_content, text_content)
268
+
269
+ def send_credits_added(self, email: str, credits: int, total_credits: int) -> bool:
270
+ """
271
+ Send notification when credits are added to account.
272
+
273
+ Args:
274
+ email: User's email address
275
+ credits: Number of credits added
276
+ total_credits: New total credit balance
277
+
278
+ Returns:
279
+ True if email was sent successfully
280
+ """
281
+ subject = f"🎉 {credits} credits added to your Nomad Karaoke account"
282
+
283
+ extra_styles = f"""
284
+ .credits-box {{
285
+ background-color: #ecfdf5;
286
+ border: 2px solid {self.BRAND_SUCCESS};
287
+ border-radius: 12px;
288
+ padding: 24px;
289
+ text-align: center;
290
+ margin: 20px 0;
291
+ }}
292
+ .credits-number {{
293
+ font-size: 48px;
294
+ font-weight: bold;
295
+ color: {self.BRAND_SUCCESS};
296
+ }}
297
+ """
298
+
299
+ content = f"""
300
+ <p>Great news!</p>
301
+
302
+ <p><strong>{credits} credits</strong> have been added to your account.</p>
303
+
304
+ <div class="credits-box">
305
+ <div>Your balance:</div>
306
+ <div class="credits-number">{total_credits}</div>
307
+ <div>credits</div>
308
+ </div>
309
+
310
+ <p>Each credit lets you create one professional karaoke video with:</p>
311
+ <ul>
312
+ <li>AI-powered vocal/instrumental separation</li>
313
+ <li>Synchronized lyrics with word-level timing</li>
314
+ <li>4K video output</li>
315
+ <li>YouTube upload</li>
316
+ </ul>
317
+
318
+ <p style="text-align: center;">
319
+ <a href="{self.frontend_url}" class="button">Create Karaoke Now</a>
320
+ </p>
321
+ """
322
+
323
+ html_content = self._build_email_html(content, extra_styles)
324
+
325
+ text_content = f"""
326
+ {credits} credits added to your Nomad Karaoke account!
327
+
328
+ Your new balance: {total_credits} credits
329
+
330
+ Each credit lets you create one professional karaoke video.
331
+
332
+ Start creating: {self.frontend_url}
333
+
334
+ ---
335
+ © {self._get_year()} Nomad Karaoke
336
+ """
337
+
338
+ return self.provider.send_email(email, subject, html_content, text_content)
339
+
340
+ def send_welcome_email(self, email: str, credits: int = 0) -> bool:
341
+ """
342
+ Send welcome email to new users.
343
+
344
+ Args:
345
+ email: User's email address
346
+ credits: Initial credit balance (if any)
347
+
348
+ Returns:
349
+ True if email was sent successfully
350
+ """
351
+ subject = "Welcome to Nomad Karaoke! 🎤"
352
+
353
+ credits_text = f"You have <strong>{credits} credits</strong> to get started!" if credits > 0 else ""
354
+
355
+ extra_styles = """
356
+ .feature {
357
+ display: flex;
358
+ align-items: flex-start;
359
+ margin: 16px 0;
360
+ }
361
+ .feature-icon {
362
+ font-size: 24px;
363
+ margin-right: 12px;
364
+ }
365
+ """
366
+
367
+ content = f"""
368
+ <p>Welcome to Nomad Karaoke!</p>
369
+
370
+ <p>Turn any song into a professional karaoke video in minutes. {credits_text}</p>
371
+
372
+ <p><strong>Here's how it works:</strong></p>
373
+
374
+ <div class="feature">
375
+ <span class="feature-icon">🎵</span>
376
+ <div>
377
+ <strong>1. Search for a song</strong><br>
378
+ Enter the artist and title, and we'll find high-quality audio.
379
+ </div>
380
+ </div>
381
+
382
+ <div class="feature">
383
+ <span class="feature-icon">✨</span>
384
+ <div>
385
+ <strong>2. Our system works its magic</strong><br>
386
+ We separate vocals, transcribe lyrics, and sync everything perfectly.
387
+ </div>
388
+ </div>
389
+
390
+ <div class="feature">
391
+ <span class="feature-icon">✏️</span>
392
+ <div>
393
+ <strong>3. Review & customize</strong><br>
394
+ Fine-tune the lyrics if needed, choose your instrumental.
395
+ </div>
396
+ </div>
397
+
398
+ <div class="feature">
399
+ <span class="feature-icon">🎬</span>
400
+ <div>
401
+ <strong>4. Get your video</strong><br>
402
+ Download your 4K karaoke video or upload directly to YouTube.
403
+ </div>
404
+ </div>
405
+
406
+ <p style="text-align: center;">
407
+ <a href="{self.frontend_url}" class="button">Get Started</a>
408
+ </p>
409
+ """
410
+
411
+ html_content = self._build_email_html(content, extra_styles)
412
+
413
+ text_content = f"""
414
+ Welcome to Nomad Karaoke!
415
+
416
+ Turn any song into a professional karaoke video in minutes.
417
+
418
+ Here's how it works:
419
+
420
+ 1. Search for a song
421
+ Enter the artist and title, and we'll find high-quality audio.
422
+
423
+ 2. Our system works its magic
424
+ We separate vocals, transcribe lyrics, and sync everything perfectly.
425
+
426
+ 3. Review & customize
427
+ Fine-tune the lyrics if needed, choose your instrumental.
428
+
429
+ 4. Get your video
430
+ Download your 4K karaoke video or upload directly to YouTube.
431
+
432
+ Get started: {self.frontend_url}
433
+
434
+ ---
435
+ © {self._get_year()} Nomad Karaoke
436
+ """
437
+
438
+ return self.provider.send_email(email, subject, html_content, text_content)
439
+
440
+ def send_beta_welcome_email(self, email: str, credits: int = 1) -> bool:
441
+ """
442
+ Send welcome email to new beta testers.
443
+
444
+ Args:
445
+ email: User's email address
446
+ credits: Initial free credits granted
447
+
448
+ Returns:
449
+ True if email was sent successfully
450
+ """
451
+ subject = "Welcome Beta Tester! Free Karaoke Credits Inside 🎤"
452
+
453
+ extra_styles = f"""
454
+ .beta-badge {{
455
+ display: inline-block;
456
+ background: linear-gradient(135deg, {self.BRAND_PRIMARY}, {self.BRAND_SECONDARY});
457
+ color: #333;
458
+ padding: 4px 12px;
459
+ border-radius: 20px;
460
+ font-size: 12px;
461
+ font-weight: bold;
462
+ margin-top: 10px;
463
+ }}
464
+ .credits-box {{
465
+ background-color: #ecfdf5;
466
+ border: 2px solid {self.BRAND_SUCCESS};
467
+ border-radius: 12px;
468
+ padding: 24px;
469
+ text-align: center;
470
+ margin: 20px 0;
471
+ }}
472
+ .credits-number {{
473
+ font-size: 48px;
474
+ font-weight: bold;
475
+ color: {self.BRAND_SUCCESS};
476
+ }}
477
+ .reminder {{
478
+ background-color: #fef3c7;
479
+ border: 1px solid #fcd34d;
480
+ border-radius: 8px;
481
+ padding: 16px;
482
+ margin: 20px 0;
483
+ }}
484
+ """
485
+
486
+ content = f"""
487
+ <p>Thank you for joining our beta program!</p>
488
+
489
+ <p>As promised, here's your free credit to create a karaoke video:</p>
490
+
491
+ <div class="credits-box">
492
+ <div>Your balance:</div>
493
+ <div class="credits-number">{credits}</div>
494
+ <div>free credit{'' if credits == 1 else 's'}</div>
495
+ </div>
496
+
497
+ <div class="reminder">
498
+ <strong>📝 Your Promise:</strong><br>
499
+ Remember, as a beta tester you agreed to:
500
+ <ul style="margin: 10px 0; padding-left: 20px;">
501
+ <li>Review and correct any lyrics transcription errors</li>
502
+ <li>Share your honest feedback after trying the tool</li>
503
+ </ul>
504
+ We'll send you a quick feedback form after your job completes!
505
+ </div>
506
+
507
+ <p style="text-align: center;">
508
+ <a href="{self.frontend_url}" class="button">Create Your Karaoke Video</a>
509
+ </p>
510
+
511
+ <p>Thanks for helping us make Nomad Karaoke better!</p>
512
+ """
513
+
514
+ extra_header = '<div><span class="beta-badge">BETA TESTER</span></div>'
515
+ html_content = self._build_email_html(content, extra_styles, extra_header)
516
+
517
+ text_content = f"""
518
+ Welcome Beta Tester!
519
+
520
+ Thank you for joining our beta program!
521
+
522
+ You've been granted {credits} free credit{'' if credits == 1 else 's'} to create karaoke videos.
523
+
524
+ YOUR PROMISE:
525
+ As a beta tester, you agreed to:
526
+ - Review and correct any lyrics transcription errors
527
+ - Share your honest feedback after trying the tool
528
+
529
+ We'll send you a quick feedback form after your job completes!
530
+
531
+ Create your karaoke video: {self.frontend_url}
532
+
533
+ Thanks for helping us make Nomad Karaoke better!
534
+
535
+ ---
536
+ © {self._get_year()} Nomad Karaoke
537
+ """
538
+
539
+ return self.provider.send_email(email, subject, html_content, text_content)
540
+
541
+ def send_feedback_request_email(self, email: str, feedback_url: str, job_title: Optional[str] = None) -> bool:
542
+ """
543
+ Send feedback request email to beta testers.
544
+
545
+ Args:
546
+ email: User's email address
547
+ feedback_url: URL to the feedback form
548
+ job_title: Optional title of the completed job
549
+
550
+ Returns:
551
+ True if email was sent successfully
552
+ """
553
+ subject = "Quick feedback on your karaoke experience? 🎤"
554
+
555
+ # Escape job_title to prevent XSS in email clients that render HTML
556
+ safe_job_title = html.escape(job_title) if job_title else ""
557
+ job_context = f" for <strong>{safe_job_title}</strong>" if job_title else ""
558
+
559
+ extra_styles = f"""
560
+ .feedback-box {{
561
+ background: linear-gradient(135deg, #fff0f9, #ffe0f2);
562
+ border: 2px solid {self.BRAND_PRIMARY};
563
+ border-radius: 12px;
564
+ padding: 24px;
565
+ text-align: center;
566
+ margin: 20px 0;
567
+ }}
568
+ .stars {{
569
+ font-size: 36px;
570
+ margin: 10px 0;
571
+ }}
572
+ .time-note {{
573
+ background-color: #fef3c7;
574
+ border-radius: 8px;
575
+ padding: 12px;
576
+ margin: 20px 0;
577
+ font-size: 14px;
578
+ }}
579
+ """
580
+
581
+ content = f"""
582
+ <p>Hi there!</p>
583
+
584
+ <p>Hope you enjoyed creating your karaoke video{job_context}! As a beta tester, your feedback is super valuable to us.</p>
585
+
586
+ <div class="feedback-box">
587
+ <div class="stars">⭐⭐⭐⭐⭐</div>
588
+ <p><strong>How was your experience?</strong></p>
589
+ <p>Quick 2-minute survey - we'd love to hear your thoughts!</p>
590
+ </div>
591
+
592
+ <p style="text-align: center;">
593
+ <a href="{feedback_url}" class="button">Share Your Feedback</a>
594
+ </p>
595
+
596
+ <div class="time-note">
597
+ ⏱️ This takes less than 2 minutes and helps us improve the tool for everyone!
598
+ </div>
599
+
600
+ <p>Specifically, we'd love to know:</p>
601
+ <ul>
602
+ <li>How easy was it to use?</li>
603
+ <li>Were the lyrics accurate?</li>
604
+ <li>How was the correction experience?</li>
605
+ <li>What could we improve?</li>
606
+ </ul>
607
+
608
+ <p>Thanks for being part of making Nomad Karaoke better!</p>
609
+ """
610
+
611
+ html_content = self._build_email_html(content, extra_styles)
612
+
613
+ text_content = f"""
614
+ Quick feedback on your karaoke experience?
615
+
616
+ Hi there!
617
+
618
+ Hope you enjoyed creating your karaoke video{' for ' + safe_job_title if safe_job_title else ''}!
619
+
620
+ As a beta tester, your feedback is super valuable to us.
621
+
622
+ Share your feedback (2-minute survey): {feedback_url}
623
+
624
+ We'd love to know:
625
+ - How easy was it to use?
626
+ - Were the lyrics accurate?
627
+ - How was the correction experience?
628
+ - What could we improve?
629
+
630
+ Thanks for being part of making Nomad Karaoke better!
631
+
632
+ ---
633
+ © {self._get_year()} Nomad Karaoke
634
+ """
635
+
636
+ return self.provider.send_email(email, subject, html_content, text_content)
637
+
638
+ def _get_year(self) -> int:
639
+ """Get current year for copyright notices."""
640
+ from datetime import datetime
641
+ return datetime.now().year
642
+
643
+ def _get_email_header(self, extra_header_content: str = "") -> str:
644
+ """Get the standard email header with logo.
645
+
646
+ Args:
647
+ extra_header_content: Optional extra content to add after the logo (e.g., beta badge)
648
+ """
649
+ return f"""
650
+ <div class="header">
651
+ <a href="https://nomadkaraoke.com"><img src="{self.LOGO_URL}" alt="Nomad Karaoke" /></a>
652
+ {extra_header_content}
653
+ </div>
654
+ """
655
+
656
+ def _get_email_footer(self) -> str:
657
+ """Get the standard email footer with support message and signature."""
658
+ return f"""
659
+ <p style="margin-top: 30px; font-style: italic; color: #666;">If anything isn't perfect, just reply to this email and I'll fix it!</p>
660
+
661
+ <div class="signature">
662
+ {self._get_email_signature()}
663
+ </div>
664
+ """
665
+
666
+ def _get_base_styles(self) -> str:
667
+ """Get base CSS styles shared by all emails."""
668
+ return f"""
669
+ body {{
670
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
671
+ line-height: 1.6;
672
+ color: #333;
673
+ max-width: 600px;
674
+ margin: 0 auto;
675
+ padding: 20px;
676
+ }}
677
+ .header {{
678
+ text-align: center;
679
+ padding: 20px 0;
680
+ }}
681
+ .header img {{
682
+ max-width: 180px;
683
+ height: auto;
684
+ border-radius: 10px;
685
+ }}
686
+ .button {{
687
+ display: inline-block;
688
+ background-color: {self.BRAND_PRIMARY};
689
+ color: white;
690
+ padding: 14px 28px;
691
+ text-decoration: none;
692
+ border-radius: 8px;
693
+ font-weight: 600;
694
+ margin: 20px 0;
695
+ }}
696
+ .signature {{
697
+ margin-top: 30px;
698
+ padding-top: 20px;
699
+ }}
700
+ """
701
+
702
+ def _build_email_html(self, content: str, extra_styles: str = "", extra_header_content: str = "") -> str:
703
+ """Build a complete HTML email with standard header, footer, and styles.
704
+
705
+ Args:
706
+ content: The main body content of the email
707
+ extra_styles: Additional CSS styles specific to this email type
708
+ extra_header_content: Optional extra content to add in header (e.g., beta badge)
709
+
710
+ Returns:
711
+ Complete HTML email string
712
+ """
713
+ return f"""
714
+ <!DOCTYPE html>
715
+ <html>
716
+ <head>
717
+ <style>
718
+ {self._get_base_styles()}
719
+ {extra_styles}
720
+ </style>
721
+ </head>
722
+ <body>
723
+ {self._get_email_header(extra_header_content)}
724
+
725
+ {content}
726
+
727
+ {self._get_email_footer()}
728
+ </body>
729
+ </html>
730
+ """
731
+
732
+ def _get_email_signature(self) -> str:
733
+ """Get the HTML email signature for Nomad Karaoke."""
734
+ return """
735
+ <table cellpadding="0" cellspacing="0" border="0" style="font-size: medium; font-family: Trebuchet MS;">
736
+ <tbody>
737
+ <tr>
738
+ <td>
739
+ <table cellpadding="0" cellspacing="0" border="0" style="font-size: medium; font-family: Trebuchet MS;">
740
+ <tbody>
741
+ <tr>
742
+ <td style="vertical-align: top;">
743
+ <table cellpadding="0" cellspacing="0" border="0"
744
+ style="font-size: medium; font-family: Trebuchet MS;">
745
+ <tbody>
746
+ <tr>
747
+ <td style="text-align: center;"><a
748
+ href="https://www.linkedin.com/in/andrewbeveridge"
749
+ target="_blank"><img
750
+ src="https://beveradb.github.io/public-images/andrew-buildspace-circle-150px.png"
751
+ role="presentation" style="display: block; max-width: 128px;"
752
+ width="130"></a></td>
753
+ </tr>
754
+ <tr>
755
+ <td height="5"></td>
756
+ </tr>
757
+ <tr>
758
+ <td style="text-align: center;"><a href="https://nomadkaraoke.com"
759
+ target="_blank"><img role="presentation" width="130"
760
+ style="display: block; max-width: 130px; border-radius: 7px;"
761
+ src="https://beveradb.github.io/public-images/Nomad-Karaoke-Logo-small-indexed-websafe-rectangle.gif"></a>
762
+ </td>
763
+ </tr>
764
+ </tbody>
765
+ </table>
766
+ </td>
767
+ <td width="10">
768
+ <div></div>
769
+ </td>
770
+ <td style="padding: 0px; vertical-align: middle;">
771
+ <h2 color="#000000"
772
+ style="margin: 0px; font-size: 18px; color: rgb(0, 0, 0); font-weight: 600;">
773
+ <span>Andrew</span><span>&nbsp;</span><span>Beveridge</span>
774
+ </h2>
775
+ <p color="#000000" font-size="medium"
776
+ style="margin: 0px; color: rgb(0, 0, 0); font-size: 14px; line-height: 22px;">
777
+ <span>Founder</span>
778
+ </p>
779
+ <p color="#000000" font-size="medium"
780
+ style="margin: 0px; font-weight: 500; color: rgb(0, 0, 0); font-size: 14px; line-height: 22px;">
781
+ <span>Nomad Karaoke
782
+ LLC</span>
783
+ </p>
784
+ <table cellpadding="0" cellspacing="0" border="0"
785
+ style="width: 100%; font-size: medium; font-family: Trebuchet MS; margin-top: 3px; margin-bottom: 3px;">
786
+ <tbody>
787
+ <tr>
788
+ <td color="#ff7acc" direction="horizontal" width="auto" height="15"
789
+ style="width: 100%; display: block; line-height:0; font-size:0;">
790
+ </td>
791
+ </tr>
792
+ <tr>
793
+ <td color="#ff7acc" direction="horizontal" width="auto" height="1"
794
+ style="width: 100%; border-bottom: 1px solid rgb(255, 122, 204); border-left: medium; display: block; line-height:0; font-size:0;">
795
+ </td>
796
+ </tr>
797
+ </tbody>
798
+ </table>
799
+
800
+ <table cellpadding="0" cellspacing="0" border="0"
801
+ style="width: 100%; font-size: medium; font-family: Trebuchet MS; margin-top: 3px; margin-bottom: 3px;">
802
+ <tbody>
803
+ <tr>
804
+ <td style="text-align: center; line-height:0; font-size:0;">
805
+ <table cellpadding="0" cellspacing="0" border="0"
806
+ style="display: inline-block; font-size: medium; font-family: Trebuchet MS;">
807
+ <tbody>
808
+ <tr style="text-align: center;">
809
+ <td><a href="https://www.youtube.com/@nomadkaraoke"
810
+ color="#230a89"
811
+ style="display: inline-block; padding: 0px; background-color: rgb(35, 10, 137);"
812
+ target="_blank"><img
813
+ src="https://beveradb.github.io/public-images/youtube-icon-2x.png"
814
+ alt="youtube" color="#230a89" width="24"
815
+ style="background-color: rgb(35, 10, 137); max-width: 135px; display: block;"></a>
816
+ </td>
817
+ <td width="5">
818
+ <div></div>
819
+ </td>
820
+ <td><a href="https://github.com/nomadkaraoke"
821
+ color="#230a89"
822
+ style="display: inline-block; padding: 0px; background-color: rgb(35, 10, 137);"
823
+ target="_blank"><img
824
+ src="https://beveradb.github.io/public-images/github-icon-2x.png"
825
+ alt="github" color="#230a89" width="24"
826
+ style="background-color: rgb(35, 10, 137); max-width: 135px; display: block;"></a>
827
+ </td>
828
+ <td width="5">
829
+ <div></div>
830
+ </td>
831
+ <td><a href="https://www.linkedin.com/in/andrewbeveridge"
832
+ color="#230a89"
833
+ style="display: inline-block; padding: 0px; background-color: rgb(35, 10, 137);"
834
+ target="_blank"><img
835
+ src="https://beveradb.github.io/public-images/linkedin-icon-2x.png"
836
+ alt="linkedin" color="#230a89" width="24"
837
+ style="background-color: rgb(35, 10, 137); max-width: 135px; display: block;"></a>
838
+ </td>
839
+ <td width="5">
840
+ <div></div>
841
+ </td>
842
+ <td><a href="https://twitter.com/beveradb" color="#230a89"
843
+ style="display: inline-block; padding: 0px; background-color: rgb(35, 10, 137);"><img
844
+ src="https://beveradb.github.io/public-images/twitter-icon-2x.png"
845
+ alt="twitter" color="#230a89" width="24"
846
+ style="background-color: rgb(35, 10, 137); max-width: 135px; display: block;"></a>
847
+ </td>
848
+ <td width="5">
849
+ <div></div>
850
+ </td>
851
+ <td><a href="https://www.instagram.com/beveradb/"
852
+ color="#230a89"
853
+ style="display: inline-block; padding: 0px; background-color: rgb(35, 10, 137);"><img
854
+ src="https://beveradb.github.io/public-images/instagram-icon-2x.png"
855
+ alt="instagram" color="#230a89" width="24"
856
+ style="background-color: rgb(35, 10, 137); max-width: 135px; display: block;"></a>
857
+ </td>
858
+ <td width="5">
859
+ <div></div>
860
+ </td>
861
+ </tr>
862
+ </tbody>
863
+ </table>
864
+ </td>
865
+ </tr>
866
+ </tbody>
867
+ </table>
868
+ <table cellpadding="0" cellspacing="0" border="0"
869
+ style="width: 100%; font-size: medium; font-family: Trebuchet MS; margin-top: 3px; margin-bottom: 3px;">
870
+ <tbody>
871
+ <tr>
872
+ <td color="#ff7acc" direction="horizontal" width="auto" height="1"
873
+ style="width: 100%; border-bottom: 1px solid rgb(255, 122, 204); border-left: medium; display: block; line-height:0; font-size:0;">
874
+ </td>
875
+ </tr>
876
+ <tr>
877
+ <td color="#ff7acc" direction="horizontal" width="auto" height="15"
878
+ style="width: 100%; display: block;">
879
+ </td>
880
+ </tr>
881
+ </tbody>
882
+ </table>
883
+ <table cellpadding="0" cellspacing="0" border="0"
884
+ style="font-size: medium; font-family: Trebuchet MS;">
885
+ <tbody>
886
+ <tr style="vertical-align: middle;" height="20">
887
+ <td width="30" style="vertical-align: middle;">
888
+ <table cellpadding="0" cellspacing="0" border="0"
889
+ style="font-size: medium; font-family: Trebuchet MS;">
890
+ <tbody>
891
+ <tr>
892
+ <td style="vertical-align: bottom;"><span color="#ff7acc"
893
+ width="11"
894
+ style="display: inline-block; background-color: rgb(255, 122, 204);"><img
895
+ src="https://beveradb.github.io/public-images/phone-icon-2x.png"
896
+ color="#ff7acc" alt="mobilePhone" width="13"
897
+ style="display: block; background-color: rgb(255, 122, 204);"></span>
898
+ </td>
899
+ </tr>
900
+ </tbody>
901
+ </table>
902
+ </td>
903
+ <td style="padding: 0px; color: rgb(0, 0, 0);"><a href="tel:8036363267"
904
+ color="#000000"
905
+ style="text-decoration: none; color: rgb(0, 0, 0); font-size: 12px;"><span>+1 (803) 636-3267</span></a> | <a href="tel:07835171222"
906
+ color="#000000"
907
+ style="text-decoration: none; color: rgb(0, 0, 0); font-size: 12px;"><span>+44 07835171222</span></a></td>
908
+ </tr>
909
+ <tr style="vertical-align: middle;" height="20">
910
+ <td width="30" style="vertical-align: middle;">
911
+ <table cellpadding="0" cellspacing="0" border="0"
912
+ style="font-size: medium; font-family: Trebuchet MS;">
913
+ <tbody>
914
+ <tr>
915
+ <td style="vertical-align: bottom;"><span color="#ff7acc"
916
+ width="11"
917
+ style="display: inline-block; background-color: rgb(255, 122, 204);"><img
918
+ src="https://beveradb.github.io/public-images/email-icon-2x.png"
919
+ color="#ff7acc" alt="emailAddress" width="13"
920
+ style="display: block; background-color: rgb(255, 122, 204);"></span>
921
+ </td>
922
+ </tr>
923
+ </tbody>
924
+ </table>
925
+ </td>
926
+ <td style="padding: 0px;"><a href="mailto:andrew@nomadkaraoke.com"
927
+ color="#000000"
928
+ style="text-decoration: none; color: rgb(0, 0, 0); font-size: 12px;"><span>andrew@nomadkaraoke.com</span></a>
929
+ </td>
930
+ </tr>
931
+ <tr style="vertical-align: middle;" height="20">
932
+ <td width="30" style="vertical-align: middle;">
933
+ <table cellpadding="0" cellspacing="0" border="0"
934
+ style="font-size: medium; font-family: Trebuchet MS;">
935
+ <tbody>
936
+ <tr>
937
+ <td style="vertical-align: bottom;"><span color="#ff7acc"
938
+ width="11"
939
+ style="display: inline-block; background-color: rgb(255, 122, 204);"><img
940
+ src="https://beveradb.github.io/public-images/link-icon-2x.png"
941
+ color="#ff7acc" alt="website" width="13"
942
+ style="display: block; background-color: rgb(255, 122, 204);"></span>
943
+ </td>
944
+ </tr>
945
+ </tbody>
946
+ </table>
947
+ </td>
948
+ <td style="padding: 0px;"><a href="https://nomadkaraoke.com" target="_blank"
949
+ color="#000000"
950
+ style="text-decoration: none; color: rgb(0, 0, 0); font-size: 12px;"><span>nomadkaraoke.com</span></a>
951
+ </td>
952
+ </tr>
953
+ </tbody>
954
+ </table>
955
+ </td>
956
+ </tr>
957
+ </tbody>
958
+ </table>
959
+ </td>
960
+ </tr>
961
+ </tbody>
962
+ </table>
963
+ """
964
+
965
+ def send_job_completion(
966
+ self,
967
+ to_email: str,
968
+ message_content: str,
969
+ artist: Optional[str] = None,
970
+ title: Optional[str] = None,
971
+ brand_code: Optional[str] = None,
972
+ cc_admin: bool = True,
973
+ ) -> bool:
974
+ """
975
+ Send job completion email with the rendered template content.
976
+
977
+ Args:
978
+ to_email: User's email address
979
+ message_content: Pre-rendered message content (plain text)
980
+ artist: Artist name for subject line
981
+ title: Song title for subject line
982
+ brand_code: Release ID (e.g., "NOMAD-1178") for subject line
983
+ cc_admin: Whether to CC gen@nomadkaraoke.com
984
+
985
+ Returns:
986
+ True if email was sent successfully
987
+ """
988
+ # Build subject: "NOMAD-1178: Artist - Title (Your karaoke video is ready!)"
989
+ # Sanitize artist/title to handle Unicode characters (curly quotes, em dashes, etc.)
990
+ # that cause email header encoding issues (MIME headers use latin-1)
991
+ safe_artist = sanitize_filename(artist) if artist else None
992
+ safe_title = sanitize_filename(title) if title else None
993
+ if brand_code and safe_artist and safe_title:
994
+ subject = f"{brand_code}: {safe_artist} - {safe_title} (Your karaoke video is ready!)"
995
+ elif safe_artist and safe_title:
996
+ subject = f"{safe_artist} - {safe_title} (Your karaoke video is ready!)"
997
+ else:
998
+ subject = "Your karaoke video is ready!"
999
+
1000
+ extra_styles = """
1001
+ .content {
1002
+ white-space: pre-wrap;
1003
+ }
1004
+ """
1005
+
1006
+ content = f"""
1007
+ <div class="content">{html.escape(message_content)}</div>
1008
+ """
1009
+
1010
+ html_content = self._build_email_html(content, extra_styles)
1011
+
1012
+ cc_emails = ["gen@nomadkaraoke.com"] if cc_admin else None
1013
+
1014
+ return self.provider.send_email(
1015
+ to_email=to_email,
1016
+ subject=subject,
1017
+ html_content=html_content,
1018
+ text_content=message_content,
1019
+ cc_emails=cc_emails,
1020
+ )
1021
+
1022
+ def send_action_reminder(
1023
+ self,
1024
+ to_email: str,
1025
+ message_content: str,
1026
+ action_type: str,
1027
+ artist: Optional[str] = None,
1028
+ title: Optional[str] = None,
1029
+ ) -> bool:
1030
+ """
1031
+ Send action-needed reminder email.
1032
+
1033
+ Args:
1034
+ to_email: User's email address
1035
+ message_content: Pre-rendered message content (plain text)
1036
+ action_type: Type of action needed ("lyrics" or "instrumental")
1037
+ artist: Artist name for subject line
1038
+ title: Song title for subject line
1039
+
1040
+ Returns:
1041
+ True if email was sent successfully
1042
+ """
1043
+ # Build subject based on action type
1044
+ song_info = f" for {artist} - {title}" if artist and title else ""
1045
+ if action_type == "lyrics":
1046
+ subject = f"Action needed: Review lyrics{song_info}"
1047
+ elif action_type == "instrumental":
1048
+ subject = f"Action needed: Select instrumental{song_info}"
1049
+ else:
1050
+ subject = f"Action needed{song_info}"
1051
+
1052
+ extra_styles = """
1053
+ .alert {
1054
+ background-color: #fef3c7;
1055
+ border: 1px solid #fcd34d;
1056
+ border-radius: 8px;
1057
+ padding: 16px;
1058
+ margin: 20px 0;
1059
+ text-align: center;
1060
+ }
1061
+ .content {
1062
+ white-space: pre-wrap;
1063
+ }
1064
+ """
1065
+
1066
+ content = f"""
1067
+ <div class="alert">
1068
+ ⏰ Your karaoke video needs input from you!
1069
+ </div>
1070
+
1071
+ <div class="content">{html.escape(message_content)}</div>
1072
+ """
1073
+
1074
+ html_content = self._build_email_html(content, extra_styles)
1075
+
1076
+ return self.provider.send_email(
1077
+ to_email=to_email,
1078
+ subject=subject,
1079
+ html_content=html_content,
1080
+ text_content=message_content,
1081
+ )
1082
+
1083
+
1084
+ # Global instance
1085
+ _email_service = None
1086
+
1087
+
1088
+ def get_email_service() -> EmailService:
1089
+ """Get the global email service instance."""
1090
+ global _email_service
1091
+ if _email_service is None:
1092
+ _email_service = EmailService()
1093
+ return _email_service