import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { strToU8, zipSync } from 'fflate'; 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, PERM_PER_METHOD } from '../plugin-sandbox/host-api'; import { resetPluginRateLimits } from '../plugin-sandbox/host-api-input'; import { snapshotHostFeatures } from '../plugin-sandbox/host-features'; import { withAssistantRuntime } from '../plugin-sandbox/assistant/runtime'; function plugin(perms: string[], id = 'ai-assistant'): InstalledPlugin { return { id, name: 'AI Assistant', version: '2.2.0', author: 'test', description: '', type: 'hook', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js', enabled: true, status: 'running', settings: {}, }; } const ASSISTANT = plugin(['email:read', 'email:blob-read']); const call = (p: InstalledPlugin, ...args: unknown[]) => dispatchApiCall(p, 'email.getAttachmentText', args); const notes = { partId: '2', blobId: 'blob-notes', name: 'workshop-notes.txt', type: 'text/plain', size: 40 }; const minutes = { partId: '3', blobId: 'blob-docx', name: 'minutes.docx', type: 'application/octet-stream', size: 300 }; const photo = { partId: '4', blobId: 'blob-jpg', name: 'photo.jpg', type: 'image/jpeg', size: 3 }; const old = { partId: '5', blobId: 'blob-doc', name: 'old.doc', type: 'application/msword', size: 8 }; const huge = { partId: '6', blobId: 'blob-big', name: 'scan.pdf', type: 'application/pdf', size: 20 * 1024 * 1024 + 1 }; const message = { id: 'm1', threadId: 't1', blobId: 'blob-raw', size: 4, mailboxIds: { inbox: true }, keywords: {}, subject: 'Workshop', receivedAt: '2026-05-01T10:00:00Z', attachments: [notes, minutes, photo, old, huge], } as unknown as Email; const DOCX = zipSync({ 'word/document.xml': strToU8('Launch moves to 12 December.'), }); const BLOBS: Record = { 'blob-notes': strToU8('Workshop notes\n\n1. Launch date moves to 12 December.\n'), 'blob-docx': DOCX, 'blob-jpg': new Uint8Array([0xff, 0xd8, 0xff]), 'blob-doc': new Uint8Array([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]), }; let client: Record>; beforeEach(() => { resetPluginRateLimits(); client = { // Every account has an m1: only the host's routing keeps a plugin out of the ones it may not read. getEmail: vi.fn(async (id: string) => (id === 'm1' ? message : null)), fetchBlobArrayBuffer: vi.fn(async (blobId: string) => { const bytes = BLOBS[blobId]; return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); }), getAccountId: vi.fn(() => 'acc1'), getMailboxes: vi.fn(async () => []), }; useAuthStore.setState({ client: client as unknown as IJMAPClient }); useEmailStore.setState({ selectedEmail: null, emails: [], mailboxes: [], selectedMailbox: '' }); }); afterEach(() => { useAuthStore.setState({ client: null }); }); describe('api.email.getAttachmentText', () => { it('is behind email:blob-read, offered in the runtime and announced in the host features', async () => { expect(PERM_PER_METHOD['email.getAttachmentText']).toBe('email:blob-read'); await expect(call(plugin(['email:read']), 'm1', 'blob-notes')).rejects.toThrow(/lacks permission "email:blob-read"/); expect(client.fetchBlobArrayBuffer).not.toHaveBeenCalled(); expect(snapshotHostFeatures()).toMatchObject({ attachmentText: true }); const calls: unknown[][] = []; const api = withAssistantRuntime({} as { email?: { getAttachmentText?: (...a: unknown[]) => unknown } }, async (method, args) => { calls.push([method, args]); return null; }); await api.email?.getAttachmentText?.('m1', 'blob-notes', { maxChars: 10 }); expect(calls).toEqual([['email.getAttachmentText', ['m1', 'blob-notes', { maxChars: 10 }]]]); }); it('returns the text of a text attachment', async () => { const out = await call(ASSISTANT, 'm1', 'blob-notes'); expect(out).toEqual({ name: 'workshop-notes.txt', type: 'text/plain', size: BLOBS['blob-notes'].byteLength, format: 'text', status: 'ok', text: 'Workshop notes\n\n1. Launch date moves to 12 December.', truncated: false, }); expect(client.fetchBlobArrayBuffer).toHaveBeenCalledWith('blob-notes', 'workshop-notes.txt', 'text/plain'); }); it('reads an Office file by its name and cuts at maxChars', async () => { expect(await call(ASSISTANT, 'm1', 'blob-docx')).toMatchObject({ format: 'docx', status: 'ok', text: 'Launch moves to 12 December.' }); expect(await call(ASSISTANT, 'm1', 'blob-docx', { maxChars: 12 })).toMatchObject({ text: 'Launch moves', truncated: true }); }); it('answers with a status for what it can not read', async () => { expect(await call(ASSISTANT, 'm1', 'blob-jpg')).toMatchObject({ status: 'unsupported', text: '' }); expect(await call(ASSISTANT, 'm1', 'blob-doc')).toMatchObject({ status: 'unsupported', format: 'other' }); }); it('answers too_big for an attachment over 20 MB without downloading it', async () => { expect(await call(ASSISTANT, 'm1', 'blob-big')).toMatchObject({ name: 'scan.pdf', format: 'pdf', status: 'too_big', text: '', truncated: false }); expect(client.fetchBlobArrayBuffer).not.toHaveBeenCalled(); }); it('refuses a blob that is not an attachment of that message, and a message it can not find', async () => { await expect(call(ASSISTANT, 'm1', 'blob-raw')).rejects.toThrow(/not an attachment of this message/); await expect(call(ASSISTANT, 'm1', 'someone-elses-blob')).rejects.toThrow(/not an attachment/); await expect(call(ASSISTANT, 'missing', 'blob-notes')).rejects.toThrow(/no message with id "missing"/); // An account this login doesn't show is not asked. await expect(call(ASSISTANT, 'm1', 'blob-notes', { accountId: 'stranger' })).rejects.toThrow(/no message with id/); expect(client.getEmail).not.toHaveBeenCalledWith('m1', 'stranger'); expect(client.fetchBlobArrayBuffer).not.toHaveBeenCalled(); }); it('reads an attachment of a shared folder from that account', async () => { useEmailStore.setState({ mailboxes: [{ id: 'shared-acc:inbox', originalId: 'inbox', name: 'Team', isShared: true, accountId: 'shared-acc' } as never] }); expect(await call(ASSISTANT, 'm1', 'blob-notes', { accountId: 'shared-acc' })).toMatchObject({ status: 'ok' }); expect(client.getEmail).toHaveBeenCalledWith('m1', 'shared-acc'); expect(client.fetchBlobArrayBuffer).toHaveBeenCalledWith('blob-notes', 'workshop-notes.txt', 'text/plain', 'shared-acc'); }); it('checks its options', async () => { await expect(call(ASSISTANT, 'm1', 'blob-notes', { maxChars: 100_001 })).rejects.toThrow(/maxChars must be a whole number from 1 to 100000/); await expect(call(ASSISTANT, 'm1', 'blob-notes', { pages: 3 })).rejects.toThrow(/unknown property "pages"/); await expect(call(ASSISTANT, 'm1', 7)).rejects.toThrow(/blobId must be a string/); }); it('allows 30 reads a minute per plugin', async () => { for (let i = 0; i < 30; i++) await call(ASSISTANT, 'm1', 'blob-notes'); await expect(call(ASSISTANT, 'm1', 'blob-notes')).rejects.toThrow(/too many reads/); // Another plugin has its own count. await expect(call(plugin(['email:blob-read'], 'other'), 'm1', 'blob-notes')).resolves.toMatchObject({ status: 'ok' }); }); });