MTA System
Owlat's custom Mail Transfer Agent for direct SMTP delivery with intelligent rate limiting, bounce processing, and IP warming.
The Owlat MTA (apps/mta/) is a custom Mail Transfer Agent that delivers emails via direct SMTP to recipient mail servers. It replaces third-party provider APIs with a self-hosted service that provides full control over sending reputation, ISP-specific throttling, IP warming, and bounce processing.
The MTA is one of the delivery providers a deployment can select, with EMAIL_PROVIDER=mta (or a per-org provider route). It is not an implicit default: with no route and no EMAIL_PROVIDER value, resolution is fail-closed and sends are refused rather than dispatched here. Amazon SES, Resend, a generic SMTP relay and Mailchimp Transactional are the alternatives — see Providers for the full list and ADR-005 for the rationale behind building a custom MTA.
What the MTA Is
- A specialized delivery engine — optimized for getting emails into inboxes with maximum deliverability
- An intelligent rate controller — adaptive per-ISP throttling, IP warming, circuit breakers, engagement-based priority
- A bounce processor — dedicated SMTP server for DSN/ARF parsing with auto-suppression
- A reputation guardian — DNSBL monitoring, per-org circuit breakers, graceful degradation under pressure
- A stateless worker — delivers and reports back via webhooks; no long-term message storage
What the MTA Is Not
- Not a complete mail server — no message archival, no full-text search, no web UI for browsing messages (unlike Postal, Mailcow, or iRedMail)
- Not a standalone product — designed as a component of the Owlat platform, depends on the Convex backend for campaign orchestration, tracking, domain management, and billing
- Not a tracking system — click/open tracking happens upstream in the Convex backend, not in the MTA
- Not a full inbound spam filter — it does authenticate inbound mail (SPF, DKIM, and DMARC) and can score outbound content with an optional rspamd sidecar, but content screening and attachment malware scanning are primarily handled via the
@owlat/email-scannerpackage (see Email Security) - Not a user-facing service — API-only, managed programmatically; no admin dashboard or self-service UI
Design Philosophy
The MTA follows a depth over breadth approach: rather than building a complete mail server that does many things adequately, it focuses exclusively on outbound delivery intelligence. Every feature serves one goal — maximizing inbox placement while protecting sender reputation.
Key architectural decisions:
- Stateless process, stateful Redis — the MTA process itself holds no state; all intelligence data lives in Redis with TTLs. This enables horizontal scaling and zero-downtime deploys.
- Intelligence before delivery — every email passes through a typed ten-phase dispatch pipeline before touching SMTP. Most MTAs send first and react to failures; Owlat prevents failures proactively.
- Engagement-aware ordering — high-engagement recipients are sent first during IP warming, maximizing positive ISP signals when reputation matters most.
- Graceful degradation — back-pressure at the API level (429), per-domain backoff, emergency mode (503) when all IPs are blocked. The system protects itself and its customers automatically.
System Components
| Component | Directory | Purpose |
|---|---|---|
| HTTP API | src/routes/ | Hono server accepting send requests from the Convex backend |
| GroupMQ Worker | src/queue/ | Redis-backed job queue with group-based processing |
| Dispatch Pipeline | src/dispatch/ | Typed, composable phase pipeline — ten ordered pre-send checks (ADR-0007) |
| Intelligence | src/intelligence/ | The check implementations the dispatch phases delegate to |
| SMTP Sender | src/smtp/ | Direct MX delivery with DKIM signing and MTA-STS enforcement |
| SMTP Submission | src/smtp/ | Authenticated SMTP submission server (port 587) |
| Connection Pool | src/smtp/ | Reusable SMTP transport pool per MX host, with cross-instance coordination |
| DKIM Key Store | src/smtp/ | Redis-backed DKIM key storage with automatic rotation |
| Bounce / Inbound Server | src/bounce/ | Inbound SMTP server for DSN/ARF parsing, classification, and inbound mail |
| Inbound Router | src/inbound/ | Rule-based inbound routing + personal-mailbox resolution |
| Credentials | src/auth/ | Per-organization API key and Postbox authentication |
| Webhook Notifier | src/webhooks/ | Event callbacks to the Convex backend |
| Monitoring | src/monitoring/ | Prometheus metrics, structured logging, Google Postmaster fetch |
| Scaling | src/scaling/ | IP pool management, pool rules, and graceful degradation |
| Leader Election | src/lib/ | Redis lock so periodic crons run on a single instance |
| Attachment Scanner | src/routes/scan.ts | File type validation + ClamAV malware scanning endpoint |
HTTP API Endpoints
The MTA supports two authentication modes: the master MTA_API_KEY (accepted on all endpoints) and per-organization API keys (accepted on /send endpoints only). Management endpoints require the master key.
Core
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /send | Bearer | Queue a single email for delivery |
GET | /health | None | System health check (Redis, queue depth) |
GET | /metrics | None | Prometheus metrics endpoint |
Credential Management
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /credentials | Master | Create a per-org API credential |
GET | /credentials | Master | List credentials (filter by ?organizationId=) |
DELETE | /credentials/:apiKey | Master | Revoke a credential |
DKIM Management
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /dkim | Master | Add or update a DKIM key |
GET | /dkim | Master | List all DKIM domains (keys redacted) |
DELETE | /dkim/:domain | Master | Remove a DKIM key |
POST | /dkim/:domain/rotate | Master | Generate a new RSA 2048 key pair |
Inbound Routing
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /inbound/routes | Master | Create or update an inbound route |
GET | /inbound/routes | Master | List all inbound routes |
DELETE | /inbound/routes/:domain/:address | Master | Remove an inbound route |
Personal Mailboxes
The MTA keeps a Redis cache of personal-mailbox (Postbox) addresses so the inbound SMTP server can resolve a recipient in O(1) without a Convex round-trip. Convex pushes mailbox CRUD into this endpoint group (src/routes/mailboxes.ts).
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /mailboxes/cache/:address | Master | Create or refresh a mailbox cache entry (mailboxId, organizationId, optional quota) |
DELETE | /mailboxes/cache/:address | Master | Remove a cache entry |
GET | /mailboxes/cache | Master | List cached addresses |
See Postbox Architecture for how Convex keeps this cache in sync.
ISP Profiles
Adaptive throttling profiles can be tuned at runtime without redeploying. Profiles are seeded from ISP_PROFILES in src/config.ts on startup (via HSETNX, so runtime overrides survive restarts) and managed through src/routes/ispProfiles.ts.
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /isp-profiles | Master | List all profiles (seeded + custom) |
GET | /isp-profiles/:domain | Master | Get the effective profile for a domain |
PUT | /isp-profiles/:domain | Master | Create or update a profile (defaultRate, ceiling, floor, backoffFactor, recoveryFactor) |
DELETE | /isp-profiles/:domain | Master | Remove a custom profile (reverts to the seeded default) |
Updates are validated: floor <= ceiling, defaultRate within [floor, ceiling], 0 < backoffFactor < 1, and recoveryFactor > 1.
IP Reputation
A read-only dashboard that aggregates per-IP warming state, pool status, and today's delivery metrics (src/routes/ipReputation.ts).
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /ip-reputation | Master | Summary row per configured IP (metrics, warming, pool eligibility, DNSBL, FCrDNS) |
GET | /ip-reputation/:ip | Master | Full reputation view for one IP, including block reasons and identity readiness |
Organization Limits
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /org-limits | Master | Set daily/hourly send limits for an organization |
GET | /org-limits/:orgId | Master | Get organization usage and limits |
Pool Rules
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /pool-rules | Master | Set pool assignment for an organization |
GET | /pool-rules/:orgId | Master | Get organization pool rule |
DELETE | /pool-rules/:orgId | Master | Remove organization pool rule |
Suppression List
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /suppression | Master | Add addresses to the suppression list (batch) |
DELETE | /suppression/:email | Master | Remove an address from the suppression list |
GET | /suppression/check/:email | Master | Check suppression status for an address |
POST | /suppression/bulk | Master | Add up to 10,000 addresses in one request |
GET | /suppression/export | Master | Paginated export with metadata (?reason=, ?cursor=) |
GET | /suppression/stats | Master | Counts by suppression reason |
Attachment Scanning
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /scan/attachment | Master | Scan file for malware (file type validation + ClamAV) |
GET | /scan/health | None | ClamAV connection status |
The scan endpoint performs two checks: file type validation (magic bytes, double extension, extension allowlist) and ClamAV malware scanning. The endpoint is fail-open — if ClamAV is unavailable, the file passes with a warning. See Email Security for details on the scanning pipeline.
Delivery Logs
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /delivery-logs | Master | Query events by date, orgId, status, domain (paginated) |
GET | /delivery-logs/stats | Master | Aggregated counts by status/domain/pool for a date range |
GET | /delivery-logs/:messageId | Master | All delivery events for a specific message |
Queue Inspection
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /queue/stats | Master | Queue depth by state (pending, active, completed, failed, delayed) |
GET | /queue/pending | Master | List pending jobs (?limit=, ?offset=, ?domain=) |
GET | /queue/jobs/:jobId | Master | Full job details with attempt history |
DELETE | /queue/jobs/:jobId | Master | Cancel a specific pending job |
POST | /queue/flush | Master | Cancel all pending jobs for an org (?orgId=) |
Dead Letter Queue
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /dlq | Master | List failed webhook events (?limit=, ?offset=) |
GET | /dlq/stats | Master | Total count and oldest entry age |
POST | /dlq/:dlqId/retry | Master | Retry a specific failed webhook event |
POST | /dlq/retry-all | Master | Retry all DLQ entries |
DELETE | /dlq/:dlqId | Master | Discard a specific failed event |
The /send endpoint accepts both the master MTA_API_KEY and per-organization API keys (owlat_...). All management endpoints — /credentials, /dkim, /org-limits, /pool-rules, /suppression, /inbound/routes, /mailboxes, /isp-profiles, /ip-reputation, /delivery-logs, /queue, /dlq, and /scan — require the master key (enforced inside each route group).
Send Payload
interface EmailJob {
messageId: string // Unique ID for correlation
to: string // Recipient email
from: string // Sender address
subject: string // Email subject
html: string // HTML body
text?: string // Plain text body
replyTo?: string // Reply-to address
headers?: Record<string, string>
ipPool: 'transactional' | 'campaign'
organizationId: string // For circuit breaker scoping
engagementScore?: number // 0-100 from Convex; omitted when unknown
dkimDomain: string // Domain for DKIM signing
}
Dispatch Pipeline
Every job runs through a typed, composable dispatch pipeline before any SMTP connection is opened (ADR-0007). The pipeline lives in src/dispatch/ and is the orchestration layer; each phase delegates the actual check to its implementation in src/intelligence/*.ts or src/scaling/*.ts.
Architecture
src/dispatch/pipeline.tsdefinesPhase<TIn, TOut>— a named step whose input context type threads into the next phase.compose(...)chains phases andrunPipeline(...)runs them. The type system enforces ordering: a phase that consumes the resolvedpool/ip(e.g.selectIp,acquireSlot) cannot be placed before the phase that produces it, or thecompose(...)call fails to type-check.- A phase returns one of three outcomes:
continue(carry the context forward),defer(re-queue the attempt after a delay), ordrop(end the attempt with statusscreenedorsuppressed, no re-queue). src/dispatch/phases/index.tscomposesmainPipelinein the real order (below).src/queue/handler.tsis the only GroupMQ-coupled file. The pipeline never throws; the handler translates adeferoutcome into GroupMQ'sDeferError(with ±15% jitter to avoid a thundering herd) and translates adropinto a logged terminal outcome.- After the pipeline returns
continue, the handler callssendToMx, then the pure reducer insrc/dispatch/outcome.tsclassifies the result into a typed effect list (src/dispatch/effects.ts) — tracking circuit-breaker, throttle, warming, metrics, suppression, and webhook side effects.
Phase order
mainPipeline runs ten phases in this exact order:
| # | Phase | Delegates to | Outcome on failure |
|---|---|---|---|
| 1 | content_screening | intelligence/contentScreening.ts | drop (screened) |
| 2 | suppression | intelligence/suppressionList.ts | drop (suppressed) |
| 3 | circuit_breaker | intelligence/circuitBreaker.ts | defer |
| 4 | org_limit | intelligence/orgLimits.ts | defer |
| 5 | smtp_intel | intelligence/smtpResponse.ts | defer |
| 6 | domain_backoff | scaling/degradation.ts | defer |
| 7 | resolve_pool | scaling/poolRules.ts | continue (enriches ctx with pool + dedicated IP) |
| 8 | select_ip | scaling/ipPool.ts | defer (enriches ctx with the bound IP) |
| 9 | acquire_slot | intelligence/domainThrottle.ts | defer |
| 10 | warming_cap | intelligence/warming.ts | defer |
The two pool/IP phases (7 and 8) enrich the context rather than gating; the throttle slot acquire (9) runs after IP selection because the throttle is scoped per IP and recipient domain.
Content pre-screening (phase 1)
File: intelligence/contentScreening.ts
Inspects content before any reputation-affecting work happens. Catches malformed or dangerous mail. Enabled by default (CONTENT_SCREENING_ENABLED=true); failures drop the job.
| Check | Notes |
|---|---|
DKIM domain alignment (dkimDomain matches the From header domain) | |
Email size budget (HTML exceeds CONTENT_MAX_SIZE_KB, default 500 KB) | |
Empty body (both html and text missing) | |
| Required headers (From, Subject present and non-empty) | |
| URL blocklist (known phishing/malware patterns in Redis) | |
| rspamd spam score (optional) | Only when RSPAMD_URL is set; rejects above RSPAMD_REJECT_THRESHOLD (default 15) |
Suppression list (phase 2)
File: intelligence/suppressionList.ts
Global suppression list. A suppressed recipient drops the job silently (no retry).
| Reason | Source |
|---|---|
hard_bounce | Auto-added when a hard bounce is detected |
complaint | Auto-added on ISP spam complaint |
manual | Added via the /suppression management API |
Suppressed addresses live in a Redis set, normalized to lowercase. Managed via POST /suppression, DELETE /suppression/:email, and GET /suppression/check/:email.
Circuit breaker (phase 3)
File: intelligence/circuitBreaker.ts
Per-organization, real-time protection tracking both bounces and complaints in a sliding ring buffer (the markers d/b/c for delivered/bounced/complained). Three states:
| State | Behavior |
|---|---|
| Closed (normal) | All sends proceed. Trips to Open when a threshold is exceeded. |
| Open (paused) | All org sends are deferred. Cools down for 30 minutes. |
| Half-Open (testing) | Up to 5 test sends proceed. A bounce/complaint re-opens with a 60-minute cooldown; all clean closes the circuit. |
Thresholds (circuitBreaker.ts):
| Signal | Fast (last 50 sends) | Slow (last 100 sends) |
|---|---|---|
| Bounce rate | > 15% | > 8% |
| Complaint rate | > 4% | > 0.2% |
Complaints are weighted far more heavily than bounces because ISPs blocklist on complaint rates an order of magnitude lower than bounce rates. When the circuit trips, an org.circuit_breaker webhook event (severity critical) is sent to Convex.
Organization rate limits (phase 4)
File: intelligence/orgLimits.ts
Per-organization daily and hourly send caps enforced via Redis counters.
| Limit | Default | Counter TTL |
|---|---|---|
| Daily | 50,000 | 48 hours |
| Hourly | 5,000 | 2 hours |
Per-org overrides are set via POST /org-limits. Exceeding a limit defers the job with a retryAfter delay until the next period boundary (midnight UTC for daily, next hour for hourly).
SMTP response intel (phase 5)
File: intelligence/smtpResponse.ts
Tracks SMTP response codes (4xx/5xx) per recipient domain. When recent patterns indicate a degraded or blocking destination, shouldDefer returns a positive delay and the phase defers the attempt.
Domain connection backoff (phase 6)
File: scaling/degradation.ts
Tracks per-domain connection failures and defers jobs targeting a domain currently in backoff, with a calculated retry delay.
Pool resolution (phase 7)
File: scaling/poolRules.ts
Enriches the context with the effective IP pool and any dedicated IP. Resolution priority:
- Organization-specific pool rule (if set via
POST /pool-rules) - The request's
ipPoolfield - Default pool
A dedicated IP, if configured, bypasses round-robin selection downstream.
IP selection (phase 8)
File: scaling/ipPool.ts
Round-robin IP selection within the resolved pool, excluding IPs that fail the
composed readiness checks. A dedicated IP from phase 7 is used directly after
passing the same checks. If no eligible IP remains, the attempt defers and emits
an all_ips_blocked alert; the system never falls back to a quarantined source.
Domain throttle slot (phase 9)
File: intelligence/domainThrottle.ts (via dispatch/phases/acquireSlot.ts)
Adaptive per-IP-per-domain rate limiting using a Redis sliding window. The phase calls domainThrottle.acquireSlot(redis, ip, domain); if no slot is free the attempt is deferred 5 seconds. The slot is released implicitly when the matching domain_throttle_* effect fires in the outcome reducer. Each ISP has a sending profile:
| ISP | Default Rate | Ceiling | Floor |
|---|---|---|---|
| Gmail / Googlemail | 100/min | 300/min | 5/min |
| Outlook / Hotmail / Live | 80/min | 200/min | 5/min |
| Yahoo / AOL / Ymail | 50/min | 150/min | 3/min |
| iCloud / me / mac | 60/min | 150/min | 5/min |
| Other domains | 30/min | 100/min | 2/min |
The throttle backs off on SMTP 4xx (rate × backoffFactor), recovers on sustained success (rate × recoveryFactor), and blocks after repeated failures. These profiles are seeded into Redis on startup and can be tuned at runtime through the /isp-profiles endpoints.
Warming cap (phase 10)
File: intelligence/warming.ts
New IPs follow a 30-day warming schedule with daily send caps that grow gradually:
| Day | Daily Cap |
|---|---|
| 1 | 50 |
| 2 | 100 |
| 3 | 200 |
| 5 | 700 |
| 7 | 1,500 |
| 10 | 3,000 |
| 14 | 7,500 |
| 18 | 15,000 |
| 21 | 20,000 |
| 25 | 30,000 |
| 30+ | Unlimited (graduated) |
The schedule adapts — accelerating when bounce/deferral rates are low and decelerating when deliverability signals are poor. When an IP graduates, an ip.warming_complete webhook event is sent.
When the cap for the day is reached the attempt defers to the next cap window rather than to a fixed short retry (intelligence/warmingCapWindow.ts). The window is the next UTC day boundary, where the daily budget actually resets — but the deferral is bounded at one hour, so outside the last hour of the day the attempt waits an hour rather than until midnight. Both halves of that are deliberate. A short fixed retry would re-queue the whole deferred backlog every few minutes for the rest of the day to reach an answer that cannot change; an unbounded wait to midnight would strand a backlog behind capacity that already exists, because a daily cap genuinely can widen intraday — the schedule advances, a new IP joins the pool, or the ramp controller's pace dial moves. The hour is the compromise between the two, and a minimum deferral keeps an attempt a few milliseconds before midnight from re-queueing immediately. Intraday pacing is different again and keeps its own retry instant: a paced attempt genuinely does get capacity back within the day, on a curve.
Per-mailbox-provider caps
Files: intelligence/warmingProviderPolicy.ts (pure policy), intelligence/warmingProviderStore.ts (Redis)
A young IP's reputation is rarely uniform: it is routinely trusted at Google while still crawling at Microsoft. Alongside the per-IP daily cap, each (IP × mailbox provider) pair carries a cap multiplier in [0.05, 1] that narrows the per-IP cap for that provider alone. The per-IP cap stays authoritative — a provider cap is always derived from it, so the union of provider traffic can never exceed the day's cap.
The multiplier moves once per UTC day, inside the same evaluateDay idempotency guard as the schedule itself, and it moves asymmetrically — cheap to retreat, expensive to advance. Multiplicative tightening (×0.5) applies the moment that provider's own bounce/deferral rates cross the shipped deceleration thresholds or it has signalled sustained volume pressure. Additive recovery (+0.1) costs three consecutive clean days; one clean day only banks a streak, and any breach resets it. Below a minimum sample of 50 sends the verdict is insufficient_data and nothing moves — the rates are not even computed, so a single bounce can never halve a cap. A provider with no recorded state is unrestricted, so an upgraded deployment keeps its existing per-IP state and behaviour until the new dimension fills in.
Intraday pacing
File: intelligence/warmingPacing.ts
A burst that empties the day's cap in ten minutes looks very different to a receiver than a smooth curve. Bulk (campaign-pool) traffic is therefore released along a linear curve across the UTC day, starting at 10% of the day's bulk ceiling.
The curve is measured against a bulk-only send counter, not the per-IP total: transactional volume shares the same daily cap but is exempt from pacing, and counting it toward the curve would let a burst of transactional mail defer a small campaign. Two guards keep the shape honest: a floor of 100 immediately-available bulk sends (so a 50-recipient campaign is never stretched across a day), and a 20% safety headroom that only transactional-pool traffic may consume — transactional sends bypass pacing entirely and are never starved by a campaign.
An attempt that already holds a warming reservation owns its slot and bypasses all three gates, exactly as before: the routing layer promised it capacity, and taking that back would strand the reservation.
Deferral-aware retry
Per-ISP volume-pressure verdicts from the SMTP classifier (rate_limited, gmail_rate_limited, yahoo_ts03, yahoo_tss04, microsoft_resource_throttle) are recorded per (IP × provider) on a six-hour horizon. While pressure is on record, the classifier's suggested retry delay for that destination is doubled per recent event (capped at ×8 and at four hours), and the same verdicts feed the per-provider cap gate above. Greylisting and full mailboxes are deliberately not treated as volume pressure.
Engagement-Based Priority
Engagement priority is not a dispatch phase — it is applied at enqueue time. src/routes/send.ts (and the SMTP submission server) map the job's engagementScore through the shared mapToPriority and priorityToOrderMs helpers in intelligence/engagementPriority.ts, setting GroupMQ's orderMs so high-engagement recipients are dequeued before low-engagement ones. Lower orderMs means processed first; the highest priority levels map to far-past timestamps so they always jump the queue.
Where the score comes from
engagementScore is optional and originates in Convex, not in the MTA:
analytics/engagementScore.tscomputes a recency-weighted 0–100 score per contact from their activity history and stores it oncontacts.engagementScore.- Two of the send producers put that value on the durable send envelope at enqueue time, and the value is normalised there (in the enqueue mutation, and at the contact read itself on the non-campaign path) so nothing degenerate can enter a durable envelope:
- campaign sends — audience resolution projects the score from the contact row it has already loaded, and
campaigns/send.tscarries it through todelivery/enqueue.ts:enqueueCampaignEmails; - automation and agent-reply sends —
delivery/enqueue.ts:enqueueNonCampaignSenddoes a single indexed point read of the contact inside the existing enqueue transaction.
The template-API transactional producer (transactional/dispatch.ts) is deliberately not wired: it resolves a contact (it writescontactIdonto thetransactionalSendsrow) but does not project that contact's score onto the envelope, so a template-API receipt to a fully scored contact is dispatched without a score and is ordered in the default band. That is intentional — transactional receipts are latency-sensitive one-offs, not a volume stream whose ordering is worth differentiating — not an oversight to read past.
The dispatch action never reads a contact per recipient. - campaign sends — audience resolution projects the score from the contact row it has already loaded, and
delivery/governedDispatch.tsnormalises the envelope value again at the read boundary and stamps it ontoMtaExtras.engagementScore; the MTA adapter forwards it in thePOST /sendbody.
Absence is normal and is not an error. A contact the scorer has not reached yet, a send with no contact record at all (test previews, agent replies to an unknown address), a send from a producer that does not project the score (the template-API transactional path above), and any envelope queued before the field existed all arrive with the field omitted. mapToPriority maps a missing score to PRIORITY_BANDS.DEFAULT (the LOW band), so such mail is ordered exactly as it was before any scoring existed — neither promoted nor pushed behind cold contacts.
A score of 0 is not the same as an absent one: 0 is a real measurement meaning "cold" and lands in the lowest band. A non-finite or out-of-range value is discarded as unknown rather than clamped.
DNSBL Monitoring
File: intelligence/dnsbl.ts
A periodic background check (every 15 minutes, startDnsblChecker) — separate from the per-send pipeline — tests each sending IP against DNS-based blocklists:
| Blocklist | Severity |
|---|---|
Spamhaus (zen.spamhaus.org) | Critical |
Barracuda (b.barracudacentral.org) | Warning |
SpamCop (bl.spamcop.net) | Warning |
Listed IPs are removed from the active sending pool (the IP-selection phase then skips them). Convex is notified via ip.blocklisted and ip.delisted webhook events.
Each lookup resolves to one of three states, and the difference between them is load-bearing:
| State | Produced by | Effect |
|---|---|---|
listed | A 127.0.0.x answer (other than the reserved 127.255.255.x block) | Spamhaus ejects the address; the other feeds are advisory |
clean | NXDOMAIN / NODATA | The address is eligible |
unknown | Timeout, SERVFAIL, REFUSED, resolver-policy refusal, query-rate limiting (127.255.255.x), or any other resolver failure | Never counted as health: the previous decision is preserved, and a never-observed address stays out of the pool |
A transient resolver failure is retried with exponential backoff inside a bounded budget — up to three attempts in total — before the sweep concludes unknown, so a slow resolver does not cost a false unknown, and a dead one cannot stall the sweep. An answered reserved code (127.255.255.x: resolver policy, open-resolver rejection, rate limiting) is an answer, not a failure: it yields unknown immediately and is never re-queried.
When every configured address is ejected, sending halts: pool selection has no eligible address, so deliveries stay queued (there is no "send anyway" path out of a fully listed pool) and an all_ips_blocked alert names the addresses and the zones that listed them — or says plainly that the status could not be measured.
The /ip-reputation routing snapshot carries the same three states rather than collapsing them: dnsbl_listed (critical, whole pool ejected) drives the shipped relay failover, while the advisory dnsbl_partial (some addresses ejected) and dnsbl_unknown (a lookup could not be completed) are recorded for measurement without triggering failover on their own.
Queue System
Files: queue/setup.ts, queue/handler.ts, queue/groups.ts
The MTA uses GroupMQ, a Redis-backed job queue with group processing.
Group Key
Jobs are grouped by {ipPool}:{recipientDomain} — for example, campaign:gmail.com. This ensures that emails to the same ISP from the same IP pool are processed sequentially, respecting per-domain rate limits. Different domains process in parallel.
Worker Configuration
| Setting | Default | Description |
|---|---|---|
| Concurrency | 50 | Parallel group processing slots (WORKER_CONCURRENCY) |
| Max attempts | 5 | Retry limit per job |
| Job timeout | 2 min | Per-job processing timeout |
| Backoff | Exponential | 30s → 2m → 8m → 30m → 2h |
| Completed retention | 1,000 | Last N completed jobs kept in Redis |
| Failed retention | 5,000 | Last N failed jobs kept in Redis |
When a dispatch phase returns a defer outcome, src/queue/handler.ts translates it into GroupMQ's DeferError to reschedule with the requested delay (plus ±15% jitter).
SMTP Delivery
MX Resolution
File: smtp/mxResolver.ts
DNS MX lookups with Redis caching. Each recipient domain's MX records are resolved and tried in priority order. If all MX hosts fail, the domain is recorded in the degradation system.
DKIM Signing
Files: smtp/dkimStore.ts, smtp/dkim.ts
All outbound mail is signed with DKIM. Keys are stored in Redis with a 5-minute in-memory cache for performance. On startup, keys from the DKIM_KEYS environment variable are seeded into Redis (existing Redis keys are not overwritten).
Key management:
- API management —
POST /dkim,GET /dkim,DELETE /dkim/:domain - Key rotation —
POST /dkim/:domain/rotategenerates a new RSA 2048-bit key pair and returns the DNS TXT record value (v=DKIM1; k=rsa; p={base64}) for publishing - Env var seeding —
DKIM_KEYSstill works for initial bootstrapping; Redis is the source of truth at runtime
Connection Pool
File: smtp/connectionPool.ts
Reusable in-house SMTP connection pool (@owlat/smtp-client) keyed by {mxHost}:{bindIp}:{dkimDomain}. Replaces single-use connections for better connection reuse and reduced DNS/TLS overhead.
| Setting | Default | Description |
|---|---|---|
| Max connections per host | 3 | Concurrent transports per MX host |
| Idle timeout | 30s | Close idle connections after this period |
| Max connection age | 5 min | Force close regardless of activity |
The pool runs a periodic eviction sweep (every 10s) to close idle and aged-out transports. Connections with in-flight sends are never evicted. A Prometheus gauge (mta_smtp_pool_connections) tracks active and idle connection counts by pool key.
Distributed coordination: in multi-instance deployments the pool enables cross-instance coordination after Redis connects (enableDistributedCoordination). A global cap (SMTP_POOL_GLOBAL_MAX_PER_HOST, default 10) limits the total number of concurrent connections to any one MX host across all MTA instances, on top of the per-instance maxPerHost limit — so a fleet of MTA nodes does not collectively hammer a single receiving server.
On shutdown, the pool drains all in-flight sends before closing transports.
VERP Return-Path
File: bounce/verp.ts
Variable Envelope Return Path encoding embeds the original message ID into the bounce address:
bounce+{base64url(messageId)}@bounces.owlat.com
The message ID is base64url-encoded (not raw) and the prefix is singular bounce+. This allows the bounce processor to correlate incoming DSN messages back to the original send without maintaining a lookup table.
BOUNCE_VERP_KEY is required at production startup. Setup generates it automatically; example environments deliberately leave it empty and known placeholder values are rejected. The token carries an authenticating truncated HMAC — bounce+{base64url(messageId)}+{hmac}@bounces.owlat.com — and only a verified signed token may provide exact DSN/FBL attribution. The unsigned helper form exists solely for isolated compatibility tests and is never accepted by the production feedback parsers.
CFBL-Address (RFC 9477)
Every complaint feedback loop except one requires an account with the mailbox provider: Google Postmaster, Microsoft SNDS/JMRP and Yahoo CFL all need bilateral enrollment. RFC 9477 needs no third-party account and no bilateral enrollment at all — only a header — so it is the complaint signal a deployment with no external credentials can actually have. It does have one local prerequisite: the pair is emitted only for a sending domain that has registered its own return-path host, because RFC 9477 §3.1.3 requires the CFBL host to align with RFC5322.From (see below). On the shared global return-path host nothing is emitted. That silence is a supported configuration, never an error or a setup nag: it is visible only as mta_cfbl_emissions_total{outcome="host_unaligned"}, and it never blocks a send.
A composed outbound message carries:
CFBL-Address: fbl+{base64url(messageId)}+{hmac}@bounce.acme.com; report=arf
CFBL-Feedback-ID: {base64url(messageId)}+{hmac}
Both fields are in the DKIM h= tag and both are oversigned. RFC 9477 §3.1.4 requires the coverage — "if the header field is not covered by the h= tag, the Mailbox Provider SHALL NOT send a report message" — and oversigning means a second CFBL-Address added in transit breaks the signature rather than creating an ambiguity the RFC gives no rule for resolving. A caller-supplied CFBL-Address or CFBL-Feedback-ID is stripped from the outbound headers regardless of letter case, whether or not we emit our own.
The pair is emitted only when the CFBL host is the RFC5322.From domain or a subdomain of it — that is, when the sending domain has registered a per-domain return-path host. On the shared global return-path host the CFBL address would be a third-party domain relative to From, which RFC 9477 §3.1.3 allows only with an additional DKIM signature aligned to that host; Owlat signs once, so instead of publishing a header every conforming provider discards, it publishes none. RFC 9477 §3.1 further requires the RFC5322.From domain to be matched by a valid DKIM signature, and §3.1.4 tells the provider it "SHALL NOT send a report message" without one, so a message sent with no DKIM key registered for its domain carries no pair either. Every send increments mta_cfbl_emissions_total with a bounded outcome label (emitted, host_unaligned, no_signature, no_key, no_address, sealed_raw), so "CFBL is off for this domain, and why" is a counter an operator can read rather than a silent default. emitted means the pair is genuinely on the wire: sealed mail ships its raw MIME verbatim and never reaches the composer, so those sends are counted sealed_raw. The outcome is derived from the bytes that were actually built, not from the configuration — if a configured DKIM key throws during signing the MTA ships the message unsigned rather than corrupt-signed, and that send is counted no_signature too.
The address is signed, for the same reason the VERP bounce address is: it is published to the whole internet and invites unauthenticated parties to mail us reports that feed complaint accounting. The HMAC is derived from the same BOUNCE_VERP_KEY secret but is domain-separated (cfbl: prefixed MAC input), so a captured bounce token can never be replayed as a complaint token or vice versa. Without a signing key no header is emitted at all — an unsigned complaint handle would be strictly worse than none.
The token encodes only the opaque internal message id: no recipient address, no recipient hash, no organization or campaign identifier. A passive observer of the header learns nothing about who was mailed.
Inbound reports arrive at the existing bounce SMTP server (the fbl+ local-part is already accepted at RCPT time, and the address rides the same return-path host as VERP, including a per-domain override) and are parsed by the existing ARF processor — there is no second parser. Attribution precedence is strongest-evidence-first: the verified envelope recipient, then a verified echoed CFBL-Feedback-ID, then the pre-existing Original-Mail-From VERP scrape. Verification failures are counted (mta_cfbl_rejections_total, bounded reason label) and dropped, never thrown and never attributed; successful attributions are counted by source in mta_cfbl_attributions_total. Replays are absorbed by the shipped complaint deduplication, whose retention is derived from the shared token acceptance horizon (MAX_FEEDBACK_TOKEN_ACCEPTANCE_SECONDS in bounce/signedToken.ts, plus a day of skew) so that a dedup record always outlives the window in which a captured token still verifies — a replay can never inflate a complaint rate by repetition.
A verified token identifies the SEND, not the reporter: every field a downstream effect trusts — organization, campaign, delivery domain and the recipient itself — is read back from the provenance record written at send time, never from the report. That record shares the same derived retention, so a complaint arriving late in the acceptance horizon still attributes rather than resolving to nothing. Holding a valid CFBL token proves only that the holder received one message from the tenant, so a report naming some other address cannot suppress it. Because the recorded recipient always wins, a DSN whose Final-Recipient is a forwarded mailbox now suppresses the address Owlat actually sent to rather than the forwarding target.
Redis sizing. Complaints are a human-latency signal, so the acceptance horizon — and with it the retention of both the per-send provenance record and the complaint dedup record — is 16 days (previously 8 days for provenance and 7 for dedup). Provenance is one Redis key per send plus a bounded recipient index, so the steady-state footprint of the mta:{feedback}:* namespace roughly doubles and the complaint-dedup namespace grows about 2.3x relative to the pre-RFC-9477 MTA. Size Redis for 16 days of send volume, not 8.
Transport
In-house SMTP connections are acquired from the connection pool for each send (smtp/sender.ts):
- Binds to a specific IP from the selected pool (or dedicated IP)
- Port 25, 30-second connection/greeting timeout, 60-second socket timeout
- STARTTLS is used opportunistically by default
- MTA-STS enforcement: when the recipient domain publishes an enforce-mode policy, the connection requires TLS and validates the peer certificate, and MX hosts not listed in the policy are skipped (see MTA-STS & TLS-RPT)
- DKIM signing keys loaded from the DKIM key store
MTA-STS & TLS-RPT
Files: smtp/mtaSts.ts, smtp/tlsRpt.ts
The sender protects outbound connections against STARTTLS-stripping attacks using MTA-STS (RFC 8461). Before delivering to a domain, getStsTlsOptions checks the _mta-sts.{domain} DNS TXT record and, on a version change, fetches https://mta-sts.{domain}/.well-known/mta-sts.txt. Parsed policies are cached in Redis (minimum 5-minute TTL, negative results cached for 1 hour). Policy modes:
| Mode | Effect |
|---|---|
enforce | requireTLS + rejectUnauthorized; only MX hosts matching the policy mx: patterns are tried |
testing | Opportunistic TLS, logged for monitoring (no enforcement) |
none | Opportunistic TLS |
MTA-STS lookups never block delivery — any failure falls back to opportunistic TLS.
Every connection attempt records a TLS result (success or a classified failure such as certificate-expired, starttls-not-supported, validation-failure). A daily leader-only cron (generateAndSendReports) aggregates failures per recipient domain and, for domains that publish a _smtp._tls TXT record with a rua= address, generates and sends a TLS-RPT (RFC 8460) JSON report. Both HTTPS report endpoints (direct POST of the gzipped report) and mailto: endpoints (the gzipped report is enqueued as an attachment on the normal DKIM/IP-pool send path) are delivered.
Publishing your own MTA-STS & TLS-RPT records
The above describes how Owlat consumes recipients' MTA-STS/TLS-RPT policies when sending outbound. To get the reciprocal protection on mail delivered to your domain, publish your own records:
- TLS-RPT (
_smtp._tls) — Owlat generates this for you. SetMTA_TLSRPT_RUAto a destination you monitor (mailto:tls-reports@yourdomain.comor an HTTPS endpoint) and registering a sending domain emits a_smtp._tlsTXT record of the formv=TLSRPTv1; rua=…(RFC 8460 §3) alongside the DKIM/DMARC records. WhenMTA_TLSRPT_RUAis unset the record is omitted. - MTA-STS (
_mta-sts+ policy file) — must be published by the operator because the policy lives on a web host, not in DNS, and Owlat does not run that host. Two parts are required (RFC 8461):- A DNS TXT record at
_mta-sts.yourdomain.comwith valuev=STSv1; id=<unique-version>(bumpidon every policy change so resolvers refetch). - A policy file served over HTTPS at
https://mta-sts.yourdomain.com/.well-known/mta-sts.txt, e.g.:version: STSv1 mode: enforce mx: mx.yourdomain.com max_age: 604800
mode: testingto collect TLS-RPT reports without risking delivery, then raise tomode: enforceonce your MX TLS is confirmed healthy. - A DNS TXT record at
- TLSA (DANE, optional) — if you publish DANE TLSA records (
_25._tcp.mx.yourdomain.com, RFC 6698) to pin your MX certificate, Owlat's DNS model accepts them: aTLSArecord type withusage/selector/matchingTypeparameters is recognised by the DNS-record validator and verifier.
Inbound Forwarding
Forwarded inbound mail is re-originated (remailed) under the mailbox domain — a new message signed with the mailbox domain's own DKIM key, with Reply-To set to the original sender so replies reach them (RFC 7960). ARC is not used.
Automatic DKIM Rotation
File: smtp/dkimRotation.ts
DKIM keys rotate on a safe overlap schedule (default 180-day rotation, 48-hour DNS-propagation overlap):
initiateRotationgenerates a new RSA key under a fresh selector, stores it as a pending key, and returns the DNS TXT record to publish — the active key keeps signing.- After the overlap window elapses and the new selector's DNS TXT record is confirmed published,
activatePendingKeypromotes the pending key to active and clears the pending state. If the record isn't live yet it keeps waiting, so signing never switches to a key DNS can't verify (an explicitforcebypasses the check for manual overrides).
A leader-only cron checks rotation status every 6 hours: it auto-activates any pending key whose overlap has passed and whose DNS record is published, and logs a recommendation for keys past their rotation date. Rotation is operator-initiated (via initiateRotation), so the new record can be published before activation.
SMTP Submission Server
File: smtp/submissionServer.ts
The MTA includes an optional SMTP submission server for traditional email client compatibility. Disabled by default.
| Setting | Value |
|---|---|
| Port | 587 (configurable via SUBMISSION_PORT) |
| Encryption | STARTTLS upgrade |
| Auth methods | PLAIN, LOGIN |
| Max message size | 25 MB |
| Default IP pool | transactional |
Authentication: Accepts both the master MTA_API_KEY and per-org API keys as the SMTP password (username is ignored). Per-org keys scope all submitted emails to that organization.
Processing: Incoming messages are parsed with the in-house @owlat/mail-message parseMessage, then fanned out to one GroupMQ job per accepted SMTP-envelope recipient. To/Cc/Bcc headers are display content and do not control delivery. Each job enters the standard dispatch pipeline, and the sender domain is extracted for DKIM signing.
Submission job identities are deterministic across an uncertain DATA retry. A client can provide an idempotency key with the XOWLATID=<key> MAIL FROM parameter or an X-Owlat-Idempotency-Key message header. Reuse that key only with the same authenticated principal, normalized envelope sender, and exact DATA bytes; a changed sender or message is rejected permanently with 554 5.5.4. Envelope recipients are bound independently, so a retry may reorder or reduce its recipient subset to reconcile partial fan-out. Use a new key for a new intentional send, even when its DATA bytes are identical. Keys are 1–200 characters from A-Z a-z 0-9 . _ ~ : + -.
Clients without an explicit key retain the safe fallback: the identity hashes the authenticated principal, normalized envelope sender, and exact DATA bytes, then derives one job ID per recipient. A durable four-day intake receipt plus the explicit GroupMQ job ID lets a retry reconcile a committed enqueue and complete only missing recipients after partial fan-out. For a deliberately non-idempotent transaction, set XOWLATDEDUP=OFF on MAIL FROM or X-Owlat-Deduplication: off in the message. This creates a fresh server identity for every DATA attempt, allowing intentional byte-identical sends during the receipt window, but it also gives up reconciliation after a lost SMTP response or partial fan-out and can therefore duplicate delivery on retry. An explicit client key is preferred whenever the caller can persist one.
| Variable | Default | Description |
|---|---|---|
SUBMISSION_ENABLED | false | Enable the submission server |
SUBMISSION_PORT | 587 | SMTP submission port |
SUBMISSION_TLS_CERT | required when SUBMISSION_ENABLED=true | PEM-encoded TLS certificate |
SUBMISSION_TLS_KEY | required when SUBMISSION_ENABLED=true | PEM-encoded TLS private key |
The submission server requires TLS. When SUBMISSION_ENABLED=true, both SUBMISSION_TLS_CERT and SUBMISSION_TLS_KEY must be set — STARTTLS is required before AUTH and the server refuses to boot without them (RFC 8314 §3.3). There is no plaintext-AUTH mode.
Bounce & Inbound Processing
Files: bounce/server.ts, bounce/pipeline.ts, bounce/phases/, bounce/outcome.ts, bounce/effects.ts, bounce/parser.ts, bounce/fblProcessor.ts, bounce/classifier.ts
Inbound SMTP Server
A dedicated SMTP server listens on the bounce port (default: 25) for incoming delivery status notifications, feedback-loop reports, routed inbound mail, and personal-mailbox (Postbox) delivery. Bounces arrive at the VERP return-path address; other mail is matched against inbound routes and the mailbox cache.
The bounce server mirrors the dispatch architecture: a typed phase pipeline (bounce/pipeline.ts + bounce/phases/) classifies each message into a BounceAttempt, and the pure reducer in bounce/outcome.ts emits a typed list of BounceEffects (Convex webhooks, circuit-breaker outcomes, attachment staging, quota bumps).
Processing Pipeline
- VERP decode — extract the original message ID from the recipient address
- DSN parse — parse RFC 3464 delivery status notifications for enhanced status codes and diagnostic information
- ARF/FBL parse — detect Abuse Reporting Format feedback-loop reports from ISPs
- Classify — categorize the event:
- Hard bounce (permanent) — invalid recipient, domain doesn't exist
- Soft bounce (temporary) — mailbox full, server temporarily unavailable
- Complaint — recipient marked the email as spam
- Route — non-bounce mail resolves to an inbound route (
endpoint/accept/…) or a personal mailbox - Effects — auto-suppress hard bounces/complaints, record the circuit-breaker outcome (a complaint feeds the complaint-rate thresholds), and post a webhook to Convex
Inbound Security
File: bounce/inboundSecurity.ts
The inbound SMTP server applies several abuse controls (configurable, see Configuration):
- Per-IP connection cap — at most
BOUNCE_MAX_CONNECTIONS_PER_IPconcurrent connections from one IP (Redis counter with a 5-minute window); excess connections are rejected. - Global client cap —
BOUNCE_MAX_CLIENTStotal concurrent connections. - Tarpit — when
BOUNCE_TARPIT_ENABLEDis on, non-local connections incur a deliberateBOUNCE_TARPIT_DELAY_MSdelay to slow down abusive senders. - SPF validation — when
INBOUND_SPF_ENABLEDis on,checkSpfevaluates the sender domain's SPF record (ip4/ip6/a/mx/include/all) against the connecting IP. - DKIM verification — when
INBOUND_DKIM_ENABLEDis on,verifyDkim(RFC 6376) checks the message signature over the raw bytes. Fail-open: a verify crash yieldstemperrorand is recorded asdkimResult, never a rejection. - DMARC evaluation — when
INBOUND_DMARC_ENABLEDis on,evaluateDmarc(RFC 7489) aligns the SPF and DKIM results with theFrom:domain and applies its published policy. The verdict (and policy) is recorded asdmarcResultso Convex can route spoofed mail to Spam. Fail-open: a lookup crash yieldstemperror, never a rejection.
Personal Mailboxes (Postbox)
When a recipient matches the mailbox cache, the message is classified as a personal-mailbox delivery rather than a bounce or route. The reducer emits an inbound.mailbox.received webhook carrying the parsed message and base64 raw RFC822 bytes (for storage), and bumps the mailbox's stored quota usage. See Postbox Architecture.
Per-Organization Credentials
File: auth/credentials.ts
API keys formatted as owlat_{32-char-hex} provide per-organization isolation. Each credential is stored in Redis and indexed by organization ID.
- The master
MTA_API_KEYcontinues to work for all endpoints (used by the Convex backend) - Per-org keys authenticate both HTTP API
/sendrequests and SMTP submission connections - Management endpoints (
POST/GET/DELETE /credentials) require the master key
Credentials track createdAt and lastUsedAt timestamps. The GET /credentials endpoint returns truncated key prefixes (first 10 chars) for safe display.
Inbound Email Routing
Files: inbound/router.ts, inbound/forwarder.ts
Route incoming emails by domain and local-part address. Routes are stored in Redis and managed via the /inbound/routes API.
Route Modes
| Mode | Behavior |
|---|---|
endpoint | Forward the parsed email to an HTTP webhook URL |
accept | Silently accept the email |
hold | Accept but hold for manual review |
bounce | Return a bounce response to the sender |
reject | Reject during SMTP negotiation |
Route Matching
Priority: exact address match, then wildcard (*). For example, a route for support@example.com takes precedence over *@example.com. Routes are keyed by domain:address in Redis.
Webhook Forwarding
For endpoint mode, the parsed email is posted as JSON to the configured URL:
- Payload includes:
from,to,subject,textBody,htmlBody,headers,date,messageId,inReplyTo,references, andattachments(base64-encoded) - 10-second timeout per request
- Up to 2 retries with exponential backoff (1s, 2s)
An inbound.received webhook event is sent to Convex for all routed inbound emails.
IP Pool Management
Files: scaling/ipPool.ts, scaling/degradation.ts
Two Pools
| Pool | Purpose |
|---|---|
transactional | Time-sensitive emails (password resets, order confirmations) |
campaign | Bulk marketing emails |
IPs are configured via IP_POOLS_TRANSACTIONAL and IP_POOLS_CAMPAIGN environment variables (comma-separated).
The default is explicitly IPv4-only. Native IPv6 literals require
MTA_IPV6_ENABLED=true; they use the same warm-up path but cannot enter
rotation until IPv4 identity, IPv6 PTR/EHLO/AAAA, return-path SPF, and DNSBL
readiness all pass. Every SMTP socket retains the selected address as its
explicit localAddress, so the OS never chooses an IPv6 source implicitly.
Selection
Round-robin IP selection within each pool. Any composed readiness quarantine (FCrDNS, IPv4 prerequisite, SPF, or critical DNSBL) excludes the address. If no eligible address remains, delivery stays queued; the sender never falls back to an unverified source.
Pool Rules
File: scaling/poolRules.ts
Per-organization IP pool assignment rules stored in Redis. A rule can override the pool or assign a dedicated IP that bypasses round-robin selection.
Resolution order:
- Organization-specific rule (if set via
POST /pool-rules) - Request
ipPoolfield from the job - Default pool
Managed via POST /pool-rules, GET /pool-rules/:orgId, DELETE /pool-rules/:orgId.
Degradation
The degradation system tracks per-domain connection failures and applies automatic backoff on repeated failures. System-level health checks monitor queue depth and Redis connectivity. When the queue is too deep, the API returns HTTP 429 (backpressure).
Webhook Events
Events are posted to {CONVEX_SITE_URL}/webhooks/mta with authentication via the X-MTA-Signature header (HMAC-SHA256 of timestamp.body, constant-time compared) plus an X-MTA-Timestamp header, and timestamp validation (5-minute tolerance, 300s).
| Event | Severity | Description |
|---|---|---|
sent | info | Email delivered successfully (SMTP 250 response) |
bounced | warning | Hard or soft bounce (includes bounceType: 'hard' | 'soft') |
complained | warning | Spam complaint from ISP feedback loop |
inbound.received | info | Inbound email received and routed |
inbound.mailbox.received | info | Inbound mail delivered to a personal mailbox (Postbox) |
org.circuit_breaker | critical | Organization bounce rate too high, sending paused |
ip.blocklisted | critical | Sending IP added to a DNS blocklist |
ip.delisted | info | Sending IP removed from a DNS blocklist |
ip.warming_complete | info | IP graduated from warming schedule |
all_ips_blocked | critical | No sending IPs available (all blocklisted) |
Convex Integration
The Convex backend receives MTA webhooks at the static route POST /webhooks/mta, served by the shared provider-feedback HTTP action (webhooks/providerFeedbackHttp.ts, one parameterised handler for every kind). Bounce and complaint events are parsed by webhooks/adapters/mta.ts — the adapter the route resolves through webhooks/adapters/index.ts — into typed email.bounced / email.complained InboundEvents and routed through the shared inbound webhook pipeline (the same runInboundPipeline dispatcher SES/Resend/Mandrill use).
On the send side, the MTA is one of the pluggable send-provider modules in apps/api/convex/lib/sendProviders/ — one per kind the catalog declares, plus any bundled plugin transport. The adapter is mtaSendProvider, a SendProviderModule<'mta'> object in lib/sendProviders/mta/index.ts. It performs a single sendEmail attempt (POST to the MTA's /send with a 30-second timeout) and a typed categorizeError that maps the HTTP status / error body to an EmailErrorCode; the shared dispatch helper owns the retry loop using the module's retryDelays ([1000, 5000] for MTA). Routing (lib/sendProviders/routing.ts) resolves the provider from the org's providerRoutes config and falls back to the EMAIL_PROVIDER env var, returning null — unconfigured, no implicit 'mta' — when neither names a provider. The shared contract lives in lib/sendProviders/types.ts (SendProviderModule, SendProviderKind, EmailErrorCode); strategy selection lives in lib/sendProviders/strategies/.
Monitoring
Prometheus Metrics
File: monitoring/collector.ts
Exposed at GET /metrics. Key metrics include:
- Emails sent/failed/deferred (by domain, pool)
- Queue depth and processing latency
- Circuit breaker state changes
- DNSBL check results
- Warming progress per IP
- SMTP connection pool size (active/idle)
Structured Logging
File: monitoring/logger.ts
Pino JSON logging with configurable log level (LOG_LEVEL env var). All log entries include structured context (message ID, domain, IP pool, organization ID).
Delivery Logger
File: monitoring/deliveryLogger.ts
Per-message delivery events (delivered/bounced/deferred/screened/suppressed) are written to a daily Redis Stream and exposed through the /delivery-logs endpoints. Stream length and TTL are bounded by DELIVERY_LOG_MAX_LEN and DELIVERY_LOG_TTL_HOURS.
Google Postmaster Tools
File: monitoring/postmaster.ts
When GOOGLE_POSTMASTER_CLIENT_ID, GOOGLE_POSTMASTER_CLIENT_SECRET, and
GOOGLE_POSTMASTER_REFRESH_TOKEN are set together, a leader-only hourly sweep
uses Google's v2 domains.domainStats.query API to fetch the preceding seven
dates. A persisted domain cursor makes the bounded sweep eventually cover every
verified domain, while a collection lease, request timeouts, and an absolute run
deadline prevent overlapping or unbounded work. The window tolerates provider
publication lag; Redis receipts make successful MTA→Convex delivery idempotent.
The refresh token remains in the MTA environment, and the access token is cached
in Redis for less than its provider lifetime. Only validated daily SPAM_RATE
aggregates cross the signed webhook. Google API v2 does not expose the domain/IP
reputation buckets, sample IPs, or confidence bounds that existed in v1; Owlat
does not infer them. Convex accepts data only for an exact verified Owlat sending
domain and retains it for 90 days.
See External reputation feedback for the required read-only scopes and provider enrollment steps.
Configuration
All configuration is loaded from environment variables via config.ts.
Required Variables
| Variable | Description |
|---|---|
MTA_API_KEY | Shared secret for HTTP API authentication (Bearer token) |
EHLO_HOSTNAME | SMTP EHLO hostname. Must match the server's rDNS PTR record. |
RETURN_PATH_DOMAIN | Domain for VERP bounce addresses (e.g., bounces.owlat.com) |
CONVEX_SITE_URL | Convex site URL for webhook callbacks |
MTA_WEBHOOK_SECRET | Shared secret for authenticating webhook requests to Convex |
IP_POOLS_TRANSACTIONAL | Comma-separated IP addresses for the transactional pool |
IP_POOLS_CAMPAIGN | Comma-separated IP addresses for the campaign pool |
MTA_IPV6_ENABLED | Explicitly permits native IPv6 pool entries; defaults false |
Optional Variables
| Variable | Default | Description |
|---|---|---|
PORT | 3100 | HTTP server port |
BOUNCE_PORT | 25 | Inbound SMTP port for bounce processing |
REDIS_URL | redis://localhost:6379 | Redis connection URL |
DKIM_KEYS | {} | JSON: {"domain.com": {"selector": "s1", "privateKey": "..."}} — seeds Redis on startup |
WORKER_CONCURRENCY | 50 | GroupMQ worker concurrency (parallel group slots) |
MTA_SERVER_ID | hostname | Server identifier for multi-instance deployments |
LOG_LEVEL | info | Pino log level |
NODE_ENV | — | Set to production for production deployments |
SUBMISSION_ENABLED | false | Enable the SMTP submission server (port 587) |
SUBMISSION_PORT | 587 | SMTP submission server port |
SUBMISSION_TLS_CERT | — | PEM-encoded TLS certificate for SMTP submission |
SUBMISSION_TLS_KEY | — | PEM-encoded TLS private key for SMTP submission |
ORG_DEFAULT_DAILY_LIMIT | 50000 | Default daily send cap per organization |
ORG_DEFAULT_HOURLY_LIMIT | 5000 | Default hourly send cap per organization |
CONTENT_SCREENING_ENABLED | true | Enable content pre-screening before delivery |
CONTENT_MAX_SIZE_KB | 500 | Maximum HTML size in KB before content screening rejects |
DELIVERY_LOG_MAX_LEN | 100000 | Max entries per daily Redis Stream delivery log |
DELIVERY_LOG_TTL_HOURS | 72 | TTL in hours for delivery log streams |
WEBHOOK_DLQ_MAX_SIZE | 10000 | Max entries in the webhook dead letter queue |
SMTP_OUTCOME_JOURNAL_MAX_SIZE | 10000 | Max unresolved SMTP outcome reservations before new attempts defer |
FBL_DEDUP_PROTOCOL | required (owned-v2) | Versioned complaint reservation protocol; absence fails MTA startup |
FBL_DEDUP_CUTOVER_ACK | required | fresh-install for a new Redis/install, or quiesced-v1-intake after the coordinated legacy cutover below |
SMTP_POOL_MAX_PER_HOST | 3 | Concurrent SMTP transports per MX host (per instance) |
SMTP_POOL_IDLE_TIMEOUT_MS | 30000 | Close idle pooled connections after this period |
SMTP_POOL_MAX_AGE_MS | 300000 | Force-close pooled connections regardless of activity |
SMTP_POOL_GLOBAL_MAX_PER_HOST | 10 | Global cap on connections per MX host across all instances |
BOUNCE_TLS_CERT | — | PEM-encoded TLS certificate for bounce SMTP server (enables STARTTLS) |
BOUNCE_TLS_KEY | — | PEM-encoded TLS private key for bounce SMTP server |
BOUNCE_MAX_CONNECTIONS_PER_IP | 10 | Max concurrent inbound connections per IP |
BOUNCE_MAX_CLIENTS | 200 | Max total concurrent inbound connections |
BOUNCE_TARPIT_ENABLED | true | Delay non-local inbound connections |
BOUNCE_TARPIT_DELAY_MS | 5000 | Tarpit delay per suspicious connection |
INBOUND_SPF_ENABLED | true | Validate SPF on inbound mail |
INBOUND_DKIM_ENABLED | true | Verify DKIM signatures (RFC 6376) on inbound mail |
INBOUND_DMARC_ENABLED | true | Evaluate DMARC (RFC 7489) on inbound mail |
RSPAMD_URL | — | rspamd HTTP URL for outbound content spam scoring |
RSPAMD_REJECT_THRESHOLD | 15 | Content-screening rejects when the rspamd score exceeds this |
GOOGLE_POSTMASTER_CLIENT_ID | — | OAuth client ID for Google Postmaster Tools; set with the secret and refresh token |
GOOGLE_POSTMASTER_CLIENT_SECRET | — | OAuth client secret for Google Postmaster Tools |
GOOGLE_POSTMASTER_REFRESH_TOKEN | — | Secret offline token for a Google user with domain/traffic read access |
ABUSIX_DNSBL_API_KEY | — | Optional 32-character Guardian Mail DNS namespace key; Abusix findings are warning-only |
CLAMAV_HOST | clamav | ClamAV daemon hostname for attachment scanning (read directly by the scan route, not via config.ts) |
CLAMAV_PORT | 3310 | ClamAV daemon port (read directly by the scan route, not via config.ts) |
FBL deduplication upgrade
The legacy complaint key stores only 1; that value cannot reveal whether an
old worker merely claimed the complaint or finished it. There is consequently
no safe mixed-version shadow mode: writing the legacy marker before completion
can turn a failed attempt into a seven-day false success. The MTA now fails at
startup unless the owned-v2 protocol and an explicit install/cutover
acknowledgement are both configured.
Use this cutover sequence for an existing installation:
- Stop bounce/FBL SMTP traffic at the load balancer or firewall for every replica. Wait for every legacy handler to finish, then stop every old MTA.
- While intake remains stopped, deploy the new binary and set these values on
every replica:
FBL_DEDUP_PROTOCOL=owned-v2 FBL_DEDUP_CUTOVER_ACK=quiesced-v1-intake
The acknowledgement is an operator assertion, not a distributed lock. Never set it while an old binary can still accept or process feedback. - Start the entire owned-v2 fleet, then resume SMTP traffic. Owned-v2 ignores
residual legacy
1values and uses only its isolated version-2 hash with ownedreserved,retryable, andcompletedstates. This is safe only because step 1 ensures no legacy worker can race or create another marker. - Do not run an old and owned-v2 binary concurrently. A rollback requires the same coordinated intake quiescence; old binaries cannot interpret v2 state.
Fresh setup can prove there is no old Redis state, so the setup CLI writes
FBL_DEDUP_PROTOCOL=owned-v2 and FBL_DEDUP_CUTOVER_ACK=fresh-install only
when no existing environment was found. Re-running setup on an existing
environment refuses to invent either value. The checked-in example env leaves
both blank so copying it cannot silently acknowledge an upgrade.
ClamAV Sidecar (Docker)
The MTA can optionally run ClamAV as a Docker sidecar for attachment malware scanning. ClamAV provides the clamd daemon on port 3310, with freshclam auto-updating virus definitions inside the official image.
# docker-compose.yml (excerpt)
clamav:
image: clamav/clamav:1.3
ports:
- '3310:3310'
volumes:
- clamav-data:/var/lib/clamav
healthcheck:
test: ["CMD", "clamdcheck"]
interval: 60s
timeout: 10s
retries: 3
The @owlat/email-scanner package provides a TCP client for the clamd INSTREAM protocol. The MTA's /scan/attachment endpoint combines file type validation with ClamAV scanning.
If ClamAV is unavailable (container not running, network error), the scan endpoint allows attachments through with a warning logged. This prevents ClamAV outages from blocking all email delivery.
Startup Sequence
Per src/index.ts:
- Load configuration from environment variables
- Set organization rate-limit defaults
- Configure the SMTP connection pool and start the eviction timer
- Connect to Redis and verify connectivity
- Enable distributed connection-pool coordination
- Seed DKIM keys from
DKIM_KEYSinto Redis (existing keys not overwritten) - Seed ISP profiles into Redis (
HSETNX, preserving runtime overrides) - Initialize IP pools in Redis (mark all IPs active)
- Initialize warming state for each IP
- Create the GroupMQ queue and worker
- Start the HTTP server (Hono on
PORT) - Start the bounce/inbound SMTP server (on
BOUNCE_PORT) - Start the SMTP submission server (on
SUBMISSION_PORT, ifSUBMISSION_ENABLED=true) - Start leader election
- Start periodic crons:
- DNSBL checker — every 15 minutes (runs on every instance)
- Warming evaluation — hourly (leader only)
- Google Postmaster fetch — hourly (leader only)
- TLS-RPT report generation — every 24 hours (leader only)
- DKIM rotation check — every 6 hours (leader only)
- Start the GroupMQ worker (begins processing jobs)
In multi-instance deployments, a Redis lock (src/lib/leaderElection.ts, 30-second TTL, renewed every 15s) ensures the warming, Postmaster, TLS-RPT, and DKIM-rotation crons run on exactly one instance. The DNSBL checker runs on every instance. Leadership fails over automatically when the leader stops renewing.
The bounce SMTP server listens on port 25 by default, which typically requires root privileges. In containerized deployments, map the host port to the container port. The MTA logs a warning if the bounce port requires elevated permissions.
Graceful Shutdown
On SIGTERM/SIGINT the shutdown is idempotent (a duplicate signal is ignored) and runs under a 40-second hard-exit watchdog that matches the compose stop_grace_period, so Docker never has to send SIGKILL mid-drain:
- Stop all periodic crons (DNSBL, warming, Postmaster, TLS-RPT, DKIM rotation)
- Close the HTTP server
- Close the bounce SMTP server
- Close the SMTP submission server (if running)
- Drain the GroupMQ worker (wait for in-flight jobs)
- Drain and close the SMTP connection pool
- Release leadership (
stopLeaderElection) - Close the Redis connection
Key Files
| File | Purpose |
|---|---|
src/index.ts | Main entry point: starts all services, leader election, and crons |
src/config.ts | Configuration loading, ISP profiles, warming schedule |
src/config/ispProfiles.ts | Runtime ISP-profile store (seed/get/set/list) |
src/server.ts | Hono HTTP app setup and route mounts |
src/redis.ts | Redis connection |
src/types.ts | Shared type definitions, webhook event union |
src/lib/leaderElection.ts | Redis lock for single-instance crons |
src/auth/credentials.ts | Per-organization API key management |
src/auth/postboxAuth.ts | Postbox (personal mailbox) authentication |
src/routes/send.ts | POST /send handler (enqueue + engagement-priority mapping) |
src/routes/health.ts | Health check and metrics endpoints |
src/routes/credentials.ts | Credential management API routes |
src/routes/dkim.ts | DKIM management API routes |
src/routes/inboundRoutes.ts | Inbound routing API routes |
src/routes/mailboxes.ts | Personal-mailbox cache API routes |
src/routes/orgLimits.ts | Organization rate-limit API routes |
src/routes/poolRules.ts | Pool-rule API routes |
src/routes/ispProfiles.ts | Runtime ISP-profile admin routes |
src/routes/ipReputation.ts | Per-IP reputation dashboard routes |
src/routes/suppression.ts | Suppression-list API routes |
src/routes/deliveryLogs.ts | Delivery-log query routes |
src/routes/queue.ts | Queue-inspection routes |
src/routes/dlq.ts | Webhook dead-letter-queue routes |
src/routes/scan.ts | Attachment scanning endpoint (file validation + ClamAV) |
src/queue/setup.ts | GroupMQ queue and worker creation |
src/queue/handler.ts | GroupMQ adapter: runs the dispatch pipeline + outcome reducer |
src/queue/groups.ts | Group key generation, ISP classification |
src/dispatch/pipeline.ts | Typed Phase/compose/runPipeline primitives |
src/dispatch/phases/index.ts | mainPipeline composition (the ten phases) + per-phase files |
src/dispatch/outcome.ts | Pure reducer: SMTP result → typed effect list |
src/dispatch/effects.ts | Effect runner (delegates to intelligence/scaling/monitoring) |
src/dispatch/types.ts | Phase context types (BasePhaseCtx, CtxWithPool, CtxWithIp) |
src/intelligence/circuitBreaker.ts | Per-org bounce + complaint-rate protection |
src/intelligence/domainThrottle.ts | Adaptive per-IP/per-domain rate limiting |
src/intelligence/contentScreening.ts | Pre-send content checks + rspamd scoring |
src/intelligence/smtpResponse.ts | SMTP response health tracking |
src/intelligence/dnsbl.ts | DNS blocklist checking |
src/intelligence/warming.ts | IP warming schedule and tracking |
src/intelligence/engagementPriority.ts | Enqueue-time engagement-priority mapping |
src/intelligence/orgLimits.ts | Per-org daily/hourly send rate limits |
src/intelligence/suppressionList.ts | Global email suppression list |
src/smtp/sender.ts | Direct MX delivery (DKIM, MTA-STS, TLS-RPT recording) |
src/smtp/mxResolver.ts | MX DNS lookups with caching |
src/smtp/dkim.ts | DKIM signing configuration |
src/smtp/dkimStore.ts | Redis-backed DKIM key storage |
src/smtp/dkimRotation.ts | Automatic DKIM key rotation workflow |
src/smtp/mtaSts.ts | MTA-STS (RFC 8461) policy fetch + enforcement |
src/smtp/tlsRpt.ts | TLS-RPT (RFC 8460) recording + daily report generation |
src/smtp/connectionPool.ts | SMTP transport pool with cross-instance coordination |
src/smtp/submissionServer.ts | SMTP submission server (port 587) |
src/inbound/router.ts | Inbound email route matching |
src/inbound/mailboxResolver.ts | Personal-mailbox address cache (O(1) Redis lookup) |
src/inbound/forwarder.ts | HTTP webhook forwarding for inbound emails |
src/bounce/server.ts | Inbound/bounce SMTP server |
src/bounce/pipeline.ts | Bounce classification phase pipeline |
src/bounce/outcome.ts | Bounce pure reducer (effect emission) |
src/bounce/effects.ts | Bounce effect runner |
src/bounce/inboundSecurity.ts | SPF validation, tarpit, per-IP connection caps |
src/bounce/parser.ts | DSN message parsing |
src/bounce/fblProcessor.ts | ARF/FBL feedback-loop parsing |
src/bounce/classifier.ts | Bounce type classification |
src/bounce/verp.ts | VERP return-path encoding/decoding |
src/scaling/ipPool.ts | IP pool selection and management |
src/scaling/poolRules.ts | Per-org IP pool assignment rules |
src/scaling/degradation.ts | System health and domain backoff |
src/monitoring/logger.ts | Pino structured logging |
src/monitoring/collector.ts | Prometheus metrics collection |
src/monitoring/deliveryLogger.ts | Per-message delivery event Redis Streams |
src/monitoring/postmaster.ts | Google Postmaster Tools data fetcher |
src/webhooks/convexNotifier.ts | Webhook delivery to Convex |