Haraka 3.1.4 → 3.1.6
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/.prettierignore +1 -1
- package/{Changes.md → CHANGELOG.md} +34 -0
- package/CONTRIBUTORS.md +26 -26
- package/README.md +68 -93
- package/SECURITY.md +178 -0
- package/bin/haraka +7 -14
- package/config/plugins +0 -3
- package/docs/Connection.md +126 -39
- package/docs/CoreConfig.md +92 -74
- package/docs/HAProxy.md +41 -25
- package/docs/Logging.md +68 -38
- package/docs/Outbound.md +124 -179
- package/docs/Plugins.md +38 -59
- package/docs/Transaction.md +78 -83
- package/docs/Tutorial.md +122 -209
- package/docs/plugins/aliases.md +1 -141
- package/docs/plugins/auth/auth_ldap.md +2 -39
- package/docs/plugins/max_unrecognized_commands.md +4 -18
- package/docs/plugins/process_title.md +3 -3
- package/docs/plugins/reseed_rng.md +11 -13
- package/docs/plugins/tls.md +7 -7
- package/docs/plugins/toobusy.md +10 -4
- package/docs/tutorials/SettingUpOutbound.md +40 -48
- package/endpoint.js +32 -2
- package/outbound/hmail.js +3 -2
- package/outbound/index.js +3 -0
- package/package.json +21 -34
- package/plugins/queue/smtp_forward.js +4 -4
- package/run_tests +3 -15
- package/server.js +17 -7
- package/smtp_client.js +8 -6
- package/test/connection.js +234 -0
- package/test/endpoint.js +32 -4
- package/test/host_pool.js +57 -31
- package/test/logger.js +75 -135
- package/test/outbound/bounce_net_errors.js +87 -131
- package/test/outbound/bounce_rfc3464.js +177 -254
- package/test/outbound/hmail.js +19 -0
- package/test/outbound/index.js +189 -0
- package/test/outbound/queue.js +92 -0
- package/test/plugins/auth/auth_base.js +39 -44
- package/test/plugins/auth/auth_vpopmaild.js +8 -9
- package/test/plugins/queue/smtp_forward.js +953 -183
- package/test/plugins/rcpt_to.host_list_base.js +58 -93
- package/test/plugins/rcpt_to.in_host_list.js +126 -175
- package/test/plugins/record_envelope_addresses.js +8 -8
- package/test/plugins/status.js +10 -10
- package/test/plugins/tls.js +9 -19
- package/test/plugins/xclient.js +75 -110
- package/test/plugins.js +10 -13
- package/test/rfc1869.js +50 -70
- package/test/server.js +438 -421
- package/test/smtp_client.js +1192 -218
- package/test/tls_socket.js +242 -0
- package/tls_socket.js +18 -22
package/test/smtp_client.js
CHANGED
|
@@ -1,303 +1,1277 @@
|
|
|
1
|
-
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { describe, it, beforeEach, afterEach } = require('node:test')
|
|
4
|
+
const assert = require('node:assert/strict')
|
|
5
|
+
const { PassThrough } = require('node:stream')
|
|
2
6
|
const path = require('node:path')
|
|
3
7
|
|
|
8
|
+
const { Address } = require('address-rfc2821')
|
|
4
9
|
const fixtures = require('haraka-test-fixtures')
|
|
10
|
+
const net_utils = require('haraka-net-utils')
|
|
5
11
|
const message = require('haraka-email-message')
|
|
6
12
|
|
|
7
|
-
const
|
|
8
|
-
const
|
|
13
|
+
const smtp_client_module = require('../smtp_client')
|
|
14
|
+
const { smtp_client: SMTPClient } = smtp_client_module
|
|
15
|
+
const tls_socket = require('../tls_socket')
|
|
16
|
+
const { Socket } = require('./fixtures/line_socket')
|
|
17
|
+
|
|
18
|
+
// State enum values mirror the module-internal STATE object
|
|
19
|
+
const STATE = { IDLE: 1, ACTIVE: 2, RELEASED: 3, DESTROYED: 4 }
|
|
20
|
+
|
|
21
|
+
// ─── Socket / client helpers ─────────────────────────────────────────────────
|
|
9
22
|
|
|
10
|
-
function
|
|
11
|
-
|
|
23
|
+
function makeSocket() {
|
|
24
|
+
const s = new Socket(25, 'localhost')
|
|
25
|
+
s.write = () => true
|
|
26
|
+
s.upgrade = (opts, cb) => cb && cb(true, null, {}, { name: 'AES128-GCM-SHA256', version: 'TLSv1.3' })
|
|
27
|
+
s.remoteAddress = '1.2.3.4'
|
|
28
|
+
return s
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeClient(opts = {}) {
|
|
32
|
+
const socket = 'socket' in opts ? opts.socket : makeSocket()
|
|
33
|
+
return new SMTPClient({
|
|
34
|
+
host: 'mx.example.com',
|
|
12
35
|
port: 25,
|
|
13
|
-
|
|
14
|
-
connect_timeout: 30,
|
|
36
|
+
connect_timeout: 10,
|
|
15
37
|
idle_timeout: 30,
|
|
16
38
|
socket,
|
|
17
|
-
|
|
39
|
+
...opts,
|
|
40
|
+
})
|
|
18
41
|
}
|
|
19
42
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
43
|
+
// Stub tls_socket.connect so get_client / get_client_plugin don't open real sockets
|
|
44
|
+
let _origTlsConnect
|
|
45
|
+
function mockTlsConnect(socketFactory) {
|
|
46
|
+
_origTlsConnect = tls_socket.connect
|
|
47
|
+
tls_socket.connect =
|
|
48
|
+
socketFactory ||
|
|
49
|
+
(() => {
|
|
50
|
+
const s = makeSocket()
|
|
51
|
+
net_utils.add_line_processor(s)
|
|
52
|
+
return s
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
function restoreTlsConnect() {
|
|
56
|
+
if (_origTlsConnect) tls_socket.connect = _origTlsConnect
|
|
57
|
+
}
|
|
23
58
|
|
|
24
|
-
|
|
25
|
-
|
|
59
|
+
function makeConnection(overrides = {}) {
|
|
60
|
+
const conn = fixtures.connection.createConnection()
|
|
61
|
+
conn.server = { notes: {} }
|
|
62
|
+
conn.hello = { host: 'client.example.com' }
|
|
63
|
+
conn.local = { host: 'relay.example.com' }
|
|
64
|
+
conn.remote = { ip: '1.2.3.4' }
|
|
65
|
+
conn.transaction = null
|
|
66
|
+
return Object.assign(conn, overrides)
|
|
67
|
+
}
|
|
26
68
|
|
|
27
|
-
|
|
69
|
+
function makePlugin() {
|
|
70
|
+
const p = new fixtures.plugin('queue/smtp_forward')
|
|
71
|
+
p.config = p.config.module_config(path.resolve('test'))
|
|
72
|
+
p.register()
|
|
73
|
+
p.tls_options = {}
|
|
74
|
+
return p
|
|
75
|
+
}
|
|
28
76
|
|
|
29
|
-
|
|
30
|
-
let upgradeArgs = {}
|
|
77
|
+
// ─── Constructor ─────────────────────────────────────────────────────────────
|
|
31
78
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
79
|
+
describe('SMTPClient constructor', () => {
|
|
80
|
+
it('initialises default properties', () => {
|
|
81
|
+
const client = makeClient()
|
|
82
|
+
assert.equal(client.command, 'greeting')
|
|
83
|
+
assert.deepEqual(client.response, [])
|
|
84
|
+
assert.equal(client.connected, false)
|
|
85
|
+
assert.equal(client.authenticating, false)
|
|
86
|
+
assert.equal(client.authenticated, false)
|
|
87
|
+
assert.deepEqual(client.auth_capabilities, [])
|
|
88
|
+
assert.equal(client.host, 'mx.example.com')
|
|
89
|
+
assert.equal(client.port, 25)
|
|
90
|
+
assert.equal(client.smtputf8, false)
|
|
91
|
+
assert.ok(client.uuid)
|
|
92
|
+
assert.equal(client.state, STATE.IDLE)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('parses connect_timeout from opts', () => {
|
|
96
|
+
const client = makeClient({ connect_timeout: '45' })
|
|
97
|
+
assert.equal(client.connect_timeout, 45)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('defaults connect_timeout to 30', () => {
|
|
101
|
+
const client = makeClient({ connect_timeout: undefined })
|
|
102
|
+
assert.equal(client.connect_timeout, 30)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('calls setTimeout and setKeepAlive on the socket', () => {
|
|
106
|
+
const socket = makeSocket()
|
|
107
|
+
let timeoutSet = false
|
|
108
|
+
socket.setTimeout = () => {
|
|
109
|
+
timeoutSet = true
|
|
41
110
|
}
|
|
111
|
+
socket.setKeepAlive = () => {}
|
|
112
|
+
new SMTPClient({ host: 'mx.example.com', port: 25, socket })
|
|
113
|
+
assert.ok(timeoutSet)
|
|
114
|
+
})
|
|
115
|
+
})
|
|
42
116
|
|
|
43
|
-
|
|
44
|
-
|
|
117
|
+
// ─── Line handler ────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
describe('SMTPClient line handler', () => {
|
|
120
|
+
let client
|
|
121
|
+
|
|
122
|
+
beforeEach(() => {
|
|
123
|
+
client = makeClient()
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('emits error and destroys on unrecognised SMTP line', () => {
|
|
127
|
+
const errors = []
|
|
128
|
+
client.on('error', (e) => errors.push(e))
|
|
129
|
+
client.socket.emit('line', 'not-smtp\r\n')
|
|
130
|
+
assert.ok(errors.length === 1)
|
|
131
|
+
assert.ok(/Unrecognized response/.test(errors[0]))
|
|
132
|
+
assert.equal(client.state, STATE.DESTROYED)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('accumulates multi-line responses (continuation marker)', () => {
|
|
136
|
+
client.command = 'ehlo'
|
|
137
|
+
// Send multi-line: first line has '-' continuation
|
|
138
|
+
client.socket.emit('line', '250-mx.example.com Hello\r\n')
|
|
139
|
+
assert.deepEqual(client.response, ['mx.example.com Hello'])
|
|
140
|
+
// No event emitted yet for ehlo — it requires a ' ' terminator
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('emits greeting EHLO on 220 response', () => {
|
|
144
|
+
let greetingArg = null
|
|
145
|
+
client.on('greeting', (cmd) => {
|
|
146
|
+
greetingArg = cmd
|
|
147
|
+
})
|
|
148
|
+
client.socket.emit('line', '220 hello server\r\n')
|
|
149
|
+
assert.equal(greetingArg, 'EHLO')
|
|
150
|
+
assert.equal(client.connected, true)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('emits helo after ehlo 250', () => {
|
|
154
|
+
client.command = 'ehlo'
|
|
155
|
+
let heloFired = false
|
|
156
|
+
client.on('helo', () => {
|
|
157
|
+
heloFired = true
|
|
158
|
+
})
|
|
159
|
+
client.socket.emit('line', '250 OK\r\n')
|
|
160
|
+
assert.ok(heloFired)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('falls back to HELO when EHLO is rejected with 5xx', () => {
|
|
164
|
+
client.command = 'ehlo'
|
|
165
|
+
let greetingArg = null
|
|
166
|
+
client.on('greeting', (cmd) => {
|
|
167
|
+
greetingArg = cmd
|
|
168
|
+
})
|
|
169
|
+
client.socket.emit('line', '502 EHLO not supported\r\n')
|
|
170
|
+
assert.equal(greetingArg, 'HELO')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('emits capabilities on EHLO 2xx then returns if command changed', () => {
|
|
174
|
+
client.command = 'ehlo'
|
|
175
|
+
let capsFired = false
|
|
176
|
+
client.on('capabilities', () => {
|
|
177
|
+
capsFired = true
|
|
178
|
+
client.command = 'starttls' // simulate command change inside handler
|
|
179
|
+
})
|
|
180
|
+
client.socket.emit('line', '250 OK\r\n')
|
|
181
|
+
assert.ok(capsFired)
|
|
182
|
+
// helo should NOT have been emitted because command changed in capabilities handler
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('emits helo/mail/rcpt/data/dot/rset/auth for their commands', () => {
|
|
186
|
+
const commands = ['helo', 'mail', 'rcpt', 'data', 'dot', 'rset', 'auth']
|
|
187
|
+
for (const cmd of commands) {
|
|
188
|
+
const c = makeClient()
|
|
189
|
+
c.command = cmd
|
|
190
|
+
let fired = false
|
|
191
|
+
c.on(cmd, () => {
|
|
192
|
+
fired = true
|
|
193
|
+
})
|
|
194
|
+
c.socket.emit('line', '250 OK\r\n')
|
|
195
|
+
assert.ok(fired, `expected '${cmd}' event to fire`)
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('emits quit and destroys on quit 2xx', () => {
|
|
200
|
+
client.command = 'quit'
|
|
201
|
+
let quitFired = false
|
|
202
|
+
client.on('quit', () => {
|
|
203
|
+
quitFired = true
|
|
204
|
+
})
|
|
205
|
+
client.socket.emit('line', '221 Bye\r\n')
|
|
206
|
+
assert.ok(quitFired)
|
|
207
|
+
assert.equal(client.state, STATE.DESTROYED)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('sets xclient flag and emits xclient on XCLIENT success', () => {
|
|
211
|
+
client.command = 'xclient'
|
|
212
|
+
let xclientArg = null
|
|
213
|
+
client.on('xclient', (arg) => {
|
|
214
|
+
xclientArg = arg
|
|
215
|
+
})
|
|
216
|
+
client.socket.emit('line', '220 OK\r\n')
|
|
217
|
+
assert.ok(client.xclient)
|
|
218
|
+
assert.equal(xclientArg, 'EHLO')
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('carries on as helo when XCLIENT is rejected with 5xx', () => {
|
|
222
|
+
client.command = 'xclient'
|
|
223
|
+
client.socket.emit('line', '503 XCLIENT not permitted\r\n')
|
|
224
|
+
assert.equal(client.command, 'helo')
|
|
225
|
+
})
|
|
45
226
|
|
|
227
|
+
it('calls upgrade on starttls response', () => {
|
|
46
228
|
client.command = 'starttls'
|
|
47
|
-
|
|
229
|
+
client.tls_options = { servername: 'mx.example.com' }
|
|
230
|
+
let upgradeCalled = false
|
|
231
|
+
client.socket.upgrade = (opts, cb) => {
|
|
232
|
+
upgradeCalled = true
|
|
233
|
+
}
|
|
234
|
+
client.socket.emit('line', '220 Go ahead\r\n')
|
|
235
|
+
assert.ok(upgradeCalled)
|
|
236
|
+
})
|
|
48
237
|
|
|
49
|
-
|
|
50
|
-
|
|
238
|
+
it('emits bad_code on 4xx/5xx for active commands', () => {
|
|
239
|
+
client.command = 'mail'
|
|
240
|
+
client.state = STATE.ACTIVE
|
|
241
|
+
let badCode = null
|
|
242
|
+
client.on('bad_code', (code) => {
|
|
243
|
+
badCode = code
|
|
244
|
+
})
|
|
245
|
+
client.socket.emit('line', '550 Rejected\r\n')
|
|
246
|
+
assert.equal(badCode, '550')
|
|
247
|
+
})
|
|
51
248
|
|
|
52
|
-
|
|
53
|
-
|
|
249
|
+
it('returns early after bad_code when state is not ACTIVE', () => {
|
|
250
|
+
client.command = 'mail'
|
|
251
|
+
client.state = STATE.IDLE
|
|
252
|
+
let heloFired = false
|
|
253
|
+
client.on('helo', () => {
|
|
254
|
+
heloFired = true
|
|
255
|
+
}) // shouldn't fire
|
|
256
|
+
let badCodeFired = false
|
|
257
|
+
client.on('bad_code', () => {
|
|
258
|
+
badCodeFired = true
|
|
259
|
+
})
|
|
260
|
+
client.socket.emit('line', '550 Rejected\r\n')
|
|
261
|
+
assert.ok(badCodeFired)
|
|
262
|
+
// state is IDLE so it returns early — no further dispatch
|
|
54
263
|
})
|
|
55
264
|
|
|
56
|
-
it('
|
|
57
|
-
|
|
265
|
+
it('destroys on 441 Connection timed out', () => {
|
|
266
|
+
client.command = 'mail'
|
|
267
|
+
client.state = STATE.ACTIVE // must be ACTIVE to pass through bad_code without returning early
|
|
268
|
+
client.socket.emit('line', '441 Connection timed out\r\n')
|
|
269
|
+
assert.equal(client.state, STATE.DESTROYED)
|
|
270
|
+
})
|
|
58
271
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
272
|
+
it('throws on unknown command', () => {
|
|
273
|
+
client.command = 'unknown_cmd'
|
|
274
|
+
assert.throws(() => client.socket.emit('line', '250 OK\r\n'), /Unknown command: unknown_cmd/)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
// ── Auth responses ──────────────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
// ── Auth challenge responses (334) ─────────────────────────────────────
|
|
280
|
+
// Each case: [event to emit, challenge line]
|
|
281
|
+
for (const [event, challenge] of [
|
|
282
|
+
['auth_username', '334 VXNlcm5hbWU6\r\n'], // base64('Username:')
|
|
283
|
+
['auth_username', '334 dXNlcm5hbWU6\r\n'], // base64('username:') — case-insensitive workaround
|
|
284
|
+
['auth_password', '334 UGFzc3dvcmQ6\r\n'], // base64('Password:')
|
|
285
|
+
]) {
|
|
286
|
+
it(`emits ${event} on ${challenge.trim()}`, () => {
|
|
287
|
+
client.command = 'auth'
|
|
288
|
+
let fired = false
|
|
289
|
+
client.on(event, () => {
|
|
290
|
+
fired = true
|
|
291
|
+
})
|
|
292
|
+
client.socket.emit('line', challenge)
|
|
293
|
+
assert.ok(fired)
|
|
294
|
+
})
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
it('emits auth and sets authenticated on 235 while authenticating', () => {
|
|
298
|
+
client.command = 'auth'
|
|
299
|
+
client.authenticating = true
|
|
300
|
+
let authFired = false
|
|
301
|
+
client.on('auth', () => {
|
|
302
|
+
authFired = true
|
|
303
|
+
})
|
|
304
|
+
client.socket.emit('line', '235 Authentication successful\r\n')
|
|
305
|
+
assert.ok(authFired)
|
|
306
|
+
assert.equal(client.authenticated, true)
|
|
307
|
+
assert.equal(client.authenticating, false)
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it('emits auth event via switch for auth command with 250', () => {
|
|
311
|
+
client.command = 'auth'
|
|
312
|
+
client.authenticating = false
|
|
313
|
+
let authFired = false
|
|
314
|
+
client.on('auth', () => {
|
|
315
|
+
authFired = true
|
|
316
|
+
})
|
|
317
|
+
client.socket.emit('line', '250 OK\r\n')
|
|
318
|
+
assert.ok(authFired)
|
|
319
|
+
})
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
// ─── Socket connect event ─────────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
describe('SMTPClient socket connect event', () => {
|
|
325
|
+
it('sets remote_ip from remoteAddress', () => {
|
|
326
|
+
const socket = makeSocket()
|
|
327
|
+
socket.remoteAddress = '::ffff:10.0.0.1'
|
|
328
|
+
const client = makeClient({ socket })
|
|
329
|
+
socket.emit('connect')
|
|
330
|
+
assert.equal(client.remote_ip, '10.0.0.1')
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('handles undefined remoteAddress without crash', () => {
|
|
334
|
+
const socket = makeSocket()
|
|
335
|
+
socket.remoteAddress = undefined
|
|
336
|
+
const client = makeClient({ socket })
|
|
337
|
+
assert.doesNotThrow(() => socket.emit('connect'))
|
|
338
|
+
assert.equal(client.remote_ip, undefined)
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('replaces timeout with idle_timeout on connect', () => {
|
|
342
|
+
const socket = makeSocket()
|
|
343
|
+
let lastTimeout = null
|
|
344
|
+
socket.setTimeout = (ms) => {
|
|
345
|
+
lastTimeout = ms
|
|
346
|
+
}
|
|
347
|
+
const client = makeClient({ socket, idle_timeout: 120 })
|
|
348
|
+
socket.emit('connect')
|
|
349
|
+
assert.equal(lastTimeout, 120_000)
|
|
350
|
+
})
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
// ─── closed() — socket error / timeout / close / end ─────────────────────────
|
|
354
|
+
|
|
355
|
+
describe('SMTPClient closed() handler', () => {
|
|
356
|
+
it('IDLE state: destroys on socket error', () => {
|
|
357
|
+
const client = makeClient()
|
|
358
|
+
assert.equal(client.state, STATE.IDLE)
|
|
359
|
+
client.socket.emit('error', new Error('ECONNREFUSED'))
|
|
360
|
+
assert.equal(client.state, STATE.DESTROYED)
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
it('ACTIVE state: emits error then destroys on socket error', () => {
|
|
364
|
+
const client = makeClient()
|
|
365
|
+
client.state = STATE.ACTIVE
|
|
366
|
+
const errors = []
|
|
367
|
+
client.on('error', (e) => errors.push(e))
|
|
368
|
+
client.socket.emit('error', new Error('connection dropped'))
|
|
369
|
+
assert.ok(errors.length === 1)
|
|
370
|
+
assert.ok(/SMTP connection errored/.test(errors[0]))
|
|
371
|
+
assert.equal(client.state, STATE.DESTROYED)
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('RELEASED state: destroys on socket error', () => {
|
|
375
|
+
const client = makeClient()
|
|
376
|
+
client.state = STATE.RELEASED
|
|
377
|
+
client.socket.emit('error', new Error('gone'))
|
|
378
|
+
assert.equal(client.state, STATE.DESTROYED)
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
it('DESTROYED state: emits connection-error on socket error', () => {
|
|
382
|
+
const client = makeClient()
|
|
383
|
+
client.destroy()
|
|
384
|
+
const connErrors = []
|
|
385
|
+
client.on('connection-error', (e) => connErrors.push(e))
|
|
386
|
+
client.socket.emit('error', new Error('late error'))
|
|
387
|
+
assert.ok(connErrors.length === 1)
|
|
388
|
+
assert.ok(/SMTP connection errored/.test(connErrors[0]))
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it('DESTROYED state: emits connection-error on socket timeout', () => {
|
|
392
|
+
const client = makeClient()
|
|
393
|
+
client.destroy()
|
|
394
|
+
const connErrors = []
|
|
395
|
+
client.on('connection-error', (e) => connErrors.push(e))
|
|
396
|
+
client.socket.emit('timeout')
|
|
397
|
+
assert.ok(connErrors.length === 1)
|
|
398
|
+
assert.ok(/timed out/.test(connErrors[0]))
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
it('DESTROYED state: does NOT emit connection-error on socket close', () => {
|
|
402
|
+
const client = makeClient()
|
|
403
|
+
client.destroy()
|
|
404
|
+
const connErrors = []
|
|
405
|
+
client.on('connection-error', (e) => connErrors.push(e))
|
|
406
|
+
client.socket.emit('close')
|
|
407
|
+
assert.equal(connErrors.length, 0)
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
it('handles socket timeout in IDLE state', () => {
|
|
411
|
+
const client = makeClient()
|
|
412
|
+
client.socket.emit('timeout')
|
|
413
|
+
assert.equal(client.state, STATE.DESTROYED)
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
it('handles socket close in IDLE state', () => {
|
|
417
|
+
const client = makeClient()
|
|
418
|
+
client.socket.emit('close')
|
|
419
|
+
assert.equal(client.state, STATE.DESTROYED)
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
it('handles socket end in IDLE state', () => {
|
|
423
|
+
const client = makeClient()
|
|
424
|
+
client.socket.emit('end')
|
|
425
|
+
assert.equal(client.state, STATE.DESTROYED)
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
it('closed handler coerces null error to empty string', () => {
|
|
429
|
+
const client = makeClient()
|
|
430
|
+
client.state = STATE.ACTIVE
|
|
431
|
+
const errors = []
|
|
432
|
+
client.on('error', (e) => errors.push(e))
|
|
433
|
+
client.socket.emit('error', null)
|
|
434
|
+
assert.ok(errors.length === 1)
|
|
435
|
+
assert.ok(errors[0].includes('SMTP connection errored'))
|
|
436
|
+
})
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
// ─── load_tls_config ──────────────────────────────────────────────────────────
|
|
440
|
+
|
|
441
|
+
describe('SMTPClient#load_tls_config', () => {
|
|
442
|
+
it('sets tls_options with servername equal to host', () => {
|
|
443
|
+
const client = makeClient()
|
|
444
|
+
client.load_tls_config()
|
|
445
|
+
assert.equal(client.tls_options.servername, 'mx.example.com')
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
it('merges additional opts into tls_options', () => {
|
|
449
|
+
const client = makeClient()
|
|
450
|
+
client.load_tls_config({ key: Buffer.from('secret'), rejectUnauthorized: false })
|
|
451
|
+
assert.equal(client.tls_options.servername, 'mx.example.com')
|
|
452
|
+
assert.equal(client.tls_options.rejectUnauthorized, false)
|
|
453
|
+
assert.ok(Buffer.isBuffer(client.tls_options.key))
|
|
454
|
+
})
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
// ─── send_command ─────────────────────────────────────────────────────────────
|
|
458
|
+
|
|
459
|
+
describe('SMTPClient#send_command', () => {
|
|
460
|
+
it('writes command + CRLF to socket', () => {
|
|
461
|
+
const written = []
|
|
462
|
+
const socket = makeSocket()
|
|
463
|
+
socket.write = (data) => written.push(data)
|
|
464
|
+
const client = makeClient({ socket })
|
|
465
|
+
client.send_command('EHLO', 'example.com')
|
|
466
|
+
assert.equal(written[0], 'EHLO example.com\r\n')
|
|
467
|
+
assert.equal(client.command, 'ehlo')
|
|
468
|
+
assert.deepEqual(client.response, [])
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
it('writes just "." for dot command', () => {
|
|
472
|
+
const written = []
|
|
473
|
+
const socket = makeSocket()
|
|
474
|
+
socket.write = (data) => written.push(data)
|
|
475
|
+
const client = makeClient({ socket })
|
|
476
|
+
client.send_command('dot')
|
|
477
|
+
assert.equal(written[0], '.\r\n')
|
|
478
|
+
assert.equal(client.command, 'dot')
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
it('sends command without data', () => {
|
|
482
|
+
const written = []
|
|
483
|
+
const socket = makeSocket()
|
|
484
|
+
socket.write = (data) => written.push(data)
|
|
485
|
+
const client = makeClient({ socket })
|
|
486
|
+
client.send_command('QUIT')
|
|
487
|
+
assert.equal(written[0], 'QUIT\r\n')
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
it('emits client_protocol event', () => {
|
|
491
|
+
const lines = []
|
|
492
|
+
const client = makeClient()
|
|
493
|
+
client.on('client_protocol', (l) => lines.push(l))
|
|
494
|
+
client.send_command('MAIL', 'FROM:<me@example.com>')
|
|
495
|
+
assert.equal(lines[0], 'MAIL FROM:<me@example.com>')
|
|
496
|
+
})
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
// ─── start_data ───────────────────────────────────────────────────────────────
|
|
500
|
+
|
|
501
|
+
describe('SMTPClient#start_data', () => {
|
|
502
|
+
it('sets command to dot and resets response', () => {
|
|
503
|
+
const client = makeClient()
|
|
504
|
+
client.response = ['leftover']
|
|
505
|
+
const pt = new PassThrough()
|
|
506
|
+
pt.pipe = () => {}
|
|
507
|
+
client.start_data(pt)
|
|
508
|
+
assert.equal(client.command, 'dot')
|
|
509
|
+
assert.deepEqual(client.response, [])
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
it('pipes the data stream to the socket', () => {
|
|
513
|
+
const client = makeClient()
|
|
514
|
+
let pipeTarget = null
|
|
515
|
+
const mockStream = {
|
|
516
|
+
pipe: (dest, opts) => {
|
|
517
|
+
pipeTarget = dest
|
|
66
518
|
},
|
|
67
519
|
}
|
|
520
|
+
client.start_data(mockStream)
|
|
521
|
+
assert.equal(pipeTarget, client.socket)
|
|
522
|
+
})
|
|
523
|
+
})
|
|
68
524
|
|
|
69
|
-
|
|
70
|
-
client.tls_options = {}
|
|
525
|
+
// ─── release ──────────────────────────────────────────────────────────────────
|
|
71
526
|
|
|
72
|
-
|
|
73
|
-
|
|
527
|
+
describe('SMTPClient#release', () => {
|
|
528
|
+
it('is a no-op when already DESTROYED', () => {
|
|
529
|
+
const client = makeClient()
|
|
530
|
+
client.destroy()
|
|
531
|
+
assert.doesNotThrow(() => client.release())
|
|
532
|
+
assert.equal(client.state, STATE.DESTROYED)
|
|
533
|
+
})
|
|
74
534
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
535
|
+
it('sends QUIT and destroys when connected', () => {
|
|
536
|
+
const written = []
|
|
537
|
+
const socket = makeSocket()
|
|
538
|
+
socket.write = (data) => written.push(data)
|
|
539
|
+
const client = makeClient({ socket })
|
|
540
|
+
client.connected = true
|
|
541
|
+
client.release()
|
|
542
|
+
assert.ok(written.some((l) => l === 'QUIT\r\n'))
|
|
543
|
+
assert.equal(client.state, STATE.DESTROYED)
|
|
544
|
+
})
|
|
78
545
|
|
|
79
|
-
|
|
546
|
+
it('destroys without QUIT when not connected', () => {
|
|
547
|
+
const written = []
|
|
548
|
+
const socket = makeSocket()
|
|
549
|
+
socket.write = (data) => written.push(data)
|
|
550
|
+
const client = makeClient({ socket })
|
|
551
|
+
client.connected = false
|
|
552
|
+
client.release()
|
|
553
|
+
assert.equal(written.length, 0)
|
|
554
|
+
assert.equal(client.state, STATE.DESTROYED)
|
|
80
555
|
})
|
|
81
556
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
557
|
+
it('removes all named event listeners', () => {
|
|
558
|
+
const client = makeClient()
|
|
559
|
+
client.on('greeting', () => {})
|
|
560
|
+
client.on('error', () => {})
|
|
561
|
+
client.on('bad_code', () => {})
|
|
562
|
+
client.release()
|
|
563
|
+
assert.equal(client.listenerCount('greeting'), 0)
|
|
564
|
+
assert.equal(client.listenerCount('error'), 0)
|
|
565
|
+
assert.equal(client.listenerCount('bad_code'), 0)
|
|
566
|
+
})
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
// ─── destroy ──────────────────────────────────────────────────────────────────
|
|
570
|
+
|
|
571
|
+
describe('SMTPClient#destroy', () => {
|
|
572
|
+
it('sets state to DESTROYED and calls socket.destroy', () => {
|
|
573
|
+
const client = makeClient()
|
|
574
|
+
client.destroy()
|
|
575
|
+
assert.equal(client.state, STATE.DESTROYED)
|
|
576
|
+
assert.ok(client.socket.destroy.called)
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
it('is idempotent — second call is a no-op', () => {
|
|
580
|
+
const client = makeClient()
|
|
581
|
+
client.destroy()
|
|
582
|
+
const callCount = client.socket.destroy.callCount
|
|
583
|
+
client.destroy()
|
|
584
|
+
assert.equal(client.socket.destroy.callCount, callCount)
|
|
585
|
+
})
|
|
586
|
+
})
|
|
587
|
+
|
|
588
|
+
// ─── upgrade ──────────────────────────────────────────────────────────────────
|
|
589
|
+
|
|
590
|
+
describe('SMTPClient#upgrade', () => {
|
|
591
|
+
it('delegates to socket.upgrade with tls_options', () => {
|
|
592
|
+
const socket = makeSocket()
|
|
593
|
+
let upgradeOpts = null
|
|
594
|
+
socket.upgrade = (opts, cb) => {
|
|
595
|
+
upgradeOpts = opts
|
|
596
|
+
}
|
|
597
|
+
const client = makeClient({ socket })
|
|
598
|
+
const opts = { servername: 'secure.example.com', rejectUnauthorized: true }
|
|
599
|
+
client.upgrade(opts)
|
|
600
|
+
assert.deepEqual(upgradeOpts, opts)
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
it('logs upgrade details in callback', () => {
|
|
604
|
+
const socket = makeSocket()
|
|
605
|
+
socket.upgrade = (opts, cb) =>
|
|
606
|
+
cb(
|
|
607
|
+
true,
|
|
608
|
+
null,
|
|
90
609
|
{
|
|
91
|
-
|
|
610
|
+
subject: { CN: 'example.com', O: 'Org' },
|
|
611
|
+
issuer: { O: 'CA' },
|
|
612
|
+
valid_to: '2030-01-01',
|
|
613
|
+
fingerprint: 'AA:BB',
|
|
92
614
|
},
|
|
615
|
+
{ name: 'AES', version: 'TLSv1.3' },
|
|
93
616
|
)
|
|
94
|
-
})
|
|
617
|
+
const client = makeClient({ socket })
|
|
618
|
+
assert.doesNotThrow(() => client.upgrade({ servername: 'example.com' }))
|
|
619
|
+
})
|
|
620
|
+
})
|
|
95
621
|
|
|
96
|
-
|
|
97
|
-
const message_stream = new message.stream({ main: { spool_after: 1024 } }, '123456789')
|
|
622
|
+
// ─── is_dead_sender ───────────────────────────────────────────────────────────
|
|
98
623
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
624
|
+
describe('SMTPClient#is_dead_sender', () => {
|
|
625
|
+
it('returns false when connection has a transaction', () => {
|
|
626
|
+
const client = makeClient()
|
|
627
|
+
const plugin = makePlugin()
|
|
628
|
+
const conn = makeConnection()
|
|
629
|
+
conn.transaction = { mail_from: new Address('<a@b.com>') }
|
|
630
|
+
assert.equal(client.is_dead_sender(plugin, conn), false)
|
|
631
|
+
})
|
|
102
632
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
633
|
+
it('returns true and releases when transaction is null', () => {
|
|
634
|
+
const client = makeClient()
|
|
635
|
+
client.connected = false // ensure release() doesn't try to QUIT
|
|
636
|
+
const plugin = makePlugin()
|
|
637
|
+
const conn = makeConnection()
|
|
638
|
+
conn.transaction = null
|
|
639
|
+
const result = client.is_dead_sender(plugin, conn)
|
|
640
|
+
assert.equal(result, true)
|
|
641
|
+
assert.equal(client.state, STATE.DESTROYED)
|
|
642
|
+
})
|
|
643
|
+
})
|
|
108
644
|
|
|
109
|
-
|
|
110
|
-
data.push('250 hello')
|
|
645
|
+
// ─── get_client export ────────────────────────────────────────────────────────
|
|
111
646
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
this.client.send_command('MAIL', 'FROM: me@example.com')
|
|
116
|
-
})
|
|
647
|
+
describe('smtp_client.get_client', () => {
|
|
648
|
+
beforeEach(() => mockTlsConnect())
|
|
649
|
+
afterEach(restoreTlsConnect)
|
|
117
650
|
|
|
118
|
-
|
|
119
|
-
|
|
651
|
+
it('calls callback with a new SMTPClient', (t, done) => {
|
|
652
|
+
smtp_client_module.get_client(
|
|
653
|
+
{ notes: {} },
|
|
654
|
+
(client) => {
|
|
655
|
+
assert.ok(client instanceof SMTPClient)
|
|
656
|
+
assert.ok(client.uuid)
|
|
657
|
+
done()
|
|
658
|
+
},
|
|
659
|
+
{ host: 'mx.example.com', port: 25 },
|
|
660
|
+
)
|
|
661
|
+
})
|
|
662
|
+
})
|
|
120
663
|
|
|
121
|
-
|
|
122
|
-
data.push('250 sender ok')
|
|
664
|
+
// ─── onCapabilitiesOutbound ───────────────────────────────────────────────────
|
|
123
665
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
this.client.send_command('RCPT', 'TO: you@example.com')
|
|
127
|
-
})
|
|
666
|
+
describe('smtp_client.onCapabilitiesOutbound', () => {
|
|
667
|
+
let client, written
|
|
128
668
|
|
|
129
|
-
|
|
130
|
-
|
|
669
|
+
beforeEach(() => {
|
|
670
|
+
written = []
|
|
671
|
+
const socket = makeSocket()
|
|
672
|
+
socket.write = (data) => written.push(data)
|
|
673
|
+
client = makeClient({ socket })
|
|
674
|
+
client.tls_options = {}
|
|
675
|
+
})
|
|
131
676
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
677
|
+
it('sends XCLIENT when capability advertised and not yet done', () => {
|
|
678
|
+
client.response = ['XCLIENT ADDR']
|
|
679
|
+
client.xclient = false
|
|
680
|
+
const conn = makeConnection()
|
|
681
|
+
smtp_client_module.onCapabilitiesOutbound(client, false, conn, {})
|
|
682
|
+
assert.ok(written.some((l) => /XCLIENT ADDR=/.test(l)))
|
|
683
|
+
})
|
|
136
684
|
|
|
137
|
-
|
|
138
|
-
|
|
685
|
+
it('skips XCLIENT when already performed', () => {
|
|
686
|
+
client.response = ['XCLIENT ADDR']
|
|
687
|
+
client.xclient = true
|
|
688
|
+
smtp_client_module.onCapabilitiesOutbound(client, false, makeConnection(), {})
|
|
689
|
+
assert.ok(!written.some((l) => l.startsWith('XCLIENT')))
|
|
690
|
+
})
|
|
139
691
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
message_stream.add_line('hi\r\n')
|
|
146
|
-
message_stream.add_line_end()
|
|
147
|
-
})
|
|
692
|
+
it('sets smtputf8 flag when SMTPUTF8 advertised', () => {
|
|
693
|
+
client.response = ['SMTPUTF8']
|
|
694
|
+
smtp_client_module.onCapabilitiesOutbound(client, false, makeConnection(), {})
|
|
695
|
+
assert.ok(client.smtputf8)
|
|
696
|
+
})
|
|
148
697
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
698
|
+
it('sends STARTTLS when advertised, not secured, and enable_tls true', () => {
|
|
699
|
+
client.response = ['STARTTLS']
|
|
700
|
+
smtp_client_module.onCapabilitiesOutbound(client, false, makeConnection(), { enable_tls: true }, () => {})
|
|
701
|
+
assert.ok(written.some((l) => l === 'STARTTLS\r\n'))
|
|
702
|
+
})
|
|
154
703
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
704
|
+
it('skips STARTTLS when already secured', () => {
|
|
705
|
+
client.response = ['STARTTLS']
|
|
706
|
+
smtp_client_module.onCapabilitiesOutbound(client, true, makeConnection(), { enable_tls: true })
|
|
707
|
+
assert.ok(!written.some((l) => l === 'STARTTLS\r\n'))
|
|
708
|
+
})
|
|
159
709
|
|
|
160
|
-
|
|
161
|
-
|
|
710
|
+
it('skips STARTTLS when enable_tls is false', () => {
|
|
711
|
+
client.response = ['STARTTLS']
|
|
712
|
+
smtp_client_module.onCapabilitiesOutbound(client, false, makeConnection(), { enable_tls: false })
|
|
713
|
+
assert.ok(!written.some((l) => l === 'STARTTLS\r\n'))
|
|
714
|
+
})
|
|
162
715
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
716
|
+
it('parses AUTH capabilities', () => {
|
|
717
|
+
client.response = ['AUTH PLAIN LOGIN CRAM-MD5']
|
|
718
|
+
smtp_client_module.onCapabilitiesOutbound(client, false, makeConnection(), {})
|
|
719
|
+
assert.deepEqual(client.auth_capabilities, ['plain', 'login', 'cram-md5'])
|
|
720
|
+
})
|
|
721
|
+
|
|
722
|
+
it('handles multiple capabilities in one response', () => {
|
|
723
|
+
client.response = ['SMTPUTF8', 'AUTH PLAIN', 'STARTTLS']
|
|
724
|
+
smtp_client_module.onCapabilitiesOutbound(client, false, makeConnection(), {})
|
|
725
|
+
assert.ok(client.smtputf8)
|
|
726
|
+
assert.deepEqual(client.auth_capabilities, ['plain'])
|
|
727
|
+
})
|
|
728
|
+
|
|
729
|
+
it('skips STARTTLS when host is in no_tls_hosts ban list', () => {
|
|
730
|
+
client.response = ['STARTTLS']
|
|
731
|
+
// Note: the code checks tls_options.no_tls_hosts but reads tls_config.no_tls_hosts
|
|
732
|
+
// (a known quirk) — set both to exercise the branch
|
|
733
|
+
client.tls_options = { no_tls_hosts: ['10.0.0.0/8'] }
|
|
734
|
+
client.tls_config = { no_tls_hosts: ['10.0.0.0/8'] }
|
|
735
|
+
client.remote_ip = '10.0.0.1'
|
|
736
|
+
const conn = makeConnection()
|
|
737
|
+
smtp_client_module.onCapabilitiesOutbound(client, false, conn, { enable_tls: true, host: '10.0.0.1' }, () => {})
|
|
738
|
+
assert.ok(!written.some((l) => l === 'STARTTLS\r\n'))
|
|
739
|
+
})
|
|
740
|
+
})
|
|
741
|
+
|
|
742
|
+
// ─── get_client_plugin ────────────────────────────────────────────────────────
|
|
743
|
+
|
|
744
|
+
describe('smtp_client.get_client_plugin', () => {
|
|
745
|
+
let plugin, conn
|
|
746
|
+
|
|
747
|
+
beforeEach(() => {
|
|
748
|
+
mockTlsConnect()
|
|
749
|
+
plugin = makePlugin()
|
|
750
|
+
conn = makeConnection()
|
|
751
|
+
conn.transaction = { mail_from: new Address('<sender@example.com>') }
|
|
752
|
+
})
|
|
753
|
+
afterEach(restoreTlsConnect)
|
|
754
|
+
|
|
755
|
+
// Helper: wrap get_client_plugin callback in a Promise
|
|
756
|
+
const getClientPlugin = (opts = { host: 'relay.example.com', port: 25 }) =>
|
|
757
|
+
new Promise((resolve, reject) => {
|
|
758
|
+
smtp_client_module.get_client_plugin(plugin, conn, opts, (err, client) => {
|
|
759
|
+
if (err) reject(err)
|
|
760
|
+
else resolve(client)
|
|
166
761
|
})
|
|
762
|
+
})
|
|
167
763
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}
|
|
173
|
-
assert.equal(`${data.shift()}\r\n`, line)
|
|
174
|
-
if (reading_body && line == '.\r\n') {
|
|
175
|
-
reading_body = false
|
|
176
|
-
}
|
|
177
|
-
if (!reading_body) {
|
|
178
|
-
if (line == 'DATA\r\n') {
|
|
179
|
-
reading_body = true
|
|
180
|
-
}
|
|
181
|
-
while (true) {
|
|
182
|
-
const line2 = data.shift()
|
|
183
|
-
this.emit('line', `${line2}\r\n`)
|
|
184
|
-
if (line2[3] == ' ') break
|
|
185
|
-
}
|
|
186
|
-
}
|
|
764
|
+
it('calls callback with null error and a SMTPClient', async () => {
|
|
765
|
+
const client = await getClientPlugin()
|
|
766
|
+
assert.ok(client instanceof SMTPClient)
|
|
767
|
+
})
|
|
187
768
|
|
|
188
|
-
|
|
189
|
-
|
|
769
|
+
it('merges auth_type / auth_user / auth_pass into c.auth', async () => {
|
|
770
|
+
const c = { host: 'relay.example.com', port: 25, auth_type: 'plain', auth_user: 'alice', auth_pass: 's3cr3t' }
|
|
771
|
+
await getClientPlugin(c)
|
|
772
|
+
assert.deepEqual(c.auth, { type: 'plain', user: 'alice', pass: 's3cr3t' })
|
|
773
|
+
})
|
|
190
774
|
|
|
191
|
-
|
|
775
|
+
it('does not set c.auth when no auth fields present', async () => {
|
|
776
|
+
const c = { host: 'relay.example.com', port: 25 }
|
|
777
|
+
await getClientPlugin(c)
|
|
778
|
+
assert.equal(c.auth, undefined)
|
|
779
|
+
})
|
|
780
|
+
|
|
781
|
+
it('loads tls_config on the returned client', async () => {
|
|
782
|
+
const client = await getClientPlugin()
|
|
783
|
+
assert.ok(client.tls_options)
|
|
784
|
+
})
|
|
785
|
+
|
|
786
|
+
it('greeting handler sends EHLO with local.host (no xclient)', async () => {
|
|
787
|
+
const client = await getClientPlugin()
|
|
788
|
+
const written = []
|
|
789
|
+
client.socket.write = (data) => written.push(data)
|
|
790
|
+
client.emit('greeting', 'EHLO')
|
|
791
|
+
assert.ok(written.some((l) => /EHLO relay\.example\.com/.test(l)))
|
|
792
|
+
})
|
|
793
|
+
|
|
794
|
+
it('greeting handler sends EHLO with hello.host when xclient is set', async () => {
|
|
795
|
+
const client = await getClientPlugin()
|
|
796
|
+
client.xclient = true
|
|
797
|
+
const written = []
|
|
798
|
+
client.socket.write = (data) => written.push(data)
|
|
799
|
+
client.emit('greeting', 'EHLO')
|
|
800
|
+
assert.ok(written.some((l) => /EHLO client\.example\.com/.test(l)))
|
|
801
|
+
})
|
|
802
|
+
|
|
803
|
+
it('xclient handler sends EHLO with hello.host', async () => {
|
|
804
|
+
const client = await getClientPlugin()
|
|
805
|
+
client.xclient = true
|
|
806
|
+
const written = []
|
|
807
|
+
client.socket.write = (data) => written.push(data)
|
|
808
|
+
client.emit('xclient', 'EHLO')
|
|
809
|
+
assert.ok(written.some((l) => /EHLO client\.example\.com/.test(l)))
|
|
810
|
+
})
|
|
811
|
+
|
|
812
|
+
it('helo handler sends MAIL FROM when no auth configured', async () => {
|
|
813
|
+
const client = await getClientPlugin()
|
|
814
|
+
const written = []
|
|
815
|
+
client.socket.write = (data) => written.push(data)
|
|
816
|
+
client.emit('helo')
|
|
817
|
+
assert.ok(written.some((l) => /MAIL FROM/.test(l)))
|
|
818
|
+
})
|
|
819
|
+
|
|
820
|
+
it('helo handler sends MAIL FROM when already authenticated', async () => {
|
|
821
|
+
const c = { host: 'relay.example.com', port: 25, auth: { type: 'plain', user: 'u', pass: 'p' } }
|
|
822
|
+
const client = await getClientPlugin(c)
|
|
823
|
+
client.authenticated = true
|
|
824
|
+
client.auth_capabilities = ['plain']
|
|
825
|
+
const written = []
|
|
826
|
+
client.socket.write = (data) => written.push(data)
|
|
827
|
+
client.emit('helo')
|
|
828
|
+
assert.ok(written.some((l) => /MAIL FROM/.test(l)))
|
|
829
|
+
})
|
|
830
|
+
|
|
831
|
+
it('helo handler skips when auth.type is null', async () => {
|
|
832
|
+
const c = { host: 'relay.example.com', port: 25, auth: { type: null, user: 'u', pass: 'p' } }
|
|
833
|
+
const client = await getClientPlugin(c)
|
|
834
|
+
client.authenticated = false
|
|
835
|
+
client.auth_capabilities = []
|
|
836
|
+
const written = []
|
|
837
|
+
client.socket.write = (data) => written.push(data)
|
|
838
|
+
assert.doesNotThrow(() => client.emit('helo'))
|
|
839
|
+
assert.equal(written.length, 0)
|
|
840
|
+
})
|
|
841
|
+
|
|
842
|
+
it('helo handler sends AUTH PLAIN with base64 credentials', async () => {
|
|
843
|
+
const c = { host: 'relay.example.com', port: 25, auth: { type: 'plain', user: 'alice', pass: 'secret' } }
|
|
844
|
+
const client = await getClientPlugin(c)
|
|
845
|
+
client.authenticated = false
|
|
846
|
+
client.auth_capabilities = ['plain']
|
|
847
|
+
const written = []
|
|
848
|
+
client.socket.write = (data) => written.push(data)
|
|
849
|
+
client.emit('helo')
|
|
850
|
+
assert.ok(written.some((l) => /AUTH PLAIN/.test(l)))
|
|
851
|
+
})
|
|
852
|
+
|
|
853
|
+
// Table-drive the helo-throws cases
|
|
854
|
+
for (const [desc, opts, capabilities, pattern] of [
|
|
855
|
+
['unsupported auth type', { type: 'plain', user: 'u', pass: 'p' }, ['cram-md5'], /not supported by server/],
|
|
856
|
+
['plain auth with no user/pass', { type: 'plain', user: '', pass: '' }, ['plain'], /Must include auth\.user/],
|
|
857
|
+
['cram-md5 (not implemented)', { type: 'cram-md5', user: 'u', pass: 'p' }, ['cram-md5'], /Not implemented/],
|
|
858
|
+
['unknown auth type', { type: 'gssapi', user: 'u', pass: 'p' }, ['gssapi'], /Unknown AUTH type/],
|
|
859
|
+
]) {
|
|
860
|
+
it(`helo handler throws for ${desc}`, async () => {
|
|
861
|
+
const c = { host: 'relay.example.com', port: 25, auth: opts }
|
|
862
|
+
const client = await getClientPlugin(c)
|
|
863
|
+
client.authenticated = false
|
|
864
|
+
client.auth_capabilities = capabilities
|
|
865
|
+
assert.throws(() => client.emit('helo'), pattern)
|
|
192
866
|
})
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
it('auth handler sends MAIL FROM after successful authentication', async () => {
|
|
870
|
+
const client = await getClientPlugin()
|
|
871
|
+
client.authenticating = false
|
|
872
|
+
const written = []
|
|
873
|
+
client.socket.write = (data) => written.push(data)
|
|
874
|
+
client.emit('auth')
|
|
875
|
+
assert.ok(written.some((l) => /MAIL FROM/.test(l)))
|
|
193
876
|
})
|
|
194
877
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
878
|
+
it('auth handler returns early when still authenticating', async () => {
|
|
879
|
+
const client = await getClientPlugin()
|
|
880
|
+
client.authenticating = true
|
|
881
|
+
const written = []
|
|
882
|
+
client.socket.write = (data) => written.push(data)
|
|
883
|
+
client.emit('auth')
|
|
884
|
+
assert.equal(written.length, 0)
|
|
885
|
+
})
|
|
886
|
+
|
|
887
|
+
it('error handler calls call_next', async () => {
|
|
888
|
+
const client = await getClientPlugin()
|
|
889
|
+
let nextCalled = false
|
|
890
|
+
client.next = () => {
|
|
891
|
+
nextCalled = true
|
|
892
|
+
}
|
|
893
|
+
client.emit('error', 'something went wrong')
|
|
894
|
+
assert.ok(nextCalled)
|
|
895
|
+
})
|
|
896
|
+
|
|
897
|
+
it('connection-error handler calls call_next', async () => {
|
|
898
|
+
const client = await getClientPlugin()
|
|
899
|
+
let nextCalled = false
|
|
900
|
+
client.next = () => {
|
|
901
|
+
nextCalled = true
|
|
902
|
+
}
|
|
903
|
+
client.emit('connection-error', 'backend unreachable')
|
|
904
|
+
assert.ok(nextCalled)
|
|
905
|
+
})
|
|
906
|
+
|
|
907
|
+
it('connection-error handler calls host_pool.failed when pool exists', async () => {
|
|
908
|
+
let failedCalled = false
|
|
909
|
+
conn.server.notes.host_pool = {
|
|
910
|
+
failed: () => {
|
|
911
|
+
failedCalled = true
|
|
912
|
+
},
|
|
913
|
+
}
|
|
914
|
+
const client = await getClientPlugin()
|
|
915
|
+
client.emit('connection-error', 'Error: connect ECONNREFUSED')
|
|
916
|
+
assert.ok(failedCalled)
|
|
917
|
+
})
|
|
918
|
+
|
|
919
|
+
it('throws when neither forwarding_host_pool nor host/port specified', () => {
|
|
920
|
+
assert.throws(
|
|
921
|
+
() => smtp_client_module.get_client_plugin(plugin, conn, {}, () => {}),
|
|
922
|
+
/forwarding_host_pool or host and port/,
|
|
923
|
+
)
|
|
924
|
+
})
|
|
925
|
+
|
|
926
|
+
it('uses forwarding_host_pool when configured', async () => {
|
|
927
|
+
const c = { forwarding_host_pool: '10.0.0.1:25, 10.0.0.2:25' }
|
|
928
|
+
const client = await getClientPlugin(c)
|
|
929
|
+
assert.ok(client instanceof SMTPClient)
|
|
930
|
+
assert.ok(conn.server.notes.host_pool)
|
|
931
|
+
})
|
|
932
|
+
|
|
933
|
+
it('reuses existing host_pool from server.notes', async () => {
|
|
934
|
+
const HostPool = require('../host_pool')
|
|
935
|
+
const pool = new HostPool('10.0.0.3:25')
|
|
936
|
+
conn.server.notes.host_pool = pool
|
|
937
|
+
await getClientPlugin({ forwarding_host_pool: '10.0.0.3:25' })
|
|
938
|
+
assert.equal(conn.server.notes.host_pool, pool)
|
|
939
|
+
})
|
|
940
|
+
|
|
941
|
+
it('server_protocol event logs protocol line', async () => {
|
|
942
|
+
const client = await getClientPlugin()
|
|
943
|
+
assert.doesNotThrow(() => client.emit('server_protocol', '220 server ready'))
|
|
944
|
+
})
|
|
945
|
+
|
|
946
|
+
it('capabilities handler calls onCapabilitiesOutbound', async () => {
|
|
947
|
+
const client = await getClientPlugin({ host: 'relay.example.com', port: 25, enable_tls: true })
|
|
948
|
+
client.response = ['SIZE 10240000', 'AUTH PLAIN LOGIN']
|
|
949
|
+
assert.doesNotThrow(() => client.emit('capabilities'))
|
|
950
|
+
assert.deepEqual(client.auth_capabilities, ['plain', 'login'])
|
|
951
|
+
})
|
|
952
|
+
|
|
953
|
+
it('on_secured fires greeting and is idempotent', async () => {
|
|
954
|
+
const client = await getClientPlugin({ host: 'relay.example.com', port: 25, enable_tls: true })
|
|
955
|
+
client.response = ['STARTTLS']
|
|
956
|
+
const written = []
|
|
957
|
+
client.socket.write = (d) => written.push(d)
|
|
958
|
+
client.emit('capabilities')
|
|
959
|
+
|
|
960
|
+
let greetingCount = 0
|
|
961
|
+
client.on('greeting', () => {
|
|
962
|
+
greetingCount++
|
|
207
963
|
})
|
|
208
964
|
|
|
209
|
-
|
|
210
|
-
|
|
965
|
+
client.socket.emit('secure')
|
|
966
|
+
client.socket.emit('secure') // second call is a no-op
|
|
967
|
+
assert.equal(greetingCount, 1)
|
|
968
|
+
})
|
|
211
969
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
970
|
+
it('connected + xclient: sends XCLIENT immediately', (t, done) => {
|
|
971
|
+
// Make the mock socket emit a 220 greeting synchronously during SMTPClient construction
|
|
972
|
+
// so smtp_client.connected is true before get_client_plugin's check runs
|
|
973
|
+
const origConnect = tls_socket.connect
|
|
974
|
+
tls_socket.connect = () => {
|
|
975
|
+
const s = makeSocket()
|
|
976
|
+
net_utils.add_line_processor(s)
|
|
977
|
+
const origOn = s.on.bind(s)
|
|
978
|
+
let lineHandlerRegistered = false
|
|
979
|
+
s.on = function (event, handler) {
|
|
980
|
+
origOn(event, handler)
|
|
981
|
+
if (event === 'line' && !lineHandlerRegistered) {
|
|
982
|
+
lineHandlerRegistered = true
|
|
983
|
+
// emit greeting synchronously so connected becomes true before callback
|
|
984
|
+
handler('220 ready\r\n')
|
|
985
|
+
}
|
|
986
|
+
return s
|
|
987
|
+
}
|
|
988
|
+
return s
|
|
989
|
+
}
|
|
215
990
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
assert.equal('EHLO', command)
|
|
219
|
-
this.client.send_command(command, 'example.com')
|
|
220
|
-
})
|
|
991
|
+
const written = []
|
|
992
|
+
const mockPlugin = makePlugin()
|
|
221
993
|
|
|
222
|
-
|
|
223
|
-
|
|
994
|
+
smtp_client_module.get_client_plugin(
|
|
995
|
+
mockPlugin,
|
|
996
|
+
conn,
|
|
997
|
+
{ host: 'relay.example.com', port: 25 },
|
|
998
|
+
(err, client) => {
|
|
999
|
+
tls_socket.connect = origConnect
|
|
1000
|
+
// If connected=true and xclient=true, XCLIENT was sent
|
|
1001
|
+
// If connected=true and xclient=false, helo was emitted
|
|
1002
|
+
// Either way connected path was exercised — just verify no crash
|
|
1003
|
+
assert.ok(client instanceof SMTPClient)
|
|
1004
|
+
done()
|
|
1005
|
+
},
|
|
1006
|
+
)
|
|
1007
|
+
})
|
|
1008
|
+
})
|
|
224
1009
|
|
|
225
|
-
|
|
226
|
-
assert.equal(this.client.response[0], 'hello')
|
|
227
|
-
this.client.send_command('MAIL', 'FROM: me@example.com')
|
|
228
|
-
})
|
|
1010
|
+
// ─── Full SMTP session (integration) ─────────────────────────────────────────
|
|
229
1011
|
|
|
230
|
-
|
|
231
|
-
|
|
1012
|
+
describe('smtp_client full session (basic)', () => {
|
|
1013
|
+
beforeEach((t, done) => {
|
|
1014
|
+
smtp_client_module.get_client(
|
|
1015
|
+
{ notes: {} },
|
|
1016
|
+
(client) => {
|
|
1017
|
+
this.client = client
|
|
1018
|
+
done()
|
|
1019
|
+
},
|
|
1020
|
+
{ socket: require('./fixtures/line_socket').connect() },
|
|
1021
|
+
)
|
|
1022
|
+
})
|
|
232
1023
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
this.client.send_command('RCPT', 'TO: you@example.com')
|
|
236
|
-
})
|
|
1024
|
+
it('conducts a SMTP session', (t, done) => {
|
|
1025
|
+
const message_stream = new message.stream({ main: { spool_after: 1024 } }, '123456789')
|
|
237
1026
|
|
|
238
|
-
|
|
239
|
-
|
|
1027
|
+
const data = []
|
|
1028
|
+
let reading_body = false
|
|
1029
|
+
data.push('220 hi')
|
|
240
1030
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
1031
|
+
this.client.on('greeting', (command) => {
|
|
1032
|
+
assert.equal(this.client.response[0], 'hi')
|
|
1033
|
+
assert.equal('EHLO', command)
|
|
1034
|
+
this.client.send_command(command, 'example.com')
|
|
1035
|
+
})
|
|
245
1036
|
|
|
246
|
-
|
|
247
|
-
|
|
1037
|
+
data.push('EHLO example.com')
|
|
1038
|
+
data.push('250 hello')
|
|
248
1039
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
message_stream.add_line('\r\n')
|
|
254
|
-
message_stream.add_line('hi\r\n')
|
|
255
|
-
message_stream.add_line_end()
|
|
256
|
-
})
|
|
1040
|
+
this.client.on('helo', () => {
|
|
1041
|
+
assert.equal(this.client.response[0], 'hello')
|
|
1042
|
+
this.client.send_command('MAIL', 'FROM: me@example.com')
|
|
1043
|
+
})
|
|
257
1044
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
data.push('hi')
|
|
261
|
-
data.push('.')
|
|
262
|
-
data.push('250 message queued')
|
|
1045
|
+
data.push('MAIL FROM: me@example.com')
|
|
1046
|
+
data.push('250 sender ok')
|
|
263
1047
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
1048
|
+
this.client.on('mail', () => {
|
|
1049
|
+
assert.equal(this.client.response[0], 'sender ok')
|
|
1050
|
+
this.client.send_command('RCPT', 'TO: you@example.com')
|
|
1051
|
+
})
|
|
268
1052
|
|
|
269
|
-
|
|
270
|
-
|
|
1053
|
+
data.push('RCPT TO: you@example.com')
|
|
1054
|
+
data.push('250 recipient ok')
|
|
1055
|
+
|
|
1056
|
+
this.client.on('rcpt', () => {
|
|
1057
|
+
assert.equal(this.client.response[0], 'recipient ok')
|
|
1058
|
+
this.client.send_command('DATA')
|
|
1059
|
+
})
|
|
271
1060
|
|
|
272
|
-
|
|
273
|
-
|
|
1061
|
+
data.push('DATA')
|
|
1062
|
+
data.push('354 go ahead')
|
|
1063
|
+
|
|
1064
|
+
this.client.on('data', () => {
|
|
1065
|
+
assert.equal(this.client.response[0], 'go ahead')
|
|
1066
|
+
this.client.start_data(message_stream)
|
|
1067
|
+
message_stream.add_line('Header: test\r\n')
|
|
1068
|
+
message_stream.add_line('\r\n')
|
|
1069
|
+
message_stream.add_line('hi\r\n')
|
|
1070
|
+
message_stream.add_line_end()
|
|
1071
|
+
})
|
|
1072
|
+
|
|
1073
|
+
data.push('Header: test')
|
|
1074
|
+
data.push('')
|
|
1075
|
+
data.push('hi')
|
|
1076
|
+
data.push('.')
|
|
1077
|
+
data.push('250 message queued')
|
|
1078
|
+
|
|
1079
|
+
this.client.on('dot', () => {
|
|
1080
|
+
assert.equal(this.client.response[0], 'message queued')
|
|
1081
|
+
this.client.send_command('QUIT')
|
|
1082
|
+
})
|
|
1083
|
+
|
|
1084
|
+
data.push('QUIT')
|
|
1085
|
+
data.push('221 goodbye')
|
|
1086
|
+
|
|
1087
|
+
this.client.on('quit', () => {
|
|
1088
|
+
assert.equal(this.client.response[0], 'goodbye')
|
|
1089
|
+
done()
|
|
1090
|
+
})
|
|
1091
|
+
|
|
1092
|
+
this.client.socket.write = function (line) {
|
|
1093
|
+
if (data.length === 0) {
|
|
1094
|
+
assert.ok(false)
|
|
1095
|
+
return
|
|
1096
|
+
}
|
|
1097
|
+
const lineStr = Buffer.isBuffer(line) ? line.toString() : line
|
|
1098
|
+
assert.equal(`${data.shift()}\r\n`, lineStr)
|
|
1099
|
+
if (reading_body && lineStr === '.\r\n') reading_body = false
|
|
1100
|
+
if (reading_body) return true
|
|
1101
|
+
if (lineStr === 'DATA\r\n') reading_body = true
|
|
1102
|
+
while (true) {
|
|
1103
|
+
const line2 = data.shift()
|
|
1104
|
+
this.emit('line', `${line2}\r\n`)
|
|
1105
|
+
if (line2[3] === ' ') break
|
|
1106
|
+
}
|
|
1107
|
+
return true
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
this.client.socket.emit('line', data.shift())
|
|
1111
|
+
})
|
|
1112
|
+
})
|
|
1113
|
+
|
|
1114
|
+
// ─── Full SMTP session with AUTH (integration) ───────────────────────────────
|
|
1115
|
+
|
|
1116
|
+
describe('smtp_client full session (auth)', () => {
|
|
1117
|
+
beforeEach((t, done) => {
|
|
1118
|
+
smtp_client_module.get_client(
|
|
1119
|
+
{ notes: {} },
|
|
1120
|
+
(client) => {
|
|
1121
|
+
this.client = client
|
|
274
1122
|
done()
|
|
275
|
-
}
|
|
1123
|
+
},
|
|
1124
|
+
{ socket: require('./fixtures/line_socket').connect() },
|
|
1125
|
+
)
|
|
1126
|
+
})
|
|
276
1127
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
assert.ok(false)
|
|
280
|
-
return
|
|
281
|
-
}
|
|
282
|
-
assert.equal(`${data.shift()}\r\n`, line)
|
|
283
|
-
if (reading_body && line == '.\r\n') {
|
|
284
|
-
reading_body = false
|
|
285
|
-
}
|
|
286
|
-
if (reading_body) return true
|
|
1128
|
+
it('authenticates during SMTP conversation', (t, done) => {
|
|
1129
|
+
const message_stream = new message.stream({ main: { spool_after: 1024 } }, '123456789')
|
|
287
1130
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
1131
|
+
const data = []
|
|
1132
|
+
let reading_body = false
|
|
1133
|
+
data.push('220 hi')
|
|
1134
|
+
|
|
1135
|
+
this.client.on('greeting', (command) => {
|
|
1136
|
+
assert.equal(this.client.response[0], 'hi')
|
|
1137
|
+
assert.equal('EHLO', command)
|
|
1138
|
+
this.client.send_command(command, 'example.com')
|
|
1139
|
+
})
|
|
1140
|
+
|
|
1141
|
+
data.push('EHLO example.com')
|
|
1142
|
+
data.push('250 hello')
|
|
1143
|
+
|
|
1144
|
+
this.client.on('helo', () => {
|
|
1145
|
+
assert.equal(this.client.response[0], 'hello')
|
|
1146
|
+
this.client.send_command('AUTH', 'PLAIN AHRlc3QAdGVzdHBhc3M=')
|
|
1147
|
+
this.client.send_command('MAIL', 'FROM: me@example.com')
|
|
1148
|
+
})
|
|
1149
|
+
|
|
1150
|
+
data.push('AUTH PLAIN AHRlc3QAdGVzdHBhc3M=')
|
|
1151
|
+
data.push('235 Authentication successful.')
|
|
1152
|
+
|
|
1153
|
+
data.push('MAIL FROM: me@example.com')
|
|
1154
|
+
data.push('250 sender ok')
|
|
1155
|
+
|
|
1156
|
+
this.client.on('mail', () => {
|
|
1157
|
+
assert.equal(this.client.response[0], 'sender ok')
|
|
1158
|
+
this.client.send_command('RCPT', 'TO: you@example.com')
|
|
1159
|
+
})
|
|
1160
|
+
|
|
1161
|
+
data.push('RCPT TO: you@example.com')
|
|
1162
|
+
data.push('250 recipient ok')
|
|
1163
|
+
|
|
1164
|
+
this.client.on('rcpt', () => {
|
|
1165
|
+
assert.equal(this.client.response[0], 'recipient ok')
|
|
1166
|
+
this.client.send_command('DATA')
|
|
1167
|
+
})
|
|
1168
|
+
|
|
1169
|
+
data.push('DATA')
|
|
1170
|
+
data.push('354 go ahead')
|
|
1171
|
+
|
|
1172
|
+
this.client.on('data', () => {
|
|
1173
|
+
assert.equal(this.client.response[0], 'go ahead')
|
|
1174
|
+
this.client.start_data(message_stream)
|
|
1175
|
+
message_stream.add_line('Header: test\r\n')
|
|
1176
|
+
message_stream.add_line('\r\n')
|
|
1177
|
+
message_stream.add_line('hi\r\n')
|
|
1178
|
+
message_stream.add_line_end()
|
|
1179
|
+
})
|
|
1180
|
+
|
|
1181
|
+
data.push('Header: test')
|
|
1182
|
+
data.push('')
|
|
1183
|
+
data.push('hi')
|
|
1184
|
+
data.push('.')
|
|
1185
|
+
data.push('250 message queued')
|
|
1186
|
+
|
|
1187
|
+
this.client.on('dot', () => {
|
|
1188
|
+
assert.equal(this.client.response[0], 'message queued')
|
|
1189
|
+
this.client.send_command('QUIT')
|
|
1190
|
+
})
|
|
1191
|
+
|
|
1192
|
+
data.push('QUIT')
|
|
1193
|
+
data.push('221 goodbye')
|
|
1194
|
+
|
|
1195
|
+
this.client.on('quit', () => {
|
|
1196
|
+
assert.equal(this.client.response[0], 'goodbye')
|
|
1197
|
+
done()
|
|
1198
|
+
})
|
|
1199
|
+
|
|
1200
|
+
this.client.socket.write = function (line) {
|
|
1201
|
+
if (data.length === 0) {
|
|
1202
|
+
assert.ok(false)
|
|
1203
|
+
return
|
|
1204
|
+
}
|
|
1205
|
+
const lineStr = Buffer.isBuffer(line) ? line.toString() : line
|
|
1206
|
+
assert.equal(`${data.shift()}\r\n`, lineStr)
|
|
1207
|
+
if (reading_body && lineStr === '.\r\n') reading_body = false
|
|
1208
|
+
if (!reading_body) {
|
|
1209
|
+
if (lineStr === 'DATA\r\n') reading_body = true
|
|
291
1210
|
while (true) {
|
|
292
1211
|
const line2 = data.shift()
|
|
293
1212
|
this.emit('line', `${line2}\r\n`)
|
|
294
|
-
if (line2[3]
|
|
1213
|
+
if (line2[3] === ' ') break
|
|
295
1214
|
}
|
|
296
|
-
|
|
297
|
-
return true
|
|
298
1215
|
}
|
|
1216
|
+
return true
|
|
1217
|
+
}
|
|
299
1218
|
|
|
300
|
-
|
|
301
|
-
|
|
1219
|
+
this.client.socket.emit('line', data.shift())
|
|
1220
|
+
})
|
|
1221
|
+
})
|
|
1222
|
+
|
|
1223
|
+
// ─── testUpgradeIsCalledOnSTARTTLS ───────────────────────────────────────────
|
|
1224
|
+
|
|
1225
|
+
describe('smtp_client', () => {
|
|
1226
|
+
it('testUpgradeIsCalledOnSTARTTLS', () => {
|
|
1227
|
+
const plugin = makePlugin()
|
|
1228
|
+
|
|
1229
|
+
const cmds = {}
|
|
1230
|
+
let upgradeArgs = {}
|
|
1231
|
+
|
|
1232
|
+
const socket = {
|
|
1233
|
+
setTimeout: () => {},
|
|
1234
|
+
setKeepAlive: () => {},
|
|
1235
|
+
on: (eventName, callback) => {
|
|
1236
|
+
cmds[eventName] = callback
|
|
1237
|
+
},
|
|
1238
|
+
upgrade: (arg) => {
|
|
1239
|
+
upgradeArgs = arg
|
|
1240
|
+
},
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
const client = new SMTPClient({ host: 'mx.example.com', port: 25, socket })
|
|
1244
|
+
client.load_tls_config({ key: Buffer.from('OutboundTlsKeyLoaded') })
|
|
1245
|
+
|
|
1246
|
+
client.command = 'starttls'
|
|
1247
|
+
cmds.line('250 Hello client.example.com\r\n')
|
|
1248
|
+
|
|
1249
|
+
const { StringDecoder } = require('node:string_decoder')
|
|
1250
|
+
const decoder = new StringDecoder('utf8')
|
|
1251
|
+
const cent = Buffer.from(upgradeArgs.key)
|
|
1252
|
+
assert.equal(decoder.write(cent), 'OutboundTlsKeyLoaded')
|
|
1253
|
+
})
|
|
1254
|
+
|
|
1255
|
+
it('startTLS', () => {
|
|
1256
|
+
let cmd = ''
|
|
1257
|
+
|
|
1258
|
+
const socket = {
|
|
1259
|
+
setTimeout: () => {},
|
|
1260
|
+
setKeepAlive: () => {},
|
|
1261
|
+
on: () => {},
|
|
1262
|
+
upgrade: () => {},
|
|
1263
|
+
write: (arg) => {
|
|
1264
|
+
cmd = arg
|
|
1265
|
+
},
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
const client = new SMTPClient({ host: 'mx.example.com', port: 25, socket })
|
|
1269
|
+
client.tls_options = {}
|
|
1270
|
+
client.secured = false
|
|
1271
|
+
client.response = ['STARTTLS']
|
|
1272
|
+
|
|
1273
|
+
smtp_client_module.onCapabilitiesOutbound(client, false, undefined, { enable_tls: true })
|
|
1274
|
+
|
|
1275
|
+
assert.equal(cmd, 'STARTTLS\r\n')
|
|
302
1276
|
})
|
|
303
1277
|
})
|