delivery.ts752 lines · main
| 1 | /** |
| 2 | * briven-engine delivery — email + SMS for OTP / magic link / password reset. |
| 3 | * |
| 4 | * Send order: |
| 5 | * SMS → project Twilio-compatible secrets → else log |
| 6 | * Email → platform SMTP (BRIVEN_SMTP_*) → else log |
| 7 | * |
| 8 | * Product name is always briven-engine. |
| 9 | */ |
| 10 | |
| 11 | import { log } from '../../lib/logger.js'; |
| 12 | import { env } from '../../env.js'; |
| 13 | import { |
| 14 | getEmailSenderInfo, |
| 15 | sendTransactional, |
| 16 | } from '../../lib/email.js'; |
| 17 | import { |
| 18 | type AuthEmailRequestMeta, |
| 19 | authEmailRequestMetaHtml, |
| 20 | authEmailRequestMetaText, |
| 21 | resolveAuthEmailRequestMeta, |
| 22 | } from './auth-email-context.js'; |
| 23 | import { |
| 24 | DEFAULT_BRIVEN_ENGINE_BRANDING, |
| 25 | buildAuthEmailFooterLines, |
| 26 | buildAuthEmailFromHeader, |
| 27 | getBrivenEngineBranding, |
| 28 | getBrivenEngineSmsSecrets, |
| 29 | type BrivenEngineBranding, |
| 30 | } from './project-config.js'; |
| 31 | |
| 32 | export type EmailDeliveryInput = { |
| 33 | email: string; |
| 34 | subject?: string; |
| 35 | body?: string; |
| 36 | type?: string; |
| 37 | userContext?: Record<string, unknown>; |
| 38 | raw?: unknown; |
| 39 | /** When set, can look up per-project SMTP later */ |
| 40 | projectId?: string; |
| 41 | /** Structured magic-link / OTP fields for the Flanders shell. */ |
| 42 | url?: string | null; |
| 43 | code?: string | null; |
| 44 | expiryMinutes?: number; |
| 45 | title?: string; |
| 46 | ctaLabel?: string; |
| 47 | /** Browser user-agent of the person who triggered this email. */ |
| 48 | userAgent?: string | null; |
| 49 | /** |
| 50 | * Sec-CH-UA client hint — Brave often looks like Chrome in User-Agent alone. |
| 51 | * Pass through from the login request when available. |
| 52 | */ |
| 53 | clientHintsUa?: string | null; |
| 54 | /** Client IP of the person who triggered this email. */ |
| 55 | clientIp?: string | null; |
| 56 | /** Pre-built meta (tests / callers that already resolved geo). */ |
| 57 | requestMeta?: AuthEmailRequestMeta | null; |
| 58 | }; |
| 59 | |
| 60 | export type SmsDeliveryInput = { |
| 61 | phoneNumber: string; |
| 62 | userInputCode?: string; |
| 63 | urlWithLinkCode?: string; |
| 64 | codeLifetime?: number; |
| 65 | type?: string; |
| 66 | userContext?: Record<string, unknown>; |
| 67 | raw?: unknown; |
| 68 | projectId?: string; |
| 69 | /** Full SMS body override (e.g. dashboard test message). */ |
| 70 | bodyOverride?: string; |
| 71 | }; |
| 72 | |
| 73 | export type DeliveryResult = { |
| 74 | ok: boolean; |
| 75 | channel: 'email' | 'sms'; |
| 76 | engine: 'briven-engine'; |
| 77 | mode: 'log' | 'platform' | 'provider' | 'smtp' | 'mittera' | 'dev-stdout' | 'error'; |
| 78 | message?: string; |
| 79 | }; |
| 80 | |
| 81 | /** Snapshot for /v1/auth-core/info — how Auth emails leave the platform. */ |
| 82 | export function getAuthEmailDeliveryStatus(): { |
| 83 | engine: 'briven-engine'; |
| 84 | activeTransport: 'smtp' | 'mittera' | 'dev-stdout'; |
| 85 | smtpConfigured: boolean; |
| 86 | mitteraConfigured: boolean; |
| 87 | fromAddress: string; |
| 88 | realEmailLikely: boolean; |
| 89 | } { |
| 90 | const info = getEmailSenderInfo(); |
| 91 | return { |
| 92 | engine: 'briven-engine', |
| 93 | activeTransport: info.activeTransport, |
| 94 | smtpConfigured: info.smtpFallbackConfigured, |
| 95 | mitteraConfigured: info.mitteraConfigured, |
| 96 | fromAddress: info.fromAddress, |
| 97 | // SMTP is real delivery; mittera may accept without deliver (platform note). |
| 98 | realEmailLikely: info.activeTransport === 'smtp', |
| 99 | }; |
| 100 | } |
| 101 | |
| 102 | function bodyFromSms(input: SmsDeliveryInput): string { |
| 103 | if (input.bodyOverride?.trim()) return input.bodyOverride.trim(); |
| 104 | if (input.type === 'DASHBOARD_TEST') { |
| 105 | return 'Auth test: SMS is working for this project. This is not a login code.'; |
| 106 | } |
| 107 | // Prefer project brand when callers put it on userContext.appName. |
| 108 | const appName = |
| 109 | typeof input.userContext?.appName === 'string' && |
| 110 | input.userContext.appName.trim() |
| 111 | ? String(input.userContext.appName).trim() |
| 112 | : 'your app'; |
| 113 | const parts = [ |
| 114 | input.userInputCode |
| 115 | ? `Your ${appName} Auth code: ${input.userInputCode}` |
| 116 | : null, |
| 117 | input.urlWithLinkCode ? `Sign in: ${input.urlWithLinkCode}` : null, |
| 118 | input.codeLifetime |
| 119 | ? `This code expires in ${Math.round(input.codeLifetime / 1000 / 60)} minutes.` |
| 120 | : null, |
| 121 | ].filter(Boolean); |
| 122 | return parts.join('\n') || `Your ${appName} Auth message`; |
| 123 | } |
| 124 | |
| 125 | /** Subject lines for project Auth emails (uses dashboard branding name). */ |
| 126 | export function authEmailSubject( |
| 127 | appName: string, |
| 128 | kind: 'sign-in' | 'code', |
| 129 | code?: string | null, |
| 130 | ): string { |
| 131 | const name = appName.trim() || 'your app'; |
| 132 | if (kind === 'code') { |
| 133 | const c = code?.trim(); |
| 134 | return c |
| 135 | ? `Your ${name} Auth code: ${c}` |
| 136 | : `Your ${name} Auth code`; |
| 137 | } |
| 138 | return `Your ${name} Auth sign-in`; |
| 139 | } |
| 140 | |
| 141 | /** |
| 142 | * Shared Briven Auth email shell — matches control-plane mail (logo + brand, |
| 143 | * primary CTA / OTP, Flanders footer). Used for all project auth notifications. |
| 144 | */ |
| 145 | export function buildBrivenEngineAuthEmailHtml(input: { |
| 146 | /** Plain-text body fallback (escaped). Prefer url/code when set. */ |
| 147 | body?: string; |
| 148 | branding: BrivenEngineBranding; |
| 149 | /** Magic-link URL → renders the green sign-in button. */ |
| 150 | url?: string | null; |
| 151 | /** One-time code → large monospace block. */ |
| 152 | code?: string | null; |
| 153 | expiryMinutes?: number; |
| 154 | /** Override title; default "sign in to {brand}". */ |
| 155 | title?: string; |
| 156 | /** CTA label when url is set. */ |
| 157 | ctaLabel?: string; |
| 158 | /** Platform / device location / send time (Europe/Brussels). */ |
| 159 | requestMeta?: AuthEmailRequestMeta | null; |
| 160 | }): string { |
| 161 | const b = input.branding; |
| 162 | const color = (b.primaryColor || DEFAULT_BRIVEN_ENGINE_BRANDING.primaryColor).toLowerCase(); |
| 163 | const rawName = b.senderName || DEFAULT_BRIVEN_ENGINE_BRANDING.senderName; |
| 164 | const name = escapeHtml(rawName); |
| 165 | const title = escapeHtml( |
| 166 | input.title?.trim() || `sign in to ${rawName}`, |
| 167 | ); |
| 168 | const expiry = |
| 169 | typeof input.expiryMinutes === 'number' && input.expiryMinutes > 0 |
| 170 | ? input.expiryMinutes |
| 171 | : 10; |
| 172 | |
| 173 | const safeLogo = sanitizeLogoUrl(b.logoUrl); |
| 174 | const logoMark = safeLogo |
| 175 | ? `<img src="${escapeHtml(safeLogo)}" alt="" width="32" height="32" style="display:block;border:0;outline:none;border-radius:8px;object-fit:contain" />` |
| 176 | : `<span style="display:inline-block;width:28px;height:28px;border-radius:999px;background:${escapeHtml(color)};box-shadow:0 0 0 3px ${escapeHtml(color)}33"></span>`; |
| 177 | |
| 178 | const brandUrl = sanitizeBrandUrl(b.brandUrl); |
| 179 | const brandUrlHref = brandUrl |
| 180 | ? brandUrl.startsWith('http') |
| 181 | ? brandUrl |
| 182 | : `https://${brandUrl}` |
| 183 | : null; |
| 184 | const brandUrlLabel = brandUrl |
| 185 | ? brandUrl.replace(/^https?:\/\//i, '').replace(/\/$/, '') |
| 186 | : null; |
| 187 | |
| 188 | // Structured content only — never dump a raw magic-link URL as the main body. |
| 189 | // OTP-only → big code. Magic-link-only → button. Both → code then button. |
| 190 | // Plain `body` is a last-resort fallback (password reset, generic notices). |
| 191 | const chunks: string[] = []; |
| 192 | if (input.code && String(input.code).trim()) { |
| 193 | const code = escapeHtml(String(input.code).trim()); |
| 194 | chunks.push(` |
| 195 | <p style="margin:0 0 16px 0;color:#9ba3af;font-size:15px;line-height:1.6">enter this code to finish signing in. it expires in ${expiry} minutes.</p> |
| 196 | <p style="margin:0 0 24px 0;font-family:ui-monospace,SFMono-Regular,Menlo,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">${code}</p>`); |
| 197 | } |
| 198 | if (input.url && sanitizeLogoUrl(input.url) /* https/localhost only */) { |
| 199 | const href = sanitizeLogoUrl(input.url)!; |
| 200 | const label = escapeHtml(input.ctaLabel?.trim() || 'sign in'); |
| 201 | chunks.push(` |
| 202 | <p style="margin:0 0 24px 0;color:#9ba3af;font-size:15px;line-height:1.6">click the button below to sign in. this link expires in ${expiry} minutes.</p> |
| 203 | <p style="margin:0 0 24px 0"><a href="${escapeHtml(href)}" style="display:inline-block;background:${escapeHtml(color)};color:#0a0b0d;padding:12px 24px;border-radius:10px;font-weight:500;font-family:system-ui,sans-serif;text-decoration:none">${label}</a></p>`); |
| 204 | } |
| 205 | let main = chunks.join(''); |
| 206 | if (!main) { |
| 207 | const lines = escapeHtml(input.body ?? '') |
| 208 | .split('\n') |
| 209 | .map((line) => (line ? line : ' ')) |
| 210 | .join('<br/>'); |
| 211 | main = `<div style="margin:0 0 24px 0;color:#9ba3af;font-size:15px;line-height:1.6">${lines}</div>`; |
| 212 | } |
| 213 | |
| 214 | const footerNote = b.footerNote?.trim() |
| 215 | ? `<p style="margin:12px 0 0 0;font-size:12px;color:#6b7280">${escapeHtml(b.footerNote.trim())}</p>` |
| 216 | : ''; |
| 217 | |
| 218 | const brandLine = brandUrlHref |
| 219 | ? `${name} · <a style="color:#9ba3af" href="${escapeHtml(brandUrlHref)}">${escapeHtml(brandUrlLabel ?? brandUrlHref)}</a>` |
| 220 | : name; |
| 221 | |
| 222 | // Custom per-project footer (no hard-coded Flanders / flndrn). |
| 223 | const customLines = buildAuthEmailFooterLines(b); |
| 224 | const customFooterHtml = customLines |
| 225 | .map((line) => { |
| 226 | // Heart glyph for "made with ♥ …" |
| 227 | const htmlLine = escapeHtml(line).replace( |
| 228 | '♥', |
| 229 | '<span style="color:#e8344a">♥</span>', |
| 230 | ); |
| 231 | return htmlLine; |
| 232 | }) |
| 233 | .join('<br/>'); |
| 234 | const footerBlock = customFooterHtml |
| 235 | ? `${brandLine}<br/>${customFooterHtml}` |
| 236 | : brandLine; |
| 237 | |
| 238 | return `<!doctype html> |
| 239 | <html><head><meta charset="utf-8"><meta name="color-scheme" content="dark"><title>${title}</title></head> |
| 240 | <body style="margin:0;background:#0a0b0d;color:#f5f7fa;font-family:system-ui,-apple-system,sans-serif;line-height:1.6"> |
| 241 | <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#0a0b0d"> |
| 242 | <tr><td align="center" style="padding:32px 16px"> |
| 243 | <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"> |
| 244 | <tr><td> |
| 245 | <table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 20px 0"> |
| 246 | <tr> |
| 247 | <td style="padding-right:10px;vertical-align:middle">${logoMark}</td> |
| 248 | <td style="vertical-align:middle"><span style="font-family:system-ui,sans-serif;font-size:20px;font-weight:500;letter-spacing:-0.02em;color:#f5f7fa">${name}</span></td> |
| 249 | </tr> |
| 250 | </table> |
| 251 | <h2 style="font-family:system-ui,sans-serif;font-size:18px;font-weight:500;margin:0 0 12px 0;color:#f5f7fa">${title}</h2> |
| 252 | ${main} |
| 253 | <p style="margin:0;color:#6b7280;font-size:13px">if you didn't request this, you can ignore this email.</p> |
| 254 | ${input.requestMeta ? authEmailRequestMetaHtml(input.requestMeta) : ''} |
| 255 | ${footerNote} |
| 256 | <p style="color:#6b7280;font-size:12px;margin-top:32px;border-top:1px solid #1e2128;padding-top:16px"> |
| 257 | ${footerBlock} |
| 258 | </p> |
| 259 | </td></tr> |
| 260 | </table> |
| 261 | </td></tr> |
| 262 | </table> |
| 263 | </body></html>`; |
| 264 | } |
| 265 | |
| 266 | /** |
| 267 | * Send auth email via briven-engine. |
| 268 | */ |
| 269 | export async function sendBrivenEngineEmail( |
| 270 | input: EmailDeliveryInput, |
| 271 | ): Promise<DeliveryResult> { |
| 272 | const branding = input.projectId |
| 273 | ? await getBrivenEngineBranding(input.projectId) |
| 274 | : { ...DEFAULT_BRIVEN_ENGINE_BRANDING }; |
| 275 | const appName = branding.senderName || DEFAULT_BRIVEN_ENGINE_BRANDING.senderName; |
| 276 | const hasCode = Boolean(input.code && String(input.code).trim()); |
| 277 | const hasUrl = Boolean(input.url && String(input.url).trim()); |
| 278 | // Prefer structured fields: OTP-only → "Auth code"; magic link → "Auth sign-in". |
| 279 | const subject = |
| 280 | input.subject ?? |
| 281 | (hasCode && !hasUrl |
| 282 | ? authEmailSubject(appName, 'code', input.code) |
| 283 | : authEmailSubject(appName, 'sign-in')); |
| 284 | const expiry = input.expiryMinutes ?? 10; |
| 285 | const requestMeta = |
| 286 | input.requestMeta ?? |
| 287 | (input.userAgent || input.clientIp || input.clientHintsUa |
| 288 | ? await resolveAuthEmailRequestMeta({ |
| 289 | userAgent: input.userAgent, |
| 290 | clientHintsUa: input.clientHintsUa, |
| 291 | clientIp: input.clientIp, |
| 292 | }) |
| 293 | : null); |
| 294 | const defaultText = [ |
| 295 | hasCode ? `Your ${appName} Auth code: ${String(input.code).trim()}` : null, |
| 296 | hasUrl ? `Sign in to ${appName}: ${String(input.url).trim()}` : null, |
| 297 | `This expires in ${expiry} minutes. If you didn't request it, ignore this email.`, |
| 298 | requestMeta ? authEmailRequestMetaText(requestMeta) : null, |
| 299 | ] |
| 300 | .filter(Boolean) |
| 301 | .join('\n\n'); |
| 302 | const text = input.body |
| 303 | ? requestMeta |
| 304 | ? `${input.body}\n\n${authEmailRequestMetaText(requestMeta)}` |
| 305 | : input.body |
| 306 | : defaultText || `${appName} message`; |
| 307 | const html = buildBrivenEngineAuthEmailHtml({ |
| 308 | body: text, |
| 309 | branding, |
| 310 | // Never pass the other channel's field when callers omit it. |
| 311 | url: hasUrl ? input.url : null, |
| 312 | code: hasCode ? input.code : null, |
| 313 | expiryMinutes: input.expiryMinutes, |
| 314 | title: input.title ?? `sign in to ${appName}`, |
| 315 | ctaLabel: input.ctaLabel, |
| 316 | requestMeta, |
| 317 | }); |
| 318 | |
| 319 | log.info('briven_engine_email', { |
| 320 | engine: 'briven-engine', |
| 321 | to: maskEmail(input.email), |
| 322 | subject, |
| 323 | type: input.type, |
| 324 | hasBody: Boolean(input.body), |
| 325 | senderName: branding.senderName, |
| 326 | senderDomain: branding.senderDomain, |
| 327 | hasCustomFrom: Boolean( |
| 328 | branding.senderDomain || branding.senderEmail || branding.senderName, |
| 329 | ), |
| 330 | }); |
| 331 | |
| 332 | // SuperTokens-style From: display name from branding. |
| 333 | // Custom domains (noreply@pando.so) only work when platform SMTP is |
| 334 | // configured and that domain is authorized. Mittera only accepts |
| 335 | // verified domains (briven.tech) — using an unverified domain returns |
| 336 | // 400 and the user never gets OTP/magic-link mail. |
| 337 | const platformDomain = (env.BRIVEN_DOMAIN ?? 'briven.tech').replace(/^@/, ''); |
| 338 | const senderInfo = getEmailSenderInfo(); |
| 339 | const customDomainRequested = Boolean( |
| 340 | branding.senderDomain?.trim() || branding.senderEmail?.trim(), |
| 341 | ); |
| 342 | // Safe From for current transport: |
| 343 | // - SMTP primary → full custom From when set |
| 344 | // - mittera / dev → keep project display name, platform mailbox only |
| 345 | let fromHeader = buildAuthEmailFromHeader( |
| 346 | senderInfo.activeTransport === 'smtp' |
| 347 | ? branding |
| 348 | : { |
| 349 | senderName: branding.senderName, |
| 350 | senderDomain: null, |
| 351 | senderLocalPart: null, |
| 352 | senderEmail: null, |
| 353 | }, |
| 354 | platformDomain, |
| 355 | ); |
| 356 | if (customDomainRequested && senderInfo.activeTransport !== 'smtp') { |
| 357 | log.info('briven_engine_email_from_fallback', { |
| 358 | reason: 'custom_domain_requires_smtp', |
| 359 | senderDomain: branding.senderDomain, |
| 360 | using: fromHeader, |
| 361 | }); |
| 362 | } |
| 363 | |
| 364 | const payload = { |
| 365 | to: input.email, |
| 366 | subject, |
| 367 | text, |
| 368 | html, |
| 369 | projectId: input.projectId ?? null, |
| 370 | }; |
| 371 | |
| 372 | // Same chain as platform operator mail: SMTP → mittera → dev stdout. |
| 373 | try { |
| 374 | await sendTransactional('briven_engine_auth', { |
| 375 | ...payload, |
| 376 | from: fromHeader ?? undefined, |
| 377 | }); |
| 378 | return { |
| 379 | ok: true, |
| 380 | channel: 'email', |
| 381 | engine: 'briven-engine', |
| 382 | mode: senderInfo.activeTransport, |
| 383 | message: |
| 384 | senderInfo.activeTransport === 'smtp' |
| 385 | ? fromHeader |
| 386 | ? `sent via platform SMTP as ${fromHeader}` |
| 387 | : 'sent via platform SMTP' |
| 388 | : senderInfo.activeTransport === 'mittera' |
| 389 | ? fromHeader |
| 390 | ? `sent via mittera as ${fromHeader}` |
| 391 | : 'sent via mittera' |
| 392 | : 'logged to stdout (dev; set BRIVEN_SMTP_* for real email)', |
| 393 | }; |
| 394 | } catch (err) { |
| 395 | const msg = err instanceof Error ? err.message : String(err); |
| 396 | // Last-chance: provider rejected custom From → retry platform mailbox. |
| 397 | const domainRejected = |
| 398 | /domain/i.test(msg) && |
| 399 | (/from/i.test(msg) || /verified/i.test(msg) || /wrong/i.test(msg)); |
| 400 | if (domainRejected && fromHeader) { |
| 401 | const safeFrom = buildAuthEmailFromHeader( |
| 402 | { |
| 403 | senderName: branding.senderName, |
| 404 | senderDomain: null, |
| 405 | senderLocalPart: null, |
| 406 | senderEmail: null, |
| 407 | }, |
| 408 | platformDomain, |
| 409 | ); |
| 410 | try { |
| 411 | log.warn('briven_engine_email_retry_platform_from', { |
| 412 | message: msg, |
| 413 | safeFrom, |
| 414 | }); |
| 415 | await sendTransactional('briven_engine_auth', { |
| 416 | ...payload, |
| 417 | from: safeFrom ?? undefined, |
| 418 | }); |
| 419 | return { |
| 420 | ok: true, |
| 421 | channel: 'email', |
| 422 | engine: 'briven-engine', |
| 423 | mode: getEmailSenderInfo().activeTransport, |
| 424 | message: `sent after From fallback (${safeFrom ?? 'platform'})`, |
| 425 | }; |
| 426 | } catch (retryErr) { |
| 427 | log.warn('briven_engine_email_send_failed', { |
| 428 | message: |
| 429 | retryErr instanceof Error ? retryErr.message : String(retryErr), |
| 430 | afterRetry: true, |
| 431 | }); |
| 432 | return { |
| 433 | ok: false, |
| 434 | channel: 'email', |
| 435 | engine: 'briven-engine', |
| 436 | mode: 'error', |
| 437 | message: |
| 438 | retryErr instanceof Error ? retryErr.message : String(retryErr), |
| 439 | }; |
| 440 | } |
| 441 | } |
| 442 | log.warn('briven_engine_email_send_failed', { message: msg }); |
| 443 | if (env.BRIVEN_ENV !== 'production') { |
| 444 | log.debug('briven_engine_email_dev_body', { |
| 445 | email: input.email, |
| 446 | bodyPreview: text.slice(0, 200), |
| 447 | }); |
| 448 | } |
| 449 | return { |
| 450 | ok: false, |
| 451 | channel: 'email', |
| 452 | engine: 'briven-engine', |
| 453 | mode: 'error', |
| 454 | message: msg, |
| 455 | }; |
| 456 | } |
| 457 | } |
| 458 | |
| 459 | /** |
| 460 | * Send auth SMS via briven-engine (Twilio-compatible HTTP when secrets present). |
| 461 | * |
| 462 | * Honest results: |
| 463 | * - provider → real Twilio send succeeded |
| 464 | * - log → no secrets / no project; body only logged (dev) — ok is false so UIs do not lie |
| 465 | * - error → secrets present but Twilio (or network) failed |
| 466 | */ |
| 467 | export async function sendBrivenEngineSms( |
| 468 | input: SmsDeliveryInput, |
| 469 | ): Promise<DeliveryResult> { |
| 470 | const body = bodyFromSms(input); |
| 471 | |
| 472 | log.info('briven_engine_sms', { |
| 473 | engine: 'briven-engine', |
| 474 | phone: maskPhone(input.phoneNumber), |
| 475 | type: input.type, |
| 476 | hasCode: Boolean(input.userInputCode), |
| 477 | hasLink: Boolean(input.urlWithLinkCode), |
| 478 | }); |
| 479 | |
| 480 | const projectId = |
| 481 | input.projectId ?? |
| 482 | (typeof input.userContext?.projectId === 'string' |
| 483 | ? input.userContext.projectId |
| 484 | : undefined); |
| 485 | |
| 486 | if (!projectId) { |
| 487 | if (env.BRIVEN_ENV !== 'production') { |
| 488 | log.debug('briven_engine_sms_dev', { |
| 489 | phone: input.phoneNumber, |
| 490 | bodyPreview: body.slice(0, 160), |
| 491 | reason: 'no_project_id', |
| 492 | }); |
| 493 | } |
| 494 | return { |
| 495 | ok: false, |
| 496 | channel: 'sms', |
| 497 | engine: 'briven-engine', |
| 498 | mode: 'log', |
| 499 | message: |
| 500 | 'no project id for SMS — set x-briven-project-id (or project context) so Twilio secrets can load', |
| 501 | }; |
| 502 | } |
| 503 | |
| 504 | try { |
| 505 | const secrets = await getBrivenEngineSmsSecrets(projectId); |
| 506 | if (!secrets) { |
| 507 | if (env.BRIVEN_ENV !== 'production') { |
| 508 | log.debug('briven_engine_sms_dev', { |
| 509 | phone: input.phoneNumber, |
| 510 | bodyPreview: body.slice(0, 160), |
| 511 | reason: 'no_secrets', |
| 512 | }); |
| 513 | } |
| 514 | return { |
| 515 | ok: false, |
| 516 | channel: 'sms', |
| 517 | engine: 'briven-engine', |
| 518 | mode: 'log', |
| 519 | message: |
| 520 | 'SMS not set for this project — save Account SID, Auth token, and From number under Authentication → Providers → SMS', |
| 521 | }; |
| 522 | } |
| 523 | |
| 524 | const sent = await sendTwilioCompatibleSms({ |
| 525 | accountSid: secrets.accountSid, |
| 526 | authToken: secrets.authToken, |
| 527 | from: secrets.fromNumber, |
| 528 | to: input.phoneNumber, |
| 529 | body, |
| 530 | }); |
| 531 | if (sent.ok) { |
| 532 | return { |
| 533 | ok: true, |
| 534 | channel: 'sms', |
| 535 | engine: 'briven-engine', |
| 536 | mode: 'provider', |
| 537 | message: 'sent via project SMS provider (Twilio-compatible)', |
| 538 | }; |
| 539 | } |
| 540 | |
| 541 | log.warn('briven_engine_sms_provider_failed', { message: sent.message }); |
| 542 | return { |
| 543 | ok: false, |
| 544 | channel: 'sms', |
| 545 | engine: 'briven-engine', |
| 546 | mode: 'error', |
| 547 | message: |
| 548 | sent.message ?? |
| 549 | 'SMS provider rejected the send — check Twilio SID, token, From number, and destination phone', |
| 550 | }; |
| 551 | } catch (err) { |
| 552 | const message = err instanceof Error ? err.message : String(err); |
| 553 | log.warn('briven_engine_sms_secrets_error', { message }); |
| 554 | return { |
| 555 | ok: false, |
| 556 | channel: 'sms', |
| 557 | engine: 'briven-engine', |
| 558 | mode: 'error', |
| 559 | message: `SMS send failed: ${message}`, |
| 560 | }; |
| 561 | } |
| 562 | } |
| 563 | |
| 564 | /** |
| 565 | * Dashboard “Send test SMS” — fixed body, no login code row. |
| 566 | */ |
| 567 | export async function sendBrivenEngineSmsTest(input: { |
| 568 | projectId: string; |
| 569 | phoneNumber: string; |
| 570 | }): Promise<DeliveryResult> { |
| 571 | const phone = input.phoneNumber.trim(); |
| 572 | if (!phone.startsWith('+') || phone.replace(/\D/g, '').length < 8) { |
| 573 | return { |
| 574 | ok: false, |
| 575 | channel: 'sms', |
| 576 | engine: 'briven-engine', |
| 577 | mode: 'error', |
| 578 | message: |
| 579 | 'phone must be E.164 (start with + and country code), e.g. +15551234567', |
| 580 | }; |
| 581 | } |
| 582 | return sendBrivenEngineSms({ |
| 583 | phoneNumber: phone, |
| 584 | projectId: input.projectId, |
| 585 | type: 'DASHBOARD_TEST', |
| 586 | bodyOverride: |
| 587 | 'Briven Auth test: SMS is working for this project. This is not a login code.', |
| 588 | }); |
| 589 | } |
| 590 | |
| 591 | async function sendTwilioCompatibleSms(opts: { |
| 592 | accountSid: string; |
| 593 | authToken: string; |
| 594 | from: string; |
| 595 | to: string; |
| 596 | body: string; |
| 597 | }): Promise<{ ok: boolean; message?: string }> { |
| 598 | const url = `https://api.twilio.com/2010-04-01/Accounts/${encodeURIComponent(opts.accountSid)}/Messages.json`; |
| 599 | const auth = Buffer.from(`${opts.accountSid}:${opts.authToken}`).toString('base64'); |
| 600 | const form = new URLSearchParams({ |
| 601 | To: opts.to, |
| 602 | From: opts.from, |
| 603 | Body: opts.body, |
| 604 | }); |
| 605 | |
| 606 | try { |
| 607 | const res = await fetch(url, { |
| 608 | method: 'POST', |
| 609 | headers: { |
| 610 | Authorization: `Basic ${auth}`, |
| 611 | 'Content-Type': 'application/x-www-form-urlencoded', |
| 612 | }, |
| 613 | body: form.toString(), |
| 614 | signal: AbortSignal.timeout(10000), |
| 615 | }); |
| 616 | if (!res.ok) { |
| 617 | const t = await res.text(); |
| 618 | return { ok: false, message: `provider ${res.status}: ${t.slice(0, 120)}` }; |
| 619 | } |
| 620 | return { ok: true }; |
| 621 | } catch (err) { |
| 622 | return { |
| 623 | ok: false, |
| 624 | message: err instanceof Error ? err.message : String(err), |
| 625 | }; |
| 626 | } |
| 627 | } |
| 628 | |
| 629 | function maskPhone(phone: string): string { |
| 630 | const digits = phone.replace(/\D/g, ''); |
| 631 | if (digits.length < 4) return '***'; |
| 632 | return `***${digits.slice(-4)}`; |
| 633 | } |
| 634 | |
| 635 | function maskEmail(email: string): string { |
| 636 | const [u, d] = email.split('@'); |
| 637 | if (!d) return '***'; |
| 638 | const user = u ?? ''; |
| 639 | return `${user.slice(0, 1)}***@${d}`; |
| 640 | } |
| 641 | |
| 642 | function escapeHtml(s: string): string { |
| 643 | return s |
| 644 | .replace(/&/g, '&') |
| 645 | .replace(/</g, '<') |
| 646 | .replace(/>/g, '>') |
| 647 | .replace(/"/g, '"'); |
| 648 | } |
| 649 | |
| 650 | /** Only plain https (or localhost) URLs — drop attribute-injection attempts. */ |
| 651 | function sanitizeLogoUrl(url: string | null | undefined): string | null { |
| 652 | if (!url) return null; |
| 653 | const t = url.trim(); |
| 654 | if (t.length > 500) return null; |
| 655 | if (/[\s"'<>]/.test(t)) return null; |
| 656 | try { |
| 657 | const u = new URL(t); |
| 658 | if (u.protocol === 'https:') return u.toString(); |
| 659 | if (u.protocol === 'http:' && u.hostname === 'localhost') return u.toString(); |
| 660 | return null; |
| 661 | } catch { |
| 662 | return null; |
| 663 | } |
| 664 | } |
| 665 | |
| 666 | /** Brand site for footer — https URL or bare domain. */ |
| 667 | function sanitizeBrandUrl(url: string | null | undefined): string | null { |
| 668 | if (!url) return null; |
| 669 | const t = url.trim(); |
| 670 | if (t.length > 200 || /[\s"'<>]/.test(t)) return null; |
| 671 | if (/^https?:\/\//i.test(t)) { |
| 672 | try { |
| 673 | const u = new URL(t); |
| 674 | if (u.protocol === 'https:') return u.toString().replace(/\/$/, ''); |
| 675 | if (u.protocol === 'http:' && u.hostname === 'localhost') { |
| 676 | return u.toString().replace(/\/$/, ''); |
| 677 | } |
| 678 | return null; |
| 679 | } catch { |
| 680 | return null; |
| 681 | } |
| 682 | } |
| 683 | if (/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i.test(t)) { |
| 684 | return t.toLowerCase(); |
| 685 | } |
| 686 | return null; |
| 687 | } |
| 688 | |
| 689 | export function passwordlessSmsDeliveryService() { |
| 690 | return { |
| 691 | service: { |
| 692 | sendSms: async (input: { |
| 693 | phoneNumber: string; |
| 694 | userInputCode?: string; |
| 695 | urlWithLinkCode?: string; |
| 696 | codeLifetime?: number; |
| 697 | type: string; |
| 698 | // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 699 | userContext?: any; |
| 700 | }) => { |
| 701 | await sendBrivenEngineSms({ |
| 702 | phoneNumber: input.phoneNumber, |
| 703 | userInputCode: input.userInputCode, |
| 704 | urlWithLinkCode: input.urlWithLinkCode, |
| 705 | codeLifetime: input.codeLifetime, |
| 706 | type: input.type, |
| 707 | userContext: input.userContext, |
| 708 | projectId: |
| 709 | typeof input.userContext?.projectId === 'string' |
| 710 | ? input.userContext.projectId |
| 711 | : undefined, |
| 712 | raw: input, |
| 713 | }); |
| 714 | }, |
| 715 | }, |
| 716 | }; |
| 717 | } |
| 718 | |
| 719 | export function passwordlessEmailDeliveryService() { |
| 720 | return { |
| 721 | service: { |
| 722 | sendEmail: async (input: { |
| 723 | email: string; |
| 724 | userInputCode?: string; |
| 725 | urlWithLinkCode?: string; |
| 726 | codeLifetime?: number; |
| 727 | type: string; |
| 728 | // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 729 | userContext?: any; |
| 730 | }) => { |
| 731 | const expiryMinutes = input.codeLifetime |
| 732 | ? Math.max(1, Math.round(input.codeLifetime / 1000 / 60)) |
| 733 | : 10; |
| 734 | // Structured only: OTP emails get `code`, magic-link emails get `url`. |
| 735 | // Do not force both into plain body text (that produced ugly dual emails). |
| 736 | await sendBrivenEngineEmail({ |
| 737 | email: input.email, |
| 738 | type: input.type, |
| 739 | userContext: input.userContext, |
| 740 | projectId: |
| 741 | typeof input.userContext?.projectId === 'string' |
| 742 | ? input.userContext.projectId |
| 743 | : undefined, |
| 744 | raw: input, |
| 745 | url: input.urlWithLinkCode ?? null, |
| 746 | code: input.userInputCode ?? null, |
| 747 | expiryMinutes, |
| 748 | }); |
| 749 | }, |
| 750 | }, |
| 751 | }; |
| 752 | } |