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,293 @@
1
+ /**
2
+ * IMAP connection pool for the Mail Reader plugin.
3
+ *
4
+ * IMAP is unlike anything else the CMS talks to: the connection is long-lived,
5
+ * stateful, and handles exactly one command at a time. Opening a fresh
6
+ * connection per request would mean a TLS handshake and a LOGIN for every
7
+ * click, and most mail servers rate-limit that fairly aggressively.
8
+ *
9
+ * So connections are cached per user and reaped when idle. All mailbox access
10
+ * goes through `withMailbox()`, which takes ImapFlow's own mailbox lock, so
11
+ * two overlapping requests from the same user queue rather than corrupting
12
+ * each other's command stream.
13
+ *
14
+ * ## Read-only, twice over
15
+ *
16
+ * Every mailbox is opened `{readOnly: true}` and ImapFlow fetches with
17
+ * `BODY.PEEK[]` (see its commands/fetch.js). Either alone would stop the
18
+ * server setting `\Seen` as a side effect of reading a message; phase 1 has
19
+ * both, because a reader that silently marks a user's mail as read on their
20
+ * phone is worse than no reader at all. Do not relax this without making
21
+ * mark-as-read a deliberate, user-visible action.
22
+ *
23
+ * **Single-process only.** The pool is module state, exactly like the messages
24
+ * plugin's SSE registry. Running the site across workers would give each
25
+ * worker its own connections and blow through per-user connection limits.
26
+ *
27
+ * @module mail-reader/services/imapPool
28
+ */
29
+ import {ImapFlow} from 'imapflow';
30
+
31
+ /**
32
+ * Identify a connection's target. A change to any of these means the cached
33
+ * connection is for a different account and must be thrown away.
34
+ *
35
+ * @param {object} connection
36
+ * @returns {string}
37
+ */
38
+ function fingerprint(connection) {
39
+ return [connection.host, connection.port, connection.secure, connection.auth.user].join('|');
40
+ }
41
+
42
+ /**
43
+ * Create an IMAP connection pool.
44
+ *
45
+ * @param {{connectionIdleMs: number, maxConnections: number, connectionTimeoutMs: number, allowInsecureTLS: boolean}} config
46
+ * @returns {object} pool API
47
+ */
48
+ export function createPool(config) {
49
+ /** @type {Map<string, {client: ImapFlow, fingerprint: string, lastUsed: number}>} */
50
+ const pool = new Map();
51
+
52
+ /** In-flight connects, so two parallel requests do not open two connections. */
53
+ const pending = new Map();
54
+
55
+ /**
56
+ * Build the ImapFlow options for a connection.
57
+ *
58
+ * @param {object} connection
59
+ * @returns {object}
60
+ */
61
+ function clientOptions(connection) {
62
+ return {
63
+ host: connection.host,
64
+ port: connection.port,
65
+ secure: connection.secure,
66
+ auth: connection.auth,
67
+ logger: false,
68
+ // A dead mail server should fail the request, not hang the admin.
69
+ greetingTimeout: config.connectionTimeoutMs,
70
+ socketTimeout: config.connectionTimeoutMs,
71
+ connectionTimeout: config.connectionTimeoutMs,
72
+ tls: {rejectUnauthorized: !config.allowInsecureTLS}
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Drop a pooled connection, closing it best-effort.
78
+ *
79
+ * @param {string} userId
80
+ * @returns {void}
81
+ */
82
+ function evict(userId) {
83
+ const entry = pool.get(userId);
84
+ if (!entry) return;
85
+ pool.delete(userId);
86
+ entry.client.logout().catch(() => entry.client.close());
87
+ }
88
+
89
+ /**
90
+ * Get a live, authenticated client for a user.
91
+ *
92
+ * @param {string} userId
93
+ * @param {object} connection
94
+ * @returns {Promise<ImapFlow>}
95
+ */
96
+ async function acquire(userId, connection) {
97
+ const fp = fingerprint(connection);
98
+ const existing = pool.get(userId);
99
+
100
+ if (existing && existing.fingerprint === fp && existing.client.usable) {
101
+ existing.lastUsed = Date.now();
102
+ return existing.client;
103
+ }
104
+ // Stale, or the account details changed under it.
105
+ if (existing) evict(userId);
106
+
107
+ const inFlight = pending.get(userId);
108
+ if (inFlight) return inFlight;
109
+
110
+ if (pool.size >= config.maxConnections) {
111
+ // Evict the least recently used rather than refusing the request -
112
+ // the cap exists to bound resource use, not to lock people out.
113
+ const oldest = [...pool.entries()].sort((a, b) => a[1].lastUsed - b[1].lastUsed)[0];
114
+ if (oldest) evict(oldest[0]);
115
+ }
116
+
117
+ const connect = (async () => {
118
+ const client = new ImapFlow(clientOptions(connection));
119
+ // ImapFlow emits 'error' on an already-failed connection; without a
120
+ // listener that becomes an unhandled event and takes the process out.
121
+ client.on('error', () => evict(userId));
122
+ client.on('close', () => {
123
+ if (pool.get(userId)?.client === client) pool.delete(userId);
124
+ });
125
+ await client.connect();
126
+ pool.set(userId, {client, fingerprint: fp, lastUsed: Date.now()});
127
+ return client;
128
+ })();
129
+
130
+ pending.set(userId, connect);
131
+ try {
132
+ return await connect;
133
+ } finally {
134
+ pending.delete(userId);
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Run a function against a user's connection, no mailbox selected.
140
+ *
141
+ * @param {string} userId
142
+ * @param {object} connection
143
+ * @param {(client: ImapFlow) => Promise<any>} fn
144
+ * @returns {Promise<any>}
145
+ */
146
+ async function withClient(userId, connection, fn) {
147
+ const client = await acquire(userId, connection);
148
+ try {
149
+ const result = await fn(client);
150
+ const entry = pool.get(userId);
151
+ if (entry) entry.lastUsed = Date.now();
152
+ return result;
153
+ } catch (err) {
154
+ evict(userId);
155
+ throw err;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Run a function with a mailbox open and locked.
161
+ *
162
+ * Read-only unless asked otherwise, and the asking is deliberate: the free
163
+ * reader's entire guarantee is that looking at mail never changes it, and
164
+ * a default of read-write would quietly break that the first time a shared
165
+ * helper forgot the option. Only the Pro edition's write operations pass
166
+ * `writable`, and only for the one call that needs it.
167
+ *
168
+ * @param {string} userId
169
+ * @param {object} connection
170
+ * @param {string} mailbox
171
+ * @param {(client: ImapFlow, mailbox: object) => Promise<any>} fn
172
+ * @param {{writable?: boolean}} [options]
173
+ * @returns {Promise<any>}
174
+ */
175
+ async function withMailbox(userId, connection, mailbox, fn, {writable = false} = {}) {
176
+ return withClient(userId, connection, async (client) => {
177
+ const lock = await client.getMailboxLock(mailbox, {readOnly: !writable});
178
+ try {
179
+ return await fn(client, client.mailbox);
180
+ } finally {
181
+ lock.release();
182
+ }
183
+ });
184
+ }
185
+
186
+ /**
187
+ * One-shot connect used by the "Test connection" button.
188
+ *
189
+ * Deliberately not pooled: it runs against details that have not been
190
+ * saved yet, and a failed attempt should leave nothing behind.
191
+ *
192
+ * @param {object} connection
193
+ * @returns {Promise<{ok: true, greeting: string|null} | {ok: false, error: string}>}
194
+ */
195
+ async function test(connection) {
196
+ const client = new ImapFlow(clientOptions(connection));
197
+ client.on('error', () => {});
198
+ try {
199
+ await client.connect();
200
+ const greeting = client.serverInfo?.greeting ?? null;
201
+ await client.logout();
202
+ return {ok: true, greeting};
203
+ } catch (err) {
204
+ try {
205
+ client.close();
206
+ } catch {
207
+ // Already down - nothing to close.
208
+ }
209
+ return {ok: false, error: describeError(err)};
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Close a specific user's connection - used when their account changes or
215
+ * is deleted, so the next request does not reuse the old credentials.
216
+ *
217
+ * @param {string} userId
218
+ * @returns {void}
219
+ */
220
+ function release(userId) {
221
+ evict(userId);
222
+ }
223
+
224
+ /**
225
+ * Close every connection a user holds, whatever mailbox it belongs to.
226
+ *
227
+ * Keys are `userId:accountId`, so this is a prefix sweep. Used when a user
228
+ * is deleted - their sockets should not outlive their account either.
229
+ *
230
+ * @param {string} userId
231
+ * @returns {void}
232
+ */
233
+ function releaseUser(userId) {
234
+ const prefix = `${userId}:`;
235
+ for (const key of [...pool.keys()]) {
236
+ if (key === userId || key.startsWith(prefix)) evict(key);
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Close everything. Called on plugin disable and server shutdown.
242
+ *
243
+ * @returns {void}
244
+ */
245
+ function closeAll() {
246
+ for (const userId of [...pool.keys()]) evict(userId);
247
+ }
248
+
249
+ const reaper = setInterval(() => {
250
+ const cutoff = Date.now() - config.connectionIdleMs;
251
+ for (const [userId, entry] of pool) {
252
+ if (entry.lastUsed < cutoff) evict(userId);
253
+ }
254
+ }, Math.max(30_000, Math.floor(config.connectionIdleMs / 2)));
255
+ // Never hold the process open on account of an idle mail connection.
256
+ reaper.unref?.();
257
+
258
+ return {withClient, withMailbox, test, release, releaseUser, closeAll, get size() {
259
+ return pool.size;
260
+ }};
261
+ }
262
+
263
+ /**
264
+ * Turn an IMAP/network error into something worth showing a user.
265
+ *
266
+ * ImapFlow's own messages are decent but its auth failures surface as raw
267
+ * server responses, which vary by server and read like a stack trace.
268
+ *
269
+ * @param {Error & {code?: string, authenticationFailed?: boolean}} err
270
+ * @returns {string}
271
+ */
272
+ export function describeError(err) {
273
+ if (!err) return 'Unknown error.';
274
+ if (err.authenticationFailed) return 'The mail server rejected that username and password.';
275
+
276
+ switch (err.code) {
277
+ case 'ENOTFOUND':
278
+ case 'EAI_AGAIN':
279
+ return 'That mail server hostname could not be resolved.';
280
+ case 'ECONNREFUSED':
281
+ return 'The mail server refused the connection on that port.';
282
+ case 'ETIMEDOUT':
283
+ case 'ECONNRESET':
284
+ return 'The mail server did not respond in time.';
285
+ case 'CERT_HAS_EXPIRED':
286
+ case 'DEPTH_ZERO_SELF_SIGNED_CERT':
287
+ case 'SELF_SIGNED_CERT_IN_CHAIN':
288
+ case 'ERR_TLS_CERT_ALTNAME_INVALID':
289
+ return 'The mail server’s TLS certificate could not be verified.';
290
+ default:
291
+ return err.message || 'The mail server connection failed.';
292
+ }
293
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Exporting a folder as mbox.
3
+ *
4
+ * The point of this feature is that it is not a feature of this plugin: an
5
+ * mbox file opens in Thunderbird, in mutt, in Apple Mail and in anything else
6
+ * that has ever handled mail, so a mailbox mirrored here is not a mailbox
7
+ * held hostage here. That matters more for a CMS plugin than for a mail
8
+ * client, because the plugin can be disabled.
9
+ *
10
+ * ## The "From " line, and why escaping it matters
11
+ *
12
+ * mbox separates messages with a line beginning `From `. Any line in a message
13
+ * body that also begins `From ` would therefore split it in two - so those
14
+ * lines get a `>` in front, which is the mboxrd convention. Readers undo it on
15
+ * the way back in. Getting this wrong does not corrupt the file visibly; it
16
+ * silently turns one message into two, one of which has no headers.
17
+ *
18
+ * @module _lib/mail/mbox
19
+ */
20
+ import {bareAddress} from '../admin/mail/identity.js';
21
+
22
+ /**
23
+ * The separator line that introduces a message.
24
+ *
25
+ * The address is the envelope sender, and the date format is asctime - not
26
+ * RFC 5322, and not ISO. This line is not a header; it predates all of them,
27
+ * and readers parse it by shape.
28
+ *
29
+ * @param {string} address
30
+ * @param {Date} date
31
+ * @returns {string}
32
+ */
33
+ export function fromLine(address, date = new Date()) {
34
+ // Through `bareAddress`, not by stripping the punctuation: removing every
35
+ // space and angle bracket from `Sales <s@e.com>` gives `Saless@e.com`,
36
+ // which is a separator line naming an address that does not exist. The
37
+ // remaining strip is a backstop for anything `bareAddress` leaves that
38
+ // would still break the line.
39
+ const clean = bareAddress(address).replace(/\s+/g, '') || 'MAILER-DAEMON';
40
+ const when = Number.isNaN(date?.getTime?.()) ? new Date() : date;
41
+ // asctime, in C locale, always: "Sun Sep 20 14:30:00 2026".
42
+ const asctime = when.toUTCString()
43
+ .replace(/^(\w{3}), (\d{2}) (\w{3}) (\d{4}) (\d{2}:\d{2}:\d{2}) GMT$/, '$1 $3 $2 $5 $4');
44
+ return `From ${clean} ${asctime}`;
45
+ }
46
+
47
+ /**
48
+ * Escape the body lines that would otherwise look like a separator.
49
+ *
50
+ * mboxrd: `From ` becomes `>From `, and an already-escaped `>From ` becomes
51
+ * `>>From `, so the transformation can be undone exactly.
52
+ *
53
+ * @param {string} text
54
+ * @returns {string}
55
+ */
56
+ export function escapeFromLines(text) {
57
+ return String(text ?? '').replace(/^(>*From )/gm, '>$1');
58
+ }
59
+
60
+ /**
61
+ * One message, as it appears in an mbox file.
62
+ *
63
+ * @param {Buffer|string} source - the raw RFC822 message
64
+ * @param {{address?: string, date?: Date}} [envelope]
65
+ * @returns {string}
66
+ */
67
+ export function mboxEntry(source, {address = '', date = new Date()} = {}) {
68
+ const text = Buffer.isBuffer(source) ? source.toString('utf8') : String(source ?? '');
69
+ // Normalised to LF. mbox is a line-oriented text format and mixing CRLF
70
+ // bodies into it is what makes some readers show a blank line between
71
+ // every line of every message.
72
+ const body = escapeFromLines(text.replace(/\r\n/g, '\n'));
73
+ return `${fromLine(address, date)}\n${body}${body.endsWith('\n') ? '' : '\n'}\n`;
74
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Deciding which mailboxes are due a poll.
3
+ *
4
+ * Pure, and separate from the timer that acts on it, because the interesting
5
+ * behaviour here is not "call sync every N minutes" - it is what happens when
6
+ * a mail server is down, or slow, or rate-limiting, and the obvious
7
+ * implementation keeps hammering it every N minutes forever.
8
+ *
9
+ * @module _lib/mail/pollSchedule
10
+ */
11
+
12
+ /** Multiplier applied per consecutive failure. */
13
+ const BACKOFF_FACTOR = 2;
14
+
15
+ /** However bad things get, try again at least this often. */
16
+ export const MAX_BACKOFF_MS = 3_600_000;
17
+
18
+ /**
19
+ * How long to wait before the next attempt for one mailbox.
20
+ *
21
+ * Doubles per consecutive failure and is capped, so a mailbox whose server is
22
+ * down backs off towards hourly instead of knocking every few minutes for a
23
+ * week - which is how an IP ends up rate-limited or blocked.
24
+ *
25
+ * @param {number} intervalMs - the mailbox's configured interval
26
+ * @param {number} failures - consecutive failures so far
27
+ * @returns {number}
28
+ */
29
+ export function backoffFor(intervalMs, failures) {
30
+ if (!failures) return intervalMs;
31
+ const grown = intervalMs * Math.pow(BACKOFF_FACTOR, Math.min(failures, 10));
32
+ return Math.min(grown, MAX_BACKOFF_MS);
33
+ }
34
+
35
+ /**
36
+ * Which mailboxes should be polled now.
37
+ *
38
+ * A mailbox never polled before is due immediately: the alternative is that
39
+ * adding one shows an empty list until the first interval elapses, which
40
+ * reads as broken.
41
+ *
42
+ * @param {object[]} accounts
43
+ * @param {Map<string, {lastAttemptAt?: number, failures?: number, running?: boolean}>} state
44
+ * @param {{now: number, defaultMinutes: number}} options
45
+ * @returns {{id: string, waitedMs: number|null}[]} due mailboxes, most overdue first
46
+ */
47
+ export function selectDuePolls(accounts, state, {now, defaultMinutes}) {
48
+ const due = [];
49
+
50
+ for (const account of accounts) {
51
+ const entry = state.get(account.id) ?? {};
52
+
53
+ // A pass already running must never be started again: two syncs of one
54
+ // folder race on the same UID range and the later write wins twice.
55
+ if (entry.running) continue;
56
+
57
+ const minutes = Number.isFinite(account.pollIntervalMinutes) && account.pollIntervalMinutes > 0
58
+ ? account.pollIntervalMinutes
59
+ : defaultMinutes;
60
+ const intervalMs = minutes * 60_000;
61
+
62
+ if (!entry.lastAttemptAt) {
63
+ due.push({id: account.id, waitedMs: null});
64
+ continue;
65
+ }
66
+
67
+ const wait = backoffFor(intervalMs, entry.failures ?? 0);
68
+ const waited = now - entry.lastAttemptAt;
69
+ if (waited >= wait) due.push({id: account.id, waitedMs: waited});
70
+ }
71
+
72
+ // Never-polled mailboxes first, then whoever has waited longest - so a
73
+ // backlog drains fairly rather than always favouring the same mailbox.
74
+ return due.sort((a, b) => {
75
+ if (a.waitedMs === null) return b.waitedMs === null ? 0 : -1;
76
+ if (b.waitedMs === null) return 1;
77
+ return b.waitedMs - a.waitedMs;
78
+ });
79
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The background poller.
3
+ *
4
+ * One ticker for the whole plugin rather than a timer per mailbox: mailboxes
5
+ * come and go while the process runs, and a per-mailbox timer means tracking
6
+ * and cancelling each one. A single tick asks `selectDuePolls()` who is due
7
+ * and works through them.
8
+ *
9
+ * The scheduling decision - including the backoff that stops a dead mail
10
+ * server being knocked every few minutes for a week - lives in
11
+ * `pollSchedule.js`, pure and tested there.
12
+ *
13
+ * @module _lib/mail/poller
14
+ */
15
+ import {selectDuePolls} from './pollSchedule.js';
16
+
17
+ /** How often the ticker looks for work. Finer than any poll interval. */
18
+ const TICK_MS = 30_000;
19
+
20
+ /**
21
+ * Create a poller.
22
+ *
23
+ * @param {{listAccounts: Function, syncAccount: Function, defaultMinutes: number,
24
+ * logger: object, tickMs?: number}} deps
25
+ * @returns {{start: Function, stop: Function, state: Function, pollNow: Function}}
26
+ */
27
+ export function createPoller({listAccounts, syncAccount, defaultMinutes, logger, tickMs = TICK_MS}) {
28
+ /** @type {Map<string, {lastAttemptAt?: number, failures?: number, running?: boolean, lastError?: string|null}>} */
29
+ const state = new Map();
30
+ let timer = null;
31
+ let stopped = false;
32
+
33
+ /**
34
+ * Sync one mailbox, recording whether it worked.
35
+ *
36
+ * @param {object} account
37
+ * @returns {Promise<void>}
38
+ */
39
+ async function runOne(account) {
40
+ const entry = state.get(account.id) ?? {};
41
+ state.set(account.id, {...entry, running: true, lastAttemptAt: Date.now()});
42
+
43
+ try {
44
+ const result = await syncAccount(account);
45
+ state.set(account.id, {
46
+ lastAttemptAt: Date.now(), failures: 0, running: false, lastError: null,
47
+ lastResult: result
48
+ });
49
+ } catch (err) {
50
+ const failures = (entry.failures ?? 0) + 1;
51
+ state.set(account.id, {
52
+ lastAttemptAt: Date.now(), failures, running: false, lastError: err.message
53
+ });
54
+ // Warn, not error: a mail server being unreachable is an ordinary
55
+ // condition, and the backoff already handles it.
56
+ logger.warn(
57
+ `[mail] poll failed for mailbox ${account.id} (${failures} in a row): ${err.message}`
58
+ );
59
+ }
60
+ }
61
+
62
+ /**
63
+ * One pass: find who is due and sync them, one at a time.
64
+ *
65
+ * Sequential on purpose. Polling every mailbox at once means opening every
66
+ * connection at once, which is exactly the burst a mail server throttles.
67
+ *
68
+ * @returns {Promise<void>}
69
+ */
70
+ async function tick() {
71
+ if (stopped) return;
72
+
73
+ let accounts;
74
+ try {
75
+ accounts = await listAccounts();
76
+ } catch (err) {
77
+ logger.warn(`[mail] could not list mailboxes to poll: ${err.message}`);
78
+ return;
79
+ }
80
+
81
+ // Forget state for mailboxes that have been removed, so the map does
82
+ // not grow for the life of the process.
83
+ const live = new Set(accounts.map(a => a.id));
84
+ for (const id of [...state.keys()]) {
85
+ if (!live.has(id)) state.delete(id);
86
+ }
87
+
88
+ const due = selectDuePolls(accounts, state, {now: Date.now(), defaultMinutes});
89
+ for (const {id} of due) {
90
+ if (stopped) return;
91
+ const account = accounts.find(a => a.id === id);
92
+ if (account) await runOne(account);
93
+ }
94
+ }
95
+
96
+ return {
97
+ /**
98
+ * @returns {void}
99
+ */
100
+ start() {
101
+ if (timer) return;
102
+ stopped = false;
103
+ timer = setInterval(() => {
104
+ tick().catch(err => logger.warn(`[mail] poll tick failed: ${err.message}`));
105
+ }, tickMs);
106
+ // Never hold the process open for a poll.
107
+ timer.unref?.();
108
+ },
109
+
110
+ /**
111
+ * @returns {void}
112
+ */
113
+ stop() {
114
+ stopped = true;
115
+ if (timer) clearInterval(timer);
116
+ timer = null;
117
+ },
118
+
119
+ /**
120
+ * Run a pass immediately, for a manual "sync now".
121
+ *
122
+ * @returns {Promise<void>}
123
+ */
124
+ pollNow: tick,
125
+
126
+ /**
127
+ * What the poller currently believes about each mailbox.
128
+ *
129
+ * @returns {object}
130
+ */
131
+ state() {
132
+ return Object.fromEntries(state);
133
+ }
134
+ };
135
+ }