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 }} + + + +
+
+

TapTrack Hub

+

Cloud Control Plane for TapTrack Deployments

+
+
+ {% 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 DuePHP {{ 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 }}
+
AmountPHP {{ 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 }}
+
AmountPHP {{ amount }}
+
Original Due Date{{ due_date }}
+
+ +

The following services are now disabled:

+ + +

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 @@ + + + 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',