feat: add email templates with placeholder variables and composer integration

- Reusable email templates with local storage persistence
- Dynamic placeholder variables ({{recipientName}}, {{date}}, etc.) with auto-fill
- Template manager modal with category filtering and search
- Template picker integrated in composer toolbar (Ctrl+Shift+T)
- Settings tab for template management
- 48 unit tests for template utilities
- i18n support for all 8 languages
This commit is contained in:
Matthieu MALVACHE
2026-02-17 01:35:26 +01:00
committed by Matthieu MALVACHE
parent b65b2f26df
commit 2636a88820
23 changed files with 2422 additions and 36 deletions
+335
View File
@@ -0,0 +1,335 @@
import { describe, it, expect } from 'vitest';
import {
extractPlaceholders,
substitutePlaceholders,
hasUnresolvedPlaceholders,
validateTemplateName,
getAutoFilledPlaceholders,
getPlaceholdersFromTemplate,
isBuiltInPlaceholder,
filterTemplates,
exportTemplates,
importTemplates,
} from '../template-utils';
import type { EmailTemplate } from '../template-types';
function makeTemplate(overrides: Partial<EmailTemplate> = {}): EmailTemplate {
return {
id: 'test-id',
name: 'Test Template',
subject: '',
body: '',
category: '',
isFavorite: false,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
...overrides,
};
}
describe('extractPlaceholders', () => {
it('extracts single placeholder', () => {
expect(extractPlaceholders('Hello {{name}}')).toEqual(['name']);
});
it('extracts multiple placeholders', () => {
const result = extractPlaceholders('{{greeting}} {{name}}, welcome to {{company}}');
expect(result).toEqual(['greeting', 'name', 'company']);
});
it('deduplicates repeated placeholders', () => {
expect(extractPlaceholders('{{name}} and {{name}}')).toEqual(['name']);
});
it('returns empty array for no placeholders', () => {
expect(extractPlaceholders('No placeholders here')).toEqual([]);
});
it('handles empty string', () => {
expect(extractPlaceholders('')).toEqual([]);
});
it('ignores malformed placeholders', () => {
expect(extractPlaceholders('{{}} {name} {{ name }}')).toEqual([]);
});
it('handles underscored names', () => {
expect(extractPlaceholders('{{first_name}} {{last_name}}')).toEqual(['first_name', 'last_name']);
});
});
describe('substitutePlaceholders', () => {
it('replaces a single placeholder', () => {
expect(substitutePlaceholders('Hello {{name}}', { name: 'Alice' })).toBe('Hello Alice');
});
it('replaces multiple placeholders', () => {
const result = substitutePlaceholders('{{greeting}} {{name}}', {
greeting: 'Hi',
name: 'Bob',
});
expect(result).toBe('Hi Bob');
});
it('leaves unresolved placeholders', () => {
expect(substitutePlaceholders('{{known}} {{unknown}}', { known: 'yes' })).toBe('yes {{unknown}}');
});
it('sanitizes XSS in values', () => {
const result = substitutePlaceholders('{{name}}', { name: '<script>alert(1)</script>' });
expect(result).not.toContain('<script>');
});
it('handles empty values', () => {
expect(substitutePlaceholders('{{name}}', { name: '' })).toBe('');
});
it('handles no placeholders in text', () => {
expect(substitutePlaceholders('No placeholders', { name: 'test' })).toBe('No placeholders');
});
});
describe('hasUnresolvedPlaceholders', () => {
it('returns true when placeholders exist', () => {
expect(hasUnresolvedPlaceholders('Hello {{name}}')).toBe(true);
});
it('returns false when no placeholders', () => {
expect(hasUnresolvedPlaceholders('Hello world')).toBe(false);
});
it('returns false for empty string', () => {
expect(hasUnresolvedPlaceholders('')).toBe(false);
});
it('returns consistent results on consecutive calls', () => {
const text = 'Hello {{name}}';
expect(hasUnresolvedPlaceholders(text)).toBe(true);
expect(hasUnresolvedPlaceholders(text)).toBe(true);
expect(hasUnresolvedPlaceholders(text)).toBe(true);
});
});
describe('validateTemplateName', () => {
it('returns null for valid name', () => {
expect(validateTemplateName('My Template')).toBeNull();
});
it('returns empty for empty string', () => {
expect(validateTemplateName('')).toBe('empty');
});
it('returns empty for whitespace only', () => {
expect(validateTemplateName(' ')).toBe('empty');
});
it('returns too_long for name over 200 chars', () => {
expect(validateTemplateName('a'.repeat(201))).toBe('too_long');
});
it('accepts name at exactly 200 chars', () => {
expect(validateTemplateName('a'.repeat(200))).toBeNull();
});
});
describe('getAutoFilledPlaceholders', () => {
it('includes date and day_of_week', () => {
const result = getAutoFilledPlaceholders({});
expect(result).toHaveProperty('date');
expect(result).toHaveProperty('day_of_week');
});
it('includes sender_name when provided', () => {
const result = getAutoFilledPlaceholders({ senderName: 'John' });
expect(result.sender_name).toBe('John');
});
it('omits sender_name when not provided', () => {
const result = getAutoFilledPlaceholders({});
expect(result).not.toHaveProperty('sender_name');
});
});
describe('getPlaceholdersFromTemplate', () => {
it('extracts from both subject and body', () => {
const tpl = makeTemplate({
subject: 'Hello {{name}}',
body: 'Welcome to {{company}}',
});
expect(getPlaceholdersFromTemplate(tpl)).toEqual(['name', 'company']);
});
it('deduplicates across subject and body', () => {
const tpl = makeTemplate({
subject: '{{name}}',
body: '{{name}} again',
});
expect(getPlaceholdersFromTemplate(tpl)).toEqual(['name']);
});
});
describe('isBuiltInPlaceholder', () => {
it('returns true for built-in names', () => {
expect(isBuiltInPlaceholder('date')).toBe(true);
expect(isBuiltInPlaceholder('sender_name')).toBe(true);
expect(isBuiltInPlaceholder('recipient_name')).toBe(true);
expect(isBuiltInPlaceholder('company')).toBe(true);
expect(isBuiltInPlaceholder('day_of_week')).toBe(true);
});
it('returns false for custom names', () => {
expect(isBuiltInPlaceholder('custom_field')).toBe(false);
expect(isBuiltInPlaceholder('project')).toBe(false);
});
});
describe('exportTemplates', () => {
it('produces valid JSON with metadata', () => {
const templates = [makeTemplate()];
const json = exportTemplates(templates);
const parsed = JSON.parse(json);
expect(parsed.version).toBe(1);
expect(parsed.type).toBe('webmail-templates');
expect(parsed.templates).toHaveLength(1);
expect(parsed.exportedAt).toBeDefined();
});
it('handles empty array', () => {
const json = exportTemplates([]);
const parsed = JSON.parse(json);
expect(parsed.templates).toHaveLength(0);
});
});
describe('importTemplates', () => {
it('imports valid export data', () => {
const original = [makeTemplate({ name: 'Test' })];
const json = exportTemplates(original);
const result = importTemplates(json);
expect(result.templates).toHaveLength(1);
expect(result.templates[0].name).toBe('Test');
expect(result.errors).toHaveLength(0);
});
it('assigns new IDs on import', () => {
const original = [makeTemplate({ name: 'Test' })];
const json = exportTemplates(original);
const result = importTemplates(json);
expect(result.templates[0].id).not.toBe('test-id');
});
it('returns error for invalid JSON', () => {
const result = importTemplates('not json');
expect(result.templates).toHaveLength(0);
expect(result.errors).toContain('invalid_json');
});
it('returns error for wrong type', () => {
const result = importTemplates(JSON.stringify({ type: 'other', version: 1, templates: [] }));
expect(result.errors).toContain('invalid_type');
});
it('returns error for unsupported version', () => {
const result = importTemplates(JSON.stringify({ type: 'webmail-templates', version: 99, templates: [] }));
expect(result.errors).toContain('unsupported_version');
});
it('returns error for non-object input', () => {
const result = importTemplates('"just a string"');
expect(result.errors).toContain('invalid_format');
});
it('skips entries without name', () => {
const json = JSON.stringify({
type: 'webmail-templates',
version: 1,
templates: [{ subject: 'no name' }, { name: 'Valid' }],
});
const result = importTemplates(json);
expect(result.templates).toHaveLength(1);
expect(result.templates[0].name).toBe('Valid');
expect(result.errors).toContain('missing_template_name');
});
it('sanitizes imported values against XSS', () => {
const json = JSON.stringify({
type: 'webmail-templates',
version: 1,
templates: [{ name: '<img onerror=alert(1) src=x>', subject: '<script>alert(1)</script>' }],
});
const result = importTemplates(json);
expect(result.templates[0].name).not.toContain('onerror');
expect(result.templates[0].subject).not.toContain('<script>');
});
it('handles missing templates array', () => {
const result = importTemplates(JSON.stringify({ type: 'webmail-templates', version: 1 }));
expect(result.errors).toContain('invalid_templates');
});
it('imports defaultRecipients correctly', () => {
const json = JSON.stringify({
type: 'webmail-templates',
version: 1,
templates: [{
name: 'With Recipients',
defaultRecipients: { to: ['a@b.com'], cc: ['c@d.com'] },
}],
});
const result = importTemplates(json);
expect(result.templates[0].defaultRecipients?.to).toEqual(['a@b.com']);
expect(result.templates[0].defaultRecipients?.cc).toEqual(['c@d.com']);
});
it('round-trips export and import', () => {
const originals = [
makeTemplate({ name: 'Template 1', subject: 'Hi {{name}}', category: 'work', isFavorite: true }),
makeTemplate({ name: 'Template 2', body: 'Body text', category: 'personal' }),
];
const json = exportTemplates(originals);
const result = importTemplates(json);
expect(result.templates).toHaveLength(2);
expect(result.templates[0].name).toBe('Template 1');
expect(result.templates[0].subject).toBe('Hi {{name}}');
expect(result.templates[1].name).toBe('Template 2');
expect(result.errors).toHaveLength(0);
});
});
describe('filterTemplates', () => {
const templates = [
makeTemplate({ id: '1', name: 'Follow-up', subject: 'Re: meeting', category: 'work' }),
makeTemplate({ id: '2', name: 'Welcome', subject: 'Hello there', category: 'personal' }),
makeTemplate({ id: '3', name: 'Invoice', subject: 'Monthly bill', category: 'work' }),
];
it('filters by name', () => {
const result = filterTemplates(templates, 'follow');
expect(result).toHaveLength(1);
expect(result[0].id).toBe('1');
});
it('filters by subject', () => {
const result = filterTemplates(templates, 'meeting');
expect(result).toHaveLength(1);
expect(result[0].id).toBe('1');
});
it('filters by category', () => {
const result = filterTemplates(templates, 'personal');
expect(result).toHaveLength(1);
expect(result[0].id).toBe('2');
});
it('is case-insensitive', () => {
expect(filterTemplates(templates, 'WELCOME')).toHaveLength(1);
});
it('returns all when multiple match', () => {
expect(filterTemplates(templates, 'work')).toHaveLength(2);
});
it('returns empty array for no matches', () => {
expect(filterTemplates(templates, 'xyz')).toHaveLength(0);
});
});
+31
View File
@@ -0,0 +1,31 @@
export interface EmailTemplate {
id: string;
name: string;
subject: string;
body: string;
category: string;
defaultRecipients?: {
to?: string[];
cc?: string[];
bcc?: string[];
};
identityId?: string;
isFavorite: boolean;
createdAt: string;
updatedAt: string;
}
export interface PlaceholderVariable {
name: string;
value: string;
}
export const BUILT_IN_PLACEHOLDERS = [
'recipient_name',
'company',
'date',
'day_of_week',
'sender_name',
] as const;
export type BuiltInPlaceholder = (typeof BUILT_IN_PLACEHOLDERS)[number];
+172
View File
@@ -0,0 +1,172 @@
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 };
}