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 spam filter — no inbound spam scoring; outbound content screening and attachment malware scanning are 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 10+ checks 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
Intelligence Pipelinesrc/intelligence/Eight pre-send safety checks before every delivery attempt
SMTP Sendersrc/smtp/Direct MX delivery with DKIM signing
SMTP Submissionsrc/smtp/Authenticated SMTP submission server (port 587)
Connection Poolsrc/smtp/Reusable SMTP transport pool per MX host
DKIM Key Storesrc/smtp/Redis-backed DKIM key storage with rotation
Bounce Processorsrc/bounce/Inbound SMTP server for DSN/ARF parsing and classification
Inbound Routersrc/inbound/Rule-based inbound email routing
Credentialssrc/auth/Per-organization API key management
Webhook Notifiersrc/webhooks/Event callbacks to the Convex backend
Monitoringsrc/monitoring/Prometheus metrics and structured logging
Scalingsrc/scaling/IP pool management, pool rules, and graceful degradation
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
POST/send/batchBearerQueue up to 1,000 emails in one request
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

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 endpoints accept both the master MTA_API_KEY and per-organization API keys (owlat_...). All management endpoints (/credentials, /dkim, /org-limits, /pool-rules, /suppression, /inbound/routes) require the master key.

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
}

Intelligence Pipeline

Every email passes through ten checks before SMTP delivery. If any check fails, the job is deferred (retried later) rather than dropped — except suppression and content screening, which silently drop the job.

0a. Content Pre-Screening

File: intelligence/contentScreening.ts

Pluggable content inspection step that runs before all other checks. Catches malformed or dangerous content before it can damage IP reputation. Enabled by default (CONTENT_SCREENING_ENABLED=true).

CheckAction on Failure
DKIM domain alignment (dkimDomain matches From header domain)Reject
Email size budget (HTML exceeds CONTENT_MAX_SIZE_KB, default 500KB)Reject
Empty body (both html and text missing)Reject
Required headers (From, Subject must be present and non-empty)Reject
URL blocklist (known phishing/malware patterns in Redis)Reject

0b. Suppression List

File: intelligence/suppressionList.ts

Global suppression list checked before all other intelligence checks. If the recipient is suppressed, the job is silently dropped (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 are stored in a Redis set, normalized to lowercase. The suppression list is managed via POST /suppression, DELETE /suppression/:email, and GET /suppression/check/:email.

1. Circuit Breaker

File: intelligence/circuitBreaker.ts

Per-organization bounce rate monitoring with three states:

StateBehavior
Closed (normal)All sends proceed. Transitions to Open when bounce rate exceeds threshold.
Open (paused)All sends for the organization are deferred. Cools down for 30 minutes.
Half-Open (testing)A small number of test sends proceed. If they bounce, returns to Open (60 min cooldown).

Thresholds:

  • Fast check (first 50 sends): trips if bounce rate > 15%
  • Slow check (100+ sends): trips if bounce rate > 8%

When the circuit opens, a org.circuit_breaker webhook event is sent to Convex.

1b. Organization Rate Limits

File: intelligence/orgLimits.ts

Per-organization daily and hourly send rate limits enforced via Redis counters.

LimitDefaultCounter TTL
Daily50,00048 hours
Hourly5,0002 hours

Per-org overrides can be set via POST /org-limits. When a limit is exceeded, the job is deferred with a retryAfter delay until the next period boundary (midnight UTC for daily, next hour for hourly).

2. Domain Throttle

File: intelligence/domainThrottle.ts

Adaptive per-domain rate limiting using a sliding window in Redis. Each ISP has a specific 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 automatically:

  • Backs off on SMTP 4xx responses (rate reduced by backoff factor)
  • Recovers on sustained successful deliveries (rate increased by recovery factor)
  • Blocks after repeated failures (auto-recovers after 5 minutes)

3. SMTP Response Tracking

File: intelligence/smtpResponse.ts

Tracks SMTP response codes (4xx/5xx) per recipient domain. Detects degraded and blocking conditions and suggests deferral delays based on response patterns.

3b. Domain Connection Backoff

File: scaling/degradation.ts

Tracks per-domain connection failures and applies automatic backoff on repeated failures. Jobs targeting domains in backoff are deferred with a calculated retry delay.

3c. Pool Rules Resolution

File: scaling/poolRules.ts

Resolves the effective IP pool and dedicated IP for the sending organization before IP selection. Resolution priority:

  1. Organization-specific pool rule (if set via POST /pool-rules)
  2. Request ipPool field from the job
  3. Default pool

If a dedicated IP is configured for the organization, it bypasses round-robin selection.

4. IP Selection

File: scaling/ipPool.ts

Round-robin IP selection within the resolved pool. IPs flagged by DNSBL checking are excluded. If a dedicated IP was resolved in step 3c, it is used directly. If all IPs in a pool are blocked, the system falls back to the first IP and sends an all_ips_blocked alert.

4b. Warming Cap

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 warming system adapts based on deliverability:

  • Accelerates when bounce and deferral rates are low
  • Decelerates when deliverability signals are poor

Daily stats are tracked in Redis. When an IP graduates, an ip.warming_complete webhook event is sent.

5. DNSBL Checking

File: intelligence/dnsbl.ts

Periodic check (every 15 minutes) against DNS-based blocklists:

BlocklistSeverity
Spamhaus (zen.spamhaus.org)Critical
Barracuda (b.barracudacentral.org)Warning
SpamCop (bl.spamcop.net)Warning

Listed IPs are automatically removed from the active sending pool. Convex is notified via ip.blocklisted and ip.delisted webhook events.

6. Engagement Priority

File: intelligence/engagementPriority.ts

Maps engagement scores (0–100, provided by the Convex backend) to delivery priority. High-engagement contacts (score 80+) are processed first using GroupMQ's orderMs mechanism.

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 an intelligence check defers a job, it uses GroupMQ's DeferError mechanism to reschedule with a specific delay.

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.

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:

bounces+{messageId}@bounces.owlat.com

This allows the bounce processor to correlate incoming DSN messages back to the original send without maintaining a lookup table.

Transport

Nodemailer transports are acquired from the connection pool for each send:

  • Binds to a specific IP from the selected pool (or dedicated IP)
  • 30-second connection timeout, 60-second socket timeout
  • No TLS requirement (port 25 MX delivery)
  • DKIM signing keys loaded from the DKIM key store

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 intelligence 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_CERTPEM-encoded TLS certificate
SUBMISSION_TLS_KEYPEM-encoded TLS private key
TLS recommended

When SUBMISSION_TLS_CERT and SUBMISSION_TLS_KEY are provided, the server requires STARTTLS before authentication. Without TLS, credentials are sent in plaintext — only use this for local or trusted networks.

Bounce Processing

Files: bounce/server.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 and feedback loop reports. Messages arrive at the VERP return-path address.

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. Auto-suppress — hard bounces and complaints automatically add the recipient to the suppression list
  6. Webhook — post the event to the Convex backend for tracking and contact suppression

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-Secret header (constant-time comparison) and timestamp validation (5-minute tolerance).

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
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 reuse the existing processBounceEvent and processComplaintEvent mutations, so they feed into the same contact suppression and analytics pipeline used by SES/Resend webhooks.

The MtaProvider class in lib/emailProviders/mta.ts implements the EmailProvider interface, posting send requests to the MTA's HTTP API with retry logic for transient errors (429, 5xx) and a 30-second request timeout.

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

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
BOUNCE_TLS_CERTPEM-encoded TLS certificate for bounce SMTP server (enables STARTTLS)
BOUNCE_TLS_KEYPEM-encoded TLS private key for bounce SMTP server
CLAMAV_HOSTlocalhostClamAV daemon hostname for attachment scanning
CLAMAV_PORT3310ClamAV daemon port

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

  1. Load configuration from environment variables
  2. Set organization rate limit defaults
  3. Configure SMTP connection pool and start eviction timer
  4. Connect to Redis and verify connectivity
  5. Seed DKIM keys from DKIM_KEYS env var into Redis (existing keys not overwritten)
  6. Initialize IP pools in Redis (mark all IPs as active)
  7. Initialize warming state for each IP
  8. Create GroupMQ queue and worker
  9. Start HTTP server (Hono on PORT)
  10. Start bounce SMTP server (on BOUNCE_PORT)
  11. Start SMTP submission server (on SUBMISSION_PORT, if SUBMISSION_ENABLED=true)
  12. Start DNSBL checker (runs every 15 minutes)
  13. Start warming evaluation cron (runs every hour)
  14. Start GroupMQ worker (begins processing jobs)
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:

  1. Stop periodic crons (DNSBL checker, warming evaluation)
  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 to complete)
  6. Drain and close the SMTP connection pool
  7. Close the Redis connection

Key Files

FilePurpose
src/index.tsMain entry point: starts all services
src/config.tsConfiguration loading, ISP profiles, warming schedule
src/server.tsHono HTTP app setup
src/redis.tsRedis connection
src/types.tsShared type definitions
src/auth/credentials.tsPer-organization API key management
src/routes/send.tsPOST /send handler
src/routes/sendBatch.tsPOST /send/batch handler
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/orgLimits.tsOrganization rate limit API routes
src/routes/poolRules.tsPool rule API routes
src/routes/suppression.tsSuppression list API routes
src/routes/scan.tsAttachment scanning endpoint (file validation + ClamAV)
src/queue/setup.tsGroupMQ queue and worker creation
src/queue/handler.tsMain job orchestrator (intelligence pipeline)
src/queue/groups.tsGroup key generation, ISP classification
src/intelligence/circuitBreaker.tsPer-organization bounce rate protection
src/intelligence/domainThrottle.tsAdaptive per-domain rate limiting
src/intelligence/smtpResponse.tsSMTP response health tracking
src/intelligence/dnsbl.tsDNS blocklist checking
src/intelligence/warming.tsIP warming schedule and tracking
src/intelligence/engagementPriority.tsEngagement-based priority mapping
src/intelligence/orgLimits.tsPer-organization daily/hourly send rate limits
src/intelligence/suppressionList.tsGlobal email suppression list
src/smtp/sender.tsDirect MX delivery
src/smtp/mxResolver.tsMX DNS lookups with caching
src/smtp/dkim.tsDKIM signing configuration
src/smtp/dkimStore.tsRedis-backed DKIM key storage with rotation
src/smtp/connectionPool.tsReusable SMTP transport pool
src/smtp/submissionServer.tsSMTP submission server (port 587)
src/inbound/router.tsInbound email route matching
src/inbound/forwarder.tsHTTP webhook forwarding for inbound emails
src/bounce/server.tsInbound SMTP server for bounces
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/webhooks/convexNotifier.tsWebhook delivery to Convex