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

@@ -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():

View File

@@ -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)
)

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

View File

@@ -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

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()

View File

@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ subject }}</title>
<style>
body { margin: 0; padding: 0; background: #f1f5f9; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; }
.wrapper { max-width: 600px; margin: 32px auto; background: #ffffff; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 12px rgba(0,0,0,0.08); }
.header { background: #1e40af; padding: 24px 32px; }
.header h1 { color: #ffffff; font-size: 18px; font-weight: 700; margin: 0; letter-spacing: -0.3px; }
.header p { color: #93c5fd; font-size: 12px; margin: 4px 0 0; }
.body { padding: 32px; color: #334155; font-size: 14px; line-height: 1.7; }
.body h2 { color: #0f172a; font-size: 18px; font-weight: 700; margin: 0 0 16px; }
.body p { margin: 0 0 14px; }
.body a { color: #2563eb; }
.info-box { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px 20px; margin: 20px 0; }
.info-box .row { display: flex; justify-content: space-between; padding: 5px 0; border-bottom: 1px solid #f1f5f9; font-size: 13px; }
.info-box .row:last-child { border-bottom: none; }
.info-box .label { color: #64748b; }
.info-box .value { font-weight: 600; color: #0f172a; }
.btn { display: inline-block; background: #2563eb; color: #ffffff !important; text-decoration: none; padding: 10px 24px; border-radius: 8px; font-weight: 600; font-size: 13px; margin: 8px 0; }
.alert-red { background: #fef2f2; border: 1px solid #fecaca; border-radius: 8px; padding: 14px 18px; margin: 20px 0; color: #991b1b; font-size: 13px; }
.alert-amber { background: #fffbeb; border: 1px solid #fde68a; border-radius: 8px; padding: 14px 18px; margin: 20px 0; color: #92400e; font-size: 13px; }
.alert-green { background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 8px; padding: 14px 18px; margin: 20px 0; color: #14532d; font-size: 13px; }
.footer { background: #f8fafc; border-top: 1px solid #e2e8f0; padding: 20px 32px; text-align: center; color: #94a3b8; font-size: 12px; }
.footer a { color: #64748b; }
</style>
</head>
<body>
<div class="wrapper">
<div class="header">
<h1>TapTrack Hub</h1>
<p>Cloud Control Plane for TapTrack Deployments</p>
</div>
<div class="body">
{% block content %}{% endblock %}
</div>
<div class="footer">
<p>TapTrack Hub &nbsp;·&nbsp; <a href="mailto:support@taptrack.io">support@taptrack.io</a></p>
<p style="margin-top:4px">You are receiving this email because you are registered as a school administrator.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,20 @@
{% extends "email/base.html" %}
{% block content %}
<h2>Invoice {{ invoice_number }}</h2>
<p>Dear {{ contact_name }},</p>
<p>Your invoice for <strong>{{ school_name }}</strong> has been generated for the period
<strong>{{ period_start }}</strong> to <strong>{{ period_end }}</strong>.</p>
<div class="info-box">
<div class="row"><span class="label">Invoice Number</span><span class="value">{{ invoice_number }}</span></div>
<div class="row"><span class="label">Billing Period</span><span class="value">{{ period_start }} — {{ period_end }}</span></div>
<div class="row"><span class="label">Amount Due</span><span class="value">PHP {{ amount }}</span></div>
<div class="row"><span class="label">Due Date</span><span class="value">{{ due_date }}</span></div>
</div>
<p>Please log in to your school portal to view and download your invoice.</p>
<p><a class="btn" href="{{ portal_url }}/portal/billing">View Invoice</a></p>
<p>If you have any questions, please contact us at <a href="mailto:support@taptrack.io">support@taptrack.io</a>.</p>
<p>Thank you,<br><strong>TapTrack Hub Team</strong></p>
{% endblock %}

View File

@@ -0,0 +1,23 @@
{% extends "email/base.html" %}
{% block content %}
<h2>License Expiring in {{ days_left }} Days</h2>
<p>Dear {{ contact_name }},</p>
<div class="alert-amber">
<strong>Your TapTrack license for {{ school_name }} expires on {{ expires_at }}.</strong>
</div>
<div class="info-box">
<div class="row"><span class="label">School</span><span class="value">{{ school_name }}</span></div>
<div class="row"><span class="label">License Key</span><span class="value" style="font-family:monospace;font-size:12px">{{ license_key }}</span></div>
<div class="row"><span class="label">Expires On</span><span class="value">{{ expires_at }}</span></div>
<div class="row"><span class="label">Days Remaining</span><span class="value" style="color:#b45309">{{ days_left }} days</span></div>
</div>
<p>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.</p>
<p><a class="btn" href="mailto:support@taptrack.io?subject=License Renewal — {{ school_name }}">Contact Us to Renew</a></p>
<p>Thank you,<br><strong>TapTrack Hub Team</strong></p>
{% endblock %}

View File

@@ -0,0 +1,23 @@
{% extends "email/base.html" %}
{% block content %}
<h2>Low SMS Credits — Action Required</h2>
<p>Dear {{ contact_name }},</p>
<div class="alert-amber">
<strong>Your SMS credit balance for {{ school_name }} is running low.</strong>
</div>
<div class="info-box">
<div class="row"><span class="label">School</span><span class="value">{{ school_name }}</span></div>
<div class="row"><span class="label">Current Balance</span><span class="value" style="color:#b45309">{{ credits_remaining }} credits</span></div>
<div class="row"><span class="label">Low Credit Threshold</span><span class="value">{{ threshold }} credits</span></div>
</div>
<p>SMS notifications to parents and guardians will stop working when your credit balance reaches zero.
Please top up your credits to ensure uninterrupted service.</p>
<p><a class="btn" href="{{ portal_url }}/portal/billing">Request Credit Top-Up</a></p>
<p>If you need assistance, contact us at <a href="mailto:support@taptrack.io">support@taptrack.io</a>.</p>
<p>Thank you,<br><strong>TapTrack Hub Team</strong></p>
{% endblock %}

View File

@@ -0,0 +1,26 @@
{% extends "email/base.html" %}
{% block content %}
<h2>Overdue Invoice — Immediate Action Required</h2>
<p>Dear {{ contact_name }},</p>
<div class="alert-red">
<strong>Invoice {{ invoice_number }} is overdue. Please settle this immediately to avoid account suspension.</strong>
</div>
<div class="info-box">
<div class="row"><span class="label">Invoice Number</span><span class="value">{{ invoice_number }}</span></div>
<div class="row"><span class="label">Amount</span><span class="value">PHP {{ amount }}</span></div>
<div class="row"><span class="label">Was Due On</span><span class="value" style="color:#dc2626">{{ due_date }}</span></div>
<div class="row"><span class="label">Days Overdue</span><span class="value" style="color:#dc2626">{{ days_overdue }} days</span></div>
</div>
<p>If this invoice is not settled within <strong>{{ days_until_suspension }} days</strong>, your TapTrack
account will be automatically suspended. This will disable SMS notifications for your school.</p>
<p><a class="btn" style="background:#dc2626" href="{{ portal_url }}/portal/billing">View &amp; Settle Invoice</a></p>
<p>If you believe this is an error or need to discuss payment arrangements, please contact us
at <a href="mailto:support@taptrack.io">support@taptrack.io</a> immediately.</p>
<p>Thank you,<br><strong>TapTrack Hub Team</strong></p>
{% endblock %}

View File

@@ -0,0 +1,32 @@
{% extends "email/base.html" %}
{% block content %}
<h2>Account Suspended</h2>
<p>Dear {{ contact_name }},</p>
<div class="alert-red">
<strong>Your TapTrack account for {{ school_name }} has been suspended due to an unpaid invoice.</strong>
</div>
<div class="info-box">
<div class="row"><span class="label">School</span><span class="value">{{ school_name }}</span></div>
<div class="row"><span class="label">Invoice</span><span class="value">{{ invoice_number }}</span></div>
<div class="row"><span class="label">Amount</span><span class="value">PHP {{ amount }}</span></div>
<div class="row"><span class="label">Original Due Date</span><span class="value">{{ due_date }}</span></div>
</div>
<p>The following services are now <strong>disabled</strong>:</p>
<ul style="margin:8px 0 16px;padding-left:20px;color:#334155">
<li>SMS notifications to parents and guardians</li>
<li>Automated monthly reports</li>
<li>License validation (on-prem may enter warning mode)</li>
</ul>
<p>Attendance recording on your on-prem TapTrack instance continues to function.</p>
<p>To restore full service, please settle the overdue invoice immediately and contact us at
<a href="mailto:support@taptrack.io">support@taptrack.io</a> to lift the suspension.</p>
<p><a class="btn" style="background:#dc2626" href="mailto:support@taptrack.io?subject=Account Suspension — {{ school_name }}">Contact Support to Restore</a></p>
<p>Thank you,<br><strong>TapTrack Hub Team</strong></p>
{% endblock %}