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,1303 @@
1
+ 'use strict'
2
+
3
+ const { describe, it, beforeEach, afterEach } = require('node:test')
4
+ const assert = require('node:assert/strict')
5
+ const { PassThrough } = require('node:stream')
6
+ const path = require('node:path')
7
+
8
+ const { Address } = require('../address')
9
+ const fixtures = require('haraka-test-fixtures')
10
+ const net_utils = require('haraka-net-utils')
11
+ const message = require('haraka-email-message')
12
+
13
+ const smtp_client_module = require('../smtp_client')
14
+ const { smtp_client: SMTPClient } = smtp_client_module
15
+ const tls_socket = require('../tls_socket')
16
+ const { Socket } = require('./fixtures/line_socket')
17
+
18
+ // State enum values mirror the module-internal STATE object
19
+ const STATE = { IDLE: 1, ACTIVE: 2, RELEASED: 3, DESTROYED: 4 }
20
+
21
+ // ─── Socket / client helpers ─────────────────────────────────────────────────
22
+
23
+ function makeSocket() {
24
+ const s = new Socket(25, 'localhost')
25
+ s.write = () => true
26
+ s.upgrade = (opts, cb) => cb && cb(true, null, {}, { name: 'AES128-GCM-SHA256', version: 'TLSv1.3' })
27
+ s.remoteAddress = '1.2.3.4'
28
+ return s
29
+ }
30
+
31
+ function makeClient(opts = {}) {
32
+ const socket = 'socket' in opts ? opts.socket : makeSocket()
33
+ return new SMTPClient({
34
+ host: 'mx.example.com',
35
+ port: 25,
36
+ connect_timeout: 10,
37
+ idle_timeout: 30,
38
+ socket,
39
+ ...opts,
40
+ })
41
+ }
42
+
43
+ // Stub tls_socket.connect so get_client / get_client_plugin don't open real sockets
44
+ let _origTlsConnect
45
+ function mockTlsConnect(socketFactory) {
46
+ _origTlsConnect = tls_socket.connect
47
+ tls_socket.connect =
48
+ socketFactory ||
49
+ (() => {
50
+ const s = makeSocket()
51
+ net_utils.add_line_processor(s)
52
+ return s
53
+ })
54
+ }
55
+ function restoreTlsConnect() {
56
+ if (_origTlsConnect) tls_socket.connect = _origTlsConnect
57
+ }
58
+
59
+ function makeConnection(overrides = {}) {
60
+ const conn = fixtures.makeConnection({ ip: '1.2.3.4' })
61
+ conn.server = { notes: {} }
62
+ conn.hello = { host: 'client.example.com' }
63
+ conn.local = { host: 'relay.example.com' }
64
+ conn.transaction = null
65
+ return Object.assign(conn, overrides)
66
+ }
67
+
68
+ function makePlugin() {
69
+ const p = fixtures.makePlugin('queue/smtp_forward', {
70
+ configDir: path.resolve('test'),
71
+ })
72
+ p.tls_options = {}
73
+ return p
74
+ }
75
+
76
+ // ─── Constructor ─────────────────────────────────────────────────────────────
77
+
78
+ describe('SMTPClient constructor', () => {
79
+ it('initialises default properties', () => {
80
+ const client = makeClient()
81
+ assert.equal(client.command, 'greeting')
82
+ assert.deepEqual(client.response, [])
83
+ assert.equal(client.connected, false)
84
+ assert.equal(client.authenticating, false)
85
+ assert.equal(client.authenticated, false)
86
+ assert.deepEqual(client.auth_capabilities, [])
87
+ assert.equal(client.host, 'mx.example.com')
88
+ assert.equal(client.port, 25)
89
+ assert.equal(client.smtputf8, false)
90
+ assert.ok(client.uuid)
91
+ assert.equal(client.state, STATE.IDLE)
92
+ })
93
+
94
+ it('parses connect_timeout from opts', () => {
95
+ const client = makeClient({ connect_timeout: '45' })
96
+ assert.equal(client.connect_timeout, 45)
97
+ })
98
+
99
+ it('defaults connect_timeout to 30', () => {
100
+ const client = makeClient({ connect_timeout: undefined })
101
+ assert.equal(client.connect_timeout, 30)
102
+ })
103
+
104
+ it('calls setTimeout and setKeepAlive on the socket', () => {
105
+ const socket = makeSocket()
106
+ let timeoutSet = false
107
+ socket.setTimeout = () => {
108
+ timeoutSet = true
109
+ }
110
+ socket.setKeepAlive = () => {}
111
+ new SMTPClient({ host: 'mx.example.com', port: 25, socket })
112
+ assert.ok(timeoutSet)
113
+ })
114
+ })
115
+
116
+ // ─── Line handler ────────────────────────────────────────────────────────────
117
+
118
+ describe('SMTPClient line handler', () => {
119
+ let client
120
+
121
+ beforeEach(() => {
122
+ client = makeClient()
123
+ })
124
+
125
+ it('emits error and destroys on unrecognised SMTP line', () => {
126
+ const errors = []
127
+ client.on('error', (e) => errors.push(e))
128
+ client.socket.emit('line', 'not-smtp\r\n')
129
+ assert.ok(errors.length === 1)
130
+ assert.ok(/Unrecognized response/.test(errors[0]))
131
+ assert.equal(client.state, STATE.DESTROYED)
132
+ })
133
+
134
+ it('accumulates multi-line responses (continuation marker)', () => {
135
+ client.command = 'ehlo'
136
+ // Send multi-line: first line has '-' continuation
137
+ client.socket.emit('line', '250-mx.example.com Hello\r\n')
138
+ assert.deepEqual(client.response, ['mx.example.com Hello'])
139
+ // No event emitted yet for ehlo — it requires a ' ' terminator
140
+ })
141
+
142
+ it('emits greeting EHLO on 220 response', () => {
143
+ let greetingArg = null
144
+ client.on('greeting', (cmd) => {
145
+ greetingArg = cmd
146
+ })
147
+ client.socket.emit('line', '220 hello server\r\n')
148
+ assert.equal(greetingArg, 'EHLO')
149
+ assert.equal(client.connected, true)
150
+ })
151
+
152
+ it('emits helo after ehlo 250', () => {
153
+ client.command = 'ehlo'
154
+ let heloFired = false
155
+ client.on('helo', () => {
156
+ heloFired = true
157
+ })
158
+ client.socket.emit('line', '250 OK\r\n')
159
+ assert.ok(heloFired)
160
+ })
161
+
162
+ it('falls back to HELO when EHLO is rejected with 5xx', () => {
163
+ client.command = 'ehlo'
164
+ let greetingArg = null
165
+ client.on('greeting', (cmd) => {
166
+ greetingArg = cmd
167
+ })
168
+ client.socket.emit('line', '502 EHLO not supported\r\n')
169
+ assert.equal(greetingArg, 'HELO')
170
+ })
171
+
172
+ it('emits capabilities on EHLO 2xx then returns if command changed', () => {
173
+ client.command = 'ehlo'
174
+ let capsFired = false
175
+ client.on('capabilities', () => {
176
+ capsFired = true
177
+ client.command = 'starttls' // simulate command change inside handler
178
+ })
179
+ client.socket.emit('line', '250 OK\r\n')
180
+ assert.ok(capsFired)
181
+ // helo should NOT have been emitted because command changed in capabilities handler
182
+ })
183
+
184
+ it('emits helo/mail/rcpt/data/dot/rset/auth for their commands', () => {
185
+ const commands = ['helo', 'mail', 'rcpt', 'data', 'dot', 'rset', 'auth']
186
+ for (const cmd of commands) {
187
+ const c = makeClient()
188
+ c.command = cmd
189
+ let fired = false
190
+ c.on(cmd, () => {
191
+ fired = true
192
+ })
193
+ c.socket.emit('line', '250 OK\r\n')
194
+ assert.ok(fired, `expected '${cmd}' event to fire`)
195
+ }
196
+ })
197
+
198
+ it('emits quit and destroys on quit 2xx', () => {
199
+ client.command = 'quit'
200
+ let quitFired = false
201
+ client.on('quit', () => {
202
+ quitFired = true
203
+ })
204
+ client.socket.emit('line', '221 Bye\r\n')
205
+ assert.ok(quitFired)
206
+ assert.equal(client.state, STATE.DESTROYED)
207
+ })
208
+
209
+ it('sets xclient flag and emits xclient on XCLIENT success', () => {
210
+ client.command = 'xclient'
211
+ let xclientArg = null
212
+ client.on('xclient', (arg) => {
213
+ xclientArg = arg
214
+ })
215
+ client.socket.emit('line', '220 OK\r\n')
216
+ assert.ok(client.xclient)
217
+ assert.equal(xclientArg, 'EHLO')
218
+ })
219
+
220
+ it('carries on as helo when XCLIENT is rejected with 5xx', () => {
221
+ client.command = 'xclient'
222
+ client.socket.emit('line', '503 XCLIENT not permitted\r\n')
223
+ assert.equal(client.command, 'helo')
224
+ })
225
+
226
+ it('calls upgrade on starttls response', () => {
227
+ client.command = 'starttls'
228
+ client.tls_options = { servername: 'mx.example.com' }
229
+ let upgradeCalled = false
230
+ client.socket.upgrade = () => {
231
+ upgradeCalled = true
232
+ }
233
+ client.socket.emit('line', '220 Go ahead\r\n')
234
+ assert.ok(upgradeCalled)
235
+ })
236
+
237
+ it('emits bad_code on 4xx/5xx for active commands', () => {
238
+ client.command = 'mail'
239
+ client.state = STATE.ACTIVE
240
+ let badCode = null
241
+ client.on('bad_code', (code) => {
242
+ badCode = code
243
+ })
244
+ client.socket.emit('line', '550 Rejected\r\n')
245
+ assert.equal(badCode, '550')
246
+ })
247
+
248
+ it('returns early after bad_code when state is not ACTIVE', () => {
249
+ client.command = 'mail'
250
+ client.state = STATE.IDLE
251
+ client.on('helo', () => {}) // shouldn't fire
252
+ let badCodeFired = false
253
+ client.on('bad_code', () => {
254
+ badCodeFired = true
255
+ })
256
+ client.socket.emit('line', '550 Rejected\r\n')
257
+ assert.ok(badCodeFired)
258
+ // state is IDLE so it returns early — no further dispatch
259
+ })
260
+
261
+ it('destroys on 441 Connection timed out', () => {
262
+ client.command = 'mail'
263
+ client.state = STATE.ACTIVE // must be ACTIVE to pass through bad_code without returning early
264
+ client.socket.emit('line', '441 Connection timed out\r\n')
265
+ assert.equal(client.state, STATE.DESTROYED)
266
+ })
267
+
268
+ it('throws on unknown command', () => {
269
+ client.command = 'unknown_cmd'
270
+ assert.throws(() => client.socket.emit('line', '250 OK\r\n'), /Unknown command: unknown_cmd/)
271
+ })
272
+
273
+ // ── Auth responses ──────────────────────────────────────────────────────
274
+
275
+ // ── Auth challenge responses (334) ─────────────────────────────────────
276
+ // Each case: [event to emit, challenge line]
277
+ for (const [event, challenge] of [
278
+ ['auth_username', '334 VXNlcm5hbWU6\r\n'], // base64('Username:')
279
+ ['auth_username', '334 dXNlcm5hbWU6\r\n'], // base64('username:') — case-insensitive workaround
280
+ ['auth_password', '334 UGFzc3dvcmQ6\r\n'], // base64('Password:')
281
+ ]) {
282
+ it(`emits ${event} on ${challenge.trim()}`, () => {
283
+ client.command = 'auth'
284
+ let fired = false
285
+ client.on(event, () => {
286
+ fired = true
287
+ })
288
+ client.socket.emit('line', challenge)
289
+ assert.ok(fired)
290
+ })
291
+ }
292
+
293
+ it('emits auth and sets authenticated on 235 while authenticating', () => {
294
+ client.command = 'auth'
295
+ client.authenticating = true
296
+ let authFired = false
297
+ client.on('auth', () => {
298
+ authFired = true
299
+ })
300
+ client.socket.emit('line', '235 Authentication successful\r\n')
301
+ assert.ok(authFired)
302
+ assert.equal(client.authenticated, true)
303
+ assert.equal(client.authenticating, false)
304
+ })
305
+
306
+ it('emits auth event via switch for auth command with 250', () => {
307
+ client.command = 'auth'
308
+ client.authenticating = false
309
+ let authFired = false
310
+ client.on('auth', () => {
311
+ authFired = true
312
+ })
313
+ client.socket.emit('line', '250 OK\r\n')
314
+ assert.ok(authFired)
315
+ })
316
+ })
317
+
318
+ // ─── Socket connect event ─────────────────────────────────────────────────────
319
+
320
+ describe('SMTPClient socket connect event', () => {
321
+ it('sets remote_ip from remoteAddress', () => {
322
+ const socket = makeSocket()
323
+ socket.remoteAddress = '::ffff:10.0.0.1'
324
+ const client = makeClient({ socket })
325
+ socket.emit('connect')
326
+ assert.equal(client.remote_ip, '10.0.0.1')
327
+ })
328
+
329
+ it('handles undefined remoteAddress without crash', () => {
330
+ const socket = makeSocket()
331
+ socket.remoteAddress = undefined
332
+ const client = makeClient({ socket })
333
+ assert.doesNotThrow(() => socket.emit('connect'))
334
+ assert.equal(client.remote_ip, undefined)
335
+ })
336
+
337
+ it('replaces timeout with idle_timeout on connect', () => {
338
+ const socket = makeSocket()
339
+ let lastTimeout = null
340
+ socket.setTimeout = (ms) => {
341
+ lastTimeout = ms
342
+ }
343
+ makeClient({ socket, idle_timeout: 120 })
344
+ socket.emit('connect')
345
+ assert.equal(lastTimeout, 120_000)
346
+ })
347
+ })
348
+
349
+ // ─── closed() — socket error / timeout / close / end ─────────────────────────
350
+
351
+ describe('SMTPClient closed() handler', () => {
352
+ it('IDLE state: destroys on socket error', () => {
353
+ const client = makeClient()
354
+ assert.equal(client.state, STATE.IDLE)
355
+ client.socket.emit('error', new Error('ECONNREFUSED'))
356
+ assert.equal(client.state, STATE.DESTROYED)
357
+ })
358
+
359
+ it('ACTIVE state: emits error then destroys on socket error', () => {
360
+ const client = makeClient()
361
+ client.state = STATE.ACTIVE
362
+ const errors = []
363
+ client.on('error', (e) => errors.push(e))
364
+ client.socket.emit('error', new Error('connection dropped'))
365
+ assert.ok(errors.length === 1)
366
+ assert.ok(/SMTP connection errored/.test(errors[0]))
367
+ assert.equal(client.state, STATE.DESTROYED)
368
+ })
369
+
370
+ it('RELEASED state: destroys on socket error', () => {
371
+ const client = makeClient()
372
+ client.state = STATE.RELEASED
373
+ client.socket.emit('error', new Error('gone'))
374
+ assert.equal(client.state, STATE.DESTROYED)
375
+ })
376
+
377
+ it('DESTROYED state: emits connection-error on socket error', () => {
378
+ const client = makeClient()
379
+ client.destroy()
380
+ const connErrors = []
381
+ client.on('connection-error', (e) => connErrors.push(e))
382
+ client.socket.emit('error', new Error('late error'))
383
+ assert.ok(connErrors.length === 1)
384
+ assert.ok(/SMTP connection errored/.test(connErrors[0]))
385
+ })
386
+
387
+ it('DESTROYED state: emits connection-error on socket timeout', () => {
388
+ const client = makeClient()
389
+ client.destroy()
390
+ const connErrors = []
391
+ client.on('connection-error', (e) => connErrors.push(e))
392
+ client.socket.emit('timeout')
393
+ assert.ok(connErrors.length === 1)
394
+ assert.ok(/timed out/.test(connErrors[0]))
395
+ })
396
+
397
+ it('DESTROYED state: does NOT emit connection-error on socket close', () => {
398
+ const client = makeClient()
399
+ client.destroy()
400
+ const connErrors = []
401
+ client.on('connection-error', (e) => connErrors.push(e))
402
+ client.socket.emit('close')
403
+ assert.equal(connErrors.length, 0)
404
+ })
405
+
406
+ it('handles socket timeout in IDLE state', () => {
407
+ const client = makeClient()
408
+ client.socket.emit('timeout')
409
+ assert.equal(client.state, STATE.DESTROYED)
410
+ })
411
+
412
+ it('handles socket close in IDLE state', () => {
413
+ const client = makeClient()
414
+ client.socket.emit('close')
415
+ assert.equal(client.state, STATE.DESTROYED)
416
+ })
417
+
418
+ it('handles socket end in IDLE state', () => {
419
+ const client = makeClient()
420
+ client.socket.emit('end')
421
+ assert.equal(client.state, STATE.DESTROYED)
422
+ })
423
+
424
+ it('closed handler coerces null error to empty string', () => {
425
+ const client = makeClient()
426
+ client.state = STATE.ACTIVE
427
+ const errors = []
428
+ client.on('error', (e) => errors.push(e))
429
+ client.socket.emit('error', null)
430
+ assert.ok(errors.length === 1)
431
+ assert.ok(errors[0].includes('SMTP connection errored'))
432
+ })
433
+ })
434
+
435
+ // ─── load_tls_options ──────────────────────────────────────────────────────────
436
+
437
+ describe('SMTPClient#load_tls_options', () => {
438
+ it('sets tls_options with servername equal to host', () => {
439
+ const client = makeClient()
440
+ client.load_tls_options()
441
+ assert.equal(client.tls_options.servername, 'mx.example.com')
442
+ })
443
+
444
+ it('merges additional opts into tls_options', () => {
445
+ const client = makeClient()
446
+ client.load_tls_options({ key: Buffer.from('secret'), rejectUnauthorized: false })
447
+ assert.equal(client.tls_options.servername, 'mx.example.com')
448
+ assert.equal(client.tls_options.rejectUnauthorized, false)
449
+ assert.ok(Buffer.isBuffer(client.tls_options.key))
450
+ })
451
+ })
452
+
453
+ // ─── send_command ─────────────────────────────────────────────────────────────
454
+
455
+ describe('SMTPClient#send_command', () => {
456
+ it('writes command + CRLF to socket', () => {
457
+ const written = []
458
+ const socket = makeSocket()
459
+ socket.write = (data) => written.push(data)
460
+ const client = makeClient({ socket })
461
+ client.send_command('EHLO', 'example.com')
462
+ assert.equal(written[0], 'EHLO example.com\r\n')
463
+ assert.equal(client.command, 'ehlo')
464
+ assert.deepEqual(client.response, [])
465
+ })
466
+
467
+ it('writes just "." for dot command', () => {
468
+ const written = []
469
+ const socket = makeSocket()
470
+ socket.write = (data) => written.push(data)
471
+ const client = makeClient({ socket })
472
+ client.send_command('dot')
473
+ assert.equal(written[0], '.\r\n')
474
+ assert.equal(client.command, 'dot')
475
+ })
476
+
477
+ it('sends command without data', () => {
478
+ const written = []
479
+ const socket = makeSocket()
480
+ socket.write = (data) => written.push(data)
481
+ const client = makeClient({ socket })
482
+ client.send_command('QUIT')
483
+ assert.equal(written[0], 'QUIT\r\n')
484
+ })
485
+
486
+ it('emits client_protocol event', () => {
487
+ const lines = []
488
+ const client = makeClient()
489
+ client.on('client_protocol', (l) => lines.push(l))
490
+ client.send_command('MAIL', 'FROM:<me@example.com>')
491
+ assert.equal(lines[0], 'MAIL FROM:<me@example.com>')
492
+ })
493
+ })
494
+
495
+ // ─── start_data ───────────────────────────────────────────────────────────────
496
+
497
+ describe('SMTPClient#start_data', () => {
498
+ it('sets command to dot and resets response', () => {
499
+ const client = makeClient()
500
+ client.response = ['leftover']
501
+ const pt = new PassThrough()
502
+ pt.pipe = () => {}
503
+ client.start_data(pt)
504
+ assert.equal(client.command, 'dot')
505
+ assert.deepEqual(client.response, [])
506
+ })
507
+
508
+ it('pipes the data stream to the socket', () => {
509
+ const client = makeClient()
510
+ let pipeTarget = null
511
+ const mockStream = {
512
+ pipe: (dest) => {
513
+ pipeTarget = dest
514
+ },
515
+ }
516
+ client.start_data(mockStream)
517
+ assert.equal(pipeTarget, client.socket)
518
+ })
519
+ })
520
+
521
+ // ─── release ──────────────────────────────────────────────────────────────────
522
+
523
+ describe('SMTPClient#release', () => {
524
+ it('is a no-op when already DESTROYED', () => {
525
+ const client = makeClient()
526
+ client.destroy()
527
+ assert.doesNotThrow(() => client.release())
528
+ assert.equal(client.state, STATE.DESTROYED)
529
+ })
530
+
531
+ it('sends QUIT and destroys when connected', () => {
532
+ const written = []
533
+ const socket = makeSocket()
534
+ socket.write = (data) => written.push(data)
535
+ const client = makeClient({ socket })
536
+ client.connected = true
537
+ client.release()
538
+ assert.ok(written.some((l) => l === 'QUIT\r\n'))
539
+ assert.equal(client.state, STATE.DESTROYED)
540
+ })
541
+
542
+ it('destroys without QUIT when not connected', () => {
543
+ const written = []
544
+ const socket = makeSocket()
545
+ socket.write = (data) => written.push(data)
546
+ const client = makeClient({ socket })
547
+ client.connected = false
548
+ client.release()
549
+ assert.equal(written.length, 0)
550
+ assert.equal(client.state, STATE.DESTROYED)
551
+ })
552
+
553
+ it('removes all named event listeners', () => {
554
+ const client = makeClient()
555
+ client.on('greeting', () => {})
556
+ client.on('error', () => {})
557
+ client.on('bad_code', () => {})
558
+ client.release()
559
+ assert.equal(client.listenerCount('greeting'), 0)
560
+ assert.equal(client.listenerCount('error'), 0)
561
+ assert.equal(client.listenerCount('bad_code'), 0)
562
+ })
563
+ })
564
+
565
+ // ─── destroy ──────────────────────────────────────────────────────────────────
566
+
567
+ describe('SMTPClient#destroy', () => {
568
+ it('sets state to DESTROYED and calls socket.destroy', () => {
569
+ const client = makeClient()
570
+ client.destroy()
571
+ assert.equal(client.state, STATE.DESTROYED)
572
+ assert.ok(client.socket.destroy.called)
573
+ })
574
+
575
+ it('is idempotent — second call is a no-op', () => {
576
+ const client = makeClient()
577
+ client.destroy()
578
+ const callCount = client.socket.destroy.callCount
579
+ client.destroy()
580
+ assert.equal(client.socket.destroy.callCount, callCount)
581
+ })
582
+ })
583
+
584
+ // ─── upgrade ──────────────────────────────────────────────────────────────────
585
+
586
+ describe('SMTPClient#upgrade', () => {
587
+ it('delegates to socket.upgrade with tls_options', () => {
588
+ const socket = makeSocket()
589
+ let upgradeOpts = null
590
+ socket.upgrade = (opts) => {
591
+ upgradeOpts = opts
592
+ }
593
+ const client = makeClient({ socket })
594
+ const opts = { servername: 'secure.example.com', rejectUnauthorized: true }
595
+ client.upgrade(opts)
596
+ assert.deepEqual(upgradeOpts, opts)
597
+ })
598
+
599
+ it('logs upgrade details in callback', () => {
600
+ const socket = makeSocket()
601
+ socket.upgrade = (opts, cb) =>
602
+ cb(
603
+ true,
604
+ null,
605
+ {
606
+ subject: { CN: 'example.com', O: 'Org' },
607
+ issuer: { O: 'CA' },
608
+ valid_to: '2030-01-01',
609
+ fingerprint: 'AA:BB',
610
+ },
611
+ { name: 'AES', version: 'TLSv1.3' },
612
+ )
613
+ const client = makeClient({ socket })
614
+ assert.doesNotThrow(() => client.upgrade({ servername: 'example.com' }))
615
+ })
616
+ })
617
+
618
+ // ─── is_dead_sender ───────────────────────────────────────────────────────────
619
+
620
+ describe('SMTPClient#is_dead_sender', () => {
621
+ it('returns false when connection has a transaction', () => {
622
+ const client = makeClient()
623
+ const plugin = makePlugin()
624
+ const conn = makeConnection()
625
+ conn.transaction = { mail_from: new Address('<a@b.com>') }
626
+ assert.equal(client.is_dead_sender(plugin, conn), false)
627
+ })
628
+
629
+ it('returns true and releases when transaction is null', () => {
630
+ const client = makeClient()
631
+ client.connected = false // ensure release() doesn't try to QUIT
632
+ const plugin = makePlugin()
633
+ const conn = makeConnection()
634
+ conn.transaction = null
635
+ const result = client.is_dead_sender(plugin, conn)
636
+ assert.equal(result, true)
637
+ assert.equal(client.state, STATE.DESTROYED)
638
+ })
639
+ })
640
+
641
+ // ─── get_client export ────────────────────────────────────────────────────────
642
+
643
+ describe('smtp_client.get_client', () => {
644
+ beforeEach(() => mockTlsConnect())
645
+ afterEach(restoreTlsConnect)
646
+
647
+ it('calls callback with a new SMTPClient', (t, done) => {
648
+ smtp_client_module.get_client(
649
+ { notes: {} },
650
+ (client) => {
651
+ assert.ok(client instanceof SMTPClient)
652
+ assert.ok(client.uuid)
653
+ done()
654
+ },
655
+ { host: 'mx.example.com', port: 25 },
656
+ )
657
+ })
658
+ })
659
+
660
+ // ─── onCapabilitiesOutbound ───────────────────────────────────────────────────
661
+
662
+ describe('smtp_client.onCapabilitiesOutbound', () => {
663
+ let client, written
664
+
665
+ beforeEach(() => {
666
+ written = []
667
+ const socket = makeSocket()
668
+ socket.write = (data) => written.push(data)
669
+ client = makeClient({ socket })
670
+ client.tls_options = {}
671
+ })
672
+
673
+ it('sends XCLIENT when capability advertised and not yet done', () => {
674
+ client.response = ['XCLIENT ADDR']
675
+ client.xclient = false
676
+ const conn = makeConnection()
677
+ smtp_client_module.onCapabilitiesOutbound(client, false, conn, {})
678
+ assert.ok(written.some((l) => /XCLIENT ADDR=/.test(l)))
679
+ })
680
+
681
+ it('skips XCLIENT when already performed', () => {
682
+ client.response = ['XCLIENT ADDR']
683
+ client.xclient = true
684
+ smtp_client_module.onCapabilitiesOutbound(client, false, makeConnection(), {})
685
+ assert.ok(!written.some((l) => l.startsWith('XCLIENT')))
686
+ })
687
+
688
+ it('sets smtputf8 flag when SMTPUTF8 advertised', () => {
689
+ client.response = ['SMTPUTF8']
690
+ smtp_client_module.onCapabilitiesOutbound(client, false, makeConnection(), {})
691
+ assert.ok(client.smtputf8)
692
+ })
693
+
694
+ it('sends STARTTLS when advertised, not secured, and enable_tls true', () => {
695
+ client.response = ['STARTTLS']
696
+ smtp_client_module.onCapabilitiesOutbound(client, false, makeConnection(), { enable_tls: true }, () => {})
697
+ assert.ok(written.some((l) => l === 'STARTTLS\r\n'))
698
+ })
699
+
700
+ it('skips STARTTLS when already secured', () => {
701
+ client.response = ['STARTTLS']
702
+ smtp_client_module.onCapabilitiesOutbound(client, true, makeConnection(), { enable_tls: true })
703
+ assert.ok(!written.some((l) => l === 'STARTTLS\r\n'))
704
+ })
705
+
706
+ it('skips STARTTLS when enable_tls is false', () => {
707
+ client.response = ['STARTTLS']
708
+ smtp_client_module.onCapabilitiesOutbound(client, false, makeConnection(), { enable_tls: false })
709
+ assert.ok(!written.some((l) => l === 'STARTTLS\r\n'))
710
+ })
711
+
712
+ it('parses AUTH capabilities', () => {
713
+ client.response = ['AUTH PLAIN LOGIN CRAM-MD5']
714
+ smtp_client_module.onCapabilitiesOutbound(client, false, makeConnection(), {})
715
+ assert.deepEqual(client.auth_capabilities, ['plain', 'login', 'cram-md5'])
716
+ })
717
+
718
+ it('handles multiple capabilities in one response', () => {
719
+ client.response = ['SMTPUTF8', 'AUTH PLAIN', 'STARTTLS']
720
+ smtp_client_module.onCapabilitiesOutbound(client, false, makeConnection(), {})
721
+ assert.ok(client.smtputf8)
722
+ assert.deepEqual(client.auth_capabilities, ['plain'])
723
+ })
724
+
725
+ it('skips STARTTLS when host is in no_tls_hosts ban list', () => {
726
+ client.response = ['STARTTLS']
727
+ // no_tls_hosts is read from tls_options consistently
728
+ client.tls_options = { no_tls_hosts: ['10.0.0.0/8'] }
729
+ client.remote_ip = '10.0.0.1'
730
+ const conn = makeConnection()
731
+ smtp_client_module.onCapabilitiesOutbound(client, false, conn, { enable_tls: true, host: '10.0.0.1' }, () => {})
732
+ assert.ok(!written.some((l) => l === 'STARTTLS\r\n'))
733
+ })
734
+ })
735
+
736
+ // ─── get_client_plugin ────────────────────────────────────────────────────────
737
+
738
+ describe('smtp_client.get_client_plugin', () => {
739
+ let plugin, conn
740
+
741
+ beforeEach(() => {
742
+ mockTlsConnect()
743
+ plugin = makePlugin()
744
+ conn = makeConnection()
745
+ conn.transaction = { mail_from: new Address('<sender@example.com>') }
746
+ })
747
+ afterEach(restoreTlsConnect)
748
+
749
+ // Helper: wrap get_client_plugin callback in a Promise
750
+ const getClientPlugin = (opts = { host: 'relay.example.com', port: 25 }) =>
751
+ new Promise((resolve, reject) => {
752
+ smtp_client_module.get_client_plugin(plugin, conn, opts, (err, client) => {
753
+ if (err) reject(err)
754
+ else resolve(client)
755
+ })
756
+ })
757
+
758
+ it('calls callback with null error and a SMTPClient', async () => {
759
+ const client = await getClientPlugin()
760
+ assert.ok(client instanceof SMTPClient)
761
+ })
762
+
763
+ it('emits error (does not throw) when AUTH type is unsupported', async () => {
764
+ const c = {
765
+ host: 'relay.example.com',
766
+ port: 25,
767
+ auth_type: 'login',
768
+ auth_user: 'a',
769
+ auth_pass: 'b',
770
+ }
771
+ const client = await getClientPlugin(c)
772
+ client.auth_capabilities = [] // server advertised no AUTH
773
+ let errMsg
774
+ client.on('error', (m) => {
775
+ errMsg = m
776
+ })
777
+ // pre-fix this threw out of the event loop and crashed the worker
778
+ assert.doesNotThrow(() => client.emit('helo'))
779
+ assert.match(String(errMsg), /not supported by server/)
780
+ })
781
+
782
+ it('merges auth_type / auth_user / auth_pass into c.auth', async () => {
783
+ const c = { host: 'relay.example.com', port: 25, auth_type: 'plain', auth_user: 'alice', auth_pass: 's3cr3t' }
784
+ await getClientPlugin(c)
785
+ assert.deepEqual(c.auth, { type: 'plain', user: 'alice', pass: 's3cr3t' })
786
+ })
787
+
788
+ it('does not set c.auth when no auth fields present', async () => {
789
+ const c = { host: 'relay.example.com', port: 25 }
790
+ await getClientPlugin(c)
791
+ assert.equal(c.auth, undefined)
792
+ })
793
+
794
+ it('loads tls_config on the returned client', async () => {
795
+ const client = await getClientPlugin()
796
+ assert.ok(client.tls_options)
797
+ })
798
+
799
+ it('greeting handler sends EHLO with local.host (no xclient)', async () => {
800
+ const client = await getClientPlugin()
801
+ const written = []
802
+ client.socket.write = (data) => written.push(data)
803
+ client.emit('greeting', 'EHLO')
804
+ assert.ok(written.some((l) => /EHLO relay\.example\.com/.test(l)))
805
+ })
806
+
807
+ it('redacts AUTH credentials from protocol logs (S4)', async () => {
808
+ const client = await getClientPlugin()
809
+ const logged = []
810
+ conn.logprotocol = (p, msg) => logged.push(msg)
811
+ client.emit('client_protocol', 'AUTH PLAIN AGFsaWNlAHMzY3JldA==')
812
+ assert.equal(logged.length, 1)
813
+ assert.equal(logged[0], 'C: AUTH PLAIN [redacted]')
814
+ assert.ok(!logged[0].includes('AGFsaWNlAHMzY3JldA=='))
815
+ })
816
+
817
+ it('greeting handler sends EHLO with hello.host when xclient is set', async () => {
818
+ const client = await getClientPlugin()
819
+ client.xclient = true
820
+ const written = []
821
+ client.socket.write = (data) => written.push(data)
822
+ client.emit('greeting', 'EHLO')
823
+ assert.ok(written.some((l) => /EHLO client\.example\.com/.test(l)))
824
+ })
825
+
826
+ it('xclient handler sends EHLO with hello.host', async () => {
827
+ const client = await getClientPlugin()
828
+ client.xclient = true
829
+ const written = []
830
+ client.socket.write = (data) => written.push(data)
831
+ client.emit('xclient', 'EHLO')
832
+ assert.ok(written.some((l) => /EHLO client\.example\.com/.test(l)))
833
+ })
834
+
835
+ it('helo handler sends MAIL FROM when no auth configured', async () => {
836
+ const client = await getClientPlugin()
837
+ const written = []
838
+ client.socket.write = (data) => written.push(data)
839
+ client.emit('helo')
840
+ assert.ok(written.some((l) => /MAIL FROM/.test(l)))
841
+ })
842
+
843
+ it('helo handler sends MAIL FROM when already authenticated', async () => {
844
+ const c = { host: 'relay.example.com', port: 25, auth: { type: 'plain', user: 'u', pass: 'p' } }
845
+ const client = await getClientPlugin(c)
846
+ client.authenticated = true
847
+ client.auth_capabilities = ['plain']
848
+ const written = []
849
+ client.socket.write = (data) => written.push(data)
850
+ client.emit('helo')
851
+ assert.ok(written.some((l) => /MAIL FROM/.test(l)))
852
+ })
853
+
854
+ it('helo handler skips when auth.type is null', async () => {
855
+ const c = { host: 'relay.example.com', port: 25, auth: { type: null, user: 'u', pass: 'p' } }
856
+ const client = await getClientPlugin(c)
857
+ client.authenticated = false
858
+ client.auth_capabilities = []
859
+ const written = []
860
+ client.socket.write = (data) => written.push(data)
861
+ assert.doesNotThrow(() => client.emit('helo'))
862
+ assert.equal(written.length, 0)
863
+ })
864
+
865
+ it('helo handler sends AUTH PLAIN with base64 credentials', async () => {
866
+ const c = { host: 'relay.example.com', port: 25, auth: { type: 'plain', user: 'alice', pass: 'secret' } }
867
+ const client = await getClientPlugin(c)
868
+ client.authenticated = false
869
+ client.auth_capabilities = ['plain']
870
+ const written = []
871
+ client.socket.write = (data) => written.push(data)
872
+ client.emit('helo')
873
+ assert.ok(written.some((l) => /AUTH PLAIN/.test(l)))
874
+ })
875
+
876
+ // these used to throw out of the event loop (crashing the worker); they must
877
+ // now route through the smtp_client 'error' flow.
878
+ for (const [desc, opts, capabilities, pattern] of [
879
+ ['unsupported auth type', { type: 'plain', user: 'u', pass: 'p' }, ['cram-md5'], /not supported by server/],
880
+ ['plain auth with no user/pass', { type: 'plain', user: '', pass: '' }, ['plain'], /Must include auth\.user/],
881
+ ['cram-md5 (not implemented)', { type: 'cram-md5', user: 'u', pass: 'p' }, ['cram-md5'], /not implemented/i],
882
+ ['unknown auth type', { type: 'gssapi', user: 'u', pass: 'p' }, ['gssapi'], /Unknown AUTH type/],
883
+ ]) {
884
+ it(`helo handler emits error (no throw) for ${desc}`, async () => {
885
+ const c = { host: 'relay.example.com', port: 25, auth: opts }
886
+ const client = await getClientPlugin(c)
887
+ client.authenticated = false
888
+ client.auth_capabilities = capabilities
889
+ let errMsg
890
+ client.on('error', (m) => {
891
+ errMsg = m
892
+ })
893
+ assert.doesNotThrow(() => client.emit('helo'))
894
+ assert.match(String(errMsg), pattern)
895
+ })
896
+ }
897
+
898
+ it('auth handler sends MAIL FROM after successful authentication', async () => {
899
+ const client = await getClientPlugin()
900
+ client.authenticating = false
901
+ const written = []
902
+ client.socket.write = (data) => written.push(data)
903
+ client.emit('auth')
904
+ assert.ok(written.some((l) => /MAIL FROM/.test(l)))
905
+ })
906
+
907
+ it('auth handler returns early when still authenticating', async () => {
908
+ const client = await getClientPlugin()
909
+ client.authenticating = true
910
+ const written = []
911
+ client.socket.write = (data) => written.push(data)
912
+ client.emit('auth')
913
+ assert.equal(written.length, 0)
914
+ })
915
+
916
+ it('error handler calls call_next', async () => {
917
+ const client = await getClientPlugin()
918
+ let nextCalled = false
919
+ client.next = () => {
920
+ nextCalled = true
921
+ }
922
+ client.emit('error', 'something went wrong')
923
+ assert.ok(nextCalled)
924
+ })
925
+
926
+ it('connection-error handler calls call_next', async () => {
927
+ const client = await getClientPlugin()
928
+ let nextCalled = false
929
+ client.next = () => {
930
+ nextCalled = true
931
+ }
932
+ client.emit('connection-error', 'backend unreachable')
933
+ assert.ok(nextCalled)
934
+ })
935
+
936
+ it('connection-error handler calls host_pool.failed when pool exists', async () => {
937
+ let failedCalled = false
938
+ conn.server.notes.host_pool = {
939
+ failed: () => {
940
+ failedCalled = true
941
+ },
942
+ }
943
+ const client = await getClientPlugin()
944
+ client.emit('connection-error', 'Error: connect ECONNREFUSED')
945
+ assert.ok(failedCalled)
946
+ })
947
+
948
+ it('throws when neither forwarding_host_pool nor host/port specified', () => {
949
+ assert.throws(
950
+ () => smtp_client_module.get_client_plugin(plugin, conn, {}, () => {}),
951
+ /forwarding_host_pool or host and port/,
952
+ )
953
+ })
954
+
955
+ it('uses forwarding_host_pool when configured', async () => {
956
+ const c = { forwarding_host_pool: '10.0.0.1:25, 10.0.0.2:25' }
957
+ const client = await getClientPlugin(c)
958
+ assert.ok(client instanceof SMTPClient)
959
+ assert.ok(conn.server.notes.host_pool)
960
+ })
961
+
962
+ it('reuses existing host_pool from server.notes', async () => {
963
+ const { HostPool } = require('haraka-net-utils')
964
+ const pool = new HostPool('10.0.0.3:25')
965
+ conn.server.notes.host_pool = pool
966
+ await getClientPlugin({ forwarding_host_pool: '10.0.0.3:25' })
967
+ assert.equal(conn.server.notes.host_pool, pool)
968
+ })
969
+
970
+ it('server_protocol event logs protocol line', async () => {
971
+ const client = await getClientPlugin()
972
+ assert.doesNotThrow(() => client.emit('server_protocol', '220 server ready'))
973
+ })
974
+
975
+ it('capabilities handler calls onCapabilitiesOutbound', async () => {
976
+ const client = await getClientPlugin({ host: 'relay.example.com', port: 25, enable_tls: true })
977
+ client.response = ['SIZE 10240000', 'AUTH PLAIN LOGIN']
978
+ assert.doesNotThrow(() => client.emit('capabilities'))
979
+ assert.deepEqual(client.auth_capabilities, ['plain', 'login'])
980
+ })
981
+
982
+ it('on_secured fires greeting and is idempotent', async () => {
983
+ const client = await getClientPlugin({ host: 'relay.example.com', port: 25, enable_tls: true })
984
+ client.response = ['STARTTLS']
985
+ const written = []
986
+ client.socket.write = (d) => written.push(d)
987
+ client.emit('capabilities')
988
+
989
+ let greetingCount = 0
990
+ client.on('greeting', () => {
991
+ greetingCount++
992
+ })
993
+
994
+ client.socket.emit('secure')
995
+ client.socket.emit('secure') // second call is a no-op
996
+ assert.equal(greetingCount, 1)
997
+ })
998
+
999
+ it('connected + xclient: sends XCLIENT immediately', (t, done) => {
1000
+ // Make the mock socket emit a 220 greeting synchronously during SMTPClient construction
1001
+ // so smtp_client.connected is true before get_client_plugin's check runs
1002
+ const origConnect = tls_socket.connect
1003
+ tls_socket.connect = () => {
1004
+ const s = makeSocket()
1005
+ net_utils.add_line_processor(s)
1006
+ const origOn = s.on.bind(s)
1007
+ let lineHandlerRegistered = false
1008
+ s.on = function (event, handler) {
1009
+ origOn(event, handler)
1010
+ if (event === 'line' && !lineHandlerRegistered) {
1011
+ lineHandlerRegistered = true
1012
+ // emit greeting synchronously so connected becomes true before callback
1013
+ handler('220 ready\r\n')
1014
+ }
1015
+ return s
1016
+ }
1017
+ return s
1018
+ }
1019
+
1020
+ const mockPlugin = makePlugin()
1021
+
1022
+ smtp_client_module.get_client_plugin(
1023
+ mockPlugin,
1024
+ conn,
1025
+ { host: 'relay.example.com', port: 25 },
1026
+ (err, client) => {
1027
+ tls_socket.connect = origConnect
1028
+ // If connected=true and xclient=true, XCLIENT was sent
1029
+ // If connected=true and xclient=false, helo was emitted
1030
+ // Either way connected path was exercised — just verify no crash
1031
+ assert.ok(client instanceof SMTPClient)
1032
+ done()
1033
+ },
1034
+ )
1035
+ })
1036
+ })
1037
+
1038
+ // ─── Full SMTP session (integration) ─────────────────────────────────────────
1039
+
1040
+ describe('smtp_client full session (basic)', () => {
1041
+ beforeEach((t, done) => {
1042
+ smtp_client_module.get_client(
1043
+ { notes: {} },
1044
+ (client) => {
1045
+ this.client = client
1046
+ done()
1047
+ },
1048
+ { socket: require('./fixtures/line_socket').connect() },
1049
+ )
1050
+ })
1051
+
1052
+ it('conducts a SMTP session', (t, done) => {
1053
+ const message_stream = new message.stream({ main: { spool_after: 1024 } }, '123456789')
1054
+
1055
+ const data = []
1056
+ let reading_body = false
1057
+ data.push('220 hi')
1058
+
1059
+ this.client.on('greeting', (command) => {
1060
+ assert.equal(this.client.response[0], 'hi')
1061
+ assert.equal('EHLO', command)
1062
+ this.client.send_command(command, 'example.com')
1063
+ })
1064
+
1065
+ data.push('EHLO example.com')
1066
+ data.push('250 hello')
1067
+
1068
+ this.client.on('helo', () => {
1069
+ assert.equal(this.client.response[0], 'hello')
1070
+ this.client.send_command('MAIL', 'FROM: me@example.com')
1071
+ })
1072
+
1073
+ data.push('MAIL FROM: me@example.com')
1074
+ data.push('250 sender ok')
1075
+
1076
+ this.client.on('mail', () => {
1077
+ assert.equal(this.client.response[0], 'sender ok')
1078
+ this.client.send_command('RCPT', 'TO: you@example.com')
1079
+ })
1080
+
1081
+ data.push('RCPT TO: you@example.com')
1082
+ data.push('250 recipient ok')
1083
+
1084
+ this.client.on('rcpt', () => {
1085
+ assert.equal(this.client.response[0], 'recipient ok')
1086
+ this.client.send_command('DATA')
1087
+ })
1088
+
1089
+ data.push('DATA')
1090
+ data.push('354 go ahead')
1091
+
1092
+ this.client.on('data', () => {
1093
+ assert.equal(this.client.response[0], 'go ahead')
1094
+ this.client.start_data(message_stream)
1095
+ message_stream.add_line('Header: test\r\n')
1096
+ message_stream.add_line('\r\n')
1097
+ message_stream.add_line('hi\r\n')
1098
+ message_stream.add_line_end()
1099
+ })
1100
+
1101
+ data.push('Header: test')
1102
+ data.push('')
1103
+ data.push('hi')
1104
+ data.push('.')
1105
+ data.push('250 message queued')
1106
+
1107
+ this.client.on('dot', () => {
1108
+ assert.equal(this.client.response[0], 'message queued')
1109
+ this.client.send_command('QUIT')
1110
+ })
1111
+
1112
+ data.push('QUIT')
1113
+ data.push('221 goodbye')
1114
+
1115
+ this.client.on('quit', () => {
1116
+ assert.equal(this.client.response[0], 'goodbye')
1117
+ done()
1118
+ })
1119
+
1120
+ this.client.socket.write = function (line) {
1121
+ if (data.length === 0) {
1122
+ assert.ok(false)
1123
+ return
1124
+ }
1125
+ const lineStr = Buffer.isBuffer(line) ? line.toString() : line
1126
+ assert.equal(`${data.shift()}\r\n`, lineStr)
1127
+ if (reading_body && lineStr === '.\r\n') reading_body = false
1128
+ if (reading_body) return true
1129
+ if (lineStr === 'DATA\r\n') reading_body = true
1130
+ while (true) {
1131
+ const line2 = data.shift()
1132
+ this.emit('line', `${line2}\r\n`)
1133
+ if (line2[3] === ' ') break
1134
+ }
1135
+ return true
1136
+ }
1137
+
1138
+ this.client.socket.emit('line', data.shift())
1139
+ })
1140
+ })
1141
+
1142
+ // ─── Full SMTP session with AUTH (integration) ───────────────────────────────
1143
+
1144
+ describe('smtp_client full session (auth)', () => {
1145
+ beforeEach((t, done) => {
1146
+ smtp_client_module.get_client(
1147
+ { notes: {} },
1148
+ (client) => {
1149
+ this.client = client
1150
+ done()
1151
+ },
1152
+ { socket: require('./fixtures/line_socket').connect() },
1153
+ )
1154
+ })
1155
+
1156
+ it('authenticates during SMTP conversation', (t, done) => {
1157
+ const message_stream = new message.stream({ main: { spool_after: 1024 } }, '123456789')
1158
+
1159
+ const data = []
1160
+ let reading_body = false
1161
+ data.push('220 hi')
1162
+
1163
+ this.client.on('greeting', (command) => {
1164
+ assert.equal(this.client.response[0], 'hi')
1165
+ assert.equal('EHLO', command)
1166
+ this.client.send_command(command, 'example.com')
1167
+ })
1168
+
1169
+ data.push('EHLO example.com')
1170
+ data.push('250 hello')
1171
+
1172
+ this.client.on('helo', () => {
1173
+ assert.equal(this.client.response[0], 'hello')
1174
+ this.client.send_command('AUTH', 'PLAIN AHRlc3QAdGVzdHBhc3M=')
1175
+ this.client.send_command('MAIL', 'FROM: me@example.com')
1176
+ })
1177
+
1178
+ data.push('AUTH PLAIN AHRlc3QAdGVzdHBhc3M=')
1179
+ data.push('235 Authentication successful.')
1180
+
1181
+ data.push('MAIL FROM: me@example.com')
1182
+ data.push('250 sender ok')
1183
+
1184
+ this.client.on('mail', () => {
1185
+ assert.equal(this.client.response[0], 'sender ok')
1186
+ this.client.send_command('RCPT', 'TO: you@example.com')
1187
+ })
1188
+
1189
+ data.push('RCPT TO: you@example.com')
1190
+ data.push('250 recipient ok')
1191
+
1192
+ this.client.on('rcpt', () => {
1193
+ assert.equal(this.client.response[0], 'recipient ok')
1194
+ this.client.send_command('DATA')
1195
+ })
1196
+
1197
+ data.push('DATA')
1198
+ data.push('354 go ahead')
1199
+
1200
+ this.client.on('data', () => {
1201
+ assert.equal(this.client.response[0], 'go ahead')
1202
+ this.client.start_data(message_stream)
1203
+ message_stream.add_line('Header: test\r\n')
1204
+ message_stream.add_line('\r\n')
1205
+ message_stream.add_line('hi\r\n')
1206
+ message_stream.add_line_end()
1207
+ })
1208
+
1209
+ data.push('Header: test')
1210
+ data.push('')
1211
+ data.push('hi')
1212
+ data.push('.')
1213
+ data.push('250 message queued')
1214
+
1215
+ this.client.on('dot', () => {
1216
+ assert.equal(this.client.response[0], 'message queued')
1217
+ this.client.send_command('QUIT')
1218
+ })
1219
+
1220
+ data.push('QUIT')
1221
+ data.push('221 goodbye')
1222
+
1223
+ this.client.on('quit', () => {
1224
+ assert.equal(this.client.response[0], 'goodbye')
1225
+ done()
1226
+ })
1227
+
1228
+ this.client.socket.write = function (line) {
1229
+ if (data.length === 0) {
1230
+ assert.ok(false)
1231
+ return
1232
+ }
1233
+ const lineStr = Buffer.isBuffer(line) ? line.toString() : line
1234
+ assert.equal(`${data.shift()}\r\n`, lineStr)
1235
+ if (reading_body && lineStr === '.\r\n') reading_body = false
1236
+ if (!reading_body) {
1237
+ if (lineStr === 'DATA\r\n') reading_body = true
1238
+ while (true) {
1239
+ const line2 = data.shift()
1240
+ this.emit('line', `${line2}\r\n`)
1241
+ if (line2[3] === ' ') break
1242
+ }
1243
+ }
1244
+ return true
1245
+ }
1246
+
1247
+ this.client.socket.emit('line', data.shift())
1248
+ })
1249
+ })
1250
+
1251
+ // ─── testUpgradeIsCalledOnSTARTTLS ───────────────────────────────────────────
1252
+
1253
+ describe('smtp_client', () => {
1254
+ it('testUpgradeIsCalledOnSTARTTLS', () => {
1255
+ const cmds = {}
1256
+ let upgradeArgs = {}
1257
+
1258
+ const socket = {
1259
+ setTimeout: () => {},
1260
+ setKeepAlive: () => {},
1261
+ on: (eventName, callback) => {
1262
+ cmds[eventName] = callback
1263
+ },
1264
+ upgrade: (arg) => {
1265
+ upgradeArgs = arg
1266
+ },
1267
+ }
1268
+
1269
+ const client = new SMTPClient({ host: 'mx.example.com', port: 25, socket })
1270
+ client.load_tls_options({ key: Buffer.from('OutboundTlsKeyLoaded') })
1271
+
1272
+ client.command = 'starttls'
1273
+ cmds.line('250 Hello client.example.com\r\n')
1274
+
1275
+ const { StringDecoder } = require('node:string_decoder')
1276
+ const decoder = new StringDecoder('utf8')
1277
+ const cent = Buffer.from(upgradeArgs.key)
1278
+ assert.equal(decoder.write(cent), 'OutboundTlsKeyLoaded')
1279
+ })
1280
+
1281
+ it('startTLS', () => {
1282
+ let cmd = ''
1283
+
1284
+ const socket = {
1285
+ setTimeout: () => {},
1286
+ setKeepAlive: () => {},
1287
+ on: () => {},
1288
+ upgrade: () => {},
1289
+ write: (arg) => {
1290
+ cmd = arg
1291
+ },
1292
+ }
1293
+
1294
+ const client = new SMTPClient({ host: 'mx.example.com', port: 25, socket })
1295
+ client.tls_options = {}
1296
+ client.secured = false
1297
+ client.response = ['STARTTLS']
1298
+
1299
+ smtp_client_module.onCapabilitiesOutbound(client, false, undefined, { enable_tls: true })
1300
+
1301
+ assert.equal(cmd, 'STARTTLS\r\n')
1302
+ })
1303
+ })