Files
waitlist/app/routes.py
Roberto Musso 73a76c6667
All checks were successful
Test & Deploy Waitlist / test (push) Successful in 35s
Test & Deploy Waitlist / deploy (push) Successful in 37s
fix: remove unused urlencode import
2026-04-11 19:00:38 +02:00

206 lines
7.2 KiB
Python

import asyncio
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).
"""
# 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:
"""Branded HTML response for confirmation result, matching the adiuvAI landing page."""
if 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 = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#e5a94e" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></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 = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#dc2626" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>'
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>
<style>
body {{
margin: 0; padding: 0; min-height: 100vh;
display: flex; align-items: center; justify-content: center;
background: #f4edf3;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
}}
body::before {{
content: '';
position: fixed; inset: 0;
background:
radial-gradient(ellipse 80% 60% at 25% 20%, rgba(251,200,129,0.10) 0%, transparent 60%),
radial-gradient(ellipse 70% 50% at 75% 70%, rgba(138,142,169,0.08) 0%, transparent 50%);
pointer-events: none; z-index: 0;
}}
.card {{
position: relative; z-index: 1;
text-align: center; max-width: 420px; width: 100%;
margin: 24px;
padding: 48px 40px 40px;
background: rgba(255,255,255,0.55);
backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255,255,255,0.65);
border-radius: 20px;
box-shadow: 0 4px 16px rgba(0,0,0,0.03), 0 1px 2px rgba(0,0,0,0.02);
}}
.logo {{
display: inline-flex; align-items: center; gap: 8px;
font-size: 18px; font-weight: 400; color: #040404;
letter-spacing: -0.02em; margin-bottom: 28px;
}}
.logo .ai {{ font-weight: 700; color: #e5a94e; }}
.icon {{
width: 56px; height: 56px; border-radius: 50%;
background: {icon_bg}; border: 1.5px solid {icon_border};
display: flex; align-items: center; justify-content: center;
margin: 0 auto 20px;
}}
h1 {{
font-size: 22px; font-weight: 600; color: #040404;
letter-spacing: -0.03em; margin: 0 0 10px;
}}
p {{
font-size: 15px; color: #8a8ea9; line-height: 1.7;
margin: 0 0 28px;
}}
.btn {{
display: inline-block;
padding: 12px 28px; border-radius: 50px;
background: #040404; color: #fbfbfb;
text-decoration: none;
font-size: 14px; font-weight: 600;
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}}
.btn:hover {{
background: #222;
transform: translateY(-1px);
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
}}
</style>
</head>
<body>
<div class="card">
<div class="logo">
<svg viewBox="0 0 64 64" width="28" height="28">
<path d="M32,4 L48,32 L16,32 Z" fill="#fbc881"/>
<path d="M16,32 L48,32 L32,60 Z" fill="#040404"/>
<circle cx="32" cy="32" r="2.5" fill="#040404" opacity="0.18"/>
</svg>
<span>adiuv<span class="ai">AI</span></span>
</div>
<div class="icon">{icon_svg}</div>
<h1>{title}</h1>
<p>{message}</p>
<a href="https://adiuvai.com" class="btn">Go to adiuvai.com</a>
</div>
</body>
</html>"""