feat(notifications): TR-only templates + name canonicalisation + MTA-STS + 2048-bit DKIM + unsubscribe (audit §9.3)
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
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>
This commit is contained in:
75
scripts/backfill-user-names.ts
Normal file
75
scripts/backfill-user-names.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* One-shot backfill: title-case existing `users.name` rows.
|
||||
*
|
||||
* After this lands, every NEW signup gets canonicalised in the better-auth
|
||||
* `user.create.before` hook (see apps/api/src/auth/auth.ts). Existing rows
|
||||
* pre-date that hook and still carry whatever the user typed at signup:
|
||||
* `mehmet`, `MEHMET`, `İLKER`, `OTO`, …. This script applies the same
|
||||
* `normalizeName()` Turkish-locale-aware title-case to historical rows so
|
||||
* mail subjects (`Sase.tr'ye hoş geldin, mehmet` → `, Mehmet`) and dashboard
|
||||
* greetings render consistently.
|
||||
*
|
||||
* Safe to re-run: the UPDATE is gated on `name <> normalized`, so already-
|
||||
* canonical rows aren't touched.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm tsx scripts/backfill-user-names.ts --dry-run # preview only
|
||||
* pnpm tsx scripts/backfill-user-names.ts # write
|
||||
*
|
||||
* Run against BOTH prod (sase) and dev (sase_dev) DBs separately by pointing
|
||||
* DATABASE_URL at each. mailAudit.md §9.3 #9.
|
||||
*/
|
||||
|
||||
import * as path from "node:path";
|
||||
import * as dotenv from "dotenv";
|
||||
import postgres from "postgres";
|
||||
import { normalizeName } from "@sase/shared";
|
||||
|
||||
dotenv.config({ path: path.join(__dirname, "../apps/api/.env") });
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const dryRun = argv.includes("--dry-run");
|
||||
|
||||
async function main() {
|
||||
if (!process.env.DATABASE_URL) {
|
||||
console.error("DATABASE_URL is not set");
|
||||
process.exit(1);
|
||||
}
|
||||
const sql = postgres(process.env.DATABASE_URL, { max: 1 });
|
||||
|
||||
const rows = await sql<{ id: string; name: string }[]>`
|
||||
SELECT id, name FROM users WHERE name IS NOT NULL AND name <> ''
|
||||
`;
|
||||
console.log(`[backfill] scanned ${rows.length} users`);
|
||||
|
||||
let changed = 0;
|
||||
let unchanged = 0;
|
||||
const samples: Array<{ before: string; after: string }> = [];
|
||||
|
||||
for (const r of rows) {
|
||||
const norm = normalizeName(r.name);
|
||||
if (norm === r.name) {
|
||||
unchanged++;
|
||||
continue;
|
||||
}
|
||||
if (samples.length < 15) samples.push({ before: r.name, after: norm });
|
||||
if (!dryRun) {
|
||||
await sql`UPDATE users SET name = ${norm}, updated_at = NOW() WHERE id = ${r.id}`;
|
||||
}
|
||||
changed++;
|
||||
}
|
||||
|
||||
console.log(`[backfill] ${changed} changed, ${unchanged} already canonical`);
|
||||
if (samples.length) {
|
||||
console.log("[backfill] sample diffs:");
|
||||
for (const s of samples) console.log(` '${s.before}' → '${s.after}'`);
|
||||
}
|
||||
if (dryRun) console.log("[backfill] DRY RUN — no writes");
|
||||
|
||||
await sql.end();
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user