diff --git a/stores/__tests__/email-store-plugin-mailbox-hooks.test.ts b/stores/__tests__/email-store-plugin-mailbox-hooks.test.ts new file mode 100644 index 000000000..bd0eb48cb --- /dev/null +++ b/stores/__tests__/email-store-plugin-mailbox-hooks.test.ts @@ -0,0 +1,192 @@ +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 type { Mailbox } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +// Folder hooks: create, rename, delete, empty, the refreshed folder list and +// the storage quota. + +function makeMailbox(overrides: Partial): Mailbox { + return { + id: 'inbox', + name: 'Inbox', + sortOrder: 0, + totalEmails: 0, + unreadEmails: 0, + totalThreads: 0, + unreadThreads: 0, + isSubscribed: true, + isShared: false, + myRights: { mayRename: true, mayDelete: true }, + ...overrides, + } as Mailbox; +} + +const mailboxes = [ + makeMailbox({ id: 'inbox', role: 'inbox', totalEmails: 4, unreadEmails: 1 }), + makeMailbox({ id: 'projects', name: 'Projects', parentId: 'inbox' }), + makeMailbox({ id: 'junk', name: 'Junk', role: 'junk', totalEmails: 7 }), + makeMailbox({ id: 'trash', name: 'Trash', role: 'trash' }), +]; + +let client: IJMAPClient; + +beforeEach(() => { + clearAllHooks(); + client = { + getAccountId: () => 'me', + createMailbox: vi.fn().mockResolvedValue(makeMailbox({ id: 'new-1', name: 'Receipts' })), + updateMailbox: vi.fn().mockResolvedValue(undefined), + deleteMailbox: vi.fn().mockResolvedValue(undefined), + emptyMailbox: vi.fn().mockResolvedValue(undefined), + moveMailboxContents: vi.fn().mockResolvedValue(undefined), + getAllMailboxes: vi.fn().mockResolvedValue(mailboxes), + // A fresh object per read, as from the server. + getQuota: vi.fn().mockImplementation(async () => ({ used: 50, total: 200 })), + } as unknown as IJMAPClient; + useAuthStore.setState({ activeAccountId: 'acct', getClientForAccount: () => client as never } as never); + useSettingsStore.setState({ deleteAction: 'trash' } as never); + useEmailStore.setState({ + mailboxes, + accountMailboxes: {}, + viewingAccountId: null, + selectedMailbox: 'inbox', + emails: [], + quota: null, + error: null, + }); +}); + +afterEach(() => clearAllHooks()); + +describe('folder hooks', () => { + it('asks before creating a folder and reports the new folder', async () => { + const before = vi.fn(() => true); + const after = vi.fn(); + emailHooks.onBeforeMailboxCreate.register('t', before); + emailHooks.onMailboxCreate.register('t', after); + + await useEmailStore.getState().createMailbox(client, 'Receipts', 'inbox'); + + expect(before).toHaveBeenCalledWith({ name: 'Receipts', parentId: 'inbox' }); + expect(after).toHaveBeenCalledWith({ mailboxId: 'new-1', name: 'Receipts', parentId: 'inbox' }); + }); + + it('creates nothing when a plugin refuses', async () => { + const after = vi.fn(); + emailHooks.onBeforeMailboxCreate.register('t', () => false); + emailHooks.onMailboxCreate.register('t', after); + + await useEmailStore.getState().createMailbox(client, 'Receipts'); + + expect(client.createMailbox).not.toHaveBeenCalled(); + expect(after).not.toHaveBeenCalled(); + }); + + it('does not report a folder the server refused to create', async () => { + vi.mocked(client.createMailbox).mockRejectedValue(new Error('nope')); + const after = vi.fn(); + emailHooks.onMailboxCreate.register('t', after); + + await expect(useEmailStore.getState().createMailbox(client, 'Receipts')).rejects.toThrow('nope'); + expect(after).not.toHaveBeenCalled(); + }); + + it('reports a rename with the previous name', async () => { + const before = vi.fn(() => true); + const after = vi.fn(); + emailHooks.onBeforeMailboxRename.register('t', before); + emailHooks.onMailboxRename.register('t', after); + + await useEmailStore.getState().renameMailbox(client, 'projects', 'Work'); + + expect(before).toHaveBeenCalledWith({ mailboxId: 'projects', name: 'Work' }); + expect(after).toHaveBeenCalledWith({ mailboxId: 'projects', name: 'Work', previousName: 'Projects' }); + }); + + it('keeps the name when a plugin refuses a rename', async () => { + emailHooks.onBeforeMailboxRename.register('t', () => false); + + await useEmailStore.getState().renameMailbox(client, 'projects', 'Work'); + + expect(client.updateMailbox).not.toHaveBeenCalled(); + expect(useEmailStore.getState().mailboxes.find(m => m.id === 'projects')?.name).toBe('Projects'); + }); + + it('asks before deleting a folder and reports it', async () => { + const before = vi.fn(() => true); + const after = vi.fn(); + emailHooks.onBeforeMailboxDelete.register('t', before); + emailHooks.onMailboxDelete.register('t', after); + + await useEmailStore.getState().deleteMailbox(client, 'projects'); + + expect(before).toHaveBeenCalledWith({ mailboxId: 'projects', name: 'Projects' }); + expect(after).toHaveBeenCalledWith({ mailboxId: 'projects', name: 'Projects' }); + }); + + it('keeps a folder a plugin refuses to delete', async () => { + emailHooks.onBeforeMailboxDelete.register('t', () => false); + + await useEmailStore.getState().deleteMailbox(client, 'projects'); + + expect(client.deleteMailbox).not.toHaveBeenCalled(); + expect(useEmailStore.getState().mailboxes.some(m => m.id === 'projects')).toBe(true); + }); + + it('asks before emptying a folder and reports how many messages it held', async () => { + const before = vi.fn(() => true); + const after = vi.fn(); + emailHooks.onBeforeMailboxEmpty.register('t', before); + emailHooks.onMailboxEmpty.register('t', after); + + await useEmailStore.getState().emptyMailbox(client, 'junk'); + + expect(before).toHaveBeenCalledWith({ mailboxId: 'junk', role: 'junk' }); + expect(after).toHaveBeenCalledWith({ mailboxId: 'junk', role: 'junk', count: 7 }); + }); + + it('leaves a folder full when a plugin refuses to empty it', async () => { + const after = vi.fn(); + emailHooks.onBeforeMailboxEmpty.register('t', () => false); + emailHooks.onMailboxEmpty.register('t', after); + + await useEmailStore.getState().emptyMailbox(client, 'junk'); + + expect(client.emptyMailbox).not.toHaveBeenCalled(); + expect(useEmailStore.getState().mailboxes.find(m => m.id === 'junk')?.totalEmails).toBe(7); + expect(after).not.toHaveBeenCalled(); + }); + + it('hands plugins plain folder views on refresh', async () => { + const refreshed = vi.fn(); + emailHooks.onMailboxesRefresh.register('t', refreshed); + + await useEmailStore.getState().fetchMailboxes(client); + + expect(refreshed).toHaveBeenCalledTimes(1); + expect(refreshed.mock.calls[0][0][1]).toEqual({ + id: 'projects', name: 'Projects', role: null, totalEmails: 0, unreadEmails: 0, parentId: 'inbox', + }); + }); +}); + +describe('quota hook', () => { + it('reports the quota when it was read and changed, not when it stayed the same', async () => { + const changed = vi.fn(); + emailHooks.onQuotaChange.register('t', changed); + + await useEmailStore.getState().fetchQuota(client); + await useEmailStore.getState().fetchQuota(client); + vi.mocked(client.getQuota).mockImplementation(async () => ({ used: 60, total: 200 })); + await useEmailStore.getState().fetchQuota(client); + + expect(changed.mock.calls).toEqual([ + [{ used: 50, total: 200, percentUsed: 25 }], + [{ used: 60, total: 200, percentUsed: 30 }], + ]); + }); +}); diff --git a/stores/__tests__/folder-management.test.ts b/stores/__tests__/folder-management.test.ts index 4e9d3816b..ee9e53eba 100644 --- a/stores/__tests__/folder-management.test.ts +++ b/stores/__tests__/folder-management.test.ts @@ -5,6 +5,7 @@ import type { Mailbox } from '@/lib/jmap/types'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import { UNIFIED_MAILBOX_IDS } from '@/lib/jmap/types'; import { emailHooks } from '@/lib/plugin-hooks'; +import { mailboxToView } from '@/lib/plugin-mail-hooks'; function makeMailbox(overrides: Partial = {}): Mailbox { return { @@ -454,7 +455,7 @@ describe('email-store folder management', () => { try { await useEmailStore.getState().fetchMailboxes(client); - expect(observer).toHaveBeenCalledWith(refreshed); + expect(observer).toHaveBeenCalledWith(refreshed.map(mailboxToView)); } finally { disposable.dispose(); } diff --git a/stores/email-store.ts b/stores/email-store.ts index 8c23ef488..6ab2e5802 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -7,7 +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 { allowEmailMove, emitEmailMove, keywordChangeLists, mailboxToView, quotaToView } 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"; @@ -2179,7 +2179,7 @@ export const useEmailStore = create((set, get) => ({ // Publish the existing mailbox-refresh extension hook after a successful // fetch. This is also emitted for Mailbox-only state changes (for example // an empty provider label), where no Email fetch follows. - await emailHooks.onMailboxesRefresh.emit(mailboxes); + if (emailHooks.onMailboxesRefresh.size > 0) await emailHooks.onMailboxesRefresh.emit(mailboxes.map(mailboxToView)); if (stale()) return; // Auto-select inbox if no mailbox is selected or the current selection @@ -4626,7 +4626,7 @@ export const useEmailStore = create((set, get) => ({ if (patched) { // Same extension hook a full refresh publishes, so plugins that mirror // folder counters see delta updates too. - await emailHooks.onMailboxesRefresh.emit(mailboxes); + if (emailHooks.onMailboxesRefresh.size > 0) await emailHooks.onMailboxesRefresh.emit(mailboxes.map(mailboxToView)); } return true; }, @@ -5322,7 +5322,12 @@ export const useEmailStore = create((set, get) => ({ const target = parentId ? resolveMailboxMutationContext(client, parentId) : { ...resolveMailboxAccountTarget(client, accountId), mailboxId: undefined }; - await target.client.createMailbox(name, target.mailboxId, target.accountId); + if (emailHooks.onBeforeMailboxCreate.size > 0 + && !(await emailHooks.onBeforeMailboxCreate.intercept({ name, parentId: parentId ?? null }))) { + return; + } + const created = await target.client.createMailbox(name, target.mailboxId, target.accountId); + void emailHooks.onMailboxCreate.emit({ mailboxId: created?.id ?? null, name, parentId: parentId ?? null }); if (get().viewingAccountId) { await refreshMailboxesForViewingAccount(client); } else { @@ -5337,7 +5342,13 @@ export const useEmailStore = create((set, get) => ({ renameMailbox: async (client, mailboxId, name) => { try { const target = resolveMailboxMutationContext(client, mailboxId); + if (emailHooks.onBeforeMailboxRename.size > 0 + && !(await emailHooks.onBeforeMailboxRename.intercept({ mailboxId, name }))) { + return; + } + const previousName = resolveActionMailboxes().find(mb => mb.id === mailboxId)?.name ?? null; await target.client.updateMailbox(target.mailboxId, { name }, target.accountId); + void emailHooks.onMailboxRename.emit({ mailboxId, name, previousName }); const viewingId = get().viewingAccountId; if (viewingId) { set((state) => ({ @@ -5364,6 +5375,11 @@ export const useEmailStore = create((set, get) => ({ deleteMailbox: async (client, mailboxId, options) => { try { const target = resolveMailboxMutationContext(client, mailboxId); + const deletedName = resolveActionMailboxes().find(mb => mb.id === mailboxId)?.name ?? null; + if (emailHooks.onBeforeMailboxDelete.size > 0 + && !(await emailHooks.onBeforeMailboxDelete.intercept({ mailboxId, name: deletedName }))) { + return; + } // Only pass the options when set: the plain call keeps the two-argument // shape other client implementations (demo, plugins) expect. if (options?.removeEmails) { @@ -5371,6 +5387,7 @@ export const useEmailStore = create((set, get) => ({ } else { await target.client.deleteMailbox(target.mailboxId, target.accountId); } + void emailHooks.onMailboxDelete.emit({ mailboxId, name: deletedName }); const { selectedMailbox, viewingAccountId: viewingId } = get(); if (viewingId) { const updatedList = (get().accountMailboxes[viewingId] ?? []).filter(mb => mb.id !== mailboxId); @@ -5585,6 +5602,11 @@ export const useEmailStore = create((set, get) => ({ }, emptyMailbox: async (client, mailboxId) => { + const emptied = resolveActionMailboxes().find(mb => mb.id === mailboxId); + if (emailHooks.onBeforeMailboxEmpty.size > 0 + && !(await emailHooks.onBeforeMailboxEmpty.intercept({ mailboxId, role: emptied?.role ?? null }))) { + return; + } try { set({ isLoading: true, error: null }); const mailboxes = resolveActionMailboxes(); @@ -5643,6 +5665,7 @@ export const useEmailStore = create((set, get) => ({ set({ mailboxes: get().mailboxes.map(updateCounters) }); } set({ isLoading: false }); + void emailHooks.onMailboxEmpty.emit({ mailboxId, role: mailbox?.role ?? null, count: mailbox?.totalEmails ?? 0 }); } catch (error) { set({ error: error instanceof Error ? error.message : 'Failed to empty folder', @@ -6243,6 +6266,16 @@ export const useEmailStore = create((set, get) => ({ }, })); +// Plugin hooks that follow a piece of store state rather than one action, so +// every path that changes it (clicks, keyboard, a batch action clearing the +// selection, an account switch) fires them once. +useEmailStore.subscribe((state, prev) => { + if (state.quota && state.quota !== prev.quota + && (state.quota.used !== prev.quota?.used || state.quota.total !== prev.quota?.total)) { + void emailHooks.onQuotaChange.emit(quotaToView(state.quota)); + } +}); + // Keep the unified-section badges in lockstep with the live per-account mailbox // lists. Whenever `mailboxes`, `accountMailboxes`, or the unified scope change - // i.e. after any optimistic delete/move/markRead patch or a push-driven mailbox