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>
222 lines
8.5 KiB
Python
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()
|