ADR-005: Custom MTA
Why Owlat built a custom Mail Transfer Agent instead of relying solely on third-party email providers.
- Status: Accepted
- Date: 2025-03-20
Context
Owlat previously depended entirely on AWS SES and Resend for email delivery. While these providers offer reliable APIs and managed infrastructure, they introduce constraints at scale:
- Cost — per-email pricing scales linearly. High-volume senders pay significantly more than the cost of direct SMTP delivery from dedicated IPs.
- Deliverability control — third-party providers manage shared and dedicated IP reputation on behalf of many customers. Owlat has no ability to implement ISP-specific throttling, IP warming schedules, or engagement-based sending priority.
- Bounce latency — bounce and complaint data arrives via provider webhooks with variable delay, making it harder to react quickly to reputation issues.
- Rate limits — provider-imposed sending quotas (especially in SES sandbox) constrain campaign throughput. Owlat cannot independently manage backpressure per ISP.
The main options:
- Keep SES/Resend only — simplest operationally, but cost and control limitations remain.
- Build a custom MTA — direct SMTP delivery with full control over IPs, throttling, warming, and bounce processing. Higher infrastructure complexity but significantly lower per-email cost and better deliverability tuning.
- Use an open-source MTA (Postfix, Haraka) — avoids building from scratch but requires adapting general-purpose software to Owlat's specific needs (GroupMQ integration, per-org circuit breakers, engagement priority).
Decision
Build a custom MTA as a standalone service (apps/mta/) that sends email via direct SMTP delivery to recipient mail servers. The MTA is one of three selectable delivery providers (mta|resend|ses), chosen via EMAIL_PROVIDER or per-org send-provider routing (apps/api/convex/lib/sendProviders/routing.ts, consumed via apps/api/convex/lib/sendProviders/route.ts). There is no implicit default — when no provider is configured the route resolves to null and the instance is fail-closed (sends nothing).
The service has since grown beyond outbound sending to also handle inbound routing/forwarding (apps/mta/src/inbound/) and the deliverability surface around SMTP — DKIM key management and rotation, MTA-STS, and TLS-RPT (apps/mta/src/smtp/). The send decision below remains the core of this ADR.
Key design choices:
- Hono HTTP API — lightweight, standard HTTP server for receiving send requests from the Convex backend. Endpoints include
/send,/health, and/metrics, plus admin/management routes (/credentials,/org-limits,/suppression,/dkim,/pool-rules,/isp-profiles,/ip-reputation,/queue,/dlq,/delivery-logs,/scan) and inbound routing (/inbound/routes,/mailboxes). - GroupMQ + Redis — job queue with group-based processing. Jobs are grouped by
{ipPool}:{recipientDomain}so emails to the same ISP from the same IP pool are processed sequentially, respecting per-domain rate limits. - Dispatch pipeline — a composed pipeline (
apps/mta/src/dispatch/phases/) runs per-attempt checks before every delivery: content screening, suppression, per-org circuit breaker, org rate limits, SMTP-response-based throttling (smtpIntel), domain backoff, IP pool resolution and selection, slot acquisition, and warming caps. Two related concerns run outside the per-send pipeline: engagement priority is applied at enqueue time as GroupMQ ordering (apps/mta/src/routes/send.ts,apps/mta/src/queue/groups.ts), and DNSBL monitoring is a 15-minute background check that removes listed IPs from the active pool (apps/mta/src/intelligence/dnsbl.ts). - VERP return-path — Variable Envelope Return Path encoding correlates bounce messages back to original sends without maintaining a lookup table.
- Webhook feedback loop — delivery events (sent, bounced, complained) are posted back to the Convex backend via authenticated webhooks, reusing the existing bounce/complaint processing pipeline.
Consequences
Enables:
- Per-ISP adaptive rate limiting (Gmail 100/min, Outlook 80/min, Yahoo 50/min) with automatic backoff on 4xx responses
- Automated IP warming over 30 days with adaptive acceleration/deceleration based on deliverability signals
- Engagement-based sending priority — high-engagement contacts are delivered first
- Per-organization circuit breaker — automatically pauses sending when bounce rates exceed thresholds
- DNS blocklist monitoring with automatic IP removal from active pool
- Direct cost savings at scale (no per-email API fees beyond infrastructure)
- Full bounce/complaint processing pipeline with DSN parsing and ARF/FBL support
Trade-offs:
- Requires dedicated IPs with proper rDNS/PTR records and DKIM key management
- Infrastructure complexity: Redis for state, SMTP port 25 access, separate containerized service
- Operational burden: monitoring DNSBL listings, managing IP warming, investigating deliverability issues
- All three providers (mta, resend, ses) are explicit opt-in choices via
EMAIL_PROVIDER/ per-org send-provider routing; the MTA is not an implicit default delivery path — an unconfigured instance is fail-closed and sends nothing
Comparison with Alternatives
| Capability | Owlat MTA | Postal | SES/Resend |
|---|---|---|---|
| Adaptive per-ISP throttling | Yes (10+ ISP profiles) | No | Provider-managed |
| IP warming | Yes (30-day adaptive) | No | Provider-managed |
| Per-org circuit breaker | Yes (3-state) | No | No |
| Engagement-based priority | Yes | No | No |
| DNSBL auto-remediation | Yes (15-min checks) | No | Provider-managed |
| Message storage/search | No | Yes (per-server MySQL) | No |
| Web admin UI | No | Yes (Rails dashboard) | Provider console |
| Spam content scoring | Yes (per-send screening: required-field/DKIM-alignment/size/URL-blocklist checks plus optional Rspamd scoring; attachment malware scanning via ClamAV/@owlat/email-scanner) | Yes (pluggable inspectors) | Provider-managed |
| Click/open tracking | No (platform layer) | Yes (built-in) | Provider-managed |
| Cost at scale | Low (infrastructure only) | Low (self-hosted) | High (per-email) |