Programmatic Linux email servers: where your code runs
For backend developers choosing a programmatic Linux email server, Haraka exposes SMTP hooks, Postfix and Exim expose policy and delivery seams, Postal exposes application APIs, WildDuck/ZoneMTA split mailbox and outbound control, and Stalwart exposes JMAP. Compare those boundaries with MailKite Server, then run a local SMTP acceptance demo.
npm ci
npm run demo
npm test
Run inside the companion repo’s programmatic directory. These commands map to its README and package.json; demo.mjs sends only to a loopback SMTP receiver. The observed output is “SMTP accepted: ticket+42@example.com” followed by “SQLite stored: Invoice question”. No DNS or external mail delivery is involved.
Choose the programming boundary before the server
“Programmatic” can mean changing SMTP decisions, choosing delivery routes, consuming message events, or manipulating a mailbox. Those requirements lead to different servers. A ticketing app doesn’t need a mailbox synchronization protocol just to receive replies; a webmail client can’t replace mailbox state with one inbound webhook.
The comparison below narrows the Linux open-source email server pillar to integration surfaces. Documentation was fetched on 2026-10-05. These are architectural judgments, not throughput benchmarks or measured installation times.
A qualitative setup rubric
Moderate means a documented single-server path, plus configuring credentials, domains and the chosen integration. High means assembling multiple services or coordinating MTA rules with an external application. Very high means both a multi-service stack and custom policy/storage behavior. Ratings assume a Linux developer who understands SMTP but isn’t already operating that project’s stack.
| Project | Main programming surface | Concrete integration | Setup difficulty |
|---|---|---|---|
| Haraka | JavaScript SMTP and queue hooks | Validate ticket recipients against your database | High: small daemon, unfinished application pipeline |
| Postfix | Policy service, Milter, delivery transports | Quota checks plus OpenDKIM signing | High: daemon plus socket/service configuration |
| Exim | ACLs, expansions, routers, pipe transports | Route accepted mail into a ticket importer | High: expressive configuration with several execution stages |
| Postal | Send API, inbound HTTP, delivery webhooks | Transactional sending and reply ingestion | High: Docker, MariaDB and application wiring |
| WildDuck + ZoneMTA | Mailbox REST API plus outbound plugins/zones | Provision users and separate sending traffic | Very high: full suite plus MongoDB/Redis |
| Stalwart | JMAP, Sieve, HTTP MTA Hooks | Synchronize a webmail client; apply SMTP policy separately | Moderate: integrated server, protocol learning remains |
| MailKite Server | Backend contract, routes, limited developer API | Store inbound mail and dispatch application jobs | High: edges, persistence and backend policy |
Existing installations change the ranking. Adding a Postfix policy daemon to a working host is a smaller job than replacing its entire mail stack. Every internet-facing deployment still needs DNS, TLS, backups, monitoring and a tested outbound path.
Haraka puts JavaScript inside the SMTP conversation
Haraka is the direct choice when acceptance itself needs application logic. Its plugin documentation exposes connect, mail, rcpt, data_post and queue hooks. A recipient plugin must accept the address; a queue plugin must take responsibility for the message. Installing the daemon alone doesn’t establish either policy.
A concrete integration is ticket+42@your-domain: look up ticket 42 at rcpt, reject unknown recipients, then persist MIME at queue. Haraka’s DENY produces a permanent SMTP error; DENYSOFT asks the sender to retry. Call next() exactly once, including when a dependency fails. Hook ordering matters because next(OK) stops subsequent handlers on that hook.
Haraka includes outbound delivery machinery, so “you must implement all retries yourself” is too broad. The missing piece depends on your queue choice: handing mail to your HTTP application doesn’t automatically supply durable application-event retries. Don’t put a slow classifier or ticket API call in a synchronous hook unless its timeout and failure policy are deliberate. The Node.js Linux mail-server guide covers that language-specific path.
Postfix and Exim expose policy and delivery as separate jobs
Keep an established MTA when a narrow extension solves the problem. Postfix offers policy delegation: an external process receives newline-separated attributes such as client address, envelope sender and recipient. It returns an action. That interface carries session metadata, not a parsed message body.
For body inspection or modification, Postfix’s Milter interface talks to a separate filter before queueing. OpenDKIM is a concrete existing integration. SMTP-originated and locally submitted mail use different Milter lists; testing only the SMTP path can miss local submissions. Socket permissions, chroot-relative paths and filter-error actions are part of the integration, not incidental install details.
Exim’s ACLs decide acceptance at SMTP stages; routers select destinations and transports perform delivery. Its pipe transport passes an accepted message on stdin to a program. A helpdesk importer can use that boundary, returning a configured temporary-error exit status when storage is unavailable.
Those aren’t equivalent “hooks.” A pipe failure occurs during delivery, after the MTA owns the message; an RCPT denial happens before acceptance. Use a fixed executable with explicit privileges, not a shell command assembled from incoming headers. For a Python policy service or importer, continue with Linux email servers and Python.
Postal exposes an application platform, not a plugin bus
Postal fits an application that wants self-hosted sending and inbound HTTP delivery. Its API documentation supports structured messages or raw RFC-formatted messages. It also explicitly says the current API doesn’t manage every Postal function. Don’t infer an infrastructure provisioning API from the presence of a send endpoint. An invoice application can submit transactional mail, route replies to a processed-JSON endpoint, and update delivery state from event webhooks. Keep inbound delivery and delivery-status events as distinct handler contracts.
Postal’s inbound HTTP documentation specifies processed/raw formats and a surprising failure rule: HTTP 5xx responses fail immediately rather than entering the documented retry sequence. A generic webhook handler that returns 500 for every transient error needs reconsideration here.
Postal gives you an opinionated platform instead of requiring custom SMTP policy code. The trade is its operational footprint and API scope. The current prerequisites require Docker and MariaDB; they don’t list RabbitMQ. Older deployment recipes should be checked against current documentation before reuse.
WildDuck and ZoneMTA separate mailbox control from sending control
WildDuck is useful when you’re building a mailbox product; ZoneMTA controls outbound transport. The Zone Mail Suite architecture combines WildDuck’s IMAP/POP3 and HTTP API with Haraka inbound, ZoneMTA outbound, Rspamd, MongoDB and Redis. “Written in Node” doesn’t mean “one Node process.” For tenant onboarding, use WildDuck’s REST surface to manage accounts, addresses and mailboxes. Message submission can queue through ZoneMTA. For outbound isolation, ZoneMTA’s sending zones select source-IP pools and concurrency settings. A receipts application and a newsletter application can have separate delivery policies without separate mailbox implementations.
Customization crosses service boundaries. ZoneMTA plugins can alter delivery behavior, but a zone name doesn’t create reputation or guarantee inbox placement. Nor is ZoneMTA an inbound MX replacement. Its documented at-least-once delivery model permits duplicates if acknowledgement is lost at the wrong moment. MongoDB recovery and Redis operation remain your responsibilities. For a few application addresses, this suite can be more machinery than the job warrants.
Stalwart JMAP programs mailbox state; MTA Hooks program acceptance
Choose Stalwart’s JMAP surface for an application that needs synchronized mailbox objects. Its JMAP overview documents discovery at /.well-known/jmap and the /jmap endpoint. A mail client discovers the session, uses its account IDs and capabilities, then queries or updates messages and mailboxes. It must handle protocol state, permissions and method-level errors. JMAP is not an inbound webhook with a different name. A webmail frontend or mailbox indexing service benefits from structured retrieval and synchronization. Sieve handles server-side filtering. If the requirement is instead “ask my service whether this recipient may be accepted,” Stalwart’s HTTP MTA Hooks expose SMTP stages with JSON decisions and configurable temporary failure on endpoint errors.
An integrated server reduces assembly work, but learning JMAP is still work. Check the deployed version’s configuration and edition boundaries before adopting a feature described in current docs. A one-shot ticket webhook shouldn’t require writing a mailbox synchronization client.
MailKite Server separates protocol edges from backend policy
A backend-owned mail pipeline lets your application choose storage and post-acceptance work without rewriting SMTP or IMAP. The MailKite Server contract puts stateless protocol heads in front of HTTP endpoints. Its ingest contract returns success after durable acceptance; webhook or agent work belongs behind that boundary.
MailKite Server, which we build, is an option for that architecture. Its reference backend supplies SQLite storage and routes, but it’s pre-1.0 and isn’t a substitute for Stalwart’s groupware stack. Haraka plus your own durable queue is the more direct route for bespoke SMTP logic; Postal is worth evaluating for a self-hosted transactional platform.
The self-hosted developer API documents SDK-compatible /v1/send, message reads and batch sends. It does not establish complete hosted API parity: templates, scheduling and attachments are refused, and tracking flags are no-ops. Outbound delivery depends on your configured smarthost. This SDK function uses only the documented basic send fields:
import { MailKite } from 'mailkite';
export async function sendTicket(baseUrl, apiKey) {
const mk = new MailKite({ apiKey, baseUrl });
return mk.send({
from: 'sender@example.com',
to: 'ticket+42@example.com',
subject: 'Invoice question',
text: 'Please check invoice 42.',
});
}
Source: sdk-send.mjs. The offline test invokes it against a loopback contract fixture, then checks real SMTP delivery into SQLite. That verifies SDK serialization, not a MailKite Server deployment. For a real server, supply its local key and URL, replace example addresses with owned domains, and configure transport. Hosted MailKite uses its hosted credentials and API URL. The provider-building guide goes further into that distinction.
The acceptance boundary decides who retries
Persist before acknowledging SMTP, then process application jobs independently. The local demo rejects unknown envelope recipients with 550 and commits MIME plus parsed fields before returning DATA success. Its SQLite rows survive reopening. It deliberately has no production worker, webhook delivery queue or internet relay.
Predict the acceptance result: the visible To header names an unknown address, but the SMTP envelope names ticket+42@example.com. Should the local receiver reject it? What should survive a database reopen?
Reveal the envelope-routing exercise and verify it locally
The receiver accepts the known envelope recipient and stores the message, even when the visible To header names header-only@example.com. The test closes and reopens SQLite, then checks that one row and the raw attachment remain. In its rejected-recipient case, an unknown envelope recipient gets SMTP 550 at RCPT TO and creates no row.
After installing dependencies in programmatic/, run node --test --test-name-pattern='SMTP accepts only known' test/boundaries.test.mjs. Inspect the assertions in the boundary test: they check both the SMTP result and persisted data, rather than relying on a success log.
This exercises recipient authority and durable acceptance on loopback. It does not test a downstream worker retry: the demo has no application-job queue.
Signed application webhooks add another boundary: verify exact request bytes before JSON parsing. webhook.mjs uses the SDK locally:
import { MailKite } from 'mailkite';
export function decodeWebhook(signature, rawBody, secret) {
if (!MailKite.verifyWebhook(signature, rawBody, secret)) {
throw new Error('Invalid or expired webhook signature');
}
return JSON.parse(rawBody);
}
Tests reject altered bodies, wrong secrets and stale signatures. These fixtures use the SDK’s hosted-delivery millisecond timestamps. Server edge ingest is a different contract: raw RFC822 with timestamps documented in seconds. Don’t apply this consumer helper to ingest. Before enabling automatic replies, review Linux AI email automation for the application-side decisions.
For programmatic Linux email servers, the decisive question is what failure your code is allowed to cause. Pick an SMTP seam for rejection, a transport seam for delivery, an event seam for workflow execution, or a mailbox API for synchronization. Run and modify the local demo to test that distinction before choosing infrastructure.
Source freshness: official documentation and repositories retrieved 2026-10-05, linked at each claim. Recheck API scope, prerequisites and hook error behavior by 2026-11-04, and whenever upgrading a server.