Haraka 3.1.3 → 3.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.prettierignore +2 -0
  2. package/CONTRIBUTORS.md +23 -1
  3. package/Changes.md +38 -0
  4. package/Plugins.md +81 -64
  5. package/README.md +1 -1
  6. package/bin/haraka +7 -5
  7. package/connection.js +15 -19
  8. package/docs/Plugins.md +1 -1
  9. package/docs/plugins/aliases.md +0 -2
  10. package/docs/plugins/queue/qmail-queue.md +0 -1
  11. package/logger.js +2 -2
  12. package/outbound/hmail.js +76 -83
  13. package/outbound/index.js +36 -34
  14. package/outbound/queue.js +231 -176
  15. package/package.json +25 -26
  16. package/plugins/prevent_credential_leaks.js +2 -2
  17. package/plugins/process_title.js +1 -1
  18. package/plugins/queue/smtp_forward.js +1 -1
  19. package/plugins/status.js +8 -5
  20. package/plugins/tls.js +1 -1
  21. package/plugins.js +19 -14
  22. package/rfc1869.js +10 -10
  23. package/run_tests +20 -2
  24. package/server.js +15 -10
  25. package/smtp_client.js +2 -9
  26. package/test/config/tls/haraka.local.pem +47 -47
  27. package/test/connection.js +286 -147
  28. package/test/fixtures/line_socket.js +1 -0
  29. package/test/fixtures/util_hmailitem.js +1 -1
  30. package/test/outbound/bounce_net_errors.js +176 -0
  31. package/test/outbound/bounce_rfc3464.js +303 -0
  32. package/test/outbound/hmail.js +140 -104
  33. package/test/outbound/index.js +61 -101
  34. package/test/outbound/qfile.js +25 -25
  35. package/test/outbound/queue.js +233 -0
  36. package/test/plugins/queue/smtp_forward.js +1 -1
  37. package/test/plugins/record_envelope_addresses.js +93 -0
  38. package/test/plugins/tls.js +2 -2
  39. package/test/plugins/xclient.js +137 -0
  40. package/test/rfc1869.js +43 -0
  41. package/test/smtp_client.js +6 -6
  42. package/test/transaction.js +486 -201
  43. package/tls_socket.js +3 -3
  44. package/transaction.js +33 -10
  45. package/config/rabbitmq.ini +0 -10
  46. package/config/rabbitmq_amqplib.ini +0 -19
  47. package/docs/plugins/queue/rabbitmq.md +0 -34
  48. package/docs/plugins/queue/rabbitmq_amqplib.md +0 -51
  49. package/plugins/queue/rabbitmq.js +0 -141
  50. package/plugins/queue/rabbitmq_amqplib.js +0 -96
  51. package/test/config/tls/ec.pem +0 -23
  52. package/test/config/tls/mismatched.pem +0 -49
  53. package/test/outbound_bounce_net_errors.js +0 -157
  54. package/test/outbound_bounce_rfc3464.js +0 -366
  55. package/test/tls_socket.js +0 -273
@@ -1,3 +1,6 @@
1
+ 'use strict'
2
+
3
+ const { describe, it, beforeEach } = require('node:test')
1
4
  const assert = require('node:assert/strict')
2
5
 
3
6
  const constants = require('haraka-constants')
@@ -6,33 +9,43 @@ const DSN = require('haraka-dsn')
6
9
  const connection = require('../connection')
7
10
  const Server = require('../server')
8
11
 
9
- // hack alert, but plugin tests need constants
12
+ // Expose SMTP result constants as globals (DENY, DENYSOFT, etc.)
10
13
  constants.import(global)
11
14
 
12
- const _set_up = (done) => {
13
- this.backup = {}
14
- const client = {
15
- remotePort: null,
16
- remoteAddress: null,
17
- destroy: () => {
18
- true
19
- },
15
+ // ── Helpers ───────────────────────────────────────────────────────────────────
16
+
17
+ function makeClient(opts = {}) {
18
+ return {
19
+ remotePort: opts.remotePort ?? null,
20
+ remoteAddress: opts.remoteAddress ?? null,
21
+ localPort: opts.localPort ?? null,
22
+ localAddress: opts.localAddress ?? null,
23
+ destroy: () => {},
24
+ pause: () => {},
25
+ resume: () => {},
20
26
  }
21
- const server = {
22
- ip_address: null,
27
+ }
28
+
29
+ function makeServer(ip = null) {
30
+ return {
31
+ ip_address: ip,
23
32
  address() {
24
33
  return this.ip_address
25
34
  },
26
35
  }
27
- this.connection = connection.createConnection(client, server, Server.cfg)
28
- done()
29
36
  }
30
37
 
38
+ const setUp = () => {
39
+ this.connection = connection.createConnection(makeClient(), makeServer(), Server.cfg)
40
+ }
41
+
42
+ // ── Tests ─────────────────────────────────────────────────────────────────────
43
+
31
44
  describe('connection', () => {
32
- describe('connectionRaw', () => {
33
- beforeEach(_set_up)
45
+ describe('initial properties', () => {
46
+ beforeEach(setUp)
34
47
 
35
- it('has remote object', () => {
48
+ it('remote object defaults', () => {
36
49
  assert.deepEqual(this.connection.remote, {
37
50
  ip: null,
38
51
  port: null,
@@ -44,13 +57,13 @@ describe('connection', () => {
44
57
  })
45
58
  })
46
59
 
47
- it('has local object', () => {
60
+ it('local object defaults', () => {
48
61
  assert.equal(this.connection.local.ip, null)
49
62
  assert.equal(this.connection.local.port, null)
50
- assert.ok(this.connection.local.host, this.connection.local.host)
63
+ assert.ok(this.connection.local.host, 'local.host is set')
51
64
  })
52
65
 
53
- it('has tls object', () => {
66
+ it('tls object defaults', () => {
54
67
  assert.deepEqual(this.connection.tls, {
55
68
  enabled: false,
56
69
  advertised: false,
@@ -59,147 +72,103 @@ describe('connection', () => {
59
72
  })
60
73
  })
61
74
 
62
- it('get_capabilities', () => {
63
- assert.deepEqual([], this.connection.get_capabilities())
75
+ it('hello object defaults', () => {
76
+ assert.equal(this.connection.hello.host, null)
77
+ assert.equal(this.connection.hello.verb, null)
64
78
  })
65
79
 
66
- it('queue_msg, defined', () => {
67
- assert.equal('test message', this.connection.queue_msg(1, 'test message'))
80
+ it('proxy object defaults', () => {
81
+ assert.equal(this.connection.proxy.allowed, false)
82
+ assert.equal(this.connection.proxy.ip, null)
83
+ assert.equal(this.connection.proxy.type, null)
84
+ assert.equal(this.connection.proxy.timer, null)
68
85
  })
69
86
 
70
- it('queue_msg, default deny', () => {
71
- assert.equal('Message denied', this.connection.queue_msg(DENY))
72
- assert.equal('Message denied', this.connection.queue_msg(DENYDISCONNECT))
87
+ it('notes object exists', () => {
88
+ assert.ok(this.connection.notes, 'notes is set')
89
+ assert.equal(typeof this.connection.notes, 'object')
73
90
  })
74
91
 
75
- it('queue_msg, default denysoft', () => {
76
- assert.equal('Message denied temporarily', this.connection.queue_msg(DENYSOFT))
77
- assert.equal('Message denied temporarily', this.connection.queue_msg(DENYSOFTDISCONNECT))
92
+ it('transaction is null', () => {
93
+ assert.equal(this.connection.transaction, null)
78
94
  })
79
95
 
80
- it('queue_msg, default else', () => {
81
- assert.equal('', this.connection.queue_msg('hello'))
96
+ it('capabilities is null', () => {
97
+ assert.equal(this.connection.capabilities, null)
82
98
  })
83
99
 
84
- it('has normalized connection properties', () => {
85
- this.connection.set('remote', 'ip', '172.16.15.1')
86
- this.connection.set('hello', 'verb', 'EHLO')
87
- this.connection.set('tls', 'enabled', true)
88
-
89
- assert.equal('172.16.15.1', this.connection.remote.ip)
90
- assert.equal(null, this.connection.remote.port)
91
- assert.equal('EHLO', this.connection.hello.verb)
92
- assert.equal(null, this.connection.hello.host)
93
- assert.equal(true, this.connection.tls.enabled)
100
+ it('remote.is_private and remote.is_local default to false', () => {
101
+ assert.equal(this.connection.remote.is_private, false)
102
+ assert.equal(this.connection.remote.is_local, false)
94
103
  })
104
+ })
95
105
 
96
- it('sets remote.is_private and remote.is_local', () => {
97
- assert.equal(false, this.connection.remote.is_private)
98
- assert.equal(false, this.connection.remote.is_local)
106
+ describe('private IP connection', () => {
107
+ beforeEach(() => {
108
+ this.connection = connection.createConnection(
109
+ makeClient({
110
+ remotePort: 2525,
111
+ remoteAddress: '172.16.15.1',
112
+ localPort: 25,
113
+ localAddress: '172.16.15.254',
114
+ }),
115
+ makeServer('172.16.15.254'),
116
+ Server.cfg,
117
+ )
99
118
  })
100
119
 
101
- it('has normalized proxy properties, default', () => {
102
- assert.equal(false, this.connection.proxy.allowed)
103
- assert.equal(null, this.connection.proxy.ip)
104
- assert.equal(null, this.connection.proxy.type)
105
- assert.equal(null, this.connection.proxy.timer)
120
+ it('remote.is_private is true', () => {
121
+ assert.equal(this.connection.remote.is_private, true)
106
122
  })
107
123
 
108
- it('has normalized proxy properties, set', () => {
109
- this.connection.set('proxy', 'ip', '172.16.15.1')
110
- this.connection.set('proxy', 'type', 'haproxy')
111
- this.connection.set(
112
- 'proxy',
113
- 'timer',
114
- setTimeout(() => {}, 1000),
115
- )
116
- this.connection.set('proxy', 'allowed', true)
124
+ it('remote.is_local is false', () => {
125
+ assert.equal(this.connection.remote.is_local, false)
126
+ })
117
127
 
118
- assert.equal(true, this.connection.proxy.allowed)
119
- assert.equal('172.16.15.1', this.connection.proxy.ip)
120
- assert.ok(this.connection.proxy.timer)
121
- assert.equal(this.connection.proxy.type, 'haproxy')
128
+ it('remote.port is set', () => {
129
+ assert.equal(this.connection.remote.port, 2525)
122
130
  })
123
131
  })
124
132
 
125
- describe('connectionPrivate', () => {
126
- beforeEach((done) => {
127
- this.backup = {}
128
- const client = {
129
- remotePort: 2525,
130
- remoteAddress: '172.16.15.1',
131
- localPort: 25,
132
- localAddress: '172.16.15.254',
133
- destroy: () => {
134
- true
135
- },
136
- }
137
- const server = {
138
- ip_address: '172.16.15.254',
139
- address() {
140
- return this.ip_address
141
- },
142
- }
143
- this.connection = connection.createConnection(client, server, Server.cfg)
144
- done()
133
+ describe('loopback connection', () => {
134
+ beforeEach(() => {
135
+ this.connection = connection.createConnection(
136
+ makeClient({ remotePort: 2525, remoteAddress: '127.0.0.2', localPort: 25, localAddress: '172.0.0.1' }),
137
+ makeServer('127.0.0.1'),
138
+ Server.cfg,
139
+ )
145
140
  })
146
141
 
147
- it('sets remote.is_private and remote.is_local', () => {
148
- assert.equal(true, this.connection.remote.is_private)
149
- assert.equal(false, this.connection.remote.is_local)
150
- assert.equal(2525, this.connection.remote.port)
142
+ it('remote.is_private is true', () => {
143
+ assert.equal(this.connection.remote.is_private, true)
151
144
  })
152
- })
153
145
 
154
- describe('connectionLocal', () => {
155
- beforeEach((done) => {
156
- const client = {
157
- remotePort: 2525,
158
- remoteAddress: '127.0.0.2',
159
- localPort: 25,
160
- localAddress: '172.0.0.1',
161
- destroy: () => {
162
- true
163
- },
164
- }
165
- const server = {
166
- ip_address: '127.0.0.1',
167
- address() {
168
- return this.ip_address
169
- },
170
- }
171
- this.connection = connection.createConnection(client, server, Server.cfg)
172
- done()
173
- })
174
-
175
- it('sets remote.is_private and remote.is_local', () => {
176
- assert.equal(true, this.connection.remote.is_private)
177
- assert.equal(true, this.connection.remote.is_local)
178
- assert.equal(2525, this.connection.remote.port)
146
+ it('remote.is_local is true', () => {
147
+ assert.equal(this.connection.remote.is_local, true)
179
148
  })
180
149
  })
181
150
 
182
151
  describe('get_remote', () => {
183
- beforeEach(_set_up)
152
+ beforeEach(setUp)
184
153
 
185
- it('valid hostname', () => {
154
+ it('formats host and IP', () => {
186
155
  this.connection.remote.host = 'a.host.tld'
187
156
  this.connection.remote.ip = '172.16.199.198'
188
157
  assert.equal(this.connection.get_remote('host'), 'a.host.tld [172.16.199.198]')
189
158
  })
190
159
 
191
- it('no hostname', () => {
160
+ it('falls back to bracketed IP when no host', () => {
192
161
  this.connection.remote.ip = '172.16.199.198'
193
162
  assert.equal(this.connection.get_remote('host'), '[172.16.199.198]')
194
163
  })
195
164
 
196
- it('DNSERROR', () => {
165
+ it('DNSERROR suppresses hostname', () => {
197
166
  this.connection.remote.host = 'DNSERROR'
198
167
  this.connection.remote.ip = '172.16.199.198'
199
168
  assert.equal(this.connection.get_remote('host'), '[172.16.199.198]')
200
169
  })
201
170
 
202
- it('NXDOMAIN', () => {
171
+ it('NXDOMAIN suppresses hostname', () => {
203
172
  this.connection.remote.host = 'NXDOMAIN'
204
173
  this.connection.remote.ip = '172.16.199.198'
205
174
  assert.equal(this.connection.get_remote('host'), '[172.16.199.198]')
@@ -207,100 +176,270 @@ describe('connection', () => {
207
176
  })
208
177
 
209
178
  describe('local.info', () => {
210
- beforeEach(_set_up)
179
+ beforeEach(setUp)
180
+
181
+ it('contains Haraka/version', () => {
182
+ assert.match(this.connection.local.info, /Haraka\/\d+\.\d+/)
183
+ })
184
+ })
211
185
 
212
- it('is Haraka/version', () => {
213
- assert.ok(/Haraka\/\d.\d/.test(this.connection.local.info), this.connection.local.info)
186
+ describe('get_capabilities', () => {
187
+ beforeEach(setUp)
188
+
189
+ it('returns empty array by default', () => {
190
+ assert.deepEqual(this.connection.get_capabilities(), [])
214
191
  })
215
192
  })
216
193
 
217
194
  describe('relaying', () => {
218
- beforeEach(_set_up)
195
+ beforeEach(setUp)
219
196
 
220
- it('sets and gets', () => {
197
+ it('defaults to false', () => {
221
198
  assert.equal(this.connection.relaying, false)
199
+ })
222
200
 
201
+ it('set() and get() round-trip on connection', () => {
223
202
  this.connection.set('relaying', 'crocodiles')
224
203
  assert.equal(this.connection.get('relaying'), 'crocodiles')
225
204
  assert.equal(this.connection.relaying, 'crocodiles')
226
205
  assert.equal(this.connection._relaying, 'crocodiles')
206
+ })
227
207
 
208
+ it('direct assignment round-trips', () => {
228
209
  this.connection.relaying = 'alligators'
229
210
  assert.equal(this.connection.get('relaying'), 'alligators')
230
- assert.equal(this.connection.relaying, 'alligators')
231
211
  assert.equal(this.connection._relaying, 'alligators')
232
212
  })
233
213
 
234
- it('sets and gets in a transaction', () => {
235
- assert.equal(this.connection.relaying, false)
236
-
214
+ it('set() with a transaction updates txn, not connection', () => {
237
215
  this.connection.transaction = {}
238
- this.connection.set('relaying', 'txn-only') // sets txn.relaying
239
-
216
+ this.connection.set('relaying', 'txn-only')
240
217
  assert.equal(this.connection.get('relaying'), 'txn-only')
241
218
  assert.equal(this.connection._relaying, false)
242
219
  assert.equal(this.connection.transaction._relaying, 'txn-only')
243
220
  })
244
221
  })
245
222
 
246
- describe('get_set', () => {
247
- beforeEach(_set_up)
223
+ describe('get / set', () => {
224
+ beforeEach(setUp)
248
225
 
249
- it('sets single level properties', () => {
226
+ it('sets and gets a single-level property', () => {
250
227
  this.connection.set('encoding', true)
251
228
  assert.ok(this.connection.encoding)
252
229
  assert.ok(this.connection.get('encoding'))
253
230
  })
254
231
 
255
- it('sets two level deep properties', () => {
232
+ it('sets and gets a two-level property', () => {
256
233
  this.connection.set('local.host', 'test')
257
234
  assert.equal(this.connection.local.host, 'test')
258
235
  assert.equal(this.connection.get('local.host'), 'test')
259
236
  })
260
237
 
261
- it('sets three level deep properties', () => {
238
+ it('sets and gets a three-level property', () => {
262
239
  this.connection.set('some.fine.example', true)
263
240
  assert.ok(this.connection.some.fine.example)
264
241
  assert.ok(this.connection.get('some.fine.example'))
265
242
  })
243
+
244
+ it('sets hello.verb via set()', () => {
245
+ this.connection.set('hello', 'verb', 'EHLO')
246
+ assert.equal(this.connection.hello.verb, 'EHLO')
247
+ })
248
+
249
+ it('sets proxy fields via set()', () => {
250
+ this.connection.set('proxy', 'ip', '172.16.15.1')
251
+ this.connection.set('proxy', 'type', 'haproxy')
252
+ this.connection.set('proxy', 'allowed', true)
253
+ assert.equal(this.connection.proxy.ip, '172.16.15.1')
254
+ assert.equal(this.connection.proxy.type, 'haproxy')
255
+ assert.equal(this.connection.proxy.allowed, true)
256
+ })
257
+
258
+ it('has normalised connection properties after set()', () => {
259
+ this.connection.set('remote', 'ip', '172.16.15.1')
260
+ this.connection.set('hello', 'verb', 'EHLO')
261
+ this.connection.set('tls', 'enabled', true)
262
+ assert.equal(this.connection.remote.ip, '172.16.15.1')
263
+ assert.equal(this.connection.remote.port, null)
264
+ assert.equal(this.connection.hello.verb, 'EHLO')
265
+ assert.equal(this.connection.hello.host, null)
266
+ assert.equal(this.connection.tls.enabled, true)
267
+ })
268
+ })
269
+
270
+ describe('queue_msg', () => {
271
+ beforeEach(setUp)
272
+
273
+ it('returns supplied message when given', () => {
274
+ assert.equal(this.connection.queue_msg(1, 'test message'), 'test message')
275
+ })
276
+
277
+ it('returns default DENY message', () => {
278
+ assert.equal(this.connection.queue_msg(DENY), 'Message denied')
279
+ assert.equal(this.connection.queue_msg(DENYDISCONNECT), 'Message denied')
280
+ })
281
+
282
+ it('returns default DENYSOFT message', () => {
283
+ assert.equal(this.connection.queue_msg(DENYSOFT), 'Message denied temporarily')
284
+ assert.equal(this.connection.queue_msg(DENYSOFTDISCONNECT), 'Message denied temporarily')
285
+ })
286
+
287
+ it('returns empty string for unrecognised code', () => {
288
+ assert.equal(this.connection.queue_msg('hello'), '')
289
+ })
266
290
  })
267
291
 
268
292
  describe('respond', () => {
269
- beforeEach(_set_up)
293
+ beforeEach(setUp)
270
294
 
271
- it('disconnected returns undefined', () => {
295
+ it('returns undefined when disconnected', () => {
272
296
  this.connection.state = constants.connection.state.DISCONNECTED
273
297
  assert.equal(this.connection.respond(200, 'your lucky day'), undefined)
274
298
  assert.equal(this.connection.respond(550, 'you are jacked'), undefined)
275
299
  })
276
300
 
277
- it('state=command, 200', () => {
301
+ it('formats a simple 200 response', () => {
278
302
  assert.equal(this.connection.respond(200, 'you may pass Go'), '200 you may pass Go\r\n')
279
303
  })
280
304
 
281
- it('DSN 200', () => {
305
+ it('formats a DSN 200 response', () => {
282
306
  assert.equal(
283
307
  this.connection.respond(200, DSN.create(200, 'you may pass Go')),
284
308
  '200 2.0.0 you may pass Go\r\n',
285
309
  )
286
310
  })
287
311
 
288
- it('DSN 550 create', () => {
289
- // note, the DSN code overrides the response code
312
+ it('DSN overrides response code', () => {
290
313
  assert.equal(
291
- this.connection.respond(450, DSN.create(550, 'This domain is not in use and does not accept mail')),
292
- '550 5.0.0 This domain is not in use and does not accept mail\r\n',
314
+ this.connection.respond(450, DSN.create(550, 'This domain is not in use')),
315
+ '550 5.0.0 This domain is not in use\r\n',
293
316
  )
294
317
  })
295
318
 
296
- it('DSN 550 addr_bad_dest_system', () => {
319
+ it('DSN addr_bad_dest_system (5.1.2)', () => {
297
320
  assert.equal(
298
- this.connection.respond(
299
- 550,
300
- DSN.addr_bad_dest_system('This domain is not in use and does not accept mail', 550),
301
- ),
302
- '550 5.1.2 This domain is not in use and does not accept mail\r\n',
321
+ this.connection.respond(550, DSN.addr_bad_dest_system('Domain not in use', 550)),
322
+ '550 5.1.2 Domain not in use\r\n',
303
323
  )
304
324
  })
325
+
326
+ it('formats multi-line response from array', () => {
327
+ const resp = this.connection.respond(250, ['Hello', 'World'])
328
+ assert.ok(resp.includes('250-Hello\r\n'), 'first line uses dash')
329
+ assert.ok(resp.includes('250 World\r\n'), 'last line uses space')
330
+ })
331
+
332
+ it('formats multi-line response from newline-separated string', () => {
333
+ const resp = this.connection.respond(250, 'Hello\nWorld')
334
+ assert.ok(resp.includes('250-Hello\r\n'), 'first line uses dash')
335
+ assert.ok(resp.includes('250 World\r\n'), 'last line uses space')
336
+ })
337
+
338
+ it('last_response is updated when client has a write method', () => {
339
+ // When client.write is defined, respond() writes to the socket and
340
+ // stores the formatted buffer in last_response.
341
+ let written = ''
342
+ this.connection.client.write = (buf) => {
343
+ written += buf
344
+ }
345
+ this.connection.respond(250, 'OK')
346
+ assert.ok(written.includes('250 OK'), 'data written to socket')
347
+ assert.ok(this.connection.last_response.includes('250 OK'), 'last_response updated')
348
+ })
349
+ })
350
+
351
+ describe('pause and resume', () => {
352
+ beforeEach(setUp)
353
+
354
+ it('restores previous state when still paused at resume', () => {
355
+ this.connection.state = constants.connection.state.PAUSE_SMTP
356
+ this.connection.pause()
357
+ this.connection.resume()
358
+ assert.equal(this.connection.state, constants.connection.state.PAUSE_SMTP)
359
+ assert.equal(this.connection.prev_state, null)
360
+ })
361
+
362
+ it('does not overwrite state changed while paused', () => {
363
+ this.connection.state = constants.connection.state.PAUSE_SMTP
364
+ this.connection.pause()
365
+ this.connection.state = constants.connection.state.CMD
366
+ this.connection.resume()
367
+ assert.equal(this.connection.state, constants.connection.state.CMD)
368
+ assert.equal(this.connection.prev_state, null)
369
+ })
370
+ })
371
+
372
+ describe('loop_respond', () => {
373
+ beforeEach(setUp)
374
+
375
+ it('sets state to LOOP', () => {
376
+ this.connection.loop_respond(554, 'Denied')
377
+ assert.equal(this.connection.state, constants.connection.state.LOOP)
378
+ })
379
+
380
+ it('records loop_code and loop_msg', () => {
381
+ this.connection.loop_respond(554, 'Denied')
382
+ assert.equal(this.connection.loop_code, 554)
383
+ assert.equal(this.connection.loop_msg, 'Denied')
384
+ })
385
+
386
+ it('does nothing when already disconnecting', () => {
387
+ this.connection.state = constants.connection.state.DISCONNECTING
388
+ this.connection.loop_respond(554, 'Denied')
389
+ assert.equal(this.connection.state, constants.connection.state.DISCONNECTING)
390
+ })
391
+ })
392
+
393
+ describe('tran_uuid', () => {
394
+ beforeEach(setUp)
395
+
396
+ it('increments tran_count on each call', () => {
397
+ assert.equal(this.connection.tran_count, 0)
398
+ const u1 = this.connection.tran_uuid()
399
+ assert.equal(this.connection.tran_count, 1)
400
+ const u2 = this.connection.tran_uuid()
401
+ assert.equal(this.connection.tran_count, 2)
402
+ assert.notEqual(u1, u2)
403
+ })
404
+
405
+ it('formats as <connection-uuid>.<count>', () => {
406
+ const u = this.connection.tran_uuid()
407
+ assert.match(u, new RegExp(`^${this.connection.uuid}\\.1$`))
408
+ })
409
+ })
410
+
411
+ describe('issue #3374 — double QUIT prevention', () => {
412
+ beforeEach(setUp)
413
+
414
+ it('quit hook fires only once when two QUITs arrive in LOOP state', async () => {
415
+ const conn = this.connection
416
+ conn.loop_respond(554, 'Denied')
417
+ assert.equal(conn.state, constants.connection.state.LOOP)
418
+
419
+ let quit_hook_calls = 0
420
+ const plugins = require('../plugins')
421
+ const original_run_hooks = plugins.run_hooks
422
+ plugins.run_hooks = (hook, c, params) => {
423
+ if (hook === 'quit') {
424
+ quit_hook_calls++
425
+ if (quit_hook_calls === 1) {
426
+ setTimeout(() => c.quit_respond(constants.ok), 50)
427
+ }
428
+ return
429
+ }
430
+ original_run_hooks(hook, c, params)
431
+ }
432
+
433
+ conn.process_line(Buffer.from('QUIT\r\n'))
434
+ conn.process_line(Buffer.from('QUIT\r\n'))
435
+
436
+ await new Promise((resolve) => {
437
+ setTimeout(() => {
438
+ plugins.run_hooks = original_run_hooks
439
+ assert.equal(quit_hook_calls, 1, 'quit hook called exactly once')
440
+ resolve()
441
+ }, 100)
442
+ })
443
+ })
305
444
  })
306
445
  })
@@ -12,6 +12,7 @@ class Socket extends events.EventEmitter {
12
12
  this.setTimeout = stub()
13
13
  this.setKeepAlive = stub()
14
14
  this.destroy = stub()
15
+ this.end = stub()
15
16
  }
16
17
  }
17
18
 
@@ -67,7 +67,7 @@ exports.createHMailItem = (outbound_context, options, callback) => {
67
67
  let line = match[1]
68
68
  line = line.replace(/\r?\n?$/, '\r\n') // make sure it ends in \r\n
69
69
  conn.transaction.add_data(Buffer.from(line))
70
- contents = contents.substr(match[1].length)
70
+ contents = contents.substring(match[1].length)
71
71
  if (contents.length === 0) {
72
72
  break
73
73
  }