- Add consent_given_at and anonymized_at fields + Alembic migration (002) - Add GET /waitlist/unsubscribe endpoint (HMAC token, anonymizes PII) - Add cleanup.py: cron-able script to anonymize unconfirmed entries after 48h - Clear IP address on email confirmation (no longer needed) - Add unsubscribe link in confirmation email footer - Record consent timestamp on signup - Add 4 new tests (unsubscribe, consent timestamp) - Update .env.example, schemas
271 lines
10 KiB
Python
271 lines
10 KiB
Python
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 = '<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 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 = '<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>'
|
|
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 = '<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>"""
|