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:
@@ -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():
|
||||
|
||||
42
backend/app/models/email_log.py
Normal file
42
backend/app/models/email_log.py
Normal 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)
|
||||
)
|
||||
94
backend/app/routers/email.py
Normal file
94
backend/app/routers/email.py
Normal 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"}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
45
backend/app/templates/email/base.html
Normal file
45
backend/app/templates/email/base.html
Normal 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 · <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>
|
||||
20
backend/app/templates/email/invoice.html
Normal file
20
backend/app/templates/email/invoice.html
Normal 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 %}
|
||||
23
backend/app/templates/email/license_expiry.html
Normal file
23
backend/app/templates/email/license_expiry.html
Normal 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 %}
|
||||
23
backend/app/templates/email/low_credit.html
Normal file
23
backend/app/templates/email/low_credit.html
Normal 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 %}
|
||||
26
backend/app/templates/email/overdue_warning.html
Normal file
26
backend/app/templates/email/overdue_warning.html
Normal 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 & 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 %}
|
||||
32
backend/app/templates/email/suspension.html
Normal file
32
backend/app/templates/email/suspension.html
Normal 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 %}
|
||||
@@ -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")
|
||||
|
||||
38
backend/migrations/versions/002_phase9_email_logs.py
Normal file
38
backend/migrations/versions/002_phase9_email_logs.py
Normal file
@@ -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")
|
||||
Reference in New Issue
Block a user