mailparser 3.9.15 → 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.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +8 -0
- package/lib/mail-parser.js +111 -12
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
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
|
+
|
|
3
11
|
## [3.9.15](https://github.com/nodemailer/mailparser/compare/v3.9.14...v3.9.15) (2026-08-07)
|
|
4
12
|
|
|
5
13
|
|
package/lib/mail-parser.js
CHANGED
|
@@ -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 &&
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1147
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
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.
|
|
3
|
+
"version": "3.9.16",
|
|
4
4
|
"description": "Parse e-mails",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"encoding-japanese": "2.2.0",
|
|
22
22
|
"he": "1.2.0",
|
|
23
|
-
"html-to-text": "10.0.
|
|
23
|
+
"html-to-text": "10.0.1",
|
|
24
24
|
"iconv-lite": "0.7.3",
|
|
25
25
|
"libmime": "5.4.2",
|
|
26
26
|
"linkify-it": "5.0.2",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"@eslint/eslintrc": "3.3.6",
|
|
34
34
|
"@eslint/js": "10.0.1",
|
|
35
35
|
"ajv": "8.20.0",
|
|
36
|
-
"eslint": "10.
|
|
36
|
+
"eslint": "10.9.0",
|
|
37
37
|
"eslint-config-nodemailer": "1.2.0",
|
|
38
38
|
"eslint-config-prettier": "10.1.8",
|
|
39
39
|
"grunt": "1.6.3",
|