diff --git a/backend/migrations/0040_gtd_delegations.sql b/backend/migrations/0040_gtd_delegations.sql new file mode 100644 index 00000000..c637ffbb --- /dev/null +++ b/backend/migrations/0040_gtd_delegations.sql @@ -0,0 +1,15 @@ +CREATE TABLE gtd_delegations ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + account_id UUID NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE, + thread_key TEXT NOT NULL, + contact_id UUID REFERENCES contacts(id) ON DELETE SET NULL, + contact_display_name_snapshot TEXT NOT NULL, + contact_primary_email_snapshot TEXT, + delegated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, account_id, thread_key) +); + +CREATE INDEX idx_gtd_delegations_contact_id + ON gtd_delegations(contact_id) + WHERE contact_id IS NOT NULL; diff --git a/backend/src/routes/gtd.classify.test.js b/backend/src/routes/gtd.classify.test.js index 38a6ea86..0babeba8 100644 --- a/backend/src/routes/gtd.classify.test.js +++ b/backend/src/routes/gtd.classify.test.js @@ -22,11 +22,16 @@ vi.mock('../services/gtdConfig.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getGtdConfig: vi.fn() }; }); +vi.mock('../services/gtdDelegations.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, delegateMessages: vi.fn(), reconcileDelegatedRemovals: vi.fn() }; +}); import express from 'express'; import { query } from '../services/db.js'; import { imapManager } from '../index.js'; import { getGtdConfig, DEFAULT_GTD_FOLDERS } from '../services/gtdConfig.js'; +import { delegateMessages, GtdDelegationError, reconcileDelegatedRemovals } from '../services/gtdDelegations.js'; import gtdRoutes from './gtd.js'; const MSG_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; @@ -63,6 +68,9 @@ const classify = (body) => fetch(`${base}/api/gtd/classify`, { const unclassify = (body) => fetch(`${base}/api/gtd/classify`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); +const delegate = (body) => fetch(`${base}/api/gtd/delegations`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), +}); let server; let base; @@ -81,9 +89,44 @@ beforeEach(() => { Object.values(imapManager).forEach(fn => fn.mockReset()); getGtdConfig.mockReset(); getGtdConfig.mockResolvedValue({ enabled: true, folders: DEFAULT_GTD_FOLDERS }); + reconcileDelegatedRemovals.mockReset(); + delegateMessages.mockReset(); stubQueries(); }); +describe('POST /api/gtd/delegations', () => { + it('deduplicates valid IDs and returns the structured bulk result', async () => { + delegateMessages.mockResolvedValue({ + status: 'success', successCount: 1, failureCount: 0, + results: [{ messageId: MSG_ID, ok: true }], + }); + const res = await delegate({ messageIds: [MSG_ID, MSG_ID], contactId: null }); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ status: 'success', successCount: 1 }); + expect(delegateMessages).toHaveBeenCalledWith(expect.objectContaining({ + userId: 'u1', messageIds: [MSG_ID], contactId: null, imapManager, + })); + }); + + it('rejects invalid shapes before calling the service', async () => { + for (const body of [ + {}, { messageIds: [], contactId: null }, { messageIds: ['bad'], contactId: null }, + { messageIds: [MSG_ID], contactId: 'bad' }, + ]) { + const res = await delegate(body); + expect(res.status).toBe(400); + } + expect(delegateMessages).not.toHaveBeenCalled(); + }); + + it('maps an unowned contact to the same 404 as an absent contact', async () => { + delegateMessages.mockRejectedValue(new GtdDelegationError('contact_not_found', 404)); + const res = await delegate({ messageIds: [MSG_ID], contactId: ACCT_ID }); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: 'contact_not_found' }); + }); +}); + describe('POST /api/gtd/classify — request validation', () => { it('rejects a missing messageId/state with 400 before any lookup', async () => { const res = await classify({ state: 'todo' }); @@ -136,6 +179,17 @@ describe('POST /api/gtd/classify — apply a GTD label (COPY)', () => { }); describe('DELETE /api/gtd/classify — remove a GTD label', () => { + it('clears person metadata after removing the Delegated label', async () => { + const delegated = { ...inboxMsg, thread_key: 'thread-a' }; + stubQueries({ msg: delegated }); + const res = await unclassify({ messageId: MSG_ID, state: 'delegated' }); + expect(res.status).toBe(200); + expect(reconcileDelegatedRemovals).toHaveBeenCalledWith({ + userId: 'u1', accountId: ACCT_ID, + delegatedFolder: 'Delegated', threadKeys: ['thread-a'], + }); + }); + it('removes the sibling copy in the state folder and returns removed:true', async () => { const res = await unclassify({ messageId: MSG_ID, state: 'todo' }); expect(res.status).toBe(200); diff --git a/backend/src/routes/gtd.done.test.js b/backend/src/routes/gtd.done.test.js index aa3f47ee..33f45b3d 100644 --- a/backend/src/routes/gtd.done.test.js +++ b/backend/src/routes/gtd.done.test.js @@ -34,12 +34,16 @@ vi.mock('../services/gtdConfig.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getGtdConfig: vi.fn() }; }); +vi.mock('../services/gtdDelegations.js', async (importOriginal) => ({ + ...(await importOriginal()), reconcileDelegatedRemovals: vi.fn(), delegateMessages: vi.fn(), +})); import express from 'express'; import { query } from '../services/db.js'; import { imapManager } from '../index.js'; import { resolveArchiveFolder, isAllMailFolder, adjustFolderCounts, fanOutReadToSiblings } from '../utils/mailUtils.js'; import { getGtdConfig, DEFAULT_GTD_FOLDERS } from '../services/gtdConfig.js'; +import { reconcileDelegatedRemovals } from '../services/gtdDelegations.js'; import gtdRoutes from './gtd.js'; const MSG_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; @@ -47,7 +51,7 @@ const ACCT_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; // The rail acts on the Watch-folder copy; a distinct INBOX sibling is what the archive step // moves. is_read true on both keeps the mark-read path off the IMAP setFlag mock. -const msg = { id: MSG_ID, account_id: ACCT_ID, uid: 10, folder: 'Watch', message_id: '', is_read: true }; +const msg = { id: MSG_ID, account_id: ACCT_ID, uid: 10, folder: 'Watch', message_id: '', thread_key: 'thread-a', is_read: true }; const account = { id: ACCT_ID, user_id: 'u1', folder_mappings: {} }; const inboxCopy = { id: 'ib-1', uid: 77, is_read: true }; @@ -91,6 +95,7 @@ beforeEach(() => { query.mockReset(); Object.values(imapManager).forEach(fn => fn.mockReset()); [resolveArchiveFolder, isAllMailFolder, adjustFolderCounts, fanOutReadToSiblings, getGtdConfig].forEach(fn => fn.mockReset()); + reconcileDelegatedRemovals.mockReset(); getGtdConfig.mockResolvedValue({ enabled: true, folders: DEFAULT_GTD_FOLDERS }); resolveArchiveFolder.mockResolvedValue('Archive'); isAllMailFolder.mockResolvedValue(false); @@ -107,6 +112,16 @@ describe('POST /api/gtd/done — id validation', () => { }); describe('POST /api/gtd/done — archive count-adjust race', () => { + it('clears person metadata after successfully stripping Delegated', async () => { + stubQueries(); + const res = await done({ id: MSG_ID, states: ['delegated'] }); + expect(res.status).toBe(200); + expect(reconcileDelegatedRemovals).toHaveBeenCalledWith({ + userId: 'u1', accountId: ACCT_ID, + delegatedFolder: 'Delegated', threadKeys: ['thread-a'], + }); + }); + it('archives + adjusts both counts when the INBOX-scoped write applied (rowCount 1)', async () => { stubQueries({ archiveWrite: { rowCount: 1 } }); imapManager.moveMessage.mockResolvedValue(88); // UIDPLUS newUid diff --git a/backend/src/routes/gtd.js b/backend/src/routes/gtd.js index 39fdedd2..242e560d 100644 --- a/backend/src/routes/gtd.js +++ b/backend/src/routes/gtd.js @@ -8,6 +8,11 @@ import { fanOutReadToSiblings } from '../utils/mailUtils.js'; import { archiveInboxCopy } from '../services/archiveInbox.js'; import { query } from '../services/db.js'; import { imapManager } from '../index.js'; +import { + delegateMessages, + GtdDelegationError, + reconcileDelegatedRemovals, +} from '../services/gtdDelegations.js'; const router = Router(); router.use(requireAuth); @@ -17,6 +22,20 @@ router.use(requireAuth); // driver cast error. Same idiom + regex as mail.js. const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +export function parseDelegationBody(body) { + if (!Array.isArray(body?.messageIds)) { + throw new GtdDelegationError('invalid_request', 400, 'messageIds must be an array'); + } + const messageIds = [...new Set(body.messageIds)]; + if (messageIds.length < 1 || messageIds.length > 100 || messageIds.some(id => !UUID_RE.test(id))) { + throw new GtdDelegationError('invalid_request', 400, 'messageIds must contain 1 to 100 UUIDs'); + } + if (body.contactId !== null && !UUID_RE.test(body.contactId || '')) { + throw new GtdDelegationError('invalid_request', 400, 'contactId must be a UUID or null'); + } + return { messageIds, contactId: body.contactId }; +} + // Shared classify precondition: an account must have GTD enabled and the request's // state must resolve to a designated folder. Returns { folder } to proceed, or // { status, error } to reject. Pure — exported for unit tests. @@ -84,6 +103,24 @@ router.get('/sections', async (req, res) => { }).catch(err => console.warn('GTD gist generation error:', err.message)); }); +router.post('/delegations', async (req, res, next) => { + try { + const { messageIds, contactId } = parseDelegationBody(req.body); + const result = await delegateMessages({ + userId: req.session.userId, + messageIds, + contactId, + imapManager, + }); + res.json(result); + } catch (error) { + if (error instanceof GtdDelegationError) { + return res.status(error.status).json({ error: error.code }); + } + next(error); + } +}); + // ── GTD Inbox-Zero pet ──────────────────────────────────────────────────────── // POST /api/gtd/pet/import { petJson, sheet } — import a user's OWN pet by uploading the @@ -225,10 +262,24 @@ router.delete('/classify', async (req, res) => { return res.status(400).json({ error: 'Message has no Message-ID — cannot resolve GTD copy' }); } const siblingUid = await resolveCopyUid(msg, stateFolder); - if (siblingUid == null) return res.json({ ok: true, removed: false }); + if (siblingUid == null) { + if (state === 'delegated' && msg.thread_key) { + await reconcileDelegatedRemovals({ + userId: req.session.userId, accountId: msg.account_id, + delegatedFolder: stateFolder, threadKeys: [msg.thread_key], + }); + } + return res.json({ ok: true, removed: false }); + } try { await imapManager.removeMessageCopy(msg.account_id, siblingUid, stateFolder); + if (state === 'delegated' && msg.thread_key) { + await reconcileDelegatedRemovals({ + userId: req.session.userId, accountId: msg.account_id, + delegatedFolder: stateFolder, threadKeys: [msg.thread_key], + }); + } } catch (err) { console.error(`GTD unclassify failed for message ${messageId} in ${stateFolder}:`, err.message); return res.status(500).json({ error: 'Failed to remove GTD label' }); @@ -315,9 +366,16 @@ router.post('/done', async (req, res) => { try { for (const folder of stripOrder) { const uid = await resolveCopyUid(msg, folder); - if (uid == null) continue; // already gone - await imapManager.removeMessageCopy(msg.account_id, uid, folder); - removed.push(folder); + if (uid != null) { + await imapManager.removeMessageCopy(msg.account_id, uid, folder); + removed.push(folder); + } + if (folder === folders.delegated && msg.thread_key) { + await reconcileDelegatedRemovals({ + userId: req.session.userId, accountId: msg.account_id, + delegatedFolder: folder, threadKeys: [msg.thread_key], + }); + } } } catch (err) { console.error(`GTD done: label strip for ${id} failed:`, err.message); diff --git a/backend/src/routes/mail.js b/backend/src/routes/mail.js index 5aa52b4e..e84999f1 100644 --- a/backend/src/routes/mail.js +++ b/backend/src/routes/mail.js @@ -13,6 +13,7 @@ import { listMessages } from '../services/messageService.js'; import { resolveAccountScope } from '../services/unifiedInbox.js'; import { validateHost } from '../services/hostValidation.js'; import { safeFetch } from '../services/safeFetch.js'; +import { DELEGATION_SELECT_SQL, delegationJoinSql, mapDelegationRow } from '../services/gtdDelegations.js'; const router = Router(); router.use(requireAuth); @@ -147,15 +148,17 @@ router.get('/messages/:id', async (req, res) => { m.has_attachments, m.account_id, m.category, m.list_unsubscribe, m.list_unsubscribe_post, m.unsubscribed_at, m.delivery_addresses, a.name AS account_name, a.email_address AS account_email, - a.color AS account_color + a.color AS account_color, + ${DELEGATION_SELECT_SQL} FROM messages m JOIN email_accounts a ON m.account_id = a.id + ${delegationJoinSql('m', 'a')} WHERE m.id = $1 AND a.user_id = $2 AND m.is_deleted = false `, [id, req.session.userId]); if (!result.rows.length) return res.status(404).json({ error: 'Message not found' }); - res.json(result.rows[0]); + res.json({ ...result.rows[0], delegation: mapDelegationRow(result.rows[0]) }); } catch (err) { console.error('GET /messages/:id error:', err.message); res.status(500).json({ error: 'Failed to load message' }); @@ -184,7 +187,8 @@ router.get('/resolve-message', async (req, res) => { m.has_attachments, m.account_id, m.category, m.list_unsubscribe, m.list_unsubscribe_post, m.unsubscribed_at, m.delivery_addresses, a.name AS account_name, a.email_address AS account_email, - a.color AS account_color`; + a.color AS account_color, + ${DELEGATION_SELECT_SQL}`; try { // Durable match on the stable Message-ID header. When the same email exists in more // than one folder (e.g. INBOX + Archive), prefer the INBOX copy, then the most recent. @@ -192,6 +196,7 @@ router.get('/resolve-message', async (req, res) => { SELECT ${COLS} FROM messages m JOIN email_accounts a ON m.account_id = a.id + ${delegationJoinSql('m', 'a')} WHERE m.message_id = $1 AND a.user_id = $2 AND m.is_deleted = false @@ -205,6 +210,7 @@ router.get('/resolve-message', async (req, res) => { SELECT ${COLS} FROM messages m JOIN email_accounts a ON m.account_id = a.id + ${delegationJoinSql('m', 'a')} WHERE m.id = $1 AND a.user_id = $2 AND m.is_deleted = false @@ -212,7 +218,7 @@ router.get('/resolve-message', async (req, res) => { `, [ref, req.session.userId, accountId]); } if (result.rows.length === 0) return res.status(404).json({ error: 'Message not found' }); - res.json(result.rows[0]); + res.json({ ...result.rows[0], delegation: mapDelegationRow(result.rows[0]) }); } catch (err) { console.error('GET /resolve-message error:', err.message); res.status(500).json({ error: 'Failed to resolve message' }); @@ -262,9 +268,11 @@ router.get('/thread/:threadId', async (req, res) => { m.date, m.snippet, m.is_read, m.is_starred, m.has_attachments, m.account_id, m.category, m.list_unsubscribe, m.list_unsubscribe_post, m.unsubscribed_at, m.delivery_addresses, - a.name AS account_name, a.email_address AS account_email, a.color AS account_color + a.name AS account_name, a.email_address AS account_email, a.color AS account_color, + ${DELEGATION_SELECT_SQL} FROM messages m JOIN email_accounts a ON m.account_id = a.id + ${delegationJoinSql('m', 'a')} WHERE m.is_deleted = false AND m.account_id = ANY($1) AND m.thread_key = $2 @@ -275,7 +283,7 @@ router.get('/thread/:threadId', async (req, res) => { SELECT * FROM deduped ORDER BY date ASC `, [accountIds, threadId]); - res.json({ messages: result.rows }); + res.json({ messages: result.rows.map(row => ({ ...row, delegation: mapDelegationRow(row) })) }); } catch (err) { console.error('Thread fetch error:', err); res.status(500).json({ error: 'Failed to load thread' }); diff --git a/backend/src/routes/mail.resolve.test.js b/backend/src/routes/mail.resolve.test.js index 774d4e5e..70bd18f2 100644 --- a/backend/src/routes/mail.resolve.test.js +++ b/backend/src/routes/mail.resolve.test.js @@ -56,13 +56,18 @@ describe('GET /api/mail/resolve-message account scope', () => { }); it('keeps unscoped deep-link resolution backward compatible', async () => { - query.mockResolvedValueOnce({ rows: [{ id: 'current-row', account_id: ACCOUNT_ID }] }); + query.mockResolvedValueOnce({ rows: [{ + id: 'current-row', account_id: ACCOUNT_ID, + delegation: '{"contact_id":null,"display_name":"Casey"}', + }] }); const response = await fetch(`${base}/api/mail/resolve-message?ref=${encodeURIComponent(MESSAGE_ID)}`); expect(response.status).toBe(200); const [, params] = query.mock.calls[0]; expect(params).toEqual([MESSAGE_ID, 'user-1', null]); + expect((await response.json()).delegation).toEqual({ contact_id: null, display_name: 'Casey' }); + expect(query.mock.calls[0][0]).toContain('gtd_delegations'); }); it('rejects a malformed account scope before querying', async () => { diff --git a/backend/src/routes/search.js b/backend/src/routes/search.js index 9572f57d..d93ff800 100644 --- a/backend/src/routes/search.js +++ b/backend/src/routes/search.js @@ -2,6 +2,7 @@ import { Router } from 'express'; import { query } from '../services/db.js'; import { requireAuth } from '../middleware/auth.js'; import { resolveAccountScope } from '../services/unifiedInbox.js'; +import { DELEGATION_SELECT_SQL, delegationJoinSql, mapDelegationRow } from '../services/gtdDelegations.js'; const router = Router(); router.use(requireAuth); @@ -241,9 +242,11 @@ router.get('/', searchLimiter, async (req, res) => { SELECT m.id, m.uid, m.folder, m.subject, m.from_name, m.from_email, m.date, m.snippet, m.is_read, m.is_starred, m.has_attachments, m.account_id, - a.name as account_name, a.email_address as account_email, a.color as account_color + a.name as account_name, a.email_address as account_email, a.color as account_color, + ${DELEGATION_SELECT_SQL} FROM messages m JOIN email_accounts a ON m.account_id = a.id + ${delegationJoinSql('m', 'a')} WHERE m.account_id = ANY($1) AND m.is_deleted = false AND ${conditions.join('\n AND ')} @@ -251,7 +254,10 @@ router.get('/', searchLimiter, async (req, res) => { LIMIT $${p} OFFSET $${p + 1} `, params); - res.json({ messages: result.rows, query: q }); + res.json({ + messages: result.rows.map(row => ({ ...row, delegation: mapDelegationRow(row) })), + query: q, + }); } catch (err) { console.error('Search error:', err); res.status(500).json({ error: 'Search failed' }); diff --git a/backend/src/routes/search.test.js b/backend/src/routes/search.test.js index 3ef54c76..876e61ec 100644 --- a/backend/src/routes/search.test.js +++ b/backend/src/routes/search.test.js @@ -1,4 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; // search.js opens a DB handle and registers auth middleware at import time; // neither is exercised by the pure parser under test, so stub them out. @@ -155,3 +156,9 @@ describe('freeTextTermCondition (oversized-body crash hotfix)', () => { expect(cond).toContain("m.search_vector @@ plainto_tsquery('english', $4)"); }); }); + +it('projects delegation metadata in message search without an N+1 lookup', () => { + const source = readFileSync(new URL('./search.js', import.meta.url), 'utf8'); + expect(source).toContain("delegationJoinSql('m', 'a')"); + expect(source).toContain('mapDelegationRow(row)'); +}); diff --git a/backend/src/services/gtdDelegations.js b/backend/src/services/gtdDelegations.js new file mode 100644 index 00000000..edb5e082 --- /dev/null +++ b/backend/src/services/gtdDelegations.js @@ -0,0 +1,266 @@ +import { query } from './db.js'; +import { getGtdConfig, resolveGtdStateFolder } from './gtdConfig.js'; +import { createKeyedSerializer } from '../utils/keyedSerializer.js'; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const serializeDelegation = createKeyedSerializer(); + +export class GtdDelegationError extends Error { + constructor(code, status, message = code) { + super(message); + this.code = code; + this.status = status; + } +} + +export function mapDelegationRow(row) { + if (!row?.delegation) return null; + return typeof row.delegation === 'string' ? JSON.parse(row.delegation) : row.delegation; +} + +export async function loadOwnedContactSnapshot(userId, contactId) { + const { rows } = await query(` + SELECT c.id, COALESCE(c.display_name, c.primary_email, 'Unknown contact') AS display_name, + c.primary_email + FROM contacts c + WHERE c.id = $1 AND c.user_id = $2 + `, [contactId, userId]); + if (!rows[0]) throw new GtdDelegationError('contact_not_found', 404); + return rows[0]; +} + +export async function upsertDelegation({ userId, accountId, threadKey, contact }) { + const { rows } = await query(` + INSERT INTO gtd_delegations ( + user_id, account_id, thread_key, contact_id, + contact_display_name_snapshot, contact_primary_email_snapshot + ) VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (user_id, account_id, thread_key) DO UPDATE SET + contact_id = EXCLUDED.contact_id, + contact_display_name_snapshot = EXCLUDED.contact_display_name_snapshot, + contact_primary_email_snapshot = EXCLUDED.contact_primary_email_snapshot, + delegated_at = CASE + WHEN gtd_delegations.contact_id IS DISTINCT FROM EXCLUDED.contact_id THEN NOW() + ELSE gtd_delegations.delegated_at + END, + updated_at = NOW() + RETURNING contact_id, + contact_display_name_snapshot AS display_name, + contact_primary_email_snapshot AS primary_email, + delegated_at, updated_at + `, [userId, accountId, threadKey, contact.id, contact.display_name, contact.primary_email]); + return rows[0]; +} + +export async function clearDelegations({ userId, accountId, threadKeys }) { + const unique = [...new Set(threadKeys.filter(Boolean))]; + if (unique.length === 0) return 0; + const result = await query(` + DELETE FROM gtd_delegations + WHERE user_id = $1 AND account_id = $2 AND thread_key = ANY($3::text[]) + `, [userId, accountId, unique]); + return result.rowCount; +} + +export async function reconcileDelegatedRemovals({ userId, accountId, delegatedFolder, threadKeys }) { + const unique = [...new Set(threadKeys.filter(Boolean))]; + if (!unique.length) return 0; + const result = await query(` + DELETE FROM gtd_delegations gd + WHERE gd.user_id = $1 + AND gd.account_id = $2 + AND gd.thread_key = ANY($3::text[]) + AND NOT EXISTS ( + SELECT 1 FROM messages m + WHERE m.account_id = gd.account_id + AND m.thread_key = gd.thread_key + AND m.folder = $4 + AND m.is_deleted = false + ) + `, [userId, accountId, unique, delegatedFolder]); + return result.rowCount; +} + +export async function sweepStaleDelegations({ userId, accountId, delegatedFolder }) { + const result = await query(` + DELETE FROM gtd_delegations gd + WHERE gd.user_id = $1 + AND gd.account_id = $2 + AND NOT EXISTS ( + SELECT 1 FROM messages m + WHERE m.account_id = gd.account_id + AND m.thread_key = gd.thread_key + AND m.folder = $3 + AND m.is_deleted = false + ) + `, [userId, accountId, delegatedFolder]); + return result.rowCount; +} + +export const DELEGATION_SELECT_SQL = 'delegation_meta.delegation AS delegation'; + +export function delegationJoinSql(messageAlias = 'm', accountAlias = 'a') { + if (![messageAlias, accountAlias].every(alias => /^[a-z][a-z0-9_]*$/i.test(alias))) { + throw new TypeError('Invalid SQL alias'); + } + return `LEFT JOIN LATERAL ( + SELECT jsonb_build_object( + 'contact_id', gd.contact_id, + 'display_name', COALESCE(dc.display_name, gd.contact_display_name_snapshot), + 'primary_email', COALESCE(dc.primary_email, gd.contact_primary_email_snapshot), + 'delegated_at', gd.delegated_at, + 'updated_at', gd.updated_at + ) AS delegation + FROM gtd_delegations gd + LEFT JOIN contacts dc ON dc.id = gd.contact_id AND dc.user_id = gd.user_id + WHERE gd.user_id = ${accountAlias}.user_id + AND gd.account_id = ${messageAlias}.account_id + AND gd.thread_key = ${messageAlias}.thread_key + ) delegation_meta ON TRUE`; +} + +async function loadOwnedTargets(userId, messageIds) { + const { rows } = await query(` + SELECT m.id, m.account_id, m.uid, m.folder, m.message_id, m.thread_key + FROM messages m + JOIN email_accounts a ON a.id = m.account_id + WHERE a.user_id = $1 AND m.id = ANY($2::uuid[]) AND m.is_deleted = false + `, [userId, messageIds]); + return rows; +} + +async function loadAccount(accountId, userId) { + const { rows } = await query( + 'SELECT * FROM email_accounts WHERE id = $1 AND user_id = $2 AND enabled = true', + [accountId, userId], + ); + return rows[0] || null; +} + +async function liveDelegatedCopies(target, folder) { + const { rows } = await query(` + SELECT uid FROM messages + WHERE account_id = $1 AND thread_key = $2 AND folder = $3 AND is_deleted = false + ORDER BY date DESC NULLS LAST + `, [target.account_id, target.thread_key, folder]); + return rows; +} + +async function compensateCopy({ target, folder, copiedUid, imapManager }) { + try { + if (copiedUid == null) return false; + await imapManager.removeMessageCopy(target.account_id, copiedUid, folder); + return true; + } catch { + return false; + } +} + +const publicFailure = (messageId, code = 'operation_failed', compensated = false) => ({ + messageId, + ok: false, + error: { code, message: code === 'not_found' ? 'Message not found' : 'Delegation failed' }, + compensated, +}); + +export async function delegateMessages({ userId, messageIds, contactId, imapManager }) { + if (!Array.isArray(messageIds)) throw new GtdDelegationError('invalid_request', 400); + const inputIds = [...new Set(messageIds)]; + if (inputIds.length < 1 || inputIds.length > 100 || inputIds.some(id => !UUID_RE.test(id))) { + throw new GtdDelegationError('invalid_request', 400); + } + if (contactId !== null && !UUID_RE.test(contactId || '')) { + throw new GtdDelegationError('invalid_request', 400); + } + const contact = contactId === null ? null : await loadOwnedContactSnapshot(userId, contactId); + const rows = await loadOwnedTargets(userId, inputIds); + const byId = new Map(rows.map(row => [row.id, row])); + const outcomes = new Map(inputIds.filter(id => !byId.has(id)).map(id => [id, publicFailure(id, 'not_found')])); + const threads = new Map(); + for (const id of inputIds) { + const row = byId.get(id); + if (!row) continue; + if (!row.thread_key) { + outcomes.set(id, publicFailure(id)); + continue; + } + const key = `${row.account_id}\u0000${row.thread_key}`; + const item = threads.get(key) || { target: row, ids: [] }; + item.ids.push(id); + threads.set(key, item); + } + + for (const [threadKey, { target, ids }] of threads) { + await serializeDelegation(threadKey, async () => { + let copyAttempted = false; + let copiedUid = null; + let account; + let delegatedFolder = null; + try { + account = await loadAccount(target.account_id, userId); + if (!account) throw new Error('account unavailable'); + const config = await getGtdConfig(target.account_id); + delegatedFolder = config.enabled ? resolveGtdStateFolder('delegated', config.folders) : null; + if (!delegatedFolder) throw new Error('delegated folder unavailable'); + await imapManager.ensureFolder(account, delegatedFolder); + let existing = await liveDelegatedCopies(target, delegatedFolder); + if (!existing.length) { + await imapManager.syncFolderOnDemand(account, delegatedFolder); + existing = await liveDelegatedCopies(target, delegatedFolder); + } + if (!existing.length) { + // The remote COPY can succeed before a later local insert fails, so the attempt + // must be marked before awaiting it. The catch path then reconciles the folder + // and removes any copy whose outcome was ambiguous. + copyAttempted = true; + copiedUid = await imapManager.copyMessage( + target.account_id, target.uid, target.folder, delegatedFolder, + ); + if (copiedUid == null) { + // Non-UIDPLUS COPY is materialized by destination sync. Await the shared + // in-flight sync before releasing this thread's serializer, otherwise an + // immediate retry could issue a second remote COPY. + await imapManager.syncFolderOnDemand(account, delegatedFolder); + existing = await liveDelegatedCopies(target, delegatedFolder); + if (!existing.length) throw new Error('delegated copy did not reconcile'); + // Attribute a destination UID only when reconciliation found exactly one. + // Multiple copies can mean a concurrent external client also labeled the + // thread, and compensation must never guess which one belongs to this call. + if (existing.length === 1) copiedUid = existing[0].uid; + } + } + const delegation = contact + ? await upsertDelegation({ + userId, accountId: target.account_id, threadKey: target.thread_key, contact, + }) + : (await clearDelegations({ + userId, accountId: target.account_id, threadKeys: [target.thread_key], + }), null); + for (const messageId of ids) outcomes.set(messageId, { + messageId, + ok: true, + accountId: target.account_id, + threadKey: target.thread_key, + delegation, + }); + } catch (error) { + if (copiedUid == null && error?.copiedUid != null) copiedUid = error.copiedUid; + const compensated = copyAttempted && delegatedFolder && copiedUid != null + ? await compensateCopy({ target, folder: delegatedFolder, copiedUid, imapManager }) + : false; + const code = error instanceof GtdDelegationError ? error.code : 'operation_failed'; + for (const messageId of ids) outcomes.set(messageId, publicFailure(messageId, code, compensated)); + } + }); + } + + const results = inputIds.map(id => outcomes.get(id)); + const successCount = results.filter(result => result.ok).length; + const failureCount = results.length - successCount; + return { + status: failureCount === 0 ? 'success' : successCount === 0 ? 'failed' : 'partial', + successCount, + failureCount, + results, + }; +} diff --git a/backend/src/services/gtdDelegations.test.js b/backend/src/services/gtdDelegations.test.js new file mode 100644 index 00000000..a2fcc0e8 --- /dev/null +++ b/backend/src/services/gtdDelegations.test.js @@ -0,0 +1,224 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./db.js', () => ({ query: vi.fn() })); +vi.mock('./gtdConfig.js', () => ({ + getGtdConfig: vi.fn(), + resolveGtdStateFolder: vi.fn((_state, folders) => folders.delegated), +})); + +import { query } from './db.js'; +import { getGtdConfig } from './gtdConfig.js'; +import { + delegateMessages, + delegationJoinSql, + loadOwnedContactSnapshot, + mapDelegationRow, + reconcileDelegatedRemovals, + sweepStaleDelegations, + upsertDelegation, +} from './gtdDelegations.js'; + +const USER = '11111111-1111-4111-8111-111111111111'; +const ACCOUNT = '22222222-2222-4222-8222-222222222222'; +const MESSAGE_A = '33333333-3333-4333-8333-333333333333'; +const MESSAGE_B = '44444444-4444-4444-8444-444444444444'; +const CONTACT = '55555555-5555-4555-8555-555555555555'; +const contact = { id: CONTACT, display_name: 'Casey Rivera', primary_email: 'casey@example.test' }; +const row = id => ({ + id, account_id: ACCOUNT, uid: id === MESSAGE_A ? 10 : 11, folder: 'INBOX', + message_id: `<${id}@example.test>`, thread_key: 'thread@example.test', +}); + +const imapManager = () => ({ + ensureFolder: vi.fn(), copyMessage: vi.fn().mockResolvedValue(77), + removeMessageCopy: vi.fn(), syncFolderOnDemand: vi.fn(), +}); + +function stubSuccess({ messages = [row(MESSAGE_A)], existing = false, insertError = null } = {}) { + query.mockImplementation(async sql => { + if (sql.includes('FROM contacts c')) return { rows: [contact] }; + if (sql.includes('FROM messages m') && sql.includes('ANY($2::uuid[])')) return { rows: messages }; + if (sql.startsWith('SELECT * FROM email_accounts')) return { rows: [{ id: ACCOUNT, user_id: USER }] }; + if (sql.includes('SELECT uid FROM messages')) return { rows: existing ? [{ uid: 88 }] : [] }; + if (sql.includes('INSERT INTO gtd_delegations')) { + if (insertError) throw insertError; + return { rows: [{ contact_id: CONTACT, display_name: contact.display_name, primary_email: contact.primary_email }] }; + } + if (sql.includes('DELETE FROM gtd_delegations')) return { rows: [], rowCount: 1 }; + return { rows: [], rowCount: 0 }; + }); +} + +beforeEach(() => { + query.mockReset(); + getGtdConfig.mockReset(); + getGtdConfig.mockResolvedValue({ enabled: true, folders: { delegated: 'Delegated' } }); +}); + +describe('delegation persistence primitives', () => { + it('rejects a contact owned by another user without exposing it', async () => { + query.mockResolvedValueOnce({ rows: [] }); + await expect(loadOwnedContactSnapshot(USER, CONTACT)) + .rejects.toMatchObject({ code: 'contact_not_found', status: 404 }); + }); + + it('resets the age anchor only when the contact changes', async () => { + query.mockResolvedValueOnce({ rows: [contact] }); + await upsertDelegation({ userId: USER, accountId: ACCOUNT, threadKey: 'thread', contact }); + expect(query.mock.calls[0][0]).toContain('contact_id IS DISTINCT FROM EXCLUDED.contact_id'); + expect(query.mock.calls[0][0]).toContain('THEN NOW()'); + expect(query.mock.calls[0][0]).toContain('updated_at = NOW()'); + }); + + it('maps JSON and validates reusable join aliases', () => { + expect(mapDelegationRow({ delegation: '{"contact_id":null}' })).toEqual({ contact_id: null }); + expect(delegationJoinSql('m', 'a')).toContain('gd.user_id = a.user_id'); + expect(() => delegationJoinSql('m;drop', 'a')).toThrow(TypeError); + }); +}); + +describe('delegateMessages', () => { + it('normalizes duplicate IDs and performs one copy for one logical thread', async () => { + stubSuccess({ messages: [row(MESSAGE_A), row(MESSAGE_B)] }); + const imap = imapManager(); + const result = await delegateMessages({ + userId: USER, messageIds: [MESSAGE_A, MESSAGE_A, MESSAGE_B], contactId: CONTACT, imapManager: imap, + }); + expect(result).toMatchObject({ status: 'success', successCount: 2, failureCount: 0 }); + expect(result.results.map(item => item.messageId)).toEqual([MESSAGE_A, MESSAGE_B]); + expect(imap.copyMessage).toHaveBeenCalledTimes(1); + }); + + it('clears a previous person when contactId is null while retaining the label', async () => { + stubSuccess({ existing: true }); + const result = await delegateMessages({ + userId: USER, messageIds: [MESSAGE_A], contactId: null, imapManager: imapManager(), + }); + expect(result.results[0]).toMatchObject({ ok: true, delegation: null }); + expect(query.mock.calls.some(([sql]) => sql.includes('DELETE FROM gtd_delegations'))).toBe(true); + }); + + it('removes a newly copied label when persistence fails', async () => { + stubSuccess({ insertError: new Error('write failed') }); + const imap = imapManager(); + const result = await delegateMessages({ + userId: USER, messageIds: [MESSAGE_A], contactId: CONTACT, imapManager: imap, + }); + expect(imap.removeMessageCopy).toHaveBeenCalledWith(ACCOUNT, 77, 'Delegated'); + expect(result.results[0]).toMatchObject({ ok: false, compensated: true }); + }); + + it('does not remove a pre-existing delegated label when persistence fails', async () => { + stubSuccess({ existing: true, insertError: new Error('write failed') }); + const imap = imapManager(); + await delegateMessages({ userId: USER, messageIds: [MESSAGE_A], contactId: CONTACT, imapManager: imap }); + expect(imap.removeMessageCopy).not.toHaveBeenCalled(); + }); + + it('serializes simultaneous requests for one thread so only one remote copy is made', async () => { + let copied = false; + query.mockImplementation(async sql => { + if (sql.includes('FROM contacts c')) return { rows: [contact] }; + if (sql.includes('FROM messages m') && sql.includes('ANY($2::uuid[])')) return { rows: [row(MESSAGE_A)] }; + if (sql.startsWith('SELECT * FROM email_accounts')) return { rows: [{ id: ACCOUNT, user_id: USER }] }; + if (sql.includes('SELECT uid FROM messages')) return { rows: copied ? [{ uid: 77 }] : [] }; + if (sql.includes('INSERT INTO gtd_delegations')) return { rows: [contact] }; + return { rows: [], rowCount: 0 }; + }); + const imap = imapManager(); + imap.copyMessage.mockImplementation(async () => { copied = true; return 77; }); + + const [first, retry] = await Promise.all([ + delegateMessages({ userId: USER, messageIds: [MESSAGE_A], contactId: CONTACT, imapManager: imap }), + delegateMessages({ userId: USER, messageIds: [MESSAGE_A], contactId: CONTACT, imapManager: imap }), + ]); + + expect(first.status).toBe('success'); + expect(retry.status).toBe('success'); + expect(imap.copyMessage).toHaveBeenCalledTimes(1); + }); + + it('awaits non-UIDPLUS destination reconciliation before reporting success', async () => { + let syncCount = 0; + query.mockImplementation(async sql => { + if (sql.includes('FROM contacts c')) return { rows: [contact] }; + if (sql.includes('FROM messages m') && sql.includes('ANY($2::uuid[])')) return { rows: [row(MESSAGE_A)] }; + if (sql.startsWith('SELECT * FROM email_accounts')) return { rows: [{ id: ACCOUNT, user_id: USER }] }; + if (sql.includes('SELECT uid FROM messages')) return { rows: syncCount >= 2 ? [{ uid: 91 }] : [] }; + if (sql.includes('INSERT INTO gtd_delegations')) return { rows: [contact] }; + return { rows: [], rowCount: 0 }; + }); + const imap = imapManager(); + imap.copyMessage.mockResolvedValue(null); + imap.syncFolderOnDemand.mockImplementation(async () => { syncCount += 1; }); + + const result = await delegateMessages({ + userId: USER, messageIds: [MESSAGE_A], contactId: CONTACT, imapManager: imap, + }); + + expect(result.status).toBe('success'); + expect(imap.syncFolderOnDemand).toHaveBeenCalledTimes(2); + }); + + it('compensates the exact UID when COPY succeeds remotely but local completion throws', async () => { + query.mockImplementation(async sql => { + if (sql.includes('FROM contacts c')) return { rows: [contact] }; + if (sql.includes('FROM messages m') && sql.includes('ANY($2::uuid[])')) return { rows: [row(MESSAGE_A)] }; + if (sql.startsWith('SELECT * FROM email_accounts')) return { rows: [{ id: ACCOUNT, user_id: USER }] }; + if (sql.includes('SELECT uid FROM messages')) return { rows: [] }; + return { rows: [], rowCount: 0 }; + }); + const imap = imapManager(); + imap.copyMessage.mockImplementation(async () => { + const error = new Error('local sibling insert failed'); + error.copiedUid = 93; + throw error; + }); + + const result = await delegateMessages({ + userId: USER, messageIds: [MESSAGE_A], contactId: CONTACT, imapManager: imap, + }); + + expect(imap.removeMessageCopy).toHaveBeenCalledWith(ACCOUNT, 93, 'Delegated'); + expect(result.results[0]).toMatchObject({ ok: false, compensated: true }); + }); + + it('does not guess at compensation when an ambiguous COPY failure has no exact UID', async () => { + stubSuccess(); + const imap = imapManager(); + imap.copyMessage.mockRejectedValue(new Error('connection lost after COPY')); + + const result = await delegateMessages({ + userId: USER, messageIds: [MESSAGE_A], contactId: CONTACT, imapManager: imap, + }); + + expect(imap.removeMessageCopy).not.toHaveBeenCalled(); + expect(result.results[0]).toMatchObject({ ok: false, compensated: false }); + }); + + it('rejects more than 100 IDs before querying', async () => { + const ids = Array.from({ length: 101 }, (_, index) => `${String(index).padStart(8, '0')}-0000-4000-8000-000000000000`); + await expect(delegateMessages({ userId: USER, messageIds: ids, contactId: null, imapManager: imapManager() })) + .rejects.toMatchObject({ code: 'invalid_request', status: 400 }); + expect(query).not.toHaveBeenCalled(); + }); +}); + +it('reconciles only delegation rows without a surviving folder copy', async () => { + query.mockResolvedValueOnce({ rowCount: 2, rows: [] }); + await expect(reconcileDelegatedRemovals({ + userId: USER, accountId: ACCOUNT, delegatedFolder: 'Delegated', threadKeys: ['a', 'a', 'b'], + })).resolves.toBe(2); + expect(query.mock.calls[0][1]).toEqual([USER, ACCOUNT, ['a', 'b'], 'Delegated']); + expect(query.mock.calls[0][0]).toContain('NOT EXISTS'); +}); + +it('sweeps stale rows even when the original removed thread is no longer available', async () => { + query.mockResolvedValueOnce({ rowCount: 3, rows: [] }); + await expect(sweepStaleDelegations({ + userId: USER, accountId: ACCOUNT, delegatedFolder: 'Delegated', + })).resolves.toBe(3); + expect(query.mock.calls[0][1]).toEqual([USER, ACCOUNT, 'Delegated']); + expect(query.mock.calls[0][0]).toContain('NOT EXISTS'); + expect(query.mock.calls[0][0]).not.toContain('ANY('); +}); diff --git a/backend/src/services/gtdSections.js b/backend/src/services/gtdSections.js index 31ce41e9..d947f069 100644 --- a/backend/src/services/gtdSections.js +++ b/backend/src/services/gtdSections.js @@ -1,6 +1,7 @@ import { query } from './db.js'; import { getGtdConfig, GTD_STATES } from './gtdConfig.js'; import { resolveAllDraftsPaths } from '../utils/mailUtils.js'; +import { DELEGATION_SELECT_SQL, delegationJoinSql, mapDelegationRow } from './gtdDelegations.js'; // States the frontend merges into the single "Waiting" section (utils/gtd.js). Their // counts must dedupe a thread holding BOTH labels; see the waiting_agg CTE below. @@ -34,8 +35,11 @@ const SECTION_SQL = ` ), msg AS ( SELECT m.id, m.account_id, m.thread_key, m.message_id, m.folder, - m.subject, m.from_name, m.from_email, m.date, m.snippet, m.is_read, m.is_starred, m.uid, m.gtd_gist + m.subject, m.from_name, m.from_email, m.date, m.snippet, m.is_read, m.is_starred, m.uid, m.gtd_gist, + ${DELEGATION_SELECT_SQL} FROM messages m + JOIN email_accounts a ON a.id = m.account_id + ${delegationJoinSql('m', 'a')} WHERE m.account_id = $1 AND m.is_deleted = false AND m.folder <> ALL($4::text[]) @@ -51,7 +55,7 @@ const SECTION_SQL = ` head AS ( SELECT DISTINCT ON (account_id, thread_key) thread_key, account_id, message_id, folder, - subject, from_name, from_email, date, snippet, is_starred, uid, id, gtd_gist + subject, from_name, from_email, date, snippet, is_starred, uid, id, gtd_gist, delegation FROM msg -- Prefer a row that lives in a GTD label folder: that copy's id is stable for as long -- as the thread is in a section, whereas a transient INBOX copy (archived/purged out @@ -85,7 +89,7 @@ const SECTION_SQL = ` ranked AS ( SELECT ts.state, h.thread_key, h.account_id, h.message_id, h.folder, - h.subject, h.from_name, h.from_email, h.date, h.snippet, h.is_starred, h.uid, h.id, h.gtd_gist, + h.subject, h.from_name, h.from_email, h.date, h.snippet, h.is_starred, h.uid, h.id, h.gtd_gist, h.delegation, fa.folders, fa.in_inbox, fa.thread_unread, COUNT(*) OVER (PARTITION BY ts.state) AS total, COUNT(*) FILTER (WHERE fa.thread_unread) OVER (PARTITION BY ts.state) AS unread, @@ -95,7 +99,7 @@ const SECTION_SQL = ` JOIN folders_agg fa ON fa.thread_key = ts.thread_key ) SELECT state, thread_key, account_id, message_id, folder, - subject, from_name, from_email, date, snippet, is_starred, uid, id, gtd_gist, + subject, from_name, from_email, date, snippet, is_starred, uid, id, gtd_gist, delegation, folders, in_inbox, thread_unread, total::int AS total, unread::int AS unread, waiting_total::int AS waiting_total, waiting_unread::int AS waiting_unread FROM ranked @@ -133,6 +137,7 @@ function mapHead(row) { // AI-condensed one-line gist for waiting rows, when cached on this head. // Null until lazily generated; the client falls back to the raw snippet. gist: row.gtd_gist || null, + delegation: mapDelegationRow(row), }; } diff --git a/backend/src/services/gtdSections.test.js b/backend/src/services/gtdSections.test.js index cd5dd2e1..655d68d1 100644 --- a/backend/src/services/gtdSections.test.js +++ b/backend/src/services/gtdSections.test.js @@ -82,6 +82,19 @@ describe('getGtdSections — account resolution', () => { }); describe('getGtdSections — section folding', () => { + it('projects normalized delegation metadata and snapshot fallbacks', async () => { + const delegation = { + contact_id: null, display_name: 'Casey Rivera', primary_email: 'casey@example.test', + delegated_at: '2026-07-01T12:00:00.000Z', updated_at: '2026-07-02T12:00:00.000Z', + }; + query + .mockResolvedValueOnce({ rows: [{ id: 'acc-1', folder_mappings: null }] }) + .mockResolvedValueOnce({ rows: [headRow({ state: 'delegated', delegation: JSON.stringify(delegation) })] }); + const { sections } = await getGtdSections({ userId: 'u1' }); + expect(sections.delegated.threads[0].delegation).toEqual(delegation); + expect(query.mock.calls[1][0]).toContain('gtd_delegations'); + }); + it('places a multi-folder thread in every state section it belongs to, once each, preserving in_inbox', async () => { query .mockResolvedValueOnce({ rows: [{ id: 'acc-1', folder_mappings: null }] }) // accounts diff --git a/backend/src/services/gtdTransitions.js b/backend/src/services/gtdTransitions.js index e16f7451..81b89ca1 100644 --- a/backend/src/services/gtdTransitions.js +++ b/backend/src/services/gtdTransitions.js @@ -2,6 +2,7 @@ import { query } from './db.js'; import { getGtdConfig } from './gtdConfig.js'; import { resolveAllDraftsPaths } from '../utils/mailUtils.js'; import { logger } from './logger.js'; +import { reconcileDelegatedRemovals } from './gtdDelegations.js'; // Transition rules for auto-stripping a GTD label once a thread's state has moved on, // evaluated per thread against its LAST non-draft message. Designed to match the @@ -148,7 +149,7 @@ export async function runGtdTransitions(imapManager, account, threadKeys) { let anyStripped = false; - for (const [, threadRows] of byThread) { + for (const [threadKey, threadRows] of byThread) { const nonDraft = threadRows.filter((r) => !draftPaths.has(r.folder)); if (nonDraft.length === 0) continue; @@ -160,6 +161,8 @@ export async function runGtdTransitions(imapManager, account, threadKeys) { if (diff > 0 || (diff === 0 && String(r.id) > String(newest.id))) newest = r; } const isSelf = owner.has(normalizeAddress(newest.from_email)); + let delegatedRemoved = false; + let delegatedRemovalFailed = false; for (const [state, folder] of Object.entries(stateFolder)) { const rule = STRIP_RULE[state]; @@ -170,7 +173,9 @@ export async function runGtdTransitions(imapManager, account, threadKeys) { anyStripped = true; try { await imapManager.removeMessageCopy(account.id, copy.uid, copy.folder); + if (state === 'delegated') delegatedRemoved = true; } catch (err) { + if (state === 'delegated') delegatedRemovalFailed = true; // An external automation may strip the same label concurrently, so the copy // can already be gone on the server. Treat a failed removal as a successful // strip and move on; the stale DB row reconciles on the next sync. @@ -178,6 +183,12 @@ export async function runGtdTransitions(imapManager, account, threadKeys) { } } } + if (delegatedRemoved && !delegatedRemovalFailed) { + await reconcileDelegatedRemovals({ + userId: account.user_id, accountId: account.id, + delegatedFolder: stateFolder.delegated, threadKeys: [threadKey], + }); + } } // One batched emit per run (not per stripped copy) so the rail converges once. diff --git a/backend/src/services/gtdTransitions.test.js b/backend/src/services/gtdTransitions.test.js index eabed8e9..49604a18 100644 --- a/backend/src/services/gtdTransitions.test.js +++ b/backend/src/services/gtdTransitions.test.js @@ -4,6 +4,7 @@ vi.mock('./db.js', () => ({ query: vi.fn() })); vi.mock('./gtdConfig.js', () => ({ getGtdConfig: vi.fn() })); vi.mock('../utils/mailUtils.js', () => ({ resolveAllDraftsPaths: vi.fn() })); vi.mock('./logger.js', () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } })); +vi.mock('./gtdDelegations.js', () => ({ reconcileDelegatedRemovals: vi.fn() })); import { getOwnerAddresses, @@ -16,6 +17,7 @@ import { import { query } from './db.js'; import { getGtdConfig } from './gtdConfig.js'; import { resolveAllDraftsPaths } from '../utils/mailUtils.js'; +import { reconcileDelegatedRemovals } from './gtdDelegations.js'; const DEFAULT_FOLDERS = { todo: 'Todo', watch: 'Watch', delegated: 'Delegated', someday: 'Someday', reference: 'Reference' }; const account = { id: 'acct-1', user_id: 'user-1', email_address: 'me@example.com', folder_mappings: {} }; @@ -89,6 +91,7 @@ describe('runGtdTransitions', () => { query.mockReset(); getGtdConfig.mockReset(); resolveAllDraftsPaths.mockReset(); + reconcileDelegatedRemovals.mockReset(); invalidateOwnerAddressesCache('acct-1'); getGtdConfig.mockResolvedValue({ enabled: true, folders: DEFAULT_FOLDERS }); resolveAllDraftsPaths.mockResolvedValue(new Set(['Drafts'])); @@ -119,6 +122,21 @@ describe('runGtdTransitions', () => { expect(mgr.removeMessageCopy).toHaveBeenCalledWith('acct-1', 22, 'Watch'); expect(mgr.removeMessageCopy).toHaveBeenCalledWith('acct-1', 23, 'Delegated'); expect(mgr.removeMessageCopy).not.toHaveBeenCalledWith('acct-1', 21, 'Todo'); + expect(reconcileDelegatedRemovals).toHaveBeenCalledWith({ + userId: 'user-1', accountId: 'acct-1', + delegatedFolder: 'Delegated', threadKeys: ['t1'], + }); + }); + + it('retains delegation metadata when the Delegated IMAP removal fails', async () => { + mockQuery({ rows: [ + { thread_key: 't1', uid: 20, folder: 'INBOX', from_email: 'them@other.com', date: '2026-07-09T12:00:00Z', id: 'r1' }, + { thread_key: 't1', uid: 23, folder: 'Delegated', from_email: 'them@other.com', date: '2026-07-09T12:00:00Z', id: 'r2' }, + ] }); + const mgr = fakeManager(); + mgr.removeMessageCopy.mockRejectedValue(new Error('remove failed')); + await runGtdTransitions(mgr, account, ['t1']); + expect(reconcileDelegatedRemovals).not.toHaveBeenCalled(); }); it('treats an alias sender as the owner (self-strips Todo)', async () => { diff --git a/backend/src/services/imapManager.js b/backend/src/services/imapManager.js index 433ae55a..59b98d20 100644 --- a/backend/src/services/imapManager.js +++ b/backend/src/services/imapManager.js @@ -16,6 +16,7 @@ import { getConnectionPolicy } from './connectionPolicy.js'; import { applyInboxRules, applyBlockList } from './inboxRules.js'; import { generateVCard } from '../utils/vcard.js'; import { randomUUID } from 'crypto'; +import { reconcileDelegatedRemovals, sweepStaleDelegations } from './gtdDelegations.js'; // Shorthand for log lines — keeps domain visible while masking the local part. @@ -764,6 +765,13 @@ export async function emitGtdSectionsRefreshIfEnabled(mgr, account, changedCount // is identical either way. export const emitGtdSectionsRefreshOnDelete = emitGtdSectionsRefreshIfEnabled; +export function delegationSnapshotIsComplete(serverUids, localRows) { + const localUids = new Set(localRows.map(row => Number(row.uid))); + const normalizedServerUids = new Set([...serverUids].map(Number)); + return localUids.size === normalizedServerUids.size + && [...normalizedServerUids].every(uid => localUids.has(uid)); +} + // One GTD tick's body: sync each designated label folder for a connected gtd_enabled // account, then broadcast a single gtd_sections_updated if any folder actually changed. // Folders are synced one at a time (not in parallel) so a multi-folder account doesn't @@ -788,15 +796,23 @@ export async function runGtdSyncTick(mgr, account) { const key = `${account.id}:${folder}`; if (mgr.onDemandSyncing.has(key)) continue; // a user-triggered sync owns this folder mgr.onDemandSyncing.add(key); - try { + mgr.onDemandSyncPromises ||= new Map(); + const syncPromise = (async () => { const before = await mgr._gtdFolderFingerprint(account.id, folder); await mgr._gtdSyncFolder(account, folder); const after = await mgr._gtdFolderFingerprint(account.id, folder); - if (before !== after) changedFolders.push(folder); + return before !== after; + })(); + mgr.onDemandSyncPromises.set(key, syncPromise); + try { + if (await syncPromise) changedFolders.push(folder); } catch (err) { console.warn(`GTD sync error ${logAccount(account)}/${folder}:`, err.message); } finally { mgr.onDemandSyncing.delete(key); + if (mgr.onDemandSyncPromises.get(key) === syncPromise) { + mgr.onDemandSyncPromises.delete(key); + } } } @@ -1309,6 +1325,7 @@ export class ImapManager { this._backfillSem = createKeyedSemaphore(BACKFILL_MAX_PER_HOST); // cap concurrent backfills per provider host this._connectCooldown = new Map(); // accountId -> { until: ms, failures: number } after connection refusals this.onDemandSyncing = new Set(); // `${accountId}:${folder}` — prevent duplicate on-demand syncs + this.onDemandSyncPromises = new Map(); // same key -> awaitable user-triggered sync this.syncingAccounts = new Set(); // prevent overlapping interval syncs this.syncStartedAt = new Map(); // accountId -> ms when the current sync tick began (hung-sync detection) this.syncThrottleSkips = new Map(); // accountId -> remaining ticks to skip when throttled @@ -3850,24 +3867,33 @@ export class ImapManager { // Uses a pooled connection — does NOT touch the main sync connection. async syncFolderOnDemand(account, folder) { const key = `${account.id}:${folder}`; + this.onDemandSyncPromises ||= new Map(); + const inFlight = this.onDemandSyncPromises.get(key); + if (inFlight) return inFlight; if (this.onDemandSyncing.has(key)) { console.log(`syncFolderOnDemand skipped (already running): ${logAccount(account)}/${folder}`); return; } this.onDemandSyncing.add(key); - console.log(`syncFolderOnDemand start: ${logAccount(account)}/${folder}`); - try { - await withFreshClient(account, async (client) => { - await this.syncMessages(account, client, folder, 100, false, true); - }); - console.log(`syncFolderOnDemand done: ${logAccount(account)}/${folder}`); - // sync_complete fires mailflow:refresh in the frontend, reloading the message list - this.broadcast({ type: 'sync_complete', accountId: account.id }, account.user_id); - } catch (err) { - console.error(`On-demand sync error ${logAccount(account)}/${folder}:`, err.message); - } finally { - this.onDemandSyncing.delete(key); - } + const syncPromise = (async () => { + console.log(`syncFolderOnDemand start: ${logAccount(account)}/${folder}`); + try { + await withFreshClient(account, async (client) => { + await this.syncMessages(account, client, folder, 100, false, true); + }); + console.log(`syncFolderOnDemand done: ${logAccount(account)}/${folder}`); + // sync_complete fires mailflow:refresh in the frontend, reloading the message list + this.broadcast({ type: 'sync_complete', accountId: account.id }, account.user_id); + } catch (err) { + console.error(`On-demand sync error ${logAccount(account)}/${folder}:`, err.message); + throw err; + } finally { + this.onDemandSyncing.delete(key); + this.onDemandSyncPromises.delete(key); + } + })(); + this.onDemandSyncPromises.set(key, syncPromise); + return syncPromise; } // Pre-fetch and cache the body for newly arrived messages immediately after sync. @@ -4464,7 +4490,14 @@ export class ImapManager { return null; } - await insertCopiedSibling(accountId, uid, fromFolder, toFolder, newUid); + try { + await insertCopiedSibling(accountId, uid, fromFolder, toFolder, newUid); + } catch (err) { + // The remote UIDPLUS COPY already succeeded. Preserve its exact destination UID so + // the caller can compensate without guessing among same-thread copies. + if (err && typeof err === 'object') err.copiedUid = newUid; + throw err; + } return newUid; } @@ -4951,9 +4984,22 @@ export class ImapManager { 'SELECT DISTINCT folder FROM messages WHERE account_id = $1', [account.id] ); - if (!folderResult.rows.length) return; - - const folders = folderResult.rows.map(r => r.folder); + let delegatedFolder; + try { + const { folders: gtdFolders } = await getGtdConfig(account.id); + delegatedFolder = gtdFolders.delegated || null; + } catch (err) { + logger.debug(`Reconcile: delegated cleanup config unavailable for ${account.id}: ${err.message}`); + return; + } + // Always include the configured Delegated folder, even when it currently has no + // local rows. A successful empty server snapshot is the authority needed to clean + // metadata whose last local message row disappeared in an earlier failed pass. + const folders = [...new Set([ + ...folderResult.rows.map(r => r.folder), + ...(delegatedFolder ? [delegatedFolder] : []), + ])]; + if (!folders.length) return; // Phase 1 — fetch server UID sets for each folder (IMAP only, inside withFreshClient). const serverUidsByFolder = new Map(); // folder -> Set @@ -4984,14 +5030,17 @@ export class ImapManager { // Phase 2 — diff each folder's server UIDs against the DB and delete orphans. // Runs outside withFreshClient so DB errors never cause unnecessary pool eviction. let deletedCount = 0; + const delegatedThreadKeys = []; for (const [folder, serverUidSet] of serverUidsByFolder) { const dbResult = await query( - 'SELECT uid FROM messages WHERE account_id = $1 AND folder = $2 AND (synced_at IS NULL OR synced_at < $3)', + 'SELECT uid, thread_key FROM messages WHERE account_id = $1 AND folder = $2 AND (synced_at IS NULL OR synced_at < $3)', [account.id, folder, reconcileStartedAt] ); - const orphanUids = dbResult.rows - .map(r => Number(r.uid)) - .filter(uid => !serverUidSet.has(uid) && !this._isMoveUidGuarded(account.id, folder, uid)); + const orphanRows = dbResult.rows.filter(row => { + const uid = Number(row.uid); + return !serverUidSet.has(uid) && !this._isMoveUidGuarded(account.id, folder, uid); + }); + const orphanUids = orphanRows.map(row => Number(row.uid)); if (orphanUids.length === 0) continue; @@ -5013,6 +5062,39 @@ export class ImapManager { [account.id, folder] ); deletedCount += orphanUids.length; + if (folder === delegatedFolder) { + delegatedThreadKeys.push(...orphanRows.map(row => row.thread_key).filter(Boolean)); + } + } + + if (delegatedThreadKeys.length) { + await reconcileDelegatedRemovals({ + userId: account.user_id, + accountId: account.id, + delegatedFolder, + threadKeys: delegatedThreadKeys, + }); + } + if (delegatedFolder && serverUidsByFolder.has(delegatedFolder)) { + // Only sweep when the local folder is an exact mirror of the successful server + // snapshot. If the folder could not be opened, or the server has a UID that local + // ingest has not materialized yet (UIDVALIDITY rebuild / incomplete sync), local + // absence is not authoritative and valid delegation metadata must be retained. + const localDelegated = await query( + 'SELECT uid FROM messages WHERE account_id = $1 AND folder = $2 AND is_deleted = false', + [account.id, delegatedFolder] + ); + const serverUids = serverUidsByFolder.get(delegatedFolder); + const snapshotIsComplete = delegationSnapshotIsComplete(serverUids, localDelegated.rows); + if (snapshotIsComplete) { + // Retry cleanup even when the message row that originally triggered it was already + // removed by a prior pass whose metadata DELETE failed. + await sweepStaleDelegations({ + userId: account.user_id, + accountId: account.id, + delegatedFolder, + }); + } } if (deletedCount > 0) { diff --git a/backend/src/services/imapManager.test.js b/backend/src/services/imapManager.test.js index afd734a4..1d2b0325 100644 --- a/backend/src/services/imapManager.test.js +++ b/backend/src/services/imapManager.test.js @@ -12,7 +12,7 @@ vi.mock('../utils/redact.js', () => ({ redactEmail: vi.fn() })); vi.mock('./hostValidation.js', () => ({ resolveForConnection: vi.fn() })); vi.mock('./gtdTransitions.js', () => ({ runGtdTransitions: vi.fn(), threadKeysForMessageIds: vi.fn(), threadKeysInFolders: vi.fn() })); -import { ImapManager, providerProfile, makeClientCfg, gtdRelocateGuard, insertCopiedSibling, deleteMessageCopyRow, emitAfterDeferredCopySync, emitGtdSectionsRefreshOnDelete, emitGtdSectionsRefreshIfEnabled, selectGtdReevalIds, ensureMailbox, runGtdSyncTick, createKeyedSemaphore, isConnectionRefusal, connectCooldownMs, effectiveSyncIntervalMs, folderSyncDue, planModseqSync, connectStaggerFor, walkStructure } from './imapManager.js'; +import { ImapManager, providerProfile, makeClientCfg, gtdRelocateGuard, insertCopiedSibling, deleteMessageCopyRow, emitAfterDeferredCopySync, emitGtdSectionsRefreshOnDelete, emitGtdSectionsRefreshIfEnabled, selectGtdReevalIds, ensureMailbox, runGtdSyncTick, createKeyedSemaphore, isConnectionRefusal, connectCooldownMs, effectiveSyncIntervalMs, folderSyncDue, planModseqSync, connectStaggerFor, walkStructure, delegationSnapshotIsComplete } from './imapManager.js'; import { query } from './db.js'; import { invalidateGtdConfigCache } from './gtdConfig.js'; import { runGtdTransitions, threadKeysInFolders } from './gtdTransitions.js'; @@ -768,6 +768,34 @@ describe('runGtdSyncTick', () => { expect(runGtdTransitions).not.toHaveBeenCalled(); }); + it('publishes an awaitable in-flight sync for an immediate user-triggered retry', async () => { + const accountId = 'acct-tick-shared'; + invalidateGtdConfigCache(accountId); + query.mockResolvedValueOnce({ rows: [{ + gtd_enabled: true, + gtd_folders: { todo: 'Todo', watch: 'Todo', delegated: 'Todo', someday: 'Todo', reference: 'Todo' }, + }] }); + let releaseSync; + const pendingSync = new Promise(resolve => { releaseSync = resolve; }); + const mgr = mgrWithConnection(accountId, { + _gtdFolderFingerprint: vi.fn() + .mockResolvedValueOnce('before') + .mockResolvedValueOnce('after'), + _gtdSyncFolder: vi.fn().mockReturnValue(pendingSync), + onDemandSyncPromises: new Map(), + }); + const acct = { id: accountId, user_id: 'user-1' }; + + const tick = runGtdSyncTick(mgr, acct); + await vi.waitFor(() => expect(mgr._gtdSyncFolder).toHaveBeenCalledTimes(1)); + const userRetry = ImapManager.prototype.syncFolderOnDemand.call(mgr, acct, 'Todo'); + expect(mgr._gtdSyncFolder).toHaveBeenCalledTimes(1); + + releaseSync(); + await Promise.all([tick, userRetry]); + expect(mgr.onDemandSyncPromises.size).toBe(0); + }); + it('broadcasts gtd_sections_updated and re-runs transitions when a folder fingerprint changes', async () => { const allTodo = { todo: 'Todo', watch: 'Todo', delegated: 'Todo', someday: 'Todo', reference: 'Todo' }; query.mockResolvedValueOnce({ rows: [{ gtd_enabled: true, gtd_folders: allTodo }] }); @@ -1272,3 +1300,29 @@ describe('walkStructure attachment classification', () => { expect(results.attachments[0].filename).toBe('invoice.pdf'); }); }); + +describe('reconcileDeletes delegation cleanup durability', () => { + beforeEach(() => query.mockReset()); + + it('only treats local absence as authoritative when the successful server snapshot agrees', () => { + expect(delegationSnapshotIsComplete(new Set(), [])).toBe(true); + expect(delegationSnapshotIsComplete(new Set([41]), [])).toBe(false); + expect(delegationSnapshotIsComplete(new Set([41]), [{ uid: 41 }])).toBe(true); + expect(delegationSnapshotIsComplete(new Set([41]), [{ uid: 41 }, { uid: 42 }])).toBe(false); + }); + + it('aborts before any message deletion when delegation config cannot be loaded', async () => { + const accountId = 'acct-reconcile-config-fail'; + invalidateGtdConfigCache(accountId); + query + .mockResolvedValueOnce({ rows: [{ folder: 'Delegated' }] }) + .mockRejectedValueOnce(new Error('config unavailable')); + const mgr = new ImapManager(null); + + await expect(mgr.reconcileDeletes({ id: accountId, user_id: 'user-1' })) + .resolves.toBeUndefined(); + + expect(query).toHaveBeenCalledTimes(2); + expect(query.mock.calls.some(([sql]) => sql.startsWith('DELETE FROM messages'))).toBe(false); + }); +}); diff --git a/backend/src/services/messageService.js b/backend/src/services/messageService.js index 7102ee36..a23af3d5 100644 --- a/backend/src/services/messageService.js +++ b/backend/src/services/messageService.js @@ -1,5 +1,6 @@ import { query } from './db.js'; import { resolveAccountScope } from './unifiedInbox.js'; +import { DELEGATION_SELECT_SQL, delegationJoinSql, mapDelegationRow } from './gtdDelegations.js'; export async function listMessages({ userId, accountId, folder = 'INBOX', limit = 50, offset = 0, unreadOnly, threaded, category }) { const accountsResult = await query( @@ -100,6 +101,7 @@ export async function listMessages({ userId, accountId, folder = 'INBOX', limit m.date, m.snippet, m.is_read, m.is_starred, m.has_attachments, m.account_id, m.category, m.list_unsubscribe, m.list_unsubscribe_post, m.delivery_addresses, + ${DELEGATION_SELECT_SQL}, a.name AS account_name, a.email_address AS account_email, a.color AS account_color, @@ -109,6 +111,7 @@ export async function listMessages({ userId, accountId, folder = 'INBOX', limit LEFT JOIN contacts co ON co.user_id = a.user_id AND co.primary_email = lower(m.from_email) AND co.photo_data IS NOT NULL + ${delegationJoinSql('m', 'a')} WHERE ${where} AND m.thread_key IN (SELECT thread_id FROM paged_threads) ORDER BY m.account_id, @@ -147,7 +150,7 @@ export async function listMessages({ userId, accountId, folder = 'INBOX', limit account_name, account_email, account_color, category, list_unsubscribe, list_unsubscribe_post, delivery_addresses, message_count, unread_count, - thread_has_contact_photo AS has_contact_photo + thread_has_contact_photo AS has_contact_photo, delegation FROM ranked WHERE rn = 1 ORDER BY date DESC @@ -160,7 +163,7 @@ export async function listMessages({ userId, accountId, folder = 'INBOX', limit `, filterValues); return { - messages: threadResult.rows, + messages: threadResult.rows.map(row => ({ ...row, delegation: mapDelegationRow(row) })), total: threadCountResult.rows[0]?.total ?? 0, threaded: true, resolvedAccountId, @@ -178,19 +181,21 @@ export async function listMessages({ userId, accountId, folder = 'INBOX', limit m.has_attachments, m.account_id, m.category, m.list_unsubscribe, m.list_unsubscribe_post, m.delivery_addresses, a.name as account_name, a.email_address as account_email, a.color as account_color, - (co.id IS NOT NULL) AS has_contact_photo + (co.id IS NOT NULL) AS has_contact_photo, + ${DELEGATION_SELECT_SQL} FROM messages m JOIN email_accounts a ON m.account_id = a.id LEFT JOIN contacts co ON co.user_id = a.user_id AND co.primary_email = lower(m.from_email) AND co.photo_data IS NOT NULL + ${delegationJoinSql('m', 'a')} WHERE ${where} ORDER BY m.date DESC LIMIT $${limitParam} OFFSET $${offsetParam} `, values); return { - messages: result.rows, + messages: result.rows.map(row => ({ ...row, delegation: mapDelegationRow(row) })), total, resolvedAccountId, }; diff --git a/backend/src/services/messageService.test.js b/backend/src/services/messageService.test.js index e696f04f..6fb11bc7 100644 --- a/backend/src/services/messageService.test.js +++ b/backend/src/services/messageService.test.js @@ -186,6 +186,27 @@ describe('listMessages — threaded mode', () => { }); describe('listMessages — message shape', () => { + it('projects delegation metadata in flat and threaded results', async () => { + const delegation = JSON.stringify({ contact_id: null, display_name: 'Casey' }); + query + .mockResolvedValueOnce({ rows: [{ id: 'acc-1' }] }) + .mockResolvedValueOnce({ rows: [{ total_count: 1, unread_count: 0 }] }) + .mockResolvedValueOnce({ rows: [{ id: 'msg-1', delegation }] }); + const flat = await listMessages({ userId: 'user-1', accountId: 'acc-1' }); + expect(flat.messages[0].delegation).toEqual({ contact_id: null, display_name: 'Casey' }); + expect(query.mock.calls[2][0]).toContain('gtd_delegations'); + + query.mockReset(); + query + .mockResolvedValueOnce({ rows: [{ id: 'acc-1' }] }) + .mockResolvedValueOnce({ rows: [{ total_count: 1, unread_count: 0 }] }) + .mockResolvedValueOnce({ rows: [{ id: 'msg-1', delegation }] }) + .mockResolvedValueOnce({ rows: [{ total: 1 }] }); + const threaded = await listMessages({ userId: 'user-1', accountId: 'acc-1', threaded: true }); + expect(threaded.messages[0].delegation.display_name).toBe('Casey'); + expect(query.mock.calls[2][0]).toContain('gtd_delegations'); + }); + it('selects delivery_addresses in the flat query', async () => { query .mockResolvedValueOnce({ rows: [{ id: 'acc-1' }] }) diff --git a/backend/src/services/migrations.gtdDelegations.test.js b/backend/src/services/migrations.gtdDelegations.test.js new file mode 100644 index 00000000..190211f4 --- /dev/null +++ b/backend/src/services/migrations.gtdDelegations.test.js @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { readdirSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const migrationsDir = fileURLToPath(new URL('../../migrations/', import.meta.url)); + +describe('gtd delegations migration', () => { + it('defines thread-stable ownership, snapshots, and deletion behavior', () => { + const files = readdirSync(migrationsDir).filter(name => /^\d{4}_gtd_delegations\.sql$/.test(name)); + expect(files).toHaveLength(1); + const sql = readFileSync(`${migrationsDir}/${files[0]}`, 'utf8'); + expect(sql).toMatch(/PRIMARY KEY \(user_id, account_id, thread_key\)/i); + expect(sql).toMatch(/contact_id UUID REFERENCES contacts\(id\) ON DELETE SET NULL/i); + expect(sql).toMatch(/contact_display_name_snapshot TEXT NOT NULL/i); + expect(sql).toMatch(/contact_primary_email_snapshot TEXT/i); + expect(sql).toMatch(/delegated_at TIMESTAMPTZ NOT NULL DEFAULT NOW\(\)/i); + expect(sql).toMatch(/updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW\(\)/i); + }); +}); diff --git a/frontend/src/commands/CommandRuntimeContext.jsx b/frontend/src/commands/CommandRuntimeContext.jsx new file mode 100644 index 00000000..2e7a63cb --- /dev/null +++ b/frontend/src/commands/CommandRuntimeContext.jsx @@ -0,0 +1,13 @@ +import { createContext, useContext } from 'react'; + +const CommandRuntimeContext = createContext(null); + +export function CommandRuntimeProvider({ runtime, children }) { + return {children}; +} + +export function useCommandRuntimeContext() { + const runtime = useContext(CommandRuntimeContext); + if (!runtime) throw new Error('useCommandRuntimeContext must be used inside CommandRuntimeProvider'); + return runtime; +} diff --git a/frontend/src/commands/CommandRuntimeContext.test.js b/frontend/src/commands/CommandRuntimeContext.test.js new file mode 100644 index 00000000..60a50e35 --- /dev/null +++ b/frontend/src/commands/CommandRuntimeContext.test.js @@ -0,0 +1,12 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +describe('CommandRuntimeContext source contract', () => { + it('exports one provider and one strict consumer hook', () => { + const source = fs.readFileSync(new URL('./CommandRuntimeContext.jsx', import.meta.url), 'utf8'); + assert.match(source, /export function CommandRuntimeProvider/); + assert.match(source, /export function useCommandRuntimeContext/); + assert.match(source, /must be used inside CommandRuntimeProvider/); + }); +}); diff --git a/frontend/src/commands/appCommands.js b/frontend/src/commands/appCommands.js new file mode 100644 index 00000000..bf8c2cd3 --- /dev/null +++ b/frontend/src/commands/appCommands.js @@ -0,0 +1,120 @@ +import { mailCommandDefinitions } from './mailActions.js'; +import { shortcutCommandDefinitions } from './shortcutCommands.js'; + +const globalCommand = (id, titleKey, icon, group, executorId, params = {}, overrides = {}) => ({ + id, titleKey, aliasKeys: [], icon, group, + defaultKeys: { primary: null, secondary: [] }, + rank: { base: 50 }, isAvailable: () => true, targetMode: 'global', executorId, params, + ...overrides, +}); + +const settingsTabs = ['accounts', 'notifications', 'rules', 'categories', 'appearance', 'shortcuts', + 'security', 'integrations', 'ai-actions', 'about']; +const adminTabs = ['users', 'sso', 'ai']; +const gtdSections = ['todo', 'watch', 'delegated', 'reference', 'someday']; + +export function createAppCommandDefinitions({ accounts = [], folders = {}, themes = {}, user = {} }) { + const definitions = [ + globalCommand('compose.new', 'commands.compose.new.title', 'compose', 'compose', 'app.compose', {}, { + aliasKeys: ['commands.compose.new.alias.write'], + defaultKeys: { primary: 'c', secondary: [] }, rank: { base: 100 }, + isAvailable: context => context.surface !== 'compose' && context.surface !== 'settings', + }), + globalCommand('navigation.search', 'commands.navigation.search.title', 'search', 'navigation', 'navigation.search', {}, { + defaultKeys: { primary: '/', secondary: [] }, rank: { base: 90 }, + isAvailable: context => context.surface !== 'compose' && context.surface !== 'settings', + }), + globalCommand('navigation.unified-inbox', 'commands.navigation.unifiedInbox.title', 'inbox', 'navigation', 'navigation.inbox', { + accountId: null, folder: 'INBOX', + }), + ]; + + for (const account of accounts) { + definitions.push(globalCommand( + `navigation.account-inbox.${account.id}`, + 'commands.navigation.accountInbox.title', + 'inbox', 'navigation', 'navigation.inbox', + { accountId: account.id, folder: 'INBOX', name: account.name || account.email_address }, + )); + for (const folder of folders[account.id] || []) { + if (folder.path === 'INBOX') continue; + definitions.push(globalCommand( + `navigation.folder.${account.id}.${folder.path}`, + 'commands.navigation.folder.title', + 'folder', 'navigation', 'navigation.folder', + { accountId: account.id, folder: folder.path, name: folder.name || folder.path }, + )); + } + } + + for (const section of gtdSections) { + definitions.push(globalCommand( + `navigation.gtd.${section}`, + `commands.navigation.gtd.${section}.title`, + 'gtd', 'navigation', 'navigation.gtd', { section }, + { isAvailable: context => context.gtdAvailable }, + )); + } + for (const [theme, value] of Object.entries(themes)) { + definitions.push(globalCommand( + `appearance.theme.${theme}`, + 'commands.appearance.theme.title', + 'appearance', 'appearance', 'appearance.theme', { theme, name: value.label || theme }, + )); + } + for (const tab of [...settingsTabs, ...(user.isAdmin ? adminTabs : [])]) { + definitions.push(globalCommand( + `settings.${tab}`, + `commands.settings.${tab}.title`, + 'settings', 'settings', 'settings.open', { tab }, + )); + } + definitions.push(...mailCommandDefinitions); + definitions.push(...shortcutCommandDefinitions); + return definitions; +} + +export function createAppCommandExecutors({ getState, emitShortcut }) { + return Object.freeze({ + 'app.compose': () => { + const state = getState(); + state.openCompose({ accountId: state.selectedAccountId || undefined }); + return { status: 'success' }; + }, + 'navigation.search': () => { + emitShortcut('focusSearch'); + return { status: 'success' }; + }, + 'navigation.contacts': () => { + const state = getState(); + state.setSelectedMessage(null); + state.clearSelectedMessageIds(); + state.setShowContacts(true); + return { status: 'success' }; + }, + 'navigation.inbox': ({ command }) => { + getState().setSelectedAccount(command.params.accountId, 'INBOX'); + return { status: 'success' }; + }, + 'navigation.folder': ({ command }) => { + getState().setSelectedAccount(command.params.accountId, command.params.folder); + return { status: 'success' }; + }, + 'navigation.gtd': ({ command }) => { + const state = getState(); + state.setSelectedAccount(state.selectedAccountId, 'INBOX'); + state.setActiveGtdTab(command.params.section); + return { status: 'success' }; + }, + 'appearance.theme': ({ command }) => { + getState().setTheme(command.params.theme); + return { status: 'success' }; + }, + 'settings.open': ({ command }) => { + const state = getState(); + state.setAdminTab(command.params.tab); + state.setShowAdmin(true); + return { status: 'success' }; + }, + }); +} diff --git a/frontend/src/commands/appCommands.test.js b/frontend/src/commands/appCommands.test.js new file mode 100644 index 00000000..d4185578 --- /dev/null +++ b/frontend/src/commands/appCommands.test.js @@ -0,0 +1,83 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createCommandContext } from './contracts.js'; +import { createCommandRegistry } from './registry.js'; +import { createAppCommandDefinitions, createAppCommandExecutors } from './appCommands.js'; + +const snapshot = { + accounts: [{ id: 'acct-1', name: 'Work', gtd_enabled: true }], + folders: { 'acct-1': [{ path: 'INBOX', name: 'Inbox' }, { path: 'Projects', name: 'Projects' }] }, + themes: { system: { label: 'System' }, midnight: { label: 'Midnight' } }, + user: { isAdmin: false }, +}; +const context = createCommandContext({ + surface: 'list', activeConversationId: null, selectedConversationIds: [], conversations: [], + accountId: 'acct-1', folder: 'INBOX', draft: null, gtdAvailable: true, + cardDavConnected: false, modal: null, editing: false, platform: 'mac', shortcutOverrides: {}, + translate: (key, values) => values?.name || key, +}); + +describe('application commands', () => { + it('builds application destinations and the shared mail command set', () => { + const ids = createAppCommandDefinitions(snapshot).map(command => command.id); + for (const id of [ + 'compose.new', 'navigation.search', 'navigation.contacts', 'navigation.unified-inbox', + 'navigation.account-inbox.acct-1', 'navigation.folder.acct-1.Projects', + 'navigation.gtd.todo', 'appearance.theme.system', 'settings.accounts', 'settings.shortcuts', + 'mail.archive', 'mail.move', 'mail.replyAll', 'mail.toggleRead', 'gtd.todo', + ]) assert.ok(ids.includes(id), `missing ${id}`); + }); + + it('keeps administrator-only settings absent for non-admin users', () => { + const userIds = createAppCommandDefinitions(snapshot).map(command => command.id); + assert.equal(userIds.includes('settings.users'), false); + const adminIds = createAppCommandDefinitions({ ...snapshot, user: { isAdmin: true } }).map(command => command.id); + assert.equal(adminIds.includes('settings.users'), true); + assert.equal(adminIds.includes('settings.sso'), true); + assert.equal(adminIds.includes('settings.ai'), true); + }); + + it('searches account/folder labels through localized dynamic keys', () => { + const registry = createCommandRegistry(createAppCommandDefinitions(snapshot)); + assert.equal(registry.search('Projects', context)[0].command.id, 'navigation.folder.acct-1.Projects'); + }); + + it('routes execution through injected state services', async () => { + const calls = []; + const state = { + openCompose: value => calls.push(['compose', value]), + setSelectedAccount: (accountId, folder) => calls.push(['navigate', accountId, folder]), + setShowContacts: value => calls.push(['contacts', value]), + setActiveGtdTab: value => calls.push(['gtd', value]), + setAdminTab: value => calls.push(['tab', value]), + setShowAdmin: value => calls.push(['admin', value]), + setTheme: value => calls.push(['theme', value]), + }; + const executors = createAppCommandExecutors({ getState: () => state, emitShortcut: id => calls.push(['shortcut', id]) }); + await executors['navigation.folder']({ command: { params: { accountId: 'acct-1', folder: 'Projects' } } }); + await executors['settings.open']({ command: { params: { tab: 'appearance' } } }); + await executors['navigation.search']({ command: { params: {} } }); + assert.deepEqual(calls, [ + ['navigate', 'acct-1', 'Projects'], ['tab', 'appearance'], ['admin', true], ['shortcut', 'focusSearch'], + ]); + }); + + it('selects a GTD destination after navigation clears the previous tab', async () => { + const state = { + selectedAccountId: 'acct-1', + activeGtdTab: 'watch', + setSelectedAccount(accountId, folder) { + this.selectedAccountId = accountId; + this.selectedFolder = folder; + this.activeGtdTab = null; + }, + setActiveGtdTab(section) { + this.activeGtdTab = section; + }, + }; + const executors = createAppCommandExecutors({ getState: () => state, emitShortcut() {} }); + await executors['navigation.gtd']({ command: { params: { section: 'delegated' } } }); + assert.equal(state.selectedFolder, 'INBOX'); + assert.equal(state.activeGtdTab, 'delegated'); + }); +}); diff --git a/frontend/src/commands/appContext.js b/frontend/src/commands/appContext.js new file mode 100644 index 00000000..c0a59845 --- /dev/null +++ b/frontend/src/commands/appContext.js @@ -0,0 +1,90 @@ +import { createCommandContext, stableConversationId } from './contracts.js'; +import { normalizeLegacyShortcutOverrides } from '../utils/defaultShortcuts.js'; + +export function detectCommandPlatform(navigatorLike = {}) { + const value = navigatorLike.userAgentData?.platform || navigatorLike.platform || ''; + if (/mac|iphone|ipad|ipod/i.test(value)) return 'mac'; + if (/win/i.test(value)) return 'windows'; + return 'linux'; +} + +function allConversations(state) { + const gtd = Object.values(state.gtdSections || {}).flatMap(section => section?.threads || []); + return [ + ...(state.messages || []), + ...(state.searchResults || []), + ...Object.values(state.threadMessages || {}).flat(), + ...gtd, + ].filter((message, index, all) => { + const id = stableConversationId(message); + return id && all.findIndex(candidate => stableConversationId(candidate) === id) === index; + }); +} + +export function buildAppCommandContext(state, { + translate, + platform = detectCommandPlatform(globalThis.navigator), + editing = false, + modal = null, +} = {}) { + const conversations = allConversations(state); + const visibleMessages = state.searchQuery?.trim() + ? (state.searchResults || []) + : state.activeGtdTab + ? (state.gtdSections?.[state.activeGtdTab]?.threads || []) + : (state.messages || []); + const selectedRows = new Set(state.selectedMessageIds || []); + const selectedConversationIds = conversations + .filter(message => selectedRows.has(message.id)) + .map(stableConversationId); + const selected = conversations.find(message => message.id === state.selectedMessageId); + const listCursor = visibleMessages.find(message => message.id === state.lastViewedMessageId); + const active = state.showContacts ? null : (selected || listCursor); + const targetedMessages = selectedConversationIds.length + ? conversations.filter(message => selectedConversationIds.includes(stableConversationId(message))) + : active ? [active] : []; + const targetedAccountIds = [...new Set(targetedMessages.map(message => message.account_id).filter(Boolean))]; + const accounts = state.accounts || []; + const gtdAvailable = targetedAccountIds.length + ? targetedAccountIds.every(id => accounts.find(account => account.id === id)?.gtd_enabled) + : state.selectedAccountId + ? Boolean(accounts.find(account => account.id === state.selectedAccountId)?.gtd_enabled) + : accounts.some(account => account.gtd_enabled); + const surface = modal ? 'picker' + : state.showAdmin || state.showContacts ? 'settings' + : state.composing ? 'compose' + : state.selectedMessageId ? 'conversation' : 'list'; + const shortcutOverrides = normalizeLegacyShortcutOverrides(state.shortcuts || {}); + + return createCommandContext({ + surface, + activeConversationId: stableConversationId(active), + activeMessage: active || null, + selectedConversationIds, + visibleConversationIds: state.showContacts ? [] : visibleMessages.map(stableConversationId).filter(Boolean), + conversations, + accountId: state.selectedAccountId, + folder: state.selectedFolder, + draft: state.composing ? (state.composeData || { id: 'active-compose' }) : null, + gtdAvailable, + cardDavConnected: Boolean(state.carddavStatus?.connected), + carddavStatus: state.carddavStatus, + carddavStatusLoaded: state.carddavStatusLoaded, + modal, + editing: editing || Boolean(state.composing) || Boolean(state.showAdmin) || Boolean(state.showContacts), + undoAvailable: (state.notifications || []).some(notification => typeof notification.onUndo === 'function'), + platform, + shortcutOverrides, + translate, + }); +} + +export function commandTargetLabel(context) { + if (context.selectedConversationIds.length > 1) { + return { key: 'commandPalette.target.selected', values: { count: context.selectedConversationIds.length } }; + } + if (context.selectedConversationIds.length === 1 || context.activeConversationId) { + return { key: 'commandPalette.target.conversation', values: {} }; + } + return { key: 'commandPalette.target.application', values: {} }; +} diff --git a/frontend/src/commands/appContext.test.js b/frontend/src/commands/appContext.test.js new file mode 100644 index 00000000..ddbebed0 --- /dev/null +++ b/frontend/src/commands/appContext.test.js @@ -0,0 +1,91 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildAppCommandContext, commandTargetLabel, detectCommandPlatform } from './appContext.js'; + +const state = overrides => ({ + messages: [ + { id: 'row-a', message_id: '', account_id: 'acct-1' }, + { id: 'row-b', message_id: '', account_id: 'acct-1' }, + ], + searchResults: [], searchQuery: '', threadMessages: {}, gtdSections: null, + selectedMessageId: null, selectedMessageIds: new Set(), selectedAccountId: 'acct-1', + selectedFolder: 'INBOX', composing: false, composeData: null, showAdmin: false, + accounts: [{ id: 'acct-1', gtd_enabled: true }], + carddavStatus: { connected: true }, carddavStatusLoaded: true, + notifications: [], activeGtdTab: null, + shortcuts: {}, ...overrides, +}); +const translate = (key, values) => values?.count == null ? key : `${key}:${values.count}`; + +describe('buildAppCommandContext', () => { + it('maps list, conversation, compose, and settings surfaces', () => { + assert.equal(buildAppCommandContext(state(), { translate, platform: 'mac' }).surface, 'list'); + assert.equal(buildAppCommandContext(state({ selectedMessageId: 'row-a' }), { translate, platform: 'mac' }).surface, 'conversation'); + assert.equal(buildAppCommandContext(state({ composing: true, composeData: { id: 'draft-1' } }), { translate, platform: 'mac' }).surface, 'compose'); + assert.equal(buildAppCommandContext(state({ showAdmin: true }), { translate, platform: 'mac' }).surface, 'settings'); + }); + + it('converts selected row IDs to account-scoped RFC identities and exposes integrations', () => { + const context = buildAppCommandContext(state({ selectedMessageIds: new Set(['row-a', 'row-b']) }), { + translate, platform: 'linux', + }); + assert.deepEqual(context.selectedConversationIds, ['acct-1:', 'acct-1:']); + assert.deepEqual(context.visibleConversationIds, ['acct-1:', 'acct-1:']); + assert.equal(context.gtdAvailable, true); + assert.equal(context.cardDavConnected, true); + assert.deepEqual(context.carddavStatus, { connected: true }); + assert.equal(context.carddavStatusLoaded, true); + }); + + it('requires every targeted account to support GTD and never reads CardDAV from accounts', () => { + const context = buildAppCommandContext(state({ + messages: [ + { id: 'row-a', message_id: '', account_id: 'acct-1' }, + { id: 'row-c', message_id: '', account_id: 'acct-2' }, + ], + selectedMessageIds: new Set(['row-a', 'row-c']), + selectedAccountId: null, + accounts: [{ id: 'acct-1', gtd_enabled: true }, { id: 'acct-2', gtd_enabled: false }], + carddavStatus: null, + }), { translate, platform: 'linux' }); + assert.equal(context.gtdAvailable, false); + assert.equal(context.cardDavConnected, false); + assert.equal(context.carddavStatusLoaded, true); + }); + + it('exposes active message and current undo availability', () => { + const context = buildAppCommandContext(state({ + selectedMessageId: 'row-a', notifications: [{ id: 'undo-1', onUndo() {} }], + }), { translate, platform: 'mac' }); + assert.equal(context.activeMessage.id, 'row-a'); + assert.equal(context.undoAvailable, true); + }); + + it('describes application, single, and bulk targets', () => { + assert.deepEqual(commandTargetLabel(buildAppCommandContext(state(), { translate, platform: 'mac' })), { + key: 'commandPalette.target.application', values: {}, + }); + assert.equal(commandTargetLabel(buildAppCommandContext(state({ selectedMessageId: 'row-a' }), { + translate, platform: 'mac', + })).key, 'commandPalette.target.conversation'); + assert.deepEqual(commandTargetLabel(buildAppCommandContext(state({ selectedMessageIds: new Set(['row-a', 'row-b']) }), { + translate, platform: 'mac', + })), { key: 'commandPalette.target.selected', values: { count: 2 } }); + }); + + it('detects all three supported platform values', () => { + assert.equal(detectCommandPlatform({ userAgentData: { platform: 'macOS' } }), 'mac'); + assert.equal(detectCommandPlatform({ userAgentData: { platform: 'Windows' } }), 'windows'); + assert.equal(detectCommandPlatform({ platform: 'Linux x86_64' }), 'linux'); + }); + + it('maps existing persisted shortcut keys without mutating stored preferences', () => { + const shortcuts = { compose: 'q', focusSearch: 'ctrl+f', goInbox: 'g u' }; + const context = buildAppCommandContext(state({ shortcuts }), { translate, platform: 'linux' }); + assert.deepEqual(context.shortcutOverrides, { + compose: 'q', focusSearch: 'ctrl+f', goInbox: 'g u', + 'compose.new': 'q', 'navigation.search': 'ctrl+f', 'navigation.inbox': 'g u', + }); + assert.deepEqual(shortcuts, { compose: 'q', focusSearch: 'ctrl+f', goInbox: 'g u' }); + }); +}); diff --git a/frontend/src/commands/contextMenuCommands.js b/frontend/src/commands/contextMenuCommands.js new file mode 100644 index 00000000..e3e0f636 --- /dev/null +++ b/frontend/src/commands/contextMenuCommands.js @@ -0,0 +1,31 @@ +const COMMANDS = Object.freeze({ + markRead: 'mail.read', + markUnread: 'mail.unread', + toggleStar: 'mail.toggleStar', + reply: 'mail.reply', + replyAll: 'mail.replyAll', + forward: 'mail.forward', + archive: 'mail.archive', + delete: 'mail.trash', + markSpam: 'mail.spam', + markHam: 'mail.notSpam', +}); + +const SINGLE_CONVERSATION_COMMANDS = new Set(['mail.reply', 'mail.replyAll', 'mail.forward']); + +export function contextMenuTargetMessages(commandId, message, selectedMessages) { + if (SINGLE_CONVERSATION_COMMANDS.has(commandId)) return [message]; + return selectedMessages.length > 1 && selectedMessages.some(candidate => candidate.id === message.id) + ? selectedMessages + : [message]; +} + +export function toContextMenuCommand(action, data) { + if (COMMANDS[action]) return { commandId: COMMANDS[action] }; + if (action === 'moveTo' && data) return { commandId: 'mail.move', input: { folder: data } }; + if (action === 'snooze' && data) return { commandId: 'mail.snooze', input: { until: data } }; + if (action === 'gtdClassify' && ['todo', 'watch', 'delegated', 'someday', 'reference'].includes(data)) { + return { commandId: data === 'delegated' ? 'gtd.delegate' : `gtd.${data}` }; + } + return null; +} diff --git a/frontend/src/commands/contextMenuCommands.test.js b/frontend/src/commands/contextMenuCommands.test.js new file mode 100644 index 00000000..00e8d5dd --- /dev/null +++ b/frontend/src/commands/contextMenuCommands.test.js @@ -0,0 +1,75 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { contextMenuTargetMessages, toContextMenuCommand } from './contextMenuCommands.js'; + +test('maps migrated context actions to command IDs and typed input', () => { + assert.deepEqual(toContextMenuCommand('archive'), { commandId: 'mail.archive' }); + assert.deepEqual(toContextMenuCommand('markRead'), { commandId: 'mail.read' }); + assert.deepEqual(toContextMenuCommand('markUnread'), { commandId: 'mail.unread' }); + assert.deepEqual(toContextMenuCommand('toggleStar'), { commandId: 'mail.toggleStar' }); + assert.deepEqual(toContextMenuCommand('replyAll'), { commandId: 'mail.replyAll' }); + assert.deepEqual(toContextMenuCommand('moveTo', 'Archive'), { + commandId: 'mail.move', input: { folder: 'Archive' }, + }); + assert.deepEqual(toContextMenuCommand('snooze', '2026-08-01T09:00:00.000Z'), { + commandId: 'mail.snooze', input: { until: '2026-08-01T09:00:00.000Z' }, + }); + assert.deepEqual(toContextMenuCommand('gtdClassify', 'todo'), { commandId: 'gtd.todo' }); + assert.deepEqual(toContextMenuCommand('gtdClassify', 'delegated'), { commandId: 'gtd.delegate' }); +}); + +test('returns null for intentionally unmigrated utilities', () => { + assert.equal(toContextMenuCommand('copy'), null); + assert.equal(toContextMenuCommand('createRuleFromMessage'), null); + assert.equal(toContextMenuCommand('setCategory', 'social'), null); +}); + +test('targets the clicked row for responses and the frozen selection for bulk-safe actions', () => { + const clicked = { id: 'clicked' }; + const selected = [{ id: 'first' }, clicked, { id: 'third' }]; + assert.deepEqual(contextMenuTargetMessages('mail.reply', clicked, selected), [clicked]); + assert.deepEqual(contextMenuTargetMessages('mail.forward', clicked, selected), [clicked]); + assert.deepEqual(contextMenuTargetMessages('mail.archive', clicked, selected), selected); + assert.deepEqual(contextMenuTargetMessages('mail.archive', clicked, [{ id: 'first' }]), [clicked]); +}); + +test('routes list, context-menu, bulk, hover, and swipe mail actions through one controller', () => { + const list = fs.readFileSync(new URL('../components/MessageList.jsx', import.meta.url), 'utf8'); + const menu = fs.readFileSync(new URL('../components/ContextMenu.jsx', import.meta.url), 'utf8'); + assert.match(list, /useCommandRuntimeContext\(\)/); + assert.match(list, /actionableMessages\.map\(stableConversationId\)/); + assert.match(list, /source,\s*input,\s*frozenTargetIds/); + assert.match(menu, /toContextMenuCommand\(action, data\)/); + assert.match(menu, /source: 'context-menu'/); + assert.match(menu, /onCommand\(invocation\.commandId, invocation\.input\)/); + assert.match(list, /contextMenuTargetMessages/); + assert.match(list, /executeForMessages\(commandId, 'context-menu', targetMessages, input\)/); + assert.doesNotMatch( + list, + /api\.(bulkArchive|bulkDelete|bulkMove|bulkRead|markStarred|markSpam|markHam|snoozeMessage)/, + ); + assert.doesNotMatch( + list, + /shortcutBus\.on\('(archive|delete|toggleRead|gtdTodo|gtdWatch|gtdDelegated)'/, + ); +}); + +test('routes pane toolbar and menu mail actions through the shared controller', () => { + const pane = fs.readFileSync(new URL('../components/MessagePane.jsx', import.meta.url), 'utf8'); + assert.match(pane, /useCommandRuntimeContext\(\)/); + assert.match(pane, /stableConversationId\(message\)/); + assert.match(pane, /source,\s*input,\s*frozenTargetIds/); + assert.match(pane, /executeForMessage\('gtd\.delegate', 'visible-message-menu'\)/); + assert.match(pane, /account\?\.gtd_enabled && \(\s* { + const list = fs.readFileSync(new URL('../components/MessageList.jsx', import.meta.url), 'utf8'); + assert.match(list, /THREAD_EXPANDING_COMMANDS = new Set\(\[[\s\S]*'gtd\.delegate'/); +}); diff --git a/frontend/src/commands/contracts.js b/frontend/src/commands/contracts.js new file mode 100644 index 00000000..05ea0055 --- /dev/null +++ b/frontend/src/commands/contracts.js @@ -0,0 +1,134 @@ +export const TARGET_MODES = Object.freeze({ + GLOBAL: 'global', + ACCOUNT: 'account', + DRAFT: 'draft', + SINGLE_CONVERSATION: 'single_conversation', + BULK_SAFE: 'bulk_safe', +}); + +export const SURFACES = Object.freeze(['list', 'conversation', 'compose', 'settings', 'picker']); +export const PLATFORMS = Object.freeze(['mac', 'windows', 'linux']); + +/** @typedef {string | {default?: string, mac?: string, windows?: string, linux?: string}} KeySpec */ + +/** + * @typedef {object} CommandDefinition + * @property {string} id + * @property {string} titleKey + * @property {string[]} aliasKeys + * @property {string} icon + * @property {string} group + * @property {{primary: KeySpec | null, secondary: KeySpec[]}} defaultKeys + * @property {{base: number, boost?: (context: CommandContext) => number}} rank + * @property {(context: CommandContext) => boolean} isAvailable + * @property {'global'|'account'|'draft'|'single_conversation'|'bulk_safe'} targetMode + * @property {string} executorId + * @property {Readonly>} [params] + */ + +/** + * @typedef {object} CommandContext + * @property {'list'|'conversation'|'compose'|'settings'|'picker'} surface + * @property {string | null} activeConversationId + * @property {object | null} activeMessage + * @property {readonly string[]} selectedConversationIds + * @property {readonly string[]} visibleConversationIds + * @property {Readonly>} conversationsById + * @property {string | null} accountId + * @property {string | null} folder + * @property {object | null} draft + * @property {boolean} gtdAvailable + * @property {boolean} cardDavConnected + * @property {Readonly} carddavStatus + * @property {boolean} carddavStatusLoaded + * @property {object | null} modal + * @property {boolean} editing + * @property {boolean} undoAvailable + * @property {'mac'|'windows'|'linux'} platform + * @property {Readonly>} shortcutOverrides + * @property {(key: string, values?: object) => string} translate + */ + +function freezeKeySpec(spec) { + return spec && typeof spec === 'object' ? Object.freeze({ ...spec }) : spec; +} + +export function stableConversationId(message) { + const accountId = message?.account_id; + const withinAccountId = message?.message_id || message?.id; + return accountId && withinAccountId ? `${accountId}:${withinAccountId}` : null; +} + +export function validateCommandDefinition(input) { + if (!input?.id?.includes('.')) throw new TypeError('command id must be namespaced'); + for (const key of ['titleKey', 'icon', 'group']) { + if (typeof input[key] !== 'string' || !input[key]) throw new TypeError(`${key} must be a non-empty string`); + } + if (!Array.isArray(input.aliasKeys) || input.aliasKeys.some(key => typeof key !== 'string')) { + throw new TypeError('aliasKeys must be an array of localization keys'); + } + if (!Object.values(TARGET_MODES).includes(input.targetMode)) { + throw new TypeError(`unsupported targetMode "${input.targetMode}"`); + } + if (typeof input.executorId !== 'string' || !input.executorId) { + throw new TypeError('executorId must be a non-empty string'); + } + if (typeof input.isAvailable !== 'function') throw new TypeError('isAvailable must be a function'); + if (!Number.isFinite(input.rank?.base)) throw new TypeError('rank.base must be a finite number'); + if (input.rank.boost != null && typeof input.rank.boost !== 'function') { + throw new TypeError('rank.boost must be a function'); + } + const primary = freezeKeySpec(input.defaultKeys?.primary ?? null); + const secondary = Object.freeze((input.defaultKeys?.secondary || []).map(freezeKeySpec)); + return Object.freeze({ + ...input, + aliasKeys: Object.freeze([...input.aliasKeys]), + defaultKeys: Object.freeze({ primary, secondary }), + rank: Object.freeze({ ...input.rank }), + params: Object.freeze({ ...(input.params || {}) }), + }); +} + +export function createCommandContext(input) { + if (!SURFACES.includes(input.surface)) throw new TypeError(`unsupported surface "${input.surface}"`); + if (!PLATFORMS.includes(input.platform)) throw new TypeError(`unsupported platform "${input.platform}"`); + if (typeof input.translate !== 'function') throw new TypeError('translate must be a function'); + + const conversationsById = {}; + for (const message of input.conversations || []) { + const id = stableConversationId(message); + if (!id || conversationsById[id]) continue; + conversationsById[id] = Object.freeze({ + id, + rowId: message.id, + accountId: message.account_id ?? null, + message, + }); + } + const selectedConversationIds = [...new Set(input.selectedConversationIds || [])]; + const carddavStatus = Object.freeze({ + ...(input.carddavStatus || {}), + connected: input.carddavStatus?.connected === true || input.cardDavConnected === true, + }); + return Object.freeze({ + surface: input.surface, + activeConversationId: input.activeConversationId || null, + activeMessage: input.activeMessage || null, + selectedConversationIds: Object.freeze(selectedConversationIds), + visibleConversationIds: Object.freeze([...new Set(input.visibleConversationIds || [])]), + conversationsById: Object.freeze(conversationsById), + accountId: input.accountId || null, + folder: input.folder || null, + draft: input.draft || null, + gtdAvailable: Boolean(input.gtdAvailable), + cardDavConnected: carddavStatus.connected, + carddavStatus, + carddavStatusLoaded: Boolean(input.carddavStatusLoaded), + modal: input.modal || null, + editing: Boolean(input.editing), + undoAvailable: Boolean(input.undoAvailable), + platform: input.platform, + shortcutOverrides: Object.freeze({ ...(input.shortcutOverrides || {}) }), + translate: input.translate, + }); +} diff --git a/frontend/src/commands/contracts.test.js b/frontend/src/commands/contracts.test.js new file mode 100644 index 00000000..923e132a --- /dev/null +++ b/frontend/src/commands/contracts.test.js @@ -0,0 +1,105 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + TARGET_MODES, + createCommandContext, + stableConversationId, + validateCommandDefinition, +} from './contracts.js'; + +const validDefinition = { + id: 'mail.archive', + titleKey: 'commands.mail.archive.title', + aliasKeys: ['commands.mail.archive.alias.done'], + icon: 'archive', + group: 'mail', + defaultKeys: { primary: 'e', secondary: ['o'] }, + rank: { base: 100, boost: context => context.selectedConversationIds.length ? 25 : 0 }, + isAvailable: context => context.surface !== 'settings', + targetMode: 'bulk_safe', + executorId: 'mail.archive', +}; + +describe('command contracts', () => { + it('uses the five approved target-mode values', () => { + assert.deepEqual(Object.values(TARGET_MODES), [ + 'global', 'account', 'draft', 'single_conversation', 'bulk_safe', + ]); + }); + + it('validates and deeply freezes a complete definition', () => { + const definition = validateCommandDefinition(validDefinition); + assert.equal(definition.id, 'mail.archive'); + assert.ok(Object.isFrozen(definition)); + assert.ok(Object.isFrozen(definition.aliasKeys)); + assert.ok(Object.isFrozen(definition.defaultKeys.secondary)); + assert.throws(() => definition.aliasKeys.push('commands.other')); + }); + + it('rejects missing, unnamespaced, and unsupported fields with exact errors', () => { + assert.throws( + () => validateCommandDefinition({ ...validDefinition, id: 'archive' }), + /command id must be namespaced/, + ); + assert.throws( + () => validateCommandDefinition({ ...validDefinition, targetMode: 'message' }), + /unsupported targetMode "message"/, + ); + assert.throws( + () => validateCommandDefinition({ ...validDefinition, executorId: '' }), + /executorId must be a non-empty string/, + ); + }); + + it('scopes RFC Message-ID and row-id fallback identities by account', () => { + assert.equal(stableConversationId({ account_id: 'acct-1', id: 'row-1', message_id: '' }), 'acct-1:'); + assert.equal(stableConversationId({ account_id: 'acct-2', id: 'row-1', message_id: '' }), 'acct-2:'); + assert.equal(stableConversationId({ account_id: 'acct-1', id: 'row-2' }), 'acct-1:row-2'); + assert.equal(stableConversationId({ id: 'row-2' }), null); + assert.equal(stableConversationId({}), null); + }); + + it('normalizes and freezes a complete command context snapshot', () => { + const context = createCommandContext({ + surface: 'list', + activeConversationId: 'acct-1:', + activeMessage: { id: 'row-a', message_id: '', account_id: 'acct-1' }, + selectedConversationIds: ['acct-1:', 'acct-1:'], + visibleConversationIds: ['acct-1:', 'acct-1:'], + conversations: [ + { id: 'row-a', message_id: '', account_id: 'acct-1' }, + { id: 'row-b', message_id: '', account_id: 'acct-1' }, + ], + accountId: 'acct-1', + folder: 'INBOX', + draft: null, + gtdAvailable: true, + cardDavConnected: false, + modal: null, + editing: false, + undoAvailable: true, + platform: 'mac', + shortcutOverrides: { 'mail.archive': 'x' }, + translate: key => ({ 'commands.mail.archive.title': 'Archive' })[key] || key, + }); + + assert.deepEqual(context.selectedConversationIds, ['acct-1:']); + assert.deepEqual(context.visibleConversationIds, ['acct-1:', 'acct-1:']); + assert.equal(context.conversationsById['acct-1:'].rowId, 'row-a'); + assert.equal(context.activeMessage.id, 'row-a'); + assert.equal(context.undoAvailable, true); + assert.equal(context.translate('commands.mail.archive.title'), 'Archive'); + assert.ok(Object.isFrozen(context)); + assert.ok(Object.isFrozen(context.selectedConversationIds)); + }); + + it('preserves the legacy CardDAV boolean while exposing a frozen status snapshot', () => { + const context = createCommandContext({ + surface: 'list', platform: 'linux', translate: key => key, + cardDavConnected: true, + }); + assert.equal(context.cardDavConnected, true); + assert.deepEqual(context.carddavStatus, { connected: true }); + assert.ok(Object.isFrozen(context.carddavStatus)); + }); +}); diff --git a/frontend/src/commands/controller.js b/frontend/src/commands/controller.js new file mode 100644 index 00000000..e1936bf4 --- /dev/null +++ b/frontend/src/commands/controller.js @@ -0,0 +1,95 @@ +import { hasTargets, resolveTargetIds } from './targets.js'; + +function failed(commandId, error, targetIds = []) { + return { status: 'failed', commandId, targetIds, error: error instanceof Error ? error : new Error(String(error)) }; +} + +function terminalOutcome(commandId, targetIds, result) { + if (!['success', 'cancelled', 'partial', 'failed'].includes(result?.status)) { + return failed(commandId, new Error(`Invalid executor outcome for "${commandId}"`), targetIds); + } + return { commandId, targetIds, ...result }; +} + +export function createCommandController({ + registry, + getContext, + executors, + onContinuation = () => {}, + onOutcome = () => {}, +}) { + const inFlight = new Map(); + + function execute(commandId, { source = 'unknown', input, frozenTargetIds } = {}) { + const initialContext = getContext(); + const command = registry.get(commandId); + if (!command) { + const outcome = failed(commandId, new Error(`Unknown command "${commandId}"`)); + onOutcome(outcome); + return Promise.resolve(outcome); + } + const resumingFrozenTargets = frozenTargetIds != null && input !== undefined; + if ((!resumingFrozenTargets && !command.isAvailable(initialContext)) + || (!frozenTargetIds && !hasTargets(command, initialContext))) { + const outcome = failed(commandId, new Error(`Command "${commandId}" is not available`)); + onOutcome(outcome); + return Promise.resolve(outcome); + } + + const frozen = frozenTargetIds == null + ? resolveTargetIds(command, initialContext).targetIds + : [...new Set(frozenTargetIds)]; + if (command.targetMode === 'single_conversation' && frozen.length !== 1) { + const outcome = failed( + commandId, + new Error(`Command "${commandId}" requires exactly one conversation`), + frozen, + ); + onOutcome(outcome); + return Promise.resolve(outcome); + } + const dedupeKey = `${commandId}:${[...frozen].sort().join('|')}`; + if (inFlight.has(dedupeKey)) return inFlight.get(dedupeKey); + + const promise = (async () => { + const context = getContext(); + const { targetIds, missingTargetIds } = resolveTargetIds(command, context, frozen); + const executor = executors[command.executorId]; + if (!executor) return failed(commandId, new Error(`Missing executor "${command.executorId}"`), targetIds); + if (frozen.length && !targetIds.length) { + return { status: 'partial', commandId, targetIds, missingTargetIds, succeededIds: [], failed: [] }; + } + try { + const result = await executor({ command, context, source, input, targetIds }); + if (result?.status === 'needs_input') { + const continuation = Object.freeze({ + commandId, + kind: result.continuation.kind, + targetIds: Object.freeze([...frozen]), + props: Object.freeze({ ...(result.continuation.props || {}) }), + }); + onContinuation(continuation); + return { status: 'needs_input', continuation }; + } + const outcome = terminalOutcome(commandId, targetIds, result); + if (missingTargetIds.length && outcome.status === 'success') { + return { + status: 'partial', commandId, targetIds, missingTargetIds, + succeededIds: [...targetIds], failed: [], value: outcome.value, + }; + } + return missingTargetIds.length ? { ...outcome, missingTargetIds } : outcome; + } catch (error) { + return failed(commandId, error, targetIds); + } + })().then(outcome => { + if (outcome.status !== 'needs_input') onOutcome(outcome); + return outcome; + }).finally(() => inFlight.delete(dedupeKey)); + + inFlight.set(dedupeKey, promise); + return promise; + } + + return Object.freeze({ execute }); +} diff --git a/frontend/src/commands/controller.test.js b/frontend/src/commands/controller.test.js new file mode 100644 index 00000000..53dd8cff --- /dev/null +++ b/frontend/src/commands/controller.test.js @@ -0,0 +1,156 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createCommandContext } from './contracts.js'; +import { createCommandRegistry } from './registry.js'; +import { createCommandController } from './controller.js'; + +const definition = (overrides = {}) => ({ + id: 'mail.move', titleKey: 'move', aliasKeys: [], icon: 'move', group: 'mail', + defaultKeys: { primary: 'v', secondary: [] }, rank: { base: 1 }, + isAvailable: () => true, targetMode: 'bulk_safe', executorId: 'mail.move', ...overrides, +}); +const makeContext = (ids = ['acct:', 'acct:']) => createCommandContext({ + surface: 'list', activeConversationId: 'acct:', selectedConversationIds: ids, + conversations: ids.map((id, index) => ({ id: `row-${index}`, message_id: id.slice('acct:'.length), account_id: 'acct' })), + accountId: 'acct', folder: 'INBOX', draft: null, gtdAvailable: false, + cardDavConnected: false, modal: null, editing: false, platform: 'mac', + shortcutOverrides: {}, translate: key => key, +}); + +describe('createCommandController', () => { + it('rejects unknown, unavailable, and missing executors as failed outcomes', async () => { + const registry = createCommandRegistry([definition({ isAvailable: () => false })]); + const controller = createCommandController({ registry, getContext: () => makeContext(), executors: {} }); + assert.match((await controller.execute('missing.command')).error.message, /Unknown command/); + assert.match((await controller.execute('mail.move')).error.message, /not available/); + + const available = createCommandRegistry([definition()]); + const noExecutor = createCommandController({ registry: available, getContext: () => makeContext(), executors: {} }); + assert.match((await noExecutor.execute('mail.move')).error.message, /Missing executor/); + }); + + it('freezes targets into a continuation and reuses them on resume', async () => { + let context = makeContext(); + const calls = []; + const continuations = []; + const registry = createCommandRegistry([definition()]); + const controller = createCommandController({ + registry, + getContext: () => context, + executors: { + 'mail.move': args => { + calls.push(args); + return args.input + ? { status: 'success', value: { folder: args.input.folder } } + : { status: 'needs_input', continuation: { kind: 'move', props: { titleKey: 'move.title' } } }; + }, + }, + onContinuation: value => continuations.push(value), + }); + + const first = await controller.execute('mail.move', { source: 'palette' }); + assert.deepEqual(first.continuation, { + commandId: 'mail.move', kind: 'move', targetIds: ['acct:', 'acct:'], props: { titleKey: 'move.title' }, + }); + assert.deepEqual(continuations, [first.continuation]); + + context = makeContext(['acct:']); + const resumed = await controller.execute('mail.move', { + source: 'palette', input: { folder: 'Archive' }, frozenTargetIds: first.continuation.targetIds, + }); + assert.equal(resumed.status, 'partial'); + assert.deepEqual(resumed.targetIds, ['acct:']); + assert.deepEqual(resumed.missingTargetIds, ['acct:']); + assert.deepEqual(calls[1].targetIds, ['acct:']); + }); + + it('resumes frozen targets after the current selection becomes unavailable', async () => { + let context = makeContext(['acct:']); + const registry = createCommandRegistry([definition({ + isAvailable: current => current.gtdAvailable, + })]); + context = Object.freeze({ ...context, gtdAvailable: true }); + const controller = createCommandController({ + registry, + getContext: () => context, + executors: { 'mail.move': () => ({ status: 'success' }) }, + }); + const frozenTargetIds = ['acct:']; + context = Object.freeze({ ...context, gtdAvailable: false }); + const outcome = await controller.execute('mail.move', { + source: 'continuation', input: { contactId: 'contact-1' }, frozenTargetIds, + }); + assert.equal(outcome.status, 'success'); + assert.deepEqual(outcome.targetIds, frozenTargetIds); + }); + + it('deduplicates only concurrent execution of the same command and target set', async () => { + let release; + let callCount = 0; + const gate = new Promise(resolve => { release = resolve; }); + const registry = createCommandRegistry([definition()]); + const controller = createCommandController({ + registry, getContext: () => makeContext(), + executors: { 'mail.move': async () => { callCount += 1; await gate; return { status: 'success' }; } }, + }); + const one = controller.execute('mail.move'); + const two = controller.execute('mail.move'); + assert.strictEqual(one, two); + release(); + await one; + await controller.execute('mail.move'); + assert.equal(callCount, 2); + }); + + it('rejects a frozen multi-selection for single-conversation commands', async () => { + let callCount = 0; + const registry = createCommandRegistry([definition({ + id: 'mail.reply', + targetMode: 'single_conversation', + executorId: 'mail.reply', + })]); + const controller = createCommandController({ + registry, + getContext: () => makeContext(), + executors: { + 'mail.reply': () => { + callCount += 1; + return { status: 'success' }; + }, + }, + }); + + const outcome = await controller.execute('mail.reply', { + source: 'context-menu', + frozenTargetIds: ['acct:', 'acct:'], + }); + + assert.equal(outcome.status, 'failed'); + assert.match(outcome.error.message, /exactly one conversation/); + assert.equal(callCount, 0); + }); + + it('normalizes success, cancelled, partial, thrown failures, and callback delivery', async () => { + const terminal = []; + const outcomes = ['success', 'cancelled', 'partial']; + for (const status of outcomes) { + const registry = createCommandRegistry([definition()]); + const controller = createCommandController({ + registry, getContext: () => makeContext(['acct:']), + executors: { 'mail.move': () => status === 'partial' + ? { status, succeededIds: [], failed: [{ targetId: 'acct:', error: new Error('nope') }] } + : { status } }, + onOutcome: outcome => terminal.push(outcome.status), + }); + assert.equal((await controller.execute('mail.move')).status, status); + } + const registry = createCommandRegistry([definition()]); + const failed = createCommandController({ + registry, getContext: () => makeContext(['acct:']), + executors: { 'mail.move': () => { throw new Error('boom'); } }, + onOutcome: outcome => terminal.push(outcome.status), + }); + assert.equal((await failed.execute('mail.move')).status, 'failed'); + assert.deepEqual(terminal, ['success', 'cancelled', 'partial', 'failed']); + }); +}); diff --git a/frontend/src/commands/mailActions.js b/frontend/src/commands/mailActions.js new file mode 100644 index 00000000..32d61428 --- /dev/null +++ b/frontend/src/commands/mailActions.js @@ -0,0 +1,403 @@ +import { openForwardFromMessage, openReplyFromMessage } from '../utils/composeFromMessage.js'; +import { delegateNeedsContact, normalizeDelegateOutcome } from '../utils/delegation.js'; + +const DEFAULT_KEYS = Object.freeze({ + 'mail.archive': { primary: 'e', secondary: [] }, + 'mail.snooze': { primary: 'h', secondary: [] }, + 'mail.move': { primary: 'v', secondary: [] }, + 'mail.toggleRead': { primary: 'u', secondary: ['m'] }, + 'mail.toggleStar': { primary: 's', secondary: [] }, + 'mail.trash': { primary: '#', secondary: [] }, + 'mail.spam': { primary: '!', secondary: [] }, + 'mail.reply': { primary: 'r', secondary: [] }, + 'mail.replyAll': { primary: 'enter', secondary: ['a'] }, + 'mail.forward': { primary: 'f', secondary: [] }, + 'gtd.todo': { primary: 't', secondary: [] }, + 'gtd.watch': { primary: 'w', secondary: [] }, + 'gtd.delegate': { primary: 'd', secondary: [] }, +}); + +const definition = (id, titleKey, aliasKeys, icon, group, targetMode, executorId, rank = 50) => ({ + id, + titleKey, + aliasKeys, + icon, + group, + defaultKeys: DEFAULT_KEYS[id] || { primary: null, secondary: [] }, + rank: { base: rank }, + targetMode, + executorId, + isAvailable: () => true, +}); + +const baseMailCommandDefinitions = [ + definition('mail.archive', 'shortcuts.actions.archive.label', ['commands.mail.archive.aliasDone'], 'archive', 'mail', 'bulk_safe', 'mail.archive', 90), + definition('mail.snooze', 'contextMenu.snooze.label', ['commands.mail.snooze.aliasRemind'], 'clock', 'mail', 'bulk_safe', 'mail.snooze', 85), + definition('mail.move', 'contextMenu.moveToFolder', [], 'folder', 'mail', 'bulk_safe', 'mail.move', 75), + definition('mail.read', 'contextMenu.markRead', [], 'mail-open', 'mail', 'bulk_safe', 'mail.read'), + definition('mail.unread', 'contextMenu.markUnread', [], 'mail', 'mail', 'bulk_safe', 'mail.unread'), + definition('mail.toggleRead', 'shortcuts.actions.toggleRead.label', [], 'mail', 'mail', 'bulk_safe', 'mail.toggleRead', 80), + definition('mail.star', 'message.star', [], 'star', 'mail', 'bulk_safe', 'mail.star'), + definition('mail.unstar', 'message.unstar', [], 'star', 'mail', 'bulk_safe', 'mail.unstar'), + definition('mail.toggleStar', 'shortcuts.actions.toggleStar.label', [], 'star', 'mail', 'bulk_safe', 'mail.toggleStar', 80), + definition('mail.trash', 'shortcuts.actions.delete.label', ['commands.mail.trash.aliasDelete'], 'trash', 'mail', 'bulk_safe', 'mail.trash', 80), + definition('mail.spam', 'contextMenu.markAsSpam', ['commands.mail.spam.aliasJunk'], 'shield', 'mail', 'bulk_safe', 'mail.spam'), + definition('mail.notSpam', 'contextMenu.markAsNotSpam', [], 'shield-check', 'mail', 'bulk_safe', 'mail.notSpam'), + definition('mail.reply', 'shortcuts.actions.reply.label', [], 'reply', 'respond', 'single_conversation', 'mail.reply', 80), + definition('mail.replyAll', 'shortcuts.actions.replyAll.label', [], 'reply-all', 'respond', 'single_conversation', 'mail.replyAll', 80), + definition('mail.forward', 'shortcuts.actions.forward.label', [], 'forward', 'respond', 'single_conversation', 'mail.forward', 70), + definition('gtd.todo', 'shortcuts.actions.gtdTodo.label', [], 'check-square', 'gtd', 'bulk_safe', 'gtd.todo'), + definition('gtd.watch', 'shortcuts.actions.gtdWatch.label', [], 'eye', 'gtd', 'bulk_safe', 'gtd.watch'), + definition('gtd.delegate', 'gtd.delegate.command', [], 'user-check', 'gtd', 'bulk_safe', 'gtd.delegate'), + definition('gtd.someday', 'gtd.states.someday', [], 'calendar', 'gtd', 'bulk_safe', 'gtd.someday'), + definition('gtd.reference', 'gtd.states.reference', [], 'bookmark', 'gtd', 'bulk_safe', 'gtd.reference'), +].map(command => Object.freeze({ + ...command, + isAvailable: context => command.id.startsWith('gtd.') ? context.gtdAvailable === true : true, +})); + +export const mailCommandDefinitions = Object.freeze([...baseMailCommandDefinitions, Object.freeze({ + id: 'mail.unsubscribe', + titleKey: 'message.unsubscribe.button', + aliasKeys: [], + icon: 'mail', + group: 'mail', + defaultKeys: { + primary: { mac: 'meta+u', windows: 'ctrl+u', linux: 'ctrl+u', default: 'ctrl+u' }, + secondary: [], + }, + rank: { base: 60 }, + targetMode: 'single_conversation', + executorId: 'mail.unsubscribe', + isAvailable: context => context.surface === 'conversation' + && !!context.activeMessage?.list_unsubscribe + && !context.activeMessage?.unsubscribed_at, +})]); + +const idOf = target => target.id; +const idsOf = targets => targets.map(idOf); +const errorText = error => error instanceof Error ? error.message : String(error); +const failedOutcome = (targets, error) => ({ + status: 'failed', + succeededIds: [], + failed: targets.map(target => ({ id: idOf(target), error: errorText(error) })), +}); +const settledOutcome = (targets, results) => { + const succeededIds = []; + const failed = []; + results.forEach((result, index) => { + if (result.status === 'fulfilled') succeededIds.push(idOf(targets[index])); + else failed.push({ id: idOf(targets[index]), error: errorText(result.reason) }); + }); + return { + status: failed.length === 0 ? 'success' : succeededIds.length === 0 ? 'failed' : 'partial', + succeededIds, + failed, + }; +}; + +const continuation = (commandId, kind, targets, props) => ({ + status: 'needs_input', + continuation: { commandId, kind, targetIds: idsOf(targets), props }, +}); + +function scheduleOptimisticRemoval(deps, { + targets, + call, + successIds, + onSuccess, + successNotice, + failureNotice, + flushOnUnload, +}) { + const rowIds = targets.map(target => target.message.id); + const unreadTargets = targets.filter(target => !target.message.is_read); + deps.guardPending(rowIds); + deps.removeMessages(targets); + deps.adjustUnread(unreadTargets, true); + let undone = false; + + let unregisterPendingRemoval = () => {}; + const run = async () => { + unregisterPendingRemoval(); + if (undone) return; + try { + const response = await call(); + const succeededRowIds = new Set(successIds(response)); + const succeededTargets = targets.filter(target => succeededRowIds.has(target.message.id)); + const failedTargets = targets.filter(target => !succeededRowIds.has(target.message.id)); + deps.guardCompleted(succeededTargets.map(target => target.message.id)); + deps.clearGuards(failedTargets.map(target => target.message.id)); + if (failedTargets.length > 0) { + deps.restoreMessages(failedTargets); + deps.adjustUnread(failedTargets.filter(target => !target.message.is_read), false); + deps.notify({ + type: 'error', + titleKey: failureNotice, + succeededCount: succeededTargets.length, + failedCount: failedTargets.length, + }); + } + onSuccess?.(response, succeededTargets); + } catch (error) { + deps.clearGuards(rowIds); + deps.restoreMessages(targets); + deps.adjustUnread(unreadTargets, false); + deps.notify({ + type: 'error', + titleKey: failureNotice, + body: errorText(error), + succeededCount: 0, + failedCount: targets.length, + }); + } + }; + const timer = deps.timers.setTimeout(run, 4500); + if (flushOnUnload) { + unregisterPendingRemoval = deps.registerPendingRemoval({ + timer, + run, + unload: () => flushOnUnload(rowIds), + }); + } + + deps.notify({ + titleKey: successNotice, + count: targets.length, + onUndo: () => { + undone = true; + unregisterPendingRemoval(); + deps.timers.clearTimeout(timer); + deps.clearGuards(rowIds); + deps.restoreMessages(targets); + deps.adjustUnread(unreadTargets, false); + }, + }); + + return { status: 'success', succeededIds: idsOf(targets), failed: [] }; +} + +export function createMailActionExecutors(deps) { + const resolveTargets = ({ context, targetIds }) => targetIds + .map(targetId => context.conversationsById[targetId]) + .filter(Boolean); + const setRead = async (targets, read) => { + const changedTargets = targets.filter(target => target.message.is_read !== read); + if (read) deps.guardReadPending(changedTargets); + else deps.clearReadGuards(changedTargets); + deps.patchMessages(targets, { is_read: read }); + deps.adjustUnread(changedTargets, read); + try { + await deps.api.bulkRead(targets.map(target => target.message.id), read); + if (read) deps.guardReadCompleted(changedTargets); + return { status: 'success', succeededIds: idsOf(targets), failed: [] }; + } catch (error) { + deps.clearReadGuards(changedTargets); + changedTargets.forEach(target => deps.patchMessages([target], { is_read: target.message.is_read })); + deps.restoreMessages(targets); + deps.adjustUnread(changedTargets, !read); + return failedOutcome(targets, error); + } + }; + const setStarred = async (targets, starred) => { + deps.patchMessages(targets, { is_starred: starred }); + const results = await Promise.allSettled( + targets.map(target => deps.api.markStarred(target.message.id, starred)), + ); + const outcome = settledOutcome(targets, results); + const failedIds = new Set(outcome.failed.map(item => item.id)); + const failedTargets = targets.filter(target => failedIds.has(idOf(target))); + failedTargets.forEach(target => deps.patchMessages([target], { is_starred: target.message.is_starred })); + deps.restoreMessages(failedTargets); + return outcome; + }; + const classify = state => async ({ targets }) => { + const results = await Promise.allSettled( + targets.map(target => deps.api.gtdClassify(target.message.id, state)), + ); + deps.scheduleGtdRefresh(); + return settledOutcome(targets, results); + }; + const delegate = async ({ context, input, targets }) => { + const carddavStatus = context.carddavStatusLoaded + ? context.carddavStatus + : await deps.refreshCarddavStatus(); + if (input === undefined && delegateNeedsContact(carddavStatus)) { + return continuation('gtd.delegate', 'contact', targets, { targetCount: targets.length }); + } + + const contactId = input?.contactId ?? null; + const result = await deps.api.gtd.delegate( + targets.map(target => target.message.id), + contactId, + ); + const resultsByMessageId = new Map( + (result?.results || []).map(item => [item.messageId, item]), + ); + targets.forEach(target => { + const item = resultsByMessageId.get(target.message.id); + if (item?.ok) deps.patchMessages([target], { delegation: item.delegation ?? null }); + }); + await Promise.all([deps.refreshMessages(), deps.refreshGtdSections()]); + return normalizeDelegateOutcome(result, targets); + }; + + const handlers = { + 'mail.archive': ({ targets }) => scheduleOptimisticRemoval(deps, { + targets, + call: () => deps.api.bulkArchive(targets.map(target => target.message.id)), + successIds: response => response.archived ?? [], + successNotice: 'messageList.bulkArchived.title', + failureNotice: 'messageList.bulkArchived.failTitle', + }), + 'mail.trash': ({ targets }) => scheduleOptimisticRemoval(deps, { + targets, + call: () => deps.api.bulkDelete(targets.map(target => target.message.id)), + successIds: response => response.deleted ?? [], + successNotice: 'messageList.bulkDeleted.title', + failureNotice: 'messageList.bulkDeleted.failTitle', + flushOnUnload: ids => deps.keepaliveDelete(ids), + }), + 'mail.move': ({ targets, input }) => { + const folder = input?.folder ?? input?.value; + if (!folder) { + return continuation('mail.move', 'move', targets, { + accountId: targets[0]?.message.account_id, + targetCount: targets.length, + titleKey: 'contextMenu.moveToFolder', + inputKey: 'folder', + items: deps.moveOptions(targets[0]?.message.account_id, targets), + }); + } + return scheduleOptimisticRemoval(deps, { + targets, + call: () => deps.api.bulkMove(targets.map(target => target.message.id), folder), + successIds: response => response.moved ?? [], + onSuccess: (_response, succeededTargets) => { + if (succeededTargets.length > 0) { + deps.recordRecentFolder(targets[0].message.account_id, folder); + } + }, + successNotice: 'messageList.bulkMoved.title', + failureNotice: 'messageList.bulkMoved.failTitle', + }); + }, + 'mail.snooze': ({ targets, input }) => { + const until = input?.until ?? input?.value; + if (!until) { + return continuation('mail.snooze', 'snooze', targets, { + targetCount: targets.length, + titleKey: 'contextMenu.snooze.label', + inputKey: 'until', + items: deps.snoozeOptions(), + }); + } + return scheduleOptimisticRemoval(deps, { + targets, + call: async () => { + const results = await Promise.allSettled( + targets.map(target => deps.api.snoozeMessage(target.message.id, until)), + ); + return { + snoozed: targets + .filter((_target, index) => results[index].status === 'fulfilled') + .map(target => target.message.id), + }; + }, + successIds: response => response.snoozed, + successNotice: 'message.snoozed.title', + failureNotice: 'message.snoozed.failTitle', + }); + }, + 'mail.spam': ({ targets }) => scheduleOptimisticRemoval(deps, { + targets, + call: async () => { + const results = await Promise.allSettled( + targets.map(target => deps.api.markSpam(target.message.id)), + ); + return { + moved: targets + .filter((_target, index) => results[index].status === 'fulfilled') + .map(target => target.message.id), + }; + }, + successIds: response => response.moved, + successNotice: 'spam.movedToSpamBulk', + failureNotice: 'spam.failTitle', + }), + 'mail.notSpam': ({ targets }) => scheduleOptimisticRemoval(deps, { + targets, + call: async () => { + const results = await Promise.allSettled( + targets.map(target => deps.api.markHam(target.message.id)), + ); + return { + moved: targets + .filter((_target, index) => results[index].status === 'fulfilled') + .map(target => target.message.id), + }; + }, + successIds: response => response.moved, + successNotice: 'spam.movedToInboxBulk', + failureNotice: 'spam.failHamTitle', + }), + 'mail.read': ({ targets }) => setRead(targets, true), + 'mail.unread': ({ targets }) => setRead(targets, false), + 'mail.toggleRead': ({ targets }) => setRead(targets, targets.some(target => !target.message.is_read)), + 'mail.star': ({ targets }) => setStarred(targets, true), + 'mail.unstar': ({ targets }) => setStarred(targets, false), + 'mail.toggleStar': ({ targets }) => setStarred(targets, targets.some(target => !target.message.is_starred)), + 'mail.reply': async ({ targets }) => { + await openReplyFromMessage(targets[0].message, { + accounts: deps.accounts(), + openCompose: deps.openCompose, + getMessageBody: deps.api.getMessageBody, + replyAll: false, + }); + return { status: 'success', succeededIds: idsOf(targets), failed: [] }; + }, + 'mail.replyAll': async ({ targets }) => { + await openReplyFromMessage(targets[0].message, { + accounts: deps.accounts(), + openCompose: deps.openCompose, + getMessageBody: deps.api.getMessageBody, + replyAll: true, + }); + return { status: 'success', succeededIds: idsOf(targets), failed: [] }; + }, + 'mail.forward': async ({ targets }) => { + await openForwardFromMessage(targets[0].message, { + openCompose: deps.openCompose, + getMessageBody: deps.api.getMessageBody, + }); + return { status: 'success', succeededIds: idsOf(targets), failed: [] }; + }, + 'mail.unsubscribe': async ({ targets }) => { + try { + const result = await deps.api.unsubscribeMessage(targets[0].message.id); + const isHandled = result?.type === 'one-click' + || (result?.type === 'url' && result.url) + || (result?.type === 'mailto' && result.mailto); + if (!isHandled) throw new Error('Unsupported unsubscribe response'); + if (result.type === 'url' && result.url) deps.openExternal(result.url); + if (result.type === 'mailto' && result.mailto) deps.openExternal(result.mailto); + deps.patchMessages(targets, { unsubscribed_at: new Date().toISOString() }); + deps.notify({ titleKey: 'message.unsubscribe.done' }); + return { status: 'success', succeededIds: idsOf(targets), failed: [] }; + } catch (error) { + deps.notify({ type: 'error', titleKey: 'message.unsubscribe.error' }); + return failedOutcome(targets, error); + } + }, + 'gtd.todo': classify('todo'), + 'gtd.watch': classify('watch'), + 'gtd.delegate': delegate, + 'gtd.someday': classify('someday'), + 'gtd.reference': classify('reference'), + }; + + return Object.fromEntries(Object.entries(handlers).map(([id, handler]) => [ + id, + args => handler({ ...args, targets: resolveTargets(args) }), + ])); +} diff --git a/frontend/src/commands/mailActions.test.js b/frontend/src/commands/mailActions.test.js new file mode 100644 index 00000000..524b3641 --- /dev/null +++ b/frontend/src/commands/mailActions.test.js @@ -0,0 +1,477 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createMailActionExecutors, mailCommandDefinitions } from './mailActions.js'; + +const target = (rowId, patch = {}) => { + const id = `account-1:<${rowId}@example.test>`; + return { + id, + rowId, + accountId: 'account-1', + message: { + id: rowId, + account_id: 'account-1', + message_id: `<${rowId}@example.test>`, + is_read: false, + is_starred: false, + subject: `Subject ${rowId}`, + ...patch, + }, + }; +}; + +function harness(apiPatch = {}, depsPatch = {}) { + const events = []; + const api = { + bulkRead: async (ids, read) => ({ ok: true, ids, read }), + markStarred: async (id, starred) => ({ ok: true, id, starred }), + gtdClassify: async (id, state) => ({ ok: true, id, state }), + gtd: { + delegate: async (ids, contactId) => ({ + status: 'success', successCount: ids.length, failureCount: 0, + results: ids.map(messageId => ({ messageId, ok: true, delegation: contactId })), + }), + }, + getMessageBody: async () => ({ text: 'body', html: '

body

', attachments: [] }), + ...apiPatch, + }; + const deps = { + api, + accounts: () => [{ id: 'account-1', email_address: 'me@example.test', gtd_enabled: true }], + openCompose: payload => events.push(['compose', payload]), + openExternal: value => events.push(['external', value]), + patchMessages: (targets, patch) => events.push(['patch', targets.map(item => item.id), patch]), + restoreMessages: targets => events.push(['restore', targets.map(item => item.id)]), + adjustUnread: (targets, read) => events.push(['unread', targets.map(item => item.id), read]), + guardReadPending: targets => events.push(['read-pending', targets.map(item => item.id)]), + guardReadCompleted: targets => events.push(['read-complete', targets.map(item => item.id)]), + clearReadGuards: targets => events.push(['read-clear', targets.map(item => item.id)]), + scheduleGtdRefresh: () => events.push(['gtd-refresh']), + refreshCarddavStatus: async () => ({ connected: false }), + refreshMessages: async () => events.push(['messages-refresh']), + refreshGtdSections: async () => events.push(['gtd-refresh']), + notify: notification => events.push(['notify', notification]), + moveOptions: () => [{ id: 'Archive', label: 'Archive' }], + snoozeOptions: () => [{ id: '2026-08-01T09:00:00.000Z', label: 'Tomorrow morning' }], + ...depsPatch, + }; + const executors = createMailActionExecutors(deps); + const invoke = (executorId, targets, rest = {}) => executors[executorId]({ + context: { conversationsById: Object.fromEntries(targets.map(item => [item.id, item])) }, + targetIds: targets.map(item => item.id), + source: 'test', + ...rest, + }); + return { events, executors, invoke }; +} + +describe('mailCommandDefinitions', () => { + it('declares bulk-safe mutations and single-only response commands', () => { + const byId = new Map(mailCommandDefinitions.map(definition => [definition.id, definition])); + assert.equal(byId.get('mail.archive').targetMode, 'bulk_safe'); + assert.equal(byId.get('mail.toggleRead').targetMode, 'bulk_safe'); + assert.equal(byId.get('mail.reply').targetMode, 'single_conversation'); + assert.equal(byId.get('mail.replyAll').targetMode, 'single_conversation'); + assert.equal(byId.get('mail.forward').targetMode, 'single_conversation'); + assert.equal(byId.get('mail.move').executorId, 'mail.move'); + assert.deepEqual(byId.get('mail.archive').aliasKeys, ['commands.mail.archive.aliasDone']); + assert.equal(byId.get('mail.snooze').titleKey, 'contextMenu.snooze.label'); + assert.deepEqual(byId.get('mail.toggleRead').defaultKeys, { primary: 'u', secondary: ['m'] }); + assert.deepEqual(byId.get('mail.replyAll').defaultKeys, { primary: 'enter', secondary: ['a'] }); + }); + + it('offers Unsubscribe only for one usable active message', () => { + const command = mailCommandDefinitions.find(item => item.id === 'mail.unsubscribe'); + assert.equal(command.targetMode, 'single_conversation'); + assert.equal(command.isAvailable({ surface: 'conversation', activeMessage: { list_unsubscribe: '' } }), true); + assert.equal(command.isAvailable({ surface: 'list', activeMessage: { list_unsubscribe: '' } }), false); + assert.equal(command.isAvailable({ surface: 'conversation', activeMessage: { list_unsubscribe: null } }), false); + }); +}); + +describe('non-destructive mail executors', () => { + it('accepts the backend one-click unsubscribe result and patches the message', async () => { + const h = harness({ unsubscribeMessage: async () => ({ ok: true, type: 'one-click' }) }); + const message = target('a', { list_unsubscribe: '' }); + const result = await h.invoke('mail.unsubscribe', [message]); + assert.equal(result.status, 'success'); + assert.ok(h.events.find(event => event[0] === 'patch')); + assert.equal(h.events.some(event => event[0] === 'external'), false); + }); + + it('marks the complete resolved target set read with one optimistic patch', async () => { + const h = harness(); + const result = await h.invoke('mail.read', [target('a'), target('b')], { source: 'toolbar' }); + assert.equal(result.status, 'success'); + assert.deepEqual(result.succeededIds, ['account-1:', 'account-1:']); + assert.deepEqual(h.events.find(event => event[0] === 'patch'), [ + 'patch', result.succeededIds, { is_read: true }, + ]); + assert.deepEqual(h.events.find(event => event[0] === 'unread'), [ + 'unread', result.succeededIds, true, + ]); + assert.deepEqual(h.events.filter(event => event[0].startsWith('read-')), [ + ['read-pending', result.succeededIds], + ['read-complete', result.succeededIds], + ]); + }); + + it('restores read state and returns failed when the API rejects', async () => { + const h = harness({ bulkRead: async () => { throw new Error('read failed'); } }); + const result = await h.invoke('mail.read', [target('a')], { source: 'shortcut' }); + assert.equal(result.status, 'failed'); + assert.deepEqual(result.failed, [{ id: 'account-1:', error: 'read failed' }]); + assert.deepEqual(h.events.find(event => event[0] === 'restore'), [ + 'restore', + ['account-1:'], + ]); + assert.deepEqual(h.events.filter(event => event[0].startsWith('read-')), [ + ['read-pending', ['account-1:']], + ['read-clear', ['account-1:']], + ]); + }); + + it('clears read guards before marking a message unread', async () => { + const h = harness(); + await h.invoke('mail.unread', [target('a', { is_read: true })]); + assert.equal(h.events.findIndex(event => event[0] === 'read-clear') + < h.events.findIndex(event => event[0] === 'patch'), true); + assert.deepEqual(h.events.filter(event => event[0].startsWith('read-')), [ + ['read-clear', ['account-1:']], + ]); + }); + + it('reverses the optimistic unread delta when a read request fails', async () => { + const h = harness({ bulkRead: async () => { throw new Error('read failed'); } }); + await h.invoke('mail.read', [target('a')]); + assert.deepEqual(h.events.slice(-2), [ + ['restore', ['account-1:']], + ['unread', ['account-1:'], false], + ]); + }); + + it('reports only failed star targets and rolls those targets back', async () => { + const h = harness({ + markStarred: async id => { + if (id === 'b') throw new Error('flag failed'); + return { ok: true }; + }, + }); + const result = await h.invoke('mail.star', [target('a'), target('b')], { source: 'palette' }); + assert.equal(result.status, 'partial'); + assert.deepEqual(result.succeededIds, ['account-1:']); + assert.deepEqual(result.failed, [{ id: 'account-1:', error: 'flag failed' }]); + assert.deepEqual(h.events.at(-1), ['restore', ['account-1:']]); + }); + + it('opens one reply-all composer through the existing payload builder', async () => { + const h = harness(); + const result = await h.invoke('mail.replyAll', [target('a')], { source: 'context-menu' }); + assert.equal(result.status, 'success'); + assert.equal(h.events[0][0], 'compose'); + assert.equal(h.events[0][1].isReplyAll, true); + }); + + it('does not depend on a pane-cached body when replying or forwarding', async () => { + const h = harness(); + const current = target('a', { thread_id: 'thread-a' }); + await h.invoke('mail.reply', [current], { source: 'pane-toolbar' }); + await h.invoke('mail.forward', [current], { source: 'list-context-menu' }); + const payloads = h.events.filter(event => event[0] === 'compose').map(event => event[1]); + assert.equal(payloads[0].threadId, 'thread-a'); + assert.equal(payloads[0].quotedBody.includes('body'), true); + assert.equal(payloads[1].quotedBody.includes('body'), true); + }); + + it('returns partial GTD classification and refreshes sections once', async () => { + const h = harness({ + gtdClassify: async id => { + if (id === 'b') throw new Error('copy failed'); + return { ok: true }; + }, + }); + const result = await h.invoke('gtd.todo', [target('a'), target('b')], { source: 'toolbar' }); + assert.equal(result.status, 'partial'); + assert.deepEqual(result.failed, [{ id: 'account-1:', error: 'copy failed' }]); + assert.equal(h.events.filter(event => event[0] === 'gtd-refresh').length, 1); + }); + + it('requests contact input with exact frozen targets when CardDAV is connected', async () => { + const h = harness(); + const targets = [target('a'), target('b')]; + const context = { + conversationsById: Object.fromEntries(targets.map(item => [item.id, item])), + carddavStatus: { connected: true }, + carddavStatusLoaded: true, + }; + const result = await h.invoke('gtd.delegate', targets, { context, source: 'shortcut' }); + assert.deepEqual(result, { + status: 'needs_input', + continuation: { + commandId: 'gtd.delegate', kind: 'contact', targetIds: targets.map(item => item.id), + props: { targetCount: 2 }, + }, + }); + assert.equal(h.events.length, 0); + }); + + it('refreshes an unknown CardDAV status before choosing the delegation workflow', async () => { + const calls = []; + const h = harness({}, { + refreshCarddavStatus: async () => { + calls.push('status'); + return { connected: true }; + }, + }); + const current = target('a'); + const context = { + conversationsById: { [current.id]: current }, + carddavStatus: { connected: false }, + carddavStatusLoaded: false, + }; + const result = await h.invoke('gtd.delegate', [current], { context }); + assert.deepEqual(calls, ['status']); + assert.equal(result.status, 'needs_input'); + assert.equal(result.continuation.kind, 'contact'); + }); + + it('delegates immediately without a person when CardDAV is disconnected', async () => { + const calls = []; + const h = harness({ + gtd: { delegate: async (...args) => { + calls.push(args); + return { + status: 'success', successCount: 1, failureCount: 0, + results: [{ messageId: 'a', ok: true }], + }; + } }, + }); + const current = target('a'); + const context = { + conversationsById: { [current.id]: current }, + carddavStatus: { connected: false }, + carddavStatusLoaded: true, + }; + const result = await h.invoke('gtd.delegate', [current], { context }); + assert.deepEqual(calls, [[['a'], null]]); + assert.equal(result.status, 'success'); + }); + + it('resumes with a stable contact ID and sends only database message UUIDs', async () => { + const calls = []; + const h = harness({ + gtd: { delegate: async (...args) => { + calls.push(args); + return { + status: 'success', successCount: 2, failureCount: 0, + results: ['a', 'b'].map(messageId => ({ messageId, ok: true })), + }; + } }, + }); + const targets = [target('a'), target('b')]; + const context = { + conversationsById: Object.fromEntries(targets.map(item => [item.id, item])), + carddavStatus: { connected: true }, + carddavStatusLoaded: true, + }; + const result = await h.invoke('gtd.delegate', targets, { + context, source: 'continuation', input: { contactId: 'contact-1' }, + }); + assert.deepEqual(calls, [[['a', 'b'], 'contact-1']]); + assert.deepEqual(result.succeededIds, targets.map(item => item.id)); + }); + + it('patches successful delegation metadata into every cached message surface', async () => { + const delegation = { + contact_id: 'contact-1', display_name: 'Casey Rivera', + primary_email: 'casey@example.test', + }; + const h = harness({ + gtd: { delegate: async () => ({ + status: 'partial', successCount: 1, failureCount: 1, + results: [ + { messageId: 'a', ok: true, delegation }, + { messageId: 'b', ok: false, error: { code: 'operation_failed' } }, + ], + }) }, + }); + const targets = [target('a'), target('b')]; + const context = { + conversationsById: Object.fromEntries(targets.map(item => [item.id, item])), + carddavStatus: { connected: true }, carddavStatusLoaded: true, + }; + await h.invoke('gtd.delegate', targets, { + context, input: { contactId: 'contact-1' }, source: 'continuation', + }); + assert.deepEqual(h.events.filter(event => event[0] === 'patch'), [ + ['patch', [targets[0].id], { delegation }], + ]); + }); +}); + +function removalHarness(apiPatch = {}) { + const events = []; + const scheduled = []; + const timers = { + setTimeout(fn, ms) { + const item = { fn, ms, cleared: false }; + scheduled.push(item); + return item; + }, + clearTimeout(item) { item.cleared = true; }, + }; + const api = { + bulkArchive: async ids => ({ archived: ids, noArchiveFolder: [] }), + bulkDelete: async ids => ({ deleted: ids }), + bulkMove: async ids => ({ moved: ids }), + snoozeMessage: async id => ({ ok: true, id }), + markSpam: async id => ({ ok: true, id }), + markHam: async id => ({ ok: true, id }), + ...apiPatch, + }; + const deps = { + api, + accounts: () => [], + openCompose() {}, + patchMessages() {}, + adjustUnread: (targets, read) => events.push(['unread', targets.map(item => item.id), read]), + removeMessages: targets => events.push(['remove', targets.map(item => item.id)]), + restoreMessages: targets => events.push(['restore', targets.map(item => item.id)]), + guardPending: ids => events.push(['guard-pending', ids]), + guardCompleted: ids => events.push(['guard-complete', ids]), + clearGuards: ids => events.push(['guard-clear', ids]), + recordRecentFolder: (accountId, folder) => events.push(['recent', accountId, folder]), + scheduleGtdRefresh() {}, + notify: notification => events.push(['notify', notification]), + moveOptions: () => [{ id: 'Archive', label: 'Archive' }], + snoozeOptions: () => [{ id: '2026-08-01T09:00:00.000Z', label: 'Tomorrow morning' }], + registerPendingRemoval: operation => { + events.push(['register-pending-removal', operation]); + return () => events.push(['unregister-pending-removal']); + }, + keepaliveDelete: ids => events.push(['keepalive-delete', ids]), + timers, + }; + const executors = createMailActionExecutors(deps); + const invoke = (executorId, targets, rest = {}) => executors[executorId]({ + context: { conversationsById: Object.fromEntries(targets.map(item => [item.id, item])) }, + targetIds: targets.map(item => item.id), + source: 'test', + ...rest, + }); + return { events, scheduled, executors, invoke }; +} + +describe('removal and continuation executors', () => { + it('requests Move input with the exact frozen target IDs', async () => { + const h = removalHarness(); + const targets = [target('a'), target('b')]; + const result = await h.invoke('mail.move', targets, { source: 'shortcut' }); + assert.deepEqual(result, { + status: 'needs_input', + continuation: { + commandId: 'mail.move', + kind: 'move', + targetIds: targets.map(item => item.id), + props: { + accountId: 'account-1', + targetCount: 2, + titleKey: 'contextMenu.moveToFolder', + inputKey: 'folder', + items: [{ id: 'Archive', label: 'Archive' }], + }, + }, + }); + assert.equal(h.events.length, 0); + }); + + it('requests Snooze input without mutating targets', async () => { + const h = removalHarness(); + const result = await h.invoke('mail.snooze', [target('a')], { source: 'palette' }); + assert.equal(result.status, 'needs_input'); + assert.equal(result.continuation.kind, 'snooze'); + assert.deepEqual(result.continuation.targetIds, ['account-1:']); + assert.equal(result.continuation.props.inputKey, 'until'); + assert.equal(result.continuation.props.items[0].label, 'Tomorrow morning'); + assert.equal(h.events.length, 0); + }); + + it('undoes Archive before the delayed API call', async () => { + const h = removalHarness(); + const result = await h.invoke('mail.archive', [target('a')], { source: 'hover' }); + assert.equal(result.status, 'success'); + assert.equal(h.scheduled[0].ms, 4500); + const notification = h.events.find(event => event[0] === 'notify')[1]; + notification.onUndo(); + assert.equal(h.scheduled[0].cleared, true); + assert.ok(h.events.some(event => event[0] === 'restore')); + assert.ok(h.events.some(event => event[0] === 'guard-clear')); + }); + + it('restores only failed Move targets and records the destination once', async () => { + const h = removalHarness({ bulkMove: async () => ({ moved: ['a'] }) }); + const result = await h.invoke('mail.move', [target('a'), target('b')], { + input: { folder: 'Archive' }, + source: 'context-menu', + }); + assert.equal(result.status, 'success'); + await h.scheduled[0].fn(); + assert.deepEqual(h.events.find(event => event[0] === 'restore'), ['restore', ['account-1:']]); + assert.equal(h.events.filter(event => event[0] === 'recent').length, 1); + }); + + it('reports per-target Spam failure after the undo window', async () => { + const h = removalHarness({ + markSpam: async id => { + if (id === 'b') throw new Error('spam failed'); + return { ok: true }; + }, + }); + await h.invoke('mail.spam', [target('a'), target('b')], { source: 'toolbar' }); + await h.scheduled[0].fn(); + const errorNotice = h.events.filter(event => event[0] === 'notify').at(-1)[1]; + assert.equal(errorNotice.failedCount, 1); + assert.equal(errorNotice.succeededCount, 1); + assert.deepEqual(h.events.find(event => event[0] === 'restore'), ['restore', ['account-1:']]); + }); + + it('restores every target and reports exact counts when a delayed request throws', async () => { + const h = removalHarness({ bulkArchive: async () => { throw new Error('archive failed'); } }); + await h.invoke('mail.archive', [target('a'), target('b')]); + await h.scheduled[0].fn(); + assert.deepEqual(h.events.find(event => event[0] === 'restore'), [ + 'restore', + ['account-1:', 'account-1:'], + ]); + const errorNotice = h.events.filter(event => event[0] === 'notify').at(-1)[1]; + assert.equal(errorNotice.succeededCount, 0); + assert.equal(errorNotice.failedCount, 2); + }); + + it('registers Trash so lifecycle cleanup can flush it normally or with keepalive', async () => { + const h = removalHarness(); + await h.invoke('mail.trash', [target('a')]); + const operation = h.events.find(event => event[0] === 'register-pending-removal')[1]; + await operation.run(); + operation.unload(); + assert.deepEqual(h.events.find(event => event[0] === 'keepalive-delete'), [ + 'keepalive-delete', + ['a'], + ]); + }); +}); + +it('constructs every executor with the documented dependency adapter', () => { + const required = [ + 'api', 'accounts', 'openCompose', 'patchMessages', 'removeMessages', + 'restoreMessages', 'adjustUnread', 'guardPending', 'guardCompleted', + 'clearGuards', 'recordRecentFolder', 'scheduleGtdRefresh', 'notify', 'timers', + 'moveOptions', 'snoozeOptions', + 'refreshCarddavStatus', 'refreshMessages', 'refreshGtdSections', + 'guardReadPending', 'guardReadCompleted', 'clearReadGuards', + 'registerPendingRemoval', 'keepaliveDelete', + ]; + const deps = Object.fromEntries(required.map(key => [key, key === 'api' ? {} : () => {}])); + deps.timers = { setTimeout, clearTimeout }; + assert.doesNotThrow(() => createMailActionExecutors(deps)); +}); diff --git a/frontend/src/commands/outcomeNotification.js b/frontend/src/commands/outcomeNotification.js new file mode 100644 index 00000000..58788617 --- /dev/null +++ b/frontend/src/commands/outcomeNotification.js @@ -0,0 +1,35 @@ +const errorText = error => error instanceof Error ? error.message : error == null ? '' : String(error); + +export function commandOutcomeNotification(outcome, t) { + if (outcome.status === 'partial' && outcome.value?.messageKey?.startsWith('gtd.delegate.')) { + const succeeded = outcome.succeededIds?.length ?? outcome.value.messageParams?.succeeded ?? 0; + const failed = (outcome.failed?.length ?? outcome.value.messageParams?.failed ?? 0) + + (outcome.missingTargetIds?.length || 0); + return { + title: t('gtd.delegate.partial', { count: succeeded + failed, succeeded, failed }), + }; + } + if (outcome.value?.messageKey) { + return { + ...(outcome.status === 'failed' ? { type: 'error' } : {}), + title: t(outcome.value.messageKey, outcome.value.messageParams), + }; + } + if (outcome.status === 'failed') { + return { + type: 'error', + title: t('commandPalette.outcome.failedTitle'), + body: errorText(outcome.error) || errorText(outcome.failed?.[0]?.error), + }; + } + if (outcome.status === 'partial') { + return { + title: t('commandPalette.outcome.partialTitle'), + body: t('commandPalette.outcome.partialBody', { + succeeded: outcome.succeededIds?.length || 0, + failed: (outcome.failed?.length || 0) + (outcome.missingTargetIds?.length || 0), + }), + }; + } + return null; +} diff --git a/frontend/src/commands/outcomeNotification.test.js b/frontend/src/commands/outcomeNotification.test.js new file mode 100644 index 00000000..8a6c4b52 --- /dev/null +++ b/frontend/src/commands/outcomeNotification.test.js @@ -0,0 +1,55 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { commandOutcomeNotification } from './outcomeNotification.js'; + +const t = (key, values) => values ? `${key}:${JSON.stringify(values)}` : key; + +test('formats executor-returned failures that do not carry a top-level error', () => { + assert.deepEqual(commandOutcomeNotification({ + status: 'failed', + failed: [{ id: 'acct:
', error: 'read failed' }], + }, t), { + type: 'error', + title: 'commandPalette.outcome.failedTitle', + body: 'read failed', + }); +}); + +test('formats partial counts including targets that disappeared before execution', () => { + assert.deepEqual(commandOutcomeNotification({ + status: 'partial', + succeededIds: ['acct:'], + failed: [{ id: 'acct:', error: 'move failed' }], + missingTargetIds: ['acct:'], + }, t), { + title: 'commandPalette.outcome.partialTitle', + body: 'commandPalette.outcome.partialBody:{"succeeded":1,"failed":2}', + }); +}); + +test('uses executor-provided localized success and partial messages', () => { + assert.deepEqual(commandOutcomeNotification({ + status: 'success', + value: { messageKey: 'gtd.delegate.success', messageParams: { count: 2 } }, + }, t), { + title: 'gtd.delegate.success:{"count":2}', + }); + assert.deepEqual(commandOutcomeNotification({ + status: 'partial', + value: { messageKey: 'gtd.delegate.partial', messageParams: { succeeded: 1, failed: 1 } }, + }, t), { + title: 'gtd.delegate.partial:{"count":2,"succeeded":1,"failed":1}', + }); +}); + +test('folds vanished frozen targets into the localized delegation partial outcome', () => { + assert.deepEqual(commandOutcomeNotification({ + status: 'partial', + succeededIds: ['acct:'], + failed: [], + missingTargetIds: ['acct:'], + value: { messageKey: 'gtd.delegate.success', messageParams: { count: 1 } }, + }, t), { + title: 'gtd.delegate.partial:{"count":2,"succeeded":1,"failed":1}', + }); +}); diff --git a/frontend/src/commands/paletteFocus.js b/frontend/src/commands/paletteFocus.js new file mode 100644 index 00000000..0dc36603 --- /dev/null +++ b/frontend/src/commands/paletteFocus.js @@ -0,0 +1,14 @@ +export function isRestorableFocus(element) { + return Boolean( + element?.isConnected + && !element.disabled + && element.tabIndex !== -1 + && element.getClientRects?.().length, + ); +} + +export function nextFocusIndex(count, currentIndex, backwards) { + if (count <= 0) return -1; + if (backwards) return currentIndex <= 0 ? count - 1 : currentIndex - 1; + return currentIndex >= count - 1 ? 0 : currentIndex + 1; +} diff --git a/frontend/src/commands/paletteFocus.test.js b/frontend/src/commands/paletteFocus.test.js new file mode 100644 index 00000000..cac24155 --- /dev/null +++ b/frontend/src/commands/paletteFocus.test.js @@ -0,0 +1,21 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { isRestorableFocus, nextFocusIndex } from './paletteFocus.js'; + +describe('palette focus helpers', () => { + it('restores only connected, enabled, visible, focusable elements', () => { + const base = { isConnected: true, disabled: false, tabIndex: 0, getClientRects: () => [{ width: 1 }] }; + assert.equal(isRestorableFocus(base), true); + assert.equal(isRestorableFocus({ ...base, isConnected: false }), false); + assert.equal(isRestorableFocus({ ...base, disabled: true }), false); + assert.equal(isRestorableFocus({ ...base, tabIndex: -1 }), false); + assert.equal(isRestorableFocus({ ...base, getClientRects: () => [] }), false); + }); + + it('wraps Tab and Shift+Tab indices inside the dialog', () => { + assert.equal(nextFocusIndex(3, 0, false), 1); + assert.equal(nextFocusIndex(3, 2, false), 0); + assert.equal(nextFocusIndex(3, 0, true), 2); + assert.equal(nextFocusIndex(0, -1, false), -1); + }); +}); diff --git a/frontend/src/commands/paletteShortcut.js b/frontend/src/commands/paletteShortcut.js new file mode 100644 index 00000000..893dc943 --- /dev/null +++ b/frontend/src/commands/paletteShortcut.js @@ -0,0 +1,12 @@ +export function commandPaletteShortcut(event, previousEditorPress, now = Date.now()) { + if (event.isComposing || event.keyCode === 229) { + return { handled: false, toggle: false, nextEditorPress: previousEditorPress }; + } + const chord = (event.metaKey || event.ctrlKey) && !event.altKey && event.key.toLowerCase() === 'k'; + if (!chord || event.isMobile) return { handled: false, toggle: false, nextEditorPress: previousEditorPress }; + if (!event.target?.isContentEditable) return { handled: true, toggle: true, nextEditorPress: null }; + const second = previousEditorPress?.target === event.target && now - previousEditorPress.at <= 1500; + return second + ? { handled: true, toggle: true, nextEditorPress: null } + : { handled: true, toggle: false, nextEditorPress: { target: event.target, at: now } }; +} diff --git a/frontend/src/commands/paletteShortcut.test.js b/frontend/src/commands/paletteShortcut.test.js new file mode 100644 index 00000000..6b33638e --- /dev/null +++ b/frontend/src/commands/paletteShortcut.test.js @@ -0,0 +1,36 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { commandPaletteShortcut } from './paletteShortcut.js'; + +describe('commandPaletteShortcut', () => { + it('ignores unrelated and mobile events', () => { + assert.equal(commandPaletteShortcut({ key: 'x', metaKey: true }, null, 1).handled, false); + assert.equal(commandPaletteShortcut({ key: 'k', metaKey: true, isMobile: true }, null, 1).handled, false); + }); + + it('ignores Cmd/Ctrl+K while an IME is composing', () => { + assert.equal(commandPaletteShortcut({ key: 'k', metaKey: true, isComposing: true }, null, 1).handled, false); + assert.equal(commandPaletteShortcut({ key: 'k', ctrlKey: true, keyCode: 229 }, null, 1).handled, false); + }); + + it('toggles ordinary Cmd/Ctrl+K immediately', () => { + assert.deepEqual(commandPaletteShortcut({ key: 'k', metaKey: true, target: {} }, null, 1), { + handled: true, toggle: true, nextEditorPress: null, + }); + }); + + it('preserves first rich-editor Insert Link and opens on the next press', () => { + const editor = { isContentEditable: true }; + const first = commandPaletteShortcut({ key: 'k', ctrlKey: true, target: editor }, null, 1000); + assert.equal(first.toggle, false); + const second = commandPaletteShortcut({ key: 'k', ctrlKey: true, target: editor }, first.nextEditorPress, 2000); + assert.equal(second.toggle, true); + assert.equal(second.nextEditorPress, null); + }); + + it('expires the editor second-press window after 1500 ms', () => { + const editor = { isContentEditable: true }; + const old = { target: editor, at: 1000 }; + assert.equal(commandPaletteShortcut({ key: 'k', metaKey: true, target: editor }, old, 2501).toggle, false); + }); +}); diff --git a/frontend/src/commands/paletteState.js b/frontend/src/commands/paletteState.js new file mode 100644 index 00000000..0c605c38 --- /dev/null +++ b/frontend/src/commands/paletteState.js @@ -0,0 +1,29 @@ +export function createPaletteState() { + return { query: '', activeIndex: 0, resultCount: 0, continuation: null }; +} + +export function paletteKeyIntent(event) { + if (event.isComposing || event.nativeEvent?.isComposing || event.keyCode === 229 || event.nativeEvent?.keyCode === 229) { + return null; + } + const key = event.key.toLowerCase(); + if (event.key === 'ArrowDown' || (event.ctrlKey && (key === 'n' || key === 'j'))) return 'next'; + if (event.key === 'ArrowUp' || (event.ctrlKey && (key === 'p' || key === 'k'))) return 'previous'; + if (event.key === 'Enter') return 'execute'; + if (event.key === 'Escape') return 'back'; + return null; +} + +export function reducePaletteState(state, event) { + switch (event.type) { + case 'open': return { ...createPaletteState(), resultCount: state.resultCount }; + case 'query': return { ...state, query: event.query, activeIndex: 0 }; + case 'results': return { ...state, resultCount: event.count, activeIndex: Math.min(state.activeIndex, Math.max(0, event.count - 1)) }; + case 'move': return { ...state, activeIndex: Math.max(0, Math.min(state.resultCount - 1, state.activeIndex + event.delta)) }; + case 'continuation': return { ...state, continuation: event.value, query: '', activeIndex: 0 }; + case 'back': return state.continuation + ? { ...state, continuation: null, query: '', activeIndex: 0 } + : state; + default: return state; + } +} diff --git a/frontend/src/commands/paletteState.test.js b/frontend/src/commands/paletteState.test.js new file mode 100644 index 00000000..4a3702c9 --- /dev/null +++ b/frontend/src/commands/paletteState.test.js @@ -0,0 +1,49 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createPaletteState, paletteKeyIntent, reducePaletteState } from './paletteState.js'; + +describe('palette state', () => { + it('maps every approved local navigation key', () => { + assert.equal(paletteKeyIntent({ key: 'ArrowDown', ctrlKey: false }), 'next'); + assert.equal(paletteKeyIntent({ key: 'n', ctrlKey: true }), 'next'); + assert.equal(paletteKeyIntent({ key: 'j', ctrlKey: true }), 'next'); + assert.equal(paletteKeyIntent({ key: 'ArrowUp', ctrlKey: false }), 'previous'); + assert.equal(paletteKeyIntent({ key: 'p', ctrlKey: true }), 'previous'); + assert.equal(paletteKeyIntent({ key: 'k', ctrlKey: true }), 'previous'); + assert.equal(paletteKeyIntent({ key: 'Enter', ctrlKey: false }), 'execute'); + assert.equal(paletteKeyIntent({ key: 'Escape', ctrlKey: false }), 'back'); + }); + + it('ignores navigation and execution keys while an IME is composing', () => { + assert.equal(paletteKeyIntent({ key: 'Enter', isComposing: true }), null); + assert.equal(paletteKeyIntent({ key: 'ArrowDown', nativeEvent: { isComposing: true } }), null); + assert.equal(paletteKeyIntent({ key: 'Enter', keyCode: 229 }), null); + }); + + it('resets highlight on query/results and clamps movement', () => { + let state = reducePaletteState(createPaletteState(), { type: 'results', count: 2 }); + state = reducePaletteState(state, { type: 'move', delta: 1 }); + state = reducePaletteState(state, { type: 'move', delta: 1 }); + assert.equal(state.activeIndex, 1); + state = reducePaletteState(state, { type: 'query', query: 'arch' }); + assert.equal(state.activeIndex, 0); + }); + + it('opens with the current result count so keyboard movement works immediately', () => { + let state = reducePaletteState(createPaletteState(), { type: 'results', count: 38 }); + state = reducePaletteState(state, { type: 'open' }); + state = reducePaletteState(state, { type: 'move', delta: 1 }); + assert.equal(state.resultCount, 38); + assert.equal(state.activeIndex, 1); + }); + + it('backs out one continuation without latching a close request across reopen', () => { + let state = reducePaletteState(createPaletteState(), { type: 'continuation', value: { kind: 'move' } }); + state = reducePaletteState(state, { type: 'back' }); + assert.equal(state.continuation, null); + state = reducePaletteState(state, { type: 'back' }); + assert.equal('closeRequested' in state, false); + state = reducePaletteState(state, { type: 'open' }); + assert.equal('closeRequested' in state, false); + }); +}); diff --git a/frontend/src/commands/pendingOperationManager.js b/frontend/src/commands/pendingOperationManager.js new file mode 100644 index 00000000..05a6de44 --- /dev/null +++ b/frontend/src/commands/pendingOperationManager.js @@ -0,0 +1,20 @@ +export function createPendingOperationManager(timers) { + const operations = new Set(); + + const register = operation => { + operations.add(operation); + return () => operations.delete(operation); + }; + + const flush = async (mode = 'normal') => { + const pending = [...operations]; + operations.clear(); + await Promise.allSettled(pending.map(operation => { + timers.clearTimeout(operation.timer); + if (mode === 'unload' && operation.unload) return operation.unload(); + return operation.run(); + })); + }; + + return Object.freeze({ register, flush }); +} diff --git a/frontend/src/commands/pendingOperationManager.test.js b/frontend/src/commands/pendingOperationManager.test.js new file mode 100644 index 00000000..3653aef8 --- /dev/null +++ b/frontend/src/commands/pendingOperationManager.test.js @@ -0,0 +1,34 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createPendingOperationManager } from './pendingOperationManager.js'; + +test('flushes registered operations once using normal or unload behavior', async () => { + const events = []; + const timers = { clearTimeout: timer => events.push(['clear', timer]) }; + const manager = createPendingOperationManager(timers); + manager.register({ + timer: 'normal-timer', + run: async () => events.push(['run']), + unload: () => events.push(['unload-unused']), + }); + await manager.flush('normal'); + assert.deepEqual(events, [['clear', 'normal-timer'], ['run']]); + + manager.register({ + timer: 'unload-timer', + run: async () => events.push(['run-unused']), + unload: () => events.push(['unload']), + }); + await manager.flush('unload'); + await manager.flush('normal'); + assert.deepEqual(events.slice(2), [['clear', 'unload-timer'], ['unload']]); +}); + +test('unregister prevents a pending operation from being flushed', async () => { + let calls = 0; + const manager = createPendingOperationManager({ clearTimeout() {} }); + const unregister = manager.register({ timer: 1, run: () => { calls += 1; } }); + unregister(); + await manager.flush('normal'); + assert.equal(calls, 0); +}); diff --git a/frontend/src/commands/registry.js b/frontend/src/commands/registry.js new file mode 100644 index 00000000..0da4eea9 --- /dev/null +++ b/frontend/src/commands/registry.js @@ -0,0 +1,34 @@ +import { validateCommandDefinition } from './contracts.js'; +import { hasTargets } from './targets.js'; +import { rankCommands } from './search.js'; +import { effectiveCommandKeys } from './shortcuts.js'; + +export function createCommandRegistry(definitions) { + const ordered = []; + const byId = new Map(); + for (const input of definitions) { + const command = validateCommandDefinition(input); + if (byId.has(command.id)) throw new TypeError(`duplicate command id "${command.id}"`); + ordered.push(command); + byId.set(command.id, command); + } + Object.freeze(ordered); + + const available = context => ordered.filter(command => command.isAvailable(context) && hasTargets(command, context)); + const decorate = (results, context) => results.map(result => ({ + ...result, + bindings: effectiveCommandKeys(result.command, context).bindings, + })); + + return Object.freeze({ + get(id) { + return byId.get(id) || null; + }, + list(context) { + return decorate(rankCommands(available(context), '', context), context); + }, + search(query, context) { + return decorate(rankCommands(available(context), query, context), context); + }, + }); +} diff --git a/frontend/src/commands/registry.test.js b/frontend/src/commands/registry.test.js new file mode 100644 index 00000000..c93bb3c7 --- /dev/null +++ b/frontend/src/commands/registry.test.js @@ -0,0 +1,48 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createCommandContext } from './contracts.js'; +import { createCommandRegistry } from './registry.js'; + +const raw = (id, overrides = {}) => ({ + id, titleKey: `${id}.title`, aliasKeys: [], icon: 'test', group: 'test', + defaultKeys: { primary: null, secondary: [] }, rank: { base: 0 }, + isAvailable: () => true, targetMode: 'global', executorId: id, ...overrides, +}); +const context = createCommandContext({ + surface: 'list', activeConversationId: null, selectedConversationIds: [], conversations: [], + accountId: null, folder: null, draft: null, gtdAvailable: false, cardDavConnected: false, + modal: null, editing: false, platform: 'mac', shortcutOverrides: {}, translate: key => key, +}); + +describe('createCommandRegistry', () => { + it('rejects duplicate IDs before exposing a partial registry', () => { + assert.throws(() => createCommandRegistry([raw('test.one'), raw('test.one')]), /duplicate command id "test.one"/); + }); + + it('returns definitions by ID without exposing mutation', () => { + const registry = createCommandRegistry([raw('test.one')]); + assert.equal(registry.get('test.one').executorId, 'test.one'); + assert.equal(registry.get('test.missing'), null); + assert.ok(Object.isFrozen(registry.get('test.one'))); + }); + + it('omits explicit and target-mode-unavailable commands', () => { + const registry = createCommandRegistry([ + raw('global.visible'), + raw('global.hidden', { isAvailable: () => false }), + raw('mail.archive', { targetMode: 'bulk_safe' }), + ]); + assert.deepEqual(registry.list(context).map(entry => entry.command.id), ['global.visible']); + }); + + it('returns effective bindings and localized alias search metadata', () => { + const registry = createCommandRegistry([raw('mail.archive', { + titleKey: 'archive', aliasKeys: ['done'], defaultKeys: { primary: 'e', secondary: [] }, + })]); + const localized = { ...context, translate: key => ({ archive: 'Archive', done: 'Done' })[key] || key }; + const [result] = registry.search('done', localized); + assert.equal(result.title, 'Archive'); + assert.equal(result.matchedAlias, 'Done'); + assert.deepEqual(result.bindings, [{ key: 'e', kind: 'primary' }]); + }); +}); diff --git a/frontend/src/commands/search.js b/frontend/src/commands/search.js new file mode 100644 index 00000000..f754123e --- /dev/null +++ b/frontend/src/commands/search.js @@ -0,0 +1,60 @@ +function normalize(value) { + return String(value || '').normalize('NFKD').replace(/\p{Diacritic}/gu, '').toLowerCase().trim(); +} + +function editDistance(a, b) { + const rows = Array.from({ length: a.length + 1 }, (_, i) => [i]); + for (let j = 1; j <= b.length; j += 1) rows[0][j] = j; + for (let i = 1; i <= a.length; i += 1) { + for (let j = 1; j <= b.length; j += 1) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + rows[i][j] = Math.min(rows[i - 1][j] + 1, rows[i][j - 1] + 1, rows[i - 1][j - 1] + cost); + if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) { + rows[i][j] = Math.min(rows[i][j], rows[i - 2][j - 2] + 1); + } + } + } + return rows[a.length][b.length]; +} + +function fuzzyScore(candidate, query) { + const text = normalize(candidate); + const needle = normalize(query); + if (!needle) return 0; + if (text === needle) return 1000; + if (text.startsWith(needle)) return 900 - (text.length - needle.length); + if (text.includes(needle)) return 800 - text.indexOf(needle); + const distance = editDistance(text, needle); + const limit = Math.max(1, Math.floor(Math.max(text.length, needle.length) * 0.34)); + return distance <= limit ? 600 - (distance * 40) - Math.abs(text.length - needle.length) : null; +} + +export function rankCommands(commands, query, context) { + return commands.map((command, index) => { + const title = context.translate(command.titleKey, command.params); + const aliases = command.aliasKeys.map(key => ({ key, value: context.translate(key, command.params) })); + const candidates = [{ key: null, value: title }, ...aliases] + .map(item => ({ ...item, fuzzy: fuzzyScore(item.value, query) })) + .filter(item => item.fuzzy != null) + .sort((a, b) => b.fuzzy - a.fuzzy); + if (query.trim() && !candidates.length) return null; + const match = candidates[0] || { key: null, value: title, fuzzy: 0 }; + const boost = command.rank.boost ? command.rank.boost(context) : 0; + return { + command, + title, + matchedAlias: match.key ? match.value : null, + matchedAliasKey: match.key, + score: match.fuzzy + command.rank.base + boost, + index, + }; + }).filter(Boolean) + .sort((a, b) => b.score - a.score || a.index - b.index) + .map(result => ({ + command: result.command, + title: result.title, + matchedAlias: result.matchedAlias, + matchedAliasKey: result.matchedAliasKey, + score: result.score, + })); +} diff --git a/frontend/src/commands/search.test.js b/frontend/src/commands/search.test.js new file mode 100644 index 00000000..65573612 --- /dev/null +++ b/frontend/src/commands/search.test.js @@ -0,0 +1,55 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createCommandContext, validateCommandDefinition } from './contracts.js'; +import { rankCommands } from './search.js'; + +const make = (id, titleKey, aliasKeys, base, boost = () => 0) => validateCommandDefinition({ + id, titleKey, aliasKeys, icon: 'test', group: 'test', + defaultKeys: { primary: null, secondary: [] }, rank: { base, boost }, + isAvailable: () => true, targetMode: 'global', executorId: id, +}); +const strings = { + archive: 'Archive', done: 'Done', compose: 'Compose', search: 'Search', settings: 'Settings', +}; +const context = createCommandContext({ + surface: 'list', activeConversationId: null, selectedConversationIds: [], conversations: [], + accountId: null, folder: null, draft: null, gtdAvailable: false, cardDavConnected: false, + modal: null, editing: false, platform: 'mac', shortcutOverrides: {}, + translate: key => strings[key] || key, +}); +const archive = make('mail.archive', 'archive', ['done'], 50, ctx => ctx.selectedConversationIds.length ? 30 : 0); +const compose = make('compose.new', 'compose', [], 60); +const settings = make('settings.open', 'settings', [], 10); + +describe('rankCommands', () => { + it('finds case-insensitive title matches', () => { + assert.equal(rankCommands([archive, compose], 'ARCHIVE', context)[0].command.id, 'mail.archive'); + }); + + it('keeps the Mailflow title and discloses the matching alias', () => { + const [result] = rankCommands([archive, compose], 'done', context); + assert.equal(result.title, 'Archive'); + assert.equal(result.matchedAlias, 'Done'); + assert.equal(result.matchedAliasKey, 'done'); + }); + + it('tolerates a close transposition misspelling', () => { + assert.equal(rankCommands([archive, compose], 'arhcive', context)[0].command.id, 'mail.archive'); + }); + + it('uses stable base priority for an empty query', () => { + assert.deepEqual(rankCommands([settings, compose, archive], '', context).map(x => x.command.id), [ + 'compose.new', 'mail.archive', 'settings.open', + ]); + }); + + it('adds contextual boost and preserves definition order for exact ties', () => { + const selected = { ...context, selectedConversationIds: ['acct:'] }; + const equalA = make('navigation.a', 'search', [], 1); + const equalB = make('navigation.b', 'search', [], 1); + assert.equal(rankCommands([compose, archive], '', selected)[0].command.id, 'mail.archive'); + assert.deepEqual(rankCommands([equalA, equalB], 'search', context).map(x => x.command.id), [ + 'navigation.a', 'navigation.b', + ]); + }); +}); diff --git a/frontend/src/commands/selection.js b/frontend/src/commands/selection.js new file mode 100644 index 00000000..ee4ca9d9 --- /dev/null +++ b/frontend/src/commands/selection.js @@ -0,0 +1,5 @@ +export function nextSelection(current, nextOrUpdater) { + const currentCopy = new Set(current || []); + const next = typeof nextOrUpdater === 'function' ? nextOrUpdater(currentCopy) : nextOrUpdater; + return new Set(next || []); +} diff --git a/frontend/src/commands/selection.test.js b/frontend/src/commands/selection.test.js new file mode 100644 index 00000000..0a2275b3 --- /dev/null +++ b/frontend/src/commands/selection.test.js @@ -0,0 +1,20 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { nextSelection } from './selection.js'; + +describe('nextSelection', () => { + it('clones direct Sets and deduplicates iterables', () => { + const input = new Set(['row-a']); + const output = nextSelection(input, input); + assert.deepEqual([...output], ['row-a']); + assert.notStrictEqual(output, input); + assert.deepEqual([...nextSelection(input, ['row-a', 'row-a', 'row-b'])], ['row-a', 'row-b']); + }); + + it('runs updater functions against a defensive Set copy', () => { + const input = new Set(['row-a']); + const output = nextSelection(input, current => current.add('row-b')); + assert.deepEqual([...input], ['row-a']); + assert.deepEqual([...output], ['row-a', 'row-b']); + }); +}); diff --git a/frontend/src/commands/shortcutCommands.js b/frontend/src/commands/shortcutCommands.js new file mode 100644 index 00000000..5a43f768 --- /dev/null +++ b/frontend/src/commands/shortcutCommands.js @@ -0,0 +1,101 @@ +const keys = (primary = null, secondary = []) => ({ primary, secondary }); +const modKey = (mac, key) => ({ mac, windows: `ctrl+${key}`, linux: `ctrl+${key}`, default: `ctrl+${key}` }); +const ICONS = { + 'app.undo': 'clock', + 'navigation.nextConversation': 'mail-open', 'navigation.previousConversation': 'mail-open', + 'navigation.nextThreadMessage': 'mail-open', 'navigation.previousThreadMessage': 'mail-open', + 'navigation.scrollDown': 'eye', 'navigation.scrollUp': 'eye', + 'navigation.openConversation': 'mail-open', 'navigation.inbox': 'inbox', + 'navigation.sent': 'folder', 'navigation.drafts': 'folder', 'navigation.contacts': 'contacts', + 'selection.toggle': 'check-square', 'selection.extendNext': 'check-square', + 'selection.extendPrevious': 'check-square', 'selection.focusedAndOlder': 'check-square', + 'selection.all': 'check-square', 'selection.clear': 'check-square', + 'navigation.back': 'mail-open', + 'help.shortcuts': 'settings', 'layout.toggleRightSidebar': 'appearance', 'mail.print': 'mail', + 'compose.send': 'compose', +}; +const command = (id, titleKey, targetMode, defaultKeys, executorId, rank = 40, isAvailable = () => true) => ({ + id, titleKey, aliasKeys: [], icon: ICONS[id] || 'settings', group: id.split('.')[0], targetMode, defaultKeys, + executorId, rank: { base: rank }, isAvailable, +}); + +export const shortcutCommandDefinitions = [ + command('app.undo', 'commands.shortcuts.undo', 'global', keys('z'), 'app.undo', 90, context => context.undoAvailable), + command('navigation.nextConversation', 'shortcuts.actions.nextMessage.label', 'global', keys('j'), 'navigation.nextConversation', 70, context => ['list', 'conversation'].includes(context.surface)), + command('navigation.previousConversation', 'shortcuts.actions.prevMessage.label', 'global', keys('k'), 'navigation.previousConversation', 70, context => ['list', 'conversation'].includes(context.surface)), + command('navigation.nextThreadMessage', 'commands.shortcuts.nextThreadMessage', 'single_conversation', keys('n'), 'navigation.nextThreadMessage', 70, context => context.surface === 'conversation'), + command('navigation.previousThreadMessage', 'commands.shortcuts.previousThreadMessage', 'single_conversation', keys('p'), 'navigation.previousThreadMessage', 70, context => context.surface === 'conversation'), + command('navigation.scrollDown', 'commands.shortcuts.scrollDown', 'global', keys('space'), 'navigation.scrollDown', 30, context => context.surface === 'conversation'), + command('navigation.scrollUp', 'commands.shortcuts.scrollUp', 'global', keys('shift+space'), 'navigation.scrollUp', 30, context => context.surface === 'conversation'), + command('navigation.openConversation', 'commands.shortcuts.openConversation', 'global', keys('enter', ['o']), 'navigation.openConversation', 100, context => context.surface === 'list' && context.visibleConversationIds.length > 0), + command('selection.toggle', 'shortcuts.actions.selectMessage.label', 'global', keys('x'), 'selection.toggle', 60, context => context.surface === 'list' && context.visibleConversationIds.length > 0), + command('selection.extendNext', 'commands.shortcuts.extendNext', 'global', keys('shift+j'), 'selection.extendNext', 60, context => context.surface === 'list'), + command('selection.extendPrevious', 'commands.shortcuts.extendPrevious', 'global', keys('shift+k'), 'selection.extendPrevious', 60, context => context.surface === 'list'), + command('selection.focusedAndOlder', 'commands.shortcuts.selectOlder', 'global', keys(modKey('meta+a', 'a')), 'selection.focusedAndOlder', 80, context => context.surface === 'list' && context.visibleConversationIds.length > 0), + command('selection.all', 'commands.shortcuts.selectAll', 'global', keys(modKey('meta+shift+a', 'shift+a')), 'selection.all', 85, context => context.surface === 'list'), + command('selection.clear', 'commands.shortcuts.clearSelection', 'global', keys('escape'), 'selection.clear', 110, context => context.selectedConversationIds.length > 0), + command('navigation.back', 'common.back', 'global', keys('escape'), 'navigation.back', 50, context => context.surface === 'conversation'), + command('navigation.inbox', 'commands.navigation.unifiedInbox.title', 'global', keys('g i'), 'navigation.shortcutInbox'), + command('navigation.sent', 'commands.shortcuts.sent', 'account', keys('g t'), 'navigation.sent', 40, context => !!context.accountId), + command('navigation.drafts', 'commands.shortcuts.drafts', 'account', keys('g d'), 'navigation.drafts', 40, context => !!context.accountId), + command('navigation.contacts', 'commands.shortcuts.contacts', 'global', keys('g c'), 'navigation.contacts'), + command('help.shortcuts', 'shortcuts.title', 'global', keys('?'), 'help.shortcuts'), + command('layout.toggleRightSidebar', 'shortcuts.actions.toggleRightSidebar.label', 'global', keys(modKey('meta+/', '/')), 'layout.toggleRightSidebar'), + command('mail.print', 'shortcuts.actions.printMessage.label', 'single_conversation', keys(modKey('meta+p', 'p')), 'mail.print', 60, context => context.surface === 'conversation'), + command('compose.send', 'compose.send', 'draft', keys(modKey('meta+enter', 'enter')), 'editor.send', 0, () => false), +]; + +const success = (ids = []) => ({ status: 'success', succeededIds: ids.filter(Boolean), failed: [] }); + +export function createShortcutCommandExecutors(deps) { + const stepConversation = direction => ({ context }) => { + const ids = context.visibleConversationIds; + if (!ids.length) return { status: 'cancelled' }; + const index = ids.indexOf(context.activeConversationId); + const nextIndex = index < 0 + ? (direction > 0 ? 0 : ids.length - 1) + : Math.max(0, Math.min(ids.length - 1, index + direction)); + const next = ids[nextIndex]; + deps.selectTarget(next, context); + return success([next]); + }; + const stepThread = direction => async ({ context }) => { + const ids = await deps.loadThreadTargets(context); + if (ids.length < 2) return { status: 'cancelled' }; + const index = Math.max(0, ids.indexOf(context.activeConversationId)); + const next = ids[Math.max(0, Math.min(ids.length - 1, index + direction))]; + deps.selectTarget(next, context); + return success([next]); + }; + const specialFolder = specialUse => async ({ context }) => { + const folders = await deps.getFolders(context.accountId); + const folder = folders.find(item => item.special_use?.toLowerCase() === specialUse.toLowerCase()); + if (!folder) return { status: 'failed', failed: [{ id: context.accountId, error: `${specialUse} folder unavailable` }] }; + deps.navigate({ accountId: context.accountId, folder: folder.path }); + return success([folder.path]); + }; + return { + 'app.undo': () => { deps.undoLatest(); return success(); }, + 'navigation.nextConversation': stepConversation(1), + 'navigation.previousConversation': stepConversation(-1), + 'navigation.nextThreadMessage': stepThread(1), + 'navigation.previousThreadMessage': stepThread(-1), + 'navigation.scrollDown': () => { deps.scrollConversation(0.8); return success(); }, + 'navigation.scrollUp': () => { deps.scrollConversation(-0.8); return success(); }, + 'navigation.openConversation': ({ context }) => { const id = context.activeConversationId || context.visibleConversationIds[0]; deps.selectTarget(id, context); return success([id]); }, + 'selection.toggle': ({ context }) => { const id = context.activeConversationId || context.visibleConversationIds[0]; deps.selection.toggle(id, context); return success([id]); }, + 'selection.extendNext': ({ context }) => { deps.selection.extend(1, context); return success(); }, + 'selection.extendPrevious': ({ context }) => { deps.selection.extend(-1, context); return success(); }, + 'selection.focusedAndOlder': ({ context }) => { deps.selection.selectFocusedAndOlder(context); return success(); }, + 'selection.all': ({ context }) => { deps.selection.selectAllVisible(context); return success(); }, + 'selection.clear': () => { deps.selection.clear(); return success(); }, + 'navigation.back': () => { deps.goBack(); return success(); }, + 'navigation.shortcutInbox': () => { deps.navigate({ accountId: null, folder: 'INBOX' }); return success(); }, + 'navigation.sent': specialFolder('\\Sent'), + 'navigation.drafts': specialFolder('\\Drafts'), + 'navigation.contacts': () => { deps.navigate({ contacts: true }); return success(); }, + 'help.shortcuts': () => { deps.emitShortcut('showHelp'); return success(); }, + 'layout.toggleRightSidebar': () => { deps.emitShortcut('toggleRightSidebar'); return success(); }, + 'mail.print': () => { deps.emitShortcut('printMessage'); return success(); }, + }; +} diff --git a/frontend/src/commands/shortcutCommands.test.js b/frontend/src/commands/shortcutCommands.test.js new file mode 100644 index 00000000..8b5ce3ae --- /dev/null +++ b/frontend/src/commands/shortcutCommands.test.js @@ -0,0 +1,46 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createShortcutCommandExecutors, shortcutCommandDefinitions } from './shortcutCommands.js'; + +function harness() { + const calls = []; + const deps = { + selection: { + toggle: id => calls.push(['toggle', id]), extend: direction => calls.push(['extend', direction]), + selectFocusedAndOlder: () => calls.push(['older']), selectAllVisible: () => calls.push(['all']), + clear: () => calls.push(['clear']), + }, + selectTarget: id => calls.push(['select', id]), + loadThreadTargets: async () => ['a', 'b', 'c'], scrollConversation: n => calls.push(['scroll', n]), + navigate: value => calls.push(['navigate', value]), + getFolders: async accountId => [{ accountId, path: 'Sent', special_use: '\\Sent' }, { accountId, path: 'Drafts', special_use: '\\Drafts' }], + undoLatest: () => calls.push(['undo']), + }; + return { calls, executors: createShortcutCommandExecutors(deps) }; +} + +describe('shortcut commands', () => { + it('owns selection directions and thread navigation', async () => { + const h = harness(); + await h.executors['selection.extendNext']({ context: {} }); + await h.executors['selection.extendPrevious']({ context: {} }); + await h.executors['navigation.nextThreadMessage']({ context: { activeConversationId: 'b' } }); + assert.deepEqual(h.calls, [['extend', 1], ['extend', -1], ['select', 'c']]); + }); + + it('resolves special folders and the latest undo', async () => { + const h = harness(); + await h.executors['navigation.sent']({ context: { accountId: 'account-1' } }); + await h.executors['app.undo']({ context: {} }); + assert.deepEqual(h.calls, [['navigate', { accountId: 'account-1', folder: 'Sent' }], ['undo']]); + }); + + it('declares contextual Enter, selection modifiers, and G sequences', () => { + const byId = new Map(shortcutCommandDefinitions.map(command => [command.id, command])); + assert.equal(byId.get('navigation.openConversation').defaultKeys.primary, 'enter'); + assert.deepEqual(byId.get('navigation.openConversation').defaultKeys.secondary, ['o']); + assert.equal(byId.get('selection.focusedAndOlder').defaultKeys.primary.mac, 'meta+a'); + assert.equal(byId.get('selection.all').defaultKeys.primary.windows, 'ctrl+shift+a'); + assert.equal(byId.get('navigation.inbox').defaultKeys.primary, 'g i'); + }); +}); diff --git a/frontend/src/commands/shortcutDispatcher.js b/frontend/src/commands/shortcutDispatcher.js new file mode 100644 index 00000000..fa7d56b9 --- /dev/null +++ b/frontend/src/commands/shortcutDispatcher.js @@ -0,0 +1,74 @@ +const isTypingTarget = target => ['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName) + || target?.isContentEditable || target?.closest?.('[data-shortcut-recorder="true"]'); + +export function shortcutEventChord(event) { + const key = event.key === ' ' ? 'space' : event.key.toLowerCase(); + const parts = []; + if (event.metaKey) parts.push('meta'); + if (event.ctrlKey) parts.push('ctrl'); + if (event.altKey) parts.push('alt'); + if (event.shiftKey && (key === 'space' || /^[a-z0-9]$/.test(key))) parts.push('shift'); + parts.push(key); + return parts.join('+'); +} + +export function createShortcutDispatcher({ registry, getContext, getBindings, execute, timers }) { + let pending = null; + let pendingTimer = null; + let pendingContextKey = null; + + const reset = () => { + pending = null; + pendingContextKey = null; + if (pendingTimer) timers.clearTimeout(pendingTimer); + pendingTimer = null; + }; + + const handleKeyDown = event => { + const context = getContext(); + const contextKey = JSON.stringify([ + context.surface, context.modal?.kind || '', context.editing, + context.accountId, context.folder, context.activeConversationId, + context.selectedConversationIds, context.shortcutOverrides, + ]); + if ((pending && pendingContextKey !== contextKey) || context.modal || context.editing + || isTypingTarget(event.target) || event.isComposing || event.keyCode === 229) { + reset(); + return false; + } + const chord = shortcutEventChord(event); + if (chord === 'escape' && pending) { + event.preventDefault(); + reset(); + return true; + } + const available = new Map(registry.list(context) + .map(result => result.command) + .map(command => [command.id, command])); + const candidates = getBindings().flatMap(item => available.has(item.commandId) + ? item.bindings.map(binding => ({ ...binding, commandId: item.commandId })) + : []); + const resolved = pending ? `${pending} ${chord}` : chord; + const exact = candidates.filter(candidate => candidate.keys.toLowerCase() === resolved) + .sort((a, b) => (available.get(b.commandId).rank?.base ?? 0) + - (available.get(a.commandId).rank?.base ?? 0)); + if (exact.length) { + event.preventDefault(); + reset(); + execute(exact[0].commandId, { source: 'shortcut' }); + return true; + } + if (candidates.some(candidate => candidate.keys.toLowerCase().startsWith(`${resolved} `))) { + event.preventDefault(); + reset(); + pending = resolved; + pendingContextKey = contextKey; + pendingTimer = timers.setTimeout(reset, 1000); + return true; + } + reset(); + return false; + }; + + return Object.freeze({ handleKeyDown, reset }); +} diff --git a/frontend/src/commands/shortcutDispatcher.test.js b/frontend/src/commands/shortcutDispatcher.test.js new file mode 100644 index 00000000..89107041 --- /dev/null +++ b/frontend/src/commands/shortcutDispatcher.test.js @@ -0,0 +1,59 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createShortcutDispatcher } from './shortcutDispatcher.js'; + +const event = (key, patch = {}) => ({ + key, ctrlKey: false, metaKey: false, shiftKey: false, altKey: false, + target: { tagName: 'DIV', isContentEditable: false }, prevented: false, + preventDefault() { this.prevented = true; }, ...patch, +}); + +function harness(context, available, bindings) { + const calls = []; + const scheduled = []; + const dispatcher = createShortcutDispatcher({ + registry: { list: () => available.map(command => ({ command, title: command.id, score: 0, bindings: [] })) }, + getContext: () => context, + getBindings: () => bindings, execute: (id, options) => calls.push([id, options]), + timers: { setTimeout(fn, ms) { const item = { fn, ms }; scheduled.push(item); return item; }, clearTimeout() {} }, + }); + return { calls, scheduled, dispatcher }; +} + +describe('shortcut dispatcher', () => { + it('resolves contextual Enter by availability and rank', () => { + const h = harness({ surface: 'list', modal: null, editing: false }, [ + { id: 'navigation.openConversation', rank: { base: 100 } }, + ], [ + { commandId: 'navigation.openConversation', bindings: [{ keys: 'enter' }] }, + { commandId: 'mail.replyAll', bindings: [{ keys: 'enter' }] }, + ]); + h.dispatcher.handleKeyDown(event('Enter')); + assert.equal(h.calls[0][0], 'navigation.openConversation'); + }); + + it('dispatches a one-second sequence and cancels it on Escape', () => { + const h = harness({ surface: 'list', modal: null, editing: false }, [ + { id: 'navigation.inbox', rank: { base: 40 } }, + ], [{ commandId: 'navigation.inbox', bindings: [{ keys: 'g i' }] }]); + const first = event('g'); + h.dispatcher.handleKeyDown(first); + assert.equal(h.scheduled[0].ms, 1000); + h.dispatcher.handleKeyDown(event('i')); + assert.equal(h.calls[0][0], 'navigation.inbox'); + h.dispatcher.handleKeyDown(event('g')); + h.dispatcher.handleKeyDown(event('Escape')); + h.dispatcher.handleKeyDown(event('i')); + assert.equal(h.calls.length, 1); + }); + + it('normalizes modifiers and yields to typing, modals, and IME composition', () => { + const available = [{ id: 'mail.unsubscribe', rank: { base: 50 } }]; + const bindings = [{ commandId: 'mail.unsubscribe', bindings: [{ keys: 'meta+u' }] }]; + const h = harness({ surface: 'conversation', modal: null, editing: false }, available, bindings); + h.dispatcher.handleKeyDown(event('u', { metaKey: true })); + h.dispatcher.handleKeyDown(event('u', { metaKey: true, target: { tagName: 'INPUT' } })); + h.dispatcher.handleKeyDown(event('u', { metaKey: true, isComposing: true })); + assert.equal(h.calls.length, 1); + }); +}); diff --git a/frontend/src/commands/shortcuts.js b/frontend/src/commands/shortcuts.js new file mode 100644 index 00000000..27b3bdad --- /dev/null +++ b/frontend/src/commands/shortcuts.js @@ -0,0 +1,60 @@ +function selectKey(spec, platform) { + if (!spec) return null; + if (typeof spec === 'string') return spec; + return spec[platform] || spec.default || null; +} + +export function effectiveCommandKeys(command, context) { + const hasOverride = Object.hasOwn(context.shortcutOverrides, command.id); + const override = hasOverride ? context.shortcutOverrides[command.id] : undefined; + const bindings = []; + const primary = hasOverride ? override : selectKey(command.defaultKeys.primary, context.platform); + if (primary) bindings.push({ + key: primary, + kind: hasOverride ? 'user' : typeof command.defaultKeys.primary === 'object' ? 'platform' : 'primary', + }); + for (const spec of command.defaultKeys.secondary) { + const key = selectKey(spec, context.platform); + if (key && !bindings.some(binding => binding.key === key)) bindings.push({ key, kind: 'secondary' }); + } + return { bindings, conflicts: [] }; +} + +function formatChord(key, platform) { + const mac = platform === 'mac'; + const labels = mac + ? { meta: '⌘', ctrl: '⌃', alt: '⌥', shift: '⇧', enter: '↵' } + : { meta: 'Win+', ctrl: 'Ctrl+', alt: 'Alt+', shift: 'Shift+', enter: 'Enter' }; + return key.split('+').map((part, index, all) => { + const normalized = part.toLowerCase(); + const label = labels[normalized] || (part.length === 1 ? part.toUpperCase() : part); + return mac || index === all.length - 1 ? label : label; + }).join(mac ? '' : ''); +} + +export function formatCommandKey(key, platform) { + return key.split(' ').map(chord => formatChord(chord, platform)).join(' then '); +} + +export function getEffectiveCommandBindings(definitions, context) { + return definitions.map(command => ({ + commandId: command.id, + bindings: effectiveCommandKeys(command, context).bindings.map(binding => ({ + keys: binding.key, + source: binding.kind, + })), + })); +} + +export function findBindingConflicts(commands, context) { + const owners = new Map(); + for (const command of commands) { + for (const { key } of effectiveCommandKeys(command, context).bindings) { + if (!owners.has(key)) owners.set(key, []); + owners.get(key).push(command.id); + } + } + return [...owners.entries()] + .filter(([, commandIds]) => commandIds.length > 1) + .map(([key, commandIds]) => ({ key, commandIds })); +} diff --git a/frontend/src/commands/shortcuts.test.js b/frontend/src/commands/shortcuts.test.js new file mode 100644 index 00000000..0b6b1c22 --- /dev/null +++ b/frontend/src/commands/shortcuts.test.js @@ -0,0 +1,60 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { effectiveCommandKeys, findBindingConflicts, formatCommandKey, getEffectiveCommandBindings } from './shortcuts.js'; +import { normalizeLegacyShortcutOverrides } from '../utils/defaultShortcuts.js'; + +const command = { + id: 'palette.toggle', + defaultKeys: { + primary: { mac: 'meta+k', windows: 'ctrl+k', linux: 'ctrl+k' }, + secondary: ['alt+k'], + }, +}; + +describe('command shortcut metadata', () => { + it('selects the platform primary and retains secondary bindings', () => { + assert.deepEqual(effectiveCommandKeys(command, { platform: 'mac', shortcutOverrides: {} }).bindings, [ + { key: 'meta+k', kind: 'platform' }, { key: 'alt+k', kind: 'secondary' }, + ]); + assert.equal(effectiveCommandKeys(command, { platform: 'linux', shortcutOverrides: {} }).bindings[0].key, 'ctrl+k'); + }); + + it('uses the unchanged user override as primary without deleting secondary keys', () => { + assert.deepEqual(effectiveCommandKeys(command, { + platform: 'windows', shortcutOverrides: { 'palette.toggle': 'ctrl+shift+k' }, + }).bindings, [ + { key: 'ctrl+shift+k', kind: 'user' }, { key: 'alt+k', kind: 'secondary' }, + ]); + }); + + it('supports explicit primary unbinding and formats platform labels', () => { + assert.deepEqual(effectiveCommandKeys(command, { + platform: 'mac', shortcutOverrides: { 'palette.toggle': null }, + }).bindings, [{ key: 'alt+k', kind: 'secondary' }]); + assert.equal(formatCommandKey('meta+shift+k', 'mac'), '⌘⇧K'); + assert.equal(formatCommandKey('ctrl+shift+k', 'windows'), 'Ctrl+Shift+K'); + }); + + it('reports conflicts instead of silently dropping either command', () => { + const commands = [command, { ...command, id: 'navigation.search', defaultKeys: { primary: 'alt+k', secondary: [] } }]; + assert.deepEqual(findBindingConflicts(commands, { platform: 'linux', shortcutOverrides: {} }), [ + { key: 'alt+k', commandIds: ['palette.toggle', 'navigation.search'] }, + ]); + }); +}); + +it('aggregates sequences, platform keys, secondary keys, and legacy overrides', () => { + const definitions = [ + { ...command, id: 'mail.toggleRead', defaultKeys: { primary: 'u', secondary: ['m'] } }, + { ...command, id: 'navigation.inbox', defaultKeys: { primary: 'g i', secondary: [] } }, + ]; + const context = { + platform: 'mac', + shortcutOverrides: normalizeLegacyShortcutOverrides({ toggleRead: 'q' }), + }; + assert.deepEqual(getEffectiveCommandBindings(definitions, context)[0].bindings, [ + { keys: 'q', source: 'user' }, + { keys: 'm', source: 'secondary' }, + ]); + assert.equal(formatCommandKey('g i', 'linux'), 'G then I'); +}); diff --git a/frontend/src/commands/targets.js b/frontend/src/commands/targets.js new file mode 100644 index 00000000..e911aa83 --- /dev/null +++ b/frontend/src/commands/targets.js @@ -0,0 +1,28 @@ +import { TARGET_MODES } from './contracts.js'; + +function contextualIds(context) { + return context.selectedConversationIds.length + ? context.selectedConversationIds + : context.activeConversationId ? [context.activeConversationId] : []; +} + +export function hasTargets(command, context) { + switch (command.targetMode) { + case TARGET_MODES.GLOBAL: return true; + case TARGET_MODES.ACCOUNT: return Boolean(context.accountId); + case TARGET_MODES.DRAFT: return Boolean(context.draft?.id); + case TARGET_MODES.SINGLE_CONVERSATION: return contextualIds(context).length === 1; + case TARGET_MODES.BULK_SAFE: return contextualIds(context).length > 0; + default: return false; + } +} + +export function resolveTargetIds(command, context, frozenTargetIds) { + if (![TARGET_MODES.SINGLE_CONVERSATION, TARGET_MODES.BULK_SAFE].includes(command.targetMode)) { + return { targetIds: [], missingTargetIds: [] }; + } + const requested = frozenTargetIds == null ? contextualIds(context) : [...new Set(frozenTargetIds)]; + const targetIds = requested.filter(id => context.conversationsById[id]); + const missingTargetIds = requested.filter(id => !context.conversationsById[id]); + return { targetIds, missingTargetIds }; +} diff --git a/frontend/src/commands/targets.test.js b/frontend/src/commands/targets.test.js new file mode 100644 index 00000000..7c8df9b0 --- /dev/null +++ b/frontend/src/commands/targets.test.js @@ -0,0 +1,55 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createCommandContext, validateCommandDefinition } from './contracts.js'; +import { hasTargets, resolveTargetIds } from './targets.js'; + +const command = targetMode => validateCommandDefinition({ + id: `test.${targetMode}`, + titleKey: 'test.title', aliasKeys: [], icon: 'test', group: 'test', + defaultKeys: { primary: null, secondary: [] }, rank: { base: 0 }, + isAvailable: () => true, targetMode, executorId: 'test.execute', +}); +const context = overrides => createCommandContext({ + surface: 'list', activeConversationId: null, selectedConversationIds: [], + conversations: [ + { id: 'row-a', message_id: '', account_id: 'acct' }, + { id: 'row-b', message_id: '', account_id: 'acct' }, + ], + accountId: 'acct', folder: 'INBOX', draft: null, gtdAvailable: false, + cardDavConnected: false, modal: null, editing: false, platform: 'linux', + shortcutOverrides: {}, translate: key => key, ...overrides, +}); + +describe('command targets', () => { + it('omits conversation commands without an active row or checkbox selection', () => { + assert.equal(hasTargets(command('single_conversation'), context({})), false); + assert.equal(hasTargets(command('bulk_safe'), context({})), false); + }); + + it('makes single and bulk-safe commands available for one active conversation', () => { + const ctx = context({ activeConversationId: 'acct:' }); + assert.equal(hasTargets(command('single_conversation'), ctx), true); + assert.deepEqual(resolveTargetIds(command('bulk_safe'), ctx), { + targetIds: ['acct:'], missingTargetIds: [], + }); + }); + + it('uses the complete selection and hides single-conversation commands in bulk', () => { + const ctx = context({ activeConversationId: 'acct:', selectedConversationIds: ['acct:', 'acct:'] }); + assert.equal(hasTargets(command('single_conversation'), ctx), false); + assert.deepEqual(resolveTargetIds(command('bulk_safe'), ctx).targetIds, ['acct:', 'acct:']); + }); + + it('re-resolves a frozen continuation target set and reports missing targets', () => { + assert.deepEqual(resolveTargetIds(command('bulk_safe'), context({}), ['acct:', 'acct:']), { + targetIds: ['acct:'], missingTargetIds: ['acct:'], + }); + }); + + it('requires current account and draft state for their scoped modes', () => { + assert.equal(hasTargets(command('account'), context({ accountId: null })), false); + assert.equal(hasTargets(command('account'), context({ accountId: 'acct' })), true); + assert.equal(hasTargets(command('draft'), context({ draft: null })), false); + assert.equal(hasTargets(command('draft'), context({ draft: { id: 'draft-1' } })), true); + }); +}); diff --git a/frontend/src/components/AdminPanel.jsx b/frontend/src/components/AdminPanel.jsx index c61b1eba..63304622 100644 --- a/frontend/src/components/AdminPanel.jsx +++ b/frontend/src/components/AdminPanel.jsx @@ -25,10 +25,12 @@ import { NOTIFICATION_SOUNDS, playNotificationSound, playCustomSound, warmUpAudi import { usePushNotifications } from '../hooks/usePushNotifications.js'; import SignatureEditor from './SignatureEditor.jsx'; import GtdZeroPet from './GtdZeroPet.jsx'; -import { getEffectiveShortcuts, getGroupedActions, ACTION_DEFS, SPECIAL_KEY_LABELS, parseModKey, modLabel } from '../utils/defaultShortcuts.js'; import { DEFAULT_GTD_FOLDERS, GTD_STATES, resolveAccountGtdFolders, diffGtdFolders, findGtdFolderCollisions } from '../utils/gtd.js'; import { unifiedUnreadTotal } from '../utils/unifiedInbox.js'; import { isValidForwardAddress } from '../utils/ruleActions.js'; +import { useCommandRuntimeContext } from '../commands/CommandRuntimeContext.jsx'; +import { formatCommandKey, getEffectiveCommandBindings } from '../commands/shortcuts.js'; +import { shortcutEventChord } from '../commands/shortcutDispatcher.js'; // ─── Shared field component ─────────────────────────────────────────────────── function Field({ label, required, children }) { @@ -2080,6 +2082,7 @@ function LayoutsTab() { // CardDAV contact sync (e.g. Nextcloud). One-way, read-only pull. function CardDavCard() { const { t } = useTranslation(); + const setCarddavStatus = useStore(state => state.setCarddavStatus); const [status, setStatus] = useState(null); // null while loading const [expanded, setExpanded] = useState(false); const [form, setForm] = useState({ serverUrl: '', username: '', password: '', dupMode: 'separate', intervalMin: 60 }); @@ -2088,7 +2091,14 @@ function CardDavCard() { const [disconnecting, setDisconnecting] = useState(false); const [error, setError] = useState(''); - useEffect(() => { api.carddav.status().then(setStatus).catch(() => setStatus({ connected: false })); }, []); + const applyStatus = useCallback((next) => { + setStatus(next); + setCarddavStatus(next); + }, [setCarddavStatus]); + + useEffect(() => { + api.carddav.status().then(applyStatus).catch(() => applyStatus({ connected: false })); + }, [applyStatus]); const connected = status?.connected; const loading = status === null; @@ -2100,24 +2110,24 @@ function CardDavCard() { serverUrl: form.serverUrl.trim(), username: form.username.trim(), password: form.password, dupMode: form.dupMode, intervalMin: Number(form.intervalMin), }); - setStatus(s); setForm(f => ({ ...f, password: '' })); + applyStatus(s); setForm(f => ({ ...f, password: '' })); } catch (e) { setError(e.message || t('admin.integrations.carddav.connectFailed')); } finally { setConnecting(false); } }; const handleSync = async () => { setSyncing(true); setError(''); - try { const r = await api.carddav.sync(); setStatus(r.status); if (!r.ok && r.error) setError(r.error); } + try { const r = await api.carddav.sync(); applyStatus(r.status); if (!r.ok && r.error) setError(r.error); } catch (e) { setError(e.message); } finally { setSyncing(false); } }; const handleDisconnect = async () => { setDisconnecting(true); setError(''); - try { await api.carddav.disconnect(); setStatus({ connected: false }); } + try { await api.carddav.disconnect(); applyStatus({ connected: false }); } catch (e) { setError(e.message); } finally { setDisconnecting(false); } }; const updateSetting = async (patch) => { - setStatus(s => ({ ...s, ...patch })); + applyStatus({ ...status, ...patch }); try { await api.carddav.update(patch); } catch (e) { setError(e.message); } }; @@ -6681,11 +6691,31 @@ const TABS = [ function ShortcutsTab() { const { t } = useTranslation(); const { shortcuts, setShortcuts } = useStore(); + const { commandDefinitions, getContext } = useCommandRuntimeContext(); const [recording, setRecording] = useState(null); // action name currently being recorded - const [pendingConflict, setPendingConflict] = useState(null); // { action: conflictingAction, key } - - const effective = getEffectiveShortcuts(shortcuts); - const groups = getGroupedActions(); + const [pendingConflict, setPendingConflict] = useState(null); // { actions: conflictingCommandIds, key } + + const context = getContext(); + const effectiveRows = getEffectiveCommandBindings(commandDefinitions, context); + const bindingsById = Object.fromEntries(effectiveRows.map(item => [item.commandId, item.bindings])); + const effective = Object.fromEntries(effectiveRows.map(item => [item.commandId, item.bindings[0]?.keys || null])); + const sources = Object.fromEntries(effectiveRows.map(item => [item.commandId, item.bindings[0]?.source])); + const definitionById = new Map(commandDefinitions.map(definition => [definition.id, definition])); + const groupKeys = { + compose: 'shortcuts.groups.composeSearch', help: 'shortcuts.groups.composeSearch', + navigation: 'shortcuts.groups.navigation', selection: 'shortcuts.groups.navigation', + layout: 'shortcuts.groups.navigation', mail: 'shortcuts.groups.messageActions', + respond: 'shortcuts.groups.messageActions', gtd: 'shortcuts.groups.gtd', app: 'shortcuts.groups.navigation', + }; + const groups = commandDefinitions.filter(definition => effective[definition.id] + || definition.id === 'gtd.someday' || definition.id === 'gtd.reference').reduce((result, definition) => { + const groupKey = groupKeys[definition.group] || 'shortcuts.groups.navigation'; + (result[groupKey] ||= []).push({ + action: definition.id, + descriptionKey: definition.titleKey, + }); + return result; + }, {}); // Listen for key presses while recording useEffect(() => { @@ -6701,12 +6731,13 @@ function ShortcutsTab() { return; } - const key = (e.ctrlKey || e.metaKey) ? `ctrl+${e.key.toLowerCase()}` : e.key; + const key = shortcutEventChord(e); // Detect conflicts with other actions (excluding the one being edited) - const conflictEntry = Object.entries(effective).find(([a, k]) => k === key && a !== recording); - if (conflictEntry) { - setPendingConflict({ action: conflictEntry[0], key }); + const conflicts = effectiveRows + .filter(item => item.commandId !== recording && item.bindings.some(binding => binding.keys === key)); + if (conflicts.length) { + setPendingConflict({ actions: conflicts.map(item => item.commandId), key }); } else { setPendingConflict(null); } @@ -6717,7 +6748,7 @@ function ShortcutsTab() { }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); - }, [recording, effective, shortcuts]); // eslint-disable-line react-hooks/exhaustive-deps + }, [recording, effective, shortcuts, context.platform]); // eslint-disable-line react-hooks/exhaustive-deps const clearShortcut = (action) => { const updated = { ...shortcuts, [action]: null }; @@ -6764,35 +6795,7 @@ function ShortcutsTab() { if (!key) { return ; } - // Modifier combos like 'ctrl+p' - const mod = parseModKey(key); - if (mod) { - return ( - - {modLabel(mod.mod)} - + - {mod.bare.toUpperCase()} - - ); - } - // Special key names like 'Delete', 'ArrowUp' — single keypress, render as one badge - if (SPECIAL_KEY_LABELS[key]) { - return {SPECIAL_KEY_LABELS[key]}; - } - // Multi-char keys like 'gi': render each character as separate kbd with "then" - if (key.length > 1) { - return ( - - {[...key].map((c, i) => ( - - {c} - {i < key.length - 1 && {t('shortcuts.then')}} - - ))} - - ); - } - return {key}; + return {formatCommandKey(key, context.platform)}; }; return ( @@ -6824,7 +6827,11 @@ function ShortcutsTab() { background: 'rgba(234, 179, 8, 0.1)', border: '1px solid rgba(234, 179, 8, 0.4)', borderRadius: 7, fontSize: 12, color: 'var(--text-secondary)', }}> - {t('admin.shortcuts.conflict', { key: pendingConflict.key, action: t(ACTION_DEFS[pendingConflict.action]?.labelKey) })} + {t('admin.shortcuts.conflict', { + key: pendingConflict.key, + action: pendingConflict.actions + .map(action => t(definitionById.get(action)?.titleKey)).join(', '), + })} )} @@ -6839,6 +6846,7 @@ function ShortcutsTab() {
{actions.map(({ action, descriptionKey }, i) => { const key = effective[action]; + const bindings = bindingsById[action] || []; const isDefault = !(action in shortcuts); const isRec = recording === action; return ( @@ -6857,6 +6865,7 @@ function ShortcutsTab() {
+ {!isRec && sources[action] && ( + + {t(`admin.shortcuts.sources.${sources[action]}`)} + + )} + {!isRec && bindings.slice(1).map(binding => ( + + {formatCommandKey(binding.keys, context.platform)} + + {t(`admin.shortcuts.sources.${binding.source}`)} + + + ))} {!isDefault && ( )} +
; +} diff --git a/frontend/src/components/CommandIcon.jsx b/frontend/src/components/CommandIcon.jsx new file mode 100644 index 00000000..0e319159 --- /dev/null +++ b/frontend/src/components/CommandIcon.jsx @@ -0,0 +1,32 @@ +const paths = { + compose: <>, + search: <>, + contacts: <>, + inbox: <>, + folder: , + archive: <>, + clock: <>, + 'mail-open': <>, + mail: <>, + star: , + trash: <>, + shield: <>, + 'shield-check': <>, + reply: <>, + 'reply-all': <>, + forward: <>, + 'check-square': <>, + eye: <>, + 'user-check': <>, + calendar: <>, + bookmark: , + gtd: <>, + appearance: <>, + settings: <>, +}; + +export default function CommandIcon({ name }) { + return ; +} diff --git a/frontend/src/components/CommandPalette.jsx b/frontend/src/components/CommandPalette.jsx index 35e5d86a..ba6f27db 100644 --- a/frontend/src/components/CommandPalette.jsx +++ b/frontend/src/components/CommandPalette.jsx @@ -1,215 +1,126 @@ -import { useState, useEffect, useRef, useCallback } from 'react'; +import { useEffect, useMemo, useReducer, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { useStore } from '../store/index.js'; -import { useMobile } from '../hooks/useMobile.js'; -import { THEMES } from '../themes.js'; - -const THEME_NAMES = Object.keys(THEMES); - -function buildActions({ t, openCompose, setSelectedAccount, setShowAdmin, setAdminTab, theme, setTheme, accounts, selectedAccountId }) { - const actions = [ - { - id: 'compose', - label: t('commandPalette.actions.compose'), - icon: , - run: () => openCompose({ accountId: selectedAccountId || undefined }), - }, - { - id: 'inbox', - label: t('commandPalette.actions.inbox'), - icon: , - run: () => setSelectedAccount(null, 'INBOX'), - }, - { - id: 'settings', - label: t('commandPalette.actions.settings'), - icon: , - run: () => { setShowAdmin(true); setAdminTab('accounts'); }, - }, - { - id: 'themes', - label: t('commandPalette.actions.themes'), - icon: , - run: () => { setShowAdmin(true); setAdminTab('appearance'); }, - }, - ]; - - // Theme switch actions - for (const themeKey of THEME_NAMES) { - const label = THEMES[themeKey]?.label || themeKey; - actions.push({ - id: `theme:${themeKey}`, - label: t('commandPalette.actions.switchTheme', { theme: label }), - icon: , - active: theme === themeKey, - run: () => setTheme(themeKey), - }); - } - - // Per-account inbox shortcuts - for (const a of accounts) { - actions.push({ - id: `account:${a.id}`, - label: t('commandPalette.actions.accountInbox', { name: a.name }), - icon: , - run: () => setSelectedAccount(a.id, 'INBOX'), - }); - } - - return actions; -} +import { commandTargetLabel } from '../commands/appContext.js'; +import { formatCommandKey } from '../commands/shortcuts.js'; +import { createPaletteState, paletteKeyIntent, reducePaletteState } from '../commands/paletteState.js'; +import { isRestorableFocus, nextFocusIndex } from '../commands/paletteFocus.js'; +import { useCommandRuntimeContext } from '../commands/CommandRuntimeContext.jsx'; +import CommandContinuation from './CommandContinuation.jsx'; +import CommandIcon from './CommandIcon.jsx'; export default function CommandPalette({ open, onClose }) { const { t } = useTranslation(); - const isMobile = useMobile(); - const { openCompose, setSelectedAccount, setShowAdmin, setAdminTab, theme, setTheme, accounts, selectedAccountId } = useStore(); - const [query, setQuery] = useState(''); - const [activeIdx, setActiveIdx] = useState(0); - const [listScrolled, setListScrolled] = useState(false); + const { registry, controller, getContext, continuation, clearContinuation } = useCommandRuntimeContext(); + const [state, dispatch] = useReducer(reducePaletteState, undefined, createPaletteState); const inputRef = useRef(null); - const listRef = useRef(null); - - const actions = buildActions({ t, openCompose, setSelectedAccount, setShowAdmin, setAdminTab, theme, setTheme, accounts, selectedAccountId }); - - const filtered = query.trim() - ? actions.filter(a => a.label.toLowerCase().includes(query.toLowerCase())) - : actions; + const priorFocusRef = useRef(null); + const dialogRef = useRef(null); + const context = getContext(); + const results = useMemo( + () => registry.search(state.query, context), + [registry, state.query, context], + ); + const target = commandTargetLabel(context); + const resultCount = continuation?.props.items?.length ?? results.length; + useEffect(() => { dispatch({ type: 'results', count: resultCount }); }, [resultCount]); useEffect(() => { - if (open) { - setQuery(''); - setActiveIdx(0); - setTimeout(() => inputRef.current?.focus(), 30); - } + if (!open) return; + priorFocusRef.current = document.activeElement; + dispatch({ type: 'open' }); + queueMicrotask(() => (inputRef.current || dialogRef.current?.querySelector('button'))?.focus()); + return () => { + const prior = priorFocusRef.current; + if (isRestorableFocus(prior)) prior.focus(); + }; }, [open]); - - useEffect(() => { setActiveIdx(0); }, [query]); - - const runAction = useCallback((action) => { - action.run(); - onClose(); - }, [onClose]); - - const handleKeyDown = (e) => { - if (e.key === 'ArrowDown') { - e.preventDefault(); - setActiveIdx(i => Math.min(i + 1, filtered.length - 1)); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - setActiveIdx(i => Math.max(i - 1, 0)); - } else if (e.key === 'Enter') { - e.preventDefault(); - if (filtered[activeIdx]) runAction(filtered[activeIdx]); - } else if (e.key === 'Escape') { - onClose(); - } - }; - - // Scroll active item into view + useEffect(() => { dispatch({ type: 'continuation', value: continuation }); }, [continuation]); useEffect(() => { - const el = listRef.current?.children[activeIdx]; - el?.scrollIntoView({ block: 'nearest' }); - }, [activeIdx]); + if (!open) return; + dialogRef.current?.querySelectorAll('[role="option"]')[state.activeIndex] + ?.scrollIntoView({ block: 'nearest' }); + }, [continuation, open, resultCount, state.activeIndex, state.query]); if (!open) return null; - return ( -
-
e.stopPropagation()} - > - {/* Search input */} -
- - - - setQuery(e.target.value)} - onKeyDown={handleKeyDown} - placeholder={t('commandPalette.placeholder')} - style={{ - flex: 1, background: 'none', border: 'none', outline: 'none', - color: 'var(--text-primary)', fontSize: 15, - }} - /> - {!isMobile && Esc} -
- - {/* Results */} -
setListScrolled(e.currentTarget.scrollTop > 4)} - style={{ - maxHeight: 360, overflowY: 'auto', padding: '6px 0', - boxShadow: listScrolled ? 'inset 0 8px 8px -8px rgba(0,0,0,0.25)' : 'none', - transition: 'box-shadow 0.2s ease', - }} - > - {filtered.length === 0 ? ( -
- {t('commandPalette.noResults')} -
- ) : filtered.map((action, i) => ( -
runAction(action)} - onMouseEnter={() => setActiveIdx(i)} - onMouseDown={e => { e.currentTarget.style.transform = 'scale(0.98)'; }} - onMouseUp={e => { e.currentTarget.style.transform = ''; }} - onMouseLeave={e => { e.currentTarget.style.transform = ''; }} - style={{ - display: 'flex', alignItems: 'center', gap: 12, - padding: '9px 16px', cursor: 'pointer', - background: i === activeIdx ? 'var(--bg-hover)' : 'transparent', - transition: 'background 0.08s, transform 0.08s', - }} - > - - {action.icon} - - - {action.label} - - {action.active && ( - - - - )} -
- ))} -
+ const execute = async result => { + const outcome = await controller.execute(result.command.id, { source: 'palette' }); + if (['success', 'cancelled', 'partial'].includes(outcome.status)) onClose(); + }; + const onKeyDown = event => { + const intent = paletteKeyIntent(event); + if (!intent) { + if (event.key === 'Tab') { + const nodes = [...dialogRef.current.querySelectorAll('input,button,[tabindex]:not([tabindex="-1"])')]; + const index = nodes.indexOf(document.activeElement); + const next = nodes[nextFocusIndex(nodes.length, index, event.shiftKey)]; + event.preventDefault(); + event.stopPropagation(); + next?.focus(); + } + return; + } + event.preventDefault(); + event.stopPropagation(); + if (intent === 'next') dispatch({ type: 'move', delta: 1 }); + if (intent === 'previous') dispatch({ type: 'move', delta: -1 }); + if (intent === 'execute' && continuation) { + dialogRef.current.querySelectorAll('[role="option"]')[state.activeIndex]?.click(); + } + if (intent === 'execute' && !continuation && results[state.activeIndex]) execute(results[state.activeIndex]); + if (intent === 'back') { + if (continuation) clearContinuation(); + else onClose(); + } + }; - {!isMobile && ( -
- ↑↓ {t('commandPalette.hint.navigate')} - {t('commandPalette.hint.select')} - Esc {t('commandPalette.hint.close')} -
- )} + return
event.target === event.currentTarget && onClose()}> +
+

{t('commandPalette.title')}

+
+ + {!continuation ? dispatch({ type: 'query', query: event.target.value })} + placeholder={t('commandPalette.placeholder')} + /> : + {t(continuation.props.titleKey)} + } + Esc
-
- ); +
+ {t('commandPalette.announcement', { count: continuation?.props.items?.length ?? results.length, target: t(target.key, target.values) })} +
+ {continuation ? dispatch({ type: 'move', delta: index - state.activeIndex })} + onFinished={() => { clearContinuation(); onClose(); }} /> : +
+ {results.map((result, index) => )} + {!results.length &&

{t('commandPalette.noResults')}

} +
} +
+ {t(target.key, target.values)} + +
+ +
; } diff --git a/frontend/src/components/CommandPalette.test.js b/frontend/src/components/CommandPalette.test.js new file mode 100644 index 00000000..817ff0ec --- /dev/null +++ b/frontend/src/components/CommandPalette.test.js @@ -0,0 +1,75 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +describe('CommandPalette source wiring', () => { + const source = fs.readFileSync(new URL('./CommandPalette.jsx', import.meta.url), 'utf8'); + const continuation = fs.readFileSync(new URL('./CommandContinuation.jsx', import.meta.url), 'utf8'); + const icons = fs.readFileSync(new URL('./CommandIcon.jsx', import.meta.url), 'utf8'); + const mailActions = fs.readFileSync(new URL('../commands/mailActions.js', import.meta.url), 'utf8'); + const css = fs.readFileSync(new URL('../index.css', import.meta.url), 'utf8'); + + it('consumes the shared runtime and required accessible semantics', () => { + assert.match(source, /useCommandRuntimeContext\(\)/); + for (const token of [ + 'role="dialog"', 'aria-modal="true"', 'role="combobox"', 'aria-controls="command-palette-results"', + 'aria-activedescendant', 'role="listbox"', 'role="option"', 'aria-live="polite"', + ]) assert.ok(source.includes(token), `missing ${token}`); + }); + + it('uses registry results, alias hints, shortcut formatting, and focus helpers', () => { + assert.match(source, /registry\.search\(state\.query, context\)/); + assert.match(source, /result\.matchedAlias/); + assert.match(source, /formatCommandKey/); + assert.match(source, /isRestorableFocus/); + assert.match(source, /nextFocusIndex/); + }); + + it('closes directly so a rapid reopen cannot inherit a stale close request', () => { + assert.match(source, /if \(continuation\) clearContinuation\(\);\s*else onClose\(\);/); + assert.doesNotMatch(source, /closeRequested/); + }); + + it('keeps the active keyboard option inside the bounded scroll window', () => { + assert.match(source, /scrollIntoView\(\{ block: 'nearest' \}\)/); + assert.match(source, /\[state\.activeIndex\]/); + }); + + it('resumes continuations with their frozen account-scoped targets', () => { + assert.match(continuation, /frozenTargetIds: continuation\.targetIds/); + assert.match(continuation, /source: 'palette'/); + assert.match(continuation, /\[continuation\.props\.inputKey \|\| 'value'\]: item\.id/); + }); + + it('localizes and bounds continuation options inside the palette', () => { + assert.match(continuation, /useTranslation\(\)/); + assert.match(continuation, /aria-label=\{t\(continuation\.props\.titleKey\)\}/); + assert.match(continuation, /className="command-palette__results"/); + }); + + it('uses the MailFlow search header, escape chip, and keyboard footer', () => { + assert.match(source, /className="command-palette__search"/); + assert.match(source, /Esc<\/kbd>/); + assert.match(source, /className="command-palette__footer"/); + assert.match(source, /className="command-palette__hints"/); + for (const key of ['navigate', 'select', 'close']) { + assert.ok(source.includes(`commandPalette.hint.${key}`)); + } + }); + + it('keeps the approved 5.5-row viewport and theme-aware selected row', () => { + assert.match(css, /\.command-palette__results\s*\{[^}]*max-height:\s*286px/); + assert.match(css, /\.command-palette__row\s*\{[^}]*min-height:\s*52px/); + assert.match(css, /\.command-palette__row\[aria-selected="true"\]\s*\{[^}]*var\(--accent-dim\)/); + }); + + it('renders the native mail-action icons instead of the settings fallback', () => { + const iconNames = [...mailActions.matchAll(/definition\([^\n]+?,\s*'([^']+)',\s*'(?:mail|respond|gtd)'/g)] + .map(match => match[1]); + assert.ok(iconNames.length > 10); + for (const iconName of new Set(iconNames)) { + assert.match(icons, new RegExp(`\\b${iconName.replace('-', "['-]")}['"]?:`), `missing ${iconName}`); + } + }); +}); diff --git a/frontend/src/components/ComposeModal.jsx b/frontend/src/components/ComposeModal.jsx index e6dd0b47..6a27cdbe 100644 --- a/frontend/src/components/ComposeModal.jsx +++ b/frontend/src/components/ComposeModal.jsx @@ -646,6 +646,7 @@ export default function ComposeModal() { const handleKeyDown = (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); + e.stopPropagation(); handleSend(); } }; @@ -3163,4 +3164,3 @@ function ChipInput({ chips, onChipsChange, value, onChange, placeholder, autoFoc
); } - diff --git a/frontend/src/components/ContextMenu.jsx b/frontend/src/components/ContextMenu.jsx index 1d1c4adf..f1219e8d 100644 --- a/frontend/src/components/ContextMenu.jsx +++ b/frontend/src/components/ContextMenu.jsx @@ -4,6 +4,9 @@ import { useStore } from '../store/index.js'; import { api } from '../utils/api.js'; import { GTD_STATES, GTD_COLORS, resolveAccountGtdFolders, gtdStatesInFolders } from '../utils/gtd.js'; import { getContextMenuPolicy, resolveContextMenuMessage } from '../utils/contextMenuPolicy.js'; +import { stableConversationId } from '../commands/contracts.js'; +import { toContextMenuCommand } from '../commands/contextMenuCommands.js'; +import { useCommandRuntimeContext } from '../commands/CommandRuntimeContext.jsx'; import MessageHeaderModal from './MessageHeaderModal.jsx'; import { useUiScale, descale } from '../hooks/useUiScale.js'; @@ -15,8 +18,19 @@ const SPAM_NAME_RE = /(spam|junk|bulk|indesiderata|spamverdacht|courrier\s*ind|p // ─── Context Menu ───────────────────────────────────────────────────────────── const CATEGORIES = ['primary', 'newsletter', 'promotion', 'automated', 'social']; -export default function ContextMenu({ x, y, message, onClose, onAction, defaultMoveView = false, variant = 'inbox' }) { +export default function ContextMenu({ + x, + y, + message, + targetIds = [stableConversationId(message)].filter(Boolean), + onClose, + onAction, + defaultMoveView = false, + variant = 'inbox', + onCommand, +}) { const { t } = useTranslation(); + const { controller } = useCommandRuntimeContext(); const uiScale = useUiScale(); // Variants share one menu; the policy removes actions that depend on the center // list or conflict with GTD's Done contract while preserving ordinary mail actions. @@ -49,6 +63,18 @@ export default function ContextMenu({ x, y, message, onClose, onAction, defaultM const [folderSearch, setFolderSearch] = useState(''); const unreadCount = Number.parseInt(message.unread_count, 10); const hasUnread = Number.isFinite(unreadCount) ? unreadCount > 0 : !message.is_read; + const runAction = (action, data) => { + const invocation = toContextMenuCommand(action, data); + if (invocation) { + if (onCommand) return onCommand(invocation.commandId, invocation.input); + return controller.execute(invocation.commandId, { + source: 'context-menu', + input: invocation.input, + frozenTargetIds: targetIds, + }); + } + return onAction(action, data); + }; // A folder is "spam-like" when either the user mapped it as spam or the IMAP // server tagged it with \Junk special-use. Falls back to a multilingual name @@ -117,14 +143,14 @@ export default function ContextMenu({ x, y, message, onClose, onAction, defaultM { label: t('contextMenu.open'), icon: , - action: () => onAction('open'), + action: () => runAction('open'), }, { label: hasUnread ? t('contextMenu.markRead') : t('contextMenu.markUnread'), icon: hasUnread ? : , - action: () => onAction(hasUnread ? 'markRead' : 'markUnread'), + action: () => runAction(hasUnread ? 'markRead' : 'markUnread'), }, { label: message.is_starred ? t('contextMenu.unstar') : t('contextMenu.star'), @@ -133,12 +159,12 @@ export default function ContextMenu({ x, y, message, onClose, onAction, defaultM stroke={message.is_starred ? 'var(--amber)' : 'currentColor'} strokeWidth="1.75"> , - action: () => onAction('toggleStar'), + action: () => runAction('toggleStar'), }, ...(!menuPolicy.select ? [] : [{ label: t('contextMenu.select'), icon: , - action: () => onAction('bulkSelect'), + action: () => runAction('bulkSelect'), }]), ] }, @@ -148,17 +174,17 @@ export default function ContextMenu({ x, y, message, onClose, onAction, defaultM ...(!menuPolicy.compose ? [] : [{ label: t('contextMenu.reply'), icon: , - action: () => onAction('reply'), + action: () => runAction('reply'), }, { label: t('contextMenu.replyAll'), icon: , - action: () => onAction('replyAll'), + action: () => runAction('replyAll'), }, { label: t('contextMenu.forward'), icon: , - action: () => onAction('forward'), + action: () => runAction('forward'), }]), { label: t('contextMenu.moveToFolder'), @@ -170,7 +196,7 @@ export default function ContextMenu({ x, y, message, onClose, onAction, defaultM ...(!menuPolicy.archive ? [] : [{ label: t('contextMenu.archive'), icon: , - action: () => onAction('archive'), + action: () => runAction('archive'), }]), ...(message.folder !== 'Snoozed' && menuPolicy.snooze ? [{ label: t('contextMenu.snooze.label'), @@ -198,17 +224,17 @@ export default function ContextMenu({ x, y, message, onClose, onAction, defaultM ...(menuPolicy.done ? [{ label: t('gtd.done'), icon: , - action: () => onAction('gtdDone'), + action: () => runAction('gtdDone'), }] : []), ...(!menuPolicy.rules ? [] : [{ label: t('contextMenu.createRule'), icon: , - action: () => onAction('createRuleFromMessage'), + action: () => runAction('createRuleFromMessage'), }, { label: t('contextMenu.addToBlockList'), icon: , - action: () => onAction('addToBlockList'), + action: () => runAction('addToBlockList'), }]), // Spam / ham are only shown when there's a real destination for the // action: "Mark as Spam" when the message isn't already in a spam-like @@ -217,12 +243,12 @@ export default function ContextMenu({ x, y, message, onClose, onAction, defaultM ...(spamFolderPaths.size > 0 && !inSpamFolder && menuPolicy.spam ? [{ label: t('contextMenu.markAsSpam'), icon: , - action: () => onAction('markSpam'), + action: () => runAction('markSpam'), }] : []), ...(inSpamFolder && menuPolicy.spam ? [{ label: t('contextMenu.markAsHam'), icon: , - action: () => onAction('markHam'), + action: () => runAction('markHam'), }] : []), ] }, @@ -232,12 +258,12 @@ export default function ContextMenu({ x, y, message, onClose, onAction, defaultM { label: t('contextMenu.copySubject'), icon: , - action: () => { navigator.clipboard.writeText(message.subject || ''); onAction('copy'); }, + action: () => { navigator.clipboard.writeText(message.subject || ''); runAction('copy'); }, }, { label: t('contextMenu.copySender'), icon: , - action: () => { navigator.clipboard.writeText(message.from_email || ''); onAction('copy'); }, + action: () => { navigator.clipboard.writeText(message.from_email || ''); runAction('copy'); }, }, ] }, @@ -264,7 +290,7 @@ export default function ContextMenu({ x, y, message, onClose, onAction, defaultM { label: t('contextMenu.delete'), icon: , - action: () => onAction('delete'), + action: () => runAction('delete'), danger: true, }, ] @@ -336,7 +362,7 @@ export default function ContextMenu({ x, y, message, onClose, onAction, defaultM {GTD_STATES.map(state => (
{ onAction('gtdClassify', state); onClose(); }} + onClick={() => { runAction('gtdClassify', state); onClose(); }} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '7px 14px', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)' }} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'} @@ -351,7 +377,7 @@ export default function ContextMenu({ x, y, message, onClose, onAction, defaultM {gtdRemovableStates.map(state => (
{ onAction('gtdRemove', state); onClose(); }} + onClick={() => { runAction('gtdRemove', state); onClose(); }} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '7px 14px', cursor: 'pointer', fontSize: 13, color: 'var(--text-secondary)' }} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'} @@ -386,7 +412,7 @@ export default function ContextMenu({ x, y, message, onClose, onAction, defaultM return (
{ if (!isCurrent) { onAction('setCategory', cat); onClose(); } }} + onClick={() => { if (!isCurrent) { runAction('setCategory', cat); onClose(); } }} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '7px 14px', cursor: isCurrent ? 'default' : 'pointer', @@ -454,7 +480,7 @@ export default function ContextMenu({ x, y, message, onClose, onAction, defaultM onClick={() => { const d = new Date(`${customDate}T${customTime}`); if (isNaN(d.getTime())) return; - onAction('snooze', d.toISOString()); + runAction('snooze', d.toISOString()); onClose(); }} style={{ @@ -503,7 +529,7 @@ export default function ContextMenu({ x, y, message, onClose, onAction, defaultM ].map(({ label, getDate }) => (
{ onAction('snooze', getDate().toISOString()); onClose(); }} + onClick={() => { runAction('snooze', getDate().toISOString()); onClose(); }} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 14px', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)' }} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'} @@ -600,7 +626,7 @@ export default function ContextMenu({ x, y, message, onClose, onAction, defaultM { onAction('moveTo', folder.path); onClose(); }} + onClick={() => { runAction('moveTo', folder.path); onClose(); }} /> ))} @@ -626,7 +652,7 @@ export default function ContextMenu({ x, y, message, onClose, onAction, defaultM { onAction('moveTo', folder.path); onClose(); }} + onClick={() => { runAction('moveTo', folder.path); onClose(); }} /> ))}
@@ -641,7 +667,7 @@ export default function ContextMenu({ x, y, message, onClose, onAction, defaultM { onAction('moveTo', folder.path); onClose(); }} + onClick={() => { runAction('moveTo', folder.path); onClose(); }} /> ))}
@@ -653,7 +679,7 @@ export default function ContextMenu({ x, y, message, onClose, onAction, defaultM { onAction('moveTo', folder.path); onClose(); }} + onClick={() => { runAction('moveTo', folder.path); onClose(); }} /> )) } diff --git a/frontend/src/components/DelegateContactPicker.jsx b/frontend/src/components/DelegateContactPicker.jsx new file mode 100644 index 00000000..f6d69709 --- /dev/null +++ b/frontend/src/components/DelegateContactPicker.jsx @@ -0,0 +1,204 @@ +import { useCallback, useEffect, useId, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useCommandRuntimeContext } from '../commands/CommandRuntimeContext.jsx'; +import { api } from '../utils/api.js'; +import { + contactOption, + createPickerRequestGate, + nextPickerIndex, +} from '../utils/delegation.js'; + +function focusableElements(container) { + return [...container.querySelectorAll( + 'button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])', + )]; +} + +export default function DelegateContactPicker({ targetCount, onSelect, onCancel }) { + const { t } = useTranslation(); + const titleId = useId(); + const listboxId = useId(); + const dialogRef = useRef(null); + const searchRef = useRef(null); + const optionRefs = useRef([]); + const gateRef = useRef(createPickerRequestGate()); + const restoreFocusRef = useRef(document.activeElement); + const [query, setQuery] = useState(''); + const [contacts, setContacts] = useState([]); + const [activeIndex, setActiveIndex] = useState(-1); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [retryKey, setRetryKey] = useState(0); + + useEffect(() => { + const previousFocus = restoreFocusRef.current; + searchRef.current?.focus(); + return () => previousFocus?.focus?.(); + }, []); + + useEffect(() => { + const requestId = gateRef.current.start(); + setLoading(true); + setError(null); + setContacts([]); + setActiveIndex(-1); + const timer = setTimeout(async () => { + try { + const response = await api.getContacts({ q: query.trim(), limit: 30, offset: 0 }); + if (!gateRef.current.isCurrent(requestId)) return; + const next = (response?.contacts || (Array.isArray(response) ? response : [])) + .map(contactOption) + .filter(contact => contact.id && contact.label); + setContacts(next); + setActiveIndex(next.length ? 0 : -1); + } catch (cause) { + if (gateRef.current.isCurrent(requestId)) setError(cause); + } finally { + if (gateRef.current.isCurrent(requestId)) setLoading(false); + } + }, 200); + return () => clearTimeout(timer); + }, [query, retryKey]); + + useEffect(() => { + optionRefs.current[activeIndex]?.scrollIntoView?.({ block: 'nearest' }); + }, [activeIndex]); + + const chooseActive = useCallback(() => { + const contact = contacts[activeIndex]; + if (contact) onSelect(contact.id); + }, [activeIndex, contacts, onSelect]); + + const handleKeyDown = (event) => { + if (event.isComposing || event.nativeEvent?.isComposing || event.keyCode === 229) return; + if (event.key === 'Escape') { + event.preventDefault(); + onCancel(); + } else if (event.key === 'ArrowDown') { + event.preventDefault(); + setActiveIndex(index => nextPickerIndex(index, 1, contacts.length)); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + setActiveIndex(index => nextPickerIndex(index, -1, contacts.length)); + } else if (event.key === 'Enter' && activeIndex >= 0 && event.target === searchRef.current) { + event.preventDefault(); + chooseActive(); + } else if (event.key === 'Tab' && dialogRef.current) { + const focusable = focusableElements(dialogRef.current); + if (!focusable.length) return; + const first = focusable[0]; + const last = focusable.at(-1); + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + }; + + return ( +
{ + if (event.target === event.currentTarget) onCancel(); + }}> +
+
+
+

{t('gtd.delegate.pickerTitle', { count: targetCount })}

+

{t('gtd.delegate.pickerHint')}

+
+ +
+ + + +
+ {loading &&
{t('common.loading')}
} + {!loading && error &&
+ {t('gtd.delegate.loadFailed')} + +
} + {!loading && !error && contacts.length === 0 && ( +
{t('gtd.delegate.empty')}
+ )} + {!loading && !error && contacts.map((contact, index) => ( + + ))} +
+ +
+ + Esc {t('common.cancel')} +
+
+
+ ); +} + +export function DelegateContactContinuationHost({ onOpen }) { + const { controller, continuation, clearContinuation } = useCommandRuntimeContext(); + const isContact = continuation?.kind === 'contact'; + useEffect(() => { if (isContact) onOpen?.(); }, [isContact, onOpen]); + if (!isContact) return null; + + return ( + { + const { commandId, targetIds } = continuation; + clearContinuation(); + void controller.execute(commandId, { + source: 'continuation', + input: { contactId }, + frozenTargetIds: targetIds, + }); + }} + /> + ); +} diff --git a/frontend/src/components/DelegateContactPicker.test.js b/frontend/src/components/DelegateContactPicker.test.js new file mode 100644 index 00000000..b9f0a61f --- /dev/null +++ b/frontend/src/components/DelegateContactPicker.test.js @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +const source = fs.readFileSync(new URL('./DelegateContactPicker.jsx', import.meta.url), 'utf8'); + +test('provides dialog, combobox, listbox, and active-descendant semantics', () => { + for (const token of [ + 'role="dialog"', 'aria-modal="true"', 'role="combobox"', + 'aria-activedescendant', 'role="listbox"', 'role="option"', + ]) assert.ok(source.includes(token), `missing ${token}`); +}); + +test('keeps keyboard navigation visible and inert during IME composition', () => { + assert.match(source, /scrollIntoView\?\.\(\{ block: 'nearest' \}\)/); + assert.match(source, /event\.isComposing/); + assert.match(source, /event\.nativeEvent\?\.isComposing/); + assert.match(source, /event\.keyCode === 229/); + assert.match(source, /event\.target === searchRef\.current/); +}); + +test('resumes with frozen target IDs while cancellation only clears the continuation', () => { + assert.match(source, /onCancel=\{clearContinuation\}/); + assert.match(source, /frozenTargetIds: targetIds/); + assert.match(source, /input: \{ contactId \}/); +}); diff --git a/frontend/src/components/DelegatePill.jsx b/frontend/src/components/DelegatePill.jsx new file mode 100644 index 00000000..8545609d --- /dev/null +++ b/frontend/src/components/DelegatePill.jsx @@ -0,0 +1,23 @@ +import { useTranslation } from 'react-i18next'; +import { delegateLabel, delegateTooltip } from '../utils/delegation.js'; + +export default function DelegatePill({ delegation, compact = false }) { + const { t } = useTranslation(); + const label = delegateLabel(delegation); + if (!label) return null; + return ( + + + {label} + + ); +} diff --git a/frontend/src/components/DelegatePill.test.js b/frontend/src/components/DelegatePill.test.js new file mode 100644 index 00000000..6e459de2 --- /dev/null +++ b/frontend/src/components/DelegatePill.test.js @@ -0,0 +1,12 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +test('renders delegate identity on GTD, thread-list, flat-list, and open-message surfaces', () => { + const gtd = fs.readFileSync(new URL('./GtdEntryRow.jsx', import.meta.url), 'utf8'); + const list = fs.readFileSync(new URL('./MessageList.jsx', import.meta.url), 'utf8'); + const pane = fs.readFileSync(new URL('./MessagePane.jsx', import.meta.url), 'utf8'); + assert.match(gtd, / {sender} + {/* Aging pill carries the row's kind color (watch yellow / delegated orange), except when stale — staleness outranks kind and keeps the red styling. */} {isWaiting && days != null && ( diff --git a/frontend/src/components/MailApp.jsx b/frontend/src/components/MailApp.jsx index 8e2df0e8..fbfe33ba 100644 --- a/frontend/src/components/MailApp.jsx +++ b/frontend/src/components/MailApp.jsx @@ -4,18 +4,22 @@ import { useStore } from '../store/index.js'; import { api } from '../utils/api.js'; import { useWebSocket } from '../hooks/useWebSocket.js'; import { useMobile } from '../hooks/useMobile.js'; +import { useCommandRuntime } from '../hooks/useCommandRuntime.js'; import { LAYOUTS } from '../layouts.js'; import { updateFaviconBadge } from '../themes.js'; import { shortcutBus } from '../utils/shortcutBus.js'; import { setPending, pendingMarkReadMap, completedMarkReadMap } from '../utils/pendingReads.js'; -import { buildKeyMap, buildModKeyMap, getEffectiveShortcuts, getGroupedActions, parseModKey, modLabel, SPECIAL_KEYS, SPECIAL_KEY_LABELS } from '../utils/defaultShortcuts.js'; +import { formatCommandKey, getEffectiveCommandBindings } from '../commands/shortcuts.js'; import Sidebar from './Sidebar.jsx'; import MessageList from './MessageList.jsx'; import MessagePane from './MessagePane.jsx'; import GtdSidebarContent from './GtdSidebarContent.jsx'; import NotificationToasts from './NotificationToasts.jsx'; import CommandPalette from './CommandPalette.jsx'; +import { DelegateContactContinuationHost } from './DelegateContactPicker.jsx'; import { gtdActiveForContext } from '../utils/gtd.js'; +import { CommandRuntimeProvider } from '../commands/CommandRuntimeContext.jsx'; +import { commandPaletteShortcut } from '../commands/paletteShortcut.js'; const ContactsPage = lazy(() => import('./ContactsPage.jsx')); @@ -59,22 +63,26 @@ const lazyFallback = ( export default function MailApp() { const { t } = useTranslation(); + const editorPalettePressRef = useRef(null); const { setAccounts, setUnreadCounts, showAdmin, setShowAdmin, setAdminTab, composing, sidebarCollapsed, layout, - unreadCounts, selectedAccountId, openCompose, setSelectedAccount, - shortcuts, selectedMessageId, setSelectedMessage, + unreadCounts, selectedAccountId, openCompose, + selectedMessageId, setSelectedMessage, mobileSidebarOpen, setMobileSidebarOpen, addNotification, fontSize, showAppBadge, showFaviconBadge, sidebarWidth, setSidebarWidth, setIsSidebarResizing, showContacts, setTodoistConnected, accounts, rightSidebarWidth, setRightSidebarWidth, isRightSidebarResizing, setIsRightSidebarResizing, fetchGtdSections, rightSidebarHidden, toggleRightSidebarHidden, + refreshCarddavStatus, } = useStore(); const syncInterval = useStore(s => s.syncInterval); const autoLockMinutes = useStore(s => s.autoLockMinutes); const lockScreen = useStore(s => s.lockScreen); + useEffect(() => { void refreshCarddavStatus(); }, [refreshCarddavStatus]); + // Auto-lock after inactivity (#235). MailApp only mounts while unlocked, so this // timer runs only when unlocked; hitting the timeout locks and unmounts this tree. useEffect(() => { @@ -131,6 +139,11 @@ export default function MailApp() { const [showShortcutHelp, setShowShortcutHelp] = useState(false); const [paletteOpen, setPaletteOpen] = useState(false); + const commandRuntime = useCommandRuntime({ t, shortcutHelpOpen: showShortcutHelp, paletteOpen }); + useEffect(() => { + if (!commandRuntime.continuation) return; + setPaletteOpen(commandRuntime.continuation.kind !== 'contact'); + }, [commandRuntime.continuation]); const isMobile = useMobile(); const sidebarDragRef = useRef(null); const sidebarResizeRef = useRef(null); @@ -197,8 +210,13 @@ export default function MailApp() { // Shortcut hint (e.g. "⌘/") for the collapse/expand tooltips, derived from the // live shortcut map via the existing helpers — no new plumbing. '' when unbound. - const rightSidebarToggleParsed = parseModKey(getEffectiveShortcuts(shortcuts).toggleRightSidebar); - const rightSidebarToggleHint = rightSidebarToggleParsed ? `${modLabel(rightSidebarToggleParsed.mod)}${rightSidebarToggleParsed.bare}` : ''; + const rightSidebarToggleKey = getEffectiveCommandBindings( + commandRuntime.commandDefinitions, + commandRuntime.getContext(), + ).find(item => item.commandId === 'layout.toggleRightSidebar')?.bindings[0]?.keys; + const rightSidebarToggleHint = rightSidebarToggleKey + ? formatCommandKey(rightSidebarToggleKey, commandRuntime.getContext().platform) + : ''; // The right sidebar renders when a feature supplies content. GTD is the // current (only) provider; the layout/shortcut infrastructure below is // feature-agnostic and keys off the seam, not the feature. @@ -517,6 +535,7 @@ export default function MailApp() { } if (paletteOpenRef.current) { + commandRuntime.clearContinuation(); setPaletteOpen(false); return true; } @@ -542,103 +561,14 @@ export default function MailApp() { return () => { if (window.__mailflowHandleAndroidBack) delete window.__mailflowHandleAndroidBack; }; - }, [setMobileSidebarOpen, setSelectedMessage, setShowAdmin]); - - useEffect(() => { - if (isMobile) return; - const keyMap = buildKeyMap(shortcuts); - const modKeyMap = buildModKeyMap(shortcuts); - // Keys that are prefixes of two-key sequences (e.g. 'g' for 'gi'). - // Special keys like 'Delete' have length > 1 but are single keypresses — exclude them. - const prefixKeys = new Set( - Object.keys(keyMap).filter(k => k.length > 1 && !SPECIAL_KEYS.has(k)).map(k => k[0]) - ); - - let pendingKey = null; - let pendingTimer = null; - - const clearPending = () => { - pendingKey = null; - if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null; } - }; - - const handler = (e) => { - // Never intercept when the compose modal or admin panel is open, or an input is focused - if (composingRef.current || showAdminRef.current) return; - const tag = e.target.tagName; - if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || e.target.isContentEditable) return; - // Modifier combos: emit registered actions, pass everything else through - if (e.ctrlKey || e.metaKey) { - const action = modKeyMap[e.key.toLowerCase()]; - if (action) { e.preventDefault(); shortcutBus.emit(action); } - return; - } - if (e.altKey) return; - - const key = e.key; - - // Pure modifier keys — never intercept - if (['CapsLock', 'Control', 'Meta', 'Alt', 'Shift'].includes(key)) return; - - // Escape cancels any pending prefix sequence - if (key === 'Escape') { clearPending(); return; } - - // Resolve two-key sequences - let resolved = key; - if (pendingKey !== null) { - resolved = pendingKey + key; - clearPending(); - } - - // Check the keymap first — bound actions take priority, including special - // keys like Delete that would otherwise be skipped below. - const action = keyMap[resolved]; - if (action) { - e.preventDefault(); - shortcutBus.emit(action); - return; - } - - // Skip non-character keys that aren't bound (arrow keys, F-keys, etc.) - if (['Tab', 'Enter', 'Backspace', 'Delete', - 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', - 'Home', 'End', 'PageUp', 'PageDown', 'Insert', - 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', - 'F7', 'F8', 'F9', 'F10', 'F11', 'F12'].includes(key)) { - return; - } - - // Check if this single key could start a two-key sequence - if (prefixKeys.has(resolved) && resolved.length === 1) { - e.preventDefault(); - pendingKey = resolved; - pendingTimer = setTimeout(clearPending, 1000); - return; - } - - // Typed key didn't match anything — clear any stale pending state - if (pendingKey !== null) clearPending(); - }; - - document.addEventListener('keydown', handler); - return () => { - document.removeEventListener('keydown', handler); - clearPending(); - }; - }, [shortcuts, isMobile]); // Re-build key map only when shortcuts or device type changes + }, [commandRuntime, setMobileSidebarOpen, setSelectedMessage, setShowAdmin]); // Subscribe to global actions that MailApp owns useEffect(() => { - const onCompose = () => openCompose({ accountId: useStore.getState().selectedAccountId || undefined }); - const onGoInbox = () => setSelectedAccount(null, 'INBOX'); const onShowHelp = () => { if (!isMobile) setShowShortcutHelp(v => !v); }; - shortcutBus.on('compose', onCompose); - shortcutBus.on('goInbox', onGoInbox); shortcutBus.on('showHelp', onShowHelp); return () => { - shortcutBus.off('compose', onCompose); - shortcutBus.off('goInbox', onGoInbox); shortcutBus.off('showHelp', onShowHelp); }; }, []); // eslint-disable-line react-hooks/exhaustive-deps @@ -651,25 +581,49 @@ export default function MailApp() { return () => shortcutBus.off('toggleRightSidebar', onToggleRightSidebar); }, [rightSidebarApplicable, toggleRightSidebarHidden]); - // Close help overlay on Escape + // Close help overlay on Escape or the same ? shortcut that opened it. useEffect(() => { if (!showShortcutHelp) return; - const handler = (e) => { if (e.key === 'Escape') { e.preventDefault(); setShowShortcutHelp(false); } }; + const handler = (e) => { + if (e.isComposing || e.keyCode === 229) return; + if (e.key === 'Escape' || e.key === '?') { + e.preventDefault(); + e.stopImmediatePropagation(); + setShowShortcutHelp(false); + } + }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); }, [showShortcutHelp]); - // Cmd+K / Ctrl+K opens command palette + // Cmd+K / Ctrl+K toggles the command palette. In a rich-text editor, the + // first press remains available to the editor and a second press opens it. useEffect(() => { - const handler = (e) => { - if ((e.metaKey || e.ctrlKey) && e.key === 'k') { - e.preventDefault(); - setPaletteOpen(v => !v); - } + if (isMobile) return undefined; + const handler = event => { + const decision = commandPaletteShortcut({ + metaKey: event.metaKey, + ctrlKey: event.ctrlKey, + altKey: event.altKey, + key: event.key, + keyCode: event.keyCode, + isComposing: event.isComposing, + target: event.target, + isMobile, + }, editorPalettePressRef.current); + if (!decision.handled) return; + editorPalettePressRef.current = decision.nextEditorPress; + if (!decision.toggle) return; + event.preventDefault(); + event.stopPropagation(); + setPaletteOpen(open => { + if (open) commandRuntime.clearContinuation(); + return !open; + }); }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); - }, []); + }, [commandRuntime, isMobile]); // Handle same-tab OAuth callback redirects (e.g. /?oauth_success=microsoft). // The popup case (window.opener present) is handled earlier in App.jsx before @@ -702,6 +656,7 @@ export default function MailApp() { }, []); // eslint-disable-line react-hooks/exhaustive-deps return ( +
{showAdmin && } {hasNativeBridge && } - setPaletteOpen(false)} /> + { + commandRuntime.clearContinuation(); + setPaletteOpen(false); + }} /> + setPaletteOpen(false)} /> {/* Keyboard shortcut help overlay — toggled by the '?' key */} {showShortcutHelp && ( setShowShortcutHelp(false)} /> )}
+ ); } -function ShortcutHelpOverlay({ shortcuts, onClose }) { +function ShortcutHelpOverlay({ definitions, context, onClose }) { const { t } = useTranslation(); - const effective = getEffectiveShortcuts(shortcuts); - const groups = getGroupedActions(); - - const keyBadge = (key) => { - if (!key) return ; - // Modifier combos like 'ctrl+p' - const mod = parseModKey(key); - if (mod) { - return ( - - {modLabel(mod.mod)} - + - {mod.bare.toUpperCase()} - - ); - } - // Special key names like 'Delete', 'ArrowUp' — single keypress, render as one badge - if (SPECIAL_KEY_LABELS[key]) { - return {SPECIAL_KEY_LABELS[key]}; - } - // For two-key sequences like 'gi', render each key separately - const parts = key.length > 1 - ? [...key].map((c, i) => ( - - {c} - {i < key.length - 1 && {t('shortcuts.then')}} - - )) - : [{key}]; - return {parts}; + const effective = new Map(getEffectiveCommandBindings(definitions, context) + .map(item => [item.commandId, item.bindings])); + const groups = definitions.filter(definition => effective.get(definition.id)?.length).reduce((result, definition) => { + (result[definition.group] ||= []).push(definition); + return result; + }, {}); + + const keyBadge = (bindings = []) => { + if (!bindings.length) return ; + return {bindings.map(binding => ( + {formatCommandKey(binding.keys, context.platform)} + ))}; }; return ( @@ -963,15 +906,15 @@ function ShortcutHelpOverlay({ shortcuts, onClose }) { {Object.entries(groups).map(([groupName, actions]) => (
- {t(groupName)} + {groupName}
- {actions.map(({ action, descriptionKey }) => ( -
( +
- {t(descriptionKey)} - {keyBadge(effective[action])} + {t(definition.titleKey, definition.params)} + {keyBadge(effective.get(definition.id))}
))}
diff --git a/frontend/src/components/MessageList.jsx b/frontend/src/components/MessageList.jsx index 43ebe780..51b58145 100644 --- a/frontend/src/components/MessageList.jsx +++ b/frontend/src/components/MessageList.jsx @@ -13,15 +13,18 @@ import GtdTabList from './GtdTabList.jsx'; import { useUiScale, descale } from '../hooks/useUiScale.js'; import { gtdActiveForContext, buildGtdDisplaySections, GTD_COLORS, GTD_CHIP_BG, sectionBadge, isSelectedRow, - classifyThread, unclassifyThread, + unclassifyThread, } from '../utils/gtd.js'; import { formatDate } from '../utils/formatDate.js'; -import { openReplyFromMessage, openForwardFromMessage } from '../utils/composeFromMessage.js'; import SenderAvatarImage from './SenderAvatarImage.jsx'; import { shortcutBus } from '../utils/shortcutBus.js'; import { createLatestRequest } from '../utils/latestRequest.js'; -import { pendingMarkReadMap, completedMarkReadMap, setPending } from '../utils/pendingReads.js'; -import { applyDeleteGuard, clearDeleteGuard, clearPendingDelete, setCompletedDelete, setPendingDelete } from '../utils/pendingDeletes.js'; +import { pendingMarkReadMap, completedMarkReadMap } from '../utils/pendingReads.js'; +import { applyDeleteGuard } from '../utils/pendingDeletes.js'; +import { stableConversationId } from '../commands/contracts.js'; +import { contextMenuTargetMessages } from '../commands/contextMenuCommands.js'; +import DelegatePill from './DelegatePill.jsx'; +import { useCommandRuntimeContext } from '../commands/CommandRuntimeContext.jsx'; // Folder icon for move picker function FolderIcon({ specialUse, size = 13 }) { @@ -59,6 +62,21 @@ const SWIPE_ACTIONS = { disabled: { color: 'transparent' }, }; +const SWIPE_COMMANDS = Object.freeze({ + archive: 'mail.archive', + delete: 'mail.trash', + star: 'mail.toggleStar', + markRead: 'mail.toggleRead', + reply: 'mail.reply', + replyAll: 'mail.replyAll', +}); + +const THREAD_EXPANDING_COMMANDS = new Set([ + 'mail.archive', 'mail.snooze', 'mail.move', 'mail.read', 'mail.unread', 'mail.toggleRead', + 'mail.star', 'mail.unstar', 'mail.toggleStar', 'mail.trash', 'mail.spam', 'mail.notSpam', + 'gtd.delegate', +]); + function getSwipeActionView(action, message, t, unreadCount = null) { const unread = unreadCount != null ? unreadCount > 0 : !message.is_read; if (action === 'archive') return { label: t('message.archive'), color: SWIPE_ACTIONS.archive.color, icon: 'archive' }; @@ -100,13 +118,14 @@ function SwipeBackground({ side, actionView, innerRef }) { export default function MessageList() { const { t } = useTranslation(); + const { controller: commandController } = useCommandRuntimeContext(); const uiScale = useUiScale(); const { selectedAccountId, selectedFolder, messages, setMessages, appendMessages, messagesTotal, setMessagesTotal, setMessagesOffset, hasMoreMessages, setHasMoreMessages, loadingMessages, setLoadingMessages, selectedMessageId, lastViewedMessageId, - setSelectedMessage, updateMessage, removeMessage, removeMessages, + setSelectedMessage, updateMessage, removeMessage, decrementUnread, incrementUnread, addNotification, notifications, removeNotification, searchQuery, setSearchQuery, setIsSearching, searchResults, setSearchResults, openCompose, accountsReady, accounts, @@ -117,7 +136,7 @@ export default function MessageList() { hoverQuickActions, showMobileAvatars, swipeActions, folders, favoriteFolders, addFavoriteFolder, removeFavoriteFolder, setSelectedAccount, - categorizationEnabled, categoryCounts, setCategoryCounts, adjustCategoryCount, + categorizationEnabled, categoryCounts, setCategoryCounts, markReadBehavior, markReadDelay, searchAllFolders, activeGtdTab, setActiveGtdTab, gtdSections, @@ -125,6 +144,8 @@ export default function MessageList() { // RFC message_id of the open message, so a row highlights when it is a different DB copy // of the selected message (multi-folder model) — e.g. the inbox copy of a GTD sidebar click. const selectedMid = useStore(selectSelectedMessageMid); + const selectedIds = useStore(state => state.selectedMessageIds); + const setSelectedIds = useStore(state => state.setSelectedMessageIds); const isMobile = useMobile(); const isUnified = selectedAccountId === null; @@ -183,12 +204,10 @@ export default function MessageList() { const searchFetchedOffsetRef = useRef(0); const listRef = useRef(null); const searchInputRef = useRef(null); // for focusSearch shortcut - const pendingDeleteTimers = useRef(new Map()); // id/thread key -> pending delete metadata const recentMessageOpenUntilRef = useRef(0); const deferredRefreshTimerRef = useRef(null); // Bulk selection state - const [selectedIds, setSelectedIds] = useState(new Set()); const [selectionModeActive, setSelectionModeActive] = useState(false); const [showFolderPicker, setShowFolderPicker] = useState(false); const [pickerFolders, setPickerFolders] = useState([]); @@ -204,7 +223,7 @@ export default function MessageList() { const layoutPickerRef = useRef(null); useEffect(() => { currentPageRef.current = currentPage; }, [currentPage]); - useEffect(() => { setActiveCategory('primary'); setActiveGtdTab(null); }, [selectedAccountId, selectedFolder, setActiveGtdTab]); + useEffect(() => { setActiveCategory('primary'); }, [selectedAccountId, selectedFolder]); useEffect(() => { const markOpening = () => { recentMessageOpenUntilRef.current = Date.now() + 1500; @@ -267,9 +286,7 @@ export default function MessageList() { // Ref that always holds the latest values needed by shortcut handlers. // Updated synchronously on every render so handlers are never stale. const scRef = useRef({}); - scRef.current = { messages, selectedIds, setSelectedIds, updateMessage, decrementUnread, addNotification }; - const tRef = useRef(t); - useEffect(() => { tRef.current = t; }, [t]); + scRef.current = { selectedIds, setSelectedIds }; // Clear selection whenever the message list resets (nav, folder change, etc.) useEffect(() => { @@ -277,7 +294,7 @@ export default function MessageList() { setSelectionModeActive(false); setShowFolderPicker(false); lastSelectIdxRef.current = -1; - }, [messagesRefreshToken]); + }, [messagesRefreshToken, setSelectedIds]); // Escape clears selection; click-outside closes folder picker useEffect(() => { @@ -303,7 +320,7 @@ export default function MessageList() { document.removeEventListener('keydown', onKey); document.removeEventListener('pointerdown', onPointer); }; - }, []); + }, [setSelectedIds]); useEffect(() => { if (!showFolderPicker) setPickerSearch(''); @@ -522,29 +539,6 @@ export default function MessageList() { } }, [searchQuery, selectedAccountId, searchFolder, searchPageSize, searchLoadingMore, applyReadGuard]); - const prefetchSearchAfterRemoval = useCallback(async (offset) => { - const qSnapshot = useStore.getState().searchQuery; - if (!qSnapshot.trim()) return; - try { - const data = await api.search(qSnapshot, selectedAccountId || undefined, { offset, limit: searchPageSize, folder: searchFolder }); - if (useStore.getState().searchQuery !== qSnapshot) return; - searchFetchedOffsetRef.current = Math.max(searchFetchedOffsetRef.current, offset + data.messages.length); - const additions = applyReadGuard(data.messages); - if (!additions.length) { - setSearchHasMore(data.messages.length === searchPageSize); - return; - } - useStore.setState(state => { - const existing = new Set(state.searchResults.map(m => m.id)); - const missing = additions.filter(m => m && !existing.has(m.id)); - return missing.length ? { searchResults: [...state.searchResults, ...missing] } : {}; - }); - setSearchHasMore(data.messages.length === searchPageSize); - } catch (err) { - console.error('Search prefetch after delete failed:', err); - } - }, [selectedAccountId, searchFolder, searchPageSize, applyReadGuard]); - // Infinite scroll + scroll-to-top visibility const handleScroll = useCallback(() => { if (!listRef.current) return; @@ -696,168 +690,43 @@ export default function MessageList() { } const effectiveFolder = selectedAccountId ? selectedFolder : 'INBOX'; const data = await api.getThread(tid, effectiveFolder, isUnified); - return data.messages?.length ? data.messages : [message]; - }, [isThreadListRow, threadMessages, selectedAccountId, selectedFolder, isUnified]); - - const setCachedThreadRead = useCallback((message, read) => { - const tid = message.thread_id || message.id; - if (threadMessages[tid]) { - setThreadMessages(tid, threadMessages[tid].map(msg => ({ ...msg, is_read: read }))); - } - }, [threadMessages, setThreadMessages]); - - const setCachedThreadStarred = useCallback((message, starred) => { - const tid = message.thread_id || message.id; - if (threadMessages[tid]) { - setThreadMessages(tid, threadMessages[tid].map(msg => ({ ...msg, is_starred: starred }))); - } - }, [threadMessages, setThreadMessages]); - - const setMessagesReadState = useCallback(async (message, read) => { - const isThreadRow = isThreadListRow(message); - const unreadCount = Number.parseInt(message.unread_count, 10); - // Use the row's own unread_count as the immediate estimate. - // For thread rows this is the aggregate already present on the row; - // for single messages it is always 1 (or 0 if already in the target state). - const estimatedDelta = isThreadRow && Number.isFinite(unreadCount) ? unreadCount : 1; - - // Immediate optimistic update — do not wait for thread resolution. - // For unexpanded thread rows this avoids a visible delay caused by the - // api.getThread call inside resolveMessagesForThreadAction. - if (isThreadRow) { - updateMessage(message.id, { is_read: read, unread_count: read ? 0 : estimatedDelta }); - // setCachedThreadRead intentionally deferred until after resolution so - // that actionMessages still reflects the pre-update sub-message states, - // letting us compute the exact delta for any needed correction. - } else { - updateMessage(message.id, { is_read: read, unread_count: read ? 0 : 1 }); - } - if (read) { - if (estimatedDelta > 0) { - decrementUnread(message.account_id, estimatedDelta); - adjustCategoryCount(message.category, -estimatedDelta); - } - } else { - if (estimatedDelta > 0) { - incrementUnread(message.account_id, estimatedDelta); - adjustCategoryCount(message.category, estimatedDelta); - } - } - - // Resolve the individual sub-messages needed for the bulk API call. - // For unexpanded thread rows this fires api.getThread, but the UI has - // already updated above so the user sees no delay. - let actionMessages; - try { - actionMessages = await resolveMessagesForThreadAction(message); - } catch (err) { - console.error('Failed to load thread for read state change:', err.message); - // Revert the optimistic update - if (isThreadRow) { - updateMessage(message.id, { is_read: !read, unread_count: !read ? 0 : estimatedDelta }); - } else { - updateMessage(message.id, { is_read: !read, unread_count: !read ? 0 : 1 }); - } - if (read && estimatedDelta > 0) { incrementUnread(message.account_id, estimatedDelta); adjustCategoryCount(message.category, estimatedDelta); } - else if (!read && estimatedDelta > 0) { decrementUnread(message.account_id, estimatedDelta); adjustCategoryCount(message.category, -estimatedDelta); } - return; - } - - // Compute exact delta from sub-message states (before mutating the cache). - const actualDelta = read - ? actionMessages.filter(msg => !msg.is_read).length - : actionMessages.filter(msg => msg.is_read).length; - - // Now update the thread cache and correct the parent row if our estimate was off. - if (isThreadRow) { - setCachedThreadRead(message, read); - if (actualDelta !== estimatedDelta) { - updateMessage(message.id, { is_read: read, unread_count: read ? 0 : actionMessages.length }); - } - } - - // Correct the sidebar badge if the estimate differed from the actual count. - if (actualDelta !== estimatedDelta) { - const diff = actualDelta - estimatedDelta; - if (read) { - if (diff > 0) decrementUnread(message.account_id, diff); - else incrementUnread(message.account_id, -diff); - } else { - if (diff > 0) incrementUnread(message.account_id, diff); - else decrementUnread(message.account_id, -diff); - } - adjustCategoryCount(message.category, read ? -diff : diff); - } - - if (read) { - actionMessages.forEach(msg => setPending(msg.id, msg.account_id)); - } else { - actionMessages.forEach(msg => { - pendingMarkReadMap.delete(msg.id); - completedMarkReadMap.delete(msg.id); - }); - } - - try { - await api.bulkRead(actionMessages.map(msg => msg.id), read); - if (read) { - actionMessages.forEach(msg => { - pendingMarkReadMap.delete(msg.id); - completedMarkReadMap.set(msg.id, msg.account_id); - setTimeout(() => completedMarkReadMap.delete(msg.id), 10000); + const resolved = data.messages?.length ? data.messages : [message]; + if (resolved.length > 1) setThreadMessages(tid, resolved); + return resolved; + }, [isThreadListRow, threadMessages, selectedAccountId, selectedFolder, isUnified, setThreadMessages]); + + const executeForMessages = useCallback(async (commandId, source, targetMessages, input) => { + let actionableMessages = targetMessages; + if (THREAD_EXPANDING_COMMANDS.has(commandId)) { + try { + actionableMessages = (await Promise.all( + targetMessages.map(message => resolveMessagesForThreadAction(message)), + )).flat(); + } catch (error) { + addNotification({ + type: 'error', + title: t('commandPalette.outcome.failedTitle'), + body: error instanceof Error ? error.message : String(error), }); - } - } catch (err) { - console.error('markRead failed:', err); - if (isThreadRow) { - updateMessage(message.id, { is_read: !read, unread_count: read ? actualDelta : 0 }); - setCachedThreadRead(message, !read); - } else { - updateMessage(message.id, { is_read: !read, unread_count: read ? 1 : 0 }); - } - if (read) { - if (actualDelta > 0) { incrementUnread(message.account_id, actualDelta); adjustCategoryCount(message.category, actualDelta); } - actionMessages.forEach(msg => pendingMarkReadMap.delete(msg.id)); - } else if (actualDelta > 0) { - decrementUnread(message.account_id, actualDelta); - adjustCategoryCount(message.category, -actualDelta); + return { status: 'failed', error }; } } - }, [ - resolveMessagesForThreadAction, isThreadListRow, updateMessage, setCachedThreadRead, - decrementUnread, incrementUnread, adjustCategoryCount, - ]); + const frozenTargetIds = [...new Set(actionableMessages.map(stableConversationId).filter(Boolean))]; + return commandController.execute(commandId, { + source, + input, + frozenTargetIds, + }); + }, [addNotification, commandController, resolveMessagesForThreadAction, t]); const handleMarkRead = (e, message) => { e.stopPropagation(); - setMessagesReadState(message, !message.is_read); + executeForMessages('mail.toggleRead', 'hover', [message]); }; - const setMessagesStarredState = useCallback(async (message, starred) => { - let actionMessages; - try { - actionMessages = await resolveMessagesForThreadAction(message); - } catch (err) { - console.error('Failed to load thread for star state change:', err.message); - return; - } - - const isThreadRow = isThreadListRow(message); - updateMessage(message.id, { is_starred: starred }); - if (isThreadRow) setCachedThreadStarred(message, starred); - - try { - await Promise.all(actionMessages.map(msg => api.markStarred(msg.id, starred))); - } catch (err) { - console.error('markStarred failed:', err.message); - updateMessage(message.id, { is_starred: !starred }); - if (isThreadRow) setCachedThreadStarred(message, !starred); - } - }, [resolveMessagesForThreadAction, isThreadListRow, updateMessage, setCachedThreadStarred]); - const handleStar = (e, message) => { e.stopPropagation(); - setMessagesStarredState(message, !message.is_starred); + executeForMessages('mail.toggleStar', 'hover', [message]); }; // GTD "done" from the inbox hover cluster (all-states mode): the backend marks the thread @@ -889,420 +758,15 @@ export default function MessageList() { } }, [removeMessage, decrementUnread, incrementUnread, addNotification, t]); - // Undo-able delete: optimistically remove, delay the API call by 4.5s so user can undo - const scheduleDelete = useCallback(async (message) => { - const tid = message.thread_id || message.id; - const isThreadRow = isThreadListRow(message); - const key = isThreadRow ? `thread:${tid}` : message.id; - if (pendingDeleteTimers.current.has(key)) return; - - let deleteMessages = [message]; - try { - deleteMessages = await resolveMessagesForThreadAction(message); - } catch (err) { - console.error('Failed to load thread for delete:', err.message); - addNotification({ type: 'error', title: t('messageList.deleted.failTitle'), body: t('messageList.deleted.failBody') }); - return; - } - - const ids = [...new Set(deleteMessages.map(msg => msg.id).filter(Boolean))]; - const visibleMessage = message; - ids.forEach((id) => setPendingDelete(id)); - - // Advance selection to the next visible message before removing this one - const { selectedMessageId, setSelectedMessage } = useStore.getState(); - if (selectedMessageId === visibleMessage.id) { - const displayMsgs = scRef.current.displayMessages || []; - const idx = displayMsgs.findIndex(m => m.id === visibleMessage.id); - const next = displayMsgs[idx + 1] || displayMsgs[idx - 1] || null; - setSelectedMessage(next?.id ?? null); - } - - removeMessage(visibleMessage.id); - if (expandedThreadId === tid) setExpandedThreadId(null); - - const unreadCount = Number.parseInt(message.unread_count, 10); - const unreadDelta = Number.isFinite(unreadCount) - ? unreadCount - : deleteMessages.filter(msg => !msg.is_read).length; - if (unreadDelta > 0) decrementUnread(message.account_id, unreadDelta); - - const timer = setTimeout(async () => { - pendingDeleteTimers.current.delete(key); - try { - if (ids.length > 1) { - const result = await api.bulkDelete(ids); - const deletedSet = new Set(result.deleted ?? []); - ids.forEach(id => (deletedSet.has(id) ? setCompletedDelete(id) : clearDeleteGuard(id))); - const failedIds = ids.filter(id => !deletedSet.has(id)); - if (failedIds.length > 0) { - const idToMsg = new Map(deleteMessages.map(m => [m.id, m])); - const failedUnreadDelta = failedIds.filter(id => idToMsg.has(id) && !idToMsg.get(id).is_read).length; - useStore.getState().restoreMessages([visibleMessage]); - if (failedUnreadDelta > 0) incrementUnread(message.account_id, failedUnreadDelta); - addNotification({ - type: 'error', - title: t('messageList.bulkDeleted.failTitle'), - body: t('messageList.bulkDeleted.failBody', { count: failedIds.length }), - }); - } - } else { - await api.deleteMessage(ids[0] || visibleMessage.id); - ids.forEach((id) => setCompletedDelete(id)); - } - } catch { - ids.forEach((id) => clearDeleteGuard(id)); - useStore.getState().restoreMessages([visibleMessage]); - if (unreadDelta > 0) incrementUnread(message.account_id, unreadDelta); - addNotification({ - type: 'error', - title: ids.length > 1 ? t('messageList.bulkDeleted.failTitle') : t('messageList.deleted.failTitle'), - body: ids.length > 1 ? t('messageList.bulkDeleted.failBody', { count: ids.length }) : t('messageList.deleted.failBody'), - }); - } - }, 4500); - pendingDeleteTimers.current.set(key, { timer, message: visibleMessage, ids }); - addNotification({ - title: ids.length > 1 ? t('messageList.bulkDeleted.title', { count: ids.length }) : t('messageList.deleted.title'), - body: ids.length > 1 ? t('messageList.bulkDeleted.body') : t('messageList.deleted.body'), - onUndo: () => { - const pending = pendingDeleteTimers.current.get(key); - if (!pending) return; - clearTimeout(pending.timer); - pendingDeleteTimers.current.delete(key); - ids.forEach((id) => clearPendingDelete(id)); - useStore.getState().restoreMessages([visibleMessage]); - if (unreadDelta > 0) incrementUnread(message.account_id, unreadDelta); - }, - }); - }, [ - isThreadListRow, expandedThreadId, resolveMessagesForThreadAction, - removeMessage, setExpandedThreadId, decrementUnread, incrementUnread, - addNotification, t, - ]); - - // Antispam helpers (v0.1). - // - // Strategy for both mark-as-spam and mark-as-ham: - // 1. Optimistically remove the message(s) from the visible list and decrement - // unread counts — same pattern as scheduleDelete above. - // 2. Show a toast with Undo (4.5s window). Undo restores the message locally - // and cancels the API call via a per-message timer. - // 3. After the timer fires, call api.markSpam / api.markHam per id in parallel - // (Promise.allSettled). On any failure, restore the messages that failed - // and show an error toast. - // - // Bulk is handled by collecting `messages` from selectedIds if multiple are - // selected; the caller (handleContextAction) decides which set to pass. - - const performSpamLabel = useCallback(async (messages, label) => { - if (!messages.length) return; - const ids = messages.map(m => m.id); - const isBulk = ids.length > 1; - - // Optimistic local update: remove from view + drop unread badge. - const unreadCount = messages.reduce((sum, m) => sum + (m.is_read ? 0 : 1), 0); - const accountId = messages[0].account_id; - messages.forEach(m => removeMessage(m.id)); - if (unreadCount > 0) decrementUnread(accountId, unreadCount); - - // Folder-aware unread badge updates for the sidebar. - // - // For spam (move INTO the junk folder) we decrement whichever folder the - // messages came from (typically INBOX, but possibly some other folder the - // user is in). For ham (move OUT of junk into inbox) we decrement the - // source folder (junk) and increment the destination folder (inbox). - // - // We aggregate by folder path because a bulk action may touch messages - // from different folders in theory (the UI currently only selects from - // one folder at a time, but the data model allows otherwise). - const { adjustFolderUnread } = useStore.getState(); - const account = accounts.find(a => a.id === accountId); - const spamDest = account?.folder_mappings?.spam; - const inboxDest = account?.folder_mappings?.inbox || 'INBOX'; - const unreadBySource = new Map(); - const unreadByHamSource = new Map(); // for ham: track source folder - messages.forEach(m => { - if (m.is_read) return; - const src = m.folder; - if (!src) return; - if (label === 'spam') { - unreadBySource.set(src, (unreadBySource.get(src) || 0) + 1); - } else if (label === 'ham') { - unreadByHamSource.set(src, (unreadByHamSource.get(src) || 0) + 1); - } - }); - if (label === 'spam') { - // origin folder loses its unread messages; junk gains them - for (const [src, n] of unreadBySource) adjustFolderUnread(accountId, src, -n); - if (spamDest && unreadCount > 0) adjustFolderUnread(accountId, spamDest, +unreadCount); - } else if (label === 'ham') { - // junk loses them; inbox gains them - for (const [src, n] of unreadByHamSource) adjustFolderUnread(accountId, src, -n); - if (inboxDest && unreadCount > 0) adjustFolderUnread(accountId, inboxDest, +unreadCount); - } - - // Per-id timer map so Undo can cancel any pending API call. - const timers = new Map(); - let settled = false; - const undo = () => { - settled = true; - timers.forEach(timer => clearTimeout(timer)); - timers.clear(); - // Restore the messages in their original position (re-sort by date). - useStore.getState().restoreMessages(messages); - if (unreadCount > 0) incrementUnread(accountId, unreadCount); - // Reverse the folder badge adjustments so undo behaves like the move - // never happened. - if (label === 'spam') { - for (const [src, n] of unreadBySource) adjustFolderUnread(accountId, src, +n); - if (spamDest && unreadCount > 0) adjustFolderUnread(accountId, spamDest, -unreadCount); - } else if (label === 'ham') { - for (const [src, n] of unreadByHamSource) adjustFolderUnread(accountId, src, +n); - if (inboxDest && unreadCount > 0) adjustFolderUnread(accountId, inboxDest, -unreadCount); - } - }; - - const performCall = (id) => { - const fn = label === 'spam' ? api.markSpam : api.markHam; - return fn(id).catch(err => ({ __failed: true, id, message: err.message })); - }; - - timers.set('__call__', setTimeout(async () => { - if (settled) return; - timers.delete('__call__'); - const results = await Promise.allSettled(ids.map(performCall)); - const failed = []; - results.forEach((r, i) => { - if (r.status === 'rejected' || r.value?.__failed) failed.push(ids[i]); - }); - if (failed.length) { - const failedMsgs = messages.filter(m => failed.includes(m.id)); - useStore.getState().restoreMessages(failedMsgs); - const failedUnread = failedMsgs.reduce((sum, m) => sum + (m.is_read ? 0 : 1), 0); - if (failedUnread > 0) incrementUnread(accountId, failedUnread); - const titleKey = label === 'spam' ? 'spam.failTitle' : 'spam.failHamTitle'; - const bodyKey = label === 'spam' ? 'spam.failBody' : 'spam.failHamBody'; - addNotification({ - type: 'error', - title: t(titleKey), - body: isBulk ? t('spam.failBodyBulk', { count: failed.length }) : t(bodyKey), - }); - } - // Safety net: after the IMAP move actually completes (or partially - // fails), reconcile sidebar counts and folder badges with the server. - // Even if our optimistic math was right, edge cases like the user - // moving messages between two folders that share a parent, or a - // concurrent IMAP IDLE update, can desync the local counters. - api.getUnreadCounts().then(c => useStore.getState().setUnreadCounts(c)).catch(() => {}); - api.getFolders(accountId).then(f => useStore.getState().setFolders(accountId, f)).catch(() => {}); - }, 4500)); - - addNotification({ - title: label === 'spam' - ? (isBulk ? t('spam.movedToSpamBulk', { count: ids.length }) : t('spam.movedToSpam')) - : (isBulk ? t('spam.movedToInboxBulk', { count: ids.length }) : t('spam.movedToInbox')), - body: messages[0].subject || t('common.noSubject'), - onUndo: undo, - }); - }, [removeMessage, decrementUnread, incrementUnread, addNotification, t, accounts]); - - // On page unload (refresh/close), fire pending deletes with keepalive:true so the - // browser completes the request even after the page tears down. Clears the map so - // the unmount cleanup below does not double-fire on normal navigation. - useEffect(() => { - const handleBeforeUnload = () => { - pendingDeleteTimers.current.forEach(({ timer, message, ids }) => { - clearTimeout(timer); - const deleteIds = ids?.length ? ids : [message.id]; - try { - if (deleteIds.length > 1) { - fetch('/api/mail/messages/bulk-delete', { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'MailFlow' }, - body: JSON.stringify({ ids: deleteIds }), - keepalive: true, - }); - } else { - fetch(`/api/mail/messages/${deleteIds[0]}`, { - method: 'DELETE', - credentials: 'include', - headers: { 'X-Requested-With': 'MailFlow' }, - keepalive: true, - }); - } - } catch { /* keepalive not supported — best effort */ } - }); - pendingDeleteTimers.current.clear(); - }; - window.addEventListener('beforeunload', handleBeforeUnload); - return () => window.removeEventListener('beforeunload', handleBeforeUnload); - }, []); - - // On normal unmount (navigating away), immediately fire any pending deletes. - // Navigating away during the 4.5s undo window should still delete the message — - // cancelling the timer would silently leave it on the server. - // (Page refresh is handled by the beforeunload listener above which clears the map first.) - useEffect(() => () => { - pendingDeleteTimers.current.forEach(({ timer, message, ids }) => { - clearTimeout(timer); - const deleteIds = ids?.length ? ids : [message.id]; - const deletePromise = - deleteIds.length > 1 - ? api.bulkDelete(deleteIds) - : api.deleteMessage(deleteIds[0]); - deletePromise - .then(result => { - const actuallyDeleted = new Set(result?.deleted ?? deleteIds); - deleteIds.forEach(id => (actuallyDeleted.has(id) ? setCompletedDelete(id) : clearDeleteGuard(id))); - }) - .catch(() => { deleteIds.forEach((id) => clearDeleteGuard(id)); }); - }); - }, []); - const handleDelete = (e, message) => { e.stopPropagation(); - scheduleDelete(message); + executeForMessages('mail.trash', 'hover', [message]); }; - // Mobile swipe action handlers (no event object needed) - const handleSwipeDelete = useCallback((message) => { - scheduleDelete(message); - }, [scheduleDelete]); - - const handleSwipeToggleRead = useCallback(async (message) => { - const unreadCount = Number.parseInt(message.unread_count, 10); - const hasThreadUnreadCount = Number.isFinite(unreadCount); - const isUnread = hasThreadUnreadCount ? unreadCount > 0 : !message.is_read; - await setMessagesReadState(message, isUnread); - }, [setMessagesReadState]); - - const handleSwipeArchive = useCallback(async (message) => { - refreshRequestRef.current.invalidate(); - advanceSelectionAfterRemoval(message.id); - removeMessage(message.id); - if (!message.is_read) decrementUnread(message.account_id); - let undone = false; - const timer = setTimeout(async () => { - if (undone) return; - try { - await api.bulkArchive([message.id]); - } catch (err) { - console.error('swipe archive failed:', err.message); - } - }, 4500); - addNotification({ - title: t('messageList.bulkArchived.title', { count: 1 }), - body: message.subject || '', - onUndo: () => { - undone = true; - clearTimeout(timer); - useStore.getState().restoreMessages([message]); - if (!message.is_read) incrementUnread(message.account_id); - }, - }); - }, [removeMessage, decrementUnread, incrementUnread, addNotification, t]); - - const handleSwipeStar = useCallback((message) => { - setMessagesStarredState(message, !message.is_starred); - }, [setMessagesStarredState]); - - const handleSwipeReply = useCallback((message, replyAll = false) => { - const replyToArr = Array.isArray(message.reply_to) - ? message.reply_to - : (() => { try { return JSON.parse(message.reply_to || '[]'); } catch { return []; } })(); - const replyTarget = (replyToArr.length && replyToArr[0].email) - ? replyToArr[0] - : { name: message.from_name || '', email: message.from_email || '' }; - const sender = replyTarget.email ? [replyTarget] : []; - - const myAccount = accounts.find(a => a.id === message.account_id); - const myEmail = myAccount?.email_address || ''; - const myAddresses = new Set([ - myEmail.toLowerCase(), - ...(myAccount?.aliases || []).map(al => al.email.toLowerCase()), - ]); - - const replyAliasId = (() => { - const aliases = myAccount?.aliases || []; - if (!aliases.length) return null; - try { - const toArr = Array.isArray(message.to_addresses) - ? message.to_addresses - : JSON.parse(message.to_addresses || '[]'); - const ccArr = Array.isArray(message.cc_addresses) - ? message.cc_addresses - : JSON.parse(message.cc_addresses || '[]'); - const allEmails = [...toArr, ...ccArr].map(t => t.email?.toLowerCase()).filter(Boolean); - const fromEmail = (message.from_email || '').toLowerCase(); - const match = aliases.find(al => { - const aliasEmail = al.email.toLowerCase(); - return allEmails.includes(aliasEmail) || fromEmail === aliasEmail; - }); - return match ? match.id : null; - } catch { return null; } - })(); - - const allRecipients = (() => { - try { - const toArr = Array.isArray(message.to_addresses) - ? message.to_addresses - : JSON.parse(message.to_addresses || '[]'); - const ccArr = Array.isArray(message.cc_addresses) - ? message.cc_addresses - : JSON.parse(message.cc_addresses || '[]'); - return [...toArr, ...ccArr].filter( - t => t.email && !myAddresses.has(t.email.toLowerCase()) && t.email !== replyTarget.email - ); - } catch { return []; } - })(); - - const referencesChain = [message.in_reply_to, message.message_id] - .filter(Boolean).join(' ').trim() || null; - const rawSubject = (message.subject || '').trim(); - - openCompose({ - to: sender, - cc: replyAll ? allRecipients : [], - subject: rawSubject.startsWith('Re:') ? rawSubject : rawSubject ? `Re: ${rawSubject}` : 'Re:', - body: '', - quotedBody: '', - inReplyTo: message.message_id, - references: referencesChain, - accountId: message.account_id, - aliasId: replyAliasId, - isReply: true, - isReplyAll: replyAll, - originalFrom: sender, - allRecipients, - }); - }, [accounts, openCompose]); - const runSwipeAction = useCallback((action, message) => { - switch (action) { - case 'archive': - handleSwipeArchive(message); - break; - case 'delete': - handleSwipeDelete(message); - break; - case 'star': - handleSwipeStar(message); - break; - case 'markRead': - handleSwipeToggleRead(message); - break; - case 'reply': - handleSwipeReply(message, false); - break; - case 'replyAll': - handleSwipeReply(message, true); - break; - default: - break; - } - }, [handleSwipeArchive, handleSwipeDelete, handleSwipeReply, handleSwipeStar, handleSwipeToggleRead]); + const commandId = SWIPE_COMMANDS[action]; + if (commandId) executeForMessages(commandId, 'swipe', [message]); + }, [executeForMessages]); // ── Bulk selection helpers ─────────────────────────────────── const toggleSelect = useCallback((id) => { @@ -1311,18 +775,18 @@ export default function MessageList() { next.has(id) ? next.delete(id) : next.add(id); return next; }); - }, []); + }, [setSelectedIds]); const selectAll = useCallback((msgs) => { setSelectedIds(new Set(msgs.map(m => m.id))); - }, []); + }, [setSelectedIds]); const clearSelection = useCallback(() => { setSelectedIds(new Set()); setSelectionModeActive(false); setShowFolderPicker(false); lastSelectIdxRef.current = -1; - }, []); + }, [setSelectedIds]); // Derived from store — must be declared before callbacks that use it in dependency arrays const displayMessages = searchQuery.trim() ? searchResults : messages; @@ -1347,14 +811,16 @@ export default function MessageList() { } return results; })(); - // Keep scRef in sync so scheduleDelete can read displayMessages without a stale closure - scRef.current.displayMessages = displayMessages; - // Arrow-key navigation: intercepts ArrowDown/ArrowUp when the list container has focus. const handleListKeyDown = useCallback((e) => { - if (e.key === 'ArrowDown') { e.preventDefault(); shortcutBus.emit('nextMessage'); } - else if (e.key === 'ArrowUp') { e.preventDefault(); shortcutBus.emit('prevMessage'); } - }, []); + if (e.key === 'ArrowDown') { + e.preventDefault(); + commandController.execute('navigation.nextConversation', { source: 'list-keydown' }); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + commandController.execute('navigation.previousConversation', { source: 'list-keydown' }); + } + }, [commandController]); // Called when the avatar is clicked: enters selection mode and selects that message const handleAvatarClick = useCallback((id) => { @@ -1366,7 +832,7 @@ export default function MessageList() { next.has(id) ? next.delete(id) : next.add(id); return next; }); - }, [displayMessages]); + }, [displayMessages, setSelectedIds]); // Called for normal (non-shift) row checkbox toggles — tracks anchor for range select const handleRowToggleSelect = useCallback((id) => { @@ -1377,7 +843,7 @@ export default function MessageList() { next.has(id) ? next.delete(id) : next.add(id); return next; }); - }, [displayMessages]); + }, [displayMessages, setSelectedIds]); // Called on shift-click: selects all rows between anchor and current index const handleRangeSelect = useCallback((id) => { @@ -1393,133 +859,19 @@ export default function MessageList() { return next; }); lastSelectIdxRef.current = clickedIdx; - }, [displayMessages]); - - const handleBulkDelete = useCallback(async (ids, msgs) => { - const key = `bulk:${ids[0]}`; - // Selected thread rows delete the whole conversation, matching the - // single-row delete path — without this only each thread's visible - // (newest) message was deleted and the rest of the thread survived. - let deleteIds = ids; - try { - const resolved = await Promise.all(msgs.map(m => resolveMessagesForThreadAction(m))); - deleteIds = [...new Set([...ids, ...resolved.flat().map(m => m?.id).filter(Boolean)])]; - } catch (err) { - console.error('Failed to load thread for bulk delete:', err.message); - } - const searchOffsetBeforeRemoval = searchFetchedOffsetRef.current; - const shouldPrefetchSearch = Boolean(useStore.getState().searchQuery.trim() && searchHasMore); - deleteIds.forEach(id => setPendingDelete(id)); - ids.forEach(id => removeMessage(id)); - if (shouldPrefetchSearch) { - prefetchSearchAfterRemoval(searchOffsetBeforeRemoval); - } - msgs.forEach(msg => { - const delta = parseInt(msg.unread_count) || (msg.is_read ? 0 : 1); - if (delta > 0) decrementUnread(msg.account_id, delta); - }); - setSelectedIds(new Set()); + }, [displayMessages, setSelectedIds]); + + const handleBulkDelete = useCallback((_ids, msgs) => { setSelectionModeActive(false); setShowFolderPicker(false); - let undone = false; - const timer = setTimeout(async () => { - pendingDeleteTimers.current.delete(key); - if (undone) return; - const chunks = []; - for (let i = 0; i < deleteIds.length; i += 500) chunks.push(deleteIds.slice(i, i + 500)); - const results = await Promise.allSettled(chunks.map(chunk => api.bulkDelete(chunk))); - results - .filter(r => r.status === 'rejected') - .forEach(r => console.error('Bulk delete failed:', r.reason?.message)); - const deleted = results - .filter(r => r.status === 'fulfilled') - .flatMap(r => r.value.deleted ?? []); - const deletedSet = new Set(deleted); - deleteIds.forEach(id => (deletedSet.has(id) ? setCompletedDelete(id) : clearDeleteGuard(id))); - const failedIds = deleteIds.filter(id => !deletedSet.has(id)); - if (failedIds.length > 0) { - const failedSet = new Set(failedIds); - const failedMsgs = msgs.filter(msg => failedSet.has(msg.id)); - useStore.getState().restoreMessages(failedMsgs); - failedMsgs.forEach(msg => { - const delta = parseInt(msg.unread_count) || (msg.is_read ? 0 : 1); - if (delta > 0) incrementUnread(msg.account_id, delta); - }); - addNotification({ type: 'error', title: t('messageList.bulkDeleted.failTitle'), body: t('messageList.bulkDeleted.failBody', { count: failedIds.length }) }); - } - if (useStore.getState().searchQuery.trim()) { - setSearchReloadToken(token => token + 1); - } - }, 4500); - pendingDeleteTimers.current.set(key, { timer, message: msgs[0], ids: deleteIds }); - addNotification({ - title: t('messageList.bulkDeleted.title', { count: deleteIds.length }), - body: t('messageList.bulkDeleted.body'), - onUndo: () => { - undone = true; - clearTimeout(timer); - pendingDeleteTimers.current.delete(key); - deleteIds.forEach(id => clearPendingDelete(id)); - useStore.getState().restoreMessages(msgs); - msgs.forEach(msg => { - const delta = parseInt(msg.unread_count) || (msg.is_read ? 0 : 1); - if (delta > 0) incrementUnread(msg.account_id, delta); - }); - }, - }); - }, [searchHasMore, removeMessage, prefetchSearchAfterRemoval, resolveMessagesForThreadAction, decrementUnread, incrementUnread, addNotification, t]); - - const handleBulkMove = useCallback(async (ids, msgs, folder) => { - // Selected thread rows move the whole conversation. A folder path is - // account-specific, so scope each thread's expansion to its row's own - // account — the server would just skip (and previously silently drop) - // another account's copies from a folder that doesn't exist there. - let moveIds = ids; - try { - const resolved = await Promise.all(msgs.map(async (m) => { - const thread = await resolveMessagesForThreadAction(m); - return thread.filter(tm => tm?.account_id === m.account_id); - })); - moveIds = [...new Set([...ids, ...resolved.flat().map(m => m?.id).filter(Boolean)])]; - } catch (err) { - console.error('Failed to load thread for bulk move:', err.message); - } - ids.forEach(id => removeMessage(id)); - msgs.forEach(msg => { if (!msg.is_read) decrementUnread(msg.account_id); }); - setSelectedIds(new Set()); + return executeForMessages('mail.trash', 'bulk-toolbar', msgs); + }, [executeForMessages]); + + const handleBulkMove = useCallback((_ids, msgs, folder) => { setSelectionModeActive(false); setShowFolderPicker(false); - let undone = false; - const timer = setTimeout(async () => { - if (undone) return; - try { - const result = await api.bulkMove(moveIds, folder); - const movedSet = new Set(result.moved ?? []); - const failedCount = moveIds.filter(id => !movedSet.has(id)).length; - if (failedCount > 0) { - const failedMsgs = msgs.filter(msg => !movedSet.has(msg.id)); - if (failedMsgs.length > 0) useStore.getState().restoreMessages(failedMsgs); - addNotification({ title: t('messageList.bulkMoved.failTitle'), body: t('messageList.bulkMoved.failBody', { count: failedCount }) }); - } else if (msgs[0]?.account_id) { - useStore.getState().recordRecentFolder({ accountId: msgs[0].account_id, path: folder }); - } - } catch (err) { - console.error('Bulk move failed:', err); - useStore.getState().restoreMessages(msgs); - addNotification({ title: t('messageList.bulkMoved.failTitle'), body: t('messageList.bulkMoved.failBody', { count: moveIds.length }) }); - } - }, 4500); - addNotification({ - title: t('messageList.bulkMoved.title', { count: moveIds.length }), - body: folder, - onUndo: () => { - undone = true; - clearTimeout(timer); - useStore.getState().restoreMessages(msgs); - msgs.forEach(msg => { if (!msg.is_read) incrementUnread(msg.account_id); }); - }, - }); - }, [removeMessage, decrementUnread, incrementUnread, resolveMessagesForThreadAction, addNotification, t]); + return executeForMessages('mail.move', 'bulk-toolbar', msgs, { folder }); + }, [executeForMessages]); const handleRowMove = useCallback((e, msg) => { e.stopPropagation(); @@ -1537,293 +889,31 @@ export default function MessageList() { e.dataTransfer.effectAllowed = 'move'; }, []); - const handleBulkArchive = useCallback((ids, msgs) => { - refreshRequestRef.current.invalidate(); - // Tombstone the ids so a background refresh/websocket refetch during the undo window - // can't resurrect them — applyDeleteGuard filters pending/completed ids out of refetch - // results — and drop every row in one batched state update rather than one per id. - ids.forEach(id => setPendingDelete(id)); - removeMessages(ids); - msgs.forEach(msg => { if (!msg.is_read) decrementUnread(msg.account_id); }); - setSelectedIds(new Set()); + const handleBulkArchive = useCallback((_ids, msgs) => { setSelectionModeActive(false); setShowFolderPicker(false); - let undone = false; - const timer = setTimeout(async () => { - if (undone) return; - try { - const result = await api.bulkArchive(ids); - const archivedSet = new Set(result.archived ?? []); - // Archived: keep guarding briefly (completed grace) so an in-flight refetch can't - // bring them back; not archived: release the guard so those rows can return. - ids.forEach(id => (archivedSet.has(id) ? setCompletedDelete(id) : clearDeleteGuard(id))); - const failedMsgs = msgs.filter(msg => !archivedSet.has(msg.id)); - if (failedMsgs.length > 0) { - useStore.getState().restoreMessages(failedMsgs); - failedMsgs.forEach(msg => { if (!msg.is_read) incrementUnread(msg.account_id); }); - if (result.noArchiveFolder?.length) { - addNotification({ title: t('messageList.bulkArchived.noFolderTitle'), body: t('messageList.bulkArchived.noFolderBody') }); - } else { - addNotification({ title: t('messageList.bulkArchived.failTitle'), body: t('messageList.bulkArchived.failBody', { count: failedMsgs.length }) }); - } - } - } catch (err) { - console.error('Bulk archive failed:', err); - ids.forEach(id => clearDeleteGuard(id)); - useStore.getState().restoreMessages(msgs); - msgs.forEach(msg => { if (!msg.is_read) incrementUnread(msg.account_id); }); - addNotification({ title: t('messageList.bulkArchived.failTitle'), body: t('messageList.bulkArchived.failBody', { count: ids.length }) }); - } - }, 4500); - addNotification({ - title: t('messageList.bulkArchived.title', { count: ids.length }), - body: t('messageList.bulkArchived.body'), - onUndo: () => { - undone = true; - clearTimeout(timer); - ids.forEach(id => clearPendingDelete(id)); - useStore.getState().restoreMessages(msgs); - msgs.forEach(msg => { if (!msg.is_read) incrementUnread(msg.account_id); }); - }, - }); - }, [removeMessages, decrementUnread, incrementUnread, addNotification, t]); - - const handleBulkMarkRead = useCallback(async (ids, msgs) => { - const markAsRead = msgs.some(m => !m.is_read); - // Compute per-account and per-category unread deltas before mutating state - const deltaByAccount = {}; - const deltaByCategory = {}; - msgs.forEach(msg => { - if (!deltaByAccount[msg.account_id]) deltaByAccount[msg.account_id] = 0; - const catKey = msg.category || 'primary'; - if (!deltaByCategory[catKey]) deltaByCategory[catKey] = 0; - if (markAsRead && !msg.is_read) { deltaByAccount[msg.account_id]++; deltaByCategory[catKey]++; } - if (!markAsRead && msg.is_read) { deltaByAccount[msg.account_id]++; deltaByCategory[catKey]++; } - }); - // Optimistic update - msgs.forEach(msg => updateMessage(msg.id, { is_read: markAsRead, unread_count: markAsRead ? 0 : 1 })); - Object.entries(deltaByAccount).forEach(([accountId, delta]) => { - if (delta > 0) markAsRead ? decrementUnread(accountId, delta) : incrementUnread(accountId, delta); - }); - Object.entries(deltaByCategory).forEach(([cat, delta]) => { - if (delta > 0) adjustCategoryCount(cat, markAsRead ? -delta : delta); - }); - setSelectedIds(new Set()); + return executeForMessages('mail.archive', 'bulk-toolbar', msgs); + }, [executeForMessages]); + + const handleBulkMarkRead = useCallback((_ids, msgs) => { setSelectionModeActive(false); - try { - await api.bulkRead(ids, markAsRead); - } catch (err) { - console.error('Bulk mark read failed:', err); - msgs.forEach(msg => updateMessage(msg.id, { is_read: msg.is_read, unread_count: msg.unread_count })); - Object.entries(deltaByAccount).forEach(([accountId, delta]) => { - if (delta > 0) markAsRead ? incrementUnread(accountId, delta) : decrementUnread(accountId, delta); - }); - Object.entries(deltaByCategory).forEach(([cat, delta]) => { - if (delta > 0) adjustCategoryCount(cat, markAsRead ? delta : -delta); - }); - } - }, [updateMessage, decrementUnread, incrementUnread, adjustCategoryCount]); + return executeForMessages('mail.toggleRead', 'bulk-toolbar', msgs); + }, [executeForMessages]); const autoMarkReadTimerRef = useRef(null); useEffect(() => () => clearTimeout(autoMarkReadTimerRef.current), []); - // Keep refs to bulk handlers so the shortcut effect (registered once) is never stale - const bulkDeleteRef = useRef(handleBulkDelete); - const bulkArchiveRef = useRef(handleBulkArchive); - const scheduleDeleteRef = useRef(scheduleDelete); - const handleContextActionRef = useRef(null); // assigned below, once handleContextAction is defined - useEffect(() => { bulkDeleteRef.current = handleBulkDelete; }, [handleBulkDelete]); - useEffect(() => { bulkArchiveRef.current = handleBulkArchive; }, [handleBulkArchive]); - useEffect(() => { scheduleDeleteRef.current = scheduleDelete; }, [scheduleDelete]); - // Subscribe to keyboard shortcut actions that belong to the message list. - // Registered once ([] deps); all live state is read through scRef/bulkDeleteRef/bulkArchiveRef. useEffect(() => { - const getState = () => useStore.getState(); - - const markRead = (msg) => { - if (msg.is_read) return; - const { updateMessage, decrementUnread, incrementUnread, adjustCategoryCount, markReadBehavior, markReadDelay } = getState(); - if (markReadBehavior === 'manual') return; - clearTimeout(autoMarkReadTimerRef.current); - autoMarkReadTimerRef.current = null; - const doMarkRead = () => { - updateMessage(msg.id, { is_read: true }); - decrementUnread(msg.account_id); - adjustCategoryCount(msg.category, -1); - setPending(msg.id, msg.account_id); - api.bulkRead([msg.id], true) - .then(() => { - pendingMarkReadMap.delete(msg.id); - completedMarkReadMap.set(msg.id, msg.account_id); - setTimeout(() => completedMarkReadMap.delete(msg.id), 10000); - }) - .catch(e => { - console.error('markRead failed:', e.message); - updateMessage(msg.id, { is_read: false }); - incrementUnread(msg.account_id); - adjustCategoryCount(msg.category, 1); - pendingMarkReadMap.delete(msg.id); - }); - }; - if (markReadBehavior === 'delay') { - autoMarkReadTimerRef.current = setTimeout(doMarkRead, (markReadDelay || 1) * 1000); - } else { - doMarkRead(); - } - }; - - const onNext = () => { - const { messages, searchResults, searchQuery, selectedMessageId, setSelectedMessage } = getState(); - const pool = searchQuery.trim() ? searchResults : messages; - if (!pool.length) return; - const idx = pool.findIndex(m => m.id === selectedMessageId); - const next = pool[idx + 1] ?? pool[0]; - setSelectedMessage(next.id); - markRead(next); - }; - - const onPrev = () => { - const { messages, searchResults, searchQuery, selectedMessageId, setSelectedMessage } = getState(); - const pool = searchQuery.trim() ? searchResults : messages; - if (!pool.length) return; - const idx = pool.findIndex(m => m.id === selectedMessageId); - const prev = idx <= 0 ? pool[pool.length - 1] : pool[idx - 1]; - setSelectedMessage(prev.id); - markRead(prev); - }; - - const onOpen = () => { - const { messages, selectedMessageId, setSelectedMessage } = getState(); - if (selectedMessageId || !messages.length) return; - setSelectedMessage(messages[0].id); - }; - - const onSelect = () => { - const { selectedMessageId } = getState(); - if (!selectedMessageId) return; - scRef.current.setSelectedIds(prev => { - const next = new Set(prev); - if (next.has(selectedMessageId)) next.delete(selectedMessageId); - else next.add(selectedMessageId); - return next; - }); - }; - - const onArchive = () => { - const { messages, searchResults, searchQuery, selectedMessageId, removeMessage, decrementUnread, addNotification } = getState(); - const pool = searchQuery.trim() ? searchResults : messages; - const ids = [...scRef.current.selectedIds]; - if (ids.length > 0) { - const msgs = pool.filter(m => ids.includes(m.id)); - bulkArchiveRef.current(ids, msgs); - } else if (selectedMessageId) { - const msg = pool.find(m => m.id === selectedMessageId); - if (!msg) return; - refreshRequestRef.current.invalidate(); - setPendingDelete(selectedMessageId); - advanceSelectionAfterRemoval(selectedMessageId); - removeMessage(selectedMessageId); - if (!msg.is_read) decrementUnread(msg.account_id); - api.bulkArchive([selectedMessageId]).then(result => { - setCompletedDelete(selectedMessageId); - if (result.noArchiveFolder?.length) { - addNotification({ title: tRef.current('messageList.noArchiveFolder.title'), body: tRef.current('messageList.noArchiveFolder.body') }); - } - }).catch(err => { clearDeleteGuard(selectedMessageId); console.error(err); }); - } - }; - - const onDelete = () => { - const { messages, searchResults, searchQuery, selectedMessageId } = getState(); - const pool = searchQuery.trim() ? searchResults : messages; - const ids = [...scRef.current.selectedIds]; - if (ids.length > 0) { - const msgs = pool.filter(m => ids.includes(m.id)); - bulkDeleteRef.current(ids, msgs); - } else if (selectedMessageId) { - const msg = pool.find(m => m.id === selectedMessageId); - if (!msg) return; - scheduleDeleteRef.current(msg); - } - }; - - const onToggleRead = () => { - const { messages, selectedMessageId, updateMessage, decrementUnread, incrementUnread, adjustCategoryCount } = getState(); - if (!selectedMessageId) return; - const msg = messages.find(m => m.id === selectedMessageId); - if (!msg) return; - const newRead = !msg.is_read; - updateMessage(selectedMessageId, { is_read: newRead }); - if (newRead) { - decrementUnread(msg.account_id); - adjustCategoryCount(msg.category, -1); - setPending(selectedMessageId, msg.account_id); - api.bulkRead([selectedMessageId], true) - .then(() => { - pendingMarkReadMap.delete(selectedMessageId); - completedMarkReadMap.set(selectedMessageId, msg.account_id); - setTimeout(() => completedMarkReadMap.delete(selectedMessageId), 10000); - }) - .catch(err => { - console.error('markRead failed:', err); - pendingMarkReadMap.delete(selectedMessageId); - }); - } else { - incrementUnread(msg.account_id); - adjustCategoryCount(msg.category, 1); - pendingMarkReadMap.delete(selectedMessageId); - completedMarkReadMap.delete(selectedMessageId); - api.bulkRead([selectedMessageId], false).catch(console.error); - } - }; - const onFocusSearch = () => { searchInputRef.current?.focus(); searchInputRef.current?.select(); }; - // GTD classify keys (t/w/d): COPY the selected message into a state's label - // folder, reusing the same classify dispatch as the context menu (no parallel - // action path). Silent no-op unless the message's account has GTD enabled, so - // the keys stay inert for non-GTD accounts. - const gtdClassifySelected = (state) => () => { - const { messages, searchResults, searchQuery, selectedMessageId, accounts } = getState(); - if (!selectedMessageId) return; - const pool = searchQuery.trim() ? searchResults : messages; - const msg = pool.find(m => m.id === selectedMessageId); - if (!msg) return; - if (!accounts.find(a => a.id === msg.account_id)?.gtd_enabled) return; - handleContextActionRef.current?.('gtdClassify', msg, state); - }; - const onGtdTodo = gtdClassifySelected('todo'); - const onGtdWatch = gtdClassifySelected('watch'); - const onGtdDelegated = gtdClassifySelected('delegated'); - - shortcutBus.on('nextMessage', onNext); - shortcutBus.on('prevMessage', onPrev); - shortcutBus.on('openMessage', onOpen); - shortcutBus.on('selectMessage', onSelect); - shortcutBus.on('archive', onArchive); - shortcutBus.on('delete', onDelete); - shortcutBus.on('toggleRead', onToggleRead); shortcutBus.on('focusSearch', onFocusSearch); - shortcutBus.on('gtdTodo', onGtdTodo); - shortcutBus.on('gtdWatch', onGtdWatch); - shortcutBus.on('gtdDelegated', onGtdDelegated); return () => { - shortcutBus.off('nextMessage', onNext); - shortcutBus.off('prevMessage', onPrev); - shortcutBus.off('openMessage', onOpen); - shortcutBus.off('selectMessage', onSelect); - shortcutBus.off('archive', onArchive); - shortcutBus.off('delete', onDelete); - shortcutBus.off('toggleRead', onToggleRead); shortcutBus.off('focusSearch', onFocusSearch); - shortcutBus.off('gtdTodo', onGtdTodo); - shortcutBus.off('gtdWatch', onGtdWatch); - shortcutBus.off('gtdDelegated', onGtdDelegated); }; }, []); @@ -1852,189 +942,14 @@ export default function MessageList() { }, [showFolderPicker]); // ───────────────────────────────────────────────────────────── - const handleContextAction = async (action, message, data) => { + const handleContextUtility = async (action, message, data) => { switch (action) { case 'open': handleSelect(message); break; - case 'markRead': { - const uc = parseInt(message.unread_count); - const threadUnread = Number.isFinite(uc) && uc > 0; - if (!message.is_read || threadUnread) { - await setMessagesReadState(message, true); - } - break; - } - case 'markUnread': { - const uc = parseInt(message.unread_count); - const needsMarkUnread = message.is_read || (Number.isFinite(uc) && uc === 0); - if (needsMarkUnread) { - await setMessagesReadState(message, false); - } - break; - } - case 'toggleStar': { - const newVal = !message.is_starred; - await setMessagesStarredState(message, newVal); - break; - } - case 'reply': - case 'replyAll': - await openReplyFromMessage(message, { - accounts, - openCompose, - getMessageBody: api.getMessageBody, - replyAll: action === 'replyAll', - }); - break; - case 'forward': - await openForwardFromMessage(message, { - openCompose, - getMessageBody: api.getMessageBody, - }); - break; case 'bulkSelect': setSelectedIds(new Set([message.id])); break; - case 'archive': { - const archived = message; - refreshRequestRef.current.invalidate(); - advanceSelectionAfterRemoval(archived.id); - removeMessage(archived.id); - if (!archived.is_read) decrementUnread(archived.account_id); - let archiveUndone = false; - const archiveTimer = setTimeout(async () => { - if (archiveUndone) return; - try { - const result = await api.bulkArchive([archived.id]); - if (result.noArchiveFolder?.length) { - addNotification({ title: t('message.archived.noFolderTitle'), body: t('message.archived.noFolderBody') }); - } - } catch (err) { - console.error('Archive failed:', err.message); - addNotification({ title: t('message.archived.failTitle'), body: t('message.archived.failBody') }); - } - }, 4500); - addNotification({ - title: t('message.archived.title'), - body: archived.subject || t('common.noSubject'), - onUndo: () => { - archiveUndone = true; - clearTimeout(archiveTimer); - useStore.getState().restoreMessages([archived]); - if (!archived.is_read) incrementUnread(archived.account_id); - }, - }); - break; - } - case 'moveTo': { - const folder = data; - if (!folder) break; - // If multiple messages are checked and the right-clicked message is among them, - // delegate to handleBulkMove so all selected messages are moved together. - if (selectedIds.size > 1 && selectedIds.has(message.id)) { - const bulkMsgs = displayMessages.filter(m => selectedIds.has(m.id)); - handleBulkMove([...selectedIds], bulkMsgs, folder); - // handleBulkMove already clears the selection internally. - break; - } - const moved = message; - let moveMessages; - try { - moveMessages = await resolveMessagesForThreadAction(message); - } catch (err) { - console.error('Failed to load thread for move:', err.message); - addNotification({ title: t('message.moved.failTitle'), body: t('message.moved.failBody') }); - break; - } - // A folder path is account-specific: a thread can span accounts (and - // always includes Sent copies), and the server skips messages whose - // account lacks the destination folder. Scope the move to the - // right-clicked message's account so nothing is silently dropped. - moveMessages = moveMessages.filter(msg => msg?.account_id === moved.account_id); - const moveIds = [...new Set(moveMessages.map(msg => msg.id).filter(Boolean))]; - if (!moveIds.length) moveIds.push(moved.id); - removeMessage(moved.id); - if (!moved.is_read) decrementUnread(moved.account_id); - // Remove the moved message from the selection so the action bar doesn't - // stay around claiming "X selected" for messages that are no longer here. - if (selectedIds.has(moved.id)) { - const next = new Set(selectedIds); - next.delete(moved.id); - setSelectedIds(next); - if (next.size === 0) setSelectionModeActive(false); - } - let moveUndone = false; - const moveTimer = setTimeout(async () => { - if (moveUndone) return; - try { - const result = await api.bulkMove(moveIds, folder); - // The server reports per-message success (200 even when some IMAP - // moves fail or are skipped) — surface partial failures instead of - // letting the thread silently reappear on the next sync. - const movedSet = new Set(result.moved ?? []); - const failedCount = moveIds.filter(id => !movedSet.has(id)).length; - if (failedCount > 0) { - if (!movedSet.has(moved.id)) { - useStore.getState().restoreMessages([moved]); - if (!moved.is_read) incrementUnread(moved.account_id); - } - addNotification({ type: 'error', title: t('message.moved.failTitle'), body: t('messageList.bulkMoved.failBody', { count: failedCount }) }); - } else { - useStore.getState().recordRecentFolder({ accountId: moved.account_id, path: folder }); - } - } catch (err) { - console.error('Move failed:', err.message); - useStore.getState().restoreMessages([moved]); - if (!moved.is_read) incrementUnread(moved.account_id); - addNotification({ title: t('message.moved.failTitle'), body: t('message.moved.failBody') }); - } - }, 4500); - addNotification({ - title: t('message.moved.title'), - body: folder, - onUndo: () => { - moveUndone = true; - clearTimeout(moveTimer); - useStore.getState().restoreMessages([moved]); - if (!moved.is_read) incrementUnread(moved.account_id); - }, - }); - break; - } - case 'snooze': { - const snoozedMsg = message; - const untilIso = data; - if (!untilIso) break; - removeMessage(snoozedMsg.id); - if (!snoozedMsg.is_read) decrementUnread(snoozedMsg.account_id); - if (selectedIds.has(snoozedMsg.id)) { - const next = new Set(selectedIds); - next.delete(snoozedMsg.id); - setSelectedIds(next); - if (next.size === 0) setSelectionModeActive(false); - } - addNotification({ title: t('message.snoozed.title'), body: snoozedMsg.subject || t('common.noSubject') }); - api.snoozeMessage(snoozedMsg.id, untilIso).catch(err => { - console.error('Snooze failed:', err.message); - useStore.getState().restoreMessages([snoozedMsg]); - if (!snoozedMsg.is_read) incrementUnread(snoozedMsg.account_id); - addNotification({ title: t('message.snoozed.failTitle'), body: t('message.snoozed.failBody') }); - }); - break; - } - case 'gtdClassify': { - // Classify = COPY into the state's label folder. The message stays put - // (no optimistic removal / undo — it does not leave INBOX), so we just - // fire the copy and poke the GTD sections store instead of waiting on the WS event. - await classifyThread(message.id, data, { - gtdClassify: api.gtdClassify, - addNotification, - scheduleGtdSectionsFetch: useStore.getState().scheduleGtdSectionsFetch, - t, - }); - break; - } case 'gtdRemove': { await unclassifyThread(message.id, data, { gtdUnclassify: api.gtdUnclassify, @@ -2061,36 +976,6 @@ export default function MessageList() { }); break; } - case 'delete': - if (selectedIds.size > 1 && selectedIds.has(message.id)) { - const bulkMsgs = displayMessages.filter(m => selectedIds.has(m.id)); - handleBulkDelete([...selectedIds], bulkMsgs); - } else { - scheduleDelete(message); - } - break; - case 'markSpam': { - // Bulk when more than one message is selected and the right-clicked - // message is among them; otherwise just the single message. - const targets = (selectedIds.size > 1 && selectedIds.has(message.id)) - ? displayMessages.filter(m => selectedIds.has(m.id)) - : [message]; - performSpamLabel(targets, 'spam'); - // The action bar should not claim "X selected" for messages that have - // just been queued for move-to-Junk. Clear the selection (handleBulk* - // already does this; performSpamLabel doesn't, because it's shared - // with the single-message toolbar path which never had a selection). - if (selectedIds.has(message.id)) clearSelection(); - break; - } - case 'markHam': { - const targets = (selectedIds.size > 1 && selectedIds.has(message.id)) - ? displayMessages.filter(m => selectedIds.has(m.id)) - : [message]; - performSpamLabel(targets, 'ham'); - if (selectedIds.has(message.id)) clearSelection(); - break; - } case 'setCategory': { const newCategory = data || 'primary'; const dbCategory = newCategory === 'primary' ? null : newCategory; @@ -2114,17 +999,11 @@ export default function MessageList() { break; } }; - // Expose the latest handleContextAction to the once-registered shortcut effect via - // a post-commit effect (the sibling handler refs' pattern), rather than mutating the - // ref during render. No dep array: handleContextAction isn't memoized, so it syncs - // on every commit. - useEffect(() => { handleContextActionRef.current = handleContextAction; }); - const handleThreadMarkRead = (e, message) => { e.stopPropagation(); const uc = parseInt(message.unread_count); const hasUnreadInThread = Number.isFinite(uc) && uc > 0; - handleContextAction(hasUnreadInThread ? 'markRead' : 'markUnread', message); + executeForMessages(hasUnreadInThread ? 'mail.read' : 'mail.unread', 'hover', [message]); }; const isDraftsFolder = (() => { @@ -2173,27 +1052,7 @@ export default function MessageList() { clearTimeout(autoMarkReadTimerRef.current); autoMarkReadTimerRef.current = null; if (!message.is_read && markReadBehavior !== 'manual') { - const prevUnread = message.unread_count; - const doMarkRead = () => { - updateMessage(message.id, { is_read: true, unread_count: 0 }); - decrementUnread(message.account_id); - adjustCategoryCount(message.category, -1); - setPending(message.id, message.account_id); - api.bulkRead([message.id], true) - .catch(() => api.bulkRead([message.id], true)) - .then(() => { - pendingMarkReadMap.delete(message.id); - completedMarkReadMap.set(message.id, message.account_id); - setTimeout(() => completedMarkReadMap.delete(message.id), 10000); - }) - .catch(e => { - console.error('markRead failed:', e.message); - updateMessage(message.id, { is_read: false, unread_count: prevUnread }); - incrementUnread(message.account_id); - adjustCategoryCount(message.category, 1); - pendingMarkReadMap.delete(message.id); - }); - }; + const doMarkRead = () => executeForMessages('mail.read', 'auto-read', [message]); if (markReadBehavior === 'delay') { autoMarkReadTimerRef.current = setTimeout(doMarkRead, markReadDelay * 1000); } else { @@ -3506,9 +2365,22 @@ export default function MessageList() { x={contextMenu.x} y={contextMenu.y} message={contextMenu.message} + targetIds={(selectedIds.size > 1 && selectedIds.has(contextMenu.message.id) + ? displayMessages.filter(candidate => selectedIds.has(candidate.id)) + : [contextMenu.message] + ).map(stableConversationId).filter(Boolean)} defaultMoveView={contextMenu.defaultMoveView} + onCommand={(commandId, input) => { + const selectedMessages = displayMessages.filter(candidate => selectedIds.has(candidate.id)); + const targetMessages = contextMenuTargetMessages( + commandId, + contextMenu.message, + selectedMessages, + ); + return executeForMessages(commandId, 'context-menu', targetMessages, input); + }} onClose={() => setContextMenu(null)} - onAction={(action, data) => handleContextAction(action, contextMenu.message, data)} + onAction={(action, data) => handleContextUtility(action, contextMenu.message, data)} /> )} @@ -4106,10 +2978,15 @@ function ThreadRow({ message, isExpanded, threadMsgs, isLoadingThread, selectedM
{/* Row 3: snippet */}
- {message.snippet || ''} + + + {message.snippet || ''} +
{hovered && hoverQuickActions && ( @@ -4417,7 +3294,8 @@ function MessageRow({ message, selected, lastViewed, isChecked, selectionMode, s
{/* Row 3: Snippet */} -
+
+ a.name ? `${a.name} <${a.email}>` : a.email).filter(Boolean).join(', '); - } catch { return ''; } -} +import DelegatePill from './DelegatePill.jsx'; function linkifyText(text) { const escaped = text.replace(/&/g, '&').replace(//g, '>'); @@ -91,24 +84,40 @@ function fileIcon(type) { export default function MessagePane() { const { t } = useTranslation(); + const { controller: commandController, commandDefinitions, getContext } = useCommandRuntimeContext(); const { messages, searchResults, searchQuery, selectedMessageId, setSelectedMessage, - updateMessage, removeMessage, decrementUnread, incrementUnread, openCompose, accounts, addNotification, + updateMessage, accounts, addNotification, imageWhitelist, addToImageWhitelist, blockRemoteImages, threadMessages, - replyDefault, shortcuts, recentFolders, favoriteFolders, todoistConnected, - categorizationEnabled, setCategoryCounts, adjustCategoryCount, + replyDefault, recentFolders, favoriteFolders, todoistConnected, + categorizationEnabled, setCategoryCounts, aiActions, setShowAdmin, setAdminTab, } = useStore(); const isMobile = useMobile(); const defaultReplyAll = replyDefault === 'replyAll'; - const effectiveShortcuts = getEffectiveShortcuts(shortcuts); + const executeForTarget = useCallback((commandId, source, target, input) => { + const targetId = stableConversationId(target); + if (!targetId) return Promise.resolve({ status: 'cancelled' }); + return commandController.execute(commandId, { + source, + input, + frozenTargetIds: [targetId], + }); + }, [commandController]); + + const commandBindings = new Map(getEffectiveCommandBindings(commandDefinitions, getContext()) + .map(item => [item.commandId, item.bindings[0]?.keys])); + const shortcutIds = { + reply: 'mail.reply', replyAll: 'mail.replyAll', forward: 'mail.forward', + archive: 'mail.archive', delete: 'mail.trash', toggleStar: 'mail.toggleStar', + toggleRead: 'mail.toggleRead', printMessage: 'mail.print', + }; const shortcutLabel = (action) => { - const k = effectiveShortcuts[action]; + const k = commandBindings.get(shortcutIds[action]); if (!k) return null; - const mod = parseModKey(k); - return mod ? `${modCompactLabel(mod.mod)}${mod.bare.toUpperCase()}` : k.toUpperCase(); + return formatCommandKey(k, getContext().platform); }; // Navigate to a message and mark it as read in one shot. // Arrow buttons and swipe gestures bypass handleSelect in MessageList, so they @@ -122,32 +131,14 @@ export default function MessagePane() { if (!msg.is_read) { const { markReadBehavior, markReadDelay } = useStore.getState(); if (markReadBehavior === 'manual') return; - const doMarkRead = () => { - updateMessage(msg.id, { is_read: true }); - decrementUnread(msg.account_id); - adjustCategoryCount(msg.category, -1); - setPending(msg.id, msg.account_id); - api.bulkRead([msg.id], true) - .then(() => { - pendingMarkReadMap.delete(msg.id); - completedMarkReadMap.set(msg.id, msg.account_id); - setTimeout(() => completedMarkReadMap.delete(msg.id), 10000); - }) - .catch(e => { - console.error('markRead failed:', e.message); - updateMessage(msg.id, { is_read: false }); - incrementUnread(msg.account_id); - adjustCategoryCount(msg.category, 1); - pendingMarkReadMap.delete(msg.id); - }); - }; + const doMarkRead = () => executeForTarget('mail.read', 'pane-navigation', msg); if (markReadBehavior === 'delay') { autoMarkReadTimerRef.current = setTimeout(doMarkRead, (markReadDelay || 1) * 1000); } else { doMarkRead(); } } - }, [setSelectedMessage, updateMessage, decrementUnread, incrementUnread, adjustCategoryCount]); + }, [executeForTarget, setSelectedMessage]); const paneRef = useRef(null); const mountedRef = useRef(true); @@ -206,6 +197,16 @@ export default function MessagePane() { const message = allMessages.find(m => m.id === selectedMessageId) ?? Object.values(threadMessages).flat().find(m => m.id === selectedMessageId); + const executeForMessage = useCallback((commandId, source, input) => { + if (!message) return Promise.resolve({ status: 'cancelled' }); + const frozenTargetIds = [stableConversationId(message)].filter(Boolean); + return commandController.execute(commandId, { + source, + input, + frozenTargetIds, + }); + }, [commandController, message]); + useEffect(() => { setResolvedSubject(null); }, [message?.id]); @@ -224,42 +225,6 @@ export default function MessagePane() { const inSpamFolder = message ? spamFolderPaths.has(message.folder) : false; const hasSpamFolder = spamFolderPaths.size > 0; - // Mark current message as spam / ham from the MessagePane toolbar. - // Mirrors MessageList.performSpamLabel (single-message variant). Kept inline - // here so the MessagePane doesn't need to reach into MessageList internals. - const performSingleSpamLabel = useCallback(async (label) => { - if (!message) return; - const wasUnread = !message.is_read; - removeMessage(message.id); - if (wasUnread) decrementUnread(message.account_id); - let settled = false; - const undo = () => { - settled = true; - useStore.getState().restoreMessages([message]); - if (wasUnread) incrementUnread(message.account_id); - }; - setTimeout(async () => { - if (settled) return; - try { - const fn = label === 'spam' ? api.markSpam : api.markHam; - await fn(message.id); - } catch (err) { - useStore.getState().restoreMessages([message]); - if (wasUnread) incrementUnread(message.account_id); - addNotification({ - type: 'error', - title: t(label === 'spam' ? 'spam.failTitle' : 'spam.failHamTitle'), - body: err.message || t(label === 'spam' ? 'spam.failBody' : 'spam.failHamBody'), - }); - } - }, 4500); - addNotification({ - title: label === 'spam' ? t('spam.movedToSpam') : t('spam.movedToInbox'), - body: message.subject || t('common.noSubject'), - onUndo: undo, - }); - }, [message, removeMessage, decrementUnread, incrementUnread, addNotification, t]); - const currentIdx = allMessages.findIndex(m => m.id === selectedMessageId); const hasPrev = currentIdx > 0; const hasNext = currentIdx >= 0 && currentIdx < allMessages.length - 1; @@ -295,6 +260,18 @@ export default function MessagePane() { const scrollContainerRef = useRef(null); const iframeRef = useRef(null); const roRef = useRef(null); + useEffect(() => { + const onScrollCommand = event => { + const element = scrollContainerRef.current; + if (!element) return; + element.scrollBy({ + top: (event.detail?.direction || 0) * element.clientHeight, + behavior: 'smooth', + }); + }; + window.addEventListener('mailflow:scroll-conversation', onScrollCommand); + return () => window.removeEventListener('mailflow:scroll-conversation', onScrollCommand); + }, []); // useMemo so prepared is available in the same render as body.html — no extra frame, // no flash of empty content between skeleton-gone and email-shown. const prepared = useMemo(() => { @@ -308,8 +285,7 @@ export default function MessagePane() { const bodyCacheOrder = useRef([]); // insertion-order keys for LRU eviction // Session-scoped set of message IDs where the user has clicked "Load images once" const imagesRequestedRef = useRef(new Set()); - // Ref holding the latest pane action handlers so shortcut subscriptions ([] deps) never go stale - const paneActionsRef = useRef({}); + const printActionRef = useRef(null); const emailScaleRef = useRef(1); // scale applied to wide emails that resist CSS reflow // Track previous blocking policy so we can detect tightening vs loosening. @@ -807,7 +783,7 @@ export default function MessagePane() { el.style.transform = 'translateX(0)'; } } else { - const { messages: msgs, searchResults: sr, searchQuery: sq, selectedMessageId: selId, setSelectedMessage: setSel, updateMessage: updMsg, decrementUnread: decUnread, incrementUnread: incUnread, adjustCategoryCount: adjCat } = useStore.getState(); + const { messages: msgs, searchResults: sr, searchQuery: sq, selectedMessageId: selId } = useStore.getState(); const list = sq.trim() ? sr : msgs; const idx = list.findIndex(m => m.id === selId); let target = null; @@ -817,40 +793,7 @@ export default function MessagePane() { target = list[idx - 1]; } if (target) { - window.dispatchEvent(new CustomEvent(MESSAGE_OPENING_EVENT)); - api.getMessageBody(target.id).catch(() => {}); - setSel(target.id); - clearTimeout(autoMarkReadTimerRef.current); - autoMarkReadTimerRef.current = null; - if (!target.is_read) { - const { markReadBehavior, markReadDelay } = useStore.getState(); - if (markReadBehavior !== 'manual') { - const doMarkRead = () => { - updMsg(target.id, { is_read: true }); - decUnread(target.account_id); - adjCat(target.category, -1); - setPending(target.id, target.account_id); - api.bulkRead([target.id], true) - .then(() => { - pendingMarkReadMap.delete(target.id); - completedMarkReadMap.set(target.id, target.account_id); - setTimeout(() => completedMarkReadMap.delete(target.id), 10000); - }) - .catch(e => { - console.error('markRead failed:', e.message); - updMsg(target.id, { is_read: false }); - incUnread(target.account_id); - adjCat(target.category, 1); - pendingMarkReadMap.delete(target.id); - }); - }; - if (markReadBehavior === 'delay') { - autoMarkReadTimerRef.current = setTimeout(doMarkRead, (markReadDelay || 1) * 1000); - } else { - doMarkRead(); - } - } - } + selectAndMarkRead(target); } } }; @@ -864,124 +807,7 @@ export default function MessagePane() { el.removeEventListener('touchmove', onMove); el.removeEventListener('touchend', onEnd); }; - }, [isMobile, setSelectedMessage, resetPaneSwipeStyles]); - - const handleReply = (replyAll = false) => { - if (!message) return; - const date = message.date ? new Date(message.date).toLocaleString() : ''; - const safeName = (message.from_name || '').replace(/[\r\n]+/g, ' '); - const fromStr = safeName - ? `${safeName} <${message.from_email}>` - : message.from_email || ''; - const quotedText = body?.text - ? `\n\n---\nOn ${date}, ${fromStr} wrote:\n${body.text.split('\n').map(l => '> ' + l).join('\n')}` - : ''; - const quotedBodyHtml = body?.html - ? `

On ${date}, ${fromStr} wrote:

${body.html}
` - : null; - - const replyToArr = Array.isArray(message.reply_to) - ? message.reply_to - : (() => { try { return JSON.parse(message.reply_to || '[]'); } catch { return []; } })(); - const replyTarget = (replyToArr.length && replyToArr[0].email) - ? replyToArr[0] - : { name: message.from_name || '', email: message.from_email || '' }; - const sender = replyTarget.email ? [replyTarget] : []; - - const myAccount = accounts.find(a => a.id === message.account_id); - const myEmail = myAccount?.email_address || ''; - - const replyAliasId = pickReplyAlias({ - aliases: myAccount?.aliases || [], - deliveryAddresses: message.delivery_addresses, - toAddresses: message.to_addresses, - ccAddresses: message.cc_addresses, - fromEmail: message.from_email, - }); - - const myAddresses = new Set([ - myEmail.toLowerCase(), - ...(myAccount?.aliases || []).map(al => al.email.toLowerCase()), - ]); - const allRecipients = (() => { - try { - const toArr = Array.isArray(message.to_addresses) - ? message.to_addresses - : JSON.parse(message.to_addresses || '[]'); - const ccArr = Array.isArray(message.cc_addresses) - ? message.cc_addresses - : JSON.parse(message.cc_addresses || '[]'); - return [...toArr, ...ccArr].filter( - t => t.email && !myAddresses.has(t.email.toLowerCase()) && t.email !== replyTarget.email - ); - } catch { return []; } - })(); - - const referencesChain = [message.in_reply_to, message.message_id] - .filter(Boolean).join(' ').trim() || null; - - const rawSubject = (message.subject || '').trim(); - const reSubject = rawSubject.startsWith('Re:') ? rawSubject : rawSubject ? `Re: ${rawSubject}` : 'Re:'; - - setShowReplyMenu(false); - openCompose({ - to: sender, - cc: replyAll ? allRecipients : [], - subject: reSubject, - body: '', - quotedBody: quotedText, - quotedBodyHtml, - inReplyTo: message.message_id, - references: referencesChain, - accountId: message.account_id, - aliasId: replyAliasId, - isReply: true, - isReplyAll: replyAll, - originalFrom: sender, - allRecipients, - threadId: message.thread_id, - }); - }; - - const handleForward = () => { - if (!message) return; - const date = message.date ? new Date(message.date).toLocaleString() : ''; - const safeName = (message.from_name || '').replace(/[\r\n]+/g, ' '); - const fromStr = safeName - ? `${safeName} <${message.from_email}>` - : message.from_email || ''; - const safeSubject = (message.subject || '').replace(/[\r\n]+/g, ' '); - - const toStr = parseAddressField(message.to_addresses); - const ccStr = parseAddressField(message.cc_addresses); - - const fwdText = `\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${safeSubject}${toStr ? `\nTo: ${toStr}` : ''}${ccStr ? `\nCc: ${ccStr}` : ''}\n\n${body?.text || ''}`; - const fwdHtml = body?.html - ? `

---------- Forwarded message ----------
From: ${fromStr}
Date: ${date}
Subject: ${safeSubject}${toStr ? `
To: ${toStr}` : ''}${ccStr ? `
Cc: ${ccStr}` : ''}

${body.html}
` - : null; - openCompose({ - subject: message.subject?.startsWith('Fwd:') ? message.subject : `Fwd: ${message.subject}`, - body: '', - quotedBody: fwdText, - quotedBodyHtml: fwdHtml, - accountId: message.account_id, - isForward: true, - forwardedAttachments: (body?.attachments || []).map(att => ({ - messageId: message.id, - part: att.part, - filename: att.filename || 'attachment', - type: att.type || 'application/octet-stream', - size: att.size || 0, - })), - }); - }; - - const handleStarToggle = async () => { - if (!message) return; - const newVal = !message.is_starred; - await api.markStarred(message.id, newVal); - updateMessage(message.id, { is_starred: newVal }); - }; + }, [isMobile, resetPaneSwipeStyles, selectAndMarkRead]); const handlePrint = () => { if (!message) return; @@ -1115,35 +941,14 @@ ${bodyContent}
); - // Keep pane action refs current every render - paneActionsRef.current = { - reply: () => handleReply(defaultReplyAll), - replyAll: () => handleReply(true), - forward: handleForward, - toggleStar: handleStarToggle, - print: handlePrint, - }; + printActionRef.current = handlePrint; - // Subscribe to keyboard shortcut actions that belong to the message pane. - // Registered once ([] deps); live state is accessed through paneActionsRef. + // Print stays on the compatibility bus until shortcut parity migrates it. useEffect(() => { - const onReply = () => paneActionsRef.current.reply(); - const onReplyAll = () => paneActionsRef.current.replyAll(); - const onForward = () => paneActionsRef.current.forward(); - const onToggleStar = () => paneActionsRef.current.toggleStar(); - const onPrintMessage = () => paneActionsRef.current.print?.(); - - shortcutBus.on('reply', onReply); - shortcutBus.on('replyAll', onReplyAll); - shortcutBus.on('forward', onForward); - shortcutBus.on('toggleStar', onToggleStar); + const onPrintMessage = () => printActionRef.current?.(); shortcutBus.on('printMessage', onPrintMessage); return () => { - shortcutBus.off('reply', onReply); - shortcutBus.off('replyAll', onReplyAll); - shortcutBus.off('forward', onForward); - shortcutBus.off('toggleStar', onToggleStar); shortcutBus.off('printMessage', onPrintMessage); }; }, []); @@ -1192,22 +997,6 @@ ${bodyContent} } }, [showMovePicker, message?.account_id]); // eslint-disable-line react-hooks/exhaustive-deps - const handleMarkUnread = useCallback(() => { - if (!message || !message.is_read) return; - updateMessage(message.id, { is_read: false }); - incrementUnread(message.account_id); - adjustCategoryCount(message.category, 1); - completedMarkReadMap.delete(message.id); - pendingMarkReadMap.delete(message.id); - api.bulkRead([message.id], false).catch(e => { - console.error('markUnread failed:', e.message); - updateMessage(message.id, { is_read: true }); - decrementUnread(message.account_id); - adjustCategoryCount(message.category, -1); - }); - if (isMobile) setSelectedMessage(null); - }, [message, updateMessage, incrementUnread, decrementUnread, adjustCategoryCount, isMobile, setSelectedMessage]); - const handleEmailClick = useCallback((ev) => { const anchor = ev.target.closest('a[href]'); if (!anchor) return; @@ -1221,36 +1010,10 @@ ${bodyContent} } }, []); - const handleMoveToFolder = useCallback((folder) => { - if (!message) return; + const moveToFolder = useCallback((folder) => { setShowMovePicker(false); - const moved = message; - removeMessage(moved.id); - if (!moved.is_read) decrementUnread(moved.account_id); - let undone = false; - const timer = setTimeout(async () => { - if (undone) return; - try { - await api.bulkMove([moved.id], folder); - useStore.getState().recordRecentFolder({ accountId: moved.account_id, path: folder }); - } catch (err) { - console.error('Move failed:', err); - useStore.getState().restoreMessages([moved]); - if (!moved.is_read) incrementUnread(moved.account_id); - addNotification({ title: t('message.moved.failTitle'), body: t('message.moved.failBody') }); - } - }, 4500); - addNotification({ - title: t('message.moved.title'), - body: folder, - onUndo: () => { - undone = true; - clearTimeout(timer); - useStore.getState().restoreMessages([moved]); - if (!moved.is_read) incrementUnread(moved.account_id); - }, - }); - }, [message, removeMessage, decrementUnread, incrementUnread, addNotification, t]); + return executeForMessage('mail.move', 'pane-move-picker', { folder }); + }, [executeForMessage]); // Close move picker when the selected message changes and handle click-outside useEffect(() => { @@ -1335,67 +1098,6 @@ ${bodyContent} ); } - const handleDelete = () => { - const deleted = message; - setPendingDelete(deleted.id); - removeMessage(deleted.id); - if (!deleted.is_read) decrementUnread(deleted.account_id); - let undone = false; - const timer = setTimeout(async () => { - if (undone) return; - try { - await api.deleteMessage(deleted.id); - setCompletedDelete(deleted.id); - } catch { - clearDeleteGuard(deleted.id); - useStore.getState().restoreMessages([deleted]); - if (!deleted.is_read) incrementUnread(deleted.account_id); - addNotification({ type: 'error', title: t('messageList.deleted.failTitle'), body: t('messageList.deleted.failBody') }); - } - }, 4500); - addNotification({ - title: t('messageList.deleted.title'), - body: t('messageList.deleted.body'), - onUndo: () => { - undone = true; - clearTimeout(timer); - clearPendingDelete(deleted.id); - useStore.getState().restoreMessages([deleted]); - if (!deleted.is_read) incrementUnread(deleted.account_id); - }, - }); - }; - - const handleArchive = () => { - const archived = message; - removeMessage(archived.id); - if (!archived.is_read) decrementUnread(archived.account_id); - let undone = false; - const timer = setTimeout(async () => { - if (undone) return; - try { - const result = await api.bulkArchive([archived.id]); - if (result.noArchiveFolder?.length) { - addNotification({ title: t('message.archived.noFolderTitle'), body: t('message.archived.noFolderBody') }); - } - } catch (err) { - console.error('Archive failed:', err); - addNotification({ title: t('message.archived.failTitle'), body: t('message.archived.failBody') }); - } - }, 4500); - addNotification({ - title: t('message.archived.title'), - body: archived.subject || t('common.noSubject'), - onUndo: () => { - undone = true; - clearTimeout(timer); - const state = useStore.getState(); - state.setMessages([...state.messages, archived].sort((a, b) => new Date(b.date) - new Date(a.date))); - if (!archived.is_read) incrementUnread(archived.account_id); - }, - }); - }; - const handleLoadImages = () => { imagesRequestedRef.current.add(selectedMessageId); delete bodyCache.current[selectedMessageId]; @@ -1424,31 +1126,8 @@ ${bodyContent} const handleUnsubscribe = async () => { if (!message) return; setUnsubscribeStatus('loading'); - const msg = message; - try { - const result = await api.unsubscribeMessage(msg.id); - const succeeded = result.type === 'one-click' || result.type === 'url' || result.type === 'mailto'; - if (!succeeded) { setUnsubscribeStatus('error'); return; } - if (result.type === 'url' && result.url) window.open(result.url, '_blank', 'noopener,noreferrer'); - else if (result.type === 'mailto' && result.mailto) window.open(result.mailto, '_blank', 'noopener,noreferrer'); - setUnsubscribeStatus('done'); - addNotification({ - title: t('message.unsubscribe.done'), - actionLabel: t('message.unsubscribe.moveToTrash'), - onAction: () => { - const { removeMessage, decrementUnread, restoreMessages, incrementUnread } = useStore.getState(); - removeMessage(msg.id); - if (!msg.is_read) decrementUnread(msg.account_id); - api.deleteMessage(msg.id).catch(() => { - restoreMessages([msg]); - if (!msg.is_read) incrementUnread(msg.account_id); - }); - }, - }); - } catch { - setUnsubscribeStatus('error'); - addNotification({ type: 'error', title: t('message.unsubscribe.error') }); - } + const outcome = await executeForTarget('mail.unsubscribe', 'pane', message); + setUnsubscribeStatus(outcome.status === 'success' ? 'done' : 'error'); }; const handleAiClassify = async () => { @@ -1595,7 +1274,7 @@ ${bodyContent} }}> {/* Split Reply button */}
- handleReply(defaultReplyAll)} style={{ borderRadius: '6px 0 0 6px' }} title={isMobile ? (defaultReplyAll ? t('message.replyAll') : t('message.reply')) : `${defaultReplyAll ? t('message.replyAll') : t('message.reply')}${shortcutLabel(defaultReplyAll ? 'replyAll' : 'reply') ? ` (${shortcutLabel(defaultReplyAll ? 'replyAll' : 'reply')})` : ''}`}> + executeForMessage(defaultReplyAll ? 'mail.replyAll' : 'mail.reply', 'pane-toolbar')} style={{ borderRadius: '6px 0 0 6px' }} title={isMobile ? (defaultReplyAll ? t('message.replyAll') : t('message.reply')) : `${defaultReplyAll ? t('message.replyAll') : t('message.reply')}${shortcutLabel(defaultReplyAll ? 'replyAll' : 'reply') ? ` (${shortcutLabel(defaultReplyAll ? 'replyAll' : 'reply')})` : ''}`}> {defaultReplyAll ? ( @@ -1641,7 +1320,7 @@ ${bodyContent} ].map(opt => (
handleReply(opt.replyAll)} + onClick={() => { setShowReplyMenu(false); executeForMessage(opt.replyAll ? 'mail.replyAll' : 'mail.reply', 'pane-reply-menu'); }} style={{ padding: '9px 14px', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)', @@ -1656,13 +1335,13 @@ ${bodyContent} )}
- + executeForMessage('mail.forward', 'pane-toolbar')} title={isMobile ? t('message.forward') : `${t('message.forward')}${shortcutLabel('forward') ? ` (${shortcutLabel('forward')})` : ''}`}> - + executeForMessage('mail.archive', 'pane-toolbar')} title={isMobile ? t('message.archive') : `${t('message.archive')}${shortcutLabel('archive') ? ` (${shortcutLabel('archive')})` : ''}`}> @@ -1726,7 +1405,7 @@ ${bodyContent} ) : filtered.map(f => (
{ setShowMoreMenu(false); handleMarkUnread(); }} + onClick={() => { setShowMoreMenu(false); executeForMessage('mail.unread', 'pane-more-menu'); }} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '11px 14px', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)', borderBottom: '1px solid var(--border-subtle)' }} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'} @@ -1835,9 +1514,24 @@ ${bodyContent} {t('contextMenu.markUnread')}
)} + {account?.gtd_enabled && ( + + )} {hasSpamFolder && !inSpamFolder && message && (
{ performSingleSpamLabel('spam'); setShowMoreMenu(false); }} + onClick={() => { setShowMoreMenu(false); executeForMessage('mail.spam', 'pane-more-menu'); }} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '11px 14px', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)', borderBottom: '1px solid var(--border-subtle)' }} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'} @@ -1851,7 +1545,7 @@ ${bodyContent} )} {inSpamFolder && message && (
{ performSingleSpamLabel('ham'); setShowMoreMenu(false); }} + onClick={() => { setShowMoreMenu(false); executeForMessage('mail.notSpam', 'pane-more-menu'); }} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '11px 14px', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)', borderBottom: '1px solid var(--border-subtle)' }} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'} @@ -1935,8 +1629,16 @@ ${bodyContent}
) : ( <> + {account?.gtd_enabled && ( + executeForMessage('gtd.delegate', 'visible-message-menu')} title={t('gtd.delegate.command')}> + + + + + + )} {hasSpamFolder && !inSpamFolder && message && ( - performSingleSpamLabel('spam')} title={t('contextMenu.markAsSpam')}> + executeForMessage('mail.spam', 'pane-toolbar')} title={t('contextMenu.markAsSpam')}> @@ -1944,7 +1646,7 @@ ${bodyContent} )} {inSpamFolder && message && ( - performSingleSpamLabel('ham')} title={t('contextMenu.markAsHam')}> + executeForMessage('mail.notSpam', 'pane-toolbar')} title={t('contextMenu.markAsHam')}> @@ -1959,7 +1661,7 @@ ${bodyContent} )} {message.is_read && ( - + executeForMessage('mail.unread', 'pane-toolbar')} title={t('contextMenu.markUnread')}> @@ -2008,7 +1710,7 @@ ${bodyContent} )} - + executeForMessage('mail.toggleStar', 'pane-toolbar')} title={t('message.star')}> @@ -2016,7 +1718,7 @@ ${bodyContent} - + executeForMessage('mail.trash', 'pane-toolbar')} title={t('message.delete')} danger> @@ -2052,13 +1754,17 @@ ${bodyContent} fontSize: 17, fontWeight: 600, color: 'var(--text-primary)', lineHeight: 1.3, fontFamily: 'var(--font-display)', + display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 8, }}> - {(() => { - const paneSubject = resolvedSubject || message.subject; - return (paneSubject && paneSubject !== '(no subject)') - ? paneSubject - : t('message.noSubject'); - })()} + + {(() => { + const paneSubject = resolvedSubject || message.subject; + return (paneSubject && paneSubject !== '(no subject)') + ? paneSubject + : t('message.noSubject'); + })()} + +
@@ -2639,7 +2345,7 @@ ${bodyContent} ) : filtered.map(f => (