diff --git a/node_modules/.package-lock.json b/node_modules/.package-lock.json index f386dad9..d1d9f61c 100644 --- a/node_modules/.package-lock.json +++ b/node_modules/.package-lock.json @@ -94,9 +94,9 @@ } }, "node_modules/nodemailer": { - "version": "10.0.11", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.11.tgz", - "integrity": "sha512-/c4P7U7aGpiNu2Rl08q525q2zECkUpSaf2zQPgIStCcAjHAkkQQKwD4sTyi3l91mGcLQrKzL1syXPHymo+Ogtw==", + "version": "10.0.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.13.tgz", + "integrity": "sha512-SzG86OlvcW/NNhUFC6uROMwRTL4n7MswfQqC/T8mhkmnY1YVa23zUEMYi4ijSeXSl9GLz9ZeTJDUatEDuY5FeQ==", "license": "MIT-0", "engines": { "node": ">=20.0.0" diff --git a/node_modules/nodemailer/CHANGELOG.md b/node_modules/nodemailer/CHANGELOG.md index 3f258d10..f4f622f9 100644 --- a/node_modules/nodemailer/CHANGELOG.md +++ b/node_modules/nodemailer/CHANGELOG.md @@ -1,5 +1,20 @@ # CHANGELOG +## [10.0.13](https://github.com/nodemailer/nodemailer/compare/v10.0.12...v10.0.13) (2026-09-30) + + +### Bug Fixes + +* read the advertised SASL methods without backtracking regexes ([b5a896f](https://github.com/nodemailer/nodemailer/commit/b5a896fcb02df1628329d7cd665ca08490b14b56)) +* strip comments inside an angle-addr before it becomes the address ([a502247](https://github.com/nodemailer/nodemailer/commit/a502247555fd81773eac337486781f3b6008e16a)) + +## [10.0.12](https://github.com/nodemailer/nodemailer/compare/v10.0.11...v10.0.12) (2026-09-28) + + +### Bug Fixes + +* settle every send on a connection error, back off pool requeues, turn a bare CR into CRLF, bound fetch, honour requireTLS ([63ccd66](https://github.com/nodemailer/nodemailer/commit/63ccd66c894bbaac18c82084ee385bf6488226cf)) + ## [10.0.11](https://github.com/nodemailer/nodemailer/compare/v10.0.10...v10.0.11) (2026-09-27) diff --git a/node_modules/nodemailer/dist/cjs/addressparser/index.js b/node_modules/nodemailer/dist/cjs/addressparser/index.js index 0f71ee1d..6d52da9d 100644 --- a/node_modules/nodemailer/dist/cjs/addressparser/index.js +++ b/node_modules/nodemailer/dist/cjs/addressparser/index.js @@ -242,6 +242,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 ' + * 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 * @@ -369,6 +444,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--) { @@ -435,6 +528,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 || '' diff --git a/node_modules/nodemailer/dist/cjs/dkim/index.js b/node_modules/nodemailer/dist/cjs/dkim/index.js index 2b311a6a..9a5dd898 100644 --- a/node_modules/nodemailer/dist/cjs/dkim/index.js +++ b/node_modules/nodemailer/dist/cjs/dkim/index.js @@ -90,13 +90,22 @@ class DKIMSigner { return; } const key = this.keys[keyPos++]; - const dkimField = (0, sign_js_1.default)(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 = (0, sign_js_1.default)(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')); } @@ -196,7 +205,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_js_1.default.unsupportedHashAlgoError(signer.hashAlgo)); + return; + } if (writeValue) { setImmediate(() => { inputStream.end(writeValue); diff --git a/node_modules/nodemailer/dist/cjs/dkim/sign.d.ts b/node_modules/nodemailer/dist/cjs/dkim/sign.d.ts index 51c54f1c..a1e4beda 100644 --- a/node_modules/nodemailer/dist/cjs/dkim/sign.d.ts +++ b/node_modules/nodemailer/dist/cjs/dkim/sign.d.ts @@ -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; diff --git a/node_modules/nodemailer/dist/cjs/dkim/sign.js b/node_modules/nodemailer/dist/cjs/dkim/sign.js index 291deeec..6fe8c4e9 100644 --- a/node_modules/nodemailer/dist/cjs/dkim/sign.js +++ b/node_modules/nodemailer/dist/cjs/dkim/sign.js @@ -39,6 +39,15 @@ Object.defineProperty(exports, "__esModule", { value: true }); const punycode = __importStar(require("../punycode/index.js")); const mimeFuncs = __importStar(require("../mime-funcs/index.js")); const node_crypto_1 = __importDefault(require("node:crypto")); +const errors = __importStar(require("../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 * @@ -63,7 +72,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 = node_crypto_1.default.createSign(('rsa-' + hashAlgo).toUpperCase()); + let signer; + try { + signer = node_crypto_1.default.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; @@ -76,6 +91,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; exports.default = sign; function generateDKIMHeader(domainName, keySelector, fieldNames, hashAlgo, bodyHash) { // the caller supplied tag values are interpolated straight into the tag list, and none of diff --git a/node_modules/nodemailer/dist/cjs/fetch/index.d.ts b/node_modules/nodemailer/dist/cjs/fetch/index.d.ts index 4f0cc61c..731c88c2 100644 --- a/node_modules/nodemailer/dist/cjs/fetch/index.d.ts +++ b/node_modules/nodemailer/dist/cjs/fetch/index.d.ts @@ -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; /** diff --git a/node_modules/nodemailer/dist/cjs/fetch/index.js b/node_modules/nodemailer/dist/cjs/fetch/index.js index 7da06315..8bf8d215 100644 --- a/node_modules/nodemailer/dist/cjs/fetch/index.js +++ b/node_modules/nodemailer/dist/cjs/fetch/index.js @@ -47,6 +47,10 @@ const node_net_1 = __importDefault(require("node:net")); const errors = __importStar(require("../errors.js")); const objects_js_1 = require("../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 @@ -252,28 +256,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) { @@ -294,13 +292,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'; @@ -320,13 +312,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 @@ -343,41 +329,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); @@ -392,11 +370,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(); @@ -404,6 +378,7 @@ function nmfetch(url, options) { return fetchRes; } nmfetch.Cookies = cookies_js_1.default; +nmfetch.DEFAULT_TIMEOUT = DEFAULT_TIMEOUT; exports.default = nmfetch; module.exports = exports.default; Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true }); diff --git a/node_modules/nodemailer/dist/cjs/package-info.d.ts b/node_modules/nodemailer/dist/cjs/package-info.d.ts index 5f166c65..5a97db40 100644 --- a/node_modules/nodemailer/dist/cjs/package-info.d.ts +++ b/node_modules/nodemailer/dist/cjs/package-info.d.ts @@ -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/"; diff --git a/node_modules/nodemailer/dist/cjs/package-info.js b/node_modules/nodemailer/dist/cjs/package-info.js index 98d7f778..6822cb55 100644 --- a/node_modules/nodemailer/dist/cjs/package-info.js +++ b/node_modules/nodemailer/dist/cjs/package-info.js @@ -3,5 +3,5 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.homepage = exports.version = exports.name = void 0; exports.name = 'nodemailer'; -exports.version = '10.0.11'; +exports.version = '10.0.13'; exports.homepage = 'https://nodemailer.com/'; diff --git a/node_modules/nodemailer/dist/cjs/smtp-connection/data-stream.d.ts b/node_modules/nodemailer/dist/cjs/smtp-connection/data-stream.d.ts index 2aa8ff8f..57578ba7 100644 --- a/node_modules/nodemailer/dist/cjs/smtp-connection/data-stream.d.ts +++ b/node_modules/nodemailer/dist/cjs/smtp-connection/data-stream.d.ts @@ -1,7 +1,8 @@ import { Transform, type TransformOptions } from 'node:stream'; /** * Escapes dots in the beginning of lines. Ends the stream with . - * Also makes sure that only sequences are used for linebreaks + * Also makes sure that only sequences are used for linebreaks, bare CR and bare LF + * are both turned into * * @param options Stream options */ diff --git a/node_modules/nodemailer/dist/cjs/smtp-connection/data-stream.js b/node_modules/nodemailer/dist/cjs/smtp-connection/data-stream.js index 37693d23..8333b1f2 100644 --- a/node_modules/nodemailer/dist/cjs/smtp-connection/data-stream.js +++ b/node_modules/nodemailer/dist/cjs/smtp-connection/data-stream.js @@ -1,9 +1,15 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const node_stream_1 = require("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 . - * Also makes sure that only sequences are used for linebreaks + * Also makes sure that only sequences are used for linebreaks, bare CR and bare LF + * are both turned into * * @param options Stream options */ @@ -32,33 +38,35 @@ class DataStream extends node_stream_1.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); diff --git a/node_modules/nodemailer/dist/cjs/smtp-connection/index.d.ts b/node_modules/nodemailer/dist/cjs/smtp-connection/index.d.ts index 498af745..87a5e643 100644 --- a/node_modules/nodemailer/dist/cjs/smtp-connection/index.d.ts +++ b/node_modules/nodemailer/dist/cjs/smtp-connection/index.d.ts @@ -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) diff --git a/node_modules/nodemailer/dist/cjs/smtp-connection/index.js b/node_modules/nodemailer/dist/cjs/smtp-connection/index.js index 0d2db5f2..2b06a180 100644 --- a/node_modules/nodemailer/dist/cjs/smtp-connection/index.js +++ b/node_modules/nodemailer/dist/cjs/smtp-connection/index.js @@ -104,8 +104,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) @@ -130,6 +130,11 @@ class SMTPConnection extends node_events_1.EventEmitter { this.id = node_crypto_1.default.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); @@ -173,6 +178,8 @@ class SMTPConnection extends node_events_1.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(); @@ -187,7 +194,9 @@ class SMTPConnection extends node_events_1.EventEmitter { */ connect(connectCallback) { if (typeof connectCallback === 'function') { + this._connectCallback = connectCallback; this.once('connect', () => { + this._connectCallback = false; this.logger.debug({ tnx: 'smtp' }, 'SMTP handshake finished'); @@ -419,6 +428,16 @@ class SMTPConnection extends node_events_1.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 @@ -606,6 +625,9 @@ class SMTPConnection extends node_events_1.EventEmitter { return; } returned = true; + if (this._pendingSend && this._pendingSend.callback === callback) { + this._pendingSend = false; + } done(err, info); }; if (!message) { @@ -622,9 +644,16 @@ class SMTPConnection extends node_events_1.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) { @@ -817,8 +846,14 @@ class SMTPConnection extends node_events_1.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) { @@ -864,14 +899,25 @@ class SMTPConnection extends node_events_1.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(); } @@ -1348,21 +1394,25 @@ class SMTPConnection extends node_events_1.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))) { @@ -1622,7 +1672,11 @@ class SMTPConnection extends node_events_1.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); diff --git a/node_modules/nodemailer/dist/cjs/smtp-pool/index.d.ts b/node_modules/nodemailer/dist/cjs/smtp-pool/index.d.ts index 9646cd50..9502b6e3 100644 --- a/node_modules/nodemailer/dist/cjs/smtp-pool/index.d.ts +++ b/node_modules/nodemailer/dist/cjs/smtp-pool/index.d.ts @@ -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 diff --git a/node_modules/nodemailer/dist/cjs/smtp-pool/index.js b/node_modules/nodemailer/dist/cjs/smtp-pool/index.js index 818cc4ce..4ed107a3 100644 --- a/node_modules/nodemailer/dist/cjs/smtp-pool/index.js +++ b/node_modules/nodemailer/dist/cjs/smtp-pool/index.js @@ -43,6 +43,10 @@ const index_js_2 = __importDefault(require("../well-known/index.js")); const shared = __importStar(require("../shared/index.js")); const errors = __importStar(require("../errors.js")); const packageData = __importStar(require("../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 * @@ -74,6 +78,8 @@ class SMTPPool extends node_events_1.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' }); @@ -117,6 +123,10 @@ class SMTPPool extends node_events_1.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({ @@ -330,15 +340,22 @@ class SMTPPool extends node_events_1.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 { @@ -353,7 +370,7 @@ class SMTPPool extends node_events_1.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; @@ -362,7 +379,9 @@ class SMTPPool extends node_events_1.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({ @@ -377,6 +396,7 @@ class SMTPPool extends node_events_1.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', @@ -386,6 +406,7 @@ class SMTPPool extends node_events_1.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 @@ -506,10 +527,15 @@ class SMTPPool extends node_events_1.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) { diff --git a/node_modules/nodemailer/dist/cjs/smtp-pool/pool-resource.js b/node_modules/nodemailer/dist/cjs/smtp-pool/pool-resource.js index 6374a5f4..cd7244fe 100644 --- a/node_modules/nodemailer/dist/cjs/smtp-pool/pool-resource.js +++ b/node_modules/nodemailer/dist/cjs/smtp-pool/pool-resource.js @@ -66,7 +66,7 @@ class PoolResource extends node_events_1.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: @@ -89,6 +89,19 @@ class PoolResource extends node_events_1.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 @@ -101,7 +114,7 @@ class PoolResource extends node_events_1.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; @@ -118,8 +131,8 @@ class PoolResource extends node_events_1.EventEmitter { options = Object.assign((0, index_js_2.assign)(false, options), socketOptions); } this.connection = new index_js_1.default(options); - this.connection.once('error', err => { - this.emit('error', err); + this.connection.on('error', err => { + this._fail(err); if (returned) { return; } @@ -128,31 +141,16 @@ class PoolResource extends node_events_1.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)) { @@ -163,7 +161,7 @@ class PoolResource extends node_events_1.EventEmitter { returned = true; if (err) { this.connection.close(); - this.emit('error', err); + this._fail(err); return callback(err); } this._connected = true; @@ -215,7 +213,7 @@ class PoolResource extends node_events_1.EventEmitter { this.messages++; if (err) { this.connection.close(); - this.emit('error', err); + this._fail(err); return callback(err); } info.envelope = { @@ -228,7 +226,7 @@ class PoolResource extends node_events_1.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(() => { diff --git a/node_modules/nodemailer/dist/cjs/smtp-transport/index.js b/node_modules/nodemailer/dist/cjs/smtp-transport/index.js index b341b8f0..bb23599b 100644 --- a/node_modules/nodemailer/dist/cjs/smtp-transport/index.js +++ b/node_modules/nodemailer/dist/cjs/smtp-transport/index.js @@ -177,31 +177,6 @@ class SMTPTransport extends node_events_1.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(); @@ -221,6 +196,10 @@ class SMTPTransport extends node_events_1.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(); @@ -247,10 +226,16 @@ class SMTPTransport extends node_events_1.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 => { @@ -332,10 +317,15 @@ class SMTPTransport extends node_events_1.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 => { diff --git a/node_modules/nodemailer/dist/cjs/xoauth2/index.d.ts b/node_modules/nodemailer/dist/cjs/xoauth2/index.d.ts index 4538d712..bbb8815a 100644 --- a/node_modules/nodemailer/dist/cjs/xoauth2/index.d.ts +++ b/node_modules/nodemailer/dist/cjs/xoauth2/index.d.ts @@ -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; diff --git a/node_modules/nodemailer/dist/cjs/xoauth2/index.js b/node_modules/nodemailer/dist/cjs/xoauth2/index.js index a6477be0..6738b0b3 100644 --- a/node_modules/nodemailer/dist/cjs/xoauth2/index.js +++ b/node_modules/nodemailer/dist/cjs/xoauth2/index.js @@ -74,12 +74,14 @@ class XOAuth2 extends node_stream_1.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; @@ -113,6 +115,9 @@ class XOAuth2 extends node_stream_1.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', @@ -349,7 +354,10 @@ class XOAuth2 extends node_stream_1.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 diff --git a/node_modules/nodemailer/dist/esm/addressparser/index.js b/node_modules/nodemailer/dist/esm/addressparser/index.js index 7afa44ff..b3ea5334 100644 --- a/node_modules/nodemailer/dist/esm/addressparser/index.js +++ b/node_modules/nodemailer/dist/esm/addressparser/index.js @@ -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 ' + * 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 || '' diff --git a/node_modules/nodemailer/dist/esm/dkim/index.js b/node_modules/nodemailer/dist/esm/dkim/index.js index 51c8d5b5..c2bf6d11 100644 --- a/node_modules/nodemailer/dist/esm/dkim/index.js +++ b/node_modules/nodemailer/dist/esm/dkim/index.js @@ -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); diff --git a/node_modules/nodemailer/dist/esm/dkim/sign.d.ts b/node_modules/nodemailer/dist/esm/dkim/sign.d.ts index 51c54f1c..a1e4beda 100644 --- a/node_modules/nodemailer/dist/esm/dkim/sign.d.ts +++ b/node_modules/nodemailer/dist/esm/dkim/sign.d.ts @@ -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; diff --git a/node_modules/nodemailer/dist/esm/dkim/sign.js b/node_modules/nodemailer/dist/esm/dkim/sign.js index 66c3aab0..85f843c6 100644 --- a/node_modules/nodemailer/dist/esm/dkim/sign.js +++ b/node_modules/nodemailer/dist/esm/dkim/sign.js @@ -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 diff --git a/node_modules/nodemailer/dist/esm/fetch/index.d.ts b/node_modules/nodemailer/dist/esm/fetch/index.d.ts index 4f0cc61c..731c88c2 100644 --- a/node_modules/nodemailer/dist/esm/fetch/index.d.ts +++ b/node_modules/nodemailer/dist/esm/fetch/index.d.ts @@ -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; /** diff --git a/node_modules/nodemailer/dist/esm/fetch/index.js b/node_modules/nodemailer/dist/esm/fetch/index.js index a38e44cd..1ef32080 100644 --- a/node_modules/nodemailer/dist/esm/fetch/index.js +++ b/node_modules/nodemailer/dist/esm/fetch/index.js @@ -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; diff --git a/node_modules/nodemailer/dist/esm/package-info.d.ts b/node_modules/nodemailer/dist/esm/package-info.d.ts index 5f166c65..5a97db40 100644 --- a/node_modules/nodemailer/dist/esm/package-info.d.ts +++ b/node_modules/nodemailer/dist/esm/package-info.d.ts @@ -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/"; diff --git a/node_modules/nodemailer/dist/esm/package-info.js b/node_modules/nodemailer/dist/esm/package-info.js index 6816d685..806eb69b 100644 --- a/node_modules/nodemailer/dist/esm/package-info.js +++ b/node_modules/nodemailer/dist/esm/package-info.js @@ -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/'; diff --git a/node_modules/nodemailer/dist/esm/smtp-connection/data-stream.d.ts b/node_modules/nodemailer/dist/esm/smtp-connection/data-stream.d.ts index 2aa8ff8f..57578ba7 100644 --- a/node_modules/nodemailer/dist/esm/smtp-connection/data-stream.d.ts +++ b/node_modules/nodemailer/dist/esm/smtp-connection/data-stream.d.ts @@ -1,7 +1,8 @@ import { Transform, type TransformOptions } from 'node:stream'; /** * Escapes dots in the beginning of lines. Ends the stream with . - * Also makes sure that only sequences are used for linebreaks + * Also makes sure that only sequences are used for linebreaks, bare CR and bare LF + * are both turned into * * @param options Stream options */ diff --git a/node_modules/nodemailer/dist/esm/smtp-connection/data-stream.js b/node_modules/nodemailer/dist/esm/smtp-connection/data-stream.js index 3da3bc1f..d2bd5f40 100644 --- a/node_modules/nodemailer/dist/esm/smtp-connection/data-stream.js +++ b/node_modules/nodemailer/dist/esm/smtp-connection/data-stream.js @@ -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 . - * Also makes sure that only sequences are used for linebreaks + * Also makes sure that only sequences are used for linebreaks, bare CR and bare LF + * are both turned into * * @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); diff --git a/node_modules/nodemailer/dist/esm/smtp-connection/index.d.ts b/node_modules/nodemailer/dist/esm/smtp-connection/index.d.ts index 498af745..87a5e643 100644 --- a/node_modules/nodemailer/dist/esm/smtp-connection/index.d.ts +++ b/node_modules/nodemailer/dist/esm/smtp-connection/index.d.ts @@ -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) diff --git a/node_modules/nodemailer/dist/esm/smtp-connection/index.js b/node_modules/nodemailer/dist/esm/smtp-connection/index.js index 83cbd18e..d9e2c3c0 100644 --- a/node_modules/nodemailer/dist/esm/smtp-connection/index.js +++ b/node_modules/nodemailer/dist/esm/smtp-connection/index.js @@ -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); diff --git a/node_modules/nodemailer/dist/esm/smtp-pool/index.d.ts b/node_modules/nodemailer/dist/esm/smtp-pool/index.d.ts index 9646cd50..9502b6e3 100644 --- a/node_modules/nodemailer/dist/esm/smtp-pool/index.d.ts +++ b/node_modules/nodemailer/dist/esm/smtp-pool/index.d.ts @@ -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 diff --git a/node_modules/nodemailer/dist/esm/smtp-pool/index.js b/node_modules/nodemailer/dist/esm/smtp-pool/index.js index 918b59d9..160c0aef 100644 --- a/node_modules/nodemailer/dist/esm/smtp-pool/index.js +++ b/node_modules/nodemailer/dist/esm/smtp-pool/index.js @@ -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) { diff --git a/node_modules/nodemailer/dist/esm/smtp-pool/pool-resource.js b/node_modules/nodemailer/dist/esm/smtp-pool/pool-resource.js index 5a8738ea..b7ce588c 100644 --- a/node_modules/nodemailer/dist/esm/smtp-pool/pool-resource.js +++ b/node_modules/nodemailer/dist/esm/smtp-pool/pool-resource.js @@ -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(() => { diff --git a/node_modules/nodemailer/dist/esm/smtp-transport/index.js b/node_modules/nodemailer/dist/esm/smtp-transport/index.js index 1ec9d1d9..9f575356 100644 --- a/node_modules/nodemailer/dist/esm/smtp-transport/index.js +++ b/node_modules/nodemailer/dist/esm/smtp-transport/index.js @@ -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 => { diff --git a/node_modules/nodemailer/dist/esm/xoauth2/index.d.ts b/node_modules/nodemailer/dist/esm/xoauth2/index.d.ts index 4538d712..bbb8815a 100644 --- a/node_modules/nodemailer/dist/esm/xoauth2/index.d.ts +++ b/node_modules/nodemailer/dist/esm/xoauth2/index.d.ts @@ -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; diff --git a/node_modules/nodemailer/dist/esm/xoauth2/index.js b/node_modules/nodemailer/dist/esm/xoauth2/index.js index f8cbacb3..4094aa8d 100644 --- a/node_modules/nodemailer/dist/esm/xoauth2/index.js +++ b/node_modules/nodemailer/dist/esm/xoauth2/index.js @@ -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 diff --git a/node_modules/nodemailer/package.json b/node_modules/nodemailer/package.json index 9b8103ec..4faacaad 100644 --- a/node_modules/nodemailer/package.json +++ b/node_modules/nodemailer/package.json @@ -1,6 +1,6 @@ { "name": "nodemailer", - "version": "10.0.11", + "version": "10.0.13", "description": "Easy as cake e-mail sending from your Node.js applications", "type": "module", "main": "./dist/cjs/nodemailer.js", @@ -151,24 +151,24 @@ }, "homepage": "https://nodemailer.com/", "devDependencies": { - "@aws-sdk/client-sesv2": "3.1141.0", + "@aws-sdk/client-sesv2": "3.1143.0", "@types/node": "20.19.43", "bunyan": "1.8.15", "c8": "12.0.0", "eslint": "10.11.0", "eslint-config-prettier": "10.1.8", "globals": "17.12.0", - "libbase64": "1.3.0", - "libmime": "5.4.4", - "libqp": "2.1.1", + "libbase64": "1.3.1", + "libmime": "5.4.6", + "libqp": "2.1.2", "mailauth": "7.1.0", "prettier": "3.9.9", "proxy": "1.0.2", "proxy-test-server": "1.0.0", - "smtp-server": "3.19.13", + "smtp-server": "3.19.15", "tsx": "4.23.15", "typescript": "6.0.3", - "typescript-eslint": "8.70.1" + "typescript-eslint": "8.71.0" }, "engines": { "node": ">=20.0.0"