spamscanner 7.0.4 → 7.0.6

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