mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 08:15:41 +00:00
api.email.getAttachmentText reads PDF (pdf.js), Word, Excel, PowerPoint and OpenDocument files (own reader over fflate and DOMParser, unpacking capped at 50 MB), text, CSV, HTML, calendar and address files, and answers with a status for what it can't read. The extraction lives in lib/attachment-text so Files can use it too. The sandbox runtime now copies every flag the host sends into api.host.features; before, it kept only the three core flags, so the assistant's flags never reached a plugin. The host bridge assertions in plugin-host-features.test.ts compare against the core flags plus the assistant's (withAssistant), since the init payload and host-change now carry attachmentText too.
129 lines
7.0 KiB
TypeScript
129 lines
7.0 KiB
TypeScript
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('<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Launch moves to 12 December.</w:t></w:r></w:p></w:body></w:document>'),
|
|
});
|
|
const BLOBS: Record<string, Uint8Array> = {
|
|
'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<string, ReturnType<typeof vi.fn>>;
|
|
|
|
beforeEach(() => {
|
|
resetPluginRateLimits();
|
|
client = {
|
|
getEmail: vi.fn(async (id: string, accountId?: string) => (id === 'm1' && (!accountId || accountId === 'shared-acc') ? 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.fetchBlobArrayBuffer).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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' });
|
|
});
|
|
});
|