fix(notifications): exclude test users + emit List-Unsubscribe header (audit §9.1) #99

Closed
root wants to merge 1 commits from fix/audit-9-1-lifecycle-and-unsubscribe into main
2 changed files with 90 additions and 4 deletions

View File

@@ -1,5 +1,5 @@
import { Job } from "bullmq";
import { and, eq, gt, gte, inArray, lt } from "drizzle-orm";
import { and, eq, gt, gte, inArray, lt, not, sql } from "drizzle-orm";
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import { userSubscriptions, users } from "../../database/schema/core";
import { buildTrackedUrl, firstNameOf, triggerNovu, webUrl } from "../../notifications/novu";
@@ -8,6 +8,40 @@ type Database = PostgresJsDatabase<Record<string, unknown>>;
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Internal / QA accounts that must never receive lifecycle e-mails. These are
* real users in the prod DB (test fixtures, admin mailboxes, Stripe / signup
* smoke-tests) — we keep them so engineering can probe the signup flow, but
* the daily cron must skip them or every morning they spam destek@sase.tr.
*
* Two layers: exact addresses (admin, adm1in, …) and email-pattern prefixes
* (test*, dialog*, pending-test-*, stripe-test-* against the in-house
* sase.tr / example.com / otoyedekparca.co domains we use for fixtures).
*
* Centralised here so trial-ending + win-back share the exact same exclusion
* and so the list lives next to where the noise was actually observed
* (postal/mailAudit.md §1 / §8 C-2 — confirmed sends to `testyeni@sase.tr`,
* `dialogtest@sase.tr` on 2026-06-04 09:00).
*/
const INTERNAL_ADDRESSES = ["admin@sase.tr", "adm1in@sase.tr"] as const;
/** WHERE-clause excluding internal/QA accounts from a SELECT on `users`. */
function notInternal() {
return and(
not(inArray(users.email, [...INTERNAL_ADDRESSES])),
// Test fixtures use predictable prefixes on our own domains. Anchored to
// `@example.com` / `@sase.tr` / `@otoyedekparca.co` so a legitimate user
// named e.g. `test@gmail.com` isn't accidentally muted.
not(sql`${users.email} LIKE 'test%@sase.tr'`),
not(sql`${users.email} LIKE 'dialog%@sase.tr'`),
not(sql`${users.email} LIKE 'testyeni@sase.tr'`),
not(sql`${users.email} LIKE '%@example.com'`),
not(sql`${users.email} LIKE 'pending-test-%@otoyedekparca.co'`),
not(sql`${users.email} LIKE 'stripe-test-%@otoyedekparca.co'`),
not(sql`${users.email} LIKE 'test-%@otoyedekparca.co'`),
);
}
/**
* Daily lifecycle e-mail cron. Two cohorts, each defined by a 1-day endDate
* window so a daily run sends to each user exactly once without needing a
@@ -52,6 +86,7 @@ async function sendTrialEnding(db: Database, now: Date): Promise<number> {
eq(userSubscriptions.status, "trial"),
gte(userSubscriptions.endDate, windowStart),
lt(userSubscriptions.endDate, windowEnd),
notInternal(),
),
);
@@ -93,6 +128,7 @@ async function sendWinBack(db: Database, now: Date): Promise<number> {
inArray(userSubscriptions.status, ["expired", "trial", "cancelled"]),
gte(userSubscriptions.endDate, windowStart),
lt(userSubscriptions.endDate, windowEnd),
notInternal(),
),
);

View File

@@ -28,6 +28,48 @@ const NOVU_API_URL = (process.env.NOVU_API_URL || "https://api.bildirim.semih.ai
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 target. Receives plain "please
* unsubscribe me" replies — Postal has a route on the catch-all that drops it
* into destek's SnappyMail so it's reviewable. Override via UNSUBSCRIBE_EMAIL.
*
* NOTE: Gmail's Feb-2024 bulk-sender enforcement also asks for a one-click
* HTTPS endpoint via `List-Unsubscribe-Post: List-Unsubscribe=One-Click`. We
* gate the HTTPS variant behind `UNSUBSCRIBE_URL_BASE` (e.g.
* `https://sase.tr/api/email/unsubscribe`) — set it once the matching endpoint
* ships. Until then the mailto: variant alone still satisfies Yahoo/Outlook
* and meaningfully lifts Gmail inbox placement.
*/
const UNSUBSCRIBE_EMAIL = process.env.UNSUBSCRIBE_EMAIL || "unsubscribe@sase.tr";
const UNSUBSCRIBE_URL_BASE = process.env.UNSUBSCRIBE_URL_BASE || "";
/**
* Workflows where we MUST NOT advertise an unsubscribe link — token-bearing
* auth flows. Mail-client privacy proxies and link-warmers occasionally hit
* `List-Unsubscribe` URLs / mailto: targets pre-emptively; for verification
* and password-reset we don't want that side-effect on the user account.
*/
const NO_UNSUBSCRIBE_WORKFLOWS = new Set<string>([
"email-verification",
"password-reset",
]);
function buildUnsubscribeHeaders(workflow: string, subscriberId: string): Record<string, string> {
if (NO_UNSUBSCRIBE_WORKFLOWS.has(workflow)) return {};
const targets: string[] = [];
if (UNSUBSCRIBE_URL_BASE) {
const q = new URLSearchParams({ u: subscriberId, w: workflow });
targets.push(`<${UNSUBSCRIBE_URL_BASE}?${q.toString()}>`);
}
targets.push(`<mailto:${UNSUBSCRIBE_EMAIL}?subject=unsubscribe%3A${encodeURIComponent(workflow)}>`);
const headers: Record<string, string> = { "List-Unsubscribe": targets.join(", ") };
// RFC 8058 one-click — only valid when an HTTPS endpoint is part of the list.
if (UNSUBSCRIBE_URL_BASE) {
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;
@@ -84,16 +126,24 @@ export async function triggerNovu(
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TRIGGER_TIMEOUT_MS);
// Bulk-sender compliance (Gmail/Yahoo Feb-2024) — see buildUnsubscribeHeaders.
// Auth workflows opt out via NO_UNSUBSCRIBE_WORKFLOWS so token links can't be
// pre-fetched by a privacy proxy hitting the unsub URL.
const unsubHeaders = buildUnsubscribeHeaders(name, to.subscriberId);
const body: Record<string, unknown> = { name, to, payload };
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({ name, to, payload }),
body: JSON.stringify(body),
signal: controller.signal,
});
if (!res.ok) {
const body = await res.text().catch(() => "");
logger.error(`[novu] trigger "${name}" failed: HTTP ${res.status} ${body.slice(0, 300)}`);
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}`);