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.
Files changed (221) hide show
  1. package/CLAUDE.md +151 -77
  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/menu-editor.html +19 -25
  19. package/admin/js/templates/plugin-code.html +1 -1
  20. package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
  21. package/admin/js/templates/settings.html +27 -97
  22. package/admin/js/templates/theme.html +173 -0
  23. package/admin/js/views/context-menu-editor.js +55 -0
  24. package/admin/js/views/context-menus.js +5 -0
  25. package/admin/js/views/form-editor.js +7 -7
  26. package/admin/js/views/index.js +1 -1
  27. package/admin/js/views/menu-editor.js +13 -13
  28. package/admin/js/views/plugin-marketplace.js +1 -1
  29. package/admin/js/views/plugins.js +25 -23
  30. package/admin/js/views/search.js +1 -0
  31. package/admin/js/views/settings.js +3 -3
  32. package/admin/js/views/theme.js +1 -0
  33. package/bin/cli.js +11 -2
  34. package/bin/lib/smtp-defaults.js +53 -0
  35. package/bin/update.js +13 -2
  36. package/config/menus/admin-sidebar.json +129 -23
  37. package/config/plugins.json +5 -5
  38. package/config/search.json +13 -0
  39. package/config/theme.json +18 -0
  40. package/package.json +12 -4
  41. package/plugins/_lib/admin/mail/compose-window.js +914 -0
  42. package/plugins/_lib/admin/mail/contacts.js +301 -0
  43. package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
  44. package/plugins/_lib/admin/mail/folder-tree.js +254 -0
  45. package/plugins/_lib/admin/mail/identity.js +480 -0
  46. package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
  47. package/plugins/_lib/admin/mail/keyboard.js +136 -0
  48. package/plugins/_lib/admin/mail/mail.css +1 -0
  49. package/plugins/_lib/admin/mail/mail.html +71 -0
  50. package/plugins/_lib/admin/mail/panes.js +253 -0
  51. package/plugins/_lib/admin/mail/reader-view.js +4453 -0
  52. package/plugins/_lib/admin/mail/resizable.js +26 -0
  53. package/plugins/_lib/admin/mail/rules.js +343 -0
  54. package/plugins/_lib/admin/mail/scheduling.js +203 -0
  55. package/plugins/_lib/admin/mail/section-kit.js +277 -0
  56. package/plugins/_lib/admin/mail/templates.js +238 -0
  57. package/plugins/_lib/admin/mail/threads.js +200 -0
  58. package/plugins/_lib/admin/mail/vacation.js +269 -0
  59. package/plugins/_lib/admin/ui/help.css +1 -0
  60. package/plugins/_lib/admin/ui/help.js +174 -0
  61. package/plugins/_lib/admin/ui/resizable.js +151 -0
  62. package/plugins/_lib/dataStore.js +117 -0
  63. package/plugins/_lib/mail/accounts.js +919 -0
  64. package/plugins/_lib/mail/bodyTokens.js +101 -0
  65. package/plugins/_lib/mail/compose.js +256 -0
  66. package/plugins/_lib/mail/defaults.js +57 -0
  67. package/plugins/_lib/mail/diagnostics.js +154 -0
  68. package/plugins/_lib/mail/envelope.js +161 -0
  69. package/plugins/_lib/mail/folders.js +192 -0
  70. package/plugins/_lib/mail/handoff.js +274 -0
  71. package/plugins/_lib/mail/imapPool.js +293 -0
  72. package/plugins/_lib/mail/mbox.js +74 -0
  73. package/plugins/_lib/mail/pollSchedule.js +79 -0
  74. package/plugins/_lib/mail/poller.js +135 -0
  75. package/plugins/_lib/mail/priority.js +138 -0
  76. package/plugins/_lib/mail/readRoutes.js +680 -0
  77. package/plugins/_lib/mail/render.js +291 -0
  78. package/plugins/_lib/mail/ruleRunner.js +152 -0
  79. package/plugins/_lib/mail/scheduler.js +254 -0
  80. package/plugins/_lib/mail/secretbox.js +229 -0
  81. package/plugins/_lib/mail/send.js +396 -0
  82. package/plugins/_lib/mail/store.js +1002 -0
  83. package/plugins/_lib/mail/sync.js +292 -0
  84. package/plugins/_lib/mail/syncPlan.js +126 -0
  85. package/plugins/_lib/mail/syncSelection.js +82 -0
  86. package/plugins/_lib/mail/unsubscribe.js +183 -0
  87. package/plugins/_lib/mail/vacationRunner.js +114 -0
  88. package/plugins/_lib/mail/write.js +473 -0
  89. package/plugins/_lib/schemaSync.js +83 -0
  90. package/plugins/_template/admin/css/index.css +0 -0
  91. package/plugins/_template/admin/templates/index.html +4 -4
  92. package/plugins/_template/admin/views/index.js +7 -0
  93. package/plugins/analytics/admin/css/index.css +1 -0
  94. package/plugins/analytics/admin/templates/analytics.html +22 -13
  95. package/plugins/analytics/plugin.json +3 -0
  96. package/plugins/blog/admin/css/index.css +1 -0
  97. package/plugins/blog/admin/templates/blog.html +30 -18
  98. package/plugins/blog/admin/templates/categories.html +2 -2
  99. package/plugins/blog/admin/templates/comments.html +2 -2
  100. package/plugins/blog/admin/templates/post-editor.html +34 -34
  101. package/plugins/blog/admin/templates/settings.html +6 -3
  102. package/plugins/blog/admin/views/blog.js +8 -5
  103. package/plugins/blog/admin/views/categories.js +5 -10
  104. package/plugins/blog/admin/views/comments.js +5 -5
  105. package/plugins/blog/admin/views/post-editor.js +39 -20
  106. package/plugins/blog/admin/views/settings.js +52 -50
  107. package/plugins/blog/collections/categories/schema.json +7 -6
  108. package/plugins/blog/collections/comments/schema.json +11 -10
  109. package/plugins/blog/collections/posts/schema.json +14 -13
  110. package/plugins/blog/plugin.js +36 -13
  111. package/plugins/blog/plugin.json +13 -5
  112. package/plugins/blog/plugin.public.js +312 -0
  113. package/plugins/contacts/admin/css/index.css +1 -0
  114. package/plugins/contacts/admin/templates/contacts.html +128 -0
  115. package/plugins/contacts/admin/views/contacts.js +237 -4
  116. package/plugins/contacts/collections/user-contacts/schema.json +108 -0
  117. package/plugins/contacts/plugin.js +214 -27
  118. package/plugins/contacts/plugin.json +4 -1
  119. package/plugins/invoice/admin/css/index.css +1 -0
  120. package/plugins/invoice/admin/templates/editor.html +140 -49
  121. package/plugins/invoice/admin/templates/index.html +153 -23
  122. package/plugins/invoice/admin/templates/issuers.html +2 -5
  123. package/plugins/invoice/admin/templates/receivers.html +2 -5
  124. package/plugins/invoice/admin/views/contacts-source.js +266 -0
  125. package/plugins/invoice/admin/views/editor.js +366 -199
  126. package/plugins/invoice/admin/views/export.js +199 -0
  127. package/plugins/invoice/admin/views/help-content.js +61 -0
  128. package/plugins/invoice/admin/views/index.js +582 -94
  129. package/plugins/invoice/admin/views/issuers.js +24 -17
  130. package/plugins/invoice/admin/views/media.js +172 -0
  131. package/plugins/invoice/admin/views/party-view.js +305 -67
  132. package/plugins/invoice/admin/views/payments.js +127 -0
  133. package/plugins/invoice/admin/views/print.js +130 -0
  134. package/plugins/invoice/admin/views/receivers.js +49 -16
  135. package/plugins/invoice/admin/views/send.js +212 -0
  136. package/plugins/invoice/admin/views/settings.js +594 -0
  137. package/plugins/invoice/admin/views/view-lifecycle.js +33 -0
  138. package/plugins/invoice/collections/invoice-issuers/schema.json +77 -11
  139. package/plugins/invoice/collections/invoice-receivers/schema.json +10 -9
  140. package/plugins/invoice/collections/invoices/schema.json +19 -13
  141. package/plugins/invoice/config.js +27 -6
  142. package/plugins/invoice/pdf.js +164 -0
  143. package/plugins/invoice/plugin.js +1217 -44
  144. package/plugins/invoice/plugin.json +10 -9
  145. package/plugins/invoice/templates/_base.css +1 -0
  146. package/plugins/invoice/templates/classic-nologo.html +100 -0
  147. package/plugins/invoice/templates/classic.html +91 -0
  148. package/plugins/invoice/templates/invoice-print.html +24 -0
  149. package/plugins/invoice/templates/minimal.html +99 -0
  150. package/plugins/invoice/templates/modern-nologo.html +114 -0
  151. package/plugins/invoice/templates/modern.html +113 -0
  152. package/plugins/invoice/templates/templates.json +11 -0
  153. package/plugins/mail-reader/admin/views/mail.js +19 -0
  154. package/plugins/mail-reader/config.js +7 -0
  155. package/plugins/mail-reader/plugin.js +48 -0
  156. package/plugins/mail-reader/plugin.json +33 -0
  157. package/plugins/notes/admin/views/notes.js +1 -1
  158. package/plugins/notes/plugin.json +2 -2
  159. package/plugins/surveys/lib/audience.js +37 -0
  160. package/plugins/surveys/lib/campaigns.js +43 -0
  161. package/plugins/surveys/lib/ledger.js +110 -0
  162. package/plugins/surveys/lib/sending.js +106 -0
  163. package/plugins/surveys/lib/stats.js +62 -0
  164. package/plugins/surveys/lib/submit.js +95 -0
  165. package/plugins/surveys/lib/tokens.js +28 -0
  166. package/plugins/surveys/plugin.public.js +149 -0
  167. package/plugins/theme-switcher/admin/templates/theme-switcher.html +1 -1
  168. package/public/css/forms.css +1 -1
  169. package/public/css/menu-highlight.css +1 -1
  170. package/public/css/search.css +1 -0
  171. package/public/css/site.css +1 -1
  172. package/public/js/collection-context.js +2 -2
  173. package/public/js/context-menus.js +1 -0
  174. package/public/js/form-logic-engine.js +1 -1
  175. package/public/js/forms.js +2 -2
  176. package/public/js/menu-decor.mjs +1 -1
  177. package/public/js/search.js +1 -0
  178. package/public/js/site.js +1 -1
  179. package/scripts/build.js +37 -3
  180. package/scripts/copy-domma.js +48 -0
  181. package/scripts/seed.js +1996 -0
  182. package/scripts/setup.js +8 -0
  183. package/server/routes/api/collections.js +34 -0
  184. package/server/routes/api/context-menus.js +104 -0
  185. package/server/routes/api/forms.js +42 -3
  186. package/server/routes/api/notifications.js +69 -19
  187. package/server/routes/api/plugins.js +50 -6
  188. package/server/routes/api/search.js +43 -0
  189. package/server/routes/api/theme.js +69 -0
  190. package/server/routes/public.js +42 -7
  191. package/server/server.js +74 -0
  192. package/server/services/adapters/FileAdapter.js +6 -1
  193. package/server/services/content.js +26 -0
  194. package/server/services/contextMenus.js +477 -0
  195. package/server/services/email.js +29 -3
  196. package/server/services/health.js +23 -2
  197. package/server/services/markdown.js +70 -9
  198. package/server/services/menuRender.js +28 -4
  199. package/server/services/menus.js +25 -1
  200. package/server/services/permissionRegistry.js +24 -0
  201. package/server/services/pluginFiles.js +52 -11
  202. package/server/services/plugins.js +229 -6
  203. package/server/services/renderer.js +148 -22
  204. package/server/services/roles.js +1 -1
  205. package/server/services/search-migration.js +82 -0
  206. package/server/services/search.js +413 -0
  207. package/server/services/sidebar-migration.js +1 -0
  208. package/server/services/themeSettings.js +541 -0
  209. package/server/services/users.js +8 -0
  210. package/server/templates/page.html +4 -2
  211. package/plugins/contacts/data/contacts.json +0 -20
  212. package/plugins/notes/data/notes.json +0 -1
  213. package/plugins/site-search/admin/views/site-search.js +0 -116
  214. package/plugins/site-search/config.js +0 -15
  215. package/plugins/site-search/plugin.js +0 -188
  216. package/plugins/site-search/plugin.json +0 -40
  217. package/plugins/site-search/public/inject-body.html +0 -17
  218. package/plugins/site-search/public/inject-head.html +0 -1
  219. package/plugins/site-search/public/search.css +0 -1
  220. package/plugins/site-search/public/search.js +0 -1
  221. package/plugins/todo/data/todos.json +0 -1
@@ -0,0 +1,680 @@
1
+ /**
2
+ * The read-only mail surface, shared by every edition.
3
+ *
4
+ * `mail-reader` (free) is exactly these routes and nothing else. `email-pro`
5
+ * registers them too and adds writing, sending and a synchronised store on
6
+ * top - forking them instead would mean every IMAP quirk, every sanitiser
7
+ * decision and every ownership check being fixed twice.
8
+ *
9
+ * Routes are relative, so they mount under whichever plugin registers them;
10
+ * `pluginName` is needed only for the body URL and the log tags.
11
+ *
12
+ * ## Access control
13
+ *
14
+ * Every route resolves the mailbox through `accounts.getAccount(userId,
15
+ * accountId)`, which takes the owner's id as well as the account's. There is
16
+ * no lookup by account id alone, so one user's mailbox cannot be reached with
17
+ * another user's token, and an id that is not the caller's answers 404 rather
18
+ * than 403 - a wrong id and someone else's id should be indistinguishable.
19
+ *
20
+ * ## Read-only
21
+ *
22
+ * Nothing here writes to a mailbox. Mailboxes open with EXAMINE and fetches
23
+ * use BODY.PEEK[], so reading never sets \Seen. `email-pro` must take its own
24
+ * writable handle rather than relax this one: the free reader depends on it.
25
+ *
26
+ * @module _lib/mail/readRoutes
27
+ */
28
+ import {simpleParser} from 'mailparser';
29
+
30
+ import {listUsers} from '../../../server/services/users.js';
31
+ import * as accounts from './accounts.js';
32
+ import * as bodyTokens from './bodyTokens.js';
33
+ import {buildDiagnostics} from './diagnostics.js';
34
+ import {ENVELOPE_QUERY, hasAttachment, parseHeaderBlock} from './envelope.js';
35
+ import {describeError} from './imapPool.js';
36
+ import {resolveSubscriptions} from './folders.js';
37
+ import {readPriority} from './priority.js';
38
+ import {listAttachments, renderBody} from './render.js';
39
+ import {readUnsubscribe} from './unsubscribe.js';
40
+ import {MailKeyError} from './secretbox.js';
41
+
42
+ /**
43
+ * Flatten an envelope address list into something the browser can render.
44
+ *
45
+ * @param {Array<{name?: string, address?: string}>} list
46
+ * @returns {{name: string, address: string}[]}
47
+ */
48
+ function addresses(list) {
49
+ return (list ?? []).map(entry => ({
50
+ name: entry.name ?? '',
51
+ address: entry.address ?? ''
52
+ }));
53
+ }
54
+
55
+ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginName, cachedList = null, diagnostics = null}) {
56
+ const {authenticate} = auth;
57
+
58
+ // A deleted user's stored mailbox passwords must not outlive their
59
+ // account. The hook is the fast path; the sweep below is what catches a
60
+ // user deleted while this plugin happened to be disabled.
61
+ hooks?.on?.('user:deleted', async ({id} = {}) => {
62
+ if (!id) return;
63
+ try {
64
+ pool.releaseUser(id);
65
+ const removed = await accounts.deleteAccountsForUser(id);
66
+ if (removed) {
67
+ fastify.log.info(`[${pluginName}] removed ${removed} stored mailbox(es) for deleted user ${id}`);
68
+ }
69
+ } catch (err) {
70
+ fastify.log.error({err}, `[${pluginName}] failed to clean up mailboxes for a deleted user`);
71
+ }
72
+ });
73
+
74
+ // Fire-and-forget: a slow or failing sweep must never delay boot, and
75
+ // there is nothing a user can do about it if it does fail.
76
+ (async () => {
77
+ try {
78
+ const removed = await accounts.pruneOrphans(new Set((await listUsers()).map(u => u.id)));
79
+ if (removed) {
80
+ fastify.log.info(`[${pluginName}] pruned ${removed} stored mailbox(es) with no matching user`);
81
+ }
82
+ } catch (err) {
83
+ fastify.log.warn({err}, `[${pluginName}] orphan mailbox sweep failed`);
84
+ }
85
+ })();
86
+
87
+ /**
88
+ * Resolve the caller's user id.
89
+ *
90
+ * @param {object} request
91
+ * @returns {string|null}
92
+ */
93
+ function userId(request) {
94
+ return request.user?.id ?? request.user?.sub ?? null;
95
+ }
96
+
97
+ /**
98
+ * Load the mailbox named by ?account= and open its stored password.
99
+ *
100
+ * Returns null after replying, so handlers can `if (!ctx) return;`.
101
+ *
102
+ * @param {object} request
103
+ * @param {object} reply
104
+ * @returns {Promise<{uid: string, account: object, connection: object, poolKey: string}|null>}
105
+ */
106
+ async function context(request, reply) {
107
+ const uid = userId(request);
108
+ if (!uid) {
109
+ reply.code(401).send({error: 'Authentication required.'});
110
+ return null;
111
+ }
112
+
113
+ const requested = request.query.account ? String(request.query.account) : null;
114
+ const owned = await accounts.listAccounts(uid);
115
+
116
+ if (!owned.length) {
117
+ reply.code(404).send({error: 'No mailbox is set up yet.', needsSetup: true});
118
+ return null;
119
+ }
120
+
121
+ // No ?account= means "the first one" - the reader opens on a mailbox
122
+ // rather than asking which before it has shown anything.
123
+ const account = requested ? owned.find(a => a.id === requested) : owned[0];
124
+ if (!account) {
125
+ reply.code(404).send({error: 'That mailbox does not exist.'});
126
+ return null;
127
+ }
128
+
129
+ try {
130
+ return {
131
+ uid,
132
+ account,
133
+ connection: accounts.toConnection(account),
134
+ // Keyed per account, not per user: one user may hold several
135
+ // mailboxes and each needs its own connection.
136
+ poolKey: `${uid}:${account.id}`
137
+ };
138
+ } catch (err) {
139
+ if (err instanceof MailKeyError) {
140
+ reply.code(409).send({error: err.message, needsPassword: true, accountId: account.id});
141
+ return null;
142
+ }
143
+ throw err;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Run an IMAP operation, turning transport failures into tidy 502s.
149
+ *
150
+ * @param {object} reply
151
+ * @param {() => Promise<any>} fn
152
+ * @returns {Promise<any>}
153
+ */
154
+ async function imap(reply, fn) {
155
+ try {
156
+ return await fn();
157
+ } catch (err) {
158
+ fastify.log.error({err}, `[${pluginName}] IMAP operation failed`);
159
+ reply.code(502).send({error: describeError(err)});
160
+ return null;
161
+ }
162
+ }
163
+
164
+ // -------------------------------------------------------------------------
165
+ // Accounts
166
+ // -------------------------------------------------------------------------
167
+
168
+ /** GET /accounts - every mailbox this user owns, without passwords. */
169
+ fastify.get('/accounts', {preHandler: [authenticate]}, async (request, reply) => {
170
+ const uid = userId(request);
171
+ if (!uid) return reply.code(401).send({error: 'Authentication required.'});
172
+ return {accounts: (await accounts.listAccounts(uid)).map(accounts.toPublic)};
173
+ });
174
+
175
+ /** POST /accounts - add a mailbox. */
176
+ fastify.post('/accounts', {preHandler: [authenticate]}, async (request, reply) => {
177
+ const uid = userId(request);
178
+ if (!uid) return reply.code(401).send({error: 'Authentication required.'});
179
+
180
+ const result = accounts.validate(request.body ?? {}, null);
181
+ if (!result.ok) return reply.code(400).send({error: result.error});
182
+
183
+ const saved = await accounts.saveAccount(uid, null, result.value);
184
+ return {account: accounts.toPublic(saved)};
185
+ });
186
+
187
+ /** PUT /accounts/:id - update one. A blank password keeps the stored one. */
188
+ fastify.put('/accounts/:id', {preHandler: [authenticate]}, async (request, reply) => {
189
+ const uid = userId(request);
190
+ if (!uid) return reply.code(401).send({error: 'Authentication required.'});
191
+
192
+ const existing = await accounts.getAccount(uid, request.params.id);
193
+ if (!existing) return reply.code(404).send({error: 'That mailbox does not exist.'});
194
+
195
+ const result = accounts.validate(request.body ?? {}, existing);
196
+ if (!result.ok) return reply.code(400).send({error: result.error});
197
+
198
+ const saved = await accounts.saveAccount(uid, request.params.id, result.value);
199
+ // The pooled connection was opened with the old details.
200
+ pool.release(`${uid}:${request.params.id}`);
201
+ return {account: accounts.toPublic(saved)};
202
+ });
203
+
204
+ /** DELETE /accounts/:id - forget a mailbox and its password. */
205
+ fastify.delete('/accounts/:id', {preHandler: [authenticate]}, async (request, reply) => {
206
+ const uid = userId(request);
207
+ if (!uid) return reply.code(401).send({error: 'Authentication required.'});
208
+
209
+ pool.release(`${uid}:${request.params.id}`);
210
+ const deleted = await accounts.deleteAccount(uid, request.params.id);
211
+ if (!deleted) return reply.code(404).send({error: 'That mailbox does not exist.'});
212
+ return {deleted: true};
213
+ });
214
+
215
+ /**
216
+ * POST /accounts/test - try details without saving them.
217
+ *
218
+ * Accepts a body so a mailbox can be tested before it is saved. When the
219
+ * password field is blank and `id` names an existing mailbox, the stored
220
+ * password is used - otherwise editing a hostname would mean retyping it.
221
+ */
222
+ fastify.post('/accounts/test', {preHandler: [authenticate]}, async (request, reply) => {
223
+ const uid = userId(request);
224
+ if (!uid) return reply.code(401).send({error: 'Authentication required.'});
225
+
226
+ const body = request.body ?? {};
227
+ const existing = body.id ? await accounts.getAccount(uid, body.id) : null;
228
+ if (body.id && !existing) return reply.code(404).send({error: 'That mailbox does not exist.'});
229
+
230
+ const result = accounts.validate(body, existing);
231
+ if (!result.ok) return reply.code(400).send({error: result.error});
232
+
233
+ let pass = result.value.password;
234
+ if (!pass) {
235
+ try {
236
+ pass = accounts.toConnection(existing).auth.pass;
237
+ } catch (err) {
238
+ if (err instanceof MailKeyError) return reply.code(409).send({error: err.message});
239
+ throw err;
240
+ }
241
+ }
242
+
243
+ return pool.test({
244
+ host: result.value.host,
245
+ port: result.value.port,
246
+ secure: result.value.secure,
247
+ auth: {user: result.value.username, pass}
248
+ });
249
+ });
250
+
251
+ // -------------------------------------------------------------------------
252
+ // Reading
253
+ // -------------------------------------------------------------------------
254
+
255
+ /** GET /folders - the mailbox tree, flattened. */
256
+ fastify.get('/folders', {preHandler: [authenticate]}, async (request, reply) => {
257
+ const ctx = await context(request, reply);
258
+ if (!ctx) return;
259
+
260
+ return imap(reply, async () => {
261
+ const list = await pool.withClient(ctx.poolKey, ctx.connection, client => client.list());
262
+ // `subscribed !== false` was wrong: imapflow reports an
263
+ // unsubscribed mailbox as `undefined`, so every hidden folder read
264
+ // as visible. See resolveSubscriptions for why it is not simply
265
+ // `=== true` either.
266
+ const visible = resolveSubscriptions(list);
267
+ const folders = list
268
+ .filter(box => !box.flags?.has('\\Noselect'))
269
+ .map(box => ({
270
+ path: box.path,
271
+ name: box.name,
272
+ delimiter: box.delimiter,
273
+ specialUse: box.specialUse ?? null,
274
+ subscribed: visible.get(box.path) !== false
275
+ }));
276
+
277
+ // Unread counts are opt-in. STATUS is a round trip per folder and
278
+ // a real mailbox here has over a hundred of them, so asking for
279
+ // them is a choice the caller makes rather than a cost every
280
+ // folder listing pays.
281
+ if (request.query.counts === '1') {
282
+ await withUnreadCounts(ctx, folders);
283
+ }
284
+
285
+ return {accountId: ctx.account.id, folders};
286
+ });
287
+ });
288
+
289
+ /**
290
+ * Fill in each folder's unread count.
291
+ *
292
+ * STATUS asks without selecting, so it does not disturb the open mailbox
293
+ * and cannot mark anything read. Run with a small concurrency: a hundred
294
+ * simultaneous commands on one connection is not faster, and on several
295
+ * connections it is how a server starts refusing them.
296
+ *
297
+ * A folder that refuses STATUS is left without a count rather than
298
+ * failing the listing - some servers will not report on some folders, and
299
+ * a missing badge is better than an empty folder pane.
300
+ *
301
+ * @param {object} ctx
302
+ * @param {object[]} folders
303
+ * @returns {Promise<void>}
304
+ */
305
+ async function withUnreadCounts(ctx, folders) {
306
+ const queue = [...folders];
307
+ const worker = async () => {
308
+ while (queue.length) {
309
+ const folder = queue.shift();
310
+ try {
311
+ const status = await pool.withClient(ctx.poolKey, ctx.connection,
312
+ client => client.status(folder.path, {unseen: true, messages: true}));
313
+ folder.unseen = status?.unseen ?? 0;
314
+ folder.messages = status?.messages ?? 0;
315
+ } catch {
316
+ folder.unseen = null;
317
+ }
318
+ }
319
+ };
320
+ // One connection, so the commands serialise anyway - this just avoids
321
+ // building a hundred pending promises.
322
+ await Promise.all([worker(), worker()]);
323
+ }
324
+
325
+ /**
326
+ * GET /messages - one page of envelopes, newest first.
327
+ *
328
+ * Paged by sequence number from the end of the mailbox rather than by
329
+ * search: it is one round trip, and "newest first" is what a reader wants.
330
+ */
331
+ fastify.get('/messages', {preHandler: [authenticate]}, async (request, reply) => {
332
+ const ctx = await context(request, reply);
333
+ if (!ctx) return;
334
+
335
+ const folder = String(request.query.folder || 'INBOX');
336
+ const page = Math.max(1, Number(request.query.page) || 1);
337
+ const limit = Math.min(
338
+ config.maxListLimit,
339
+ Math.max(1, Number(request.query.limit) || config.listLimit)
340
+ );
341
+
342
+ // The Pro edition mirrors chosen folders, and a mirrored folder is
343
+ // answered from the store - instant, and no connection needed. It
344
+ // returns null for anything not mirrored, which falls through to the
345
+ // wire exactly as the free edition always does.
346
+ if (cachedList) {
347
+ try {
348
+ const cached = await cachedList({
349
+ account: ctx.account, userId: ctx.uid, folder, page, limit
350
+ });
351
+ if (cached) {
352
+ return {...cached, page, limit, folder, accountId: ctx.account.id, source: 'cache'};
353
+ }
354
+ } catch (err) {
355
+ // A broken mirror must never make mail unreadable; fall back.
356
+ fastify.log.warn({err}, `[${pluginName}] cached list failed - falling back to IMAP`);
357
+ }
358
+ }
359
+
360
+ return imap(reply, () => pool.withMailbox(ctx.poolKey, ctx.connection, folder, async (client, mailbox) => {
361
+ const total = mailbox.exists;
362
+ if (!total) return {messages: [], total: 0, page, limit, folder, accountId: ctx.account.id};
363
+
364
+ const end = total - (page - 1) * limit;
365
+ if (end < 1) return {messages: [], total, page, limit, folder, accountId: ctx.account.id};
366
+ const start = Math.max(1, end - limit + 1);
367
+
368
+ const messages = [];
369
+ // The same query the mirror uses, so a folder listed live and one
370
+ // listed from the mirror produce identical rows - including the
371
+ // priority headers, which are not part of the envelope.
372
+ for await (const message of client.fetch(`${start}:${end}`, ENVELOPE_QUERY)) {
373
+ messages.push({
374
+ uid: message.uid,
375
+ seq: message.seq,
376
+ subject: message.envelope?.subject ?? '',
377
+ from: addresses(message.envelope?.from),
378
+ to: addresses(message.envelope?.to),
379
+ date: message.envelope?.date ?? null,
380
+ size: message.size ?? 0,
381
+ // The raw flag list, not just the booleans derived from it.
382
+ // The list view keeps an observable of this per row, so
383
+ // omitting it left every message on a non-mirrored folder
384
+ // initialised with no flags - and therefore drawn unread,
385
+ // whatever the server said.
386
+ flags: [...(message.flags ?? [])],
387
+ seen: message.flags?.has('\\Seen') ?? false,
388
+ flagged: message.flags?.has('\\Flagged') ?? false,
389
+ answered: message.flags?.has('\\Answered') ?? false,
390
+ hasAttachment: hasAttachment(message.bodyStructure),
391
+ priority: readPriority(parseHeaderBlock(message.headers))
392
+ });
393
+ }
394
+
395
+ // The server returns ascending sequence order regardless of range.
396
+ messages.reverse();
397
+ return {messages, total, page, limit, folder, accountId: ctx.account.id};
398
+ }));
399
+ });
400
+
401
+ /** GET /messages/:uid - one message, rendered for the reader pane. */
402
+ fastify.get('/messages/:uid', {preHandler: [authenticate]}, async (request, reply) => {
403
+ const ctx = await context(request, reply);
404
+ if (!ctx) return;
405
+
406
+ const folder = String(request.query.folder || 'INBOX');
407
+ const uid = Number(request.params.uid);
408
+ if (!Number.isInteger(uid) || uid < 1) {
409
+ return reply.code(400).send({error: 'Invalid message id.'});
410
+ }
411
+ // Three ways images may load, and only one of them is remembered.
412
+ // The per-message override is deliberately not stored - it is a
413
+ // decision about one message. A sender the user has explicitly chosen
414
+ // to trust is stored, listed in settings and revocable, which is a
415
+ // different act from silently remembering a click.
416
+ const requestedNow = request.query.images === 'load';
417
+
418
+ return imap(reply, () => pool.withMailbox(ctx.poolKey, ctx.connection, folder, async (client) => {
419
+ const fetched = await client.fetchOne(uid, {source: true, flags: true}, {uid: true});
420
+ if (!fetched || !fetched.source) {
421
+ reply.code(404).send({error: 'That message is no longer in this folder.'});
422
+ return null;
423
+ }
424
+ if (fetched.source.length > config.maxMessageBytes) {
425
+ reply.code(413).send({error: 'That message is too large to display.'});
426
+ return null;
427
+ }
428
+
429
+ const parsed = await simpleParser(fetched.source);
430
+ const from = addresses(parsed.from?.value);
431
+ const senderTrusted = accounts.sendersImagesTrusted(ctx.account, from);
432
+ const allowRemoteImages = requestedNow || config.allowRemoteImages || senderTrusted;
433
+
434
+ const rendered = renderBody(parsed, {
435
+ allowRemoteImages,
436
+ inlineImageMaxBytes: config.inlineImageMaxBytes
437
+ });
438
+
439
+ return {
440
+ uid,
441
+ folder,
442
+ accountId: ctx.account.id,
443
+ senderTrusted,
444
+ // Offered only when remembering is switched on for this mailbox.
445
+ canTrustSender: ctx.account.rememberImageSenders !== false
446
+ && !senderTrusted
447
+ && !!from[0]?.address,
448
+ trustableSender: from[0]?.address ?? null,
449
+ subject: parsed.subject ?? '',
450
+ from,
451
+ to: addresses(parsed.to?.value),
452
+ cc: addresses(parsed.cc?.value),
453
+ // Carried so the expanded header can show them. Reply-To
454
+ // especially: a sender who sets it is asking for replies
455
+ // somewhere other than where the message came from, and until
456
+ // now the reader hid that entirely. Bcc only ever appears on a
457
+ // copy in Sent - a received message never carries one, which
458
+ // is the point of it.
459
+ replyTo: addresses(parsed.replyTo?.value),
460
+ bcc: addresses(parsed.bcc?.value),
461
+ date: parsed.date ?? null,
462
+ messageId: parsed.messageId ?? null,
463
+ // From the parsed message here rather than a header fetch:
464
+ // the whole source is already in hand at this point.
465
+ priority: readPriority(parsed.headers),
466
+ // A URL, not the markup: an iframe filled with srcdoc inherits
467
+ // the admin's CSP (img-src 'self' data: blob:), which silently
468
+ // blocks every remote image no matter what the reader asks for.
469
+ // Served from a route, the document carries its own policy.
470
+ bodyUrl: `/api/plugins/${pluginName}/body?token=${encodeURIComponent(bodyTokens.sign({
471
+ userId: ctx.uid,
472
+ accountId: ctx.account.id,
473
+ folder,
474
+ uid,
475
+ images: allowRemoteImages
476
+ }))}`,
477
+ isHtml: rendered.isHtml,
478
+ blockedImages: rendered.blockedImages,
479
+ remoteImagesLoaded: allowRemoteImages,
480
+ // What this message offers by way of getting off the list it
481
+ // came from. Read in both editions, because knowing there is
482
+ // an unsubscribe link is reading; acting on it is a write and
483
+ // lives in the Pro edition.
484
+ unsubscribe: readUnsubscribe(parsed.headers),
485
+ attachments: listAttachments(parsed.attachments)
486
+ };
487
+ }));
488
+ });
489
+
490
+ /**
491
+ * GET /body - the rendered message, as its own document.
492
+ *
493
+ * Authorised by a short-lived signed token rather than a bearer header,
494
+ * because an `<iframe src>` cannot send one. The token names a single
495
+ * message for a single user and expires in minutes.
496
+ *
497
+ * The CSP here is the point of the whole route: served as its own
498
+ * document, this policy applies instead of the admin's, so a message the
499
+ * reader has chosen to load images for can actually load them - while
500
+ * scripts, frames and everything else stay refused.
501
+ */
502
+ fastify.get('/body', {
503
+ // Helmet adds X-Frame-Options in an onSend hook, which runs after
504
+ // the handler - so removing it in there was too early and the
505
+ // browser kept warning, once per message, that it was ignoring the
506
+ // header in favour of the frame-ancestors this route already sets.
507
+ // Helmet writes onto the raw response rather than Fastify's header
508
+ // store, so reply.removeHeader() never sees it - it has to come off
509
+ // the raw response, and only for this route.
510
+ onSend: async (request, reply, payload) => {
511
+ reply.removeHeader('X-Frame-Options');
512
+ reply.raw.removeHeader?.('X-Frame-Options');
513
+ return payload;
514
+ }
515
+ }, async (request, reply) => {
516
+ const grant = bodyTokens.verify(String(request.query.token ?? ''));
517
+ if (!grant) {
518
+ return reply.code(403).type('text/plain').send('This message link has expired. Reopen the message.');
519
+ }
520
+
521
+ const account = await accounts.getAccount(grant.userId, grant.accountId);
522
+ if (!account) return reply.code(404).type('text/plain').send('That mailbox no longer exists.');
523
+
524
+ let connection;
525
+ try {
526
+ connection = accounts.toConnection(account);
527
+ } catch (err) {
528
+ if (err instanceof MailKeyError) return reply.code(409).type('text/plain').send(err.message);
529
+ throw err;
530
+ }
531
+
532
+ const poolKey = `${grant.userId}:${account.id}`;
533
+ try {
534
+ const document = await pool.withMailbox(poolKey, connection, grant.folder, async (client) => {
535
+ const fetched = await client.fetchOne(grant.uid, {source: true}, {uid: true});
536
+ if (!fetched || !fetched.source) return null;
537
+ if (fetched.source.length > config.maxMessageBytes) return null;
538
+
539
+ const parsed = await simpleParser(fetched.source);
540
+ return renderBody(parsed, {
541
+ allowRemoteImages: grant.images,
542
+ inlineImageMaxBytes: config.inlineImageMaxBytes
543
+ }).document;
544
+ });
545
+
546
+ if (!document) return reply.code(404).type('text/plain').send('That message is no longer available.');
547
+
548
+ return reply
549
+ .type('text/html; charset=utf-8')
550
+ .header('Content-Security-Policy',
551
+ "default-src 'none'; " +
552
+ (grant.images ? "img-src data: http: https:; " : "img-src data:; ") +
553
+ "style-src 'unsafe-inline'; font-src data:; frame-ancestors 'self'")
554
+ .header('X-Content-Type-Options', 'nosniff')
555
+ .header('Referrer-Policy', 'no-referrer')
556
+ // Someone else's mail has no business in a cache.
557
+ .header('Cache-Control', 'no-store, no-cache, must-revalidate, private')
558
+ .send(document);
559
+ } catch (err) {
560
+ fastify.log.error({err}, `[${pluginName}] failed to render a message body`);
561
+ return reply.code(502).type('text/plain').send(describeError(err));
562
+ }
563
+ });
564
+
565
+ /**
566
+ * GET /diagnostics - what this build is, and what it is configured with.
567
+ *
568
+ * Exists so a bug report can start from evidence. No secrets: hostnames
569
+ * and usernames identify a mailbox, the key SOURCE is reported without the
570
+ * key, and no password appears sealed or otherwise.
571
+ */
572
+ fastify.get('/diagnostics', {preHandler: [authenticate]}, async (request, reply) => {
573
+ const uid = userId(request);
574
+ if (!uid) return reply.code(401).send({error: 'Authentication required.'});
575
+ return buildDiagnostics({pluginName, userId: uid, config, extra: diagnostics});
576
+ });
577
+
578
+ /**
579
+ * GET /image-senders - the senders whose images always load.
580
+ */
581
+ fastify.get('/image-senders', {preHandler: [authenticate]}, async (request, reply) => {
582
+ const ctx = await context(request, reply);
583
+ if (!ctx) return;
584
+ return {
585
+ accountId: ctx.account.id,
586
+ remember: ctx.account.rememberImageSenders !== false,
587
+ senders: Array.isArray(ctx.account.imageSenders) ? ctx.account.imageSenders : []
588
+ };
589
+ });
590
+
591
+ /**
592
+ * POST /image-senders - trust a sender's images from now on.
593
+ */
594
+ fastify.post('/image-senders', {preHandler: [authenticate]}, async (request, reply) => {
595
+ const ctx = await context(request, reply);
596
+ if (!ctx) return;
597
+
598
+ if (ctx.account.rememberImageSenders === false) {
599
+ return reply.code(409).send({error: 'Remembering senders is switched off for this mailbox.'});
600
+ }
601
+
602
+ const address = String(request.body?.address ?? '').trim();
603
+ if (!address) return reply.code(400).send({error: 'A sender address is required.'});
604
+
605
+ const saved = await accounts.trustImageSender(ctx.uid, ctx.account.id, address);
606
+ if (!saved) return reply.code(404).send({error: 'That mailbox does not exist.'});
607
+ return {senders: saved.imageSenders ?? []};
608
+ });
609
+
610
+ /**
611
+ * DELETE /image-senders - stop trusting one.
612
+ */
613
+ fastify.delete('/image-senders', {preHandler: [authenticate]}, async (request, reply) => {
614
+ const ctx = await context(request, reply);
615
+ if (!ctx) return;
616
+ const address = String(request.query.address ?? '').trim();
617
+ if (!address) return reply.code(400).send({error: 'A sender address is required.'});
618
+
619
+ const saved = await accounts.untrustImageSender(ctx.uid, ctx.account.id, address);
620
+ if (!saved) return reply.code(404).send({error: 'That mailbox does not exist.'});
621
+ return {senders: saved.imageSenders ?? []};
622
+ });
623
+
624
+ /**
625
+ * PUT /image-senders/remember - turn the whole feature on or off.
626
+ *
627
+ * Switching it off ignores the list without discarding it: turning it back
628
+ * on should not mean rebuilding something someone curated.
629
+ */
630
+ fastify.put('/image-senders/remember', {preHandler: [authenticate]}, async (request, reply) => {
631
+ const ctx = await context(request, reply);
632
+ if (!ctx) return;
633
+ const saved = await accounts.setRememberImageSenders(ctx.uid, ctx.account.id, request.body?.remember !== false);
634
+ if (!saved) return reply.code(404).send({error: 'That mailbox does not exist.'});
635
+ return {remember: saved.rememberImageSenders !== false, senders: saved.imageSenders ?? []};
636
+ });
637
+
638
+ /**
639
+ * GET /messages/:uid/attachments/:index - download one part.
640
+ *
641
+ * Re-fetches and re-parses the message rather than caching it. Phase 1
642
+ * keeps no message store at all, and correctness beats a second copy of
643
+ * someone's mail sitting in memory.
644
+ */
645
+ fastify.get('/messages/:uid/attachments/:index', {preHandler: [authenticate]}, async (request, reply) => {
646
+ const ctx = await context(request, reply);
647
+ if (!ctx) return;
648
+
649
+ const folder = String(request.query.folder || 'INBOX');
650
+ const uid = Number(request.params.uid);
651
+ const index = Number(request.params.index);
652
+ if (!Number.isInteger(uid) || !Number.isInteger(index) || index < 0) {
653
+ return reply.code(400).send({error: 'Invalid attachment reference.'});
654
+ }
655
+
656
+ return imap(reply, () => pool.withMailbox(ctx.poolKey, ctx.connection, folder, async (client) => {
657
+ const fetched = await client.fetchOne(uid, {source: true}, {uid: true});
658
+ if (!fetched || !fetched.source) {
659
+ reply.code(404).send({error: 'That message is no longer in this folder.'});
660
+ return null;
661
+ }
662
+
663
+ const parsed = await simpleParser(fetched.source);
664
+ const attachment = parsed.attachments?.[index];
665
+ if (!attachment) {
666
+ reply.code(404).send({error: 'That attachment is not on this message.'});
667
+ return null;
668
+ }
669
+
670
+ const filename = (attachment.filename || `attachment-${index + 1}`).replace(/["\\\r\n]/g, '_');
671
+ reply
672
+ .header('Content-Type', attachment.contentType || 'application/octet-stream')
673
+ // Always an attachment, never inline: nothing from a stranger's
674
+ // mail should render in the admin's own origin.
675
+ .header('Content-Disposition', `attachment; filename="${filename}"`)
676
+ .header('X-Content-Type-Options', 'nosniff');
677
+ return reply.send(attachment.content);
678
+ }));
679
+ });
680
+ }