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.
- package/.editorconfig +21 -0
- package/.github/workflows/auto-pr-description.yml +49 -0
- package/.github/workflows/release.yml +32 -0
- package/.github/workflows/test-coverage.yml +58 -0
- package/.husky/pre-commit +2 -0
- package/.kiro/steering/code-style.md +56 -0
- package/.kiro/steering/product.md +20 -0
- package/.kiro/steering/structure.md +77 -0
- package/.kiro/steering/tech.md +87 -0
- package/.prettierrc +10 -0
- package/.releaserc.js +134 -0
- package/AGENTS.md +84 -0
- package/CHANGELOG.md +181 -0
- package/CODE_OF_CONDUCT.md +83 -0
- package/CONTRIBUTING.md +63 -0
- package/LICENSE +661 -0
- package/README.md +57 -0
- package/SECURITY.md +26 -0
- package/bin/candy +10 -0
- package/bin/candypack +10 -0
- package/cli/index.js +3 -0
- package/cli/src/Cli.js +348 -0
- package/cli/src/Connector.js +93 -0
- package/cli/src/Monitor.js +416 -0
- package/core/Candy.js +87 -0
- package/core/Commands.js +239 -0
- package/core/Config.js +1094 -0
- package/core/Lang.js +52 -0
- package/core/Log.js +43 -0
- package/core/Process.js +26 -0
- package/docs/backend/01-overview/01-whats-in-the-candy-box.md +9 -0
- package/docs/backend/01-overview/02-super-handy-helper-functions.md +9 -0
- package/docs/backend/01-overview/03-development-server.md +79 -0
- package/docs/backend/02-structure/01-typical-project-layout.md +39 -0
- package/docs/backend/03-config/00-configuration-overview.md +214 -0
- package/docs/backend/03-config/01-database-connection.md +60 -0
- package/docs/backend/03-config/02-static-route-mapping-optional.md +20 -0
- package/docs/backend/03-config/03-request-timeout.md +11 -0
- package/docs/backend/03-config/04-environment-variables.md +227 -0
- package/docs/backend/03-config/05-early-hints.md +352 -0
- package/docs/backend/04-routing/01-basic-page-routes.md +28 -0
- package/docs/backend/04-routing/02-controller-less-view-routes.md +43 -0
- package/docs/backend/04-routing/03-api-and-data-routes.md +20 -0
- package/docs/backend/04-routing/04-authentication-aware-routes.md +48 -0
- package/docs/backend/04-routing/05-advanced-routing.md +14 -0
- package/docs/backend/04-routing/06-error-pages.md +101 -0
- package/docs/backend/04-routing/07-cron-jobs.md +149 -0
- package/docs/backend/05-controllers/01-how-to-build-a-controller.md +17 -0
- package/docs/backend/05-controllers/02-your-trusty-candy-assistant.md +20 -0
- package/docs/backend/05-controllers/03-controller-classes.md +93 -0
- package/docs/backend/05-forms/01-custom-forms.md +395 -0
- package/docs/backend/05-forms/02-automatic-database-insert.md +297 -0
- package/docs/backend/06-request-and-response/01-the-request-object-what-is-the-user-asking-for.md +96 -0
- package/docs/backend/06-request-and-response/02-sending-a-response-replying-to-the-user.md +40 -0
- package/docs/backend/07-views/01-the-view-directory.md +73 -0
- package/docs/backend/07-views/02-rendering-a-view.md +179 -0
- package/docs/backend/07-views/03-template-syntax.md +181 -0
- package/docs/backend/07-views/03-variables.md +328 -0
- package/docs/backend/07-views/04-request-data.md +231 -0
- package/docs/backend/07-views/05-conditionals.md +290 -0
- package/docs/backend/07-views/06-loops.md +353 -0
- package/docs/backend/07-views/07-translations.md +358 -0
- package/docs/backend/07-views/08-backend-javascript.md +398 -0
- package/docs/backend/07-views/09-comments.md +297 -0
- package/docs/backend/08-database/01-database-connection.md +99 -0
- package/docs/backend/08-database/02-using-mysql.md +322 -0
- package/docs/backend/09-validation/01-the-validator-service.md +424 -0
- package/docs/backend/10-authentication/01-user-logins-with-authjs.md +53 -0
- package/docs/backend/10-authentication/02-foiling-villains-with-csrf-protection.md +55 -0
- package/docs/backend/10-authentication/03-register.md +134 -0
- package/docs/backend/10-authentication/04-candy-register-forms.md +676 -0
- package/docs/backend/10-authentication/05-session-management.md +159 -0
- package/docs/backend/10-authentication/06-candy-login-forms.md +596 -0
- package/docs/backend/11-mail/01-the-mail-service.md +42 -0
- package/docs/backend/12-streaming/01-streaming-overview.md +300 -0
- package/docs/backend/13-utilities/01-candy-var.md +504 -0
- package/docs/frontend/01-overview/01-introduction.md +146 -0
- package/docs/frontend/02-ajax-navigation/01-quick-start.md +608 -0
- package/docs/frontend/02-ajax-navigation/02-configuration.md +370 -0
- package/docs/frontend/02-ajax-navigation/03-advanced-usage.md +519 -0
- package/docs/frontend/03-forms/01-form-handling.md +420 -0
- package/docs/frontend/04-api-requests/01-get-post.md +443 -0
- package/docs/frontend/05-streaming/01-client-streaming.md +163 -0
- package/docs/index.json +452 -0
- package/docs/server/01-installation/01-quick-install.md +19 -0
- package/docs/server/01-installation/02-manual-installation-via-npm.md +9 -0
- package/docs/server/02-get-started/01-core-concepts.md +7 -0
- package/docs/server/02-get-started/02-basic-commands.md +57 -0
- package/docs/server/02-get-started/03-cli-reference.md +276 -0
- package/docs/server/02-get-started/04-cli-quick-reference.md +102 -0
- package/docs/server/03-service/01-start-a-new-service.md +57 -0
- package/docs/server/03-service/02-delete-a-service.md +48 -0
- package/docs/server/04-web/01-create-a-website.md +36 -0
- package/docs/server/04-web/02-list-websites.md +9 -0
- package/docs/server/04-web/03-delete-a-website.md +29 -0
- package/docs/server/05-subdomain/01-create-a-subdomain.md +32 -0
- package/docs/server/05-subdomain/02-list-subdomains.md +33 -0
- package/docs/server/05-subdomain/03-delete-a-subdomain.md +41 -0
- package/docs/server/06-ssl/01-renew-an-ssl-certificate.md +34 -0
- package/docs/server/07-mail/01-create-a-mail-account.md +23 -0
- package/docs/server/07-mail/02-delete-a-mail-account.md +20 -0
- package/docs/server/07-mail/03-list-mail-accounts.md +20 -0
- package/docs/server/07-mail/04-change-account-password.md +23 -0
- package/eslint.config.mjs +120 -0
- package/framework/index.js +4 -0
- package/framework/src/Auth.js +309 -0
- package/framework/src/Candy.js +81 -0
- package/framework/src/Config.js +79 -0
- package/framework/src/Env.js +60 -0
- package/framework/src/Lang.js +57 -0
- package/framework/src/Mail.js +83 -0
- package/framework/src/Mysql.js +575 -0
- package/framework/src/Request.js +301 -0
- package/framework/src/Route/Cron.js +128 -0
- package/framework/src/Route/Internal.js +439 -0
- package/framework/src/Route.js +455 -0
- package/framework/src/Server.js +15 -0
- package/framework/src/Stream.js +163 -0
- package/framework/src/Token.js +37 -0
- package/framework/src/Validator.js +271 -0
- package/framework/src/Var.js +211 -0
- package/framework/src/View/EarlyHints.js +190 -0
- package/framework/src/View/Form.js +600 -0
- package/framework/src/View.js +513 -0
- package/framework/web/candy.js +838 -0
- package/jest.config.js +22 -0
- package/locale/de-DE.json +80 -0
- package/locale/en-US.json +79 -0
- package/locale/es-ES.json +80 -0
- package/locale/fr-FR.json +80 -0
- package/locale/pt-BR.json +80 -0
- package/locale/ru-RU.json +80 -0
- package/locale/tr-TR.json +85 -0
- package/locale/zh-CN.json +80 -0
- package/package.json +86 -0
- package/server/index.js +5 -0
- package/server/src/Api.js +88 -0
- package/server/src/DNS.js +940 -0
- package/server/src/Hub.js +535 -0
- package/server/src/Mail.js +571 -0
- package/server/src/SSL.js +180 -0
- package/server/src/Server.js +27 -0
- package/server/src/Service.js +248 -0
- package/server/src/Subdomain.js +64 -0
- package/server/src/Web/Firewall.js +170 -0
- package/server/src/Web/Proxy.js +134 -0
- package/server/src/Web.js +451 -0
- package/server/src/mail/imap.js +1091 -0
- package/server/src/mail/server.js +32 -0
- package/server/src/mail/smtp.js +786 -0
- package/test/cli/Cli.test.js +36 -0
- package/test/core/Candy.test.js +234 -0
- package/test/core/Commands.test.js +538 -0
- package/test/core/Config.test.js +1435 -0
- package/test/core/Lang.test.js +250 -0
- package/test/core/Process.test.js +156 -0
- package/test/framework/Route.test.js +239 -0
- package/test/framework/View/EarlyHints.test.js +282 -0
- package/test/scripts/check-coverage.js +132 -0
- package/test/server/Api.test.js +647 -0
- package/test/server/Client.test.js +338 -0
- package/test/server/DNS.test.js +2050 -0
- package/test/server/DNS.test.js.bak +2084 -0
- package/test/server/Log.test.js +73 -0
- package/test/server/Mail.account.test_.js +460 -0
- package/test/server/Mail.init.test_.js +411 -0
- package/test/server/Mail.test_.js +1340 -0
- package/test/server/SSL.test_.js +1491 -0
- package/test/server/Server.test.js +765 -0
- package/test/server/Service.test_.js +1127 -0
- package/test/server/Subdomain.test.js +440 -0
- package/test/server/Web/Firewall.test.js +175 -0
- package/test/server/Web.test_.js +1562 -0
- package/test/server/__mocks__/acme-client.js +17 -0
- package/test/server/__mocks__/bcrypt.js +50 -0
- package/test/server/__mocks__/child_process.js +389 -0
- package/test/server/__mocks__/crypto.js +432 -0
- package/test/server/__mocks__/fs.js +450 -0
- package/test/server/__mocks__/globalCandy.js +227 -0
- package/test/server/__mocks__/http-proxy.js +105 -0
- package/test/server/__mocks__/http.js +575 -0
- package/test/server/__mocks__/https.js +272 -0
- package/test/server/__mocks__/index.js +249 -0
- package/test/server/__mocks__/mail/server.js +100 -0
- package/test/server/__mocks__/mail/smtp.js +31 -0
- package/test/server/__mocks__/mailparser.js +81 -0
- package/test/server/__mocks__/net.js +369 -0
- package/test/server/__mocks__/node-forge.js +328 -0
- package/test/server/__mocks__/os.js +320 -0
- package/test/server/__mocks__/path.js +291 -0
- package/test/server/__mocks__/selfsigned.js +8 -0
- package/test/server/__mocks__/server/src/mail/server.js +100 -0
- package/test/server/__mocks__/server/src/mail/smtp.js +31 -0
- package/test/server/__mocks__/smtp-server.js +106 -0
- package/test/server/__mocks__/sqlite3.js +394 -0
- package/test/server/__mocks__/testFactories.js +299 -0
- package/test/server/__mocks__/testHelpers.js +363 -0
- package/test/server/__mocks__/tls.js +229 -0
- package/watchdog/index.js +3 -0
- package/watchdog/src/Watchdog.js +156 -0
- package/web/config.json +5 -0
- package/web/controller/page/about.js +27 -0
- package/web/controller/page/index.js +34 -0
- package/web/package.json +18 -0
- package/web/public/assets/css/style.css +1835 -0
- package/web/public/assets/js/app.js +96 -0
- package/web/route/www.js +19 -0
- package/web/skeleton/main.html +22 -0
- package/web/view/content/about.html +65 -0
- package/web/view/content/home.html +205 -0
- package/web/view/footer/main.html +11 -0
- package/web/view/head/main.html +5 -0
- 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()
|