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-scannerpackage (see Email Security) - Not a user-facing service — API-only, managed programmatically; no admin dashboard or self-service UI
Design Philosophy
The MTA follows a depth over breadth approach: rather than building a complete mail server that does many things adequately, it focuses exclusively on outbound delivery intelligence. Every feature serves one goal — maximizing inbox placement while protecting sender reputation.
Key architectural decisions:
- Stateless process, stateful Redis — the MTA process itself holds no state; all intelligence data lives in Redis with TTLs. This enables horizontal scaling and zero-downtime deploys.
- Intelligence before delivery — every email passes through 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
| Component | Directory | Purpose |
|---|---|---|
| HTTP API | src/routes/ | Hono server accepting send requests from the Convex backend |
| GroupMQ Worker | src/queue/ | Redis-backed job queue with group-based processing |
| Intelligence Pipeline | src/intelligence/ | Eight pre-send safety checks before every delivery attempt |
| SMTP Sender | src/smtp/ | Direct MX delivery with DKIM signing |
| SMTP Submission | src/smtp/ | Authenticated SMTP submission server (port 587) |
| Connection Pool | src/smtp/ | Reusable SMTP transport pool per MX host |
| DKIM Key Store | src/smtp/ | Redis-backed DKIM key storage with rotation |
| Bounce Processor | src/bounce/ | Inbound SMTP server for DSN/ARF parsing and classification |
| Inbound Router | src/inbound/ | Rule-based inbound email routing |
| Credentials | src/auth/ | Per-organization API key management |
| Webhook Notifier | src/webhooks/ | Event callbacks to the Convex backend |
| Monitoring | src/monitoring/ | Prometheus metrics and structured logging |
| Scaling | src/scaling/ | IP pool management, pool rules, and graceful degradation |
| Attachment Scanner | src/routes/scan.ts | File type validation + ClamAV malware scanning endpoint |
HTTP API Endpoints
The MTA supports two authentication modes: the master MTA_API_KEY (accepted on all endpoints) and per-organization API keys (accepted on /send endpoints only). Management endpoints require the master key.
Core
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /send | Bearer | Queue a single email for delivery |
POST | /send/batch | Bearer | Queue up to 1,000 emails in one request |
GET | /health | None | System health check (Redis, queue depth) |
GET | /metrics | None | Prometheus metrics endpoint |
Credential Management
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /credentials | Master | Create a per-org API credential |
GET | /credentials | Master | List credentials (filter by ?organizationId=) |
DELETE | /credentials/:apiKey | Master | Revoke a credential |
DKIM Management
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /dkim | Master | Add or update a DKIM key |
GET | /dkim | Master | List all DKIM domains (keys redacted) |
DELETE | /dkim/:domain | Master | Remove a DKIM key |
POST | /dkim/:domain/rotate | Master | Generate a new RSA 2048 key pair |
Inbound Routing
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /inbound/routes | Master | Create or update an inbound route |
GET | /inbound/routes | Master | List all inbound routes |
DELETE | /inbound/routes/:domain/:address | Master | Remove an inbound route |
Organization Limits
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /org-limits | Master | Set daily/hourly send limits for an organization |
GET | /org-limits/:orgId | Master | Get organization usage and limits |
Pool Rules
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /pool-rules | Master | Set pool assignment for an organization |
GET | /pool-rules/:orgId | Master | Get organization pool rule |
DELETE | /pool-rules/:orgId | Master | Remove organization pool rule |
Suppression List
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /suppression | Master | Add addresses to the suppression list (batch) |
DELETE | /suppression/:email | Master | Remove an address from the suppression list |
GET | /suppression/check/:email | Master | Check suppression status for an address |
POST | /suppression/bulk | Master | Add up to 10,000 addresses in one request |
GET | /suppression/export | Master | Paginated export with metadata (?reason=, ?cursor=) |
GET | /suppression/stats | Master | Counts by suppression reason |
Attachment Scanning
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /scan/attachment | Master | Scan file for malware (file type validation + ClamAV) |
GET | /scan/health | None | ClamAV connection status |
The scan endpoint performs two checks: file type validation (magic bytes, double extension, extension allowlist) and ClamAV malware scanning. The endpoint is fail-open — if ClamAV is unavailable, the file passes with a warning. See Email Security for details on the scanning pipeline.
Delivery Logs
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /delivery-logs | Master | Query events by date, orgId, status, domain (paginated) |
GET | /delivery-logs/stats | Master | Aggregated counts by status/domain/pool for a date range |
GET | /delivery-logs/:messageId | Master | All delivery events for a specific message |
Queue Inspection
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /queue/stats | Master | Queue depth by state (pending, active, completed, failed, delayed) |
GET | /queue/pending | Master | List pending jobs (?limit=, ?offset=, ?domain=) |
GET | /queue/jobs/:jobId | Master | Full job details with attempt history |
DELETE | /queue/jobs/:jobId | Master | Cancel a specific pending job |
POST | /queue/flush | Master | Cancel all pending jobs for an org (?orgId=) |
Dead Letter Queue
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /dlq | Master | List failed webhook events (?limit=, ?offset=) |
GET | /dlq/stats | Master | Total count and oldest entry age |
POST | /dlq/:dlqId/retry | Master | Retry a specific failed webhook event |
POST | /dlq/retry-all | Master | Retry all DLQ entries |
DELETE | /dlq/:dlqId | Master | Discard a specific failed event |
The /send 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).
| Check | Action 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).
| Reason | Source |
|---|---|
hard_bounce | Auto-added when a hard bounce is detected |
complaint | Auto-added on ISP spam complaint |
manual | Added via the /suppression management API |
Suppressed addresses 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:
| State | Behavior |
|---|---|
| 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.
| Limit | Default | Counter TTL |
|---|---|---|
| Daily | 50,000 | 48 hours |
| Hourly | 5,000 | 2 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:
| ISP | Default Rate | Ceiling | Floor |
|---|---|---|---|
| Gmail / Googlemail | 100/min | 300/min | 5/min |
| Outlook / Hotmail / Live | 80/min | 200/min | 5/min |
| Yahoo / AOL / Ymail | 50/min | 150/min | 3/min |
| iCloud / me / mac | 60/min | 150/min | 5/min |
| Other domains | 30/min | 100/min | 2/min |
The throttle 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:
- Organization-specific pool rule (if set via
POST /pool-rules) - Request
ipPoolfield from the job - 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:
| Day | Daily Cap |
|---|---|
| 1 | 50 |
| 2 | 100 |
| 3 | 200 |
| 5 | 700 |
| 7 | 1,500 |
| 10 | 3,000 |
| 14 | 7,500 |
| 18 | 15,000 |
| 21 | 20,000 |
| 25 | 30,000 |
| 30+ | Unlimited (graduated) |
The 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:
| Blocklist | Severity |
|---|---|
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
| Setting | Default | Description |
|---|---|---|
| Concurrency | 50 | Parallel group processing slots (WORKER_CONCURRENCY) |
| Max attempts | 5 | Retry limit per job |
| Job timeout | 2 min | Per-job processing timeout |
| Backoff | Exponential | 30s → 2m → 8m → 30m → 2h |
| Completed retention | 1,000 | Last N completed jobs kept in Redis |
| Failed retention | 5,000 | Last N failed jobs kept in Redis |
When 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 management —
POST /dkim,GET /dkim,DELETE /dkim/:domain - Key rotation —
POST /dkim/:domain/rotategenerates a new RSA 2048-bit key pair and returns the DNS TXT record value (v=DKIM1; k=rsa; p={base64}) for publishing - Env var seeding —
DKIM_KEYSstill works for initial bootstrapping; Redis is the source of truth at runtime
Connection Pool
File: smtp/connectionPool.ts
Reusable Nodemailer transport pool keyed by {mxHost}:{bindIp}:{dkimDomain}. Replaces single-use transports for better connection reuse and reduced DNS/TLS overhead.
| Setting | Default | Description |
|---|---|---|
| Max connections per host | 3 | Concurrent transports per MX host |
| Idle timeout | 30s | Close idle connections after this period |
| Max connection age | 5 min | Force close regardless of activity |
The pool runs a periodic eviction sweep (every 10s) to close idle and aged-out transports. Connections with in-flight sends are never evicted. A Prometheus gauge (mta_smtp_pool_connections) tracks active and idle connection counts by pool key.
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.
| Setting | Value |
|---|---|
| Port | 587 (configurable via SUBMISSION_PORT) |
| Encryption | STARTTLS upgrade |
| Auth methods | PLAIN, LOGIN |
| Max message size | 25 MB |
| Default IP pool | transactional |
Authentication: Accepts both the master MTA_API_KEY and per-org API keys as the SMTP password (username is ignored). Per-org keys scope all submitted emails to that organization.
Processing: Incoming messages are parsed with 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.
| Variable | Default | Description |
|---|---|---|
SUBMISSION_ENABLED | false | Enable the submission server |
SUBMISSION_PORT | 587 | SMTP submission port |
SUBMISSION_TLS_CERT | — | PEM-encoded TLS certificate |
SUBMISSION_TLS_KEY | — | PEM-encoded TLS private key |
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
- VERP decode — extract the original message ID from the recipient address
- DSN parse — parse RFC 3464 delivery status notifications for enhanced status codes and diagnostic information
- ARF/FBL parse — detect Abuse Reporting Format feedback loop reports from ISPs
- Classify — categorize the event:
- Hard bounce (permanent) — invalid recipient, domain doesn't exist
- Soft bounce (temporary) — mailbox full, server temporarily unavailable
- Complaint — recipient marked the email as spam
- Auto-suppress — hard bounces and complaints automatically add the recipient to the suppression list
- 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_KEYcontinues to work for all endpoints (used by the Convex backend) - Per-org keys authenticate both HTTP API
/sendrequests and SMTP submission connections - Management endpoints (
POST/GET/DELETE /credentials) require the master key
Credentials track createdAt and lastUsedAt timestamps. The GET /credentials endpoint returns truncated key prefixes (first 10 chars) for safe display.
Inbound Email Routing
Files: inbound/router.ts, inbound/forwarder.ts
Route incoming emails by domain and local-part address. Routes are stored in Redis and managed via the /inbound/routes API.
Route Modes
| Mode | Behavior |
|---|---|
endpoint | Forward the parsed email to an HTTP webhook URL |
accept | Silently accept the email |
hold | Accept but hold for manual review |
bounce | Return a bounce response to the sender |
reject | Reject during SMTP negotiation |
Route Matching
Priority: exact address match, then wildcard (*). For example, a route for support@example.com takes precedence over *@example.com. Routes are keyed by domain:address in Redis.
Webhook Forwarding
For endpoint mode, the parsed email is posted as JSON to the configured URL:
- Payload includes:
from,to,subject,textBody,htmlBody,headers,date,messageId,inReplyTo,references, andattachments(base64-encoded) - 10-second timeout per request
- Up to 2 retries with exponential backoff (1s, 2s)
An inbound.received webhook event is sent to Convex for all routed inbound emails.
IP Pool Management
Files: scaling/ipPool.ts, scaling/degradation.ts
Two Pools
| Pool | Purpose |
|---|---|
transactional | Time-sensitive emails (password resets, order confirmations) |
campaign | Bulk marketing emails |
IPs are configured via IP_POOLS_TRANSACTIONAL and IP_POOLS_CAMPAIGN environment variables (comma-separated).
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:
- Organization-specific rule (if set via
POST /pool-rules) - Request
ipPoolfield from the job - Default pool
Managed via POST /pool-rules, GET /pool-rules/:orgId, DELETE /pool-rules/:orgId.
Degradation
The degradation system tracks per-domain connection failures and applies automatic backoff on repeated failures. System-level health checks monitor queue depth and Redis connectivity. When the queue is too deep, the API returns HTTP 429 (backpressure).
Webhook Events
Events are posted to {CONVEX_SITE_URL}/webhooks/mta with authentication via the X-MTA-Secret header (constant-time comparison) and timestamp validation (5-minute tolerance).
| Event | Severity | Description |
|---|---|---|
sent | info | Email delivered successfully (SMTP 250 response) |
bounced | warning | Hard or soft bounce (includes bounceType: 'hard' | 'soft') |
complained | warning | Spam complaint from ISP feedback loop |
inbound.received | info | Inbound email received and routed |
org.circuit_breaker | critical | Organization bounce rate too high, sending paused |
ip.blocklisted | critical | Sending IP added to a DNS blocklist |
ip.delisted | info | Sending IP removed from a DNS blocklist |
ip.warming_complete | info | IP graduated from warming schedule |
all_ips_blocked | critical | No sending IPs available (all blocklisted) |
Convex Integration
The Convex backend receives MTA webhooks 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
| Variable | Description |
|---|---|
MTA_API_KEY | Shared secret for HTTP API authentication (Bearer token) |
EHLO_HOSTNAME | SMTP EHLO hostname. Must match the server's rDNS PTR record. |
RETURN_PATH_DOMAIN | Domain for VERP bounce addresses (e.g., bounces.owlat.com) |
CONVEX_SITE_URL | Convex site URL for webhook callbacks |
MTA_WEBHOOK_SECRET | Shared secret for authenticating webhook requests to Convex |
IP_POOLS_TRANSACTIONAL | Comma-separated IP addresses for the transactional pool |
IP_POOLS_CAMPAIGN | Comma-separated IP addresses for the campaign pool |
Optional Variables
| Variable | Default | Description |
|---|---|---|
PORT | 3100 | HTTP server port |
BOUNCE_PORT | 25 | Inbound SMTP port for bounce processing |
REDIS_URL | redis://localhost:6379 | Redis connection URL |
DKIM_KEYS | {} | JSON: {"domain.com": {"selector": "s1", "privateKey": "..."}} — seeds Redis on startup |
WORKER_CONCURRENCY | 50 | GroupMQ worker concurrency (parallel group slots) |
MTA_SERVER_ID | hostname | Server identifier for multi-instance deployments |
LOG_LEVEL | info | Pino log level |
NODE_ENV | — | Set to production for production deployments |
SUBMISSION_ENABLED | false | Enable the SMTP submission server (port 587) |
SUBMISSION_PORT | 587 | SMTP submission server port |
SUBMISSION_TLS_CERT | — | PEM-encoded TLS certificate for SMTP submission |
SUBMISSION_TLS_KEY | — | PEM-encoded TLS private key for SMTP submission |
ORG_DEFAULT_DAILY_LIMIT | 50000 | Default daily send cap per organization |
ORG_DEFAULT_HOURLY_LIMIT | 5000 | Default hourly send cap per organization |
CONTENT_SCREENING_ENABLED | true | Enable content pre-screening before delivery |
CONTENT_MAX_SIZE_KB | 500 | Maximum HTML size in KB before content screening rejects |
DELIVERY_LOG_MAX_LEN | 100000 | Max entries per daily Redis Stream delivery log |
DELIVERY_LOG_TTL_HOURS | 72 | TTL in hours for delivery log streams |
WEBHOOK_DLQ_MAX_SIZE | 10000 | Max entries in the webhook dead letter queue |
BOUNCE_TLS_CERT | — | PEM-encoded TLS certificate for bounce SMTP server (enables STARTTLS) |
BOUNCE_TLS_KEY | — | PEM-encoded TLS private key for bounce SMTP server |
CLAMAV_HOST | localhost | ClamAV daemon hostname for attachment scanning |
CLAMAV_PORT | 3310 | ClamAV 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.
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
- Load configuration from environment variables
- Set organization rate limit defaults
- Configure SMTP connection pool and start eviction timer
- Connect to Redis and verify connectivity
- Seed DKIM keys from
DKIM_KEYSenv var into Redis (existing keys not overwritten) - Initialize IP pools in Redis (mark all IPs as active)
- Initialize warming state for each IP
- Create GroupMQ queue and worker
- Start HTTP server (Hono on
PORT) - Start bounce SMTP server (on
BOUNCE_PORT) - Start SMTP submission server (on
SUBMISSION_PORT, ifSUBMISSION_ENABLED=true) - Start DNSBL checker (runs every 15 minutes)
- Start warming evaluation cron (runs every hour)
- Start GroupMQ worker (begins processing jobs)
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:
- Stop periodic crons (DNSBL checker, warming evaluation)
- Close the HTTP server
- Close the bounce SMTP server
- Close the SMTP submission server (if running)
- Drain the GroupMQ worker (wait for in-flight jobs to complete)
- Drain and close the SMTP connection pool
- Close the Redis connection
Key Files
| File | Purpose |
|---|---|
src/index.ts | Main entry point: starts all services |
src/config.ts | Configuration loading, ISP profiles, warming schedule |
src/server.ts | Hono HTTP app setup |
src/redis.ts | Redis connection |
src/types.ts | Shared type definitions |
src/auth/credentials.ts | Per-organization API key management |
src/routes/send.ts | POST /send handler |
src/routes/sendBatch.ts | POST /send/batch handler |
src/routes/health.ts | Health check and metrics endpoints |
src/routes/credentials.ts | Credential management API routes |
src/routes/dkim.ts | DKIM management API routes |
src/routes/inboundRoutes.ts | Inbound routing API routes |
src/routes/orgLimits.ts | Organization rate limit API routes |
src/routes/poolRules.ts | Pool rule API routes |
src/routes/suppression.ts | Suppression list API routes |
src/routes/scan.ts | Attachment scanning endpoint (file validation + ClamAV) |
src/queue/setup.ts | GroupMQ queue and worker creation |
src/queue/handler.ts | Main job orchestrator (intelligence pipeline) |
src/queue/groups.ts | Group key generation, ISP classification |
src/intelligence/circuitBreaker.ts | Per-organization bounce rate protection |
src/intelligence/domainThrottle.ts | Adaptive per-domain rate limiting |
src/intelligence/smtpResponse.ts | SMTP response health tracking |
src/intelligence/dnsbl.ts | DNS blocklist checking |
src/intelligence/warming.ts | IP warming schedule and tracking |
src/intelligence/engagementPriority.ts | Engagement-based priority mapping |
src/intelligence/orgLimits.ts | Per-organization daily/hourly send rate limits |
src/intelligence/suppressionList.ts | Global email suppression list |
src/smtp/sender.ts | Direct MX delivery |
src/smtp/mxResolver.ts | MX DNS lookups with caching |
src/smtp/dkim.ts | DKIM signing configuration |
src/smtp/dkimStore.ts | Redis-backed DKIM key storage with rotation |
src/smtp/connectionPool.ts | Reusable SMTP transport pool |
src/smtp/submissionServer.ts | SMTP submission server (port 587) |
src/inbound/router.ts | Inbound email route matching |
src/inbound/forwarder.ts | HTTP webhook forwarding for inbound emails |
src/bounce/server.ts | Inbound SMTP server for bounces |
src/bounce/parser.ts | DSN message parsing |
src/bounce/fblProcessor.ts | ARF/FBL feedback loop parsing |
src/bounce/classifier.ts | Bounce type classification |
src/bounce/verp.ts | VERP return-path encoding/decoding |
src/scaling/ipPool.ts | IP pool selection and management |
src/scaling/poolRules.ts | Per-org IP pool assignment rules |
src/scaling/degradation.ts | System health and domain backoff |
src/monitoring/logger.ts | Pino structured logging |
src/monitoring/collector.ts | Prometheus metrics collection |
src/webhooks/convexNotifier.ts | Webhook delivery to Convex |