fix/audit-9-4-operability #104
3 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
bdbdd07566 |
feat(notifications): operability tier — send_limit + open-pixel + signed-URL exp + retention + sent-flag (audit §9.4)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Lands the §9.4 operability tier of postal/mailAudit.md as one PR on top of the §9.3 stack (PR #101). Seven items, all independent of each other but sharing the same notifications surface. #15 Postal send_limit fuse — set per-hour cap (already applied LIVE in DB: servers.send_limit = 500). A runaway loop now hits Postal's own throttle long before flooding recipient ISPs into a rate-limit penalty. #16 noreply@sase.tr decommission — change default fromAddress in both email.service.ts and config/configuration.ts to destek@sase.tr. `noreply@` had no inbound route so user replies bounced; `destek@` lands in the SnappyMail destek inbox. Overridable via POSTAL_FROM_ADDRESS env per workflow that genuinely shouldn't accept replies. #21 Welcome CTA fallback — flip the {{else}} branch in novu-welcome.html from https://sase.tr to https://sase.tr/dashboard (the actual onboarding entry, not the marketing page). Already pushed live to Novu Mongo too. #17 Open-pixel embed — new buildTrackPixelUrl() in novu.ts; injected trackPixel payload into welcome/trial-ending/win-back/referral×3/ payment×2 NovuService methods + lifecycle-email.processor. Auth flows (email-verification, password-reset) deliberately skip the pixel. Templates updated with {{#if trackPixel}}<img ...>{{/if}} just before the footer; 8 templates touched, 2 (auth) skipped. Novu Mongo updated. #18 Signed-URL exp / replay-resistance — track.sase.tr Worker /c endpoint now expects `e=<unix-ms>` + `s=HMAC(MID|TARGET|EXP)`. Expired signatures return 410. Legacy signatures (no `e=`) still accepted while in-flight mail with old links drains; remove that branch ~30 days post-deploy. buildTrackedUrl() now mints exp=now+30d. #19 D1 retention cron — Cloudflare Cron Trigger added to mailtrack worker (`17 4 * * *` UTC, after Europe/Istanbul cron settles). `scheduled` handler DELETEs events older than RETENTION_DAYS (default 90). Both code and the cron schedule are LIVE on the production worker. #20 Lifecycle sent-flag idempotency — new `lifecycle_email_sent` table (migration 0012) keyed (user_id, workflow). Replaces the 1-day endDate window's at-most-once trick that lost cohorts on skipped days. Cron now LEFT JOINs and writes the row immediately after each successful trigger. Historical seed in scripts/backfill-lifecycle-sent.sql (19 trial-ending + 5 win-back users — generated from postal-server-1.messages) so the first post-deploy cron doesn't re-send to users we already mailed. ## Live infrastructure (deploy-independent) - Postal MariaDB: `UPDATE servers SET send_limit = 500`. - Cloudflare Worker mailtrack redeployed with new /c logic + scheduled handler. - Cloudflare Worker: cron `17 4 * * *` registered on production env. - Cloudflare Worker: RETENTION_DAYS=90 plain_text binding. - Novu Mongo: 16 messagetemplates updated with pixel + Welcome /dashboard. ## Companion deploy steps post-merge 1. `pnpm db:generate` to refresh drizzle snapshots for 0011 + 0012. 2. Run `scripts/backfill-lifecycle-sent.sql` against prod + dev BEFORE the first cron tick post-deploy. 3. Apply host-side novu-patches/apply-headers-patch.sh again if Novu container rolled (idempotent). ## Verification curl /c?…e=<future>… ⇒ 302 new-style signature accepted curl /c?…(no e)… ⇒ 302 legacy signature still accepted (drain) curl /c?…e=<past>… ⇒ 410 expired signature rejected curl /c?…s=bad… ⇒ 403 bad signature rejected CF API schedules ⇒ `17 4 * * *` live on mailtrack worker. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
f5cd5be933 |
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
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>
|
||
|
|
327d698945 |
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
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> |