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 (
-
-
+
+

{t('messages.title', 'Messages')} @@ -187,7 +238,16 @@ export const MessagesPage: React.FC = () => { {t('messages.subtitle', 'Sent, automated and incoming mail — one place.')}

-
+
+ + 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" + /> +
+
))} + {/* System folders — Archived + Deleted, across all accounts. */} +
+ {systemFolders.map((f) => { + const active = f.id === activeFolder; + return ( + + ); + })} +
{/* ── message list ── */} @@ -248,15 +328,20 @@ export const MessagesPage: React.FC = () => {
{folder.f.name}
- {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'))}
{ account={folder.a} identities={identities} flags={flags} + folderState={folderState} onViewDoc={setPdfDocId} onOpenAccounting={() => navigate('/admin/accounting/inbox')} onCompose={(init, title) => setComposer({ init, title, accountKey: 'customers' })} onOpenDoc={(docType, senderEmail) => setDocAction({ docType, senderEmail })} + onItemAction={doItemAction} t={t} /> @@ -310,10 +397,11 @@ const MessageList: React.FC<{ queue?: import('../../../services/email.service').EmailQueueItem[]; received?: ReceivedEmail[]; loading: boolean; + search: string; selection: Selection; onSelect: (s: Selection) => void; 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') { return (
@@ -324,31 +412,39 @@ const MessageList: React.FC<{ } if (loading) return
; - const rows = - folder.src === 'queue' - ? (queue || []).map((m) => ({ - key: `q${m.id}`, - onClick: () => onSelect({ kind: 'queue', id: m.id }), - active: selection?.kind === 'queue' && selection.id === m.id, - who: m.recipientEmail, - subject: friendlyType(m.emailType), - when: fmt(m.sentAt || m.createdAt), - status: m.status, - attach: 0, - })) - : (received || []).map((m) => ({ - key: `r${m.id}`, - onClick: () => onSelect({ kind: 'received', item: m }), - active: selection?.kind === 'received' && selection.item.id === m.id, - who: m.from_address || '—', - subject: m.subject || t('messages.noSubject', '(no subject)'), - when: fmt(m.received_at), - status: m.status, - attach: m.attachment_count, - })); + const qRows = (queue || []).map((m) => ({ + key: `q${m.id}`, + sortKey: m.sentAt || m.createdAt || '', + onClick: () => onSelect({ kind: 'queue', id: m.id }), + active: selection?.kind === 'queue' && selection.id === m.id, + who: m.recipientEmail, + subject: friendlyType(m.emailType), + when: fmt(m.sentAt || m.createdAt), + status: m.status, + attach: 0, + })); + const rRows = (received || []).map((m) => ({ + key: `r${m.id}`, + sortKey: m.received_at || '', + onClick: () => onSelect({ kind: 'received', item: m }), + active: selection?.kind === 'received' && selection.item.id === m.id, + who: m.from_address || '—', + subject: m.subject || t('messages.noSubject', '(no subject)'), + when: fmt(m.received_at), + status: m.status, + 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) { - return
{t('messages.noMessages', 'No messages')}
; + return
{q ? t('messages.noSearchResults', 'No matches') : t('messages.noMessages', 'No messages')}
; } return ( @@ -391,12 +487,14 @@ const ReadingPane: React.FC<{ account: Account; identities?: MailIdentities | null; flags: Record; + folderState?: 'archived' | 'deleted'; onViewDoc: (id: number) => void; onOpenAccounting: () => void; onCompose: (init: ComposerInit, title?: string) => void; onOpenDoc: (docType: DocType, senderEmail: string) => void; + onItemAction: (action: 'archive' | 'delete' | 'restore') => void; 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({ queryKey: ['messages', 'queue', selection?.kind === 'queue' ? selection.id : null], queryFn: () => emailService.getQueueItem((selection as { kind: 'queue'; id: number }).id), @@ -442,7 +540,7 @@ const ReadingPane: React.FC<{ return (
- +
{selection.kind === 'queue' ? ( detailQuery.isLoading ? : detailQuery.data ? ( @@ -579,10 +677,12 @@ const ReceivedDetail: React.FC<{ const Toolbar: React.FC<{ isAcct: boolean; flags: Record; + folderState?: 'archived' | 'deleted'; onReply?: () => void; onDoc?: (docType: DocType) => void; + onItemAction: (action: 'archive' | 'delete' | 'restore') => void; 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 enabled = !!onClick; return ( @@ -619,8 +719,13 @@ const Toolbar: React.FC<{ )} - - + {folderState && onItemAction('restore')} />} + {folderState !== 'archived' && onItemAction('archive')} />} + onItemAction('delete')} + />
); }; diff --git a/frontend/src/services/email.service.ts b/frontend/src/services/email.service.ts index ad008a90..18b42934 100644 --- a/frontend/src/services/email.service.ts +++ b/frontend/src/services/email.service.ts @@ -220,10 +220,18 @@ export const emailService = { const response = await api.post('/admin/email/incoming-config/poll', {}); return response.data; }, - async listReceived(params: { page?: number; pageSize?: number; account?: string } = {}): Promise { + async listReceived(params: { page?: number; pageSize?: number; account?: string; state?: 'active' | 'archived' | 'deleted' } = {}): Promise { const response = await api.get('/admin/email/received', { params }); return response.data; }, + /** Archive / Delete (soft) / Restore an email. kind = 'queue' | 'received'. */ + async setItemState(kind: 'queue' | 'received', id: number, state: 'active' | 'archived' | 'deleted'): Promise { + 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 { + await api.delete(`/admin/email/item/${kind}/${id}`); + }, async getReceivedItem(id: number): Promise { const response = await api.get(`/admin/email/received/${id}`); return response.data; @@ -262,6 +270,7 @@ export const emailService = { status?: EmailQueueStatus; emailType?: string; origin?: 'system' | 'manual'; + state?: 'active' | 'archived' | 'deleted'; q?: string; from?: string; to?: string;