- 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)
143 lines
5.0 KiB
Python
143 lines
5.0 KiB
Python
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()
|
|
|
|
|
|
@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).
|
|
"""
|
|
# Honeypot — bots fill hidden fields; silently "succeed"
|
|
if body.website:
|
|
return WaitlistResponse()
|
|
|
|
email = body.email.lower().strip()
|
|
ip = _get_client_ip(request)
|
|
|
|
# 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:
|
|
# 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])
|
|
|
|
# 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>"""
|