spamscanner 7.0.4 → 7.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/esm/index.js CHANGED
@@ -3258,17 +3258,22 @@ function zipEntries(buffer, limit = 2e3) {
3258
3258
  records.push(i);
3259
3259
  }
3260
3260
  }
3261
- const entries = [];
3262
- const read = /* @__PURE__ */ new Set();
3261
+ const state = {
3262
+ entries: [],
3263
+ limit,
3264
+ read: /* @__PURE__ */ new Set(),
3265
+ names: MAX_NAME_BYTES
3266
+ };
3263
3267
  for (const eocd of records) {
3264
3268
  const offset = directoryOf(buffer, eocd);
3265
- if (offset !== -1 && !read.has(offset)) {
3266
- read.add(offset);
3267
- readDirectory(buffer, offset, limit - entries.length, entries);
3269
+ if (offset !== -1) {
3270
+ readDirectory(buffer, offset, state);
3268
3271
  }
3269
3272
  }
3270
- return entries;
3273
+ return state.entries;
3271
3274
  }
3275
+ var MAX_NAME_LENGTH = 1024;
3276
+ var MAX_NAME_BYTES = 1024 * 1024;
3272
3277
  var DIRECTORY = 33639248;
3273
3278
  var isDirectory = (buffer, offset) => offset >= 0 && offset + 46 <= buffer.length && buffer.readUInt32LE(offset) === DIRECTORY;
3274
3279
  function directoryOf(buffer, eocd) {
@@ -3295,18 +3300,32 @@ function directoryOf(buffer, eocd) {
3295
3300
  }
3296
3301
  return -1;
3297
3302
  }
3298
- function readDirectory(buffer, offset, count, entries) {
3299
- for (let n = 0; n < count && isDirectory(buffer, offset); n++) {
3303
+ function readDirectory(buffer, offset, state) {
3304
+ while (state.entries.length < state.limit && isDirectory(buffer, offset) && !state.read.has(offset)) {
3305
+ state.read.add(offset);
3300
3306
  const flags = buffer.readUInt16LE(offset + 8);
3301
3307
  const size = buffer.readUInt32LE(offset + 24);
3302
3308
  const nameLength = buffer.readUInt16LE(offset + 28);
3303
3309
  const extraLength = buffer.readUInt16LE(offset + 30);
3304
3310
  const commentLength = buffer.readUInt16LE(offset + 32);
3305
- const name = buffer.subarray(offset + 46, offset + 46 + nameLength).toString(flags & 2048 ? "utf8" : "latin1");
3306
- entries.push({ name, encrypted: (flags & 1) === 1, size });
3311
+ const encoding = flags & 2048 ? "utf8" : "latin1";
3312
+ const start = offset + 46;
3313
+ const end = Math.min(buffer.length, start + nameLength);
3314
+ const kept = Math.min(end - start, MAX_NAME_LENGTH);
3315
+ state.names -= kept;
3316
+ if (state.names < 0) {
3317
+ return;
3318
+ }
3319
+ const half = MAX_NAME_LENGTH / 2;
3320
+ 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);
3321
+ state.entries.push({ name, encrypted: (flags & 1) === 1, size });
3307
3322
  offset += 46 + nameLength + extraLength + commentLength;
3308
3323
  }
3309
3324
  }
3325
+ function shorten(name, max = 100) {
3326
+ const characters = [...name];
3327
+ return characters.length <= max ? name : `${characters.slice(0, max - 41).join("")}\u2026${characters.slice(-40).join("")}`;
3328
+ }
3310
3329
  function extensionOf(name) {
3311
3330
  const base = trimEndOf(String(name).toLowerCase().split(/[/\\]/).pop(), ". ");
3312
3331
  const dot = base.lastIndexOf(".");
@@ -3369,7 +3388,7 @@ function inspectAttachments(attachments = []) {
3369
3388
  }
3370
3389
  const inner = entries.filter((entry) => EXECUTABLE_EXTENSIONS.has(extensionOf(entry.name)) && !(office && OFFICE_EMBEDDED.has(extensionOf(entry.name))));
3371
3390
  const details = { extension: extensionOf(inner[0]?.name ?? ""), extensions: [...new Set(inner.map((entry) => extensionOf(entry.name)))] };
3372
- const listed = inner.slice(0, 3).map((entry) => entry.name.split("/").pop()).join(", ");
3391
+ const listed = inner.slice(0, 3).map((entry) => shorten(entry.name.split("/").pop())).join(", ");
3373
3392
  if (inner.some((entry) => !SCRIPT_EXTENSIONS.has(extensionOf(entry.name)))) {
3374
3393
  add("executable_in_archive", `Archive "${name}" contains ${inner.length === 1 ? "a program" : `${inner.length} programs`} (${listed})`, details);
3375
3394
  } else if (inner.length > 0) {
@@ -3399,10 +3418,486 @@ function inspectAttachments(attachments = []) {
3399
3418
  }
3400
3419
 
3401
3420
  // src/auth.js
3402
- import { Buffer as Buffer3, File } from "node:buffer";
3421
+ import { Buffer as Buffer4, File } from "node:buffer";
3403
3422
  import dns2 from "node:dns";
3404
3423
  import { debuglog } from "node:util";
3405
3424
 
3425
+ // src/parse.js
3426
+ import { Buffer as Buffer2 } from "node:buffer";
3427
+ import { createHash } from "node:crypto";
3428
+ import { Readable } from "node:stream";
3429
+ import { MailParser, simpleParser } from "mailparser";
3430
+ var ADDRESS_FIELDS = /* @__PURE__ */ new Set(["from", "to", "cc", "bcc", "sender", "reply-to", "delivered-to", "return-path", "disposition-notification-to"]);
3431
+ var MAX_ADDRESS_FIELD = 16 * 1024;
3432
+ var MAX_ADDRESS_SEPARATORS = 2e3;
3433
+ var MAX_ADDRESS_GROUPS = 16;
3434
+ var MAX_ADDRESS_TOTAL = 64 * 1024;
3435
+ var AUTH_ADDRESS_FIELDS = /* @__PURE__ */ new Set(["from", "return-path"]);
3436
+ var ADDRESS_OPERATORS = /* @__PURE__ */ new Map([['"', '"'], ["(", ")"], ["<", ">"], [",", ""], [":", ";"], [";", ""]]);
3437
+ 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"]);
3438
+ var PRIORITY_FIELDS = /* @__PURE__ */ new Set(["priority", "x-priority", "x-msmail-priority", "importance"]);
3439
+ var BOUNDED2 = /* @__PURE__ */ Symbol("bounded address fields");
3440
+ var ROOM = /* @__PURE__ */ Symbol("room left for address fields");
3441
+ var PARSER_OPTIONS = {
3442
+ skipHtmlToText: true,
3443
+ skipImageLinks: true,
3444
+ skipTextToHtml: true,
3445
+ skipTextLinks: true,
3446
+ [BOUNDED2]: true
3447
+ };
3448
+ function boundAddressField(line, room) {
3449
+ const start = line.indexOf(":") + 1;
3450
+ const limit = Math.min(line.length, MAX_ADDRESS_FIELD, room);
3451
+ let end = start;
3452
+ let expecting = "";
3453
+ let escaped = false;
3454
+ let separators = 0;
3455
+ let groups = 0;
3456
+ let index = start;
3457
+ for (; index < limit; index++) {
3458
+ const chr = line[index];
3459
+ if ((chr === "," || chr === ";") && ++separators > MAX_ADDRESS_SEPARATORS) {
3460
+ break;
3461
+ }
3462
+ if (escaped) {
3463
+ escaped = false;
3464
+ } else if (expecting !== "" && chr === expecting) {
3465
+ expecting = "";
3466
+ end = chr === ";" ? index + 1 : end;
3467
+ } else if (expecting === "" && ADDRESS_OPERATORS.has(chr)) {
3468
+ expecting = ADDRESS_OPERATORS.get(chr);
3469
+ end = chr === "," || chr === ";" ? index : end;
3470
+ } else if (expecting === '"' && chr === "\\") {
3471
+ escaped = true;
3472
+ }
3473
+ if (chr === ":" && expecting === ";" && ++groups > MAX_ADDRESS_GROUPS) {
3474
+ break;
3475
+ }
3476
+ }
3477
+ return index === line.length ? line : line.slice(0, end).trimEnd();
3478
+ }
3479
+ function boundAddressLines(lines2, budget, only = null) {
3480
+ const cut = /* @__PURE__ */ new Set();
3481
+ const bounded = lines2.map((entry) => {
3482
+ if (only ? !only.has(entry.key) : !ADDRESS_FIELDS.has(entry.key) && !entry.key.startsWith("list-")) {
3483
+ return entry;
3484
+ }
3485
+ const line = boundAddressField(entry.line, budget.room);
3486
+ budget.room = Math.max(budget.room - line.length, 0);
3487
+ if (line === entry.line) {
3488
+ return entry;
3489
+ }
3490
+ cut.add(entry.key);
3491
+ return { ...entry, line };
3492
+ });
3493
+ return { lines: bounded, cut };
3494
+ }
3495
+ var { processHeaders } = MailParser.prototype;
3496
+ MailParser.prototype.processHeaders = function(lines2) {
3497
+ if (!this.options[BOUNDED2]) {
3498
+ return processHeaders.call(this, lines2);
3499
+ }
3500
+ this[ROOM] ??= { room: MAX_ADDRESS_TOTAL };
3501
+ const bounded = boundAddressLines(lines2, this[ROOM]);
3502
+ const headers = processHeaders.call(this, bounded.lines);
3503
+ const keyOf = (entry) => PRIORITY_FIELDS.has(entry.key) ? "priority" : entry.key;
3504
+ const counts = /* @__PURE__ */ new Map();
3505
+ for (const key of bounded.lines.map((entry) => keyOf(entry)).filter((key2) => SINGLE_FIELDS.has(key2))) {
3506
+ counts.set(key, (counts.get(key) || 0) + 1);
3507
+ }
3508
+ for (const key of [...counts.keys()].filter((key2) => counts.get(key2) > 1)) {
3509
+ const last = bounded.lines.findLast((entry) => keyOf(entry) === key && processHeaders.call(this, [entry]).has(key));
3510
+ if (last) {
3511
+ headers.set(key, processHeaders.call(this, [last]).get(key));
3512
+ }
3513
+ }
3514
+ for (const value of [...bounded.cut].flatMap((key) => headers.get(key) ?? [])) {
3515
+ value.truncated = true;
3516
+ }
3517
+ return headers;
3518
+ };
3519
+ function boundedForAuth(raw) {
3520
+ const ends = [raw.indexOf("\n\n"), raw.indexOf("\n\r\n")].filter((index) => index !== -1);
3521
+ const end = ends.length > 0 ? Math.min(...ends) + 1 : raw.length;
3522
+ const body = raw.subarray(ends.length > 0 ? raw.indexOf(10, end) + 1 : raw.length);
3523
+ const text = raw.subarray(0, end).toString("latin1");
3524
+ let { length } = text;
3525
+ while (length > 0 && (text[length - 1] === "\n" || text[length - 1] === "\r")) {
3526
+ length--;
3527
+ }
3528
+ const fields = [];
3529
+ let folds = 0;
3530
+ let work = 0;
3531
+ for (let start = 0; start <= length; ) {
3532
+ let stop = text.indexOf("\n", start);
3533
+ stop = stop === -1 || stop > length ? length : stop;
3534
+ const lineEnd = stop > start && text[stop - 1] === "\r" ? stop - 1 : stop;
3535
+ const last = fields.at(-1);
3536
+ if (last && /\s/.test(text[start])) {
3537
+ last.end = lineEnd;
3538
+ last.folds++;
3539
+ folds++;
3540
+ work += last.folds;
3541
+ } else {
3542
+ fields.push({ start, end: lineEnd, folds: 0 });
3543
+ }
3544
+ start = stop + 1;
3545
+ }
3546
+ let signatures = 0;
3547
+ let dkim = 0;
3548
+ for (const field of fields) {
3549
+ field.line = text.slice(field.start, field.end);
3550
+ const colon = field.line.indexOf(":");
3551
+ field.key = (colon === -1 ? field.line : field.line.slice(0, colon)).trim().toLowerCase();
3552
+ field.dropped = field.key === "dkim-signature" && ++dkim > MAX_DKIM_SIGNATURES;
3553
+ if (SIGNATURE_FIELDS.has(field.key) && !field.dropped) {
3554
+ signatures += field.line.split(":").length - 1;
3555
+ }
3556
+ }
3557
+ const read = dkim > MAX_DKIM_SIGNATURES ? fields.filter((field) => !field.dropped) : fields;
3558
+ work += (folds + signatures) * read.length;
3559
+ const bounded = boundAddressLines(read, { room: MAX_ADDRESS_TOTAL }, AUTH_ADDRESS_FIELDS);
3560
+ let kept = bounded.lines;
3561
+ let fromCut = bounded.cut.has("from");
3562
+ if (work > MAX_AUTH_WORK) {
3563
+ const counts = /* @__PURE__ */ new Map();
3564
+ kept = kept.filter((field) => {
3565
+ counts.set(field.key, (counts.get(field.key) || 0) + 1);
3566
+ const keep = AUTH_FIELDS.has(field.key) && counts.get(field.key) <= (SIGNATURE_FIELDS.has(field.key) ? MAX_SIGNATURES : MAX_REPEATED);
3567
+ fromCut ||= field.key === "from" && !keep;
3568
+ return keep;
3569
+ }).map((field) => {
3570
+ const line = field.line.replaceAll(/\r?\n(?=\s)/g, "");
3571
+ return { ...field, line: ADDRESS_FIELDS.has(field.key) ? line : line.slice(0, MAX_FIELD) };
3572
+ });
3573
+ } else if (bounded.cut.size === 0 && read === fields) {
3574
+ return { raw, fromCut };
3575
+ }
3576
+ const header = kept.map((field) => field.line).join("\r\n");
3577
+ return { raw: Buffer2.concat([Buffer2.from(`${header}\r
3578
+ \r
3579
+ `, "latin1"), body]), fromCut };
3580
+ }
3581
+ var TOO_MANY_PARTS = "Max allowed child nodes exceeded";
3582
+ var HEADER_TOO_LARGE = "Max header size for a MIME node exceeded";
3583
+ var MAX_PART_LINES = 990;
3584
+ var MAX_PREFIX_BYTES = 1e6;
3585
+ var MAX_LINE = 998;
3586
+ var MAX_FIELD = 8192;
3587
+ var MAX_HEADER = 512 * 1024;
3588
+ var MAX_REPEATED = 100;
3589
+ var KEPT_FIELDS = /* @__PURE__ */ new Set([
3590
+ "from",
3591
+ "sender",
3592
+ "reply-to",
3593
+ "to",
3594
+ "cc",
3595
+ "subject",
3596
+ "date",
3597
+ "message-id",
3598
+ "return-path",
3599
+ "received",
3600
+ "authentication-results",
3601
+ "arc-authentication-results",
3602
+ "dkim-signature",
3603
+ "mime-version",
3604
+ "content-type",
3605
+ "content-transfer-encoding",
3606
+ "list-unsubscribe",
3607
+ "list-id"
3608
+ ]);
3609
+ var MAX_AUTH_WORK = 1e7;
3610
+ var SIGNATURE_FIELDS = /* @__PURE__ */ new Set(["dkim-signature", "arc-message-signature"]);
3611
+ var AUTH_FIELDS = /* @__PURE__ */ new Set([...KEPT_FIELDS, "arc-seal", "arc-message-signature"]);
3612
+ var MAX_SIGNATURES = 10;
3613
+ var MAX_DKIM_SIGNATURES = 8;
3614
+ var MAX_NESTED_DEPTH = 3;
3615
+ var MAX_ATTACHMENTS = 100;
3616
+ var MAX_NESTED_BYTES = 25 * 1024 * 1024;
3617
+ var PARSE_TIMEOUT = 6e4;
3618
+ async function simpleParse(raw, { timeout = PARSE_TIMEOUT, parser = simpleParser } = {}) {
3619
+ const input = Readable.from([finishable(unfoldHeaders(raw))], { objectMode: false, autoDestroy: false });
3620
+ input.on("error", () => {
3621
+ });
3622
+ const { pipe } = input;
3623
+ input.pipe = function(parser2, options) {
3624
+ parser2.splitter?.on("error", (error) => input.destroy(error));
3625
+ return pipe.call(this, parser2, options);
3626
+ };
3627
+ let timer;
3628
+ try {
3629
+ return await Promise.race([
3630
+ parser(input, PARSER_OPTIONS),
3631
+ new Promise((resolve, reject) => {
3632
+ timer = setTimeout(() => {
3633
+ const error = Object.assign(new Error(`Message not parsed within ${timeout} ms`), { code: "ETIMEDOUT" });
3634
+ input.destroy(error);
3635
+ reject(error);
3636
+ }, timeout);
3637
+ })
3638
+ ]);
3639
+ } finally {
3640
+ clearTimeout(timer);
3641
+ input.destroy();
3642
+ }
3643
+ }
3644
+ async function parseMessage(raw, options = {}) {
3645
+ const buffer = Buffer2.isBuffer(raw) ? raw : Buffer2.from(raw);
3646
+ const mail = await simpleParse(buffer, options);
3647
+ const added = buffer.at(-1) === 10 ? 2 : 3;
3648
+ for (const key of ["text", "html"]) {
3649
+ if (typeof mail[key] === "string") {
3650
+ mail[key] = mail[key].replace(new RegExp(`\\n{0,${added}}$`), "");
3651
+ }
3652
+ }
3653
+ const tail = Buffer2.from("\r\n".repeat(added));
3654
+ const last = mail.attachments?.at(-1);
3655
+ const encoding = String(last?.headers?.get?.("content-transfer-encoding") ?? "7bit").trim().toLowerCase();
3656
+ const own = Buffer2.isBuffer(last?.content) && last.content.length >= tail.length && last.content.subarray(-tail.length).equals(tail) ? last.content.subarray(0, -tail.length) : null;
3657
+ if (own && ["7bit", "8bit", "binary"].includes(encoding) && own.length <= buffer.length && buffer.subarray(buffer.length - own.length).equals(own)) {
3658
+ last.content = own;
3659
+ last.size = own.length;
3660
+ last.checksum = createHash("md5").update(own).digest("hex");
3661
+ }
3662
+ return mail;
3663
+ }
3664
+ function finishable(raw) {
3665
+ const buffer = Buffer2.isBuffer(raw) ? raw : Buffer2.from(raw);
3666
+ return Buffer2.concat([buffer, Buffer2.from(buffer.at(-1) === 10 ? "\r\n\r\n" : "\r\n\r\n\r\n")]);
3667
+ }
3668
+ var MAX_FOLDS = 256;
3669
+ function headerValue2(line) {
3670
+ const parameters = /* @__PURE__ */ new Map();
3671
+ let key = null;
3672
+ let inKey = false;
3673
+ let quoted = false;
3674
+ let escaped = false;
3675
+ let value = "";
3676
+ for (const chr of line.slice(line.indexOf(":") + 1).replaceAll(/\r?\n[ \t]*/g, " ")) {
3677
+ if (inKey && chr === "=") {
3678
+ key = value.trim().toLowerCase();
3679
+ inKey = false;
3680
+ value = "";
3681
+ } else if (inKey || escaped) {
3682
+ value += chr;
3683
+ escaped = false;
3684
+ } else if (chr === "\\") {
3685
+ escaped = true;
3686
+ } else if (chr === '"') {
3687
+ quoted = !quoted;
3688
+ } else if (chr === ";" && !quoted) {
3689
+ parameters.set(key, value.trim());
3690
+ inKey = true;
3691
+ value = "";
3692
+ } else {
3693
+ value += chr;
3694
+ }
3695
+ }
3696
+ if (!inKey) {
3697
+ parameters.set(key, value.trim());
3698
+ }
3699
+ return { value: parameters.get(null).toLowerCase(), boundary: parameters.get("boundary") ?? "" };
3700
+ }
3701
+ function delimiter(raw, start, stop, boundary) {
3702
+ const rest = start + 2 + boundary.length;
3703
+ if (stop - start < boundary.length + 2 || stop - rest > 4 || raw[start] !== 45 || raw[start + 1] !== 45 || !raw.subarray(start + 2, rest).equals(boundary)) {
3704
+ return 0;
3705
+ }
3706
+ if (raw[rest] === 13 || raw[rest] === 10) {
3707
+ return 1;
3708
+ }
3709
+ const tail = raw.subarray(rest, stop).toString("latin1");
3710
+ return /^-{0,2}$|^--\r?\n$|^--\r$/.test(tail) ? 2 : 0;
3711
+ }
3712
+ function unfoldHeaders(raw) {
3713
+ const boundaries = [];
3714
+ const unfold = [];
3715
+ let head = true;
3716
+ let fields = [];
3717
+ const endHead = (blank) => {
3718
+ unfold.push(...fields.filter((field) => field.folds > MAX_FOLDS));
3719
+ const first = /* @__PURE__ */ new Map();
3720
+ for (const field of fields) {
3721
+ const name = raw.toString("latin1", field.start, Math.min(field.end, field.start + 32));
3722
+ const key = name.slice(0, name.indexOf(":")).trim().toLowerCase();
3723
+ if (["content-type", "content-transfer-encoding", "content-disposition"].includes(key) && !first.has(key)) {
3724
+ first.set(key, headerValue2(raw.toString("latin1", field.start, field.end)));
3725
+ }
3726
+ }
3727
+ fields = [];
3728
+ const type = first.get("content-type") ?? { value: "", boundary: "" };
3729
+ const encoding = (first.get("content-transfer-encoding")?.value ?? "").replaceAll(/\(.*\)/g, "").trim();
3730
+ if (blank && type.boundary) {
3731
+ boundaries.push(Buffer2.from(type.boundary));
3732
+ }
3733
+ head = blank && type.value === "message/rfc822" && ["", "7bit", "8bit", "binary"].includes(encoding) && first.get("content-disposition")?.value === "inline";
3734
+ };
3735
+ for (let start = 0; start < raw.length; ) {
3736
+ const lf = raw.indexOf(10, start);
3737
+ const stop = lf === -1 ? raw.length : lf + 1;
3738
+ const end = lf === -1 ? stop : raw[lf - 1] === 13 ? lf - 1 : lf;
3739
+ const kind = boundaries.length > 0 ? delimiter(raw, start, stop, boundaries.at(-1)) : 0;
3740
+ if (kind !== 0) {
3741
+ if (head) {
3742
+ endHead(false);
3743
+ }
3744
+ head = kind === 1;
3745
+ if (kind === 2) {
3746
+ boundaries.pop();
3747
+ }
3748
+ } else if (head && end === start) {
3749
+ endHead(true);
3750
+ } else if (head && fields.length > 0 && (raw[start] === 32 || raw[start] === 9)) {
3751
+ fields.at(-1).end = end;
3752
+ fields.at(-1).folds++;
3753
+ } else if (head) {
3754
+ fields.push({ start, end, folds: 0 });
3755
+ }
3756
+ start = stop;
3757
+ }
3758
+ unfold.push(...fields.filter((field) => field.folds > MAX_FOLDS));
3759
+ if (unfold.length === 0) {
3760
+ return raw;
3761
+ }
3762
+ const chunks = [];
3763
+ let copied = 0;
3764
+ for (const field of unfold) {
3765
+ chunks.push(raw.subarray(copied, field.start), Buffer2.from(raw.toString("latin1", field.start, field.end).replaceAll(/\r?\n[ \t]*/g, " "), "latin1"));
3766
+ copied = field.end;
3767
+ }
3768
+ chunks.push(raw.subarray(copied));
3769
+ return Buffer2.concat(chunks);
3770
+ }
3771
+ function upToLine(raw, offset) {
3772
+ return raw.subarray(0, Math.max(raw.lastIndexOf(10, offset - 1) + 1, 0));
3773
+ }
3774
+ function fewerParts(raw) {
3775
+ let offset = -1;
3776
+ for (let count = 0; count < MAX_PART_LINES; count++) {
3777
+ offset = raw.indexOf("\n--", offset + 1);
3778
+ if (offset === -1) {
3779
+ return upToLine(raw, Math.floor(raw.length / 2));
3780
+ }
3781
+ }
3782
+ return raw.subarray(0, offset + 1);
3783
+ }
3784
+ function smallerHeader(raw) {
3785
+ const text = raw.toString("latin1");
3786
+ const blank = /\r?\n\r?\n/.exec(text);
3787
+ const end = blank ? blank.index : text.length;
3788
+ const fields = [];
3789
+ let start = 0;
3790
+ while (start < end) {
3791
+ let stop = text.indexOf("\n", start);
3792
+ stop = stop === -1 || stop > end ? end : stop;
3793
+ const last = fields.at(-1);
3794
+ const whole = text.slice(start, stop).replace(/\r$/, "");
3795
+ if (last && (whole.startsWith(" ") || whole.startsWith(" "))) {
3796
+ const line = last.address ? whole : whole.slice(0, MAX_LINE);
3797
+ if (last.address || last.size + line.length <= MAX_FIELD) {
3798
+ last.lines.push(line);
3799
+ last.size += line.length;
3800
+ }
3801
+ } else {
3802
+ const name = whole.slice(0, whole.indexOf(":")).trim().toLowerCase();
3803
+ const address2 = ADDRESS_FIELDS.has(name) || name.startsWith("list-");
3804
+ const line = address2 ? whole : whole.slice(0, MAX_LINE);
3805
+ fields.push({
3806
+ name,
3807
+ address: address2,
3808
+ lines: [line],
3809
+ size: line.length
3810
+ });
3811
+ }
3812
+ start = stop + 1;
3813
+ }
3814
+ let fromCut = false;
3815
+ for (const field of fields.filter((field2) => field2.address)) {
3816
+ const line = field.lines.join("\r\n");
3817
+ const bounded = boundAddressField(line, Number.POSITIVE_INFINITY);
3818
+ fromCut ||= field.name === "from" && bounded !== line;
3819
+ field.lines = [bounded];
3820
+ field.size = bounded.length;
3821
+ }
3822
+ const total = fields.reduce((sum, field) => sum + field.size, 0);
3823
+ const counts = /* @__PURE__ */ new Map();
3824
+ const kept = total > MAX_HEADER ? fields.filter((field) => {
3825
+ counts.set(field.name, (counts.get(field.name) || 0) + 1);
3826
+ const keep = KEPT_FIELDS.has(field.name) && counts.get(field.name) <= MAX_REPEATED;
3827
+ fromCut ||= field.name === "from" && !keep;
3828
+ return keep;
3829
+ }) : fields;
3830
+ const header = kept.flatMap((field) => field.lines).join("\r\n");
3831
+ return { raw: Buffer2.concat([Buffer2.from(`${header}\r
3832
+ \r
3833
+ `, "latin1"), raw.subarray(blank ? blank.index + blank[0].length : raw.length)]), fromCut };
3834
+ }
3835
+ async function parseWithin(raw, parse = parseMessage, deadline = Date.now() + PARSE_TIMEOUT) {
3836
+ let input = raw;
3837
+ let reason = null;
3838
+ let fromCut = false;
3839
+ for (; ; ) {
3840
+ try {
3841
+ const mail = await parse(input, { timeout: Math.max(1, deadline - Date.now()) });
3842
+ if (fromCut && mail.from) {
3843
+ mail.from.truncated = true;
3844
+ }
3845
+ return { mail, error: reason };
3846
+ } catch (error) {
3847
+ const limit = error.code === "EMAXLEN" && [TOO_MANY_PARTS, HEADER_TOO_LARGE].includes(error.message) ? error.message : null;
3848
+ let shorter = input;
3849
+ if (limit === TOO_MANY_PARTS) {
3850
+ shorter = fewerParts(input);
3851
+ } else if (limit === HEADER_TOO_LARGE) {
3852
+ const smaller = smallerHeader(input);
3853
+ shorter = smaller.raw.length < input.length ? smaller.raw : upToLine(input, MAX_PREFIX_BYTES);
3854
+ fromCut ||= smaller.raw === shorter && smaller.fromCut;
3855
+ }
3856
+ if (shorter.length >= input.length) {
3857
+ throw error;
3858
+ }
3859
+ reason ??= limit;
3860
+ input = shorter;
3861
+ }
3862
+ }
3863
+ }
3864
+ async function expandAttachments(attachments = [], errors = [], parse = parseMessage) {
3865
+ const all = [];
3866
+ const deadline = Date.now() + PARSE_TIMEOUT;
3867
+ let budget = MAX_NESTED_BYTES;
3868
+ let nested = 0;
3869
+ const visit = async (list, prefix, depth) => {
3870
+ for (const attachment of list) {
3871
+ if (prefix) {
3872
+ if (nested >= MAX_ATTACHMENTS) {
3873
+ return;
3874
+ }
3875
+ nested++;
3876
+ }
3877
+ const name = attachment.filename || (prefix ? "unnamed attachment" : "");
3878
+ all.push(prefix ? { ...attachment, filename: `${prefix}/${name}` } : attachment);
3879
+ const { content } = attachment;
3880
+ const attached = attachment.contentType === "message/rfc822" || /\.eml$/i.test(attachment.filename || "");
3881
+ if (attached && depth < MAX_NESTED_DEPTH && Buffer2.isBuffer(content) && content.length <= budget) {
3882
+ budget -= content.length;
3883
+ let inner;
3884
+ try {
3885
+ inner = await parseWithin(content, parse, deadline);
3886
+ } catch (error) {
3887
+ errors.push(`${name || "attached message"}: ${error.message}`);
3888
+ continue;
3889
+ }
3890
+ if (inner.error) {
3891
+ errors.push(inner.error);
3892
+ }
3893
+ await visit(inner.mail.attachments, prefix ? `${prefix}/${name}` : name || "attached message", depth + 1);
3894
+ }
3895
+ }
3896
+ };
3897
+ await visit(attachments, "", 0);
3898
+ return all;
3899
+ }
3900
+
3406
3901
  // src/reputation.js
3407
3902
  import { isIP as isIP2 } from "node:net";
3408
3903
  import { domainToASCII } from "node:url";
@@ -3651,14 +4146,14 @@ var DnsChecker = class {
3651
4146
  };
3652
4147
 
3653
4148
  // src/http.js
3654
- import { Buffer as Buffer2 } from "node:buffer";
4149
+ import { Buffer as Buffer3 } from "node:buffer";
3655
4150
  import http from "node:http";
3656
4151
  import https from "node:https";
3657
4152
  function requestJson(method, url, body, options = {}) {
3658
4153
  const { headers = {}, timeout = 3e4, maxResponseBytes = 1048576, ca } = options;
3659
4154
  const target = new URL(url);
3660
4155
  const transport = target.protocol === "https:" ? https : http;
3661
- const payload = body === null || body === void 0 ? null : Buffer2.from(JSON.stringify(body));
4156
+ const payload = body === null || body === void 0 ? null : Buffer3.from(JSON.stringify(body));
3662
4157
  return new Promise((resolve, reject) => {
3663
4158
  let settled = false;
3664
4159
  const finish = (error, value) => {
@@ -3696,7 +4191,7 @@ function requestJson(method, url, body, options = {}) {
3696
4191
  });
3697
4192
  response.on("error", (error) => finish(error));
3698
4193
  response.on("end", () => {
3699
- const text = Buffer2.concat(chunks).toString("utf8");
4194
+ const text = Buffer3.concat(chunks).toString("utf8");
3700
4195
  if (response.statusCode < 200 || response.statusCode >= 300) {
3701
4196
  const error = new Error(`HTTP ${response.statusCode} from ${target.origin}${target.pathname}: ${text.slice(0, 300)}`);
3702
4197
  error.statusCode = response.statusCode;
@@ -3972,7 +4467,7 @@ function trustedReputationValues(mail = {}, session = {}, authentication = null)
3972
4467
  ...[spfAligned].filter((signer) => signer !== null && (alignment?.spf?.strict !== true || signer === domain))
3973
4468
  ])];
3974
4469
  const fromValues = [];
3975
- for (const { address: address2 } of mail.from?.value || []) {
4470
+ for (const { address: address2 } of mail.from?.truncated ? [] : mail.from?.value || []) {
3976
4471
  const domain = domainOf(address2);
3977
4472
  const values = reputationValues({ from: { value: [{ address: address2 }] } });
3978
4473
  if (domain !== null && dmarcDomain !== null && (domain === dmarcDomain || registrableDomain(domain) === dmarcDomain)) {
@@ -4035,6 +4530,37 @@ function createResolver(timeout = 1e4, servers) {
4035
4530
  }
4036
4531
  };
4037
4532
  }
4533
+ var MAX_DNS_QUERIES = 300;
4534
+ var MAX_AUTH_RESULTS_LENGTH = 8192;
4535
+ function dnsTimeout(message) {
4536
+ const error = new Error(message);
4537
+ error.code = "ETIMEOUT";
4538
+ return error;
4539
+ }
4540
+ function limitResolver(resolver, { maxQueries = MAX_DNS_QUERIES, deadline = Number.POSITIVE_INFINITY } = {}) {
4541
+ let count = 0;
4542
+ return async (...args) => {
4543
+ count++;
4544
+ if (count > maxQueries) {
4545
+ throw dnsTimeout(`More than ${maxQueries} DNS lookups for one message`);
4546
+ }
4547
+ const left = deadline - Date.now();
4548
+ if (left <= 0) {
4549
+ throw dnsTimeout("Out of time for this message's DNS lookups");
4550
+ }
4551
+ let timer;
4552
+ try {
4553
+ return await Promise.race([
4554
+ resolver(...args),
4555
+ new Promise((_resolve, reject) => {
4556
+ timer = setTimeout(() => reject(dnsTimeout("Out of time for this message's DNS lookups")), Math.min(left, 2147483647));
4557
+ })
4558
+ ]);
4559
+ } finally {
4560
+ clearTimeout(timer);
4561
+ }
4562
+ };
4563
+ }
4038
4564
  function emptyResult() {
4039
4565
  return {
4040
4566
  dkim: { status: { result: "none", comment: "not checked" }, results: [], aligned: null },
@@ -4174,9 +4700,14 @@ async function authenticate(message, options = {}) {
4174
4700
  }
4175
4701
  try {
4176
4702
  const { authenticate: run } = await getMailauth();
4177
- const resolver = options.resolver || createResolver(timeout, options.dnsServers);
4178
- const raw = Buffer3.isBuffer(message) ? message : Buffer3.from(String(message));
4179
- const output = await run(raw, {
4703
+ const resolver = limitResolver(options.resolver || createResolver(timeout, options.dnsServers), {
4704
+ maxQueries: options.maxDnsQueries ?? MAX_DNS_QUERIES,
4705
+ // (a little after `timeout`, so a caller that waits that long for the
4706
+ // result, as scan() does, has its answer first)
4707
+ deadline: Date.now() + timeout + 100
4708
+ });
4709
+ const bounded = boundedForAuth(Buffer4.isBuffer(message) ? message : Buffer4.from(String(message)));
4710
+ const output = await run(bounded.raw, {
4180
4711
  ip,
4181
4712
  helo: helo || void 0,
4182
4713
  sender: sender ?? void 0,
@@ -4184,6 +4715,10 @@ async function authenticate(message, options = {}) {
4184
4715
  resolver
4185
4716
  });
4186
4717
  Object.assign(result, normalizeAuthOutput(output, { trustedArcSealers: options.trustedArcSealers }));
4718
+ if (bounded.fromCut) {
4719
+ result.dmarc = { ...result.dmarc, status: { result: "fail", comment: "From field too long to read whole" } };
4720
+ result.headers = "";
4721
+ }
4187
4722
  } catch (error) {
4188
4723
  debug("authentication failed: %s", error.message);
4189
4724
  result.error = error.message;
@@ -4234,11 +4769,24 @@ function calculateAuthScore(auth, weights = {}) {
4234
4769
  }
4235
4770
  return { score: tests.reduce((sum, test) => sum + test.score, 0), tests };
4236
4771
  }
4772
+ function limitResults(value) {
4773
+ if (value.length <= MAX_AUTH_RESULTS_LENGTH) {
4774
+ return value;
4775
+ }
4776
+ const [server, ...results] = value.split(/;(?=\s*(?:dkim|spf|dmarc|arc|bimi)=)/i);
4777
+ let kept = server;
4778
+ for (const result of results) {
4779
+ if (kept.length + 1 + result.length <= MAX_AUTH_RESULTS_LENGTH) {
4780
+ kept += `;${result}`;
4781
+ }
4782
+ }
4783
+ return kept;
4784
+ }
4237
4785
  function formatAuthResultsHeader(auth, hostname) {
4238
4786
  if (typeof auth?.headers === "string") {
4239
4787
  const match = auth.headers.match(/^authentication-results:[ \t]*([\s\S]*?)(?=\r?\n\S|(?![\s\S]))/im);
4240
4788
  if (match) {
4241
- const value = match[1].replaceAll(/\r?\n/g, "\r\n").trim();
4789
+ const value = limitResults(match[1].replaceAll(/\r?\n/g, "\r\n").trim());
4242
4790
  return hostname ? value.replace(/^[^;\s]+/, hostname) : value;
4243
4791
  }
4244
4792
  }
@@ -4253,7 +4801,7 @@ function summarizeAuth(auth) {
4253
4801
  }
4254
4802
 
4255
4803
  // src/clamav.js
4256
- import { Buffer as Buffer4 } from "node:buffer";
4804
+ import { Buffer as Buffer5 } from "node:buffer";
4257
4805
  import { existsSync } from "node:fs";
4258
4806
  import net from "node:net";
4259
4807
  var 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"];
@@ -4280,10 +4828,10 @@ function exchange(address2, write, timeout) {
4280
4828
  resolve(value);
4281
4829
  }
4282
4830
  };
4283
- const timer = setTimeout(() => finish(new Error(`clamd did not answer within ${timeout} ms`)), timeout);
4831
+ const timer = setTimeout(() => finish(Object.assign(new Error(`clamd did not answer within ${timeout} ms`), { code: "ETIMEDOUT", timedOut: true })), timeout);
4284
4832
  socket.on("connect", () => write(socket));
4285
4833
  socket.on("data", (chunk2) => chunks.push(chunk2));
4286
- socket.on("end", () => finish(null, Buffer4.concat(chunks).toString("utf8").replaceAll("\0", "").trim()));
4834
+ socket.on("end", () => finish(null, Buffer5.concat(chunks).toString("utf8").replaceAll("\0", "").trim()));
4287
4835
  socket.on("error", (error) => finish(error));
4288
4836
  });
4289
4837
  }
@@ -4297,12 +4845,12 @@ async function scanBuffer(buffer, options = {}) {
4297
4845
  socket.write("zINSTREAM\0");
4298
4846
  for (let offset = 0; offset < buffer.length; offset += chunkSize) {
4299
4847
  const chunk2 = buffer.subarray(offset, offset + chunkSize);
4300
- const size = Buffer4.alloc(4);
4848
+ const size = Buffer5.alloc(4);
4301
4849
  size.writeUInt32BE(chunk2.length);
4302
4850
  socket.write(size);
4303
4851
  socket.write(chunk2);
4304
4852
  }
4305
- socket.end(Buffer4.alloc(4));
4853
+ socket.end(Buffer5.alloc(4));
4306
4854
  }, options.timeout ?? 3e4);
4307
4855
  if (/\bOK$/.test(reply2)) {
4308
4856
  return { infected: false, viruses: [], reply: reply2 };
@@ -4315,11 +4863,11 @@ async function scanBuffer(buffer, options = {}) {
4315
4863
  }
4316
4864
 
4317
4865
  // src/classifier.js
4318
- import { Buffer as Buffer5 } from "node:buffer";
4866
+ import { Buffer as Buffer6 } from "node:buffer";
4319
4867
  var MODEL_VERSION = 2;
4320
4868
  function hashFeature(value) {
4321
4869
  let hash = 2166136261;
4322
- const bytes = Buffer5.from(value, "utf8");
4870
+ const bytes = Buffer6.from(value, "utf8");
4323
4871
  for (const byte of bytes) {
4324
4872
  hash ^= byte;
4325
4873
  hash = Math.imul(hash, 16777619);
@@ -4645,7 +5193,7 @@ var Classifier = class _Classifier {
4645
5193
  spam[i] = s;
4646
5194
  ham[i] = h;
4647
5195
  }
4648
- const encode = (array) => Buffer5.from(array.buffer, array.byteOffset, array.byteLength).toString("base64");
5196
+ const encode = (array) => Buffer6.from(array.buffer, array.byteOffset, array.byteLength).toString("base64");
4649
5197
  return {
4650
5198
  type: "spamscanner-classifier",
4651
5199
  version: MODEL_VERSION,
@@ -4696,7 +5244,7 @@ var Classifier = class _Classifier {
4696
5244
  throw new TypeError(`Unsupported classifier model version ${data.version}; this release reads version ${MODEL_VERSION}`);
4697
5245
  }
4698
5246
  const decode = (value, name) => {
4699
- const buffer = typeof value === "string" ? Buffer5.from(value, "base64") : null;
5247
+ const buffer = typeof value === "string" ? Buffer6.from(value, "base64") : null;
4700
5248
  if (!buffer || buffer.byteLength % 4 !== 0) {
4701
5249
  throw corrupt(`${name} is not a list of 32-bit numbers`);
4702
5250
  }
@@ -5061,8 +5609,8 @@ ${withoutHelp(markup)}`) || attributeText(markup).some(({ text: text2, start, en
5061
5609
  }
5062
5610
 
5063
5611
  // src/llm.js
5064
- import { Buffer as Buffer6 } from "node:buffer";
5065
- import { createHash, randomBytes } from "node:crypto";
5612
+ import { Buffer as Buffer7 } from "node:buffer";
5613
+ import { createHash as createHash2, randomBytes } from "node:crypto";
5066
5614
  import os from "node:os";
5067
5615
  import process from "node:process";
5068
5616
  var PROVIDERS = {
@@ -5422,7 +5970,7 @@ function authHeaders(config) {
5422
5970
  case "basic": {
5423
5971
  const user = config.username ?? "";
5424
5972
  const pass = config.password ?? config.apiKey ?? "";
5425
- headers.authorization = `Basic ${Buffer6.from(`${user}:${pass}`).toString("base64")}`;
5973
+ headers.authorization = `Basic ${Buffer7.from(`${user}:${pass}`).toString("base64")}`;
5426
5974
  break;
5427
5975
  }
5428
5976
  case "x-api-key": {
@@ -5989,7 +6537,7 @@ var LLMClassifier = class {
5989
6537
  * @returns {Promise<{verdict: string, confidence: number, language: string|null, reasons: string[], provider: string, model: string, cached: boolean, time: number}>}
5990
6538
  */
5991
6539
  async classifyText(text) {
5992
- const key = createHash("sha256").update(text).digest("hex");
6540
+ const key = createHash2("sha256").update(text).digest("hex");
5993
6541
  if (this.cache.has(key)) {
5994
6542
  const hit = this.cache.get(key);
5995
6543
  this.cache.delete(key);
@@ -6051,7 +6599,11 @@ var LLMClassifier = class {
6051
6599
 
6052
6600
  // src/model.js
6053
6601
  import {
6602
+ closeSync,
6054
6603
  existsSync as existsSync2,
6604
+ fchmodSync,
6605
+ fsyncSync,
6606
+ openSync,
6055
6607
  readFileSync,
6056
6608
  writeFileSync,
6057
6609
  mkdirSync,
@@ -6127,9 +6679,19 @@ function saveModel(classifier, file, options = {}) {
6127
6679
  const resolved = path.resolve(file);
6128
6680
  mkdirSync(path.dirname(resolved), { recursive: true });
6129
6681
  const temporary = `${resolved}.${process2.pid}.${randomBytes2(6).toString("hex")}.tmp`;
6682
+ const mode = statSync(resolved, { throwIfNoEntry: false })?.mode;
6683
+ const fd = openSync(temporary, "wx");
6130
6684
  try {
6131
- writeFileSync(temporary, `${JSON.stringify(classifier.toJSON(options))}
6685
+ try {
6686
+ if (mode !== void 0) {
6687
+ fchmodSync(fd, mode % 512);
6688
+ }
6689
+ writeFileSync(fd, `${JSON.stringify(classifier.toJSON(options))}
6132
6690
  `);
6691
+ fsyncSync(fd);
6692
+ } finally {
6693
+ closeSync(fd);
6694
+ }
6133
6695
  renameSync(temporary, resolved);
6134
6696
  } catch (error) {
6135
6697
  rmSync(temporary, { force: true });
@@ -6138,175 +6700,6 @@ function saveModel(classifier, file, options = {}) {
6138
6700
  loaded.delete(resolved);
6139
6701
  }
6140
6702
 
6141
- // src/parse.js
6142
- import { Buffer as Buffer7 } from "node:buffer";
6143
- import { createHash as createHash2 } from "node:crypto";
6144
- import { simpleParser } from "mailparser";
6145
- var PARSER_OPTIONS = {
6146
- skipHtmlToText: true,
6147
- skipImageLinks: true,
6148
- skipTextToHtml: true,
6149
- skipTextLinks: true
6150
- };
6151
- var TOO_MANY_PARTS = "Max allowed child nodes exceeded";
6152
- var HEADER_TOO_LARGE = "Max header size for a MIME node exceeded";
6153
- var MAX_PART_LINES = 990;
6154
- var MAX_PREFIX_BYTES = 1e6;
6155
- var MAX_LINE = 998;
6156
- var MAX_FIELD = 8192;
6157
- var MAX_HEADER = 512 * 1024;
6158
- var MAX_REPEATED = 100;
6159
- var KEPT_FIELDS = /* @__PURE__ */ new Set([
6160
- "from",
6161
- "sender",
6162
- "reply-to",
6163
- "to",
6164
- "cc",
6165
- "subject",
6166
- "date",
6167
- "message-id",
6168
- "return-path",
6169
- "received",
6170
- "authentication-results",
6171
- "arc-authentication-results",
6172
- "dkim-signature",
6173
- "mime-version",
6174
- "content-type",
6175
- "content-transfer-encoding",
6176
- "list-unsubscribe",
6177
- "list-id"
6178
- ]);
6179
- var MAX_NESTED_DEPTH = 3;
6180
- var MAX_ATTACHMENTS = 100;
6181
- var MAX_NESTED_BYTES = 25 * 1024 * 1024;
6182
- async function parseMessage(raw) {
6183
- const buffer = Buffer7.isBuffer(raw) ? raw : Buffer7.from(raw);
6184
- const mail = await simpleParser(finishable(buffer), PARSER_OPTIONS);
6185
- const added = buffer.at(-1) === 10 ? 2 : 3;
6186
- for (const key of ["text", "html"]) {
6187
- if (typeof mail[key] === "string") {
6188
- mail[key] = mail[key].replace(new RegExp(`\\n{0,${added}}$`), "");
6189
- }
6190
- }
6191
- const tail = Buffer7.from("\r\n".repeat(added));
6192
- const last = mail.attachments?.at(-1);
6193
- const encoding = String(last?.headers?.get?.("content-transfer-encoding") ?? "7bit").trim().toLowerCase();
6194
- const own = Buffer7.isBuffer(last?.content) && last.content.length >= tail.length && last.content.subarray(-tail.length).equals(tail) ? last.content.subarray(0, -tail.length) : null;
6195
- if (own && ["7bit", "8bit", "binary"].includes(encoding) && own.length <= buffer.length && buffer.subarray(buffer.length - own.length).equals(own)) {
6196
- last.content = own;
6197
- last.size = own.length;
6198
- last.checksum = createHash2("md5").update(own).digest("hex");
6199
- }
6200
- return mail;
6201
- }
6202
- function finishable(raw) {
6203
- const buffer = Buffer7.isBuffer(raw) ? raw : Buffer7.from(raw);
6204
- return Buffer7.concat([buffer, Buffer7.from(buffer.at(-1) === 10 ? "\r\n\r\n" : "\r\n\r\n\r\n")]);
6205
- }
6206
- function upToLine(raw, offset) {
6207
- return raw.subarray(0, Math.max(raw.lastIndexOf(10, offset - 1) + 1, 0));
6208
- }
6209
- function fewerParts(raw) {
6210
- let offset = -1;
6211
- for (let count = 0; count < MAX_PART_LINES; count++) {
6212
- offset = raw.indexOf("\n--", offset + 1);
6213
- if (offset === -1) {
6214
- return upToLine(raw, Math.floor(raw.length / 2));
6215
- }
6216
- }
6217
- return raw.subarray(0, offset + 1);
6218
- }
6219
- function smallerHeader(raw) {
6220
- const text = raw.toString("latin1");
6221
- const blank = /\r?\n\r?\n/.exec(text);
6222
- const end = blank ? blank.index : text.length;
6223
- const fields = [];
6224
- let start = 0;
6225
- while (start < end) {
6226
- let stop = text.indexOf("\n", start);
6227
- stop = stop === -1 || stop > end ? end : stop;
6228
- const line = text.slice(start, Math.min(stop, start + MAX_LINE)).replace(/\r$/, "");
6229
- const last = fields.at(-1);
6230
- if (last && (line.startsWith(" ") || line.startsWith(" "))) {
6231
- if (last.size + line.length <= MAX_FIELD) {
6232
- last.lines.push(line);
6233
- last.size += line.length;
6234
- }
6235
- } else {
6236
- fields.push({ name: line.slice(0, line.indexOf(":")).trim().toLowerCase(), lines: [line], size: line.length });
6237
- }
6238
- start = stop + 1;
6239
- }
6240
- const total = fields.reduce((sum, field) => sum + field.size, 0);
6241
- const counts = /* @__PURE__ */ new Map();
6242
- const kept = total > MAX_HEADER ? fields.filter((field) => {
6243
- counts.set(field.name, (counts.get(field.name) || 0) + 1);
6244
- return KEPT_FIELDS.has(field.name) && counts.get(field.name) <= MAX_REPEATED;
6245
- }) : fields;
6246
- const header = kept.flatMap((field) => field.lines).join("\r\n");
6247
- return Buffer7.concat([Buffer7.from(`${header}\r
6248
- \r
6249
- `, "latin1"), raw.subarray(blank ? blank.index + blank[0].length : raw.length)]);
6250
- }
6251
- async function parseWithin(raw, parse = parseMessage) {
6252
- let input = raw;
6253
- let reason = null;
6254
- for (; ; ) {
6255
- try {
6256
- return { mail: await parse(input), error: reason };
6257
- } catch (error) {
6258
- const limit = error.code === "EMAXLEN" && [TOO_MANY_PARTS, HEADER_TOO_LARGE].includes(error.message) ? error.message : null;
6259
- let shorter = input;
6260
- if (limit === TOO_MANY_PARTS) {
6261
- shorter = fewerParts(input);
6262
- } else if (limit === HEADER_TOO_LARGE) {
6263
- shorter = smallerHeader(input);
6264
- shorter = shorter.length < input.length ? shorter : upToLine(input, MAX_PREFIX_BYTES);
6265
- }
6266
- if (shorter.length >= input.length) {
6267
- throw error;
6268
- }
6269
- reason ??= limit;
6270
- input = shorter;
6271
- }
6272
- }
6273
- }
6274
- async function expandAttachments(attachments = [], errors = [], parse = parseMessage) {
6275
- const all = [];
6276
- let budget = MAX_NESTED_BYTES;
6277
- let nested = 0;
6278
- const visit = async (list, prefix, depth) => {
6279
- for (const attachment of list) {
6280
- if (prefix) {
6281
- if (nested >= MAX_ATTACHMENTS) {
6282
- return;
6283
- }
6284
- nested++;
6285
- }
6286
- const name = attachment.filename || (prefix ? "unnamed attachment" : "");
6287
- all.push(prefix ? { ...attachment, filename: `${prefix}/${name}` } : attachment);
6288
- const { content } = attachment;
6289
- const attached = attachment.contentType === "message/rfc822" || /\.eml$/i.test(attachment.filename || "");
6290
- if (attached && depth < MAX_NESTED_DEPTH && Buffer7.isBuffer(content) && content.length <= budget) {
6291
- budget -= content.length;
6292
- let inner;
6293
- try {
6294
- inner = await parseWithin(content, parse);
6295
- } catch (error) {
6296
- errors.push(`${name || "attached message"}: ${error.message}`);
6297
- continue;
6298
- }
6299
- if (inner.error) {
6300
- errors.push(inner.error);
6301
- }
6302
- await visit(inner.mail.attachments, prefix ? `${prefix}/${name}` : name || "attached message", depth + 1);
6303
- }
6304
- }
6305
- };
6306
- await visit(attachments, "", 0);
6307
- return all;
6308
- }
6309
-
6310
6703
  // src/score.js
6311
6704
  var BAYES_SCALE = Math.log10(99) / 0.8;
6312
6705
  var DEFAULT_SCORES = {
@@ -6523,7 +6916,7 @@ function scoreResults(results, options) {
6523
6916
  var package_default = {
6524
6917
  name: "spamscanner",
6525
6918
  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.",
6526
- version: "7.0.4",
6919
+ version: "7.0.5",
6527
6920
  author: "Forward Email (https://forwardemail.net)",
6528
6921
  bin: {
6529
6922
  spamscanner: "./dist/esm/cli.js"
@@ -7127,9 +7520,198 @@ async function evaluate(classifier, examples) {
7127
7520
  }
7128
7521
 
7129
7522
  // src/milter.js
7130
- import { Buffer as Buffer10 } from "node:buffer";
7523
+ import { Buffer as Buffer11 } from "node:buffer";
7131
7524
  import { EventEmitter } from "node:events";
7525
+ import net3 from "node:net";
7526
+
7527
+ // src/server.js
7528
+ import { Buffer as Buffer10 } from "node:buffer";
7529
+ import { timingSafeEqual } from "node:crypto";
7530
+ import http2 from "node:http";
7132
7531
  import net2 from "node:net";
7532
+ var MAX_MESSAGE_SIZE = 25 * 1024 * 1024;
7533
+ var MAX_CONNECTIONS = 200;
7534
+ function serializeResult(result, options = {}) {
7535
+ const { mail, tokens, ...rest } = result;
7536
+ const output = {
7537
+ ...rest,
7538
+ results: { ...result.results }
7539
+ };
7540
+ if (output.results.authentication) {
7541
+ const { receivedChain, ...auth } = output.results.authentication;
7542
+ output.results.authentication = auth;
7543
+ }
7544
+ if (options.verbose) {
7545
+ output.tokens = tokens;
7546
+ output.subject = mail?.subject ?? null;
7547
+ }
7548
+ return output;
7549
+ }
7550
+ function sessionFromQuery(query) {
7551
+ const session = {};
7552
+ if (query.get("ip")) {
7553
+ session.remoteAddress = query.get("ip");
7554
+ }
7555
+ if (query.get("hostname")) {
7556
+ session.resolvedClientHostname = query.get("hostname");
7557
+ }
7558
+ if (query.get("helo")) {
7559
+ session.helo = query.get("helo");
7560
+ }
7561
+ const recipients = query.getAll("to").flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean);
7562
+ if (query.has("from") || recipients.length > 0) {
7563
+ session.envelope = { mailFrom: { address: query.get("from") || "" }, rcptTo: recipients.map((address2) => ({ address: address2 })) };
7564
+ }
7565
+ return session;
7566
+ }
7567
+ function sameToken(given, expected) {
7568
+ const a = Buffer10.from(String(given));
7569
+ const b = Buffer10.from(String(expected));
7570
+ return a.length === b.length && timingSafeEqual(a, b);
7571
+ }
7572
+ function readBody(request, limit) {
7573
+ return new Promise((resolve, reject) => {
7574
+ const chunks = [];
7575
+ let size = 0;
7576
+ request.on("data", (chunk2) => {
7577
+ size += chunk2.length;
7578
+ if (size <= limit) {
7579
+ chunks.push(chunk2);
7580
+ }
7581
+ });
7582
+ request.on("end", () => {
7583
+ if (size > limit) {
7584
+ const error = new Error(`Message larger than ${limit} bytes`);
7585
+ error.statusCode = 413;
7586
+ reject(error);
7587
+ return;
7588
+ }
7589
+ resolve(Buffer10.concat(chunks));
7590
+ });
7591
+ request.on("error", reject);
7592
+ });
7593
+ }
7594
+ function createHttpServer(scanner, options = {}) {
7595
+ const {
7596
+ token = null,
7597
+ maxSize = MAX_MESSAGE_SIZE,
7598
+ modelPath = null,
7599
+ maxConnections = MAX_CONNECTIONS
7600
+ } = options;
7601
+ let active = 0;
7602
+ const server = http2.createServer(async (request, response) => {
7603
+ const send = (status, body, headers = {}) => {
7604
+ const isBuffer = Buffer10.isBuffer(body);
7605
+ response.writeHead(status, { "content-type": isBuffer ? "message/rfc822" : "application/json; charset=utf-8", "cache-control": "no-store", ...headers });
7606
+ response.end(isBuffer ? body : `${JSON.stringify(body)}
7607
+ `);
7608
+ };
7609
+ try {
7610
+ const url = new URL(request.url, "http://localhost");
7611
+ if (request.method === "GET" && url.pathname === "/health") {
7612
+ send(200, { ok: true, version: VERSION });
7613
+ return;
7614
+ }
7615
+ if (token && !sameToken((request.headers.authorization || "").replace(/^bearer\s+/i, ""), token)) {
7616
+ send(401, { error: "Unauthorized" }, { "www-authenticate": "Bearer" });
7617
+ return;
7618
+ }
7619
+ if (request.method !== "POST") {
7620
+ send(405, { error: "Method not allowed" }, { allow: "POST" });
7621
+ return;
7622
+ }
7623
+ const learn = /^\/learn\/(spam|ham)$/.exec(url.pathname);
7624
+ if (!["/scan", "/check"].includes(url.pathname) && !learn) {
7625
+ send(404, { error: "Not found" });
7626
+ return;
7627
+ }
7628
+ if (learn && !token) {
7629
+ send(403, { error: "Learning needs the server to be started with a token" });
7630
+ return;
7631
+ }
7632
+ const length = request.headers["content-length"];
7633
+ if (length !== void 0 && Number(length) > maxSize) {
7634
+ send(413, { error: `Message larger than ${maxSize} bytes` }, { connection: "close" });
7635
+ return;
7636
+ }
7637
+ if (active >= maxConnections) {
7638
+ send(503, { error: "Too many requests at once" }, { connection: "close", "retry-after": "1" });
7639
+ return;
7640
+ }
7641
+ active++;
7642
+ try {
7643
+ const raw = await readBody(request, maxSize);
7644
+ if (learn) {
7645
+ await scanner.learn(raw, learn[1]);
7646
+ if (modelPath) {
7647
+ scanner.saveModel(modelPath);
7648
+ }
7649
+ send(200, { ok: true, learned: learn[1] });
7650
+ return;
7651
+ }
7652
+ const result = await scanner.scan(raw, { session: sessionFromQuery(url.searchParams) });
7653
+ if (url.pathname === "/scan") {
7654
+ send(200, serializeResult(result, { verbose: url.searchParams.get("verbose") === "1" }));
7655
+ return;
7656
+ }
7657
+ const tag = url.searchParams.get("subjectTag");
7658
+ const rewritten = rewriteMessage(raw, spamHeaders(result, { version: VERSION }), { subjectTag: result.isSpam && tag ? tag : null });
7659
+ send(200, rewritten, { "x-spam-flag": result.isSpam ? "YES" : "NO", "x-spam-score": result.score.toFixed(1), "x-spam-action": result.action });
7660
+ } finally {
7661
+ active--;
7662
+ }
7663
+ } catch (error) {
7664
+ send(error.statusCode || 500, { error: error.message });
7665
+ }
7666
+ });
7667
+ server.maxConnections = maxConnections;
7668
+ return server;
7669
+ }
7670
+ function createTcpServer(scanner, options = {}) {
7671
+ const {
7672
+ json = true,
7673
+ maxSize = MAX_MESSAGE_SIZE,
7674
+ timeout = 6e4,
7675
+ maxConnections = MAX_CONNECTIONS
7676
+ } = options;
7677
+ const server = net2.createServer({ allowHalfOpen: true }, (socket) => {
7678
+ const chunks = [];
7679
+ let size = 0;
7680
+ socket.setTimeout(timeout, () => socket.destroy());
7681
+ const onEnd = async () => {
7682
+ socket.setTimeout(0);
7683
+ try {
7684
+ const result = await scanner.scan(Buffer10.concat(chunks));
7685
+ socket.end(json ? `${JSON.stringify(serializeResult(result))}
7686
+ ` : `${result.isSpam ? "SPAM" : "HAM"} ${result.score.toFixed(1)}/${result.threshold.toFixed(1)} ${result.tests.map((test) => test.name).join(",")}
7687
+ `);
7688
+ } catch (error) {
7689
+ socket.end(json ? `${JSON.stringify({ error: error.message })}
7690
+ ` : `ERROR ${error.message}
7691
+ `);
7692
+ }
7693
+ };
7694
+ const onData = (chunk2) => {
7695
+ size += chunk2.length;
7696
+ if (size > maxSize) {
7697
+ socket.off("data", onData);
7698
+ socket.off("end", onEnd);
7699
+ socket.end(`${JSON.stringify({ error: `Message larger than ${maxSize} bytes` })}
7700
+ `, () => socket.destroy());
7701
+ return;
7702
+ }
7703
+ chunks.push(chunk2);
7704
+ };
7705
+ socket.on("data", onData);
7706
+ socket.on("end", onEnd);
7707
+ socket.on("error", () => {
7708
+ });
7709
+ });
7710
+ server.maxConnections = maxConnections;
7711
+ return server;
7712
+ }
7713
+
7714
+ // src/milter.js
7133
7715
  var COMMANDS = {
7134
7716
  ABORT: "A",
7135
7717
  BODY: "B",
@@ -7193,12 +7775,12 @@ var PROTOCOL = {
7193
7775
  HDR_LEADSPC: 1048576
7194
7776
  };
7195
7777
  var MAX_PACKET = 1024 * 1024;
7196
- function packet(command, data = Buffer10.alloc(0)) {
7197
- const payload = Buffer10.isBuffer(data) ? data : Buffer10.from(data, "latin1");
7198
- const header = Buffer10.alloc(5);
7778
+ function packet(command, data = Buffer11.alloc(0)) {
7779
+ const payload = Buffer11.isBuffer(data) ? data : Buffer11.from(data, "latin1");
7780
+ const header = Buffer11.alloc(5);
7199
7781
  header.writeUInt32BE(payload.length + 1);
7200
7782
  header.write(command, 4, "latin1");
7201
- return Buffer10.concat([header, payload]);
7783
+ return Buffer11.concat([header, payload]);
7202
7784
  }
7203
7785
  function cstrings(data) {
7204
7786
  const parts = data.toString("latin1").split("\0");
@@ -7208,7 +7790,7 @@ function cstrings(data) {
7208
7790
  return parts;
7209
7791
  }
7210
7792
  function cstring(value) {
7211
- return Buffer10.from(`${value}\0`, "latin1");
7793
+ return Buffer11.from(`${value}\0`, "latin1");
7212
7794
  }
7213
7795
  function address(value) {
7214
7796
  const text = String(value ?? "").trim();
@@ -7244,6 +7826,15 @@ var MilterSession = class {
7244
7826
  this.body = [];
7245
7827
  this.size = 0;
7246
7828
  this.truncated = false;
7829
+ this.headerSize = 0;
7830
+ }
7831
+ // The envelope and headers are kept whole for the scan, so beyond the
7832
+ // message size limit the message is not scanned (see onError).
7833
+ keep(data) {
7834
+ this.headerSize += data.length;
7835
+ if (this.headerSize > this.server.options.maxSize) {
7836
+ throw new Error(`Message headers larger than ${this.server.options.maxSize} bytes`);
7837
+ }
7247
7838
  }
7248
7839
  send(command, data) {
7249
7840
  if (!this.socket.destroyed) {
@@ -7256,7 +7847,7 @@ var MilterSession = class {
7256
7847
  if (this.buffered < this.needed) {
7257
7848
  return;
7258
7849
  }
7259
- let buffer = this.chunks.length === 1 ? this.chunks[0] : Buffer10.concat(this.chunks);
7850
+ let buffer = this.chunks.length === 1 ? this.chunks[0] : Buffer11.concat(this.chunks);
7260
7851
  this.needed = 4;
7261
7852
  while (buffer.length >= 4) {
7262
7853
  const length = buffer.readUInt32BE(0);
@@ -7301,7 +7892,7 @@ var MilterSession = class {
7301
7892
  const protocol = data.readUInt32BE(8);
7302
7893
  const wanted = ACTIONS.ADDHDRS | ACTIONS.CHGHDRS | ACTIONS.QUARANTINE;
7303
7894
  this.leadingSpace = Boolean(protocol & PROTOCOL.HDR_LEADSPC);
7304
- const reply2 = Buffer10.alloc(12);
7895
+ const reply2 = Buffer11.alloc(12);
7305
7896
  reply2.writeUInt32BE(Math.min(version, 6), 0);
7306
7897
  reply2.writeUInt32BE(actions & wanted, 4);
7307
7898
  reply2.writeUInt32BE(protocol & (PROTOCOL.HDR_LEADSPC | PROTOCOL.NOUNKNOWN), 8);
@@ -7344,16 +7935,19 @@ var MilterSession = class {
7344
7935
  }
7345
7936
  case COMMANDS.MAIL: {
7346
7937
  this.resetMessage();
7938
+ this.keep(data);
7347
7939
  this.envelope.mailFrom = { address: address(cstrings(data)[0]) };
7348
7940
  this.send(RESPONSES.CONTINUE);
7349
7941
  break;
7350
7942
  }
7351
7943
  case COMMANDS.RCPT: {
7944
+ this.keep(data);
7352
7945
  this.envelope.rcptTo.push({ address: address(cstrings(data)[0]) });
7353
7946
  this.send(RESPONSES.CONTINUE);
7354
7947
  break;
7355
7948
  }
7356
7949
  case COMMANDS.HEADER: {
7950
+ this.keep(data);
7357
7951
  const [name, value = ""] = cstrings(data);
7358
7952
  this.headers.push([name, value]);
7359
7953
  this.send(RESPONSES.CONTINUE);
@@ -7363,14 +7957,19 @@ var MilterSession = class {
7363
7957
  if (this.truncated || this.size + data.length > options.maxSize) {
7364
7958
  this.truncated = true;
7365
7959
  } else {
7366
- this.body.push(Buffer10.from(data));
7960
+ this.body.push(Buffer11.from(data));
7367
7961
  this.size += data.length;
7368
7962
  }
7369
7963
  this.send(RESPONSES.CONTINUE);
7370
7964
  break;
7371
7965
  }
7372
7966
  case COMMANDS.BODYEOB: {
7373
- await this.endOfMessage();
7967
+ this.socket.pause();
7968
+ try {
7969
+ await this.endOfMessage();
7970
+ } finally {
7971
+ this.socket.resume();
7972
+ }
7374
7973
  break;
7375
7974
  }
7376
7975
  case COMMANDS.ABORT: {
@@ -7393,7 +7992,7 @@ var MilterSession = class {
7393
7992
  /** @returns {Buffer} */
7394
7993
  rawMessage() {
7395
7994
  const head = this.headers.map(([name, value]) => `${name}:${this.leadingSpace || value.startsWith(" ") || value.startsWith(" ") ? "" : " "}${value}`).join("\r\n");
7396
- return Buffer10.concat([Buffer10.from(this.headers.length > 0 ? `${head}\r
7995
+ return Buffer11.concat([Buffer11.from(this.headers.length > 0 ? `${head}\r
7397
7996
  \r
7398
7997
  ` : "\r\n", "latin1"), ...this.body]);
7399
7998
  }
@@ -7404,9 +8003,9 @@ var MilterSession = class {
7404
8003
  const key = name.toLowerCase();
7405
8004
  counts.set(key, (counts.get(key) || 0) + 1);
7406
8005
  if (key.startsWith("x-spam-")) {
7407
- const index = Buffer10.alloc(4);
8006
+ const index = Buffer11.alloc(4);
7408
8007
  index.writeUInt32BE(counts.get(key));
7409
- this.send(RESPONSES.CHGHEADER, Buffer10.concat([index, cstring(name), cstring("")]));
8008
+ this.send(RESPONSES.CHGHEADER, Buffer11.concat([index, cstring(name), cstring("")]));
7410
8009
  }
7411
8010
  }
7412
8011
  }
@@ -7429,7 +8028,7 @@ var MilterSession = class {
7429
8028
  }
7430
8029
  this.server.report(error);
7431
8030
  this.removeForgedHeaders();
7432
- this.send(RESPONSES.ADDHEADER, Buffer10.concat([cstring("X-Spam-Virus-Scan"), cstring(`${this.leadingSpace ? " " : ""}failed`)]));
8031
+ this.send(RESPONSES.ADDHEADER, Buffer11.concat([cstring("X-Spam-Virus-Scan"), cstring(`${this.leadingSpace ? " " : ""}failed`)]));
7433
8032
  this.send(RESPONSES.CONTINUE);
7434
8033
  this.resetMessage();
7435
8034
  return;
@@ -7450,15 +8049,15 @@ var MilterSession = class {
7450
8049
  headers.unshift(["Authentication-Results", formatAuthResultsHeader(result.results.authentication, options.hostname)]);
7451
8050
  }
7452
8051
  for (const [name, value] of headers) {
7453
- this.send(RESPONSES.ADDHEADER, Buffer10.concat([cstring(name), cstring(`${this.leadingSpace ? " " : ""}${value}`.replaceAll("\r\n", "\n"))]));
8052
+ this.send(RESPONSES.ADDHEADER, Buffer11.concat([cstring(name), cstring(`${this.leadingSpace ? " " : ""}${value}`.replaceAll("\r\n", "\n"))]));
7454
8053
  }
7455
8054
  if (result.isSpam && options.subjectTag) {
7456
8055
  const subject = this.headers.find(([name]) => name.toLowerCase() === "subject");
7457
8056
  const value = taggedSubject(options.subjectTag, subject ? subject[1].trim() : "", "\n");
7458
8057
  if (value !== null) {
7459
- const index = Buffer10.alloc(4);
8058
+ const index = Buffer11.alloc(4);
7460
8059
  index.writeUInt32BE(1);
7461
- this.send(RESPONSES.CHGHEADER, Buffer10.concat([index, cstring("Subject"), cstring(`${this.leadingSpace ? " " : ""}${value}`.replaceAll("\r\n", "\n"))]));
8060
+ this.send(RESPONSES.CHGHEADER, Buffer11.concat([index, cstring("Subject"), cstring(`${this.leadingSpace ? " " : ""}${value}`.replaceAll("\r\n", "\n"))]));
7462
8061
  }
7463
8062
  }
7464
8063
  if (result.action === "reject" && options.reject) {
@@ -7485,8 +8084,10 @@ var MilterServer = class extends EventEmitter {
7485
8084
  * @param {boolean} [options.quarantine] - hold spam in the MTA's quarantine
7486
8085
  * @param {string|null} [options.subjectTag] - e.g. "[SPAM]"
7487
8086
  * @param {string} [options.hostname] - this server's name, for Authentication-Results
7488
- * @param {number} [options.maxSize] - body bytes kept for scanning
8087
+ * @param {number} [options.maxSize] - body bytes kept for scanning (and the
8088
+ * most bytes of envelope and headers: beyond them a message is not scanned)
7489
8089
  * @param {'tempfail'|'accept'} [options.onError] - answer when a scan fails
8090
+ * @param {number} [options.maxConnections] - connections kept open at once; more are closed at once
7490
8091
  */
7491
8092
  constructor(scanner, options = {}) {
7492
8093
  super();
@@ -7498,19 +8099,21 @@ var MilterServer = class extends EventEmitter {
7498
8099
  quarantine: false,
7499
8100
  subjectTag: null,
7500
8101
  hostname: "spamscanner",
7501
- maxSize: 25 * 1024 * 1024,
8102
+ maxSize: MAX_MESSAGE_SIZE,
7502
8103
  onError: "tempfail",
8104
+ maxConnections: MAX_CONNECTIONS,
7503
8105
  ...options
7504
8106
  };
7505
8107
  if (!/^[45]\d\d$/.test(String(this.options.rejectCode))) {
7506
8108
  throw new RangeError(`rejectCode must be a 4xx or 5xx SMTP code, not ${this.options.rejectCode}`);
7507
8109
  }
7508
8110
  this.sockets = /* @__PURE__ */ new Set();
7509
- this.server = net2.createServer((socket) => {
8111
+ this.server = net3.createServer((socket) => {
7510
8112
  this.sockets.add(socket);
7511
8113
  socket.on("close", () => this.sockets.delete(socket));
7512
8114
  return new MilterSession(socket, this);
7513
8115
  });
8116
+ this.server.maxConnections = this.options.maxConnections;
7514
8117
  this.server.on("error", (error) => this.report(error));
7515
8118
  }
7516
8119
  // Errors go to "error" listeners; with none, a broken connection or failed
@@ -7556,162 +8159,6 @@ var MilterServer = class extends EventEmitter {
7556
8159
  }
7557
8160
  };
7558
8161
 
7559
- // src/server.js
7560
- import { Buffer as Buffer11 } from "node:buffer";
7561
- import { timingSafeEqual } from "node:crypto";
7562
- import http2 from "node:http";
7563
- import net3 from "node:net";
7564
- function serializeResult(result, options = {}) {
7565
- const { mail, tokens, ...rest } = result;
7566
- const output = {
7567
- ...rest,
7568
- results: { ...result.results }
7569
- };
7570
- if (output.results.authentication) {
7571
- const { receivedChain, ...auth } = output.results.authentication;
7572
- output.results.authentication = auth;
7573
- }
7574
- if (options.verbose) {
7575
- output.tokens = tokens;
7576
- output.subject = mail?.subject ?? null;
7577
- }
7578
- return output;
7579
- }
7580
- function sessionFromQuery(query) {
7581
- const session = {};
7582
- if (query.get("ip")) {
7583
- session.remoteAddress = query.get("ip");
7584
- }
7585
- if (query.get("hostname")) {
7586
- session.resolvedClientHostname = query.get("hostname");
7587
- }
7588
- if (query.get("helo")) {
7589
- session.helo = query.get("helo");
7590
- }
7591
- const recipients = query.getAll("to").flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean);
7592
- if (query.has("from") || recipients.length > 0) {
7593
- session.envelope = { mailFrom: { address: query.get("from") || "" }, rcptTo: recipients.map((address2) => ({ address: address2 })) };
7594
- }
7595
- return session;
7596
- }
7597
- function sameToken(given, expected) {
7598
- const a = Buffer11.from(String(given));
7599
- const b = Buffer11.from(String(expected));
7600
- return a.length === b.length && timingSafeEqual(a, b);
7601
- }
7602
- function readBody(request, limit) {
7603
- return new Promise((resolve, reject) => {
7604
- const chunks = [];
7605
- let size = 0;
7606
- request.on("data", (chunk2) => {
7607
- size += chunk2.length;
7608
- if (size <= limit) {
7609
- chunks.push(chunk2);
7610
- }
7611
- });
7612
- request.on("end", () => {
7613
- if (size > limit) {
7614
- const error = new Error(`Message larger than ${limit} bytes`);
7615
- error.statusCode = 413;
7616
- reject(error);
7617
- return;
7618
- }
7619
- resolve(Buffer11.concat(chunks));
7620
- });
7621
- request.on("error", reject);
7622
- });
7623
- }
7624
- function createHttpServer(scanner, options = {}) {
7625
- const { token = null, maxSize = 25 * 1024 * 1024, modelPath = null } = options;
7626
- return http2.createServer(async (request, response) => {
7627
- const send = (status, body, headers = {}) => {
7628
- const isBuffer = Buffer11.isBuffer(body);
7629
- response.writeHead(status, { "content-type": isBuffer ? "message/rfc822" : "application/json; charset=utf-8", "cache-control": "no-store", ...headers });
7630
- response.end(isBuffer ? body : `${JSON.stringify(body)}
7631
- `);
7632
- };
7633
- try {
7634
- const url = new URL(request.url, "http://localhost");
7635
- if (request.method === "GET" && url.pathname === "/health") {
7636
- send(200, { ok: true, version: VERSION });
7637
- return;
7638
- }
7639
- if (token && !sameToken((request.headers.authorization || "").replace(/^bearer\s+/i, ""), token)) {
7640
- send(401, { error: "Unauthorized" }, { "www-authenticate": "Bearer" });
7641
- return;
7642
- }
7643
- if (request.method !== "POST") {
7644
- send(405, { error: "Method not allowed" }, { allow: "POST" });
7645
- return;
7646
- }
7647
- const learn = /^\/learn\/(spam|ham)$/.exec(url.pathname);
7648
- if (!["/scan", "/check"].includes(url.pathname) && !learn) {
7649
- send(404, { error: "Not found" });
7650
- return;
7651
- }
7652
- if (learn && !token) {
7653
- send(403, { error: "Learning needs the server to be started with a token" });
7654
- return;
7655
- }
7656
- const raw = await readBody(request, maxSize);
7657
- if (learn) {
7658
- await scanner.learn(raw, learn[1]);
7659
- if (modelPath) {
7660
- scanner.saveModel(modelPath);
7661
- }
7662
- send(200, { ok: true, learned: learn[1] });
7663
- return;
7664
- }
7665
- const result = await scanner.scan(raw, { session: sessionFromQuery(url.searchParams) });
7666
- if (url.pathname === "/scan") {
7667
- send(200, serializeResult(result, { verbose: url.searchParams.get("verbose") === "1" }));
7668
- return;
7669
- }
7670
- const tag = url.searchParams.get("subjectTag");
7671
- const rewritten = rewriteMessage(raw, spamHeaders(result, { version: VERSION }), { subjectTag: result.isSpam && tag ? tag : null });
7672
- send(200, rewritten, { "x-spam-flag": result.isSpam ? "YES" : "NO", "x-spam-score": result.score.toFixed(1), "x-spam-action": result.action });
7673
- } catch (error) {
7674
- send(error.statusCode || 500, { error: error.message });
7675
- }
7676
- });
7677
- }
7678
- function createTcpServer(scanner, options = {}) {
7679
- const { json = true, maxSize = 25 * 1024 * 1024, timeout = 6e4 } = options;
7680
- return net3.createServer({ allowHalfOpen: true }, (socket) => {
7681
- const chunks = [];
7682
- let size = 0;
7683
- socket.setTimeout(timeout, () => socket.destroy());
7684
- const onEnd = async () => {
7685
- socket.setTimeout(0);
7686
- try {
7687
- const result = await scanner.scan(Buffer11.concat(chunks));
7688
- socket.end(json ? `${JSON.stringify(serializeResult(result))}
7689
- ` : `${result.isSpam ? "SPAM" : "HAM"} ${result.score.toFixed(1)}/${result.threshold.toFixed(1)} ${result.tests.map((test) => test.name).join(",")}
7690
- `);
7691
- } catch (error) {
7692
- socket.end(json ? `${JSON.stringify({ error: error.message })}
7693
- ` : `ERROR ${error.message}
7694
- `);
7695
- }
7696
- };
7697
- const onData = (chunk2) => {
7698
- size += chunk2.length;
7699
- if (size > maxSize) {
7700
- socket.off("data", onData);
7701
- socket.off("end", onEnd);
7702
- socket.end(`${JSON.stringify({ error: `Message larger than ${maxSize} bytes` })}
7703
- `, () => socket.destroy());
7704
- return;
7705
- }
7706
- chunks.push(chunk2);
7707
- };
7708
- socket.on("data", onData);
7709
- socket.on("end", onEnd);
7710
- socket.on("error", () => {
7711
- });
7712
- });
7713
- }
7714
-
7715
8162
  // src/spamd.js
7716
8163
  import { Buffer as Buffer12 } from "node:buffer";
7717
8164
  import net4 from "node:net";
@@ -7807,14 +8254,15 @@ async function answer(scanner, request, message, options) {
7807
8254
  }
7808
8255
  function createSpamdServer(scanner, options = {}) {
7809
8256
  const settings = {
7810
- maxSize: 25 * 1024 * 1024,
8257
+ maxSize: MAX_MESSAGE_SIZE,
7811
8258
  allowTell: false,
7812
8259
  modelPath: null,
7813
8260
  subjectTag: null,
7814
8261
  timeout: 6e4,
8262
+ maxConnections: MAX_CONNECTIONS,
7815
8263
  ...options
7816
8264
  };
7817
- return net4.createServer({ allowHalfOpen: true }, (socket) => {
8265
+ const server = net4.createServer({ allowHalfOpen: true }, (socket) => {
7818
8266
  let head = Buffer12.alloc(0);
7819
8267
  const chunks = [];
7820
8268
  let size = 0;
@@ -7896,6 +8344,8 @@ function createSpamdServer(scanner, options = {}) {
7896
8344
  socket.on("error", () => {
7897
8345
  });
7898
8346
  });
8347
+ server.maxConnections = settings.maxConnections;
8348
+ return server;
7899
8349
  }
7900
8350
 
7901
8351
  // src/models.js
@@ -8472,6 +8922,7 @@ var SpamScanner = class {
8472
8922
  const settings = config.clamav === true ? {} : config.clamav;
8473
8923
  const budget = settings.timeout ?? CLAMAV_TIMEOUT;
8474
8924
  const deadline = Date.now() + budget;
8925
+ let expired = false;
8475
8926
  const viruses = [];
8476
8927
  const errors = [];
8477
8928
  const skipped = [];
@@ -8491,7 +8942,7 @@ var SpamScanner = class {
8491
8942
  while (queue.length > 0) {
8492
8943
  const attachment = queue.shift();
8493
8944
  const filename = attachment.filename || "unnamed attachment";
8494
- if (deadline <= Date.now()) {
8945
+ if (expired || deadline <= Date.now()) {
8495
8946
  errors.push({ filename, error: `not scanned within ${budget} ms` });
8496
8947
  continue;
8497
8948
  }
@@ -8511,6 +8962,7 @@ var SpamScanner = class {
8511
8962
  }
8512
8963
  } catch (error) {
8513
8964
  debug2("virus scan failed: %s", error.message);
8965
+ expired ||= Boolean(error.timedOut);
8514
8966
  if (CLAMAV_LIMITS.test(error.message)) {
8515
8967
  skipped.push({ filename, reason: error.message.replace(/^clamd: (?:stream: )?/, "") });
8516
8968
  } else {