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"""\ {title} — adiuvAI

adiuvAI

{'✓' if success else '✕'}

{title}

{message}

Go to adiuvai.com
"""