mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: tell plugins when folders are created, renamed, deleted or emptied, and when the quota changes
This commit is contained in:
@@ -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 }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user