Email campaigns
An email campaign is a Campaign row with type = email. It shares the table and status enum
with call campaigns but uses a separate recipient model (EmailCampaignTarget) and its own
configuration columns.
Configuration
| Field | Default | Meaning |
|---|---|---|
|
|
Only |
|
|
Long-form description, also used as AI context |
|
|
Short goal statement given to the variation generator |
|
|
|
|
service default |
Maximum enqueued per tick |
|
|
Hard daily cap across all ticks |
|
|
Rolling 60-minute cap per variation |
|
|
Allowed sending window |
|
|
Whether weekend sends are allowed |
|
|
Resend topic for audience segmentation and platform unsubscribe |
|
|
The campaign-less |
|
|
Optional audience segment this campaign is assigned to (label + import starting point; not a live sync) |
The broadcast loop
POST /api/email-campaigns/{id}/broadcast is internal-only and gated by
verifyInternalSecret(). EmailCampaignService.processBroadcast(campaignId) runs one tick.
flowchart TD
Cron["pg_cron every schedulerIntervalSeconds"] --> Route["POST /api/email-campaigns/{id}/broadcast"]
Route --> Guard{"type = email\nand status = running?"}
Guard -->|No| Halt1["Return throttled"]
Guard -->|Yes| Daily{"countSentToday >= maxEmailsPerDay?"}
Daily -->|Yes| Halt2["Return throttled — daily cap"]
Daily -->|No| Batch["batchSize = min(emailsPerHour, remaining daily cap)"]
Batch --> Lock["fetchAndLockPendingTargets(campaignId, batchSize, 2-day lock)"]
Lock --> Empty{"Any targets?"}
Empty -->|No| Idle["Return — no pending recipients"]
Empty -->|Yes| Var["selectVariation(campaign)"]
Var --> Dom["selectSendingDomains(campaign)"]
Dom --> Enqueue["Per target: random account,\nbuild unsubscribe token,\ninngest.send email-campaign/send.requested"]
Target locking
fetchAndLockPendingTargets leases a batch with one atomic statement, built by
leasePendingTargetsSql:
WITH picked AS (
SELECT ect.id
FROM email_campaign_targets AS ect
INNER JOIN target_emails AS te ON te.id = ect."emailId"
WHERE ect."campaignId" = $1
AND ect.status = 'pending'
AND (ect."queuedAt" IS NULL OR ect."queuedAt" < $lockCutoff)
AND te."newsletterOptOut" = false
AND te."isBlocked" = false
AND (te."newsletterLastSentAt" IS NULL
OR te."newsletterLastSentAt" <= NOW() - (COALESCE(te."newsletterGapDays", 7) * INTERVAL '1 day'))
ORDER BY ect."createdAt" ASC
LIMIT $limit
FOR UPDATE OF ect SKIP LOCKED
)
UPDATE email_campaign_targets AS u
SET "queuedAt" = $now
FROM picked
WHERE u.id = picked.id
RETURNING u.id, u."createdAt"
It then loads the leased targets with their contacts, outside any transaction, and re-sorts them by
createdAt because RETURNING order is unspecified.
-
There is no Prisma interactive transaction. Those time out after 5 seconds, and on large campaigns the lease query alone used to exceed that, failing the tick with
P2028. -
FOR UPDATE … SKIP LOCKEDmeans two concurrent ticks take disjoint batches instead of blocking. -
The index
(campaignId, status, createdAt)lets Postgres walk pending rows in send order and stop at the batch limit, instead of sorting every pending row in the campaign. -
The lock window is
EMAIL_TARGET_LOCK_WINDOW_SECONDS(2 days). A target becomes eligible again only once itsqueuedAtis older than that, which prevents duplicate events while the Inngest send job is still in flight. -
A tick that leases targets and then does not enqueue them gives the leases back with
releaseTargetQueueLocks. That happens when variation selection or sending-account selection halts the run, or wheninngest.sendthrows for a target. Without this, a run throttled on the daily cap would hide a batch of recipients for 2 days. -
Suppression and newsletter cadence are joined in at lease time, so ineligible recipients never reach the queue.
-
ORDER BY createdAt ASCmakes sending fair: earliest imported recipients go first.
Newsletter cadence gap
TargetEmails.newsletterLastSentAt and newsletterGapDays (default 7) enforce a cross-campaign
minimum gap between marketing emails to the same address. Without this, an address enrolled in
three campaigns would receive three emails the same morning. isNewsletterSendEligible implements
the check, and email-send bumps newsletterLastSentAt after a successful hand-off.
Variation selection
selectVariation(campaign):
-
Load active variations and each one’s send count over the last rolling hour.
-
Keep those under
emailsPerVariationPerHour; pick one at random. -
If no active variations exist, halt with
throttled: falseand the message "No active email variations found" — a configuration problem that will not resolve by waiting. -
If all active variations are over the rate:
-
count all variations for the campaign. At
MAX_VARIATIONS = 5or more, halt withthrottled: trueand wait for the rate window to reset. -
below the limit, call
EmailVariationService.generateVariation(campaignId, subscriptionId)inline to write a new AI variation, then halt withthrottled: true. The next tick picks it up.
-
Note that generation is synchronous within the broadcast tick — it is not dispatched as an Inngest event.
EmailVariation.usageCount is a lifetime statistic. It never gates selection; only the rolling
hourly count does.
Sending account selection
selectSendingDomains(campaign) calls findEligibleSendingDomains(subscriptionId), which returns
active accounts under both their own EmailSendingDomain.dailyCap and their parent
Domain.dailyCap. The broadcaster then picks one at random per target.
An empty pool is disambiguated with a second lookup, findActiveSendingDomains, which applies the same
"active account on an active domain (or no domain)" filter but ignores caps, so the operator gets an
actionable message:
| Situation | throttled |
Message |
|---|---|---|
No active account on an active domain |
|
"No active email accounts on an active domain. Add or activate a sending domain to start." |
Active accounts, all over cap |
|
"All sending accounts/domains have reached their daily cap. Sending resumes after the daily reset." |
The distinction matters: throttled: false tells the operator that waiting will not help.
See Email deliverability for the cap model.
The send workflow
emailSend (retries: 2, concurrency: { limit: 20 }) reacts to
email-campaign/send.requested.
| # | Step | Behaviour |
|---|---|---|
1 |
|
Refuse if the target is missing, or its status is any of |
2 |
|
One read of |
3 |
|
Re-check the campaign daily cap and the assigned variation’s hourly rate. Daily cap reached →
release the lock and return |
4 |
|
Resolve template variables from |
5 |
|
|
6 |
|
Best-effort: write an |
Limits are re-checked in step 3 even though the broadcaster already checked them, because minutes can pass between leasing and sending and other campaigns may have consumed the budget in between.
sentAt records the successful hand-off to Resend, and is later refreshed by the
email.delivered webhook.
Matching webhooks to targets
send-email and update-target are separate steps, so Resend can deliver email.sent,
email.delivered, email.opened or email.suppressed before resendEmailId is saved. Before
running a handler, ResendWebhookDispatcher calls resolveResendWebhookTarget
(lib/events/resend-target-resolution.ts):
-
A target already holding the event’s
email_idis used as is. -
Otherwise the
target_idtag names the target. ItsresendEmailIdis claimed only while it is still null. A target already linked to a different id is never re-pointed. -
Without a usable tag,
data.template_idis matched toEmailVariation.resendTemplateId, and the recipient address to a queued target in one of those campaigns whoseresendEmailIdis null. The claim happens only when exactly one target matches.
Resolution failures are logged and the handler still runs, so an unmatched event is a no-op.
Handlers only move status forward. email.sent and email.delivered promote pending or
requested to sent; email.opened promotes pending, requested or sent to opened and sets
openedAt regardless of status; email.bounced and email.unsubscribed never overwrite blocked
or suppressed. A late email.delivered therefore no longer downgrades an opened target.
Content variations
EmailVariation holds a subject and a full HTML body.
| Field | Meaning |
|---|---|
|
The owning campaign, or |
|
Content |
|
Resend template created programmatically; synced on create and update |
|
Always |
|
Array of |
|
|
|
Lifetime send count |
Templates versus campaign variations
A row with campaignId = NULL is a template, managed at /email-campaigns/variations. Templates
are database-only — they have no Resend template — until they are copied into a campaign, at which
point the campaign POST path creates a normal variation with a Resend template. Every selection and
send query filters by campaignId, so a template can never be sent by accident.
AI generation
EmailVariationService rephrases an existing variation’s HTML body through the Vercel AI Gateway,
creates a matching Resend template, and saves the result as a new active variation. It is triggered
when every active variation is over its hourly rate and the campaign has fewer than five
variations. lib/services/email-variation-sqs.ts provides an SQS-based alternative path.
Unsubscribe (RFC 8058)
Every outgoing email carries List-Unsubscribe and List-Unsubscribe-Post headers pointing at
/api/unsubscribe, plus an in-body link using the same URL. The token is the EmailCampaignTarget
id encoded as base64url.
GET renders a confirmation page; POST performs the one-click opt-out. A successful unsubscribe
sets TargetEmails.newsletterOptOut with its timestamp, which suppresses the address across every
campaign in the workspace. Resend topics provide a second, platform-level unsubscribe surface.
Duplication
POST /api/email-campaigns/{id}/duplicate emits email-campaign/duplicate.requested, handled by
the emailCampaignDuplicate Inngest function. Copying a campaign means copying its variations,
recreating their Resend templates and re-resolving its recipients — too much work for one request,
so it runs as a durable workflow. See Email workflows.
Recipient import
| Endpoint | Behaviour |
|---|---|
|
Import from an uploaded list; contacts are upserted first, so |
|
Import from the |
|
Preview how many registry rows a filter would import |
|
Queue a one-shot Inngest copy of a segment’s current active members ( |
|
Export recipients with their current status |
EmailCampaignTarget is unique on both (campaignId, contactId) and (campaignId, email), so the
same person cannot be enrolled twice in one campaign but can be a recipient of many campaigns.
Recipient statuses
| Status | Set by |
|---|---|
|
Import. Eligible for the next tick |
|
|
|
The |
|
The |
|
The |
|
The |
|
The |
|
The |
|
A send error that exhausted retries |
|
There is no |
EmailCampaignTarget.category (EmailTargetCategory) holds the LLM classification of a reply:
interested, not_interested, question, out_of_office, unsubscribe, wrong_person,
other. See Email inbox and replies.
Campaign statistics
List and detail counts come from the v_email_campaign_stats materialized view
(lib/supabase/views/v_email_campaign_stats.sql). pg_cron job
refresh-v_email_campaign_stats runs REFRESH MATERIALIZED VIEW CONCURRENTLY at the top of every
hour, so counts can lag until that refresh. Campaign name, status, and settings are read from the
live Campaign row, so creates and edits show up immediately. A campaign created after the last
refresh has no view row yet; the API treats that as zeros.
The view also snapshots description, send limits, window, template, and segment. The API does not read those columns from the view.
| Field | Composition |
|---|---|
|
every target on the campaign |
|
targets whose registry row is not opted out and not blocked |
|
|
|
|
|
status |
|
|
|
status |
|
status |
|
status |
|
status |
|
|
|
|
|
|
pending_count and queued_count do not overlap. The 2-day interval in the view must match
EMAIL_TARGET_LOCK_WINDOW_SECONDS in lib/services/email-campaign-service.ts. Because the view
refreshes hourly, a lease can show as queued for up to an hour after it expires.
sent_count follows the timestamp rather than status: a recipient who later opens or bounces still
has sentAt set. A click does not change status, so clicked_count combines clickedAt with the
delivered statuses. A complaint is stored as blocked, so it appears in blocked_count and not in
delivered_count.
requested counts as delivered because not every send receives an email.sent or
email.delivered webhook. The send workflow (lib/workflows/functions/email-send.ts) sets
requested and sentAt together once Resend accepts the email. Without a webhook the target stays
requested, and excluding it would leave it out of the open and click rate denominators. An open
still moves it to opened, but a click does not change status, so its click is counted only
because requested is a delivered status. vm_domain_email_stats uses the same rule.
An unsubscribe counts as an open: the recipient had to open the email to click the link, and
Resend counts it the same way. A suppressed target has sentAt set but was never delivered, so it
is in sent_count and not in delivered_count. The same opened and delivered rules apply in
vm_domain_email_stats, vm_email_campaign_target_summary (total_opened, total_suppressed),
v_email_campaign_daily_engagement and the variation performance SQL.
The campaign Analytics tab and the campaigns list divide opened and clicked by delivered_count,
and the Analytics tab divides bounced by sent_count.
Performance tab
The Performance tab on the campaign detail page compares variations. It reads
GET /api/email-campaigns/[id]/variation-performance, which queries email_campaign_targets live
for one campaign, so its numbers do not wait for the hourly refresh. The route resolves the
subscription, returns 404 unless the campaign is an email campaign in that subscription, and the
repository SQL (variationPerformanceSql, variationDailyPerformanceSql in
lib/repositories/email-campaign-repository.ts) filters on both subscriptionId and campaignId.
Only targets with sentAt set are counted, grouped by variationId. Sent, delivered (including
requested), opened, clicked, and bounced use the same definitions as the table above, so summing
the variations matches the Analytics tab once the view has refreshed. Targets without a
variationId appear as "Unassigned". Archived variations are included because they carry
historical sends.
Open and click rates are a percentage of delivered; bounce rate is a percentage of sent. Rates are
rounded to one decimal and are null when the denominator is zero (lib/reports/email-variation-performance.ts).
A variation with fewer than MIN_DELIVERED_FOR_COMPARISON (50) delivered emails is flagged as low
sample: its bubble is faded and it cannot be highlighted as the best open or click rate. A best
rate is only highlighted when at least two variations have enough data.
The rates chart is a bubble scatter: open rate on the x axis, click rate on the y axis, bubble size
by delivered emails, and dashed reference lines at the campaign-wide open and click rate. Bounce
rate appears in the bubble tooltip, the trend chart’s Bounce toggle, and the comparison table. Each
variation keeps one color across both charts and the table (components/email-campaign/variation-colors.ts).
The daily trend groups sends into cohorts by the America/New_York day of sentAt. Each point is the
rate for emails a variation sent that day, wherever the open or click happened later, so a point
never exceeds 100%. Recent days rise as late opens arrive. The optional dateFrom and dateTo
query parameters (YYYY-MM-DD, inclusive) bound the same sent day for both the comparison and the
trend.
Re-engagement tab
The Re-engagement tab follows everyone who clicked the campaign through the re-engagement
process. It reads GET /api/email-campaigns/[id]/reengagement-funnel. The route resolves the
subscription and returns 404 unless the campaign is an email campaign in that subscription. The
service (lib/services/email-click-reengagement-funnel-service.ts) runs one BigQuery query,
emailClickReengagementFunnelSql in lib/bigquery/client.ts, because re-engagement lists and
HubSpot calls only exist in BigQuery. The query filters the outreach_app.email_campaign_targets
mirror on both subscriptionid and campaignid, keeps the latest row per target, and binds every
value as a parameter.
Each count is unique people, keyed by the recipient’s lowercased email. Every stage only counts what happened on or after the click:
| Stage | Definition |
|---|---|
Clicked |
Targets with |
On a re-engagement list |
At least one |
Called by a rep |
At least one |
Connected |
The person’s best call disposition is in |
Clickers are matched to HubSpot by email through hubspot4.v_contacts_latest, plus the mirrored
outreach_app.contacts.hubspotcontactid. The mirrored id alone is often null or stale (see
lib/bigquery/v_email_clicked_re_engagement_summary.sql). Clickers with no match are counted as not
on a list and reported as unmatched_hubspot.
Each called person is placed under one disposition: the best one across all their qualifying rep
calls, ranked by CLICK_FUNNEL_RANKED_DISPOSITIONS in lib/reports/email-click-reengagement-funnel.ts.
The 13 conversation dispositions rank first, from Account Created down to Hang up, so connected means
a rank below 13. Not interested and Do not contact are not in the conversation set, so they appear
under not connected. Unknown dispositions count as "Other disposition" and empty ones as "No
disposition".
The data comes from the BigQuery mirror, so recent clicks and calls can lag the live tabs. The client caches the response for five minutes; the Refresh button runs the query again.
People list
The People card under the funnel lists the clickers themselves. It reads
GET /api/email-campaigns/[id]/reengagement-funnel/people?stage=&deal=&search=&page=&pageSize=,
with the same subscription and email-campaign checks as the funnel route. The response uses the
standard pagination shape { data, total, page, pageSize }; pageSize is 1 to 100 (default 25).
The funnel and the list share one BigQuery CTE, emailClickFunnelPeopleCte in
lib/bigquery/client.ts, which builds one row per clicked email. The funnel groups those rows and
the list pages through them, so a filtered list always adds up to the matching funnel stage. Rows are
ordered by first click, newest first.
| Filter | Values |
|---|---|
|
|
|
|
|
Case-insensitive substring of email, full name, or company. Trimmed, at most 100 characters. |
Each person shows a primary HubSpot contact id for the contact link. It is the first of: an id that
put the person on a re-engagement list, the mirrored outreach_app.contacts.hubspotcontactid, then
the most recently updated email match. When several ids match, the row shows how many more exist.
The deal column shows any non-deleted hubspot4.v_deals_latest deal associated with any of the
person’s matched ids, not only deals created after the click. When there are several, open deals win
over closed ones, then the newest. The stage label and won, pending, or lost outcome come from
lib/reports/ai-calling-deal-stages.ts. The before or after click flag compares the deal’s creation
day (UTC) with the click day.