haraka 0.0.32 → 3.3.0

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 (310) hide show
  1. package/.claude/settings.local.json +28 -0
  2. package/.githooks/pre-commit +41 -0
  3. package/.prettierignore +6 -0
  4. package/.qlty/.gitignore +7 -0
  5. package/.qlty/configs/.shellcheckrc +1 -0
  6. package/.qlty/qlty.toml +15 -0
  7. package/CHANGELOG.md +1872 -62
  8. package/CLAUDE.md +40 -0
  9. package/CONTRIBUTORS.md +34 -0
  10. package/Dockerfile +50 -0
  11. package/GEMINI.md +38 -0
  12. package/LICENSE +2 -1
  13. package/Plugins.md +227 -0
  14. package/README.md +100 -115
  15. package/SECURITY.md +178 -0
  16. package/TODO +22 -0
  17. package/address.js +53 -0
  18. package/bin/haraka +593 -0
  19. package/bin/haraka_grep +32 -0
  20. package/config/aliases +2 -0
  21. package/config/auth_flat_file.ini +7 -0
  22. package/config/auth_vpopmaild.ini +9 -0
  23. package/config/connection.ini +79 -0
  24. package/config/delay_deny.ini +7 -0
  25. package/config/dhparams.pem +8 -0
  26. package/config/host_list +3 -0
  27. package/config/host_list_regex +6 -0
  28. package/config/http.ini +11 -0
  29. package/config/lmtp.ini +7 -0
  30. package/config/log.ini +11 -0
  31. package/config/me +1 -0
  32. package/config/outbound.bounce_message +18 -0
  33. package/config/outbound.bounce_message_html +36 -0
  34. package/config/outbound.bounce_message_image +106 -0
  35. package/config/outbound.ini +24 -0
  36. package/config/plugins +67 -0
  37. package/config/smtp.ini +37 -0
  38. package/config/smtp_bridge.ini +4 -0
  39. package/config/smtp_forward.ini +31 -0
  40. package/config/smtp_proxy.ini +27 -0
  41. package/config/tarpit.timeout +1 -0
  42. package/config/tls.ini +83 -0
  43. package/config/tls_cert.pem +23 -0
  44. package/config/tls_key.pem +28 -0
  45. package/config/watch.ini +12 -0
  46. package/config/xclient.hosts +2 -0
  47. package/connection.js +1863 -0
  48. package/contrib/Haraka.cf +6 -0
  49. package/contrib/Haraka.pm +35 -0
  50. package/contrib/bad_smtp_server.pl +25 -0
  51. package/contrib/bsd-rc.d/haraka +61 -0
  52. package/contrib/debian-init.d/haraka +87 -0
  53. package/contrib/haraka.init +96 -0
  54. package/contrib/haraka.service +23 -0
  55. package/contrib/plugin2npm.sh +81 -0
  56. package/contrib/ubuntu-upstart/haraka.conf +27 -0
  57. package/coverage/coverage-final.json +2 -0
  58. package/coverage/coverage-summary.json +33 -0
  59. package/coverage/tmp/coverage-79131-1779241025146-0.json +1 -0
  60. package/coverage/tmp/coverage-79132-1779240999690-0.json +1 -0
  61. package/coverage/tmp/coverage-79172-1779241000095-0.json +1 -0
  62. package/coverage/tmp/coverage-79210-1779241000156-0.json +1 -0
  63. package/coverage/tmp/coverage-79211-1779241000209-0.json +1 -0
  64. package/coverage/tmp/coverage-79212-1779241000266-0.json +1 -0
  65. package/coverage/tmp/coverage-79213-1779241000441-0.json +1 -0
  66. package/coverage/tmp/coverage-79214-1779241000626-0.json +1 -0
  67. package/coverage/tmp/coverage-79215-1779241000795-0.json +1 -0
  68. package/coverage/tmp/coverage-79216-1779241000965-0.json +1 -0
  69. package/coverage/tmp/coverage-79218-1779241001013-0.json +1 -0
  70. package/coverage/tmp/coverage-79219-1779241001179-0.json +1 -0
  71. package/coverage/tmp/coverage-79220-1779241006249-0.json +1 -0
  72. package/coverage/tmp/coverage-79227-1779241011453-0.json +1 -0
  73. package/coverage/tmp/coverage-79229-1779241011537-0.json +1 -0
  74. package/coverage/tmp/coverage-79230-1779241011647-0.json +1 -0
  75. package/coverage/tmp/coverage-79231-1779241011765-0.json +1 -0
  76. package/coverage/tmp/coverage-79232-1779241011841-0.json +1 -0
  77. package/coverage/tmp/coverage-79233-1779241011909-0.json +1 -0
  78. package/coverage/tmp/coverage-79234-1779241011984-0.json +1 -0
  79. package/coverage/tmp/coverage-79235-1779241012055-0.json +1 -0
  80. package/coverage/tmp/coverage-79236-1779241012230-0.json +1 -0
  81. package/coverage/tmp/coverage-79237-1779241012300-0.json +1 -0
  82. package/coverage/tmp/coverage-79238-1779241012368-0.json +1 -0
  83. package/coverage/tmp/coverage-79239-1779241012438-0.json +1 -0
  84. package/coverage/tmp/coverage-79240-1779241012511-0.json +1 -0
  85. package/coverage/tmp/coverage-79241-1779241012582-0.json +1 -0
  86. package/coverage/tmp/coverage-79242-1779241012652-0.json +1 -0
  87. package/coverage/tmp/coverage-79243-1779241012814-0.json +1 -0
  88. package/coverage/tmp/coverage-79244-1779241012931-0.json +1 -0
  89. package/coverage/tmp/coverage-79245-1779241013007-0.json +1 -0
  90. package/coverage/tmp/coverage-79246-1779241013106-0.json +1 -0
  91. package/coverage/tmp/coverage-79247-1779241013178-0.json +1 -0
  92. package/coverage/tmp/coverage-79248-1779241013244-0.json +1 -0
  93. package/coverage/tmp/coverage-79249-1779241013409-0.json +1 -0
  94. package/coverage/tmp/coverage-79250-1779241013697-0.json +1 -0
  95. package/coverage/tmp/coverage-79251-1779241013847-0.json +1 -0
  96. package/coverage/tmp/coverage-79252-1779241014288-0.json +1 -0
  97. package/coverage/tmp/coverage-79253-1779241014378-0.json +1 -0
  98. package/coverage/tmp/coverage-79254-1779241014428-0.json +1 -0
  99. package/coverage/tmp/coverage-79255-1779241021774-0.json +1 -0
  100. package/coverage/tmp/coverage-80382-1779241021949-0.json +1 -0
  101. package/coverage/tmp/coverage-80383-1779241025019-0.json +1 -0
  102. package/coverage/tmp/coverage-80384-1779241025133-0.json +1 -0
  103. package/docs/Body.md +1 -0
  104. package/docs/Config.md +1 -0
  105. package/docs/Connection.md +153 -0
  106. package/docs/CoreConfig.md +96 -0
  107. package/docs/CustomReturnCodes.md +3 -0
  108. package/docs/HAProxy.md +62 -0
  109. package/docs/Header.md +1 -0
  110. package/docs/Logging.md +129 -0
  111. package/docs/Outbound.md +210 -0
  112. package/docs/Plugins.md +372 -0
  113. package/docs/Results.md +7 -0
  114. package/docs/Transaction.md +135 -0
  115. package/docs/Tutorial.md +183 -0
  116. package/docs/deprecated/access.md +3 -0
  117. package/docs/deprecated/backscatterer.md +9 -0
  118. package/docs/deprecated/connect.rdns_access.md +53 -0
  119. package/docs/deprecated/data.headers.md +3 -0
  120. package/docs/deprecated/data.nomsgid.md +7 -0
  121. package/docs/deprecated/data.noreceived.md +11 -0
  122. package/docs/deprecated/data.rfc5322_header_checks.md +11 -0
  123. package/docs/deprecated/dkim_sign.md +97 -0
  124. package/docs/deprecated/dkim_verify.md +28 -0
  125. package/docs/deprecated/dnsbl.md +80 -0
  126. package/docs/deprecated/dnswl.md +73 -0
  127. package/docs/deprecated/lookup_rdns.strict.md +67 -0
  128. package/docs/deprecated/mail_from.access.md +52 -0
  129. package/docs/deprecated/mail_from.blocklist.md +18 -0
  130. package/docs/deprecated/mail_from.nobounces.md +8 -0
  131. package/docs/deprecated/rcpt_to.access.md +53 -0
  132. package/docs/deprecated/rcpt_to.blocklist.md +18 -0
  133. package/docs/deprecated/rcpt_to.routes.md +3 -0
  134. package/docs/deprecated/rdns.regexp.md +30 -0
  135. package/docs/plugins/aliases.md +3 -0
  136. package/docs/plugins/auth/auth_bridge.md +34 -0
  137. package/docs/plugins/auth/auth_ldap.md +4 -0
  138. package/docs/plugins/auth/auth_proxy.md +36 -0
  139. package/docs/plugins/auth/auth_vpopmaild.md +33 -0
  140. package/docs/plugins/auth/flat_file.md +40 -0
  141. package/docs/plugins/block_me.md +18 -0
  142. package/docs/plugins/data.signatures.md +11 -0
  143. package/docs/plugins/delay_deny.md +23 -0
  144. package/docs/plugins/max_unrecognized_commands.md +6 -0
  145. package/docs/plugins/prevent_credential_leaks.md +22 -0
  146. package/docs/plugins/process_title.md +42 -0
  147. package/docs/plugins/queue/deliver.md +3 -0
  148. package/docs/plugins/queue/discard.md +32 -0
  149. package/docs/plugins/queue/lmtp.md +24 -0
  150. package/docs/plugins/queue/qmail-queue.md +16 -0
  151. package/docs/plugins/queue/quarantine.md +87 -0
  152. package/docs/plugins/queue/smtp_bridge.md +32 -0
  153. package/docs/plugins/queue/smtp_forward.md +127 -0
  154. package/docs/plugins/queue/smtp_proxy.md +68 -0
  155. package/docs/plugins/queue/test.md +7 -0
  156. package/docs/plugins/rcpt_to.in_host_list.md +34 -0
  157. package/docs/plugins/rcpt_to.max_count.md +3 -0
  158. package/docs/plugins/record_envelope_addresses.md +20 -0
  159. package/docs/plugins/relay.md +3 -0
  160. package/docs/plugins/reseed_rng.md +16 -0
  161. package/docs/plugins/status.md +41 -0
  162. package/docs/plugins/tarpit.md +50 -0
  163. package/docs/plugins/tls.md +235 -0
  164. package/docs/plugins/toobusy.md +27 -0
  165. package/docs/plugins/xclient.md +10 -0
  166. package/docs/tutorials/Migrating_from_v1_to_v2.md +96 -0
  167. package/docs/tutorials/SettingUpOutbound.md +62 -0
  168. package/eslint.config.mjs +2 -0
  169. package/haraka.js +74 -0
  170. package/haraka.sh +2 -0
  171. package/http/html/404.html +58 -0
  172. package/http/html/index.html +47 -0
  173. package/http/package.json +21 -0
  174. package/line_socket.js +24 -0
  175. package/logger.js +322 -0
  176. package/outbound/client_pool.js +59 -0
  177. package/outbound/config.js +134 -0
  178. package/outbound/hmail.js +1504 -0
  179. package/outbound/index.js +349 -0
  180. package/outbound/qfile.js +93 -0
  181. package/outbound/queue.js +399 -0
  182. package/outbound/tls.js +85 -0
  183. package/outbound/todo.js +17 -0
  184. package/package.json +91 -29
  185. package/plugins/.eslintrc.yaml +3 -0
  186. package/plugins/auth/auth_base.js +261 -0
  187. package/plugins/auth/auth_bridge.js +20 -0
  188. package/plugins/auth/auth_proxy.js +227 -0
  189. package/plugins/auth/auth_vpopmaild.js +162 -0
  190. package/plugins/auth/flat_file.js +44 -0
  191. package/plugins/block_me.js +88 -0
  192. package/plugins/data.signatures.js +30 -0
  193. package/plugins/delay_deny.js +153 -0
  194. package/plugins/prevent_credential_leaks.js +61 -0
  195. package/plugins/process_title.js +197 -0
  196. package/plugins/profile.js +11 -0
  197. package/plugins/queue/deliver.js +12 -0
  198. package/plugins/queue/discard.js +27 -0
  199. package/plugins/queue/lmtp.js +45 -0
  200. package/plugins/queue/qmail-queue.js +93 -0
  201. package/plugins/queue/quarantine.js +133 -0
  202. package/plugins/queue/smtp_bridge.js +45 -0
  203. package/plugins/queue/smtp_forward.js +371 -0
  204. package/plugins/queue/smtp_proxy.js +142 -0
  205. package/plugins/queue/test.js +15 -0
  206. package/plugins/rcpt_to.host_list_base.js +65 -0
  207. package/plugins/rcpt_to.in_host_list.js +56 -0
  208. package/plugins/record_envelope_addresses.js +17 -0
  209. package/plugins/reseed_rng.js +7 -0
  210. package/plugins/status.js +274 -0
  211. package/plugins/tarpit.js +45 -0
  212. package/plugins/tls.js +164 -0
  213. package/plugins/toobusy.js +47 -0
  214. package/plugins/xclient.js +124 -0
  215. package/plugins.js +604 -0
  216. package/queue/1772642154987_1775581346001_4_82235_TGwgfd_2_mattbook-m3.home.simerson.net +0 -0
  217. package/run_tests +11 -0
  218. package/server.js +827 -0
  219. package/smtp_client.js +504 -0
  220. package/test/.eslintrc.yaml +11 -0
  221. package/test/config/auth_flat_file.ini +5 -0
  222. package/test/config/block_me.recipient +1 -0
  223. package/test/config/block_me.senders +1 -0
  224. package/test/config/dhparams.pem +8 -0
  225. package/test/config/host_list +2 -0
  226. package/test/config/outbound_tls_cert.pem +1 -0
  227. package/test/config/outbound_tls_key.pem +1 -0
  228. package/test/config/plugins +7 -0
  229. package/test/config/smtp.ini +11 -0
  230. package/test/config/smtp_forward.ini +30 -0
  231. package/test/config/tls/example.com/_.example.com.key +28 -0
  232. package/test/config/tls/example.com/example.com.crt +25 -0
  233. package/test/config/tls/haraka.local.pem +51 -0
  234. package/test/config/tls.ini +45 -0
  235. package/test/config/tls_cert.pem +21 -0
  236. package/test/config/tls_key.pem +28 -0
  237. package/test/connection.js +817 -0
  238. package/test/fixtures/haproxy_allowed/config/connection.ini +3 -0
  239. package/test/fixtures/haproxy_disabled/config/connection.ini +3 -0
  240. package/test/fixtures/haproxy_untrusted/config/connection.ini +3 -0
  241. package/test/fixtures/line_socket.js +21 -0
  242. package/test/fixtures/todo_qfile.txt +0 -0
  243. package/test/fixtures/util_hmailitem.js +156 -0
  244. package/test/installation/config/test-plugin-flat +1 -0
  245. package/test/installation/config/test-plugin.ini +10 -0
  246. package/test/installation/config/tls.ini +1 -0
  247. package/test/installation/node_modules/load_first/index.js +5 -0
  248. package/test/installation/node_modules/load_first/package.json +11 -0
  249. package/test/installation/node_modules/test-plugin/config/test-plugin-flat +1 -0
  250. package/test/installation/node_modules/test-plugin/config/test-plugin.ini +9 -0
  251. package/test/installation/node_modules/test-plugin/package.json +5 -0
  252. package/test/installation/node_modules/test-plugin/test-plugin.js +5 -0
  253. package/test/installation/plugins/base_plugin.js +3 -0
  254. package/test/installation/plugins/folder_plugin/index.js +3 -0
  255. package/test/installation/plugins/folder_plugin/package.json +11 -0
  256. package/test/installation/plugins/inherits.js +7 -0
  257. package/test/installation/plugins/load_first.js +3 -0
  258. package/test/installation/plugins/plugin.js +1 -0
  259. package/test/installation/plugins/tls.js +3 -0
  260. package/test/logger.js +217 -0
  261. package/test/loud/config/dhparams.pem +0 -0
  262. package/test/loud/config/tls/goobered.pem +45 -0
  263. package/test/loud/config/tls.ini +43 -0
  264. package/test/mail_specimen/base64-root-part.txt +23 -0
  265. package/test/mail_specimen/varied-fold-lengths-preserve-data.txt +283 -0
  266. package/test/outbound/bounce_net_errors.js +133 -0
  267. package/test/outbound/bounce_rfc3464.js +226 -0
  268. package/test/outbound/hmail.js +210 -0
  269. package/test/outbound/index.js +385 -0
  270. package/test/outbound/qfile.js +124 -0
  271. package/test/outbound/queue.js +325 -0
  272. package/test/plugins/auth/auth_base.js +620 -0
  273. package/test/plugins/auth/auth_bridge.js +80 -0
  274. package/test/plugins/auth/auth_vpopmaild.js +81 -0
  275. package/test/plugins/auth/flat_file.js +123 -0
  276. package/test/plugins/block_me.js +141 -0
  277. package/test/plugins/data.signatures.js +111 -0
  278. package/test/plugins/delay_deny.js +262 -0
  279. package/test/plugins/prevent_credential_leaks.js +174 -0
  280. package/test/plugins/process_title.js +141 -0
  281. package/test/plugins/queue/deliver.js +98 -0
  282. package/test/plugins/queue/discard.js +78 -0
  283. package/test/plugins/queue/lmtp.js +137 -0
  284. package/test/plugins/queue/qmail-queue.js +98 -0
  285. package/test/plugins/queue/quarantine.js +80 -0
  286. package/test/plugins/queue/smtp_bridge.js +152 -0
  287. package/test/plugins/queue/smtp_forward.js +1023 -0
  288. package/test/plugins/queue/smtp_proxy.js +138 -0
  289. package/test/plugins/rcpt_to.host_list_base.js +102 -0
  290. package/test/plugins/rcpt_to.in_host_list.js +186 -0
  291. package/test/plugins/record_envelope_addresses.js +66 -0
  292. package/test/plugins/reseed_rng.js +34 -0
  293. package/test/plugins/status.js +207 -0
  294. package/test/plugins/tarpit.js +90 -0
  295. package/test/plugins/tls.js +86 -0
  296. package/test/plugins/toobusy.js +21 -0
  297. package/test/plugins/xclient.js +119 -0
  298. package/test/plugins.js +230 -0
  299. package/test/queue/1507509981169_1507509981169_0_61403_e0Y0Ym_1_fixed +0 -0
  300. package/test/queue/1507509981169_1507509981169_0_61403_e0Y0Ym_1_haraka +0 -0
  301. package/test/queue/1508269674999_1508269674999_0_34002_socVUF_1_haraka +0 -0
  302. package/test/queue/1508455115683_1508455115683_0_90253_9Q4o4V_1_haraka +0 -0
  303. package/test/queue/zero-length +0 -0
  304. package/test/server.js +1012 -0
  305. package/test/smtp_client.js +1303 -0
  306. package/test/tls_socket.js +321 -0
  307. package/test/transaction.js +554 -0
  308. package/tls_socket.js +771 -0
  309. package/transaction.js +267 -0
  310. package/lib/index.js +0 -371
package/test/server.js ADDED
@@ -0,0 +1,1012 @@
1
+ 'use strict'
2
+
3
+ const { describe, it, beforeEach, afterEach } = require('node:test')
4
+ const assert = require('node:assert/strict')
5
+ const { createHmac } = require('node:crypto')
6
+ const net = require('node:net')
7
+ const { once } = require('node:events')
8
+ const path = require('node:path')
9
+ const tls = require('node:tls')
10
+ const constants = require('haraka-constants')
11
+ const net_utils = require('haraka-net-utils')
12
+
13
+ const { endpoint } = require('haraka-net-utils')
14
+ const message = require('haraka-email-message')
15
+ const { get_client } = require('../smtp_client')
16
+
17
+ function fixtureConfig(name) {
18
+ const testRoot = path.resolve('test')
19
+ return require('haraka-config').module_config(testRoot, path.resolve('test/fixtures', name))
20
+ }
21
+
22
+ function useHaproxyFixture(server, name) {
23
+ const originalConfig = net_utils.config
24
+ const originalConnectionCfg = server.connection.cfg
25
+ const config = fixtureConfig(name)
26
+ net_utils.config = config
27
+ server.connection.cfg = config.get('connection.ini', { booleans: ['+haproxy.enabled'] })
28
+ return () => {
29
+ net_utils.config = originalConfig
30
+ server.connection.cfg = originalConnectionCfg
31
+ }
32
+ }
33
+
34
+ // ─── CRAM-MD5 helper ──────────────────────────────────────────────────────────
35
+
36
+ /** Compute a CRAM-MD5 response to a server challenge. */
37
+ const cramMd5Response = (user, pass, challenge) => {
38
+ const decoded = Buffer.from(challenge, 'base64').toString()
39
+ const hmac = createHmac('md5', pass).update(decoded).digest('hex')
40
+ return Buffer.from(`${user} ${hmac}`).toString('base64')
41
+ }
42
+
43
+ // ─── Server lifecycle helpers ─────────────────────────────────────────────────
44
+
45
+ const setupServer = (ip_port) =>
46
+ new Promise((resolve) => {
47
+ process.env.YES_REALLY_DO_DISCARD = '1'
48
+ process.env.HARAKA_TEST_DIR = path.resolve('test')
49
+ const test_cfg_path = path.resolve('test')
50
+
51
+ this.server = require('../server')
52
+ this.config = require('haraka-config').module_config(test_cfg_path)
53
+ this.server.logger.loglevel = 6
54
+ this.server.config = this.config.module_config(test_cfg_path)
55
+ this.server.plugins.config = this.config.module_config(test_cfg_path)
56
+
57
+ this.server.load_smtp_ini()
58
+ this.server.cfg.main.listen = ip_port
59
+ this.server.cfg.main.smtps_port = 2465
60
+
61
+ this.server.load_default_tls_config(() => {
62
+ this.server.createServer({})
63
+ setTimeout(resolve, 200)
64
+ })
65
+ })
66
+
67
+ const tearDownServer = () =>
68
+ new Promise((resolve) => {
69
+ delete process.env.YES_REALLY_DO_DISCARD
70
+ delete process.env.HARAKA_TEST_DIR
71
+ this.server.stopListeners()
72
+ this.server.plugins.registered_hooks = {}
73
+ setTimeout(resolve, 200)
74
+ })
75
+
76
+ // ─── SMTP session helper ──────────────────────────────────────────────────────
77
+
78
+ /**
79
+ * Deliver a message via smtp_client and return a Promise that resolves on
80
+ * acceptance (dot event) or rejects on any SMTP error (bad_code event).
81
+ *
82
+ * When `user`/`pass` are provided, CRAM-MD5 authentication is performed
83
+ * before sending the message.
84
+ */
85
+ const sendMessage = ({
86
+ host = '127.0.0.1',
87
+ port,
88
+ from = '<test@haraka.local>',
89
+ to = '<discard@haraka.local>',
90
+ user,
91
+ pass,
92
+ body = 'Hello from smtp_client test',
93
+ } = {}) =>
94
+ new Promise((resolve, reject) => {
95
+ get_client(
96
+ { notes: {} },
97
+ (client) => {
98
+ let credsSent = false
99
+
100
+ client
101
+ .on('greeting', (cmd) => client.send_command(cmd, host))
102
+ .on('helo', () => {
103
+ if (user && !credsSent) {
104
+ client.authenticating = true
105
+ client.send_command('AUTH', 'CRAM-MD5')
106
+ } else {
107
+ client.send_command('MAIL', `FROM:${from}`)
108
+ }
109
+ })
110
+ .on('auth', () => {
111
+ if (client.authenticated) {
112
+ client.send_command('MAIL', `FROM:${from}`)
113
+ } else if (!credsSent) {
114
+ credsSent = true
115
+ const resp = cramMd5Response(user, pass, client.response[0])
116
+ // Write CRAM-MD5 response directly (no command prefix)
117
+ client.command = 'auth'
118
+ client.response = []
119
+ client.socket.write(`${resp}\r\n`)
120
+ }
121
+ })
122
+ .on('mail', () => client.send_command('RCPT', `TO:${to}`))
123
+ .on('rcpt', () => client.send_command('DATA'))
124
+ .on('data', () => {
125
+ const stream = new message.stream({ main: { spool_after: 1024 } }, 'testId')
126
+ stream.on('end', () => client.socket.write('.\r\n'))
127
+ stream.add_line('Subject: test\r\n')
128
+ stream.add_line('\r\n')
129
+ stream.add_line(`${body}\r\n`)
130
+ stream.add_line_end()
131
+ client.start_data(stream)
132
+ })
133
+ .on('dot', () => {
134
+ client.release()
135
+ resolve()
136
+ })
137
+ .on('bad_code', (code, msg) => {
138
+ client.release()
139
+ reject(new Error(`${code} ${msg}`))
140
+ })
141
+ },
142
+ { host, port, connect_timeout: 5 },
143
+ )
144
+ })
145
+
146
+ const listen = (server, host = '127.0.0.1') =>
147
+ new Promise((resolve, reject) => {
148
+ server.once('error', reject)
149
+ server.listen(0, host, () => {
150
+ server.removeListener('error', reject)
151
+ resolve()
152
+ })
153
+ })
154
+
155
+ const close = (server) =>
156
+ new Promise((resolve) => {
157
+ server.close(resolve)
158
+ })
159
+
160
+ const withTimeout = (promise, ms, msg) =>
161
+ new Promise((resolve, reject) => {
162
+ const timer = setTimeout(() => reject(new Error(msg)), ms)
163
+ promise.then(
164
+ (result) => {
165
+ clearTimeout(timer)
166
+ resolve(result)
167
+ },
168
+ (err) => {
169
+ clearTimeout(timer)
170
+ reject(err)
171
+ },
172
+ )
173
+ })
174
+
175
+ // ─── Tests ────────────────────────────────────────────────────────────────────
176
+
177
+ describe('server', () => {
178
+ // ── get_listen_addrs ──────────────────────────────────────────────────────
179
+ describe('get_listen_addrs', () => {
180
+ beforeEach(() => {
181
+ this.config = require('haraka-config')
182
+ this.server = require('../server')
183
+ })
184
+
185
+ const cases = [
186
+ {
187
+ desc: 'IPv4 fully qualified',
188
+ args: [{ listen: '127.0.0.1:25' }],
189
+ expected: ['127.0.0.1:25'],
190
+ },
191
+ {
192
+ desc: 'IPv4, default port',
193
+ args: [{ listen: '127.0.0.1' }],
194
+ expected: ['127.0.0.1:25'],
195
+ },
196
+ {
197
+ desc: 'IPv4, custom port',
198
+ args: [{ listen: '127.0.0.1' }, 250],
199
+ expected: ['127.0.0.1:250'],
200
+ },
201
+ {
202
+ desc: 'IPv6 fully qualified',
203
+ args: [{ listen: '[::1]:25' }],
204
+ expected: ['[::1]:25'],
205
+ },
206
+ {
207
+ desc: 'IPv6, default port',
208
+ args: [{ listen: '[::1]' }],
209
+ expected: ['[::1]:25'],
210
+ },
211
+ {
212
+ desc: 'IPv6, custom port',
213
+ args: [{ listen: '[::1]' }, 250],
214
+ expected: ['[::1]:250'],
215
+ },
216
+ {
217
+ desc: 'IPv4 & IPv6 fully qualified',
218
+ args: [{ listen: '127.0.0.1:25,[::1]:25' }],
219
+ expected: ['127.0.0.1:25', '[::1]:25'],
220
+ },
221
+ {
222
+ desc: 'IPv4 & IPv6, default port',
223
+ args: [{ listen: '127.0.0.1:25,[::1]' }],
224
+ expected: ['127.0.0.1:25', '[::1]:25'],
225
+ },
226
+ {
227
+ desc: 'IPv4 & IPv6, custom port',
228
+ args: [{ listen: '127.0.0.1,[::1]' }, 250],
229
+ expected: ['127.0.0.1:250', '[::1]:250'],
230
+ },
231
+ ]
232
+
233
+ for (const { desc, args, expected } of cases) {
234
+ it(desc, () => {
235
+ assert.deepEqual(this.server.get_listen_addrs(...args), expected)
236
+ })
237
+ }
238
+ })
239
+
240
+ // ── load_smtp_ini ─────────────────────────────────────────────────────────
241
+ describe('load_smtp_ini', () => {
242
+ beforeEach(() => {
243
+ this.config = require('haraka-config')
244
+ this.server = require('../server')
245
+ })
246
+
247
+ it('saves settings to Server.cfg', () => {
248
+ this.server.load_smtp_ini()
249
+ const c = this.server.cfg.main
250
+ assert.notEqual(c.daemonize, undefined)
251
+ assert.notEqual(c.daemon_log_file, undefined)
252
+ assert.notEqual(c.daemon_pid_file, undefined)
253
+ })
254
+ })
255
+
256
+ // ── get_smtp_server ───────────────────────────────────────────────────────
257
+ describe('get_smtp_server', () => {
258
+ beforeEach(async () => {
259
+ this.config = require('haraka-config').module_config(path.resolve('test'))
260
+ this.server = require('../server')
261
+ this.server.config = this.config
262
+ this.server.plugins.config = this.config
263
+ await new Promise((resolve) => {
264
+ this.server.load_default_tls_config(() => setTimeout(resolve, 200))
265
+ })
266
+ })
267
+
268
+ it('gets a net server object', async () => {
269
+ const server = await this.server.get_smtp_server(endpoint('0.0.0.0:2501'), 10)
270
+ if (!server) {
271
+ if (process.env.CI) return
272
+ assert.fail('unable to bind to 0.0.0.0:2501')
273
+ }
274
+ assert.ok(server)
275
+ assert.equal(server.has_tls, false)
276
+ const count = await new Promise((res) => server.getConnections((err, n) => res(n)))
277
+ assert.equal(count, 0)
278
+ })
279
+
280
+ it('gets a TLS net server object', async () => {
281
+ this.server.cfg.main.smtps_port = 2502
282
+ const server = await this.server.get_smtp_server(endpoint('0.0.0.0:2502'), 10)
283
+ if (!server) {
284
+ if (process.env.CI) return
285
+ assert.fail('unable to bind to 0.0.0.0:2502')
286
+ }
287
+ assert.ok(server)
288
+ assert.equal(server.has_tls, true)
289
+ const count = await new Promise((res) => server.getConnections((err, n) => res(n)))
290
+ assert.equal(count, 0)
291
+ })
292
+
293
+ it('accepts PROXY v1 before the SMTPS TLS handshake', async () => {
294
+ const restoreHaproxyConfig = useHaproxyFixture(this.server, 'haproxy_allowed')
295
+ this.server.cfg.main.smtps_port = 0
296
+
297
+ // PROXY-before-TLS takes slightly longer than the default 10 ms timeout on Windows,
298
+ // use 50 ms timeout to avoid flaky tests (default is 300000 ms).
299
+ const server = await this.server.get_smtp_server(endpoint('127.0.0.1:0'), 50)
300
+ const tlsErrors = []
301
+ let raw
302
+ let client
303
+
304
+ server.on('tlsClientError', (err) => {
305
+ tlsErrors.push(err)
306
+ })
307
+
308
+ try {
309
+ await listen(server)
310
+
311
+ raw = net.connect(server.address().port, '127.0.0.1')
312
+ await withTimeout(
313
+ Promise.race([
314
+ once(raw, 'connect'),
315
+ once(raw, 'error').then(([err]) => {
316
+ throw err
317
+ }),
318
+ ]),
319
+ 3000,
320
+ 'SMTPS TCP connection timed out',
321
+ )
322
+
323
+ raw.write('PROXY TCP4 127.0.0.1 127.0.0.1 42310 465\r\n')
324
+ client = tls.connect({
325
+ socket: raw,
326
+ rejectUnauthorized: false,
327
+ servername: 'localhost',
328
+ })
329
+
330
+ await withTimeout(
331
+ Promise.race([
332
+ once(client, 'secureConnect'),
333
+ once(client, 'error').then(([err]) => {
334
+ throw err
335
+ }),
336
+ ]),
337
+ 3000,
338
+ 'SMTPS PROXY handshake timed out',
339
+ )
340
+ const [banner] = await withTimeout(once(client, 'data'), 3000, 'SMTPS PROXY banner timed out')
341
+ assert.match(banner.toString(), /^220 /)
342
+ assert.equal(tlsErrors.length, 0)
343
+ } finally {
344
+ if (client) client.destroy()
345
+ else if (raw) raw.destroy()
346
+ await close(server)
347
+ restoreHaproxyConfig()
348
+ }
349
+ })
350
+
351
+ it('accepts direct SMTPS from a PROXY-allowed peer', async () => {
352
+ const restoreHaproxyConfig = useHaproxyFixture(this.server, 'haproxy_allowed')
353
+ this.server.cfg.main.smtps_port = 0
354
+
355
+ const server = await this.server.get_smtp_server(endpoint('127.0.0.1:0'), 1000)
356
+ const tlsErrors = []
357
+ let client
358
+
359
+ server.on('tlsClientError', (err) => {
360
+ tlsErrors.push(err)
361
+ })
362
+
363
+ try {
364
+ await listen(server)
365
+
366
+ client = tls.connect({
367
+ port: server.address().port,
368
+ host: '127.0.0.1',
369
+ rejectUnauthorized: false,
370
+ servername: 'localhost',
371
+ })
372
+
373
+ await withTimeout(
374
+ Promise.race([
375
+ once(client, 'secureConnect'),
376
+ once(client, 'error').then(([err]) => {
377
+ throw err
378
+ }),
379
+ ]),
380
+ 3000,
381
+ 'direct SMTPS handshake timed out',
382
+ )
383
+ const [banner] = await withTimeout(once(client, 'data'), 3000, 'direct SMTPS banner timed out')
384
+ assert.match(banner.toString(), /^220 /)
385
+ assert.equal(tlsErrors.length, 0)
386
+ } finally {
387
+ if (client) client.destroy()
388
+ await close(server)
389
+ restoreHaproxyConfig()
390
+ }
391
+ })
392
+
393
+ it('preserves TLS server events for SMTPS connections', async () => {
394
+ this.server.cfg.main.smtps_port = 0
395
+
396
+ const server = await this.server.get_smtp_server(endpoint('127.0.0.1:0'), 10)
397
+ let ocspRequests = 0
398
+ let first
399
+ let second
400
+
401
+ server.tlsServer.on('OCSPRequest', (cert, issuer, cb) => {
402
+ ocspRequests++
403
+ cb()
404
+ })
405
+
406
+ try {
407
+ await listen(server)
408
+
409
+ first = tls.connect({
410
+ port: server.address().port,
411
+ host: '127.0.0.1',
412
+ rejectUnauthorized: false,
413
+ requestOCSP: true,
414
+ servername: 'localhost',
415
+ maxVersion: 'TLSv1.2',
416
+ })
417
+
418
+ await withTimeout(
419
+ Promise.race([
420
+ once(first, 'secureConnect'),
421
+ once(first, 'error').then(([err]) => {
422
+ throw err
423
+ }),
424
+ ]),
425
+ 3000,
426
+ 'first SMTPS handshake timed out',
427
+ )
428
+ const session = first.getSession()
429
+ first.destroy()
430
+ await withTimeout(once(first, 'close'), 3000, 'first SMTPS close timed out')
431
+
432
+ second = tls.connect({
433
+ port: server.address().port,
434
+ host: '127.0.0.1',
435
+ rejectUnauthorized: false,
436
+ requestOCSP: true,
437
+ servername: 'localhost',
438
+ maxVersion: 'TLSv1.2',
439
+ session,
440
+ })
441
+
442
+ await withTimeout(
443
+ Promise.race([
444
+ once(second, 'secureConnect'),
445
+ once(second, 'error').then(([err]) => {
446
+ throw err
447
+ }),
448
+ ]),
449
+ 3000,
450
+ 'resumed SMTPS handshake timed out',
451
+ )
452
+
453
+ assert.equal(ocspRequests, 1)
454
+ assert.equal(second.isSessionReused(), true)
455
+ } finally {
456
+ if (second) second.destroy()
457
+ if (first) first.destroy()
458
+ await close(server)
459
+ }
460
+ })
461
+
462
+ it('uses direct TLS for SMTPS when HAProxy support is disabled', async () => {
463
+ const restoreHaproxyConfig = useHaproxyFixture(this.server, 'haproxy_disabled')
464
+ this.server.cfg.main.smtps_port = 0
465
+
466
+ let server
467
+ let client
468
+
469
+ try {
470
+ server = await this.server.get_smtp_server(endpoint('127.0.0.1:0'), 10)
471
+ assert.equal(server.tlsServer, undefined)
472
+
473
+ await listen(server)
474
+
475
+ client = tls.connect({
476
+ port: server.address().port,
477
+ host: '127.0.0.1',
478
+ rejectUnauthorized: false,
479
+ servername: 'localhost',
480
+ })
481
+
482
+ await withTimeout(
483
+ Promise.race([
484
+ once(client, 'secureConnect'),
485
+ once(client, 'error').then(([err]) => {
486
+ throw err
487
+ }),
488
+ ]),
489
+ 3000,
490
+ 'direct TLS fallback handshake timed out',
491
+ )
492
+ } finally {
493
+ if (client) client.destroy()
494
+ if (server) await close(server)
495
+ restoreHaproxyConfig()
496
+ }
497
+ })
498
+
499
+ it('accepts direct SMTPS from an untrusted PROXY peer', async () => {
500
+ const restoreHaproxyConfig = useHaproxyFixture(this.server, 'haproxy_untrusted')
501
+ this.server.cfg.main.smtps_port = 0
502
+
503
+ const server = await this.server.get_smtp_server(endpoint('127.0.0.1:0'), 10)
504
+ let client
505
+
506
+ try {
507
+ await listen(server)
508
+
509
+ client = tls.connect({
510
+ port: server.address().port,
511
+ host: '127.0.0.1',
512
+ rejectUnauthorized: false,
513
+ servername: 'localhost',
514
+ })
515
+
516
+ await withTimeout(
517
+ Promise.race([
518
+ once(client, 'secureConnect'),
519
+ once(client, 'error').then(([err]) => {
520
+ throw err
521
+ }),
522
+ ]),
523
+ 3000,
524
+ 'untrusted direct SMTPS handshake timed out',
525
+ )
526
+ } finally {
527
+ if (client) client.destroy()
528
+ await close(server)
529
+ restoreHaproxyConfig()
530
+ }
531
+ })
532
+
533
+ it('rejects malformed SMTPS PROXY lines before TLS', async () => {
534
+ const restoreHaproxyConfig = useHaproxyFixture(this.server, 'haproxy_allowed')
535
+ this.server.cfg.main.smtps_port = 0
536
+
537
+ const server = await this.server.get_smtp_server(endpoint('127.0.0.1:0'), 10)
538
+ let raw
539
+
540
+ try {
541
+ await listen(server)
542
+
543
+ raw = net.connect(server.address().port, '127.0.0.1')
544
+ await withTimeout(once(raw, 'connect'), 3000, 'malformed PROXY TCP connection timed out')
545
+ raw.write('PROXY TCP4 nope 127.0.0.1 42310 465\r\n')
546
+
547
+ const [response] = await withTimeout(once(raw, 'data'), 3000, 'malformed PROXY response timed out')
548
+ assert.match(response.toString(), /^421 Invalid PROXY format/)
549
+ } finally {
550
+ if (raw) raw.destroy()
551
+ await close(server)
552
+ restoreHaproxyConfig()
553
+ }
554
+ })
555
+
556
+ it('rejects oversized SMTPS PROXY lines before TLS', async () => {
557
+ const restoreHaproxyConfig = useHaproxyFixture(this.server, 'haproxy_allowed')
558
+ this.server.cfg.main.smtps_port = 0
559
+
560
+ const server = await this.server.get_smtp_server(endpoint('127.0.0.1:0'), 10)
561
+ let raw
562
+
563
+ try {
564
+ await listen(server)
565
+
566
+ raw = net.connect(server.address().port, '127.0.0.1')
567
+ await withTimeout(once(raw, 'connect'), 3000, 'oversized PROXY TCP connection timed out')
568
+ raw.write(`PROXY ${'x'.repeat(513)}`)
569
+
570
+ const [response] = await withTimeout(once(raw, 'data'), 3000, 'oversized PROXY response timed out')
571
+ assert.match(response.toString(), /^421 Invalid PROXY format/)
572
+ } finally {
573
+ if (raw) raw.destroy()
574
+ await close(server)
575
+ restoreHaproxyConfig()
576
+ }
577
+ })
578
+
579
+ it('times out waiting for SMTPS PROXY from an allowed peer', async () => {
580
+ const restoreHaproxyConfig = useHaproxyFixture(this.server, 'haproxy_allowed')
581
+ const originalSetTimeout = global.setTimeout
582
+ global.setTimeout = (fn, ms, ...args) => originalSetTimeout(fn, ms === 30 * 1000 ? 20 : ms, ...args)
583
+ this.server.cfg.main.smtps_port = 0
584
+
585
+ const server = await this.server.get_smtp_server(endpoint('127.0.0.1:0'), 10)
586
+ let raw
587
+
588
+ try {
589
+ await listen(server)
590
+
591
+ raw = net.connect(server.address().port, '127.0.0.1')
592
+ await withTimeout(once(raw, 'connect'), 3000, 'PROXY timeout TCP connection timed out')
593
+
594
+ const [response] = await withTimeout(once(raw, 'data'), 3000, 'PROXY timeout response timed out')
595
+ assert.match(response.toString(), /^421 PROXY timeout/)
596
+ } finally {
597
+ global.setTimeout = originalSetTimeout
598
+ if (raw) raw.destroy()
599
+ await close(server)
600
+ restoreHaproxyConfig()
601
+ }
602
+ })
603
+
604
+ it('accepts byte-by-byte direct SMTPS from a PROXY-allowed peer', async () => {
605
+ const restoreHaproxyConfig = useHaproxyFixture(this.server, 'haproxy_allowed')
606
+ this.server.cfg.main.smtps_port = 0
607
+
608
+ const server = await this.server.get_smtp_server(endpoint('127.0.0.1:0'), 1000)
609
+ let raw
610
+ let client
611
+
612
+ try {
613
+ await listen(server)
614
+
615
+ raw = net.connect(server.address().port, '127.0.0.1')
616
+ await withTimeout(once(raw, 'connect'), 3000, 'fragmented direct SMTPS TCP connection timed out')
617
+
618
+ const write = raw.write.bind(raw)
619
+ raw.write = (chunk, encoding, cb) => {
620
+ if (typeof encoding === 'function') {
621
+ cb = encoding
622
+ encoding = undefined
623
+ }
624
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding)
625
+ let pos = 0
626
+ const write_next = () => {
627
+ if (pos >= buffer.length) {
628
+ if (cb) cb()
629
+ return
630
+ }
631
+ write(buffer.subarray(pos, pos + 1))
632
+ pos++
633
+ setImmediate(write_next)
634
+ }
635
+ write_next()
636
+ return true
637
+ }
638
+
639
+ client = tls.connect({
640
+ socket: raw,
641
+ rejectUnauthorized: false,
642
+ servername: 'localhost',
643
+ })
644
+
645
+ await withTimeout(
646
+ Promise.race([
647
+ once(client, 'secureConnect'),
648
+ once(client, 'error').then(([err]) => {
649
+ throw err
650
+ }),
651
+ ]),
652
+ 3000,
653
+ 'fragmented direct SMTPS handshake timed out',
654
+ )
655
+ const [banner] = await withTimeout(
656
+ once(client, 'data'),
657
+ 3000,
658
+ 'fragmented direct SMTPS banner timed out',
659
+ )
660
+ assert.match(banner.toString(), /^220 /)
661
+ } finally {
662
+ if (client) client.destroy()
663
+ else if (raw) raw.destroy()
664
+ await close(server)
665
+ restoreHaproxyConfig()
666
+ }
667
+ })
668
+ })
669
+
670
+ // ── get_http_docroot ──────────────────────────────────────────────────────
671
+ describe('get_http_docroot', () => {
672
+ beforeEach(() => {
673
+ this.config = require('haraka-config')
674
+ this.server = require('../server')
675
+ })
676
+
677
+ it('gets a fs path', () => {
678
+ assert.ok(this.server.get_http_docroot())
679
+ })
680
+ })
681
+
682
+ describe('lifecycle helpers', () => {
683
+ beforeEach(() => {
684
+ this.server = require('../server')
685
+ this.server.cfg = this.server.cfg || { main: {} }
686
+ this.server.cfg.main = this.server.cfg.main || {}
687
+ })
688
+
689
+ it('init_child_respond OK path starts HTTP listeners', () => {
690
+ let called = 0
691
+ const original = this.server.setup_http_listeners
692
+ this.server.setup_http_listeners = () => {
693
+ called++
694
+ }
695
+ try {
696
+ this.server.init_child_respond(constants.ok)
697
+ assert.equal(called, 1)
698
+ } finally {
699
+ this.server.setup_http_listeners = original
700
+ }
701
+ })
702
+
703
+ it('init_child_respond error path kills master and exits', () => {
704
+ process.env.CLUSTER_MASTER_PID = '12345'
705
+ const originalKill = process.kill
706
+ const originalDump = this.server.logger.dump_and_exit
707
+ let killed = null
708
+ let exitCode = null
709
+ process.kill = (pid) => {
710
+ killed = pid
711
+ }
712
+ this.server.logger.dump_and_exit = (code) => {
713
+ exitCode = code
714
+ }
715
+ try {
716
+ this.server.init_child_respond(constants.deny, 'nope')
717
+ assert.equal(killed, '12345')
718
+ assert.equal(exitCode, 1)
719
+ } finally {
720
+ process.kill = originalKill
721
+ this.server.logger.dump_and_exit = originalDump
722
+ delete process.env.CLUSTER_MASTER_PID
723
+ }
724
+ })
725
+
726
+ it('listening applies configured uid/gid and marks ready', () => {
727
+ this.server.cfg.main.group = 'staff'
728
+ this.server.cfg.main.user = 'nobody'
729
+ const originalGetGid = process.getgid
730
+ const originalSetGid = process.setgid
731
+ const originalGetUid = process.getuid
732
+ const originalSetUid = process.setuid
733
+ const calls = { setgid: 0, setuid: 0 }
734
+ process.getgid = () => 20
735
+ process.setgid = () => {
736
+ calls.setgid++
737
+ }
738
+ process.getuid = () => 501
739
+ process.setuid = () => {
740
+ calls.setuid++
741
+ }
742
+ try {
743
+ this.server.listening()
744
+ assert.equal(calls.setgid, 1)
745
+ assert.equal(calls.setuid, 1)
746
+ assert.equal(this.server.ready, 1)
747
+ } finally {
748
+ process.getgid = originalGetGid
749
+ process.setgid = originalSetGid
750
+ process.getuid = originalGetUid
751
+ process.setuid = originalSetUid
752
+ delete this.server.cfg.main.group
753
+ delete this.server.cfg.main.user
754
+ }
755
+ })
756
+
757
+ it('sendToMaster calls receiveAsMaster when not clustered', () => {
758
+ const originalCluster = this.server.cluster
759
+ const originalReceive = this.server.receiveAsMaster
760
+ const seen = []
761
+ this.server.cluster = null
762
+ this.server.receiveAsMaster = (cmd, params) => {
763
+ seen.push([cmd, params])
764
+ }
765
+ try {
766
+ this.server.sendToMaster('flushQueue', ['example.com'])
767
+ assert.deepEqual(seen[0], ['flushQueue', ['example.com']])
768
+ } finally {
769
+ this.server.cluster = originalCluster
770
+ this.server.receiveAsMaster = originalReceive
771
+ }
772
+ })
773
+
774
+ it('receiveAsMaster ignores invalid commands and executes valid ones', () => {
775
+ const errors = []
776
+ const originalLogError = this.server.logerror
777
+ this.server.logerror = (msg) => errors.push(msg)
778
+ this.server._testCommand = (a, b) => {
779
+ this.server.notes.received = [a, b]
780
+ }
781
+ try {
782
+ this.server.receiveAsMaster('notACommand', [])
783
+ assert.equal(errors.length > 0, true)
784
+
785
+ this.server.receiveAsMaster('_testCommand', ['x', 'y'])
786
+ assert.deepEqual(this.server.notes.received, ['x', 'y'])
787
+ } finally {
788
+ this.server.logerror = originalLogError
789
+ delete this.server._testCommand
790
+ }
791
+ })
792
+ })
793
+
794
+ describe('HTTP helpers', () => {
795
+ beforeEach(() => {
796
+ this.server = require('../server')
797
+ })
798
+
799
+ it('handle404 serves html/json/text based on request accepts', () => {
800
+ const makeReq = (kind) => ({
801
+ accepts(type) {
802
+ return type === kind
803
+ },
804
+ })
805
+ const responses = []
806
+ const makeRes = () => ({
807
+ status(code) {
808
+ responses.push({ code })
809
+ return this
810
+ },
811
+ sendFile(name, opts) {
812
+ responses.push({ type: 'html', name, opts })
813
+ },
814
+ send(body) {
815
+ responses.push({ type: 'body', body })
816
+ },
817
+ })
818
+
819
+ this.server.handle404(makeReq('html'), makeRes())
820
+ this.server.handle404(makeReq('json'), makeRes())
821
+ this.server.handle404(makeReq('none'), makeRes())
822
+
823
+ assert.equal(responses[0].code, 404)
824
+ assert.equal(responses[1].type, 'html')
825
+ assert.equal(responses[3].type, 'body')
826
+ assert.deepEqual(responses[3].body, { err: 'Not found' })
827
+ assert.equal(responses[5].body, 'Not found!')
828
+ })
829
+
830
+ it('init_http_respond logs and returns when ws is unavailable', () => {
831
+ const Module = require('node:module')
832
+ const originalRequire = Module.prototype.require
833
+ const originalLogError = this.server.logerror
834
+ const errors = []
835
+ this.server.logerror = (msg) => {
836
+ errors.push(msg)
837
+ }
838
+ this.server.http = { server: {} }
839
+ Module.prototype.require = function (id) {
840
+ if (id === 'ws') throw new Error('ws missing')
841
+ return originalRequire.apply(this, arguments)
842
+ }
843
+ try {
844
+ this.server.init_http_respond()
845
+ assert.equal(errors.length > 0, true)
846
+ } finally {
847
+ Module.prototype.require = originalRequire
848
+ this.server.logerror = originalLogError
849
+ }
850
+ })
851
+ })
852
+
853
+ // ── SMTP sessions ─────────────────────────────────────────────────────────
854
+ describe('SMTP sessions', () => {
855
+ beforeEach(async () => setupServer('127.0.0.1:2503'))
856
+ afterEach(async () => tearDownServer())
857
+
858
+ it('accepts plain SMTP message', async () => {
859
+ await sendMessage({ port: 2503 })
860
+ })
861
+
862
+ it('accepts CRAM-MD5 authenticated SMTP', async () => {
863
+ await sendMessage({ port: 2503, user: 'matt', pass: 'goodPass' })
864
+ })
865
+
866
+ it('rejects invalid CRAM-MD5 credentials', async () => {
867
+ await assert.rejects(() => sendMessage({ port: 2503, user: 'matt', pass: 'badPass' }), /5\d\d/)
868
+ })
869
+
870
+ it('accepts message with custom headers', async () => {
871
+ await sendMessage({
872
+ port: 2503,
873
+ from: '<sender@haraka.local>',
874
+ to: '<discard@haraka.local>',
875
+ body: 'X-Custom: test-value\r\n\r\nBody text',
876
+ })
877
+ })
878
+ })
879
+
880
+ // ── requireAuthorized: SMTPS (implicit TLS) ───────────────────────────────
881
+ describe('requireAuthorized_SMTPS', () => {
882
+ beforeEach(async () => setupServer('127.0.0.1:2465'))
883
+ afterEach(async () => tearDownServer())
884
+
885
+ it('rejects non-validated SMTPS connection', async () => {
886
+ // Port 2465 is configured as SMTPS with requireAuthorized.
887
+ // In TLSv1.3 the handshake completes (secureConnect fires), then the server
888
+ // sends a post-handshake "certificate required" alert as a socket error.
889
+ const err = await new Promise((resolve) => {
890
+ const socket = tls.connect({
891
+ host: '127.0.0.1',
892
+ port: 2465,
893
+ rejectUnauthorized: false,
894
+ })
895
+ socket.on('error', resolve)
896
+ // secureConnect may fire before the post-handshake alert; keep waiting.
897
+ socket.on('secureConnect', () => {})
898
+ setTimeout(() => {
899
+ socket.destroy()
900
+ resolve(new Error('timeout'))
901
+ }, 3000)
902
+ })
903
+ assert.ok(
904
+ /socket hang up|disconnected before secure TLS|alert certificate required/.test(err.message),
905
+ `unexpected error: ${err.message}`,
906
+ )
907
+ })
908
+ })
909
+
910
+ // ── requireAuthorized: STARTTLS ───────────────────────────────────────────
911
+ describe('requireAuthorized_STARTTLS', () => {
912
+ beforeEach(async () => setupServer('127.0.0.1:2587'))
913
+ afterEach(async () => tearDownServer())
914
+
915
+ it('rejects non-validated STARTTLS connection', async () => {
916
+ // Port 2587 is plain SMTP; requireAuthorized enforces mutual TLS on STARTTLS upgrade.
917
+ // In TLSv1.3 secureConnect fires first, then the server sends a post-handshake
918
+ // "certificate required" alert. Use raw sockets to observe the TLS error.
919
+ // (smtp_client's upgrade path silently swallows the post-upgrade error.)
920
+ const err = await new Promise((resolve) => {
921
+ const sock = net.connect({ host: '127.0.0.1', port: 2587 })
922
+ let state = 'greeting'
923
+ let buf = ''
924
+ sock.on('data', (d) => {
925
+ buf += d.toString()
926
+ for (const line of buf.split('\r\n').slice(0, -1)) {
927
+ buf = buf.slice(line.length + 2)
928
+ if (line[3] === '-') continue // multi-line continuation
929
+ if (state === 'greeting') {
930
+ sock.write('EHLO test\r\n')
931
+ state = 'ehlo'
932
+ } else if (state === 'ehlo') {
933
+ sock.write('STARTTLS\r\n')
934
+ state = 'starttls'
935
+ } else if (state === 'starttls') {
936
+ state = 'tls'
937
+ const cleartext = tls.connect({ socket: sock, rejectUnauthorized: false })
938
+ cleartext.on('secureConnect', () => {})
939
+ cleartext.on('error', resolve)
940
+ cleartext.on('close', () => resolve(new Error('closed without error')))
941
+ }
942
+ }
943
+ })
944
+ sock.on('error', resolve)
945
+ setTimeout(() => resolve(new Error('timeout')), 3000)
946
+ })
947
+ assert.ok(
948
+ /alert certificate required|socket hang up|disconnected/.test(err.message),
949
+ `unexpected error: ${err.message}`,
950
+ )
951
+ })
952
+ })
953
+ })
954
+
955
+ describe('_graceful (cluster restart)', () => {
956
+ it('actually disconnects workers (queued thunks are invoked)', async () => {
957
+ const cluster = require('node:cluster')
958
+ const Server = require('../server')
959
+ Server.cfg = Server.cfg || { main: {} }
960
+ Server.cfg.main = Server.cfg.main || {}
961
+ Server.cfg.main.force_shutdown_timeout = 1
962
+
963
+ const saved = {
964
+ cluster: Server.cluster,
965
+ workers: cluster.workers,
966
+ fork: cluster.fork,
967
+ rmAll: cluster.removeAllListeners,
968
+ }
969
+
970
+ let disconnected = 0
971
+ const mkWorker = () => ({
972
+ _cbs: {},
973
+ send() {},
974
+ kill() {},
975
+ once(ev, cb) {
976
+ ;(this._cbs[ev] ||= []).push(cb)
977
+ },
978
+ on(ev, cb) {
979
+ ;(this._cbs[ev] ||= []).push(cb)
980
+ },
981
+ _fire(ev) {
982
+ for (const cb of this._cbs[ev] || []) cb()
983
+ },
984
+ disconnect() {
985
+ disconnected++
986
+ setImmediate(() => {
987
+ this._fire('disconnect')
988
+ setImmediate(() => this._fire('exit'))
989
+ })
990
+ },
991
+ })
992
+
993
+ cluster.workers = { 1: mkWorker() }
994
+ cluster.removeAllListeners = () => {}
995
+ cluster.fork = () => {
996
+ const nw = mkWorker()
997
+ setImmediate(() => nw._fire('listening'))
998
+ return nw
999
+ }
1000
+ Server.cluster = cluster
1001
+
1002
+ try {
1003
+ await Server._graceful()
1004
+ assert.equal(disconnected, 1, 'worker.disconnect() was invoked')
1005
+ } finally {
1006
+ Server.cluster = saved.cluster
1007
+ cluster.workers = saved.workers
1008
+ cluster.fork = saved.fork
1009
+ cluster.removeAllListeners = saved.rmAll
1010
+ }
1011
+ })
1012
+ })