haraka 0.0.33 → 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 (309) 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 +1894 -0
  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 +22 -0
  13. package/Plugins.md +227 -0
  14. package/README.md +119 -4
  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 +99 -4
  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
@@ -0,0 +1,1023 @@
1
+ 'use strict'
2
+
3
+ const { describe, it, beforeEach, afterEach } = require('node:test')
4
+ const assert = require('node:assert/strict')
5
+ const { EventEmitter } = require('node:events')
6
+ const path = require('node:path')
7
+
8
+ const { Address } = require('../../../address')
9
+ const { makeConnection, makePlugin } = require('haraka-test-fixtures')
10
+ const Notes = require('haraka-notes')
11
+
12
+ // Haraka result codes (haraka-constants)
13
+ const OK = 906
14
+ const DENY = 902
15
+ const DENYSOFT = 903
16
+
17
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
18
+
19
+ // repo `test/` dir (holds config/), independent of cwd
20
+ const TEST_DIR = path.resolve(__dirname, '../..')
21
+
22
+ function makeHmail(notes = {}) {
23
+ const n = new Notes()
24
+ for (const [k, v] of Object.entries(notes)) n.set(k, v)
25
+ return { todo: { notes: n } }
26
+ }
27
+
28
+ /** Mock SMTPClient returned by get_client_plugin stubs. */
29
+ class MockSMTPClient extends EventEmitter {
30
+ constructor() {
31
+ super()
32
+ this.smtputf8 = false
33
+ this.response = ['250 OK']
34
+ this.next = null
35
+ this.commands = []
36
+ }
37
+
38
+ call_next(code, msg) {
39
+ if (this.next) {
40
+ const n = this.next
41
+ delete this.next
42
+ n(code, msg)
43
+ }
44
+ }
45
+
46
+ release() {
47
+ this.released = true
48
+ }
49
+
50
+ is_dead_sender() {
51
+ return false
52
+ }
53
+
54
+ send_command(cmd, data) {
55
+ this.commands.push(data !== undefined ? `${cmd} ${data}` : cmd)
56
+ }
57
+
58
+ start_data() {
59
+ this.started = true
60
+ }
61
+ }
62
+
63
+ // Temporarily replace smtp_client_mod.get_client_plugin for queue_forward tests
64
+ const smtp_client_mod = require('../../../smtp_client')
65
+
66
+ function stubGetClientPlugin(factory) {
67
+ const orig = smtp_client_mod.get_client_plugin
68
+ smtp_client_mod.get_client_plugin = factory
69
+ return () => {
70
+ smtp_client_mod.get_client_plugin = orig
71
+ }
72
+ }
73
+
74
+ // ─── register ────────────────────────────────────────────────────────────────
75
+
76
+ describe('smtp_forward register', () => {
77
+ it('registers the queue hook', () => {
78
+ const plugin = makePlugin('queue/smtp_forward', { configDir: TEST_DIR })
79
+ assert.ok(plugin.hooks.queue)
80
+ })
81
+
82
+ it('registers the get_mx hook', () => {
83
+ const plugin = makePlugin('queue/smtp_forward', { configDir: TEST_DIR })
84
+ assert.ok(plugin.hooks.get_mx)
85
+ })
86
+
87
+ it('registers check_sender hook when check_sender=true', () => {
88
+ const plugin = makePlugin('queue/smtp_forward', { register: false, configDir: TEST_DIR })
89
+ plugin.load_smtp_forward_ini = function () {
90
+ this.cfg = {
91
+ main: { check_sender: true, check_recipient: true, enable_outbound: true, host: 'localhost', port: 25 },
92
+ }
93
+ }
94
+ plugin.register()
95
+ assert.ok(plugin.hooks.mail)
96
+ })
97
+
98
+ it('registers check_recipient hook when check_recipient=true', () => {
99
+ const plugin = makePlugin('queue/smtp_forward', { register: false, configDir: TEST_DIR })
100
+ plugin.load_smtp_forward_ini = function () {
101
+ this.cfg = { main: { check_recipient: true, host: 'localhost', port: 25 } }
102
+ }
103
+ plugin.register()
104
+ assert.ok(plugin.hooks.rcpt)
105
+ })
106
+
107
+ it('registers queue_outbound hook when enable_outbound=true', () => {
108
+ const plugin = makePlugin('queue/smtp_forward', { register: false, configDir: TEST_DIR })
109
+ plugin.load_smtp_forward_ini = function () {
110
+ this.cfg = { main: { enable_outbound: true, host: 'localhost', port: 25 } }
111
+ }
112
+ plugin.register()
113
+ assert.ok(plugin.hooks.queue_outbound)
114
+ })
115
+
116
+ it('aborts registration when load_errs is non-empty', () => {
117
+ const plugin = makePlugin('queue/smtp_forward', { register: false, configDir: TEST_DIR })
118
+ plugin.load_smtp_forward_ini = function () {
119
+ this.cfg = { main: {} }
120
+ this.load_errs.push('simulated error')
121
+ }
122
+ plugin.register()
123
+ assert.equal(plugin.hooks.queue, undefined)
124
+ })
125
+
126
+ it('populates tls_options after register (no-op shape)', () => {
127
+ const plugin = makePlugin('queue/smtp_forward', { configDir: TEST_DIR })
128
+ assert.ok(plugin.tls_options, 'tls_options should be populated after register')
129
+ assert.ok(Array.isArray(plugin.tls_options.no_tls_hosts))
130
+ assert.ok(Array.isArray(plugin.tls_options.force_tls_hosts))
131
+ })
132
+ })
133
+
134
+ // ─── tls_options ─────────────────────────────────────────────────────────────
135
+
136
+ describe('smtp_forward tls_options', () => {
137
+ const tls_socket = require('../../../tls_socket')
138
+ let origTlsConfig, origTlsCfg
139
+
140
+ beforeEach(() => {
141
+ // Redirect tls_socket.config at test/config so tls.ini fixtures load.
142
+ origTlsConfig = tls_socket.config
143
+ origTlsCfg = tls_socket.cfg
144
+ tls_socket.config = require('haraka-config').module_config(TEST_DIR)
145
+ tls_socket.cfg = undefined
146
+ })
147
+
148
+ afterEach(() => {
149
+ tls_socket.config = origTlsConfig
150
+ tls_socket.cfg = origTlsCfg
151
+ })
152
+
153
+ it('inherits rejectUnauthorized/minVersion/ciphers from tls.ini [main]', () => {
154
+ const plugin = makePlugin('queue/smtp_forward', { configDir: TEST_DIR })
155
+ assert.equal(plugin.tls_options.rejectUnauthorized, false)
156
+ assert.equal(plugin.tls_options.minVersion, 'TLSv1')
157
+ assert.ok(plugin.tls_options.ciphers)
158
+ })
159
+
160
+ it('reload re-derives tls_options', () => {
161
+ const plugin = makePlugin('queue/smtp_forward', { configDir: TEST_DIR })
162
+ const first = plugin.tls_options
163
+ plugin.load_smtp_forward_ini()
164
+ assert.ok(plugin.tls_options)
165
+ assert.notEqual(plugin.tls_options, first, 'reload returns a fresh object')
166
+ assert.equal(plugin.tls_options.rejectUnauthorized, false)
167
+ })
168
+ })
169
+
170
+ // ─── load_smtp_forward_ini ────────────────────────────────────────────────────
171
+
172
+ describe('smtp_forward load_smtp_forward_ini', () => {
173
+ it('loads configuration from ini file', () => {
174
+ const plugin = makePlugin('queue/smtp_forward', { configDir: TEST_DIR })
175
+ assert.ok(plugin.cfg.main)
176
+ assert.equal(plugin.cfg.main.host, 'localhost')
177
+ })
178
+
179
+ it('sets up a reload callback', () => {
180
+ // Calling load_smtp_forward_ini again should not crash
181
+ const plugin = makePlugin('queue/smtp_forward', { configDir: TEST_DIR })
182
+ assert.doesNotThrow(() => plugin.load_smtp_forward_ini())
183
+ assert.ok(plugin.cfg.main)
184
+ })
185
+ })
186
+
187
+ // ─── get_config ───────────────────────────────────────────────────────────────
188
+
189
+ describe('smtp_forward get_config', () => {
190
+ let plugin, connection
191
+
192
+ beforeEach(() => {
193
+ plugin = makePlugin('queue/smtp_forward', { configDir: TEST_DIR })
194
+ plugin.cfg = JSON.parse(JSON.stringify(plugin.cfg))
195
+ connection = makeConnection({ withTxn: true })
196
+ })
197
+
198
+ it('returns main cfg when no transaction', () => {
199
+ connection.transaction = null
200
+ const cfg = plugin.get_config(connection)
201
+ assert.equal(cfg.host, 'localhost')
202
+ })
203
+
204
+ it('returns main cfg when no rcpt_to (no domain_selector set)', () => {
205
+ const cfg = plugin.get_config(connection)
206
+ assert.equal(cfg.host, 'localhost')
207
+ assert.equal(cfg.enable_tls, true)
208
+ })
209
+
210
+ it('returns main cfg for null recipient', () => {
211
+ connection.transaction.rcpt_to.push(new Address('<>'))
212
+ const cfg = plugin.get_config(connection)
213
+ assert.equal(cfg.host, 'localhost')
214
+ })
215
+
216
+ it('returns main cfg for unknown recipient domain', () => {
217
+ connection.transaction.rcpt_to.push(new Address('<matt@example.com>'))
218
+ const cfg = plugin.get_config(connection)
219
+ assert.equal(cfg.host, 'localhost')
220
+ })
221
+
222
+ it('returns domain config for known recipient domain', () => {
223
+ connection.transaction.rcpt_to.push(new Address('<matt@test.com>'))
224
+ const cfg = plugin.get_config(connection)
225
+ assert.equal(cfg.host, '1.2.3.4')
226
+ assert.equal(cfg.auth_user, 'postmaster@test.com')
227
+ })
228
+
229
+ it('returns domain config with different TLS setting', () => {
230
+ connection.transaction.rcpt_to.push(new Address('<matt@test1.com>'))
231
+ const cfg = plugin.get_config(connection)
232
+ assert.deepEqual(cfg, { host: '1.2.3.4', enable_tls: false })
233
+ })
234
+
235
+ it('returns main cfg when domain_selector=mail_from but mail_from is null', () => {
236
+ plugin.cfg.main.domain_selector = 'mail_from'
237
+ connection.transaction.mail_from = null
238
+ const cfg = plugin.get_config(connection)
239
+ assert.equal(cfg.host, 'localhost')
240
+ })
241
+
242
+ it('returns main cfg when domain_selector=mail_from and null sender', () => {
243
+ plugin.cfg.main.domain_selector = 'mail_from'
244
+ connection.transaction.mail_from = new Address('<>')
245
+ const cfg = plugin.get_config(connection)
246
+ assert.equal(cfg.host, 'localhost')
247
+ })
248
+
249
+ it('returns domain config for mail_from domain_selector', () => {
250
+ plugin.cfg.main.domain_selector = 'mail_from'
251
+ connection.transaction.mail_from = new Address('<matt@test2.com>')
252
+ const cfg = plugin.get_config(connection)
253
+ assert.equal(cfg.host, '2.3.4.5')
254
+ })
255
+
256
+ it('returns config by full email address when present', () => {
257
+ plugin.cfg.main.domain_selector = 'mail_from'
258
+ plugin.cfg['specific@test.com'] = { host: 'specific.example.com' }
259
+ connection.transaction.mail_from = new Address('<specific@test.com>')
260
+ const cfg = plugin.get_config(connection)
261
+ assert.equal(cfg.host, 'specific.example.com')
262
+ })
263
+ })
264
+
265
+ // ─── check_sender ────────────────────────────────────────────────────────────
266
+
267
+ describe('smtp_forward check_sender', () => {
268
+ let plugin, connection
269
+
270
+ beforeEach(() => {
271
+ plugin = makePlugin('queue/smtp_forward', { configDir: TEST_DIR })
272
+ connection = makeConnection({ withTxn: true })
273
+ })
274
+
275
+ it('returns without calling next when no transaction', () => {
276
+ connection.transaction = null
277
+ let nextCalled = false
278
+ plugin.check_sender(
279
+ () => {
280
+ nextCalled = true
281
+ },
282
+ connection,
283
+ [new Address('<a@test.com>')],
284
+ )
285
+ assert.equal(nextCalled, false)
286
+ })
287
+
288
+ it('skips and calls next() for null/empty sender', () => {
289
+ let code
290
+ plugin.check_sender(
291
+ (c) => {
292
+ code = c
293
+ },
294
+ connection,
295
+ [new Address('<>')],
296
+ )
297
+ assert.equal(code, undefined) // next() with no args
298
+ })
299
+
300
+ it('calls next() when sender domain not in config', () => {
301
+ let called = false
302
+ plugin.check_sender(
303
+ () => {
304
+ called = true
305
+ },
306
+ connection,
307
+ [new Address('<user@unknown.com>')],
308
+ )
309
+ assert.ok(called)
310
+ })
311
+
312
+ it('denies spoofed MAIL FROM (domain in cfg, not relaying)', () => {
313
+ connection.relaying = false
314
+ let code
315
+ plugin.check_sender(
316
+ (c) => {
317
+ code = c
318
+ },
319
+ connection,
320
+ [new Address('<user@test.com>')],
321
+ )
322
+ assert.equal(code, DENY)
323
+ const r = connection.transaction.results.get(plugin)
324
+ assert.ok(r.fail.includes('mail_from!spoof'))
325
+ })
326
+
327
+ it('passes and calls next() when relaying from local domain', () => {
328
+ connection.relaying = true
329
+ let code
330
+ plugin.check_sender(
331
+ (c) => {
332
+ code = c
333
+ },
334
+ connection,
335
+ [new Address('<user@test.com>')],
336
+ )
337
+ assert.equal(code, undefined)
338
+ assert.ok(connection.transaction.notes.local_sender)
339
+ const r = connection.transaction.results.get(plugin)
340
+ assert.ok(r.pass.includes('mail_from'))
341
+ })
342
+ })
343
+
344
+ // ─── set_queue ────────────────────────────────────────────────────────────────
345
+
346
+ describe('smtp_forward set_queue', () => {
347
+ let plugin, connection
348
+
349
+ beforeEach(() => {
350
+ plugin = makePlugin('queue/smtp_forward', { configDir: TEST_DIR })
351
+ plugin.cfg = JSON.parse(JSON.stringify(plugin.cfg))
352
+ connection = makeConnection({ withTxn: true })
353
+ })
354
+
355
+ it('returns false when transaction has no notes (no transaction)', () => {
356
+ connection.transaction = null
357
+ assert.equal(plugin.set_queue(connection, 'smtp_forward', 'test.com'), false)
358
+ })
359
+
360
+ it('sets queue.wants on first call', () => {
361
+ const result = plugin.set_queue(connection, 'smtp_forward', 'test.com')
362
+ assert.equal(result, true)
363
+ assert.equal(connection.transaction.notes.get('queue.wants'), 'smtp_forward')
364
+ })
365
+
366
+ it('sets queue.next_hop when domain has a host', () => {
367
+ plugin.set_queue(connection, 'smtp_forward', 'test.com')
368
+ assert.equal(connection.transaction.notes.get('queue.next_hop'), 'smtp://1.2.3.4')
369
+ })
370
+
371
+ it('does not set next_hop when domain has no host override', () => {
372
+ // test2.com has host=2.3.4.5, so it will set next_hop
373
+ plugin.set_queue(connection, 'smtp_forward', 'test2.com')
374
+ assert.equal(connection.transaction.notes.get('queue.next_hop'), 'smtp://2.3.4.5')
375
+ })
376
+
377
+ it('returns true for undefined domain (no dom_cfg)', () => {
378
+ const result = plugin.set_queue(connection, 'smtp_forward', 'unknown.com')
379
+ assert.equal(result, true)
380
+ assert.equal(connection.transaction.notes.get('queue.wants'), 'smtp_forward')
381
+ })
382
+
383
+ it('returns true when queue already set to same value (no dst_host)', () => {
384
+ connection.transaction.notes.set('queue.wants', 'smtp_forward')
385
+ // unknown.com has no host, so dst_host is just from main (localhost)
386
+ const result = plugin.set_queue(connection, 'smtp_forward', 'unknown.com')
387
+ assert.equal(result, true)
388
+ })
389
+
390
+ it('returns true when next_hop matches existing next_hop', () => {
391
+ connection.transaction.notes.set('queue.wants', 'smtp_forward')
392
+ connection.transaction.notes.set('queue.next_hop', 'smtp://1.2.3.4')
393
+ const result = plugin.set_queue(connection, 'smtp_forward', 'test.com')
394
+ assert.equal(result, true)
395
+ })
396
+
397
+ it('returns true when next_hop already set but no new dst_host', () => {
398
+ connection.transaction.notes.set('queue.wants', 'smtp_forward')
399
+ connection.transaction.notes.set('queue.next_hop', 'smtp://1.2.3.4')
400
+ // unknown.com has no specific host so dst_host comes from main.host='localhost'
401
+ // Actually let's use a domain with no host to test the !dst_host branch
402
+ delete plugin.cfg.main.host
403
+ const result = plugin.set_queue(connection, 'smtp_forward', 'unknown.com')
404
+ assert.equal(result, true)
405
+ })
406
+
407
+ it('returns false when different destination (split transaction)', () => {
408
+ connection.transaction.notes.set('queue.wants', 'smtp_forward')
409
+ connection.transaction.notes.set('queue.next_hop', 'smtp://9.9.9.9')
410
+ // test.com has host=1.2.3.4, which differs from 9.9.9.9
411
+ const result = plugin.set_queue(connection, 'smtp_forward', 'test.com')
412
+ assert.equal(result, false)
413
+ })
414
+
415
+ it('returns false when queue_wanted differs from existing', () => {
416
+ connection.transaction.notes.set('queue.wants', 'outbound')
417
+ const result = plugin.set_queue(connection, 'smtp_forward', 'test.com')
418
+ assert.equal(result, false)
419
+ })
420
+ })
421
+
422
+ // ─── check_recipient ─────────────────────────────────────────────────────────
423
+
424
+ describe('smtp_forward check_recipient', () => {
425
+ let plugin, connection
426
+
427
+ beforeEach(() => {
428
+ plugin = makePlugin('queue/smtp_forward', { configDir: TEST_DIR })
429
+ connection = makeConnection({ withTxn: true })
430
+ })
431
+
432
+ it('returns without calling next when no transaction', () => {
433
+ connection.transaction = null
434
+ let called = false
435
+ plugin.check_recipient(
436
+ () => {
437
+ called = true
438
+ },
439
+ connection,
440
+ [new Address('<a@test.com>')],
441
+ )
442
+ assert.equal(called, false)
443
+ })
444
+
445
+ it('skips and calls next for rcpt with no host', () => {
446
+ let code
447
+ const rcpt = new Address('<>')
448
+ plugin.check_recipient(
449
+ (c) => {
450
+ code = c
451
+ },
452
+ connection,
453
+ [rcpt],
454
+ )
455
+ assert.equal(code, undefined)
456
+ })
457
+
458
+ it('uses outbound queue when relaying as local_sender', () => {
459
+ connection.relaying = true
460
+ connection.transaction.notes.local_sender = true
461
+ let code
462
+ plugin.check_recipient(
463
+ (c) => {
464
+ code = c
465
+ },
466
+ connection,
467
+ [new Address('<user@example.com>')],
468
+ )
469
+ assert.equal(code, OK)
470
+ assert.equal(connection.transaction.notes.get('queue.wants'), 'outbound')
471
+ })
472
+
473
+ it('accepts rcpt for a configured domain', () => {
474
+ let code
475
+ plugin.check_recipient(
476
+ (c) => {
477
+ code = c
478
+ },
479
+ connection,
480
+ [new Address('<user@test.com>')],
481
+ )
482
+ assert.equal(code, OK)
483
+ })
484
+
485
+ it('denies softly when set_queue fails for configured domain (split transaction)', () => {
486
+ // First call sets queue.wants to smtp_forward for test.com
487
+ plugin.set_queue(connection, 'smtp_forward', 'test.com')
488
+ // Now change the next_hop so the second call conflicts
489
+ connection.transaction.notes.set('queue.next_hop', 'smtp://9.9.9.9')
490
+ let code
491
+ plugin.check_recipient(
492
+ (c) => {
493
+ code = c
494
+ },
495
+ connection,
496
+ [new Address('<user@test.com>')],
497
+ )
498
+ assert.equal(code, DENYSOFT)
499
+ })
500
+
501
+ it('passes through for unconfigured domain (no route)', () => {
502
+ let code
503
+ plugin.check_recipient(
504
+ (c) => {
505
+ code = c
506
+ },
507
+ connection,
508
+ [new Address('<user@unknown.com>')],
509
+ )
510
+ assert.equal(code, undefined) // next() with no args
511
+ })
512
+ })
513
+
514
+ // ─── auth ─────────────────────────────────────────────────────────────────────
515
+
516
+ describe('smtp_forward auth', () => {
517
+ let plugin, connection, smtp_client
518
+
519
+ beforeEach(() => {
520
+ plugin = makePlugin('queue/smtp_forward', { configDir: TEST_DIR })
521
+ connection = makeConnection({ withTxn: true })
522
+ smtp_client = new MockSMTPClient()
523
+ })
524
+
525
+ it('does nothing when smtp_client.secured is pending (false)', () => {
526
+ smtp_client.secured = false
527
+ const cfg = { auth_type: 'plain', auth_user: 'user', auth_pass: 'pass', host: 'relay', port: 25 }
528
+ plugin.auth(cfg, connection, smtp_client)
529
+ smtp_client.emit('capabilities')
530
+ assert.equal(smtp_client.commands.length, 0) // AUTH not sent
531
+ })
532
+
533
+ it('sends AUTH PLAIN credentials when auth_type=plain', () => {
534
+ const cfg = { auth_type: 'plain', auth_user: 'testuser', auth_pass: 'testpass', host: 'relay', port: 25 }
535
+ plugin.auth(cfg, connection, smtp_client)
536
+ smtp_client.emit('capabilities')
537
+ assert.ok(smtp_client.commands.some((c) => /^AUTH PLAIN/.test(c)))
538
+ })
539
+
540
+ it('AUTH PLAIN base64 encodes \\0user\\0pass', () => {
541
+ const cfg = { auth_type: 'plain', auth_user: 'u', auth_pass: 'p', host: 'relay', port: 25 }
542
+ plugin.auth(cfg, connection, smtp_client)
543
+ smtp_client.emit('capabilities')
544
+ const authCmd = smtp_client.commands.find((c) => /^AUTH PLAIN/.test(c))
545
+ assert.ok(authCmd)
546
+ const encoded = authCmd.split(' ')[2]
547
+ assert.equal(Buffer.from(encoded, 'base64').toString(), '\0u\0p')
548
+ })
549
+
550
+ it('sends AUTH LOGIN and sets authenticating=true when auth_type=login', () => {
551
+ const cfg = { auth_type: 'login', auth_user: 'testuser', auth_pass: 'testpass', host: 'relay', port: 25 }
552
+ plugin.auth(cfg, connection, smtp_client)
553
+ smtp_client.emit('capabilities')
554
+ assert.ok(smtp_client.commands.includes('AUTH LOGIN'))
555
+ assert.equal(smtp_client.authenticating, true)
556
+ assert.equal(smtp_client.authenticated, false)
557
+ })
558
+
559
+ it('login: responds to auth_username with base64 username', () => {
560
+ const cfg = { auth_type: 'login', auth_user: 'testuser', auth_pass: 'testpass', host: 'relay', port: 25 }
561
+ plugin.auth(cfg, connection, smtp_client)
562
+ smtp_client.emit('capabilities')
563
+ smtp_client.emit('auth_username')
564
+ assert.equal(smtp_client.commands.at(-1), Buffer.from('testuser').toString('base64'))
565
+ })
566
+
567
+ it('login: responds to auth_password with base64 password', () => {
568
+ const cfg = { auth_type: 'login', auth_user: 'testuser', auth_pass: 'testpass', host: 'relay', port: 25 }
569
+ plugin.auth(cfg, connection, smtp_client)
570
+ smtp_client.emit('capabilities')
571
+ smtp_client.emit('auth_password')
572
+ assert.equal(smtp_client.commands.at(-1), Buffer.from('testpass').toString('base64'))
573
+ })
574
+
575
+ it('skips AUTH when secured is undefined (not pending)', () => {
576
+ // secured is undefined → no early return, AUTH PLAIN is sent
577
+ const cfg = { auth_type: 'plain', auth_user: 'u', auth_pass: 'p', host: 'relay', port: 25 }
578
+ delete smtp_client.secured
579
+ plugin.auth(cfg, connection, smtp_client)
580
+ smtp_client.emit('capabilities')
581
+ assert.ok(smtp_client.commands.some((c) => /^AUTH PLAIN/.test(c)))
582
+ })
583
+ })
584
+
585
+ // ─── forward_enabled ──────────────────────────────────────────────────────────
586
+
587
+ describe('smtp_forward forward_enabled', () => {
588
+ let plugin, connection
589
+
590
+ beforeEach(() => {
591
+ plugin = makePlugin('queue/smtp_forward', { configDir: TEST_DIR })
592
+ plugin.cfg = JSON.parse(JSON.stringify(plugin.cfg))
593
+ connection = makeConnection({ withTxn: true })
594
+ })
595
+
596
+ it('returns false when queue.wants is set to a non smtp_forward value', () => {
597
+ connection.transaction.notes.set('queue.wants', 'outbound')
598
+ assert.equal(plugin.forward_enabled(connection, plugin.cfg.main), false)
599
+ })
600
+
601
+ it('returns false when relaying and outbound is disabled', () => {
602
+ connection.relaying = true
603
+ // enable_outbound is false by default in test config
604
+ assert.equal(plugin.forward_enabled(connection, plugin.cfg.main), false)
605
+ })
606
+
607
+ it('returns true when queue.wants is smtp_forward', () => {
608
+ connection.transaction.notes.set('queue.wants', 'smtp_forward')
609
+ assert.equal(plugin.forward_enabled(connection, plugin.cfg.main), true)
610
+ })
611
+
612
+ it('returns true when not relaying (even if outbound disabled)', () => {
613
+ connection.relaying = false
614
+ assert.equal(plugin.forward_enabled(connection, plugin.cfg.main), true)
615
+ })
616
+
617
+ it('returns true when relaying and outbound is enabled', () => {
618
+ connection.relaying = true
619
+ plugin.cfg.main.enable_outbound = true
620
+ assert.equal(plugin.forward_enabled(connection, plugin.cfg.main), true)
621
+ })
622
+ })
623
+
624
+ // ─── queue_forward ────────────────────────────────────────────────────────────
625
+
626
+ describe('smtp_forward queue_forward', () => {
627
+ let plugin, connection, restore
628
+
629
+ beforeEach(() => {
630
+ plugin = makePlugin('queue/smtp_forward', { configDir: TEST_DIR })
631
+ plugin.cfg = JSON.parse(JSON.stringify(plugin.cfg))
632
+ connection = makeConnection({
633
+ mailFrom: '<sender@example.com>',
634
+ rcptTo: ['<rcpt@example.com>'],
635
+ })
636
+ connection.relaying = false
637
+ })
638
+
639
+ afterEach(() => {
640
+ if (restore) {
641
+ restore()
642
+ restore = null
643
+ }
644
+ })
645
+
646
+ it('returns without calling next when remote.closed', () => {
647
+ connection.remote.closed = true
648
+ let called = false
649
+ plugin.queue_forward(() => {
650
+ called = true
651
+ }, connection)
652
+ assert.equal(called, false)
653
+ })
654
+
655
+ it('calls next() when forward_enabled returns false', (t, done) => {
656
+ connection.relaying = true // outbound disabled → forward_enabled=false
657
+ plugin.queue_forward((code) => {
658
+ assert.equal(code, undefined)
659
+ done()
660
+ }, connection)
661
+ })
662
+
663
+ it('forwards mail: mail event triggers first RCPT', (t, done) => {
664
+ const client = new MockSMTPClient()
665
+ restore = stubGetClientPlugin((plug, conn, cfg, cb) => cb(null, client))
666
+
667
+ plugin.queue_forward(() => {}, connection)
668
+ client.emit('mail')
669
+
670
+ assert.ok(client.commands.some((c) => /^RCPT TO:/.test(c)))
671
+ done()
672
+ })
673
+
674
+ it('sends DATA after last RCPT TO', (t, done) => {
675
+ const client = new MockSMTPClient()
676
+ restore = stubGetClientPlugin((plug, conn, cfg, cb) => cb(null, client))
677
+
678
+ plugin.queue_forward(() => {}, connection)
679
+ client.emit('mail') // sends RCPT TO for index 0
680
+ client.emit('rcpt') // one_message_per_rcpt=true, sends DATA
681
+
682
+ // wait for the DATA command
683
+ assert.ok(client.commands.some((c) => c === 'DATA' || c.includes('DATA')))
684
+ done()
685
+ })
686
+
687
+ it('data event calls start_data with message_stream', (t, done) => {
688
+ const client = new MockSMTPClient()
689
+ restore = stubGetClientPlugin((plug, conn, cfg, cb) => cb(null, client))
690
+
691
+ plugin.queue_forward(() => {}, connection)
692
+ client.emit('mail')
693
+ client.emit('rcpt') // sends DATA (one_message_per_rcpt)
694
+ client.emit('data')
695
+
696
+ assert.ok(client.started)
697
+ done()
698
+ })
699
+
700
+ it('dot event calls next(OK) and releases when all rcpts done', (t, done) => {
701
+ const client = new MockSMTPClient()
702
+ restore = stubGetClientPlugin((plug, conn, cfg, cb) => cb(null, client))
703
+
704
+ let gotCode
705
+ plugin.queue_forward((code) => {
706
+ gotCode = code
707
+ }, connection)
708
+
709
+ client.emit('mail')
710
+ client.emit('rcpt')
711
+ client.emit('data')
712
+ client.emit('dot')
713
+
714
+ // release() is called after call_next() in the dot handler
715
+ assert.equal(gotCode, OK)
716
+ assert.ok(client.released)
717
+ done()
718
+ })
719
+
720
+ it('dot event sends RSET when more rcpts remain (multi-rcpt, one_message_per_rcpt)', (t, done) => {
721
+ connection.transaction.rcpt_to = [new Address('<a@example.com>'), new Address('<b@example.com>')]
722
+ const client = new MockSMTPClient()
723
+ restore = stubGetClientPlugin((plug, conn, cfg, cb) => cb(null, client))
724
+
725
+ plugin.queue_forward(() => {}, connection)
726
+ client.emit('mail') // sends RCPT TO for index 0
727
+ client.emit('rcpt') // one_message_per_rcpt → sends DATA
728
+ client.emit('data')
729
+ client.commands = [] // clear to observe next commands
730
+ client.emit('dot') // more rcpts remain → RSET
731
+
732
+ assert.ok(client.commands.includes('RSET'))
733
+ done()
734
+ })
735
+
736
+ it('rset event sends MAIL FROM', (t, done) => {
737
+ const client = new MockSMTPClient()
738
+ restore = stubGetClientPlugin((plug, conn, cfg, cb) => cb(null, client))
739
+
740
+ plugin.queue_forward(() => {}, connection)
741
+ client.commands = []
742
+ client.emit('rset')
743
+
744
+ assert.ok(client.commands.some((c) => /^MAIL FROM:/.test(c)))
745
+ done()
746
+ })
747
+
748
+ it('bad_code 5xx emits DENY and releases', (t, done) => {
749
+ const client = new MockSMTPClient()
750
+ restore = stubGetClientPlugin((plug, conn, cfg, cb) => cb(null, client))
751
+
752
+ let gotCode
753
+ plugin.queue_forward((code) => {
754
+ gotCode = code
755
+ }, connection)
756
+
757
+ client.emit('bad_code', '550', 'User unknown')
758
+
759
+ // release() is called after call_next() in the bad_code handler
760
+ assert.equal(gotCode, DENY)
761
+ assert.ok(client.released)
762
+ done()
763
+ })
764
+
765
+ it('bad_code 4xx emits DENYSOFT and releases', (t, done) => {
766
+ const client = new MockSMTPClient()
767
+ restore = stubGetClientPlugin((plug, conn, cfg, cb) => cb(null, client))
768
+
769
+ plugin.queue_forward((code) => {
770
+ assert.equal(code, DENYSOFT)
771
+ done()
772
+ }, connection)
773
+
774
+ client.emit('bad_code', '421', 'Service unavailable')
775
+ })
776
+
777
+ it('dead_sender: adds err result and skips forwarding', (t, done) => {
778
+ const client = new MockSMTPClient()
779
+ client.is_dead_sender = () => true
780
+ restore = stubGetClientPlugin((plug, conn, cfg, cb) => cb(null, client))
781
+
782
+ plugin.queue_forward(() => {}, connection)
783
+ client.emit('mail')
784
+
785
+ const r = connection.transaction.results.get(plugin)
786
+ assert.ok(r.err.some((e) => /dead sender/.test(e)))
787
+ done()
788
+ })
789
+
790
+ it('calls plugin.auth when auth_user is configured in cfg', (t, done) => {
791
+ const client = new MockSMTPClient()
792
+ restore = stubGetClientPlugin((plug, conn, cfg, cb) => cb(null, client))
793
+
794
+ // point the connection to test.com domain which has auth_user in the ini
795
+ connection.transaction.rcpt_to = [new Address('<user@test.com>')]
796
+
797
+ let authCalled = false
798
+ const origAuth = plugin.auth
799
+ plugin.auth = () => {
800
+ authCalled = true
801
+ }
802
+ plugin.queue_forward(() => {}, connection)
803
+ plugin.auth = origAuth
804
+
805
+ assert.ok(authCalled)
806
+ done()
807
+ })
808
+
809
+ it('uses forwarding_host_pool when configured', (t, done) => {
810
+ const client = new MockSMTPClient()
811
+ let capturedCfg
812
+ restore = stubGetClientPlugin((plug, conn, cfg, cb) => {
813
+ capturedCfg = cfg
814
+ cb(null, client)
815
+ })
816
+
817
+ plugin.cfg.main.forwarding_host_pool = '10.0.0.1:25'
818
+ delete plugin.cfg.main.host
819
+ plugin.queue_forward(() => {}, connection)
820
+
821
+ assert.ok(capturedCfg.forwarding_host_pool)
822
+ done()
823
+ })
824
+ })
825
+
826
+ // ─── get_mx_next_hop ─────────────────────────────────────────────────────────
827
+
828
+ describe('smtp_forward get_mx_next_hop', () => {
829
+ it('parses smtp URL with port', () => {
830
+ const plugin = makePlugin('queue/smtp_forward', { configDir: TEST_DIR })
831
+ const mx_val = plugin.get_mx_next_hop('smtp://10.0.0.1:587')
832
+ assert.equal(mx_val.exchange, '10.0.0.1')
833
+ assert.equal(mx_val.port, '587')
834
+ assert.equal(mx_val.priority, 0)
835
+ })
836
+
837
+ it('defaults port to 25 for smtp without explicit port', () => {
838
+ const plugin = makePlugin('queue/smtp_forward', { configDir: TEST_DIR })
839
+ const mx_val = plugin.get_mx_next_hop('smtp://10.0.0.1')
840
+ assert.equal(mx_val.port, 25)
841
+ })
842
+
843
+ it('parses lmtp URL and sets using_lmtp=true with port 24', () => {
844
+ const plugin = makePlugin('queue/smtp_forward', { configDir: TEST_DIR })
845
+ const mx_val = plugin.get_mx_next_hop('lmtp://10.0.0.2')
846
+ assert.equal(mx_val.using_lmtp, true)
847
+ assert.equal(mx_val.port, 24)
848
+ })
849
+
850
+ it('extracts auth credentials from URL', () => {
851
+ const plugin = makePlugin('queue/smtp_forward', { configDir: TEST_DIR })
852
+ const mx_val = plugin.get_mx_next_hop('smtp://user:secret@10.0.0.1:25')
853
+ assert.equal(mx_val.auth_type, 'plain')
854
+ assert.equal(mx_val.auth_user, 'user')
855
+ assert.equal(mx_val.auth_pass, 'secret')
856
+ })
857
+ })
858
+
859
+ // ─── get_mx ───────────────────────────────────────────────────────────────────
860
+
861
+ describe('smtp_forward get_mx', () => {
862
+ let plugin, hmail
863
+
864
+ beforeEach(() => {
865
+ plugin = makePlugin('queue/smtp_forward', { configDir: TEST_DIR })
866
+ plugin.cfg = JSON.parse(JSON.stringify(plugin.cfg))
867
+ hmail = makeHmail()
868
+ })
869
+
870
+ it('returns no route for undefined domains', (t, done) => {
871
+ plugin.get_mx(
872
+ (code, mx) => {
873
+ assert.equal(code, undefined)
874
+ assert.equal(mx, undefined)
875
+ done()
876
+ },
877
+ hmail,
878
+ 'undefined.com',
879
+ )
880
+ })
881
+
882
+ it('returns no route when queue.wants is not smtp_forward or outbound', (t, done) => {
883
+ hmail.todo.notes.set('queue.wants', 'some_other_queue')
884
+ plugin.get_mx(
885
+ (code) => {
886
+ assert.equal(code, undefined)
887
+ done()
888
+ },
889
+ hmail,
890
+ 'test.com',
891
+ )
892
+ })
893
+
894
+ it('returns route from next_hop URL when queue.wants=smtp_forward', (t, done) => {
895
+ hmail.todo.notes.set('queue.wants', 'smtp_forward')
896
+ hmail.todo.notes.set('queue.next_hop', 'smtp://4.3.2.1:465')
897
+ plugin.get_mx(
898
+ (code, mx) => {
899
+ assert.equal(code, OK)
900
+ assert.equal(mx.exchange, '4.3.2.1')
901
+ assert.equal(mx.port, '465')
902
+ done()
903
+ },
904
+ hmail,
905
+ 'anything.com',
906
+ )
907
+ })
908
+
909
+ it('returns route for configured domain', (t, done) => {
910
+ plugin.get_mx(
911
+ (code, mx) => {
912
+ assert.equal(code, OK)
913
+ assert.equal(mx.exchange, '1.2.3.4')
914
+ assert.equal(mx.port, 2555)
915
+ assert.equal(mx.auth_user, 'postmaster@test.com')
916
+ assert.equal(mx.auth_pass, 'superDuperSecret')
917
+ done()
918
+ },
919
+ hmail,
920
+ 'test.com',
921
+ )
922
+ })
923
+
924
+ it('returns no route (DNS MX) for unconfigured domain when queue.wants=outbound', (t, done) => {
925
+ hmail.todo.notes.set('queue.wants', 'outbound')
926
+ plugin.get_mx(
927
+ (code) => {
928
+ assert.equal(code, undefined)
929
+ done()
930
+ },
931
+ hmail,
932
+ 'notconfigured.com',
933
+ )
934
+ })
935
+
936
+ it('uses lmtp URL and sets using_lmtp when next_hop is lmtp', (t, done) => {
937
+ hmail.todo.notes.set('queue.wants', 'smtp_forward')
938
+ hmail.todo.notes.set('queue.next_hop', 'lmtp://4.3.2.1')
939
+ plugin.get_mx(
940
+ (code, mx) => {
941
+ assert.equal(code, OK)
942
+ assert.equal(mx.using_lmtp, true)
943
+ assert.equal(mx.port, 24)
944
+ done()
945
+ },
946
+ hmail,
947
+ 'anywhere.com',
948
+ )
949
+ })
950
+
951
+ it('uses mail_from host when domain_selector=mail_from', (t, done) => {
952
+ plugin.cfg.main.domain_selector = 'mail_from'
953
+ hmail.todo.mail_from = new Address('<sender@test.com>')
954
+ plugin.get_mx(
955
+ (code, mx) => {
956
+ assert.equal(code, OK)
957
+ assert.equal(mx.exchange, '1.2.3.4')
958
+ done()
959
+ },
960
+ hmail,
961
+ 'anything.com',
962
+ )
963
+ })
964
+
965
+ it('applies mx_opts from domain config', (t, done) => {
966
+ plugin.cfg['test.com'].bind = '192.168.1.1'
967
+ plugin.cfg['test.com'].bind_helo = 'relay.example.com'
968
+ plugin.get_mx(
969
+ (code, mx) => {
970
+ assert.equal(code, OK)
971
+ assert.equal(mx.bind, '192.168.1.1')
972
+ assert.equal(mx.bind_helo, 'relay.example.com')
973
+ done()
974
+ },
975
+ hmail,
976
+ 'test.com',
977
+ )
978
+ })
979
+ })
980
+
981
+ // ─── is_outbound_enabled ──────────────────────────────────────────────────────
982
+
983
+ describe('smtp_forward is_outbound_enabled', () => {
984
+ let plugin, connection
985
+
986
+ beforeEach(() => {
987
+ plugin = makePlugin('queue/smtp_forward', { configDir: TEST_DIR })
988
+ plugin.cfg = JSON.parse(JSON.stringify(plugin.cfg))
989
+ connection = makeConnection({ withTxn: true })
990
+ })
991
+
992
+ it('enable_outbound is false by default (global)', () => {
993
+ assert.equal(plugin.is_outbound_enabled(plugin.cfg), false)
994
+ })
995
+
996
+ it('per-domain enable_outbound is false by default', () => {
997
+ connection.transaction.rcpt_to = [new Address('<postmaster@test.com>')]
998
+ const cfg = plugin.get_config(connection)
999
+ assert.equal(plugin.is_outbound_enabled(cfg), false)
1000
+ })
1001
+
1002
+ it('per-domain enable_outbound can be set to true', () => {
1003
+ plugin.cfg['test.com'].enable_outbound = true
1004
+ connection.transaction.rcpt_to = [new Address('<postmaster@test.com>')]
1005
+ const cfg = plugin.get_config(connection)
1006
+ assert.equal(plugin.is_outbound_enabled(cfg), true)
1007
+ })
1008
+
1009
+ it('per-domain enable_outbound overrides global false', () => {
1010
+ plugin.cfg.main.enable_outbound = false
1011
+ plugin.cfg['test.com'].enable_outbound = false
1012
+ connection.transaction.rcpt_to = [new Address('<postmaster@test.com>')]
1013
+ const cfg = plugin.get_config(connection)
1014
+ assert.equal(plugin.is_outbound_enabled(cfg), false)
1015
+ })
1016
+
1017
+ it('falls back to global enable_outbound when not in domain cfg', () => {
1018
+ plugin.cfg.main.enable_outbound = true
1019
+ connection.transaction.rcpt_to = [new Address('<user@example.com>')]
1020
+ const cfg = plugin.get_config(connection) // returns cfg.main
1021
+ assert.equal(plugin.is_outbound_enabled(cfg), true)
1022
+ })
1023
+ })