mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 08:15:41 +00:00
feat: let plugins read a whole conversation
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { InstalledPlugin, EmailReadView } 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, 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']);
|
||||
const call = (p: InstalledPlugin, ...args: unknown[]) => dispatchApiCall(p, 'email.getThread', args) as Promise<EmailReadView[]>;
|
||||
|
||||
const mail = (id: string, hoursAfter: number, subject: string, threadId = 't1'): Email => ({
|
||||
id, threadId, mailboxIds: { inbox: true }, keywords: {}, subject, from: [{ name: 'Bob', email: 'bob@example.org' }],
|
||||
receivedAt: new Date(Date.UTC(2026, 9, 1, hoursAfter)).toISOString(),
|
||||
textBody: [{ partId: '1', blobId: `b-${id}`, size: 1, type: 'text/plain' }],
|
||||
bodyValues: { 1: { value: `${subject} body` } },
|
||||
} as unknown as Email);
|
||||
|
||||
// The server lists a thread's messages in its own order; the API sorts them.
|
||||
const THREAD = [mail('m3', 30, 'Re: Re: Report'), mail('m1', 10, 'Report'), mail('m2', 20, 'Re: Report')];
|
||||
|
||||
let client: Record<string, ReturnType<typeof vi.fn>>;
|
||||
|
||||
beforeEach(() => {
|
||||
resetPluginRateLimits();
|
||||
client = {
|
||||
getEmail: vi.fn(async (id: string, accountId?: string) => {
|
||||
if (accountId === 'shared-acc') return id === 's1' ? mail('s1', 1, 'Team news', 'ts') : null;
|
||||
return THREAD.find((e) => e.id === id) ?? (id === 'lonely' ? mail('lonely', 5, 'Alone', 'tl') : null);
|
||||
}),
|
||||
getThreadEmails: vi.fn(async (threadId: string, accountId?: string) => {
|
||||
if (threadId === 't1' && !accountId) return THREAD;
|
||||
if (threadId === 'ts' && accountId === 'shared-acc') return [mail('s2', 2, 'Re: Team news', 'ts'), mail('s1', 1, 'Team news', 'ts')];
|
||||
if (threadId === 'big') return Array.from({ length: 60 }, (_, i) => mail(`b${i}`, i, `Part ${i}`, 'big'));
|
||||
return [];
|
||||
}),
|
||||
getAccountId: vi.fn(() => 'acc1'),
|
||||
getMailboxes: vi.fn(async () => [{ id: 'shared-inbox', name: 'Team', isShared: true, accountId: 'shared-acc' } as unknown as Mailbox]),
|
||||
};
|
||||
useAuthStore.setState({ client: client as unknown as IJMAPClient });
|
||||
useEmailStore.setState({ selectedEmail: null, emails: [], mailboxes: [], selectedMailbox: '' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useAuthStore.setState({ client: null });
|
||||
});
|
||||
|
||||
describe('api.email.getThread', () => {
|
||||
it('is behind email:read, offered in the runtime and announced in the host features', async () => {
|
||||
expect(PERM_PER_METHOD['email.getThread']).toBe('email:read');
|
||||
await expect(call(plugin(['email:blob-read']), 'm2')).rejects.toThrow(/lacks permission "email:read"/);
|
||||
expect(client.getThreadEmails).not.toHaveBeenCalled();
|
||||
expect(snapshotHostFeatures()).toMatchObject({ threads: true });
|
||||
const calls: unknown[][] = [];
|
||||
const api = withAssistantRuntime({} as { email?: { getThread?: (...a: unknown[]) => unknown } }, async (method, args) => { calls.push([method, args]); return []; });
|
||||
await api.email?.getThread?.('m2');
|
||||
expect(calls).toEqual([['email.getThread', ['m2', undefined]]]);
|
||||
});
|
||||
|
||||
it('returns every message of the conversation, oldest first, with their text', async () => {
|
||||
const thread = await call(ASSISTANT, 'm2');
|
||||
expect(thread.map((e) => [e.id, e.subject, e.text, e.accountId])).toEqual([
|
||||
['m1', 'Report', 'Report body', 'acc1'],
|
||||
['m2', 'Re: Report', 'Re: Report body', 'acc1'],
|
||||
['m3', 'Re: Re: Report', 'Re: Re: Report body', 'acc1'],
|
||||
]);
|
||||
expect(client.getThreadEmails).toHaveBeenCalledWith('t1', undefined);
|
||||
});
|
||||
|
||||
it('reads a shared account\'s conversation from that account', async () => {
|
||||
const thread = await call(ASSISTANT, 's1', { accountId: 'shared-acc' });
|
||||
expect(thread.map((e) => [e.id, e.accountId])).toEqual([['s1', 'shared-acc'], ['s2', 'shared-acc']]);
|
||||
expect(client.getThreadEmails).toHaveBeenCalledWith('ts', 'shared-acc');
|
||||
});
|
||||
|
||||
it('refuses an account this login does not show, and a missing message', async () => {
|
||||
await expect(call(ASSISTANT, 'm2', { accountId: 'stranger' })).rejects.toThrow(/no message with id "m2"/);
|
||||
await expect(call(ASSISTANT, 'nope')).rejects.toThrow(/no message with id "nope"/);
|
||||
expect(client.getThreadEmails).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('gives the message alone when the server lists no thread, and at most the newest 50', async () => {
|
||||
expect((await call(ASSISTANT, 'lonely')).map((e) => e.id)).toEqual(['lonely']);
|
||||
client.getEmail.mockResolvedValueOnce(mail('b59', 59, 'Part 59', 'big'));
|
||||
const big = await call(ASSISTANT, 'b59');
|
||||
expect(big).toHaveLength(50);
|
||||
expect(big[0].id).toBe('b10');
|
||||
expect(big[49].id).toBe('b59');
|
||||
});
|
||||
|
||||
it('checks its options and allows 60 reads a minute', async () => {
|
||||
await expect(call(ASSISTANT, 'm2', { threadId: 't1' })).rejects.toThrow(/unknown property "threadId"/);
|
||||
await expect(call(ASSISTANT, 42)).rejects.toThrow(/emailId must be a string/);
|
||||
resetPluginRateLimits();
|
||||
for (let i = 0; i < 60; i++) await call(ASSISTANT, 'm1');
|
||||
await expect(call(ASSISTANT, 'm1')).rejects.toThrow(/too many reads/);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
// Area b of the assistant's host APIs, inside the plugin sandbox (see README.md).
|
||||
|
||||
import type { CallApi, RuntimeAdditions } from './runtime';
|
||||
import type { EmailReadView } from '../../plugin-types';
|
||||
import type { AttachmentImageOptions, AttachmentImageView, AttachmentTextOptions, AttachmentTextView } from './b.types';
|
||||
|
||||
/** Reads that download and unpack a file get the network timeout. */
|
||||
@@ -25,6 +26,12 @@ export function runtime(callApi: CallApi): RuntimeAdditions {
|
||||
*/
|
||||
getAttachmentImage: (emailId: string, blobId: string, options?: AttachmentImageOptions) =>
|
||||
callApi('email.getAttachmentImage', [emailId, blobId, options], READ_TIMEOUT_MS) as Promise<AttachmentImageView>,
|
||||
/**
|
||||
* Every message of the conversation a message belongs to, oldest
|
||||
* first, at most the newest 50 (email:read, 60 a minute).
|
||||
*/
|
||||
getThread: (emailId: string, options?: { accountId?: string }) =>
|
||||
callApi('email.getThread', [emailId, options], READ_TIMEOUT_MS) as Promise<EmailReadView[]>,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Area b of the assistant's host APIs, host side (see README.md): more
|
||||
// context for the assistant. The text of an attachment, and an image
|
||||
// attachment shrunk to what a model needs.
|
||||
// context for the assistant. The text of an attachment, an image
|
||||
// attachment shrunk to what a model needs, and a whole conversation.
|
||||
|
||||
import type { InstalledPlugin, Permission } from '../../plugin-types';
|
||||
import type { EmailReadView, InstalledPlugin, Permission } from '../../plugin-types';
|
||||
import type { IJMAPClient } from '../../jmap/client-interface';
|
||||
import type { Attachment, Email } from '../../jmap/types';
|
||||
import { NOT_MINE } from './not-mine';
|
||||
@@ -13,17 +13,23 @@ import { readBoolean, readId, readInteger, readObject, sessionClient, withinRate
|
||||
import { DEFAULT_TEXT_CHARS, MAX_TEXT_CHARS, MAX_TEXT_SOURCE_BYTES, extractText, textFormatOf } from '@/lib/attachment-text';
|
||||
import { DEFAULT_IMAGE_SIDE, MAX_IMAGE_SIDE, MIN_IMAGE_SIDE, shrinkImage, toBase64 } from '@/lib/image-shrink';
|
||||
import { readPhotoExif } from '@/lib/exif';
|
||||
import { emailToReadView } from '@/lib/plugin-projection';
|
||||
|
||||
/** Method → permission, joined into PERM_PER_METHOD. */
|
||||
export const PERMS: Readonly<Record<string, Permission | null>> = {
|
||||
'email.getAttachmentText': 'email:blob-read',
|
||||
'email.getAttachmentImage': 'email:blob-read',
|
||||
'email.getThread': 'email:read',
|
||||
};
|
||||
|
||||
/** Attachment reads (text) a plugin may make per minute. */
|
||||
export const MAX_ATTACHMENT_TEXT_PER_MINUTE = 30;
|
||||
/** Attachment reads (images) a plugin may make per minute. */
|
||||
export const MAX_ATTACHMENT_IMAGE_PER_MINUTE = 30;
|
||||
/** Conversations a plugin may read per minute. */
|
||||
export const MAX_THREADS_PER_MINUTE = 60;
|
||||
/** Most messages api.email.getThread returns (the newest of a longer conversation). */
|
||||
export const MAX_THREAD_MESSAGES = 50;
|
||||
|
||||
interface FoundAttachment {
|
||||
client: IJMAPClient;
|
||||
@@ -133,16 +139,42 @@ export async function doEmailGetAttachmentImage(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every message of the conversation `emailId` belongs to, oldest first, as
|
||||
* read views (at most the newest MAX_THREAD_MESSAGES). The message is found
|
||||
* the way api.email.get finds it, and the conversation is read from the
|
||||
* same account.
|
||||
*/
|
||||
export async function doEmailGetThread(plugin: InstalledPlugin, emailIdArg: unknown, optionsArg?: unknown): Promise<EmailReadView[]> {
|
||||
const method = 'email.getThread';
|
||||
const emailId = readId(emailIdArg, method, 'emailId');
|
||||
const opts = readObject(optionsArg, method, 'options', ['accountId'], true);
|
||||
if (!withinRate(method, plugin.id, MAX_THREADS_PER_MINUTE)) throw new Error(`${method}: too many reads, try again in a minute`);
|
||||
const client = sessionClient(method);
|
||||
const found = await readNamedEmail(client, emailId, method, opts.accountId);
|
||||
if (!found) throw new Error(`${method}: no message with id "${emailId}"`);
|
||||
const accountId = found.accountId ?? client.getAccountId();
|
||||
const emails = found.email.threadId ? await client.getThreadEmails(found.email.threadId, found.accountId) : [];
|
||||
// A server that knows no thread for it: the message on its own.
|
||||
const list = emails.length ? emails : [found.email];
|
||||
return list
|
||||
.slice()
|
||||
.sort((a, b) => String(a.receivedAt ?? '').localeCompare(String(b.receivedAt ?? '')))
|
||||
.slice(-MAX_THREAD_MESSAGES)
|
||||
.map((email) => emailToReadView(email, accountId));
|
||||
}
|
||||
|
||||
/** Runs a method of this area; NOT_MINE for any other. */
|
||||
export async function dispatch(plugin: InstalledPlugin, method: string, args: unknown[]): Promise<unknown> {
|
||||
switch (method) {
|
||||
case 'email.getAttachmentText': return doEmailGetAttachmentText(plugin, args[0], args[1], args[2]);
|
||||
case 'email.getAttachmentImage': return doEmailGetAttachmentImage(plugin, args[0], args[1], args[2]);
|
||||
case 'email.getThread': return doEmailGetThread(plugin, args[0], args[1]);
|
||||
default: return NOT_MINE;
|
||||
}
|
||||
}
|
||||
|
||||
/** This area's flags in api.host.features, right now. */
|
||||
export function features(): AreaHostFeaturesB {
|
||||
return { attachmentText: true, attachmentImages: true };
|
||||
return { attachmentText: true, attachmentImages: true, threads: true };
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface AreaHostFeaturesB {
|
||||
attachmentText?: boolean;
|
||||
/** api.email.getAttachmentImage: an image attachment, shrunk for a model. */
|
||||
attachmentImages?: boolean;
|
||||
/** api.email.getThread: every message of a conversation. */
|
||||
threads?: boolean;
|
||||
}
|
||||
|
||||
/** What EmailReadView.attachments adds for an image the message shows inline. */
|
||||
|
||||
Reference in New Issue
Block a user