From 4da26e7ef1ccdcc9de3c143e9b9941777236ac27 Mon Sep 17 00:00:00 2001 From: Michilis Date: Thu, 12 Mar 2026 19:13:24 +0000 Subject: [PATCH] feat(emails): add re-send for all emails, failed tab, and resend indicators - Add resend_attempts and last_resent_at to email_logs schema and migrations - Add POST /api/emails/logs/:id/resend and emailService.resendFromLog - Add resendLog API and EmailLog.resendAttempts/lastResentAt - Add All/Failed sub-tabs, resend button for all emails, re-sent indicator in logs and detail modal Made-with: Cursor --- backend/src/db/migrate.ts | 14 +++ backend/src/db/schema.ts | 4 + backend/src/lib/email.ts | 55 ++++++++++++ backend/src/routes/emails.ts | 17 ++++ frontend/src/app/admin/emails/page.tsx | 115 ++++++++++++++++++++++--- frontend/src/lib/api.ts | 9 +- 6 files changed, 203 insertions(+), 11 deletions(-) diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index dff9ad9..675c5f5 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -368,6 +368,13 @@ async function migrate() { ) `); + try { + await (db as any).run(sql`ALTER TABLE email_logs ADD COLUMN resend_attempts INTEGER NOT NULL DEFAULT 0`); + } catch (e) { /* column may already exist */ } + try { + await (db as any).run(sql`ALTER TABLE email_logs ADD COLUMN last_resent_at TEXT`); + } catch (e) { /* column may already exist */ } + await (db as any).run(sql` CREATE TABLE IF NOT EXISTS email_settings ( id TEXT PRIMARY KEY, @@ -772,6 +779,13 @@ async function migrate() { ) `); + try { + await (db as any).execute(sql`ALTER TABLE email_logs ADD COLUMN resend_attempts INTEGER NOT NULL DEFAULT 0`); + } catch (e) { /* column may already exist */ } + try { + await (db as any).execute(sql`ALTER TABLE email_logs ADD COLUMN last_resent_at TIMESTAMP`); + } catch (e) { /* column may already exist */ } + await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS email_settings ( id UUID PRIMARY KEY, diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 7eb2d12..739fd92 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -243,6 +243,8 @@ export const sqliteEmailLogs = sqliteTable('email_logs', { sentAt: text('sent_at'), sentBy: text('sent_by').references(() => sqliteUsers.id), createdAt: text('created_at').notNull(), + resendAttempts: integer('resend_attempts').notNull().default(0), + lastResentAt: text('last_resent_at'), }); export const sqliteEmailSettings = sqliteTable('email_settings', { @@ -557,6 +559,8 @@ export const pgEmailLogs = pgTable('email_logs', { sentAt: timestamp('sent_at'), sentBy: uuid('sent_by').references(() => pgUsers.id), createdAt: timestamp('created_at').notNull(), + resendAttempts: pgInteger('resend_attempts').notNull().default(0), + lastResentAt: timestamp('last_resent_at'), }); export const pgEmailSettings = pgTable('email_settings', { diff --git a/backend/src/lib/email.ts b/backend/src/lib/email.ts index 39e4c58..f591580 100644 --- a/backend/src/lib/email.ts +++ b/backend/src/lib/email.ts @@ -1342,6 +1342,61 @@ export const emailService = { error: result.error }; }, + + /** + * Resend an email from an existing log entry + */ + async resendFromLog(logId: string): Promise<{ success: boolean; error?: string }> { + const log = await dbGet( + (db as any).select().from(emailLogs).where(eq((emailLogs as any).id, logId)) + ); + + if (!log) { + return { success: false, error: 'Email log not found' }; + } + + if (!log.bodyHtml || !log.subject || !log.recipientEmail) { + return { success: false, error: 'Email log missing required data to resend' }; + } + + const result = await sendEmail({ + to: log.recipientEmail, + subject: log.subject, + html: log.bodyHtml, + text: undefined, + }); + + const now = getNow(); + const currentResendAttempts = (log.resendAttempts ?? 0) + 1; + + if (result.success) { + await (db as any) + .update(emailLogs) + .set({ + status: 'sent', + sentAt: now, + errorMessage: null, + resendAttempts: currentResendAttempts, + lastResentAt: now, + }) + .where(eq((emailLogs as any).id, logId)); + } else { + await (db as any) + .update(emailLogs) + .set({ + status: 'failed', + errorMessage: result.error, + resendAttempts: currentResendAttempts, + lastResentAt: now, + }) + .where(eq((emailLogs as any).id, logId)); + } + + return { + success: result.success, + error: result.error, + }; + }, }; // Export the main sendEmail function for direct use diff --git a/backend/src/routes/emails.ts b/backend/src/routes/emails.ts index 058e2ba..91f8406 100644 --- a/backend/src/routes/emails.ts +++ b/backend/src/routes/emails.ts @@ -349,6 +349,23 @@ emailsRouter.get('/logs/:id', requireAuth(['admin', 'organizer']), async (c) => return c.json({ log }); }); +// Resend email from log +emailsRouter.post('/logs/:id/resend', requireAuth(['admin', 'organizer']), async (c) => { + const { id } = c.req.param(); + + const result = await emailService.resendFromLog(id); + + if (!result.success && result.error === 'Email log not found') { + return c.json({ error: 'Email log not found' }, 404); + } + + if (!result.success && result.error === 'Email log missing required data to resend') { + return c.json({ error: result.error }, 400); + } + + return c.json({ success: result.success, error: result.error }); +}); + // Get email stats emailsRouter.get('/stats', requireAuth(['admin', 'organizer']), async (c) => { const eventId = c.req.query('eventId'); diff --git a/frontend/src/app/admin/emails/page.tsx b/frontend/src/app/admin/emails/page.tsx index 5da81d8..426d928 100644 --- a/frontend/src/app/admin/emails/page.tsx +++ b/frontend/src/app/admin/emails/page.tsx @@ -20,6 +20,7 @@ import { ChevronLeftIcon, ChevronRightIcon, XMarkIcon, + ArrowPathIcon, } from '@heroicons/react/24/outline'; import toast from 'react-hot-toast'; import clsx from 'clsx'; @@ -52,6 +53,8 @@ export default function AdminEmailsPage() { const [logs, setLogs] = useState([]); const [logsOffset, setLogsOffset] = useState(0); const [logsTotal, setLogsTotal] = useState(0); + const [logsSubTab, setLogsSubTab] = useState<'all' | 'failed'>('all'); + const [resendingLogId, setResendingLogId] = useState(null); const [selectedLog, setSelectedLog] = useState(null); // Stats state @@ -214,7 +217,7 @@ export default function AdminEmailsPage() { if (activeTab === 'logs') { loadLogs(); } - }, [activeTab, logsOffset]); + }, [activeTab, logsOffset, logsSubTab]); const loadData = async () => { try { @@ -233,7 +236,11 @@ export default function AdminEmailsPage() { const loadLogs = async () => { try { - const res = await emailsApi.getLogs({ limit: 20, offset: logsOffset }); + const res = await emailsApi.getLogs({ + limit: 20, + offset: logsOffset, + ...(logsSubTab === 'failed' ? { status: 'failed' } : {}), + }); setLogs(res.logs); setLogsTotal(res.pagination.total); } catch (error) { @@ -241,6 +248,27 @@ export default function AdminEmailsPage() { } }; + const handleResend = async (log: EmailLog) => { + setResendingLogId(log.id); + try { + const res = await emailsApi.resendLog(log.id); + if (res.success) { + toast.success('Email re-sent successfully'); + } else { + toast.error(res.error || 'Failed to re-send email'); + } + await loadLogs(); + if (selectedLog?.id === log.id) { + const { log: updatedLog } = await emailsApi.getLog(log.id); + setSelectedLog(updatedLog); + } + } catch (error: any) { + toast.error(error.message || 'Failed to re-send email'); + } finally { + setResendingLogId(null); + } + }; + const resetTemplateForm = () => { setTemplateForm({ name: '', @@ -699,6 +727,35 @@ export default function AdminEmailsPage() { {/* Logs Tab */} {activeTab === 'logs' && (
+ {/* Sub-tabs: All | Failed */} +
+ +
+ {/* Desktop: Table */}
@@ -714,12 +771,17 @@ export default function AdminEmailsPage() { {logs.length === 0 ? ( - No emails sent yet + {logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'} ) : ( logs.map((log) => ( -
{getStatusIcon(log.status)}{log.status}
+
+
{getStatusIcon(log.status)}{log.status}
+ {(log.resendAttempts ?? 0) > 0 && ( + Re-sent {log.resendAttempts} time{(log.resendAttempts ?? 0) !== 1 ? 's' : ''} + )} +

{log.recipientName || 'Unknown'}

@@ -728,7 +790,15 @@ export default function AdminEmailsPage() {

{log.subject}

{formatDate(log.sentAt || log.createdAt)} -
+
+ @@ -758,7 +828,7 @@ export default function AdminEmailsPage() { {/* Mobile: Card List */}
{logs.length === 0 ? ( -
No emails sent yet
+
{logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'}
) : ( logs.map((log) => ( setSelectedLog(log)}> @@ -768,7 +838,18 @@ export default function AdminEmailsPage() {

{log.subject}

{log.recipientName || 'Unknown'} <{log.recipientEmail}>

{formatDate(log.sentAt || log.createdAt)}

+ {(log.resendAttempts ?? 0) > 0 && ( +

Re-sent {log.resendAttempts} time{(log.resendAttempts ?? 0) !== 1 ? 's' : ''}

+ )}
+
)) @@ -938,12 +1019,26 @@ export default function AdminEmailsPage() { {selectedLog.errorMessage && ( - {selectedLog.errorMessage} )} + {(selectedLog.resendAttempts ?? 0) > 0 && ( + Re-sent {selectedLog.resendAttempts} time{(selectedLog.resendAttempts ?? 0) !== 1 ? 's' : ''}{selectedLog.lastResentAt ? ` (${formatDate(selectedLog.lastResentAt)})` : ''} + )}
- +
+ + +

To: {selectedLog.recipientName} <{selectedLog.recipientEmail}>

diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index c59f3c2..617fc6b 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -492,7 +492,12 @@ export const emailsApi = { }, getLog: (id: string) => fetchApi<{ log: EmailLog }>(`/api/emails/logs/${id}`), - + + resendLog: (id: string) => + fetchApi<{ success: boolean; error?: string }>(`/api/emails/logs/${id}/resend`, { + method: 'POST', + }), + getStats: (eventId?: string) => { const query = eventId ? `?eventId=${eventId}` : ''; return fetchApi<{ stats: EmailStats }>(`/api/emails/stats${query}`); @@ -792,6 +797,8 @@ export interface EmailLog { sentAt?: string; sentBy?: string; createdAt: string; + resendAttempts?: number; + lastResentAt?: string; } export interface EmailStats {