import { createHmac, randomUUID } from "node:crypto"; /** * Framework-agnostic Novu client. Used both by the NestJS API (via NovuService) * and by the standalone BullMQ worker (which has no NestJS DI), so it reads * configuration straight from process.env and has zero NestJS dependencies. * * All lifecycle e-mails are triggered through Novu (https://api.bildirim.semih.ai, * Tailscale-only) and delivered via Postal. See postal/NOVU-INTEGRATION.md. */ /** Recipient passed as Novu's `to`. subscriberId must be stable (we use user.id). */ export interface NovuRecipient { subscriberId: string; email: string; /** First name for greeting (`Merhaba {firstName}`). */ firstName?: string; } export type NovuPayload = Record; const NOVU_API_URL = (process.env.NOVU_API_URL || "https://api.bildirim.semih.ai").replace( /\/+$/, "", ); const APP_PUBLIC_URL = (process.env.APP_PUBLIC_URL || "https://sase.tr").replace(/\/+$/, ""); const TRIGGER_TIMEOUT_MS = 10_000; /** * Mailbox we expose as the List-Unsubscribe mailto: target. Receives any * "please unsubscribe me" replies — Postal has a route on `unsubscribe@sase.tr` * (audit §9.1) forwarding to destek's SnappyMail so the team sees them. */ const UNSUBSCRIBE_EMAIL = process.env.UNSUBSCRIBE_EMAIL || "unsubscribe@sase.tr"; /** * HTTPS one-click endpoint base. Defaults to `/api/email/unsubscribe` * which is where UnsubscribeController lives. Empty string disables the HTTPS * variant (mailto-only header), which is what we want until UNSUBSCRIBE_SECRET * is configured. */ const UNSUBSCRIBE_URL_BASE = process.env.UNSUBSCRIBE_URL_BASE || `${APP_PUBLIC_URL}/api/email/unsubscribe`; /** * HMAC secret for stateless unsubscribe tokens. Must be set in prod for the * HTTPS variant to mint valid tokens — when unset, we ship the mailto: header * only (still RFC-2369-compliant, satisfies Yahoo, partial credit on Gmail). */ const UNSUBSCRIBE_SECRET = process.env.UNSUBSCRIBE_SECRET || ""; /** * Auth + payment flows where we MUST NOT advertise an unsubscribe link — * privacy-proxy bots sometimes pre-fetch List-Unsubscribe URLs and we don't * want token consumption for the verify/reset case, and we don't want to * suppress receipt/dunning mail at all. */ const NO_UNSUBSCRIBE_WORKFLOWS = new Set([ "email-verification", "password-reset", "payment-success", "payment-failed", ]); /** * Signed HTTPS unsubscribe link for a (workflow, user) pair — the same URL the * List-Unsubscribe header carries. GET renders a confirmation page, POST is * the RFC 8058 one-click. Null for transactional flows or when the secret is * unset (dev), so callers can skip the payload/header entirely. */ export function buildUnsubscribeUrl(workflow: string, subscriberId: string): string | null { if (NO_UNSUBSCRIBE_WORKFLOWS.has(workflow)) return null; if (!UNSUBSCRIBE_URL_BASE || !UNSUBSCRIBE_SECRET) return null; const token = createHmac("sha256", UNSUBSCRIBE_SECRET) .update(`${subscriberId}|${workflow}`) .digest("hex"); const q = new URLSearchParams({ u: subscriberId, w: workflow, t: token }); return `${UNSUBSCRIBE_URL_BASE}?${q.toString()}`; } function buildUnsubscribeHeaders(workflow: string, subscriberId: string): Record { if (NO_UNSUBSCRIBE_WORKFLOWS.has(workflow)) return {}; const targets: string[] = []; const httpsUrl = buildUnsubscribeUrl(workflow, subscriberId); if (httpsUrl) targets.push(`<${httpsUrl}>`); targets.push( ``, ); const headers: Record = { "List-Unsubscribe": targets.join(", ") }; // RFC 8058 one-click — only assert when an HTTPS endpoint is wired; Gmail // will probe the HTTPS target with POST when this header is present. if (httpsUrl) { headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"; } return headers; } /** Build an absolute URL on the public marketing site (e.g. webUrl("/dashboard")). */ export function webUrl(path: string): string { if (/^https?:\/\//i.test(path)) return path; return `${APP_PUBLIC_URL}${path.startsWith("/") ? "" : "/"}${path}`; } /** Turkish currency formatting from an integer kuruş amount (e.g. 29900 → "₺299,00"). */ export function formatTryAmount(kurus: number): string { return new Intl.NumberFormat("tr-TR", { style: "currency", currency: "TRY" }).format(kurus / 100); } /** Turkish short date (e.g. "27.05.2026"). */ export function formatTrDate(date: Date): string { return new Intl.DateTimeFormat("tr-TR", { day: "2-digit", month: "2-digit", year: "numeric", }).format(date); } /** * Click-tracking lifetime. 30d is longer than any realistic "I'll get back * to this welcome mail" window (template CTAs point at /dashboard, useful * for ~weeks) and short enough that a leaked signed URL ages out before it * becomes a replay nuisance. mailAudit.md §9.4 #18. */ const TRACK_URL_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000; /** * Wrap a CTA target in a signed track.sase.tr click link for open/click tracking. * HMAC = hex(HMAC_SHA256(MAILTRACK_SECRET, `${mid}|${target}|${exp}`)). Returns * the raw target unchanged when no secret is configured. * * `exp` is a unix-ms timestamp; the worker rejects clicks after that point * with HTTP 410 even if the HMAC matches. Enough that a leaked link can't be * replayed indefinitely. * * 🔒 NEVER use this for auth links (email verification / password reset) — the * tracking redirect can consume the one-time token. Pass those URLs directly. */ export function buildTrackedUrl(campaign: string, recipient: string, target: string): string { const secret = process.env.MAILTRACK_SECRET; if (!secret) return target; const mid = randomUUID(); const exp = String(Date.now() + TRACK_URL_EXPIRY_MS); const sig = createHmac("sha256", secret).update(`${mid}|${target}|${exp}`).digest("hex"); const q = new URLSearchParams({ m: mid, c: campaign, r: recipient, u: target, e: exp, s: sig }); return `https://track.sase.tr/c?${q.toString()}`; } /** * Open-pixel URL for embedding in templates as ``. The pixel * itself is a 1x1 transparent GIF; the side-effect is a row in the mailtrack * D1 events table. Unsigned — knowing the recipient's address is the only * "secret" and that's already on the message. * * Returns `null` when MAILTRACK_SECRET is unset so the caller can skip the * payload entirely rather than shipping a pixel pointing nowhere (cleaner * dev path; aligns with how buildTrackedUrl no-ops). mailAudit.md §9.4 #17. */ export function buildTrackPixelUrl(campaign: string, recipient: string): string | null { if (!process.env.MAILTRACK_SECRET) return null; const mid = randomUUID(); const q = new URLSearchParams({ m: mid, c: campaign, r: recipient }); return `https://track.sase.tr/o?${q.toString()}`; } /** * Fire a Novu workflow. Never throws — failures are caught and reported via the * optional logger so a notification hiccup can never break signup/payment flows. * No-ops (logs only) when NOVU_API_KEY is unset, so local dev needs no tailnet. */ export async function triggerNovu( name: string, to: NovuRecipient, payload: NovuPayload = {}, logger: Pick = console, ): Promise { const apiKey = process.env.NOVU_API_KEY; if (!apiKey) { logger.log(`[novu:dev] would trigger "${name}" → ${to.email} ${JSON.stringify(payload)}`); return; } const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), TRIGGER_TIMEOUT_MS); // Bulk-sender compliance (Gmail/Yahoo Feb-2024) — see buildUnsubscribeHeaders. // Auth + payment workflows opt out via NO_UNSUBSCRIBE_WORKFLOWS. Header reaches // Postal only AFTER the host-side Novu NodemailerProvider patch is applied — // see postal/novu-patches/apply-headers-patch.sh. const unsubHeaders = buildUnsubscribeHeaders(name, to.subscriberId); // Visible body-footer variant of the same link — templates render it via // `{{#if unsubscribeUrl}}` so mails stay valid when the secret is unset. const unsubscribeUrl = buildUnsubscribeUrl(name, to.subscriberId); const fullPayload = unsubscribeUrl && payload.unsubscribeUrl === undefined ? { ...payload, unsubscribeUrl } : payload; const body: Record = { name, to, payload: fullPayload }; if (Object.keys(unsubHeaders).length > 0) { body.overrides = { email: { headers: unsubHeaders } }; } try { const res = await fetch(`${NOVU_API_URL}/v1/events/trigger`, { method: "POST", headers: { Authorization: `ApiKey ${apiKey}`, "Content-Type": "application/json" }, body: JSON.stringify(body), signal: controller.signal, }); if (!res.ok) { const errBody = await res.text().catch(() => ""); logger.error(`[novu] trigger "${name}" failed: HTTP ${res.status} ${errBody.slice(0, 300)}`); return; } logger.log(`[novu] triggered "${name}" → ${to.email}`); } catch (err) { logger.error(`[novu] trigger "${name}" error: ${(err as Error).message}`); } finally { clearTimeout(timeout); } } /** Greeting helper: first token of a full name, undefined when empty. */ export function firstNameOf(name?: string | null): string | undefined { const first = (name ?? "").trim().split(/\s+/)[0]; return first || undefined; }