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 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 !== ')' || parts[parts.length - 1].slice(-1) === '@' || token.value.charAt(0) === '@');
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
- data.text[i] = data.text[i]
241
- .replace(/\s*\b[^@\s]+@[^\s]+\b\s*/, (match) => {
242
- if (!extracted) {
243
- data.address = [match.trim()];
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
- return ' ';
364
+ remainder = part.slice(0, at) + ' ' + part.slice(at + match[0].length);
246
365
  }
247
- return match;
248
- })
249
- .trim();
366
+ }
367
+ data.text[i] = remainder.trim();
250
368
  if (extracted) {
251
369
  break;
252
370
  }
@@ -1,3 +1,3 @@
1
1
  export declare const name = "nodemailer";
2
- export declare const version = "10.0.4";
2
+ export declare const version = "10.0.6";
3
3
  export declare const homepage = "https://nodemailer.com/";
@@ -3,5 +3,5 @@
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.homepage = exports.version = exports.name = void 0;
5
5
  exports.name = 'nodemailer';
6
- exports.version = '10.0.4';
6
+ exports.version = '10.0.6';
7
7
  exports.homepage = 'https://nodemailer.com/';
@@ -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 !== ')' || parts[parts.length - 1].slice(-1) === '@' || token.value.charAt(0) === '@');
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
- data.text[i] = data.text[i]
238
- .replace(/\s*\b[^@\s]+@[^\s]+\b\s*/, (match) => {
239
- if (!extracted) {
240
- data.address = [match.trim()];
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
- return ' ';
361
+ remainder = part.slice(0, at) + ' ' + part.slice(at + match[0].length);
243
362
  }
244
- return match;
245
- })
246
- .trim();
363
+ }
364
+ data.text[i] = remainder.trim();
247
365
  if (extracted) {
248
366
  break;
249
367
  }
@@ -1,3 +1,3 @@
1
1
  export declare const name = "nodemailer";
2
- export declare const version = "10.0.4";
2
+ export declare const version = "10.0.6";
3
3
  export declare const homepage = "https://nodemailer.com/";
@@ -1,4 +1,4 @@
1
1
  // Generated by scripts/build.js from package.json. Do not edit by hand.
2
2
  export const name = 'nodemailer';
3
- export const version = '10.0.4';
3
+ export const version = '10.0.6';
4
4
  export const homepage = 'https://nodemailer.com/';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodemailer",
3
- "version": "10.0.4",
3
+ "version": "10.0.6",
4
4
  "description": "Easy as cake e-mail sending from your Node.js applications",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/nodemailer.js",