mailparser 3.9.39 → 3.9.41
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 +16 -0
- package/README.md +2 -1
- package/lib/mail-parser.js +160 -65
- package/lib/simple-parser.js +3 -3
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [3.9.41](https://github.com/nodemailer/mailparser/compare/v3.9.40...v3.9.41) (2026-10-18)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* bump @zone-eu/mailsplit to 5.4.24 ([eb4b34b](https://github.com/nodemailer/mailparser/commit/eb4b34b04b18de974a3bd61c295d2abca1eb0d69))
|
|
9
|
+
|
|
10
|
+
## [3.9.40](https://github.com/nodemailer/mailparser/compare/v3.9.39...v3.9.40) (2026-10-16)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* bump @zone-eu/mailsplit to 5.4.23 ([32adf4f](https://github.com/nodemailer/mailparser/commit/32adf4f9b7d10a95f98359de7476ebb7fe526cf0))
|
|
16
|
+
* bump libmime to 5.4.9 and nodemailer to 10.1.2 ([a928892](https://github.com/nodemailer/mailparser/commit/a92889221c11eb81b06472f911672b71c288c4ef))
|
|
17
|
+
* keep the message when its HTML can not be converted to text, and never turn a decoded display name into an address ([0d234fd](https://github.com/nodemailer/mailparser/commit/0d234fd3ab1207a617e3682b27c72f4902d76936))
|
|
18
|
+
|
|
3
19
|
## [3.9.39](https://github.com/nodemailer/mailparser/compare/v3.9.38...v3.9.39) (2026-10-14)
|
|
4
20
|
|
|
5
21
|
|
package/README.md
CHANGED
|
@@ -28,7 +28,8 @@ See [mailparser homepage](https://nodemailer.com/extras/mailparser/) for documen
|
|
|
28
28
|
### Limits
|
|
29
29
|
|
|
30
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
|
|
31
|
+
- `simpleParser` calls its callback, or settles its promise, exactly once. When `MailParser` reports a non-fatal error and then finishes parsing, that error is what the caller receives.
|
|
32
|
+
- HTML that can not be converted to text (longer than `maxHtmlLengthToParse`, nested deeper than 1000 levels, or rejected by html-to-text) does not fail the message. `text` is empty, `html` is kept, and the reason is set as `textConversionError` on the result (and on the `text` object `MailParser` emits).
|
|
32
33
|
|
|
33
34
|
### License
|
|
34
35
|
|
package/lib/mail-parser.js
CHANGED
|
@@ -82,27 +82,121 @@ linkify.add('@', {
|
|
|
82
82
|
}
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
let
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
85
|
+
// Lists the parameters a header value gives in RFC 2231 form (name*=, name*0= or
|
|
86
|
+
// name*0*=). libmime decodes those while parsing the header, so the result must not go
|
|
87
|
+
// through decodeWords as well: filename*=utf-8''a%3D%3Futf-8%3FQ%3F%3D2Eexe%3F%3D would
|
|
88
|
+
// otherwise come out as "a.exe" while a parser that decodes once sees the encoded word.
|
|
89
|
+
function rfc2231ParamNames(value) {
|
|
90
|
+
let names = new Set();
|
|
91
|
+
let quote = false;
|
|
92
|
+
let escaped = false;
|
|
93
|
+
let start = 0;
|
|
94
|
+
|
|
95
|
+
let addSegment = end => {
|
|
96
|
+
let segment = value.substring(start, end);
|
|
97
|
+
let eq = segment.indexOf('=');
|
|
98
|
+
if (eq < 0) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
let match = segment
|
|
102
|
+
.substring(0, eq)
|
|
103
|
+
.trim()
|
|
104
|
+
.toLowerCase()
|
|
105
|
+
.match(/^(.+?)\*(\d+\*?)?$/);
|
|
106
|
+
if (match) {
|
|
107
|
+
names.add(match[1]);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
for (let i = 0; i < value.length; i++) {
|
|
112
|
+
let chr = value.charAt(i);
|
|
113
|
+
if (escaped) {
|
|
114
|
+
escaped = false;
|
|
115
|
+
} else if (chr === '\\') {
|
|
116
|
+
escaped = true;
|
|
117
|
+
} else if (chr === '"') {
|
|
118
|
+
quote = !quote;
|
|
119
|
+
} else if (chr === ';' && !quote) {
|
|
120
|
+
addSegment(i);
|
|
121
|
+
start = i + 1;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
addSegment(value.length);
|
|
125
|
+
|
|
126
|
+
return names;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Deepest element nesting html-to-text is asked to convert. htmlparser2 keeps its open
|
|
130
|
+
// element stack at the front of an array, so parsing alone is quadratic in the nesting
|
|
131
|
+
// depth (200k nested <b> took 9 s), and html-to-text's recursive walk overflows the call
|
|
132
|
+
// stack at about 2000 levels. Real markup stays far below this: Chrome's own parser
|
|
133
|
+
// flattens anything nested deeper than 512 levels.
|
|
134
|
+
const MAX_HTML_NESTING_DEPTH = 1000;
|
|
135
|
+
|
|
136
|
+
// Elements that never hold content, so a start tag does not open a level
|
|
137
|
+
const HTML_VOID_ELEMENTS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
|
|
138
|
+
|
|
139
|
+
// Elements the HTML parser closes implicitly when a sibling opens (li after li, td after
|
|
140
|
+
// td, p after p), so an unclosed run of them does not nest. They are left out of the depth
|
|
141
|
+
// count: a report with thousands of unclosed table cells is sloppy but common markup
|
|
142
|
+
const HTML_IMPLIED_END_ELEMENTS = new Set(['p', 'li', 'dt', 'dd', 'option', 'optgroup', 'tr', 'td', 'th', 'thead', 'tbody', 'tfoot', 'rt', 'rp']);
|
|
143
|
+
|
|
144
|
+
function isTagNameChar(code) {
|
|
145
|
+
return (
|
|
146
|
+
(code >= 0x61 && code <= 0x7a) || (code >= 0x41 && code <= 0x5a) || (code >= 0x30 && code <= 0x39) || code === 0x2d || code === 0x3a || code === 0x5f
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Reports whether `html` nests elements deeper than `maxDepth`, in one linear pass that
|
|
151
|
+
// follows what htmlparser2 builds: a start tag opens an element (a trailing "/" does not
|
|
152
|
+
// close it in HTML mode), an end tag closes down to the nearest open element of that name
|
|
153
|
+
// and is ignored otherwise, comments are skipped. Returns as soon as the limit is crossed.
|
|
154
|
+
function exceedsHtmlNestingDepth(html, maxDepth) {
|
|
155
|
+
let stack = [];
|
|
156
|
+
let open = new Map(); // element name -> how many of them are on the stack
|
|
157
|
+
let len = html.length;
|
|
158
|
+
let i = html.indexOf('<');
|
|
159
|
+
while (i >= 0) {
|
|
160
|
+
let pos = i + 1;
|
|
161
|
+
if (html.startsWith('!--', pos)) {
|
|
162
|
+
let end = html.indexOf('-->', pos + 3);
|
|
163
|
+
i = end < 0 ? -1 : html.indexOf('<', end + 3);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
let closing = html.charCodeAt(pos) === 0x2f; // "/"
|
|
167
|
+
if (closing) {
|
|
168
|
+
pos++;
|
|
169
|
+
}
|
|
170
|
+
let nameStart = pos;
|
|
171
|
+
while (pos < len && isTagNameChar(html.charCodeAt(pos))) {
|
|
172
|
+
pos++;
|
|
173
|
+
}
|
|
174
|
+
if (pos === nameStart) {
|
|
175
|
+
// not a tag, "<" is text here
|
|
176
|
+
i = html.indexOf('<', pos);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
let name = html.slice(nameStart, pos).toLowerCase();
|
|
180
|
+
let end = html.indexOf('>', pos);
|
|
181
|
+
if (end < 0) {
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
if (!HTML_VOID_ELEMENTS.has(name) && !HTML_IMPLIED_END_ELEMENTS.has(name)) {
|
|
185
|
+
if (!closing) {
|
|
186
|
+
stack.push(name);
|
|
187
|
+
open.set(name, (open.get(name) || 0) + 1);
|
|
188
|
+
if (stack.length > maxDepth) {
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
} else if (open.get(name)) {
|
|
192
|
+
let popped;
|
|
193
|
+
do {
|
|
194
|
+
popped = stack.pop();
|
|
195
|
+
open.set(popped, open.get(popped) - 1);
|
|
196
|
+
} while (popped !== name);
|
|
100
197
|
}
|
|
101
|
-
start = -1;
|
|
102
|
-
at = -1;
|
|
103
|
-
} else if (chr === '@' && start >= 0 && at < 0 && i > start + 1) {
|
|
104
|
-
at = i;
|
|
105
198
|
}
|
|
199
|
+
i = html.indexOf('<', end + 1);
|
|
106
200
|
}
|
|
107
201
|
return false;
|
|
108
202
|
}
|
|
@@ -379,12 +473,17 @@ class MailParser extends Transform {
|
|
|
379
473
|
switch (key) {
|
|
380
474
|
case 'content-type':
|
|
381
475
|
case 'content-disposition':
|
|
382
|
-
case 'dkim-signature':
|
|
476
|
+
case 'dkim-signature': {
|
|
477
|
+
// a parameter given in RFC 2231 form is decoded by parseHeaderValue already
|
|
478
|
+
let rfc2231 = rfc2231ParamNames(value);
|
|
383
479
|
value = this.libmime.parseHeaderValue(value);
|
|
384
480
|
if (value.value) {
|
|
385
481
|
value.value = this.libmime.decodeWords(value.value);
|
|
386
482
|
}
|
|
387
483
|
Object.keys((value && value.params) || {}).forEach(key => {
|
|
484
|
+
if (rfc2231.has(key)) {
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
388
487
|
try {
|
|
389
488
|
value.params[key] = this.libmime.decodeWords(value.params[key]);
|
|
390
489
|
} catch (E) {
|
|
@@ -392,6 +491,7 @@ class MailParser extends Transform {
|
|
|
392
491
|
}
|
|
393
492
|
});
|
|
394
493
|
break;
|
|
494
|
+
}
|
|
395
495
|
case 'date': {
|
|
396
496
|
let dateValue = new Date(value);
|
|
397
497
|
if (isNaN(dateValue)) {
|
|
@@ -464,7 +564,12 @@ class MailParser extends Transform {
|
|
|
464
564
|
if (!headers.has(key)) {
|
|
465
565
|
headers.set(key, [].concat(value || []));
|
|
466
566
|
} else if (Array.isArray(value)) {
|
|
467
|
-
|
|
567
|
+
// appended in place: rebuilding the list with concat for every repeated
|
|
568
|
+
// header was quadratic in the number of References lines
|
|
569
|
+
let list = headers.get(key);
|
|
570
|
+
for (let entry of value) {
|
|
571
|
+
list.push(entry);
|
|
572
|
+
}
|
|
468
573
|
} else {
|
|
469
574
|
headers.get(key).push(value);
|
|
470
575
|
}
|
|
@@ -495,7 +600,9 @@ class MailParser extends Transform {
|
|
|
495
600
|
headers.forEach((value, key) => {
|
|
496
601
|
if (Array.isArray(value)) {
|
|
497
602
|
if (singleKeys.includes(key) && value.length) {
|
|
498
|
-
|
|
603
|
+
// the first occurrence: the one mailsplit built the MIME structure from,
|
|
604
|
+
// and the one mail clients display when the header is duplicated
|
|
605
|
+
headers.set(key, value[0]);
|
|
499
606
|
} else if (value.length === 1) {
|
|
500
607
|
headers.set(key, value[0]);
|
|
501
608
|
}
|
|
@@ -593,38 +700,14 @@ class MailParser extends Transform {
|
|
|
593
700
|
}
|
|
594
701
|
|
|
595
702
|
decodeAddresses(addresses) {
|
|
596
|
-
let processedAddress = new WeakSet();
|
|
597
703
|
for (let i = 0; i < addresses.length; i++) {
|
|
598
704
|
let address = addresses[i];
|
|
599
705
|
address.name = (address.name || '').toString().trim();
|
|
600
706
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
// must not be re-interpreted as an address -- RFC 2047 Section 5 prohibits
|
|
606
|
-
// encoded-words in addr-spec, and re-parsing fabricates addresses not present
|
|
607
|
-
// in the original header.
|
|
608
|
-
if (hasBracketedAddress(decoded)) {
|
|
609
|
-
let parsed = addressparser(decoded);
|
|
610
|
-
if (parsed.length) {
|
|
611
|
-
parsed.forEach(entry => {
|
|
612
|
-
processedAddress.add(entry);
|
|
613
|
-
addresses.push(entry);
|
|
614
|
-
});
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
// remove current element
|
|
618
|
-
addresses.splice(i, 1);
|
|
619
|
-
i--;
|
|
620
|
-
continue;
|
|
621
|
-
} else {
|
|
622
|
-
// Treat decoded content as display name only
|
|
623
|
-
address.name = decoded;
|
|
624
|
-
continue;
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
|
|
707
|
+
// An encoded word decodes into the display name and nothing else, whatever it
|
|
708
|
+
// decodes to. RFC 2047 section 5 keeps encoded words out of the addr-spec, so a
|
|
709
|
+
// name that decodes to "CEO" <ceo@bank.com> is still only a name: parsing it
|
|
710
|
+
// again would report an address the header never carried
|
|
628
711
|
if (address.name) {
|
|
629
712
|
try {
|
|
630
713
|
address.name = this.libmime.decodeWords(address.name);
|
|
@@ -762,6 +845,7 @@ class MailParser extends Transform {
|
|
|
762
845
|
getTextContent() {
|
|
763
846
|
let text = [];
|
|
764
847
|
let html = [];
|
|
848
|
+
let textConversionError = null;
|
|
765
849
|
let processNode = (alternative, level, node) => {
|
|
766
850
|
if (node.showMeta) {
|
|
767
851
|
let meta = ['From', 'Subject', 'Date', 'To', 'Cc', 'Bcc']
|
|
@@ -845,27 +929,34 @@ class MailParser extends Transform {
|
|
|
845
929
|
html.push(this.textToHtml(node.textContent));
|
|
846
930
|
}
|
|
847
931
|
} else if (node.contentType === 'text/html') {
|
|
848
|
-
let failedToParseHtml = false;
|
|
849
932
|
if ((!alternative && this.hasText) || (node.root && !this.hasText)) {
|
|
850
933
|
if (this.options.skipHtmlToText) {
|
|
851
934
|
text.push('');
|
|
852
|
-
} else if (node.textContent.length > this.options.maxHtmlLengthToParse) {
|
|
853
|
-
this.emit('error', new Error(`HTML too long for parsing ${node.textContent.length} bytes`));
|
|
854
|
-
text.push('Invalid HTML content (too long)');
|
|
855
|
-
failedToParseHtml = true;
|
|
856
935
|
} else {
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
936
|
+
// HTML that can not be converted is no reason to lose the message:
|
|
937
|
+
// the text stays empty, the HTML is kept and the reason travels with
|
|
938
|
+
// the result as textConversionError. An 'error' event here used to
|
|
939
|
+
// reject the whole simpleParser() promise, headers and attachments
|
|
940
|
+
// included, after burning the CPU the conversion attempt took
|
|
941
|
+
let err = null;
|
|
942
|
+
if (node.textContent.length > this.options.maxHtmlLengthToParse) {
|
|
943
|
+
err = new Error(`HTML too long for parsing ${node.textContent.length} bytes`);
|
|
944
|
+
} else if (exceedsHtmlNestingDepth(node.textContent, MAX_HTML_NESTING_DEPTH)) {
|
|
945
|
+
err = new Error(`HTML nested deeper than ${MAX_HTML_NESTING_DEPTH} levels`);
|
|
946
|
+
} else {
|
|
947
|
+
try {
|
|
948
|
+
text.push(htmlToText(node.textContent, { limits: { maxDepth: MAX_HTML_NESTING_DEPTH } }));
|
|
949
|
+
} catch (cause) {
|
|
950
|
+
err = new Error('Failed to parse HTML', { cause });
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
if (err) {
|
|
954
|
+
text.push('');
|
|
955
|
+
textConversionError = textConversionError || err;
|
|
863
956
|
}
|
|
864
957
|
}
|
|
865
958
|
}
|
|
866
|
-
|
|
867
|
-
html.push(node.textContent);
|
|
868
|
-
}
|
|
959
|
+
html.push(node.textContent);
|
|
869
960
|
}
|
|
870
961
|
}
|
|
871
962
|
alternative = alternative || node.contentType === 'multipart/alternative';
|
|
@@ -888,6 +979,9 @@ class MailParser extends Transform {
|
|
|
888
979
|
this.text = response.text = text.join('\n');
|
|
889
980
|
this.textAsHtml = response.textAsHtml = text.map(part => this.textToHtml(part)).join('<br/>\n');
|
|
890
981
|
}
|
|
982
|
+
if (textConversionError) {
|
|
983
|
+
response.textConversionError = textConversionError;
|
|
984
|
+
}
|
|
891
985
|
return response;
|
|
892
986
|
}
|
|
893
987
|
|
|
@@ -1147,7 +1241,8 @@ class MailParser extends Transform {
|
|
|
1147
1241
|
.map(address => {
|
|
1148
1242
|
let str = '';
|
|
1149
1243
|
if (address.name) {
|
|
1150
|
-
|
|
1244
|
+
// a quoted-string, so a quote or backslash inside the name is escaped
|
|
1245
|
+
str += `"${address.name.replace(/["\\]/g, '\\$&')}"` + (address.group ? ': ' : '');
|
|
1151
1246
|
}
|
|
1152
1247
|
if (address.address) {
|
|
1153
1248
|
let link = address.address;
|
package/lib/simple-parser.js
CHANGED
|
@@ -19,8 +19,8 @@ module.exports = (input, options, callback) => {
|
|
|
19
19
|
});
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
// MailParser may emit a non-fatal 'error'
|
|
23
|
-
//
|
|
22
|
+
// MailParser may emit a non-fatal 'error' and still reach 'end', so settle only once:
|
|
23
|
+
// the first error or the result wins
|
|
24
24
|
let settled = false;
|
|
25
25
|
let settle = callback;
|
|
26
26
|
callback = (...args) => {
|
|
@@ -62,7 +62,7 @@ module.exports = (input, options, callback) => {
|
|
|
62
62
|
|
|
63
63
|
if (data.type === 'text') {
|
|
64
64
|
Object.keys(data).forEach(key => {
|
|
65
|
-
if (['text', 'html', 'textAsHtml'].includes(key)) {
|
|
65
|
+
if (['text', 'html', 'textAsHtml', 'textConversionError'].includes(key)) {
|
|
66
66
|
mail[key] = data[key];
|
|
67
67
|
}
|
|
68
68
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mailparser",
|
|
3
|
-
"version": "3.9.
|
|
3
|
+
"version": "3.9.41",
|
|
4
4
|
"description": "Parse e-mails",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -18,14 +18,14 @@
|
|
|
18
18
|
],
|
|
19
19
|
"license": "MIT",
|
|
20
20
|
"dependencies": {
|
|
21
|
+
"@zone-eu/mailsplit": "5.4.24",
|
|
21
22
|
"encoding-japanese": "2.4.0",
|
|
22
23
|
"he": "1.2.0",
|
|
23
24
|
"html-to-text": "10.0.1",
|
|
24
25
|
"iconv-lite": "0.7.3",
|
|
25
|
-
"libmime": "5.4.
|
|
26
|
+
"libmime": "5.4.9",
|
|
26
27
|
"linkify-it": "5.0.2",
|
|
27
|
-
"
|
|
28
|
-
"nodemailer": "10.1.1",
|
|
28
|
+
"nodemailer": "10.1.2",
|
|
29
29
|
"punycode.js": "2.3.1",
|
|
30
30
|
"tlds": "1.261.0"
|
|
31
31
|
},
|