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