From 99d5996561a2dcff2d431692d5bab5c7286d1f6f Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:16:51 +0200 Subject: [PATCH] feat(messages): search bar + Archive/Delete with Archived & Deleted folders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- backend/migrations/core/157_mailbox_state.js | 33 ++++ backend/src/routes/adminEmail.js | 42 ++++ .../src/pages/admin/messages/MessagesPage.tsx | 185 ++++++++++++++---- frontend/src/services/email.service.ts | 11 +- 4 files changed, 230 insertions(+), 41 deletions(-) create mode 100644 backend/migrations/core/157_mailbox_state.js diff --git a/backend/migrations/core/157_mailbox_state.js b/backend/migrations/core/157_mailbox_state.js new file mode 100644 index 00000000..00f48e7f --- /dev/null +++ b/backend/migrations/core/157_mailbox_state.js @@ -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'); }); + } + } +}; diff --git a/backend/src/routes/adminEmail.js b/backend/src/routes/adminEmail.js index b92e70cd..41cc6866 100644 --- a/backend/src/routes/adminEmail.js +++ b/backend/src/routes/adminEmail.js @@ -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 pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize, 10) || 25)); 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). const applyAccount = (qb) => { if (account === 'accounting') qb.where((b) => b.where('account_key', 'accounting').orWhereNull('account_key')); 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; }; 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 // email_configs) — e.g. the customer hello@ box. Passwords are masked out. 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('emailType').optional({ values: 'falsy' }).isString().isLength({ max: 64 }), 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('from').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). 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')); + // 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.to) qb.where('email_queue.created_at', '<=', new Date(req.query.to)); if (req.query.q) { diff --git a/frontend/src/pages/admin/messages/MessagesPage.tsx b/frontend/src/pages/admin/messages/MessagesPage.tsx index 4cae3019..74850837 100644 --- a/frontend/src/pages/admin/messages/MessagesPage.tsx +++ b/frontend/src/pages/admin/messages/MessagesPage.tsx @@ -6,7 +6,7 @@ import { toast } from 'react-toastify'; import { Inbox, Send, Reply, ReplyAll, Forward, Archive, Trash2, Paperclip, 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'; import { emailService, type ReceivedEmail, type MailIdentities } from '../../../services/email.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. */ -type FolderSrc = 'queue' | 'received' | 'empty'; -interface Folder { id: string; name: string; icon: LucideIcon; src: FolderSrc; account?: string; origin?: 'system' | 'manual'; note?: string; } +type FolderSrc = 'queue' | 'received' | 'empty' | 'state'; +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[]; } type Selection = @@ -73,6 +73,7 @@ export const MessagesPage: React.FC = () => { const [composer, setComposer] = useState<{ init: ComposerInit; title?: string; accountKey?: string } | null>(null); const [docAction, setDocAction] = useState<{ docType: DocType; senderEmail: string } | null>(null); const { flags } = useFeatureFlags(); + const [search, setSearch] = useState(''); // "Sync" = poll the inbound mailboxes now instead of waiting for the 60s loop. const sync = useMutation({ @@ -115,6 +116,48 @@ export const MessagesPage: React.FC = () => { }); 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 acctTotal = acctQuery.data?.pagination.total; const custTotal = custQuery.data?.pagination.total; @@ -136,6 +179,12 @@ export const MessagesPage: React.FC = () => { ] }, ], [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 // (human composed → Customers ▸ Sent). Legacy rows (origin undefined) = system. const queueItemsAll = queueQuery.data?.items || []; @@ -146,8 +195,10 @@ export const MessagesPage: React.FC = () => { const folder = useMemo(() => { 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] }; - }, [accounts, activeFolder]); + }, [accounts, systemFolders, activeFolder]); const countFor = (f: Folder): number | undefined => { if (f.src === 'queue') return f.origin ? queueFor(f.origin).length : queueTotal; @@ -177,8 +228,8 @@ export const MessagesPage: React.FC = () => { return (