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() email = body.email.lower().strip() 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: # 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() entry = WaitlistEntry( email=email, ip_address=ip, source="website", 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)) 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 # 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)) @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")) _anonymize_entry(entry) await db.commit() logger.info("Waitlist entry anonymized (unsubscribe)") return HTMLResponse(content=_result_page(success=True, variant="unsubscribe")) 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") -> str: """Branded HTML response for confirmation/unsubscribe result.""" if variant == "unsubscribe": if success: title = "Data removed" message = "Your personal data has been anonymized. You will not receive any further emails from us." 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." 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." 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." icon_bg = "rgba(220,38,38,0.08)" icon_border = "rgba(220,38,38,0.18)" icon_svg = '' return f"""\ {title} — adiuvAI
{icon_svg}

{title}

{message}

Go to adiuvai.com
"""