From 6e88c72525e2d5729a6df35740e685171bc53712 Mon Sep 17 00:00:00 2001 From: Dawid Dziurla Date: Sat, 10 Oct 2026 17:27:57 +0200 Subject: [PATCH] node_modules: update (#344) Co-authored-by: dawidd6 <9713907+dawidd6@users.noreply.github.com> --- node_modules/.package-lock.json | 6 +- node_modules/nodemailer/CHANGELOG.md | 36 ++ .../dist/cjs/addressparser/index.js | 6 +- .../nodemailer/dist/cjs/base64/index.d.ts | 3 + .../nodemailer/dist/cjs/base64/index.js | 121 +++-- .../nodemailer/dist/cjs/dkim/index.js | 76 +-- .../dist/cjs/dkim/message-parser.js | 4 +- node_modules/nodemailer/dist/cjs/errors.d.ts | 17 + node_modules/nodemailer/dist/cjs/errors.js | 13 +- .../nodemailer/dist/cjs/fetch/cookies.js | 29 +- .../nodemailer/dist/cjs/fetch/index.js | 26 +- .../nodemailer/dist/cjs/mailer/index.js | 30 +- .../dist/cjs/mailer/mail-message.d.ts | 5 + .../dist/cjs/mailer/mail-message.js | 20 + .../nodemailer/dist/cjs/mime-node/index.d.ts | 2 +- .../nodemailer/dist/cjs/mime-node/index.js | 280 ++++++---- .../nodemailer/dist/cjs/nodemailer.js | 21 +- .../nodemailer/dist/cjs/package-info.d.ts | 2 +- .../nodemailer/dist/cjs/package-info.js | 2 +- .../nodemailer/dist/cjs/qp/index.d.ts | 2 + node_modules/nodemailer/dist/cjs/qp/index.js | 136 +++-- .../dist/cjs/sendmail-transport/index.js | 68 ++- .../nodemailer/dist/cjs/shared/index.d.ts | 33 +- .../nodemailer/dist/cjs/shared/index.js | 331 +++++++----- .../smtp-connection/http-proxy-client.d.ts | 4 +- .../cjs/smtp-connection/http-proxy-client.js | 156 +++--- .../dist/cjs/smtp-connection/index.d.ts | 8 +- .../dist/cjs/smtp-connection/index.js | 486 +++++++++++++----- .../nodemailer/dist/cjs/smtp-pool/index.d.ts | 3 + .../nodemailer/dist/cjs/smtp-pool/index.js | 8 +- .../dist/cjs/smtp-pool/pool-resource.js | 123 ++++- .../dist/cjs/smtp-transport/index.js | 10 +- .../dist/esm/addressparser/index.js | 6 +- .../nodemailer/dist/esm/base64/index.d.ts | 3 + .../nodemailer/dist/esm/base64/index.js | 121 +++-- .../nodemailer/dist/esm/dkim/index.js | 76 +-- .../dist/esm/dkim/message-parser.js | 4 +- node_modules/nodemailer/dist/esm/errors.d.ts | 17 + node_modules/nodemailer/dist/esm/errors.js | 10 + .../nodemailer/dist/esm/fetch/cookies.js | 29 +- .../nodemailer/dist/esm/fetch/index.js | 26 +- .../nodemailer/dist/esm/mailer/index.js | 30 +- .../dist/esm/mailer/mail-message.d.ts | 5 + .../dist/esm/mailer/mail-message.js | 20 + .../nodemailer/dist/esm/mime-node/index.d.ts | 2 +- .../nodemailer/dist/esm/mime-node/index.js | 282 ++++++---- .../nodemailer/dist/esm/nodemailer.js | 21 +- .../nodemailer/dist/esm/package-info.d.ts | 2 +- .../nodemailer/dist/esm/package-info.js | 2 +- .../nodemailer/dist/esm/qp/index.d.ts | 2 + node_modules/nodemailer/dist/esm/qp/index.js | 136 +++-- .../dist/esm/sendmail-transport/index.js | 68 ++- .../nodemailer/dist/esm/shared/index.d.ts | 33 +- .../nodemailer/dist/esm/shared/index.js | 326 +++++++----- .../smtp-connection/http-proxy-client.d.ts | 4 +- .../esm/smtp-connection/http-proxy-client.js | 156 +++--- .../dist/esm/smtp-connection/index.d.ts | 8 +- .../dist/esm/smtp-connection/index.js | 486 +++++++++++++----- .../nodemailer/dist/esm/smtp-pool/index.d.ts | 3 + .../nodemailer/dist/esm/smtp-pool/index.js | 8 +- .../dist/esm/smtp-pool/pool-resource.js | 123 ++++- .../dist/esm/smtp-transport/index.js | 10 +- node_modules/nodemailer/package.json | 4 +- 63 files changed, 2863 insertions(+), 1227 deletions(-) diff --git a/node_modules/.package-lock.json b/node_modules/.package-lock.json index f60c8f68..f65c65d2 100644 --- a/node_modules/.package-lock.json +++ b/node_modules/.package-lock.json @@ -94,9 +94,9 @@ } }, "node_modules/nodemailer": { - "version": "10.0.15", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.15.tgz", - "integrity": "sha512-EUqp5PhtcsYXs9Fq/lS7s/8zlTrnBqmzZWzFFROrNikMiz+om/YRKMwqN907t+0A1KKyT8jd39CBUbFZmaZmcA==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.1.0.tgz", + "integrity": "sha512-fXW8AulB25aOTuwfRYV6lqHyfz2VM4rr4SGXH03mE7x5znEUpzL5mu6anARUpZN7744Ziwkmr4AxSLPJsBQspg==", "license": "MIT-0", "engines": { "node": ">=20.0.0" diff --git a/node_modules/nodemailer/CHANGELOG.md b/node_modules/nodemailer/CHANGELOG.md index 8146ae1e..6ecd547c 100644 --- a/node_modules/nodemailer/CHANGELOG.md +++ b/node_modules/nodemailer/CHANGELOG.md @@ -1,5 +1,41 @@ # CHANGELOG +## [10.1.0](https://github.com/nodemailer/nodemailer/compare/v10.0.16...v10.1.0) (2026-10-10) + + +### Features + +* harden message streams, SMTP connections and the connection pool ([8009da6](https://github.com/nodemailer/nodemailer/commit/8009da6e10bb8e10d7f0948a25d65895fdcd64f1)) + + +### Bug Fixes + +* do not read the environment or the interface table on import ([094179d](https://github.com/nodemailer/nodemailer/commit/094179d176d2ebe7a28742411ad427d3c87a3981)) +* end the SMTP session on any 421 reply and drive the envelope through one step ([5638ac1](https://github.com/nodemailer/nodemailer/commit/5638ac1914342460df270f736e7ceb0a844cf83a)) +* keep ERR_ACCESS_DENIED from the Node.js permission model ([8b33a3d](https://github.com/nodemailer/nodemailer/commit/8b33a3d4fedee3431b97da7da12af954c3c8dceb)) +* let the proxy handshake and the SMTP connection share one deadline ([91ed683](https://github.com/nodemailer/nodemailer/commit/91ed683f7a1e9ff8c56e24fb76facf16af08b162)) +* **mime-node:** decode an SMTPUTF8 domain from its A-label form ([e436f4d](https://github.com/nodemailer/nodemailer/commit/e436f4dccbc8b0fb57b4747ebaafa5bb035c15cb)) +* **mime-node:** finish an encoded part only after its output was read ([ec97d8f](https://github.com/nodemailer/nodemailer/commit/ec97d8f679a1d9d47bbebb03ea064f5fa6f58d01)) +* **qp:** encode a lone CR or LF in binary quoted-printable parts ([42b91d9](https://github.com/nodemailer/nodemailer/commit/42b91d9a0fe18eea412c9774c5c142adf5b3efc7)) +* **smtp-connection:** count the wait for the greeting against connectionTimeout ([bdcd046](https://github.com/nodemailer/nodemailer/commit/bdcd0462cab163eca3c34058d09b9981ab671c47)) +* **smtp-connection:** drop the connection when DATA is taken after a refused sender ([f90fb04](https://github.com/nodemailer/nodemailer/commit/f90fb045bc24f96a128710791b2fa443e64f55fd)) +* **smtp-connection:** refuse a second connect() call ([24ba2e0](https://github.com/nodemailer/nodemailer/commit/24ba2e0847ecb8202540f1c5e53483bd119ae4f2)) +* turn Nagle off for SMTP sockets and report an early sendmail exit as such ([fa9b8d3](https://github.com/nodemailer/nodemailer/commit/fa9b8d300bf642cd9e11f6732ebb312c09868677)) + + +### Performance Improvements + +* encode base64 and quoted-printable without per-chunk delays ([fd377bb](https://github.com/nodemailer/nodemailer/commit/fd377bbf559363860aedbedba6f107348fc152d2)) + +## [10.0.16](https://github.com/nodemailer/nodemailer/compare/v10.0.15...v10.0.16) (2026-10-07) + + +### Bug Fixes + +* **addressparser:** keep an escaped parenthesis inside a comment ([c4ae20d](https://github.com/nodemailer/nodemailer/commit/c4ae20d074ac0510cc2d0cbd6039ee7fab3a4ca8)) +* **fetch:** keep the case of cookie names ([#1888](https://github.com/nodemailer/nodemailer/issues/1888)) ([9f16eed](https://github.com/nodemailer/nodemailer/commit/9f16eedf8de0485e6d07969113db992ff70f89ce)) +* **fetch:** read the cookie name-value pair by position ([645e97c](https://github.com/nodemailer/nodemailer/commit/645e97c8f0586404d9fb861c9527be7572c2c832)) + ## [10.0.15](https://github.com/nodemailer/nodemailer/compare/v10.0.14...v10.0.15) (2026-10-05) diff --git a/node_modules/nodemailer/dist/cjs/addressparser/index.js b/node_modules/nodemailer/dist/cjs/addressparser/index.js index 4c36464b..6618b403 100644 --- a/node_modules/nodemailer/dist/cjs/addressparser/index.js +++ b/node_modules/nodemailer/dist/cjs/addressparser/index.js @@ -670,7 +670,11 @@ class Tokenizer { this.escaped = false; return; } - else if (['"', "'"].includes(this.operatorExpecting) && chr === '\\') { + else if (['"', "'", ')'].includes(this.operatorExpecting) && chr === '\\') { + // A backslash escapes the next character inside a quoted string and inside a + // comment alike (RFC 5322 3.2.2, ccontent includes quoted-pair). Honouring it + // only in quoted strings let an escaped parenthesis close the comment, which + // released the rest of it, an address included, into the header this.escaped = true; return; } diff --git a/node_modules/nodemailer/dist/cjs/base64/index.d.ts b/node_modules/nodemailer/dist/cjs/base64/index.d.ts index 56f51cd0..28ee041a 100644 --- a/node_modules/nodemailer/dist/cjs/base64/index.d.ts +++ b/node_modules/nodemailer/dist/cjs/base64/index.d.ts @@ -24,6 +24,9 @@ export interface EncoderOptions { /** * Creates a transform stream for encoding data to base64 encoding * + * The output is the same as `wrap(encode(input), lineLength)` no matter how the input is split + * into chunks: every line but the last one ends with a line break, the last one does not + * * @constructor * @param options Stream options * @param [options.lineLength=76] Maximum length for lines, set to false to disable wrapping diff --git a/node_modules/nodemailer/dist/cjs/base64/index.js b/node_modules/nodemailer/dist/cjs/base64/index.js index 4e9e2193..8a2c8565 100644 --- a/node_modules/nodemailer/dist/cjs/base64/index.js +++ b/node_modules/nodemailer/dist/cjs/base64/index.js @@ -16,6 +16,47 @@ function encode(buffer) { } return buffer.toString('base64'); } +/** + * Turns a line length option into a whole number of characters, the default for anything unusable + */ +function normalizeLineLength(lineLength) { + const length = Math.floor(Number(lineLength)); + return Number.isFinite(length) && length >= 1 ? length : 76; +} +/** + * Splits the bytes of `src` into lines of `lineLength` bytes, each followed by a line break. With + * `final` set the last line, which may be shorter, gets no line break; otherwise only complete + * lines are taken and the rest is left for the caller + * + * @param src Bytes to wrap + * @param lineLength Line length + * @param final Whether `src` ends the output + * @returns The wrapped bytes and the number of trailing bytes not taken + */ +function wrapBuffer(src, lineLength, final) { + const lines = Math.ceil(src.length / lineLength); + // the last line waits for more data unless this is the end: whether it gets a line break + // depends on whether anything follows it + const complete = Math.max(lines - 1, 0); + let rest = src.length - complete * lineLength; + const output = Buffer.allocUnsafe(complete * (lineLength + 2) + (final ? rest : 0)); + let to = 0; + for (let from = 0; from < complete * lineLength; from += lineLength) { + src.copy(output, to, from, from + lineLength); + to += lineLength; + output[to++] = 0x0d; + output[to++] = 0x0a; + } + if (final) { + to += src.copy(output, to, complete * lineLength); + rest = 0; + } + if (to !== output.length) { + // never hand out bytes of the unfilled allocation + throw new Error('Unexpected wrapped length'); + } + return { output, rest }; +} /** * Adds soft line breaks to a base64 string * @@ -44,6 +85,9 @@ function wrap(str, lineLength) { /** * Creates a transform stream for encoding data to base64 encoding * + * The output is the same as `wrap(encode(input), lineLength)` no matter how the input is split + * into chunks: every line but the last one ends with a line break, the last one does not + * * @constructor * @param options Stream options * @param [options.lineLength=76] Maximum length for lines, set to false to disable wrapping @@ -53,66 +97,61 @@ class Encoder extends node_stream_1.Transform { super(); this.options = options || {}; if (this.options.lineLength !== false) { - this.options.lineLength = this.options.lineLength || 76; + this.options.lineLength = normalizeLineLength(this.options.lineLength); } this._curLine = ''; this._remainingBytes = false; this.inputBytes = 0; this.outputBytes = 0; } + /** + * Emits the encoded characters `b64` that follow the current line, keeping what can not be + * emitted yet as the new current line + * + * @internal + */ + _emit(b64, final) { + const src = Buffer.from(this._curLine + b64, 'latin1'); + if (!src.length) { + return; + } + let output = src; + if (this.options.lineLength) { + const wrapped = wrapBuffer(src, this.options.lineLength, final); + output = wrapped.output; + this._curLine = wrapped.rest ? src.toString('latin1', src.length - wrapped.rest) : ''; + } + else { + this._curLine = ''; + } + if (output.length) { + this.outputBytes += output.length; + this.push(output); + } + } /** @internal */ _transform(chunk, encoding, done) { let buf = encoding !== 'buffer' ? Buffer.from(chunk, encoding) : chunk; if (!buf || !buf.length) { - setImmediate(done); - return; + return done(); } this.inputBytes += buf.length; - if (this._remainingBytes && this._remainingBytes.length) { + if (this._remainingBytes) { buf = Buffer.concat([this._remainingBytes, buf], this._remainingBytes.length + buf.length); this._remainingBytes = false; } - if (buf.length % 3) { - this._remainingBytes = buf.slice(buf.length - (buf.length % 3)); - buf = buf.slice(0, buf.length - (buf.length % 3)); + const extra = buf.length % 3; + if (extra) { + this._remainingBytes = buf.subarray(buf.length - extra); + buf = buf.subarray(0, buf.length - extra); } - else { - this._remainingBytes = false; - } - let b64 = this._curLine + encode(buf); - if (this.options.lineLength) { - b64 = wrap(b64, this.options.lineLength); - // remove last line as it is still most probably incomplete - const lastLF = b64.lastIndexOf('\n'); - if (lastLF < 0) { - this._curLine = b64; - b64 = ''; - } - else if (lastLF === b64.length - 1) { - this._curLine = ''; - } - else { - this._curLine = b64.substring(lastLF + 1); - b64 = b64.substring(0, lastLF + 1); - } - } - if (b64) { - this.outputBytes += b64.length; - this.push(Buffer.from(b64, 'ascii')); - } - setImmediate(done); + this._emit(encode(buf), false); + done(); } /** @internal */ _flush(done) { - if (this._remainingBytes && this._remainingBytes.length) { - this._curLine += encode(this._remainingBytes); - } - if (this._curLine) { - this._curLine = wrap(this._curLine, this.options.lineLength); - this.outputBytes += this._curLine.length; - this.push(Buffer.from(this._curLine, 'ascii')); - this._curLine = ''; - } + this._emit(this._remainingBytes ? encode(this._remainingBytes) : '', true); + this._remainingBytes = false; done(); } } diff --git a/node_modules/nodemailer/dist/cjs/dkim/index.js b/node_modules/nodemailer/dist/cjs/dkim/index.js index 9a5dd898..17bb0b6f 100644 --- a/node_modules/nodemailer/dist/cjs/dkim/index.js +++ b/node_modules/nodemailer/dist/cjs/dkim/index.js @@ -37,51 +37,67 @@ class DKIMSigner { this.output = output; this.output.usingCache = false; this.hasErrored = false; - this.input.on('error', err => { + this.input.on('error', err => this.fail(err)); + // A consumer that goes away before the signed message was read in full destroys the + // output. Stop reading the input and drop the cache file instead of leaving them open + this.output.once('close', () => { + if (this.output.writableFinished) { + return; + } this.hasErrored = true; + this.input.unpipe(); + this.input.destroy(); this.cleanup(); - output.emit('error', err); }); } + /** + * Ends the output with an error and releases the cache file + */ + fail(err) { + this.hasErrored = true; + this.cleanup(); + this.output.destroy(err); + } cleanup() { if (!this.cache || !this.cachePath) { return; } + const cache = this.cache; + this.cache = false; + cache.destroy(); node_fs_1.default.unlink(this.cachePath, () => false); } createReadCache() { // pipe remainings to cache file this.cache = node_fs_1.default.createReadStream(this.cachePath); - this.cache.once('error', err => { - this.cleanup(); - this.output.emit('error', err); - }); + this.cache.once('error', err => this.fail(err)); this.cache.once('close', () => { this.cleanup(); }); this.cache.pipe(this.output); } sendNextChunk() { + while (!this.hasErrored) { + if (this.readPos >= this.chunks.length) { + if (!this.cache) { + this.output.end(); + return; + } + return this.createReadCache(); + } + const chunk = this.chunks[this.readPos++]; + if (this.output.write(chunk) === false) { + this.output.once('drain', () => { + this.sendNextChunk(); + }); + return; + } + } + } + sendSignedOutput() { if (this.hasErrored) { return; } - if (this.readPos >= this.chunks.length) { - if (!this.cache) { - this.output.end(); - return; - } - return this.createReadCache(); - } - const chunk = this.chunks[this.readPos++]; - if (this.output.write(chunk) === false) { - this.output.once('drain', () => { - this.sendNextChunk(); - }); - return; - } - setImmediate(() => this.sendNextChunk()); - } - sendSignedOutput() { let keyPos = 0; const signNextKey = () => { if (keyPos >= this.keys.length) { @@ -101,9 +117,7 @@ class DKIMSigner { }); } catch (err) { - this.hasErrored = true; - this.cleanup(); - this.output.emit('error', err); + this.fail(err); return; } if (dkimField) { @@ -122,7 +136,6 @@ class DKIMSigner { // pipe remainings to cache file this.cache = node_fs_1.default.createWriteStream(this.cachePath); this.cache.once('error', err => { - this.cleanup(); // drain input this.relaxedBody.unpipe(this.cache); this.relaxedBody.on('readable', () => { @@ -130,11 +143,12 @@ class DKIMSigner { // do nothing } }); - this.hasErrored = true; - // emit error - this.output.emit('error', err); + this.fail(err); }); this.cache.once('close', () => { + if (this.hasErrored) { + return; + } this.sendSignedOutput(); }); this.relaxedBody.removeAllListeners('readable'); @@ -211,7 +225,7 @@ class DKIM { 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)); + signer.fail(sign_js_1.default.unsupportedHashAlgoError(signer.hashAlgo)); return; } if (writeValue) { diff --git a/node_modules/nodemailer/dist/cjs/dkim/message-parser.js b/node_modules/nodemailer/dist/cjs/dkim/message-parser.js index e34a5076..57d3bc41 100644 --- a/node_modules/nodemailer/dist/cjs/dkim/message-parser.js +++ b/node_modules/nodemailer/dist/cjs/dkim/message-parser.js @@ -107,7 +107,7 @@ class MessageParser extends node_stream_1.Transform { const chunk = data.slice(headerPos); this.bodySize += chunk.length; // this would be the first chunk of data sent downstream - setImmediate(() => this.push(chunk)); + this.push(chunk); } return false; } @@ -136,7 +136,7 @@ class MessageParser extends node_stream_1.Transform { this.bodySize += chunk.length; this.push(chunk); } - setImmediate(callback); + callback(); } /** @internal */ _flush(callback) { diff --git a/node_modules/nodemailer/dist/cjs/errors.d.ts b/node_modules/nodemailer/dist/cjs/errors.d.ts index 345678f8..bb7ec1f9 100644 --- a/node_modules/nodemailer/dist/cjs/errors.d.ts +++ b/node_modules/nodemailer/dist/cjs/errors.d.ts @@ -61,6 +61,19 @@ export declare const EPROXY = "EPROXY"; export declare const EFILEACCESS = "EFILEACCESS"; export declare const EURLACCESS = "EURLACCESS"; export declare const EFETCH = "EFETCH"; +/** + * Tells whether the error ended the connection rather than rejected what was sent over it + */ +export declare const isTransientError: (err: { + code?: string | undefined; + responseCode?: number | undefined; +}) => boolean; +/** + * Code Node.js sets on an error when its permission model (`--permission`) denies an + * operation. It is not replaced with the Nodemailer code of the failing step (`ESOCKET`, + * `EDNS`, `EFETCH`, ...), so a missing grant such as `--allow-net` stays recognizable. + */ +export declare const ERR_ACCESS_DENIED = "ERR_ACCESS_DENIED"; /** * An Error together with the properties Nodemailer attaches to the errors it * hands to callers. Every property is optional, the set that is present @@ -84,6 +97,10 @@ export interface NodemailerError extends NodeJS.ErrnoException { rejected?: string[] | undefined; /** Per-recipient errors for the rejected addresses */ rejectedErrors?: NodemailerError[] | undefined; + /** The code the error had before Nodemailer set its own, such as ECONNREFUSED for an ESOCKET error */ + originalCode?: string | undefined; + /** Which wait an ETIMEDOUT error ended: connecting, the greeting, a TLS upgrade or an idle socket */ + timeoutType?: 'CONNECT_TIMEOUT' | 'GREETING_TIMEOUT' | 'UPGRADE_TIMEOUT' | 'SOCKET_TIMEOUT' | undefined; } /** * Node style callback: called with an error, or with null and the result diff --git a/node_modules/nodemailer/dist/cjs/errors.js b/node_modules/nodemailer/dist/cjs/errors.js index 3cdaa235..b7cc8b72 100644 --- a/node_modules/nodemailer/dist/cjs/errors.js +++ b/node_modules/nodemailer/dist/cjs/errors.js @@ -10,7 +10,7 @@ * err.code = errors.ECONNECTION; */ Object.defineProperty(exports, "__esModule", { value: true }); -exports.EFETCH = exports.EURLACCESS = exports.EFILEACCESS = exports.EPROXY = exports.ECONFIG = exports.ESES = exports.ESENDMAIL = exports.EMAXRECIPIENTS = exports.EMAXLIMIT = exports.EOAUTH2 = exports.ENOAUTH = exports.EAUTH = exports.ESTREAM = exports.EMESSAGE = exports.EENVELOPE = exports.EPROTOCOL = exports.EREQUIRETLS = exports.ETLS = exports.EDNS = exports.ESOCKET = exports.ETIMEDOUT = exports.ECONNECTION = exports.ERROR_CODES = void 0; +exports.ERR_ACCESS_DENIED = exports.isTransientError = exports.EFETCH = exports.EURLACCESS = exports.EFILEACCESS = exports.EPROXY = exports.ECONFIG = exports.ESES = exports.ESENDMAIL = exports.EMAXRECIPIENTS = exports.EMAXLIMIT = exports.EOAUTH2 = exports.ENOAUTH = exports.EAUTH = exports.ESTREAM = exports.EMESSAGE = exports.EENVELOPE = exports.EPROTOCOL = exports.EREQUIRETLS = exports.ETLS = exports.EDNS = exports.ESOCKET = exports.ETIMEDOUT = exports.ECONNECTION = exports.ERROR_CODES = void 0; /** * Error code descriptions for documentation and debugging */ @@ -68,3 +68,14 @@ exports.EPROXY = 'EPROXY'; exports.EFILEACCESS = 'EFILEACCESS'; exports.EURLACCESS = 'EURLACCESS'; exports.EFETCH = 'EFETCH'; +/** + * Tells whether the error ended the connection rather than rejected what was sent over it + */ +const isTransientError = (err) => err.responseCode === 421 || err.code === exports.ECONNECTION || err.code === exports.ESOCKET || err.code === exports.ETIMEDOUT; +exports.isTransientError = isTransientError; +/** + * Code Node.js sets on an error when its permission model (`--permission`) denies an + * operation. It is not replaced with the Nodemailer code of the failing step (`ESOCKET`, + * `EDNS`, `EFETCH`, ...), so a missing grant such as `--allow-net` stays recognizable. + */ +exports.ERR_ACCESS_DENIED = 'ERR_ACCESS_DENIED'; diff --git a/node_modules/nodemailer/dist/cjs/fetch/cookies.js b/node_modules/nodemailer/dist/cjs/fetch/cookies.js index 4c832e34..f4cc82e4 100644 --- a/node_modules/nodemailer/dist/cjs/fetch/cookies.js +++ b/node_modules/nodemailer/dist/cjs/fetch/cookies.js @@ -130,19 +130,31 @@ class Cookies { */ parse(cookieStr) { const cookie = {}; + let hasNameValue = false; (cookieStr || '') .toString() .split(';') .forEach(cookiePart => { - const valueParts = cookiePart.split('='); - const key = valueParts.shift().trim().toLowerCase(); - let value = valueParts.join('=').trim(); - let domain; - if (!key) { + if (!cookiePart.trim()) { // skip empty parts return; } - switch (key) { + const valueParts = cookiePart.split('='); + const name = valueParts.shift().trim(); + let value = valueParts.join('=').trim(); + let domain; + // the first part is always the name-value pair, so a cookie named + // like an attribute is not mistaken for one (RFC 6265 section 5.2) + if (!hasNameValue) { + hasNameValue = true; + if (name) { + // cookie names are case-sensitive, only attribute names are not + cookie.name = name; + cookie.value = value; + } + return; + } + switch (name.toLowerCase()) { case 'expires': { const expires = new Date(value); // ignore date if can not parse it @@ -170,11 +182,6 @@ class Cookies { case 'httponly': cookie.httponly = true; break; - default: - if (!cookie.name) { - cookie.name = key; - cookie.value = value; - } } }); return cookie; diff --git a/node_modules/nodemailer/dist/cjs/fetch/index.js b/node_modules/nodemailer/dist/cjs/fetch/index.js index 8bf8d215..a446f546 100644 --- a/node_modules/nodemailer/dist/cjs/fetch/index.js +++ b/node_modules/nodemailer/dist/cjs/fetch/index.js @@ -81,6 +81,17 @@ const TLS_OPTION_KEYS = [ 'sessionIdContext', 'sigalgs' ]; +/** + * Marks an error of the request as a fetch error, unless it is a permission model + * denial, which keeps its own code (see ERR_ACCESS_DENIED) + * + * @param err Error to mark + */ +function setFetchCode(err) { + if (err.code !== errors.ERR_ACCESS_DENIED) { + err.code = errors.EFETCH; + } +} /** * Resolves a URL only if it is one this module is willing to request. * @@ -177,7 +188,7 @@ function nmfetch(url, options) { return; } finished = true; - err.code = errors.EFETCH; + setFetchCode(err); err.sourceUrl = url; fetchRes.emit('error', err); }); @@ -250,7 +261,7 @@ function nmfetch(url, options) { catch (E) { finished = true; setImmediate(() => { - E.code = errors.EFETCH; + setFetchCode(E); E.sourceUrl = url; fetchRes.emit('error', E); }); @@ -262,7 +273,7 @@ function nmfetch(url, options) { return; } finished = true; - err.code = errors.EFETCH; + setFetchCode(err); err.sourceUrl = sourceUrl; fetchRes.emit('error', err); req.abort(); @@ -272,6 +283,15 @@ function nmfetch(url, options) { req.setTimeout(timeout, () => fail(new Error('Request Timeout'))); } req.on('error', (err) => fail(err)); + // a consumer that destroys the response stream before it ended does not want the rest of + // the body, release the request now instead of when the timeout fires + fetchRes.once('close', () => { + if (finished || fetchRes.readableEnded) { + return; + } + finished = true; + req.destroy(); + }); req.on('response', res => { let inflate; if (finished) { diff --git a/node_modules/nodemailer/dist/cjs/mailer/index.js b/node_modules/nodemailer/dist/cjs/mailer/index.js index 22ac10ed..4a200c8e 100644 --- a/node_modules/nodemailer/dist/cjs/mailer/index.js +++ b/node_modules/nodemailer/dist/cjs/mailer/index.js @@ -157,6 +157,11 @@ class Mail extends node_events_1.EventEmitter { this.getSocket = false; } const mail = new mail_message_js_1.default(this, data); + // a failed message is not going to be read anymore, release its content streams + const fail = (err) => { + mail.releaseStreams(); + done(err); + }; this.logger.debug({ tnx: 'transport', name: this.transporter.name, @@ -170,7 +175,7 @@ class Mail extends node_events_1.EventEmitter { tnx: 'plugin', action: 'compile' }, 'PluginCompile Error: %s', err.message); - return done(err); + return fail(err); } let recipientCount; try { @@ -187,7 +192,7 @@ class Mail extends node_events_1.EventEmitter { tnx: 'transport', action: 'send' }, 'Compile Error: %s', err.message); - return done(err); + return fail(err); } const maxRecipients = mail.data.maxRecipients === undefined ? DEFAULT_MAX_RECIPIENTS : mail.data.maxRecipients; if (maxRecipients && recipientCount > maxRecipients) { @@ -198,7 +203,7 @@ class Mail extends node_events_1.EventEmitter { tnx: 'transport', action: 'send' }, 'Send Error: %s', err.message); - return done(err); + return fail(err); } this._processPlugins('stream', mail, err => { if (err) { @@ -207,7 +212,7 @@ class Mail extends node_events_1.EventEmitter { tnx: 'plugin', action: 'stream' }, 'PluginStream Error: %s', err.message); - return done(err); + return fail(err); } if (mail.data.dkim || this.dkim) { mail.message.processFunc(input => { @@ -222,6 +227,7 @@ class Mail extends node_events_1.EventEmitter { } this.transporter.send(mail, (...args) => { if (args[0]) { + mail.releaseStreams(); this.logger.error({ err: args[0], tnx: 'transport', @@ -289,6 +295,11 @@ class Mail extends node_events_1.EventEmitter { // setup socket handler for the mailer object this.getSocket = (options, callback) => { const protocol = proxy.protocol.replace(/:$/, '').toLowerCase(); + // The proxy handshake is a part of connecting and draws from the same deadline as the + // SMTP connection that follows it: whatever the proxy takes, the connection does not get + const configuredTimeout = Number(this.options.connectionTimeout) || 0; + const connectStartedAt = Number(options.connectStartedAt) || Date.now(); + const connectionTimeout = configuredTimeout ? Math.max(connectStartedAt + configuredTimeout - Date.now(), 1) : undefined; if (this.meta.has('proxy_handler_' + protocol)) { return this.meta.get('proxy_handler_' + protocol)(proxy, options, callback); } @@ -296,7 +307,7 @@ class Mail extends node_events_1.EventEmitter { // Connect using a HTTP CONNECT method case 'http': case 'https': - (0, http_proxy_client_js_1.default)(proxy.href, options.port, options.host, this.options.tls || {}, (err, socket) => { + (0, http_proxy_client_js_1.default)(proxy.href, options.port, options.host, connectionTimeout ? Object.assign({}, this.options.tls, { timeout: connectionTimeout }) : this.options.tls || {}, (err, socket) => { if (err) { return callback(err); } @@ -330,6 +341,9 @@ class Mail extends node_events_1.EventEmitter { }, command: 'connect' }; + if (connectionTimeout) { + connectionOpts.timeout = connectionTimeout; + } if (proxy.username || proxy.password) { const username = proxy.username || ''; const password = proxy.password || ''; @@ -359,11 +373,13 @@ class Mail extends node_events_1.EventEmitter { if (node_net_1.default.isIP(proxy.hostname)) { return connect(proxy.hostname); } - return node_dns_1.default.resolve(proxy.hostname, (err, address) => { + // lookup goes through the hosts file and returns an IPv6 address as well, + // the same way a connection to the proxy host would be resolved otherwise + return node_dns_1.default.lookup(proxy.hostname, (err, address) => { if (err) { return callback(err); } - connect(Array.isArray(address) ? address[0] : address); + connect(address); }); } } diff --git a/node_modules/nodemailer/dist/cjs/mailer/mail-message.d.ts b/node_modules/nodemailer/dist/cjs/mailer/mail-message.d.ts index 6c20de37..8f2973fe 100644 --- a/node_modules/nodemailer/dist/cjs/mailer/mail-message.d.ts +++ b/node_modules/nodemailer/dist/cjs/mailer/mail-message.d.ts @@ -90,6 +90,11 @@ export default class MailMessage { [key: string]: any; }, key: string | number, options?: ResolveContentOptions | false): Promise; resolveAll(callback: MailMessageDataCallback): void; + /** + * Destroys the content streams of the message once sending failed. A stream that was not + * read to the end would otherwise keep the file or the socket behind it open + */ + releaseStreams(): void; normalize(callback: MailMessageDataCallback): void; setMailerHeader(): void; setPriorityHeaders(): void; diff --git a/node_modules/nodemailer/dist/cjs/mailer/mail-message.js b/node_modules/nodemailer/dist/cjs/mailer/mail-message.js index 2f17516c..4d9f8196 100644 --- a/node_modules/nodemailer/dist/cjs/mailer/mail-message.js +++ b/node_modules/nodemailer/dist/cjs/mailer/mail-message.js @@ -174,6 +174,26 @@ class MailMessage { }; setImmediate(() => resolveNext()); } + /** + * Destroys the content streams of the message once sending failed. A stream that was not + * read to the end would otherwise keep the file or the socket behind it open + */ + releaseStreams() { + const data = this.data; + const values = [data.html, data.text, data.watchHtml, data.amp, data.raw, data.icalEvent]; + for (const key of ['attachments', 'alternatives']) { + if (Array.isArray(data[key])) { + values.push(...data[key]); + } + } + for (const value of values) { + if (value && typeof value === 'object') { + shared.destroyStream(value); + shared.destroyStream(value.content); + shared.destroyStream(value.raw); + } + } + } normalize(callback) { const envelope = this.message.getEnvelope(); const messageId = this.message.messageId(); diff --git a/node_modules/nodemailer/dist/cjs/mime-node/index.d.ts b/node_modules/nodemailer/dist/cjs/mime-node/index.d.ts index 03d7603b..4e2b4131 100644 --- a/node_modules/nodemailer/dist/cjs/mime-node/index.d.ts +++ b/node_modules/nodemailer/dist/cjs/mime-node/index.d.ts @@ -181,7 +181,7 @@ declare class MimeNode { childNodes: MimeNode[]; /** Filename for this node. Useful with attachments */ filename?: string | undefined; - /** Body content, or the error a content stream emitted before it was read */ + /** Body content */ content?: MimeNodeContent | Error | undefined; /** Lowercase content type, set when the headers are built */ contentType?: string | undefined; diff --git a/node_modules/nodemailer/dist/cjs/mime-node/index.js b/node_modules/nodemailer/dist/cjs/mime-node/index.js index 97e5ab14..b5989dd0 100644 --- a/node_modules/nodemailer/dist/cjs/mime-node/index.js +++ b/node_modules/nodemailer/dist/cjs/mime-node/index.js @@ -71,6 +71,14 @@ const PLAIN_ADDRESS = /^[^\s"(),:;<>@[\\\]]+@[^\s"(),:;<>@[\\\]]+$/; // unroutable garbage into mail for a domain the sender never named. None of these // characters are legal in a domain, so keep them away from the mapper. const URL_PARSER_UNSAFE = /[/\\?#%\x00-\x20\x7F]/; +// pipeline() needs a callback to not throw, the errors it sees reach the last stream anyway +const PIPELINE_NOOP = () => false; +// The error a node streaming into an output that was destroyed stops with +function abortedError() { + const err = new Error('Message stream was closed before the message was generated'); + err.code = errors.ESTREAM; + return err; +} /** * Encodes a domain the way browsers, the WHATWG URL Standard and DNS facing resolvers do, * which is with UTS-46 mapping applied before the Punycode step. @@ -90,9 +98,14 @@ const URL_PARSER_UNSAFE = /[/\\?#%\x00-\x20\x7F]/; */ function normalizeDomain(domain, toUnicode) { // domainToASCII and domainToUnicode landed in Node 7, the bundled codec covers Node 6 - const mapper = toUnicode ? node_url_1.default.domainToUnicode : node_url_1.default.domainToASCII; - if (typeof mapper === 'function' && !URL_PARSER_UNSAFE.test(domain)) { - const mapped = mapper(domain); + if (typeof node_url_1.default.domainToASCII === 'function' && + typeof node_url_1.default.domainToUnicode === 'function' && + !URL_PARSER_UNSAFE.test(domain)) { + // The U-label form is decoded from the A-label form rather than from the input: + // Deno's domainToASCII returns an empty string for a label it cannot decode, while + // its domainToUnicode returns the label with U+FFFD in place of the bad part + const ascii = node_url_1.default.domainToASCII(domain); + const mapped = ascii && toUnicode ? node_url_1.default.domainToUnicode(ascii) : ascii; if (mapped) { return mapped; } @@ -444,11 +457,7 @@ class MimeNode { if (typeof this.content.pipe === 'function') { // pre-stream handler. might be triggered if a stream is set as content // and 'error' fires before anything is done with this stream - this._contentErrorHandler = err => { - this.content.removeListener('error', this._contentErrorHandler); - this.content = err; - }; - this.content.once('error', this._contentErrorHandler); + shared.recordStreamErrors(this.content); } else if (typeof this.content === 'string') { this._isPlainText = mimeFuncs.isPlainText(this.content); @@ -660,40 +669,45 @@ class MimeNode { createReadStream(options) { options = options || {}; const stream = new node_stream_1.PassThrough(options); - let outputStream = stream; - let transform; this.stream(stream, options, err => { if (err) { - outputStream.emit('error', err); + stream.destroy(err); return; } stream.end(); }); - for (let i = 0, len = this._transforms.length; i < len; i++) { - transform = - typeof this._transforms[i] === 'function' ? this._transforms[i]() : this._transforms[i]; - outputStream.once('error', err => { - transform.emit('error', err); - }); - outputStream = outputStream.pipe(transform); + // the content streams of the nodes the message did not get to are not going to be read + stream.once('close', () => { + if (!stream.writableFinished) { + this._destroyContentStreams(); + } + }); + // The stages are joined with pipeline, which destroys all of them when any one fails + // or is destroyed. An error anywhere reaches the returned stream exactly once, and a + // consumer that destroys the returned stream stops the tree from reading its sources + const stages = [stream]; + for (const transform of this._transforms) { + stages.push(typeof transform === 'function' ? transform() : transform); } // ensure terminating newline after possible user transforms - transform = new last_newline_js_1.default(); - outputStream.once('error', err => { - transform.emit('error', err); - }); - outputStream = outputStream.pipe(transform); - // dkim and stuff - for (let i = 0, len = this._processFuncs.length; i < len; i++) { - transform = this._processFuncs[i]; - outputStream = transform(outputStream); + stages.push(new last_newline_js_1.default()); + let outputStream = (0, node_stream_1.pipeline)(stages, PIPELINE_NOOP); + // dkim and stuff. A process function reads its input itself and reports the errors of + // that input on its output, so only a consumer abort has to be carried back upstream + for (const processFunc of this._processFuncs) { + const input = outputStream; + outputStream = processFunc(input); + if (outputStream !== input) { + (0, node_stream_1.finished)(outputStream, err => { + if (err) { + input.destroy(); + } + }); + } } if (this.newline) { const winbreak = ['win', 'windows', 'dos', '\r\n'].includes(this.newline.toString().toLowerCase()); - const newlineTransform = winbreak ? new le_windows_js_1.default() : new le_unix_js_1.default(); - const stream = outputStream.pipe(newlineTransform); - outputStream.on('error', err => stream.emit('error', err)); - return stream; + outputStream = (0, node_stream_1.pipeline)(outputStream, winbreak ? new le_windows_js_1.default() : new le_unix_js_1.default(), PIPELINE_NOOP); } return outputStream; } @@ -719,8 +733,10 @@ class MimeNode { } stream(outputStream, options, done) { const transferEncoding = this.getTransferEncoding(); - let contentStream; - let localStream; + // the streams this node is reading from. A consumer that goes away mid-message destroys + // the output, and these are released with it instead of staying paused with a file or a + // socket open behind them + let activeStreams = []; // protect actual callback against multiple triggering let returned = false; const callback = (err) => { @@ -728,13 +744,39 @@ class MimeNode { return; } returned = true; + outputStream.removeListener('close', onOutputClose); done(err); }; + function onOutputClose() { + for (const stream of activeStreams) { + stream.destroy(); + } + return callback(abortedError()); + } + // reads the streams into the output. The listener is only attached while the node reads + // something, a deeply nested tree would otherwise stack one per level on the output + const readInto = (...streams) => { + if (!activeStreams.length) { + outputStream.once('close', onOutputClose); + } + activeStreams = streams; + }; + // stops here if the output was destroyed in the meantime + const aborted = () => { + if (outputStream.destroyed) { + callback(abortedError()); + return true; + } + return false; + }; // for multipart nodes, push child nodes // for content nodes end the stream const finalize = () => { let childId = 0; const processChildNode = () => { + if (aborted()) { + return; + } if (childId >= this.childNodes.length) { outputStream.write('\r\n--' + this.boundary + '--\r\n'); return callback(); @@ -757,89 +799,80 @@ class MimeNode { }; // pushes node content const sendContent = () => { - if (this.content) { - if (Object.prototype.toString.call(this.content) === '[object Error]') { - // content is already errored - return callback(this.content); - } - if (typeof this.content.pipe === 'function') { - this.content.removeListener('error', this._contentErrorHandler); - this._contentErrorHandler = err => callback(err); - this.content.once('error', this._contentErrorHandler); - } - const createStream = () => { - if (['quoted-printable', 'base64'].includes(transferEncoding)) { - contentStream = new (transferEncoding === 'base64' ? base64 : qp).Encoder(options); - contentStream.pipe(outputStream, { - end: false - }); - contentStream.once('end', finalize); - contentStream.once('error', err => callback(err)); - localStream = this._getStream(this.content); - localStream.pipe(contentStream); - } - else { - // anything that is not QP or Base54 passes as-is - localStream = this._getStream(this.content); - localStream.pipe(outputStream, { - end: false - }); - localStream.once('end', finalize); - } - localStream.once('error', err => callback(err)); - }; - if (this.content._resolve) { - const chunks = []; - let chunklen = 0; - let returned = false; - const sourceStream = this._getStream(this.content); - sourceStream.on('error', err => { - if (returned) { - return; - } - returned = true; - callback(err); - }); - sourceStream.on('readable', () => { - let chunk; - while ((chunk = sourceStream.read()) !== null) { - chunks.push(chunk); - chunklen += chunk.length; - } - }); - sourceStream.on('end', () => { - if (returned) { - return; - } - returned = true; - this.content._resolve = false; - this.content._resolvedValue = Buffer.concat(chunks, chunklen); - setImmediate(createStream); - }); - } - else { - setImmediate(createStream); - } + if (aborted()) { return; } - return setImmediate(finalize); + if (!this.content) { + return setImmediate(finalize); + } + const createStream = () => { + if (aborted()) { + return; + } + const contentError = this._takeStreamContent(this.content); + if (contentError) { + return callback(contentError); + } + const localStream = this._getStream(this.content); + if (['quoted-printable', 'base64'].includes(transferEncoding)) { + const contentStream = transferEncoding === 'base64' + ? new base64.Encoder(options) + : // outside of text a lone CR or LF is data, encoding it keeps it from being + // turned into a line break by a newline transform or the receiving side + new qp.Encoder(Object.assign({}, options, { binary: !/^text\//i.test(this.contentType || '') })); + readInto(localStream, contentStream); + contentStream.pipe(outputStream, { + end: false + }); + // pipeline reports errors and tears both streams down. It calls back once the + // encoder took all of its input, the encoded output may still be waiting to + // be read, so the node is only done once the encoder's readable side ended + (0, node_stream_1.pipeline)(localStream, contentStream, err => err && callback(err)); + (0, node_stream_1.finished)(contentStream, { writable: false }, err => (err ? callback(err) : finalize())); + } + else { + // anything that is not QP or Base54 passes as-is + readInto(localStream); + localStream.pipe(outputStream, { + end: false + }); + (0, node_stream_1.finished)(localStream, { writable: false }, err => (err ? callback(err) : finalize())); + } + }; + if (this.content._resolve) { + const sourceStream = this._getStream(this.content); + readInto(sourceStream); + shared.resolveStream(sourceStream, (err, value) => { + if (err) { + return callback(err); + } + if (returned) { + return; + } + this.content._resolve = false; + this.content._resolvedValue = value; + setImmediate(createStream); + }); + } + else { + setImmediate(createStream); + } }; if (this._raw) { setImmediate(() => { - if (Object.prototype.toString.call(this._raw) === '[object Error]') { - // content is already errored - return callback(this._raw); + if (aborted()) { + return; } - // remove default error handler (if set) - if (typeof this._raw.pipe === 'function') { - this._raw.removeListener('error', this._contentErrorHandler); + const rawError = this._takeStreamContent(this._raw); + if (rawError) { + return callback(rawError); } const raw = this._getStream(this._raw); + readInto(raw); raw.pipe(outputStream, { end: false }); - raw.on('error', err => outputStream.emit('error', err)); - raw.on('end', finalize); + (0, node_stream_1.finished)(raw, { writable: false }, err => (err ? callback(err) : finalize())); }); } else { @@ -955,11 +988,7 @@ class MimeNode { if (this._raw && typeof this._raw.pipe === 'function') { // pre-stream handler. might be triggered if a stream is set as content // and 'error' fires before anything is done with this stream - this._contentErrorHandler = err => { - this._raw.removeListener('error', this._contentErrorHandler); - this._raw = err; - }; - this._raw.once('error', this._contentErrorHandler); + shared.recordStreamErrors(this._raw); } return this; } @@ -985,6 +1014,37 @@ class MimeNode { } return false; } + /** + * Destroys the content streams of this node and of every node below it + * + * @internal + */ + _destroyContentStreams() { + shared.destroyStream(this.content); + shared.destroyStream(this._raw); + for (const child of this.childNodes) { + child._destroyContentStreams(); + } + } + /** + * Checks that a content value can still be read before the node streams it. A stream is + * refused once it errored, ended or was destroyed: piping it would either never finish or + * produce an empty body without any error + * + * @param content Node content or raw value + * @returns The error to stop with, or null when the content can be read + * @internal + */ + _takeStreamContent(content) { + if (Object.prototype.toString.call(content) === '[object Error]') { + return content; + } + if (!content || typeof content.pipe !== 'function' || content._resolvedValue) { + // the value of a resolved stream is read from the buffered copy + return null; + } + return shared.unreadableStreamError(content); + } /** * Detects and returns handle to a stream related with the content. * diff --git a/node_modules/nodemailer/dist/cjs/nodemailer.js b/node_modules/nodemailer/dist/cjs/nodemailer.js index f2213f16..bab487ae 100644 --- a/node_modules/nodemailer/dist/cjs/nodemailer.js +++ b/node_modules/nodemailer/dist/cjs/nodemailer.js @@ -50,10 +50,12 @@ const index_js_7 = __importDefault(require("./ses-transport/index.js")); const errors = __importStar(require("./errors.js")); const index_js_8 = __importDefault(require("./fetch/index.js")); const packageData = __importStar(require("./package-info.js")); -const ETHEREAL_API = (process.env.ETHEREAL_API || 'https://api.nodemailer.com').replace(/\/+$/, ''); -const ETHEREAL_WEB = (process.env.ETHEREAL_WEB || 'https://ethereal.email').replace(/\/+$/, ''); -const ETHEREAL_API_KEY = (process.env.ETHEREAL_API_KEY || '').replace(/\s*/g, '') || null; -const ETHEREAL_CACHE = ['true', 'yes', 'y', '1'].includes((process.env.ETHEREAL_CACHE || 'yes').toString().trim().toLowerCase()); +// Read on use rather than at load, so that importing the module does not touch the +// environment (Deno refuses that without --allow-env, even for apps that never use Ethereal) +const etherealApi = () => (process.env.ETHEREAL_API || 'https://api.nodemailer.com').replace(/\/+$/, ''); +const etherealWeb = () => (process.env.ETHEREAL_WEB || 'https://ethereal.email').replace(/\/+$/, ''); +const etherealApiKey = () => (process.env.ETHEREAL_API_KEY || '').replace(/\s*/g, '') || null; +const etherealCache = () => ['true', 'yes', 'y', '1'].includes((process.env.ETHEREAL_CACHE || 'yes').toString().trim().toLowerCase()); let testAccount = false; function createTransport(transporter, defaults) { let options; @@ -114,11 +116,11 @@ function createTestAccount(apiUrl, callback) { }); } const done = callback; - if (ETHEREAL_CACHE && testAccount) { + if (etherealCache() && testAccount) { setImmediate(() => done(null, testAccount)); return promise; } - apiUrl = apiUrl || ETHEREAL_API; + apiUrl = apiUrl || etherealApi(); const chunks = []; let chunklen = 0; const requestHeaders = {}; @@ -126,8 +128,9 @@ function createTestAccount(apiUrl, callback) { requestor: packageData.name, version: packageData.version }; - if (ETHEREAL_API_KEY) { - requestHeaders.Authorization = 'Bearer ' + ETHEREAL_API_KEY; + const apiKey = etherealApiKey(); + if (apiKey) { + requestHeaders.Authorization = 'Bearer ' + apiKey; } const fetchOptions = { contentType: 'application/json', @@ -195,7 +198,7 @@ function getTestMessageUrl(info) { } } if (infoProps.has('STATUS') && infoProps.has('MSGID')) { - return ((testAccount && testAccount.web) || ETHEREAL_WEB) + '/message/' + infoProps.get('MSGID'); + return ((testAccount && testAccount.web) || etherealWeb()) + '/message/' + infoProps.get('MSGID'); } return false; } diff --git a/node_modules/nodemailer/dist/cjs/package-info.d.ts b/node_modules/nodemailer/dist/cjs/package-info.d.ts index dea3c135..20d8d73a 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.15"; +export declare const version = "10.1.0"; 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 a24addba..2d173066 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.15'; +exports.version = '10.1.0'; exports.homepage = 'https://nodemailer.com/'; diff --git a/node_modules/nodemailer/dist/cjs/qp/index.d.ts b/node_modules/nodemailer/dist/cjs/qp/index.d.ts index 6253dbf6..cee9cc45 100644 --- a/node_modules/nodemailer/dist/cjs/qp/index.d.ts +++ b/node_modules/nodemailer/dist/cjs/qp/index.d.ts @@ -14,6 +14,8 @@ export declare function wrap(str: string, lineLength?: number): string; export interface QPEncoderOptions { /** Maximum length for lines, set to false to disable wrapping */ lineLength?: number | false | undefined; + /** The input is binary data: a CR or LF that is not part of a CRLF pair is encoded */ + binary?: boolean | undefined; } /** The name @types/nodemailer used for QPEncoderOptions */ export type EncoderOptions = QPEncoderOptions; diff --git a/node_modules/nodemailer/dist/cjs/qp/index.js b/node_modules/nodemailer/dist/cjs/qp/index.js index 70f43668..304b42cc 100644 --- a/node_modules/nodemailer/dist/cjs/qp/index.js +++ b/node_modules/nodemailer/dist/cjs/qp/index.js @@ -21,23 +21,52 @@ const QP_RANGES = [ [0x20, 0x3c], // !"#$%&'()*+,-./0123456789:; [0x3e, 0x7e] // >?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|} ]; +// 1 for every byte value that is written as is, see QP_RANGES +const QP_LITERAL = new Uint8Array(256); +for (let i = 0; i < 256; i++) { + QP_LITERAL[i] = checkRanges(i, QP_RANGES) ? 1 : 0; +} +const HEX_DIGITS = Buffer.from('0123456789ABCDEF', 'latin1'); +const isWhitespace = (c) => c === 0x20 || c === 0x09; function encode(buffer) { - if (typeof buffer === 'string') { - buffer = Buffer.from(buffer, 'utf-8'); - } - let result = ''; - let ord; - for (let i = 0, len = buffer.length; i < len; i++) { - ord = buffer[i]; + return encodeBytes(typeof buffer === 'string' ? Buffer.from(buffer, 'utf-8') : buffer); +} +/** + * Encodes bytes that may be followed by more input + * + * @param buffer Bytes to encode + * @param [next] The byte that follows the buffer, whitespace before it is kept literal unless it + * is a line break. Without it the buffer ends the input and its trailing whitespace is encoded + * @param [binary] Keep only CRLF pairs literal. A lone CR or LF is data, not a line break, and + * anything that rewrites line endings on the way would change it + * @param [previous] The byte before the buffer, for a buffer that starts with LF + * @returns Quoted-Printable encoded string + */ +function encodeBytes(buffer, next, binary, previous) { + const len = buffer.length; + // every byte takes three characters at most + const output = Buffer.allocUnsafe(len * 3); + let pos = 0; + for (let i = 0; i < len; i++) { + const ord = buffer[i]; + const following = i + 1 < len ? buffer[i + 1] : next; + const lineBreakByte = binary && (ord === 0x0d || ord === 0x0a) + ? ord === 0x0d + ? following === 0x0a + : (i > 0 ? buffer[i - 1] : previous) === 0x0d + : true; // if the char is in allowed range, then keep as is, unless it is a WS in the end of a line - if (checkRanges(ord, QP_RANGES) && - !((ord === 0x20 || ord === 0x09) && (i === len - 1 || buffer[i + 1] === 0x0a || buffer[i + 1] === 0x0d))) { - result += String.fromCharCode(ord); + if (QP_LITERAL[ord] && + lineBreakByte && + !(isWhitespace(ord) && (following === undefined || following === 0x0a || following === 0x0d))) { + output[pos++] = ord; continue; } - result += '=' + (ord < 0x10 ? '0' : '') + ord.toString(16).toUpperCase(); + output[pos++] = 0x3d; // = + output[pos++] = HEX_DIGITS[Math.floor(ord / 16)]; + output[pos++] = HEX_DIGITS[ord % 16]; } - return result; + return output.toString('latin1', 0, pos); } /** * Adds soft line breaks to a Quoted-Printable string @@ -148,6 +177,10 @@ function checkRanges(nr, ranges) { } return false; } +// Input is encoded and wrapped this many bytes at a time. wrap() works on strings, and a large +// chunk handed over at once, such as a whole Buffer attachment, would be held several times over +// as intermediate strings +const ENCODE_SLICE_SIZE = 64 * 1024; /** * Creates a transform stream for encoding data to Quoted-Printable encoding * @@ -163,45 +196,82 @@ class Encoder extends node_stream_1.Transform { this.options.lineLength = this.options.lineLength || 76; } this._curLine = ''; + this._remainingBytes = false; + this._lastByte = undefined; this.inputBytes = 0; this.outputBytes = 0; } /** @internal */ _transform(chunk, encoding, done) { - let qp; - if (encoding !== 'buffer') { - chunk = Buffer.from(chunk, encoding); - } - if (!chunk || !chunk.length) { + let buf = encoding !== 'buffer' ? Buffer.from(chunk, encoding) : chunk; + if (!buf || !buf.length) { return done(); } - this.inputBytes += chunk.length; - if (this.options.lineLength) { - qp = this._curLine + encode(chunk); - qp = wrap(qp, this.options.lineLength); - qp = qp.replace(/(^|\n)([^\n]*)$/, (match, lineBreak, lastLine) => { - this._curLine = lastLine; - return lineBreak; - }); - if (qp) { - this.outputBytes += qp.length; - this.push(qp); - } + this.inputBytes += buf.length; + if (this._remainingBytes) { + buf = Buffer.concat([this._remainingBytes, buf], this._remainingBytes.length + buf.length); + this._remainingBytes = false; } - else { - qp = encode(chunk); - this.outputBytes += qp.length; - this.push(qp, 'ascii'); + // Whitespace is encoded when it ends a line, and the end of the input counts as one. Hold + // back the whitespace a chunk ends with until it is known what follows it, so the output + // does not depend on where the input was split + let end = buf.length; + // a binary CR can only be written once it is known whether LF follows + while (end > 0 && (isWhitespace(buf[end - 1]) || (this.options.binary && buf[end - 1] === 0x0d))) { + end--; } + if (buf.length - end <= ENCODE_SLICE_SIZE) { + this._remainingBytes = end < buf.length ? Buffer.from(buf.subarray(end)) : false; + buf = buf.subarray(0, end); + } + this._encodeSlices(buf); done(); } /** @internal */ _flush(done) { + if (this._remainingBytes) { + this._encodeSlices(this._remainingBytes); + this._remainingBytes = false; + } if (this._curLine) { this.outputBytes += this._curLine.length; this.push(this._curLine, 'ascii'); + this._curLine = ''; } done(); } + /** + * Encodes the input in slices of ENCODE_SLICE_SIZE bytes. Each slice is told the byte that + * follows it, so a slice that ends in whitespace is encoded as if it was not split + * + * @internal + */ + _encodeSlices(buf) { + for (let start = 0; start < buf.length; start += ENCODE_SLICE_SIZE) { + const end = Math.min(start + ENCODE_SLICE_SIZE, buf.length); + this._encodeSlice(buf.subarray(start, end), end < buf.length ? buf[end] : undefined); + this._lastByte = buf[end - 1]; + } + } + /** @internal */ + _encodeSlice(buf, next) { + let qp; + if (this.options.lineLength) { + qp = wrap(this._curLine + encodeBytes(buf, next, this.options.binary, this._lastByte), this.options.lineLength); + // the last line is kept until it is known whether it needs a soft break + const lastLF = qp.lastIndexOf('\n'); + this._curLine = qp.substring(lastLF + 1); + qp = qp.substring(0, lastLF + 1); + if (qp) { + this.outputBytes += qp.length; + this.push(qp, 'ascii'); + } + } + else { + qp = encodeBytes(buf, next, this.options.binary, this._lastByte); + this.outputBytes += qp.length; + this.push(qp, 'ascii'); + } + } } exports.Encoder = Encoder; diff --git a/node_modules/nodemailer/dist/cjs/sendmail-transport/index.js b/node_modules/nodemailer/dist/cjs/sendmail-transport/index.js index f22c9355..9073dee8 100644 --- a/node_modules/nodemailer/dist/cjs/sendmail-transport/index.js +++ b/node_modules/nodemailer/dist/cjs/sendmail-transport/index.js @@ -37,11 +37,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) { }; Object.defineProperty(exports, "__esModule", { value: true }); const node_child_process_1 = require("node:child_process"); +const node_stream_1 = require("node:stream"); const packageData = __importStar(require("../package-info.js")); const shared = __importStar(require("../shared/index.js")); const errors = __importStar(require("../errors.js")); const le_windows_js_1 = __importDefault(require("../mime-node/le-windows.js")); const le_unix_js_1 = __importDefault(require("../mime-node/le-unix.js")); +// how long an stdin error waits for the exit code of the process before it is reported +const STDIN_ERROR_EXIT_WAIT = 1000; /** * Generates a Transport object for Sendmail * @@ -139,37 +142,75 @@ class SendmailTransport { return callback(E); } if (sendmail) { + let stream; + // ended once the whole message was handed to stdin + const messageWritten = () => !!stream && stream.readableEnded; + // an EPIPE says less than the exit code that usually follows it, so it is only + // reported when the process exits without one + let stdinError = null; + // releases whatever the message is still being read from + const release = () => { + if (stream && !messageWritten()) { + stream.destroy(); + } + }; + const fail = (err) => { + release(); + callback(err); + }; sendmail.on('error', err => { this.logger.error({ err, tnx: 'spawn', messageId }, 'Error occurred when sending message %s. %s', messageId, err.message); - callback(err); + fail(err); }); - sendmail.once('exit', code => { - if (!code) { + // 'close' follows 'exit' with the same arguments, it is only listened to in case the + // process ends without an 'exit' event + const onExit = (code, signal) => { + let err = null; + if (code) { + err = new Error(code === 127 ? 'Sendmail command not found, process exited with code ' + code : 'Sendmail exited with code ' + code); + } + else if (signal) { + err = new Error('Sendmail was terminated by ' + signal); + } + else if (!messageWritten()) { + // exiting with 0 before the message was written does not mean it was queued + err = new Error('Sendmail exited before the message was written'); + } + else if (stdinError) { + err = stdinError; + } + if (!err) { return callback(); } - const err = new Error(code === 127 ? 'Sendmail command not found, process exited with code ' + code : 'Sendmail exited with code ' + code); - err.code = errors.ESENDMAIL; + err.code = err.code || errors.ESENDMAIL; this.logger.error({ err, tnx: 'stdin', messageId }, 'Error sending message %s to sendmail. %s', messageId, err.message); - callback(err); - }); - // the close listener is handed the exit code as its first argument, so a non-zero - // code reaching it before the exit listener did counts as the error value - sendmail.once('close', callback); + fail(err); + }; + sendmail.once('exit', onExit); + sendmail.once('close', onExit); sendmail.stdin.on('error', err => { this.logger.error({ err, tnx: 'stdin', messageId }, 'Error occurred when piping message %s to sendmail. %s', messageId, err.message); - callback(err); + if (stdinError) { + return; + } + stdinError = err; + release(); + // a process that closed its stdin normally exits right after, with a code that + // tells more than the EPIPE. Report the EPIPE if it does not + const exitTimer = setTimeout(() => fail(err), STDIN_ERROR_EXIT_WAIT); + sendmail.once('exit', () => clearTimeout(exitTimer)); }); const recipients = [].concat(envelope.to || []); if (recipients.length > 3) { @@ -180,12 +221,11 @@ class SendmailTransport { messageId }, 'Sending message %s to <%s>', messageId, recipients.join(', ')); const sourceStream = mail.message.createReadStream(); - let stream = sourceStream; + stream = sourceStream; if (this.options.newline) { // apply the transport-level line ending transform; the message-level // `newline` option is handled by MimeNode in createReadStream() - stream = sourceStream.pipe(this.winbreak ? new le_windows_js_1.default() : new le_unix_js_1.default()); - sourceStream.once('error', err => stream.emit('error', err)); + stream = (0, node_stream_1.pipeline)(sourceStream, this.winbreak ? new le_windows_js_1.default() : new le_unix_js_1.default(), () => false); } stream.once('error', err => { this.logger.error({ diff --git a/node_modules/nodemailer/dist/cjs/shared/index.d.ts b/node_modules/nodemailer/dist/cjs/shared/index.d.ts index 805783ef..8c7fff18 100644 --- a/node_modules/nodemailer/dist/cjs/shared/index.d.ts +++ b/node_modules/nodemailer/dist/cjs/shared/index.d.ts @@ -1,6 +1,7 @@ +import type { NodemailerError } from '../errors.js'; import { isProtoKey, copyOwnKeys } from './objects.js'; import os from 'node:os'; -import type { Readable } from 'node:stream'; +import { type Readable } from 'node:stream'; import type { OutgoingHttpHeaders } from 'node:http'; export { isProtoKey, copyOwnKeys }; /** @@ -258,3 +259,33 @@ export declare const assign: (...args: ({ [key: string]: any; }; export declare const encodeXText: (str: string) => string; +/** + * Keeps the first error a content stream emits before it is read, so the error is reported + * when the stream is read instead of being thrown as unhandled. The listener stays attached + * for good, a stream that emits 'error' more than once never throws the later ones either + * + * @param stream Readable stream + */ +export declare function recordStreamErrors(stream: Readable): void; +/** + * Destroys a value if it is a readable stream that was not destroyed yet + * + * @param value Any content value + */ +export declare function destroyStream(value: unknown): void; +/** + * Tells why a stream can not be read from start to end anymore. A stream that ended or was + * destroyed before anyone read it would never emit 'end' to a new reader, or, when piped, would + * end the destination right away and turn into an empty value without any error + * + * @param stream Readable stream + * @returns The error the stream failed with, an ESTREAM error, or null when it can be read + */ +export declare function unreadableStreamError(stream: Readable): NodemailerError | null; +/** + * Streams a stream value into a Buffer + * + * @param stream Readable stream + * @param callback Callback function with (err, value) + */ +export declare function resolveStream(stream: Readable, callback: (err: Error | null, value?: Buffer) => void): void; diff --git a/node_modules/nodemailer/dist/cjs/shared/index.js b/node_modules/nodemailer/dist/cjs/shared/index.js index a78ae8a5..b125d639 100644 --- a/node_modules/nodemailer/dist/cjs/shared/index.js +++ b/node_modules/nodemailer/dist/cjs/shared/index.js @@ -37,8 +37,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.encodeXText = exports.assign = exports.parseDataURI = exports.callbackPromise = exports.getLogger = exports._logFunc = exports.parseConnectionUrl = exports.resolveHostname = exports.dnsCache = exports.networkInterfaces = exports._resetCacheCleanup = exports._lastCacheCleanup = exports.copyOwnKeys = exports.isProtoKey = void 0; +exports.encodeXText = exports.assign = exports.parseDataURI = exports.callbackPromise = exports.getLogger = exports._logFunc = exports.parseConnectionUrl = exports.resolveHostname = exports.dnsCache = exports._readNetworkInterfaces = exports.networkInterfaces = exports._resetCacheCleanup = exports._lastCacheCleanup = exports.copyOwnKeys = exports.isProtoKey = void 0; exports.resolveContent = resolveContent; +exports.recordStreamErrors = recordStreamErrors; +exports.destroyStream = destroyStream; +exports.unreadableStreamError = unreadableStreamError; +exports.resolveStream = resolveStream; const urllib = __importStar(require("./url.js")); const node_util_1 = __importDefault(require("node:util")); const node_fs_1 = __importDefault(require("node:fs")); @@ -50,6 +54,7 @@ Object.defineProperty(exports, "copyOwnKeys", { enumerable: true, get: function const node_dns_1 = __importDefault(require("node:dns")); const node_net_1 = __importDefault(require("node:net")); const node_os_1 = __importDefault(require("node:os")); +const node_stream_1 = require("node:stream"); const DNS_TTL = 5 * 60 * 1000; const CACHE_CLEANUP_INTERVAL = 30 * 1000; // Minimum 30 seconds between cleanups const MAX_CACHE_SIZE = 1000; // Maximum number of entries in cache @@ -62,14 +67,25 @@ const _resetCacheCleanup = () => { lastCacheCleanup = 0; }; exports._resetCacheCleanup = _resetCacheCleanup; -try { - exports.networkInterfaces = node_os_1.default.networkInterfaces(); -} -catch (_err) { - // fails on some systems -} +let networkInterfacesRead = false; +// Read on first use rather than at load, so that importing the module does not ask for +// the interface table (Deno prompts for --allow-sys on it) +/** @internal */ +const _readNetworkInterfaces = () => { + if (!networkInterfacesRead) { + networkInterfacesRead = true; + try { + exports.networkInterfaces = node_os_1.default.networkInterfaces(); + } + catch (_err) { + // fails on some systems + } + } + return exports.networkInterfaces; +}; +exports._readNetworkInterfaces = _readNetworkInterfaces; const isFamilySupported = (family, allowInternal) => { - const addresses = Object.values(exports.networkInterfaces || {}).flat(); + const addresses = Object.values((0, exports._readNetworkInterfaces)() || {}).flat(); if (!addresses.length) { // hope for the best. Runtimes without an interface table (Cloudflare // Workers) report an empty object rather than throwing @@ -80,7 +96,8 @@ const isFamilySupported = (family, allowInternal) => { const resolve = (family, hostname, options, callback) => { options = options || {}; if (!isFamilySupported(family, options.allowInternalNetworkInterfaces)) { - return callback(null, []); + callback(null, []); + return null; } const dnsResolver = node_dns_1.default.Resolver ? new node_dns_1.default.Resolver(options) : node_dns_1.default; dnsResolver['resolve' + family](hostname, (err, addresses) => { @@ -99,6 +116,7 @@ const resolve = (family, hostname, options, callback) => { } return callback(null, Array.isArray(addresses) ? addresses : [].concat(addresses || [])); }); + return node_dns_1.default.Resolver ? dnsResolver : null; }; exports.dnsCache = new Map(); const formatDNSValue = (value, extra) => { @@ -162,115 +180,139 @@ const resolveHostname = (options, callback) => { })); } } - // Resolve both IPv4 and IPv6 addresses for fallback support + // The timeout limits the lookup as a whole. It is also handed to the resolver, but there + // it applies to every query attempt on its own, and the resolver retries a few times + let responded = false; + let deadline; + const respond = (err, result) => { + if (responded) { + return; + } + responded = true; + clearTimeout(deadline); + callback(err, result); + }; + // a stale cached value is still better than no value at all + const respondCached = (error) => { + if (!cached) { + return false; + } + exports.dnsCache.set(host, { + value: cached.value, + expires: Date.now() + (options.dnsTtl || DNS_TTL) + }); + respond(null, formatDNSValue(cached.value, { + servername, + cached: true, + error + })); + return true; + }; + const resolvers = []; + const timeout = Number(options.timeout) || 0; + if (timeout > 0) { + deadline = setTimeout(() => { + for (const resolver of resolvers) { + if (typeof resolver.cancel === 'function') { + resolver.cancel(); + } + } + const err = new Error('DNS lookup for ' + host + ' timed out'); + err.code = node_dns_1.default.TIMEOUT; + if (!respondCached(err)) { + respond(err); + } + }, timeout); + } + // Resolve both IPv4 and IPv6 addresses for fallback support, at the same time let ipv4Addresses = []; let ipv6Addresses = []; let ipv4Error = null; let ipv6Error = null; - resolve(4, options.host, options, (err, addresses) => { - if (err) { - ipv4Error = err; + let pending = 2; + const onResolved = () => { + if (--pending || responded) { + return; } - else { - ipv4Addresses = addresses || []; + // Combine addresses: IPv4 first, then IPv6 + const allAddresses = ipv4Addresses.concat(ipv6Addresses); + if (allAddresses.length) { + const value = { + addresses: allAddresses + }; + exports.dnsCache.set(host, { + value, + expires: Date.now() + (options.dnsTtl || DNS_TTL) + }); + return respond(null, formatDNSValue(value, { + servername, + cached: false + })); } - resolve(6, host, options, (err, addresses) => { - if (err) { - ipv6Error = err; - } - else { - ipv6Addresses = addresses || []; - } - // Combine addresses: IPv4 first, then IPv6 - const allAddresses = ipv4Addresses.concat(ipv6Addresses); - if (allAddresses.length) { + // No addresses from resolve4/resolve6, try dns.lookup as fallback + if (ipv4Error && ipv6Error && respondCached(ipv4Error)) { + // Both resolvers had errors + return; + } + try { + node_dns_1.default.lookup(host, { all: true }, (err, addresses) => { + if (err) { + if (respondCached(err)) { + return; + } + return respond(err); + } + // Get all supported addresses from dns.lookup + const supportedAddresses = addresses + ? addresses.filter(addr => isFamilySupported(addr.family)).map(addr => addr.address) + : []; + if (addresses && addresses.length && !supportedAddresses.length) { + // there are addresses but none can be used + console.warn(`Failed to resolve IPv${addresses[0].family} addresses with current network`); + } + if (!supportedAddresses.length && cached) { + // nothing was found, fallback to cached value + return respond(null, formatDNSValue(cached.value, { + servername, + cached: true + })); + } const value = { - addresses: allAddresses + addresses: supportedAddresses.length ? supportedAddresses : [host] }; exports.dnsCache.set(host, { value, expires: Date.now() + (options.dnsTtl || DNS_TTL) }); - return callback(null, formatDNSValue(value, { + return respond(null, formatDNSValue(value, { servername, cached: false })); + }); + } + catch (lookupErr) { + if (respondCached(lookupErr)) { + return; } - // No addresses from resolve4/resolve6, try dns.lookup as fallback - if (ipv4Error && ipv6Error) { - // Both resolvers had errors - if (cached) { - exports.dnsCache.set(host, { - value: cached.value, - expires: Date.now() + (options.dnsTtl || DNS_TTL) - }); - return callback(null, formatDNSValue(cached.value, { - servername, - cached: true, - error: ipv4Error - })); - } + return respond(ipv4Error || ipv6Error || lookupErr); + } + }; + for (const family of [4, 6]) { + const resolver = resolve(family, host, options, (err, addresses) => { + if (family === 4) { + ipv4Error = err; + ipv4Addresses = addresses || []; } - try { - node_dns_1.default.lookup(host, { all: true }, (err, addresses) => { - if (err) { - if (cached) { - exports.dnsCache.set(host, { - value: cached.value, - expires: Date.now() + (options.dnsTtl || DNS_TTL) - }); - return callback(null, formatDNSValue(cached.value, { - servername, - cached: true, - error: err - })); - } - return callback(err); - } - // Get all supported addresses from dns.lookup - const supportedAddresses = addresses - ? addresses.filter(addr => isFamilySupported(addr.family)).map(addr => addr.address) - : []; - if (addresses && addresses.length && !supportedAddresses.length) { - // there are addresses but none can be used - console.warn(`Failed to resolve IPv${addresses[0].family} addresses with current network`); - } - if (!supportedAddresses.length && cached) { - // nothing was found, fallback to cached value - return callback(null, formatDNSValue(cached.value, { - servername, - cached: true - })); - } - const value = { - addresses: supportedAddresses.length ? supportedAddresses : [host] - }; - exports.dnsCache.set(host, { - value, - expires: Date.now() + (options.dnsTtl || DNS_TTL) - }); - return callback(null, formatDNSValue(value, { - servername, - cached: false - })); - }); - } - catch (lookupErr) { - if (cached) { - exports.dnsCache.set(host, { - value: cached.value, - expires: Date.now() + (options.dnsTtl || DNS_TTL) - }); - return callback(null, formatDNSValue(cached.value, { - servername, - cached: true, - error: lookupErr - })); - } - return callback(ipv4Error || ipv6Error || lookupErr); + else { + ipv6Error = err; + ipv6Addresses = addresses || []; } + onResolved(); }); - }); + if (resolver) { + resolvers.push(resolver); + } + } }; exports.resolveHostname = resolveHostname; /** @@ -597,6 +639,53 @@ const encodeXText = (str) => { return result; }; exports.encodeXText = encodeXText; +// The first error a stream emitted, see recordStreamErrors +const streamErrors = new WeakMap(); +/** + * Keeps the first error a content stream emits before it is read, so the error is reported + * when the stream is read instead of being thrown as unhandled. The listener stays attached + * for good, a stream that emits 'error' more than once never throws the later ones either + * + * @param stream Readable stream + */ +function recordStreamErrors(stream) { + stream.on('error', err => { + if (!streamErrors.has(stream)) { + streamErrors.set(stream, err); + } + }); +} +/** + * Destroys a value if it is a readable stream that was not destroyed yet + * + * @param value Any content value + */ +function destroyStream(value) { + const stream = value; + if (stream && typeof stream.pipe === 'function' && typeof stream.destroy === 'function' && !stream.destroyed) { + stream.destroy(); + } +} +/** + * Tells why a stream can not be read from start to end anymore. A stream that ended or was + * destroyed before anyone read it would never emit 'end' to a new reader, or, when piped, would + * end the destination right away and turn into an empty value without any error + * + * @param stream Readable stream + * @returns The error the stream failed with, an ESTREAM error, or null when it can be read + */ +function unreadableStreamError(stream) { + const err = streamErrors.get(stream) || stream.errored; + if (err) { + return err; + } + if (stream.readableEnded || stream.destroyed) { + const unreadable = new Error('Content stream was already read or destroyed'); + unreadable.code = errors.ESTREAM; + return unreadable; + } + return null; +} /** * Streams a stream value into a Buffer * @@ -605,35 +694,43 @@ exports.encodeXText = encodeXText; */ function resolveStream(stream, callback) { let responded = false; + const respond = (err, value) => { + if (responded) { + return; + } + responded = true; + callback(err, value); + }; + const unreadable = unreadableStreamError(stream); + if (unreadable) { + // absorbs a later 'error' from the stream, there is nobody left to report it to + stream.on('error', () => false); + setImmediate(() => respond(unreadable)); + return; + } const chunks = []; let chunklen = 0; - stream.on('error', err => { - if (responded) { - return; + stream.on('data', (chunk) => { + if (typeof chunk === 'string') { + chunk = Buffer.from(chunk); } - responded = true; - callback(err); + chunks.push(chunk); + chunklen += chunk.length; }); - stream.on('readable', () => { - let chunk; - while ((chunk = stream.read()) !== null) { - chunks.push(chunk); - chunklen += chunk.length; + // finished() also reports a stream that is destroyed before 'end', which would otherwise + // leave the callback waiting forever + (0, node_stream_1.finished)(stream, { writable: false }, err => { + if (err) { + return respond(err); } - }); - stream.on('end', () => { - if (responded) { - return; - } - responded = true; let value; try { value = Buffer.concat(chunks, chunklen); } catch (E) { - return callback(E); + return respond(E); } - callback(null, value); + respond(null, value); }); } /** diff --git a/node_modules/nodemailer/dist/cjs/smtp-connection/http-proxy-client.d.ts b/node_modules/nodemailer/dist/cjs/smtp-connection/http-proxy-client.d.ts index 934d5cbd..311d2265 100644 --- a/node_modules/nodemailer/dist/cjs/smtp-connection/http-proxy-client.d.ts +++ b/node_modules/nodemailer/dist/cjs/smtp-connection/http-proxy-client.d.ts @@ -9,6 +9,8 @@ import type { Callback } from '../errors.js'; export interface HttpProxyClientOptions { /** Set to false to accept a proxy certificate that fails validation (e.g. self-signed) */ rejectUnauthorized?: boolean | undefined; + /** Time in milliseconds the CONNECT handshake may take, defaults to httpProxyClient.timeout or 30 seconds */ + timeout?: number | undefined; } /** * Receives the proxied socket once the CONNECT handshake has succeeded, or the error that prevented it @@ -38,7 +40,7 @@ declare function httpProxyClient(proxyUrl: string, destinationPort: number | str */ declare function httpProxyClient(proxyUrl: string, destinationPort: number | string, destinationHost: string, tlsOptions: HttpProxyClientOptions | undefined, callback: HttpProxyClientCallback): void; /** - * Socket timeout in milliseconds while the CONNECT handshake is in progress, defaults to 30 seconds. + * Time in milliseconds the CONNECT handshake may take when the call does not set one, defaults to 30 seconds. * Settable on the function itself, the same way the CommonJS module exposed it. */ declare namespace httpProxyClient { diff --git a/node_modules/nodemailer/dist/cjs/smtp-connection/http-proxy-client.js b/node_modules/nodemailer/dist/cjs/smtp-connection/http-proxy-client.js index 0ca39702..3ad79769 100644 --- a/node_modules/nodemailer/dist/cjs/smtp-connection/http-proxy-client.js +++ b/node_modules/nodemailer/dist/cjs/smtp-connection/http-proxy-client.js @@ -46,6 +46,8 @@ const errors = __importStar(require("../errors.js")); // Cap the CONNECT response we buffer before the header terminator, so a proxy that // never sends \r\n\r\n cannot grow memory unboundedly before the socket times out. const MAX_RESPONSE_HEADER_BYTES = 64 * 1024; +// URL hostnames keep the brackets around an IPv6 literal, socket options and net.isIPv6 take it without +const unbracket = (host) => typeof host === 'string' && host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host; function httpProxyClient(proxyUrl, destinationPort, destinationHost, tlsOptions, callback) { if (typeof tlsOptions === 'function') { callback = tlsOptions; @@ -65,6 +67,8 @@ function httpProxyClient(proxyUrl, destinationPort, destinationHost, tlsOptions, return; } const proxy = urllib.parse(proxyUrl); + // the CONNECT request line and the Host header take an IPv6 destination in brackets + const authority = (node_net_1.default.isIPv6(unbracket(destinationHost)) ? '[' + unbracket(destinationHost) + ']' : destinationHost) + ':' + destinationPort; const connectOptions = { host: proxy.hostname, port: Number(proxy.port) ? Number(proxy.port) : proxy.protocol === 'https:' ? 443 : 80 @@ -80,15 +84,27 @@ function httpProxyClient(proxyUrl, destinationPort, destinationHost, tlsOptions, else { connect = node_net_1.default.connect.bind(node_net_1.default); } + // The handshake is bounded as a whole, a proxy that keeps sending a byte now and then can + // not hold the connection open past it + const timeout = Number(tlsOptions.timeout) || httpProxyClient.timeout || 30 * 1000; let socket; - // Error harness for initial connection. Once connection is established, the responsibility - // to handle errors is passed to whoever uses this socket + // Single settlement path for the handshake: every temporary listener and the timer are + // dropped exactly once. Once the tunnel is up, the responsibility to handle errors is passed + // to whoever uses this socket let finished = false; - const tempSocketErr = (err) => { + let timer; + const cleanup = () => { + clearTimeout(timer); + socket.removeListener('data', onSocketData); + socket.removeListener('error', fail); + socket.removeListener('close', onEarlyClose); + }; + function fail(err) { if (finished) { return; } finished = true; + cleanup(); try { socket.destroy(); } @@ -96,18 +112,72 @@ function httpProxyClient(proxyUrl, destinationPort, destinationHost, tlsOptions, // ignore } done(err); - }; - const timeoutErr = () => { - const err = new Error('Proxy socket timed out'); - err.code = 'ETIMEDOUT'; - tempSocketErr(err); - }; + } + function onEarlyClose() { + const err = new Error('Proxy closed the connection before the tunnel was established'); + err.code = errors.EPROXY; + fail(err); + } + // The response is collected as chunks and only the bytes that just arrived, together + // with the three before them, are searched for the end of the headers. Appending to a + // string and searching all of it again re-read the whole response on every chunk. + const chunks = []; + let received = 0; + let tail = ''; + function onSocketData(chunk) { + if (finished) { + return; + } + const window = tail + chunk.toString('binary'); + const windowEnd = window.indexOf('\r\n\r\n'); + chunks.push(chunk); + received += chunk.length; + tail = window.slice(-3); + if (windowEnd < 0) { + if (received > MAX_RESPONSE_HEADER_BYTES) { + const err = new Error('Proxy response headers too large'); + err.code = errors.EPROXY; + fail(err); + } + return; + } + // Stop reading before anything is put back. A socket that keeps flowing would emit the + // bytes after the headers, a greeting the proxy sent together with its own response, + // before the next owner of the socket has a listener for them + socket.removeListener('data', onSocketData); + socket.pause(); + const headerEnd = received - window.length + windowEnd; + const response = Buffer.concat(chunks, received); + if (response.length > headerEnd + 4) { + socket.unshift(response.subarray(headerEnd + 4)); + } + // check response code + const match = response.toString('binary', 0, headerEnd).match(/^HTTP\/\d+\.\d+ (\d+)/i); + if (!match || (match[1] || '').charAt(0) !== '2') { + const err = new Error('Invalid response from proxy' + ((match && ': ' + match[1]) || '')); + err.code = errors.EPROXY; + return fail(err); + } + // proxy connection is now established + finished = true; + cleanup(); + // A fresh socket starts flowing once something listens for 'data', a paused one would + // not. Keep that behaviour for the next owner of the socket + const resumeOnData = (event) => { + if (event === 'data') { + socket.removeListener('newListener', resumeOnData); + socket.resume(); + } + }; + socket.on('newListener', resumeOnData); + return done(null, socket); + } socket = connect(connectOptions, () => { if (finished) { return; } const reqHeaders = { - Host: destinationHost + ':' + destinationPort, + Host: authority, Connection: 'close' }; if (proxy.auth) { @@ -116,9 +186,7 @@ function httpProxyClient(proxyUrl, destinationPort, destinationHost, tlsOptions, socket.write( // HTTP method 'CONNECT ' + - destinationHost + - ':' + - destinationPort + + authority + ' HTTP/1.1\r\n' + // HTTP request headers Object.keys(reqHeaders) @@ -126,63 +194,15 @@ function httpProxyClient(proxyUrl, destinationPort, destinationHost, tlsOptions, .join('\r\n') + // End request '\r\n\r\n'); - // The response is collected as chunks and only the bytes that just arrived, together - // with the three before them, are searched for the end of the headers. Appending to a - // string and searching all of it again re-read the whole response on every chunk. - const chunks = []; - let received = 0; - let tail = ''; - const onSocketData = (chunk) => { - let match; - if (finished) { - return; - } - const window = tail + chunk.toString('binary'); - const windowEnd = window.indexOf('\r\n\r\n'); - chunks.push(chunk); - received += chunk.length; - tail = window.slice(-3); - if (windowEnd >= 0) { - socket.removeListener('data', onSocketData); - const headerEnd = received - window.length + windowEnd; - const response = Buffer.concat(chunks, received).toString('binary'); - const headers = response.substr(0, headerEnd); - const remainder = response.substr(headerEnd + 4); - if (remainder) { - socket.unshift(Buffer.from(remainder, 'binary')); - } - // proxy connection is now established - finished = true; - // check response code - match = headers.match(/^HTTP\/\d+\.\d+ (\d+)/i); - if (!match || (match[1] || '').charAt(0) !== '2') { - try { - socket.destroy(); - } - catch (_E) { - // ignore - } - const err = new Error('Invalid response from proxy' + ((match && ': ' + match[1]) || '')); - err.code = errors.EPROXY; - return done(err); - } - socket.removeListener('error', tempSocketErr); - socket.removeListener('timeout', timeoutErr); - socket.setTimeout(0); - return done(null, socket); - } - if (received > MAX_RESPONSE_HEADER_BYTES) { - socket.removeListener('data', onSocketData); - const err = new Error('Proxy response headers too large'); - err.code = errors.EPROXY; - return tempSocketErr(err); - } - }; socket.on('data', onSocketData); }); - socket.setTimeout(httpProxyClient.timeout || 30 * 1000); - socket.on('timeout', timeoutErr); - socket.once('error', tempSocketErr); + timer = setTimeout(() => { + const err = new Error('Proxy socket timed out'); + err.code = errors.ETIMEDOUT; + fail(err); + }, timeout); + socket.once('error', fail); + socket.once('close', onEarlyClose); } exports.default = httpProxyClient; module.exports = exports.default; 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 a14a5903..da692c86 100644 --- a/node_modules/nodemailer/dist/cjs/smtp-connection/index.d.ts +++ b/node_modules/nodemailer/dist/cjs/smtp-connection/index.d.ts @@ -3,7 +3,7 @@ import net from 'node:net'; import tls from 'node:tls'; import { type Readable } from 'node:stream'; import * as shared from '../shared/index.js'; -import type { Callback, NodemailerError } from '../errors.js'; +import { type Callback, type NodemailerError } from '../errors.js'; import type XOAuth2 from '../xoauth2/index.js'; import type { XOAuth2Options } from '../xoauth2/index.js'; /** @@ -275,6 +275,12 @@ export interface SMTPConnectionConnectOptions extends tls.ConnectionOptions { allowInternalNetworkInterfaces?: boolean | undefined; /** DNS lookup timeout in ms */ timeout?: number | undefined; + /** Try the resolved addresses in turn, see net.connect */ + autoSelectFamily?: boolean | undefined; + /** Time in ms an address gets before the next one is tried, see net.connect */ + autoSelectFamilyAttemptTimeout?: number | undefined; + /** Hands the resolved addresses to net.connect */ + lookup?: net.LookupFunction | undefined; } /** * A queued handler for the next server response diff --git a/node_modules/nodemailer/dist/cjs/smtp-connection/index.js b/node_modules/nodemailer/dist/cjs/smtp-connection/index.js index c02906ae..5c6476cc 100644 --- a/node_modules/nodemailer/dist/cjs/smtp-connection/index.js +++ b/node_modules/nodemailer/dist/cjs/smtp-connection/index.js @@ -45,12 +45,30 @@ const node_crypto_1 = __importDefault(require("node:crypto")); const data_stream_js_1 = __importDefault(require("./data-stream.js")); const node_stream_1 = require("node:stream"); const shared = __importStar(require("../shared/index.js")); +const errors_js_1 = require("../errors.js"); // default timeout values in ms const CONNECTION_TIMEOUT = 2 * 60 * 1000; // how much to wait for the connection to be established const SOCKET_TIMEOUT = 10 * 60 * 1000; // how much to wait for socket inactivity before disconnecting the client const GREETING_TIMEOUT = 30 * 1000; // how much to wait after connection is established but SMTP greeting is not receieved const DNS_TIMEOUT = 30 * 1000; // how much to wait for resolveHostname +const CLOSE_TIMEOUT = 5 * 1000; // how much to wait for the server to close its side after we closed ours +const KEEPALIVE_DELAY = 30 * 1000; // idle time before TCP keepalive probes start, keeps NAT mappings of idle connections alive const TEARDOWN_NOOP = () => { }; // reusable no-op handler for absorbing errors during socket teardown +// Random order, so that connections spread over the addresses of a host +function shuffle(list) { + const result = list.slice(); + for (let i = result.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [result[i], result[j]] = [result[j], result[i]]; + } + return result; +} +// Every timeout is reported with the ETIMEDOUT code, timeoutType tells which one it was +function timeoutError(message, timeoutType) { + const err = new Error(message); + err.timeoutType = timeoutType; + return err; +} // how many bytes a single server response may occupy while it is still being received. // Generous compared to any real reply, it only stops a peer that never completes one const MAX_RESPONSE_SIZE = 1024 * 1024; @@ -173,8 +191,8 @@ class SMTPConnection extends node_events_1.EventEmitter { this._maxAllowedSize = 0; this._responseActions = []; this._recipientQueue = []; - this._greetingTimeout = false; - this._connectionTimeout = false; + this._phaseTimer = false; + this._plaintextEhlo = false; this._destroyed = false; this._closing = false; this._currentDataStream = false; @@ -185,14 +203,25 @@ class SMTPConnection extends node_events_1.EventEmitter { this._onSocketClose = () => this._onClose(); this._onSocketEnd = () => this._onEnd(); this._onSocketTimeout = () => this._onTimeout(); - this._onConnectionSocketError = err => this._onConnectionError(err, 'ESOCKET'); - this._connectionAttemptId = 0; + this._onConnectionSocketError = err => this._onError(err, 'ESOCKET', false, 'CONN'); } /** * Creates a connection to a SMTP server and sets up connection * listener */ connect(connectCallback) { + if (this._connectCalled && !this._destroyed) { + // A connection is opened once. A second call would open a second socket over the + // first one and run the session handlers of both against the same state + const err = this._formatError('Cannot connect - connect() was already called for this connection', 'ECONNECTION', false, 'API'); + if (typeof connectCallback === 'function') { + setImmediate(() => connectCallback(err)); + return; + } + this.logger.warn({ tnx: 'smtp' }, '%s', err.message); + return; + } + this._connectCalled = true; if (typeof connectCallback === 'function') { this._connectCallback = connectCallback; this.once('connect', () => { @@ -207,11 +236,15 @@ class SMTPConnection extends node_events_1.EventEmitter { return connectCallback(this._formatError(isDestroyedMessage, 'ECONNECTION', false, 'CONN')); } } + // connectionTimeout covers the whole of connecting: the DNS lookup and every address tried + const connectionTimeout = this.options.connectionTimeout || CONNECTION_TIMEOUT; + // a transport that first opened a proxy connection for this one sets when that started + this._connectionDeadline = (this._connectStartedAt || Date.now()) + connectionTimeout; let opts = { port: this.port, host: this.host, allowInternalNetworkInterfaces: this.allowInternalNetworkInterfaces, - timeout: this.options.dnsTimeout || DNS_TIMEOUT + timeout: Math.min(this.options.dnsTimeout || DNS_TIMEOUT, connectionTimeout) }; if (this.options.localAddress) { opts.localAddress = this.options.localAddress; @@ -240,7 +273,6 @@ class SMTPConnection extends node_events_1.EventEmitter { return this._resolveAndConnect(opts, _resolved => { try { this._socket.connect(this.port, this.host, () => { - this._socket.setKeepAlive(true); // a `secure` connection over a caller-provided socket must still // perform the TLS handshake, otherwise AUTH and the message body // would be sent in cleartext despite the caller requesting TLS @@ -272,9 +304,40 @@ class SMTPConnection extends node_events_1.EventEmitter { } } return this._resolveAndConnect(opts, resolved => { - // Store fallback addresses for retry on connection failure - this._fallbackAddresses = (resolved._addresses || []).filter(addr => addr !== opts.host); - this._connectOpts = Object.assign({}, opts); + let addresses = resolved._addresses || []; + if (opts.localAddress) { + // a socket bound to an address of one family can not reach the other one + const localFamily = node_net_1.default.isIPv6(opts.localAddress) ? 6 : 4; + const sameFamily = addresses.filter(addr => node_net_1.default.isIP(addr) === localFamily); + addresses = sameFamily.length ? sameFamily : addresses; + } + if (addresses.length > 1) { + // net.connect tries the addresses in turn and moves on to the next one when an + // attempt fails or takes too long. With both families it starts on IPv6 and + // alternates (RFC 8305), so a host with a broken IPv6 path costs a fraction of + // a second instead of a whole connection timeout + const ipv6 = addresses.filter(addr => node_net_1.default.isIPv6(addr)); + const ipv4 = addresses.filter(addr => !node_net_1.default.isIPv6(addr)); + const ordered = shuffle(ipv6).concat(shuffle(ipv4)); + opts.host = this.host; + opts.autoSelectFamily = true; + if (!ipv6.length || !ipv4.length) { + // a slow address of the only family gets its share of the time, not the + // quarter second meant for an address family that does not work + const remaining = this._connectionDeadline - Date.now(); + opts.autoSelectFamilyAttemptTimeout = Math.max(Math.floor(remaining / ordered.length), 10); + } + opts.lookup = ((hostname, lookupOptions, callback) => { + if (lookupOptions && lookupOptions.all) { + const all = ordered.map(address => ({ address, family: node_net_1.default.isIPv6(address) ? 6 : 4 })); + return setImmediate(() => callback(null, all)); + } + setImmediate(() => callback(null, ordered[0], node_net_1.default.isIPv6(ordered[0]) ? 6 : 4)); + }); + } + else if (addresses.length) { + opts.host = addresses[0]; + } this._connectToHost(opts, this.secureConnection); }); } @@ -321,20 +384,11 @@ class SMTPConnection extends node_events_1.EventEmitter { if (this._destroyed || this._closing) { return; } - this._connectionAttemptId++; - const currentAttemptId = this._connectionAttemptId; const connectFn = secure ? node_tls_1.default.connect : node_net_1.default.connect; try { - this._socket = connectFn(opts, () => { - // Ignore callback if this is a stale connection attempt - if (this._connectionAttemptId !== currentAttemptId) { - return; - } - this._socket.setKeepAlive(true); - this._onConnect(); - }); + this._socket = connectFn(opts, () => this._onConnect()); this._setupConnectionHandlers(); } catch (E) { @@ -347,51 +401,37 @@ class SMTPConnection extends node_events_1.EventEmitter { * @internal */ _setupConnectionHandlers() { - this._connectionTimeout = setTimeout(() => { - this._onConnectionError('Connection timeout', 'ETIMEDOUT'); - }, this.options.connectionTimeout || CONNECTION_TIMEOUT); + this._startPhase(Math.max((this._connectionDeadline || Date.now()) - Date.now(), 0), timeoutError('Connection timeout', 'CONNECT_TIMEOUT')); this._socket.on('error', this._onConnectionSocketError); } /** - * Handles connection errors with fallback to alternative addresses + * Starts the timer of a connection phase: connecting, waiting for the greeting or a TLS + * upgrade. The phases follow one another, so starting one ends the one before * - * @param err Error object or message - * @param code Error code + * @param timeout Time the phase may take + * @param err Error to fail with when it takes longer * @internal */ - _onConnectionError(err, code) { - clearTimeout(this._connectionTimeout); - // Check if we have fallback addresses to try - const canFallback = this._fallbackAddresses && this._fallbackAddresses.length && this.stage === 'init' && !this._destroyed; - if (!canFallback) { - // No more fallback addresses, report the error - this._onError(err, code, false, 'CONN'); - return; - } - const nextHost = this._fallbackAddresses.shift(); - this.logger.info({ - tnx: 'network', - failedHost: this._connectOpts.host, - nextHost, - error: err.message || err - }, 'Connection to %s failed, trying %s', this._connectOpts.host, nextHost); - // Clean up current socket - if (this._socket) { - try { - this._socket.removeListener('error', this._onConnectionSocketError); - // Absorb any late teardown error (e.g. a TLS fallback socket emitting - // after destroy), mirroring the guard used in close() - this._socket.on('error', TEARDOWN_NOOP); - this._socket.destroy(); - } - catch (_E) { - // ignore - } - this._socket = null; - } - // Update host and retry - this._connectOpts.host = nextHost; - this._connectToHost(this._connectOpts, this.secureConnection); + _startPhase(timeout, err) { + this._clearPhase(); + this._phaseTimer = setTimeout(() => { + this._phaseTimer = false; + this._onError(err, 'ETIMEDOUT', false, 'CONN'); + }, timeout); + } + /** + * Time the greeting or a STARTTLS upgrade may take: greetingTimeout, cut short by what is left + * of connectionTimeout + * + * @internal + */ + _remainingSetupTime() { + return Math.min(this.options.greetingTimeout || GREETING_TIMEOUT, Math.max((this._connectionDeadline || Infinity) - Date.now(), 1)); + } + /** @internal */ + _clearPhase() { + clearTimeout(this._phaseTimer); + this._phaseTimer = false; } /** * Sends QUIT @@ -404,8 +444,7 @@ class SMTPConnection extends node_events_1.EventEmitter { * Closes the connection to the server */ close() { - clearTimeout(this._connectionTimeout); - clearTimeout(this._greetingTimeout); + this._clearPhase(); this._responseActions = []; // allow to run this function only once if (this._closing) { @@ -428,13 +467,15 @@ 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 + // Detach from the message stream as well and release whatever it reads from, the message + // can not be sent over this connection anymore. The listener is swapped for a no-op rather + // than removed, a stream destroyed with an error 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); + stream.destroy(); } this._pendingSend = false; } @@ -453,6 +494,15 @@ class SMTPConnection extends node_events_1.EventEmitter { // sending cleartext after TLS shutdown triggers ERR_SSL_BAD_RECORD_TYPE) socket.on('error', TEARDOWN_NOOP); socket[closeMethod](); + if (closeMethod === 'end') { + // end() only closes our side, a server that never closes its own would keep + // the socket, and the process with it, around for good + const closeTimer = setTimeout(() => socket.destroy(), CLOSE_TIMEOUT); + if (typeof closeTimer.unref === 'function') { + closeTimer.unref(); + } + socket.once('close', () => clearTimeout(closeTimer)); + } } catch (_E) { // just ignore @@ -657,14 +707,9 @@ class SMTPConnection extends node_events_1.EventEmitter { const startTime = Date.now(); this._setEnvelope(envelope, (err, info) => { if (err) { - // create passthrough stream to consume to prevent OOM - const stream = new node_stream_1.PassThrough(); - if (typeof message.pipe === 'function') { - message.pipe(stream); - } - else { - stream.write(message); - stream.end(); + // the message is not going to be sent, release whatever the stream reads from + if (typeof message.destroy === 'function') { + message.destroy(); } return callback(err); } @@ -718,7 +763,7 @@ class SMTPConnection extends node_events_1.EventEmitter { */ _onConnect() { const socket = this._socket; - clearTimeout(this._connectionTimeout); + this._clearPhase(); this.logger.info({ tnx: 'network', localAddress: socket.localAddress, @@ -748,16 +793,42 @@ class SMTPConnection extends node_events_1.EventEmitter { socket.once('end', this._onSocketEnd); socket.setTimeout(this.options.socketTimeout || SOCKET_TIMEOUT); socket.on('timeout', this._onSocketTimeout); - this._greetingTimeout = setTimeout(() => { - // if still waiting for greeting, give up - if (this._socket && !this._destroyed && this._responseActions[0] === this._actionGreeting) { - this._onError('Greeting never received', 'ETIMEDOUT', false, 'CONN'); - } - }, this.options.greetingTimeout || GREETING_TIMEOUT); + // keepalive also covers sockets handed over by a proxy or by the caller + if (typeof socket.setKeepAlive === 'function') { + socket.setKeepAlive(true, KEEPALIVE_DELAY); + } + // Commands are written in the batches they belong to (see cork() for PIPELINING), Nagle + // would only hold a write back until the server acknowledged the previous one. Against a + // server that delays its ACKs that costs 40ms on every message + if (typeof socket.setNoDelay === 'function') { + socket.setNoDelay(true); + } + // bounded by greetingTimeout and by what is left of connectionTimeout, which covers setting + // the session up from the DNS lookup to the end of a STARTTLS upgrade + this._startPhase(this._remainingSetupTime(), timeoutError('Greeting never received', 'GREETING_TIMEOUT')); this._responseActions.push(this._actionGreeting); // we have a 'data' listener set up so resume socket if it was paused socket.resume(); } + /** + * Ends the session after a 421 reply. The replies queued for commands sent along with the + * answered one are not going to come, so the message in flight is failed here + * + * @param str The 421 reply + * @internal + */ + _onServerClosing(str) { + if (this._destroyed) { + return; + } + const pendingSend = this._pendingSend; + const envelope = this._envelope; + this._responseActions = []; + this.close(); + if (pendingSend) { + pendingSend.callback((envelope && envelope.mailError) || this._formatError('Server closed the connection', 'ECONNECTION', str, 'CONN')); + } + } /** * 'data' listener for data coming from the server * @@ -832,8 +903,7 @@ class SMTPConnection extends node_events_1.EventEmitter { * @internal */ _onError(err, type, data, command) { - clearTimeout(this._connectionTimeout); - clearTimeout(this._greetingTimeout); + this._clearPhase(); if (this._destroyed) { // just ignore, already closed // this might happen when a socket is canceled because of reached timeout @@ -841,12 +911,12 @@ class SMTPConnection extends node_events_1.EventEmitter { return; } err = this._formatError(err, type, data, command); - const transientCodes = ['ETIMEDOUT', 'ESOCKET', 'ECONNECTION']; - if (transientCodes.includes(err.code)) { - this.logger.warn(data, err.message); + // the message carries the server response, it is an argument and not the format string + if ((0, errors_js_1.isTransientError)(err)) { + this.logger.warn({ tnx: 'smtp', err }, '%s', err.message); } else { - this.logger.error(data, err.message); + this.logger.error({ tnx: 'smtp', err }, '%s', 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 @@ -866,7 +936,12 @@ class SMTPConnection extends node_events_1.EventEmitter { else { err = new Error(message); } - if (type && type !== 'Error') { + // a permission model denial keeps its own code, see ERR_ACCESS_DENIED + if (type && type !== 'Error' && err.code !== errors_js_1.ERR_ACCESS_DENIED) { + // the code of a system error, such as ECONNREFUSED, still tells what happened + if (err.code && err.code !== type && !err.originalCode) { + err.originalCode = err.code; + } err.code = type; } if (response) { @@ -918,6 +993,19 @@ class SMTPConnection extends node_events_1.EventEmitter { this.close(); return; } + if (!failureResponse && + this.stage === 'connected' && + !this._responseActions.length && + !this._pendingSend && + !this._destroyed && + !this._closing) { + // nothing was waiting for the server, so this is the server ending an idle session + // (usually after its idle timeout), not a failure + this.logger.info({ + tnx: 'network' + }, 'Server closed the idle connection'); + return this._destroy(); + } if (failureResponse || (this._responseActions[0] !== this.close && !this._destroyed)) { return this._onError(new Error('Connection closed unexpectedly'), 'ECONNECTION', failureResponse, 'CONN'); } @@ -943,7 +1031,7 @@ class SMTPConnection extends node_events_1.EventEmitter { * @internal */ _onTimeout() { - return this._onError(new Error('Timeout'), 'ETIMEDOUT', false, 'CONN'); + return this._onError(timeoutError('Timeout', 'SOCKET_TIMEOUT'), 'ETIMEDOUT', false, 'CONN'); } /** * Destroys the client, emits 'end' @@ -958,10 +1046,7 @@ class SMTPConnection extends node_events_1.EventEmitter { this.destroyed = true; // a connection the server dropped before the greeting would otherwise keep // the greeting timer, and with it the process, alive until it fires - clearTimeout(this._connectionTimeout); - clearTimeout(this._greetingTimeout); - this._connectionTimeout = false; - this._greetingTimeout = false; + this._clearPhase(); this.emit('end'); } /** @@ -978,6 +1063,15 @@ class SMTPConnection extends node_events_1.EventEmitter { // inject plaintext bytes after the "220" reply (e.g. a CRLF-free fragment that // would otherwise be prepended to the first post-TLS response and parsed as // part of the secured EHLO capabilities). STARTTLS response injection. + const discarded = this._remainder.length + this._responseQueue.reduce((total, response) => total + response.length, 0); + if (discarded) { + // a server does not send anything here on its own, this is worth knowing about + this.logger.warn({ + tnx: 'smtp', + discarded + }, 'Discarded %s bytes received in plaintext after the STARTTLS response', discarded); + } + this._plaintextEhlo = false; this._remainder = ''; this._responseQueue = []; this._responsePartial = false; @@ -987,6 +1081,7 @@ class SMTPConnection extends node_events_1.EventEmitter { const socketPlain = this._socket; socketPlain.removeListener('data', this._onSocketData); // incoming data is going to be gibberish from this point onwards socketPlain.removeListener('timeout', this._onSocketTimeout); // timeout will be re-set for the new socket object + socketPlain.setTimeout(0); const opts = Object.assign({ socket: socketPlain, host: this.host @@ -1007,9 +1102,14 @@ class SMTPConnection extends node_events_1.EventEmitter { socketPlain.removeListener('error', this._onConnectionSocketError); }; this.upgrading = true; + // the socket timeout only notices a server that sends nothing at all, a handshake that + // trickles along would otherwise hold the connection for as long as the server likes + // STARTTLS is the last step of setting the session up + this._startPhase(this._remainingSetupTime(), timeoutError('TLS handshake timed out', 'UPGRADE_TIMEOUT')); // tls.connect is not an asynchronous function however it may still throw errors and requires to be wrapped with try/catch try { this._socket = node_tls_1.default.connect(opts, () => { + this._clearPhase(); this.secure = true; this.upgrading = false; this._socket.on('data', this._onSocketData); @@ -1018,6 +1118,7 @@ class SMTPConnection extends node_events_1.EventEmitter { }); } catch (err) { + this._clearPhase(); removePlainSocketListeners(); return callback(err); } @@ -1058,10 +1159,24 @@ class SMTPConnection extends node_events_1.EventEmitter { }, str.replace(/\r?\n$/, '')); } const action = this._responseActions.shift(); + // RFC 5321 4.2: 421 means the server is about to close the connection, whatever it answers + const closing = /^421[ -]/.test(str); if (typeof action === 'function') { + // the command gets its own error first, the code tells which step failed action.call(this, str); + if (closing) { + return this._onServerClosing(str); + } setImmediate(() => this._processResponse()); } + else if (closing && !this._pendingSend && this.stage === 'connected') { + // RFC 5321 4.2: a server may send 421 at any time when it is about to close the + // connection. Nothing was waiting for a reply, so this ends an idle session + this.logger.info({ + tnx: 'smtp' + }, 'Server closed the idle connection: %s', str); + this.close(); + } else { return this._onError(new Error('Unexpected Response'), 'EPROTOCOL', str, 'CONN'); } @@ -1156,6 +1271,9 @@ class SMTPConnection extends node_events_1.EventEmitter { return callback(this._formatError('Server does not support REQUIRETLS extension (RFC 8689)', 'EREQUIRETLS', false, 'MAIL FROM')); } } + // RFC 2920: with PIPELINING the whole envelope and DATA go out without waiting for the + // replies in between, which saves two round trips for every message + this._envelope.pipelined = this._supportedExtensions.includes('PIPELINING'); this._responseActions.push(str => { this._actionMAIL(str, callback); }); @@ -1193,7 +1311,38 @@ class SMTPConnection extends node_events_1.EventEmitter { if (this._envelope.requireTLSExtensionEnabled) { args.push('REQUIRETLS'); } - this._sendCommand('MAIL FROM:<' + this._envelope.from + '>' + (args.length ? ' ' + args.join(' ') : '')); + const mailFrom = 'MAIL FROM:<' + this._envelope.from + '>' + (args.length ? ' ' + args.join(' ') : ''); + this._recipientQueue = []; + if (!this._envelope.pipelined) { + this._sendCommand(mailFrom); + return; + } + // corked, so the batch leaves in one segment instead of the first command alone + const socket = this._socket; + socket.cork(); + this._sendCommand(mailFrom); + while (this._envelope.rcptQueue.length) { + this._sendRcpt(this._envelope.rcptQueue.shift(), callback); + } + this._responseActions.push(str => { + this._actionDATA(str, callback); + }); + this._sendCommand('DATA'); + socket.uncork(); + } + /** + * Sends RCPT TO for a recipient and queues the handler for the reply + * + * @param recipient Recipient address + * @param callback Callback to run once the envelope is processed + * @internal + */ + _sendRcpt(recipient, callback) { + this._recipientQueue.push(recipient); + this._responseActions.push(str => { + this._actionRCPT(str, callback); + }); + this._sendCommand('RCPT TO:<' + recipient + '>' + this._getDsnRcptToArgs()); } /** @internal */ _setDsnEnvelope(params) { @@ -1309,7 +1458,7 @@ class SMTPConnection extends node_events_1.EventEmitter { * @internal */ _actionGreeting(str) { - clearTimeout(this._greetingTimeout); + this._clearPhase(); if (str.substr(0, 3) !== '220') { this._onError(new Error('Invalid greeting. response=' + str), 'EPROTOCOL', str, 'CONN'); return; @@ -1347,7 +1496,6 @@ class SMTPConnection extends node_events_1.EventEmitter { * @internal */ _actionEHLO(str) { - let match; if (str.substr(0, 3) === '421') { this._onError(new Error('Server terminates connection. response=' + str), 'ECONNECTION', str, 'EHLO'); return; @@ -1362,17 +1510,30 @@ class SMTPConnection extends node_events_1.EventEmitter { this._sendCommand('HELO ' + this.name); return; } + // Detect if the server supports STARTTLS + if (!this.secure && !this.options.ignoreTLS && (/[ -]STARTTLS\b/im.test(str) || this.options.requireTLS)) { + // kept for opportunisticTLS, a session that stays in plaintext still has these extensions + this._plaintextEhlo = str; + this._sendCommand('STARTTLS'); + this._responseActions.push(this._actionSTARTTLS); + return; + } + this._parseEhloExtensions(str); + this.emit('connect'); + } + /** + * Reads the extensions and the authentication mechanisms out of an EHLO response + * + * @param str EHLO response from the server + * @internal + */ + _parseEhloExtensions(str) { + let match; this._ehloLines = str .split(/\r?\n/) .map(line => line.replace(/^\d+[ -]/, '').trim()) .filter(line => line) .slice(1); - // Detect if the server supports STARTTLS - if (!this.secure && !this.options.ignoreTLS && (/[ -]STARTTLS\b/im.test(str) || this.options.requireTLS)) { - this._sendCommand('STARTTLS'); - this._responseActions.push(this._actionSTARTTLS); - return; - } // Detect if the server supports SMTPUTF8 if (/[ -]SMTPUTF8\b/im.test(str)) { this._supportedExtensions.push('SMTPUTF8'); @@ -1422,7 +1583,6 @@ class SMTPConnection extends node_events_1.EventEmitter { this._supportedExtensions.push('SIZE'); this._maxAllowedSize = Number(match[1]) || 0; } - this.emit('connect'); } /** * Handles server response for HELO command. If it yielded in @@ -1454,6 +1614,14 @@ class SMTPConnection extends node_events_1.EventEmitter { this.logger.info({ tnx: 'smtp' }, 'Failed STARTTLS upgrade, continuing unencrypted'); + // the plaintext session goes on with what the server announced for it, except for + // AUTH: credentials are not sent over a connection that failed to encrypt + if (this._plaintextEhlo) { + this._parseEhloExtensions(this._plaintextEhlo); + this._plaintextEhlo = false; + this.allowsAuth = false; + this._supportedAuth = []; + } this.emit('connect'); return; } @@ -1634,21 +1802,9 @@ class SMTPConnection extends node_events_1.EventEmitter { const message = this._usingSmtpUtf8 && /^550 /.test(str) && /[\x80-\uFFFF]/.test(envelope.from) ? 'Internationalized mailbox name not allowed' : 'Mail command failed'; - return callback(this._formatError(message, 'EENVELOPE', str, 'MAIL FROM')); + envelope.mailError = this._formatError(message, 'EENVELOPE', str, 'MAIL FROM'); } - if (!envelope.rcptQueue.length) { - return callback(this._formatError("Can't send mail - no recipients defined", 'EENVELOPE', false, 'API')); - } - this._recipientQueue = []; - const usePipelining = this._supportedExtensions.includes('PIPELINING'); - do { - const curRecipient = envelope.rcptQueue.shift(); - this._recipientQueue.push(curRecipient); - this._responseActions.push(str => { - this._actionRCPT(str, callback); - }); - this._sendCommand('RCPT TO:<' + curRecipient + '>' + this._getDsnRcptToArgs()); - } while (usePipelining && envelope.rcptQueue.length); + this._advanceEnvelope(str, callback); } /** * Handle response for a RCPT TO: command @@ -1675,32 +1831,67 @@ class SMTPConnection extends node_events_1.EventEmitter { else { envelope.accepted.push(curRecipient); } - if (!envelope.rcptQueue.length && !this._recipientQueue.length) { - if (envelope.rejected.length < envelope.to.length) { - this._responseActions.push(str => { - this._actionDATA(str, callback); - }); - this._sendCommand('DATA'); - } - else { - // 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); - } + this._advanceEnvelope(str, callback); + } + /** + * Moves the envelope on after a reply to MAIL FROM or RCPT TO. A pipelined envelope sent every + * command at once and is decided by the reply to DATA. Otherwise the commands go one at a + * time: the next recipient, or once every reply is in, DATA or the error that ends the message + * + * @param str The reply that was handled + * @param callback Callback to run once the envelope is processed + * @internal + */ + _advanceEnvelope(str, callback) { + const envelope = this._envelope; + if (envelope.pipelined) { + return; } - else if (envelope.rcptQueue.length) { - const nextRecipient = envelope.rcptQueue.shift(); - this._recipientQueue.push(nextRecipient); - this._responseActions.push(str => { - this._actionRCPT(str, callback); - }); - this._sendCommand('RCPT TO:<' + nextRecipient + '>' + this._getDsnRcptToArgs()); + if (envelope.mailError) { + return callback(envelope.mailError); } + if (envelope.rcptQueue.length) { + return this._sendRcpt(envelope.rcptQueue.shift(), callback); + } + if (this._recipientQueue.length) { + // replies still to come + return; + } + const err = this._envelopeError(str); + if (err) { + return callback(err); + } + this._responseActions.push(str => { + this._actionDATA(str, callback); + }); + this._sendCommand('DATA'); + } + /** + * Decides how the envelope went once every reply to MAIL FROM and RCPT TO is in. Called after + * the last RCPT TO reply, or with PIPELINING on the reply to the DATA command sent along + * + * @param str The reply being handled + * @returns The error to fail the message with, or null when DATA can go ahead + * @internal + */ + _envelopeError(str) { + const envelope = this._envelope; + if (envelope.mailError) { + return envelope.mailError; + } + if (envelope.accepted.length) { + return null; + } + // 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 lastRejected = envelope.rejectedErrors[envelope.rejectedErrors.length - 1]; + const reply = deferred?.response ?? (envelope.pipelined && lastRejected ? lastRejected.response : str); + // every recipient was rejected + const err = this._formatError("Can't send mail - all recipients were rejected", 'EENVELOPE', reply, 'RCPT TO'); + err.rejected = envelope.rejected; + err.rejectedErrors = envelope.rejectedErrors; + return err; } /** * Handle response for a DATA command @@ -1711,6 +1902,27 @@ class SMTPConnection extends node_events_1.EventEmitter { */ _actionDATA(str, callback) { const envelope = this._envelope; + if (envelope.pipelined) { + const err = this._envelopeError(str); + if (err) { + if (/^3/.test(str)) { + if (envelope.mailError) { + // A server that refused the sender has no transaction to end, one that + // took DATA anyway can not be trusted with an empty message either. + // Drop the connection instead of answering it + this.close(); + return callback(err); + } + // A server must refuse DATA without an accepted recipient, this one took it + // anyway. End the empty message, it has nobody to go to, so the session + // stays usable + this._responseActions.push(() => callback(err)); + this._sendCommand('.'); + return; + } + return callback(err); + } + } // response should be 354 but according to this issue https://github.com/eleith/emailjs/issues/24 // some servers might use 250 instead, so lets check for 2 or 3 as the first digit if (!/^[23]/.test(str)) { 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 1f1aeff0..7c28f12b 100644 --- a/node_modules/nodemailer/dist/cjs/smtp-pool/index.d.ts +++ b/node_modules/nodemailer/dist/cjs/smtp-pool/index.d.ts @@ -19,6 +19,8 @@ export interface SMTPPoolOptions extends SMTPTransportOptions { rateDelta?: number | undefined; /** How many times a message is requeued when its connection closes while sending, defaults to 5, a negative value means unlimited */ maxRequeues?: number | undefined; + /** Time in milliseconds a connection may stay idle before it is closed, defaults to 4 minutes, 0 keeps it open until the server closes it */ + idleTimeout?: number | undefined; } /** * The pool options once the constructor has applied the defaults @@ -27,6 +29,7 @@ export type SMTPPoolResolvedOptions = SMTPPoolOptions & { maxConnections: number; maxMessages: number; maxRequeues: number; + idleTimeout: 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 4ed107a3..a947a198 100644 --- a/node_modules/nodemailer/dist/cjs/smtp-pool/index.js +++ b/node_modules/nodemailer/dist/cjs/smtp-pool/index.js @@ -80,6 +80,9 @@ class SMTPPool extends node_events_1.EventEmitter { 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; + // below the 5 minutes RFC 5321 asks servers to wait at least, the connection is closed by us + // and not by the server in the middle of handing it a message + this.options.idleTimeout = typeof this.options.idleTimeout === 'number' ? this.options.idleTimeout : 4 * 60 * 1000; this.logger = shared.getLogger(this.options, { component: this.options.component || 'smtp-pool' }); @@ -486,7 +489,9 @@ class SMTPPool extends node_events_1.EventEmitter { // the error paths hand over the error alone const done = callback; const auth = new pool_resource_js_1.default(this).auth; - this.getSocket(this.options, (err, socketOptions) => { + // the proxy handshake, if any, counts against connectionTimeout as well + const connectStartedAt = Date.now(); + this.getSocket(Object.assign({}, this.options, { connectStartedAt }), (err, socketOptions) => { if (err) { return done(err); } @@ -503,6 +508,7 @@ class SMTPPool extends node_events_1.EventEmitter { options = Object.assign(shared.assign(false, options), socketOptions); } const connection = new index_js_1.default(options); + connection._connectStartedAt = connectStartedAt; let returned = false; connection.once('error', 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 cd7244fe..87ca3b4f 100644 --- a/node_modules/nodemailer/dist/cjs/smtp-pool/pool-resource.js +++ b/node_modules/nodemailer/dist/cjs/smtp-pool/pool-resource.js @@ -90,6 +90,8 @@ class PoolResource extends node_events_1.EventEmitter { this.messages = 0; this.available = true; this._failed = false; + this._sending = false; + this._idleTimer = false; } /** * Emits 'error' for the first failure only. A dead resource can report the same failure more @@ -109,7 +111,9 @@ class PoolResource extends node_events_1.EventEmitter { * @param callback Callback function to run once the connection is established or failed */ connect(callback) { - this.pool.getSocket(this.options, (err, socketOptions) => { + // the proxy handshake, if any, counts against connectionTimeout as well + const connectStartedAt = Date.now(); + this.pool.getSocket(Object.assign({}, this.options, { connectStartedAt }), (err, socketOptions) => { if (err) { // 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 @@ -131,7 +135,21 @@ 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.on('error', err => { + this.connection._connectStartedAt = connectStartedAt; + this.connection.on('error', (err) => { + if (this._sending) { + // the send callback gets the same error and decides what it means for the message + return; + } + if (this._connected && errors.isTransientError(err)) { + // the server ended a connection that had nothing in flight, usually after it + // was idle for a while. The 'end' that follows closes this resource + this.logger.info({ + tnx: 'pool', + cid: this.id + }, 'Connection #%s was closed by the server: %s', this.id, err.message); + return; + } this._fail(err); if (returned) { return; @@ -209,9 +227,39 @@ class PoolResource extends node_events_1.EventEmitter { if (mail.data.requireTLSExtensionEnabled) { envelope.requireTLSExtensionEnabled = mail.data.requireTLSExtensionEnabled; } - this.connection.send(envelope, mail.message.createReadStream(), (err, info) => { + this._sending = true; + // a connection that sent messages before may have been dropped by the server in between + const reused = this.messages > 0; + const messageStream = mail.message.createReadStream(); + this.connection.send(envelope, messageStream, (err, info) => { + this._sending = false; this.messages++; if (err) { + if (reused && messageStream.readableDidRead === false && errors.isTransientError(err)) { + // Not a byte of the message was sent, so it can go out over another + // connection. The pool requeues the message when this resource closes + this.logger.info({ + tnx: 'pool', + cid: this.id, + messageId + }, 'Connection #%s was closed by the server before message %s was sent: %s', this.id, messageId, err.message); + this.connection.close(); + return; + } + if ((err.code === errors.EENVELOPE || err.code === errors.EMESSAGE) && + err.responseCode !== 421 && + !this.connection._destroyed) { + // The server refused this message, the connection itself is fine. Reset the + // session and keep using it instead of opening a new one + this.connection.reset(resetErr => { + if (resetErr) { + this.connection.close(); + return; + } + this._release(); + }); + return callback(err); + } this.connection.close(); this._fail(err); return callback(err); @@ -221,28 +269,69 @@ class PoolResource extends node_events_1.EventEmitter { to: envelope.to }; info.messageId = messageId; - setImmediate(() => { - if (this.messages >= this.options.maxMessages) { - const err = new Error('Resource exhausted'); - err.code = errors.EMAXLIMIT; - this.connection.close(); - this._fail(err); - } - else { - this.pool._checkRateLimit(() => { - this.available = true; - this.emit('available'); - }); - } - }); + setImmediate(() => this._release()); callback(null, info); }); } + /** + * Makes the connection available for the next message, or closes it once it has sent + * maxMessages messages + * + * @internal + */ + _release() { + if (this.messages >= this.options.maxMessages) { + const err = new Error('Resource exhausted'); + err.code = errors.EMAXLIMIT; + this.connection.close(); + this._fail(err); + return; + } + this.pool._checkRateLimit(() => { + this.available = true; + this._startIdleTimer(); + this.emit('available'); + }); + } + /** @internal */ + _startIdleTimer() { + if (!this.options.idleTimeout || this.options.idleTimeout < 0) { + return; + } + // one timer per connection, restarted every time the connection becomes available. A + // connection that is busy when it fires is simply not closed + if (this._idleTimer && typeof this._idleTimer.refresh === 'function') { + this._idleTimer.refresh(); + return; + } + clearTimeout(this._idleTimer); + this._idleTimer = setTimeout(() => { + if (!this.available) { + return; + } + this.logger.debug({ + tnx: 'pool', + cid: this.id + }, 'Closing connection #%s after it was idle for %sms', this.id, this.options.idleTimeout); + // not handed another message while it says goodbye + this.available = false; + this.connection.quit(); + }, this.options.idleTimeout); + if (typeof this._idleTimer.unref === 'function') { + this._idleTimer.unref(); + } + } + /** @internal */ + _stopIdleTimer() { + clearTimeout(this._idleTimer); + this._idleTimer = false; + } /** * Closes the connection */ close() { this._connected = false; + this._stopIdleTimer(); if (this.auth && this.auth.oauth2) { this.auth.oauth2.removeAllListeners(); } diff --git a/node_modules/nodemailer/dist/cjs/smtp-transport/index.js b/node_modules/nodemailer/dist/cjs/smtp-transport/index.js index bb23599b..fe550d76 100644 --- a/node_modules/nodemailer/dist/cjs/smtp-transport/index.js +++ b/node_modules/nodemailer/dist/cjs/smtp-transport/index.js @@ -142,7 +142,9 @@ class SMTPTransport extends node_events_1.EventEmitter { * @param callback Callback function */ send(mail, callback) { - this.getSocket(this.options, (err, socketOptions) => { + // the proxy handshake, if any, counts against connectionTimeout as well + const connectStartedAt = Date.now(); + this.getSocket(Object.assign({}, this.options, { connectStartedAt }), (err, socketOptions) => { if (err) { return callback(err); } @@ -161,6 +163,7 @@ class SMTPTransport extends node_events_1.EventEmitter { options = Object.assign(shared.assign(false, options), socketOptions); } const connection = new index_js_1.default(options); + connection._connectStartedAt = connectStartedAt; let perCallAuth; const cleanupPerCallAuth = () => { if (perCallAuth && perCallAuth !== this.auth && perCallAuth.oauth2) { @@ -266,7 +269,9 @@ class SMTPTransport extends node_events_1.EventEmitter { } // the error paths hand over the error alone const done = callback; - this.getSocket(this.options, (err, socketOptions) => { + // the proxy handshake, if any, counts against connectionTimeout as well + const connectStartedAt = Date.now(); + this.getSocket(Object.assign({}, this.options, { connectStartedAt }), (err, socketOptions) => { if (err) { return done(err); } @@ -283,6 +288,7 @@ class SMTPTransport extends node_events_1.EventEmitter { options = Object.assign(shared.assign(false, options), socketOptions); } const connection = new index_js_1.default(options); + connection._connectStartedAt = connectStartedAt; let returned = false; let perCallAuth; const cleanupPerCallAuth = () => { diff --git a/node_modules/nodemailer/dist/esm/addressparser/index.js b/node_modules/nodemailer/dist/esm/addressparser/index.js index 64c03e2d..aa579fcd 100644 --- a/node_modules/nodemailer/dist/esm/addressparser/index.js +++ b/node_modules/nodemailer/dist/esm/addressparser/index.js @@ -668,7 +668,11 @@ class Tokenizer { this.escaped = false; return; } - else if (['"', "'"].includes(this.operatorExpecting) && chr === '\\') { + else if (['"', "'", ')'].includes(this.operatorExpecting) && chr === '\\') { + // A backslash escapes the next character inside a quoted string and inside a + // comment alike (RFC 5322 3.2.2, ccontent includes quoted-pair). Honouring it + // only in quoted strings let an escaped parenthesis close the comment, which + // released the rest of it, an address included, into the header this.escaped = true; return; } diff --git a/node_modules/nodemailer/dist/esm/base64/index.d.ts b/node_modules/nodemailer/dist/esm/base64/index.d.ts index 56f51cd0..28ee041a 100644 --- a/node_modules/nodemailer/dist/esm/base64/index.d.ts +++ b/node_modules/nodemailer/dist/esm/base64/index.d.ts @@ -24,6 +24,9 @@ export interface EncoderOptions { /** * Creates a transform stream for encoding data to base64 encoding * + * The output is the same as `wrap(encode(input), lineLength)` no matter how the input is split + * into chunks: every line but the last one ends with a line break, the last one does not + * * @constructor * @param options Stream options * @param [options.lineLength=76] Maximum length for lines, set to false to disable wrapping diff --git a/node_modules/nodemailer/dist/esm/base64/index.js b/node_modules/nodemailer/dist/esm/base64/index.js index 75165fd9..b56b6db6 100644 --- a/node_modules/nodemailer/dist/esm/base64/index.js +++ b/node_modules/nodemailer/dist/esm/base64/index.js @@ -11,6 +11,47 @@ export function encode(buffer) { } return buffer.toString('base64'); } +/** + * Turns a line length option into a whole number of characters, the default for anything unusable + */ +function normalizeLineLength(lineLength) { + const length = Math.floor(Number(lineLength)); + return Number.isFinite(length) && length >= 1 ? length : 76; +} +/** + * Splits the bytes of `src` into lines of `lineLength` bytes, each followed by a line break. With + * `final` set the last line, which may be shorter, gets no line break; otherwise only complete + * lines are taken and the rest is left for the caller + * + * @param src Bytes to wrap + * @param lineLength Line length + * @param final Whether `src` ends the output + * @returns The wrapped bytes and the number of trailing bytes not taken + */ +function wrapBuffer(src, lineLength, final) { + const lines = Math.ceil(src.length / lineLength); + // the last line waits for more data unless this is the end: whether it gets a line break + // depends on whether anything follows it + const complete = Math.max(lines - 1, 0); + let rest = src.length - complete * lineLength; + const output = Buffer.allocUnsafe(complete * (lineLength + 2) + (final ? rest : 0)); + let to = 0; + for (let from = 0; from < complete * lineLength; from += lineLength) { + src.copy(output, to, from, from + lineLength); + to += lineLength; + output[to++] = 0x0d; + output[to++] = 0x0a; + } + if (final) { + to += src.copy(output, to, complete * lineLength); + rest = 0; + } + if (to !== output.length) { + // never hand out bytes of the unfilled allocation + throw new Error('Unexpected wrapped length'); + } + return { output, rest }; +} /** * Adds soft line breaks to a base64 string * @@ -39,6 +80,9 @@ export function wrap(str, lineLength) { /** * Creates a transform stream for encoding data to base64 encoding * + * The output is the same as `wrap(encode(input), lineLength)` no matter how the input is split + * into chunks: every line but the last one ends with a line break, the last one does not + * * @constructor * @param options Stream options * @param [options.lineLength=76] Maximum length for lines, set to false to disable wrapping @@ -48,66 +92,61 @@ export class Encoder extends Transform { super(); this.options = options || {}; if (this.options.lineLength !== false) { - this.options.lineLength = this.options.lineLength || 76; + this.options.lineLength = normalizeLineLength(this.options.lineLength); } this._curLine = ''; this._remainingBytes = false; this.inputBytes = 0; this.outputBytes = 0; } + /** + * Emits the encoded characters `b64` that follow the current line, keeping what can not be + * emitted yet as the new current line + * + * @internal + */ + _emit(b64, final) { + const src = Buffer.from(this._curLine + b64, 'latin1'); + if (!src.length) { + return; + } + let output = src; + if (this.options.lineLength) { + const wrapped = wrapBuffer(src, this.options.lineLength, final); + output = wrapped.output; + this._curLine = wrapped.rest ? src.toString('latin1', src.length - wrapped.rest) : ''; + } + else { + this._curLine = ''; + } + if (output.length) { + this.outputBytes += output.length; + this.push(output); + } + } /** @internal */ _transform(chunk, encoding, done) { let buf = encoding !== 'buffer' ? Buffer.from(chunk, encoding) : chunk; if (!buf || !buf.length) { - setImmediate(done); - return; + return done(); } this.inputBytes += buf.length; - if (this._remainingBytes && this._remainingBytes.length) { + if (this._remainingBytes) { buf = Buffer.concat([this._remainingBytes, buf], this._remainingBytes.length + buf.length); this._remainingBytes = false; } - if (buf.length % 3) { - this._remainingBytes = buf.slice(buf.length - (buf.length % 3)); - buf = buf.slice(0, buf.length - (buf.length % 3)); + const extra = buf.length % 3; + if (extra) { + this._remainingBytes = buf.subarray(buf.length - extra); + buf = buf.subarray(0, buf.length - extra); } - else { - this._remainingBytes = false; - } - let b64 = this._curLine + encode(buf); - if (this.options.lineLength) { - b64 = wrap(b64, this.options.lineLength); - // remove last line as it is still most probably incomplete - const lastLF = b64.lastIndexOf('\n'); - if (lastLF < 0) { - this._curLine = b64; - b64 = ''; - } - else if (lastLF === b64.length - 1) { - this._curLine = ''; - } - else { - this._curLine = b64.substring(lastLF + 1); - b64 = b64.substring(0, lastLF + 1); - } - } - if (b64) { - this.outputBytes += b64.length; - this.push(Buffer.from(b64, 'ascii')); - } - setImmediate(done); + this._emit(encode(buf), false); + done(); } /** @internal */ _flush(done) { - if (this._remainingBytes && this._remainingBytes.length) { - this._curLine += encode(this._remainingBytes); - } - if (this._curLine) { - this._curLine = wrap(this._curLine, this.options.lineLength); - this.outputBytes += this._curLine.length; - this.push(Buffer.from(this._curLine, 'ascii')); - this._curLine = ''; - } + this._emit(this._remainingBytes ? encode(this._remainingBytes) : '', true); + this._remainingBytes = false; done(); } } diff --git a/node_modules/nodemailer/dist/esm/dkim/index.js b/node_modules/nodemailer/dist/esm/dkim/index.js index c2bf6d11..ff815b4f 100644 --- a/node_modules/nodemailer/dist/esm/dkim/index.js +++ b/node_modules/nodemailer/dist/esm/dkim/index.js @@ -32,51 +32,67 @@ class DKIMSigner { this.output = output; this.output.usingCache = false; this.hasErrored = false; - this.input.on('error', err => { + this.input.on('error', err => this.fail(err)); + // A consumer that goes away before the signed message was read in full destroys the + // output. Stop reading the input and drop the cache file instead of leaving them open + this.output.once('close', () => { + if (this.output.writableFinished) { + return; + } this.hasErrored = true; + this.input.unpipe(); + this.input.destroy(); this.cleanup(); - output.emit('error', err); }); } + /** + * Ends the output with an error and releases the cache file + */ + fail(err) { + this.hasErrored = true; + this.cleanup(); + this.output.destroy(err); + } cleanup() { if (!this.cache || !this.cachePath) { return; } + const cache = this.cache; + this.cache = false; + cache.destroy(); fs.unlink(this.cachePath, () => false); } createReadCache() { // pipe remainings to cache file this.cache = fs.createReadStream(this.cachePath); - this.cache.once('error', err => { - this.cleanup(); - this.output.emit('error', err); - }); + this.cache.once('error', err => this.fail(err)); this.cache.once('close', () => { this.cleanup(); }); this.cache.pipe(this.output); } sendNextChunk() { + while (!this.hasErrored) { + if (this.readPos >= this.chunks.length) { + if (!this.cache) { + this.output.end(); + return; + } + return this.createReadCache(); + } + const chunk = this.chunks[this.readPos++]; + if (this.output.write(chunk) === false) { + this.output.once('drain', () => { + this.sendNextChunk(); + }); + return; + } + } + } + sendSignedOutput() { if (this.hasErrored) { return; } - if (this.readPos >= this.chunks.length) { - if (!this.cache) { - this.output.end(); - return; - } - return this.createReadCache(); - } - const chunk = this.chunks[this.readPos++]; - if (this.output.write(chunk) === false) { - this.output.once('drain', () => { - this.sendNextChunk(); - }); - return; - } - setImmediate(() => this.sendNextChunk()); - } - sendSignedOutput() { let keyPos = 0; const signNextKey = () => { if (keyPos >= this.keys.length) { @@ -96,9 +112,7 @@ class DKIMSigner { }); } catch (err) { - this.hasErrored = true; - this.cleanup(); - this.output.emit('error', err); + this.fail(err); return; } if (dkimField) { @@ -117,7 +131,6 @@ class DKIMSigner { // pipe remainings to cache file this.cache = fs.createWriteStream(this.cachePath); this.cache.once('error', err => { - this.cleanup(); // drain input this.relaxedBody.unpipe(this.cache); this.relaxedBody.on('readable', () => { @@ -125,11 +138,12 @@ class DKIMSigner { // do nothing } }); - this.hasErrored = true; - // emit error - this.output.emit('error', err); + this.fail(err); }); this.cache.once('close', () => { + if (this.hasErrored) { + return; + } this.sendSignedOutput(); }); this.relaxedBody.removeAllListeners('readable'); @@ -206,7 +220,7 @@ class DKIM { 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)); + signer.fail(sign.unsupportedHashAlgoError(signer.hashAlgo)); return; } if (writeValue) { diff --git a/node_modules/nodemailer/dist/esm/dkim/message-parser.js b/node_modules/nodemailer/dist/esm/dkim/message-parser.js index 03e89397..5dfb8cc6 100644 --- a/node_modules/nodemailer/dist/esm/dkim/message-parser.js +++ b/node_modules/nodemailer/dist/esm/dkim/message-parser.js @@ -105,7 +105,7 @@ class MessageParser extends Transform { const chunk = data.slice(headerPos); this.bodySize += chunk.length; // this would be the first chunk of data sent downstream - setImmediate(() => this.push(chunk)); + this.push(chunk); } return false; } @@ -134,7 +134,7 @@ class MessageParser extends Transform { this.bodySize += chunk.length; this.push(chunk); } - setImmediate(callback); + callback(); } /** @internal */ _flush(callback) { diff --git a/node_modules/nodemailer/dist/esm/errors.d.ts b/node_modules/nodemailer/dist/esm/errors.d.ts index 345678f8..bb7ec1f9 100644 --- a/node_modules/nodemailer/dist/esm/errors.d.ts +++ b/node_modules/nodemailer/dist/esm/errors.d.ts @@ -61,6 +61,19 @@ export declare const EPROXY = "EPROXY"; export declare const EFILEACCESS = "EFILEACCESS"; export declare const EURLACCESS = "EURLACCESS"; export declare const EFETCH = "EFETCH"; +/** + * Tells whether the error ended the connection rather than rejected what was sent over it + */ +export declare const isTransientError: (err: { + code?: string | undefined; + responseCode?: number | undefined; +}) => boolean; +/** + * Code Node.js sets on an error when its permission model (`--permission`) denies an + * operation. It is not replaced with the Nodemailer code of the failing step (`ESOCKET`, + * `EDNS`, `EFETCH`, ...), so a missing grant such as `--allow-net` stays recognizable. + */ +export declare const ERR_ACCESS_DENIED = "ERR_ACCESS_DENIED"; /** * An Error together with the properties Nodemailer attaches to the errors it * hands to callers. Every property is optional, the set that is present @@ -84,6 +97,10 @@ export interface NodemailerError extends NodeJS.ErrnoException { rejected?: string[] | undefined; /** Per-recipient errors for the rejected addresses */ rejectedErrors?: NodemailerError[] | undefined; + /** The code the error had before Nodemailer set its own, such as ECONNREFUSED for an ESOCKET error */ + originalCode?: string | undefined; + /** Which wait an ETIMEDOUT error ended: connecting, the greeting, a TLS upgrade or an idle socket */ + timeoutType?: 'CONNECT_TIMEOUT' | 'GREETING_TIMEOUT' | 'UPGRADE_TIMEOUT' | 'SOCKET_TIMEOUT' | undefined; } /** * Node style callback: called with an error, or with null and the result diff --git a/node_modules/nodemailer/dist/esm/errors.js b/node_modules/nodemailer/dist/esm/errors.js index c9ed3a19..4eee7a31 100644 --- a/node_modules/nodemailer/dist/esm/errors.js +++ b/node_modules/nodemailer/dist/esm/errors.js @@ -65,3 +65,13 @@ export const EPROXY = 'EPROXY'; export const EFILEACCESS = 'EFILEACCESS'; export const EURLACCESS = 'EURLACCESS'; export const EFETCH = 'EFETCH'; +/** + * Tells whether the error ended the connection rather than rejected what was sent over it + */ +export const isTransientError = (err) => err.responseCode === 421 || err.code === ECONNECTION || err.code === ESOCKET || err.code === ETIMEDOUT; +/** + * Code Node.js sets on an error when its permission model (`--permission`) denies an + * operation. It is not replaced with the Nodemailer code of the failing step (`ESOCKET`, + * `EDNS`, `EFETCH`, ...), so a missing grant such as `--allow-net` stays recognizable. + */ +export const ERR_ACCESS_DENIED = 'ERR_ACCESS_DENIED'; diff --git a/node_modules/nodemailer/dist/esm/fetch/cookies.js b/node_modules/nodemailer/dist/esm/fetch/cookies.js index 15edd60f..8a66c215 100644 --- a/node_modules/nodemailer/dist/esm/fetch/cookies.js +++ b/node_modules/nodemailer/dist/esm/fetch/cookies.js @@ -92,19 +92,31 @@ class Cookies { */ parse(cookieStr) { const cookie = {}; + let hasNameValue = false; (cookieStr || '') .toString() .split(';') .forEach(cookiePart => { - const valueParts = cookiePart.split('='); - const key = valueParts.shift().trim().toLowerCase(); - let value = valueParts.join('=').trim(); - let domain; - if (!key) { + if (!cookiePart.trim()) { // skip empty parts return; } - switch (key) { + const valueParts = cookiePart.split('='); + const name = valueParts.shift().trim(); + let value = valueParts.join('=').trim(); + let domain; + // the first part is always the name-value pair, so a cookie named + // like an attribute is not mistaken for one (RFC 6265 section 5.2) + if (!hasNameValue) { + hasNameValue = true; + if (name) { + // cookie names are case-sensitive, only attribute names are not + cookie.name = name; + cookie.value = value; + } + return; + } + switch (name.toLowerCase()) { case 'expires': { const expires = new Date(value); // ignore date if can not parse it @@ -132,11 +144,6 @@ class Cookies { case 'httponly': cookie.httponly = true; break; - default: - if (!cookie.name) { - cookie.name = key; - cookie.value = value; - } } }); return cookie; diff --git a/node_modules/nodemailer/dist/esm/fetch/index.js b/node_modules/nodemailer/dist/esm/fetch/index.js index 1ef32080..0ae1a40d 100644 --- a/node_modules/nodemailer/dist/esm/fetch/index.js +++ b/node_modules/nodemailer/dist/esm/fetch/index.js @@ -43,6 +43,17 @@ const TLS_OPTION_KEYS = [ 'sessionIdContext', 'sigalgs' ]; +/** + * Marks an error of the request as a fetch error, unless it is a permission model + * denial, which keeps its own code (see ERR_ACCESS_DENIED) + * + * @param err Error to mark + */ +function setFetchCode(err) { + if (err.code !== errors.ERR_ACCESS_DENIED) { + err.code = errors.EFETCH; + } +} /** * Resolves a URL only if it is one this module is willing to request. * @@ -139,7 +150,7 @@ function nmfetch(url, options) { return; } finished = true; - err.code = errors.EFETCH; + setFetchCode(err); err.sourceUrl = url; fetchRes.emit('error', err); }); @@ -212,7 +223,7 @@ function nmfetch(url, options) { catch (E) { finished = true; setImmediate(() => { - E.code = errors.EFETCH; + setFetchCode(E); E.sourceUrl = url; fetchRes.emit('error', E); }); @@ -224,7 +235,7 @@ function nmfetch(url, options) { return; } finished = true; - err.code = errors.EFETCH; + setFetchCode(err); err.sourceUrl = sourceUrl; fetchRes.emit('error', err); req.abort(); @@ -234,6 +245,15 @@ function nmfetch(url, options) { req.setTimeout(timeout, () => fail(new Error('Request Timeout'))); } req.on('error', (err) => fail(err)); + // a consumer that destroys the response stream before it ended does not want the rest of + // the body, release the request now instead of when the timeout fires + fetchRes.once('close', () => { + if (finished || fetchRes.readableEnded) { + return; + } + finished = true; + req.destroy(); + }); req.on('response', res => { let inflate; if (finished) { diff --git a/node_modules/nodemailer/dist/esm/mailer/index.js b/node_modules/nodemailer/dist/esm/mailer/index.js index 74058d64..eebd3b9f 100644 --- a/node_modules/nodemailer/dist/esm/mailer/index.js +++ b/node_modules/nodemailer/dist/esm/mailer/index.js @@ -119,6 +119,11 @@ class Mail extends EventEmitter { this.getSocket = false; } const mail = new MailMessage(this, data); + // a failed message is not going to be read anymore, release its content streams + const fail = (err) => { + mail.releaseStreams(); + done(err); + }; this.logger.debug({ tnx: 'transport', name: this.transporter.name, @@ -132,7 +137,7 @@ class Mail extends EventEmitter { tnx: 'plugin', action: 'compile' }, 'PluginCompile Error: %s', err.message); - return done(err); + return fail(err); } let recipientCount; try { @@ -149,7 +154,7 @@ class Mail extends EventEmitter { tnx: 'transport', action: 'send' }, 'Compile Error: %s', err.message); - return done(err); + return fail(err); } const maxRecipients = mail.data.maxRecipients === undefined ? DEFAULT_MAX_RECIPIENTS : mail.data.maxRecipients; if (maxRecipients && recipientCount > maxRecipients) { @@ -160,7 +165,7 @@ class Mail extends EventEmitter { tnx: 'transport', action: 'send' }, 'Send Error: %s', err.message); - return done(err); + return fail(err); } this._processPlugins('stream', mail, err => { if (err) { @@ -169,7 +174,7 @@ class Mail extends EventEmitter { tnx: 'plugin', action: 'stream' }, 'PluginStream Error: %s', err.message); - return done(err); + return fail(err); } if (mail.data.dkim || this.dkim) { mail.message.processFunc(input => { @@ -184,6 +189,7 @@ class Mail extends EventEmitter { } this.transporter.send(mail, (...args) => { if (args[0]) { + mail.releaseStreams(); this.logger.error({ err: args[0], tnx: 'transport', @@ -251,6 +257,11 @@ class Mail extends EventEmitter { // setup socket handler for the mailer object this.getSocket = (options, callback) => { const protocol = proxy.protocol.replace(/:$/, '').toLowerCase(); + // The proxy handshake is a part of connecting and draws from the same deadline as the + // SMTP connection that follows it: whatever the proxy takes, the connection does not get + const configuredTimeout = Number(this.options.connectionTimeout) || 0; + const connectStartedAt = Number(options.connectStartedAt) || Date.now(); + const connectionTimeout = configuredTimeout ? Math.max(connectStartedAt + configuredTimeout - Date.now(), 1) : undefined; if (this.meta.has('proxy_handler_' + protocol)) { return this.meta.get('proxy_handler_' + protocol)(proxy, options, callback); } @@ -258,7 +269,7 @@ class Mail extends EventEmitter { // Connect using a HTTP CONNECT method case 'http': case 'https': - httpProxyClient(proxy.href, options.port, options.host, this.options.tls || {}, (err, socket) => { + httpProxyClient(proxy.href, options.port, options.host, connectionTimeout ? Object.assign({}, this.options.tls, { timeout: connectionTimeout }) : this.options.tls || {}, (err, socket) => { if (err) { return callback(err); } @@ -292,6 +303,9 @@ class Mail extends EventEmitter { }, command: 'connect' }; + if (connectionTimeout) { + connectionOpts.timeout = connectionTimeout; + } if (proxy.username || proxy.password) { const username = proxy.username || ''; const password = proxy.password || ''; @@ -321,11 +335,13 @@ class Mail extends EventEmitter { if (net.isIP(proxy.hostname)) { return connect(proxy.hostname); } - return dns.resolve(proxy.hostname, (err, address) => { + // lookup goes through the hosts file and returns an IPv6 address as well, + // the same way a connection to the proxy host would be resolved otherwise + return dns.lookup(proxy.hostname, (err, address) => { if (err) { return callback(err); } - connect(Array.isArray(address) ? address[0] : address); + connect(address); }); } } diff --git a/node_modules/nodemailer/dist/esm/mailer/mail-message.d.ts b/node_modules/nodemailer/dist/esm/mailer/mail-message.d.ts index 6c20de37..8f2973fe 100644 --- a/node_modules/nodemailer/dist/esm/mailer/mail-message.d.ts +++ b/node_modules/nodemailer/dist/esm/mailer/mail-message.d.ts @@ -90,6 +90,11 @@ export default class MailMessage { [key: string]: any; }, key: string | number, options?: ResolveContentOptions | false): Promise; resolveAll(callback: MailMessageDataCallback): void; + /** + * Destroys the content streams of the message once sending failed. A stream that was not + * read to the end would otherwise keep the file or the socket behind it open + */ + releaseStreams(): void; normalize(callback: MailMessageDataCallback): void; setMailerHeader(): void; setPriorityHeaders(): void; diff --git a/node_modules/nodemailer/dist/esm/mailer/mail-message.js b/node_modules/nodemailer/dist/esm/mailer/mail-message.js index 9df7734c..5d039886 100644 --- a/node_modules/nodemailer/dist/esm/mailer/mail-message.js +++ b/node_modules/nodemailer/dist/esm/mailer/mail-message.js @@ -136,6 +136,26 @@ export default class MailMessage { }; setImmediate(() => resolveNext()); } + /** + * Destroys the content streams of the message once sending failed. A stream that was not + * read to the end would otherwise keep the file or the socket behind it open + */ + releaseStreams() { + const data = this.data; + const values = [data.html, data.text, data.watchHtml, data.amp, data.raw, data.icalEvent]; + for (const key of ['attachments', 'alternatives']) { + if (Array.isArray(data[key])) { + values.push(...data[key]); + } + } + for (const value of values) { + if (value && typeof value === 'object') { + shared.destroyStream(value); + shared.destroyStream(value.content); + shared.destroyStream(value.raw); + } + } + } normalize(callback) { const envelope = this.message.getEnvelope(); const messageId = this.message.messageId(); diff --git a/node_modules/nodemailer/dist/esm/mime-node/index.d.ts b/node_modules/nodemailer/dist/esm/mime-node/index.d.ts index 03d7603b..4e2b4131 100644 --- a/node_modules/nodemailer/dist/esm/mime-node/index.d.ts +++ b/node_modules/nodemailer/dist/esm/mime-node/index.d.ts @@ -181,7 +181,7 @@ declare class MimeNode { childNodes: MimeNode[]; /** Filename for this node. Useful with attachments */ filename?: string | undefined; - /** Body content, or the error a content stream emitted before it was read */ + /** Body content */ content?: MimeNodeContent | Error | undefined; /** Lowercase content type, set when the headers are built */ contentType?: string | undefined; diff --git a/node_modules/nodemailer/dist/esm/mime-node/index.js b/node_modules/nodemailer/dist/esm/mime-node/index.js index 40f8eed8..e473a7fd 100644 --- a/node_modules/nodemailer/dist/esm/mime-node/index.js +++ b/node_modules/nodemailer/dist/esm/mime-node/index.js @@ -2,7 +2,7 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import * as punycode from '../punycode/index.js'; -import { PassThrough } from 'node:stream'; +import { PassThrough, finished, pipeline } from 'node:stream'; import * as shared from '../shared/index.js'; import urlModule from 'node:url'; import * as mimeFuncs from '../mime-funcs/index.js'; @@ -33,6 +33,14 @@ const PLAIN_ADDRESS = /^[^\s"(),:;<>@[\\\]]+@[^\s"(),:;<>@[\\\]]+$/; // unroutable garbage into mail for a domain the sender never named. None of these // characters are legal in a domain, so keep them away from the mapper. const URL_PARSER_UNSAFE = /[/\\?#%\x00-\x20\x7F]/; +// pipeline() needs a callback to not throw, the errors it sees reach the last stream anyway +const PIPELINE_NOOP = () => false; +// The error a node streaming into an output that was destroyed stops with +function abortedError() { + const err = new Error('Message stream was closed before the message was generated'); + err.code = errors.ESTREAM; + return err; +} /** * Encodes a domain the way browsers, the WHATWG URL Standard and DNS facing resolvers do, * which is with UTS-46 mapping applied before the Punycode step. @@ -52,9 +60,14 @@ const URL_PARSER_UNSAFE = /[/\\?#%\x00-\x20\x7F]/; */ function normalizeDomain(domain, toUnicode) { // domainToASCII and domainToUnicode landed in Node 7, the bundled codec covers Node 6 - const mapper = toUnicode ? urlModule.domainToUnicode : urlModule.domainToASCII; - if (typeof mapper === 'function' && !URL_PARSER_UNSAFE.test(domain)) { - const mapped = mapper(domain); + if (typeof urlModule.domainToASCII === 'function' && + typeof urlModule.domainToUnicode === 'function' && + !URL_PARSER_UNSAFE.test(domain)) { + // The U-label form is decoded from the A-label form rather than from the input: + // Deno's domainToASCII returns an empty string for a label it cannot decode, while + // its domainToUnicode returns the label with U+FFFD in place of the bad part + const ascii = urlModule.domainToASCII(domain); + const mapped = ascii && toUnicode ? urlModule.domainToUnicode(ascii) : ascii; if (mapped) { return mapped; } @@ -406,11 +419,7 @@ class MimeNode { if (typeof this.content.pipe === 'function') { // pre-stream handler. might be triggered if a stream is set as content // and 'error' fires before anything is done with this stream - this._contentErrorHandler = err => { - this.content.removeListener('error', this._contentErrorHandler); - this.content = err; - }; - this.content.once('error', this._contentErrorHandler); + shared.recordStreamErrors(this.content); } else if (typeof this.content === 'string') { this._isPlainText = mimeFuncs.isPlainText(this.content); @@ -622,40 +631,45 @@ class MimeNode { createReadStream(options) { options = options || {}; const stream = new PassThrough(options); - let outputStream = stream; - let transform; this.stream(stream, options, err => { if (err) { - outputStream.emit('error', err); + stream.destroy(err); return; } stream.end(); }); - for (let i = 0, len = this._transforms.length; i < len; i++) { - transform = - typeof this._transforms[i] === 'function' ? this._transforms[i]() : this._transforms[i]; - outputStream.once('error', err => { - transform.emit('error', err); - }); - outputStream = outputStream.pipe(transform); + // the content streams of the nodes the message did not get to are not going to be read + stream.once('close', () => { + if (!stream.writableFinished) { + this._destroyContentStreams(); + } + }); + // The stages are joined with pipeline, which destroys all of them when any one fails + // or is destroyed. An error anywhere reaches the returned stream exactly once, and a + // consumer that destroys the returned stream stops the tree from reading its sources + const stages = [stream]; + for (const transform of this._transforms) { + stages.push(typeof transform === 'function' ? transform() : transform); } // ensure terminating newline after possible user transforms - transform = new LastNewline(); - outputStream.once('error', err => { - transform.emit('error', err); - }); - outputStream = outputStream.pipe(transform); - // dkim and stuff - for (let i = 0, len = this._processFuncs.length; i < len; i++) { - transform = this._processFuncs[i]; - outputStream = transform(outputStream); + stages.push(new LastNewline()); + let outputStream = pipeline(stages, PIPELINE_NOOP); + // dkim and stuff. A process function reads its input itself and reports the errors of + // that input on its output, so only a consumer abort has to be carried back upstream + for (const processFunc of this._processFuncs) { + const input = outputStream; + outputStream = processFunc(input); + if (outputStream !== input) { + finished(outputStream, err => { + if (err) { + input.destroy(); + } + }); + } } if (this.newline) { const winbreak = ['win', 'windows', 'dos', '\r\n'].includes(this.newline.toString().toLowerCase()); - const newlineTransform = winbreak ? new LeWindows() : new LeUnix(); - const stream = outputStream.pipe(newlineTransform); - outputStream.on('error', err => stream.emit('error', err)); - return stream; + outputStream = pipeline(outputStream, winbreak ? new LeWindows() : new LeUnix(), PIPELINE_NOOP); } return outputStream; } @@ -681,8 +695,10 @@ class MimeNode { } stream(outputStream, options, done) { const transferEncoding = this.getTransferEncoding(); - let contentStream; - let localStream; + // the streams this node is reading from. A consumer that goes away mid-message destroys + // the output, and these are released with it instead of staying paused with a file or a + // socket open behind them + let activeStreams = []; // protect actual callback against multiple triggering let returned = false; const callback = (err) => { @@ -690,13 +706,39 @@ class MimeNode { return; } returned = true; + outputStream.removeListener('close', onOutputClose); done(err); }; + function onOutputClose() { + for (const stream of activeStreams) { + stream.destroy(); + } + return callback(abortedError()); + } + // reads the streams into the output. The listener is only attached while the node reads + // something, a deeply nested tree would otherwise stack one per level on the output + const readInto = (...streams) => { + if (!activeStreams.length) { + outputStream.once('close', onOutputClose); + } + activeStreams = streams; + }; + // stops here if the output was destroyed in the meantime + const aborted = () => { + if (outputStream.destroyed) { + callback(abortedError()); + return true; + } + return false; + }; // for multipart nodes, push child nodes // for content nodes end the stream const finalize = () => { let childId = 0; const processChildNode = () => { + if (aborted()) { + return; + } if (childId >= this.childNodes.length) { outputStream.write('\r\n--' + this.boundary + '--\r\n'); return callback(); @@ -719,89 +761,80 @@ class MimeNode { }; // pushes node content const sendContent = () => { - if (this.content) { - if (Object.prototype.toString.call(this.content) === '[object Error]') { - // content is already errored - return callback(this.content); - } - if (typeof this.content.pipe === 'function') { - this.content.removeListener('error', this._contentErrorHandler); - this._contentErrorHandler = err => callback(err); - this.content.once('error', this._contentErrorHandler); - } - const createStream = () => { - if (['quoted-printable', 'base64'].includes(transferEncoding)) { - contentStream = new (transferEncoding === 'base64' ? base64 : qp).Encoder(options); - contentStream.pipe(outputStream, { - end: false - }); - contentStream.once('end', finalize); - contentStream.once('error', err => callback(err)); - localStream = this._getStream(this.content); - localStream.pipe(contentStream); - } - else { - // anything that is not QP or Base54 passes as-is - localStream = this._getStream(this.content); - localStream.pipe(outputStream, { - end: false - }); - localStream.once('end', finalize); - } - localStream.once('error', err => callback(err)); - }; - if (this.content._resolve) { - const chunks = []; - let chunklen = 0; - let returned = false; - const sourceStream = this._getStream(this.content); - sourceStream.on('error', err => { - if (returned) { - return; - } - returned = true; - callback(err); - }); - sourceStream.on('readable', () => { - let chunk; - while ((chunk = sourceStream.read()) !== null) { - chunks.push(chunk); - chunklen += chunk.length; - } - }); - sourceStream.on('end', () => { - if (returned) { - return; - } - returned = true; - this.content._resolve = false; - this.content._resolvedValue = Buffer.concat(chunks, chunklen); - setImmediate(createStream); - }); - } - else { - setImmediate(createStream); - } + if (aborted()) { return; } - return setImmediate(finalize); + if (!this.content) { + return setImmediate(finalize); + } + const createStream = () => { + if (aborted()) { + return; + } + const contentError = this._takeStreamContent(this.content); + if (contentError) { + return callback(contentError); + } + const localStream = this._getStream(this.content); + if (['quoted-printable', 'base64'].includes(transferEncoding)) { + const contentStream = transferEncoding === 'base64' + ? new base64.Encoder(options) + : // outside of text a lone CR or LF is data, encoding it keeps it from being + // turned into a line break by a newline transform or the receiving side + new qp.Encoder(Object.assign({}, options, { binary: !/^text\//i.test(this.contentType || '') })); + readInto(localStream, contentStream); + contentStream.pipe(outputStream, { + end: false + }); + // pipeline reports errors and tears both streams down. It calls back once the + // encoder took all of its input, the encoded output may still be waiting to + // be read, so the node is only done once the encoder's readable side ended + pipeline(localStream, contentStream, err => err && callback(err)); + finished(contentStream, { writable: false }, err => (err ? callback(err) : finalize())); + } + else { + // anything that is not QP or Base54 passes as-is + readInto(localStream); + localStream.pipe(outputStream, { + end: false + }); + finished(localStream, { writable: false }, err => (err ? callback(err) : finalize())); + } + }; + if (this.content._resolve) { + const sourceStream = this._getStream(this.content); + readInto(sourceStream); + shared.resolveStream(sourceStream, (err, value) => { + if (err) { + return callback(err); + } + if (returned) { + return; + } + this.content._resolve = false; + this.content._resolvedValue = value; + setImmediate(createStream); + }); + } + else { + setImmediate(createStream); + } }; if (this._raw) { setImmediate(() => { - if (Object.prototype.toString.call(this._raw) === '[object Error]') { - // content is already errored - return callback(this._raw); + if (aborted()) { + return; } - // remove default error handler (if set) - if (typeof this._raw.pipe === 'function') { - this._raw.removeListener('error', this._contentErrorHandler); + const rawError = this._takeStreamContent(this._raw); + if (rawError) { + return callback(rawError); } const raw = this._getStream(this._raw); + readInto(raw); raw.pipe(outputStream, { end: false }); - raw.on('error', err => outputStream.emit('error', err)); - raw.on('end', finalize); + finished(raw, { writable: false }, err => (err ? callback(err) : finalize())); }); } else { @@ -917,11 +950,7 @@ class MimeNode { if (this._raw && typeof this._raw.pipe === 'function') { // pre-stream handler. might be triggered if a stream is set as content // and 'error' fires before anything is done with this stream - this._contentErrorHandler = err => { - this._raw.removeListener('error', this._contentErrorHandler); - this._raw = err; - }; - this._raw.once('error', this._contentErrorHandler); + shared.recordStreamErrors(this._raw); } return this; } @@ -947,6 +976,37 @@ class MimeNode { } return false; } + /** + * Destroys the content streams of this node and of every node below it + * + * @internal + */ + _destroyContentStreams() { + shared.destroyStream(this.content); + shared.destroyStream(this._raw); + for (const child of this.childNodes) { + child._destroyContentStreams(); + } + } + /** + * Checks that a content value can still be read before the node streams it. A stream is + * refused once it errored, ended or was destroyed: piping it would either never finish or + * produce an empty body without any error + * + * @param content Node content or raw value + * @returns The error to stop with, or null when the content can be read + * @internal + */ + _takeStreamContent(content) { + if (Object.prototype.toString.call(content) === '[object Error]') { + return content; + } + if (!content || typeof content.pipe !== 'function' || content._resolvedValue) { + // the value of a resolved stream is read from the buffered copy + return null; + } + return shared.unreadableStreamError(content); + } /** * Detects and returns handle to a stream related with the content. * diff --git a/node_modules/nodemailer/dist/esm/nodemailer.js b/node_modules/nodemailer/dist/esm/nodemailer.js index e9ff9fd0..e4047300 100644 --- a/node_modules/nodemailer/dist/esm/nodemailer.js +++ b/node_modules/nodemailer/dist/esm/nodemailer.js @@ -9,10 +9,12 @@ import SESTransport from './ses-transport/index.js'; import * as errors from './errors.js'; import nmfetch from './fetch/index.js'; import * as packageData from './package-info.js'; -const ETHEREAL_API = (process.env.ETHEREAL_API || 'https://api.nodemailer.com').replace(/\/+$/, ''); -const ETHEREAL_WEB = (process.env.ETHEREAL_WEB || 'https://ethereal.email').replace(/\/+$/, ''); -const ETHEREAL_API_KEY = (process.env.ETHEREAL_API_KEY || '').replace(/\s*/g, '') || null; -const ETHEREAL_CACHE = ['true', 'yes', 'y', '1'].includes((process.env.ETHEREAL_CACHE || 'yes').toString().trim().toLowerCase()); +// Read on use rather than at load, so that importing the module does not touch the +// environment (Deno refuses that without --allow-env, even for apps that never use Ethereal) +const etherealApi = () => (process.env.ETHEREAL_API || 'https://api.nodemailer.com').replace(/\/+$/, ''); +const etherealWeb = () => (process.env.ETHEREAL_WEB || 'https://ethereal.email').replace(/\/+$/, ''); +const etherealApiKey = () => (process.env.ETHEREAL_API_KEY || '').replace(/\s*/g, '') || null; +const etherealCache = () => ['true', 'yes', 'y', '1'].includes((process.env.ETHEREAL_CACHE || 'yes').toString().trim().toLowerCase()); let testAccount = false; export function createTransport(transporter, defaults) { let options; @@ -73,11 +75,11 @@ export function createTestAccount(apiUrl, callback) { }); } const done = callback; - if (ETHEREAL_CACHE && testAccount) { + if (etherealCache() && testAccount) { setImmediate(() => done(null, testAccount)); return promise; } - apiUrl = apiUrl || ETHEREAL_API; + apiUrl = apiUrl || etherealApi(); const chunks = []; let chunklen = 0; const requestHeaders = {}; @@ -85,8 +87,9 @@ export function createTestAccount(apiUrl, callback) { requestor: packageData.name, version: packageData.version }; - if (ETHEREAL_API_KEY) { - requestHeaders.Authorization = 'Bearer ' + ETHEREAL_API_KEY; + const apiKey = etherealApiKey(); + if (apiKey) { + requestHeaders.Authorization = 'Bearer ' + apiKey; } const fetchOptions = { contentType: 'application/json', @@ -154,7 +157,7 @@ export function getTestMessageUrl(info) { } } if (infoProps.has('STATUS') && infoProps.has('MSGID')) { - return ((testAccount && testAccount.web) || ETHEREAL_WEB) + '/message/' + infoProps.get('MSGID'); + return ((testAccount && testAccount.web) || etherealWeb()) + '/message/' + infoProps.get('MSGID'); } return false; } diff --git a/node_modules/nodemailer/dist/esm/package-info.d.ts b/node_modules/nodemailer/dist/esm/package-info.d.ts index dea3c135..20d8d73a 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.15"; +export declare const version = "10.1.0"; 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 2793b58a..0ea5259b 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.15'; +export const version = '10.1.0'; export const homepage = 'https://nodemailer.com/'; diff --git a/node_modules/nodemailer/dist/esm/qp/index.d.ts b/node_modules/nodemailer/dist/esm/qp/index.d.ts index 6253dbf6..cee9cc45 100644 --- a/node_modules/nodemailer/dist/esm/qp/index.d.ts +++ b/node_modules/nodemailer/dist/esm/qp/index.d.ts @@ -14,6 +14,8 @@ export declare function wrap(str: string, lineLength?: number): string; export interface QPEncoderOptions { /** Maximum length for lines, set to false to disable wrapping */ lineLength?: number | false | undefined; + /** The input is binary data: a CR or LF that is not part of a CRLF pair is encoded */ + binary?: boolean | undefined; } /** The name @types/nodemailer used for QPEncoderOptions */ export type EncoderOptions = QPEncoderOptions; diff --git a/node_modules/nodemailer/dist/esm/qp/index.js b/node_modules/nodemailer/dist/esm/qp/index.js index 506ce3d7..e062f8c1 100644 --- a/node_modules/nodemailer/dist/esm/qp/index.js +++ b/node_modules/nodemailer/dist/esm/qp/index.js @@ -16,23 +16,52 @@ const QP_RANGES = [ [0x20, 0x3c], // !"#$%&'()*+,-./0123456789:; [0x3e, 0x7e] // >?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|} ]; +// 1 for every byte value that is written as is, see QP_RANGES +const QP_LITERAL = new Uint8Array(256); +for (let i = 0; i < 256; i++) { + QP_LITERAL[i] = checkRanges(i, QP_RANGES) ? 1 : 0; +} +const HEX_DIGITS = Buffer.from('0123456789ABCDEF', 'latin1'); +const isWhitespace = (c) => c === 0x20 || c === 0x09; export function encode(buffer) { - if (typeof buffer === 'string') { - buffer = Buffer.from(buffer, 'utf-8'); - } - let result = ''; - let ord; - for (let i = 0, len = buffer.length; i < len; i++) { - ord = buffer[i]; + return encodeBytes(typeof buffer === 'string' ? Buffer.from(buffer, 'utf-8') : buffer); +} +/** + * Encodes bytes that may be followed by more input + * + * @param buffer Bytes to encode + * @param [next] The byte that follows the buffer, whitespace before it is kept literal unless it + * is a line break. Without it the buffer ends the input and its trailing whitespace is encoded + * @param [binary] Keep only CRLF pairs literal. A lone CR or LF is data, not a line break, and + * anything that rewrites line endings on the way would change it + * @param [previous] The byte before the buffer, for a buffer that starts with LF + * @returns Quoted-Printable encoded string + */ +function encodeBytes(buffer, next, binary, previous) { + const len = buffer.length; + // every byte takes three characters at most + const output = Buffer.allocUnsafe(len * 3); + let pos = 0; + for (let i = 0; i < len; i++) { + const ord = buffer[i]; + const following = i + 1 < len ? buffer[i + 1] : next; + const lineBreakByte = binary && (ord === 0x0d || ord === 0x0a) + ? ord === 0x0d + ? following === 0x0a + : (i > 0 ? buffer[i - 1] : previous) === 0x0d + : true; // if the char is in allowed range, then keep as is, unless it is a WS in the end of a line - if (checkRanges(ord, QP_RANGES) && - !((ord === 0x20 || ord === 0x09) && (i === len - 1 || buffer[i + 1] === 0x0a || buffer[i + 1] === 0x0d))) { - result += String.fromCharCode(ord); + if (QP_LITERAL[ord] && + lineBreakByte && + !(isWhitespace(ord) && (following === undefined || following === 0x0a || following === 0x0d))) { + output[pos++] = ord; continue; } - result += '=' + (ord < 0x10 ? '0' : '') + ord.toString(16).toUpperCase(); + output[pos++] = 0x3d; // = + output[pos++] = HEX_DIGITS[Math.floor(ord / 16)]; + output[pos++] = HEX_DIGITS[ord % 16]; } - return result; + return output.toString('latin1', 0, pos); } /** * Adds soft line breaks to a Quoted-Printable string @@ -143,6 +172,10 @@ function checkRanges(nr, ranges) { } return false; } +// Input is encoded and wrapped this many bytes at a time. wrap() works on strings, and a large +// chunk handed over at once, such as a whole Buffer attachment, would be held several times over +// as intermediate strings +const ENCODE_SLICE_SIZE = 64 * 1024; /** * Creates a transform stream for encoding data to Quoted-Printable encoding * @@ -158,44 +191,81 @@ export class Encoder extends Transform { this.options.lineLength = this.options.lineLength || 76; } this._curLine = ''; + this._remainingBytes = false; + this._lastByte = undefined; this.inputBytes = 0; this.outputBytes = 0; } /** @internal */ _transform(chunk, encoding, done) { - let qp; - if (encoding !== 'buffer') { - chunk = Buffer.from(chunk, encoding); - } - if (!chunk || !chunk.length) { + let buf = encoding !== 'buffer' ? Buffer.from(chunk, encoding) : chunk; + if (!buf || !buf.length) { return done(); } - this.inputBytes += chunk.length; - if (this.options.lineLength) { - qp = this._curLine + encode(chunk); - qp = wrap(qp, this.options.lineLength); - qp = qp.replace(/(^|\n)([^\n]*)$/, (match, lineBreak, lastLine) => { - this._curLine = lastLine; - return lineBreak; - }); - if (qp) { - this.outputBytes += qp.length; - this.push(qp); - } + this.inputBytes += buf.length; + if (this._remainingBytes) { + buf = Buffer.concat([this._remainingBytes, buf], this._remainingBytes.length + buf.length); + this._remainingBytes = false; } - else { - qp = encode(chunk); - this.outputBytes += qp.length; - this.push(qp, 'ascii'); + // Whitespace is encoded when it ends a line, and the end of the input counts as one. Hold + // back the whitespace a chunk ends with until it is known what follows it, so the output + // does not depend on where the input was split + let end = buf.length; + // a binary CR can only be written once it is known whether LF follows + while (end > 0 && (isWhitespace(buf[end - 1]) || (this.options.binary && buf[end - 1] === 0x0d))) { + end--; } + if (buf.length - end <= ENCODE_SLICE_SIZE) { + this._remainingBytes = end < buf.length ? Buffer.from(buf.subarray(end)) : false; + buf = buf.subarray(0, end); + } + this._encodeSlices(buf); done(); } /** @internal */ _flush(done) { + if (this._remainingBytes) { + this._encodeSlices(this._remainingBytes); + this._remainingBytes = false; + } if (this._curLine) { this.outputBytes += this._curLine.length; this.push(this._curLine, 'ascii'); + this._curLine = ''; } done(); } + /** + * Encodes the input in slices of ENCODE_SLICE_SIZE bytes. Each slice is told the byte that + * follows it, so a slice that ends in whitespace is encoded as if it was not split + * + * @internal + */ + _encodeSlices(buf) { + for (let start = 0; start < buf.length; start += ENCODE_SLICE_SIZE) { + const end = Math.min(start + ENCODE_SLICE_SIZE, buf.length); + this._encodeSlice(buf.subarray(start, end), end < buf.length ? buf[end] : undefined); + this._lastByte = buf[end - 1]; + } + } + /** @internal */ + _encodeSlice(buf, next) { + let qp; + if (this.options.lineLength) { + qp = wrap(this._curLine + encodeBytes(buf, next, this.options.binary, this._lastByte), this.options.lineLength); + // the last line is kept until it is known whether it needs a soft break + const lastLF = qp.lastIndexOf('\n'); + this._curLine = qp.substring(lastLF + 1); + qp = qp.substring(0, lastLF + 1); + if (qp) { + this.outputBytes += qp.length; + this.push(qp, 'ascii'); + } + } + else { + qp = encodeBytes(buf, next, this.options.binary, this._lastByte); + this.outputBytes += qp.length; + this.push(qp, 'ascii'); + } + } } diff --git a/node_modules/nodemailer/dist/esm/sendmail-transport/index.js b/node_modules/nodemailer/dist/esm/sendmail-transport/index.js index a90e505b..61c77593 100644 --- a/node_modules/nodemailer/dist/esm/sendmail-transport/index.js +++ b/node_modules/nodemailer/dist/esm/sendmail-transport/index.js @@ -1,9 +1,12 @@ import { spawn } from 'node:child_process'; +import { pipeline } from 'node:stream'; import * as packageData from '../package-info.js'; import * as shared from '../shared/index.js'; import * as errors from '../errors.js'; import LeWindows from '../mime-node/le-windows.js'; import LeUnix from '../mime-node/le-unix.js'; +// how long an stdin error waits for the exit code of the process before it is reported +const STDIN_ERROR_EXIT_WAIT = 1000; /** * Generates a Transport object for Sendmail * @@ -101,37 +104,75 @@ class SendmailTransport { return callback(E); } if (sendmail) { + let stream; + // ended once the whole message was handed to stdin + const messageWritten = () => !!stream && stream.readableEnded; + // an EPIPE says less than the exit code that usually follows it, so it is only + // reported when the process exits without one + let stdinError = null; + // releases whatever the message is still being read from + const release = () => { + if (stream && !messageWritten()) { + stream.destroy(); + } + }; + const fail = (err) => { + release(); + callback(err); + }; sendmail.on('error', err => { this.logger.error({ err, tnx: 'spawn', messageId }, 'Error occurred when sending message %s. %s', messageId, err.message); - callback(err); + fail(err); }); - sendmail.once('exit', code => { - if (!code) { + // 'close' follows 'exit' with the same arguments, it is only listened to in case the + // process ends without an 'exit' event + const onExit = (code, signal) => { + let err = null; + if (code) { + err = new Error(code === 127 ? 'Sendmail command not found, process exited with code ' + code : 'Sendmail exited with code ' + code); + } + else if (signal) { + err = new Error('Sendmail was terminated by ' + signal); + } + else if (!messageWritten()) { + // exiting with 0 before the message was written does not mean it was queued + err = new Error('Sendmail exited before the message was written'); + } + else if (stdinError) { + err = stdinError; + } + if (!err) { return callback(); } - const err = new Error(code === 127 ? 'Sendmail command not found, process exited with code ' + code : 'Sendmail exited with code ' + code); - err.code = errors.ESENDMAIL; + err.code = err.code || errors.ESENDMAIL; this.logger.error({ err, tnx: 'stdin', messageId }, 'Error sending message %s to sendmail. %s', messageId, err.message); - callback(err); - }); - // the close listener is handed the exit code as its first argument, so a non-zero - // code reaching it before the exit listener did counts as the error value - sendmail.once('close', callback); + fail(err); + }; + sendmail.once('exit', onExit); + sendmail.once('close', onExit); sendmail.stdin.on('error', err => { this.logger.error({ err, tnx: 'stdin', messageId }, 'Error occurred when piping message %s to sendmail. %s', messageId, err.message); - callback(err); + if (stdinError) { + return; + } + stdinError = err; + release(); + // a process that closed its stdin normally exits right after, with a code that + // tells more than the EPIPE. Report the EPIPE if it does not + const exitTimer = setTimeout(() => fail(err), STDIN_ERROR_EXIT_WAIT); + sendmail.once('exit', () => clearTimeout(exitTimer)); }); const recipients = [].concat(envelope.to || []); if (recipients.length > 3) { @@ -142,12 +183,11 @@ class SendmailTransport { messageId }, 'Sending message %s to <%s>', messageId, recipients.join(', ')); const sourceStream = mail.message.createReadStream(); - let stream = sourceStream; + stream = sourceStream; if (this.options.newline) { // apply the transport-level line ending transform; the message-level // `newline` option is handled by MimeNode in createReadStream() - stream = sourceStream.pipe(this.winbreak ? new LeWindows() : new LeUnix()); - sourceStream.once('error', err => stream.emit('error', err)); + stream = pipeline(sourceStream, this.winbreak ? new LeWindows() : new LeUnix(), () => false); } stream.once('error', err => { this.logger.error({ diff --git a/node_modules/nodemailer/dist/esm/shared/index.d.ts b/node_modules/nodemailer/dist/esm/shared/index.d.ts index 805783ef..8c7fff18 100644 --- a/node_modules/nodemailer/dist/esm/shared/index.d.ts +++ b/node_modules/nodemailer/dist/esm/shared/index.d.ts @@ -1,6 +1,7 @@ +import type { NodemailerError } from '../errors.js'; import { isProtoKey, copyOwnKeys } from './objects.js'; import os from 'node:os'; -import type { Readable } from 'node:stream'; +import { type Readable } from 'node:stream'; import type { OutgoingHttpHeaders } from 'node:http'; export { isProtoKey, copyOwnKeys }; /** @@ -258,3 +259,33 @@ export declare const assign: (...args: ({ [key: string]: any; }; export declare const encodeXText: (str: string) => string; +/** + * Keeps the first error a content stream emits before it is read, so the error is reported + * when the stream is read instead of being thrown as unhandled. The listener stays attached + * for good, a stream that emits 'error' more than once never throws the later ones either + * + * @param stream Readable stream + */ +export declare function recordStreamErrors(stream: Readable): void; +/** + * Destroys a value if it is a readable stream that was not destroyed yet + * + * @param value Any content value + */ +export declare function destroyStream(value: unknown): void; +/** + * Tells why a stream can not be read from start to end anymore. A stream that ended or was + * destroyed before anyone read it would never emit 'end' to a new reader, or, when piped, would + * end the destination right away and turn into an empty value without any error + * + * @param stream Readable stream + * @returns The error the stream failed with, an ESTREAM error, or null when it can be read + */ +export declare function unreadableStreamError(stream: Readable): NodemailerError | null; +/** + * Streams a stream value into a Buffer + * + * @param stream Readable stream + * @param callback Callback function with (err, value) + */ +export declare function resolveStream(stream: Readable, callback: (err: Error | null, value?: Buffer) => void): void; diff --git a/node_modules/nodemailer/dist/esm/shared/index.js b/node_modules/nodemailer/dist/esm/shared/index.js index 95ef4030..507dfaee 100644 --- a/node_modules/nodemailer/dist/esm/shared/index.js +++ b/node_modules/nodemailer/dist/esm/shared/index.js @@ -8,6 +8,7 @@ import { isProtoKey, copyOwnKeys } from './objects.js'; import dns from 'node:dns'; import net from 'node:net'; import os from 'node:os'; +import { finished } from 'node:stream'; // re-exported for the callers that already depend on this module, see ./objects export { isProtoKey, copyOwnKeys }; const DNS_TTL = 5 * 60 * 1000; @@ -21,14 +22,24 @@ export const _resetCacheCleanup = () => { lastCacheCleanup = 0; }; export let networkInterfaces; -try { - networkInterfaces = os.networkInterfaces(); -} -catch (_err) { - // fails on some systems -} +let networkInterfacesRead = false; +// Read on first use rather than at load, so that importing the module does not ask for +// the interface table (Deno prompts for --allow-sys on it) +/** @internal */ +export const _readNetworkInterfaces = () => { + if (!networkInterfacesRead) { + networkInterfacesRead = true; + try { + networkInterfaces = os.networkInterfaces(); + } + catch (_err) { + // fails on some systems + } + } + return networkInterfaces; +}; const isFamilySupported = (family, allowInternal) => { - const addresses = Object.values(networkInterfaces || {}).flat(); + const addresses = Object.values(_readNetworkInterfaces() || {}).flat(); if (!addresses.length) { // hope for the best. Runtimes without an interface table (Cloudflare // Workers) report an empty object rather than throwing @@ -39,7 +50,8 @@ const isFamilySupported = (family, allowInternal) => { const resolve = (family, hostname, options, callback) => { options = options || {}; if (!isFamilySupported(family, options.allowInternalNetworkInterfaces)) { - return callback(null, []); + callback(null, []); + return null; } const dnsResolver = dns.Resolver ? new dns.Resolver(options) : dns; dnsResolver['resolve' + family](hostname, (err, addresses) => { @@ -58,6 +70,7 @@ const resolve = (family, hostname, options, callback) => { } return callback(null, Array.isArray(addresses) ? addresses : [].concat(addresses || [])); }); + return dns.Resolver ? dnsResolver : null; }; export const dnsCache = new Map(); const formatDNSValue = (value, extra) => { @@ -121,115 +134,139 @@ export const resolveHostname = (options, callback) => { })); } } - // Resolve both IPv4 and IPv6 addresses for fallback support + // The timeout limits the lookup as a whole. It is also handed to the resolver, but there + // it applies to every query attempt on its own, and the resolver retries a few times + let responded = false; + let deadline; + const respond = (err, result) => { + if (responded) { + return; + } + responded = true; + clearTimeout(deadline); + callback(err, result); + }; + // a stale cached value is still better than no value at all + const respondCached = (error) => { + if (!cached) { + return false; + } + dnsCache.set(host, { + value: cached.value, + expires: Date.now() + (options.dnsTtl || DNS_TTL) + }); + respond(null, formatDNSValue(cached.value, { + servername, + cached: true, + error + })); + return true; + }; + const resolvers = []; + const timeout = Number(options.timeout) || 0; + if (timeout > 0) { + deadline = setTimeout(() => { + for (const resolver of resolvers) { + if (typeof resolver.cancel === 'function') { + resolver.cancel(); + } + } + const err = new Error('DNS lookup for ' + host + ' timed out'); + err.code = dns.TIMEOUT; + if (!respondCached(err)) { + respond(err); + } + }, timeout); + } + // Resolve both IPv4 and IPv6 addresses for fallback support, at the same time let ipv4Addresses = []; let ipv6Addresses = []; let ipv4Error = null; let ipv6Error = null; - resolve(4, options.host, options, (err, addresses) => { - if (err) { - ipv4Error = err; + let pending = 2; + const onResolved = () => { + if (--pending || responded) { + return; } - else { - ipv4Addresses = addresses || []; + // Combine addresses: IPv4 first, then IPv6 + const allAddresses = ipv4Addresses.concat(ipv6Addresses); + if (allAddresses.length) { + const value = { + addresses: allAddresses + }; + dnsCache.set(host, { + value, + expires: Date.now() + (options.dnsTtl || DNS_TTL) + }); + return respond(null, formatDNSValue(value, { + servername, + cached: false + })); } - resolve(6, host, options, (err, addresses) => { - if (err) { - ipv6Error = err; - } - else { - ipv6Addresses = addresses || []; - } - // Combine addresses: IPv4 first, then IPv6 - const allAddresses = ipv4Addresses.concat(ipv6Addresses); - if (allAddresses.length) { + // No addresses from resolve4/resolve6, try dns.lookup as fallback + if (ipv4Error && ipv6Error && respondCached(ipv4Error)) { + // Both resolvers had errors + return; + } + try { + dns.lookup(host, { all: true }, (err, addresses) => { + if (err) { + if (respondCached(err)) { + return; + } + return respond(err); + } + // Get all supported addresses from dns.lookup + const supportedAddresses = addresses + ? addresses.filter(addr => isFamilySupported(addr.family)).map(addr => addr.address) + : []; + if (addresses && addresses.length && !supportedAddresses.length) { + // there are addresses but none can be used + console.warn(`Failed to resolve IPv${addresses[0].family} addresses with current network`); + } + if (!supportedAddresses.length && cached) { + // nothing was found, fallback to cached value + return respond(null, formatDNSValue(cached.value, { + servername, + cached: true + })); + } const value = { - addresses: allAddresses + addresses: supportedAddresses.length ? supportedAddresses : [host] }; dnsCache.set(host, { value, expires: Date.now() + (options.dnsTtl || DNS_TTL) }); - return callback(null, formatDNSValue(value, { + return respond(null, formatDNSValue(value, { servername, cached: false })); + }); + } + catch (lookupErr) { + if (respondCached(lookupErr)) { + return; } - // No addresses from resolve4/resolve6, try dns.lookup as fallback - if (ipv4Error && ipv6Error) { - // Both resolvers had errors - if (cached) { - dnsCache.set(host, { - value: cached.value, - expires: Date.now() + (options.dnsTtl || DNS_TTL) - }); - return callback(null, formatDNSValue(cached.value, { - servername, - cached: true, - error: ipv4Error - })); - } + return respond(ipv4Error || ipv6Error || lookupErr); + } + }; + for (const family of [4, 6]) { + const resolver = resolve(family, host, options, (err, addresses) => { + if (family === 4) { + ipv4Error = err; + ipv4Addresses = addresses || []; } - try { - dns.lookup(host, { all: true }, (err, addresses) => { - if (err) { - if (cached) { - dnsCache.set(host, { - value: cached.value, - expires: Date.now() + (options.dnsTtl || DNS_TTL) - }); - return callback(null, formatDNSValue(cached.value, { - servername, - cached: true, - error: err - })); - } - return callback(err); - } - // Get all supported addresses from dns.lookup - const supportedAddresses = addresses - ? addresses.filter(addr => isFamilySupported(addr.family)).map(addr => addr.address) - : []; - if (addresses && addresses.length && !supportedAddresses.length) { - // there are addresses but none can be used - console.warn(`Failed to resolve IPv${addresses[0].family} addresses with current network`); - } - if (!supportedAddresses.length && cached) { - // nothing was found, fallback to cached value - return callback(null, formatDNSValue(cached.value, { - servername, - cached: true - })); - } - const value = { - addresses: supportedAddresses.length ? supportedAddresses : [host] - }; - dnsCache.set(host, { - value, - expires: Date.now() + (options.dnsTtl || DNS_TTL) - }); - return callback(null, formatDNSValue(value, { - servername, - cached: false - })); - }); - } - catch (lookupErr) { - if (cached) { - dnsCache.set(host, { - value: cached.value, - expires: Date.now() + (options.dnsTtl || DNS_TTL) - }); - return callback(null, formatDNSValue(cached.value, { - servername, - cached: true, - error: lookupErr - })); - } - return callback(ipv4Error || ipv6Error || lookupErr); + else { + ipv6Error = err; + ipv6Addresses = addresses || []; } + onResolved(); }); - }); + if (resolver) { + resolvers.push(resolver); + } + } }; /** * Parses connection url to a structured configuration object @@ -548,43 +585,98 @@ export const encodeXText = (str) => { } return result; }; +// The first error a stream emitted, see recordStreamErrors +const streamErrors = new WeakMap(); +/** + * Keeps the first error a content stream emits before it is read, so the error is reported + * when the stream is read instead of being thrown as unhandled. The listener stays attached + * for good, a stream that emits 'error' more than once never throws the later ones either + * + * @param stream Readable stream + */ +export function recordStreamErrors(stream) { + stream.on('error', err => { + if (!streamErrors.has(stream)) { + streamErrors.set(stream, err); + } + }); +} +/** + * Destroys a value if it is a readable stream that was not destroyed yet + * + * @param value Any content value + */ +export function destroyStream(value) { + const stream = value; + if (stream && typeof stream.pipe === 'function' && typeof stream.destroy === 'function' && !stream.destroyed) { + stream.destroy(); + } +} +/** + * Tells why a stream can not be read from start to end anymore. A stream that ended or was + * destroyed before anyone read it would never emit 'end' to a new reader, or, when piped, would + * end the destination right away and turn into an empty value without any error + * + * @param stream Readable stream + * @returns The error the stream failed with, an ESTREAM error, or null when it can be read + */ +export function unreadableStreamError(stream) { + const err = streamErrors.get(stream) || stream.errored; + if (err) { + return err; + } + if (stream.readableEnded || stream.destroyed) { + const unreadable = new Error('Content stream was already read or destroyed'); + unreadable.code = errors.ESTREAM; + return unreadable; + } + return null; +} /** * Streams a stream value into a Buffer * * @param stream Readable stream * @param callback Callback function with (err, value) */ -function resolveStream(stream, callback) { +export function resolveStream(stream, callback) { let responded = false; + const respond = (err, value) => { + if (responded) { + return; + } + responded = true; + callback(err, value); + }; + const unreadable = unreadableStreamError(stream); + if (unreadable) { + // absorbs a later 'error' from the stream, there is nobody left to report it to + stream.on('error', () => false); + setImmediate(() => respond(unreadable)); + return; + } const chunks = []; let chunklen = 0; - stream.on('error', err => { - if (responded) { - return; + stream.on('data', (chunk) => { + if (typeof chunk === 'string') { + chunk = Buffer.from(chunk); } - responded = true; - callback(err); + chunks.push(chunk); + chunklen += chunk.length; }); - stream.on('readable', () => { - let chunk; - while ((chunk = stream.read()) !== null) { - chunks.push(chunk); - chunklen += chunk.length; + // finished() also reports a stream that is destroyed before 'end', which would otherwise + // leave the callback waiting forever + finished(stream, { writable: false }, err => { + if (err) { + return respond(err); } - }); - stream.on('end', () => { - if (responded) { - return; - } - responded = true; let value; try { value = Buffer.concat(chunks, chunklen); } catch (E) { - return callback(E); + return respond(E); } - callback(null, value); + respond(null, value); }); } /** diff --git a/node_modules/nodemailer/dist/esm/smtp-connection/http-proxy-client.d.ts b/node_modules/nodemailer/dist/esm/smtp-connection/http-proxy-client.d.ts index 934d5cbd..311d2265 100644 --- a/node_modules/nodemailer/dist/esm/smtp-connection/http-proxy-client.d.ts +++ b/node_modules/nodemailer/dist/esm/smtp-connection/http-proxy-client.d.ts @@ -9,6 +9,8 @@ import type { Callback } from '../errors.js'; export interface HttpProxyClientOptions { /** Set to false to accept a proxy certificate that fails validation (e.g. self-signed) */ rejectUnauthorized?: boolean | undefined; + /** Time in milliseconds the CONNECT handshake may take, defaults to httpProxyClient.timeout or 30 seconds */ + timeout?: number | undefined; } /** * Receives the proxied socket once the CONNECT handshake has succeeded, or the error that prevented it @@ -38,7 +40,7 @@ declare function httpProxyClient(proxyUrl: string, destinationPort: number | str */ declare function httpProxyClient(proxyUrl: string, destinationPort: number | string, destinationHost: string, tlsOptions: HttpProxyClientOptions | undefined, callback: HttpProxyClientCallback): void; /** - * Socket timeout in milliseconds while the CONNECT handshake is in progress, defaults to 30 seconds. + * Time in milliseconds the CONNECT handshake may take when the call does not set one, defaults to 30 seconds. * Settable on the function itself, the same way the CommonJS module exposed it. */ declare namespace httpProxyClient { diff --git a/node_modules/nodemailer/dist/esm/smtp-connection/http-proxy-client.js b/node_modules/nodemailer/dist/esm/smtp-connection/http-proxy-client.js index 023b3fcc..750f7946 100644 --- a/node_modules/nodemailer/dist/esm/smtp-connection/http-proxy-client.js +++ b/node_modules/nodemailer/dist/esm/smtp-connection/http-proxy-client.js @@ -8,6 +8,8 @@ import * as errors from '../errors.js'; // Cap the CONNECT response we buffer before the header terminator, so a proxy that // never sends \r\n\r\n cannot grow memory unboundedly before the socket times out. const MAX_RESPONSE_HEADER_BYTES = 64 * 1024; +// URL hostnames keep the brackets around an IPv6 literal, socket options and net.isIPv6 take it without +const unbracket = (host) => typeof host === 'string' && host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host; function httpProxyClient(proxyUrl, destinationPort, destinationHost, tlsOptions, callback) { if (typeof tlsOptions === 'function') { callback = tlsOptions; @@ -27,6 +29,8 @@ function httpProxyClient(proxyUrl, destinationPort, destinationHost, tlsOptions, return; } const proxy = urllib.parse(proxyUrl); + // the CONNECT request line and the Host header take an IPv6 destination in brackets + const authority = (net.isIPv6(unbracket(destinationHost)) ? '[' + unbracket(destinationHost) + ']' : destinationHost) + ':' + destinationPort; const connectOptions = { host: proxy.hostname, port: Number(proxy.port) ? Number(proxy.port) : proxy.protocol === 'https:' ? 443 : 80 @@ -42,15 +46,27 @@ function httpProxyClient(proxyUrl, destinationPort, destinationHost, tlsOptions, else { connect = net.connect.bind(net); } + // The handshake is bounded as a whole, a proxy that keeps sending a byte now and then can + // not hold the connection open past it + const timeout = Number(tlsOptions.timeout) || httpProxyClient.timeout || 30 * 1000; let socket; - // Error harness for initial connection. Once connection is established, the responsibility - // to handle errors is passed to whoever uses this socket + // Single settlement path for the handshake: every temporary listener and the timer are + // dropped exactly once. Once the tunnel is up, the responsibility to handle errors is passed + // to whoever uses this socket let finished = false; - const tempSocketErr = (err) => { + let timer; + const cleanup = () => { + clearTimeout(timer); + socket.removeListener('data', onSocketData); + socket.removeListener('error', fail); + socket.removeListener('close', onEarlyClose); + }; + function fail(err) { if (finished) { return; } finished = true; + cleanup(); try { socket.destroy(); } @@ -58,18 +74,72 @@ function httpProxyClient(proxyUrl, destinationPort, destinationHost, tlsOptions, // ignore } done(err); - }; - const timeoutErr = () => { - const err = new Error('Proxy socket timed out'); - err.code = 'ETIMEDOUT'; - tempSocketErr(err); - }; + } + function onEarlyClose() { + const err = new Error('Proxy closed the connection before the tunnel was established'); + err.code = errors.EPROXY; + fail(err); + } + // The response is collected as chunks and only the bytes that just arrived, together + // with the three before them, are searched for the end of the headers. Appending to a + // string and searching all of it again re-read the whole response on every chunk. + const chunks = []; + let received = 0; + let tail = ''; + function onSocketData(chunk) { + if (finished) { + return; + } + const window = tail + chunk.toString('binary'); + const windowEnd = window.indexOf('\r\n\r\n'); + chunks.push(chunk); + received += chunk.length; + tail = window.slice(-3); + if (windowEnd < 0) { + if (received > MAX_RESPONSE_HEADER_BYTES) { + const err = new Error('Proxy response headers too large'); + err.code = errors.EPROXY; + fail(err); + } + return; + } + // Stop reading before anything is put back. A socket that keeps flowing would emit the + // bytes after the headers, a greeting the proxy sent together with its own response, + // before the next owner of the socket has a listener for them + socket.removeListener('data', onSocketData); + socket.pause(); + const headerEnd = received - window.length + windowEnd; + const response = Buffer.concat(chunks, received); + if (response.length > headerEnd + 4) { + socket.unshift(response.subarray(headerEnd + 4)); + } + // check response code + const match = response.toString('binary', 0, headerEnd).match(/^HTTP\/\d+\.\d+ (\d+)/i); + if (!match || (match[1] || '').charAt(0) !== '2') { + const err = new Error('Invalid response from proxy' + ((match && ': ' + match[1]) || '')); + err.code = errors.EPROXY; + return fail(err); + } + // proxy connection is now established + finished = true; + cleanup(); + // A fresh socket starts flowing once something listens for 'data', a paused one would + // not. Keep that behaviour for the next owner of the socket + const resumeOnData = (event) => { + if (event === 'data') { + socket.removeListener('newListener', resumeOnData); + socket.resume(); + } + }; + socket.on('newListener', resumeOnData); + return done(null, socket); + } socket = connect(connectOptions, () => { if (finished) { return; } const reqHeaders = { - Host: destinationHost + ':' + destinationPort, + Host: authority, Connection: 'close' }; if (proxy.auth) { @@ -78,9 +148,7 @@ function httpProxyClient(proxyUrl, destinationPort, destinationHost, tlsOptions, socket.write( // HTTP method 'CONNECT ' + - destinationHost + - ':' + - destinationPort + + authority + ' HTTP/1.1\r\n' + // HTTP request headers Object.keys(reqHeaders) @@ -88,62 +156,14 @@ function httpProxyClient(proxyUrl, destinationPort, destinationHost, tlsOptions, .join('\r\n') + // End request '\r\n\r\n'); - // The response is collected as chunks and only the bytes that just arrived, together - // with the three before them, are searched for the end of the headers. Appending to a - // string and searching all of it again re-read the whole response on every chunk. - const chunks = []; - let received = 0; - let tail = ''; - const onSocketData = (chunk) => { - let match; - if (finished) { - return; - } - const window = tail + chunk.toString('binary'); - const windowEnd = window.indexOf('\r\n\r\n'); - chunks.push(chunk); - received += chunk.length; - tail = window.slice(-3); - if (windowEnd >= 0) { - socket.removeListener('data', onSocketData); - const headerEnd = received - window.length + windowEnd; - const response = Buffer.concat(chunks, received).toString('binary'); - const headers = response.substr(0, headerEnd); - const remainder = response.substr(headerEnd + 4); - if (remainder) { - socket.unshift(Buffer.from(remainder, 'binary')); - } - // proxy connection is now established - finished = true; - // check response code - match = headers.match(/^HTTP\/\d+\.\d+ (\d+)/i); - if (!match || (match[1] || '').charAt(0) !== '2') { - try { - socket.destroy(); - } - catch (_E) { - // ignore - } - const err = new Error('Invalid response from proxy' + ((match && ': ' + match[1]) || '')); - err.code = errors.EPROXY; - return done(err); - } - socket.removeListener('error', tempSocketErr); - socket.removeListener('timeout', timeoutErr); - socket.setTimeout(0); - return done(null, socket); - } - if (received > MAX_RESPONSE_HEADER_BYTES) { - socket.removeListener('data', onSocketData); - const err = new Error('Proxy response headers too large'); - err.code = errors.EPROXY; - return tempSocketErr(err); - } - }; socket.on('data', onSocketData); }); - socket.setTimeout(httpProxyClient.timeout || 30 * 1000); - socket.on('timeout', timeoutErr); - socket.once('error', tempSocketErr); + timer = setTimeout(() => { + const err = new Error('Proxy socket timed out'); + err.code = errors.ETIMEDOUT; + fail(err); + }, timeout); + socket.once('error', fail); + socket.once('close', onEarlyClose); } export default httpProxyClient; 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 a14a5903..da692c86 100644 --- a/node_modules/nodemailer/dist/esm/smtp-connection/index.d.ts +++ b/node_modules/nodemailer/dist/esm/smtp-connection/index.d.ts @@ -3,7 +3,7 @@ import net from 'node:net'; import tls from 'node:tls'; import { type Readable } from 'node:stream'; import * as shared from '../shared/index.js'; -import type { Callback, NodemailerError } from '../errors.js'; +import { type Callback, type NodemailerError } from '../errors.js'; import type XOAuth2 from '../xoauth2/index.js'; import type { XOAuth2Options } from '../xoauth2/index.js'; /** @@ -275,6 +275,12 @@ export interface SMTPConnectionConnectOptions extends tls.ConnectionOptions { allowInternalNetworkInterfaces?: boolean | undefined; /** DNS lookup timeout in ms */ timeout?: number | undefined; + /** Try the resolved addresses in turn, see net.connect */ + autoSelectFamily?: boolean | undefined; + /** Time in ms an address gets before the next one is tried, see net.connect */ + autoSelectFamilyAttemptTimeout?: number | undefined; + /** Hands the resolved addresses to net.connect */ + lookup?: net.LookupFunction | undefined; } /** * A queued handler for the next server response diff --git a/node_modules/nodemailer/dist/esm/smtp-connection/index.js b/node_modules/nodemailer/dist/esm/smtp-connection/index.js index 259eedf7..5c98c6ef 100644 --- a/node_modules/nodemailer/dist/esm/smtp-connection/index.js +++ b/node_modules/nodemailer/dist/esm/smtp-connection/index.js @@ -7,12 +7,30 @@ import crypto from 'node:crypto'; import DataStream from './data-stream.js'; import { PassThrough } from 'node:stream'; import * as shared from '../shared/index.js'; +import { ERR_ACCESS_DENIED, isTransientError } from '../errors.js'; // default timeout values in ms const CONNECTION_TIMEOUT = 2 * 60 * 1000; // how much to wait for the connection to be established const SOCKET_TIMEOUT = 10 * 60 * 1000; // how much to wait for socket inactivity before disconnecting the client const GREETING_TIMEOUT = 30 * 1000; // how much to wait after connection is established but SMTP greeting is not receieved const DNS_TIMEOUT = 30 * 1000; // how much to wait for resolveHostname +const CLOSE_TIMEOUT = 5 * 1000; // how much to wait for the server to close its side after we closed ours +const KEEPALIVE_DELAY = 30 * 1000; // idle time before TCP keepalive probes start, keeps NAT mappings of idle connections alive const TEARDOWN_NOOP = () => { }; // reusable no-op handler for absorbing errors during socket teardown +// Random order, so that connections spread over the addresses of a host +function shuffle(list) { + const result = list.slice(); + for (let i = result.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [result[i], result[j]] = [result[j], result[i]]; + } + return result; +} +// Every timeout is reported with the ETIMEDOUT code, timeoutType tells which one it was +function timeoutError(message, timeoutType) { + const err = new Error(message); + err.timeoutType = timeoutType; + return err; +} // how many bytes a single server response may occupy while it is still being received. // Generous compared to any real reply, it only stops a peer that never completes one const MAX_RESPONSE_SIZE = 1024 * 1024; @@ -135,8 +153,8 @@ class SMTPConnection extends EventEmitter { this._maxAllowedSize = 0; this._responseActions = []; this._recipientQueue = []; - this._greetingTimeout = false; - this._connectionTimeout = false; + this._phaseTimer = false; + this._plaintextEhlo = false; this._destroyed = false; this._closing = false; this._currentDataStream = false; @@ -147,14 +165,25 @@ class SMTPConnection extends EventEmitter { this._onSocketClose = () => this._onClose(); this._onSocketEnd = () => this._onEnd(); this._onSocketTimeout = () => this._onTimeout(); - this._onConnectionSocketError = err => this._onConnectionError(err, 'ESOCKET'); - this._connectionAttemptId = 0; + this._onConnectionSocketError = err => this._onError(err, 'ESOCKET', false, 'CONN'); } /** * Creates a connection to a SMTP server and sets up connection * listener */ connect(connectCallback) { + if (this._connectCalled && !this._destroyed) { + // A connection is opened once. A second call would open a second socket over the + // first one and run the session handlers of both against the same state + const err = this._formatError('Cannot connect - connect() was already called for this connection', 'ECONNECTION', false, 'API'); + if (typeof connectCallback === 'function') { + setImmediate(() => connectCallback(err)); + return; + } + this.logger.warn({ tnx: 'smtp' }, '%s', err.message); + return; + } + this._connectCalled = true; if (typeof connectCallback === 'function') { this._connectCallback = connectCallback; this.once('connect', () => { @@ -169,11 +198,15 @@ class SMTPConnection extends EventEmitter { return connectCallback(this._formatError(isDestroyedMessage, 'ECONNECTION', false, 'CONN')); } } + // connectionTimeout covers the whole of connecting: the DNS lookup and every address tried + const connectionTimeout = this.options.connectionTimeout || CONNECTION_TIMEOUT; + // a transport that first opened a proxy connection for this one sets when that started + this._connectionDeadline = (this._connectStartedAt || Date.now()) + connectionTimeout; let opts = { port: this.port, host: this.host, allowInternalNetworkInterfaces: this.allowInternalNetworkInterfaces, - timeout: this.options.dnsTimeout || DNS_TIMEOUT + timeout: Math.min(this.options.dnsTimeout || DNS_TIMEOUT, connectionTimeout) }; if (this.options.localAddress) { opts.localAddress = this.options.localAddress; @@ -202,7 +235,6 @@ class SMTPConnection extends EventEmitter { return this._resolveAndConnect(opts, _resolved => { try { this._socket.connect(this.port, this.host, () => { - this._socket.setKeepAlive(true); // a `secure` connection over a caller-provided socket must still // perform the TLS handshake, otherwise AUTH and the message body // would be sent in cleartext despite the caller requesting TLS @@ -234,9 +266,40 @@ class SMTPConnection extends EventEmitter { } } return this._resolveAndConnect(opts, resolved => { - // Store fallback addresses for retry on connection failure - this._fallbackAddresses = (resolved._addresses || []).filter(addr => addr !== opts.host); - this._connectOpts = Object.assign({}, opts); + let addresses = resolved._addresses || []; + if (opts.localAddress) { + // a socket bound to an address of one family can not reach the other one + const localFamily = net.isIPv6(opts.localAddress) ? 6 : 4; + const sameFamily = addresses.filter(addr => net.isIP(addr) === localFamily); + addresses = sameFamily.length ? sameFamily : addresses; + } + if (addresses.length > 1) { + // net.connect tries the addresses in turn and moves on to the next one when an + // attempt fails or takes too long. With both families it starts on IPv6 and + // alternates (RFC 8305), so a host with a broken IPv6 path costs a fraction of + // a second instead of a whole connection timeout + const ipv6 = addresses.filter(addr => net.isIPv6(addr)); + const ipv4 = addresses.filter(addr => !net.isIPv6(addr)); + const ordered = shuffle(ipv6).concat(shuffle(ipv4)); + opts.host = this.host; + opts.autoSelectFamily = true; + if (!ipv6.length || !ipv4.length) { + // a slow address of the only family gets its share of the time, not the + // quarter second meant for an address family that does not work + const remaining = this._connectionDeadline - Date.now(); + opts.autoSelectFamilyAttemptTimeout = Math.max(Math.floor(remaining / ordered.length), 10); + } + opts.lookup = ((hostname, lookupOptions, callback) => { + if (lookupOptions && lookupOptions.all) { + const all = ordered.map(address => ({ address, family: net.isIPv6(address) ? 6 : 4 })); + return setImmediate(() => callback(null, all)); + } + setImmediate(() => callback(null, ordered[0], net.isIPv6(ordered[0]) ? 6 : 4)); + }); + } + else if (addresses.length) { + opts.host = addresses[0]; + } this._connectToHost(opts, this.secureConnection); }); } @@ -283,20 +346,11 @@ class SMTPConnection extends EventEmitter { if (this._destroyed || this._closing) { return; } - this._connectionAttemptId++; - const currentAttemptId = this._connectionAttemptId; const connectFn = secure ? tls.connect : net.connect; try { - this._socket = connectFn(opts, () => { - // Ignore callback if this is a stale connection attempt - if (this._connectionAttemptId !== currentAttemptId) { - return; - } - this._socket.setKeepAlive(true); - this._onConnect(); - }); + this._socket = connectFn(opts, () => this._onConnect()); this._setupConnectionHandlers(); } catch (E) { @@ -309,51 +363,37 @@ class SMTPConnection extends EventEmitter { * @internal */ _setupConnectionHandlers() { - this._connectionTimeout = setTimeout(() => { - this._onConnectionError('Connection timeout', 'ETIMEDOUT'); - }, this.options.connectionTimeout || CONNECTION_TIMEOUT); + this._startPhase(Math.max((this._connectionDeadline || Date.now()) - Date.now(), 0), timeoutError('Connection timeout', 'CONNECT_TIMEOUT')); this._socket.on('error', this._onConnectionSocketError); } /** - * Handles connection errors with fallback to alternative addresses + * Starts the timer of a connection phase: connecting, waiting for the greeting or a TLS + * upgrade. The phases follow one another, so starting one ends the one before * - * @param err Error object or message - * @param code Error code + * @param timeout Time the phase may take + * @param err Error to fail with when it takes longer * @internal */ - _onConnectionError(err, code) { - clearTimeout(this._connectionTimeout); - // Check if we have fallback addresses to try - const canFallback = this._fallbackAddresses && this._fallbackAddresses.length && this.stage === 'init' && !this._destroyed; - if (!canFallback) { - // No more fallback addresses, report the error - this._onError(err, code, false, 'CONN'); - return; - } - const nextHost = this._fallbackAddresses.shift(); - this.logger.info({ - tnx: 'network', - failedHost: this._connectOpts.host, - nextHost, - error: err.message || err - }, 'Connection to %s failed, trying %s', this._connectOpts.host, nextHost); - // Clean up current socket - if (this._socket) { - try { - this._socket.removeListener('error', this._onConnectionSocketError); - // Absorb any late teardown error (e.g. a TLS fallback socket emitting - // after destroy), mirroring the guard used in close() - this._socket.on('error', TEARDOWN_NOOP); - this._socket.destroy(); - } - catch (_E) { - // ignore - } - this._socket = null; - } - // Update host and retry - this._connectOpts.host = nextHost; - this._connectToHost(this._connectOpts, this.secureConnection); + _startPhase(timeout, err) { + this._clearPhase(); + this._phaseTimer = setTimeout(() => { + this._phaseTimer = false; + this._onError(err, 'ETIMEDOUT', false, 'CONN'); + }, timeout); + } + /** + * Time the greeting or a STARTTLS upgrade may take: greetingTimeout, cut short by what is left + * of connectionTimeout + * + * @internal + */ + _remainingSetupTime() { + return Math.min(this.options.greetingTimeout || GREETING_TIMEOUT, Math.max((this._connectionDeadline || Infinity) - Date.now(), 1)); + } + /** @internal */ + _clearPhase() { + clearTimeout(this._phaseTimer); + this._phaseTimer = false; } /** * Sends QUIT @@ -366,8 +406,7 @@ class SMTPConnection extends EventEmitter { * Closes the connection to the server */ close() { - clearTimeout(this._connectionTimeout); - clearTimeout(this._greetingTimeout); + this._clearPhase(); this._responseActions = []; // allow to run this function only once if (this._closing) { @@ -390,13 +429,15 @@ 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 + // Detach from the message stream as well and release whatever it reads from, the message + // can not be sent over this connection anymore. The listener is swapped for a no-op rather + // than removed, a stream destroyed with an error 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); + stream.destroy(); } this._pendingSend = false; } @@ -415,6 +456,15 @@ class SMTPConnection extends EventEmitter { // sending cleartext after TLS shutdown triggers ERR_SSL_BAD_RECORD_TYPE) socket.on('error', TEARDOWN_NOOP); socket[closeMethod](); + if (closeMethod === 'end') { + // end() only closes our side, a server that never closes its own would keep + // the socket, and the process with it, around for good + const closeTimer = setTimeout(() => socket.destroy(), CLOSE_TIMEOUT); + if (typeof closeTimer.unref === 'function') { + closeTimer.unref(); + } + socket.once('close', () => clearTimeout(closeTimer)); + } } catch (_E) { // just ignore @@ -619,14 +669,9 @@ class SMTPConnection extends EventEmitter { const startTime = Date.now(); this._setEnvelope(envelope, (err, info) => { if (err) { - // create passthrough stream to consume to prevent OOM - const stream = new PassThrough(); - if (typeof message.pipe === 'function') { - message.pipe(stream); - } - else { - stream.write(message); - stream.end(); + // the message is not going to be sent, release whatever the stream reads from + if (typeof message.destroy === 'function') { + message.destroy(); } return callback(err); } @@ -680,7 +725,7 @@ class SMTPConnection extends EventEmitter { */ _onConnect() { const socket = this._socket; - clearTimeout(this._connectionTimeout); + this._clearPhase(); this.logger.info({ tnx: 'network', localAddress: socket.localAddress, @@ -710,16 +755,42 @@ class SMTPConnection extends EventEmitter { socket.once('end', this._onSocketEnd); socket.setTimeout(this.options.socketTimeout || SOCKET_TIMEOUT); socket.on('timeout', this._onSocketTimeout); - this._greetingTimeout = setTimeout(() => { - // if still waiting for greeting, give up - if (this._socket && !this._destroyed && this._responseActions[0] === this._actionGreeting) { - this._onError('Greeting never received', 'ETIMEDOUT', false, 'CONN'); - } - }, this.options.greetingTimeout || GREETING_TIMEOUT); + // keepalive also covers sockets handed over by a proxy or by the caller + if (typeof socket.setKeepAlive === 'function') { + socket.setKeepAlive(true, KEEPALIVE_DELAY); + } + // Commands are written in the batches they belong to (see cork() for PIPELINING), Nagle + // would only hold a write back until the server acknowledged the previous one. Against a + // server that delays its ACKs that costs 40ms on every message + if (typeof socket.setNoDelay === 'function') { + socket.setNoDelay(true); + } + // bounded by greetingTimeout and by what is left of connectionTimeout, which covers setting + // the session up from the DNS lookup to the end of a STARTTLS upgrade + this._startPhase(this._remainingSetupTime(), timeoutError('Greeting never received', 'GREETING_TIMEOUT')); this._responseActions.push(this._actionGreeting); // we have a 'data' listener set up so resume socket if it was paused socket.resume(); } + /** + * Ends the session after a 421 reply. The replies queued for commands sent along with the + * answered one are not going to come, so the message in flight is failed here + * + * @param str The 421 reply + * @internal + */ + _onServerClosing(str) { + if (this._destroyed) { + return; + } + const pendingSend = this._pendingSend; + const envelope = this._envelope; + this._responseActions = []; + this.close(); + if (pendingSend) { + pendingSend.callback((envelope && envelope.mailError) || this._formatError('Server closed the connection', 'ECONNECTION', str, 'CONN')); + } + } /** * 'data' listener for data coming from the server * @@ -794,8 +865,7 @@ class SMTPConnection extends EventEmitter { * @internal */ _onError(err, type, data, command) { - clearTimeout(this._connectionTimeout); - clearTimeout(this._greetingTimeout); + this._clearPhase(); if (this._destroyed) { // just ignore, already closed // this might happen when a socket is canceled because of reached timeout @@ -803,12 +873,12 @@ class SMTPConnection extends EventEmitter { return; } err = this._formatError(err, type, data, command); - const transientCodes = ['ETIMEDOUT', 'ESOCKET', 'ECONNECTION']; - if (transientCodes.includes(err.code)) { - this.logger.warn(data, err.message); + // the message carries the server response, it is an argument and not the format string + if (isTransientError(err)) { + this.logger.warn({ tnx: 'smtp', err }, '%s', err.message); } else { - this.logger.error(data, err.message); + this.logger.error({ tnx: 'smtp', err }, '%s', 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 @@ -828,7 +898,12 @@ class SMTPConnection extends EventEmitter { else { err = new Error(message); } - if (type && type !== 'Error') { + // a permission model denial keeps its own code, see ERR_ACCESS_DENIED + if (type && type !== 'Error' && err.code !== ERR_ACCESS_DENIED) { + // the code of a system error, such as ECONNREFUSED, still tells what happened + if (err.code && err.code !== type && !err.originalCode) { + err.originalCode = err.code; + } err.code = type; } if (response) { @@ -880,6 +955,19 @@ class SMTPConnection extends EventEmitter { this.close(); return; } + if (!failureResponse && + this.stage === 'connected' && + !this._responseActions.length && + !this._pendingSend && + !this._destroyed && + !this._closing) { + // nothing was waiting for the server, so this is the server ending an idle session + // (usually after its idle timeout), not a failure + this.logger.info({ + tnx: 'network' + }, 'Server closed the idle connection'); + return this._destroy(); + } if (failureResponse || (this._responseActions[0] !== this.close && !this._destroyed)) { return this._onError(new Error('Connection closed unexpectedly'), 'ECONNECTION', failureResponse, 'CONN'); } @@ -905,7 +993,7 @@ class SMTPConnection extends EventEmitter { * @internal */ _onTimeout() { - return this._onError(new Error('Timeout'), 'ETIMEDOUT', false, 'CONN'); + return this._onError(timeoutError('Timeout', 'SOCKET_TIMEOUT'), 'ETIMEDOUT', false, 'CONN'); } /** * Destroys the client, emits 'end' @@ -920,10 +1008,7 @@ class SMTPConnection extends EventEmitter { this.destroyed = true; // a connection the server dropped before the greeting would otherwise keep // the greeting timer, and with it the process, alive until it fires - clearTimeout(this._connectionTimeout); - clearTimeout(this._greetingTimeout); - this._connectionTimeout = false; - this._greetingTimeout = false; + this._clearPhase(); this.emit('end'); } /** @@ -940,6 +1025,15 @@ class SMTPConnection extends EventEmitter { // inject plaintext bytes after the "220" reply (e.g. a CRLF-free fragment that // would otherwise be prepended to the first post-TLS response and parsed as // part of the secured EHLO capabilities). STARTTLS response injection. + const discarded = this._remainder.length + this._responseQueue.reduce((total, response) => total + response.length, 0); + if (discarded) { + // a server does not send anything here on its own, this is worth knowing about + this.logger.warn({ + tnx: 'smtp', + discarded + }, 'Discarded %s bytes received in plaintext after the STARTTLS response', discarded); + } + this._plaintextEhlo = false; this._remainder = ''; this._responseQueue = []; this._responsePartial = false; @@ -949,6 +1043,7 @@ class SMTPConnection extends EventEmitter { const socketPlain = this._socket; socketPlain.removeListener('data', this._onSocketData); // incoming data is going to be gibberish from this point onwards socketPlain.removeListener('timeout', this._onSocketTimeout); // timeout will be re-set for the new socket object + socketPlain.setTimeout(0); const opts = Object.assign({ socket: socketPlain, host: this.host @@ -969,9 +1064,14 @@ class SMTPConnection extends EventEmitter { socketPlain.removeListener('error', this._onConnectionSocketError); }; this.upgrading = true; + // the socket timeout only notices a server that sends nothing at all, a handshake that + // trickles along would otherwise hold the connection for as long as the server likes + // STARTTLS is the last step of setting the session up + this._startPhase(this._remainingSetupTime(), timeoutError('TLS handshake timed out', 'UPGRADE_TIMEOUT')); // tls.connect is not an asynchronous function however it may still throw errors and requires to be wrapped with try/catch try { this._socket = tls.connect(opts, () => { + this._clearPhase(); this.secure = true; this.upgrading = false; this._socket.on('data', this._onSocketData); @@ -980,6 +1080,7 @@ class SMTPConnection extends EventEmitter { }); } catch (err) { + this._clearPhase(); removePlainSocketListeners(); return callback(err); } @@ -1020,10 +1121,24 @@ class SMTPConnection extends EventEmitter { }, str.replace(/\r?\n$/, '')); } const action = this._responseActions.shift(); + // RFC 5321 4.2: 421 means the server is about to close the connection, whatever it answers + const closing = /^421[ -]/.test(str); if (typeof action === 'function') { + // the command gets its own error first, the code tells which step failed action.call(this, str); + if (closing) { + return this._onServerClosing(str); + } setImmediate(() => this._processResponse()); } + else if (closing && !this._pendingSend && this.stage === 'connected') { + // RFC 5321 4.2: a server may send 421 at any time when it is about to close the + // connection. Nothing was waiting for a reply, so this ends an idle session + this.logger.info({ + tnx: 'smtp' + }, 'Server closed the idle connection: %s', str); + this.close(); + } else { return this._onError(new Error('Unexpected Response'), 'EPROTOCOL', str, 'CONN'); } @@ -1118,6 +1233,9 @@ class SMTPConnection extends EventEmitter { return callback(this._formatError('Server does not support REQUIRETLS extension (RFC 8689)', 'EREQUIRETLS', false, 'MAIL FROM')); } } + // RFC 2920: with PIPELINING the whole envelope and DATA go out without waiting for the + // replies in between, which saves two round trips for every message + this._envelope.pipelined = this._supportedExtensions.includes('PIPELINING'); this._responseActions.push(str => { this._actionMAIL(str, callback); }); @@ -1155,7 +1273,38 @@ class SMTPConnection extends EventEmitter { if (this._envelope.requireTLSExtensionEnabled) { args.push('REQUIRETLS'); } - this._sendCommand('MAIL FROM:<' + this._envelope.from + '>' + (args.length ? ' ' + args.join(' ') : '')); + const mailFrom = 'MAIL FROM:<' + this._envelope.from + '>' + (args.length ? ' ' + args.join(' ') : ''); + this._recipientQueue = []; + if (!this._envelope.pipelined) { + this._sendCommand(mailFrom); + return; + } + // corked, so the batch leaves in one segment instead of the first command alone + const socket = this._socket; + socket.cork(); + this._sendCommand(mailFrom); + while (this._envelope.rcptQueue.length) { + this._sendRcpt(this._envelope.rcptQueue.shift(), callback); + } + this._responseActions.push(str => { + this._actionDATA(str, callback); + }); + this._sendCommand('DATA'); + socket.uncork(); + } + /** + * Sends RCPT TO for a recipient and queues the handler for the reply + * + * @param recipient Recipient address + * @param callback Callback to run once the envelope is processed + * @internal + */ + _sendRcpt(recipient, callback) { + this._recipientQueue.push(recipient); + this._responseActions.push(str => { + this._actionRCPT(str, callback); + }); + this._sendCommand('RCPT TO:<' + recipient + '>' + this._getDsnRcptToArgs()); } /** @internal */ _setDsnEnvelope(params) { @@ -1271,7 +1420,7 @@ class SMTPConnection extends EventEmitter { * @internal */ _actionGreeting(str) { - clearTimeout(this._greetingTimeout); + this._clearPhase(); if (str.substr(0, 3) !== '220') { this._onError(new Error('Invalid greeting. response=' + str), 'EPROTOCOL', str, 'CONN'); return; @@ -1309,7 +1458,6 @@ class SMTPConnection extends EventEmitter { * @internal */ _actionEHLO(str) { - let match; if (str.substr(0, 3) === '421') { this._onError(new Error('Server terminates connection. response=' + str), 'ECONNECTION', str, 'EHLO'); return; @@ -1324,17 +1472,30 @@ class SMTPConnection extends EventEmitter { this._sendCommand('HELO ' + this.name); return; } + // Detect if the server supports STARTTLS + if (!this.secure && !this.options.ignoreTLS && (/[ -]STARTTLS\b/im.test(str) || this.options.requireTLS)) { + // kept for opportunisticTLS, a session that stays in plaintext still has these extensions + this._plaintextEhlo = str; + this._sendCommand('STARTTLS'); + this._responseActions.push(this._actionSTARTTLS); + return; + } + this._parseEhloExtensions(str); + this.emit('connect'); + } + /** + * Reads the extensions and the authentication mechanisms out of an EHLO response + * + * @param str EHLO response from the server + * @internal + */ + _parseEhloExtensions(str) { + let match; this._ehloLines = str .split(/\r?\n/) .map(line => line.replace(/^\d+[ -]/, '').trim()) .filter(line => line) .slice(1); - // Detect if the server supports STARTTLS - if (!this.secure && !this.options.ignoreTLS && (/[ -]STARTTLS\b/im.test(str) || this.options.requireTLS)) { - this._sendCommand('STARTTLS'); - this._responseActions.push(this._actionSTARTTLS); - return; - } // Detect if the server supports SMTPUTF8 if (/[ -]SMTPUTF8\b/im.test(str)) { this._supportedExtensions.push('SMTPUTF8'); @@ -1384,7 +1545,6 @@ class SMTPConnection extends EventEmitter { this._supportedExtensions.push('SIZE'); this._maxAllowedSize = Number(match[1]) || 0; } - this.emit('connect'); } /** * Handles server response for HELO command. If it yielded in @@ -1416,6 +1576,14 @@ class SMTPConnection extends EventEmitter { this.logger.info({ tnx: 'smtp' }, 'Failed STARTTLS upgrade, continuing unencrypted'); + // the plaintext session goes on with what the server announced for it, except for + // AUTH: credentials are not sent over a connection that failed to encrypt + if (this._plaintextEhlo) { + this._parseEhloExtensions(this._plaintextEhlo); + this._plaintextEhlo = false; + this.allowsAuth = false; + this._supportedAuth = []; + } this.emit('connect'); return; } @@ -1596,21 +1764,9 @@ class SMTPConnection extends EventEmitter { const message = this._usingSmtpUtf8 && /^550 /.test(str) && /[\x80-\uFFFF]/.test(envelope.from) ? 'Internationalized mailbox name not allowed' : 'Mail command failed'; - return callback(this._formatError(message, 'EENVELOPE', str, 'MAIL FROM')); + envelope.mailError = this._formatError(message, 'EENVELOPE', str, 'MAIL FROM'); } - if (!envelope.rcptQueue.length) { - return callback(this._formatError("Can't send mail - no recipients defined", 'EENVELOPE', false, 'API')); - } - this._recipientQueue = []; - const usePipelining = this._supportedExtensions.includes('PIPELINING'); - do { - const curRecipient = envelope.rcptQueue.shift(); - this._recipientQueue.push(curRecipient); - this._responseActions.push(str => { - this._actionRCPT(str, callback); - }); - this._sendCommand('RCPT TO:<' + curRecipient + '>' + this._getDsnRcptToArgs()); - } while (usePipelining && envelope.rcptQueue.length); + this._advanceEnvelope(str, callback); } /** * Handle response for a RCPT TO: command @@ -1637,32 +1793,67 @@ class SMTPConnection extends EventEmitter { else { envelope.accepted.push(curRecipient); } - if (!envelope.rcptQueue.length && !this._recipientQueue.length) { - if (envelope.rejected.length < envelope.to.length) { - this._responseActions.push(str => { - this._actionDATA(str, callback); - }); - this._sendCommand('DATA'); - } - else { - // 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); - } + this._advanceEnvelope(str, callback); + } + /** + * Moves the envelope on after a reply to MAIL FROM or RCPT TO. A pipelined envelope sent every + * command at once and is decided by the reply to DATA. Otherwise the commands go one at a + * time: the next recipient, or once every reply is in, DATA or the error that ends the message + * + * @param str The reply that was handled + * @param callback Callback to run once the envelope is processed + * @internal + */ + _advanceEnvelope(str, callback) { + const envelope = this._envelope; + if (envelope.pipelined) { + return; } - else if (envelope.rcptQueue.length) { - const nextRecipient = envelope.rcptQueue.shift(); - this._recipientQueue.push(nextRecipient); - this._responseActions.push(str => { - this._actionRCPT(str, callback); - }); - this._sendCommand('RCPT TO:<' + nextRecipient + '>' + this._getDsnRcptToArgs()); + if (envelope.mailError) { + return callback(envelope.mailError); } + if (envelope.rcptQueue.length) { + return this._sendRcpt(envelope.rcptQueue.shift(), callback); + } + if (this._recipientQueue.length) { + // replies still to come + return; + } + const err = this._envelopeError(str); + if (err) { + return callback(err); + } + this._responseActions.push(str => { + this._actionDATA(str, callback); + }); + this._sendCommand('DATA'); + } + /** + * Decides how the envelope went once every reply to MAIL FROM and RCPT TO is in. Called after + * the last RCPT TO reply, or with PIPELINING on the reply to the DATA command sent along + * + * @param str The reply being handled + * @returns The error to fail the message with, or null when DATA can go ahead + * @internal + */ + _envelopeError(str) { + const envelope = this._envelope; + if (envelope.mailError) { + return envelope.mailError; + } + if (envelope.accepted.length) { + return null; + } + // 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 lastRejected = envelope.rejectedErrors[envelope.rejectedErrors.length - 1]; + const reply = deferred?.response ?? (envelope.pipelined && lastRejected ? lastRejected.response : str); + // every recipient was rejected + const err = this._formatError("Can't send mail - all recipients were rejected", 'EENVELOPE', reply, 'RCPT TO'); + err.rejected = envelope.rejected; + err.rejectedErrors = envelope.rejectedErrors; + return err; } /** * Handle response for a DATA command @@ -1673,6 +1864,27 @@ class SMTPConnection extends EventEmitter { */ _actionDATA(str, callback) { const envelope = this._envelope; + if (envelope.pipelined) { + const err = this._envelopeError(str); + if (err) { + if (/^3/.test(str)) { + if (envelope.mailError) { + // A server that refused the sender has no transaction to end, one that + // took DATA anyway can not be trusted with an empty message either. + // Drop the connection instead of answering it + this.close(); + return callback(err); + } + // A server must refuse DATA without an accepted recipient, this one took it + // anyway. End the empty message, it has nobody to go to, so the session + // stays usable + this._responseActions.push(() => callback(err)); + this._sendCommand('.'); + return; + } + return callback(err); + } + } // response should be 354 but according to this issue https://github.com/eleith/emailjs/issues/24 // some servers might use 250 instead, so lets check for 2 or 3 as the first digit if (!/^[23]/.test(str)) { 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 1f1aeff0..7c28f12b 100644 --- a/node_modules/nodemailer/dist/esm/smtp-pool/index.d.ts +++ b/node_modules/nodemailer/dist/esm/smtp-pool/index.d.ts @@ -19,6 +19,8 @@ export interface SMTPPoolOptions extends SMTPTransportOptions { rateDelta?: number | undefined; /** How many times a message is requeued when its connection closes while sending, defaults to 5, a negative value means unlimited */ maxRequeues?: number | undefined; + /** Time in milliseconds a connection may stay idle before it is closed, defaults to 4 minutes, 0 keeps it open until the server closes it */ + idleTimeout?: number | undefined; } /** * The pool options once the constructor has applied the defaults @@ -27,6 +29,7 @@ export type SMTPPoolResolvedOptions = SMTPPoolOptions & { maxConnections: number; maxMessages: number; maxRequeues: number; + idleTimeout: 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 160c0aef..1e3ea174 100644 --- a/node_modules/nodemailer/dist/esm/smtp-pool/index.js +++ b/node_modules/nodemailer/dist/esm/smtp-pool/index.js @@ -42,6 +42,9 @@ class SMTPPool extends EventEmitter { 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; + // below the 5 minutes RFC 5321 asks servers to wait at least, the connection is closed by us + // and not by the server in the middle of handing it a message + this.options.idleTimeout = typeof this.options.idleTimeout === 'number' ? this.options.idleTimeout : 4 * 60 * 1000; this.logger = shared.getLogger(this.options, { component: this.options.component || 'smtp-pool' }); @@ -448,7 +451,9 @@ class SMTPPool extends EventEmitter { // the error paths hand over the error alone const done = callback; const auth = new PoolResource(this).auth; - this.getSocket(this.options, (err, socketOptions) => { + // the proxy handshake, if any, counts against connectionTimeout as well + const connectStartedAt = Date.now(); + this.getSocket(Object.assign({}, this.options, { connectStartedAt }), (err, socketOptions) => { if (err) { return done(err); } @@ -465,6 +470,7 @@ class SMTPPool extends EventEmitter { options = Object.assign(shared.assign(false, options), socketOptions); } const connection = new SMTPConnection(options); + connection._connectStartedAt = connectStartedAt; let returned = false; connection.once('error', 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 b7ce588c..5ca031ea 100644 --- a/node_modules/nodemailer/dist/esm/smtp-pool/pool-resource.js +++ b/node_modules/nodemailer/dist/esm/smtp-pool/pool-resource.js @@ -52,6 +52,8 @@ export default class PoolResource extends EventEmitter { this.messages = 0; this.available = true; this._failed = false; + this._sending = false; + this._idleTimer = false; } /** * Emits 'error' for the first failure only. A dead resource can report the same failure more @@ -71,7 +73,9 @@ export default class PoolResource extends EventEmitter { * @param callback Callback function to run once the connection is established or failed */ connect(callback) { - this.pool.getSocket(this.options, (err, socketOptions) => { + // the proxy handshake, if any, counts against connectionTimeout as well + const connectStartedAt = Date.now(); + this.pool.getSocket(Object.assign({}, this.options, { connectStartedAt }), (err, socketOptions) => { if (err) { // 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 @@ -93,7 +97,21 @@ export default class PoolResource extends EventEmitter { options = Object.assign(assign(false, options), socketOptions); } this.connection = new SMTPConnection(options); - this.connection.on('error', err => { + this.connection._connectStartedAt = connectStartedAt; + this.connection.on('error', (err) => { + if (this._sending) { + // the send callback gets the same error and decides what it means for the message + return; + } + if (this._connected && errors.isTransientError(err)) { + // the server ended a connection that had nothing in flight, usually after it + // was idle for a while. The 'end' that follows closes this resource + this.logger.info({ + tnx: 'pool', + cid: this.id + }, 'Connection #%s was closed by the server: %s', this.id, err.message); + return; + } this._fail(err); if (returned) { return; @@ -171,9 +189,39 @@ export default class PoolResource extends EventEmitter { if (mail.data.requireTLSExtensionEnabled) { envelope.requireTLSExtensionEnabled = mail.data.requireTLSExtensionEnabled; } - this.connection.send(envelope, mail.message.createReadStream(), (err, info) => { + this._sending = true; + // a connection that sent messages before may have been dropped by the server in between + const reused = this.messages > 0; + const messageStream = mail.message.createReadStream(); + this.connection.send(envelope, messageStream, (err, info) => { + this._sending = false; this.messages++; if (err) { + if (reused && messageStream.readableDidRead === false && errors.isTransientError(err)) { + // Not a byte of the message was sent, so it can go out over another + // connection. The pool requeues the message when this resource closes + this.logger.info({ + tnx: 'pool', + cid: this.id, + messageId + }, 'Connection #%s was closed by the server before message %s was sent: %s', this.id, messageId, err.message); + this.connection.close(); + return; + } + if ((err.code === errors.EENVELOPE || err.code === errors.EMESSAGE) && + err.responseCode !== 421 && + !this.connection._destroyed) { + // The server refused this message, the connection itself is fine. Reset the + // session and keep using it instead of opening a new one + this.connection.reset(resetErr => { + if (resetErr) { + this.connection.close(); + return; + } + this._release(); + }); + return callback(err); + } this.connection.close(); this._fail(err); return callback(err); @@ -183,28 +231,69 @@ export default class PoolResource extends EventEmitter { to: envelope.to }; info.messageId = messageId; - setImmediate(() => { - if (this.messages >= this.options.maxMessages) { - const err = new Error('Resource exhausted'); - err.code = errors.EMAXLIMIT; - this.connection.close(); - this._fail(err); - } - else { - this.pool._checkRateLimit(() => { - this.available = true; - this.emit('available'); - }); - } - }); + setImmediate(() => this._release()); callback(null, info); }); } + /** + * Makes the connection available for the next message, or closes it once it has sent + * maxMessages messages + * + * @internal + */ + _release() { + if (this.messages >= this.options.maxMessages) { + const err = new Error('Resource exhausted'); + err.code = errors.EMAXLIMIT; + this.connection.close(); + this._fail(err); + return; + } + this.pool._checkRateLimit(() => { + this.available = true; + this._startIdleTimer(); + this.emit('available'); + }); + } + /** @internal */ + _startIdleTimer() { + if (!this.options.idleTimeout || this.options.idleTimeout < 0) { + return; + } + // one timer per connection, restarted every time the connection becomes available. A + // connection that is busy when it fires is simply not closed + if (this._idleTimer && typeof this._idleTimer.refresh === 'function') { + this._idleTimer.refresh(); + return; + } + clearTimeout(this._idleTimer); + this._idleTimer = setTimeout(() => { + if (!this.available) { + return; + } + this.logger.debug({ + tnx: 'pool', + cid: this.id + }, 'Closing connection #%s after it was idle for %sms', this.id, this.options.idleTimeout); + // not handed another message while it says goodbye + this.available = false; + this.connection.quit(); + }, this.options.idleTimeout); + if (typeof this._idleTimer.unref === 'function') { + this._idleTimer.unref(); + } + } + /** @internal */ + _stopIdleTimer() { + clearTimeout(this._idleTimer); + this._idleTimer = false; + } /** * Closes the connection */ close() { this._connected = false; + this._stopIdleTimer(); if (this.auth && this.auth.oauth2) { this.auth.oauth2.removeAllListeners(); } diff --git a/node_modules/nodemailer/dist/esm/smtp-transport/index.js b/node_modules/nodemailer/dist/esm/smtp-transport/index.js index 9f575356..521280cc 100644 --- a/node_modules/nodemailer/dist/esm/smtp-transport/index.js +++ b/node_modules/nodemailer/dist/esm/smtp-transport/index.js @@ -104,7 +104,9 @@ class SMTPTransport extends EventEmitter { * @param callback Callback function */ send(mail, callback) { - this.getSocket(this.options, (err, socketOptions) => { + // the proxy handshake, if any, counts against connectionTimeout as well + const connectStartedAt = Date.now(); + this.getSocket(Object.assign({}, this.options, { connectStartedAt }), (err, socketOptions) => { if (err) { return callback(err); } @@ -123,6 +125,7 @@ class SMTPTransport extends EventEmitter { options = Object.assign(shared.assign(false, options), socketOptions); } const connection = new SMTPConnection(options); + connection._connectStartedAt = connectStartedAt; let perCallAuth; const cleanupPerCallAuth = () => { if (perCallAuth && perCallAuth !== this.auth && perCallAuth.oauth2) { @@ -228,7 +231,9 @@ class SMTPTransport extends EventEmitter { } // the error paths hand over the error alone const done = callback; - this.getSocket(this.options, (err, socketOptions) => { + // the proxy handshake, if any, counts against connectionTimeout as well + const connectStartedAt = Date.now(); + this.getSocket(Object.assign({}, this.options, { connectStartedAt }), (err, socketOptions) => { if (err) { return done(err); } @@ -245,6 +250,7 @@ class SMTPTransport extends EventEmitter { options = Object.assign(shared.assign(false, options), socketOptions); } const connection = new SMTPConnection(options); + connection._connectStartedAt = connectStartedAt; let returned = false; let perCallAuth; const cleanupPerCallAuth = () => { diff --git a/node_modules/nodemailer/package.json b/node_modules/nodemailer/package.json index 8e3facf1..8ecdec04 100644 --- a/node_modules/nodemailer/package.json +++ b/node_modules/nodemailer/package.json @@ -1,6 +1,6 @@ { "name": "nodemailer", - "version": "10.0.15", + "version": "10.1.0", "description": "Easy as cake e-mail sending from your Node.js applications", "type": "module", "main": "./dist/cjs/nodemailer.js", @@ -168,7 +168,7 @@ "smtp-server": "3.19.17", "tsx": "4.23.15", "typescript": "6.0.3", - "typescript-eslint": "8.71.0" + "typescript-eslint": "8.71.1" }, "engines": { "node": ">=20.0.0"