email.ts1071 lines · main
| 1 | import { createHmac, timingSafeEqual } from 'node:crypto'; |
| 2 | |
| 3 | import nodemailer from 'nodemailer'; |
| 4 | import type { Transporter } from 'nodemailer'; |
| 5 | |
| 6 | import { env } from '../env.js'; |
| 7 | import { audit } from '../services/audit.js'; |
| 8 | import { isSuppressed } from '../services/suppressions.js'; |
| 9 | import { 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 | |
| 33 | const SEND_PATH = '/api/v1/emails'; |
| 34 | const SIGNATURE_PREFIX = 'v1='; |
| 35 | const DEFAULT_TOLERANCE_MS = 5 * 60 * 1000; |
| 36 | |
| 37 | interface 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 | |
| 56 | function 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 | |
| 63 | function 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 | */ |
| 75 | function 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 | */ |
| 87 | let smtpTransporter: Transporter | null = null; |
| 88 | |
| 89 | function 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 | */ |
| 116 | function 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 | */ |
| 128 | async 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 | */ |
| 182 | export 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 | |
| 193 | export 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 | |
| 209 | async 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 | */ |
| 323 | export 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 | */ |
| 334 | export 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 | */ |
| 361 | export 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 | |
| 374 | export 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 | |
| 383 | export 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 | |
| 392 | export 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 | |
| 401 | export 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 | |
| 414 | export 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 | */ |
| 430 | export 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 | |
| 439 | export 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 | */ |
| 456 | export 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 | |
| 467 | export 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 | */ |
| 484 | export 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 | |
| 495 | function 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 | */ |
| 518 | export 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 | */ |
| 540 | export 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 | |
| 577 | function 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 | |
| 588 | function 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 | |
| 592 | function 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 | |
| 603 | function invitationText(url: string): string { |
| 604 | return `you were invited to a briven project\n\n${url}\n\nexpires in 7 days.`; |
| 605 | } |
| 606 | |
| 607 | function 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 | |
| 617 | function verifyEmailText(url: string): string { |
| 618 | return `verify your briven email\n\n${url}\n`; |
| 619 | } |
| 620 | |
| 621 | function 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 | |
| 632 | function 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 | |
| 636 | function 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 | |
| 648 | function 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 | |
| 652 | function 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 | |
| 670 | function 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 | |
| 689 | function escapeHtml(s: string): string { |
| 690 | return s |
| 691 | .replace(/&/g, '&') |
| 692 | .replace(/</g, '<') |
| 693 | .replace(/>/g, '>') |
| 694 | .replace(/"/g, '"') |
| 695 | .replace(/'/g, '''); |
| 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. |
| 701 | const MIGRATIONS_CONTACT = 'migrations@flndrn.com'; |
| 702 | |
| 703 | function 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 | |
| 720 | function 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 | |
| 736 | function 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 | |
| 760 | function 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 | |
| 777 | function 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 | |
| 800 | function 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 | |
| 818 | function 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 | |
| 839 | function 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 | |
| 843 | function 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">♥</span> in Flanders by flndrn<br/> |
| 863 | 100% self-funded, sustainable & 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 | */ |
| 886 | export 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 | */ |
| 902 | export 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 | */ |
| 922 | export 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 | */ |
| 935 | export 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 | |
| 948 | const SUPPORT_CONTACT = 'support@flndrn.com'; |
| 949 | |
| 950 | function 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 | |
| 956 | function 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 | |
| 969 | function 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 | |
| 982 | function 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 | |
| 996 | function 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 | |
| 1011 | function 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 | |
| 1022 | function 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 | |
| 1032 | function 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 | |
| 1045 | function 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 | |
| 1058 | function 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 | } |