email.ts1071 lines · main
1import { createHmac, timingSafeEqual } from 'node:crypto';
2
3import nodemailer from 'nodemailer';
4import type { Transporter } from 'nodemailer';
5
6import { env } from '../env.js';
7import { audit } from '../services/audit.js';
8import { isSuppressed } from '../services/suppressions.js';
9import { log } from './logger.js';
10
11/**
12 * Transactional email client — talks to mittera.eu's REST API at
13 * /api/v1/emails using Bearer-token auth. Inbound delivery / bounce /
14 * complaint events arrive at /mittera-webhook signed with mittera's
15 * X-mittera-Signature scheme; verifySignature below mirrors mittera's
16 * own SDK so the receiver stays in lock-step with the publisher.
17 *
18 * Outbound request shape:
19 * POST {BRIVEN_MITTERA_API_URL}/api/v1/emails
20 * Authorization: Bearer {BRIVEN_MITTERA_API_KEY}
21 * Content-Type: application/json
22 * { from, to, subject, html, text }
23 *
24 * Inbound webhook headers (verified by verifySignature, not produced
25 * by this module):
26 * X-mittera-Signature: v1=<hex_hmac_sha256("${ts_ms}.${rawBody}")>
27 * X-mittera-Timestamp: <unix_milliseconds>
28 *
29 * In dev (no API key configured) emails print to stdout so the
30 * first-user bootstrap flow still works on a fresh self-host.
31 */
32
33const SEND_PATH = '/api/v1/emails';
34const SIGNATURE_PREFIX = 'v1=';
35const DEFAULT_TOLERANCE_MS = 5 * 60 * 1000;
36
37interface SendArgs {
38 to: string;
39 subject: string;
40 html: string;
41 text: string;
42 /**
43 * Optional From: override. When set, replaces the global `fromAddress()`
44 * default — used by briven auth for per-tenant senders (mittera-verified
45 * customer domain) per BUILD_PLAN.md §8.
46 */
47 from?: string;
48 /**
49 * Optional tenant context. Threaded into the audit log row so admin
50 * email-events streams can scope by project. Default null preserves
51 * existing control-plane behavior.
52 */
53 projectId?: string | null;
54}
55
56function fromAddress(): string {
57 // Use the configured public domain so the From: matches the deployment.
58 // Falls back to a literal in dev so the env never has to be set locally.
59 const domain = env.BRIVEN_DOMAIN ?? 'briven.local';
60 return `briven <noreply@${domain}>`;
61}
62
63function isConfigured(): boolean {
64 return Boolean(env.BRIVEN_MITTERA_API_URL && env.BRIVEN_MITTERA_API_KEY);
65}
66
67/**
68 * SMTP is the real delivery path. It's "configured" only when HOST + USER
69 * + PASS are all non-empty (loadEnv treats empty strings as unset, so a
70 * blank var here reads as undefined). When configured, SMTP becomes the
71 * PRIMARY sender ahead of mittera — mittera accepts sends but never
72 * delivers, so a wired SMTP provider (Resend / Mailgun / Postmark / SES)
73 * takes precedence.
74 */
75function isSmtpConfigured(): boolean {
76 return Boolean(env.BRIVEN_SMTP_HOST && env.BRIVEN_SMTP_USER && env.BRIVEN_SMTP_PASS);
77}
78
79/**
80 * Lazily-built, module-scoped nodemailer transporter singleton. Built once
81 * on first send and reused for the pooled connection — rebuilding per send
82 * would defeat nodemailer's connection reuse. `secure` is true only on 465
83 * (implicit TLS); 587 uses STARTTLS which nodemailer negotiates itself. The
84 * ~10s greeting/socket timeouts mirror the mittera path's 10s AbortSignal
85 * so a hung SMTP host can't tie up the magic-link request.
86 */
87let smtpTransporter: Transporter | null = null;
88
89function getSmtpTransporter(): Transporter {
90 if (!smtpTransporter) {
91 const port = env.BRIVEN_SMTP_PORT;
92 smtpTransporter = nodemailer.createTransport({
93 host: env.BRIVEN_SMTP_HOST!,
94 port,
95 secure: port === 465,
96 auth: {
97 user: env.BRIVEN_SMTP_USER!,
98 pass: env.BRIVEN_SMTP_PASS!,
99 },
100 // Hard caps so a hung SMTP server doesn't tie up the request that
101 // triggered the send (mirrors the mittera fetch's 10s AbortSignal).
102 greetingTimeout: 10_000,
103 socketTimeout: 10_000,
104 connectionTimeout: 10_000,
105 });
106 }
107 return smtpTransporter;
108}
109
110/**
111 * The From: SMTP sends use. Prefers the explicit BRIVEN_SMTP_FROM (e.g.
112 * "Briven <noreply@briven.tech>") so the operator can align it with their
113 * provider's verified sender; falls back to a per-call override, then the
114 * global fromAddress().
115 */
116function smtpFrom(args: SendArgs): string {
117 return env.BRIVEN_SMTP_FROM ?? args.from ?? fromAddress();
118}
119
120/**
121 * Send via the real SMTP provider. On success logs `smtp_send_ok` and
122 * writes an audit row with action `smtp.<label>.sent` carrying the
123 * provider messageId — mirroring the mittera path's `mittera.<label>.sent`
124 * so the Email Admin cockpit's per-template stats pick it up automatically
125 * (SEND_ACTION_RE already matches `smtp\.(.+)\.sent`). On failure logs
126 * `smtp_send_failed` and throws so the caller sees the error.
127 */
128async function sendViaSmtp(label: string, args: SendArgs): Promise<void> {
129 let messageId: string | null = null;
130 try {
131 const info = await getSmtpTransporter().sendMail({
132 from: smtpFrom(args),
133 to: args.to,
134 subject: args.subject,
135 html: args.html,
136 text: args.text,
137 });
138 messageId = info.messageId ?? null;
139 } catch (err) {
140 log.error('smtp_send_failed', {
141 label,
142 error: err instanceof Error ? err.message : String(err),
143 });
144 throw err instanceof Error ? err : new Error(`smtp send failed: ${String(err)}`);
145 }
146
147 log.info('smtp_send_ok', { label, messageId });
148
149 // Audit-log the send so operators see it in the admin email-events
150 // stream, tagged with the template. Same audit() call shape as the
151 // mittera path so aggregateTemplateStats attributes it identically;
152 // recipient redacted per CLAUDE.md §5.1.
153 await audit({
154 actorId: null,
155 projectId: args.projectId ?? null,
156 action: `smtp.${label}.sent`,
157 ipHash: null,
158 userAgent: 'briven-api',
159 metadata: {
160 messageId,
161 recipientRedacted: redactEmail(args.to),
162 subject: args.subject,
163 },
164 });
165}
166
167/**
168 * What the admin cockpit can TRUTHFULLY report about the live sender,
169 * read straight off this module's own config + transport helpers so the
170 * dashboard never drifts from the real send path (Phase 8 §1).
171 *
172 * Note on provider: Briven talks only to mittera.eu, which abstracts the
173 * underlying provider (SES / Mailgun / Pando). That provider is NOT
174 * reported back to Briven on the send path or the webhook envelope, so we
175 * deliberately do not invent a provider field here — `activeTransport`
176 * reports the leg Briven itself drives (smtp vs mittera vs the dev stdout
177 * sink), which is the part Briven can actually observe. When a real SMTP
178 * provider is configured (HOST + USER + PASS) it is the PRIMARY sender —
179 * `smtpFallbackConfigured` is true and `activeTransport` is 'smtp';
180 * otherwise the send path falls back to mittera, then dev stdout.
181 */
182export interface EmailSenderInfo {
183 /** The default From: every control-plane send uses (per-tenant sends override it). */
184 fromAddress: string;
185 mitteraConfigured: boolean;
186 /** The exact mittera POST endpoint, or null when unconfigured. */
187 mitteraEndpoint: string | null;
188 smtpFallbackConfigured: boolean;
189 /** The transport that WOULD carry the next control-plane send given current config. */
190 activeTransport: 'mittera' | 'smtp' | 'dev-stdout';
191}
192
193export function getEmailSenderInfo(): EmailSenderInfo {
194 const smtp = isSmtpConfigured();
195 const mittera = isConfigured();
196 return {
197 fromAddress: fromAddress(),
198 mitteraConfigured: mittera,
199 mitteraEndpoint: env.BRIVEN_MITTERA_API_URL
200 ? `${env.BRIVEN_MITTERA_API_URL.replace(/\/$/, '')}${SEND_PATH}`
201 : null,
202 smtpFallbackConfigured: smtp,
203 // Same precedence as send(): SMTP is primary when configured, then
204 // mittera, then the dev stdout sink.
205 activeTransport: smtp ? 'smtp' : mittera ? 'mittera' : 'dev-stdout',
206 };
207}
208
209async function send(label: string, args: SendArgs): Promise<void> {
210 // Suppression guard — never POST to mittera for a recipient on the
211 // local suppression list (permanent bounce, complaint, mittera-side
212 // suppression). Cheaper than a 4xx + retry storm; protects sender
213 // reputation from re-sending to a known-bad address.
214 if (await isSuppressed(args.to)) {
215 log.warn(`${label}_recipient_suppressed`, {
216 // recipient logged ONLY at this stage — already on our suppression
217 // list, not new PII.
218 to: args.to,
219 });
220 return;
221 }
222
223 // Primary path: real SMTP provider. mittera accepts sends but never
224 // delivers, so once SMTP is configured it takes precedence over mittera.
225 if (isSmtpConfigured()) {
226 await sendViaSmtp(label, args);
227 return;
228 }
229
230 // Fallback: mittera's REST API (kept as the fallback, not deleted).
231 // Dev fallback: print so j can complete bootstrap without external email.
232 if (!isConfigured()) {
233 log.warn(`${label}_logged_only`);
234 process.stdout.write(`\n ${label} (dev only):\n to: ${args.to}\n subject: ${args.subject}\n\n`);
235 return;
236 }
237
238 const body = JSON.stringify({
239 from: args.from ?? fromAddress(),
240 to: args.to,
241 subject: args.subject,
242 html: args.html,
243 text: args.text,
244 });
245
246 const url = `${env.BRIVEN_MITTERA_API_URL!.replace(/\/$/, '')}${SEND_PATH}`;
247
248 const res = await fetch(url, {
249 method: 'POST',
250 headers: {
251 'content-type': 'application/json',
252 authorization: `Bearer ${env.BRIVEN_MITTERA_API_KEY!}`,
253 },
254 body,
255 // Hard cap so a hung mittera doesn't tie up the magic-link request.
256 signal: AbortSignal.timeout(10_000),
257 });
258
259 if (!res.ok) {
260 const text = await res.text().catch(() => '');
261 log.error('mittera_send_failed', {
262 status: res.status,
263 label,
264 // Truncate so a misconfigured server returning HTML doesn't bloat logs.
265 body: text.slice(0, 240),
266 });
267 throw new Error(`mittera send failed: ${res.status}`);
268 }
269
270 // Successful POST. mittera returns the email id under `emailId`
271 // (verified empirically; `id`/`messageId` are accepted as fallbacks
272 // in case the API shape evolves). The id is what shows up later in
273 // delivery / bounce webhook events under `messageId`, so capturing
274 // it here lets an operator correlate "did my magic link ship?" with
275 // "did mittera accept it / did the recipient bounce?" via grep.
276 const responseBody = await res.text().catch(() => '');
277 let messageId: string | null = null;
278 try {
279 const parsed = JSON.parse(responseBody) as {
280 emailId?: string;
281 id?: string;
282 messageId?: string;
283 };
284 messageId = parsed.emailId ?? parsed.id ?? parsed.messageId ?? null;
285 } catch {
286 // Non-JSON body — log raw so we can see what mittera actually returned.
287 }
288 log.info('mittera_send_ok', {
289 label,
290 status: res.status,
291 messageId,
292 bodyPreview: messageId ? undefined : responseBody.slice(0, 240),
293 });
294
295 // Audit-log the send so operators can see it in the admin email-events
296 // stream alongside inbound webhook events. Recipient is redacted per
297 // CLAUDE.md §5.1; the messageId is the authoritative correlation key
298 // (mittera echoes it back on delivery / bounce / complaint webhooks).
299 await audit({
300 actorId: null,
301 projectId: args.projectId ?? null,
302 action: `mittera.${label}.sent`,
303 ipHash: null,
304 userAgent: 'briven-api',
305 metadata: {
306 messageId,
307 recipientRedacted: redactEmail(args.to),
308 subject: args.subject,
309 },
310 });
311}
312
313/**
314 * Per-tenant send entry point — used by briven auth's customer-facing
315 * email flows (BUILD_PLAN.md §8). Same mittera POST + suppression +
316 * audit chain as the control-plane sends; only the From: address and
317 * audit projectId are tenant-scoped.
318 *
319 * Caller resolves the From: via `getAuthConfig(projectId).branding`:
320 * - verified `senderDomain` → `senderName <noreply@<senderDomain>>`
321 * - unset / unverified domain → fallback `briven auth <noreply@auth.briven.tech>`
322 */
323export async function sendTenantEmail(
324 label: string,
325 args: SendArgs & { from: string; projectId: string },
326): Promise<void> {
327 await send(label, args);
328}
329
330/**
331 * Generic transactional send for briven-engine Auth (OTP, magic link, etc.).
332 * Same chain as platform mail: SMTP primary → mittera → dev stdout.
333 */
334export async function sendTransactional(
335 label: string,
336 args: {
337 to: string;
338 subject: string;
339 html: string;
340 text: string;
341 projectId?: string | null;
342 from?: string;
343 },
344): Promise<void> {
345 await send(label, {
346 to: args.to,
347 subject: args.subject,
348 html: args.html,
349 text: args.text,
350 projectId: args.projectId ?? null,
351 from: args.from,
352 });
353}
354
355/**
356 * `flandriendev@hotmail.com` → `f•••v@h•••m`. Enough for an operator to
357 * disambiguate two recent sends in the admin stream without surfacing
358 * the full address. Same pattern documented in CLAUDE.md §5.1.
359 * Exported for tests.
360 */
361export function redactEmail(email: string): string {
362 const at = email.indexOf('@');
363 if (at < 0) return '•••';
364 const local = email.slice(0, at);
365 const domain = email.slice(at + 1);
366 const head = (s: string): string => {
367 if (s.length === 0) return '';
368 if (s.length === 1) return s;
369 return `${s[0]}•••${s[s.length - 1]}`;
370 };
371 return `${head(local)}@${head(domain)}`;
372}
373
374export async function sendMagicLink(to: string, url: string): Promise<void> {
375 await send('magic_link', {
376 to,
377 subject: 'your briven sign-in link',
378 html: magicLinkHtml(url),
379 text: magicLinkText(url),
380 });
381}
382
383export async function sendInvitation(to: string, url: string): Promise<void> {
384 await send('invitation', {
385 to,
386 subject: 'you were invited to a briven project',
387 html: invitationHtml(url),
388 text: invitationText(url),
389 });
390}
391
392export async function sendEmailVerification(to: string, url: string): Promise<void> {
393 await send('verify_email', {
394 to,
395 subject: 'verify your briven email',
396 html: verifyEmailHtml(url),
397 text: verifyEmailText(url),
398 });
399}
400
401export async function sendEmailChangeConfirmation(
402 to: string,
403 newEmail: string,
404 url: string,
405): Promise<void> {
406 await send('email_change_confirmation', {
407 to,
408 subject: 'confirm your new briven sign-in email',
409 html: emailChangeHtml(newEmail, url),
410 text: emailChangeText(newEmail, url),
411 });
412}
413
414export async function sendPasswordReset(to: string, url: string): Promise<void> {
415 await send('reset_password', {
416 to,
417 subject: 'reset your briven password',
418 html: resetPasswordHtml(url),
419 text: resetPasswordText(url),
420 });
421}
422
423/**
424 * Confirmation that an account-deletion request was received. Sent
425 * *before* the cascade runs so the user has a paper trail even if the
426 * mailbox attached to the account is the one they're closing. Includes
427 * the 30-day reversal window — operator support can revert within that
428 * window before the hard-delete cron runs.
429 */
430export async function sendAccountDeletionConfirmation(to: string): Promise<void> {
431 await send('account_deletion', {
432 to,
433 subject: 'your briven account is being deleted',
434 html: accountDeletionHtml(),
435 text: accountDeletionText(),
436 });
437}
438
439export interface MigrationRequestEmailInput {
440 requestId: string;
441 source: string;
442 contactEmail: string;
443 sourceUrl: string | null;
444 urgency: string;
445 estimatedTables: number | null;
446 estimatedRows: string | null;
447 estimatedFunctions: number | null;
448 sourceNotes: string;
449}
450
451/**
452 * Confirms to the customer that their migration intake was received,
453 * shows the briven request id (to quote if they email migrations@), and
454 * sets the expectation for next contact (one business day).
455 */
456export async function sendMigrationRequestCustomerConfirmation(
457 input: MigrationRequestEmailInput,
458): Promise<void> {
459 await send('migration_request_confirmation', {
460 to: input.contactEmail,
461 subject: `we got your migration request · ${input.requestId}`,
462 html: migrationCustomerHtml(input),
463 text: migrationCustomerText(input),
464 });
465}
466
467export interface MigrationStatusUpdateInput {
468 requestId: string;
469 source: string;
470 contactEmail: string;
471 oldStatus: string;
472 newStatus: string;
473 operatorMessage?: string;
474}
475
476/**
477 * Auto-fires whenever an operator flips a migration request's status.
478 * Skipped for the transition that happens at creation (already covered
479 * by the customer confirmation email). Skipped for the `new → contacted`
480 * transition because the operator is about to email manually anyway —
481 * a status-change email on top would arrive twice. Skipped for
482 * operator-notes-only edits.
483 */
484export async function sendMigrationStatusUpdate(
485 input: MigrationStatusUpdateInput,
486): Promise<void> {
487 await send('migration_status_update', {
488 to: input.contactEmail,
489 subject: `your migration · ${migrationStatusHeadline(input.newStatus, input.source)}`,
490 html: migrationStatusUpdateHtml(input),
491 text: migrationStatusUpdateText(input),
492 });
493}
494
495function migrationStatusHeadline(status: string, source: string): string {
496 switch (status) {
497 case 'scheduled':
498 return `${source} migration scheduled`;
499 case 'in_progress':
500 return `${source} migration in progress`;
501 case 'completed':
502 return `${source} migration completed`;
503 case 'cancelled':
504 return `${source} migration cancelled`;
505 case 'contacted':
506 return `we’ve reached out about your ${source} migration`;
507 default:
508 return `${source} migration updated`;
509 }
510}
511
512/**
513 * Notifies the operator inbox (default: migrations@<domain>) that a new
514 * intake landed. Includes everything the customer submitted so the
515 * operator can triage from the inbox without opening the dashboard for
516 * the first read.
517 */
518export async function sendMigrationRequestOperatorAlert(
519 input: MigrationRequestEmailInput,
520): Promise<void> {
521 const inbox = env.BRIVEN_MIGRATIONS_INBOX;
522 await send('migration_request_alert', {
523 to: inbox,
524 subject: `new migration request · ${input.source} · ${input.urgency.replace(/_/g, ' ')}`,
525 html: migrationOperatorHtml(input),
526 text: migrationOperatorText(input),
527 });
528}
529
530/**
531 * Verify a `X-mittera-Signature: v1=<hex>` header against the raw
532 * request body using the shared webhook secret. The timestamp lives
533 * in a separate `X-mittera-Timestamp` header (unix milliseconds) and
534 * is checked against `nowMs` (default `Date.now()`) with the configured
535 * `toleranceMs` (default ±5 min) to defeat replay.
536 *
537 * Mirrors `@mittera/sdk`'s Webhooks verifier exactly so a future swap
538 * to the SDK is one-line.
539 */
540export function verifySignature(args: {
541 secret: string;
542 signatureHeader: string | null;
543 timestampHeader: string | null;
544 body: string;
545 toleranceMs?: number;
546 nowMs?: number;
547}): boolean {
548 if (!args.signatureHeader || !args.timestampHeader) return false;
549 if (!args.signatureHeader.startsWith(SIGNATURE_PREFIX)) return false;
550
551 const ts = Number(args.timestampHeader);
552 if (!Number.isFinite(ts)) return false;
553
554 const tolerance = args.toleranceMs ?? DEFAULT_TOLERANCE_MS;
555 const now = args.nowMs ?? Date.now();
556 if (Math.abs(now - ts) > tolerance) return false;
557
558 const expected = `${SIGNATURE_PREFIX}${createHmac('sha256', args.secret)
559 .update(`${args.timestampHeader}.${args.body}`)
560 .digest('hex')}`;
561
562 if (expected.length !== args.signatureHeader.length) return false;
563 try {
564 return timingSafeEqual(
565 Buffer.from(expected, 'utf8'),
566 Buffer.from(args.signatureHeader, 'utf8'),
567 );
568 } catch {
569 return false;
570 }
571}
572
573/* -------------------------------------------------------------------------- */
574/* Email HTML — dark palette, single column, primary CTA in brand green. */
575/* -------------------------------------------------------------------------- */
576
577function magicLinkHtml(url: string): string {
578 return shell(
579 'sign in to briven',
580 `
581 <p>click the button below to sign in. this link expires in 10 minutes.</p>
582 ${cta('sign in', url)}
583 <p class="muted">if you didn't request this, you can ignore this email.</p>
584 `,
585 );
586}
587
588function magicLinkText(url: string): string {
589 return `sign in to briven\n\n${url}\n\nthis link expires in 10 minutes. if you didn't request it, ignore this email.`;
590}
591
592function invitationHtml(url: string): string {
593 return shell(
594 'you were invited to a briven project',
595 `
596 <p>accept the invitation to join the project on briven. the link expires in 7 days.</p>
597 ${cta('accept invitation', url)}
598 <p class="muted">if you weren't expecting this, ignore the email — nothing happens.</p>
599 `,
600 );
601}
602
603function invitationText(url: string): string {
604 return `you were invited to a briven project\n\n${url}\n\nexpires in 7 days.`;
605}
606
607function verifyEmailHtml(url: string): string {
608 return shell(
609 'verify your briven email',
610 `
611 <p>confirm this address so we can reach you about your briven account.</p>
612 ${cta('verify email', url)}
613 `,
614 );
615}
616
617function verifyEmailText(url: string): string {
618 return `verify your briven email\n\n${url}\n`;
619}
620
621function resetPasswordHtml(url: string): string {
622 return shell(
623 'reset your briven password',
624 `
625 <p>click below to set a new password. this link expires in 1 hour.</p>
626 ${cta('reset password', url)}
627 <p class="muted">if you didn't request a reset, you can ignore this email — your password stays unchanged.</p>
628 `,
629 );
630}
631
632function resetPasswordText(url: string): string {
633 return `reset your briven password\n\n${url}\n\nthis link expires in 1 hour. if you didn't request a reset, ignore this email.`;
634}
635
636function emailChangeHtml(newEmail: string, url: string): string {
637 return shell(
638 'confirm your new briven sign-in email',
639 `
640 <p>we received a request to change the sign-in email on your briven account to <strong>${escapeHtml(newEmail)}</strong>.</p>
641 <p>click the button below to confirm. this link expires in 1 hour.</p>
642 ${cta('confirm new email', url)}
643 <p class="muted">if you didn't request this, ignore this email — your current sign-in email stays unchanged. if you keep getting these, email support@flndrn.com.</p>
644 `,
645 );
646}
647
648function emailChangeText(newEmail: string, url: string): string {
649 return `confirm your new briven sign-in email\n\nwe received a request to change your sign-in email to ${newEmail}.\n\n${url}\n\nthis link expires in 1 hour. if you didn't request this, ignore this email.`;
650}
651
652function accountDeletionHtml(): string {
653 return shell(
654 'your briven account is being deleted',
655 `
656 <p>we received your account deletion request and started the process.</p>
657 <ul style="color:#9ba3af;font-size:15px;padding-left:18px">
658 <li>your personal data has been cleared from our control plane (legal name, address, vat id, display name, profile picture).</li>
659 <li>projects owned only by you have been soft-deleted and stop accepting traffic immediately.</li>
660 <li>team orgs where you're not the only owner stay live; you've been removed from membership.</li>
661 <li>api keys you owned are revoked.</li>
662 </ul>
663 <p>if you have a paid subscription, manage cancellation on polar via your billing portal — we don't auto-cancel.</p>
664 <p>you have <strong>30 days</strong> to change your mind: email support@flndrn.com from this address and we can revert. after that the soft-delete becomes a hard-delete and we can't get the data back.</p>
665 <p class="muted">if you did not request this, contact support@flndrn.com immediately.</p>
666 `,
667 );
668}
669
670function accountDeletionText(): string {
671 return [
672 'your briven account is being deleted',
673 '',
674 'we received your account deletion request and started the process.',
675 '',
676 '- personal data cleared from our control plane.',
677 '- projects owned only by you soft-deleted, traffic stopped.',
678 '- team orgs where you are not the sole owner stay live.',
679 '- api keys revoked.',
680 '',
681 'if you have a paid subscription, cancel via polar billing portal — we do not auto-cancel.',
682 '',
683 `you have 30 days to revert: email support@flndrn.com from this address. after that the delete is permanent.`,
684 '',
685 `if you did not request this, contact support@flndrn.com immediately.`,
686 ].join('\n');
687}
688
689function escapeHtml(s: string): string {
690 return s
691 .replace(/&/g, '&amp;')
692 .replace(/</g, '&lt;')
693 .replace(/>/g, '&gt;')
694 .replace(/"/g, '&quot;')
695 .replace(/'/g, '&#039;');
696}
697
698// Customer-visible contact addresses route to the parent flndrn Limited
699// inbox (admin.flndrn.com queue). Outbound product From: stays on
700// briven.tech for SPF/DKIM alignment — only inbound contact moves.
701const MIGRATIONS_CONTACT = 'migrations@flndrn.com';
702
703function migrationCustomerHtml(input: MigrationRequestEmailInput): string {
704 return shell(
705 `we got your migration request from ${escapeHtml(input.source)}`,
706 `
707 <p>thanks for asking us to help move your project to briven.</p>
708 <p>an operator will reach out from <code>${MIGRATIONS_CONTACT}</code> within one business day with the next steps — typically a short call to confirm scope, then the actual data + functions move while you keep running on your current platform.</p>
709 <p style="background:#1a1d24;border-radius:8px;padding:12px;font-family:ui-monospace,SFMono-Regular,monospace;font-size:13px;color:#9ba3af;border:1px solid #2a2e36">
710 request id: ${escapeHtml(input.requestId)}<br/>
711 source: ${escapeHtml(input.source)}<br/>
712 urgency: ${escapeHtml(input.urgency.replace(/_/g, ' '))}
713 </p>
714 <p class="muted">your ${escapeHtml(input.source)} stays untouched. we only read from it. nothing on your source is moved or modified until you press the cutover button — which we won't do until you say so.</p>
715 <p class="muted">questions or follow-ups: reply to this email, or write to ${MIGRATIONS_CONTACT} and quote the request id above.</p>
716 `,
717 );
718}
719
720function migrationCustomerText(input: MigrationRequestEmailInput): string {
721 return [
722 `we got your migration request from ${input.source}`,
723 '',
724 'thanks for asking us to help move your project to briven. an operator will reach out within one business day with the next steps.',
725 '',
726 `request id: ${input.requestId}`,
727 `source: ${input.source}`,
728 `urgency: ${input.urgency.replace(/_/g, ' ')}`,
729 '',
730 `your ${input.source} stays untouched. we only read from it. nothing on your source is moved or modified until you press the cutover button.`,
731 '',
732 `questions or follow-ups: reply to this email, or write to ${MIGRATIONS_CONTACT} and quote the request id.`,
733 ].join('\n');
734}
735
736function migrationOperatorHtml(input: MigrationRequestEmailInput): string {
737 const domain = env.BRIVEN_DOMAIN ?? 'briven.tech';
738 return shell(
739 `new migration request · ${escapeHtml(input.source)}`,
740 `
741 <p><strong>${escapeHtml(input.contactEmail)}</strong> requested a migration from <strong>${escapeHtml(input.source)}</strong>.</p>
742 <p style="background:#1a1d24;border-radius:8px;padding:12px;font-family:ui-monospace,SFMono-Regular,monospace;font-size:13px;color:#9ba3af;border:1px solid #2a2e36">
743 request id: ${escapeHtml(input.requestId)}<br/>
744 contact: ${escapeHtml(input.contactEmail)}<br/>
745 source: ${escapeHtml(input.source)}<br/>
746 urgency: ${escapeHtml(input.urgency.replace(/_/g, ' '))}<br/>
747 source URL: ${input.sourceUrl ? escapeHtml(input.sourceUrl) : '—'}<br/>
748 tables: ${input.estimatedTables ?? '—'} · rows: ${escapeHtml(input.estimatedRows ?? '—')} · functions: ${input.estimatedFunctions ?? '—'}
749 </p>
750 ${
751 input.sourceNotes
752 ? `<p style="font-family:ui-monospace,SFMono-Regular,monospace;font-size:13px;white-space:pre-wrap;background:#1a1d24;border-radius:8px;padding:12px;color:#d1d5db;border:1px solid #2a2e36">${escapeHtml(input.sourceNotes)}</p>`
753 : '<p class="muted">no extra notes from the customer.</p>'
754 }
755 ${cta('open in admin', `https://${domain}/dashboard/admin/migrations`)}
756 `,
757 );
758}
759
760function statusBlurb(status: string): string {
761 switch (status) {
762 case 'contacted':
763 return 'we’ve reached out — check your inbox for a reply with next steps.';
764 case 'scheduled':
765 return 'a migration window has been scheduled. you should have a calendar invite or proposed time from us.';
766 case 'in_progress':
767 return 'we’re moving your project right now. you’ll get another update when we’re done. your current platform stays untouched until the cutover step you control.';
768 case 'completed':
769 return 'your migration is complete on the briven side. open the dashboard to verify your data and run the cutover when you’re ready.';
770 case 'cancelled':
771 return 'we’ve cancelled this request. nothing changed on your current platform.';
772 default:
773 return 'status updated.';
774 }
775}
776
777function migrationStatusUpdateHtml(input: MigrationStatusUpdateInput): string {
778 const domain = env.BRIVEN_DOMAIN ?? 'briven.tech';
779 const dashboardHref = `https://${domain}/dashboard/migrations`;
780 return shell(
781 migrationStatusHeadline(input.newStatus, input.source),
782 `
783 <p>${statusBlurb(input.newStatus)}</p>
784 ${
785 input.operatorMessage
786 ? `<p style="background:#1a1d24;border-radius:8px;padding:12px;white-space:pre-wrap;color:#d1d5db;border:1px solid #2a2e36;font-size:14px">${escapeHtml(input.operatorMessage)}</p>`
787 : ''
788 }
789 <p style="background:#1a1d24;border-radius:8px;padding:12px;font-family:ui-monospace,SFMono-Regular,monospace;font-size:13px;color:#9ba3af;border:1px solid #2a2e36">
790 request id: ${escapeHtml(input.requestId)}<br/>
791 source: ${escapeHtml(input.source)}<br/>
792 status: ${escapeHtml(input.oldStatus.replace(/_/g, ' '))} → <strong style="color:#f5f7fa">${escapeHtml(input.newStatus.replace(/_/g, ' '))}</strong>
793 </p>
794 ${cta('open dashboard', dashboardHref)}
795 <p class="muted">need a human? reply to this email or write to ${MIGRATIONS_CONTACT} and quote the request id.</p>
796 `,
797 );
798}
799
800function migrationStatusUpdateText(input: MigrationStatusUpdateInput): string {
801 const domain = env.BRIVEN_DOMAIN ?? 'briven.tech';
802 return [
803 migrationStatusHeadline(input.newStatus, input.source),
804 '',
805 statusBlurb(input.newStatus),
806 '',
807 ...(input.operatorMessage ? [input.operatorMessage, ''] : []),
808 `request id: ${input.requestId}`,
809 `source: ${input.source}`,
810 `status: ${input.oldStatus.replace(/_/g, ' ')} → ${input.newStatus.replace(/_/g, ' ')}`,
811 '',
812 `open dashboard: https://${domain}/dashboard/migrations`,
813 '',
814 `need a human? reply to this email or write to ${MIGRATIONS_CONTACT}.`,
815 ].join('\n');
816}
817
818function migrationOperatorText(input: MigrationRequestEmailInput): string {
819 const domain = env.BRIVEN_DOMAIN ?? 'briven.tech';
820 return [
821 `new migration request · ${input.source}`,
822 '',
823 `${input.contactEmail} requested a migration from ${input.source}.`,
824 '',
825 `request id: ${input.requestId}`,
826 `contact: ${input.contactEmail}`,
827 `source: ${input.source}`,
828 `urgency: ${input.urgency.replace(/_/g, ' ')}`,
829 `source URL: ${input.sourceUrl ?? '—'}`,
830 `tables: ${input.estimatedTables ?? '—'} · rows: ${input.estimatedRows ?? '—'} · functions: ${input.estimatedFunctions ?? '—'}`,
831 '',
832 'customer notes:',
833 input.sourceNotes || '(none)',
834 '',
835 `open in admin: https://${domain}/dashboard/admin/migrations`,
836 ].join('\n');
837}
838
839function cta(label: string, href: string): string {
840 return `<p style="margin:32px 0"><a href="${href}" style="display:inline-block;background:#00e87a;color:#0a0b0d;padding:12px 24px;border-radius:10px;font-weight:500;font-family:system-ui,sans-serif;text-decoration:none">${label}</a></p>`;
841}
842
843function shell(title: string, body: string): string {
844 const domain = env.BRIVEN_DOMAIN ?? 'briven.tech';
845 return `<!doctype html>
846<html><head><meta charset="utf-8"><meta name="color-scheme" content="dark"><title>${title}</title></head>
847<body style="margin:0;background:#0a0b0d;color:#f5f7fa;font-family:system-ui,-apple-system,sans-serif;line-height:1.6">
848 <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#0a0b0d">
849 <tr><td align="center" style="padding:32px 16px">
850 <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">
851 <tr><td>
852 <table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 16px 0">
853 <tr>
854 <td style="padding-right:10px;vertical-align:middle"><img src="https://${domain}/icon-email.png" alt="" width="32" height="32" style="display:block;border:0;outline:none" /></td>
855 <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">briven</span></td>
856 </tr>
857 </table>
858 <h2 style="font-family:system-ui,sans-serif;font-size:18px;font-weight:500;margin:0 0 16px 0">${title}</h2>
859 <div style="color:#9ba3af;font-size:15px">${body}</div>
860 <p style="color:#6b7280;font-size:13px;margin-top:32px;border-top:1px solid #1e2128;padding-top:16px">
861 briven · <a style="color:#9ba3af" href="https://${domain}">${domain}</a><br/>
862 made with <span style="color:#e8344a">&#9829;</span> in Flanders by flndrn<br/>
863 100% self-funded, sustainable &amp; independent<br/>
864 flndrn Limited, Limassol, Cyprus
865 </p>
866 </td></tr>
867 </table>
868 </td></tr>
869 </table>
870 <style>.muted { color:#6b7280;font-size:13px }</style>
871</body></html>`;
872}
873
874/* ─── RESTORED AFTER MERGE LOSS (2026-07-02): support-ticket emails ── */
875// This block was dropped when lib/email.ts was rewritten during a branch
876// merge; routes/contact.ts and services/support-tickets.ts still import
877// these senders. Restored verbatim from d48bceb.
878
879/* ─── support tickets ────────────────────────────────────────────── */
880
881/**
882 * Confirms to the sender that their tagged /contact submission became a
883 * support ticket, and gives them the ticket number to quote. Sent on
884 * ticket creation. `ticketNumber` is the rendered, '#'-prefixed value.
885 */
886export async function sendTicketCreatedConfirmation(
887 to: string,
888 ticketNumber: string,
889): Promise<void> {
890 await send('ticket_created', {
891 to,
892 subject: `we got your support request · ${ticketNumber}`,
893 html: ticketCreatedHtml(ticketNumber),
894 text: ticketCreatedText(ticketNumber),
895 });
896}
897
898/**
899 * Emails the sender an operator's reply on their ticket. `body` is the
900 * operator's message text; `ticketNumber` is the rendered '#'-prefixed value.
901 */
902export async function sendTicketReply(
903 to: string,
904 ticketNumber: string,
905 body: string,
906): Promise<void> {
907 await send('ticket_reply', {
908 to,
909 subject: `re: your support request · ${ticketNumber}`,
910 html: ticketReplyHtml(ticketNumber, body),
911 text: ticketReplyText(ticketNumber, body),
912 });
913}
914
915/**
916 * Emails the sender an operator's reply to a PLAIN (untagged) contact
917 * message — one that never became a support ticket, so there's no ticket
918 * number to quote. Same transport chain as sendTicketReply; the copy just
919 * references "your message to briven" instead of a ticket. `body` is the
920 * operator's message text.
921 */
922export async function sendContactReply(to: string, body: string): Promise<void> {
923 await send('contact_reply', {
924 to,
925 subject: 're: your message to briven',
926 html: contactReplyHtml(body),
927 text: contactReplyText(body),
928 });
929}
930
931/**
932 * Notifies the sender that the status of their ticket changed (used for
933 * the 'replied'/'closed' transitions). `status` is the new status code.
934 */
935export async function sendTicketStatusUpdate(
936 to: string,
937 ticketNumber: string,
938 status: string,
939): Promise<void> {
940 await send('ticket_status_update', {
941 to,
942 subject: `update on your support request · ${ticketNumber}`,
943 html: ticketStatusHtml(ticketNumber, status),
944 text: ticketStatusText(ticketNumber, status),
945 });
946}
947
948const SUPPORT_CONTACT = 'support@flndrn.com';
949
950function ticketBlock(ticketNumber: string): string {
951 return `<p style="background:#1a1d24;border-radius:8px;padding:12px;font-family:ui-monospace,SFMono-Regular,monospace;font-size:13px;color:#9ba3af;border:1px solid #2a2e36">
952 ticket: <strong style="color:#f5f7fa">${escapeHtml(ticketNumber)}</strong>
953 </p>`;
954}
955
956function ticketCreatedHtml(ticketNumber: string): string {
957 const domain = env.BRIVEN_DOMAIN ?? 'briven.tech';
958 return shell(
959 'we got your support request',
960 `
961 <p>thanks for reaching out. your message is now a support ticket and we'll get back to you by email.</p>
962 ${ticketBlock(ticketNumber)}
963 ${cta('view your tickets', `https://${domain}/dashboard/support`)}
964 <p class="muted">quote your ticket number above in any follow-up, or write to ${SUPPORT_CONTACT}.</p>
965 `,
966 );
967}
968
969function ticketCreatedText(ticketNumber: string): string {
970 const domain = env.BRIVEN_DOMAIN ?? 'briven.tech';
971 return [
972 'we got your support request',
973 '',
974 `thanks for reaching out. your message is now a support ticket: ${ticketNumber}`,
975 '',
976 `view your tickets: https://${domain}/dashboard/support`,
977 '',
978 `quote your ticket number in any follow-up, or write to ${SUPPORT_CONTACT}.`,
979 ].join('\n');
980}
981
982function ticketReplyHtml(ticketNumber: string, body: string): string {
983 const domain = env.BRIVEN_DOMAIN ?? 'briven.tech';
984 return shell(
985 'a reply on your support request',
986 `
987 <p>we've replied to your ticket:</p>
988 <p style="background:#1a1d24;border-radius:8px;padding:12px;white-space:pre-wrap;color:#d1d5db;border:1px solid #2a2e36;font-size:14px">${escapeHtml(body)}</p>
989 ${ticketBlock(ticketNumber)}
990 ${cta('open the ticket', `https://${domain}/dashboard/support`)}
991 <p class="muted">reply to this email or write to ${SUPPORT_CONTACT} and quote the ticket number above.</p>
992 `,
993 );
994}
995
996function ticketReplyText(ticketNumber: string, body: string): string {
997 const domain = env.BRIVEN_DOMAIN ?? 'briven.tech';
998 return [
999 'a reply on your support request',
1000 '',
1001 body,
1002 '',
1003 `ticket: ${ticketNumber}`,
1004 '',
1005 `open the ticket: https://${domain}/dashboard/support`,
1006 '',
1007 `reply to this email or write to ${SUPPORT_CONTACT} and quote the ticket number.`,
1008 ].join('\n');
1009}
1010
1011function contactReplyHtml(body: string): string {
1012 return shell(
1013 'a reply to your message',
1014 `
1015 <p>we've replied to the message you sent us:</p>
1016 <p style="background:#1a1d24;border-radius:8px;padding:12px;white-space:pre-wrap;color:#d1d5db;border:1px solid #2a2e36;font-size:14px">${escapeHtml(body)}</p>
1017 <p class="muted">reply to this email or write to ${SUPPORT_CONTACT} and we'll pick it up from there.</p>
1018 `,
1019 );
1020}
1021
1022function contactReplyText(body: string): string {
1023 return [
1024 'a reply to your message',
1025 '',
1026 body,
1027 '',
1028 `reply to this email or write to ${SUPPORT_CONTACT} and we'll pick it up from there.`,
1029 ].join('\n');
1030}
1031
1032function ticketStatusBlurb(status: string): string {
1033 switch (status) {
1034 case 'replied':
1035 return 'we’ve replied to your ticket — check the thread for our latest message.';
1036 case 'closed':
1037 return 'we’ve marked your ticket as resolved. if you still need help, just reply and it’ll reopen.';
1038 case 'in_review':
1039 return 'your ticket is being looked at — we’ll follow up shortly.';
1040 default:
1041 return 'your ticket status was updated.';
1042 }
1043}
1044
1045function ticketStatusHtml(ticketNumber: string, status: string): string {
1046 const domain = env.BRIVEN_DOMAIN ?? 'briven.tech';
1047 return shell(
1048 'update on your support request',
1049 `
1050 <p>${ticketStatusBlurb(status)}</p>
1051 ${ticketBlock(ticketNumber)}
1052 ${cta('open the ticket', `https://${domain}/dashboard/support`)}
1053 <p class="muted">need a human? reply to this email or write to ${SUPPORT_CONTACT}.</p>
1054 `,
1055 );
1056}
1057
1058function ticketStatusText(ticketNumber: string, status: string): string {
1059 const domain = env.BRIVEN_DOMAIN ?? 'briven.tech';
1060 return [
1061 'update on your support request',
1062 '',
1063 ticketStatusBlurb(status),
1064 '',
1065 `ticket: ${ticketNumber}`,
1066 '',
1067 `open the ticket: https://${domain}/dashboard/support`,
1068 '',
1069 `need a human? reply to this email or write to ${SUPPORT_CONTACT}.`,
1070 ].join('\n');
1071}