node_modules: update (#325)

Co-authored-by: dawidd6 <9713907+dawidd6@users.noreply.github.com>
This commit is contained in:
Dawid Dziurla
2026-09-25 18:42:41 +02:00
committed by GitHub
parent bd35283f19
commit 6d91308e46
19 changed files with 300 additions and 79 deletions
+3 -3
View File
@@ -94,9 +94,9 @@
} }
}, },
"node_modules/nodemailer": { "node_modules/nodemailer": {
"version": "10.0.8", "version": "10.0.10",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.8.tgz", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.10.tgz",
"integrity": "sha512-uCZ0AtFDI46sHwJ6mhwNuIoA/KiTC0ZCzX7g01941+H9VMIXEiZjtgjQkzoBRTeQba0cIrKfwk31q99PfcIMRw==", "integrity": "sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A==",
"license": "MIT-0", "license": "MIT-0",
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
+17
View File
@@ -1,5 +1,22 @@
# CHANGELOG # CHANGELOG
## [10.0.10](https://github.com/nodemailer/nodemailer/compare/v10.0.9...v10.0.10) (2026-09-14)
### Bug Fixes
* derive the attachment filename from the basename of a Windows path ([c7cc7ce](https://github.com/nodemailer/nodemailer/commit/c7cc7ce41a3602441747476a2a2c4a8ff466a83e))
* **dkim:** unfold folded header lines in linear time ([28a5909](https://github.com/nodemailer/nodemailer/commit/28a5909cec27646cb001dc7e97a8f5d26c973078))
* **smtp-connection:** reassemble multiline replies in linear time ([f2d82fa](https://github.com/nodemailer/nodemailer/commit/f2d82fa84d47015a7bbb4253da61eeb778c658b1))
## [10.0.9](https://github.com/nodemailer/nodemailer/compare/v10.0.8...v10.0.9) (2026-09-12)
### Bug Fixes
* **addressparser:** bound the '@' probe to the run being scanned ([1465c3f](https://github.com/nodemailer/nodemailer/commit/1465c3f5ff74a7c4fbbe9853bd01448bb92bf5b7))
* **addressparser:** keep the text after a comment out of a quoted local part address ([2f36eb1](https://github.com/nodemailer/nodemailer/commit/2f36eb1aa1dd33e312411dc9b888548e14db54ee))
## [10.0.8](https://github.com/nodemailer/nodemailer/compare/v10.0.7...v10.0.8) (2026-09-11) ## [10.0.8](https://github.com/nodemailer/nodemailer/compare/v10.0.7...v10.0.8) (2026-09-11)
+53 -8
View File
@@ -88,6 +88,29 @@ function _isWordCode(code) {
function _isBoundary(text, at) { function _isBoundary(text, at) {
return _isWordCode(text.charCodeAt(at - 1)) !== _isWordCode(text.charCodeAt(at)); return _isWordCode(text.charCodeAt(at - 1)) !== _isWordCode(text.charCodeAt(at));
} }
/**
* Offset of the first '@' in `text` between `from` and `to`, or -1 when the range holds none.
*
* indexOf would scan on to the end of the value, and the value here is a whole header. The
* walk below steps one whitespace delimited run at a time and only ever uses a '@' that sits
* inside the run it is on, so an unbounded probe rescans everything behind that run once per
* run and grows with the square of the header: 400KB of free text carrying no '@' took a
* quarter of a second. GHSA-v53p-9fqp-m79j took the pattern search out of this walk and left
* the probe unbounded behind it.
*
* @param text Text to look in
* @param from Offset to start at
* @param to Offset to stop before
* @return Offset of the '@', or -1
*/
function _indexOfAt(text, from, to) {
for (let i = from; i < to; i++) {
if (text.charCodeAt(i) === 0x40) {
return i;
}
}
return -1;
}
/** /**
* Finds the offset LOOSE_TEXT_ADDR matches at, or -1 when it does not match at all. * Finds the offset LOOSE_TEXT_ADDR matches at, or -1 when it does not match at all.
* *
@@ -118,8 +141,8 @@ function _looseAddressStart(text) {
while (runEnd < len && !_isSpaceCode(text.charCodeAt(runEnd))) { while (runEnd < len && !_isSpaceCode(text.charCodeAt(runEnd))) {
runEnd++; runEnd++;
} }
let at = text.indexOf('@', runStart); let at = _indexOfAt(text, runStart, runEnd);
if (at >= 0 && at < runEnd) { if (at >= 0) {
let lastBoundary = -1; let lastBoundary = -1;
for (let k = runEnd; k > runStart; k--) { for (let k = runEnd; k > runStart; k--) {
if (_isBoundary(text, k)) { if (_isBoundary(text, k)) {
@@ -128,7 +151,7 @@ function _looseAddressStart(text) {
} }
} }
let atomStart = runStart; let atomStart = runStart;
while (lastBoundary >= 0 && at >= 0 && at < runEnd) { while (lastBoundary >= 0 && at >= 0) {
// '[^@\s]+' has to cover a character before the '@' and '[^\s]+' one after it, // '[^@\s]+' has to cover a character before the '@' and '[^\s]+' one after it,
// and the boundary that ends the match has to sit past both // and the boundary that ends the match has to sit past both
if (at > atomStart && runEnd > at + 1 && lastBoundary > at + 1) { if (at > atomStart && runEnd > at + 1 && lastBoundary > at + 1) {
@@ -148,7 +171,7 @@ function _looseAddressStart(text) {
} }
} }
atomStart = at + 1; atomStart = at + 1;
at = text.indexOf('@', atomStart); at = _indexOfAt(text, atomStart, runEnd);
} }
} }
pos = runEnd; pos = runEnd;
@@ -275,6 +298,19 @@ function _handleAddress(tokens, depth) {
} }
} }
else if (token.value) { else if (token.value) {
// An empty quoted string is dropped by the tokenizer, leaving no text token of its
// own for textWasQuoted to be recorded on, so the pair of quote operators right in
// front of this token is all that is left of it and the run it opens carries the
// quoting instead. Without this '""@example.com' reads as the bare '@example.com',
// the quotes never go back on, and the value is no longer an addr-spec a trailing
// comment can be peeled off of. It only ever opens a run: a run that already holds
// material collected outside the quotes is not a quoted string, whatever follows it
const prevPrevToken = i > 1 ? tokens[i - 2] : null;
const opensAfterEmptyQuotedString = prevToken?.type === 'operator' &&
prevToken.value === '"' &&
!!prevToken.noBreak &&
prevPrevToken?.type === 'operator' &&
prevPrevToken.value === '"';
if (state === 'address') { if (state === 'address') {
// Handle unquoted name that includes a "<". // Handle unquoted name that includes a "<".
// Apple Mail truncates everything between an unexpected < and an address. // Apple Mail truncates everything between an unexpected < and an address.
@@ -302,7 +338,7 @@ function _handleAddress(tokens, depth) {
data[state].push(token.value); data[state].push(token.value);
lastChars[state] = token.value.charAt(token.value.length - 1); lastChars[state] = token.value.charAt(token.value.length - 1);
if (state === 'text') { if (state === 'text') {
data.textWasQuoted.push(insideQuotes); data.textWasQuoted.push(insideQuotes || opensAfterEmptyQuotedString);
} }
} }
} }
@@ -387,6 +423,18 @@ function _handleAddress(tokens, depth) {
// Join values with spaces // Join values with spaces
data.text = data.text.join(' '); data.text = data.text.join(' ');
data.address = data.address.join(' '); data.address = data.address.join(' ');
if (addressFromQuotedText && data.text) {
// The mailbox is still sitting in the text, so it moves over here and is quoted
// before the recovery below rather than after it. Anything else the text holds
// came along with it: a comment ends the domain but leaves the atoms behind it in
// the same text, and '"user"@example.com(x)evil.com' was handed on as the address
// 'user@example.com evil.com', a second domain riding into the envelope recipient
// on a value that is no addr-spec at all (GHSA-g57g-f23g-4646). Putting the quotes
// back first is what lets the recovery tell the whitespace an addr-spec may carry
// from the wreckage trailing one, as only a quoted local part may hold whitespace
data.address = _quoteLocalPart(data.text);
data.text = '';
}
_recoverAddrSpec(data); _recoverAddrSpec(data);
const address = { const address = {
address: data.address || data.text || '', address: data.address || data.text || '',
@@ -400,9 +448,6 @@ function _handleAddress(tokens, depth) {
address.address = ''; address.address = '';
} }
} }
if (addressFromQuotedText && address.address) {
address.address = _quoteLocalPart(address.address);
}
addresses.push(address); addresses.push(address);
} }
return addresses; return addresses;
+12 -5
View File
@@ -126,11 +126,18 @@ class MessageParser extends node_stream_1.Transform {
// signature covers exactly the bytes the receiving side canonicalizes // signature covers exactly the bytes the receiving side canonicalizes
// Only SP and HTAB fold a line, and only they are trimmed from the field name, the // Only SP and HTAB fold a line, and only they are trimmed from the field name, the
// same whitespace the relaxed canonicalization in sign.ts works with // same whitespace the relaxed canonicalization in sign.ts works with
const lines = (this.rawHeaders || Buffer.alloc(0)).toString('binary').split(/\r?\n/); const rawLines = (this.rawHeaders || Buffer.alloc(0)).toString('binary').split(/\r?\n/);
for (let i = lines.length - 1; i > 0; i--) { // Unfold in a single forward pass and only ever test a freshly split line for the
if (/^[ \t]/.test(lines[i])) { // continuation prefix. Testing an already merged line instead would rescan a string
lines[i - 1] += '\n' + lines[i]; // that grows with every continuation line, so a header folded into many continuation
lines.splice(i, 1); // lines (a large recipient list, for example) would take quadratic time to unfold
const lines = [];
for (const rawLine of rawLines) {
if (lines.length && /^[ \t]/.test(rawLine)) {
lines[lines.length - 1] += '\n' + rawLine;
}
else {
lines.push(rawLine);
} }
} }
return lines return lines
+3 -1
View File
@@ -151,8 +151,10 @@ class MailComposer {
data.filename = attachment.filename; data.filename = attachment.filename;
} }
else if (!isMessageNode && attachment.filename !== false) { else if (!isMessageNode && attachment.filename !== false) {
// a backslash separates as well, so a Windows path does not put the sender's directories in the headers
data.filename = data.filename =
(attachment.path || attachment.href || '').split('/').pop().split('?').shift() || 'attachment-' + (i + 1); (attachment.path || attachment.href || '').split(/[/\\]/).pop().split('?').shift() ||
'attachment-' + (i + 1);
if (data.filename.indexOf('.') < 0) { if (data.filename.indexOf('.') < 0) {
data.filename += '.' + mimeFuncs.detectExtension(data.contentType); data.filename += '.' + mimeFuncs.detectExtension(data.contentType);
} }
+2 -1
View File
@@ -107,8 +107,9 @@ class MailMessage {
if (this.data.attachments && this.data.attachments.length) { if (this.data.attachments && this.data.attachments.length) {
this.data.attachments.forEach((attachment, i) => { this.data.attachments.forEach((attachment, i) => {
if (!attachment.filename) { if (!attachment.filename) {
// a backslash separates as well, so a Windows path does not put the sender's directories in the headers
attachment.filename = attachment.filename =
(attachment.path || attachment.href || '').split('/').pop().split('?').shift() || (attachment.path || attachment.href || '').split(/[/\\]/).pop().split('?').shift() ||
'attachment-' + (i + 1); 'attachment-' + (i + 1);
if (attachment.filename.indexOf('.') < 0) { if (attachment.filename.indexOf('.') < 0) {
attachment.filename += '.' + mimeFuncs.detectExtension(attachment.contentType); attachment.filename += '.' + mimeFuncs.detectExtension(attachment.contentType);
+1 -1
View File
@@ -1,3 +1,3 @@
export declare const name = "nodemailer"; export declare const name = "nodemailer";
export declare const version = "10.0.8"; export declare const version = "10.0.10";
export declare const homepage = "https://nodemailer.com/"; export declare const homepage = "https://nodemailer.com/";
+1 -1
View File
@@ -3,5 +3,5 @@
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.homepage = exports.version = exports.name = void 0; exports.homepage = exports.version = exports.name = void 0;
exports.name = 'nodemailer'; exports.name = 'nodemailer';
exports.version = '10.0.8'; exports.version = '10.0.10';
exports.homepage = 'https://nodemailer.com/'; exports.homepage = 'https://nodemailer.com/';
+3
View File
@@ -41,6 +41,8 @@ export interface SMTPConnectionOptions {
greetingTimeout?: number | undefined; greetingTimeout?: number | undefined;
/** Time of inactivity in ms until the connection is closed, defaults to 10 minutes */ /** Time of inactivity in ms until the connection is closed, defaults to 10 minutes */
socketTimeout?: number | undefined; socketTimeout?: number | undefined;
/** Largest single server response to accept in bytes, defaults to 1 MB */
maxResponseSize?: number | undefined;
/** Time to wait in ms for the DNS requests to be resolved, defaults to 30 seconds */ /** Time to wait in ms for the DNS requests to be resolved, defaults to 30 seconds */
dnsTimeout?: number | undefined; dnsTimeout?: number | undefined;
/** Use LMTP instead of SMTP */ /** Use LMTP instead of SMTP */
@@ -268,6 +270,7 @@ export type SMTPConnectionResponseAction = (str: string) => void;
* * **greetingTimeout** - Time to wait in ms until greeting message is received from the server (defaults to 30 seconds) * * **greetingTimeout** - Time to wait in ms until greeting message is received from the server (defaults to 30 seconds)
* * **connectionTimeout** - how many milliseconds to wait for the connection to establish (defaults to 2 minutes) * * **connectionTimeout** - how many milliseconds to wait for the connection to establish (defaults to 2 minutes)
* * **socketTimeout** - Time of inactivity until the connection is closed (defaults to 10 minutes) * * **socketTimeout** - Time of inactivity until the connection is closed (defaults to 10 minutes)
* * **maxResponseSize** - Largest single server response to accept in bytes (defaults to 1 MB)
* * **dnsTimeout** - Time to wait in ms for the DNS requests to be resolved (defaults to 30 seconds) * * **dnsTimeout** - Time to wait in ms for the DNS requests to be resolved (defaults to 30 seconds)
* * **lmtp** - if true, uses LMTP instead of SMTP protocol * * **lmtp** - if true, uses LMTP instead of SMTP protocol
* * **logger** - bunyan compatible logger interface * * **logger** - bunyan compatible logger interface
+63 -19
View File
@@ -51,6 +51,9 @@ const SOCKET_TIMEOUT = 10 * 60 * 1000; // how much to wait for socket inactivity
const GREETING_TIMEOUT = 30 * 1000; // how much to wait after connection is established but SMTP greeting is not receieved const GREETING_TIMEOUT = 30 * 1000; // how much to wait after connection is established but SMTP greeting is not receieved
const DNS_TIMEOUT = 30 * 1000; // how much to wait for resolveHostname const DNS_TIMEOUT = 30 * 1000; // how much to wait for resolveHostname
const TEARDOWN_NOOP = () => { }; // reusable no-op handler for absorbing errors during socket teardown const TEARDOWN_NOOP = () => { }; // reusable no-op handler for absorbing errors during socket teardown
// how many bytes a single server response may occupy while it is still being received.
// Generous compared to any real reply, it only stops a peer that never completes one
const MAX_RESPONSE_SIZE = 1024 * 1024;
/** /**
* Re-interpret a server response stored in fake 8-bit byte-container form * Re-interpret a server response stored in fake 8-bit byte-container form
* (the result of chunk.toString('binary') in _onData) as UTF-8. * (the result of chunk.toString('binary') in _onData) as UTF-8.
@@ -79,11 +82,19 @@ function decodeServerResponse(str) {
* Called with the byte-container form the queue holds (see _onData): the check only looks * Called with the byte-container form the queue holds (see _onData): the check only looks
* at leading ASCII digits and '-' of the last line, and a UTF-8 continuation byte is never * at leading ASCII digits and '-' of the last line, and a UTF-8 continuation byte is never
* 0x0A, so line boundaries and the tested prefix are the same before and after decoding. * 0x0A, so line boundaries and the tested prefix are the same before and after decoding.
* The last line is read with lastIndexOf rather than split() because a queue entry grows * The last line is read with lastIndexOf rather than split() because a queue entry may hold
* with every chunk appended to it and only its final line matters. * a whole multiline reply and only its final line matters.
*/ */
function isPartialResponse(str) { function isPartialResponse(str) {
return /^\d+-/.test(str.slice(str.lastIndexOf('\n') + 1)); return isPartialLine(str.slice(str.lastIndexOf('\n') + 1));
}
/**
* True when a single reply line is a continuation ("250-..."). Used where the line is
* already known to be one line, which skips the scan isPartialResponse needs to find the
* last line of a whole reply.
*/
function isPartialLine(line) {
return /^\d+-/.test(line);
} }
/** /**
* Generates a SMTP connection object * Generates a SMTP connection object
@@ -100,6 +111,7 @@ function isPartialResponse(str) {
* * **greetingTimeout** - Time to wait in ms until greeting message is received from the server (defaults to 30 seconds) * * **greetingTimeout** - Time to wait in ms until greeting message is received from the server (defaults to 30 seconds)
* * **connectionTimeout** - how many milliseconds to wait for the connection to establish (defaults to 2 minutes) * * **connectionTimeout** - how many milliseconds to wait for the connection to establish (defaults to 2 minutes)
* * **socketTimeout** - Time of inactivity until the connection is closed (defaults to 10 minutes) * * **socketTimeout** - Time of inactivity until the connection is closed (defaults to 10 minutes)
* * **maxResponseSize** - Largest single server response to accept in bytes (defaults to 1 MB)
* * **dnsTimeout** - Time to wait in ms for the DNS requests to be resolved (defaults to 30 seconds) * * **dnsTimeout** - Time to wait in ms for the DNS requests to be resolved (defaults to 30 seconds)
* * **lmtp** - if true, uses LMTP instead of SMTP protocol * * **lmtp** - if true, uses LMTP instead of SMTP protocol
* * **logger** - bunyan compatible logger interface * * **logger** - bunyan compatible logger interface
@@ -146,6 +158,7 @@ class SMTPConnection extends node_events_1.EventEmitter {
this.secure = !!this.secureConnection; this.secure = !!this.secureConnection;
this._remainder = ''; this._remainder = '';
this._responseQueue = []; this._responseQueue = [];
this._responsePartial = false;
this.lastServerResponse = false; this.lastServerResponse = false;
this._socket = false; this._socket = false;
this._supportedAuth = []; this._supportedAuth = [];
@@ -714,28 +727,58 @@ class SMTPConnection extends node_events_1.EventEmitter {
if (this._destroyed || !chunk || !chunk.length) { if (this._destroyed || !chunk || !chunk.length) {
return; return;
} }
let data = chunk.toString('binary'); const maxResponseSize = this.options.maxResponseSize || MAX_RESPONSE_SIZE;
let lines = (this._remainder + data).split(/\r?\n/); const data = chunk.toString('binary');
let lastline; // A chunk without a line break only extends the line currently being received, so
this._remainder = lines.pop(); // keep it in the remainder and leave that string unflattened. Splitting the whole
for (let i = 0, len = lines.length; i < len; i++) { // remainder again on every chunk would rescan everything buffered for that line so
if (this._responseQueue.length) { // far, which is quadratic in the length of a line the peer never terminates
lastline = this._responseQueue[this._responseQueue.length - 1]; if (!data.includes('\n')) {
if (isPartialResponse(lastline)) { this._remainder += data;
this._responseQueue[this._responseQueue.length - 1] += '\n' + lines[i]; if (this._remainder.length > maxResponseSize) {
continue; return this._onResponseTooLarge();
} }
}
this._responseQueue.push(lines[i]);
}
if (this._responseQueue.length) {
lastline = this._responseQueue[this._responseQueue.length - 1];
if (isPartialResponse(lastline)) {
return; return;
} }
const lines = (this._remainder + data).split(/\r?\n/);
this._remainder = lines.pop();
for (let i = 0, len = lines.length; i < len; i++) {
if (this._responsePartial) {
this._responseQueue[this._responseQueue.length - 1] += '\n' + lines[i];
}
else {
this._responseQueue.push(lines[i]);
}
// The line just added is the last line of that queue entry, so it alone decides
// whether the reply is still partial. Looking for the last line of the accumulated
// entry instead would rescan a string that grows with every continuation line,
// which is quadratic in the size of the reply
this._responsePartial = isPartialLine(lines[i]);
// Checked as each line lands, so a peer that never completes a reply cannot keep
// buffering, and the limit does not depend on how it split its bytes into chunks
if (this._responsePartial && this._responseQueue[this._responseQueue.length - 1].length > maxResponseSize) {
return this._onResponseTooLarge();
}
}
if (this._remainder.length > maxResponseSize) {
return this._onResponseTooLarge();
}
if (this._responsePartial) {
return;
} }
this._processResponse(); this._processResponse();
} }
/**
* Drops a connection whose peer keeps extending a reply it never completes, releasing
* whatever was buffered for that reply
* @internal
*/
_onResponseTooLarge() {
this._remainder = '';
this._responseQueue = [];
this._responsePartial = false;
this._onError(new Error('Server response exceeds maximum allowed size'), 'EPROTOCOL', false, 'CONN');
}
/** /**
* 'error' listener for the socket * 'error' listener for the socket
* *
@@ -876,6 +919,7 @@ class SMTPConnection extends node_events_1.EventEmitter {
// part of the secured EHLO capabilities). STARTTLS response injection. // part of the secured EHLO capabilities). STARTTLS response injection.
this._remainder = ''; this._remainder = '';
this._responseQueue = []; this._responseQueue = [];
this._responsePartial = false;
// do not remove all listeners or it breaks node v0.10 as there's // do not remove all listeners or it breaks node v0.10 as there's
// apparently a 'finish' event set that would be cleared as well // apparently a 'finish' event set that would be cleared as well
// we can safely keep 'error', 'end', 'close' etc. events // we can safely keep 'error', 'end', 'close' etc. events
+53 -8
View File
@@ -85,6 +85,29 @@ function _isWordCode(code) {
function _isBoundary(text, at) { function _isBoundary(text, at) {
return _isWordCode(text.charCodeAt(at - 1)) !== _isWordCode(text.charCodeAt(at)); return _isWordCode(text.charCodeAt(at - 1)) !== _isWordCode(text.charCodeAt(at));
} }
/**
* Offset of the first '@' in `text` between `from` and `to`, or -1 when the range holds none.
*
* indexOf would scan on to the end of the value, and the value here is a whole header. The
* walk below steps one whitespace delimited run at a time and only ever uses a '@' that sits
* inside the run it is on, so an unbounded probe rescans everything behind that run once per
* run and grows with the square of the header: 400KB of free text carrying no '@' took a
* quarter of a second. GHSA-v53p-9fqp-m79j took the pattern search out of this walk and left
* the probe unbounded behind it.
*
* @param text Text to look in
* @param from Offset to start at
* @param to Offset to stop before
* @return Offset of the '@', or -1
*/
function _indexOfAt(text, from, to) {
for (let i = from; i < to; i++) {
if (text.charCodeAt(i) === 0x40) {
return i;
}
}
return -1;
}
/** /**
* Finds the offset LOOSE_TEXT_ADDR matches at, or -1 when it does not match at all. * Finds the offset LOOSE_TEXT_ADDR matches at, or -1 when it does not match at all.
* *
@@ -115,8 +138,8 @@ function _looseAddressStart(text) {
while (runEnd < len && !_isSpaceCode(text.charCodeAt(runEnd))) { while (runEnd < len && !_isSpaceCode(text.charCodeAt(runEnd))) {
runEnd++; runEnd++;
} }
let at = text.indexOf('@', runStart); let at = _indexOfAt(text, runStart, runEnd);
if (at >= 0 && at < runEnd) { if (at >= 0) {
let lastBoundary = -1; let lastBoundary = -1;
for (let k = runEnd; k > runStart; k--) { for (let k = runEnd; k > runStart; k--) {
if (_isBoundary(text, k)) { if (_isBoundary(text, k)) {
@@ -125,7 +148,7 @@ function _looseAddressStart(text) {
} }
} }
let atomStart = runStart; let atomStart = runStart;
while (lastBoundary >= 0 && at >= 0 && at < runEnd) { while (lastBoundary >= 0 && at >= 0) {
// '[^@\s]+' has to cover a character before the '@' and '[^\s]+' one after it, // '[^@\s]+' has to cover a character before the '@' and '[^\s]+' one after it,
// and the boundary that ends the match has to sit past both // and the boundary that ends the match has to sit past both
if (at > atomStart && runEnd > at + 1 && lastBoundary > at + 1) { if (at > atomStart && runEnd > at + 1 && lastBoundary > at + 1) {
@@ -145,7 +168,7 @@ function _looseAddressStart(text) {
} }
} }
atomStart = at + 1; atomStart = at + 1;
at = text.indexOf('@', atomStart); at = _indexOfAt(text, atomStart, runEnd);
} }
} }
pos = runEnd; pos = runEnd;
@@ -272,6 +295,19 @@ function _handleAddress(tokens, depth) {
} }
} }
else if (token.value) { else if (token.value) {
// An empty quoted string is dropped by the tokenizer, leaving no text token of its
// own for textWasQuoted to be recorded on, so the pair of quote operators right in
// front of this token is all that is left of it and the run it opens carries the
// quoting instead. Without this '""@example.com' reads as the bare '@example.com',
// the quotes never go back on, and the value is no longer an addr-spec a trailing
// comment can be peeled off of. It only ever opens a run: a run that already holds
// material collected outside the quotes is not a quoted string, whatever follows it
const prevPrevToken = i > 1 ? tokens[i - 2] : null;
const opensAfterEmptyQuotedString = prevToken?.type === 'operator' &&
prevToken.value === '"' &&
!!prevToken.noBreak &&
prevPrevToken?.type === 'operator' &&
prevPrevToken.value === '"';
if (state === 'address') { if (state === 'address') {
// Handle unquoted name that includes a "<". // Handle unquoted name that includes a "<".
// Apple Mail truncates everything between an unexpected < and an address. // Apple Mail truncates everything between an unexpected < and an address.
@@ -299,7 +335,7 @@ function _handleAddress(tokens, depth) {
data[state].push(token.value); data[state].push(token.value);
lastChars[state] = token.value.charAt(token.value.length - 1); lastChars[state] = token.value.charAt(token.value.length - 1);
if (state === 'text') { if (state === 'text') {
data.textWasQuoted.push(insideQuotes); data.textWasQuoted.push(insideQuotes || opensAfterEmptyQuotedString);
} }
} }
} }
@@ -384,6 +420,18 @@ function _handleAddress(tokens, depth) {
// Join values with spaces // Join values with spaces
data.text = data.text.join(' '); data.text = data.text.join(' ');
data.address = data.address.join(' '); data.address = data.address.join(' ');
if (addressFromQuotedText && data.text) {
// The mailbox is still sitting in the text, so it moves over here and is quoted
// before the recovery below rather than after it. Anything else the text holds
// came along with it: a comment ends the domain but leaves the atoms behind it in
// the same text, and '"user"@example.com(x)evil.com' was handed on as the address
// 'user@example.com evil.com', a second domain riding into the envelope recipient
// on a value that is no addr-spec at all (GHSA-g57g-f23g-4646). Putting the quotes
// back first is what lets the recovery tell the whitespace an addr-spec may carry
// from the wreckage trailing one, as only a quoted local part may hold whitespace
data.address = _quoteLocalPart(data.text);
data.text = '';
}
_recoverAddrSpec(data); _recoverAddrSpec(data);
const address = { const address = {
address: data.address || data.text || '', address: data.address || data.text || '',
@@ -397,9 +445,6 @@ function _handleAddress(tokens, depth) {
address.address = ''; address.address = '';
} }
} }
if (addressFromQuotedText && address.address) {
address.address = _quoteLocalPart(address.address);
}
addresses.push(address); addresses.push(address);
} }
return addresses; return addresses;
+12 -5
View File
@@ -124,11 +124,18 @@ export default class MessageParser extends Transform {
// signature covers exactly the bytes the receiving side canonicalizes // signature covers exactly the bytes the receiving side canonicalizes
// Only SP and HTAB fold a line, and only they are trimmed from the field name, the // Only SP and HTAB fold a line, and only they are trimmed from the field name, the
// same whitespace the relaxed canonicalization in sign.ts works with // same whitespace the relaxed canonicalization in sign.ts works with
const lines = (this.rawHeaders || Buffer.alloc(0)).toString('binary').split(/\r?\n/); const rawLines = (this.rawHeaders || Buffer.alloc(0)).toString('binary').split(/\r?\n/);
for (let i = lines.length - 1; i > 0; i--) { // Unfold in a single forward pass and only ever test a freshly split line for the
if (/^[ \t]/.test(lines[i])) { // continuation prefix. Testing an already merged line instead would rescan a string
lines[i - 1] += '\n' + lines[i]; // that grows with every continuation line, so a header folded into many continuation
lines.splice(i, 1); // lines (a large recipient list, for example) would take quadratic time to unfold
const lines = [];
for (const rawLine of rawLines) {
if (lines.length && /^[ \t]/.test(rawLine)) {
lines[lines.length - 1] += '\n' + rawLine;
}
else {
lines.push(rawLine);
} }
} }
return lines return lines
+3 -1
View File
@@ -113,8 +113,10 @@ class MailComposer {
data.filename = attachment.filename; data.filename = attachment.filename;
} }
else if (!isMessageNode && attachment.filename !== false) { else if (!isMessageNode && attachment.filename !== false) {
// a backslash separates as well, so a Windows path does not put the sender's directories in the headers
data.filename = data.filename =
(attachment.path || attachment.href || '').split('/').pop().split('?').shift() || 'attachment-' + (i + 1); (attachment.path || attachment.href || '').split(/[/\\]/).pop().split('?').shift() ||
'attachment-' + (i + 1);
if (data.filename.indexOf('.') < 0) { if (data.filename.indexOf('.') < 0) {
data.filename += '.' + mimeFuncs.detectExtension(data.contentType); data.filename += '.' + mimeFuncs.detectExtension(data.contentType);
} }
+2 -1
View File
@@ -69,8 +69,9 @@ export default class MailMessage {
if (this.data.attachments && this.data.attachments.length) { if (this.data.attachments && this.data.attachments.length) {
this.data.attachments.forEach((attachment, i) => { this.data.attachments.forEach((attachment, i) => {
if (!attachment.filename) { if (!attachment.filename) {
// a backslash separates as well, so a Windows path does not put the sender's directories in the headers
attachment.filename = attachment.filename =
(attachment.path || attachment.href || '').split('/').pop().split('?').shift() || (attachment.path || attachment.href || '').split(/[/\\]/).pop().split('?').shift() ||
'attachment-' + (i + 1); 'attachment-' + (i + 1);
if (attachment.filename.indexOf('.') < 0) { if (attachment.filename.indexOf('.') < 0) {
attachment.filename += '.' + mimeFuncs.detectExtension(attachment.contentType); attachment.filename += '.' + mimeFuncs.detectExtension(attachment.contentType);
+1 -1
View File
@@ -1,3 +1,3 @@
export declare const name = "nodemailer"; export declare const name = "nodemailer";
export declare const version = "10.0.8"; export declare const version = "10.0.10";
export declare const homepage = "https://nodemailer.com/"; export declare const homepage = "https://nodemailer.com/";
+1 -1
View File
@@ -1,4 +1,4 @@
// Generated by scripts/build.js from package.json. Do not edit by hand. // Generated by scripts/build.js from package.json. Do not edit by hand.
export const name = 'nodemailer'; export const name = 'nodemailer';
export const version = '10.0.8'; export const version = '10.0.10';
export const homepage = 'https://nodemailer.com/'; export const homepage = 'https://nodemailer.com/';
+3
View File
@@ -41,6 +41,8 @@ export interface SMTPConnectionOptions {
greetingTimeout?: number | undefined; greetingTimeout?: number | undefined;
/** Time of inactivity in ms until the connection is closed, defaults to 10 minutes */ /** Time of inactivity in ms until the connection is closed, defaults to 10 minutes */
socketTimeout?: number | undefined; socketTimeout?: number | undefined;
/** Largest single server response to accept in bytes, defaults to 1 MB */
maxResponseSize?: number | undefined;
/** Time to wait in ms for the DNS requests to be resolved, defaults to 30 seconds */ /** Time to wait in ms for the DNS requests to be resolved, defaults to 30 seconds */
dnsTimeout?: number | undefined; dnsTimeout?: number | undefined;
/** Use LMTP instead of SMTP */ /** Use LMTP instead of SMTP */
@@ -268,6 +270,7 @@ export type SMTPConnectionResponseAction = (str: string) => void;
* * **greetingTimeout** - Time to wait in ms until greeting message is received from the server (defaults to 30 seconds) * * **greetingTimeout** - Time to wait in ms until greeting message is received from the server (defaults to 30 seconds)
* * **connectionTimeout** - how many milliseconds to wait for the connection to establish (defaults to 2 minutes) * * **connectionTimeout** - how many milliseconds to wait for the connection to establish (defaults to 2 minutes)
* * **socketTimeout** - Time of inactivity until the connection is closed (defaults to 10 minutes) * * **socketTimeout** - Time of inactivity until the connection is closed (defaults to 10 minutes)
* * **maxResponseSize** - Largest single server response to accept in bytes (defaults to 1 MB)
* * **dnsTimeout** - Time to wait in ms for the DNS requests to be resolved (defaults to 30 seconds) * * **dnsTimeout** - Time to wait in ms for the DNS requests to be resolved (defaults to 30 seconds)
* * **lmtp** - if true, uses LMTP instead of SMTP protocol * * **lmtp** - if true, uses LMTP instead of SMTP protocol
* * **logger** - bunyan compatible logger interface * * **logger** - bunyan compatible logger interface
+63 -19
View File
@@ -13,6 +13,9 @@ const SOCKET_TIMEOUT = 10 * 60 * 1000; // how much to wait for socket inactivity
const GREETING_TIMEOUT = 30 * 1000; // how much to wait after connection is established but SMTP greeting is not receieved const GREETING_TIMEOUT = 30 * 1000; // how much to wait after connection is established but SMTP greeting is not receieved
const DNS_TIMEOUT = 30 * 1000; // how much to wait for resolveHostname const DNS_TIMEOUT = 30 * 1000; // how much to wait for resolveHostname
const TEARDOWN_NOOP = () => { }; // reusable no-op handler for absorbing errors during socket teardown const TEARDOWN_NOOP = () => { }; // reusable no-op handler for absorbing errors during socket teardown
// how many bytes a single server response may occupy while it is still being received.
// Generous compared to any real reply, it only stops a peer that never completes one
const MAX_RESPONSE_SIZE = 1024 * 1024;
/** /**
* Re-interpret a server response stored in fake 8-bit byte-container form * Re-interpret a server response stored in fake 8-bit byte-container form
* (the result of chunk.toString('binary') in _onData) as UTF-8. * (the result of chunk.toString('binary') in _onData) as UTF-8.
@@ -41,11 +44,19 @@ function decodeServerResponse(str) {
* Called with the byte-container form the queue holds (see _onData): the check only looks * Called with the byte-container form the queue holds (see _onData): the check only looks
* at leading ASCII digits and '-' of the last line, and a UTF-8 continuation byte is never * at leading ASCII digits and '-' of the last line, and a UTF-8 continuation byte is never
* 0x0A, so line boundaries and the tested prefix are the same before and after decoding. * 0x0A, so line boundaries and the tested prefix are the same before and after decoding.
* The last line is read with lastIndexOf rather than split() because a queue entry grows * The last line is read with lastIndexOf rather than split() because a queue entry may hold
* with every chunk appended to it and only its final line matters. * a whole multiline reply and only its final line matters.
*/ */
function isPartialResponse(str) { function isPartialResponse(str) {
return /^\d+-/.test(str.slice(str.lastIndexOf('\n') + 1)); return isPartialLine(str.slice(str.lastIndexOf('\n') + 1));
}
/**
* True when a single reply line is a continuation ("250-..."). Used where the line is
* already known to be one line, which skips the scan isPartialResponse needs to find the
* last line of a whole reply.
*/
function isPartialLine(line) {
return /^\d+-/.test(line);
} }
/** /**
* Generates a SMTP connection object * Generates a SMTP connection object
@@ -62,6 +73,7 @@ function isPartialResponse(str) {
* * **greetingTimeout** - Time to wait in ms until greeting message is received from the server (defaults to 30 seconds) * * **greetingTimeout** - Time to wait in ms until greeting message is received from the server (defaults to 30 seconds)
* * **connectionTimeout** - how many milliseconds to wait for the connection to establish (defaults to 2 minutes) * * **connectionTimeout** - how many milliseconds to wait for the connection to establish (defaults to 2 minutes)
* * **socketTimeout** - Time of inactivity until the connection is closed (defaults to 10 minutes) * * **socketTimeout** - Time of inactivity until the connection is closed (defaults to 10 minutes)
* * **maxResponseSize** - Largest single server response to accept in bytes (defaults to 1 MB)
* * **dnsTimeout** - Time to wait in ms for the DNS requests to be resolved (defaults to 30 seconds) * * **dnsTimeout** - Time to wait in ms for the DNS requests to be resolved (defaults to 30 seconds)
* * **lmtp** - if true, uses LMTP instead of SMTP protocol * * **lmtp** - if true, uses LMTP instead of SMTP protocol
* * **logger** - bunyan compatible logger interface * * **logger** - bunyan compatible logger interface
@@ -108,6 +120,7 @@ class SMTPConnection extends EventEmitter {
this.secure = !!this.secureConnection; this.secure = !!this.secureConnection;
this._remainder = ''; this._remainder = '';
this._responseQueue = []; this._responseQueue = [];
this._responsePartial = false;
this.lastServerResponse = false; this.lastServerResponse = false;
this._socket = false; this._socket = false;
this._supportedAuth = []; this._supportedAuth = [];
@@ -676,28 +689,58 @@ class SMTPConnection extends EventEmitter {
if (this._destroyed || !chunk || !chunk.length) { if (this._destroyed || !chunk || !chunk.length) {
return; return;
} }
let data = chunk.toString('binary'); const maxResponseSize = this.options.maxResponseSize || MAX_RESPONSE_SIZE;
let lines = (this._remainder + data).split(/\r?\n/); const data = chunk.toString('binary');
let lastline; // A chunk without a line break only extends the line currently being received, so
this._remainder = lines.pop(); // keep it in the remainder and leave that string unflattened. Splitting the whole
for (let i = 0, len = lines.length; i < len; i++) { // remainder again on every chunk would rescan everything buffered for that line so
if (this._responseQueue.length) { // far, which is quadratic in the length of a line the peer never terminates
lastline = this._responseQueue[this._responseQueue.length - 1]; if (!data.includes('\n')) {
if (isPartialResponse(lastline)) { this._remainder += data;
this._responseQueue[this._responseQueue.length - 1] += '\n' + lines[i]; if (this._remainder.length > maxResponseSize) {
continue; return this._onResponseTooLarge();
} }
}
this._responseQueue.push(lines[i]);
}
if (this._responseQueue.length) {
lastline = this._responseQueue[this._responseQueue.length - 1];
if (isPartialResponse(lastline)) {
return; return;
} }
const lines = (this._remainder + data).split(/\r?\n/);
this._remainder = lines.pop();
for (let i = 0, len = lines.length; i < len; i++) {
if (this._responsePartial) {
this._responseQueue[this._responseQueue.length - 1] += '\n' + lines[i];
}
else {
this._responseQueue.push(lines[i]);
}
// The line just added is the last line of that queue entry, so it alone decides
// whether the reply is still partial. Looking for the last line of the accumulated
// entry instead would rescan a string that grows with every continuation line,
// which is quadratic in the size of the reply
this._responsePartial = isPartialLine(lines[i]);
// Checked as each line lands, so a peer that never completes a reply cannot keep
// buffering, and the limit does not depend on how it split its bytes into chunks
if (this._responsePartial && this._responseQueue[this._responseQueue.length - 1].length > maxResponseSize) {
return this._onResponseTooLarge();
}
}
if (this._remainder.length > maxResponseSize) {
return this._onResponseTooLarge();
}
if (this._responsePartial) {
return;
} }
this._processResponse(); this._processResponse();
} }
/**
* Drops a connection whose peer keeps extending a reply it never completes, releasing
* whatever was buffered for that reply
* @internal
*/
_onResponseTooLarge() {
this._remainder = '';
this._responseQueue = [];
this._responsePartial = false;
this._onError(new Error('Server response exceeds maximum allowed size'), 'EPROTOCOL', false, 'CONN');
}
/** /**
* 'error' listener for the socket * 'error' listener for the socket
* *
@@ -838,6 +881,7 @@ class SMTPConnection extends EventEmitter {
// part of the secured EHLO capabilities). STARTTLS response injection. // part of the secured EHLO capabilities). STARTTLS response injection.
this._remainder = ''; this._remainder = '';
this._responseQueue = []; this._responseQueue = [];
this._responsePartial = false;
// do not remove all listeners or it breaks node v0.10 as there's // do not remove all listeners or it breaks node v0.10 as there's
// apparently a 'finish' event set that would be cleared as well // apparently a 'finish' event set that would be cleared as well
// we can safely keep 'error', 'end', 'close' etc. events // we can safely keep 'error', 'end', 'close' etc. events
+6 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "nodemailer", "name": "nodemailer",
"version": "10.0.8", "version": "10.0.10",
"description": "Easy as cake e-mail sending from your Node.js applications", "description": "Easy as cake e-mail sending from your Node.js applications",
"type": "module", "type": "module",
"main": "./dist/cjs/nodemailer.js", "main": "./dist/cjs/nodemailer.js",
@@ -147,24 +147,24 @@
}, },
"homepage": "https://nodemailer.com/", "homepage": "https://nodemailer.com/",
"devDependencies": { "devDependencies": {
"@aws-sdk/client-sesv2": "3.1124.0", "@aws-sdk/client-sesv2": "3.1131.0",
"@types/node": "20.19.43", "@types/node": "20.19.43",
"bunyan": "1.8.15", "bunyan": "1.8.15",
"c8": "12.0.0", "c8": "12.0.0",
"eslint": "10.9.1", "eslint": "10.10.0",
"eslint-config-prettier": "10.1.8", "eslint-config-prettier": "10.1.8",
"globals": "17.12.0", "globals": "17.12.0",
"libbase64": "1.3.0", "libbase64": "1.3.0",
"libmime": "5.4.3", "libmime": "5.4.3",
"libqp": "2.1.1", "libqp": "2.1.1",
"mailauth": "5.0.2", "mailauth": "5.0.3",
"prettier": "3.9.6", "prettier": "3.9.6",
"proxy": "1.0.2", "proxy": "1.0.2",
"proxy-test-server": "1.0.0", "proxy-test-server": "1.0.0",
"smtp-server": "3.19.7", "smtp-server": "3.19.11",
"tsx": "4.23.13", "tsx": "4.23.13",
"typescript": "6.0.3", "typescript": "6.0.3",
"typescript-eslint": "8.69.0" "typescript-eslint": "8.70.0"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"