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-scannerpackage (see Email Security) - Not a user-facing service — API-only, managed programmatically; no admin dashboard or self-service UI
Design Philosophy
The MTA follows a depth over breadth approach: rather than building a complete mail server that does many things adequately, it focuses exclusively on outbound delivery intelligence. Every feature serves one goal — maximizing inbox placement while protecting sender reputation.
Key architectural decisions:
- Stateless process, stateful Redis — the MTA process itself holds no state; all intelligence data lives in Redis with TTLs. This enables horizontal scaling and zero-downtime deploys.
- Intelligence before delivery — every email passes through a typed ten-phase dispatch pipeline before touching SMTP. Most MTAs send first and react to failures; Owlat prevents failures proactively.
- Engagement-aware ordering — high-engagement recipients are sent first during IP warming, maximizing positive ISP signals when reputation matters most.
- Graceful degradation — back-pressure at the API level (429), per-domain backoff, emergency mode (503) when all IPs are blocked. The system protects itself and its customers automatically.
System Components
| Component | Directory | Purpose |
|---|---|---|
| HTTP API | src/routes/ | Hono server accepting send requests from the Convex backend |
| GroupMQ Worker | src/queue/ | Redis-backed job queue with group-based processing |
| Dispatch Pipeline | src/dispatch/ | Typed, composable phase pipeline — ten ordered pre-send checks (ADR-0007) |
| Intelligence | src/intelligence/ | The check implementations the dispatch phases delegate to |
| SMTP Sender | src/smtp/ | Direct MX delivery with DKIM signing and MTA-STS enforcement |
| SMTP Submission | src/smtp/ | Authenticated SMTP submission server (port 587) |
| Connection Pool | src/smtp/ | Reusable SMTP transport pool per MX host, with cross-instance coordination |
| DKIM Key Store | src/smtp/ | Redis-backed DKIM key storage with automatic rotation |
| Bounce / Inbound Server | src/bounce/ | Inbound SMTP server for DSN/ARF parsing, classification, and inbound mail |
| Inbound Router | src/inbound/ | Rule-based inbound routing + personal-mailbox resolution |
| Credentials | src/auth/ | Per-organization API key and Postbox authentication |
| Webhook Notifier | src/webhooks/ | Event callbacks to the Convex backend |
| Monitoring | src/monitoring/ | Prometheus metrics, structured logging, Google Postmaster fetch |
| Scaling | src/scaling/ | IP pool management, pool rules, and graceful degradation |
| Leader Election | src/lib/ | Redis lock so periodic crons run on a single instance |
| Attachment Scanner | src/routes/scan.ts | File type validation + ClamAV malware scanning endpoint |
HTTP API Endpoints
The MTA supports two authentication modes: the master MTA_API_KEY (accepted on all endpoints) and per-organization API keys (accepted on /send endpoints only). Management endpoints require the master key.
Core
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /send | Bearer | Queue a single email for delivery |
GET | /health | None | System health check (Redis, queue depth) |
GET | /metrics | None | Prometheus metrics endpoint |
Credential Management
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /credentials | Master | Create a per-org API credential |
GET | /credentials | Master | List credentials (filter by ?organizationId=) |
DELETE | /credentials/:apiKey | Master | Revoke a credential |
DKIM Management
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /dkim | Master | Add or update a DKIM key |
GET | /dkim | Master | List all DKIM domains (keys redacted) |
DELETE | /dkim/:domain | Master | Remove a DKIM key |
POST | /dkim/:domain/rotate | Master | Generate a new RSA 2048 key pair |
Inbound Routing
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /inbound/routes | Master | Create or update an inbound route |
GET | /inbound/routes | Master | List all inbound routes |
DELETE | /inbound/routes/:domain/:address | Master | Remove an inbound route |
Personal Mailboxes
The MTA keeps a Redis cache of personal-mailbox (Postbox) addresses so the inbound SMTP server can resolve a recipient in O(1) without a Convex round-trip. Convex pushes mailbox CRUD into this endpoint group (src/routes/mailboxes.ts).
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /mailboxes/cache/:address | Master | Create or refresh a mailbox cache entry (mailboxId, organizationId, optional quota) |
DELETE | /mailboxes/cache/:address | Master | Remove a cache entry |
GET | /mailboxes/cache | Master | List cached addresses |
See Postbox Architecture for how Convex keeps this cache in sync.
ISP Profiles
Adaptive throttling profiles can be tuned at runtime without redeploying. Profiles are seeded from ISP_PROFILES in src/config.ts on startup (via HSETNX, so runtime overrides survive restarts) and managed through src/routes/ispProfiles.ts.
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /isp-profiles | Master | List all profiles (seeded + custom) |
GET | /isp-profiles/:domain | Master | Get the effective profile for a domain |
PUT | /isp-profiles/:domain | Master | Create or update a profile (defaultRate, ceiling, floor, backoffFactor, recoveryFactor) |
DELETE | /isp-profiles/:domain | Master | Remove a custom profile (reverts to the seeded default) |
Updates are validated: floor <= ceiling, defaultRate within [floor, ceiling], 0 < backoffFactor < 1, and recoveryFactor > 1.
IP Reputation
A read-only dashboard that aggregates per-IP warming state, pool status, and today's delivery metrics (src/routes/ipReputation.ts).
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /ip-reputation | Master | Summary row per configured IP (sent, bounce rate, warming phase/day, pool, active flag) |
GET | /ip-reputation/:ip | Master | Full reputation view for one IP (metrics, computed rates, warming, pool) |
Organization Limits
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /org-limits | Master | Set daily/hourly send limits for an organization |
GET | /org-limits/:orgId | Master | Get organization usage and limits |
Pool Rules
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /pool-rules | Master | Set pool assignment for an organization |
GET | /pool-rules/:orgId | Master | Get organization pool rule |
DELETE | /pool-rules/:orgId | Master | Remove organization pool rule |
Suppression List
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /suppression | Master | Add addresses to the suppression list (batch) |
DELETE | /suppression/:email | Master | Remove an address from the suppression list |
GET | /suppression/check/:email | Master | Check suppression status for an address |
POST | /suppression/bulk | Master | Add up to 10,000 addresses in one request |
GET | /suppression/export | Master | Paginated export with metadata (?reason=, ?cursor=) |
GET | /suppression/stats | Master | Counts by suppression reason |
Attachment Scanning
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /scan/attachment | Master | Scan file for malware (file type validation + ClamAV) |
GET | /scan/health | None | ClamAV connection status |
The scan endpoint performs two checks: file type validation (magic bytes, double extension, extension allowlist) and ClamAV malware scanning. The endpoint is fail-open — if ClamAV is unavailable, the file passes with a warning. See Email Security for details on the scanning pipeline.
Delivery Logs
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /delivery-logs | Master | Query events by date, orgId, status, domain (paginated) |
GET | /delivery-logs/stats | Master | Aggregated counts by status/domain/pool for a date range |
GET | /delivery-logs/:messageId | Master | All delivery events for a specific message |
Queue Inspection
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /queue/stats | Master | Queue depth by state (pending, active, completed, failed, delayed) |
GET | /queue/pending | Master | List pending jobs (?limit=, ?offset=, ?domain=) |
GET | /queue/jobs/:jobId | Master | Full job details with attempt history |
DELETE | /queue/jobs/:jobId | Master | Cancel a specific pending job |
POST | /queue/flush | Master | Cancel all pending jobs for an org (?orgId=) |
Dead Letter Queue
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /dlq | Master | List failed webhook events (?limit=, ?offset=) |
GET | /dlq/stats | Master | Total count and oldest entry age |
POST | /dlq/:dlqId/retry | Master | Retry a specific failed webhook event |
POST | /dlq/retry-all | Master | Retry all DLQ entries |
DELETE | /dlq/:dlqId | Master | Discard a specific failed event |
The /send endpoint accepts both the master MTA_API_KEY and per-organization API keys (owlat_...). All management endpoints — /credentials, /dkim, /org-limits, /pool-rules, /suppression, /inbound/routes, /mailboxes, /isp-profiles, /ip-reputation, /delivery-logs, /queue, /dlq, and /scan — require the master key (enforced inside each route group).
Send Payload
interface EmailJob {
messageId: string // Unique ID for correlation
to: string // Recipient email
from: string // Sender address
subject: string // Email subject
html: string // HTML body
text?: string // Plain text body
replyTo?: string // Reply-to address
headers?: Record<string, string>
ipPool: 'transactional' | 'campaign'
organizationId: string // For circuit breaker scoping
engagementScore?: number // 0-100 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.tsdefinesPhase<TIn, TOut>— a named step whose input context type threads into the next phase.compose(...)chains phases andrunPipeline(...)runs them. The type system enforces ordering: a phase that consumes the resolvedpool/ip(e.g.selectIp,acquireSlot) cannot be placed before the phase that produces it, or thecompose(...)call fails to type-check.- A phase returns one of three outcomes:
continue(carry the context forward),defer(re-queue the attempt after a delay), ordrop(end the attempt with statusscreenedorsuppressed, no re-queue). src/dispatch/phases/index.tscomposesmainPipelinein the real order (below).src/queue/handler.tsis the only GroupMQ-coupled file. The pipeline never throws; the handler translates adeferoutcome into GroupMQ'sDeferError(with ±15% jitter to avoid a thundering herd) and translates adropinto a logged terminal outcome.- After the pipeline returns
continue, the handler callssendToMx, then the pure reducer insrc/dispatch/outcome.tsclassifies the result into a typed effect list (src/dispatch/effects.ts) — tracking circuit-breaker, throttle, warming, metrics, suppression, and webhook side effects.
Phase order
mainPipeline runs ten phases in this exact order:
| # | Phase | Delegates to | Outcome on failure |
|---|---|---|---|
| 1 | content_screening | intelligence/contentScreening.ts | drop (screened) |
| 2 | suppression | intelligence/suppressionList.ts | drop (suppressed) |
| 3 | circuit_breaker | intelligence/circuitBreaker.ts | defer |
| 4 | org_limit | intelligence/orgLimits.ts | defer |
| 5 | smtp_intel | intelligence/smtpResponse.ts | defer |
| 6 | domain_backoff | scaling/degradation.ts | defer |
| 7 | resolve_pool | scaling/poolRules.ts | continue (enriches ctx with pool + dedicated IP) |
| 8 | select_ip | scaling/ipPool.ts | defer (enriches ctx with the bound IP) |
| 9 | acquire_slot | intelligence/domainThrottle.ts | defer |
| 10 | warming_cap | intelligence/warming.ts | defer |
The two pool/IP phases (7 and 8) enrich the context rather than gating; the throttle slot acquire (9) runs after IP selection because the throttle is scoped per IP and recipient domain.
Content pre-screening (phase 1)
File: intelligence/contentScreening.ts
Inspects content before any reputation-affecting work happens. Catches malformed or dangerous mail. Enabled by default (CONTENT_SCREENING_ENABLED=true); failures drop the job.
| Check | Notes |
|---|---|
DKIM domain alignment (dkimDomain matches the From header domain) | |
Email size budget (HTML exceeds CONTENT_MAX_SIZE_KB, default 500 KB) | |
Empty body (both html and text missing) | |
| Required headers (From, Subject present and non-empty) | |
| URL blocklist (known phishing/malware patterns in Redis) | |
| rspamd spam score (optional) | Only when RSPAMD_URL is set; rejects above RSPAMD_REJECT_THRESHOLD (default 15) |
Suppression list (phase 2)
File: intelligence/suppressionList.ts
Global suppression list. A suppressed recipient drops the job silently (no retry).
| Reason | Source |
|---|---|
hard_bounce | Auto-added when a hard bounce is detected |
complaint | Auto-added on ISP spam complaint |
manual | Added via the /suppression management API |
Suppressed addresses live in a Redis set, normalized to lowercase. Managed via POST /suppression, DELETE /suppression/:email, and GET /suppression/check/:email.
Circuit breaker (phase 3)
File: intelligence/circuitBreaker.ts
Per-organization, real-time protection tracking both bounces and complaints in a sliding ring buffer (the markers d/b/c for delivered/bounced/complained). Three states:
| State | Behavior |
|---|---|
| Closed (normal) | All sends proceed. Trips to Open when a threshold is exceeded. |
| Open (paused) | All org sends are deferred. Cools down for 30 minutes. |
| Half-Open (testing) | Up to 5 test sends proceed. A bounce/complaint re-opens with a 60-minute cooldown; all clean closes the circuit. |
Thresholds (circuitBreaker.ts):
| Signal | Fast (last 50 sends) | Slow (last 100 sends) |
|---|---|---|
| Bounce rate | > 15% | > 8% |
| Complaint rate | > 4% | > 0.2% |
Complaints are weighted far more heavily than bounces because ISPs blocklist on complaint rates an order of magnitude lower than bounce rates. When the circuit trips, an org.circuit_breaker webhook event (severity critical) is sent to Convex.
Organization rate limits (phase 4)
File: intelligence/orgLimits.ts
Per-organization daily and hourly send caps enforced via Redis counters.
| Limit | Default | Counter TTL |
|---|---|---|
| Daily | 50,000 | 48 hours |
| Hourly | 5,000 | 2 hours |
Per-org overrides are set via POST /org-limits. Exceeding a limit defers the job with a retryAfter delay until the next period boundary (midnight UTC for daily, next hour for hourly).
SMTP response intel (phase 5)
File: intelligence/smtpResponse.ts
Tracks SMTP response codes (4xx/5xx) per recipient domain. When recent patterns indicate a degraded or blocking destination, shouldDefer returns a positive delay and the phase defers the attempt.
Domain connection backoff (phase 6)
File: scaling/degradation.ts
Tracks per-domain connection failures and defers jobs targeting a domain currently in backoff, with a calculated retry delay.
Pool resolution (phase 7)
File: scaling/poolRules.ts
Enriches the context with the effective IP pool and any dedicated IP. Resolution priority:
- Organization-specific pool rule (if set via
POST /pool-rules) - The request's
ipPoolfield - Default pool
A dedicated IP, if configured, bypasses round-robin selection downstream.
IP selection (phase 8)
File: scaling/ipPool.ts
Round-robin IP selection within the resolved pool, excluding IPs 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:
| ISP | Default Rate | Ceiling | Floor |
|---|---|---|---|
| Gmail / Googlemail | 100/min | 300/min | 5/min |
| Outlook / Hotmail / Live | 80/min | 200/min | 5/min |
| Yahoo / AOL / Ymail | 50/min | 150/min | 3/min |
| iCloud / me / mac | 60/min | 150/min | 5/min |
| Other domains | 30/min | 100/min | 2/min |
The throttle backs off on SMTP 4xx (rate × backoffFactor), recovers on sustained success (rate × recoveryFactor), and blocks after repeated failures. These profiles are seeded into Redis on startup and can be tuned at runtime through the /isp-profiles endpoints.
Warming cap (phase 10)
File: intelligence/warming.ts
New IPs follow a 30-day warming schedule with daily send caps that grow gradually:
| Day | Daily Cap |
|---|---|
| 1 | 50 |
| 2 | 100 |
| 3 | 200 |
| 5 | 700 |
| 7 | 1,500 |
| 10 | 3,000 |
| 14 | 7,500 |
| 18 | 15,000 |
| 21 | 20,000 |
| 25 | 30,000 |
| 30+ | Unlimited (graduated) |
The schedule adapts — accelerating when bounce/deferral rates are low and decelerating when deliverability signals are poor. When 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:
| Blocklist | Severity |
|---|---|
Spamhaus (zen.spamhaus.org) | Critical |
Barracuda (b.barracudacentral.org) | Warning |
SpamCop (bl.spamcop.net) | Warning |
Listed IPs are removed from the active sending pool (the IP-selection phase then skips them). Convex is notified via ip.blocklisted and ip.delisted webhook events.
Queue System
Files: queue/setup.ts, queue/handler.ts, queue/groups.ts
The MTA uses GroupMQ, a Redis-backed job queue with group processing.
Group Key
Jobs are grouped by {ipPool}:{recipientDomain} — for example, campaign:gmail.com. This ensures that emails to the same ISP from the same IP pool are processed sequentially, respecting per-domain rate limits. Different domains process in parallel.
Worker Configuration
| Setting | Default | Description |
|---|---|---|
| Concurrency | 50 | Parallel group processing slots (WORKER_CONCURRENCY) |
| Max attempts | 5 | Retry limit per job |
| Job timeout | 2 min | Per-job processing timeout |
| Backoff | Exponential | 30s → 2m → 8m → 30m → 2h |
| Completed retention | 1,000 | Last N completed jobs kept in Redis |
| Failed retention | 5,000 | Last N failed jobs kept in Redis |
When a dispatch phase returns a defer outcome, src/queue/handler.ts translates it into GroupMQ's DeferError to reschedule with the requested delay (plus ±15% jitter).
SMTP Delivery
MX Resolution
File: smtp/mxResolver.ts
DNS MX lookups with Redis caching. Each recipient domain's MX records are resolved and tried in priority order. If all MX hosts fail, the domain is recorded in the degradation system.
DKIM Signing
Files: smtp/dkimStore.ts, smtp/dkim.ts
All outbound mail is signed with DKIM. Keys are stored in Redis with a 5-minute in-memory cache for performance. On startup, keys from the DKIM_KEYS environment variable are seeded into Redis (existing Redis keys are not overwritten).
Key management:
- API management —
POST /dkim,GET /dkim,DELETE /dkim/:domain - Key rotation —
POST /dkim/:domain/rotategenerates a new RSA 2048-bit key pair and returns the DNS TXT record value (v=DKIM1; k=rsa; p={base64}) for publishing - Env var seeding —
DKIM_KEYSstill works for initial bootstrapping; Redis is the source of truth at runtime
Connection Pool
File: smtp/connectionPool.ts
Reusable 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.
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
requireTLSandrejectUnauthorized, and MX hosts not listed in the policy are skipped (see MTA-STS & TLS-RPT) - DKIM signing keys loaded from the DKIM key store
MTA-STS & TLS-RPT
Files: smtp/mtaSts.ts, smtp/tlsRpt.ts
The sender protects outbound connections against STARTTLS-stripping attacks using MTA-STS (RFC 8461). Before delivering to a domain, getStsTlsOptions checks the _mta-sts.{domain} DNS TXT record and, on a version change, fetches https://mta-sts.{domain}/.well-known/mta-sts.txt. Parsed policies are cached in Redis (minimum 5-minute TTL, negative results cached for 1 hour). Policy modes:
| Mode | Effect |
|---|---|
enforce | requireTLS + rejectUnauthorized; only MX hosts matching the policy mx: patterns are tried |
testing | Opportunistic TLS, logged for monitoring (no enforcement) |
none | Opportunistic TLS |
MTA-STS lookups never block delivery — any failure falls back to opportunistic TLS.
Every connection attempt records a TLS result (success or a classified failure such as certificate-expired, starttls-not-supported, validation-failure). A daily leader-only cron (generateAndSendReports) aggregates failures per recipient domain and, for domains that publish a _smtp._tls TXT record with a rua= address, generates and sends a TLS-RPT (RFC 8460) JSON report. Both HTTPS report endpoints (direct POST of the gzipped report) and mailto: endpoints (the gzipped report is enqueued as an attachment on the normal DKIM/IP-pool send path) are delivered.
Publishing your own MTA-STS & TLS-RPT records
The above describes how Owlat consumes recipients' MTA-STS/TLS-RPT policies when sending outbound. To get the reciprocal protection on mail delivered to your domain, publish your own records:
- TLS-RPT (
_smtp._tls) — Owlat generates this for you. SetMTA_TLSRPT_RUAto a destination you monitor (mailto:tls-reports@yourdomain.comor an HTTPS endpoint) and registering a sending domain emits a_smtp._tlsTXT record of the formv=TLSRPTv1; rua=…(RFC 8460 §3) alongside the DKIM/DMARC records. WhenMTA_TLSRPT_RUAis unset the record is omitted. - MTA-STS (
_mta-sts+ policy file) — must be published by the operator because the policy lives on a web host, not in DNS, and Owlat does not run that host. Two parts are required (RFC 8461):- A DNS TXT record at
_mta-sts.yourdomain.comwith valuev=STSv1; id=<unique-version>(bumpidon every policy change so resolvers refetch). - A policy file served over HTTPS at
https://mta-sts.yourdomain.com/.well-known/mta-sts.txt, e.g.:version: STSv1 mode: enforce mx: mx.yourdomain.com max_age: 604800
mode: testingto collect TLS-RPT reports without risking delivery, then raise tomode: enforceonce your MX TLS is confirmed healthy. - A DNS TXT record at
- TLSA (DANE, optional) — if you publish DANE TLSA records (
_25._tcp.mx.yourdomain.com, RFC 6698) to pin your MX certificate, Owlat's DNS model accepts them: aTLSArecord type withusage/selector/matchingTypeparameters is recognised by the DNS-record validator and verifier.
Inbound Forwarding
Forwarded inbound mail is re-originated (remailed) under the mailbox domain — a new message signed with the mailbox domain's own DKIM key, with Reply-To set to the original sender so replies reach them (RFC 7960). ARC is not used.
Automatic DKIM Rotation
File: smtp/dkimRotation.ts
DKIM keys rotate on a safe overlap schedule (default 180-day rotation, 48-hour DNS-propagation overlap):
initiateRotationgenerates a new RSA key under a fresh selector, stores it as a pending key, and returns the DNS TXT record to publish — the active key keeps signing.- After the overlap window elapses and the new selector's DNS TXT record is confirmed published,
activatePendingKeypromotes the pending key to active and clears the pending state. If the record isn't live yet it keeps waiting, so signing never switches to a key DNS can't verify (an explicitforcebypasses the check for manual overrides).
A leader-only cron checks rotation status every 6 hours: it auto-activates any pending key whose overlap has passed and whose DNS record is published, and logs a recommendation for keys past their rotation date. Rotation is operator-initiated (via initiateRotation), so the new record can be published before activation.
SMTP Submission Server
File: smtp/submissionServer.ts
The MTA includes an optional SMTP submission server for traditional email client compatibility. Disabled by default.
| Setting | Value |
|---|---|
| Port | 587 (configurable via SUBMISSION_PORT) |
| Encryption | STARTTLS upgrade |
| Auth methods | PLAIN, LOGIN |
| Max message size | 25 MB |
| Default IP pool | transactional |
Authentication: Accepts both the master MTA_API_KEY and per-org API keys as the SMTP password (username is ignored). Per-org keys scope all submitted emails to that organization.
Processing: Incoming messages are parsed with 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.
| Variable | Default | Description |
|---|---|---|
SUBMISSION_ENABLED | false | Enable the submission server |
SUBMISSION_PORT | 587 | SMTP submission port |
SUBMISSION_TLS_CERT | required when SUBMISSION_ENABLED=true | PEM-encoded TLS certificate |
SUBMISSION_TLS_KEY | required when SUBMISSION_ENABLED=true | PEM-encoded TLS private key |
The submission server requires TLS. When SUBMISSION_ENABLED=true, both SUBMISSION_TLS_CERT and SUBMISSION_TLS_KEY must be set — STARTTLS is required before AUTH and the server refuses to boot without them (RFC 8314 §3.3). There is no plaintext-AUTH mode.
Bounce & Inbound Processing
Files: bounce/server.ts, bounce/pipeline.ts, bounce/phases/, bounce/outcome.ts, bounce/effects.ts, bounce/parser.ts, bounce/fblProcessor.ts, bounce/classifier.ts
Inbound SMTP Server
A dedicated SMTP server listens on the bounce port (default: 25) for incoming delivery status notifications, feedback-loop reports, routed inbound mail, and personal-mailbox (Postbox) delivery. Bounces arrive at the VERP return-path address; other mail is matched against inbound routes and the mailbox cache.
The bounce server mirrors the dispatch architecture: a typed phase pipeline (bounce/pipeline.ts + bounce/phases/) classifies each message into a BounceAttempt, and the pure reducer in bounce/outcome.ts emits a typed list of BounceEffects (Convex webhooks, circuit-breaker outcomes, attachment staging, quota bumps).
Processing Pipeline
- VERP decode — extract the original message ID from the recipient address
- DSN parse — parse RFC 3464 delivery status notifications for enhanced status codes and diagnostic information
- ARF/FBL parse — detect Abuse Reporting Format feedback-loop reports from ISPs
- Classify — categorize the event:
- Hard bounce (permanent) — invalid recipient, domain doesn't exist
- Soft bounce (temporary) — mailbox full, server temporarily unavailable
- Complaint — recipient marked the email as spam
- Route — non-bounce mail resolves to an inbound route (
endpoint/accept/…) or a personal mailbox - Effects — auto-suppress hard bounces/complaints, record the circuit-breaker outcome (a complaint feeds the complaint-rate thresholds), and post a webhook to Convex
Inbound Security
File: bounce/inboundSecurity.ts
The inbound SMTP server applies several abuse controls (configurable, see Configuration):
- Per-IP connection cap — at most
BOUNCE_MAX_CONNECTIONS_PER_IPconcurrent connections from one IP (Redis counter with a 5-minute window); excess connections are rejected. - Global client cap —
BOUNCE_MAX_CLIENTStotal concurrent connections. - Tarpit — when
BOUNCE_TARPIT_ENABLEDis on, non-local connections incur a deliberateBOUNCE_TARPIT_DELAY_MSdelay to slow down abusive senders. - SPF validation — when
INBOUND_SPF_ENABLEDis on,checkSpfevaluates the sender domain's SPF record (ip4/ip6/a/mx/include/all) against the connecting IP. - DKIM verification — when
INBOUND_DKIM_ENABLEDis on,verifyDkim(RFC 6376) checks the message signature over the raw bytes. Fail-open: a verify crash yieldstemperrorand is recorded asdkimResult, never a rejection. - DMARC evaluation — when
INBOUND_DMARC_ENABLEDis on,evaluateDmarc(RFC 7489) aligns the SPF and DKIM results with theFrom:domain and applies its published policy. The verdict (and policy) is recorded asdmarcResultso Convex can route spoofed mail to Spam. Fail-open: a lookup crash yieldstemperror, never a rejection.
Personal Mailboxes (Postbox)
When a recipient matches the mailbox cache, the message is classified as a personal-mailbox delivery rather than a bounce or route. The reducer emits an inbound.mailbox.received webhook carrying the parsed message and base64 raw RFC822 bytes (for storage), and bumps the mailbox's stored quota usage. See Postbox Architecture.
Per-Organization Credentials
File: auth/credentials.ts
API keys formatted as owlat_{32-char-hex} provide per-organization isolation. Each credential is stored in Redis and indexed by organization ID.
- The master
MTA_API_KEYcontinues to work for all endpoints (used by the Convex backend) - Per-org keys authenticate both HTTP API
/sendrequests and SMTP submission connections - Management endpoints (
POST/GET/DELETE /credentials) require the master key
Credentials track createdAt and lastUsedAt timestamps. The GET /credentials endpoint returns truncated key prefixes (first 10 chars) for safe display.
Inbound Email Routing
Files: inbound/router.ts, inbound/forwarder.ts
Route incoming emails by domain and local-part address. Routes are stored in Redis and managed via the /inbound/routes API.
Route Modes
| Mode | Behavior |
|---|---|
endpoint | Forward the parsed email to an HTTP webhook URL |
accept | Silently accept the email |
hold | Accept but hold for manual review |
bounce | Return a bounce response to the sender |
reject | Reject during SMTP negotiation |
Route Matching
Priority: exact address match, then wildcard (*). For example, a route for support@example.com takes precedence over *@example.com. Routes are keyed by domain:address in Redis.
Webhook Forwarding
For endpoint mode, the parsed email is posted as JSON to the configured URL:
- Payload includes:
from,to,subject,textBody,htmlBody,headers,date,messageId,inReplyTo,references, andattachments(base64-encoded) - 10-second timeout per request
- Up to 2 retries with exponential backoff (1s, 2s)
An inbound.received webhook event is sent to Convex for all routed inbound emails.
IP Pool Management
Files: scaling/ipPool.ts, scaling/degradation.ts
Two Pools
| Pool | Purpose |
|---|---|
transactional | Time-sensitive emails (password resets, order confirmations) |
campaign | Bulk marketing emails |
IPs are configured via IP_POOLS_TRANSACTIONAL and IP_POOLS_CAMPAIGN environment variables (comma-separated).
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-Signature header (HMAC-SHA256 of timestamp.body, constant-time compared) plus an X-MTA-Timestamp header, and timestamp validation (5-minute tolerance, 300s).
| Event | Severity | Description |
|---|---|---|
sent | info | Email delivered successfully (SMTP 250 response) |
bounced | warning | Hard or soft bounce (includes bounceType: 'hard' | 'soft') |
complained | warning | Spam complaint from ISP feedback loop |
inbound.received | info | Inbound email received and routed |
inbound.mailbox.received | info | Inbound mail delivered to a personal mailbox (Postbox) |
org.circuit_breaker | critical | Organization bounce rate too high, sending paused |
ip.blocklisted | critical | Sending IP added to a DNS blocklist |
ip.delisted | info | Sending IP removed from a DNS blocklist |
ip.warming_complete | info | IP graduated from warming schedule |
all_ips_blocked | critical | No sending IPs available (all blocklisted) |
Convex Integration
The Convex backend receives MTA webhooks 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
| 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 |
SMTP_POOL_MAX_PER_HOST | 3 | Concurrent SMTP transports per MX host (per instance) |
SMTP_POOL_IDLE_TIMEOUT_MS | 30000 | Close idle pooled connections after this period |
SMTP_POOL_MAX_AGE_MS | 300000 | Force-close pooled connections regardless of activity |
SMTP_POOL_GLOBAL_MAX_PER_HOST | 10 | Global cap on connections per MX host across all instances |
BOUNCE_TLS_CERT | — | PEM-encoded TLS certificate for bounce SMTP server (enables STARTTLS) |
BOUNCE_TLS_KEY | — | PEM-encoded TLS private key for bounce SMTP server |
BOUNCE_MAX_CONNECTIONS_PER_IP | 10 | Max concurrent inbound connections per IP |
BOUNCE_MAX_CLIENTS | 200 | Max total concurrent inbound connections |
BOUNCE_TARPIT_ENABLED | true | Delay non-local inbound connections |
BOUNCE_TARPIT_DELAY_MS | 5000 | Tarpit delay per suspicious connection |
INBOUND_SPF_ENABLED | true | Validate SPF on inbound mail |
INBOUND_DKIM_ENABLED | true | Verify DKIM signatures (RFC 6376) on inbound mail |
INBOUND_DMARC_ENABLED | true | Evaluate DMARC (RFC 7489) on inbound mail |
RSPAMD_URL | — | rspamd HTTP URL for outbound content spam scoring |
RSPAMD_REJECT_THRESHOLD | 15 | Content-screening rejects when the rspamd score exceeds this |
GOOGLE_POSTMASTER_CREDENTIALS | — | Google service-account JSON key for the Postmaster Tools fetcher |
CLAMAV_HOST | clamav | ClamAV daemon hostname for attachment scanning (read directly by the scan route, not via config.ts) |
CLAMAV_PORT | 3310 | ClamAV daemon port (read directly by the scan route, not via config.ts) |
ClamAV Sidecar (Docker)
The MTA can optionally run ClamAV as a Docker sidecar for attachment malware scanning. ClamAV provides the clamd daemon on port 3310, with freshclam auto-updating virus definitions inside the official image.
# docker-compose.yml (excerpt)
clamav:
image: clamav/clamav:1.3
ports:
- '3310:3310'
volumes:
- clamav-data:/var/lib/clamav
healthcheck:
test: ["CMD", "clamdcheck"]
interval: 60s
timeout: 10s
retries: 3
The @owlat/email-scanner package provides a TCP client for the clamd INSTREAM protocol. The MTA's /scan/attachment endpoint combines file type validation with ClamAV scanning.
If ClamAV is unavailable (container not running, network error), the scan endpoint allows attachments through with a warning logged. This prevents ClamAV outages from blocking all email delivery.
Startup Sequence
Per src/index.ts:
- Load configuration from environment variables
- Set organization rate-limit defaults
- Configure the SMTP connection pool and start the eviction timer
- Connect to Redis and verify connectivity
- Enable distributed connection-pool coordination
- Seed DKIM keys from
DKIM_KEYSinto Redis (existing keys not overwritten) - Seed ISP profiles into Redis (
HSETNX, preserving runtime overrides) - Initialize IP pools in Redis (mark all IPs active)
- Initialize warming state for each IP
- Create the GroupMQ queue and worker
- Start the HTTP server (Hono on
PORT) - Start the bounce/inbound SMTP server (on
BOUNCE_PORT) - Start the SMTP submission server (on
SUBMISSION_PORT, ifSUBMISSION_ENABLED=true) - Start leader election
- Start periodic crons:
- DNSBL checker — every 15 minutes (runs on every instance)
- Warming evaluation — hourly (leader only)
- Google Postmaster fetch — hourly (leader only)
- TLS-RPT report generation — every 24 hours (leader only)
- DKIM rotation check — every 6 hours (leader only)
- Start the GroupMQ worker (begins processing jobs)
In multi-instance deployments, a Redis lock (src/lib/leaderElection.ts, 30-second TTL, renewed every 15s) ensures the warming, Postmaster, TLS-RPT, and DKIM-rotation crons run on exactly one instance. The DNSBL checker runs on every instance. Leadership fails over automatically when the leader stops renewing.
The bounce SMTP server listens on port 25 by default, which typically requires root privileges. In containerized deployments, map the host port to the container port. The MTA logs a warning if the bounce port requires elevated permissions.
Graceful Shutdown
On SIGTERM/SIGINT the shutdown is idempotent (a duplicate signal is ignored) and runs under a 40-second hard-exit watchdog that matches the compose stop_grace_period, so Docker never has to send SIGKILL mid-drain:
- Stop all periodic crons (DNSBL, warming, Postmaster, TLS-RPT, DKIM rotation)
- Close the HTTP server
- Close the bounce SMTP server
- Close the SMTP submission server (if running)
- Drain the GroupMQ worker (wait for in-flight jobs)
- Drain and close the SMTP connection pool
- Release leadership (
stopLeaderElection) - Close the Redis connection
Key Files
| File | Purpose |
|---|---|
src/index.ts | Main entry point: starts all services, leader election, and crons |
src/config.ts | Configuration loading, ISP profiles, warming schedule |
src/config/ispProfiles.ts | Runtime ISP-profile store (seed/get/set/list) |
src/server.ts | Hono HTTP app setup and route mounts |
src/redis.ts | Redis connection |
src/types.ts | Shared type definitions, webhook event union |
src/lib/leaderElection.ts | Redis lock for single-instance crons |
src/auth/credentials.ts | Per-organization API key management |
src/auth/postboxAuth.ts | Postbox (personal mailbox) authentication |
src/routes/send.ts | POST /send handler (enqueue + engagement-priority mapping) |
src/routes/health.ts | Health check and metrics endpoints |
src/routes/credentials.ts | Credential management API routes |
src/routes/dkim.ts | DKIM management API routes |
src/routes/inboundRoutes.ts | Inbound routing API routes |
src/routes/mailboxes.ts | Personal-mailbox cache API routes |
src/routes/orgLimits.ts | Organization rate-limit API routes |
src/routes/poolRules.ts | Pool-rule API routes |
src/routes/ispProfiles.ts | Runtime ISP-profile admin routes |
src/routes/ipReputation.ts | Per-IP reputation dashboard routes |
src/routes/suppression.ts | Suppression-list API routes |
src/routes/deliveryLogs.ts | Delivery-log query routes |
src/routes/queue.ts | Queue-inspection routes |
src/routes/dlq.ts | Webhook dead-letter-queue routes |
src/routes/scan.ts | Attachment scanning endpoint (file validation + ClamAV) |
src/queue/setup.ts | GroupMQ queue and worker creation |
src/queue/handler.ts | GroupMQ adapter: runs the dispatch pipeline + outcome reducer |
src/queue/groups.ts | Group key generation, ISP classification |
src/dispatch/pipeline.ts | Typed Phase/compose/runPipeline primitives |
src/dispatch/phases/index.ts | mainPipeline composition (the ten phases) + per-phase files |
src/dispatch/outcome.ts | Pure reducer: SMTP result → typed effect list |
src/dispatch/effects.ts | Effect runner (delegates to intelligence/scaling/monitoring) |
src/dispatch/types.ts | Phase context types (BasePhaseCtx, CtxWithPool, CtxWithIp) |
src/intelligence/circuitBreaker.ts | Per-org bounce + complaint-rate protection |
src/intelligence/domainThrottle.ts | Adaptive per-IP/per-domain rate limiting |
src/intelligence/contentScreening.ts | Pre-send content checks + rspamd scoring |
src/intelligence/smtpResponse.ts | SMTP response health tracking |
src/intelligence/dnsbl.ts | DNS blocklist checking |
src/intelligence/warming.ts | IP warming schedule and tracking |
src/intelligence/engagementPriority.ts | Enqueue-time engagement-priority mapping |
src/intelligence/orgLimits.ts | Per-org daily/hourly send rate limits |
src/intelligence/suppressionList.ts | Global email suppression list |
src/smtp/sender.ts | Direct MX delivery (DKIM, MTA-STS, TLS-RPT recording) |
src/smtp/mxResolver.ts | MX DNS lookups with caching |
src/smtp/dkim.ts | DKIM signing configuration |
src/smtp/dkimStore.ts | Redis-backed DKIM key storage |
src/smtp/dkimRotation.ts | Automatic DKIM key rotation workflow |
src/smtp/mtaSts.ts | MTA-STS (RFC 8461) policy fetch + enforcement |
src/smtp/tlsRpt.ts | TLS-RPT (RFC 8460) recording + daily report generation |
src/smtp/connectionPool.ts | SMTP transport pool with cross-instance coordination |
src/smtp/submissionServer.ts | SMTP submission server (port 587) |
src/inbound/router.ts | Inbound email route matching |
src/inbound/mailboxResolver.ts | Personal-mailbox address cache (O(1) Redis lookup) |
src/inbound/forwarder.ts | HTTP webhook forwarding for inbound emails |
src/bounce/server.ts | Inbound/bounce SMTP server |
src/bounce/pipeline.ts | Bounce classification phase pipeline |
src/bounce/outcome.ts | Bounce pure reducer (effect emission) |
src/bounce/effects.ts | Bounce effect runner |
src/bounce/inboundSecurity.ts | SPF validation, tarpit, per-IP connection caps |
src/bounce/parser.ts | DSN message parsing |
src/bounce/fblProcessor.ts | ARF/FBL feedback-loop parsing |
src/bounce/classifier.ts | Bounce type classification |
src/bounce/verp.ts | VERP return-path encoding/decoding |
src/scaling/ipPool.ts | IP pool selection and management |
src/scaling/poolRules.ts | Per-org IP pool assignment rules |
src/scaling/degradation.ts | System health and domain backoff |
src/monitoring/logger.ts | Pino structured logging |
src/monitoring/collector.ts | Prometheus metrics collection |
src/monitoring/deliveryLogger.ts | Per-message delivery event Redis Streams |
src/monitoring/postmaster.ts | Google Postmaster Tools data fetcher |
src/webhooks/convexNotifier.ts | Webhook delivery to Convex |