- conftest.py: set required env vars before app import to prevent Settings() crash
- gcs.py: lazy bucket init checks _bucket instead of _client; add @bucket.setter
- vtt.py: fix float precision in _format_timestamp; include empty-text cues in parser
- security.py: guard verify_password against empty hash (passlib UnknownHashError)
- tts.py: _parse_timestamp raises ValueError("Invalid timestamp format: …")
- emailer.py: HTML-escape job_title in _render_completion_template (XSS fix)
- test_emailer.py: rewrite for Mailgun-based service (replaced SendGrid)
- test_gcs.py: fix UploadFile constructor, MIME type, remove executor.submit mock
- test_gemini.py: patch module-level client instead of non-existent genai.upload_file;
translate_vtt tests use numbered-list mock responses matching new implementation
- test_tts.py: fix aiohttp async CM mock pattern; fix error message match
- test_models.py: update JobCreate to use source_is_english instead of language
- test_security.py: set jwt_access_ttl_min in token test
- test_cross_tenant_isolation.py: add patch to imports
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
396 lines
16 KiB
Python
396 lines
16 KiB
Python
|
|
import html as _html
|
|
from datetime import datetime
|
|
|
|
from jinja2 import Template
|
|
|
|
from ..core.config import settings
|
|
from ..core.logging import get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
class EmailService:
|
|
"""Sends email via Mailgun REST API (httpx, async-safe)."""
|
|
|
|
@property
|
|
def _configured(self) -> bool:
|
|
return bool(settings.mailgun_api_key and settings.mailgun_domain)
|
|
|
|
async def _send(self, to_email: str, subject: str, html: str) -> bool:
|
|
if not self._configured:
|
|
logger.warning("Mailgun not configured — email skipped")
|
|
return False
|
|
try:
|
|
import httpx
|
|
url = f"https://api.mailgun.net/v3/{settings.mailgun_domain}/messages"
|
|
async with httpx.AsyncClient(timeout=15) as client:
|
|
response = await client.post(
|
|
url,
|
|
auth=("api", settings.mailgun_api_key),
|
|
data={
|
|
"from": f"Accessible Video Platform <{settings.mailgun_from}>",
|
|
"to": to_email,
|
|
"subject": subject,
|
|
"html": html,
|
|
},
|
|
)
|
|
if response.status_code in (200, 202):
|
|
logger.info(f"Email sent to {to_email}: {subject}")
|
|
return True
|
|
logger.error(f"Mailgun error {response.status_code}: {response.text}")
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"Email sending failed: {e}")
|
|
return False
|
|
|
|
async def send_invitation_email(
|
|
self,
|
|
to_email: str,
|
|
inviter_name: str,
|
|
org_name: str,
|
|
accept_url: str,
|
|
expires_at: datetime,
|
|
) -> bool:
|
|
html = Template("""
|
|
<!DOCTYPE html>
|
|
<html><head><meta charset="utf-8">
|
|
<style>
|
|
body{font-family:Arial,sans-serif;line-height:1.6;color:#333}
|
|
.container{max-width:600px;margin:0 auto;padding:20px}
|
|
.header{background:#4f46e5;color:#fff;padding:20px;text-align:center;border-radius:8px 8px 0 0}
|
|
.content{padding:24px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px}
|
|
.btn{display:inline-block;padding:12px 28px;background:#4f46e5;color:#fff;text-decoration:none;border-radius:6px;font-weight:bold;margin:16px 0}
|
|
.footer{text-align:center;padding:16px;color:#9ca3af;font-size:12px}
|
|
</style>
|
|
</head><body>
|
|
<div class="container">
|
|
<div class="header"><h1>You've been invited!</h1></div>
|
|
<div class="content">
|
|
<p>Hi there,</p>
|
|
<p><strong>{{ inviter_name }}</strong> has invited you to join <strong>{{ org_name }}</strong> on the Accessible Video Platform.</p>
|
|
<p><a href="{{ accept_url }}" class="btn">Accept Invitation</a></p>
|
|
<p>Or copy this link: <code>{{ accept_url }}</code></p>
|
|
<p style="color:#6b7280;font-size:13px">This invitation expires on {{ expires_at }}.</p>
|
|
</div>
|
|
<div class="footer">Accessible Video Platform — do not reply to this email</div>
|
|
</div>
|
|
</body></html>
|
|
""").render(
|
|
inviter_name=inviter_name,
|
|
org_name=org_name,
|
|
accept_url=accept_url,
|
|
expires_at=expires_at.strftime("%B %d, %Y"),
|
|
)
|
|
return await self._send(to_email, f"You're invited to join {org_name}", html)
|
|
|
|
async def send_welcome_email(
|
|
self,
|
|
to_email: str,
|
|
full_name: str,
|
|
org_name: str,
|
|
) -> bool:
|
|
html = Template("""
|
|
<!DOCTYPE html>
|
|
<html><head><meta charset="utf-8">
|
|
<style>
|
|
body{font-family:Arial,sans-serif;line-height:1.6;color:#333}
|
|
.container{max-width:600px;margin:0 auto;padding:20px}
|
|
.header{background:#4f46e5;color:#fff;padding:20px;text-align:center;border-radius:8px 8px 0 0}
|
|
.content{padding:24px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px}
|
|
.footer{text-align:center;padding:16px;color:#9ca3af;font-size:12px}
|
|
</style>
|
|
</head><body>
|
|
<div class="container">
|
|
<div class="header"><h1>Welcome to {{ org_name }}!</h1></div>
|
|
<div class="content">
|
|
<p>Hi {{ full_name }},</p>
|
|
<p>Your account has been set up and you now have access to <strong>{{ org_name }}</strong> on the Accessible Video Platform.</p>
|
|
<p>If you have any questions, reach out to your organization admin.</p>
|
|
</div>
|
|
<div class="footer">Accessible Video Platform</div>
|
|
</div>
|
|
</body></html>
|
|
""").render(full_name=full_name, org_name=org_name)
|
|
return await self._send(to_email, f"Welcome to {org_name}", html)
|
|
|
|
async def send_password_reset_email(
|
|
self,
|
|
to_email: str,
|
|
full_name: str,
|
|
reset_url: str,
|
|
) -> bool:
|
|
html = Template("""
|
|
<!DOCTYPE html>
|
|
<html><head><meta charset="utf-8">
|
|
<style>
|
|
body{font-family:Arial,sans-serif;line-height:1.6;color:#333}
|
|
.container{max-width:600px;margin:0 auto;padding:20px}
|
|
.header{background:#4f46e5;color:#fff;padding:20px;text-align:center;border-radius:8px 8px 0 0}
|
|
.content{padding:24px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px}
|
|
.btn{display:inline-block;padding:12px 28px;background:#4f46e5;color:#fff;text-decoration:none;border-radius:6px;font-weight:bold;margin:16px 0}
|
|
.footer{text-align:center;padding:16px;color:#9ca3af;font-size:12px}
|
|
</style>
|
|
</head><body>
|
|
<div class="container">
|
|
<div class="header"><h1>Reset your password</h1></div>
|
|
<div class="content">
|
|
<p>Hi {{ full_name }},</p>
|
|
<p>Click below to reset your password. This link is valid for 1 hour.</p>
|
|
<p><a href="{{ reset_url }}" class="btn">Reset Password</a></p>
|
|
<p>If you didn't request this, you can ignore this email.</p>
|
|
</div>
|
|
<div class="footer">Accessible Video Platform</div>
|
|
</div>
|
|
</body></html>
|
|
""").render(full_name=full_name, reset_url=reset_url)
|
|
return await self._send(to_email, "Reset your password", html)
|
|
|
|
async def send_language_assignment_email(
|
|
self,
|
|
to_email: str,
|
|
full_name: str,
|
|
job_title: str,
|
|
lang: str,
|
|
role: str,
|
|
deep_link: str,
|
|
) -> bool:
|
|
html = Template("""
|
|
<!DOCTYPE html><html><head><meta charset="utf-8">
|
|
<style>
|
|
body{font-family:Arial,sans-serif;line-height:1.6;color:#333}
|
|
.container{max-width:600px;margin:0 auto;padding:20px}
|
|
.header{background:#4f46e5;color:#fff;padding:20px;text-align:center;border-radius:8px 8px 0 0}
|
|
.content{padding:24px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px}
|
|
.btn{display:inline-block;padding:12px 28px;background:#4f46e5;color:#fff;text-decoration:none;border-radius:6px;font-weight:bold;margin:16px 0}
|
|
.footer{text-align:center;padding:16px;color:#9ca3af;font-size:12px}
|
|
</style>
|
|
</head><body>
|
|
<div class="container">
|
|
<div class="header"><h1>New QC assignment</h1></div>
|
|
<div class="content">
|
|
<p>Hi {{ full_name or 'there' }},</p>
|
|
<p>You have been assigned as <strong>{{ role }}</strong> for language <strong>{{ lang }}</strong> on job <strong>{{ job_title }}</strong>.</p>
|
|
<p><a href="{{ deep_link }}" class="btn">Open QC Review</a></p>
|
|
</div>
|
|
<div class="footer">Accessible Video Platform</div>
|
|
</div>
|
|
</body></html>
|
|
""").render(full_name=full_name, role=role, lang=lang, job_title=job_title, deep_link=deep_link)
|
|
subject = f"[{job_title}] You've been assigned as {role} for {lang}"
|
|
return await self._send(to_email, subject, html)
|
|
|
|
async def send_language_submitted_email(
|
|
self,
|
|
to_email: str,
|
|
full_name: str,
|
|
job_title: str,
|
|
lang: str,
|
|
linguist_name: str,
|
|
deep_link: str,
|
|
) -> bool:
|
|
html = Template("""
|
|
<!DOCTYPE html><html><head><meta charset="utf-8">
|
|
<style>
|
|
body{font-family:Arial,sans-serif;line-height:1.6;color:#333}
|
|
.container{max-width:600px;margin:0 auto;padding:20px}
|
|
.header{background:#0891b2;color:#fff;padding:20px;text-align:center;border-radius:8px 8px 0 0}
|
|
.content{padding:24px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px}
|
|
.btn{display:inline-block;padding:12px 28px;background:#0891b2;color:#fff;text-decoration:none;border-radius:6px;font-weight:bold;margin:16px 0}
|
|
.footer{text-align:center;padding:16px;color:#9ca3af;font-size:12px}
|
|
</style>
|
|
</head><body>
|
|
<div class="container">
|
|
<div class="header"><h1>Ready for your review</h1></div>
|
|
<div class="content">
|
|
<p>Hi {{ full_name or 'there' }},</p>
|
|
<p><strong>{{ linguist_name or 'The linguist' }}</strong> has submitted language <strong>{{ lang }}</strong> on job <strong>{{ job_title }}</strong> for your review.</p>
|
|
<p><a href="{{ deep_link }}" class="btn">Open Review</a></p>
|
|
</div>
|
|
<div class="footer">Accessible Video Platform</div>
|
|
</div>
|
|
</body></html>
|
|
""").render(full_name=full_name, linguist_name=linguist_name, lang=lang, job_title=job_title, deep_link=deep_link)
|
|
return await self._send(to_email, f"[{job_title}] {lang} ready for review", html)
|
|
|
|
async def send_qc_comment_email(
|
|
self,
|
|
to_email: str,
|
|
full_name: str,
|
|
job_title: str,
|
|
lang: str,
|
|
author_name: str,
|
|
comment_body: str,
|
|
deep_link: str,
|
|
) -> bool:
|
|
html = Template("""
|
|
<!DOCTYPE html><html><head><meta charset="utf-8">
|
|
<style>
|
|
body{font-family:Arial,sans-serif;line-height:1.6;color:#333}
|
|
.container{max-width:600px;margin:0 auto;padding:20px}
|
|
.header{background:#7c3aed;color:#fff;padding:20px;text-align:center;border-radius:8px 8px 0 0}
|
|
.content{padding:24px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px}
|
|
.comment{background:#f3f4f6;border-left:4px solid #7c3aed;padding:12px 16px;margin:12px 0;border-radius:0 6px 6px 0}
|
|
.btn{display:inline-block;padding:12px 28px;background:#7c3aed;color:#fff;text-decoration:none;border-radius:6px;font-weight:bold;margin:16px 0}
|
|
.footer{text-align:center;padding:16px;color:#9ca3af;font-size:12px}
|
|
</style>
|
|
</head><body>
|
|
<div class="container">
|
|
<div class="header"><h1>New comment</h1></div>
|
|
<div class="content">
|
|
<p>Hi {{ full_name or 'there' }},</p>
|
|
<p><strong>{{ author_name }}</strong> commented on <strong>{{ lang }}</strong> · <em>{{ job_title }}</em>:</p>
|
|
<div class="comment">{{ comment_body }}</div>
|
|
<p><a href="{{ deep_link }}" class="btn">View Comment</a></p>
|
|
</div>
|
|
<div class="footer">Accessible Video Platform</div>
|
|
</div>
|
|
</body></html>
|
|
""").render(full_name=full_name, author_name=author_name, lang=lang, job_title=job_title, comment_body=comment_body, deep_link=deep_link)
|
|
return await self._send(to_email, f"[{job_title}] New comment on {lang}", html)
|
|
|
|
async def send_qc_approved_email(
|
|
self,
|
|
to_email: str,
|
|
full_name: str,
|
|
job_title: str,
|
|
lang: str,
|
|
approver_name: str,
|
|
deep_link: str,
|
|
) -> bool:
|
|
html = Template("""
|
|
<!DOCTYPE html><html><head><meta charset="utf-8">
|
|
<style>
|
|
body{font-family:Arial,sans-serif;line-height:1.6;color:#333}
|
|
.container{max-width:600px;margin:0 auto;padding:20px}
|
|
.header{background:#16a34a;color:#fff;padding:20px;text-align:center;border-radius:8px 8px 0 0}
|
|
.content{padding:24px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px}
|
|
.btn{display:inline-block;padding:12px 28px;background:#16a34a;color:#fff;text-decoration:none;border-radius:6px;font-weight:bold;margin:16px 0}
|
|
.footer{text-align:center;padding:16px;color:#9ca3af;font-size:12px}
|
|
</style>
|
|
</head><body>
|
|
<div class="container">
|
|
<div class="header"><h1>Language approved ✓</h1></div>
|
|
<div class="content">
|
|
<p>Hi {{ full_name or 'there' }},</p>
|
|
<p><strong>{{ lang }}</strong> has been <strong>approved</strong> by {{ approver_name }} on job <strong>{{ job_title }}</strong>.</p>
|
|
<p><a href="{{ deep_link }}" class="btn">View Details</a></p>
|
|
</div>
|
|
<div class="footer">Accessible Video Platform</div>
|
|
</div>
|
|
</body></html>
|
|
""").render(full_name=full_name, lang=lang, approver_name=approver_name, job_title=job_title, deep_link=deep_link)
|
|
return await self._send(to_email, f"[{job_title}] {lang} approved", html)
|
|
|
|
async def send_qc_rejected_email(
|
|
self,
|
|
to_email: str,
|
|
full_name: str,
|
|
job_title: str,
|
|
lang: str,
|
|
reviewer_name: str,
|
|
reason: str,
|
|
deep_link: str,
|
|
) -> bool:
|
|
html = Template("""
|
|
<!DOCTYPE html><html><head><meta charset="utf-8">
|
|
<style>
|
|
body{font-family:Arial,sans-serif;line-height:1.6;color:#333}
|
|
.container{max-width:600px;margin:0 auto;padding:20px}
|
|
.header{background:#dc2626;color:#fff;padding:20px;text-align:center;border-radius:8px 8px 0 0}
|
|
.content{padding:24px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px}
|
|
.reason{background:#fef2f2;border-left:4px solid #dc2626;padding:12px 16px;margin:12px 0;border-radius:0 6px 6px 0}
|
|
.btn{display:inline-block;padding:12px 28px;background:#dc2626;color:#fff;text-decoration:none;border-radius:6px;font-weight:bold;margin:16px 0}
|
|
.footer{text-align:center;padding:16px;color:#9ca3af;font-size:12px}
|
|
</style>
|
|
</head><body>
|
|
<div class="container">
|
|
<div class="header"><h1>Changes requested</h1></div>
|
|
<div class="content">
|
|
<p>Hi {{ full_name or 'there' }},</p>
|
|
<p><strong>{{ lang }}</strong> on job <strong>{{ job_title }}</strong> has been sent back for changes by {{ reviewer_name }}.</p>
|
|
<div class="reason"><strong>Feedback:</strong><br>{{ reason }}</div>
|
|
<p><a href="{{ deep_link }}" class="btn">Open and Revise</a></p>
|
|
</div>
|
|
<div class="footer">Accessible Video Platform</div>
|
|
</div>
|
|
</body></html>
|
|
""").render(full_name=full_name, lang=lang, reviewer_name=reviewer_name, reason=reason, job_title=job_title, deep_link=deep_link)
|
|
return await self._send(to_email, f"[{job_title}] {lang} rejected — needs changes", html)
|
|
|
|
async def send_completion_email(
|
|
self,
|
|
recipient_email: str,
|
|
job_title: str,
|
|
download_links: dict[str, dict[str, str]]
|
|
) -> bool:
|
|
html = self._render_completion_template(job_title=job_title, download_links=download_links)
|
|
return await self._send(recipient_email, f"Your accessible video assets are ready: {job_title}", html)
|
|
|
|
def _render_completion_template(
|
|
self,
|
|
job_title: str,
|
|
download_links: dict[str, dict[str, str]]
|
|
) -> str:
|
|
"""Render the completion email HTML template"""
|
|
template_str = """
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Your Accessible Video Assets Are Ready</title>
|
|
<style>
|
|
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
|
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
|
.header { background-color: #4f46e5; color: white; padding: 20px; text-align: center; }
|
|
.content { padding: 20px; }
|
|
.download-section { margin: 20px 0; padding: 15px; background-color: #f9fafb; border-radius: 8px; }
|
|
.download-link { display: inline-block; padding: 10px 20px; margin: 5px; background-color: #4f46e5; color: white; text-decoration: none; border-radius: 5px; }
|
|
.footer { text-align: center; padding: 20px; color: #6b7280; font-size: 12px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<h1>Your Accessible Video Assets Are Ready!</h1>
|
|
</div>
|
|
|
|
<div class="content">
|
|
<h2>{{ job_title }}</h2>
|
|
|
|
<p>Great news! Your video accessibility assets have been processed and are ready for download.</p>
|
|
|
|
{% for language, files in download_links.items() %}
|
|
<div class="download-section">
|
|
<h3>{{ language.upper() }} Assets</h3>
|
|
{% for file_type, url in files.items() %}
|
|
<a href="{{ url }}" class="download-link">
|
|
Download {{ file_type|replace('_', ' ')|title }}
|
|
</a>
|
|
{% endfor %}
|
|
</div>
|
|
{% endfor %}
|
|
|
|
<p><strong>Important:</strong> These download links will expire in 24 hours for security purposes.</p>
|
|
|
|
<p>If you need assistance or have questions about your accessible video assets, please don't hesitate to contact our support team.</p>
|
|
</div>
|
|
|
|
<div class="footer">
|
|
<p>This email was sent by the Accessible Video Platform</p>
|
|
<p>Links expire in 24 hours for security</p>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
template = Template(template_str)
|
|
return template.render(
|
|
job_title=_html.escape(job_title),
|
|
download_links=download_links
|
|
)
|
|
|
|
|
|
# Global service instance
|
|
email_service = EmailService()
|