Skip to main content
← Back to list
01Issue
FeatureOpenSwamp Club
Assigneesadam

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_subscriber row (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):

  1. Queue resend_contact_sync (registered in the OTel queue registry, feed-judge-style watcher via everyTick, 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 sends unsubscribed). Paced under Resend's 10 req/s.
  2. Triggers: subscribeToNewsletter (podcast unlock + campaign form routes) enqueues email-kind; databaseHooks.user.create/update/delete.after in lib/auth.ts enqueue user-kind (mirrored in lib/auth_test_utils.ts — the #1475 trap); admin merge enqueues the loser explicitly.
  3. Email-change reconciliation: update.after only 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.
  4. Backfill: marker-guarded (resend_contact_backfill_v1 in migrations, badge-backfill pattern), batched cursors over user + newsletter_subscriber, enqueues into the same queue; marker withheld on partial failure. Kicked from startBackgroundServices(); watcher + backfill gated on RESEND_API_KEY (enqueues always on, so enabling the key later drains history).
  5. Unsubscribe sync-back: Svix-verified POST /api/resend/webhook (RESEND_WEBHOOK_SECRET) handles contact.updated/contact.deleted → new unsubscribedAt field + 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 to lib/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.created webhooks.

Out of scope

Broadcast sending itself; double opt-in; cleanup of contacts for emails changed before this ships.

02Bog Flow
OPENTRIAGEDIN PROGRESSSHIPPED

Open

8/4/2026, 8:15:10 PM

No activity in this phase yet.

03Sludge Pulse
adam assigned adam8/4/2026, 8:16:34 PM
Editable. Press Enter to edit.

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.

Sign in to post a ripple.