// 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 { updateIdentityWithHooks, 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; } // Through the store's hooked update, so onBeforeIdentityChange can stop a // plugin's change like it stops one made in Settings. const done = await updateIdentityWithHooks(sessionClient(method), identity as Identity, updates as Parameters[2]); if (!done) return null; 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(); }