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,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()
|