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,571 @@
1
+ const {log, error} = Candy.core('Log', false).init('Service')
2
+
3
+ const bcrypt = require('bcrypt')
4
+ const SMTPServer = require('smtp-server').SMTPServer
5
+ const parser = require('mailparser').simpleParser
6
+ const sqlite3 = require('sqlite3').verbose()
7
+ const forge = require('node-forge')
8
+ const fs = require('fs')
9
+ const os = require('os')
10
+ const server = require('./mail/server')
11
+ const smtp = require('./mail/smtp')
12
+ const tls = require('tls')
13
+
14
+ class Mail {
15
+ #checking = false
16
+ #clients = {}
17
+ #counts = {}
18
+ #db
19
+ #server_smtp
20
+ #started = false
21
+ #sslCache = new Map()
22
+
23
+ clearSSLCache(domain) {
24
+ if (domain) {
25
+ for (const key of this.#sslCache.keys()) {
26
+ if (key === domain || key.endsWith('.' + domain)) {
27
+ this.#sslCache.delete(key)
28
+ }
29
+ }
30
+ } else {
31
+ this.#sslCache.clear()
32
+ }
33
+ }
34
+
35
+ check() {
36
+ if (this.#checking) return
37
+ if (!this.#started) this.init()
38
+ if (!this.#started) return
39
+ this.#checking = true
40
+ for (const domain of Object.keys(Candy.core('Config').config.websites)) {
41
+ if (!Candy.core('Config').config.websites[domain].DNS || !Candy.core('Config').config.websites[domain].DNS.MX) continue
42
+ if (Candy.core('Config').config.websites[domain].cert !== false && !Candy.core('Config').config.websites[domain].cert?.dkim)
43
+ this.#dkim(domain)
44
+ }
45
+ this.#checking = false
46
+ }
47
+
48
+ async create(email, password, retype) {
49
+ if (!email || !password || !retype) return Candy.server('Api').result(false, await __('All fields are required.'))
50
+ if (password != retype) return Candy.server('Api').result(false, await __('Passwords do not match.'))
51
+ password = await new Promise((resolve, reject) => {
52
+ bcrypt.hash(password, 10, (err, hash) => {
53
+ if (err) reject(err)
54
+ resolve(hash)
55
+ })
56
+ })
57
+ if (!email.match(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/))
58
+ return Candy.server('Api').result(false, await __('Invalid email address.'))
59
+ if (await this.exists(email)) return Candy.server('Api').result(false, await __('Mail account %s already exists.', email))
60
+ let domain = email.split('@')[1]
61
+ if (!Candy.core('Config').config.websites[domain]) {
62
+ for (let d in Candy.core('Config').config.websites) {
63
+ if (domain.substr(-d.length) != d) continue
64
+ if (Candy.core('Config').config.websites[d].subdomain.includes(domain.substr(-d.length))) {
65
+ domain = d
66
+ break
67
+ }
68
+ }
69
+ return Candy.server('Api').result(false, await __('Domain %s not found.', domain))
70
+ }
71
+ this.#db.serialize(() => {
72
+ let stmt = this.#db.prepare("INSERT INTO mail_account ('email', 'password', 'domain') VALUES (?, ?, ?)")
73
+ stmt.run(email, password, domain)
74
+ stmt.finalize()
75
+ })
76
+ return Candy.server('Api').result(true, await __('Mail account %s created successfully.', email))
77
+ }
78
+
79
+ async delete(email) {
80
+ if (!email) return Candy.server('Api').result(false, await __('Email address is required.'))
81
+ if (!email.match(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/))
82
+ return Candy.server('Api').result(false, await __('Invalid email address.'))
83
+ if (!(await this.exists(email))) return Candy.server('Api').result(false, await __('Mail account %s not found.', email))
84
+ this.#db.serialize(() => {
85
+ let stmt = this.#db.prepare('DELETE FROM mail_account WHERE email = ?')
86
+ stmt.run(email)
87
+ stmt.finalize()
88
+ })
89
+ return Candy.server('Api').result(true, await __('Mail account %s deleted successfully.', email))
90
+ }
91
+
92
+ #dkim(domain) {
93
+ let keys = forge.pki.rsa.generateKeyPair(1024)
94
+ const privateKeyPem = forge.pki.privateKeyToPem(keys.privateKey)
95
+ let publicKeyPem = forge.pki.publicKeyToPem(keys.publicKey)
96
+ if (!fs.existsSync(os.homedir() + '/.candypack/cert/dkim')) fs.mkdirSync(os.homedir() + '/.candypack/cert/dkim', {recursive: true})
97
+ fs.writeFileSync(os.homedir() + '/.candypack/cert/dkim/' + domain + '.key', privateKeyPem)
98
+ fs.writeFileSync(os.homedir() + '/.candypack/cert/dkim/' + domain + '.pub', publicKeyPem)
99
+ publicKeyPem = publicKeyPem
100
+ .replace('-----BEGIN PUBLIC KEY-----', '')
101
+ .replace('-----END PUBLIC KEY-----', '')
102
+ .replace(/\r\n/g, '')
103
+ .replace(/\n/g, '')
104
+ if (!Candy.core('Config').config.websites[domain].cert) Candy.core('Config').config.websites[domain].cert = {}
105
+ Candy.core('Config').config.websites[domain].cert.dkim = {
106
+ private: os.homedir() + '/.candypack/cert/dkim/' + domain + '.key',
107
+ public: os.homedir() + '/.candypack/cert/dkim/' + domain + '.pub'
108
+ }
109
+ Candy.server('DNS').record({
110
+ type: 'TXT',
111
+ name: `default._domainkey.${domain}`,
112
+ value: `v=DKIM1; k=rsa; p=${publicKeyPem}`
113
+ })
114
+ }
115
+
116
+ exists(email) {
117
+ return new Promise(resolve => {
118
+ this.#db.get('SELECT * FROM mail_account WHERE email = ?', [email], (err, row) => {
119
+ if (row) resolve(row)
120
+ else resolve(false)
121
+ })
122
+ })
123
+ }
124
+
125
+ init() {
126
+ let start = false
127
+ for (let domain in Candy.core('Config').config.websites) {
128
+ let web = Candy.core('Config').config.websites[domain]
129
+ if (web && web.DNS && web.DNS.MX) start = true
130
+ }
131
+ if (!start || this.#started) return
132
+ this.#started = true
133
+ if (!fs.existsSync(os.homedir() + '/.candypack/db')) fs.mkdirSync(os.homedir() + '/.candypack/db', {recursive: true})
134
+ this.#db = new sqlite3.Database(os.homedir() + '/.candypack/db/mail', err => {
135
+ if (err) error(err.message)
136
+ })
137
+ this.#db.serialize(() => {
138
+ this.#db.run(`CREATE TABLE IF NOT EXISTS mail_received ('id' INTEGER PRIMARY KEY AUTOINCREMENT,
139
+ 'uid' INTEGER NOT NULL,
140
+ 'email' VARCHAR(255) NOT NULL,
141
+ 'mailbox' VARCHAR(255),
142
+ 'flags' JSON DEFAULT '[]',
143
+ 'attachments' JSON,
144
+ 'headers' JSON,
145
+ 'headerLines' JSON,
146
+ 'html' TEXT,
147
+ 'text' TEXT,
148
+ 'textAsHtml' TEXT,
149
+ 'subject' TEXT,
150
+ 'date' TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
151
+ 'to' JSON,
152
+ 'from' JSON,
153
+ 'messageId' TEXT,
154
+ UNIQUE(email, uid))`)
155
+ this.#db.run(`CREATE TABLE IF NOT EXISTS mail_account ('id' INTEGER PRIMARY KEY AUTOINCREMENT,
156
+ 'email' VARCHAR(255) UNIQUE,
157
+ 'password' VARCHAR(255),
158
+ 'domain' VARCHAR(255),
159
+ 'created' TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`)
160
+ this.#db.run(`CREATE TABLE IF NOT EXISTS mail_box ('id' INTEGER PRIMARY KEY AUTOINCREMENT,
161
+ 'email' VARCHAR(255),
162
+ 'title' VARCHAR(255),
163
+ 'parent' INTEGER DEFAULT 0,
164
+ 'deleted' BOOLEAN DEFAULT 0,
165
+ 'date' TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
166
+ UNIQUE(email, title))`)
167
+ this.#db.run(`CREATE INDEX IF NOT EXISTS idx_email ON mail_account (email);`)
168
+ this.#db.run(`CREATE INDEX IF NOT EXISTS idx_domain ON mail_account (domain);`)
169
+ this.#db.run(`CREATE INDEX IF NOT EXISTS idx_uid ON mail_received (uid);`)
170
+ this.#db.run(`CREATE INDEX IF NOT EXISTS idx_email ON mail_received (email);`)
171
+ this.#db.run(`CREATE INDEX IF NOT EXISTS idx_flags ON mail_received (flags);`)
172
+ this.#db.run(`CREATE INDEX IF NOT EXISTS idx_date ON mail_received (date);`)
173
+ this.#db.run(`CREATE INDEX IF NOT EXISTS idx_email ON mail_box (email);`)
174
+ this.#db.run(`CREATE INDEX IF NOT EXISTS idx_title ON mail_box (title);`)
175
+ })
176
+ const self = this
177
+ let options = {
178
+ logger: true,
179
+ secure: false,
180
+ banner: 'CandyPack',
181
+ size: 1024 * 1024 * 10,
182
+ authOptional: true,
183
+ onAuth(auth, session, callback) {
184
+ let ip = session.remoteAddress
185
+ if (self.#clients[ip]) {
186
+ if (self.#clients[ip].attempts > 1 && Date.now() - self.#clients[ip].last < 1000 * 60 * 60)
187
+ return callback(new Error('Too many attempts from this IP: ' + ip))
188
+ if (self.#clients[ip].last < Date.now() - 1000 * 60 * 60) self.#clients[ip] = {attempts: 0, last: 0}
189
+ }
190
+ if (!auth.username.match(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/))
191
+ return callback(new Error('Invalid username or password'))
192
+ self.exists(auth.username).then(async result => {
193
+ if (result && (await bcrypt.compare(auth.password, result.password))) return callback(null, {user: auth.username})
194
+ if (!self.#clients[ip]) self.#clients[ip] = {attempts: 0, last: 0}
195
+ self.#clients[ip].attempts++
196
+ self.#clients[ip].last = Date.now()
197
+ return callback(new Error('Invalid username or password'))
198
+ })
199
+ },
200
+ onAppend(data, callback) {
201
+ parser(data.message, {}, async (err, parsed) => {
202
+ if (err) {
203
+ error(err)
204
+ return callback(err)
205
+ }
206
+ await self.#store(data.address, parsed, data.mailbox, data.flags)
207
+ callback()
208
+ })
209
+ },
210
+ onExpunge(data, callback) {
211
+ self.#db.all(
212
+ "SELECT uid FROM mail_received WHERE email = ? AND mailbox = ? AND flags LIKE '%deleted%'",
213
+ [data.address, data.mailbox],
214
+ (err, rows) => {
215
+ if (err) {
216
+ error(err)
217
+ return callback(err)
218
+ }
219
+ let uids = rows.map(row => row.uid)
220
+ self.#db.run(
221
+ "DELETE FROM mail_received WHERE email = ? AND mailbox = ? AND flags LIKE '%deleted%'",
222
+ [data.address, data.mailbox],
223
+ err => {
224
+ if (err) {
225
+ error(err)
226
+ return callback(err)
227
+ }
228
+ callback(null, uids)
229
+ }
230
+ )
231
+ }
232
+ )
233
+ },
234
+ onData(stream, session, callback) {
235
+ parser(stream, {}, async (err, parsed) => {
236
+ if (err) return error(err)
237
+ // log('ON DATA:', session);
238
+ if (!parsed.to.value[0].address.match(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/)) {
239
+ error('Invalid recipient:', parsed.to.value[0].address)
240
+ return callback(new Error('Invalid recipient'))
241
+ }
242
+ if (!parsed.from.value[0].address.match(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/)) {
243
+ error('Invalid sender:', parsed.from.value[0].address)
244
+ return callback(new Error('Invalid sender'))
245
+ }
246
+ let sender = await self.exists(parsed.from.value[0].address)
247
+ if (sender && (!session.user || parsed.from.value[0].address !== session.user)) {
248
+ error('Unexpected sender:', parsed.from.value[0].address)
249
+ return callback(new Error('Unexpected sender'))
250
+ }
251
+ if (
252
+ !sender &&
253
+ !['hostmaster', 'postmaster'].includes(parsed.to.value[0].address.split('@')[0]) &&
254
+ !(await self.exists(parsed.to.value[0].address))
255
+ ) {
256
+ error('Unexpected recipient:', parsed.to.value[0].address)
257
+ return callback(new Error('Unexpected recipient'))
258
+ }
259
+ await self.#store(session.user ?? parsed.to.value[0].address, parsed)
260
+ if (session.user && parsed.from.value[0].address === session.user) smtp.send(parsed)
261
+ callback()
262
+ })
263
+ },
264
+ onCreate(data, callback) {
265
+ self.#db.run('INSERT INTO mail_box (email, title) VALUES (?, ?)', [data.address, data.mailbox], err => {
266
+ if (err) {
267
+ error(err)
268
+ return callback(err)
269
+ }
270
+ callback()
271
+ })
272
+ },
273
+ onDelete(data, callback) {
274
+ self.#db.run('DELETE FROM mail_box WHERE email = ? AND title = ?', [data.address, data.mailbox], err => {
275
+ if (err) {
276
+ error(err)
277
+ return callback(err)
278
+ }
279
+ callback()
280
+ })
281
+ },
282
+ onRename(data, callback) {
283
+ self.#db.run(
284
+ 'UPDATE mail_box SET title = ? WHERE email = ? AND title = ?',
285
+ [data.newMailbox, data.address, data.oldMailbox],
286
+ err => {
287
+ if (err) {
288
+ error(err)
289
+ return callback(err)
290
+ }
291
+ callback()
292
+ }
293
+ )
294
+ },
295
+ onFetch(data, session, callback) {
296
+ let limit = ``
297
+ if (data.limit) {
298
+ if (data.limit[0] && !isNaN(data.limit[0])) limit += `AND uid >= ${parseInt(data.limit[0])} `
299
+ if (data.limit[1] && !isNaN(data.limit[1])) limit += `AND uid <= ${parseInt(data.limit[1])} `
300
+ }
301
+ self.#db.all(
302
+ `SELECT * FROM mail_received
303
+ WHERE email = ? AND mailbox = ? ${limit}
304
+ ORDER BY id DESC`,
305
+ [data.email, data.mailbox],
306
+ (err, rows) => {
307
+ if (err) {
308
+ error(err)
309
+ return callback(false)
310
+ }
311
+ callback(rows)
312
+ }
313
+ )
314
+ },
315
+ onList(data, callback) {
316
+ self.#db.all('SELECT title FROM mail_box WHERE email = ?', [data.address], (err, rows) => {
317
+ if (err) {
318
+ error(err)
319
+ return callback(err)
320
+ }
321
+ let boxes = rows.map(row => row.title)
322
+ if (!boxes.includes('INBOX')) boxes.unshift('INBOX')
323
+ callback(null, boxes)
324
+ })
325
+ },
326
+ onLsub(data, callback) {
327
+ self.#db.all('SELECT title FROM mail_box WHERE email = ?', [data.address], (err, rows) => {
328
+ if (err) {
329
+ error(err)
330
+ return callback(err)
331
+ }
332
+ let boxes = rows.map(row => row.title)
333
+ if (!boxes.includes('INBOX')) boxes.unshift('INBOX')
334
+ callback(null, boxes)
335
+ })
336
+ },
337
+ onMailFrom(address, session, callback) {
338
+ if (!address.address.match(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/)) return callback(new Error('Invalid email address'))
339
+ return callback()
340
+ },
341
+ onRcptTo(address, session, callback) {
342
+ if (!address.address.match(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/)) return callback(new Error('Invalid email address'))
343
+ return callback()
344
+ },
345
+ onSelect(data, session, callback) {
346
+ self.#db.get(
347
+ "SELECT COUNT(*) AS 'exists', SUM(IIF(flags LIKE '%seen%', 0, 1)) AS 'unseen', MAX(uid) + 1 AS uidnext, MAX(uid) AS uidvalidity FROM mail_received WHERE email = ? AND mailbox = ?",
348
+ [data.address, data.mailbox],
349
+ (err, row) => {
350
+ if (err) {
351
+ error(err)
352
+ return callback(err)
353
+ }
354
+ callback(row)
355
+ }
356
+ )
357
+ },
358
+ onStore(data, session, callback) {
359
+ let uids = data.uids
360
+ for (let flag of data.flags) {
361
+ for (let uid of uids) {
362
+ uid = [uid, uid]
363
+ if (uid.includes(':')) uid = uid.split(':')
364
+ switch (data.action) {
365
+ case 'add':
366
+ self.#db.run(
367
+ `UPDATE mail_received
368
+ SET flags = JSON_INSERT(flags, '$[#]', ?)
369
+ WHERE email = ? AND uid BETWEEN ? AND ? AND flags NOT LIKE ?`,
370
+ [flag, data.address, uid[0], uid[1], `%${flag}%`],
371
+ err => {
372
+ if (err) {
373
+ error(err)
374
+ return callback(err)
375
+ }
376
+ }
377
+ )
378
+ break
379
+ case 'remove':
380
+ self.#db.run(
381
+ `UPDATE mail_received
382
+ SET flags = JSON_REMOVE(flags, (SELECT value FROM JSON_EACH(flags) WHERE value = ?))
383
+ WHERE email = ? AND uid BETWEEN ? AND ? AND flags LIKE ?`,
384
+ [flag, data.address, uid[0], uid[1], `%${flag}%`],
385
+ err => {
386
+ if (err) {
387
+ error(err)
388
+ return callback(err)
389
+ }
390
+ }
391
+ )
392
+ break
393
+ case 'set':
394
+ self.#db.run(
395
+ `UPDATE mail_received
396
+ SET flags = JSON_SET(flags, '$', ?)
397
+ WHERE email = ? AND uid BETWEEN ? AND ?`,
398
+ [JSON.stringify(data.flags), data.address, uid[0], uid[1]],
399
+ err => {
400
+ if (err) {
401
+ error(err)
402
+ return callback(err)
403
+ }
404
+ }
405
+ )
406
+ break
407
+ }
408
+ }
409
+ }
410
+ callback()
411
+ },
412
+ onError(err) {
413
+ error('Error:', err)
414
+ }
415
+ }
416
+ let serv = new SMTPServer(options)
417
+ serv.listen(25)
418
+ serv.on('error', err => log('SMTP Server Error: ', err))
419
+ const imap = new server(options)
420
+ imap.listen(143)
421
+ options.SNICallback = (hostname, callback) => {
422
+ const cached = this.#sslCache.get(hostname)
423
+ if (cached) return callback(null, cached)
424
+
425
+ let ssl = Candy.core('Config').config.ssl ?? {}
426
+ let sslOptions = {}
427
+ while (!Candy.core('Config').config.websites[hostname] && hostname.includes('.')) hostname = hostname.split('.').slice(1).join('.')
428
+ let website = Candy.core('Config').config.websites[hostname]
429
+ if (
430
+ website &&
431
+ website.cert.ssl &&
432
+ website.cert.ssl.key &&
433
+ website.cert.ssl.cert &&
434
+ fs.existsSync(website.cert.ssl.key) &&
435
+ fs.existsSync(website.cert.ssl.cert)
436
+ ) {
437
+ sslOptions = {
438
+ key: fs.readFileSync(website.cert.ssl.key),
439
+ cert: fs.readFileSync(website.cert.ssl.cert)
440
+ }
441
+ } else {
442
+ sslOptions = {
443
+ key: fs.readFileSync(ssl.key),
444
+ cert: fs.readFileSync(ssl.cert)
445
+ }
446
+ }
447
+ const ctx = tls.createSecureContext(sslOptions)
448
+ this.#sslCache.set(hostname, ctx)
449
+ callback(null, ctx)
450
+ }
451
+ options.secure = true
452
+ this.#server_smtp = new SMTPServer(options)
453
+ this.#server_smtp.listen(465)
454
+ this.#server_smtp.on('error', err => error('SMTP Server Error: ', err))
455
+ const imap_sec = new server(options)
456
+ imap_sec.listen(993)
457
+ }
458
+
459
+ async list(domain) {
460
+ if (!domain) return Candy.server('Api').result(false, await __('Domain is required.'))
461
+ if (!Candy.core('Config').config.websites[domain]) return Candy.server('Api').result(false, await __('Domain %s not found.', domain))
462
+ let accounts = []
463
+ await new Promise((resolve, reject) => {
464
+ this.#db.each(
465
+ 'SELECT * FROM mail_account WHERE domain = ?',
466
+ [domain],
467
+ (err, row) => {
468
+ if (err) reject(err)
469
+ accounts.push(row.email)
470
+ },
471
+ (err, count) => {
472
+ if (err) reject(err)
473
+ resolve(count)
474
+ }
475
+ )
476
+ })
477
+ return Candy.server('Api').result(true, (await __('Mail accounts for domain %s.', domain)) + '\n' + accounts.join('\n'))
478
+ }
479
+
480
+ async password(email, password, retype) {
481
+ if (!email || !password || !retype) return Candy.server('Api').result(false, await __('All fields are required.'))
482
+ if (password != retype) return Candy.server('Api').result(false, await __('Passwords do not match.'))
483
+ password = await new Promise((resolve, reject) => {
484
+ bcrypt.hash(password, 10, (err, hash) => {
485
+ if (err) reject(err)
486
+ resolve(hash)
487
+ })
488
+ })
489
+ if (!email.match(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/))
490
+ return Candy.server('Api').result(false, await __('Invalid email address.'))
491
+ if (!this.exists(email)) return Candy.server('Api').result(false, await __('Mail account %s not found.', email))
492
+ this.#db.serialize(() => {
493
+ let stmt = this.#db.prepare('UPDATE mail_account SET password = ? WHERE email = ?')
494
+ stmt.run(password, email)
495
+ stmt.finalize()
496
+ })
497
+ return Candy.server('Api').result(true, await __('Mail account %s password updated successfully.', email))
498
+ }
499
+
500
+ async send(data) {
501
+ if (!data || !data.from || !data.to || !data.header) return Candy.server('Api').result(false, await __('All fields are required.'))
502
+ if (!data.from.value[0].address.match(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/))
503
+ return Candy.server('Api').result(false, await __('Invalid email address.'))
504
+ if (!data.to.value[0].address.match(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/))
505
+ return Candy.server('Api').result(false, await __('Invalid email address.'))
506
+ let domain = data.from.value[0].address.split('@')[1].split('.')
507
+ while (domain.length > 2 && !Candy.core('Config').config.websites[domain.join('.')]) domain.shift()
508
+ domain = domain.join('.')
509
+ if (!Candy.core('Config').config.websites[domain]) return Candy.server('Api').result(false, await __('Domain %s not found.', domain))
510
+ let mail = {
511
+ atttachments: [],
512
+ headerLines: [],
513
+ from: data.from,
514
+ to: data.to,
515
+ subject: data.subject ?? ''
516
+ }
517
+ for (let key in data.header) mail.headerLines.push({key: key.toLowerCase(), line: key + ': ' + data.header[key]})
518
+ if (data.html) mail.html = data.html
519
+ if (data.text) mail.text = data.text
520
+ mail.attachments = data.attachments ?? []
521
+ smtp.send(mail)
522
+ return Candy.server('Api').result(true, await __('Mail sent successfully.'))
523
+ }
524
+
525
+ #store(email, data, mailbox = 'INBOX', flags = '[]') {
526
+ return new Promise(resolve => {
527
+ if (email === data.from.value[0].address) {
528
+ flags = JSON.stringify(['seen'])
529
+ mailbox = 'Sent'
530
+ }
531
+ this.#db.serialize(async () => {
532
+ if (!this.#counts[email]) {
533
+ await new Promise((sub_resolve, sub_reject) => {
534
+ this.#db.get('SELECT COUNT(*) AS count FROM mail_received WHERE email = ?', [email], (err, row) => {
535
+ if (err) return sub_reject(err)
536
+ this.#counts[email] = row.count + 1
537
+ return sub_resolve()
538
+ })
539
+ })
540
+ } else this.#counts[email]++
541
+ if (data.html === '0') data.html = ''
542
+ this.#db.run(
543
+ "INSERT INTO mail_received ('uid', 'email', 'mailbox', 'attachments', 'headers', 'headerLines', 'html', 'text', 'textAsHtml', 'subject', 'to', 'from', 'messageId', 'flags') VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
544
+ [
545
+ this.#counts[email],
546
+ email,
547
+ mailbox,
548
+ JSON.stringify(data.attachments),
549
+ JSON.stringify(data.headers),
550
+ JSON.stringify(data.headerLines),
551
+ data.html,
552
+ data.text,
553
+ data.textAsHtml,
554
+ data.subject,
555
+ JSON.stringify(data.to),
556
+ JSON.stringify(data.from),
557
+ data.messageId,
558
+ flags
559
+ ],
560
+ async err => {
561
+ if (!err) return resolve(true)
562
+ error(err)
563
+ return resolve(await this.#store(email, data))
564
+ }
565
+ )
566
+ })
567
+ })
568
+ }
569
+ }
570
+
571
+ module.exports = new Mail()