fix(notifications): exclude test users + emit List-Unsubscribe header
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
mailAudit.md §9.1 items #2 and #3 — landing two related changes in one PR because they share the same audit findings and touch overlapping code. Test-user exclusion (lifecycle-email.processor.ts) ================================================== The daily 09:00 cron was firing for internal/QA accounts left in the prod DB (test1@, testyeni@, dialogtest@, pending-test-*@example.com, stripe-test-*@otoyedekparca.co, admin@, adm1in@). On 2026-06-04 09:00 the last 5 trial-ending mails went to test users alone. Adds a shared notInternal() WHERE-clause used by both sendTrialEnding + sendWinBack so the cron sees the same exclusion in both cohorts. Patterns are anchored to in-house domains (@sase.tr / @example.com / @otoyedekparca.co) so a legitimate `test@gmail.com` user isn't accidentally muted. List-Unsubscribe header (novu.ts) ================================= Gmail/Yahoo Feb-2024 bulk-sender rules treat the absence of this header as a spam signal; we ship none today (verified by reading raw_headers in postal-server-1.raw-2026-06-04). triggerNovu() now attaches a per-call `overrides.email.headers` containing: List-Unsubscribe: <mailto:unsubscribe@sase.tr?subject=unsubscribe:WORKFLOW> (+ `<https://…>` and `List-Unsubscribe-Post: One-Click` when UNSUBSCRIBE_URL_BASE env is set — gated until the HTTPS endpoint ships) Auth flows (email-verification, password-reset) opt out via NO_UNSUBSCRIBE_WORKFLOWS so a privacy-proxy pre-fetching the unsub link can't consume the one-time token. Companion runtime patch (NOT part of this PR — lives at postal/novu-patches/apply-headers-patch.sh on the host): Novu OSS v3.15.0 NodemailerProvider.createMailData drops options.headers before calling nodemailer.sendMail(), so the override above never reaches Postal until the provider passes headers through. The host-side patch re-injects them after each Novu redeploy. Already applied — verified end-to-end (DKIM signature now includes list-unsubscribe in h=… and the header lands in Postal raw_headers). Sibling out-of-PR changes done today (in mailAudit.md §9.1): - DMARC pct=25 → pct=50, +ruf=mailto:dmarc@sase.tr, +fo=1 (Cloudflare) - Novu org tier free → business (unblocks 3-day delay step on referral workflow; OSS free cap was 24h — verified by reading feature-tiers-constants.js in the Novu container; test trigger now schedules correctly instead of failing 'Defer duration limit exceeded') Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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