feat: let plugins manage templates, sender signatures and the out-of-office reply

This commit is contained in:
Linus Rath
2026-10-14 04:06:06 +02:00
parent 3df031edd1
commit 121d8ba30c
6 changed files with 494 additions and 1 deletions
@@ -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<string, ReturnType<typeof vi.fn>>;
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: '<p onclick="x()">Hi</p><script>alert(1)</script>', isHTML: true,
}) as { id: string; body: string };
expect(created.body).toContain('<p>Hi</p>');
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 <desk@example.com>'],
htmlSignature: '<b>Ann</b><img src=x onerror="steal()">',
});
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('<b>Ann</b>');
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: '<p>Back <b>Monday</b></p><script>x()</script>',
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('<b>Monday</b>');
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);
});
});
+224
View File
@@ -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<string, unknown>, 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<string, unknown>;
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<IdentityView> {
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<Identity>);
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<VacationView> {
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<VacationView | null> {
const method = 'vacation.set';
const data = readObject(input, method, 'vacation', ['isEnabled', 'subject', 'textBody', 'htmlBody', 'fromDate', 'toDate']) as VacationInput & Record<string, unknown>;
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();
}
+26
View File
@@ -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<Record<string, Permission | null>> = {
'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}"`);
}
+6
View File
@@ -19,6 +19,12 @@ export const HIGH_RISK_PERMISSIONS: ReadonlySet<string> = 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`. */
+31 -1
View File
@@ -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<void>,
delete: (id: string) => callApi('tasks.delete', [id]) as Promise<void>,
},
// 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<TemplateView[]>,
/** One template, or null. */
get: (id: string) => callApi('templates.get', [id]) as Promise<TemplateView | null>,
/** At most 500 templates in all. An HTML body is sanitised. */
create: (template: TemplateInput) => callApi('templates.create', [template]) as Promise<TemplateView>,
update: (id: string, patch: Partial<TemplateInput>) =>
callApi('templates.update', [id, patch]) as Promise<TemplateView>,
delete: (id: string) => callApi('templates.delete', [id]) as Promise<void>,
},
// 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<IdentityView>,
},
// Out-of-office reply (permissions: vacation:read / vacation:write).
vacation: {
get: () => callApi('vacation.get', []) as Promise<VacationView>,
/** Resolves to the reply as saved, or null when another plugin stopped the change. */
set: (vacation: VacationInput) => callApi('vacation.set', [vacation]) as Promise<VacationView | null>,
},
// 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.
+40
View File
@@ -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 <a@example.com>`); 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;