auth-email-templates.ts124 lines · main
1/**
2 * Email template customization — Phase 7.5.
3 *
4 * Per-tenant overrides for transactional emails. Templates support
5 * simple {{variable}} substitution. When a custom template is active,
6 * it replaces the default briven template in the mailer pipeline.
7 */
8
9import { ValidationError } from '@briven/shared';
10import { runInProjectDatabase } from '../db/data-plane.js';
11
12export const EMAIL_TEMPLATE_NAMES = [
13 'verification',
14 'magic-link',
15 'otp',
16 'password-reset',
17] as const;
18export type EmailTemplateName = (typeof EMAIL_TEMPLATE_NAMES)[number];
19
20export interface EmailTemplateInput {
21 name: EmailTemplateName;
22 subject: string;
23 html: string;
24 text?: string | null;
25}
26
27export interface RenderedEmail {
28 subject: string;
29 html: string;
30 text: string | null;
31}
32
33export async function getEmailTemplate(
34 projectId: string,
35 name: EmailTemplateName,
36): Promise<{ subject: string; html: string; text: string | null } | null> {
37 try {
38 const rows = await runInProjectDatabase(projectId, async (tx) => {
39 return (await tx.unsafe(
40 `SELECT subject, html, text FROM "_briven_auth_email_templates" WHERE name = $1 AND active = true LIMIT 1`,
41 [name] as never,
42 )) as Array<{ subject: string; html: string; text: string | null }>;
43 });
44 return rows[0] ?? null;
45 } catch {
46 // Table missing on older tenants (before self-heal) must never 500 the
47 // magic-link / OTP send path — fall back to built-in templates.
48 return null;
49 }
50}
51
52export async function setEmailTemplate(
53 projectId: string,
54 input: EmailTemplateInput,
55): Promise<void> {
56 if (!EMAIL_TEMPLATE_NAMES.includes(input.name)) {
57 throw new ValidationError(`unknown template name: ${input.name}`);
58 }
59 const id = crypto.randomUUID();
60 await runInProjectDatabase(projectId, async (tx) => {
61 await tx.unsafe(
62 `INSERT INTO "_briven_auth_email_templates" (id, name, subject, html, text)
63 VALUES ($1, $2, $3, $4, $5)
64 ON CONFLICT (name) DO UPDATE SET
65 subject = $3, html = $4, text = $5, active = true, updated_at = now()`,
66 [id, input.name, input.subject, input.html, input.text ?? null] as never,
67 );
68 });
69}
70
71export async function deactivateEmailTemplate(projectId: string, name: EmailTemplateName): Promise<void> {
72 await runInProjectDatabase(projectId, async (tx) => {
73 await tx.unsafe(
74 `UPDATE "_briven_auth_email_templates" SET active = false, updated_at = now() WHERE name = $1`,
75 [name] as never,
76 );
77 });
78}
79
80export async function listEmailTemplates(
81 projectId: string,
82): Promise<Array<{ name: string; subject: string; active: boolean; updatedAt: Date }>> {
83 const rows = await runInProjectDatabase(projectId, async (tx) => {
84 return (await tx.unsafe(
85 `SELECT name, subject, active, updated_at FROM "_briven_auth_email_templates" ORDER BY name`,
86 )) as Array<{ name: string; subject: string; active: boolean; updated_at: Date }>;
87 });
88 return rows.map((r) => ({ name: r.name, subject: r.subject, active: r.active, updatedAt: r.updated_at }));
89}
90
91/**
92 * Simple variable substitution: {{key}} → value.
93 * Falls back to the default template when no custom template exists.
94 */
95export function renderTemplate(
96 template: { subject: string; html: string; text: string | null },
97 vars: Record<string, string>,
98): RenderedEmail {
99 let subject = template.subject;
100 let html = template.html;
101 let text = template.text;
102
103 for (const [key, value] of Object.entries(vars)) {
104 const placeholder = new RegExp(`\\{\\{\\s*${escapeRegExp(key)}\\s*\\}\\}`, 'g');
105 subject = subject.replace(placeholder, value);
106 html = html.replace(placeholder, escapeHtml(value));
107 if (text) text = text.replace(placeholder, value);
108 }
109
110 return { subject, html, text: text ?? null };
111}
112
113function escapeRegExp(s: string): string {
114 return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
115}
116
117function escapeHtml(s: string): string {
118 return s
119 .replace(/&/g, '&amp;')
120 .replace(/</g, '&lt;')
121 .replace(/>/g, '&gt;')
122 .replace(/"/g, '&quot;')
123 .replace(/'/g, '&#39;');
124}