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.
- package/.prettierignore +2 -0
- package/CONTRIBUTORS.md +23 -1
- package/Changes.md +38 -0
- package/Plugins.md +81 -64
- package/README.md +1 -1
- package/bin/haraka +7 -5
- package/connection.js +15 -19
- package/docs/Plugins.md +1 -1
- package/docs/plugins/aliases.md +0 -2
- package/docs/plugins/queue/qmail-queue.md +0 -1
- package/logger.js +2 -2
- package/outbound/hmail.js +76 -83
- package/outbound/index.js +36 -34
- package/outbound/queue.js +231 -176
- package/package.json +25 -26
- package/plugins/prevent_credential_leaks.js +2 -2
- package/plugins/process_title.js +1 -1
- package/plugins/queue/smtp_forward.js +1 -1
- package/plugins/status.js +8 -5
- package/plugins/tls.js +1 -1
- package/plugins.js +19 -14
- package/rfc1869.js +10 -10
- package/run_tests +20 -2
- package/server.js +15 -10
- package/smtp_client.js +2 -9
- package/test/config/tls/haraka.local.pem +47 -47
- package/test/connection.js +286 -147
- package/test/fixtures/line_socket.js +1 -0
- package/test/fixtures/util_hmailitem.js +1 -1
- package/test/outbound/bounce_net_errors.js +176 -0
- package/test/outbound/bounce_rfc3464.js +303 -0
- package/test/outbound/hmail.js +140 -104
- package/test/outbound/index.js +61 -101
- package/test/outbound/qfile.js +25 -25
- package/test/outbound/queue.js +233 -0
- package/test/plugins/queue/smtp_forward.js +1 -1
- package/test/plugins/record_envelope_addresses.js +93 -0
- package/test/plugins/tls.js +2 -2
- package/test/plugins/xclient.js +137 -0
- package/test/rfc1869.js +43 -0
- package/test/smtp_client.js +6 -6
- package/test/transaction.js +486 -201
- package/tls_socket.js +3 -3
- package/transaction.js +33 -10
- package/config/rabbitmq.ini +0 -10
- package/config/rabbitmq_amqplib.ini +0 -19
- package/docs/plugins/queue/rabbitmq.md +0 -34
- package/docs/plugins/queue/rabbitmq_amqplib.md +0 -51
- package/plugins/queue/rabbitmq.js +0 -141
- package/plugins/queue/rabbitmq_amqplib.js +0 -96
- package/test/config/tls/ec.pem +0 -23
- package/test/config/tls/mismatched.pem +0 -49
- package/test/outbound_bounce_net_errors.js +0 -157
- package/test/outbound_bounce_rfc3464.js +0 -366
- package/test/tls_socket.js +0 -273
package/test/transaction.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
52
|
-
|
|
52
|
+
|
|
53
|
+
it('initialises header_pos to 0', () => {
|
|
54
|
+
assert.equal(this.transaction.header_pos, 0)
|
|
53
55
|
})
|
|
54
56
|
|
|
55
|
-
|
|
56
|
-
this.transaction.
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
80
|
-
this.transaction.
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
'
|
|
120
|
-
|
|
121
|
-
'
|
|
122
|
-
|
|
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('
|
|
146
|
-
|
|
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('
|
|
149
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
163
|
-
write_file_data_to_transaction(this.transaction,
|
|
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
|
|
168
|
-
for (const
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
281
|
+
it('base64 root HTML decodes correct byte count', () => {
|
|
183
282
|
this.transaction.parse_body = true
|
|
184
|
-
const
|
|
185
|
-
write_file_data_to_transaction(this.transaction,
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
]
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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
|
+
})
|