diff --git a/backend/src/routes/adminEmail.js b/backend/src/routes/adminEmail.js index 3283f1ac..acc37156 100644 --- a/backend/src/routes/adminEmail.js +++ b/backend/src/routes/adminEmail.js @@ -1,6 +1,6 @@ const express = require('express'); const nodemailer = require('nodemailer'); -const { body, validationResult } = require('express-validator'); +const { body, query, validationResult } = require('express-validator'); const { db, logActivity } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); @@ -268,6 +268,94 @@ router.post('/flush-queue', adminAuth, requirePermission('email.send'), async (r } }); +// Read-only "Sent emails" feed — paginated view of email_queue with +// filters (status, type, recipient search, date range). email_data is +// deliberately NOT returned (it can carry attachment paths / PII); the +// list only needs the envelope + delivery state. event_id is joined to +// events so the UI can link back to the source gallery when present. +router.get('/queue', adminAuth, requirePermission('email.view'), [ + query('status').optional({ values: 'falsy' }).isIn(['pending', 'sent', 'failed']), + query('emailType').optional({ values: 'falsy' }).isString().isLength({ max: 64 }), + query('q').optional({ values: 'falsy' }).isString().isLength({ max: 255 }), + query('from').optional({ values: 'falsy' }).isISO8601(), + query('to').optional({ values: 'falsy' }).isISO8601(), + query('page').optional({ values: 'falsy' }).isInt({ min: 1 }), + query('pageSize').optional({ values: 'falsy' }).isInt({ min: 1, max: 100 }), +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const page = req.query.page ? parseInt(req.query.page, 10) : 1; + const pageSize = req.query.pageSize ? parseInt(req.query.pageSize, 10) : 25; + + const applyFilters = (qb) => { + if (req.query.status) qb.where('email_queue.status', req.query.status); + if (req.query.emailType) qb.where('email_queue.email_type', req.query.emailType); + if (req.query.from) qb.where('email_queue.created_at', '>=', new Date(req.query.from)); + if (req.query.to) qb.where('email_queue.created_at', '<=', new Date(req.query.to)); + if (req.query.q) { + const term = `%${String(req.query.q).trim()}%`; + qb.where(function () { + this.where('email_queue.recipient_email', 'like', term) + .orWhere('email_queue.email_type', 'like', term); + }); + } + return qb; + }; + + const [{ count }] = await applyFilters(db('email_queue')).count({ count: '*' }); + const total = parseInt(count, 10) || 0; + + const rows = await applyFilters( + db('email_queue') + .leftJoin('events', 'events.id', 'email_queue.event_id') + .select( + 'email_queue.id', + 'email_queue.recipient_email', + 'email_queue.email_type', + 'email_queue.status', + 'email_queue.created_at', + 'email_queue.scheduled_at', + 'email_queue.sent_at', + 'email_queue.error_message', + 'email_queue.retry_count', + 'email_queue.event_id', + 'events.event_name as event_name', + 'events.slug as event_slug' + ) + ) + .orderBy('email_queue.created_at', 'desc') + .limit(pageSize) + .offset((page - 1) * pageSize); + + const items = rows.map((r) => ({ + id: r.id, + recipientEmail: r.recipient_email, + emailType: r.email_type, + status: r.status, + createdAt: r.created_at, + scheduledAt: r.scheduled_at, + sentAt: r.sent_at, + errorMessage: r.error_message, + retryCount: r.retry_count, + eventId: r.event_id, + eventName: r.event_name || null, + eventSlug: r.event_slug || null, + })); + + res.json({ + items, + pagination: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) || 1 }, + }); + } catch (error) { + console.error('List email queue error:', error); + res.status(500).json({ error: 'Failed to load email queue', details: error.message }); + } +}); + // Helper: parse variables JSON safely function parseVariables(template) { try { diff --git a/frontend/src/components/admin/SentEmailsPanel.tsx b/frontend/src/components/admin/SentEmailsPanel.tsx new file mode 100644 index 00000000..19591616 --- /dev/null +++ b/frontend/src/components/admin/SentEmailsPanel.tsx @@ -0,0 +1,172 @@ +/** + * Sent-emails feed — read-only, paginated view of the email_queue table. + * Rendered as the "Sent emails" tab inside EmailConfigPage. Pairs with + * the "Send queued emails now" flush button on the SMTP tab: flush, then + * watch what sent / failed here. + * + * Filters: status (pending/sent/failed), free-text search (recipient or + * type), and a created-at date range. email_data is never fetched. + */ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { Search, AlertCircle } from 'lucide-react'; +import { Button, Card, Loading } from '../common'; +import { LocalizedDateInput } from '../common/LocalizedDateInput'; +import { useLocalizedDate } from '../../hooks/useLocalizedDate'; +import { emailService, type EmailQueueStatus } from '../../services/email.service'; + +const STATUSES: EmailQueueStatus[] = ['pending', 'sent', 'failed']; + +const statusClass = (s: EmailQueueStatus): string => + s === 'sent' ? 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300' + : s === 'failed' ? 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300' + : 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300'; + +export const SentEmailsPanel: React.FC = () => { + const { t } = useTranslation(); + const { formatDateTime: fmtDateTime } = useLocalizedDate(); + const [search, setSearch] = useState(''); + const [statusFilter, setStatusFilter] = useState(null); + const [from, setFrom] = useState(''); + const [to, setTo] = useState(''); + const [page, setPage] = useState(1); + + const { data, isLoading } = useQuery({ + queryKey: ['email-queue', { search, statusFilter, from, to, page }], + queryFn: () => emailService.listQueue({ + q: search || undefined, + status: statusFilter || undefined, + from: from || undefined, + to: to || undefined, + page, + pageSize: 25, + }), + }); + + const resetTo1 = () => setPage(1); + + return ( + +

+ {t('email.sentEmails.title', 'Sent emails')} +

+

+ {t('email.sentEmails.subtitle', 'Delivery status of every queued and sent notification.')} +

+ +
+
+ + { setSearch(e.target.value); resetTo1(); }} + /> +
+
+ { setFrom(iso); resetTo1(); }} /> +
+
+ { setTo(iso); resetTo1(); }} /> +
+
+ +
+ {STATUSES.map((s) => { + const active = statusFilter === s; + return ( + + ); + })} +
+ +
+ {isLoading ? : !data || data.items.length === 0 ? ( +

+ {t('email.sentEmails.empty', 'No emails match these filters.')} +

+ ) : ( +
+
+ + + + + + + + + + + + + {data.items.map((m) => ( + + + + + + + + + ))} + +
{t('email.sentEmails.col.recipient', 'Recipient')}{t('email.sentEmails.col.type', 'Type')}{t('email.sentEmails.col.status', 'Status')}{t('email.sentEmails.col.created', 'Queued')}{t('email.sentEmails.col.sent', 'Sent')}{t('email.sentEmails.col.event', 'Event')}
{m.recipientEmail}{m.emailType} + + {t(`email.sentEmails.status.${m.status}`, m.status)} + + {m.status === 'failed' && m.errorMessage && ( +
+ + {m.errorMessage} +
+ )} + {m.status === 'pending' && m.retryCount > 0 && ( +
+ {t('email.sentEmails.retries', '{{count}} retries', { count: m.retryCount })} +
+ )} +
{m.createdAt ? fmtDateTime(m.createdAt) : '—'}{m.sentAt ? fmtDateTime(m.sentAt) : '—'} + {m.eventId ? ( + e.stopPropagation()}> + {m.eventName || `#${m.eventId}`} + + ) : '—'} +
+
+ {data.pagination.totalPages > 1 && ( +
+ + {t('email.sentEmails.pagination', 'Page {{page}} of {{total}} · {{count}} emails', { + page: data.pagination.page, total: data.pagination.totalPages, count: data.pagination.total, + })} + +
+ + +
+
+ )} +
+ )} +
+
+ ); +}; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 4161ca43..21108ee9 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -2312,6 +2312,30 @@ "success": "Warteschlange geleert – {{sent}} gesendet, {{failed}} fehlgeschlagen", "empty": "Keine ausstehenden E-Mails zum Senden" }, + "sentEmails": { + "tab": "Gesendete E-Mails", + "title": "Gesendete E-Mails", + "subtitle": "Versandstatus aller eingereihten und gesendeten Benachrichtigungen.", + "searchPlaceholder": "Nach Empfänger oder Typ suchen…", + "from": "Von", + "to": "Bis", + "empty": "Keine E-Mails entsprechen diesen Filtern.", + "retries": "{{count}} Versuche", + "pagination": "Seite {{page}} von {{total}} · {{count}} E-Mails", + "status": { + "pending": "Ausstehend", + "sent": "Gesendet", + "failed": "Fehlgeschlagen" + }, + "col": { + "recipient": "Empfänger", + "type": "Typ", + "status": "Status", + "created": "Eingereiht", + "sent": "Gesendet", + "event": "Anlass" + } + }, "commonSmtpSettings": "Häufige SMTP-Einstellungen:", "editTemplate": "Vorlage bearbeiten", "templateName": "Vorlagenname", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 515ae02a..2eb33882 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1966,6 +1966,30 @@ "success": "Email queue flushed — {{sent}} sent, {{failed}} failed", "empty": "No pending emails to send" }, + "sentEmails": { + "tab": "Sent emails", + "title": "Sent emails", + "subtitle": "Delivery status of every queued and sent notification.", + "searchPlaceholder": "Search by recipient or type…", + "from": "From", + "to": "To", + "empty": "No emails match these filters.", + "retries": "{{count}} retries", + "pagination": "Page {{page}} of {{total}} · {{count}} emails", + "status": { + "pending": "Pending", + "sent": "Sent", + "failed": "Failed" + }, + "col": { + "recipient": "Recipient", + "type": "Type", + "status": "Status", + "created": "Queued", + "sent": "Sent", + "event": "Event" + } + }, "commonSmtpSettings": "Common SMTP Settings:", "editTemplate": "Edit Template", "templateName": "Template Name", diff --git a/frontend/src/pages/admin/EmailConfigPage.tsx b/frontend/src/pages/admin/EmailConfigPage.tsx index d421da6e..457e227a 100644 --- a/frontend/src/pages/admin/EmailConfigPage.tsx +++ b/frontend/src/pages/admin/EmailConfigPage.tsx @@ -18,6 +18,7 @@ import { toast } from 'react-toastify'; import { Button, Input, Card, Loading } from '../../components/common'; import { EmailPreviewModal } from '../../components/admin/EmailPreviewModal'; import { EmailTemplateEditor } from '../../components/admin/EmailTemplateEditor'; +import { SentEmailsPanel } from '../../components/admin/SentEmailsPanel'; import { Palette, RefreshCw, Info } from 'lucide-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { emailService, type EmailConfig, type EmailTemplate, type EmailTemplateTranslation } from '../../services/email.service'; @@ -129,7 +130,7 @@ The Photo Sharing Team`, export const EmailConfigPage: React.FC = () => { const { t } = useTranslation(); - const [activeTab, setActiveTab] = useState<'smtp' | 'templates'>('smtp'); + const [activeTab, setActiveTab] = useState<'smtp' | 'templates' | 'sent'>('smtp'); const [selectedTemplateKey, setSelectedTemplateKey] = useState('gallery_created'); const [editedTemplate, setEditedTemplate] = useState>({}); const [editingLang, setEditingLang] = useState('en'); @@ -476,9 +477,22 @@ export const EmailConfigPage: React.FC = () => { > {t('email.emailTemplates')} + + {/* Sent emails Tab */} + {activeTab === 'sent' && } + {/* SMTP Settings Tab */} {activeTab === 'smtp' && (
diff --git a/frontend/src/services/email.service.ts b/frontend/src/services/email.service.ts index 0e952408..b56e91e9 100644 --- a/frontend/src/services/email.service.ts +++ b/frontend/src/services/email.service.ts @@ -1,5 +1,27 @@ import { api } from '../config/api'; +export type EmailQueueStatus = 'pending' | 'sent' | 'failed'; + +export interface EmailQueueItem { + id: number; + recipientEmail: string; + emailType: string; + status: EmailQueueStatus; + createdAt: string; + scheduledAt: string | null; + sentAt: string | null; + errorMessage: string | null; + retryCount: number; + eventId: number | null; + eventName: string | null; + eventSlug: string | null; +} + +export interface EmailQueueListResponse { + items: EmailQueueItem[]; + pagination: { total: number; page: number; pageSize: number; totalPages: number }; +} + export interface EmailConfig { smtp_host: string; smtp_port: number; @@ -84,6 +106,21 @@ export const emailService = { return response.data; }, + /** Read-only "Sent emails" feed — paginated view of the email_queue + * table with filters. email_data is never returned. */ + async listQueue(params: { + status?: EmailQueueStatus; + emailType?: string; + q?: string; + from?: string; + to?: string; + page?: number; + pageSize?: number; + } = {}): Promise { + const response = await api.get('/admin/email/queue', { params }); + return response.data; + }, + // Get all email templates async getTemplates(): Promise { const response = await api.get('/admin/email/templates');