From b7ba18641bed41612f55ba63a6ca2b163cfb1772 Mon Sep 17 00:00:00 2001 From: Roberto Musso Date: Sun, 12 Apr 2026 10:06:35 +0200 Subject: [PATCH] feat(i18n): add multilanguage support to waitlist emails and result pages - 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 --- alembic/versions/003_add_language_column.py | 23 +++++ app/brevo.py | 97 +++++++++++++++--- app/models.py | 1 + app/routes.py | 104 ++++++++++++++++---- app/schemas.py | 18 ++++ 5 files changed, 210 insertions(+), 33 deletions(-) create mode 100644 alembic/versions/003_add_language_column.py diff --git a/alembic/versions/003_add_language_column.py b/alembic/versions/003_add_language_column.py new file mode 100644 index 0000000..5ec9329 --- /dev/null +++ b/alembic/versions/003_add_language_column.py @@ -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") diff --git a/app/brevo.py b/app/brevo.py index 306e4b2..43b16a5 100644 --- a/app/brevo.py +++ b/app/brevo.py @@ -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
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
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
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
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
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 { @@ -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"""\ - + - Confirm your spot — adiuvAI + {_t(lang, "subject")} — adiuvAI str:
- Confirm your email to secure your early access spot on the adiuvAI waitlist. + {preheader}
@@ -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;"> - ● One more step + ● {badge}

- Confirm your spot on
the waitlist + {heading}

- Thanks for signing up! Please confirm your email address - so we can keep you in the loop when adiuvAI launches. + {body}

@@ -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}

- This link expires in {settings.CONFIRM_TOKEN_EXPIRY_HOURS} hours. + {expiry}

@@ -206,12 +273,12 @@ def _confirmation_html(confirm_url: str, unsubscribe_url: str = "") -> str:

- If you didn't sign up, simply ignore this email. + {footer}

adiuvai.com - {f' · Unsubscribe' if unsubscribe_url else ''} + {f' · {unsub_label}' if unsubscribe_url else ''}

diff --git a/app/models.py b/app/models.py index 1fa71f4..2948778 100644 --- a/app/models.py +++ b/app/models.py @@ -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, diff --git a/app/routes.py b/app/routes.py index 3c98226..cb99532 100644 --- a/app/routes.py +++ b/app/routes.py @@ -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’re confirmed!", + "confirmed_msg": "Your email has been verified. We’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 e‑mail 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'e‑mails 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 E‑Mail 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 E‑Mails 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 = '' 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 = '' elif success: - title = "You’re confirmed!" - message = "Your email has been verified. We’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 = '' 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 = '' + btn_label = t["btn"] + return f"""\ - + @@ -264,7 +332,7 @@ def _result_page(*, success: bool, variant: str = "confirm") -> str:
{icon_svg}

{title}

{message}

- Go to adiuvai.com + {btn_label} """ diff --git a/app/schemas.py b/app/schemas.py index fedc8a5..041a18c 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -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"]))