Build a Linux email provider with MailKite integrations
Building a Linux email provider means owning tenant identity, DNS, mailbox storage, and delivery failures. This guide helps backend teams choose an open-source stack, then run an adapter that reads their own IMAP mailbox and sends a notification through MailKite without moving inbound mail to the cloud.
#!/bin/sh
# Docs: README.md; run from the provider directory.
set -eu
npm ci
npm test
npm run demo
Run provider/quickstart.sh in the companion demo. It exercises the actual MailKite SDK against a loopback HTTP fixture and a simulated IMAP client, so you can inspect the boundary before configuring DNS or sending real mail. The live entry point connects to your existing Dovecot server over IMAPS.
Jump to stack selection, tenant ownership, DNS, or the adapter.
Choose the stack by its storage and queue boundaries
An email provider needs a mail data plane and a tenant control plane. Installing an SMTP daemon supplies only part of the former. Decide whether you’re selling human mailboxes or application email before choosing software; the mailbox model determines provisioning, credentials, backups, and what a migration must preserve.
The Linux email-server pillar catalogs individual projects. This comparison instead asks which components own stored messages and delivery attempts. These are architectural choices, not a popularity ranking.
| Stack | Mailbox and delivery ownership | Integration seam | When it fits |
|---|---|---|---|
| Postfix + Dovecot + Rspamd | Postfix queues transport; Dovecot owns mailbox access and local delivery; Rspamd supplies filtering | LMTP delivery, IMAP reads, Milter filtering | Existing Linux operations and human mailboxes |
| Haraka + WildDuck + ZoneMTA | Haraka handles inbound SMTP; WildDuck stores mail in MongoDB; ZoneMTA queues outbound | SMTP plugins and WildDuck REST management | API-managed mailbox products with database operations expertise |
| Stalwart | Integrated SMTP, IMAP/JMAP, storage, filtering and collaboration | Native management interfaces and mail protocols | A consolidated mailbox service; inspect edition limits |
| Postal | Application mail delivery platform, organizations, credentials, outbound and inbound routing | Its send API and webhooks | Hosted transactional-email product, not an IMAP mailbox replacement |
| MailKite Server | Stateless protocol edges over its defined backend; local SQLite implementation owns messages and policy | Trusted edge contract and local API | Application-owned addresses; inspect each required endpoint |
For the classic stack, Postfix’s Architecture Overview describes incoming, active, and deferred queues. Dovecot’s LMTP documentation separates local delivery from mailbox access. Rspamd documents Postfix Milter integration. Keep those responsibilities visible: a filtering timeout is a different failure from a mailbox disk filling up.
WildDuck’s documentation explicitly describes the Haraka/WildDuck/ZoneMTA assembly. It buys an API-managed store, but adds MongoDB and Redis operational responsibilities. ZoneMTA documents sending zones and at-least-once delivery, including the possibility of duplicates after an acknowledgement is lost. A sending zone partitions traffic; it doesn’t authorize your tenants by itself.
Stalwart consolidates the protocol stack, including JMAP and collaboration services. That reduces cross-daemon configuration, but ties mailbox operations to one product’s upgrade and storage model. Postal is a better-shaped starting point when your product is a delivery API. Don’t select it expecting Dovecot-style mailbox access.
The tenant control plane owns authority
Tenant identity must come from authenticated provisioning, never from a message’s To header. A customer can put any address in that header. Even an authentic message can target several recipients, omit a Bcc recipient, or arrive through an alias.
Keep a domain-to-tenant ownership record with verification evidence and lifecycle state. Derive SMTP acceptance, mailbox provisioning, credential scopes, and allowed outbound identities from that record. Track mailbox accounts separately from aliases. Disable new submissions when a tenant is suspended without accidentally deleting already accepted mail or another tenant’s shared-domain data.
The useful separation is authority versus storage location. A mailbox being readable through IMAP proves that one credential can read that mailbox. It doesn’t prove that the same user may send from every domain represented in its messages. An adapter must carry trusted account context across the read/send boundary instead of rediscovering it from MIME.
The demo runs one operator-configured tenant per process. Its IMAP username, outbound sender, and notification recipient are trusted configuration, not request parameters. The adapter rejects events whose tenant or mailbox account doesn’t match. That isn’t a complete tenancy system: it demonstrates the invariant a real control plane must maintain.
Before exposing self-service enrollment, add tenant quotas, credential rotation, recipient validation at SMTP acceptance, and per-tenant abuse controls. Decide who handles bounce and complaint signals. A shared outbound IP creates a shared reputation boundary even when database rows are perfectly isolated. Export and deletion rules also need to cover mailbox blobs, indexes, and pending jobs.
MX ownership and outbound authentication are separate
Keep the domain’s MX pointed at the server that should receive its mail. An outbound integration doesn’t require moving that MX. In this hybrid, Postfix receives for the mailbox domain and the adapter submits its notification through a separately authenticated sending identity.
Publish the exact outbound DNS records returned for your MailKite domain. Merge authorized sources into one SPF policy rather than creating competing SPF records; keep DKIM selectors distinct when multiple systems sign. Configure DMARC for the visible From domain and check actual received headers for alignment. PTR belongs to the sending-IP operator, not your ordinary DNS-zone editor.
MailKite’s current domainGate implementation separates inbound MX verification from outbound SPF/DKIM verification; the demo’s source-inspection notes record the checked conditions. Its Domains & DNS guide describes both record sets. The live demo needs the owned sending domain’s outbound checks to pass; it does not need MailKite to receive the Dovecot domain’s mail. Domain-management calls use management authentication, while the send worker uses a sending credential.
Using a sending subdomain makes the arrangement easier to audit: receive at inbox@your-domain, send notices from notice@send.your-domain, and set Reply-To to the Linux mailbox. This separates configuration, not all reputation. Receivers can still associate subdomains with their parent domain, and valid SPF/DKIM doesn’t guarantee inbox placement.
Don’t publish both providers as equal-priority MX records hoping each receives a copy. SMTP routing chooses delivery destinations; it isn’t mailbox replication. If two providers host the same domain during migration, design routing and accepted-recipient behavior deliberately and verify where each recipient lands.
Integrate Dovecot receiving with SDK outbound
Read a selected IMAP UID, persist a claim, and submit a fixed notification. This is a small application adapter above the mailbox, not a replacement for Postfix’s transport queue or Dovecot’s store. No incoming message body is uploaded, and no untrusted sender address becomes an outbound recipient.
The live entry point is provider/live.mjs. After installing the pinned dependencies, supply the environment variables documented in the README and run this file. NOTIFY_TO must be a recipient you’ve deliberately chosen; MAIL_FROM must belong to your outbound-authenticated domain.
// Docs: README.md; docs/design.md
import { MailKite } from 'mailkite';
import { ImapFlow } from 'imapflow';
import { openLedger, readEvent, notify } from './adapter.mjs';
const required = ['TENANT_ID', 'IMAP_HOST', 'IMAP_USER', 'IMAP_PASSWORD',
'MAILKITE_API_KEY', 'MAIL_FROM', 'NOTIFY_TO', 'IMAP_UID'];
for (const name of required) {
if (!process.env[name]) throw new Error(`${name} required`);
}
const tenant = { id: process.env.TENANT_ID, account: process.env.IMAP_USER,
from: process.env.MAIL_FROM, to: process.env.NOTIFY_TO };
const imap = new ImapFlow({ host: process.env.IMAP_HOST, port: 993, secure: true,
auth: { user: tenant.account, pass: process.env.IMAP_PASSWORD }, logger: false });
imap.on('error', () => imap.close());
const mk = new MailKite(process.env.MAILKITE_API_KEY);
const db = openLedger('provider.sqlite');
try {
await imap.connect();
const event = await readEvent(imap, tenant, Number(process.env.IMAP_UID));
const job = await notify(db, tenant, event, mk);
console.log(JSON.stringify({ status: job.status, providerId: job.provider_id }));
if (job.status !== 'accepted') process.exitCode = 1;
} finally {
imap.close();
db.close();
}
ImapFlow’s API provides the read-only mailbox lock and UID-based fetch. The source key combines tenant, account, mailbox, UIDVALIDITY, and UID. Sequence numbers change as messages disappear; a bare UID loses meaning when a mailbox is rebuilt. This key represents a mailbox occurrence, not a globally unique email across copies or migrations.
Inside adapter.mjs, notify calls mk.send with an operator-defined From and To, a fixed subject, and a text-only mailbox pointer. Tracking is explicitly disabled. The ledger retains the returned provider ID and status. There is no automatic Sent-folder append in Dovecot; add that separately if your product promises it.
A durable claim is not exactly-once delivery
Persist the send claim before crossing the HTTP boundary. The demo’s SQLite primary key makes duplicate observations and concurrent calls share one job. It prevents a second application attempt, but cannot make an external email delivery transactional with your local database.
If the provider accepts a message and the connection drops before your worker sees the response, retrying can send another message. The demo holds any error as unknown, including explicit rejections, to keep its recovery policy simple. A crash after claiming leaves sending. Both require review. This favors avoiding duplicates over automatic recovery and can leave an unsent notification held.
Don’t treat a MIME correlation header as an idempotency guarantee. The inspected SDK’s send method makes one HTTP request; this demo adds no automatic retry. A production worker should distinguish known rejections from uncertain outcomes, retain sanitized diagnostics, reconcile provider records where possible, and expose a deliberate retry action. Never delete the ledger merely to make the status look successful.
The offline tests cover tenant mismatch, account isolation, UIDVALIDITY changes, concurrent claims, SQLite reopen, missing UIDs, rejected requests, lost responses, malformed acknowledgements, and crash-held jobs. They exercise the real SDK request path against a local fixture. They don’t establish deliverability, Dovecot interoperability, or production throughput; those require live infrastructure checks.
Predict the recovery state: the send request reaches the loopback fixture, but its response is lost. The same mailbox occurrence is observed again. Does the adapter make a second request?
Reveal the lost-response exercise and verify the request count
No automatic resend occurs. The disconnect test records unknown on the first call, returns unknown on the repeated observation, and asserts exactly one HTTP request. The reject and malformed-response cases use the same held-state policy. A separate crash test leaves the durable claim at sending and asserts that the send method is never called.
After installing dependencies in provider/, run node --test --test-name-pattern='held as unknown|crash after durable claim' test/adapter.test.mjs. Read the failure assertions alongside the ledger transitions in adapter.mjs.
These are offline fault injections, not evidence that an email was delivered. Holding the claim avoids an automatic duplicate attempt but can also hold an unsent notice. Reconcile the outcome before choosing a recovery action; clearing the ledger defeats the claim.
MailKite Server has a specific contract, not universal backend compatibility
The open-source server’s edges are interchangeable only with backends implementing its exact trusted HTTP contract. The contract defines raw inbound ingest, accepted domains, SMTP authentication/relay, and scoped IMAP reads. Dovecot’s LMTP socket and WildDuck’s REST API do not implement that contract merely because they store email.
The current local backend also implements /v1/send with a cloud-shaped response (server source). That isn’t blanket cloud SDK parity. This endpoint rejects external recipients with 400 no_smarthost before storing anything when no smarthost is configured. With a transport configured, the pipeline stores a Sent copy, delivers local recipients, and hands external recipients to the smarthost. A transport failure can return 502 after the Sent copy was stored. Neither a Sent copy nor a successful submission proves destination inbox delivery.
MailKite, which we build, is useful here when you want to keep Linux mailbox operations while outsourcing the outbound submission path shown above. Hosted outbound still has domain-verification, account, suppression, and usage constraints. It doesn’t provision your Dovecot users, synchronize folders, or turn this adapter into a provider control panel. For a fully owned outbound queue, use Postfix or ZoneMTA; for a self-hosted delivery product, consider Postal.
Likewise, the cloud edge-ingest endpoint is a trusted internal seam, not a documented arbitrary third-party inbound-upload API. Keep the demonstrated integration at IMAP reads plus SDK outbound. This article verifies source interfaces; it does not claim a tested self-host SDK deployment or a tested swap between unrelated mail backends.
Extend the provider at one boundary at a time
Start with the working adapter, inspect its ledger after a failure, then connect a real mailbox and authenticated sending domain. Preserve that boundary when adding background polling: unread flags aren’t a durable job cursor, and mailbox copies aren’t duplicate-free event streams.
The series splits the next jobs into programmable Linux email servers, Node.js mail-server integration, Python integration, and AI-assisted replies. Automated replies introduce sender authorization and loop prevention that a fixed operator notification avoids. Use the demo repository’s issues to discuss integration failures with a reproducible case.
Architecture and source interfaces checked on 2026-10-05. Recheck the SDK, server contract, and project documentation before deployment; this search/LLM reference is due for review by 2026-11-04.