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

@@ -136,20 +136,29 @@ def send_invoice_email_task(invoice_id: str):
if not school or not school.billing_email:
return
body = (
plain = (
f"Dear {school.contact_name or school.name},\n\n"
f"Please find your invoice {inv.invoice_number} for the period "
f"{inv.billing_period_start} to {inv.billing_period_end}.\n\n"
f"Amount Due: PHP {float(inv.total_amount):,.2f}\n"
f"Due Date: {inv.due_date or 'Upon receipt'}\n\n"
f"Please log in to your TapTrack Hub school portal to view and download your invoice:\n"
f"{_hub_url()}/portal/billing\n\n"
f"Thank you,\nTapTrack Hub Team"
f"Your invoice {inv.invoice_number} for PHP {float(inv.total_amount):,.2f} "
f"covering {inv.billing_period_start} to {inv.billing_period_end} is ready.\n"
f"Due: {inv.due_date or 'Upon receipt'}\n\n"
f"Log in: {_hub_url()}/portal/billing\n\nTapTrack Hub Team"
)
send_email(
to=school.billing_email,
subject=f"Invoice {inv.invoice_number} — TapTrack Hub",
body=body,
body=plain,
template_name="email/invoice.html",
context={
"contact_name": school.contact_name or school.name,
"school_name": school.name,
"invoice_number": inv.invoice_number,
"period_start": str(inv.billing_period_start),
"period_end": str(inv.billing_period_end),
"amount": f"{float(inv.total_amount):,.2f}",
"due_date": str(inv.due_date) if inv.due_date else "Upon receipt",
},
email_type="invoice",
school_id=school.id,
)
inv.email_sent_at = datetime.now(timezone.utc)
if inv.status == InvoiceStatus.draft:
@@ -209,17 +218,30 @@ def check_overdue():
for inv in warn_invoices:
school = db.get(School, inv.school_id)
if school and school.billing_email:
days_overdue = (today - inv.due_date).days
days_until_suspension = max(0, 30 - days_overdue)
send_email(
to=school.billing_email,
subject=f"[TapTrack Hub] Overdue Invoice — {inv.invoice_number}",
body=(
f"Dear {school.contact_name or school.name},\n\n"
f"Your invoice {inv.invoice_number} for PHP {float(inv.total_amount):,.2f} "
f"was due on {inv.due_date} and is now overdue.\n\n"
f"Please settle this invoice immediately to avoid account suspension.\n\n"
f"Log in to your portal: {_hub_url()}/portal/billing\n\n"
f"TapTrack Hub Team"
f"Invoice {inv.invoice_number} (PHP {float(inv.total_amount):,.2f}) "
f"was due on {inv.due_date} and is now overdue.\n"
f"Please settle immediately to avoid suspension.\n\n"
f"Portal: {_hub_url()}/portal/billing\n\nTapTrack Hub Team"
),
template_name="email/overdue_warning.html",
context={
"contact_name": school.contact_name or school.name,
"school_name": school.name,
"invoice_number": inv.invoice_number,
"amount": f"{float(inv.total_amount):,.2f}",
"due_date": str(inv.due_date),
"days_overdue": days_overdue,
"days_until_suspension": days_until_suspension,
},
email_type="overdue_warning",
school_id=school.id,
)
logger.info("Sent %d overdue warning emails", len(warn_invoices))
@@ -249,14 +271,21 @@ def check_overdue():
to=school.billing_email,
subject=f"[TapTrack Hub] Account Suspended — Invoice {inv.invoice_number}",
body=(
f"Dear {school.contact_name or school.name},\n\n"
f"Your TapTrack account has been suspended due to unpaid invoice "
f"{inv.invoice_number} (PHP {float(inv.total_amount):,.2f}), "
f"which was due on {inv.due_date}.\n\n"
f"SMS notifications and automated reports are now disabled.\n\n"
f"To restore service, please contact support@taptrack.io immediately.\n\n"
f"TapTrack Hub Team"
f"Your TapTrack account for {school.name} has been suspended.\n"
f"Invoice {inv.invoice_number} (PHP {float(inv.total_amount):,.2f}) "
f"was due on {inv.due_date} and remains unpaid.\n\n"
f"Contact support@taptrack.io to restore service."
),
template_name="email/suspension.html",
context={
"contact_name": school.contact_name or school.name,
"school_name": school.name,
"invoice_number": inv.invoice_number,
"amount": f"{float(inv.total_amount):,.2f}",
"due_date": str(inv.due_date),
},
email_type="suspension",
school_id=school.id,
)
db.commit()

View File

@@ -6,6 +6,7 @@ from app.worker import celery_app
logger = logging.getLogger(__name__)
@celery_app.task(name="license.check_expiry")
def check_expiry():
"""Send expiry warning emails for licenses expiring in 30, 14, or 7 days."""
@@ -34,7 +35,20 @@ def check_expiry():
send_email(
to=school.billing_email,
subject=f"[TapTrack Hub] License expires in {days_ahead} days — {school.name}",
body=f"Your TapTrack license for {school.name} expires on {lic.expires_at}. Please contact us to renew.",
body=(
f"Your TapTrack license for {school.name} expires on {lic.expires_at} "
f"({days_ahead} days remaining). Please contact us to renew."
),
template_name="email/license_expiry.html",
context={
"contact_name": school.contact_name or school.name,
"school_name": school.name,
"license_key": lic.key,
"expires_at": str(lic.expires_at),
"days_left": days_ahead,
},
email_type="license_expiry",
school_id=school.id,
)
db.commit()
finally:

View File

@@ -146,7 +146,20 @@ def send_low_credit_alert(school_id: str):
send_email(
to=school.billing_email,
subject=f"[TapTrack Hub] Low SMS Credits — {school.name}",
body=f"Your SMS credit balance for {school.name} is low ({float(school.sms_credits):.0f} remaining). Please top up to continue sending SMS notifications.",
body=(
f"Your SMS credit balance for {school.name} is low "
f"({float(school.sms_credits):.0f} credits remaining). "
f"Please top up to continue sending SMS notifications."
),
template_name="email/low_credit.html",
context={
"contact_name": school.contact_name or school.name,
"school_name": school.name,
"credits_remaining": int(float(school.sms_credits)),
"threshold": school.sms_credit_low_threshold,
},
email_type="low_credit",
school_id=school.id,
)
finally:
db.close()