auth-mailer.ts568 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 | import { |
| 8 | type AuthEmailRequestMeta, |
| 9 | authEmailRequestMetaHtml, |
| 10 | authEmailRequestMetaText, |
| 11 | resolveAuthEmailRequestMeta, |
| 12 | } from './auth-core/auth-email-context.js'; |
| 13 | import { |
| 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 | |
| 47 | const FALLBACK_DOMAIN = env.BRIVEN_DOMAIN ?? 'briven.tech'; |
| 48 | |
| 49 | // ─── pure HTML escape (no DOM, no library) ────────────────────────────── |
| 50 | |
| 51 | export function escapeHtml(s: string): string { |
| 52 | return s |
| 53 | .replace(/&/g, '&') |
| 54 | .replace(/</g, '<') |
| 55 | .replace(/>/g, '>') |
| 56 | .replace(/"/g, '"') |
| 57 | .replace(/'/g, '''); |
| 58 | } |
| 59 | |
| 60 | // ─── shell + cta (Flanders footer — same layout as control-plane mail) ── |
| 61 | |
| 62 | interface 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 | |
| 76 | function 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 | |
| 90 | function 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">♥</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 | |
| 168 | function 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 | |
| 178 | export 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 | |
| 190 | function 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 | |
| 204 | export 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 | |
| 228 | export 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 | |
| 253 | export 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 | |
| 277 | export 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 | |
| 301 | export 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 | */ |
| 340 | export 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 | */ |
| 349 | export function resolveFallbackFromAddress(config: AuthConfig): string { |
| 350 | return formatFrom(config.branding.senderName, FALLBACK_DOMAIN); |
| 351 | } |
| 352 | |
| 353 | function 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 | |
| 361 | interface TenantSendArgs { |
| 362 | projectId: string; |
| 363 | to: string; |
| 364 | subject: string; |
| 365 | html: string; |
| 366 | text: string; |
| 367 | } |
| 368 | |
| 369 | async 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 | |
| 409 | async 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 | */ |
| 434 | async 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 | |
| 476 | export type AuthMailRequestContext = { |
| 477 | userAgent?: string | null; |
| 478 | clientHintsUa?: string | null; |
| 479 | clientIp?: string | null; |
| 480 | }; |
| 481 | |
| 482 | export 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 | |
| 499 | export 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 | |
| 516 | export 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 | |
| 533 | export 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 | |
| 550 | export 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 | } |