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-scanner package (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

ComponentDirectoryPurpose
HTTP APIsrc/routes/Hono server accepting send requests from the Convex backend
GroupMQ Workersrc/queue/Redis-backed job queue with group-based processing
Dispatch Pipelinesrc/dispatch/Typed, composable phase pipeline — ten ordered pre-send checks (ADR-0007)
Intelligencesrc/intelligence/The check implementations the dispatch phases delegate to
SMTP Sendersrc/smtp/Direct MX delivery with DKIM signing and MTA-STS enforcement
SMTP Submissionsrc/smtp/Authenticated SMTP submission server (port 587)
Connection Poolsrc/smtp/Reusable SMTP transport pool per MX host, with cross-instance coordination
DKIM Key Storesrc/smtp/Redis-backed DKIM key storage with automatic rotation
Bounce / Inbound Serversrc/bounce/Inbound SMTP server for DSN/ARF parsing, classification, and inbound mail
Inbound Routersrc/inbound/Rule-based inbound routing + personal-mailbox resolution
Credentialssrc/auth/Per-organization API key and Postbox authentication
Webhook Notifiersrc/webhooks/Event callbacks to the Convex backend
Monitoringsrc/monitoring/Prometheus metrics, structured logging, Google Postmaster fetch
Scalingsrc/scaling/IP pool management, pool rules, and graceful degradation
Leader Electionsrc/lib/Redis lock so periodic crons run on a single instance
Attachment Scannersrc/routes/scan.tsFile 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

MethodPathAuthDescription
POST/sendBearerQueue a single email for delivery
GET/healthNoneSystem health check (Redis, queue depth)
GET/metricsNonePrometheus metrics endpoint

Credential Management

MethodPathAuthDescription
POST/credentialsMasterCreate a per-org API credential
GET/credentialsMasterList credentials (filter by ?organizationId=)
DELETE/credentials/:apiKeyMasterRevoke a credential

DKIM Management

MethodPathAuthDescription
POST/dkimMasterAdd or update a DKIM key
GET/dkimMasterList all DKIM domains (keys redacted)
DELETE/dkim/:domainMasterRemove a DKIM key
POST/dkim/:domain/rotateMasterGenerate a new RSA 2048 key pair

Inbound Routing

MethodPathAuthDescription
POST/inbound/routesMasterCreate or update an inbound route
GET/inbound/routesMasterList all inbound routes
DELETE/inbound/routes/:domain/:addressMasterRemove 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).

MethodPathAuthDescription
POST/mailboxes/cache/:addressMasterCreate or refresh a mailbox cache entry (mailboxId, organizationId, optional quota)
DELETE/mailboxes/cache/:addressMasterRemove a cache entry
GET/mailboxes/cacheMasterList 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.

MethodPathAuthDescription
GET/isp-profilesMasterList all profiles (seeded + custom)
GET/isp-profiles/:domainMasterGet the effective profile for a domain
PUT/isp-profiles/:domainMasterCreate or update a profile (defaultRate, ceiling, floor, backoffFactor, recoveryFactor)
DELETE/isp-profiles/:domainMasterRemove 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).

MethodPathAuthDescription
GET/ip-reputationMasterSummary row per configured IP (metrics, warming, pool eligibility, DNSBL, FCrDNS)
GET/ip-reputation/:ipMasterFull reputation view for one IP, including block reasons and identity readiness

Organization Limits

MethodPathAuthDescription
POST/org-limitsMasterSet daily/hourly send limits for an organization
GET/org-limits/:orgIdMasterGet organization usage and limits

Pool Rules

MethodPathAuthDescription
POST/pool-rulesMasterSet pool assignment for an organization
GET/pool-rules/:orgIdMasterGet organization pool rule
DELETE/pool-rules/:orgIdMasterRemove organization pool rule

Suppression List

MethodPathAuthDescription
POST/suppressionMasterAdd addresses to the suppression list (batch)
DELETE/suppression/:emailMasterRemove an address from the suppression list
GET/suppression/check/:emailMasterCheck suppression status for an address
POST/suppression/bulkMasterAdd up to 10,000 addresses in one request
GET/suppression/exportMasterPaginated export with metadata (?reason=, ?cursor=)
GET/suppression/statsMasterCounts by suppression reason

Attachment Scanning

MethodPathAuthDescription
POST/scan/attachmentMasterScan file for malware (file type validation + ClamAV)
GET/scan/healthNoneClamAV 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

MethodPathAuthDescription
GET/delivery-logsMasterQuery events by date, orgId, status, domain (paginated)
GET/delivery-logs/statsMasterAggregated counts by status/domain/pool for a date range
GET/delivery-logs/:messageIdMasterAll delivery events for a specific message

Queue Inspection

MethodPathAuthDescription
GET/queue/statsMasterQueue depth by state (pending, active, completed, failed, delayed)
GET/queue/pendingMasterList pending jobs (?limit=, ?offset=, ?domain=)
GET/queue/jobs/:jobIdMasterFull job details with attempt history
DELETE/queue/jobs/:jobIdMasterCancel a specific pending job
POST/queue/flushMasterCancel all pending jobs for an org (?orgId=)

Dead Letter Queue

MethodPathAuthDescription
GET/dlqMasterList failed webhook events (?limit=, ?offset=)
GET/dlq/statsMasterTotal count and oldest entry age
POST/dlq/:dlqId/retryMasterRetry a specific failed webhook event
POST/dlq/retry-allMasterRetry all DLQ entries
DELETE/dlq/:dlqIdMasterDiscard a specific failed event
Authentication

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.ts defines Phase<TIn, TOut> — a named step whose input context type threads into the next phase. compose(...) chains phases and runPipeline(...) runs them. The type system enforces ordering: a phase that consumes the resolved pool/ip (e.g. selectIp, acquireSlot) cannot be placed before the phase that produces it, or the compose(...) 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), or drop (end the attempt with status screened or suppressed, no re-queue).
  • src/dispatch/phases/index.ts composes mainPipeline in the real order (below).
  • src/queue/handler.ts is the only GroupMQ-coupled file. The pipeline never throws; the handler translates a defer outcome into GroupMQ's DeferError (with ±15% jitter to avoid a thundering herd) and translates a drop into a logged terminal outcome.
  • After the pipeline returns continue, the handler calls sendToMx, then the pure reducer in src/dispatch/outcome.ts classifies 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:

#PhaseDelegates toOutcome on failure
1content_screeningintelligence/contentScreening.tsdrop (screened)
2suppressionintelligence/suppressionList.tsdrop (suppressed)
3circuit_breakerintelligence/circuitBreaker.tsdefer
4org_limitintelligence/orgLimits.tsdefer
5smtp_intelintelligence/smtpResponse.tsdefer
6domain_backoffscaling/degradation.tsdefer
7resolve_poolscaling/poolRules.tscontinue (enriches ctx with pool + dedicated IP)
8select_ipscaling/ipPool.tsdefer (enriches ctx with the bound IP)
9acquire_slotintelligence/domainThrottle.tsdefer
10warming_capintelligence/warming.tsdefer

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.

CheckNotes
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).

ReasonSource
hard_bounceAuto-added when a hard bounce is detected
complaintAuto-added on ISP spam complaint
manualAdded 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:

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

SignalFast (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.

LimitDefaultCounter TTL
Daily50,00048 hours
Hourly5,0002 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:

  1. Organization-specific pool rule (if set via POST /pool-rules)
  2. The request's ipPool field
  3. 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:

ISPDefault RateCeilingFloor
Gmail / Googlemail100/min300/min5/min
Outlook / Hotmail / Live80/min200/min5/min
Yahoo / AOL / Ymail50/min150/min3/min
iCloud / me / mac60/min150/min5/min
Other domains30/min100/min2/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:

DayDaily Cap
150
2100
3200
5700
71,500
103,000
147,500
1815,000
2120,000
2530,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:

  1. analytics/engagementScore.ts computes a recency-weighted 0–100 score per contact from their activity history and stores it on contacts.engagementScore.
  2. 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.ts carries it through to delivery/enqueue.ts:enqueueCampaignEmails;
    • automation and agent-reply sends — delivery/enqueue.ts:enqueueNonCampaignSend does 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 writes contactId onto the transactionalSends row) 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.
  3. delivery/governedDispatch.ts normalises the envelope value again at the read boundary and stamps it onto MtaExtras.engagementScore; the MTA adapter forwards it in the POST /send body.

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:

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

StateProduced byEffect
listedA 127.0.0.x answer (other than the reserved 127.255.255.x block)Spamhaus ejects the address; the other feeds are advisory
cleanNXDOMAIN / NODATAThe address is eligible
unknownTimeout, SERVFAIL, REFUSED, resolver-policy refusal, query-rate limiting (127.255.255.x), or any other resolver failureNever 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

SettingDefaultDescription
Concurrency50Parallel group processing slots (WORKER_CONCURRENCY)
Max attempts5Retry limit per job
Job timeout2 minPer-job processing timeout
BackoffExponential30s → 2m → 8m → 30m → 2h
Completed retention1,000Last N completed jobs kept in Redis
Failed retention5,000Last 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 managementPOST /dkim, GET /dkim, DELETE /dkim/:domain
  • Key rotationPOST /dkim/:domain/rotate generates a new RSA 2048-bit key pair and returns the DNS TXT record value (v=DKIM1; k=rsa; p={base64}) for publishing
  • Env var seedingDKIM_KEYS still 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.

SettingDefaultDescription
Max connections per host3Concurrent transports per MX host
Idle timeout30sClose idle connections after this period
Max connection age5 minForce 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:

ModeEffect
enforcerequireTLS + rejectUnauthorized; only MX hosts matching the policy mx: patterns are tried
testingOpportunistic TLS, logged for monitoring (no enforcement)
noneOpportunistic 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. Set MTA_TLSRPT_RUA to a destination you monitor (mailto:tls-reports@yourdomain.com or an HTTPS endpoint) and registering a sending domain emits a _smtp._tls TXT record of the form v=TLSRPTv1; rua=… (RFC 8460 §3) alongside the DKIM/DMARC records. When MTA_TLSRPT_RUA is 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):
    1. A DNS TXT record at _mta-sts.yourdomain.com with value v=STSv1; id=<unique-version> (bump id on every policy change so resolvers refetch).
    2. 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
      
    Start in mode: testing to collect TLS-RPT reports without risking delivery, then raise to mode: enforce once your MX TLS is confirmed healthy.
  • 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: a TLSA record type with usage/selector/matchingType parameters 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):

  1. initiateRotation generates 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.
  2. After the overlap window elapses and the new selector's DNS TXT record is confirmed published, activatePendingKey promotes 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 explicit force bypasses 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.

SettingValue
Port587 (configurable via SUBMISSION_PORT)
EncryptionSTARTTLS upgrade
Auth methodsPLAIN, LOGIN
Max message size25 MB
Default IP pooltransactional

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.

VariableDefaultDescription
SUBMISSION_ENABLEDfalseEnable the submission server
SUBMISSION_PORT587SMTP submission port
SUBMISSION_TLS_CERTrequired when SUBMISSION_ENABLED=truePEM-encoded TLS certificate
SUBMISSION_TLS_KEYrequired when SUBMISSION_ENABLED=truePEM-encoded TLS private key
TLS required

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

  1. VERP decode — extract the original message ID from the recipient address
  2. DSN parse — parse RFC 3464 delivery status notifications for enhanced status codes and diagnostic information
  3. ARF/FBL parse — detect Abuse Reporting Format feedback-loop reports from ISPs
  4. 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
  5. Route — non-bounce mail resolves to an inbound route (endpoint/accept/…) or a personal mailbox
  6. 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_IP concurrent connections from one IP (Redis counter with a 5-minute window); excess connections are rejected.
  • Global client capBOUNCE_MAX_CLIENTS total concurrent connections.
  • Tarpit — when BOUNCE_TARPIT_ENABLED is on, non-local connections incur a deliberate BOUNCE_TARPIT_DELAY_MS delay to slow down abusive senders.
  • SPF validation — when INBOUND_SPF_ENABLED is on, checkSpf evaluates the sender domain's SPF record (ip4/ip6/a/mx/include/all) against the connecting IP.
  • DKIM verification — when INBOUND_DKIM_ENABLED is on, verifyDkim (RFC 6376) checks the message signature over the raw bytes. Fail-open: a verify crash yields temperror and is recorded as dkimResult, never a rejection.
  • DMARC evaluation — when INBOUND_DMARC_ENABLED is on, evaluateDmarc (RFC 7489) aligns the SPF and DKIM results with the From: domain and applies its published policy. The verdict (and policy) is recorded as dmarcResult so Convex can route spoofed mail to Spam. Fail-open: a lookup crash yields temperror, 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_KEY continues to work for all endpoints (used by the Convex backend)
  • Per-org keys authenticate both HTTP API /send requests 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

ModeBehavior
endpointForward the parsed email to an HTTP webhook URL
acceptSilently accept the email
holdAccept but hold for manual review
bounceReturn a bounce response to the sender
rejectReject 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, and attachments (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

PoolPurpose
transactionalTime-sensitive emails (password resets, order confirmations)
campaignBulk 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:

  1. Organization-specific rule (if set via POST /pool-rules)
  2. Request ipPool field from the job
  3. 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).

EventSeverityDescription
sentinfoEmail delivered successfully (SMTP 250 response)
bouncedwarningHard or soft bounce (includes bounceType: 'hard' | 'soft')
complainedwarningSpam complaint from ISP feedback loop
inbound.receivedinfoInbound email received and routed
inbound.mailbox.receivedinfoInbound mail delivered to a personal mailbox (Postbox)
org.circuit_breakercriticalOrganization bounce rate too high, sending paused
ip.blocklistedcriticalSending IP added to a DNS blocklist
ip.delistedinfoSending IP removed from a DNS blocklist
ip.warming_completeinfoIP graduated from warming schedule
all_ips_blockedcriticalNo 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

VariableDescription
MTA_API_KEYShared secret for HTTP API authentication (Bearer token)
EHLO_HOSTNAMESMTP EHLO hostname. Must match the server's rDNS PTR record.
RETURN_PATH_DOMAINDomain for VERP bounce addresses (e.g., bounces.owlat.com)
CONVEX_SITE_URLConvex site URL for webhook callbacks
MTA_WEBHOOK_SECRETShared secret for authenticating webhook requests to Convex
IP_POOLS_TRANSACTIONALComma-separated IP addresses for the transactional pool
IP_POOLS_CAMPAIGNComma-separated IP addresses for the campaign pool
MTA_IPV6_ENABLEDExplicitly permits native IPv6 pool entries; defaults false

Optional Variables

VariableDefaultDescription
PORT3100HTTP server port
BOUNCE_PORT25Inbound SMTP port for bounce processing
REDIS_URLredis://localhost:6379Redis connection URL
DKIM_KEYS{}JSON: {"domain.com": {"selector": "s1", "privateKey": "..."}} — seeds Redis on startup
WORKER_CONCURRENCY50GroupMQ worker concurrency (parallel group slots)
MTA_SERVER_IDhostnameServer identifier for multi-instance deployments
LOG_LEVELinfoPino log level
NODE_ENVSet to production for production deployments
SUBMISSION_ENABLEDfalseEnable the SMTP submission server (port 587)
SUBMISSION_PORT587SMTP submission server port
SUBMISSION_TLS_CERTPEM-encoded TLS certificate for SMTP submission
SUBMISSION_TLS_KEYPEM-encoded TLS private key for SMTP submission
ORG_DEFAULT_DAILY_LIMIT50000Default daily send cap per organization
ORG_DEFAULT_HOURLY_LIMIT5000Default hourly send cap per organization
CONTENT_SCREENING_ENABLEDtrueEnable content pre-screening before delivery
CONTENT_MAX_SIZE_KB500Maximum HTML size in KB before content screening rejects
DELIVERY_LOG_MAX_LEN100000Max entries per daily Redis Stream delivery log
DELIVERY_LOG_TTL_HOURS72TTL in hours for delivery log streams
WEBHOOK_DLQ_MAX_SIZE10000Max entries in the webhook dead letter queue
SMTP_OUTCOME_JOURNAL_MAX_SIZE10000Max unresolved SMTP outcome reservations before new attempts defer
FBL_DEDUP_PROTOCOLrequired (owned-v2)Versioned complaint reservation protocol; absence fails MTA startup
FBL_DEDUP_CUTOVER_ACKrequiredfresh-install for a new Redis/install, or quiesced-v1-intake after the coordinated legacy cutover below
SMTP_POOL_MAX_PER_HOST3Concurrent SMTP transports per MX host (per instance)
SMTP_POOL_IDLE_TIMEOUT_MS30000Close idle pooled connections after this period
SMTP_POOL_MAX_AGE_MS300000Force-close pooled connections regardless of activity
SMTP_POOL_GLOBAL_MAX_PER_HOST10Global cap on connections per MX host across all instances
BOUNCE_TLS_CERTPEM-encoded TLS certificate for bounce SMTP server (enables STARTTLS)
BOUNCE_TLS_KEYPEM-encoded TLS private key for bounce SMTP server
BOUNCE_MAX_CONNECTIONS_PER_IP10Max concurrent inbound connections per IP
BOUNCE_MAX_CLIENTS200Max total concurrent inbound connections
BOUNCE_TARPIT_ENABLEDtrueDelay non-local inbound connections
BOUNCE_TARPIT_DELAY_MS5000Tarpit delay per suspicious connection
INBOUND_SPF_ENABLEDtrueValidate SPF on inbound mail
INBOUND_DKIM_ENABLEDtrueVerify DKIM signatures (RFC 6376) on inbound mail
INBOUND_DMARC_ENABLEDtrueEvaluate DMARC (RFC 7489) on inbound mail
RSPAMD_URLrspamd HTTP URL for outbound content spam scoring
RSPAMD_REJECT_THRESHOLD15Content-screening rejects when the rspamd score exceeds this
GOOGLE_POSTMASTER_CLIENT_IDOAuth client ID for Google Postmaster Tools; set with the secret and refresh token
GOOGLE_POSTMASTER_CLIENT_SECRETOAuth client secret for Google Postmaster Tools
GOOGLE_POSTMASTER_REFRESH_TOKENSecret offline token for a Google user with domain/traffic read access
ABUSIX_DNSBL_API_KEYOptional 32-character Guardian Mail DNS namespace key; Abusix findings are warning-only
CLAMAV_HOSTclamavClamAV daemon hostname for attachment scanning (read directly by the scan route, not via config.ts)
CLAMAV_PORT3310ClamAV 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:

  1. 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.
  2. 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.
  3. Start the entire owned-v2 fleet, then resume SMTP traffic. Owned-v2 ignores residual legacy 1 values and uses only its isolated version-2 hash with owned reserved, retryable, and completed states. This is safe only because step 1 ensures no legacy worker can race or create another marker.
  4. 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.

Fail-open design

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:

  1. Load configuration from environment variables
  2. Set organization rate-limit defaults
  3. Configure the SMTP connection pool and start the eviction timer
  4. Connect to Redis and verify connectivity
  5. Enable distributed connection-pool coordination
  6. Seed DKIM keys from DKIM_KEYS into Redis (existing keys not overwritten)
  7. Seed ISP profiles into Redis (HSETNX, preserving runtime overrides)
  8. Initialize IP pools in Redis (mark all IPs active)
  9. Initialize warming state for each IP
  10. Create the GroupMQ queue and worker
  11. Start the HTTP server (Hono on PORT)
  12. Start the bounce/inbound SMTP server (on BOUNCE_PORT)
  13. Start the SMTP submission server (on SUBMISSION_PORT, if SUBMISSION_ENABLED=true)
  14. Start leader election
  15. 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)
  16. Start the GroupMQ worker (begins processing jobs)
Leader election

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.

Port 25 access

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:

  1. Stop all periodic crons (DNSBL, warming, Postmaster, TLS-RPT, DKIM rotation)
  2. Close the HTTP server
  3. Close the bounce SMTP server
  4. Close the SMTP submission server (if running)
  5. Drain the GroupMQ worker (wait for in-flight jobs)
  6. Drain and close the SMTP connection pool
  7. Release leadership (stopLeaderElection)
  8. Close the Redis connection

Key Files

FilePurpose
src/index.tsMain entry point: starts all services, leader election, and crons
src/config.tsConfiguration loading, ISP profiles, warming schedule
src/config/ispProfiles.tsRuntime ISP-profile store (seed/get/set/list)
src/server.tsHono HTTP app setup and route mounts
src/redis.tsRedis connection
src/types.tsShared type definitions, webhook event union
src/lib/leaderElection.tsRedis lock for single-instance crons
src/auth/credentials.tsPer-organization API key management
src/auth/postboxAuth.tsPostbox (personal mailbox) authentication
src/routes/send.tsPOST /send handler (enqueue + engagement-priority mapping)
src/routes/health.tsHealth check and metrics endpoints
src/routes/credentials.tsCredential management API routes
src/routes/dkim.tsDKIM management API routes
src/routes/inboundRoutes.tsInbound routing API routes
src/routes/mailboxes.tsPersonal-mailbox cache API routes
src/routes/orgLimits.tsOrganization rate-limit API routes
src/routes/poolRules.tsPool-rule API routes
src/routes/ispProfiles.tsRuntime ISP-profile admin routes
src/routes/ipReputation.tsPer-IP reputation dashboard routes
src/routes/suppression.tsSuppression-list API routes
src/routes/deliveryLogs.tsDelivery-log query routes
src/routes/queue.tsQueue-inspection routes
src/routes/dlq.tsWebhook dead-letter-queue routes
src/routes/scan.tsAttachment scanning endpoint (file validation + ClamAV)
src/queue/setup.tsGroupMQ queue and worker creation
src/queue/handler.tsGroupMQ adapter: runs the dispatch pipeline + outcome reducer
src/queue/groups.tsGroup key generation, ISP classification
src/dispatch/pipeline.tsTyped Phase/compose/runPipeline primitives
src/dispatch/phases/index.tsmainPipeline composition (the ten phases) + per-phase files
src/dispatch/outcome.tsPure reducer: SMTP result → typed effect list
src/dispatch/effects.tsEffect runner (delegates to intelligence/scaling/monitoring)
src/dispatch/types.tsPhase context types (BasePhaseCtx, CtxWithPool, CtxWithIp)
src/intelligence/circuitBreaker.tsPer-org bounce + complaint-rate protection
src/intelligence/domainThrottle.tsAdaptive per-IP/per-domain rate limiting
src/intelligence/contentScreening.tsPre-send content checks + rspamd scoring
src/intelligence/smtpResponse.tsSMTP response health tracking
src/intelligence/dnsbl.tsDNS blocklist checking
src/intelligence/warming.tsIP warming schedule and tracking
src/intelligence/engagementPriority.tsEnqueue-time engagement-priority mapping
src/intelligence/orgLimits.tsPer-org daily/hourly send rate limits
src/intelligence/suppressionList.tsGlobal email suppression list
src/smtp/sender.tsDirect MX delivery (DKIM, MTA-STS, TLS-RPT recording)
src/smtp/mxResolver.tsMX DNS lookups with caching
src/smtp/dkim.tsDKIM signing configuration
src/smtp/dkimStore.tsRedis-backed DKIM key storage
src/smtp/dkimRotation.tsAutomatic DKIM key rotation workflow
src/smtp/mtaSts.tsMTA-STS (RFC 8461) policy fetch + enforcement
src/smtp/tlsRpt.tsTLS-RPT (RFC 8460) recording + daily report generation
src/smtp/connectionPool.tsSMTP transport pool with cross-instance coordination
src/smtp/submissionServer.tsSMTP submission server (port 587)
src/inbound/router.tsInbound email route matching
src/inbound/mailboxResolver.tsPersonal-mailbox address cache (O(1) Redis lookup)
src/inbound/forwarder.tsHTTP webhook forwarding for inbound emails
src/bounce/server.tsInbound/bounce SMTP server
src/bounce/pipeline.tsBounce classification phase pipeline
src/bounce/outcome.tsBounce pure reducer (effect emission)
src/bounce/effects.tsBounce effect runner
src/bounce/inboundSecurity.tsSPF validation, tarpit, per-IP connection caps
src/bounce/parser.tsDSN message parsing
src/bounce/fblProcessor.tsARF/FBL feedback-loop parsing
src/bounce/classifier.tsBounce type classification
src/bounce/verp.tsVERP return-path encoding/decoding
src/scaling/ipPool.tsIP pool selection and management
src/scaling/poolRules.tsPer-org IP pool assignment rules
src/scaling/degradation.tsSystem health and domain backoff
src/monitoring/logger.tsPino structured logging
src/monitoring/collector.tsPrometheus metrics collection
src/monitoring/deliveryLogger.tsPer-message delivery event Redis Streams
src/monitoring/postmaster.tsGoogle Postmaster Tools data fetcher
src/webhooks/convexNotifier.tsWebhook delivery to Convex