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/dist/index.js
ADDED
|
@@ -0,0 +1,914 @@
|
|
|
1
|
+
// src/constants.ts
|
|
2
|
+
var SMFI_VERSION = 6;
|
|
3
|
+
var SMFIA = {
|
|
4
|
+
UNKNOWN: "U",
|
|
5
|
+
UNIX: "L",
|
|
6
|
+
INET: "4",
|
|
7
|
+
INET6: "6"
|
|
8
|
+
};
|
|
9
|
+
var SMFIC = {
|
|
10
|
+
CONNECT: "C",
|
|
11
|
+
OPTNEG: "O",
|
|
12
|
+
HELO: "H",
|
|
13
|
+
HEADER: "L",
|
|
14
|
+
EOH: "N",
|
|
15
|
+
BODY: "B",
|
|
16
|
+
BODYEOB: "E",
|
|
17
|
+
MAIL: "M",
|
|
18
|
+
RCPT: "R",
|
|
19
|
+
ABORT: "A",
|
|
20
|
+
MACRO: "D",
|
|
21
|
+
QUIT: "Q",
|
|
22
|
+
DATA: "T",
|
|
23
|
+
UNKNOWN: "U"
|
|
24
|
+
};
|
|
25
|
+
var SMFIR = {
|
|
26
|
+
ACCEPT: "a",
|
|
27
|
+
CONTINUE: "c",
|
|
28
|
+
DISCARD: "d",
|
|
29
|
+
REJECT: "r",
|
|
30
|
+
TEMPFAIL: "t",
|
|
31
|
+
REPLYCODE: "y",
|
|
32
|
+
ADDRCPT: "+",
|
|
33
|
+
DELRCPT: "-",
|
|
34
|
+
REPLBODY: "b",
|
|
35
|
+
ADDHEADER: "h",
|
|
36
|
+
INSHEADER: "i",
|
|
37
|
+
CHGHEADER: "m",
|
|
38
|
+
PROGRESS: "p",
|
|
39
|
+
QUARANTINE: "q",
|
|
40
|
+
SETSENDER: "s"
|
|
41
|
+
};
|
|
42
|
+
var SMFIF = {
|
|
43
|
+
ADDHDRS: 1,
|
|
44
|
+
CHGBODY: 2,
|
|
45
|
+
ADDRCPT: 4,
|
|
46
|
+
DELRCPT: 8,
|
|
47
|
+
CHGHDRS: 16,
|
|
48
|
+
QUARANTINE: 32,
|
|
49
|
+
SETSENDER: 64
|
|
50
|
+
};
|
|
51
|
+
var ACTION_ALL = SMFIF.ADDHDRS | SMFIF.CHGBODY | SMFIF.ADDRCPT | SMFIF.DELRCPT | SMFIF.CHGHDRS | SMFIF.QUARANTINE | SMFIF.SETSENDER;
|
|
52
|
+
var SMFIP = {
|
|
53
|
+
NOCONNECT: 1,
|
|
54
|
+
NOHELO: 2,
|
|
55
|
+
NOMAIL: 4,
|
|
56
|
+
NORCPT: 8,
|
|
57
|
+
NOBODY: 16,
|
|
58
|
+
NOHDRS: 32,
|
|
59
|
+
NOEOH: 64,
|
|
60
|
+
NONE: 127
|
|
61
|
+
};
|
|
62
|
+
var DISABLE_ALL_CALLBACKS = SMFIP.NOCONNECT | SMFIP.NOHELO | SMFIP.NOMAIL | SMFIP.NORCPT | SMFIP.NOBODY | SMFIP.NOHDRS | SMFIP.NOEOH;
|
|
63
|
+
|
|
64
|
+
// src/frames.ts
|
|
65
|
+
function frame(command, data = Buffer.alloc(0)) {
|
|
66
|
+
const len = Buffer.alloc(4);
|
|
67
|
+
len.writeUInt32BE(data.length + 1, 0);
|
|
68
|
+
return Buffer.concat([len, Buffer.from(command, "ascii"), data]);
|
|
69
|
+
}
|
|
70
|
+
function send(socket, command, data) {
|
|
71
|
+
if (!socket) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
socket.write(frame(command, data ?? Buffer.alloc(0)));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// src/context.ts
|
|
78
|
+
var MilterActionError = class extends Error {
|
|
79
|
+
code;
|
|
80
|
+
constructor(code, message) {
|
|
81
|
+
super(message);
|
|
82
|
+
this.name = "MilterActionError";
|
|
83
|
+
this.code = code;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
var EOM_ONLY_PHASES = /* @__PURE__ */ new Set(["bodyEnd"]);
|
|
87
|
+
var MilterContext = class {
|
|
88
|
+
id;
|
|
89
|
+
collectBody;
|
|
90
|
+
maxBodyBytes;
|
|
91
|
+
enforceActionStages;
|
|
92
|
+
chunks = [];
|
|
93
|
+
bodyBytes = 0;
|
|
94
|
+
phase = "connect";
|
|
95
|
+
negotiatedActions = 0;
|
|
96
|
+
socket;
|
|
97
|
+
headers = {};
|
|
98
|
+
headerLines = [];
|
|
99
|
+
macros = {};
|
|
100
|
+
connection = null;
|
|
101
|
+
helo = null;
|
|
102
|
+
dkim = null;
|
|
103
|
+
spf = null;
|
|
104
|
+
from = null;
|
|
105
|
+
to = null;
|
|
106
|
+
constructor(id, socket, collectBody, maxBodyBytes, enforceActionStages) {
|
|
107
|
+
this.id = id;
|
|
108
|
+
this.socket = socket;
|
|
109
|
+
this.collectBody = collectBody;
|
|
110
|
+
this.maxBodyBytes = maxBodyBytes;
|
|
111
|
+
this.enforceActionStages = enforceActionStages;
|
|
112
|
+
}
|
|
113
|
+
setPhase(phase) {
|
|
114
|
+
this.phase = phase;
|
|
115
|
+
}
|
|
116
|
+
getPhase() {
|
|
117
|
+
return this.phase;
|
|
118
|
+
}
|
|
119
|
+
setNegotiatedActions(actions) {
|
|
120
|
+
this.negotiatedActions = actions >>> 0;
|
|
121
|
+
}
|
|
122
|
+
can(action) {
|
|
123
|
+
return (this.negotiatedActions & action) === action;
|
|
124
|
+
}
|
|
125
|
+
appendHeader(name, value) {
|
|
126
|
+
const key = name.toLowerCase();
|
|
127
|
+
this.headerLines.push([name, value]);
|
|
128
|
+
if (!this.headers[key]) {
|
|
129
|
+
this.headers[key] = [];
|
|
130
|
+
}
|
|
131
|
+
this.headers[key].push(value);
|
|
132
|
+
}
|
|
133
|
+
appendBody(chunk) {
|
|
134
|
+
if (!this.collectBody) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
this.bodyBytes += chunk.length;
|
|
138
|
+
if (this.bodyBytes > this.maxBodyBytes) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`Body limit exceeded for connection ${this.id}: ${this.bodyBytes} > ${this.maxBodyBytes}`
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
this.chunks.push(Buffer.from(chunk));
|
|
144
|
+
}
|
|
145
|
+
getBody() {
|
|
146
|
+
return Buffer.concat(this.chunks);
|
|
147
|
+
}
|
|
148
|
+
resetMessage() {
|
|
149
|
+
this.headers = {};
|
|
150
|
+
this.headerLines = [];
|
|
151
|
+
this.macros = {};
|
|
152
|
+
this.dkim = null;
|
|
153
|
+
this.spf = null;
|
|
154
|
+
this.from = null;
|
|
155
|
+
this.to = null;
|
|
156
|
+
this.chunks = [];
|
|
157
|
+
this.bodyBytes = 0;
|
|
158
|
+
this.phase = "connect";
|
|
159
|
+
}
|
|
160
|
+
requireAction(action, actionName, phases) {
|
|
161
|
+
if ((this.negotiatedActions & action) !== action) {
|
|
162
|
+
throw new MilterActionError(
|
|
163
|
+
"E_MILTER_ACTION_CAPABILITY",
|
|
164
|
+
`${actionName} not permitted by negotiated action flags`
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
if (this.enforceActionStages && phases && !phases.has(this.phase)) {
|
|
168
|
+
throw new MilterActionError(
|
|
169
|
+
"E_MILTER_ACTION_STAGE",
|
|
170
|
+
`${actionName} is not allowed during phase '${this.phase}'`
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
send(command, data) {
|
|
175
|
+
send(this.socket, command, data);
|
|
176
|
+
}
|
|
177
|
+
continue() {
|
|
178
|
+
this.send(SMFIR.CONTINUE);
|
|
179
|
+
}
|
|
180
|
+
accept() {
|
|
181
|
+
this.send(SMFIR.ACCEPT);
|
|
182
|
+
}
|
|
183
|
+
reject() {
|
|
184
|
+
this.send(SMFIR.REJECT);
|
|
185
|
+
}
|
|
186
|
+
discard() {
|
|
187
|
+
this.send(SMFIR.DISCARD);
|
|
188
|
+
}
|
|
189
|
+
tempfail() {
|
|
190
|
+
this.send(SMFIR.TEMPFAIL);
|
|
191
|
+
}
|
|
192
|
+
progress() {
|
|
193
|
+
this.send(SMFIR.PROGRESS);
|
|
194
|
+
}
|
|
195
|
+
addHeader(name, value) {
|
|
196
|
+
this.requireAction(SMFIF.ADDHDRS, "addHeader", EOM_ONLY_PHASES);
|
|
197
|
+
this.send(SMFIR.ADDHEADER, Buffer.from(`${name}\0${value}\0`, "utf8"));
|
|
198
|
+
}
|
|
199
|
+
insertHeader(index, name, value) {
|
|
200
|
+
this.requireAction(SMFIF.ADDHDRS, "insertHeader", EOM_ONLY_PHASES);
|
|
201
|
+
const suffix = Buffer.from(`${name}\0${value}\0`, "utf8");
|
|
202
|
+
const data = Buffer.alloc(4 + suffix.length);
|
|
203
|
+
data.writeUInt32BE(index >>> 0, 0);
|
|
204
|
+
suffix.copy(data, 4);
|
|
205
|
+
this.send(SMFIR.INSHEADER, data);
|
|
206
|
+
}
|
|
207
|
+
changeHeader(name, index, value = "") {
|
|
208
|
+
this.requireAction(SMFIF.CHGHDRS, "changeHeader", EOM_ONLY_PHASES);
|
|
209
|
+
const suffix = Buffer.from(`${name}\0${value}\0`, "utf8");
|
|
210
|
+
const data = Buffer.alloc(4 + suffix.length);
|
|
211
|
+
data.writeUInt32BE(index >>> 0, 0);
|
|
212
|
+
suffix.copy(data, 4);
|
|
213
|
+
this.send(SMFIR.CHGHEADER, data);
|
|
214
|
+
}
|
|
215
|
+
addRecipient(address) {
|
|
216
|
+
this.requireAction(SMFIF.ADDRCPT, "addRecipient", EOM_ONLY_PHASES);
|
|
217
|
+
this.send(SMFIR.ADDRCPT, Buffer.from(`${address}\0`, "utf8"));
|
|
218
|
+
}
|
|
219
|
+
deleteRecipient(address) {
|
|
220
|
+
this.requireAction(SMFIF.DELRCPT, "deleteRecipient", EOM_ONLY_PHASES);
|
|
221
|
+
this.send(SMFIR.DELRCPT, Buffer.from(`${address}\0`, "utf8"));
|
|
222
|
+
}
|
|
223
|
+
replaceBody(chunk) {
|
|
224
|
+
this.requireAction(SMFIF.CHGBODY, "replaceBody", EOM_ONLY_PHASES);
|
|
225
|
+
const payload = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
|
|
226
|
+
this.send(SMFIR.REPLBODY, payload);
|
|
227
|
+
}
|
|
228
|
+
quarantine(reason = "") {
|
|
229
|
+
this.requireAction(SMFIF.QUARANTINE, "quarantine", EOM_ONLY_PHASES);
|
|
230
|
+
this.send(SMFIR.QUARANTINE, Buffer.from(`${reason}\0`, "utf8"));
|
|
231
|
+
}
|
|
232
|
+
setSender(sender) {
|
|
233
|
+
this.requireAction(SMFIF.SETSENDER, "setSender", EOM_ONLY_PHASES);
|
|
234
|
+
this.send(SMFIR.SETSENDER, Buffer.from(`${sender}\0`, "utf8"));
|
|
235
|
+
}
|
|
236
|
+
replyCode(code, message, enhancedCode = "5.7.1") {
|
|
237
|
+
this.send(SMFIR.REPLYCODE, Buffer.from(`${code} ${enhancedCode} ${message}\0`, "utf8"));
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// src/decisions.ts
|
|
242
|
+
var DECISION_TO_COMMAND = {
|
|
243
|
+
continue: SMFIR.CONTINUE,
|
|
244
|
+
accept: SMFIR.ACCEPT,
|
|
245
|
+
reject: SMFIR.REJECT,
|
|
246
|
+
discard: SMFIR.DISCARD,
|
|
247
|
+
tempfail: SMFIR.TEMPFAIL
|
|
248
|
+
};
|
|
249
|
+
var Decision = {
|
|
250
|
+
continue() {
|
|
251
|
+
return { command: SMFIR.CONTINUE };
|
|
252
|
+
},
|
|
253
|
+
accept() {
|
|
254
|
+
return { command: SMFIR.ACCEPT };
|
|
255
|
+
},
|
|
256
|
+
reject() {
|
|
257
|
+
return { command: SMFIR.REJECT };
|
|
258
|
+
},
|
|
259
|
+
discard() {
|
|
260
|
+
return { command: SMFIR.DISCARD };
|
|
261
|
+
},
|
|
262
|
+
tempfail() {
|
|
263
|
+
return { command: SMFIR.TEMPFAIL };
|
|
264
|
+
},
|
|
265
|
+
replyCode(code, message, enhancedCode = "5.7.1") {
|
|
266
|
+
const payload = Buffer.from(`${code} ${enhancedCode} ${message}\0`, "utf8");
|
|
267
|
+
return {
|
|
268
|
+
command: SMFIR.REPLYCODE,
|
|
269
|
+
data: payload
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
function normalizeDecision(decision, fallback) {
|
|
274
|
+
if (decision === null) {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
if (decision === void 0) {
|
|
278
|
+
return { command: DECISION_TO_COMMAND[fallback] };
|
|
279
|
+
}
|
|
280
|
+
if (typeof decision === "string") {
|
|
281
|
+
return { command: DECISION_TO_COMMAND[decision] };
|
|
282
|
+
}
|
|
283
|
+
if (decision.data === void 0) {
|
|
284
|
+
return { command: decision.command };
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
command: decision.command,
|
|
288
|
+
data: decision.data
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// src/dkim.ts
|
|
293
|
+
import { dkimSign } from "mailauth/lib/dkim/sign.js";
|
|
294
|
+
import { dkimVerify } from "mailauth/lib/dkim/verify.js";
|
|
295
|
+
function normalizeSender(from) {
|
|
296
|
+
const value = from?.[0]?.trim();
|
|
297
|
+
if (!value || value === "<>") {
|
|
298
|
+
return void 0;
|
|
299
|
+
}
|
|
300
|
+
if (value.startsWith("<") && value.endsWith(">")) {
|
|
301
|
+
return value.slice(1, -1).trim() || void 0;
|
|
302
|
+
}
|
|
303
|
+
return value;
|
|
304
|
+
}
|
|
305
|
+
function buildMessage(ctx, body) {
|
|
306
|
+
const headers = ctx.headerLines.map(([name, value]) => `${name}: ${value}`).join("\r\n");
|
|
307
|
+
return Buffer.concat([
|
|
308
|
+
Buffer.from(`${headers}\r
|
|
309
|
+
\r
|
|
310
|
+
`, "utf8"),
|
|
311
|
+
body
|
|
312
|
+
]);
|
|
313
|
+
}
|
|
314
|
+
async function verifyDkim(body, ctx, options = {}) {
|
|
315
|
+
const sender = options.sender ?? normalizeSender(ctx.from);
|
|
316
|
+
const result = await dkimVerify(buildMessage(ctx, body), {
|
|
317
|
+
...options,
|
|
318
|
+
...sender === void 0 ? {} : { sender }
|
|
319
|
+
});
|
|
320
|
+
ctx.dkim = result;
|
|
321
|
+
return result;
|
|
322
|
+
}
|
|
323
|
+
async function signDkim(message, options) {
|
|
324
|
+
const signatureData = options.signatureData?.length ? options.signatureData.map((entry) => ({ ...entry })) : [{
|
|
325
|
+
signingDomain: options.signingDomain,
|
|
326
|
+
selector: options.selector,
|
|
327
|
+
privateKey: options.privateKey,
|
|
328
|
+
...options.canonicalization === void 0 ? {} : { canonicalization: options.canonicalization },
|
|
329
|
+
...options.algorithm === void 0 ? {} : { algorithm: options.algorithm },
|
|
330
|
+
...options.maxBodyLength === void 0 ? {} : { maxBodyLength: options.maxBodyLength },
|
|
331
|
+
...options.identity === void 0 ? {} : { identity: options.identity }
|
|
332
|
+
}];
|
|
333
|
+
return dkimSign(message, {
|
|
334
|
+
...options,
|
|
335
|
+
signatureData
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// src/logger.ts
|
|
340
|
+
var ConsoleLogger = class {
|
|
341
|
+
prefix;
|
|
342
|
+
constructor(prefix = "[MILTER]") {
|
|
343
|
+
this.prefix = prefix;
|
|
344
|
+
}
|
|
345
|
+
debug(...args) {
|
|
346
|
+
console.debug(this.prefix, ...args);
|
|
347
|
+
}
|
|
348
|
+
info(...args) {
|
|
349
|
+
console.info(this.prefix, ...args);
|
|
350
|
+
}
|
|
351
|
+
warn(...args) {
|
|
352
|
+
console.warn(this.prefix, ...args);
|
|
353
|
+
}
|
|
354
|
+
error(...args) {
|
|
355
|
+
console.error(this.prefix, ...args);
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
// src/server.ts
|
|
360
|
+
import fs from "fs";
|
|
361
|
+
import net from "net";
|
|
362
|
+
|
|
363
|
+
// src/spf.ts
|
|
364
|
+
import { spf as verifySpf } from "mailauth/lib/spf/index.js";
|
|
365
|
+
var DEFAULT_POLICY = {
|
|
366
|
+
pass: "continue",
|
|
367
|
+
fail: "reject",
|
|
368
|
+
softfail: "continue",
|
|
369
|
+
neutral: "continue",
|
|
370
|
+
none: "continue",
|
|
371
|
+
temperror: "tempfail",
|
|
372
|
+
permerror: "continue"
|
|
373
|
+
};
|
|
374
|
+
function normalizeSender2(from) {
|
|
375
|
+
const value = (typeof from === "string" ? from : from[0])?.trim();
|
|
376
|
+
if (!value || value === "<>") {
|
|
377
|
+
return void 0;
|
|
378
|
+
}
|
|
379
|
+
if (value.startsWith("<") && value.endsWith(">")) {
|
|
380
|
+
return value.slice(1, -1).trim() || void 0;
|
|
381
|
+
}
|
|
382
|
+
return value;
|
|
383
|
+
}
|
|
384
|
+
async function checkSpf(from, ctx, options = {}) {
|
|
385
|
+
const ip = ctx.connection?.address;
|
|
386
|
+
if (!ip) {
|
|
387
|
+
throw new Error("SPF requires the client IP from the Milter CONNECT event.");
|
|
388
|
+
}
|
|
389
|
+
const sender = normalizeSender2(from);
|
|
390
|
+
const helo = ctx.helo ?? ctx.connection?.hostname;
|
|
391
|
+
const result = await verifySpf({
|
|
392
|
+
ip,
|
|
393
|
+
...sender === void 0 ? {} : { sender },
|
|
394
|
+
...helo === void 0 ? {} : { helo },
|
|
395
|
+
...options.mta === void 0 ? {} : { mta: options.mta },
|
|
396
|
+
...options.maxResolveCount === void 0 ? {} : { maxResolveCount: options.maxResolveCount },
|
|
397
|
+
...options.maxVoidCount === void 0 ? {} : { maxVoidCount: options.maxVoidCount },
|
|
398
|
+
...options.resolver === void 0 ? {} : { resolver: options.resolver }
|
|
399
|
+
});
|
|
400
|
+
ctx.spf = result;
|
|
401
|
+
return result;
|
|
402
|
+
}
|
|
403
|
+
function spfDecision(result, policy = {}) {
|
|
404
|
+
const code = result.status.result;
|
|
405
|
+
return policy[code] ?? DEFAULT_POLICY[code] ?? "continue";
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// src/utils.ts
|
|
409
|
+
function readCString(buf, start = 0) {
|
|
410
|
+
if (start >= buf.length) {
|
|
411
|
+
return {
|
|
412
|
+
value: "",
|
|
413
|
+
next: buf.length
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
const end = buf.indexOf(0, start);
|
|
417
|
+
if (end === -1) {
|
|
418
|
+
return {
|
|
419
|
+
value: buf.toString("utf8", start),
|
|
420
|
+
next: buf.length
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
return {
|
|
424
|
+
value: buf.toString("utf8", start, end),
|
|
425
|
+
next: end + 1
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
function readCStringList(buf, start = 0) {
|
|
429
|
+
const out = [];
|
|
430
|
+
let offset = start;
|
|
431
|
+
while (offset < buf.length) {
|
|
432
|
+
const { value, next } = readCString(buf, offset);
|
|
433
|
+
if (next === offset) {
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
offset = next;
|
|
437
|
+
if (value.length === 0) {
|
|
438
|
+
if (offset >= buf.length) {
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
out.push(value);
|
|
444
|
+
}
|
|
445
|
+
return out;
|
|
446
|
+
}
|
|
447
|
+
function readCStringPairs(buf, start = 0) {
|
|
448
|
+
const list = readCStringList(buf, start);
|
|
449
|
+
const map = {};
|
|
450
|
+
for (let i = 0; i + 1 < list.length; i += 2) {
|
|
451
|
+
const key = list[i];
|
|
452
|
+
const value = list[i + 1];
|
|
453
|
+
if (key && value) {
|
|
454
|
+
map[key] = value;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return map;
|
|
458
|
+
}
|
|
459
|
+
function parseConnectInfo(payload) {
|
|
460
|
+
const host = readCString(payload, 0);
|
|
461
|
+
const familyByte = payload.toString("ascii", host.next, host.next + 1);
|
|
462
|
+
if (familyByte === SMFIA.UNKNOWN || host.next >= payload.length) {
|
|
463
|
+
return {
|
|
464
|
+
hostname: host.value,
|
|
465
|
+
family: "UNKNOWN"
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
if (host.next + 3 > payload.length) {
|
|
469
|
+
return {
|
|
470
|
+
hostname: host.value,
|
|
471
|
+
family: "UNKNOWN"
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
const port = payload.readUInt16BE(host.next + 1);
|
|
475
|
+
const addr = readCString(payload, host.next + 3).value;
|
|
476
|
+
const family = familyByte === SMFIA.UNIX ? "UNIX" : familyByte === SMFIA.INET ? "INET" : familyByte === SMFIA.INET6 ? "INET6" : "UNKNOWN";
|
|
477
|
+
return {
|
|
478
|
+
hostname: host.value,
|
|
479
|
+
family,
|
|
480
|
+
address: addr,
|
|
481
|
+
port
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
function cloneHeaders(headers) {
|
|
485
|
+
const out = {};
|
|
486
|
+
for (const [name, values] of Object.entries(headers)) {
|
|
487
|
+
out[name] = [...values];
|
|
488
|
+
}
|
|
489
|
+
return out;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// src/server.ts
|
|
493
|
+
var DEFAULT_OPTIONS = {
|
|
494
|
+
socketPath: void 0,
|
|
495
|
+
host: "127.0.0.1",
|
|
496
|
+
port: void 0,
|
|
497
|
+
actions: SMFIF.ADDHDRS | SMFIF.CHGBODY | SMFIF.ADDRCPT,
|
|
498
|
+
unlinkOnStart: true,
|
|
499
|
+
chmod: 511,
|
|
500
|
+
collectBody: true,
|
|
501
|
+
maxBodyBytes: 32 * 1024 * 1024,
|
|
502
|
+
enforceActionStages: true,
|
|
503
|
+
defaultDecision: "continue",
|
|
504
|
+
logger: new ConsoleLogger("[MILTER]")
|
|
505
|
+
};
|
|
506
|
+
function createHandlerMap() {
|
|
507
|
+
return {
|
|
508
|
+
connect: /* @__PURE__ */ new Set(),
|
|
509
|
+
helo: /* @__PURE__ */ new Set(),
|
|
510
|
+
mail: /* @__PURE__ */ new Set(),
|
|
511
|
+
rcpt: /* @__PURE__ */ new Set(),
|
|
512
|
+
headerLine: /* @__PURE__ */ new Set(),
|
|
513
|
+
headers: /* @__PURE__ */ new Set(),
|
|
514
|
+
header: /* @__PURE__ */ new Set(),
|
|
515
|
+
bodyChunk: /* @__PURE__ */ new Set(),
|
|
516
|
+
bodyEnd: /* @__PURE__ */ new Set(),
|
|
517
|
+
data: /* @__PURE__ */ new Set(),
|
|
518
|
+
macro: /* @__PURE__ */ new Set(),
|
|
519
|
+
abort: /* @__PURE__ */ new Set(),
|
|
520
|
+
close: /* @__PURE__ */ new Set(),
|
|
521
|
+
unknown: /* @__PURE__ */ new Set(),
|
|
522
|
+
error: /* @__PURE__ */ new Set()
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
var MilterServer = class {
|
|
526
|
+
server;
|
|
527
|
+
handlers;
|
|
528
|
+
options;
|
|
529
|
+
nextConnectionId = 1;
|
|
530
|
+
dkimOptions = null;
|
|
531
|
+
spfOptions = null;
|
|
532
|
+
constructor(options = {}) {
|
|
533
|
+
this.options = {
|
|
534
|
+
...DEFAULT_OPTIONS,
|
|
535
|
+
...options,
|
|
536
|
+
logger: options.logger ?? DEFAULT_OPTIONS.logger
|
|
537
|
+
};
|
|
538
|
+
if (!this.options.socketPath && this.options.port === void 0) {
|
|
539
|
+
throw new Error("Either socketPath or port must be provided.");
|
|
540
|
+
}
|
|
541
|
+
this.handlers = createHandlerMap();
|
|
542
|
+
this.server = net.createServer((socket) => {
|
|
543
|
+
this.handleConnection(socket);
|
|
544
|
+
});
|
|
545
|
+
this.server.on("error", (err) => {
|
|
546
|
+
this.options.logger.error("Server error", err);
|
|
547
|
+
this.emitInternalError(void 0, err);
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
on(event, handler) {
|
|
551
|
+
this.handlers[event].add(handler);
|
|
552
|
+
return this;
|
|
553
|
+
}
|
|
554
|
+
off(event, handler) {
|
|
555
|
+
this.handlers[event].delete(handler);
|
|
556
|
+
return this;
|
|
557
|
+
}
|
|
558
|
+
once(event, handler) {
|
|
559
|
+
const wrap = (async (...args) => {
|
|
560
|
+
this.off(event, wrap);
|
|
561
|
+
return handler(...args);
|
|
562
|
+
});
|
|
563
|
+
this.on(event, wrap);
|
|
564
|
+
return this;
|
|
565
|
+
}
|
|
566
|
+
useSpf(options = {}) {
|
|
567
|
+
this.spfOptions = options;
|
|
568
|
+
return this;
|
|
569
|
+
}
|
|
570
|
+
useDkim(options = {}) {
|
|
571
|
+
if (!this.options.collectBody) {
|
|
572
|
+
throw new Error("DKIM verification requires collectBody to be enabled.");
|
|
573
|
+
}
|
|
574
|
+
this.dkimOptions = options;
|
|
575
|
+
return this;
|
|
576
|
+
}
|
|
577
|
+
async listen(socketPathOverride) {
|
|
578
|
+
if (socketPathOverride) {
|
|
579
|
+
this.options.socketPath = socketPathOverride;
|
|
580
|
+
}
|
|
581
|
+
if (this.options.socketPath) {
|
|
582
|
+
if (this.options.unlinkOnStart && fs.existsSync(this.options.socketPath)) {
|
|
583
|
+
fs.unlinkSync(this.options.socketPath);
|
|
584
|
+
}
|
|
585
|
+
await new Promise((resolve, reject) => {
|
|
586
|
+
this.server.listen(this.options.socketPath, (err) => {
|
|
587
|
+
if (err) {
|
|
588
|
+
reject(err);
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
if (this.options.chmod !== false) {
|
|
592
|
+
fs.chmodSync(this.options.socketPath, this.options.chmod);
|
|
593
|
+
}
|
|
594
|
+
this.options.logger.info("Listening on", this.options.socketPath);
|
|
595
|
+
resolve();
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
await new Promise((resolve, reject) => {
|
|
601
|
+
this.server.listen(this.options.port, this.options.host, (err) => {
|
|
602
|
+
if (err) {
|
|
603
|
+
reject(err);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
this.options.logger.info("Listening on", `${this.options.host}:${this.options.port}`);
|
|
607
|
+
resolve();
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
async close() {
|
|
612
|
+
await new Promise((resolve, reject) => {
|
|
613
|
+
this.server.close((err) => {
|
|
614
|
+
if (err) {
|
|
615
|
+
reject(err);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
resolve();
|
|
619
|
+
});
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
handleConnection(socket) {
|
|
623
|
+
const ctx = new MilterContext(
|
|
624
|
+
this.nextConnectionId++,
|
|
625
|
+
socket,
|
|
626
|
+
this.options.collectBody,
|
|
627
|
+
this.options.maxBodyBytes,
|
|
628
|
+
this.options.enforceActionStages
|
|
629
|
+
);
|
|
630
|
+
this.options.logger.info("MTA connected", ctx.id);
|
|
631
|
+
let closed = false;
|
|
632
|
+
let buffer = Buffer.alloc(0);
|
|
633
|
+
let pipeline = Promise.resolve();
|
|
634
|
+
const emitClose = () => {
|
|
635
|
+
if (closed) {
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
closed = true;
|
|
639
|
+
void this.emit("close", ctx);
|
|
640
|
+
};
|
|
641
|
+
socket.on("error", (err) => {
|
|
642
|
+
this.options.logger.error("Socket error", ctx.id, err);
|
|
643
|
+
this.emitInternalError(ctx, err);
|
|
644
|
+
});
|
|
645
|
+
socket.on("close", () => {
|
|
646
|
+
ctx.socket = null;
|
|
647
|
+
emitClose();
|
|
648
|
+
});
|
|
649
|
+
socket.on("end", () => {
|
|
650
|
+
this.options.logger.info("MTA disconnected", ctx.id);
|
|
651
|
+
ctx.socket = null;
|
|
652
|
+
emitClose();
|
|
653
|
+
});
|
|
654
|
+
socket.on("data", (chunk) => {
|
|
655
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
656
|
+
while (buffer.length >= 5) {
|
|
657
|
+
const len = buffer.readUInt32BE(0);
|
|
658
|
+
if (len < 1) {
|
|
659
|
+
this.options.logger.warn("Invalid frame length", len, "connection", ctx.id);
|
|
660
|
+
socket.destroy();
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
const total = 4 + len;
|
|
664
|
+
if (buffer.length < total) {
|
|
665
|
+
break;
|
|
666
|
+
}
|
|
667
|
+
const command = buffer.toString("ascii", 4, 5);
|
|
668
|
+
const data = buffer.subarray(5, total);
|
|
669
|
+
buffer = buffer.subarray(total);
|
|
670
|
+
pipeline = pipeline.then(() => this.parsePacket(ctx, command, data)).catch((err) => {
|
|
671
|
+
this.options.logger.error("Packet handling error", ctx.id, err);
|
|
672
|
+
this.emitInternalError(ctx, err);
|
|
673
|
+
send(ctx.socket, "t");
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
async parsePacket(ctx, command, data) {
|
|
679
|
+
switch (command) {
|
|
680
|
+
case SMFIC.OPTNEG: {
|
|
681
|
+
if (data.length < 12) {
|
|
682
|
+
this.options.logger.warn("Invalid OPTNEG payload length", data.length, "connection", ctx.id);
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
const mtaVersion = data.readUInt32BE(0);
|
|
686
|
+
const mtaActions = data.readUInt32BE(4);
|
|
687
|
+
const mtaProtocol = data.readUInt32BE(8);
|
|
688
|
+
const version = Math.min(mtaVersion, SMFI_VERSION);
|
|
689
|
+
const actions = (this.options.actions & mtaActions) >>> 0;
|
|
690
|
+
const protocol = (this.computeProtocolMask() & mtaProtocol) >>> 0;
|
|
691
|
+
ctx.setNegotiatedActions(actions);
|
|
692
|
+
const payload = Buffer.alloc(12);
|
|
693
|
+
payload.writeUInt32BE(version, 0);
|
|
694
|
+
payload.writeUInt32BE(actions, 4);
|
|
695
|
+
payload.writeUInt32BE(protocol, 8);
|
|
696
|
+
send(ctx.socket, SMFIC.OPTNEG, payload);
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
case SMFIC.CONNECT: {
|
|
700
|
+
ctx.setPhase("connect");
|
|
701
|
+
const info = parseConnectInfo(data);
|
|
702
|
+
ctx.connection = info;
|
|
703
|
+
const decision = await this.emit("connect", info, ctx);
|
|
704
|
+
this.respond(ctx, decision);
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
case SMFIC.HELO: {
|
|
708
|
+
ctx.setPhase("helo");
|
|
709
|
+
const helo = readCString(data, 0).value;
|
|
710
|
+
ctx.helo = helo;
|
|
711
|
+
const decision = await this.emit("helo", helo, ctx);
|
|
712
|
+
this.respond(ctx, decision);
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
case SMFIC.MAIL: {
|
|
716
|
+
ctx.setPhase("mail");
|
|
717
|
+
const from = readCStringList(data);
|
|
718
|
+
ctx.from = from;
|
|
719
|
+
if (this.spfOptions) {
|
|
720
|
+
await checkSpf(from, ctx, this.spfOptions);
|
|
721
|
+
}
|
|
722
|
+
const decision = await this.emit("mail", from, ctx);
|
|
723
|
+
this.respond(ctx, decision);
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
case SMFIC.RCPT: {
|
|
727
|
+
ctx.setPhase("rcpt");
|
|
728
|
+
const to = readCStringList(data);
|
|
729
|
+
ctx.to = to;
|
|
730
|
+
const decision = await this.emit("rcpt", to, ctx);
|
|
731
|
+
this.respond(ctx, decision);
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
case SMFIC.HEADER: {
|
|
735
|
+
ctx.setPhase("headerLine");
|
|
736
|
+
const p = readCString(data, 0);
|
|
737
|
+
const q = readCString(data, p.next);
|
|
738
|
+
if (p.value.length > 0) {
|
|
739
|
+
ctx.appendHeader(p.value, q.value);
|
|
740
|
+
const decision = await this.emit("headerLine", p.value, q.value, ctx);
|
|
741
|
+
this.respond(ctx, decision);
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
this.respond(ctx, void 0);
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
case SMFIC.EOH: {
|
|
748
|
+
ctx.setPhase("eoh");
|
|
749
|
+
const headers = cloneHeaders(ctx.headers);
|
|
750
|
+
const legacy = await this.emit("header", headers, ctx);
|
|
751
|
+
const modern = await this.emit("headers", headers, ctx);
|
|
752
|
+
this.respond(ctx, modern ?? legacy);
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
case SMFIC.BODY: {
|
|
756
|
+
ctx.setPhase("bodyChunk");
|
|
757
|
+
try {
|
|
758
|
+
ctx.appendBody(data);
|
|
759
|
+
} catch (err) {
|
|
760
|
+
this.emitInternalError(ctx, err);
|
|
761
|
+
this.respond(ctx, "tempfail");
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
const decision = await this.emit("bodyChunk", Buffer.from(data), ctx);
|
|
765
|
+
this.respond(ctx, decision);
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
case SMFIC.BODYEOB: {
|
|
769
|
+
ctx.setPhase("bodyEnd");
|
|
770
|
+
const body = ctx.getBody();
|
|
771
|
+
if (this.dkimOptions) {
|
|
772
|
+
await verifyDkim(body, ctx, this.dkimOptions);
|
|
773
|
+
}
|
|
774
|
+
const decision = await this.emit("bodyEnd", body, ctx);
|
|
775
|
+
this.respond(ctx, decision);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
case SMFIC.DATA: {
|
|
779
|
+
ctx.setPhase("data");
|
|
780
|
+
const decision = await this.emit("data", data.toString("utf8"), ctx);
|
|
781
|
+
this.respond(ctx, decision);
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
case SMFIC.MACRO: {
|
|
785
|
+
ctx.setPhase("macro");
|
|
786
|
+
const cmd = data.toString("ascii", 0, 1);
|
|
787
|
+
const map = readCStringPairs(data, 1);
|
|
788
|
+
ctx.macros[cmd] = map;
|
|
789
|
+
await this.emit("macro", cmd, map, ctx);
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
case SMFIC.ABORT: {
|
|
793
|
+
ctx.setPhase("abort");
|
|
794
|
+
ctx.resetMessage();
|
|
795
|
+
await this.emit("abort", ctx);
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
case SMFIC.QUIT: {
|
|
799
|
+
ctx.setPhase("close");
|
|
800
|
+
await this.emit("close", ctx);
|
|
801
|
+
if (ctx.socket) {
|
|
802
|
+
ctx.socket.end();
|
|
803
|
+
ctx.socket = null;
|
|
804
|
+
}
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
case SMFIC.UNKNOWN: {
|
|
808
|
+
ctx.setPhase("unknown");
|
|
809
|
+
const decision = await this.emit("unknown", command, data, ctx);
|
|
810
|
+
this.respond(ctx, decision);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
default: {
|
|
814
|
+
ctx.setPhase("unknown");
|
|
815
|
+
this.options.logger.warn("Unknown command", command, "connection", ctx.id);
|
|
816
|
+
const decision = await this.emit("unknown", command, data, ctx);
|
|
817
|
+
this.respond(ctx, decision);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
computeProtocolMask() {
|
|
822
|
+
let flags = DISABLE_ALL_CALLBACKS;
|
|
823
|
+
if (this.handlers.connect.size > 0) {
|
|
824
|
+
flags &= ~SMFIP.NOCONNECT;
|
|
825
|
+
}
|
|
826
|
+
if (this.handlers.helo.size > 0) {
|
|
827
|
+
flags &= ~SMFIP.NOHELO;
|
|
828
|
+
}
|
|
829
|
+
if (this.handlers.mail.size > 0 || this.spfOptions) {
|
|
830
|
+
flags &= ~SMFIP.NOMAIL;
|
|
831
|
+
}
|
|
832
|
+
if (this.handlers.rcpt.size > 0) {
|
|
833
|
+
flags &= ~SMFIP.NORCPT;
|
|
834
|
+
}
|
|
835
|
+
if (this.handlers.bodyChunk.size > 0 || this.handlers.bodyEnd.size > 0 || this.dkimOptions) {
|
|
836
|
+
flags &= ~SMFIP.NOBODY;
|
|
837
|
+
}
|
|
838
|
+
if (this.handlers.headerLine.size > 0 || this.handlers.headers.size > 0 || this.handlers.header.size > 0 || this.dkimOptions) {
|
|
839
|
+
flags &= ~SMFIP.NOHDRS;
|
|
840
|
+
}
|
|
841
|
+
if (this.handlers.headers.size > 0 || this.handlers.header.size > 0) {
|
|
842
|
+
flags &= ~SMFIP.NOEOH;
|
|
843
|
+
}
|
|
844
|
+
return flags >>> 0;
|
|
845
|
+
}
|
|
846
|
+
respond(ctx, decision) {
|
|
847
|
+
const normalized = normalizeDecision(decision, this.options.defaultDecision);
|
|
848
|
+
if (!normalized) {
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
send(ctx.socket, normalized.command, normalized.data);
|
|
852
|
+
}
|
|
853
|
+
async emit(event, ...args) {
|
|
854
|
+
const callbacks = this.handlers[event];
|
|
855
|
+
if (!callbacks || callbacks.size === 0) {
|
|
856
|
+
return void 0;
|
|
857
|
+
}
|
|
858
|
+
let result;
|
|
859
|
+
for (const fn of callbacks) {
|
|
860
|
+
result = await fn(...args);
|
|
861
|
+
}
|
|
862
|
+
return result;
|
|
863
|
+
}
|
|
864
|
+
emitInternalError(ctx, err) {
|
|
865
|
+
const callbacks = this.handlers.error;
|
|
866
|
+
if (callbacks.size === 0) {
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
for (const fn of callbacks) {
|
|
870
|
+
Promise.resolve(fn(err, ctx)).catch(() => {
|
|
871
|
+
this.options.logger.error("Error handler failed", err);
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
};
|
|
876
|
+
var Milter = class extends MilterServer {
|
|
877
|
+
constructor(options = {}) {
|
|
878
|
+
const resolved = {
|
|
879
|
+
actions: ACTION_ALL,
|
|
880
|
+
...options
|
|
881
|
+
};
|
|
882
|
+
super(resolved);
|
|
883
|
+
}
|
|
884
|
+
};
|
|
885
|
+
export {
|
|
886
|
+
ACTION_ALL,
|
|
887
|
+
ConsoleLogger,
|
|
888
|
+
DISABLE_ALL_CALLBACKS,
|
|
889
|
+
Decision,
|
|
890
|
+
Milter,
|
|
891
|
+
MilterActionError,
|
|
892
|
+
MilterContext,
|
|
893
|
+
MilterServer,
|
|
894
|
+
SMFIA,
|
|
895
|
+
SMFIC,
|
|
896
|
+
SMFIF,
|
|
897
|
+
SMFIP,
|
|
898
|
+
SMFIR,
|
|
899
|
+
SMFI_VERSION,
|
|
900
|
+
checkSpf,
|
|
901
|
+
cloneHeaders,
|
|
902
|
+
MilterServer as default,
|
|
903
|
+
frame,
|
|
904
|
+
normalizeDecision,
|
|
905
|
+
parseConnectInfo,
|
|
906
|
+
readCString,
|
|
907
|
+
readCStringList,
|
|
908
|
+
readCStringPairs,
|
|
909
|
+
send,
|
|
910
|
+
signDkim,
|
|
911
|
+
spfDecision,
|
|
912
|
+
verifyDkim
|
|
913
|
+
};
|
|
914
|
+
//# sourceMappingURL=index.js.map
|