auth-mailer.ts385 lines · main
1import { env } from '../env.js';
2import { sendTenantEmail } from '../lib/email.js';
3import { log } from '../lib/logger.js';
4import { recordAuthMailerFailure } from './auth-reliability.js';
5import { getAuthConfig, type AuthConfig } from './tenant-config-store.js';
6import { getEmailTemplate, renderTemplate, type EmailTemplateName } from './auth-email-templates.js';
7
8/**
9 * briven auth per-tenant email pipeline (BUILD_PLAN.md §8).
10 *
11 * Five customer-facing template renderers + their `send*` wrappers. The
12 * renderers are pure — no I/O, no zod, no postgres — so they unit-test
13 * cheaply. The wrappers resolve per-tenant branding via
14 * `getAuthConfig(projectId)`, render with the tenant's primary color +
15 * sender name, and dispatch via `sendTenantEmail` (lib/email.ts).
16 *
17 * Sender resolution (per BUILD_PLAN.md §8):
18 * - tenant has a `senderDomain` → `"${senderName}" <noreply@${senderDomain}>`
19 * - no custom domain → `briven auth <noreply@${BRIVEN_DOMAIN}>`
20 * - custom domain REJECTED at send time (provider hasn't verified it)
21 * → retry once from the fallback sender. A half-configured domain must
22 * never break a tenant's login flow (konnos magic-link 500, 2026-07-07).
23 *
24 * The fallback domain MUST be a sender verified with the email provider
25 * (mittera.eu / SMTP). It tracks `BRIVEN_DOMAIN` (briven.tech) — the SAME
26 * verified address the control-plane sender uses (lib/email.ts `fromAddress`)
27 * — NOT a bare `auth.` subdomain. The old `auth.briven.tech` fallback was
28 * never verified in mittera, so every tenant send on the fallback was
29 * rejected instantly with a 500 (broke Konnos magic-link, 2026-07-05).
30 *
31 * Templates are dark-themed by default to match the briven brand. Every
32 * template includes a "you didn't request this" disclaimer to soften the
33 * impact of a misdirected send.
34 */
35
36const FALLBACK_DOMAIN = env.BRIVEN_DOMAIN ?? 'briven.tech';
37
38// ─── pure HTML escape (no DOM, no library) ──────────────────────────────
39
40export function escapeHtml(s: string): string {
41 return s
42 .replace(/&/g, '&amp;')
43 .replace(/</g, '&lt;')
44 .replace(/>/g, '&gt;')
45 .replace(/"/g, '&quot;')
46 .replace(/'/g, '&#39;');
47}
48
49// ─── shell + cta with per-tenant primary color ──────────────────────────
50
51interface ShellArgs {
52 title: string;
53 body: string;
54 primaryColor: string;
55 senderName: string;
56}
57
58function shell({ title, body, primaryColor, senderName }: ShellArgs): string {
59 // Inline-styled for max email-client compatibility. Dark-themed defaults
60 // per BRAND.md §3; primary color is the only per-tenant variable.
61 const accent = primaryColor.toLowerCase();
62 return `<!doctype html><html><body style="margin:0;padding:0;background:#0a0b0d;color:#f5f7fa;font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif">
63 <div style="max-width:520px;margin:0 auto;padding:40px 24px">
64 <h1 style="font-size:20px;font-weight:600;margin:0 0 24px;color:#f5f7fa">${escapeHtml(title)}</h1>
65 <div style="font-size:15px;line-height:1.5;color:#d1d5db">${body}</div>
66 <hr style="border:none;border-top:1px solid #2a2e36;margin:32px 0">
67 <p style="font-size:12px;color:#6b7280;margin:0">${escapeHtml(senderName)} · powered by <span style="color:${accent}">briven auth</span></p>
68 </div>
69</body></html>`;
70}
71
72function cta(label: string, href: string, primaryColor: string): string {
73 const accent = primaryColor.toLowerCase();
74 // briven brand contrast: text on accent is always #0a0b0d (dark) regardless
75 // of which hex the customer picked. Their primary-color picker enforces
76 // WCAG-AA against #0a0b0d (BUILD_PLAN.md §6 Branding panel).
77 return `<p style="margin:32px 0"><a href="${escapeHtml(href)}" style="display:inline-block;background:${accent};color:#0a0b0d;padding:12px 24px;border-radius:10px;font-weight:500;text-decoration:none">${escapeHtml(label)}</a></p>`;
78}
79
80// ─── template renderers (pure; exported for tests) ──────────────────────
81
82export interface RenderContext {
83 primaryColor: string;
84 senderName: string;
85}
86
87export function renderMagicLink(
88 ctx: RenderContext,
89 args: { url: string; expiryMinutes: number },
90): { subject: string; html: string; text: string } {
91 return {
92 subject: `your sign-in link to ${ctx.senderName}`,
93 html: shell({
94 title: 'sign in',
95 body: `
96 <p>click below to sign in. this link expires in ${args.expiryMinutes} minutes.</p>
97 ${cta('sign in', args.url, ctx.primaryColor)}
98 <p style="color:#6b7280;font-size:13px">if you didn't request this, ignore the email — nothing happens.</p>
99 `,
100 primaryColor: ctx.primaryColor,
101 senderName: ctx.senderName,
102 }),
103 text: `sign in to ${ctx.senderName}\n\n${args.url}\n\nthis link expires in ${args.expiryMinutes} minutes. if you didn't request it, ignore this email.`,
104 };
105}
106
107export function renderOtpCode(
108 ctx: RenderContext,
109 args: { code: string; expiryMinutes: number },
110): { subject: string; html: string; text: string } {
111 const escapedCode = escapeHtml(args.code);
112 return {
113 subject: `your ${ctx.senderName} sign-in code: ${args.code}`,
114 html: shell({
115 title: 'one-time code',
116 body: `
117 <p>enter this code to finish signing in. it expires in ${args.expiryMinutes} minutes.</p>
118 <p style="font-family:ui-monospace,SFMono-Regular,monospace;font-size:32px;letter-spacing:8px;text-align:center;background:#1a1d24;border-radius:10px;padding:24px;border:1px solid #2a2e36;color:#f5f7fa">${escapedCode}</p>
119 <p style="color:#6b7280;font-size:13px">if you didn't request this, someone may have typed your email by mistake. you can ignore it safely.</p>
120 `,
121 primaryColor: ctx.primaryColor,
122 senderName: ctx.senderName,
123 }),
124 text: `sign in to ${ctx.senderName}\n\nyour code: ${args.code}\n\nthis code expires in ${args.expiryMinutes} minutes. if you didn't request it, ignore this email.`,
125 };
126}
127
128export function renderEmailVerify(
129 ctx: RenderContext,
130 args: { url: string },
131): { subject: string; html: string; text: string } {
132 return {
133 subject: `verify your email for ${ctx.senderName}`,
134 html: shell({
135 title: 'verify your email',
136 body: `
137 <p>click below to confirm this email address.</p>
138 ${cta('verify email', args.url, ctx.primaryColor)}
139 <p style="color:#6b7280;font-size:13px">if you didn't sign up, ignore the email.</p>
140 `,
141 primaryColor: ctx.primaryColor,
142 senderName: ctx.senderName,
143 }),
144 text: `verify your email for ${ctx.senderName}\n\n${args.url}\n\nif you didn't sign up, ignore this email.`,
145 };
146}
147
148export function renderPasswordReset(
149 ctx: RenderContext,
150 args: { url: string },
151): { subject: string; html: string; text: string } {
152 return {
153 subject: `reset your ${ctx.senderName} password`,
154 html: shell({
155 title: 'reset your password',
156 body: `
157 <p>click below to choose a new password. this link expires in 1 hour.</p>
158 ${cta('reset password', args.url, ctx.primaryColor)}
159 <p style="color:#6b7280;font-size:13px">if you didn't request this, secure your account: change your password and review active sessions.</p>
160 `,
161 primaryColor: ctx.primaryColor,
162 senderName: ctx.senderName,
163 }),
164 text: `reset your ${ctx.senderName} password\n\n${args.url}\n\nthis link expires in 1 hour. if you didn't request this, secure your account.`,
165 };
166}
167
168export function renderNewDeviceLogin(
169 ctx: RenderContext,
170 args: { deviceHint: string; whenIso: string; manageUrl: string },
171): { subject: string; html: string; text: string } {
172 // deviceHint format: "Firefox on macOS, Antwerp BE" — pre-redacted at
173 // the call site so this template doesn't see raw IPs (CLAUDE.md §5.1).
174 const escDevice = escapeHtml(args.deviceHint);
175 const escWhen = escapeHtml(args.whenIso);
176 return {
177 subject: `new sign-in to ${ctx.senderName}`,
178 html: shell({
179 title: 'new device signed in',
180 body: `
181 <p>a new device just signed in to your account.</p>
182 <p style="background:#1a1d24;border-radius:8px;padding:12px;font-family:ui-monospace,SFMono-Regular,monospace;font-size:13px;color:#9ba3af;border:1px solid #2a2e36">
183 ${escDevice}<br>
184 at ${escWhen}
185 </p>
186 ${cta('manage sessions', args.manageUrl, ctx.primaryColor)}
187 <p style="color:#6b7280;font-size:13px">if this was you, no action needed. if not, revoke the session immediately and change your password.</p>
188 `,
189 primaryColor: ctx.primaryColor,
190 senderName: ctx.senderName,
191 }),
192 text: `new sign-in to ${ctx.senderName}\n\n${args.deviceHint}\nat ${args.whenIso}\n\nmanage: ${args.manageUrl}\n\nif this wasn't you, revoke the session and change your password.`,
193 };
194}
195
196// ─── tenant-aware sender ────────────────────────────────────────────────
197
198/**
199 * Build the From: header for a tenant. Custom domain → tenant-branded
200 * sender. No custom domain → briven-fallback so first-day customers
201 * can still send while their DNS propagates.
202 */
203export function resolveFromAddress(config: AuthConfig): string {
204 return formatFrom(config.branding.senderName, config.branding.senderDomain ?? FALLBACK_DOMAIN);
205}
206
207/**
208 * The From: header a tenant send retries with when the custom
209 * `senderDomain` is rejected by the email provider (not yet verified
210 * there). Keeps the tenant's display name, swaps only the domain.
211 */
212export function resolveFallbackFromAddress(config: AuthConfig): string {
213 return formatFrom(config.branding.senderName, FALLBACK_DOMAIN);
214}
215
216function formatFrom(senderName: string, domain: string): string {
217 // Quote the display name when it contains characters that would
218 // otherwise break the RFC 5322 mailbox grammar (spaces, ".", etc).
219 const needsQuote = /[\s",;:<>@()\\[\]]/.test(senderName);
220 const display = needsQuote ? `"${senderName.replace(/"/g, '\\"')}"` : senderName;
221 return `${display} <noreply@${domain}>`;
222}
223
224interface TenantSendArgs {
225 projectId: string;
226 to: string;
227 subject: string;
228 html: string;
229 text: string;
230}
231
232async function sendForTenant(label: string, args: TenantSendArgs): Promise<void> {
233 const config = await getAuthConfig(args.projectId);
234 const from = resolveFromAddress(config);
235 const payload = {
236 projectId: args.projectId,
237 to: args.to,
238 subject: args.subject,
239 html: args.html,
240 text: args.text,
241 };
242 try {
243 await sendTenantEmail(label, { from, ...payload });
244 } catch (err) {
245 // A custom senderDomain that the email provider hasn't verified is
246 // rejected at send time. That must NEVER break the tenant's login
247 // flow (it 500'd konnos magic-link, 2026-07-07) — retry once from
248 // the always-verified briven fallback sender instead.
249 const fallbackFrom = resolveFallbackFromAddress(config);
250 if (from === fallbackFrom) {
251 // Already on the fallback — real outage. S6.3: surface for operators.
252 recordAuthMailerFailure(label);
253 throw err;
254 }
255 log.warn('tenant_sender_domain_rejected_falling_back', {
256 label,
257 projectId: args.projectId,
258 senderDomain: config.branding.senderDomain,
259 error: err instanceof Error ? err.message : String(err),
260 });
261 try {
262 await sendTenantEmail(label, { from: fallbackFrom, ...payload });
263 } catch (err2) {
264 recordAuthMailerFailure(`${label}_fallback`);
265 throw err2;
266 }
267 }
268}
269
270// ─── custom template helper ─────────────────────────────────────────────
271
272async function maybeUseCustomTemplate(
273 projectId: string,
274 name: EmailTemplateName,
275 vars: Record<string, string>,
276 fallback: () => { subject: string; html: string; text: string },
277): Promise<{ subject: string; html: string; text: string }> {
278 const custom = await getEmailTemplate(projectId, name);
279 if (custom) {
280 const rendered = renderTemplate(custom, vars);
281 return {
282 subject: rendered.subject,
283 html: rendered.html,
284 text: rendered.text ?? fallback().text,
285 };
286 }
287 return fallback();
288}
289
290// ─── Better Auth callback shape ─────────────────────────────────────────
291
292/**
293 * Send a magic-link email. Resolves brand + sender from the tenant's
294 * config. Used by Better Auth's `magicLink` plugin's `sendMagicLink`
295 * callback (wired in `auth-tenant-pool.ts` when the plugin is enabled).
296 */
297export async function sendBrivenAuthMagicLink(
298 projectId: string,
299 to: string,
300 url: string,
301): Promise<void> {
302 const config = await getAuthConfig(projectId);
303 const ctx: RenderContext = {
304 primaryColor: config.branding.primaryColor,
305 senderName: config.branding.senderName,
306 };
307 const tpl = await maybeUseCustomTemplate(
308 projectId,
309 'magic-link',
310 { url, expiryMinutes: String(config.providers.magicLink.expiryMinutes), appName: ctx.senderName },
311 () => renderMagicLink(ctx, { url, expiryMinutes: config.providers.magicLink.expiryMinutes }),
312 );
313 await sendForTenant('briven_auth_magic_link', { projectId, to, ...tpl });
314}
315
316export async function sendBrivenAuthOtp(
317 projectId: string,
318 to: string,
319 code: string,
320): Promise<void> {
321 const config = await getAuthConfig(projectId);
322 const ctx: RenderContext = {
323 primaryColor: config.branding.primaryColor,
324 senderName: config.branding.senderName,
325 };
326 const tpl = await maybeUseCustomTemplate(
327 projectId,
328 'otp',
329 { code, expiryMinutes: String(config.providers.emailOtp.expiryMinutes), appName: ctx.senderName },
330 () => renderOtpCode(ctx, { code, expiryMinutes: config.providers.emailOtp.expiryMinutes }),
331 );
332 await sendForTenant('briven_auth_otp', { projectId, to, ...tpl });
333}
334
335export async function sendBrivenAuthEmailVerification(
336 projectId: string,
337 to: string,
338 url: string,
339): Promise<void> {
340 const config = await getAuthConfig(projectId);
341 const ctx: RenderContext = {
342 primaryColor: config.branding.primaryColor,
343 senderName: config.branding.senderName,
344 };
345 const tpl = await maybeUseCustomTemplate(
346 projectId,
347 'verification',
348 { url, appName: ctx.senderName },
349 () => renderEmailVerify(ctx, { url }),
350 );
351 await sendForTenant('briven_auth_email_verify', { projectId, to, ...tpl });
352}
353
354export async function sendBrivenAuthPasswordReset(
355 projectId: string,
356 to: string,
357 url: string,
358): Promise<void> {
359 const config = await getAuthConfig(projectId);
360 const ctx: RenderContext = {
361 primaryColor: config.branding.primaryColor,
362 senderName: config.branding.senderName,
363 };
364 const tpl = await maybeUseCustomTemplate(
365 projectId,
366 'password-reset',
367 { url, appName: ctx.senderName },
368 () => renderPasswordReset(ctx, { url }),
369 );
370 await sendForTenant('briven_auth_password_reset', { projectId, to, ...tpl });
371}
372
373export async function sendBrivenAuthNewDeviceLogin(
374 projectId: string,
375 to: string,
376 args: { deviceHint: string; whenIso: string; manageUrl: string },
377): Promise<void> {
378 const config = await getAuthConfig(projectId);
379 const ctx: RenderContext = {
380 primaryColor: config.branding.primaryColor,
381 senderName: config.branding.senderName,
382 };
383 const tpl = renderNewDeviceLogin(ctx, args);
384 await sendForTenant('briven_auth_new_device', { projectId, to, ...tpl });
385}