feat(convex/broadcast): audience-export pipeline for PRO launch#3431
Conversation
Stacked on feat/customers-normalized-email-index — needs that branch's
customers.normalizedEmail field + index + backfill before this action
can produce a clean dedup.
Adds:
- convex/broadcast/audienceExport.ts: paginated internalAction plus
three internalQueries that build the deduped audience and push
contacts to a Resend Audience via the Contacts API.
Dedup formula: registrations − emailSuppressions − customers. Any
user who's been through Dodo checkout (active, cancelled, expired)
is excluded — pitching paid users a "buy PRO!" email is the worst-
case failure mode the launch is trying to avoid.
Idempotent on re-run: Resend's 422 already_exists response is counted
as `alreadyExists`, not `failed`. Cursor-paginated for safe resume
mid-export. Dry-run mode (counts only, no Resend calls) for verifying
exclusion math before the first real send.
Codegen catch-up:
- _generated/api.d.ts updated to declare broadcast/audienceExport
(this file cross-references its own siblings via internal.* and
needs the typed reference).
- Also catches up payments/backfillCustomerNormalizedEmail from the
base PR — that file didn't cross-reference so its CI passed
without the codegen update; adding now so future calls into it
resolve correctly.
- Convex regenerates identically on deploy — no drift risk.
Operational:
npx convex run broadcast/audienceExport:exportProLaunchAudience \
'{"audienceId":"aud_xxx"}'
# Loop, passing continueCursor from each response, until isDone:true
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR introduces
Confidence Score: 3/5Not safe to merge as-is — the 422 conflation can silently mis-report export failures as successes, giving operators incorrect data before the launch send. One P1 logic bug (422 conflation) directly affects the correctness of the export stats that operators rely on to verify the launch is clean. The dedup logic itself is sound, but the Resend error handling needs fixing before production use. convex/broadcast/audienceExport.ts — specifically the 409/422 error handling block and the console.error PII log.
|
| Filename | Overview |
|---|---|
| convex/broadcast/audienceExport.ts | New internal action + 3 queries implementing the PRO-launch audience export pipeline; P1 issue with 422 error conflation, plus P2 concerns on PII logging, missing User-Agent, and dry-run counter semantics. |
| convex/_generated/api.d.ts | Codegen catch-up adding type declarations for broadcast/audienceExport and payments/backfillCustomerNormalizedEmail; mechanical and correct. |
Sequence Diagram
sequenceDiagram
participant CLI as CLI (npx convex run)
participant Action as exportProLaunchAudience
participant Q1 as getSuppressedEmails
participant Q2 as getPaidEmails
participant Q3 as getRegistrationsPage
participant Resend as Resend API
CLI->>Action: audienceId, cursor?, numItems?, dryRun?
Action->>Q1: runQuery getSuppressedEmails
Action->>Q2: runQuery getPaidEmails
Q1-->>Action: suppressed[]
Q2-->>Action: paid[]
Action->>Q3: runQuery getRegistrationsPage(cursor, numItems)
Q3-->>Action: page{page[], continueCursor, isDone}
loop for each registration in page
alt email empty
Action->>Action: emptyEmail++
else email in suppressedSet
Action->>Action: suppressedSkipped++
else email in paidSet
Action->>Action: paidSkipped++
else dryRun
Action->>Action: upserted++ (dry)
else live
Action->>Resend: POST /audiences/{id}/contacts
alt 200 OK
Resend-->>Action: success → upserted++
else 409 or 422
Resend-->>Action: conflict → alreadyExists++
else other error
Resend-->>Action: error → failed++
end
end
end
Action-->>CLI: ExportStats{upserted, skipped counts, isDone, continueCursor}
Reviews (1): Last reviewed commit: "feat(convex/broadcast): audience-export ..." | Re-trigger Greptile
| } else if (res.status === 409 || res.status === 422) { | ||
| // Resend returns 422 with `name: "validation_error"` and message | ||
| // mentioning duplicate when the email is already in the audience. | ||
| // Treat as already-imported, not a failure. | ||
| stats.alreadyExists++; |
There was a problem hiding this comment.
422 conflates duplicate-already-exists with genuine validation errors
Both 409 and 422 are counted as alreadyExists, but Resend uses 422 for any validation failure — not just duplicate contacts. A malformed email address, a missing required field, or a payload schema mismatch would also return 422 yet be silently counted as alreadyExists instead of failed. When the export finishes, the operator would see a healthy-looking alreadyExists count while those emails were never actually added to the audience.
The body should be parsed to confirm the error is specifically already_exists before routing to that counter.
| const res = await fetch( | ||
| `${RESEND_API_BASE}/audiences/${encodeURIComponent(audienceId)}/contacts`, | ||
| { | ||
| method: "POST", | ||
| headers: { | ||
| "Content-Type": "application/json", | ||
| Authorization: `Bearer ${apiKey}`, | ||
| }, | ||
| body: JSON.stringify({ email, unsubscribed: false }), | ||
| }, | ||
| ); |
There was a problem hiding this comment.
Missing
User-Agent header on Resend fetch
AGENTS.md requires "Always include User-Agent header in server-side fetch calls," but the Resend contacts POST omits it. Add a descriptive User-Agent to the headers object alongside Content-Type and Authorization.
Context Used: AGENTS.md (source)
| stats.failed++; | ||
| const body = await res.text().catch(() => "<no body>"); | ||
| console.error( | ||
| `[exportProLaunchAudience] Resend ${res.status} for ${email}: ${body}`, | ||
| ); | ||
| } |
There was a problem hiding this comment.
Raw email PII logged in error path
Line 199 emits for ${email}: ${body} into Convex's dashboard logs. Every Resend error (transient 5xx, network timeout, etc.) will write the contact's email address to the observable log stream. Consider logging only the HTTP status and an anonymised hash or redacted placeholder instead of the raw address.
| if (dry) { | ||
| stats.upserted++; | ||
| continue; | ||
| } |
There was a problem hiding this comment.
upserted counter semantics differ between dry-run and live run
In dry-run mode, stats.upserted++ is incremented for every email that passes the dedup filters, but in the live path upserted only increments on a successful 200 OK from Resend. The same field name therefore means two different things depending on dryRun. An operator comparing dry-run vs. live totals to validate the dedup math will see a meaningful discrepancy if any Resend calls fail or return 409/422. Consider using a dedicated wouldUpsert counter for dry mode, or at minimum calling this out clearly in the returned stats key.
All three findings valid; verified line numbers match commit 7a440c1 and the Resend API claim against current docs at https://resend.com/docs/api-reference/contacts/create-contact. P1 #1 — Paid-customer exclusion fails open when normalizedEmail missing: `getPaidEmails` now derives the join key from `customers.email` (the existing `email.trim().toLowerCase()` convention) when `normalizedEmail` is unset. The backfill becomes a perf optimization — a missed/incomplete run can no longer silently leak paid users into the launch audience. P1 #2 — Wrong Resend endpoint: Switched from `POST /audiences/{id}/contacts` (legacy, may still resolve but no longer the canonical 2026 path) to `POST /contacts` with `segments: [{ id: segmentId }]` in the body, per current Resend docs. Renamed the action arg from `audienceId` to `segmentId` to match Resend's renaming of Audiences → Segments. Updated JSDoc usage examples. P1 #3 — 409/422 catch-all masking validation errors: Added `isDuplicateContactError()` helper that parses the 422 body and matches on `name` (`*_already_exists` / `*_duplicate`) and `message` (`/already (exists|in)|duplicate/`). Only duplicate-shaped responses increment `alreadyExists`; everything else (missing segment, invalid email, unauthorized field, etc.) increments `failed` and logs the parsed body. Also dropped the speculative 409 branch — Resend doesn't use 409 for contacts; 422 is the real status.
|
All three P1s addressed in ebf2bf8. Verified each citation against the actual file at commit 7a440c1, and verified the Resend API claim against the current docs at https://resend.com/docs/api-reference/contacts/create-contact before changing the endpoint. P1 #1 — Paid-customer exclusion failing open when P1 #2 — Legacy audience-scoped endpoint: Switched from P1 #3 — 409/422 catch-all masking validation errors: Added |
Round-2 review finding: previous fix counted POST /contacts 422 duplicates
as alreadyExists without verifying the contact ended up in OUR segment.
Resend's global-Contacts model means a 422 duplicate could mean the
contact exists in a different segment or no segment at all — and the
`segments` field on the duplicate path is not applied — so existing
global contacts could be silently OMITTED from the launch segment while
the export reports success.
New `upsertContactToSegment(apiKey, email, segmentId)` helper does a
deterministic two-step:
1. POST /contacts with `segments: [{ id }]`. Brand-new globally →
creates and assigns in one call → outcome=created.
2. If step 1 returns a duplicate-shaped 422, the contact exists
globally. Disambiguate with POST /contacts/{email}/segments/{id}:
- 2xx → was global-only or in another segment, now linked here
→ outcome=linkedExisting
- 422 duplicate-shaped → was already in this segment
→ outcome=alreadyInSegment
- anything else → outcome=failed
Stats updated:
- `upserted` now counts BOTH `created` AND `linkedExisting` (anything
that ended up in the segment via this call).
- `linkedExisting` added as a separate diagnostic counter — operator
can compare to verify how many were pre-existing global contacts.
- `alreadyExists` now strictly means "was already in this segment
before this call" — never inferred from a global-duplicate response
without segment-membership verification.
|
Valid catch — addressed in f6c079c. The previous fix counted New
Stats reshaped:
Net diff: +120/−39 on one file. Typecheck clean. |
Stale: P1 "422 conflates duplicate with validation errors" was cited on commit 7a440c1; addressed in ebf2bf8 — replied as resolved. Three valid P2s on current code, all fixed: P2 — Missing User-Agent header on Resend fetches: AGENTS.md:185 requires User-Agent on every server-side fetch. Added USER_AGENT constant and applied to both /contacts and /contacts/{email}/segments/{id} POSTs. P2 — Raw email PII written to Convex dashboard logs: The action's failure-path console.error interpolated `${email}` into log lines that anyone with project access can read. Added maskEmail() helper (`[email protected]` → `jo******@example.com`) and applied to the failure-path log. P2 — `upserted` semantics differ between dry-run and live: The previous draft incremented stats.upserted in BOTH dry-run (every email passing dedup) and live (only successful Resend calls). Operators comparing dry-run to live totals to validate dedup math would see a spurious discrepancy on any failure / linkedExisting / alreadyInSegment. Reshaped ExportStats: - Dry-run-only: `wouldUpsertAfterDedup` — count of emails that pass the dedup filters and would be attempted on a live run. - Live-only: `upserted`, `linkedExisting`, `alreadyExists`, `failed` — strictly result of Resend interactions; all zero in dry-run mode. - Shared (dedup-only): `suppressedSkipped`, `paidSkipped`, `emptyEmail`. Operator can now compare dry-run `wouldUpsertAfterDedup` to live `upserted + alreadyExists` and any divergence is a real Resend issue, not a counter-semantics artefact.
|
Round 3 — addressed in cf091ee. Stale (no action): the P1 "422 conflates duplicate with validation errors" was cited on commit Three valid P2s, all fixed: Missing Raw email PII in Convex dashboard logs: Added
Operators can now compare dry-run's Net diff: +46/−8 on one file. Typecheck clean. |
…ed content (#3434) * feat(convex/broadcast): PRO-launch broadcast trigger + metrics + locked content Third PR in the WorldMonitor PRO-launch broadcast chain. Builds on the audience-export pipeline (#3431, merged) to deliver the actual send + canary kill-gate observability. Adds: - convex/schema.ts: new `broadcastEvents` table with `by_webhookEventId` (idempotency) and `by_broadcast_event` (aggregation) indexes. Stores no recipient email — Convex dashboard logs are observable; we rely on Resend's `email_id` for traceability via their dashboard. - convex/broadcast/proLaunchEmailContent.ts: source-of-truth locked email content (subject, from, reply-to, plain-text fallback, HTML, CAN-SPAM physical-address footer placeholder). Copy was finalised with Elie 2026-04-26; future re-sends edit this file. - convex/broadcast/metrics.ts: - `recordBroadcastEvent` (internalMutation): idempotent on svix-id, drops untracked event types defensively. - `getBroadcastStats` (internalQuery): live aggregate per broadcast with bounce / complaint / open / click rates and `bouncesOver- Threshold` / `complaintsOverThreshold` flags driven by the project plan's kill-gates (>4% bounce, >0.08% complaint). - convex/broadcast/sendBroadcast.ts: - `createProLaunchBroadcast` (internalAction): POST /broadcasts to Resend with the locked content + given segment_id. Returns the Resend broadcast id. - `sendProLaunchBroadcast` (internalAction): POST /broadcasts/{id}/send, optional `scheduled_at` ISO timestamp for time-of-day scheduling. - convex/resendWebhookHandler.ts: extended to also forward any tracked event with a `broadcast_id` to `recordBroadcastEvent` BEFORE the existing suppression handling. Metrics record failure does NOT 500 the webhook — suppression is the more important path and Resend retries would amplify the problem. Codegen catch-up: - _generated/api.d.ts: declare broadcast/metrics, broadcast/sendBroadcast, broadcast/proLaunchEmailContent. Convex regenerates identically on next deploy — no drift risk. Operational sequence (after merge + deploy): # Pre-flight (one-time) - Configure Dodo coupon EARLYWM30 (30% off, 30-day expiry, usage cap) - Set up `[email protected]` sender on Resend domain - Manually create canary + main Resend Segments in dashboard - Run `payments/backfillCustomerNormalizedEmail:backfill` until done # Populate the audience (uses #3431's action) npx convex run broadcast/audienceExport:exportProLaunchAudience \ '{"segmentId":"<canary>","dryRun":true}' # verify dedup npx convex run broadcast/audienceExport:exportProLaunchAudience \ '{"segmentId":"<canary>"}' # populate canary first npx convex run broadcast/audienceExport:exportProLaunchAudience \ '{"segmentId":"<main>"}' # then the rest # Fire the canary npx convex run broadcast/sendBroadcast:createProLaunchBroadcast \ '{"segmentId":"<canary>","nameSuffix":"Canary 250"}' # → broadcastId npx convex run broadcast/sendBroadcast:sendProLaunchBroadcast \ '{"broadcastId":"<id>"}' # Watch the kill-gates npx convex run broadcast/metrics:getBroadcastStats \ '{"broadcastId":"<id>"}' # poll until clean or threshold trips # Repeat for main segment if canary clean Out of scope (deferred): - Programmatic segment splitting (canary-vs-main from a single source). For first send, segments are split manually in Resend dashboard. - Live monitoring loop / dashboard. - Send-time RFC 8058 List-Unsubscribe-Post header verification — must be checked by inspecting raw headers on a test send before canary. * copy(proLaunch): add 50K GitHub stars beat to the build-pace line Repo just crossed 50K stars (verified: 52,668 at time of commit). Combining with the existing "more than half didn't exist 45 days ago" sentence makes the build-pace claim carry two proof points (code volume + community signal) in one beat instead of standing alone. Updated both the plain-text and HTML versions of the locked launch content. Bare https://github.com/koala73/worldmonitor URL in plain text, hyperlinked "open-source repo" in HTML. * fix(broadcast): address PR review (P1×3) Three P1s on PR #3434, all valid: P1 — `rawPayload: event.data` leaks recipient email PII: The schema's "no recipient email stored" claim was false — Resend's `event.data` includes `to: string[]`, `from`, `subject`. Anyone with Convex dashboard access could enumerate the entire waitlist. Fix: removed `rawPayload` from the `broadcastEvents` table entirely. Identifier metadata (webhookEventId, broadcastId, emailMessageId, eventType, occurredAt) is enough for canary metrics and Resend dashboard cross-reference. Updated the schema comment + handler to not pass event.data through. P1 — Placeholder physical address would ship if not replaced: `createProLaunchBroadcast` reads `PRO_LAUNCH_PHYSICAL_ADDRESS` and sends it to Resend verbatim. One missed manual step → CAN-SPAM non-compliant launch email. Fix: hard-throw at the top of `createProLaunchBroadcast` if the constant still contains "TBD" or "placeholder". Operator must edit `proLaunchEmailContent.ts` with a real address before any send. P1 — `getBroadcastStats` `.collect()` would fail on main-segment scale: Counting events per type via `.collect()` on `broadcastEvents` exceeds Convex's 16,384-doc per-query read limit the moment a 30k+ recipient send accumulates `email.delivered` events. Stats query breaks → main-send monitoring breaks → kill-gates can't be evaluated. Fix: pre-aggregate via a new `broadcastEventCounts` table, one row per (broadcastId, eventType). `recordBroadcastEvent` upserts the counter on every successful (idempotent) insert, gated on insert-success so duplicate webhook deliveries can't inflate the count. `getBroadcastStats` now reads N counts (N = tracked event types = 8) — constant time regardless of broadcast size. Schema deltas: - broadcastEvents: dropped `rawPayload: v.any()` field - broadcastEventCounts: new table with `by_broadcast_event` index * fix(resendWebhookHandler): drop redundant svixId guard Greptile P2 round 4: verifySignature already returns false if svix-id is absent, and the handler 401s above on invalid signature. The post-verify if(svixId) was dead code — would never fire, but if it ever did, it silently swallowed the broadcast event. Replaced with a non-null assertion + comment explaining the invariant.
Why
Second PR in the WorldMonitor PRO launch broadcast chain. Builds on the
customers.normalizedEmailindex landed in #3424 (now on main).Goal: build the deduped waitlist audience and push contacts to a Resend Audience for one-shot launch broadcasting.
Dedup formula
Any user who's been through Dodo checkout (active, cancelled, expired — all statuses) is excluded. Pitching paid users a "buy PRO!" email is the worst-case failure mode of the entire launch and the reason this PR exists.
What this adds
convex/broadcast/audienceExport.ts— three internal queries + one internal action:getSuppressedEmails— snapshot ofemailSuppressions.normalizedEmail(all bounces, complaints, manual)getPaidEmails— snapshot ofcustomers.normalizedEmail(anyone who paid at any point)getRegistrationsPage— cursor-paginated page ofregistrationsrowsexportProLaunchAudience— orchestrates: materializes the two exclusion sets, paginates registrations, posts each non-excluded email to Resend'sPOST /audiences/{id}/contactsAction behavior
already_existsis counted asalreadyExists, notfailed. Re-running the same page is safe.continueCursorfor the next call. Loop untilisDone: true.dryRun: truereturns counts without calling Resend — verify exclusion math before the first real send.RESEND_API_KEY(already set; same key used bysubscriptionEmails.ts)Output shape
Codegen catch-up
_generated/api.d.tsnow declares both:broadcast/audienceExport(this PR — required because the action cross-references its own sibling queries viainternal.*)payments/backfillCustomerNormalizedEmail(feat(convex/customers): add normalizedEmail field + index for broadcast dedup #3424's file — that file didn't cross-reference so its CI passed without the codegen update; adding now so future callers can resolve it).Convex regenerates
api.d.tsidentically onconvex dev/convex deploy. No drift risk.Verification
npx tsc --noEmit -p convex/tsconfig.json→ cleaninternalonly — never exposed to clientsRESEND_API_KEYrequirement so the exclusion math can be verified independently of the Resend infraOperational sequence
After this PR merges and Convex deploys:
Then verify in Resend dashboard that the audience contact count matches
upserted + alreadyExists(across all calls).Out of scope (future PRs)
resendWebhookHandler.tsforemail.bounced/email.complained→ newbroadcastMetricstableList-Unsubscribe-Post)Known caveats
getSuppressedEmailsandgetPaidEmailsuse.collect()— bounded by Convex's 16,384-doc read limit per query. Both tables are well under that today (low thousands). Comment in code flags the limit; if either grows, switch to streamed/paginated counts.failed. Acceptable for the launch send cadence (250→500/wk in the canary plan); revisit if blast volume exceeds 10k/hour.