feat(phase-9): email dispatcher — HTML templates, delivery log, test endpoint

Backend:
- app/models/email_log.py: EmailLog table (school_id, to, subject, type, status,
  error, sent_at) with EmailType + EmailStatus enums
- migrations/002_phase9_email_logs.py: Alembic migration for email_logs table
- app/templates/email/: 6 Jinja2 HTML templates — base layout, invoice,
  low_credit, license_expiry, overdue_warning, suspension
- app/services/email.py: enhanced send_email() — accepts template_name+context
  for HTML rendering, logs every attempt to email_logs, retries up to 3x on
  transient SMTP failure with exponential backoff
- app/routers/email.py: GET /api/email/logs (paginated, filterable by type/status/school),
  POST /api/email/test (send test email, super admin)
- tasks/billing.py: invoice + overdue warning + suspension emails now use HTML templates
- tasks/sms.py: low credit alert now uses HTML template
- tasks/license.py: expiry warning now uses HTML template
- app/main.py + migrations/env.py: wire in email_log model + email router

Frontend:
- EmailLogsPage.vue: table with to/subject/type badge/status badge/sent_at/error,
  type+status filters, pagination, Send Test Email modal
- router/index.ts: /email-logs route
- AppSidebar.vue: Email Logs nav item
- api.ts: getEmailLogs, sendTestEmail
This commit is contained in:
kevin-asprec
2026-03-16 14:03:02 +08:00
parent ce829a009d
commit 1febb3cfa9
22 changed files with 846 additions and 101 deletions

View File

@@ -1,32 +1,154 @@
"""Simple SMTP email service."""
"""Email service — SMTP delivery with HTML templates and delivery logging."""
import smtplib
import logging
import time
import uuid
from datetime import datetime, timezone
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from pathlib import Path
from typing import Optional
from app.config import settings
logger = logging.getLogger(__name__)
def send_email(to: str, subject: str, body: str, html: str | None = None) -> bool:
"""Send email via configured SMTP. Returns True on success."""
if not settings.SMTP_HOST:
logger.warning(f"SMTP not configured — would send to {to}: {subject}")
return False
TEMPLATE_DIR = Path(__file__).parent.parent / "templates"
def _render_template(template_name: str, context: dict) -> str:
"""Render a Jinja2 HTML email template. Returns empty string on failure."""
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = settings.SMTP_FROM
msg["To"] = to
msg.attach(MIMEText(body, "plain"))
if html:
msg.attach(MIMEText(html, "html"))
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as smtp:
smtp.starttls()
if settings.SMTP_USER:
smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
smtp.sendmail(settings.SMTP_FROM, [to], msg.as_string())
logger.info(f"Email sent to {to}: {subject}")
return True
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR)), autoescape=True)
tmpl = env.get_template(template_name)
return tmpl.render(**context)
except Exception as e:
logger.error(f"Email failed to {to}: {e}")
logger.warning("Template render failed (%s): %s", template_name, e)
return ""
def _log_email(
to: str,
subject: str,
email_type: str,
status: str,
school_id: Optional[str] = None,
error_message: Optional[str] = None,
) -> None:
"""Write an email delivery record to email_logs. Best-effort — never raises."""
try:
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.models.email_log import EmailLog, EmailStatus, EmailType
db_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/taptrack_hub")
engine = create_engine(
db_url.replace("postgresql+asyncpg://", "postgresql://"),
pool_pre_ping=True,
pool_size=1,
max_overflow=0,
)
Session = sessionmaker(bind=engine)
db = Session()
try:
# Map type string to enum, default to 'other'
try:
etype = EmailType(email_type)
except ValueError:
etype = EmailType.other
log = EmailLog(
id=str(uuid.uuid4()),
school_id=school_id,
to_email=to,
subject=subject,
email_type=etype,
status=EmailStatus(status),
error_message=error_message,
sent_at=datetime.now(timezone.utc) if status == "sent" else None,
created_at=datetime.now(timezone.utc),
)
db.add(log)
db.commit()
finally:
db.close()
engine.dispose()
except Exception as e:
logger.debug("Email log write failed: %s", e)
def send_email(
to: str,
subject: str,
body: str,
html: Optional[str] = None,
template_name: Optional[str] = None,
context: Optional[dict] = None,
email_type: str = "other",
school_id: Optional[str] = None,
max_retries: int = 3,
) -> bool:
"""
Send an email via configured SMTP.
Priority order for HTML content:
1. `html` parameter (raw HTML string)
2. `template_name` + `context` (rendered Jinja2 template)
3. `body` only (plain text)
Logs every attempt to email_logs table.
Retries up to `max_retries` times on transient SMTP failure.
Returns True on success.
"""
if not settings.SMTP_HOST:
logger.warning("SMTP not configured — would send to %s: %s", to, subject)
_log_email(to, subject, email_type, "failed", school_id, "SMTP not configured")
return False
# Resolve HTML content
html_content = html
if not html_content and template_name:
ctx = context or {}
ctx.setdefault("subject", subject)
ctx.setdefault("portal_url", settings.HUB_BASE_URL)
html_content = _render_template(template_name, ctx)
last_error: Optional[str] = None
for attempt in range(1, max_retries + 1):
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = settings.SMTP_FROM
msg["To"] = to
msg.attach(MIMEText(body, "plain"))
if html_content:
msg.attach(MIMEText(html_content, "html"))
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as smtp:
smtp.ehlo()
smtp.starttls()
smtp.ehlo()
if settings.SMTP_USER:
smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
smtp.sendmail(settings.SMTP_FROM, [to], msg.as_string())
logger.info("Email sent to %s: %s", to, subject)
_log_email(to, subject, email_type, "sent", school_id)
return True
except smtplib.SMTPException as e:
last_error = str(e)
logger.warning("SMTP attempt %d/%d failed for %s: %s", attempt, max_retries, to, e)
if attempt < max_retries:
time.sleep(2 ** attempt) # exponential backoff: 2s, 4s
except Exception as e:
last_error = str(e)
logger.error("Email send failed for %s: %s", to, e)
break # Non-SMTP errors don't retry
_log_email(to, subject, email_type, "failed", school_id, last_error)
return False