- Add 'language' column to waitlist_entries (en/it/es/fr/de, default en) - Accept 'lang' field in POST /waitlist request body - Translate confirmation email (subject, badge, heading, body, CTA, footer) - Translate confirm/unsubscribe result HTML pages - Return localized success message in WaitlistResponse - Update language preference on duplicate signups - Alembic migration 003_add_language_column
293 lines
12 KiB
Python
293 lines
12 KiB
Python
"""
|
||
Brevo (ex-Sendinblue) integration.
|
||
|
||
- Send transactional confirmation emails
|
||
- Sync confirmed contacts to a Brevo list
|
||
"""
|
||
|
||
import logging
|
||
|
||
import httpx
|
||
|
||
from app.config import settings
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
BREVO_API = "https://api.brevo.com/v3"
|
||
|
||
# ── Translations for transactional emails ──────────────────────────────
|
||
_EMAIL_I18N: dict[str, dict[str, str]] = {
|
||
"en": {
|
||
"subject": "Confirm your spot on the adiuvAI waitlist",
|
||
"preheader": "Confirm your email to secure your early access spot on the adiuvAI waitlist.",
|
||
"badge": "One more step",
|
||
"heading": "Confirm your spot on<br>the waitlist",
|
||
"body": "Thanks for signing up! Please confirm your email address so we can keep you in the loop when adiuvAI launches.",
|
||
"cta": "Confirm my email",
|
||
"expiry": "This link expires in {hours} hours.",
|
||
"footer": "If you didn't sign up, simply ignore this email.",
|
||
},
|
||
"it": {
|
||
"subject": "Conferma il tuo posto nella lista d'attesa di adiuvAI",
|
||
"preheader": "Conferma la tua email per assicurarti un accesso anticipato ad adiuvAI.",
|
||
"badge": "Ancora un passaggio",
|
||
"heading": "Conferma il tuo posto<br>nella lista d'attesa",
|
||
"body": "Grazie per esserti iscritto! Conferma il tuo indirizzo email così potremo aggiornarti quando adiuvAI sarà disponibile.",
|
||
"cta": "Conferma la mia email",
|
||
"expiry": "Questo link scade tra {hours} ore.",
|
||
"footer": "Se non ti sei iscritto, ignora semplicemente questa email.",
|
||
},
|
||
"es": {
|
||
"subject": "Confirma tu lugar en la lista de espera de adiuvAI",
|
||
"preheader": "Confirma tu correo para asegurar tu acceso anticipado a adiuvAI.",
|
||
"badge": "Un paso más",
|
||
"heading": "Confirma tu lugar en<br>la lista de espera",
|
||
"body": "¡Gracias por registrarte! Confirma tu dirección de correo para que podamos avisarte cuando adiuvAI esté listo.",
|
||
"cta": "Confirmar mi correo",
|
||
"expiry": "Este enlace caduca en {hours} horas.",
|
||
"footer": "Si no te registraste, simplemente ignora este correo.",
|
||
},
|
||
"fr": {
|
||
"subject": "Confirmez votre place sur la liste d'attente d'adiuvAI",
|
||
"preheader": "Confirmez votre e‑mail pour sécuriser votre accès anticipé à adiuvAI.",
|
||
"badge": "Encore une étape",
|
||
"heading": "Confirmez votre place sur<br>la liste d'attente",
|
||
"body": "Merci de vous être inscrit ! Veuillez confirmer votre adresse e‑mail pour que nous puissions vous tenir informé du lancement d'adiuvAI.",
|
||
"cta": "Confirmer mon e‑mail",
|
||
"expiry": "Ce lien expire dans {hours} heures.",
|
||
"footer": "Si vous ne vous êtes pas inscrit, ignorez simplement cet e‑mail.",
|
||
},
|
||
"de": {
|
||
"subject": "Bestätige deinen Platz auf der adiuvAI-Warteliste",
|
||
"preheader": "Bestätige deine E‑Mail, um dir den frühen Zugang zu adiuvAI zu sichern.",
|
||
"badge": "Noch ein Schritt",
|
||
"heading": "Bestätige deinen Platz<br>auf der Warteliste",
|
||
"body": "Danke für deine Anmeldung! Bitte bestätige deine E‑Mail-Adresse, damit wir dich informieren können, wenn adiuvAI startet.",
|
||
"cta": "Meine E‑Mail bestätigen",
|
||
"expiry": "Dieser Link läuft in {hours} Stunden ab.",
|
||
"footer": "Falls du dich nicht angemeldet hast, ignoriere diese E‑Mail einfach.",
|
||
},
|
||
}
|
||
|
||
|
||
def _t(lang: str, key: str) -> str:
|
||
"""Get translated string, falling back to English."""
|
||
return _EMAIL_I18N.get(lang, _EMAIL_I18N["en"]).get(key, _EMAIL_I18N["en"][key])
|
||
|
||
|
||
def _headers() -> dict[str, str]:
|
||
return {
|
||
"api-key": settings.BREVO_API_KEY,
|
||
"Content-Type": "application/json",
|
||
"Accept": "application/json",
|
||
}
|
||
|
||
|
||
async def send_confirmation_email(email: str, confirm_url: str, unsubscribe_url: str = "", lang: str = "en") -> bool:
|
||
"""Send a double opt-in confirmation email. Returns True on success."""
|
||
if not settings.brevo_configured:
|
||
logger.warning("Brevo not configured — skipping confirmation email for %s***", email[:3])
|
||
return False
|
||
|
||
payload = {
|
||
"sender": {
|
||
"name": settings.BREVO_SENDER_NAME,
|
||
"email": settings.BREVO_SENDER_EMAIL,
|
||
},
|
||
"to": [{"email": email}],
|
||
"subject": _t(lang, "subject"),
|
||
"htmlContent": _confirmation_html(confirm_url, unsubscribe_url, lang),
|
||
}
|
||
|
||
try:
|
||
async with httpx.AsyncClient(timeout=10) as client:
|
||
resp = await client.post(f"{BREVO_API}/smtp/email", headers=_headers(), json=payload)
|
||
resp.raise_for_status()
|
||
logger.info("Confirmation email sent to %s***", email[:3])
|
||
return True
|
||
except httpx.HTTPError:
|
||
logger.exception("Failed to send confirmation email to %s***", email[:3])
|
||
return False
|
||
|
||
|
||
async def add_contact_to_list(email: str) -> bool:
|
||
"""Add a confirmed contact to the Brevo waitlist list. Returns True on success."""
|
||
if not settings.brevo_configured:
|
||
logger.warning("Brevo not configured — skipping contact sync for %s***", email[:3])
|
||
return False
|
||
|
||
if settings.BREVO_LIST_ID == 0:
|
||
logger.warning("BREVO_LIST_ID not set — skipping contact sync")
|
||
return False
|
||
|
||
payload = {
|
||
"email": email,
|
||
"listIds": [settings.BREVO_LIST_ID],
|
||
"updateEnabled": True,
|
||
}
|
||
|
||
try:
|
||
async with httpx.AsyncClient(timeout=10) as client:
|
||
resp = await client.post(f"{BREVO_API}/contacts", headers=_headers(), json=payload)
|
||
resp.raise_for_status()
|
||
logger.info("Contact synced to Brevo list %d: %s***", settings.BREVO_LIST_ID, email[:3])
|
||
return True
|
||
except httpx.HTTPError:
|
||
logger.exception("Failed to sync contact to Brevo: %s***", email[:3])
|
||
return False
|
||
|
||
|
||
def _confirmation_html(confirm_url: str, unsubscribe_url: str = "", lang: str = "en") -> str:
|
||
"""Email template aligned with the adiuvAI landing page brand."""
|
||
html_lang = lang if lang in ("en", "it", "es", "fr", "de") else "en"
|
||
badge = _t(lang, "badge")
|
||
heading = _t(lang, "heading")
|
||
body = _t(lang, "body")
|
||
cta = _t(lang, "cta")
|
||
expiry = _t(lang, "expiry").format(hours=settings.CONFIRM_TOKEN_EXPIRY_HOURS)
|
||
preheader = _t(lang, "preheader")
|
||
footer = _t(lang, "footer")
|
||
unsub_label = {"en": "Unsubscribe", "it": "Annulla iscrizione", "es": "Cancelar suscripción", "fr": "Se désabonner", "de": "Abmelden"}.get(lang, "Unsubscribe")
|
||
return f"""\
|
||
<!DOCTYPE html>
|
||
<html lang="{html_lang}" xmlns="http://www.w3.org/1999/xhtml">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||
<meta name="color-scheme" content="light">
|
||
<meta name="supported-color-schemes" content="light">
|
||
<title>{_t(lang, "subject")} — adiuvAI</title>
|
||
<!--[if mso]><style>table,td{{font-family:Arial,sans-serif!important;}}</style><![endif]-->
|
||
</head>
|
||
<body style="margin:0;padding:0;width:100%;background-color:#f4edf3;
|
||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;
|
||
-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;">
|
||
|
||
<!-- Preheader (hidden text for inbox preview) -->
|
||
<div style="display:none;font-size:1px;color:#f4edf3;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;">
|
||
{preheader}
|
||
</div>
|
||
|
||
<!-- Outer wrapper -->
|
||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"
|
||
style="background-color:#f4edf3;">
|
||
<tr><td align="center" style="padding:48px 16px;">
|
||
|
||
<!-- Card -->
|
||
<table role="presentation" width="520" cellpadding="0" cellspacing="0" border="0"
|
||
style="max-width:520px;width:100%;background-color:#ffffff;
|
||
border-radius:20px;overflow:hidden;
|
||
box-shadow:0 4px 16px rgba(0,0,0,0.03),0 1px 2px rgba(0,0,0,0.02);">
|
||
|
||
<!-- Gold accent bar -->
|
||
<tr><td style="height:4px;background:linear-gradient(135deg,#fbc881 0%,#e5a94e 100%);
|
||
font-size:0;line-height:0;"> </td></tr>
|
||
|
||
<!-- Logo row -->
|
||
<tr><td align="center" style="padding:36px 40px 0;">
|
||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||
<tr>
|
||
<!-- Compass SVG (inline, matching the website nav-logo) -->
|
||
<td style="padding-right:8px;vertical-align:middle;">
|
||
<!--[if mso]>
|
||
<v:group style="width:28px;height:28px;" coordsize="64,64">
|
||
<v:shape style="width:64;height:64;" path="M32,4 L48,32 L16,32 Z" fillcolor="#fbc881" stroked="f"/>
|
||
<v:shape style="width:64;height:64;" path="M16,32 L48,32 L32,60 Z" fillcolor="#040404" stroked="f"/>
|
||
</v:group>
|
||
<![endif]-->
|
||
<!--[if !mso]><!-->
|
||
<svg viewBox="0 0 64 64" width="28" height="28" xmlns="http://www.w3.org/2000/svg"
|
||
style="display:block;width:28px;height:28px;">
|
||
<path d="M32,4 L48,32 L16,32 Z" fill="#fbc881"/>
|
||
<path d="M16,32 L48,32 L32,60 Z" fill="#040404"/>
|
||
<circle cx="32" cy="32" r="2.5" fill="#040404" opacity="0.18"/>
|
||
</svg>
|
||
<!--<![endif]-->
|
||
</td>
|
||
<td style="vertical-align:middle;font-size:18px;font-weight:400;
|
||
color:#040404;letter-spacing:-0.02em;">
|
||
adiuv<span style="font-weight:700;color:#e5a94e;">AI</span>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td></tr>
|
||
|
||
<!-- Body -->
|
||
<tr><td style="padding:32px 40px 0;">
|
||
|
||
<!-- Badge -->
|
||
<table role="presentation" cellpadding="0" cellspacing="0" border="0"
|
||
style="margin:0 auto 24px;">
|
||
<tr><td style="padding:5px 14px;border-radius:50px;
|
||
background-color:rgba(251,200,129,0.12);
|
||
border:1px solid rgba(251,200,129,0.2);
|
||
font-size:11px;font-weight:600;letter-spacing:0.06em;
|
||
text-transform:uppercase;color:#e5a94e;">
|
||
● {badge}
|
||
</td></tr>
|
||
</table>
|
||
|
||
<!-- Heading -->
|
||
<h1 style="margin:0 0 16px;font-size:24px;font-weight:600;
|
||
letter-spacing:-0.03em;line-height:1.2;color:#040404;text-align:center;">
|
||
{heading}
|
||
</h1>
|
||
|
||
<!-- Paragraph -->
|
||
<p style="margin:0 0 28px;font-size:15px;line-height:1.7;color:#8a8ea9;
|
||
text-align:center;">
|
||
{body}
|
||
</p>
|
||
|
||
<!-- CTA Button -->
|
||
<table role="presentation" cellpadding="0" cellspacing="0" border="0"
|
||
style="margin:0 auto;">
|
||
<tr><td align="center" style="border-radius:50px;background-color:#040404;">
|
||
<a href="{confirm_url}"
|
||
target="_blank"
|
||
style="display:inline-block;padding:14px 36px;
|
||
font-size:15px;font-weight:600;
|
||
color:#fbfbfb;text-decoration:none;
|
||
border-radius:50px;
|
||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||
{cta}
|
||
</a>
|
||
</td></tr>
|
||
</table>
|
||
|
||
<!-- Expiry note -->
|
||
<p style="margin:20px 0 0;font-size:13px;color:#c8c3cd;text-align:center;">
|
||
{expiry}
|
||
</p>
|
||
|
||
</td></tr>
|
||
|
||
<!-- Divider -->
|
||
<tr><td style="padding:32px 40px 0;">
|
||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||
<tr><td style="height:1px;background-color:#f4edf3;font-size:0;line-height:0;"> </td></tr>
|
||
</table>
|
||
</td></tr>
|
||
|
||
<!-- Footer -->
|
||
<tr><td style="padding:20px 40px 32px;text-align:center;">
|
||
<p style="margin:0;font-size:12px;color:#c8c3cd;line-height:1.5;">
|
||
{footer}
|
||
</p>
|
||
<p style="margin:8px 0 0;font-size:12px;color:#c8c3cd;">
|
||
<a href="https://adiuvai.com" style="color:#8a8ea9;text-decoration:underline;
|
||
text-underline-offset:2px;">adiuvai.com</a>
|
||
{f' · <a href="{unsubscribe_url}" style="color:#8a8ea9;text-decoration:underline;text-underline-offset:2px;">{unsub_label}</a>' if unsubscribe_url else ''}
|
||
</p>
|
||
</td></tr>
|
||
|
||
</table>
|
||
<!-- /Card -->
|
||
|
||
</td></tr>
|
||
</table>
|
||
|
||
</body>
|
||
</html>"""
|