mirror of
https://github.com/dawidd6/action-send-mail.git
synced 2026-10-12 02:42:27 +07:00
node_modules: update (#344)
Co-authored-by: dawidd6 <9713907+dawidd6@users.noreply.github.com>
This commit is contained in:
1 parent
b099186883
commit
6e88c72525
63 files changed
+2645
-1009
No files matched your search
+3
-3
@@ -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"
|
||||
|
||||
+36
@@ -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)
|
||||
|
||||
|
||||
|
||||
+5
-1
@@ -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;
|
||||
}
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
+80
-41
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+33
-19
@@ -37,34 +37,47 @@ 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();
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Ends the output with an error and releases the cache file
|
||||
*/
|
||||
fail(err) {
|
||||
this.hasErrored = true;
|
||||
this.cleanup();
|
||||
output.emit('error', err);
|
||||
});
|
||||
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() {
|
||||
if (this.hasErrored) {
|
||||
return;
|
||||
}
|
||||
while (!this.hasErrored) {
|
||||
if (this.readPos >= this.chunks.length) {
|
||||
if (!this.cache) {
|
||||
this.output.end();
|
||||
@@ -79,9 +92,12 @@ class DKIMSigner {
|
||||
});
|
||||
return;
|
||||
}
|
||||
setImmediate(() => this.sendNextChunk());
|
||||
}
|
||||
}
|
||||
sendSignedOutput() {
|
||||
if (this.hasErrored) {
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
|
||||
+2
-2
@@ -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) {
|
||||
|
||||
+17
@@ -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
|
||||
|
||||
+12
-1
@@ -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';
|
||||
+18
-11
@@ -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;
|
||||
|
||||
+23
-3
@@ -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) {
|
||||
|
||||
+23
-7
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -90,6 +90,11 @@ export default class MailMessage<T = SentMessageInfo> {
|
||||
[key: string]: any;
|
||||
}, key: string | number, options?: ResolveContentOptions | false): Promise<any>;
|
||||
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;
|
||||
|
||||
+20
@@ -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();
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+143
-83
@@ -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);
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
outputStream = outputStream.pipe(transform);
|
||||
// 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);
|
||||
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();
|
||||
}
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
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 (aborted()) {
|
||||
return;
|
||||
}
|
||||
if (typeof this.content.pipe === 'function') {
|
||||
this.content.removeListener('error', this._contentErrorHandler);
|
||||
this._contentErrorHandler = err => callback(err);
|
||||
this.content.once('error', this._contentErrorHandler);
|
||||
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)) {
|
||||
contentStream = new (transferEncoding === 'base64' ? base64 : qp).Encoder(options);
|
||||
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
|
||||
});
|
||||
contentStream.once('end', finalize);
|
||||
contentStream.once('error', err => callback(err));
|
||||
localStream = this._getStream(this.content);
|
||||
localStream.pipe(contentStream);
|
||||
// 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
|
||||
localStream = this._getStream(this.content);
|
||||
readInto(localStream);
|
||||
localStream.pipe(outputStream, {
|
||||
end: false
|
||||
});
|
||||
localStream.once('end', finalize);
|
||||
(0, node_stream_1.finished)(localStream, { writable: false }, err => (err ? callback(err) : 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 => {
|
||||
readInto(sourceStream);
|
||||
shared.resolveStream(sourceStream, (err, value) => {
|
||||
if (err) {
|
||||
return callback(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);
|
||||
this.content._resolvedValue = value;
|
||||
setImmediate(createStream);
|
||||
});
|
||||
}
|
||||
else {
|
||||
setImmediate(createStream);
|
||||
}
|
||||
return;
|
||||
}
|
||||
return setImmediate(finalize);
|
||||
};
|
||||
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.
|
||||
*
|
||||
|
||||
+12
-9
@@ -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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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/";
|
||||
+1
-1
@@ -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/';
|
||||
+2
@@ -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;
|
||||
|
||||
+102
-32
@@ -21,23 +21,52 @@ const QP_RANGES = [
|
||||
[0x20, 0x3c], // <SP>!"#$%&'()*+,-./0123456789:;
|
||||
[0x3e, 0x7e] // >?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}
|
||||
];
|
||||
function encode(buffer) {
|
||||
if (typeof buffer === 'string') {
|
||||
buffer = Buffer.from(buffer, 'utf-8');
|
||||
// 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;
|
||||
}
|
||||
let result = '';
|
||||
let ord;
|
||||
for (let i = 0, len = buffer.length; i < len; i++) {
|
||||
ord = buffer[i];
|
||||
const HEX_DIGITS = Buffer.from('0123456789ABCDEF', 'latin1');
|
||||
const isWhitespace = (c) => c === 0x20 || c === 0x09;
|
||||
function encode(buffer) {
|
||||
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;
|
||||
}
|
||||
// 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--;
|
||||
}
|
||||
else {
|
||||
qp = encode(chunk);
|
||||
this.outputBytes += qp.length;
|
||||
this.push(qp, 'ascii');
|
||||
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;
|
||||
+54
-14
@@ -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({
|
||||
|
||||
+32
-1
@@ -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;
|
||||
+168
-71
@@ -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;
|
||||
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,24 +180,59 @@ 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;
|
||||
}
|
||||
else {
|
||||
ipv4Addresses = addresses || [];
|
||||
}
|
||||
resolve(6, host, options, (err, addresses) => {
|
||||
if (err) {
|
||||
ipv6Error = err;
|
||||
}
|
||||
else {
|
||||
ipv6Addresses = addresses || [];
|
||||
let pending = 2;
|
||||
const onResolved = () => {
|
||||
if (--pending || responded) {
|
||||
return;
|
||||
}
|
||||
// Combine addresses: IPv4 first, then IPv6
|
||||
const allAddresses = ipv4Addresses.concat(ipv6Addresses);
|
||||
@@ -191,41 +244,23 @@ const resolveHostname = (options, callback) => {
|
||||
value,
|
||||
expires: Date.now() + (options.dnsTtl || DNS_TTL)
|
||||
});
|
||||
return callback(null, formatDNSValue(value, {
|
||||
return respond(null, formatDNSValue(value, {
|
||||
servername,
|
||||
cached: false
|
||||
}));
|
||||
}
|
||||
// No addresses from resolve4/resolve6, try dns.lookup as fallback
|
||||
if (ipv4Error && ipv6Error) {
|
||||
if (ipv4Error && ipv6Error && respondCached(ipv4Error)) {
|
||||
// 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;
|
||||
}
|
||||
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
|
||||
}));
|
||||
if (respondCached(err)) {
|
||||
return;
|
||||
}
|
||||
return callback(err);
|
||||
return respond(err);
|
||||
}
|
||||
// Get all supported addresses from dns.lookup
|
||||
const supportedAddresses = addresses
|
||||
@@ -237,7 +272,7 @@ const resolveHostname = (options, callback) => {
|
||||
}
|
||||
if (!supportedAddresses.length && cached) {
|
||||
// nothing was found, fallback to cached value
|
||||
return callback(null, formatDNSValue(cached.value, {
|
||||
return respond(null, formatDNSValue(cached.value, {
|
||||
servername,
|
||||
cached: true
|
||||
}));
|
||||
@@ -249,28 +284,35 @@ const resolveHostname = (options, callback) => {
|
||||
value,
|
||||
expires: Date.now() + (options.dnsTtl || DNS_TTL)
|
||||
});
|
||||
return callback(null, formatDNSValue(value, {
|
||||
return respond(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
|
||||
}));
|
||||
if (respondCached(lookupErr)) {
|
||||
return;
|
||||
}
|
||||
return callback(ipv4Error || ipv6Error || lookupErr);
|
||||
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 || [];
|
||||
}
|
||||
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);
|
||||
});
|
||||
stream.on('readable', () => {
|
||||
let chunk;
|
||||
while ((chunk = stream.read()) !== null) {
|
||||
chunks.push(chunk);
|
||||
chunklen += chunk.length;
|
||||
}
|
||||
});
|
||||
stream.on('end', () => {
|
||||
if (responded) {
|
||||
return;
|
||||
// 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);
|
||||
}
|
||||
responded = true;
|
||||
let value;
|
||||
try {
|
||||
value = Buffer.concat(chunks, chunklen);
|
||||
}
|
||||
catch (E) {
|
||||
return callback(E);
|
||||
return respond(E);
|
||||
}
|
||||
callback(null, value);
|
||||
respond(null, value);
|
||||
});
|
||||
}
|
||||
/**
|
||||
|
||||
+3
-1
@@ -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 {
|
||||
|
||||
+85
-65
@@ -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,44 +112,19 @@ function httpProxyClient(proxyUrl, destinationPort, destinationHost, tlsOptions,
|
||||
// ignore
|
||||
}
|
||||
done(err);
|
||||
};
|
||||
const timeoutErr = () => {
|
||||
const err = new Error('Proxy socket timed out');
|
||||
err.code = 'ETIMEDOUT';
|
||||
tempSocketErr(err);
|
||||
};
|
||||
socket = connect(connectOptions, () => {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
const reqHeaders = {
|
||||
Host: destinationHost + ':' + destinationPort,
|
||||
Connection: 'close'
|
||||
};
|
||||
if (proxy.auth) {
|
||||
reqHeaders['Proxy-Authorization'] = 'Basic ' + Buffer.from(proxy.auth).toString('base64');
|
||||
function onEarlyClose() {
|
||||
const err = new Error('Proxy closed the connection before the tunnel was established');
|
||||
err.code = errors.EPROXY;
|
||||
fail(err);
|
||||
}
|
||||
socket.write(
|
||||
// HTTP method
|
||||
'CONNECT ' +
|
||||
destinationHost +
|
||||
':' +
|
||||
destinationPort +
|
||||
' HTTP/1.1\r\n' +
|
||||
// HTTP request headers
|
||||
Object.keys(reqHeaders)
|
||||
.map(key => key + ': ' + reqHeaders[key])
|
||||
.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;
|
||||
function onSocketData(chunk) {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
@@ -142,47 +133,76 @@ function httpProxyClient(proxyUrl, destinationPort, destinationHost, tlsOptions,
|
||||
chunks.push(chunk);
|
||||
received += chunk.length;
|
||||
tail = window.slice(-3);
|
||||
if (windowEnd >= 0) {
|
||||
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).toString('binary');
|
||||
const headers = response.substr(0, headerEnd);
|
||||
const remainder = response.substr(headerEnd + 4);
|
||||
if (remainder) {
|
||||
socket.unshift(Buffer.from(remainder, 'binary'));
|
||||
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;
|
||||
// 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);
|
||||
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: authority,
|
||||
Connection: 'close'
|
||||
};
|
||||
if (proxy.auth) {
|
||||
reqHeaders['Proxy-Authorization'] = 'Basic ' + Buffer.from(proxy.auth).toString('base64');
|
||||
}
|
||||
socket.write(
|
||||
// HTTP method
|
||||
'CONNECT ' +
|
||||
authority +
|
||||
' HTTP/1.1\r\n' +
|
||||
// HTTP request headers
|
||||
Object.keys(reqHeaders)
|
||||
.map(key => key + ': ' + reqHeaders[key])
|
||||
.join('\r\n') +
|
||||
// End request
|
||||
'\r\n\r\n');
|
||||
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;
|
||||
|
||||
+7
-1
@@ -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
|
||||
|
||||
+338
-126
@@ -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;
|
||||
_startPhase(timeout, err) {
|
||||
this._clearPhase();
|
||||
this._phaseTimer = setTimeout(() => {
|
||||
this._phaseTimer = false;
|
||||
this._onError(err, 'ETIMEDOUT', false, 'CONN');
|
||||
}, timeout);
|
||||
}
|
||||
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();
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
catch (_E) {
|
||||
// ignore
|
||||
}
|
||||
this._socket = null;
|
||||
}
|
||||
// Update host and retry
|
||||
this._connectOpts.host = nextHost;
|
||||
this._connectToHost(this._connectOpts, this.secureConnection);
|
||||
/** @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');
|
||||
// keepalive also covers sockets handed over by a proxy or by the caller
|
||||
if (typeof socket.setKeepAlive === 'function') {
|
||||
socket.setKeepAlive(true, KEEPALIVE_DELAY);
|
||||
}
|
||||
}, this.options.greetingTimeout || GREETING_TIMEOUT);
|
||||
// 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._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;
|
||||
}
|
||||
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');
|
||||
}
|
||||
else {
|
||||
/**
|
||||
* 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 reply = deferred?.response ?? str;
|
||||
err = this._formatError("Can't send mail - all recipients were rejected", 'EENVELOPE', reply, 'RCPT TO');
|
||||
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 callback(err);
|
||||
}
|
||||
}
|
||||
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());
|
||||
}
|
||||
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)) {
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
+7
-1
@@ -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) {
|
||||
|
||||
+97
-8
@@ -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(() => {
|
||||
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;
|
||||
}
|
||||
else {
|
||||
this.pool._checkRateLimit(() => {
|
||||
this.available = true;
|
||||
this._startIdleTimer();
|
||||
this.emit('available');
|
||||
});
|
||||
}
|
||||
});
|
||||
callback(null, info);
|
||||
});
|
||||
/** @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();
|
||||
}
|
||||
|
||||
+8
-2
@@ -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 = () => {
|
||||
|
||||
+5
-1
@@ -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;
|
||||
}
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
+80
-41
@@ -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();
|
||||
}
|
||||
}
|
||||
+33
-19
@@ -32,34 +32,47 @@ 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();
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Ends the output with an error and releases the cache file
|
||||
*/
|
||||
fail(err) {
|
||||
this.hasErrored = true;
|
||||
this.cleanup();
|
||||
output.emit('error', err);
|
||||
});
|
||||
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() {
|
||||
if (this.hasErrored) {
|
||||
return;
|
||||
}
|
||||
while (!this.hasErrored) {
|
||||
if (this.readPos >= this.chunks.length) {
|
||||
if (!this.cache) {
|
||||
this.output.end();
|
||||
@@ -74,9 +87,12 @@ class DKIMSigner {
|
||||
});
|
||||
return;
|
||||
}
|
||||
setImmediate(() => this.sendNextChunk());
|
||||
}
|
||||
}
|
||||
sendSignedOutput() {
|
||||
if (this.hasErrored) {
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
|
||||
+2
-2
@@ -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) {
|
||||
|
||||
+17
@@ -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
|
||||
|
||||
+10
@@ -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';
|
||||
+18
-11
@@ -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;
|
||||
|
||||
+23
-3
@@ -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) {
|
||||
|
||||
+23
-7
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -90,6 +90,11 @@ export default class MailMessage<T = SentMessageInfo> {
|
||||
[key: string]: any;
|
||||
}, key: string | number, options?: ResolveContentOptions | false): Promise<any>;
|
||||
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;
|
||||
|
||||
+20
@@ -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();
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+144
-84
@@ -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);
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
outputStream = outputStream.pipe(transform);
|
||||
// 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);
|
||||
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();
|
||||
}
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
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 (aborted()) {
|
||||
return;
|
||||
}
|
||||
if (typeof this.content.pipe === 'function') {
|
||||
this.content.removeListener('error', this._contentErrorHandler);
|
||||
this._contentErrorHandler = err => callback(err);
|
||||
this.content.once('error', this._contentErrorHandler);
|
||||
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)) {
|
||||
contentStream = new (transferEncoding === 'base64' ? base64 : qp).Encoder(options);
|
||||
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
|
||||
});
|
||||
contentStream.once('end', finalize);
|
||||
contentStream.once('error', err => callback(err));
|
||||
localStream = this._getStream(this.content);
|
||||
localStream.pipe(contentStream);
|
||||
// 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
|
||||
localStream = this._getStream(this.content);
|
||||
readInto(localStream);
|
||||
localStream.pipe(outputStream, {
|
||||
end: false
|
||||
});
|
||||
localStream.once('end', finalize);
|
||||
finished(localStream, { writable: false }, err => (err ? callback(err) : 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 => {
|
||||
readInto(sourceStream);
|
||||
shared.resolveStream(sourceStream, (err, value) => {
|
||||
if (err) {
|
||||
return callback(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);
|
||||
this.content._resolvedValue = value;
|
||||
setImmediate(createStream);
|
||||
});
|
||||
}
|
||||
else {
|
||||
setImmediate(createStream);
|
||||
}
|
||||
return;
|
||||
}
|
||||
return setImmediate(finalize);
|
||||
};
|
||||
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.
|
||||
*
|
||||
|
||||
+12
-9
@@ -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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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/";
|
||||
+1
-1
@@ -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/';
|
||||
+2
@@ -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;
|
||||
|
||||
+102
-32
@@ -16,23 +16,52 @@ const QP_RANGES = [
|
||||
[0x20, 0x3c], // <SP>!"#$%&'()*+,-./0123456789:;
|
||||
[0x3e, 0x7e] // >?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}
|
||||
];
|
||||
export function encode(buffer) {
|
||||
if (typeof buffer === 'string') {
|
||||
buffer = Buffer.from(buffer, 'utf-8');
|
||||
// 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;
|
||||
}
|
||||
let result = '';
|
||||
let ord;
|
||||
for (let i = 0, len = buffer.length; i < len; i++) {
|
||||
ord = buffer[i];
|
||||
const HEX_DIGITS = Buffer.from('0123456789ABCDEF', 'latin1');
|
||||
const isWhitespace = (c) => c === 0x20 || c === 0x09;
|
||||
export function encode(buffer) {
|
||||
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;
|
||||
}
|
||||
// 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--;
|
||||
}
|
||||
else {
|
||||
qp = encode(chunk);
|
||||
this.outputBytes += qp.length;
|
||||
this.push(qp, 'ascii');
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
-14
@@ -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({
|
||||
|
||||
+32
-1
@@ -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;
|
||||
+163
-71
@@ -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;
|
||||
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,24 +134,59 @@ 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;
|
||||
}
|
||||
else {
|
||||
ipv4Addresses = addresses || [];
|
||||
}
|
||||
resolve(6, host, options, (err, addresses) => {
|
||||
if (err) {
|
||||
ipv6Error = err;
|
||||
}
|
||||
else {
|
||||
ipv6Addresses = addresses || [];
|
||||
let pending = 2;
|
||||
const onResolved = () => {
|
||||
if (--pending || responded) {
|
||||
return;
|
||||
}
|
||||
// Combine addresses: IPv4 first, then IPv6
|
||||
const allAddresses = ipv4Addresses.concat(ipv6Addresses);
|
||||
@@ -150,41 +198,23 @@ export const resolveHostname = (options, callback) => {
|
||||
value,
|
||||
expires: Date.now() + (options.dnsTtl || DNS_TTL)
|
||||
});
|
||||
return callback(null, formatDNSValue(value, {
|
||||
return respond(null, formatDNSValue(value, {
|
||||
servername,
|
||||
cached: false
|
||||
}));
|
||||
}
|
||||
// No addresses from resolve4/resolve6, try dns.lookup as fallback
|
||||
if (ipv4Error && ipv6Error) {
|
||||
if (ipv4Error && ipv6Error && respondCached(ipv4Error)) {
|
||||
// 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;
|
||||
}
|
||||
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
|
||||
}));
|
||||
if (respondCached(err)) {
|
||||
return;
|
||||
}
|
||||
return callback(err);
|
||||
return respond(err);
|
||||
}
|
||||
// Get all supported addresses from dns.lookup
|
||||
const supportedAddresses = addresses
|
||||
@@ -196,7 +226,7 @@ export const resolveHostname = (options, callback) => {
|
||||
}
|
||||
if (!supportedAddresses.length && cached) {
|
||||
// nothing was found, fallback to cached value
|
||||
return callback(null, formatDNSValue(cached.value, {
|
||||
return respond(null, formatDNSValue(cached.value, {
|
||||
servername,
|
||||
cached: true
|
||||
}));
|
||||
@@ -208,28 +238,35 @@ export const resolveHostname = (options, callback) => {
|
||||
value,
|
||||
expires: Date.now() + (options.dnsTtl || DNS_TTL)
|
||||
});
|
||||
return callback(null, formatDNSValue(value, {
|
||||
return respond(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
|
||||
}));
|
||||
if (respondCached(lookupErr)) {
|
||||
return;
|
||||
}
|
||||
return callback(ipv4Error || ipv6Error || lookupErr);
|
||||
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 || [];
|
||||
}
|
||||
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);
|
||||
});
|
||||
stream.on('readable', () => {
|
||||
let chunk;
|
||||
while ((chunk = stream.read()) !== null) {
|
||||
chunks.push(chunk);
|
||||
chunklen += chunk.length;
|
||||
}
|
||||
});
|
||||
stream.on('end', () => {
|
||||
if (responded) {
|
||||
return;
|
||||
// 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);
|
||||
}
|
||||
responded = true;
|
||||
let value;
|
||||
try {
|
||||
value = Buffer.concat(chunks, chunklen);
|
||||
}
|
||||
catch (E) {
|
||||
return callback(E);
|
||||
return respond(E);
|
||||
}
|
||||
callback(null, value);
|
||||
respond(null, value);
|
||||
});
|
||||
}
|
||||
/**
|
||||
|
||||
+3
-1
@@ -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 {
|
||||
|
||||
+85
-65
@@ -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,44 +74,19 @@ function httpProxyClient(proxyUrl, destinationPort, destinationHost, tlsOptions,
|
||||
// ignore
|
||||
}
|
||||
done(err);
|
||||
};
|
||||
const timeoutErr = () => {
|
||||
const err = new Error('Proxy socket timed out');
|
||||
err.code = 'ETIMEDOUT';
|
||||
tempSocketErr(err);
|
||||
};
|
||||
socket = connect(connectOptions, () => {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
const reqHeaders = {
|
||||
Host: destinationHost + ':' + destinationPort,
|
||||
Connection: 'close'
|
||||
};
|
||||
if (proxy.auth) {
|
||||
reqHeaders['Proxy-Authorization'] = 'Basic ' + Buffer.from(proxy.auth).toString('base64');
|
||||
function onEarlyClose() {
|
||||
const err = new Error('Proxy closed the connection before the tunnel was established');
|
||||
err.code = errors.EPROXY;
|
||||
fail(err);
|
||||
}
|
||||
socket.write(
|
||||
// HTTP method
|
||||
'CONNECT ' +
|
||||
destinationHost +
|
||||
':' +
|
||||
destinationPort +
|
||||
' HTTP/1.1\r\n' +
|
||||
// HTTP request headers
|
||||
Object.keys(reqHeaders)
|
||||
.map(key => key + ': ' + reqHeaders[key])
|
||||
.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;
|
||||
function onSocketData(chunk) {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
@@ -104,46 +95,75 @@ function httpProxyClient(proxyUrl, destinationPort, destinationHost, tlsOptions,
|
||||
chunks.push(chunk);
|
||||
received += chunk.length;
|
||||
tail = window.slice(-3);
|
||||
if (windowEnd >= 0) {
|
||||
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).toString('binary');
|
||||
const headers = response.substr(0, headerEnd);
|
||||
const remainder = response.substr(headerEnd + 4);
|
||||
if (remainder) {
|
||||
socket.unshift(Buffer.from(remainder, 'binary'));
|
||||
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;
|
||||
// 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);
|
||||
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: authority,
|
||||
Connection: 'close'
|
||||
};
|
||||
if (proxy.auth) {
|
||||
reqHeaders['Proxy-Authorization'] = 'Basic ' + Buffer.from(proxy.auth).toString('base64');
|
||||
}
|
||||
socket.write(
|
||||
// HTTP method
|
||||
'CONNECT ' +
|
||||
authority +
|
||||
' HTTP/1.1\r\n' +
|
||||
// HTTP request headers
|
||||
Object.keys(reqHeaders)
|
||||
.map(key => key + ': ' + reqHeaders[key])
|
||||
.join('\r\n') +
|
||||
// End request
|
||||
'\r\n\r\n');
|
||||
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;
|
||||
+7
-1
@@ -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
|
||||
|
||||
+338
-126
@@ -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;
|
||||
_startPhase(timeout, err) {
|
||||
this._clearPhase();
|
||||
this._phaseTimer = setTimeout(() => {
|
||||
this._phaseTimer = false;
|
||||
this._onError(err, 'ETIMEDOUT', false, 'CONN');
|
||||
}, timeout);
|
||||
}
|
||||
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();
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
catch (_E) {
|
||||
// ignore
|
||||
}
|
||||
this._socket = null;
|
||||
}
|
||||
// Update host and retry
|
||||
this._connectOpts.host = nextHost;
|
||||
this._connectToHost(this._connectOpts, this.secureConnection);
|
||||
/** @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');
|
||||
// keepalive also covers sockets handed over by a proxy or by the caller
|
||||
if (typeof socket.setKeepAlive === 'function') {
|
||||
socket.setKeepAlive(true, KEEPALIVE_DELAY);
|
||||
}
|
||||
}, this.options.greetingTimeout || GREETING_TIMEOUT);
|
||||
// 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._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;
|
||||
}
|
||||
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');
|
||||
}
|
||||
else {
|
||||
/**
|
||||
* 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 reply = deferred?.response ?? str;
|
||||
err = this._formatError("Can't send mail - all recipients were rejected", 'EENVELOPE', reply, 'RCPT TO');
|
||||
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 callback(err);
|
||||
}
|
||||
}
|
||||
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());
|
||||
}
|
||||
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)) {
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
+7
-1
@@ -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) {
|
||||
|
||||
+97
-8
@@ -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(() => {
|
||||
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;
|
||||
}
|
||||
else {
|
||||
this.pool._checkRateLimit(() => {
|
||||
this.available = true;
|
||||
this._startIdleTimer();
|
||||
this.emit('available');
|
||||
});
|
||||
}
|
||||
});
|
||||
callback(null, info);
|
||||
});
|
||||
/** @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();
|
||||
}
|
||||
|
||||
+8
-2
@@ -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 = () => {
|
||||
|
||||
+2
-2
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user