import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { InstalledPlugin } from '../plugin-types'; import type { IJMAPClient } from '../jmap/client-interface'; import { useAuthStore } from '@/stores/auth-store'; import { useIdentityStore } from '@/stores/identity-store'; import { INTERNAL_MAILTO_EVENT, type ParsedMailto } from '../protocol-handlers/mailto'; import { PLUGIN_REPLY_EVENT, type PluginReplyRequest } from '../plugin-sandbox/host-api-compose'; import { consumePendingMailto } from '../protocol-handlers/session'; import { resetPluginRateLimits } from '../plugin-sandbox/host-api-input'; import { HIGH_RISK_PERMISSIONS } from '../plugin-sandbox/permission-text'; import { ALL_PERMISSIONS } from '../plugin-types'; 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[], id = 'cloud'): InstalledPlugin { return { id, name: 'Cloud', version: '1.0.0', author: 'test', description: '', type: 'hook', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js', enabled: true, status: 'running', settings: {}, }; } const COMPOSER = plugin(['compose:open']); const DRAFTER = plugin(['email:write']); const call = (p: InstalledPlugin, method: string, ...args: unknown[]) => dispatchApiCall(p, method, args); const pdf = { blobId: 'b1', name: 'report.pdf', type: 'application/pdf', size: 1200 }; describe('api.compose.open', () => { let taken: ParsedMailto[]; const listener = (event: Event) => { event.preventDefault(); taken.push((event as CustomEvent).detail); }; beforeEach(() => { taken = []; resetPluginRateLimits(); window.addEventListener(INTERNAL_MAILTO_EVENT, listener); }); afterEach(() => { window.removeEventListener(INTERNAL_MAILTO_EVENT, listener); sessionStorage.clear(); }); it('hands the mail page a filled-in draft, like an in-app mailto: link', async () => { expect(await call(COMPOSER, 'compose.open', { to: ['Bob '], cc: ['carol@example.com'], subject: 'Report', body: 'See attached', attachments: [pdf], })).toBe('opened'); expect(taken).toEqual([{ to: ['Bob '], cc: ['carol@example.com'], bcc: [], subject: 'Report', body: 'See attached', attachments: [pdf], // onMailtoIntercept sees a mailto: URL for it too. href: expect.stringMatching(/^mailto:/), }]); }); it('sanitises an HTML body and sends it as htmlBody', async () => { await call(COMPOSER, 'compose.open', { body: '

Hi there

', isHtml: true }); expect(taken[0].htmlBody).toContain('there'); expect(taken[0].htmlBody).not.toContain('onerror'); expect(taken[0].body).toMatch(/Hi there/); }); it('parks the draft for the mail page when none is on screen', async () => { window.removeEventListener(INTERNAL_MAILTO_EVENT, listener); // jsdom does not navigate; the call still reports where it is going. expect(await call(COMPOSER, 'compose.open', { to: ['bob@example.com'], subject: 'Hi' })).toBe('navigating'); expect(consumePendingMailto()).toEqual(expect.objectContaining({ to: ['bob@example.com'], subject: 'Hi' })); }); it('refuses bad addresses, header injection, too many recipients and odd attachments', async () => { await expect(call(COMPOSER, 'compose.open', { to: ['nobody'] })).rejects.toThrow(/to\[0\] is not a valid address/); await expect(call(COMPOSER, 'compose.open', { subject: 'Hi\r\nBcc: x@example.com' })).rejects.toThrow(/subject must be one line/); const many = Array.from({ length: 101 }, (_, i) => `u${i}@example.com`); await expect(call(COMPOSER, 'compose.open', { to: many })).rejects.toThrow(/at most 100 addresses/); await expect(call(COMPOSER, 'compose.open', { to: many.slice(0, 60), cc: many.slice(60) })).rejects.toThrow(/at most 100 recipients in all/); await expect(call(COMPOSER, 'compose.open', { attachments: [{ ...pdf, type: 'not a type' }] })).rejects.toThrow(/not a media type/); await expect(call(COMPOSER, 'compose.open', { from: 'boss@example.com' })).rejects.toThrow(/unknown property "from"/); expect(taken).toEqual([]); }); it('opens at most five times a minute per plugin', async () => { for (let i = 0; i < 5; i++) await call(COMPOSER, 'compose.open', {}); await expect(call(COMPOSER, 'compose.open', {})).rejects.toThrow(/at most 5 times a minute/); expect(await call(plugin(['compose:open'], 'other'), 'compose.open', {})).toBe('opened'); expect(taken).toHaveLength(6); }); it('needs compose:open, a new permission that is not marked high risk', async () => { await expect(call(plugin(['email:send']), 'compose.open', {})).rejects.toThrow(/lacks permission "compose:open"/); expect(taken).toEqual([]); expect(ALL_PERMISSIONS).toContain('compose:open'); expect(HIGH_RISK_PERMISSIONS.has('compose:open')).toBe(false); }); }); describe('api.compose.reply', () => { // The mail page, with message E1 open in the reader. let taken: PluginReplyRequest[]; const mailPage = (event: Event) => { const req = (event as CustomEvent).detail; if (req.emailId !== 'E1') return; event.preventDefault(); taken.push(req); }; beforeEach(() => { taken = []; resetPluginRateLimits(); window.addEventListener(PLUGIN_REPLY_EVENT, mailPage); }); afterEach(() => window.removeEventListener(PLUGIN_REPLY_EVENT, mailPage)); it('asks the mail page to reply to the open message with the text', async () => { expect(await call(COMPOSER, 'compose.reply', 'E1', { text: 'Thursday works for me.' })).toBe(true); expect(await call(COMPOSER, 'compose.reply', 'E1', { text: 'Fine by all of us.', all: true })).toBe(true); expect(taken).toEqual([ { emailId: 'E1', text: 'Thursday works for me.', all: false }, { emailId: 'E1', text: 'Fine by all of us.', all: true }, ]); }); it('refuses a message that is not open, and needs compose:open', async () => { await expect(call(COMPOSER, 'compose.reply', 'E2', { text: 'x' })).rejects.toThrow(/not open in the reader/); await expect(call(DRAFTER, 'compose.reply', 'E1', { text: 'x' })).rejects.toThrow(/lacks permission "compose:open"/); await expect(call(COMPOSER, 'compose.reply', 'E1', { text: 'x', to: ['eve@example.com'] })).rejects.toThrow(/unknown property "to"/); expect(taken).toEqual([]); }); }); describe('api.drafts.create', () => { let createDraft: ReturnType; beforeEach(() => { resetPluginRateLimits(); createDraft = vi.fn(async () => 'draft-1'); useAuthStore.setState({ client: { createDraft } as unknown as IJMAPClient }); useIdentityStore.setState({ identities: [ { id: 'id-a', name: 'Ann', email: 'ann@example.com' }, { id: 'id-b', name: 'Ann Work', email: 'ann@work.example' }, ] as never, preferredPrimaryId: 'id-b', }); }); afterEach(() => { useAuthStore.setState({ client: null }); useIdentityStore.setState({ identities: [], preferredPrimaryId: null }); }); it('saves a draft from the default identity and returns its id', async () => { expect(await call(DRAFTER, 'drafts.create', { to: ['bob@example.com'], subject: 'Notes', body: '

Hello

', isHtml: true, attachments: [pdf], })).toBe('draft-1'); const [to, subject, text, cc, bcc, identityId, fromEmail, draftId, attachments, fromName, html] = createDraft.mock.calls[0]; expect({ to, subject, cc, bcc, identityId, fromEmail, draftId, fromName }).toEqual({ to: ['bob@example.com'], subject: 'Notes', cc: [], bcc: [], identityId: 'id-b', fromEmail: 'ann@work.example', draftId: undefined, fromName: 'Ann Work', }); expect(text).toMatch(/Hello/); expect(html).toContain('

Hello

'); expect(html).not.toContain('script'); expect(attachments).toEqual([{ ...pdf, disposition: 'attachment' }]); }); it('needs email:write and valid input', async () => { await expect(call(plugin(['compose:open']), 'drafts.create', {})).rejects.toThrow(/lacks permission "email:write"/); await expect(call(DRAFTER, 'drafts.create', { to: 'bob@example.com' })).rejects.toThrow(/to must be an array/); await expect(call(DRAFTER, 'drafts.create', { attachments: [{ blobId: 'b1' }] })).rejects.toThrow(/attachments\[0\]\.type must be a string/); expect(createDraft).not.toHaveBeenCalled(); }); it('saves at most ten drafts a minute', async () => { for (let i = 0; i < 10; i++) await call(DRAFTER, 'drafts.create', {}); await expect(call(DRAFTER, 'drafts.create', {})).rejects.toThrow(/at most 10 drafts a minute/); expect(createDraft).toHaveBeenCalledTimes(10); }); });