domma-cms 0.55.1 → 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.
Files changed (211) hide show
  1. package/CLAUDE.md +130 -2
  2. package/README.md +4 -4
  3. package/admin/css/admin.css +1 -1
  4. package/admin/dist/domma/domma-tools.css +3 -3
  5. package/admin/dist/domma/domma-tools.min.js +3 -3
  6. package/admin/js/api.js +1 -1
  7. package/admin/js/app.js +3 -3
  8. package/admin/js/lib/page-picker.js +1 -0
  9. package/admin/js/lib/plugin-accent.js +1 -0
  10. package/admin/js/lib/plugin-chrome.js +1 -0
  11. package/admin/js/lib/shortcode-context-menu.js +2 -2
  12. package/admin/js/lib/sidebar-grouping.js +1 -1
  13. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  14. package/admin/js/lib/sidebar-renderer.js +4 -4
  15. package/admin/js/lib/slideover-resizable.js +1 -0
  16. package/admin/js/templates/context-menu-editor.html +212 -0
  17. package/admin/js/templates/context-menus.html +16 -0
  18. package/admin/js/templates/plugin-code.html +1 -1
  19. package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
  20. package/admin/js/templates/settings.html +16 -97
  21. package/admin/js/templates/theme.html +173 -0
  22. package/admin/js/views/context-menu-editor.js +55 -0
  23. package/admin/js/views/context-menus.js +5 -0
  24. package/admin/js/views/form-editor.js +7 -7
  25. package/admin/js/views/index.js +1 -1
  26. package/admin/js/views/plugin-marketplace.js +1 -1
  27. package/admin/js/views/plugins.js +25 -23
  28. package/admin/js/views/search.js +1 -0
  29. package/admin/js/views/settings.js +3 -3
  30. package/admin/js/views/theme.js +1 -0
  31. package/bin/cli.js +2 -0
  32. package/bin/update.js +13 -2
  33. package/config/menus/admin-sidebar.json +129 -23
  34. package/config/plugins.json +5 -5
  35. package/config/search.json +13 -0
  36. package/config/theme.json +18 -0
  37. package/package.json +12 -4
  38. package/plugins/_lib/admin/mail/compose-window.js +914 -0
  39. package/plugins/_lib/admin/mail/contacts.js +301 -0
  40. package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
  41. package/plugins/_lib/admin/mail/folder-tree.js +254 -0
  42. package/plugins/_lib/admin/mail/identity.js +480 -0
  43. package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
  44. package/plugins/_lib/admin/mail/keyboard.js +136 -0
  45. package/plugins/_lib/admin/mail/mail.css +1 -0
  46. package/plugins/_lib/admin/mail/mail.html +71 -0
  47. package/plugins/_lib/admin/mail/panes.js +253 -0
  48. package/plugins/_lib/admin/mail/reader-view.js +4453 -0
  49. package/plugins/_lib/admin/mail/resizable.js +26 -0
  50. package/plugins/_lib/admin/mail/rules.js +343 -0
  51. package/plugins/_lib/admin/mail/scheduling.js +203 -0
  52. package/plugins/_lib/admin/mail/section-kit.js +277 -0
  53. package/plugins/_lib/admin/mail/templates.js +238 -0
  54. package/plugins/_lib/admin/mail/threads.js +200 -0
  55. package/plugins/_lib/admin/mail/vacation.js +269 -0
  56. package/plugins/_lib/admin/ui/help.css +1 -0
  57. package/plugins/_lib/admin/ui/help.js +174 -0
  58. package/plugins/_lib/admin/ui/resizable.js +151 -0
  59. package/plugins/_lib/dataStore.js +117 -0
  60. package/plugins/_lib/mail/accounts.js +919 -0
  61. package/plugins/_lib/mail/bodyTokens.js +101 -0
  62. package/plugins/_lib/mail/compose.js +256 -0
  63. package/plugins/_lib/mail/defaults.js +57 -0
  64. package/plugins/_lib/mail/diagnostics.js +154 -0
  65. package/plugins/_lib/mail/envelope.js +161 -0
  66. package/plugins/_lib/mail/folders.js +192 -0
  67. package/plugins/_lib/mail/handoff.js +274 -0
  68. package/plugins/_lib/mail/imapPool.js +293 -0
  69. package/plugins/_lib/mail/mbox.js +74 -0
  70. package/plugins/_lib/mail/pollSchedule.js +79 -0
  71. package/plugins/_lib/mail/poller.js +135 -0
  72. package/plugins/_lib/mail/priority.js +138 -0
  73. package/plugins/_lib/mail/readRoutes.js +680 -0
  74. package/plugins/_lib/mail/render.js +291 -0
  75. package/plugins/_lib/mail/ruleRunner.js +152 -0
  76. package/plugins/_lib/mail/scheduler.js +254 -0
  77. package/plugins/_lib/mail/secretbox.js +229 -0
  78. package/plugins/_lib/mail/send.js +396 -0
  79. package/plugins/_lib/mail/store.js +1002 -0
  80. package/plugins/_lib/mail/sync.js +292 -0
  81. package/plugins/_lib/mail/syncPlan.js +126 -0
  82. package/plugins/_lib/mail/syncSelection.js +82 -0
  83. package/plugins/_lib/mail/unsubscribe.js +183 -0
  84. package/plugins/_lib/mail/vacationRunner.js +114 -0
  85. package/plugins/_lib/mail/write.js +473 -0
  86. package/plugins/_lib/schemaSync.js +83 -0
  87. package/plugins/_template/admin/css/index.css +0 -0
  88. package/plugins/_template/admin/templates/index.html +4 -4
  89. package/plugins/_template/admin/views/index.js +7 -0
  90. package/plugins/analytics/admin/css/index.css +1 -0
  91. package/plugins/analytics/admin/templates/analytics.html +22 -13
  92. package/plugins/analytics/plugin.json +3 -0
  93. package/plugins/blog/admin/css/index.css +1 -0
  94. package/plugins/blog/admin/templates/blog.html +30 -18
  95. package/plugins/blog/admin/templates/categories.html +2 -2
  96. package/plugins/blog/admin/templates/comments.html +2 -2
  97. package/plugins/blog/admin/templates/post-editor.html +34 -34
  98. package/plugins/blog/admin/templates/settings.html +6 -3
  99. package/plugins/blog/admin/views/blog.js +8 -5
  100. package/plugins/blog/admin/views/categories.js +5 -10
  101. package/plugins/blog/admin/views/comments.js +5 -5
  102. package/plugins/blog/admin/views/post-editor.js +39 -20
  103. package/plugins/blog/admin/views/settings.js +52 -50
  104. package/plugins/blog/collections/categories/schema.json +7 -6
  105. package/plugins/blog/collections/comments/schema.json +11 -10
  106. package/plugins/blog/collections/posts/schema.json +14 -13
  107. package/plugins/blog/plugin.js +36 -13
  108. package/plugins/blog/plugin.json +13 -5
  109. package/plugins/blog/plugin.public.js +312 -0
  110. package/plugins/contacts/admin/css/index.css +1 -0
  111. package/plugins/contacts/admin/templates/contacts.html +128 -0
  112. package/plugins/contacts/admin/views/contacts.js +237 -4
  113. package/plugins/contacts/collections/user-contacts/schema.json +108 -0
  114. package/plugins/contacts/plugin.js +214 -27
  115. package/plugins/contacts/plugin.json +4 -1
  116. package/plugins/invoice/admin/css/index.css +1 -0
  117. package/plugins/invoice/admin/templates/editor.html +140 -49
  118. package/plugins/invoice/admin/templates/index.html +153 -23
  119. package/plugins/invoice/admin/templates/issuers.html +2 -5
  120. package/plugins/invoice/admin/templates/receivers.html +2 -5
  121. package/plugins/invoice/admin/views/contacts-source.js +266 -0
  122. package/plugins/invoice/admin/views/editor.js +366 -199
  123. package/plugins/invoice/admin/views/export.js +199 -0
  124. package/plugins/invoice/admin/views/help-content.js +61 -0
  125. package/plugins/invoice/admin/views/index.js +582 -94
  126. package/plugins/invoice/admin/views/issuers.js +24 -17
  127. package/plugins/invoice/admin/views/media.js +172 -0
  128. package/plugins/invoice/admin/views/party-view.js +305 -67
  129. package/plugins/invoice/admin/views/payments.js +127 -0
  130. package/plugins/invoice/admin/views/print.js +130 -0
  131. package/plugins/invoice/admin/views/receivers.js +49 -16
  132. package/plugins/invoice/admin/views/send.js +212 -0
  133. package/plugins/invoice/admin/views/settings.js +594 -0
  134. package/plugins/invoice/admin/views/view-lifecycle.js +33 -0
  135. package/plugins/invoice/collections/invoice-issuers/schema.json +77 -11
  136. package/plugins/invoice/collections/invoice-receivers/schema.json +10 -9
  137. package/plugins/invoice/collections/invoices/schema.json +19 -13
  138. package/plugins/invoice/config.js +27 -6
  139. package/plugins/invoice/pdf.js +164 -0
  140. package/plugins/invoice/plugin.js +1217 -44
  141. package/plugins/invoice/plugin.json +10 -9
  142. package/plugins/invoice/templates/_base.css +1 -0
  143. package/plugins/invoice/templates/classic-nologo.html +100 -0
  144. package/plugins/invoice/templates/classic.html +91 -0
  145. package/plugins/invoice/templates/invoice-print.html +24 -0
  146. package/plugins/invoice/templates/minimal.html +99 -0
  147. package/plugins/invoice/templates/modern-nologo.html +114 -0
  148. package/plugins/invoice/templates/modern.html +113 -0
  149. package/plugins/invoice/templates/templates.json +11 -0
  150. package/plugins/mail-reader/admin/views/mail.js +19 -0
  151. package/plugins/mail-reader/config.js +7 -0
  152. package/plugins/mail-reader/plugin.js +48 -0
  153. package/plugins/mail-reader/plugin.json +33 -0
  154. package/plugins/notes/admin/views/notes.js +1 -1
  155. package/plugins/notes/plugin.json +2 -2
  156. package/plugins/surveys/lib/audience.js +37 -0
  157. package/plugins/surveys/lib/campaigns.js +43 -0
  158. package/plugins/surveys/lib/ledger.js +110 -0
  159. package/plugins/surveys/lib/sending.js +106 -0
  160. package/plugins/surveys/lib/stats.js +62 -0
  161. package/plugins/surveys/lib/submit.js +95 -0
  162. package/plugins/surveys/lib/tokens.js +28 -0
  163. package/plugins/surveys/plugin.public.js +149 -0
  164. package/plugins/theme-switcher/admin/templates/theme-switcher.html +1 -1
  165. package/public/css/forms.css +1 -1
  166. package/public/css/search.css +1 -0
  167. package/public/css/site.css +1 -1
  168. package/public/js/collection-context.js +2 -2
  169. package/public/js/context-menus.js +1 -0
  170. package/public/js/form-logic-engine.js +1 -1
  171. package/public/js/forms.js +2 -2
  172. package/public/js/search.js +1 -0
  173. package/public/js/site.js +1 -1
  174. package/scripts/build.js +37 -3
  175. package/scripts/copy-domma.js +48 -0
  176. package/scripts/seed.js +1996 -0
  177. package/server/routes/api/collections.js +34 -0
  178. package/server/routes/api/context-menus.js +104 -0
  179. package/server/routes/api/forms.js +42 -3
  180. package/server/routes/api/notifications.js +69 -19
  181. package/server/routes/api/plugins.js +50 -6
  182. package/server/routes/api/search.js +43 -0
  183. package/server/routes/api/theme.js +69 -0
  184. package/server/routes/public.js +42 -7
  185. package/server/server.js +74 -0
  186. package/server/services/adapters/FileAdapter.js +6 -1
  187. package/server/services/content.js +26 -0
  188. package/server/services/contextMenus.js +477 -0
  189. package/server/services/markdown.js +70 -9
  190. package/server/services/permissionRegistry.js +24 -0
  191. package/server/services/pluginFiles.js +52 -11
  192. package/server/services/plugins.js +229 -6
  193. package/server/services/renderer.js +144 -22
  194. package/server/services/roles.js +1 -1
  195. package/server/services/search-migration.js +82 -0
  196. package/server/services/search.js +413 -0
  197. package/server/services/sidebar-migration.js +1 -0
  198. package/server/services/themeSettings.js +541 -0
  199. package/server/services/users.js +8 -0
  200. package/server/templates/page.html +4 -2
  201. package/plugins/contacts/data/contacts.json +0 -20
  202. package/plugins/notes/data/notes.json +0 -1
  203. package/plugins/site-search/admin/views/site-search.js +0 -116
  204. package/plugins/site-search/config.js +0 -15
  205. package/plugins/site-search/plugin.js +0 -188
  206. package/plugins/site-search/plugin.json +0 -40
  207. package/plugins/site-search/public/inject-body.html +0 -17
  208. package/plugins/site-search/public/inject-head.html +0 -1
  209. package/plugins/site-search/public/search.css +0 -1
  210. package/plugins/site-search/public/search.js +0 -1
  211. 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
+ }