Files
sase.tr/apps/api/src/notifications/email-preferences.service.ts
Claude (audit §9.3) 327d698945
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
feat(notifications): TR-only templates + name canonicalisation + MTA-STS + 2048-bit DKIM + unsubscribe (audit §9.3)
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>
2026-06-04 14:28:39 +03:00

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),
),
);
}
}