Relationships
#1524 Resend newsletter audience sync: contacts, segments, backfill, unsubscribe sync-back
Opened by adam · 8/4/2026
Problem
We send transactional email through Resend, but nothing can send newsletters: podcast-rundown signups sit in Mongo newsletter_subscriber with no send pipeline, account emails aren't in any audience, and the unsubscribe our UI copy and privacy policy promise doesn't exist anywhere in the app.
Proposed solution
Sync both email populations to Resend as contacts (official npm:resend SDK; Resend's current model is one global audience, contacts keyed by email, static segments) with three segments:
- Podcast Rundown — every
newsletter_subscriberrow (podcast gate + campaign forms; same marketing consent). - Swamp Club — every non-banned account email, synced at signup, no verification gate.
- Everyone — the union, so one broadcast reaches both groups without double-sends.
Mechanism (follows existing repo patterns):
- Queue
resend_contact_sync(registered in the OTel queue registry, feed-judge-style watcher viaeveryTick, stale-reclaim, MAX_ATTEMPTS 5, coalescing enqueue on deterministic_id). Messages carry only an identity — email-keyed (subscribe forms) or user-keyed (auth hooks). The consumer recomputes desired segments at delivery and reconciles Resend to them (diff within our three managed segments; never touches other segments and never sendsunsubscribed). Paced under Resend's 10 req/s. - Triggers:
subscribeToNewsletter(podcast unlock + campaign form routes) enqueues email-kind;databaseHooks.user.create/update/delete.afterinlib/auth.tsenqueue user-kind (mirrored inlib/auth_test_utils.ts— the #1475 trap); admin merge enqueues the loser explicitly. - Email-change reconciliation:
update.afteronly carries the new doc, so the sync keeps a per-user shadow of the last-synced email (resend_sync_state). On drain: reconcile current email, and if the shadow differs, re-reconcile the old address — it drops account segments but keeps rundown if independently subscribed.delete.after(receives the deleted doc; verified in better-auth 1.4.18) covers deletion/merge the same way. Ban/unban converges via the same recompute. - Backfill: marker-guarded (
resend_contact_backfill_v1inmigrations, badge-backfill pattern), batched cursors overuser+newsletter_subscriber, enqueues into the same queue; marker withheld on partial failure. Kicked fromstartBackgroundServices(); watcher + backfill gated onRESEND_API_KEY(enqueues always on, so enabling the key later drains history). - Unsubscribe sync-back: Svix-verified
POST /api/resend/webhook(RESEND_WEBHOOK_SECRET) handlescontact.updated/contact.deleted→ newunsubscribedAtfield +setUnsubscribed()on the newsletter repo. Resend stays source of truth for suppression; our stamp is the Art. 7(3)-style withdrawal record the repo comments already name.
Segments auto-provision by name with ids persisted in resend_sync_state ($setOnInsert to converge pod races).
Alternatives considered
- Raw fetch like
resend-mailer.ts— SDK preferred now that it covers the segments API; SDK confined tolib/infrastructure/. - Emit-time segment decisions on the queue doc — rejected for recompute-at-delivery (announce-routing precedent): coalescible, ordering-free, self-healing against dashboard drift.
- Resend bulk CSV import for backfill — rejected; one queue path for backfill + steady state, and imports skip
contact.createdwebhooks.
Out of scope
Broadcast sending itself; double opt-in; cleanup of contacts for emails changed before this ships.
Closed
No activity in this phase yet.
adam commented 8/4/2026, 11:02:48 PM
Implementation up: https://github.com/swamp-club/swamp-club/pull/1038 — includes retry backoff on the sync queue (30s×2^attempt, 15m cap) beyond the original plan, so multi-replica 429 bursts during the backfill drain can't park contacts. Post-deploy manual step: register https://swamp-club.com/api/resend/webhook (contact.updated + contact.deleted) in the Resend dashboard and set RESEND_WEBHOOK_SECRET.
keeb commented 8/12/2026, 3:54:06 PM
Shipped — closing.
The full design in this issue is in the tree and documented in CLAUDE.md:
- Queue —
resend_contact_sync, drained bylib/app/resend-contact-sync.ts, with the watcher inlib/infrastructure/resend-contact-queue-watcher.ts. - Three managed segments —
lib/infrastructure/resend-marketing-contacts.ts:22mapsswamp-club→ "Swamp Club" andpodcast-rundown→ "Podcast Rundown", plus the union. Segment ids resolve lazily and the reconcile touches only the managed three, so dashboard-curated segments survive. - Backfill —
lib/app/backfill-resend-contacts.ts, marker-gated, started fromrepos.tsonly whenRESEND_API_KEYis set, so enabling the key later drains accumulated history. - Unsubscribe sync-back —
routes/api/resend/webhook.ts, Svix-verified, mirroring provider-side unsubscribes ontonewsletter_subscriber.unsubscribedAt. The drain never writesunsubscribeditself; Resend owns suppression. - Email-change handling — the per-operative shadow in
lib/infrastructure/resend-sync-state.tsre-reconciles the old address instead of stranding it.
Thanks for the design write-up — the implementation follows it closely.
Sign in to post a ripple.