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/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 { existsSync as existsSync3, readFileSync as readFileSync2, statSync as statSync2 } from "node:fs";
9
+ import {
10
+ createReadStream as createReadStream2,
11
+ existsSync as existsSync3,
12
+ readFileSync as readFileSync2,
13
+ statSync as statSync2
14
+ } from "node:fs";
10
15
  import process3 from "node:process";
11
- import { parseArgs } from "node:util";
16
+ import { 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, count]] = counts;
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 = count / total;
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, limits]) => {
2317
+ const closeImplied = ([names, limits2]) => {
2312
2318
  const index = nearest(names);
2313
- if (index !== -1 && index >= (limits ? nearest(limits) + 1 : blocks.at(-1))) {
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, count] of counts) {
2861
- if (count > (counts.get(most) ?? 0)) {
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 count = 0;
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
- count++;
3320
+ count2++;
3315
3321
  }
3316
3322
  body = out + body.slice(from);
3317
- if (count > 0) {
3323
+ if (count2 > 0) {
3318
3324
  add(`pat:${name}`);
3319
- add(`pat:${name}:${bucket(count, [1, 3, 10])}`);
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 Buffer6 } from "node:buffer";
4621
+ import { Buffer as Buffer8 } from "node:buffer";
4616
4622
  import { EventEmitter } from "node:events";
4617
- import net from "node:net";
4623
+ import net2 from "node:net";
4618
4624
 
4619
4625
  // src/auth.js
4620
- import { Buffer as Buffer5, File } from "node:buffer";
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 count = embedded ? 6 : 8;
5147
+ const count2 = embedded ? 6 : 8;
4666
5148
  let run = { start: -1, length: 1 };
4667
- for (let i = 0; i < count; i++) {
5149
+ for (let i = 0; i < count2; i++) {
4668
5150
  let length = 0;
4669
- while (i + length < count && groups[i + length] === 0) {
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, count).map((group) => group.toString(16));
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
- const raw = Buffer5.isBuffer(message) ? message : Buffer5.from(String(message));
5328
- const output = await run(raw, {
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.4",
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/milter.js
5555
- var COMMANDS = {
5556
- ABORT: "A",
5557
- BODY: "B",
5558
- CONNECT: "C",
5559
- MACRO: "D",
5560
- BODYEOB: "E",
5561
- HELO: "H",
5562
- QUIT_NC: "K",
5563
- HEADER: "L",
5564
- MAIL: "M",
5565
- EOH: "N",
5566
- OPTNEG: "O",
5567
- QUIT: "Q",
5568
- RCPT: "R",
5569
- DATA: "T",
5570
- UNKNOWN: "U"
5571
- };
5572
- var RESPONSES = {
5573
- ACCEPT: "a",
5574
- CONTINUE: "c",
5575
- DISCARD: "d",
5576
- ADDHEADER: "h",
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 = Buffer6.alloc(0)) {
5619
- const payload = Buffer6.isBuffer(data) ? data : Buffer6.from(data, "latin1");
5620
- const header = Buffer6.alloc(5);
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 Buffer6.concat([header, payload]);
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 Buffer6.from(`${value}\0`, "latin1");
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] : Buffer6.concat(this.chunks);
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 = Buffer6.alloc(12);
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(Buffer6.from(data));
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
- await this.endOfMessage();
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 Buffer6.concat([Buffer6.from(this.headers.length > 0 ? `${head}\r
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 = Buffer6.alloc(4);
6570
+ const index = Buffer8.alloc(4);
5830
6571
  index.writeUInt32BE(counts.get(key));
5831
- this.send(RESPONSES.CHGHEADER, Buffer6.concat([index, cstring(name), cstring("")]));
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, Buffer6.concat([cstring("X-Spam-Virus-Scan"), cstring(`${this.leadingSpace ? " " : ""}failed`)]));
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, Buffer6.concat([cstring(name), cstring(`${this.leadingSpace ? " " : ""}${value}`.replaceAll("\r\n", "\n"))]));
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 = Buffer6.alloc(4);
6622
+ const index = Buffer8.alloc(4);
5882
6623
  index.writeUInt32BE(1);
5883
- this.send(RESPONSES.CHGHEADER, Buffer6.concat([index, cstring("Subject"), cstring(`${this.leadingSpace ? " " : ""}${value}`.replaceAll("\r\n", "\n"))]));
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: 25 * 1024 * 1024,
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 = net.createServer((socket) => {
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
- writeFileSync(temporary, `${JSON.stringify(classifier.toJSON(options))}
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 Buffer8 } from "node:buffer";
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 ? Buffer8.from(lines2.join("\r\n")) : Buffer8.concat([Buffer8.from(lines2.join("\r\n")), body]);
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], Buffer8.from(result.tests.map((test) => test.name).join(",")));
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 ? Buffer8.from(report(result)) : Buffer8.alloc(0));
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], Buffer8.from(`${header}${newline}${newline}`, "latin1"));
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: 25 * 1024 * 1024,
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
- return net3.createServer({ allowHalfOpen: true }, (socket) => {
6466
- let head = Buffer8.alloc(0);
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 = Buffer8.concat([head, chunk2]);
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(Buffer8.concat(chunks).subarray(0, request.length ?? 0));
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(Buffer8.concat(chunks));
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 count = 0;
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
- count++;
6960
- if (onProgress && count % 1e3 === 0) {
6961
- onProgress(count);
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 entries = [];
7318
- const read = /* @__PURE__ */ new Set();
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 && !read.has(offset)) {
7322
- read.add(offset);
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, count, entries) {
7355
- for (let n = 0; n < count && isDirectory(buffer, offset); n++) {
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 name = buffer.subarray(offset + 46, offset + 46 + nameLength).toString(flags & 2048 ? "utf8" : "latin1");
7362
- entries.push({ name, encrypted: (flags & 1) === 1, size });
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 count = 0; count < MAX_AFTER; count++) {
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 readInput(file, stdin) {
9113
- if (!file || file === "-") {
9114
- const chunks = [];
9115
- for await (const chunk2 of stdin) {
9116
- chunks.push(Buffer14.from(chunk2));
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
- return readFileSync2(file);
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
- child.stdin.end(message);
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 raw = await readInput("-", stdin);
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), { allowTell: Boolean(values["allow-tell"]), modelPath: values.out || null, subjectTag: values["subject-tag"] ?? null });
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: (count) => stderr.write(`\r${count} messages`)
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
  `);