mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
235 lines
12 KiB
TypeScript
235 lines
12 KiB
TypeScript
// Host side of api.templates, api.identities and api.vacation: the message
|
|
// templates (the active account's own: in the account on Stalwart 1.0, in the
|
|
// browser and synced with the settings elsewhere), 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 { personalTemplatesFor } from '@/lib/templates/personal-templates';
|
|
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;
|
|
|
|
// The active account's own templates: kept in the account on Stalwart 1.0,
|
|
// in the browser's list otherwise (lib/templates/personal-templates.ts).
|
|
const personal = () => personalTemplatesFor();
|
|
|
|
export function doTemplatesList(): TemplateView[] {
|
|
return personal().templates().map(templateToView);
|
|
}
|
|
|
|
export function doTemplatesGet(id: unknown): TemplateView | null {
|
|
const templateId = readId(id, 'templates.get');
|
|
const template = personal().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 = personal();
|
|
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.add({
|
|
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 store = personal();
|
|
const existing = store.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 || '');
|
|
store.update(templateId, fields);
|
|
return templateToView(store.templates().find((t) => t.id === templateId)!);
|
|
}
|
|
|
|
export function doTemplatesDelete(id: unknown): void {
|
|
const method = 'templates.delete';
|
|
const templateId = readId(id, method);
|
|
const store = personal();
|
|
if (!store.templates().some((t) => t.id === templateId)) {
|
|
throw new Error(`${method}: no template with id "${templateId}"`);
|
|
}
|
|
store.remove(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 | null> {
|
|
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<typeof updateIdentityWithHooks>[2]);
|
|
if (!done) return null;
|
|
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();
|
|
}
|