All posts
Build a Linux email provider with MailKite integrations
Gabe 13 min read

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.

One provider, separate ownership boundaries The tenant control plane authorizes the domain and credentials. Inbound mail passes through Postfix and Rspamd to a Dovecot mailbox. An adapter reads a selected UID, writes a durable claim, and uses the MailKite SDK for hosted outbound. Mailbox data stays on Linux; only a notification goes outbound. Tenant control plane Domain → account → credentials Postfix + Rspamd MX accepts; filter checks mail Dovecot mailbox Store + IMAP stay on Linux Adapter + durable claim Read one UID; send one notice MailKite SDK → outbound
Integration sketch: the control plane authorizes the paths; the adapter reads an existing mailbox. This is not a cloud inbound-import pipeline.
#!/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.

StackMailbox and delivery ownershipIntegration seamWhen it fits
Postfix + Dovecot + RspamdPostfix queues transport; Dovecot owns mailbox access and local delivery; Rspamd supplies filteringLMTP delivery, IMAP reads, Milter filteringExisting Linux operations and human mailboxes
Haraka + WildDuck + ZoneMTAHaraka handles inbound SMTP; WildDuck stores mail in MongoDB; ZoneMTA queues outboundSMTP plugins and WildDuck REST managementAPI-managed mailbox products with database operations expertise
StalwartIntegrated SMTP, IMAP/JMAP, storage, filtering and collaborationNative management interfaces and mail protocolsA consolidated mailbox service; inspect edition limits
PostalApplication mail delivery platform, organizations, credentials, outbound and inbound routingIts send API and webhooksHosted transactional-email product, not an IMAP mailbox replacement
MailKite ServerStateless protocol edges over its defined backend; local SQLite implementation owns messages and policyTrusted edge contract and local APIApplication-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.

The send acknowledgement is the uncertain boundary A selected mailbox occurrence gets a unique SQLite claim in sending state. The SDK makes one send attempt. A recognized acknowledgement records accepted and the provider ID. Rejection, disconnect or malformed response records unknown for operator review. A crash leaves sending held. No state automatically resends. Mailbox occurrence Account + UIDVALIDITY + UID Durable unique claim sending → one SDK attempt Known acknowledgement accepted + provider ID No reliable outcome unknown → operator review Process crash? sending stays held; no resend
Accepted means a recognized API acknowledgement, not recipient delivery. The lower boxes are alternative failure outcomes, not successive steps.

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.

Discuss this post: Hacker News Share on X Share on LinkedIn

Related posts