nodemailer 10.0.4 → 10.0.6
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/CHANGELOG.md +14 -0
- package/dist/cjs/addressparser/index.js +127 -9
- package/dist/cjs/package-info.d.ts +1 -1
- package/dist/cjs/package-info.js +1 -1
- package/dist/esm/addressparser/index.js +127 -9
- package/dist/esm/package-info.d.ts +1 -1
- package/dist/esm/package-info.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
## [10.0.6](https://github.com/nodemailer/nodemailer/compare/v10.0.5...v10.0.6) (2026-09-11)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **addressparser:** scan free text for an address in linear time ([437d7fc](https://github.com/nodemailer/nodemailer/commit/437d7fc47403df176bc39271641541b7a9bce102))
|
|
9
|
+
|
|
10
|
+
## [10.0.5](https://github.com/nodemailer/nodemailer/compare/v10.0.4...v10.0.5) (2026-09-11)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **addressparser:** parse comment-joined addresses in linear time ([c07f175](https://github.com/nodemailer/nodemailer/commit/c07f17518d25aca8ab2ad66968dcbca538c24b89))
|
|
16
|
+
|
|
3
17
|
## [10.0.4](https://github.com/nodemailer/nodemailer/compare/v10.0.3...v10.0.4) (2026-09-11)
|
|
4
18
|
|
|
5
19
|
|
|
@@ -50,6 +50,111 @@ const ADDR_SPEC = /^[^@\s]+@[^@\s]+$/;
|
|
|
50
50
|
* further '@' that a domain should not have but malformed headers carry anyway.
|
|
51
51
|
*/
|
|
52
52
|
const LOOSE_ADDR_SPEC = /^[^@\s]+@\S+$/;
|
|
53
|
+
/**
|
|
54
|
+
* An addr-spec sitting inside free text, together with the whitespace around it. Sticky
|
|
55
|
+
* on purpose: it is run at the one offset _looseAddressStart picks rather than being let
|
|
56
|
+
* loose to search, see there.
|
|
57
|
+
*/
|
|
58
|
+
const LOOSE_TEXT_ADDR = /\s*\b[^@\s]+@[^\s]+\b\s*/y;
|
|
59
|
+
/**
|
|
60
|
+
* The characters JS `\s` matches, which the scan below has to agree with to land on the
|
|
61
|
+
* same match the pattern would.
|
|
62
|
+
*/
|
|
63
|
+
function _isSpaceCode(code) {
|
|
64
|
+
return (code === 0x20 ||
|
|
65
|
+
(code >= 0x09 && code <= 0x0d) ||
|
|
66
|
+
code === 0xa0 ||
|
|
67
|
+
code === 0x1680 ||
|
|
68
|
+
(code >= 0x2000 && code <= 0x200a) ||
|
|
69
|
+
code === 0x2028 ||
|
|
70
|
+
code === 0x2029 ||
|
|
71
|
+
code === 0x202f ||
|
|
72
|
+
code === 0x205f ||
|
|
73
|
+
code === 0x3000 ||
|
|
74
|
+
code === 0xfeff);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The characters JS `\w` matches without the unicode flag, the set the `\b` in
|
|
78
|
+
* LOOSE_TEXT_ADDR is read against. charCodeAt off either end of the string gives NaN,
|
|
79
|
+
* which compares false throughout, so out of range reads as the non-word the pattern
|
|
80
|
+
* treats them as.
|
|
81
|
+
*/
|
|
82
|
+
function _isWordCode(code) {
|
|
83
|
+
return (code >= 0x30 && code <= 0x39) || (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a) || code === 0x5f;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Whether `\b` holds at an offset
|
|
87
|
+
*/
|
|
88
|
+
function _isBoundary(text, at) {
|
|
89
|
+
return _isWordCode(text.charCodeAt(at - 1)) !== _isWordCode(text.charCodeAt(at));
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Finds the offset LOOSE_TEXT_ADDR matches at, or -1 when it does not match at all.
|
|
93
|
+
*
|
|
94
|
+
* Letting the pattern search for itself is quadratic: '[^@\s]+' is retried from every
|
|
95
|
+
* offset and rescans the run to the next '@' each time, so 140KB of header holding no
|
|
96
|
+
* usable '@' blocks the event loop for about ten seconds (GHSA-v53p-9fqp-m79j). The search is also unnecessary.
|
|
97
|
+
* '[^@\s]+' crosses neither whitespace nor a '@', so a match can only begin at the head of
|
|
98
|
+
* a whitespace delimited run or just past a '@' inside one, and '[^\s]+\b' gives characters
|
|
99
|
+
* back until it lands on a boundary, so the only end it can take in that run is the last
|
|
100
|
+
* boundary in it. Both are found in one pass, and the pattern is then run at that single
|
|
101
|
+
* offset.
|
|
102
|
+
*
|
|
103
|
+
* @param text Free text to look in
|
|
104
|
+
* @return Offset to match at, or -1
|
|
105
|
+
*/
|
|
106
|
+
function _looseAddressStart(text) {
|
|
107
|
+
const len = text.length;
|
|
108
|
+
let pos = 0;
|
|
109
|
+
while (pos < len) {
|
|
110
|
+
while (pos < len && _isSpaceCode(text.charCodeAt(pos))) {
|
|
111
|
+
pos++;
|
|
112
|
+
}
|
|
113
|
+
if (pos >= len) {
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
const runStart = pos;
|
|
117
|
+
let runEnd = pos;
|
|
118
|
+
while (runEnd < len && !_isSpaceCode(text.charCodeAt(runEnd))) {
|
|
119
|
+
runEnd++;
|
|
120
|
+
}
|
|
121
|
+
let at = text.indexOf('@', runStart);
|
|
122
|
+
if (at >= 0 && at < runEnd) {
|
|
123
|
+
let lastBoundary = -1;
|
|
124
|
+
for (let k = runEnd; k > runStart; k--) {
|
|
125
|
+
if (_isBoundary(text, k)) {
|
|
126
|
+
lastBoundary = k;
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
let atomStart = runStart;
|
|
131
|
+
while (lastBoundary >= 0 && at >= 0 && at < runEnd) {
|
|
132
|
+
// '[^@\s]+' has to cover a character before the '@' and '[^\s]+' one after it,
|
|
133
|
+
// and the boundary that ends the match has to sit past both
|
|
134
|
+
if (at > atomStart && runEnd > at + 1 && lastBoundary > at + 1) {
|
|
135
|
+
for (let start = atomStart; start < at; start++) {
|
|
136
|
+
if (_isBoundary(text, start)) {
|
|
137
|
+
if (start > runStart) {
|
|
138
|
+
return start;
|
|
139
|
+
}
|
|
140
|
+
// the leading '\s*' is greedy, so a match that begins at the run
|
|
141
|
+
// takes the whitespace in front of it along
|
|
142
|
+
let padded = runStart;
|
|
143
|
+
while (padded > 0 && _isSpaceCode(text.charCodeAt(padded - 1))) {
|
|
144
|
+
padded--;
|
|
145
|
+
}
|
|
146
|
+
return padded;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
atomStart = at + 1;
|
|
151
|
+
at = text.indexOf('@', atomStart);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
pos = runEnd;
|
|
155
|
+
}
|
|
156
|
+
return -1;
|
|
157
|
+
}
|
|
53
158
|
/**
|
|
54
159
|
* Recovers the addr-spec from an angle-addr that came back holding unquoted whitespace.
|
|
55
160
|
*
|
|
@@ -134,6 +239,12 @@ function _handleAddress(tokens, depth) {
|
|
|
134
239
|
textWasQuoted: []
|
|
135
240
|
};
|
|
136
241
|
let insideQuotes = false;
|
|
242
|
+
// Last character of the run each state is currently accumulating. Reading it back off
|
|
243
|
+
// the accumulator with slice(-1) makes the engine flatten the whole growing string on
|
|
244
|
+
// every token, which is quadratic over an address built from many comment-joined atoms
|
|
245
|
+
// (GHSA-prgh-xp8r-p3m5). A run only ever grows by the token appended below, so the
|
|
246
|
+
// character is carried along instead of re-read.
|
|
247
|
+
const lastChars = { address: '', comment: '', group: '', text: '' };
|
|
137
248
|
// Filter out <addresses>, (comments) and regular text
|
|
138
249
|
for (let i = 0, len = tokens.length; i < len; i++) {
|
|
139
250
|
const token = tokens[i];
|
|
@@ -177,15 +288,19 @@ function _handleAddress(tokens, depth) {
|
|
|
177
288
|
const joins = prevToken &&
|
|
178
289
|
prevToken.noBreak &&
|
|
179
290
|
parts.length &&
|
|
180
|
-
(prevToken.value !== ')' ||
|
|
291
|
+
(prevToken.value !== ')' || lastChars[state] === '@' || token.value.charAt(0) === '@');
|
|
181
292
|
if (joins) {
|
|
182
293
|
data[state][data[state].length - 1] += token.value;
|
|
294
|
+
if (token.value) {
|
|
295
|
+
lastChars[state] = token.value.charAt(token.value.length - 1);
|
|
296
|
+
}
|
|
183
297
|
if (state === 'text' && insideQuotes) {
|
|
184
298
|
data.textWasQuoted[data.textWasQuoted.length - 1] = true;
|
|
185
299
|
}
|
|
186
300
|
}
|
|
187
301
|
else {
|
|
188
302
|
data[state].push(token.value);
|
|
303
|
+
lastChars[state] = token.value.charAt(token.value.length - 1);
|
|
189
304
|
if (state === 'text') {
|
|
190
305
|
data.textWasQuoted.push(insideQuotes);
|
|
191
306
|
}
|
|
@@ -237,16 +352,19 @@ function _handleAddress(tokens, depth) {
|
|
|
237
352
|
for (let i = data.text.length - 1; i >= 0; i--) {
|
|
238
353
|
// Security: Do not extract email addresses from quoted strings
|
|
239
354
|
if (!data.textWasQuoted[i]) {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
355
|
+
const part = data.text[i];
|
|
356
|
+
let remainder = part;
|
|
357
|
+
const at = _looseAddressStart(part);
|
|
358
|
+
if (at >= 0) {
|
|
359
|
+
LOOSE_TEXT_ADDR.lastIndex = at;
|
|
360
|
+
const match = LOOSE_TEXT_ADDR.exec(part);
|
|
361
|
+
if (match) {
|
|
362
|
+
data.address = [match[0].trim()];
|
|
244
363
|
extracted = true;
|
|
245
|
-
|
|
364
|
+
remainder = part.slice(0, at) + ' ' + part.slice(at + match[0].length);
|
|
246
365
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
.trim();
|
|
366
|
+
}
|
|
367
|
+
data.text[i] = remainder.trim();
|
|
250
368
|
if (extracted) {
|
|
251
369
|
break;
|
|
252
370
|
}
|
package/dist/cjs/package-info.js
CHANGED
|
@@ -47,6 +47,111 @@ const ADDR_SPEC = /^[^@\s]+@[^@\s]+$/;
|
|
|
47
47
|
* further '@' that a domain should not have but malformed headers carry anyway.
|
|
48
48
|
*/
|
|
49
49
|
const LOOSE_ADDR_SPEC = /^[^@\s]+@\S+$/;
|
|
50
|
+
/**
|
|
51
|
+
* An addr-spec sitting inside free text, together with the whitespace around it. Sticky
|
|
52
|
+
* on purpose: it is run at the one offset _looseAddressStart picks rather than being let
|
|
53
|
+
* loose to search, see there.
|
|
54
|
+
*/
|
|
55
|
+
const LOOSE_TEXT_ADDR = /\s*\b[^@\s]+@[^\s]+\b\s*/y;
|
|
56
|
+
/**
|
|
57
|
+
* The characters JS `\s` matches, which the scan below has to agree with to land on the
|
|
58
|
+
* same match the pattern would.
|
|
59
|
+
*/
|
|
60
|
+
function _isSpaceCode(code) {
|
|
61
|
+
return (code === 0x20 ||
|
|
62
|
+
(code >= 0x09 && code <= 0x0d) ||
|
|
63
|
+
code === 0xa0 ||
|
|
64
|
+
code === 0x1680 ||
|
|
65
|
+
(code >= 0x2000 && code <= 0x200a) ||
|
|
66
|
+
code === 0x2028 ||
|
|
67
|
+
code === 0x2029 ||
|
|
68
|
+
code === 0x202f ||
|
|
69
|
+
code === 0x205f ||
|
|
70
|
+
code === 0x3000 ||
|
|
71
|
+
code === 0xfeff);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The characters JS `\w` matches without the unicode flag, the set the `\b` in
|
|
75
|
+
* LOOSE_TEXT_ADDR is read against. charCodeAt off either end of the string gives NaN,
|
|
76
|
+
* which compares false throughout, so out of range reads as the non-word the pattern
|
|
77
|
+
* treats them as.
|
|
78
|
+
*/
|
|
79
|
+
function _isWordCode(code) {
|
|
80
|
+
return (code >= 0x30 && code <= 0x39) || (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a) || code === 0x5f;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Whether `\b` holds at an offset
|
|
84
|
+
*/
|
|
85
|
+
function _isBoundary(text, at) {
|
|
86
|
+
return _isWordCode(text.charCodeAt(at - 1)) !== _isWordCode(text.charCodeAt(at));
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Finds the offset LOOSE_TEXT_ADDR matches at, or -1 when it does not match at all.
|
|
90
|
+
*
|
|
91
|
+
* Letting the pattern search for itself is quadratic: '[^@\s]+' is retried from every
|
|
92
|
+
* offset and rescans the run to the next '@' each time, so 140KB of header holding no
|
|
93
|
+
* usable '@' blocks the event loop for about ten seconds (GHSA-v53p-9fqp-m79j). The search is also unnecessary.
|
|
94
|
+
* '[^@\s]+' crosses neither whitespace nor a '@', so a match can only begin at the head of
|
|
95
|
+
* a whitespace delimited run or just past a '@' inside one, and '[^\s]+\b' gives characters
|
|
96
|
+
* back until it lands on a boundary, so the only end it can take in that run is the last
|
|
97
|
+
* boundary in it. Both are found in one pass, and the pattern is then run at that single
|
|
98
|
+
* offset.
|
|
99
|
+
*
|
|
100
|
+
* @param text Free text to look in
|
|
101
|
+
* @return Offset to match at, or -1
|
|
102
|
+
*/
|
|
103
|
+
function _looseAddressStart(text) {
|
|
104
|
+
const len = text.length;
|
|
105
|
+
let pos = 0;
|
|
106
|
+
while (pos < len) {
|
|
107
|
+
while (pos < len && _isSpaceCode(text.charCodeAt(pos))) {
|
|
108
|
+
pos++;
|
|
109
|
+
}
|
|
110
|
+
if (pos >= len) {
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
const runStart = pos;
|
|
114
|
+
let runEnd = pos;
|
|
115
|
+
while (runEnd < len && !_isSpaceCode(text.charCodeAt(runEnd))) {
|
|
116
|
+
runEnd++;
|
|
117
|
+
}
|
|
118
|
+
let at = text.indexOf('@', runStart);
|
|
119
|
+
if (at >= 0 && at < runEnd) {
|
|
120
|
+
let lastBoundary = -1;
|
|
121
|
+
for (let k = runEnd; k > runStart; k--) {
|
|
122
|
+
if (_isBoundary(text, k)) {
|
|
123
|
+
lastBoundary = k;
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
let atomStart = runStart;
|
|
128
|
+
while (lastBoundary >= 0 && at >= 0 && at < runEnd) {
|
|
129
|
+
// '[^@\s]+' has to cover a character before the '@' and '[^\s]+' one after it,
|
|
130
|
+
// and the boundary that ends the match has to sit past both
|
|
131
|
+
if (at > atomStart && runEnd > at + 1 && lastBoundary > at + 1) {
|
|
132
|
+
for (let start = atomStart; start < at; start++) {
|
|
133
|
+
if (_isBoundary(text, start)) {
|
|
134
|
+
if (start > runStart) {
|
|
135
|
+
return start;
|
|
136
|
+
}
|
|
137
|
+
// the leading '\s*' is greedy, so a match that begins at the run
|
|
138
|
+
// takes the whitespace in front of it along
|
|
139
|
+
let padded = runStart;
|
|
140
|
+
while (padded > 0 && _isSpaceCode(text.charCodeAt(padded - 1))) {
|
|
141
|
+
padded--;
|
|
142
|
+
}
|
|
143
|
+
return padded;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
atomStart = at + 1;
|
|
148
|
+
at = text.indexOf('@', atomStart);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
pos = runEnd;
|
|
152
|
+
}
|
|
153
|
+
return -1;
|
|
154
|
+
}
|
|
50
155
|
/**
|
|
51
156
|
* Recovers the addr-spec from an angle-addr that came back holding unquoted whitespace.
|
|
52
157
|
*
|
|
@@ -131,6 +236,12 @@ function _handleAddress(tokens, depth) {
|
|
|
131
236
|
textWasQuoted: []
|
|
132
237
|
};
|
|
133
238
|
let insideQuotes = false;
|
|
239
|
+
// Last character of the run each state is currently accumulating. Reading it back off
|
|
240
|
+
// the accumulator with slice(-1) makes the engine flatten the whole growing string on
|
|
241
|
+
// every token, which is quadratic over an address built from many comment-joined atoms
|
|
242
|
+
// (GHSA-prgh-xp8r-p3m5). A run only ever grows by the token appended below, so the
|
|
243
|
+
// character is carried along instead of re-read.
|
|
244
|
+
const lastChars = { address: '', comment: '', group: '', text: '' };
|
|
134
245
|
// Filter out <addresses>, (comments) and regular text
|
|
135
246
|
for (let i = 0, len = tokens.length; i < len; i++) {
|
|
136
247
|
const token = tokens[i];
|
|
@@ -174,15 +285,19 @@ function _handleAddress(tokens, depth) {
|
|
|
174
285
|
const joins = prevToken &&
|
|
175
286
|
prevToken.noBreak &&
|
|
176
287
|
parts.length &&
|
|
177
|
-
(prevToken.value !== ')' ||
|
|
288
|
+
(prevToken.value !== ')' || lastChars[state] === '@' || token.value.charAt(0) === '@');
|
|
178
289
|
if (joins) {
|
|
179
290
|
data[state][data[state].length - 1] += token.value;
|
|
291
|
+
if (token.value) {
|
|
292
|
+
lastChars[state] = token.value.charAt(token.value.length - 1);
|
|
293
|
+
}
|
|
180
294
|
if (state === 'text' && insideQuotes) {
|
|
181
295
|
data.textWasQuoted[data.textWasQuoted.length - 1] = true;
|
|
182
296
|
}
|
|
183
297
|
}
|
|
184
298
|
else {
|
|
185
299
|
data[state].push(token.value);
|
|
300
|
+
lastChars[state] = token.value.charAt(token.value.length - 1);
|
|
186
301
|
if (state === 'text') {
|
|
187
302
|
data.textWasQuoted.push(insideQuotes);
|
|
188
303
|
}
|
|
@@ -234,16 +349,19 @@ function _handleAddress(tokens, depth) {
|
|
|
234
349
|
for (let i = data.text.length - 1; i >= 0; i--) {
|
|
235
350
|
// Security: Do not extract email addresses from quoted strings
|
|
236
351
|
if (!data.textWasQuoted[i]) {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
352
|
+
const part = data.text[i];
|
|
353
|
+
let remainder = part;
|
|
354
|
+
const at = _looseAddressStart(part);
|
|
355
|
+
if (at >= 0) {
|
|
356
|
+
LOOSE_TEXT_ADDR.lastIndex = at;
|
|
357
|
+
const match = LOOSE_TEXT_ADDR.exec(part);
|
|
358
|
+
if (match) {
|
|
359
|
+
data.address = [match[0].trim()];
|
|
241
360
|
extracted = true;
|
|
242
|
-
|
|
361
|
+
remainder = part.slice(0, at) + ' ' + part.slice(at + match[0].length);
|
|
243
362
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
.trim();
|
|
363
|
+
}
|
|
364
|
+
data.text[i] = remainder.trim();
|
|
247
365
|
if (extracted) {
|
|
248
366
|
break;
|
|
249
367
|
}
|
package/dist/esm/package-info.js
CHANGED