📧 Easily run your own mail server — complete email infrastructure with full control, in a single Node.js package with zero dependencies.
⚠️ Project status: Active development. APIs may change without notice until we reach v1.0. Use at your own risk and please report issues!
- 📬 SMTP Server — inbound (port 25), submission (port 587), implicit TLS (465). STARTTLS, AUTH PLAIN/LOGIN, XOAUTH2, PROXY protocol.
- 📤 SMTP Client — direct delivery via MX lookup or via relay/smarthost. Connection pooling, retry with backoff, per-domain rate limiting.
- 📥 IMAP Server — RFC 3501 + UIDPLUS, CONDSTORE, QRESYNC, SORT, THREAD, IDLE, MOVE, NAMESPACE, SPECIAL-USE, LITERAL+, ENABLE, COMPRESS=DEFLATE, LIST-EXTENDED, LIST-STATUS, WITHIN, QUOTA, METADATA.
- 📫 IMAP Client — full client-mode
IMAPSessionwith LOGIN / XOAUTH2, FETCH, SEARCH, STORE, COPY, MOVE, IDLE, LIST-EXTENDED, QUOTA, METADATA, COMPRESS. - 📭 POP3 Server — RFC 1939 + RFC 2449 CAPA, UIDL, TOP, STLS, SASL, XOAUTH2. Same
mailboxSessionevent model as IMAP. - 📮 POP3 Client — connect, authenticate (USER/PASS/APOP/XOAUTH2), LIST, RETR, DELE, TOP.
- 🔑 DKIM — RSA-SHA256 and Ed25519-SHA256, automatic sign on send and verify on receive.
- 🛡 SPF, DMARC, rDNS — all inbound auth checks run automatically in parallel before
mailfires. - 🔐 XOAUTH2 everywhere — SMTP submission, IMAP, and POP3 — both server and client directions.
- 🛡 REQUIRETLS (RFC 8689) — enforce TLS end-to-end on sensitive mail.
- 🌐 MTA-STS (RFC 8461) + TLS-RPT (RFC 8460) — policy generation, DNS records, and HTTP handler all included.
- 📦 MIME compose & parse — text, HTML, attachments, inline images, UTF-8 — cross-compatible with nodemailer / mailparser.
- 🌍 SMTPUTF8 + IDN (RFC 6531 + 5890) — international addresses with automatic Punycode fallback when the peer doesn't support UTF-8.
- 📣 DSN (RFC 3461/3464) — parse NOTIFY / ORCPT / RET / ENVID; generate multipart/report delivery status notifications;
server.sendDsn()API. - 🏷 Clean flag names — use
'Seen'not'\\Seen'— the library handles the protocol details.
- 🧩 Unified event model — one pattern across SMTP, IMAP, and POP3:
server.on('auth')+server.on('smtpSession' | 'mailboxSession'), thensession.on(...)for storage handlers. - 💾 Bring your own storage — the library is a protocol layer; it never holds or persists messages. You wire up any backend (SQLite, Postgres, S3, memory).
- 🔄 IDLE push primitives —
session.notifyExists / notifyRecent / notifyExpunge / notifyVanished / notifyFlagswake IDLE clients the moment your backend changes. - 🏗 Domain management —
buildDomainMailMaterial()auto-generates DKIM keys and every DNS record you need (DKIM, SPF, DMARC, MX, MTA-STS, TLS-RPT). - 🔒 Multi-domain TLS — SNI context caching with
server.clearTlsCache()for Let's Encrypt rotation. - 🎯 Listener-gated capabilities — advanced extensions (MOVE, QUOTA, METADATA) are advertised only when you wire up their handlers, so clients never attempt something that would return NO.
- ⚡ Zero dependencies — only
node:builtins and the tinyflat-rangeslibrary.
npm install email-serverRequirements: Node.js 18+ (uses node:crypto, node:url IDN, node:zlib deflate-raw).
The Quick Start covers three scenarios that together show the whole surface: receiving mail, sending mail, and letting users retrieve it over IMAP. A full combined server comes at the end.
Inbound SMTP on port 25 — other mail servers delivering to your users. No auth; SPF/DKIM/DMARC/rDNS checks run automatically before the mail event fires.
import { createServer, buildDomainMailMaterial } from 'email-server';
// Generate DKIM keys + every DNS record you need
const mat = buildDomainMailMaterial('example.com', {
// Optional: opt in to MTA-STS enforcement
mtaSts: { mode: 'enforce', mx: ['mx.example.com'] },
tlsRpt: { ruaEmail: 'tls-reports@example.com' }
});
console.log(mat.requiredDNS); // Add these to your DNS provider
const server = createServer({
hostname: 'mx.example.com',
ports: { inbound: 25 }
});
server.addDomain(mat);
server.on('smtpSession', session => {
session.on('mail', mail => {
// Envelope + auth results available immediately
console.log(mail.from, '→', mail.to);
console.log('DKIM:', mail.auth.dkim, 'SPF:', mail.auth.spf, 'DMARC:', mail.auth.dmarc);
// Reject on DMARC policy
if (mail.auth.dmarc === 'fail' && mail.auth.dmarcPolicy === 'reject') {
return mail.reject(550, 'DMARC policy rejection');
}
// Stream body, then commit
mail.on('data', chunk => { /* bytes arriving (Uint8Array) */ });
mail.on('end', () => {
console.log(mail.subject, mail.text, mail.html);
console.log(mail.attachments.length, 'attachments');
// Save to your storage here (DB, filesystem, S3, ...)
// saveMessage(mail.to, rawBytes);
mail.accept(); // → 250 OK
});
});
});
server.listen(() => console.log('SMTP MX on port 25'));Authenticated submission on port 587 — your users sending outbound. Library signs with DKIM and delivers via the outbound pool.
import { createServer, buildDomainMailMaterial } from 'email-server';
const mat = buildDomainMailMaterial('example.com');
const server = createServer({
hostname: 'mail.example.com',
ports: { submission: 587 },
tlsOptions: {
cert: await fs.readFile('cert.pem'),
key: await fs.readFile('key.pem')
}
});
server.addDomain(mat);
// Unified auth event — fires on SMTP submission, IMAP, and POP3.
server.on('auth', async info => {
// info.protocol: 'smtp' | 'imap' | 'pop3'
// info.authMethod: 'plain' | 'login' | 'xoauth2'
const ok = await checkCredentials(info.username, info.password);
ok ? info.accept() : info.reject('Invalid credentials');
});
server.on('smtpSession', session => {
session.on('mail', mail => {
mail.on('end', () => {
// Library signs + delivers on your behalf
mail.deliver((err, info) => {
if (err) return mail.reject(451, 'Temporary failure');
mail.accept();
});
});
});
});
server.listen();Or send standalone, without a server running:
import { sendMail } from 'email-server';
// Direct delivery via MX lookup — no relay needed
sendMail({
from: 'alice@example.com',
to: 'bob@other.com', // Unicode addresses work — SMTPUTF8 with IDN fallback
subject: 'Hi',
text: 'Body text',
html: '<p>HTML body</p>',
attachments: [
{ filename: 'report.pdf', content: pdfBuffer }
]
}, (err, info) => {
console.log(info.accepted, info.rejected);
});
// Or via a relay/smarthost:
sendMail({
relay: { host: 'smtp.sendgrid.net', port: 587, auth: { user: '...', pass: '...' } },
from: 'alice@example.com',
to: 'bob@other.com',
subject: 'Hi', text: 'Body'
}, callback);Both IMAP and POP3 share one event model — mailboxSession. Define your storage handlers once; both protocols use them.
import { createServer } from 'email-server';
const server = createServer({
hostname: 'mail.example.com',
ports: { imap: 143, imaps: 993, pop3: 110, pop3s: 995 },
tlsOptions: { cert, key }
});
server.on('auth', info => {
// Same auth handler for IMAP and POP3
info.protocol === 'imap' || info.protocol === 'pop3';
checkCreds(info.username, info.password)
? info.accept() : info.reject('Invalid');
});
server.on('mailboxSession', session => {
// session.protocol: 'imap' | 'pop3'
// session.username, session.remoteAddress
session.on('folders', cb => {
// Return the user's folder list
cb(null, [
{ name: 'INBOX', specialUse: null },
{ name: 'Sent', specialUse: 'Sent' },
{ name: 'Drafts', specialUse: 'Drafts' }
]);
});
session.on('openFolder', (name, cb) => {
cb(null, {
uidValidity: 1,
total: await countMessages(session.username, name),
unread: await countUnread(session.username, name)
});
});
session.on('resolveMessages', (range, cb) => {
// Resolve a sequence/UID range to concrete message IDs
cb(null, await listMessageIds(session.username, session.currentFolder, range));
});
session.on('messageMeta', (ids, cb) => {
cb(null, ids.map(id => ({
id: id,
uid: id,
size: getSize(id),
internalDate: getDate(id),
flags: ['Seen'] // clean names — no backslashes
})));
});
session.on('messageBody', (id, responder) => {
// Stream the raw RFC 5322 message — library handles zero-copy delivery
responder.send({ length: size, stream: fs.createReadStream(pathFor(id)) });
});
// IMAP-only (POP3 doesn't have flags)
session.on('setFlags', (ids, flags, cb) => {
updateFlags(ids, flags); cb(null);
});
});
server.listen();When a new message arrives from SMTP, wake any active IDLE client:
server.on('smtpSession', s => s.on('mail', m => m.on('end', () => {
saveMessage(m.to, raw);
// Push the new EXISTS count to any connected IDLE client
server.forEachMailboxSession(session => {
if (session.username === m.to[0] && session.currentFolder === 'INBOX') {
session.notifyExists(newTotalCount);
}
});
m.accept();
})));IMAPSession with isServer: false is a full client. Connect to any IMAP server — Gmail, Outlook, Fastmail, or your own.
import net from 'node:net';
import tls from 'node:tls';
import { IMAPSession } from 'email-server';
const socket = tls.connect({ host: 'imap.gmail.com', port: 993 });
const client = new IMAPSession({ isServer: false });
client.on('send', data => socket.write(data));
socket.on('data', data => client.feed(data));
socket.once('secureConnect', () => {
client.greet();
client.on('ready', () => {
client.xoauth2('user@gmail.com', bearerToken, err => {
client.select('INBOX', (err, info) => {
console.log(info.total, 'messages in INBOX');
client.search({ seen: false }, (err, uids) => {
client.fetch(uids, ['UID', 'ENVELOPE', 'BODY.PEEK[]'], (err, messages) => {
messages.forEach(m => console.log(m.envelope.subject));
client.logout();
});
});
});
});
});
});Everything together — receive, store, serve, with auth consistency across all three protocols.
import { createServer, buildDomainMailMaterial } from 'email-server';
import fs from 'node:fs/promises';
const mat = buildDomainMailMaterial('example.com', {
mtaSts: { mode: 'enforce', mx: ['mx.example.com'] },
tlsRpt: { ruaEmail: 'tls-reports@example.com' }
});
const server = createServer({
hostname: 'mail.example.com',
ports: {
inbound: 25, // Incoming mail from other servers
submission: 587, // Your users sending (STARTTLS)
secure: 465, // Your users sending (implicit TLS)
imap: 143, // IMAP (STARTTLS)
imaps: 993, // IMAP (implicit TLS)
pop3: 110, // POP3 (STARTTLS)
pop3s: 995 // POP3 (implicit TLS)
},
tlsOptions: {
cert: await fs.readFile('./cert.pem'),
key: await fs.readFile('./key.pem')
}
});
server.addDomain(mat);
// One auth handler for SMTP submission, IMAP, and POP3
server.on('auth', async info => {
const user = await db.users.findByEmail(info.username);
if (!user) return info.reject('No such user');
if (info.authMethod === 'xoauth2') {
const valid = await verifyOAuth2Token(info.password, user);
return valid ? info.accept() : info.reject('Token expired');
}
const ok = await verifyPassword(info.password, user.passwordHash);
ok ? info.accept() : info.reject('Bad password');
});
// Inbound mail — save + notify IDLE clients
server.on('smtpSession', session => {
session.on('mail', mail => {
mail.on('end', async () => {
const id = await db.messages.save(mail.to[0], mail.rawBytes);
server.forEachMailboxSession(s => {
if (s.username === mail.to[0] && s.currentFolder === 'INBOX') {
s.notifyExists(await db.messages.countInFolder(s.username, 'INBOX'));
}
});
mail.accept();
});
});
});
// IMAP + POP3 storage handlers (shared)
server.on('mailboxSession', session => {
session.on('folders', cb => db.folders.list(session.username, cb));
session.on('openFolder', (n, cb) => db.folders.open(session.username, n, cb));
session.on('resolveMessages', (r, cb) => db.messages.resolve(session.username, session.currentFolder, r, cb));
session.on('messageMeta', (ids, cb) => db.messages.meta(ids, cb));
session.on('messageBody', (id, r) => db.messages.streamBody(id, r));
session.on('setFlags', (ids, flags, cb) => db.messages.setFlags(ids, flags, cb));
session.on('append', (folder, raw, flags, cb) => db.messages.append(session.username, folder, raw, flags, cb));
session.on('copyMessages', (ids, dest, cb) => db.messages.copy(ids, dest, cb));
session.on('move', (ids, dest, cb) => db.messages.move(ids, dest, cb));
session.on('expunge', (ids, cb) => db.messages.expunge(ids, cb));
// Opt-in: advertise QUOTA when you register the handler
session.on('quota', (root, cb) => db.quotas.get(session.username, cb));
// Opt-in: advertise METADATA when you register the handler
session.on('getMetadata', (mbox, paths, cb) => db.metadata.get(session.username, mbox, paths, cb));
session.on('setMetadata', (mbox, entries, cb) => db.metadata.set(session.username, mbox, entries, cb));
});
server.listen(() => console.log('mail stack online'));See examples/demo_server.js for a complete working reference implementation with an in-memory backend, 16 seeded messages, and Thunderbird-tested IMAP on port 9143 + SMTP on port 9587.
These five ideas explain how the library is structured. Read them once; the rest of the docs assume you know them.
┌─────────────────────────────────────────────────────────┐
│ server.on('auth', info → accept/reject) │ ← unified
│ server.on('smtpSession', session ) │ ← SMTP
│ server.on('mailboxSession',session ) │ ← IMAP + POP3
└─────────────────────────────────────────────────────────┘
↓
session.on('folders' , ...)
session.on('messageMeta' , ...)
session.on('messageBody' , ...)
session.on('setFlags' , ...)
session.on('append' , ...)
session.on('move' , ...)
session.on('quota' , ...)
session.on('getMetadata' , ...)
... (24 total — see API Reference)
The same mailboxSession event fires for IMAP and POP3. You write storage handlers once and both protocols use them; the library maps each handler to the subset of commands that protocol supports. POP3 will never fire setFlags (it has no flags); IMAP will never fire retr (it fetches differently).
email-server is a protocol-layer library. It parses wire formats, enforces RFC behavior, and emits events — it never stores messages, user lists, folder structures, or flags. You provide all of that in your handlers. This means you can wire it to SQLite for a personal server, Postgres for production, S3 for cold storage, or even an in-memory Map for testing.
The examples throughout this document use handlers like db.messages.save(...) — that's your code, not the library's.
Advanced IMAP extensions are expensive to half-implement. MOVE, QUOTA, and METADATA are therefore advertised only when your code has registered the corresponding handler. If your backend doesn't do quotas, the client never sees QUOTA in the CAPABILITY list and never attempts GETQUOTAROOT. When you later add session.on('quota', ...), the capability automatically appears.
For this to work across authentication — where the capability list may change as the developer registers handlers inside mailboxSession — the server re-emits the fresh CAPABILITY list in the OK response to LOGIN and AUTHENTICATE (RFC 3501 §7.1). Clients built with this library honor that response automatically.
When the library passes flags to your handlers, they come without the \ prefix: 'Seen', 'Flagged', 'Answered', 'Draft', 'Deleted'. When you return flags from a handler, do the same — return ['Seen', 'Flagged'], not ['\\Seen', '\\Flagged'].
This is a one-time migration for developers coming from raw IMAP. The library warns once per process if it sees backslashed flag names in a handler's return value. Custom keywords (unflagged labels) are passed through unchanged.
When your backend changes — new message arrives, flags change, a message is expunged — you call a notify* method on the active mailboxSession and the library takes care of the wire format:
session.notifyExists(newTotalCount); // * n EXISTS
session.notifyRecent(newRecentCount); // * n RECENT
session.notifyExpunge(seqNum); // * n EXPUNGE
session.notifyVanished(uid); // * VANISHED uid (QRESYNC)
session.notifyFlags(seqNum, uid, flags); // * n FETCH (FLAGS ...)The library handles the IDLE state — if the client isn't in IDLE, notifications are buffered and flushed on the next SELECT/EXAMINE; if the client is in IDLE, they're pushed immediately.
import {
// Server
createServer, // Create unified server (SMTP + IMAP + POP3)
Server, // Server class (if you need to construct directly)
// Domain material (DKIM + DNS + MTA-STS)
buildDomainMailMaterial, // Generate DKIM keys + all DNS records
// Message composition / parsing
composeMessage, // Build RFC 5322 message
parseMessage, // Parse raw email → { text, html, attachments }
// SMTP client (standalone — no server needed)
sendMail, // Send mail (direct MX or via relay)
resolveMX, // MX record lookup
// DSN (RFC 3461/3464)
buildDsn, // Build a multipart/report DSN message
// DKIM / SPF / DMARC (standalone auth checks)
dkimSign, dkimVerify,
checkSPF, checkDMARC,
// Low-level session constructors (use these for custom transports)
SMTPSession, // SMTP session (server or client mode)
IMAPSession, // IMAP session (server or client mode)
POP3Session, // POP3 session (server or client mode)
// IDN / internationalization helpers
domainToAscii, // Unicode → Punycode
domainToUnicode, // Punycode → Unicode
splitAddress, // 'user@host' → {local, domain}
// Constants
SPECIAL_USE, // Folder special-use flags (SPECIAL_USE.Sent, Drafts, ...)
FLAGS // Standard message flags (FLAGS.Seen, Flagged, ...)
} from 'email-server';| Option | Type | Default | Description |
|---|---|---|---|
hostname |
string | 'localhost' |
Server hostname for EHLO/banner/STS identity |
ports |
object | — | { inbound, submission, secure, imap, imaps, pop3, pop3s } |
tlsOptions |
object | null | { cert, key, ca, ... } — base TLS context |
maxSize |
number | 25 MB | Maximum message size in bytes |
maxRecipients |
number | 100 | Maximum RCPT TO per message |
relay |
object | null | { host, port, auth } smarthost for outbound |
pool |
object | defaults | Connection pool settings — see below |
useProxy |
boolean | false | Enable HAProxy PROXY protocol v1 |
closeTimeout |
number | 30000 | Graceful shutdown timeout (ms) |
SNICallback |
function | null | (servername, cb) for dynamic TLS |
dkimCallback |
function | null | (domain, cb) for dynamic DKIM |
onSecure |
function | null | Post-TLS handshake callback |
rateLimit |
object | null | { perMinute, perHour, banDuration } inbound rate limiting |
| Option | Type | Default | Description |
|---|---|---|---|
maxPerDomain |
number | 3 | Max simultaneous connections per destination domain |
maxMessagesPerConn |
number | 100 | Close connection after N messages |
idleTimeout |
number | 30000 | Close idle connection after (ms) |
rateLimitPerMinute |
number | 60 | Max messages per domain per minute |
reconnectDelay |
number | 1000 | Min time between connections to same domain |
| Event | Args | Description |
|---|---|---|
connection |
(info) |
TCP connection — info.protocol, info.remoteAddress, info.reject() |
auth |
(info) |
Authentication request — unified across SMTP, IMAP, POP3 |
smtpSession |
(session) |
SMTP session ready (both inbound and submission) |
mailboxSession |
(session) |
IMAP or POP3 session ready |
error |
(err) |
Server-level error |
close |
— | Server fully closed |
| Method | Purpose |
|---|---|
server.listen(cb) |
Start listening on all configured ports |
server.close(cb) |
Graceful shutdown (close listeners, drain sessions) |
server.addDomain(material) |
Register a domain for inbound mail + DKIM signing |
server.removeDomain(domain) |
Remove a domain |
server.sendDsn(options, cb) |
Dispatch a DSN message with null return-path |
server.buildDsn(options) |
Build a DSN message (Buffer) without sending |
server.clearTlsCache(servername?) |
Clear SNI cache (use after cert rotation) |
server.forEachMailboxSession(fn) |
Iterate active IMAP/POP3 sessions (for push notifications) |
server.ban(ip, durationMs?) |
Temporarily ban an IP (rate-limit integration) |
server.unban(ip) |
Remove a ban |
server.on('auth', info => {
info.protocol // 'smtp' | 'imap' | 'pop3'
info.authMethod // 'plain' | 'login' | 'xoauth2'
info.username // user identifier
info.password // password OR bearer token (for xoauth2)
info.remoteAddress
info.isTLS
info.accept() // permit the session
info.reject(message?) // deny with optional text
});server.on('smtpSession', session => {
session.isSubmission // false for inbound, true for submission
session.username // null for inbound, set for submission
session.remoteAddress
session.isTLS
session.on('mail', mail => {
mail.from // envelope sender
mail.to // [envelope recipients]
mail.params // { size, body, smtputf8, requiretls, ret, envid }
mail.auth // { dkim, spf, dmarc, rdns, dmarcPolicy } (inbound only)
mail.subject, mail.messageId, mail.date, mail.headers
mail.on('data', chunk => {})
mail.on('end', () => {
mail.text, mail.html, mail.attachments
mail.accept() // → 250 OK
mail.reject(code, text) // → 5xx
mail.deliver(cb) // library signs + sends (submission mode)
});
});
});Your backend implements any subset of these. The library gates features (MOVE, QUOTA, METADATA, THREAD, SORT) on whether the listener is registered.
| Event | Fires on | Purpose |
|---|---|---|
folders |
LIST, LIST-EXTENDED, LSUB | Return the user's folder list |
openFolder |
SELECT, EXAMINE | Return folder metadata (uidValidity, total, unread) |
status |
STATUS | Folder stats without opening it |
resolveMessages |
Any sequence/UID command | Range → concrete message IDs |
messageMeta |
FETCH minimal | IDs → {uid, size, internalDate, flags} |
messageEnvelope |
FETCH ENVELOPE | Cached envelope (opt-in perf optimization) |
messageBodyStructure |
FETCH BODYSTRUCTURE | Cached body structure |
messageBody |
FETCH BODY[], RFC822 | Stream the raw message to the client |
setFlags |
STORE | Update flags on messages |
append |
APPEND | Add a message to a folder |
copyMessages |
COPY | Copy IDs to destination folder |
move |
MOVE | Move IDs to destination (IMAP MOVE extension) |
expunge |
EXPUNGE, CLOSE | Permanently delete flagged messages |
createFolder |
CREATE | Create new folder |
deleteFolder |
DELETE | Delete folder |
renameFolder |
RENAME | Rename folder |
subscribe |
SUBSCRIBE | Add to subscription list |
unsubscribe |
UNSUBSCRIBE | Remove from subscription list |
search |
SEARCH (incl. WITHIN/OLDER/YOUNGER) | Evaluate search criteria |
sort |
SORT | Ordered search (RFC 5256) |
thread |
THREAD | Conversation grouping |
namespace |
NAMESPACE | Return server namespaces |
qresync |
ENABLE QRESYNC, SELECT...QRESYNC | Fast resync for CONDSTORE clients |
resolveVanished |
UID FETCH...VANISHED | Return UIDs vanished since modseq |
quota / quotaRoot |
GETQUOTA, GETQUOTAROOT | Per-folder quota (opt-in) |
getMetadata / setMetadata |
GETMETADATA, SETMETADATA | RFC 5464 annotations (opt-in) |
close |
— | Session disconnecting, cleanup |
Call these on an active mailboxSession to deliver real-time notifications to connected IDLE clients:
session.notifyExists(count) // → * <count> EXISTS
session.notifyRecent(count) // → * <count> RECENT
session.notifyExpunge(seqNum) // → * <seqNum> EXPUNGE
session.notifyVanished(uid) // → * VANISHED <uid> (QRESYNC)
session.notifyFlags(seqNum, uid, flags) // → * <seqNum> FETCH (UID ... FLAGS (...))If the client isn't in IDLE, the library queues notifications and flushes on the next SELECT/EXAMINE. If it is in IDLE, they push immediately.
Send a message with no server running. Automatic IDN Punycode + SMTPUTF8 negotiation, MX lookup, DKIM signing (when a domain is registered), connection pooling.
sendMail({
from: 'alice@example.com',
to: ['bob@other.com', 'charlie@другой.рф'], // Unicode ok
cc, bcc,
subject, text, html,
attachments: [{ filename, content, contentType, cid }],
headers: { 'X-Custom': 'value' },
replyTo,
priority, // 'high' | 'normal' | 'low'
relay: { host, port, auth }, // Optional — direct MX lookup if omitted
pool // Share a pool across many sendMail calls
}, (err, info) => {
info.accepted // [successful hosts]
info.rejected // [{ domain, error }]
info.messageId
});Both accept { isServer: false } and operate as clients. You attach them to a net.Socket or tls.TLSSocket and pump bytes via .feed() / on('send').
const client = new IMAPSession({ isServer: false });
client.on('send', data => socket.write(data));
socket.on('data', data => client.feed(data));
// After socket connects:
client.greet();
client.on('ready', () => {
client.login(user, pass, cb);
// or: client.xoauth2(user, token, cb);
client.capability // string[] — refreshed after LOGIN/STARTTLS
client.list('', '*', cb);
client.listExtended({ reference, patterns, selection, return: ['STATUS'] }, cb);
client.select('INBOX', cb);
client.examine('INBOX', cb);
client.fetch(uids, items, cb);
client.search(criteria, cb);
client.sort(keys, charset, criteria, cb);
client.thread(algo, charset, criteria, cb);
client.store(uids, flags, mode, cb); // mode: '+' | '-' | ''
client.copy(uids, dest, cb);
client.move(uids, dest, cb);
client.append(folder, raw, flags, cb);
client.expunge(cb);
client.idle(cb); // enters IDLE — client.done() to exit
client.getQuota(root, cb);
client.getQuotaRoot(mailbox, cb);
client.getMetadata(mailbox, paths, cb);
client.setMetadata(mailbox, entries, cb);
client.compress(cb); // enable COMPRESS=DEFLATE
client.logout();
});POP3 client is smaller:
const pop = new POP3Session({ isServer: false });
// ... wire up socket ...
pop.greet();
pop.on('ready', () => {
pop.user('alice', err => pop.pass('password', err => {
pop.list((err, entries) => { // [{ index, size }, ...]
pop.top(1, 10, (err, headers) => {}); // first 10 lines
pop.retr(1, (err, raw) => {}); // full message
pop.dele(1, cb);
pop.quit();
});
}));
});
// or: pop.xoauth2(user, token, cb);Generate RFC 3464 delivery status notifications with the right MIME format, or dispatch them through the pool with a null return-path (RFC 3461 §6 loop prevention).
import { buildDsn } from 'email-server';
const raw = buildDsn({
reportingMta: 'mail.example.com',
originalEnvelopeId: 'ENV-42', // from MAIL FROM ENVID
returnContent: 'headers', // or 'full' — matches RET=
originalMessage: originalBytes,
from: 'postmaster@example.com',
to: 'alice@example.com', // original envelope sender
recipients: [{
finalRecipient: 'bob@unknown.example',
originalRecipient: 'bob@unknown.example', // from ORCPT
action: 'failed', // 'failed'|'delayed'|'delivered'|'relayed'|'expanded'
status: '5.1.2',
diagnostic: '550 5.1.2 Host unknown',
remoteMta: 'mx.unknown.example',
lastAttempt: new Date()
}]
});
// Dispatch via the outbound pool with null return-path:
server.sendDsn({ /* same shape */ }, (err, info) => {});Generate everything needed to operate as @domain — DKIM key pair, all DNS records, MTA-STS policy file, TLS-RPT setup.
const mat = buildDomainMailMaterial('example.com', {
dkim: {
algo: 'ed25519-sha256', // or 'rsa-sha256' (default)
selector: 's202604', // auto-generated if omitted
privateKey: existingPemString // provide or auto-generate
},
policy: {
spfTxt: 'v=spf1 mx a ~all', // override defaults
dmarcTxt: 'v=DMARC1; p=reject; adkim=s;'
},
mtaSts: {
mode: 'enforce', // 'enforce' | 'testing' | 'none'
mx: ['mx.example.com'], // single string or array
maxAgeSeconds: 604800 // 1 week (default)
},
tlsRpt: {
ruaEmail: 'tls-reports@example.com' // or explicit rua: URI
}
});
// What you get back:
mat.dkim.privateKey // PEM string — store safely
mat.dkim.dnsName // 's202604._domainkey.example.com'
mat.dkim.dnsValue // DNS TXT value
mat.mtaSts.policy // policy file contents
mat.mtaSts.policyUrl // https://mta-sts.example.com/.well-known/mta-sts.txt
mat.mtaSts.serve // http(s).createServer handler — serves the policy
mat.tlsRpt.value // 'v=TLSRPTv1; rua=...'
mat.requiredDNS // [{type, name, value}, ...] — all records to create
// Verify your DNS setup:
mat.verifyDNS((err, results) => {
// { dkim: true, spf: true, dmarc: true, mx: true, mtaSts: true, tlsRpt: true }
});Mount the MTA-STS HTTP handler on any Node HTTP server:
import http from 'node:http';
import https from 'node:https';
// On HTTPS for the real mta-sts.example.com subdomain:
https.createServer({ cert, key }, mat.mtaSts.serve).listen(443);
// Or compose with Express/fastify/etc — it's just a (req, res) handler.| RFC | Title | Support |
|---|---|---|
| SMTP | ||
| RFC 5321 | Simple Mail Transfer Protocol | ✅ Full |
| RFC 3207 | SMTP STARTTLS | ✅ |
| RFC 4954 | SMTP AUTH | ✅ PLAIN, LOGIN, XOAUTH2 |
| RFC 6152 | 8BITMIME | ✅ |
| RFC 2920 | PIPELINING | ✅ |
| RFC 3030 | CHUNKING (BDAT) | ✅ |
| RFC 1870 | SMTP SIZE | ✅ |
| RFC 2034 | ENHANCEDSTATUSCODES | ✅ |
| RFC 6531 | SMTPUTF8 (internationalized addresses) | ✅ |
| RFC 5890 | Internationalized Domain Names (IDN) | ✅ — Punycode fallback |
| RFC 8689 | REQUIRETLS | ✅ |
| RFC 3461 | Delivery Status Notifications (SMTP) | ✅ — NOTIFY / ORCPT / RET / ENVID |
| RFC 3464 | DSN format | ✅ — buildDsn() generates compliant messages |
| RFC 8461 | MTA-STS | ✅ — policy + DNS + HTTP handler |
| RFC 8460 | TLS-RPT | ✅ — DNS record generation |
| IMAP | ||
| RFC 3501 / 9051 | IMAP4rev1 / IMAP4rev2 | ✅ Core |
| RFC 4315 | UIDPLUS | ✅ |
| RFC 2088 / 7888 | LITERAL+ / LITERAL- | ✅ |
| RFC 5161 | ENABLE | ✅ |
| RFC 7162 | CONDSTORE / QRESYNC | ✅ |
| RFC 5256 | SORT / THREAD | ✅ — ORDEREDSUBJECT, REFERENCES |
| RFC 2177 | IDLE | ✅ — with push primitives |
| RFC 6154 | SPECIAL-USE | ✅ |
| RFC 5258 | LIST-EXTENDED | ✅ |
| RFC 5819 | LIST-STATUS | ✅ |
| RFC 5032 | WITHIN (OLDER / YOUNGER) | ✅ |
| RFC 6851 | MOVE | ✅ (opt-in) |
| RFC 2342 | NAMESPACE | ✅ |
| RFC 9208 | QUOTA | ✅ (opt-in) |
| RFC 5464 | METADATA | ✅ (opt-in) |
| RFC 4978 | COMPRESS=DEFLATE | ✅ |
| POP3 | ||
| RFC 1939 | POP3 | ✅ |
| RFC 2449 | CAPA | ✅ |
| RFC 2595 | STLS | ✅ |
| RFC 5034 | SASL | ✅ |
| Auth & Security | ||
| RFC 6376 | DKIM | ✅ Sign + verify (RSA-SHA256, Ed25519-SHA256) |
| RFC 7208 | SPF | ✅ ip4, ip6, a, mx, include, redirect, ptr |
| RFC 7489 | DMARC | ✅ alignment + org-domain fallback |
| RFC 8617 | ARC | ⏳ Planned |
When a message is received on port 25, all checks run automatically and in parallel before mail.accept() can be called:
- Reverse DNS — FCrDNS (forward-confirmed reverse) + EHLO hostname match
- SPF — authorize the sending IP against the envelope sender's domain
- DKIM — verify every signature in the message; require at least one from the From domain for alignment
- DMARC — evaluate policy (none / quarantine / reject) with SPF-or-DKIM alignment
Results arrive on mail.auth = { dkim, spf, dmarc, rdns, dmarcPolicy }. The library does not auto-reject — it gives you the data so you decide what to do.
When a domain is registered via server.addDomain(material), every outbound message from that domain is automatically DKIM-signed using the key from material.dkim. No per-message configuration needed.
- STARTTLS + implicit TLS on every protocol (SMTP 25/587/465, IMAP 143/993, POP3 110/995)
- SNI support — multi-domain on a single port with
SNICallback - TLS context caching with
server.clearTlsCache()for Let's Encrypt rotation - MTA-STS enforcement —
server.on('secure')exposes negotiated version/cipher - REQUIRETLS — refuse to deliver sensitive mail over cleartext
- TLS-RPT — receive daily reports about TLS failures from other MTAs
- SMTP smuggling protection (RFC 5321 §4.1.1.4) — bare LF normalization prevents CVE-2023-51764-class attacks
- Per-IP rate limiting —
{ rateLimit: { perMinute, perHour, banDuration } }increateServer - Graceful shutdown —
server.close()drains sessions withcloseTimeout - Backpressure handling — server respects TCP flow control; slow clients don't OOM the process
- PROXY protocol v1 — trust the real client IP when behind HAProxy / Nginx
- Auth timeout — unauthenticated sessions time out (configurable)
- Size limits — per-message
maxSize(default 25 MB) enforced before body arrives
npm test # full suite — 360 assertions across 13 files
node tests/test_imap_unit.mjs # run individual suites
node tests/test_smtputf8.mjs
node tests/test_dsn.mjs
# ...Current test counts:
| Suite | Tests |
|---|---|
imap_unit |
37 |
imap_session |
14 |
server_integration |
60 |
idle_push |
11 |
pop3_server |
42 |
pop3_client |
29 |
xoauth2 |
27 |
compress |
9 |
smtputf8 |
23 |
requiretls |
7 |
dsn |
43 |
metadata |
16 |
mta_sts |
42 |
| Total | 360 |
src/
server.js Main server — integrates all protocols, TLS, domains, DSN
smtp_session.js SMTP session — server + client, SMTPUTF8, REQUIRETLS
smtp_client.js sendMail + deliverToDomain with IDN negotiation
smtp_wire.js SMTP wire parsers (NOTIFY, ORCPT, xtext, ESMTP params)
imap_session.js IMAP session — server + client core + dispatcher
imap_folders.js IMAP folder ops (LIST-EXTENDED, QUOTA, STATUS)
imap_messages.js IMAP message ops (FETCH, STORE, COPY)
imap_search.js IMAP SEARCH + SORT + THREAD + WITHIN
imap_metadata.js IMAP METADATA (RFC 5464)
imap_wire.js IMAP wire parsers + serializers
pop3_session.js POP3 session — server + client + SASL + XOAUTH2
dsn.js Delivery Status Notification builder
domain.js buildDomainMailMaterial, MTA-STS, TLS-RPT
dkim.js DKIM sign + verify
spf.js SPF evaluation
dmarc.js DMARC evaluation
message.js MIME compose + parse
pool.js Outbound connection pool
dns_cache.js Shared DNS cache (TXT/A/AAAA/MX/PTR) with IDN normalize
rate_limit.js Per-IP rate limiter
utils.js IDN helpers, address utilities, UTF-8
examples/
demo_server.js Full IMAP + SMTP reference server (Thunderbird-tested)
demo_db.js In-memory backend for the demo
tests/
test_*.mjs One file per feature area (13 files, 360 tests)
Once you understand the scope, here's how email-server fits against popular alternatives:
| email-server | nodemailer | smtp-server | imapflow | Haraka | |
|---|---|---|---|---|---|
| SMTP server | ✅ | — | ✅ | — | ✅ |
| SMTP client | ✅ | ✅ | — | — | — |
| IMAP server | ✅ | — | — | — | plugin |
| IMAP client | ✅ | — | — | ✅ | — |
| POP3 server | ✅ | — | — | — | — |
| POP3 client | ✅ | — | — | — | — |
| DKIM sign/verify | ✅ | sign only | — | — | plugin |
| SPF / DMARC / rDNS | ✅ | — | — | — | plugin |
| XOAUTH2 (all protocols) | ✅ | client | — | client | — |
| IDLE push primitives | ✅ | — | — | client | — |
| DSN generation | ✅ | — | — | — | plugin |
| MTA-STS + TLS-RPT setup | ✅ | — | — | — | — |
| SMTPUTF8 + IDN | ✅ | ✅ | ✅ | partial | ✅ |
| REQUIRETLS | ✅ | — | — | — | — |
| Dependencies | 1 | many | 6 | many | many |
When to choose email-server: you want one library for both sides of every protocol with mail authentication built in — a complete mail stack in a single package.
When another library fits better: if you only need to send transactional mail through an external SMTP relay, nodemailer has a larger ecosystem of transports and templating integrations. If you only need a customizable MTA and don't mind plugin sprawl, Haraka has a rich plugin library. If you only need an IMAP client for a single inbox, imapflow has a Promise-first API.
✅ = Completed ⏳ = Planned
| Category | Item |
|---|---|
| SMTP | Inbound, submission, implicit TLS |
| SMTP | STARTTLS (server + client) |
| SMTP | AUTH PLAIN / LOGIN / XOAUTH2 |
| SMTP | 8BITMIME, SMTPUTF8, PIPELINING, ENHANCEDSTATUSCODES, SIZE, CHUNKING |
| SMTP | REQUIRETLS (RFC 8689) |
| SMTP | DSN (RFC 3461/3464) — parse + generate |
| SMTP | SMTP smuggling protection, PROXY protocol v1 |
| SMTP client | Direct MX + relay, connection pool with RSET reuse |
| SMTP client | Retry with backoff, per-domain rate limiting |
| SMTP client | IDN Punycode fallback when peer lacks SMTPUTF8 |
| IMAP server | RFC 3501 full, UIDPLUS, LITERAL+, ENABLE |
| IMAP server | CONDSTORE, QRESYNC (RFC 7162) |
| IMAP server | SORT, THREAD (RFC 5256) |
| IMAP server | IDLE with push primitives |
| IMAP server | MOVE (RFC 6851), NAMESPACE, SPECIAL-USE |
| IMAP server | LIST-EXTENDED (RFC 5258), LIST-STATUS (RFC 5819), WITHIN (RFC 5032) |
| IMAP server | QUOTA (RFC 9208) |
| IMAP server | METADATA (RFC 5464) |
| IMAP server | COMPRESS=DEFLATE (RFC 4978) |
| IMAP client | Full IMAPSession({isServer:false}) with all extensions |
| POP3 server | RFC 1939 + CAPA + STLS + SASL + XOAUTH2 |
| POP3 client | Full POP3Session({isServer:false}) |
| Auth | DKIM sign + verify (RSA-SHA256, Ed25519-SHA256) |
| Auth | SPF, DMARC, rDNS — all automatic on inbound |
| Security | MTA-STS (RFC 8461) generation + HTTP handler |
| Security | TLS-RPT (RFC 8460) generation |
| Security | Multi-domain TLS with SNI caching |
| MIME | Compose + parse (nodemailer-compatible) |
| Architecture | Unified auth / smtpSession / mailboxSession event model |
| Architecture | Bring-your-own-storage — library never persists messages |
| Architecture | Listener-gated capabilities — honest CAPABILITY advertising |
| Tooling | 360 tests, buildDomainMailMaterial(), Thunderbird demo |
| Packaging | Zero dependencies (node: builtins + flat-ranges) |
| Item | Notes |
|---|---|
| Well-known services | { service: 'gmail' | 'outlook' | 'icloud' } presets |
| Autoconfig / Autodiscover | RFC 6186 SRV + Mozilla ISPDB + MS Autodiscover |
| ARC (RFC 8617) | Authenticated Received Chain for forwarded mail |
| BIMI | Brand indicators for message identification |
| SIEVE (RFC 5228) | Server-side mail filtering |
| TypeScript types | Comprehensive index.d.ts |
| Benchmarks | Throughput, memory, concurrent connections |
Pull requests are welcome!
Please open an issue before submitting major changes.
This project is part of the colocohen Node.js infrastructure stack (QUIC, WebRTC, DNSSEC, TLS, and more).
You can support ongoing development via GitHub Sponsors.
- RFC 5321 — SMTP
- RFC 3501 — IMAP4rev1
- RFC 1939 — POP3
- RFC 6376 — DKIM
- RFC 7208 — SPF
- RFC 7489 — DMARC
- RFC 8461 — MTA-STS
- RFC 8460 — TLS-RPT
- RFC 3461 — DSN
- RFC 6531 — SMTPUTF8
- Email Authentication Best Practices (M3AAWG)
Apache License 2.0
Copyright © 2025 colocohen
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.