Deliverability Infrastructure

The Convex-side deliverability backend: provider routing, health-aware failover, sending reputation with auto-enforcement, IP warming cache, the blocklist, and the content-scan gate.

This page maps the deliverability machinery that lives in the Convex backend: how a send picks a provider, how provider failures feed failover, how delivery events accumulate into a reputation score that can auto-warn or auto-suspend the deployment, the cached view of IP-warming state, the address blocklist, and the daily send counters plus the pre-send content-scan thresholds.

The sending-side intelligence — per-ISP throttling, the actual IP warming schedule, circuit breakers, and DNSBL monitoring — lives in the MTA, not Convex. See MTA System for that. This page covers everything Convex owns; the two meet at the MTA's /ip-reputation snapshot, the authenticated /send/decision lease boundary, and delivery/routing-reentry webhooks.

Per-org provider routing and strategies

A deployment can route each message type to a different email provider, or split a single type across several. Routes are stored in the providerRoutes table (apps/api/convex/schema/delivery.ts) and managed through apps/api/convex/providerRoutes.ts.

Each route row keys on one message type and carries a strategy, an ordered provider list, and an optional IP-pool override:

FieldTypeMeaning
messageTypecampaign | transactional | automationOne route per message type
strategysingle | priority_failover | workload_split | adaptive_mixSelection algorithm (adaptive_mix is controller-owned, not operator-selectable)
providersarray of { providerType, weight?, isEnabled }Ordered candidate set; built-ins are mta / ses / resend / smtp
ipPoolstring (optional)Override the MTA IP pool for sends on this route

setRoute upserts a route and removeRoute deletes one (reverting that message type to the global default). Both require the organization:manage permission. The public reader is listRoutes (an authedQuery); send paths resolve routes through resolveSendRoute / resolveSendRouteFromDb (apps/api/convex/lib/sendProviders/route.ts).

The four strategies

Selection is a pure function. The thin dispatcher resolveRoute (apps/api/convex/lib/sendProviders/routing.ts) looks up a strategy module by strategy and calls its select(entries, ipPool, healthStatuses, mix) with the enabled providers, the route's ipPool, the current provider-health snapshot, and the per-recipient mix context. Only adaptive_mix reads that fourth argument; the shipped three ignore it. Each strategy lives in its own folder under lib/sendProviders/strategies/:

StrategyBehaviour
singleAlways use the first enabled provider. Ignores health.
priority_failoverWalk enabled providers in order; pick the first that is not down. Falls back to the first enabled provider if all are down or no health data exists.
workload_splitWeighted-random pick across enabled providers, excluding any that are down. Weights default to 100 (uniform). If every provider is down, it still picks one rather than blocking the send.
adaptive_mixDeterministic PER-RECIPIENT split between the own MTA and the reference transport, at the share the ramp controller holds for the (stream, destinationProvider) cell. Controller-owned: it is never offered in the strategy picker. A degenerate share resolves the WHOLE cell to one arm — the reference transport at s = 0 (today's fallback-active behaviour), the own MTA at s = 1 — chosen by arm rather than by health; health-driven failover stays with the fallback sequence and the dispatch-time re-resolution.

When there is no route, no enabled provider, or the strategy returns nothing, resolveRoute falls through to the EMAIL_PROVIDER env var, and otherwise returns null (unconfigured). Resolution is fail-closed — there is no implicit mta default, so an unconfigured deployment never silently dispatches to a phantom MTA. The returned ResolvedRoute records its source (org_config, env_fallback, or deliverability_fallback) for observability.

Tenant-owned campaign, automation, agent-reply, transactional, and test sends resolve a fresh last-mile decision immediately before dispatch. Governed MTA intake requires the resulting lease and binds it to the message, organization, recipient, sender, message type, provider/pool, warmup-overflow policy, selected IP, and breaker generations. If a queued MTA job discovers a changed or expired decision before opening SMTP, it releases its reservations and sends an authenticated routing-reentry outcome to Convex. Convex atomically re-enqueues the same Send through the bounded workpool with the same idempotency key, where a fresh decision can choose owned delivery, relay, or defer. System/auth mail and Postbox use separate master-key-only fixed-scope MTA endpoints; authenticated raw SMTP submission is an owned-MTA path and does not participate in the tenant relay fallback.

The handoff itself is fenced by a durable per-job re-entry receipt (apps/mta/src/queue/routingReentryHandoff.ts), because the queue only records completion after the worker returns. reserved means the successor may not be persisted yet, so a replay finishes the same handoff idempotently — the outbox payload is frozen in the receipt, so every rebuild is byte-identical. accepted means the protected outbox already owns the successor and the replayed job must not re-enter dispatch: without that fence, a crash in the completion window would let the job re-run the whole pipeline against fresh capacity and deliver a message the Convex successor is also delivering. The re-entry callback carries the retry state the callback digest was issued over, including the workAttemptId and acceptanceReconciliation fields added after an acceptance-unknown dispatch; dropping either would turn the callback into a permanent binding_mismatch.

Every terminal path that never opened SMTP — screening and suppression drops, and the four-day expiry give-up — releases the message's warming reservation and half-open breaker probe. Nothing was transmitted, so holding them would spend a warming IP's daily cap on mail that was never sent. The intake receipt is re-armed on every worker run that still owns it, so a message deferred right up to the max age can still emit its terminal expired bounce instead of dead-lettering with the Send stuck in queued.

Two invariants keep an ambiguous MTA acceptance from turning into duplicate or lost mail. First, while a send is reconciling an unknown acceptance it never leaves the owned-MTA path — only that path deduplicates the reused workAttemptId, and a relay carries no idempotency key at all, so any other transport would send a second copy of a message the MTA may already be delivering. Every non-owned routing outcome defers instead. Second, a routing re-entry successor is a new work attempt: it mints its own workAttemptId and drops the reconciliation flag, because the handoff happens before SMTP and therefore proves the previous attempt did not deliver. A successor that inherited the old identity would dedupe against the intake receipt of the job that just surrendered ownership, and the Send would wait queued for a webhook that can never arrive.

Transient routing refusals hold rather than terminalize. An open org-wide safety circuit, or a fallback whose relay identity is unverified, throws out of resolveRoute; left uncaught it would surface as a workpool failure and mark every in-flight send WORKPOOL_FAILED, so tripping a safety signal would destroy exactly the mail it was meant to pause. The governed route queries report these as typed deferrals instead, logged with their code because that is the operator's only clue as to why mail paused. A safety hold does not consume a routing attempt — the attempt cap bounds routing churn, and eight 60-second attempts would terminalize the send about seven minutes in, inside a single signal's own ten-minute freshness window. Held sends re-check at that same horizon and are bounded by the four-day delivery deadline.

Bounce and complaint accounting stays paired. A message the MTA rejects at SMTP goes queued → bounced without passing through sent, so the terminal transition also emits the outbound counters sent would have — otherwise the reputation bounce rate that drives auto-enforcement has a numerator with no denominator and reports a half-bounced batch as 100%.

The SMTP outcome journal covers the genuinely irreversible window only. sendToMx resolves leases, MX and provider profiles, DKIM signing and the TLS floor before it acquires a connection; a failure in that stretch transmitted nothing, so the reservation is released and the job retries normally. Once the first connection acquisition begins, an interrupted attempt stays uncertain and a replay resolves it as ambiguous rather than putting the message on the wire a second time.

IP pool plumbing

The governed dispatch boundary (apps/api/convex/delivery/governedDispatch.ts) threads the freshly resolved route's ipPool to the MTA through MtaExtras; when no route/input pool exists, the MTA adapter defaults to transactional. It also carries the stable message id, message type, routing lease, warmup-overflow bit, and the bounded Convex work item needed for a pre-network routing re-entry. Campaign pool selection therefore survives both initial dispatch and an accepted-job re-entry; it is not reduced to a message-id-only adapter call.

Send dispatch and provider health-aware failover

Every provider attempt funnels through one helper, sendProviderDispatch (apps/api/convex/lib/sendProviders/dispatch.ts), described by ADR-0020 (docs/adr/0020-send-provider-adapter-modules.md). The governed workpool path, campaign test sends, and fixed-scope system/auth sender all reach this boundary; automation and agent-reply producers first converge on the same workpool worker rather than calling a transport directly. The dispatcher does three things uniformly:

  1. Retry loop driven by each provider module's retryDelays and categorizeError. Each attempt calls the module's single-attempt sendEmail.
  2. Health recording — after every terminal outcome (success or exhausted retries) it schedules recordSendResult on the Send provider health module, so even bypass callers (test sends, automation steps) record health.
  3. Error categorization — the result carries a typed EmailErrorCode, not a raw string.

Provider health

recordSendResult (apps/api/convex/lib/sendProviders/health.ts) maintains one providerHealth row per provider kind, using exponentially-decayed rolling success/failure counts, an EMA latency, and a consecutive-failure counter. Status is derived from those:

StatusCondition
healthysuccess rate ≥ 90%
degradedsuccess rate ≥ 50% and < 90%
downsuccess rate < 50%, or ≥ 5 consecutive failures

The providerHealth rows are collected by resolveSendRouteFromDb (apps/api/convex/lib/sendProviders/route.ts) and passed to the pure resolveRoute before each dispatch, closing the loop: a provider that starts failing flips to down and priority_failover / workload_split route around it automatically.

Sending reputation (org + per-domain, derived risk, auto-enforcement)

Delivery outcomes accumulate into the sendingReputation table, owned exclusively by the Sending reputation module (apps/api/convex/analytics/sendingReputation.ts), per ADR-0042 (docs/adr/0042-sending-reputation-module.md). It is a scope-discriminated table: scope: 'org' rows track the whole deployment; scope: 'domain' rows track one sending domain. Bounce rate, complaint rate, and risk level are never stored — they are derived on read.

How events arrive

The Send lifecycle (apps/api/convex/delivery/sendLifecycle.ts) emits a reputation_update effect on each delivery transition, which schedules recordEvent with an event type and (when known) the sending domain. recordEvent is the single writer: it bumps today's org day-bucket always, and the domain day-bucket when a domain is present.

Event typeCounters bumped
sendtotalSent
delivertotalDelivered
bouncetotalBounced
hard_bouncetotalBounced and totalHardBounced
complainttotalComplaints

Derived risk

summarize (and summarizeDomains for the per-domain view) is the only place the rolling 30-day risk window is summed; it is reader-typed so the writer, the session-auth queries, the platform-admin queries, and the control-plane reporter all derive the identical number. Risk is computed from internal enforcement thresholds. The separate provider-facing FBL spam rate uses complaints divided by delivered volume: below 0.1% is the target and 0.3% is the hard line. Google's current wording is delivery-impact and mitigation ineligibility at/above the hard line, not a universal SMTP-rejection promise. Senders below the minimum sample size are always low:

RiskTrigger (with ≥ 100 sends in window)
lowbelow the medium thresholds
mediumcomplaint rate ≥ 0.1% or bounce rate ≥ 2%
highcomplaint rate ≥ 0.2% or bounce rate ≥ 5%
criticalcomplaint rate ≥ 0.3% or bounce rate ≥ 10%

Auto-enforcement

Auto-enforcement no longer runs inside recordEvent (which now only bumps the sharded counters). It runs hourly via the evaluateAutoEnforce cron, which summarizes the org window once and — at high or critical — schedules autoEnforceReputation, picking a target Abuse status (highwarned, criticalsuspended) and delegating the transition to the Abuse status module (ADR-0011), which dedupes idempotently and refuses severity downgrades. Domain buckets feed the per-domain dashboard only — Abuse status is a deployment-level state.

recalculateAll is a cleanup-only hourly cron (wired in apps/api/convex/crons.ts); it ages out day-buckets older than 60 days across both scopes. Risk no longer needs periodic recalculation because it is derived on read.

Compliance telemetry

analytics/complianceTelemetry.ts joins three bounded signals for the Delivery page:

  • summarizeSpamRate and the shared per-domain bucket grouping derive FBL complaints over delivered volume without changing the existing 0.2%/100-send circuit breaker. The domain dashboard reads and groups the reputation buckets once, then derives both risk and spam rate from that same bounded result. The seven-day internal evidence counter counts completed UTC days with delivered traffic and a rate strictly below 0.3%; a missing-volume day cannot claim clean evidence. It is not a Google mitigation-eligibility verdict: operators must verify Google's daily rate in Postmaster Tools and every remaining sender requirement.
  • MTA POST /send success means only queue acceptance and advances a Send to sent; it never increments delivered volume. The later authenticated MTA sent webhook means the destination SMTP server accepted DATA, so the adapter maps that truthful remote-acceptance signal to delivery evidence. The Send lifecycle persists the first accepted/open/click/complaint observation in deliveredAt; that single idempotency seam records campaign, daily, and reputation delivery effects once without regressing an advanced or terminal display status. Send bouncedAt/failedAt and Postbox recipient bouncedAt/failedAt establish event-time ordering, so an earlier acceptance remains attributable even if its webhook arrives last; a terminal event that truly occurred first rejects it. A replayed earlier acceptance does not clear contact recovery state created by the newer terminal event. Postbox persists the independent observation in acceptedAt. The Phase-2 DestinationSnapshot.providerKey travels on the webhook. Gmail observations are written only when the lifecycle resolves an attributable Send/Postbox outcome and are idempotent by provider message id. The hot path deterministically shards each PSL-correct primary DKIM domain's hourly writes across eight documents, avoiding a single-document Convex OCC bottleneck. One stable per-domain job coalesces bursts and asynchronously refreshes the fixed-width materialized rollup; the indexed dashboard query returns the top 100 domains plus an explicit truncation flag without scanning domain × hour × shard cardinality. The authenticated MTA acceptedAt chooses the measurement hour, while trusted Convex ingestedAt controls receipt retention. A bounded five-minute future skew is clamped; events beyond that or older than the 48-hour telemetry horizon retain idempotency but do not create volume. Cleanup deletes, refreshes, or reschedules at most 128 rows from each indexed class per transaction and immediately schedules another batch while backlog remains. The dashboard warns at 4,000 on the way to Google's approximate 5,000/24h permanent classifier. Hourly totals can overlap the exact trailing boundary by up to 60 minutes, which the API and UI disclose.
  • The RFC 8058 POST handler records a bounded daily latency histogram only after its synchronous unsubscribe mutation completes. The dashboard derives p95 over 30 days and alerts beyond the 48-hour honor window. Telemetry failure is fail-open for the endpoint; it can never turn an applied unsubscribe into a provider-visible error.

delivery/marketingCompliance.ts owns the single final-envelope assertion for campaign and automation mail. It requires both one-click headers and reads SIGNED_HEADERS exported by @owlat/mail-message; no second signed-header list is maintained in Convex.

Receiver policy mapped to Owlat controls

This table separates mailbox-provider policy from Owlat's implementation. The provider values are not interchangeable: Google calculates a daily Postmaster Tools spam rate, Yahoo divides complaints by inbox-delivered mail, and Owlat's FBL view uses delivered volume over its own rolling window.

Policy boundaryCurrent receiver ruleOwlat control
Gmail bulk proximityClassification is around 5,000 messages in 24 hours to personal Gmail, aggregated by primary sending domainWarn at 4,000 accepted Gmail-attributed messages in the rolling approximation; display the 5,000 classifier boundary
Gmail spam rateKeep Postmaster Tools below 0.10%; avoid 0.30% or higherDisplay target and hard line; count seven internal clean sending days only as investigation evidence
Yahoo spam rateKeep below 0.3%; Yahoo's denominator is mail delivered to the inboxDisplay the same hard line, but never label Owlat's denominator as Yahoo's rate
One-click processingGmail bulk marketing/subscribed mail needs RFC 8058; Yahoo requires functioning List-Unsubscribe and highly recommends RFC 8058; both expect completion within 2 daysRefuse campaign/marketing-automation envelopes without signed RFC 8058 headers; apply suppression synchronously; alert when p95 crosses 48 hours
Campaign complaintsReceiver placement can degrade before a whole organization crosses a breakerAlert above 0.3% after at least 100 attributable deliveries

The MTA's real-time organization breaker is intentionally a different, stricter control. Each comparison is strictly greater-than:

SignalWindowOpens above
Bounce, fastLast 50 outcomes15%
Bounce, sustainedLast 100 outcomes8%
Complaint, fastLast 50 outcomes4%
Complaint, sustainedLast 100 outcomes0.2%

An open breaker cools down for 30 minutes before half-open probes. It is not Google's or Yahoo's measurement and must not be described as provider policy.

Current consumer-mailbox requirements

Receiver scopeAuthentication floorOther current requirements
Gmail, all sendersSPF or DKIMValid forward/reverse DNS, TLS, RFC 5322, spam below 0.3%
Gmail, bulkSPF and DKIM; DMARC at least p=none; From aligned with SPF or DKIMAll-sender floor; RFC 8058 plus visible body unsubscribe for marketing/subscribed mail; honor within 48 hours
Yahoo, all sendersSPF or DKIMValid forward/reverse DNS, RFC 5321/5322, spam below 0.3%
Yahoo, bulkSPF and DKIM; passing DMARC at least p=none; From alignmentFunctioning List-Unsubscribe plus visible body link for marketing/subscribed mail; honor within 2 days
Outlook.com consumer, 5,000+ per day per From domainSPF and DKIM both pass; DMARC published at least p=none; aligned SPF or DKIM makes DMARC passNon-compliant high-volume mail is rejected with 550 5.7.515

Microsoft's row applies to Outlook.com, Hotmail, Live, and MSN consumer addresses, not automatically to every Microsoft 365 tenant. Rejection began on May 5, 2025.

Official sources rechecked July 26, 2026: Google sender guidelines, Google sender FAQ, Yahoo Sender Requirements & Recommendations, Yahoo sender FAQ, Microsoft 550 5.7.515 guidance, Microsoft's enforcement announcement, and RFC 8058.

Google's November 2025 escalation covers non-compliant sender traffic broadly. Its current enforcement table lists missing one-click or missing the 48-hour honor window as mitigation-ineligible; the FAQ says that omission alone does not automatically reject or spam-folder a message. Yahoo began List-Unsubscribe enforcement in June 2024 and explicitly accepts mailto, although it highly recommends the RFC 8058 POST method.

Checked-in destination-provider profiles

These are startup defaults, not immutable provider quotas. They are seeded into Redis without overwriting operator changes and can be tuned through the MTA ISP-profile API. TLS minimums still compose strictest-wins.

ProviderInitial / ceiling / floor per minuteTLS floorConnectionsDeliveries per connection
Gmail100 / 300 / 5Required550
Microsoft80 / 200 / 5Opportunistic3100
Yahoo50 / 150 / 3Opportunistic3100
Apple60 / 150 / 5Opportunistic3100
Other30 / 100 / 2Opportunistic3100
Where the dashboards live

The session-scoped reputation dashboard reads getSendingOverview in apps/api/convex/analytics/reputationQueries.ts. The deployment-wide platform-admin reputation surface (roster, abuse status, content-review) is a backend/API surface in this OSS repo, not a bundled product dashboard — the rich control-plane UI was extracted to a separate private repo.

IP warming state (Convex-cached) and send estimates

The authenticated GET /ip-reputation response also carries a bounded routing snapshot when Convex supplies the singleton organization id. Every signal has an explicit receiver-provider scope, fixed source/severity taxonomy, and observation time. Convex rejects the whole snapshot if it is malformed or stale, then materializes at most one durable hysteresis row per known provider plus the global pool scope. Provider-local state is never widened into a global outage.

The MTA owns the real warming schedule and per-IP state in Redis. Convex keeps a cached, reactive copy so queries can subscribe to it without hitting the MTA on every read. syncWarmingState (apps/api/convex/delivery/warmingSync.ts) runs every 5 minutes (cron in crons.ts), fetches GET /ip-reputation from the MTA, caches every transactional and campaign IP, aggregates campaign capacity, and upserts the singleton warmingState row. If MTA_INTERNAL_URL / MTA_API_KEY are unset, it silently skips.

The cached row carries an overall phase (ramp / plateau / graduated), the summed campaign daily cap, today's campaign send count, an IP count, and a per-IP breakdown (phase, warming day, daily cap, sent today, bounce/deferral rate, pool, active/block reasons, DNSBL status, and the FCrDNS checklist).

Two client-facing queries read it (reputationQueries.ts):

  • getSendingOverview — combines warming state, daily send volume, the rolling 30-day org reputation summary, and the current abuse status into one card.
  • getCampaignSendEstimate — given a recipient count, estimates how many days a campaign will take based on remaining daily capacity, projecting forward conservatively (~1.5× cap growth per day) when IPs are still warming. Fully-warmed deployments report a single-day estimate.
Estimate is a projection

getCampaignSendEstimate is a UI-facing projection, not a scheduler. The actual pacing is enforced by the MTA's warming throttle at delivery time; this query only sets recipient expectations.

Suppression list and blocklist

The blockedEmails table is the address-level suppression list — the last line of defense for sender reputation. It is managed in apps/api/convex/blockedEmails.ts. Each row stores a normalized (lowercased, trimmed) address and a reason:

ReasonSource
bouncedHard bounce — the address doesn't exist
complainedRecipient marked the email as spam
manualOperator added it by hand

Blocked addresses are excluded from sends as part of the campaign audience eligibility predicate (soft-delete + email-present + suppression + DOI-if-topic). Auto-blocking happens through addFromEvent, the internal writer the bounce/complaint handlers call; it is idempotent (re-blocking an existing address returns the existing record). Operator-facing surface:

  • add / bulkAdd / remove — require the contacts:manage permission.
  • listByTeam (optionally filtered by reason), get, getByEmail, getCountsByReason — reads.
  • isBlocked (session) and isBlockedInternal (used by other Convex functions, no access check) — point lookups.

All lookups go through the by_email index on the normalized address; by_reason backs the filtered list and counts.

Daily send stats and content-scan gate thresholds

Daily counters

Two separate daily counters exist, for different purposes:

  • instanceSettings.dailySendCount — a single running counter for the current UTC day, bumped via nextDailySendCount (apps/api/convex/lib/sendingLimits.ts), which folds the increment into the instanceSettings patch (the bulk-send writer is incrementDailySendCountInternal in campaigns/sendQueries.ts). It is display-only; tier-based limits were removed and pacing is the MTA's job. getDailySendVolume resets it lazily on the first read of a new UTC day.
  • sendDailyStats — one row per UTC day with sent / delivered / opened / clicked counters, written by the Send lifecycle's daily_stats_bump effect through bumpSendDailyStat (apps/api/convex/lib/sendDailyStats.ts). The dashboard summary card reads the last 30 rows of this table instead of scanning every send.

The content-scan gate

Before a campaign fans out, the orchestrator (apps/api/convex/campaigns/send.ts) runs the address through the email scanner. It combines the local content score (scanContent from @owlat/email-scanner) with an optional Google Safe Browsing URL-reputation pass (only when GOOGLE_SAFE_BROWSING_API_KEY is set; URL-check failures never block a send). The combined 0–100 score maps to three levels:

ScoreLevelOutcome
≥ 40blockedCampaign reverts to draft with a contentBlockReason; send aborts
15–39suspiciousCampaign transitions to pending_review for platform-admin review
< 15cleanSend proceeds

Non-clean results are persisted to contentScanResults as an audit trail (keyed by resourceType + resourceId). The same scanner backs attachment and media-upload validation elsewhere; URL verdicts are cached in urlReputationCache (24h for clean, 1h for flagged). For the security-scanning internals, see Email Security.

Yahoo Complaint Feedback Loop (guided DKIM-domain enrollment)

Yahoo's CFL is DKIM-domain based: there is no API and no credential to store, only a bilateral enrollment an operator performs on Yahoo's sender site against a domain we already sign. So the integration is a guided flow plus a recorded state, not a client.

  • No second parser. A Yahoo CFL report is an ordinary RFC 5965 ARF message and flows through the shipped processor at apps/mta/src/bounce/fblProcessor.ts, whose ISP map already resolves yahoo, and through the shipped signed-VERP + persisted-provenance attribution in feedbackProvenance.ts. One complaint pipeline, three sources (Yahoo CFL, the RFC 9477 CFBL-Address feed, and the unsubscribe-rate proxy).
  • What the MTA now forwards. The reduced complained webhook event carries the RFC 5965 Reported-Domain (our sending/DKIM domain) and the resolved source ISP. Both are bounded at the emission site in outcome.ts: the reported domain is internet-controlled report text, and an oversized value that failed the webhook event validator would drop the whole complaint, which must always reach the blocklist.
  • The state machine lives in packages/shared/src/yahooCfl.ts and is pure — not_started → awaiting_yahoo → enrolled, plus lapsed when an enrolled domain has seen no Yahoo complaint for 90 days. A report may confirm and refresh an enrollment, never manufacture one: everything the observation path could gate on is report-supplied (the source-ISP token comes from the report's own User-Agent, the reported domain from its own RFC 5965 field) and the only authentication upstream is a VERP-attributed message id every recipient of a send already holds, so a report against a not_started domain is refused with reason not_submitted and writes no row at all. From awaiting_yahoo onward a report promotes and revives the enrollment regardless of what the operator recorded, and an out-of-order replay never rewinds lastReportAt. The Convex shell (apps/api/convex/domains/yahooCfl.ts) loads, calls, and writes.
  • The re-check is derived on read, not scheduled. There is no cron and no re-check write: getGuide computes lapsed from lastReportAt (falling back to enrolledAt) against the clock at read time, so the verdict is always current and can never go stale between passes. Report observation is the only write, and it is coalesced to at most one row patch per hour (YAHOO_CFL_REPORT_COALESCE_MS) — complaints arrive in bursts and all of a domain's reports land on one row, so patching per report would be the single-document contention ADR-0042 was written about. The observation also runs after suppression and inside a try/catch, and only for production delivery-domain traffic: a complaint must always reach the blocklist regardless of what the bookkeeping does, and member-preview mail must never mark an enrollment live.
  • The operator surface is a per-domain panel on the expanded domain row (Delivery → Domains, apps/web/app/components/domains/YahooCflPanel.vue): the four guided steps with their "how to tell it worked", Submit / Confirm / Start over controls for owners and admins (with the reason Submit is disabled rendered as visible, aria-describedby text, and a consequence-naming confirmation on Start over), and the confidence sentence the pure core always supplies. not_started renders as a calm, unstarted option — never a warning badge and never a "setup incomplete" nag.
  • Every operator event is audited. submitEnrollment / confirmEnrollment / resetEnrollment each write a sending_domain.yahoo_cfl_changed audit-log row — including refusals — carrying the event, whether the row changed, the resulting state and reason, and which complaint source the yahoo cell runs on afterwards. Start over is the sharp one: it is destructive and it downgrades that source from Yahoo's own feed to the unsubscribe-rate proxy, so the downgrade always names an actor.
  • The gate-3 trip point has one home, and it is a union of two. apps/api/convex/delivery/signals/yahooCfl.ts picks the live source and publishes a YahooComplaintTrip — the same rule evaluateStandaloneComplaintGate runs, never a second declaration of it. The two feed sources (Yahoo CFL, CFBL-Address) yield an absolute_rate trip at RAMP_GATE_THRESHOLDS.complaintMax from delivery/ramp/gateConfig.ts — the same branded RateFraction the ramp's own complaint gate compares against — and it is strict on the fail side: exactly the threshold passes. The proxy source yields a trailing_multiple trip at RAMP_GATE_THRESHOLDS.unsubscribeProxyMultiple against the cell's own 30-day trailing unsubscribe rate, and that one is inclusive on the fail side: exactly 3x fails, matching UNSUBSCRIBE_PROXY_SPEC's boundary: 'inclusive_fail' in trailingBaselineGates.ts, where the proxy's rule lives. Two kinds of trip point, two boundaries, one comparator: compareYahooComplaintRate is three-valued — breach / no_breach / not_comparable — so a rate the counters could not express, or a trailing baseline that cannot be a denominator, reads as the hold it is rather than as a healthy cell.
  • The DKIM precondition gates only our own wizard: Yahoo will not accept an enrollment for a domain it cannot see our signature on, so step 2 stays blocked until the domain is verified and carries an MTA DKIM selector. It never gates sending.
  • Absence is a supported configuration. A domain with no enrollment is simply not_started. The yahoo cell's complaint gate substitutes the CFBL feed (medium confidence) or the unsubscribe-rate proxy at 3x the cell's own 30-day trailing unsubscribe rate (medium confidence — a proxy, labelled as one, with the confidence sentence and the improvement suggestion stated on the cell) instead of the direct 0.1% complaint ceiling. The wizard publishes exactly the rule the controller applies: there is one definition of that trip point, not one for the screen and another for the gate. lapsed is a prompt to re-check at Yahoo, not an alarm: nothing stops, nothing errors, and there is no "setup incomplete" nag.
  • Re-checking a lapsed enrollment is a real transition. A lapsed domain's stored state is still enrolled, so both the affordance and the state machine key on the derived state: yahooCflAvailableActions re-offers Submit, the submit step reopens as a to-do, and re-submitting moves the record back to awaiting_yahoo with a fresh submittedAt, keeping lastReportAt (observed history) and dropping the now-stale enrolledAt. A live enrollment still refuses submission with already_enrolled — checked before the DKIM precondition, so an enrolled domain that later lost its DKIM readiness is never told to publish a record for a domain Yahoo already accepted. The fourth step reopens with it: "watch complaints arrive" asks whether a report has been seen since the current submission (lastReportAt >= submittedAt), not whether one was ever seen, so the kept lastReportAt cannot leave the step reading done — and claiming complaints are flowing — for an enrollment we just stopped believing was live. It closes again on the first report that beats the new submittedAt. The panel renders those affordances verbatim rather than deriving its own, so a control it offers is always one the state machine will service.

Custom tracking domains

Open/click links point at the deployment's own tracking host — the /t/o and /t/c HTTP actions, served at CONVEX_SITE_URL. A deployment can register and DNS-verify a branded subdomain so that, eventually, tracked links carry its own domain rather than a shared host. Registration, admin-gated DNS verification, and the Settings surface are in place; the send-time link rewrite is not yet wired (see the second bullet). The trackingDomains table is managed in apps/api/convex/domains/trackingDomains.ts and surfaced under Settings → Domains (TrackingDomainsSection.vue):

  • addTrackingDomain records the subdomain with a cnameTarget derived from CONVEX_SITE_URL's hostname — the host that actually serves the tracking handlers, never an external SaaS host. verifyTrackingDomain schedules a DNS-over-HTTPS (Cloudflare) CNAME check (verifyTrackingDomainDns) that flips the row to verified only when the CNAME resolves to that target. All three mutations require requireAdminContext.
  • The internal getActiveTrackingDomain query (trackingDomains.ts) exposes the first verified row to the send pipeline, but no producer consumes it yet. delivery/worker.ts derives trackingBaseUrl as envelopeInput.trackingBaseUrl ?? convexSiteUrl, and nothing on the campaign send path populates trackingBaseUrl from a tracking domain (apps/api/convex/campaigns/ has zero references to it). So tracked links still default to CONVEX_SITE_URL regardless of any verified domain — the branding half of this feature is registered and verified but not yet rendered into links.

Dual-transport alignment pre-flight

When a sending domain has two configured arms — the own MTA and a reference relay identity — the two must be indistinguishable to the receiver in everything except the sending infrastructure. Otherwise a share ramp between them measures DMARC alignment, not deliverability. apps/api/convex/delivery/alignmentPreflight.ts (state) and alignmentPreflightGather.ts (live DNS, Node runtime) re-verify four checks and store the verdict in deliverabilityAlignmentStates; the pure decision core is packages/shared/src/deliverabilityAlignment.ts. The sweep cron runs hourly and each domain carries its own nextCheckDueAt — 24h after a resolved verdict, ~1h after an unresolved lookup — so the shorter retry cadence is actually honoured; a domain that is not due costs one indexed read. Each run walks a bounded number of pages of verified domains and hands the cursor to a scheduled continuation, so a deployment with many domains re-checks all of them instead of only the first page.

  • From domain — identical on both arms. Blocking. A per-transport subdomain splits domain reputation and makes the arms incomparable; per-stream subdomains (news., bounces.) are the legitimate, separate thing and pass when both arms share them.
  • SPF — one v=spf1 record authorizing both arms, inside RFC 7208's 10-lookup limit. The counting and merge live in packages/shared/src/spfCoexistence.ts (one implementation, on top of the shipped spf.ts merge primitives); an over-limit record names the include: to flatten.
  • DKIM — both arms sign the same d= with distinct selectors, proven by live TXT lookup. An empty p= (revoked) fails.
  • DMARC — a single _dmarc record exists and both arms align under its mode (adkim=s requires d= to equal the From domain exactly).

The reference arm, and why it must be singular

The adaptive mix has exactly two arms: the own arm (OWN_ARM_TRANSPORT_KIND = 'mta') and one reference arm — whichever relay this deployment sends the other share through. Every other kind is automatically the reference arm; there is no per-provider controller code, which is how Mailchimp Transactional became a first-class migration arm without the controller learning anything about it. The identity that describes an arm comes from the sending-domain provider registry (domains/providers/<kind>/describeReferenceArm), so a provider ships its own arm the same way it ships its own relay proof.

Keep exactly one relay enabled during a migration. With two, there is no single second arm for a domain to be compared against: the pre-flight records unknown (a hold, never a pass), the verdict degrades measurement confidence, and every cell sits at its current share. That is not an error state to be fixed by verifying something — it is a configuration question with one answer, so the unknown detail says which relays are enabled and the dashboard surfaces it as its own warning on Delivery → Provider and Delivery → Cells rather than as a fourth DNS finding. A team arriving from Mailchimp hits this the moment they leave an old SES or Resend key set beside MANDRILL_API_KEY.

Relays whose proof ages out are held the same way. A Mandrill sending-domain identity is re-confirmed by an hourly sweep and expires after 7 days (MANDRILL_RELAY_PROOF_MAX_AGE_MS); an expired one reads "re-checking" and holds the share rather than reporting a verified relay routing has already stopped trusting.

Semantics that matter more than the checks themselves:

  • A lookup that could not be answered is unknown, not a failure and not a pass. Timeout / SERVFAIL / REFUSED hold the cell at its current share and are retried in an hour; NXDOMAIN and an empty answer are authoritative absences and fail. This is the one DNS read in the backend that deliberately does not fail soft to "not found".
  • No reference transport means nothing to align — and nothing to store. The sweep skips the domain entirely: no arms to compare, no DNS lookups, no verdict row. The readiness panel therefore renders no alignment warning for a deployment with zero third-party accounts; no error or "setup incomplete" state appears anywhere.
  • A stored single_arm verdict stops being evidence the moment a relay is configured. It was recorded while no second arm existed, and it is the one verdict a domain could hold forever without a refresh, so the gate does not honour it: with a reference arm in play the row falls through to not_yet_checked and holds until the sweep produces a real two-arm verdict. referenceArm === 'none' is the only single-arm ground for opening. (Rows like this exist only from before the sweep stopped writing them; the mirror of the "a relay-only domain is skipped" rule below.)
  • A relay we cannot describe holds, and is never called single_arm. The shipped transport set is wider than SES (mta/ses/resend/smtp plus plugin.*). When a relay is enabled but the domain has no signing identity we can compare against, the verdict is unknown — a hold — because answering "no second arm" for a transport we merely failed to describe would let two genuinely unaligned arms ramp.
  • The own arm's SPF mechanisms come from MTA_IP_POOLS, not from the domain's stored SPF value (which for an SES-registered domain is the relay's include). With no pool configured the SPF check is unknown, never a pass on a relay-only record.
  • A relay-only domain is skipped, not blocked. A verified domain with no own-MTA identity has no arm to compare, so no verdict row is written — an unactionable permanent blocked would be an error state manufactured for a supported configuration.
  • The readiness surface (getAlignmentReadinessapps/web/app/utils/deliveryReadiness.ts → the delivery readiness panel) renders a gate only when a reference transport is really in play, and that gate never changes the send-path verdict: what it holds is the ramp, not sending.
  • Return-Path is recorded, never blocking. A relay that cannot carry the custom VERP return path only marks the domain's measurement as degraded.

Delivery measurement screen (read-only)

/dashboard/admin/delivery/advanced/measurement renders what the ramp gates see, before anything acts on it. One org-scoped query — apps/api/convex/delivery/deliverabilityDashboard.ts — reads each (stream, destinationProvider) cell's transport outcomes for both arms and returns, per cell: both arms' counters and rates, every gate's verdict with the numbers that produced it, the measurement confidence, and one trend point per day of the window. There are no controls and no writes behind this screen.

  • The window is pinned, not chosen. The query takes no arguments: dashboardWindow() derives the 7-day evaluation window, the trailing baseline gate 4b compares against, and the widest bound the single index read has to cover. The baseline ends where the evaluation window begins — a baseline that overlapped the recent window would already carry the decay the slow-poison floor exists to detect, and the screen would render a verdict the controller would never reach.
  • Every rate is the summarizer's. The query re-runs summarizeTransportOutcomeBuckets over the rows it read — one read per (cell, arm), several disjoint sub-windows derived from it — and the Vue components format what they were handed. Nothing in apps/web/app/utils/deliverabilityMeasurement.ts divides, so the screen and the ramp controller cannot report different numbers for the same traffic (ADR-0042).
  • Thin data is a state, not a failure. A gate below its sample floor renders neutrally as "not enough data yet — N of 400 sends this window". A cell nobody sent through renders empty and calm.
  • No reference transport is a supported configuration — measured by a different gate implementation. With no relay in play the query selects trailingBaselineGateEvaluator (apps/api/convex/delivery/ramp/gateEvaluation.ts, chosen once per org rather than per cell), so the gate rows a standalone operator reads are the trailing-baseline substitutions — a cell's own 30-day history stands in for the second arm — and not the two-armed comparisons the rest of this section describes: hard bounce is absolute plus ≤ 1.5× the cell's trailing rate, deferral is promoted to the primary fast signal — the deferral rate, with the per-ISP block-message hard stop (evaluateSmtpBlockMessages) outranking it: the MTA classifies every 4xx/5xx it receives into the shared @owlat/shared/smtpBlockCategories vocabulary and reports the verdict as a typed field on an smtp.classified webhook, analytics/smtpResponseCategories.ts counts it per (stream, destinationProvider) cell, arm and UTC day, and a window whose classified responses are at least 0.5% refusals halts the cell outright rather than merely failing it (issue #501). Refusal means the receiver rejecting our content, our sending identity or our IP — throttling and greylisting are rate pressure, they are counted in the same window as the denominator, and they never contribute to that numerator. A cell whose window contains no classified response at all is ABSENT rather than clean: the clause returns no verdict and the deferral rate decides on its own — one-click unsubscribe at ≥ 3× trailing stands in for complaints where no feedback loop exists, engagement compares against the cell's own EWMA (≥ 0.85, 7-day window, 2000-send floor) and may only ever justify a decrease, and placement reads the self-hosted seeds absolutely. What apps/api/convex/delivery/ramp/trailingBaselineGates.ts owns is the shape of each substitution, not its numbers: TRAILING_HARD_BOUNCE_SPEC, UNSUBSCRIBE_PROXY_SPEC and TRAILING_ENGAGEMENT_SPEC say which series is compared against which (the recent window against the cell's own trailing baseline, rather than against a second arm), that the bounce and unsubscribe comparisons are kind: 'multiple' — a ratio, because a different month with a different list can only be judged relatively — and which side of the boundary each one sits on: inclusive_pass for the 1.5× bounce allowance (exactly 1.5× still passes) and inclusive_fail for the 3× unsubscribe proxy (exactly 3× fails). The values live where every other ramp threshold does: RAMP_GATE_THRESHOLDS.hardBounceTrailingMultiple, RAMP_GATE_THRESHOLDS.unsubscribeProxyMultiple and RAMP_GATE_SAMPLE_FLOORS.engagementTrailing in delivery/ramp/gateConfig.ts, and ENGAGEMENT_GATE_THRESHOLDS.trailingBaselineRatio in delivery/ramp/engagementConfig.ts. The 7-day window is deliberately not a constant anywhere — the gates take their windows as parameters, so the caller that picked the window is the one that names it.
  • Confidence on a one-armed cell has three cases, not one. dashboardConfidence takes the weakest of what the gates actually measured and a ceiling set by which instruments exist: none for a cell nobody has sent through (ownSent <= 0, short-circuited before any grade is read), low when neither a relay nor seed mailboxes are connected, and medium when seed mailboxes exist without a relay — a shipped configuration since the self-hosted seeds landed, not a hypothetical one. high is reachable only with a reference arm. Beside the level the card names what would raise it — connect a relay, add seed mailboxes, send more volume — as an invitation. The headline is "Warm-up autopilot" rather than a degraded "Sending independence". Nothing errors, warns or nags.