nodemailer 10.0.8 → 10.0.10
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 +17 -0
- package/dist/cjs/addressparser/index.js +53 -8
- package/dist/cjs/dkim/message-parser.js +12 -5
- package/dist/cjs/mail-composer/index.js +3 -1
- package/dist/cjs/mailer/mail-message.js +2 -1
- package/dist/cjs/package-info.d.ts +1 -1
- package/dist/cjs/package-info.js +1 -1
- package/dist/cjs/smtp-connection/index.d.ts +3 -0
- package/dist/cjs/smtp-connection/index.js +62 -18
- package/dist/esm/addressparser/index.js +53 -8
- package/dist/esm/dkim/message-parser.js +12 -5
- package/dist/esm/mail-composer/index.js +3 -1
- package/dist/esm/mailer/mail-message.js +2 -1
- package/dist/esm/package-info.d.ts +1 -1
- package/dist/esm/package-info.js +1 -1
- package/dist/esm/smtp-connection/index.d.ts +3 -0
- package/dist/esm/smtp-connection/index.js +62 -18
- package/package.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
## [10.0.10](https://github.com/nodemailer/nodemailer/compare/v10.0.9...v10.0.10) (2026-09-14)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* derive the attachment filename from the basename of a Windows path ([c7cc7ce](https://github.com/nodemailer/nodemailer/commit/c7cc7ce41a3602441747476a2a2c4a8ff466a83e))
|
|
9
|
+
* **dkim:** unfold folded header lines in linear time ([28a5909](https://github.com/nodemailer/nodemailer/commit/28a5909cec27646cb001dc7e97a8f5d26c973078))
|
|
10
|
+
* **smtp-connection:** reassemble multiline replies in linear time ([f2d82fa](https://github.com/nodemailer/nodemailer/commit/f2d82fa84d47015a7bbb4253da61eeb778c658b1))
|
|
11
|
+
|
|
12
|
+
## [10.0.9](https://github.com/nodemailer/nodemailer/compare/v10.0.8...v10.0.9) (2026-09-12)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
* **addressparser:** bound the '@' probe to the run being scanned ([1465c3f](https://github.com/nodemailer/nodemailer/commit/1465c3f5ff74a7c4fbbe9853bd01448bb92bf5b7))
|
|
18
|
+
* **addressparser:** keep the text after a comment out of a quoted local part address ([2f36eb1](https://github.com/nodemailer/nodemailer/commit/2f36eb1aa1dd33e312411dc9b888548e14db54ee))
|
|
19
|
+
|
|
3
20
|
## [10.0.8](https://github.com/nodemailer/nodemailer/compare/v10.0.7...v10.0.8) (2026-09-11)
|
|
4
21
|
|
|
5
22
|
|
|
@@ -88,6 +88,29 @@ function _isWordCode(code) {
|
|
|
88
88
|
function _isBoundary(text, at) {
|
|
89
89
|
return _isWordCode(text.charCodeAt(at - 1)) !== _isWordCode(text.charCodeAt(at));
|
|
90
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Offset of the first '@' in `text` between `from` and `to`, or -1 when the range holds none.
|
|
93
|
+
*
|
|
94
|
+
* indexOf would scan on to the end of the value, and the value here is a whole header. The
|
|
95
|
+
* walk below steps one whitespace delimited run at a time and only ever uses a '@' that sits
|
|
96
|
+
* inside the run it is on, so an unbounded probe rescans everything behind that run once per
|
|
97
|
+
* run and grows with the square of the header: 400KB of free text carrying no '@' took a
|
|
98
|
+
* quarter of a second. GHSA-v53p-9fqp-m79j took the pattern search out of this walk and left
|
|
99
|
+
* the probe unbounded behind it.
|
|
100
|
+
*
|
|
101
|
+
* @param text Text to look in
|
|
102
|
+
* @param from Offset to start at
|
|
103
|
+
* @param to Offset to stop before
|
|
104
|
+
* @return Offset of the '@', or -1
|
|
105
|
+
*/
|
|
106
|
+
function _indexOfAt(text, from, to) {
|
|
107
|
+
for (let i = from; i < to; i++) {
|
|
108
|
+
if (text.charCodeAt(i) === 0x40) {
|
|
109
|
+
return i;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return -1;
|
|
113
|
+
}
|
|
91
114
|
/**
|
|
92
115
|
* Finds the offset LOOSE_TEXT_ADDR matches at, or -1 when it does not match at all.
|
|
93
116
|
*
|
|
@@ -118,8 +141,8 @@ function _looseAddressStart(text) {
|
|
|
118
141
|
while (runEnd < len && !_isSpaceCode(text.charCodeAt(runEnd))) {
|
|
119
142
|
runEnd++;
|
|
120
143
|
}
|
|
121
|
-
let at = text
|
|
122
|
-
if (at >= 0
|
|
144
|
+
let at = _indexOfAt(text, runStart, runEnd);
|
|
145
|
+
if (at >= 0) {
|
|
123
146
|
let lastBoundary = -1;
|
|
124
147
|
for (let k = runEnd; k > runStart; k--) {
|
|
125
148
|
if (_isBoundary(text, k)) {
|
|
@@ -128,7 +151,7 @@ function _looseAddressStart(text) {
|
|
|
128
151
|
}
|
|
129
152
|
}
|
|
130
153
|
let atomStart = runStart;
|
|
131
|
-
while (lastBoundary >= 0 && at >= 0
|
|
154
|
+
while (lastBoundary >= 0 && at >= 0) {
|
|
132
155
|
// '[^@\s]+' has to cover a character before the '@' and '[^\s]+' one after it,
|
|
133
156
|
// and the boundary that ends the match has to sit past both
|
|
134
157
|
if (at > atomStart && runEnd > at + 1 && lastBoundary > at + 1) {
|
|
@@ -148,7 +171,7 @@ function _looseAddressStart(text) {
|
|
|
148
171
|
}
|
|
149
172
|
}
|
|
150
173
|
atomStart = at + 1;
|
|
151
|
-
at = text
|
|
174
|
+
at = _indexOfAt(text, atomStart, runEnd);
|
|
152
175
|
}
|
|
153
176
|
}
|
|
154
177
|
pos = runEnd;
|
|
@@ -275,6 +298,19 @@ function _handleAddress(tokens, depth) {
|
|
|
275
298
|
}
|
|
276
299
|
}
|
|
277
300
|
else if (token.value) {
|
|
301
|
+
// An empty quoted string is dropped by the tokenizer, leaving no text token of its
|
|
302
|
+
// own for textWasQuoted to be recorded on, so the pair of quote operators right in
|
|
303
|
+
// front of this token is all that is left of it and the run it opens carries the
|
|
304
|
+
// quoting instead. Without this '""@example.com' reads as the bare '@example.com',
|
|
305
|
+
// the quotes never go back on, and the value is no longer an addr-spec a trailing
|
|
306
|
+
// comment can be peeled off of. It only ever opens a run: a run that already holds
|
|
307
|
+
// material collected outside the quotes is not a quoted string, whatever follows it
|
|
308
|
+
const prevPrevToken = i > 1 ? tokens[i - 2] : null;
|
|
309
|
+
const opensAfterEmptyQuotedString = prevToken?.type === 'operator' &&
|
|
310
|
+
prevToken.value === '"' &&
|
|
311
|
+
!!prevToken.noBreak &&
|
|
312
|
+
prevPrevToken?.type === 'operator' &&
|
|
313
|
+
prevPrevToken.value === '"';
|
|
278
314
|
if (state === 'address') {
|
|
279
315
|
// Handle unquoted name that includes a "<".
|
|
280
316
|
// Apple Mail truncates everything between an unexpected < and an address.
|
|
@@ -302,7 +338,7 @@ function _handleAddress(tokens, depth) {
|
|
|
302
338
|
data[state].push(token.value);
|
|
303
339
|
lastChars[state] = token.value.charAt(token.value.length - 1);
|
|
304
340
|
if (state === 'text') {
|
|
305
|
-
data.textWasQuoted.push(insideQuotes);
|
|
341
|
+
data.textWasQuoted.push(insideQuotes || opensAfterEmptyQuotedString);
|
|
306
342
|
}
|
|
307
343
|
}
|
|
308
344
|
}
|
|
@@ -387,6 +423,18 @@ function _handleAddress(tokens, depth) {
|
|
|
387
423
|
// Join values with spaces
|
|
388
424
|
data.text = data.text.join(' ');
|
|
389
425
|
data.address = data.address.join(' ');
|
|
426
|
+
if (addressFromQuotedText && data.text) {
|
|
427
|
+
// The mailbox is still sitting in the text, so it moves over here and is quoted
|
|
428
|
+
// before the recovery below rather than after it. Anything else the text holds
|
|
429
|
+
// came along with it: a comment ends the domain but leaves the atoms behind it in
|
|
430
|
+
// the same text, and '"user"@example.com(x)evil.com' was handed on as the address
|
|
431
|
+
// 'user@example.com evil.com', a second domain riding into the envelope recipient
|
|
432
|
+
// on a value that is no addr-spec at all (GHSA-g57g-f23g-4646). Putting the quotes
|
|
433
|
+
// back first is what lets the recovery tell the whitespace an addr-spec may carry
|
|
434
|
+
// from the wreckage trailing one, as only a quoted local part may hold whitespace
|
|
435
|
+
data.address = _quoteLocalPart(data.text);
|
|
436
|
+
data.text = '';
|
|
437
|
+
}
|
|
390
438
|
_recoverAddrSpec(data);
|
|
391
439
|
const address = {
|
|
392
440
|
address: data.address || data.text || '',
|
|
@@ -400,9 +448,6 @@ function _handleAddress(tokens, depth) {
|
|
|
400
448
|
address.address = '';
|
|
401
449
|
}
|
|
402
450
|
}
|
|
403
|
-
if (addressFromQuotedText && address.address) {
|
|
404
|
-
address.address = _quoteLocalPart(address.address);
|
|
405
|
-
}
|
|
406
451
|
addresses.push(address);
|
|
407
452
|
}
|
|
408
453
|
return addresses;
|
|
@@ -126,11 +126,18 @@ class MessageParser extends node_stream_1.Transform {
|
|
|
126
126
|
// signature covers exactly the bytes the receiving side canonicalizes
|
|
127
127
|
// Only SP and HTAB fold a line, and only they are trimmed from the field name, the
|
|
128
128
|
// same whitespace the relaxed canonicalization in sign.ts works with
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
129
|
+
const rawLines = (this.rawHeaders || Buffer.alloc(0)).toString('binary').split(/\r?\n/);
|
|
130
|
+
// Unfold in a single forward pass and only ever test a freshly split line for the
|
|
131
|
+
// continuation prefix. Testing an already merged line instead would rescan a string
|
|
132
|
+
// that grows with every continuation line, so a header folded into many continuation
|
|
133
|
+
// lines (a large recipient list, for example) would take quadratic time to unfold
|
|
134
|
+
const lines = [];
|
|
135
|
+
for (const rawLine of rawLines) {
|
|
136
|
+
if (lines.length && /^[ \t]/.test(rawLine)) {
|
|
137
|
+
lines[lines.length - 1] += '\n' + rawLine;
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
lines.push(rawLine);
|
|
134
141
|
}
|
|
135
142
|
}
|
|
136
143
|
return lines
|
|
@@ -151,8 +151,10 @@ class MailComposer {
|
|
|
151
151
|
data.filename = attachment.filename;
|
|
152
152
|
}
|
|
153
153
|
else if (!isMessageNode && attachment.filename !== false) {
|
|
154
|
+
// a backslash separates as well, so a Windows path does not put the sender's directories in the headers
|
|
154
155
|
data.filename =
|
|
155
|
-
(attachment.path || attachment.href || '').split(
|
|
156
|
+
(attachment.path || attachment.href || '').split(/[/\\]/).pop().split('?').shift() ||
|
|
157
|
+
'attachment-' + (i + 1);
|
|
156
158
|
if (data.filename.indexOf('.') < 0) {
|
|
157
159
|
data.filename += '.' + mimeFuncs.detectExtension(data.contentType);
|
|
158
160
|
}
|
|
@@ -107,8 +107,9 @@ class MailMessage {
|
|
|
107
107
|
if (this.data.attachments && this.data.attachments.length) {
|
|
108
108
|
this.data.attachments.forEach((attachment, i) => {
|
|
109
109
|
if (!attachment.filename) {
|
|
110
|
+
// a backslash separates as well, so a Windows path does not put the sender's directories in the headers
|
|
110
111
|
attachment.filename =
|
|
111
|
-
(attachment.path || attachment.href || '').split(
|
|
112
|
+
(attachment.path || attachment.href || '').split(/[/\\]/).pop().split('?').shift() ||
|
|
112
113
|
'attachment-' + (i + 1);
|
|
113
114
|
if (attachment.filename.indexOf('.') < 0) {
|
|
114
115
|
attachment.filename += '.' + mimeFuncs.detectExtension(attachment.contentType);
|
package/dist/cjs/package-info.js
CHANGED
|
@@ -41,6 +41,8 @@ export interface SMTPConnectionOptions {
|
|
|
41
41
|
greetingTimeout?: number | undefined;
|
|
42
42
|
/** Time of inactivity in ms until the connection is closed, defaults to 10 minutes */
|
|
43
43
|
socketTimeout?: number | undefined;
|
|
44
|
+
/** Largest single server response to accept in bytes, defaults to 1 MB */
|
|
45
|
+
maxResponseSize?: number | undefined;
|
|
44
46
|
/** Time to wait in ms for the DNS requests to be resolved, defaults to 30 seconds */
|
|
45
47
|
dnsTimeout?: number | undefined;
|
|
46
48
|
/** Use LMTP instead of SMTP */
|
|
@@ -268,6 +270,7 @@ export type SMTPConnectionResponseAction = (str: string) => void;
|
|
|
268
270
|
* * **greetingTimeout** - Time to wait in ms until greeting message is received from the server (defaults to 30 seconds)
|
|
269
271
|
* * **connectionTimeout** - how many milliseconds to wait for the connection to establish (defaults to 2 minutes)
|
|
270
272
|
* * **socketTimeout** - Time of inactivity until the connection is closed (defaults to 10 minutes)
|
|
273
|
+
* * **maxResponseSize** - Largest single server response to accept in bytes (defaults to 1 MB)
|
|
271
274
|
* * **dnsTimeout** - Time to wait in ms for the DNS requests to be resolved (defaults to 30 seconds)
|
|
272
275
|
* * **lmtp** - if true, uses LMTP instead of SMTP protocol
|
|
273
276
|
* * **logger** - bunyan compatible logger interface
|
|
@@ -51,6 +51,9 @@ const SOCKET_TIMEOUT = 10 * 60 * 1000; // how much to wait for socket inactivity
|
|
|
51
51
|
const GREETING_TIMEOUT = 30 * 1000; // how much to wait after connection is established but SMTP greeting is not receieved
|
|
52
52
|
const DNS_TIMEOUT = 30 * 1000; // how much to wait for resolveHostname
|
|
53
53
|
const TEARDOWN_NOOP = () => { }; // reusable no-op handler for absorbing errors during socket teardown
|
|
54
|
+
// how many bytes a single server response may occupy while it is still being received.
|
|
55
|
+
// Generous compared to any real reply, it only stops a peer that never completes one
|
|
56
|
+
const MAX_RESPONSE_SIZE = 1024 * 1024;
|
|
54
57
|
/**
|
|
55
58
|
* Re-interpret a server response stored in fake 8-bit byte-container form
|
|
56
59
|
* (the result of chunk.toString('binary') in _onData) as UTF-8.
|
|
@@ -79,11 +82,19 @@ function decodeServerResponse(str) {
|
|
|
79
82
|
* Called with the byte-container form the queue holds (see _onData): the check only looks
|
|
80
83
|
* at leading ASCII digits and '-' of the last line, and a UTF-8 continuation byte is never
|
|
81
84
|
* 0x0A, so line boundaries and the tested prefix are the same before and after decoding.
|
|
82
|
-
* The last line is read with lastIndexOf rather than split() because a queue entry
|
|
83
|
-
*
|
|
85
|
+
* The last line is read with lastIndexOf rather than split() because a queue entry may hold
|
|
86
|
+
* a whole multiline reply and only its final line matters.
|
|
84
87
|
*/
|
|
85
88
|
function isPartialResponse(str) {
|
|
86
|
-
return
|
|
89
|
+
return isPartialLine(str.slice(str.lastIndexOf('\n') + 1));
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* True when a single reply line is a continuation ("250-..."). Used where the line is
|
|
93
|
+
* already known to be one line, which skips the scan isPartialResponse needs to find the
|
|
94
|
+
* last line of a whole reply.
|
|
95
|
+
*/
|
|
96
|
+
function isPartialLine(line) {
|
|
97
|
+
return /^\d+-/.test(line);
|
|
87
98
|
}
|
|
88
99
|
/**
|
|
89
100
|
* Generates a SMTP connection object
|
|
@@ -100,6 +111,7 @@ function isPartialResponse(str) {
|
|
|
100
111
|
* * **greetingTimeout** - Time to wait in ms until greeting message is received from the server (defaults to 30 seconds)
|
|
101
112
|
* * **connectionTimeout** - how many milliseconds to wait for the connection to establish (defaults to 2 minutes)
|
|
102
113
|
* * **socketTimeout** - Time of inactivity until the connection is closed (defaults to 10 minutes)
|
|
114
|
+
* * **maxResponseSize** - Largest single server response to accept in bytes (defaults to 1 MB)
|
|
103
115
|
* * **dnsTimeout** - Time to wait in ms for the DNS requests to be resolved (defaults to 30 seconds)
|
|
104
116
|
* * **lmtp** - if true, uses LMTP instead of SMTP protocol
|
|
105
117
|
* * **logger** - bunyan compatible logger interface
|
|
@@ -146,6 +158,7 @@ class SMTPConnection extends node_events_1.EventEmitter {
|
|
|
146
158
|
this.secure = !!this.secureConnection;
|
|
147
159
|
this._remainder = '';
|
|
148
160
|
this._responseQueue = [];
|
|
161
|
+
this._responsePartial = false;
|
|
149
162
|
this.lastServerResponse = false;
|
|
150
163
|
this._socket = false;
|
|
151
164
|
this._supportedAuth = [];
|
|
@@ -714,28 +727,58 @@ class SMTPConnection extends node_events_1.EventEmitter {
|
|
|
714
727
|
if (this._destroyed || !chunk || !chunk.length) {
|
|
715
728
|
return;
|
|
716
729
|
}
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
730
|
+
const maxResponseSize = this.options.maxResponseSize || MAX_RESPONSE_SIZE;
|
|
731
|
+
const data = chunk.toString('binary');
|
|
732
|
+
// A chunk without a line break only extends the line currently being received, so
|
|
733
|
+
// keep it in the remainder and leave that string unflattened. Splitting the whole
|
|
734
|
+
// remainder again on every chunk would rescan everything buffered for that line so
|
|
735
|
+
// far, which is quadratic in the length of a line the peer never terminates
|
|
736
|
+
if (!data.includes('\n')) {
|
|
737
|
+
this._remainder += data;
|
|
738
|
+
if (this._remainder.length > maxResponseSize) {
|
|
739
|
+
return this._onResponseTooLarge();
|
|
740
|
+
}
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
const lines = (this._remainder + data).split(/\r?\n/);
|
|
720
744
|
this._remainder = lines.pop();
|
|
721
745
|
for (let i = 0, len = lines.length; i < len; i++) {
|
|
722
|
-
if (this.
|
|
723
|
-
|
|
724
|
-
if (isPartialResponse(lastline)) {
|
|
725
|
-
this._responseQueue[this._responseQueue.length - 1] += '\n' + lines[i];
|
|
726
|
-
continue;
|
|
727
|
-
}
|
|
746
|
+
if (this._responsePartial) {
|
|
747
|
+
this._responseQueue[this._responseQueue.length - 1] += '\n' + lines[i];
|
|
728
748
|
}
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
if (this._responseQueue.length) {
|
|
732
|
-
lastline = this._responseQueue[this._responseQueue.length - 1];
|
|
733
|
-
if (isPartialResponse(lastline)) {
|
|
734
|
-
return;
|
|
749
|
+
else {
|
|
750
|
+
this._responseQueue.push(lines[i]);
|
|
735
751
|
}
|
|
752
|
+
// The line just added is the last line of that queue entry, so it alone decides
|
|
753
|
+
// whether the reply is still partial. Looking for the last line of the accumulated
|
|
754
|
+
// entry instead would rescan a string that grows with every continuation line,
|
|
755
|
+
// which is quadratic in the size of the reply
|
|
756
|
+
this._responsePartial = isPartialLine(lines[i]);
|
|
757
|
+
// Checked as each line lands, so a peer that never completes a reply cannot keep
|
|
758
|
+
// buffering, and the limit does not depend on how it split its bytes into chunks
|
|
759
|
+
if (this._responsePartial && this._responseQueue[this._responseQueue.length - 1].length > maxResponseSize) {
|
|
760
|
+
return this._onResponseTooLarge();
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
if (this._remainder.length > maxResponseSize) {
|
|
764
|
+
return this._onResponseTooLarge();
|
|
765
|
+
}
|
|
766
|
+
if (this._responsePartial) {
|
|
767
|
+
return;
|
|
736
768
|
}
|
|
737
769
|
this._processResponse();
|
|
738
770
|
}
|
|
771
|
+
/**
|
|
772
|
+
* Drops a connection whose peer keeps extending a reply it never completes, releasing
|
|
773
|
+
* whatever was buffered for that reply
|
|
774
|
+
* @internal
|
|
775
|
+
*/
|
|
776
|
+
_onResponseTooLarge() {
|
|
777
|
+
this._remainder = '';
|
|
778
|
+
this._responseQueue = [];
|
|
779
|
+
this._responsePartial = false;
|
|
780
|
+
this._onError(new Error('Server response exceeds maximum allowed size'), 'EPROTOCOL', false, 'CONN');
|
|
781
|
+
}
|
|
739
782
|
/**
|
|
740
783
|
* 'error' listener for the socket
|
|
741
784
|
*
|
|
@@ -876,6 +919,7 @@ class SMTPConnection extends node_events_1.EventEmitter {
|
|
|
876
919
|
// part of the secured EHLO capabilities). STARTTLS response injection.
|
|
877
920
|
this._remainder = '';
|
|
878
921
|
this._responseQueue = [];
|
|
922
|
+
this._responsePartial = false;
|
|
879
923
|
// do not remove all listeners or it breaks node v0.10 as there's
|
|
880
924
|
// apparently a 'finish' event set that would be cleared as well
|
|
881
925
|
// we can safely keep 'error', 'end', 'close' etc. events
|
|
@@ -85,6 +85,29 @@ function _isWordCode(code) {
|
|
|
85
85
|
function _isBoundary(text, at) {
|
|
86
86
|
return _isWordCode(text.charCodeAt(at - 1)) !== _isWordCode(text.charCodeAt(at));
|
|
87
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* Offset of the first '@' in `text` between `from` and `to`, or -1 when the range holds none.
|
|
90
|
+
*
|
|
91
|
+
* indexOf would scan on to the end of the value, and the value here is a whole header. The
|
|
92
|
+
* walk below steps one whitespace delimited run at a time and only ever uses a '@' that sits
|
|
93
|
+
* inside the run it is on, so an unbounded probe rescans everything behind that run once per
|
|
94
|
+
* run and grows with the square of the header: 400KB of free text carrying no '@' took a
|
|
95
|
+
* quarter of a second. GHSA-v53p-9fqp-m79j took the pattern search out of this walk and left
|
|
96
|
+
* the probe unbounded behind it.
|
|
97
|
+
*
|
|
98
|
+
* @param text Text to look in
|
|
99
|
+
* @param from Offset to start at
|
|
100
|
+
* @param to Offset to stop before
|
|
101
|
+
* @return Offset of the '@', or -1
|
|
102
|
+
*/
|
|
103
|
+
function _indexOfAt(text, from, to) {
|
|
104
|
+
for (let i = from; i < to; i++) {
|
|
105
|
+
if (text.charCodeAt(i) === 0x40) {
|
|
106
|
+
return i;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return -1;
|
|
110
|
+
}
|
|
88
111
|
/**
|
|
89
112
|
* Finds the offset LOOSE_TEXT_ADDR matches at, or -1 when it does not match at all.
|
|
90
113
|
*
|
|
@@ -115,8 +138,8 @@ function _looseAddressStart(text) {
|
|
|
115
138
|
while (runEnd < len && !_isSpaceCode(text.charCodeAt(runEnd))) {
|
|
116
139
|
runEnd++;
|
|
117
140
|
}
|
|
118
|
-
let at = text
|
|
119
|
-
if (at >= 0
|
|
141
|
+
let at = _indexOfAt(text, runStart, runEnd);
|
|
142
|
+
if (at >= 0) {
|
|
120
143
|
let lastBoundary = -1;
|
|
121
144
|
for (let k = runEnd; k > runStart; k--) {
|
|
122
145
|
if (_isBoundary(text, k)) {
|
|
@@ -125,7 +148,7 @@ function _looseAddressStart(text) {
|
|
|
125
148
|
}
|
|
126
149
|
}
|
|
127
150
|
let atomStart = runStart;
|
|
128
|
-
while (lastBoundary >= 0 && at >= 0
|
|
151
|
+
while (lastBoundary >= 0 && at >= 0) {
|
|
129
152
|
// '[^@\s]+' has to cover a character before the '@' and '[^\s]+' one after it,
|
|
130
153
|
// and the boundary that ends the match has to sit past both
|
|
131
154
|
if (at > atomStart && runEnd > at + 1 && lastBoundary > at + 1) {
|
|
@@ -145,7 +168,7 @@ function _looseAddressStart(text) {
|
|
|
145
168
|
}
|
|
146
169
|
}
|
|
147
170
|
atomStart = at + 1;
|
|
148
|
-
at = text
|
|
171
|
+
at = _indexOfAt(text, atomStart, runEnd);
|
|
149
172
|
}
|
|
150
173
|
}
|
|
151
174
|
pos = runEnd;
|
|
@@ -272,6 +295,19 @@ function _handleAddress(tokens, depth) {
|
|
|
272
295
|
}
|
|
273
296
|
}
|
|
274
297
|
else if (token.value) {
|
|
298
|
+
// An empty quoted string is dropped by the tokenizer, leaving no text token of its
|
|
299
|
+
// own for textWasQuoted to be recorded on, so the pair of quote operators right in
|
|
300
|
+
// front of this token is all that is left of it and the run it opens carries the
|
|
301
|
+
// quoting instead. Without this '""@example.com' reads as the bare '@example.com',
|
|
302
|
+
// the quotes never go back on, and the value is no longer an addr-spec a trailing
|
|
303
|
+
// comment can be peeled off of. It only ever opens a run: a run that already holds
|
|
304
|
+
// material collected outside the quotes is not a quoted string, whatever follows it
|
|
305
|
+
const prevPrevToken = i > 1 ? tokens[i - 2] : null;
|
|
306
|
+
const opensAfterEmptyQuotedString = prevToken?.type === 'operator' &&
|
|
307
|
+
prevToken.value === '"' &&
|
|
308
|
+
!!prevToken.noBreak &&
|
|
309
|
+
prevPrevToken?.type === 'operator' &&
|
|
310
|
+
prevPrevToken.value === '"';
|
|
275
311
|
if (state === 'address') {
|
|
276
312
|
// Handle unquoted name that includes a "<".
|
|
277
313
|
// Apple Mail truncates everything between an unexpected < and an address.
|
|
@@ -299,7 +335,7 @@ function _handleAddress(tokens, depth) {
|
|
|
299
335
|
data[state].push(token.value);
|
|
300
336
|
lastChars[state] = token.value.charAt(token.value.length - 1);
|
|
301
337
|
if (state === 'text') {
|
|
302
|
-
data.textWasQuoted.push(insideQuotes);
|
|
338
|
+
data.textWasQuoted.push(insideQuotes || opensAfterEmptyQuotedString);
|
|
303
339
|
}
|
|
304
340
|
}
|
|
305
341
|
}
|
|
@@ -384,6 +420,18 @@ function _handleAddress(tokens, depth) {
|
|
|
384
420
|
// Join values with spaces
|
|
385
421
|
data.text = data.text.join(' ');
|
|
386
422
|
data.address = data.address.join(' ');
|
|
423
|
+
if (addressFromQuotedText && data.text) {
|
|
424
|
+
// The mailbox is still sitting in the text, so it moves over here and is quoted
|
|
425
|
+
// before the recovery below rather than after it. Anything else the text holds
|
|
426
|
+
// came along with it: a comment ends the domain but leaves the atoms behind it in
|
|
427
|
+
// the same text, and '"user"@example.com(x)evil.com' was handed on as the address
|
|
428
|
+
// 'user@example.com evil.com', a second domain riding into the envelope recipient
|
|
429
|
+
// on a value that is no addr-spec at all (GHSA-g57g-f23g-4646). Putting the quotes
|
|
430
|
+
// back first is what lets the recovery tell the whitespace an addr-spec may carry
|
|
431
|
+
// from the wreckage trailing one, as only a quoted local part may hold whitespace
|
|
432
|
+
data.address = _quoteLocalPart(data.text);
|
|
433
|
+
data.text = '';
|
|
434
|
+
}
|
|
387
435
|
_recoverAddrSpec(data);
|
|
388
436
|
const address = {
|
|
389
437
|
address: data.address || data.text || '',
|
|
@@ -397,9 +445,6 @@ function _handleAddress(tokens, depth) {
|
|
|
397
445
|
address.address = '';
|
|
398
446
|
}
|
|
399
447
|
}
|
|
400
|
-
if (addressFromQuotedText && address.address) {
|
|
401
|
-
address.address = _quoteLocalPart(address.address);
|
|
402
|
-
}
|
|
403
448
|
addresses.push(address);
|
|
404
449
|
}
|
|
405
450
|
return addresses;
|
|
@@ -124,11 +124,18 @@ export default class MessageParser extends Transform {
|
|
|
124
124
|
// signature covers exactly the bytes the receiving side canonicalizes
|
|
125
125
|
// Only SP and HTAB fold a line, and only they are trimmed from the field name, the
|
|
126
126
|
// same whitespace the relaxed canonicalization in sign.ts works with
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
127
|
+
const rawLines = (this.rawHeaders || Buffer.alloc(0)).toString('binary').split(/\r?\n/);
|
|
128
|
+
// Unfold in a single forward pass and only ever test a freshly split line for the
|
|
129
|
+
// continuation prefix. Testing an already merged line instead would rescan a string
|
|
130
|
+
// that grows with every continuation line, so a header folded into many continuation
|
|
131
|
+
// lines (a large recipient list, for example) would take quadratic time to unfold
|
|
132
|
+
const lines = [];
|
|
133
|
+
for (const rawLine of rawLines) {
|
|
134
|
+
if (lines.length && /^[ \t]/.test(rawLine)) {
|
|
135
|
+
lines[lines.length - 1] += '\n' + rawLine;
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
lines.push(rawLine);
|
|
132
139
|
}
|
|
133
140
|
}
|
|
134
141
|
return lines
|
|
@@ -113,8 +113,10 @@ class MailComposer {
|
|
|
113
113
|
data.filename = attachment.filename;
|
|
114
114
|
}
|
|
115
115
|
else if (!isMessageNode && attachment.filename !== false) {
|
|
116
|
+
// a backslash separates as well, so a Windows path does not put the sender's directories in the headers
|
|
116
117
|
data.filename =
|
|
117
|
-
(attachment.path || attachment.href || '').split(
|
|
118
|
+
(attachment.path || attachment.href || '').split(/[/\\]/).pop().split('?').shift() ||
|
|
119
|
+
'attachment-' + (i + 1);
|
|
118
120
|
if (data.filename.indexOf('.') < 0) {
|
|
119
121
|
data.filename += '.' + mimeFuncs.detectExtension(data.contentType);
|
|
120
122
|
}
|
|
@@ -69,8 +69,9 @@ export default class MailMessage {
|
|
|
69
69
|
if (this.data.attachments && this.data.attachments.length) {
|
|
70
70
|
this.data.attachments.forEach((attachment, i) => {
|
|
71
71
|
if (!attachment.filename) {
|
|
72
|
+
// a backslash separates as well, so a Windows path does not put the sender's directories in the headers
|
|
72
73
|
attachment.filename =
|
|
73
|
-
(attachment.path || attachment.href || '').split(
|
|
74
|
+
(attachment.path || attachment.href || '').split(/[/\\]/).pop().split('?').shift() ||
|
|
74
75
|
'attachment-' + (i + 1);
|
|
75
76
|
if (attachment.filename.indexOf('.') < 0) {
|
|
76
77
|
attachment.filename += '.' + mimeFuncs.detectExtension(attachment.contentType);
|
package/dist/esm/package-info.js
CHANGED
|
@@ -41,6 +41,8 @@ export interface SMTPConnectionOptions {
|
|
|
41
41
|
greetingTimeout?: number | undefined;
|
|
42
42
|
/** Time of inactivity in ms until the connection is closed, defaults to 10 minutes */
|
|
43
43
|
socketTimeout?: number | undefined;
|
|
44
|
+
/** Largest single server response to accept in bytes, defaults to 1 MB */
|
|
45
|
+
maxResponseSize?: number | undefined;
|
|
44
46
|
/** Time to wait in ms for the DNS requests to be resolved, defaults to 30 seconds */
|
|
45
47
|
dnsTimeout?: number | undefined;
|
|
46
48
|
/** Use LMTP instead of SMTP */
|
|
@@ -268,6 +270,7 @@ export type SMTPConnectionResponseAction = (str: string) => void;
|
|
|
268
270
|
* * **greetingTimeout** - Time to wait in ms until greeting message is received from the server (defaults to 30 seconds)
|
|
269
271
|
* * **connectionTimeout** - how many milliseconds to wait for the connection to establish (defaults to 2 minutes)
|
|
270
272
|
* * **socketTimeout** - Time of inactivity until the connection is closed (defaults to 10 minutes)
|
|
273
|
+
* * **maxResponseSize** - Largest single server response to accept in bytes (defaults to 1 MB)
|
|
271
274
|
* * **dnsTimeout** - Time to wait in ms for the DNS requests to be resolved (defaults to 30 seconds)
|
|
272
275
|
* * **lmtp** - if true, uses LMTP instead of SMTP protocol
|
|
273
276
|
* * **logger** - bunyan compatible logger interface
|
|
@@ -13,6 +13,9 @@ const SOCKET_TIMEOUT = 10 * 60 * 1000; // how much to wait for socket inactivity
|
|
|
13
13
|
const GREETING_TIMEOUT = 30 * 1000; // how much to wait after connection is established but SMTP greeting is not receieved
|
|
14
14
|
const DNS_TIMEOUT = 30 * 1000; // how much to wait for resolveHostname
|
|
15
15
|
const TEARDOWN_NOOP = () => { }; // reusable no-op handler for absorbing errors during socket teardown
|
|
16
|
+
// how many bytes a single server response may occupy while it is still being received.
|
|
17
|
+
// Generous compared to any real reply, it only stops a peer that never completes one
|
|
18
|
+
const MAX_RESPONSE_SIZE = 1024 * 1024;
|
|
16
19
|
/**
|
|
17
20
|
* Re-interpret a server response stored in fake 8-bit byte-container form
|
|
18
21
|
* (the result of chunk.toString('binary') in _onData) as UTF-8.
|
|
@@ -41,11 +44,19 @@ function decodeServerResponse(str) {
|
|
|
41
44
|
* Called with the byte-container form the queue holds (see _onData): the check only looks
|
|
42
45
|
* at leading ASCII digits and '-' of the last line, and a UTF-8 continuation byte is never
|
|
43
46
|
* 0x0A, so line boundaries and the tested prefix are the same before and after decoding.
|
|
44
|
-
* The last line is read with lastIndexOf rather than split() because a queue entry
|
|
45
|
-
*
|
|
47
|
+
* The last line is read with lastIndexOf rather than split() because a queue entry may hold
|
|
48
|
+
* a whole multiline reply and only its final line matters.
|
|
46
49
|
*/
|
|
47
50
|
function isPartialResponse(str) {
|
|
48
|
-
return
|
|
51
|
+
return isPartialLine(str.slice(str.lastIndexOf('\n') + 1));
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* True when a single reply line is a continuation ("250-..."). Used where the line is
|
|
55
|
+
* already known to be one line, which skips the scan isPartialResponse needs to find the
|
|
56
|
+
* last line of a whole reply.
|
|
57
|
+
*/
|
|
58
|
+
function isPartialLine(line) {
|
|
59
|
+
return /^\d+-/.test(line);
|
|
49
60
|
}
|
|
50
61
|
/**
|
|
51
62
|
* Generates a SMTP connection object
|
|
@@ -62,6 +73,7 @@ function isPartialResponse(str) {
|
|
|
62
73
|
* * **greetingTimeout** - Time to wait in ms until greeting message is received from the server (defaults to 30 seconds)
|
|
63
74
|
* * **connectionTimeout** - how many milliseconds to wait for the connection to establish (defaults to 2 minutes)
|
|
64
75
|
* * **socketTimeout** - Time of inactivity until the connection is closed (defaults to 10 minutes)
|
|
76
|
+
* * **maxResponseSize** - Largest single server response to accept in bytes (defaults to 1 MB)
|
|
65
77
|
* * **dnsTimeout** - Time to wait in ms for the DNS requests to be resolved (defaults to 30 seconds)
|
|
66
78
|
* * **lmtp** - if true, uses LMTP instead of SMTP protocol
|
|
67
79
|
* * **logger** - bunyan compatible logger interface
|
|
@@ -108,6 +120,7 @@ class SMTPConnection extends EventEmitter {
|
|
|
108
120
|
this.secure = !!this.secureConnection;
|
|
109
121
|
this._remainder = '';
|
|
110
122
|
this._responseQueue = [];
|
|
123
|
+
this._responsePartial = false;
|
|
111
124
|
this.lastServerResponse = false;
|
|
112
125
|
this._socket = false;
|
|
113
126
|
this._supportedAuth = [];
|
|
@@ -676,28 +689,58 @@ class SMTPConnection extends EventEmitter {
|
|
|
676
689
|
if (this._destroyed || !chunk || !chunk.length) {
|
|
677
690
|
return;
|
|
678
691
|
}
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
692
|
+
const maxResponseSize = this.options.maxResponseSize || MAX_RESPONSE_SIZE;
|
|
693
|
+
const data = chunk.toString('binary');
|
|
694
|
+
// A chunk without a line break only extends the line currently being received, so
|
|
695
|
+
// keep it in the remainder and leave that string unflattened. Splitting the whole
|
|
696
|
+
// remainder again on every chunk would rescan everything buffered for that line so
|
|
697
|
+
// far, which is quadratic in the length of a line the peer never terminates
|
|
698
|
+
if (!data.includes('\n')) {
|
|
699
|
+
this._remainder += data;
|
|
700
|
+
if (this._remainder.length > maxResponseSize) {
|
|
701
|
+
return this._onResponseTooLarge();
|
|
702
|
+
}
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
const lines = (this._remainder + data).split(/\r?\n/);
|
|
682
706
|
this._remainder = lines.pop();
|
|
683
707
|
for (let i = 0, len = lines.length; i < len; i++) {
|
|
684
|
-
if (this.
|
|
685
|
-
|
|
686
|
-
if (isPartialResponse(lastline)) {
|
|
687
|
-
this._responseQueue[this._responseQueue.length - 1] += '\n' + lines[i];
|
|
688
|
-
continue;
|
|
689
|
-
}
|
|
708
|
+
if (this._responsePartial) {
|
|
709
|
+
this._responseQueue[this._responseQueue.length - 1] += '\n' + lines[i];
|
|
690
710
|
}
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
if (this._responseQueue.length) {
|
|
694
|
-
lastline = this._responseQueue[this._responseQueue.length - 1];
|
|
695
|
-
if (isPartialResponse(lastline)) {
|
|
696
|
-
return;
|
|
711
|
+
else {
|
|
712
|
+
this._responseQueue.push(lines[i]);
|
|
697
713
|
}
|
|
714
|
+
// The line just added is the last line of that queue entry, so it alone decides
|
|
715
|
+
// whether the reply is still partial. Looking for the last line of the accumulated
|
|
716
|
+
// entry instead would rescan a string that grows with every continuation line,
|
|
717
|
+
// which is quadratic in the size of the reply
|
|
718
|
+
this._responsePartial = isPartialLine(lines[i]);
|
|
719
|
+
// Checked as each line lands, so a peer that never completes a reply cannot keep
|
|
720
|
+
// buffering, and the limit does not depend on how it split its bytes into chunks
|
|
721
|
+
if (this._responsePartial && this._responseQueue[this._responseQueue.length - 1].length > maxResponseSize) {
|
|
722
|
+
return this._onResponseTooLarge();
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
if (this._remainder.length > maxResponseSize) {
|
|
726
|
+
return this._onResponseTooLarge();
|
|
727
|
+
}
|
|
728
|
+
if (this._responsePartial) {
|
|
729
|
+
return;
|
|
698
730
|
}
|
|
699
731
|
this._processResponse();
|
|
700
732
|
}
|
|
733
|
+
/**
|
|
734
|
+
* Drops a connection whose peer keeps extending a reply it never completes, releasing
|
|
735
|
+
* whatever was buffered for that reply
|
|
736
|
+
* @internal
|
|
737
|
+
*/
|
|
738
|
+
_onResponseTooLarge() {
|
|
739
|
+
this._remainder = '';
|
|
740
|
+
this._responseQueue = [];
|
|
741
|
+
this._responsePartial = false;
|
|
742
|
+
this._onError(new Error('Server response exceeds maximum allowed size'), 'EPROTOCOL', false, 'CONN');
|
|
743
|
+
}
|
|
701
744
|
/**
|
|
702
745
|
* 'error' listener for the socket
|
|
703
746
|
*
|
|
@@ -838,6 +881,7 @@ class SMTPConnection extends EventEmitter {
|
|
|
838
881
|
// part of the secured EHLO capabilities). STARTTLS response injection.
|
|
839
882
|
this._remainder = '';
|
|
840
883
|
this._responseQueue = [];
|
|
884
|
+
this._responsePartial = false;
|
|
841
885
|
// do not remove all listeners or it breaks node v0.10 as there's
|
|
842
886
|
// apparently a 'finish' event set that would be cleared as well
|
|
843
887
|
// we can safely keep 'error', 'end', 'close' etc. events
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nodemailer",
|
|
3
|
-
"version": "10.0.
|
|
3
|
+
"version": "10.0.10",
|
|
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",
|
|
@@ -147,24 +147,24 @@
|
|
|
147
147
|
},
|
|
148
148
|
"homepage": "https://nodemailer.com/",
|
|
149
149
|
"devDependencies": {
|
|
150
|
-
"@aws-sdk/client-sesv2": "3.
|
|
150
|
+
"@aws-sdk/client-sesv2": "3.1131.0",
|
|
151
151
|
"@types/node": "20.19.43",
|
|
152
152
|
"bunyan": "1.8.15",
|
|
153
153
|
"c8": "12.0.0",
|
|
154
|
-
"eslint": "10.
|
|
154
|
+
"eslint": "10.10.0",
|
|
155
155
|
"eslint-config-prettier": "10.1.8",
|
|
156
156
|
"globals": "17.12.0",
|
|
157
157
|
"libbase64": "1.3.0",
|
|
158
158
|
"libmime": "5.4.3",
|
|
159
159
|
"libqp": "2.1.1",
|
|
160
|
-
"mailauth": "5.0.
|
|
160
|
+
"mailauth": "5.0.3",
|
|
161
161
|
"prettier": "3.9.6",
|
|
162
162
|
"proxy": "1.0.2",
|
|
163
163
|
"proxy-test-server": "1.0.0",
|
|
164
|
-
"smtp-server": "3.19.
|
|
164
|
+
"smtp-server": "3.19.11",
|
|
165
165
|
"tsx": "4.23.13",
|
|
166
166
|
"typescript": "6.0.3",
|
|
167
|
-
"typescript-eslint": "8.
|
|
167
|
+
"typescript-eslint": "8.70.0"
|
|
168
168
|
},
|
|
169
169
|
"engines": {
|
|
170
170
|
"node": ">=20.0.0"
|