haraka 0.0.33 → 3.3.1

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 (254) hide show
  1. package/.githooks/pre-commit +41 -0
  2. package/.prettierignore +7 -0
  3. package/.qlty/.gitignore +7 -0
  4. package/.qlty/configs/.shellcheckrc +1 -0
  5. package/.qlty/qlty.toml +15 -0
  6. package/CHANGELOG.md +1898 -0
  7. package/CONTRIBUTORS.md +34 -0
  8. package/Dockerfile +50 -0
  9. package/LICENSE +22 -0
  10. package/Plugins.md +227 -0
  11. package/README.md +119 -4
  12. package/SECURITY.md +178 -0
  13. package/TODO +22 -0
  14. package/bin/haraka +593 -0
  15. package/bin/haraka_grep +32 -0
  16. package/config/aliases +2 -0
  17. package/config/auth_flat_file.ini +7 -0
  18. package/config/auth_vpopmaild.ini +9 -0
  19. package/config/connection.ini +79 -0
  20. package/config/delay_deny.ini +7 -0
  21. package/config/host_list +3 -0
  22. package/config/host_list_regex +6 -0
  23. package/config/http.ini +11 -0
  24. package/config/lmtp.ini +7 -0
  25. package/config/log.ini +11 -0
  26. package/config/outbound.bounce_message +18 -0
  27. package/config/outbound.bounce_message_html +36 -0
  28. package/config/outbound.bounce_message_image +106 -0
  29. package/config/outbound.ini +24 -0
  30. package/config/plugins +67 -0
  31. package/config/smtp.ini +37 -0
  32. package/config/smtp_bridge.ini +4 -0
  33. package/config/smtp_forward.ini +31 -0
  34. package/config/smtp_proxy.ini +27 -0
  35. package/config/tarpit.timeout +1 -0
  36. package/config/tls.ini +83 -0
  37. package/config/watch.ini +12 -0
  38. package/config/xclient.hosts +2 -0
  39. package/connection.js +1865 -0
  40. package/contrib/Haraka.cf +6 -0
  41. package/contrib/Haraka.pm +35 -0
  42. package/contrib/bad_smtp_server.pl +25 -0
  43. package/contrib/bsd-rc.d/haraka +63 -0
  44. package/contrib/debian-init.d/haraka +87 -0
  45. package/contrib/haraka.init +96 -0
  46. package/contrib/haraka.service +23 -0
  47. package/contrib/plugin2npm.sh +81 -0
  48. package/contrib/ubuntu-upstart/haraka.conf +27 -0
  49. package/docs/Body.md +1 -0
  50. package/docs/Config.md +1 -0
  51. package/docs/Connection.md +153 -0
  52. package/docs/CoreConfig.md +96 -0
  53. package/docs/CustomReturnCodes.md +3 -0
  54. package/docs/HAProxy.md +62 -0
  55. package/docs/Header.md +1 -0
  56. package/docs/Logging.md +129 -0
  57. package/docs/Outbound.md +210 -0
  58. package/docs/Plugins.md +372 -0
  59. package/docs/Results.md +7 -0
  60. package/docs/Transaction.md +135 -0
  61. package/docs/Tutorial.md +183 -0
  62. package/docs/deprecated/access.md +3 -0
  63. package/docs/deprecated/backscatterer.md +9 -0
  64. package/docs/deprecated/connect.rdns_access.md +53 -0
  65. package/docs/deprecated/data.headers.md +3 -0
  66. package/docs/deprecated/data.nomsgid.md +7 -0
  67. package/docs/deprecated/data.noreceived.md +11 -0
  68. package/docs/deprecated/data.rfc5322_header_checks.md +11 -0
  69. package/docs/deprecated/dkim_sign.md +97 -0
  70. package/docs/deprecated/dkim_verify.md +28 -0
  71. package/docs/deprecated/dnsbl.md +80 -0
  72. package/docs/deprecated/dnswl.md +73 -0
  73. package/docs/deprecated/lookup_rdns.strict.md +67 -0
  74. package/docs/deprecated/mail_from.access.md +52 -0
  75. package/docs/deprecated/mail_from.blocklist.md +18 -0
  76. package/docs/deprecated/mail_from.nobounces.md +8 -0
  77. package/docs/deprecated/rcpt_to.access.md +53 -0
  78. package/docs/deprecated/rcpt_to.blocklist.md +18 -0
  79. package/docs/deprecated/rcpt_to.routes.md +3 -0
  80. package/docs/deprecated/rdns.regexp.md +30 -0
  81. package/docs/plugins/aliases.md +3 -0
  82. package/docs/plugins/auth/auth_bridge.md +34 -0
  83. package/docs/plugins/auth/auth_ldap.md +4 -0
  84. package/docs/plugins/auth/auth_proxy.md +36 -0
  85. package/docs/plugins/auth/auth_vpopmaild.md +33 -0
  86. package/docs/plugins/auth/flat_file.md +40 -0
  87. package/docs/plugins/block_me.md +18 -0
  88. package/docs/plugins/data.signatures.md +11 -0
  89. package/docs/plugins/delay_deny.md +23 -0
  90. package/docs/plugins/max_unrecognized_commands.md +6 -0
  91. package/docs/plugins/prevent_credential_leaks.md +22 -0
  92. package/docs/plugins/process_title.md +42 -0
  93. package/docs/plugins/queue/deliver.md +3 -0
  94. package/docs/plugins/queue/discard.md +32 -0
  95. package/docs/plugins/queue/lmtp.md +24 -0
  96. package/docs/plugins/queue/qmail-queue.md +16 -0
  97. package/docs/plugins/queue/quarantine.md +87 -0
  98. package/docs/plugins/queue/smtp_bridge.md +32 -0
  99. package/docs/plugins/queue/smtp_forward.md +127 -0
  100. package/docs/plugins/queue/smtp_proxy.md +68 -0
  101. package/docs/plugins/queue/test.md +7 -0
  102. package/docs/plugins/rcpt_to.in_host_list.md +34 -0
  103. package/docs/plugins/rcpt_to.max_count.md +3 -0
  104. package/docs/plugins/record_envelope_addresses.md +20 -0
  105. package/docs/plugins/relay.md +3 -0
  106. package/docs/plugins/reseed_rng.md +16 -0
  107. package/docs/plugins/status.md +41 -0
  108. package/docs/plugins/tarpit.md +50 -0
  109. package/docs/plugins/tls.md +235 -0
  110. package/docs/plugins/toobusy.md +27 -0
  111. package/docs/plugins/xclient.md +10 -0
  112. package/docs/tutorials/Migrating_from_v1_to_v2.md +96 -0
  113. package/docs/tutorials/SettingUpOutbound.md +62 -0
  114. package/eslint.config.mjs +2 -0
  115. package/haraka.js +74 -0
  116. package/haraka.sh +2 -0
  117. package/http/html/404.html +58 -0
  118. package/http/html/index.html +47 -0
  119. package/http/package.json +21 -0
  120. package/line_socket.js +24 -0
  121. package/logger.js +322 -0
  122. package/outbound/client_pool.js +59 -0
  123. package/outbound/config.js +134 -0
  124. package/outbound/hmail.js +1504 -0
  125. package/outbound/index.js +349 -0
  126. package/outbound/qfile.js +93 -0
  127. package/outbound/queue.js +399 -0
  128. package/outbound/tls.js +85 -0
  129. package/outbound/todo.js +17 -0
  130. package/package.json +100 -4
  131. package/plugins/.eslintrc.yaml +3 -0
  132. package/plugins/auth/auth_base.js +261 -0
  133. package/plugins/auth/auth_bridge.js +20 -0
  134. package/plugins/auth/auth_proxy.js +227 -0
  135. package/plugins/auth/auth_vpopmaild.js +162 -0
  136. package/plugins/auth/flat_file.js +44 -0
  137. package/plugins/block_me.js +88 -0
  138. package/plugins/data.signatures.js +30 -0
  139. package/plugins/delay_deny.js +153 -0
  140. package/plugins/prevent_credential_leaks.js +61 -0
  141. package/plugins/process_title.js +197 -0
  142. package/plugins/profile.js +11 -0
  143. package/plugins/queue/deliver.js +12 -0
  144. package/plugins/queue/discard.js +27 -0
  145. package/plugins/queue/lmtp.js +45 -0
  146. package/plugins/queue/qmail-queue.js +93 -0
  147. package/plugins/queue/quarantine.js +133 -0
  148. package/plugins/queue/smtp_bridge.js +45 -0
  149. package/plugins/queue/smtp_forward.js +371 -0
  150. package/plugins/queue/smtp_proxy.js +142 -0
  151. package/plugins/queue/test.js +15 -0
  152. package/plugins/rcpt_to.host_list_base.js +65 -0
  153. package/plugins/rcpt_to.in_host_list.js +56 -0
  154. package/plugins/record_envelope_addresses.js +17 -0
  155. package/plugins/reseed_rng.js +7 -0
  156. package/plugins/status.js +274 -0
  157. package/plugins/tarpit.js +45 -0
  158. package/plugins/tls.js +164 -0
  159. package/plugins/toobusy.js +47 -0
  160. package/plugins/xclient.js +124 -0
  161. package/plugins.js +605 -0
  162. package/run_tests +11 -0
  163. package/server.js +827 -0
  164. package/smtp_client.js +504 -0
  165. package/test/.eslintrc.yaml +11 -0
  166. package/test/config/auth_flat_file.ini +5 -0
  167. package/test/config/block_me.recipient +1 -0
  168. package/test/config/block_me.senders +1 -0
  169. package/test/config/dhparams.pem +8 -0
  170. package/test/config/host_list +2 -0
  171. package/test/config/outbound_tls_cert.pem +1 -0
  172. package/test/config/outbound_tls_key.pem +1 -0
  173. package/test/config/plugins +7 -0
  174. package/test/config/smtp.ini +11 -0
  175. package/test/config/smtp_forward.ini +30 -0
  176. package/test/config/tls/example.com/_.example.com.key +28 -0
  177. package/test/config/tls/example.com/example.com.crt +25 -0
  178. package/test/config/tls/haraka.local.pem +51 -0
  179. package/test/config/tls.ini +45 -0
  180. package/test/config/tls_cert.pem +21 -0
  181. package/test/config/tls_key.pem +28 -0
  182. package/test/connection.js +820 -0
  183. package/test/fixtures/haproxy_allowed/config/connection.ini +3 -0
  184. package/test/fixtures/haproxy_disabled/config/connection.ini +3 -0
  185. package/test/fixtures/haproxy_untrusted/config/connection.ini +3 -0
  186. package/test/fixtures/line_socket.js +21 -0
  187. package/test/fixtures/todo_qfile.txt +0 -0
  188. package/test/fixtures/util_hmailitem.js +156 -0
  189. package/test/installation/config/test-plugin-flat +1 -0
  190. package/test/installation/config/test-plugin.ini +10 -0
  191. package/test/installation/config/tls.ini +1 -0
  192. package/test/installation/node_modules/load_first/index.js +5 -0
  193. package/test/installation/node_modules/load_first/package.json +11 -0
  194. package/test/installation/node_modules/test-plugin/config/test-plugin-flat +1 -0
  195. package/test/installation/node_modules/test-plugin/config/test-plugin.ini +9 -0
  196. package/test/installation/node_modules/test-plugin/package.json +5 -0
  197. package/test/installation/node_modules/test-plugin/test-plugin.js +5 -0
  198. package/test/installation/plugins/base_plugin.js +3 -0
  199. package/test/installation/plugins/folder_plugin/index.js +3 -0
  200. package/test/installation/plugins/folder_plugin/package.json +11 -0
  201. package/test/installation/plugins/inherits.js +7 -0
  202. package/test/installation/plugins/load_first.js +3 -0
  203. package/test/installation/plugins/plugin.js +1 -0
  204. package/test/installation/plugins/tls.js +3 -0
  205. package/test/logger.js +217 -0
  206. package/test/loud/config/dhparams.pem +0 -0
  207. package/test/loud/config/tls/goobered.pem +45 -0
  208. package/test/loud/config/tls.ini +43 -0
  209. package/test/mail_specimen/base64-root-part.txt +23 -0
  210. package/test/mail_specimen/varied-fold-lengths-preserve-data.txt +283 -0
  211. package/test/outbound/bounce_net_errors.js +133 -0
  212. package/test/outbound/bounce_rfc3464.js +226 -0
  213. package/test/outbound/hmail.js +210 -0
  214. package/test/outbound/index.js +385 -0
  215. package/test/outbound/qfile.js +124 -0
  216. package/test/outbound/queue.js +325 -0
  217. package/test/plugins/auth/auth_base.js +620 -0
  218. package/test/plugins/auth/auth_bridge.js +80 -0
  219. package/test/plugins/auth/auth_vpopmaild.js +81 -0
  220. package/test/plugins/auth/flat_file.js +123 -0
  221. package/test/plugins/block_me.js +141 -0
  222. package/test/plugins/data.signatures.js +111 -0
  223. package/test/plugins/delay_deny.js +262 -0
  224. package/test/plugins/prevent_credential_leaks.js +174 -0
  225. package/test/plugins/process_title.js +141 -0
  226. package/test/plugins/queue/deliver.js +98 -0
  227. package/test/plugins/queue/discard.js +78 -0
  228. package/test/plugins/queue/lmtp.js +137 -0
  229. package/test/plugins/queue/qmail-queue.js +98 -0
  230. package/test/plugins/queue/quarantine.js +80 -0
  231. package/test/plugins/queue/smtp_bridge.js +152 -0
  232. package/test/plugins/queue/smtp_forward.js +1023 -0
  233. package/test/plugins/queue/smtp_proxy.js +138 -0
  234. package/test/plugins/rcpt_to.host_list_base.js +102 -0
  235. package/test/plugins/rcpt_to.in_host_list.js +186 -0
  236. package/test/plugins/record_envelope_addresses.js +66 -0
  237. package/test/plugins/reseed_rng.js +34 -0
  238. package/test/plugins/status.js +207 -0
  239. package/test/plugins/tarpit.js +90 -0
  240. package/test/plugins/tls.js +86 -0
  241. package/test/plugins/toobusy.js +198 -0
  242. package/test/plugins/xclient.js +119 -0
  243. package/test/plugins.js +230 -0
  244. package/test/queue/1507509981169_1507509981169_0_61403_e0Y0Ym_1_fixed +0 -0
  245. package/test/queue/1507509981169_1507509981169_0_61403_e0Y0Ym_1_haraka +0 -0
  246. package/test/queue/1508269674999_1508269674999_0_34002_socVUF_1_haraka +0 -0
  247. package/test/queue/1508455115683_1508455115683_0_90253_9Q4o4V_1_haraka +0 -0
  248. package/test/queue/zero-length +0 -0
  249. package/test/server.js +1012 -0
  250. package/test/smtp_client.js +1303 -0
  251. package/test/tls_socket.js +321 -0
  252. package/test/transaction.js +554 -0
  253. package/tls_socket.js +771 -0
  254. package/transaction.js +267 -0
@@ -0,0 +1,554 @@
1
+ 'use strict'
2
+
3
+ const { describe, it, beforeEach } = require('node:test')
4
+ const assert = require('node:assert')
5
+ const fs = require('node:fs')
6
+ const path = require('node:path')
7
+
8
+ const config = require('haraka-config')
9
+ const transaction = require('../transaction')
10
+
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 = () => {
17
+ this.transaction = transaction.createTransaction(undefined, config.get('smtp.ini'))
18
+ }
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
+
38
+ describe('transaction', () => {
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')
46
+ })
47
+
48
+ it('uses the provided UUID', () => {
49
+ const txn = transaction.createTransaction('TEST-UUID')
50
+ assert.equal(txn.uuid, 'TEST-UUID')
51
+ })
52
+
53
+ it('initialises header_pos to 0', () => {
54
+ assert.equal(this.transaction.header_pos, 0)
55
+ })
56
+
57
+ it('initialises found_hb_sep to false', () => {
58
+ assert.equal(this.transaction.found_hb_sep, false)
59
+ })
60
+ })
61
+
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
+ })
88
+
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')
94
+
95
+ let filter_called = false
96
+ this.transaction.add_body_filter('text/plain', (ct, enc, buf) => {
97
+ filter_called = true
98
+ return buf
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')
121
+ })
122
+
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
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')
141
+ })
142
+ })
143
+
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
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')
163
+ })
164
+ })
165
+
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
+ })
174
+
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 = []
252
+ this.transaction.parse_body = true
253
+ this.transaction.attachment_hooks((ct, filename, body, stream) => {
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
+ )
266
+ })
267
+
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)
271
+
272
+ assert.equal(this.transaction.body.children.length, 6)
273
+
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`)
278
+ }
279
+ })
280
+
281
+ it('base64 root HTML decodes correct byte count', () => {
282
+ this.transaction.parse_body = true
283
+ const specimen = path.join(__dirname, 'mail_specimen', 'base64-root-part.txt')
284
+ write_file_data_to_transaction(this.transaction, specimen)
285
+ assert.equal(this.transaction.body.bodytext.length, 425)
286
+ })
287
+ })
288
+
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(
295
+ 'To: "User1_firstname_middlename_lastname" <user1_firstname_middlename_lastname@test.com>,\r\n',
296
+ )
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
300
+ for (let i = 0; i < 725; i++) {
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
304
+ }
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 = [
320
+ 'Content-Type: text/plain\r\n',
321
+ '\r\n',
322
+ 'Text part\r\n',
323
+ '--abcd\r\n',
324
+ 'Content-Type: text/html\r\n',
325
+ '\r\n',
326
+ '<p>HTML part</p>\r\n',
327
+ '--abcd--\r\n',
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)
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
+ ]
369
+
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)
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')
430
+ })
431
+ })
432
+
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')
439
+
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)
442
+
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
+ })