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:
@@ -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'); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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,10 +412,9 @@ 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'
|
|
||||||
? (queue || []).map((m) => ({
|
|
||||||
key: `q${m.id}`,
|
key: `q${m.id}`,
|
||||||
|
sortKey: m.sentAt || m.createdAt || '',
|
||||||
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,
|
||||||
@@ -335,9 +422,10 @@ const MessageList: React.FC<{
|
|||||||
when: fmt(m.sentAt || m.createdAt),
|
when: fmt(m.sentAt || m.createdAt),
|
||||||
status: m.status,
|
status: m.status,
|
||||||
attach: 0,
|
attach: 0,
|
||||||
}))
|
}));
|
||||||
: (received || []).map((m) => ({
|
const rRows = (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 || '—',
|
||||||
@@ -347,8 +435,16 @@ const MessageList: React.FC<{
|
|||||||
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user