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 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) {