Email deliverability

Deliverability is managed through a two-level sender hierarchy and four independent send caps.

Sender hierarchy

flowchart TD
  Sub["Subscription"] --> D1["Domain\npayability.com\ndailyCap 2000"]
  Sub --> D2["Domain\nsales.payability.com\ndailyCap 1000"]
  D1 --> A1["EmailSendingDomain\nalex@payability.com\ndailyCap 300"]
  D1 --> A2["EmailSendingDomain\nsam@payability.com\ndailyCap 300"]
  D2 --> A3["EmailSendingDomain\njo@sales.payability.com\ndailyCap 250"]
Domain

One row per Resend-verified domain, unique on (subscriptionId, name). Holds resendDomainId, resendStatus, a shared dailyCap, a warmupStatus label (warming_up or active), isActive, and a totalSentCount that is never reset. The domain’s usage today is the sum of its accounts' sends today — it has no counter of its own.

EmailSendingDomain ("email account" in the UI)

A concrete From address under a parent domain. Holds fromName, fromEmail, domain, resendDomainId, isActive, isVerified, its own dailyCap, dailySentCount and dailySentDate, a lifetime totalSentCount, and per-event counters. domainId is nullable because it was added after the table existed and legacy rows are backfilled by hand.

Subdomains are not modelled as children of their parent domain. In Resend each subdomain is its own verified domain with its own domain id, so sales.payability.com is a separate Domain row. An account’s fromEmail host always matches its parent Domain.name exactly.

The four caps

Checked in this order; the first breach stops the send.

Level Field Enforcement

Campaign per day

Campaign.maxEmailsPerDay

countSentToday(campaignId) at the top of the broadcast, and again in emailSend step 3

Variation per hour

Campaign.emailsPerVariationPerHour

countSendsPerVariationInLastHour(campaignId) — a rolling 60-minute count, not a clock hour. When breached, the broadcaster picks a different variation or triggers AI generation; emailSend falls back to another eligible variation or releases the lock

Account per day

EmailSendingDomain.dailyCap

findEligibleSendingDomains filters the pool; dailySentCount is incremented at Resend hand-off, not on the webhook, so the cap reflects committed sends immediately

Domain per day

Domain.dailyCap

Same query, comparing the sum of the domain’s active accounts' sends today against the parent cap

null means unlimited at every level.

Rolling windows are used for the per-variation rate rather than clock hours because a clock-hour reset produces a burst at the top of every hour, which is exactly the pattern spam filters look for.

Date-scoped daily counters

dailySentCount only counts for the UTC day stored in dailySentDate. incrementSendingDomainDailyCount does one atomic UPDATE: when dailySentDate is today it adds 1, otherwise it restarts the counter at 1 and stamps today’s date. Every reader goes through effectiveDailySent() in lib/utils/email-daily-cap.ts, which treats a counter from an earlier day as 0:

  • findEligibleSendingDomains, for both the account cap and the domain total

  • getDomainSummary (sentToday) and the email-domain serializers (daily_sent_count, daily_sent_total)

So a missed reset can no longer leave accounts stuck over their cap.

Daily reset

The Inngest function sendingDomainDailyResetCron runs at 00:00 UTC, zeroes dailySentCount and clears dailySentDate on every account. It is a backstop; the date-scoped counters already roll over on their own. totalSentCount on both the account and its parent domain is never reset.

Daily capacity

The Sending page’s daily capacity (getDomainSummary.dailyCapacity) adds, for each active domain, the lower of Domain.dailyCap and the sum of its active accounts' caps. A domain with a 3,000 cap but one 1,000-cap account contributes 1,000. A domain where both sides are unlimited contributes 0.

Counters

Incremented by the Resend webhook handlers (see Resend email events):

Counter Incremented on Notes

dailySentCount

Resend hand-off in emailSend

Counts only for dailySentDate (UTC); drives the daily cap

totalSentCount

Resend hand-off in emailSend

Lifetime, never reset

sentCount

email.sent

Lifetime provider-confirmed sends

receivedCount

email.received

Inbound replies to this account

openedCount

first email.opened per target

Deduplicated by openedAt being null

clickedCount

first email.clicked per target

Deduplicated by clickedAt being null

bouncedCount

email.bounced

unsubscribedCount

email.unsubscribed

email.delivered deliberately does not increment sentCount — email.sent already did, and counting both would double every send.

Opens and clicks are counted only on their first occurrence per recipient, so the numbers are unique-recipient counts rather than raw event counts.

Suppression

Suppression lives on TargetEmails, the per-workspace address registry — not on a separate suppression table, and not per campaign. An address is suppressed when any of these is true:

Flag Set by

newsletterOptOut (+ newsletterOptOutAt)

/api/unsubscribe (RFC 8058 one-click or hosted link), or the email.unsubscribed webhook

marketingOptOut (+ marketingOptOutAt)

The email.complained webhook (spam complaint)

isBlocked (+ blockReason, blockedAt)

Permanent email.bounced (hard bounce: the address is invalid) and email.complained (spam). Complaints also set marketing and newsletter opt-out and store blockReason Spam complaint. Hard bounces do not change opt-out flags. Transient and Undetermined bounces are recorded on the campaign target and leave the address sendable

oneToOneOptOut exists for direct one-to-one email and is not part of the campaign suppression check.

Suppression is enforced twice:

  1. At lease time — fetchAndLockPendingTargets joins target_emails and requires newsletterOptOut = false AND isBlocked = false, so suppressed addresses never enter the queue.

  2. At send time — emailSend step check-eligibility calls getTargetEmailEligibility. isBlocked sets the campaign target to blocked and copies blockReason into lastError. Newsletter or marketing opt-out, when the address is not blocked, sets unsubscribed.

The double check exists because minutes pass between the two, and a recipient may unsubscribe from a different campaign’s email in that window.

A spam complaint sets marketingOptOut and isBlocked, which is stricter than an unsubscribe: a complaint is a deliverability risk to the whole domain, not just a preference. A Permanent bounce sets only isBlocked plus blockReason, because the address cannot receive mail. The bounced campaign row stays status bounced so deliverability charts do not count it as a complaint.

Resend topics

Campaign.resendTopicId links a campaign to a Resend topic, giving audience segmentation and platform-managed unsubscribe alongside the application’s own. lib/services/resend-topic-service.ts manages topics, and emailSend passes topicId on every send.

Reply-to

buildReplyToForSendingDomain({ fromEmail, fromName }) in lib/utils/emails.ts derives the Reply-To header from the sending account, so replies land on an address the inbox can correlate back to a campaign. See Email inbox and replies.

Operating checklist

  • Verify the domain in Resend, then create the Domain row with its resendDomainId (GET /api/email-domains/resend-domains lists verified domains).

  • Add at least one active, verified EmailSendingDomain under it.

  • Set dailyCap on both levels. During warm-up, raise the account caps gradually and leave warmupStatus at warming_up as a signal to other operators.

  • Watch bouncedCount and unsubscribedCount per account — a single bad account can damage the reputation of the whole domain.

  • GET /api/email-domains/stats and GET /api/email-domains/summary back the dashboard views.