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:
@@ -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 | — |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)`
|
||||
|
||||
@@ -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")
|
||||
@@ -21,7 +21,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LayoutDashboard, Building2, KeyRound, MessageSquare, Receipt, Ticket, Users, Megaphone, Layers } from 'lucide-vue-next'
|
||||
import { LayoutDashboard, Building2, KeyRound, MessageSquare, Receipt, Ticket, Users, Megaphone, Layers, Mail } from 'lucide-vue-next'
|
||||
import SidebarItem from './SidebarItem.vue'
|
||||
|
||||
const navItems = [
|
||||
@@ -33,5 +33,6 @@ const navItems = [
|
||||
{ label: 'Support', to: '/tickets', icon: Ticket },
|
||||
{ label: 'Users', to: '/users', icon: Users },
|
||||
{ label: 'Announcements', to: '/announcements', icon: Megaphone },
|
||||
{ label: 'Email Logs', to: '/email-logs', icon: Mail },
|
||||
]
|
||||
</script>
|
||||
|
||||
@@ -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)
|
||||
|
||||
220
frontend/src/pages/EmailLogsPage.vue
Normal file
220
frontend/src/pages/EmailLogsPage.vue
Normal file
@@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-900">Email Logs</h1>
|
||||
<p class="text-sm text-slate-500 mt-0.5">All automated email delivery history</p>
|
||||
</div>
|
||||
<button @click="showTestModal = true"
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 transition-colors">
|
||||
<Send :size="15" />
|
||||
Send Test Email
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<select v-model="typeFilter"
|
||||
class="border border-slate-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">All Types</option>
|
||||
<option value="invoice">Invoice</option>
|
||||
<option value="low_credit">Low Credit</option>
|
||||
<option value="license_expiry">License Expiry</option>
|
||||
<option value="overdue_warning">Overdue Warning</option>
|
||||
<option value="suspension">Suspension</option>
|
||||
<option value="monthly_report">Monthly Report</option>
|
||||
<option value="welcome">Welcome</option>
|
||||
<option value="test">Test</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
<select v-model="statusFilter"
|
||||
class="border border-slate-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="sent">Sent</option>
|
||||
<option value="failed">Failed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="bg-white rounded-xl overflow-hidden" style="box-shadow:0 2px 8px #0000000A">
|
||||
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-slate-100">
|
||||
<h2 class="text-base font-semibold text-slate-900">Delivery Log</h2>
|
||||
<span class="text-xs text-slate-400">{{ total }} record{{ total !== 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="p-8 text-center text-sm text-slate-400 animate-pulse">Loading…</div>
|
||||
|
||||
<div v-else-if="logs.length === 0"
|
||||
class="flex flex-col items-center justify-center py-14 text-slate-400">
|
||||
<Mail :size="36" class="mb-3 opacity-30" />
|
||||
<p class="font-medium text-sm">No email records found</p>
|
||||
<p v-if="typeFilter || statusFilter" class="text-xs mt-1">Try a different filter</p>
|
||||
</div>
|
||||
|
||||
<table v-else class="w-full text-sm">
|
||||
<thead class="bg-slate-50 border-b border-slate-100">
|
||||
<tr class="text-left text-xs text-slate-500 font-semibold uppercase tracking-wide">
|
||||
<th class="px-5 py-3">To</th>
|
||||
<th class="px-5 py-3">Subject</th>
|
||||
<th class="px-5 py-3">Type</th>
|
||||
<th class="px-5 py-3">Status</th>
|
||||
<th class="px-5 py-3">Sent At</th>
|
||||
<th class="px-5 py-3">Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-50">
|
||||
<tr v-for="log in logs" :key="log.id"
|
||||
class="hover:bg-slate-50 transition-colors"
|
||||
:class="log.status === 'failed' ? 'bg-red-50/40' : ''">
|
||||
<td class="px-5 py-3 text-xs font-mono text-slate-700">{{ log.to_email }}</td>
|
||||
<td class="px-5 py-3 text-xs text-slate-700 max-w-xs">
|
||||
<span class="block truncate" :title="log.subject">{{ log.subject }}</span>
|
||||
</td>
|
||||
<td class="px-5 py-3">
|
||||
<span class="inline-block text-xs font-semibold px-2 py-0.5 rounded-full"
|
||||
:class="typeClass(log.email_type)">
|
||||
{{ log.email_type.replace('_', ' ') }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-5 py-3">
|
||||
<span class="inline-flex items-center gap-1 text-xs font-semibold"
|
||||
:class="log.status === 'sent' ? 'text-emerald-600' : 'text-red-500'">
|
||||
<CheckCircle v-if="log.status === 'sent'" :size="12" />
|
||||
<XCircle v-else :size="12" />
|
||||
{{ log.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-5 py-3 text-xs text-slate-500">
|
||||
{{ log.sent_at ? new Date(log.sent_at).toLocaleString('en-PH') : '—' }}
|
||||
</td>
|
||||
<td class="px-5 py-3 text-xs text-red-400 max-w-xs">
|
||||
<span v-if="log.error_message" class="block truncate" :title="log.error_message">
|
||||
{{ log.error_message }}
|
||||
</span>
|
||||
<span v-else class="text-slate-300">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="total > perPage" class="px-5 py-3 border-t border-slate-100 flex items-center justify-between text-sm text-slate-600">
|
||||
<span class="text-xs text-slate-400">
|
||||
Showing {{ (page - 1) * perPage + 1 }}–{{ Math.min(page * perPage, total) }} of {{ total }}
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<button :disabled="page <= 1" @click="page--; fetchLogs()"
|
||||
class="px-3 py-1.5 border border-slate-200 rounded-lg text-sm hover:bg-slate-50 disabled:opacity-40">Prev</button>
|
||||
<button :disabled="page * perPage >= total" @click="page++; fetchLogs()"
|
||||
class="px-3 py-1.5 border border-slate-200 rounded-lg text-sm hover:bg-slate-50 disabled:opacity-40">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test email modal -->
|
||||
<div v-if="showTestModal"
|
||||
class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"
|
||||
@click.self="showTestModal = false">
|
||||
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-sm p-6">
|
||||
<h2 class="text-lg font-bold text-slate-900 mb-1">Send Test Email</h2>
|
||||
<p class="text-sm text-slate-500 mb-5">Verify your SMTP configuration is working correctly.</p>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Recipient Email</label>
|
||||
<input v-model="testEmail" type="email"
|
||||
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="you@example.com" />
|
||||
</div>
|
||||
<p v-if="testResult" class="text-sm" :class="testResult.includes('Failed') ? 'text-red-500' : 'text-emerald-600'">
|
||||
{{ testResult }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button @click="showTestModal = false; testResult = ''"
|
||||
class="flex-1 px-4 py-2 border border-slate-200 rounded-lg text-sm text-slate-700 hover:bg-slate-50">
|
||||
Close
|
||||
</button>
|
||||
<button @click="doSendTest" :disabled="!testEmail || sendingTest"
|
||||
class="flex-1 px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50">
|
||||
{{ sendingTest ? 'Sending…' : 'Send Test' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { Send, Mail, CheckCircle, XCircle } from 'lucide-vue-next'
|
||||
import { getEmailLogs, sendTestEmail } from '@/lib/api'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const logs = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const perPage = 50
|
||||
const loading = ref(false)
|
||||
const typeFilter = ref('')
|
||||
const statusFilter = ref('')
|
||||
|
||||
const showTestModal = ref(false)
|
||||
const testEmail = ref('')
|
||||
const sendingTest = ref(false)
|
||||
const testResult = ref('')
|
||||
|
||||
async function fetchLogs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await getEmailLogs({
|
||||
page: page.value,
|
||||
per_page: perPage,
|
||||
email_type: typeFilter.value || undefined,
|
||||
status: statusFilter.value || undefined,
|
||||
})
|
||||
logs.value = r.items
|
||||
total.value = r.total
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
watch([typeFilter, statusFilter], () => { page.value = 1; fetchLogs() })
|
||||
|
||||
async function doSendTest() {
|
||||
sendingTest.value = true
|
||||
testResult.value = ''
|
||||
try {
|
||||
const res = await sendTestEmail(testEmail.value)
|
||||
testResult.value = res.message
|
||||
if (res.message.includes('successfully')) {
|
||||
toast.success('Test email sent')
|
||||
setTimeout(fetchLogs, 1500)
|
||||
}
|
||||
} catch (e: any) {
|
||||
testResult.value = e?.response?.data?.detail ?? 'Failed to send'
|
||||
toast.error(testResult.value)
|
||||
} finally { sendingTest.value = false }
|
||||
}
|
||||
|
||||
function typeClass(type: string): string {
|
||||
const map: Record<string, string> = {
|
||||
invoice: 'bg-blue-100 text-blue-700',
|
||||
low_credit: 'bg-amber-100 text-amber-700',
|
||||
license_expiry: 'bg-purple-100 text-purple-700',
|
||||
overdue_warning: 'bg-red-100 text-red-700',
|
||||
suspension: 'bg-red-100 text-red-800',
|
||||
monthly_report: 'bg-emerald-100 text-emerald-700',
|
||||
welcome: 'bg-green-100 text-green-700',
|
||||
test: 'bg-slate-100 text-slate-600',
|
||||
other: 'bg-slate-100 text-slate-500',
|
||||
}
|
||||
return map[type] ?? 'bg-slate-100 text-slate-500'
|
||||
}
|
||||
|
||||
onMounted(fetchLogs)
|
||||
</script>
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user