odac 0.9.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 (213) hide show
  1. package/.editorconfig +21 -0
  2. package/.github/workflows/auto-pr-description.yml +49 -0
  3. package/.github/workflows/release.yml +32 -0
  4. package/.github/workflows/test-coverage.yml +58 -0
  5. package/.husky/pre-commit +2 -0
  6. package/.kiro/steering/code-style.md +56 -0
  7. package/.kiro/steering/product.md +20 -0
  8. package/.kiro/steering/structure.md +77 -0
  9. package/.kiro/steering/tech.md +87 -0
  10. package/.prettierrc +10 -0
  11. package/.releaserc.js +134 -0
  12. package/AGENTS.md +84 -0
  13. package/CHANGELOG.md +181 -0
  14. package/CODE_OF_CONDUCT.md +83 -0
  15. package/CONTRIBUTING.md +63 -0
  16. package/LICENSE +661 -0
  17. package/README.md +57 -0
  18. package/SECURITY.md +26 -0
  19. package/bin/candy +10 -0
  20. package/bin/candypack +10 -0
  21. package/cli/index.js +3 -0
  22. package/cli/src/Cli.js +348 -0
  23. package/cli/src/Connector.js +93 -0
  24. package/cli/src/Monitor.js +416 -0
  25. package/core/Candy.js +87 -0
  26. package/core/Commands.js +239 -0
  27. package/core/Config.js +1094 -0
  28. package/core/Lang.js +52 -0
  29. package/core/Log.js +43 -0
  30. package/core/Process.js +26 -0
  31. package/docs/backend/01-overview/01-whats-in-the-candy-box.md +9 -0
  32. package/docs/backend/01-overview/02-super-handy-helper-functions.md +9 -0
  33. package/docs/backend/01-overview/03-development-server.md +79 -0
  34. package/docs/backend/02-structure/01-typical-project-layout.md +39 -0
  35. package/docs/backend/03-config/00-configuration-overview.md +214 -0
  36. package/docs/backend/03-config/01-database-connection.md +60 -0
  37. package/docs/backend/03-config/02-static-route-mapping-optional.md +20 -0
  38. package/docs/backend/03-config/03-request-timeout.md +11 -0
  39. package/docs/backend/03-config/04-environment-variables.md +227 -0
  40. package/docs/backend/03-config/05-early-hints.md +352 -0
  41. package/docs/backend/04-routing/01-basic-page-routes.md +28 -0
  42. package/docs/backend/04-routing/02-controller-less-view-routes.md +43 -0
  43. package/docs/backend/04-routing/03-api-and-data-routes.md +20 -0
  44. package/docs/backend/04-routing/04-authentication-aware-routes.md +48 -0
  45. package/docs/backend/04-routing/05-advanced-routing.md +14 -0
  46. package/docs/backend/04-routing/06-error-pages.md +101 -0
  47. package/docs/backend/04-routing/07-cron-jobs.md +149 -0
  48. package/docs/backend/05-controllers/01-how-to-build-a-controller.md +17 -0
  49. package/docs/backend/05-controllers/02-your-trusty-candy-assistant.md +20 -0
  50. package/docs/backend/05-controllers/03-controller-classes.md +93 -0
  51. package/docs/backend/05-forms/01-custom-forms.md +395 -0
  52. package/docs/backend/05-forms/02-automatic-database-insert.md +297 -0
  53. package/docs/backend/06-request-and-response/01-the-request-object-what-is-the-user-asking-for.md +96 -0
  54. package/docs/backend/06-request-and-response/02-sending-a-response-replying-to-the-user.md +40 -0
  55. package/docs/backend/07-views/01-the-view-directory.md +73 -0
  56. package/docs/backend/07-views/02-rendering-a-view.md +179 -0
  57. package/docs/backend/07-views/03-template-syntax.md +181 -0
  58. package/docs/backend/07-views/03-variables.md +328 -0
  59. package/docs/backend/07-views/04-request-data.md +231 -0
  60. package/docs/backend/07-views/05-conditionals.md +290 -0
  61. package/docs/backend/07-views/06-loops.md +353 -0
  62. package/docs/backend/07-views/07-translations.md +358 -0
  63. package/docs/backend/07-views/08-backend-javascript.md +398 -0
  64. package/docs/backend/07-views/09-comments.md +297 -0
  65. package/docs/backend/08-database/01-database-connection.md +99 -0
  66. package/docs/backend/08-database/02-using-mysql.md +322 -0
  67. package/docs/backend/09-validation/01-the-validator-service.md +424 -0
  68. package/docs/backend/10-authentication/01-user-logins-with-authjs.md +53 -0
  69. package/docs/backend/10-authentication/02-foiling-villains-with-csrf-protection.md +55 -0
  70. package/docs/backend/10-authentication/03-register.md +134 -0
  71. package/docs/backend/10-authentication/04-candy-register-forms.md +676 -0
  72. package/docs/backend/10-authentication/05-session-management.md +159 -0
  73. package/docs/backend/10-authentication/06-candy-login-forms.md +596 -0
  74. package/docs/backend/11-mail/01-the-mail-service.md +42 -0
  75. package/docs/backend/12-streaming/01-streaming-overview.md +300 -0
  76. package/docs/backend/13-utilities/01-candy-var.md +504 -0
  77. package/docs/frontend/01-overview/01-introduction.md +146 -0
  78. package/docs/frontend/02-ajax-navigation/01-quick-start.md +608 -0
  79. package/docs/frontend/02-ajax-navigation/02-configuration.md +370 -0
  80. package/docs/frontend/02-ajax-navigation/03-advanced-usage.md +519 -0
  81. package/docs/frontend/03-forms/01-form-handling.md +420 -0
  82. package/docs/frontend/04-api-requests/01-get-post.md +443 -0
  83. package/docs/frontend/05-streaming/01-client-streaming.md +163 -0
  84. package/docs/index.json +452 -0
  85. package/docs/server/01-installation/01-quick-install.md +19 -0
  86. package/docs/server/01-installation/02-manual-installation-via-npm.md +9 -0
  87. package/docs/server/02-get-started/01-core-concepts.md +7 -0
  88. package/docs/server/02-get-started/02-basic-commands.md +57 -0
  89. package/docs/server/02-get-started/03-cli-reference.md +276 -0
  90. package/docs/server/02-get-started/04-cli-quick-reference.md +102 -0
  91. package/docs/server/03-service/01-start-a-new-service.md +57 -0
  92. package/docs/server/03-service/02-delete-a-service.md +48 -0
  93. package/docs/server/04-web/01-create-a-website.md +36 -0
  94. package/docs/server/04-web/02-list-websites.md +9 -0
  95. package/docs/server/04-web/03-delete-a-website.md +29 -0
  96. package/docs/server/05-subdomain/01-create-a-subdomain.md +32 -0
  97. package/docs/server/05-subdomain/02-list-subdomains.md +33 -0
  98. package/docs/server/05-subdomain/03-delete-a-subdomain.md +41 -0
  99. package/docs/server/06-ssl/01-renew-an-ssl-certificate.md +34 -0
  100. package/docs/server/07-mail/01-create-a-mail-account.md +23 -0
  101. package/docs/server/07-mail/02-delete-a-mail-account.md +20 -0
  102. package/docs/server/07-mail/03-list-mail-accounts.md +20 -0
  103. package/docs/server/07-mail/04-change-account-password.md +23 -0
  104. package/eslint.config.mjs +120 -0
  105. package/framework/index.js +4 -0
  106. package/framework/src/Auth.js +309 -0
  107. package/framework/src/Candy.js +81 -0
  108. package/framework/src/Config.js +79 -0
  109. package/framework/src/Env.js +60 -0
  110. package/framework/src/Lang.js +57 -0
  111. package/framework/src/Mail.js +83 -0
  112. package/framework/src/Mysql.js +575 -0
  113. package/framework/src/Request.js +301 -0
  114. package/framework/src/Route/Cron.js +128 -0
  115. package/framework/src/Route/Internal.js +439 -0
  116. package/framework/src/Route.js +455 -0
  117. package/framework/src/Server.js +15 -0
  118. package/framework/src/Stream.js +163 -0
  119. package/framework/src/Token.js +37 -0
  120. package/framework/src/Validator.js +271 -0
  121. package/framework/src/Var.js +211 -0
  122. package/framework/src/View/EarlyHints.js +190 -0
  123. package/framework/src/View/Form.js +600 -0
  124. package/framework/src/View.js +513 -0
  125. package/framework/web/candy.js +838 -0
  126. package/jest.config.js +22 -0
  127. package/locale/de-DE.json +80 -0
  128. package/locale/en-US.json +79 -0
  129. package/locale/es-ES.json +80 -0
  130. package/locale/fr-FR.json +80 -0
  131. package/locale/pt-BR.json +80 -0
  132. package/locale/ru-RU.json +80 -0
  133. package/locale/tr-TR.json +85 -0
  134. package/locale/zh-CN.json +80 -0
  135. package/package.json +86 -0
  136. package/server/index.js +5 -0
  137. package/server/src/Api.js +88 -0
  138. package/server/src/DNS.js +940 -0
  139. package/server/src/Hub.js +535 -0
  140. package/server/src/Mail.js +571 -0
  141. package/server/src/SSL.js +180 -0
  142. package/server/src/Server.js +27 -0
  143. package/server/src/Service.js +248 -0
  144. package/server/src/Subdomain.js +64 -0
  145. package/server/src/Web/Firewall.js +170 -0
  146. package/server/src/Web/Proxy.js +134 -0
  147. package/server/src/Web.js +451 -0
  148. package/server/src/mail/imap.js +1091 -0
  149. package/server/src/mail/server.js +32 -0
  150. package/server/src/mail/smtp.js +786 -0
  151. package/test/cli/Cli.test.js +36 -0
  152. package/test/core/Candy.test.js +234 -0
  153. package/test/core/Commands.test.js +538 -0
  154. package/test/core/Config.test.js +1435 -0
  155. package/test/core/Lang.test.js +250 -0
  156. package/test/core/Process.test.js +156 -0
  157. package/test/framework/Route.test.js +239 -0
  158. package/test/framework/View/EarlyHints.test.js +282 -0
  159. package/test/scripts/check-coverage.js +132 -0
  160. package/test/server/Api.test.js +647 -0
  161. package/test/server/Client.test.js +338 -0
  162. package/test/server/DNS.test.js +2050 -0
  163. package/test/server/DNS.test.js.bak +2084 -0
  164. package/test/server/Log.test.js +73 -0
  165. package/test/server/Mail.account.test_.js +460 -0
  166. package/test/server/Mail.init.test_.js +411 -0
  167. package/test/server/Mail.test_.js +1340 -0
  168. package/test/server/SSL.test_.js +1491 -0
  169. package/test/server/Server.test.js +765 -0
  170. package/test/server/Service.test_.js +1127 -0
  171. package/test/server/Subdomain.test.js +440 -0
  172. package/test/server/Web/Firewall.test.js +175 -0
  173. package/test/server/Web.test_.js +1562 -0
  174. package/test/server/__mocks__/acme-client.js +17 -0
  175. package/test/server/__mocks__/bcrypt.js +50 -0
  176. package/test/server/__mocks__/child_process.js +389 -0
  177. package/test/server/__mocks__/crypto.js +432 -0
  178. package/test/server/__mocks__/fs.js +450 -0
  179. package/test/server/__mocks__/globalCandy.js +227 -0
  180. package/test/server/__mocks__/http-proxy.js +105 -0
  181. package/test/server/__mocks__/http.js +575 -0
  182. package/test/server/__mocks__/https.js +272 -0
  183. package/test/server/__mocks__/index.js +249 -0
  184. package/test/server/__mocks__/mail/server.js +100 -0
  185. package/test/server/__mocks__/mail/smtp.js +31 -0
  186. package/test/server/__mocks__/mailparser.js +81 -0
  187. package/test/server/__mocks__/net.js +369 -0
  188. package/test/server/__mocks__/node-forge.js +328 -0
  189. package/test/server/__mocks__/os.js +320 -0
  190. package/test/server/__mocks__/path.js +291 -0
  191. package/test/server/__mocks__/selfsigned.js +8 -0
  192. package/test/server/__mocks__/server/src/mail/server.js +100 -0
  193. package/test/server/__mocks__/server/src/mail/smtp.js +31 -0
  194. package/test/server/__mocks__/smtp-server.js +106 -0
  195. package/test/server/__mocks__/sqlite3.js +394 -0
  196. package/test/server/__mocks__/testFactories.js +299 -0
  197. package/test/server/__mocks__/testHelpers.js +363 -0
  198. package/test/server/__mocks__/tls.js +229 -0
  199. package/watchdog/index.js +3 -0
  200. package/watchdog/src/Watchdog.js +156 -0
  201. package/web/config.json +5 -0
  202. package/web/controller/page/about.js +27 -0
  203. package/web/controller/page/index.js +34 -0
  204. package/web/package.json +18 -0
  205. package/web/public/assets/css/style.css +1835 -0
  206. package/web/public/assets/js/app.js +96 -0
  207. package/web/route/www.js +19 -0
  208. package/web/skeleton/main.html +22 -0
  209. package/web/view/content/about.html +65 -0
  210. package/web/view/content/home.html +205 -0
  211. package/web/view/footer/main.html +11 -0
  212. package/web/view/head/main.html +5 -0
  213. package/web/view/header/main.html +14 -0
@@ -0,0 +1,786 @@
1
+ const {log, error} = Candy.core('Log', false).init('Mail', 'SMTP')
2
+
3
+ const nodeCrypto = require('crypto')
4
+ const dns = require('dns')
5
+ const net = require('net')
6
+ const tls = require('tls')
7
+ const fs = require('fs')
8
+ const {promisify} = require('util')
9
+ const DKIMSign = require('dkim-signer').DKIMSign
10
+
11
+ // DNS resolver promisify
12
+ const resolveMx = promisify(dns.resolveMx)
13
+
14
+ class smtp {
15
+ constructor() {
16
+ // Configuration with defaults
17
+ this.config = {
18
+ timeout: 30000, // 30 seconds
19
+ retryAttempts: 3,
20
+ retryDelay: 1000, // 1 second
21
+ maxConnections: 10,
22
+ ports: [25, 587, 465, 2525],
23
+ enableAuth: true,
24
+ enableDKIM: true,
25
+ maxEmailSize: 25 * 1024 * 1024, // 25MB
26
+ connectionPoolTimeout: 300000, // 5 minutes
27
+ dnsTimeout: 10000, // 10 seconds
28
+ rateLimitPerHour: 1000,
29
+ tls: {
30
+ minVersion: 'TLSv1.1', // More flexible minimum
31
+ secureProtocol: 'TLS_method', // Auto-negotiation
32
+ ciphers: [
33
+ 'ECDHE-RSA-AES256-GCM-SHA384',
34
+ 'ECDHE-RSA-AES128-GCM-SHA256',
35
+ 'ECDHE-RSA-AES256-SHA384',
36
+ 'ECDHE-RSA-AES128-SHA256',
37
+ 'AES256-GCM-SHA384',
38
+ 'AES128-GCM-SHA256',
39
+ 'AES256-SHA256',
40
+ 'AES128-SHA256',
41
+ 'HIGH',
42
+ '!aNULL',
43
+ '!eNULL',
44
+ '!EXPORT',
45
+ '!DES',
46
+ '!RC4',
47
+ '!MD5',
48
+ '!PSK',
49
+ '!SRP',
50
+ '!CAMELLIA'
51
+ ].join(':')
52
+ }
53
+ }
54
+
55
+ // Connection pool and caches
56
+ this.connectionPool = new Map()
57
+ this.mxCache = new Map()
58
+ this.rateLimiter = new Map()
59
+
60
+ // Cleanup interval for connection pool
61
+ setInterval(() => this.#cleanupConnections(), 60000) // 1 minute
62
+ }
63
+
64
+ #validateEmailObject(obj) {
65
+ if (!obj || typeof obj !== 'object') {
66
+ throw new Error('Invalid email object')
67
+ }
68
+
69
+ if (!obj.from || !obj.from.value || !Array.isArray(obj.from.value) || !obj.from.value[0]?.address) {
70
+ throw new Error('Invalid sender address')
71
+ }
72
+
73
+ if (!obj.to || !obj.to.value || !Array.isArray(obj.to.value) || obj.to.value.length === 0) {
74
+ throw new Error('Invalid recipient addresses')
75
+ }
76
+
77
+ // Email address validation
78
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
79
+ if (!emailRegex.test(obj.from.value[0].address)) {
80
+ throw new Error('Invalid sender email format')
81
+ }
82
+
83
+ for (const recipient of obj.to.value) {
84
+ if (!emailRegex.test(recipient.address)) {
85
+ throw new Error(`Invalid recipient email format: ${recipient.address}`)
86
+ }
87
+ }
88
+
89
+ // Content validation
90
+ if (!obj.text && !obj.html && (!obj.attachments || obj.attachments.length === 0)) {
91
+ throw new Error('Email must have content (text, html, or attachments)')
92
+ }
93
+
94
+ return true
95
+ }
96
+
97
+ #sanitizeInput(input) {
98
+ if (typeof input !== 'string') return input
99
+ // Prevent SMTP injection
100
+ return input.replace(/[\r\n]/g, '').substring(0, 1000)
101
+ }
102
+
103
+ #checkRateLimit(domain) {
104
+ const now = Date.now()
105
+ const hourAgo = now - 3600000 // 1 hour
106
+
107
+ if (!this.rateLimiter.has(domain)) {
108
+ this.rateLimiter.set(domain, [])
109
+ }
110
+
111
+ const timestamps = this.rateLimiter.get(domain)
112
+ // Remove old timestamps
113
+ const recentTimestamps = timestamps.filter(ts => ts > hourAgo)
114
+
115
+ if (recentTimestamps.length >= this.config.rateLimitPerHour) {
116
+ throw new Error(`Rate limit exceeded for domain ${domain}`)
117
+ }
118
+
119
+ recentTimestamps.push(now)
120
+ this.rateLimiter.set(domain, recentTimestamps)
121
+ }
122
+
123
+ #commandWithTimeout(socket, command, timeoutMs = this.config.timeout) {
124
+ return new Promise((resolve, reject) => {
125
+ const timeout = setTimeout(() => {
126
+ socket.removeAllListeners('data')
127
+ reject(new Error(`Command timeout: ${command.trim()}`))
128
+ }, timeoutMs)
129
+
130
+ socket.once('data', data => {
131
+ clearTimeout(timeout)
132
+ const response = data.toString()
133
+ log('SMTP Response', response.trim())
134
+ resolve(response)
135
+ })
136
+
137
+ socket.once('error', err => {
138
+ clearTimeout(timeout)
139
+ reject(err)
140
+ })
141
+
142
+ try {
143
+ if (socket.writable) {
144
+ socket.write(command)
145
+ } else {
146
+ reject(new Error('Socket not writable'))
147
+ }
148
+ } catch (err) {
149
+ clearTimeout(timeout)
150
+ reject(err)
151
+ }
152
+ })
153
+ }
154
+
155
+ #cleanupConnections() {
156
+ const now = Date.now()
157
+ for (const [key, connection] of this.connectionPool.entries()) {
158
+ if (now - connection.lastUsed > this.config.connectionPoolTimeout) {
159
+ try {
160
+ connection.socket.end()
161
+ } catch {
162
+ // Ignore cleanup errors - connection already closed
163
+ }
164
+ this.connectionPool.delete(key)
165
+ log('Connection Pool', `Cleaned up connection to ${key}`)
166
+ }
167
+ }
168
+ }
169
+
170
+ #encodeQuotedPrintable(str) {
171
+ return (
172
+ str
173
+ // eslint-disable-next-line no-control-regex
174
+ .replace(/[=\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\xFF]/g, match => {
175
+ return '=' + match.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')
176
+ })
177
+ .replace(/[ \t]+$/gm, match => {
178
+ return match.replace(/./g, char => '=' + char.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0'))
179
+ })
180
+ .replace(/(.{75})/g, '$1=\r\n')
181
+ )
182
+ }
183
+
184
+ #encodeBase64(buffer) {
185
+ return buffer.toString('base64').replace(/(.{76})/g, '$1\r\n')
186
+ }
187
+
188
+ async #authenticateSocket(socket, username, password) {
189
+ if (!this.config.enableAuth) return true
190
+
191
+ try {
192
+ // Try AUTH LOGIN
193
+ let response = await this.#commandWithTimeout(socket, 'AUTH LOGIN\r\n')
194
+ if (response.startsWith('334')) {
195
+ // Send username
196
+ response = await this.#commandWithTimeout(socket, Buffer.from(username).toString('base64') + '\r\n')
197
+ if (response.startsWith('334')) {
198
+ // Send password
199
+ response = await this.#commandWithTimeout(socket, Buffer.from(password).toString('base64') + '\r\n')
200
+ if (response.startsWith('235')) {
201
+ log('SMTP Auth', 'Authentication successful')
202
+ return true
203
+ }
204
+ }
205
+ }
206
+
207
+ // Try AUTH PLAIN as fallback
208
+ const authString = Buffer.from(`\0${username}\0${password}`).toString('base64')
209
+ response = await this.#commandWithTimeout(socket, `AUTH PLAIN ${authString}\r\n`)
210
+ if (response.startsWith('235')) {
211
+ log('SMTP Auth', 'PLAIN authentication successful')
212
+ return true
213
+ }
214
+
215
+ log('SMTP Auth', 'Authentication failed')
216
+ return false
217
+ } catch (err) {
218
+ error('SMTP Auth Error', err.message)
219
+ return false
220
+ }
221
+ }
222
+
223
+ async #getConnectionFromPool(host, port) {
224
+ const key = `${host}:${port}`
225
+ const connection = this.connectionPool.get(key)
226
+
227
+ if (connection && connection.socket.readyState === 'open') {
228
+ connection.lastUsed = Date.now()
229
+ log('Connection Pool', `Reusing connection to ${key}`)
230
+ return connection.socket
231
+ }
232
+
233
+ if (connection) {
234
+ this.connectionPool.delete(key)
235
+ }
236
+
237
+ return null
238
+ }
239
+
240
+ #addToConnectionPool(host, port, socket) {
241
+ const key = `${host}:${port}`
242
+ if (this.connectionPool.size >= this.config.maxConnections) {
243
+ // Remove oldest connection
244
+ const oldestKey = this.connectionPool.keys().next().value
245
+ const oldConnection = this.connectionPool.get(oldestKey)
246
+ try {
247
+ oldConnection.socket.end()
248
+ } catch {
249
+ // Ignore cleanup errors
250
+ }
251
+ this.connectionPool.delete(oldestKey)
252
+ }
253
+
254
+ this.connectionPool.set(key, {
255
+ socket: socket,
256
+ lastUsed: Date.now()
257
+ })
258
+ log('Connection Pool', `Added connection to ${key}`)
259
+ }
260
+
261
+ async #connectWithRetry(sender, host, port, retryCount = 0) {
262
+ try {
263
+ return await this.#connect(sender, host, port)
264
+ } catch (err) {
265
+ if (retryCount < this.config.retryAttempts) {
266
+ log('SMTP Retry', `Retrying connection to ${host}:${port} (attempt ${retryCount + 1})`)
267
+ await new Promise(resolve => setTimeout(resolve, this.config.retryDelay * (retryCount + 1)))
268
+ return await this.#connectWithRetry(sender, host, port, retryCount + 1)
269
+ }
270
+ throw err
271
+ }
272
+ }
273
+
274
+ #connect(sender, host, port) {
275
+ return new Promise((resolve, reject) => {
276
+ // Check connection pool first
277
+ this.#getConnectionFromPool(host, port)
278
+ .then(pooledSocket => {
279
+ if (pooledSocket) {
280
+ return resolve(pooledSocket)
281
+ }
282
+
283
+ let socket
284
+ const timeout = setTimeout(() => {
285
+ if (socket) {
286
+ socket.destroy()
287
+ }
288
+ reject(new Error(`Connection timeout to ${host}:${port}`))
289
+ }, this.config.timeout)
290
+
291
+ const cleanup = () => {
292
+ clearTimeout(timeout)
293
+ }
294
+
295
+ if (port == 465) {
296
+ socket = tls.connect(
297
+ {
298
+ host: host,
299
+ port: port,
300
+ rejectUnauthorized: false,
301
+ timeout: this.config.timeout,
302
+ ...this.config.tls
303
+ },
304
+ async () => {
305
+ cleanup()
306
+ try {
307
+ socket.setEncoding('utf8')
308
+ await new Promise(resolve => socket.once('data', resolve))
309
+ await this.#commandWithTimeout(socket, `EHLO ${this.#sanitizeInput(sender)}\r\n`)
310
+ this.#addToConnectionPool(host, port, socket)
311
+ resolve(socket)
312
+ } catch (err) {
313
+ socket.destroy()
314
+ reject(err)
315
+ }
316
+ }
317
+ )
318
+
319
+ socket.on('error', err => {
320
+ if (err.code === 'ERR_SSL_NO_SHARED_CIPHER') {
321
+ error('TLS Cipher Error on port 465 - Trying fallback:', err.message)
322
+ // Try with minimal TLS configuration
323
+ const fallbackSocket = tls.connect({
324
+ host: host,
325
+ port: port,
326
+ rejectUnauthorized: false,
327
+ timeout: this.config.timeout,
328
+ minVersion: 'TLSv1.1'
329
+ })
330
+ fallbackSocket.on('secureConnect', async () => {
331
+ log('Fallback SSL connection successful on port 465')
332
+ try {
333
+ fallbackSocket.setEncoding('utf8')
334
+ await new Promise(resolve => fallbackSocket.once('data', resolve))
335
+ await this.#commandWithTimeout(fallbackSocket, `EHLO ${this.#sanitizeInput(sender)}\r\n`)
336
+ this.#addToConnectionPool(host, port, fallbackSocket)
337
+ resolve(fallbackSocket)
338
+ } catch (fallbackErr) {
339
+ fallbackSocket.destroy()
340
+ reject(fallbackErr)
341
+ }
342
+ })
343
+ fallbackSocket.on('error', fallbackErr => {
344
+ error('Fallback SSL connection also failed:', fallbackErr)
345
+ reject(err)
346
+ })
347
+ } else {
348
+ error('SSL Error on port 465:', err)
349
+ socket.destroy()
350
+ reject(err)
351
+ }
352
+ })
353
+ } else {
354
+ socket = net.createConnection(port, host, async () => {
355
+ cleanup()
356
+ try {
357
+ socket.setEncoding('utf8')
358
+ socket.setTimeout(this.config.timeout)
359
+ await new Promise(resolve => socket.once('data', resolve))
360
+ let response = await this.#commandWithTimeout(socket, `EHLO ${this.#sanitizeInput(sender)}\r\n`)
361
+
362
+ if (!response.startsWith('2') || !response.includes('STARTTLS')) {
363
+ this.#addToConnectionPool(host, port, socket)
364
+ return resolve(socket)
365
+ }
366
+
367
+ response = await this.#commandWithTimeout(socket, `STARTTLS\r\n`)
368
+ if (!response.startsWith('2')) {
369
+ this.#addToConnectionPool(host, port, socket)
370
+ return resolve(socket)
371
+ }
372
+
373
+ socket.removeAllListeners()
374
+ socket = tls.connect(
375
+ {
376
+ socket: socket,
377
+ servername: host,
378
+ rejectUnauthorized: false,
379
+ ...this.config.tls
380
+ },
381
+ async () => {
382
+ try {
383
+ socket.setEncoding('utf8')
384
+ await new Promise(resolve => setTimeout(resolve, 1000))
385
+ response = await this.#commandWithTimeout(socket, `EHLO ${this.#sanitizeInput(sender)}\r\n`)
386
+ this.#addToConnectionPool(host, port, socket)
387
+ resolve(socket)
388
+ } catch (err) {
389
+ socket.destroy()
390
+ reject(err)
391
+ }
392
+ }
393
+ )
394
+
395
+ socket.on('error', err => {
396
+ if (err.code === 'ERR_SSL_NO_SHARED_CIPHER') {
397
+ error('TLS Cipher Error - Trying fallback connection:', err.message)
398
+ // Try without custom cipher configuration as fallback
399
+ const fallbackSocket = tls.connect({
400
+ socket: socket,
401
+ servername: host,
402
+ rejectUnauthorized: false,
403
+ minVersion: 'TLSv1.1'
404
+ })
405
+ fallbackSocket.on('secureConnect', () => {
406
+ log('Fallback TLS connection successful')
407
+ resolve(fallbackSocket)
408
+ })
409
+ fallbackSocket.on('error', fallbackErr => {
410
+ error('Fallback connection also failed:', fallbackErr)
411
+ reject(err)
412
+ })
413
+ } else {
414
+ error('Error connecting to the server (TLS):', err)
415
+ socket.destroy()
416
+ reject(err)
417
+ }
418
+ })
419
+ } catch (err) {
420
+ socket.destroy()
421
+ reject(err)
422
+ }
423
+ })
424
+ }
425
+
426
+ socket.on('error', err => {
427
+ cleanup()
428
+ error('Error connecting to the server:', err)
429
+ reject(err)
430
+ })
431
+
432
+ socket.on('timeout', () => {
433
+ cleanup()
434
+ socket.destroy()
435
+ reject(new Error(`Socket timeout to ${host}:${port}`))
436
+ })
437
+ })
438
+ .catch(reject)
439
+ })
440
+ }
441
+
442
+ #content(obj) {
443
+ try {
444
+ let domain = obj.from.value[0].address.split('@')[1]
445
+ let headers = obj.headerLines.map(header => `${header.line}`).join('\r\n')
446
+ let content = ''
447
+
448
+ if (obj.html.length || obj.attachments.length) {
449
+ let boundary = headers.match(/boundary="(.*)"/)?.[1]
450
+ if (!boundary) {
451
+ boundary = 'boundary_' + nodeCrypto.randomBytes(16).toString('hex')
452
+ headers = headers.replace(/Content-Type: multipart\/mixed/, `Content-Type: multipart/mixed; boundary="${boundary}"`)
453
+ }
454
+
455
+ if (obj.text.length) {
456
+ content += `--${boundary}\r\nContent-Type: text/plain; charset="UTF-8"\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n${this.#encodeQuotedPrintable(obj.text)}\r\n`
457
+ }
458
+
459
+ if (obj.html.length) {
460
+ content += `--${boundary}\r\nContent-Type: text/html; charset="UTF-8"\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n${this.#encodeQuotedPrintable(obj.html)}\r\n`
461
+ }
462
+
463
+ for (let attachment of obj.attachments) {
464
+ if (!attachment.filename || !attachment.content) {
465
+ error('Invalid attachment', attachment)
466
+ continue
467
+ }
468
+
469
+ const encodedContent = Buffer.isBuffer(attachment.content)
470
+ ? this.#encodeBase64(attachment.content)
471
+ : Buffer.from(attachment.content).toString('base64')
472
+
473
+ content += `--${boundary}\r\nContent-Type: ${attachment.contentType || 'application/octet-stream'}; name="${this.#sanitizeInput(attachment.filename)}"\r\nContent-Transfer-Encoding: base64\r\nContent-Disposition: attachment; filename="${this.#sanitizeInput(attachment.filename)}"\r\n\r\n${encodedContent}\r\n`
474
+ }
475
+ content += `--${boundary}--\r\n`
476
+ } else {
477
+ content = this.#encodeQuotedPrintable(obj.text || '')
478
+ }
479
+
480
+ if (content) content = content.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n')
481
+
482
+ let signature = ''
483
+ if (this.config.enableDKIM) {
484
+ try {
485
+ let dkim = Candy.core('Config').config.websites[domain]?.cert?.dkim
486
+ if (dkim && this.#validateDKIMConfig(dkim)) {
487
+ signature = this.#dkim({
488
+ header: headers,
489
+ content: content,
490
+ domain: domain,
491
+ private: fs.readFileSync(dkim.private, 'utf8'),
492
+ selector: dkim.selector || 'default'
493
+ })
494
+ }
495
+ } catch (err) {
496
+ error('DKIM Error', err.message)
497
+ // Continue without DKIM if there's an error
498
+ }
499
+ }
500
+
501
+ content = signature + (signature ? '\r\n' : '') + headers + '\r\n\r\n' + content + '\r\n'
502
+
503
+ // Check email size
504
+ const emailSize = Buffer.byteLength(content, 'utf8')
505
+ if (emailSize > this.config.maxEmailSize) {
506
+ throw new Error(`Email size (${emailSize} bytes) exceeds maximum allowed size (${this.config.maxEmailSize} bytes)`)
507
+ }
508
+
509
+ return content
510
+ } catch (err) {
511
+ error('Content generation error', err.message)
512
+ throw err
513
+ }
514
+ }
515
+
516
+ #validateDKIMConfig(dkim) {
517
+ if (!dkim || !dkim.private) {
518
+ return false
519
+ }
520
+
521
+ try {
522
+ // Check if private key file exists and is readable
523
+ if (!fs.existsSync(dkim.private)) {
524
+ error('DKIM Error', `Private key file not found: ${dkim.private}`)
525
+ return false
526
+ }
527
+
528
+ const stats = fs.statSync(dkim.private)
529
+ if (!stats.isFile()) {
530
+ error('DKIM Error', `Private key path is not a file: ${dkim.private}`)
531
+ return false
532
+ }
533
+
534
+ // Check file permissions (should not be world readable)
535
+ if (stats.mode & 0o044) {
536
+ error('DKIM Warning', `Private key file has loose permissions: ${dkim.private}`)
537
+ }
538
+
539
+ // Try to read the key to validate format
540
+ const keyContent = fs.readFileSync(dkim.private, 'utf8')
541
+ if (!keyContent.includes('BEGIN') || !keyContent.includes('PRIVATE KEY')) {
542
+ error('DKIM Error', 'Invalid private key format')
543
+ return false
544
+ }
545
+
546
+ return true
547
+ } catch (err) {
548
+ error('DKIM Validation Error', err.message)
549
+ return false
550
+ }
551
+ }
552
+
553
+ #dkim(obj) {
554
+ try {
555
+ const options = {
556
+ domainName: obj.domain,
557
+ keySelector: obj.selector,
558
+ privateKey: obj.private,
559
+ headerFieldNames: 'from:to:subject:date:message-id'
560
+ }
561
+ return DKIMSign(obj.header + '\r\n\r\n' + obj.content, options)
562
+ } catch (err) {
563
+ error('DKIM Signing Error', err.message)
564
+ throw err
565
+ }
566
+ }
567
+
568
+ async #host(domain) {
569
+ // Check cache first
570
+ if (this.mxCache.has(domain)) {
571
+ const cached = this.mxCache.get(domain)
572
+ if (Date.now() - cached.timestamp < 3600000) {
573
+ // 1 hour cache
574
+ log('DNS Cache', `Using cached MX for ${domain}: ${cached.host}`)
575
+ return cached.host
576
+ } else {
577
+ this.mxCache.delete(domain)
578
+ }
579
+ }
580
+
581
+ try {
582
+ const addresses = await Promise.race([
583
+ resolveMx(domain),
584
+ new Promise((_, reject) => setTimeout(() => reject(new Error('DNS timeout')), this.config.dnsTimeout))
585
+ ])
586
+
587
+ if (!addresses || addresses.length === 0) {
588
+ throw new Error(`No MX records found for ${domain}`)
589
+ }
590
+
591
+ addresses.sort((a, b) => a.priority - b.priority)
592
+ const host = addresses[0].exchange
593
+
594
+ // Cache the result
595
+ this.mxCache.set(domain, {
596
+ host: host,
597
+ timestamp: Date.now()
598
+ })
599
+
600
+ log('DNS Resolution', `MX for ${domain}: ${host}`)
601
+ return host
602
+ } catch (err) {
603
+ error('DNS Resolution Error', `Failed to resolve MX for ${domain}: ${err.message}`)
604
+ throw new Error(`Failed to resolve MX records for ${domain}`)
605
+ }
606
+ }
607
+
608
+ async #sendSingle(to, obj, retryCount = 0) {
609
+ try {
610
+ log('Mail', `Sending email to ${to}`)
611
+
612
+ const domain = to.split('@')[1]
613
+ this.#checkRateLimit(domain)
614
+
615
+ const host = await this.#host(domain)
616
+ const sender = obj.from.value[0].address.split('@')[1]
617
+
618
+ let socket = null
619
+ let lastError = null
620
+
621
+ // Try different ports
622
+ for (const port of this.config.ports) {
623
+ try {
624
+ socket = await this.#connectWithRetry(sender, host, port)
625
+ if (socket) {
626
+ log('Mail', `Connected to ${host}:${port}`)
627
+ break
628
+ }
629
+ } catch (err) {
630
+ lastError = err
631
+ log('Mail', `Failed to connect to ${host}:${port} - ${err.message}`)
632
+ }
633
+ }
634
+
635
+ if (!socket) {
636
+ throw new Error(`Could not connect to any port for ${host}. Last error: ${lastError?.message}`)
637
+ }
638
+
639
+ try {
640
+ // Authentication if configured
641
+ const config = Candy.core('Config').config.websites[sender]
642
+ if (config?.smtp?.auth) {
643
+ const authSuccess = await this.#authenticateSocket(socket, config.smtp.username, config.smtp.password)
644
+ if (!authSuccess) {
645
+ throw new Error('SMTP authentication failed')
646
+ }
647
+ }
648
+
649
+ let result = await this.#commandWithTimeout(socket, `MAIL FROM:<${this.#sanitizeInput(obj.from.value[0].address)}>\r\n`)
650
+ if (!result.startsWith('2')) {
651
+ throw new Error(`MAIL FROM rejected: ${result.trim()}`)
652
+ }
653
+
654
+ result = await this.#commandWithTimeout(socket, `RCPT TO:<${this.#sanitizeInput(to)}>\r\n`)
655
+ if (!result.startsWith('2')) {
656
+ throw new Error(`RCPT TO rejected: ${result.trim()}`)
657
+ }
658
+
659
+ result = await this.#commandWithTimeout(socket, `DATA\r\n`)
660
+ if (!result.startsWith('2') && !result.startsWith('3')) {
661
+ throw new Error(`DATA command rejected: ${result.trim()}`)
662
+ }
663
+
664
+ const emailContent = this.#content(obj)
665
+ if (socket.writable) {
666
+ socket.write(emailContent)
667
+ } else {
668
+ throw new Error('Socket became unwritable during DATA transfer')
669
+ }
670
+
671
+ result = await this.#commandWithTimeout(socket, `.\r\n`)
672
+ if (!result.startsWith('2')) {
673
+ throw new Error(`Email content rejected: ${result.trim()}`)
674
+ }
675
+
676
+ log('Mail', `Email sent successfully to ${to}`)
677
+ } finally {
678
+ // Don't close the socket, let it be reused from pool
679
+ // if (socket) socket.end()
680
+ }
681
+ } catch (err) {
682
+ if (retryCount < this.config.retryAttempts) {
683
+ log('Mail Retry', `Retrying email to ${to} (attempt ${retryCount + 1}): ${err.message}`)
684
+ await new Promise(resolve => setTimeout(resolve, this.config.retryDelay * (retryCount + 1)))
685
+ return await this.#sendSingle(to, obj, retryCount + 1)
686
+ } else {
687
+ error('Mail Error', `Failed to send email to ${to} after ${this.config.retryAttempts} attempts: ${err.message}`)
688
+ throw err
689
+ }
690
+ }
691
+ }
692
+
693
+ async send(obj) {
694
+ try {
695
+ // Validate email object
696
+ this.#validateEmailObject(obj)
697
+
698
+ log('Mail', `Starting to send email from ${obj.from.value[0].address} to ${obj.to.value.length} recipients`)
699
+
700
+ const results = []
701
+ const errors = []
702
+
703
+ // Send to all recipients
704
+ for (const recipient of obj.to.value) {
705
+ try {
706
+ await this.#sendSingle(recipient.address, obj)
707
+ results.push({
708
+ address: recipient.address,
709
+ status: 'sent',
710
+ timestamp: new Date().toISOString()
711
+ })
712
+ } catch (err) {
713
+ const errorInfo = {
714
+ address: recipient.address,
715
+ status: 'failed',
716
+ error: err.message,
717
+ timestamp: new Date().toISOString()
718
+ }
719
+ results.push(errorInfo)
720
+ errors.push(errorInfo)
721
+ error('Mail Send Error', `Failed to send to ${recipient.address}: ${err.message}`)
722
+ }
723
+ }
724
+
725
+ // Log summary
726
+ const successful = results.filter(r => r.status === 'sent').length
727
+ const failed = errors.length
728
+
729
+ log('Mail Summary', `Email sending completed: ${successful} successful, ${failed} failed`)
730
+
731
+ if (errors.length > 0) {
732
+ log('Mail Errors', `Failed recipients: ${errors.map(e => e.address).join(', ')}`)
733
+ }
734
+
735
+ return {
736
+ total: obj.to.value.length,
737
+ successful: successful,
738
+ failed: failed,
739
+ results: results,
740
+ errors: errors
741
+ }
742
+ } catch (err) {
743
+ error('Mail Send Error', `Email sending failed: ${err.message}`)
744
+ throw err
745
+ }
746
+ }
747
+
748
+ // Public method to get connection pool stats
749
+ getStats() {
750
+ return {
751
+ connectionPoolSize: this.connectionPool.size,
752
+ mxCacheSize: this.mxCache.size,
753
+ rateLimiterDomains: this.rateLimiter.size,
754
+ config: {
755
+ timeout: this.config.timeout,
756
+ retryAttempts: this.config.retryAttempts,
757
+ maxConnections: this.config.maxConnections,
758
+ enableAuth: this.config.enableAuth,
759
+ enableDKIM: this.config.enableDKIM
760
+ }
761
+ }
762
+ }
763
+
764
+ // Public method to clear caches
765
+ clearCaches() {
766
+ this.mxCache.clear()
767
+ this.rateLimiter.clear()
768
+ for (const [, connection] of this.connectionPool.entries()) {
769
+ try {
770
+ connection.socket.end()
771
+ } catch {
772
+ // Ignore cleanup errors
773
+ }
774
+ }
775
+ this.connectionPool.clear()
776
+ log('SMTP', 'All caches and connections cleared')
777
+ }
778
+
779
+ // Public method to update configuration
780
+ updateConfig(newConfig) {
781
+ this.config = {...this.config, ...newConfig}
782
+ log('SMTP', 'Configuration updated')
783
+ }
784
+ }
785
+
786
+ module.exports = new smtp()