nodemailer 10.0.11 → 10.0.13
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.
- package/CHANGELOG.md +15 -0
- package/dist/cjs/addressparser/index.js +97 -0
- package/dist/cjs/dkim/index.js +25 -8
- package/dist/cjs/dkim/sign.d.ts +2 -0
- package/dist/cjs/dkim/sign.js +17 -1
- package/dist/cjs/fetch/index.d.ts +4 -1
- package/dist/cjs/fetch/index.js +34 -59
- package/dist/cjs/package-info.d.ts +1 -1
- package/dist/cjs/package-info.js +1 -1
- package/dist/cjs/smtp-connection/data-stream.d.ts +2 -1
- package/dist/cjs/smtp-connection/data-stream.js +32 -24
- package/dist/cjs/smtp-connection/index.d.ts +5 -5
- package/dist/cjs/smtp-connection/index.js +78 -24
- package/dist/cjs/smtp-pool/index.d.ts +2 -1
- package/dist/cjs/smtp-pool/index.js +31 -5
- package/dist/cjs/smtp-pool/pool-resource.js +27 -29
- package/dist/cjs/smtp-transport/index.js +17 -27
- package/dist/cjs/xoauth2/index.d.ts +2 -0
- package/dist/cjs/xoauth2/index.js +11 -3
- package/dist/esm/addressparser/index.js +97 -0
- package/dist/esm/dkim/index.js +25 -8
- package/dist/esm/dkim/sign.d.ts +2 -0
- package/dist/esm/dkim/sign.js +17 -1
- package/dist/esm/fetch/index.d.ts +4 -1
- package/dist/esm/fetch/index.js +34 -59
- package/dist/esm/package-info.d.ts +1 -1
- package/dist/esm/package-info.js +1 -1
- package/dist/esm/smtp-connection/data-stream.d.ts +2 -1
- package/dist/esm/smtp-connection/data-stream.js +32 -24
- package/dist/esm/smtp-connection/index.d.ts +5 -5
- package/dist/esm/smtp-connection/index.js +78 -24
- package/dist/esm/smtp-pool/index.d.ts +2 -1
- package/dist/esm/smtp-pool/index.js +31 -5
- package/dist/esm/smtp-pool/pool-resource.js +27 -29
- package/dist/esm/smtp-transport/index.js +17 -27
- package/dist/esm/xoauth2/index.d.ts +2 -0
- package/dist/esm/xoauth2/index.js +11 -3
- package/package.json +7 -7
package/dist/esm/dkim/index.js
CHANGED
|
@@ -85,13 +85,22 @@ class DKIMSigner {
|
|
|
85
85
|
return;
|
|
86
86
|
}
|
|
87
87
|
const key = this.keys[keyPos++];
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
88
|
+
let dkimField;
|
|
89
|
+
try {
|
|
90
|
+
dkimField = sign(this.headers, this.hashAlgo, this.bodyHash, {
|
|
91
|
+
domainName: key.domainName,
|
|
92
|
+
keySelector: key.keySelector,
|
|
93
|
+
privateKey: key.privateKey,
|
|
94
|
+
headerFieldNames: this.options.headerFieldNames,
|
|
95
|
+
skipFields: this.options.skipFields
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
this.hasErrored = true;
|
|
100
|
+
this.cleanup();
|
|
101
|
+
this.output.emit('error', err);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
95
104
|
if (dkimField) {
|
|
96
105
|
this.output.write(Buffer.from(dkimField + '\r\n'));
|
|
97
106
|
}
|
|
@@ -191,7 +200,15 @@ class DKIM {
|
|
|
191
200
|
}
|
|
192
201
|
const signer = new DKIMSigner(options, this.keys, inputStream, output);
|
|
193
202
|
setImmediate(() => {
|
|
194
|
-
|
|
203
|
+
try {
|
|
204
|
+
signer.signStream();
|
|
205
|
+
}
|
|
206
|
+
catch (_E) {
|
|
207
|
+
// the body hash is created here, an unknown hashAlgo throws inside this timer
|
|
208
|
+
// where nothing else could catch it
|
|
209
|
+
output.emit('error', sign.unsupportedHashAlgoError(signer.hashAlgo));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
195
212
|
if (writeValue) {
|
|
196
213
|
setImmediate(() => {
|
|
197
214
|
inputStream.end(writeValue);
|
package/dist/esm/dkim/sign.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
|
+
import type { NodemailerError } from '../errors.js';
|
|
2
3
|
import type { MessageParserHeaderLine } from './message-parser.js';
|
|
3
4
|
/**
|
|
4
5
|
* Private key accepted by crypto.Sign#sign: a PEM string, a Buffer, a KeyObject or an
|
|
@@ -45,5 +46,6 @@ export interface DKIMRelaxedHeaders {
|
|
|
45
46
|
declare function sign(headers: MessageParserHeaderLine[], hashAlgo: string, bodyHash: string, options?: DKIMSignOptions): string | false;
|
|
46
47
|
declare namespace sign {
|
|
47
48
|
var relaxedHeaders: (headers: MessageParserHeaderLine[], fieldNames?: string, skipFields?: string) => DKIMRelaxedHeaders;
|
|
49
|
+
var unsupportedHashAlgoError: (hashAlgo: string) => NodemailerError;
|
|
48
50
|
}
|
|
49
51
|
export default sign;
|
package/dist/esm/dkim/sign.js
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import * as punycode from '../punycode/index.js';
|
|
2
2
|
import * as mimeFuncs from '../mime-funcs/index.js';
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
|
+
import * as errors from '../errors.js';
|
|
5
|
+
/**
|
|
6
|
+
* Error for a hashAlgo value the crypto module does not know
|
|
7
|
+
*/
|
|
8
|
+
function unsupportedHashAlgoError(hashAlgo) {
|
|
9
|
+
const err = new Error('Unsupported DKIM hash algorithm "' + hashAlgo + '"');
|
|
10
|
+
err.code = errors.ECONFIG;
|
|
11
|
+
return err;
|
|
12
|
+
}
|
|
4
13
|
/**
|
|
5
14
|
* Returns DKIM signature header line
|
|
6
15
|
*
|
|
@@ -25,7 +34,13 @@ function sign(headers, hashAlgo, bodyHash, options) {
|
|
|
25
34
|
const canonicalizedHeaderData = relaxedHeaders(headers, fieldNames, options.skipFields);
|
|
26
35
|
const dkimHeader = generateDKIMHeader(options.domainName, options.keySelector, canonicalizedHeaderData.fieldNames, hashAlgo, bodyHash);
|
|
27
36
|
canonicalizedHeaderData.headers += 'dkim-signature:' + relaxedHeaderLine(dkimHeader);
|
|
28
|
-
|
|
37
|
+
let signer;
|
|
38
|
+
try {
|
|
39
|
+
signer = crypto.createSign(('rsa-' + hashAlgo).toUpperCase());
|
|
40
|
+
}
|
|
41
|
+
catch (_E) {
|
|
42
|
+
throw unsupportedHashAlgoError(hashAlgo);
|
|
43
|
+
}
|
|
29
44
|
// the header lines are 'binary' strings, so this reproduces the original header bytes
|
|
30
45
|
signer.update(canonicalizedHeaderData.headers, 'latin1');
|
|
31
46
|
let signature;
|
|
@@ -38,6 +53,7 @@ function sign(headers, hashAlgo, bodyHash, options) {
|
|
|
38
53
|
return dkimHeader + signature.replace(/(^.{73}|.{75}(?!\r?\n|\r))/g, '$&\r\n ').trim();
|
|
39
54
|
}
|
|
40
55
|
sign.relaxedHeaders = relaxedHeaders;
|
|
56
|
+
sign.unsupportedHashAlgoError = unsupportedHashAlgoError;
|
|
41
57
|
export default sign;
|
|
42
58
|
function generateDKIMHeader(domainName, keySelector, fieldNames, hashAlgo, bodyHash) {
|
|
43
59
|
// the caller supplied tag values are interpolated straight into the tag list, and none of
|
|
@@ -25,8 +25,10 @@ export interface FetchOptions {
|
|
|
25
25
|
tls?: {
|
|
26
26
|
[key: string]: any;
|
|
27
27
|
} | undefined;
|
|
28
|
-
/**
|
|
28
|
+
/** Socket inactivity timeout in milliseconds, defaults to 60000, 0 disables it */
|
|
29
29
|
timeout?: number | undefined;
|
|
30
|
+
/** Maximum size of the (decoded) response body in bytes, defaults to 64 MB, Infinity disables the limit */
|
|
31
|
+
maxBytes?: number | undefined;
|
|
30
32
|
/** Maximum number of redirects to follow (default 5) */
|
|
31
33
|
maxRedirects?: number | undefined;
|
|
32
34
|
/** Resolve responses with a status code of 300 or above instead of emitting an error */
|
|
@@ -47,6 +49,7 @@ export interface FetchResponse extends PassThrough {
|
|
|
47
49
|
declare function nmfetch(url: string, options?: FetchOptions): FetchResponse;
|
|
48
50
|
declare namespace nmfetch {
|
|
49
51
|
var Cookies: typeof import("./cookies.js").default;
|
|
52
|
+
var DEFAULT_TIMEOUT: number;
|
|
50
53
|
}
|
|
51
54
|
type CookiesJar = Cookies;
|
|
52
55
|
/**
|
package/dist/esm/fetch/index.js
CHANGED
|
@@ -9,6 +9,10 @@ import net from 'node:net';
|
|
|
9
9
|
import * as errors from '../errors.js';
|
|
10
10
|
import { isProtoKey } from '../shared/objects.js';
|
|
11
11
|
const MAX_REDIRECTS = 5;
|
|
12
|
+
// a stalled server would otherwise hold the request, and whatever waits on it, open forever
|
|
13
|
+
const DEFAULT_TIMEOUT = 60 * 1000;
|
|
14
|
+
// the body is usually buffered in memory by the caller, so an unbounded download is refused
|
|
15
|
+
const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
|
|
12
16
|
// Only genuine TLS settings are taken from options.tls. That object reaches us straight
|
|
13
17
|
// from a user supplied attachment (content.tls), so keys like host, port, path, socketPath
|
|
14
18
|
// or lookup would otherwise repoint the request at a destination that never went through
|
|
@@ -214,28 +218,22 @@ function nmfetch(url, options) {
|
|
|
214
218
|
});
|
|
215
219
|
return fetchRes;
|
|
216
220
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
if (finished) {
|
|
220
|
-
return;
|
|
221
|
-
}
|
|
222
|
-
finished = true;
|
|
223
|
-
req.abort();
|
|
224
|
-
const err = new Error('Request Timeout');
|
|
225
|
-
err.code = errors.EFETCH;
|
|
226
|
-
err.sourceUrl = url;
|
|
227
|
-
fetchRes.emit('error', err);
|
|
228
|
-
});
|
|
229
|
-
}
|
|
230
|
-
req.on('error', (err) => {
|
|
221
|
+
// reports the first failure of this request on fetchRes and releases the request
|
|
222
|
+
const fail = (err, sourceUrl = url) => {
|
|
231
223
|
if (finished) {
|
|
232
224
|
return;
|
|
233
225
|
}
|
|
234
226
|
finished = true;
|
|
235
227
|
err.code = errors.EFETCH;
|
|
236
|
-
err.sourceUrl =
|
|
228
|
+
err.sourceUrl = sourceUrl;
|
|
237
229
|
fetchRes.emit('error', err);
|
|
238
|
-
|
|
230
|
+
req.abort();
|
|
231
|
+
};
|
|
232
|
+
const timeout = typeof options.timeout === 'number' && options.timeout >= 0 ? options.timeout : DEFAULT_TIMEOUT;
|
|
233
|
+
if (timeout) {
|
|
234
|
+
req.setTimeout(timeout, () => fail(new Error('Request Timeout')));
|
|
235
|
+
}
|
|
236
|
+
req.on('error', (err) => fail(err));
|
|
239
237
|
req.on('response', res => {
|
|
240
238
|
let inflate;
|
|
241
239
|
if (finished) {
|
|
@@ -256,13 +254,7 @@ function nmfetch(url, options) {
|
|
|
256
254
|
// redirect
|
|
257
255
|
options.redirects++;
|
|
258
256
|
if (options.redirects > options.maxRedirects) {
|
|
259
|
-
|
|
260
|
-
const err = new Error('Maximum redirect count exceeded');
|
|
261
|
-
err.code = errors.EFETCH;
|
|
262
|
-
err.sourceUrl = url;
|
|
263
|
-
fetchRes.emit('error', err);
|
|
264
|
-
req.abort();
|
|
265
|
-
return;
|
|
257
|
+
return fail(new Error('Maximum redirect count exceeded'));
|
|
266
258
|
}
|
|
267
259
|
// redirect does not include POST body
|
|
268
260
|
options.method = 'GET';
|
|
@@ -282,13 +274,7 @@ function nmfetch(url, options) {
|
|
|
282
274
|
// call: that call gets its own `finished` flag and no handle on this
|
|
283
275
|
// request, so this one would stay open and could emit a second error on
|
|
284
276
|
// the shared fetchRes once it times out. Callers listen with req.once().
|
|
285
|
-
|
|
286
|
-
const err = new Error('Unsupported protocol for URL ' + redirectUrl);
|
|
287
|
-
err.code = errors.EFETCH;
|
|
288
|
-
err.sourceUrl = redirectUrl;
|
|
289
|
-
fetchRes.emit('error', err);
|
|
290
|
-
req.abort();
|
|
291
|
-
return;
|
|
277
|
+
return fail(new Error('Unsupported protocol for URL ' + redirectUrl), redirectUrl);
|
|
292
278
|
}
|
|
293
279
|
// Do not forward credentials when the redirect leaves the original
|
|
294
280
|
// security context: a different host, or a downgrade from https to
|
|
@@ -305,41 +291,33 @@ function nmfetch(url, options) {
|
|
|
305
291
|
}
|
|
306
292
|
});
|
|
307
293
|
}
|
|
294
|
+
// this request is done with, release it so its socket and timeout do not
|
|
295
|
+
// outlive it and report a late error on the shared fetchRes
|
|
296
|
+
finished = true;
|
|
297
|
+
res.resume();
|
|
298
|
+
req.abort();
|
|
308
299
|
return nmfetch(redirectUrl, options);
|
|
309
300
|
}
|
|
310
301
|
fetchRes.statusCode = res.statusCode;
|
|
311
302
|
fetchRes.headers = res.headers;
|
|
312
303
|
if (res.statusCode >= 300 && !options.allowErrorResponse) {
|
|
313
|
-
|
|
314
|
-
const err = new Error('Invalid status code ' + res.statusCode);
|
|
315
|
-
err.code = errors.EFETCH;
|
|
316
|
-
err.sourceUrl = url;
|
|
317
|
-
fetchRes.emit('error', err);
|
|
318
|
-
req.abort();
|
|
319
|
-
return;
|
|
304
|
+
return fail(new Error('Invalid status code ' + res.statusCode));
|
|
320
305
|
}
|
|
321
|
-
res.on('error', (err) =>
|
|
322
|
-
|
|
306
|
+
res.on('error', (err) => fail(err));
|
|
307
|
+
const maxBytes = typeof options.maxBytes === 'number' && options.maxBytes > 0 ? options.maxBytes : DEFAULT_MAX_BYTES;
|
|
308
|
+
const source = inflate || res;
|
|
309
|
+
let received = 0;
|
|
310
|
+
source.on('data', (chunk) => {
|
|
311
|
+
received += chunk.length;
|
|
312
|
+
if (received <= maxBytes || finished) {
|
|
323
313
|
return;
|
|
324
314
|
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
err.sourceUrl = url;
|
|
328
|
-
fetchRes.emit('error', err);
|
|
329
|
-
req.abort();
|
|
315
|
+
source.unpipe(fetchRes);
|
|
316
|
+
fail(new Error('Response size exceeds the allowed ' + maxBytes + ' bytes'));
|
|
330
317
|
});
|
|
331
318
|
if (inflate) {
|
|
332
319
|
res.pipe(inflate).pipe(fetchRes);
|
|
333
|
-
inflate.on('error', (err) =>
|
|
334
|
-
if (finished) {
|
|
335
|
-
return;
|
|
336
|
-
}
|
|
337
|
-
finished = true;
|
|
338
|
-
err.code = errors.EFETCH;
|
|
339
|
-
err.sourceUrl = url;
|
|
340
|
-
fetchRes.emit('error', err);
|
|
341
|
-
req.abort();
|
|
342
|
-
});
|
|
320
|
+
inflate.on('error', (err) => fail(err));
|
|
343
321
|
}
|
|
344
322
|
else {
|
|
345
323
|
res.pipe(fetchRes);
|
|
@@ -354,11 +332,7 @@ function nmfetch(url, options) {
|
|
|
354
332
|
req.write(body);
|
|
355
333
|
}
|
|
356
334
|
catch (err) {
|
|
357
|
-
|
|
358
|
-
err.code = errors.EFETCH;
|
|
359
|
-
err.sourceUrl = url;
|
|
360
|
-
fetchRes.emit('error', err);
|
|
361
|
-
return;
|
|
335
|
+
return fail(err);
|
|
362
336
|
}
|
|
363
337
|
}
|
|
364
338
|
req.end();
|
|
@@ -366,4 +340,5 @@ function nmfetch(url, options) {
|
|
|
366
340
|
return fetchRes;
|
|
367
341
|
}
|
|
368
342
|
nmfetch.Cookies = Cookies;
|
|
343
|
+
nmfetch.DEFAULT_TIMEOUT = DEFAULT_TIMEOUT;
|
|
369
344
|
export default nmfetch;
|
package/dist/esm/package-info.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Transform, type TransformOptions } from 'node:stream';
|
|
2
2
|
/**
|
|
3
3
|
* Escapes dots in the beginning of lines. Ends the stream with <CR><LF>.<CR><LF>
|
|
4
|
-
* Also makes sure that only <CR><LF> sequences are used for linebreaks
|
|
4
|
+
* Also makes sure that only <CR><LF> sequences are used for linebreaks, bare CR and bare LF
|
|
5
|
+
* are both turned into <CR><LF>
|
|
5
6
|
*
|
|
6
7
|
* @param options Stream options
|
|
7
8
|
*/
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { Transform } from 'node:stream';
|
|
2
|
+
// bytes inserted into the output, shared as they are only ever copied by Buffer.concat
|
|
3
|
+
const INSERT_LF = Buffer.from('\n');
|
|
4
|
+
const INSERT_LF_DOT = Buffer.from('\n.');
|
|
5
|
+
const INSERT_CR = Buffer.from('\r');
|
|
6
|
+
const INSERT_DOT = Buffer.from('.');
|
|
2
7
|
/**
|
|
3
8
|
* Escapes dots in the beginning of lines. Ends the stream with <CR><LF>.<CR><LF>
|
|
4
|
-
* Also makes sure that only <CR><LF> sequences are used for linebreaks
|
|
9
|
+
* Also makes sure that only <CR><LF> sequences are used for linebreaks, bare CR and bare LF
|
|
10
|
+
* are both turned into <CR><LF>
|
|
5
11
|
*
|
|
6
12
|
* @param options Stream options
|
|
7
13
|
*/
|
|
@@ -30,33 +36,35 @@ export default class DataStream extends Transform {
|
|
|
30
36
|
}
|
|
31
37
|
this.inByteCount += chunk.length;
|
|
32
38
|
for (i = 0, len = chunk.length; i < len; i++) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
const byte = chunk[i];
|
|
40
|
+
const prev = i ? chunk[i - 1] : this.lastByte;
|
|
41
|
+
let insert = false;
|
|
42
|
+
if (prev === 0x0d && byte !== 0x0a) {
|
|
43
|
+
// a bare CR becomes CRLF. A receiver that treats a lone CR as a line end would
|
|
44
|
+
// otherwise see "\r.\r" as the end of the data (SMTP smuggling), so a dot
|
|
45
|
+
// following it is stuffed like at the start of any other line
|
|
46
|
+
insert = byte === 0x2e ? INSERT_LF_DOT : INSERT_LF;
|
|
47
|
+
}
|
|
48
|
+
else if (byte === 0x0a && prev !== 0x0d) {
|
|
49
|
+
// a bare LF becomes CRLF
|
|
50
|
+
insert = INSERT_CR;
|
|
42
51
|
}
|
|
43
|
-
else if (
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
chunklen += 2;
|
|
53
|
-
}
|
|
54
|
-
chunks.push(Buffer.from('\r\n'));
|
|
55
|
-
lastPos = i + 1;
|
|
52
|
+
else if (byte === 0x2e && (prev === 0x0a || prev === false)) {
|
|
53
|
+
// a dot at the start of a line
|
|
54
|
+
insert = INSERT_DOT;
|
|
55
|
+
}
|
|
56
|
+
if (insert) {
|
|
57
|
+
if (i > lastPos) {
|
|
58
|
+
buf = chunk.slice(lastPos, i);
|
|
59
|
+
chunks.push(buf);
|
|
60
|
+
chunklen += buf.length;
|
|
56
61
|
}
|
|
62
|
+
chunks.push(insert);
|
|
63
|
+
chunklen += insert.length;
|
|
64
|
+
lastPos = i;
|
|
57
65
|
}
|
|
58
66
|
}
|
|
59
|
-
if (
|
|
67
|
+
if (chunks.length) {
|
|
60
68
|
// add last piece
|
|
61
69
|
if (lastPos < chunk.length) {
|
|
62
70
|
buf = chunk.slice(lastPos);
|
|
@@ -26,11 +26,11 @@ export interface SMTPConnectionOptions {
|
|
|
26
26
|
secured?: boolean | undefined;
|
|
27
27
|
/** Server name for SNI, defaults to host when that is not an IP address */
|
|
28
28
|
servername?: string | undefined;
|
|
29
|
-
/** Ignore STARTTLS even when the server advertises it */
|
|
29
|
+
/** Ignore STARTTLS even when the server advertises it, has no effect when requireTLS is set */
|
|
30
30
|
ignoreTLS?: boolean | undefined;
|
|
31
|
-
/** Force STARTTLS, fail when the server does not support it */
|
|
31
|
+
/** Force STARTTLS, fail when the server does not support it. Takes precedence over ignoreTLS and opportunisticTLS */
|
|
32
32
|
requireTLS?: boolean | undefined;
|
|
33
|
-
/** Continue unencrypted when the STARTTLS upgrade fails */
|
|
33
|
+
/** Continue unencrypted when the STARTTLS upgrade fails, has no effect when requireTLS is set */
|
|
34
34
|
opportunisticTLS?: boolean | undefined;
|
|
35
35
|
/** Name of the client server, sent with EHLO/HELO, CRLF is stripped */
|
|
36
36
|
name?: string | undefined;
|
|
@@ -288,8 +288,8 @@ export type SMTPConnectionResponseAction = (str: string) => void;
|
|
|
288
288
|
* * **port** - is the port to connect to (defaults to 587 or 465)
|
|
289
289
|
* * **host** - is the hostname or IP address to connect to (defaults to 'localhost')
|
|
290
290
|
* * **secure** - use SSL
|
|
291
|
-
* * **ignoreTLS** - ignore server support for STARTTLS
|
|
292
|
-
* * **requireTLS** - forces the client to use STARTTLS
|
|
291
|
+
* * **ignoreTLS** - ignore server support for STARTTLS (has no effect when requireTLS is set)
|
|
292
|
+
* * **requireTLS** - forces the client to use STARTTLS, takes precedence over ignoreTLS and opportunisticTLS
|
|
293
293
|
* * **name** - the name of the client server
|
|
294
294
|
* * **localAddress** - outbound address to bind to (see: http://nodejs.org/api/net.html#net_net_connect_options_connectionlistener)
|
|
295
295
|
* * **greetingTimeout** - Time to wait in ms until greeting message is received from the server (defaults to 30 seconds)
|
|
@@ -66,8 +66,8 @@ function isPartialLine(line) {
|
|
|
66
66
|
* * **port** - is the port to connect to (defaults to 587 or 465)
|
|
67
67
|
* * **host** - is the hostname or IP address to connect to (defaults to 'localhost')
|
|
68
68
|
* * **secure** - use SSL
|
|
69
|
-
* * **ignoreTLS** - ignore server support for STARTTLS
|
|
70
|
-
* * **requireTLS** - forces the client to use STARTTLS
|
|
69
|
+
* * **ignoreTLS** - ignore server support for STARTTLS (has no effect when requireTLS is set)
|
|
70
|
+
* * **requireTLS** - forces the client to use STARTTLS, takes precedence over ignoreTLS and opportunisticTLS
|
|
71
71
|
* * **name** - the name of the client server
|
|
72
72
|
* * **localAddress** - outbound address to bind to (see: http://nodejs.org/api/net.html#net_net_connect_options_connectionlistener)
|
|
73
73
|
* * **greetingTimeout** - Time to wait in ms until greeting message is received from the server (defaults to 30 seconds)
|
|
@@ -92,6 +92,11 @@ class SMTPConnection extends EventEmitter {
|
|
|
92
92
|
this.id = crypto.randomBytes(8).toString('base64').replace(/\W/g, '');
|
|
93
93
|
this.stage = 'init';
|
|
94
94
|
this.options = options || {};
|
|
95
|
+
if (this.options.requireTLS && (this.options.ignoreTLS || this.options.opportunisticTLS)) {
|
|
96
|
+
// requireTLS wins, a contradictory configuration must not quietly fall back to plaintext.
|
|
97
|
+
// Copied so the caller's (possibly shared) options object is left as it was
|
|
98
|
+
this.options = Object.assign({}, this.options, { ignoreTLS: false, opportunisticTLS: false });
|
|
99
|
+
}
|
|
95
100
|
this.secureConnection = !!this.options.secure;
|
|
96
101
|
this.alreadySecured = !!this.options.secured;
|
|
97
102
|
this.port = Number(this.options.port) || (this.secureConnection ? 465 : 587);
|
|
@@ -135,6 +140,8 @@ class SMTPConnection extends EventEmitter {
|
|
|
135
140
|
this._destroyed = false;
|
|
136
141
|
this._closing = false;
|
|
137
142
|
this._currentDataStream = false;
|
|
143
|
+
this._pendingSend = false;
|
|
144
|
+
this._connectCallback = false;
|
|
138
145
|
this._onSocketData = chunk => this._onData(chunk);
|
|
139
146
|
this._onSocketError = error => this._onError(error, 'ESOCKET', false, 'CONN');
|
|
140
147
|
this._onSocketClose = () => this._onClose();
|
|
@@ -149,7 +156,9 @@ class SMTPConnection extends EventEmitter {
|
|
|
149
156
|
*/
|
|
150
157
|
connect(connectCallback) {
|
|
151
158
|
if (typeof connectCallback === 'function') {
|
|
159
|
+
this._connectCallback = connectCallback;
|
|
152
160
|
this.once('connect', () => {
|
|
161
|
+
this._connectCallback = false;
|
|
153
162
|
this.logger.debug({
|
|
154
163
|
tnx: 'smtp'
|
|
155
164
|
}, 'SMTP handshake finished');
|
|
@@ -381,6 +390,16 @@ class SMTPConnection extends EventEmitter {
|
|
|
381
390
|
}
|
|
382
391
|
this._currentDataStream = false;
|
|
383
392
|
}
|
|
393
|
+
// Detach from the message stream as well. The listener is swapped for a no-op rather than
|
|
394
|
+
// removed, a stream destroyed with an error later on would otherwise throw it as unhandled
|
|
395
|
+
if (this._pendingSend) {
|
|
396
|
+
const { stream, onStreamError } = this._pendingSend;
|
|
397
|
+
if (stream) {
|
|
398
|
+
stream.removeListener('error', onStreamError);
|
|
399
|
+
stream.on('error', TEARDOWN_NOOP);
|
|
400
|
+
}
|
|
401
|
+
this._pendingSend = false;
|
|
402
|
+
}
|
|
384
403
|
if (socket && !socket.destroyed) {
|
|
385
404
|
try {
|
|
386
405
|
// Clear socket timeout to prevent timer leaks
|
|
@@ -568,6 +587,9 @@ class SMTPConnection extends EventEmitter {
|
|
|
568
587
|
return;
|
|
569
588
|
}
|
|
570
589
|
returned = true;
|
|
590
|
+
if (this._pendingSend && this._pendingSend.callback === callback) {
|
|
591
|
+
this._pendingSend = false;
|
|
592
|
+
}
|
|
571
593
|
done(err, info);
|
|
572
594
|
};
|
|
573
595
|
if (!message) {
|
|
@@ -584,9 +606,16 @@ class SMTPConnection extends EventEmitter {
|
|
|
584
606
|
});
|
|
585
607
|
return;
|
|
586
608
|
}
|
|
609
|
+
const pendingSend = {
|
|
610
|
+
callback,
|
|
611
|
+
stream: false,
|
|
612
|
+
onStreamError: err => callback(this._formatError(err, 'ESTREAM', false, 'API'))
|
|
613
|
+
};
|
|
587
614
|
if (typeof message.on === 'function') {
|
|
588
|
-
|
|
615
|
+
pendingSend.stream = message;
|
|
616
|
+
pendingSend.stream.on('error', pendingSend.onStreamError);
|
|
589
617
|
}
|
|
618
|
+
this._pendingSend = pendingSend;
|
|
590
619
|
const startTime = Date.now();
|
|
591
620
|
this._setEnvelope(envelope, (err, info) => {
|
|
592
621
|
if (err) {
|
|
@@ -779,8 +808,14 @@ class SMTPConnection extends EventEmitter {
|
|
|
779
808
|
else {
|
|
780
809
|
this.logger.error(data, err.message);
|
|
781
810
|
}
|
|
811
|
+
// close() forgets the send in flight, it is completed with this same error afterwards so
|
|
812
|
+
// a late message stream error has nothing left to report
|
|
813
|
+
const pendingSend = this._pendingSend;
|
|
782
814
|
this.emit('error', err);
|
|
783
815
|
this.close();
|
|
816
|
+
if (pendingSend) {
|
|
817
|
+
pendingSend.callback(err);
|
|
818
|
+
}
|
|
784
819
|
}
|
|
785
820
|
/** @internal */
|
|
786
821
|
_formatError(message, type, response, command) {
|
|
@@ -826,14 +861,25 @@ class SMTPConnection extends EventEmitter {
|
|
|
826
861
|
this.logger.info({
|
|
827
862
|
tnx: 'network'
|
|
828
863
|
}, 'Connection closed');
|
|
864
|
+
// the unterminated remainder is only reported as a reply (and so gives the error a responseCode)
|
|
865
|
+
// when it starts like a complete failure reply, not for a fragment such as "55" or a 250
|
|
866
|
+
const failureResponse = typeof serverResponse === 'string' && /^[45]\d{2}[ -]/.test(serverResponse) ? serverResponse : false;
|
|
829
867
|
if (this.upgrading && !this._destroyed) {
|
|
830
|
-
return this._onError(new Error('Connection closed unexpectedly'), 'ETLS',
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
|
|
868
|
+
return this._onError(new Error('Connection closed unexpectedly'), 'ETLS', failureResponse, 'CONN');
|
|
869
|
+
}
|
|
870
|
+
if (!failureResponse && this._responseActions[0] === this._actionGreeting && this._connectCallback && !this._destroyed) {
|
|
871
|
+
// A silent close before the greeting is handed to the connect() callback rather than
|
|
872
|
+
// emitted as 'error', callers that never saw an error for it must not start throwing one
|
|
873
|
+
const connectCallback = this._connectCallback;
|
|
874
|
+
this._connectCallback = false;
|
|
875
|
+
const err = this._formatError(new Error('Connection closed unexpectedly'), 'ECONNECTION', false, 'CONN');
|
|
876
|
+
this.logger.warn({ tnx: 'network' }, err.message);
|
|
877
|
+
connectCallback(err);
|
|
878
|
+
this.close();
|
|
879
|
+
return;
|
|
834
880
|
}
|
|
835
|
-
|
|
836
|
-
return this._onError(new Error('Connection closed unexpectedly'), 'ECONNECTION',
|
|
881
|
+
if (failureResponse || (this._responseActions[0] !== this.close && !this._destroyed)) {
|
|
882
|
+
return this._onError(new Error('Connection closed unexpectedly'), 'ECONNECTION', failureResponse, 'CONN');
|
|
837
883
|
}
|
|
838
884
|
this._destroy();
|
|
839
885
|
}
|
|
@@ -1310,21 +1356,25 @@ class SMTPConnection extends EventEmitter {
|
|
|
1310
1356
|
if (/[ -]AUTH\b/i.test(str)) {
|
|
1311
1357
|
this.allowsAuth = true;
|
|
1312
1358
|
}
|
|
1313
|
-
// Detect
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
//
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1359
|
+
// Detect the advertised SASL mechanisms. The list is split into whole tokens rather
|
|
1360
|
+
// than searched for each name with a pattern: the patterns this replaced let two
|
|
1361
|
+
// whitespace runs overlap and backtracked quadratically over an AUTH line padded with
|
|
1362
|
+
// spaces, so a hostile server could stall the event loop from its EHLO reply
|
|
1363
|
+
// (GHSA-4ffr-jq9g-5ffx).
|
|
1364
|
+
const authMechanisms = new Set();
|
|
1365
|
+
for (const line of this._ehloLines) {
|
|
1366
|
+
const authMatch = /^AUTH[\s=](.*)/i.exec(line);
|
|
1367
|
+
if (authMatch) {
|
|
1368
|
+
for (const mechanism of authMatch[1].split(/[\s=]+/)) {
|
|
1369
|
+
authMechanisms.add(mechanism.toUpperCase());
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1324
1372
|
}
|
|
1325
|
-
//
|
|
1326
|
-
|
|
1327
|
-
|
|
1373
|
+
// listed in order of preference, the first one the credentials allow is used
|
|
1374
|
+
for (const mechanism of ['PLAIN', 'LOGIN', 'CRAM-MD5', 'XOAUTH2']) {
|
|
1375
|
+
if (authMechanisms.has(mechanism)) {
|
|
1376
|
+
this._supportedAuth.push(mechanism);
|
|
1377
|
+
}
|
|
1328
1378
|
}
|
|
1329
1379
|
// Detect if the server supports SIZE extensions (and the max allowed size)
|
|
1330
1380
|
if ((match = str.match(/[ -]SIZE(?:[ \t]+(\d+))?/im))) {
|
|
@@ -1584,7 +1634,11 @@ class SMTPConnection extends EventEmitter {
|
|
|
1584
1634
|
this._sendCommand('DATA');
|
|
1585
1635
|
}
|
|
1586
1636
|
else {
|
|
1587
|
-
|
|
1637
|
+
// report a temporary rejection when there is one, taking the last reply would mark the
|
|
1638
|
+
// whole message as permanently failed although some recipients were only deferred
|
|
1639
|
+
const deferred = envelope.rejectedErrors.find(rejectedErr => rejectedErr.responseCode && rejectedErr.responseCode < 500);
|
|
1640
|
+
const reply = deferred?.response ?? str;
|
|
1641
|
+
err = this._formatError("Can't send mail - all recipients were rejected", 'EENVELOPE', reply, 'RCPT TO');
|
|
1588
1642
|
err.rejected = envelope.rejected;
|
|
1589
1643
|
err.rejectedErrors = envelope.rejectedErrors;
|
|
1590
1644
|
return callback(err);
|
|
@@ -17,7 +17,7 @@ export interface SMTPPoolOptions extends SMTPTransportOptions {
|
|
|
17
17
|
rateLimit?: number | undefined;
|
|
18
18
|
/** Time window for rateLimit in milliseconds, defaults to 1000 */
|
|
19
19
|
rateDelta?: number | undefined;
|
|
20
|
-
/** How many times a message is requeued when its connection closes while sending,
|
|
20
|
+
/** How many times a message is requeued when its connection closes while sending, defaults to 5, a negative value means unlimited */
|
|
21
21
|
maxRequeues?: number | undefined;
|
|
22
22
|
}
|
|
23
23
|
/**
|
|
@@ -26,6 +26,7 @@ export interface SMTPPoolOptions extends SMTPTransportOptions {
|
|
|
26
26
|
export type SMTPPoolResolvedOptions = SMTPPoolOptions & {
|
|
27
27
|
maxConnections: number;
|
|
28
28
|
maxMessages: number;
|
|
29
|
+
maxRequeues: number;
|
|
29
30
|
};
|
|
30
31
|
/**
|
|
31
32
|
* Result of a message sent through the pool, same as for the SMTP transport
|