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:
| Field | Type | Meaning |
|---|---|---|
messageType | campaign | transactional | automation | One route per message type |
strategy | single | priority_failover | workload_split | adaptive_mix | Selection algorithm (adaptive_mix is controller-owned, not operator-selectable) |
providers | array of { providerType, weight?, isEnabled } | Ordered candidate set; built-ins are mta / ses / resend / smtp |
ipPool | string (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/:
| Strategy | Behaviour |
|---|---|
single | Always use the first enabled provider. Ignores health. |
priority_failover | Walk 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_split | Weighted-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_mix | Deterministic 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.
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:
- Retry loop driven by each provider module's
retryDelaysandcategorizeError. Each attempt calls the module's single-attemptsendEmail. - Health recording — after every terminal outcome (success or exhausted retries) it schedules
recordSendResulton the Send provider health module, so even bypass callers (test sends, automation steps) record health. - 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:
| Status | Condition |
|---|---|
healthy | success rate ≥ 90% |
degraded | success rate ≥ 50% and < 90% |
down | success 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 type | Counters bumped |
|---|---|
send | totalSent |
deliver | totalDelivered |
bounce | totalBounced |
hard_bounce | totalBounced and totalHardBounced |
complaint | totalComplaints |
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:
| Risk | Trigger (with ≥ 100 sends in window) |
|---|---|
low | below the medium thresholds |
medium | complaint rate ≥ 0.1% or bounce rate ≥ 2% |
high | complaint rate ≥ 0.2% or bounce rate ≥ 5% |
critical | complaint 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 (high → warned, critical → suspended) 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:
summarizeSpamRateand 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 /sendsuccess means only queue acceptance and advances a Send tosent; it never increments delivered volume. The later authenticated MTAsentwebhook 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 indeliveredAt; that single idempotency seam records campaign, daily, and reputation delivery effects once without regressing an advanced or terminal display status. SendbouncedAt/failedAtand Postbox recipientbouncedAt/failedAtestablish 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 inacceptedAt. The Phase-2DestinationSnapshot.providerKeytravels 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 MTAacceptedAtchooses the measurement hour, while trusted ConvexingestedAtcontrols 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 boundary | Current receiver rule | Owlat control |
|---|---|---|
| Gmail bulk proximity | Classification is around 5,000 messages in 24 hours to personal Gmail, aggregated by primary sending domain | Warn at 4,000 accepted Gmail-attributed messages in the rolling approximation; display the 5,000 classifier boundary |
| Gmail spam rate | Keep Postmaster Tools below 0.10%; avoid 0.30% or higher | Display target and hard line; count seven internal clean sending days only as investigation evidence |
| Yahoo spam rate | Keep below 0.3%; Yahoo's denominator is mail delivered to the inbox | Display the same hard line, but never label Owlat's denominator as Yahoo's rate |
| One-click processing | Gmail bulk marketing/subscribed mail needs RFC 8058; Yahoo requires functioning List-Unsubscribe and highly recommends RFC 8058; both expect completion within 2 days | Refuse campaign/marketing-automation envelopes without signed RFC 8058 headers; apply suppression synchronously; alert when p95 crosses 48 hours |
| Campaign complaints | Receiver placement can degrade before a whole organization crosses a breaker | Alert 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:
| Signal | Window | Opens above |
|---|---|---|
| Bounce, fast | Last 50 outcomes | 15% |
| Bounce, sustained | Last 100 outcomes | 8% |
| Complaint, fast | Last 50 outcomes | 4% |
| Complaint, sustained | Last 100 outcomes | 0.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 scope | Authentication floor | Other current requirements |
|---|---|---|
| Gmail, all senders | SPF or DKIM | Valid forward/reverse DNS, TLS, RFC 5322, spam below 0.3% |
| Gmail, bulk | SPF and DKIM; DMARC at least p=none; From aligned with SPF or DKIM | All-sender floor; RFC 8058 plus visible body unsubscribe for marketing/subscribed mail; honor within 48 hours |
| Yahoo, all senders | SPF or DKIM | Valid forward/reverse DNS, RFC 5321/5322, spam below 0.3% |
| Yahoo, bulk | SPF and DKIM; passing DMARC at least p=none; From alignment | Functioning List-Unsubscribe plus visible body link for marketing/subscribed mail; honor within 2 days |
| Outlook.com consumer, 5,000+ per day per From domain | SPF and DKIM both pass; DMARC published at least p=none; aligned SPF or DKIM makes DMARC pass | Non-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.
| Provider | Initial / ceiling / floor per minute | TLS floor | Connections | Deliveries per connection |
|---|---|---|---|---|
| Gmail | 100 / 300 / 5 | Required | 5 | 50 |
| Microsoft | 80 / 200 / 5 | Opportunistic | 3 | 100 |
| Yahoo | 50 / 150 / 3 | Opportunistic | 3 | 100 |
| Apple | 60 / 150 / 5 | Opportunistic | 3 | 100 |
| Other | 30 / 100 / 2 | Opportunistic | 3 | 100 |
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.
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:
| Reason | Source |
|---|---|
bounced | Hard bounce — the address doesn't exist |
complained | Recipient marked the email as spam |
manual | Operator 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 thecontacts:managepermission.listByTeam(optionally filtered by reason),get,getByEmail,getCountsByReason— reads.isBlocked(session) andisBlockedInternal(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 vianextDailySendCount(apps/api/convex/lib/sendingLimits.ts), which folds the increment into theinstanceSettingspatch (the bulk-send writer isincrementDailySendCountInternalincampaigns/sendQueries.ts). It is display-only; tier-based limits were removed and pacing is the MTA's job.getDailySendVolumeresets it lazily on the first read of a new UTC day.sendDailyStats— one row per UTC day withsent/delivered/opened/clickedcounters, written by the Send lifecycle'sdaily_stats_bumpeffect throughbumpSendDailyStat(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:
| Score | Level | Outcome |
|---|---|---|
| ≥ 40 | blocked | Campaign reverts to draft with a contentBlockReason; send aborts |
| 15–39 | suspicious | Campaign transitions to pending_review for platform-admin review |
| < 15 | clean | Send 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 resolvesyahoo, and through the shipped signed-VERP + persisted-provenance attribution infeedbackProvenance.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
complainedwebhook event carries the RFC 5965Reported-Domain(our sending/DKIM domain) and the resolved source ISP. Both are bounded at the emission site inoutcome.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.tsand is pure —not_started → awaiting_yahoo → enrolled, pluslapsedwhen 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 ownUser-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 anot_starteddomain is refused with reasonnot_submittedand writes no row at all. Fromawaiting_yahooonward a report promotes and revives the enrollment regardless of what the operator recorded, and an out-of-order replay never rewindslastReportAt. 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:
getGuidecomputeslapsedfromlastReportAt(falling back toenrolledAt) 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 atry/catch, and only forproductiondelivery-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-describedbytext, and a consequence-naming confirmation on Start over), and the confidence sentence the pure core always supplies.not_startedrenders as a calm, unstarted option — never a warning badge and never a "setup incomplete" nag. - Every operator event is audited.
submitEnrollment/confirmEnrollment/resetEnrollmenteach write asending_domain.yahoo_cfl_changedaudit-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.tspicks the live source and publishes aYahooComplaintTrip— the same ruleevaluateStandaloneComplaintGateruns, never a second declaration of it. The two feed sources (Yahoo CFL, CFBL-Address) yield anabsolute_ratetrip atRAMP_GATE_THRESHOLDS.complaintMaxfromdelivery/ramp/gateConfig.ts— the same brandedRateFractionthe ramp's own complaint gate compares against — and it is strict on the fail side: exactly the threshold passes. The proxy source yields atrailing_multipletrip atRAMP_GATE_THRESHOLDS.unsubscribeProxyMultipleagainst the cell's own 30-day trailing unsubscribe rate, and that one is inclusive on the fail side: exactly 3x fails, matchingUNSUBSCRIBE_PROXY_SPEC'sboundary: 'inclusive_fail'intrailingBaselineGates.ts, where the proxy's rule lives. Two kinds of trip point, two boundaries, one comparator:compareYahooComplaintRateis 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
blockeduntil the domain isverifiedand 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.lapsedis 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:yahooCflAvailableActionsre-offers Submit, the submit step reopens as a to-do, and re-submitting moves the record back toawaiting_yahoowith a freshsubmittedAt, keepinglastReportAt(observed history) and dropping the now-staleenrolledAt. A live enrollment still refuses submission withalready_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 keptlastReportAtcannot leave the step readingdone— and claiming complaints are flowing — for an enrollment we just stopped believing was live. It closes again on the first report that beats the newsubmittedAt. 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):
addTrackingDomainrecords the subdomain with acnameTargetderived fromCONVEX_SITE_URL's hostname — the host that actually serves the tracking handlers, never an external SaaS host.verifyTrackingDomainschedules 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 requirerequireAdminContext.- The internal
getActiveTrackingDomainquery (trackingDomains.ts) exposes the first verified row to the send pipeline, but no producer consumes it yet.delivery/worker.tsderivestrackingBaseUrlasenvelopeInput.trackingBaseUrl ?? convexSiteUrl, and nothing on the campaign send path populatestrackingBaseUrlfrom a tracking domain (apps/api/convex/campaigns/has zero references to it). So tracked links still default toCONVEX_SITE_URLregardless 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=spf1record authorizing both arms, inside RFC 7208's 10-lookup limit. The counting and merge live inpackages/shared/src/spfCoexistence.ts(one implementation, on top of the shippedspf.tsmerge primitives); an over-limit record names theinclude:to flatten. - DKIM — both arms sign the same
d=with distinct selectors, proven by live TXT lookup. An emptyp=(revoked) fails. - DMARC — a single
_dmarcrecord exists and both arms align under its mode (adkim=srequiresd=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_armverdict 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 tonot_yet_checkedand 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/smtpplusplugin.*). When a relay is enabled but the domain has no signing identity we can compare against, the verdict isunknown— 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 isunknown, 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
blockedwould be an error state manufactured for a supported configuration. - The readiness surface (
getAlignmentReadiness→apps/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
summarizeTransportOutcomeBucketsover 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 inapps/web/app/utils/deliverabilityMeasurement.tsdivides, 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/smtpBlockCategoriesvocabulary and reports the verdict as a typed field on ansmtp.classifiedwebhook,analytics/smtpResponseCategories.tscounts 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. Whatapps/api/convex/delivery/ramp/trailingBaselineGates.tsowns is the shape of each substitution, not its numbers:TRAILING_HARD_BOUNCE_SPEC,UNSUBSCRIBE_PROXY_SPECandTRAILING_ENGAGEMENT_SPECsay 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 arekind: '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_passfor the 1.5× bounce allowance (exactly 1.5× still passes) andinclusive_failfor the 3× unsubscribe proxy (exactly 3× fails). The values live where every other ramp threshold does:RAMP_GATE_THRESHOLDS.hardBounceTrailingMultiple,RAMP_GATE_THRESHOLDS.unsubscribeProxyMultipleandRAMP_GATE_SAMPLE_FLOORS.engagementTrailingindelivery/ramp/gateConfig.ts, andENGAGEMENT_GATE_THRESHOLDS.trailingBaselineRatioindelivery/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.
dashboardConfidencetakes the weakest of what the gates actually measured and a ceiling set by which instruments exist:nonefor a cell nobody has sent through (ownSent <= 0, short-circuited before any grade is read),lowwhen neither a relay nor seed mailboxes are connected, andmediumwhen seed mailboxes exist without a relay — a shipped configuration since the self-hosted seeds landed, not a hypothetical one.highis 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.