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

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

View File

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

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

View File

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