node_modules: update (#338)

Co-authored-by: dawidd6 <9713907+dawidd6@users.noreply.github.com>
This commit is contained in:
Dawid Dziurlaanddawidd6 authored and GitHub committed 2026-10-03 10:23:35 +02:00
1 parent e1936242ed
commit a9c5eb6c35
39 files changed
+799 -388

No files matched your search

+97
View File
@@ -240,6 +240,81 @@ function _recoverAddrSpec(data) {
.filter(part => part)
.join(' ');
}
/**
* Takes the RFC 5322 comments out of the contents of an angle-addr.
*
* The tokenizer tracks a single open operator, so once '<' is open a '(' is plain text and
* a comment inside the brackets reached the address verbatim: 'Name <user@example.com(x)evil.com>'
* put 'user@example.com(x)evil.com' into the envelope, a value that is no mailbox and that a
* receiver stripping the comment reads as 'user@example.comevil.com' (GHSA-g73g-hqqh-jr95).
* A comment is folding whitespace, so it is read as a space that splits two atoms and as
* nothing next to an '@', the same rule the token walk applies outside the brackets. Quoted
* strings and domain literals are copied through untouched, a '(' in there is not a comment.
*
* @param address Contents of the angle brackets
* @return The address with the comments removed, and the text of the comments
*/
function _stripAddressComments(address) {
const comments = [];
let result = '';
let comment = '';
let depth = 0;
let closer = '';
// carried along rather than read back off the growing result, which would flatten it on
// every comment (see lastChars in _handleAddress)
let lastChar = '';
for (let i = 0, len = address.length; i < len; i++) {
const chr = address.charAt(i);
if (depth) {
if (chr === '\\' && i < len - 1) {
comment += address.charAt(++i);
}
else if (chr === '(') {
depth++;
comment += chr;
}
else if (chr === ')' && !--depth) {
comments.push(comment.trim());
comment = '';
if (lastChar !== '@' && address.charAt(i + 1) !== '@') {
result += ' ';
lastChar = ' ';
}
}
else {
comment += chr;
}
continue;
}
if (closer) {
if (chr === '\\' && closer === '"' && i < len - 1) {
result += chr + address.charAt(++i);
lastChar = address.charAt(i);
continue;
}
if (chr === closer) {
closer = '';
}
}
else if (chr === '"') {
closer = '"';
}
else if (chr === '[') {
closer = ']';
}
else if (chr === '(') {
depth = 1;
continue;
}
result += chr;
lastChar = chr;
}
if (depth) {
// an unterminated comment runs to the end of the address
comments.push(comment.trim());
}
return { address: result.trim(), comments: comments.filter(text => text) };
}
/**
* Converts tokens for a single address into an address object
*
@@ -367,6 +442,24 @@ function _handleAddress(tokens, depth) {
});
}
else {
// Comments come out of the angle-addr before anything asks whether one was found, so
// that brackets holding nothing but a comment read the same as empty ones
const addressComments = [];
const addressParts = [];
for (const part of data.address) {
if (part.indexOf('(') < 0) {
addressParts.push(part);
continue;
}
const stripped = _stripAddressComments(part);
for (const comment of stripped.comments) {
addressComments.push(comment);
}
if (stripped.address) {
addressParts.push(stripped.address);
}
}
data.address = addressParts;
// If no address was found, try to detect one from regular text
if (!data.address.length && data.text.length) {
for (let i = data.text.length - 1; i >= 0; i--) {
@@ -433,6 +526,10 @@ function _handleAddress(tokens, depth) {
data.text = '';
}
_recoverAddrSpec(data);
// a comment names the mailbox only when nothing else does, as one outside the brackets
if (!data.text && addressComments.length) {
data.text = addressComments.join(' ');
}
const address = {
address: data.address || data.text || '',
name: data.text || data.address || ''
+25 -8
View File
@@ -85,13 +85,22 @@ class DKIMSigner {
return;
}
const key = this.keys[keyPos++];
const dkimField = sign(this.headers, this.hashAlgo, this.bodyHash, {
domainName: key.domainName,
keySelector: key.keySelector,
privateKey: key.privateKey,
headerFieldNames: this.options.headerFieldNames,
skipFields: this.options.skipFields
});
let dkimField;
try {
dkimField = sign(this.headers, this.hashAlgo, this.bodyHash, {
domainName: key.domainName,
keySelector: key.keySelector,
privateKey: key.privateKey,
headerFieldNames: this.options.headerFieldNames,
skipFields: this.options.skipFields
});
}
catch (err) {
this.hasErrored = true;
this.cleanup();
this.output.emit('error', err);
return;
}
if (dkimField) {
this.output.write(Buffer.from(dkimField + '\r\n'));
}
@@ -191,7 +200,15 @@ class DKIM {
}
const signer = new DKIMSigner(options, this.keys, inputStream, output);
setImmediate(() => {
signer.signStream();
try {
signer.signStream();
}
catch (_E) {
// the body hash is created here, an unknown hashAlgo throws inside this timer
// where nothing else could catch it
output.emit('error', sign.unsupportedHashAlgoError(signer.hashAlgo));
return;
}
if (writeValue) {
setImmediate(() => {
inputStream.end(writeValue);
+2
View File
@@ -1,4 +1,5 @@
import crypto from 'node:crypto';
import type { NodemailerError } from '../errors.js';
import type { MessageParserHeaderLine } from './message-parser.js';
/**
* Private key accepted by crypto.Sign#sign: a PEM string, a Buffer, a KeyObject or an
@@ -45,5 +46,6 @@ export interface DKIMRelaxedHeaders {
declare function sign(headers: MessageParserHeaderLine[], hashAlgo: string, bodyHash: string, options?: DKIMSignOptions): string | false;
declare namespace sign {
var relaxedHeaders: (headers: MessageParserHeaderLine[], fieldNames?: string, skipFields?: string) => DKIMRelaxedHeaders;
var unsupportedHashAlgoError: (hashAlgo: string) => NodemailerError;
}
export default sign;
+17 -1
View File
@@ -1,6 +1,15 @@
import * as punycode from '../punycode/index.js';
import * as mimeFuncs from '../mime-funcs/index.js';
import crypto from 'node:crypto';
import * as errors from '../errors.js';
/**
* Error for a hashAlgo value the crypto module does not know
*/
function unsupportedHashAlgoError(hashAlgo) {
const err = new Error('Unsupported DKIM hash algorithm "' + hashAlgo + '"');
err.code = errors.ECONFIG;
return err;
}
/**
* Returns DKIM signature header line
*
@@ -25,7 +34,13 @@ function sign(headers, hashAlgo, bodyHash, options) {
const canonicalizedHeaderData = relaxedHeaders(headers, fieldNames, options.skipFields);
const dkimHeader = generateDKIMHeader(options.domainName, options.keySelector, canonicalizedHeaderData.fieldNames, hashAlgo, bodyHash);
canonicalizedHeaderData.headers += 'dkim-signature:' + relaxedHeaderLine(dkimHeader);
const signer = crypto.createSign(('rsa-' + hashAlgo).toUpperCase());
let signer;
try {
signer = crypto.createSign(('rsa-' + hashAlgo).toUpperCase());
}
catch (_E) {
throw unsupportedHashAlgoError(hashAlgo);
}
// the header lines are 'binary' strings, so this reproduces the original header bytes
signer.update(canonicalizedHeaderData.headers, 'latin1');
let signature;
@@ -38,6 +53,7 @@ function sign(headers, hashAlgo, bodyHash, options) {
return dkimHeader + signature.replace(/(^.{73}|.{75}(?!\r?\n|\r))/g, '$&\r\n ').trim();
}
sign.relaxedHeaders = relaxedHeaders;
sign.unsupportedHashAlgoError = unsupportedHashAlgoError;
export default sign;
function generateDKIMHeader(domainName, keySelector, fieldNames, hashAlgo, bodyHash) {
// the caller supplied tag values are interpolated straight into the tag list, and none of
+4 -1
View File
@@ -25,8 +25,10 @@ export interface FetchOptions {
tls?: {
[key: string]: any;
} | undefined;
/** Request timeout in milliseconds */
/** Socket inactivity timeout in milliseconds, defaults to 60000, 0 disables it */
timeout?: number | undefined;
/** Maximum size of the (decoded) response body in bytes, defaults to 64 MB, Infinity disables the limit */
maxBytes?: number | undefined;
/** Maximum number of redirects to follow (default 5) */
maxRedirects?: number | undefined;
/** Resolve responses with a status code of 300 or above instead of emitting an error */
@@ -47,6 +49,7 @@ export interface FetchResponse extends PassThrough {
declare function nmfetch(url: string, options?: FetchOptions): FetchResponse;
declare namespace nmfetch {
var Cookies: typeof import("./cookies.js").default;
var DEFAULT_TIMEOUT: number;
}
type CookiesJar = Cookies;
/**
+34 -59
View File
@@ -9,6 +9,10 @@ import net from 'node:net';
import * as errors from '../errors.js';
import { isProtoKey } from '../shared/objects.js';
const MAX_REDIRECTS = 5;
// a stalled server would otherwise hold the request, and whatever waits on it, open forever
const DEFAULT_TIMEOUT = 60 * 1000;
// the body is usually buffered in memory by the caller, so an unbounded download is refused
const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
// Only genuine TLS settings are taken from options.tls. That object reaches us straight
// from a user supplied attachment (content.tls), so keys like host, port, path, socketPath
// or lookup would otherwise repoint the request at a destination that never went through
@@ -214,28 +218,22 @@ function nmfetch(url, options) {
});
return fetchRes;
}
if (options.timeout) {
req.setTimeout(options.timeout, () => {
if (finished) {
return;
}
finished = true;
req.abort();
const err = new Error('Request Timeout');
err.code = errors.EFETCH;
err.sourceUrl = url;
fetchRes.emit('error', err);
});
}
req.on('error', (err) => {
// reports the first failure of this request on fetchRes and releases the request
const fail = (err, sourceUrl = url) => {
if (finished) {
return;
}
finished = true;
err.code = errors.EFETCH;
err.sourceUrl = url;
err.sourceUrl = sourceUrl;
fetchRes.emit('error', err);
});
req.abort();
};
const timeout = typeof options.timeout === 'number' && options.timeout >= 0 ? options.timeout : DEFAULT_TIMEOUT;
if (timeout) {
req.setTimeout(timeout, () => fail(new Error('Request Timeout')));
}
req.on('error', (err) => fail(err));
req.on('response', res => {
let inflate;
if (finished) {
@@ -256,13 +254,7 @@ function nmfetch(url, options) {
// redirect
options.redirects++;
if (options.redirects > options.maxRedirects) {
finished = true;
const err = new Error('Maximum redirect count exceeded');
err.code = errors.EFETCH;
err.sourceUrl = url;
fetchRes.emit('error', err);
req.abort();
return;
return fail(new Error('Maximum redirect count exceeded'));
}
// redirect does not include POST body
options.method = 'GET';
@@ -282,13 +274,7 @@ function nmfetch(url, options) {
// call: that call gets its own `finished` flag and no handle on this
// request, so this one would stay open and could emit a second error on
// the shared fetchRes once it times out. Callers listen with req.once().
finished = true;
const err = new Error('Unsupported protocol for URL ' + redirectUrl);
err.code = errors.EFETCH;
err.sourceUrl = redirectUrl;
fetchRes.emit('error', err);
req.abort();
return;
return fail(new Error('Unsupported protocol for URL ' + redirectUrl), redirectUrl);
}
// Do not forward credentials when the redirect leaves the original
// security context: a different host, or a downgrade from https to
@@ -305,41 +291,33 @@ function nmfetch(url, options) {
}
});
}
// this request is done with, release it so its socket and timeout do not
// outlive it and report a late error on the shared fetchRes
finished = true;
res.resume();
req.abort();
return nmfetch(redirectUrl, options);
}
fetchRes.statusCode = res.statusCode;
fetchRes.headers = res.headers;
if (res.statusCode >= 300 && !options.allowErrorResponse) {
finished = true;
const err = new Error('Invalid status code ' + res.statusCode);
err.code = errors.EFETCH;
err.sourceUrl = url;
fetchRes.emit('error', err);
req.abort();
return;
return fail(new Error('Invalid status code ' + res.statusCode));
}
res.on('error', (err) => {
if (finished) {
res.on('error', (err) => fail(err));
const maxBytes = typeof options.maxBytes === 'number' && options.maxBytes > 0 ? options.maxBytes : DEFAULT_MAX_BYTES;
const source = inflate || res;
let received = 0;
source.on('data', (chunk) => {
received += chunk.length;
if (received <= maxBytes || finished) {
return;
}
finished = true;
err.code = errors.EFETCH;
err.sourceUrl = url;
fetchRes.emit('error', err);
req.abort();
source.unpipe(fetchRes);
fail(new Error('Response size exceeds the allowed ' + maxBytes + ' bytes'));
});
if (inflate) {
res.pipe(inflate).pipe(fetchRes);
inflate.on('error', (err) => {
if (finished) {
return;
}
finished = true;
err.code = errors.EFETCH;
err.sourceUrl = url;
fetchRes.emit('error', err);
req.abort();
});
inflate.on('error', (err) => fail(err));
}
else {
res.pipe(fetchRes);
@@ -354,11 +332,7 @@ function nmfetch(url, options) {
req.write(body);
}
catch (err) {
finished = true;
err.code = errors.EFETCH;
err.sourceUrl = url;
fetchRes.emit('error', err);
return;
return fail(err);
}
}
req.end();
@@ -366,4 +340,5 @@ function nmfetch(url, options) {
return fetchRes;
}
nmfetch.Cookies = Cookies;
nmfetch.DEFAULT_TIMEOUT = DEFAULT_TIMEOUT;
export default nmfetch;
+1 -1
View File
@@ -1,3 +1,3 @@
export declare const name = "nodemailer";
export declare const version = "10.0.11";
export declare const version = "10.0.13";
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.
export const name = 'nodemailer';
export const version = '10.0.11';
export const version = '10.0.13';
export const homepage = 'https://nodemailer.com/';
+2 -1
View File
@@ -1,7 +1,8 @@
import { Transform, type TransformOptions } from 'node:stream';
/**
* Escapes dots in the beginning of lines. Ends the stream with <CR><LF>.<CR><LF>
* Also makes sure that only <CR><LF> sequences are used for linebreaks
* Also makes sure that only <CR><LF> sequences are used for linebreaks, bare CR and bare LF
* are both turned into <CR><LF>
*
* @param options Stream options
*/
+32 -24
View File
@@ -1,7 +1,13 @@
import { Transform } from 'node:stream';
// bytes inserted into the output, shared as they are only ever copied by Buffer.concat
const INSERT_LF = Buffer.from('\n');
const INSERT_LF_DOT = Buffer.from('\n.');
const INSERT_CR = Buffer.from('\r');
const INSERT_DOT = Buffer.from('.');
/**
* Escapes dots in the beginning of lines. Ends the stream with <CR><LF>.<CR><LF>
* Also makes sure that only <CR><LF> sequences are used for linebreaks
* Also makes sure that only <CR><LF> sequences are used for linebreaks, bare CR and bare LF
* are both turned into <CR><LF>
*
* @param options Stream options
*/
@@ -30,33 +36,35 @@ export default class DataStream extends Transform {
}
this.inByteCount += chunk.length;
for (i = 0, len = chunk.length; i < len; i++) {
if (chunk[i] === 0x2e) {
// .
if ((i && chunk[i - 1] === 0x0a) || (!i && (!this.lastByte || this.lastByte === 0x0a))) {
buf = chunk.slice(lastPos, i + 1);
chunks.push(buf);
chunks.push(Buffer.from('.'));
chunklen += buf.length + 1;
lastPos = i + 1;
}
const byte = chunk[i];
const prev = i ? chunk[i - 1] : this.lastByte;
let insert = false;
if (prev === 0x0d && byte !== 0x0a) {
// a bare CR becomes CRLF. A receiver that treats a lone CR as a line end would
// otherwise see "\r.\r" as the end of the data (SMTP smuggling), so a dot
// following it is stuffed like at the start of any other line
insert = byte === 0x2e ? INSERT_LF_DOT : INSERT_LF;
}
else if (chunk[i] === 0x0a) {
// \n
if ((i && chunk[i - 1] !== 0x0d) || (!i && this.lastByte !== 0x0d)) {
if (i > lastPos) {
buf = chunk.slice(lastPos, i);
chunks.push(buf);
chunklen += buf.length + 2;
}
else {
chunklen += 2;
}
chunks.push(Buffer.from('\r\n'));
lastPos = i + 1;
else if (byte === 0x0a && prev !== 0x0d) {
// a bare LF becomes CRLF
insert = INSERT_CR;
}
else if (byte === 0x2e && (prev === 0x0a || prev === false)) {
// a dot at the start of a line
insert = INSERT_DOT;
}
if (insert) {
if (i > lastPos) {
buf = chunk.slice(lastPos, i);
chunks.push(buf);
chunklen += buf.length;
}
chunks.push(insert);
chunklen += insert.length;
lastPos = i;
}
}
if (chunklen) {
if (chunks.length) {
// add last piece
if (lastPos < chunk.length) {
buf = chunk.slice(lastPos);
+5 -5
View File
@@ -26,11 +26,11 @@ export interface SMTPConnectionOptions {
secured?: boolean | undefined;
/** Server name for SNI, defaults to host when that is not an IP address */
servername?: string | undefined;
/** Ignore STARTTLS even when the server advertises it */
/** Ignore STARTTLS even when the server advertises it, has no effect when requireTLS is set */
ignoreTLS?: boolean | undefined;
/** Force STARTTLS, fail when the server does not support it */
/** Force STARTTLS, fail when the server does not support it. Takes precedence over ignoreTLS and opportunisticTLS */
requireTLS?: boolean | undefined;
/** Continue unencrypted when the STARTTLS upgrade fails */
/** Continue unencrypted when the STARTTLS upgrade fails, has no effect when requireTLS is set */
opportunisticTLS?: boolean | undefined;
/** Name of the client server, sent with EHLO/HELO, CRLF is stripped */
name?: string | undefined;
@@ -288,8 +288,8 @@ export type SMTPConnectionResponseAction = (str: string) => void;
* * **port** - is the port to connect to (defaults to 587 or 465)
* * **host** - is the hostname or IP address to connect to (defaults to 'localhost')
* * **secure** - use SSL
* * **ignoreTLS** - ignore server support for STARTTLS
* * **requireTLS** - forces the client to use STARTTLS
* * **ignoreTLS** - ignore server support for STARTTLS (has no effect when requireTLS is set)
* * **requireTLS** - forces the client to use STARTTLS, takes precedence over ignoreTLS and opportunisticTLS
* * **name** - the name of the client server
* * **localAddress** - outbound address to bind to (see: http://nodejs.org/api/net.html#net_net_connect_options_connectionlistener)
* * **greetingTimeout** - Time to wait in ms until greeting message is received from the server (defaults to 30 seconds)
+77 -23
View File
@@ -66,8 +66,8 @@ function isPartialLine(line) {
* * **port** - is the port to connect to (defaults to 587 or 465)
* * **host** - is the hostname or IP address to connect to (defaults to 'localhost')
* * **secure** - use SSL
* * **ignoreTLS** - ignore server support for STARTTLS
* * **requireTLS** - forces the client to use STARTTLS
* * **ignoreTLS** - ignore server support for STARTTLS (has no effect when requireTLS is set)
* * **requireTLS** - forces the client to use STARTTLS, takes precedence over ignoreTLS and opportunisticTLS
* * **name** - the name of the client server
* * **localAddress** - outbound address to bind to (see: http://nodejs.org/api/net.html#net_net_connect_options_connectionlistener)
* * **greetingTimeout** - Time to wait in ms until greeting message is received from the server (defaults to 30 seconds)
@@ -92,6 +92,11 @@ class SMTPConnection extends EventEmitter {
this.id = crypto.randomBytes(8).toString('base64').replace(/\W/g, '');
this.stage = 'init';
this.options = options || {};
if (this.options.requireTLS && (this.options.ignoreTLS || this.options.opportunisticTLS)) {
// requireTLS wins, a contradictory configuration must not quietly fall back to plaintext.
// Copied so the caller's (possibly shared) options object is left as it was
this.options = Object.assign({}, this.options, { ignoreTLS: false, opportunisticTLS: false });
}
this.secureConnection = !!this.options.secure;
this.alreadySecured = !!this.options.secured;
this.port = Number(this.options.port) || (this.secureConnection ? 465 : 587);
@@ -135,6 +140,8 @@ class SMTPConnection extends EventEmitter {
this._destroyed = false;
this._closing = false;
this._currentDataStream = false;
this._pendingSend = false;
this._connectCallback = false;
this._onSocketData = chunk => this._onData(chunk);
this._onSocketError = error => this._onError(error, 'ESOCKET', false, 'CONN');
this._onSocketClose = () => this._onClose();
@@ -149,7 +156,9 @@ class SMTPConnection extends EventEmitter {
*/
connect(connectCallback) {
if (typeof connectCallback === 'function') {
this._connectCallback = connectCallback;
this.once('connect', () => {
this._connectCallback = false;
this.logger.debug({
tnx: 'smtp'
}, 'SMTP handshake finished');
@@ -381,6 +390,16 @@ class SMTPConnection extends EventEmitter {
}
this._currentDataStream = false;
}
// Detach from the message stream as well. The listener is swapped for a no-op rather than
// removed, a stream destroyed with an error later on would otherwise throw it as unhandled
if (this._pendingSend) {
const { stream, onStreamError } = this._pendingSend;
if (stream) {
stream.removeListener('error', onStreamError);
stream.on('error', TEARDOWN_NOOP);
}
this._pendingSend = false;
}
if (socket && !socket.destroyed) {
try {
// Clear socket timeout to prevent timer leaks
@@ -568,6 +587,9 @@ class SMTPConnection extends EventEmitter {
return;
}
returned = true;
if (this._pendingSend && this._pendingSend.callback === callback) {
this._pendingSend = false;
}
done(err, info);
};
if (!message) {
@@ -584,9 +606,16 @@ class SMTPConnection extends EventEmitter {
});
return;
}
const pendingSend = {
callback,
stream: false,
onStreamError: err => callback(this._formatError(err, 'ESTREAM', false, 'API'))
};
if (typeof message.on === 'function') {
message.on('error', err => callback(this._formatError(err, 'ESTREAM', false, 'API')));
pendingSend.stream = message;
pendingSend.stream.on('error', pendingSend.onStreamError);
}
this._pendingSend = pendingSend;
const startTime = Date.now();
this._setEnvelope(envelope, (err, info) => {
if (err) {
@@ -779,8 +808,14 @@ class SMTPConnection extends EventEmitter {
else {
this.logger.error(data, err.message);
}
// close() forgets the send in flight, it is completed with this same error afterwards so
// a late message stream error has nothing left to report
const pendingSend = this._pendingSend;
this.emit('error', err);
this.close();
if (pendingSend) {
pendingSend.callback(err);
}
}
/** @internal */
_formatError(message, type, response, command) {
@@ -826,14 +861,25 @@ class SMTPConnection extends EventEmitter {
this.logger.info({
tnx: 'network'
}, 'Connection closed');
// the unterminated remainder is only reported as a reply (and so gives the error a responseCode)
// when it starts like a complete failure reply, not for a fragment such as "55" or a 250
const failureResponse = typeof serverResponse === 'string' && /^[45]\d{2}[ -]/.test(serverResponse) ? serverResponse : false;
if (this.upgrading && !this._destroyed) {
return this._onError(new Error('Connection closed unexpectedly'), 'ETLS', serverResponse, 'CONN');
return this._onError(new Error('Connection closed unexpectedly'), 'ETLS', failureResponse, 'CONN');
}
else if (![this._actionGreeting, this.close].includes(this._responseActions[0]) && !this._destroyed) {
return this._onError(new Error('Connection closed unexpectedly'), 'ECONNECTION', serverResponse, 'CONN');
if (!failureResponse && this._responseActions[0] === this._actionGreeting && this._connectCallback && !this._destroyed) {
// A silent close before the greeting is handed to the connect() callback rather than
// emitted as 'error', callers that never saw an error for it must not start throwing one
const connectCallback = this._connectCallback;
this._connectCallback = false;
const err = this._formatError(new Error('Connection closed unexpectedly'), 'ECONNECTION', false, 'CONN');
this.logger.warn({ tnx: 'network' }, err.message);
connectCallback(err);
this.close();
return;
}
else if (/^[45]\d{2}\b/.test(serverResponse)) {
return this._onError(new Error('Connection closed unexpectedly'), 'ECONNECTION', serverResponse, 'CONN');
if (failureResponse || (this._responseActions[0] !== this.close && !this._destroyed)) {
return this._onError(new Error('Connection closed unexpectedly'), 'ECONNECTION', failureResponse, 'CONN');
}
this._destroy();
}
@@ -1310,21 +1356,25 @@ class SMTPConnection extends EventEmitter {
if (/[ -]AUTH\b/i.test(str)) {
this.allowsAuth = true;
}
// Detect if the server supports PLAIN auth
if (/[ -]AUTH(?:(\s+|=)[^\n]*\s+|\s+|=)PLAIN/i.test(str)) {
this._supportedAuth.push('PLAIN');
// Detect the advertised SASL mechanisms. The list is split into whole tokens rather
// than searched for each name with a pattern: the patterns this replaced let two
// whitespace runs overlap and backtracked quadratically over an AUTH line padded with
// spaces, so a hostile server could stall the event loop from its EHLO reply
// (GHSA-4ffr-jq9g-5ffx).
const authMechanisms = new Set();
for (const line of this._ehloLines) {
const authMatch = /^AUTH[\s=](.*)/i.exec(line);
if (authMatch) {
for (const mechanism of authMatch[1].split(/[\s=]+/)) {
authMechanisms.add(mechanism.toUpperCase());
}
}
}
// Detect if the server supports LOGIN auth
if (/[ -]AUTH(?:(\s+|=)[^\n]*\s+|\s+|=)LOGIN/i.test(str)) {
this._supportedAuth.push('LOGIN');
}
// Detect if the server supports CRAM-MD5 auth
if (/[ -]AUTH(?:(\s+|=)[^\n]*\s+|\s+|=)CRAM-MD5/i.test(str)) {
this._supportedAuth.push('CRAM-MD5');
}
// Detect if the server supports XOAUTH2 auth
if (/[ -]AUTH(?:(\s+|=)[^\n]*\s+|\s+|=)XOAUTH2/i.test(str)) {
this._supportedAuth.push('XOAUTH2');
// listed in order of preference, the first one the credentials allow is used
for (const mechanism of ['PLAIN', 'LOGIN', 'CRAM-MD5', 'XOAUTH2']) {
if (authMechanisms.has(mechanism)) {
this._supportedAuth.push(mechanism);
}
}
// Detect if the server supports SIZE extensions (and the max allowed size)
if ((match = str.match(/[ -]SIZE(?:[ \t]+(\d+))?/im))) {
@@ -1584,7 +1634,11 @@ class SMTPConnection extends EventEmitter {
this._sendCommand('DATA');
}
else {
err = this._formatError("Can't send mail - all recipients were rejected", 'EENVELOPE', str, 'RCPT TO');
// report a temporary rejection when there is one, taking the last reply would mark the
// whole message as permanently failed although some recipients were only deferred
const deferred = envelope.rejectedErrors.find(rejectedErr => rejectedErr.responseCode && rejectedErr.responseCode < 500);
const reply = deferred?.response ?? str;
err = this._formatError("Can't send mail - all recipients were rejected", 'EENVELOPE', reply, 'RCPT TO');
err.rejected = envelope.rejected;
err.rejectedErrors = envelope.rejectedErrors;
return callback(err);
+2 -1
View File
@@ -17,7 +17,7 @@ export interface SMTPPoolOptions extends SMTPTransportOptions {
rateLimit?: number | undefined;
/** Time window for rateLimit in milliseconds, defaults to 1000 */
rateDelta?: number | undefined;
/** How many times a message is requeued when its connection closes while sending, unlimited when not set or negative */
/** How many times a message is requeued when its connection closes while sending, defaults to 5, a negative value means unlimited */
maxRequeues?: number | undefined;
}
/**
@@ -26,6 +26,7 @@ export interface SMTPPoolOptions extends SMTPTransportOptions {
export type SMTPPoolResolvedOptions = SMTPPoolOptions & {
maxConnections: number;
maxMessages: number;
maxRequeues: number;
};
/**
* Result of a message sent through the pool, same as for the SMTP transport
+31 -5
View File
@@ -5,6 +5,10 @@ import wellKnown from '../well-known/index.js';
import * as shared from '../shared/index.js';
import * as errors from '../errors.js';
import * as packageData from '../package-info.js';
/** First delay before a requeued message is retried, doubled on every further requeue */
const REQUEUE_BASE_DELAY = 50;
/** Upper bound for the requeue delay */
const REQUEUE_MAX_DELAY = 2000;
/**
* Creates a SMTP pool transport object for Nodemailer
*
@@ -36,6 +40,8 @@ class SMTPPool extends EventEmitter {
);
this.options.maxConnections = this.options.maxConnections || 5;
this.options.maxMessages = this.options.maxMessages || 100;
// a default bound, a server that closes every connection before the greeting would otherwise be retried forever
this.options.maxRequeues = typeof this.options.maxRequeues === 'number' ? this.options.maxRequeues : 5;
this.logger = shared.getLogger(this.options, {
component: this.options.component || 'smtp-pool'
});
@@ -79,6 +85,10 @@ class SMTPPool extends EventEmitter {
*/
send(mail, callback) {
if (this._closed) {
// Mail.sendMail ignores the return value, so without a callback its promise would never settle
const err = new Error('Connection pool was closed');
err.code = errors.ECONNECTION;
setImmediate(() => callback(err));
return false;
}
this._queue.push({
@@ -292,15 +302,22 @@ class SMTPPool extends EventEmitter {
// Note that we must wait a bit.. because the callback of the 'error' handler might be called
// in the next event loop
setTimeout(() => {
let delay = 0;
if (connection.queueEntry) {
if (this._shouldRequeuOnConnectionClose(connection.queueEntry)) {
this._requeueEntryOnConnectionClose(connection);
delay = this._requeueEntryOnConnectionClose(connection);
}
else {
this._failDeliveryOnConnectionClose(connection);
}
}
this._continueProcessing();
if (delay) {
// back off, a server that keeps dropping connections is not hammered
setTimeout(() => this._continueProcessing(), delay);
}
else {
this._continueProcessing();
}
}, 50);
}
else {
@@ -315,7 +332,7 @@ class SMTPPool extends EventEmitter {
}
/** @internal */
_shouldRequeuOnConnectionClose(queueEntry) {
if (this.options.maxRequeues === undefined || this.options.maxRequeues < 0) {
if (this.options.maxRequeues < 0) {
return true;
}
return queueEntry.requeueAttempts < this.options.maxRequeues;
@@ -324,7 +341,9 @@ class SMTPPool extends EventEmitter {
_failDeliveryOnConnectionClose(connection) {
if (connection.queueEntry && connection.queueEntry.callback) {
try {
connection.queueEntry.callback(new Error('Reached maximum number of retries after connection was closed'));
const err = new Error('Reached maximum number of retries after connection was closed');
err.code = errors.ECONNECTION;
connection.queueEntry.callback(err);
}
catch (E) {
this.logger.error({
@@ -339,6 +358,7 @@ class SMTPPool extends EventEmitter {
}
/** @internal */
_requeueEntryOnConnectionClose(connection) {
const delay = Math.min(REQUEUE_BASE_DELAY * 2 ** connection.queueEntry.requeueAttempts, REQUEUE_MAX_DELAY);
connection.queueEntry.requeueAttempts += 1;
this.logger.debug({
tnx: 'pool',
@@ -348,6 +368,7 @@ class SMTPPool extends EventEmitter {
}, 'Re-queued message <%s> for #%s. Attempt: #%s', connection.queueEntry.messageId, connection.id, connection.queueEntry.requeueAttempts);
this._queue.unshift(connection.queueEntry);
connection.queueEntry = false;
return delay;
}
/**
* Continue to process message if the pool hasn't closed
@@ -468,10 +489,15 @@ class SMTPPool extends EventEmitter {
connection.quit();
return done(null, true);
};
connection.connect(() => {
connection.connect(err => {
if (returned) {
return;
}
if (err) {
returned = true;
connection.close();
return done(err);
}
if (auth && (connection.allowsAuth || options.forceAuth)) {
connection.login(auth, err => {
if (returned) {
+27 -29
View File
@@ -28,7 +28,7 @@ export default class PoolResource extends EventEmitter {
method: 'XOAUTH2'
};
oauth2.on('token', (token) => this.pool.mailer.emit('token', token));
oauth2.on('error', err => this.emit('error', err));
oauth2.on('error', err => this._fail(err));
break;
}
default:
@@ -51,6 +51,19 @@ export default class PoolResource extends EventEmitter {
this._connected = false;
this.messages = 0;
this.available = true;
this._failed = false;
}
/**
* Emits 'error' for the first failure only. A dead resource can report the same failure more
* than once (the connection error, then the send callback), the pool handles it once
* @internal
*/
_fail(err) {
if (this._failed) {
return;
}
this._failed = true;
this.emit('error', err);
}
/**
* Initiates a connection to the SMTP server
@@ -63,7 +76,7 @@ export default class PoolResource extends EventEmitter {
// nothing was connected, so no 'close' event is coming that would free the
// slot this resource holds in the pool, report the failure the way a failed
// login does
this.emit('error', err);
this._fail(err);
return callback(err);
}
let returned = false;
@@ -80,8 +93,8 @@ export default class PoolResource extends EventEmitter {
options = Object.assign(assign(false, options), socketOptions);
}
this.connection = new SMTPConnection(options);
this.connection.once('error', err => {
this.emit('error', err);
this.connection.on('error', err => {
this._fail(err);
if (returned) {
return;
}
@@ -90,31 +103,16 @@ export default class PoolResource extends EventEmitter {
});
this.connection.once('end', () => {
this.close();
returned = true;
});
this.connection.connect(err => {
if (returned) {
return;
}
returned = true;
const timer = setTimeout(() => {
if (returned) {
return;
}
// still have not returned, this means we have an unexpected connection close
const err = new Error('Unexpected socket close');
if (this.connection && this.connection.upgrading) {
// starttls connection errors
err.code = errors.ETLS;
}
callback(err);
}, 1000);
try {
timer.unref();
}
catch (_E) {
// Ignore. Happens on envs with non-node timer implementation
}
});
this.connection.connect(() => {
if (returned) {
if (err) {
// a close before the greeting, the 'end' that follows closes this resource
// and the pool requeues or fails the entry, bounded by maxRequeues
returned = true;
return;
}
if (this.auth && (this.connection.allowsAuth || options.forceAuth)) {
@@ -125,7 +123,7 @@ export default class PoolResource extends EventEmitter {
returned = true;
if (err) {
this.connection.close();
this.emit('error', err);
this._fail(err);
return callback(err);
}
this._connected = true;
@@ -177,7 +175,7 @@ export default class PoolResource extends EventEmitter {
this.messages++;
if (err) {
this.connection.close();
this.emit('error', err);
this._fail(err);
return callback(err);
}
info.envelope = {
@@ -190,7 +188,7 @@ export default class PoolResource extends EventEmitter {
const err = new Error('Resource exhausted');
err.code = errors.EMAXLIMIT;
this.connection.close();
this.emit('error', err);
this._fail(err);
}
else {
this.pool._checkRateLimit(() => {
+17 -27
View File
@@ -139,31 +139,6 @@ class SMTPTransport extends EventEmitter {
connection.close();
return callback(err);
});
connection.once('end', () => {
if (returned) {
return;
}
const timer = setTimeout(() => {
if (returned) {
return;
}
returned = true;
cleanupPerCallAuth();
// still have not returned, this means we have an unexpected connection close
const err = new Error('Unexpected socket close');
if (connection && connection.upgrading) {
// starttls connection errors
err.code = errors.ETLS;
}
callback(err);
}, 1000);
try {
timer.unref();
}
catch (_E) {
// Ignore. Happens on envs with non-node timer implementation
}
});
const sendMessage = () => {
const envelope = mail.message.getEnvelope();
const messageId = mail.message.messageId();
@@ -183,6 +158,10 @@ class SMTPTransport extends EventEmitter {
messageId
}, 'Sending message %s to <%s>', messageId, recipients.join(', '));
connection.send(envelope, mail.message.createReadStream(), (err, info) => {
if (returned) {
// the connection error handler has already reported this send
return;
}
returned = true;
cleanupPerCallAuth();
connection.close();
@@ -209,10 +188,16 @@ class SMTPTransport extends EventEmitter {
}
});
};
connection.connect(() => {
connection.connect(err => {
if (returned) {
return;
}
if (err) {
// the server closed the connection before the greeting
returned = true;
connection.close();
return callback(err);
}
perCallAuth = this.getAuth(mail.data.auth);
if (perCallAuth && (connection.allowsAuth || options.forceAuth)) {
connection.login(perCallAuth, err => {
@@ -294,10 +279,15 @@ class SMTPTransport extends EventEmitter {
connection.quit();
return done(null, true);
};
connection.connect(() => {
connection.connect(err => {
if (returned) {
return;
}
if (err) {
returned = true;
connection.close();
return done(err);
}
perCallAuth = this.getAuth({});
if (perCallAuth && (connection.allowsAuth || options.forceAuth)) {
connection.login(perCallAuth, err => {
+2
View File
@@ -61,6 +61,8 @@ export interface XOAuth2Options {
component?: string | undefined;
/** Extra headers for the token request */
customHeaders?: OutgoingHttpHeaders | undefined;
/** Timeout for the token request in milliseconds, defaults to 60000, 0 disables it */
requestTimeout?: number | undefined;
/** Extra form fields for the token request */
customParams?: {
[key: string]: any;
+11 -3
View File
@@ -36,12 +36,14 @@ class XOAuth2 extends Stream {
constructor(options, logger) {
super();
this.options = options || {};
this.configError = false;
if (options && options.serviceClient) {
if (!options.privateKey || !options.user) {
// reported through getToken() rather than an 'error' event: the transports forward
// that event up to the Mail object, where nothing may be listening
const err = new Error('Options "privateKey" and "user" are required for service account!');
err.code = errors.EOAUTH2;
setImmediate(() => this.emit('error', err));
return;
this.configError = err;
}
const serviceRequestTimeout = Math.min(Math.max(Number(this.options.serviceRequestTimeout) || 0, 0), 3600);
this.options.serviceRequestTimeout = serviceRequestTimeout || 5 * 60;
@@ -75,6 +77,9 @@ class XOAuth2 extends Stream {
getToken(renew, callback) {
// the error paths hand over the error alone
const done = callback;
if (this.configError) {
return done(this.configError);
}
if (!renew && this.accessToken && (!this.expires || this.expires > Date.now())) {
this.logger.debug({
tnx: 'OAUTH2',
@@ -311,7 +316,10 @@ class XOAuth2 extends Stream {
method: 'post',
headers: params.customHeaders,
body: payload,
allowErrorResponse: true
allowErrorResponse: true,
// unset falls back to the fetch default, a stalled token endpoint would otherwise keep
// `renewing` set and queue every later request
timeout: params.requestTimeout
};
// OAuth2 token endpoints are credential-bearing. src/fetch already
// validates certs by default; pin rejectUnauthorized:true here so the