auth-mailer.ts568 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';
7import {
8 type AuthEmailRequestMeta,
9 authEmailRequestMetaHtml,
10 authEmailRequestMetaText,
11 resolveAuthEmailRequestMeta,
12} from './auth-core/auth-email-context.js';
13import {
14 buildAuthEmailFooterLines,
15 getBrivenEngineBranding,
16 type BrivenEngineBranding,
17} from './auth-core/project-config.js';
18
19/**
20 * briven auth per-tenant email pipeline (BUILD_PLAN.md §8).
21 *
22 * Five customer-facing template renderers + their `send*` wrappers. The
23 * renderers are pure — no I/O, no zod, no postgres — so they unit-test
24 * cheaply. The wrappers resolve per-tenant branding via
25 * `getAuthConfig(projectId)`, render with the tenant's primary color +
26 * sender name, and dispatch via `sendTenantEmail` (lib/email.ts).
27 *
28 * Sender resolution (per BUILD_PLAN.md §8):
29 * - tenant has a `senderDomain` → `"${senderName}" <noreply@${senderDomain}>`
30 * - no custom domain → `briven auth <noreply@${BRIVEN_DOMAIN}>`
31 * - custom domain REJECTED at send time (provider hasn't verified it)
32 * → retry once from the fallback sender. A half-configured domain must
33 * never break a tenant's login flow (konnos magic-link 500, 2026-07-07).
34 *
35 * The fallback domain MUST be a sender verified with the email provider
36 * (mittera.eu / SMTP). It tracks `BRIVEN_DOMAIN` (briven.tech) — the SAME
37 * verified address the control-plane sender uses (lib/email.ts `fromAddress`)
38 * — NOT a bare `auth.` subdomain. The old `auth.briven.tech` fallback was
39 * never verified in mittera, so every tenant send on the fallback was
40 * rejected instantly with a 500 (broke Konnos magic-link, 2026-07-05).
41 *
42 * Templates are dark-themed by default to match the briven brand. Every
43 * template includes a "you didn't request this" disclaimer to soften the
44 * impact of a misdirected send.
45 */
46
47const FALLBACK_DOMAIN = env.BRIVEN_DOMAIN ?? 'briven.tech';
48
49// ─── pure HTML escape (no DOM, no library) ──────────────────────────────
50
51export function escapeHtml(s: string): string {
52 return s
53 .replace(/&/g, '&amp;')
54 .replace(/</g, '&lt;')
55 .replace(/>/g, '&gt;')
56 .replace(/"/g, '&quot;')
57 .replace(/'/g, '&#39;');
58}
59
60// ─── shell + cta (Flanders footer — same layout as control-plane mail) ──
61
62interface ShellArgs {
63 title: string;
64 body: string;
65 primaryColor: string;
66 senderName: string;
67 logoUrl?: string | null;
68 brandUrl?: string | null;
69 footerNote?: string | null;
70 /** Optional custom footer lines (from briven-engine branding). */
71 footerLines?: string[];
72 /** Platform / device location / send time. */
73 requestMeta?: AuthEmailRequestMeta | null;
74}
75
76function safeHttpUrl(url: string | null | undefined): string | null {
77 if (!url) return null;
78 const t = url.trim();
79 if (t.length > 500 || /[\s"'<>]/.test(t)) return null;
80 try {
81 const u = new URL(t);
82 if (u.protocol === 'https:') return u.toString();
83 if (u.protocol === 'http:' && u.hostname === 'localhost') return u.toString();
84 return null;
85 } catch {
86 return null;
87 }
88}
89
90function shell({
91 title,
92 body,
93 primaryColor,
94 senderName,
95 logoUrl,
96 brandUrl,
97 footerNote,
98 footerLines,
99 requestMeta,
100}: ShellArgs): string {
101 const accent = primaryColor.toLowerCase();
102 const name = escapeHtml(senderName);
103 const safeLogo = safeHttpUrl(logoUrl ?? null);
104 const logoMark = safeLogo
105 ? `<img src="${escapeHtml(safeLogo)}" alt="" width="32" height="32" style="display:block;border:0;outline:none;border-radius:8px;object-fit:contain" />`
106 : `<span style="display:inline-block;width:28px;height:28px;border-radius:999px;background:${escapeHtml(accent)};box-shadow:0 0 0 3px ${escapeHtml(accent)}33"></span>`;
107
108 let brandHref: string | null = null;
109 let brandLabel: string | null = null;
110 if (brandUrl?.trim()) {
111 const raw = brandUrl.trim();
112 if (/^https?:\/\//i.test(raw)) {
113 brandHref = safeHttpUrl(raw);
114 brandLabel = brandHref
115 ? brandHref.replace(/^https?:\/\//i, '').replace(/\/$/, '')
116 : null;
117 } else if (/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i.test(raw)) {
118 brandHref = `https://${raw}`;
119 brandLabel = raw;
120 }
121 }
122 const brandLine = brandHref
123 ? `${name} · <a style="color:#9ba3af" href="${escapeHtml(brandHref)}">${escapeHtml(brandLabel ?? brandHref)}</a>`
124 : name;
125 const note = footerNote?.trim()
126 ? `<p style="margin:12px 0 0 0;font-size:12px;color:#6b7280">${escapeHtml(footerNote.trim())}</p>`
127 : '';
128
129 const customHtml = (footerLines ?? [])
130 .map((line) =>
131 escapeHtml(line).replace(
132 '♥',
133 '<span style="color:#e8344a">&#9829;</span>',
134 ),
135 )
136 .join('<br/>');
137 const footerBlock = customHtml
138 ? `${brandLine}<br/>${customHtml}`
139 : brandLine;
140
141 return `<!doctype html>
142<html><head><meta charset="utf-8"><meta name="color-scheme" content="dark"><title>${escapeHtml(title)}</title></head>
143<body style="margin:0;background:#0a0b0d;color:#f5f7fa;font-family:system-ui,-apple-system,sans-serif;line-height:1.6">
144 <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#0a0b0d">
145 <tr><td align="center" style="padding:32px 16px">
146 <table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;width:100%;background:#13151a;border:1px solid #2a2e36;border-radius:14px;padding:32px">
147 <tr><td>
148 <table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 20px 0">
149 <tr>
150 <td style="padding-right:10px;vertical-align:middle">${logoMark}</td>
151 <td style="vertical-align:middle"><span style="font-size:20px;font-weight:500;letter-spacing:-0.02em;color:#f5f7fa">${name}</span></td>
152 </tr>
153 </table>
154 <h2 style="font-size:18px;font-weight:500;margin:0 0 12px 0;color:#f5f7fa">${escapeHtml(title)}</h2>
155 <div style="font-size:15px;line-height:1.6;color:#d1d5db">${body}</div>
156 ${requestMeta ? authEmailRequestMetaHtml(requestMeta) : ''}
157 ${note}
158 <p style="color:#6b7280;font-size:12px;margin-top:32px;border-top:1px solid #1e2128;padding-top:16px">
159 ${footerBlock}
160 </p>
161 </td></tr>
162 </table>
163 </td></tr>
164 </table>
165</body></html>`;
166}
167
168function cta(label: string, href: string, primaryColor: string): string {
169 const accent = primaryColor.toLowerCase();
170 // briven brand contrast: text on accent is always #0a0b0d (dark) regardless
171 // of which hex the customer picked. Their primary-color picker enforces
172 // WCAG-AA against #0a0b0d (BUILD_PLAN.md §6 Branding panel).
173 return `<p style="margin:0 0 24px 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>`;
174}
175
176// ─── template renderers (pure; exported for tests) ──────────────────────
177
178export interface RenderContext {
179 primaryColor: string;
180 senderName: string;
181 /** Public URL of the *uploaded* logo (never a free-form customer paste). */
182 logoUrl?: string | null;
183 /** Brand site for footer (`name · brandUrl`). */
184 brandUrl?: string | null;
185 footerNote?: string | null;
186 footerLines?: string[];
187 requestMeta?: AuthEmailRequestMeta | null;
188}
189
190function shellOpts(ctx: RenderContext, title: string, body: string): ShellArgs {
191 return {
192 title,
193 body,
194 primaryColor: ctx.primaryColor,
195 senderName: ctx.senderName,
196 logoUrl: ctx.logoUrl,
197 brandUrl: ctx.brandUrl,
198 footerNote: ctx.footerNote,
199 footerLines: ctx.footerLines,
200 requestMeta: ctx.requestMeta,
201 };
202}
203
204export function renderMagicLink(
205 ctx: RenderContext,
206 args: { url: string; expiryMinutes: number },
207): { subject: string; html: string; text: string } {
208 const metaText = ctx.requestMeta
209 ? `\n\n${authEmailRequestMetaText(ctx.requestMeta)}`
210 : '';
211 return {
212 subject: `Your ${ctx.senderName} Auth sign-in`,
213 html: shell(
214 shellOpts(
215 ctx,
216 `sign in to ${ctx.senderName}`,
217 `
218 <p style="margin:0 0 24px 0;color:#9ba3af;font-size:15px">click the button below to sign in. this link expires in ${args.expiryMinutes} minutes.</p>
219 ${cta('sign in', args.url, ctx.primaryColor)}
220 <p style="margin:0;color:#6b7280;font-size:13px">if you didn't request this, you can ignore this email.</p>
221 `,
222 ),
223 ),
224 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.${metaText}`,
225 };
226}
227
228export function renderOtpCode(
229 ctx: RenderContext,
230 args: { code: string; expiryMinutes: number },
231): { subject: string; html: string; text: string } {
232 const escapedCode = escapeHtml(args.code);
233 const metaText = ctx.requestMeta
234 ? `\n\n${authEmailRequestMetaText(ctx.requestMeta)}`
235 : '';
236 return {
237 subject: `Your ${ctx.senderName} Auth code: ${args.code}`,
238 html: shell(
239 shellOpts(
240 ctx,
241 `sign in to ${ctx.senderName}`,
242 `
243 <p style="margin:0 0 16px 0;color:#9ba3af;font-size:15px">enter this code to finish signing in. it expires in ${args.expiryMinutes} minutes.</p>
244 <p style="margin:0 0 24px 0;font-family:ui-monospace,SFMono-Regular,monospace;font-size:28px;letter-spacing:0.35em;text-align:center;background:#1a1d24;border-radius:10px;padding:20px 16px;border:1px solid #2a2e36;color:#f5f7fa">${escapedCode}</p>
245 <p style="margin:0;color:#6b7280;font-size:13px">if you didn't request this, you can ignore this email.</p>
246 `,
247 ),
248 ),
249 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.${metaText}`,
250 };
251}
252
253export function renderEmailVerify(
254 ctx: RenderContext,
255 args: { url: string },
256): { subject: string; html: string; text: string } {
257 const metaText = ctx.requestMeta
258 ? `\n\n${authEmailRequestMetaText(ctx.requestMeta)}`
259 : '';
260 return {
261 subject: `verify your email for ${ctx.senderName}`,
262 html: shell(
263 shellOpts(
264 ctx,
265 `verify your email for ${ctx.senderName}`,
266 `
267 <p style="margin:0 0 24px 0;color:#9ba3af;font-size:15px">click the button below to confirm this email address.</p>
268 ${cta('verify email', args.url, ctx.primaryColor)}
269 <p style="margin:0;color:#6b7280;font-size:13px">if you didn't request this, you can ignore this email.</p>
270 `,
271 ),
272 ),
273 text: `verify your email for ${ctx.senderName}\n\n${args.url}\n\nif you didn't sign up, ignore this email.${metaText}`,
274 };
275}
276
277export function renderPasswordReset(
278 ctx: RenderContext,
279 args: { url: string },
280): { subject: string; html: string; text: string } {
281 const metaText = ctx.requestMeta
282 ? `\n\n${authEmailRequestMetaText(ctx.requestMeta)}`
283 : '';
284 return {
285 subject: `reset your ${ctx.senderName} password`,
286 html: shell(
287 shellOpts(
288 ctx,
289 `reset your ${ctx.senderName} password`,
290 `
291 <p style="margin:0 0 24px 0;color:#9ba3af;font-size:15px">click the button below to choose a new password. this link expires in 1 hour.</p>
292 ${cta('reset password', args.url, ctx.primaryColor)}
293 <p style="margin:0;color:#6b7280;font-size:13px">if you didn't request this, you can ignore this email. if it wasn't you, secure your account.</p>
294 `,
295 ),
296 ),
297 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.${metaText}`,
298 };
299}
300
301export function renderNewDeviceLogin(
302 ctx: RenderContext,
303 args: { deviceHint: string; whenIso: string; manageUrl: string },
304): { subject: string; html: string; text: string } {
305 // deviceHint format: "Firefox on macOS, Antwerp BE" — pre-redacted at
306 // the call site so this template doesn't see raw IPs (CLAUDE.md §5.1).
307 const escDevice = escapeHtml(args.deviceHint);
308 const escWhen = escapeHtml(args.whenIso);
309 const metaText = ctx.requestMeta
310 ? `\n\n${authEmailRequestMetaText(ctx.requestMeta)}`
311 : '';
312 return {
313 subject: `new sign-in to ${ctx.senderName}`,
314 html: shell(
315 shellOpts(
316 ctx,
317 `new sign-in to ${ctx.senderName}`,
318 `
319 <p style="margin:0 0 16px 0;color:#9ba3af;font-size:15px">a new device just signed in to your account.</p>
320 <p style="margin:0 0 24px 0;background:#1a1d24;border-radius:8px;padding:12px;font-family:ui-monospace,SFMono-Regular,monospace;font-size:13px;color:#9ba3af;border:1px solid #2a2e36">
321 ${escDevice}<br>
322 at ${escWhen}
323 </p>
324 ${cta('manage sessions', args.manageUrl, ctx.primaryColor)}
325 <p style="margin:0;color:#6b7280;font-size:13px">if this was you, no action needed. if not, revoke the session and change your password.</p>
326 `,
327 ),
328 ),
329 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.${metaText}`,
330 };
331}
332
333// ─── tenant-aware sender ────────────────────────────────────────────────
334
335/**
336 * Build the From: header for a tenant. Custom domain → tenant-branded
337 * sender. No custom domain → briven-fallback so first-day customers
338 * can still send while their DNS propagates.
339 */
340export function resolveFromAddress(config: AuthConfig): string {
341 return formatFrom(config.branding.senderName, config.branding.senderDomain ?? FALLBACK_DOMAIN);
342}
343
344/**
345 * The From: header a tenant send retries with when the custom
346 * `senderDomain` is rejected by the email provider (not yet verified
347 * there). Keeps the tenant's display name, swaps only the domain.
348 */
349export function resolveFallbackFromAddress(config: AuthConfig): string {
350 return formatFrom(config.branding.senderName, FALLBACK_DOMAIN);
351}
352
353function formatFrom(senderName: string, domain: string): string {
354 // Quote the display name when it contains characters that would
355 // otherwise break the RFC 5322 mailbox grammar (spaces, ".", etc).
356 const needsQuote = /[\s",;:<>@()\\[\]]/.test(senderName);
357 const display = needsQuote ? `"${senderName.replace(/"/g, '\\"')}"` : senderName;
358 return `${display} <noreply@${domain}>`;
359}
360
361interface TenantSendArgs {
362 projectId: string;
363 to: string;
364 subject: string;
365 html: string;
366 text: string;
367}
368
369async function sendForTenant(label: string, args: TenantSendArgs): Promise<void> {
370 const config = await getAuthConfig(args.projectId);
371 const from = resolveFromAddress(config);
372 const payload = {
373 projectId: args.projectId,
374 to: args.to,
375 subject: args.subject,
376 html: args.html,
377 text: args.text,
378 };
379 try {
380 await sendTenantEmail(label, { from, ...payload });
381 } catch (err) {
382 // A custom senderDomain that the email provider hasn't verified is
383 // rejected at send time. That must NEVER break the tenant's login
384 // flow (it 500'd konnos magic-link, 2026-07-07) — retry once from
385 // the always-verified briven fallback sender instead.
386 const fallbackFrom = resolveFallbackFromAddress(config);
387 if (from === fallbackFrom) {
388 // Already on the fallback — real outage. S6.3: surface for operators.
389 recordAuthMailerFailure(label);
390 throw err;
391 }
392 log.warn('tenant_sender_domain_rejected_falling_back', {
393 label,
394 projectId: args.projectId,
395 senderDomain: config.branding.senderDomain,
396 error: err instanceof Error ? err.message : String(err),
397 });
398 try {
399 await sendTenantEmail(label, { from: fallbackFrom, ...payload });
400 } catch (err2) {
401 recordAuthMailerFailure(`${label}_fallback`);
402 throw err2;
403 }
404 }
405}
406
407// ─── custom template helper ─────────────────────────────────────────────
408
409async function maybeUseCustomTemplate(
410 projectId: string,
411 name: EmailTemplateName,
412 vars: Record<string, string>,
413 fallback: () => { subject: string; html: string; text: string },
414): Promise<{ subject: string; html: string; text: string }> {
415 const custom = await getEmailTemplate(projectId, name);
416 if (custom) {
417 const rendered = renderTemplate(custom, vars);
418 return {
419 subject: rendered.subject,
420 html: rendered.html,
421 text: rendered.text ?? fallback().text,
422 };
423 }
424 return fallback();
425}
426
427// ─── Better Auth callback shape ─────────────────────────────────────────
428
429/**
430 * Send a magic-link email. Resolves brand + sender from the tenant's
431 * config. Used by Better Auth's `magicLink` plugin's `sendMagicLink`
432 * callback (wired in `auth-tenant-pool.ts` when the plugin is enabled).
433 */
434async function renderCtxForProject(
435 projectId: string,
436 config: AuthConfig,
437 request?: {
438 userAgent?: string | null;
439 clientHintsUa?: string | null;
440 clientIp?: string | null;
441 },
442): Promise<RenderContext> {
443 // Prefer briven-engine branding (dashboard Auth → branding) for logo + footer.
444 let engine: BrivenEngineBranding | null = null;
445 try {
446 engine = await getBrivenEngineBranding(projectId);
447 } catch {
448 engine = null;
449 }
450 const primaryColor =
451 engine?.primaryColor ?? config.branding.primaryColor;
452 const senderName = engine?.senderName ?? config.branding.senderName;
453 const logoUrl = engine?.logoUrl ?? config.branding.logoUrl;
454 const brandUrl = engine?.brandUrl ?? null;
455 const footerNote = engine?.footerNote ?? null;
456 const footerLines = engine ? buildAuthEmailFooterLines(engine) : [];
457 const requestMeta =
458 request?.userAgent || request?.clientIp || request?.clientHintsUa
459 ? await resolveAuthEmailRequestMeta({
460 userAgent: request.userAgent,
461 clientHintsUa: request.clientHintsUa,
462 clientIp: request.clientIp,
463 })
464 : null;
465 return {
466 primaryColor,
467 senderName,
468 logoUrl,
469 brandUrl,
470 footerNote,
471 footerLines,
472 requestMeta,
473 };
474}
475
476export type AuthMailRequestContext = {
477 userAgent?: string | null;
478 clientHintsUa?: string | null;
479 clientIp?: string | null;
480};
481
482export async function sendBrivenAuthMagicLink(
483 projectId: string,
484 to: string,
485 url: string,
486 request?: AuthMailRequestContext,
487): Promise<void> {
488 const config = await getAuthConfig(projectId);
489 const ctx = await renderCtxForProject(projectId, config, request);
490 const tpl = await maybeUseCustomTemplate(
491 projectId,
492 'magic-link',
493 { url, expiryMinutes: String(config.providers.magicLink.expiryMinutes), appName: ctx.senderName },
494 () => renderMagicLink(ctx, { url, expiryMinutes: config.providers.magicLink.expiryMinutes }),
495 );
496 await sendForTenant('briven_auth_magic_link', { projectId, to, ...tpl });
497}
498
499export async function sendBrivenAuthOtp(
500 projectId: string,
501 to: string,
502 code: string,
503 request?: AuthMailRequestContext,
504): Promise<void> {
505 const config = await getAuthConfig(projectId);
506 const ctx = await renderCtxForProject(projectId, config, request);
507 const tpl = await maybeUseCustomTemplate(
508 projectId,
509 'otp',
510 { code, expiryMinutes: String(config.providers.emailOtp.expiryMinutes), appName: ctx.senderName },
511 () => renderOtpCode(ctx, { code, expiryMinutes: config.providers.emailOtp.expiryMinutes }),
512 );
513 await sendForTenant('briven_auth_otp', { projectId, to, ...tpl });
514}
515
516export async function sendBrivenAuthEmailVerification(
517 projectId: string,
518 to: string,
519 url: string,
520 request?: AuthMailRequestContext,
521): Promise<void> {
522 const config = await getAuthConfig(projectId);
523 const ctx = await renderCtxForProject(projectId, config, request);
524 const tpl = await maybeUseCustomTemplate(
525 projectId,
526 'verification',
527 { url, appName: ctx.senderName },
528 () => renderEmailVerify(ctx, { url }),
529 );
530 await sendForTenant('briven_auth_email_verify', { projectId, to, ...tpl });
531}
532
533export async function sendBrivenAuthPasswordReset(
534 projectId: string,
535 to: string,
536 url: string,
537 request?: AuthMailRequestContext,
538): Promise<void> {
539 const config = await getAuthConfig(projectId);
540 const ctx = await renderCtxForProject(projectId, config, request);
541 const tpl = await maybeUseCustomTemplate(
542 projectId,
543 'password-reset',
544 { url, appName: ctx.senderName },
545 () => renderPasswordReset(ctx, { url }),
546 );
547 await sendForTenant('briven_auth_password_reset', { projectId, to, ...tpl });
548}
549
550export async function sendBrivenAuthNewDeviceLogin(
551 projectId: string,
552 to: string,
553 args: {
554 deviceHint: string;
555 whenIso: string;
556 manageUrl: string;
557 userAgent?: string | null;
558 clientIp?: string | null;
559 },
560): Promise<void> {
561 const config = await getAuthConfig(projectId);
562 const ctx = await renderCtxForProject(projectId, config, {
563 userAgent: args.userAgent,
564 clientIp: args.clientIp,
565 });
566 const tpl = renderNewDeviceLogin(ctx, args);
567 await sendForTenant('briven_auth_new_device', { projectId, to, ...tpl });
568}