video-accessibility/backend/app/services/emailer.py
Vadym Samoilenko 1563714454 feat(saas): Phase 3 — membership-based authz + Mailgun + job.organization_id
authz.py (new):
- MembershipContext — per-request membership dict for the current user
- get_membership_context FastAPI dependency
- require_org_role(min_role) — dependency factory keyed off org_id path param
- require_platform_admin()
- OrgScopedQuery — adds organization_id filter; platform admin passes through
- bump_user_membership_cache — invalidates Redis key on membership writes

dependencies.py:
- get_accessible_project_ids now queries memberships collection first;
  legacy pm_client_ids / team.member_user_ids fallback retained until migration runs
  (four job-route access checks at lines 608/1054/1181/1538 are fixed via this function)

routes_clients.py:
- _assert_pm_or_admin and _assert_client_access are now async and query memberships
- All 10 call sites updated with await + db arg

emailer.py:
- Switched from SendGrid to Mailgun REST API via httpx (already in requirements)
- _send() is now fully async; same public method signatures preserved
- send_completion_email uses _send()

config.py:
- Added mailgun_api_key, mailgun_domain, mailgun_from settings
- sendgrid_api_key kept with empty default for backward compat

migration_2026-04-28-000003:
- Backfills job.organization_id from project.client_id
- Creates (organization_id, status, created_at) sparse index on jobs

routes_organizations.py / routes_invitations.py:
- Call bump_user_membership_cache after every membership write

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 16:56:42 +01:00

222 lines
8.5 KiB
Python

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_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=job_title,
download_links=download_links
)
# Global service instance
email_service = EmailService()