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.
@@ -3642,16 +3642,19 @@ function zipEntries(buffer, limit = 2e3) {
3642
3642
  records.push(i);
3643
3643
  }
3644
3644
  }
3645
- const entries = [];
3646
- const read = /* @__PURE__ */ new Set();
3645
+ const state = {
3646
+ entries: [],
3647
+ limit,
3648
+ read: /* @__PURE__ */ new Set(),
3649
+ names: MAX_NAME_BYTES
3650
+ };
3647
3651
  for (const eocd of records) {
3648
3652
  const offset = directoryOf(buffer, eocd);
3649
- if (offset !== -1 && !read.has(offset)) {
3650
- read.add(offset);
3651
- readDirectory(buffer, offset, limit - entries.length, entries);
3653
+ if (offset !== -1) {
3654
+ readDirectory(buffer, offset, state);
3652
3655
  }
3653
3656
  }
3654
- return entries;
3657
+ return state.entries;
3655
3658
  }
3656
3659
  function directoryOf(buffer, eocd) {
3657
3660
  let offset = buffer.readUInt32LE(eocd + 16);
@@ -3677,18 +3680,32 @@ function directoryOf(buffer, eocd) {
3677
3680
  }
3678
3681
  return -1;
3679
3682
  }
3680
- function readDirectory(buffer, offset, count, entries) {
3681
- for (let n = 0; n < count && isDirectory(buffer, offset); n++) {
3683
+ function readDirectory(buffer, offset, state) {
3684
+ while (state.entries.length < state.limit && isDirectory(buffer, offset) && !state.read.has(offset)) {
3685
+ state.read.add(offset);
3682
3686
  const flags = buffer.readUInt16LE(offset + 8);
3683
3687
  const size = buffer.readUInt32LE(offset + 24);
3684
3688
  const nameLength = buffer.readUInt16LE(offset + 28);
3685
3689
  const extraLength = buffer.readUInt16LE(offset + 30);
3686
3690
  const commentLength = buffer.readUInt16LE(offset + 32);
3687
- const name = buffer.subarray(offset + 46, offset + 46 + nameLength).toString(flags & 2048 ? "utf8" : "latin1");
3688
- entries.push({ name, encrypted: (flags & 1) === 1, size });
3691
+ const encoding = flags & 2048 ? "utf8" : "latin1";
3692
+ const start = offset + 46;
3693
+ const end = Math.min(buffer.length, start + nameLength);
3694
+ const kept = Math.min(end - start, MAX_NAME_LENGTH);
3695
+ state.names -= kept;
3696
+ if (state.names < 0) {
3697
+ return;
3698
+ }
3699
+ const half = MAX_NAME_LENGTH / 2;
3700
+ 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);
3701
+ state.entries.push({ name, encrypted: (flags & 1) === 1, size });
3689
3702
  offset += 46 + nameLength + extraLength + commentLength;
3690
3703
  }
3691
3704
  }
3705
+ function shorten(name, max = 100) {
3706
+ const characters = [...name];
3707
+ return characters.length <= max ? name : `${characters.slice(0, max - 41).join("")}\u2026${characters.slice(-40).join("")}`;
3708
+ }
3692
3709
  function extensionOf(name) {
3693
3710
  const base = trimEndOf(String(name).toLowerCase().split(/[/\\]/).pop(), ". ");
3694
3711
  const dot = base.lastIndexOf(".");
@@ -3747,7 +3764,7 @@ function inspectAttachments(attachments = []) {
3747
3764
  }
3748
3765
  const inner = entries.filter((entry) => EXECUTABLE_EXTENSIONS.has(extensionOf(entry.name)) && !(office && OFFICE_EMBEDDED.has(extensionOf(entry.name))));
3749
3766
  const details = { extension: extensionOf(inner[0]?.name ?? ""), extensions: [...new Set(inner.map((entry) => extensionOf(entry.name)))] };
3750
- const listed = inner.slice(0, 3).map((entry) => entry.name.split("/").pop()).join(", ");
3767
+ const listed = inner.slice(0, 3).map((entry) => shorten(entry.name.split("/").pop())).join(", ");
3751
3768
  if (inner.some((entry) => !SCRIPT_EXTENSIONS.has(extensionOf(entry.name)))) {
3752
3769
  add("executable_in_archive", `Archive "${name}" contains ${inner.length === 1 ? "a program" : `${inner.length} programs`} (${listed})`, details);
3753
3770
  } else if (inner.length > 0) {
@@ -3775,7 +3792,7 @@ function inspectAttachments(attachments = []) {
3775
3792
  }
3776
3793
  return findings;
3777
3794
  }
3778
- var import_node_buffer, EXECUTABLE_EXTENSIONS, RISKY_EXTENSIONS, SCRIPT_EXTENSIONS, MACRO_EXTENSIONS, DOCUMENT_EXTENSIONS, TEMPORARY_EXTENSIONS, BIDI_CONTROLS, ARCHIVE_TYPES, ZIP_FAMILY, OFFICE_TYPES, OFFICE_EMBEDDED, ENTRIES, DIRECTORY, isDirectory, VBA_NAMES, PDF_ACTIVE, PDF_ESCAPED_NAME, decodePdfNames;
3795
+ var import_node_buffer, EXECUTABLE_EXTENSIONS, RISKY_EXTENSIONS, SCRIPT_EXTENSIONS, MACRO_EXTENSIONS, DOCUMENT_EXTENSIONS, TEMPORARY_EXTENSIONS, BIDI_CONTROLS, ARCHIVE_TYPES, ZIP_FAMILY, OFFICE_TYPES, OFFICE_EMBEDDED, ENTRIES, MAX_NAME_LENGTH, MAX_NAME_BYTES, DIRECTORY, isDirectory, VBA_NAMES, PDF_ACTIVE, PDF_ESCAPED_NAME, decodePdfNames;
3779
3796
  var init_attachments = __esm({
3780
3797
  "src/attachments.js"() {
3781
3798
  import_node_buffer = require("node:buffer");
@@ -3931,6 +3948,8 @@ var init_attachments = __esm({
3931
3948
  OFFICE_TYPES = /* @__PURE__ */ new Set(["docx", "xlsx", "pptx"]);
3932
3949
  OFFICE_EMBEDDED = MACRO_EXTENSIONS;
3933
3950
  ENTRIES = /* @__PURE__ */ new WeakMap();
3951
+ MAX_NAME_LENGTH = 1024;
3952
+ MAX_NAME_BYTES = 1024 * 1024;
3934
3953
  DIRECTORY = 33639248;
3935
3954
  isDirectory = (buffer, offset) => offset >= 0 && offset + 46 <= buffer.length && buffer.readUInt32LE(offset) === DIRECTORY;
3936
3955
  VBA_NAMES = /* @__PURE__ */ new Set(["vba", "_vba_project", "_vba_project_cur", "macros"]);
@@ -3940,6 +3959,487 @@ var init_attachments = __esm({
3940
3959
  }
3941
3960
  });
3942
3961
 
3962
+ // src/parse.js
3963
+ function boundAddressField(line, room) {
3964
+ const start = line.indexOf(":") + 1;
3965
+ const limit = Math.min(line.length, MAX_ADDRESS_FIELD, room);
3966
+ let end = start;
3967
+ let expecting = "";
3968
+ let escaped = false;
3969
+ let separators = 0;
3970
+ let groups = 0;
3971
+ let index = start;
3972
+ for (; index < limit; index++) {
3973
+ const chr = line[index];
3974
+ if ((chr === "," || chr === ";") && ++separators > MAX_ADDRESS_SEPARATORS) {
3975
+ break;
3976
+ }
3977
+ if (escaped) {
3978
+ escaped = false;
3979
+ } else if (expecting !== "" && chr === expecting) {
3980
+ expecting = "";
3981
+ end = chr === ";" ? index + 1 : end;
3982
+ } else if (expecting === "" && ADDRESS_OPERATORS.has(chr)) {
3983
+ expecting = ADDRESS_OPERATORS.get(chr);
3984
+ end = chr === "," || chr === ";" ? index : end;
3985
+ } else if (expecting === '"' && chr === "\\") {
3986
+ escaped = true;
3987
+ }
3988
+ if (chr === ":" && expecting === ";" && ++groups > MAX_ADDRESS_GROUPS) {
3989
+ break;
3990
+ }
3991
+ }
3992
+ return index === line.length ? line : line.slice(0, end).trimEnd();
3993
+ }
3994
+ function boundAddressLines(lines2, budget, only = null) {
3995
+ const cut = /* @__PURE__ */ new Set();
3996
+ const bounded = lines2.map((entry) => {
3997
+ if (only ? !only.has(entry.key) : !ADDRESS_FIELDS.has(entry.key) && !entry.key.startsWith("list-")) {
3998
+ return entry;
3999
+ }
4000
+ const line = boundAddressField(entry.line, budget.room);
4001
+ budget.room = Math.max(budget.room - line.length, 0);
4002
+ if (line === entry.line) {
4003
+ return entry;
4004
+ }
4005
+ cut.add(entry.key);
4006
+ return { ...entry, line };
4007
+ });
4008
+ return { lines: bounded, cut };
4009
+ }
4010
+ function boundedForAuth(raw) {
4011
+ const ends = [raw.indexOf("\n\n"), raw.indexOf("\n\r\n")].filter((index) => index !== -1);
4012
+ const end = ends.length > 0 ? Math.min(...ends) + 1 : raw.length;
4013
+ const body = raw.subarray(ends.length > 0 ? raw.indexOf(10, end) + 1 : raw.length);
4014
+ const text = raw.subarray(0, end).toString("latin1");
4015
+ let { length } = text;
4016
+ while (length > 0 && (text[length - 1] === "\n" || text[length - 1] === "\r")) {
4017
+ length--;
4018
+ }
4019
+ const fields = [];
4020
+ let folds = 0;
4021
+ let work = 0;
4022
+ for (let start = 0; start <= length; ) {
4023
+ let stop = text.indexOf("\n", start);
4024
+ stop = stop === -1 || stop > length ? length : stop;
4025
+ const lineEnd = stop > start && text[stop - 1] === "\r" ? stop - 1 : stop;
4026
+ const last = fields.at(-1);
4027
+ if (last && /\s/.test(text[start])) {
4028
+ last.end = lineEnd;
4029
+ last.folds++;
4030
+ folds++;
4031
+ work += last.folds;
4032
+ } else {
4033
+ fields.push({ start, end: lineEnd, folds: 0 });
4034
+ }
4035
+ start = stop + 1;
4036
+ }
4037
+ let signatures = 0;
4038
+ let dkim = 0;
4039
+ for (const field of fields) {
4040
+ field.line = text.slice(field.start, field.end);
4041
+ const colon = field.line.indexOf(":");
4042
+ field.key = (colon === -1 ? field.line : field.line.slice(0, colon)).trim().toLowerCase();
4043
+ field.dropped = field.key === "dkim-signature" && ++dkim > MAX_DKIM_SIGNATURES;
4044
+ if (SIGNATURE_FIELDS.has(field.key) && !field.dropped) {
4045
+ signatures += field.line.split(":").length - 1;
4046
+ }
4047
+ }
4048
+ const read = dkim > MAX_DKIM_SIGNATURES ? fields.filter((field) => !field.dropped) : fields;
4049
+ work += (folds + signatures) * read.length;
4050
+ const bounded = boundAddressLines(read, { room: MAX_ADDRESS_TOTAL }, AUTH_ADDRESS_FIELDS);
4051
+ let kept = bounded.lines;
4052
+ let fromCut = bounded.cut.has("from");
4053
+ if (work > MAX_AUTH_WORK) {
4054
+ const counts = /* @__PURE__ */ new Map();
4055
+ kept = kept.filter((field) => {
4056
+ counts.set(field.key, (counts.get(field.key) || 0) + 1);
4057
+ const keep = AUTH_FIELDS.has(field.key) && counts.get(field.key) <= (SIGNATURE_FIELDS.has(field.key) ? MAX_SIGNATURES : MAX_REPEATED);
4058
+ fromCut ||= field.key === "from" && !keep;
4059
+ return keep;
4060
+ }).map((field) => {
4061
+ const line = field.line.replaceAll(/\r?\n(?=\s)/g, "");
4062
+ return { ...field, line: ADDRESS_FIELDS.has(field.key) ? line : line.slice(0, MAX_FIELD) };
4063
+ });
4064
+ } else if (bounded.cut.size === 0 && read === fields) {
4065
+ return { raw, fromCut };
4066
+ }
4067
+ const header = kept.map((field) => field.line).join("\r\n");
4068
+ return { raw: import_node_buffer2.Buffer.concat([import_node_buffer2.Buffer.from(`${header}\r
4069
+ \r
4070
+ `, "latin1"), body]), fromCut };
4071
+ }
4072
+ async function simpleParse(raw, { timeout = PARSE_TIMEOUT, parser = import_mailparser.simpleParser } = {}) {
4073
+ const input = import_node_stream.Readable.from([finishable(unfoldHeaders(raw))], { objectMode: false, autoDestroy: false });
4074
+ input.on("error", () => {
4075
+ });
4076
+ const { pipe } = input;
4077
+ input.pipe = function(parser2, options) {
4078
+ parser2.splitter?.on("error", (error) => input.destroy(error));
4079
+ return pipe.call(this, parser2, options);
4080
+ };
4081
+ let timer;
4082
+ try {
4083
+ return await Promise.race([
4084
+ parser(input, PARSER_OPTIONS),
4085
+ new Promise((resolve, reject) => {
4086
+ timer = setTimeout(() => {
4087
+ const error = Object.assign(new Error(`Message not parsed within ${timeout} ms`), { code: "ETIMEDOUT" });
4088
+ input.destroy(error);
4089
+ reject(error);
4090
+ }, timeout);
4091
+ })
4092
+ ]);
4093
+ } finally {
4094
+ clearTimeout(timer);
4095
+ input.destroy();
4096
+ }
4097
+ }
4098
+ async function parseMessage(raw, options = {}) {
4099
+ const buffer = import_node_buffer2.Buffer.isBuffer(raw) ? raw : import_node_buffer2.Buffer.from(raw);
4100
+ const mail = await simpleParse(buffer, options);
4101
+ const added = buffer.at(-1) === 10 ? 2 : 3;
4102
+ for (const key of ["text", "html"]) {
4103
+ if (typeof mail[key] === "string") {
4104
+ mail[key] = mail[key].replace(new RegExp(`\\n{0,${added}}$`), "");
4105
+ }
4106
+ }
4107
+ const tail = import_node_buffer2.Buffer.from("\r\n".repeat(added));
4108
+ const last = mail.attachments?.at(-1);
4109
+ const encoding = String(last?.headers?.get?.("content-transfer-encoding") ?? "7bit").trim().toLowerCase();
4110
+ const own3 = import_node_buffer2.Buffer.isBuffer(last?.content) && last.content.length >= tail.length && last.content.subarray(-tail.length).equals(tail) ? last.content.subarray(0, -tail.length) : null;
4111
+ if (own3 && ["7bit", "8bit", "binary"].includes(encoding) && own3.length <= buffer.length && buffer.subarray(buffer.length - own3.length).equals(own3)) {
4112
+ last.content = own3;
4113
+ last.size = own3.length;
4114
+ last.checksum = (0, import_node_crypto.createHash)("md5").update(own3).digest("hex");
4115
+ }
4116
+ return mail;
4117
+ }
4118
+ function finishable(raw) {
4119
+ const buffer = import_node_buffer2.Buffer.isBuffer(raw) ? raw : import_node_buffer2.Buffer.from(raw);
4120
+ return import_node_buffer2.Buffer.concat([buffer, import_node_buffer2.Buffer.from(buffer.at(-1) === 10 ? "\r\n\r\n" : "\r\n\r\n\r\n")]);
4121
+ }
4122
+ function headerValue2(line) {
4123
+ const parameters = /* @__PURE__ */ new Map();
4124
+ let key = null;
4125
+ let inKey = false;
4126
+ let quoted = false;
4127
+ let escaped = false;
4128
+ let value = "";
4129
+ for (const chr of line.slice(line.indexOf(":") + 1).replaceAll(/\r?\n[ \t]*/g, " ")) {
4130
+ if (inKey && chr === "=") {
4131
+ key = value.trim().toLowerCase();
4132
+ inKey = false;
4133
+ value = "";
4134
+ } else if (inKey || escaped) {
4135
+ value += chr;
4136
+ escaped = false;
4137
+ } else if (chr === "\\") {
4138
+ escaped = true;
4139
+ } else if (chr === '"') {
4140
+ quoted = !quoted;
4141
+ } else if (chr === ";" && !quoted) {
4142
+ parameters.set(key, value.trim());
4143
+ inKey = true;
4144
+ value = "";
4145
+ } else {
4146
+ value += chr;
4147
+ }
4148
+ }
4149
+ if (!inKey) {
4150
+ parameters.set(key, value.trim());
4151
+ }
4152
+ return { value: parameters.get(null).toLowerCase(), boundary: parameters.get("boundary") ?? "" };
4153
+ }
4154
+ function delimiter(raw, start, stop, boundary) {
4155
+ const rest = start + 2 + boundary.length;
4156
+ if (stop - start < boundary.length + 2 || stop - rest > 4 || raw[start] !== 45 || raw[start + 1] !== 45 || !raw.subarray(start + 2, rest).equals(boundary)) {
4157
+ return 0;
4158
+ }
4159
+ if (raw[rest] === 13 || raw[rest] === 10) {
4160
+ return 1;
4161
+ }
4162
+ const tail = raw.subarray(rest, stop).toString("latin1");
4163
+ return /^-{0,2}$|^--\r?\n$|^--\r$/.test(tail) ? 2 : 0;
4164
+ }
4165
+ function unfoldHeaders(raw) {
4166
+ const boundaries = [];
4167
+ const unfold = [];
4168
+ let head = true;
4169
+ let fields = [];
4170
+ const endHead = (blank) => {
4171
+ unfold.push(...fields.filter((field) => field.folds > MAX_FOLDS));
4172
+ const first = /* @__PURE__ */ new Map();
4173
+ for (const field of fields) {
4174
+ const name = raw.toString("latin1", field.start, Math.min(field.end, field.start + 32));
4175
+ const key = name.slice(0, name.indexOf(":")).trim().toLowerCase();
4176
+ if (["content-type", "content-transfer-encoding", "content-disposition"].includes(key) && !first.has(key)) {
4177
+ first.set(key, headerValue2(raw.toString("latin1", field.start, field.end)));
4178
+ }
4179
+ }
4180
+ fields = [];
4181
+ const type = first.get("content-type") ?? { value: "", boundary: "" };
4182
+ const encoding = (first.get("content-transfer-encoding")?.value ?? "").replaceAll(/\(.*\)/g, "").trim();
4183
+ if (blank && type.boundary) {
4184
+ boundaries.push(import_node_buffer2.Buffer.from(type.boundary));
4185
+ }
4186
+ head = blank && type.value === "message/rfc822" && ["", "7bit", "8bit", "binary"].includes(encoding) && first.get("content-disposition")?.value === "inline";
4187
+ };
4188
+ for (let start = 0; start < raw.length; ) {
4189
+ const lf = raw.indexOf(10, start);
4190
+ const stop = lf === -1 ? raw.length : lf + 1;
4191
+ const end = lf === -1 ? stop : raw[lf - 1] === 13 ? lf - 1 : lf;
4192
+ const kind = boundaries.length > 0 ? delimiter(raw, start, stop, boundaries.at(-1)) : 0;
4193
+ if (kind !== 0) {
4194
+ if (head) {
4195
+ endHead(false);
4196
+ }
4197
+ head = kind === 1;
4198
+ if (kind === 2) {
4199
+ boundaries.pop();
4200
+ }
4201
+ } else if (head && end === start) {
4202
+ endHead(true);
4203
+ } else if (head && fields.length > 0 && (raw[start] === 32 || raw[start] === 9)) {
4204
+ fields.at(-1).end = end;
4205
+ fields.at(-1).folds++;
4206
+ } else if (head) {
4207
+ fields.push({ start, end, folds: 0 });
4208
+ }
4209
+ start = stop;
4210
+ }
4211
+ unfold.push(...fields.filter((field) => field.folds > MAX_FOLDS));
4212
+ if (unfold.length === 0) {
4213
+ return raw;
4214
+ }
4215
+ const chunks = [];
4216
+ let copied = 0;
4217
+ for (const field of unfold) {
4218
+ chunks.push(raw.subarray(copied, field.start), import_node_buffer2.Buffer.from(raw.toString("latin1", field.start, field.end).replaceAll(/\r?\n[ \t]*/g, " "), "latin1"));
4219
+ copied = field.end;
4220
+ }
4221
+ chunks.push(raw.subarray(copied));
4222
+ return import_node_buffer2.Buffer.concat(chunks);
4223
+ }
4224
+ function upToLine(raw, offset) {
4225
+ return raw.subarray(0, Math.max(raw.lastIndexOf(10, offset - 1) + 1, 0));
4226
+ }
4227
+ function fewerParts(raw) {
4228
+ let offset = -1;
4229
+ for (let count = 0; count < MAX_PART_LINES; count++) {
4230
+ offset = raw.indexOf("\n--", offset + 1);
4231
+ if (offset === -1) {
4232
+ return upToLine(raw, Math.floor(raw.length / 2));
4233
+ }
4234
+ }
4235
+ return raw.subarray(0, offset + 1);
4236
+ }
4237
+ function smallerHeader(raw) {
4238
+ const text = raw.toString("latin1");
4239
+ const blank = /\r?\n\r?\n/.exec(text);
4240
+ const end = blank ? blank.index : text.length;
4241
+ const fields = [];
4242
+ let start = 0;
4243
+ while (start < end) {
4244
+ let stop = text.indexOf("\n", start);
4245
+ stop = stop === -1 || stop > end ? end : stop;
4246
+ const last = fields.at(-1);
4247
+ const whole = text.slice(start, stop).replace(/\r$/, "");
4248
+ if (last && (whole.startsWith(" ") || whole.startsWith(" "))) {
4249
+ const line = last.address ? whole : whole.slice(0, MAX_LINE);
4250
+ if (last.address || last.size + line.length <= MAX_FIELD) {
4251
+ last.lines.push(line);
4252
+ last.size += line.length;
4253
+ }
4254
+ } else {
4255
+ const name = whole.slice(0, whole.indexOf(":")).trim().toLowerCase();
4256
+ const address2 = ADDRESS_FIELDS.has(name) || name.startsWith("list-");
4257
+ const line = address2 ? whole : whole.slice(0, MAX_LINE);
4258
+ fields.push({
4259
+ name,
4260
+ address: address2,
4261
+ lines: [line],
4262
+ size: line.length
4263
+ });
4264
+ }
4265
+ start = stop + 1;
4266
+ }
4267
+ let fromCut = false;
4268
+ for (const field of fields.filter((field2) => field2.address)) {
4269
+ const line = field.lines.join("\r\n");
4270
+ const bounded = boundAddressField(line, Number.POSITIVE_INFINITY);
4271
+ fromCut ||= field.name === "from" && bounded !== line;
4272
+ field.lines = [bounded];
4273
+ field.size = bounded.length;
4274
+ }
4275
+ const total = fields.reduce((sum, field) => sum + field.size, 0);
4276
+ const counts = /* @__PURE__ */ new Map();
4277
+ const kept = total > MAX_HEADER ? fields.filter((field) => {
4278
+ counts.set(field.name, (counts.get(field.name) || 0) + 1);
4279
+ const keep = KEPT_FIELDS.has(field.name) && counts.get(field.name) <= MAX_REPEATED;
4280
+ fromCut ||= field.name === "from" && !keep;
4281
+ return keep;
4282
+ }) : fields;
4283
+ const header = kept.flatMap((field) => field.lines).join("\r\n");
4284
+ return { raw: import_node_buffer2.Buffer.concat([import_node_buffer2.Buffer.from(`${header}\r
4285
+ \r
4286
+ `, "latin1"), raw.subarray(blank ? blank.index + blank[0].length : raw.length)]), fromCut };
4287
+ }
4288
+ async function parseWithin(raw, parse = parseMessage, deadline = Date.now() + PARSE_TIMEOUT) {
4289
+ let input = raw;
4290
+ let reason = null;
4291
+ let fromCut = false;
4292
+ for (; ; ) {
4293
+ try {
4294
+ const mail = await parse(input, { timeout: Math.max(1, deadline - Date.now()) });
4295
+ if (fromCut && mail.from) {
4296
+ mail.from.truncated = true;
4297
+ }
4298
+ return { mail, error: reason };
4299
+ } catch (error) {
4300
+ const limit = error.code === "EMAXLEN" && [TOO_MANY_PARTS, HEADER_TOO_LARGE].includes(error.message) ? error.message : null;
4301
+ let shorter = input;
4302
+ if (limit === TOO_MANY_PARTS) {
4303
+ shorter = fewerParts(input);
4304
+ } else if (limit === HEADER_TOO_LARGE) {
4305
+ const smaller = smallerHeader(input);
4306
+ shorter = smaller.raw.length < input.length ? smaller.raw : upToLine(input, MAX_PREFIX_BYTES);
4307
+ fromCut ||= smaller.raw === shorter && smaller.fromCut;
4308
+ }
4309
+ if (shorter.length >= input.length) {
4310
+ throw error;
4311
+ }
4312
+ reason ??= limit;
4313
+ input = shorter;
4314
+ }
4315
+ }
4316
+ }
4317
+ async function expandAttachments(attachments = [], errors = [], parse = parseMessage) {
4318
+ const all = [];
4319
+ const deadline = Date.now() + PARSE_TIMEOUT;
4320
+ let budget = MAX_NESTED_BYTES;
4321
+ let nested = 0;
4322
+ const visit = async (list, prefix, depth) => {
4323
+ for (const attachment of list) {
4324
+ if (prefix) {
4325
+ if (nested >= MAX_ATTACHMENTS) {
4326
+ return;
4327
+ }
4328
+ nested++;
4329
+ }
4330
+ const name = attachment.filename || (prefix ? "unnamed attachment" : "");
4331
+ all.push(prefix ? { ...attachment, filename: `${prefix}/${name}` } : attachment);
4332
+ const { content } = attachment;
4333
+ const attached = attachment.contentType === "message/rfc822" || /\.eml$/i.test(attachment.filename || "");
4334
+ if (attached && depth < MAX_NESTED_DEPTH && import_node_buffer2.Buffer.isBuffer(content) && content.length <= budget) {
4335
+ budget -= content.length;
4336
+ let inner;
4337
+ try {
4338
+ inner = await parseWithin(content, parse, deadline);
4339
+ } catch (error) {
4340
+ errors.push(`${name || "attached message"}: ${error.message}`);
4341
+ continue;
4342
+ }
4343
+ if (inner.error) {
4344
+ errors.push(inner.error);
4345
+ }
4346
+ await visit(inner.mail.attachments, prefix ? `${prefix}/${name}` : name || "attached message", depth + 1);
4347
+ }
4348
+ }
4349
+ };
4350
+ await visit(attachments, "", 0);
4351
+ return all;
4352
+ }
4353
+ var import_node_buffer2, import_node_crypto, import_node_stream, import_mailparser, ADDRESS_FIELDS, MAX_ADDRESS_FIELD, MAX_ADDRESS_SEPARATORS, MAX_ADDRESS_GROUPS, MAX_ADDRESS_TOTAL, AUTH_ADDRESS_FIELDS, ADDRESS_OPERATORS, SINGLE_FIELDS, PRIORITY_FIELDS, BOUNDED2, ROOM, PARSER_OPTIONS, processHeaders, TOO_MANY_PARTS, HEADER_TOO_LARGE, MAX_PART_LINES, MAX_PREFIX_BYTES, MAX_LINE, MAX_FIELD, MAX_HEADER, MAX_REPEATED, KEPT_FIELDS, MAX_AUTH_WORK, SIGNATURE_FIELDS, AUTH_FIELDS, MAX_SIGNATURES, MAX_DKIM_SIGNATURES, MAX_NESTED_DEPTH, MAX_ATTACHMENTS, MAX_NESTED_BYTES, PARSE_TIMEOUT, MAX_FOLDS;
4354
+ var init_parse = __esm({
4355
+ "src/parse.js"() {
4356
+ import_node_buffer2 = require("node:buffer");
4357
+ import_node_crypto = require("node:crypto");
4358
+ import_node_stream = require("node:stream");
4359
+ import_mailparser = require("mailparser");
4360
+ ADDRESS_FIELDS = /* @__PURE__ */ new Set(["from", "to", "cc", "bcc", "sender", "reply-to", "delivered-to", "return-path", "disposition-notification-to"]);
4361
+ MAX_ADDRESS_FIELD = 16 * 1024;
4362
+ MAX_ADDRESS_SEPARATORS = 2e3;
4363
+ MAX_ADDRESS_GROUPS = 16;
4364
+ MAX_ADDRESS_TOTAL = 64 * 1024;
4365
+ AUTH_ADDRESS_FIELDS = /* @__PURE__ */ new Set(["from", "return-path"]);
4366
+ ADDRESS_OPERATORS = /* @__PURE__ */ new Map([['"', '"'], ["(", ")"], ["<", ">"], [",", ""], [":", ";"], [";", ""]]);
4367
+ 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"]);
4368
+ PRIORITY_FIELDS = /* @__PURE__ */ new Set(["priority", "x-priority", "x-msmail-priority", "importance"]);
4369
+ BOUNDED2 = /* @__PURE__ */ Symbol("bounded address fields");
4370
+ ROOM = /* @__PURE__ */ Symbol("room left for address fields");
4371
+ PARSER_OPTIONS = {
4372
+ skipHtmlToText: true,
4373
+ skipImageLinks: true,
4374
+ skipTextToHtml: true,
4375
+ skipTextLinks: true,
4376
+ [BOUNDED2]: true
4377
+ };
4378
+ ({ processHeaders } = import_mailparser.MailParser.prototype);
4379
+ import_mailparser.MailParser.prototype.processHeaders = function(lines2) {
4380
+ if (!this.options[BOUNDED2]) {
4381
+ return processHeaders.call(this, lines2);
4382
+ }
4383
+ this[ROOM] ??= { room: MAX_ADDRESS_TOTAL };
4384
+ const bounded = boundAddressLines(lines2, this[ROOM]);
4385
+ const headers = processHeaders.call(this, bounded.lines);
4386
+ const keyOf = (entry) => PRIORITY_FIELDS.has(entry.key) ? "priority" : entry.key;
4387
+ const counts = /* @__PURE__ */ new Map();
4388
+ for (const key of bounded.lines.map((entry) => keyOf(entry)).filter((key2) => SINGLE_FIELDS.has(key2))) {
4389
+ counts.set(key, (counts.get(key) || 0) + 1);
4390
+ }
4391
+ for (const key of [...counts.keys()].filter((key2) => counts.get(key2) > 1)) {
4392
+ const last = bounded.lines.findLast((entry) => keyOf(entry) === key && processHeaders.call(this, [entry]).has(key));
4393
+ if (last) {
4394
+ headers.set(key, processHeaders.call(this, [last]).get(key));
4395
+ }
4396
+ }
4397
+ for (const value of [...bounded.cut].flatMap((key) => headers.get(key) ?? [])) {
4398
+ value.truncated = true;
4399
+ }
4400
+ return headers;
4401
+ };
4402
+ TOO_MANY_PARTS = "Max allowed child nodes exceeded";
4403
+ HEADER_TOO_LARGE = "Max header size for a MIME node exceeded";
4404
+ MAX_PART_LINES = 990;
4405
+ MAX_PREFIX_BYTES = 1e6;
4406
+ MAX_LINE = 998;
4407
+ MAX_FIELD = 8192;
4408
+ MAX_HEADER = 512 * 1024;
4409
+ MAX_REPEATED = 100;
4410
+ KEPT_FIELDS = /* @__PURE__ */ new Set([
4411
+ "from",
4412
+ "sender",
4413
+ "reply-to",
4414
+ "to",
4415
+ "cc",
4416
+ "subject",
4417
+ "date",
4418
+ "message-id",
4419
+ "return-path",
4420
+ "received",
4421
+ "authentication-results",
4422
+ "arc-authentication-results",
4423
+ "dkim-signature",
4424
+ "mime-version",
4425
+ "content-type",
4426
+ "content-transfer-encoding",
4427
+ "list-unsubscribe",
4428
+ "list-id"
4429
+ ]);
4430
+ MAX_AUTH_WORK = 1e7;
4431
+ SIGNATURE_FIELDS = /* @__PURE__ */ new Set(["dkim-signature", "arc-message-signature"]);
4432
+ AUTH_FIELDS = /* @__PURE__ */ new Set([...KEPT_FIELDS, "arc-seal", "arc-message-signature"]);
4433
+ MAX_SIGNATURES = 10;
4434
+ MAX_DKIM_SIGNATURES = 8;
4435
+ MAX_NESTED_DEPTH = 3;
4436
+ MAX_ATTACHMENTS = 100;
4437
+ MAX_NESTED_BYTES = 25 * 1024 * 1024;
4438
+ PARSE_TIMEOUT = 6e4;
4439
+ MAX_FOLDS = 256;
4440
+ }
4441
+ });
4442
+
3943
4443
  // src/dnsbl.js
3944
4444
  function isLookupName(name) {
3945
4445
  if (typeof name !== "string") {
@@ -4193,7 +4693,7 @@ function requestJson(method, url, body, options = {}) {
4193
4693
  const { headers = {}, timeout = 3e4, maxResponseBytes = 1048576, ca } = options;
4194
4694
  const target = new URL(url);
4195
4695
  const transport = target.protocol === "https:" ? import_node_https.default : import_node_http.default;
4196
- const payload = body === null || body === void 0 ? null : import_node_buffer2.Buffer.from(JSON.stringify(body));
4696
+ const payload = body === null || body === void 0 ? null : import_node_buffer3.Buffer.from(JSON.stringify(body));
4197
4697
  return new Promise((resolve, reject) => {
4198
4698
  let settled = false;
4199
4699
  const finish = (error, value) => {
@@ -4231,7 +4731,7 @@ function requestJson(method, url, body, options = {}) {
4231
4731
  });
4232
4732
  response.on("error", (error) => finish(error));
4233
4733
  response.on("end", () => {
4234
- const text = import_node_buffer2.Buffer.concat(chunks).toString("utf8");
4734
+ const text = import_node_buffer3.Buffer.concat(chunks).toString("utf8");
4235
4735
  if (response.statusCode < 200 || response.statusCode >= 300) {
4236
4736
  const error = new Error(`HTTP ${response.statusCode} from ${target.origin}${target.pathname}: ${text.slice(0, 300)}`);
4237
4737
  error.statusCode = response.statusCode;
@@ -4253,10 +4753,10 @@ function requestJson(method, url, body, options = {}) {
4253
4753
  request.end(payload ?? void 0);
4254
4754
  });
4255
4755
  }
4256
- var import_node_buffer2, import_node_http, import_node_https;
4756
+ var import_node_buffer3, import_node_http, import_node_https;
4257
4757
  var init_http = __esm({
4258
4758
  "src/http.js"() {
4259
- import_node_buffer2 = require("node:buffer");
4759
+ import_node_buffer3 = require("node:buffer");
4260
4760
  import_node_http = __toESM(require("node:http"), 1);
4261
4761
  import_node_https = __toESM(require("node:https"), 1);
4262
4762
  }
@@ -4396,7 +4896,7 @@ function trustedReputationValues(mail = {}, session = {}, authentication = null)
4396
4896
  ...[spfAligned].filter((signer) => signer !== null && (alignment?.spf?.strict !== true || signer === domain))
4397
4897
  ])];
4398
4898
  const fromValues = [];
4399
- for (const { address: address2 } of mail.from?.value || []) {
4899
+ for (const { address: address2 } of mail.from?.truncated ? [] : mail.from?.value || []) {
4400
4900
  const domain = domainOf(address2);
4401
4901
  const values = reputationValues({ from: { value: [{ address: address2 }] } });
4402
4902
  if (domain !== null && dmarcDomain !== null && (domain === dmarcDomain || registrableDomain(domain) === dmarcDomain)) {
@@ -4552,7 +5052,7 @@ var init_reputation = __esm({
4552
5052
 
4553
5053
  // src/auth.js
4554
5054
  async function getMailauth() {
4555
- globalThis.File ||= import_node_buffer3.File;
5055
+ globalThis.File ||= import_node_buffer4.File;
4556
5056
  mailauth ||= await import("mailauth");
4557
5057
  return mailauth;
4558
5058
  }
@@ -4585,6 +5085,35 @@ function createResolver(timeout = 1e4, servers) {
4585
5085
  }
4586
5086
  };
4587
5087
  }
5088
+ function dnsTimeout(message) {
5089
+ const error = new Error(message);
5090
+ error.code = "ETIMEOUT";
5091
+ return error;
5092
+ }
5093
+ function limitResolver(resolver, { maxQueries = MAX_DNS_QUERIES, deadline = Number.POSITIVE_INFINITY } = {}) {
5094
+ let count = 0;
5095
+ return async (...args) => {
5096
+ count++;
5097
+ if (count > maxQueries) {
5098
+ throw dnsTimeout(`More than ${maxQueries} DNS lookups for one message`);
5099
+ }
5100
+ const left = deadline - Date.now();
5101
+ if (left <= 0) {
5102
+ throw dnsTimeout("Out of time for this message's DNS lookups");
5103
+ }
5104
+ let timer;
5105
+ try {
5106
+ return await Promise.race([
5107
+ resolver(...args),
5108
+ new Promise((_resolve, reject) => {
5109
+ timer = setTimeout(() => reject(dnsTimeout("Out of time for this message's DNS lookups")), Math.min(left, 2147483647));
5110
+ })
5111
+ ]);
5112
+ } finally {
5113
+ clearTimeout(timer);
5114
+ }
5115
+ };
5116
+ }
4588
5117
  function emptyResult() {
4589
5118
  return {
4590
5119
  dkim: { status: { result: "none", comment: "not checked" }, results: [], aligned: null },
@@ -4722,9 +5251,14 @@ async function authenticate(message, options = {}) {
4722
5251
  }
4723
5252
  try {
4724
5253
  const { authenticate: run } = await getMailauth();
4725
- const resolver = options.resolver || createResolver(timeout, options.dnsServers);
4726
- const raw = import_node_buffer3.Buffer.isBuffer(message) ? message : import_node_buffer3.Buffer.from(String(message));
4727
- const output = await run(raw, {
5254
+ const resolver = limitResolver(options.resolver || createResolver(timeout, options.dnsServers), {
5255
+ maxQueries: options.maxDnsQueries ?? MAX_DNS_QUERIES,
5256
+ // (a little after `timeout`, so a caller that waits that long for the
5257
+ // result, as scan() does, has its answer first)
5258
+ deadline: Date.now() + timeout + 100
5259
+ });
5260
+ const bounded = boundedForAuth(import_node_buffer4.Buffer.isBuffer(message) ? message : import_node_buffer4.Buffer.from(String(message)));
5261
+ const output = await run(bounded.raw, {
4728
5262
  ip,
4729
5263
  helo: helo || void 0,
4730
5264
  sender: sender ?? void 0,
@@ -4732,6 +5266,10 @@ async function authenticate(message, options = {}) {
4732
5266
  resolver
4733
5267
  });
4734
5268
  Object.assign(result, normalizeAuthOutput(output, { trustedArcSealers: options.trustedArcSealers }));
5269
+ if (bounded.fromCut) {
5270
+ result.dmarc = { ...result.dmarc, status: { result: "fail", comment: "From field too long to read whole" } };
5271
+ result.headers = "";
5272
+ }
4735
5273
  } catch (error) {
4736
5274
  debug("authentication failed: %s", error.message);
4737
5275
  result.error = error.message;
@@ -4766,11 +5304,24 @@ function calculateAuthScore(auth, weights = {}) {
4766
5304
  }
4767
5305
  return { score: tests.reduce((sum, test) => sum + test.score, 0), tests };
4768
5306
  }
5307
+ function limitResults(value) {
5308
+ if (value.length <= MAX_AUTH_RESULTS_LENGTH) {
5309
+ return value;
5310
+ }
5311
+ const [server, ...results] = value.split(/;(?=\s*(?:dkim|spf|dmarc|arc|bimi)=)/i);
5312
+ let kept = server;
5313
+ for (const result of results) {
5314
+ if (kept.length + 1 + result.length <= MAX_AUTH_RESULTS_LENGTH) {
5315
+ kept += `;${result}`;
5316
+ }
5317
+ }
5318
+ return kept;
5319
+ }
4769
5320
  function formatAuthResultsHeader(auth, hostname) {
4770
5321
  if (typeof auth?.headers === "string") {
4771
5322
  const match = auth.headers.match(/^authentication-results:[ \t]*([\s\S]*?)(?=\r?\n\S|(?![\s\S]))/im);
4772
5323
  if (match) {
4773
- const value = match[1].replaceAll(/\r?\n/g, "\r\n").trim();
5324
+ const value = limitResults(match[1].replaceAll(/\r?\n/g, "\r\n").trim());
4774
5325
  return hostname ? value.replace(/^[^;\s]+/, hostname) : value;
4775
5326
  }
4776
5327
  }
@@ -4783,16 +5334,20 @@ function formatAuthResultsHeader(auth, hostname) {
4783
5334
  function summarizeAuth(auth) {
4784
5335
  return ["spf", "dkim", "dmarc", "arc"].map((name) => `${name}=${auth?.[name]?.status?.result || "none"}`).join(" ");
4785
5336
  }
4786
- var import_node_buffer3, import_node_dns2, import_node_util, debug, mailauth, RECORD_TYPES, tagValue, tagText, DEFAULT_AUTH_WEIGHTS;
5337
+ var import_node_buffer4, import_node_dns2, import_node_util, debug, mailauth, RECORD_TYPES, MAX_DNS_QUERIES, MAX_AUTH_RESULTS_LENGTH, tagValue, tagText, DEFAULT_AUTH_WEIGHTS;
4787
5338
  var init_auth = __esm({
4788
5339
  "src/auth.js"() {
4789
- import_node_buffer3 = require("node:buffer");
5340
+ import_node_buffer4 = require("node:buffer");
4790
5341
  import_node_dns2 = __toESM(require("node:dns"), 1);
4791
5342
  import_node_util = require("node:util");
5343
+ init_parse();
4792
5344
  init_reputation();
4793
5345
  init_tokenizer();
5346
+ init_parse();
4794
5347
  debug = (0, import_node_util.debuglog)("spamscanner:auth");
4795
5348
  RECORD_TYPES = /* @__PURE__ */ new Set(["A", "AAAA", "MX", "TXT", "PTR", "CNAME", "NS", "SOA", "SRV"]);
5349
+ MAX_DNS_QUERIES = 300;
5350
+ MAX_AUTH_RESULTS_LENGTH = 8192;
4796
5351
  tagValue = (value) => value && typeof value === "object" ? value.value : value;
4797
5352
  tagText = (value) => typeof value === "string" && value.trim() !== "" ? value.trim().toLowerCase() : null;
4798
5353
  DEFAULT_AUTH_WEIGHTS = {
@@ -4838,10 +5393,10 @@ function exchange(address2, write, timeout) {
4838
5393
  resolve(value);
4839
5394
  }
4840
5395
  };
4841
- const timer = setTimeout(() => finish(new Error(`clamd did not answer within ${timeout} ms`)), timeout);
5396
+ const timer = setTimeout(() => finish(Object.assign(new Error(`clamd did not answer within ${timeout} ms`), { code: "ETIMEDOUT", timedOut: true })), timeout);
4842
5397
  socket.on("connect", () => write(socket));
4843
5398
  socket.on("data", (chunk2) => chunks.push(chunk2));
4844
- socket.on("end", () => finish(null, import_node_buffer4.Buffer.concat(chunks).toString("utf8").replaceAll("\0", "").trim()));
5399
+ socket.on("end", () => finish(null, import_node_buffer5.Buffer.concat(chunks).toString("utf8").replaceAll("\0", "").trim()));
4845
5400
  socket.on("error", (error) => finish(error));
4846
5401
  });
4847
5402
  }
@@ -4855,12 +5410,12 @@ async function scanBuffer(buffer, options = {}) {
4855
5410
  socket.write("zINSTREAM\0");
4856
5411
  for (let offset = 0; offset < buffer.length; offset += chunkSize) {
4857
5412
  const chunk2 = buffer.subarray(offset, offset + chunkSize);
4858
- const size = import_node_buffer4.Buffer.alloc(4);
5413
+ const size = import_node_buffer5.Buffer.alloc(4);
4859
5414
  size.writeUInt32BE(chunk2.length);
4860
5415
  socket.write(size);
4861
5416
  socket.write(chunk2);
4862
5417
  }
4863
- socket.end(import_node_buffer4.Buffer.alloc(4));
5418
+ socket.end(import_node_buffer5.Buffer.alloc(4));
4864
5419
  }, options.timeout ?? 3e4);
4865
5420
  if (/\bOK$/.test(reply2)) {
4866
5421
  return { infected: false, viruses: [], reply: reply2 };
@@ -4871,10 +5426,10 @@ async function scanBuffer(buffer, options = {}) {
4871
5426
  }
4872
5427
  throw new Error(`clamd: ${reply2 || "no reply"}`);
4873
5428
  }
4874
- var import_node_buffer4, import_node_fs, import_node_net3, SOCKET_PATHS;
5429
+ var import_node_buffer5, import_node_fs, import_node_net3, SOCKET_PATHS;
4875
5430
  var init_clamav = __esm({
4876
5431
  "src/clamav.js"() {
4877
- import_node_buffer4 = require("node:buffer");
5432
+ import_node_buffer5 = require("node:buffer");
4878
5433
  import_node_fs = require("node:fs");
4879
5434
  import_node_net3 = __toESM(require("node:net"), 1);
4880
5435
  SOCKET_PATHS = ["/var/run/clamav/clamd.ctl", "/run/clamav/clamd.ctl", "/run/clamd.scan/clamd.sock", "/var/run/clamd.scan/clamd.sock", "/var/run/clamav/clamd.sock", "/tmp/clamd.socket", "/opt/homebrew/var/run/clamav/clamd.sock", "/usr/local/var/run/clamav/clamd.sock"];
@@ -4884,7 +5439,7 @@ var init_clamav = __esm({
4884
5439
  // src/classifier.js
4885
5440
  function hashFeature(value) {
4886
5441
  let hash = 2166136261;
4887
- const bytes = import_node_buffer5.Buffer.from(value, "utf8");
5442
+ const bytes = import_node_buffer6.Buffer.from(value, "utf8");
4888
5443
  for (const byte of bytes) {
4889
5444
  hash ^= byte;
4890
5445
  hash = Math.imul(hash, 16777619);
@@ -4920,10 +5475,10 @@ function modelOptions(options, corrupt) {
4920
5475
  }
4921
5476
  return checked;
4922
5477
  }
4923
- var import_node_buffer5, MODEL_VERSION, DEFAULTS, OPTION_CHECKS, NULL_MEANS, isWord, IDENTITY, Classifier;
5478
+ var import_node_buffer6, MODEL_VERSION, DEFAULTS, OPTION_CHECKS, NULL_MEANS, isWord, IDENTITY, Classifier;
4924
5479
  var init_classifier = __esm({
4925
5480
  "src/classifier.js"() {
4926
- import_node_buffer5 = require("node:buffer");
5481
+ import_node_buffer6 = require("node:buffer");
4927
5482
  MODEL_VERSION = 2;
4928
5483
  DEFAULTS = {
4929
5484
  // Robinson's s and x: how much an unseen or rare feature is pulled to 0.5.
@@ -5215,7 +5770,7 @@ var init_classifier = __esm({
5215
5770
  spam[i] = s;
5216
5771
  ham[i] = h;
5217
5772
  }
5218
- const encode = (array) => import_node_buffer5.Buffer.from(array.buffer, array.byteOffset, array.byteLength).toString("base64");
5773
+ const encode = (array) => import_node_buffer6.Buffer.from(array.buffer, array.byteOffset, array.byteLength).toString("base64");
5219
5774
  return {
5220
5775
  type: "spamscanner-classifier",
5221
5776
  version: MODEL_VERSION,
@@ -5266,7 +5821,7 @@ var init_classifier = __esm({
5266
5821
  throw new TypeError(`Unsupported classifier model version ${data2.version}; this release reads version ${MODEL_VERSION}`);
5267
5822
  }
5268
5823
  const decode = (value, name) => {
5269
- const buffer = typeof value === "string" ? import_node_buffer5.Buffer.from(value, "base64") : null;
5824
+ const buffer = typeof value === "string" ? import_node_buffer6.Buffer.from(value, "base64") : null;
5270
5825
  if (!buffer || buffer.byteLength % 4 !== 0) {
5271
5826
  throw corrupt(`${name} is not a list of 32-bit numbers`);
5272
5827
  }
@@ -5757,7 +6312,7 @@ function authHeaders(config) {
5757
6312
  case "basic": {
5758
6313
  const user = config.username ?? "";
5759
6314
  const pass = config.password ?? config.apiKey ?? "";
5760
- headers.authorization = `Basic ${import_node_buffer6.Buffer.from(`${user}:${pass}`).toString("base64")}`;
6315
+ headers.authorization = `Basic ${import_node_buffer7.Buffer.from(`${user}:${pass}`).toString("base64")}`;
5761
6316
  break;
5762
6317
  }
5763
6318
  case "x-api-key": {
@@ -6108,7 +6663,7 @@ function endpoint(config, suffix) {
6108
6663
  return url.href;
6109
6664
  }
6110
6665
  function buildRequest(config, text) {
6111
- const nonce = (0, import_node_crypto.randomBytes)(6).toString("hex");
6666
+ const nonce = (0, import_node_crypto2.randomBytes)(6).toString("hex");
6112
6667
  const policy = config.policy ? `
6113
6668
 
6114
6669
  Additional policy from the mail server's operator:
@@ -6204,11 +6759,11 @@ function replyText(api, reply2) {
6204
6759
  }
6205
6760
  return reply2?.choices?.[0]?.message?.content || null;
6206
6761
  }
6207
- var import_node_buffer6, import_node_crypto, import_node_os, import_node_process, PROVIDERS, DECISION_APIS, TOP_LOGPROBS, VERDICTS, VERDICT_CRITERIA, SIGNS, GUIDE, DEFAULT_SYSTEM_PROMPT, DECISION_SYSTEM_PROMPT, DECISION_INSTRUCTIONS, LOCALHOST, PRIVATE_HOST, REDACTIONS, THINK_OPEN, THINK_CLOSE, ANSWER_FIRST, LOCAL_CHARACTER, BREAKS2, VERDICT_WORD2, NOT, DENIED, JOINED, SPAM_LABEL, HAM_WORDS, HAM_LABEL, NOT_HAM_LABEL, UNWANTED, MIN_VERDICT_MASS, percent, MODES, LLMClassifier;
6762
+ var import_node_buffer7, import_node_crypto2, import_node_os, import_node_process, PROVIDERS, DECISION_APIS, TOP_LOGPROBS, VERDICTS, VERDICT_CRITERIA, SIGNS, GUIDE, DEFAULT_SYSTEM_PROMPT, DECISION_SYSTEM_PROMPT, DECISION_INSTRUCTIONS, LOCALHOST, PRIVATE_HOST, REDACTIONS, THINK_OPEN, THINK_CLOSE, ANSWER_FIRST, LOCAL_CHARACTER, BREAKS2, VERDICT_WORD2, NOT, DENIED, JOINED, SPAM_LABEL, HAM_WORDS, HAM_LABEL, NOT_HAM_LABEL, UNWANTED, MIN_VERDICT_MASS, percent, MODES, LLMClassifier;
6208
6763
  var init_llm = __esm({
6209
6764
  "src/llm.js"() {
6210
- import_node_buffer6 = require("node:buffer");
6211
- import_node_crypto = require("node:crypto");
6765
+ import_node_buffer7 = require("node:buffer");
6766
+ import_node_crypto2 = require("node:crypto");
6212
6767
  import_node_os = __toESM(require("node:os"), 1);
6213
6768
  import_node_process = __toESM(require("node:process"), 1);
6214
6769
  init_http();
@@ -6576,7 +7131,7 @@ Answer with exactly one word: ham, spam, phishing, scam or malware.`;
6576
7131
  * @returns {Promise<{verdict: string, confidence: number, language: string|null, reasons: string[], provider: string, model: string, cached: boolean, time: number}>}
6577
7132
  */
6578
7133
  async classifyText(text) {
6579
- const key = (0, import_node_crypto.createHash)("sha256").update(text).digest("hex");
7134
+ const key = (0, import_node_crypto2.createHash)("sha256").update(text).digest("hex");
6580
7135
  if (this.cache.has(key)) {
6581
7136
  const hit = this.cache.get(key);
6582
7137
  this.cache.delete(key);
@@ -6700,10 +7255,20 @@ function loadModel(file, options = {}) {
6700
7255
  function saveModel(classifier, file, options = {}) {
6701
7256
  const resolved = import_node_path.default.resolve(file);
6702
7257
  (0, import_node_fs2.mkdirSync)(import_node_path.default.dirname(resolved), { recursive: true });
6703
- const temporary = `${resolved}.${import_node_process2.default.pid}.${(0, import_node_crypto2.randomBytes)(6).toString("hex")}.tmp`;
7258
+ const temporary = `${resolved}.${import_node_process2.default.pid}.${(0, import_node_crypto3.randomBytes)(6).toString("hex")}.tmp`;
7259
+ const mode = (0, import_node_fs2.statSync)(resolved, { throwIfNoEntry: false })?.mode;
7260
+ const fd = (0, import_node_fs2.openSync)(temporary, "wx");
6704
7261
  try {
6705
- (0, import_node_fs2.writeFileSync)(temporary, `${JSON.stringify(classifier.toJSON(options))}
7262
+ try {
7263
+ if (mode !== void 0) {
7264
+ (0, import_node_fs2.fchmodSync)(fd, mode % 512);
7265
+ }
7266
+ (0, import_node_fs2.writeFileSync)(fd, `${JSON.stringify(classifier.toJSON(options))}
6706
7267
  `);
7268
+ (0, import_node_fs2.fsyncSync)(fd);
7269
+ } finally {
7270
+ (0, import_node_fs2.closeSync)(fd);
7271
+ }
6707
7272
  (0, import_node_fs2.renameSync)(temporary, resolved);
6708
7273
  } catch (error) {
6709
7274
  (0, import_node_fs2.rmSync)(temporary, { force: true });
@@ -6711,11 +7276,11 @@ function saveModel(classifier, file, options = {}) {
6711
7276
  }
6712
7277
  loaded.delete(resolved);
6713
7278
  }
6714
- var import_node_fs2, import_node_crypto2, import_node_module2, import_node_path, import_node_process2, import_node_url3, loaded;
7279
+ var import_node_fs2, import_node_crypto3, import_node_module2, import_node_path, import_node_process2, import_node_url3, loaded;
6715
7280
  var init_model = __esm({
6716
7281
  "src/model.js"() {
6717
7282
  import_node_fs2 = require("node:fs");
6718
- import_node_crypto2 = require("node:crypto");
7283
+ import_node_crypto3 = require("node:crypto");
6719
7284
  import_node_module2 = require("node:module");
6720
7285
  import_node_path = __toESM(require("node:path"), 1);
6721
7286
  import_node_process2 = __toESM(require("node:process"), 1);
@@ -6725,180 +7290,6 @@ var init_model = __esm({
6725
7290
  }
6726
7291
  });
6727
7292
 
6728
- // src/parse.js
6729
- async function parseMessage(raw) {
6730
- const buffer = import_node_buffer7.Buffer.isBuffer(raw) ? raw : import_node_buffer7.Buffer.from(raw);
6731
- const mail = await (0, import_mailparser.simpleParser)(finishable(buffer), PARSER_OPTIONS);
6732
- const added = buffer.at(-1) === 10 ? 2 : 3;
6733
- for (const key of ["text", "html"]) {
6734
- if (typeof mail[key] === "string") {
6735
- mail[key] = mail[key].replace(new RegExp(`\\n{0,${added}}$`), "");
6736
- }
6737
- }
6738
- const tail = import_node_buffer7.Buffer.from("\r\n".repeat(added));
6739
- const last = mail.attachments?.at(-1);
6740
- const encoding = String(last?.headers?.get?.("content-transfer-encoding") ?? "7bit").trim().toLowerCase();
6741
- const own3 = import_node_buffer7.Buffer.isBuffer(last?.content) && last.content.length >= tail.length && last.content.subarray(-tail.length).equals(tail) ? last.content.subarray(0, -tail.length) : null;
6742
- if (own3 && ["7bit", "8bit", "binary"].includes(encoding) && own3.length <= buffer.length && buffer.subarray(buffer.length - own3.length).equals(own3)) {
6743
- last.content = own3;
6744
- last.size = own3.length;
6745
- last.checksum = (0, import_node_crypto3.createHash)("md5").update(own3).digest("hex");
6746
- }
6747
- return mail;
6748
- }
6749
- function finishable(raw) {
6750
- const buffer = import_node_buffer7.Buffer.isBuffer(raw) ? raw : import_node_buffer7.Buffer.from(raw);
6751
- return import_node_buffer7.Buffer.concat([buffer, import_node_buffer7.Buffer.from(buffer.at(-1) === 10 ? "\r\n\r\n" : "\r\n\r\n\r\n")]);
6752
- }
6753
- function upToLine(raw, offset) {
6754
- return raw.subarray(0, Math.max(raw.lastIndexOf(10, offset - 1) + 1, 0));
6755
- }
6756
- function fewerParts(raw) {
6757
- let offset = -1;
6758
- for (let count = 0; count < MAX_PART_LINES; count++) {
6759
- offset = raw.indexOf("\n--", offset + 1);
6760
- if (offset === -1) {
6761
- return upToLine(raw, Math.floor(raw.length / 2));
6762
- }
6763
- }
6764
- return raw.subarray(0, offset + 1);
6765
- }
6766
- function smallerHeader(raw) {
6767
- const text = raw.toString("latin1");
6768
- const blank = /\r?\n\r?\n/.exec(text);
6769
- const end = blank ? blank.index : text.length;
6770
- const fields = [];
6771
- let start = 0;
6772
- while (start < end) {
6773
- let stop = text.indexOf("\n", start);
6774
- stop = stop === -1 || stop > end ? end : stop;
6775
- const line = text.slice(start, Math.min(stop, start + MAX_LINE)).replace(/\r$/, "");
6776
- const last = fields.at(-1);
6777
- if (last && (line.startsWith(" ") || line.startsWith(" "))) {
6778
- if (last.size + line.length <= MAX_FIELD) {
6779
- last.lines.push(line);
6780
- last.size += line.length;
6781
- }
6782
- } else {
6783
- fields.push({ name: line.slice(0, line.indexOf(":")).trim().toLowerCase(), lines: [line], size: line.length });
6784
- }
6785
- start = stop + 1;
6786
- }
6787
- const total = fields.reduce((sum, field) => sum + field.size, 0);
6788
- const counts = /* @__PURE__ */ new Map();
6789
- const kept = total > MAX_HEADER ? fields.filter((field) => {
6790
- counts.set(field.name, (counts.get(field.name) || 0) + 1);
6791
- return KEPT_FIELDS.has(field.name) && counts.get(field.name) <= MAX_REPEATED;
6792
- }) : fields;
6793
- const header = kept.flatMap((field) => field.lines).join("\r\n");
6794
- return import_node_buffer7.Buffer.concat([import_node_buffer7.Buffer.from(`${header}\r
6795
- \r
6796
- `, "latin1"), raw.subarray(blank ? blank.index + blank[0].length : raw.length)]);
6797
- }
6798
- async function parseWithin(raw, parse = parseMessage) {
6799
- let input = raw;
6800
- let reason = null;
6801
- for (; ; ) {
6802
- try {
6803
- return { mail: await parse(input), error: reason };
6804
- } catch (error) {
6805
- const limit = error.code === "EMAXLEN" && [TOO_MANY_PARTS, HEADER_TOO_LARGE].includes(error.message) ? error.message : null;
6806
- let shorter = input;
6807
- if (limit === TOO_MANY_PARTS) {
6808
- shorter = fewerParts(input);
6809
- } else if (limit === HEADER_TOO_LARGE) {
6810
- shorter = smallerHeader(input);
6811
- shorter = shorter.length < input.length ? shorter : upToLine(input, MAX_PREFIX_BYTES);
6812
- }
6813
- if (shorter.length >= input.length) {
6814
- throw error;
6815
- }
6816
- reason ??= limit;
6817
- input = shorter;
6818
- }
6819
- }
6820
- }
6821
- async function expandAttachments(attachments = [], errors = [], parse = parseMessage) {
6822
- const all = [];
6823
- let budget = MAX_NESTED_BYTES;
6824
- let nested = 0;
6825
- const visit = async (list, prefix, depth) => {
6826
- for (const attachment of list) {
6827
- if (prefix) {
6828
- if (nested >= MAX_ATTACHMENTS) {
6829
- return;
6830
- }
6831
- nested++;
6832
- }
6833
- const name = attachment.filename || (prefix ? "unnamed attachment" : "");
6834
- all.push(prefix ? { ...attachment, filename: `${prefix}/${name}` } : attachment);
6835
- const { content } = attachment;
6836
- const attached = attachment.contentType === "message/rfc822" || /\.eml$/i.test(attachment.filename || "");
6837
- if (attached && depth < MAX_NESTED_DEPTH && import_node_buffer7.Buffer.isBuffer(content) && content.length <= budget) {
6838
- budget -= content.length;
6839
- let inner;
6840
- try {
6841
- inner = await parseWithin(content, parse);
6842
- } catch (error) {
6843
- errors.push(`${name || "attached message"}: ${error.message}`);
6844
- continue;
6845
- }
6846
- if (inner.error) {
6847
- errors.push(inner.error);
6848
- }
6849
- await visit(inner.mail.attachments, prefix ? `${prefix}/${name}` : name || "attached message", depth + 1);
6850
- }
6851
- }
6852
- };
6853
- await visit(attachments, "", 0);
6854
- return all;
6855
- }
6856
- var import_node_buffer7, import_node_crypto3, import_mailparser, PARSER_OPTIONS, TOO_MANY_PARTS, HEADER_TOO_LARGE, MAX_PART_LINES, MAX_PREFIX_BYTES, MAX_LINE, MAX_FIELD, MAX_HEADER, MAX_REPEATED, KEPT_FIELDS, MAX_NESTED_DEPTH, MAX_ATTACHMENTS, MAX_NESTED_BYTES;
6857
- var init_parse = __esm({
6858
- "src/parse.js"() {
6859
- import_node_buffer7 = require("node:buffer");
6860
- import_node_crypto3 = require("node:crypto");
6861
- import_mailparser = require("mailparser");
6862
- PARSER_OPTIONS = {
6863
- skipHtmlToText: true,
6864
- skipImageLinks: true,
6865
- skipTextToHtml: true,
6866
- skipTextLinks: true
6867
- };
6868
- TOO_MANY_PARTS = "Max allowed child nodes exceeded";
6869
- HEADER_TOO_LARGE = "Max header size for a MIME node exceeded";
6870
- MAX_PART_LINES = 990;
6871
- MAX_PREFIX_BYTES = 1e6;
6872
- MAX_LINE = 998;
6873
- MAX_FIELD = 8192;
6874
- MAX_HEADER = 512 * 1024;
6875
- MAX_REPEATED = 100;
6876
- KEPT_FIELDS = /* @__PURE__ */ new Set([
6877
- "from",
6878
- "sender",
6879
- "reply-to",
6880
- "to",
6881
- "cc",
6882
- "subject",
6883
- "date",
6884
- "message-id",
6885
- "return-path",
6886
- "received",
6887
- "authentication-results",
6888
- "arc-authentication-results",
6889
- "dkim-signature",
6890
- "mime-version",
6891
- "content-type",
6892
- "content-transfer-encoding",
6893
- "list-unsubscribe",
6894
- "list-id"
6895
- ]);
6896
- MAX_NESTED_DEPTH = 3;
6897
- MAX_ATTACHMENTS = 100;
6898
- MAX_NESTED_BYTES = 25 * 1024 * 1024;
6899
- }
6900
- });
6901
-
6902
7293
  // src/score.js
6903
7294
  function bayesTestName(probability) {
6904
7295
  const steps = [[0.999, "999"], [0.99, "99"], [0.95, "95"], [0.8, "80"], [0.6, "60"], [0.4, "50"], [0.2, "40"], [0.05, "20"], [0.01, "05"]];
@@ -7123,7 +7514,7 @@ var init_package = __esm({
7123
7514
  package_default = {
7124
7515
  name: "spamscanner",
7125
7516
  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.",
7126
- version: "7.0.4",
7517
+ version: "7.0.5",
7127
7518
  author: "Forward Email (https://forwardemail.net)",
7128
7519
  bin: {
7129
7520
  spamscanner: "./dist/esm/cli.js"
@@ -7751,13 +8142,207 @@ var init_train = __esm({
7751
8142
  }
7752
8143
  });
7753
8144
 
8145
+ // src/server.js
8146
+ function serializeResult(result, options = {}) {
8147
+ const { mail, tokens, ...rest } = result;
8148
+ const output = {
8149
+ ...rest,
8150
+ results: { ...result.results }
8151
+ };
8152
+ if (output.results.authentication) {
8153
+ const { receivedChain, ...auth } = output.results.authentication;
8154
+ output.results.authentication = auth;
8155
+ }
8156
+ if (options.verbose) {
8157
+ output.tokens = tokens;
8158
+ output.subject = mail?.subject ?? null;
8159
+ }
8160
+ return output;
8161
+ }
8162
+ function sessionFromQuery(query) {
8163
+ const session = {};
8164
+ if (query.get("ip")) {
8165
+ session.remoteAddress = query.get("ip");
8166
+ }
8167
+ if (query.get("hostname")) {
8168
+ session.resolvedClientHostname = query.get("hostname");
8169
+ }
8170
+ if (query.get("helo")) {
8171
+ session.helo = query.get("helo");
8172
+ }
8173
+ const recipients = query.getAll("to").flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean);
8174
+ if (query.has("from") || recipients.length > 0) {
8175
+ session.envelope = { mailFrom: { address: query.get("from") || "" }, rcptTo: recipients.map((address2) => ({ address: address2 })) };
8176
+ }
8177
+ return session;
8178
+ }
8179
+ function sameToken(given, expected) {
8180
+ const a = import_node_buffer10.Buffer.from(String(given));
8181
+ const b = import_node_buffer10.Buffer.from(String(expected));
8182
+ return a.length === b.length && (0, import_node_crypto5.timingSafeEqual)(a, b);
8183
+ }
8184
+ function readBody(request, limit) {
8185
+ return new Promise((resolve, reject) => {
8186
+ const chunks = [];
8187
+ let size = 0;
8188
+ request.on("data", (chunk2) => {
8189
+ size += chunk2.length;
8190
+ if (size <= limit) {
8191
+ chunks.push(chunk2);
8192
+ }
8193
+ });
8194
+ request.on("end", () => {
8195
+ if (size > limit) {
8196
+ const error = new Error(`Message larger than ${limit} bytes`);
8197
+ error.statusCode = 413;
8198
+ reject(error);
8199
+ return;
8200
+ }
8201
+ resolve(import_node_buffer10.Buffer.concat(chunks));
8202
+ });
8203
+ request.on("error", reject);
8204
+ });
8205
+ }
8206
+ function createHttpServer(scanner, options = {}) {
8207
+ const {
8208
+ token = null,
8209
+ maxSize = MAX_MESSAGE_SIZE,
8210
+ modelPath = null,
8211
+ maxConnections = MAX_CONNECTIONS
8212
+ } = options;
8213
+ let active = 0;
8214
+ const server = import_node_http2.default.createServer(async (request, response) => {
8215
+ const send = (status, body, headers = {}) => {
8216
+ const isBuffer = import_node_buffer10.Buffer.isBuffer(body);
8217
+ response.writeHead(status, { "content-type": isBuffer ? "message/rfc822" : "application/json; charset=utf-8", "cache-control": "no-store", ...headers });
8218
+ response.end(isBuffer ? body : `${JSON.stringify(body)}
8219
+ `);
8220
+ };
8221
+ try {
8222
+ const url = new URL(request.url, "http://localhost");
8223
+ if (request.method === "GET" && url.pathname === "/health") {
8224
+ send(200, { ok: true, version: VERSION });
8225
+ return;
8226
+ }
8227
+ if (token && !sameToken((request.headers.authorization || "").replace(/^bearer\s+/i, ""), token)) {
8228
+ send(401, { error: "Unauthorized" }, { "www-authenticate": "Bearer" });
8229
+ return;
8230
+ }
8231
+ if (request.method !== "POST") {
8232
+ send(405, { error: "Method not allowed" }, { allow: "POST" });
8233
+ return;
8234
+ }
8235
+ const learn = /^\/learn\/(spam|ham)$/.exec(url.pathname);
8236
+ if (!["/scan", "/check"].includes(url.pathname) && !learn) {
8237
+ send(404, { error: "Not found" });
8238
+ return;
8239
+ }
8240
+ if (learn && !token) {
8241
+ send(403, { error: "Learning needs the server to be started with a token" });
8242
+ return;
8243
+ }
8244
+ const length = request.headers["content-length"];
8245
+ if (length !== void 0 && Number(length) > maxSize) {
8246
+ send(413, { error: `Message larger than ${maxSize} bytes` }, { connection: "close" });
8247
+ return;
8248
+ }
8249
+ if (active >= maxConnections) {
8250
+ send(503, { error: "Too many requests at once" }, { connection: "close", "retry-after": "1" });
8251
+ return;
8252
+ }
8253
+ active++;
8254
+ try {
8255
+ const raw = await readBody(request, maxSize);
8256
+ if (learn) {
8257
+ await scanner.learn(raw, learn[1]);
8258
+ if (modelPath) {
8259
+ scanner.saveModel(modelPath);
8260
+ }
8261
+ send(200, { ok: true, learned: learn[1] });
8262
+ return;
8263
+ }
8264
+ const result = await scanner.scan(raw, { session: sessionFromQuery(url.searchParams) });
8265
+ if (url.pathname === "/scan") {
8266
+ send(200, serializeResult(result, { verbose: url.searchParams.get("verbose") === "1" }));
8267
+ return;
8268
+ }
8269
+ const tag = url.searchParams.get("subjectTag");
8270
+ const rewritten = rewriteMessage(raw, spamHeaders(result, { version: VERSION }), { subjectTag: result.isSpam && tag ? tag : null });
8271
+ send(200, rewritten, { "x-spam-flag": result.isSpam ? "YES" : "NO", "x-spam-score": result.score.toFixed(1), "x-spam-action": result.action });
8272
+ } finally {
8273
+ active--;
8274
+ }
8275
+ } catch (error) {
8276
+ send(error.statusCode || 500, { error: error.message });
8277
+ }
8278
+ });
8279
+ server.maxConnections = maxConnections;
8280
+ return server;
8281
+ }
8282
+ function createTcpServer(scanner, options = {}) {
8283
+ const {
8284
+ json = true,
8285
+ maxSize = MAX_MESSAGE_SIZE,
8286
+ timeout = 6e4,
8287
+ maxConnections = MAX_CONNECTIONS
8288
+ } = options;
8289
+ const server = import_node_net4.default.createServer({ allowHalfOpen: true }, (socket) => {
8290
+ const chunks = [];
8291
+ let size = 0;
8292
+ socket.setTimeout(timeout, () => socket.destroy());
8293
+ const onEnd = async () => {
8294
+ socket.setTimeout(0);
8295
+ try {
8296
+ const result = await scanner.scan(import_node_buffer10.Buffer.concat(chunks));
8297
+ socket.end(json ? `${JSON.stringify(serializeResult(result))}
8298
+ ` : `${result.isSpam ? "SPAM" : "HAM"} ${result.score.toFixed(1)}/${result.threshold.toFixed(1)} ${result.tests.map((test) => test.name).join(",")}
8299
+ `);
8300
+ } catch (error) {
8301
+ socket.end(json ? `${JSON.stringify({ error: error.message })}
8302
+ ` : `ERROR ${error.message}
8303
+ `);
8304
+ }
8305
+ };
8306
+ const onData = (chunk2) => {
8307
+ size += chunk2.length;
8308
+ if (size > maxSize) {
8309
+ socket.off("data", onData);
8310
+ socket.off("end", onEnd);
8311
+ socket.end(`${JSON.stringify({ error: `Message larger than ${maxSize} bytes` })}
8312
+ `, () => socket.destroy());
8313
+ return;
8314
+ }
8315
+ chunks.push(chunk2);
8316
+ };
8317
+ socket.on("data", onData);
8318
+ socket.on("end", onEnd);
8319
+ socket.on("error", () => {
8320
+ });
8321
+ });
8322
+ server.maxConnections = maxConnections;
8323
+ return server;
8324
+ }
8325
+ var import_node_buffer10, import_node_crypto5, import_node_http2, import_node_net4, MAX_MESSAGE_SIZE, MAX_CONNECTIONS;
8326
+ var init_server = __esm({
8327
+ "src/server.js"() {
8328
+ import_node_buffer10 = require("node:buffer");
8329
+ import_node_crypto5 = require("node:crypto");
8330
+ import_node_http2 = __toESM(require("node:http"), 1);
8331
+ import_node_net4 = __toESM(require("node:net"), 1);
8332
+ init_headers();
8333
+ init_version();
8334
+ MAX_MESSAGE_SIZE = 25 * 1024 * 1024;
8335
+ MAX_CONNECTIONS = 200;
8336
+ }
8337
+ });
8338
+
7754
8339
  // src/milter.js
7755
- function packet(command, data2 = import_node_buffer10.Buffer.alloc(0)) {
7756
- const payload = import_node_buffer10.Buffer.isBuffer(data2) ? data2 : import_node_buffer10.Buffer.from(data2, "latin1");
7757
- const header = import_node_buffer10.Buffer.alloc(5);
8340
+ function packet(command, data2 = import_node_buffer11.Buffer.alloc(0)) {
8341
+ const payload = import_node_buffer11.Buffer.isBuffer(data2) ? data2 : import_node_buffer11.Buffer.from(data2, "latin1");
8342
+ const header = import_node_buffer11.Buffer.alloc(5);
7758
8343
  header.writeUInt32BE(payload.length + 1);
7759
8344
  header.write(command, 4, "latin1");
7760
- return import_node_buffer10.Buffer.concat([header, payload]);
8345
+ return import_node_buffer11.Buffer.concat([header, payload]);
7761
8346
  }
7762
8347
  function cstrings(data2) {
7763
8348
  const parts = data2.toString("latin1").split("\0");
@@ -7767,21 +8352,22 @@ function cstrings(data2) {
7767
8352
  return parts;
7768
8353
  }
7769
8354
  function cstring(value) {
7770
- return import_node_buffer10.Buffer.from(`${value}\0`, "latin1");
8355
+ return import_node_buffer11.Buffer.from(`${value}\0`, "latin1");
7771
8356
  }
7772
8357
  function address(value) {
7773
8358
  const text = String(value ?? "").trim();
7774
8359
  const match = /^<?([^<>]*)>?$/.exec(text);
7775
8360
  return match ? match[1] : text;
7776
8361
  }
7777
- var import_node_buffer10, import_node_events, import_node_net4, COMMANDS, RESPONSES, ACTIONS, PROTOCOL, MAX_PACKET, MilterSession, MilterServer;
8362
+ var import_node_buffer11, import_node_events, import_node_net5, COMMANDS, RESPONSES, ACTIONS, PROTOCOL, MAX_PACKET, MilterSession, MilterServer;
7778
8363
  var init_milter = __esm({
7779
8364
  "src/milter.js"() {
7780
- import_node_buffer10 = require("node:buffer");
8365
+ import_node_buffer11 = require("node:buffer");
7781
8366
  import_node_events = require("node:events");
7782
- import_node_net4 = __toESM(require("node:net"), 1);
8367
+ import_node_net5 = __toESM(require("node:net"), 1);
7783
8368
  init_headers();
7784
8369
  init_auth();
8370
+ init_server();
7785
8371
  init_version();
7786
8372
  COMMANDS = {
7787
8373
  ABORT: "A",
@@ -7875,6 +8461,15 @@ var init_milter = __esm({
7875
8461
  this.body = [];
7876
8462
  this.size = 0;
7877
8463
  this.truncated = false;
8464
+ this.headerSize = 0;
8465
+ }
8466
+ // The envelope and headers are kept whole for the scan, so beyond the
8467
+ // message size limit the message is not scanned (see onError).
8468
+ keep(data2) {
8469
+ this.headerSize += data2.length;
8470
+ if (this.headerSize > this.server.options.maxSize) {
8471
+ throw new Error(`Message headers larger than ${this.server.options.maxSize} bytes`);
8472
+ }
7878
8473
  }
7879
8474
  send(command, data2) {
7880
8475
  if (!this.socket.destroyed) {
@@ -7887,7 +8482,7 @@ var init_milter = __esm({
7887
8482
  if (this.buffered < this.needed) {
7888
8483
  return;
7889
8484
  }
7890
- let buffer = this.chunks.length === 1 ? this.chunks[0] : import_node_buffer10.Buffer.concat(this.chunks);
8485
+ let buffer = this.chunks.length === 1 ? this.chunks[0] : import_node_buffer11.Buffer.concat(this.chunks);
7891
8486
  this.needed = 4;
7892
8487
  while (buffer.length >= 4) {
7893
8488
  const length = buffer.readUInt32BE(0);
@@ -7932,7 +8527,7 @@ var init_milter = __esm({
7932
8527
  const protocol = data2.readUInt32BE(8);
7933
8528
  const wanted = ACTIONS.ADDHDRS | ACTIONS.CHGHDRS | ACTIONS.QUARANTINE;
7934
8529
  this.leadingSpace = Boolean(protocol & PROTOCOL.HDR_LEADSPC);
7935
- const reply2 = import_node_buffer10.Buffer.alloc(12);
8530
+ const reply2 = import_node_buffer11.Buffer.alloc(12);
7936
8531
  reply2.writeUInt32BE(Math.min(version, 6), 0);
7937
8532
  reply2.writeUInt32BE(actions & wanted, 4);
7938
8533
  reply2.writeUInt32BE(protocol & (PROTOCOL.HDR_LEADSPC | PROTOCOL.NOUNKNOWN), 8);
@@ -7975,16 +8570,19 @@ var init_milter = __esm({
7975
8570
  }
7976
8571
  case COMMANDS.MAIL: {
7977
8572
  this.resetMessage();
8573
+ this.keep(data2);
7978
8574
  this.envelope.mailFrom = { address: address(cstrings(data2)[0]) };
7979
8575
  this.send(RESPONSES.CONTINUE);
7980
8576
  break;
7981
8577
  }
7982
8578
  case COMMANDS.RCPT: {
8579
+ this.keep(data2);
7983
8580
  this.envelope.rcptTo.push({ address: address(cstrings(data2)[0]) });
7984
8581
  this.send(RESPONSES.CONTINUE);
7985
8582
  break;
7986
8583
  }
7987
8584
  case COMMANDS.HEADER: {
8585
+ this.keep(data2);
7988
8586
  const [name, value = ""] = cstrings(data2);
7989
8587
  this.headers.push([name, value]);
7990
8588
  this.send(RESPONSES.CONTINUE);
@@ -7994,14 +8592,19 @@ var init_milter = __esm({
7994
8592
  if (this.truncated || this.size + data2.length > options.maxSize) {
7995
8593
  this.truncated = true;
7996
8594
  } else {
7997
- this.body.push(import_node_buffer10.Buffer.from(data2));
8595
+ this.body.push(import_node_buffer11.Buffer.from(data2));
7998
8596
  this.size += data2.length;
7999
8597
  }
8000
8598
  this.send(RESPONSES.CONTINUE);
8001
8599
  break;
8002
8600
  }
8003
8601
  case COMMANDS.BODYEOB: {
8004
- await this.endOfMessage();
8602
+ this.socket.pause();
8603
+ try {
8604
+ await this.endOfMessage();
8605
+ } finally {
8606
+ this.socket.resume();
8607
+ }
8005
8608
  break;
8006
8609
  }
8007
8610
  case COMMANDS.ABORT: {
@@ -8024,7 +8627,7 @@ var init_milter = __esm({
8024
8627
  /** @returns {Buffer} */
8025
8628
  rawMessage() {
8026
8629
  const head = this.headers.map(([name, value]) => `${name}:${this.leadingSpace || value.startsWith(" ") || value.startsWith(" ") ? "" : " "}${value}`).join("\r\n");
8027
- return import_node_buffer10.Buffer.concat([import_node_buffer10.Buffer.from(this.headers.length > 0 ? `${head}\r
8630
+ return import_node_buffer11.Buffer.concat([import_node_buffer11.Buffer.from(this.headers.length > 0 ? `${head}\r
8028
8631
  \r
8029
8632
  ` : "\r\n", "latin1"), ...this.body]);
8030
8633
  }
@@ -8035,9 +8638,9 @@ var init_milter = __esm({
8035
8638
  const key = name.toLowerCase();
8036
8639
  counts.set(key, (counts.get(key) || 0) + 1);
8037
8640
  if (key.startsWith("x-spam-")) {
8038
- const index = import_node_buffer10.Buffer.alloc(4);
8641
+ const index = import_node_buffer11.Buffer.alloc(4);
8039
8642
  index.writeUInt32BE(counts.get(key));
8040
- this.send(RESPONSES.CHGHEADER, import_node_buffer10.Buffer.concat([index, cstring(name), cstring("")]));
8643
+ this.send(RESPONSES.CHGHEADER, import_node_buffer11.Buffer.concat([index, cstring(name), cstring("")]));
8041
8644
  }
8042
8645
  }
8043
8646
  }
@@ -8060,7 +8663,7 @@ var init_milter = __esm({
8060
8663
  }
8061
8664
  this.server.report(error);
8062
8665
  this.removeForgedHeaders();
8063
- this.send(RESPONSES.ADDHEADER, import_node_buffer10.Buffer.concat([cstring("X-Spam-Virus-Scan"), cstring(`${this.leadingSpace ? " " : ""}failed`)]));
8666
+ this.send(RESPONSES.ADDHEADER, import_node_buffer11.Buffer.concat([cstring("X-Spam-Virus-Scan"), cstring(`${this.leadingSpace ? " " : ""}failed`)]));
8064
8667
  this.send(RESPONSES.CONTINUE);
8065
8668
  this.resetMessage();
8066
8669
  return;
@@ -8081,15 +8684,15 @@ var init_milter = __esm({
8081
8684
  headers.unshift(["Authentication-Results", formatAuthResultsHeader(result.results.authentication, options.hostname)]);
8082
8685
  }
8083
8686
  for (const [name, value] of headers) {
8084
- this.send(RESPONSES.ADDHEADER, import_node_buffer10.Buffer.concat([cstring(name), cstring(`${this.leadingSpace ? " " : ""}${value}`.replaceAll("\r\n", "\n"))]));
8687
+ this.send(RESPONSES.ADDHEADER, import_node_buffer11.Buffer.concat([cstring(name), cstring(`${this.leadingSpace ? " " : ""}${value}`.replaceAll("\r\n", "\n"))]));
8085
8688
  }
8086
8689
  if (result.isSpam && options.subjectTag) {
8087
8690
  const subject = this.headers.find(([name]) => name.toLowerCase() === "subject");
8088
8691
  const value = taggedSubject(options.subjectTag, subject ? subject[1].trim() : "", "\n");
8089
8692
  if (value !== null) {
8090
- const index = import_node_buffer10.Buffer.alloc(4);
8693
+ const index = import_node_buffer11.Buffer.alloc(4);
8091
8694
  index.writeUInt32BE(1);
8092
- this.send(RESPONSES.CHGHEADER, import_node_buffer10.Buffer.concat([index, cstring("Subject"), cstring(`${this.leadingSpace ? " " : ""}${value}`.replaceAll("\r\n", "\n"))]));
8695
+ this.send(RESPONSES.CHGHEADER, import_node_buffer11.Buffer.concat([index, cstring("Subject"), cstring(`${this.leadingSpace ? " " : ""}${value}`.replaceAll("\r\n", "\n"))]));
8093
8696
  }
8094
8697
  }
8095
8698
  if (result.action === "reject" && options.reject) {
@@ -8116,8 +8719,10 @@ var init_milter = __esm({
8116
8719
  * @param {boolean} [options.quarantine] - hold spam in the MTA's quarantine
8117
8720
  * @param {string|null} [options.subjectTag] - e.g. "[SPAM]"
8118
8721
  * @param {string} [options.hostname] - this server's name, for Authentication-Results
8119
- * @param {number} [options.maxSize] - body bytes kept for scanning
8722
+ * @param {number} [options.maxSize] - body bytes kept for scanning (and the
8723
+ * most bytes of envelope and headers: beyond them a message is not scanned)
8120
8724
  * @param {'tempfail'|'accept'} [options.onError] - answer when a scan fails
8725
+ * @param {number} [options.maxConnections] - connections kept open at once; more are closed at once
8121
8726
  */
8122
8727
  constructor(scanner, options = {}) {
8123
8728
  super();
@@ -8129,19 +8734,21 @@ var init_milter = __esm({
8129
8734
  quarantine: false,
8130
8735
  subjectTag: null,
8131
8736
  hostname: "spamscanner",
8132
- maxSize: 25 * 1024 * 1024,
8737
+ maxSize: MAX_MESSAGE_SIZE,
8133
8738
  onError: "tempfail",
8739
+ maxConnections: MAX_CONNECTIONS,
8134
8740
  ...options
8135
8741
  };
8136
8742
  if (!/^[45]\d\d$/.test(String(this.options.rejectCode))) {
8137
8743
  throw new RangeError(`rejectCode must be a 4xx or 5xx SMTP code, not ${this.options.rejectCode}`);
8138
8744
  }
8139
8745
  this.sockets = /* @__PURE__ */ new Set();
8140
- this.server = import_node_net4.default.createServer((socket) => {
8746
+ this.server = import_node_net5.default.createServer((socket) => {
8141
8747
  this.sockets.add(socket);
8142
8748
  socket.on("close", () => this.sockets.delete(socket));
8143
8749
  return new MilterSession(socket, this);
8144
8750
  });
8751
+ this.server.maxConnections = this.options.maxConnections;
8145
8752
  this.server.on("error", (error) => this.report(error));
8146
8753
  }
8147
8754
  // Errors go to "error" listeners; with none, a broken connection or failed
@@ -8189,169 +8796,6 @@ var init_milter = __esm({
8189
8796
  }
8190
8797
  });
8191
8798
 
8192
- // src/server.js
8193
- function serializeResult(result, options = {}) {
8194
- const { mail, tokens, ...rest } = result;
8195
- const output = {
8196
- ...rest,
8197
- results: { ...result.results }
8198
- };
8199
- if (output.results.authentication) {
8200
- const { receivedChain, ...auth } = output.results.authentication;
8201
- output.results.authentication = auth;
8202
- }
8203
- if (options.verbose) {
8204
- output.tokens = tokens;
8205
- output.subject = mail?.subject ?? null;
8206
- }
8207
- return output;
8208
- }
8209
- function sessionFromQuery(query) {
8210
- const session = {};
8211
- if (query.get("ip")) {
8212
- session.remoteAddress = query.get("ip");
8213
- }
8214
- if (query.get("hostname")) {
8215
- session.resolvedClientHostname = query.get("hostname");
8216
- }
8217
- if (query.get("helo")) {
8218
- session.helo = query.get("helo");
8219
- }
8220
- const recipients = query.getAll("to").flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean);
8221
- if (query.has("from") || recipients.length > 0) {
8222
- session.envelope = { mailFrom: { address: query.get("from") || "" }, rcptTo: recipients.map((address2) => ({ address: address2 })) };
8223
- }
8224
- return session;
8225
- }
8226
- function sameToken(given, expected) {
8227
- const a = import_node_buffer11.Buffer.from(String(given));
8228
- const b = import_node_buffer11.Buffer.from(String(expected));
8229
- return a.length === b.length && (0, import_node_crypto5.timingSafeEqual)(a, b);
8230
- }
8231
- function readBody(request, limit) {
8232
- return new Promise((resolve, reject) => {
8233
- const chunks = [];
8234
- let size = 0;
8235
- request.on("data", (chunk2) => {
8236
- size += chunk2.length;
8237
- if (size <= limit) {
8238
- chunks.push(chunk2);
8239
- }
8240
- });
8241
- request.on("end", () => {
8242
- if (size > limit) {
8243
- const error = new Error(`Message larger than ${limit} bytes`);
8244
- error.statusCode = 413;
8245
- reject(error);
8246
- return;
8247
- }
8248
- resolve(import_node_buffer11.Buffer.concat(chunks));
8249
- });
8250
- request.on("error", reject);
8251
- });
8252
- }
8253
- function createHttpServer(scanner, options = {}) {
8254
- const { token = null, maxSize = 25 * 1024 * 1024, modelPath = null } = options;
8255
- return import_node_http2.default.createServer(async (request, response) => {
8256
- const send = (status, body, headers = {}) => {
8257
- const isBuffer = import_node_buffer11.Buffer.isBuffer(body);
8258
- response.writeHead(status, { "content-type": isBuffer ? "message/rfc822" : "application/json; charset=utf-8", "cache-control": "no-store", ...headers });
8259
- response.end(isBuffer ? body : `${JSON.stringify(body)}
8260
- `);
8261
- };
8262
- try {
8263
- const url = new URL(request.url, "http://localhost");
8264
- if (request.method === "GET" && url.pathname === "/health") {
8265
- send(200, { ok: true, version: VERSION });
8266
- return;
8267
- }
8268
- if (token && !sameToken((request.headers.authorization || "").replace(/^bearer\s+/i, ""), token)) {
8269
- send(401, { error: "Unauthorized" }, { "www-authenticate": "Bearer" });
8270
- return;
8271
- }
8272
- if (request.method !== "POST") {
8273
- send(405, { error: "Method not allowed" }, { allow: "POST" });
8274
- return;
8275
- }
8276
- const learn = /^\/learn\/(spam|ham)$/.exec(url.pathname);
8277
- if (!["/scan", "/check"].includes(url.pathname) && !learn) {
8278
- send(404, { error: "Not found" });
8279
- return;
8280
- }
8281
- if (learn && !token) {
8282
- send(403, { error: "Learning needs the server to be started with a token" });
8283
- return;
8284
- }
8285
- const raw = await readBody(request, maxSize);
8286
- if (learn) {
8287
- await scanner.learn(raw, learn[1]);
8288
- if (modelPath) {
8289
- scanner.saveModel(modelPath);
8290
- }
8291
- send(200, { ok: true, learned: learn[1] });
8292
- return;
8293
- }
8294
- const result = await scanner.scan(raw, { session: sessionFromQuery(url.searchParams) });
8295
- if (url.pathname === "/scan") {
8296
- send(200, serializeResult(result, { verbose: url.searchParams.get("verbose") === "1" }));
8297
- return;
8298
- }
8299
- const tag = url.searchParams.get("subjectTag");
8300
- const rewritten = rewriteMessage(raw, spamHeaders(result, { version: VERSION }), { subjectTag: result.isSpam && tag ? tag : null });
8301
- send(200, rewritten, { "x-spam-flag": result.isSpam ? "YES" : "NO", "x-spam-score": result.score.toFixed(1), "x-spam-action": result.action });
8302
- } catch (error) {
8303
- send(error.statusCode || 500, { error: error.message });
8304
- }
8305
- });
8306
- }
8307
- function createTcpServer(scanner, options = {}) {
8308
- const { json = true, maxSize = 25 * 1024 * 1024, timeout = 6e4 } = options;
8309
- return import_node_net5.default.createServer({ allowHalfOpen: true }, (socket) => {
8310
- const chunks = [];
8311
- let size = 0;
8312
- socket.setTimeout(timeout, () => socket.destroy());
8313
- const onEnd = async () => {
8314
- socket.setTimeout(0);
8315
- try {
8316
- const result = await scanner.scan(import_node_buffer11.Buffer.concat(chunks));
8317
- socket.end(json ? `${JSON.stringify(serializeResult(result))}
8318
- ` : `${result.isSpam ? "SPAM" : "HAM"} ${result.score.toFixed(1)}/${result.threshold.toFixed(1)} ${result.tests.map((test) => test.name).join(",")}
8319
- `);
8320
- } catch (error) {
8321
- socket.end(json ? `${JSON.stringify({ error: error.message })}
8322
- ` : `ERROR ${error.message}
8323
- `);
8324
- }
8325
- };
8326
- const onData = (chunk2) => {
8327
- size += chunk2.length;
8328
- if (size > maxSize) {
8329
- socket.off("data", onData);
8330
- socket.off("end", onEnd);
8331
- socket.end(`${JSON.stringify({ error: `Message larger than ${maxSize} bytes` })}
8332
- `, () => socket.destroy());
8333
- return;
8334
- }
8335
- chunks.push(chunk2);
8336
- };
8337
- socket.on("data", onData);
8338
- socket.on("end", onEnd);
8339
- socket.on("error", () => {
8340
- });
8341
- });
8342
- }
8343
- var import_node_buffer11, import_node_crypto5, import_node_http2, import_node_net5;
8344
- var init_server = __esm({
8345
- "src/server.js"() {
8346
- import_node_buffer11 = require("node:buffer");
8347
- import_node_crypto5 = require("node:crypto");
8348
- import_node_http2 = __toESM(require("node:http"), 1);
8349
- import_node_net5 = __toESM(require("node:net"), 1);
8350
- init_headers();
8351
- init_version();
8352
- }
8353
- });
8354
-
8355
8799
  // src/spamd.js
8356
8800
  function parseRequest(head) {
8357
8801
  const [line, ...rest] = head.split("\r\n");
@@ -8438,14 +8882,15 @@ async function answer(scanner, request, message, options) {
8438
8882
  }
8439
8883
  function createSpamdServer(scanner, options = {}) {
8440
8884
  const settings = {
8441
- maxSize: 25 * 1024 * 1024,
8885
+ maxSize: MAX_MESSAGE_SIZE,
8442
8886
  allowTell: false,
8443
8887
  modelPath: null,
8444
8888
  subjectTag: null,
8445
8889
  timeout: 6e4,
8890
+ maxConnections: MAX_CONNECTIONS,
8446
8891
  ...options
8447
8892
  };
8448
- return import_node_net6.default.createServer({ allowHalfOpen: true }, (socket) => {
8893
+ const server = import_node_net6.default.createServer({ allowHalfOpen: true }, (socket) => {
8449
8894
  let head = import_node_buffer12.Buffer.alloc(0);
8450
8895
  const chunks = [];
8451
8896
  let size = 0;
@@ -8527,6 +8972,8 @@ function createSpamdServer(scanner, options = {}) {
8527
8972
  socket.on("error", () => {
8528
8973
  });
8529
8974
  });
8975
+ server.maxConnections = settings.maxConnections;
8976
+ return server;
8530
8977
  }
8531
8978
  var import_node_buffer12, import_node_net6, EX_OK, EX_USAGE, EX_DATAERR, EX_SOFTWARE, EX_PROTOCOL, EX_NOPERM, COMMANDS2;
8532
8979
  var init_spamd = __esm({
@@ -8534,6 +8981,7 @@ var init_spamd = __esm({
8534
8981
  import_node_buffer12 = require("node:buffer");
8535
8982
  import_node_net6 = __toESM(require("node:net"), 1);
8536
8983
  init_headers();
8984
+ init_server();
8537
8985
  init_version();
8538
8986
  EX_OK = 0;
8539
8987
  EX_USAGE = 64;
@@ -9190,6 +9638,7 @@ var init_src = __esm({
9190
9638
  const settings = config.clamav === true ? {} : config.clamav;
9191
9639
  const budget = settings.timeout ?? CLAMAV_TIMEOUT;
9192
9640
  const deadline = Date.now() + budget;
9641
+ let expired = false;
9193
9642
  const viruses = [];
9194
9643
  const errors = [];
9195
9644
  const skipped = [];
@@ -9209,7 +9658,7 @@ var init_src = __esm({
9209
9658
  while (queue.length > 0) {
9210
9659
  const attachment = queue.shift();
9211
9660
  const filename = attachment.filename || "unnamed attachment";
9212
- if (deadline <= Date.now()) {
9661
+ if (expired || deadline <= Date.now()) {
9213
9662
  errors.push({ filename, error: `not scanned within ${budget} ms` });
9214
9663
  continue;
9215
9664
  }
@@ -9229,6 +9678,7 @@ var init_src = __esm({
9229
9678
  }
9230
9679
  } catch (error) {
9231
9680
  debug2("virus scan failed: %s", error.message);
9681
+ expired ||= Boolean(error.timedOut);
9232
9682
  if (CLAMAV_LIMITS.test(error.message)) {
9233
9683
  skipped.push({ filename, reason: error.message.replace(/^clamd: (?:stream: )?/, "") });
9234
9684
  } else {