Files
TapTrack-Hub/backend/app/models/email_log.py
kevin-asprec 1febb3cfa9 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
2026-03-16 14:03:02 +08:00

43 lines
1.6 KiB
Python

"""Email delivery log model."""
import uuid
import enum
from datetime import datetime, timezone
from sqlalchemy import String, DateTime, Enum as SAEnum, ForeignKey, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class EmailStatus(str, enum.Enum):
sent = "sent"
failed = "failed"
class EmailType(str, enum.Enum):
invoice = "invoice"
low_credit = "low_credit"
license_expiry = "license_expiry"
overdue_warning = "overdue_warning"
suspension = "suspension"
monthly_report = "monthly_report"
welcome = "welcome"
test = "test"
other = "other"
class EmailLog(Base):
__tablename__ = "email_logs"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
school_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("schools.id", ondelete="SET NULL"), nullable=True, index=True
)
to_email: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
subject: Mapped[str] = mapped_column(String(500), nullable=False)
email_type: Mapped[EmailType] = mapped_column(SAEnum(EmailType), default=EmailType.other, nullable=False, index=True)
status: Mapped[EmailStatus] = mapped_column(SAEnum(EmailStatus), nullable=False, index=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)