Files
sase.tr/scripts/backfill-lifecycle-sent.sql
Claude (audit §9.4) bdbdd07566
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
feat(notifications): operability tier — send_limit + open-pixel + signed-URL exp + retention + sent-flag (audit §9.4)
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>
2026-06-04 17:04:17 +03:00

27 lines
3.4 KiB
SQL

-- Historical seed (generated 2026-06-04 from postal-server-1)
-- Run AFTER migration 0012, BEFORE tomorrow's lifecycle cron.
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('a988f621-f3b4-4086-aee8-38583bc0aaca', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('107ed4f1-2bb1-4730-9f71-1a33c4d75504', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('664cd1fa-4ce3-4f2b-9184-bbaca0f8b020', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('d03d711a-6235-4f45-ae80-65bf0b815ce0', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('844a47b8-640d-42a3-910c-68a23e14a8f6', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('6e4c13b2-1e35-4e9c-a737-55dd6075b130', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('ee0b8dfa-ae02-41b8-9886-3247a0a2c471', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('08baff23-9754-4762-a665-ce8251b81333', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('9d966aa6-8033-4c67-98bb-e84c00c68157', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('9faea025-6ed2-4897-a503-7d5697d6ab7e', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('2f70a2b6-e3df-4419-b688-7488a1a69515', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('9112fa82-61ee-4b89-b815-e50abb78544f', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('9c41b4c4-3b67-401d-9563-ea9d97763513', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('16ed8cb3-9bdf-4918-a1c0-253008d112e0', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('0e69e85f-3cfb-4d2e-abec-de086bd6e592', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('fd469c94-377a-4148-a280-a1cf361b7b6d', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('419b51f1-14c8-4ef0-a221-f6ffff93f6b1', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('fa2bd88e-c256-4a7f-8ee6-51e06a785c7a', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('5b1eda7f-e7bd-4238-9964-9d3707eb2e39', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('e457416c-f406-47ed-8e23-4a832c2fc965', 'win-back') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('89607516-7061-432e-bd40-971ccbf4e02b', 'win-back') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('15390f23-6d87-4be1-a644-df7602ee58bc', 'win-back') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('21191b23-b327-4239-a0fc-18974b97e35c', 'win-back') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('83d9628a-faa5-40d3-a75c-8ae12629ecdf', 'win-back') ON CONFLICT DO NOTHING;