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:
100
app/routes.py
100
app/routes.py
@@ -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>"""
|
||||
|
||||
Reference in New Issue
Block a user