Haraka 3.1.3 → 3.1.5

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 (65) hide show
  1. package/.prettierignore +2 -0
  2. package/CONTRIBUTORS.md +23 -1
  3. package/Changes.md +52 -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 +26 -29
  16. package/plugins/prevent_credential_leaks.js +2 -2
  17. package/plugins/process_title.js +1 -1
  18. package/plugins/queue/smtp_forward.js +5 -5
  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 +8 -2
  24. package/server.js +15 -10
  25. package/smtp_client.js +10 -15
  26. package/test/config/tls/haraka.local.pem +47 -47
  27. package/test/connection.js +286 -147
  28. package/test/endpoint.js +5 -4
  29. package/test/fixtures/line_socket.js +1 -0
  30. package/test/fixtures/util_hmailitem.js +1 -1
  31. package/test/host_pool.js +57 -31
  32. package/test/logger.js +75 -135
  33. package/test/outbound/bounce_net_errors.js +132 -0
  34. package/test/outbound/bounce_rfc3464.js +226 -0
  35. package/test/outbound/hmail.js +140 -104
  36. package/test/outbound/index.js +61 -101
  37. package/test/outbound/qfile.js +25 -25
  38. package/test/outbound/queue.js +233 -0
  39. package/test/plugins/auth/auth_base.js +39 -44
  40. package/test/plugins/auth/auth_vpopmaild.js +8 -9
  41. package/test/plugins/queue/smtp_forward.js +953 -183
  42. package/test/plugins/rcpt_to.host_list_base.js +58 -93
  43. package/test/plugins/rcpt_to.in_host_list.js +126 -175
  44. package/test/plugins/record_envelope_addresses.js +93 -0
  45. package/test/plugins/status.js +10 -10
  46. package/test/plugins/tls.js +11 -21
  47. package/test/plugins/xclient.js +102 -0
  48. package/test/plugins.js +10 -13
  49. package/test/rfc1869.js +71 -48
  50. package/test/server.js +281 -436
  51. package/test/smtp_client.js +1194 -220
  52. package/test/tls_socket.js +74 -243
  53. package/test/transaction.js +486 -201
  54. package/tls_socket.js +19 -23
  55. package/transaction.js +33 -10
  56. package/config/rabbitmq.ini +0 -10
  57. package/config/rabbitmq_amqplib.ini +0 -19
  58. package/docs/plugins/queue/rabbitmq.md +0 -34
  59. package/docs/plugins/queue/rabbitmq_amqplib.md +0 -51
  60. package/plugins/queue/rabbitmq.js +0 -141
  61. package/plugins/queue/rabbitmq_amqplib.js +0 -96
  62. package/test/config/tls/ec.pem +0 -23
  63. package/test/config/tls/mismatched.pem +0 -49
  64. package/test/outbound_bounce_net_errors.js +0 -157
  65. package/test/outbound_bounce_rfc3464.js +0 -366
@@ -1,3 +1,6 @@
1
+ 'use strict'
2
+
3
+ const { describe, it, beforeEach } = require('node:test')
1
4
  const assert = require('node:assert')
2
5
  const fs = require('node:fs')
3
6
  const path = require('node:path')
@@ -5,230 +8,315 @@ const path = require('node:path')
5
8
  const config = require('haraka-config')
6
9
  const transaction = require('../transaction')
7
10
 
8
- const _set_up = (done) => {
11
+ // ── Helpers ───────────────────────────────────────────────────────────────────
12
+
13
+ const endData = (txn) => new Promise((resolve) => txn.end_data(resolve))
14
+ const getData = (stream) => new Promise((resolve) => stream.get_data(resolve))
15
+
16
+ const setUp = () => {
9
17
  this.transaction = transaction.createTransaction(undefined, config.get('smtp.ini'))
10
- done()
11
18
  }
12
19
 
20
+ function addLines(txn, lines) {
21
+ for (const line of lines) txn.add_data(line)
22
+ }
23
+
24
+ function write_file_data_to_transaction(test_transaction, filename) {
25
+ const specimen = fs.readFileSync(filename, 'utf8')
26
+ const matcher = /[^\n]*([\n]|$)/g
27
+ let line
28
+ do {
29
+ line = matcher.exec(specimen)
30
+ if (line[0] === '') break
31
+ test_transaction.add_data(line[0])
32
+ } while (line[0] !== '')
33
+ test_transaction.end_data()
34
+ }
35
+
36
+ // ── Tests ─────────────────────────────────────────────────────────────────────
37
+
13
38
  describe('transaction', () => {
14
- beforeEach(_set_up)
15
-
16
- it('add_body_filter', (done) => {
17
- this.transaction.add_body_filter('text/plain', (ct, enc, buf) => {
18
- // The functionality of these filter functions is tested in
19
- // haraka-email-message. This just assures the plumbing is in place.
20
-
21
- assert.ok(ct.indexOf('text/plain') === 0, 'correct body part')
22
- assert.ok(/utf-?8/i.test(enc), 'correct encoding')
23
- assert.equal(buf.toString().trim(), 'Text part', 'correct body contents')
24
- })
25
- ;[
26
- 'Content-Type: multipart/alternative; boundary=abcd\n',
27
- '\n',
28
- '--abcd\n',
29
- 'Content-Type: text/plain\n',
30
- '\n',
31
- 'Text part\n',
32
- '--abcd\n',
33
- 'Content-Type: text/html\n',
34
- '\n',
35
- '<p>HTML part</p>\n',
36
- '--abcd--\n',
37
- ].forEach((line) => {
38
- this.transaction.add_data(line)
39
- })
40
- this.transaction.end_data(() => {
41
- done()
39
+ beforeEach(setUp)
40
+
41
+ describe('createTransaction', () => {
42
+ it('generates a UUID when none is provided', () => {
43
+ const txn = transaction.createTransaction()
44
+ assert.ok(txn.uuid, 'uuid is set')
45
+ assert.match(txn.uuid, /^[0-9A-F-]+$/i, 'uuid looks like a UUID')
42
46
  })
43
- })
44
47
 
45
- it('regression: attachment_hooks before set_banner/add_body_filter', (done) => {
46
- this.transaction.attachment_hooks(() => {})
47
- this.transaction.set_banner('banner')
48
- this.transaction.add_body_filter('', () => {
49
- assert.ok(true, 'body filter called')
48
+ it('uses the provided UUID', () => {
49
+ const txn = transaction.createTransaction('TEST-UUID')
50
+ assert.equal(txn.uuid, 'TEST-UUID')
50
51
  })
51
- ;['Content-Type: text/plain\n', '\n', 'Some text\n'].forEach((line) => {
52
- this.transaction.add_data(line)
52
+
53
+ it('initialises header_pos to 0', () => {
54
+ assert.equal(this.transaction.header_pos, 0)
53
55
  })
54
56
 
55
- this.transaction.end_data(() => {
56
- this.transaction.message_stream.get_data((body) => {
57
- assert.ok(/banner$/.test(body.toString().trim()), 'banner applied')
58
- done()
59
- })
57
+ it('initialises found_hb_sep to false', () => {
58
+ assert.equal(this.transaction.found_hb_sep, false)
60
59
  })
61
60
  })
62
61
 
63
- it('correct output encoding when content in non-utf8 #2176', (done) => {
64
- // Czech panagram "Příliš žluťoučký kůň úpěl ďábelské ódy." in ISO-8859-2 encoding
65
- const message = [
66
- 0x50, 0xf8, 0xed, 0x6c, 0x69, 0xb9, 0x20, 0xbe, 0x6c, 0x75, 0xbb, 0x6f, 0x76, 0xe8, 0x6b, 0xfd, 0x20, 0x6b,
67
- 0xf9, 0xf2, 0xfa, 0xec, 0x6c, 0x20, 0xef, 0xe2, 0x62, 0x65, 0x6c, 0x73, 0x6b, 0xe9, 0x20, 0xf3, 0x64, 0x79,
68
- 0x2e,
69
- ]
70
- const payload = [
71
- Buffer.from('Content-Type: text/plain; charset=iso-8859-2; format=flowed\n'),
72
- '\n',
73
- Buffer.from([...message, 0x0a]), // Add \n
74
- ]
62
+ describe('add_body_filter', () => {
63
+ it('filter callback receives correct content-type, encoding, and body', async () => {
64
+ let called = false
65
+ this.transaction.add_body_filter('text/plain', (ct, enc, buf) => {
66
+ assert.ok(ct.startsWith('text/plain'), 'correct content-type')
67
+ assert.match(enc, /utf-?8/i, 'correct encoding')
68
+ assert.equal(buf.toString().trim(), 'Text part', 'correct body text')
69
+ called = true
70
+ })
71
+ addLines(this.transaction, [
72
+ 'Content-Type: multipart/alternative; boundary=abcd\n',
73
+ '\n',
74
+ '--abcd\n',
75
+ 'Content-Type: text/plain\n',
76
+ '\n',
77
+ 'Text part\n',
78
+ '--abcd\n',
79
+ 'Content-Type: text/html\n',
80
+ '\n',
81
+ '<p>HTML part</p>\n',
82
+ '--abcd--\n',
83
+ ])
84
+ await endData(this.transaction)
85
+ await getData(this.transaction.message_stream)
86
+ assert.ok(called, 'filter was called')
87
+ })
75
88
 
76
- this.transaction.parse_body = true
77
- this.transaction.attachment_hooks(function () {})
89
+ // Issue #2290: add_body_filter called after ensure_body() has already run must still apply.
90
+ it('filter applied when added after body already initialised', async () => {
91
+ this.transaction.attachment_hooks(() => {})
92
+ this.transaction.add_data('Content-Type: text/plain\n')
93
+ this.transaction.add_data('\n')
78
94
 
79
- for (const line of payload) {
80
- this.transaction.add_data(line)
81
- }
82
- this.transaction.end_data(() => {
83
- this.transaction.message_stream.get_data(function (body) {
84
- assert.ok(body.includes(Buffer.from(message)), 'message not damaged')
85
- done()
95
+ let filter_called = false
96
+ this.transaction.add_body_filter('text/plain', (ct, enc, buf) => {
97
+ filter_called = true
98
+ return buf
86
99
  })
100
+
101
+ this.transaction.add_data('Hello\n')
102
+ await endData(this.transaction)
103
+ await getData(this.transaction.message_stream)
104
+ assert.ok(filter_called, 'filter called even when added after body init')
105
+ })
106
+
107
+ it('filter added after body init can transform content', async () => {
108
+ this.transaction.attachment_hooks(() => {})
109
+ this.transaction.add_data('Content-Type: text/plain\n')
110
+ this.transaction.add_data('\n')
111
+
112
+ this.transaction.add_body_filter('text/plain', (ct, enc, buf) => {
113
+ return Buffer.from(buf.toString().replace('Hello', 'World'))
114
+ })
115
+
116
+ this.transaction.add_data('Hello\n')
117
+ await endData(this.transaction)
118
+ const body = await getData(this.transaction.message_stream)
119
+ assert.ok(body.toString().includes('World'), 'filter transformed content')
120
+ assert.ok(!body.toString().includes('Hello'), 'original content was replaced')
87
121
  })
88
- })
89
122
 
90
- it('no munging of bytes if not parsing body', (done) => {
91
- // Czech panagram "Příliš žluťoučký kůň úpěl ďábelské ódy.\n" in ISO-8859-2 encoding
92
- const message = Buffer.from([
93
- 0x50, 0xf8, 0xed, 0x6c, 0x69, 0xb9, 0x20, 0xbe, 0x6c, 0x75, 0xbb, 0x6f, 0x76, 0xe8, 0x6b, 0xfd, 0x20, 0x6b,
94
- 0xf9, 0xf2, 0xfa, 0xec, 0x6c, 0x20, 0xef, 0xe2, 0x62, 0x65, 0x6c, 0x73, 0x6b, 0xe9, 0x20, 0xf3, 0x64, 0x79,
95
- 0x2e, 0x0a,
96
- ])
97
- const payload = ['Content-Type: text/plain; charset=iso-8859-2; format=flowed\n', '\n', message]
98
-
99
- payload.forEach((line) => {
100
- this.transaction.add_data(line)
101
- })
102
- this.transaction.end_data(() => {
103
- this.transaction.message_stream.get_data((body) => {
104
- assert.ok(body.includes(message), 'message not damaged')
105
- done()
123
+ it('filter with regex ct_match fires on matching part', async () => {
124
+ let matched_ct = null
125
+ this.transaction.add_body_filter(/^text\//, (ct, enc, buf) => {
126
+ matched_ct = ct
127
+ return buf
106
128
  })
129
+ addLines(this.transaction, [
130
+ 'Content-Type: multipart/alternative; boundary=X\n',
131
+ '\n',
132
+ '--X\n',
133
+ 'Content-Type: text/plain\n',
134
+ '\n',
135
+ 'Plain\n',
136
+ '--X--\n',
137
+ ])
138
+ await endData(this.transaction)
139
+ await getData(this.transaction.message_stream)
140
+ assert.ok(matched_ct && matched_ct.startsWith('text/'), 'regex matched content-type')
107
141
  })
108
142
  })
109
143
 
110
- it('bannering with nested mime structure', (done) => {
111
- this.transaction.set_banner('TEXT_BANNER', 'HTML_BANNER')
112
- ;[
113
- 'Content-Type: multipart/mixed; boundary="TOP_LEVEL"',
114
- '',
115
- '--TOP_LEVEL',
116
- 'Content-Type: multipart/alternative; boundary="INNER_LEVEL"',
117
- '',
118
- '--INNER_LEVEL',
119
- 'Content-Type: text/plain; charset=us-ascii',
120
- '',
121
- 'Hello, this is a text part',
122
- '--INNER_LEVEL',
123
- 'Content-Type: text/html; charset=us-ascii',
124
- '',
125
- '<p>This is an html part</p>',
126
- '--INNER_LEVEL--',
127
- '--TOP_LEVEL--',
128
- ].forEach((line) => {
129
- this.transaction.add_data(`${line}\r\n`)
130
- })
131
- this.transaction.end_data(() => {
132
- this.transaction.message_stream.get_data((body) => {
133
- assert.ok(
134
- /Hello, this is a text part/.test(body.toString()),
135
- 'text content comes through in final message',
136
- )
137
- assert.ok(/This is an html part/.test(body.toString()), 'html content comes through in final message')
138
- assert.ok(/TEXT_BANNER/.test(body.toString()), 'text banner comes through in final message')
139
- assert.ok(/HTML_BANNER/.test(body.toString()), 'html banner comes through in final message')
140
- done()
144
+ describe('attachment_hooks', () => {
145
+ it('sets parse_body to true', () => {
146
+ assert.equal(this.transaction.parse_body, false)
147
+ this.transaction.attachment_hooks(() => {})
148
+ assert.equal(this.transaction.parse_body, true)
149
+ })
150
+
151
+ it('attachment_hooks before set_banner and add_body_filter all cooperate', async () => {
152
+ this.transaction.attachment_hooks(() => {})
153
+ this.transaction.set_banner('banner')
154
+ let filter_called = false
155
+ this.transaction.add_body_filter('', () => {
156
+ filter_called = true
141
157
  })
158
+ addLines(this.transaction, ['Content-Type: text/plain\n', '\n', 'Some text\n'])
159
+ await endData(this.transaction)
160
+ const body = await getData(this.transaction.message_stream)
161
+ assert.ok(/banner$/.test(body.toString().trim()), 'banner applied')
162
+ assert.ok(filter_called, 'body filter called')
142
163
  })
143
164
  })
144
165
 
145
- describe('base64_handling', () => {
146
- beforeEach(_set_up)
166
+ describe('set_banner', () => {
167
+ it('appends text banner to plain-text body', async () => {
168
+ this.transaction.set_banner('TEXT_BANNER', 'HTML_BANNER')
169
+ addLines(this.transaction, ['Content-Type: text/plain\n', '\n', 'Hello\n'])
170
+ await endData(this.transaction)
171
+ const body = await getData(this.transaction.message_stream)
172
+ assert.ok(body.toString().includes('TEXT_BANNER'), 'text banner present')
173
+ })
147
174
 
148
- it('varied-base64-fold-lengths-preserve-data', (done) => {
149
- const parsed_attachments = {}
175
+ it('appends banners in nested MIME structure', async () => {
176
+ this.transaction.set_banner('TEXT_BANNER', 'HTML_BANNER')
177
+ addLines(this.transaction, [
178
+ 'Content-Type: multipart/mixed; boundary="TOP_LEVEL"\r\n',
179
+ '\r\n',
180
+ '--TOP_LEVEL\r\n',
181
+ 'Content-Type: multipart/alternative; boundary="INNER_LEVEL"\r\n',
182
+ '\r\n',
183
+ '--INNER_LEVEL\r\n',
184
+ 'Content-Type: text/plain; charset=us-ascii\r\n',
185
+ '\r\n',
186
+ 'Hello, this is a text part\r\n',
187
+ '--INNER_LEVEL\r\n',
188
+ 'Content-Type: text/html; charset=us-ascii\r\n',
189
+ '\r\n',
190
+ '<p>This is an html part</p>\r\n',
191
+ '--INNER_LEVEL--\r\n',
192
+ '--TOP_LEVEL--\r\n',
193
+ ])
194
+ await endData(this.transaction)
195
+ const body = await getData(this.transaction.message_stream)
196
+ const str = body.toString()
197
+ assert.ok(/Hello, this is a text part/.test(str), 'text part present')
198
+ assert.ok(/This is an html part/.test(str), 'html part present')
199
+ assert.ok(/TEXT_BANNER/.test(str), 'text banner present')
200
+ assert.ok(/HTML_BANNER/.test(str), 'html banner present')
201
+ })
202
+ })
203
+
204
+ describe('encoding', () => {
205
+ it('correct output when content is non-utf8 (#2176)', async () => {
206
+ // Czech panagram in ISO-8859-2
207
+ const message = Buffer.from([
208
+ 0x50, 0xf8, 0xed, 0x6c, 0x69, 0xb9, 0x20, 0xbe, 0x6c, 0x75, 0xbb, 0x6f, 0x76, 0xe8, 0x6b, 0xfd, 0x20,
209
+ 0x6b, 0xf9, 0xf2, 0xfa, 0xec, 0x6c, 0x20, 0xef, 0xe2, 0x62, 0x65, 0x6c, 0x73, 0x6b, 0xe9, 0x20, 0xf3,
210
+ 0x64, 0x79, 0x2e,
211
+ ])
212
+ this.transaction.parse_body = true
213
+ this.transaction.attachment_hooks(() => {})
214
+ addLines(this.transaction, [
215
+ Buffer.from('Content-Type: text/plain; charset=iso-8859-2; format=flowed\n'),
216
+ '\n',
217
+ Buffer.from([...message, 0x0a]),
218
+ ])
219
+ await endData(this.transaction)
220
+ const body = await getData(this.transaction.message_stream)
221
+ assert.ok(body.includes(message), 'ISO-8859-2 content not damaged')
222
+ })
223
+
224
+ it('no munging of bytes when not parsing body', async () => {
225
+ // Same Czech panagram — verifies raw pass-through
226
+ const message = Buffer.from([
227
+ 0x50, 0xf8, 0xed, 0x6c, 0x69, 0xb9, 0x20, 0xbe, 0x6c, 0x75, 0xbb, 0x6f, 0x76, 0xe8, 0x6b, 0xfd, 0x20,
228
+ 0x6b, 0xf9, 0xf2, 0xfa, 0xec, 0x6c, 0x20, 0xef, 0xe2, 0x62, 0x65, 0x6c, 0x73, 0x6b, 0xe9, 0x20, 0xf3,
229
+ 0x64, 0x79, 0x2e, 0x0a,
230
+ ])
231
+ addLines(this.transaction, ['Content-Type: text/plain; charset=iso-8859-2; format=flowed\n', '\n', message])
232
+ await endData(this.transaction)
233
+ const body = await getData(this.transaction.message_stream)
234
+ assert.ok(body.includes(message), 'raw bytes not damaged')
235
+ })
236
+
237
+ it('add_data auto-converts string input to Buffer', async () => {
238
+ // The code path for string input (should never happen but is defensive)
239
+ this.transaction.add_data('Subject: string-input\n')
240
+ this.transaction.add_data('\n')
241
+ this.transaction.add_data('body\n')
242
+ await endData(this.transaction)
243
+ const body = await getData(this.transaction.message_stream)
244
+ assert.ok(body.toString().includes('string-input'), 'string input was processed')
245
+ })
246
+ })
247
+
248
+ describe('base64 handling', () => {
249
+ it('varied fold-lengths preserve data integrity', async () => {
250
+ const parsed = {}
251
+ const pendingStreams = []
150
252
  this.transaction.parse_body = true
151
- //accumulate attachment buffers.
152
253
  this.transaction.attachment_hooks((ct, filename, body, stream) => {
153
- let attachment = Buffer.alloc(0)
154
- stream.on('data', (data) => {
155
- attachment = Buffer.concat([attachment, data])
156
- })
157
- stream.on('end', () => {
158
- parsed_attachments[filename] = attachment
159
- })
254
+ pendingStreams.push(
255
+ new Promise((resolve) => {
256
+ let buf = Buffer.alloc(0)
257
+ stream.on('data', (d) => {
258
+ buf = Buffer.concat([buf, d])
259
+ })
260
+ stream.on('end', () => {
261
+ parsed[filename] = buf
262
+ resolve()
263
+ })
264
+ }),
265
+ )
160
266
  })
161
267
 
162
- const specimen_path = path.join(__dirname, 'mail_specimen', 'varied-fold-lengths-preserve-data.txt')
163
- write_file_data_to_transaction(this.transaction, specimen_path)
268
+ const specimen = path.join(__dirname, 'mail_specimen', 'varied-fold-lengths-preserve-data.txt')
269
+ write_file_data_to_transaction(this.transaction, specimen)
270
+ await Promise.all(pendingStreams)
164
271
 
165
272
  assert.equal(this.transaction.body.children.length, 6)
166
273
 
167
- let first_attachment = null
168
- for (const i in parsed_attachments) {
169
- const current_attachment = parsed_attachments[i]
170
- first_attachment = first_attachment || current_attachment
171
- // All buffers from data that was encoded with varied line lengths should
172
- // still have the same final data.
173
- assert.equal(
174
- true,
175
- first_attachment.equals(current_attachment),
176
- `The buffer data for '${i}' doesn't appear to be equal to the other attachments, and is likely corrupted.`,
177
- )
274
+ let first = null
275
+ for (const name in parsed) {
276
+ first = first || parsed[name]
277
+ assert.ok(first.equals(parsed[name]), `buffer for '${name}' matches the others`)
178
278
  }
179
- done()
180
279
  })
181
280
 
182
- it('base64-root-html-decodes-correct-number-of-bytes', (done) => {
281
+ it('base64 root HTML decodes correct byte count', () => {
183
282
  this.transaction.parse_body = true
184
- const specimen_path = path.join(__dirname, 'mail_specimen', 'base64-root-part.txt')
185
- write_file_data_to_transaction(this.transaction, specimen_path)
186
-
283
+ const specimen = path.join(__dirname, 'mail_specimen', 'base64-root-part.txt')
284
+ write_file_data_to_transaction(this.transaction, specimen)
187
285
  assert.equal(this.transaction.body.bodytext.length, 425)
188
- done()
189
286
  })
190
287
  })
191
288
 
192
- // Test is to ensure boundary marker just after the headers, is in-tact
193
- // Issue: "User1990" <--abcd
194
- // Expected: --abcd
195
- describe('boundarymarkercorrupt_test', () => {
196
- beforeEach(_set_up)
197
-
198
- // populate the same email data in transaction (self.transaction.add_data()) and
199
- // in raw buffer, then compare
200
- it('fix mime boundary corruption issue', (done) => {
201
- const self = this
202
- let buffer = ''
203
- self.transaction.add_data('Content-Type: multipart/alternative; boundary=abcd\r\n')
204
- buffer += 'Content-Type: multipart/alternative; boundary=abcd\r\n'
205
- self.transaction.add_data(
289
+ describe('boundary marker corruption (#2244)', () => {
290
+ it('boundary marker is intact after large folded To header', async () => {
291
+ let buf = ''
292
+ this.transaction.add_data('Content-Type: multipart/alternative; boundary=abcd\r\n')
293
+ buf += 'Content-Type: multipart/alternative; boundary=abcd\r\n'
294
+ this.transaction.add_data(
206
295
  'To: "User1_firstname_middlename_lastname" <user1_firstname_middlename_lastname@test.com>,\r\n',
207
296
  )
208
- buffer += 'To: "User1_firstname_middlename_lastname" <user1_firstname_middlename_lastname@test.com>,\r\n'
209
- // make sure we add headers so that it exceeds 64k bytes to expose this issue
297
+ buf += 'To: "User1_firstname_middlename_lastname" <user1_firstname_middlename_lastname@test.com>,\r\n'
298
+
299
+ // Add enough continuation lines to exceed 64 KB
210
300
  for (let i = 0; i < 725; i++) {
211
- self.transaction.add_data(
212
- ` "User${i}_firstname_middlename_lastname" <user${i}_firstname_middlename_lastname@test.com>,\r\n`,
213
- )
214
- buffer += ` "User${i}_firstname_middlename_lastname" <user${i}_firstname_middlename_lastname@test.com>,\r\n`
301
+ const line = ` "User${i}_fn_mn_ln" <user${i}_fn_mn_ln@test.com>,\r\n`
302
+ this.transaction.add_data(line)
303
+ buf += line
215
304
  }
216
- self.transaction.add_data(
217
- ' "Final User_firstname_middlename_lastname" <final_user_firstname_middlename_lastname@test.com>\r\n',
218
- )
219
- buffer +=
220
- ' "Final User_firstname_middlename_lastname" <final_user_firstname_middlename_lastname@test.com>\r\n'
221
- self.transaction.add_data('Message-ID: <Boundary_Marker_Test>\r\n')
222
- buffer += 'Message-ID: <Boundary_Marker_Test>\r\n'
223
- self.transaction.add_data('MIME-Version: 1.0\r\n')
224
- buffer += 'MIME-Version: 1.0\r\n'
225
- self.transaction.add_data('Date: Wed, 1 Jun 2022 16:44:39 +0530 (IST)\r\n')
226
- buffer += 'Date: Wed, 1 Jun 2022 16:44:39 +0530 (IST)\r\n'
227
- self.transaction.add_data('\r\n')
228
- buffer += '\r\n'
229
- self.transaction.add_data('--abcd\r\n')
230
- buffer += '--abcd\r\n'
231
- ;[
305
+ const last = ' "Final_User_fn_mn_ln" <final_user_fn_mn_ln@test.com>\r\n'
306
+ this.transaction.add_data(last)
307
+ buf += last
308
+ this.transaction.add_data('Message-ID: <Boundary_Marker_Test>\r\n')
309
+ buf += 'Message-ID: <Boundary_Marker_Test>\r\n'
310
+ this.transaction.add_data('MIME-Version: 1.0\r\n')
311
+ buf += 'MIME-Version: 1.0\r\n'
312
+ this.transaction.add_data('Date: Wed, 1 Jun 2022 16:44:39 +0530\r\n')
313
+ buf += 'Date: Wed, 1 Jun 2022 16:44:39 +0530\r\n'
314
+ this.transaction.add_data('\r\n')
315
+ buf += '\r\n'
316
+ this.transaction.add_data('--abcd\r\n')
317
+ buf += '--abcd\r\n'
318
+
319
+ const rest = [
232
320
  'Content-Type: text/plain\r\n',
233
321
  '\r\n',
234
322
  'Text part\r\n',
@@ -237,33 +325,230 @@ describe('transaction', () => {
237
325
  '\r\n',
238
326
  '<p>HTML part</p>\r\n',
239
327
  '--abcd--\r\n',
240
- ].forEach((line) => {
241
- self.transaction.add_data(line)
242
- buffer += line
328
+ ]
329
+ for (const line of rest) {
330
+ this.transaction.add_data(line)
331
+ buf += line
332
+ }
333
+
334
+ await endData(this.transaction)
335
+ const body = await getData(this.transaction.message_stream)
336
+ assert.ok(body.includes(Buffer.from(buf)), 'message not damaged')
337
+ })
338
+ })
339
+
340
+ describe('remove_final_cr', () => {
341
+ const cases = [
342
+ { desc: 'empty buffer', input: '', expected: '' },
343
+ { desc: 'single byte', input: 'a', expected: 'a' },
344
+ { desc: 'CRLF ending stripped to LF', input: 'hello\r\n', expected: 'hello\n' },
345
+ { desc: 'LF-only ending unchanged', input: 'hello\n', expected: 'hello\n' },
346
+ { desc: 'no newline unchanged', input: 'hello', expected: 'hello' },
347
+ { desc: 'string input', input: 'hello\r\n', expected: 'hello\n' },
348
+ ]
349
+
350
+ for (const { desc, input, expected } of cases) {
351
+ it(desc, () => {
352
+ const result = this.transaction.remove_final_cr(Buffer.from(input))
353
+ assert.equal(result.toString(), expected)
243
354
  })
355
+ }
356
+ })
357
+
358
+ describe('add_dot_stuffing_and_ensure_crlf_newlines', () => {
359
+ const cases = [
360
+ { desc: 'empty string', input: '', expected: '' },
361
+ { desc: 'no dots or newlines', input: 'hello world', expected: 'hello world' },
362
+ { desc: 'bare LF becomes CRLF', input: 'hello\n', expected: 'hello\r\n' },
363
+ { desc: 'CRLF preserved', input: 'hello\r\n', expected: 'hello\r\n' },
364
+ { desc: 'leading dot stuffed', input: '.hello\n', expected: '..hello\r\n' },
365
+ { desc: 'mid-line dot not stuffed', input: 'hel.lo\n', expected: 'hel.lo\r\n' },
366
+ { desc: 'multi-line with leading dots', input: 'a\n.b\n', expected: 'a\r\n..b\r\n' },
367
+ { desc: 'dot after CRLF stuffed', input: 'a\r\n.b\n', expected: 'a\r\n..b\r\n' },
368
+ ]
244
369
 
245
- this.transaction.end_data(function () {
246
- self.transaction.message_stream.get_data(function (body) {
247
- assert.ok(body.includes(buffer), 'message is damaged')
248
- done()
249
- })
370
+ for (const { desc, input, expected } of cases) {
371
+ it(desc, () => {
372
+ const result = this.transaction.add_dot_stuffing_and_ensure_crlf_newlines(Buffer.from(input))
373
+ assert.equal(result.toString(), expected)
250
374
  })
375
+ }
376
+ })
377
+
378
+ describe('header manipulation (post-data)', () => {
379
+ it('add_header appends a header', async () => {
380
+ addLines(this.transaction, ['Subject: original\n', '\n', 'body\n'])
381
+ await endData(this.transaction)
382
+ this.transaction.add_header('X-Test', 'added')
383
+ assert.deepEqual(this.transaction.header.get_all('X-Test'), ['added'])
384
+ })
385
+
386
+ it('add_leading_header prepends a header', async () => {
387
+ addLines(this.transaction, ['Subject: original\n', '\n', 'body\n'])
388
+ await endData(this.transaction)
389
+ this.transaction.add_leading_header('X-Lead', 'first')
390
+ assert.deepEqual(this.transaction.header.get_all('X-Lead'), ['first'])
391
+ })
392
+
393
+ it('remove_header removes a header', async () => {
394
+ addLines(this.transaction, ['X-Remove: gone\n', '\n', 'body\n'])
395
+ await endData(this.transaction)
396
+ this.transaction.remove_header('X-Remove')
397
+ assert.equal(this.transaction.header.get_all('X-Remove').length, 0)
398
+ })
399
+
400
+ it('add_header appears in message stream output', async () => {
401
+ addLines(this.transaction, ['Subject: original\n', '\n', 'body\n'])
402
+ await endData(this.transaction)
403
+ this.transaction.add_header('X-Added', 'yes')
404
+ const output = (await getData(this.transaction.message_stream)).toString()
405
+ assert.ok(output.includes('X-Added: yes'), 'added header in output')
406
+ })
407
+
408
+ it('remove_header absent from message stream output', async () => {
409
+ // Keep Subject so header_list stays non-empty after removal; the
410
+ // ctor-headers path then replaces raw headers, omitting X-Remove.
411
+ addLines(this.transaction, ['Subject: Keep\n', 'X-Remove: gone\n', '\n', 'body\n'])
412
+ await endData(this.transaction)
413
+ this.transaction.remove_header('X-Remove')
414
+ const output = (await getData(this.transaction.message_stream)).toString()
415
+ assert.ok(output.includes('Subject: Keep'), 'non-removed header present')
416
+ assert.ok(!output.includes('X-Remove'), 'removed header not in output')
417
+ })
418
+
419
+ it('folded continuation headers are merged into header_list', async () => {
420
+ addLines(this.transaction, [
421
+ 'Subject: This is a very long\n',
422
+ ' subject line\n',
423
+ 'From: foo@example.com\n',
424
+ '\n',
425
+ 'body\n',
426
+ ])
427
+ await endData(this.transaction)
428
+ assert.ok(this.transaction.header.get('Subject').includes('long'), 'folded subject parsed')
429
+ assert.ok(this.transaction.header.get('From').includes('foo@example.com'), 'From parsed')
251
430
  })
252
431
  })
253
- })
254
432
 
255
- function write_file_data_to_transaction(test_transaction, filename) {
256
- const specimen = fs.readFileSync(filename, 'utf8')
257
- const matcher = /[^\n]*([\n]|$)/g
433
+ describe('pre-data header modifications (e.g. hook_mail / hook_rcpt)', () => {
434
+ it('add_header before data preserves all email headers', async () => {
435
+ // Simulates record_envelope_addresses which calls add_header in hook_mail/hook_rcpt
436
+ // before DATA is received. Must not corrupt header_pos.
437
+ this.transaction.add_header('X-Envelope-From', 'sender@example.com')
438
+ this.transaction.add_header('X-Envelope-To', 'rcpt@example.com')
258
439
 
259
- let line
260
- do {
261
- line = matcher.exec(specimen)
262
- if (line[0] == '') {
263
- break
264
- }
265
- test_transaction.add_data(line[0])
266
- } while (line[0] != '')
440
+ addLines(this.transaction, ['Subject: Test\r\n', 'From: sender@example.com\r\n', '\r\n', 'Body line 1\r\n'])
441
+ await endData(this.transaction)
267
442
 
268
- test_transaction.end_data()
269
- }
443
+ const str = (await getData(this.transaction.message_stream)).toString()
444
+ assert.ok(str.includes('Subject: Test'), 'Subject preserved')
445
+ assert.ok(str.includes('From: sender@example.com'), 'From preserved')
446
+ assert.ok(str.includes('X-Envelope-From: sender@example.com'), 'pre-data header present')
447
+ assert.ok(str.includes('X-Envelope-To: rcpt@example.com'), 'pre-data header present')
448
+ assert.ok(str.includes('Body line 1'), 'body present')
449
+ })
450
+
451
+ it('add_leading_header before data does not corrupt header_pos', async () => {
452
+ this.transaction.add_leading_header('X-Early', 'value')
453
+
454
+ addLines(this.transaction, ['Subject: Check\r\n', '\r\n', 'body\r\n'])
455
+ await endData(this.transaction)
456
+
457
+ const str = (await getData(this.transaction.message_stream)).toString()
458
+ assert.ok(str.includes('Subject: Check'), 'Subject preserved after add_leading_header')
459
+ assert.ok(str.includes('X-Early: value'), 'pre-data leading header present')
460
+ })
461
+
462
+ it('remove_header before data does not corrupt header_pos', async () => {
463
+ // Calling remove_header before data arrives should be a no-op for header_pos
464
+ this.transaction.remove_header('X-Nonexistent')
465
+
466
+ addLines(this.transaction, ['Subject: Check\r\n', '\r\n', 'body\r\n'])
467
+ await endData(this.transaction)
468
+
469
+ const str = (await getData(this.transaction.message_stream)).toString()
470
+ assert.ok(str.includes('Subject: Check'), 'Subject preserved after pre-data remove_header')
471
+ })
472
+ })
473
+
474
+ describe('late header additions (post end_data)', () => {
475
+ it('late add_header to busted email appears before body', async () => {
476
+ addLines(this.transaction, ['Subject: Test\r\n', 'From: user@example.com\r\n', 'Body line 1\r\n'])
477
+ await endData(this.transaction)
478
+ this.transaction.add_header('X-Late', 'true')
479
+
480
+ const str = (await getData(this.transaction.message_stream)).toString()
481
+ assert.ok(str.includes('X-Late: true'), 'late header present')
482
+ assert.ok(str.indexOf('X-Late: true') < str.indexOf('Body line 1'), 'late header before body')
483
+ })
484
+
485
+ it('late add_header to clean email appears before body', async () => {
486
+ addLines(this.transaction, ['Subject: Clean\r\n', '\r\n', 'Body line 1\r\n'])
487
+ await endData(this.transaction)
488
+ this.transaction.add_header('X-Late', 'true')
489
+
490
+ const str = (await getData(this.transaction.message_stream)).toString()
491
+ assert.ok(str.includes('X-Late: true'), 'late header present')
492
+ assert.ok(str.indexOf('X-Late: true') < str.indexOf('Body line 1'), 'late header before body')
493
+ })
494
+ })
495
+
496
+ describe('incr_mime_count', () => {
497
+ it('increments mime_part_count', () => {
498
+ assert.equal(this.transaction.mime_part_count, 0)
499
+ this.transaction.incr_mime_count()
500
+ assert.equal(this.transaction.mime_part_count, 1)
501
+ this.transaction.incr_mime_count()
502
+ assert.equal(this.transaction.mime_part_count, 2)
503
+ })
504
+ })
505
+
506
+ describe('discard_data', () => {
507
+ it('end_data calls callback even when discard_data is true', async () => {
508
+ this.transaction.discard_data = true
509
+ addLines(this.transaction, ['Subject: test\n', '\n', 'body\n'])
510
+ await endData(this.transaction) // resolves → callback was called
511
+ })
512
+
513
+ it('discard_data with broken email (no separator) calls callback', async () => {
514
+ this.transaction.discard_data = true
515
+ addLines(this.transaction, ['Subject: test\n', 'From: a@b.com\n', 'Body\n'])
516
+ await endData(this.transaction)
517
+ })
518
+ })
519
+
520
+ describe('busted email (no header/body separator)', () => {
521
+ it('headers and body are extracted when separator is missing', async () => {
522
+ addLines(this.transaction, ['Subject: test\n', 'From: a@b.com\n', 'Body line 1\n'])
523
+ await endData(this.transaction)
524
+
525
+ assert.equal(this.transaction.header.get('Subject').trim(), 'test')
526
+ assert.equal(this.transaction.header.get('From').trim(), 'a@b.com')
527
+
528
+ const str = (await getData(this.transaction.message_stream)).toString()
529
+ assert.ok(str.includes('Subject: test'), 'Subject in output')
530
+ assert.ok(str.includes('Body line 1'), 'Body in output')
531
+ })
532
+
533
+ it('late add_header to busted email ends up before body in output', async () => {
534
+ addLines(this.transaction, ['Subject: Test\r\n', 'From: user@example.com\r\n', 'Body line 1\r\n'])
535
+ await endData(this.transaction)
536
+ this.transaction.add_header('X-Late', 'true')
537
+
538
+ const str = (await getData(this.transaction.message_stream)).toString()
539
+ assert.ok(str.includes('X-Late: true'), 'late header present')
540
+ assert.ok(str.indexOf('X-Late: true') < str.indexOf('Body line 1'), 'late header before body')
541
+ })
542
+ })
543
+
544
+ describe('parse_body enabled after separator', () => {
545
+ it('does not throw when parse_body set true after separator seen', async () => {
546
+ this.transaction.add_data('Subject: test\n')
547
+ this.transaction.add_data('\n')
548
+ this.transaction.parse_body = true
549
+ assert.doesNotThrow(() => this.transaction.add_data('body line\n'))
550
+ await endData(this.transaction)
551
+ assert.ok(this.transaction.body, 'body was lazily created')
552
+ })
553
+ })
554
+ })