video-accessibility/backend/app/services/emailer.py
Vadym Samoilenko 5fd370c093 test: fix all unit tests — 168 passing, 0 failures
- 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>
2026-04-30 14:02:04 +01:00

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()