Files
webmail/lib/__tests__/plugin-host-mail-read.test.ts
T

166 lines
8.6 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 { Email, Mailbox } from '../jmap/types';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
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(perms: string[]): InstalledPlugin {
return {
id: 'reader', name: 'Reader', version: '1.0.0', author: 'test', description: '',
type: 'hook', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js',
enabled: true, status: 'running', settings: {},
};
}
const READER = plugin(['email:read']);
const call = (p: InstalledPlugin, method: string, ...args: unknown[]) => dispatchApiCall(p, method, args);
function mail(id: string, over: Partial<Email> = {}): Email {
return {
id, threadId: `t-${id}`, mailboxIds: { inbox: true }, keywords: { $seen: true },
from: [{ name: 'Bob', email: 'bob@example.com' }], to: [{ email: 'ann@example.com' }],
subject: `Subject ${id}`, receivedAt: '2026-05-01T10:00:00Z', preview: 'preview', size: 1234,
hasAttachment: false,
...over,
} as Email;
}
const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 12, unreadEmails: 3, sortOrder: 0 } as Mailbox;
const shared = {
id: 'acc2:box', originalId: 'box', name: 'Team', role: undefined, parentId: undefined,
totalEmails: 1, unreadEmails: 0, isShared: true, accountId: 'acc2', sortOrder: 1,
} as unknown as Mailbox;
let client: Record<string, ReturnType<typeof vi.fn>>;
const realGetClientForAccount = useAuthStore.getState().getClientForAccount;
beforeEach(() => {
client = {
getEmail: vi.fn(async (id: string) => (id === 'm1'
? mail('m1', { textBody: [{ partId: '1' }], bodyValues: { 1: { value: 'Full body text' } } } as unknown as Partial<Email>)
: null)),
getEmails: vi.fn(async () => ({ emails: [mail('a'), mail('b')], hasMore: false, total: 2 })),
getMailboxes: vi.fn(async () => [inbox]),
};
useAuthStore.setState({ client: client as unknown as IJMAPClient });
useEmailStore.setState({ mailboxes: [inbox, shared], selectedEmail: null });
});
afterEach(() => {
useAuthStore.setState({ client: null, getClientForAccount: realGetClientForAccount });
useEmailStore.setState({ mailboxes: [], selectedEmail: null, selectedMailbox: '', emails: [] });
});
describe('api.email.get', () => {
it('returns any message with its body, not only the open one', async () => {
const view = await call(READER, 'email.get', 'm1') as { id: string; text: string; from: unknown };
expect(view).toEqual(expect.objectContaining({
id: 'm1', subject: 'Subject m1', text: 'Full body text', from: [{ name: 'Bob', email: 'bob@example.com' }],
}));
expect(client.getEmail).toHaveBeenCalledWith('m1');
expect(await call(READER, 'email.get', 'gone')).toBeNull();
});
it('asks the owning account for a listed message of a shared folder', async () => {
client.getEmail.mockImplementation(async (id: string, accountId?: string) => (
id === 's1' && accountId === 'acc2' ? mail('s1', { subject: 'Shared' }) : null));
useEmailStore.setState({ selectedMailbox: 'acc2:box', emails: [mail('s1', { mailboxIds: { 'acc2:box': true } })] });
expect(await call(READER, 'email.get', 's1')).toEqual(expect.objectContaining({ id: 's1', subject: 'Shared' }));
expect(client.getEmail).toHaveBeenCalledWith('s1', 'acc2');
});
it('reads no listed message of another connected login', async () => {
// Rows can be put in the list by a plugin (onEmailsFetched), stamps included.
const other = { getEmail: vi.fn(async () => mail('x1', { subject: 'Other login' })) };
useAuthStore.setState({
getClientForAccount: ((id: string) => (id === 'login2' ? other : undefined)) as never,
});
useEmailStore.setState({ emails: [mail('x1', { sourceClientAccountId: 'login2', sourceAccountId: 'acc9' })] });
expect(await call(READER, 'email.get', 'x1')).toBeNull();
expect(other.getEmail).not.toHaveBeenCalled();
// Nor is the signed-in login asked for an id that belongs to another one.
expect(client.getEmail).not.toHaveBeenCalled();
});
it('uses the open message when its body is loaded', async () => {
useEmailStore.setState({ selectedEmail: mail('open', { bodyValues: { 1: { value: 'x' } }, textBody: [{ partId: '1' }] } as unknown as Partial<Email>) });
expect(await call(READER, 'email.get', 'open')).toEqual(expect.objectContaining({ id: 'open', text: 'x' }));
expect(client.getEmail).not.toHaveBeenCalled();
});
it('needs email:read and a string id', async () => {
await expect(call(plugin([]), 'email.get', 'm1')).rejects.toThrow(/lacks permission "email:read"/);
await expect(call(READER, 'email.get', { id: 'm1' })).rejects.toThrow(/emailId must be a string/);
expect(client.getEmail).not.toHaveBeenCalled();
});
});
describe('api.email.search', () => {
it('runs a JMAP filter across all mail, newest first, as summaries', async () => {
const list = await call(READER, 'email.search', { text: 'invoice', from: 'carol', after: '2026-10-01' }) as { id: string }[];
expect(list.map((e) => e.id)).toEqual(['a', 'b']);
expect(list[0]).not.toHaveProperty('text');
expect(client.getEmails).toHaveBeenCalledWith(undefined, undefined, 20, 0, undefined, false, { text: 'invoice', from: 'carol', after: '2026-10-01T00:00:00.000Z' });
});
it('searches one folder, a shared one through its owner, with a subject and attachments', async () => {
await call(READER, 'email.search', { subject: 'offer', hasAttachment: true, mailboxId: 'acc2:box', limit: 5 });
expect(client.getEmails).toHaveBeenCalledWith('box', 'acc2', 5, 0, undefined, false, { subject: 'offer', hasAttachment: true });
});
it('wants a condition, a real date, at most 50 results, and email:read', async () => {
await expect(call(READER, 'email.search', {})).rejects.toThrow(/at least one of/);
await expect(call(READER, 'email.search', { text: ' ' })).rejects.toThrow(/at least one of/);
await expect(call(READER, 'email.search', { text: 'x', before: 'yesterday' })).rejects.toThrow(/before must be a date/);
await expect(call(READER, 'email.search', { text: 'x', limit: 51 })).rejects.toThrow(/limit must be a whole number from 1 to 50/);
await expect(call(READER, 'email.search', { body: 'x' })).rejects.toThrow(/unknown property "body"/);
await expect(call(plugin([]), 'email.search', { text: 'x' })).rejects.toThrow(/lacks permission "email:read"/);
expect(client.getEmails).not.toHaveBeenCalled();
});
});
describe('api.email.list', () => {
it('returns summaries of a folder page', async () => {
const list = await call(READER, 'email.list', { mailboxId: 'inbox', limit: 2, position: 10 }) as { id: string }[];
expect(list.map((e) => e.id)).toEqual(['a', 'b']);
expect(list[0]).not.toHaveProperty('text');
expect(client.getEmails).toHaveBeenCalledWith('inbox', undefined, 2, 10);
});
it('asks the owning account for a shared folder', async () => {
await call(READER, 'email.list', { mailboxId: 'acc2:box' });
expect(client.getEmails).toHaveBeenCalledWith('box', 'acc2', 50, 0);
});
it('caps the page at 100 and refuses unknown folders', async () => {
await expect(call(READER, 'email.list', { limit: 101 })).rejects.toThrow(/limit must be a whole number from 1 to 100/);
await expect(call(READER, 'email.list', { mailboxId: 'nope' })).rejects.toThrow(/no mailbox with id "nope"/);
await expect(call(READER, 'email.list', { mailbox: 'inbox' })).rejects.toThrow(/unknown property "mailbox"/);
await expect(call(plugin([]), 'email.list')).rejects.toThrow(/lacks permission "email:read"/);
expect(client.getEmails).not.toHaveBeenCalled();
});
});
describe('api.mailboxes.list', () => {
it('returns plain folder views', async () => {
expect(await call(READER, 'mailboxes.list')).toEqual([
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, totalEmails: 12, unreadEmails: 3 },
{ id: 'acc2:box', name: 'Team', role: null, parentId: null, totalEmails: 1, unreadEmails: 0 },
]);
await expect(call(plugin(['email:write']), 'mailboxes.list')).rejects.toThrow(/lacks permission "email:read"/);
});
it('asks the server when the app has not loaded folders yet', async () => {
useEmailStore.setState({ mailboxes: [] });
expect(await call(READER, 'mailboxes.list')).toHaveLength(1);
expect(client.getMailboxes).toHaveBeenCalled();
});
});