feat(email): read-only "Sent emails" tab over email_queue

Add a paginated, filterable view of the email_queue (recipient, type,
status, queued/sent timestamps, error, event link) as a third tab in
Email config, beside SMTP + Templates. Filters: status, recipient/type
search, created-at range. email_data is never exposed. Pairs with the
"Send queued emails now" flush — flush, then watch what sent/failed.
This commit is contained in:
Luca
2026-06-02 13:58:54 +02:00
parent 8d14441df4
commit 603ba1e504
6 changed files with 361 additions and 2 deletions
+89 -1
View File
@@ -1,6 +1,6 @@
const express = require('express'); const express = require('express');
const nodemailer = require('nodemailer'); const nodemailer = require('nodemailer');
const { body, validationResult } = require('express-validator'); const { body, query, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db'); const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions'); 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 // Helper: parse variables JSON safely
function parseVariables(template) { function parseVariables(template) {
try { try {
@@ -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<EmailQueueStatus | null>(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 (
<Card padding="lg">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1">
{t('email.sentEmails.title', 'Sent emails')}
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('email.sentEmails.subtitle', 'Delivery status of every queued and sent notification.')}
</p>
<div className="flex flex-wrap items-end gap-3">
<div className="relative flex-1 min-w-[220px]">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400" />
<input
type="text"
placeholder={t('email.sentEmails.searchPlaceholder', 'Search by recipient or type…') as string}
className="w-full pl-9 pr-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
value={search}
onChange={(e) => { setSearch(e.target.value); resetTo1(); }}
/>
</div>
<div className="w-40">
<LocalizedDateInput label={t('email.sentEmails.from', 'From') as string} value={from}
onChange={(iso) => { setFrom(iso); resetTo1(); }} />
</div>
<div className="w-40">
<LocalizedDateInput label={t('email.sentEmails.to', 'To') as string} value={to}
onChange={(iso) => { setTo(iso); resetTo1(); }} />
</div>
</div>
<div className="mt-3 flex flex-wrap gap-1">
{STATUSES.map((s) => {
const active = statusFilter === s;
return (
<button key={s} type="button"
onClick={() => { setStatusFilter(active ? null : s); resetTo1(); }}
className={`px-2.5 py-1 rounded-full text-xs font-medium border transition-colors ${
active
? 'bg-accent-dark text-white border-accent-dark'
: 'bg-white dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 border-neutral-300 dark:border-neutral-600'
}`}
>{t(`email.sentEmails.status.${s}`, s)}</button>
);
})}
</div>
<div className="mt-4">
{isLoading ? <Loading /> : !data || data.items.length === 0 ? (
<p className="text-center text-neutral-500 dark:text-neutral-400 py-8">
{t('email.sentEmails.empty', 'No emails match these filters.')}
</p>
) : (
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300">
<tr>
<th className="px-3 py-2 text-left">{t('email.sentEmails.col.recipient', 'Recipient')}</th>
<th className="px-3 py-2 text-left">{t('email.sentEmails.col.type', 'Type')}</th>
<th className="px-3 py-2 text-left">{t('email.sentEmails.col.status', 'Status')}</th>
<th className="px-3 py-2 text-left">{t('email.sentEmails.col.created', 'Queued')}</th>
<th className="px-3 py-2 text-left">{t('email.sentEmails.col.sent', 'Sent')}</th>
<th className="px-3 py-2 text-left">{t('email.sentEmails.col.event', 'Event')}</th>
</tr>
</thead>
<tbody>
{data.items.map((m) => (
<tr key={m.id} className="border-t border-neutral-200 dark:border-neutral-700 align-top">
<td className="px-3 py-2 break-all">{m.recipientEmail}</td>
<td className="px-3 py-2 font-mono text-xs">{m.emailType}</td>
<td className="px-3 py-2">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusClass(m.status)}`}>
{t(`email.sentEmails.status.${m.status}`, m.status)}
</span>
{m.status === 'failed' && m.errorMessage && (
<div className="mt-1 flex items-start gap-1 text-xs text-red-700 dark:text-red-400 max-w-xs">
<AlertCircle className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
<span className="break-words">{m.errorMessage}</span>
</div>
)}
{m.status === 'pending' && m.retryCount > 0 && (
<div className="mt-1 text-xs text-amber-700 dark:text-amber-400">
{t('email.sentEmails.retries', '{{count}} retries', { count: m.retryCount })}
</div>
)}
</td>
<td className="px-3 py-2 whitespace-nowrap">{m.createdAt ? fmtDateTime(m.createdAt) : '—'}</td>
<td className="px-3 py-2 whitespace-nowrap">{m.sentAt ? fmtDateTime(m.sentAt) : '—'}</td>
<td className="px-3 py-2">
{m.eventId ? (
<Link to={`/admin/events/${m.eventId}`} className="text-accent hover:underline" onClick={(e) => e.stopPropagation()}>
{m.eventName || `#${m.eventId}`}
</Link>
) : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
{data.pagination.totalPages > 1 && (
<div className="flex justify-between items-center px-3 py-2 border-t border-neutral-200 dark:border-neutral-700 text-sm">
<span className="text-neutral-500 dark:text-neutral-400">
{t('email.sentEmails.pagination', 'Page {{page}} of {{total}} · {{count}} emails', {
page: data.pagination.page, total: data.pagination.totalPages, count: data.pagination.total,
})}
</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
{t('common.previous', 'Previous')}
</Button>
<Button variant="outline" size="sm" disabled={page >= data.pagination.totalPages} onClick={() => setPage((p) => p + 1)}>
{t('common.next', 'Next')}
</Button>
</div>
</div>
)}
</div>
)}
</div>
</Card>
);
};
+24
View File
@@ -2312,6 +2312,30 @@
"success": "Warteschlange geleert {{sent}} gesendet, {{failed}} fehlgeschlagen", "success": "Warteschlange geleert {{sent}} gesendet, {{failed}} fehlgeschlagen",
"empty": "Keine ausstehenden E-Mails zum Senden" "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:", "commonSmtpSettings": "Häufige SMTP-Einstellungen:",
"editTemplate": "Vorlage bearbeiten", "editTemplate": "Vorlage bearbeiten",
"templateName": "Vorlagenname", "templateName": "Vorlagenname",
+24
View File
@@ -1966,6 +1966,30 @@
"success": "Email queue flushed — {{sent}} sent, {{failed}} failed", "success": "Email queue flushed — {{sent}} sent, {{failed}} failed",
"empty": "No pending emails to send" "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:", "commonSmtpSettings": "Common SMTP Settings:",
"editTemplate": "Edit Template", "editTemplate": "Edit Template",
"templateName": "Template Name", "templateName": "Template Name",
+15 -1
View File
@@ -18,6 +18,7 @@ import { toast } from 'react-toastify';
import { Button, Input, Card, Loading } from '../../components/common'; import { Button, Input, Card, Loading } from '../../components/common';
import { EmailPreviewModal } from '../../components/admin/EmailPreviewModal'; import { EmailPreviewModal } from '../../components/admin/EmailPreviewModal';
import { EmailTemplateEditor } from '../../components/admin/EmailTemplateEditor'; import { EmailTemplateEditor } from '../../components/admin/EmailTemplateEditor';
import { SentEmailsPanel } from '../../components/admin/SentEmailsPanel';
import { Palette, RefreshCw, Info } from 'lucide-react'; import { Palette, RefreshCw, Info } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { emailService, type EmailConfig, type EmailTemplate, type EmailTemplateTranslation } from '../../services/email.service'; 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 = () => { export const EmailConfigPage: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<'smtp' | 'templates'>('smtp'); const [activeTab, setActiveTab] = useState<'smtp' | 'templates' | 'sent'>('smtp');
const [selectedTemplateKey, setSelectedTemplateKey] = useState<string>('gallery_created'); const [selectedTemplateKey, setSelectedTemplateKey] = useState<string>('gallery_created');
const [editedTemplate, setEditedTemplate] = useState<Partial<EmailTemplate>>({}); const [editedTemplate, setEditedTemplate] = useState<Partial<EmailTemplate>>({});
const [editingLang, setEditingLang] = useState<string>('en'); const [editingLang, setEditingLang] = useState<string>('en');
@@ -476,9 +477,22 @@ export const EmailConfigPage: React.FC = () => {
> >
{t('email.emailTemplates')} {t('email.emailTemplates')}
</button> </button>
<button
onClick={() => setActiveTab('sent')}
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'sent'
? 'border-accent text-accent'
: 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300'
}`}
>
{t('email.sentEmails.tab', 'Sent emails')}
</button>
</nav> </nav>
</div> </div>
{/* Sent emails Tab */}
{activeTab === 'sent' && <SentEmailsPanel />}
{/* SMTP Settings Tab */} {/* SMTP Settings Tab */}
{activeTab === 'smtp' && ( {activeTab === 'smtp' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
+37
View File
@@ -1,5 +1,27 @@
import { api } from '../config/api'; 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 { export interface EmailConfig {
smtp_host: string; smtp_host: string;
smtp_port: number; smtp_port: number;
@@ -84,6 +106,21 @@ export const emailService = {
return response.data; 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<EmailQueueListResponse> {
const response = await api.get<EmailQueueListResponse>('/admin/email/queue', { params });
return response.data;
},
// Get all email templates // Get all email templates
async getTemplates(): Promise<EmailTemplate[]> { async getTemplates(): Promise<EmailTemplate[]> {
const response = await api.get<EmailTemplate[]>('/admin/email/templates'); const response = await api.get<EmailTemplate[]>('/admin/email/templates');