Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Lands the §9.3 "compliance + brand" tier of mailAudit.md as one PR. Six changes share enough surface (notifications, shared utils, infrastructure) that splitting them would require multiple stacked PRs. #9 — Turkish-locale title-case for names at signup • New `normalizeName()` in @sase/shared, locale-aware (İ/ı pairs handled via toLocaleLowerCase('tr-TR') + matching toLocaleUpperCase). Hyphen- aware, collapses whitespace, idempotent. • Wired into better-auth's `user.create.before` hook so every new signup gets canonicalised before the row lands in Postgres. • 28 unit tests in packages/shared/src/index.spec.ts. • Backfill script at `scripts/backfill-user-names.ts` (already run against prod + dev — 210/402 prod users and 72/153 dev users canonicalised, plus 71 Novu subscribers). #10 — Email typo correction at signup • New `suggestEmailFix()` in @sase/shared: exact-match typo dictionary for the addresses we've actually suppressed (icould.com, gmial.com, xn--gmail-bgd.com, …) plus Levenshtein ≤ 2 fallback against popular providers. • Inline UI hint on the register form — "Bunu mu demek istedin? <link>" that swaps the email on click; PostHog event tracks acceptance. #11 — Strip EN branches (decision: TR-only) • 0/205 prod subscribers have locale='en' and there's no marketing in English — the {{#equals subscriber.locale "en"}}…{{else}}…{{/equals}} framework was dead code in all 10 templates. • Templates updated in-place (avg ~30 % smaller). Renamed `novu-welcome-tr.html` → `novu-welcome.html` for consistency with the other 9 files. • Novu workflow definitions in both Dev + Prod envs updated via Mongo: subjects collapsed to TR-only, content replaced with new HTML (mongodump/restore-safe). • App code: `NovuRecipient.locale` and `NovuUser.locale` removed; the `...(user.locale === "en" ? { locale: "en" } : {})` spread in NovuService is gone. #12 — DKIM rotated to 2048-bit RSA • Postal default was 1024-bit (selector `postal-YeIm3w`). Generated new 2048-bit key, added DNS TXT `postal-2k260604._domainkey.sase.tr`, atomically swapped `domains.dkim_identifier_string` + `dkim_private_key` in Postal MariaDB, restarted Postal SMTP. • Verified: outgoing welcome mail now signs with `s=postal-2k260604` and a 256-byte signature body (vs the previous 128-byte 1024-bit signature). Pubkey on DNS matches the private key. • OLD TXT record (`postal-YeIm3w._domainkey`) stays in DNS for ~7 days as a grace window for in-flight mail. #13 — MTA-STS + TLS-RPT • Extended the existing mailtrack Cloudflare Worker to also serve `mta-sts.sase.tr/.well-known/mta-sts.txt` (`mode: enforce, mx: mx.postal.sase.tr, max_age: 604800`). Workers Domain bound to the mailtrack service via Cloudflare API. • DNS: `_mta-sts.sase.tr` TXT "v=STSv1; id=20260604111347" `_smtp._tls.sase.tr` TXT "v=TLSRPTv1; rua=mailto:dmarc@sase.tr" • Verified policy fetch returns 200 with the expected body; cert valid (sase.tr SAN issued by GTS). #14 — Unsubscribe preferences + RFC 8058 one-click endpoint • New `email_preferences` table (migration 0011) keyed (user_id, workflow), captures source for audit (one_click / manual_link / settings_page). • New `UnsubscribeController` at `/api/email/unsubscribe`: - POST: Gmail/Yahoo one-click bot path (200 fast) - GET: human-visit, renders a Turkish confirmation page Both validate an HMAC-SHA256(`userId|workflow`) token under `UNSUBSCRIBE_SECRET` — stateless, no DB lookup to validate, secret rotation invalidates all outstanding tokens. • `triggerNovu()` now mints the per-call `overrides.email.headers`: `List-Unsubscribe: <https://…?u=&w=&t=>, <mailto:unsubscribe@…>` `List-Unsubscribe-Post: List-Unsubscribe=One-Click` Auth + payment workflows opt out via NO_UNSUBSCRIBE_WORKFLOWS so the unsubscribe URL never appears on transactional mail. • `NovuService.trigger()` pre-flight-checks `isOptedOut()` and skips the trigger entirely if the user opted out. Fail-open on DB error so a transient blip can't swallow auth mail. • `lifecycle-email.processor.ts` (standalone BullMQ worker — no NestJS DI) does the same check inline via a LEFT JOIN on `email_preferences WHERE opted_out IS NULL`. • Coolify env wired in both Prod and Dev apps: `UNSUBSCRIBE_SECRET` (32-byte hex, distinct per env) `UNSUBSCRIBE_URL_BASE` = `https://(dev.)sase.tr/api/email/unsubscribe` ## Companion sibling changes (already applied, NOT in this PR) - Cloudflare worker `mailtrack` redeployed with mta-sts.sase.tr custom domain. - Postal MariaDB `domains.dkim_identifier_string` + `dkim_private_key` updated to the new 2k260604 selector (live since 2026-06-04 11:18). - `postal-2k260604._domainkey.sase.tr` TXT record live at Cloudflare. - `_mta-sts.sase.tr` + `_smtp._tls.sase.tr` TXT records live at Cloudflare. - Novu Mongo notification + message templates updated to TR-only. - 282 user names canonicalised across prod + dev + Novu subscribers. ## Verification snapshot - Postal raw_headers (ID 157, post-rotation): `s=postal-2k260604` + 256-byte b= - `dig +short TXT _mta-sts.sase.tr @1.1.1.1` ⇒ live id=20260604111347 - `curl https://mta-sts.sase.tr/.well-known/mta-sts.txt` ⇒ 200 with policy - 28 unit tests (normalizeName + suggestEmailFix) all green via Node sanity. ## Deploy notes - Re-run `pnpm db:generate` to regenerate the drizzle snapshot for 0011 (added the journal entry manually because no drizzle-kit on this box). - Run `pnpm tsx scripts/backfill-user-names.ts --apply` against any DB not yet canonicalised (already done for prod + dev today). - The host-side Novu nodemailer-headers patch at `postal/novu-patches/apply-headers-patch.sh` must be re-run after every Novu container redeploy or the List-Unsubscribe header is silently dropped before reaching Postal (see audit §9.1 #3 for the upstream cause). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
104 lines
3.5 KiB
TypeScript
104 lines
3.5 KiB
TypeScript
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
import { Inject, Injectable, Logger } from "@nestjs/common";
|
|
import { and, eq } from "drizzle-orm";
|
|
import { DATABASE, type Database } from "../database/database.provider";
|
|
import * as schema from "../database/schema/core";
|
|
|
|
/**
|
|
* Workflows the user can opt out of. Auth + payment flows are deliberately
|
|
* NOT in this set — they're transactional and must reach the user (the
|
|
* compliance argument is the same as Stripe's "we still send receipts even
|
|
* if you unsubscribed from marketing").
|
|
*/
|
|
export const OPTIONAL_WORKFLOWS = new Set<string>([
|
|
"welcome",
|
|
"trial-ending",
|
|
"win-back",
|
|
"referral",
|
|
"referral-qualified",
|
|
"referral-reward",
|
|
]);
|
|
|
|
/**
|
|
* Stateless HMAC token in the List-Unsubscribe URL — no DB lookup needed to
|
|
* validate. Anyone holding the token can opt out, but only the server can
|
|
* mint one (the secret never leaves the API). Rotating UNSUBSCRIBE_SECRET
|
|
* invalidates every outstanding token, which is a useful nuke-button if a
|
|
* mail leak ever surfaces.
|
|
*/
|
|
export function signUnsubscribeToken(secret: string, userId: string, workflow: string): string {
|
|
return createHmac("sha256", secret).update(`${userId}|${workflow}`).digest("hex");
|
|
}
|
|
|
|
export function verifyUnsubscribeToken(
|
|
secret: string,
|
|
userId: string,
|
|
workflow: string,
|
|
token: string,
|
|
): boolean {
|
|
if (!secret) return false;
|
|
if (!/^[0-9a-f]+$/i.test(token) || token.length % 2 !== 0) return false;
|
|
const expected = signUnsubscribeToken(secret, userId, workflow);
|
|
if (expected.length !== token.length) return false;
|
|
try {
|
|
return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(token, "hex"));
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
@Injectable()
|
|
export class EmailPreferencesService {
|
|
private readonly logger = new Logger(EmailPreferencesService.name);
|
|
|
|
constructor(@Inject(DATABASE) private readonly db: Database) {}
|
|
|
|
/** True if the user has explicitly opted out of `workflow`. */
|
|
async isOptedOut(userId: string, workflow: string): Promise<boolean> {
|
|
if (!OPTIONAL_WORKFLOWS.has(workflow)) return false;
|
|
const [row] = await this.db
|
|
.select({ optedOut: schema.emailPreferences.optedOut })
|
|
.from(schema.emailPreferences)
|
|
.where(
|
|
and(
|
|
eq(schema.emailPreferences.userId, userId),
|
|
eq(schema.emailPreferences.workflow, workflow),
|
|
),
|
|
)
|
|
.limit(1);
|
|
return row?.optedOut === true;
|
|
}
|
|
|
|
/**
|
|
* Mark a (user, workflow) pair as opted-out. Idempotent — re-clicking the
|
|
* unsubscribe link doesn't error, just no-ops the row's updated_at.
|
|
* `source` is captured for audit (`one_click`, `settings_page`,
|
|
* `admin_panel`, …).
|
|
*/
|
|
async optOut(userId: string, workflow: string, source: string): Promise<void> {
|
|
if (!OPTIONAL_WORKFLOWS.has(workflow)) {
|
|
this.logger.warn(`refusing optOut on non-optional workflow ${workflow}`);
|
|
return;
|
|
}
|
|
await this.db
|
|
.insert(schema.emailPreferences)
|
|
.values({ userId, workflow, optedOut: true, source })
|
|
.onConflictDoUpdate({
|
|
target: [schema.emailPreferences.userId, schema.emailPreferences.workflow],
|
|
set: { optedOut: true, source, updatedAt: new Date() },
|
|
});
|
|
}
|
|
|
|
/** Re-subscribe — used by the dashboard settings UI when a user toggles back on. */
|
|
async optIn(userId: string, workflow: string): Promise<void> {
|
|
await this.db
|
|
.delete(schema.emailPreferences)
|
|
.where(
|
|
and(
|
|
eq(schema.emailPreferences.userId, userId),
|
|
eq(schema.emailPreferences.workflow, workflow),
|
|
),
|
|
);
|
|
}
|
|
}
|