feat: Brevo double opt-in + contact sync

- Add brevo.py: transactional email sending + contact list sync via Brevo API
- Add token.py: stateless HMAC-signed confirmation tokens (no DB migration needed)
- Update routes.py: POST /waitlist sends confirmation email, GET /waitlist/confirm verifies token
- Update config.py: Brevo + confirmation settings (gracefully disabled when BREVO_API_KEY is empty)
- Update .env.example with new Brevo and confirmation variables
- Add httpx dependency
- Add 8 new tests (token roundtrip/expiry/tamper, confirm endpoint, Brevo mock)
This commit is contained in:
Roberto Musso
2026-04-11 18:48:58 +02:00
parent 7553a0c02b
commit f956f0a260
7 changed files with 528 additions and 4 deletions

View File

@@ -1,13 +1,19 @@
import asyncio
import logging
from urllib.parse import urlencode
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()
@@ -25,6 +31,7 @@ async def join_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).
"""
# Honeypot — bots fill hidden fields; silently "succeed"
if body.website:
@@ -35,14 +42,101 @@ async def join_waitlist(
# Check for existing entry — idempotent
existing = await db.execute(
select(WaitlistEntry.id).where(WaitlistEntry.email == email)
select(WaitlistEntry).where(WaitlistEntry.email == email)
)
if existing.scalar_one_or_none() is not None:
entry = existing.scalar_one_or_none()
if entry is not None:
# Re-send confirmation if not yet confirmed
if not entry.confirmed and settings.brevo_configured:
token = generate_token(email)
confirm_url = f"{settings.CONFIRM_BASE_URL}/api/v1/waitlist/confirm?token={token}"
asyncio.create_task(send_confirmation_email(email, confirm_url))
return WaitlistResponse()
entry = WaitlistEntry(email=email, ip_address=ip, source="website")
db.add(entry)
await db.commit()
logger.info("New waitlist signup: %s", email[:3] + "***")
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}"
asyncio.create_task(send_confirmation_email(email, confirm_url))
return WaitlistResponse()
@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)
if not entry.confirmed:
entry.confirmed = True
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))
def _result_page(*, success: bool) -> str:
"""Minimal HTML response for confirmation result."""
if success:
title = "You're confirmed!"
message = "Your email has been verified. We'll notify you when adiuvAI is ready."
color = "#16a34a"
else:
title = "Invalid or expired link"
message = "This confirmation link is no longer valid. Please sign up again."
color = "#dc2626"
return f"""\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{title} — adiuvAI</title>
</head>
<body style="margin:0;padding:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
background:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<div style="text-align:center;max-width:400px;padding:40px;">
<h1 style="font-size:28px;color:#18181b;margin:0 0 8px;">adiuvAI</h1>
<div style="width:48px;height:48px;border-radius:50%;background:{color};margin:24px auto;
display:flex;align-items:center;justify-content:center;">
<span style="color:#fff;font-size:24px;">{'' if success else ''}</span>
</div>
<h2 style="font-size:20px;color:#18181b;margin:0 0 12px;">{title}</h2>
<p style="font-size:16px;color:#52525b;line-height:1.5;margin:0 0 24px;">{message}</p>
<a href="https://adiuvai.com"
style="display:inline-block;background:#18181b;color:#fafafa;text-decoration:none;
padding:10px 24px;border-radius:8px;font-size:14px;">
Go to adiuvai.com
</a>
</div>
</body>
</html>"""