domma-cms 0.54.2 → 0.66.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/CLAUDE.md +151 -77
- package/README.md +4 -4
- package/admin/css/admin.css +1 -1
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +3 -3
- package/admin/js/lib/page-picker.js +1 -0
- package/admin/js/lib/plugin-accent.js +1 -0
- package/admin/js/lib/plugin-chrome.js +1 -0
- package/admin/js/lib/shortcode-context-menu.js +2 -2
- package/admin/js/lib/sidebar-grouping.js +1 -1
- package/admin/js/lib/sidebar-grouping.test.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +4 -4
- package/admin/js/lib/slideover-resizable.js +1 -0
- package/admin/js/templates/context-menu-editor.html +212 -0
- package/admin/js/templates/context-menus.html +16 -0
- package/admin/js/templates/menu-editor.html +19 -25
- package/admin/js/templates/plugin-code.html +1 -1
- package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
- package/admin/js/templates/settings.html +27 -97
- package/admin/js/templates/theme.html +173 -0
- package/admin/js/views/context-menu-editor.js +55 -0
- package/admin/js/views/context-menus.js +5 -0
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/menu-editor.js +13 -13
- package/admin/js/views/plugin-marketplace.js +1 -1
- package/admin/js/views/plugins.js +25 -23
- package/admin/js/views/search.js +1 -0
- package/admin/js/views/settings.js +3 -3
- package/admin/js/views/theme.js +1 -0
- package/bin/cli.js +11 -2
- package/bin/lib/smtp-defaults.js +53 -0
- package/bin/update.js +13 -2
- package/config/menus/admin-sidebar.json +129 -23
- package/config/plugins.json +5 -5
- package/config/search.json +13 -0
- package/config/theme.json +18 -0
- package/package.json +12 -4
- package/plugins/_lib/admin/mail/compose-window.js +914 -0
- package/plugins/_lib/admin/mail/contacts.js +301 -0
- package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
- package/plugins/_lib/admin/mail/folder-tree.js +254 -0
- package/plugins/_lib/admin/mail/identity.js +480 -0
- package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
- package/plugins/_lib/admin/mail/keyboard.js +136 -0
- package/plugins/_lib/admin/mail/mail.css +1 -0
- package/plugins/_lib/admin/mail/mail.html +71 -0
- package/plugins/_lib/admin/mail/panes.js +253 -0
- package/plugins/_lib/admin/mail/reader-view.js +4453 -0
- package/plugins/_lib/admin/mail/resizable.js +26 -0
- package/plugins/_lib/admin/mail/rules.js +343 -0
- package/plugins/_lib/admin/mail/scheduling.js +203 -0
- package/plugins/_lib/admin/mail/section-kit.js +277 -0
- package/plugins/_lib/admin/mail/templates.js +238 -0
- package/plugins/_lib/admin/mail/threads.js +200 -0
- package/plugins/_lib/admin/mail/vacation.js +269 -0
- package/plugins/_lib/admin/ui/help.css +1 -0
- package/plugins/_lib/admin/ui/help.js +174 -0
- package/plugins/_lib/admin/ui/resizable.js +151 -0
- package/plugins/_lib/dataStore.js +117 -0
- package/plugins/_lib/mail/accounts.js +919 -0
- package/plugins/_lib/mail/bodyTokens.js +101 -0
- package/plugins/_lib/mail/compose.js +256 -0
- package/plugins/_lib/mail/defaults.js +57 -0
- package/plugins/_lib/mail/diagnostics.js +154 -0
- package/plugins/_lib/mail/envelope.js +161 -0
- package/plugins/_lib/mail/folders.js +192 -0
- package/plugins/_lib/mail/handoff.js +274 -0
- package/plugins/_lib/mail/imapPool.js +293 -0
- package/plugins/_lib/mail/mbox.js +74 -0
- package/plugins/_lib/mail/pollSchedule.js +79 -0
- package/plugins/_lib/mail/poller.js +135 -0
- package/plugins/_lib/mail/priority.js +138 -0
- package/plugins/_lib/mail/readRoutes.js +680 -0
- package/plugins/_lib/mail/render.js +291 -0
- package/plugins/_lib/mail/ruleRunner.js +152 -0
- package/plugins/_lib/mail/scheduler.js +254 -0
- package/plugins/_lib/mail/secretbox.js +229 -0
- package/plugins/_lib/mail/send.js +396 -0
- package/plugins/_lib/mail/store.js +1002 -0
- package/plugins/_lib/mail/sync.js +292 -0
- package/plugins/_lib/mail/syncPlan.js +126 -0
- package/plugins/_lib/mail/syncSelection.js +82 -0
- package/plugins/_lib/mail/unsubscribe.js +183 -0
- package/plugins/_lib/mail/vacationRunner.js +114 -0
- package/plugins/_lib/mail/write.js +473 -0
- package/plugins/_lib/schemaSync.js +83 -0
- package/plugins/_template/admin/css/index.css +0 -0
- package/plugins/_template/admin/templates/index.html +4 -4
- package/plugins/_template/admin/views/index.js +7 -0
- package/plugins/analytics/admin/css/index.css +1 -0
- package/plugins/analytics/admin/templates/analytics.html +22 -13
- package/plugins/analytics/plugin.json +3 -0
- package/plugins/blog/admin/css/index.css +1 -0
- package/plugins/blog/admin/templates/blog.html +30 -18
- package/plugins/blog/admin/templates/categories.html +2 -2
- package/plugins/blog/admin/templates/comments.html +2 -2
- package/plugins/blog/admin/templates/post-editor.html +34 -34
- package/plugins/blog/admin/templates/settings.html +6 -3
- package/plugins/blog/admin/views/blog.js +8 -5
- package/plugins/blog/admin/views/categories.js +5 -10
- package/plugins/blog/admin/views/comments.js +5 -5
- package/plugins/blog/admin/views/post-editor.js +39 -20
- package/plugins/blog/admin/views/settings.js +52 -50
- package/plugins/blog/collections/categories/schema.json +7 -6
- package/plugins/blog/collections/comments/schema.json +11 -10
- package/plugins/blog/collections/posts/schema.json +14 -13
- package/plugins/blog/plugin.js +36 -13
- package/plugins/blog/plugin.json +13 -5
- package/plugins/blog/plugin.public.js +312 -0
- package/plugins/contacts/admin/css/index.css +1 -0
- package/plugins/contacts/admin/templates/contacts.html +128 -0
- package/plugins/contacts/admin/views/contacts.js +237 -4
- package/plugins/contacts/collections/user-contacts/schema.json +108 -0
- package/plugins/contacts/plugin.js +214 -27
- package/plugins/contacts/plugin.json +4 -1
- package/plugins/invoice/admin/css/index.css +1 -0
- package/plugins/invoice/admin/templates/editor.html +140 -49
- package/plugins/invoice/admin/templates/index.html +153 -23
- package/plugins/invoice/admin/templates/issuers.html +2 -5
- package/plugins/invoice/admin/templates/receivers.html +2 -5
- package/plugins/invoice/admin/views/contacts-source.js +266 -0
- package/plugins/invoice/admin/views/editor.js +366 -199
- package/plugins/invoice/admin/views/export.js +199 -0
- package/plugins/invoice/admin/views/help-content.js +61 -0
- package/plugins/invoice/admin/views/index.js +582 -94
- package/plugins/invoice/admin/views/issuers.js +24 -17
- package/plugins/invoice/admin/views/media.js +172 -0
- package/plugins/invoice/admin/views/party-view.js +305 -67
- package/plugins/invoice/admin/views/payments.js +127 -0
- package/plugins/invoice/admin/views/print.js +130 -0
- package/plugins/invoice/admin/views/receivers.js +49 -16
- package/plugins/invoice/admin/views/send.js +212 -0
- package/plugins/invoice/admin/views/settings.js +594 -0
- package/plugins/invoice/admin/views/view-lifecycle.js +33 -0
- package/plugins/invoice/collections/invoice-issuers/schema.json +77 -11
- package/plugins/invoice/collections/invoice-receivers/schema.json +10 -9
- package/plugins/invoice/collections/invoices/schema.json +19 -13
- package/plugins/invoice/config.js +27 -6
- package/plugins/invoice/pdf.js +164 -0
- package/plugins/invoice/plugin.js +1217 -44
- package/plugins/invoice/plugin.json +10 -9
- package/plugins/invoice/templates/_base.css +1 -0
- package/plugins/invoice/templates/classic-nologo.html +100 -0
- package/plugins/invoice/templates/classic.html +91 -0
- package/plugins/invoice/templates/invoice-print.html +24 -0
- package/plugins/invoice/templates/minimal.html +99 -0
- package/plugins/invoice/templates/modern-nologo.html +114 -0
- package/plugins/invoice/templates/modern.html +113 -0
- package/plugins/invoice/templates/templates.json +11 -0
- package/plugins/mail-reader/admin/views/mail.js +19 -0
- package/plugins/mail-reader/config.js +7 -0
- package/plugins/mail-reader/plugin.js +48 -0
- package/plugins/mail-reader/plugin.json +33 -0
- package/plugins/notes/admin/views/notes.js +1 -1
- package/plugins/notes/plugin.json +2 -2
- package/plugins/surveys/lib/audience.js +37 -0
- package/plugins/surveys/lib/campaigns.js +43 -0
- package/plugins/surveys/lib/ledger.js +110 -0
- package/plugins/surveys/lib/sending.js +106 -0
- package/plugins/surveys/lib/stats.js +62 -0
- package/plugins/surveys/lib/submit.js +95 -0
- package/plugins/surveys/lib/tokens.js +28 -0
- package/plugins/surveys/plugin.public.js +149 -0
- package/plugins/theme-switcher/admin/templates/theme-switcher.html +1 -1
- package/public/css/forms.css +1 -1
- package/public/css/menu-highlight.css +1 -1
- package/public/css/search.css +1 -0
- package/public/css/site.css +1 -1
- package/public/js/collection-context.js +2 -2
- package/public/js/context-menus.js +1 -0
- package/public/js/form-logic-engine.js +1 -1
- package/public/js/forms.js +2 -2
- package/public/js/menu-decor.mjs +1 -1
- package/public/js/search.js +1 -0
- package/public/js/site.js +1 -1
- package/scripts/build.js +37 -3
- package/scripts/copy-domma.js +48 -0
- package/scripts/seed.js +1996 -0
- package/scripts/setup.js +8 -0
- package/server/routes/api/collections.js +34 -0
- package/server/routes/api/context-menus.js +104 -0
- package/server/routes/api/forms.js +42 -3
- package/server/routes/api/notifications.js +69 -19
- package/server/routes/api/plugins.js +50 -6
- package/server/routes/api/search.js +43 -0
- package/server/routes/api/theme.js +69 -0
- package/server/routes/public.js +42 -7
- package/server/server.js +74 -0
- package/server/services/adapters/FileAdapter.js +6 -1
- package/server/services/content.js +26 -0
- package/server/services/contextMenus.js +477 -0
- package/server/services/email.js +29 -3
- package/server/services/health.js +23 -2
- package/server/services/markdown.js +70 -9
- package/server/services/menuRender.js +28 -4
- package/server/services/menus.js +25 -1
- package/server/services/permissionRegistry.js +24 -0
- package/server/services/pluginFiles.js +52 -11
- package/server/services/plugins.js +229 -6
- package/server/services/renderer.js +148 -22
- package/server/services/roles.js +1 -1
- package/server/services/search-migration.js +82 -0
- package/server/services/search.js +413 -0
- package/server/services/sidebar-migration.js +1 -0
- package/server/services/themeSettings.js +541 -0
- package/server/services/users.js +8 -0
- package/server/templates/page.html +4 -2
- package/plugins/contacts/data/contacts.json +0 -20
- package/plugins/notes/data/notes.json +0 -1
- package/plugins/site-search/admin/views/site-search.js +0 -116
- package/plugins/site-search/config.js +0 -15
- package/plugins/site-search/plugin.js +0 -188
- package/plugins/site-search/plugin.json +0 -40
- package/plugins/site-search/public/inject-body.html +0 -17
- package/plugins/site-search/public/inject-head.html +0 -1
- package/plugins/site-search/public/search.css +0 -1
- package/plugins/site-search/public/search.js +0 -1
- package/plugins/todo/data/todos.json +0 -1
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sealed-secret helper for the Mail Reader plugin.
|
|
3
|
+
*
|
|
4
|
+
* IMAP is a password protocol: to reconnect without asking the user every time
|
|
5
|
+
* we have to keep their mailbox password, and keeping it in plain text on disk
|
|
6
|
+
* is not an option. Everything stored goes through seal()/open() here.
|
|
7
|
+
*
|
|
8
|
+
* ## Key resolution
|
|
9
|
+
*
|
|
10
|
+
* The key is derived from whichever of these is present, in order:
|
|
11
|
+
*
|
|
12
|
+
* 1. `MAIL_SECRET` — explicit override for anyone who wants mail
|
|
13
|
+
* credentials on their own rotatable key.
|
|
14
|
+
* 2. `INSTANCE_SECRET` — written by `npm run setup` and by manager
|
|
15
|
+
* registration. Present on fresh installs; NOT
|
|
16
|
+
* present on sites that predate it, which is why
|
|
17
|
+
* this is a chain and not a single source.
|
|
18
|
+
* 3. a generated key in `data/.mailkey` (0600) — last resort so the plugin
|
|
19
|
+
* works out of the box. It sits next to the sealed
|
|
20
|
+
* data, so it defends a leaked *backup* of
|
|
21
|
+
* accounts.json, not a compromised host.
|
|
22
|
+
*
|
|
23
|
+
* Deliberately NOT derived from JWT_SECRET: rotating that logs everyone out,
|
|
24
|
+
* which is recoverable, and silently bricking every stored mailbox password at
|
|
25
|
+
* the same time is not.
|
|
26
|
+
*
|
|
27
|
+
* The source that sealed a value is recorded in the token, so moving up the
|
|
28
|
+
* chain (adding MAIL_SECRET to a site that had been using .mailkey) fails with
|
|
29
|
+
* a sentence that says what happened instead of a bare auth error.
|
|
30
|
+
*
|
|
31
|
+
* @module _lib/mail/secretbox
|
|
32
|
+
*/
|
|
33
|
+
import {createCipheriv, createDecipheriv, randomBytes, scryptSync} from 'node:crypto';
|
|
34
|
+
import fs from 'node:fs';
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
import {fileURLToPath} from 'node:url';
|
|
37
|
+
|
|
38
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Domain separation - stops this key colliding with any other use of the same
|
|
42
|
+
* secret. Deliberately NOT named after the plugin: `email-pro` is expected to
|
|
43
|
+
* open the same sealed accounts, and renaming this tag would invalidate every
|
|
44
|
+
* stored password for the sake of cosmetics.
|
|
45
|
+
*/
|
|
46
|
+
const DOMAIN = 'domma-mail-v1';
|
|
47
|
+
|
|
48
|
+
/** Minimum acceptable length for an env-supplied secret. */
|
|
49
|
+
const MIN_SECRET_LENGTH = 32;
|
|
50
|
+
|
|
51
|
+
/** Shares the override used by accounts.js - see the note there. */
|
|
52
|
+
const DATA_DIR = process.env.DOMMA_MAIL_DATA_DIR || path.join(__dirname, '..', 'data');
|
|
53
|
+
|
|
54
|
+
const KEY_FILE = path.join(DATA_DIR, '.mailkey');
|
|
55
|
+
|
|
56
|
+
/** Where the key lived while this code belonged to the mail-reader plugin. */
|
|
57
|
+
const LEGACY_KEY_FILE = path.join(__dirname, '..', '..', 'mail-reader', 'data', '.mailkey');
|
|
58
|
+
|
|
59
|
+
/** scrypt is deliberately slow; cache derived keys so a reconnect is not a CPU event. */
|
|
60
|
+
const keyCache = new Map();
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Raised when a sealed value cannot be opened with the key we can derive.
|
|
64
|
+
* Carries the two sources so the caller can explain the mismatch.
|
|
65
|
+
*/
|
|
66
|
+
export class MailKeyError extends Error {
|
|
67
|
+
/**
|
|
68
|
+
* @param {string} message
|
|
69
|
+
* @param {{sealedWith?: string, availableFrom?: string}} [detail]
|
|
70
|
+
*/
|
|
71
|
+
constructor(message, detail = {}) {
|
|
72
|
+
super(message);
|
|
73
|
+
this.name = 'MailKeyError';
|
|
74
|
+
this.sealedWith = detail.sealedWith ?? null;
|
|
75
|
+
this.availableFrom = detail.availableFrom ?? null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Read, or create, the local fallback key.
|
|
81
|
+
*
|
|
82
|
+
* Written 0600 at create time rather than chmod-ed afterwards, so there is no
|
|
83
|
+
* window where the file exists and is world-readable.
|
|
84
|
+
*
|
|
85
|
+
* @returns {string} hex key material
|
|
86
|
+
*/
|
|
87
|
+
function localKeyMaterial() {
|
|
88
|
+
try {
|
|
89
|
+
const existing = fs.readFileSync(KEY_FILE, 'utf8').trim();
|
|
90
|
+
if (existing.length >= MIN_SECRET_LENGTH) return existing;
|
|
91
|
+
} catch {
|
|
92
|
+
// Missing or unreadable - fall through.
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Adopt the key from the old mail-reader-owned location before generating
|
|
96
|
+
// a new one. Generating first would strand every password already sealed
|
|
97
|
+
// with it, and the user would have no way to tell why.
|
|
98
|
+
try {
|
|
99
|
+
const legacy = fs.readFileSync(LEGACY_KEY_FILE, 'utf8').trim();
|
|
100
|
+
if (legacy.length >= MIN_SECRET_LENGTH) {
|
|
101
|
+
fs.mkdirSync(path.dirname(KEY_FILE), {recursive: true});
|
|
102
|
+
fs.writeFileSync(KEY_FILE, legacy + '\n', {encoding: 'utf8', mode: 0o600});
|
|
103
|
+
return legacy;
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
// No legacy key - fall through and generate.
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const generated = randomBytes(32).toString('hex');
|
|
110
|
+
fs.mkdirSync(path.dirname(KEY_FILE), {recursive: true});
|
|
111
|
+
fs.writeFileSync(KEY_FILE, generated + '\n', {encoding: 'utf8', mode: 0o600});
|
|
112
|
+
return generated;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Resolve the key material and say where it came from.
|
|
117
|
+
*
|
|
118
|
+
* @returns {{secret: string, source: 'env-mail'|'env-instance'|'local'}}
|
|
119
|
+
*/
|
|
120
|
+
export function resolveKeySource() {
|
|
121
|
+
const mailSecret = process.env.MAIL_SECRET;
|
|
122
|
+
if (typeof mailSecret === 'string' && mailSecret.length >= MIN_SECRET_LENGTH) {
|
|
123
|
+
return {secret: mailSecret, source: 'env-mail'};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const instanceSecret = process.env.INSTANCE_SECRET;
|
|
127
|
+
if (typeof instanceSecret === 'string' && instanceSecret.length >= MIN_SECRET_LENGTH) {
|
|
128
|
+
return {secret: instanceSecret, source: 'env-instance'};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {secret: localKeyMaterial(), source: 'local'};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Derive a 32-byte key for a given salt, memoised per (source, salt).
|
|
136
|
+
*
|
|
137
|
+
* @param {string} secret
|
|
138
|
+
* @param {string} source
|
|
139
|
+
* @param {Buffer} salt
|
|
140
|
+
* @returns {Buffer}
|
|
141
|
+
*/
|
|
142
|
+
function deriveKey(secret, source, salt) {
|
|
143
|
+
const cacheKey = source + ':' + salt.toString('base64');
|
|
144
|
+
const cached = keyCache.get(cacheKey);
|
|
145
|
+
if (cached) return cached;
|
|
146
|
+
|
|
147
|
+
const key = scryptSync(DOMAIN + ':' + secret, salt, 32);
|
|
148
|
+
keyCache.set(cacheKey, key);
|
|
149
|
+
return key;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Encrypt a string for storage.
|
|
154
|
+
*
|
|
155
|
+
* Format: `v1.<source>.<salt>.<iv>.<tag>.<ciphertext>`, each part base64url.
|
|
156
|
+
*
|
|
157
|
+
* @param {string} plaintext
|
|
158
|
+
* @returns {string} sealed token
|
|
159
|
+
*/
|
|
160
|
+
export function seal(plaintext) {
|
|
161
|
+
const {secret, source} = resolveKeySource();
|
|
162
|
+
const salt = randomBytes(16);
|
|
163
|
+
const iv = randomBytes(12);
|
|
164
|
+
const key = deriveKey(secret, source, salt);
|
|
165
|
+
|
|
166
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
167
|
+
const ciphertext = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]);
|
|
168
|
+
const tag = cipher.getAuthTag();
|
|
169
|
+
|
|
170
|
+
return [
|
|
171
|
+
'v1',
|
|
172
|
+
source,
|
|
173
|
+
salt.toString('base64url'),
|
|
174
|
+
iv.toString('base64url'),
|
|
175
|
+
tag.toString('base64url'),
|
|
176
|
+
ciphertext.toString('base64url')
|
|
177
|
+
].join('.');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Decrypt a sealed token.
|
|
182
|
+
*
|
|
183
|
+
* @param {string} token
|
|
184
|
+
* @returns {string} plaintext
|
|
185
|
+
* @throws {MailKeyError} when the token is malformed or the key no longer matches
|
|
186
|
+
*/
|
|
187
|
+
export function open(token) {
|
|
188
|
+
if (typeof token !== 'string' || !token.startsWith('v1.')) {
|
|
189
|
+
throw new MailKeyError('Stored password is not in a recognised format.');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const [, sealedWith, saltPart, ivPart, tagPart, ctPart] = token.split('.');
|
|
193
|
+
if (!saltPart || !ivPart || !tagPart || !ctPart) {
|
|
194
|
+
throw new MailKeyError('Stored password is malformed.');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const {secret, source} = resolveKeySource();
|
|
198
|
+
const key = deriveKey(secret, source, Buffer.from(saltPart, 'base64url'));
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(ivPart, 'base64url'));
|
|
202
|
+
decipher.setAuthTag(Buffer.from(tagPart, 'base64url'));
|
|
203
|
+
return Buffer.concat([
|
|
204
|
+
decipher.update(Buffer.from(ctPart, 'base64url')),
|
|
205
|
+
decipher.final()
|
|
206
|
+
]).toString('utf8');
|
|
207
|
+
} catch {
|
|
208
|
+
const moved = sealedWith !== source;
|
|
209
|
+
throw new MailKeyError(
|
|
210
|
+
moved
|
|
211
|
+
? `This password was saved using the ${describeSource(sealedWith)} and the server is now using the ${describeSource(source)}. Re-enter the password to save it against the new key.`
|
|
212
|
+
: 'Stored password could not be decrypted - the encryption key has changed. Re-enter the password.',
|
|
213
|
+
{sealedWith, availableFrom: source}
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Human-readable name for a key source, for error messages.
|
|
220
|
+
*
|
|
221
|
+
* @param {string} source
|
|
222
|
+
* @returns {string}
|
|
223
|
+
*/
|
|
224
|
+
export function describeSource(source) {
|
|
225
|
+
if (source === 'env-mail') return 'MAIL_SECRET environment variable';
|
|
226
|
+
if (source === 'env-instance') return 'INSTANCE_SECRET environment variable';
|
|
227
|
+
if (source === 'local') return 'plugin-local key file';
|
|
228
|
+
return 'unknown key source';
|
|
229
|
+
}
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sending, and keeping a copy where the rest of your clients will find it.
|
|
3
|
+
*
|
|
4
|
+
* The message is built once, as raw RFC822, and that same buffer is both sent
|
|
5
|
+
* and appended to Sent. Building it twice - once for the transport and once
|
|
6
|
+
* for the copy - is how a Sent folder ends up holding something subtly unlike
|
|
7
|
+
* what the recipient received.
|
|
8
|
+
*
|
|
9
|
+
* @module _lib/mail/send
|
|
10
|
+
*/
|
|
11
|
+
import {createRequire} from 'node:module';
|
|
12
|
+
|
|
13
|
+
import nodemailer from 'nodemailer';
|
|
14
|
+
|
|
15
|
+
import {bareAddress, formatFrom} from '../admin/mail/identity.js';
|
|
16
|
+
import {normalisePriority, priorityHeaders} from './priority.js';
|
|
17
|
+
|
|
18
|
+
// MailComposer is not on nodemailer's public export, but it is the piece that
|
|
19
|
+
// turns a message object into the bytes that go on the wire - which is what a
|
|
20
|
+
// faithful Sent copy needs.
|
|
21
|
+
const MailComposer = createRequire(import.meta.url)('nodemailer/lib/mail-composer');
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Turn an address list into what nodemailer expects.
|
|
25
|
+
*
|
|
26
|
+
* @param {Array<{name?: string, address?: string}>|string|undefined} list
|
|
27
|
+
* @returns {object[]}
|
|
28
|
+
*/
|
|
29
|
+
function addresses(list) {
|
|
30
|
+
if (!list) return [];
|
|
31
|
+
if (typeof list === 'string') {
|
|
32
|
+
return list.split(',').map(part => ({address: part.trim()})).filter(a => a.address);
|
|
33
|
+
}
|
|
34
|
+
return list
|
|
35
|
+
.map(entry => (typeof entry === 'string' ? {address: entry.trim()} : entry))
|
|
36
|
+
.filter(entry => entry?.address);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check a draft is worth trying to send.
|
|
41
|
+
*
|
|
42
|
+
* @param {object} draft
|
|
43
|
+
* @returns {{ok: true} | {ok: false, error: string}}
|
|
44
|
+
*/
|
|
45
|
+
export function validateDraft(draft, {maxAttachmentBytes = 20_000_000} = {}) {
|
|
46
|
+
const to = addresses(draft?.to);
|
|
47
|
+
const cc = addresses(draft?.cc);
|
|
48
|
+
const bcc = addresses(draft?.bcc);
|
|
49
|
+
|
|
50
|
+
if (!to.length && !cc.length && !bcc.length) {
|
|
51
|
+
return {ok: false, error: 'A message needs at least one recipient.'};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const bad = [...to, ...cc, ...bcc]
|
|
55
|
+
.map(a => a.address)
|
|
56
|
+
.filter(address => !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address));
|
|
57
|
+
if (bad.length) {
|
|
58
|
+
return {ok: false, error: `Not a valid address: ${bad.join(', ')}`};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!String(draft?.body ?? '').trim() && !String(draft?.subject ?? '').trim()) {
|
|
62
|
+
return {ok: false, error: 'A message needs a subject or a body.'};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const attachments = draft?.attachments ?? [];
|
|
66
|
+
if (!Array.isArray(attachments)) {
|
|
67
|
+
return {ok: false, error: 'Attachments must be a list.'};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let total = 0;
|
|
71
|
+
for (const file of attachments) {
|
|
72
|
+
if (!file?.filename || typeof file.content !== 'string') {
|
|
73
|
+
return {ok: false, error: 'Every attachment needs a filename and its content.'};
|
|
74
|
+
}
|
|
75
|
+
// Base64 length maps to bytes without decoding it first, which means
|
|
76
|
+
// an oversized payload is refused before it is turned into memory.
|
|
77
|
+
total += Math.floor(file.content.length * 3 / 4);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (total > maxAttachmentBytes) {
|
|
81
|
+
const mb = n => `${(n / 1_000_000).toFixed(1)}MB`;
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
error: `Attachments total ${mb(total)}, over the ${mb(maxAttachmentBytes)} limit. Most providers refuse around 25MB, so this is better refused here than bounced later.`
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {ok: true, attachmentBytes: total};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Build the raw bytes of a message.
|
|
93
|
+
*
|
|
94
|
+
* `from` is the header value, display name and all. The envelope address is
|
|
95
|
+
* the caller's business, not this function's - see `sendMessage`.
|
|
96
|
+
*
|
|
97
|
+
* @param {object} draft
|
|
98
|
+
* @param {string} from
|
|
99
|
+
* @param {{replyTo?: string, readReceiptTo?: string, headers?: object}} [options]
|
|
100
|
+
* @returns {Promise<Buffer>}
|
|
101
|
+
*/
|
|
102
|
+
function buildRaw(draft, from, {replyTo = '', readReceiptTo = '', headers = {}} = {}) {
|
|
103
|
+
const composer = new MailComposer({
|
|
104
|
+
from,
|
|
105
|
+
// Only when the mailbox asks for one. An absent Reply-To means
|
|
106
|
+
// "answer the From address", which is what every client already does -
|
|
107
|
+
// setting it to the From address says the same thing in a header that
|
|
108
|
+
// some mailing lists then rewrite.
|
|
109
|
+
...(replyTo ? {replyTo} : {}),
|
|
110
|
+
to: addresses(draft.to),
|
|
111
|
+
cc: addresses(draft.cc),
|
|
112
|
+
bcc: addresses(draft.bcc),
|
|
113
|
+
subject: draft.subject ?? '',
|
|
114
|
+
// Set by the caller for a QUEUED send, so that a retry after a
|
|
115
|
+
// failure carries the same id as the attempt before it.
|
|
116
|
+
//
|
|
117
|
+
// The claim in the store makes a send exactly-once up to the moment
|
|
118
|
+
// the message leaves; it cannot make SMTP exactly-once. A connection
|
|
119
|
+
// dropped between the terminating dot and the 250 looks identical to
|
|
120
|
+
// a message that was never accepted, and the only honest options are
|
|
121
|
+
// to risk losing it or to risk sending it twice. This takes the
|
|
122
|
+
// second and makes the duplicate detectable: both copies carry one
|
|
123
|
+
// Message-ID, which is what every receiver deduplicates on.
|
|
124
|
+
...(draft.messageId ? {messageId: draft.messageId} : {}),
|
|
125
|
+
text: draft.body ?? '',
|
|
126
|
+
// Both parts when there is formatting, so the message is
|
|
127
|
+
// multipart/alternative: clients that render HTML get it, and those
|
|
128
|
+
// that do not - and anyone reading in a terminal - still get something
|
|
129
|
+
// written for them rather than a wall of tags.
|
|
130
|
+
...(String(draft.html ?? '').trim() ? {html: draft.html} : {}),
|
|
131
|
+
// Threading. Present only for a reply - a forward deliberately carries
|
|
132
|
+
// neither, so it does not file itself into a conversation its new
|
|
133
|
+
// recipient has never seen.
|
|
134
|
+
...(draft.inReplyTo ? {inReplyTo: draft.inReplyTo} : {}),
|
|
135
|
+
...(draft.references?.length ? {references: draft.references} : {}),
|
|
136
|
+
// All three priority headers, or none. A recipient's client reads
|
|
137
|
+
// whichever one it knows, and setting only one is how a message looks
|
|
138
|
+
// urgent in Outlook and ordinary everywhere else. Normal sends none:
|
|
139
|
+
// the absence of the header IS normal.
|
|
140
|
+
headers: {
|
|
141
|
+
...priorityHeaders(normalisePriority(draft.priority)),
|
|
142
|
+
// Both spellings when a receipt is wanted. `Disposition-Notification-To`
|
|
143
|
+
// is the standard one (RFC 8098) and `Return-Receipt-To` is what
|
|
144
|
+
// older clients read; sending one and not the other is how a
|
|
145
|
+
// receipt request works in half the world. A receipt is a request,
|
|
146
|
+
// not a mechanism - every client is free to ignore it, and many
|
|
147
|
+
// do, which is worth knowing before relying on one.
|
|
148
|
+
...(readReceiptTo
|
|
149
|
+
? {
|
|
150
|
+
'Disposition-Notification-To': readReceiptTo,
|
|
151
|
+
'Return-Receipt-To': readReceiptTo
|
|
152
|
+
}
|
|
153
|
+
: {}),
|
|
154
|
+
// Anything the caller must put on the wire itself. Today that is
|
|
155
|
+
// the away message announcing itself as automatic, which is what
|
|
156
|
+
// stops the responder at the far end answering it back.
|
|
157
|
+
...headers
|
|
158
|
+
},
|
|
159
|
+
attachments: (draft.attachments ?? []).map(file => ({
|
|
160
|
+
filename: file.filename,
|
|
161
|
+
content: Buffer.from(file.content, 'base64'),
|
|
162
|
+
contentType: file.contentType
|
|
163
|
+
}))
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return new Promise((resolve, reject) => {
|
|
167
|
+
composer.compile().build((err, message) => (err ? reject(err) : resolve(message)));
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Thrown to skip filing a copy, rather than checked around the whole block.
|
|
173
|
+
*
|
|
174
|
+
* The filing is already wrapped in a try/catch that treats any failure as
|
|
175
|
+
* "delivered but not filed", which is exactly the outcome wanted here - so
|
|
176
|
+
* the cheapest correct way in is through the same door.
|
|
177
|
+
*/
|
|
178
|
+
class SkipFiling extends Error {}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Send a draft, and file a copy.
|
|
182
|
+
*
|
|
183
|
+
* The copy is best effort. A message that was delivered but could not be
|
|
184
|
+
* filed is still delivered, and failing the send at that point would invite
|
|
185
|
+
* someone to send it a second time.
|
|
186
|
+
*
|
|
187
|
+
* @param {{smtp: object, draft: object, pool: object, poolKey: string,
|
|
188
|
+
* connection: object, sentFolder: string|null}} options
|
|
189
|
+
* @returns {Promise<{messageId: string, accepted: string[], rejected: string[], filedTo: string|null, fileError: string|null}>}
|
|
190
|
+
*/
|
|
191
|
+
export async function sendMessage({
|
|
192
|
+
smtp, draft, pool, poolKey, connection, sentFolder = null, allowInsecureTLS = false,
|
|
193
|
+
replaces = null, draftsFolder = null, headers = {}, fileCopy = true
|
|
194
|
+
}) {
|
|
195
|
+
// The header carries the display name; the envelope never can. They are
|
|
196
|
+
// built from the same stored address so they cannot name two different
|
|
197
|
+
// mailboxes, but they are not the same string.
|
|
198
|
+
const raw = await buildRaw(draft, formatFrom(smtp.from, smtp.fromName), {
|
|
199
|
+
replyTo: smtp.replyTo,
|
|
200
|
+
// Asked for per message, addressed to wherever replies would go -
|
|
201
|
+
// which is the Reply-To when there is one and the From otherwise.
|
|
202
|
+
// A receipt sent to an address the sender does not read is a receipt
|
|
203
|
+
// nobody sees.
|
|
204
|
+
readReceiptTo: draft.readReceipt
|
|
205
|
+
? (smtp.replyTo || bareAddress(smtp.from))
|
|
206
|
+
: '',
|
|
207
|
+
headers
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// The same setting that governs incoming TLS governs outgoing: a site
|
|
211
|
+
// running its own mail server with a self-signed certificate has the same
|
|
212
|
+
// problem in both directions, and two switches for one decision is how
|
|
213
|
+
// one of them ends up wrong.
|
|
214
|
+
const transport = nodemailer.createTransport({
|
|
215
|
+
...smtp.transport,
|
|
216
|
+
tls: {rejectUnauthorized: !allowInsecureTLS}
|
|
217
|
+
});
|
|
218
|
+
const info = await transport.sendMail({
|
|
219
|
+
envelope: {
|
|
220
|
+
// Bare, always. A display name here is not decoration the server
|
|
221
|
+
// ignores - it is a MAIL FROM the server rejects, and `smtp.from`
|
|
222
|
+
// is a free-text field somebody may well have typed a name into.
|
|
223
|
+
from: bareAddress(smtp.from),
|
|
224
|
+
to: [...addresses(draft.to), ...addresses(draft.cc), ...addresses(draft.bcc)]
|
|
225
|
+
.map(a => a.address)
|
|
226
|
+
},
|
|
227
|
+
raw
|
|
228
|
+
});
|
|
229
|
+
transport.close();
|
|
230
|
+
|
|
231
|
+
let filedTo = null;
|
|
232
|
+
let fileError = null;
|
|
233
|
+
try {
|
|
234
|
+
// An away message deliberately does not file. One holiday can produce
|
|
235
|
+
// a hundred of them, and a Sent folder that is mostly "Out of office"
|
|
236
|
+
// is a Sent folder nobody can find anything in - the responder keeps
|
|
237
|
+
// its own log instead.
|
|
238
|
+
if (!fileCopy) throw new SkipFiling();
|
|
239
|
+
const destination = sentFolder ?? await findSentFolder(pool, poolKey, connection);
|
|
240
|
+
if (destination) {
|
|
241
|
+
await pool.withClient(poolKey, connection,
|
|
242
|
+
client => client.append(destination, raw, ['\\Seen']));
|
|
243
|
+
filedTo = destination;
|
|
244
|
+
}
|
|
245
|
+
} catch (err) {
|
|
246
|
+
if (!(err instanceof SkipFiling)) fileError = err.message;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Only once it has actually gone. A draft cleared before the send would
|
|
250
|
+
// be a draft lost to a rejected recipient, and the text only existed
|
|
251
|
+
// there.
|
|
252
|
+
const draftRemoved = replaces ? await discardDraft({
|
|
253
|
+
pool, poolKey, connection, uid: replaces, draftsFolder
|
|
254
|
+
}) : false;
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
messageId: info.messageId,
|
|
258
|
+
accepted: info.accepted ?? [],
|
|
259
|
+
rejected: info.rejected ?? [],
|
|
260
|
+
filedTo,
|
|
261
|
+
fileError,
|
|
262
|
+
draftRemoved
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Take a superseded draft out of the Drafts folder.
|
|
268
|
+
*
|
|
269
|
+
* Expunged rather than moved to Trash, which is the one place this codebase
|
|
270
|
+
* departs from "deleting means Trash": the draft has just been sent, so the
|
|
271
|
+
* message still exists in Sent, and putting a copy of it in the bin as well
|
|
272
|
+
* leaves someone three copies of one email to reason about.
|
|
273
|
+
*
|
|
274
|
+
* Best-effort. A leftover draft is untidy; a send reported as failed because
|
|
275
|
+
* the tidying did is worse.
|
|
276
|
+
*
|
|
277
|
+
* @param {{pool: object, poolKey: string, connection: object, uid: number,
|
|
278
|
+
* draftsFolder?: string|null}} options
|
|
279
|
+
* @returns {Promise<boolean>}
|
|
280
|
+
*/
|
|
281
|
+
async function discardDraft({pool, poolKey, connection, uid, draftsFolder = null}) {
|
|
282
|
+
try {
|
|
283
|
+
const destination = draftsFolder
|
|
284
|
+
?? await findSpecialFolder(pool, poolKey, connection, '\\Drafts');
|
|
285
|
+
if (!destination) return false;
|
|
286
|
+
|
|
287
|
+
await pool.withMailbox(poolKey, connection, destination, async (client) => {
|
|
288
|
+
await client.messageFlagsAdd({uid: String(uid)}, ['\\Deleted'], {uid: true});
|
|
289
|
+
await client.messageDelete({uid: String(uid)}, {uid: true});
|
|
290
|
+
}, {writable: true});
|
|
291
|
+
return true;
|
|
292
|
+
} catch {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Save a draft to the Drafts folder.
|
|
299
|
+
*
|
|
300
|
+
* Built with the same code that builds a sendable message, so what is stored
|
|
301
|
+
* is what would have gone out. Saved with `\\Draft` so other clients treat it
|
|
302
|
+
* as editable rather than as received mail.
|
|
303
|
+
*
|
|
304
|
+
* A previous version is replaced rather than accumulated: editing a draft four
|
|
305
|
+
* times should leave one draft, not four. The old copy is removed only after
|
|
306
|
+
* the new one is stored, so an interruption loses nothing.
|
|
307
|
+
*
|
|
308
|
+
* @param {{draft: object, from: string, replyTo?: string, pool: object, poolKey: string,
|
|
309
|
+
* connection: object, draftsFolder?: string|null, replaces?: number|null}} options
|
|
310
|
+
* @returns {Promise<{savedTo: string, uid: number|null, replaced: number|null}>}
|
|
311
|
+
*/
|
|
312
|
+
export async function saveDraft({
|
|
313
|
+
draft, from, replyTo = '', pool, poolKey, connection, draftsFolder = null, replaces = null
|
|
314
|
+
}) {
|
|
315
|
+
// Written into the draft, not applied when it is eventually sent: a draft
|
|
316
|
+
// opened on the phone should show the same headers it will go out with.
|
|
317
|
+
const raw = await buildRaw(draft, from, {
|
|
318
|
+
replyTo,
|
|
319
|
+
readReceiptTo: draft.readReceipt ? (replyTo || bareAddress(from)) : ''
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const destination = draftsFolder ?? await findSpecialFolder(pool, poolKey, connection, '\\Drafts');
|
|
323
|
+
if (!destination) {
|
|
324
|
+
throw new Error('This server has no Drafts folder to save into.');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const appended = await pool.withClient(poolKey, connection,
|
|
328
|
+
client => client.append(destination, raw, ['\\Draft', '\\Seen']));
|
|
329
|
+
|
|
330
|
+
// Only now is the previous version expendable.
|
|
331
|
+
let replaced = null;
|
|
332
|
+
if (replaces) {
|
|
333
|
+
try {
|
|
334
|
+
await pool.withMailbox(poolKey, connection, destination, async (client) => {
|
|
335
|
+
await client.messageFlagsAdd({uid: String(replaces)}, ['\\Deleted'], {uid: true});
|
|
336
|
+
await client.messageDelete({uid: String(replaces)}, {uid: true});
|
|
337
|
+
}, {writable: true});
|
|
338
|
+
replaced = replaces;
|
|
339
|
+
} catch {
|
|
340
|
+
// The new draft is saved; a leftover old one is untidy, not broken.
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return {savedTo: destination, uid: appended?.uid ?? null, replaced};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Find a folder by its SPECIAL-USE attribute.
|
|
349
|
+
*
|
|
350
|
+
* @param {object} pool
|
|
351
|
+
* @param {string} poolKey
|
|
352
|
+
* @param {object} connection
|
|
353
|
+
* @param {string} use
|
|
354
|
+
* @returns {Promise<string|null>}
|
|
355
|
+
*/
|
|
356
|
+
async function findSpecialFolder(pool, poolKey, connection, use) {
|
|
357
|
+
const boxes = await pool.withClient(poolKey, connection, client => client.list());
|
|
358
|
+
return boxes.find(box => box.specialUse === use)?.path ?? null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Where this server keeps sent mail.
|
|
363
|
+
*
|
|
364
|
+
* By SPECIAL-USE, never by name: it is localised on plenty of servers, and
|
|
365
|
+
* guessing wrong means the copy lands in a folder nobody looks at.
|
|
366
|
+
*
|
|
367
|
+
* @param {object} pool
|
|
368
|
+
* @param {string} poolKey
|
|
369
|
+
* @param {object} connection
|
|
370
|
+
* @returns {Promise<string|null>}
|
|
371
|
+
*/
|
|
372
|
+
async function findSentFolder(pool, poolKey, connection) {
|
|
373
|
+
return findSpecialFolder(pool, poolKey, connection, '\\Sent');
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Try the outgoing server without sending anything.
|
|
378
|
+
*
|
|
379
|
+
* @param {object} transportConfig
|
|
380
|
+
* @returns {Promise<{ok: boolean, error?: string}>}
|
|
381
|
+
*/
|
|
382
|
+
export async function verifySmtp(transportConfig, allowInsecureTLS = false) {
|
|
383
|
+
const transport = nodemailer.createTransport({
|
|
384
|
+
...transportConfig,
|
|
385
|
+
connectionTimeout: 10_000,
|
|
386
|
+
tls: {rejectUnauthorized: !allowInsecureTLS}
|
|
387
|
+
});
|
|
388
|
+
try {
|
|
389
|
+
await transport.verify();
|
|
390
|
+
return {ok: true};
|
|
391
|
+
} catch (err) {
|
|
392
|
+
return {ok: false, error: err.message};
|
|
393
|
+
} finally {
|
|
394
|
+
transport.close();
|
|
395
|
+
}
|
|
396
|
+
}
|