Haraka 3.1.2 → 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 (66) hide show
  1. package/.prettierignore +2 -0
  2. package/CONTRIBUTORS.md +24 -2
  3. package/Changes.md +48 -0
  4. package/Plugins.md +81 -64
  5. package/README.md +1 -1
  6. package/bin/haraka +9 -7
  7. package/config/connection.ini +10 -0
  8. package/config/smtp.ini +0 -9
  9. package/connection.js +15 -19
  10. package/docs/CoreConfig.md +2 -3
  11. package/docs/Plugins.md +1 -1
  12. package/docs/plugins/aliases.md +0 -2
  13. package/docs/plugins/queue/qmail-queue.md +0 -1
  14. package/docs/tutorials/Migrating_from_v1_to_v2.md +1 -1
  15. package/logger.js +2 -2
  16. package/outbound/client_pool.js +1 -1
  17. package/outbound/hmail.js +76 -83
  18. package/outbound/index.js +36 -34
  19. package/outbound/queue.js +231 -176
  20. package/package.json +29 -31
  21. package/plugins/prevent_credential_leaks.js +2 -2
  22. package/plugins/process_title.js +1 -1
  23. package/plugins/queue/smtp_forward.js +1 -1
  24. package/plugins/status.js +8 -5
  25. package/plugins/tls.js +1 -1
  26. package/plugins.js +19 -14
  27. package/rfc1869.js +10 -10
  28. package/run_tests +20 -2
  29. package/server.js +15 -10
  30. package/smtp_client.js +2 -9
  31. package/test/config/tls/haraka.local.pem +47 -47
  32. package/test/connection.js +286 -147
  33. package/test/fixtures/line_socket.js +1 -0
  34. package/test/fixtures/util_hmailitem.js +1 -1
  35. package/test/outbound/bounce_net_errors.js +176 -0
  36. package/test/outbound/bounce_rfc3464.js +303 -0
  37. package/test/outbound/hmail.js +140 -104
  38. package/test/outbound/index.js +61 -101
  39. package/test/outbound/qfile.js +25 -25
  40. package/test/outbound/queue.js +233 -0
  41. package/test/plugins/queue/smtp_forward.js +1 -1
  42. package/test/plugins/record_envelope_addresses.js +93 -0
  43. package/test/plugins/tls.js +2 -2
  44. package/test/plugins/xclient.js +137 -0
  45. package/test/rfc1869.js +43 -0
  46. package/test/smtp_client.js +6 -6
  47. package/test/transaction.js +486 -201
  48. package/tls_socket.js +3 -3
  49. package/transaction.js +33 -10
  50. package/config/me +0 -1
  51. package/config/rabbitmq.ini +0 -10
  52. package/config/rabbitmq_amqplib.ini +0 -19
  53. package/config/tls_cert.pem +0 -23
  54. package/config/tls_key.pem +0 -28
  55. package/docs/plugins/queue/rabbitmq.md +0 -34
  56. package/docs/plugins/queue/rabbitmq_amqplib.md +0 -51
  57. package/plugins/queue/rabbitmq.js +0 -141
  58. package/plugins/queue/rabbitmq_amqplib.js +0 -96
  59. package/test/config/tls/ec.pem +0 -23
  60. package/test/config/tls/mismatched.pem +0 -49
  61. package/test/outbound_bounce_net_errors.js +0 -157
  62. package/test/outbound_bounce_rfc3464.js +0 -366
  63. package/test/queue/multibyte +0 -0
  64. package/test/queue/plain +0 -0
  65. package/test/test-queue/delete-me +0 -0
  66. package/test/tls_socket.js +0 -273
@@ -1,15 +1,15 @@
1
+ 'use strict'
2
+
3
+ const { describe, it, beforeEach } = require('node:test')
1
4
  const assert = require('node:assert')
2
5
  const os = require('node:os')
3
6
 
4
- describe('qfile', () => {
5
- describe('qfile', () => {
6
- beforeEach((done) => {
7
- this.qfile = require('../../outbound/qfile')
8
- done()
9
- })
7
+ const qfile = require('../../outbound/qfile')
10
8
 
9
+ describe('outbound/qfile', () => {
10
+ describe('name', () => {
11
11
  it('name() basic functions', () => {
12
- const name = this.qfile.name()
12
+ const name = qfile.name()
13
13
  const split = name.split('_')
14
14
  assert.equal(split.length, 7)
15
15
  assert.equal(split[2], 0)
@@ -25,7 +25,7 @@ describe('qfile', () => {
25
25
  uid: 'XXYYZZ',
26
26
  host: os.hostname(),
27
27
  }
28
- const name = this.qfile.name(overrides)
28
+ const name = qfile.name(overrides)
29
29
  const split = name.split('_')
30
30
  assert.equal(split.length, 7)
31
31
  assert.equal(split[0], overrides.arrival)
@@ -38,9 +38,9 @@ describe('qfile', () => {
38
38
 
39
39
  it('rnd_unique() is unique-ish', () => {
40
40
  const repeats = 1000
41
- const u = this.qfile.rnd_unique()
41
+ const u = qfile.rnd_unique()
42
42
  for (let i = 0; i < repeats; i++) {
43
- assert.notEqual(u, this.qfile.rnd_unique())
43
+ assert.notEqual(u, qfile.rnd_unique())
44
44
  }
45
45
  })
46
46
  })
@@ -49,7 +49,7 @@ describe('qfile', () => {
49
49
  it('parts() updates previous queue filenames', () => {
50
50
  // $nextattempt_$attempts_$pid_$uniq.$host
51
51
  const name = '1111_0_2222_3333.foo.example.com'
52
- const parts = this.qfile.parts(name)
52
+ const parts = qfile.parts(name)
53
53
  assert.equal(parts.next_attempt, 1111)
54
54
  assert.equal(parts.attempts, 0)
55
55
  assert.equal(parts.pid, 2222)
@@ -65,8 +65,8 @@ describe('qfile', () => {
65
65
  uid: 'XXYYZZ',
66
66
  host: os.hostname(),
67
67
  }
68
- const name = this.qfile.name(overrides)
69
- const parts = this.qfile.parts(name)
68
+ const name = qfile.name(overrides)
69
+ const parts = qfile.parts(name)
70
70
  assert.equal(parts.arrival, overrides.arrival)
71
71
  assert.equal(parts.next_attempt, overrides.next_attempt)
72
72
  assert.equal(parts.attempts, overrides.attempts)
@@ -75,8 +75,8 @@ describe('qfile', () => {
75
75
  assert.equal(parts.host, overrides.host)
76
76
  })
77
77
 
78
- it('handles 4', () => {
79
- const r = this.qfile.parts('1484878079415_0_12345_8888.mta1.example.com')
78
+ it('handles 4-part legacy filename', () => {
79
+ const r = qfile.parts('1484878079415_0_12345_8888.mta1.example.com')
80
80
  delete r.arrival
81
81
  delete r.uid
82
82
  delete r.counter
@@ -89,8 +89,8 @@ describe('qfile', () => {
89
89
  })
90
90
  })
91
91
 
92
- it('handles 7', () => {
93
- const r = this.qfile.parts('1516650518128_1516667073032_8_29538_TkPZWz_1_haraka')
92
+ it('handles 7-part standard filename', () => {
93
+ const r = qfile.parts('1516650518128_1516667073032_8_29538_TkPZWz_1_haraka')
94
94
  delete r.age
95
95
  assert.deepEqual(r, {
96
96
  arrival: 1516650518128,
@@ -103,22 +103,22 @@ describe('qfile', () => {
103
103
  })
104
104
  })
105
105
 
106
- it('punts on 5', () => {
107
- assert.deepEqual(this.qfile.parts('1516650518128_1516667073032_8_29538_TkPZWz'), null)
106
+ it('punts on 5-part filename', () => {
107
+ assert.deepEqual(qfile.parts('1516650518128_1516667073032_8_29538_TkPZWz'), null)
108
108
  })
109
109
  })
110
110
 
111
111
  describe('hostname', () => {
112
- it('hostname, defaults to os.hostname()', () => {
113
- assert.deepEqual(this.qfile.hostname(), require('os').hostname())
112
+ it('defaults to os.hostname()', () => {
113
+ assert.deepEqual(qfile.hostname(), os.hostname())
114
114
  })
115
115
 
116
- it('hostname, replaces \\ char', () => {
117
- assert.deepEqual(this.qfile.hostname('mt\\a1.exam\\ple.com'), 'mt\\057a1.exam\\057ple.com')
116
+ it('replaces backslash char', () => {
117
+ assert.deepEqual(qfile.hostname('mt\\a1.exam\\ple.com'), 'mt\\057a1.exam\\057ple.com')
118
118
  })
119
119
 
120
- it('hostname, replaces _ char', () => {
121
- assert.deepEqual(this.qfile.hostname('mt_a1.exam_ple.com'), 'mt\\137a1.exam\\137ple.com')
120
+ it('replaces underscore char', () => {
121
+ assert.deepEqual(qfile.hostname('mt_a1.exam_ple.com'), 'mt\\137a1.exam\\137ple.com')
122
122
  })
123
123
  })
124
124
  })
@@ -0,0 +1,233 @@
1
+ 'use strict'
2
+
3
+ const { describe, it, beforeEach, afterEach } = require('node:test')
4
+ const assert = require('node:assert')
5
+ const fs = require('node:fs')
6
+ const path = require('node:path')
7
+ const os = require('node:os')
8
+
9
+ const queue = require('../../outbound/queue')
10
+ const qfile = require('../../outbound/qfile')
11
+
12
+ const sourceQueueDir = path.join('test', 'queue')
13
+ const testQueueDir = path.join('test', 'test-queue')
14
+ const fixtureFiles = [
15
+ '1508455115683_1508455115683_0_90253_9Q4o4V_1_haraka',
16
+ '1508269674999_1508269674999_0_34002_socVUF_1_haraka',
17
+ ]
18
+
19
+ const clearTestQueue = () => {
20
+ fs.mkdirSync(testQueueDir, { recursive: true })
21
+ for (const file of fs.readdirSync(testQueueDir)) {
22
+ fs.unlinkSync(path.join(testQueueDir, file))
23
+ }
24
+ }
25
+
26
+ const populateTestQueue = () => {
27
+ clearTestQueue()
28
+ for (const file of fixtureFiles) {
29
+ fs.copyFileSync(path.join(sourceQueueDir, file), path.join(testQueueDir, file))
30
+ }
31
+ }
32
+
33
+ describe('outbound/queue', () => {
34
+ describe('read_parts', () => {
35
+ it('parses valid queue filenames', () => {
36
+ const filename = '1507509981169_1507509981169_0_61403_e0Y0Ym_1_haraka'
37
+ const parts = queue.read_parts(filename)
38
+ assert.ok(parts)
39
+ assert.equal(parts.arrival, 1507509981169)
40
+ assert.equal(parts.next_attempt, 1507509981169)
41
+ assert.equal(parts.attempts, 0)
42
+ assert.equal(parts.pid, 61403)
43
+ assert.equal(parts.uid, 'e0Y0Ym')
44
+ })
45
+
46
+ it('rejects dot files', () => {
47
+ assert.strictEqual(queue.read_parts('__tmp__.filename'), false)
48
+ })
49
+
50
+ it('rejects error files', () => {
51
+ assert.strictEqual(queue.read_parts('error.something'), false)
52
+ })
53
+
54
+ it('rejects invalid queue files', () => {
55
+ assert.strictEqual(queue.read_parts('invalid-file'), false)
56
+ })
57
+ })
58
+
59
+ describe('load_queue_files', () => {
60
+ beforeEach(() => {
61
+ populateTestQueue()
62
+ })
63
+ afterEach(() => {
64
+ clearTestQueue()
65
+ })
66
+
67
+ it('processes valid queue files', async () => {
68
+ const seen = []
69
+
70
+ const files = await queue.load_queue_files(
71
+ null,
72
+ ['1508455115683_1508455115683_0_90253_9Q4o4V_1_haraka'],
73
+ (file) => {
74
+ seen.push(file)
75
+ return file
76
+ },
77
+ )
78
+ assert.equal(seen.length, 1)
79
+ assert.equal(files[0], '1508455115683_1508455115683_0_90253_9Q4o4V_1_haraka')
80
+ })
81
+
82
+ it('skips invalid files', async () => {
83
+ const seen = []
84
+
85
+ await queue.load_queue_files(
86
+ null,
87
+ ['1507509981169_1507509981169_0_61403_e0Y0Ym_1_haraka', 'invalid-file', 'zero-length'],
88
+ (file) => {
89
+ seen.push(file)
90
+ },
91
+ )
92
+ assert.equal(seen.length, 1)
93
+ })
94
+
95
+ it('filters files by pid', async () => {
96
+ let renameAttempts = 0
97
+
98
+ const originalRename = queue.rename_to_actual_pid
99
+ queue.rename_to_actual_pid = (_file, _parts) => {
100
+ renameAttempts++
101
+ throw new Error('test skip')
102
+ }
103
+
104
+ await queue.load_queue_files(
105
+ 61403,
106
+ [
107
+ '1507509981169_1507509981169_0_61403_e0Y0Ym_1_haraka',
108
+ '1508455115683_1508455115683_0_90253_9Q4o4V_1_haraka',
109
+ ],
110
+ (_file) => {},
111
+ )
112
+ queue.rename_to_actual_pid = originalRename
113
+ assert.equal(renameAttempts, 1)
114
+ })
115
+ })
116
+
117
+ describe('ensure_queue_dir', () => {
118
+ it('creates queue dir', async () => {
119
+ const tmpDir = path.join(os.tmpdir(), `haraka-test-queue-${Date.now()}`)
120
+
121
+ const originalQueueDir = queue.queue_dir
122
+ queue.queue_dir = tmpDir
123
+
124
+ try {
125
+ await queue.ensure_queue_dir()
126
+ assert.ok(fs.existsSync(tmpDir))
127
+ const stat = await fs.promises.stat(tmpDir)
128
+ assert.ok(stat.isDirectory())
129
+ } catch (err) {
130
+ assert.fail(`ensure_queue_dir threw an error: ${err.message}`)
131
+ } finally {
132
+ queue.queue_dir = originalQueueDir
133
+ if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true })
134
+ }
135
+ })
136
+
137
+ it('returns early if queue dir already exists', async () => {
138
+ const tmpDir = path.join(os.tmpdir(), `haraka-test-queue-exists-${Date.now()}`)
139
+ fs.mkdirSync(tmpDir)
140
+
141
+ const originalQueueDir = queue.queue_dir
142
+ queue.queue_dir = tmpDir
143
+
144
+ try {
145
+ await queue.ensure_queue_dir()
146
+ assert.ok(fs.existsSync(tmpDir))
147
+ } catch (err) {
148
+ assert.fail(`ensure_queue_dir threw an error: ${err.message}`)
149
+ } finally {
150
+ queue.queue_dir = originalQueueDir
151
+ fs.rmSync(tmpDir, { recursive: true })
152
+ }
153
+ })
154
+ })
155
+
156
+ describe('_load_cur_queue', () => {
157
+ beforeEach(() => {
158
+ populateTestQueue()
159
+ })
160
+ afterEach(() => {
161
+ clearTestQueue()
162
+ })
163
+
164
+ it('reads queue directory and processes files', async () => {
165
+ const processedFiles = []
166
+ await queue._load_cur_queue(null, (file) => {
167
+ processedFiles.push(file)
168
+ })
169
+
170
+ assert.ok(processedFiles.length >= 0)
171
+ })
172
+ })
173
+
174
+ describe('list_queue', () => {
175
+ beforeEach(() => {
176
+ populateTestQueue()
177
+ })
178
+ afterEach(() => {
179
+ clearTestQueue()
180
+ })
181
+
182
+ it('returns todo objects from real queue files', async () => {
183
+ const qlist = await queue.list_queue()
184
+ assert.ok(Array.isArray(qlist))
185
+ assert.ok(qlist.length > 0)
186
+ assert.ok(qlist[0].mail_from)
187
+ assert.ok(Array.isArray(qlist[0].rcpt_to))
188
+ })
189
+ })
190
+
191
+ describe('stat_queue', () => {
192
+ beforeEach(() => {
193
+ populateTestQueue()
194
+ })
195
+ afterEach(() => {
196
+ clearTestQueue()
197
+ })
198
+
199
+ it('returns queue stats', async () => {
200
+ const stats = await queue.stat_queue()
201
+ assert.ok(stats)
202
+ assert.ok('queue_dir' in stats)
203
+ assert.ok(stats.queue_count >= 1)
204
+ })
205
+ })
206
+
207
+ describe('load_pid_queue', () => {
208
+ beforeEach(() => {
209
+ populateTestQueue()
210
+ })
211
+ afterEach(() => {
212
+ clearTestQueue()
213
+ })
214
+
215
+ it('delegates pid loading to init_queue', async () => {
216
+ const parts = qfile.parts(fixtureFiles[0])
217
+ const observed = []
218
+ const originalLoadQueue = queue.init_queue
219
+
220
+ queue.init_queue = (pid) => {
221
+ observed.push(pid)
222
+ }
223
+
224
+ try {
225
+ assert.ok(fs.existsSync(path.join(testQueueDir, fixtureFiles[0])))
226
+ await queue.load_pid_queue(parts.pid)
227
+ assert.deepEqual(observed, [parts.pid])
228
+ } finally {
229
+ queue.init_queue = originalLoadQueue
230
+ }
231
+ })
232
+ })
233
+ })
@@ -30,7 +30,7 @@ describe('smtp_forward', () => {
30
30
  plugin.register()
31
31
 
32
32
  assert.equal(plugin.tls_options, undefined)
33
- assert.equal(plugin.register_hook.called, true)
33
+ assert.ok(Object.keys(plugin.hooks).length)
34
34
  })
35
35
  })
36
36
 
@@ -0,0 +1,93 @@
1
+ 'use strict'
2
+
3
+ const assert = require('node:assert')
4
+
5
+ const { Address } = require('address-rfc2821')
6
+ const fixtures = require('haraka-test-fixtures')
7
+
8
+ const _set_up = (done) => {
9
+ this.plugin = new fixtures.plugin('record_envelope_addresses')
10
+ this.connection = fixtures.connection.createConnection()
11
+ this.connection.init_transaction()
12
+ done()
13
+ }
14
+
15
+ describe('record_envelope_addresses', () => {
16
+ beforeEach(_set_up)
17
+
18
+ describe('hook_mail', () => {
19
+ it('adds X-Envelope-From header from MAIL FROM address', (done) => {
20
+ const addr = new Address('<sender@example.com>')
21
+ this.plugin.hook_mail(
22
+ () => {
23
+ const vals = this.connection.transaction.header.get_all('X-Envelope-From')
24
+ assert.equal(vals.length, 1, 'header was added')
25
+ assert.equal(vals[0], 'sender@example.com')
26
+ done()
27
+ },
28
+ this.connection,
29
+ [addr],
30
+ )
31
+ })
32
+
33
+ it('does not throw when connection has no transaction', (done) => {
34
+ this.connection.transaction = null
35
+ const addr = new Address('<sender@example.com>')
36
+ this.plugin.hook_mail(
37
+ () => {
38
+ assert.ok(true, 'next was called without error')
39
+ done()
40
+ },
41
+ this.connection,
42
+ [addr],
43
+ )
44
+ })
45
+ })
46
+
47
+ describe('hook_rcpt', () => {
48
+ it('adds X-Envelope-To header from RCPT TO address', (done) => {
49
+ const addr = new Address('<rcpt@example.com>')
50
+ this.plugin.hook_rcpt(
51
+ () => {
52
+ const vals = this.connection.transaction.header.get_all('X-Envelope-To')
53
+ assert.equal(vals.length, 1, 'header was added')
54
+ assert.equal(vals[0], 'rcpt@example.com')
55
+ done()
56
+ },
57
+ this.connection,
58
+ [addr],
59
+ )
60
+ })
61
+
62
+ it('adds X-Envelope-To header for each recipient', (done) => {
63
+ const addr1 = new Address('<one@example.com>')
64
+ const addr2 = new Address('<two@example.com>')
65
+ let calls = 0
66
+ const next = () => {
67
+ calls++
68
+ if (calls === 2) {
69
+ const vals = this.connection.transaction.header.get_all('X-Envelope-To')
70
+ assert.equal(vals.length, 2, 'two headers added')
71
+ assert.equal(vals[0], 'one@example.com')
72
+ assert.equal(vals[1], 'two@example.com')
73
+ done()
74
+ }
75
+ }
76
+ this.plugin.hook_rcpt(next, this.connection, [addr1])
77
+ this.plugin.hook_rcpt(next, this.connection, [addr2])
78
+ })
79
+
80
+ it('does not throw when connection has no transaction', (done) => {
81
+ this.connection.transaction = null
82
+ const addr = new Address('<rcpt@example.com>')
83
+ this.plugin.hook_rcpt(
84
+ () => {
85
+ assert.ok(true, 'next was called without error')
86
+ done()
87
+ },
88
+ this.connection,
89
+ [addr],
90
+ )
91
+ })
92
+ })
93
+ })
@@ -39,9 +39,9 @@ describe('tls', () => {
39
39
  })
40
40
 
41
41
  describe('register', () => {
42
- it('with certs, should call register_hook()', () => {
42
+ it('with certs, should register hooks', () => {
43
43
  this.plugin.register()
44
- assert.ok(this.plugin.register_hook.called)
44
+ assert.ok(Object.keys(this.plugin.hooks).length)
45
45
  })
46
46
  })
47
47
 
@@ -0,0 +1,137 @@
1
+ 'use strict'
2
+
3
+ const assert = require('node:assert')
4
+
5
+ const fixtures = require('haraka-test-fixtures')
6
+
7
+ const _set_up = (done) => {
8
+ this.plugin = new fixtures.plugin('xclient')
9
+ this.connection = fixtures.connection.createConnection()
10
+ this.connection.capabilities = []
11
+ done()
12
+ }
13
+
14
+ describe('xclient', () => {
15
+ beforeEach(_set_up)
16
+
17
+ describe('hook_capabilities', () => {
18
+ it('adds XCLIENT capability for allowed IP (127.0.0.1)', (done) => {
19
+ this.connection.remote.ip = '127.0.0.1'
20
+ this.plugin.hook_capabilities(() => {
21
+ assert.ok(
22
+ this.connection.capabilities.some((c) => c.startsWith('XCLIENT')),
23
+ 'XCLIENT capability added',
24
+ )
25
+ done()
26
+ }, this.connection)
27
+ })
28
+
29
+ it('adds XCLIENT capability for allowed IP (::1)', (done) => {
30
+ this.connection.remote.ip = '::1'
31
+ this.plugin.hook_capabilities(() => {
32
+ assert.ok(
33
+ this.connection.capabilities.some((c) => c.startsWith('XCLIENT')),
34
+ 'XCLIENT capability added for IPv6 loopback',
35
+ )
36
+ done()
37
+ }, this.connection)
38
+ })
39
+
40
+ it('does not add XCLIENT capability for disallowed IP', (done) => {
41
+ this.connection.remote.ip = '10.0.0.1'
42
+ this.plugin.hook_capabilities(() => {
43
+ assert.ok(
44
+ !this.connection.capabilities.some((c) => c.startsWith('XCLIENT')),
45
+ 'XCLIENT capability not added',
46
+ )
47
+ done()
48
+ }, this.connection)
49
+ })
50
+ })
51
+
52
+ describe('hook_unrecognized_command', () => {
53
+ it('ignores non-XCLIENT commands', (done) => {
54
+ this.plugin.hook_unrecognized_command(
55
+ (code) => {
56
+ assert.equal(code, undefined, 'next called with no args')
57
+ done()
58
+ },
59
+ this.connection,
60
+ ['EHLO', 'example.com'],
61
+ )
62
+ })
63
+
64
+ it('denies XCLIENT when transaction is in progress', (done) => {
65
+ this.connection.init_transaction()
66
+ this.plugin.hook_unrecognized_command(
67
+ (code) => {
68
+ assert.equal(code, DENY, 'denied with transaction in progress')
69
+ done()
70
+ },
71
+ this.connection,
72
+ ['XCLIENT', 'ADDR=127.0.0.1'],
73
+ )
74
+ })
75
+
76
+ it('denies XCLIENT from disallowed IP', (done) => {
77
+ this.connection.remote.ip = '10.0.0.1'
78
+ this.plugin.hook_unrecognized_command(
79
+ (code) => {
80
+ assert.equal(code, DENY, 'denied from non-allowed IP')
81
+ done()
82
+ },
83
+ this.connection,
84
+ ['XCLIENT', 'ADDR=127.0.0.2'],
85
+ )
86
+ })
87
+
88
+ it('denies XCLIENT with no valid IP address', (done) => {
89
+ this.connection.remote.ip = '127.0.0.1'
90
+ this.plugin.hook_unrecognized_command(
91
+ (code) => {
92
+ assert.equal(code, DENY, 'denied when no valid ADDR')
93
+ done()
94
+ },
95
+ this.connection,
96
+ ['XCLIENT', 'NAME=example.com'],
97
+ )
98
+ })
99
+
100
+ it('accepts XCLIENT with valid IPv4 ADDR from allowed host', (done) => {
101
+ this.connection.remote.ip = '127.0.0.1'
102
+ this.plugin.hook_unrecognized_command(
103
+ (code) => {
104
+ // NEXT_HOOK or undefined (next called) means accepted
105
+ assert.ok(code === NEXT_HOOK || code === undefined, 'accepted valid XCLIENT')
106
+ done()
107
+ },
108
+ this.connection,
109
+ ['XCLIENT', 'ADDR=1.2.3.4'],
110
+ )
111
+ })
112
+
113
+ it('accepts XCLIENT with valid IPv6 ADDR from allowed host', (done) => {
114
+ this.connection.remote.ip = '127.0.0.1'
115
+ this.plugin.hook_unrecognized_command(
116
+ (code) => {
117
+ assert.ok(code === NEXT_HOOK || code === undefined, 'accepted valid IPv6 XCLIENT')
118
+ done()
119
+ },
120
+ this.connection,
121
+ ['XCLIENT', 'ADDR=IPV6:2001:db8::1'],
122
+ )
123
+ })
124
+
125
+ it('accepts XCLIENT with ADDR and NAME, skipping rdns lookup', (done) => {
126
+ this.connection.remote.ip = '127.0.0.1'
127
+ this.plugin.hook_unrecognized_command(
128
+ (code) => {
129
+ assert.equal(code, NEXT_HOOK, 'jumps to connect hook when NAME provided')
130
+ done()
131
+ },
132
+ this.connection,
133
+ ['XCLIENT', 'ADDR=1.2.3.4 NAME=example.com'],
134
+ )
135
+ })
136
+ })
137
+ })
package/test/rfc1869.js CHANGED
@@ -63,4 +63,47 @@ describe('rfc1869', () => {
63
63
  it('RCPT TO:<postmaster>', () => {
64
64
  _check('RCPT TO:<postmaster>', ['<postmaster>'])
65
65
  })
66
+
67
+ describe('error cases', () => {
68
+ const throwCases = [
69
+ {
70
+ desc: 'MAIL FROM with space inside angle-bracket address',
71
+ args: ['mail', 'FROM:<user@dom ain>'],
72
+ },
73
+ {
74
+ desc: 'RCPT TO with syntax error in address (space in address)',
75
+ args: ['rcpt', 'TO: user @domain bad'],
76
+ },
77
+ {
78
+ desc: 'RCPT TO unknown address (no @ and not postmaster/abuse)',
79
+ args: ['rcpt', 'TO:unknown'],
80
+ },
81
+ ]
82
+
83
+ for (const { desc, args } of throwCases) {
84
+ it(`throws: ${desc}`, () => {
85
+ assert.throws(() => parse(...args), Error)
86
+ })
87
+ }
88
+ })
89
+
90
+ describe('strict mode', () => {
91
+ it('strict MAIL FROM:<user@domain> accepts angle-bracket address', () => {
92
+ const result = parse('mail', 'FROM:<user@domain.com>', true)
93
+ assert.equal(result[0], '<user@domain.com>')
94
+ })
95
+
96
+ it('strict MAIL FROM without angle brackets throws', () => {
97
+ assert.throws(() => parse('mail', 'FROM:user@domain.com', true), Error)
98
+ })
99
+
100
+ it('strict RCPT TO:<user@domain> accepts angle-bracket address', () => {
101
+ const result = parse('rcpt', 'TO:<user@domain.com>', true)
102
+ assert.equal(result[0], '<user@domain.com>')
103
+ })
104
+
105
+ it('strict RCPT TO without angle brackets throws', () => {
106
+ assert.throws(() => parse('rcpt', 'TO:user@domain.com', true), Error)
107
+ })
108
+ })
66
109
  })
@@ -140,15 +140,15 @@ describe('smtp_client', () => {
140
140
  this.client.on('data', () => {
141
141
  assert.equal(this.client.response[0], 'go ahead')
142
142
  this.client.start_data(message_stream)
143
- message_stream.on('end', () => {
144
- this.client.socket.write('.\r\n')
145
- })
146
143
  message_stream.add_line('Header: test\r\n')
147
144
  message_stream.add_line('\r\n')
148
145
  message_stream.add_line('hi\r\n')
149
146
  message_stream.add_line_end()
150
147
  })
151
148
 
149
+ data.push('Header: test')
150
+ data.push('')
151
+ data.push('hi')
152
152
  data.push('.')
153
153
  data.push('250 message queued')
154
154
 
@@ -249,15 +249,15 @@ describe('smtp_client', () => {
249
249
  this.client.on('data', () => {
250
250
  assert.equal(this.client.response[0], 'go ahead')
251
251
  this.client.start_data(message_stream)
252
- message_stream.on('end', () => {
253
- this.client.socket.write('.\r\n')
254
- })
255
252
  message_stream.add_line('Header: test\r\n')
256
253
  message_stream.add_line('\r\n')
257
254
  message_stream.add_line('hi\r\n')
258
255
  message_stream.add_line_end()
259
256
  })
260
257
 
258
+ data.push('Header: test')
259
+ data.push('')
260
+ data.push('hi')
261
261
  data.push('.')
262
262
  data.push('250 message queued')
263
263