auth-mailer.ts385 lines · main
| 1 | import { env } from '../env.js'; |
| 2 | import { sendTenantEmail } from '../lib/email.js'; |
| 3 | import { log } from '../lib/logger.js'; |
| 4 | import { recordAuthMailerFailure } from './auth-reliability.js'; |
| 5 | import { getAuthConfig, type AuthConfig } from './tenant-config-store.js'; |
| 6 | import { 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 | |
| 36 | const FALLBACK_DOMAIN = env.BRIVEN_DOMAIN ?? 'briven.tech'; |
| 37 | |
| 38 | // ─── pure HTML escape (no DOM, no library) ────────────────────────────── |
| 39 | |
| 40 | export function escapeHtml(s: string): string { |
| 41 | return s |
| 42 | .replace(/&/g, '&') |
| 43 | .replace(/</g, '<') |
| 44 | .replace(/>/g, '>') |
| 45 | .replace(/"/g, '"') |
| 46 | .replace(/'/g, '''); |
| 47 | } |
| 48 | |
| 49 | // ─── shell + cta with per-tenant primary color ────────────────────────── |
| 50 | |
| 51 | interface ShellArgs { |
| 52 | title: string; |
| 53 | body: string; |
| 54 | primaryColor: string; |
| 55 | senderName: string; |
| 56 | } |
| 57 | |
| 58 | function 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 | |
| 72 | function 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 | |
| 82 | export interface RenderContext { |
| 83 | primaryColor: string; |
| 84 | senderName: string; |
| 85 | } |
| 86 | |
| 87 | export 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 | |
| 107 | export 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 | |
| 128 | export 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 | |
| 148 | export 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 | |
| 168 | export 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 | */ |
| 203 | export 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 | */ |
| 212 | export function resolveFallbackFromAddress(config: AuthConfig): string { |
| 213 | return formatFrom(config.branding.senderName, FALLBACK_DOMAIN); |
| 214 | } |
| 215 | |
| 216 | function 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 | |
| 224 | interface TenantSendArgs { |
| 225 | projectId: string; |
| 226 | to: string; |
| 227 | subject: string; |
| 228 | html: string; |
| 229 | text: string; |
| 230 | } |
| 231 | |
| 232 | async 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 | |
| 272 | async 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 | */ |
| 297 | export 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 | |
| 316 | export 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 | |
| 335 | export 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 | |
| 354 | export 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 | |
| 373 | export 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 | } |