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,1091 @@
1
+ const {log, error} = Candy.core('Log', false).init('Mail', 'IMAP')
2
+
3
+ // IMAP Constants
4
+ const CONSTANTS = {
5
+ UIDVALIDITY: 123456789,
6
+ MAX_AUTH_DATA_SIZE: 1024,
7
+ MAX_COMMAND_SIZE: 8192,
8
+ TIMEOUT_INTERVAL: 30000,
9
+ DEFAULT_BOXES: ['INBOX', 'Drafts', 'Sent', 'Spam', 'Trash'],
10
+ PERMANENT_FLAGS: ['\\Answered', '\\Flagged', '\\Deleted', '\\Seen', '\\Draft', '\\*'],
11
+ CAPABILITIES: ['IMAP4rev1', 'AUTH=PLAIN', 'STARTTLS', 'IDLE'],
12
+ MAX_CONNECTIONS_PER_IP: 10
13
+ }
14
+
15
+ // Rate limiting store
16
+ const rateLimitStore = new Map()
17
+
18
+ class Connection {
19
+ #auth
20
+ #actions = {
21
+ APPEND: () => this.#append(),
22
+ AUTHENTICATE: () => this.#authenticate(),
23
+ CAPABILITY: () => this.#capability(),
24
+ CLOSE: () => this.#close(),
25
+ COPY: () => this.#copy(),
26
+ CREATE: () => this.#create(),
27
+ DELETE: () => this.#delete(),
28
+ EXAMINE: () => this.#examine(),
29
+ EXPUNGE: () => this.#expunge(),
30
+ FETCH: () => this.#fetch(),
31
+ IDLE: () => this.#idle(),
32
+ LIST: () => this.#list(),
33
+ LSUB: () => this.#lsub(),
34
+ LOGIN: () => this.#login(),
35
+ LOGOUT: () => this.#logout(),
36
+ NOOP: () => this.#noop(),
37
+ RENAME: () => this.#rename(),
38
+ SEARCH: () => this.#search(),
39
+ SELECT: () => this.#select(),
40
+ STARTTLS: () => this.#starttls(),
41
+ STATUS: () => this.#status(),
42
+ STORE: () => this.#store()
43
+ }
44
+ #box = 'INBOX'
45
+ #commands
46
+ #end = false
47
+ #idleInterval
48
+ #options
49
+ #request
50
+ #socket
51
+ #timeout
52
+ #wait = false
53
+
54
+ constructor(socket, self) {
55
+ this.#socket = socket
56
+ this.#options = self.options
57
+ this.#setupRateLimit()
58
+ this.#setupTimeout()
59
+ }
60
+
61
+ #setupRateLimit() {
62
+ const clientIP = this.#getClientIP()
63
+ const now = Date.now()
64
+
65
+ if (!rateLimitStore.has(clientIP)) {
66
+ rateLimitStore.set(clientIP, {count: 1, firstRequest: now})
67
+ } else {
68
+ const clientData = rateLimitStore.get(clientIP)
69
+ if (now - clientData.firstRequest > 60000) {
70
+ // Reset after 1 minute
71
+ rateLimitStore.set(clientIP, {count: 1, firstRequest: now})
72
+ } else {
73
+ clientData.count++
74
+ if (clientData.count > CONSTANTS.MAX_CONNECTIONS_PER_IP) {
75
+ this.#socket.end()
76
+ return
77
+ }
78
+ }
79
+ }
80
+ }
81
+
82
+ #setupTimeout() {
83
+ this.#timeout = setTimeout(() => {
84
+ if (!this.#end && this.#socket && !this.#socket.destroyed) {
85
+ try {
86
+ this.#socket.write('* BYE Server timeout\r\n')
87
+ this.#end = true
88
+ this.#socket.end()
89
+ } catch (timeoutError) {
90
+ // Socket might already be closed, just cleanup
91
+ error('Timeout cleanup error:', timeoutError.message)
92
+ this.#cleanup()
93
+ }
94
+ }
95
+ }, CONSTANTS.TIMEOUT_INTERVAL)
96
+ }
97
+
98
+ #getClientIP() {
99
+ return this.#socket.remoteAddress?.replace('::ffff:', '') || '0.0.0.0'
100
+ }
101
+
102
+ #authenticate() {
103
+ try {
104
+ const authMechanism = this.#commands[2]?.toUpperCase()
105
+ log('Authenticate request from: ' + this.#getClientIP() + ' using mechanism: ' + authMechanism)
106
+
107
+ // Send appropriate challenge based on auth mechanism
108
+ if (authMechanism === 'PLAIN') {
109
+ this.#write('+ \r\n') // Empty challenge for PLAIN
110
+ } else if (authMechanism === 'LOGIN') {
111
+ this.#write('+ VXNlcm5hbWU6\r\n') // Base64 encoded "Username:"
112
+ } else {
113
+ this.#write('+ Ready for authentication\r\n')
114
+ }
115
+
116
+ this.#wait = true
117
+
118
+ // Set a timeout for authentication data
119
+ const authTimeout = setTimeout(() => {
120
+ if (this.#wait) {
121
+ this.#wait = false
122
+ this.#write(`${this.#request.id} NO Authentication timeout\r\n`)
123
+ log('Authentication timeout from: ' + this.#getClientIP())
124
+ }
125
+ }, 10000) // 10 second timeout
126
+
127
+ this.#socket.once('data', data => {
128
+ clearTimeout(authTimeout)
129
+ this.#wait = false
130
+
131
+ try {
132
+ const dataStr = data.toString().trim()
133
+ log('Authentication data received from ' + this.#getClientIP() + ': ' + dataStr.substring(0, 50) + '...')
134
+
135
+ if (!dataStr || dataStr.length > CONSTANTS.MAX_AUTH_DATA_SIZE) {
136
+ this.#write(`${this.#request.id} NO Authentication data too large\r\n`)
137
+ log('Authentication data too large from: ' + this.#getClientIP())
138
+ return
139
+ }
140
+
141
+ // Handle AUTHENTICATE CANCEL
142
+ if (dataStr === '*') {
143
+ this.#write(`${this.#request.id} BAD Authentication cancelled\r\n`)
144
+ log('Authentication cancelled by client: ' + this.#getClientIP())
145
+ return
146
+ }
147
+
148
+ if (authMechanism === 'PLAIN') {
149
+ let auth
150
+ try {
151
+ auth = Buffer.from(dataStr, 'base64').toString('utf8').split('\0')
152
+ } catch {
153
+ this.#write(`${this.#request.id} NO Authentication data invalid\r\n`)
154
+ log('Base64 decode failed from: ' + this.#getClientIP())
155
+ return
156
+ }
157
+
158
+ if (auth.length !== 3 || !auth[1] || !auth[2]) {
159
+ this.#write(`${this.#request.id} NO Authentication failed\r\n`)
160
+ this.#auth = false
161
+ log('Authentication failed - invalid format from: ' + this.#getClientIP())
162
+ return
163
+ }
164
+
165
+ if (!this.#options.onAuth || typeof this.#options.onAuth !== 'function') {
166
+ this.#write(`${this.#request.id} NO Authentication not available\r\n`)
167
+ log('onAuth handler not available')
168
+ return
169
+ }
170
+
171
+ this.#options.onAuth(
172
+ {
173
+ username: auth[1],
174
+ password: auth[2]
175
+ },
176
+ {
177
+ remoteAddress: this.#getClientIP()
178
+ },
179
+ err => {
180
+ if (err) {
181
+ this.#write(`${this.#request.id} NO Authentication failed\r\n`)
182
+ log('Authentication failed for: ' + auth[1])
183
+ this.#auth = false
184
+ } else {
185
+ this.#write(`${this.#request.id} OK Authentication successful\r\n`)
186
+ log('Authentication successful for: ' + auth[1])
187
+ this.#auth = auth[1]
188
+ }
189
+ }
190
+ )
191
+ } else if (authMechanism === 'LOGIN') {
192
+ // LOGIN mechanism expects username first, then password in separate responses
193
+ const username = Buffer.from(dataStr, 'base64').toString('utf8')
194
+ this.#write('+ UGFzc3dvcmQ6\r\n') // Base64 encoded "Password:"
195
+
196
+ this.#socket.once('data', passwordData => {
197
+ const password = Buffer.from(passwordData.toString().trim(), 'base64').toString('utf8')
198
+
199
+ if (!this.#options.onAuth || typeof this.#options.onAuth !== 'function') {
200
+ this.#write(`${this.#request.id} NO Authentication not available\r\n`)
201
+ return
202
+ }
203
+
204
+ this.#options.onAuth(
205
+ {
206
+ username: username,
207
+ password: password
208
+ },
209
+ {
210
+ remoteAddress: this.#getClientIP()
211
+ },
212
+ err => {
213
+ if (err) {
214
+ this.#write(`${this.#request.id} NO Authentication failed\r\n`)
215
+ log('Authentication failed for: ' + username)
216
+ this.#auth = false
217
+ } else {
218
+ this.#write(`${this.#request.id} OK Authentication successful\r\n`)
219
+ log('Authentication successful for: ' + username)
220
+ this.#auth = username
221
+ }
222
+ }
223
+ )
224
+ })
225
+ } else {
226
+ this.#write(`${this.#request.id} NO Unsupported authentication mechanism\r\n`)
227
+ log('Unsupported auth mechanism: ' + authMechanism)
228
+ this.#auth = false
229
+ }
230
+ } catch (authError) {
231
+ error('Authentication error:', authError.message)
232
+ this.#write(`${this.#request.id} NO Authentication failed\r\n`)
233
+ this.#auth = false
234
+ }
235
+ })
236
+
237
+ // Also listen for socket errors during authentication
238
+ const errorHandler = err => {
239
+ clearTimeout(authTimeout)
240
+ this.#wait = false
241
+ error('Socket error during authentication:', err.message)
242
+ this.#write(`${this.#request.id} NO Authentication failed\r\n`)
243
+ this.#auth = false
244
+ }
245
+
246
+ this.#socket.once('error', errorHandler)
247
+ this.#socket.once('close', () => {
248
+ clearTimeout(authTimeout)
249
+ this.#wait = false
250
+ log('Socket closed during authentication from: ' + this.#getClientIP())
251
+ })
252
+ } catch (err) {
253
+ error('Authenticate method error:', err.message)
254
+ this.#write(`${this.#request.id} NO Authentication failed\r\n`)
255
+ this.#wait = false
256
+ }
257
+ }
258
+
259
+ #safeParse(jsonString, defaultValue = null) {
260
+ try {
261
+ return JSON.parse(jsonString)
262
+ } catch (parseError) {
263
+ error('JSON parse error:', parseError.message)
264
+ return defaultValue
265
+ }
266
+ }
267
+
268
+ #append() {
269
+ if (!this.#auth) return this.#write(`${this.#request.id} NO Authentication required\r\n`)
270
+ if (!this.#options.onAppend || typeof this.#options.onAppend != 'function')
271
+ return this.#write(`${this.#request.id} NO APPEND failed\r\n`)
272
+ let mailbox = this.#commands[2]
273
+ let flags = this.#commands[3]
274
+ let size = this.#commands[4]
275
+ if (size.startsWith('{') && size.endsWith('}')) size = size.substr(1, size.length - 2)
276
+ this.#write('+ Ready for literal data\r\n')
277
+ this.#wait = true
278
+ this.#socket.once('data', data => {
279
+ this.#options.onAppend({address: this.#auth, mailbox: mailbox, flags: flags, message: data.toString()}, err => {
280
+ if (err) return this.#write(`${this.#request.id} NO APPEND failed\r\n`)
281
+ this.#write(`${this.#request.id} OK APPEND completed\r
282
+ `)
283
+ })
284
+ this.#wait = false
285
+ })
286
+ }
287
+
288
+ #bad() {
289
+ error('Unknown command', this.#request.action)
290
+ this.#write(`${this.#request.id} BAD Unknown command\r\n`)
291
+ }
292
+
293
+ #capability() {
294
+ this.#write(`* CAPABILITY ${CONSTANTS.CAPABILITIES.join(' ')}\r\n`)
295
+ this.#write(`${this.#request.id} OK CAPABILITY completed\r\n`)
296
+ }
297
+
298
+ #close() {
299
+ if (this.#box) this.#expunge()
300
+ this.#write(`${this.#request.id} OK CLOSE completed\r\n`)
301
+ }
302
+
303
+ #create() {
304
+ if (!this.#auth) return this.#write(`${this.#request.id} NO Authentication required\r\n`)
305
+ if (!this.#options.onCreate || typeof this.#options.onCreate != 'function')
306
+ return this.#write(`${this.#request.id} NO CREATE failed\r\n`)
307
+ let mailbox = this.#commands.slice(2).join(' ')
308
+ this.#options.onCreate({address: this.#auth, mailbox: mailbox}, err => {
309
+ if (err) return this.#write(`${this.#request.id} NO CREATE failed\r\n`)
310
+ this.#write(`${this.#request.id} OK CREATE completed\r\n`)
311
+ })
312
+ }
313
+
314
+ #delete() {
315
+ if (!this.#auth) return this.#write(`${this.#request.id} NO Authentication required\r\n`)
316
+ if (!this.#options.onDelete || typeof this.#options.onDelete != 'function')
317
+ return this.#write(`${this.#request.id} NO DELETE failed\r\n`)
318
+ let mailbox = this.#commands.slice(2).join(' ')
319
+ this.#options.onDelete({address: this.#auth, mailbox: mailbox}, err => {
320
+ if (err) return this.#write(`${this.#request.id} NO DELETE failed\r\n`)
321
+ this.#write(`${this.#request.id} OK DELETE completed\r\n`)
322
+ })
323
+ }
324
+
325
+ #data(data) {
326
+ try {
327
+ log('Data received from: ' + this.#getClientIP(), data.toString().trim())
328
+ if (this.#wait || !data || data.toString().trim().length === 0) {
329
+ return
330
+ }
331
+
332
+ const dataStr = data.toString().trim()
333
+ if (dataStr.length > CONSTANTS.MAX_COMMAND_SIZE) {
334
+ this.#write(`* BAD Command too large\r\n`)
335
+ return
336
+ }
337
+
338
+ this.#commands = dataStr.split(' ')
339
+ this.#request = {}
340
+ const dataParts = dataStr.split(' ')
341
+ this.#request.id = dataParts.shift()
342
+ this.#request.action = dataParts.filter(item => Object.keys(this.#actions).includes(item.toUpperCase())).join(' ')
343
+
344
+ log('Incoming IMAP command: ' + this.#request.action)
345
+
346
+ const index = dataParts.indexOf(this.#request.action)
347
+ dataParts.splice(dataParts.indexOf(this.#request.action), 1)
348
+ this.#request.action = this.#request.action.toUpperCase()
349
+
350
+ if (dataParts.includes('UID') && dataParts.indexOf('UID') < index) {
351
+ this.#request.uid = dataParts[dataParts.indexOf('UID') + 1]
352
+ dataParts.splice(dataParts.indexOf('UID'), 2)
353
+ if (!dataParts.includes('UID') && !dataParts.includes('(UID')) {
354
+ dataParts.splice(0, 0, 'UID')
355
+ }
356
+ } else if (index === 0) {
357
+ this.#request.uid = dataParts[0]
358
+ dataParts.shift()
359
+ }
360
+
361
+ this.#request.requests = this.#export(dataParts)
362
+
363
+ if (this.#actions[this.#request.action]) {
364
+ this.#actions[this.#request.action]()
365
+ } else {
366
+ this.#bad()
367
+ }
368
+ } catch (err) {
369
+ error('Data processing error:', err.message)
370
+ this.#bad()
371
+ }
372
+ }
373
+
374
+ #examine() {
375
+ try {
376
+ if (!this.#auth) return this.#write(`${this.#request.id} NO Authentication required\r\n`)
377
+ if (!this.#options.onSelect || typeof this.#options.onSelect !== 'function') {
378
+ return this.#write(`${this.#request.id} NO EXAMINE failed\r\n`)
379
+ }
380
+
381
+ this.#box = this.#commands[2]
382
+ this.#options.onSelect(this.#auth, this.#options, data => {
383
+ const flagsList = CONSTANTS.PERMANENT_FLAGS.join(' ')
384
+ this.#write(`* FLAGS (${flagsList})\r\n`)
385
+ this.#write(`* OK [PERMANENTFLAGS (${flagsList})] Flags permitted\r\n`)
386
+ if (data.exists !== undefined) this.#write('* ' + data.exists + ' EXISTS\r\n')
387
+ this.#write('* ' + (data.recent ?? data.exists ?? 0) + ' RECENT\r\n')
388
+ if (data.unseen !== undefined) {
389
+ this.#write('* OK [UNSEEN ' + data.unseen + '] Message ' + (data.unseen ?? 0) + ' is first unseen\r\n')
390
+ }
391
+ if (data.uidvalidity !== undefined) {
392
+ this.#write('* OK [UIDVALIDITY ' + data.uidvalidity + '] UIDs valid\r\n')
393
+ }
394
+ if (data.uidnext !== undefined) {
395
+ this.#write('* OK [UIDNEXT ' + data.uidnext + '] Predicted next UID\r\n')
396
+ }
397
+ this.#write(`${this.#request.id} OK [READ-ONLY] EXAMINE completed\r\n`)
398
+ })
399
+ } catch (err) {
400
+ error('EXAMINE command failed:', err.message)
401
+ this.#write(`${this.#request.id} NO EXAMINE failed\r\n`)
402
+ }
403
+ }
404
+
405
+ #expunge() {
406
+ if (!this.#auth) return this.#write(`${this.#request.id} NO Authentication required\r\n`)
407
+ if (!this.#options.onExpunge || typeof this.#options.onExpunge != 'function')
408
+ return this.#write(`${this.#request.id} NO EXPUNGE failed\r\n`)
409
+ this.#options.onExpunge({address: this.#auth, mailbox: this.#box}, (err, uids) => {
410
+ if (err) return this.#write(`${this.#request.id} NO EXPUNGE failed\r\n`)
411
+ for (let uid of uids) this.#write(`* ${uid} EXPUNGE\r\n`)
412
+ this.#write(`${this.#request.id} OK EXPUNGE completed\r\n`)
413
+ })
414
+ }
415
+
416
+ #export(data) {
417
+ let result = []
418
+ while (data.length) {
419
+ let item = data.shift()
420
+ let fields = []
421
+ let index = data.indexOf(item)
422
+ if (item.includes('[]')) item = item.split('[]')[0]
423
+ if (item.startsWith('(') || item.startsWith('[')) item = item.substring(1)
424
+ if (!data[index + 1]?.startsWith('(BODY')) {
425
+ if (
426
+ item.includes('[') ||
427
+ item.includes('(') ||
428
+ (data[index + 1] ?? '').startsWith('[') ||
429
+ (data[index + 1] ?? '').startsWith('(')
430
+ ) {
431
+ let next = true
432
+ if (item.includes('[') || item.includes('(')) {
433
+ item = item.split(item.includes('[') ? '[' : '(')
434
+ fields.push(item[1].split(']')[0])
435
+ next = !item[1].includes(']') && !item[1].includes(')')
436
+ item = item[0]
437
+ }
438
+ if (next)
439
+ while (data.length && item[1] && !item[1].includes(']') && !item[1].includes(')')) {
440
+ fields.push(data.shift())
441
+ }
442
+ }
443
+ }
444
+ while (item.endsWith(']') || item.endsWith(')')) item = item.substring(0, item.length - 1)
445
+ let peek = item.includes('.')
446
+ if (peek) {
447
+ peek = item.split('.')[1]
448
+ item = item.split('.')[0]
449
+ }
450
+ result.push({
451
+ value: item,
452
+ peek: peek,
453
+ fields: this.#export(fields)
454
+ })
455
+ }
456
+ return result
457
+ }
458
+
459
+ async #fetch() {
460
+ try {
461
+ if (!this.#auth) return this.#write(`${this.#request.id} NO Authentication required\r\n`)
462
+ if (!this.#box) return this.#write(`${this.#request.id} NO Mailbox required\r\n`)
463
+ if (!this.#options.onFetch || typeof this.#options.onFetch !== 'function') {
464
+ return this.#write(`${this.#request.id} NO FETCH failed\r\n`)
465
+ }
466
+
467
+ const ids = this.#request.uid ? this.#request.uid.split(',') : ['ALL']
468
+ for (const id of ids) {
469
+ await new Promise(resolve => {
470
+ this.#options.onFetch(
471
+ {
472
+ email: this.#auth,
473
+ mailbox: this.#box,
474
+ limit: id === 'ALL' ? null : id.includes(':') ? id.split(':') : [id, id]
475
+ },
476
+ this.#commands,
477
+ data => {
478
+ if (data === false) {
479
+ this.#write(`${this.#request.id} NO FETCH failed\r\n`)
480
+ return resolve()
481
+ }
482
+ for (const row of data) {
483
+ this.#write('* ' + row.uid + ' FETCH (')
484
+ this.#prepare(this.#request.requests, row)
485
+ this.#write(')\r\n')
486
+ }
487
+ return resolve()
488
+ }
489
+ )
490
+ })
491
+ }
492
+ this.#write(`${this.#request.id} OK FETCH completed\r\n`)
493
+ } catch (err) {
494
+ error('FETCH command failed:', err.message)
495
+ this.#write(`${this.#request.id} NO FETCH failed\r\n`)
496
+ }
497
+ }
498
+
499
+ #search() {
500
+ try {
501
+ if (!this.#auth) return this.#write(`${this.#request.id} NO Authentication required\r\n`)
502
+ if (!this.#box) return this.#write(`${this.#request.id} NO Mailbox required\r\n`)
503
+ if (!this.#options.onSearch || typeof this.#options.onSearch !== 'function') {
504
+ return this.#write(`${this.#request.id} NO SEARCH not implemented\r\n`)
505
+ }
506
+
507
+ const criteria = this.#commands.slice(2)
508
+ this.#options.onSearch(
509
+ {
510
+ address: this.#auth,
511
+ mailbox: this.#box,
512
+ criteria: criteria
513
+ },
514
+ (err, uids) => {
515
+ if (err) return this.#write(`${this.#request.id} NO SEARCH failed\r\n`)
516
+ this.#write(`* SEARCH ${uids.join(' ')}\r\n`)
517
+ this.#write(`${this.#request.id} OK SEARCH completed\r\n`)
518
+ }
519
+ )
520
+ } catch (err) {
521
+ error('SEARCH command failed:', err.message)
522
+ this.#write(`${this.#request.id} NO SEARCH failed\r\n`)
523
+ }
524
+ }
525
+
526
+ #copy() {
527
+ try {
528
+ if (!this.#auth) return this.#write(`${this.#request.id} NO Authentication required\r\n`)
529
+ if (!this.#box) return this.#write(`${this.#request.id} NO Mailbox required\r\n`)
530
+ if (!this.#options.onCopy || typeof this.#options.onCopy !== 'function') {
531
+ return this.#write(`${this.#request.id} NO COPY not implemented\r\n`)
532
+ }
533
+
534
+ const uids = this.#request.uid
535
+ const targetMailbox = this.#commands[this.#commands.length - 1]
536
+
537
+ this.#options.onCopy(
538
+ {
539
+ address: this.#auth,
540
+ sourceMailbox: this.#box,
541
+ targetMailbox: targetMailbox,
542
+ uids: uids
543
+ },
544
+ err => {
545
+ if (err) return this.#write(`${this.#request.id} NO COPY failed\r\n`)
546
+ this.#write(`${this.#request.id} OK COPY completed\r\n`)
547
+ }
548
+ )
549
+ } catch (err) {
550
+ error('COPY command failed:', err.message)
551
+ this.#write(`${this.#request.id} NO COPY failed\r\n`)
552
+ }
553
+ }
554
+
555
+ #idle() {
556
+ try {
557
+ if (!this.#auth) return this.#write(`${this.#request.id} NO Authentication required\r\n`)
558
+ if (!this.#box) return this.#write(`${this.#request.id} NO Mailbox required\r\n`)
559
+
560
+ this.#write('+ idling\r\n')
561
+ this.#wait = true
562
+
563
+ // Event listener for IDLE state
564
+ this.#idleInterval = setInterval(() => {
565
+ if (this.#options.onIdle && typeof this.#options.onIdle === 'function') {
566
+ this.#options.onIdle(
567
+ {
568
+ address: this.#auth,
569
+ mailbox: this.#box
570
+ },
571
+ updates => {
572
+ if (updates) {
573
+ for (const update of updates) {
574
+ this.#write(`* ${update}\r\n`)
575
+ }
576
+ }
577
+ }
578
+ )
579
+ }
580
+ }, 5000)
581
+
582
+ this.#socket.once('data', data => {
583
+ const command = data.toString().trim().toUpperCase()
584
+ if (command === 'DONE') {
585
+ clearInterval(this.#idleInterval)
586
+ this.#wait = false
587
+ this.#write(`${this.#request.id} OK IDLE terminated\r\n`)
588
+ }
589
+ })
590
+ } catch (err) {
591
+ error('IDLE command failed:', err.message)
592
+ this.#write(`${this.#request.id} NO IDLE failed\r\n`)
593
+ }
594
+ }
595
+
596
+ #starttls() {
597
+ try {
598
+ if (this.#socket.encrypted) {
599
+ return this.#write(`${this.#request.id} NO TLS already active\r\n`)
600
+ }
601
+
602
+ this.#write(`${this.#request.id} OK Begin TLS negotiation now\r\n`)
603
+
604
+ // TLS upgrade implementation would go here
605
+ // This is a placeholder for actual TLS implementation
606
+ if (this.#options.onStartTLS && typeof this.#options.onStartTLS === 'function') {
607
+ this.#options.onStartTLS(this.#socket, err => {
608
+ if (err) {
609
+ error('STARTTLS failed:', err.message)
610
+ this.#socket.end()
611
+ }
612
+ })
613
+ }
614
+ } catch (err) {
615
+ error('STARTTLS command failed:', err.message)
616
+ this.#write(`${this.#request.id} NO STARTTLS failed\r\n`)
617
+ }
618
+ }
619
+
620
+ #list() {
621
+ if (!this.#auth) return this.#write(`${this.#request.id} NO Authentication required\r\n`)
622
+ if (!this.#options.onList || typeof this.#options.onList != 'function') return this.#write(`${this.#request.id} NO LIST failed\r\n`)
623
+ this.#options.onList({address: this.#auth}, (err, boxes) => {
624
+ if (err) return this.#write(`${this.#request.id} NO LIST failed\r\n`)
625
+ for (let box of boxes) this.#write(`* LIST (\\HasNoChildren) "/" ${box}\r\n`)
626
+ this.#write(`${this.#request.id} OK LIST completed\r\n`)
627
+ })
628
+ }
629
+
630
+ listen() {
631
+ try {
632
+ this.#socket.on('data', data => {
633
+ try {
634
+ this.#data(data)
635
+ } catch (err) {
636
+ error('Data handling error:', err.message)
637
+ this.#bad()
638
+ }
639
+ })
640
+
641
+ this.#socket.on('end', () => {
642
+ this.#end = true
643
+ this.#cleanup()
644
+ if (!this.#socket.destroyed) {
645
+ this.#socket.end()
646
+ }
647
+ })
648
+
649
+ this.#socket.on('error', err => {
650
+ error('Socket error:', err.message)
651
+ this.#end = true
652
+ if (this.#options.onError) {
653
+ this.#options.onError(err)
654
+ }
655
+ this.#cleanup()
656
+ })
657
+
658
+ this.#socket.on('close', () => {
659
+ this.#end = true
660
+ this.#cleanup()
661
+ })
662
+ } catch (err) {
663
+ error('Listen setup error:', err.message)
664
+ this.#cleanup()
665
+ }
666
+ }
667
+
668
+ #cleanup() {
669
+ try {
670
+ // Set end flag first to prevent any further writes
671
+ this.#end = true
672
+
673
+ // Clear timeout
674
+ if (this.#timeout) {
675
+ clearTimeout(this.#timeout)
676
+ this.#timeout = null
677
+ }
678
+
679
+ // Clear idle interval
680
+ if (this.#idleInterval) {
681
+ clearInterval(this.#idleInterval)
682
+ this.#idleInterval = null
683
+ }
684
+
685
+ // Remove from rate limit store
686
+ const clientIP = this.#getClientIP()
687
+ if (rateLimitStore.has(clientIP)) {
688
+ const clientData = rateLimitStore.get(clientIP)
689
+ clientData.count--
690
+ if (clientData.count <= 0) {
691
+ rateLimitStore.delete(clientIP)
692
+ }
693
+ }
694
+
695
+ // Remove all listeners safely
696
+ if (this.#socket && !this.#socket.destroyed) {
697
+ this.#socket.removeAllListeners()
698
+ }
699
+ } catch (cleanupError) {
700
+ error('Cleanup error:', cleanupError.message)
701
+ }
702
+ }
703
+
704
+ #lsub() {
705
+ if (!this.#auth) return this.#write(`${this.#request.id} NO Authentication required\r\n`)
706
+ if (!this.#options.onLsub || typeof this.#options.onLsub != 'function') return this.#write(`${this.#request.id} NO LSUB failed\r\n`)
707
+ this.#options.onLsub({address: this.#auth}, (err, boxes) => {
708
+ if (err) return this.#write(`${this.#request.id} NO LSUB failed\r\n`)
709
+ for (let box of boxes) this.#write(`* LSUB (\\HasNoChildren) "/" "${box}"\r\n`)
710
+ this.#write(`${this.#request.id} OK LSUB completed\r\n`)
711
+ })
712
+ }
713
+
714
+ #login() {
715
+ if (this.#options.onAuth && typeof this.#options.onAuth == 'function') {
716
+ if (this.#commands[2].startsWith('"') && this.#commands[2].endsWith('"'))
717
+ this.#commands[2] = this.#commands[2].substr(1, this.#commands[2].length - 2)
718
+ if (this.#commands[3].startsWith('"') && this.#commands[3].endsWith('"'))
719
+ this.#commands[3] = this.#commands[3].substr(1, this.#commands[3].length - 2)
720
+ this.#options.onAuth(
721
+ {
722
+ username: this.#commands[2],
723
+ password: this.#commands[3]
724
+ },
725
+ this.#commands,
726
+ err => {
727
+ if (err) {
728
+ this.#write(`${this.#request.id} NO Authentication failed\r\n`)
729
+ this.#auth = false
730
+ } else {
731
+ this.#write(`${this.#request.id} OK Authentication successful\r\n`)
732
+ this.#auth = this.#commands[2]
733
+ }
734
+ }
735
+ )
736
+ } else {
737
+ this.#write(`${this.#request.id} NO Authentication failed\r\n`)
738
+ this.#auth = false
739
+ }
740
+ }
741
+
742
+ #logout() {
743
+ try {
744
+ if (!this.#end && this.#socket && !this.#socket.destroyed && !this.#socket.writableEnded) {
745
+ this.#write('* BYE IMAP4rev1 Server logging out\r\n')
746
+ this.#write(`${this.#request.id} OK LOGOUT completed\r\n`)
747
+ }
748
+ this.#end = true
749
+ this.#cleanup()
750
+ if (this.#socket && !this.#socket.destroyed) {
751
+ this.#socket.end()
752
+ }
753
+ } catch (err) {
754
+ error('Logout error:', err.message)
755
+ this.#cleanup()
756
+ }
757
+ }
758
+
759
+ #noop() {
760
+ this.#write(`${this.#request.id} OK NOOP completed\r\n`)
761
+ }
762
+
763
+ #prepareBody(request, data, boundary) {
764
+ let body = {keys: '', header: '', content: ''}
765
+ for (let obj of request.fields.length ? request.fields : [{value: 'HEADER'}, {value: 'TEXT'}]) {
766
+ let fields = obj.fields ? obj.fields.map(field => field.value.toLowerCase()) : []
767
+ if (request.fields.length) body.keys += obj.value + (obj.peek ? '.' + obj.peek : '')
768
+ if (fields.length > 0) body.keys += ' ('
769
+ if (obj.value == 'HEADER') {
770
+ for (let line of data.headerLines) {
771
+ let include = true
772
+ if (obj.peek)
773
+ if (obj.peek == 'FIELDS') include = fields.includes(line.key)
774
+ else if (obj.peek == 'FIELDS.NOT') include = !fields.includes(line.key)
775
+ if (include) {
776
+ if (fields.length > 0) body.keys += line.key + ' '
777
+ if (line.key.toLowerCase() == 'content-type') {
778
+ if (data.attachments.length > 0) {
779
+ body.header += 'Content-Type: multipart/mixed; boundary="' + boundary + '"\r\n'
780
+ } else if (data.html && data.html.length > 1 && data.text && data.text.length > 1) {
781
+ body.header += 'Content-Type: multipart/alternative; boundary="' + boundary + '_alt"\r\n'
782
+ } else if (!data.text || data.text.length < 1) {
783
+ body.header += 'Content-Type: text/html; charset=utf-8\r\n'
784
+ } else if (!data.html || data.html.length < 1) {
785
+ body.header += 'Content-Type: text/plain; charset=utf-8\r\n'
786
+ }
787
+ } else body.header += line.line + '\r\n'
788
+ }
789
+ }
790
+ if ((obj.peek ?? '') !== 'FIELDS.NOT') {
791
+ for (let field of fields) {
792
+ if (!data.headerLines.find(line => line.key == field)) {
793
+ if (fields.length > 0) body.keys += field + ' '
794
+ else body.header += field + ': \r\n'
795
+ }
796
+ }
797
+ }
798
+ body.header = body.header.trim()
799
+ if (fields.length > 0) body.keys = body.keys.trim() + ')'
800
+ } else if (obj.value == 'TEXT') {
801
+ if (body.header.length) body.content += body.header + '\r\n\r\n'
802
+ if (data.html.length > 1 || data.attachments.length) {
803
+ if (data.attachments.length && data.html && data.html.length && data.text && data.text.length) {
804
+ body.content += '--' + boundary + '\r\n'
805
+ body.content += 'Content-Type: multipart/alternative; boundary="' + boundary + '_alt"\r\n'
806
+ }
807
+ if (data.text && data.text.length) {
808
+ body.content += '\r\n--' + boundary + '_alt\r\n'
809
+ body.content += 'Content-Type: text/plain; charset=utf-8\r\n'
810
+ body.content += 'Content-Transfer-Encoding: quoted-printable\r\n\r\n'
811
+ body.content += data.text
812
+ body.content += '\r\n--' + boundary + '_alt\r\n'
813
+ }
814
+ if (data.html.length) {
815
+ if (data.text && data.text.length) {
816
+ body.content += 'Content-Type: text/html; charset=utf-8\r\n'
817
+ body.content += 'Content-Transfer-Encoding: quoted-printable\r\n\r\n'
818
+ }
819
+ body.content += data.html
820
+ if (data.text && data.text.length) body.content += '\r\n--' + boundary + '_alt--\r\n'
821
+ }
822
+ for (let attachment of data.attachments) {
823
+ body.content += '\r\n--' + boundary + '\r\n'
824
+ body.content += 'Content-Type: ' + attachment.contentType + '; name="' + attachment.filename + '"\r\n'
825
+ body.content += 'Content-Transfer-Encoding: base64\r\n'
826
+ body.content += 'Content-Disposition: attachment; filename="' + attachment.filename + '"\r\n\r\n'
827
+ body.content += Buffer.from(attachment.content.data).toString('base64')
828
+ }
829
+ if (data.attachments.length) body.content += '--' + boundary + '--\r\n'
830
+ } else body.content += data.text
831
+ } else if (!isNaN(obj.value)) {
832
+ obj.value = parseInt(obj.value)
833
+ if (obj.value === 1 || (obj.value === 2 && !data.attachments.length)) {
834
+ if (obj.peek === 2 || obj.value === 2) body.content += data.html
835
+ else body.content += data.text
836
+ } else if (obj.value > 1 && data.attachments[obj.value - 2])
837
+ body.content += Buffer.from(data.attachments[obj.value - 2].content.data).toString('base64') + '\r\n'
838
+ }
839
+ }
840
+ if (body.content == '') body.content = body.header
841
+ body.content = body.content.replace(/\r\n/g, '\n')
842
+ this.#write('BODY[' + body.keys + '] {' + Buffer.byteLength(body.content, 'utf8') + '}\r\n')
843
+ this.#write(body.content)
844
+ }
845
+
846
+ #prepareBodyStructure(data, boundary) {
847
+ let structure = ''
848
+ if (data.text && data.text.length && data.html && data.html.length) structure += '('
849
+ if (data.text && data.text.length)
850
+ structure +=
851
+ '("TEXT" "PLAIN" ("CHARSET" "UTF-8") NIL NIL "QUOTED-PRINTABLE" ' +
852
+ Buffer.byteLength(data.text, 'utf8') +
853
+ ' ' +
854
+ data.text.split('\n').length +
855
+ ' NIL NIL NIL NIL)'
856
+ if (data.html && data.html.length)
857
+ structure +=
858
+ '("TEXT" "HTML" ("CHARSET" "UTF-8") NIL NIL "QUOTED-PRINTABLE" ' +
859
+ Buffer.byteLength(data.html, 'utf8') +
860
+ ' ' +
861
+ data.html.split('\n').length +
862
+ ')'
863
+ if (data.text && data.text.length && data.html && data.html.length)
864
+ structure += ' "ALTERNATIVE" ("BOUNDARY" "' + boundary + '_alt") NIL NIL NIL'
865
+ if (data.text && data.text.length && data.html && data.html.length) structure += ')'
866
+ for (let attachment of data.attachments)
867
+ structure +=
868
+ '("APPLICATION" "' +
869
+ attachment.contentType.split('/')[1].toUpperCase() +
870
+ '" ("NAME" "' +
871
+ attachment.filename +
872
+ '") NIL NIL "BASE64" ' +
873
+ Buffer.from(attachment.content.data).toString('base64').length +
874
+ ' NIL ("ATTACHMENT" ("FILENAME" "' +
875
+ attachment.filename +
876
+ '")) NIL NIL)'
877
+ if (data.attachments.length) structure += ' "MIXED" ("BOUNDARY" "' + boundary + '") NIL NIL NIL'
878
+ this.#write('BODYSTRUCTURE ' + structure)
879
+ }
880
+
881
+ #prepareEnvelope(data) {
882
+ try {
883
+ let envelope = ''
884
+ let from = this.#safeParse(data.from, {value: {address: 'unknown@unknown.com'}})
885
+ envelope += '"' + (data.date || '') + '" '
886
+ envelope += '"' + (data.subject || '') + '" '
887
+ envelope += '"<' + from.value.address + '>" '
888
+ this.#write('ENVELOPE (' + envelope + ') ')
889
+ } catch (err) {
890
+ error('PrepareEnvelope error:', err.message)
891
+ this.#write('ENVELOPE ("" "" "" "") ')
892
+ }
893
+ }
894
+
895
+ #prepareInternalDate(data) {
896
+ let date = new Date(data.date)
897
+ this.#write('INTERNALDATE "' + date.toUTCString() + '" ')
898
+ }
899
+
900
+ #prepareFlags(data) {
901
+ let flags = []
902
+ try {
903
+ flags = data.flags ? this.#safeParse(data.flags, []) : []
904
+ } catch (err) {
905
+ error('Error parsing flags:', err.message)
906
+ flags = []
907
+ }
908
+ flags = flags.map(flag => '\\' + flag)
909
+ this.#write('FLAGS (' + flags.join(' ') + ') ')
910
+ }
911
+
912
+ #prepareRfc822(data) {
913
+ this.#write('RFC822.SIZE ' + data.html.length + ' ')
914
+ }
915
+
916
+ #prepareUid(data) {
917
+ this.#write('UID ' + data.uid + ' ')
918
+ }
919
+
920
+ #rename() {
921
+ if (!this.#auth) return this.#write(`${this.#request.id} NO Authentication required\r\n`)
922
+ if (!this.#options.onRename || typeof this.#options.onRename != 'function')
923
+ return this.#write(`${this.#request.id} NO RENAME failed\r\n`)
924
+ let oldMailbox = this.#commands[2]
925
+ let newMailbox = this.#commands[3]
926
+ this.#options.onRename({address: this.#auth, oldMailbox: oldMailbox, newMailbox: newMailbox}, err => {
927
+ if (err) return this.#write(`${this.#request.id} NO RENAME failed\r\n`)
928
+ this.#write(`${this.#request.id} OK RENAME completed\r\n`)
929
+ })
930
+ }
931
+
932
+ #prepare(requests, data) {
933
+ try {
934
+ data.attachments = data.attachments ? this.#safeParse(data.attachments, []) : []
935
+ for (let request of requests) {
936
+ if (typeof data.headerLines === 'string') {
937
+ data.headerLines = this.#safeParse(data.headerLines, [])
938
+ }
939
+ let boundary = data.headerLines.find(line => line.key && line.key.toLowerCase() === 'content-type')
940
+ if (boundary) boundary = boundary.line.replace(/"/g, '').split('boundary=')[1]
941
+ if (!boundary) boundary = 'boundary' + (data.id || Date.now())
942
+
943
+ switch (request.value) {
944
+ case 'BODY':
945
+ this.#prepareBody(request, data, boundary)
946
+ break
947
+ case 'BODYSTRUCTURE':
948
+ this.#prepareBodyStructure(data, boundary)
949
+ break
950
+ case 'ENVELOPE':
951
+ this.#prepareEnvelope(data)
952
+ break
953
+ case 'INTERNALDATE':
954
+ this.#prepareInternalDate(data)
955
+ break
956
+ case 'FLAGS':
957
+ this.#prepareFlags(data)
958
+ break
959
+ case 'RFC822':
960
+ this.#prepareRfc822(data)
961
+ break
962
+ case 'UID':
963
+ this.#prepareUid(data)
964
+ break
965
+ }
966
+ }
967
+ } catch (err) {
968
+ error('Prepare error:', err.message)
969
+ }
970
+ }
971
+
972
+ #select() {
973
+ try {
974
+ if (!this.#auth) return this.#write(`${this.#request.id} NO Authentication required\r\n`)
975
+ if (!this.#options.onSelect || typeof this.#options.onSelect !== 'function') {
976
+ return this.#write(`${this.#request.id} NO SELECT failed\r\n`)
977
+ }
978
+
979
+ let box = this.#commands[2]
980
+ if (box && box.startsWith('"') && box.endsWith('"')) {
981
+ box = box.substr(1, box.length - 2)
982
+ }
983
+
984
+ if (!box) {
985
+ return this.#write(`${this.#request.id} NO Mailbox name required\r\n`)
986
+ }
987
+
988
+ if (!CONSTANTS.DEFAULT_BOXES.includes(box.toUpperCase())) {
989
+ box = 'INBOX'
990
+ }
991
+
992
+ this.#box = box
993
+ this.#options.onSelect({address: this.#auth, mailbox: this.#box}, this.#options, data => {
994
+ const flagsList = CONSTANTS.PERMANENT_FLAGS.join(' ')
995
+ this.#write(`* FLAGS (${flagsList})\r\n`)
996
+ this.#write(`* OK [PERMANENTFLAGS (${flagsList})] Flags permitted\r\n`)
997
+ this.#write('* ' + ((data.uidnext ?? 1) - 1) + ' EXISTS\r\n')
998
+ this.#write('* ' + (data.recent ?? data.unseen ?? 0) + ' RECENT\r\n')
999
+ this.#write('* OK [UNSEEN ' + (data.unseen ?? 0) + '] Message ' + (data.unseen ?? 0) + ' is first unseen\r\n')
1000
+ this.#write('* OK [UIDVALIDITY ' + (data.uidvalidity ?? CONSTANTS.UIDVALIDITY) + '] UIDs valid\r\n')
1001
+ this.#write('* OK [UIDNEXT ' + (data.uidnext ?? 1) + '] Predicted next UID\r\n')
1002
+ this.#write(`${this.#request.id} OK [READ-WRITE] SELECT completed\r\n`)
1003
+ })
1004
+ } catch (err) {
1005
+ error('SELECT command failed:', err.message)
1006
+ this.#write(`${this.#request.id} NO SELECT failed\r\n`)
1007
+ }
1008
+ }
1009
+
1010
+ #status() {
1011
+ if (!this.#auth) return this.#write(`${this.#request.id} NO Authentication required\r\n`)
1012
+ if (!this.#options.onSelect || typeof this.#options.onSelect != 'function')
1013
+ return this.#write(`${this.#request.id} NO STATUS failed\r\n`)
1014
+ let mailbox = this.#commands[2]
1015
+ let fields = this.#commands.slice(3).map(field => field.toUpperCase().replace('(', '').replace(')', ''))
1016
+ if (fields.length === 0) fields = ['MESSAGES', 'RECENT', 'UIDNEXT', 'UIDVALIDITY', 'UNSEEN']
1017
+ this.#options.onSelect({address: this.#auth, mailbox: mailbox}, this.#options, data => {
1018
+ if (data.exists && !data.messages) data.messages = data.exists
1019
+ this.#write('* STATUS ' + mailbox + ' (')
1020
+ for (let field of fields)
1021
+ if (data[field.toLowerCase()] !== undefined) this.#write(field.toUpperCase() + ' ' + (data[field.toLowerCase()] ?? 0) + ' ')
1022
+ this.#write(')\r\n')
1023
+ this.#write(`${this.#request.id} OK STATUS completed\r\n`)
1024
+ })
1025
+ }
1026
+
1027
+ #store() {
1028
+ try {
1029
+ if (!this.#auth) return this.#write(`${this.#request.id} NO Authentication required\r\n`)
1030
+ if (!this.#request.uid) return this.#write(`${this.#request.id} NO UID required\r\n`)
1031
+
1032
+ const uids = this.#request.uid.split(',')
1033
+ for (const field of this.#request.requests) {
1034
+ let action
1035
+ switch (field.value) {
1036
+ case '+FLAGS':
1037
+ action = 'add'
1038
+ break
1039
+ case '-FLAGS':
1040
+ action = 'remove'
1041
+ break
1042
+ case 'FLAGS':
1043
+ action = 'set'
1044
+ break
1045
+ }
1046
+
1047
+ if (action && this.#options.onStore && typeof this.#options.onStore === 'function') {
1048
+ const flags = field.fields.map(flag => flag.value.replace('\\', '').toLowerCase())
1049
+ this.#options.onStore(
1050
+ {
1051
+ address: this.#auth,
1052
+ uids: uids,
1053
+ action: action,
1054
+ flags: flags
1055
+ },
1056
+ this.#options,
1057
+ data => {
1058
+ if (field.peek !== 'SILENT') {
1059
+ for (const uid of uids) {
1060
+ this.#write('* ' + uid + ' FETCH (FLAGS (' + (data.flags || []).join(' ') + '))\r\n')
1061
+ }
1062
+ }
1063
+ this.#write(`${this.#request.id} OK STORE completed\r\n`)
1064
+ }
1065
+ )
1066
+ }
1067
+ }
1068
+ } catch (err) {
1069
+ error('STORE command failed:', err.message)
1070
+ this.#write(`${this.#request.id} NO STORE failed\r\n`)
1071
+ }
1072
+ }
1073
+
1074
+ #write(data) {
1075
+ try {
1076
+ if (!this.#end && this.#socket && !this.#socket.destroyed && !this.#socket.writableEnded) {
1077
+ this.#socket.write(data)
1078
+ // Reset timeout on activity
1079
+ if (this.#timeout) {
1080
+ clearTimeout(this.#timeout)
1081
+ this.#setupTimeout()
1082
+ }
1083
+ }
1084
+ } catch (err) {
1085
+ error('Write error:', err.message)
1086
+ this.#cleanup()
1087
+ }
1088
+ }
1089
+ }
1090
+
1091
+ module.exports = Connection