@nocobase/plugin-workflow-mailer 2.4.0-alpha.1 → 2.4.0-alpha.10
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/dist/externalVersion.js +6 -6
- package/dist/node_modules/joi/dist/joi-browser.min.js +1 -1
- package/dist/node_modules/joi/lib/index.js +1 -1
- package/dist/node_modules/joi/lib/messages.js +35 -6
- package/dist/node_modules/joi/lib/types/keys.js +18 -1
- package/dist/node_modules/joi/lib/types/link.js +18 -2
- package/dist/node_modules/joi/lib/types/string.js +2 -2
- package/dist/node_modules/joi/package.json +1 -1
- package/dist/node_modules/nodemailer/dist/cjs/addressparser/index.d.ts +59 -0
- package/dist/node_modules/nodemailer/dist/cjs/addressparser/index.js +790 -0
- package/dist/node_modules/nodemailer/dist/cjs/base64/index.d.ts +36 -0
- package/dist/node_modules/nodemailer/dist/cjs/base64/index.js +119 -0
- package/dist/node_modules/nodemailer/dist/cjs/dkim/index.d.ts +52 -0
- package/dist/node_modules/nodemailer/dist/cjs/dkim/index.js +228 -0
- package/dist/node_modules/nodemailer/dist/cjs/dkim/message-parser.d.ts +48 -0
- package/dist/node_modules/nodemailer/dist/cjs/dkim/message-parser.js +180 -0
- package/dist/node_modules/nodemailer/dist/cjs/dkim/relaxed-body.d.ts +36 -0
- package/dist/node_modules/nodemailer/dist/cjs/dkim/relaxed-body.js +148 -0
- package/dist/node_modules/nodemailer/dist/cjs/dkim/sign.d.ts +52 -0
- package/dist/node_modules/nodemailer/dist/cjs/dkim/sign.js +165 -0
- package/dist/node_modules/nodemailer/dist/cjs/errors.d.ts +96 -0
- package/dist/node_modules/nodemailer/dist/cjs/errors.js +70 -0
- package/dist/node_modules/nodemailer/dist/cjs/fetch/cookies.d.ts +107 -0
- package/dist/node_modules/nodemailer/dist/cjs/fetch/cookies.js +282 -0
- package/dist/node_modules/nodemailer/dist/cjs/fetch/index.d.ts +65 -0
- package/dist/node_modules/nodemailer/dist/cjs/fetch/index.js +384 -0
- package/dist/node_modules/nodemailer/dist/cjs/json-transport/index.d.ts +64 -0
- package/dist/node_modules/nodemailer/dist/cjs/json-transport/index.js +99 -0
- package/dist/node_modules/nodemailer/dist/cjs/mail-composer/index.d.ts +174 -0
- package/dist/node_modules/nodemailer/dist/cjs/mail-composer/index.js +613 -0
- package/dist/node_modules/nodemailer/dist/cjs/mailer/index.d.ts +237 -0
- package/dist/node_modules/nodemailer/dist/cjs/mailer/index.js +415 -0
- package/dist/node_modules/nodemailer/dist/cjs/mailer/mail-message.d.ts +97 -0
- package/dist/node_modules/nodemailer/dist/cjs/mailer/mail-message.js +342 -0
- package/dist/node_modules/nodemailer/dist/cjs/mime-funcs/index.d.ts +161 -0
- package/dist/node_modules/nodemailer/dist/cjs/mime-funcs/index.js +787 -0
- package/dist/node_modules/nodemailer/dist/cjs/mime-funcs/mime-types.d.ts +2 -0
- package/dist/node_modules/nodemailer/dist/cjs/mime-funcs/mime-types.js +2103 -0
- package/dist/node_modules/nodemailer/dist/cjs/mime-node/index.d.ts +333 -0
- package/dist/node_modules/nodemailer/dist/cjs/mime-node/index.js +1493 -0
- package/dist/node_modules/nodemailer/dist/cjs/mime-node/last-newline.d.ts +5 -0
- package/dist/node_modules/nodemailer/dist/cjs/mime-node/last-newline.js +32 -0
- package/dist/node_modules/nodemailer/dist/cjs/mime-node/le-unix.d.ts +9 -0
- package/dist/node_modules/nodemailer/dist/cjs/mime-node/le-unix.js +40 -0
- package/dist/node_modules/nodemailer/dist/cjs/mime-node/le-windows.d.ts +10 -0
- package/dist/node_modules/nodemailer/dist/cjs/mime-node/le-windows.js +47 -0
- package/dist/node_modules/nodemailer/dist/cjs/nodemailer.d.ts +124 -0
- package/dist/node_modules/nodemailer/dist/cjs/nodemailer.js +1 -0
- package/dist/node_modules/nodemailer/dist/cjs/package-info.d.ts +3 -0
- package/dist/node_modules/nodemailer/dist/cjs/package-info.js +7 -0
- package/dist/node_modules/nodemailer/dist/cjs/package.json +3 -0
- package/dist/node_modules/nodemailer/dist/cjs/punycode/index.d.ts +73 -0
- package/dist/node_modules/nodemailer/{lib → dist/cjs}/punycode/index.js +63 -112
- package/dist/node_modules/nodemailer/dist/cjs/qp/index.d.ts +32 -0
- package/dist/node_modules/nodemailer/dist/cjs/qp/index.js +207 -0
- package/dist/node_modules/nodemailer/dist/cjs/sendmail-transport/index.d.ts +69 -0
- package/dist/node_modules/nodemailer/dist/cjs/sendmail-transport/index.js +210 -0
- package/dist/node_modules/nodemailer/dist/cjs/ses-transport/index.d.ts +112 -0
- package/dist/node_modules/nodemailer/dist/cjs/ses-transport/index.js +252 -0
- package/dist/node_modules/nodemailer/dist/cjs/shared/index.d.ts +260 -0
- package/dist/node_modules/nodemailer/dist/cjs/shared/index.js +680 -0
- package/dist/node_modules/nodemailer/dist/cjs/shared/objects.d.ts +23 -0
- package/dist/node_modules/nodemailer/dist/cjs/shared/objects.js +43 -0
- package/dist/node_modules/nodemailer/dist/cjs/shared/url.d.ts +21 -0
- package/dist/node_modules/nodemailer/dist/cjs/shared/url.js +255 -0
- package/dist/node_modules/nodemailer/dist/cjs/smtp-connection/data-stream.d.ts +15 -0
- package/dist/node_modules/nodemailer/dist/cjs/smtp-connection/data-stream.js +108 -0
- package/dist/node_modules/nodemailer/dist/cjs/smtp-connection/http-proxy-client.d.ts +47 -0
- package/dist/node_modules/nodemailer/dist/cjs/smtp-connection/http-proxy-client.js +189 -0
- package/dist/node_modules/nodemailer/dist/cjs/smtp-connection/index.d.ts +416 -0
- package/dist/node_modules/nodemailer/dist/cjs/smtp-connection/index.js +1837 -0
- package/dist/node_modules/nodemailer/dist/cjs/smtp-pool/index.d.ts +133 -0
- package/dist/node_modules/nodemailer/dist/cjs/smtp-pool/index.js +569 -0
- package/dist/node_modules/nodemailer/dist/cjs/smtp-pool/pool-resource.d.ts +62 -0
- package/dist/node_modules/nodemailer/dist/cjs/smtp-pool/pool-resource.js +257 -0
- package/dist/node_modules/nodemailer/dist/cjs/smtp-transport/index.d.ts +156 -0
- package/dist/node_modules/nodemailer/dist/cjs/smtp-transport/index.js +371 -0
- package/dist/node_modules/nodemailer/dist/cjs/stream-transport/index.d.ts +65 -0
- package/dist/node_modules/nodemailer/dist/cjs/stream-transport/index.js +146 -0
- package/dist/node_modules/nodemailer/dist/cjs/well-known/index.d.ts +35 -0
- package/dist/node_modules/nodemailer/dist/cjs/well-known/index.js +41 -0
- package/dist/node_modules/nodemailer/dist/cjs/well-known/services.d.ts +2 -0
- package/dist/node_modules/nodemailer/dist/cjs/well-known/services.js +674 -0
- package/dist/node_modules/nodemailer/dist/cjs/xoauth2/index.d.ts +201 -0
- package/dist/node_modules/nodemailer/dist/cjs/xoauth2/index.js +426 -0
- package/dist/node_modules/nodemailer/dist/esm/addressparser/index.d.ts +59 -0
- package/dist/node_modules/nodemailer/dist/esm/addressparser/index.js +786 -0
- package/dist/node_modules/nodemailer/dist/esm/base64/index.d.ts +36 -0
- package/dist/node_modules/nodemailer/{lib → dist/esm}/base64/index.js +39 -68
- package/dist/node_modules/nodemailer/dist/esm/dkim/index.d.ts +52 -0
- package/dist/node_modules/nodemailer/{lib → dist/esm}/dkim/index.js +62 -92
- package/dist/node_modules/nodemailer/dist/esm/dkim/message-parser.d.ts +48 -0
- package/dist/node_modules/nodemailer/dist/esm/dkim/message-parser.js +176 -0
- package/dist/node_modules/nodemailer/dist/esm/dkim/relaxed-body.d.ts +36 -0
- package/dist/node_modules/nodemailer/dist/esm/dkim/relaxed-body.js +141 -0
- package/dist/node_modules/nodemailer/dist/esm/dkim/sign.d.ts +52 -0
- package/dist/node_modules/nodemailer/dist/esm/dkim/sign.js +125 -0
- package/dist/node_modules/nodemailer/dist/esm/errors.d.ts +96 -0
- package/dist/node_modules/nodemailer/dist/esm/errors.js +67 -0
- package/dist/node_modules/nodemailer/dist/esm/fetch/cookies.d.ts +107 -0
- package/dist/node_modules/nodemailer/dist/esm/fetch/cookies.js +242 -0
- package/dist/node_modules/nodemailer/dist/esm/fetch/index.d.ts +65 -0
- package/dist/node_modules/nodemailer/dist/esm/fetch/index.js +344 -0
- package/dist/node_modules/nodemailer/dist/esm/json-transport/index.d.ts +64 -0
- package/dist/node_modules/nodemailer/dist/esm/json-transport/index.js +62 -0
- package/dist/node_modules/nodemailer/dist/esm/mail-composer/index.d.ts +174 -0
- package/dist/node_modules/nodemailer/{lib → dist/esm}/mail-composer/index.js +283 -275
- package/dist/node_modules/nodemailer/dist/esm/mailer/index.d.ts +237 -0
- package/dist/node_modules/nodemailer/{lib → dist/esm}/mailer/index.js +151 -205
- package/dist/node_modules/nodemailer/dist/esm/mailer/mail-message.d.ts +97 -0
- package/dist/node_modules/nodemailer/{lib → dist/esm}/mailer/mail-message.js +111 -125
- package/dist/node_modules/nodemailer/dist/esm/mime-funcs/index.d.ts +161 -0
- package/dist/node_modules/nodemailer/dist/esm/mime-funcs/index.js +738 -0
- package/dist/node_modules/nodemailer/dist/esm/mime-funcs/mime-types.d.ts +2 -0
- package/dist/node_modules/nodemailer/{lib → dist/esm}/mime-funcs/mime-types.js +43 -49
- package/dist/node_modules/nodemailer/dist/esm/mime-node/index.d.ts +333 -0
- package/dist/node_modules/nodemailer/{lib → dist/esm}/mime-node/index.js +576 -437
- package/dist/node_modules/nodemailer/dist/esm/mime-node/last-newline.d.ts +5 -0
- package/dist/node_modules/nodemailer/{lib → dist/esm}/mime-node/last-newline.js +4 -10
- package/dist/node_modules/nodemailer/dist/esm/mime-node/le-unix.d.ts +9 -0
- package/dist/node_modules/nodemailer/{lib → dist/esm}/mime-node/le-unix.js +7 -15
- package/dist/node_modules/nodemailer/dist/esm/mime-node/le-windows.d.ts +10 -0
- package/dist/node_modules/nodemailer/{lib → dist/esm}/mime-node/le-windows.js +6 -16
- package/dist/node_modules/nodemailer/dist/esm/nodemailer.d.ts +124 -0
- package/dist/node_modules/nodemailer/dist/esm/nodemailer.js +166 -0
- package/dist/node_modules/nodemailer/dist/esm/package-info.d.ts +3 -0
- package/dist/node_modules/nodemailer/dist/esm/package-info.js +4 -0
- package/dist/node_modules/nodemailer/dist/esm/package.json +3 -0
- package/dist/node_modules/nodemailer/dist/esm/punycode/index.d.ts +73 -0
- package/dist/node_modules/nodemailer/dist/esm/punycode/index.js +405 -0
- package/dist/node_modules/nodemailer/dist/esm/qp/index.d.ts +32 -0
- package/dist/node_modules/nodemailer/{lib → dist/esm}/qp/index.js +59 -77
- package/dist/node_modules/nodemailer/dist/esm/sendmail-transport/index.d.ts +69 -0
- package/dist/node_modules/nodemailer/dist/esm/sendmail-transport/index.js +170 -0
- package/dist/node_modules/nodemailer/dist/esm/ses-transport/index.d.ts +112 -0
- package/dist/node_modules/nodemailer/dist/esm/ses-transport/index.js +212 -0
- package/dist/node_modules/nodemailer/dist/esm/shared/index.d.ts +260 -0
- package/dist/node_modules/nodemailer/dist/esm/shared/index.js +631 -0
- package/dist/node_modules/nodemailer/dist/esm/shared/objects.d.ts +23 -0
- package/dist/node_modules/nodemailer/dist/esm/shared/objects.js +38 -0
- package/dist/node_modules/nodemailer/dist/esm/shared/url.d.ts +21 -0
- package/dist/node_modules/nodemailer/dist/esm/shared/url.js +214 -0
- package/dist/node_modules/nodemailer/dist/esm/smtp-connection/data-stream.d.ts +15 -0
- package/dist/node_modules/nodemailer/dist/esm/smtp-connection/data-stream.js +103 -0
- package/dist/node_modules/nodemailer/dist/esm/smtp-connection/http-proxy-client.d.ts +47 -0
- package/dist/node_modules/nodemailer/dist/esm/smtp-connection/http-proxy-client.js +149 -0
- package/dist/node_modules/nodemailer/dist/esm/smtp-connection/index.d.ts +416 -0
- package/dist/node_modules/nodemailer/dist/esm/smtp-connection/index.js +1797 -0
- package/dist/node_modules/nodemailer/dist/esm/smtp-pool/index.d.ts +133 -0
- package/dist/node_modules/nodemailer/{lib → dist/esm}/smtp-pool/index.js +195 -314
- package/dist/node_modules/nodemailer/dist/esm/smtp-pool/pool-resource.d.ts +62 -0
- package/dist/node_modules/nodemailer/{lib → dist/esm}/smtp-pool/pool-resource.js +70 -107
- package/dist/node_modules/nodemailer/dist/esm/smtp-transport/index.d.ts +156 -0
- package/dist/node_modules/nodemailer/dist/esm/smtp-transport/index.js +331 -0
- package/dist/node_modules/nodemailer/dist/esm/stream-transport/index.d.ts +65 -0
- package/dist/node_modules/nodemailer/dist/esm/stream-transport/index.js +106 -0
- package/dist/node_modules/nodemailer/dist/esm/well-known/index.d.ts +35 -0
- package/dist/node_modules/nodemailer/{lib → dist/esm}/well-known/index.js +12 -23
- package/dist/node_modules/nodemailer/dist/esm/well-known/services.d.ts +2 -0
- package/dist/node_modules/nodemailer/dist/esm/well-known/services.js +671 -0
- package/dist/node_modules/nodemailer/dist/esm/xoauth2/index.d.ts +201 -0
- package/dist/node_modules/nodemailer/dist/esm/xoauth2/index.js +386 -0
- package/dist/node_modules/nodemailer/dist/well-known/services.json +640 -0
- package/dist/node_modules/nodemailer/package.json +1 -1
- package/package.json +4 -4
- package/dist/node_modules/nodemailer/.gitattributes +0 -6
- package/dist/node_modules/nodemailer/.ncurc.js +0 -7
- package/dist/node_modules/nodemailer/.prettierrc.js +0 -8
- package/dist/node_modules/nodemailer/lib/addressparser/index.js +0 -313
- package/dist/node_modules/nodemailer/lib/dkim/message-parser.js +0 -155
- package/dist/node_modules/nodemailer/lib/dkim/relaxed-body.js +0 -154
- package/dist/node_modules/nodemailer/lib/dkim/sign.js +0 -117
- package/dist/node_modules/nodemailer/lib/fetch/cookies.js +0 -281
- package/dist/node_modules/nodemailer/lib/fetch/index.js +0 -274
- package/dist/node_modules/nodemailer/lib/json-transport/index.js +0 -82
- package/dist/node_modules/nodemailer/lib/mime-funcs/index.js +0 -625
- package/dist/node_modules/nodemailer/lib/nodemailer.js +0 -1
- package/dist/node_modules/nodemailer/lib/sendmail-transport/index.js +0 -210
- package/dist/node_modules/nodemailer/lib/ses-transport/index.js +0 -349
- package/dist/node_modules/nodemailer/lib/shared/index.js +0 -688
- package/dist/node_modules/nodemailer/lib/smtp-connection/data-stream.js +0 -108
- package/dist/node_modules/nodemailer/lib/smtp-connection/http-proxy-client.js +0 -143
- package/dist/node_modules/nodemailer/lib/smtp-connection/index.js +0 -1825
- package/dist/node_modules/nodemailer/lib/smtp-transport/index.js +0 -416
- package/dist/node_modules/nodemailer/lib/stream-transport/index.js +0 -135
- package/dist/node_modules/nodemailer/lib/well-known/services.json +0 -343
- package/dist/node_modules/nodemailer/lib/xoauth2/index.js +0 -376
|
@@ -0,0 +1,1797 @@
|
|
|
1
|
+
import * as packageData from '../package-info.js';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import net from 'node:net';
|
|
4
|
+
import tls from 'node:tls';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import DataStream from './data-stream.js';
|
|
8
|
+
import { PassThrough } from 'node:stream';
|
|
9
|
+
import * as shared from '../shared/index.js';
|
|
10
|
+
// default timeout values in ms
|
|
11
|
+
const CONNECTION_TIMEOUT = 2 * 60 * 1000; // how much to wait for the connection to be established
|
|
12
|
+
const SOCKET_TIMEOUT = 10 * 60 * 1000; // how much to wait for socket inactivity before disconnecting the client
|
|
13
|
+
const GREETING_TIMEOUT = 30 * 1000; // how much to wait after connection is established but SMTP greeting is not receieved
|
|
14
|
+
const DNS_TIMEOUT = 30 * 1000; // how much to wait for resolveHostname
|
|
15
|
+
const TEARDOWN_NOOP = () => { }; // reusable no-op handler for absorbing errors during socket teardown
|
|
16
|
+
// how many bytes a single server response may occupy while it is still being received.
|
|
17
|
+
// Generous compared to any real reply, it only stops a peer that never completes one
|
|
18
|
+
const MAX_RESPONSE_SIZE = 1024 * 1024;
|
|
19
|
+
/**
|
|
20
|
+
* Re-interpret a server response stored in fake 8-bit byte-container form
|
|
21
|
+
* (the result of chunk.toString('binary') in _onData) as UTF-8.
|
|
22
|
+
*
|
|
23
|
+
* Server reply text has no formally defined charset (RFC 5321 §4.2.1), but
|
|
24
|
+
* modern MTAs commonly use UTF-8. The byte-container plumbing in _onData is
|
|
25
|
+
* required to reassemble multi-byte sequences split across socket chunks;
|
|
26
|
+
* this helper performs the actual decode at the line boundary, falling back
|
|
27
|
+
* to the byte-container form when the bytes are not valid UTF-8 so that
|
|
28
|
+
* legacy 8-bit replies are still recoverable byte-for-byte.
|
|
29
|
+
*/
|
|
30
|
+
function decodeServerResponse(str) {
|
|
31
|
+
if (!str) {
|
|
32
|
+
return str;
|
|
33
|
+
}
|
|
34
|
+
const utf8 = Buffer.from(str, 'binary').toString('utf8');
|
|
35
|
+
// The input is a byte container (each char is in U+0000..U+00FF) so it can never
|
|
36
|
+
// already contain U+FFFD; any \uFFFD in the result was inserted by Node's UTF-8
|
|
37
|
+
// decoder for invalid bytes, which means we should return the original bytes intact.
|
|
38
|
+
return utf8.includes('\uFFFD') ? str : utf8;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* True when the last line of a queued reply is a continuation ("250-..."), which means
|
|
42
|
+
* the rest of the reply is still on its way.
|
|
43
|
+
*
|
|
44
|
+
* Called with the byte-container form the queue holds (see _onData): the check only looks
|
|
45
|
+
* at leading ASCII digits and '-' of the last line, and a UTF-8 continuation byte is never
|
|
46
|
+
* 0x0A, so line boundaries and the tested prefix are the same before and after decoding.
|
|
47
|
+
* The last line is read with lastIndexOf rather than split() because a queue entry may hold
|
|
48
|
+
* a whole multiline reply and only its final line matters.
|
|
49
|
+
*/
|
|
50
|
+
function isPartialResponse(str) {
|
|
51
|
+
return isPartialLine(str.slice(str.lastIndexOf('\n') + 1));
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* True when a single reply line is a continuation ("250-..."). Used where the line is
|
|
55
|
+
* already known to be one line, which skips the scan isPartialResponse needs to find the
|
|
56
|
+
* last line of a whole reply.
|
|
57
|
+
*/
|
|
58
|
+
function isPartialLine(line) {
|
|
59
|
+
return /^\d+-/.test(line);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Generates a SMTP connection object
|
|
63
|
+
*
|
|
64
|
+
* Optional options object takes the following possible properties:
|
|
65
|
+
*
|
|
66
|
+
* * **port** - is the port to connect to (defaults to 587 or 465)
|
|
67
|
+
* * **host** - is the hostname or IP address to connect to (defaults to 'localhost')
|
|
68
|
+
* * **secure** - use SSL
|
|
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
|
+
* * **name** - the name of the client server
|
|
72
|
+
* * **localAddress** - outbound address to bind to (see: http://nodejs.org/api/net.html#net_net_connect_options_connectionlistener)
|
|
73
|
+
* * **greetingTimeout** - Time to wait in ms until greeting message is received from the server (defaults to 30 seconds)
|
|
74
|
+
* * **connectionTimeout** - how many milliseconds to wait for the connection to establish (defaults to 2 minutes)
|
|
75
|
+
* * **socketTimeout** - Time of inactivity until the connection is closed (defaults to 10 minutes)
|
|
76
|
+
* * **maxResponseSize** - Largest single server response to accept in bytes (defaults to 1 MB)
|
|
77
|
+
* * **dnsTimeout** - Time to wait in ms for the DNS requests to be resolved (defaults to 30 seconds)
|
|
78
|
+
* * **lmtp** - if true, uses LMTP instead of SMTP protocol
|
|
79
|
+
* * **logger** - bunyan compatible logger interface
|
|
80
|
+
* * **debug** - if true pass SMTP traffic to the logger
|
|
81
|
+
* * **tls** - options for createCredentials
|
|
82
|
+
* * **socket** - existing socket to use instead of creating a new one (see: http://nodejs.org/api/net.html#net_class_net_socket)
|
|
83
|
+
* * **secured** - boolean indicates that the provided socket has already been upgraded to tls
|
|
84
|
+
*
|
|
85
|
+
* @constructor
|
|
86
|
+
* @namespace SMTP Client module
|
|
87
|
+
* @param [options] Option properties
|
|
88
|
+
*/
|
|
89
|
+
class SMTPConnection extends EventEmitter {
|
|
90
|
+
constructor(options) {
|
|
91
|
+
super(options);
|
|
92
|
+
this.id = crypto.randomBytes(8).toString('base64').replace(/\W/g, '');
|
|
93
|
+
this.stage = 'init';
|
|
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
|
+
}
|
|
100
|
+
this.secureConnection = !!this.options.secure;
|
|
101
|
+
this.alreadySecured = !!this.options.secured;
|
|
102
|
+
this.port = Number(this.options.port) || (this.secureConnection ? 465 : 587);
|
|
103
|
+
this.host = this.options.host || 'localhost';
|
|
104
|
+
this.servername = this.options.servername ? this.options.servername : !net.isIP(this.host) ? this.host : false;
|
|
105
|
+
this.allowInternalNetworkInterfaces = this.options.allowInternalNetworkInterfaces || false;
|
|
106
|
+
if (typeof this.options.secure === 'undefined' && this.port === 465) {
|
|
107
|
+
// if secure option is not set but port is 465, then default to secure
|
|
108
|
+
this.secureConnection = true;
|
|
109
|
+
}
|
|
110
|
+
this.name = (this.options.name || this._getHostname()).toString().replace(/[\r\n]+/g, '');
|
|
111
|
+
this.logger = shared.getLogger(this.options, {
|
|
112
|
+
component: this.options.component || 'smtp-connection',
|
|
113
|
+
sid: this.id
|
|
114
|
+
});
|
|
115
|
+
this.customAuth = new Map();
|
|
116
|
+
for (const key of Object.keys(this.options.customAuth || {})) {
|
|
117
|
+
const mapKey = (key || '').toString().trim().toUpperCase();
|
|
118
|
+
if (mapKey) {
|
|
119
|
+
this.customAuth.set(mapKey, this.options.customAuth[key]);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
this.version = packageData.version;
|
|
123
|
+
this.authenticated = false;
|
|
124
|
+
this.destroyed = false;
|
|
125
|
+
this.secure = !!this.secureConnection;
|
|
126
|
+
this._remainder = '';
|
|
127
|
+
this._responseQueue = [];
|
|
128
|
+
this._responsePartial = false;
|
|
129
|
+
this.lastServerResponse = false;
|
|
130
|
+
this._socket = false;
|
|
131
|
+
this._supportedAuth = [];
|
|
132
|
+
this.allowsAuth = false;
|
|
133
|
+
this._envelope = false;
|
|
134
|
+
this._supportedExtensions = [];
|
|
135
|
+
this._maxAllowedSize = 0;
|
|
136
|
+
this._responseActions = [];
|
|
137
|
+
this._recipientQueue = [];
|
|
138
|
+
this._greetingTimeout = false;
|
|
139
|
+
this._connectionTimeout = false;
|
|
140
|
+
this._destroyed = false;
|
|
141
|
+
this._closing = false;
|
|
142
|
+
this._currentDataStream = false;
|
|
143
|
+
this._pendingSend = false;
|
|
144
|
+
this._connectCallback = false;
|
|
145
|
+
this._onSocketData = chunk => this._onData(chunk);
|
|
146
|
+
this._onSocketError = error => this._onError(error, 'ESOCKET', false, 'CONN');
|
|
147
|
+
this._onSocketClose = () => this._onClose();
|
|
148
|
+
this._onSocketEnd = () => this._onEnd();
|
|
149
|
+
this._onSocketTimeout = () => this._onTimeout();
|
|
150
|
+
this._onConnectionSocketError = err => this._onConnectionError(err, 'ESOCKET');
|
|
151
|
+
this._connectionAttemptId = 0;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Creates a connection to a SMTP server and sets up connection
|
|
155
|
+
* listener
|
|
156
|
+
*/
|
|
157
|
+
connect(connectCallback) {
|
|
158
|
+
if (typeof connectCallback === 'function') {
|
|
159
|
+
this._connectCallback = connectCallback;
|
|
160
|
+
this.once('connect', () => {
|
|
161
|
+
this._connectCallback = false;
|
|
162
|
+
this.logger.debug({
|
|
163
|
+
tnx: 'smtp'
|
|
164
|
+
}, 'SMTP handshake finished');
|
|
165
|
+
connectCallback();
|
|
166
|
+
});
|
|
167
|
+
const isDestroyedMessage = this._isDestroyedMessage('connect');
|
|
168
|
+
if (isDestroyedMessage) {
|
|
169
|
+
return connectCallback(this._formatError(isDestroyedMessage, 'ECONNECTION', false, 'CONN'));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
let opts = {
|
|
173
|
+
port: this.port,
|
|
174
|
+
host: this.host,
|
|
175
|
+
allowInternalNetworkInterfaces: this.allowInternalNetworkInterfaces,
|
|
176
|
+
timeout: this.options.dnsTimeout || DNS_TIMEOUT
|
|
177
|
+
};
|
|
178
|
+
if (this.options.localAddress) {
|
|
179
|
+
opts.localAddress = this.options.localAddress;
|
|
180
|
+
}
|
|
181
|
+
if (this.options.connection) {
|
|
182
|
+
// connection is already opened
|
|
183
|
+
this._socket = this.options.connection;
|
|
184
|
+
this._setupConnectionHandlers();
|
|
185
|
+
if (this.secureConnection && !this.alreadySecured) {
|
|
186
|
+
setImmediate(() => this._upgradeConnection(err => {
|
|
187
|
+
if (err) {
|
|
188
|
+
this._onError(new Error('Error initiating TLS - ' + (err.message || err)), 'ETLS', false, 'CONN');
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
this._onConnect();
|
|
192
|
+
}));
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
setImmediate(() => this._onConnect());
|
|
196
|
+
}
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
else if (this.options.socket) {
|
|
200
|
+
// socket object is set up but not yet connected
|
|
201
|
+
this._socket = this.options.socket;
|
|
202
|
+
return this._resolveAndConnect(opts, _resolved => {
|
|
203
|
+
try {
|
|
204
|
+
this._socket.connect(this.port, this.host, () => {
|
|
205
|
+
this._socket.setKeepAlive(true);
|
|
206
|
+
// a `secure` connection over a caller-provided socket must still
|
|
207
|
+
// perform the TLS handshake, otherwise AUTH and the message body
|
|
208
|
+
// would be sent in cleartext despite the caller requesting TLS
|
|
209
|
+
if (this.secureConnection && !this.alreadySecured) {
|
|
210
|
+
return this._upgradeConnection(err => {
|
|
211
|
+
if (err) {
|
|
212
|
+
this._onError(new Error('Error initiating TLS - ' + (err.message || err)), 'ETLS', false, 'CONN');
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
this._onConnect();
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
this._onConnect();
|
|
219
|
+
});
|
|
220
|
+
this._setupConnectionHandlers();
|
|
221
|
+
}
|
|
222
|
+
catch (E) {
|
|
223
|
+
setImmediate(() => this._onError(E, 'ECONNECTION', false, 'CONN'));
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
if (this.secureConnection) {
|
|
230
|
+
Object.assign(opts, this.options.tls || {});
|
|
231
|
+
// ensure servername for SNI
|
|
232
|
+
if (this.servername && !opts.servername) {
|
|
233
|
+
opts.servername = this.servername;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return this._resolveAndConnect(opts, resolved => {
|
|
237
|
+
// Store fallback addresses for retry on connection failure
|
|
238
|
+
this._fallbackAddresses = (resolved._addresses || []).filter(addr => addr !== opts.host);
|
|
239
|
+
this._connectOpts = Object.assign({}, opts);
|
|
240
|
+
this._connectToHost(opts, this.secureConnection);
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Resolves the hostname and applies resolved values to opts,
|
|
246
|
+
* then calls the provided callback with the resolved data
|
|
247
|
+
*
|
|
248
|
+
* @param opts Connection options (modified in place)
|
|
249
|
+
* @param callback Called with resolved data on success
|
|
250
|
+
* @internal
|
|
251
|
+
*/
|
|
252
|
+
_resolveAndConnect(opts, callback) {
|
|
253
|
+
return shared.resolveHostname(opts, (err, resolved) => {
|
|
254
|
+
if (err) {
|
|
255
|
+
return setImmediate(() => this._onError(err, 'EDNS', false, 'CONN'));
|
|
256
|
+
}
|
|
257
|
+
this.logger.debug({
|
|
258
|
+
tnx: 'dns',
|
|
259
|
+
source: opts.host,
|
|
260
|
+
resolved: resolved.host,
|
|
261
|
+
cached: !!resolved.cached
|
|
262
|
+
}, 'Resolved %s as %s [cache %s]', opts.host, resolved.host, resolved.cached ? 'hit' : 'miss');
|
|
263
|
+
for (const key of Object.keys(resolved)) {
|
|
264
|
+
if (key.charAt(0) !== '_' && resolved[key]) {
|
|
265
|
+
opts[key] = resolved[key];
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
callback(resolved);
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Attempts to connect to the specified host address
|
|
273
|
+
*
|
|
274
|
+
* @param opts Connection options
|
|
275
|
+
* @param secure Whether to use TLS
|
|
276
|
+
* @internal
|
|
277
|
+
*/
|
|
278
|
+
_connectToHost(opts, secure) {
|
|
279
|
+
// If the client was closed while DNS resolution was in flight, do not open
|
|
280
|
+
// a socket here: close() ran with this._socket still unset and so had
|
|
281
|
+
// nothing to tear down, and _onConnect's remedial close() is a no-op once
|
|
282
|
+
// _closing is set, the freshly connected socket would leak.
|
|
283
|
+
if (this._destroyed || this._closing) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
this._connectionAttemptId++;
|
|
287
|
+
const currentAttemptId = this._connectionAttemptId;
|
|
288
|
+
const connectFn = secure
|
|
289
|
+
? tls.connect
|
|
290
|
+
: net.connect;
|
|
291
|
+
try {
|
|
292
|
+
this._socket = connectFn(opts, () => {
|
|
293
|
+
// Ignore callback if this is a stale connection attempt
|
|
294
|
+
if (this._connectionAttemptId !== currentAttemptId) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
this._socket.setKeepAlive(true);
|
|
298
|
+
this._onConnect();
|
|
299
|
+
});
|
|
300
|
+
this._setupConnectionHandlers();
|
|
301
|
+
}
|
|
302
|
+
catch (E) {
|
|
303
|
+
setImmediate(() => this._onError(E, 'ECONNECTION', false, 'CONN'));
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Sets up connection timeout and error handlers
|
|
309
|
+
* @internal
|
|
310
|
+
*/
|
|
311
|
+
_setupConnectionHandlers() {
|
|
312
|
+
this._connectionTimeout = setTimeout(() => {
|
|
313
|
+
this._onConnectionError('Connection timeout', 'ETIMEDOUT');
|
|
314
|
+
}, this.options.connectionTimeout || CONNECTION_TIMEOUT);
|
|
315
|
+
this._socket.on('error', this._onConnectionSocketError);
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Handles connection errors with fallback to alternative addresses
|
|
319
|
+
*
|
|
320
|
+
* @param err Error object or message
|
|
321
|
+
* @param code Error code
|
|
322
|
+
* @internal
|
|
323
|
+
*/
|
|
324
|
+
_onConnectionError(err, code) {
|
|
325
|
+
clearTimeout(this._connectionTimeout);
|
|
326
|
+
// Check if we have fallback addresses to try
|
|
327
|
+
const canFallback = this._fallbackAddresses && this._fallbackAddresses.length && this.stage === 'init' && !this._destroyed;
|
|
328
|
+
if (!canFallback) {
|
|
329
|
+
// No more fallback addresses, report the error
|
|
330
|
+
this._onError(err, code, false, 'CONN');
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const nextHost = this._fallbackAddresses.shift();
|
|
334
|
+
this.logger.info({
|
|
335
|
+
tnx: 'network',
|
|
336
|
+
failedHost: this._connectOpts.host,
|
|
337
|
+
nextHost,
|
|
338
|
+
error: err.message || err
|
|
339
|
+
}, 'Connection to %s failed, trying %s', this._connectOpts.host, nextHost);
|
|
340
|
+
// Clean up current socket
|
|
341
|
+
if (this._socket) {
|
|
342
|
+
try {
|
|
343
|
+
this._socket.removeListener('error', this._onConnectionSocketError);
|
|
344
|
+
// Absorb any late teardown error (e.g. a TLS fallback socket emitting
|
|
345
|
+
// after destroy), mirroring the guard used in close()
|
|
346
|
+
this._socket.on('error', TEARDOWN_NOOP);
|
|
347
|
+
this._socket.destroy();
|
|
348
|
+
}
|
|
349
|
+
catch (_E) {
|
|
350
|
+
// ignore
|
|
351
|
+
}
|
|
352
|
+
this._socket = null;
|
|
353
|
+
}
|
|
354
|
+
// Update host and retry
|
|
355
|
+
this._connectOpts.host = nextHost;
|
|
356
|
+
this._connectToHost(this._connectOpts, this.secureConnection);
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Sends QUIT
|
|
360
|
+
*/
|
|
361
|
+
quit() {
|
|
362
|
+
this._sendCommand('QUIT');
|
|
363
|
+
this._responseActions.push(this.close);
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Closes the connection to the server
|
|
367
|
+
*/
|
|
368
|
+
close() {
|
|
369
|
+
clearTimeout(this._connectionTimeout);
|
|
370
|
+
clearTimeout(this._greetingTimeout);
|
|
371
|
+
this._responseActions = [];
|
|
372
|
+
// allow to run this function only once
|
|
373
|
+
if (this._closing) {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
this._closing = true;
|
|
377
|
+
const closeMethod = this.stage === 'init' ? 'destroy' : 'end';
|
|
378
|
+
this.logger.debug({
|
|
379
|
+
tnx: 'smtp'
|
|
380
|
+
}, 'Closing connection to the server using "%s"', closeMethod);
|
|
381
|
+
const socket = (this._socket && this._socket.socket) || this._socket;
|
|
382
|
+
// Detach any in-flight DATA stream from the socket so the source stream
|
|
383
|
+
// can be garbage-collected once the socket is gone.
|
|
384
|
+
if (this._currentDataStream) {
|
|
385
|
+
try {
|
|
386
|
+
this._currentDataStream.unpipe(this._socket);
|
|
387
|
+
}
|
|
388
|
+
catch (_E) {
|
|
389
|
+
// ignore
|
|
390
|
+
}
|
|
391
|
+
this._currentDataStream = false;
|
|
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
|
+
}
|
|
403
|
+
if (socket && !socket.destroyed) {
|
|
404
|
+
try {
|
|
405
|
+
// Clear socket timeout to prevent timer leaks
|
|
406
|
+
socket.setTimeout(0);
|
|
407
|
+
// Remove all listeners to allow proper garbage collection
|
|
408
|
+
socket.removeListener('data', this._onSocketData);
|
|
409
|
+
socket.removeListener('timeout', this._onSocketTimeout);
|
|
410
|
+
socket.removeListener('close', this._onSocketClose);
|
|
411
|
+
socket.removeListener('end', this._onSocketEnd);
|
|
412
|
+
socket.removeListener('error', this._onSocketError);
|
|
413
|
+
socket.removeListener('error', this._onConnectionSocketError);
|
|
414
|
+
// Absorb errors that may fire during socket teardown (e.g. server
|
|
415
|
+
// sending cleartext after TLS shutdown triggers ERR_SSL_BAD_RECORD_TYPE)
|
|
416
|
+
socket.on('error', TEARDOWN_NOOP);
|
|
417
|
+
socket[closeMethod]();
|
|
418
|
+
}
|
|
419
|
+
catch (_E) {
|
|
420
|
+
// just ignore
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
this._destroy();
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Authenticate user
|
|
427
|
+
*/
|
|
428
|
+
login(authData, callback) {
|
|
429
|
+
const isDestroyedMessage = this._isDestroyedMessage('login');
|
|
430
|
+
if (isDestroyedMessage) {
|
|
431
|
+
return callback(this._formatError(isDestroyedMessage, 'ECONNECTION', false, 'API'));
|
|
432
|
+
}
|
|
433
|
+
this._auth = authData || {};
|
|
434
|
+
// Select SASL authentication method
|
|
435
|
+
this._authMethod = (this._auth.method || '').toString().trim().toUpperCase() || false;
|
|
436
|
+
// XOAUTH2 needs a token generator or a custom handler, without either the method
|
|
437
|
+
// can not be run even when it is the only one the server advertised
|
|
438
|
+
const canUseXOAuth2 = !!this._auth.oauth2 || this.customAuth.has('XOAUTH2');
|
|
439
|
+
if (!this._authMethod && this._auth.oauth2 && !this._auth.credentials) {
|
|
440
|
+
this._authMethod = 'XOAUTH2';
|
|
441
|
+
}
|
|
442
|
+
else if (!this._authMethod || (this._authMethod === 'XOAUTH2' && !this._auth.oauth2)) {
|
|
443
|
+
// use the first supported method that can be run
|
|
444
|
+
const supported = this._supportedAuth.find(method => method !== 'XOAUTH2' || canUseXOAuth2);
|
|
445
|
+
this._authMethod = (supported || 'PLAIN').toUpperCase().trim();
|
|
446
|
+
}
|
|
447
|
+
// a token login needs no credentials, every other method and every custom handler
|
|
448
|
+
// gets them filled in from the user and pass values
|
|
449
|
+
if ((this._authMethod !== 'XOAUTH2' || this.customAuth.has('XOAUTH2')) &&
|
|
450
|
+
(!this._auth.credentials || !this._auth.credentials.user || !this._auth.credentials.pass)) {
|
|
451
|
+
if ((this._auth.user && this._auth.pass) || this.customAuth.has(this._authMethod)) {
|
|
452
|
+
this._auth.credentials = {
|
|
453
|
+
user: this._auth.user,
|
|
454
|
+
pass: this._auth.pass,
|
|
455
|
+
options: this._auth.options
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
return callback(this._formatError('Missing credentials for "' + this._authMethod + '"', 'EAUTH', false, 'API'));
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
if (this.customAuth.has(this._authMethod)) {
|
|
463
|
+
const handler = this.customAuth.get(this._authMethod);
|
|
464
|
+
let lastResponse;
|
|
465
|
+
let returned = false;
|
|
466
|
+
const resolve = () => {
|
|
467
|
+
if (returned) {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
returned = true;
|
|
471
|
+
this.logger.info({
|
|
472
|
+
tnx: 'smtp',
|
|
473
|
+
username: this._auth.user,
|
|
474
|
+
action: 'authenticated',
|
|
475
|
+
method: this._authMethod
|
|
476
|
+
}, 'User %s authenticated', JSON.stringify(this._auth.user));
|
|
477
|
+
this.authenticated = true;
|
|
478
|
+
callback(null, true);
|
|
479
|
+
};
|
|
480
|
+
const reject = (err) => {
|
|
481
|
+
if (returned) {
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
returned = true;
|
|
485
|
+
callback(this._formatError(err, 'EAUTH', lastResponse, 'AUTH ' + this._authMethod));
|
|
486
|
+
};
|
|
487
|
+
// one implementation serves both sendCommand overloads, the promise is returned
|
|
488
|
+
// exactly when no callback was given
|
|
489
|
+
const sendCommand = (cmd, done) => {
|
|
490
|
+
let promise;
|
|
491
|
+
if (!done) {
|
|
492
|
+
promise = new Promise((resolve, reject) => {
|
|
493
|
+
done = shared.callbackPromise(resolve, reject);
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
this._responseActions.push(str => {
|
|
497
|
+
lastResponse = str;
|
|
498
|
+
let codes = str.match(/^(\d+)(?:\s(\d+\.\d+\.\d+))?\s/);
|
|
499
|
+
let data = {
|
|
500
|
+
command: cmd,
|
|
501
|
+
response: str
|
|
502
|
+
};
|
|
503
|
+
if (codes) {
|
|
504
|
+
data.status = Number(codes[1]) || 0;
|
|
505
|
+
if (codes[2]) {
|
|
506
|
+
data.code = codes[2];
|
|
507
|
+
}
|
|
508
|
+
data.text = str.substr(codes[0].length);
|
|
509
|
+
}
|
|
510
|
+
else {
|
|
511
|
+
data.text = str;
|
|
512
|
+
data.status = 0; // just in case we need to perform numeric comparisons
|
|
513
|
+
}
|
|
514
|
+
done(null, data);
|
|
515
|
+
});
|
|
516
|
+
setImmediate(() => this._sendCommand(cmd));
|
|
517
|
+
return promise;
|
|
518
|
+
};
|
|
519
|
+
const handlerResponse = handler({
|
|
520
|
+
auth: this._auth,
|
|
521
|
+
method: this._authMethod,
|
|
522
|
+
extensions: [].concat(this._supportedExtensions),
|
|
523
|
+
authMethods: [].concat(this._supportedAuth),
|
|
524
|
+
maxAllowedSize: this._maxAllowedSize || false,
|
|
525
|
+
sendCommand: sendCommand,
|
|
526
|
+
resolve,
|
|
527
|
+
reject
|
|
528
|
+
});
|
|
529
|
+
if (handlerResponse && typeof handlerResponse.catch === 'function') {
|
|
530
|
+
// a promise was returned
|
|
531
|
+
handlerResponse.then(resolve).catch(reject);
|
|
532
|
+
}
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
switch (this._authMethod) {
|
|
536
|
+
case 'XOAUTH2':
|
|
537
|
+
this._handleXOauth2Token(false, callback);
|
|
538
|
+
return;
|
|
539
|
+
case 'LOGIN':
|
|
540
|
+
this._responseActions.push(str => {
|
|
541
|
+
this._actionAUTH_LOGIN_USER(str, callback);
|
|
542
|
+
});
|
|
543
|
+
this._sendCommand('AUTH LOGIN');
|
|
544
|
+
return;
|
|
545
|
+
case 'PLAIN':
|
|
546
|
+
this._responseActions.push(str => {
|
|
547
|
+
this._actionAUTHComplete(str, callback);
|
|
548
|
+
});
|
|
549
|
+
this._sendCommand('AUTH PLAIN ' +
|
|
550
|
+
Buffer.from(
|
|
551
|
+
//this._auth.user+'\u0000'+
|
|
552
|
+
'\u0000' + // skip authorization identity as it causes problems with some servers
|
|
553
|
+
this._auth.credentials.user +
|
|
554
|
+
'\u0000' +
|
|
555
|
+
this._auth.credentials.pass, 'utf-8').toString('base64'),
|
|
556
|
+
// log entry without passwords
|
|
557
|
+
'AUTH PLAIN ' +
|
|
558
|
+
Buffer.from(
|
|
559
|
+
//this._auth.user+'\u0000'+
|
|
560
|
+
'\u0000' + // skip authorization identity as it causes problems with some servers
|
|
561
|
+
this._auth.credentials.user +
|
|
562
|
+
'\u0000' +
|
|
563
|
+
'/* secret */', 'utf-8').toString('base64'));
|
|
564
|
+
return;
|
|
565
|
+
case 'CRAM-MD5':
|
|
566
|
+
this._responseActions.push(str => {
|
|
567
|
+
this._actionAUTH_CRAM_MD5(str, callback);
|
|
568
|
+
});
|
|
569
|
+
this._sendCommand('AUTH CRAM-MD5');
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
return callback(this._formatError('Unknown authentication method "' + this._authMethod + '"', 'EAUTH', false, 'API'));
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Sends a message
|
|
576
|
+
*
|
|
577
|
+
* @param envelope Envelope object, {from: addr, to: [addr]}
|
|
578
|
+
* @param message String, Buffer or a Stream
|
|
579
|
+
* @param done Callback to return once sending is completed
|
|
580
|
+
*/
|
|
581
|
+
send(envelope, message, done) {
|
|
582
|
+
// ensure that the callback is only called once. The public callback type has a
|
|
583
|
+
// required result, the error paths hand over the error alone
|
|
584
|
+
let returned = false;
|
|
585
|
+
const callback = (err, info) => {
|
|
586
|
+
if (returned) {
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
returned = true;
|
|
590
|
+
if (this._pendingSend && this._pendingSend.callback === callback) {
|
|
591
|
+
this._pendingSend = false;
|
|
592
|
+
}
|
|
593
|
+
done(err, info);
|
|
594
|
+
};
|
|
595
|
+
if (!message) {
|
|
596
|
+
return callback(this._formatError('Empty message', 'EMESSAGE', false, 'API'));
|
|
597
|
+
}
|
|
598
|
+
const isDestroyedMessage = this._isDestroyedMessage('send message');
|
|
599
|
+
if (isDestroyedMessage) {
|
|
600
|
+
return callback(this._formatError(isDestroyedMessage, 'ECONNECTION', false, 'API'));
|
|
601
|
+
}
|
|
602
|
+
// reject larger messages than allowed
|
|
603
|
+
if (this._maxAllowedSize && envelope.size > this._maxAllowedSize) {
|
|
604
|
+
setImmediate(() => {
|
|
605
|
+
callback(this._formatError('Message size larger than allowed ' + this._maxAllowedSize, 'EMESSAGE', false, 'MAIL FROM'));
|
|
606
|
+
});
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
const pendingSend = {
|
|
610
|
+
callback,
|
|
611
|
+
stream: false,
|
|
612
|
+
onStreamError: err => callback(this._formatError(err, 'ESTREAM', false, 'API'))
|
|
613
|
+
};
|
|
614
|
+
if (typeof message.on === 'function') {
|
|
615
|
+
pendingSend.stream = message;
|
|
616
|
+
pendingSend.stream.on('error', pendingSend.onStreamError);
|
|
617
|
+
}
|
|
618
|
+
this._pendingSend = pendingSend;
|
|
619
|
+
const startTime = Date.now();
|
|
620
|
+
this._setEnvelope(envelope, (err, info) => {
|
|
621
|
+
if (err) {
|
|
622
|
+
// create passthrough stream to consume to prevent OOM
|
|
623
|
+
const stream = new PassThrough();
|
|
624
|
+
if (typeof message.pipe === 'function') {
|
|
625
|
+
message.pipe(stream);
|
|
626
|
+
}
|
|
627
|
+
else {
|
|
628
|
+
stream.write(message);
|
|
629
|
+
stream.end();
|
|
630
|
+
}
|
|
631
|
+
return callback(err);
|
|
632
|
+
}
|
|
633
|
+
const envelopeTime = Date.now();
|
|
634
|
+
const stream = this._createSendStream((err, str) => {
|
|
635
|
+
if (err) {
|
|
636
|
+
return callback(err);
|
|
637
|
+
}
|
|
638
|
+
// the envelope info becomes the send result once the timings are on it
|
|
639
|
+
const result = info;
|
|
640
|
+
result.envelopeTime = envelopeTime - startTime;
|
|
641
|
+
result.messageTime = Date.now() - envelopeTime;
|
|
642
|
+
result.messageSize = stream.outByteCount;
|
|
643
|
+
result.response = str;
|
|
644
|
+
return callback(null, result);
|
|
645
|
+
});
|
|
646
|
+
if (typeof message.pipe === 'function') {
|
|
647
|
+
message.pipe(stream);
|
|
648
|
+
}
|
|
649
|
+
else {
|
|
650
|
+
stream.write(message);
|
|
651
|
+
stream.end();
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Resets connection state
|
|
657
|
+
*
|
|
658
|
+
* @param callback Callback to return once connection is reset
|
|
659
|
+
*/
|
|
660
|
+
reset(callback) {
|
|
661
|
+
const isDestroyedMessage = this._isDestroyedMessage('reset');
|
|
662
|
+
if (isDestroyedMessage) {
|
|
663
|
+
return callback(this._formatError(isDestroyedMessage, 'ECONNECTION', false, 'API'));
|
|
664
|
+
}
|
|
665
|
+
this._sendCommand('RSET');
|
|
666
|
+
this._responseActions.push(str => {
|
|
667
|
+
if (str.charAt(0) !== '2') {
|
|
668
|
+
return callback(this._formatError('Could not reset session state. response=' + str, 'EPROTOCOL', str, 'RSET'));
|
|
669
|
+
}
|
|
670
|
+
this._envelope = false;
|
|
671
|
+
return callback(null, true);
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Connection listener that is run when the connection to
|
|
676
|
+
* the server is opened
|
|
677
|
+
*
|
|
678
|
+
* @event
|
|
679
|
+
* @internal
|
|
680
|
+
*/
|
|
681
|
+
_onConnect() {
|
|
682
|
+
const socket = this._socket;
|
|
683
|
+
clearTimeout(this._connectionTimeout);
|
|
684
|
+
this.logger.info({
|
|
685
|
+
tnx: 'network',
|
|
686
|
+
localAddress: socket.localAddress,
|
|
687
|
+
localPort: socket.localPort,
|
|
688
|
+
remoteAddress: socket.remoteAddress,
|
|
689
|
+
remotePort: socket.remotePort
|
|
690
|
+
}, '%s established to %s:%s', this.secure ? 'Secure connection' : 'Connection', socket.remoteAddress, socket.remotePort);
|
|
691
|
+
if (this._destroyed) {
|
|
692
|
+
// Connection was established after we already had canceled it
|
|
693
|
+
this.close();
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
this.stage = 'connected';
|
|
697
|
+
// clear existing listeners for the socket
|
|
698
|
+
socket.removeListener('data', this._onSocketData);
|
|
699
|
+
socket.removeListener('timeout', this._onSocketTimeout);
|
|
700
|
+
socket.removeListener('close', this._onSocketClose);
|
|
701
|
+
socket.removeListener('end', this._onSocketEnd);
|
|
702
|
+
// Switch from connection-phase error handler to normal error handler
|
|
703
|
+
socket.removeListener('error', this._onConnectionSocketError);
|
|
704
|
+
// _upgradeConnection (options.connection + secure) may already have attached
|
|
705
|
+
// the normal handler; remove it first so we never end up with a duplicate
|
|
706
|
+
socket.removeListener('error', this._onSocketError);
|
|
707
|
+
socket.on('error', this._onSocketError);
|
|
708
|
+
socket.on('data', this._onSocketData);
|
|
709
|
+
socket.once('close', this._onSocketClose);
|
|
710
|
+
socket.once('end', this._onSocketEnd);
|
|
711
|
+
socket.setTimeout(this.options.socketTimeout || SOCKET_TIMEOUT);
|
|
712
|
+
socket.on('timeout', this._onSocketTimeout);
|
|
713
|
+
this._greetingTimeout = setTimeout(() => {
|
|
714
|
+
// if still waiting for greeting, give up
|
|
715
|
+
if (this._socket && !this._destroyed && this._responseActions[0] === this._actionGreeting) {
|
|
716
|
+
this._onError('Greeting never received', 'ETIMEDOUT', false, 'CONN');
|
|
717
|
+
}
|
|
718
|
+
}, this.options.greetingTimeout || GREETING_TIMEOUT);
|
|
719
|
+
this._responseActions.push(this._actionGreeting);
|
|
720
|
+
// we have a 'data' listener set up so resume socket if it was paused
|
|
721
|
+
socket.resume();
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* 'data' listener for data coming from the server
|
|
725
|
+
*
|
|
726
|
+
* @event
|
|
727
|
+
* @param chunk Data chunk coming from the server
|
|
728
|
+
* @internal
|
|
729
|
+
*/
|
|
730
|
+
_onData(chunk) {
|
|
731
|
+
if (this._destroyed || !chunk || !chunk.length) {
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
const maxResponseSize = this.options.maxResponseSize || MAX_RESPONSE_SIZE;
|
|
735
|
+
const data = chunk.toString('binary');
|
|
736
|
+
// A chunk without a line break only extends the line currently being received, so
|
|
737
|
+
// keep it in the remainder and leave that string unflattened. Splitting the whole
|
|
738
|
+
// remainder again on every chunk would rescan everything buffered for that line so
|
|
739
|
+
// far, which is quadratic in the length of a line the peer never terminates
|
|
740
|
+
if (!data.includes('\n')) {
|
|
741
|
+
this._remainder += data;
|
|
742
|
+
if (this._remainder.length > maxResponseSize) {
|
|
743
|
+
return this._onResponseTooLarge();
|
|
744
|
+
}
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
const lines = (this._remainder + data).split(/\r?\n/);
|
|
748
|
+
this._remainder = lines.pop();
|
|
749
|
+
for (let i = 0, len = lines.length; i < len; i++) {
|
|
750
|
+
if (this._responsePartial) {
|
|
751
|
+
this._responseQueue[this._responseQueue.length - 1] += '\n' + lines[i];
|
|
752
|
+
}
|
|
753
|
+
else {
|
|
754
|
+
this._responseQueue.push(lines[i]);
|
|
755
|
+
}
|
|
756
|
+
// The line just added is the last line of that queue entry, so it alone decides
|
|
757
|
+
// whether the reply is still partial. Looking for the last line of the accumulated
|
|
758
|
+
// entry instead would rescan a string that grows with every continuation line,
|
|
759
|
+
// which is quadratic in the size of the reply
|
|
760
|
+
this._responsePartial = isPartialLine(lines[i]);
|
|
761
|
+
// Checked as each line lands, so a peer that never completes a reply cannot keep
|
|
762
|
+
// buffering, and the limit does not depend on how it split its bytes into chunks
|
|
763
|
+
if (this._responsePartial && this._responseQueue[this._responseQueue.length - 1].length > maxResponseSize) {
|
|
764
|
+
return this._onResponseTooLarge();
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
if (this._remainder.length > maxResponseSize) {
|
|
768
|
+
return this._onResponseTooLarge();
|
|
769
|
+
}
|
|
770
|
+
if (this._responsePartial) {
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
this._processResponse();
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Drops a connection whose peer keeps extending a reply it never completes, releasing
|
|
777
|
+
* whatever was buffered for that reply
|
|
778
|
+
* @internal
|
|
779
|
+
*/
|
|
780
|
+
_onResponseTooLarge() {
|
|
781
|
+
this._remainder = '';
|
|
782
|
+
this._responseQueue = [];
|
|
783
|
+
this._responsePartial = false;
|
|
784
|
+
this._onError(new Error('Server response exceeds maximum allowed size'), 'EPROTOCOL', false, 'CONN');
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* 'error' listener for the socket
|
|
788
|
+
*
|
|
789
|
+
* @event
|
|
790
|
+
* @param err Error object
|
|
791
|
+
* @param type Error name
|
|
792
|
+
* @param data Server response that triggered the error, false if there is none
|
|
793
|
+
* @param command SMTP command that was in flight
|
|
794
|
+
* @internal
|
|
795
|
+
*/
|
|
796
|
+
_onError(err, type, data, command) {
|
|
797
|
+
clearTimeout(this._connectionTimeout);
|
|
798
|
+
clearTimeout(this._greetingTimeout);
|
|
799
|
+
if (this._destroyed) {
|
|
800
|
+
// just ignore, already closed
|
|
801
|
+
// this might happen when a socket is canceled because of reached timeout
|
|
802
|
+
// but the socket timeout error itself receives only after
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
err = this._formatError(err, type, data, command);
|
|
806
|
+
const transientCodes = ['ETIMEDOUT', 'ESOCKET', 'ECONNECTION'];
|
|
807
|
+
if (transientCodes.includes(err.code)) {
|
|
808
|
+
this.logger.warn(data, err.message);
|
|
809
|
+
}
|
|
810
|
+
else {
|
|
811
|
+
this.logger.error(data, err.message);
|
|
812
|
+
}
|
|
813
|
+
// close() forgets the send in flight, it is completed with this same error afterwards so
|
|
814
|
+
// a late message stream error has nothing left to report
|
|
815
|
+
const pendingSend = this._pendingSend;
|
|
816
|
+
this.emit('error', err);
|
|
817
|
+
this.close();
|
|
818
|
+
if (pendingSend) {
|
|
819
|
+
pendingSend.callback(err);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
/** @internal */
|
|
823
|
+
_formatError(message, type, response, command) {
|
|
824
|
+
let err;
|
|
825
|
+
if (/Error\]$/i.test(Object.prototype.toString.call(message))) {
|
|
826
|
+
err = message;
|
|
827
|
+
}
|
|
828
|
+
else {
|
|
829
|
+
err = new Error(message);
|
|
830
|
+
}
|
|
831
|
+
if (type && type !== 'Error') {
|
|
832
|
+
err.code = type;
|
|
833
|
+
}
|
|
834
|
+
if (response) {
|
|
835
|
+
err.response = response;
|
|
836
|
+
err.message += ': ' + response;
|
|
837
|
+
}
|
|
838
|
+
const responseCode = (typeof response === 'string' && Number((response.match(/^\d+/) || [])[0])) || false;
|
|
839
|
+
if (responseCode) {
|
|
840
|
+
err.responseCode = responseCode;
|
|
841
|
+
}
|
|
842
|
+
if (command) {
|
|
843
|
+
err.command = command;
|
|
844
|
+
}
|
|
845
|
+
return err;
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* 'close' listener for the socket
|
|
849
|
+
*
|
|
850
|
+
* @event
|
|
851
|
+
* @internal
|
|
852
|
+
*/
|
|
853
|
+
_onClose() {
|
|
854
|
+
let serverResponse = false;
|
|
855
|
+
if (this._remainder && this._remainder.trim()) {
|
|
856
|
+
this.lastServerResponse = serverResponse = decodeServerResponse(this._remainder.trim());
|
|
857
|
+
if (this.options.debug || this.options.transactionLog) {
|
|
858
|
+
this.logger.debug({
|
|
859
|
+
tnx: 'server'
|
|
860
|
+
}, serverResponse);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
this.logger.info({
|
|
864
|
+
tnx: 'network'
|
|
865
|
+
}, 'Connection closed');
|
|
866
|
+
// the unterminated remainder is only reported as a reply (and so gives the error a responseCode)
|
|
867
|
+
// when it starts like a complete failure reply, not for a fragment such as "55" or a 250
|
|
868
|
+
const failureResponse = typeof serverResponse === 'string' && /^[45]\d{2}[ -]/.test(serverResponse) ? serverResponse : false;
|
|
869
|
+
if (this.upgrading && !this._destroyed) {
|
|
870
|
+
return this._onError(new Error('Connection closed unexpectedly'), 'ETLS', failureResponse, 'CONN');
|
|
871
|
+
}
|
|
872
|
+
if (!failureResponse && this._responseActions[0] === this._actionGreeting && this._connectCallback && !this._destroyed) {
|
|
873
|
+
// A silent close before the greeting is handed to the connect() callback rather than
|
|
874
|
+
// emitted as 'error', callers that never saw an error for it must not start throwing one
|
|
875
|
+
const connectCallback = this._connectCallback;
|
|
876
|
+
this._connectCallback = false;
|
|
877
|
+
const err = this._formatError(new Error('Connection closed unexpectedly'), 'ECONNECTION', false, 'CONN');
|
|
878
|
+
this.logger.warn({ tnx: 'network' }, err.message);
|
|
879
|
+
connectCallback(err);
|
|
880
|
+
this.close();
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
if (failureResponse || (this._responseActions[0] !== this.close && !this._destroyed)) {
|
|
884
|
+
return this._onError(new Error('Connection closed unexpectedly'), 'ECONNECTION', failureResponse, 'CONN');
|
|
885
|
+
}
|
|
886
|
+
this._destroy();
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* 'end' listener for the socket
|
|
890
|
+
*
|
|
891
|
+
* @event
|
|
892
|
+
* @internal
|
|
893
|
+
*/
|
|
894
|
+
_onEnd() {
|
|
895
|
+
if (this._socket && !this._socket.destroyed) {
|
|
896
|
+
// Peer sent FIN, finish our half of the close gracefully rather
|
|
897
|
+
// than destroying. 'close' fires after the OS finalizes teardown.
|
|
898
|
+
this._socket.end();
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* 'timeout' listener for the socket
|
|
903
|
+
*
|
|
904
|
+
* @event
|
|
905
|
+
* @internal
|
|
906
|
+
*/
|
|
907
|
+
_onTimeout() {
|
|
908
|
+
return this._onError(new Error('Timeout'), 'ETIMEDOUT', false, 'CONN');
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* Destroys the client, emits 'end'
|
|
912
|
+
* @internal
|
|
913
|
+
*/
|
|
914
|
+
_destroy() {
|
|
915
|
+
if (this._destroyed) {
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
this._destroyed = true;
|
|
919
|
+
// keep the documented public flag in sync with the private state
|
|
920
|
+
this.destroyed = true;
|
|
921
|
+
// a connection the server dropped before the greeting would otherwise keep
|
|
922
|
+
// the greeting timer, and with it the process, alive until it fires
|
|
923
|
+
clearTimeout(this._connectionTimeout);
|
|
924
|
+
clearTimeout(this._greetingTimeout);
|
|
925
|
+
this._connectionTimeout = false;
|
|
926
|
+
this._greetingTimeout = false;
|
|
927
|
+
this.emit('end');
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
930
|
+
* Upgrades the connection to TLS
|
|
931
|
+
*
|
|
932
|
+
* @param callback Callback function to run when the connection
|
|
933
|
+
* has been secured
|
|
934
|
+
* @internal
|
|
935
|
+
*/
|
|
936
|
+
_upgradeConnection(callback) {
|
|
937
|
+
// RFC 3207 section 6: the client MUST discard any knowledge obtained from
|
|
938
|
+
// the server that was not received over the TLS-protected session. Drop any
|
|
939
|
+
// buffered input received before the handshake so a man-in-the-middle cannot
|
|
940
|
+
// inject plaintext bytes after the "220" reply (e.g. a CRLF-free fragment that
|
|
941
|
+
// would otherwise be prepended to the first post-TLS response and parsed as
|
|
942
|
+
// part of the secured EHLO capabilities). STARTTLS response injection.
|
|
943
|
+
this._remainder = '';
|
|
944
|
+
this._responseQueue = [];
|
|
945
|
+
this._responsePartial = false;
|
|
946
|
+
// do not remove all listeners or it breaks node v0.10 as there's
|
|
947
|
+
// apparently a 'finish' event set that would be cleared as well
|
|
948
|
+
// we can safely keep 'error', 'end', 'close' etc. events
|
|
949
|
+
const socketPlain = this._socket;
|
|
950
|
+
socketPlain.removeListener('data', this._onSocketData); // incoming data is going to be gibberish from this point onwards
|
|
951
|
+
socketPlain.removeListener('timeout', this._onSocketTimeout); // timeout will be re-set for the new socket object
|
|
952
|
+
const opts = Object.assign({
|
|
953
|
+
socket: socketPlain,
|
|
954
|
+
host: this.host
|
|
955
|
+
}, this.options.tls || {});
|
|
956
|
+
// ensure servername for SNI
|
|
957
|
+
if (this.servername && !opts.servername) {
|
|
958
|
+
opts.servername = this.servername;
|
|
959
|
+
}
|
|
960
|
+
// Remove all listeners from the plain socket to allow proper garbage
|
|
961
|
+
// collection. Used on both the TLS-success path and the synchronous
|
|
962
|
+
// tls.connect() throw path; either way the plain socket is done.
|
|
963
|
+
const removePlainSocketListeners = () => {
|
|
964
|
+
socketPlain.removeListener('close', this._onSocketClose);
|
|
965
|
+
socketPlain.removeListener('end', this._onSocketEnd);
|
|
966
|
+
socketPlain.removeListener('error', this._onSocketError);
|
|
967
|
+
// the connection-phase handler is attached when upgrading a pre-opened
|
|
968
|
+
// options.connection socket; strip it so nothing lingers on the plain socket
|
|
969
|
+
socketPlain.removeListener('error', this._onConnectionSocketError);
|
|
970
|
+
};
|
|
971
|
+
this.upgrading = true;
|
|
972
|
+
// tls.connect is not an asynchronous function however it may still throw errors and requires to be wrapped with try/catch
|
|
973
|
+
try {
|
|
974
|
+
this._socket = tls.connect(opts, () => {
|
|
975
|
+
this.secure = true;
|
|
976
|
+
this.upgrading = false;
|
|
977
|
+
this._socket.on('data', this._onSocketData);
|
|
978
|
+
removePlainSocketListeners();
|
|
979
|
+
return callback(null, true);
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
catch (err) {
|
|
983
|
+
removePlainSocketListeners();
|
|
984
|
+
return callback(err);
|
|
985
|
+
}
|
|
986
|
+
this._socket.on('error', this._onSocketError);
|
|
987
|
+
this._socket.once('close', this._onSocketClose);
|
|
988
|
+
this._socket.once('end', this._onSocketEnd);
|
|
989
|
+
this._socket.setTimeout(this.options.socketTimeout || SOCKET_TIMEOUT); // 10 min.
|
|
990
|
+
this._socket.on('timeout', this._onSocketTimeout);
|
|
991
|
+
// resume in case the socket was paused
|
|
992
|
+
socketPlain.resume();
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Processes queued responses from the server
|
|
996
|
+
* @internal
|
|
997
|
+
*/
|
|
998
|
+
_processResponse() {
|
|
999
|
+
if (!this._responseQueue.length) {
|
|
1000
|
+
return false;
|
|
1001
|
+
}
|
|
1002
|
+
const raw = (this._responseQueue.shift() || '').toString();
|
|
1003
|
+
// Skip unexpected empty lines without consuming a response action or
|
|
1004
|
+
// overwriting lastServerResponse; reprocess whatever else is queued.
|
|
1005
|
+
if (!raw.trim()) {
|
|
1006
|
+
setImmediate(() => this._processResponse());
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
if (isPartialResponse(raw)) {
|
|
1010
|
+
// the rest of the reply is still on its way: put it back on the queue and wait
|
|
1011
|
+
// rather than dropping it. It has not been received in full, so it must not be
|
|
1012
|
+
// reported as the last server response either
|
|
1013
|
+
this._responseQueue.unshift(raw);
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
const str = (this.lastServerResponse = decodeServerResponse(raw));
|
|
1017
|
+
if (this.options.debug || this.options.transactionLog) {
|
|
1018
|
+
this.logger.debug({
|
|
1019
|
+
tnx: 'server'
|
|
1020
|
+
}, str.replace(/\r?\n$/, ''));
|
|
1021
|
+
}
|
|
1022
|
+
const action = this._responseActions.shift();
|
|
1023
|
+
if (typeof action === 'function') {
|
|
1024
|
+
action.call(this, str);
|
|
1025
|
+
setImmediate(() => this._processResponse());
|
|
1026
|
+
}
|
|
1027
|
+
else {
|
|
1028
|
+
return this._onError(new Error('Unexpected Response'), 'EPROTOCOL', str, 'CONN');
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
/**
|
|
1032
|
+
* Send a command to the server, append \r\n
|
|
1033
|
+
*
|
|
1034
|
+
* @param str String to be sent to the server
|
|
1035
|
+
* @param logStr Optional string to be used for logging instead of the actual string
|
|
1036
|
+
* @internal
|
|
1037
|
+
*/
|
|
1038
|
+
_sendCommand(str, logStr) {
|
|
1039
|
+
if (this._destroyed) {
|
|
1040
|
+
// Connection already closed, can't send any more data
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
const socket = this._socket;
|
|
1044
|
+
if (socket.destroyed) {
|
|
1045
|
+
return this.close();
|
|
1046
|
+
}
|
|
1047
|
+
if (this.options.debug || this.options.transactionLog) {
|
|
1048
|
+
this.logger.debug({
|
|
1049
|
+
tnx: 'client'
|
|
1050
|
+
}, (logStr || str || '').toString().replace(/\r?\n$/, ''));
|
|
1051
|
+
}
|
|
1052
|
+
socket.write(Buffer.from(str + '\r\n', 'utf-8'));
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Initiates a new message by submitting envelope data, starting with
|
|
1056
|
+
* MAIL FROM: command
|
|
1057
|
+
*
|
|
1058
|
+
* @param envelope Envelope object in the form of
|
|
1059
|
+
* {from:'...', to:['...']}
|
|
1060
|
+
* or
|
|
1061
|
+
* {from:{address:'...',name:'...'}, to:[address:'...',name:'...']}
|
|
1062
|
+
* @param callback Callback to run once the envelope is processed
|
|
1063
|
+
* @internal
|
|
1064
|
+
*/
|
|
1065
|
+
_setEnvelope(envelope, callback) {
|
|
1066
|
+
const args = [];
|
|
1067
|
+
let useSmtpUtf8 = false;
|
|
1068
|
+
this._envelope = (envelope || {});
|
|
1069
|
+
this._envelope.from = ((this._envelope.from && this._envelope.from.address) || this._envelope.from || '')
|
|
1070
|
+
.toString()
|
|
1071
|
+
.trim();
|
|
1072
|
+
this._envelope.to = []
|
|
1073
|
+
.concat(this._envelope.to || [])
|
|
1074
|
+
.map(to => ((to && to.address) || to || '').toString().trim());
|
|
1075
|
+
if (!this._envelope.to.length) {
|
|
1076
|
+
return callback(this._formatError('No recipients defined', 'EENVELOPE', false, 'API'));
|
|
1077
|
+
}
|
|
1078
|
+
if (this._envelope.from && /[\r\n<>]/.test(this._envelope.from)) {
|
|
1079
|
+
return callback(this._formatError('Invalid sender ' + JSON.stringify(this._envelope.from), 'EENVELOPE', false, 'API'));
|
|
1080
|
+
}
|
|
1081
|
+
// check if the sender address uses only ASCII characters,
|
|
1082
|
+
// otherwise require usage of SMTPUTF8 extension
|
|
1083
|
+
if (/[\x80-\uFFFF]/.test(this._envelope.from)) {
|
|
1084
|
+
useSmtpUtf8 = true;
|
|
1085
|
+
}
|
|
1086
|
+
for (let i = 0, len = this._envelope.to.length; i < len; i++) {
|
|
1087
|
+
if (!this._envelope.to[i] || /[\r\n<>]/.test(this._envelope.to[i])) {
|
|
1088
|
+
return callback(this._formatError('Invalid recipient ' + JSON.stringify(this._envelope.to[i]), 'EENVELOPE', false, 'API'));
|
|
1089
|
+
}
|
|
1090
|
+
// check if the recipients addresses use only ASCII characters,
|
|
1091
|
+
// otherwise require usage of SMTPUTF8 extension
|
|
1092
|
+
if (/[\x80-\uFFFF]/.test(this._envelope.to[i])) {
|
|
1093
|
+
useSmtpUtf8 = true;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
// clone the recipients array for latter manipulation
|
|
1097
|
+
this._envelope.rcptQueue = [].concat(this._envelope.to || []);
|
|
1098
|
+
this._envelope.rejected = [];
|
|
1099
|
+
this._envelope.rejectedErrors = [];
|
|
1100
|
+
this._envelope.accepted = [];
|
|
1101
|
+
if (this._envelope.dsn) {
|
|
1102
|
+
try {
|
|
1103
|
+
this._envelope.dsn = this._setDsnEnvelope(this._envelope.dsn);
|
|
1104
|
+
}
|
|
1105
|
+
catch (err) {
|
|
1106
|
+
return callback(this._formatError('Invalid DSN ' + err.message, 'EENVELOPE', false, 'API'));
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
// RFC 8689: validate REQUIRETLS eligibility before queuing the MAIL FROM
|
|
1110
|
+
// response action, so a rejection here cannot leave an orphaned action in
|
|
1111
|
+
// _responseActions (which would consume the next reply and desync a reused
|
|
1112
|
+
// connection).
|
|
1113
|
+
if (this._envelope.requireTLSExtensionEnabled) {
|
|
1114
|
+
if (!this.secure) {
|
|
1115
|
+
return callback(this._formatError('REQUIRETLS can only be used over TLS connections (RFC 8689)', 'EREQUIRETLS', false, 'MAIL FROM'));
|
|
1116
|
+
}
|
|
1117
|
+
if (!this._supportedExtensions.includes('REQUIRETLS')) {
|
|
1118
|
+
return callback(this._formatError('Server does not support REQUIRETLS extension (RFC 8689)', 'EREQUIRETLS', false, 'MAIL FROM'));
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
this._responseActions.push(str => {
|
|
1122
|
+
this._actionMAIL(str, callback);
|
|
1123
|
+
});
|
|
1124
|
+
// If the server supports SMTPUTF8 and the envelope includes an internationalized
|
|
1125
|
+
// email address then append SMTPUTF8 keyword to the MAIL FROM command
|
|
1126
|
+
if (useSmtpUtf8 && this._supportedExtensions.includes('SMTPUTF8')) {
|
|
1127
|
+
args.push('SMTPUTF8');
|
|
1128
|
+
this._usingSmtpUtf8 = true;
|
|
1129
|
+
}
|
|
1130
|
+
// If the server supports 8BITMIME and the message might contain non-ascii bytes
|
|
1131
|
+
// then append the 8BITMIME keyword to the MAIL FROM command
|
|
1132
|
+
if (this._envelope.use8BitMime && this._supportedExtensions.includes('8BITMIME')) {
|
|
1133
|
+
args.push('BODY=8BITMIME');
|
|
1134
|
+
this._using8BitMime = true;
|
|
1135
|
+
}
|
|
1136
|
+
if (this._envelope.size && this._supportedExtensions.includes('SIZE')) {
|
|
1137
|
+
const sizeValue = Number(this._envelope.size) || 0;
|
|
1138
|
+
if (sizeValue > 0) {
|
|
1139
|
+
args.push('SIZE=' + sizeValue);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
// If the server supports DSN and the envelope includes an DSN prop
|
|
1143
|
+
// then append DSN params to the MAIL FROM command
|
|
1144
|
+
if (this._envelope.dsn && this._supportedExtensions.includes('DSN')) {
|
|
1145
|
+
if (this._envelope.dsn.ret) {
|
|
1146
|
+
args.push('RET=' + shared.encodeXText(this._envelope.dsn.ret));
|
|
1147
|
+
}
|
|
1148
|
+
if (this._envelope.dsn.envid) {
|
|
1149
|
+
args.push('ENVID=' + shared.encodeXText(this._envelope.dsn.envid));
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
// RFC 8689: append the REQUIRETLS keyword to MAIL FROM. Eligibility
|
|
1153
|
+
// (TLS connection + server support) was already validated above, before
|
|
1154
|
+
// the response action was queued.
|
|
1155
|
+
if (this._envelope.requireTLSExtensionEnabled) {
|
|
1156
|
+
args.push('REQUIRETLS');
|
|
1157
|
+
}
|
|
1158
|
+
this._sendCommand('MAIL FROM:<' + this._envelope.from + '>' + (args.length ? ' ' + args.join(' ') : ''));
|
|
1159
|
+
}
|
|
1160
|
+
/** @internal */
|
|
1161
|
+
_setDsnEnvelope(params) {
|
|
1162
|
+
let ret = (params.ret || params.return || '').toString().toUpperCase() || null;
|
|
1163
|
+
if (ret) {
|
|
1164
|
+
switch (ret) {
|
|
1165
|
+
case 'HDRS':
|
|
1166
|
+
case 'HEADERS':
|
|
1167
|
+
ret = 'HDRS';
|
|
1168
|
+
break;
|
|
1169
|
+
case 'FULL':
|
|
1170
|
+
case 'BODY':
|
|
1171
|
+
ret = 'FULL';
|
|
1172
|
+
break;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
if (ret && !['FULL', 'HDRS'].includes(ret)) {
|
|
1176
|
+
throw new Error('ret: ' + JSON.stringify(ret));
|
|
1177
|
+
}
|
|
1178
|
+
const envid = (params.envid || params.id || '').toString() || null;
|
|
1179
|
+
let notify = params.notify || null;
|
|
1180
|
+
if (notify) {
|
|
1181
|
+
if (typeof notify === 'string') {
|
|
1182
|
+
notify = notify.split(',');
|
|
1183
|
+
}
|
|
1184
|
+
notify = notify.map(n => n.trim().toUpperCase());
|
|
1185
|
+
const validNotify = ['NEVER', 'SUCCESS', 'FAILURE', 'DELAY'];
|
|
1186
|
+
const invalidNotify = notify.filter(n => !validNotify.includes(n));
|
|
1187
|
+
if (invalidNotify.length || (notify.length > 1 && notify.includes('NEVER'))) {
|
|
1188
|
+
throw new Error('notify: ' + JSON.stringify(notify.join(',')));
|
|
1189
|
+
}
|
|
1190
|
+
notify = notify.join(',');
|
|
1191
|
+
}
|
|
1192
|
+
let orcpt = (params.recipient || params.orcpt || '').toString() || null;
|
|
1193
|
+
if (orcpt && orcpt.indexOf(';') < 0) {
|
|
1194
|
+
orcpt = 'rfc822;' + orcpt;
|
|
1195
|
+
}
|
|
1196
|
+
return {
|
|
1197
|
+
ret,
|
|
1198
|
+
envid,
|
|
1199
|
+
notify,
|
|
1200
|
+
orcpt
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
/** @internal */
|
|
1204
|
+
_getDsnRcptToArgs() {
|
|
1205
|
+
const envelope = this._envelope;
|
|
1206
|
+
const args = [];
|
|
1207
|
+
// If the server supports DSN and the envelope includes an DSN prop
|
|
1208
|
+
// then append DSN params to the RCPT TO command
|
|
1209
|
+
if (envelope.dsn && this._supportedExtensions.includes('DSN')) {
|
|
1210
|
+
if (envelope.dsn.notify) {
|
|
1211
|
+
args.push('NOTIFY=' + shared.encodeXText(envelope.dsn.notify));
|
|
1212
|
+
}
|
|
1213
|
+
if (envelope.dsn.orcpt) {
|
|
1214
|
+
args.push('ORCPT=' + shared.encodeXText(envelope.dsn.orcpt));
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
return args.length ? ' ' + args.join(' ') : '';
|
|
1218
|
+
}
|
|
1219
|
+
/** @internal */
|
|
1220
|
+
_createSendStream(callback) {
|
|
1221
|
+
const envelope = this._envelope;
|
|
1222
|
+
const dataStream = new DataStream();
|
|
1223
|
+
if (this.options.lmtp) {
|
|
1224
|
+
envelope.accepted.forEach((recipient, i) => {
|
|
1225
|
+
const final = i === envelope.accepted.length - 1;
|
|
1226
|
+
this._responseActions.push(str => {
|
|
1227
|
+
this._actionLMTPStream(recipient, final, str, callback);
|
|
1228
|
+
});
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
else {
|
|
1232
|
+
this._responseActions.push(str => {
|
|
1233
|
+
this._actionSMTPStream(str, callback);
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
this._currentDataStream = dataStream;
|
|
1237
|
+
dataStream.pipe(this._socket, {
|
|
1238
|
+
end: false
|
|
1239
|
+
});
|
|
1240
|
+
if (this.options.debug) {
|
|
1241
|
+
const logStream = new PassThrough();
|
|
1242
|
+
logStream.on('readable', () => {
|
|
1243
|
+
let chunk;
|
|
1244
|
+
while ((chunk = logStream.read())) {
|
|
1245
|
+
this.logger.debug({
|
|
1246
|
+
tnx: 'message'
|
|
1247
|
+
}, chunk.toString('binary').replace(/\r?\n$/, ''));
|
|
1248
|
+
}
|
|
1249
|
+
});
|
|
1250
|
+
dataStream.pipe(logStream);
|
|
1251
|
+
}
|
|
1252
|
+
dataStream.once('end', () => {
|
|
1253
|
+
if (this._currentDataStream === dataStream) {
|
|
1254
|
+
this._currentDataStream = false;
|
|
1255
|
+
}
|
|
1256
|
+
this.logger.info({
|
|
1257
|
+
tnx: 'message',
|
|
1258
|
+
inByteCount: dataStream.inByteCount,
|
|
1259
|
+
outByteCount: dataStream.outByteCount
|
|
1260
|
+
}, '<%s bytes encoded mime message (source size %s bytes)>', dataStream.outByteCount, dataStream.inByteCount);
|
|
1261
|
+
});
|
|
1262
|
+
return dataStream;
|
|
1263
|
+
}
|
|
1264
|
+
/** ACTIONS **/
|
|
1265
|
+
/**
|
|
1266
|
+
* Will be run after the connection is created and the server sends
|
|
1267
|
+
* a greeting. If the incoming message starts with 220 initiate
|
|
1268
|
+
* SMTP session by sending EHLO command
|
|
1269
|
+
*
|
|
1270
|
+
* @param str Message from the server
|
|
1271
|
+
* @internal
|
|
1272
|
+
*/
|
|
1273
|
+
_actionGreeting(str) {
|
|
1274
|
+
clearTimeout(this._greetingTimeout);
|
|
1275
|
+
if (str.substr(0, 3) !== '220') {
|
|
1276
|
+
this._onError(new Error('Invalid greeting. response=' + str), 'EPROTOCOL', str, 'CONN');
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
if (this.options.lmtp) {
|
|
1280
|
+
this._responseActions.push(this._actionLHLO);
|
|
1281
|
+
this._sendCommand('LHLO ' + this.name);
|
|
1282
|
+
}
|
|
1283
|
+
else {
|
|
1284
|
+
this._responseActions.push(this._actionEHLO);
|
|
1285
|
+
this._sendCommand('EHLO ' + this.name);
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* Handles server response for LHLO command. If it yielded in
|
|
1290
|
+
* error, emit 'error', otherwise treat this as an EHLO response
|
|
1291
|
+
*
|
|
1292
|
+
* @param str Message from the server
|
|
1293
|
+
* @internal
|
|
1294
|
+
*/
|
|
1295
|
+
_actionLHLO(str) {
|
|
1296
|
+
if (str.charAt(0) !== '2') {
|
|
1297
|
+
this._onError(new Error('Invalid LHLO. response=' + str), 'EPROTOCOL', str, 'LHLO');
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
this._actionEHLO(str);
|
|
1301
|
+
}
|
|
1302
|
+
/**
|
|
1303
|
+
* Handles server response for EHLO command. If it yielded in
|
|
1304
|
+
* error, try HELO instead, otherwise initiate TLS negotiation
|
|
1305
|
+
* if STARTTLS is supported by the server or move into the
|
|
1306
|
+
* authentication phase.
|
|
1307
|
+
*
|
|
1308
|
+
* @param str Message from the server
|
|
1309
|
+
* @internal
|
|
1310
|
+
*/
|
|
1311
|
+
_actionEHLO(str) {
|
|
1312
|
+
let match;
|
|
1313
|
+
if (str.substr(0, 3) === '421') {
|
|
1314
|
+
this._onError(new Error('Server terminates connection. response=' + str), 'ECONNECTION', str, 'EHLO');
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
if (str.charAt(0) !== '2') {
|
|
1318
|
+
if (this.options.requireTLS) {
|
|
1319
|
+
this._onError(new Error('EHLO failed but HELO does not support required STARTTLS. response=' + str), 'ECONNECTION', str, 'EHLO');
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
// Try HELO instead
|
|
1323
|
+
this._responseActions.push(this._actionHELO);
|
|
1324
|
+
this._sendCommand('HELO ' + this.name);
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
this._ehloLines = str
|
|
1328
|
+
.split(/\r?\n/)
|
|
1329
|
+
.map(line => line.replace(/^\d+[ -]/, '').trim())
|
|
1330
|
+
.filter(line => line)
|
|
1331
|
+
.slice(1);
|
|
1332
|
+
// Detect if the server supports STARTTLS
|
|
1333
|
+
if (!this.secure && !this.options.ignoreTLS && (/[ -]STARTTLS\b/im.test(str) || this.options.requireTLS)) {
|
|
1334
|
+
this._sendCommand('STARTTLS');
|
|
1335
|
+
this._responseActions.push(this._actionSTARTTLS);
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
// Detect if the server supports SMTPUTF8
|
|
1339
|
+
if (/[ -]SMTPUTF8\b/im.test(str)) {
|
|
1340
|
+
this._supportedExtensions.push('SMTPUTF8');
|
|
1341
|
+
}
|
|
1342
|
+
// Detect if the server supports DSN
|
|
1343
|
+
if (/[ -]DSN\b/im.test(str)) {
|
|
1344
|
+
this._supportedExtensions.push('DSN');
|
|
1345
|
+
}
|
|
1346
|
+
// Detect if the server supports 8BITMIME
|
|
1347
|
+
if (/[ -]8BITMIME\b/im.test(str)) {
|
|
1348
|
+
this._supportedExtensions.push('8BITMIME');
|
|
1349
|
+
}
|
|
1350
|
+
// Detect if the server supports REQUIRETLS (RFC 8689)
|
|
1351
|
+
if (/[ -]REQUIRETLS\b/im.test(str)) {
|
|
1352
|
+
this._supportedExtensions.push('REQUIRETLS');
|
|
1353
|
+
}
|
|
1354
|
+
// Detect if the server supports PIPELINING
|
|
1355
|
+
if (/[ -]PIPELINING\b/im.test(str)) {
|
|
1356
|
+
this._supportedExtensions.push('PIPELINING');
|
|
1357
|
+
}
|
|
1358
|
+
// Detect if the server supports AUTH
|
|
1359
|
+
if (/[ -]AUTH\b/i.test(str)) {
|
|
1360
|
+
this.allowsAuth = true;
|
|
1361
|
+
}
|
|
1362
|
+
// Detect the advertised SASL mechanisms. The list is split into whole tokens rather
|
|
1363
|
+
// than searched for each name with a pattern: the patterns this replaced let two
|
|
1364
|
+
// whitespace runs overlap and backtracked quadratically over an AUTH line padded with
|
|
1365
|
+
// spaces, so a hostile server could stall the event loop from its EHLO reply
|
|
1366
|
+
// (GHSA-4ffr-jq9g-5ffx).
|
|
1367
|
+
const authMechanisms = new Set();
|
|
1368
|
+
for (const line of this._ehloLines) {
|
|
1369
|
+
const authMatch = /^AUTH[\s=](.*)/i.exec(line);
|
|
1370
|
+
if (authMatch) {
|
|
1371
|
+
for (const mechanism of authMatch[1].split(/[\s=]+/)) {
|
|
1372
|
+
authMechanisms.add(mechanism.toUpperCase());
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
// listed in order of preference, the first one the credentials allow is used
|
|
1377
|
+
for (const mechanism of ['PLAIN', 'LOGIN', 'CRAM-MD5', 'XOAUTH2']) {
|
|
1378
|
+
if (authMechanisms.has(mechanism)) {
|
|
1379
|
+
this._supportedAuth.push(mechanism);
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
// Detect if the server supports SIZE extensions (and the max allowed size)
|
|
1383
|
+
if ((match = str.match(/[ -]SIZE(?:[ \t]+(\d+))?/im))) {
|
|
1384
|
+
this._supportedExtensions.push('SIZE');
|
|
1385
|
+
this._maxAllowedSize = Number(match[1]) || 0;
|
|
1386
|
+
}
|
|
1387
|
+
this.emit('connect');
|
|
1388
|
+
}
|
|
1389
|
+
/**
|
|
1390
|
+
* Handles server response for HELO command. If it yielded in
|
|
1391
|
+
* error, emit 'error', otherwise move into the authentication phase.
|
|
1392
|
+
*
|
|
1393
|
+
* @param str Message from the server
|
|
1394
|
+
* @internal
|
|
1395
|
+
*/
|
|
1396
|
+
_actionHELO(str) {
|
|
1397
|
+
if (str.charAt(0) !== '2') {
|
|
1398
|
+
this._onError(new Error('Invalid HELO. response=' + str), 'EPROTOCOL', str, 'HELO');
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
// assume that authentication is enabled (most probably is not though)
|
|
1402
|
+
this.allowsAuth = true;
|
|
1403
|
+
this.emit('connect');
|
|
1404
|
+
}
|
|
1405
|
+
/**
|
|
1406
|
+
* Handles server response for STARTTLS command. If there's an error
|
|
1407
|
+
* try HELO instead, otherwise initiate TLS upgrade. If the upgrade
|
|
1408
|
+
* succeedes restart the EHLO
|
|
1409
|
+
*
|
|
1410
|
+
* @param str Message from the server
|
|
1411
|
+
* @internal
|
|
1412
|
+
*/
|
|
1413
|
+
_actionSTARTTLS(str) {
|
|
1414
|
+
if (str.charAt(0) !== '2') {
|
|
1415
|
+
if (this.options.opportunisticTLS) {
|
|
1416
|
+
this.logger.info({
|
|
1417
|
+
tnx: 'smtp'
|
|
1418
|
+
}, 'Failed STARTTLS upgrade, continuing unencrypted');
|
|
1419
|
+
this.emit('connect');
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
this._onError(new Error('Error upgrading connection with STARTTLS'), 'ETLS', str, 'STARTTLS');
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
this._upgradeConnection((err, secured) => {
|
|
1426
|
+
if (err) {
|
|
1427
|
+
this._onError(new Error('Error initiating TLS - ' + (err.message || err)), 'ETLS', false, 'STARTTLS');
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
this.logger.info({
|
|
1431
|
+
tnx: 'smtp'
|
|
1432
|
+
}, 'Connection upgraded with STARTTLS');
|
|
1433
|
+
if (secured) {
|
|
1434
|
+
// restart session
|
|
1435
|
+
if (this.options.lmtp) {
|
|
1436
|
+
this._responseActions.push(this._actionLHLO);
|
|
1437
|
+
this._sendCommand('LHLO ' + this.name);
|
|
1438
|
+
}
|
|
1439
|
+
else {
|
|
1440
|
+
this._responseActions.push(this._actionEHLO);
|
|
1441
|
+
this._sendCommand('EHLO ' + this.name);
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
else {
|
|
1445
|
+
this.emit('connect');
|
|
1446
|
+
}
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
/**
|
|
1450
|
+
* Handle the response for AUTH LOGIN command. We are expecting
|
|
1451
|
+
* '334 VXNlcm5hbWU6' (base64 for 'Username:'). Data to be sent as
|
|
1452
|
+
* response needs to be base64 encoded username. We do not need
|
|
1453
|
+
* exact match but settle with 334 response in general as some
|
|
1454
|
+
* hosts invalidly use a longer message than VXNlcm5hbWU6
|
|
1455
|
+
*
|
|
1456
|
+
* @param str Message from the server
|
|
1457
|
+
* @param callback Callback to run once the authentication sequence completes
|
|
1458
|
+
* @internal
|
|
1459
|
+
*/
|
|
1460
|
+
_actionAUTH_LOGIN_USER(str, callback) {
|
|
1461
|
+
if (!/^334[ -]/.test(str)) {
|
|
1462
|
+
// expecting '334 VXNlcm5hbWU6'
|
|
1463
|
+
callback(this._formatError('Invalid login sequence while waiting for "334 VXNlcm5hbWU6"', 'EAUTH', str, 'AUTH LOGIN'));
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
this._responseActions.push(str => {
|
|
1467
|
+
this._actionAUTH_LOGIN_PASS(str, callback);
|
|
1468
|
+
});
|
|
1469
|
+
this._sendCommand(Buffer.from(this._auth.credentials.user + '', 'utf-8').toString('base64'));
|
|
1470
|
+
}
|
|
1471
|
+
/**
|
|
1472
|
+
* Handle the response for AUTH CRAM-MD5 command. We are expecting
|
|
1473
|
+
* '334 <challenge string>'. Data to be sent as response needs to be
|
|
1474
|
+
* base64 decoded challenge string, MD5 hashed using the password as
|
|
1475
|
+
* a HMAC key, prefixed by the username and a space, and finally all
|
|
1476
|
+
* base64 encoded again.
|
|
1477
|
+
*
|
|
1478
|
+
* @param str Message from the server
|
|
1479
|
+
* @param callback Callback to run once the authentication sequence completes
|
|
1480
|
+
* @internal
|
|
1481
|
+
*/
|
|
1482
|
+
_actionAUTH_CRAM_MD5(str, callback) {
|
|
1483
|
+
const challengeMatch = str.match(/^334\s+(.+)$/);
|
|
1484
|
+
if (!challengeMatch) {
|
|
1485
|
+
return callback(this._formatError('Invalid login sequence while waiting for server challenge string', 'EAUTH', str, 'AUTH CRAM-MD5'));
|
|
1486
|
+
}
|
|
1487
|
+
// Decode from base64
|
|
1488
|
+
const base64decoded = Buffer.from(challengeMatch[1], 'base64').toString('ascii');
|
|
1489
|
+
const hmacMD5 = crypto.createHmac('md5', this._auth.credentials.pass);
|
|
1490
|
+
hmacMD5.update(base64decoded);
|
|
1491
|
+
const prepended = this._auth.credentials.user + ' ' + hmacMD5.digest('hex');
|
|
1492
|
+
this._responseActions.push(str => {
|
|
1493
|
+
this._actionAUTH_CRAM_MD5_PASS(str, callback);
|
|
1494
|
+
});
|
|
1495
|
+
this._sendCommand(Buffer.from(prepended).toString('base64'),
|
|
1496
|
+
// hidden hash for logs
|
|
1497
|
+
Buffer.from(this._auth.credentials.user + ' /* secret */').toString('base64'));
|
|
1498
|
+
}
|
|
1499
|
+
/**
|
|
1500
|
+
* Handles the response to CRAM-MD5 authentication, if there's no error,
|
|
1501
|
+
* the user can be considered logged in. Start waiting for a message to send
|
|
1502
|
+
*
|
|
1503
|
+
* @param str Message from the server
|
|
1504
|
+
* @param callback Callback to run once the authentication sequence completes
|
|
1505
|
+
* @internal
|
|
1506
|
+
*/
|
|
1507
|
+
_actionAUTH_CRAM_MD5_PASS(str, callback) {
|
|
1508
|
+
if (!str.match(/^235\s+/)) {
|
|
1509
|
+
return callback(this._formatError('Invalid login sequence while waiting for "235"', 'EAUTH', str, 'AUTH CRAM-MD5'));
|
|
1510
|
+
}
|
|
1511
|
+
this.logger.info({
|
|
1512
|
+
tnx: 'smtp',
|
|
1513
|
+
username: this._auth.user,
|
|
1514
|
+
action: 'authenticated',
|
|
1515
|
+
method: this._authMethod
|
|
1516
|
+
}, 'User %s authenticated', JSON.stringify(this._auth.user));
|
|
1517
|
+
this.authenticated = true;
|
|
1518
|
+
callback(null, true);
|
|
1519
|
+
}
|
|
1520
|
+
/**
|
|
1521
|
+
* Handle the response for AUTH LOGIN command. We are expecting
|
|
1522
|
+
* '334 UGFzc3dvcmQ6' (base64 for 'Password:'). Data to be sent as
|
|
1523
|
+
* response needs to be base64 encoded password.
|
|
1524
|
+
*
|
|
1525
|
+
* @param str Message from the server
|
|
1526
|
+
* @param callback Callback to run once the authentication sequence completes
|
|
1527
|
+
* @internal
|
|
1528
|
+
*/
|
|
1529
|
+
_actionAUTH_LOGIN_PASS(str, callback) {
|
|
1530
|
+
if (!/^334[ -]/.test(str)) {
|
|
1531
|
+
// expecting '334 UGFzc3dvcmQ6'
|
|
1532
|
+
return callback(this._formatError('Invalid login sequence while waiting for "334 UGFzc3dvcmQ6"', 'EAUTH', str, 'AUTH LOGIN'));
|
|
1533
|
+
}
|
|
1534
|
+
this._responseActions.push(str => {
|
|
1535
|
+
this._actionAUTHComplete(str, callback);
|
|
1536
|
+
});
|
|
1537
|
+
this._sendCommand(Buffer.from((this._auth.credentials.pass || '').toString(), 'utf-8').toString('base64'),
|
|
1538
|
+
// Hidden pass for logs
|
|
1539
|
+
Buffer.from('/* secret */', 'utf-8').toString('base64'));
|
|
1540
|
+
}
|
|
1541
|
+
/**
|
|
1542
|
+
* Handles the response for authentication, if there's no error,
|
|
1543
|
+
* the user can be considered logged in. Start waiting for a message to send
|
|
1544
|
+
*
|
|
1545
|
+
* @param str Message from the server
|
|
1546
|
+
* @param isRetry True if this is a retry after a failed login, or the callback itself
|
|
1547
|
+
* @param [callback] Callback to run once the authentication sequence completes
|
|
1548
|
+
* @internal
|
|
1549
|
+
*/
|
|
1550
|
+
_actionAUTHComplete(str, isRetry, callback) {
|
|
1551
|
+
if (!callback && typeof isRetry === 'function') {
|
|
1552
|
+
callback = isRetry;
|
|
1553
|
+
isRetry = false;
|
|
1554
|
+
}
|
|
1555
|
+
if (str.substr(0, 3) === '334') {
|
|
1556
|
+
this._responseActions.push(str => {
|
|
1557
|
+
if (isRetry || this._authMethod !== 'XOAUTH2') {
|
|
1558
|
+
this._actionAUTHComplete(str, true, callback);
|
|
1559
|
+
}
|
|
1560
|
+
else {
|
|
1561
|
+
// fetch a new OAuth2 access token
|
|
1562
|
+
setImmediate(() => this._handleXOauth2Token(true, callback));
|
|
1563
|
+
}
|
|
1564
|
+
});
|
|
1565
|
+
this._sendCommand('');
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
if (str.charAt(0) !== '2') {
|
|
1569
|
+
this.logger.info({
|
|
1570
|
+
tnx: 'smtp',
|
|
1571
|
+
username: this._auth.user,
|
|
1572
|
+
action: 'authfail',
|
|
1573
|
+
method: this._authMethod
|
|
1574
|
+
}, 'User %s failed to authenticate', JSON.stringify(this._auth.user));
|
|
1575
|
+
return callback(this._formatError('Invalid login', 'EAUTH', str, 'AUTH ' + this._authMethod));
|
|
1576
|
+
}
|
|
1577
|
+
this.logger.info({
|
|
1578
|
+
tnx: 'smtp',
|
|
1579
|
+
username: this._auth.user,
|
|
1580
|
+
action: 'authenticated',
|
|
1581
|
+
method: this._authMethod
|
|
1582
|
+
}, 'User %s authenticated', JSON.stringify(this._auth.user));
|
|
1583
|
+
this.authenticated = true;
|
|
1584
|
+
callback(null, true);
|
|
1585
|
+
}
|
|
1586
|
+
/**
|
|
1587
|
+
* Handle response for a MAIL FROM: command
|
|
1588
|
+
*
|
|
1589
|
+
* @param str Message from the server
|
|
1590
|
+
* @param callback Callback to run once the envelope is processed
|
|
1591
|
+
* @internal
|
|
1592
|
+
*/
|
|
1593
|
+
_actionMAIL(str, callback) {
|
|
1594
|
+
const envelope = this._envelope;
|
|
1595
|
+
if (Number(str.charAt(0)) !== 2) {
|
|
1596
|
+
const message = this._usingSmtpUtf8 && /^550 /.test(str) && /[\x80-\uFFFF]/.test(envelope.from)
|
|
1597
|
+
? 'Internationalized mailbox name not allowed'
|
|
1598
|
+
: 'Mail command failed';
|
|
1599
|
+
return callback(this._formatError(message, 'EENVELOPE', str, 'MAIL FROM'));
|
|
1600
|
+
}
|
|
1601
|
+
if (!envelope.rcptQueue.length) {
|
|
1602
|
+
return callback(this._formatError("Can't send mail - no recipients defined", 'EENVELOPE', false, 'API'));
|
|
1603
|
+
}
|
|
1604
|
+
this._recipientQueue = [];
|
|
1605
|
+
const usePipelining = this._supportedExtensions.includes('PIPELINING');
|
|
1606
|
+
do {
|
|
1607
|
+
const curRecipient = envelope.rcptQueue.shift();
|
|
1608
|
+
this._recipientQueue.push(curRecipient);
|
|
1609
|
+
this._responseActions.push(str => {
|
|
1610
|
+
this._actionRCPT(str, callback);
|
|
1611
|
+
});
|
|
1612
|
+
this._sendCommand('RCPT TO:<' + curRecipient + '>' + this._getDsnRcptToArgs());
|
|
1613
|
+
} while (usePipelining && envelope.rcptQueue.length);
|
|
1614
|
+
}
|
|
1615
|
+
/**
|
|
1616
|
+
* Handle response for a RCPT TO: command
|
|
1617
|
+
*
|
|
1618
|
+
* @param str Message from the server
|
|
1619
|
+
* @param callback Callback to run once the envelope is processed
|
|
1620
|
+
* @internal
|
|
1621
|
+
*/
|
|
1622
|
+
_actionRCPT(str, callback) {
|
|
1623
|
+
const envelope = this._envelope;
|
|
1624
|
+
let err;
|
|
1625
|
+
const curRecipient = this._recipientQueue.shift();
|
|
1626
|
+
if (Number(str.charAt(0)) !== 2) {
|
|
1627
|
+
// this is a soft error
|
|
1628
|
+
const message = this._usingSmtpUtf8 && /^553 /.test(str) && /[\x80-\uFFFF]/.test(curRecipient)
|
|
1629
|
+
? 'Internationalized mailbox name not allowed'
|
|
1630
|
+
: 'Recipient command failed';
|
|
1631
|
+
envelope.rejected.push(curRecipient);
|
|
1632
|
+
// store error for the failed recipient
|
|
1633
|
+
err = this._formatError(message, 'EENVELOPE', str, 'RCPT TO');
|
|
1634
|
+
err.recipient = curRecipient;
|
|
1635
|
+
envelope.rejectedErrors.push(err);
|
|
1636
|
+
}
|
|
1637
|
+
else {
|
|
1638
|
+
envelope.accepted.push(curRecipient);
|
|
1639
|
+
}
|
|
1640
|
+
if (!envelope.rcptQueue.length && !this._recipientQueue.length) {
|
|
1641
|
+
if (envelope.rejected.length < envelope.to.length) {
|
|
1642
|
+
this._responseActions.push(str => {
|
|
1643
|
+
this._actionDATA(str, callback);
|
|
1644
|
+
});
|
|
1645
|
+
this._sendCommand('DATA');
|
|
1646
|
+
}
|
|
1647
|
+
else {
|
|
1648
|
+
// report a temporary rejection when there is one, taking the last reply would mark the
|
|
1649
|
+
// whole message as permanently failed although some recipients were only deferred
|
|
1650
|
+
const deferred = envelope.rejectedErrors.find(rejectedErr => rejectedErr.responseCode && rejectedErr.responseCode < 500);
|
|
1651
|
+
const reply = deferred?.response ?? str;
|
|
1652
|
+
err = this._formatError("Can't send mail - all recipients were rejected", 'EENVELOPE', reply, 'RCPT TO');
|
|
1653
|
+
err.rejected = envelope.rejected;
|
|
1654
|
+
err.rejectedErrors = envelope.rejectedErrors;
|
|
1655
|
+
return callback(err);
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
else if (envelope.rcptQueue.length) {
|
|
1659
|
+
const nextRecipient = envelope.rcptQueue.shift();
|
|
1660
|
+
this._recipientQueue.push(nextRecipient);
|
|
1661
|
+
this._responseActions.push(str => {
|
|
1662
|
+
this._actionRCPT(str, callback);
|
|
1663
|
+
});
|
|
1664
|
+
this._sendCommand('RCPT TO:<' + nextRecipient + '>' + this._getDsnRcptToArgs());
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
/**
|
|
1668
|
+
* Handle response for a DATA command
|
|
1669
|
+
*
|
|
1670
|
+
* @param str Message from the server
|
|
1671
|
+
* @param callback Callback to run once the envelope is processed
|
|
1672
|
+
* @internal
|
|
1673
|
+
*/
|
|
1674
|
+
_actionDATA(str, callback) {
|
|
1675
|
+
const envelope = this._envelope;
|
|
1676
|
+
// response should be 354 but according to this issue https://github.com/eleith/emailjs/issues/24
|
|
1677
|
+
// some servers might use 250 instead, so lets check for 2 or 3 as the first digit
|
|
1678
|
+
if (!/^[23]/.test(str)) {
|
|
1679
|
+
return callback(this._formatError('Data command failed', 'EENVELOPE', str, 'DATA'));
|
|
1680
|
+
}
|
|
1681
|
+
const response = {
|
|
1682
|
+
accepted: envelope.accepted,
|
|
1683
|
+
rejected: envelope.rejected
|
|
1684
|
+
};
|
|
1685
|
+
if (this._ehloLines && this._ehloLines.length) {
|
|
1686
|
+
response.ehlo = this._ehloLines;
|
|
1687
|
+
}
|
|
1688
|
+
if (envelope.rejectedErrors.length) {
|
|
1689
|
+
response.rejectedErrors = envelope.rejectedErrors;
|
|
1690
|
+
}
|
|
1691
|
+
callback(null, response);
|
|
1692
|
+
}
|
|
1693
|
+
/**
|
|
1694
|
+
* Handle response for a DATA stream when using SMTP
|
|
1695
|
+
* We expect a single response that defines if the sending succeeded or failed
|
|
1696
|
+
*
|
|
1697
|
+
* @param str Message from the server
|
|
1698
|
+
* @param callback Callback to run with the final send result
|
|
1699
|
+
* @internal
|
|
1700
|
+
*/
|
|
1701
|
+
_actionSMTPStream(str, callback) {
|
|
1702
|
+
if (Number(str.charAt(0)) !== 2) {
|
|
1703
|
+
return callback(this._formatError('Message failed', 'EMESSAGE', str, 'DATA'));
|
|
1704
|
+
}
|
|
1705
|
+
return callback(null, str);
|
|
1706
|
+
}
|
|
1707
|
+
/**
|
|
1708
|
+
* Handle response for a DATA stream
|
|
1709
|
+
* We expect a separate response for every recipient. All recipients can either
|
|
1710
|
+
* succeed or fail separately
|
|
1711
|
+
*
|
|
1712
|
+
* @param recipient The recipient this response applies to
|
|
1713
|
+
* @param final Is this the final recipient?
|
|
1714
|
+
* @param str Message from the server
|
|
1715
|
+
* @param callback Callback to run with the final send result
|
|
1716
|
+
* @internal
|
|
1717
|
+
*/
|
|
1718
|
+
_actionLMTPStream(recipient, final, str, callback) {
|
|
1719
|
+
const envelope = this._envelope;
|
|
1720
|
+
let err;
|
|
1721
|
+
if (Number(str.charAt(0)) !== 2) {
|
|
1722
|
+
// Message failed
|
|
1723
|
+
err = this._formatError('Message failed for recipient ' + recipient, 'EMESSAGE', str, 'DATA');
|
|
1724
|
+
err.recipient = recipient;
|
|
1725
|
+
envelope.rejected.push(recipient);
|
|
1726
|
+
envelope.rejectedErrors.push(err);
|
|
1727
|
+
for (let i = 0, len = envelope.accepted.length; i < len; i++) {
|
|
1728
|
+
if (envelope.accepted[i] === recipient) {
|
|
1729
|
+
envelope.accepted.splice(i, 1);
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
if (final) {
|
|
1734
|
+
return callback(null, str);
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
/** @internal */
|
|
1738
|
+
_handleXOauth2Token(isRetry, callback) {
|
|
1739
|
+
this._auth.oauth2.getToken(isRetry, (err, accessToken) => {
|
|
1740
|
+
if (err) {
|
|
1741
|
+
this.logger.info({
|
|
1742
|
+
tnx: 'smtp',
|
|
1743
|
+
username: this._auth.user,
|
|
1744
|
+
action: 'authfail',
|
|
1745
|
+
method: this._authMethod
|
|
1746
|
+
}, 'User %s failed to authenticate', JSON.stringify(this._auth.user));
|
|
1747
|
+
return callback(this._formatError(err, 'EAUTH', false, 'AUTH XOAUTH2'));
|
|
1748
|
+
}
|
|
1749
|
+
this._responseActions.push(str => {
|
|
1750
|
+
this._actionAUTHComplete(str, isRetry, callback);
|
|
1751
|
+
});
|
|
1752
|
+
this._sendCommand('AUTH XOAUTH2 ' + this._auth.oauth2.buildXOAuth2Token(accessToken),
|
|
1753
|
+
// Hidden for logs
|
|
1754
|
+
'AUTH XOAUTH2 ' + this._auth.oauth2.buildXOAuth2Token('/* secret */'));
|
|
1755
|
+
});
|
|
1756
|
+
}
|
|
1757
|
+
/**
|
|
1758
|
+
*
|
|
1759
|
+
* @param command
|
|
1760
|
+
* @internal
|
|
1761
|
+
*/
|
|
1762
|
+
_isDestroyedMessage(command) {
|
|
1763
|
+
if (this._destroyed) {
|
|
1764
|
+
return 'Cannot ' + command + ' - smtp connection is already destroyed.';
|
|
1765
|
+
}
|
|
1766
|
+
if (this._socket) {
|
|
1767
|
+
if (this._socket.destroyed) {
|
|
1768
|
+
return 'Cannot ' + command + ' - smtp connection socket is already destroyed.';
|
|
1769
|
+
}
|
|
1770
|
+
if (!this._socket.writable) {
|
|
1771
|
+
return 'Cannot ' + command + ' - smtp connection socket is already half-closed.';
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
/** @internal */
|
|
1776
|
+
_getHostname() {
|
|
1777
|
+
// defaul hostname is machine hostname or [IP]
|
|
1778
|
+
let defaultHostname;
|
|
1779
|
+
try {
|
|
1780
|
+
defaultHostname = os.hostname() || '';
|
|
1781
|
+
}
|
|
1782
|
+
catch (_err) {
|
|
1783
|
+
// fails on windows 7
|
|
1784
|
+
defaultHostname = 'localhost';
|
|
1785
|
+
}
|
|
1786
|
+
// ignore if not FQDN
|
|
1787
|
+
if (!defaultHostname || defaultHostname.indexOf('.') < 0) {
|
|
1788
|
+
defaultHostname = '[127.0.0.1]';
|
|
1789
|
+
}
|
|
1790
|
+
// IP should be enclosed in []
|
|
1791
|
+
if (defaultHostname.match(/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/)) {
|
|
1792
|
+
defaultHostname = '[' + defaultHostname + ']';
|
|
1793
|
+
}
|
|
1794
|
+
return defaultHostname;
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
export default SMTPConnection;
|