mirror of
https://github.com/root-fr/jmap-webmail.git
synced 2026-09-23 16:01:15 +00:00
Local-storage templates with dynamic {{variable}} placeholders that auto-fill
from composer context (recipient, sender, date/time). Includes template manager,
category filtering, quick picker in composer toolbar, and settings tab.
48 tests covering placeholder extraction, filling, and variable suggestions.
173 lines
4.9 KiB
TypeScript
173 lines
4.9 KiB
TypeScript
import DOMPurify from 'dompurify';
|
|
import type { EmailTemplate } from './template-types';
|
|
import { BUILT_IN_PLACEHOLDERS } from './template-types';
|
|
|
|
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' }),
|
|
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)
|
|
);
|
|
}
|
|
|
|
function sanitizeText(value: unknown): string {
|
|
return DOMPurify.sanitize(String(value || ''), STRIP_HTML_CONFIG);
|
|
}
|
|
|
|
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[];
|
|
}
|
|
|
|
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: crypto.randomUUID(),
|
|
name: sanitizeText(t.name),
|
|
subject: sanitizeText(t.subject),
|
|
body: sanitizeText(t.body),
|
|
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 };
|
|
}
|