milter 1.0.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +593 -118
- package/dist/constants.cjs +105 -0
- package/dist/constants.cjs.map +1 -0
- package/dist/constants.d.cts +63 -0
- package/dist/constants.d.ts +63 -0
- package/dist/constants.js +73 -0
- package/dist/constants.js.map +1 -0
- package/dist/index.cjs +976 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +187 -0
- package/dist/index.d.ts +187 -0
- package/dist/index.js +914 -0
- package/dist/index.js.map +1 -0
- package/package.json +116 -27
- package/res/maail-logo.svg +11 -0
- package/res/milterjs-logo.svg +79 -0
- package/.npmignore +0 -4
- package/binding.gyp +0 -13
- package/lib/milter.js +0 -1
- package/src/envelope.cc +0 -592
- package/src/envelope.h +0 -92
- package/src/events.cc +0 -426
- package/src/events.h +0 -303
- package/src/forward.h +0 -10
- package/src/milter.cc +0 -722
- package/src/milter.h +0 -76
package/README.md
CHANGED
|
@@ -1,154 +1,629 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
1
|
+
|
|
2
|
+

|
|
3
|
+
|
|
4
|
+
# Milter.js
|
|
5
|
+
|
|
6
|
+
[](https://www.npmjs.com/package/milter)
|
|
7
|
+
[](https://opensource.org/licenses/MIT)
|
|
8
|
+
|
|
9
|
+
**Make your mail server programmable with JavaScript.**
|
|
10
|
+
|
|
11
|
+
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.
|
|
12
|
+
|
|
13
|
+
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.
|
|
14
|
+
|
|
15
|
+
**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.
|
|
16
|
+
|
|
17
|
+
```javascript
|
|
18
|
+
server.on("bodyEnd", async (body, ctx) => {
|
|
19
|
+
const spam = await classifyMessage(body, ctx.headers);
|
|
20
|
+
|
|
21
|
+
if (spam) {
|
|
22
|
+
ctx.addHeader("X-Spam-Flag", "YES");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return "accept";
|
|
26
|
+
});
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
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.
|
|
30
|
+
|
|
31
|
+
## What You Can Build
|
|
32
|
+
|
|
33
|
+
- **Custom spam filters:** combine your own rules with OpenAI, Ollama, or another classifier.
|
|
34
|
+
- **Sender checks:** use SPF and DKIM results, domain intelligence, or your own allowlists and blocklists.
|
|
35
|
+
- **Mail automation:** collect reports and pass message data to your applications.
|
|
36
|
+
- **Message routing:** add headers that your delivery system uses to select a mailbox or folder.
|
|
37
|
+
- **Message rewriting:** update headers, change recipients or the envelope sender, and replace message bodies.
|
|
38
|
+
|
|
39
|
+
You control the policy. Milter.js handles the Milter protocol, connection state, and communication with the mail server.
|
|
40
|
+
|
|
41
|
+
## How It Works
|
|
42
|
+
|
|
43
|
+
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.
|
|
44
|
+
|
|
45
|
+
With Milter.js, those stages become JavaScript events:
|
|
46
|
+
|
|
47
|
+
| Event | What you receive |
|
|
48
|
+
| --- | --- |
|
|
49
|
+
| `connect` | Information about the sending connection |
|
|
50
|
+
| `helo` | The sender's HELO or EHLO name |
|
|
51
|
+
| `mail` | The envelope sender |
|
|
52
|
+
| `rcpt` | An envelope recipient |
|
|
53
|
+
| `headers` | The message headers |
|
|
54
|
+
| `bodyEnd` | The collected message body |
|
|
55
|
+
|
|
56
|
+
Register the events you need. Handlers can be synchronous or asynchronous.
|
|
57
|
+
|
|
58
|
+
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.
|
|
59
|
+
|
|
60
|
+
## Installation
|
|
61
|
+
|
|
62
|
+
Requires **Node.js 22.19.0 or newer**.
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npm install milter
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
ES modules:
|
|
69
|
+
|
|
70
|
+
```javascript
|
|
71
|
+
import { MilterServer, Decision } from "milter";
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
CommonJS:
|
|
75
|
+
|
|
76
|
+
```javascript
|
|
77
|
+
const { MilterServer, Decision } = require("milter");
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
TypeScript declarations are included.
|
|
81
|
+
|
|
82
|
+
## Your First Filter
|
|
83
|
+
|
|
84
|
+
Save this as `filter.mjs`:
|
|
85
|
+
|
|
86
|
+
```javascript
|
|
87
|
+
import { MilterServer } from "milter";
|
|
88
|
+
|
|
89
|
+
const server = new MilterServer({
|
|
90
|
+
host: "127.0.0.1",
|
|
91
|
+
port: 7357
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
server.on("mail", (from) => {
|
|
95
|
+
console.log("Envelope sender:", from[0]);
|
|
96
|
+
return "continue";
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
server.on("headers", (headers) => {
|
|
100
|
+
console.log("Subject:", headers.subject ?? []);
|
|
101
|
+
return "continue";
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
server.on("bodyEnd", (_body, ctx) => {
|
|
105
|
+
ctx.addHeader("X-Processed-By", "Milter.js");
|
|
106
|
+
return "accept";
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
server.on("error", (error, ctx) => {
|
|
110
|
+
console.error("Milter error", ctx?.id, error);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
await server.listen();
|
|
114
|
+
console.log("Milter listening on 127.0.0.1:7357");
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Run it:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
node filter.mjs
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
For Postfix, add these settings to `main.cf`:
|
|
124
|
+
|
|
125
|
+
```conf
|
|
126
|
+
smtpd_milters = inet:127.0.0.1:7357
|
|
127
|
+
non_smtpd_milters = inet:127.0.0.1:7357
|
|
128
|
+
milter_protocol = 6
|
|
129
|
+
milter_default_action = accept
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Then reload Postfix:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
postfix reload
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
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.
|
|
139
|
+
|
|
140
|
+
## Making Decisions
|
|
141
|
+
|
|
142
|
+
Return a decision from an event handler:
|
|
143
|
+
|
|
144
|
+
| Return value | Meaning |
|
|
145
|
+
| --- | --- |
|
|
146
|
+
| `"continue"` | Continue filtering at the next stage. |
|
|
147
|
+
| `"accept"` | Accept and stop further filtering by this milter for the current message. |
|
|
148
|
+
| `"reject"` | Reject permanently. |
|
|
149
|
+
| `"tempfail"` | Defer delivery so the sending server can retry later. |
|
|
150
|
+
| `"discard"` | Accept the SMTP transaction, then silently discard the message. |
|
|
151
|
+
| `undefined` | Use `defaultDecision`, which is `"continue"` by default. |
|
|
152
|
+
|
|
153
|
+
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.
|
|
154
|
+
|
|
155
|
+
The `Decision` helper provides equivalent reply objects and custom SMTP replies:
|
|
156
|
+
|
|
157
|
+
```javascript
|
|
158
|
+
import { Decision } from "milter";
|
|
159
|
+
|
|
160
|
+
server.on("rcpt", (to) => {
|
|
161
|
+
const address = (to[0] ?? "").replace(/^<|>$/g, "");
|
|
162
|
+
|
|
163
|
+
if (address === "retired@example.com") {
|
|
164
|
+
return Decision.replyCode(
|
|
165
|
+
"550",
|
|
166
|
+
"This mailbox no longer accepts mail",
|
|
167
|
+
"5.1.1"
|
|
49
168
|
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return Decision.continue();
|
|
172
|
+
});
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
`Decision.accept()`, `reject()`, `tempfail()`, and `discard()` are also available.
|
|
176
|
+
|
|
177
|
+
## Working with Messages
|
|
178
|
+
|
|
179
|
+
### Check the Sender
|
|
180
|
+
|
|
181
|
+
The `mail` event receives the decoded `MAIL FROM` fields as an array. The first field contains the envelope sender.
|
|
182
|
+
|
|
183
|
+
```javascript
|
|
184
|
+
server.on("mail", (from) => {
|
|
185
|
+
const address = (from[0] ?? "")
|
|
186
|
+
.replace(/^<|>$/g, "")
|
|
187
|
+
.toLowerCase();
|
|
188
|
+
|
|
189
|
+
if (address.endsWith("@blocked.example")) {
|
|
190
|
+
return "reject";
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return "continue";
|
|
194
|
+
});
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
The envelope sender is separate from the visible `From` header.
|
|
198
|
+
|
|
199
|
+
### Read Headers
|
|
200
|
+
|
|
201
|
+
Header names are lowercase, and each value is an array because a header can appear more than once.
|
|
202
|
+
|
|
203
|
+
```javascript
|
|
204
|
+
server.on("headers", (headers) => {
|
|
205
|
+
console.log("Subject:", headers.subject ?? []);
|
|
206
|
+
console.log("Content-Type:", headers["content-type"] ?? []);
|
|
207
|
+
});
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Use `headerLine(name, value, ctx)` to process headers individually.
|
|
211
|
+
|
|
212
|
+
### Inspect the Body
|
|
213
|
+
|
|
214
|
+
By default, Milter.js collects body chunks and passes the complete body to `bodyEnd` as a `Buffer`:
|
|
215
|
+
|
|
216
|
+
```javascript
|
|
217
|
+
server.on("bodyEnd", (body, ctx) => {
|
|
218
|
+
console.log("Body bytes:", body.length);
|
|
219
|
+
console.log("Headers:", ctx.headers);
|
|
220
|
+
return "accept";
|
|
221
|
+
});
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
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.
|
|
225
|
+
|
|
226
|
+
### Modify a Message
|
|
227
|
+
|
|
228
|
+
Request the actions your filter needs, then make changes in `bodyEnd`:
|
|
229
|
+
|
|
230
|
+
```javascript
|
|
231
|
+
import { MilterServer, SMFIF } from "milter";
|
|
232
|
+
|
|
233
|
+
const server = new MilterServer({
|
|
234
|
+
host: "127.0.0.1",
|
|
235
|
+
port: 7357,
|
|
236
|
+
actions:
|
|
237
|
+
SMFIF.ADDHDRS |
|
|
238
|
+
SMFIF.CHGHDRS |
|
|
239
|
+
SMFIF.ADDRCPT |
|
|
240
|
+
SMFIF.DELRCPT
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
server.on("bodyEnd", (_body, ctx) => {
|
|
244
|
+
ctx.addHeader("X-Filtered", "yes");
|
|
245
|
+
ctx.changeHeader("Subject", 1, "[Filtered] Message");
|
|
246
|
+
ctx.addRecipient("archive@example.com");
|
|
247
|
+
ctx.deleteRecipient("old-address@example.com");
|
|
248
|
+
return "accept";
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
await server.listen();
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
The mail server must support and agree to each requested action. By default, `MilterServer` requests `ADDHDRS | CHGBODY | ADDRCPT`.
|
|
255
|
+
|
|
256
|
+
### Deliver Spam to a Folder
|
|
257
|
+
|
|
258
|
+
A filter can mark a message as spam and still accept it. The delivery system can then place it in `Junk`.
|
|
259
|
+
|
|
260
|
+
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:
|
|
261
|
+
|
|
262
|
+
```text
|
|
263
|
+
X-Spam-Flag: YES
|
|
264
|
+
X-Spam-Status: Yes
|
|
265
|
+
X-Spam-Score: 0.950
|
|
266
|
+
X-LLM-Spam-Action: move-to-spam
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
A Dovecot Sieve rule can use that result:
|
|
270
|
+
|
|
271
|
+
```sieve
|
|
272
|
+
require ["fileinto"];
|
|
273
|
+
|
|
274
|
+
if header :is "X-LLM-Spam-Action" "move-to-spam" {
|
|
275
|
+
fileinto "Junk";
|
|
276
|
+
stop;
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
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.
|
|
281
|
+
|
|
282
|
+
See the [Postfix and Dovecot routing guide](https://raw.org/software/administration/llm-spam-filter-with-postfix-dovecot/) for a complete setup.
|
|
283
|
+
|
|
284
|
+
## SPF Checks
|
|
285
|
+
|
|
286
|
+
Enable SPF checking with `useSpf()`:
|
|
287
|
+
|
|
288
|
+
```javascript
|
|
289
|
+
server.useSpf({ mta: "mx.receiver.example" });
|
|
290
|
+
|
|
291
|
+
server.on("mail", (_from, ctx) => {
|
|
292
|
+
const result = ctx.spf.status.result;
|
|
293
|
+
|
|
294
|
+
if (result === "fail") {
|
|
295
|
+
return "reject";
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (result === "temperror") {
|
|
299
|
+
return "tempfail";
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return "continue";
|
|
303
|
+
});
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
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.
|
|
307
|
+
|
|
308
|
+
For a configurable decision mapping:
|
|
309
|
+
|
|
310
|
+
```javascript
|
|
311
|
+
import { spfDecision } from "milter";
|
|
312
|
+
|
|
313
|
+
server.on("mail", (_from, ctx) => spfDecision(ctx.spf, {
|
|
314
|
+
fail: "reject",
|
|
315
|
+
temperror: "tempfail"
|
|
316
|
+
}));
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
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.
|
|
320
|
+
|
|
321
|
+
## DKIM Verification and Signing
|
|
322
|
+
|
|
323
|
+
### Verify Incoming Messages
|
|
324
|
+
|
|
325
|
+
```javascript
|
|
326
|
+
server.useDkim();
|
|
327
|
+
|
|
328
|
+
server.on("bodyEnd", (_body, ctx) => {
|
|
329
|
+
const passed = ctx.dkim.results.some((signature) => (
|
|
330
|
+
signature.status.result === "pass"
|
|
331
|
+
));
|
|
332
|
+
|
|
333
|
+
console.log("Has a passing DKIM signature:", passed);
|
|
334
|
+
return "continue";
|
|
335
|
+
});
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
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.
|
|
339
|
+
|
|
340
|
+
Body collection must remain enabled. Use `verifyDkim(body, ctx, options)` if you want to run verification explicitly.
|
|
341
|
+
|
|
342
|
+
### Sign a Message
|
|
343
|
+
|
|
344
|
+
`signDkim()` signs a complete RFC 822 message, including its headers and body:
|
|
345
|
+
|
|
346
|
+
```javascript
|
|
347
|
+
import fs from "node:fs";
|
|
348
|
+
import { signDkim } from "milter";
|
|
349
|
+
|
|
350
|
+
const message = fs.readFileSync("./message.eml", "utf8");
|
|
351
|
+
|
|
352
|
+
const result = await signDkim(message, {
|
|
353
|
+
signingDomain: "example.com",
|
|
354
|
+
selector: "mail",
|
|
355
|
+
privateKey: fs.readFileSync("./dkim-private.pem")
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
if (result.errors.length > 0) {
|
|
359
|
+
throw result.errors[0];
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const signedMessage = result.signatures + message;
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
`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.
|
|
366
|
+
|
|
367
|
+
## Repository Examples
|
|
368
|
+
|
|
369
|
+
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.
|
|
370
|
+
|
|
371
|
+
After cloning the repository:
|
|
372
|
+
|
|
373
|
+
```bash
|
|
374
|
+
npm install
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
Choose an example:
|
|
378
|
+
|
|
379
|
+
| Command | Example |
|
|
380
|
+
| --- | --- |
|
|
381
|
+
| `npm run example:dmarc` | Collect DMARC aggregate reports. |
|
|
382
|
+
| `npm run example:dmarc:db` | Store reports through a JSONL, MySQL, or PostgreSQL adapter. |
|
|
383
|
+
| `npm run example:chatgpt-spamfilter` | Classify spam with OpenAI. |
|
|
384
|
+
| `npm run example:ollama-spamfilter` | Classify spam with Ollama. |
|
|
385
|
+
| `npm run example:domaindata-holo-check` | Check domains with the domaindata API. |
|
|
386
|
+
|
|
387
|
+
Choose a DMARC storage adapter with environment variables:
|
|
388
|
+
|
|
389
|
+
```bash
|
|
390
|
+
DMARC_DB=jsonl npm run example:dmarc:db
|
|
391
|
+
|
|
392
|
+
DMARC_DB=mysql MYSQL_HOST=127.0.0.1 MYSQL_USER=master MYSQL_PASSWORD=secret MYSQL_DB=mail npm run example:dmarc:db
|
|
393
|
+
|
|
394
|
+
DMARC_DB=postgres PGHOST=127.0.0.1 PGUSER=postgres PGPASSWORD=secret PGDATABASE=mail npm run example:dmarc:db
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
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.
|
|
398
|
+
|
|
399
|
+
## API Reference
|
|
400
|
+
|
|
401
|
+
### Events
|
|
402
|
+
|
|
403
|
+
Protocol handlers receive the event payload first and `MilterContext` last.
|
|
404
|
+
|
|
405
|
+
| Event signature | Description |
|
|
406
|
+
| --- | --- |
|
|
407
|
+
| `connect(info, ctx)` | New SMTP connection. `info` contains `hostname`, `family`, and, where available, `address` and `port`. |
|
|
408
|
+
| `helo(helo, ctx)` | HELO or EHLO name. |
|
|
409
|
+
| `mail(from, ctx)` | Decoded `MAIL FROM` fields as a string array; also available as `ctx.from`. |
|
|
410
|
+
| `rcpt(to, ctx)` | Decoded `RCPT TO` fields as a string array; the latest is also available as `ctx.to`. |
|
|
411
|
+
| `headerLine(name, value, ctx)` | One message header. |
|
|
412
|
+
| `headers(headers, ctx)` | End of headers, with a cloned map of lowercase names to arrays of values. |
|
|
413
|
+
| `bodyChunk(chunk, ctx)` | One body chunk as a `Buffer`. |
|
|
414
|
+
| `bodyEnd(body, ctx)` | End of message, with the collected body. Use this stage for message mutations. |
|
|
415
|
+
| `data(raw, ctx)` | Milter DATA payload decoded as UTF-8 text. |
|
|
416
|
+
| `macro(command, values, ctx)` | MTA macros as a command byte and a string-to-string map. |
|
|
417
|
+
| `abort(ctx)` | Current message aborted; message-specific context state is reset. |
|
|
418
|
+
| `close(ctx)` | Milter session closed. |
|
|
419
|
+
| `unknown(command, data, ctx)` | Unsupported or unknown protocol command and its raw payload. |
|
|
420
|
+
| `error(error, ctx)` | Server, socket, parser, handler, or body-limit error. `ctx` may be undefined. |
|
|
421
|
+
|
|
422
|
+
`header` remains an alias for `headers`; use `headers` in new filters.
|
|
423
|
+
|
|
424
|
+
Milter.js requests registered protocol callbacks during negotiation, so a filter does not need to subscribe to every stage.
|
|
425
|
+
|
|
426
|
+
### Multiple Handlers and Replies
|
|
427
|
+
|
|
428
|
+
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.
|
|
429
|
+
|
|
430
|
+
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"`.
|
|
431
|
+
|
|
432
|
+
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.
|
|
433
|
+
|
|
434
|
+
### MilterContext
|
|
435
|
+
|
|
436
|
+
Each connection has one context object. Message-related fields describe the current message.
|
|
437
|
+
|
|
438
|
+
| Property or method | Description |
|
|
439
|
+
| --- | --- |
|
|
440
|
+
| `id` | Monotonically increasing connection identifier. |
|
|
441
|
+
| `socket` | Node.js socket, or `null` after disconnect. |
|
|
442
|
+
| `headers` | Lowercase header map with arrays of values. |
|
|
443
|
+
| `macros` | MTA macros grouped by protocol command. |
|
|
444
|
+
| `from` | Current envelope sender fields. |
|
|
445
|
+
| `to` | Most recent envelope recipient fields. |
|
|
446
|
+
| `spf` | SPF result when SPF checking is enabled. |
|
|
447
|
+
| `dkim` | DKIM results when verification is enabled. |
|
|
448
|
+
| `getPhase()` | Current protocol phase. |
|
|
449
|
+
| `can(action)` | Whether the MTA negotiated an action. |
|
|
450
|
+
| `getBody()` | Collected body as a `Buffer`. |
|
|
451
|
+
|
|
452
|
+
Immediate response methods:
|
|
453
|
+
|
|
454
|
+
```javascript
|
|
455
|
+
ctx.continue();
|
|
456
|
+
ctx.accept();
|
|
457
|
+
ctx.reject();
|
|
458
|
+
ctx.discard();
|
|
459
|
+
ctx.tempfail();
|
|
460
|
+
ctx.replyCode(code, message, enhancedCode);
|
|
461
|
+
ctx.progress();
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
Message mutation methods:
|
|
465
|
+
|
|
466
|
+
```javascript
|
|
467
|
+
ctx.addHeader(name, value);
|
|
468
|
+
ctx.insertHeader(index, name, value);
|
|
469
|
+
ctx.changeHeader(name, index, value);
|
|
470
|
+
ctx.addRecipient(address);
|
|
471
|
+
ctx.deleteRecipient(address);
|
|
472
|
+
ctx.replaceBody(chunk);
|
|
473
|
+
ctx.quarantine(reason);
|
|
474
|
+
ctx.setSender(sender);
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
The `value` argument of `changeHeader()` and the `reason` argument of `quarantine()` are optional.
|
|
478
|
+
|
|
479
|
+
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`.
|
|
480
|
+
|
|
481
|
+
`enforceActionStages: false` disables stage checks. Capability checks remain active.
|
|
482
|
+
|
|
483
|
+
### Server Options
|
|
484
|
+
|
|
485
|
+
| Option | Default | Description |
|
|
486
|
+
| --- | --- | --- |
|
|
487
|
+
| `socketPath` | — | UNIX socket path. Required if `port` is absent. |
|
|
488
|
+
| `host` | `127.0.0.1` | TCP bind address. |
|
|
489
|
+
| `port` | — | TCP port. Required if `socketPath` is absent. |
|
|
490
|
+
| `actions` | `ADDHDRS \| CHGBODY \| ADDRCPT` | Requested mutation capabilities. |
|
|
491
|
+
| `unlinkOnStart` | `true` | Remove an existing UNIX socket before listening. |
|
|
492
|
+
| `chmod` | `0o777` | UNIX socket permissions; `false` leaves permissions unchanged. |
|
|
493
|
+
| `collectBody` | `true` | Collect body chunks for `bodyEnd`. |
|
|
494
|
+
| `maxBodyBytes` | `33554432` | Maximum collected body size: 32 MiB. |
|
|
495
|
+
| `defaultDecision` | `"continue"` | Decision when a handler returns `undefined`. |
|
|
496
|
+
| `enforceActionStages` | `true` | Check that mutations happen at a valid stage. |
|
|
497
|
+
| `logger` | `ConsoleLogger` | Logger implementing `debug`, `info`, `warn`, and `error`. |
|
|
498
|
+
|
|
499
|
+
For streaming filters, disable collection and process chunks as they arrive:
|
|
500
|
+
|
|
501
|
+
```javascript
|
|
502
|
+
const server = new MilterServer({
|
|
503
|
+
host: "127.0.0.1",
|
|
504
|
+
port: 7357,
|
|
505
|
+
collectBody: false
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
server.on("bodyChunk", (chunk) => {
|
|
509
|
+
console.log("Received body chunk:", chunk.length);
|
|
510
|
+
return "continue";
|
|
511
|
+
});
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
With `collectBody: false`, the buffer passed to `bodyEnd` is empty. DKIM verification requires body collection.
|
|
515
|
+
|
|
516
|
+
### Server Methods
|
|
517
|
+
|
|
518
|
+
| Method | Description |
|
|
519
|
+
| --- | --- |
|
|
520
|
+
| `on(event, handler)` | Register a handler; returns the server. |
|
|
521
|
+
| `once(event, handler)` | Register a handler that removes itself before its first invocation. |
|
|
522
|
+
| `off(event, handler)` | Remove a handler; returns the server. |
|
|
523
|
+
| `listen(socketPathOverride?)` | Start listening; an optional argument overrides the UNIX socket path. |
|
|
524
|
+
| `close()` | Stop accepting connections; resolves when the underlying Node.js server closes. |
|
|
50
525
|
|
|
526
|
+
### Compatibility Class
|
|
51
527
|
|
|
52
|
-
|
|
528
|
+
The `Milter` compatibility class enables `ACTION_ALL` unless you provide `actions` explicitly:
|
|
53
529
|
|
|
54
|
-
|
|
530
|
+
```javascript
|
|
531
|
+
import { Milter } from "milter";
|
|
55
532
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
533
|
+
const server = new Milter({
|
|
534
|
+
socketPath: "/run/example-milter.sock"
|
|
535
|
+
});
|
|
536
|
+
```
|
|
60
537
|
|
|
538
|
+
Prefer `MilterServer` with the specific actions your filter needs for new code.
|
|
61
539
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
milter.SMFIS_CONTINUE
|
|
65
|
-
milter.SMFIS_REJECT
|
|
66
|
-
milter.SMFIS_DISCARD
|
|
67
|
-
milter.SMFIS_ACCEPT
|
|
68
|
-
milter.SMFIS_TEMPFAIL
|
|
69
|
-
milter.SMFIS_NOREPLY
|
|
70
|
-
milter.SMFIS_SKIP
|
|
540
|
+
### Protocol Constants
|
|
71
541
|
|
|
542
|
+
Constants are available from `milter` and `milter/constants`:
|
|
72
543
|
|
|
73
|
-
|
|
74
|
-
|
|
544
|
+
```javascript
|
|
545
|
+
import {
|
|
546
|
+
ACTION_ALL,
|
|
547
|
+
SMFI_VERSION,
|
|
548
|
+
SMFIA,
|
|
549
|
+
SMFIC,
|
|
550
|
+
SMFIF,
|
|
551
|
+
SMFIP,
|
|
552
|
+
SMFIR
|
|
553
|
+
} from "milter/constants";
|
|
554
|
+
```
|
|
75
555
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
556
|
+
| Constant | Meaning |
|
|
557
|
+
| --- | --- |
|
|
558
|
+
| `SMFIA` | Connection address families. |
|
|
559
|
+
| `SMFIC` | Commands sent by the mail server. |
|
|
560
|
+
| `SMFIR` | Replies sent by the filter. |
|
|
561
|
+
| `SMFIF` | Message mutation capabilities. |
|
|
562
|
+
| `SMFIP` | Callback suppression flags. |
|
|
563
|
+
| `ACTION_ALL` | All supported mutation capabilities combined. |
|
|
564
|
+
| `SMFI_VERSION` | Supported protocol version: 6. |
|
|
79
565
|
|
|
566
|
+
Low-level `frame()` and `send()` helpers are also exported for protocol tooling and tests.
|
|
80
567
|
|
|
81
|
-
|
|
82
|
-
access to message modifiers is allowed during the EOM event with these methods.
|
|
568
|
+
## UNIX Sockets and Sendmail
|
|
83
569
|
|
|
84
|
-
|
|
85
|
-
env.chgheader(name, refcount, value)
|
|
86
|
-
env.insheader(index, name, value)
|
|
87
|
-
env.replacebody(newbody)
|
|
88
|
-
env.addrcpt(recipient)
|
|
89
|
-
env.addrcpt_par(recipient, args)
|
|
90
|
-
env.delrcpt(recipient)
|
|
91
|
-
env.chgfrom(envfrom, args)
|
|
92
|
-
env.quarantine(reason)
|
|
93
|
-
env.progress()
|
|
570
|
+
For a local mail server, you can use a UNIX socket instead of TCP:
|
|
94
571
|
|
|
572
|
+
```javascript
|
|
573
|
+
const server = new MilterServer({
|
|
574
|
+
socketPath: "/run/example-milter.sock"
|
|
575
|
+
});
|
|
95
576
|
|
|
96
|
-
|
|
577
|
+
await server.listen();
|
|
578
|
+
```
|
|
97
579
|
|
|
98
|
-
|
|
580
|
+
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.
|
|
99
581
|
|
|
582
|
+
For Sendmail, reference the same socket in `sendmail.mc`:
|
|
100
583
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
callback can be changed after node.js returns control to libmilter. call this
|
|
105
|
-
method BEFORE calling env.done(), also, the values changed by this function are
|
|
106
|
-
ignored unless env.done() is called with SMFIS_CONTINUE.
|
|
584
|
+
```m4
|
|
585
|
+
INPUT_MAIL_FILTER(`example', `S=local:/run/example-milter.sock, F=T, T=S:4m;R:4m;E:10m')dnl
|
|
586
|
+
```
|
|
107
587
|
|
|
108
|
-
|
|
588
|
+
Rebuild and reload the Sendmail configuration using your operating system's procedure.
|
|
109
589
|
|
|
590
|
+
## Development
|
|
110
591
|
|
|
111
|
-
|
|
112
|
-
the macro doesn't have a value.
|
|
592
|
+
Install dependencies and build:
|
|
113
593
|
|
|
114
|
-
|
|
594
|
+
```bash
|
|
595
|
+
npm install
|
|
596
|
+
npm run build
|
|
597
|
+
```
|
|
115
598
|
|
|
599
|
+
The build generates ESM, CommonJS, and TypeScript declaration files in `dist`.
|
|
116
600
|
|
|
117
|
-
|
|
118
|
-
i believe this should also exclude negotiate, so i enforce that as well.
|
|
601
|
+
Run linting, type checks, the build, and tests:
|
|
119
602
|
|
|
120
|
-
|
|
121
|
-
|
|
603
|
+
```bash
|
|
604
|
+
npm run check
|
|
605
|
+
```
|
|
122
606
|
|
|
607
|
+
Run only the tests:
|
|
123
608
|
|
|
124
|
-
|
|
609
|
+
```bash
|
|
610
|
+
npm test
|
|
611
|
+
```
|
|
125
612
|
|
|
126
|
-
|
|
127
|
-
features in node with this addon.
|
|
613
|
+
### Version Compatibility
|
|
128
614
|
|
|
615
|
+
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.
|
|
129
616
|
|
|
130
|
-
|
|
131
|
-
"node-milter" is intended to give the project a sensible npm identity.
|
|
617
|
+
## Sponsor
|
|
132
618
|
|
|
133
|
-
|
|
134
|
-
- libmilter explicitly uses pthreads, and libuv is implicitly using them. the
|
|
135
|
-
code compiles because of this happy coincidence.
|
|
136
|
-
- you can't use this on any non-POSIX platforms yet. but then again, you can't
|
|
137
|
-
use milters there, either.
|
|
138
|
-
- this cannot be worked around by switching the relevant code over to libuv
|
|
139
|
-
without also rewriting all of libmilter. i looked. it's not worth it.
|
|
619
|
+
Development and maintenance of **Milter.js** are supported by [Maail](https://maail.co/).
|
|
140
620
|
|
|
141
|
-
|
|
142
|
-
send command "DATA", and now a chunk of the message data has arrived) using the
|
|
143
|
-
i-suspect-is-soon-to-be-deprecated method Buffer::Use(), which should have been
|
|
144
|
-
named Buffer::New() like the others, by passing (const unsigned char *) as the
|
|
145
|
-
expected (char *) which is probably stupid. it is unclear to me why there is no
|
|
146
|
-
Buffer::New() that simply accepts (void *) like all the real POSIX C buffer-
|
|
147
|
-
manipulating functions. whatever.
|
|
621
|
+
<a href="https://maail.co/"><img src="res/maail-logo.svg" alt="Maail" width="80"></a>
|
|
148
622
|
|
|
623
|
+
Maail is a privacy-first European email intelligence API. Its support helps keep Milter.js maintained and freely available.
|
|
149
624
|
|
|
150
|
-
|
|
625
|
+
## Copyright and Licensing
|
|
151
626
|
|
|
152
|
-
|
|
627
|
+
Copyright (c) 2026, [Robert Eisele](https://raw.org/)
|
|
153
628
|
|
|
154
|
-
|
|
629
|
+
Licensed under the MIT license.
|