spamscanner 7.0.4 → 7.0.6
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/dist/cjs/arf.cjs +245 -11
- package/dist/cjs/arf.cjs.map +3 -3
- package/dist/cjs/index.cjs +858 -408
- package/dist/cjs/index.cjs.map +4 -4
- package/dist/esm/arf.js +246 -12
- package/dist/esm/arf.js.map +3 -3
- package/dist/esm/cli.js +1013 -445
- package/dist/esm/cli.js.map +4 -4
- package/dist/esm/index.js +835 -383
- package/dist/esm/index.js.map +4 -4
- package/dist/types/arf.d.ts +4 -4
- package/dist/types/attachments.d.ts +3 -1
- package/dist/types/auth.d.ts +32 -2
- package/dist/types/milter.d.ts +7 -1
- package/dist/types/model.d.ts +3 -2
- package/dist/types/parse.d.ts +66 -4
- package/dist/types/reputation.d.ts +2 -1
- package/dist/types/server.d.ts +17 -0
- package/dist/types/spamd.d.ts +2 -0
- package/package.json +1 -1
package/dist/esm/cli.js
CHANGED
|
@@ -6,9 +6,14 @@ import process4 from "node:process";
|
|
|
6
6
|
// src/cli.js
|
|
7
7
|
import { Buffer as Buffer14 } from "node:buffer";
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
createReadStream as createReadStream2,
|
|
11
|
+
existsSync as existsSync3,
|
|
12
|
+
readFileSync as readFileSync2,
|
|
13
|
+
statSync as statSync2
|
|
14
|
+
} from "node:fs";
|
|
10
15
|
import process3 from "node:process";
|
|
11
|
-
import { parseArgs } from "node:util";
|
|
16
|
+
import { format, parseArgs } from "node:util";
|
|
12
17
|
|
|
13
18
|
// src/classifier.js
|
|
14
19
|
import { Buffer } from "node:buffer";
|
|
@@ -1067,10 +1072,10 @@ function detectLanguage(text) {
|
|
|
1067
1072
|
if (total === 0) {
|
|
1068
1073
|
return result;
|
|
1069
1074
|
}
|
|
1070
|
-
const [[script,
|
|
1075
|
+
const [[script, count2]] = counts;
|
|
1071
1076
|
result.script = script;
|
|
1072
1077
|
result.scripts = [...counts.keys()].filter((name) => counts.get(name) / total >= 0.1);
|
|
1073
|
-
result.confidence =
|
|
1078
|
+
result.confidence = count2 / total;
|
|
1074
1079
|
const kana = (counts.get("Hiragana") || 0) + (counts.get("Katakana") || 0);
|
|
1075
1080
|
if (kana / total >= 0.05) {
|
|
1076
1081
|
result.language = "ja";
|
|
@@ -2308,9 +2313,9 @@ function visibleText(html) {
|
|
|
2308
2313
|
}
|
|
2309
2314
|
}
|
|
2310
2315
|
};
|
|
2311
|
-
const closeImplied = ([names,
|
|
2316
|
+
const closeImplied = ([names, limits2]) => {
|
|
2312
2317
|
const index = nearest(names);
|
|
2313
|
-
if (index !== -1 && index >= (
|
|
2318
|
+
if (index !== -1 && index >= (limits2 ? nearest(limits2) + 1 : blocks.at(-1))) {
|
|
2314
2319
|
popTo(index);
|
|
2315
2320
|
}
|
|
2316
2321
|
};
|
|
@@ -2857,8 +2862,8 @@ for (const [char, latin] of Object.entries(LOOKALIKES)) {
|
|
|
2857
2862
|
function foldLookalikes(word) {
|
|
2858
2863
|
let most = "Latin";
|
|
2859
2864
|
const counts = scriptLetters(word);
|
|
2860
|
-
for (const [script,
|
|
2861
|
-
if (
|
|
2865
|
+
for (const [script, count2] of counts) {
|
|
2866
|
+
if (count2 > (counts.get(most) ?? 0)) {
|
|
2862
2867
|
most = script;
|
|
2863
2868
|
}
|
|
2864
2869
|
}
|
|
@@ -3305,18 +3310,18 @@ function addPatternFeatures(text, add) {
|
|
|
3305
3310
|
["phone", PHONE_PATTERN]
|
|
3306
3311
|
];
|
|
3307
3312
|
for (const [name, pattern] of patterns) {
|
|
3308
|
-
let
|
|
3313
|
+
let count2 = 0;
|
|
3309
3314
|
let out = "";
|
|
3310
3315
|
let from = 0;
|
|
3311
3316
|
for (const match of matchesOf(pattern, body)) {
|
|
3312
3317
|
out += `${body.slice(from, match.index)} `;
|
|
3313
3318
|
from = match.index + match[0].length;
|
|
3314
|
-
|
|
3319
|
+
count2++;
|
|
3315
3320
|
}
|
|
3316
3321
|
body = out + body.slice(from);
|
|
3317
|
-
if (
|
|
3322
|
+
if (count2 > 0) {
|
|
3318
3323
|
add(`pat:${name}`);
|
|
3319
|
-
add(`pat:${name}:${bucket(
|
|
3324
|
+
add(`pat:${name}:${bucket(count2, [1, 3, 10])}`);
|
|
3320
3325
|
}
|
|
3321
3326
|
}
|
|
3322
3327
|
MONEY_PATTERN.lastIndex = 0;
|
|
@@ -4612,15 +4617,491 @@ var LLMClassifier = class {
|
|
|
4612
4617
|
};
|
|
4613
4618
|
|
|
4614
4619
|
// src/milter.js
|
|
4615
|
-
import { Buffer as
|
|
4620
|
+
import { Buffer as Buffer8 } from "node:buffer";
|
|
4616
4621
|
import { EventEmitter } from "node:events";
|
|
4617
|
-
import
|
|
4622
|
+
import net2 from "node:net";
|
|
4618
4623
|
|
|
4619
4624
|
// src/auth.js
|
|
4620
|
-
import { Buffer as
|
|
4625
|
+
import { Buffer as Buffer6, File } from "node:buffer";
|
|
4621
4626
|
import dns2 from "node:dns";
|
|
4622
4627
|
import { debuglog } from "node:util";
|
|
4623
4628
|
|
|
4629
|
+
// src/parse.js
|
|
4630
|
+
import { Buffer as Buffer5 } from "node:buffer";
|
|
4631
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
4632
|
+
import { Readable } from "node:stream";
|
|
4633
|
+
import { MailParser, simpleParser } from "mailparser";
|
|
4634
|
+
var ADDRESS_FIELDS = /* @__PURE__ */ new Set(["from", "to", "cc", "bcc", "sender", "reply-to", "delivered-to", "return-path", "disposition-notification-to"]);
|
|
4635
|
+
var MAX_ADDRESS_FIELD = 16 * 1024;
|
|
4636
|
+
var MAX_ADDRESS_SEPARATORS = 2e3;
|
|
4637
|
+
var MAX_ADDRESS_GROUPS = 16;
|
|
4638
|
+
var MAX_ADDRESS_TOTAL = 64 * 1024;
|
|
4639
|
+
var AUTH_ADDRESS_FIELDS = /* @__PURE__ */ new Set(["from", "return-path"]);
|
|
4640
|
+
var ADDRESS_OPERATORS = /* @__PURE__ */ new Map([['"', '"'], ["(", ")"], ["<", ">"], [",", ""], [":", ";"], [";", ""]]);
|
|
4641
|
+
var SINGLE_FIELDS = /* @__PURE__ */ new Set(["message-id", "content-id", "from", "sender", "in-reply-to", "reply-to", "subject", "date", "content-disposition", "content-type", "content-transfer-encoding", "priority", "mime-version", "content-description", "precedence", "errors-to", "disposition-notification-to"]);
|
|
4642
|
+
var PRIORITY_FIELDS = /* @__PURE__ */ new Set(["priority", "x-priority", "x-msmail-priority", "importance"]);
|
|
4643
|
+
var BOUNDED2 = /* @__PURE__ */ Symbol("bounded address fields");
|
|
4644
|
+
var ROOM = /* @__PURE__ */ Symbol("room left for address fields");
|
|
4645
|
+
var PARSER_OPTIONS = {
|
|
4646
|
+
skipHtmlToText: true,
|
|
4647
|
+
skipImageLinks: true,
|
|
4648
|
+
skipTextToHtml: true,
|
|
4649
|
+
skipTextLinks: true,
|
|
4650
|
+
[BOUNDED2]: true
|
|
4651
|
+
};
|
|
4652
|
+
function boundAddressField(line, room) {
|
|
4653
|
+
const start = line.indexOf(":") + 1;
|
|
4654
|
+
const limit = Math.min(line.length, MAX_ADDRESS_FIELD, room);
|
|
4655
|
+
let end = start;
|
|
4656
|
+
let expecting = "";
|
|
4657
|
+
let escaped = false;
|
|
4658
|
+
let separators = 0;
|
|
4659
|
+
let groups = 0;
|
|
4660
|
+
let index = start;
|
|
4661
|
+
for (; index < limit; index++) {
|
|
4662
|
+
const chr = line[index];
|
|
4663
|
+
if ((chr === "," || chr === ";") && ++separators > MAX_ADDRESS_SEPARATORS) {
|
|
4664
|
+
break;
|
|
4665
|
+
}
|
|
4666
|
+
if (escaped) {
|
|
4667
|
+
escaped = false;
|
|
4668
|
+
} else if (expecting !== "" && chr === expecting) {
|
|
4669
|
+
expecting = "";
|
|
4670
|
+
end = chr === ";" ? index + 1 : end;
|
|
4671
|
+
} else if (expecting === "" && ADDRESS_OPERATORS.has(chr)) {
|
|
4672
|
+
expecting = ADDRESS_OPERATORS.get(chr);
|
|
4673
|
+
end = chr === "," || chr === ";" ? index : end;
|
|
4674
|
+
} else if (expecting === '"' && chr === "\\") {
|
|
4675
|
+
escaped = true;
|
|
4676
|
+
}
|
|
4677
|
+
if (chr === ":" && expecting === ";" && ++groups > MAX_ADDRESS_GROUPS) {
|
|
4678
|
+
break;
|
|
4679
|
+
}
|
|
4680
|
+
}
|
|
4681
|
+
return index === line.length ? line : line.slice(0, end).trimEnd();
|
|
4682
|
+
}
|
|
4683
|
+
function boundAddressLines(lines2, budget, only = null) {
|
|
4684
|
+
const cut = /* @__PURE__ */ new Set();
|
|
4685
|
+
const bounded = lines2.map((entry) => {
|
|
4686
|
+
if (only ? !only.has(entry.key) : !ADDRESS_FIELDS.has(entry.key) && !entry.key.startsWith("list-")) {
|
|
4687
|
+
return entry;
|
|
4688
|
+
}
|
|
4689
|
+
const line = boundAddressField(entry.line, budget.room);
|
|
4690
|
+
budget.room = Math.max(budget.room - line.length, 0);
|
|
4691
|
+
if (line === entry.line) {
|
|
4692
|
+
return entry;
|
|
4693
|
+
}
|
|
4694
|
+
cut.add(entry.key);
|
|
4695
|
+
return { ...entry, line };
|
|
4696
|
+
});
|
|
4697
|
+
return { lines: bounded, cut };
|
|
4698
|
+
}
|
|
4699
|
+
var { processHeaders } = MailParser.prototype;
|
|
4700
|
+
MailParser.prototype.processHeaders = function(lines2) {
|
|
4701
|
+
if (!this.options[BOUNDED2]) {
|
|
4702
|
+
return processHeaders.call(this, lines2);
|
|
4703
|
+
}
|
|
4704
|
+
this[ROOM] ??= { room: MAX_ADDRESS_TOTAL };
|
|
4705
|
+
const bounded = boundAddressLines(lines2, this[ROOM]);
|
|
4706
|
+
const headers = processHeaders.call(this, bounded.lines);
|
|
4707
|
+
const keyOf = (entry) => PRIORITY_FIELDS.has(entry.key) ? "priority" : entry.key;
|
|
4708
|
+
const counts = /* @__PURE__ */ new Map();
|
|
4709
|
+
for (const key of bounded.lines.map((entry) => keyOf(entry)).filter((key2) => SINGLE_FIELDS.has(key2))) {
|
|
4710
|
+
counts.set(key, (counts.get(key) || 0) + 1);
|
|
4711
|
+
}
|
|
4712
|
+
for (const key of [...counts.keys()].filter((key2) => counts.get(key2) > 1)) {
|
|
4713
|
+
const last = bounded.lines.findLast((entry) => keyOf(entry) === key && processHeaders.call(this, [entry]).has(key));
|
|
4714
|
+
if (last) {
|
|
4715
|
+
headers.set(key, processHeaders.call(this, [last]).get(key));
|
|
4716
|
+
}
|
|
4717
|
+
}
|
|
4718
|
+
for (const value of [...bounded.cut].flatMap((key) => headers.get(key) ?? [])) {
|
|
4719
|
+
value.truncated = true;
|
|
4720
|
+
}
|
|
4721
|
+
return headers;
|
|
4722
|
+
};
|
|
4723
|
+
function boundedForAuth(raw) {
|
|
4724
|
+
const ends = [raw.indexOf("\n\n"), raw.indexOf("\n\r\n")].filter((index) => index !== -1);
|
|
4725
|
+
const end = ends.length > 0 ? Math.min(...ends) + 1 : raw.length;
|
|
4726
|
+
const body = raw.subarray(ends.length > 0 ? raw.indexOf(10, end) + 1 : raw.length);
|
|
4727
|
+
const text = raw.subarray(0, end).toString("latin1");
|
|
4728
|
+
let { length } = text;
|
|
4729
|
+
while (length > 0 && (text[length - 1] === "\n" || text[length - 1] === "\r")) {
|
|
4730
|
+
length--;
|
|
4731
|
+
}
|
|
4732
|
+
const fields = [];
|
|
4733
|
+
let folds = 0;
|
|
4734
|
+
let work = 0;
|
|
4735
|
+
for (let start = 0; start <= length; ) {
|
|
4736
|
+
let stop = text.indexOf("\n", start);
|
|
4737
|
+
stop = stop === -1 || stop > length ? length : stop;
|
|
4738
|
+
const lineEnd = stop > start && text[stop - 1] === "\r" ? stop - 1 : stop;
|
|
4739
|
+
const last = fields.at(-1);
|
|
4740
|
+
if (last && /\s/.test(text[start])) {
|
|
4741
|
+
last.end = lineEnd;
|
|
4742
|
+
last.folds++;
|
|
4743
|
+
folds++;
|
|
4744
|
+
work += last.folds;
|
|
4745
|
+
} else {
|
|
4746
|
+
fields.push({ start, end: lineEnd, folds: 0 });
|
|
4747
|
+
}
|
|
4748
|
+
start = stop + 1;
|
|
4749
|
+
}
|
|
4750
|
+
let signatures = 0;
|
|
4751
|
+
let dkim = 0;
|
|
4752
|
+
for (const field of fields) {
|
|
4753
|
+
field.line = text.slice(field.start, field.end);
|
|
4754
|
+
const colon = field.line.indexOf(":");
|
|
4755
|
+
field.key = (colon === -1 ? field.line : field.line.slice(0, colon)).trim().toLowerCase();
|
|
4756
|
+
field.dropped = field.key === "dkim-signature" && ++dkim > MAX_DKIM_SIGNATURES;
|
|
4757
|
+
if (SIGNATURE_FIELDS.has(field.key) && !field.dropped) {
|
|
4758
|
+
signatures += field.line.split(":").length - 1;
|
|
4759
|
+
}
|
|
4760
|
+
}
|
|
4761
|
+
const read = dkim > MAX_DKIM_SIGNATURES ? fields.filter((field) => !field.dropped) : fields;
|
|
4762
|
+
work += (folds + signatures) * read.length;
|
|
4763
|
+
const bounded = boundAddressLines(read, { room: MAX_ADDRESS_TOTAL }, AUTH_ADDRESS_FIELDS);
|
|
4764
|
+
let kept = bounded.lines;
|
|
4765
|
+
let fromCut = bounded.cut.has("from");
|
|
4766
|
+
if (work > MAX_AUTH_WORK) {
|
|
4767
|
+
const counts = /* @__PURE__ */ new Map();
|
|
4768
|
+
kept = kept.filter((field) => {
|
|
4769
|
+
counts.set(field.key, (counts.get(field.key) || 0) + 1);
|
|
4770
|
+
const keep = AUTH_FIELDS.has(field.key) && counts.get(field.key) <= (SIGNATURE_FIELDS.has(field.key) ? MAX_SIGNATURES : MAX_REPEATED);
|
|
4771
|
+
fromCut ||= field.key === "from" && !keep;
|
|
4772
|
+
return keep;
|
|
4773
|
+
}).map((field) => {
|
|
4774
|
+
const line = field.line.replaceAll(/\r?\n(?=\s)/g, "");
|
|
4775
|
+
return { ...field, line: ADDRESS_FIELDS.has(field.key) ? line : line.slice(0, MAX_FIELD) };
|
|
4776
|
+
});
|
|
4777
|
+
} else if (bounded.cut.size === 0 && read === fields) {
|
|
4778
|
+
return { raw, fromCut };
|
|
4779
|
+
}
|
|
4780
|
+
const header = kept.map((field) => field.line).join("\r\n");
|
|
4781
|
+
return { raw: Buffer5.concat([Buffer5.from(`${header}\r
|
|
4782
|
+
\r
|
|
4783
|
+
`, "latin1"), body]), fromCut };
|
|
4784
|
+
}
|
|
4785
|
+
var TOO_MANY_PARTS = "Max allowed child nodes exceeded";
|
|
4786
|
+
var HEADER_TOO_LARGE = "Max header size for a MIME node exceeded";
|
|
4787
|
+
var MAX_PART_LINES = 990;
|
|
4788
|
+
var MAX_PREFIX_BYTES = 1e6;
|
|
4789
|
+
var MAX_LINE2 = 998;
|
|
4790
|
+
var MAX_FIELD = 8192;
|
|
4791
|
+
var MAX_HEADER = 512 * 1024;
|
|
4792
|
+
var MAX_REPEATED = 100;
|
|
4793
|
+
var KEPT_FIELDS = /* @__PURE__ */ new Set([
|
|
4794
|
+
"from",
|
|
4795
|
+
"sender",
|
|
4796
|
+
"reply-to",
|
|
4797
|
+
"to",
|
|
4798
|
+
"cc",
|
|
4799
|
+
"subject",
|
|
4800
|
+
"date",
|
|
4801
|
+
"message-id",
|
|
4802
|
+
"return-path",
|
|
4803
|
+
"received",
|
|
4804
|
+
"authentication-results",
|
|
4805
|
+
"arc-authentication-results",
|
|
4806
|
+
"dkim-signature",
|
|
4807
|
+
"mime-version",
|
|
4808
|
+
"content-type",
|
|
4809
|
+
"content-transfer-encoding",
|
|
4810
|
+
"list-unsubscribe",
|
|
4811
|
+
"list-id"
|
|
4812
|
+
]);
|
|
4813
|
+
var MAX_AUTH_WORK = 1e7;
|
|
4814
|
+
var SIGNATURE_FIELDS = /* @__PURE__ */ new Set(["dkim-signature", "arc-message-signature"]);
|
|
4815
|
+
var AUTH_FIELDS = /* @__PURE__ */ new Set([...KEPT_FIELDS, "arc-seal", "arc-message-signature"]);
|
|
4816
|
+
var MAX_SIGNATURES = 10;
|
|
4817
|
+
var MAX_DKIM_SIGNATURES = 8;
|
|
4818
|
+
var MAX_NESTED_DEPTH = 3;
|
|
4819
|
+
var MAX_ATTACHMENTS = 100;
|
|
4820
|
+
var MAX_NESTED_BYTES = 25 * 1024 * 1024;
|
|
4821
|
+
var PARSE_TIMEOUT = 6e4;
|
|
4822
|
+
async function simpleParse(raw, { timeout = PARSE_TIMEOUT, parser = simpleParser } = {}) {
|
|
4823
|
+
const input = Readable.from([finishable(unfoldHeaders(raw))], { objectMode: false, autoDestroy: false });
|
|
4824
|
+
input.on("error", () => {
|
|
4825
|
+
});
|
|
4826
|
+
const { pipe } = input;
|
|
4827
|
+
input.pipe = function(parser2, options) {
|
|
4828
|
+
parser2.splitter?.on("error", (error) => input.destroy(error));
|
|
4829
|
+
return pipe.call(this, parser2, options);
|
|
4830
|
+
};
|
|
4831
|
+
let timer;
|
|
4832
|
+
try {
|
|
4833
|
+
return await Promise.race([
|
|
4834
|
+
parser(input, PARSER_OPTIONS),
|
|
4835
|
+
new Promise((resolve, reject) => {
|
|
4836
|
+
timer = setTimeout(() => {
|
|
4837
|
+
const error = Object.assign(new Error(`Message not parsed within ${timeout} ms`), { code: "ETIMEDOUT" });
|
|
4838
|
+
input.destroy(error);
|
|
4839
|
+
reject(error);
|
|
4840
|
+
}, timeout);
|
|
4841
|
+
})
|
|
4842
|
+
]);
|
|
4843
|
+
} finally {
|
|
4844
|
+
clearTimeout(timer);
|
|
4845
|
+
input.destroy();
|
|
4846
|
+
}
|
|
4847
|
+
}
|
|
4848
|
+
async function parseMessage(raw, options = {}) {
|
|
4849
|
+
const buffer = Buffer5.isBuffer(raw) ? raw : Buffer5.from(raw);
|
|
4850
|
+
const mail = await simpleParse(buffer, options);
|
|
4851
|
+
const added = buffer.at(-1) === 10 ? 2 : 3;
|
|
4852
|
+
for (const key of ["text", "html"]) {
|
|
4853
|
+
if (typeof mail[key] === "string") {
|
|
4854
|
+
mail[key] = mail[key].replace(new RegExp(`\\n{0,${added}}$`), "");
|
|
4855
|
+
}
|
|
4856
|
+
}
|
|
4857
|
+
const tail = Buffer5.from("\r\n".repeat(added));
|
|
4858
|
+
const last = mail.attachments?.at(-1);
|
|
4859
|
+
const encoding = String(last?.headers?.get?.("content-transfer-encoding") ?? "7bit").trim().toLowerCase();
|
|
4860
|
+
const own = Buffer5.isBuffer(last?.content) && last.content.length >= tail.length && last.content.subarray(-tail.length).equals(tail) ? last.content.subarray(0, -tail.length) : null;
|
|
4861
|
+
if (own && ["7bit", "8bit", "binary"].includes(encoding) && own.length <= buffer.length && buffer.subarray(buffer.length - own.length).equals(own)) {
|
|
4862
|
+
last.content = own;
|
|
4863
|
+
last.size = own.length;
|
|
4864
|
+
last.checksum = createHash2("md5").update(own).digest("hex");
|
|
4865
|
+
}
|
|
4866
|
+
return mail;
|
|
4867
|
+
}
|
|
4868
|
+
function finishable(raw) {
|
|
4869
|
+
const buffer = Buffer5.isBuffer(raw) ? raw : Buffer5.from(raw);
|
|
4870
|
+
return Buffer5.concat([buffer, Buffer5.from(buffer.at(-1) === 10 ? "\r\n\r\n" : "\r\n\r\n\r\n")]);
|
|
4871
|
+
}
|
|
4872
|
+
var MAX_FOLDS = 256;
|
|
4873
|
+
function headerValue2(line) {
|
|
4874
|
+
const parameters = /* @__PURE__ */ new Map();
|
|
4875
|
+
let key = null;
|
|
4876
|
+
let inKey = false;
|
|
4877
|
+
let quoted = false;
|
|
4878
|
+
let escaped = false;
|
|
4879
|
+
let value = "";
|
|
4880
|
+
for (const chr of line.slice(line.indexOf(":") + 1).replaceAll(/\r?\n[ \t]*/g, " ")) {
|
|
4881
|
+
if (inKey && chr === "=") {
|
|
4882
|
+
key = value.trim().toLowerCase();
|
|
4883
|
+
inKey = false;
|
|
4884
|
+
value = "";
|
|
4885
|
+
} else if (inKey || escaped) {
|
|
4886
|
+
value += chr;
|
|
4887
|
+
escaped = false;
|
|
4888
|
+
} else if (chr === "\\") {
|
|
4889
|
+
escaped = true;
|
|
4890
|
+
} else if (chr === '"') {
|
|
4891
|
+
quoted = !quoted;
|
|
4892
|
+
} else if (chr === ";" && !quoted) {
|
|
4893
|
+
parameters.set(key, value.trim());
|
|
4894
|
+
inKey = true;
|
|
4895
|
+
value = "";
|
|
4896
|
+
} else {
|
|
4897
|
+
value += chr;
|
|
4898
|
+
}
|
|
4899
|
+
}
|
|
4900
|
+
if (!inKey) {
|
|
4901
|
+
parameters.set(key, value.trim());
|
|
4902
|
+
}
|
|
4903
|
+
return { value: parameters.get(null).toLowerCase(), boundary: parameters.get("boundary") ?? "" };
|
|
4904
|
+
}
|
|
4905
|
+
function delimiter(raw, start, stop, boundary) {
|
|
4906
|
+
const rest = start + 2 + boundary.length;
|
|
4907
|
+
if (stop - start < boundary.length + 2 || stop - rest > 4 || raw[start] !== 45 || raw[start + 1] !== 45 || !raw.subarray(start + 2, rest).equals(boundary)) {
|
|
4908
|
+
return 0;
|
|
4909
|
+
}
|
|
4910
|
+
if (raw[rest] === 13 || raw[rest] === 10) {
|
|
4911
|
+
return 1;
|
|
4912
|
+
}
|
|
4913
|
+
const tail = raw.subarray(rest, stop).toString("latin1");
|
|
4914
|
+
return /^-{0,2}$|^--\r?\n$|^--\r$/.test(tail) ? 2 : 0;
|
|
4915
|
+
}
|
|
4916
|
+
function unfoldHeaders(raw) {
|
|
4917
|
+
const boundaries = [];
|
|
4918
|
+
const unfold = [];
|
|
4919
|
+
let head = true;
|
|
4920
|
+
let fields = [];
|
|
4921
|
+
const endHead = (blank) => {
|
|
4922
|
+
unfold.push(...fields.filter((field) => field.folds > MAX_FOLDS));
|
|
4923
|
+
const first = /* @__PURE__ */ new Map();
|
|
4924
|
+
for (const field of fields) {
|
|
4925
|
+
const name = raw.toString("latin1", field.start, Math.min(field.end, field.start + 32));
|
|
4926
|
+
const key = name.slice(0, name.indexOf(":")).trim().toLowerCase();
|
|
4927
|
+
if (["content-type", "content-transfer-encoding", "content-disposition"].includes(key) && !first.has(key)) {
|
|
4928
|
+
first.set(key, headerValue2(raw.toString("latin1", field.start, field.end)));
|
|
4929
|
+
}
|
|
4930
|
+
}
|
|
4931
|
+
fields = [];
|
|
4932
|
+
const type = first.get("content-type") ?? { value: "", boundary: "" };
|
|
4933
|
+
const encoding = (first.get("content-transfer-encoding")?.value ?? "").replaceAll(/\(.*\)/g, "").trim();
|
|
4934
|
+
if (blank && type.boundary) {
|
|
4935
|
+
boundaries.push(Buffer5.from(type.boundary));
|
|
4936
|
+
}
|
|
4937
|
+
head = blank && type.value === "message/rfc822" && ["", "7bit", "8bit", "binary"].includes(encoding) && first.get("content-disposition")?.value === "inline";
|
|
4938
|
+
};
|
|
4939
|
+
for (let start = 0; start < raw.length; ) {
|
|
4940
|
+
const lf = raw.indexOf(10, start);
|
|
4941
|
+
const stop = lf === -1 ? raw.length : lf + 1;
|
|
4942
|
+
const end = lf === -1 ? stop : raw[lf - 1] === 13 ? lf - 1 : lf;
|
|
4943
|
+
const kind = boundaries.length > 0 ? delimiter(raw, start, stop, boundaries.at(-1)) : 0;
|
|
4944
|
+
if (kind !== 0) {
|
|
4945
|
+
if (head) {
|
|
4946
|
+
endHead(false);
|
|
4947
|
+
}
|
|
4948
|
+
head = kind === 1;
|
|
4949
|
+
if (kind === 2) {
|
|
4950
|
+
boundaries.pop();
|
|
4951
|
+
}
|
|
4952
|
+
} else if (head && end === start) {
|
|
4953
|
+
endHead(true);
|
|
4954
|
+
} else if (head && fields.length > 0 && (raw[start] === 32 || raw[start] === 9)) {
|
|
4955
|
+
fields.at(-1).end = end;
|
|
4956
|
+
fields.at(-1).folds++;
|
|
4957
|
+
} else if (head) {
|
|
4958
|
+
fields.push({ start, end, folds: 0 });
|
|
4959
|
+
}
|
|
4960
|
+
start = stop;
|
|
4961
|
+
}
|
|
4962
|
+
unfold.push(...fields.filter((field) => field.folds > MAX_FOLDS));
|
|
4963
|
+
if (unfold.length === 0) {
|
|
4964
|
+
return raw;
|
|
4965
|
+
}
|
|
4966
|
+
const chunks = [];
|
|
4967
|
+
let copied = 0;
|
|
4968
|
+
for (const field of unfold) {
|
|
4969
|
+
chunks.push(raw.subarray(copied, field.start), Buffer5.from(raw.toString("latin1", field.start, field.end).replaceAll(/\r?\n[ \t]*/g, " "), "latin1"));
|
|
4970
|
+
copied = field.end;
|
|
4971
|
+
}
|
|
4972
|
+
chunks.push(raw.subarray(copied));
|
|
4973
|
+
return Buffer5.concat(chunks);
|
|
4974
|
+
}
|
|
4975
|
+
function upToLine(raw, offset) {
|
|
4976
|
+
return raw.subarray(0, Math.max(raw.lastIndexOf(10, offset - 1) + 1, 0));
|
|
4977
|
+
}
|
|
4978
|
+
function fewerParts(raw) {
|
|
4979
|
+
let offset = -1;
|
|
4980
|
+
for (let count2 = 0; count2 < MAX_PART_LINES; count2++) {
|
|
4981
|
+
offset = raw.indexOf("\n--", offset + 1);
|
|
4982
|
+
if (offset === -1) {
|
|
4983
|
+
return upToLine(raw, Math.floor(raw.length / 2));
|
|
4984
|
+
}
|
|
4985
|
+
}
|
|
4986
|
+
return raw.subarray(0, offset + 1);
|
|
4987
|
+
}
|
|
4988
|
+
function smallerHeader(raw) {
|
|
4989
|
+
const text = raw.toString("latin1");
|
|
4990
|
+
const blank = /\r?\n\r?\n/.exec(text);
|
|
4991
|
+
const end = blank ? blank.index : text.length;
|
|
4992
|
+
const fields = [];
|
|
4993
|
+
let start = 0;
|
|
4994
|
+
while (start < end) {
|
|
4995
|
+
let stop = text.indexOf("\n", start);
|
|
4996
|
+
stop = stop === -1 || stop > end ? end : stop;
|
|
4997
|
+
const last = fields.at(-1);
|
|
4998
|
+
const whole = text.slice(start, stop).replace(/\r$/, "");
|
|
4999
|
+
if (last && (whole.startsWith(" ") || whole.startsWith(" "))) {
|
|
5000
|
+
const line = last.address ? whole : whole.slice(0, MAX_LINE2);
|
|
5001
|
+
if (last.address || last.size + line.length <= MAX_FIELD) {
|
|
5002
|
+
last.lines.push(line);
|
|
5003
|
+
last.size += line.length;
|
|
5004
|
+
}
|
|
5005
|
+
} else {
|
|
5006
|
+
const name = whole.slice(0, whole.indexOf(":")).trim().toLowerCase();
|
|
5007
|
+
const address2 = ADDRESS_FIELDS.has(name) || name.startsWith("list-");
|
|
5008
|
+
const line = address2 ? whole : whole.slice(0, MAX_LINE2);
|
|
5009
|
+
fields.push({
|
|
5010
|
+
name,
|
|
5011
|
+
address: address2,
|
|
5012
|
+
lines: [line],
|
|
5013
|
+
size: line.length
|
|
5014
|
+
});
|
|
5015
|
+
}
|
|
5016
|
+
start = stop + 1;
|
|
5017
|
+
}
|
|
5018
|
+
let fromCut = false;
|
|
5019
|
+
for (const field of fields.filter((field2) => field2.address)) {
|
|
5020
|
+
const line = field.lines.join("\r\n");
|
|
5021
|
+
const bounded = boundAddressField(line, Number.POSITIVE_INFINITY);
|
|
5022
|
+
fromCut ||= field.name === "from" && bounded !== line;
|
|
5023
|
+
field.lines = [bounded];
|
|
5024
|
+
field.size = bounded.length;
|
|
5025
|
+
}
|
|
5026
|
+
const total = fields.reduce((sum, field) => sum + field.size, 0);
|
|
5027
|
+
const counts = /* @__PURE__ */ new Map();
|
|
5028
|
+
const kept = total > MAX_HEADER ? fields.filter((field) => {
|
|
5029
|
+
counts.set(field.name, (counts.get(field.name) || 0) + 1);
|
|
5030
|
+
const keep = KEPT_FIELDS.has(field.name) && counts.get(field.name) <= MAX_REPEATED;
|
|
5031
|
+
fromCut ||= field.name === "from" && !keep;
|
|
5032
|
+
return keep;
|
|
5033
|
+
}) : fields;
|
|
5034
|
+
const header = kept.flatMap((field) => field.lines).join("\r\n");
|
|
5035
|
+
return { raw: Buffer5.concat([Buffer5.from(`${header}\r
|
|
5036
|
+
\r
|
|
5037
|
+
`, "latin1"), raw.subarray(blank ? blank.index + blank[0].length : raw.length)]), fromCut };
|
|
5038
|
+
}
|
|
5039
|
+
async function parseWithin(raw, parse = parseMessage, deadline = Date.now() + PARSE_TIMEOUT) {
|
|
5040
|
+
let input = raw;
|
|
5041
|
+
let reason = null;
|
|
5042
|
+
let fromCut = false;
|
|
5043
|
+
for (; ; ) {
|
|
5044
|
+
try {
|
|
5045
|
+
const mail = await parse(input, { timeout: Math.max(1, deadline - Date.now()) });
|
|
5046
|
+
if (fromCut && mail.from) {
|
|
5047
|
+
mail.from.truncated = true;
|
|
5048
|
+
}
|
|
5049
|
+
return { mail, error: reason };
|
|
5050
|
+
} catch (error) {
|
|
5051
|
+
const limit = error.code === "EMAXLEN" && [TOO_MANY_PARTS, HEADER_TOO_LARGE].includes(error.message) ? error.message : null;
|
|
5052
|
+
let shorter = input;
|
|
5053
|
+
if (limit === TOO_MANY_PARTS) {
|
|
5054
|
+
shorter = fewerParts(input);
|
|
5055
|
+
} else if (limit === HEADER_TOO_LARGE) {
|
|
5056
|
+
const smaller = smallerHeader(input);
|
|
5057
|
+
shorter = smaller.raw.length < input.length ? smaller.raw : upToLine(input, MAX_PREFIX_BYTES);
|
|
5058
|
+
fromCut ||= smaller.raw === shorter && smaller.fromCut;
|
|
5059
|
+
}
|
|
5060
|
+
if (shorter.length >= input.length) {
|
|
5061
|
+
throw error;
|
|
5062
|
+
}
|
|
5063
|
+
reason ??= limit;
|
|
5064
|
+
input = shorter;
|
|
5065
|
+
}
|
|
5066
|
+
}
|
|
5067
|
+
}
|
|
5068
|
+
async function expandAttachments(attachments = [], errors = [], parse = parseMessage) {
|
|
5069
|
+
const all = [];
|
|
5070
|
+
const deadline = Date.now() + PARSE_TIMEOUT;
|
|
5071
|
+
let budget = MAX_NESTED_BYTES;
|
|
5072
|
+
let nested = 0;
|
|
5073
|
+
const visit = async (list, prefix, depth) => {
|
|
5074
|
+
for (const attachment of list) {
|
|
5075
|
+
if (prefix) {
|
|
5076
|
+
if (nested >= MAX_ATTACHMENTS) {
|
|
5077
|
+
return;
|
|
5078
|
+
}
|
|
5079
|
+
nested++;
|
|
5080
|
+
}
|
|
5081
|
+
const name = attachment.filename || (prefix ? "unnamed attachment" : "");
|
|
5082
|
+
all.push(prefix ? { ...attachment, filename: `${prefix}/${name}` } : attachment);
|
|
5083
|
+
const { content } = attachment;
|
|
5084
|
+
const attached = attachment.contentType === "message/rfc822" || /\.eml$/i.test(attachment.filename || "");
|
|
5085
|
+
if (attached && depth < MAX_NESTED_DEPTH && Buffer5.isBuffer(content) && content.length <= budget) {
|
|
5086
|
+
budget -= content.length;
|
|
5087
|
+
let inner;
|
|
5088
|
+
try {
|
|
5089
|
+
inner = await parseWithin(content, parse, deadline);
|
|
5090
|
+
} catch (error) {
|
|
5091
|
+
errors.push(`${name || "attached message"}: ${error.message}`);
|
|
5092
|
+
continue;
|
|
5093
|
+
}
|
|
5094
|
+
if (inner.error) {
|
|
5095
|
+
errors.push(inner.error);
|
|
5096
|
+
}
|
|
5097
|
+
await visit(inner.mail.attachments, prefix ? `${prefix}/${name}` : name || "attached message", depth + 1);
|
|
5098
|
+
}
|
|
5099
|
+
}
|
|
5100
|
+
};
|
|
5101
|
+
await visit(attachments, "", 0);
|
|
5102
|
+
return all;
|
|
5103
|
+
}
|
|
5104
|
+
|
|
4624
5105
|
// src/reputation.js
|
|
4625
5106
|
import { isIP as isIP2 } from "node:net";
|
|
4626
5107
|
import { domainToASCII } from "node:url";
|
|
@@ -4662,11 +5143,11 @@ function normalizeIp(ip) {
|
|
|
4662
5143
|
return ipv4;
|
|
4663
5144
|
}
|
|
4664
5145
|
const embedded = groups.slice(0, 4).every((group) => group === 0) && groups[4] === 65535 && groups[5] === 0 || groups[0] === 100 && groups[1] === 65435 && groups.slice(2, 6).every((group) => group === 0);
|
|
4665
|
-
const
|
|
5146
|
+
const count2 = embedded ? 6 : 8;
|
|
4666
5147
|
let run = { start: -1, length: 1 };
|
|
4667
|
-
for (let i = 0; i <
|
|
5148
|
+
for (let i = 0; i < count2; i++) {
|
|
4668
5149
|
let length = 0;
|
|
4669
|
-
while (i + length <
|
|
5150
|
+
while (i + length < count2 && groups[i + length] === 0) {
|
|
4670
5151
|
length++;
|
|
4671
5152
|
}
|
|
4672
5153
|
if (length > run.length) {
|
|
@@ -4674,7 +5155,7 @@ function normalizeIp(ip) {
|
|
|
4674
5155
|
}
|
|
4675
5156
|
i += length;
|
|
4676
5157
|
}
|
|
4677
|
-
const hex = groups.slice(0,
|
|
5158
|
+
const hex = groups.slice(0, count2).map((group) => group.toString(16));
|
|
4678
5159
|
const written = run.start === -1 ? hex.join(":") : `${hex.slice(0, run.start).join(":")}::${hex.slice(run.start + run.length).join(":")}`;
|
|
4679
5160
|
if (!embedded) {
|
|
4680
5161
|
return written;
|
|
@@ -5121,7 +5602,7 @@ function trustedReputationValues(mail = {}, session = {}, authentication = null)
|
|
|
5121
5602
|
...[spfAligned].filter((signer) => signer !== null && (alignment?.spf?.strict !== true || signer === domain))
|
|
5122
5603
|
])];
|
|
5123
5604
|
const fromValues = [];
|
|
5124
|
-
for (const { address: address2 } of mail.from?.value || []) {
|
|
5605
|
+
for (const { address: address2 } of mail.from?.truncated ? [] : mail.from?.value || []) {
|
|
5125
5606
|
const domain = domainOf(address2);
|
|
5126
5607
|
const values = reputationValues({ from: { value: [{ address: address2 }] } });
|
|
5127
5608
|
if (domain !== null && dmarcDomain !== null && (domain === dmarcDomain || registrableDomain(domain) === dmarcDomain)) {
|
|
@@ -5184,6 +5665,37 @@ function createResolver(timeout = 1e4, servers) {
|
|
|
5184
5665
|
}
|
|
5185
5666
|
};
|
|
5186
5667
|
}
|
|
5668
|
+
var MAX_DNS_QUERIES = 300;
|
|
5669
|
+
var MAX_AUTH_RESULTS_LENGTH = 8192;
|
|
5670
|
+
function dnsTimeout(message) {
|
|
5671
|
+
const error = new Error(message);
|
|
5672
|
+
error.code = "ETIMEOUT";
|
|
5673
|
+
return error;
|
|
5674
|
+
}
|
|
5675
|
+
function limitResolver(resolver, { maxQueries = MAX_DNS_QUERIES, deadline = Number.POSITIVE_INFINITY } = {}) {
|
|
5676
|
+
let count2 = 0;
|
|
5677
|
+
return async (...args) => {
|
|
5678
|
+
count2++;
|
|
5679
|
+
if (count2 > maxQueries) {
|
|
5680
|
+
throw dnsTimeout(`More than ${maxQueries} DNS lookups for one message`);
|
|
5681
|
+
}
|
|
5682
|
+
const left = deadline - Date.now();
|
|
5683
|
+
if (left <= 0) {
|
|
5684
|
+
throw dnsTimeout("Out of time for this message's DNS lookups");
|
|
5685
|
+
}
|
|
5686
|
+
let timer;
|
|
5687
|
+
try {
|
|
5688
|
+
return await Promise.race([
|
|
5689
|
+
resolver(...args),
|
|
5690
|
+
new Promise((_resolve, reject) => {
|
|
5691
|
+
timer = setTimeout(() => reject(dnsTimeout("Out of time for this message's DNS lookups")), Math.min(left, 2147483647));
|
|
5692
|
+
})
|
|
5693
|
+
]);
|
|
5694
|
+
} finally {
|
|
5695
|
+
clearTimeout(timer);
|
|
5696
|
+
}
|
|
5697
|
+
};
|
|
5698
|
+
}
|
|
5187
5699
|
function emptyResult() {
|
|
5188
5700
|
return {
|
|
5189
5701
|
dkim: { status: { result: "none", comment: "not checked" }, results: [], aligned: null },
|
|
@@ -5323,9 +5835,14 @@ async function authenticate(message, options = {}) {
|
|
|
5323
5835
|
}
|
|
5324
5836
|
try {
|
|
5325
5837
|
const { authenticate: run } = await getMailauth();
|
|
5326
|
-
const resolver = options.resolver || createResolver(timeout, options.dnsServers)
|
|
5327
|
-
|
|
5328
|
-
|
|
5838
|
+
const resolver = limitResolver(options.resolver || createResolver(timeout, options.dnsServers), {
|
|
5839
|
+
maxQueries: options.maxDnsQueries ?? MAX_DNS_QUERIES,
|
|
5840
|
+
// (a little after `timeout`, so a caller that waits that long for the
|
|
5841
|
+
// result, as scan() does, has its answer first)
|
|
5842
|
+
deadline: Date.now() + timeout + 100
|
|
5843
|
+
});
|
|
5844
|
+
const bounded = boundedForAuth(Buffer6.isBuffer(message) ? message : Buffer6.from(String(message)));
|
|
5845
|
+
const output = await run(bounded.raw, {
|
|
5329
5846
|
ip,
|
|
5330
5847
|
helo: helo || void 0,
|
|
5331
5848
|
sender: sender ?? void 0,
|
|
@@ -5333,6 +5850,10 @@ async function authenticate(message, options = {}) {
|
|
|
5333
5850
|
resolver
|
|
5334
5851
|
});
|
|
5335
5852
|
Object.assign(result, normalizeAuthOutput(output, { trustedArcSealers: options.trustedArcSealers }));
|
|
5853
|
+
if (bounded.fromCut) {
|
|
5854
|
+
result.dmarc = { ...result.dmarc, status: { result: "fail", comment: "From field too long to read whole" } };
|
|
5855
|
+
result.headers = "";
|
|
5856
|
+
}
|
|
5336
5857
|
} catch (error) {
|
|
5337
5858
|
debug("authentication failed: %s", error.message);
|
|
5338
5859
|
result.error = error.message;
|
|
@@ -5383,11 +5904,24 @@ function calculateAuthScore(auth, weights = {}) {
|
|
|
5383
5904
|
}
|
|
5384
5905
|
return { score: tests.reduce((sum, test) => sum + test.score, 0), tests };
|
|
5385
5906
|
}
|
|
5907
|
+
function limitResults(value) {
|
|
5908
|
+
if (value.length <= MAX_AUTH_RESULTS_LENGTH) {
|
|
5909
|
+
return value;
|
|
5910
|
+
}
|
|
5911
|
+
const [server, ...results] = value.split(/;(?=\s*(?:dkim|spf|dmarc|arc|bimi)=)/i);
|
|
5912
|
+
let kept = server;
|
|
5913
|
+
for (const result of results) {
|
|
5914
|
+
if (kept.length + 1 + result.length <= MAX_AUTH_RESULTS_LENGTH) {
|
|
5915
|
+
kept += `;${result}`;
|
|
5916
|
+
}
|
|
5917
|
+
}
|
|
5918
|
+
return kept;
|
|
5919
|
+
}
|
|
5386
5920
|
function formatAuthResultsHeader(auth, hostname) {
|
|
5387
5921
|
if (typeof auth?.headers === "string") {
|
|
5388
5922
|
const match = auth.headers.match(/^authentication-results:[ \t]*([\s\S]*?)(?=\r?\n\S|(?![\s\S]))/im);
|
|
5389
5923
|
if (match) {
|
|
5390
|
-
const value = match[1].replaceAll(/\r?\n/g, "\r\n").trim();
|
|
5924
|
+
const value = limitResults(match[1].replaceAll(/\r?\n/g, "\r\n").trim());
|
|
5391
5925
|
return hostname ? value.replace(/^[^;\s]+/, hostname) : value;
|
|
5392
5926
|
}
|
|
5393
5927
|
}
|
|
@@ -5401,11 +5935,17 @@ function summarizeAuth(auth) {
|
|
|
5401
5935
|
return ["spf", "dkim", "dmarc", "arc"].map((name) => `${name}=${auth?.[name]?.status?.result || "none"}`).join(" ");
|
|
5402
5936
|
}
|
|
5403
5937
|
|
|
5938
|
+
// src/server.js
|
|
5939
|
+
import { Buffer as Buffer7 } from "node:buffer";
|
|
5940
|
+
import { timingSafeEqual } from "node:crypto";
|
|
5941
|
+
import http2 from "node:http";
|
|
5942
|
+
import net from "node:net";
|
|
5943
|
+
|
|
5404
5944
|
// package.json
|
|
5405
5945
|
var package_default = {
|
|
5406
5946
|
name: "spamscanner",
|
|
5407
5947
|
description: "Spam, phishing and malware filter for email in any language: a trainable classifier, link and attachment checks, SPF/DKIM/DMARC, and an optional AI second opinion from any LLM, including local Ollama models. Works as a library, CLI, Postfix milter or HTTP API.",
|
|
5408
|
-
version: "7.0.
|
|
5948
|
+
version: "7.0.6",
|
|
5409
5949
|
author: "Forward Email (https://forwardemail.net)",
|
|
5410
5950
|
bin: {
|
|
5411
5951
|
spamscanner: "./dist/esm/cli.js"
|
|
@@ -5551,29 +6091,212 @@ var package_default = {
|
|
|
5551
6091
|
// src/version.js
|
|
5552
6092
|
var VERSION = package_default.version;
|
|
5553
6093
|
|
|
5554
|
-
// src/
|
|
5555
|
-
var
|
|
5556
|
-
|
|
5557
|
-
|
|
5558
|
-
|
|
5559
|
-
|
|
5560
|
-
|
|
5561
|
-
|
|
5562
|
-
|
|
5563
|
-
|
|
5564
|
-
|
|
5565
|
-
|
|
5566
|
-
|
|
5567
|
-
|
|
5568
|
-
|
|
5569
|
-
|
|
5570
|
-
|
|
5571
|
-
|
|
5572
|
-
|
|
5573
|
-
|
|
5574
|
-
|
|
5575
|
-
|
|
5576
|
-
|
|
6094
|
+
// src/server.js
|
|
6095
|
+
var MAX_MESSAGE_SIZE = 25 * 1024 * 1024;
|
|
6096
|
+
var MAX_CONNECTIONS = 200;
|
|
6097
|
+
function serializeResult(result, options = {}) {
|
|
6098
|
+
const { mail, tokens, ...rest } = result;
|
|
6099
|
+
const output = {
|
|
6100
|
+
...rest,
|
|
6101
|
+
results: { ...result.results }
|
|
6102
|
+
};
|
|
6103
|
+
if (output.results.authentication) {
|
|
6104
|
+
const { receivedChain, ...auth } = output.results.authentication;
|
|
6105
|
+
output.results.authentication = auth;
|
|
6106
|
+
}
|
|
6107
|
+
if (options.verbose) {
|
|
6108
|
+
output.tokens = tokens;
|
|
6109
|
+
output.subject = mail?.subject ?? null;
|
|
6110
|
+
}
|
|
6111
|
+
return output;
|
|
6112
|
+
}
|
|
6113
|
+
function sessionFromQuery(query) {
|
|
6114
|
+
const session = {};
|
|
6115
|
+
if (query.get("ip")) {
|
|
6116
|
+
session.remoteAddress = query.get("ip");
|
|
6117
|
+
}
|
|
6118
|
+
if (query.get("hostname")) {
|
|
6119
|
+
session.resolvedClientHostname = query.get("hostname");
|
|
6120
|
+
}
|
|
6121
|
+
if (query.get("helo")) {
|
|
6122
|
+
session.helo = query.get("helo");
|
|
6123
|
+
}
|
|
6124
|
+
const recipients = query.getAll("to").flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean);
|
|
6125
|
+
if (query.has("from") || recipients.length > 0) {
|
|
6126
|
+
session.envelope = { mailFrom: { address: query.get("from") || "" }, rcptTo: recipients.map((address2) => ({ address: address2 })) };
|
|
6127
|
+
}
|
|
6128
|
+
return session;
|
|
6129
|
+
}
|
|
6130
|
+
function sameToken(given, expected) {
|
|
6131
|
+
const a = Buffer7.from(String(given));
|
|
6132
|
+
const b = Buffer7.from(String(expected));
|
|
6133
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
6134
|
+
}
|
|
6135
|
+
function readBody(request, limit) {
|
|
6136
|
+
return new Promise((resolve, reject) => {
|
|
6137
|
+
const chunks = [];
|
|
6138
|
+
let size = 0;
|
|
6139
|
+
request.on("data", (chunk2) => {
|
|
6140
|
+
size += chunk2.length;
|
|
6141
|
+
if (size <= limit) {
|
|
6142
|
+
chunks.push(chunk2);
|
|
6143
|
+
}
|
|
6144
|
+
});
|
|
6145
|
+
request.on("end", () => {
|
|
6146
|
+
if (size > limit) {
|
|
6147
|
+
const error = new Error(`Message larger than ${limit} bytes`);
|
|
6148
|
+
error.statusCode = 413;
|
|
6149
|
+
reject(error);
|
|
6150
|
+
return;
|
|
6151
|
+
}
|
|
6152
|
+
resolve(Buffer7.concat(chunks));
|
|
6153
|
+
});
|
|
6154
|
+
request.on("error", reject);
|
|
6155
|
+
});
|
|
6156
|
+
}
|
|
6157
|
+
function createHttpServer(scanner, options = {}) {
|
|
6158
|
+
const {
|
|
6159
|
+
token = null,
|
|
6160
|
+
maxSize = MAX_MESSAGE_SIZE,
|
|
6161
|
+
modelPath = null,
|
|
6162
|
+
maxConnections = MAX_CONNECTIONS
|
|
6163
|
+
} = options;
|
|
6164
|
+
let active = 0;
|
|
6165
|
+
const server = http2.createServer(async (request, response) => {
|
|
6166
|
+
const send = (status, body, headers = {}) => {
|
|
6167
|
+
const isBuffer = Buffer7.isBuffer(body);
|
|
6168
|
+
response.writeHead(status, { "content-type": isBuffer ? "message/rfc822" : "application/json; charset=utf-8", "cache-control": "no-store", ...headers });
|
|
6169
|
+
response.end(isBuffer ? body : `${JSON.stringify(body)}
|
|
6170
|
+
`);
|
|
6171
|
+
};
|
|
6172
|
+
try {
|
|
6173
|
+
const url = new URL(request.url, "http://localhost");
|
|
6174
|
+
if (request.method === "GET" && url.pathname === "/health") {
|
|
6175
|
+
send(200, { ok: true, version: VERSION });
|
|
6176
|
+
return;
|
|
6177
|
+
}
|
|
6178
|
+
if (token && !sameToken((request.headers.authorization || "").replace(/^bearer\s+/i, ""), token)) {
|
|
6179
|
+
send(401, { error: "Unauthorized" }, { "www-authenticate": "Bearer" });
|
|
6180
|
+
return;
|
|
6181
|
+
}
|
|
6182
|
+
if (request.method !== "POST") {
|
|
6183
|
+
send(405, { error: "Method not allowed" }, { allow: "POST" });
|
|
6184
|
+
return;
|
|
6185
|
+
}
|
|
6186
|
+
const learn = /^\/learn\/(spam|ham)$/.exec(url.pathname);
|
|
6187
|
+
if (!["/scan", "/check"].includes(url.pathname) && !learn) {
|
|
6188
|
+
send(404, { error: "Not found" });
|
|
6189
|
+
return;
|
|
6190
|
+
}
|
|
6191
|
+
if (learn && !token) {
|
|
6192
|
+
send(403, { error: "Learning needs the server to be started with a token" });
|
|
6193
|
+
return;
|
|
6194
|
+
}
|
|
6195
|
+
const length = request.headers["content-length"];
|
|
6196
|
+
if (length !== void 0 && Number(length) > maxSize) {
|
|
6197
|
+
send(413, { error: `Message larger than ${maxSize} bytes` }, { connection: "close" });
|
|
6198
|
+
return;
|
|
6199
|
+
}
|
|
6200
|
+
if (active >= maxConnections) {
|
|
6201
|
+
send(503, { error: "Too many requests at once" }, { connection: "close", "retry-after": "1" });
|
|
6202
|
+
return;
|
|
6203
|
+
}
|
|
6204
|
+
active++;
|
|
6205
|
+
try {
|
|
6206
|
+
const raw = await readBody(request, maxSize);
|
|
6207
|
+
if (learn) {
|
|
6208
|
+
await scanner.learn(raw, learn[1]);
|
|
6209
|
+
if (modelPath) {
|
|
6210
|
+
scanner.saveModel(modelPath);
|
|
6211
|
+
}
|
|
6212
|
+
send(200, { ok: true, learned: learn[1] });
|
|
6213
|
+
return;
|
|
6214
|
+
}
|
|
6215
|
+
const result = await scanner.scan(raw, { session: sessionFromQuery(url.searchParams) });
|
|
6216
|
+
if (url.pathname === "/scan") {
|
|
6217
|
+
send(200, serializeResult(result, { verbose: url.searchParams.get("verbose") === "1" }));
|
|
6218
|
+
return;
|
|
6219
|
+
}
|
|
6220
|
+
const tag = url.searchParams.get("subjectTag");
|
|
6221
|
+
const rewritten = rewriteMessage(raw, spamHeaders(result, { version: VERSION }), { subjectTag: result.isSpam && tag ? tag : null });
|
|
6222
|
+
send(200, rewritten, { "x-spam-flag": result.isSpam ? "YES" : "NO", "x-spam-score": result.score.toFixed(1), "x-spam-action": result.action });
|
|
6223
|
+
} finally {
|
|
6224
|
+
active--;
|
|
6225
|
+
}
|
|
6226
|
+
} catch (error) {
|
|
6227
|
+
send(error.statusCode || 500, { error: error.message });
|
|
6228
|
+
}
|
|
6229
|
+
});
|
|
6230
|
+
server.maxConnections = maxConnections;
|
|
6231
|
+
return server;
|
|
6232
|
+
}
|
|
6233
|
+
function createTcpServer(scanner, options = {}) {
|
|
6234
|
+
const {
|
|
6235
|
+
json = true,
|
|
6236
|
+
maxSize = MAX_MESSAGE_SIZE,
|
|
6237
|
+
timeout = 6e4,
|
|
6238
|
+
maxConnections = MAX_CONNECTIONS
|
|
6239
|
+
} = options;
|
|
6240
|
+
const server = net.createServer({ allowHalfOpen: true }, (socket) => {
|
|
6241
|
+
const chunks = [];
|
|
6242
|
+
let size = 0;
|
|
6243
|
+
socket.setTimeout(timeout, () => socket.destroy());
|
|
6244
|
+
const onEnd = async () => {
|
|
6245
|
+
socket.setTimeout(0);
|
|
6246
|
+
try {
|
|
6247
|
+
const result = await scanner.scan(Buffer7.concat(chunks));
|
|
6248
|
+
socket.end(json ? `${JSON.stringify(serializeResult(result))}
|
|
6249
|
+
` : `${result.isSpam ? "SPAM" : "HAM"} ${result.score.toFixed(1)}/${result.threshold.toFixed(1)} ${result.tests.map((test) => test.name).join(",")}
|
|
6250
|
+
`);
|
|
6251
|
+
} catch (error) {
|
|
6252
|
+
socket.end(json ? `${JSON.stringify({ error: error.message })}
|
|
6253
|
+
` : `ERROR ${error.message}
|
|
6254
|
+
`);
|
|
6255
|
+
}
|
|
6256
|
+
};
|
|
6257
|
+
const onData = (chunk2) => {
|
|
6258
|
+
size += chunk2.length;
|
|
6259
|
+
if (size > maxSize) {
|
|
6260
|
+
socket.off("data", onData);
|
|
6261
|
+
socket.off("end", onEnd);
|
|
6262
|
+
socket.end(`${JSON.stringify({ error: `Message larger than ${maxSize} bytes` })}
|
|
6263
|
+
`, () => socket.destroy());
|
|
6264
|
+
return;
|
|
6265
|
+
}
|
|
6266
|
+
chunks.push(chunk2);
|
|
6267
|
+
};
|
|
6268
|
+
socket.on("data", onData);
|
|
6269
|
+
socket.on("end", onEnd);
|
|
6270
|
+
socket.on("error", () => {
|
|
6271
|
+
});
|
|
6272
|
+
});
|
|
6273
|
+
server.maxConnections = maxConnections;
|
|
6274
|
+
return server;
|
|
6275
|
+
}
|
|
6276
|
+
|
|
6277
|
+
// src/milter.js
|
|
6278
|
+
var COMMANDS = {
|
|
6279
|
+
ABORT: "A",
|
|
6280
|
+
BODY: "B",
|
|
6281
|
+
CONNECT: "C",
|
|
6282
|
+
MACRO: "D",
|
|
6283
|
+
BODYEOB: "E",
|
|
6284
|
+
HELO: "H",
|
|
6285
|
+
QUIT_NC: "K",
|
|
6286
|
+
HEADER: "L",
|
|
6287
|
+
MAIL: "M",
|
|
6288
|
+
EOH: "N",
|
|
6289
|
+
OPTNEG: "O",
|
|
6290
|
+
QUIT: "Q",
|
|
6291
|
+
RCPT: "R",
|
|
6292
|
+
DATA: "T",
|
|
6293
|
+
UNKNOWN: "U"
|
|
6294
|
+
};
|
|
6295
|
+
var RESPONSES = {
|
|
6296
|
+
ACCEPT: "a",
|
|
6297
|
+
CONTINUE: "c",
|
|
6298
|
+
DISCARD: "d",
|
|
6299
|
+
ADDHEADER: "h",
|
|
5577
6300
|
INSHEADER: "i",
|
|
5578
6301
|
CHGHEADER: "m",
|
|
5579
6302
|
QUARANTINE: "q",
|
|
@@ -5615,12 +6338,12 @@ var PROTOCOL = {
|
|
|
5615
6338
|
HDR_LEADSPC: 1048576
|
|
5616
6339
|
};
|
|
5617
6340
|
var MAX_PACKET = 1024 * 1024;
|
|
5618
|
-
function packet(command, data =
|
|
5619
|
-
const payload =
|
|
5620
|
-
const header =
|
|
6341
|
+
function packet(command, data = Buffer8.alloc(0)) {
|
|
6342
|
+
const payload = Buffer8.isBuffer(data) ? data : Buffer8.from(data, "latin1");
|
|
6343
|
+
const header = Buffer8.alloc(5);
|
|
5621
6344
|
header.writeUInt32BE(payload.length + 1);
|
|
5622
6345
|
header.write(command, 4, "latin1");
|
|
5623
|
-
return
|
|
6346
|
+
return Buffer8.concat([header, payload]);
|
|
5624
6347
|
}
|
|
5625
6348
|
function cstrings(data) {
|
|
5626
6349
|
const parts = data.toString("latin1").split("\0");
|
|
@@ -5630,7 +6353,7 @@ function cstrings(data) {
|
|
|
5630
6353
|
return parts;
|
|
5631
6354
|
}
|
|
5632
6355
|
function cstring(value) {
|
|
5633
|
-
return
|
|
6356
|
+
return Buffer8.from(`${value}\0`, "latin1");
|
|
5634
6357
|
}
|
|
5635
6358
|
function address(value) {
|
|
5636
6359
|
const text = String(value ?? "").trim();
|
|
@@ -5666,6 +6389,15 @@ var MilterSession = class {
|
|
|
5666
6389
|
this.body = [];
|
|
5667
6390
|
this.size = 0;
|
|
5668
6391
|
this.truncated = false;
|
|
6392
|
+
this.headerSize = 0;
|
|
6393
|
+
}
|
|
6394
|
+
// The envelope and headers are kept whole for the scan, so beyond the
|
|
6395
|
+
// message size limit the message is not scanned (see onError).
|
|
6396
|
+
keep(data) {
|
|
6397
|
+
this.headerSize += data.length;
|
|
6398
|
+
if (this.headerSize > this.server.options.maxSize) {
|
|
6399
|
+
throw new Error(`Message headers larger than ${this.server.options.maxSize} bytes`);
|
|
6400
|
+
}
|
|
5669
6401
|
}
|
|
5670
6402
|
send(command, data) {
|
|
5671
6403
|
if (!this.socket.destroyed) {
|
|
@@ -5678,7 +6410,7 @@ var MilterSession = class {
|
|
|
5678
6410
|
if (this.buffered < this.needed) {
|
|
5679
6411
|
return;
|
|
5680
6412
|
}
|
|
5681
|
-
let buffer = this.chunks.length === 1 ? this.chunks[0] :
|
|
6413
|
+
let buffer = this.chunks.length === 1 ? this.chunks[0] : Buffer8.concat(this.chunks);
|
|
5682
6414
|
this.needed = 4;
|
|
5683
6415
|
while (buffer.length >= 4) {
|
|
5684
6416
|
const length = buffer.readUInt32BE(0);
|
|
@@ -5723,7 +6455,7 @@ var MilterSession = class {
|
|
|
5723
6455
|
const protocol = data.readUInt32BE(8);
|
|
5724
6456
|
const wanted = ACTIONS.ADDHDRS | ACTIONS.CHGHDRS | ACTIONS.QUARANTINE;
|
|
5725
6457
|
this.leadingSpace = Boolean(protocol & PROTOCOL.HDR_LEADSPC);
|
|
5726
|
-
const reply2 =
|
|
6458
|
+
const reply2 = Buffer8.alloc(12);
|
|
5727
6459
|
reply2.writeUInt32BE(Math.min(version, 6), 0);
|
|
5728
6460
|
reply2.writeUInt32BE(actions & wanted, 4);
|
|
5729
6461
|
reply2.writeUInt32BE(protocol & (PROTOCOL.HDR_LEADSPC | PROTOCOL.NOUNKNOWN), 8);
|
|
@@ -5766,16 +6498,19 @@ var MilterSession = class {
|
|
|
5766
6498
|
}
|
|
5767
6499
|
case COMMANDS.MAIL: {
|
|
5768
6500
|
this.resetMessage();
|
|
6501
|
+
this.keep(data);
|
|
5769
6502
|
this.envelope.mailFrom = { address: address(cstrings(data)[0]) };
|
|
5770
6503
|
this.send(RESPONSES.CONTINUE);
|
|
5771
6504
|
break;
|
|
5772
6505
|
}
|
|
5773
6506
|
case COMMANDS.RCPT: {
|
|
6507
|
+
this.keep(data);
|
|
5774
6508
|
this.envelope.rcptTo.push({ address: address(cstrings(data)[0]) });
|
|
5775
6509
|
this.send(RESPONSES.CONTINUE);
|
|
5776
6510
|
break;
|
|
5777
6511
|
}
|
|
5778
6512
|
case COMMANDS.HEADER: {
|
|
6513
|
+
this.keep(data);
|
|
5779
6514
|
const [name, value = ""] = cstrings(data);
|
|
5780
6515
|
this.headers.push([name, value]);
|
|
5781
6516
|
this.send(RESPONSES.CONTINUE);
|
|
@@ -5785,14 +6520,19 @@ var MilterSession = class {
|
|
|
5785
6520
|
if (this.truncated || this.size + data.length > options.maxSize) {
|
|
5786
6521
|
this.truncated = true;
|
|
5787
6522
|
} else {
|
|
5788
|
-
this.body.push(
|
|
6523
|
+
this.body.push(Buffer8.from(data));
|
|
5789
6524
|
this.size += data.length;
|
|
5790
6525
|
}
|
|
5791
6526
|
this.send(RESPONSES.CONTINUE);
|
|
5792
6527
|
break;
|
|
5793
6528
|
}
|
|
5794
6529
|
case COMMANDS.BODYEOB: {
|
|
5795
|
-
|
|
6530
|
+
this.socket.pause();
|
|
6531
|
+
try {
|
|
6532
|
+
await this.endOfMessage();
|
|
6533
|
+
} finally {
|
|
6534
|
+
this.socket.resume();
|
|
6535
|
+
}
|
|
5796
6536
|
break;
|
|
5797
6537
|
}
|
|
5798
6538
|
case COMMANDS.ABORT: {
|
|
@@ -5815,7 +6555,7 @@ var MilterSession = class {
|
|
|
5815
6555
|
/** @returns {Buffer} */
|
|
5816
6556
|
rawMessage() {
|
|
5817
6557
|
const head = this.headers.map(([name, value]) => `${name}:${this.leadingSpace || value.startsWith(" ") || value.startsWith(" ") ? "" : " "}${value}`).join("\r\n");
|
|
5818
|
-
return
|
|
6558
|
+
return Buffer8.concat([Buffer8.from(this.headers.length > 0 ? `${head}\r
|
|
5819
6559
|
\r
|
|
5820
6560
|
` : "\r\n", "latin1"), ...this.body]);
|
|
5821
6561
|
}
|
|
@@ -5826,9 +6566,9 @@ var MilterSession = class {
|
|
|
5826
6566
|
const key = name.toLowerCase();
|
|
5827
6567
|
counts.set(key, (counts.get(key) || 0) + 1);
|
|
5828
6568
|
if (key.startsWith("x-spam-")) {
|
|
5829
|
-
const index =
|
|
6569
|
+
const index = Buffer8.alloc(4);
|
|
5830
6570
|
index.writeUInt32BE(counts.get(key));
|
|
5831
|
-
this.send(RESPONSES.CHGHEADER,
|
|
6571
|
+
this.send(RESPONSES.CHGHEADER, Buffer8.concat([index, cstring(name), cstring("")]));
|
|
5832
6572
|
}
|
|
5833
6573
|
}
|
|
5834
6574
|
}
|
|
@@ -5851,7 +6591,7 @@ var MilterSession = class {
|
|
|
5851
6591
|
}
|
|
5852
6592
|
this.server.report(error);
|
|
5853
6593
|
this.removeForgedHeaders();
|
|
5854
|
-
this.send(RESPONSES.ADDHEADER,
|
|
6594
|
+
this.send(RESPONSES.ADDHEADER, Buffer8.concat([cstring("X-Spam-Virus-Scan"), cstring(`${this.leadingSpace ? " " : ""}failed`)]));
|
|
5855
6595
|
this.send(RESPONSES.CONTINUE);
|
|
5856
6596
|
this.resetMessage();
|
|
5857
6597
|
return;
|
|
@@ -5872,15 +6612,15 @@ var MilterSession = class {
|
|
|
5872
6612
|
headers.unshift(["Authentication-Results", formatAuthResultsHeader(result.results.authentication, options.hostname)]);
|
|
5873
6613
|
}
|
|
5874
6614
|
for (const [name, value] of headers) {
|
|
5875
|
-
this.send(RESPONSES.ADDHEADER,
|
|
6615
|
+
this.send(RESPONSES.ADDHEADER, Buffer8.concat([cstring(name), cstring(`${this.leadingSpace ? " " : ""}${value}`.replaceAll("\r\n", "\n"))]));
|
|
5876
6616
|
}
|
|
5877
6617
|
if (result.isSpam && options.subjectTag) {
|
|
5878
6618
|
const subject = this.headers.find(([name]) => name.toLowerCase() === "subject");
|
|
5879
6619
|
const value = taggedSubject(options.subjectTag, subject ? subject[1].trim() : "", "\n");
|
|
5880
6620
|
if (value !== null) {
|
|
5881
|
-
const index =
|
|
6621
|
+
const index = Buffer8.alloc(4);
|
|
5882
6622
|
index.writeUInt32BE(1);
|
|
5883
|
-
this.send(RESPONSES.CHGHEADER,
|
|
6623
|
+
this.send(RESPONSES.CHGHEADER, Buffer8.concat([index, cstring("Subject"), cstring(`${this.leadingSpace ? " " : ""}${value}`.replaceAll("\r\n", "\n"))]));
|
|
5884
6624
|
}
|
|
5885
6625
|
}
|
|
5886
6626
|
if (result.action === "reject" && options.reject) {
|
|
@@ -5907,8 +6647,10 @@ var MilterServer = class extends EventEmitter {
|
|
|
5907
6647
|
* @param {boolean} [options.quarantine] - hold spam in the MTA's quarantine
|
|
5908
6648
|
* @param {string|null} [options.subjectTag] - e.g. "[SPAM]"
|
|
5909
6649
|
* @param {string} [options.hostname] - this server's name, for Authentication-Results
|
|
5910
|
-
* @param {number} [options.maxSize] - body bytes kept for scanning
|
|
6650
|
+
* @param {number} [options.maxSize] - body bytes kept for scanning (and the
|
|
6651
|
+
* most bytes of envelope and headers: beyond them a message is not scanned)
|
|
5911
6652
|
* @param {'tempfail'|'accept'} [options.onError] - answer when a scan fails
|
|
6653
|
+
* @param {number} [options.maxConnections] - connections kept open at once; more are closed at once
|
|
5912
6654
|
*/
|
|
5913
6655
|
constructor(scanner, options = {}) {
|
|
5914
6656
|
super();
|
|
@@ -5920,19 +6662,21 @@ var MilterServer = class extends EventEmitter {
|
|
|
5920
6662
|
quarantine: false,
|
|
5921
6663
|
subjectTag: null,
|
|
5922
6664
|
hostname: "spamscanner",
|
|
5923
|
-
maxSize:
|
|
6665
|
+
maxSize: MAX_MESSAGE_SIZE,
|
|
5924
6666
|
onError: "tempfail",
|
|
6667
|
+
maxConnections: MAX_CONNECTIONS,
|
|
5925
6668
|
...options
|
|
5926
6669
|
};
|
|
5927
6670
|
if (!/^[45]\d\d$/.test(String(this.options.rejectCode))) {
|
|
5928
6671
|
throw new RangeError(`rejectCode must be a 4xx or 5xx SMTP code, not ${this.options.rejectCode}`);
|
|
5929
6672
|
}
|
|
5930
6673
|
this.sockets = /* @__PURE__ */ new Set();
|
|
5931
|
-
this.server =
|
|
6674
|
+
this.server = net2.createServer((socket) => {
|
|
5932
6675
|
this.sockets.add(socket);
|
|
5933
6676
|
socket.on("close", () => this.sockets.delete(socket));
|
|
5934
6677
|
return new MilterSession(socket, this);
|
|
5935
6678
|
});
|
|
6679
|
+
this.server.maxConnections = this.options.maxConnections;
|
|
5936
6680
|
this.server.on("error", (error) => this.report(error));
|
|
5937
6681
|
}
|
|
5938
6682
|
// Errors go to "error" listeners; with none, a broken connection or failed
|
|
@@ -5980,7 +6724,11 @@ var MilterServer = class extends EventEmitter {
|
|
|
5980
6724
|
|
|
5981
6725
|
// src/model.js
|
|
5982
6726
|
import {
|
|
6727
|
+
closeSync,
|
|
5983
6728
|
existsSync,
|
|
6729
|
+
fchmodSync,
|
|
6730
|
+
fsyncSync,
|
|
6731
|
+
openSync,
|
|
5984
6732
|
readFileSync,
|
|
5985
6733
|
writeFileSync,
|
|
5986
6734
|
mkdirSync,
|
|
@@ -6056,9 +6804,19 @@ function saveModel(classifier, file, options = {}) {
|
|
|
6056
6804
|
const resolved = path.resolve(file);
|
|
6057
6805
|
mkdirSync(path.dirname(resolved), { recursive: true });
|
|
6058
6806
|
const temporary = `${resolved}.${process2.pid}.${randomBytes2(6).toString("hex")}.tmp`;
|
|
6807
|
+
const mode = statSync(resolved, { throwIfNoEntry: false })?.mode;
|
|
6808
|
+
const fd = openSync(temporary, "wx");
|
|
6059
6809
|
try {
|
|
6060
|
-
|
|
6810
|
+
try {
|
|
6811
|
+
if (mode !== void 0) {
|
|
6812
|
+
fchmodSync(fd, mode % 512);
|
|
6813
|
+
}
|
|
6814
|
+
writeFileSync(fd, `${JSON.stringify(classifier.toJSON(options))}
|
|
6061
6815
|
`);
|
|
6816
|
+
fsyncSync(fd);
|
|
6817
|
+
} finally {
|
|
6818
|
+
closeSync(fd);
|
|
6819
|
+
}
|
|
6062
6820
|
renameSync(temporary, resolved);
|
|
6063
6821
|
} catch (error) {
|
|
6064
6822
|
rmSync(temporary, { force: true });
|
|
@@ -6204,164 +6962,8 @@ var CLASSIFIER_MODELS = [
|
|
|
6204
6962
|
}
|
|
6205
6963
|
];
|
|
6206
6964
|
|
|
6207
|
-
// src/server.js
|
|
6208
|
-
import { Buffer as Buffer7 } from "node:buffer";
|
|
6209
|
-
import { timingSafeEqual } from "node:crypto";
|
|
6210
|
-
import http2 from "node:http";
|
|
6211
|
-
import net2 from "node:net";
|
|
6212
|
-
function serializeResult(result, options = {}) {
|
|
6213
|
-
const { mail, tokens, ...rest } = result;
|
|
6214
|
-
const output = {
|
|
6215
|
-
...rest,
|
|
6216
|
-
results: { ...result.results }
|
|
6217
|
-
};
|
|
6218
|
-
if (output.results.authentication) {
|
|
6219
|
-
const { receivedChain, ...auth } = output.results.authentication;
|
|
6220
|
-
output.results.authentication = auth;
|
|
6221
|
-
}
|
|
6222
|
-
if (options.verbose) {
|
|
6223
|
-
output.tokens = tokens;
|
|
6224
|
-
output.subject = mail?.subject ?? null;
|
|
6225
|
-
}
|
|
6226
|
-
return output;
|
|
6227
|
-
}
|
|
6228
|
-
function sessionFromQuery(query) {
|
|
6229
|
-
const session = {};
|
|
6230
|
-
if (query.get("ip")) {
|
|
6231
|
-
session.remoteAddress = query.get("ip");
|
|
6232
|
-
}
|
|
6233
|
-
if (query.get("hostname")) {
|
|
6234
|
-
session.resolvedClientHostname = query.get("hostname");
|
|
6235
|
-
}
|
|
6236
|
-
if (query.get("helo")) {
|
|
6237
|
-
session.helo = query.get("helo");
|
|
6238
|
-
}
|
|
6239
|
-
const recipients = query.getAll("to").flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean);
|
|
6240
|
-
if (query.has("from") || recipients.length > 0) {
|
|
6241
|
-
session.envelope = { mailFrom: { address: query.get("from") || "" }, rcptTo: recipients.map((address2) => ({ address: address2 })) };
|
|
6242
|
-
}
|
|
6243
|
-
return session;
|
|
6244
|
-
}
|
|
6245
|
-
function sameToken(given, expected) {
|
|
6246
|
-
const a = Buffer7.from(String(given));
|
|
6247
|
-
const b = Buffer7.from(String(expected));
|
|
6248
|
-
return a.length === b.length && timingSafeEqual(a, b);
|
|
6249
|
-
}
|
|
6250
|
-
function readBody(request, limit) {
|
|
6251
|
-
return new Promise((resolve, reject) => {
|
|
6252
|
-
const chunks = [];
|
|
6253
|
-
let size = 0;
|
|
6254
|
-
request.on("data", (chunk2) => {
|
|
6255
|
-
size += chunk2.length;
|
|
6256
|
-
if (size <= limit) {
|
|
6257
|
-
chunks.push(chunk2);
|
|
6258
|
-
}
|
|
6259
|
-
});
|
|
6260
|
-
request.on("end", () => {
|
|
6261
|
-
if (size > limit) {
|
|
6262
|
-
const error = new Error(`Message larger than ${limit} bytes`);
|
|
6263
|
-
error.statusCode = 413;
|
|
6264
|
-
reject(error);
|
|
6265
|
-
return;
|
|
6266
|
-
}
|
|
6267
|
-
resolve(Buffer7.concat(chunks));
|
|
6268
|
-
});
|
|
6269
|
-
request.on("error", reject);
|
|
6270
|
-
});
|
|
6271
|
-
}
|
|
6272
|
-
function createHttpServer(scanner, options = {}) {
|
|
6273
|
-
const { token = null, maxSize = 25 * 1024 * 1024, modelPath = null } = options;
|
|
6274
|
-
return http2.createServer(async (request, response) => {
|
|
6275
|
-
const send = (status, body, headers = {}) => {
|
|
6276
|
-
const isBuffer = Buffer7.isBuffer(body);
|
|
6277
|
-
response.writeHead(status, { "content-type": isBuffer ? "message/rfc822" : "application/json; charset=utf-8", "cache-control": "no-store", ...headers });
|
|
6278
|
-
response.end(isBuffer ? body : `${JSON.stringify(body)}
|
|
6279
|
-
`);
|
|
6280
|
-
};
|
|
6281
|
-
try {
|
|
6282
|
-
const url = new URL(request.url, "http://localhost");
|
|
6283
|
-
if (request.method === "GET" && url.pathname === "/health") {
|
|
6284
|
-
send(200, { ok: true, version: VERSION });
|
|
6285
|
-
return;
|
|
6286
|
-
}
|
|
6287
|
-
if (token && !sameToken((request.headers.authorization || "").replace(/^bearer\s+/i, ""), token)) {
|
|
6288
|
-
send(401, { error: "Unauthorized" }, { "www-authenticate": "Bearer" });
|
|
6289
|
-
return;
|
|
6290
|
-
}
|
|
6291
|
-
if (request.method !== "POST") {
|
|
6292
|
-
send(405, { error: "Method not allowed" }, { allow: "POST" });
|
|
6293
|
-
return;
|
|
6294
|
-
}
|
|
6295
|
-
const learn = /^\/learn\/(spam|ham)$/.exec(url.pathname);
|
|
6296
|
-
if (!["/scan", "/check"].includes(url.pathname) && !learn) {
|
|
6297
|
-
send(404, { error: "Not found" });
|
|
6298
|
-
return;
|
|
6299
|
-
}
|
|
6300
|
-
if (learn && !token) {
|
|
6301
|
-
send(403, { error: "Learning needs the server to be started with a token" });
|
|
6302
|
-
return;
|
|
6303
|
-
}
|
|
6304
|
-
const raw = await readBody(request, maxSize);
|
|
6305
|
-
if (learn) {
|
|
6306
|
-
await scanner.learn(raw, learn[1]);
|
|
6307
|
-
if (modelPath) {
|
|
6308
|
-
scanner.saveModel(modelPath);
|
|
6309
|
-
}
|
|
6310
|
-
send(200, { ok: true, learned: learn[1] });
|
|
6311
|
-
return;
|
|
6312
|
-
}
|
|
6313
|
-
const result = await scanner.scan(raw, { session: sessionFromQuery(url.searchParams) });
|
|
6314
|
-
if (url.pathname === "/scan") {
|
|
6315
|
-
send(200, serializeResult(result, { verbose: url.searchParams.get("verbose") === "1" }));
|
|
6316
|
-
return;
|
|
6317
|
-
}
|
|
6318
|
-
const tag = url.searchParams.get("subjectTag");
|
|
6319
|
-
const rewritten = rewriteMessage(raw, spamHeaders(result, { version: VERSION }), { subjectTag: result.isSpam && tag ? tag : null });
|
|
6320
|
-
send(200, rewritten, { "x-spam-flag": result.isSpam ? "YES" : "NO", "x-spam-score": result.score.toFixed(1), "x-spam-action": result.action });
|
|
6321
|
-
} catch (error) {
|
|
6322
|
-
send(error.statusCode || 500, { error: error.message });
|
|
6323
|
-
}
|
|
6324
|
-
});
|
|
6325
|
-
}
|
|
6326
|
-
function createTcpServer(scanner, options = {}) {
|
|
6327
|
-
const { json = true, maxSize = 25 * 1024 * 1024, timeout = 6e4 } = options;
|
|
6328
|
-
return net2.createServer({ allowHalfOpen: true }, (socket) => {
|
|
6329
|
-
const chunks = [];
|
|
6330
|
-
let size = 0;
|
|
6331
|
-
socket.setTimeout(timeout, () => socket.destroy());
|
|
6332
|
-
const onEnd = async () => {
|
|
6333
|
-
socket.setTimeout(0);
|
|
6334
|
-
try {
|
|
6335
|
-
const result = await scanner.scan(Buffer7.concat(chunks));
|
|
6336
|
-
socket.end(json ? `${JSON.stringify(serializeResult(result))}
|
|
6337
|
-
` : `${result.isSpam ? "SPAM" : "HAM"} ${result.score.toFixed(1)}/${result.threshold.toFixed(1)} ${result.tests.map((test) => test.name).join(",")}
|
|
6338
|
-
`);
|
|
6339
|
-
} catch (error) {
|
|
6340
|
-
socket.end(json ? `${JSON.stringify({ error: error.message })}
|
|
6341
|
-
` : `ERROR ${error.message}
|
|
6342
|
-
`);
|
|
6343
|
-
}
|
|
6344
|
-
};
|
|
6345
|
-
const onData = (chunk2) => {
|
|
6346
|
-
size += chunk2.length;
|
|
6347
|
-
if (size > maxSize) {
|
|
6348
|
-
socket.off("data", onData);
|
|
6349
|
-
socket.off("end", onEnd);
|
|
6350
|
-
socket.end(`${JSON.stringify({ error: `Message larger than ${maxSize} bytes` })}
|
|
6351
|
-
`, () => socket.destroy());
|
|
6352
|
-
return;
|
|
6353
|
-
}
|
|
6354
|
-
chunks.push(chunk2);
|
|
6355
|
-
};
|
|
6356
|
-
socket.on("data", onData);
|
|
6357
|
-
socket.on("end", onEnd);
|
|
6358
|
-
socket.on("error", () => {
|
|
6359
|
-
});
|
|
6360
|
-
});
|
|
6361
|
-
}
|
|
6362
|
-
|
|
6363
6965
|
// src/spamd.js
|
|
6364
|
-
import { Buffer as
|
|
6966
|
+
import { Buffer as Buffer9 } from "node:buffer";
|
|
6365
6967
|
import net3 from "node:net";
|
|
6366
6968
|
var EX_OK = 0;
|
|
6367
6969
|
var EX_USAGE = 64;
|
|
@@ -6392,7 +6994,7 @@ function reply(code, text, headers = [], body = null) {
|
|
|
6392
6994
|
lines2.push(`Content-length: ${body.length}`);
|
|
6393
6995
|
}
|
|
6394
6996
|
lines2.push(...headers.map(([name, value]) => `${name}: ${value}`), "", "");
|
|
6395
|
-
return body === null ?
|
|
6997
|
+
return body === null ? Buffer9.from(lines2.join("\r\n")) : Buffer9.concat([Buffer9.from(lines2.join("\r\n")), body]);
|
|
6396
6998
|
}
|
|
6397
6999
|
function report(result) {
|
|
6398
7000
|
const lines2 = [
|
|
@@ -6437,11 +7039,11 @@ async function answer(scanner, request, message, options) {
|
|
|
6437
7039
|
return reply(EX_OK, "EX_OK", [verdict]);
|
|
6438
7040
|
}
|
|
6439
7041
|
case "SYMBOLS": {
|
|
6440
|
-
return reply(EX_OK, "EX_OK", [verdict],
|
|
7042
|
+
return reply(EX_OK, "EX_OK", [verdict], Buffer9.from(result.tests.map((test) => test.name).join(",")));
|
|
6441
7043
|
}
|
|
6442
7044
|
case "REPORT":
|
|
6443
7045
|
case "REPORT_IFSPAM": {
|
|
6444
|
-
return reply(EX_OK, "EX_OK", [verdict], command === "REPORT" || result.isSpam ?
|
|
7046
|
+
return reply(EX_OK, "EX_OK", [verdict], command === "REPORT" || result.isSpam ? Buffer9.from(report(result)) : Buffer9.alloc(0));
|
|
6445
7047
|
}
|
|
6446
7048
|
default: {
|
|
6447
7049
|
const rewritten = rewriteMessage(message, spamHeaders(result, { version: VERSION }), { subjectTag: result.isSpam ? options.subjectTag : null });
|
|
@@ -6449,21 +7051,22 @@ async function answer(scanner, request, message, options) {
|
|
|
6449
7051
|
return reply(EX_OK, "EX_OK", [verdict], rewritten);
|
|
6450
7052
|
}
|
|
6451
7053
|
const { header, newline } = splitMessage(rewritten);
|
|
6452
|
-
return reply(EX_OK, "EX_OK", [verdict],
|
|
7054
|
+
return reply(EX_OK, "EX_OK", [verdict], Buffer9.from(`${header}${newline}${newline}`, "latin1"));
|
|
6453
7055
|
}
|
|
6454
7056
|
}
|
|
6455
7057
|
}
|
|
6456
7058
|
function createSpamdServer(scanner, options = {}) {
|
|
6457
7059
|
const settings = {
|
|
6458
|
-
maxSize:
|
|
7060
|
+
maxSize: MAX_MESSAGE_SIZE,
|
|
6459
7061
|
allowTell: false,
|
|
6460
7062
|
modelPath: null,
|
|
6461
7063
|
subjectTag: null,
|
|
6462
7064
|
timeout: 6e4,
|
|
7065
|
+
maxConnections: MAX_CONNECTIONS,
|
|
6463
7066
|
...options
|
|
6464
7067
|
};
|
|
6465
|
-
|
|
6466
|
-
let head =
|
|
7068
|
+
const server = net3.createServer({ allowHalfOpen: true }, (socket) => {
|
|
7069
|
+
let head = Buffer9.alloc(0);
|
|
6467
7070
|
const chunks = [];
|
|
6468
7071
|
let size = 0;
|
|
6469
7072
|
let request = null;
|
|
@@ -6489,7 +7092,7 @@ function createSpamdServer(scanner, options = {}) {
|
|
|
6489
7092
|
return;
|
|
6490
7093
|
}
|
|
6491
7094
|
if (!request) {
|
|
6492
|
-
head =
|
|
7095
|
+
head = Buffer9.concat([head, chunk2]);
|
|
6493
7096
|
const end = head.indexOf("\r\n\r\n");
|
|
6494
7097
|
if (end === -1) {
|
|
6495
7098
|
if (head.length > 64 * 1024) {
|
|
@@ -6524,7 +7127,7 @@ function createSpamdServer(scanner, options = {}) {
|
|
|
6524
7127
|
chunks.push(chunk2);
|
|
6525
7128
|
size += chunk2.length;
|
|
6526
7129
|
if (request.length === null ? ["PING", "SKIP"].includes(request.command) : size >= request.length) {
|
|
6527
|
-
finish(
|
|
7130
|
+
finish(Buffer9.concat(chunks).subarray(0, request.length ?? 0));
|
|
6528
7131
|
} else if (size > settings.maxSize) {
|
|
6529
7132
|
fail(EX_DATAERR, `Message larger than ${settings.maxSize} bytes`);
|
|
6530
7133
|
}
|
|
@@ -6536,7 +7139,7 @@ function createSpamdServer(scanner, options = {}) {
|
|
|
6536
7139
|
if (!request) {
|
|
6537
7140
|
fail(EX_PROTOCOL, "Incomplete request");
|
|
6538
7141
|
} else if (request.length === null) {
|
|
6539
|
-
finish(
|
|
7142
|
+
finish(Buffer9.concat(chunks));
|
|
6540
7143
|
} else {
|
|
6541
7144
|
fail(EX_PROTOCOL, `Message shorter than its Content-length of ${request.length} bytes`);
|
|
6542
7145
|
}
|
|
@@ -6544,181 +7147,14 @@ function createSpamdServer(scanner, options = {}) {
|
|
|
6544
7147
|
socket.on("error", () => {
|
|
6545
7148
|
});
|
|
6546
7149
|
});
|
|
7150
|
+
server.maxConnections = settings.maxConnections;
|
|
7151
|
+
return server;
|
|
6547
7152
|
}
|
|
6548
7153
|
|
|
6549
7154
|
// src/train.js
|
|
6550
7155
|
import { createHash as createHash3 } from "node:crypto";
|
|
6551
7156
|
import path3 from "node:path";
|
|
6552
7157
|
|
|
6553
|
-
// src/parse.js
|
|
6554
|
-
import { Buffer as Buffer9 } from "node:buffer";
|
|
6555
|
-
import { createHash as createHash2 } from "node:crypto";
|
|
6556
|
-
import { simpleParser } from "mailparser";
|
|
6557
|
-
var PARSER_OPTIONS = {
|
|
6558
|
-
skipHtmlToText: true,
|
|
6559
|
-
skipImageLinks: true,
|
|
6560
|
-
skipTextToHtml: true,
|
|
6561
|
-
skipTextLinks: true
|
|
6562
|
-
};
|
|
6563
|
-
var TOO_MANY_PARTS = "Max allowed child nodes exceeded";
|
|
6564
|
-
var HEADER_TOO_LARGE = "Max header size for a MIME node exceeded";
|
|
6565
|
-
var MAX_PART_LINES = 990;
|
|
6566
|
-
var MAX_PREFIX_BYTES = 1e6;
|
|
6567
|
-
var MAX_LINE2 = 998;
|
|
6568
|
-
var MAX_FIELD = 8192;
|
|
6569
|
-
var MAX_HEADER = 512 * 1024;
|
|
6570
|
-
var MAX_REPEATED = 100;
|
|
6571
|
-
var KEPT_FIELDS = /* @__PURE__ */ new Set([
|
|
6572
|
-
"from",
|
|
6573
|
-
"sender",
|
|
6574
|
-
"reply-to",
|
|
6575
|
-
"to",
|
|
6576
|
-
"cc",
|
|
6577
|
-
"subject",
|
|
6578
|
-
"date",
|
|
6579
|
-
"message-id",
|
|
6580
|
-
"return-path",
|
|
6581
|
-
"received",
|
|
6582
|
-
"authentication-results",
|
|
6583
|
-
"arc-authentication-results",
|
|
6584
|
-
"dkim-signature",
|
|
6585
|
-
"mime-version",
|
|
6586
|
-
"content-type",
|
|
6587
|
-
"content-transfer-encoding",
|
|
6588
|
-
"list-unsubscribe",
|
|
6589
|
-
"list-id"
|
|
6590
|
-
]);
|
|
6591
|
-
var MAX_NESTED_DEPTH = 3;
|
|
6592
|
-
var MAX_ATTACHMENTS = 100;
|
|
6593
|
-
var MAX_NESTED_BYTES = 25 * 1024 * 1024;
|
|
6594
|
-
async function parseMessage(raw) {
|
|
6595
|
-
const buffer = Buffer9.isBuffer(raw) ? raw : Buffer9.from(raw);
|
|
6596
|
-
const mail = await simpleParser(finishable(buffer), PARSER_OPTIONS);
|
|
6597
|
-
const added = buffer.at(-1) === 10 ? 2 : 3;
|
|
6598
|
-
for (const key of ["text", "html"]) {
|
|
6599
|
-
if (typeof mail[key] === "string") {
|
|
6600
|
-
mail[key] = mail[key].replace(new RegExp(`\\n{0,${added}}$`), "");
|
|
6601
|
-
}
|
|
6602
|
-
}
|
|
6603
|
-
const tail = Buffer9.from("\r\n".repeat(added));
|
|
6604
|
-
const last = mail.attachments?.at(-1);
|
|
6605
|
-
const encoding = String(last?.headers?.get?.("content-transfer-encoding") ?? "7bit").trim().toLowerCase();
|
|
6606
|
-
const own = Buffer9.isBuffer(last?.content) && last.content.length >= tail.length && last.content.subarray(-tail.length).equals(tail) ? last.content.subarray(0, -tail.length) : null;
|
|
6607
|
-
if (own && ["7bit", "8bit", "binary"].includes(encoding) && own.length <= buffer.length && buffer.subarray(buffer.length - own.length).equals(own)) {
|
|
6608
|
-
last.content = own;
|
|
6609
|
-
last.size = own.length;
|
|
6610
|
-
last.checksum = createHash2("md5").update(own).digest("hex");
|
|
6611
|
-
}
|
|
6612
|
-
return mail;
|
|
6613
|
-
}
|
|
6614
|
-
function finishable(raw) {
|
|
6615
|
-
const buffer = Buffer9.isBuffer(raw) ? raw : Buffer9.from(raw);
|
|
6616
|
-
return Buffer9.concat([buffer, Buffer9.from(buffer.at(-1) === 10 ? "\r\n\r\n" : "\r\n\r\n\r\n")]);
|
|
6617
|
-
}
|
|
6618
|
-
function upToLine(raw, offset) {
|
|
6619
|
-
return raw.subarray(0, Math.max(raw.lastIndexOf(10, offset - 1) + 1, 0));
|
|
6620
|
-
}
|
|
6621
|
-
function fewerParts(raw) {
|
|
6622
|
-
let offset = -1;
|
|
6623
|
-
for (let count = 0; count < MAX_PART_LINES; count++) {
|
|
6624
|
-
offset = raw.indexOf("\n--", offset + 1);
|
|
6625
|
-
if (offset === -1) {
|
|
6626
|
-
return upToLine(raw, Math.floor(raw.length / 2));
|
|
6627
|
-
}
|
|
6628
|
-
}
|
|
6629
|
-
return raw.subarray(0, offset + 1);
|
|
6630
|
-
}
|
|
6631
|
-
function smallerHeader(raw) {
|
|
6632
|
-
const text = raw.toString("latin1");
|
|
6633
|
-
const blank = /\r?\n\r?\n/.exec(text);
|
|
6634
|
-
const end = blank ? blank.index : text.length;
|
|
6635
|
-
const fields = [];
|
|
6636
|
-
let start = 0;
|
|
6637
|
-
while (start < end) {
|
|
6638
|
-
let stop = text.indexOf("\n", start);
|
|
6639
|
-
stop = stop === -1 || stop > end ? end : stop;
|
|
6640
|
-
const line = text.slice(start, Math.min(stop, start + MAX_LINE2)).replace(/\r$/, "");
|
|
6641
|
-
const last = fields.at(-1);
|
|
6642
|
-
if (last && (line.startsWith(" ") || line.startsWith(" "))) {
|
|
6643
|
-
if (last.size + line.length <= MAX_FIELD) {
|
|
6644
|
-
last.lines.push(line);
|
|
6645
|
-
last.size += line.length;
|
|
6646
|
-
}
|
|
6647
|
-
} else {
|
|
6648
|
-
fields.push({ name: line.slice(0, line.indexOf(":")).trim().toLowerCase(), lines: [line], size: line.length });
|
|
6649
|
-
}
|
|
6650
|
-
start = stop + 1;
|
|
6651
|
-
}
|
|
6652
|
-
const total = fields.reduce((sum, field) => sum + field.size, 0);
|
|
6653
|
-
const counts = /* @__PURE__ */ new Map();
|
|
6654
|
-
const kept = total > MAX_HEADER ? fields.filter((field) => {
|
|
6655
|
-
counts.set(field.name, (counts.get(field.name) || 0) + 1);
|
|
6656
|
-
return KEPT_FIELDS.has(field.name) && counts.get(field.name) <= MAX_REPEATED;
|
|
6657
|
-
}) : fields;
|
|
6658
|
-
const header = kept.flatMap((field) => field.lines).join("\r\n");
|
|
6659
|
-
return Buffer9.concat([Buffer9.from(`${header}\r
|
|
6660
|
-
\r
|
|
6661
|
-
`, "latin1"), raw.subarray(blank ? blank.index + blank[0].length : raw.length)]);
|
|
6662
|
-
}
|
|
6663
|
-
async function parseWithin(raw, parse = parseMessage) {
|
|
6664
|
-
let input = raw;
|
|
6665
|
-
let reason = null;
|
|
6666
|
-
for (; ; ) {
|
|
6667
|
-
try {
|
|
6668
|
-
return { mail: await parse(input), error: reason };
|
|
6669
|
-
} catch (error) {
|
|
6670
|
-
const limit = error.code === "EMAXLEN" && [TOO_MANY_PARTS, HEADER_TOO_LARGE].includes(error.message) ? error.message : null;
|
|
6671
|
-
let shorter = input;
|
|
6672
|
-
if (limit === TOO_MANY_PARTS) {
|
|
6673
|
-
shorter = fewerParts(input);
|
|
6674
|
-
} else if (limit === HEADER_TOO_LARGE) {
|
|
6675
|
-
shorter = smallerHeader(input);
|
|
6676
|
-
shorter = shorter.length < input.length ? shorter : upToLine(input, MAX_PREFIX_BYTES);
|
|
6677
|
-
}
|
|
6678
|
-
if (shorter.length >= input.length) {
|
|
6679
|
-
throw error;
|
|
6680
|
-
}
|
|
6681
|
-
reason ??= limit;
|
|
6682
|
-
input = shorter;
|
|
6683
|
-
}
|
|
6684
|
-
}
|
|
6685
|
-
}
|
|
6686
|
-
async function expandAttachments(attachments = [], errors = [], parse = parseMessage) {
|
|
6687
|
-
const all = [];
|
|
6688
|
-
let budget = MAX_NESTED_BYTES;
|
|
6689
|
-
let nested = 0;
|
|
6690
|
-
const visit = async (list, prefix, depth) => {
|
|
6691
|
-
for (const attachment of list) {
|
|
6692
|
-
if (prefix) {
|
|
6693
|
-
if (nested >= MAX_ATTACHMENTS) {
|
|
6694
|
-
return;
|
|
6695
|
-
}
|
|
6696
|
-
nested++;
|
|
6697
|
-
}
|
|
6698
|
-
const name = attachment.filename || (prefix ? "unnamed attachment" : "");
|
|
6699
|
-
all.push(prefix ? { ...attachment, filename: `${prefix}/${name}` } : attachment);
|
|
6700
|
-
const { content } = attachment;
|
|
6701
|
-
const attached = attachment.contentType === "message/rfc822" || /\.eml$/i.test(attachment.filename || "");
|
|
6702
|
-
if (attached && depth < MAX_NESTED_DEPTH && Buffer9.isBuffer(content) && content.length <= budget) {
|
|
6703
|
-
budget -= content.length;
|
|
6704
|
-
let inner;
|
|
6705
|
-
try {
|
|
6706
|
-
inner = await parseWithin(content, parse);
|
|
6707
|
-
} catch (error) {
|
|
6708
|
-
errors.push(`${name || "attached message"}: ${error.message}`);
|
|
6709
|
-
continue;
|
|
6710
|
-
}
|
|
6711
|
-
if (inner.error) {
|
|
6712
|
-
errors.push(inner.error);
|
|
6713
|
-
}
|
|
6714
|
-
await visit(inner.mail.attachments, prefix ? `${prefix}/${name}` : name || "attached message", depth + 1);
|
|
6715
|
-
}
|
|
6716
|
-
}
|
|
6717
|
-
};
|
|
6718
|
-
await visit(attachments, "", 0);
|
|
6719
|
-
return all;
|
|
6720
|
-
}
|
|
6721
|
-
|
|
6722
7158
|
// src/sources.js
|
|
6723
7159
|
import { Buffer as Buffer10 } from "node:buffer";
|
|
6724
7160
|
import { createReadStream } from "node:fs";
|
|
@@ -6949,16 +7385,16 @@ function featuresFromText(row, options = {}) {
|
|
|
6949
7385
|
async function* readExamples(sources, options = {}) {
|
|
6950
7386
|
const { onProgress, limit = Infinity } = options;
|
|
6951
7387
|
const seen = /* @__PURE__ */ new Set();
|
|
6952
|
-
let
|
|
7388
|
+
let count2 = 0;
|
|
6953
7389
|
const emit = (label, features, content) => {
|
|
6954
7390
|
const id = createHash3("sha1").update(content).digest("hex");
|
|
6955
7391
|
if (seen.has(id)) {
|
|
6956
7392
|
return null;
|
|
6957
7393
|
}
|
|
6958
7394
|
seen.add(id);
|
|
6959
|
-
|
|
6960
|
-
if (onProgress &&
|
|
6961
|
-
onProgress(
|
|
7395
|
+
count2++;
|
|
7396
|
+
if (onProgress && count2 % 1e3 === 0) {
|
|
7397
|
+
onProgress(count2);
|
|
6962
7398
|
}
|
|
6963
7399
|
return { label, features, id };
|
|
6964
7400
|
};
|
|
@@ -7314,17 +7750,22 @@ function zipEntries(buffer, limit = 2e3) {
|
|
|
7314
7750
|
records.push(i);
|
|
7315
7751
|
}
|
|
7316
7752
|
}
|
|
7317
|
-
const
|
|
7318
|
-
|
|
7753
|
+
const state = {
|
|
7754
|
+
entries: [],
|
|
7755
|
+
limit,
|
|
7756
|
+
read: /* @__PURE__ */ new Set(),
|
|
7757
|
+
names: MAX_NAME_BYTES
|
|
7758
|
+
};
|
|
7319
7759
|
for (const eocd of records) {
|
|
7320
7760
|
const offset = directoryOf(buffer, eocd);
|
|
7321
|
-
if (offset !== -1
|
|
7322
|
-
|
|
7323
|
-
readDirectory(buffer, offset, limit - entries.length, entries);
|
|
7761
|
+
if (offset !== -1) {
|
|
7762
|
+
readDirectory(buffer, offset, state);
|
|
7324
7763
|
}
|
|
7325
7764
|
}
|
|
7326
|
-
return entries;
|
|
7765
|
+
return state.entries;
|
|
7327
7766
|
}
|
|
7767
|
+
var MAX_NAME_LENGTH = 1024;
|
|
7768
|
+
var MAX_NAME_BYTES = 1024 * 1024;
|
|
7328
7769
|
var DIRECTORY = 33639248;
|
|
7329
7770
|
var isDirectory = (buffer, offset) => offset >= 0 && offset + 46 <= buffer.length && buffer.readUInt32LE(offset) === DIRECTORY;
|
|
7330
7771
|
function directoryOf(buffer, eocd) {
|
|
@@ -7351,18 +7792,32 @@ function directoryOf(buffer, eocd) {
|
|
|
7351
7792
|
}
|
|
7352
7793
|
return -1;
|
|
7353
7794
|
}
|
|
7354
|
-
function readDirectory(buffer, offset,
|
|
7355
|
-
|
|
7795
|
+
function readDirectory(buffer, offset, state) {
|
|
7796
|
+
while (state.entries.length < state.limit && isDirectory(buffer, offset) && !state.read.has(offset)) {
|
|
7797
|
+
state.read.add(offset);
|
|
7356
7798
|
const flags = buffer.readUInt16LE(offset + 8);
|
|
7357
7799
|
const size = buffer.readUInt32LE(offset + 24);
|
|
7358
7800
|
const nameLength = buffer.readUInt16LE(offset + 28);
|
|
7359
7801
|
const extraLength = buffer.readUInt16LE(offset + 30);
|
|
7360
7802
|
const commentLength = buffer.readUInt16LE(offset + 32);
|
|
7361
|
-
const
|
|
7362
|
-
|
|
7803
|
+
const encoding = flags & 2048 ? "utf8" : "latin1";
|
|
7804
|
+
const start = offset + 46;
|
|
7805
|
+
const end = Math.min(buffer.length, start + nameLength);
|
|
7806
|
+
const kept = Math.min(end - start, MAX_NAME_LENGTH);
|
|
7807
|
+
state.names -= kept;
|
|
7808
|
+
if (state.names < 0) {
|
|
7809
|
+
return;
|
|
7810
|
+
}
|
|
7811
|
+
const half = MAX_NAME_LENGTH / 2;
|
|
7812
|
+
const name = end - start > MAX_NAME_LENGTH ? `${buffer.toString(encoding, start, start + half)}\u2026${buffer.toString(encoding, end - half, end)}` : buffer.toString(encoding, start, end);
|
|
7813
|
+
state.entries.push({ name, encrypted: (flags & 1) === 1, size });
|
|
7363
7814
|
offset += 46 + nameLength + extraLength + commentLength;
|
|
7364
7815
|
}
|
|
7365
7816
|
}
|
|
7817
|
+
function shorten(name, max = 100) {
|
|
7818
|
+
const characters = [...name];
|
|
7819
|
+
return characters.length <= max ? name : `${characters.slice(0, max - 41).join("")}\u2026${characters.slice(-40).join("")}`;
|
|
7820
|
+
}
|
|
7366
7821
|
function extensionOf(name) {
|
|
7367
7822
|
const base = trimEndOf(String(name).toLowerCase().split(/[/\\]/).pop(), ". ");
|
|
7368
7823
|
const dot = base.lastIndexOf(".");
|
|
@@ -7425,7 +7880,7 @@ function inspectAttachments(attachments = []) {
|
|
|
7425
7880
|
}
|
|
7426
7881
|
const inner = entries.filter((entry) => EXECUTABLE_EXTENSIONS.has(extensionOf(entry.name)) && !(office && OFFICE_EMBEDDED.has(extensionOf(entry.name))));
|
|
7427
7882
|
const details = { extension: extensionOf(inner[0]?.name ?? ""), extensions: [...new Set(inner.map((entry) => extensionOf(entry.name)))] };
|
|
7428
|
-
const listed = inner.slice(0, 3).map((entry) => entry.name.split("/").pop()).join(", ");
|
|
7883
|
+
const listed = inner.slice(0, 3).map((entry) => shorten(entry.name.split("/").pop())).join(", ");
|
|
7429
7884
|
if (inner.some((entry) => !SCRIPT_EXTENSIONS.has(extensionOf(entry.name)))) {
|
|
7430
7885
|
add("executable_in_archive", `Archive "${name}" contains ${inner.length === 1 ? "a program" : `${inner.length} programs`} (${listed})`, details);
|
|
7431
7886
|
} else if (inner.length > 0) {
|
|
@@ -7482,7 +7937,7 @@ function exchange(address2, write, timeout) {
|
|
|
7482
7937
|
resolve(value);
|
|
7483
7938
|
}
|
|
7484
7939
|
};
|
|
7485
|
-
const timer = setTimeout(() => finish(new Error(`clamd did not answer within ${timeout} ms`)), timeout);
|
|
7940
|
+
const timer = setTimeout(() => finish(Object.assign(new Error(`clamd did not answer within ${timeout} ms`), { code: "ETIMEDOUT", timedOut: true })), timeout);
|
|
7486
7941
|
socket.on("connect", () => write(socket));
|
|
7487
7942
|
socket.on("data", (chunk2) => chunks.push(chunk2));
|
|
7488
7943
|
socket.on("end", () => finish(null, Buffer12.concat(chunks).toString("utf8").replaceAll("\0", "").trim()));
|
|
@@ -7613,7 +8068,7 @@ var NEXT_WORD = /[^\S\n]{1,8}([\p{L}\p{N}]+)/uy;
|
|
|
7613
8068
|
var MAX_AFTER = 8;
|
|
7614
8069
|
function namesPlace(name, end) {
|
|
7615
8070
|
NEXT_WORD.lastIndex = end;
|
|
7616
|
-
for (let
|
|
8071
|
+
for (let count2 = 0; count2 < MAX_AFTER; count2++) {
|
|
7617
8072
|
const word = NEXT_WORD.exec(name);
|
|
7618
8073
|
if (word === null || word[1].toLowerCase() === "apple") {
|
|
7619
8074
|
return false;
|
|
@@ -8496,6 +8951,7 @@ var SpamScanner = class {
|
|
|
8496
8951
|
const settings = config.clamav === true ? {} : config.clamav;
|
|
8497
8952
|
const budget = settings.timeout ?? CLAMAV_TIMEOUT;
|
|
8498
8953
|
const deadline = Date.now() + budget;
|
|
8954
|
+
let expired = false;
|
|
8499
8955
|
const viruses = [];
|
|
8500
8956
|
const errors = [];
|
|
8501
8957
|
const skipped = [];
|
|
@@ -8515,7 +8971,7 @@ var SpamScanner = class {
|
|
|
8515
8971
|
while (queue.length > 0) {
|
|
8516
8972
|
const attachment = queue.shift();
|
|
8517
8973
|
const filename = attachment.filename || "unnamed attachment";
|
|
8518
|
-
if (deadline <= Date.now()) {
|
|
8974
|
+
if (expired || deadline <= Date.now()) {
|
|
8519
8975
|
errors.push({ filename, error: `not scanned within ${budget} ms` });
|
|
8520
8976
|
continue;
|
|
8521
8977
|
}
|
|
@@ -8535,6 +8991,7 @@ var SpamScanner = class {
|
|
|
8535
8991
|
}
|
|
8536
8992
|
} catch (error) {
|
|
8537
8993
|
debug2("virus scan failed: %s", error.message);
|
|
8994
|
+
expired ||= Boolean(error.timedOut);
|
|
8538
8995
|
if (CLAMAV_LIMITS.test(error.message)) {
|
|
8539
8996
|
skipped.push({ filename, reason: error.message.replace(/^clamd: (?:stream: )?/, "") });
|
|
8540
8997
|
} else {
|
|
@@ -8823,6 +9280,8 @@ Scanning:
|
|
|
8823
9280
|
--no-classifier Do not use the classifier
|
|
8824
9281
|
--config <file> JSON file with scanner settings (see the API docs)
|
|
8825
9282
|
--allow-language <codes> Accepted languages, e.g. en,de,fr
|
|
9283
|
+
--max-size <bytes> Largest message read, by every command and server
|
|
9284
|
+
(default ${MAX_MESSAGE_SIZE}, 25 MiB)
|
|
8826
9285
|
|
|
8827
9286
|
SMTP session (improves accuracy):
|
|
8828
9287
|
--ip <address> IP address of the client that sent the message
|
|
@@ -8869,6 +9328,8 @@ Filter (Postfix pipe):
|
|
|
8869
9328
|
|
|
8870
9329
|
Servers:
|
|
8871
9330
|
--port <n> --host <ip> --socket <path>
|
|
9331
|
+
--max-connections <n> Connections open at once (default ${MAX_CONNECTIONS}; HTTP: also
|
|
9332
|
+
requests at once, more get 503)
|
|
8872
9333
|
--reject Milter: refuse mail at the reject threshold
|
|
8873
9334
|
--reject-code <n> Milter: a 4xx or 5xx code, 451 (try later; default) or 550
|
|
8874
9335
|
--name <hostname> Milter: this server's name in Authentication-Results
|
|
@@ -8905,6 +9366,8 @@ var OPTIONS = {
|
|
|
8905
9366
|
"no-classifier": { type: "boolean" },
|
|
8906
9367
|
config: { type: "string" },
|
|
8907
9368
|
"allow-language": { type: "string" },
|
|
9369
|
+
"max-size": { type: "string" },
|
|
9370
|
+
"max-connections": { type: "string" },
|
|
8908
9371
|
ip: { type: "string" },
|
|
8909
9372
|
"sender-ip": { type: "string" },
|
|
8910
9373
|
hostname: { type: "string" },
|
|
@@ -8977,6 +9440,19 @@ function number(value, name) {
|
|
|
8977
9440
|
}
|
|
8978
9441
|
return parsed;
|
|
8979
9442
|
}
|
|
9443
|
+
function count(value, name, fallback) {
|
|
9444
|
+
if (value === void 0) {
|
|
9445
|
+
return fallback;
|
|
9446
|
+
}
|
|
9447
|
+
const parsed = number(value, name);
|
|
9448
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
9449
|
+
throw new TypeError(`${name} must be a whole number of at least 1, not "${value}"`);
|
|
9450
|
+
}
|
|
9451
|
+
return parsed;
|
|
9452
|
+
}
|
|
9453
|
+
function limits(values) {
|
|
9454
|
+
return { maxSize: count(values["max-size"], "--max-size", MAX_MESSAGE_SIZE), maxConnections: count(values["max-connections"], "--max-connections", MAX_CONNECTIONS) };
|
|
9455
|
+
}
|
|
8980
9456
|
function isSocketArgument(value) {
|
|
8981
9457
|
return /[/:]|\.(?:sock|ctl)$/.test(value) && !statSync2(value, { throwIfNoEntry: false })?.isFile();
|
|
8982
9458
|
}
|
|
@@ -9109,15 +9585,31 @@ function buildSession(values) {
|
|
|
9109
9585
|
}
|
|
9110
9586
|
return session;
|
|
9111
9587
|
}
|
|
9112
|
-
async function
|
|
9113
|
-
|
|
9114
|
-
|
|
9115
|
-
|
|
9116
|
-
|
|
9588
|
+
async function readMessage(stream, maxSize) {
|
|
9589
|
+
const iterator = stream[Symbol.asyncIterator]();
|
|
9590
|
+
const chunks = [];
|
|
9591
|
+
let size = 0;
|
|
9592
|
+
for (; ; ) {
|
|
9593
|
+
const { value, done } = await iterator.next();
|
|
9594
|
+
if (done) {
|
|
9595
|
+
return { data: Buffer14.concat(chunks), complete: true, rest: iterator };
|
|
9117
9596
|
}
|
|
9118
|
-
|
|
9597
|
+
const chunk2 = Buffer14.from(value);
|
|
9598
|
+
chunks.push(chunk2);
|
|
9599
|
+
size += chunk2.length;
|
|
9600
|
+
if (size > maxSize) {
|
|
9601
|
+
return { data: Buffer14.concat(chunks), complete: false, rest: iterator };
|
|
9602
|
+
}
|
|
9603
|
+
}
|
|
9604
|
+
}
|
|
9605
|
+
async function readInput(file, stdin, maxSize) {
|
|
9606
|
+
const stream = !file || file === "-" ? stdin : createReadStream2(file);
|
|
9607
|
+
const { data, complete, rest } = await readMessage(stream, maxSize);
|
|
9608
|
+
if (!complete) {
|
|
9609
|
+
await rest.return();
|
|
9610
|
+
throw new Error(`Message larger than ${maxSize} bytes (see --max-size)`);
|
|
9119
9611
|
}
|
|
9120
|
-
return
|
|
9612
|
+
return data;
|
|
9121
9613
|
}
|
|
9122
9614
|
function formatResult(result, verbose) {
|
|
9123
9615
|
const lines2 = [`${result.isSpam ? "SPAM" : "HAM"} score ${result.score.toFixed(1)} (spam at ${result.threshold.toFixed(1)}, reject at ${result.rejectThreshold.toFixed(1)}) action: ${result.action}${result.language ? ` language: ${result.language}` : ""}`];
|
|
@@ -9149,18 +9641,49 @@ function listen(server, values, defaultPort, io) {
|
|
|
9149
9641
|
}
|
|
9150
9642
|
});
|
|
9151
9643
|
}
|
|
9152
|
-
function deliver(sendmail, args, message) {
|
|
9644
|
+
function deliver(sendmail, args, message, rest) {
|
|
9153
9645
|
return new Promise((resolve, reject) => {
|
|
9154
9646
|
const child = spawn(sendmail, args, { stdio: ["pipe", "ignore", "inherit"] });
|
|
9647
|
+
let written = Promise.resolve(true);
|
|
9155
9648
|
child.on("error", reject);
|
|
9156
|
-
child.on("close", (code) =>
|
|
9649
|
+
child.on("close", async (code) => {
|
|
9650
|
+
resolve(await written ? code ?? "a signal" : "a signal");
|
|
9651
|
+
});
|
|
9157
9652
|
child.stdin.on("error", () => {
|
|
9158
9653
|
});
|
|
9159
|
-
|
|
9654
|
+
if (!rest) {
|
|
9655
|
+
child.stdin.end(message);
|
|
9656
|
+
return;
|
|
9657
|
+
}
|
|
9658
|
+
written = (async () => {
|
|
9659
|
+
try {
|
|
9660
|
+
for await (const chunk2 of prepend(message, rest)) {
|
|
9661
|
+
await new Promise((resolve2, reject2) => {
|
|
9662
|
+
child.stdin.write(chunk2, (error) => {
|
|
9663
|
+
if (error) {
|
|
9664
|
+
reject2(error);
|
|
9665
|
+
} else {
|
|
9666
|
+
resolve2();
|
|
9667
|
+
}
|
|
9668
|
+
});
|
|
9669
|
+
});
|
|
9670
|
+
}
|
|
9671
|
+
child.stdin.end();
|
|
9672
|
+
return true;
|
|
9673
|
+
} catch {
|
|
9674
|
+
child.kill("SIGKILL");
|
|
9675
|
+
child.stdin.destroy();
|
|
9676
|
+
return false;
|
|
9677
|
+
}
|
|
9678
|
+
})();
|
|
9160
9679
|
});
|
|
9161
9680
|
}
|
|
9162
|
-
async function
|
|
9163
|
-
|
|
9681
|
+
async function* prepend(first, rest) {
|
|
9682
|
+
yield first;
|
|
9683
|
+
yield* rest;
|
|
9684
|
+
}
|
|
9685
|
+
async function deliverOrDefer({ sendmail, args, message, rest }, stderr) {
|
|
9686
|
+
const code = await deliver(sendmail, args, message, rest);
|
|
9164
9687
|
if (code !== 0) {
|
|
9165
9688
|
stderr.write(`filter: sendmail exited with ${code}, deferring
|
|
9166
9689
|
`);
|
|
@@ -9189,7 +9712,7 @@ function waitForClose(server) {
|
|
|
9189
9712
|
}
|
|
9190
9713
|
var HANDLERS = {
|
|
9191
9714
|
async scan({ positionals, values, config, session, stdin, stdout, out }) {
|
|
9192
|
-
const raw = await readInput(positionals[0], stdin);
|
|
9715
|
+
const raw = await readInput(positionals[0], stdin, limits(values).maxSize);
|
|
9193
9716
|
const scanner = new SpamScanner(config);
|
|
9194
9717
|
const result = await scanner.scan(raw, { session });
|
|
9195
9718
|
const tag = values["subject-tag"] ?? (values["prepend-subject"] ? "[SPAM]" : null);
|
|
@@ -9204,13 +9727,30 @@ var HANDLERS = {
|
|
|
9204
9727
|
},
|
|
9205
9728
|
// Postfix: argv=spamscanner filter -f ${sender} -- ${recipient}
|
|
9206
9729
|
async filter({ positionals: recipients, values, config, session, stdin, stderr }) {
|
|
9207
|
-
const
|
|
9730
|
+
const { maxSize } = limits(values);
|
|
9731
|
+
const { data: raw, complete, rest } = await readMessage(stdin, maxSize);
|
|
9208
9732
|
if (recipients.length === 0) {
|
|
9209
9733
|
stderr.write("filter: no recipients given (use: spamscanner filter -f sender -- recipient...)\n");
|
|
9210
9734
|
return 64;
|
|
9211
9735
|
}
|
|
9212
9736
|
const accept = onError(values) === "accept";
|
|
9213
9737
|
const sendmail = ["-G", "-i", "-f", values.from ?? values.sender ?? "", "--", ...recipients];
|
|
9738
|
+
if (!complete) {
|
|
9739
|
+
const unscanned = `filter: message larger than ${maxSize} bytes (see --max-size)`;
|
|
9740
|
+
if (!accept || !/^\r?\n|\r?\n\r?\n/.test(raw.toString("latin1"))) {
|
|
9741
|
+
stderr.write(`${unscanned}, deferring
|
|
9742
|
+
`);
|
|
9743
|
+
return 75;
|
|
9744
|
+
}
|
|
9745
|
+
stderr.write(`${unscanned}, passing it on unscanned
|
|
9746
|
+
`);
|
|
9747
|
+
return deliverOrDefer({
|
|
9748
|
+
sendmail: values.sendmail,
|
|
9749
|
+
args: sendmail,
|
|
9750
|
+
message: rewriteMessage(raw, [["X-Spam-Virus-Scan", "failed"]]),
|
|
9751
|
+
rest
|
|
9752
|
+
}, stderr);
|
|
9753
|
+
}
|
|
9214
9754
|
let message;
|
|
9215
9755
|
let result;
|
|
9216
9756
|
try {
|
|
@@ -9233,7 +9773,7 @@ var HANDLERS = {
|
|
|
9233
9773
|
return 75;
|
|
9234
9774
|
}
|
|
9235
9775
|
if (result === null) {
|
|
9236
|
-
return deliverOrDefer(values.sendmail, sendmail, message, stderr);
|
|
9776
|
+
return deliverOrDefer({ sendmail: values.sendmail, args: sendmail, message }, stderr);
|
|
9237
9777
|
}
|
|
9238
9778
|
if (result.action === "reject" && values.reject) {
|
|
9239
9779
|
stderr.write(`5.7.1 Message rejected as spam (score ${result.score.toFixed(1)})
|
|
@@ -9243,11 +9783,12 @@ var HANDLERS = {
|
|
|
9243
9783
|
if (result.action === "reject" && values.discard) {
|
|
9244
9784
|
return 0;
|
|
9245
9785
|
}
|
|
9246
|
-
return deliverOrDefer(values.sendmail, sendmail, message, stderr);
|
|
9786
|
+
return deliverOrDefer({ sendmail: values.sendmail, args: sendmail, message }, stderr);
|
|
9247
9787
|
},
|
|
9248
9788
|
async milter({ values, config, stderr, io }) {
|
|
9249
9789
|
const scanner = new SpamScanner(config);
|
|
9250
9790
|
const milter = new MilterServer(scanner, {
|
|
9791
|
+
...limits(values),
|
|
9251
9792
|
reject: Boolean(values.reject),
|
|
9252
9793
|
rejectCode: values["reject-code"] ? number(values["reject-code"], "--reject-code") : 451,
|
|
9253
9794
|
quarantine: Boolean(values.quarantine),
|
|
@@ -9267,21 +9808,26 @@ var HANDLERS = {
|
|
|
9267
9808
|
},
|
|
9268
9809
|
async http({ values, config, env, stderr, io }) {
|
|
9269
9810
|
warnWithoutClientIp("http", config, stderr);
|
|
9270
|
-
const server = createHttpServer(new SpamScanner(config), { token: values.token || env.SPAMSCANNER_TOKEN || null, modelPath: values.out || null });
|
|
9811
|
+
const server = createHttpServer(new SpamScanner(config), { ...limits(values), token: values.token || env.SPAMSCANNER_TOKEN || null, modelPath: values.out || null });
|
|
9271
9812
|
await listen(server, values, 7832, { stderr });
|
|
9272
9813
|
io.onListening?.(server);
|
|
9273
9814
|
return waitForClose(server);
|
|
9274
9815
|
},
|
|
9275
9816
|
async server({ values, config, stderr, io }) {
|
|
9276
9817
|
warnWithoutClientIp("server", config, stderr);
|
|
9277
|
-
const server = createTcpServer(new SpamScanner(config), { json: !values.verbose });
|
|
9818
|
+
const server = createTcpServer(new SpamScanner(config), { ...limits(values), json: !values.verbose });
|
|
9278
9819
|
await listen(server, values, 7830, { stderr });
|
|
9279
9820
|
io.onListening?.(server);
|
|
9280
9821
|
return waitForClose(server);
|
|
9281
9822
|
},
|
|
9282
9823
|
async spamd({ values, config, stderr, io }) {
|
|
9283
9824
|
warnWithoutClientIp("spamd", config, stderr);
|
|
9284
|
-
const server = createSpamdServer(new SpamScanner(config), {
|
|
9825
|
+
const server = createSpamdServer(new SpamScanner(config), {
|
|
9826
|
+
...limits(values),
|
|
9827
|
+
allowTell: Boolean(values["allow-tell"]),
|
|
9828
|
+
modelPath: values.out || null,
|
|
9829
|
+
subjectTag: values["subject-tag"] ?? null
|
|
9830
|
+
});
|
|
9285
9831
|
await listen(server, values, 783, { stderr });
|
|
9286
9832
|
io.onListening?.(server);
|
|
9287
9833
|
return waitForClose(server);
|
|
@@ -9295,7 +9841,7 @@ var HANDLERS = {
|
|
|
9295
9841
|
const base = values.merge ? values.model ? loadModel(values.model) : loadDefaultModel() : new Classifier();
|
|
9296
9842
|
const { classifier, spam, ham } = await train({ spam: values.spam, ham: values.ham, datasets }, {
|
|
9297
9843
|
classifier: base,
|
|
9298
|
-
onProgress: (
|
|
9844
|
+
onProgress: (count2) => stderr.write(`\r${count2} messages`)
|
|
9299
9845
|
});
|
|
9300
9846
|
const file = values.out || "spamscanner-model.json";
|
|
9301
9847
|
saveModel(classifier, file, {
|
|
@@ -9337,7 +9883,7 @@ var HANDLERS = {
|
|
|
9337
9883
|
return 2;
|
|
9338
9884
|
}
|
|
9339
9885
|
const base = [values.model, values.out].find((candidate) => candidate && existsSync3(candidate));
|
|
9340
|
-
const raw = await readInput(source, stdin);
|
|
9886
|
+
const raw = await readInput(source, stdin, limits(values).maxSize);
|
|
9341
9887
|
const scanner = new SpamScanner({ ...config, classifier: base });
|
|
9342
9888
|
await scanner.learn(raw, category);
|
|
9343
9889
|
scanner.saveModel(file);
|
|
@@ -9386,6 +9932,28 @@ var HANDLERS = {
|
|
|
9386
9932
|
return 0;
|
|
9387
9933
|
}
|
|
9388
9934
|
};
|
|
9935
|
+
var consoleTargets = [];
|
|
9936
|
+
var consoleMethods;
|
|
9937
|
+
async function withConsoleTo(stderr, run) {
|
|
9938
|
+
if (consoleTargets.length === 0) {
|
|
9939
|
+
consoleMethods = { log: console.log, info: console.info };
|
|
9940
|
+
const write = (...args) => {
|
|
9941
|
+
consoleTargets.at(-1).write(`${format(...args)}
|
|
9942
|
+
`);
|
|
9943
|
+
};
|
|
9944
|
+
console.log = write;
|
|
9945
|
+
console.info = write;
|
|
9946
|
+
}
|
|
9947
|
+
consoleTargets.push(stderr);
|
|
9948
|
+
try {
|
|
9949
|
+
return await run();
|
|
9950
|
+
} finally {
|
|
9951
|
+
consoleTargets.splice(consoleTargets.lastIndexOf(stderr), 1);
|
|
9952
|
+
if (consoleTargets.length === 0) {
|
|
9953
|
+
Object.assign(console, consoleMethods);
|
|
9954
|
+
}
|
|
9955
|
+
}
|
|
9956
|
+
}
|
|
9389
9957
|
async function main(argv = process3.argv.slice(2), io = {}) {
|
|
9390
9958
|
const stdin = io.stdin || process3.stdin;
|
|
9391
9959
|
const stdout = io.stdout || process3.stdout;
|
|
@@ -9413,7 +9981,7 @@ Run "spamscanner help" for usage.
|
|
|
9413
9981
|
return command || values.help ? 0 : 2;
|
|
9414
9982
|
}
|
|
9415
9983
|
try {
|
|
9416
|
-
return await HANDLERS[command]({
|
|
9984
|
+
return await withConsoleTo(stderr, () => HANDLERS[command]({
|
|
9417
9985
|
positionals,
|
|
9418
9986
|
values,
|
|
9419
9987
|
config: buildConfig(values, env),
|
|
@@ -9424,7 +9992,7 @@ Run "spamscanner help" for usage.
|
|
|
9424
9992
|
env,
|
|
9425
9993
|
out,
|
|
9426
9994
|
io
|
|
9427
|
-
});
|
|
9995
|
+
}));
|
|
9428
9996
|
} catch (error) {
|
|
9429
9997
|
stderr.write(`spamscanner: ${error.message}
|
|
9430
9998
|
`);
|