Skip to content

Repository files navigation

Milter.js

Milter.js

NPM Package MIT License

Make your mail server programmable with JavaScript.

Your mail server already knows how to receive and deliver email. Your application knows your users, your data, and your rules. Connecting the two should be simple.

Want to check a sender against your database before accepting a message? Ask an AI model whether an email is spam? Collect DMARC reports, inspect attachments, or mark messages for delivery to a particular folder? The tools are already available in Node.js. Milter.js brings them into your mail server's processing flow.

Milter.js lets you inspect and act on email as it arrives. Write ordinary JavaScript handlers for the sender, recipients, headers, or body. Call an API, query a database, or use an npm package, then decide what happens next.

server.on("bodyEnd", async (body, ctx) => {
  const spam = await classifyMessage(body, ctx.headers);

  if (spam) {
    ctx.addHeader("X-Spam-Flag", "YES");
  }

  return "accept";
});

Here, classifyMessage is your own function. It could use a few rules, a local model, or an external service. Milter.js connects that logic to the mail server.

What You Can Build

  • Custom spam filters: combine your own rules with OpenAI, Ollama, or another classifier.
  • Sender checks: use SPF and DKIM results, domain intelligence, or your own allowlists and blocklists.
  • Mail automation: collect reports and pass message data to your applications.
  • Message routing: add headers that your delivery system uses to select a mailbox or folder.
  • Message rewriting: update headers, change recipients or the envelope sender, and replace message bodies.

You control the policy. Milter.js handles the Milter protocol, connection state, and communication with the mail server.

How It Works

A milter is a program a mail server consults while processing an email. The mail server reports each requested stage of the SMTP transaction, and the filter returns a decision.

With Milter.js, those stages become JavaScript events:

Event What you receive
connect Information about the sending connection
helo The sender's HELO or EHLO name
mail The envelope sender
rcpt An envelope recipient
headers The message headers
bodyEnd The collected message body

Register the events you need. Handlers can be synchronous or asynchronous.

Milter.js runs alongside a Milter-compatible mail server such as Postfix or Sendmail. The mail server handles SMTP and delivery; your handlers provide the filtering logic. MIME parsing and spam classification can be added through the packages or services you choose.

Installation

Requires Node.js 22.19.0 or newer.

npm install milter

ES modules:

import { MilterServer, Decision } from "milter";

CommonJS:

const { MilterServer, Decision } = require("milter");

TypeScript declarations are included.

Your First Filter

Save this as filter.mjs:

import { MilterServer } from "milter";

const server = new MilterServer({
  host: "127.0.0.1",
  port: 7357
});

server.on("mail", (from) => {
  console.log("Envelope sender:", from[0]);
  return "continue";
});

server.on("headers", (headers) => {
  console.log("Subject:", headers.subject ?? []);
  return "continue";
});

server.on("bodyEnd", (_body, ctx) => {
  ctx.addHeader("X-Processed-By", "Milter.js");
  return "accept";
});

server.on("error", (error, ctx) => {
  console.error("Milter error", ctx?.id, error);
});

await server.listen();
console.log("Milter listening on 127.0.0.1:7357");

Run it:

node filter.mjs

For Postfix, add these settings to main.cf:

smtpd_milters = inet:127.0.0.1:7357
non_smtpd_milters = inet:127.0.0.1:7357
milter_protocol = 6
milter_default_action = accept

Then reload Postfix:

postfix reload

The example adds an X-Processed-By header to messages that pass through the filter. milter_default_action = accept tells Postfix to keep accepting mail if the filter is unavailable; choose tempfail if mail should wait until filtering is available again.

Making Decisions

Return a decision from an event handler:

Return value Meaning
"continue" Continue filtering at the next stage.
"accept" Accept and stop further filtering by this milter for the current message.
"reject" Reject permanently.
"tempfail" Defer delivery so the sending server can retry later.
"discard" Accept the SMTP transaction, then silently discard the message.
undefined Use defaultDecision, which is "continue" by default.

Use "continue" when later handlers still need to inspect the message. For example, accepting at the sender stage ends this milter's filtering before its body handler runs.

The Decision helper provides equivalent reply objects and custom SMTP replies:

import { Decision } from "milter";

server.on("rcpt", (to) => {
  const address = (to[0] ?? "").replace(/^<|>$/g, "");

  if (address === "retired@example.com") {
    return Decision.replyCode(
      "550",
      "This mailbox no longer accepts mail",
      "5.1.1"
    );
  }

  return Decision.continue();
});

Decision.accept(), reject(), tempfail(), and discard() are also available.

Working with Messages

Check the Sender

The mail event receives the decoded MAIL FROM fields as an array. The first field contains the envelope sender.

server.on("mail", (from) => {
  const address = (from[0] ?? "")
    .replace(/^<|>$/g, "")
    .toLowerCase();

  if (address.endsWith("@blocked.example")) {
    return "reject";
  }

  return "continue";
});

The envelope sender is separate from the visible From header.

Read Headers

Header names are lowercase, and each value is an array because a header can appear more than once.

server.on("headers", (headers) => {
  console.log("Subject:", headers.subject ?? []);
  console.log("Content-Type:", headers["content-type"] ?? []);
});

Use headerLine(name, value, ctx) to process headers individually.

Inspect the Body

By default, Milter.js collects body chunks and passes the complete body to bodyEnd as a Buffer:

server.on("bodyEnd", (body, ctx) => {
  console.log("Body bytes:", body.length);
  console.log("Headers:", ctx.headers);
  return "accept";
});

This is the raw message body, which can contain MIME parts and encoded content. Use a MIME parser when you need decoded text or attachments.

Modify a Message

Request the actions your filter needs, then make changes in bodyEnd:

import { MilterServer, SMFIF } from "milter";

const server = new MilterServer({
  host: "127.0.0.1",
  port: 7357,
  actions:
    SMFIF.ADDHDRS |
    SMFIF.CHGHDRS |
    SMFIF.ADDRCPT |
    SMFIF.DELRCPT
});

server.on("bodyEnd", (_body, ctx) => {
  ctx.addHeader("X-Filtered", "yes");
  ctx.changeHeader("Subject", 1, "[Filtered] Message");
  ctx.addRecipient("archive@example.com");
  ctx.deleteRecipient("old-address@example.com");
  return "accept";
});

await server.listen();

The mail server must support and agree to each requested action. By default, MilterServer requests ADDHDRS | CHGBODY | ADDRCPT.

Deliver Spam to a Folder

A filter can mark a message as spam and still accept it. The delivery system can then place it in Junk.

The included LLM spam-filter examples remove incoming copies of their spam-result headers before writing their own results. Classified spam receives headers such as:

X-Spam-Flag: YES
X-Spam-Status: Yes
X-Spam-Score: 0.950
X-LLM-Spam-Action: move-to-spam

A Dovecot Sieve rule can use that result:

require ["fileinto"];

if header :is "X-LLM-Spam-Action" "move-to-spam" {
  fileinto "Junk";
  stop;
}

Install and compile the rule according to your Dovecot configuration. Ensure that routing headers are set by your trusted filter rather than accepted unchanged from incoming mail.

See the Postfix and Dovecot routing guide for a complete setup.

SPF Checks

Enable SPF checking with useSpf():

server.useSpf({ mta: "mx.receiver.example" });

server.on("mail", (_from, ctx) => {
  const result = ctx.spf.status.result;

  if (result === "fail") {
    return "reject";
  }

  if (result === "temperror") {
    return "tempfail";
  }

  return "continue";
});

The check runs before your mail handler and stores its result in ctx.spf. It does not accept or reject mail automatically: your handler decides how to use the result.

For a configurable decision mapping:

import { spfDecision } from "milter";

server.on("mail", (_from, ctx) => spfDecision(ctx.spf, {
  fail: "reject",
  temperror: "tempfail"
}));

Use either approach above. The result remains available in subsequent handlers. For explicit control over when checking runs, use the exported checkSpf(from, ctx, options) helper.

DKIM Verification and Signing

Verify Incoming Messages

server.useDkim();

server.on("bodyEnd", (_body, ctx) => {
  const passed = ctx.dkim.results.some((signature) => (
    signature.status.result === "pass"
  ));

  console.log("Has a passing DKIM signature:", passed);
  return "continue";
});

Verification runs before bodyEnd and stores its results in ctx.dkim. As with SPF, it leaves the decision to your handler. This example records the result without requiring every message to have a passing signature.

Body collection must remain enabled. Use verifyDkim(body, ctx, options) if you want to run verification explicitly.

Sign a Message

signDkim() signs a complete RFC 822 message, including its headers and body:

import fs from "node:fs";
import { signDkim } from "milter";

const message = fs.readFileSync("./message.eml", "utf8");

const result = await signDkim(message, {
  signingDomain: "example.com",
  selector: "mail",
  privateKey: fs.readFileSync("./dkim-private.pem")
});

if (result.errors.length > 0) {
  throw result.errors[0];
}

const signedMessage = result.signatures + message;

result.signatures contains complete DKIM-Signature header lines, including their terminating line breaks. Finish changes to the message before signing; later changes to signed content can invalidate the signature.

Repository Examples

The repository includes examples for inspecting Milter events, collecting DMARC reports, storing reports in JSONL or a database, classifying spam, and checking domains with the domaindata API.

After cloning the repository:

npm install

Choose an example:

Command Example
npm run example:dmarc Collect DMARC aggregate reports.
npm run example:dmarc:db Store reports through a JSONL, MySQL, or PostgreSQL adapter.
npm run example:chatgpt-spamfilter Classify spam with OpenAI.
npm run example:ollama-spamfilter Classify spam with Ollama.
npm run example:domaindata-holo-check Check domains with the domaindata API.

Choose a DMARC storage adapter with environment variables:

DMARC_DB=jsonl npm run example:dmarc:db

DMARC_DB=mysql MYSQL_HOST=127.0.0.1 MYSQL_USER=master MYSQL_PASSWORD=secret MYSQL_DB=mail npm run example:dmarc:db

DMARC_DB=postgres PGHOST=127.0.0.1 PGUSER=postgres PGPASSWORD=secret PGDATABASE=mail npm run example:dmarc:db

The spam-filter examples include opinionated rules before the AI check: decoded Cyrillic or Han characters and attachments ending in .exe, .bin, or .html trigger an SMTP 550 rejection without calling the model. Adapt these example policies to your users. They are not default filtering rules imposed by Milter.js.

API Reference

Events

Protocol handlers receive the event payload first and MilterContext last.

Event signature Description
connect(info, ctx) New SMTP connection. info contains hostname, family, and, where available, address and port.
helo(helo, ctx) HELO or EHLO name.
mail(from, ctx) Decoded MAIL FROM fields as a string array; also available as ctx.from.
rcpt(to, ctx) Decoded RCPT TO fields as a string array; the latest is also available as ctx.to.
headerLine(name, value, ctx) One message header.
headers(headers, ctx) End of headers, with a cloned map of lowercase names to arrays of values.
bodyChunk(chunk, ctx) One body chunk as a Buffer.
bodyEnd(body, ctx) End of message, with the collected body. Use this stage for message mutations.
data(raw, ctx) Milter DATA payload decoded as UTF-8 text.
macro(command, values, ctx) MTA macros as a command byte and a string-to-string map.
abort(ctx) Current message aborted; message-specific context state is reset.
close(ctx) Milter session closed.
unknown(command, data, ctx) Unsupported or unknown protocol command and its raw payload.
error(error, ctx) Server, socket, parser, handler, or body-limit error. ctx may be undefined.

header remains an alias for headers; use headers in new filters.

Milter.js requests registered protocol callbacks during negotiation, so a filter does not need to subscribe to every stage.

Multiple Handlers and Replies

Handlers for the same event run in registration order. The last handler's return value determines the protocol decision. An earlier handler returning "reject" does not make it the final decision if another handler follows it. Keep the decision in one handler per event when combining checks.

In addition to decision strings, handlers can return reply objects such as { command, data? }. Returning null deliberately sends no protocol response; it is an advanced option, not an alias for "continue".

Context methods such as ctx.reject() write a response immediately. Do not combine an immediate context decision with a returned decision for the same event.

MilterContext

Each connection has one context object. Message-related fields describe the current message.

Property or method Description
id Monotonically increasing connection identifier.
socket Node.js socket, or null after disconnect.
headers Lowercase header map with arrays of values.
macros MTA macros grouped by protocol command.
from Current envelope sender fields.
to Most recent envelope recipient fields.
spf SPF result when SPF checking is enabled.
dkim DKIM results when verification is enabled.
getPhase() Current protocol phase.
can(action) Whether the MTA negotiated an action.
getBody() Collected body as a Buffer.

Immediate response methods:

ctx.continue();
ctx.accept();
ctx.reject();
ctx.discard();
ctx.tempfail();
ctx.replyCode(code, message, enhancedCode);
ctx.progress();

Message mutation methods:

ctx.addHeader(name, value);
ctx.insertHeader(index, name, value);
ctx.changeHeader(name, index, value);
ctx.addRecipient(address);
ctx.deleteRecipient(address);
ctx.replaceBody(chunk);
ctx.quarantine(reason);
ctx.setSender(sender);

The value argument of changeHeader() and the reason argument of quarantine() are optional.

Mutation helpers check negotiated capabilities and, by default, the current protocol phase. Invalid calls throw MilterActionError with code E_MILTER_ACTION_CAPABILITY or E_MILTER_ACTION_STAGE.

enforceActionStages: false disables stage checks. Capability checks remain active.

Server Options

Option Default Description
socketPath — UNIX socket path. Required if port is absent.
host 127.0.0.1 TCP bind address.
port — TCP port. Required if socketPath is absent.
actions ADDHDRS | CHGBODY | ADDRCPT Requested mutation capabilities.
unlinkOnStart true Remove an existing UNIX socket before listening.
chmod 0o777 UNIX socket permissions; false leaves permissions unchanged.
collectBody true Collect body chunks for bodyEnd.
maxBodyBytes 33554432 Maximum collected body size: 32 MiB.
defaultDecision "continue" Decision when a handler returns undefined.
enforceActionStages true Check that mutations happen at a valid stage.
logger ConsoleLogger Logger implementing debug, info, warn, and error.

For streaming filters, disable collection and process chunks as they arrive:

const server = new MilterServer({
  host: "127.0.0.1",
  port: 7357,
  collectBody: false
});

server.on("bodyChunk", (chunk) => {
  console.log("Received body chunk:", chunk.length);
  return "continue";
});

With collectBody: false, the buffer passed to bodyEnd is empty. DKIM verification requires body collection.

Server Methods

Method Description
on(event, handler) Register a handler; returns the server.
once(event, handler) Register a handler that removes itself before its first invocation.
off(event, handler) Remove a handler; returns the server.
listen(socketPathOverride?) Start listening; an optional argument overrides the UNIX socket path.
close() Stop accepting connections; resolves when the underlying Node.js server closes.

Compatibility Class

The Milter compatibility class enables ACTION_ALL unless you provide actions explicitly:

import { Milter } from "milter";

const server = new Milter({
  socketPath: "/run/example-milter.sock"
});

Prefer MilterServer with the specific actions your filter needs for new code.

Protocol Constants

Constants are available from milter and milter/constants:

import {
  ACTION_ALL,
  SMFI_VERSION,
  SMFIA,
  SMFIC,
  SMFIF,
  SMFIP,
  SMFIR
} from "milter/constants";
Constant Meaning
SMFIA Connection address families.
SMFIC Commands sent by the mail server.
SMFIR Replies sent by the filter.
SMFIF Message mutation capabilities.
SMFIP Callback suppression flags.
ACTION_ALL All supported mutation capabilities combined.
SMFI_VERSION Supported protocol version: 6.

Low-level frame() and send() helpers are also exported for protocol tooling and tests.

UNIX Sockets and Sendmail

For a local mail server, you can use a UNIX socket instead of TCP:

const server = new MilterServer({
  socketPath: "/run/example-milter.sock"
});

await server.listen();

Ensure the mail server can access the socket and its parent directories. If Postfix runs chrooted, the socket must be visible inside that chroot. The TCP configuration in the quick start avoids UNIX socket path differences.

For Sendmail, reference the same socket in sendmail.mc:

INPUT_MAIL_FILTER(`example', `S=local:/run/example-milter.sock, F=T, T=S:4m;R:4m;E:10m')dnl

Rebuild and reload the Sendmail configuration using your operating system's procedure.

Development

Install dependencies and build:

npm install
npm run build

The build generates ESM, CommonJS, and TypeScript declaration files in dist.

Run linting, type checks, the build, and tests:

npm run check

Run only the tests:

npm test

Version Compatibility

This README describes the API in this repository. When using a published npm release, refer to the documentation for that version: event names, defaults, and mutation helpers can differ between releases.

Sponsor

Development and maintenance of Milter.js are supported by Maail.

Maail

Maail is a privacy-first European email intelligence API. Its support helps keep Milter.js maintained and freely available.

Copyright and Licensing

Copyright (c) 2026, Robert Eisele

Licensed under the MIT license.

About

Milter server for asynchronous Node.js mail filters and message mutation

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages