6 Commits

Author SHA1 Message Date
Roberto Musso
d32fc7ae30 feat: add daily waitlist report email (Brevo)
All checks were successful
Test & Deploy Waitlist / test (push) Successful in 35s
Test & Deploy Waitlist / deploy (push) Successful in 15s
2026-04-11 20:28:29 +02:00
Roberto Musso
352e25d651 feat: GDPR compliance — anonymization, unsubscribe, consent tracking
- 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
2026-04-11 19:41:27 +02:00
Roberto Musso
5f79ce87f9 fix: update confirm base URL to adiuvai.com and success messages for double opt-in
All checks were successful
Test & Deploy Waitlist / test (push) Successful in 35s
Test & Deploy Waitlist / deploy (push) Successful in 16s
2026-04-11 19:13:59 +02:00
Roberto Musso
73a76c6667 fix: remove unused urlencode import
All checks were successful
Test & Deploy Waitlist / test (push) Successful in 35s
Test & Deploy Waitlist / deploy (push) Successful in 37s
2026-04-11 19:00:38 +02:00
Roberto Musso
e9fb6102ab style: align confirmation result page with adiuvAI brand
Some checks failed
Test & Deploy Waitlist / test (push) Failing after 33s
Test & Deploy Waitlist / deploy (push) Has been skipped
2026-04-11 18:58:12 +02:00
Roberto Musso
f956f0a260 feat: Brevo double opt-in + contact sync
- 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)
2026-04-11 18:48:58 +02:00
12 changed files with 1094 additions and 7 deletions

View File

@@ -9,3 +9,17 @@ RATE_LIMIT_PER_MINUTE=5
# Set to "production" in prod to enforce strict origin checks
ENVIRONMENT=development
# Brevo (email) — leave BREVO_API_KEY empty to disable email features
BREVO_API_KEY=
BREVO_SENDER_EMAIL=noreply@adiuvai.com
BREVO_SENDER_NAME=adiuvAI
BREVO_LIST_ID=0
# Confirmation link
CONFIRM_SECRET=replace-with-a-long-random-string
CONFIRM_BASE_URL=https://adiuvai.com
CONFIRM_TOKEN_EXPIRY_HOURS=48
# Daily report — leave empty to disable
REPORT_RECIPIENT_EMAIL=

View File

@@ -0,0 +1,25 @@
"""add consent_given_at and anonymized_at columns
Revision ID: 002
Revises: 001
Create Date: 2026-04-11
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "002"
down_revision: Union[str, None] = "001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("waitlist_entries", sa.Column("consent_given_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("waitlist_entries", sa.Column("anonymized_at", sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
op.drop_column("waitlist_entries", "anonymized_at")
op.drop_column("waitlist_entries", "consent_given_at")

225
app/brevo.py Normal file
View File

@@ -0,0 +1,225 @@
"""
Brevo (ex-Sendinblue) integration.
- Send transactional confirmation emails
- Sync confirmed contacts to a Brevo list
"""
import logging
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
BREVO_API = "https://api.brevo.com/v3"
def _headers() -> dict[str, str]:
return {
"api-key": settings.BREVO_API_KEY,
"Content-Type": "application/json",
"Accept": "application/json",
}
async def send_confirmation_email(email: str, confirm_url: str, unsubscribe_url: str = "") -> bool:
"""Send a double opt-in confirmation email. Returns True on success."""
if not settings.brevo_configured:
logger.warning("Brevo not configured — skipping confirmation email for %s***", email[:3])
return False
payload = {
"sender": {
"name": settings.BREVO_SENDER_NAME,
"email": settings.BREVO_SENDER_EMAIL,
},
"to": [{"email": email}],
"subject": "Confirm your spot on the adiuvAI waitlist",
"htmlContent": _confirmation_html(confirm_url, unsubscribe_url),
}
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(f"{BREVO_API}/smtp/email", headers=_headers(), json=payload)
resp.raise_for_status()
logger.info("Confirmation email sent to %s***", email[:3])
return True
except httpx.HTTPError:
logger.exception("Failed to send confirmation email to %s***", email[:3])
return False
async def add_contact_to_list(email: str) -> bool:
"""Add a confirmed contact to the Brevo waitlist list. Returns True on success."""
if not settings.brevo_configured:
logger.warning("Brevo not configured — skipping contact sync for %s***", email[:3])
return False
if settings.BREVO_LIST_ID == 0:
logger.warning("BREVO_LIST_ID not set — skipping contact sync")
return False
payload = {
"email": email,
"listIds": [settings.BREVO_LIST_ID],
"updateEnabled": True,
}
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(f"{BREVO_API}/contacts", headers=_headers(), json=payload)
resp.raise_for_status()
logger.info("Contact synced to Brevo list %d: %s***", settings.BREVO_LIST_ID, email[:3])
return True
except httpx.HTTPError:
logger.exception("Failed to sync contact to Brevo: %s***", email[:3])
return False
def _confirmation_html(confirm_url: str, unsubscribe_url: str = "") -> str:
"""Email template aligned with the adiuvAI landing page brand."""
return f"""\
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<title>Confirm your spot — adiuvAI</title>
<!--[if mso]><style>table,td{{font-family:Arial,sans-serif!important;}}</style><![endif]-->
</head>
<body style="margin:0;padding:0;width:100%;background-color:#f4edf3;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;
-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;">
<!-- Preheader (hidden text for inbox preview) -->
<div style="display:none;font-size:1px;color:#f4edf3;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;">
Confirm your email to secure your early access spot on the adiuvAI waitlist.
</div>
<!-- Outer wrapper -->
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"
style="background-color:#f4edf3;">
<tr><td align="center" style="padding:48px 16px;">
<!-- Card -->
<table role="presentation" width="520" cellpadding="0" cellspacing="0" border="0"
style="max-width:520px;width:100%;background-color:#ffffff;
border-radius:20px;overflow:hidden;
box-shadow:0 4px 16px rgba(0,0,0,0.03),0 1px 2px rgba(0,0,0,0.02);">
<!-- Gold accent bar -->
<tr><td style="height:4px;background:linear-gradient(135deg,#fbc881 0%,#e5a94e 100%);
font-size:0;line-height:0;">&nbsp;</td></tr>
<!-- Logo row -->
<tr><td align="center" style="padding:36px 40px 0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<!-- Compass SVG (inline, matching the website nav-logo) -->
<td style="padding-right:8px;vertical-align:middle;">
<!--[if mso]>
<v:group style="width:28px;height:28px;" coordsize="64,64">
<v:shape style="width:64;height:64;" path="M32,4 L48,32 L16,32 Z" fillcolor="#fbc881" stroked="f"/>
<v:shape style="width:64;height:64;" path="M16,32 L48,32 L32,60 Z" fillcolor="#040404" stroked="f"/>
</v:group>
<![endif]-->
<!--[if !mso]><!-->
<svg viewBox="0 0 64 64" width="28" height="28" xmlns="http://www.w3.org/2000/svg"
style="display:block;width:28px;height:28px;">
<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>
<!--<![endif]-->
</td>
<td style="vertical-align:middle;font-size:18px;font-weight:400;
color:#040404;letter-spacing:-0.02em;">
adiuv<span style="font-weight:700;color:#e5a94e;">AI</span>
</td>
</tr>
</table>
</td></tr>
<!-- Body -->
<tr><td style="padding:32px 40px 0;">
<!-- Badge -->
<table role="presentation" cellpadding="0" cellspacing="0" border="0"
style="margin:0 auto 24px;">
<tr><td style="padding:5px 14px;border-radius:50px;
background-color:rgba(251,200,129,0.12);
border:1px solid rgba(251,200,129,0.2);
font-size:11px;font-weight:600;letter-spacing:0.06em;
text-transform:uppercase;color:#e5a94e;">
&#9679;&ensp;One more step
</td></tr>
</table>
<!-- Heading -->
<h1 style="margin:0 0 16px;font-size:24px;font-weight:600;
letter-spacing:-0.03em;line-height:1.2;color:#040404;text-align:center;">
Confirm your spot on<br>the waitlist
</h1>
<!-- Paragraph -->
<p style="margin:0 0 28px;font-size:15px;line-height:1.7;color:#8a8ea9;
text-align:center;">
Thanks for signing up! Please confirm your email address
so we can keep you in the loop when adiuvAI launches.
</p>
<!-- CTA Button -->
<table role="presentation" cellpadding="0" cellspacing="0" border="0"
style="margin:0 auto;">
<tr><td align="center" style="border-radius:50px;background-color:#040404;">
<a href="{confirm_url}"
target="_blank"
style="display:inline-block;padding:14px 36px;
font-size:15px;font-weight:600;
color:#fbfbfb;text-decoration:none;
border-radius:50px;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
Confirm my email
</a>
</td></tr>
</table>
<!-- Expiry note -->
<p style="margin:20px 0 0;font-size:13px;color:#c8c3cd;text-align:center;">
This link expires in {settings.CONFIRM_TOKEN_EXPIRY_HOURS} hours.
</p>
</td></tr>
<!-- Divider -->
<tr><td style="padding:32px 40px 0;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td style="height:1px;background-color:#f4edf3;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<!-- Footer -->
<tr><td style="padding:20px 40px 32px;text-align:center;">
<p style="margin:0;font-size:12px;color:#c8c3cd;line-height:1.5;">
If you didn't sign up, simply ignore this email.
</p>
<p style="margin:8px 0 0;font-size:12px;color:#c8c3cd;">
<a href="https://adiuvai.com" style="color:#8a8ea9;text-decoration:underline;
text-underline-offset:2px;">adiuvai.com</a>
{f'&ensp;·&ensp;<a href="{unsubscribe_url}" style="color:#8a8ea9;text-decoration:underline;text-underline-offset:2px;">Unsubscribe</a>' if unsubscribe_url else ''}
</p>
</td></tr>
</table>
<!-- /Card -->
</td></tr>
</table>
</body>
</html>"""

58
app/cleanup.py Normal file
View File

@@ -0,0 +1,58 @@
"""
Periodic cleanup: anonymize unconfirmed waitlist entries older than CONFIRM_TOKEN_EXPIRY_HOURS.
Run as a cron job:
python -m app.cleanup
Or integrate into docker-compose with a one-shot service.
"""
import asyncio
import datetime
import logging
from sqlalchemy import select, and_
from app.config import settings
from app.db import async_session
from app.models import WaitlistEntry
from app.routes import _anonymize_entry
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
async def anonymize_expired() -> int:
"""Anonymize all unconfirmed entries past the token expiry window. Returns count."""
cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(
hours=settings.CONFIRM_TOKEN_EXPIRY_HOURS
)
async with async_session() as db:
result = await db.execute(
select(WaitlistEntry).where(
and_(
WaitlistEntry.confirmed == False, # noqa: E712
WaitlistEntry.anonymized_at == None, # noqa: E711
WaitlistEntry.created_at < cutoff,
)
)
)
entries = result.scalars().all()
for entry in entries:
_anonymize_entry(entry)
await db.commit()
if entries:
logger.info("Anonymized %d expired unconfirmed entries", len(entries))
else:
logger.info("No expired unconfirmed entries to anonymize")
return len(entries)
if __name__ == "__main__":
count = asyncio.run(anonymize_expired())
raise SystemExit(0)

View File

@@ -1,3 +1,5 @@
import secrets
from pydantic_settings import BaseSettings
@@ -7,6 +9,20 @@ class Settings(BaseSettings):
RATE_LIMIT_PER_MINUTE: int = 5
ENVIRONMENT: str = "development"
# Brevo (email)
BREVO_API_KEY: str = ""
BREVO_SENDER_EMAIL: str = "noreply@adiuvai.com"
BREVO_SENDER_NAME: str = "adiuvAI"
BREVO_LIST_ID: int = 0 # Brevo contact list ID for waitlist subscribers
# Confirmation link
CONFIRM_SECRET: str = secrets.token_hex(32) # override in production .env
CONFIRM_BASE_URL: str = "https://adiuvai.com"
CONFIRM_TOKEN_EXPIRY_HOURS: int = 48
# Daily report
REPORT_RECIPIENT_EMAIL: str = "" # your email — leave empty to disable
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
@property
@@ -17,5 +33,9 @@ class Settings(BaseSettings):
def sync_database_url(self) -> str:
return self.DATABASE_URL.replace("+asyncpg", "+psycopg2")
@property
def brevo_configured(self) -> bool:
return bool(self.BREVO_API_KEY)
settings = Settings()

276
app/daily_report.py Normal file
View File

@@ -0,0 +1,276 @@
"""
Daily waitlist report — sends an evening summary email via Brevo.
Run as a cron job (e.g. every day at 21:00):
python -m app.daily_report
Requires REPORT_RECIPIENT_EMAIL and BREVO_API_KEY in .env.
"""
import asyncio
import datetime
import logging
import httpx
from sqlalchemy import func, select, and_
from app.config import settings
from app.db import async_session
from app.models import WaitlistEntry
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
async def gather_stats() -> dict:
"""Collect today's waitlist statistics."""
now = datetime.datetime.now(datetime.timezone.utc)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
async with async_session() as db:
# Total counts
total = (await db.execute(select(func.count(WaitlistEntry.id)))).scalar() or 0
confirmed_total = (await db.execute(
select(func.count(WaitlistEntry.id)).where(WaitlistEntry.confirmed == True) # noqa: E712
)).scalar() or 0
anonymized_total = (await db.execute(
select(func.count(WaitlistEntry.id)).where(WaitlistEntry.anonymized_at != None) # noqa: E711
)).scalar() or 0
pending = total - confirmed_total - anonymized_total
# Today's activity
new_today = (await db.execute(
select(func.count(WaitlistEntry.id)).where(
and_(
WaitlistEntry.created_at >= today_start,
WaitlistEntry.anonymized_at == None, # noqa: E711
)
)
)).scalar() or 0
confirmed_today = (await db.execute(
select(func.count(WaitlistEntry.id)).where(
and_(
WaitlistEntry.confirmed == True, # noqa: E712
WaitlistEntry.consent_given_at >= today_start,
)
)
)).scalar() or 0
anonymized_today = (await db.execute(
select(func.count(WaitlistEntry.id)).where(
and_(
WaitlistEntry.anonymized_at != None, # noqa: E711
WaitlistEntry.anonymized_at >= today_start,
)
)
)).scalar() or 0
return {
"date": now.strftime("%B %d, %Y"),
"total": total,
"confirmed_total": confirmed_total,
"pending": pending,
"anonymized_total": anonymized_total,
"new_today": new_today,
"confirmed_today": confirmed_today,
"anonymized_today": anonymized_today,
"conversion_rate": round(confirmed_total / total * 100, 1) if total > 0 else 0,
}
async def send_report() -> bool:
"""Gather stats and send the daily report email."""
if not settings.REPORT_RECIPIENT_EMAIL:
logger.warning("REPORT_RECIPIENT_EMAIL not set — skipping daily report")
return False
if not settings.brevo_configured:
logger.warning("Brevo not configured — skipping daily report")
return False
stats = await gather_stats()
html = _report_html(stats)
payload = {
"sender": {
"name": settings.BREVO_SENDER_NAME,
"email": settings.BREVO_SENDER_EMAIL,
},
"to": [{"email": settings.REPORT_RECIPIENT_EMAIL}],
"subject": f"Waitlist report — {stats['date']}",
"htmlContent": html,
}
headers = {
"api-key": settings.BREVO_API_KEY,
"Content-Type": "application/json",
"Accept": "application/json",
}
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
"https://api.brevo.com/v3/smtp/email",
headers=headers,
json=payload,
)
resp.raise_for_status()
logger.info("Daily report sent to %s", settings.REPORT_RECIPIENT_EMAIL)
return True
except httpx.HTTPError:
logger.exception("Failed to send daily report")
return False
def _report_html(s: dict) -> str:
"""adiuvAI-branded daily report email."""
def _stat_cell(label: str, value, color: str = "#040404") -> str:
return f"""\
<td align="center" style="padding:16px 8px;">
<p style="margin:0;font-size:28px;font-weight:700;color:{color};
letter-spacing:-0.03em;line-height:1;">{value}</p>
<p style="margin:6px 0 0;font-size:11px;font-weight:600;color:#8a8ea9;
text-transform:uppercase;letter-spacing:0.06em;">{label}</p>
</td>"""
def _row(label: str, value, highlight: bool = False) -> str:
color = "#e5a94e" if highlight else "#040404"
return f"""\
<tr>
<td style="padding:10px 0;font-size:14px;color:#8a8ea9;border-bottom:1px solid #f4edf3;">
{label}
</td>
<td align="right" style="padding:10px 0;font-size:14px;font-weight:600;color:{color};
border-bottom:1px solid #f4edf3;">
{value}
</td>
</tr>"""
return f"""\
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light">
<title>Waitlist Report — {s['date']}</title>
<!--[if mso]><style>table,td{{font-family:Arial,sans-serif!important;}}</style><![endif]-->
</head>
<body style="margin:0;padding:0;width:100%;background-color:#f4edf3;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;
-webkit-font-smoothing:antialiased;">
<div style="display:none;font-size:1px;color:#f4edf3;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;">
{s['new_today']} new signups, {s['confirmed_total']} confirmed total ({s['conversion_rate']}% conversion)
</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"
style="background-color:#f4edf3;">
<tr><td align="center" style="padding:48px 16px;">
<table role="presentation" width="520" cellpadding="0" cellspacing="0" border="0"
style="max-width:520px;width:100%;background-color:#ffffff;
border-radius:20px;overflow:hidden;
box-shadow:0 4px 16px rgba(0,0,0,0.03),0 1px 2px rgba(0,0,0,0.02);">
<!-- Gold bar -->
<tr><td style="height:4px;background:linear-gradient(135deg,#fbc881 0%,#e5a94e 100%);
font-size:0;line-height:0;">&nbsp;</td></tr>
<!-- Logo -->
<tr><td align="center" style="padding:32px 40px 0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="padding-right:8px;vertical-align:middle;">
<!--[if !mso]><!-->
<svg viewBox="0 0 64 64" width="24" height="24" xmlns="http://www.w3.org/2000/svg"
style="display:block;width:24px;height:24px;">
<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>
<!--<![endif]-->
</td>
<td style="vertical-align:middle;font-size:16px;font-weight:400;
color:#040404;letter-spacing:-0.02em;">
adiuv<span style="font-weight:700;color:#e5a94e;">AI</span>
</td>
</tr>
</table>
</td></tr>
<!-- Header -->
<tr><td style="padding:24px 40px 0;text-align:center;">
<p style="margin:0 0 4px;font-size:11px;font-weight:600;letter-spacing:0.06em;
text-transform:uppercase;color:#e5a94e;">Daily Report</p>
<h1 style="margin:0;font-size:22px;font-weight:600;color:#040404;
letter-spacing:-0.03em;">{s['date']}</h1>
</td></tr>
<!-- Hero stats -->
<tr><td style="padding:28px 40px 0;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"
style="background:#f4edf3;border-radius:14px;">
<tr>
{_stat_cell("Confirmed", s['confirmed_total'], "#040404")}
{_stat_cell("Pending", s['pending'], "#8a8ea9")}
{_stat_cell("Conversion", f"{s['conversion_rate']}%", "#e5a94e")}
</tr>
</table>
</td></tr>
<!-- Today's activity -->
<tr><td style="padding:28px 40px 0;">
<p style="margin:0 0 12px;font-size:11px;font-weight:600;letter-spacing:0.06em;
text-transform:uppercase;color:#e5a94e;">Today's Activity</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
{_row("New signups", f"+{s['new_today']}", highlight=s['new_today'] > 0)}
{_row("Confirmed", f"+{s['confirmed_today']}", highlight=s['confirmed_today'] > 0)}
{_row("Anonymized (expired/unsub)", s['anonymized_today'])}
</table>
</td></tr>
<!-- Totals -->
<tr><td style="padding:24px 40px 0;">
<p style="margin:0 0 12px;font-size:11px;font-weight:600;letter-spacing:0.06em;
text-transform:uppercase;color:#e5a94e;">All Time</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
{_row("Total records", s['total'])}
{_row("Confirmed", s['confirmed_total'], highlight=True)}
{_row("Pending confirmation", s['pending'])}
{_row("Anonymized", s['anonymized_total'])}
</table>
</td></tr>
<!-- Divider -->
<tr><td style="padding:28px 40px 0;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td style="height:1px;background-color:#f4edf3;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<!-- Footer -->
<tr><td style="padding:16px 40px 28px;text-align:center;">
<p style="margin:0;font-size:12px;color:#c8c3cd;">
Automated report from
<a href="https://adiuvai.com" style="color:#8a8ea9;text-decoration:underline;
text-underline-offset:2px;">adiuvai.com</a>
</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>"""
if __name__ == "__main__":
asyncio.run(send_report())

View File

@@ -21,6 +21,12 @@ class WaitlistEntry(Base):
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
source: Mapped[str | None] = mapped_column(String(64), nullable=True)
confirmed: Mapped[bool] = mapped_column(Boolean, default=False, server_default=sa.text("0"))
consent_given_at: Mapped[datetime.datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True,
)
anonymized_at: Mapped[datetime.datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True,
)
created_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True),
server_default=sa.text("CURRENT_TIMESTAMP"),

View File

@@ -1,13 +1,19 @@
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()
@@ -25,6 +31,8 @@ async def join_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:
@@ -32,17 +40,231 @@ async def join_waitlist(
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.id).where(WaitlistEntry.email == email)
select(WaitlistEntry).where(WaitlistEntry.email == email)
)
if existing.scalar_one_or_none() is not None:
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")
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] + "***")
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&#8217;re confirmed!"
message = "Your email has been verified. We&#8217;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>"""

View File

@@ -9,4 +9,4 @@ class WaitlistRequest(BaseModel):
class WaitlistResponse(BaseModel):
ok: bool = True
message: str = "You're on the list!"
message: str = "Check your inbox for a confirmation link!"

60
app/token.py Normal file
View File

@@ -0,0 +1,60 @@
"""
Stateless HMAC-signed confirmation tokens.
Token format: base64url( email : unix_timestamp : hmac_sha256 )
No database storage needed — the signature proves authenticity.
"""
import base64
import hashlib
import hmac
import time
from app.config import settings
def generate_token(email: str) -> str:
"""Create a URL-safe confirmation token for the given email."""
timestamp = str(int(time.time()))
payload = f"{email}:{timestamp}"
sig = hmac.new(
settings.CONFIRM_SECRET.encode(),
payload.encode(),
hashlib.sha256,
).hexdigest()
return base64.urlsafe_b64encode(f"{payload}:{sig}".encode()).decode()
def verify_token(token: str) -> str | None:
"""
Verify a confirmation token. Returns the email if valid, None otherwise.
Checks both HMAC signature and expiry.
"""
try:
decoded = base64.urlsafe_b64decode(token.encode()).decode()
# rsplit from the right: email may contain colons (unlikely but safe)
parts = decoded.rsplit(":", 2)
if len(parts) != 3:
return None
email, timestamp, sig = parts
# Verify HMAC
expected = hmac.new(
settings.CONFIRM_SECRET.encode(),
f"{email}:{timestamp}".encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(sig, expected):
return None
# Check expiry
age_seconds = time.time() - int(timestamp)
if age_seconds > settings.CONFIRM_TOKEN_EXPIRY_HOURS * 3600:
return None
if age_seconds < 0:
return None # future timestamp — tampered
return email
except Exception:
return None

View File

@@ -8,3 +8,4 @@ pydantic>=2.0,<3.0
pydantic-settings>=2.0,<3.0
email-validator>=2.0,<3.0
python-dotenv>=1.0,<2.0
httpx>=0.27,<1.0

View File

@@ -1,12 +1,17 @@
import time
from unittest.mock import AsyncMock, patch
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.main import app
from app.models import Base
from app.models import Base, WaitlistEntry
from app.db import get_db
from app.rate_limit import _hits_store
from app.token import generate_token, verify_token
# Use SQLite for tests (no Postgres dependency)
TEST_DB_URL = "sqlite+aiosqlite:///./test_waitlist.db"
@@ -52,7 +57,7 @@ async def test_join_waitlist_success(client):
assert resp.status_code == 200
data = resp.json()
assert data["ok"] is True
assert "list" in data["message"].lower()
assert "inbox" in data["message"].lower()
@pytest.mark.asyncio
@@ -107,3 +112,178 @@ async def test_rate_limit(client):
# The 6th request should be rate-limited (limit is 5)
assert resp.status_code == 429
assert "Retry-After" in resp.headers
# ── Confirmation token tests ─────────────────────────────────────────
@pytest.mark.asyncio
async def test_token_roundtrip():
"""A generated token should verify back to the same email."""
email = "token@example.com"
token = generate_token(email)
assert verify_token(token) == email
@pytest.mark.asyncio
async def test_token_expired():
"""An expired token should return None."""
email = "expired@example.com"
with patch("app.token.time") as mock_time:
# Generate token "49 hours ago"
past = time.time() - 49 * 3600
mock_time.time.return_value = past
token = generate_token(email)
# Now verify with real time — should be expired (>48h)
assert verify_token(token) is None
@pytest.mark.asyncio
async def test_token_tampered():
"""A tampered token should return None."""
token = generate_token("legit@example.com")
# Flip a character in the token
tampered = token[:-1] + ("A" if token[-1] != "A" else "B")
assert verify_token(tampered) is None
# ── Confirm endpoint tests ───────────────────────────────────────────
@pytest.mark.asyncio
async def test_confirm_valid_token(client, db_session):
"""GET /confirm with valid token marks email as confirmed and clears IP."""
# Seed an unconfirmed entry
entry = WaitlistEntry(email="confirm@example.com", source="website", ip_address="1.2.3.4")
db_session.add(entry)
await db_session.commit()
token = generate_token("confirm@example.com")
resp = await client.get(f"/api/v1/waitlist/confirm?token={token}")
assert resp.status_code == 200
assert "confirmed" in resp.text.lower() or "verified" in resp.text.lower()
# Verify DB state
result = await db_session.execute(
select(WaitlistEntry).where(WaitlistEntry.email == "confirm@example.com")
)
confirmed_entry = result.scalar_one()
assert confirmed_entry.confirmed is True
assert confirmed_entry.ip_address is None # IP cleared on confirm
@pytest.mark.asyncio
async def test_confirm_invalid_token(client):
"""GET /confirm with invalid token returns 400."""
resp = await client.get("/api/v1/waitlist/confirm?token=garbage")
assert resp.status_code == 400
assert "invalid" in resp.text.lower() or "expired" in resp.text.lower()
@pytest.mark.asyncio
async def test_confirm_idempotent(client, db_session):
"""Confirming an already confirmed email returns 200 (idempotent)."""
entry = WaitlistEntry(email="idem@example.com", source="website", confirmed=True)
db_session.add(entry)
await db_session.commit()
token = generate_token("idem@example.com")
resp = await client.get(f"/api/v1/waitlist/confirm?token={token}")
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_confirm_unknown_email(client):
"""Token for a non-existent email returns 400."""
token = generate_token("unknown@example.com")
resp = await client.get(f"/api/v1/waitlist/confirm?token={token}")
assert resp.status_code == 400
# ── Brevo integration tests (mocked) ────────────────────────────────
@pytest.mark.asyncio
async def test_signup_triggers_confirmation_email(client, db_session):
"""When Brevo is configured, signup sends a confirmation email."""
with patch("app.routes.settings") as mock_settings, \
patch("app.routes.send_confirmation_email", new_callable=AsyncMock) as mock_send:
mock_settings.brevo_configured = True
mock_settings.CONFIRM_BASE_URL = "http://test"
resp = await client.post(
"/api/v1/waitlist",
json={"email": "brevo@example.com"},
)
assert resp.status_code == 200
# Wait for fire-and-forget task
import asyncio
await asyncio.sleep(0.1)
mock_send.assert_called_once()
call_args = mock_send.call_args
assert call_args[0][0] == "brevo@example.com"
assert "confirm" in call_args[0][1]
# ── Unsubscribe / anonymize tests ────────────────────────────────────
@pytest.mark.asyncio
async def test_unsubscribe_anonymizes_entry(client, db_session):
"""GET /unsubscribe with valid token anonymizes the entry."""
entry = WaitlistEntry(email="unsub@example.com", source="website", confirmed=True)
db_session.add(entry)
await db_session.commit()
entry_id = entry.id
token = generate_token("unsub@example.com")
resp = await client.get(f"/api/v1/waitlist/unsubscribe?token={token}")
assert resp.status_code == 200
assert "removed" in resp.text.lower() or "anonymized" in resp.text.lower()
# Verify anonymization
result = await db_session.execute(
select(WaitlistEntry).where(WaitlistEntry.id == entry_id)
)
anon = result.scalar_one()
assert anon.email == f"anon-{entry_id}@removed.invalid"
assert anon.ip_address is None
assert anon.consent_given_at is None
assert anon.anonymized_at is not None
@pytest.mark.asyncio
async def test_unsubscribe_invalid_token(client):
"""GET /unsubscribe with invalid token returns 400."""
resp = await client.get("/api/v1/waitlist/unsubscribe?token=garbage")
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_unsubscribe_already_gone(client):
"""GET /unsubscribe for non-existent entry returns 200 (idempotent)."""
token = generate_token("gone@example.com")
resp = await client.get(f"/api/v1/waitlist/unsubscribe?token={token}")
assert resp.status_code == 200
# ── Consent timestamp tests ──────────────────────────────────────────
@pytest.mark.asyncio
async def test_signup_records_consent_timestamp(client, db_session):
"""New signup records consent_given_at."""
resp = await client.post(
"/api/v1/waitlist",
json={"email": "consent@example.com"},
)
assert resp.status_code == 200
result = await db_session.execute(
select(WaitlistEntry).where(WaitlistEntry.email == "consent@example.com")
)
entry = result.scalar_one()
assert entry.consent_given_at is not None