From 121d8ba30c5538c74fa4571a51e076bedf05f9d2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:06:06 +0200 Subject: [PATCH] feat: let plugins manage templates, sender signatures and the out-of-office reply --- .../plugin-host-settings-apis.test.ts | 167 +++++++++++++ lib/plugin-sandbox/host-api-settings.ts | 224 ++++++++++++++++++ lib/plugin-sandbox/host-api.ts | 26 ++ lib/plugin-sandbox/permission-text.ts | 6 + lib/plugin-sandbox/runtime.tsx | 32 ++- lib/plugin-types.ts | 40 ++++ 6 files changed, 494 insertions(+), 1 deletion(-) create mode 100644 lib/__tests__/plugin-host-settings-apis.test.ts create mode 100644 lib/plugin-sandbox/host-api-settings.ts diff --git a/lib/__tests__/plugin-host-settings-apis.test.ts b/lib/__tests__/plugin-host-settings-apis.test.ts new file mode 100644 index 000000000..3d4ac565b --- /dev/null +++ b/lib/__tests__/plugin-host-settings-apis.test.ts @@ -0,0 +1,167 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { InstalledPlugin } from '../plugin-types'; +import type { IJMAPClient } from '../jmap/client-interface'; +import type { EmailTemplate } from '../template-types'; +import { useAuthStore } from '@/stores/auth-store'; +import { useTemplateStore } from '@/stores/template-store'; +import { useIdentityStore } from '@/stores/identity-store'; +import { useVacationStore } from '@/stores/vacation-store'; +import { HIGH_RISK_PERMISSIONS } from '../plugin-sandbox/permission-text'; + +vi.mock('@/stores/toast-store', () => ({ + toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }, +})); + +import { dispatchApiCall } from '../plugin-sandbox/host-api'; + +function plugin(perms: string[]): InstalledPlugin { + return { + id: 'helper', name: 'Helper', version: '1.0.0', author: 'test', description: '', + type: 'hook', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js', + enabled: true, status: 'running', settings: {}, + }; +} + +const ALL = plugin(['templates:read', 'templates:write', 'identity:write', 'vacation:read', 'vacation:write']); +const READ_ONLY = plugin(['templates:read', 'identity:read', 'vacation:read']); +const call = (p: InstalledPlugin, method: string, ...args: unknown[]) => dispatchApiCall(p, method, args); + +const greeting: EmailTemplate = { + id: 'tpl-1', name: 'Greeting', subject: 'Hello', body: 'Hi {{recipient_name}}', isHTML: false, + category: 'Sales', isFavorite: true, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z', +}; + +let client: Record>; + +beforeEach(() => { + client = { + updateIdentity: vi.fn(async () => undefined), + supportsVacationResponse: vi.fn(() => true), + supportsSieve: vi.fn(() => false), + getVacationResponse: vi.fn(async () => ({ + id: 'singleton', isEnabled: true, fromDate: null, toDate: '2026-06-01T00:00:00Z', + subject: 'Away', textBody: 'Back in June', htmlBody: null, + })), + setVacationResponse: vi.fn(async () => undefined), + }; + useAuthStore.setState({ client: client as unknown as IJMAPClient }); + useTemplateStore.setState({ templates: [greeting], recentTemplateIds: [], deletedTemplateIds: {} }); + useIdentityStore.setState({ + identities: [{ id: 'id1', name: 'Ann', email: 'ann@example.com', replyTo: null, bcc: null, textSignature: '', htmlSignature: '', mayDelete: true }] as never, + }); +}); + +afterEach(() => { + useAuthStore.setState({ client: null }); + useTemplateStore.setState({ templates: [], deletedTemplateIds: {} }); + useIdentityStore.setState({ identities: [] }); + useVacationStore.getState().clearState(); +}); + +describe('api.templates', () => { + it('lists and gets templates as views', async () => { + expect(await call(READ_ONLY, 'templates.list')).toEqual([ + { id: 'tpl-1', name: 'Greeting', category: 'Sales', subject: 'Hello', body: 'Hi {{recipient_name}}', isHTML: false }, + ]); + expect(await call(READ_ONLY, 'templates.get', 'tpl-1')).toEqual(expect.objectContaining({ name: 'Greeting' })); + expect(await call(READ_ONLY, 'templates.get', 'nope')).toBeNull(); + }); + + it('needs templates:read and templates:write', async () => { + await expect(call(plugin([]), 'templates.list')).rejects.toThrow(/lacks permission "templates:read"/); + await expect(call(READ_ONLY, 'templates.create', { name: 'x' })).rejects.toThrow(/lacks permission "templates:write"/); + await expect(call(READ_ONLY, 'templates.delete', 'tpl-1')).rejects.toThrow(/lacks permission "templates:write"/); + expect(useTemplateStore.getState().templates).toHaveLength(1); + }); + + it('creates, updates and deletes through the store, sanitising HTML bodies', async () => { + const created = await call(ALL, 'templates.create', { + name: 'Rich', subject: 'S', body: '

Hi

', isHTML: true, + }) as { id: string; body: string }; + expect(created.body).toContain('

Hi

'); + expect(created.body).not.toMatch(/script|onclick/); + expect(useTemplateStore.getState().templates.map((t) => t.name)).toEqual(['Greeting', 'Rich']); + + expect(await call(ALL, 'templates.update', 'tpl-1', { subject: 'Hi there' })) + .toEqual(expect.objectContaining({ id: 'tpl-1', subject: 'Hi there', body: 'Hi {{recipient_name}}' })); + + await call(ALL, 'templates.delete', created.id); + expect(useTemplateStore.getState().templates.map((t) => t.id)).toEqual(['tpl-1']); + expect(useTemplateStore.getState().deletedTemplateIds).toHaveProperty(created.id); + }); + + it('refuses bad template input', async () => { + await expect(call(ALL, 'templates.create', { subject: 'no name' })).rejects.toThrow(/name is required/); + await expect(call(ALL, 'templates.create', { name: 'x', isFavorite: true })).rejects.toThrow(/unknown property "isFavorite"/); + await expect(call(ALL, 'templates.create', { name: 'x', body: 'a'.repeat(100_001) })).rejects.toThrow(/at most 100000/); + await expect(call(ALL, 'templates.update', 'nope', { name: 'x' })).rejects.toThrow(/no template with id "nope"/); + useTemplateStore.setState({ templates: Array.from({ length: 500 }, (_, i) => ({ ...greeting, id: `t${i}` })) }); + await expect(call(ALL, 'templates.create', { name: 'one more' })).rejects.toThrow(/already 500 templates/); + }); +}); + +describe('api.identities.update', () => { + it('changes name, reply-to and signatures and never the address', async () => { + const view = await call(ALL, 'identities.update', 'id1', { + name: 'Ann Smith', + replyTo: ['Desk '], + htmlSignature: 'Ann', + }); + const [id, updates] = client.updateIdentity.mock.calls[0]; + expect(id).toBe('id1'); + expect(updates).toEqual(expect.objectContaining({ + name: 'Ann Smith', replyTo: [{ name: 'Desk', email: 'desk@example.com' }], + })); + expect(updates.htmlSignature).toContain('Ann'); + expect(updates.htmlSignature).not.toContain('onerror'); + expect(updates).not.toHaveProperty('email'); + expect(view).toEqual(expect.objectContaining({ id: 'id1', name: 'Ann Smith', email: 'ann@example.com', replyTo: 'desk@example.com' })); + expect(useIdentityStore.getState().identities[0].name).toBe('Ann Smith'); + }); + + it('refuses the address, bad addresses and unknown identities', async () => { + await expect(call(ALL, 'identities.update', 'id1', { email: 'evil@example.com' })).rejects.toThrow(/can't be changed/); + await expect(call(ALL, 'identities.update', 'id1', { replyTo: ['not an address'] })).rejects.toThrow(/not a valid address/); + await expect(call(ALL, 'identities.update', 'id1', { replyTo: ['a@example.com\r\nBcc: x@example.com'] })).rejects.toThrow(/not a valid address/); + await expect(call(ALL, 'identities.update', 'other', { name: 'x' })).rejects.toThrow(/no identity with id "other"/); + expect(client.updateIdentity).not.toHaveBeenCalled(); + }); + + it('needs identity:write, which the consent dialog marks', async () => { + await expect(call(READ_ONLY, 'identities.update', 'id1', { name: 'x' })).rejects.toThrow(/lacks permission "identity:write"/); + expect(client.updateIdentity).not.toHaveBeenCalled(); + expect(HIGH_RISK_PERMISSIONS.has('identity:write')).toBe(true); + }); +}); + +describe('api.vacation', () => { + it('reads the reply', async () => { + expect(await call(READ_ONLY, 'vacation.get')).toEqual({ + isEnabled: true, subject: 'Away', textBody: 'Back in June', htmlBody: '', fromDate: null, toDate: '2026-06-01T00:00:00Z', + }); + }); + + it('sets the reply through the store, sanitising HTML and deriving the text', async () => { + const saved = await call(ALL, 'vacation.set', { + isEnabled: true, subject: 'Out', htmlBody: '

Back Monday

', + fromDate: '2026-05-01T08:00:00+02:00', toDate: null, + }); + const [updates] = client.setVacationResponse.mock.calls[0]; + expect(updates).toEqual(expect.objectContaining({ isEnabled: true, subject: 'Out', fromDate: '2026-05-01T06:00:00.000Z', toDate: null })); + expect(updates.htmlBody).toContain('Monday'); + expect(updates.htmlBody).not.toContain('script'); + expect(updates.textBody).toMatch(/Back Monday/); + expect(saved).toEqual(expect.objectContaining({ isEnabled: true, subject: 'Out' })); + expect(useVacationStore.getState().subject).toBe('Out'); + }); + + it('refuses bad input and needs vacation:write', async () => { + await expect(call(ALL, 'vacation.set', { subject: 'x' })).rejects.toThrow(/isEnabled must be true or false/); + await expect(call(ALL, 'vacation.set', { isEnabled: true, fromDate: 'next week' })).rejects.toThrow(/fromDate must be a date/); + await expect(call(ALL, 'vacation.set', { isEnabled: true, fromDate: '2026-06-01T00:00:00Z', toDate: '2026-05-01T00:00:00Z' })) + .rejects.toThrow(/toDate must be after fromDate/); + await expect(call(READ_ONLY, 'vacation.set', { isEnabled: false })).rejects.toThrow(/lacks permission "vacation:write"/); + expect(client.setVacationResponse).not.toHaveBeenCalled(); + expect(HIGH_RISK_PERMISSIONS.has('vacation:write')).toBe(true); + }); +}); diff --git a/lib/plugin-sandbox/host-api-settings.ts b/lib/plugin-sandbox/host-api-settings.ts new file mode 100644 index 000000000..192080268 --- /dev/null +++ b/lib/plugin-sandbox/host-api-settings.ts @@ -0,0 +1,224 @@ +// Host side of api.templates, api.identities and api.vacation: the message +// templates (kept in the browser and synced with the settings), the sender +// identities and the out-of-office reply. Changes go through the same stores +// and the same HTML sanitising as Settings, so what a plugin saves is held to +// the rules a user's own edit is. + +import type { + IdentityView, TemplateInput, VacationInput, VacationView, TemplateView, +} from '../plugin-types'; +import type { EmailAddress, Identity } from '../jmap/types'; +import { useTemplateStore } from '@/stores/template-store'; +import { useIdentityStore } from '@/stores/identity-store'; +import { useVacationStore } from '@/stores/vacation-store'; +import { identityToView, templateToView, vacationToView } from '../plugin-projection'; +import { sanitizeEmailHtml, sanitizePluginBodyHtml, sanitizeSignatureHtml } from '../email-sanitization'; +import { htmlToPlainText } from '../html-to-text'; +import { isPeriodBoundary } from '../sieve/period'; +import { + addressOf, nameOf, readAddressList, readBoolean, readId, readObject, readString, sessionClient, +} from './host-api-input'; + +// ─── Templates ──────────────────────────────────────────────── + +/** Templates a plugin may leave in the store; they all sync to the server. */ +export const MAX_PLUGIN_TEMPLATES = 500; +const MAX_TEMPLATE_NAME = 200; +const MAX_TEMPLATE_CATEGORY = 100; +const MAX_TEMPLATE_SUBJECT = 998; +const MAX_TEMPLATE_BODY = 100_000; +const TEMPLATE_FIELDS = ['name', 'subject', 'body', 'isHTML', 'category'] as const; + +export function doTemplatesList(): TemplateView[] { + return useTemplateStore.getState().templates.map(templateToView); +} + +export function doTemplatesGet(id: unknown): TemplateView | null { + const templateId = readId(id, 'templates.get'); + const template = useTemplateStore.getState().templates.find((t) => t.id === templateId); + return template ? templateToView(template) : null; +} + +/** Template fields from a plugin; an HTML body is sanitised like a plugin-supplied body anywhere else. */ +function templateFields(method: string, data: Record, isHTML: boolean) { + const out: { name?: string; subject?: string; body?: string; isHTML?: boolean; category?: string } = {}; + if (data.name !== undefined) out.name = readString(data.name, method, 'name', MAX_TEMPLATE_NAME, { min: 1, trim: true }); + if (data.subject !== undefined) out.subject = readString(data.subject, method, 'subject', MAX_TEMPLATE_SUBJECT); + if (data.category !== undefined) { + out.category = readString(data.category, method, 'category', MAX_TEMPLATE_CATEGORY, { trim: true }); + } + if (data.isHTML !== undefined) out.isHTML = readBoolean(data.isHTML, method, 'isHTML'); + if (data.body !== undefined) { + const body = readString(data.body, method, 'body', MAX_TEMPLATE_BODY); + out.body = isHTML ? sanitizePluginBodyHtml(body) : body; + } + return out; +} + +export function doTemplatesCreate(input: unknown): TemplateView { + const method = 'templates.create'; + const data = readObject(input, method, 'template', TEMPLATE_FIELDS) as TemplateInput & Record; + if (data.name === undefined) throw new Error(`${method}: name is required`); + const store = useTemplateStore.getState(); + if (store.templates.length >= MAX_PLUGIN_TEMPLATES) { + throw new Error(`${method}: there are already ${MAX_PLUGIN_TEMPLATES} templates`); + } + const isHTML = data.isHTML === undefined ? false : readBoolean(data.isHTML, method, 'isHTML'); + const fields = templateFields(method, data, isHTML); + const created = store.addTemplate({ + name: fields.name!, + subject: fields.subject ?? '', + body: fields.body ?? '', + isHTML, + category: fields.category ?? '', + isFavorite: false, + }); + return templateToView(created); +} + +export function doTemplatesUpdate(id: unknown, patch: unknown): TemplateView { + const method = 'templates.update'; + const templateId = readId(id, method); + const data = readObject(patch, method, 'patch', TEMPLATE_FIELDS); + if (Object.keys(data).length === 0) throw new Error(`${method}: patch is empty`); + const existing = useTemplateStore.getState().templates.find((t) => t.id === templateId); + if (!existing) throw new Error(`${method}: no template with id "${templateId}"`); + const isHTML = data.isHTML === undefined ? !!existing.isHTML : readBoolean(data.isHTML, method, 'isHTML'); + const fields = templateFields(method, data, isHTML); + // Turning an existing body into HTML must not let unsanitised text through. + if (isHTML && !existing.isHTML && fields.body === undefined) fields.body = sanitizePluginBodyHtml(existing.body || ''); + useTemplateStore.getState().updateTemplate(templateId, fields); + return templateToView(useTemplateStore.getState().templates.find((t) => t.id === templateId)!); +} + +export function doTemplatesDelete(id: unknown): void { + const method = 'templates.delete'; + const templateId = readId(id, method); + if (!useTemplateStore.getState().templates.some((t) => t.id === templateId)) { + throw new Error(`${method}: no template with id "${templateId}"`); + } + useTemplateStore.getState().deleteTemplate(templateId); +} + +// ─── Identities ─────────────────────────────────────────────── + +const MAX_IDENTITY_NAME = 256; +const MAX_REPLY_TO = 10; +const MAX_TEXT_SIGNATURE = 20_000; +const MAX_HTML_SIGNATURE = 100_000; + +/** + * Change one sender identity's name, reply-to addresses or signatures. The + * address itself can't be changed; an HTML signature is sanitised exactly + * like one typed into Settings > Identities. + */ +export async function doIdentitiesUpdate(id: unknown, patch: unknown): Promise { + const method = 'identities.update'; + const identityId = readId(id, method); + if (patch && typeof patch === 'object' && 'email' in patch) { + throw new Error(`${method}: the address of an identity can't be changed`); + } + const data = readObject(patch, method, 'patch', ['name', 'replyTo', 'textSignature', 'htmlSignature']); + if (Object.keys(data).length === 0) throw new Error(`${method}: patch is empty`); + const identity = useIdentityStore.getState().identities.find((i) => i.id === identityId); + if (!identity) throw new Error(`${method}: no identity with id "${identityId}"`); + + const updates: { name?: string; replyTo?: EmailAddress[] | null; textSignature?: string | null; htmlSignature?: string | null } = {}; + if (data.name !== undefined) updates.name = readString(data.name, method, 'name', MAX_IDENTITY_NAME, { trim: true }); + if (data.replyTo !== undefined) { + const list = data.replyTo === null ? [] : readAddressList(data.replyTo, method, 'replyTo', MAX_REPLY_TO); + updates.replyTo = list.length === 0 + ? null + : list.map((entry) => ({ ...(nameOf(entry) ? { name: nameOf(entry) } : {}), email: addressOf(entry) })); + } + if (data.textSignature !== undefined) { + const text = data.textSignature === null ? '' : readString(data.textSignature, method, 'textSignature', MAX_TEXT_SIGNATURE); + // JMAP clears a field with null; an empty string would be stored as such. + updates.textSignature = text.trim() ? text : null; + } + if (data.htmlSignature !== undefined) { + const html = data.htmlSignature === null ? '' : readString(data.htmlSignature, method, 'htmlSignature', MAX_HTML_SIGNATURE); + updates.htmlSignature = html.trim() ? sanitizeSignatureHtml(html) : null; + } + + await sessionClient(method).updateIdentity(identityId, updates); + useIdentityStore.getState().updateIdentityLocal(identityId, updates as Partial); + return identityToView({ ...identity, ...updates } as Identity); +} + +// ─── Vacation ───────────────────────────────────────────────── + +const MAX_VACATION_SUBJECT = 500; +const MAX_VACATION_BODY = 64_000; + +function vacationClient(method: string) { + const client = sessionClient(method); + if (!client.supportsVacationResponse()) throw new Error(`${method}: the mail server has no out-of-office reply`); + return client; +} + +function vacationViewFromStore(): VacationView { + const s = useVacationStore.getState(); + return vacationToView({ + id: 'singleton', + isEnabled: s.isEnabled, + fromDate: s.fromDate, + toDate: s.toDate, + subject: s.subject, + textBody: s.textBody, + htmlBody: s.htmlBody, + }); +} + +export async function doVacationGet(): Promise { + const method = 'vacation.get'; + const client = vacationClient(method); + await useVacationStore.getState().fetchVacationResponse(client); + const { error } = useVacationStore.getState(); + if (error) throw new Error(`${method}: ${error}`); + return vacationViewFromStore(); +} + +function readMoment(value: unknown, method: string, field: string): string | null { + if (value === null) return null; + if (!isPeriodBoundary(value)) { + throw new Error(`${method}: ${field} must be a date and time with a zone, such as 2026-05-01T00:00:00Z, or null`); + } + return new Date(value).toISOString(); +} + +/** + * Turn the out-of-office reply on or off and set its text and dates. The + * HTML body is sanitised like the one typed into Settings; without a text + * body the text is derived from it. Resolves to the reply as saved, or null + * when another plugin stopped the change. + */ +export async function doVacationSet(input: unknown): Promise { + const method = 'vacation.set'; + const data = readObject(input, method, 'vacation', ['isEnabled', 'subject', 'textBody', 'htmlBody', 'fromDate', 'toDate']) as VacationInput & Record; + const updates: { + isEnabled: boolean; subject?: string; textBody?: string; htmlBody?: string | null; + fromDate?: string | null; toDate?: string | null; + } = { isEnabled: readBoolean(data.isEnabled, method, 'isEnabled') }; + if (data.subject !== undefined) updates.subject = readString(data.subject, method, 'subject', MAX_VACATION_SUBJECT); + if (data.textBody !== undefined) updates.textBody = readString(data.textBody, method, 'textBody', MAX_VACATION_BODY); + if (data.htmlBody !== undefined) { + const html = data.htmlBody === null ? '' : readString(data.htmlBody, method, 'htmlBody', MAX_VACATION_BODY); + updates.htmlBody = html && htmlToPlainText(html).trim() ? sanitizeEmailHtml(html) : null; + if (updates.htmlBody && updates.textBody === undefined) { + updates.textBody = htmlToPlainText(updates.htmlBody, { paragraphSpacing: true }); + } + } + if (data.fromDate !== undefined) updates.fromDate = readMoment(data.fromDate, method, 'fromDate'); + if (data.toDate !== undefined) updates.toDate = readMoment(data.toDate, method, 'toDate'); + if (updates.fromDate && updates.toDate && updates.toDate <= updates.fromDate) { + throw new Error(`${method}: toDate must be after fromDate`); + } + + const client = vacationClient(method); + // Returns false once the onBeforeVacationUpdate hook is wired into the + // store and a plugin said no; nothing (undefined) before that. + const outcome: unknown = await useVacationStore.getState().updateVacationResponse(client, updates); + if (outcome === false) return null; + return vacationViewFromStore(); +} diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 886873e8b..796337398 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -37,6 +37,10 @@ import { doCalendarListCalendars, doCalendarUpdateEvent, doTasksCreate, doTasksDelete, doTasksGet, doTasksList, doTasksSetComplete, doTasksUpdate, } from './host-api-calendar'; +import { + doIdentitiesUpdate, doTemplatesCreate, doTemplatesDelete, doTemplatesGet, doTemplatesList, + doTemplatesUpdate, doVacationGet, doVacationSet, +} from './host-api-settings'; /** * Methods only callable from the privileged (same-origin) tier. These expose @@ -194,6 +198,17 @@ export const PERM_PER_METHOD: Readonly> = { 'tasks.update': 'tasks:write', 'tasks.setComplete': 'tasks:write', 'tasks.delete': 'tasks:write', + // message templates + 'templates.list': 'templates:read', + 'templates.get': 'templates:read', + 'templates.create': 'templates:write', + 'templates.update': 'templates:write', + 'templates.delete': 'templates:write', + // sender identities: name, reply-to and signatures, never the address + 'identities.update': 'identity:write', + // out-of-office reply + 'vacation.get': 'vacation:read', + 'vacation.set': 'vacation:write', }; // ─── Cross-origin allow-list (mirrors lib/plugin-api.ts) ────── @@ -1869,6 +1884,17 @@ export async function dispatchApiCall( case 'tasks.setComplete': return doTasksSetComplete(args[0], args[1]); case 'tasks.delete': return doTasksDelete(args[0]); + case 'templates.list': return doTemplatesList(); + case 'templates.get': return doTemplatesGet(args[0]); + case 'templates.create': return doTemplatesCreate(args[0]); + case 'templates.update': return doTemplatesUpdate(args[0], args[1]); + case 'templates.delete': doTemplatesDelete(args[0]); return undefined; + + case 'identities.update': return doIdentitiesUpdate(args[0], args[1]); + + case 'vacation.get': return doVacationGet(); + case 'vacation.set': return doVacationSet(args[0]); + default: throw new Error(`Unhandled method "${method}"`); } diff --git a/lib/plugin-sandbox/permission-text.ts b/lib/plugin-sandbox/permission-text.ts index 11482525d..e4477c335 100644 --- a/lib/plugin-sandbox/permission-text.ts +++ b/lib/plugin-sandbox/permission-text.ts @@ -19,6 +19,12 @@ export const HIGH_RISK_PERMISSIONS: ReadonlySet = new Set([ 'http:fetch', 'http:post', 'filters:write', + // A plugin can point the reply-to of a sender address elsewhere, so + // answers to the user's mail go to someone else. + 'identity:write', + // The out-of-office reply goes out to everyone who writes, in the user's + // name, with whatever text the plugin set. + 'vacation:write', ]); /** Locale key segment for a permission: `email:read` → `email_read`. */ diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 539f0b1e3..7828bd3b2 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -31,7 +31,8 @@ import { themeSnapshotToCSS, type ThemeSnapshot } from './host-theme'; import { interpolate, lookup, textDirection, type LocaleTables } from './plugin-i18n'; import { isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layout'; import type { - CalendarEventInput, CalendarEventView, CalendarView, SlotName, TaskInput, TaskView, + CalendarEventInput, CalendarEventView, CalendarView, IdentityPatch, IdentityView, SlotName, + TaskInput, TaskView, TemplateInput, TemplateView, VacationInput, VacationView, } from '../plugin-types'; import { AddressBook, ContactCard } from '../jmap/types'; import { EncryptionAtRestConfig, PublicKeyInput } from '@/stores/account-security-store'; @@ -530,6 +531,35 @@ function buildPluginApi(manifest: PluginManifest) { setComplete: (id: string, complete: boolean) => callApi('tasks.setComplete', [id, complete]) as Promise, delete: (id: string) => callApi('tasks.delete', [id]) as Promise, }, + // Message templates (permissions: templates:read / templates:write). + // They live with the user's settings and sync across devices. + templates: { + list: () => callApi('templates.list', []) as Promise, + /** One template, or null. */ + get: (id: string) => callApi('templates.get', [id]) as Promise, + /** At most 500 templates in all. An HTML body is sanitised. */ + create: (template: TemplateInput) => callApi('templates.create', [template]) as Promise, + update: (id: string, patch: Partial) => + callApi('templates.update', [id, patch]) as Promise, + delete: (id: string) => callApi('templates.delete', [id]) as Promise, + }, + // Sender identities. Read them with api.user.getIdentities() + // (identity:read); change one with identity:write. + identities: { + /** + * Change the name, reply-to addresses or signatures of one identity. + * The address can't be changed. An HTML signature is sanitised like + * one typed into Settings. Resolves to the identity as changed. + */ + update: (id: string, patch: IdentityPatch) => + callApi('identities.update', [id, patch]) as Promise, + }, + // Out-of-office reply (permissions: vacation:read / vacation:write). + vacation: { + get: () => callApi('vacation.get', []) as Promise, + /** Resolves to the reply as saved, or null when another plugin stopped the change. */ + set: (vacation: VacationInput) => callApi('vacation.set', [vacation]) as Promise, + }, // OAuth with a third party (permission: http:fetch). Put createState()'s // value in the authorize URL; the code comes back through the // onOAuthCallback hook, to this plugin only. diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index bd927073b..c21df5cb6 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -646,6 +646,46 @@ export interface TemplateView { isHTML: boolean; } +/** What a plugin passes to `api.templates.create` (and, every field optional, to `update`). */ +export interface TemplateInput { + /** 1-200 characters. */ + name: string; + /** Up to 998 characters. */ + subject?: string; + /** Up to 100,000 characters. When isHTML, sanitised like any HTML a plugin hands the host. */ + body?: string; + isHTML?: boolean; + /** Up to 100 characters; '' for none. */ + category?: string; +} + +/** What a plugin may change on a sender identity with `api.identities.update`. The address can't be changed. */ +export interface IdentityPatch { + /** Up to 256 characters. */ + name?: string; + /** At most 10 addresses (`a@example.com` or `Name `); null or [] removes them. */ + replyTo?: string[] | null; + /** Up to 20,000 characters; null or '' removes it. */ + textSignature?: string | null; + /** Up to 100,000 characters, sanitised like a signature typed into Settings; null or '' removes it. */ + htmlSignature?: string | null; +} + +/** What a plugin passes to `api.vacation.set`. Fields left out keep their value. */ +export interface VacationInput { + isEnabled: boolean; + /** Up to 500 characters. */ + subject?: string; + /** Up to 64,000 characters. Derived from htmlBody when that is given and this is not. */ + textBody?: string; + /** Up to 64,000 characters, sanitised like the HTML typed into Settings; null removes it. */ + htmlBody?: string | null; + /** When the reply starts, with a zone (`2026-05-01T00:00:00Z`), or null for now. */ + fromDate?: string | null; + /** When it ends, or null for open-ended. */ + toDate?: string | null; +} + export interface FilterRuleView { id: string; name: string;