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