feat: add daily waitlist report email (Brevo)
This commit is contained in:
@@ -20,3 +20,6 @@ BREVO_LIST_ID=0
|
||||
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=
|
||||
|
||||
@@ -20,6 +20,9 @@ class Settings(BaseSettings):
|
||||
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
|
||||
|
||||
276
app/daily_report.py
Normal file
276
app/daily_report.py
Normal 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;"> </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;"> </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())
|
||||
Reference in New Issue
Block a user