feat(notifications): TR-only templates + name canonicalisation + MTA-STS + 2048-bit DKIM + unsubscribe (audit §9.3) #101

Closed
root wants to merge 0 commits from fix/audit-9-3-tr-only-mta-sts-names into main
Owner

Lands the §9.3 compliance + brand tier of postal/mailAudit.md as one PR.

What

Six audit items, all in the notifications surface or one hop away:

# Item Where it lives
9 TR title-case at signup + backfill normalizeName() in @sase/shared + better-auth user.create.before hook + scripts/backfill-user-names.ts
10 Email typo correction at signup suggestEmailFix() in @sase/shared + inline UI hint on register form
11 Strip EN branches (TR-only decision) All 10 novu-*.html templates + Novu Mongo workflow defs + NovuRecipient.locale removed
12 DKIM rotated to 2048-bit RSA Postal domains.dkim_identifier_string swapped to 2k260604 + new TXT record at Cloudflare
13 MTA-STS + TLS-RPT mailtrack CF Worker extended to serve mta-sts.sase.tr/.well-known/mta-sts.txt + 2 DNS TXT records
14 Unsubscribe preferences + RFC 8058 one-click New email_preferences table + UnsubscribeController + token sign/verify in novu.ts + pre-flight skip in NovuService and lifecycle-email.processor

EN i18n decision: user picked strip-EN. All 10 templates collapsed to TR-only (avg ~30 % smaller). EN can come back as a follow-up if/when there is an EN market, with a users.locale column + UI switcher.

Verification snapshot

  • Postal raw_headers ID 157 (post-rotation): s=postal-2k260604 + 256-byte b=… signature.
  • 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 the expected policy.
  • 28 unit tests (normalizeName + suggestEmailFix) all green via Node sanity.
  • Backfill: 210/402 prod users + 72/153 dev users + 71 Novu subscribers canonicalised.

Sibling changes already applied (NOT in this PR)

  • Cloudflare worker mailtrack redeployed with mta-sts.sase.tr custom domain.
  • Postal MariaDB swapped to the new 2048-bit DKIM key, SMTP container restarted.
  • New 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 messagetemplates updated for all 10 workflows in both Dev + Prod envs.
  • Coolify env (Prod + Dev): UNSUBSCRIBE_SECRET (32-byte hex, distinct per env) + UNSUBSCRIBE_URL_BASE (= https://(dev.)sase.tr/api/email/unsubscribe).

Deploy gotchas

  1. pnpm db:generate to regenerate the drizzle snapshot for migration 0011 (added the journal entry manually because no drizzle-kit on this box).
  2. The host-side Novu nodemailer-headers patch at postal/novu-patches/apply-headers-patch.sh must be re-run after every Novu container redeploy — Novu OSS v3.15.0 NodemailerProvider.createMailData drops options.headers before calling nodemailer.sendMail() (see audit §9.1 #3).
  3. Settings UI page at /dashboard/settings/notifications is deliberately NOT in this PR — the one-click endpoint alone is what Gmail/Yahoo bulk-sender enforcement requires. UI is a follow-up.
  4. PR #99 (audit §9.1) touches lifecycle-email.processor.ts and novu.ts too — if it lands first, this PR's lifecycle filter will rebase cleanly on top of the test-user exclusion clause.

🤖 Generated with Claude Code

Lands the §9.3 compliance + brand tier of `postal/mailAudit.md` as one PR. ## What Six audit items, all in the notifications surface or one hop away: | # | Item | Where it lives | |---|---|---| | 9 | TR title-case at signup + backfill | `normalizeName()` in @sase/shared + better-auth `user.create.before` hook + `scripts/backfill-user-names.ts` | | 10 | Email typo correction at signup | `suggestEmailFix()` in @sase/shared + inline UI hint on register form | | 11 | Strip EN branches (TR-only decision) | All 10 `novu-*.html` templates + Novu Mongo workflow defs + `NovuRecipient.locale` removed | | 12 | DKIM rotated to 2048-bit RSA | Postal `domains.dkim_identifier_string` swapped to `2k260604` + new TXT record at Cloudflare | | 13 | MTA-STS + TLS-RPT | mailtrack CF Worker extended to serve `mta-sts.sase.tr/.well-known/mta-sts.txt` + 2 DNS TXT records | | 14 | Unsubscribe preferences + RFC 8058 one-click | New `email_preferences` table + `UnsubscribeController` + token sign/verify in `novu.ts` + pre-flight skip in `NovuService` and `lifecycle-email.processor` | **EN i18n decision:** user picked strip-EN. All 10 templates collapsed to TR-only (avg ~30 % smaller). EN can come back as a follow-up if/when there is an EN market, with a `users.locale` column + UI switcher. ## Verification snapshot - Postal `raw_headers` ID 157 (post-rotation): `s=postal-2k260604` + 256-byte `b=…` signature. - `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 the expected policy. - 28 unit tests (normalizeName + suggestEmailFix) all green via Node sanity. - Backfill: 210/402 prod users + 72/153 dev users + 71 Novu subscribers canonicalised. ## Sibling changes already applied (NOT in this PR) - Cloudflare worker `mailtrack` redeployed with `mta-sts.sase.tr` custom domain. - Postal MariaDB swapped to the new 2048-bit DKIM key, SMTP container restarted. - New `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 `messagetemplates` updated for all 10 workflows in both Dev + Prod envs. - Coolify env (Prod + Dev): `UNSUBSCRIBE_SECRET` (32-byte hex, distinct per env) + `UNSUBSCRIBE_URL_BASE` (= `https://(dev.)sase.tr/api/email/unsubscribe`). ## Deploy gotchas 1. `pnpm db:generate` to regenerate the drizzle snapshot for migration 0011 (added the journal entry manually because no drizzle-kit on this box). 2. The host-side Novu nodemailer-headers patch at `postal/novu-patches/apply-headers-patch.sh` must be re-run after every Novu container redeploy — Novu OSS v3.15.0 `NodemailerProvider.createMailData` drops `options.headers` before calling `nodemailer.sendMail()` (see audit §9.1 #3). 3. Settings UI page at `/dashboard/settings/notifications` is deliberately NOT in this PR — the one-click endpoint alone is what Gmail/Yahoo bulk-sender enforcement requires. UI is a follow-up. 4. PR #99 (audit §9.1) touches `lifecycle-email.processor.ts` and `novu.ts` too — if it lands first, this PR's lifecycle filter will rebase cleanly on top of the test-user exclusion clause. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
root added 1 commit 2026-06-04 14:29:11 +03:00
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
327d698945
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>
root added 1 commit 2026-06-04 14:51:39 +03:00
feat(notifications): settings UI for per-workflow opt-out (audit §9.3 #14 follow-on)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
f5cd5be933
Lands the user-facing half of the unsubscribe preferences work. The
one-click endpoint already shipped in this PR's main commit; this adds
the proactive self-service path at /dashboard/settings?tab=notifications
so users don't have to wait for a mail to land before tuning their
preferences.

Backend
-------
New EmailPreferencesController at /api/email/preferences:
  GET  → returns one row per OPTIONAL_WORKFLOWS entry, each with current
         optedOut boolean (false when no DB row exists).
  POST → body {workflow, optedOut} flips the row; source='settings_page'
         captured for the audit trail.
Auth+payment workflows are deliberately not exposed — the server's
OPTIONAL_WORKFLOWS set stays the single source of truth.

Frontend
--------
Adds a 'notifications' tab to /dashboard/settings (between 'preferences'
and 'security'). One toggle row per optional workflow with TR copy that
explains what each mail is for. Optimistic update — switch flips
instantly and reverts on failure; PostHog event captures accept/reject.

Static footer note clarifies that auth + payment mail keeps coming
regardless of the switches above (so users don't think they've
unsubscribed from password-reset).

i18n
----
Added settings.tabs.notifications + settings.notifications.{title,
description} to both tr.json and en.json. Body copy is hard-coded TR
(matches audit §9.3 #11 TR-only decision).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
root closed this pull request 2026-06-04 23:42:47 +03:00
root deleted branch fix/audit-9-3-tr-only-mta-sts-names 2026-06-04 23:42:47 +03:00
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

Pull request closed

Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: root/sase.tr#101