nodemailer 10.0.15 → 10.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/cjs/addressparser/index.js +5 -1
  3. package/dist/cjs/base64/index.d.ts +3 -0
  4. package/dist/cjs/base64/index.js +80 -41
  5. package/dist/cjs/dkim/index.js +43 -29
  6. package/dist/cjs/dkim/message-parser.js +2 -2
  7. package/dist/cjs/errors.d.ts +17 -0
  8. package/dist/cjs/errors.js +12 -1
  9. package/dist/cjs/fetch/cookies.js +16 -9
  10. package/dist/cjs/fetch/index.js +23 -3
  11. package/dist/cjs/mailer/index.js +23 -7
  12. package/dist/cjs/mailer/mail-message.d.ts +5 -0
  13. package/dist/cjs/mailer/mail-message.js +20 -0
  14. package/dist/cjs/mime-node/index.d.ts +1 -1
  15. package/dist/cjs/mime-node/index.js +165 -105
  16. package/dist/cjs/nodemailer.js +12 -9
  17. package/dist/cjs/package-info.d.ts +1 -1
  18. package/dist/cjs/package-info.js +1 -1
  19. package/dist/cjs/qp/index.d.ts +2 -0
  20. package/dist/cjs/qp/index.js +103 -33
  21. package/dist/cjs/sendmail-transport/index.js +54 -14
  22. package/dist/cjs/shared/index.d.ts +32 -1
  23. package/dist/cjs/shared/index.js +212 -115
  24. package/dist/cjs/smtp-connection/http-proxy-client.d.ts +3 -1
  25. package/dist/cjs/smtp-connection/http-proxy-client.js +88 -68
  26. package/dist/cjs/smtp-connection/index.d.ts +7 -1
  27. package/dist/cjs/smtp-connection/index.js +348 -136
  28. package/dist/cjs/smtp-pool/index.d.ts +3 -0
  29. package/dist/cjs/smtp-pool/index.js +7 -1
  30. package/dist/cjs/smtp-pool/pool-resource.js +106 -17
  31. package/dist/cjs/smtp-transport/index.js +8 -2
  32. package/dist/esm/addressparser/index.js +5 -1
  33. package/dist/esm/base64/index.d.ts +3 -0
  34. package/dist/esm/base64/index.js +80 -41
  35. package/dist/esm/dkim/index.js +43 -29
  36. package/dist/esm/dkim/message-parser.js +2 -2
  37. package/dist/esm/errors.d.ts +17 -0
  38. package/dist/esm/errors.js +10 -0
  39. package/dist/esm/fetch/cookies.js +16 -9
  40. package/dist/esm/fetch/index.js +23 -3
  41. package/dist/esm/mailer/index.js +23 -7
  42. package/dist/esm/mailer/mail-message.d.ts +5 -0
  43. package/dist/esm/mailer/mail-message.js +20 -0
  44. package/dist/esm/mime-node/index.d.ts +1 -1
  45. package/dist/esm/mime-node/index.js +166 -106
  46. package/dist/esm/nodemailer.js +12 -9
  47. package/dist/esm/package-info.d.ts +1 -1
  48. package/dist/esm/package-info.js +1 -1
  49. package/dist/esm/qp/index.d.ts +2 -0
  50. package/dist/esm/qp/index.js +103 -33
  51. package/dist/esm/sendmail-transport/index.js +54 -14
  52. package/dist/esm/shared/index.d.ts +32 -1
  53. package/dist/esm/shared/index.js +207 -115
  54. package/dist/esm/smtp-connection/http-proxy-client.d.ts +3 -1
  55. package/dist/esm/smtp-connection/http-proxy-client.js +88 -68
  56. package/dist/esm/smtp-connection/index.d.ts +7 -1
  57. package/dist/esm/smtp-connection/index.js +348 -136
  58. package/dist/esm/smtp-pool/index.d.ts +3 -0
  59. package/dist/esm/smtp-pool/index.js +7 -1
  60. package/dist/esm/smtp-pool/pool-resource.js +106 -17
  61. package/dist/esm/smtp-transport/index.js +8 -2
  62. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## [10.1.0](https://github.com/nodemailer/nodemailer/compare/v10.0.16...v10.1.0) (2026-10-10)
4
+
5
+
6
+ ### Features
7
+
8
+ * harden message streams, SMTP connections and the connection pool ([8009da6](https://github.com/nodemailer/nodemailer/commit/8009da6e10bb8e10d7f0948a25d65895fdcd64f1))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * do not read the environment or the interface table on import ([094179d](https://github.com/nodemailer/nodemailer/commit/094179d176d2ebe7a28742411ad427d3c87a3981))
14
+ * end the SMTP session on any 421 reply and drive the envelope through one step ([5638ac1](https://github.com/nodemailer/nodemailer/commit/5638ac1914342460df270f736e7ceb0a844cf83a))
15
+ * keep ERR_ACCESS_DENIED from the Node.js permission model ([8b33a3d](https://github.com/nodemailer/nodemailer/commit/8b33a3d4fedee3431b97da7da12af954c3c8dceb))
16
+ * let the proxy handshake and the SMTP connection share one deadline ([91ed683](https://github.com/nodemailer/nodemailer/commit/91ed683f7a1e9ff8c56e24fb76facf16af08b162))
17
+ * **mime-node:** decode an SMTPUTF8 domain from its A-label form ([e436f4d](https://github.com/nodemailer/nodemailer/commit/e436f4dccbc8b0fb57b4747ebaafa5bb035c15cb))
18
+ * **mime-node:** finish an encoded part only after its output was read ([ec97d8f](https://github.com/nodemailer/nodemailer/commit/ec97d8f679a1d9d47bbebb03ea064f5fa6f58d01))
19
+ * **qp:** encode a lone CR or LF in binary quoted-printable parts ([42b91d9](https://github.com/nodemailer/nodemailer/commit/42b91d9a0fe18eea412c9774c5c142adf5b3efc7))
20
+ * **smtp-connection:** count the wait for the greeting against connectionTimeout ([bdcd046](https://github.com/nodemailer/nodemailer/commit/bdcd0462cab163eca3c34058d09b9981ab671c47))
21
+ * **smtp-connection:** drop the connection when DATA is taken after a refused sender ([f90fb04](https://github.com/nodemailer/nodemailer/commit/f90fb045bc24f96a128710791b2fa443e64f55fd))
22
+ * **smtp-connection:** refuse a second connect() call ([24ba2e0](https://github.com/nodemailer/nodemailer/commit/24ba2e0847ecb8202540f1c5e53483bd119ae4f2))
23
+ * turn Nagle off for SMTP sockets and report an early sendmail exit as such ([fa9b8d3](https://github.com/nodemailer/nodemailer/commit/fa9b8d300bf642cd9e11f6732ebb312c09868677))
24
+
25
+
26
+ ### Performance Improvements
27
+
28
+ * encode base64 and quoted-printable without per-chunk delays ([fd377bb](https://github.com/nodemailer/nodemailer/commit/fd377bbf559363860aedbedba6f107348fc152d2))
29
+
30
+ ## [10.0.16](https://github.com/nodemailer/nodemailer/compare/v10.0.15...v10.0.16) (2026-10-07)
31
+
32
+
33
+ ### Bug Fixes
34
+
35
+ * **addressparser:** keep an escaped parenthesis inside a comment ([c4ae20d](https://github.com/nodemailer/nodemailer/commit/c4ae20d074ac0510cc2d0cbd6039ee7fab3a4ca8))
36
+ * **fetch:** keep the case of cookie names ([#1888](https://github.com/nodemailer/nodemailer/issues/1888)) ([9f16eed](https://github.com/nodemailer/nodemailer/commit/9f16eedf8de0485e6d07969113db992ff70f89ce))
37
+ * **fetch:** read the cookie name-value pair by position ([645e97c](https://github.com/nodemailer/nodemailer/commit/645e97c8f0586404d9fb861c9527be7572c2c832))
38
+
3
39
  ## [10.0.15](https://github.com/nodemailer/nodemailer/compare/v10.0.14...v10.0.15) (2026-10-05)
4
40
 
5
41
 
@@ -670,7 +670,11 @@ class Tokenizer {
670
670
  this.escaped = false;
671
671
  return;
672
672
  }
673
- else if (['"', "'"].includes(this.operatorExpecting) && chr === '\\') {
673
+ else if (['"', "'", ')'].includes(this.operatorExpecting) && chr === '\\') {
674
+ // A backslash escapes the next character inside a quoted string and inside a
675
+ // comment alike (RFC 5322 3.2.2, ccontent includes quoted-pair). Honouring it
676
+ // only in quoted strings let an escaped parenthesis close the comment, which
677
+ // released the rest of it, an address included, into the header
674
678
  this.escaped = true;
675
679
  return;
676
680
  }
@@ -24,6 +24,9 @@ export interface EncoderOptions {
24
24
  /**
25
25
  * Creates a transform stream for encoding data to base64 encoding
26
26
  *
27
+ * The output is the same as `wrap(encode(input), lineLength)` no matter how the input is split
28
+ * into chunks: every line but the last one ends with a line break, the last one does not
29
+ *
27
30
  * @constructor
28
31
  * @param options Stream options
29
32
  * @param [options.lineLength=76] Maximum length for lines, set to false to disable wrapping
@@ -16,6 +16,47 @@ function encode(buffer) {
16
16
  }
17
17
  return buffer.toString('base64');
18
18
  }
19
+ /**
20
+ * Turns a line length option into a whole number of characters, the default for anything unusable
21
+ */
22
+ function normalizeLineLength(lineLength) {
23
+ const length = Math.floor(Number(lineLength));
24
+ return Number.isFinite(length) && length >= 1 ? length : 76;
25
+ }
26
+ /**
27
+ * Splits the bytes of `src` into lines of `lineLength` bytes, each followed by a line break. With
28
+ * `final` set the last line, which may be shorter, gets no line break; otherwise only complete
29
+ * lines are taken and the rest is left for the caller
30
+ *
31
+ * @param src Bytes to wrap
32
+ * @param lineLength Line length
33
+ * @param final Whether `src` ends the output
34
+ * @returns The wrapped bytes and the number of trailing bytes not taken
35
+ */
36
+ function wrapBuffer(src, lineLength, final) {
37
+ const lines = Math.ceil(src.length / lineLength);
38
+ // the last line waits for more data unless this is the end: whether it gets a line break
39
+ // depends on whether anything follows it
40
+ const complete = Math.max(lines - 1, 0);
41
+ let rest = src.length - complete * lineLength;
42
+ const output = Buffer.allocUnsafe(complete * (lineLength + 2) + (final ? rest : 0));
43
+ let to = 0;
44
+ for (let from = 0; from < complete * lineLength; from += lineLength) {
45
+ src.copy(output, to, from, from + lineLength);
46
+ to += lineLength;
47
+ output[to++] = 0x0d;
48
+ output[to++] = 0x0a;
49
+ }
50
+ if (final) {
51
+ to += src.copy(output, to, complete * lineLength);
52
+ rest = 0;
53
+ }
54
+ if (to !== output.length) {
55
+ // never hand out bytes of the unfilled allocation
56
+ throw new Error('Unexpected wrapped length');
57
+ }
58
+ return { output, rest };
59
+ }
19
60
  /**
20
61
  * Adds soft line breaks to a base64 string
21
62
  *
@@ -44,6 +85,9 @@ function wrap(str, lineLength) {
44
85
  /**
45
86
  * Creates a transform stream for encoding data to base64 encoding
46
87
  *
88
+ * The output is the same as `wrap(encode(input), lineLength)` no matter how the input is split
89
+ * into chunks: every line but the last one ends with a line break, the last one does not
90
+ *
47
91
  * @constructor
48
92
  * @param options Stream options
49
93
  * @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 {
53
97
  super();
54
98
  this.options = options || {};
55
99
  if (this.options.lineLength !== false) {
56
- this.options.lineLength = this.options.lineLength || 76;
100
+ this.options.lineLength = normalizeLineLength(this.options.lineLength);
57
101
  }
58
102
  this._curLine = '';
59
103
  this._remainingBytes = false;
60
104
  this.inputBytes = 0;
61
105
  this.outputBytes = 0;
62
106
  }
107
+ /**
108
+ * Emits the encoded characters `b64` that follow the current line, keeping what can not be
109
+ * emitted yet as the new current line
110
+ *
111
+ * @internal
112
+ */
113
+ _emit(b64, final) {
114
+ const src = Buffer.from(this._curLine + b64, 'latin1');
115
+ if (!src.length) {
116
+ return;
117
+ }
118
+ let output = src;
119
+ if (this.options.lineLength) {
120
+ const wrapped = wrapBuffer(src, this.options.lineLength, final);
121
+ output = wrapped.output;
122
+ this._curLine = wrapped.rest ? src.toString('latin1', src.length - wrapped.rest) : '';
123
+ }
124
+ else {
125
+ this._curLine = '';
126
+ }
127
+ if (output.length) {
128
+ this.outputBytes += output.length;
129
+ this.push(output);
130
+ }
131
+ }
63
132
  /** @internal */
64
133
  _transform(chunk, encoding, done) {
65
134
  let buf = encoding !== 'buffer' ? Buffer.from(chunk, encoding) : chunk;
66
135
  if (!buf || !buf.length) {
67
- setImmediate(done);
68
- return;
136
+ return done();
69
137
  }
70
138
  this.inputBytes += buf.length;
71
- if (this._remainingBytes && this._remainingBytes.length) {
139
+ if (this._remainingBytes) {
72
140
  buf = Buffer.concat([this._remainingBytes, buf], this._remainingBytes.length + buf.length);
73
141
  this._remainingBytes = false;
74
142
  }
75
- if (buf.length % 3) {
76
- this._remainingBytes = buf.slice(buf.length - (buf.length % 3));
77
- buf = buf.slice(0, buf.length - (buf.length % 3));
78
- }
79
- else {
80
- this._remainingBytes = false;
81
- }
82
- let b64 = this._curLine + encode(buf);
83
- if (this.options.lineLength) {
84
- b64 = wrap(b64, this.options.lineLength);
85
- // remove last line as it is still most probably incomplete
86
- const lastLF = b64.lastIndexOf('\n');
87
- if (lastLF < 0) {
88
- this._curLine = b64;
89
- b64 = '';
90
- }
91
- else if (lastLF === b64.length - 1) {
92
- this._curLine = '';
93
- }
94
- else {
95
- this._curLine = b64.substring(lastLF + 1);
96
- b64 = b64.substring(0, lastLF + 1);
97
- }
98
- }
99
- if (b64) {
100
- this.outputBytes += b64.length;
101
- this.push(Buffer.from(b64, 'ascii'));
143
+ const extra = buf.length % 3;
144
+ if (extra) {
145
+ this._remainingBytes = buf.subarray(buf.length - extra);
146
+ buf = buf.subarray(0, buf.length - extra);
102
147
  }
103
- setImmediate(done);
148
+ this._emit(encode(buf), false);
149
+ done();
104
150
  }
105
151
  /** @internal */
106
152
  _flush(done) {
107
- if (this._remainingBytes && this._remainingBytes.length) {
108
- this._curLine += encode(this._remainingBytes);
109
- }
110
- if (this._curLine) {
111
- this._curLine = wrap(this._curLine, this.options.lineLength);
112
- this.outputBytes += this._curLine.length;
113
- this.push(Buffer.from(this._curLine, 'ascii'));
114
- this._curLine = '';
115
- }
153
+ this._emit(this._remainingBytes ? encode(this._remainingBytes) : '', true);
154
+ this._remainingBytes = false;
116
155
  done();
117
156
  }
118
157
  }
@@ -37,51 +37,67 @@ class DKIMSigner {
37
37
  this.output = output;
38
38
  this.output.usingCache = false;
39
39
  this.hasErrored = false;
40
- this.input.on('error', err => {
40
+ this.input.on('error', err => this.fail(err));
41
+ // A consumer that goes away before the signed message was read in full destroys the
42
+ // output. Stop reading the input and drop the cache file instead of leaving them open
43
+ this.output.once('close', () => {
44
+ if (this.output.writableFinished) {
45
+ return;
46
+ }
41
47
  this.hasErrored = true;
48
+ this.input.unpipe();
49
+ this.input.destroy();
42
50
  this.cleanup();
43
- output.emit('error', err);
44
51
  });
45
52
  }
53
+ /**
54
+ * Ends the output with an error and releases the cache file
55
+ */
56
+ fail(err) {
57
+ this.hasErrored = true;
58
+ this.cleanup();
59
+ this.output.destroy(err);
60
+ }
46
61
  cleanup() {
47
62
  if (!this.cache || !this.cachePath) {
48
63
  return;
49
64
  }
65
+ const cache = this.cache;
66
+ this.cache = false;
67
+ cache.destroy();
50
68
  node_fs_1.default.unlink(this.cachePath, () => false);
51
69
  }
52
70
  createReadCache() {
53
71
  // pipe remainings to cache file
54
72
  this.cache = node_fs_1.default.createReadStream(this.cachePath);
55
- this.cache.once('error', err => {
56
- this.cleanup();
57
- this.output.emit('error', err);
58
- });
73
+ this.cache.once('error', err => this.fail(err));
59
74
  this.cache.once('close', () => {
60
75
  this.cleanup();
61
76
  });
62
77
  this.cache.pipe(this.output);
63
78
  }
64
79
  sendNextChunk() {
65
- if (this.hasErrored) {
66
- return;
67
- }
68
- if (this.readPos >= this.chunks.length) {
69
- if (!this.cache) {
70
- this.output.end();
80
+ while (!this.hasErrored) {
81
+ if (this.readPos >= this.chunks.length) {
82
+ if (!this.cache) {
83
+ this.output.end();
84
+ return;
85
+ }
86
+ return this.createReadCache();
87
+ }
88
+ const chunk = this.chunks[this.readPos++];
89
+ if (this.output.write(chunk) === false) {
90
+ this.output.once('drain', () => {
91
+ this.sendNextChunk();
92
+ });
71
93
  return;
72
94
  }
73
- return this.createReadCache();
74
- }
75
- const chunk = this.chunks[this.readPos++];
76
- if (this.output.write(chunk) === false) {
77
- this.output.once('drain', () => {
78
- this.sendNextChunk();
79
- });
80
- return;
81
95
  }
82
- setImmediate(() => this.sendNextChunk());
83
96
  }
84
97
  sendSignedOutput() {
98
+ if (this.hasErrored) {
99
+ return;
100
+ }
85
101
  let keyPos = 0;
86
102
  const signNextKey = () => {
87
103
  if (keyPos >= this.keys.length) {
@@ -101,9 +117,7 @@ class DKIMSigner {
101
117
  });
102
118
  }
103
119
  catch (err) {
104
- this.hasErrored = true;
105
- this.cleanup();
106
- this.output.emit('error', err);
120
+ this.fail(err);
107
121
  return;
108
122
  }
109
123
  if (dkimField) {
@@ -122,7 +136,6 @@ class DKIMSigner {
122
136
  // pipe remainings to cache file
123
137
  this.cache = node_fs_1.default.createWriteStream(this.cachePath);
124
138
  this.cache.once('error', err => {
125
- this.cleanup();
126
139
  // drain input
127
140
  this.relaxedBody.unpipe(this.cache);
128
141
  this.relaxedBody.on('readable', () => {
@@ -130,11 +143,12 @@ class DKIMSigner {
130
143
  // do nothing
131
144
  }
132
145
  });
133
- this.hasErrored = true;
134
- // emit error
135
- this.output.emit('error', err);
146
+ this.fail(err);
136
147
  });
137
148
  this.cache.once('close', () => {
149
+ if (this.hasErrored) {
150
+ return;
151
+ }
138
152
  this.sendSignedOutput();
139
153
  });
140
154
  this.relaxedBody.removeAllListeners('readable');
@@ -211,7 +225,7 @@ class DKIM {
211
225
  catch (_E) {
212
226
  // the body hash is created here, an unknown hashAlgo throws inside this timer
213
227
  // where nothing else could catch it
214
- output.emit('error', sign_js_1.default.unsupportedHashAlgoError(signer.hashAlgo));
228
+ signer.fail(sign_js_1.default.unsupportedHashAlgoError(signer.hashAlgo));
215
229
  return;
216
230
  }
217
231
  if (writeValue) {
@@ -107,7 +107,7 @@ class MessageParser extends node_stream_1.Transform {
107
107
  const chunk = data.slice(headerPos);
108
108
  this.bodySize += chunk.length;
109
109
  // this would be the first chunk of data sent downstream
110
- setImmediate(() => this.push(chunk));
110
+ this.push(chunk);
111
111
  }
112
112
  return false;
113
113
  }
@@ -136,7 +136,7 @@ class MessageParser extends node_stream_1.Transform {
136
136
  this.bodySize += chunk.length;
137
137
  this.push(chunk);
138
138
  }
139
- setImmediate(callback);
139
+ callback();
140
140
  }
141
141
  /** @internal */
142
142
  _flush(callback) {
@@ -61,6 +61,19 @@ export declare const EPROXY = "EPROXY";
61
61
  export declare const EFILEACCESS = "EFILEACCESS";
62
62
  export declare const EURLACCESS = "EURLACCESS";
63
63
  export declare const EFETCH = "EFETCH";
64
+ /**
65
+ * Tells whether the error ended the connection rather than rejected what was sent over it
66
+ */
67
+ export declare const isTransientError: (err: {
68
+ code?: string | undefined;
69
+ responseCode?: number | undefined;
70
+ }) => boolean;
71
+ /**
72
+ * Code Node.js sets on an error when its permission model (`--permission`) denies an
73
+ * operation. It is not replaced with the Nodemailer code of the failing step (`ESOCKET`,
74
+ * `EDNS`, `EFETCH`, ...), so a missing grant such as `--allow-net` stays recognizable.
75
+ */
76
+ export declare const ERR_ACCESS_DENIED = "ERR_ACCESS_DENIED";
64
77
  /**
65
78
  * An Error together with the properties Nodemailer attaches to the errors it
66
79
  * hands to callers. Every property is optional, the set that is present
@@ -84,6 +97,10 @@ export interface NodemailerError extends NodeJS.ErrnoException {
84
97
  rejected?: string[] | undefined;
85
98
  /** Per-recipient errors for the rejected addresses */
86
99
  rejectedErrors?: NodemailerError[] | undefined;
100
+ /** The code the error had before Nodemailer set its own, such as ECONNREFUSED for an ESOCKET error */
101
+ originalCode?: string | undefined;
102
+ /** Which wait an ETIMEDOUT error ended: connecting, the greeting, a TLS upgrade or an idle socket */
103
+ timeoutType?: 'CONNECT_TIMEOUT' | 'GREETING_TIMEOUT' | 'UPGRADE_TIMEOUT' | 'SOCKET_TIMEOUT' | undefined;
87
104
  }
88
105
  /**
89
106
  * Node style callback: called with an error, or with null and the result
@@ -10,7 +10,7 @@
10
10
  * err.code = errors.ECONNECTION;
11
11
  */
12
12
  Object.defineProperty(exports, "__esModule", { value: true });
13
- 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;
13
+ 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;
14
14
  /**
15
15
  * Error code descriptions for documentation and debugging
16
16
  */
@@ -68,3 +68,14 @@ exports.EPROXY = 'EPROXY';
68
68
  exports.EFILEACCESS = 'EFILEACCESS';
69
69
  exports.EURLACCESS = 'EURLACCESS';
70
70
  exports.EFETCH = 'EFETCH';
71
+ /**
72
+ * Tells whether the error ended the connection rather than rejected what was sent over it
73
+ */
74
+ const isTransientError = (err) => err.responseCode === 421 || err.code === exports.ECONNECTION || err.code === exports.ESOCKET || err.code === exports.ETIMEDOUT;
75
+ exports.isTransientError = isTransientError;
76
+ /**
77
+ * Code Node.js sets on an error when its permission model (`--permission`) denies an
78
+ * operation. It is not replaced with the Nodemailer code of the failing step (`ESOCKET`,
79
+ * `EDNS`, `EFETCH`, ...), so a missing grant such as `--allow-net` stays recognizable.
80
+ */
81
+ exports.ERR_ACCESS_DENIED = 'ERR_ACCESS_DENIED';
@@ -130,19 +130,31 @@ class Cookies {
130
130
  */
131
131
  parse(cookieStr) {
132
132
  const cookie = {};
133
+ let hasNameValue = false;
133
134
  (cookieStr || '')
134
135
  .toString()
135
136
  .split(';')
136
137
  .forEach(cookiePart => {
138
+ if (!cookiePart.trim()) {
139
+ // skip empty parts
140
+ return;
141
+ }
137
142
  const valueParts = cookiePart.split('=');
138
- const key = valueParts.shift().trim().toLowerCase();
143
+ const name = valueParts.shift().trim();
139
144
  let value = valueParts.join('=').trim();
140
145
  let domain;
141
- if (!key) {
142
- // skip empty parts
146
+ // the first part is always the name-value pair, so a cookie named
147
+ // like an attribute is not mistaken for one (RFC 6265 section 5.2)
148
+ if (!hasNameValue) {
149
+ hasNameValue = true;
150
+ if (name) {
151
+ // cookie names are case-sensitive, only attribute names are not
152
+ cookie.name = name;
153
+ cookie.value = value;
154
+ }
143
155
  return;
144
156
  }
145
- switch (key) {
157
+ switch (name.toLowerCase()) {
146
158
  case 'expires': {
147
159
  const expires = new Date(value);
148
160
  // ignore date if can not parse it
@@ -170,11 +182,6 @@ class Cookies {
170
182
  case 'httponly':
171
183
  cookie.httponly = true;
172
184
  break;
173
- default:
174
- if (!cookie.name) {
175
- cookie.name = key;
176
- cookie.value = value;
177
- }
178
185
  }
179
186
  });
180
187
  return cookie;
@@ -81,6 +81,17 @@ const TLS_OPTION_KEYS = [
81
81
  'sessionIdContext',
82
82
  'sigalgs'
83
83
  ];
84
+ /**
85
+ * Marks an error of the request as a fetch error, unless it is a permission model
86
+ * denial, which keeps its own code (see ERR_ACCESS_DENIED)
87
+ *
88
+ * @param err Error to mark
89
+ */
90
+ function setFetchCode(err) {
91
+ if (err.code !== errors.ERR_ACCESS_DENIED) {
92
+ err.code = errors.EFETCH;
93
+ }
94
+ }
84
95
  /**
85
96
  * Resolves a URL only if it is one this module is willing to request.
86
97
  *
@@ -177,7 +188,7 @@ function nmfetch(url, options) {
177
188
  return;
178
189
  }
179
190
  finished = true;
180
- err.code = errors.EFETCH;
191
+ setFetchCode(err);
181
192
  err.sourceUrl = url;
182
193
  fetchRes.emit('error', err);
183
194
  });
@@ -250,7 +261,7 @@ function nmfetch(url, options) {
250
261
  catch (E) {
251
262
  finished = true;
252
263
  setImmediate(() => {
253
- E.code = errors.EFETCH;
264
+ setFetchCode(E);
254
265
  E.sourceUrl = url;
255
266
  fetchRes.emit('error', E);
256
267
  });
@@ -262,7 +273,7 @@ function nmfetch(url, options) {
262
273
  return;
263
274
  }
264
275
  finished = true;
265
- err.code = errors.EFETCH;
276
+ setFetchCode(err);
266
277
  err.sourceUrl = sourceUrl;
267
278
  fetchRes.emit('error', err);
268
279
  req.abort();
@@ -272,6 +283,15 @@ function nmfetch(url, options) {
272
283
  req.setTimeout(timeout, () => fail(new Error('Request Timeout')));
273
284
  }
274
285
  req.on('error', (err) => fail(err));
286
+ // a consumer that destroys the response stream before it ended does not want the rest of
287
+ // the body, release the request now instead of when the timeout fires
288
+ fetchRes.once('close', () => {
289
+ if (finished || fetchRes.readableEnded) {
290
+ return;
291
+ }
292
+ finished = true;
293
+ req.destroy();
294
+ });
275
295
  req.on('response', res => {
276
296
  let inflate;
277
297
  if (finished) {
@@ -157,6 +157,11 @@ class Mail extends node_events_1.EventEmitter {
157
157
  this.getSocket = false;
158
158
  }
159
159
  const mail = new mail_message_js_1.default(this, data);
160
+ // a failed message is not going to be read anymore, release its content streams
161
+ const fail = (err) => {
162
+ mail.releaseStreams();
163
+ done(err);
164
+ };
160
165
  this.logger.debug({
161
166
  tnx: 'transport',
162
167
  name: this.transporter.name,
@@ -170,7 +175,7 @@ class Mail extends node_events_1.EventEmitter {
170
175
  tnx: 'plugin',
171
176
  action: 'compile'
172
177
  }, 'PluginCompile Error: %s', err.message);
173
- return done(err);
178
+ return fail(err);
174
179
  }
175
180
  let recipientCount;
176
181
  try {
@@ -187,7 +192,7 @@ class Mail extends node_events_1.EventEmitter {
187
192
  tnx: 'transport',
188
193
  action: 'send'
189
194
  }, 'Compile Error: %s', err.message);
190
- return done(err);
195
+ return fail(err);
191
196
  }
192
197
  const maxRecipients = mail.data.maxRecipients === undefined ? DEFAULT_MAX_RECIPIENTS : mail.data.maxRecipients;
193
198
  if (maxRecipients && recipientCount > maxRecipients) {
@@ -198,7 +203,7 @@ class Mail extends node_events_1.EventEmitter {
198
203
  tnx: 'transport',
199
204
  action: 'send'
200
205
  }, 'Send Error: %s', err.message);
201
- return done(err);
206
+ return fail(err);
202
207
  }
203
208
  this._processPlugins('stream', mail, err => {
204
209
  if (err) {
@@ -207,7 +212,7 @@ class Mail extends node_events_1.EventEmitter {
207
212
  tnx: 'plugin',
208
213
  action: 'stream'
209
214
  }, 'PluginStream Error: %s', err.message);
210
- return done(err);
215
+ return fail(err);
211
216
  }
212
217
  if (mail.data.dkim || this.dkim) {
213
218
  mail.message.processFunc(input => {
@@ -222,6 +227,7 @@ class Mail extends node_events_1.EventEmitter {
222
227
  }
223
228
  this.transporter.send(mail, (...args) => {
224
229
  if (args[0]) {
230
+ mail.releaseStreams();
225
231
  this.logger.error({
226
232
  err: args[0],
227
233
  tnx: 'transport',
@@ -289,6 +295,11 @@ class Mail extends node_events_1.EventEmitter {
289
295
  // setup socket handler for the mailer object
290
296
  this.getSocket = (options, callback) => {
291
297
  const protocol = proxy.protocol.replace(/:$/, '').toLowerCase();
298
+ // The proxy handshake is a part of connecting and draws from the same deadline as the
299
+ // SMTP connection that follows it: whatever the proxy takes, the connection does not get
300
+ const configuredTimeout = Number(this.options.connectionTimeout) || 0;
301
+ const connectStartedAt = Number(options.connectStartedAt) || Date.now();
302
+ const connectionTimeout = configuredTimeout ? Math.max(connectStartedAt + configuredTimeout - Date.now(), 1) : undefined;
292
303
  if (this.meta.has('proxy_handler_' + protocol)) {
293
304
  return this.meta.get('proxy_handler_' + protocol)(proxy, options, callback);
294
305
  }
@@ -296,7 +307,7 @@ class Mail extends node_events_1.EventEmitter {
296
307
  // Connect using a HTTP CONNECT method
297
308
  case 'http':
298
309
  case 'https':
299
- (0, http_proxy_client_js_1.default)(proxy.href, options.port, options.host, this.options.tls || {}, (err, socket) => {
310
+ (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) => {
300
311
  if (err) {
301
312
  return callback(err);
302
313
  }
@@ -330,6 +341,9 @@ class Mail extends node_events_1.EventEmitter {
330
341
  },
331
342
  command: 'connect'
332
343
  };
344
+ if (connectionTimeout) {
345
+ connectionOpts.timeout = connectionTimeout;
346
+ }
333
347
  if (proxy.username || proxy.password) {
334
348
  const username = proxy.username || '';
335
349
  const password = proxy.password || '';
@@ -359,11 +373,13 @@ class Mail extends node_events_1.EventEmitter {
359
373
  if (node_net_1.default.isIP(proxy.hostname)) {
360
374
  return connect(proxy.hostname);
361
375
  }
362
- return node_dns_1.default.resolve(proxy.hostname, (err, address) => {
376
+ // lookup goes through the hosts file and returns an IPv6 address as well,
377
+ // the same way a connection to the proxy host would be resolved otherwise
378
+ return node_dns_1.default.lookup(proxy.hostname, (err, address) => {
363
379
  if (err) {
364
380
  return callback(err);
365
381
  }
366
- connect(Array.isArray(address) ? address[0] : address);
382
+ connect(address);
367
383
  });
368
384
  }
369
385
  }
@@ -90,6 +90,11 @@ export default class MailMessage<T = SentMessageInfo> {
90
90
  [key: string]: any;
91
91
  }, key: string | number, options?: ResolveContentOptions | false): Promise<any>;
92
92
  resolveAll(callback: MailMessageDataCallback): void;
93
+ /**
94
+ * Destroys the content streams of the message once sending failed. A stream that was not
95
+ * read to the end would otherwise keep the file or the socket behind it open
96
+ */
97
+ releaseStreams(): void;
93
98
  normalize(callback: MailMessageDataCallback): void;
94
99
  setMailerHeader(): void;
95
100
  setPriorityHeaders(): void;