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.
Files changed (38) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/cjs/addressparser/index.js +97 -0
  3. package/dist/cjs/dkim/index.js +25 -8
  4. package/dist/cjs/dkim/sign.d.ts +2 -0
  5. package/dist/cjs/dkim/sign.js +17 -1
  6. package/dist/cjs/fetch/index.d.ts +4 -1
  7. package/dist/cjs/fetch/index.js +34 -59
  8. package/dist/cjs/package-info.d.ts +1 -1
  9. package/dist/cjs/package-info.js +1 -1
  10. package/dist/cjs/smtp-connection/data-stream.d.ts +2 -1
  11. package/dist/cjs/smtp-connection/data-stream.js +32 -24
  12. package/dist/cjs/smtp-connection/index.d.ts +5 -5
  13. package/dist/cjs/smtp-connection/index.js +78 -24
  14. package/dist/cjs/smtp-pool/index.d.ts +2 -1
  15. package/dist/cjs/smtp-pool/index.js +31 -5
  16. package/dist/cjs/smtp-pool/pool-resource.js +27 -29
  17. package/dist/cjs/smtp-transport/index.js +17 -27
  18. package/dist/cjs/xoauth2/index.d.ts +2 -0
  19. package/dist/cjs/xoauth2/index.js +11 -3
  20. package/dist/esm/addressparser/index.js +97 -0
  21. package/dist/esm/dkim/index.js +25 -8
  22. package/dist/esm/dkim/sign.d.ts +2 -0
  23. package/dist/esm/dkim/sign.js +17 -1
  24. package/dist/esm/fetch/index.d.ts +4 -1
  25. package/dist/esm/fetch/index.js +34 -59
  26. package/dist/esm/package-info.d.ts +1 -1
  27. package/dist/esm/package-info.js +1 -1
  28. package/dist/esm/smtp-connection/data-stream.d.ts +2 -1
  29. package/dist/esm/smtp-connection/data-stream.js +32 -24
  30. package/dist/esm/smtp-connection/index.d.ts +5 -5
  31. package/dist/esm/smtp-connection/index.js +78 -24
  32. package/dist/esm/smtp-pool/index.d.ts +2 -1
  33. package/dist/esm/smtp-pool/index.js +31 -5
  34. package/dist/esm/smtp-pool/pool-resource.js +27 -29
  35. package/dist/esm/smtp-transport/index.js +17 -27
  36. package/dist/esm/xoauth2/index.d.ts +2 -0
  37. package/dist/esm/xoauth2/index.js +11 -3
  38. package/package.json +7 -7
@@ -104,8 +104,8 @@ function isPartialLine(line) {
104
104
  * * **port** - is the port to connect to (defaults to 587 or 465)
105
105
  * * **host** - is the hostname or IP address to connect to (defaults to 'localhost')
106
106
  * * **secure** - use SSL
107
- * * **ignoreTLS** - ignore server support for STARTTLS
108
- * * **requireTLS** - forces the client to use STARTTLS
107
+ * * **ignoreTLS** - ignore server support for STARTTLS (has no effect when requireTLS is set)
108
+ * * **requireTLS** - forces the client to use STARTTLS, takes precedence over ignoreTLS and opportunisticTLS
109
109
  * * **name** - the name of the client server
110
110
  * * **localAddress** - outbound address to bind to (see: http://nodejs.org/api/net.html#net_net_connect_options_connectionlistener)
111
111
  * * **greetingTimeout** - Time to wait in ms until greeting message is received from the server (defaults to 30 seconds)
@@ -130,6 +130,11 @@ class SMTPConnection extends node_events_1.EventEmitter {
130
130
  this.id = node_crypto_1.default.randomBytes(8).toString('base64').replace(/\W/g, '');
131
131
  this.stage = 'init';
132
132
  this.options = options || {};
133
+ if (this.options.requireTLS && (this.options.ignoreTLS || this.options.opportunisticTLS)) {
134
+ // requireTLS wins, a contradictory configuration must not quietly fall back to plaintext.
135
+ // Copied so the caller's (possibly shared) options object is left as it was
136
+ this.options = Object.assign({}, this.options, { ignoreTLS: false, opportunisticTLS: false });
137
+ }
133
138
  this.secureConnection = !!this.options.secure;
134
139
  this.alreadySecured = !!this.options.secured;
135
140
  this.port = Number(this.options.port) || (this.secureConnection ? 465 : 587);
@@ -173,6 +178,8 @@ class SMTPConnection extends node_events_1.EventEmitter {
173
178
  this._destroyed = false;
174
179
  this._closing = false;
175
180
  this._currentDataStream = false;
181
+ this._pendingSend = false;
182
+ this._connectCallback = false;
176
183
  this._onSocketData = chunk => this._onData(chunk);
177
184
  this._onSocketError = error => this._onError(error, 'ESOCKET', false, 'CONN');
178
185
  this._onSocketClose = () => this._onClose();
@@ -187,7 +194,9 @@ class SMTPConnection extends node_events_1.EventEmitter {
187
194
  */
188
195
  connect(connectCallback) {
189
196
  if (typeof connectCallback === 'function') {
197
+ this._connectCallback = connectCallback;
190
198
  this.once('connect', () => {
199
+ this._connectCallback = false;
191
200
  this.logger.debug({
192
201
  tnx: 'smtp'
193
202
  }, 'SMTP handshake finished');
@@ -419,6 +428,16 @@ class SMTPConnection extends node_events_1.EventEmitter {
419
428
  }
420
429
  this._currentDataStream = false;
421
430
  }
431
+ // Detach from the message stream as well. The listener is swapped for a no-op rather than
432
+ // removed, a stream destroyed with an error later on would otherwise throw it as unhandled
433
+ if (this._pendingSend) {
434
+ const { stream, onStreamError } = this._pendingSend;
435
+ if (stream) {
436
+ stream.removeListener('error', onStreamError);
437
+ stream.on('error', TEARDOWN_NOOP);
438
+ }
439
+ this._pendingSend = false;
440
+ }
422
441
  if (socket && !socket.destroyed) {
423
442
  try {
424
443
  // Clear socket timeout to prevent timer leaks
@@ -606,6 +625,9 @@ class SMTPConnection extends node_events_1.EventEmitter {
606
625
  return;
607
626
  }
608
627
  returned = true;
628
+ if (this._pendingSend && this._pendingSend.callback === callback) {
629
+ this._pendingSend = false;
630
+ }
609
631
  done(err, info);
610
632
  };
611
633
  if (!message) {
@@ -622,9 +644,16 @@ class SMTPConnection extends node_events_1.EventEmitter {
622
644
  });
623
645
  return;
624
646
  }
647
+ const pendingSend = {
648
+ callback,
649
+ stream: false,
650
+ onStreamError: err => callback(this._formatError(err, 'ESTREAM', false, 'API'))
651
+ };
625
652
  if (typeof message.on === 'function') {
626
- message.on('error', err => callback(this._formatError(err, 'ESTREAM', false, 'API')));
653
+ pendingSend.stream = message;
654
+ pendingSend.stream.on('error', pendingSend.onStreamError);
627
655
  }
656
+ this._pendingSend = pendingSend;
628
657
  const startTime = Date.now();
629
658
  this._setEnvelope(envelope, (err, info) => {
630
659
  if (err) {
@@ -817,8 +846,14 @@ class SMTPConnection extends node_events_1.EventEmitter {
817
846
  else {
818
847
  this.logger.error(data, err.message);
819
848
  }
849
+ // close() forgets the send in flight, it is completed with this same error afterwards so
850
+ // a late message stream error has nothing left to report
851
+ const pendingSend = this._pendingSend;
820
852
  this.emit('error', err);
821
853
  this.close();
854
+ if (pendingSend) {
855
+ pendingSend.callback(err);
856
+ }
822
857
  }
823
858
  /** @internal */
824
859
  _formatError(message, type, response, command) {
@@ -864,14 +899,25 @@ class SMTPConnection extends node_events_1.EventEmitter {
864
899
  this.logger.info({
865
900
  tnx: 'network'
866
901
  }, 'Connection closed');
902
+ // the unterminated remainder is only reported as a reply (and so gives the error a responseCode)
903
+ // when it starts like a complete failure reply, not for a fragment such as "55" or a 250
904
+ const failureResponse = typeof serverResponse === 'string' && /^[45]\d{2}[ -]/.test(serverResponse) ? serverResponse : false;
867
905
  if (this.upgrading && !this._destroyed) {
868
- return this._onError(new Error('Connection closed unexpectedly'), 'ETLS', serverResponse, 'CONN');
869
- }
870
- else if (![this._actionGreeting, this.close].includes(this._responseActions[0]) && !this._destroyed) {
871
- return this._onError(new Error('Connection closed unexpectedly'), 'ECONNECTION', serverResponse, 'CONN');
906
+ return this._onError(new Error('Connection closed unexpectedly'), 'ETLS', failureResponse, 'CONN');
907
+ }
908
+ if (!failureResponse && this._responseActions[0] === this._actionGreeting && this._connectCallback && !this._destroyed) {
909
+ // A silent close before the greeting is handed to the connect() callback rather than
910
+ // emitted as 'error', callers that never saw an error for it must not start throwing one
911
+ const connectCallback = this._connectCallback;
912
+ this._connectCallback = false;
913
+ const err = this._formatError(new Error('Connection closed unexpectedly'), 'ECONNECTION', false, 'CONN');
914
+ this.logger.warn({ tnx: 'network' }, err.message);
915
+ connectCallback(err);
916
+ this.close();
917
+ return;
872
918
  }
873
- else if (/^[45]\d{2}\b/.test(serverResponse)) {
874
- return this._onError(new Error('Connection closed unexpectedly'), 'ECONNECTION', serverResponse, 'CONN');
919
+ if (failureResponse || (this._responseActions[0] !== this.close && !this._destroyed)) {
920
+ return this._onError(new Error('Connection closed unexpectedly'), 'ECONNECTION', failureResponse, 'CONN');
875
921
  }
876
922
  this._destroy();
877
923
  }
@@ -1348,21 +1394,25 @@ class SMTPConnection extends node_events_1.EventEmitter {
1348
1394
  if (/[ -]AUTH\b/i.test(str)) {
1349
1395
  this.allowsAuth = true;
1350
1396
  }
1351
- // Detect if the server supports PLAIN auth
1352
- if (/[ -]AUTH(?:(\s+|=)[^\n]*\s+|\s+|=)PLAIN/i.test(str)) {
1353
- this._supportedAuth.push('PLAIN');
1354
- }
1355
- // Detect if the server supports LOGIN auth
1356
- if (/[ -]AUTH(?:(\s+|=)[^\n]*\s+|\s+|=)LOGIN/i.test(str)) {
1357
- this._supportedAuth.push('LOGIN');
1358
- }
1359
- // Detect if the server supports CRAM-MD5 auth
1360
- if (/[ -]AUTH(?:(\s+|=)[^\n]*\s+|\s+|=)CRAM-MD5/i.test(str)) {
1361
- this._supportedAuth.push('CRAM-MD5');
1397
+ // Detect the advertised SASL mechanisms. The list is split into whole tokens rather
1398
+ // than searched for each name with a pattern: the patterns this replaced let two
1399
+ // whitespace runs overlap and backtracked quadratically over an AUTH line padded with
1400
+ // spaces, so a hostile server could stall the event loop from its EHLO reply
1401
+ // (GHSA-4ffr-jq9g-5ffx).
1402
+ const authMechanisms = new Set();
1403
+ for (const line of this._ehloLines) {
1404
+ const authMatch = /^AUTH[\s=](.*)/i.exec(line);
1405
+ if (authMatch) {
1406
+ for (const mechanism of authMatch[1].split(/[\s=]+/)) {
1407
+ authMechanisms.add(mechanism.toUpperCase());
1408
+ }
1409
+ }
1362
1410
  }
1363
- // Detect if the server supports XOAUTH2 auth
1364
- if (/[ -]AUTH(?:(\s+|=)[^\n]*\s+|\s+|=)XOAUTH2/i.test(str)) {
1365
- this._supportedAuth.push('XOAUTH2');
1411
+ // listed in order of preference, the first one the credentials allow is used
1412
+ for (const mechanism of ['PLAIN', 'LOGIN', 'CRAM-MD5', 'XOAUTH2']) {
1413
+ if (authMechanisms.has(mechanism)) {
1414
+ this._supportedAuth.push(mechanism);
1415
+ }
1366
1416
  }
1367
1417
  // Detect if the server supports SIZE extensions (and the max allowed size)
1368
1418
  if ((match = str.match(/[ -]SIZE(?:[ \t]+(\d+))?/im))) {
@@ -1622,7 +1672,11 @@ class SMTPConnection extends node_events_1.EventEmitter {
1622
1672
  this._sendCommand('DATA');
1623
1673
  }
1624
1674
  else {
1625
- err = this._formatError("Can't send mail - all recipients were rejected", 'EENVELOPE', str, 'RCPT TO');
1675
+ // report a temporary rejection when there is one, taking the last reply would mark the
1676
+ // whole message as permanently failed although some recipients were only deferred
1677
+ const deferred = envelope.rejectedErrors.find(rejectedErr => rejectedErr.responseCode && rejectedErr.responseCode < 500);
1678
+ const reply = deferred?.response ?? str;
1679
+ err = this._formatError("Can't send mail - all recipients were rejected", 'EENVELOPE', reply, 'RCPT TO');
1626
1680
  err.rejected = envelope.rejected;
1627
1681
  err.rejectedErrors = envelope.rejectedErrors;
1628
1682
  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, unlimited when not set or negative */
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
@@ -43,6 +43,10 @@ const index_js_2 = __importDefault(require("../well-known/index.js"));
43
43
  const shared = __importStar(require("../shared/index.js"));
44
44
  const errors = __importStar(require("../errors.js"));
45
45
  const packageData = __importStar(require("../package-info.js"));
46
+ /** First delay before a requeued message is retried, doubled on every further requeue */
47
+ const REQUEUE_BASE_DELAY = 50;
48
+ /** Upper bound for the requeue delay */
49
+ const REQUEUE_MAX_DELAY = 2000;
46
50
  /**
47
51
  * Creates a SMTP pool transport object for Nodemailer
48
52
  *
@@ -74,6 +78,8 @@ class SMTPPool extends node_events_1.EventEmitter {
74
78
  );
75
79
  this.options.maxConnections = this.options.maxConnections || 5;
76
80
  this.options.maxMessages = this.options.maxMessages || 100;
81
+ // a default bound, a server that closes every connection before the greeting would otherwise be retried forever
82
+ this.options.maxRequeues = typeof this.options.maxRequeues === 'number' ? this.options.maxRequeues : 5;
77
83
  this.logger = shared.getLogger(this.options, {
78
84
  component: this.options.component || 'smtp-pool'
79
85
  });
@@ -117,6 +123,10 @@ class SMTPPool extends node_events_1.EventEmitter {
117
123
  */
118
124
  send(mail, callback) {
119
125
  if (this._closed) {
126
+ // Mail.sendMail ignores the return value, so without a callback its promise would never settle
127
+ const err = new Error('Connection pool was closed');
128
+ err.code = errors.ECONNECTION;
129
+ setImmediate(() => callback(err));
120
130
  return false;
121
131
  }
122
132
  this._queue.push({
@@ -330,15 +340,22 @@ class SMTPPool extends node_events_1.EventEmitter {
330
340
  // Note that we must wait a bit.. because the callback of the 'error' handler might be called
331
341
  // in the next event loop
332
342
  setTimeout(() => {
343
+ let delay = 0;
333
344
  if (connection.queueEntry) {
334
345
  if (this._shouldRequeuOnConnectionClose(connection.queueEntry)) {
335
- this._requeueEntryOnConnectionClose(connection);
346
+ delay = this._requeueEntryOnConnectionClose(connection);
336
347
  }
337
348
  else {
338
349
  this._failDeliveryOnConnectionClose(connection);
339
350
  }
340
351
  }
341
- this._continueProcessing();
352
+ if (delay) {
353
+ // back off, a server that keeps dropping connections is not hammered
354
+ setTimeout(() => this._continueProcessing(), delay);
355
+ }
356
+ else {
357
+ this._continueProcessing();
358
+ }
342
359
  }, 50);
343
360
  }
344
361
  else {
@@ -353,7 +370,7 @@ class SMTPPool extends node_events_1.EventEmitter {
353
370
  }
354
371
  /** @internal */
355
372
  _shouldRequeuOnConnectionClose(queueEntry) {
356
- if (this.options.maxRequeues === undefined || this.options.maxRequeues < 0) {
373
+ if (this.options.maxRequeues < 0) {
357
374
  return true;
358
375
  }
359
376
  return queueEntry.requeueAttempts < this.options.maxRequeues;
@@ -362,7 +379,9 @@ class SMTPPool extends node_events_1.EventEmitter {
362
379
  _failDeliveryOnConnectionClose(connection) {
363
380
  if (connection.queueEntry && connection.queueEntry.callback) {
364
381
  try {
365
- connection.queueEntry.callback(new Error('Reached maximum number of retries after connection was closed'));
382
+ const err = new Error('Reached maximum number of retries after connection was closed');
383
+ err.code = errors.ECONNECTION;
384
+ connection.queueEntry.callback(err);
366
385
  }
367
386
  catch (E) {
368
387
  this.logger.error({
@@ -377,6 +396,7 @@ class SMTPPool extends node_events_1.EventEmitter {
377
396
  }
378
397
  /** @internal */
379
398
  _requeueEntryOnConnectionClose(connection) {
399
+ const delay = Math.min(REQUEUE_BASE_DELAY * 2 ** connection.queueEntry.requeueAttempts, REQUEUE_MAX_DELAY);
380
400
  connection.queueEntry.requeueAttempts += 1;
381
401
  this.logger.debug({
382
402
  tnx: 'pool',
@@ -386,6 +406,7 @@ class SMTPPool extends node_events_1.EventEmitter {
386
406
  }, 'Re-queued message <%s> for #%s. Attempt: #%s', connection.queueEntry.messageId, connection.id, connection.queueEntry.requeueAttempts);
387
407
  this._queue.unshift(connection.queueEntry);
388
408
  connection.queueEntry = false;
409
+ return delay;
389
410
  }
390
411
  /**
391
412
  * Continue to process message if the pool hasn't closed
@@ -506,10 +527,15 @@ class SMTPPool extends node_events_1.EventEmitter {
506
527
  connection.quit();
507
528
  return done(null, true);
508
529
  };
509
- connection.connect(() => {
530
+ connection.connect(err => {
510
531
  if (returned) {
511
532
  return;
512
533
  }
534
+ if (err) {
535
+ returned = true;
536
+ connection.close();
537
+ return done(err);
538
+ }
513
539
  if (auth && (connection.allowsAuth || options.forceAuth)) {
514
540
  connection.login(auth, err => {
515
541
  if (returned) {
@@ -66,7 +66,7 @@ class PoolResource extends node_events_1.EventEmitter {
66
66
  method: 'XOAUTH2'
67
67
  };
68
68
  oauth2.on('token', (token) => this.pool.mailer.emit('token', token));
69
- oauth2.on('error', err => this.emit('error', err));
69
+ oauth2.on('error', err => this._fail(err));
70
70
  break;
71
71
  }
72
72
  default:
@@ -89,6 +89,19 @@ class PoolResource extends node_events_1.EventEmitter {
89
89
  this._connected = false;
90
90
  this.messages = 0;
91
91
  this.available = true;
92
+ this._failed = false;
93
+ }
94
+ /**
95
+ * Emits 'error' for the first failure only. A dead resource can report the same failure more
96
+ * than once (the connection error, then the send callback), the pool handles it once
97
+ * @internal
98
+ */
99
+ _fail(err) {
100
+ if (this._failed) {
101
+ return;
102
+ }
103
+ this._failed = true;
104
+ this.emit('error', err);
92
105
  }
93
106
  /**
94
107
  * Initiates a connection to the SMTP server
@@ -101,7 +114,7 @@ class PoolResource extends node_events_1.EventEmitter {
101
114
  // nothing was connected, so no 'close' event is coming that would free the
102
115
  // slot this resource holds in the pool, report the failure the way a failed
103
116
  // login does
104
- this.emit('error', err);
117
+ this._fail(err);
105
118
  return callback(err);
106
119
  }
107
120
  let returned = false;
@@ -118,8 +131,8 @@ class PoolResource extends node_events_1.EventEmitter {
118
131
  options = Object.assign((0, index_js_2.assign)(false, options), socketOptions);
119
132
  }
120
133
  this.connection = new index_js_1.default(options);
121
- this.connection.once('error', err => {
122
- this.emit('error', err);
134
+ this.connection.on('error', err => {
135
+ this._fail(err);
123
136
  if (returned) {
124
137
  return;
125
138
  }
@@ -128,33 +141,18 @@ class PoolResource extends node_events_1.EventEmitter {
128
141
  });
129
142
  this.connection.once('end', () => {
130
143
  this.close();
131
- if (returned) {
132
- return;
133
- }
134
144
  returned = true;
135
- const timer = setTimeout(() => {
136
- if (returned) {
137
- return;
138
- }
139
- // still have not returned, this means we have an unexpected connection close
140
- const err = new Error('Unexpected socket close');
141
- if (this.connection && this.connection.upgrading) {
142
- // starttls connection errors
143
- err.code = errors.ETLS;
144
- }
145
- callback(err);
146
- }, 1000);
147
- try {
148
- timer.unref();
149
- }
150
- catch (_E) {
151
- // Ignore. Happens on envs with non-node timer implementation
152
- }
153
145
  });
154
- this.connection.connect(() => {
146
+ this.connection.connect(err => {
155
147
  if (returned) {
156
148
  return;
157
149
  }
150
+ if (err) {
151
+ // a close before the greeting, the 'end' that follows closes this resource
152
+ // and the pool requeues or fails the entry, bounded by maxRequeues
153
+ returned = true;
154
+ return;
155
+ }
158
156
  if (this.auth && (this.connection.allowsAuth || options.forceAuth)) {
159
157
  this.connection.login(this.auth, err => {
160
158
  if (returned) {
@@ -163,7 +161,7 @@ class PoolResource extends node_events_1.EventEmitter {
163
161
  returned = true;
164
162
  if (err) {
165
163
  this.connection.close();
166
- this.emit('error', err);
164
+ this._fail(err);
167
165
  return callback(err);
168
166
  }
169
167
  this._connected = true;
@@ -215,7 +213,7 @@ class PoolResource extends node_events_1.EventEmitter {
215
213
  this.messages++;
216
214
  if (err) {
217
215
  this.connection.close();
218
- this.emit('error', err);
216
+ this._fail(err);
219
217
  return callback(err);
220
218
  }
221
219
  info.envelope = {
@@ -228,7 +226,7 @@ class PoolResource extends node_events_1.EventEmitter {
228
226
  const err = new Error('Resource exhausted');
229
227
  err.code = errors.EMAXLIMIT;
230
228
  this.connection.close();
231
- this.emit('error', err);
229
+ this._fail(err);
232
230
  }
233
231
  else {
234
232
  this.pool._checkRateLimit(() => {
@@ -177,31 +177,6 @@ class SMTPTransport extends node_events_1.EventEmitter {
177
177
  connection.close();
178
178
  return callback(err);
179
179
  });
180
- connection.once('end', () => {
181
- if (returned) {
182
- return;
183
- }
184
- const timer = setTimeout(() => {
185
- if (returned) {
186
- return;
187
- }
188
- returned = true;
189
- cleanupPerCallAuth();
190
- // still have not returned, this means we have an unexpected connection close
191
- const err = new Error('Unexpected socket close');
192
- if (connection && connection.upgrading) {
193
- // starttls connection errors
194
- err.code = errors.ETLS;
195
- }
196
- callback(err);
197
- }, 1000);
198
- try {
199
- timer.unref();
200
- }
201
- catch (_E) {
202
- // Ignore. Happens on envs with non-node timer implementation
203
- }
204
- });
205
180
  const sendMessage = () => {
206
181
  const envelope = mail.message.getEnvelope();
207
182
  const messageId = mail.message.messageId();
@@ -221,6 +196,10 @@ class SMTPTransport extends node_events_1.EventEmitter {
221
196
  messageId
222
197
  }, 'Sending message %s to <%s>', messageId, recipients.join(', '));
223
198
  connection.send(envelope, mail.message.createReadStream(), (err, info) => {
199
+ if (returned) {
200
+ // the connection error handler has already reported this send
201
+ return;
202
+ }
224
203
  returned = true;
225
204
  cleanupPerCallAuth();
226
205
  connection.close();
@@ -247,10 +226,16 @@ class SMTPTransport extends node_events_1.EventEmitter {
247
226
  }
248
227
  });
249
228
  };
250
- connection.connect(() => {
229
+ connection.connect(err => {
251
230
  if (returned) {
252
231
  return;
253
232
  }
233
+ if (err) {
234
+ // the server closed the connection before the greeting
235
+ returned = true;
236
+ connection.close();
237
+ return callback(err);
238
+ }
254
239
  perCallAuth = this.getAuth(mail.data.auth);
255
240
  if (perCallAuth && (connection.allowsAuth || options.forceAuth)) {
256
241
  connection.login(perCallAuth, err => {
@@ -332,10 +317,15 @@ class SMTPTransport extends node_events_1.EventEmitter {
332
317
  connection.quit();
333
318
  return done(null, true);
334
319
  };
335
- connection.connect(() => {
320
+ connection.connect(err => {
336
321
  if (returned) {
337
322
  return;
338
323
  }
324
+ if (err) {
325
+ returned = true;
326
+ connection.close();
327
+ return done(err);
328
+ }
339
329
  perCallAuth = this.getAuth({});
340
330
  if (perCallAuth && (connection.allowsAuth || options.forceAuth)) {
341
331
  connection.login(perCallAuth, err => {
@@ -61,6 +61,8 @@ export interface XOAuth2Options {
61
61
  component?: string | undefined;
62
62
  /** Extra headers for the token request */
63
63
  customHeaders?: OutgoingHttpHeaders | undefined;
64
+ /** Timeout for the token request in milliseconds, defaults to 60000, 0 disables it */
65
+ requestTimeout?: number | undefined;
64
66
  /** Extra form fields for the token request */
65
67
  customParams?: {
66
68
  [key: string]: any;
@@ -74,12 +74,14 @@ class XOAuth2 extends node_stream_1.Stream {
74
74
  constructor(options, logger) {
75
75
  super();
76
76
  this.options = options || {};
77
+ this.configError = false;
77
78
  if (options && options.serviceClient) {
78
79
  if (!options.privateKey || !options.user) {
80
+ // reported through getToken() rather than an 'error' event: the transports forward
81
+ // that event up to the Mail object, where nothing may be listening
79
82
  const err = new Error('Options "privateKey" and "user" are required for service account!');
80
83
  err.code = errors.EOAUTH2;
81
- setImmediate(() => this.emit('error', err));
82
- return;
84
+ this.configError = err;
83
85
  }
84
86
  const serviceRequestTimeout = Math.min(Math.max(Number(this.options.serviceRequestTimeout) || 0, 0), 3600);
85
87
  this.options.serviceRequestTimeout = serviceRequestTimeout || 5 * 60;
@@ -113,6 +115,9 @@ class XOAuth2 extends node_stream_1.Stream {
113
115
  getToken(renew, callback) {
114
116
  // the error paths hand over the error alone
115
117
  const done = callback;
118
+ if (this.configError) {
119
+ return done(this.configError);
120
+ }
116
121
  if (!renew && this.accessToken && (!this.expires || this.expires > Date.now())) {
117
122
  this.logger.debug({
118
123
  tnx: 'OAUTH2',
@@ -349,7 +354,10 @@ class XOAuth2 extends node_stream_1.Stream {
349
354
  method: 'post',
350
355
  headers: params.customHeaders,
351
356
  body: payload,
352
- allowErrorResponse: true
357
+ allowErrorResponse: true,
358
+ // unset falls back to the fetch default, a stalled token endpoint would otherwise keep
359
+ // `renewing` set and queue every later request
360
+ timeout: params.requestTimeout
353
361
  };
354
362
  // OAuth2 token endpoints are credential-bearing. src/fetch already
355
363
  // validates certs by default; pin rejectUnauthorized:true here so the
@@ -240,6 +240,81 @@ function _recoverAddrSpec(data) {
240
240
  .filter(part => part)
241
241
  .join(' ');
242
242
  }
243
+ /**
244
+ * Takes the RFC 5322 comments out of the contents of an angle-addr.
245
+ *
246
+ * The tokenizer tracks a single open operator, so once '<' is open a '(' is plain text and
247
+ * a comment inside the brackets reached the address verbatim: 'Name <user@example.com(x)evil.com>'
248
+ * put 'user@example.com(x)evil.com' into the envelope, a value that is no mailbox and that a
249
+ * receiver stripping the comment reads as 'user@example.comevil.com' (GHSA-g73g-hqqh-jr95).
250
+ * A comment is folding whitespace, so it is read as a space that splits two atoms and as
251
+ * nothing next to an '@', the same rule the token walk applies outside the brackets. Quoted
252
+ * strings and domain literals are copied through untouched, a '(' in there is not a comment.
253
+ *
254
+ * @param address Contents of the angle brackets
255
+ * @return The address with the comments removed, and the text of the comments
256
+ */
257
+ function _stripAddressComments(address) {
258
+ const comments = [];
259
+ let result = '';
260
+ let comment = '';
261
+ let depth = 0;
262
+ let closer = '';
263
+ // carried along rather than read back off the growing result, which would flatten it on
264
+ // every comment (see lastChars in _handleAddress)
265
+ let lastChar = '';
266
+ for (let i = 0, len = address.length; i < len; i++) {
267
+ const chr = address.charAt(i);
268
+ if (depth) {
269
+ if (chr === '\\' && i < len - 1) {
270
+ comment += address.charAt(++i);
271
+ }
272
+ else if (chr === '(') {
273
+ depth++;
274
+ comment += chr;
275
+ }
276
+ else if (chr === ')' && !--depth) {
277
+ comments.push(comment.trim());
278
+ comment = '';
279
+ if (lastChar !== '@' && address.charAt(i + 1) !== '@') {
280
+ result += ' ';
281
+ lastChar = ' ';
282
+ }
283
+ }
284
+ else {
285
+ comment += chr;
286
+ }
287
+ continue;
288
+ }
289
+ if (closer) {
290
+ if (chr === '\\' && closer === '"' && i < len - 1) {
291
+ result += chr + address.charAt(++i);
292
+ lastChar = address.charAt(i);
293
+ continue;
294
+ }
295
+ if (chr === closer) {
296
+ closer = '';
297
+ }
298
+ }
299
+ else if (chr === '"') {
300
+ closer = '"';
301
+ }
302
+ else if (chr === '[') {
303
+ closer = ']';
304
+ }
305
+ else if (chr === '(') {
306
+ depth = 1;
307
+ continue;
308
+ }
309
+ result += chr;
310
+ lastChar = chr;
311
+ }
312
+ if (depth) {
313
+ // an unterminated comment runs to the end of the address
314
+ comments.push(comment.trim());
315
+ }
316
+ return { address: result.trim(), comments: comments.filter(text => text) };
317
+ }
243
318
  /**
244
319
  * Converts tokens for a single address into an address object
245
320
  *
@@ -367,6 +442,24 @@ function _handleAddress(tokens, depth) {
367
442
  });
368
443
  }
369
444
  else {
445
+ // Comments come out of the angle-addr before anything asks whether one was found, so
446
+ // that brackets holding nothing but a comment read the same as empty ones
447
+ const addressComments = [];
448
+ const addressParts = [];
449
+ for (const part of data.address) {
450
+ if (part.indexOf('(') < 0) {
451
+ addressParts.push(part);
452
+ continue;
453
+ }
454
+ const stripped = _stripAddressComments(part);
455
+ for (const comment of stripped.comments) {
456
+ addressComments.push(comment);
457
+ }
458
+ if (stripped.address) {
459
+ addressParts.push(stripped.address);
460
+ }
461
+ }
462
+ data.address = addressParts;
370
463
  // If no address was found, try to detect one from regular text
371
464
  if (!data.address.length && data.text.length) {
372
465
  for (let i = data.text.length - 1; i >= 0; i--) {
@@ -433,6 +526,10 @@ function _handleAddress(tokens, depth) {
433
526
  data.text = '';
434
527
  }
435
528
  _recoverAddrSpec(data);
529
+ // a comment names the mailbox only when nothing else does, as one outside the brackets
530
+ if (!data.text && addressComments.length) {
531
+ data.text = addressComments.join(' ');
532
+ }
436
533
  const address = {
437
534
  address: data.address || data.text || '',
438
535
  name: data.text || data.address || ''