- inject signed unsubscribeUrl into every optional-workflow Novu payload
(templates render it via {{#if unsubscribeUrl}} footer)
- add 'conversion' campaign workflow to OPTIONAL_WORKFLOWS + email_marketing
category so its one-click tokens validate and opt-outs suppress it
- declare UNSUBSCRIBE_SECRET/URL_BASE/EMAIL in env schema (""→undefined
preprocess against the url().optional() boot-crash trap), .env.example and
both compose env blocks
- fix confirmation-page settings link (?tab=notifications)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
225 lines
9.3 KiB
TypeScript
225 lines
9.3 KiB
TypeScript
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<string, unknown>;
|
|
|
|
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 `<APP_PUBLIC_URL>/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<string>([
|
|
"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<string, string> {
|
|
if (NO_UNSUBSCRIBE_WORKFLOWS.has(workflow)) return {};
|
|
const targets: string[] = [];
|
|
const httpsUrl = buildUnsubscribeUrl(workflow, subscriberId);
|
|
if (httpsUrl) targets.push(`<${httpsUrl}>`);
|
|
targets.push(
|
|
`<mailto:${UNSUBSCRIBE_EMAIL}?subject=unsubscribe%3A${encodeURIComponent(workflow)}>`,
|
|
);
|
|
const headers: Record<string, string> = { "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 `<img src="...">`. 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, "log" | "warn" | "error"> = console,
|
|
): Promise<void> {
|
|
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<string, unknown> = { 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;
|
|
}
|