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