feat(messages): search bar + Archive/Delete with Archived & Deleted folders

- Search box in the header filters the current folder's list (sender/subject),
  client-side; works across the merged Archived/Deleted views too.
- Archive and Delete are now implemented as soft moves: migration 157 adds
  mailbox_state ('active'|'archived'|'deleted') to email_queue + received_emails.
  Archive → 'archived', Delete → 'deleted' (trash). Restore → 'active'. Deleting
  FROM the Deleted folder is permanent (hard row delete).
- New cross-account system folders Archived + Deleted (merge sent + received of
  that state, sorted by date). Normal folders now exclude archived/deleted.
- Backend: /queue + /received gain a `state` filter (default active + legacy
  NULL); new POST /item/:kind/:id/state (archive/delete/restore) and DELETE
  /item/:kind/:id (purge, email.edit).
- Toolbar Archive/Delete wired; Restore + "Delete permanently" shown in the
  system folders.

Frontend build + migration boot (157) verified.
This commit is contained in:
Luca
2026-07-07 16:16:51 +02:00
parent c8cb4c88ca
commit 99d5996561
4 changed files with 230 additions and 41 deletions
@@ -0,0 +1,33 @@
/**
* Messages — Archive / Delete (trash) support.
*
* `mailbox_state` on both mail tables: 'active' (normal folders), 'archived'
* (Archived folder), or 'deleted' (Deleted/trash folder). Delete is soft — the
* row moves to 'deleted' and is only removed for good when purged FROM the
* Deleted folder. Legacy rows have NULL, treated as 'active'. Additive/guarded.
*/
exports.up = async function up(knex) {
for (const table of ['email_queue', 'received_emails']) {
// eslint-disable-next-line no-await-in-loop
const has = await knex.schema.hasColumn(table, 'mailbox_state');
// eslint-disable-next-line no-await-in-loop
if (!has) {
// eslint-disable-next-line no-await-in-loop
await knex.schema.alterTable(table, (t) => {
t.string('mailbox_state', 16).defaultTo('active');
});
}
}
};
exports.down = async function down(knex) {
for (const table of ['email_queue', 'received_emails']) {
// eslint-disable-next-line no-await-in-loop
const has = await knex.schema.hasColumn(table, 'mailbox_state');
// eslint-disable-next-line no-await-in-loop
if (has) {
// eslint-disable-next-line no-await-in-loop
await knex.schema.alterTable(table, (t) => { t.dropColumn('mailbox_state'); });
}
}
};
+42
View File
@@ -261,10 +261,14 @@ router.get('/received', adminAuth, requirePermission('email.view'), async (req,
const page = Math.max(1, parseInt(req.query.page, 10) || 1); const page = Math.max(1, parseInt(req.query.page, 10) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize, 10) || 25)); const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize, 10) || 25));
const account = req.query.account ? String(req.query.account) : null; const account = req.query.account ? String(req.query.account) : null;
// mailbox_state filter: no param → active (+ legacy NULL); else exact.
const state = ['archived', 'deleted'].includes(String(req.query.state)) ? String(req.query.state) : 'active';
// 'accounting' matches legacy rows too (account_key was NULL before mig 154). // 'accounting' matches legacy rows too (account_key was NULL before mig 154).
const applyAccount = (qb) => { const applyAccount = (qb) => {
if (account === 'accounting') qb.where((b) => b.where('account_key', 'accounting').orWhereNull('account_key')); if (account === 'accounting') qb.where((b) => b.where('account_key', 'accounting').orWhereNull('account_key'));
else if (account) qb.where('account_key', account); else if (account) qb.where('account_key', account);
if (state === 'active') qb.where((b) => b.where('mailbox_state', 'active').orWhereNull('mailbox_state'));
else qb.where('mailbox_state', state);
return qb; return qb;
}; };
const countRow = await applyAccount(db('received_emails')).count({ c: '*' }).first(); const countRow = await applyAccount(db('received_emails')).count({ c: '*' }).first();
@@ -295,6 +299,39 @@ router.get('/received/:id', adminAuth, requirePermission('email.view'), async (r
} }
}); });
// Move an email between mailbox states: Archive / Delete (soft) or Restore
// (back to active). kind = 'queue' | 'received'. Delete is a soft move to the
// trash; the row is only removed for good by the DELETE handler below.
router.post('/item/:kind/:id/state', adminAuth, requirePermission('email.view'), async (req, res) => {
try {
const table = req.params.kind === 'received' ? 'received_emails' : req.params.kind === 'queue' ? 'email_queue' : null;
if (!table) return res.status(400).json({ error: 'Invalid kind' });
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Invalid id' });
const state = String(req.body?.state || '');
if (!['active', 'archived', 'deleted'].includes(state)) return res.status(400).json({ error: 'Invalid state' });
const n = await db(table).where({ id }).update({ mailbox_state: state });
if (!n) return res.status(404).json({ error: 'Not found' });
res.json({ ok: true });
} catch (error) {
errorResponse(res, error, 500, 'Failed to update email');
}
});
// Permanently delete an email row — only offered from the Deleted folder.
router.delete('/item/:kind/:id', adminAuth, requirePermission('email.edit'), async (req, res) => {
try {
const table = req.params.kind === 'received' ? 'received_emails' : req.params.kind === 'queue' ? 'email_queue' : null;
if (!table) return res.status(400).json({ error: 'Invalid kind' });
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Invalid id' });
await db(table).where({ id }).del();
res.json({ ok: true });
} catch (error) {
errorResponse(res, error, 500, 'Failed to delete email');
}
});
// Additional inbound mailboxes (beyond the primary accounting IMAP in // Additional inbound mailboxes (beyond the primary accounting IMAP in
// email_configs) — e.g. the customer hello@ box. Passwords are masked out. // email_configs) — e.g. the customer hello@ box. Passwords are masked out.
router.get('/accounts', adminAuth, requirePermission('email.view'), async (req, res) => { router.get('/accounts', adminAuth, requirePermission('email.view'), async (req, res) => {
@@ -578,6 +615,7 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [
query('status').optional({ values: 'falsy' }).isIn(['pending', 'sent', 'failed']), query('status').optional({ values: 'falsy' }).isIn(['pending', 'sent', 'failed']),
query('emailType').optional({ values: 'falsy' }).isString().isLength({ max: 64 }), query('emailType').optional({ values: 'falsy' }).isString().isLength({ max: 64 }),
query('origin').optional({ values: 'falsy' }).isIn(['system', 'manual']), query('origin').optional({ values: 'falsy' }).isIn(['system', 'manual']),
query('state').optional({ values: 'falsy' }).isIn(['active', 'archived', 'deleted']),
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 255 }), query('q').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
query('from').optional({ values: 'falsy' }).isISO8601(), query('from').optional({ values: 'falsy' }).isISO8601(),
query('to').optional({ values: 'falsy' }).isISO8601(), query('to').optional({ values: 'falsy' }).isISO8601(),
@@ -599,6 +637,10 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [
// 'system' includes legacy rows (origin was NULL before migration 155). // 'system' includes legacy rows (origin was NULL before migration 155).
if (req.query.origin === 'manual') qb.where('email_queue.origin', 'manual'); if (req.query.origin === 'manual') qb.where('email_queue.origin', 'manual');
else if (req.query.origin === 'system') qb.where((b) => b.where('email_queue.origin', 'system').orWhereNull('email_queue.origin')); else if (req.query.origin === 'system') qb.where((b) => b.where('email_queue.origin', 'system').orWhereNull('email_queue.origin'));
// mailbox_state: default active (+ legacy NULL); Archived/Deleted folders pass it explicitly.
const st = ['archived', 'deleted'].includes(String(req.query.state)) ? String(req.query.state) : 'active';
if (st === 'active') qb.where((b) => b.where('email_queue.mailbox_state', 'active').orWhereNull('email_queue.mailbox_state'));
else qb.where('email_queue.mailbox_state', st);
if (req.query.from) qb.where('email_queue.created_at', '>=', new Date(req.query.from)); 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.to) qb.where('email_queue.created_at', '<=', new Date(req.query.to));
if (req.query.q) { if (req.query.q) {
@@ -6,7 +6,7 @@ import { toast } from 'react-toastify';
import { import {
Inbox, Send, Reply, ReplyAll, Forward, Archive, Trash2, Paperclip, Inbox, Send, Reply, ReplyAll, Forward, Archive, Trash2, Paperclip,
FileText, Quote, FileSignature, Image as ImageIcon, ReceiptText, FileText, Quote, FileSignature, Image as ImageIcon, ReceiptText,
Link2, X, ChevronLeft, ChevronRight, Mail, RefreshCw, PenSquare, type LucideIcon, Link2, X, ChevronLeft, ChevronRight, Mail, RefreshCw, PenSquare, Search, RotateCcw, type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import { emailService, type ReceivedEmail, type MailIdentities } from '../../../services/email.service'; import { emailService, type ReceivedEmail, type MailIdentities } from '../../../services/email.service';
import { accountingService } from '../../../services/accounting.service'; import { accountingService } from '../../../services/accounting.service';
@@ -23,8 +23,8 @@ import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
* folders render an explanatory empty state so the full IA is visible now. * folders render an explanatory empty state so the full IA is visible now.
*/ */
type FolderSrc = 'queue' | 'received' | 'empty'; type FolderSrc = 'queue' | 'received' | 'empty' | 'state';
interface Folder { id: string; name: string; icon: LucideIcon; src: FolderSrc; account?: string; origin?: 'system' | 'manual'; note?: string; } interface Folder { id: string; name: string; icon: LucideIcon; src: FolderSrc; account?: string; origin?: 'system' | 'manual'; state?: 'archived' | 'deleted'; note?: string; }
interface Account { id: string; name: string; addr?: string; color: string; folders: Folder[]; } interface Account { id: string; name: string; addr?: string; color: string; folders: Folder[]; }
type Selection = type Selection =
@@ -73,6 +73,7 @@ export const MessagesPage: React.FC = () => {
const [composer, setComposer] = useState<{ init: ComposerInit; title?: string; accountKey?: string } | null>(null); const [composer, setComposer] = useState<{ init: ComposerInit; title?: string; accountKey?: string } | null>(null);
const [docAction, setDocAction] = useState<{ docType: DocType; senderEmail: string } | null>(null); const [docAction, setDocAction] = useState<{ docType: DocType; senderEmail: string } | null>(null);
const { flags } = useFeatureFlags(); const { flags } = useFeatureFlags();
const [search, setSearch] = useState('');
// "Sync" = poll the inbound mailboxes now instead of waiting for the 60s loop. // "Sync" = poll the inbound mailboxes now instead of waiting for the 60s loop.
const sync = useMutation({ const sync = useMutation({
@@ -115,6 +116,48 @@ export const MessagesPage: React.FC = () => {
}); });
const identities = identitiesQuery.data; const identities = identitiesQuery.data;
// Archived / Deleted system folders — fetch queue + received for that state,
// on demand (only when the folder is open).
const folderState: 'archived' | 'deleted' | undefined =
activeFolder === 'archived' ? 'archived' : activeFolder === 'deleted' ? 'deleted' : undefined;
const stateQueueQuery = useQuery({
queryKey: ['messages', 'state-queue', folderState],
enabled: !!folderState,
queryFn: () => emailService.listQueue({ state: folderState as 'archived' | 'deleted', pageSize: 100 }),
});
const stateRecvQuery = useQuery({
queryKey: ['messages', 'state-received', folderState],
enabled: !!folderState,
queryFn: () => emailService.listReceived({ state: folderState as 'archived' | 'deleted', pageSize: 100 }),
});
const refetchAll = () => {
queueQuery.refetch(); acctQuery.refetch(); custQuery.refetch();
stateQueueQuery.refetch(); stateRecvQuery.refetch();
};
const stateMut = useMutation({
mutationFn: (v: { kind: 'queue' | 'received'; id: number; state: 'active' | 'archived' | 'deleted' }) =>
emailService.setItemState(v.kind, v.id, v.state),
onSuccess: () => { setSelection(null); refetchAll(); },
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || t('messages.actionFailed', 'Action failed.')),
});
const purgeMut = useMutation({
mutationFn: (v: { kind: 'queue' | 'received'; id: number }) => emailService.deleteItem(v.kind, v.id),
onSuccess: () => { setSelection(null); refetchAll(); },
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || t('messages.actionFailed', 'Action failed.')),
});
// Archive / Delete (soft) / Restore, acting on the current selection. Delete
// from the Deleted folder is permanent.
const doItemAction = (action: 'archive' | 'delete' | 'restore') => {
if (!selection) return;
const kind = selection.kind;
const id = selection.kind === 'queue' ? selection.id : selection.item.id;
if (action === 'restore') stateMut.mutate({ kind, id, state: 'active' });
else if (action === 'archive') stateMut.mutate({ kind, id, state: 'archived' });
else if (folderState === 'deleted') purgeMut.mutate({ kind, id });
else stateMut.mutate({ kind, id, state: 'deleted' });
};
const queueTotal = queueQuery.data?.pagination.total; const queueTotal = queueQuery.data?.pagination.total;
const acctTotal = acctQuery.data?.pagination.total; const acctTotal = acctQuery.data?.pagination.total;
const custTotal = custQuery.data?.pagination.total; const custTotal = custQuery.data?.pagination.total;
@@ -136,6 +179,12 @@ export const MessagesPage: React.FC = () => {
] }, ] },
], [t, identities]); ], [t, identities]);
// Cross-account system folders — Archived + Deleted (trash).
const systemFolders: Folder[] = useMemo(() => [
{ id: 'archived', name: t('messages.folder.archived', 'Archived'), icon: Archive, src: 'state', state: 'archived' },
{ id: 'deleted', name: t('messages.folder.deleted', 'Deleted'), icon: Trash2, src: 'state', state: 'deleted' },
], [t]);
// Sent stream is split client-side by origin: system (Automated) vs manual // Sent stream is split client-side by origin: system (Automated) vs manual
// (human composed → Customers ▸ Sent). Legacy rows (origin undefined) = system. // (human composed → Customers ▸ Sent). Legacy rows (origin undefined) = system.
const queueItemsAll = queueQuery.data?.items || []; const queueItemsAll = queueQuery.data?.items || [];
@@ -146,8 +195,10 @@ export const MessagesPage: React.FC = () => {
const folder = useMemo(() => { const folder = useMemo(() => {
for (const a of accounts) for (const f of a.folders) if (f.id === activeFolder) return { a, f }; for (const a of accounts) for (const f of a.folders) if (f.id === activeFolder) return { a, f };
const sf = systemFolders.find((f) => f.id === activeFolder);
if (sf) return { a: { id: 'system', name: sf.name, color: '#94a3b8', folders: [] } as Account, f: sf };
return { a: accounts[0], f: accounts[0].folders[0] }; return { a: accounts[0], f: accounts[0].folders[0] };
}, [accounts, activeFolder]); }, [accounts, systemFolders, activeFolder]);
const countFor = (f: Folder): number | undefined => { const countFor = (f: Folder): number | undefined => {
if (f.src === 'queue') return f.origin ? queueFor(f.origin).length : queueTotal; if (f.src === 'queue') return f.origin ? queueFor(f.origin).length : queueTotal;
@@ -177,8 +228,8 @@ export const MessagesPage: React.FC = () => {
return ( return (
<div className="flex flex-col h-[calc(100vh-8.5rem)] min-h-[540px]"> <div className="flex flex-col h-[calc(100vh-8.5rem)] min-h-[540px]">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center gap-3 mb-3">
<div> <div className="flex-none">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100 flex items-center gap-2"> <h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
<Mail className="w-6 h-6 text-neutral-500 dark:text-neutral-400" /> <Mail className="w-6 h-6 text-neutral-500 dark:text-neutral-400" />
{t('messages.title', 'Messages')} {t('messages.title', 'Messages')}
@@ -187,7 +238,16 @@ export const MessagesPage: React.FC = () => {
{t('messages.subtitle', 'Sent, automated and incoming mail — one place.')} {t('messages.subtitle', 'Sent, automated and incoming mail — one place.')}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="relative flex-1 max-w-md ml-auto">
<Search className="w-4 h-4 text-neutral-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('messages.searchPlaceholder', 'Search this folder…')}
className="w-full h-9 pl-9 pr-3 rounded-lg border border-neutral-300 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div className="flex items-center gap-2 flex-none">
<button <button
onClick={() => sync.mutate()} onClick={() => sync.mutate()}
disabled={sync.isPending} disabled={sync.isPending}
@@ -241,6 +301,26 @@ export const MessagesPage: React.FC = () => {
</div> </div>
</div> </div>
))} ))}
{/* System folders — Archived + Deleted, across all accounts. */}
<div className="mt-2 pt-2 border-t border-neutral-200 dark:border-neutral-800 flex flex-col gap-0.5">
{systemFolders.map((f) => {
const active = f.id === activeFolder;
return (
<button
key={f.id}
onClick={() => { setActiveFolder(f.id); setSelection(null); }}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-[13.5px] text-left transition-colors ${
active
? 'bg-accent-soft text-on-accent-soft font-semibold'
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800/60'
}`}
>
<f.icon className="w-4 h-4 opacity-80" />
<span>{f.name}</span>
</button>
);
})}
</div>
</nav> </nav>
{/* ── message list ── */} {/* ── message list ── */}
@@ -248,15 +328,20 @@ export const MessagesPage: React.FC = () => {
<div className="px-4 py-3 border-b border-neutral-200 dark:border-neutral-800 flex-none"> <div className="px-4 py-3 border-b border-neutral-200 dark:border-neutral-800 flex-none">
<div className="text-base font-semibold text-neutral-900 dark:text-neutral-100">{folder.f.name}</div> <div className="text-base font-semibold text-neutral-900 dark:text-neutral-100">{folder.f.name}</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5"> <div className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">
{folder.a.addr || (folder.a.id === 'all' ? t('messages.unified', 'Unified across accounts') : t('messages.systemGenerated', 'System-generated'))} {folder.f.src === 'state'
? t('messages.acrossAccounts', 'Across all accounts')
: folder.a.addr || (folder.a.id === 'all' ? t('messages.unified', 'Unified across accounts') : t('messages.systemGenerated', 'System-generated'))}
</div> </div>
</div> </div>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<MessageList <MessageList
folder={folder.f} folder={folder.f}
queue={queueFor(folder.f.origin)} queue={folder.f.src === 'state' ? stateQueueQuery.data?.items : queueFor(folder.f.origin)}
received={receivedItems} received={folder.f.src === 'state' ? stateRecvQuery.data?.items : receivedItems}
loading={folder.f.src === 'queue' ? queueQuery.isLoading : folder.f.src === 'received' ? receivedLoading : false} loading={folder.f.src === 'state'
? (stateQueueQuery.isLoading || stateRecvQuery.isLoading)
: folder.f.src === 'queue' ? queueQuery.isLoading : folder.f.src === 'received' ? receivedLoading : false}
search={search}
selection={selection} selection={selection}
onSelect={setSelection} onSelect={setSelection}
t={t} t={t}
@@ -271,10 +356,12 @@ export const MessagesPage: React.FC = () => {
account={folder.a} account={folder.a}
identities={identities} identities={identities}
flags={flags} flags={flags}
folderState={folderState}
onViewDoc={setPdfDocId} onViewDoc={setPdfDocId}
onOpenAccounting={() => navigate('/admin/accounting/inbox')} onOpenAccounting={() => navigate('/admin/accounting/inbox')}
onCompose={(init, title) => setComposer({ init, title, accountKey: 'customers' })} onCompose={(init, title) => setComposer({ init, title, accountKey: 'customers' })}
onOpenDoc={(docType, senderEmail) => setDocAction({ docType, senderEmail })} onOpenDoc={(docType, senderEmail) => setDocAction({ docType, senderEmail })}
onItemAction={doItemAction}
t={t} t={t}
/> />
</section> </section>
@@ -310,10 +397,11 @@ const MessageList: React.FC<{
queue?: import('../../../services/email.service').EmailQueueItem[]; queue?: import('../../../services/email.service').EmailQueueItem[];
received?: ReceivedEmail[]; received?: ReceivedEmail[];
loading: boolean; loading: boolean;
search: string;
selection: Selection; selection: Selection;
onSelect: (s: Selection) => void; onSelect: (s: Selection) => void;
t: (k: string, d?: string) => string; t: (k: string, d?: string) => string;
}> = ({ folder, queue, received, loading, selection, onSelect, t }) => { }> = ({ folder, queue, received, loading, search, selection, onSelect, t }) => {
if (folder.src === 'empty') { if (folder.src === 'empty') {
return ( return (
<div className="p-8 text-center text-sm text-neutral-500 dark:text-neutral-400"> <div className="p-8 text-center text-sm text-neutral-500 dark:text-neutral-400">
@@ -324,31 +412,39 @@ const MessageList: React.FC<{
} }
if (loading) return <div className="p-6"><Loading /></div>; if (loading) return <div className="p-6"><Loading /></div>;
const rows = const qRows = (queue || []).map((m) => ({
folder.src === 'queue' key: `q${m.id}`,
? (queue || []).map((m) => ({ sortKey: m.sentAt || m.createdAt || '',
key: `q${m.id}`, onClick: () => onSelect({ kind: 'queue', id: m.id }),
onClick: () => onSelect({ kind: 'queue', id: m.id }), active: selection?.kind === 'queue' && selection.id === m.id,
active: selection?.kind === 'queue' && selection.id === m.id, who: m.recipientEmail,
who: m.recipientEmail, subject: friendlyType(m.emailType),
subject: friendlyType(m.emailType), when: fmt(m.sentAt || m.createdAt),
when: fmt(m.sentAt || m.createdAt), status: m.status,
status: m.status, attach: 0,
attach: 0, }));
})) const rRows = (received || []).map((m) => ({
: (received || []).map((m) => ({ key: `r${m.id}`,
key: `r${m.id}`, sortKey: m.received_at || '',
onClick: () => onSelect({ kind: 'received', item: m }), onClick: () => onSelect({ kind: 'received', item: m }),
active: selection?.kind === 'received' && selection.item.id === m.id, active: selection?.kind === 'received' && selection.item.id === m.id,
who: m.from_address || '—', who: m.from_address || '—',
subject: m.subject || t('messages.noSubject', '(no subject)'), subject: m.subject || t('messages.noSubject', '(no subject)'),
when: fmt(m.received_at), when: fmt(m.received_at),
status: m.status, status: m.status,
attach: m.attachment_count, attach: m.attachment_count,
})); }));
// Archived/Deleted folders (src 'state') merge both streams by date.
let rows = folder.src === 'queue' ? qRows
: folder.src === 'received' ? rRows
: [...qRows, ...rRows].sort((a, b) => (b.sortKey || '').localeCompare(a.sortKey || ''));
const q = search.trim().toLowerCase();
if (q) rows = rows.filter((r) => r.who.toLowerCase().includes(q) || r.subject.toLowerCase().includes(q));
if (rows.length === 0) { if (rows.length === 0) {
return <div className="p-8 text-center text-sm text-neutral-500 dark:text-neutral-400">{t('messages.noMessages', 'No messages')}</div>; return <div className="p-8 text-center text-sm text-neutral-500 dark:text-neutral-400">{q ? t('messages.noSearchResults', 'No matches') : t('messages.noMessages', 'No messages')}</div>;
} }
return ( return (
@@ -391,12 +487,14 @@ const ReadingPane: React.FC<{
account: Account; account: Account;
identities?: MailIdentities | null; identities?: MailIdentities | null;
flags: Record<string, boolean>; flags: Record<string, boolean>;
folderState?: 'archived' | 'deleted';
onViewDoc: (id: number) => void; onViewDoc: (id: number) => void;
onOpenAccounting: () => void; onOpenAccounting: () => void;
onCompose: (init: ComposerInit, title?: string) => void; onCompose: (init: ComposerInit, title?: string) => void;
onOpenDoc: (docType: DocType, senderEmail: string) => void; onOpenDoc: (docType: DocType, senderEmail: string) => void;
onItemAction: (action: 'archive' | 'delete' | 'restore') => void;
t: (k: string, d?: string) => string; t: (k: string, d?: string) => string;
}> = ({ selection, account, identities, flags, onViewDoc, onOpenAccounting, onCompose, onOpenDoc, t }) => { }> = ({ selection, account, identities, flags, folderState, onViewDoc, onOpenAccounting, onCompose, onOpenDoc, onItemAction, t }) => {
const detailQuery = useQuery({ const detailQuery = useQuery({
queryKey: ['messages', 'queue', selection?.kind === 'queue' ? selection.id : null], queryKey: ['messages', 'queue', selection?.kind === 'queue' ? selection.id : null],
queryFn: () => emailService.getQueueItem((selection as { kind: 'queue'; id: number }).id), queryFn: () => emailService.getQueueItem((selection as { kind: 'queue'; id: number }).id),
@@ -442,7 +540,7 @@ const ReadingPane: React.FC<{
return ( return (
<div className="flex flex-col min-h-0 flex-1"> <div className="flex flex-col min-h-0 flex-1">
<Toolbar isAcct={isAcct} flags={flags} onReply={onReply} onDoc={onDoc} t={t} /> <Toolbar isAcct={isAcct} flags={flags} folderState={folderState} onReply={onReply} onDoc={onDoc} onItemAction={onItemAction} t={t} />
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
{selection.kind === 'queue' ? ( {selection.kind === 'queue' ? (
detailQuery.isLoading ? <Loading /> : detailQuery.data ? ( detailQuery.isLoading ? <Loading /> : detailQuery.data ? (
@@ -579,10 +677,12 @@ const ReceivedDetail: React.FC<{
const Toolbar: React.FC<{ const Toolbar: React.FC<{
isAcct: boolean; isAcct: boolean;
flags: Record<string, boolean>; flags: Record<string, boolean>;
folderState?: 'archived' | 'deleted';
onReply?: () => void; onReply?: () => void;
onDoc?: (docType: DocType) => void; onDoc?: (docType: DocType) => void;
onItemAction: (action: 'archive' | 'delete' | 'restore') => void;
t: (k: string, d?: string) => string; t: (k: string, d?: string) => string;
}> = ({ isAcct, flags, onReply, onDoc, t }) => { }> = ({ isAcct, flags, folderState, onReply, onDoc, onItemAction, t }) => {
const Tb: React.FC<{ icon: LucideIcon; label: string; accent?: boolean; onClick?: () => void }> = ({ icon: Icon, label, accent, onClick }) => { const Tb: React.FC<{ icon: LucideIcon; label: string; accent?: boolean; onClick?: () => void }> = ({ icon: Icon, label, accent, onClick }) => {
const enabled = !!onClick; const enabled = !!onClick;
return ( return (
@@ -619,8 +719,13 @@ const Toolbar: React.FC<{
</> </>
)} )}
<span className="flex-1" /> <span className="flex-1" />
<Tb icon={Archive} label={t('messages.archive', 'Archive')} /> {folderState && <Tb icon={RotateCcw} label={t('messages.restore', 'Restore')} onClick={() => onItemAction('restore')} />}
<Tb icon={Trash2} label={t('messages.delete', 'Delete')} /> {folderState !== 'archived' && <Tb icon={Archive} label={t('messages.archive', 'Archive')} onClick={() => onItemAction('archive')} />}
<Tb
icon={Trash2}
label={folderState === 'deleted' ? t('messages.deleteForever', 'Delete permanently') : t('messages.delete', 'Delete')}
onClick={() => onItemAction('delete')}
/>
</div> </div>
); );
}; };
+10 -1
View File
@@ -220,10 +220,18 @@ export const emailService = {
const response = await api.post<ImapPollResult>('/admin/email/incoming-config/poll', {}); const response = await api.post<ImapPollResult>('/admin/email/incoming-config/poll', {});
return response.data; return response.data;
}, },
async listReceived(params: { page?: number; pageSize?: number; account?: string } = {}): Promise<ReceivedEmailsResponse> { async listReceived(params: { page?: number; pageSize?: number; account?: string; state?: 'active' | 'archived' | 'deleted' } = {}): Promise<ReceivedEmailsResponse> {
const response = await api.get<ReceivedEmailsResponse>('/admin/email/received', { params }); const response = await api.get<ReceivedEmailsResponse>('/admin/email/received', { params });
return response.data; return response.data;
}, },
/** Archive / Delete (soft) / Restore an email. kind = 'queue' | 'received'. */
async setItemState(kind: 'queue' | 'received', id: number, state: 'active' | 'archived' | 'deleted'): Promise<void> {
await api.post(`/admin/email/item/${kind}/${id}/state`, { state });
},
/** Permanently delete an email (only from the Deleted folder). */
async deleteItem(kind: 'queue' | 'received', id: number): Promise<void> {
await api.delete(`/admin/email/item/${kind}/${id}`);
},
async getReceivedItem(id: number): Promise<ReceivedEmailDetail> { async getReceivedItem(id: number): Promise<ReceivedEmailDetail> {
const response = await api.get<ReceivedEmailDetail>(`/admin/email/received/${id}`); const response = await api.get<ReceivedEmailDetail>(`/admin/email/received/${id}`);
return response.data; return response.data;
@@ -262,6 +270,7 @@ export const emailService = {
status?: EmailQueueStatus; status?: EmailQueueStatus;
emailType?: string; emailType?: string;
origin?: 'system' | 'manual'; origin?: 'system' | 'manual';
state?: 'active' | 'archived' | 'deleted';
q?: string; q?: string;
from?: string; from?: string;
to?: string; to?: string;