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:
@@ -26,6 +26,8 @@ import {
|
||||
slugify,
|
||||
generateReferralCode,
|
||||
normalizeEmail,
|
||||
normalizeName,
|
||||
suggestEmailFix,
|
||||
referralRewardForCount,
|
||||
referralTotalRewardDays,
|
||||
// Constants
|
||||
@@ -631,6 +633,114 @@ describe("normalizeEmail", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeName (Turkish-locale title-case)", () => {
|
||||
it("title-cases lowercase input", () => {
|
||||
expect(normalizeName("mehmet")).toBe("Mehmet");
|
||||
});
|
||||
|
||||
it("title-cases uppercase input", () => {
|
||||
expect(normalizeName("MEHMET")).toBe("Mehmet");
|
||||
});
|
||||
|
||||
it("handles Turkish İ → i pair correctly (locale-aware)", () => {
|
||||
// `İLKER` lower-cases to `ilker` (NOT `i̇lker`) under tr locale,
|
||||
// and lowercase `i` upper-cases to `İ`, not `I`.
|
||||
expect(normalizeName("İLKER")).toBe("İlker");
|
||||
expect(normalizeName("ilker")).toBe("İlker");
|
||||
});
|
||||
|
||||
it("handles Turkish ı (dotless) correctly", () => {
|
||||
expect(normalizeName("ALİ YILMAZ")).toBe("Ali Yılmaz");
|
||||
expect(normalizeName("yılmaz")).toBe("Yılmaz");
|
||||
});
|
||||
|
||||
it("title-cases each whitespace-separated token", () => {
|
||||
expect(normalizeName("ali yılmaz")).toBe("Ali Yılmaz");
|
||||
expect(normalizeName("ahmet veli mehmet")).toBe("Ahmet Veli Mehmet");
|
||||
});
|
||||
|
||||
it("collapses internal whitespace", () => {
|
||||
expect(normalizeName("ali yılmaz")).toBe("Ali Yılmaz");
|
||||
expect(normalizeName(" ali\tyılmaz ")).toBe("Ali Yılmaz");
|
||||
});
|
||||
|
||||
it("preserves diacritics", () => {
|
||||
expect(normalizeName("ÖMER")).toBe("Ömer");
|
||||
expect(normalizeName("ÇAĞRI")).toBe("Çağrı");
|
||||
expect(normalizeName("ŞENOL")).toBe("Şenol");
|
||||
expect(normalizeName("ÜLKÜ")).toBe("Ülkü");
|
||||
});
|
||||
|
||||
it("title-cases each segment of a hyphenated name", () => {
|
||||
expect(normalizeName("mehmet-ali")).toBe("Mehmet-Ali");
|
||||
expect(normalizeName("ANNA-MARIA")).toBe("Anna-Maria");
|
||||
});
|
||||
|
||||
it("returns '' for null/undefined/whitespace-only", () => {
|
||||
expect(normalizeName(null)).toBe("");
|
||||
expect(normalizeName(undefined)).toBe("");
|
||||
expect(normalizeName("")).toBe("");
|
||||
expect(normalizeName(" ")).toBe("");
|
||||
});
|
||||
|
||||
it("is idempotent (already-canonical input is unchanged)", () => {
|
||||
expect(normalizeName("Mehmet")).toBe("Mehmet");
|
||||
expect(normalizeName("Ali Yılmaz")).toBe("Ali Yılmaz");
|
||||
expect(normalizeName(normalizeName("MEHMET"))).toBe("Mehmet");
|
||||
});
|
||||
|
||||
it("handles mixed-case noise (typo-tier signups)", () => {
|
||||
expect(normalizeName("MEhmEt")).toBe("Mehmet");
|
||||
expect(normalizeName("aLİ")).toBe("Ali");
|
||||
expect(normalizeName("oto")).toBe("Oto");
|
||||
expect(normalizeName("OTO")).toBe("Oto");
|
||||
});
|
||||
});
|
||||
|
||||
describe("suggestEmailFix", () => {
|
||||
it("returns null for already-popular domains", () => {
|
||||
expect(suggestEmailFix("user@gmail.com")).toBe(null);
|
||||
expect(suggestEmailFix("user@hotmail.com")).toBe(null);
|
||||
expect(suggestEmailFix("user@outlook.com.tr")).toBe(null);
|
||||
});
|
||||
|
||||
it("catches real-world prod typos via exact-match dictionary", () => {
|
||||
// these all came from prod Postal suppressions:
|
||||
expect(suggestEmailFix("muratsmz61@icould.com")).toBe("muratsmz61@icloud.com");
|
||||
expect(suggestEmailFix("user@gmial.com")).toBe("user@gmail.com");
|
||||
expect(suggestEmailFix("user@hotmial.com")).toBe("user@hotmail.com");
|
||||
expect(suggestEmailFix("user@gmail.co")).toBe("user@gmail.com");
|
||||
expect(suggestEmailFix("user@yaho.com")).toBe("user@yahoo.com");
|
||||
});
|
||||
|
||||
it("catches IDN-encoded typos (Turkish keyboard hiccups)", () => {
|
||||
expect(suggestEmailFix("user@xn--gmail-bgd.com")).toBe("user@gmail.com");
|
||||
expect(suggestEmailFix("user@xn--iclud-p4a.com")).toBe("user@icloud.com");
|
||||
});
|
||||
|
||||
it("catches near-miss typos via Levenshtein ≤ 2", () => {
|
||||
expect(suggestEmailFix("user@gnail.com")).toBe("user@gmail.com");
|
||||
expect(suggestEmailFix("user@htmail.com")).toBe("user@hotmail.com");
|
||||
});
|
||||
|
||||
it("returns null for plausibly legitimate non-popular domains", () => {
|
||||
expect(suggestEmailFix("user@otoyedekparca.co")).toBe(null);
|
||||
expect(suggestEmailFix("user@volanthastanesi.com.tr")).toBe(null);
|
||||
expect(suggestEmailFix("user@karalarfiltre.com.tr")).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null for malformed input", () => {
|
||||
expect(suggestEmailFix("")).toBe(null);
|
||||
expect(suggestEmailFix("no-at-sign")).toBe(null);
|
||||
expect(suggestEmailFix("@no-local-part.com")).toBe(null);
|
||||
expect(suggestEmailFix("local-part-only@")).toBe(null);
|
||||
});
|
||||
|
||||
it("preserves the local part", () => {
|
||||
expect(suggestEmailFix("Foo.Bar+tag@gmial.com")).toBe("foo.bar+tag@gmail.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("referralRewardForCount (per-milestone, every 3 → 7d, every 5 → 14d)", () => {
|
||||
it("grants nothing below the first milestone", () => {
|
||||
expect(referralRewardForCount(0)).toBe(0);
|
||||
|
||||
@@ -72,4 +72,6 @@ export {
|
||||
slugify,
|
||||
generateReferralCode,
|
||||
normalizeEmail,
|
||||
normalizeName,
|
||||
suggestEmailFix,
|
||||
} from "./utils/formatters.js";
|
||||
|
||||
@@ -62,3 +62,171 @@ export function generateReferralCode(): string {
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalises a personal name to "Sentence Case" with Turkish locale awareness.
|
||||
*
|
||||
* Why: Postal logs show signup names arrive in every casing — `mehmet`,
|
||||
* `MEHMET`, `MEhmEt`, `İLKER`, `oto` — and we render them directly into mail
|
||||
* subjects (`Sase.tr'ye hoş geldin, mehmet`). Title-casing in the auth hook
|
||||
* means every downstream consumer (Novu subscriber profile, Stripe customer
|
||||
* name, dashboard greeting) gets the same canonicalised string.
|
||||
*
|
||||
* Turkish rules that `toLowerCase()` / `toUpperCase()` get WRONG:
|
||||
* - `I` ↔ `ı` (dotless), `İ` ↔ `i` (dotted) — invariant casing produces
|
||||
* `i → I` which Turks read as a different letter. `toLocaleLowerCase("tr")`
|
||||
* handles this correctly.
|
||||
* - Other diacritics (Ç, Ğ, Ö, Ş, Ü) work fine under invariant casing but we
|
||||
* use locale-aware for consistency.
|
||||
*
|
||||
* Behaviour:
|
||||
* - Trims and collapses internal whitespace.
|
||||
* - For each whitespace-separated token: first cp upper, rest lower.
|
||||
* - Hyphenated names: each segment is title-cased (`mehmet-ali` →
|
||||
* `Mehmet-Ali`).
|
||||
* - Apostrophes and other punctuation are passed through unchanged.
|
||||
* - Returns "" for null/undefined/whitespace-only input (so the caller's
|
||||
* schema validation can reject it the same way as before).
|
||||
*
|
||||
* Examples:
|
||||
* normalizeName("mehmet") → "Mehmet"
|
||||
* normalizeName("MEHMET") → "Mehmet"
|
||||
* normalizeName("İLKER") → "İlker"
|
||||
* normalizeName("ali yılmaz")→ "Ali Yılmaz"
|
||||
* normalizeName("ÖMER") → "Ömer"
|
||||
* normalizeName("ahmet-ali") → "Ahmet-Ali"
|
||||
*/
|
||||
export function normalizeName(name: string | null | undefined): string {
|
||||
if (name == null) return "";
|
||||
const trimmed = name.replace(/\s+/g, " ").trim();
|
||||
if (trimmed === "") return "";
|
||||
return trimmed
|
||||
.split(" ")
|
||||
.map(titleCaseToken)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggests a corrected e-mail when the domain looks like a typo of a popular
|
||||
* provider. Returns `null` when the address looks fine.
|
||||
*
|
||||
* Why: prod Postal logs show a steady ~6% typo rate at signup — `icould.com`,
|
||||
* `gmial.com`, `hotmial.com`, `gmail.co`, plus IDN-encoded variants like
|
||||
* `xn--gmail-bgd.com` (Turkish keyboard "ı" → punycoded). These addresses
|
||||
* hard-bounce, the user never gets the verification mail, and Postal
|
||||
* suppresses the recipient. Catching it at signup avoids that whole loop.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Lowercase + trim, then split local + domain.
|
||||
* 2. Exact-match a typo dictionary first (cheapest, catches `gmial.com` →
|
||||
* `gmail.com` and the punycoded IDNs we've actually seen in prod).
|
||||
* 3. Fall back to Levenshtein distance ≤ 2 against a popular-provider list
|
||||
* (catches longer-tail misses like `gnail.com` or `htmail.com`).
|
||||
* 4. Refuse to suggest when the input domain is identical to a popular one
|
||||
* (else `gmail.com` → suggest `gmail.com` self-suggest).
|
||||
*
|
||||
* Returns the FULL corrected address so the caller can swap it directly.
|
||||
*/
|
||||
const POPULAR_DOMAINS = [
|
||||
"gmail.com",
|
||||
"hotmail.com",
|
||||
"outlook.com",
|
||||
"yahoo.com",
|
||||
"icloud.com",
|
||||
"msn.com",
|
||||
"live.com",
|
||||
"yandex.com",
|
||||
"yandex.com.tr",
|
||||
"outlook.com.tr",
|
||||
"hotmail.com.tr",
|
||||
];
|
||||
|
||||
const EXACT_TYPOS: Record<string, string> = {
|
||||
// Real punycoded typos we've seen in prod (Turkish keyboard quirks):
|
||||
"xn--gmail-bgd.com": "gmail.com",
|
||||
"xn--hotmail-cie.com": "hotmail.com",
|
||||
"xn--iclud-p4a.com": "icloud.com",
|
||||
// Common Latin-letter typos:
|
||||
"gmial.com": "gmail.com",
|
||||
"gnail.com": "gmail.com",
|
||||
"gmail.co": "gmail.com",
|
||||
"gmail.cm": "gmail.com",
|
||||
"gmaill.com": "gmail.com",
|
||||
"gmal.com": "gmail.com",
|
||||
"gamil.com": "gmail.com",
|
||||
"hotmial.com": "hotmail.com",
|
||||
"hotmal.com": "hotmail.com",
|
||||
"hotnail.com": "hotmail.com",
|
||||
"hotmail.co": "hotmail.com",
|
||||
"hotmail.cm": "hotmail.com",
|
||||
"outlok.com": "outlook.com",
|
||||
"outloook.com": "outlook.com",
|
||||
"outloko.com": "outlook.com",
|
||||
"yaho.com": "yahoo.com",
|
||||
"yahooo.com": "yahoo.com",
|
||||
"yahoo.co": "yahoo.com",
|
||||
"icould.com": "icloud.com",
|
||||
"iclod.com": "icloud.com",
|
||||
"iclud.com": "icloud.com",
|
||||
};
|
||||
|
||||
export function suggestEmailFix(email: string): string | null {
|
||||
const trimmed = (email || "").trim().toLowerCase();
|
||||
const at = trimmed.lastIndexOf("@");
|
||||
if (at < 1 || at >= trimmed.length - 1) return null;
|
||||
const local = trimmed.slice(0, at);
|
||||
const domain = trimmed.slice(at + 1);
|
||||
if (POPULAR_DOMAINS.includes(domain)) return null;
|
||||
// Exact-match dictionary first (cheapest).
|
||||
const exact = EXACT_TYPOS[domain];
|
||||
if (exact) return `${local}@${exact}`;
|
||||
// Levenshtein distance ≤ 2 against popular list.
|
||||
let best: { d: string; dist: number } | null = null;
|
||||
for (const candidate of POPULAR_DOMAINS) {
|
||||
// Quick length-prefilter: distance ≥ |len diff|.
|
||||
if (Math.abs(candidate.length - domain.length) > 2) continue;
|
||||
const dist = levenshtein(domain, candidate);
|
||||
if (dist <= 2 && (best === null || dist < best.dist)) {
|
||||
best = { d: candidate, dist };
|
||||
}
|
||||
}
|
||||
if (best) return `${local}@${best.d}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Standard Levenshtein — small string, allocation-cheap. */
|
||||
function levenshtein(a: string, b: string): number {
|
||||
if (a === b) return 0;
|
||||
if (a.length === 0) return b.length;
|
||||
if (b.length === 0) return a.length;
|
||||
const m = a.length;
|
||||
const n = b.length;
|
||||
let prev = new Array(n + 1).fill(0);
|
||||
let curr = new Array(n + 1).fill(0);
|
||||
for (let j = 0; j <= n; j++) prev[j] = j;
|
||||
for (let i = 1; i <= m; i++) {
|
||||
curr[0] = i;
|
||||
for (let j = 1; j <= n; j++) {
|
||||
const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
|
||||
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
|
||||
}
|
||||
[prev, curr] = [curr, prev];
|
||||
}
|
||||
return prev[n];
|
||||
}
|
||||
|
||||
/** Title-case a single whitespace-free token, hyphen-aware. */
|
||||
function titleCaseToken(token: string): string {
|
||||
if (token === "") return token;
|
||||
// Hyphen-separated names — each part gets its own title-case so
|
||||
// `mehmet-ali` → `Mehmet-Ali`, not `Mehmet-ali`.
|
||||
if (token.includes("-")) {
|
||||
return token.split("-").map(titleCaseToken).join("-");
|
||||
}
|
||||
// Use array-of-codepoints to avoid splitting surrogate pairs mid-character
|
||||
// (Turkish letters are BMP, but defending against accidental emoji etc.).
|
||||
const chars = [...token.toLocaleLowerCase("tr-TR")];
|
||||
if (chars.length === 0) return "";
|
||||
chars[0] = chars[0].toLocaleUpperCase("tr-TR");
|
||||
return chars.join("");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user