Milter.js is a typed implementation of the Milter protocol for Node.js. It lets a mail transfer agent such as Postfix stream an SMTP transaction into an event-driven JavaScript service, inspect envelope data, headers, and body content, then continue, accept, reject, temporarily fail, or modify the message. The package provides native ECMAScript modules, CommonJS builds, and TypeScript declarations from one API.
A milter is not an SMTP server and does not replace Postfix. Postfix remains responsible for SMTP state, queueing, routing, and delivery. The filter receives protocol callbacks over a local Unix or TCP socket and returns a decision for each relevant stage.
Installation
Milter.js 1.0 requires Node.js 20.18.1 or newer. Install the package from npm:
npm install milter
The package name is milter; Milter.js is the project name. Named exports work with ESM, while the same package also exposes a CommonJS build and declarations for TypeScript-aware editors and compilers.
Create a Filter
The following service listens only on the loopback interface. It collects the subject, examines the complete body at end of message, adds a diagnostic header, and rejects a deliberately blocked domain. Returning continue keeps the SMTP transaction moving; returning accept at end of message gives control back to Postfix for normal queueing.
import {
Decision,
MilterServer,
SMFIF,
} from 'milter';
const server = new MilterServer({
host: '127.0.0.1',
port: 8892,
actions: SMFIF.ADDHDRS,
maxBodyBytes: 8 * 1024 * 1024,
});
server.on('headerLine', (name, value, ctx) => {
if (name.toLowerCase() === 'subject') {
console.log(ctx.id, 'subject:', value);
}
return Decision.continue();
});
server.on('bodyEnd', (body, ctx) => {
const text = body.toString('utf8');
if (text.includes('forbidden.example')) {
ctx.replyCode('550', 'Message rejected by local policy');
return Decision.reject();
}
ctx.addHeader('X-Processed-By', 'Milter.js');
return Decision.accept();
});
server.on('error', (error, ctx) => {
console.error('Milter error', ctx?.id, error);
});
await server.listen(); A Unix socket can be used instead by passing socketPath. Milter.js removes a stale socket before listening by default and can apply a configured file mode. TCP on 127.0.0.1 avoids Postfix chroot path translation; it must never be exposed to an untrusted network.
Transaction Events
Handlers receive the event payload first and a per-connection MilterContext last. The event sequence follows the SMTP transaction rather than an HTTP-style request model:
| Event | Available information | Typical use |
|---|---|---|
| connect, helo | Peer address, host name, HELO/EHLO value | Connection policy and reputation lookup |
| mail, rcpt | Envelope sender and recipients | Relay, sender, and recipient policy |
| headerLine, headers | Individual fields or a cloned header map | Authentication context and metadata checks |
| bodyChunk | One message-body chunk | Streaming scanners and size-aware processing |
| bodyEnd | The collected body when collection is enabled | Final classification and message mutation |
| macro | MTA-provided macro values | Daemon name, authenticated identity, or queue context |
| abort, close | Message or connection lifecycle | Release per-connection state |
Handlers may be synchronous or asynchronous. A connection is processed sequentially, so an awaited DNS query or classifier preserves callback order for that transaction. Long-running work still needs explicit timeouts because the remote SMTP session remains open while the filter is deciding.
Decisions and Message Actions
The Decision helpers encode the common protocol replies: continue, accept, reject, discard, and tempfail. Returning undefined uses the configured default decision. Returning null deliberately sends no response and is appropriate only when protocol control is handled elsewhere.
Message changes are negotiated capabilities, not unconditional local mutations. Declare every operation the filter may perform in the actions bitmask:
const server = new MilterServer({
host: '127.0.0.1',
port: 8892,
actions:
SMFIF.ADDHDRS |
SMFIF.CHGHDRS |
SMFIF.ADDRCPT |
SMFIF.DELRCPT,
}); Context helpers can add or insert headers, change an existing header, add or delete recipients, replace body data, change the envelope sender, and request administrative quarantine. Milter.js checks both the negotiated capability and the protocol phase. End-of-message mutations such as addHeader() or changeHeader() throw a MilterActionError when used too early or without permission.
Connect Postfix
Attach the loopback listener to inbound SMTP in /etc/postfix/main.cf:
milter_protocol = 6
milter_default_action = accept
smtpd_milters = inet:127.0.0.1:8892 Multiple filters are comma-separated and run in order. Authentication filters should normally inspect the original message before a later content filter changes headers or body data. Authenticated submission can override smtpd_milters in master.cf when an inbound-only policy must not process outgoing customer mail.
A Unix socket is equally valid, but its path is interpreted from Postfix's environment. On a chrooted service, place the socket below the Postfix spool and set ownership and mode so the Postfix process can connect without making the socket globally writable.
Resource and Failure Boundaries
Full-body collection is enabled by default and capped at 32 MiB. Lower maxBodyBytes to match the accepted mail size, or use bodyChunk with collectBody: false for scanners that can stream. MIME parsing, antivirus engines, network APIs, and language models require their own byte, time, and concurrency limits.
Choose failure behavior deliberately. milter_default_action = accept keeps mail flowing when the process is unavailable but allows unfiltered delivery. tempfail asks senders to retry but can grow queues during a prolonged outage. A content classifier can fail open, while a filter enforcing a non-negotiable security boundary may need a bounded temporary failure instead.
Development and Compatibility
The package targets Milter protocol version 6 and exports protocol constants from both the package root and milter/constants. The source is written in TypeScript and builds dual ESM/CJS artifacts. Run the project checks after changing protocol parsing or action handling:
npm install npm run check
Unit tests should verify encoded frames as well as business decisions. For integration testing, run Postfix against a loopback test listener and inspect both SMTP replies and the final queued message; a correct callback return value does not by itself prove that negotiated message mutations were emitted correctly.
References
- [MilterJS] Milter.js source code and API documentation.
- [PostfixMilter] Postfix before-queue Milter support.
- [SendmailMilter] Sendmail libmilter documentation.