// Area b of the assistant's host APIs, host side (see README.md): more // context for the assistant. The text of an attachment, an image // attachment shrunk to what a model needs, and a whole conversation. 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'; import type { AreaHostFeaturesB, AttachmentImageView, AttachmentTextView } from './b.types'; import { attachmentExtras, cidAltTexts } from './b.projection'; import { readNamedEmail, sourceAccount } from '../host-api-mail'; import { readBoolean, readId, readInteger, readObject, sessionClient, withinRate } from '../host-api-input'; // The readers (pdf.js, the Office reader) load only when an attachment is read. import { DEFAULT_TEXT_CHARS, MAX_TEXT_CHARS, MAX_TEXT_SOURCE_BYTES } from '@/lib/attachment-text/types'; 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> = { 'email.getAttachmentText': 'email:blob-read', 'email.getAttachmentImage': 'email:blob-read', 'email.getThread': 'email:read', 'email.getThreadCount': '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; /** Conversation sizes a plugin may ask for per minute (a panel probing whether to offer a thread read). */ export const MAX_THREAD_COUNTS_PER_MINUTE = 120; /** Most messages api.email.getThread returns (the newest of a longer conversation). */ export const MAX_THREAD_MESSAGES = 50; interface FoundAttachment { client: IJMAPClient; email: Email; accountId: string | undefined; part: Attachment; } /** * One attachment of one message. The blob has to be an attachment of that * message, as for api.email.getAttachment; the message is found the way * api.email.get finds it (the account the plugin names, the list's row, * the signed-in account). */ async function findAttachment(method: string, emailIdArg: unknown, blobIdArg: unknown, accountArg: unknown): Promise { const emailId = readId(emailIdArg, method, 'emailId'); const blobId = readId(blobIdArg, method, 'blobId'); const client = sessionClient(method); const found = await readNamedEmail(client, emailId, method, accountArg); if (!found) throw new Error(`${method}: no message with id "${emailId}"`); const part = (found.email.attachments ?? []).find((a) => a.blobId === blobId); if (!part) throw new Error(`${method}: that blob is not an attachment of this message`); return { client, email: found.email, accountId: found.accountId, part }; } function fetchPart({ client, accountId, part }: FoundAttachment): Promise { return client.fetchBlobArrayBuffer(part.blobId, part.name || undefined, part.type || undefined, ...(accountId ? [accountId] : [])); } /** The text of one attachment of one message (see AttachmentTextView). */ export async function doEmailGetAttachmentText( plugin: InstalledPlugin, emailIdArg: unknown, blobIdArg: unknown, optionsArg?: unknown, ): Promise { const method = 'email.getAttachmentText'; readId(emailIdArg, method, 'emailId'); readId(blobIdArg, method, 'blobId'); const opts = readObject(optionsArg, method, 'options', ['maxChars', 'accountId'], true); const maxChars = opts.maxChars === undefined ? DEFAULT_TEXT_CHARS : readInteger(opts.maxChars, method, 'maxChars', 1, MAX_TEXT_CHARS); if (!withinRate(method, plugin.id, MAX_ATTACHMENT_TEXT_PER_MINUTE)) throw new Error(`${method}: too many reads, try again in a minute`); const found = await findAttachment(method, emailIdArg, blobIdArg, opts.accountId); const { part } = found; const name = part.name || 'attachment'; const type = part.type || 'application/octet-stream'; const base = { name, type, size: part.size || 0 }; const { extractText, textFormatOf } = await import('@/lib/attachment-text'); if ((part.size || 0) > MAX_TEXT_SOURCE_BYTES) { return { ...base, format: textFormatOf(name, type), status: 'too_big', text: '', truncated: false }; } const data = await fetchPart(found); const result = await extractText(data, { name, type, maxChars }); return { ...base, size: data.byteLength, ...result }; } const IMAGE_NAME = /\.(jpe?g|png|gif|webp|bmp|avif|heic|heif|tiff?)$/i; /** Raster images by type or name. SVG is left out: it can load remote content. */ function isRasterImage(name: string, type: string): boolean { const mediaType = type.split(';')[0].trim().toLowerCase(); if (mediaType === 'image/svg+xml' || /\.svgz?$/i.test(name)) return false; return mediaType.startsWith('image/') || IMAGE_NAME.test(name); } /** * An image attachment (or an inline image) of one message, shrunk in the * app to `maxSide` on its long edge and at most 1.5 MB, as base64. Only * the message's own parts: images the HTML loads from the web are never * fetched. The photo's date and place only with `exif: true`. */ export async function doEmailGetAttachmentImage( plugin: InstalledPlugin, emailIdArg: unknown, blobIdArg: unknown, optionsArg?: unknown, ): Promise { const method = 'email.getAttachmentImage'; readId(emailIdArg, method, 'emailId'); readId(blobIdArg, method, 'blobId'); const opts = readObject(optionsArg, method, 'options', ['maxSide', 'exif', 'accountId'], true); const maxSide = opts.maxSide === undefined ? DEFAULT_IMAGE_SIDE : readInteger(opts.maxSide, method, 'maxSide', MIN_IMAGE_SIDE, MAX_IMAGE_SIDE); const wantExif = opts.exif === undefined ? false : readBoolean(opts.exif, method, 'exif'); if (!withinRate(method, plugin.id, MAX_ATTACHMENT_IMAGE_PER_MINUTE)) throw new Error(`${method}: too many reads, try again in a minute`); const found = await findAttachment(method, emailIdArg, blobIdArg, opts.accountId); const { part, email } = found; const name = part.name || 'image'; const sourceType = part.type || 'application/octet-stream'; const { alt } = attachmentExtras(part, cidAltTexts(email)); const answer = (status: AttachmentImageView['status'], width = 0, height = 0): AttachmentImageView => ({ name, type: sourceType, width, height, data: '', status, ...(alt ? { alt } : {}), }); if (!isRasterImage(name, sourceType)) return answer('unsupported'); if ((part.size || 0) > MAX_TEXT_SOURCE_BYTES) return answer('too_big'); const data = await fetchPart(found); const shrunk = await shrinkImage(new Blob([data], { type: sourceType }), { maxSide }); if (shrunk.status !== 'ok' || !shrunk.bytes || !shrunk.type) return answer(shrunk.status === 'ok' ? 'failed' : shrunk.status, shrunk.width, shrunk.height); const exif = wantExif ? readPhotoExif(data) : null; return { name, type: shrunk.type, width: shrunk.width ?? 0, height: shrunk.height ?? 0, data: toBase64(shrunk.bytes), status: 'ok', ...(alt ? { alt } : {}), ...(exif && Object.keys(exif).length ? { exif } : {}), }; } /** * 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 { 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(); // Only the newest messages are fetched, with their bodies; a thread of thousands stays one small read. const emails = found.email.threadId ? await client.getThreadEmails(found.email.threadId, found.accountId, { newest: MAX_THREAD_MESSAGES }) : []; // 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)); } /** * How many messages the conversation of `emailId` has (Thread/get; no * message bodies are read). 1 for a message without a thread. Routed like * api.email.get; its own rate limit, so probing it never uses up getThread's. */ export async function doEmailGetThreadCount(plugin: InstalledPlugin, emailIdArg: unknown, optionsArg?: unknown): Promise { const method = 'email.getThreadCount'; const emailId = readId(emailIdArg, method, 'emailId'); const opts = readObject(optionsArg, method, 'options', ['accountId'], true); if (!withinRate(method, plugin.id, MAX_THREAD_COUNTS_PER_MINUTE)) throw new Error(`${method}: too many reads, try again in a minute`); const client = sessionClient(method); const accountId = await sourceAccount(client, emailId, method, opts.accountId); if (accountId === null) throw new Error(`${method}: no message with id "${emailId}"`); // The summary only (no bodies), for its thread id. const [email] = await client.getSomeEmails([emailId], accountId); if (!email) throw new Error(`${method}: no message with id "${emailId}"`); if (!email.threadId) return 1; const thread = await client.getThread(email.threadId, accountId); return Math.max(1, thread?.emailIds?.length ?? 1); } /** Runs a method of this area; NOT_MINE for any other. */ export async function dispatch(plugin: InstalledPlugin, method: string, args: unknown[]): Promise { 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]); case 'email.getThreadCount': return doEmailGetThreadCount(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, threads: true, listSelection: true }; }