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 the default email provider (EMAIL_PROVIDER=mta). SES and Resend are available as alternatives. See 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 (sent, bounce rate, warming phase/day, pool, active flag)
GET/ip-reputation/:ipMasterFull reputation view for one IP (metrics, computed rates, warming, pool)

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 for priority ordering
  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 flagged by DNSBL checking. A dedicated IP from phase 7 is used directly. If no IP is available the attempt defers; if all IPs in a pool are blocked the system falls back to the first IP and emits an all_ips_blocked alert.

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 the cap for the day is reached the attempt defers. When an IP graduates, an ip.warming_complete webhook event is sent.

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 (0–100, supplied by Convex) through mapToPriority (intelligence/engagementPriority.ts) and then priorityToOrderMs (a local helper in src/routes/send.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.

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.

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 Nodemailer transport pool keyed by {mxHost}:{bindIp}:{dkimDomain}. Replaces single-use transports 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.

When BOUNCE_VERP_KEY is set, the token carries an authenticating truncated HMAC — bounce+{base64url(messageId)}+{hmac}@bounces.owlat.com — and the unsigned form shown above is the backward-compatible fallback used only when no signing key is configured.

Transport

Nodemailer transports 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 transport sets requireTLS and rejectUnauthorized, 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 mailparser, then fanned out to one GroupMQ job per recipient (To, Cc, Bcc). Each job enters the standard dispatch pipeline. The sender domain is extracted for DKIM signing, and each job is assigned a smtp-{uuid} message ID.

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

Selection

Round-robin IP selection within each pool. IPs flagged by DNSBL checking are excluded from selection. If all IPs in a pool are blocked, the system falls back to the first IP and sends an all_ips_blocked alert.

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 via mtaWebhook.ts (HTTP action). Bounce and complaint events are parsed by webhooks/adapters/mta.ts into typed email.bounced / email.complained InboundEvents and routed through the shared inbound webhook pipeline (the same dispatcher SES/Resend use, via runInboundPipeline in mtaWebhook.ts).

On the send side, the MTA is one of three pluggable send-provider modules in apps/api/convex/lib/sendProviders/. 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, defaulting to 'mta'. 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_CREDENTIALS (a Google service-account JSON key with Postmaster Tools access) is set, a leader-only hourly cron pulls per-domain reputation, user-reported spam rate, SPF/DKIM/DMARC pass rates, and delivery-error categories from Google's Postmaster Tools API for the previous day. Data is cached in Redis (30 days) and surfaced as Prometheus gauges (mta_postmaster_domain_reputation, mta_postmaster_spam_rate, mta_postmaster_auth_rate, mta_postmaster_delivery_errors).

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

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_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_CREDENTIALSGoogle service-account JSON key for the Postmaster Tools fetcher
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)

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