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,919 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-user IMAP account store for the Mail Reader plugin.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this is not a collection
|
|
5
|
+
*
|
|
6
|
+
* Every other plugin keeps its records in `server/services/collections.js`.
|
|
7
|
+
* This one does not, on purpose. A plugin-owned collection is still listed in
|
|
8
|
+
* the admin Collections screen with a working Entries link (`schema.plugin` is
|
|
9
|
+
* a badge, not a visibility gate), it is reachable through the `/api/v1`
|
|
10
|
+
* surface, and it can be pointed at a shared Mongo by global config. None of
|
|
11
|
+
* those are places a mailbox hostname, username and sealed password belong.
|
|
12
|
+
*
|
|
13
|
+
* So: a plugin-local JSON file, written 0600, gitignored, and never exposed
|
|
14
|
+
* through any generic data route. `_lib/dataStore` would do the atomic write
|
|
15
|
+
* for us but writes with default permissions, and a credentials file should
|
|
16
|
+
* not exist world-readable even briefly - hence the local writer below.
|
|
17
|
+
*
|
|
18
|
+
* ## Ownership
|
|
19
|
+
*
|
|
20
|
+
* A user may hold several mailboxes. Each record carries its own `id` and the
|
|
21
|
+
* `userId` that owns it, and **every** lookup here takes the user id as well
|
|
22
|
+
* as the account id. There is no function that resolves an account by id
|
|
23
|
+
* alone, so a route cannot accidentally hand one user another's mailbox.
|
|
24
|
+
*
|
|
25
|
+
* @module _lib/mail/accounts
|
|
26
|
+
*/
|
|
27
|
+
import {randomUUID} from 'node:crypto';
|
|
28
|
+
import fs from 'node:fs/promises';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import {fileURLToPath} from 'node:url';
|
|
31
|
+
|
|
32
|
+
import {normaliseIdentity, normaliseIdentityList, findIdentity} from '../admin/mail/identity.js';
|
|
33
|
+
import {normaliseRules} from '../admin/mail/rules.js';
|
|
34
|
+
import {normaliseUndoSeconds} from '../admin/mail/scheduling.js';
|
|
35
|
+
import {normaliseVacation} from '../admin/mail/vacation.js';
|
|
36
|
+
import {normaliseTemplates} from '../admin/mail/templates.js';
|
|
37
|
+
import {sanitiseSignature} from './render.js';
|
|
38
|
+
import {open, seal} from './secretbox.js';
|
|
39
|
+
|
|
40
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Where mail state lives. Overridable so tests can point at a temp directory:
|
|
44
|
+
* without that they would have to operate on the real store, and a test that
|
|
45
|
+
* cleans up after itself would delete live mailbox credentials.
|
|
46
|
+
*/
|
|
47
|
+
const DATA_DIR = process.env.DOMMA_MAIL_DATA_DIR || path.join(__dirname, '..', 'data');
|
|
48
|
+
// `plugins/<name>/data` is the ONLY path bin/update.js preserves across an
|
|
49
|
+
// engine update - it deletes and replaces the rest of the directory. A
|
|
50
|
+
// nested `_lib/mail/data` would be wiped on every upgrade, taking every
|
|
51
|
+
// saved mailbox with it.
|
|
52
|
+
const STORE_FILE = path.join(DATA_DIR, 'mail-accounts.json');
|
|
53
|
+
|
|
54
|
+
/** Where the store lived while this code belonged to the mail-reader plugin. */
|
|
55
|
+
const LEGACY_STORE_FILE = path.join(__dirname, '..', '..', 'mail-reader', 'data', 'accounts.json');
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Bring a stored record up to the current shape.
|
|
59
|
+
*
|
|
60
|
+
* The first release kept one account per user, keyed by the user id itself.
|
|
61
|
+
* Those records have no `userId` and their `id` is the owner - carry them
|
|
62
|
+
* forward rather than stranding anyone's saved mailbox.
|
|
63
|
+
*
|
|
64
|
+
* @param {object} record
|
|
65
|
+
* @returns {object}
|
|
66
|
+
*/
|
|
67
|
+
function normalise(record) {
|
|
68
|
+
if (record.userId) return record;
|
|
69
|
+
return {...record, userId: record.id, id: randomUUID()};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Take over the store from the old mail-reader-owned location, once.
|
|
74
|
+
*
|
|
75
|
+
* Returns the records so the caller can carry on, or null when there is
|
|
76
|
+
* nothing to adopt. The old file is left where it is: an engine update
|
|
77
|
+
* preserves it, and leaving it costs nothing while deleting it would throw
|
|
78
|
+
* away the only copy if this went wrong.
|
|
79
|
+
*
|
|
80
|
+
* @returns {Promise<object[]|null>}
|
|
81
|
+
*/
|
|
82
|
+
async function adoptLegacyStore() {
|
|
83
|
+
let legacy;
|
|
84
|
+
try {
|
|
85
|
+
legacy = JSON.parse(await fs.readFile(LEGACY_STORE_FILE, 'utf8'));
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
if (!Array.isArray(legacy) || !legacy.length) return null;
|
|
90
|
+
|
|
91
|
+
const records = legacy.map(normalise);
|
|
92
|
+
await writeAll(records);
|
|
93
|
+
return records;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Read every stored account, migrating any legacy records as a side effect.
|
|
98
|
+
*
|
|
99
|
+
* The migration is WRITTEN BACK, not just applied in memory. Normalising on
|
|
100
|
+
* every read without persisting would hand a legacy record a fresh `id` each
|
|
101
|
+
* time it was read, so it could never be selected twice or deleted at all -
|
|
102
|
+
* an upgraded user would be left with a ghost mailbox they could not remove.
|
|
103
|
+
*
|
|
104
|
+
* @returns {Promise<object[]>}
|
|
105
|
+
*/
|
|
106
|
+
async function readAll() {
|
|
107
|
+
let parsed;
|
|
108
|
+
try {
|
|
109
|
+
parsed = JSON.parse(await fs.readFile(STORE_FILE, 'utf8'));
|
|
110
|
+
} catch {
|
|
111
|
+
parsed = await adoptLegacyStore();
|
|
112
|
+
if (!parsed) return [];
|
|
113
|
+
}
|
|
114
|
+
if (!Array.isArray(parsed)) return [];
|
|
115
|
+
|
|
116
|
+
const normalised = parsed.map(normalise);
|
|
117
|
+
// normalise() returns the same reference when nothing changed.
|
|
118
|
+
if (normalised.some((record, i) => record !== parsed[i])) {
|
|
119
|
+
await writeAll(normalised);
|
|
120
|
+
}
|
|
121
|
+
return normalised;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Atomically write the store with owner-only permissions.
|
|
126
|
+
*
|
|
127
|
+
* The temp file is created 0600 rather than chmod-ed after the fact, so the
|
|
128
|
+
* sealed passwords are never briefly readable by other local users.
|
|
129
|
+
*
|
|
130
|
+
* @param {object[]} accounts
|
|
131
|
+
* @returns {Promise<void>}
|
|
132
|
+
*/
|
|
133
|
+
async function writeAll(accounts) {
|
|
134
|
+
await fs.mkdir(path.dirname(STORE_FILE), {recursive: true});
|
|
135
|
+
const tmp = STORE_FILE + '.tmp';
|
|
136
|
+
await fs.writeFile(tmp, JSON.stringify(accounts, null, 2) + '\n', {encoding: 'utf8', mode: 0o600});
|
|
137
|
+
await fs.rename(tmp, STORE_FILE);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Strip everything the browser must never see.
|
|
142
|
+
*
|
|
143
|
+
* @param {object|null} account
|
|
144
|
+
* @returns {object|null}
|
|
145
|
+
*/
|
|
146
|
+
export function toPublic(account) {
|
|
147
|
+
if (!account) return null;
|
|
148
|
+
const {passwordSealed, userId, smtp, ...rest} = account;
|
|
149
|
+
return {
|
|
150
|
+
...rest,
|
|
151
|
+
// Pulled out and rebuilt rather than spread through: `smtp` carries a
|
|
152
|
+
// sealed password of its own, and a top-level destructure does not
|
|
153
|
+
// reach it. Spreading the record verbatim shipped that ciphertext to
|
|
154
|
+
// every browser that listed its mailboxes.
|
|
155
|
+
...(smtp ? {smtp: smtpToPublic(account)} : {}),
|
|
156
|
+
hasPassword: typeof passwordSealed === 'string' && passwordSealed.length > 0
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Store the outgoing server for a mailbox.
|
|
162
|
+
*
|
|
163
|
+
* Kept beside the IMAP settings and sealed the same way. A blank password
|
|
164
|
+
* means "use the IMAP one", which is right far more often than not - most
|
|
165
|
+
* providers use one credential for both - and saves asking for the same
|
|
166
|
+
* secret twice.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} userId
|
|
169
|
+
* @param {string} accountId
|
|
170
|
+
* @param {{host: string, port: number, secure: boolean, username: string,
|
|
171
|
+
* password: string, from: string, sentFolder: string|null}} smtp
|
|
172
|
+
* @returns {Promise<object|null>}
|
|
173
|
+
*/
|
|
174
|
+
export async function setSmtp(userId, accountId, smtp) {
|
|
175
|
+
const all = await readAll();
|
|
176
|
+
const index = all.findIndex(a => a.id === accountId && a.userId === userId);
|
|
177
|
+
if (index === -1) return null;
|
|
178
|
+
|
|
179
|
+
const existing = all[index].smtp ?? {};
|
|
180
|
+
all[index] = {
|
|
181
|
+
...all[index],
|
|
182
|
+
smtp: {
|
|
183
|
+
host: smtp.host,
|
|
184
|
+
port: smtp.port,
|
|
185
|
+
secure: smtp.secure,
|
|
186
|
+
username: smtp.username,
|
|
187
|
+
passwordSealed: smtp.password
|
|
188
|
+
? seal(smtp.password)
|
|
189
|
+
: (existing.passwordSealed ?? null),
|
|
190
|
+
from: smtp.from,
|
|
191
|
+
sentFolder: smtp.sentFolder ?? null
|
|
192
|
+
},
|
|
193
|
+
updatedAt: new Date().toISOString()
|
|
194
|
+
};
|
|
195
|
+
await writeAll(all);
|
|
196
|
+
return all[index];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Build a nodemailer transport config for a mailbox.
|
|
201
|
+
*
|
|
202
|
+
* Falls back to the IMAP credentials when the outgoing server has none of its
|
|
203
|
+
* own, which is the common case.
|
|
204
|
+
*
|
|
205
|
+
* `identityId` chooses which of the mailbox's identities is sending. Absent,
|
|
206
|
+
* it is the default one - which is what every message sent before aliases
|
|
207
|
+
* existed did, and what most messages still do.
|
|
208
|
+
*
|
|
209
|
+
* @param {object} account
|
|
210
|
+
* @param {string|null} [identityId]
|
|
211
|
+
* @returns {{transport: object, from: string}|null}
|
|
212
|
+
*/
|
|
213
|
+
export function toSmtpConnection(account, identityId = null) {
|
|
214
|
+
const smtp = account?.smtp;
|
|
215
|
+
if (!smtp?.host) return null;
|
|
216
|
+
|
|
217
|
+
const pass = smtp.passwordSealed
|
|
218
|
+
? open(smtp.passwordSealed)
|
|
219
|
+
: open(account.passwordSealed);
|
|
220
|
+
|
|
221
|
+
const identity = findIdentity(normaliseIdentityList(account), identityId);
|
|
222
|
+
const mailboxAddress = smtp.from || account.username;
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
transport: {
|
|
226
|
+
host: smtp.host,
|
|
227
|
+
port: smtp.port,
|
|
228
|
+
secure: smtp.secure !== false,
|
|
229
|
+
auth: {user: smtp.username || account.username, pass}
|
|
230
|
+
},
|
|
231
|
+
// Still the bare address, as it always was. The display name travels
|
|
232
|
+
// beside it rather than folded into it, because the two go to
|
|
233
|
+
// different places: the name belongs in the From header and must stay
|
|
234
|
+
// out of the SMTP envelope.
|
|
235
|
+
//
|
|
236
|
+
// An alias replaces it outright: sending AS sales@ means the From and
|
|
237
|
+
// the envelope both say sales@, or the alias fails DMARC alignment
|
|
238
|
+
// and lands in spam at a good many receivers.
|
|
239
|
+
from: identity?.address || mailboxAddress,
|
|
240
|
+
fromName: identity?.displayName ?? '',
|
|
241
|
+
replyTo: identity?.replyTo ?? '',
|
|
242
|
+
identity: identity ?? null,
|
|
243
|
+
sentFolder: smtp.sentFolder ?? null
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The outgoing settings, without the password.
|
|
249
|
+
*
|
|
250
|
+
* @param {object} account
|
|
251
|
+
* @returns {object|null}
|
|
252
|
+
*/
|
|
253
|
+
export function smtpToPublic(account) {
|
|
254
|
+
const smtp = account?.smtp;
|
|
255
|
+
if (!smtp) return null;
|
|
256
|
+
const {passwordSealed, ...rest} = smtp;
|
|
257
|
+
return {...rest, hasPassword: typeof passwordSealed === 'string' && passwordSealed.length > 0};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* What this mailbox signs its mail with.
|
|
262
|
+
*
|
|
263
|
+
* Always an object, never null: a mailbox with nothing configured has an
|
|
264
|
+
* empty identity rather than an absent one, so the settings form and the send
|
|
265
|
+
* path both have the same shape to read and neither needs a null branch.
|
|
266
|
+
*
|
|
267
|
+
* @param {object|null} account
|
|
268
|
+
* @returns {object}
|
|
269
|
+
*/
|
|
270
|
+
export function identityToPublic(account) {
|
|
271
|
+
return normaliseIdentity(account?.identity);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Every identity this mailbox can send as.
|
|
276
|
+
*
|
|
277
|
+
* Always a list, possibly empty. The migration from the single-identity shape
|
|
278
|
+
* lives in `normaliseIdentityList`, so a mailbox configured before aliases
|
|
279
|
+
* existed answers a one-entry list here without anything being rewritten on
|
|
280
|
+
* disk - the rewrite happens the first time the list is saved, which is the
|
|
281
|
+
* only moment it is safe to assume the new shape is what was meant.
|
|
282
|
+
*
|
|
283
|
+
* @param {object|null} account
|
|
284
|
+
* @returns {object[]}
|
|
285
|
+
*/
|
|
286
|
+
export function identitiesToPublic(account) {
|
|
287
|
+
return normaliseIdentityList(account);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Store the identities for a mailbox.
|
|
292
|
+
*
|
|
293
|
+
* A full replace, not a merge - which is what PUT means, and what makes
|
|
294
|
+
* deleting one possible at all. Each signature is sanitised HERE rather than
|
|
295
|
+
* in the route, for the reason `setIdentity` gives: this markup is handed
|
|
296
|
+
* back to an editor that sets it as innerHTML and mailed to third parties, so
|
|
297
|
+
* the guarantee has to hold for the next caller too.
|
|
298
|
+
*
|
|
299
|
+
* @param {string} userId
|
|
300
|
+
* @param {string} accountId
|
|
301
|
+
* @param {object[]} identities
|
|
302
|
+
* @returns {Promise<object|null>}
|
|
303
|
+
*/
|
|
304
|
+
export async function setIdentities(userId, accountId, identities) {
|
|
305
|
+
const all = await readAll();
|
|
306
|
+
const index = all.findIndex(a => a.id === accountId && a.userId === userId);
|
|
307
|
+
if (index === -1) return null;
|
|
308
|
+
|
|
309
|
+
const cleaned = (Array.isArray(identities) ? identities : []).map(entry => ({
|
|
310
|
+
...entry,
|
|
311
|
+
signatureHtml: sanitiseSignature(entry?.signatureHtml ?? '')
|
|
312
|
+
}));
|
|
313
|
+
|
|
314
|
+
const {identity, ...rest} = all[index];
|
|
315
|
+
all[index] = {
|
|
316
|
+
...rest,
|
|
317
|
+
identities: normaliseIdentityList({identities: cleaned}),
|
|
318
|
+
updatedAt: new Date().toISOString()
|
|
319
|
+
};
|
|
320
|
+
await writeAll(all);
|
|
321
|
+
return all[index];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* The canned responses this mailbox holds.
|
|
326
|
+
*
|
|
327
|
+
* @param {object|null} account
|
|
328
|
+
* @returns {object[]}
|
|
329
|
+
*/
|
|
330
|
+
export function templatesToPublic(account) {
|
|
331
|
+
return normaliseTemplates(account);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Store the canned responses for a mailbox.
|
|
336
|
+
*
|
|
337
|
+
* Sanitised on the way in, exactly as signatures are, and for exactly the
|
|
338
|
+
* same reasons.
|
|
339
|
+
*
|
|
340
|
+
* @param {string} userId
|
|
341
|
+
* @param {string} accountId
|
|
342
|
+
* @param {object[]} templates
|
|
343
|
+
* @returns {Promise<object|null>}
|
|
344
|
+
*/
|
|
345
|
+
export async function setTemplates(userId, accountId, templates) {
|
|
346
|
+
const all = await readAll();
|
|
347
|
+
const index = all.findIndex(a => a.id === accountId && a.userId === userId);
|
|
348
|
+
if (index === -1) return null;
|
|
349
|
+
|
|
350
|
+
const cleaned = (Array.isArray(templates) ? templates : []).map(entry => ({
|
|
351
|
+
...entry,
|
|
352
|
+
bodyHtml: sanitiseSignature(entry?.bodyHtml ?? '')
|
|
353
|
+
}));
|
|
354
|
+
|
|
355
|
+
all[index] = {
|
|
356
|
+
...all[index],
|
|
357
|
+
templates: normaliseTemplates({templates: cleaned}),
|
|
358
|
+
updatedAt: new Date().toISOString()
|
|
359
|
+
};
|
|
360
|
+
await writeAll(all);
|
|
361
|
+
return all[index];
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* The filing rules this mailbox runs, in order.
|
|
366
|
+
*
|
|
367
|
+
* @param {object|null} account
|
|
368
|
+
* @returns {object[]}
|
|
369
|
+
*/
|
|
370
|
+
export function rulesToPublic(account) {
|
|
371
|
+
return normaliseRules(account);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Store the filing rules for a mailbox.
|
|
376
|
+
*
|
|
377
|
+
* `rulesFrom` is stamped the first time rules are saved and never moved
|
|
378
|
+
* afterwards. It is what stops a newly written rule reaching back through a
|
|
379
|
+
* decade of archive on the next sync: rules apply to mail that arrives from
|
|
380
|
+
* now on, which is what every other mail client means by a rule, and what
|
|
381
|
+
* someone writing their first one expects.
|
|
382
|
+
*
|
|
383
|
+
* @param {string} userId
|
|
384
|
+
* @param {string} accountId
|
|
385
|
+
* @param {object[]} rules
|
|
386
|
+
* @returns {Promise<object|null>}
|
|
387
|
+
*/
|
|
388
|
+
export async function setRules(userId, accountId, rules) {
|
|
389
|
+
const all = await readAll();
|
|
390
|
+
const index = all.findIndex(a => a.id === accountId && a.userId === userId);
|
|
391
|
+
if (index === -1) return null;
|
|
392
|
+
|
|
393
|
+
all[index] = {
|
|
394
|
+
...all[index],
|
|
395
|
+
rules: normaliseRules({rules}),
|
|
396
|
+
rulesFrom: all[index].rulesFrom ?? new Date().toISOString(),
|
|
397
|
+
updatedAt: new Date().toISOString()
|
|
398
|
+
};
|
|
399
|
+
await writeAll(all);
|
|
400
|
+
return all[index];
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Which folders rules are applied to.
|
|
405
|
+
*
|
|
406
|
+
* The inbox alone by default. A rule that also ran over Sent would file your
|
|
407
|
+
* own replies, and one that ran over Archive would undo the archiving.
|
|
408
|
+
*
|
|
409
|
+
* @param {object|null} account
|
|
410
|
+
* @returns {string[]}
|
|
411
|
+
*/
|
|
412
|
+
export function ruleFolders(account) {
|
|
413
|
+
const chosen = Array.isArray(account?.ruleFolders) ? account.ruleFolders : null;
|
|
414
|
+
return (chosen && chosen.length) ? chosen : ['INBOX'];
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* How long this mailbox holds a message after Send, in seconds.
|
|
419
|
+
*
|
|
420
|
+
* @param {object|null} account
|
|
421
|
+
* @returns {number}
|
|
422
|
+
*/
|
|
423
|
+
export function undoSendSeconds(account) {
|
|
424
|
+
return normaliseUndoSeconds(account?.undoSendSeconds);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Set the sending behaviour that is neither the server nor the identity:
|
|
429
|
+
* how long Send can be taken back, and which folder snoozed mail waits in.
|
|
430
|
+
*
|
|
431
|
+
* @param {string} userId
|
|
432
|
+
* @param {string} accountId
|
|
433
|
+
* @param {{undoSendSeconds?: number, snoozeFolder?: string, ruleFolders?: string[]}} options
|
|
434
|
+
* @returns {Promise<object|null>}
|
|
435
|
+
*/
|
|
436
|
+
export async function setSendingOptions(userId, accountId, options) {
|
|
437
|
+
const all = await readAll();
|
|
438
|
+
const index = all.findIndex(a => a.id === accountId && a.userId === userId);
|
|
439
|
+
if (index === -1) return null;
|
|
440
|
+
|
|
441
|
+
const folders = Array.isArray(options?.ruleFolders)
|
|
442
|
+
? [...new Set(options.ruleFolders.filter(f => typeof f === 'string' && f.trim()))]
|
|
443
|
+
: null;
|
|
444
|
+
|
|
445
|
+
all[index] = {
|
|
446
|
+
...all[index],
|
|
447
|
+
undoSendSeconds: normaliseUndoSeconds(options?.undoSendSeconds),
|
|
448
|
+
snoozeFolder: String(options?.snoozeFolder ?? '').trim() || null,
|
|
449
|
+
...(folders ? {ruleFolders: folders} : {}),
|
|
450
|
+
updatedAt: new Date().toISOString()
|
|
451
|
+
};
|
|
452
|
+
await writeAll(all);
|
|
453
|
+
return all[index];
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Every address this mailbox sends as.
|
|
458
|
+
*
|
|
459
|
+
* The away message needs all of them for two of its refusals: never answer
|
|
460
|
+
* something this mailbox sent, and never answer something it was merely
|
|
461
|
+
* Bcc'd on. Both need to know which addresses count as "us", and a mailbox
|
|
462
|
+
* with aliases has more than one.
|
|
463
|
+
*
|
|
464
|
+
* @param {object|null} account
|
|
465
|
+
* @returns {string[]}
|
|
466
|
+
*/
|
|
467
|
+
export function sendingAddresses(account) {
|
|
468
|
+
const addresses = new Set();
|
|
469
|
+
if (account?.username) addresses.add(String(account.username).trim().toLowerCase());
|
|
470
|
+
const from = account?.smtp?.from;
|
|
471
|
+
if (from) addresses.add(String(from).replace(/^.*<|>.*$/g, '').trim().toLowerCase());
|
|
472
|
+
for (const identity of normaliseIdentityList(account)) {
|
|
473
|
+
if (identity.address) addresses.add(identity.address.toLowerCase());
|
|
474
|
+
}
|
|
475
|
+
return [...addresses].filter(Boolean);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* The away message this mailbox is configured with.
|
|
480
|
+
*
|
|
481
|
+
* @param {object|null} account
|
|
482
|
+
* @returns {object}
|
|
483
|
+
*/
|
|
484
|
+
export function vacationToPublic(account) {
|
|
485
|
+
return normaliseVacation(account);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Store the away message.
|
|
490
|
+
*
|
|
491
|
+
* The body is sanitised on the way in, exactly as a signature is: it is
|
|
492
|
+
* authored by hand, handed back to an editor that sets it as innerHTML, and
|
|
493
|
+
* mailed to strangers - which is a wider audience than a signature reaches,
|
|
494
|
+
* since it goes to everyone who writes while it is on.
|
|
495
|
+
*
|
|
496
|
+
* @param {string} userId
|
|
497
|
+
* @param {string} accountId
|
|
498
|
+
* @param {object} vacation
|
|
499
|
+
* @returns {Promise<object|null>}
|
|
500
|
+
*/
|
|
501
|
+
export async function setVacation(userId, accountId, vacation) {
|
|
502
|
+
const all = await readAll();
|
|
503
|
+
const index = all.findIndex(a => a.id === accountId && a.userId === userId);
|
|
504
|
+
if (index === -1) return null;
|
|
505
|
+
|
|
506
|
+
all[index] = {
|
|
507
|
+
...all[index],
|
|
508
|
+
vacation: normaliseVacation({
|
|
509
|
+
vacation: {
|
|
510
|
+
...vacation,
|
|
511
|
+
bodyHtml: sanitiseSignature(vacation?.bodyHtml ?? '')
|
|
512
|
+
}
|
|
513
|
+
}),
|
|
514
|
+
updatedAt: new Date().toISOString()
|
|
515
|
+
};
|
|
516
|
+
await writeAll(all);
|
|
517
|
+
return all[index];
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** The folder snoozed mail waits in, for a mailbox that has not chosen one. */
|
|
521
|
+
export const DEFAULT_SNOOZE_FOLDER = 'Snoozed';
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Where this mailbox parks snoozed mail.
|
|
525
|
+
*
|
|
526
|
+
* @param {object|null} account
|
|
527
|
+
* @returns {string}
|
|
528
|
+
*/
|
|
529
|
+
export function snoozeFolder(account) {
|
|
530
|
+
return String(account?.snoozeFolder ?? '').trim() || DEFAULT_SNOOZE_FOLDER;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Store the sending identity for a mailbox.
|
|
535
|
+
*
|
|
536
|
+
* Normalised on the way in rather than on the way out. The signature HTML is
|
|
537
|
+
* embedded in outgoing mail and handed back to the settings editor, so the
|
|
538
|
+
* copy on disk should already be the safe, known-shaped one - sanitising at
|
|
539
|
+
* read time leaves the raw thing sitting in the store waiting for the next
|
|
540
|
+
* reader that forgets.
|
|
541
|
+
*
|
|
542
|
+
* @param {string} userId
|
|
543
|
+
* @param {string} accountId
|
|
544
|
+
* @param {object} identity
|
|
545
|
+
* @returns {Promise<object|null>}
|
|
546
|
+
*/
|
|
547
|
+
export async function setIdentity(userId, accountId, identity) {
|
|
548
|
+
const all = await readAll();
|
|
549
|
+
const index = all.findIndex(a => a.id === accountId && a.userId === userId);
|
|
550
|
+
if (index === -1) return null;
|
|
551
|
+
|
|
552
|
+
all[index] = {
|
|
553
|
+
...all[index],
|
|
554
|
+
// Sanitised HERE, not in the route that happens to be the only caller
|
|
555
|
+
// today. This markup is handed back to an editor that sets it as
|
|
556
|
+
// innerHTML and mailed to third parties, so the guarantee has to hold
|
|
557
|
+
// for the next caller too - an import, a fleet tool, a second route -
|
|
558
|
+
// none of which would think to reach for the sanitiser themselves.
|
|
559
|
+
identity: normaliseIdentity({
|
|
560
|
+
...identity,
|
|
561
|
+
signatureHtml: sanitiseSignature(identity?.signatureHtml ?? '')
|
|
562
|
+
}),
|
|
563
|
+
updatedAt: new Date().toISOString()
|
|
564
|
+
};
|
|
565
|
+
await writeAll(all);
|
|
566
|
+
return all[index];
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Trust a sender's remote images from now on.
|
|
571
|
+
*
|
|
572
|
+
* The per-message "Load images" button is deliberately not persisted - it is a
|
|
573
|
+
* decision about one message. This is the other thing people want: a sender
|
|
574
|
+
* whose images are always fine, chosen explicitly, listed in settings and
|
|
575
|
+
* revocable. That is a different act from silently remembering a click, which
|
|
576
|
+
* is why it is a separate button and a visible list rather than a side effect.
|
|
577
|
+
*
|
|
578
|
+
* Matching is on the address, lower-cased. Display names are trivially forged
|
|
579
|
+
* and are not part of the decision.
|
|
580
|
+
*
|
|
581
|
+
* @param {string} userId
|
|
582
|
+
* @param {string} accountId
|
|
583
|
+
* @param {string} address
|
|
584
|
+
* @returns {Promise<object|null>}
|
|
585
|
+
*/
|
|
586
|
+
export async function trustImageSender(userId, accountId, address) {
|
|
587
|
+
const clean = String(address ?? '').trim().toLowerCase();
|
|
588
|
+
if (!clean) return null;
|
|
589
|
+
|
|
590
|
+
const all = await readAll();
|
|
591
|
+
const index = all.findIndex(a => a.id === accountId && a.userId === userId);
|
|
592
|
+
if (index === -1) return null;
|
|
593
|
+
|
|
594
|
+
const current = Array.isArray(all[index].imageSenders) ? all[index].imageSenders : [];
|
|
595
|
+
if (current.includes(clean)) return all[index];
|
|
596
|
+
|
|
597
|
+
all[index] = {...all[index], imageSenders: [...current, clean], updatedAt: new Date().toISOString()};
|
|
598
|
+
await writeAll(all);
|
|
599
|
+
return all[index];
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Stop trusting a sender's images.
|
|
604
|
+
*
|
|
605
|
+
* @param {string} userId
|
|
606
|
+
* @param {string} accountId
|
|
607
|
+
* @param {string} address
|
|
608
|
+
* @returns {Promise<object|null>}
|
|
609
|
+
*/
|
|
610
|
+
export async function untrustImageSender(userId, accountId, address) {
|
|
611
|
+
const clean = String(address ?? '').trim().toLowerCase();
|
|
612
|
+
const all = await readAll();
|
|
613
|
+
const index = all.findIndex(a => a.id === accountId && a.userId === userId);
|
|
614
|
+
if (index === -1) return null;
|
|
615
|
+
|
|
616
|
+
const current = Array.isArray(all[index].imageSenders) ? all[index].imageSenders : [];
|
|
617
|
+
all[index] = {
|
|
618
|
+
...all[index],
|
|
619
|
+
imageSenders: current.filter(a => a !== clean),
|
|
620
|
+
updatedAt: new Date().toISOString()
|
|
621
|
+
};
|
|
622
|
+
await writeAll(all);
|
|
623
|
+
return all[index];
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Turn the whole remembering feature on or off for a mailbox.
|
|
628
|
+
*
|
|
629
|
+
* Off means the button is never offered and any existing list is ignored
|
|
630
|
+
* without being thrown away - turning it back on should not mean rebuilding
|
|
631
|
+
* a list someone curated.
|
|
632
|
+
*
|
|
633
|
+
* @param {string} userId
|
|
634
|
+
* @param {string} accountId
|
|
635
|
+
* @param {boolean} enabled
|
|
636
|
+
* @returns {Promise<object|null>}
|
|
637
|
+
*/
|
|
638
|
+
export async function setRememberImageSenders(userId, accountId, enabled) {
|
|
639
|
+
const all = await readAll();
|
|
640
|
+
const index = all.findIndex(a => a.id === accountId && a.userId === userId);
|
|
641
|
+
if (index === -1) return null;
|
|
642
|
+
|
|
643
|
+
all[index] = {...all[index], rememberImageSenders: !!enabled, updatedAt: new Date().toISOString()};
|
|
644
|
+
await writeAll(all);
|
|
645
|
+
return all[index];
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Should this message's remote images load without being asked?
|
|
650
|
+
*
|
|
651
|
+
* @param {object} account
|
|
652
|
+
* @param {Array<{address?: string}>} from
|
|
653
|
+
* @returns {boolean}
|
|
654
|
+
*/
|
|
655
|
+
export function sendersImagesTrusted(account, from) {
|
|
656
|
+
if (account?.rememberImageSenders === false) return false;
|
|
657
|
+
const trusted = Array.isArray(account?.imageSenders) ? account.imageSenders : [];
|
|
658
|
+
if (!trusted.length) return false;
|
|
659
|
+
return (from ?? []).some(entry => trusted.includes(String(entry?.address ?? '').trim().toLowerCase()));
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Poll intervals a mailbox may be set to, in minutes.
|
|
664
|
+
*
|
|
665
|
+
* A short list on purpose. A free-text field invites someone to type 1 and
|
|
666
|
+
* spend the day being rate-limited by their mail server, and the difference
|
|
667
|
+
* between 11 and 12 minutes is not worth a decision.
|
|
668
|
+
*/
|
|
669
|
+
export const POLL_INTERVALS = [3, 5, 10, 15];
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Set how often a mailbox is polled.
|
|
673
|
+
*
|
|
674
|
+
* `null` means "inherit the plugin default" rather than "never" - there is no
|
|
675
|
+
* off switch here, because a mailbox that is mirrored but never refreshed is
|
|
676
|
+
* worse than one that is not mirrored at all.
|
|
677
|
+
*
|
|
678
|
+
* @param {string} userId
|
|
679
|
+
* @param {string} accountId
|
|
680
|
+
* @param {number|null} minutes
|
|
681
|
+
* @returns {Promise<{ok: true, account: object} | {ok: false, error: string}>}
|
|
682
|
+
*/
|
|
683
|
+
export async function setPollInterval(userId, accountId, minutes) {
|
|
684
|
+
if (minutes !== null && !POLL_INTERVALS.includes(minutes)) {
|
|
685
|
+
return {ok: false, error: `Poll interval must be one of ${POLL_INTERVALS.join(', ')} minutes.`};
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const all = await readAll();
|
|
689
|
+
const index = all.findIndex(a => a.id === accountId && a.userId === userId);
|
|
690
|
+
if (index === -1) return {ok: false, error: 'That mailbox does not exist.'};
|
|
691
|
+
|
|
692
|
+
all[index] = {...all[index], pollIntervalMinutes: minutes, updatedAt: new Date().toISOString()};
|
|
693
|
+
await writeAll(all);
|
|
694
|
+
return {ok: true, account: all[index]};
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Every stored mailbox, for every user.
|
|
699
|
+
*
|
|
700
|
+
* Only the poller needs this - it runs on a timer with no request and so no
|
|
701
|
+
* user to scope by. Nothing that serves a request should call it.
|
|
702
|
+
*
|
|
703
|
+
* @returns {Promise<object[]>}
|
|
704
|
+
*/
|
|
705
|
+
export async function listAllAccounts() {
|
|
706
|
+
return readAll();
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Choose which folders to keep mirrored.
|
|
711
|
+
*
|
|
712
|
+
* `null` means "the sensible default" rather than "none": a mailbox that has
|
|
713
|
+
* never been configured should still sync its inbox. Only the Pro edition
|
|
714
|
+
* writes this - the free reader fetches on demand and ignores it entirely.
|
|
715
|
+
*
|
|
716
|
+
* @param {string} userId
|
|
717
|
+
* @param {string} accountId
|
|
718
|
+
* @param {string[]|null} folders - explicit paths, or null to go back to the default
|
|
719
|
+
* @returns {Promise<object|null>} the stored record, or null if not theirs
|
|
720
|
+
*/
|
|
721
|
+
export async function setSyncFolders(userId, accountId, folders) {
|
|
722
|
+
const all = await readAll();
|
|
723
|
+
const index = all.findIndex(a => a.id === accountId && a.userId === userId);
|
|
724
|
+
if (index === -1) return null;
|
|
725
|
+
|
|
726
|
+
const value = folders === null
|
|
727
|
+
? null
|
|
728
|
+
: [...new Set(folders.filter(f => typeof f === 'string' && f.trim()))];
|
|
729
|
+
|
|
730
|
+
all[index] = {...all[index], syncFolders: value, updatedAt: new Date().toISOString()};
|
|
731
|
+
await writeAll(all);
|
|
732
|
+
return all[index];
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Validate and normalise submitted account fields.
|
|
737
|
+
*
|
|
738
|
+
* @param {object} input
|
|
739
|
+
* @param {object|null} existing
|
|
740
|
+
* @returns {{ok: true, value: object} | {ok: false, error: string}}
|
|
741
|
+
*/
|
|
742
|
+
export function validate(input, existing) {
|
|
743
|
+
const host = String(input.host ?? '').trim();
|
|
744
|
+
const username = String(input.username ?? '').trim();
|
|
745
|
+
const label = String(input.label ?? '').trim();
|
|
746
|
+
const port = Number(input.port ?? (input.secure === false ? 143 : 993));
|
|
747
|
+
const secure = input.secure !== false;
|
|
748
|
+
const password = typeof input.password === 'string' ? input.password : '';
|
|
749
|
+
|
|
750
|
+
if (!host) return {ok: false, error: 'A mail server hostname is required.'};
|
|
751
|
+
if (!username) return {ok: false, error: 'A username is required.'};
|
|
752
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
753
|
+
return {ok: false, error: 'Port must be a whole number between 1 and 65535.'};
|
|
754
|
+
}
|
|
755
|
+
if (!password && !existing?.passwordSealed) {
|
|
756
|
+
return {ok: false, error: 'A password is required.'};
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
return {
|
|
760
|
+
ok: true,
|
|
761
|
+
value: {
|
|
762
|
+
label: label || host,
|
|
763
|
+
host,
|
|
764
|
+
port,
|
|
765
|
+
secure,
|
|
766
|
+
username,
|
|
767
|
+
// An empty password on update means "keep the one you have" - the
|
|
768
|
+
// admin form never round-trips the stored password, so a blank
|
|
769
|
+
// field is the normal case when editing the hostname.
|
|
770
|
+
password
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Every mailbox belonging to one user, oldest first.
|
|
777
|
+
*
|
|
778
|
+
* @param {string} userId
|
|
779
|
+
* @returns {Promise<object[]>}
|
|
780
|
+
*/
|
|
781
|
+
export async function listAccounts(userId) {
|
|
782
|
+
const all = await readAll();
|
|
783
|
+
return all
|
|
784
|
+
.filter(a => a.userId === userId)
|
|
785
|
+
.sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* One mailbox, but only if this user owns it.
|
|
790
|
+
*
|
|
791
|
+
* Takes both ids on purpose: there is no way to ask for an account without
|
|
792
|
+
* saying who is asking.
|
|
793
|
+
*
|
|
794
|
+
* @param {string} userId
|
|
795
|
+
* @param {string} accountId
|
|
796
|
+
* @returns {Promise<object|null>}
|
|
797
|
+
*/
|
|
798
|
+
export async function getAccount(userId, accountId) {
|
|
799
|
+
const all = await readAll();
|
|
800
|
+
return all.find(a => a.id === accountId && a.userId === userId) ?? null;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Create a mailbox, or update one this user owns.
|
|
805
|
+
*
|
|
806
|
+
* @param {string} userId
|
|
807
|
+
* @param {string|null} accountId - null to create
|
|
808
|
+
* @param {object} fields - output of validate()
|
|
809
|
+
* @returns {Promise<object|null>} the stored record, or null if not theirs
|
|
810
|
+
*/
|
|
811
|
+
export async function saveAccount(userId, accountId, fields) {
|
|
812
|
+
const all = await readAll();
|
|
813
|
+
const now = new Date().toISOString();
|
|
814
|
+
|
|
815
|
+
if (accountId) {
|
|
816
|
+
const index = all.findIndex(a => a.id === accountId && a.userId === userId);
|
|
817
|
+
if (index === -1) return null;
|
|
818
|
+
const existing = all[index];
|
|
819
|
+
all[index] = {
|
|
820
|
+
...existing,
|
|
821
|
+
label: fields.label,
|
|
822
|
+
host: fields.host,
|
|
823
|
+
port: fields.port,
|
|
824
|
+
secure: fields.secure,
|
|
825
|
+
username: fields.username,
|
|
826
|
+
passwordSealed: fields.password ? seal(fields.password) : existing.passwordSealed,
|
|
827
|
+
updatedAt: now
|
|
828
|
+
};
|
|
829
|
+
await writeAll(all);
|
|
830
|
+
return all[index];
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const record = {
|
|
834
|
+
id: randomUUID(),
|
|
835
|
+
userId,
|
|
836
|
+
label: fields.label,
|
|
837
|
+
host: fields.host,
|
|
838
|
+
port: fields.port,
|
|
839
|
+
secure: fields.secure,
|
|
840
|
+
username: fields.username,
|
|
841
|
+
passwordSealed: seal(fields.password),
|
|
842
|
+
createdAt: now,
|
|
843
|
+
updatedAt: now
|
|
844
|
+
};
|
|
845
|
+
all.push(record);
|
|
846
|
+
await writeAll(all);
|
|
847
|
+
return record;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Remove a mailbox this user owns.
|
|
852
|
+
*
|
|
853
|
+
* @param {string} userId
|
|
854
|
+
* @param {string} accountId
|
|
855
|
+
* @returns {Promise<boolean>} true when something was removed
|
|
856
|
+
*/
|
|
857
|
+
export async function deleteAccount(userId, accountId) {
|
|
858
|
+
const all = await readAll();
|
|
859
|
+
const next = all.filter(a => !(a.id === accountId && a.userId === userId));
|
|
860
|
+
if (next.length === all.length) return false;
|
|
861
|
+
await writeAll(next);
|
|
862
|
+
return true;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Forget every mailbox belonging to one user.
|
|
867
|
+
*
|
|
868
|
+
* Called when a CMS user is deleted. Their stored passwords have no owner
|
|
869
|
+
* left and must not outlive the account.
|
|
870
|
+
*
|
|
871
|
+
* @param {string} userId
|
|
872
|
+
* @returns {Promise<number>} how many were removed
|
|
873
|
+
*/
|
|
874
|
+
export async function deleteAccountsForUser(userId) {
|
|
875
|
+
const all = await readAll();
|
|
876
|
+
const next = all.filter(a => a.userId !== userId);
|
|
877
|
+
const removed = all.length - next.length;
|
|
878
|
+
if (removed) await writeAll(next);
|
|
879
|
+
return removed;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Drop mailboxes whose owner no longer exists.
|
|
884
|
+
*
|
|
885
|
+
* The `user:deleted` hook covers the normal case, but only while a plugin
|
|
886
|
+
* using this core is loaded to hear it - a user deleted while the Mail Reader
|
|
887
|
+
* was disabled would otherwise leave credentials behind for good. This sweeps
|
|
888
|
+
* those up on the next start, so the hook is the fast path rather than the
|
|
889
|
+
* only one.
|
|
890
|
+
*
|
|
891
|
+
* @param {Set<string>|string[]} existingUserIds
|
|
892
|
+
* @returns {Promise<number>} how many were removed
|
|
893
|
+
*/
|
|
894
|
+
export async function pruneOrphans(existingUserIds) {
|
|
895
|
+
const live = existingUserIds instanceof Set ? existingUserIds : new Set(existingUserIds);
|
|
896
|
+
const all = await readAll();
|
|
897
|
+
const next = all.filter(a => live.has(a.userId));
|
|
898
|
+
const removed = all.length - next.length;
|
|
899
|
+
if (removed) await writeAll(next);
|
|
900
|
+
return removed;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Build an ImapFlow-shaped connection config.
|
|
905
|
+
*
|
|
906
|
+
* Throws MailKeyError (from secretbox) if the stored password cannot be
|
|
907
|
+
* opened - callers surface that message directly, it is written for humans.
|
|
908
|
+
*
|
|
909
|
+
* @param {object} account
|
|
910
|
+
* @returns {{host: string, port: number, secure: boolean, auth: {user: string, pass: string}}}
|
|
911
|
+
*/
|
|
912
|
+
export function toConnection(account) {
|
|
913
|
+
return {
|
|
914
|
+
host: account.host,
|
|
915
|
+
port: account.port,
|
|
916
|
+
secure: account.secure,
|
|
917
|
+
auth: {user: account.username, pass: open(account.passwordSealed)}
|
|
918
|
+
};
|
|
919
|
+
}
|