feat(i18n): add multilanguage support to waitlist emails and result pages
Some checks failed
Test & Deploy Waitlist / test (push) Successful in 34s
Test & Deploy Waitlist / deploy (push) Failing after 15s

- 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
This commit is contained in:
Roberto Musso
2026-04-12 10:06:35 +02:00
parent d32fc7ae30
commit b7ba18641b
5 changed files with 210 additions and 33 deletions

View File

@@ -0,0 +1,23 @@
"""add language column to waitlist_entries
Revision ID: 003
Revises: 002
Create Date: 2026-04-12
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "003"
down_revision: Union[str, None] = "002"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("waitlist_entries", sa.Column("language", sa.String(5), nullable=False, server_default="en"))
def downgrade() -> None:
op.drop_column("waitlist_entries", "language")

View File

@@ -15,6 +15,65 @@ 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 email 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 email pour que nous puissions vous tenir informé du lancement d'adiuvAI.",
"cta": "Confirmer mon email",
"expiry": "Ce lien expire dans {hours} heures.",
"footer": "Si vous ne vous êtes pas inscrit, ignorez simplement cet email.",
},
"de": {
"subject": "Bestätige deinen Platz auf der adiuvAI-Warteliste",
"preheader": "Bestätige deine EMail, 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 EMail-Adresse, damit wir dich informieren können, wenn adiuvAI startet.",
"cta": "Meine EMail bestätigen",
"expiry": "Dieser Link läuft in {hours} Stunden ab.",
"footer": "Falls du dich nicht angemeldet hast, ignoriere diese EMail 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 {
@@ -24,7 +83,7 @@ def _headers() -> dict[str, str]:
}
async def send_confirmation_email(email: str, confirm_url: str, unsubscribe_url: str = "") -> bool:
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])
@@ -36,8 +95,8 @@ async def send_confirmation_email(email: str, confirm_url: str, unsubscribe_url:
"email": settings.BREVO_SENDER_EMAIL,
},
"to": [{"email": email}],
"subject": "Confirm your spot on the adiuvAI waitlist",
"htmlContent": _confirmation_html(confirm_url, unsubscribe_url),
"subject": _t(lang, "subject"),
"htmlContent": _confirmation_html(confirm_url, unsubscribe_url, lang),
}
try:
@@ -78,18 +137,27 @@ async def add_contact_to_list(email: str) -> bool:
return False
def _confirmation_html(confirm_url: str, unsubscribe_url: str = "") -> str:
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="en" xmlns="http://www.w3.org/1999/xhtml">
<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>Confirm your spot — adiuvAI</title>
<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;
@@ -98,7 +166,7 @@ def _confirmation_html(confirm_url: str, unsubscribe_url: str = "") -> str:
<!-- 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;">
Confirm your email to secure your early access spot on the adiuvAI waitlist.
{preheader}
</div>
<!-- Outer wrapper -->
@@ -156,21 +224,20 @@ def _confirmation_html(confirm_url: str, unsubscribe_url: str = "") -> str:
border:1px solid rgba(251,200,129,0.2);
font-size:11px;font-weight:600;letter-spacing:0.06em;
text-transform:uppercase;color:#e5a94e;">
&#9679;&ensp;One more step
&#9679;&ensp;{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;">
Confirm your spot on<br>the waitlist
{heading}
</h1>
<!-- Paragraph -->
<p style="margin:0 0 28px;font-size:15px;line-height:1.7;color:#8a8ea9;
text-align:center;">
Thanks for signing up! Please confirm your email address
so we can keep you in the loop when adiuvAI launches.
{body}
</p>
<!-- CTA Button -->
@@ -184,14 +251,14 @@ def _confirmation_html(confirm_url: str, unsubscribe_url: str = "") -> str:
color:#fbfbfb;text-decoration:none;
border-radius:50px;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
Confirm my email
{cta}
</a>
</td></tr>
</table>
<!-- Expiry note -->
<p style="margin:20px 0 0;font-size:13px;color:#c8c3cd;text-align:center;">
This link expires in {settings.CONFIRM_TOKEN_EXPIRY_HOURS} hours.
{expiry}
</p>
</td></tr>
@@ -206,12 +273,12 @@ def _confirmation_html(confirm_url: str, unsubscribe_url: str = "") -> str:
<!-- Footer -->
<tr><td style="padding:20px 40px 32px;text-align:center;">
<p style="margin:0;font-size:12px;color:#c8c3cd;line-height:1.5;">
If you didn't sign up, simply ignore this email.
{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'&ensp;·&ensp;<a href="{unsubscribe_url}" style="color:#8a8ea9;text-decoration:underline;text-underline-offset:2px;">Unsubscribe</a>' if unsubscribe_url else ''}
{f'&ensp;·&ensp;<a href="{unsubscribe_url}" style="color:#8a8ea9;text-decoration:underline;text-underline-offset:2px;">{unsub_label}</a>' if unsubscribe_url else ''}
</p>
</td></tr>

View File

@@ -20,6 +20,7 @@ class WaitlistEntry(Base):
email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False, index=True)
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
source: Mapped[str | None] = mapped_column(String(64), nullable=True)
language: Mapped[str] = mapped_column(String(5), nullable=False, server_default=sa.text("'en'"))
confirmed: Mapped[bool] = mapped_column(Boolean, default=False, server_default=sa.text("0"))
consent_given_at: Mapped[datetime.datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True,

View File

@@ -36,9 +36,10 @@ async def join_waitlist(
"""
# Honeypot — bots fill hidden fields; silently "succeed"
if body.website:
return WaitlistResponse()
return WaitlistResponse.for_lang(body.lang)
email = body.email.lower().strip()
lang = body.lang
ip = _get_client_ip(request)
now = datetime.datetime.now(datetime.timezone.utc)
@@ -49,18 +50,23 @@ async def join_waitlist(
entry = existing.scalar_one_or_none()
if entry is not None:
# Update language preference if changed
if entry.language != lang:
entry.language = lang
await db.commit()
# Re-send confirmation if not yet confirmed (and not anonymized)
if not entry.confirmed and not entry.anonymized_at and settings.brevo_configured:
token = generate_token(email)
confirm_url = f"{settings.CONFIRM_BASE_URL}/api/v1/waitlist/confirm?token={token}"
unsub_url = f"{settings.CONFIRM_BASE_URL}/api/v1/waitlist/unsubscribe?token={token}"
asyncio.create_task(send_confirmation_email(email, confirm_url, unsub_url))
return WaitlistResponse()
asyncio.create_task(send_confirmation_email(email, confirm_url, unsub_url, lang=lang))
return WaitlistResponse.for_lang(lang)
entry = WaitlistEntry(
email=email,
ip_address=ip,
source="website",
language=lang,
consent_given_at=now,
)
db.add(entry)
@@ -73,9 +79,9 @@ async def join_waitlist(
token = generate_token(email)
confirm_url = f"{settings.CONFIRM_BASE_URL}/api/v1/waitlist/confirm?token={token}"
unsub_url = f"{settings.CONFIRM_BASE_URL}/api/v1/waitlist/unsubscribe?token={token}"
asyncio.create_task(send_confirmation_email(email, confirm_url, unsub_url))
asyncio.create_task(send_confirmation_email(email, confirm_url, unsub_url, lang=lang))
return WaitlistResponse()
return WaitlistResponse.for_lang(lang)
@router.get("/waitlist/confirm", response_class=HTMLResponse)
@@ -100,6 +106,8 @@ async def confirm_email(
if entry is None:
return HTMLResponse(content=_result_page(success=False), status_code=400)
lang = entry.language or "en"
if not entry.confirmed:
entry.confirmed = True
# Clear IP now that consent is confirmed — no longer needed
@@ -111,7 +119,7 @@ async def confirm_email(
if settings.brevo_configured:
asyncio.create_task(add_contact_to_list(email))
return HTMLResponse(content=_result_page(success=True))
return HTMLResponse(content=_result_page(success=True, lang=lang))
@router.get("/waitlist/unsubscribe", response_class=HTMLResponse)
@@ -138,11 +146,12 @@ async def unsubscribe(
# Already gone — show success anyway (idempotent)
return HTMLResponse(content=_result_page(success=True, variant="unsubscribe"))
lang = entry.language or "en"
_anonymize_entry(entry)
await db.commit()
logger.info("Waitlist entry anonymized (unsubscribe)")
return HTMLResponse(content=_result_page(success=True, variant="unsubscribe"))
return HTMLResponse(content=_result_page(success=True, variant="unsubscribe", lang=lang))
def _anonymize_entry(entry: WaitlistEntry) -> None:
@@ -154,37 +163,96 @@ def _anonymize_entry(entry: WaitlistEntry) -> None:
entry.anonymized_at = now
def _result_page(*, success: bool, variant: str = "confirm") -> str:
def _result_page(*, success: bool, variant: str = "confirm", lang: str = "en") -> str:
"""Branded HTML response for confirmation/unsubscribe result."""
# ── Translations for result pages ──
_PAGE_I18N: dict[str, dict[str, str]] = {
"en": {
"confirmed_title": "You&#8217;re confirmed!",
"confirmed_msg": "Your email has been verified. We&#8217;ll notify you when adiuvAI is ready.",
"invalid_confirm": "This confirmation link is no longer valid. Please sign up again.",
"removed_title": "Data removed",
"removed_msg": "Your personal data has been anonymized. You will not receive any further emails from us.",
"invalid_unsub": "This unsubscribe link is no longer valid. Contact privacy@adiuvai.com if you need help.",
"invalid_title": "Invalid or expired link",
"btn": "Go to adiuvai.com",
},
"it": {
"confirmed_title": "Sei confermato!",
"confirmed_msg": "La tua email è stata verificata. Ti avviseremo quando adiuvAI sarà pronto.",
"invalid_confirm": "Questo link di conferma non è più valido. Iscriviti di nuovo.",
"removed_title": "Dati rimossi",
"removed_msg": "I tuoi dati personali sono stati anonimizzati. Non riceverai più email da noi.",
"invalid_unsub": "Questo link di cancellazione non è più valido. Contatta privacy@adiuvai.com per assistenza.",
"invalid_title": "Link non valido o scaduto",
"btn": "Vai su adiuvai.com",
},
"es": {
"confirmed_title": "¡Estás confirmado!",
"confirmed_msg": "Tu correo ha sido verificado. Te avisaremos cuando adiuvAI esté listo.",
"invalid_confirm": "Este enlace de confirmación ya no es válido. Regístrate de nuevo.",
"removed_title": "Datos eliminados",
"removed_msg": "Tus datos personales han sido anonimizados. No recibirás más correos de nuestra parte.",
"invalid_unsub": "Este enlace de cancelación ya no es válido. Contacta privacy@adiuvai.com si necesitas ayuda.",
"invalid_title": "Enlace no válido o expirado",
"btn": "Ir a adiuvai.com",
},
"fr": {
"confirmed_title": "Vous êtes confirmé !",
"confirmed_msg": "Votre email a été vérifié. Nous vous préviendrons quand adiuvAI sera prêt.",
"invalid_confirm": "Ce lien de confirmation n'est plus valide. Veuillez vous inscrire à nouveau.",
"removed_title": "Données supprimées",
"removed_msg": "Vos données personnelles ont été anonymisées. Vous ne recevrez plus d'emails de notre part.",
"invalid_unsub": "Ce lien de désinscription n'est plus valide. Contactez privacy@adiuvai.com pour obtenir de l'aide.",
"invalid_title": "Lien non valide ou expiré",
"btn": "Aller sur adiuvai.com",
},
"de": {
"confirmed_title": "Du bist bestätigt!",
"confirmed_msg": "Deine EMail wurde verifiziert. Wir benachrichtigen dich, wenn adiuvAI bereit ist.",
"invalid_confirm": "Dieser Bestätigungslink ist nicht mehr gültig. Bitte melde dich erneut an.",
"removed_title": "Daten entfernt",
"removed_msg": "Deine persönlichen Daten wurden anonymisiert. Du wirst keine weiteren EMails von uns erhalten.",
"invalid_unsub": "Dieser Abmeldelink ist nicht mehr gültig. Kontaktiere privacy@adiuvai.com für Hilfe.",
"invalid_title": "Ungültiger oder abgelaufener Link",
"btn": "Zu adiuvai.com",
},
}
t = _PAGE_I18N.get(lang, _PAGE_I18N["en"])
html_lang = lang if lang in _PAGE_I18N else "en"
if variant == "unsubscribe":
if success:
title = "Data removed"
message = "Your personal data has been anonymized. You will not receive any further emails from us."
title = t["removed_title"]
message = t["removed_msg"]
icon_bg = "rgba(251,200,129,0.12)"
icon_border = "rgba(251,200,129,0.25)"
icon_svg = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#e5a94e" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'
else:
title = "Invalid or expired link"
message = "This unsubscribe link is no longer valid. Contact privacy@adiuvai.com if you need help."
title = t["invalid_title"]
message = t["invalid_unsub"]
icon_bg = "rgba(220,38,38,0.08)"
icon_border = "rgba(220,38,38,0.18)"
icon_svg = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#dc2626" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>'
elif success:
title = "You&#8217;re confirmed!"
message = "Your email has been verified. We&#8217;ll notify you when adiuvAI is ready."
title = t["confirmed_title"]
message = t["confirmed_msg"]
icon_bg = "rgba(251,200,129,0.12)"
icon_border = "rgba(251,200,129,0.25)"
icon_svg = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#e5a94e" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'
else:
title = "Invalid or expired link"
message = "This confirmation link is no longer valid. Please sign up again."
title = t["invalid_title"]
message = t["invalid_confirm"]
icon_bg = "rgba(220,38,38,0.08)"
icon_border = "rgba(220,38,38,0.18)"
icon_svg = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#dc2626" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>'
btn_label = t["btn"]
return f"""\
<!DOCTYPE html>
<html lang="en">
<html lang="{html_lang}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
@@ -264,7 +332,7 @@ def _result_page(*, success: bool, variant: str = "confirm") -> str:
<div class="icon">{icon_svg}</div>
<h1>{title}</h1>
<p>{message}</p>
<a href="https://adiuvai.com" class="btn">Go to adiuvai.com</a>
<a href="https://adiuvai.com" class="btn">{btn_label}</a>
</div>
</body>
</html>"""

View File

@@ -1,12 +1,30 @@
from typing import Literal
from pydantic import BaseModel, EmailStr, Field
SUPPORTED_LANGS = ("en", "it", "es", "fr", "de")
class WaitlistRequest(BaseModel):
email: EmailStr
lang: Literal["en", "it", "es", "fr", "de"] = "en"
# Honeypot field — must be empty. Bots tend to fill hidden fields.
website: str = Field(default="", max_length=0)
_RESPONSE_MESSAGES: dict[str, str] = {
"en": "Check your inbox for a confirmation link!",
"it": "Controlla la tua casella email per il link di conferma!",
"es": "¡Revisa tu bandeja de entrada para el enlace de confirmación!",
"fr": "Vérifiez votre boîte de réception pour le lien de confirmation !",
"de": "Überprüfe deinen Posteingang für den Bestätigungslink!",
}
class WaitlistResponse(BaseModel):
ok: bool = True
message: str = "Check your inbox for a confirmation link!"
@classmethod
def for_lang(cls, lang: str = "en") -> "WaitlistResponse":
return cls(message=_RESPONSE_MESSAGES.get(lang, _RESPONSE_MESSAGES["en"]))