Files
waitlist/app/routes.py
Roberto Musso b7ba18641b
Some checks failed
Test & Deploy Waitlist / test (push) Successful in 34s
Test & Deploy Waitlist / deploy (push) Failing after 15s
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
2026-04-12 10:06:35 +02:00

339 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import asyncio
import datetime
import logging
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.brevo import add_contact_to_list, send_confirmation_email
from app.config import settings
from app.db import get_db
from app.rate_limit import _get_client_ip
from app.schemas import WaitlistRequest, WaitlistResponse
from app.models import WaitlistEntry
from app.token import generate_token, verify_token
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/waitlist", response_model=WaitlistResponse)
async def join_waitlist(
body: WaitlistRequest,
request: Request,
db: AsyncSession = Depends(get_db),
) -> WaitlistResponse:
"""
Add an email to the waitlist.
- Honeypot: if `website` field is non-empty, silently succeed (bot trap).
- Duplicate emails: idempotent — returns success without error.
- Stores the Cloudflare-resolved client IP for analytics (not exposed).
- Sends a confirmation email via Brevo (fire-and-forget).
- Records consent timestamp (GDPR Art. 7).
"""
# Honeypot — bots fill hidden fields; silently "succeed"
if body.website:
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)
# Check for existing entry — idempotent
existing = await db.execute(
select(WaitlistEntry).where(WaitlistEntry.email == email)
)
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, 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)
await db.commit()
logger.info("New waitlist signup: %s***", email[:3])
# Fire-and-forget: send confirmation email via Brevo
if 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, lang=lang))
return WaitlistResponse.for_lang(lang)
@router.get("/waitlist/confirm", response_class=HTMLResponse)
async def confirm_email(
token: str,
db: AsyncSession = Depends(get_db),
) -> HTMLResponse:
"""
Double opt-in confirmation endpoint.
Verifies the HMAC token, marks the entry as confirmed,
and syncs the contact to Brevo.
"""
email = verify_token(token)
if email is None:
return HTMLResponse(content=_result_page(success=False), status_code=400)
result = await db.execute(
select(WaitlistEntry).where(WaitlistEntry.email == email)
)
entry = result.scalar_one_or_none()
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
entry.ip_address = None
await db.commit()
logger.info("Email confirmed: %s***", email[:3])
# Sync confirmed contact to Brevo list (fire-and-forget)
if settings.brevo_configured:
asyncio.create_task(add_contact_to_list(email))
return HTMLResponse(content=_result_page(success=True, lang=lang))
@router.get("/waitlist/unsubscribe", response_class=HTMLResponse)
async def unsubscribe(
token: str,
db: AsyncSession = Depends(get_db),
) -> HTMLResponse:
"""
GDPR erasure (Art. 17) — anonymize the entry.
Uses the same HMAC token system as confirmation.
"""
email = verify_token(token)
if email is None:
return HTMLResponse(
content=_result_page(success=False, variant="unsubscribe"),
status_code=400,
)
result = await db.execute(
select(WaitlistEntry).where(WaitlistEntry.email == email)
)
entry = result.scalar_one_or_none()
if entry is None:
# 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", lang=lang))
def _anonymize_entry(entry: WaitlistEntry) -> None:
"""Strip all PII from a waitlist entry, keeping only anonymous analytics."""
now = datetime.datetime.now(datetime.timezone.utc)
entry.email = f"anon-{entry.id}@removed.invalid"
entry.ip_address = None
entry.consent_given_at = None
entry.anonymized_at = now
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 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 = 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 = 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 = 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 = 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="{html_lang}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{title} — adiuvAI</title>
<style>
body {{
margin: 0; padding: 0; min-height: 100vh;
display: flex; align-items: center; justify-content: center;
background: #f4edf3;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
}}
body::before {{
content: '';
position: fixed; inset: 0;
background:
radial-gradient(ellipse 80% 60% at 25% 20%, rgba(251,200,129,0.10) 0%, transparent 60%),
radial-gradient(ellipse 70% 50% at 75% 70%, rgba(138,142,169,0.08) 0%, transparent 50%);
pointer-events: none; z-index: 0;
}}
.card {{
position: relative; z-index: 1;
text-align: center; max-width: 420px; width: 100%;
margin: 24px;
padding: 48px 40px 40px;
background: rgba(255,255,255,0.55);
backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255,255,255,0.65);
border-radius: 20px;
box-shadow: 0 4px 16px rgba(0,0,0,0.03), 0 1px 2px rgba(0,0,0,0.02);
}}
.logo {{
display: inline-flex; align-items: center; gap: 8px;
font-size: 18px; font-weight: 400; color: #040404;
letter-spacing: -0.02em; margin-bottom: 28px;
}}
.logo .ai {{ font-weight: 700; color: #e5a94e; }}
.icon {{
width: 56px; height: 56px; border-radius: 50%;
background: {icon_bg}; border: 1.5px solid {icon_border};
display: flex; align-items: center; justify-content: center;
margin: 0 auto 20px;
}}
h1 {{
font-size: 22px; font-weight: 600; color: #040404;
letter-spacing: -0.03em; margin: 0 0 10px;
}}
p {{
font-size: 15px; color: #8a8ea9; line-height: 1.7;
margin: 0 0 28px;
}}
.btn {{
display: inline-block;
padding: 12px 28px; border-radius: 50px;
background: #040404; color: #fbfbfb;
text-decoration: none;
font-size: 14px; font-weight: 600;
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}}
.btn:hover {{
background: #222;
transform: translateY(-1px);
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
}}
</style>
</head>
<body>
<div class="card">
<div class="logo">
<svg viewBox="0 0 64 64" width="28" height="28">
<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>
<span>adiuv<span class="ai">AI</span></span>
</div>
<div class="icon">{icon_svg}</div>
<h1>{title}</h1>
<p>{message}</p>
<a href="https://adiuvai.com" class="btn">{btn_label}</a>
</div>
</body>
</html>"""