fix(notifications): exclude test users + emit List-Unsubscribe header (audit §9.1) #99
@@ -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(),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user