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:
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user