Files
webmail/lib/template-utils.ts
T
Linus Rath 88f20e1ab0 fix: never lose personal templates to a deletion, a clock or a settings load elsewhere
The first version took a template missing from the account's Inbox
metadata as deleted, skipped the browser's own list once the account
held anything, wrote the marker before the settings blob had loaded,
and let a round overwrite edits made while it ran. Each of these could
drop templates, or bring deleted ones back.

Now the marker entry `org.bulwarkmail.templates` holds tombstones
(90 days, at most 8 KB, oldest dropped first) and only a tombstone
deletes; a missing entry is uploaded again. Every round merges the
browser's list in when it is this account's, each version once, minus
what a newer tombstone deleted. While settings sync hasn't loaded the
account's settings, the list isn't used and no marker is written; a
blob that loads later is merged into the account's copy. The settings
blob drops its templates only once the marker is on the server. Without
settings sync the list goes into the first account that answers only.
A round that saw changes while it ran plans again against the server as
it left it. An answer without the metadata property counts as
unsupported. Changes move `updatedAt` past the previous value, dateless
synced templates get a fixed early date, and ids that don't fit a
metadata key get a new id derived from the old one. Queued changes go
out before an account signs out; a full sign-out keeps unsent templates
and deletions.

Team templates are written only if the entry is still the version the
edit started from ("Changed by someone else"), never carry Bcc, and a
refusal is told apart by Stalwart's error type. The template manager's
lists are a real tab list with arrow keys and 44 px row buttons on
phones.
2026-10-28 07:34:25 +01:00

321 lines
11 KiB
TypeScript

import DOMPurify from 'dompurify';
import { sanitizeEmailHtml } from './email-sanitization';
import type { EmailTemplate } from './template-types';
import { BUILT_IN_PLACEHOLDERS } from './template-types';
import { generateUUID } from './utils';
const PLACEHOLDER_REGEX = /\{\{(\w+)\}\}/g;
const MAX_TEMPLATE_NAME_LENGTH = 200;
const STRIP_HTML_CONFIG = { ALLOWED_TAGS: [] as string[], ALLOWED_ATTR: [] as string[] };
export function extractPlaceholders(text: string): string[] {
const matches = new Set<string>();
let match: RegExpExecArray | null;
const regex = new RegExp(PLACEHOLDER_REGEX.source, 'g');
while ((match = regex.exec(text)) !== null) {
matches.add(match[1]);
}
return Array.from(matches);
}
export function substitutePlaceholders(
text: string,
values: Record<string, string>
): string {
return text.replace(PLACEHOLDER_REGEX, (full, name) => {
if (values[name] === undefined) return full;
return DOMPurify.sanitize(values[name], STRIP_HTML_CONFIG);
});
}
export function hasUnresolvedPlaceholders(text: string): boolean {
return new RegExp(PLACEHOLDER_REGEX.source).test(text);
}
export function validateTemplateName(name: string): string | null {
const trimmed = name.trim();
if (!trimmed) return 'empty';
if (trimmed.length > MAX_TEMPLATE_NAME_LENGTH) return 'too_long';
return null;
}
export interface AutoFillContext {
senderName?: string;
locale?: string;
}
export function getAutoFilledPlaceholders(
context: AutoFillContext
): Record<string, string> {
const now = new Date();
const locale = context.locale || 'en';
const values: Record<string, string> = {
date: now.toLocaleDateString(locale, { year: 'numeric', month: 'long', day: 'numeric' }),
// The locale's all-numeric form (19.10.2026, 10/19/2026), and the parts
// for any other order or separator (#1284).
short_date: now.toLocaleDateString(locale, { year: 'numeric', month: '2-digit', day: '2-digit' }),
day: String(now.getDate()).padStart(2, '0'),
month: String(now.getMonth() + 1).padStart(2, '0'),
year: String(now.getFullYear()),
day_of_week: now.toLocaleDateString(locale, { weekday: 'long' }),
};
if (context.senderName) {
values.sender_name = context.senderName;
}
return values;
}
export function getPlaceholdersFromTemplate(template: EmailTemplate): string[] {
const combined = `${template.subject} ${template.body}`;
return extractPlaceholders(combined);
}
export function isBuiltInPlaceholder(name: string): boolean {
return (BUILT_IN_PLACEHOLDERS as readonly string[]).includes(name);
}
export function filterTemplates(templates: EmailTemplate[], query: string): EmailTemplate[] {
const lower = query.toLowerCase();
return templates.filter(
(t) =>
t.name.toLowerCase().includes(lower) ||
t.subject.toLowerCase().includes(lower) ||
t.category.toLowerCase().includes(lower)
);
}
const SIGNATURE_START_SELECTOR = '[data-signature-block="separator"], [data-signature-block="start"]';
const SIGNATURE_END_SELECTOR = '[data-signature-block="end"]';
// Compose bodies carry the embedded signature bracketed by
// data-signature-block markers (see email-composer's
// buildEmbeddedSignatureHtml). Applying a template must replace only the
// message content, so splice the template above the signature range instead
// of overwriting the whole body.
export function spliceTemplateAboveSignature(prevHtml: string, templateHtml: string): string {
const doc = new DOMParser().parseFromString(prevHtml, 'text/html');
const startEl = doc.querySelector(SIGNATURE_START_SELECTOR);
if (!startEl) return templateHtml;
const endEl = doc.querySelector(SIGNATURE_END_SELECTOR);
const host = doc.createElement('div');
let cursor: Node | null = startEl;
while (cursor) {
host.appendChild(cursor.cloneNode(true));
if (cursor === endEl) break;
cursor = cursor.nextSibling;
}
return templateHtml + host.innerHTML;
}
/**
* Whether a compose body holds anything the user wrote above the embedded
* signature. A fresh compose body is just an empty paragraph plus the
* signature range, so this stays false until the user types something -
* the composer then inserts the template at the caret rather than splicing
* it over the draft (#540).
*/
export function composeBodyHasUserContent(prevHtml: string): boolean {
if (!prevHtml.trim()) return false;
const doc = new DOMParser().parseFromString(prevHtml, 'text/html');
const startEl = doc.querySelector(SIGNATURE_START_SELECTOR);
if (startEl) {
// Drop the signature range [start … end] inclusive, mirroring the
// traversal spliceTemplateAboveSignature uses to keep it.
const endEl = doc.querySelector(SIGNATURE_END_SELECTOR);
let cursor: Node | null = startEl;
while (cursor) {
const next: Node | null = cursor.nextSibling;
const isEnd = cursor === endEl;
cursor.parentNode?.removeChild(cursor);
if (isEnd) break;
cursor = next;
}
}
if (doc.body.textContent?.trim()) return true;
// Text-free content still counts as a draft worth keeping.
return doc.body.querySelector('img, table, hr, [data-quoted-html]') !== null;
}
function sanitizeText(value: unknown): string {
return DOMPurify.sanitize(String(value || ''), STRIP_HTML_CONFIG);
}
/**
* An HTML template body from outside the app (a templates file, a synced
* settings blob written by another client) as the store may hold it. Bodies
* the plugin API creates are sanitised at creation; these were kept raw and
* reached the composer's placeholder preview as markup in the app document.
*/
function sanitizeTemplateHtml(value: unknown): string {
return sanitizeEmailHtml(String(value || ''));
}
interface ExportData {
version: 1;
type: 'webmail-templates';
exportedAt: string;
templates: EmailTemplate[];
}
export function exportTemplates(templates: EmailTemplate[]): string {
const data: ExportData = {
version: 1,
type: 'webmail-templates',
exportedAt: new Date().toISOString(),
templates,
};
return JSON.stringify(data, null, 2);
}
export interface ImportResult {
templates: EmailTemplate[];
errors: string[];
}
// --- Cross-device sync (settings blob) helpers -----------------------------
// Templates ride along in the per-account settings sync blob (see
// settings-store exportSettings/importSettings, #825). Unlike the file import
// below, synced templates keep their ids so the same template converges to a
// single entry across devices instead of duplicating on every server load.
// The merge itself lives in template-merge so the settings API route can use
// it without pulling DOMPurify into the server bundle.
export {
mergeSyncedTemplates,
TEMPLATE_TOMBSTONE_TTL_MS,
type SyncedTemplateState,
} from './template-merge';
/** `createdAt` / `updatedAt` of a synced template that has none. */
const UNKNOWN_TIME = new Date(0).toISOString();
/**
* Structurally validates a `templates` value from a synced settings blob or
* settings-file import. Returns null when the value is not an array (a blob
* from a build that predates template sync), so callers can leave the local
* store untouched; malformed entries are dropped, ids are preserved.
*/
export function parseSyncedTemplates(value: unknown): EmailTemplate[] | null {
if (!Array.isArray(value)) return null;
const templates: EmailTemplate[] = [];
const seen = new Set<string>();
for (const item of value) {
if (typeof item !== 'object' || item === null) continue;
const t = item as Record<string, unknown>;
if (typeof t.id !== 'string' || !t.id || seen.has(t.id)) continue;
if (typeof t.name !== 'string' || !t.name.trim()) continue;
seen.add(t.id);
const recipients = t.defaultRecipients as Record<string, unknown> | undefined;
// A fixed time, not now: a copy without a date must never win a merge
// by `updatedAt` against one that has a date.
const now = UNKNOWN_TIME;
templates.push({
id: t.id,
name: sanitizeText(t.name),
subject: sanitizeText(t.subject),
body: t.isHTML ? sanitizeTemplateHtml(t.body) : sanitizeText(t.body),
isHTML: Boolean(t.isHTML),
category: sanitizeText(t.category),
defaultRecipients: recipients && typeof recipients === 'object'
? {
to: Array.isArray(recipients.to) ? (recipients.to as string[]).map(String) : undefined,
cc: Array.isArray(recipients.cc) ? (recipients.cc as string[]).map(String) : undefined,
bcc: Array.isArray(recipients.bcc) ? (recipients.bcc as string[]).map(String) : undefined,
}
: undefined,
identityId: typeof t.identityId === 'string' ? t.identityId : undefined,
isFavorite: Boolean(t.isFavorite),
createdAt: typeof t.createdAt === 'string' ? t.createdAt : now,
updatedAt: typeof t.updatedAt === 'string' ? t.updatedAt : now,
});
}
return templates;
}
/** Structurally validates a `deletedTemplateIds` map from a synced blob. */
export function parseTemplateTombstones(value: unknown): Record<string, string> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return {};
const out: Record<string, string> = {};
for (const [id, deletedAt] of Object.entries(value as Record<string, unknown>)) {
if (typeof deletedAt === 'string' && !Number.isNaN(Date.parse(deletedAt))) {
out[id] = deletedAt;
}
}
return out;
}
export function importTemplates(json: string): ImportResult {
const errors: string[] = [];
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch {
return { templates: [], errors: ['invalid_json'] };
}
if (typeof parsed !== 'object' || parsed === null) {
return { templates: [], errors: ['invalid_format'] };
}
const data = parsed as Record<string, unknown>;
if (data.type !== 'webmail-templates') {
return { templates: [], errors: ['invalid_type'] };
}
if (data.version !== 1) {
return { templates: [], errors: ['unsupported_version'] };
}
if (!Array.isArray(data.templates)) {
return { templates: [], errors: ['invalid_templates'] };
}
const templates: EmailTemplate[] = [];
for (const item of data.templates) {
if (typeof item !== 'object' || item === null) {
errors.push('invalid_template_entry');
continue;
}
const t = item as Record<string, unknown>;
if (typeof t.name !== 'string' || !t.name.trim()) {
errors.push('missing_template_name');
continue;
}
const recipients = t.defaultRecipients as Record<string, unknown> | undefined;
templates.push({
id: generateUUID(),
name: sanitizeText(t.name),
subject: sanitizeText(t.subject),
body: t.isHTML ? sanitizeTemplateHtml(t.body) : sanitizeText(t.body),
isHTML: Boolean(t.isHTML),
category: sanitizeText(t.category),
defaultRecipients: recipients && typeof recipients === 'object'
? {
to: Array.isArray(recipients.to) ? (recipients.to as string[]).map(String) : undefined,
cc: Array.isArray(recipients.cc) ? (recipients.cc as string[]).map(String) : undefined,
bcc: Array.isArray(recipients.bcc) ? (recipients.bcc as string[]).map(String) : undefined,
}
: undefined,
identityId: typeof t.identityId === 'string' ? t.identityId : undefined,
isFavorite: Boolean(t.isFavorite),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
}
return { templates, errors };
}