mailparser 3.9.14 → 3.9.16

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.14"
2
+ ".": "3.9.16"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.9.16](https://github.com/nodemailer/mailparser/compare/v3.9.15...v3.9.16) (2026-08-24)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * bound linkify-it scanning of untrusted text bodies ([1910471](https://github.com/nodemailer/mailparser/commit/1910471a4edd72249237ce7ad243228d61c4908f))
9
+ * **deps:** update html-to-text to 10.0.1 ([308e1b2](https://github.com/nodemailer/mailparser/commit/308e1b2993e554470ecbd3570cdd18a394b866b3))
10
+
11
+ ## [3.9.15](https://github.com/nodemailer/mailparser/compare/v3.9.14...v3.9.15) (2026-08-07)
12
+
13
+
14
+ ### Bug Fixes
15
+
16
+ * update dependencies (@zone-eu/mailsplit 5.4.15, libmime 5.4.2, nodemailer 9.0.5) ([047379d](https://github.com/nodemailer/mailparser/commit/047379df5537d799e21b410a7c9019872579ff5a))
17
+
3
18
  ## [3.9.14](https://github.com/nodemailer/mailparser/compare/v3.9.13...v3.9.14) (2026-07-05)
4
19
 
5
20
 
@@ -16,6 +16,24 @@ const linkify = require('linkify-it')();
16
16
  const tlds = require('tlds');
17
17
  const encodingJapanese = require('encoding-japanese');
18
18
 
19
+ // Bounds for link detection in text bodies. Message content is untrusted and
20
+ // linkify-it backtracks heavily on crafted input: matching a host is quadratic
21
+ // in the length of a single whitespace-free run and exponential in the number
22
+ // of dot separated labels it has to try (a label starting with "xn--" matches
23
+ // two of the alternatives in linkify-it's domain pattern, so N such labels can
24
+ // be grouped in 2^N ways). Text is therefore scanned one whitespace-delimited
25
+ // segment at a time, segments long enough or with enough chained host labels to
26
+ // get expensive are left as plain text, and a message may only spend so much
27
+ // work in total. Scanning one segment costs roughly length * (length + 1024), so
28
+ // the budget is counted in those units rather than in characters - neither a
29
+ // few very long segments nor very many short ones can then add up to an
30
+ // unbounded scan. Together these keep the worst case at about half a second.
31
+ // Ordinary mail stays far below all three: across test/fixtures the longest
32
+ // segment is 565 characters and 99.9% of them chain up at most 3 host labels.
33
+ const MAX_LINKIFY_SEGMENT_LENGTH = 4096;
34
+ const MAX_LINKIFY_HOST_LABELS = 6;
35
+ const MAX_LINKIFY_WORK = 768 * 1024 * 1024; // shared by all text parts of a message
36
+
19
37
  linkify
20
38
  .tlds(tlds) // Reload with full tlds list
21
39
  .tlds('onion', true) // Add unofficial `.onion` domain
@@ -23,6 +41,13 @@ linkify
23
41
  .add('ftp:', null) // Disable `ftp:` ptotocol
24
42
  .set({ fuzzyIP: true, fuzzyLink: true, fuzzyEmail: true });
25
43
 
44
+ // Characters linkify-it accepts inside a host name: letters, digits, a dash,
45
+ // and anything non-ASCII (its label pattern takes any character that is not
46
+ // punctuation, whitespace or a control character).
47
+ function isHostChar(code) {
48
+ return (code >= 0x61 && code <= 0x7a) || (code >= 0x41 && code <= 0x5a) || (code >= 0x30 && code <= 0x39) || code === 0x2d || code >= 0x80;
49
+ }
50
+
26
51
  // twitter linkifier from
27
52
  // https://github.com/markdown-it/linkify-it#example-2-add-twitter-mentions-handler
28
53
  linkify.add('@', {
@@ -35,7 +60,7 @@ linkify.add('@', {
35
60
  if (self.re.twitter.test(tail)) {
36
61
  // Linkifier allows punctuation chars before prefix,
37
62
  // but we additionally disable `@` ("@@mention" is invalid)
38
- if (pos >= 2 && tail[pos - 2] === '@') {
63
+ if (pos >= 2 && text[pos - 2] === '@') {
39
64
  return false;
40
65
  }
41
66
  return tail.match(self.re.twitter)[0].length;
@@ -156,6 +181,7 @@ class MailParser extends Transform {
156
181
  this.text = false;
157
182
  this.html = false;
158
183
  this.textAsHtml = false;
184
+ this.linkifyWork = 0;
159
185
 
160
186
  this.attachmentList = [];
161
187
 
@@ -277,9 +303,7 @@ class MailParser extends Transform {
277
303
  return this.cleanup(done);
278
304
  }
279
305
  this.waitingEnd = () => {
280
- this.cleanup(() => {
281
- done();
282
- });
306
+ this.cleanup(done);
283
307
  };
284
308
  }
285
309
 
@@ -289,7 +313,8 @@ class MailParser extends Transform {
289
313
  let t = this.getTextContent();
290
314
  this.push(t);
291
315
  } catch (err) {
292
- return this.emit('error', err);
316
+ // report through the stream instead of leaving _flush hanging
317
+ return done(err);
293
318
  }
294
319
 
295
320
  done();
@@ -351,7 +376,10 @@ class MailParser extends Transform {
351
376
  } catch (E) {
352
377
  // ignore
353
378
  }
354
- value = value.split(/\s+/).map(this.ensureMessageIDFormat).filter(val => val);
379
+ value = value
380
+ .split(/\s+/)
381
+ .map(this.ensureMessageIDFormat)
382
+ .filter(val => val);
355
383
  break;
356
384
  case 'message-id':
357
385
  case 'in-reply-to':
@@ -1133,6 +1161,73 @@ class MailParser extends Transform {
1133
1161
  setImmediate(processNext);
1134
1162
  }
1135
1163
 
1164
+ // Tells whether a text segment is worth handing to linkify-it, see
1165
+ // MAX_LINKIFY_* above. Note that linkify.pretest() can not be used for this
1166
+ // - it is a quick deny that also denies links its own matcher finds, eg. a
1167
+ // host followed by a fullwidth vertical bar.
1168
+ canScanSegment(segment) {
1169
+ // every link linkify-it can find contains at least one of these
1170
+ if (segment.length > MAX_LINKIFY_SEGMENT_LENGTH || !/[.:@/]/.test(segment)) {
1171
+ return false;
1172
+ }
1173
+
1174
+ // Dots only chain up into one host candidate while the characters
1175
+ // around them could be part of a host, so a slash, comma or colon ends
1176
+ // the chain and dots in a path or query do not add to it.
1177
+ let labels = 0;
1178
+ for (let i = 0, len = segment.length; i < len; i++) {
1179
+ let code = segment.charCodeAt(i);
1180
+ if (code === 0x2e) {
1181
+ if (++labels > MAX_LINKIFY_HOST_LABELS) {
1182
+ return false;
1183
+ }
1184
+ } else if (!isHostChar(code)) {
1185
+ labels = 0;
1186
+ }
1187
+ }
1188
+
1189
+ return true;
1190
+ }
1191
+
1192
+ findLinks(str) {
1193
+ let links = [];
1194
+
1195
+ if (this.linkifyWork >= MAX_LINKIFY_WORK || !linkify.pretest(str)) {
1196
+ return links;
1197
+ }
1198
+
1199
+ // Whitespace can not be part of a link and linkify-it treats the end of
1200
+ // a segment exactly like whitespace, so scanning segment by segment
1201
+ // finds the same links as scanning the entire string would. U+FEFF is
1202
+ // whitespace for Javascript but an ordinary link character for
1203
+ // linkify-it, so it must not end a segment.
1204
+ let segmentRegex = /[\S\uFEFF]+/g;
1205
+ let segmentMatch;
1206
+
1207
+ while ((segmentMatch = segmentRegex.exec(str)) !== null) {
1208
+ let segment = segmentMatch[0];
1209
+
1210
+ if (!this.canScanSegment(segment)) {
1211
+ // too expensive to scan or can not match at all, keep as plain text
1212
+ continue;
1213
+ }
1214
+
1215
+ let matches = linkify.match(segment);
1216
+ for (let i = 0; matches && i < matches.length; i++) {
1217
+ matches[i].index += segmentMatch.index;
1218
+ matches[i].lastIndex += segmentMatch.index;
1219
+ links.push(matches[i]);
1220
+ }
1221
+
1222
+ this.linkifyWork += segment.length * (segment.length + 1024);
1223
+ if (this.linkifyWork >= MAX_LINKIFY_WORK) {
1224
+ break;
1225
+ }
1226
+ }
1227
+
1228
+ return links;
1229
+ }
1230
+
1136
1231
  textToHtml(str) {
1137
1232
  if (this.options.skipTextToHtml) {
1138
1233
  return '';
@@ -1140,12 +1235,10 @@ class MailParser extends Transform {
1140
1235
  str = (str || '').toString();
1141
1236
  let encoded;
1142
1237
 
1143
- let linkified = false;
1144
1238
  if (!this.options.skipTextLinks) {
1145
1239
  try {
1146
- if (linkify.pretest(str)) {
1147
- linkified = true;
1148
- let links = linkify.match(str) || [];
1240
+ let links = this.findLinks(str);
1241
+ if (links.length) {
1149
1242
  let result = [];
1150
1243
  let last = 0;
1151
1244
 
@@ -1182,7 +1275,8 @@ class MailParser extends Transform {
1182
1275
  }
1183
1276
  }
1184
1277
 
1185
- if (!linkified) {
1278
+ // nothing linkified, or linkifying threw halfway through
1279
+ if (!encoded) {
1186
1280
  encoded = he
1187
1281
  // encode special chars
1188
1282
  .encode(str, {
@@ -1195,7 +1289,12 @@ class MailParser extends Transform {
1195
1289
  encoded
1196
1290
  .replace(/\r?\n/g, '\n')
1197
1291
  .trim() // normalize line endings
1198
- .replace(/[ \t]+$/gm, '')
1292
+ // Trims trailing spaces and tabs off every line. The leading
1293
+ // group keeps this linear, plain /[ \t]+$/gm rescans the whole
1294
+ // run from every position inside it, which is quadratic. The
1295
+ // lookahead lists the line terminators that multiline $ matches
1296
+ // before, the group covers the line starts.
1297
+ .replace(/(^|[^ \t])[ \t]+(?=[\n\r\u2028\u2029]|$)/g, '$1')
1199
1298
  .trim() // trim empty line endings
1200
1299
  .replace(/\n\n+/g, '</p><p>')
1201
1300
  .trim() // insert <p> to multiple linebreaks
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mailparser",
3
- "version": "3.9.14",
3
+ "version": "3.9.16",
4
4
  "description": "Parse e-mails",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -20,28 +20,28 @@
20
20
  "dependencies": {
21
21
  "encoding-japanese": "2.2.0",
22
22
  "he": "1.2.0",
23
- "html-to-text": "10.0.0",
23
+ "html-to-text": "10.0.1",
24
24
  "iconv-lite": "0.7.3",
25
- "libmime": "5.4.1",
25
+ "libmime": "5.4.2",
26
26
  "linkify-it": "5.0.2",
27
- "@zone-eu/mailsplit": "5.4.14",
28
- "nodemailer": "9.0.3",
27
+ "@zone-eu/mailsplit": "5.4.15",
28
+ "nodemailer": "9.0.5",
29
29
  "punycode.js": "2.3.1",
30
30
  "tlds": "1.261.0"
31
31
  },
32
32
  "devDependencies": {
33
- "@eslint/eslintrc": "3.3.5",
33
+ "@eslint/eslintrc": "3.3.6",
34
34
  "@eslint/js": "10.0.1",
35
35
  "ajv": "8.20.0",
36
- "eslint": "10.6.0",
36
+ "eslint": "10.9.0",
37
37
  "eslint-config-nodemailer": "1.2.0",
38
38
  "eslint-config-prettier": "10.1.8",
39
- "grunt": "1.6.2",
39
+ "grunt": "1.6.3",
40
40
  "grunt-cli": "1.5.0",
41
41
  "grunt-contrib-nodeunit": "5.0.0",
42
42
  "grunt-eslint": "26.0.0",
43
43
  "iconv": "3.0.1",
44
- "prettier": "3.9.4",
44
+ "prettier": "3.9.6",
45
45
  "random-message": "1.1.0"
46
46
  },
47
47
  "repository": {