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

@@ -0,0 +1,94 @@
"""Email log viewer and test-email endpoint."""
from typing import Optional
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, EmailStr
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from app.auth.dependencies import require_super_admin
from app.database import get_db
from app.models.user import HubUser
from app.models.email_log import EmailLog, EmailType, EmailStatus
router = APIRouter(prefix="/api/email", tags=["email"])
@router.get("/logs")
async def list_email_logs(
email_type: Optional[EmailType] = Query(None),
status: Optional[EmailStatus] = Query(None),
school_id: Optional[str] = Query(None),
page: int = Query(1, ge=1),
per_page: int = Query(50, ge=1, le=200),
_admin: HubUser = Depends(require_super_admin),
db: AsyncSession = Depends(get_db),
):
"""Paginated email delivery history — super admin only."""
stmt = select(EmailLog).order_by(desc(EmailLog.created_at))
if email_type:
stmt = stmt.where(EmailLog.email_type == email_type)
if status:
stmt = stmt.where(EmailLog.status == status)
if school_id:
stmt = stmt.where(EmailLog.school_id == school_id)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
logs = (await db.execute(stmt.offset((page - 1) * per_page).limit(per_page))).scalars().all()
return {
"items": [
{
"id": log.id,
"school_id": log.school_id,
"to_email": log.to_email,
"subject": log.subject,
"email_type": log.email_type.value,
"status": log.status.value,
"error_message": log.error_message,
"sent_at": log.sent_at.isoformat() if log.sent_at else None,
"created_at": log.created_at.isoformat(),
}
for log in logs
],
"total": total,
"page": page,
"per_page": per_page,
}
class TestEmailBody(BaseModel):
to: EmailStr
@router.post("/test")
async def send_test_email(
body: TestEmailBody,
_admin: HubUser = Depends(require_super_admin),
):
"""Send a test email to verify SMTP configuration."""
from app.services.email import send_email
success = send_email(
to=body.to,
subject="TapTrack Hub — SMTP Test Email",
body=(
"This is a test email from TapTrack Hub.\n\n"
"If you received this, your SMTP configuration is working correctly.\n\n"
"TapTrack Hub Team"
),
html=(
"<div style='font-family:sans-serif;max-width:480px;margin:32px auto;padding:24px;"
"background:#fff;border:1px solid #e2e8f0;border-radius:12px'>"
"<h2 style='color:#1e40af;margin:0 0 16px'>TapTrack Hub — SMTP Test</h2>"
"<p style='color:#334155'>This is a test email from <strong>TapTrack Hub</strong>.</p>"
"<p style='color:#334155'>If you received this, your SMTP configuration is working correctly.</p>"
"<p style='color:#94a3b8;font-size:12px;margin-top:24px'>TapTrack Hub Team</p>"
"</div>"
),
email_type="test",
)
if success:
return {"message": f"Test email sent successfully to {body.to}"}
return {"message": "Failed to send test email — check SMTP configuration and server logs"}