mailparser 3.9.29 → 3.9.30

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.
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "3.9.29"
2
+ ".": "3.9.30"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.9.30](https://github.com/nodemailer/mailparser/compare/v3.9.29...v3.9.30) (2026-09-28)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * cap inlined cid images, report late splitter errors, settle once, honour attachment backpressure, derive partId from the part number ([af3fe56](https://github.com/nodemailer/mailparser/commit/af3fe56719ae6bf5d867f22faf3bf81942659bdd))
9
+
3
10
  ## [3.9.29](https://github.com/nodemailer/mailparser/compare/v3.9.28...v3.9.29) (2026-09-27)
4
11
 
5
12
 
package/README.md CHANGED
@@ -25,6 +25,11 @@ const mailparser = require('mailparser');
25
25
 
26
26
  See [mailparser homepage](https://nodemailer.com/extras/mailparser/) for documentation and terms.
27
27
 
28
+ ### Limits
29
+
30
+ - `simpleParser` replaces `cid:` image references in the HTML with `data:` URLs. The total length of the URLs written into the HTML is capped by the `maxInlinedImagesSize` option (default 20 MB, counted in characters of the inserted URLs). References past the cap stay as `cid:` links. The same cap applies to URLs returned from a custom `updateImageLinks()` callback.
31
+ - `simpleParser` calls its callback, or settles its promise, exactly once. When `MailParser` reports a non-fatal error (for example HTML longer than `maxHtmlLengthToParse`), that error is what the caller receives. `MailParser` itself still emits the `'error'` event and then finishes parsing.
32
+
28
33
  ### License
29
34
 
30
35
  Licensed under MIT
@@ -34,6 +34,16 @@ const MAX_LINKIFY_SEGMENT_LENGTH = 4096;
34
34
  const MAX_LINKIFY_HOST_LABELS = 6;
35
35
  const MAX_LINKIFY_WORK = 768 * 1024 * 1024; // shared by all text parts of a message
36
36
 
37
+ // Default upper bound for the total length of the URLs updateImageLinks() writes
38
+ // into the HTML (options.maxInlinedImagesSize). simpleParser inlines every cid:
39
+ // reference as a full data: URL, so a small message repeating a reference to
40
+ // one image thousands of times would otherwise grow the HTML past the maximum
41
+ // string length. References beyond the budget are left as cid: links.
42
+ const MAX_INLINED_IMAGES_SIZE = 20 * 1024 * 1024;
43
+
44
+ // List-* header names that must not become keys of the plain list object
45
+ const UNSAFE_LIST_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
46
+
37
47
  linkify
38
48
  .tlds(tlds) // Reload with full tlds list
39
49
  .tlds('onion', true) // Add unofficial `.onion` domain
@@ -72,6 +82,31 @@ linkify.add('@', {
72
82
  }
73
83
  });
74
84
 
85
+ // Linear equivalent of /<[^<>]+@[^<>]+>/.test(str). The regex backtracks
86
+ // quadratically on a long run of @ characters after a lone <, and the input is a
87
+ // decoded encoded-word bounded only by the header size limit.
88
+ function hasBracketedAddress(str) {
89
+ let start = -1;
90
+ // first @ after the current < that has at least one character before it
91
+ let at = -1;
92
+ for (let i = 0, len = str.length; i < len; i++) {
93
+ let chr = str.charAt(i);
94
+ if (chr === '<') {
95
+ start = i;
96
+ at = -1;
97
+ } else if (chr === '>') {
98
+ if (start >= 0 && at >= 0 && at < i - 1) {
99
+ return true;
100
+ }
101
+ start = -1;
102
+ at = -1;
103
+ } else if (chr === '@' && start >= 0 && at < 0 && i > start + 1) {
104
+ at = i;
105
+ }
106
+ }
107
+ return false;
108
+ }
109
+
75
110
  class IconvDecoder extends Transform {
76
111
  constructor(Iconv, charset) {
77
112
  super();
@@ -185,8 +220,6 @@ class MailParser extends Transform {
185
220
 
186
221
  this.attachmentList = [];
187
222
 
188
- this.boundaries = [];
189
-
190
223
  this.textTypes = ['text/plain', 'text/html'].concat(!this.options.keepDeliveryStatus ? 'message/delivery-status' : []);
191
224
 
192
225
  this.decoder = this.getDecoder();
@@ -236,7 +269,7 @@ class MailParser extends Transform {
236
269
  decodeStream(charset) {
237
270
  // resolve charset aliases (eg. iso-8859-8-i -> ISO-8859-8) via libmime's canonical table
238
271
  charset = libmime.normalizeCharset((charset || 'ascii').toString());
239
- if (/^jis|^iso-?2022-?jp|^EUCJP/i.test(charset)) {
272
+ if (/^jis|^iso-?2022-?jp/i.test(charset)) {
240
273
  // special case not supported by iconv-lite
241
274
  return new JPDecoder(charset);
242
275
  }
@@ -302,7 +335,12 @@ class MailParser extends Transform {
302
335
  if (this.finished) {
303
336
  return this.cleanup(done);
304
337
  }
305
- this.waitingEnd = () => {
338
+ this.waitingEnd = err => {
339
+ // runs once, either on a splitter error after end of input or when the splitter ends
340
+ this.waitingEnd = false;
341
+ if (err) {
342
+ return done(err);
343
+ }
306
344
  this.cleanup(done);
307
345
  };
308
346
  }
@@ -479,6 +517,10 @@ class MailParser extends Transform {
479
517
  }
480
518
 
481
519
  parseListHeader(key, value) {
520
+ if (UNSAFE_LIST_KEYS.has(key)) {
521
+ // would replace the prototype of (or shadow) the normalized list object
522
+ return false;
523
+ }
482
524
  let addresses = addressparser(value);
483
525
  let response = {};
484
526
  let data = addresses
@@ -563,7 +605,7 @@ class MailParser extends Transform {
563
605
  // must not be re-interpreted as an address -- RFC 2047 Section 5 prohibits
564
606
  // encoded-words in addr-spec, and re-parsing fabricates addresses not present
565
607
  // in the original header.
566
- if (/<[^<>]+@[^<>]+>/.test(decoded)) {
608
+ if (hasBracketedAddress(decoded)) {
567
609
  let parsed = addressparser(decoded);
568
610
  if (parsed.length) {
569
611
  parsed.forEach(entry => {
@@ -852,7 +894,8 @@ class MailParser extends Transform {
852
894
  processChunk(data, done) {
853
895
  let partId = null;
854
896
  if (data._parentBoundary) {
855
- partId = this._getPartId(data._parentBoundary);
897
+ // mailsplit numbers parts IMAP style from the actual tree, TEXT markers are not part of the id
898
+ partId = (data.partNr || []).filter(nr => typeof nr === 'number').join('.') || null;
856
899
  }
857
900
  switch (data.type) {
858
901
  case 'node': {
@@ -884,6 +927,11 @@ class MailParser extends Transform {
884
927
  contentType = this.libmime.detectMimeType(data.filename) || 'application/octet-stream';
885
928
  }
886
929
 
930
+ // backpressure state for the attachment stream, see pump() below
931
+ let released = false;
932
+ let waitingDrain = false;
933
+ let pump = null;
934
+
887
935
  let attachment = {
888
936
  type: 'attachment',
889
937
  content: null,
@@ -891,6 +939,11 @@ class MailParser extends Transform {
891
939
  partId,
892
940
  release: () => {
893
941
  attachment.release = null;
942
+ released = true;
943
+ if (waitingDrain) {
944
+ waitingDrain = false;
945
+ pump();
946
+ }
894
947
  if (this.waitUntilAttachmentEnd && typeof this.attachmentCallback === 'function') {
895
948
  setImmediate(this.attachmentCallback);
896
949
  }
@@ -905,11 +958,25 @@ class MailParser extends Transform {
905
958
  hasher.emit('error', err);
906
959
  });
907
960
 
908
- node.decoder.on('readable', () => {
961
+ // Honour the consumer's backpressure while it reads the attachment. Once the
962
+ // attachment is released it is drained regardless, so that a caller that
963
+ // releases without reading the content does not stall the parser.
964
+ pump = () => {
909
965
  let chunk;
910
-
911
966
  while ((chunk = node.decoder.read()) !== null) {
912
- hasher.write(chunk);
967
+ if (hasher.write(chunk) === false && !released) {
968
+ waitingDrain = true;
969
+ return hasher.once('drain', () => {
970
+ waitingDrain = false;
971
+ pump();
972
+ });
973
+ }
974
+ }
975
+ };
976
+
977
+ node.decoder.on('readable', () => {
978
+ if (!waitingDrain) {
979
+ pump();
913
980
  }
914
981
  });
915
982
 
@@ -1041,22 +1108,6 @@ class MailParser extends Transform {
1041
1108
  setImmediate(done);
1042
1109
  }
1043
1110
 
1044
- _getPartId(parentBoundary) {
1045
- let boundaryIndex = this.boundaries.findIndex(item => item.name === parentBoundary);
1046
- if (boundaryIndex === -1) {
1047
- this.boundaries.push({ name: parentBoundary, count: 1 });
1048
- boundaryIndex = this.boundaries.length - 1;
1049
- } else {
1050
- this.boundaries[boundaryIndex].count++;
1051
- }
1052
- let partId = '1';
1053
- for (let i = 0; i <= boundaryIndex; i++) {
1054
- if (i === 0) partId = this.boundaries[i].count.toString();
1055
- else partId += '.' + this.boundaries[i].count.toString();
1056
- }
1057
- return partId;
1058
- }
1059
-
1060
1111
  getAddressesHTML(value) {
1061
1112
  let formatSingleLevel = addresses =>
1062
1113
  addresses
@@ -1139,12 +1190,28 @@ class MailParser extends Transform {
1139
1190
  let pos = 0;
1140
1191
  let processNext = () => {
1141
1192
  if (pos >= cidList.length) {
1142
- html = html.replace(/\bcid:([^'"\s]{1,256})/g, (match, cid) => {
1143
- if (cids.has(cid) && cids.get(cid).url) {
1144
- return cids.get(cid).url;
1145
- }
1146
- return match;
1147
- });
1193
+ let maxSize = this.options.maxInlinedImagesSize;
1194
+ if (typeof maxSize !== 'number' || !(maxSize >= 0)) {
1195
+ maxSize = MAX_INLINED_IMAGES_SIZE;
1196
+ }
1197
+ let inlinedSize = 0;
1198
+ try {
1199
+ html = html.replace(/\bcid:([^'"\s]{1,256})/g, (match, cid) => {
1200
+ let url = cids.has(cid) && cids.get(cid).url;
1201
+ if (!url) {
1202
+ return match;
1203
+ }
1204
+ url = String(url);
1205
+ if (inlinedSize + url.length > maxSize) {
1206
+ return match;
1207
+ }
1208
+ inlinedSize += url.length;
1209
+ return url;
1210
+ });
1211
+ } catch (err) {
1212
+ // runs from setImmediate, a throw here would be an uncaught exception
1213
+ return done(err);
1214
+ }
1148
1215
 
1149
1216
  return done(null, html);
1150
1217
  }
@@ -19,6 +19,18 @@ module.exports = (input, options, callback) => {
19
19
  });
20
20
  }
21
21
 
22
+ // MailParser may emit a non-fatal 'error' (eg. HTML too long to convert to text) and
23
+ // still reach 'end', so settle only once: the first error or the result wins
24
+ let settled = false;
25
+ let settle = callback;
26
+ callback = (...args) => {
27
+ if (settled) {
28
+ return;
29
+ }
30
+ settled = true;
31
+ settle(...args);
32
+ };
33
+
22
34
  options = options || {};
23
35
  let keepCidLinks = !!options.keepCidLinks;
24
36
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mailparser",
3
- "version": "3.9.29",
3
+ "version": "3.9.30",
4
4
  "description": "Parse e-mails",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -22,10 +22,10 @@
22
22
  "he": "1.2.0",
23
23
  "html-to-text": "10.0.1",
24
24
  "iconv-lite": "0.7.3",
25
- "libmime": "5.4.4",
25
+ "libmime": "5.4.5",
26
26
  "linkify-it": "5.0.2",
27
- "@zone-eu/mailsplit": "5.4.17",
28
- "nodemailer": "10.0.11",
27
+ "@zone-eu/mailsplit": "5.4.18",
28
+ "nodemailer": "10.0.12",
29
29
  "punycode.js": "2.3.1",
30
30
  "tlds": "1.261.0"
31
31
  },