feat: tell plugins when folders are created, renamed, deleted or emptied, and when the quota changes

This commit is contained in:
Linus Rath
2026-10-14 03:55:18 +02:00
parent 286cce6877
commit 77d8b604ae
3 changed files with 231 additions and 5 deletions
@@ -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>): 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 }],
]);
});
});
+2 -1
View File
@@ -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> = {}): 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();
}
+37 -4
View File
@@ -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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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