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 } 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, MAX_PLUGIN_FETCH_BYTES } from '../plugin-sandbox/host-api'; function plugin(perms: string[]): InstalledPlugin { return { id: 'archiver', name: 'Archiver', version: '1.0.0', author: 'test', description: '', type: 'hook', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js', enabled: true, status: 'running', settings: {}, }; } const ARCHIVER = plugin(['email:blob-read']); // Untrusted tier: no { privileged: true } here, which is the point. const call = (p: InstalledPlugin, method: string, ...args: unknown[]) => dispatchApiCall(p, method, args); const invoice = { partId: '2', blobId: 'blob-pdf', name: 'invoice.pdf', type: 'application/pdf', size: 3 }; const huge = { partId: '3', blobId: 'blob-big', name: 'big.iso', type: 'application/octet-stream', size: MAX_PLUGIN_FETCH_BYTES + 1 }; const message = { id: 'm1', threadId: 't1', blobId: 'blob-raw', size: 4, mailboxIds: { inbox: true }, keywords: {}, subject: 'Invoice', receivedAt: '2026-05-01T10:00:00Z', attachments: [invoice, huge], } as unknown as Email; let client: Record>; beforeEach(() => { client = { getEmail: vi.fn(async (id: string) => (id === 'm1' ? message : null)), fetchBlobArrayBuffer: vi.fn(async (blobId: string) => { if (blobId === 'blob-raw') return new TextEncoder().encode('From').buffer; return new Uint8Array([1, 2, 3]).buffer; }), }; useAuthStore.setState({ client: client as unknown as IJMAPClient }); useEmailStore.setState({ selectedEmail: null }); }); afterEach(() => { useAuthStore.setState({ client: null }); }); describe('api.email.getAttachment', () => { it('returns the bytes of an attachment of that message, to an untrusted plugin', async () => { const result = await call(ARCHIVER, 'email.getAttachment', 'm1', 'blob-pdf') as { name: string; type: string; size: number; data: ArrayBuffer; }; expect(result.name).toBe('invoice.pdf'); expect(result.type).toBe('application/pdf'); expect(result.size).toBe(3); expect(result.data).toBeInstanceOf(ArrayBuffer); expect([...new Uint8Array(result.data)]).toEqual([1, 2, 3]); expect(client.fetchBlobArrayBuffer).toHaveBeenCalledWith('blob-pdf', 'invoice.pdf', 'application/pdf'); }); it('refuses a blob that is not an attachment of that message', async () => { await expect(call(ARCHIVER, 'email.getAttachment', 'm1', 'blob-raw')).rejects.toThrow(/not an attachment of this message/); await expect(call(ARCHIVER, 'email.getAttachment', 'm1', 'someone-elses-blob')).rejects.toThrow(/not an attachment/); await expect(call(ARCHIVER, 'email.getAttachment', 'missing', 'blob-pdf')).rejects.toThrow(/no message with id "missing"/); expect(client.fetchBlobArrayBuffer).not.toHaveBeenCalled(); }); it('refuses attachments over 25 MB before downloading them', async () => { await expect(call(ARCHIVER, 'email.getAttachment', 'm1', 'blob-big')).rejects.toThrow(/larger than 26214400 bytes/); expect(client.fetchBlobArrayBuffer).not.toHaveBeenCalled(); }); it('needs email:blob-read, and string ids', async () => { await expect(call(plugin(['email:read']), 'email.getAttachment', 'm1', 'blob-pdf')).rejects.toThrow(/lacks permission "email:blob-read"/); await expect(call(ARCHIVER, 'email.getAttachment', 'm1', 7)).rejects.toThrow(/blobId must be a string/); expect(client.fetchBlobArrayBuffer).not.toHaveBeenCalled(); }); }); describe('api.email.getRaw', () => { it('returns the message source from its own blob', async () => { const raw = await call(ARCHIVER, 'email.getRaw', 'm1') as ArrayBuffer; expect(new TextDecoder().decode(raw)).toBe('From'); expect(client.fetchBlobArrayBuffer).toHaveBeenCalledWith('blob-raw', 'message.eml', 'message/rfc822'); }); it('refuses big messages, unknown ones and missing permission', async () => { client.getEmail.mockResolvedValueOnce({ ...message, size: MAX_PLUGIN_FETCH_BYTES + 1 }); await expect(call(ARCHIVER, 'email.getRaw', 'm1')).rejects.toThrow(/larger than/); await expect(call(ARCHIVER, 'email.getRaw', 'missing')).rejects.toThrow(/no message/); await expect(call(plugin([]), 'email.getRaw', 'm1')).rejects.toThrow(/lacks permission "email:blob-read"/); expect(client.fetchBlobArrayBuffer).not.toHaveBeenCalled(); }); it('leaves arbitrary blob reads to the privileged tier', async () => { await expect(call(ARCHIVER, 'jmap.fetchBlob', 'blob-raw')).rejects.toThrow(/requires the privileged plugin tier/); }); });