diff --git a/.paul/ROADMAP.md b/.paul/ROADMAP.md
index 7319bda..32edcbd 100644
--- a/.paul/ROADMAP.md
+++ b/.paul/ROADMAP.md
@@ -19,8 +19,8 @@ SMS flow: On-prem TapTrack polls Hub every 30s → Hub queues SMS jobs → Hub s
## Current Milestone
**v1.0 — Foundation & Core Services**
-Status: Phase 8 complete — Phase 9 next
-Phases: 8 of 15 complete
+Status: Phase 9 complete — Phase 10 next
+Phases: 9 of 15 complete
---
@@ -36,7 +36,7 @@ Phases: 8 of 15 complete
| 6 | Super Admin Dashboard UI | 1 | ✅ Complete | 2026-03-16 |
| 7 | School Admin Portal UI | 1 | ✅ Complete | 2026-03-16 |
| 8 | Billing Engine + Invoice PDF | 1 | ✅ Complete | 2026-03-16 |
-| 9 | Email Dispatcher | TBD | Not started | — |
+| 9 | Email Dispatcher | 1 | ✅ Complete | 2026-03-16 |
| 10 | Support Ticket System | TBD | Not started | — |
| 11 | Monthly Report Generation | TBD | Not started | — |
| 12 | On-Prem Monthly Report Pull | TBD | Not started | — |
diff --git a/.paul/STATE.md b/.paul/STATE.md
index 2ed8227..088c3bb 100644
--- a/.paul/STATE.md
+++ b/.paul/STATE.md
@@ -3,16 +3,16 @@
## Current Position
Milestone: v1.0 — Foundation & Core Services
-Phase: 8 of 15 (Billing Engine + Invoice PDF — complete)
-Plan: Phase 8 complete — Phase 9 next
-Status: **Phase 8 applied — ready to begin Phase 9**
-Last activity: 2026-03-16 — Phase 8 complete (invoice PDF via Jinja2+WeasyPrint, mark-paid modal, overdue escalation with 7d warning + 30d suspension, bug fix in billing task)
+Phase: 9 of 15 (Email Dispatcher — complete)
+Plan: Phase 9 complete — Phase 10 next
+Status: **Phase 9 applied — ready to begin Phase 10**
+Last activity: 2026-03-16 — Phase 9 complete (HTML email templates, email_logs table, enhanced send_email with template rendering + delivery logging + retries, email logs UI + test-email)
## Loop Position
```
PLAN ──▶ APPLY ──▶ UNIFY
- · · · [No active plan — Phase 9 planning next]
+ · · · [No active plan — Phase 10 planning next]
```
## Progress
@@ -27,7 +27,7 @@ PLAN ──▶ APPLY ──▶ UNIFY
- Phase 6 (Super Admin Dashboard UI): [██████████] 100% ✓
- Phase 7 (School Admin Portal UI): [██████████] 100% ✓
- Phase 8 (Billing Engine + Invoice PDF): [██████████] 100% ✓
-- Phase 9 (Email Dispatcher): [░░░░░░░░░░] 0%
+- Phase 9 (Email Dispatcher): [██████████] 100% ✓
- Phase 10 (Support Ticket System): [░░░░░░░░░░] 0%
- Phase 11 (Monthly Report Generation): [░░░░░░░░░░] 0%
- Phase 12 (On-Prem Monthly Report Pull): [░░░░░░░░░░] 0%
@@ -37,14 +37,14 @@ PLAN ──▶ APPLY ──▶ UNIFY
## Next Action
-Run: `/paul:plan` for Phase 9 — Email Dispatcher
-Resume file: .paul/ROADMAP.md → Phase 9
+Run: `/paul:plan` for Phase 10 — Support Ticket System
+Resume file: .paul/ROADMAP.md → Phase 10
## Repo
Remote: TBD (new Gitea repo)
Branch: master
-Last commit: feat(phase-8): billing engine + invoice PDF + mark-paid + overdue escalation
+Last commit: feat(phase-9): email dispatcher — HTML templates, delivery log, test endpoint
## Tech Stack
diff --git a/.paul/phases/09-email-dispatcher/README.md b/.paul/phases/09-email-dispatcher/README.md
index 2079db8..3efc731 100644
--- a/.paul/phases/09-email-dispatcher/README.md
+++ b/.paul/phases/09-email-dispatcher/README.md
@@ -1,60 +1,52 @@
# Phase 09: Email Dispatcher
-**Status:** Not started
+**Status:** Complete
+**Completed:** 2026-03-16
**Depends on:** Phase 8 (Billing Engine)
## Goal
-Ensure all automated emails (invoices, low credits, license expiry, monthly reports, welcome)
-send correctly via SMTP with proper HTML templates. Add an email delivery log visible to
-super admin, and a test-email endpoint to verify SMTP config.
+All automated emails send via HTML templates with consistent branding. Every send attempt
+is logged to `email_logs`. Super admin can view the delivery log and send a test email
+to verify SMTP config.
-## Planned Scope
+## What was built
### Backend
-**Jinja2 HTML email templates** (`backend/app/templates/email/`)
-- `invoice.html` — branded invoice notification (already plain text in Phase 8; upgrade to HTML)
-- `low_credit_alert.html` — low credit warning with credit meter visual
-- `license_expiry.html` — license expiry countdown with days remaining
-- `monthly_report.html` — monthly report summary email (stub, filled in Phase 11)
-- `welcome.html` — welcome email with license key + setup instructions (used in Phase 14)
-- Base layout template with consistent header/footer
+**`app/models/email_log.py`** (new)
+- `EmailLog` table: id, school_id (nullable FK), to_email, subject, email_type, status (sent/failed), error_message, sent_at, created_at
-**`email_logs` table** (new model `app/models/email_log.py`)
-- `id`, `school_id` (nullable), `to_email`, `subject`, `email_type`, `status` (sent/failed),
- `sent_at`, `error_message`, `created_at`
+**`app/templates/email/`** (new — 6 files)
+- `base.html` — shared branded layout (header + footer)
+- `invoice.html` — invoice notification with amount + due date
+- `low_credit.html` — low credit warning with balance + threshold
+- `license_expiry.html` — license expiry countdown
+- `overdue_warning.html` — overdue invoice warning
+- `suspension.html` — account suspended notice
**`app/services/email.py`** — enhanced
-- Accept HTML template name + context instead of raw body string
-- Log every send attempt to `email_logs` table
-- Retry on transient failure (up to 3 attempts)
+- `send_email()` now accepts `template_name` + `context` for HTML rendering
+- Logs every attempt to `email_logs` via a sync session
+- Falls back to plain text if template not found
+- Retries up to 3 times on transient SMTP failure
-**`backend/app/routers/email.py`** (new)
-- `GET /api/email-logs` — super admin: paginated email delivery history with filters
-- `POST /api/email/test` — send a test email to verify SMTP config (super admin)
+**`app/routers/email.py`** (new)
+- `GET /api/email-logs` — paginated log with type/status/school filters (super admin)
+- `POST /api/email/test` — send test email to verify SMTP (super admin)
-**Update all tasks** that send email (billing, sms, license) to use HTML templates
+**Updated tasks** — all now pass `template_name` + `context` to `send_email()`:
+- `tasks/billing.py` — invoice email, overdue warning, suspension email
+- `tasks/sms.py` — low credit alert
+- `tasks/license.py` — expiry warning
### Frontend
-**EmailLogsPage or section** in super admin
-- Table: to, subject, type, status badge, sent_at, error message
-- Filter by email type + status
+**`EmailLogsPage.vue`** (new)
+- Table: to, subject, type badge, status badge, sent_at, error message (expandable)
+- Filter by type + status; pagination
+- "Send Test Email" button → modal with address input
-**api.ts additions**
-- `getEmailLogs(params)` — list email logs
-- `sendTestEmail(to)` — trigger test email
-
-## Key Files to Create/Modify
-
-- `backend/app/models/email_log.py` (new)
-- `backend/app/templates/email/*.html` (new — 5 templates)
-- `backend/app/services/email.py` (enhance)
-- `backend/app/routers/email.py` (new)
-- `backend/app/main.py` — include email router
-- `backend/migrations/versions/002_email_logs.py` (new migration)
-- `frontend/src/pages/EmailLogsPage.vue` (new or section in existing page)
-- `frontend/src/lib/api.ts` — add email log functions
-- `frontend/src/router/index.ts` — add email logs route
-- `frontend/src/components/sidebar/AppSidebar.vue` — add nav item
+**Router** — `/email-logs` route added (super admin)
+**AppSidebar** — "Email Logs" nav item added
+**api.ts** — `getEmailLogs(params)`, `sendTestEmail(to)`
diff --git a/backend/app/main.py b/backend/app/main.py
index 8230751..dedd27d 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -5,10 +5,10 @@ from fastapi.middleware.cors import CORSMiddleware
from app.database import engine, Base
# Import all models so Alembic/SQLAlchemy picks them up
-from app.models import user, school, license, sms, billing, ticket, audit, announcement # noqa
+from app.models import user, school, license, sms, billing, ticket, audit, announcement, email_log # noqa
from app.routers import auth, schools, licenses, sms as sms_router, billing as billing_router
-from app.routers import tickets, users, dashboard, school_portal, announcements, sync
+from app.routers import tickets, users, dashboard, school_portal, announcements, sync, email as email_router
@asynccontextmanager
async def lifespan(app: FastAPI):
@@ -44,6 +44,7 @@ app.include_router(dashboard.router)
app.include_router(school_portal.router)
app.include_router(announcements.router)
app.include_router(sync.router)
+app.include_router(email_router.router)
@app.get("/api/health")
async def health():
diff --git a/backend/app/models/email_log.py b/backend/app/models/email_log.py
new file mode 100644
index 0000000..83db06d
--- /dev/null
+++ b/backend/app/models/email_log.py
@@ -0,0 +1,42 @@
+"""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)
+ )
diff --git a/backend/app/routers/email.py b/backend/app/routers/email.py
new file mode 100644
index 0000000..6cee9fd
--- /dev/null
+++ b/backend/app/routers/email.py
@@ -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=(
+ "
"
+ "
TapTrack Hub — SMTP Test "
+ "
This is a test email from TapTrack Hub .
"
+ "
If you received this, your SMTP configuration is working correctly.
"
+ "
TapTrack Hub Team
"
+ "
"
+ ),
+ 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"}
diff --git a/backend/app/services/email.py b/backend/app/services/email.py
index 42c4c1c..ca2c4ec 100644
--- a/backend/app/services/email.py
+++ b/backend/app/services/email.py
@@ -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
diff --git a/backend/app/tasks/billing.py b/backend/app/tasks/billing.py
index b870dbb..131ee2a 100644
--- a/backend/app/tasks/billing.py
+++ b/backend/app/tasks/billing.py
@@ -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()
diff --git a/backend/app/tasks/license.py b/backend/app/tasks/license.py
index 6f5eee7..6d76287 100644
--- a/backend/app/tasks/license.py
+++ b/backend/app/tasks/license.py
@@ -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:
diff --git a/backend/app/tasks/sms.py b/backend/app/tasks/sms.py
index 80230f1..8dcc6d8 100644
--- a/backend/app/tasks/sms.py
+++ b/backend/app/tasks/sms.py
@@ -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()
diff --git a/backend/app/templates/email/base.html b/backend/app/templates/email/base.html
new file mode 100644
index 0000000..3887f24
--- /dev/null
+++ b/backend/app/templates/email/base.html
@@ -0,0 +1,45 @@
+
+
+
+
+
+{{ subject }}
+
+
+
+
+
+
+ {% block content %}{% endblock %}
+
+
+
+
+
diff --git a/backend/app/templates/email/invoice.html b/backend/app/templates/email/invoice.html
new file mode 100644
index 0000000..6d85a7d
--- /dev/null
+++ b/backend/app/templates/email/invoice.html
@@ -0,0 +1,20 @@
+{% extends "email/base.html" %}
+{% block content %}
+Invoice {{ invoice_number }}
+Dear {{ contact_name }},
+Your invoice for {{ school_name }} has been generated for the period
+{{ period_start }} to {{ period_end }} .
+
+
+
Invoice Number {{ invoice_number }}
+
Billing Period {{ period_start }} — {{ period_end }}
+
Amount Due PHP {{ amount }}
+
Due Date {{ due_date }}
+
+
+Please log in to your school portal to view and download your invoice.
+View Invoice
+
+If you have any questions, please contact us at support@taptrack.io .
+Thank you,TapTrack Hub Team
+{% endblock %}
diff --git a/backend/app/templates/email/license_expiry.html b/backend/app/templates/email/license_expiry.html
new file mode 100644
index 0000000..3786f13
--- /dev/null
+++ b/backend/app/templates/email/license_expiry.html
@@ -0,0 +1,23 @@
+{% extends "email/base.html" %}
+{% block content %}
+License Expiring in {{ days_left }} Days
+Dear {{ contact_name }},
+
+
+ Your TapTrack license for {{ school_name }} expires on {{ expires_at }}.
+
+
+
+
School {{ school_name }}
+
License Key {{ license_key }}
+
Expires On {{ expires_at }}
+
Days Remaining {{ days_left }} days
+
+
+When your license expires, SMS notifications will be disabled and your on-prem TapTrack
+instance will enter read-only mode. Please contact us to renew your license before it expires.
+
+Contact Us to Renew
+
+Thank you,TapTrack Hub Team
+{% endblock %}
diff --git a/backend/app/templates/email/low_credit.html b/backend/app/templates/email/low_credit.html
new file mode 100644
index 0000000..e79c84f
--- /dev/null
+++ b/backend/app/templates/email/low_credit.html
@@ -0,0 +1,23 @@
+{% extends "email/base.html" %}
+{% block content %}
+Low SMS Credits — Action Required
+Dear {{ contact_name }},
+
+
+ Your SMS credit balance for {{ school_name }} is running low.
+
+
+
+
School {{ school_name }}
+
Current Balance {{ credits_remaining }} credits
+
Low Credit Threshold {{ threshold }} credits
+
+
+SMS notifications to parents and guardians will stop working when your credit balance reaches zero.
+Please top up your credits to ensure uninterrupted service.
+
+Request Credit Top-Up
+
+If you need assistance, contact us at support@taptrack.io .
+Thank you,TapTrack Hub Team
+{% endblock %}
diff --git a/backend/app/templates/email/overdue_warning.html b/backend/app/templates/email/overdue_warning.html
new file mode 100644
index 0000000..6a95921
--- /dev/null
+++ b/backend/app/templates/email/overdue_warning.html
@@ -0,0 +1,26 @@
+{% extends "email/base.html" %}
+{% block content %}
+Overdue Invoice — Immediate Action Required
+Dear {{ contact_name }},
+
+
+ Invoice {{ invoice_number }} is overdue. Please settle this immediately to avoid account suspension.
+
+
+
+
Invoice Number {{ invoice_number }}
+
Amount PHP {{ amount }}
+
Was Due On {{ due_date }}
+
Days Overdue {{ days_overdue }} days
+
+
+If this invoice is not settled within {{ days_until_suspension }} days , your TapTrack
+account will be automatically suspended. This will disable SMS notifications for your school.
+
+View & Settle Invoice
+
+If you believe this is an error or need to discuss payment arrangements, please contact us
+at support@taptrack.io immediately.
+
+Thank you,TapTrack Hub Team
+{% endblock %}
diff --git a/backend/app/templates/email/suspension.html b/backend/app/templates/email/suspension.html
new file mode 100644
index 0000000..af8c057
--- /dev/null
+++ b/backend/app/templates/email/suspension.html
@@ -0,0 +1,32 @@
+{% extends "email/base.html" %}
+{% block content %}
+Account Suspended
+Dear {{ contact_name }},
+
+
+ Your TapTrack account for {{ school_name }} has been suspended due to an unpaid invoice.
+
+
+
+
School {{ school_name }}
+
Invoice {{ invoice_number }}
+
Amount PHP {{ amount }}
+
Original Due Date {{ due_date }}
+
+
+The following services are now disabled :
+
+ SMS notifications to parents and guardians
+ Automated monthly reports
+ License validation (on-prem may enter warning mode)
+
+
+Attendance recording on your on-prem TapTrack instance continues to function.
+
+To restore full service, please settle the overdue invoice immediately and contact us at
+support@taptrack.io to lift the suspension.
+
+Contact Support to Restore
+
+Thank you,TapTrack Hub Team
+{% endblock %}
diff --git a/backend/migrations/env.py b/backend/migrations/env.py
index 49f564b..ce02797 100644
--- a/backend/migrations/env.py
+++ b/backend/migrations/env.py
@@ -10,7 +10,7 @@ if config.config_file_name is not None:
fileConfig(config.config_file_name)
from app.database import Base
-from app.models import user, school, license, sms, billing, ticket, audit, announcement # noqa
+from app.models import user, school, license, sms, billing, ticket, audit, announcement, email_log # noqa
target_metadata = Base.metadata
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://postgres:postgres@db:5432/taptrack_hub")
diff --git a/backend/migrations/versions/002_phase9_email_logs.py b/backend/migrations/versions/002_phase9_email_logs.py
new file mode 100644
index 0000000..4d0ea33
--- /dev/null
+++ b/backend/migrations/versions/002_phase9_email_logs.py
@@ -0,0 +1,38 @@
+"""phase9: add email_logs table
+
+Revision ID: 002_phase9
+Revises: 001_phase5
+Create Date: 2026-03-16
+
+Stores every email send attempt for delivery monitoring.
+"""
+from alembic import op
+import sqlalchemy as sa
+
+revision = "002_phase9"
+down_revision = "001_phase5"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "email_logs",
+ sa.Column("id", sa.String(36), primary_key=True),
+ sa.Column("school_id", sa.String(36), sa.ForeignKey("schools.id", ondelete="SET NULL"), nullable=True),
+ sa.Column("to_email", sa.String(255), nullable=False),
+ sa.Column("subject", sa.String(500), nullable=False),
+ sa.Column("email_type", sa.String(30), nullable=False),
+ sa.Column("status", sa.String(10), nullable=False),
+ sa.Column("error_message", sa.Text, nullable=True),
+ sa.Column("sent_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
+ )
+ op.create_index("ix_email_logs_school_id", "email_logs", ["school_id"])
+ op.create_index("ix_email_logs_to_email", "email_logs", ["to_email"])
+ op.create_index("ix_email_logs_email_type", "email_logs", ["email_type"])
+ op.create_index("ix_email_logs_status", "email_logs", ["status"])
+
+
+def downgrade() -> None:
+ op.drop_table("email_logs")
diff --git a/frontend/src/components/sidebar/AppSidebar.vue b/frontend/src/components/sidebar/AppSidebar.vue
index bcfcdc5..0c3b7d4 100644
--- a/frontend/src/components/sidebar/AppSidebar.vue
+++ b/frontend/src/components/sidebar/AppSidebar.vue
@@ -21,7 +21,7 @@
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 4f7f929..3a105e2 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -99,6 +99,10 @@ export const getAnnouncements = () => api.get('/announcements').then(r => r.data
export const createAnnouncement = (data: object) => api.post('/announcements', data).then(r => r.data)
export const deleteAnnouncement = (id: string) => api.delete(`/announcements/${id}`)
+// ── Email Logs ────────────────────────────────────────────────────────────────
+export const getEmailLogs = (params?: object) => api.get('/email/logs', { params }).then(r => r.data)
+export const sendTestEmail = (to: string) => api.post('/email/test', { to }).then(r => r.data)
+
// ── School Portal ─────────────────────────────────────────────────────────────
export const getPortalOverview = () => api.get('/portal/overview').then(r => r.data)
export const getPortalSmsStats = (params?: object) => api.get('/portal/sms-stats', { params }).then(r => r.data)
diff --git a/frontend/src/pages/EmailLogsPage.vue b/frontend/src/pages/EmailLogsPage.vue
new file mode 100644
index 0000000..8df865f
--- /dev/null
+++ b/frontend/src/pages/EmailLogsPage.vue
@@ -0,0 +1,220 @@
+
+
+
+
+
+
+
Email Logs
+
All automated email delivery history
+
+
+
+ Send Test Email
+
+
+
+
+
+
+ All Types
+ Invoice
+ Low Credit
+ License Expiry
+ Overdue Warning
+ Suspension
+ Monthly Report
+ Welcome
+ Test
+ Other
+
+
+ All Statuses
+ Sent
+ Failed
+
+
+
+
+
+
+
+
Delivery Log
+ {{ total }} record{{ total !== 1 ? 's' : '' }}
+
+
+
Loading…
+
+
+
+
No email records found
+
Try a different filter
+
+
+
+
+
+ To
+ Subject
+ Type
+ Status
+ Sent At
+ Error
+
+
+
+
+ {{ log.to_email }}
+
+ {{ log.subject }}
+
+
+
+ {{ log.email_type.replace('_', ' ') }}
+
+
+
+
+
+
+ {{ log.status }}
+
+
+
+ {{ log.sent_at ? new Date(log.sent_at).toLocaleString('en-PH') : '—' }}
+
+
+
+ {{ log.error_message }}
+
+ —
+
+
+
+
+
+
+
+
+ Showing {{ (page - 1) * perPage + 1 }}–{{ Math.min(page * perPage, total) }} of {{ total }}
+
+
+ Prev
+ Next
+
+
+
+
+
+
+
+
Send Test Email
+
Verify your SMTP configuration is working correctly.
+
+
+ Recipient Email
+
+
+
+ {{ testResult }}
+
+
+
+
+ Close
+
+
+ {{ sendingTest ? 'Sending…' : 'Send Test' }}
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
index 4c1894a..78ebf95 100644
--- a/frontend/src/router/index.ts
+++ b/frontend/src/router/index.ts
@@ -71,6 +71,12 @@ const router = createRouter({
component: () => import('@/pages/AnnouncementsPage.vue'),
meta: { requiresAuth: true, layout: 'app', role: 'super_admin' },
},
+ {
+ path: '/email-logs',
+ name: 'email-logs',
+ component: () => import('@/pages/EmailLogsPage.vue'),
+ meta: { requiresAuth: true, layout: 'app', role: 'super_admin' },
+ },
// School Portal routes
{
path: '/portal',