raw Software

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.

View Milter.js on GitHub

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.

Message flow through Postfix and Milter.js Postfix sends envelope, header, and body events to a Node.js filter, which returns decisions and optional message changes before Postfix queues the message. Postfix SMTP owns the transaction events decision + actions Milter.js Node.js policy service typed events and helpers Postfix Queue routing and delivery

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