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
@@ -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",
"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",
+24
View File
@@ -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",
+15 -1
View File
@@ -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<string>('gallery_created');
const [editedTemplate, setEditedTemplate] = useState<Partial<EmailTemplate>>({});
const [editingLang, setEditingLang] = useState<string>('en');
@@ -476,9 +477,22 @@ export const EmailConfigPage: React.FC = () => {
>
{t('email.emailTemplates')}
</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>
</div>
{/* Sent emails Tab */}
{activeTab === 'sent' && <SentEmailsPanel />}
{/* SMTP Settings Tab */}
{activeTab === 'smtp' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
+37
View File
@@ -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<EmailQueueListResponse> {
const response = await api.get<EmailQueueListResponse>('/admin/email/queue', { params });
return response.data;
},
// Get all email templates
async getTemplates(): Promise<EmailTemplate[]> {
const response = await api.get<EmailTemplate[]>('/admin/email/templates');