From 286cce68776cdf72070f3772c38713bead82b771 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:54:40 +0200 Subject: [PATCH] feat: tell plugins when messages are deleted, moved, archived, marked or tagged, and let them stop it --- components/mail/mail-app.tsx | 6 + components/pro/pro-folder-tab-body.tsx | 21 +- lib/plugin-mail-hooks.ts | 100 +++++ .../email-store-plugin-organize-hooks.test.ts | 407 ++++++++++++++++++ stores/email-store.ts | 183 +++++++- 5 files changed, 703 insertions(+), 14 deletions(-) create mode 100644 lib/plugin-mail-hooks.ts create mode 100644 stores/__tests__/email-store-plugin-organize-hooks.test.ts diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 385bbbdaa..aa83bbac1 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -3667,8 +3667,14 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { if (useEmailStore.getState().emails.some((e) => e.id === target.id)) { await markAsRead(client, target.id, true); } else { + // Not in the store's list, so the store action can't ask plugins. + if (emailHooks.onBeforeEmailReadStateChange.size > 0 + && !(await emailHooks.onBeforeEmailReadStateChange.intercept({ emailIds: [target.id], read: true }))) { + return; + } const { threadClient, accountId } = mvConversationRoute(target, client); await threadClient.markAsRead(target.id, true, accountId); + void emailHooks.onEmailReadStateChange.emit({ emailIds: [target.id], read: true }); } patchMvConversation(target.id, (e) => ({ ...e, keywords: { ...e.keywords, $seen: true } })); } catch (error) { diff --git a/components/pro/pro-folder-tab-body.tsx b/components/pro/pro-folder-tab-body.tsx index 642d730a2..3132e3ff2 100644 --- a/components/pro/pro-folder-tab-body.tsx +++ b/components/pro/pro-folder-tab-body.tsx @@ -14,6 +14,7 @@ import { useProTabStore, type ProFolderTabData } from "@/stores/pro-tab-store"; import { useDeviceDetection } from "@/hooks/use-media-query"; import { usePaneId } from "@/hooks/use-pane-context"; import type { Email, Mailbox } from "@/lib/jmap/types"; +import { emailHooks } from "@/lib/plugin-hooks"; interface ProFolderTabBodyProps { tabId: string; @@ -118,16 +119,24 @@ export function ProFolderTabBody({ tabId, data }: ProFolderTabBodyProps) { } }, [hasMore, isLoadingMore, isLoading, loadPage, emails.length]); - const markRowAsRead = useCallback((email: Email) => { + const markRowAsRead = useCallback(async (email: Email) => { if (!client || email.keywords?.$seen) return; + // Straight to the client: the store's markAsRead only serves emails in + // its own (global) list, which this tab's rows are not part of. So the + // plugin hooks the store action fires are fired here. + if (emailHooks.onBeforeEmailReadStateChange.size > 0 + && !(await emailHooks.onBeforeEmailReadStateChange.intercept({ emailIds: [email.id], read: true }))) { + return; + } setEmails((prev) => prev.map((e) => e.id === email.id ? { ...e, keywords: { ...e.keywords, $seen: true } } : e)); - // Straight to the client: the store's markAsRead only serves emails in - // its own (global) list, which this tab's rows are not part of. - void client.markAsRead(email.id, true, jmapAccountId).catch((err) => { + try { + await client.markAsRead(email.id, true, jmapAccountId); + void emailHooks.onEmailReadStateChange.emit({ emailIds: [email.id], read: true }); + } catch (err) { console.error('Mark as read failed:', err); - }); + } }, [client, jmapAccountId]); const openAsEmailTab = useCallback((email: Email, reuseReader: boolean) => { @@ -143,7 +152,7 @@ export function ProFolderTabBody({ tabId, data }: ProFolderTabBodyProps) { }, [data.mailboxId, paneId, t]); const handleRowClick = useCallback((email: Email) => { - markRowAsRead(email); + void markRowAsRead(email); if (isMobile) { // No room for a reading pane - promote to a dedicated email tab. openAsEmailTab(email, true); diff --git a/lib/plugin-mail-hooks.ts b/lib/plugin-mail-hooks.ts new file mode 100644 index 000000000..608b25c8f --- /dev/null +++ b/lib/plugin-mail-hooks.ts @@ -0,0 +1,100 @@ +// Shared pieces for firing the mail plugin hooks (organize, folders, sync) +// from the email store: the plain views plugins receive, and the rules that +// decide which hooks a move fires. + +import { emailHooks } from './plugin-hooks'; +import type { Mailbox } from './jmap/types'; +import type { MailboxView, QuotaView } from './plugin-types'; + +export function mailboxToView(mailbox: Mailbox): MailboxView { + return { + id: mailbox.id, + name: mailbox.name, + role: mailbox.role ?? null, + totalEmails: mailbox.totalEmails ?? 0, + unreadEmails: mailbox.unreadEmails ?? 0, + parentId: mailbox.parentId ?? null, + }; +} + +export function quotaToView(quota: { used: number; total: number }): QuotaView { + return { + used: quota.used, + total: quota.total, + percentUsed: quota.total > 0 ? Math.round((quota.used / quota.total) * 1000) / 10 : 0, + }; +} + +/** Bare email ids of a key-based selection (`scope:id` or `id`), each once. */ +export function idsFromSelectionKeys(keys: Iterable): string[] { + const ids = new Set(); + for (const key of keys) ids.add(key.slice(key.lastIndexOf(':') + 1)); + return [...ids]; +} + +/** Keywords a `{ keyword: on/off }` change puts on and takes off. */ +export function keywordChangeLists(changes: Record): { added: string[]; removed: string[] } { + const added: string[] = []; + const removed: string[] = []; + for (const [keyword, on] of Object.entries(changes)) (on ? added : removed).push(keyword); + return { added, removed }; +} + +/** + * Whether a folder is the Archive folder or one of its sub-folders: a year or + * month archive files messages under the Archive folder, and those + * sub-folders carry no role of their own. + */ +export function isArchiveFolder(mailboxId: string | null | undefined, mailboxes: Mailbox[]): boolean { + if (!mailboxId) return false; + const find = (id: string) => mailboxes.find((mb) => mb.id === id) ?? mailboxes.find((mb) => mb.originalId === id); + const seen = new Set(); + let current = find(mailboxId); + while (current && !seen.has(current.id)) { + if (current.role === 'archive') return true; + seen.add(current.id); + current = current.parentId ? find(current.parentId) : undefined; + } + return false; +} + +export interface EmailMove { + emailIds: string[]; + fromMailboxId: string | null; + toMailboxId: string; +} + +/** + * Ask plugins before messages are moved. A move into the archive also asks + * onBeforeEmailArchive. Returns false when a plugin stopped it. + */ +export async function allowEmailMove(move: EmailMove, mailboxes: Mailbox[]): Promise { + if (move.emailIds.length === 0) return true; + if (emailHooks.onBeforeEmailMove.size > 0 + && !(await emailHooks.onBeforeEmailMove.intercept({ ...move, emailIds: [...move.emailIds] }))) { + return false; + } + if (emailHooks.onBeforeEmailArchive.size > 0 + && isArchiveFolder(move.toMailboxId, mailboxes) + && !isArchiveFolder(move.fromMailboxId, mailboxes) + && !(await emailHooks.onBeforeEmailArchive.intercept({ emailIds: [...move.emailIds] }))) { + return false; + } + return true; +} + +/** + * Tell plugins messages were moved; a move into the archive is also an + * archive, a move out of it an unarchive. + */ +export function emitEmailMove(move: EmailMove, mailboxes: Mailbox[]): void { + if (move.emailIds.length === 0) return; + void emailHooks.onAfterEmailMove.emit({ ...move, emailIds: [...move.emailIds] }); + const toArchive = isArchiveFolder(move.toMailboxId, mailboxes); + const fromArchive = isArchiveFolder(move.fromMailboxId, mailboxes); + if (toArchive && !fromArchive) { + void emailHooks.onEmailArchive.emit({ emailIds: [...move.emailIds] }); + } else if (fromArchive && !toArchive) { + void emailHooks.onEmailUnarchive.emit({ emailIds: [...move.emailIds], toMailboxId: move.toMailboxId }); + } +} diff --git a/stores/__tests__/email-store-plugin-organize-hooks.test.ts b/stores/__tests__/email-store-plugin-organize-hooks.test.ts new file mode 100644 index 000000000..2bab12d2b --- /dev/null +++ b/stores/__tests__/email-store-plugin-organize-hooks.test.ts @@ -0,0 +1,407 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useEmailStore } from '../email-store'; +import { useAuthStore } from '../auth-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { clearAllHooks, emailHooks } from '@/lib/plugin-hooks'; +import { emailKeyFor } from '@/lib/thread-utils'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +// The organize hooks (delete, move, archive, spam, read, star, keywords) fire +// from the store actions every button, menu and shortcut goes through. + +function makeMailbox(overrides: Partial): Mailbox { + return { + id: 'inbox', + name: 'Inbox', + sortOrder: 0, + totalEmails: 0, + unreadEmails: 0, + totalThreads: 0, + unreadThreads: 0, + isSubscribed: true, + isShared: false, + ...overrides, + } as Mailbox; +} + +function makeEmail(id: string, overrides: Partial = {}): Email { + return { + id, + threadId: `t-${id}`, + subject: id, + receivedAt: '2026-01-01T00:00:00Z', + keywords: { $seen: true }, + mailboxIds: { inbox: true }, + ...overrides, + } as Email; +} + +const mailboxes = [ + makeMailbox({ id: 'inbox', role: 'inbox' }), + makeMailbox({ id: 'custom', name: 'Projects' }), + makeMailbox({ id: 'archive', name: 'Archive', role: 'archive' }), + makeMailbox({ id: 'archive-2026', name: '2026', parentId: 'archive' }), + makeMailbox({ id: 'trash', name: 'Trash', role: 'trash' }), + makeMailbox({ id: 'junk', name: 'Junk', role: 'junk' }), +]; + +function makeClient(): IJMAPClient { + return { + getAccountId: () => 'me', + moveToTrash: vi.fn().mockResolvedValue(undefined), + deleteEmail: vi.fn().mockResolvedValue(undefined), + batchDeleteEmails: vi.fn().mockResolvedValue(undefined), + moveEmail: vi.fn().mockResolvedValue(undefined), + batchMoveEmails: vi.fn().mockResolvedValue(undefined), + batchArchiveEmails: vi.fn().mockResolvedValue(undefined), + markAsRead: vi.fn().mockResolvedValue(undefined), + batchMarkAsRead: vi.fn().mockResolvedValue(undefined), + toggleStar: vi.fn().mockResolvedValue(undefined), + batchUpdateKeywords: vi.fn().mockResolvedValue(undefined), + setKeyword: vi.fn().mockResolvedValue(undefined), + markAsSpam: vi.fn().mockResolvedValue(undefined), + undoSpam: vi.fn().mockResolvedValue(undefined), + getMailboxes: vi.fn().mockResolvedValue(mailboxes), + getAllMailboxes: vi.fn().mockResolvedValue(mailboxes), + getEmails: vi.fn().mockResolvedValue({ emails: [], hasMore: false, total: 0 }), + getMaxObjectsInGet: () => 500, + getThreads: vi.fn().mockResolvedValue([]), + } as unknown as IJMAPClient; +} + +let client: IJMAPClient; + +function openFolder(folderId: string, emails: Email[]) { + useEmailStore.setState({ + mailboxes, + accountMailboxes: {}, + viewingAccountId: null, + selectedMailbox: folderId, + selectedKeyword: null, + isUnifiedView: false, + unifiedRole: null, + crossView: null, + emails, + selectedEmail: null, + selectedEmailKeys: new Set(), + threadEmailsCache: new Map(), + processingReadStatus: new Set(), + error: null, + }); +} + +beforeEach(() => { + clearAllHooks(); + client = makeClient(); + useAuthStore.setState({ + activeAccountId: 'acct', + getClientForAccount: () => client as never, + } as never); + useSettingsStore.setState({ deleteAction: 'trash', permanentlyDeleteJunk: false, archiveMode: 'single' } as never); +}); + +afterEach(() => clearAllHooks()); + +describe('delete hooks', () => { + it('asks before moving to Trash and reports a non-permanent delete', async () => { + openFolder('inbox', [makeEmail('e1')]); + const order: string[] = []; + const before = vi.fn(() => { order.push('before'); return true; }); + vi.mocked(client.moveToTrash).mockImplementation(async () => { order.push('server'); }); + const after = vi.fn(); + emailHooks.onBeforeEmailDelete.register('t', before); + emailHooks.onAfterEmailDelete.register('t', after); + + await useEmailStore.getState().deleteEmail(client, 'e1'); + + expect(before).toHaveBeenCalledWith({ emailIds: ['e1'], permanent: false }); + expect(order).toEqual(['before', 'server']); + expect(after).toHaveBeenCalledTimes(1); + expect(after).toHaveBeenCalledWith({ emailIds: ['e1'], permanent: false }); + }); + + it('reports permanent when the message is destroyed', async () => { + openFolder('inbox', [makeEmail('e1')]); + const after = vi.fn(); + emailHooks.onAfterEmailDelete.register('t', after); + + await useEmailStore.getState().deleteEmail(client, 'e1', true); + + expect(client.deleteEmail).toHaveBeenCalled(); + expect(after).toHaveBeenCalledWith({ emailIds: ['e1'], permanent: true }); + }); + + it('stops when a plugin says no', async () => { + openFolder('inbox', [makeEmail('e1')]); + const after = vi.fn(); + emailHooks.onBeforeEmailDelete.register('t', () => false); + emailHooks.onAfterEmailDelete.register('t', after); + + await useEmailStore.getState().deleteEmail(client, 'e1'); + + expect(client.moveToTrash).not.toHaveBeenCalled(); + expect(useEmailStore.getState().emails.map(e => e.id)).toEqual(['e1']); + expect(after).not.toHaveBeenCalled(); + }); + + it('does not report a delete the server refused', async () => { + openFolder('inbox', [makeEmail('e1')]); + vi.mocked(client.moveToTrash).mockRejectedValue(new Error('nope')); + const after = vi.fn(); + emailHooks.onAfterEmailDelete.register('t', after); + + await expect(useEmailStore.getState().deleteEmail(client, 'e1')).rejects.toThrow('nope'); + expect(after).not.toHaveBeenCalled(); + }); + + it('batch delete in Trash is permanent', async () => { + const a = makeEmail('a', { mailboxIds: { trash: true } }); + const b = makeEmail('b', { mailboxIds: { trash: true } }); + openFolder('trash', [a, b]); + useEmailStore.setState({ selectedEmailKeys: new Set([emailKeyFor(a), emailKeyFor(b)]) }); + const before = vi.fn(() => true); + const after = vi.fn(); + emailHooks.onBeforeEmailDelete.register('t', before); + emailHooks.onAfterEmailDelete.register('t', after); + + await useEmailStore.getState().batchDelete(client); + + expect(before).toHaveBeenCalledWith({ emailIds: ['a', 'b'], permanent: true }); + expect(after).toHaveBeenCalledWith({ emailIds: ['a', 'b'], permanent: true }); + }); +}); + +describe('move and archive hooks', () => { + it('asks before a move and reports it with source and destination', async () => { + openFolder('inbox', [makeEmail('e1')]); + const before = vi.fn(() => true); + const after = vi.fn(); + const archived = vi.fn(); + emailHooks.onBeforeEmailMove.register('t', before); + emailHooks.onAfterEmailMove.register('t', after); + emailHooks.onEmailArchive.register('t', archived); + + await useEmailStore.getState().moveToMailbox(client, 'e1', 'custom'); + + const payload = { emailIds: ['e1'], fromMailboxId: 'inbox', toMailboxId: 'custom' }; + expect(before).toHaveBeenCalledWith(payload); + expect(after).toHaveBeenCalledTimes(1); + expect(after).toHaveBeenCalledWith(payload); + expect(archived).not.toHaveBeenCalled(); + }); + + it('stops a move a plugin refuses', async () => { + openFolder('inbox', [makeEmail('e1')]); + const after = vi.fn(); + emailHooks.onBeforeEmailMove.register('t', () => false); + emailHooks.onAfterEmailMove.register('t', after); + + await useEmailStore.getState().moveToMailbox(client, 'e1', 'custom'); + + expect(client.moveEmail).not.toHaveBeenCalled(); + expect(useEmailStore.getState().emails).toHaveLength(1); + expect(after).not.toHaveBeenCalled(); + }); + + it('treats a move into the archive (or a sub-folder of it) as archiving', async () => { + openFolder('inbox', [makeEmail('e1'), makeEmail('e2')]); + const archived = vi.fn(); + emailHooks.onEmailArchive.register('t', archived); + + await useEmailStore.getState().moveToMailbox(client, 'e1', 'archive'); + await useEmailStore.getState().moveToMailbox(client, 'e2', 'archive-2026'); + + expect(archived.mock.calls).toEqual([[{ emailIds: ['e1'] }], [{ emailIds: ['e2'] }]]); + }); + + it('lets onBeforeEmailArchive stop an archive', async () => { + openFolder('inbox', [makeEmail('e1')]); + emailHooks.onBeforeEmailArchive.register('t', () => false); + + await useEmailStore.getState().moveToMailbox(client, 'e1', 'archive'); + + expect(client.moveEmail).not.toHaveBeenCalled(); + }); + + it('reports a move out of the archive as unarchiving', async () => { + openFolder('archive', [makeEmail('e1', { mailboxIds: { archive: true } })]); + const archived = vi.fn(); + const unarchived = vi.fn(); + emailHooks.onEmailArchive.register('t', archived); + emailHooks.onEmailUnarchive.register('t', unarchived); + + await useEmailStore.getState().moveToMailbox(client, 'e1', 'inbox'); + + expect(unarchived).toHaveBeenCalledWith({ emailIds: ['e1'], toMailboxId: 'inbox' }); + expect(archived).not.toHaveBeenCalled(); + }); + + it('batch archive asks and reports once for the selection', async () => { + const a = makeEmail('a'); + const b = makeEmail('b'); + openFolder('inbox', [a, b]); + useEmailStore.setState({ selectedEmailKeys: new Set([emailKeyFor(a), emailKeyFor(b)]) }); + const before = vi.fn(() => true); + const archived = vi.fn(); + emailHooks.onBeforeEmailArchive.register('t', before); + emailHooks.onEmailArchive.register('t', archived); + + await useEmailStore.getState().batchArchive(client); + + expect(before).toHaveBeenCalledWith({ emailIds: ['a', 'b'] }); + expect(archived).toHaveBeenCalledTimes(1); + expect(archived).toHaveBeenCalledWith({ emailIds: ['a', 'b'] }); + }); + + it('batch move reports nothing when the server refused it', async () => { + const a = makeEmail('a'); + openFolder('inbox', [a]); + useEmailStore.setState({ selectedEmailKeys: new Set([emailKeyFor(a)]) }); + vi.mocked(client.batchMoveEmails).mockRejectedValue(new Error('nope')); + const after = vi.fn(); + emailHooks.onAfterEmailMove.register('t', after); + + await useEmailStore.getState().batchMoveToMailbox(client, 'custom'); + + expect(after).not.toHaveBeenCalled(); + }); +}); + +describe('spam hooks', () => { + it('asks and reports mark-as-spam', async () => { + openFolder('inbox', [makeEmail('e1')]); + const before = vi.fn(() => true); + const after = vi.fn(); + emailHooks.onBeforeEmailSpamToggle.register('t', before); + emailHooks.onEmailSpamToggle.register('t', after); + + await useEmailStore.getState().markAsSpam(client, 'e1'); + + expect(before).toHaveBeenCalledWith({ emailIds: ['e1'], spam: true }); + expect(after).toHaveBeenCalledWith({ emailIds: ['e1'], spam: true }); + }); + + it('stops mark-as-spam a plugin refuses', async () => { + openFolder('inbox', [makeEmail('e1')]); + emailHooks.onBeforeEmailSpamToggle.register('t', () => false); + + await useEmailStore.getState().markAsSpam(client, 'e1'); + + expect(client.markAsSpam).not.toHaveBeenCalled(); + expect(useEmailStore.getState().emails).toHaveLength(1); + }); + + it('reports not-spam for a batch', async () => { + const a = makeEmail('a', { mailboxIds: { junk: true } }); + const b = makeEmail('b', { mailboxIds: { junk: true } }); + openFolder('junk', [a, b]); + const after = vi.fn(); + emailHooks.onEmailSpamToggle.register('t', after); + + await useEmailStore.getState().batchUndoSpam(client, ['a', 'b']); + + expect(after).toHaveBeenCalledWith({ emailIds: ['a', 'b'], spam: false }); + }); +}); + +describe('read, star and keyword hooks', () => { + it('asks and reports a read-state change', async () => { + openFolder('inbox', [makeEmail('e1', { keywords: {} })]); + const before = vi.fn(() => true); + const after = vi.fn(); + emailHooks.onBeforeEmailReadStateChange.register('t', before); + emailHooks.onEmailReadStateChange.register('t', after); + + await useEmailStore.getState().markAsRead(client, 'e1', true); + + expect(before).toHaveBeenCalledWith({ emailIds: ['e1'], read: true }); + expect(after).toHaveBeenCalledWith({ emailIds: ['e1'], read: true }); + }); + + it('leaves the message unread when a plugin refuses', async () => { + openFolder('inbox', [makeEmail('e1', { keywords: {} })]); + emailHooks.onBeforeEmailReadStateChange.register('t', () => false); + + await useEmailStore.getState().markAsRead(client, 'e1', true); + + expect(client.markAsRead).not.toHaveBeenCalled(); + expect(useEmailStore.getState().emails[0].keywords.$seen).toBeUndefined(); + // A later attempt is not blocked by a stale in-progress marker. + expect(useEmailStore.getState().processingReadStatus.size).toBe(0); + }); + + it('reports a batch read-state change', async () => { + const a = makeEmail('a', { keywords: {} }); + openFolder('inbox', [a]); + useEmailStore.setState({ selectedEmailKeys: new Set([emailKeyFor(a)]) }); + const after = vi.fn(); + emailHooks.onEmailReadStateChange.register('t', after); + + await useEmailStore.getState().batchMarkAsRead(client, true); + + expect(after).toHaveBeenCalledWith({ emailIds: ['a'], read: true }); + }); + + it('asks and reports a star toggle', async () => { + openFolder('inbox', [makeEmail('e1')]); + const before = vi.fn(() => true); + const after = vi.fn(); + emailHooks.onBeforeEmailStarToggle.register('t', before); + emailHooks.onEmailStarToggle.register('t', after); + + await useEmailStore.getState().toggleStar(client, 'e1'); + + expect(before).toHaveBeenCalledWith({ emailIds: ['e1'], starred: true }); + expect(after).toHaveBeenCalledWith({ emailIds: ['e1'], starred: true }); + }); + + it('stops a star toggle a plugin refuses', async () => { + openFolder('inbox', [makeEmail('e1')]); + emailHooks.onBeforeEmailStarToggle.register('t', () => false); + + await useEmailStore.getState().toggleStar(client, 'e1'); + + expect(client.toggleStar).not.toHaveBeenCalled(); + }); + + it('reports added and removed keywords', async () => { + openFolder('inbox', [makeEmail('e1', { keywords: { $seen: true, '$label:old': true } })]); + const before = vi.fn(() => true); + const after = vi.fn(); + emailHooks.onBeforeEmailKeywordChange.register('t', before); + emailHooks.onEmailKeywordChange.register('t', after); + + await useEmailStore.getState().setEmailKeywords(client, 'e1', { $seen: true, '$label:new': true }); + + const payload = { emailIds: ['e1'], added: ['$label:new'], removed: ['$label:old'] }; + expect(before).toHaveBeenCalledWith(payload); + expect(after).toHaveBeenCalledTimes(1); + expect(after).toHaveBeenCalledWith(payload); + }); + + it('stops a keyword change a plugin refuses', async () => { + openFolder('inbox', [makeEmail('e1')]); + const after = vi.fn(); + emailHooks.onBeforeEmailKeywordChange.register('t', () => false); + emailHooks.onEmailKeywordChange.register('t', after); + + await useEmailStore.getState().patchEmailKeywords(client, 'e1', { '$label:x': true }); + + expect(client.batchUpdateKeywords).not.toHaveBeenCalled(); + expect(after).not.toHaveBeenCalled(); + }); + + it('reports a tag put on a selection', async () => { + const a = makeEmail('a'); + const b = makeEmail('b'); + openFolder('inbox', [a, b]); + useEmailStore.setState({ selectedEmailKeys: new Set([emailKeyFor(a), emailKeyFor(b)]) }); + const after = vi.fn(); + emailHooks.onEmailKeywordChange.register('t', after); + + await useEmailStore.getState().batchSetTag(client, 'work', true); + + expect(after).toHaveBeenCalledWith({ emailIds: ['a', 'b'], added: ['$label:work'], removed: [] }); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index 63d62eb7c..8c23ef488 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -7,6 +7,7 @@ import { useCalendarStore } from "@/stores/calendar-store"; import { orderKeywords, placeHeldRows, type HeldKeywords, type SortLevel } from "@/lib/message-list-order"; import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils"; import { emailHooks } from "@/lib/plugin-hooks"; +import { allowEmailMove, emitEmailMove, keywordChangeLists } from "@/lib/plugin-mail-hooks"; import { resolveThreadRoute } from "@/lib/thread-routing"; import { emailKeyFor, threadKeyFor, threadIdFromKey, KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils"; import type { ExternalSearchResult } from "@/lib/plugin-types"; @@ -1433,6 +1434,25 @@ async function refreshMailboxesForViewingAccount(fallbackClient: IJMAPClient): P // still carries a bare owner id (scoped to the owning account via // `sourceAccountId === mailbox.accountId` so a bare owner id can't collide with // another account's folder). (#281) +/** + * The folder messages are moved out of, for the move hooks: the open folder, + * or in an aggregate view (unified, tag, search across folders) the one + * folder they all share, else null. + */ +function moveSourceMailboxId(emails: Array>, mailboxes: Mailbox[]): string | null { + const { selectedMailbox, selectedKeyword, isUnifiedView } = useEmailStore.getState(); + if (!selectedKeyword && !isUnifiedView && selectedMailbox && mailboxes.some(mb => mb.id === selectedMailbox)) { + return selectedMailbox; + } + const sources = new Set(); + for (const email of emails) { + const mailbox = mailboxes.find(mb => emailInMailbox(email, mb)); + if (!mailbox) return null; + sources.add(mailbox.id); + } + return sources.size === 1 ? [...sources][0] : null; +} + function emailInMailbox( email: { mailboxIds?: Record; sourceAccountId?: string }, mailbox: Mailbox, @@ -1549,6 +1569,11 @@ async function runBatchSpamAction( if (targets.length === 0) { throw new Error('Emails not found'); } + const spam = role === 'junk'; + if (emailHooks.onBeforeEmailSpamToggle.size > 0 + && !(await emailHooks.onBeforeEmailSpamToggle.intercept({ emailIds: [...new Set(targets.map(e => e.id))], spam }))) { + return; + } const destinationByKey = new Map(); const failures: unknown[] = []; @@ -1598,6 +1623,7 @@ async function runBatchSpamAction( ...patch, }; }); + void emailHooks.onEmailSpamToggle.emit({ emailIds: [...new Set(moved.map(e => e.id))], spam }); } if (failures.length > 0) { @@ -2790,6 +2816,12 @@ export const useEmailStore = create((set, get) => ({ forceDelete = true; } + const permanent = !((deleteAction === 'trash' || deleteAction === 'trash-and-read') && !forceDelete); + if (emailHooks.onBeforeEmailDelete.size > 0 + && !(await emailHooks.onBeforeEmailDelete.intercept({ emailIds: [emailId], permanent }))) { + return; + } + // If deleteAction is 'trash' or 'trash-and-read' and not forced permanent delete, try to move to trash mailbox if ((deleteAction === 'trash' || deleteAction === 'trash-and-read') && !forceDelete) { const trashMailbox = findTrashMailbox(mailboxes, { accountId }); @@ -2837,6 +2869,7 @@ export const useEmailStore = create((set, get) => ({ ...mailboxPatch, }; }); + void emailHooks.onAfterEmailDelete.emit({ emailIds: [emailId], permanent: false }); return; } // No trash folder found in this account. Surface the failure rather @@ -2870,6 +2903,7 @@ export const useEmailStore = create((set, get) => ({ ...mailboxPatch, }; }); + void emailHooks.onAfterEmailDelete.emit({ emailIds: [emailId], permanent: true }); } catch (error) { set({ error: error instanceof Error ? error.message : "Failed to delete email" @@ -2902,6 +2936,16 @@ export const useEmailStore = create((set, get) => ({ processingReadStatus: new Set([...state.processingReadStatus, processingKey]) })); + if (emailHooks.onBeforeEmailReadStateChange.size > 0 + && !(await emailHooks.onBeforeEmailReadStateChange.intercept({ emailIds: [emailId], read }))) { + set((state) => { + const next = new Set(state.processingReadStatus); + next.delete(processingKey); + return { processingReadStatus: next }; + }); + return; + } + // In unified view route to the email's own account client; otherwise use // the active/viewing client and the shared-folder accountId. (#281) const { client: actionClient, accountId } = resolveEmailActionContext(email, client); @@ -2979,6 +3023,7 @@ export const useEmailStore = create((set, get) => ({ })(), }; }); + void emailHooks.onEmailReadStateChange.emit({ emailIds: [emailId], read }); refillAfterKeywordChange(get, client, ['$seen']); } catch (error) { // Remove from processing set on error @@ -3057,6 +3102,9 @@ export const useEmailStore = create((set, get) => ({ const destMailbox = mailboxes.find(mb => mb.id === destinationMailboxId); const jmapDestId = destMailbox?.originalId || destinationMailboxId; + const move = { emailIds: [emailId], fromMailboxId: moveSourceMailboxId([email], mailboxes), toMailboxId: destinationMailboxId }; + if (!(await allowEmailMove(move, mailboxes))) return; + await actionClient.moveEmail(emailId, jmapDestId, accountId); set((state) => { @@ -3090,6 +3138,7 @@ export const useEmailStore = create((set, get) => ({ ...mailboxPatch, }; }); + emitEmailMove(move, mailboxes); } catch (error) { set({ error: error instanceof Error ? error.message : "Failed to move email" @@ -3112,6 +3161,10 @@ export const useEmailStore = create((set, get) => ({ const jmapDestId = destMailbox?.originalId || destinationMailboxId; const idSet = new Set(emailIds); const affected = emails.filter(e => idSet.has(e.id)); + const move = { emailIds: [...idSet], fromMailboxId: moveSourceMailboxId(affected, mailboxes), toMailboxId: destinationMailboxId }; + if (!(await allowEmailMove(move, mailboxes))) return; + // Messages of an account no client reaches stay where they are. + const skipped = new Set(); if (isAggregateListView()) { // In unified view, emails may span accounts – group by owning JMAP account @@ -3128,7 +3181,10 @@ export const useEmailStore = create((set, get) => ({ const acctClient = sourceAccountId === '__default__' ? resolveActionClient(client) : (clientAccountId ? useAuthStore.getState().getClientForAccount(clientAccountId) : undefined); - if (!acctClient) return; + if (!acctClient) { + ids.forEach(id => skipped.add(id)); + return; + } const jmapAccountId = sourceAccountId === '__default__' ? undefined : sourceAccountId; await acctClient.batchMoveEmails(ids, jmapDestId, jmapAccountId); })); @@ -3178,6 +3234,7 @@ export const useEmailStore = create((set, get) => ({ ...patch, }; }); + emitEmailMove({ ...move, emailIds: move.emailIds.filter(id => !skipped.has(id)) }, mailboxes); } catch (error) { set({ error: error instanceof Error ? error.message : 'Failed to move emails' }); throw error; @@ -3187,6 +3244,15 @@ export const useEmailStore = create((set, get) => ({ crossAccountMoveEmails: async (emailIdsBySource, destAccountId, destMailboxId, destJmapAccountId, sourceJmapAccountId, options) => { if (emailIdsBySource.size === 0) return; const keepOriginal = options?.keepOriginal === true; + // A copy leaves the messages where they are, so only a move asks plugins. + const hookMailboxes = [...resolveActionMailboxes(), ...(get().accountMailboxes[destAccountId] ?? [])]; + const requestedIds = [...new Set([...emailIdsBySource.values()].flat())]; + const move = keepOriginal ? null : { + emailIds: requestedIds, + fromMailboxId: moveSourceMailboxId(get().emails.filter(e => requestedIds.includes(e.id)), hookMailboxes), + toMailboxId: destMailboxId, + }; + if (move && !(await allowEmailMove(move, hookMailboxes))) return; set({ isLoading: true, error: null }); try { const destClient = useAuthStore.getState().getClientForAccount(destAccountId); @@ -3290,6 +3356,8 @@ export const useEmailStore = create((set, get) => ({ } } + if (move && movedIds.length > 0) emitEmailMove({ ...move, emailIds: movedIds }, hookMailboxes); + if (failures.length > 0) { const first = failures[0]; const verb = keepOriginal ? 'copy' : 'move'; @@ -3383,6 +3451,9 @@ export const useEmailStore = create((set, get) => ({ return; } + const move = { emailIds: threadEmailIds, fromMailboxId: moveSourceMailboxId([email], mailboxes), toMailboxId: destinationMailboxId }; + if (!(await allowEmailMove(move, mailboxes))) return; + await effectiveClient.batchMoveEmails(threadEmailIds, jmapDestId, accountId); const removedEmailIds = new Set(threadEmailIds); @@ -3407,6 +3478,7 @@ export const useEmailStore = create((set, get) => ({ threadEmailsCache: nextThreadEmailsCache, }; }); + emitEmailMove(move, mailboxes); } catch (error) { set({ error: error instanceof Error ? error.message : "Failed to move email thread" @@ -3692,6 +3764,10 @@ export const useEmailStore = create((set, get) => ({ if (!email) return; const isFlagged = email.keywords.$flagged || false; + if (emailHooks.onBeforeEmailStarToggle.size > 0 + && !(await emailHooks.onBeforeEmailStarToggle.intercept({ emailIds: [emailId], starred: !isFlagged }))) { + return; + } // In unified view route to the email's own account client + owner accountId // (the reaching client's primary is not the owner for shared sources). (#281) const { client: actionClient, accountId } = resolveEmailActionContext(email, client); @@ -3714,6 +3790,7 @@ export const useEmailStore = create((set, get) => ({ emailId, ), })); + void emailHooks.onEmailStarToggle.emit({ emailIds: [emailId], starred: !isFlagged }); refillAfterKeywordChange(get, client, ['$flagged']); } catch (error) { set({ @@ -3728,8 +3805,13 @@ export const useEmailStore = create((set, get) => ({ // $mdnsent). Same routing, same reason: an unrouted write against the // reaching client's primary account is a silent no-op on a shared/group // message. Best-effort by contract - callers treat a failure as non-fatal. + if (emailHooks.onBeforeEmailKeywordChange.size > 0 + && !(await emailHooks.onBeforeEmailKeywordChange.intercept({ emailIds: [emailId], added: [keyword], removed: [] }))) { + return; + } const { client: actionClient, accountId } = resolveKeywordActionContext(emailId, client); await actionClient.setKeyword(emailId, keyword, accountId); + void emailHooks.onEmailKeywordChange.emit({ emailIds: [emailId], added: [keyword], removed: [] }); }, setEmailKeywords: async (client, emailId, keywords) => { @@ -3760,6 +3842,10 @@ export const useEmailStore = create((set, get) => ({ patchEmailKeywords: async (client, emailId, changes) => { const keys = Object.keys(changes); if (keys.length === 0) return; + if (emailHooks.onBeforeEmailKeywordChange.size > 0 + && !(await emailHooks.onBeforeEmailKeywordChange.intercept({ emailIds: [emailId], ...keywordChangeLists(changes) }))) { + return; + } const { client: actionClient, accountId } = resolveKeywordActionContext(emailId, client); // `keywords/` pointers touch only these keywords on the server; // a cleared keyword is removed (null), since a keyword map holds `true` @@ -3774,6 +3860,7 @@ export const useEmailStore = create((set, get) => ({ else delete next[key]; } get().setEmailKeywordsLocal(emailId, next); + void emailHooks.onEmailKeywordChange.emit({ emailIds: [emailId], ...keywordChangeLists(changes) }); refillAfterKeywordChange(get, client, keys.filter(k => !!previous[k] !== !!changes[k])); }, @@ -3809,9 +3896,15 @@ export const useEmailStore = create((set, get) => ({ const targetIds = target ? new Set(target.emailIds) : null; const emailIdsArray = targetIds ? Array.from(targetIds) : selectedEmails.map(e => e.id); if (emailIdsArray.length === 0) return; + if (emailHooks.onBeforeEmailReadStateChange.size > 0 + && !(await emailHooks.onBeforeEmailReadStateChange.intercept({ emailIds: [...new Set(emailIdsArray)], read }))) { + return; + } set({ isLoading: true, error: null }); try { + // Ids of an account whose write failed or that no client reaches. + const notWritten = new Set(); if (target) { // Messages named by the caller need not be loaded, so the list's @@ -3830,9 +3923,15 @@ export const useEmailStore = create((set, get) => ({ const acctClient = sourceAccountId === '__default__' ? resolveActionClient(client) : (clientAccountId ? useAuthStore.getState().getClientForAccount(clientAccountId) : undefined); - if (!acctClient) return; + if (!acctClient) { + ids.forEach(id => notWritten.add(id)); + return; + } const jmapAccountId = sourceAccountId === '__default__' ? undefined : sourceAccountId; - await acctClient.batchMarkAsRead(ids, read, jmapAccountId); + await acctClient.batchMarkAsRead(ids, read, jmapAccountId).catch((error) => { + ids.forEach(id => notWritten.add(id)); + throw error; + }); }); await Promise.allSettled(promises); } else { @@ -3892,6 +3991,8 @@ export const useEmailStore = create((set, get) => ({ selectedEmailKeys: target ? get().selectedEmailKeys : new Set(), isLoading: false }); + const changedIds = [...new Set(emailIdsArray)].filter(id => !notWritten.has(id)); + if (changedIds.length > 0) void emailHooks.onEmailReadStateChange.emit({ emailIds: changedIds, read }); refillAfterKeywordChange(get, client, ['$seen']); } catch (error) { set({ @@ -3925,14 +4026,24 @@ export const useEmailStore = create((set, get) => ({ byAccount.set(accountId, [...(byAccount.get(accountId) ?? []), email]); } + if (groups.size === 0) return; + const tagChange = add ? { added: changedKeys, removed: [] } : { added: [], removed: changedKeys }; + if (emailHooks.onBeforeEmailKeywordChange.size > 0) { + const emailIds = [...new Set([...groups.values()].flatMap(byAccount => [...byAccount.values()].flat().map(e => e.id)))]; + if (!(await emailHooks.onBeforeEmailKeywordChange.intercept({ emailIds, ...tagChange }))) return; + } + const writes = [...groups].flatMap(([actionClient, byAccount]) => [...byAccount].map(async ([accountId, group]) => { await actionClient.batchUpdateKeywords(group.map(e => e.id), patch, accountId); - return group.map(emailKeyFor); + return group; }), ); - if (writes.length === 0) return; - const results = await Promise.allSettled(writes); + const settled = await Promise.allSettled(writes); + const writtenEmails = settled.flatMap(result => result.status === 'fulfilled' ? result.value : []); + const results = settled.map(result => result.status === 'fulfilled' + ? { status: 'fulfilled' as const, value: result.value.map(emailKeyFor) } + : result); // Only the accounts whose write went through are patched locally. const written = new Set(results.flatMap(result => result.status === 'fulfilled' ? result.value : [])); @@ -3949,6 +4060,7 @@ export const useEmailStore = create((set, get) => ({ : state.selectedEmail, })); if (written.size > 0) { + void emailHooks.onEmailKeywordChange.emit({ emailIds: [...new Set(writtenEmails.map(e => e.id))], ...tagChange }); void get().fetchTagCounts(client); refillAfterKeywordChange(get, client, changedKeys); } @@ -3962,6 +4074,15 @@ export const useEmailStore = create((set, get) => ({ const mailboxes = resolveActionMailboxes(); if (selectedEmailKeys.size === 0) return; + if (emailHooks.onBeforeEmailDelete.size > 0) { + const toDelete = resolveSelectedEmails(get()); + if (toDelete.length > 0) { + const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); + const destroys = permanent || bulkDeleteIsPermanent(currentMailbox); + if (!(await emailHooks.onBeforeEmailDelete.intercept({ emailIds: [...new Set(toDelete.map(e => e.id))], permanent: destroys }))) return; + } + } + set({ isLoading: true, error: null }); try { // The selection is keyed by owning account; JMAP takes the bare ids of @@ -4012,11 +4133,14 @@ export const useEmailStore = create((set, get) => ({ const jmapIdFor = (sourceAccountId: string) => sourceAccountId === '__default__' ? viewAccountId : sourceAccountId; + // Ids the server confirmed, for onAfterEmailDelete. + const deletedIds = new Set(); if (forceDestroy) { const promises = Array.from(bySource.entries()).map(async ([sourceAccountId, { clientAccountId, ids }]) => { const acctClient = getClient(sourceAccountId, clientAccountId); if (!acctClient) return; await acctClient.batchDeleteEmails(ids, jmapIdFor(sourceAccountId)); + ids.forEach(id => deletedIds.add(id)); }); await Promise.allSettled(promises); } else { @@ -4042,6 +4166,7 @@ export const useEmailStore = create((set, get) => ({ const trashId = trashMailbox.originalId || trashMailbox.id; await acctClient.batchMoveEmails(ids, trashId, jmapIdFor(sourceAccountId), alsoMarkRead); keys.forEach(key => movedKeys.add(key)); + ids.forEach(id => deletedIds.add(id)); }); await Promise.allSettled(promises); @@ -4063,6 +4188,7 @@ export const useEmailStore = create((set, get) => ({ isLoading: false, error: 'Some emails could not be moved: trash folder missing for one or more accounts', }); + void emailHooks.onAfterEmailDelete.emit({ emailIds: [...deletedIds], permanent: false }); return; } } @@ -4081,6 +4207,7 @@ export const useEmailStore = create((set, get) => ({ selectedEmail: null, isLoading: false }); + if (deletedIds.size > 0) void emailHooks.onAfterEmailDelete.emit({ emailIds: [...deletedIds], permanent: forceDestroy }); } catch (error) { set({ error: error instanceof Error ? error.message : "Failed to delete emails", @@ -4096,9 +4223,18 @@ export const useEmailStore = create((set, get) => ({ const targetIds = target ? new Set(target.emailIds) : null; const emailIdsArray = targetIds ? Array.from(targetIds) : selectedEmails.map(e => e.id); if (emailIdsArray.length === 0) return; + const hookMailboxes = resolveActionMailboxes(); + const move = { + emailIds: [...new Set(emailIdsArray)], + fromMailboxId: moveSourceMailboxId(targetIds ? emails.filter(e => targetIds.has(e.id)) : selectedEmails, hookMailboxes), + toMailboxId, + }; + if (!(await allowEmailMove(move, hookMailboxes))) return; set({ isLoading: true, error: null }); try { + // Ids of an account whose move failed or that no client reaches. + const notMoved = new Set(); if (target) { // Messages named by the caller need not be loaded, so the list's @@ -4120,9 +4256,15 @@ export const useEmailStore = create((set, get) => ({ const acctClient = sourceAccountId === '__default__' ? resolveActionClient(client) : (clientAccountId ? useAuthStore.getState().getClientForAccount(clientAccountId) : undefined); - if (!acctClient) return; + if (!acctClient) { + ids.forEach(id => notMoved.add(id)); + return; + } const jmapAccountId = sourceAccountId === '__default__' ? undefined : sourceAccountId; - await acctClient.batchMoveEmails(ids, jmapDestId, jmapAccountId); + await acctClient.batchMoveEmails(ids, jmapDestId, jmapAccountId).catch((error) => { + ids.forEach(id => notMoved.add(id)); + throw error; + }); }); await Promise.allSettled(promises); } else { @@ -4145,6 +4287,7 @@ export const useEmailStore = create((set, get) => ({ selectedEmailKeys: target ? dropFromSelection(get(), moved) : new Set(), isLoading: false }); + emitEmailMove({ ...move, emailIds: move.emailIds.filter(id => !notMoved.has(id)) }, hookMailboxes); // Refresh emails to get updated list (honors active search/filters) if (!get().isUnifiedView) { @@ -4188,6 +4331,14 @@ export const useEmailStore = create((set, get) => ({ const selected = emails.filter(e => selectedEmailKeys.has(emailKeyFor(e))); if (selected.length === 0) return; + const hookMailboxes = [...resolveActionMailboxes(), archiveMailbox]; + const move = { + emailIds: [...new Set(selected.map(e => e.id))], + fromMailboxId: moveSourceMailboxId(selected, hookMailboxes), + toMailboxId: archiveMailbox.id, + }; + if (!(await allowEmailMove(move, hookMailboxes))) return; + set({ isLoading: true, error: null }); try { await resolveActionClient(client).batchArchiveEmails( @@ -4200,6 +4351,7 @@ export const useEmailStore = create((set, get) => ({ const remaining = emails.filter(e => !selectedEmailKeys.has(emailKeyFor(e))); set({ emails: remaining, selectedEmailKeys: new Set(), isLoading: false }); + emitEmailMove(move, hookMailboxes); // Refresh the active or viewed account's mailbox cache after the // archive (a year/month archive can create new sub-folders). @@ -4226,6 +4378,10 @@ export const useEmailStore = create((set, get) => ({ if (!email) { throw new Error('Email not found - cannot mark as spam'); } + if (emailHooks.onBeforeEmailSpamToggle.size > 0 + && !(await emailHooks.onBeforeEmailSpamToggle.intercept({ emailIds: [emailId], spam: true }))) { + return; + } // In unified view route to the email's own account (client + that account's // mailbox list); otherwise the active/viewing context. (#281) @@ -4294,6 +4450,7 @@ export const useEmailStore = create((set, get) => ({ ...mailboxPatch, }; }); + void emailHooks.onEmailSpamToggle.emit({ emailIds: [emailId], spam: true }); } catch (error) { console.error('Failed to mark as spam:', error); throw error; @@ -4301,6 +4458,10 @@ export const useEmailStore = create((set, get) => ({ }, undoSpam: async (client, emailId) => { + if (emailHooks.onBeforeEmailSpamToggle.size > 0 + && !(await emailHooks.onBeforeEmailSpamToggle.intercept({ emailIds: [emailId], spam: false }))) { + return; + } const { selectedMailbox } = get(); const mailboxes = resolveActionMailboxes(); @@ -4360,6 +4521,7 @@ export const useEmailStore = create((set, get) => ({ emails: state.emails.filter(e => e.id !== emailId), selectedEmail: getNextSelectedEmail(state, emailId), })); + void emailHooks.onEmailSpamToggle.emit({ emailIds: [emailId], spam: false }); // Refresh the view the user is actually looking at. If they leave it // while the account list loads, skip the fetch rather than re-enter it. @@ -5026,6 +5188,10 @@ export const useEmailStore = create((set, get) => ({ .map(e => e.id); if (unreadIds.length === 0) return; + if (emailHooks.onBeforeEmailReadStateChange.size > 0 + && !(await emailHooks.onBeforeEmailReadStateChange.intercept({ emailIds: [...unreadIds], read: true }))) { + return; + } // Group by account for unified view support const emailsById = new Map(); @@ -5092,6 +5258,7 @@ export const useEmailStore = create((set, get) => ({ : state.selectedEmail, }; }); + void emailHooks.onEmailReadStateChange.emit({ emailIds: [...unreadIds], read: true }); refillAfterKeywordChange(get, client, ['$seen']); },