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
11import { log } from '../../lib/logger.js';
12import { env } from '../../env.js';
13import {
14 getEmailSenderInfo,
15 sendTransactional,
16} from '../../lib/email.js';
17import {
18 type AuthEmailRequestMeta,
19 authEmailRequestMetaHtml,
20 authEmailRequestMetaText,
21 resolveAuthEmailRequestMeta,
22} from './auth-email-context.js';
23import {
24 DEFAULT_BRIVEN_ENGINE_BRANDING,
25 buildAuthEmailFooterLines,
26 buildAuthEmailFromHeader,
27 getBrivenEngineBranding,
28 getBrivenEngineSmsSecrets,
29 type BrivenEngineBranding,
30} from './project-config.js';
31
32export 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
60export 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
73export 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. */
82export 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
102function 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). */
126export 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 */
145export 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 : '&nbsp;'))
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">&#9829;</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 */
269export 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 */
467export 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 */
567export 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
591async 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
629function maskPhone(phone: string): string {
630 const digits = phone.replace(/\D/g, '');
631 if (digits.length < 4) return '***';
632 return `***${digits.slice(-4)}`;
633}
634
635function 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
642function escapeHtml(s: string): string {
643 return s
644 .replace(/&/g, '&amp;')
645 .replace(/</g, '&lt;')
646 .replace(/>/g, '&gt;')
647 .replace(/"/g, '&quot;');
648}
649
650/** Only plain https (or localhost) URLs — drop attribute-injection attempts. */
651function 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. */
667function 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
689export 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
719export 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}