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"]))