4 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
10 changed files with 518 additions and 19 deletions

View File

@@ -18,5 +18,8 @@ BREVO_LIST_ID=0
# Confirmation link # Confirmation link
CONFIRM_SECRET=replace-with-a-long-random-string CONFIRM_SECRET=replace-with-a-long-random-string
CONFIRM_BASE_URL=https://waitlist.adiuvai.com CONFIRM_BASE_URL=https://adiuvai.com
CONFIRM_TOKEN_EXPIRY_HOURS=48 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")

View File

@@ -24,7 +24,7 @@ def _headers() -> dict[str, str]:
} }
async def send_confirmation_email(email: str, confirm_url: str) -> bool: 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.""" """Send a double opt-in confirmation email. Returns True on success."""
if not settings.brevo_configured: if not settings.brevo_configured:
logger.warning("Brevo not configured — skipping confirmation email for %s***", email[:3]) logger.warning("Brevo not configured — skipping confirmation email for %s***", email[:3])
@@ -37,7 +37,7 @@ async def send_confirmation_email(email: str, confirm_url: str) -> bool:
}, },
"to": [{"email": email}], "to": [{"email": email}],
"subject": "Confirm your spot on the adiuvAI waitlist", "subject": "Confirm your spot on the adiuvAI waitlist",
"htmlContent": _confirmation_html(confirm_url), "htmlContent": _confirmation_html(confirm_url, unsubscribe_url),
} }
try: try:
@@ -78,7 +78,7 @@ async def add_contact_to_list(email: str) -> bool:
return False return False
def _confirmation_html(confirm_url: str) -> str: def _confirmation_html(confirm_url: str, unsubscribe_url: str = "") -> str:
"""Email template aligned with the adiuvAI landing page brand.""" """Email template aligned with the adiuvAI landing page brand."""
return f"""\ return f"""\
<!DOCTYPE html> <!DOCTYPE html>
@@ -211,6 +211,7 @@ def _confirmation_html(confirm_url: str) -> str:
<p style="margin:8px 0 0;font-size:12px;color:#c8c3cd;"> <p style="margin:8px 0 0;font-size:12px;color:#c8c3cd;">
<a href="https://adiuvai.com" style="color:#8a8ea9;text-decoration:underline; <a href="https://adiuvai.com" style="color:#8a8ea9;text-decoration:underline;
text-underline-offset:2px;">adiuvai.com</a> 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> </p>
</td></tr> </td></tr>

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

@@ -17,9 +17,12 @@ class Settings(BaseSettings):
# Confirmation link # Confirmation link
CONFIRM_SECRET: str = secrets.token_hex(32) # override in production .env CONFIRM_SECRET: str = secrets.token_hex(32) # override in production .env
CONFIRM_BASE_URL: str = "https://waitlist.adiuvai.com" CONFIRM_BASE_URL: str = "https://adiuvai.com"
CONFIRM_TOKEN_EXPIRY_HOURS: int = 48 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"} model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
@property @property

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) ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
source: Mapped[str | None] = mapped_column(String(64), 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")) 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( created_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
server_default=sa.text("CURRENT_TIMESTAMP"), server_default=sa.text("CURRENT_TIMESTAMP"),

View File

@@ -1,6 +1,6 @@
import asyncio import asyncio
import datetime
import logging import logging
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
@@ -32,6 +32,7 @@ async def join_waitlist(
- Duplicate emails: idempotent — returns success without error. - Duplicate emails: idempotent — returns success without error.
- Stores the Cloudflare-resolved client IP for analytics (not exposed). - Stores the Cloudflare-resolved client IP for analytics (not exposed).
- Sends a confirmation email via Brevo (fire-and-forget). - Sends a confirmation email via Brevo (fire-and-forget).
- Records consent timestamp (GDPR Art. 7).
""" """
# Honeypot — bots fill hidden fields; silently "succeed" # Honeypot — bots fill hidden fields; silently "succeed"
if body.website: if body.website:
@@ -39,6 +40,7 @@ async def join_waitlist(
email = body.email.lower().strip() email = body.email.lower().strip()
ip = _get_client_ip(request) ip = _get_client_ip(request)
now = datetime.datetime.now(datetime.timezone.utc)
# Check for existing entry — idempotent # Check for existing entry — idempotent
existing = await db.execute( existing = await db.execute(
@@ -47,14 +49,20 @@ async def join_waitlist(
entry = existing.scalar_one_or_none() entry = existing.scalar_one_or_none()
if entry is not None: if entry is not None:
# Re-send confirmation if not yet confirmed # Re-send confirmation if not yet confirmed (and not anonymized)
if not entry.confirmed and settings.brevo_configured: if not entry.confirmed and not entry.anonymized_at and settings.brevo_configured:
token = generate_token(email) token = generate_token(email)
confirm_url = f"{settings.CONFIRM_BASE_URL}/api/v1/waitlist/confirm?token={token}" confirm_url = f"{settings.CONFIRM_BASE_URL}/api/v1/waitlist/confirm?token={token}"
asyncio.create_task(send_confirmation_email(email, confirm_url)) 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() 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) db.add(entry)
await db.commit() await db.commit()
@@ -64,7 +72,8 @@ async def join_waitlist(
if settings.brevo_configured: if settings.brevo_configured:
token = generate_token(email) token = generate_token(email)
confirm_url = f"{settings.CONFIRM_BASE_URL}/api/v1/waitlist/confirm?token={token}" confirm_url = f"{settings.CONFIRM_BASE_URL}/api/v1/waitlist/confirm?token={token}"
asyncio.create_task(send_confirmation_email(email, confirm_url)) 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() return WaitlistResponse()
@@ -93,6 +102,8 @@ async def confirm_email(
if not entry.confirmed: if not entry.confirmed:
entry.confirmed = True entry.confirmed = True
# Clear IP now that consent is confirmed — no longer needed
entry.ip_address = None
await db.commit() await db.commit()
logger.info("Email confirmed: %s***", email[:3]) logger.info("Email confirmed: %s***", email[:3])
@@ -103,9 +114,62 @@ async def confirm_email(
return HTMLResponse(content=_result_page(success=True)) return HTMLResponse(content=_result_page(success=True))
def _result_page(*, success: bool) -> str: @router.get("/waitlist/unsubscribe", response_class=HTMLResponse)
"""Branded HTML response for confirmation result, matching the adiuvAI landing page.""" 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: 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!" title = "You&#8217;re confirmed!"
message = "Your email has been verified. We&#8217;ll notify you when adiuvAI is ready." message = "Your email has been verified. We&#8217;ll notify you when adiuvAI is ready."
icon_bg = "rgba(251,200,129,0.12)" icon_bg = "rgba(251,200,129,0.12)"

View File

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

View File

@@ -57,7 +57,7 @@ async def test_join_waitlist_success(client):
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data["ok"] is True assert data["ok"] is True
assert "list" in data["message"].lower() assert "inbox" in data["message"].lower()
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -153,9 +153,9 @@ async def test_token_tampered():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_confirm_valid_token(client, db_session): async def test_confirm_valid_token(client, db_session):
"""GET /confirm with valid token marks email as confirmed.""" """GET /confirm with valid token marks email as confirmed and clears IP."""
# Seed an unconfirmed entry # Seed an unconfirmed entry
entry = WaitlistEntry(email="confirm@example.com", source="website") entry = WaitlistEntry(email="confirm@example.com", source="website", ip_address="1.2.3.4")
db_session.add(entry) db_session.add(entry)
await db_session.commit() await db_session.commit()
@@ -168,7 +168,9 @@ async def test_confirm_valid_token(client, db_session):
result = await db_session.execute( result = await db_session.execute(
select(WaitlistEntry).where(WaitlistEntry.email == "confirm@example.com") select(WaitlistEntry).where(WaitlistEntry.email == "confirm@example.com")
) )
assert result.scalar_one().confirmed is True 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 @pytest.mark.asyncio
@@ -224,3 +226,64 @@ async def test_signup_triggers_confirmation_email(client, db_session):
call_args = mock_send.call_args call_args = mock_send.call_args
assert call_args[0][0] == "brevo@example.com" assert call_args[0][0] == "brevo@example.com"
assert "confirm" in call_args[0][1] 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