delivery.ts461 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 DEFAULT_BRIVEN_ENGINE_BRANDING,
19 getBrivenEngineBranding,
20 getBrivenEngineSmsSecrets,
21 type BrivenEngineBranding,
22} from './project-config.js';
23
24export type EmailDeliveryInput = {
25 email: string;
26 subject?: string;
27 body?: string;
28 type?: string;
29 userContext?: Record<string, unknown>;
30 raw?: unknown;
31 /** When set, can look up per-project SMTP later */
32 projectId?: string;
33};
34
35export type SmsDeliveryInput = {
36 phoneNumber: string;
37 userInputCode?: string;
38 urlWithLinkCode?: string;
39 codeLifetime?: number;
40 type?: string;
41 userContext?: Record<string, unknown>;
42 raw?: unknown;
43 projectId?: string;
44 /** Full SMS body override (e.g. dashboard test message). */
45 bodyOverride?: string;
46};
47
48export type DeliveryResult = {
49 ok: boolean;
50 channel: 'email' | 'sms';
51 engine: 'briven-engine';
52 mode: 'log' | 'platform' | 'provider' | 'smtp' | 'mittera' | 'dev-stdout' | 'error';
53 message?: string;
54};
55
56/** Snapshot for /v1/auth-core/info — how Auth emails leave the platform. */
57export function getAuthEmailDeliveryStatus(): {
58 engine: 'briven-engine';
59 activeTransport: 'smtp' | 'mittera' | 'dev-stdout';
60 smtpConfigured: boolean;
61 mitteraConfigured: boolean;
62 fromAddress: string;
63 realEmailLikely: boolean;
64} {
65 const info = getEmailSenderInfo();
66 return {
67 engine: 'briven-engine',
68 activeTransport: info.activeTransport,
69 smtpConfigured: info.smtpFallbackConfigured,
70 mitteraConfigured: info.mitteraConfigured,
71 fromAddress: info.fromAddress,
72 // SMTP is real delivery; mittera may accept without deliver (platform note).
73 realEmailLikely: info.activeTransport === 'smtp',
74 };
75}
76
77function bodyFromSms(input: SmsDeliveryInput): string {
78 if (input.bodyOverride?.trim()) return input.bodyOverride.trim();
79 if (input.type === 'DASHBOARD_TEST') {
80 return 'Briven Auth test: SMS is working for this project. This is not a login code.';
81 }
82 const parts = [
83 input.userInputCode ? `Your Briven Auth code: ${input.userInputCode}` : null,
84 input.urlWithLinkCode ? `Sign in: ${input.urlWithLinkCode}` : null,
85 input.codeLifetime
86 ? `This code expires in ${Math.round(input.codeLifetime / 1000 / 60)} minutes.`
87 : null,
88 ].filter(Boolean);
89 return parts.join('\n') || 'Your Briven Auth message';
90}
91
92/**
93 * Branded HTML for auth emails (OTP / magic link).
94 */
95export function buildBrivenEngineAuthEmailHtml(input: {
96 body: string;
97 branding: BrivenEngineBranding;
98}): string {
99 const b = input.branding;
100 const color = b.primaryColor || DEFAULT_BRIVEN_ENGINE_BRANDING.primaryColor;
101 const name = escapeHtml(b.senderName || DEFAULT_BRIVEN_ENGINE_BRANDING.senderName);
102 const safeLogo = sanitizeLogoUrl(b.logoUrl);
103 const logo = safeLogo
104 ? `<img src="${escapeHtml(safeLogo)}" alt="${name}" width="120" style="display:block;max-width:120px;height:auto;margin:0 0 16px 0;border:0" />`
105 : '';
106 const lines = escapeHtml(input.body)
107 .split('\n')
108 .map((line) => (line ? line : '&nbsp;'))
109 .join('<br/>');
110 return `<!DOCTYPE html>
111<html><body style="margin:0;padding:0;background:#0a0a0a;font-family:ui-monospace,SFMono-Regular,Menlo,monospace">
112 <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#0a0a0a;padding:32px 16px">
113 <tr><td align="center">
114 <table role="presentation" width="100%" style="max-width:480px;background:#141414;border:1px solid #2a2a2a;border-radius:8px;padding:28px 24px">
115 <tr><td>
116 ${logo}
117 <p style="margin:0 0 8px 0;font-size:11px;letter-spacing:0.12em;text-transform:uppercase;color:#888">${name}</p>
118 <div style="height:3px;width:48px;background:${escapeHtml(color)};margin:0 0 20px 0;border-radius:2px"></div>
119 <div style="font-size:14px;line-height:1.55;color:#f2f2f2">${lines}</div>
120 <p style="margin:24px 0 0 0;font-size:11px;color:#666">If you did not request this, you can ignore this email.</p>
121 </td></tr>
122 </table>
123 </td></tr>
124 </table>
125</body></html>`;
126}
127
128/**
129 * Send auth email via briven-engine.
130 */
131export async function sendBrivenEngineEmail(
132 input: EmailDeliveryInput,
133): Promise<DeliveryResult> {
134 const branding = input.projectId
135 ? await getBrivenEngineBranding(input.projectId)
136 : { ...DEFAULT_BRIVEN_ENGINE_BRANDING };
137 const subject =
138 input.subject ?? `Your ${branding.senderName} sign-in`;
139 const text = input.body ?? `${branding.senderName} message`;
140 const html = buildBrivenEngineAuthEmailHtml({ body: text, branding });
141
142 log.info('briven_engine_email', {
143 engine: 'briven-engine',
144 to: maskEmail(input.email),
145 subject,
146 type: input.type,
147 hasBody: Boolean(input.body),
148 senderName: branding.senderName,
149 });
150
151 // Same chain as platform operator mail: SMTP → mittera → dev stdout.
152 try {
153 await sendTransactional('briven_engine_auth', {
154 to: input.email,
155 subject,
156 text,
157 html,
158 projectId: input.projectId ?? null,
159 });
160 const sender = getEmailSenderInfo();
161 return {
162 ok: true,
163 channel: 'email',
164 engine: 'briven-engine',
165 mode: sender.activeTransport,
166 message:
167 sender.activeTransport === 'smtp'
168 ? 'sent via platform SMTP'
169 : sender.activeTransport === 'mittera'
170 ? 'sent via mittera (set BRIVEN_SMTP_* for guaranteed inbox delivery)'
171 : 'logged to stdout (dev; set BRIVEN_SMTP_* for real email)',
172 };
173 } catch (err) {
174 log.warn('briven_engine_email_send_failed', {
175 message: err instanceof Error ? err.message : String(err),
176 });
177 if (env.BRIVEN_ENV !== 'production') {
178 log.debug('briven_engine_email_dev_body', {
179 email: input.email,
180 bodyPreview: text.slice(0, 200),
181 });
182 }
183 return {
184 ok: false,
185 channel: 'email',
186 engine: 'briven-engine',
187 mode: 'error',
188 message: err instanceof Error ? err.message : String(err),
189 };
190 }
191}
192
193/**
194 * Send auth SMS via briven-engine (Twilio-compatible HTTP when secrets present).
195 *
196 * Honest results:
197 * - provider → real Twilio send succeeded
198 * - log → no secrets / no project; body only logged (dev) — ok is false so UIs do not lie
199 * - error → secrets present but Twilio (or network) failed
200 */
201export async function sendBrivenEngineSms(
202 input: SmsDeliveryInput,
203): Promise<DeliveryResult> {
204 const body = bodyFromSms(input);
205
206 log.info('briven_engine_sms', {
207 engine: 'briven-engine',
208 phone: maskPhone(input.phoneNumber),
209 type: input.type,
210 hasCode: Boolean(input.userInputCode),
211 hasLink: Boolean(input.urlWithLinkCode),
212 });
213
214 const projectId =
215 input.projectId ??
216 (typeof input.userContext?.projectId === 'string'
217 ? input.userContext.projectId
218 : undefined);
219
220 if (!projectId) {
221 if (env.BRIVEN_ENV !== 'production') {
222 log.debug('briven_engine_sms_dev', {
223 phone: input.phoneNumber,
224 bodyPreview: body.slice(0, 160),
225 reason: 'no_project_id',
226 });
227 }
228 return {
229 ok: false,
230 channel: 'sms',
231 engine: 'briven-engine',
232 mode: 'log',
233 message:
234 'no project id for SMS — set x-briven-project-id (or project context) so Twilio secrets can load',
235 };
236 }
237
238 try {
239 const secrets = await getBrivenEngineSmsSecrets(projectId);
240 if (!secrets) {
241 if (env.BRIVEN_ENV !== 'production') {
242 log.debug('briven_engine_sms_dev', {
243 phone: input.phoneNumber,
244 bodyPreview: body.slice(0, 160),
245 reason: 'no_secrets',
246 });
247 }
248 return {
249 ok: false,
250 channel: 'sms',
251 engine: 'briven-engine',
252 mode: 'log',
253 message:
254 'SMS not set for this project — save Account SID, Auth token, and From number under Authentication → Providers → SMS',
255 };
256 }
257
258 const sent = await sendTwilioCompatibleSms({
259 accountSid: secrets.accountSid,
260 authToken: secrets.authToken,
261 from: secrets.fromNumber,
262 to: input.phoneNumber,
263 body,
264 });
265 if (sent.ok) {
266 return {
267 ok: true,
268 channel: 'sms',
269 engine: 'briven-engine',
270 mode: 'provider',
271 message: 'sent via project SMS provider (Twilio-compatible)',
272 };
273 }
274
275 log.warn('briven_engine_sms_provider_failed', { message: sent.message });
276 return {
277 ok: false,
278 channel: 'sms',
279 engine: 'briven-engine',
280 mode: 'error',
281 message:
282 sent.message ??
283 'SMS provider rejected the send — check Twilio SID, token, From number, and destination phone',
284 };
285 } catch (err) {
286 const message = err instanceof Error ? err.message : String(err);
287 log.warn('briven_engine_sms_secrets_error', { message });
288 return {
289 ok: false,
290 channel: 'sms',
291 engine: 'briven-engine',
292 mode: 'error',
293 message: `SMS send failed: ${message}`,
294 };
295 }
296}
297
298/**
299 * Dashboard “Send test SMS” — fixed body, no login code row.
300 */
301export async function sendBrivenEngineSmsTest(input: {
302 projectId: string;
303 phoneNumber: string;
304}): Promise<DeliveryResult> {
305 const phone = input.phoneNumber.trim();
306 if (!phone.startsWith('+') || phone.replace(/\D/g, '').length < 8) {
307 return {
308 ok: false,
309 channel: 'sms',
310 engine: 'briven-engine',
311 mode: 'error',
312 message:
313 'phone must be E.164 (start with + and country code), e.g. +15551234567',
314 };
315 }
316 return sendBrivenEngineSms({
317 phoneNumber: phone,
318 projectId: input.projectId,
319 type: 'DASHBOARD_TEST',
320 bodyOverride:
321 'Briven Auth test: SMS is working for this project. This is not a login code.',
322 });
323}
324
325async function sendTwilioCompatibleSms(opts: {
326 accountSid: string;
327 authToken: string;
328 from: string;
329 to: string;
330 body: string;
331}): Promise<{ ok: boolean; message?: string }> {
332 const url = `https://api.twilio.com/2010-04-01/Accounts/${encodeURIComponent(opts.accountSid)}/Messages.json`;
333 const auth = Buffer.from(`${opts.accountSid}:${opts.authToken}`).toString('base64');
334 const form = new URLSearchParams({
335 To: opts.to,
336 From: opts.from,
337 Body: opts.body,
338 });
339
340 try {
341 const res = await fetch(url, {
342 method: 'POST',
343 headers: {
344 Authorization: `Basic ${auth}`,
345 'Content-Type': 'application/x-www-form-urlencoded',
346 },
347 body: form.toString(),
348 signal: AbortSignal.timeout(10000),
349 });
350 if (!res.ok) {
351 const t = await res.text();
352 return { ok: false, message: `provider ${res.status}: ${t.slice(0, 120)}` };
353 }
354 return { ok: true };
355 } catch (err) {
356 return {
357 ok: false,
358 message: err instanceof Error ? err.message : String(err),
359 };
360 }
361}
362
363function maskPhone(phone: string): string {
364 const digits = phone.replace(/\D/g, '');
365 if (digits.length < 4) return '***';
366 return `***${digits.slice(-4)}`;
367}
368
369function maskEmail(email: string): string {
370 const [u, d] = email.split('@');
371 if (!d) return '***';
372 const user = u ?? '';
373 return `${user.slice(0, 1)}***@${d}`;
374}
375
376function escapeHtml(s: string): string {
377 return s
378 .replace(/&/g, '&amp;')
379 .replace(/</g, '&lt;')
380 .replace(/>/g, '&gt;')
381 .replace(/"/g, '&quot;');
382}
383
384/** Only plain https (or localhost) URLs — drop attribute-injection attempts. */
385function sanitizeLogoUrl(url: string | null | undefined): string | null {
386 if (!url) return null;
387 const t = url.trim();
388 if (t.length > 500) return null;
389 if (/[\s"'<>]/.test(t)) return null;
390 try {
391 const u = new URL(t);
392 if (u.protocol === 'https:') return u.toString();
393 if (u.protocol === 'http:' && u.hostname === 'localhost') return u.toString();
394 return null;
395 } catch {
396 return null;
397 }
398}
399
400export function passwordlessSmsDeliveryService() {
401 return {
402 service: {
403 sendSms: async (input: {
404 phoneNumber: string;
405 userInputCode?: string;
406 urlWithLinkCode?: string;
407 codeLifetime?: number;
408 type: string;
409 // eslint-disable-next-line @typescript-eslint/no-explicit-any
410 userContext?: any;
411 }) => {
412 await sendBrivenEngineSms({
413 phoneNumber: input.phoneNumber,
414 userInputCode: input.userInputCode,
415 urlWithLinkCode: input.urlWithLinkCode,
416 codeLifetime: input.codeLifetime,
417 type: input.type,
418 userContext: input.userContext,
419 projectId:
420 typeof input.userContext?.projectId === 'string'
421 ? input.userContext.projectId
422 : undefined,
423 raw: input,
424 });
425 },
426 },
427 };
428}
429
430export function passwordlessEmailDeliveryService() {
431 return {
432 service: {
433 sendEmail: async (input: {
434 email: string;
435 userInputCode?: string;
436 urlWithLinkCode?: string;
437 codeLifetime?: number;
438 type: string;
439 // eslint-disable-next-line @typescript-eslint/no-explicit-any
440 userContext?: any;
441 }) => {
442 const bodyParts = [
443 input.userInputCode ? `Your code: ${input.userInputCode}` : null,
444 input.urlWithLinkCode ? `Magic link: ${input.urlWithLinkCode}` : null,
445 ].filter(Boolean);
446 await sendBrivenEngineEmail({
447 email: input.email,
448 subject: 'Your Briven Auth sign-in',
449 body: bodyParts.join('\n') || 'Briven Auth message',
450 type: input.type,
451 userContext: input.userContext,
452 projectId:
453 typeof input.userContext?.projectId === 'string'
454 ? input.userContext.projectId
455 : undefined,
456 raw: input,
457 });
458 },
459 },
460 };
461}