Skip to content

Resend broadcasts: segment sync, one-call mass email, batch sends, contact pruning - #273

Merged
gregv merged 1 commit into
developfrom
feat/resend-broadcasts
Aug 30, 2026
Merged

Resend broadcasts: segment sync, one-call mass email, batch sends, contact pruning#273
gregv merged 1 commit into
developfrom
feat/resend-broadcasts

Conversation

@gregv

@gregv gregv commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Summary

New api/broadcasts/ blueprint + services/broadcasts_service.py powering the admin Email tab (frontend PR: opportunity-hack/frontend-ohack.dev — "Admin Email tab" branch feat/admin-email-broadcasts). Replaces one-HTTP-request-per-recipient mass emails with Resend segments + broadcasts, and adds a Batch path for personalized sends.

Routes (all volunteer.admin-gated, under /api/admin/broadcasts):

  • GET segments / POST preview (dry-run per-source counts) / POST segments/sync + GET segments/<id>/sync-status
  • GET|POST '' (list / create broadcast — draft by default, send:true/scheduled_at), GET <id>, POST <id>/send
  • POST batch-sendresend.Batch.send in chunks of 100 on the transactional quota
  • GET contacts / POST contacts/prune + GET contacts/prune-status — quota reclaim (we're at 2,154/1,000 marketing contacts)

Recipient sources (ported from scripts/sync_resend_audience.py): registered users, volunteers (type/event/approved-only), leads, Slack members (activity-windowed; deleted/disabled always excluded), and contact_submissions filtered by inquiryType (+ optional receiveUpdates opt-in-only).

Load-bearing details

  • Segment sync + contact prune run as daemon threads with redis status/lock (stall detection, idempotent retries). Contact sync is create-only — never re-subscribes an unsubscribed contact. Prune deletes GLOBAL contacts (what actually frees marketing quota).
  • Broadcast HTML gets {{{RESEND_UNSUBSCRIBE_URL}}} enforced server-side; from-address is allowlisted via RESEND_BROADCAST_FROM(_DOMAINS) defaulting to notify.ohack.dev (notifs.ohack.org verification is partially_failed).
  • Segment/contact/broadcast ops require the full-access RESEND_API_KEY (no send-only-key fallback — it 401s).
  • userlist() (common/utils/slack.py) is now redis-cached 10 min above the rate limiter; clear_slack_cache() clears the new prefix.
  • Everything is ENVIRONMENT=test-gated (simulated: true) and send_slack_audited.

New env vars: RESEND_BROADCAST_FROM, RESEND_BROADCAST_FROM_DOMAINS, RESEND_MARKETING_CONTACT_LIMIT (all have safe defaults).

Test plan

  • api/broadcasts/tests/test_broadcasts_service.py — 36 cases (loaders/dedupe, sync lock/stall/simulation, from-address allowlist, unsubscribe footer, batch-send ordering/QR rejection, prune modes) — 49 passed incl. slack tests
  • Manual: sync own email into a throwaway segment, draft + send a broadcast, verify unsubscribe link, run "delete unsubscribed" prune

🤖 Generated with Claude Code

…ls, contact pruning

- New api/broadcasts/ blueprint + services/broadcasts_service.py (all routes
  volunteer.admin-gated), porting scripts/sync_resend_audience.py loaders:
  profiles, volunteers (type/event/selected), leads, Slack members, and
  contact_submissions filtered by inquiryType (+ receiveUpdates opt-in)
- Segment sync runs as a daemon thread with redis status/lock (idempotent,
  create-only — never re-subscribes unsubscribed contacts); preview endpoint
  returns per-source counts + a contact-quota guardrail
  (RESEND_MARKETING_CONTACT_LIMIT, default 1000 = current free tier)
- Broadcast create/send with server-enforced unsubscribe footer and a
  from-address allowlist (RESEND_BROADCAST_FROM / _FROM_DOMAINS, defaulting
  to notify.ohack.dev — notifs.ohack.org verification is partially failed)
- POST /admin/broadcasts/batch-send: pre-personalized messages via
  resend.Batch.send in chunks of 100 (transactional quota), replacing the
  frontend's one-request-per-recipient loop; QR messages rejected per-recipient
- Contact management for quota reclaim: full inventory endpoint + background
  prune job (modes: unsubscribed / emails / all) deleting GLOBAL contacts
- userlist() in common/utils/slack.py is now redis-cached (10 min) above the
  rate limiter so varying active_days filters reuse one crawl;
  clear_slack_cache() clears the new prefix
- Segment/contact/broadcast ops require RESEND_API_KEY (no send-only-key
  fallback); everything is ENVIRONMENT=test-gated and Slack-audited
- Tests: api/broadcasts/tests/test_broadcasts_service.py (36 cases)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@gregv
gregv merged commit e9d921d into develop Aug 30, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant