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

status

draft

Only running broadcasts. completed is set from paused only and is not broadcast

description

null

Long-form description, also used as AI context

goal

null

Short goal statement given to the variation generator

schedulerIntervalSeconds

30

pg_cron interval

emailsPerHour

service default

Maximum enqueued per tick

maxEmailsPerDay

null (unlimited)

Hard daily cap across all ticks

emailsPerVariationPerHour

100 in the send workflow

Rolling 60-minute cap per variation

emailWindowStart / emailWindowEnd

null

Allowed sending window

emailAllowWeekends

false

Whether weekend sends are allowed

resendTopicId

null

Resend topic for audience segmentation and platform unsubscribe

templateId

null

The campaign-less EmailVariation this campaign was seeded from

segmentId

null

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 LOCKED means 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 its queuedAt is 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 when inngest.send throws 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 ASC makes 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):

  1. Load active variations and each one’s send count over the last rolling hour.

  2. Keep those under emailsPerVariationPerHour; pick one at random.

  3. If no active variations exist, halt with throttled: false and the message "No active email variations found" — a configuration problem that will not resolve by waiting.

  4. If all active variations are over the rate:

    1. count all variations for the campaign. At MAX_VARIATIONS = 5 or more, halt with throttled: true and wait for the rate window to reset.

    2. below the limit, call EmailVariationService.generateVariation(campaignId, subscriptionId) inline to write a new AI variation, then halt with throttled: 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

false

"No active email accounts on an active domain. Add or activate a sending domain to start."

Active accounts, all over cap

true

"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

check-already-sent

Refuse if the target is missing, or its status is any of requested, sent, opened, clicked, unsubscribed, bounced, blocked, suppressed, failed. This is the primary duplicate-send guard.

2

check-eligibility

One read of target_emails through the target. Suppressed (newsletterOptOut or marketingOptOut or isBlocked) → set target unsubscribed and stop. Cadence gap not yet elapsed → clear the queue lock and stop, so the row can be re-leased later.

3

check-send-limits

Re-check the campaign daily cap and the assigned variation’s hourly rate. Daily cap reached → release the lock and return daily_limit_reached. If the assigned variation is exhausted, fall back to another active variation under the rate. If none is available, release the lock and return variation_rate_limited.

4

send-email

Resolve template variables from contactData, applying each variable’s fallbackValue when the contact field is empty, and coercing number-typed variables. UNSUBSCRIBE_LINK is set last so no user-defined variable can overwrite it. Send through Resend with the resolved from, replyTo, subject, HTML, optional resendTemplateId and topicId, and a target_id tag carrying the target id.

5

update-target

markTargetRequested stores resendEmailId, variationId, sendingDomainId and sentAt, and moves the status to requested only while it is still pending, so a webhook that already arrived is not overwritten; bump target_emails.newsletterLastSentAt; increment the sending account’s dailySentCount; increment the variation’s usageCount.

6

log-activity-and-crm

Best-effort: write an Activity row (type email, body truncated to 500 chars) and publish Crm.NewActivity when the contact has a HubSpot id. Failures are logged, not fatal.

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):

  1. A target already holding the event’s email_id is used as is.

  2. Otherwise the target_id tag names the target. Its resendEmailId is claimed only while it is still null. A target already linked to a different id is never re-pointed.

  3. Without a usable tag, data.template_id is matched to EmailVariation.resendTemplateId, and the recipient address to a queued target in one of those campaigns whose resendEmailId is 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

campaignId

The owning campaign, or NULL for a reusable template

name, subject, htmlBody

Content

resendTemplateId

Resend template created programmatically; synced on create and update

templateType

Always "custom" — Resend templates are the canonical store

templateVariables

Array of { key, type, fallbackValue }; keys map to contact fields at send time

status

pending_generation, active or archived

usageCount

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

POST /api/email-campaigns/{id}/targets

Import from an uploaded list; contacts are upserted first, so EmailCampaignTarget.contactId is always set

POST /api/email-campaigns/{id}/targets/from-registry

Import from the TargetEmails registry by filter

GET /api/email-campaigns/{id}/targets/from-registry/resolve

Preview how many registry rows a filter would import

POST /api/email-campaigns/{id}/targets/from-segment

Queue a one-shot Inngest copy of a segment’s current active members (202) and persist Campaign.segmentId. Same chunked INSERT…SELECT pattern as duplication. Members without a local Contact + email + sendable target_emails row are skipped. Optional segment_id on POST /api/email-campaigns stores the assignment and starts the same job after create.

GET /api/email-campaigns/{id}/targets/export

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

pending

Import. Eligible for the next tick

requested

emailSend after Resend accepted the message

sent

The email.sent or email.delivered webhook

opened

The email.opened webhook

bounced

The email.bounced webhook

unsubscribed

The email.unsubscribed webhook, or the eligibility step finding suppression

blocked

The email.complained webhook (spam complaint)

suppressed

The email.suppressed webhook: Resend dropped the send because the address is on its suppression list (so far always previous_bounce). The address is also blocked in target_emails with blockReason resend_suppressed:<reason>

failed

A send error that exhausted retries

There is no clicked status in the EmailTargetStatus enum. A click is recorded as a clickedAt timestamp on the target and as clickedCount on the sending account, while the status stays at whatever the delivery lifecycle last set. Filter on has_clicked rather than on status.

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

total_count

every target on the campaign

valid_count

targets whose registry row is not opted out and not blocked

pending_count

pending, not opted out, not blocked in the registry, and queuedAt unset or older than the 2-day lock window

queued_count

pending, not opted out, not blocked, and queuedAt within the 2-day lock window

requested_count

status requested (accepted by Resend, no email.sent or email.delivered webhook yet); also counted in delivered_count

opened_count

sentAt is set and status is opened or unsubscribed

bounced_count

status bounced

suppressed_count

status suppressed

unsubscribed_count

status unsubscribed

blocked_count

status blocked, plus pending targets whose target_emails row is blocked (a hard bounce or complaint on any earlier campaign)

sent_count

sentAt is set, any status

delivered_count

sentAt is set and status is requested, sent, opened, or unsubscribed

clicked_count

clickedAt is set on a target counted in delivered_count

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 clickedat set. The click day is the America/New_York day of the person’s first click.

On a re-engagement list

At least one outreach_app.reengagement_daily_list_entries row (any segment) for a matched HubSpot contact, with a list date on or after the click day.

Called by a rep

At least one hubspot4.v_calls_latest call for a matched HubSpot contact from a human owner (not the AI dialer owner 86817215), on or after the first list date after the click.

Connected

The person’s best call disposition is in REENGAGEMENT_CONVERSATION_DISPOSITION_IDS, the same conversation set as the re-engagement daily report.

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

stage

all, not_re_engaged (not on a list, including no HubSpot match), not_called (on a list, not called), called, connected, not_connected. Clicking a funnel stage card sets this filter.

deal

all, has_deal, no_deal.

search

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.