Linux email servers with Node.js: SMTP, IMAP, and APIs
Connect Node.js to Linux email servers through SMTP, IMAP, or HTTP. Compare the integration boundaries, then run a local MIME round trip with an attachment and envelope-based ticket routing.
// Docs: README.md. Run from nodejs/: node loopback.mjs
import nodemailer from 'nodemailer';
import { openSink } from './sink.mjs';
const sink = await openSink();
const smtp = nodemailer.createTransport({ host: '127.0.0.1', port: sink.port, ignoreTLS: true });
try {
await smtp.sendMail({
from: 'app@example.com', to: 'support@example.com', subject: 'Ticket 42 ✓',
envelope: { from: 'bounce@example.com', to: ['ticket+42@example.com'] },
text: 'Please inspect the attached log.',
attachments: [{ filename: 'log.txt', content: 'status=ok\n' }],
});
const { mail, recipients } = sink.records[0];
console.log(JSON.stringify({ recipients, headerTo: mail.to.text, subject: mail.subject,
attachment: mail.attachments[0].content.toString() }, null, 2));
} finally { smtp.close(); await sink.close(); }
Source: nodejs/loopback.mjs in the companion demo. Install with npm ci in that directory, then run npm start. It opens a temporary loopback SMTP listener, receives and parses the MIME, prints the decoded attachment, and closes. No account, DNS changes, or external sends are involved.
Compare the boundary, setup, and customization
Node.js can integrate with a Linux mail server without running inside that server. Nodemailer is an SMTP client; ImapFlow reads mailboxes; MailParser decodes message contents. HTTP APIs expose different operations, not a universal replacement for either protocol. The Linux email-server roundup covers which projects fill which infrastructure slots.
| Server or stack | Node.js transport | Setup you operate | Customization boundary | Useful for |
|---|---|---|---|---|
| Postfix | Nodemailer SMTP | MTA, submission auth, TLS, queue | Maps, restrictions, external policy services | Existing Linux relay |
| Exim | Nodemailer SMTP | MTA, authenticators, TLS, routing | ACLs, routers, transports | Complex delivery policy |
| Dovecot | ImapFlow + MailParser | Mail store, accounts, IMAPS | Sieve and mailbox policy | Reading retained replies |
| mailcow | SMTP + IMAP clients | Container stack, domains, mailboxes | Supported Postfix/Dovecot overrides | Human mailboxes plus app access |
| Haraka | SMTP plus JS plugins | SMTP listener, recipient and queue plugins | SMTP lifecycle hooks | Validation before acceptance |
| Postal | JSON HTTP or SMTP | Delivery platform and backing services | API requests, routes, webhooks | Self-hosted transactional delivery |
| WildDuck + ZoneMTA | REST/IMAP + outbound SMTP/HTTP | MongoDB, Redis, suite services | Mailbox API and outbound plugins | Building a mailbox product |
| Stalwart | JMAP HTTP; also SMTP/IMAP | Server, accounts, HTTP listener, TLS | JMAP methods and server policy | Mail synchronization over JSON |
| MailKite Cloud (ours) | Hosted mailkite SDK + webhooks | Verified domain and application handler | Routes and application code | App-owned email without an MX host |
| MailKite Server (ours) | Basic SDK send subset; SMTP/IMAP edges | Local backend, edges, outbound path | Open-source backend and edge contract | Owning the application-email stack |
The setup column describes operational responsibility, not a timed installation benchmark. A protocol client lets you keep an existing server. A server plugin gives you control during acceptance, but couples application failures to mail delivery. That distinction matters more than whether the server itself is written in JavaScript.
Postfix and Exim: test the SMTP envelope, not just sendMail
SMTP is the portable choice for submitting mail to Postfix or Exim. Configure an authenticated submission listener and let the MTA handle onward routing. Postfix’s SASL Howto separates authentication from permission to relay; Exim’s SMTP transport documentation documents the onward SMTP/LMTP delivery controls.
The demo intentionally makes the visible To header different from RCPT TO. The header says support@example.com; the envelope routes to ticket+42@example.com. A ticket application should use the accepted envelope recipient when associating incoming mail with a ticket. Parsing the visible header alone loses that distinction, particularly for aliases and Bcc.
The executed round trip preserved the Unicode subject Ticket 42 ✓ and decoded log.txt back to status=ok followed by a newline. Tests also exercised recipient rejection, oversized DATA, and a failed persistence callback. The demo sink is memory-only by default: it demonstrates receiving, not durable production storage.
For a real submission host, replace the loopback transport with your hostname and credentials. Use port 587 with secure: false and requireTLS: true for required STARTTLS, or port 465 with secure: true. The demo’s ignoreTLS: true is only for its isolated listener. Nodemailer’s SMTP reference explains these settings and why verify() does not prove sender acceptance or destination delivery.
Predict two outcomes: if only the SMTP envelope changes to other@example.com, will the visible support address rescue delivery? If the persistence callback fails instead, should the sink record success?
Reveal the SMTP failure exercise and compare it with the round trip
The unknown envelope recipient gets SMTP 550 and leaves zero sink records. A failed persistence callback gets 451 and also leaves zero records. Keeping the visible To header unchanged does not authorize the envelope recipient. The successful npm start run instead prints recipients containing ticket+42@example.com, headerTo as support@example.com, the Unicode subject, and attachment bytes status=ok\n.
After installing dependencies in nodejs/, run node --test --test-name-pattern='SMTP rejects unknown|storage failure tempfails' test/integration.test.mjs. The two failure tests assert SMTP response codes and record counts; they inject the failure rather than requiring you to break a real mail server.
The sink is memory-only. A successful local round trip proves MIME parsing and envelope handling, not durable queuing or inbox delivery.
Dovecot and mailcow: fetch raw MIME before parsing
IMAP provides mailbox access; it does not turn mail into application events by itself. Dovecot CE supports IMAP and LMTP. mailcow’s manual client configuration documents IMAPS on 993 and SMTP submission separately. Use mailbox credentials for the receiving side, not an administration API key.
The companion imap.mjs opens INBOX read-only, fetches raw message sources, and passes each source to simpleParser. Its CLI requires IMAP_HOST, IMAP_USER, and IMAP_PASSWORD in your environment; run npm run imap against a mailbox you operate. The demo reads the first ten sequence positions to keep the example bounded. It is a diagnostic reader, not an incremental synchronizer.
For a worker, store the mailbox identity, UIDVALIDITY, and UID together. Sequence positions shift when messages are expunged. A bare UID can also be reused after a mailbox rebuild, so the demo emits keys such as 19:7 instead. Include the account and folder in the production database key.
ImapFlow’s client reference warns against issuing another IMAP command inside its fetch() iterator: that can deadlock. This adapter only parses and calls a consumer there, then releases the mailbox lock in finally. Keep that consumer free of commands on the same IMAP connection too. Tests use real generated MIME with an injected client contract; they do not certify a live Dovecot or mailcow deployment.
MailParser’s documentation draws another important boundary: simpleParser buffers attachments and doesn’t sanitize HTML. For large messages, use the streaming parser and consume attachment streams. Keep mailbox size limits, HTML rendering policy, and application persistence outside the parser’s responsibilities.
Haraka: extend acceptance without bypassing other plugins
Haraka is appropriate when your application must influence the SMTP conversation. Its plugin contract exposes recipient and queue hooks. The demo’s complete recipient guard is small:
// Docs: README.md. Load before recipient acceptance plugins in config/plugins.
exports.register = function () { this.register_hook('rcpt', 'check_ticket'); };
exports.check_ticket = function (next, connection, params) {
const recipient = params[0].address().toLowerCase();
if (recipient !== 'ticket+42@example.com') return next(DENY, 'Unknown ticket');
next(); // Continue to the configured recipient and queue plugins; do not short-circuit.
};
This is haraka-ticket.cjs, tested in a plugin-shaped VM. Install it as a plugin in your existing Haraka setup and place it before recipient-acceptance plugins. It rejects unknown tickets but lets known ones continue. next(OK) would stop subsequent plugins on that hook. You still need recipient acceptance and a queue plugin; this guard alone is not a mail server.
Don’t wait for an AI response inside the queue hook. Store the message and schedule work. If storage fails, a temporary rejection preserves the retry path. The programmatic Linux server guide covers that architecture; automating replies with AI builds on it.
Postal, WildDuck, and Stalwart expose different HTTP jobs
HTTP doesn’t make these APIs interchangeable. Postal’s API guide describes sending JSON with X-Server-API-Key. Its response body carries status: success or error; checking only the HTTP status misses application failures. postal.mjs exercises both paths offline:
// Docs: README.md. Postal v1 reports application errors inside its JSON response.
export async function postalReceipt(baseUrl, key, fetchImpl = fetch) {
const response = await fetchImpl(`${baseUrl}/api/v1/send/message`, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Server-API-Key': key },
body: JSON.stringify({ from: 'app@example.com', to: ['customer@example.com'],
subject: 'Ticket 42 receipt', plain_body: 'Log received.', tag: 'ticket-42' }),
signal: AbortSignal.timeout(10000),
});
const result = await response.json();
if (!response.ok || result.status !== 'success') throw new Error('Postal rejected request');
return result.data;
}
Postal explicitly says this API does not manage every platform function. Choose it for delivery-platform operations, not because you expect a general mailbox synchronization API.
WildDuck instead exposes REST operations over accounts, mailboxes, and messages, alongside IMAP/POP3. Its documented suite pairs Haraka for inbound SMTP with ZoneMTA for outbound delivery. ZoneMTA is outbound-only and adds sending-zone policy; it does not replace WildDuck’s mailbox store. Operating that suite means operating MongoDB, Redis, and the service boundaries too.
Stalwart’s JMAP documentation describes discovery through /.well-known/jmap and operations through /jmap. JMAP is a stateful synchronization protocol using HTTP and JSON, not a generic send endpoint. A Node client must discover session details and use account identifiers and capabilities. Prefer it for mailbox synchronization when both your client and server support that model; SMTP/IMAP remains the compatibility path.
MailKite Cloud and Server share only specific contracts
Once an application needs parsed inbound events, signed delivery, and outbound receipts, you can assemble those pieces yourself or consume a hosted boundary. MailKite, which we build, offers the hosted SDK/webhook route; MailKite Server is our open-source self-hosted option. Postfix/Dovecot remains a reasonable choice for existing mailboxes, and Postal suits a self-operated delivery platform.
The hosted example in mailkite.mjs uses the current Node SDK, pinned to mailkite@0.20.0:
// Docs: README.md. Hosted SDK examples, exercised with intercepted transport in tests.
import { MailKite } from 'mailkite';
export async function sendReceipt(mk) {
return mk.send({
from: 'app@example.com', to: 'customer@example.com',
subject: 'Ticket 42 receipt', text: 'Log received.',
attachments: [{ filename: 'receipt.txt', content: Buffer.from('ticket=42\n').toString('base64') }],
});
}
export function receiveCloud(signature, raw, secret) {
if (!MailKite.verifyWebhook(signature, raw, secret)) throw new Error('Invalid signature');
const event = JSON.parse(raw.toString('utf8'));
if (event.type !== 'email.received') return null;
return { id: event.id, subject: event.subject, text: event.text };
}
Construct MailKite with your Cloud credential for an actual integration; the tests intercept its HTTP request and make no send. A webhook handler must pass the untouched request bytes to receiveCloud, persist accepted work, then acknowledge. Signature validation doesn’t deduplicate retries. The security documentation specifies milliseconds timestamps and freshness verification.
The inspected Server checkout exposes basic /v1/send, but its implementation rejects attachments, templateId, templateData, and scheduledAt. External delivery requires a configured smarthost. The shared edge contract concerns SMTP/IMAP adapters, not every hosted SDK method. See sendV1Message and the developer routes.
Server’s webhook implementation also sends metadata with event: inbound, uid, and raw_url, and signs using seconds. Cloud sends a different event shape and uses milliseconds. The pinned SDK rejects that seconds signature under its default freshness check. Sharing an HMAC algorithm does not make the handlers drop-in compatible. Fetching Server’s raw MIME requires its documented authenticated path; keep that credential inside a trusted adapter.
For the deployment side, continue with building a Linux email provider. For another client language, the Python integration guide covers the corresponding boundaries.
Run the tests before connecting a real server
The demo directory contains exact dependency pins, a lockfile, and npm test. The ten checks cover the real local SMTP/MIME round trip and failure replies, IMAP adapter cleanup, the Haraka guard, Postal JSON errors, and intercepted MailKite SDK calls. These are integration seams, not deliverability tests or evidence that every listed server was installed.
Keep the MTA, mailbox store, and application worker as separate responsibilities. Then choose the smallest boundary that serves your job: SMTP submission, IMAP/JMAP synchronization, a server hook, or a signed application event. That keeps both server replacement and application debugging tractable.
Sources were retrieved on 2026-10-05. Exact URLs, rejected documentation paths, and the inspected Server commit are recorded in the demo’s research.md; local results and remaining publication gates are in verification.md. This search-track reference should be rechecked by 2026-11-04, particularly for SDK versions and Server compatibility.