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.
- 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.
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.
Requires Node.js 22.19.0 or newer.
npm install milterES modules:
import { MilterServer, Decision } from "milter";CommonJS:
const { MilterServer, Decision } = require("milter");TypeScript declarations are included.
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.mjsFor 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 = acceptThen reload Postfix:
postfix reloadThe 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 installChoose 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:dbThe 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.
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.
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.
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.
| 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.
| 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. |
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.
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.
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')dnlRebuild and reload the Sendmail configuration using your operating system's procedure.
Install dependencies and build:
npm install
npm run buildThe build generates ESM, CommonJS, and TypeScript declaration files in dist.
Run linting, type checks, the build, and tests:
npm run checkRun only the tests:
npm testThis 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.
Development and maintenance of Milter.js are supported by Maail.
Maail is a privacy-first European email intelligence API. Its support helps keep Milter.js maintained and freely available.
Copyright (c) 2026, Robert Eisele
Licensed under the MIT license.