mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 08:15:41 +00:00
148 lines
8.7 KiB
TypeScript
148 lines
8.7 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import type { InstalledPlugin } from '../plugin-types';
|
|
import type { IJMAPClient } from '../jmap/client-interface';
|
|
import type { Mailbox } from '../jmap/types';
|
|
import { useAuthStore } from '@/stores/auth-store';
|
|
import { useEmailStore } from '@/stores/email-store';
|
|
import { useSettingsStore } from '@/stores/settings-store';
|
|
import { resetPluginRateLimits } from '../plugin-sandbox/host-api-input';
|
|
import { clearUndoHistory, undoHistory, undoLatest } from '../undo-history';
|
|
import { clearUndoTokens } from '../plugin-sandbox/assistant/c.undo';
|
|
|
|
vi.mock('@/stores/toast-store', () => ({
|
|
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
|
|
}));
|
|
|
|
import { dispatchApiCall } from '../plugin-sandbox/host-api';
|
|
|
|
function plugin(id: string, perms: string[]): InstalledPlugin {
|
|
return {
|
|
id, name: id, version: '1.0.0', author: 'test', description: '',
|
|
type: 'hook', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js',
|
|
enabled: true, status: 'running', settings: {},
|
|
};
|
|
}
|
|
const WRITER = plugin('assistant', ['email:write']);
|
|
const call = (p: InstalledPlugin, method: string, ...args: unknown[]) => dispatchApiCall(p, method, args);
|
|
|
|
const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox' } as Mailbox;
|
|
const news = { id: 'news', name: 'Newsletters', role: null } as unknown as Mailbox;
|
|
const trash = { id: 'trash', name: 'Trash', role: 'trash' } as Mailbox;
|
|
// As the client lists folders: without their share maps (only Mailbox/get
|
|
// with shareWith, getMailboxShareWith, says who else reads a folder).
|
|
const sharedOut = { id: 'team', name: 'Team', role: null } as unknown as Mailbox;
|
|
const underShared = { id: 'team-sub', name: 'Projects', role: null, parentId: 'team' } as unknown as Mailbox;
|
|
const unknownShare = { id: 'flaky', name: 'Flaky', role: null } as unknown as Mailbox;
|
|
const SHARES: Record<string, unknown> = { team: { u2: { mayReadItems: true } } };
|
|
const otherAccount = { id: 'acc2:box', name: 'Theirs', isShared: true, accountId: 'acc2' } as unknown as Mailbox;
|
|
|
|
const rows = [
|
|
{ id: 'm1', mailboxIds: { inbox: true }, keywords: { $seen: true }, receivedAt: '2026-10-01T10:00:00Z' },
|
|
{ id: 'm2', mailboxIds: { inbox: true, news: true }, keywords: {}, receivedAt: '2026-10-02T10:00:00Z' },
|
|
];
|
|
let getEmailFields: ReturnType<typeof vi.fn>;
|
|
let client: IJMAPClient;
|
|
const store = {
|
|
batchArchive: vi.fn(async () => true),
|
|
batchMoveToMailbox: vi.fn(async () => true),
|
|
batchMarkAsRead: vi.fn(async () => true),
|
|
batchSetTag: vi.fn(async () => true),
|
|
undoMove: vi.fn(async () => {}),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
resetPluginRateLimits();
|
|
clearUndoHistory();
|
|
clearUndoTokens();
|
|
for (const fn of Object.values(store)) fn.mockClear();
|
|
getEmailFields = vi.fn(async (ids: string[]) => rows.filter((r) => ids.includes(r.id)));
|
|
client = {
|
|
getAccountId: () => 'acc1', getEmailFields,
|
|
supportsMailboxSharing: () => true,
|
|
getMailboxShareWith: vi.fn(async (id: string) => {
|
|
if (id === 'flaky') throw new Error('Mailbox/get failed');
|
|
return SHARES[id] ?? null;
|
|
}),
|
|
} as unknown as IJMAPClient;
|
|
useAuthStore.setState({ client });
|
|
useEmailStore.setState({ mailboxes: [inbox, news, trash, sharedOut, underShared, unknownShare, otherAccount], ...store } as never);
|
|
useSettingsStore.setState({ emailKeywords: [{ id: 'work', label: 'Work', color: 'blue' }] } as never);
|
|
});
|
|
afterEach(() => {
|
|
useAuthStore.setState({ client: null });
|
|
});
|
|
|
|
describe('api.email.batch', () => {
|
|
it('archives the messages of the account, as the store archives them, with one Undo', async () => {
|
|
const result = await call(WRITER, 'email.batch', { emailIds: ['m1', 'm2', 'gone'], action: 'archive' }) as { done: number; failed: number; undoToken: string };
|
|
expect(result).toMatchObject({ done: 2, failed: 1 });
|
|
// From the signed-in account only, and through the store with an explicit target.
|
|
expect(getEmailFields).toHaveBeenCalledWith(['m1', 'm2', 'gone'], expect.any(Array), undefined);
|
|
expect(store.batchArchive).toHaveBeenCalledWith(client, expect.objectContaining({ emailIds: ['m1', 'm2'], client }));
|
|
// One entry in the app's undo history for the whole batch.
|
|
expect(undoHistory()).toHaveLength(1);
|
|
|
|
await call(WRITER, 'email.undoBatch', result.undoToken);
|
|
// The restore points were taken before: every folder and the read state.
|
|
expect(store.undoMove).toHaveBeenCalledTimes(1);
|
|
expect(store.undoMove).toHaveBeenCalledWith(client, [{
|
|
client, accountId: undefined,
|
|
points: [{ id: 'm1', mailboxIds: { inbox: true }, seen: true }, { id: 'm2', mailboxIds: { inbox: true, news: true }, seen: false }],
|
|
}], []);
|
|
// Off the history too, and it works once.
|
|
expect(undoHistory()).toHaveLength(0);
|
|
await expect(call(WRITER, 'email.undoBatch', result.undoToken)).rejects.toThrow(/can't be undone/);
|
|
});
|
|
|
|
it('runs the undo once whether Ctrl+Z or the plugin comes first', async () => {
|
|
const { undoToken } = await call(WRITER, 'email.batch', { emailIds: ['m1'], action: 'move', mailboxId: 'news' }) as { undoToken: string };
|
|
expect(store.batchMoveToMailbox).toHaveBeenCalledWith(client, 'news', expect.objectContaining({ emailIds: ['m1'] }));
|
|
undoLatest();
|
|
await call(WRITER, 'email.undoBatch', undoToken);
|
|
expect(store.undoMove).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('marks read only what changes and puts exactly that back', async () => {
|
|
const { undoToken } = await call(WRITER, 'email.batch', { emailIds: ['m1', 'm2'], action: 'read' }) as { undoToken: string };
|
|
expect(store.batchMarkAsRead).toHaveBeenLastCalledWith(client, true, { emailIds: ['m2'], client });
|
|
await call(WRITER, 'email.undoBatch', undoToken);
|
|
expect(store.batchMarkAsRead).toHaveBeenLastCalledWith(client, false, { emailIds: ['m2'], client });
|
|
});
|
|
|
|
it('tags through the store and takes the tag off again on undo', async () => {
|
|
const { undoToken } = await call(WRITER, 'email.batch', { emailIds: ['m1', 'm2'], action: 'tag', tagId: 'work' }) as { undoToken: string };
|
|
expect(store.batchSetTag).toHaveBeenLastCalledWith(client, 'work', true, expect.objectContaining({ emailIds: ['m1', 'm2'] }));
|
|
await call(WRITER, 'email.undoBatch', undoToken);
|
|
expect(store.batchSetTag).toHaveBeenLastCalledWith(client, 'work', false, expect.objectContaining({ emailIds: ['m1', 'm2'] }));
|
|
await expect(call(WRITER, 'email.batch', { emailIds: ['m1'], action: 'tag', tagId: 'nope' })).rejects.toThrow(/not one of the user's tags/);
|
|
});
|
|
|
|
it('refuses shared folders, other accounts, the trash and more than 200 messages', async () => {
|
|
await expect(call(WRITER, 'email.batch', { emailIds: ['m1'], action: 'move', mailboxId: 'team' })).rejects.toThrow(/only the user reads/);
|
|
// A folder inside a shared one, and one whose share map the server didn't give.
|
|
await expect(call(WRITER, 'email.batch', { emailIds: ['m1'], action: 'move', mailboxId: 'team-sub' })).rejects.toThrow(/only the user reads/);
|
|
await expect(call(WRITER, 'email.batch', { emailIds: ['m1'], action: 'move', mailboxId: 'flaky' })).rejects.toThrow(/only the user reads/);
|
|
await expect(call(WRITER, 'email.batch', { emailIds: ['m1'], action: 'move', mailboxId: 'acc2:box' })).rejects.toThrow(/only the user reads/);
|
|
await expect(call(WRITER, 'email.batch', { emailIds: ['m1'], action: 'move', mailboxId: 'trash' })).rejects.toThrow(/trash/);
|
|
await expect(call(WRITER, 'email.batch', { emailIds: Array.from({ length: 201 }, (_, i) => `m${i}`), action: 'read' })).rejects.toThrow(/at most 200/);
|
|
await expect(call(WRITER, 'email.batch', { emailIds: ['m1'], action: 'delete' })).rejects.toThrow(/action must be/);
|
|
expect(store.batchMoveToMailbox).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('lets the plugin try a failed undo again', async () => {
|
|
const { undoToken } = await call(WRITER, 'email.batch', { emailIds: ['m1'], action: 'archive' }) as { undoToken: string };
|
|
store.undoMove.mockRejectedValueOnce(new Error('server down'));
|
|
await expect(call(WRITER, 'email.undoBatch', undoToken)).rejects.toThrow('server down');
|
|
await call(WRITER, 'email.undoBatch', undoToken);
|
|
expect(store.undoMove).toHaveBeenCalledTimes(2);
|
|
await expect(call(WRITER, 'email.undoBatch', undoToken)).rejects.toThrow(/can't be undone/);
|
|
});
|
|
|
|
it('keeps the undo to the plugin that made it, and needs email:write', async () => {
|
|
const { undoToken } = await call(WRITER, 'email.batch', { emailIds: ['m1'], action: 'archive' }) as { undoToken: string };
|
|
await expect(call(plugin('other', ['email:write']), 'email.undoBatch', undoToken)).rejects.toThrow(/can't be undone/);
|
|
expect(store.undoMove).not.toHaveBeenCalled();
|
|
await expect(call(plugin('reader', ['email:read']), 'email.batch', { emailIds: ['m1'], action: 'read' })).rejects.toThrow(/lacks permission "email:write"/);
|
|
});
|
|
});
|