feat(notifications): operability tier (audit §9.4) — send_limit + open-pixel + signed-URL exp + retention + sent-flag #102

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

Stacked on top of PR #101 (§9.3). Auto-rebases to main once that merges.

§9.4 — operability tier

# Item Where it lives
15 Postal send_limit fuse UPDATE servers SET send_limit = 500 (live in DB)
16 noreply@ decommission email.service.ts + config/configuration.ts defaults flipped to destek@sase.tr
17 Open-pixel embed New buildTrackPixelUrl() + 8 templates with {{#if trackPixel}}<img>{{/if}} + Novu Mongo updated
18 Signed-URL exp track.sase.tr Worker /c accepts e=<exp> + s=HMAC(mid|target|exp); expired → 410; legacy still accepted for drain
19 D1 retention cron Cloudflare Cron Trigger 17 4 * * * on mailtrack worker; daily DELETE events WHERE ts < now - 90d
20 Lifecycle sent-flag idempotency New lifecycle_email_sent table (migration 0012) keyed (user_id, workflow); LEFT JOIN ... IS NULL in cron + INSERT after each trigger
21 Welcome CTA fallback https://sase.trhttps://sase.tr/dashboard (live in Novu Mongo too)

Live changes (deploy-independent, in effect now)

  • Postal MariaDB: servers.send_limit = 500 (per-hour cap, ≈ 12k/day).
  • Cloudflare Worker mailtrack redeployed: new /c validation + scheduled retention handler.
  • Cloudflare Cron Trigger: 17 4 * * * UTC registered on the production worker (verified via /accounts/.../workers/scripts/mailtrack/schedules).
  • Cloudflare Worker var RETENTION_DAYS = 90.
  • Novu Mongo: 16 messagetemplates updated (8 workflows × 2 envs) with trackPixel block; Welcome CTA fallback corrected.

Verification snapshot

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)

Post-merge deploy steps

  1. pnpm db:generate to refresh drizzle snapshots for migrations 0011 + 0012.
  2. Run scripts/backfill-lifecycle-sent.sql against prod + dev before the next cron tick (09:00 Europe/Istanbul) so the first run after deploy doesn't re-send to the 24 historical recipients we already mailed.
  3. Re-run postal/novu-patches/apply-headers-patch.sh if Novu container rolled (idempotent).
  4. ~30 days post-deploy, remove the legacy-signature branch in the worker (the if (!ok) fallback in src/index.js) — all in-flight pre-#18 mail will have aged out by then.

🤖 Generated with Claude Code

Stacked on top of PR #101 (§9.3). Auto-rebases to `main` once that merges. ## §9.4 — operability tier | # | Item | Where it lives | |---|---|---| | 15 | Postal send_limit fuse | `UPDATE servers SET send_limit = 500` (live in DB) | | 16 | noreply@ decommission | `email.service.ts` + `config/configuration.ts` defaults flipped to `destek@sase.tr` | | 17 | Open-pixel embed | New `buildTrackPixelUrl()` + 8 templates with `{{#if trackPixel}}<img>{{/if}}` + Novu Mongo updated | | 18 | Signed-URL exp | track.sase.tr Worker `/c` accepts `e=<exp>` + `s=HMAC(mid\|target\|exp)`; expired → 410; legacy still accepted for drain | | 19 | D1 retention cron | Cloudflare Cron Trigger `17 4 * * *` on mailtrack worker; daily `DELETE events WHERE ts < now - 90d` | | 20 | Lifecycle sent-flag idempotency | New `lifecycle_email_sent` table (migration 0012) keyed `(user_id, workflow)`; LEFT JOIN ... IS NULL in cron + INSERT after each trigger | | 21 | Welcome CTA fallback | `https://sase.tr` → `https://sase.tr/dashboard` (live in Novu Mongo too) | ## Live changes (deploy-independent, in effect now) - Postal MariaDB: `servers.send_limit = 500` (per-hour cap, ≈ 12k/day). - Cloudflare Worker `mailtrack` redeployed: new `/c` validation + `scheduled` retention handler. - Cloudflare Cron Trigger: `17 4 * * *` UTC registered on the production worker (verified via `/accounts/.../workers/scripts/mailtrack/schedules`). - Cloudflare Worker var `RETENTION_DAYS = 90`. - Novu Mongo: 16 messagetemplates updated (8 workflows × 2 envs) with `trackPixel` block; Welcome CTA fallback corrected. ## Verification snapshot ``` 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) ``` ## Post-merge deploy steps 1. `pnpm db:generate` to refresh drizzle snapshots for migrations 0011 + 0012. 2. Run `scripts/backfill-lifecycle-sent.sql` against prod + dev **before** the next cron tick (09:00 Europe/Istanbul) so the first run after deploy doesn't re-send to the 24 historical recipients we already mailed. 3. Re-run `postal/novu-patches/apply-headers-patch.sh` if Novu container rolled (idempotent). 4. ~30 days post-deploy, remove the legacy-signature branch in the worker (the `if (!ok)` fallback in src/index.js) — all in-flight pre-#18 mail will have aged out by then. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
root added 1 commit 2026-06-04 17:04:49 +03:00
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
bdbdd07566
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>
root closed this pull request 2026-06-04 23:39:21 +03:00
root deleted branch fix/audit-9-4-operability 2026-06-04 23:39:22 +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#102