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,292 @@
1
+ /**
2
+ * Bringing a folder's local mirror up to date.
3
+ *
4
+ * The decision lives in `syncPlan.js`, which is pure and tested on its own.
5
+ * This is the half that talks to IMAP and MongoDB and carries the plan out.
6
+ *
7
+ * ## Still read-only
8
+ *
9
+ * Sync opens mailboxes exactly as the reader does - `EXAMINE`, and fetches
10
+ * through `BODY.PEEK[]` - so mirroring a mailbox never marks anything read.
11
+ * Writing flags is a separate, deliberate operation that does not belong here.
12
+ *
13
+ * ## Envelopes only
14
+ *
15
+ * Nothing in this module fetches a message body. The mirror is what a list
16
+ * view needs; bodies are fetched per view and rendered through the sanitiser,
17
+ * so a compromised store leaks metadata rather than correspondence.
18
+ *
19
+ * @module _lib/mail/sync
20
+ */
21
+ import * as accounts from './accounts.js';
22
+ import {applyRules, eligible} from './ruleRunner.js';
23
+ import {runVacation} from './vacationRunner.js';
24
+ import {missingUids, planFolderSync} from './syncPlan.js';
25
+ import {resolveSyncFolders} from './syncSelection.js';
26
+ import {ENVELOPE_QUERY, toRecord} from './envelope.js';
27
+
28
+ /**
29
+ * Sync one folder.
30
+ *
31
+ * @param {object} client - a connected ImapFlow, mailbox already locked
32
+ * @param {object} store
33
+ * `collectAdded` asks for the records of whatever was newly fetched, so the
34
+ * caller can run filing rules over them. Off by default and capped, because
35
+ * the whole point of batching is not to hold a folder in memory - a mailbox
36
+ * catching up on ten thousand messages must not build a ten thousand element
37
+ * array to decide none of them matched a rule.
38
+ *
39
+ * @param {{accountId: string, path: string, batchSize?: number, now?: number,
40
+ * collectAdded?: boolean, collectLimit?: number}} options
41
+ * @returns {Promise<{mode: string, reason: string, added: number, flagged: number,
42
+ * removed: number, addedMessages: object[]}>}
43
+ */
44
+ export async function syncFolder(client, store, {
45
+ accountId, path, batchSize = 500, now = Date.now(),
46
+ collectAdded = false, collectLimit = 200
47
+ }) {
48
+ const mailbox = client.mailbox;
49
+ const server = {
50
+ uidValidity: mailbox.uidValidity,
51
+ uidNext: mailbox.uidNext,
52
+ exists: mailbox.exists,
53
+ highestModseq: mailbox.highestModseq ?? null
54
+ };
55
+
56
+ const stored = await store.getFolder(accountId, path);
57
+ const plan = planFolderSync(stored, server, {now});
58
+
59
+ const result = {mode: plan.mode, reason: plan.reason, added: 0, flagged: 0, removed: 0, addedMessages: []};
60
+ if (plan.mode === 'none') return result;
61
+
62
+ // Never collected on a full resync: that is the whole folder arriving
63
+ // again, not new mail, and filing it would move an archive.
64
+ const collect = collectAdded && plan.mode !== 'full' ? result.addedMessages : null;
65
+
66
+ if (plan.mode === 'full') {
67
+ // The stored UIDs are meaningless now, so they go before anything new
68
+ // arrives - leaving them would mix two generations of the same folder.
69
+ await store.clearFolder(accountId, path);
70
+ result.added = await fetchRange(client, store, accountId, path, '1:*', batchSize);
71
+ } else {
72
+ if (plan.fetchFrom !== null) {
73
+ result.added = await fetchRange(
74
+ client, store, accountId, path, `${plan.fetchFrom}:*`, batchSize,
75
+ collect, collectLimit,
76
+ // The floor is the point. In an IMAP UID range `*` is the
77
+ // HIGHEST EXISTING UID, not infinity - so `101:*` on a folder
78
+ // whose highest UID is 100 is read as `100:101` and returns
79
+ // UID 100, a message that was already here. Harmless for the
80
+ // mirror, which just rewrites a row it already had; not
81
+ // harmless for rules, which would fire again on mail the user
82
+ // had deliberately left alone.
83
+ plan.fetchFrom
84
+ );
85
+ }
86
+
87
+ if (plan.flagScan !== 'none') {
88
+ result.flagged = await scanFlags(client, store, accountId, path, plan);
89
+ }
90
+
91
+ if (plan.reconcile) {
92
+ result.removed = await reconcile(client, store, accountId, path);
93
+ }
94
+ }
95
+
96
+ await store.saveFolder(accountId, path, {
97
+ uidValidity: String(server.uidValidity),
98
+ uidNext: Number(server.uidNext),
99
+ exists: Number(server.exists),
100
+ highestModseq: server.highestModseq ? String(server.highestModseq) : null,
101
+ lastSyncAt: new Date(now),
102
+ ...(plan.mode === 'full' || plan.reconcile ? {lastReconcileAt: now} : {})
103
+ });
104
+
105
+ return result;
106
+ }
107
+
108
+ /**
109
+ * Fetch envelopes for a UID range, writing them in batches.
110
+ *
111
+ * Batched so a mailbox with tens of thousands of messages does not build one
112
+ * enormous array before anything is stored.
113
+ *
114
+ * @param {object} client
115
+ * @param {object} store
116
+ * @param {string} accountId
117
+ * @param {string} path
118
+ * @param {string} range
119
+ * @param {number} batchSize
120
+ * @param {object[]|null} [collect] - records are pushed here when given
121
+ * @param {number} [collectLimit]
122
+ * @param {number} [collectFrom] - only collect UIDs at or above this
123
+ * @returns {Promise<number>}
124
+ */
125
+ async function fetchRange(
126
+ client, store, accountId, path, range, batchSize,
127
+ collect = null, collectLimit = 200, collectFrom = 0
128
+ ) {
129
+ let batch = [];
130
+ let written = 0;
131
+
132
+ for await (const message of client.fetch(range, ENVELOPE_QUERY, {uid: true})) {
133
+ const record = toRecord(message);
134
+ // Collected only when it is genuinely new. See the note at the call
135
+ // site: an IMAP `n:*` range can hand back a UID below `n`.
136
+ if (collect && collect.length < collectLimit && record.uid >= collectFrom) {
137
+ collect.push(record);
138
+ }
139
+ batch.push(record);
140
+ if (batch.length >= batchSize) {
141
+ await store.putMessages(accountId, path, batch);
142
+ written += batch.length;
143
+ batch = [];
144
+ }
145
+ }
146
+
147
+ if (batch.length) {
148
+ await store.putMessages(accountId, path, batch);
149
+ written += batch.length;
150
+ }
151
+ return written;
152
+ }
153
+
154
+ /**
155
+ * Bring stored flags back in line with the server's.
156
+ *
157
+ * @param {object} client
158
+ * @param {object} store
159
+ * @param {string} accountId
160
+ * @param {string} path
161
+ * @param {object} plan
162
+ * @returns {Promise<number>}
163
+ */
164
+ async function scanFlags(client, store, accountId, path, plan) {
165
+ const query = {uid: true, flags: true};
166
+ const options = {uid: true};
167
+ // CONDSTORE lets the server send only what moved, which on a large folder
168
+ // is the difference between a few rows and all of them.
169
+ if (plan.flagScan === 'changed' && plan.changedSince) {
170
+ options.changedSince = BigInt(plan.changedSince);
171
+ }
172
+
173
+ const updates = [];
174
+ for await (const message of client.fetch('1:*', query, options)) {
175
+ updates.push({uid: message.uid, flags: [...(message.flags ?? [])]});
176
+ }
177
+ return store.setFlags(accountId, path, updates);
178
+ }
179
+
180
+ /**
181
+ * Drop messages the server no longer lists.
182
+ *
183
+ * @param {object} client
184
+ * @param {object} store
185
+ * @param {string} accountId
186
+ * @param {string} path
187
+ * @returns {Promise<number>}
188
+ */
189
+ async function reconcile(client, store, accountId, path) {
190
+ const onServer = [];
191
+ for await (const message of client.fetch('1:*', {uid: true}, {uid: true})) {
192
+ onServer.push(message.uid);
193
+ }
194
+
195
+ const stored = await store.storedUids(accountId, path);
196
+ const gone = missingUids(stored, onServer);
197
+ return store.removeMessages(accountId, path, gone);
198
+ }
199
+
200
+ /**
201
+ * Mirror every folder one mailbox has chosen.
202
+ *
203
+ * Shared by the manual "sync now" route and the background poller, so the two
204
+ * cannot drift into disagreeing about what a sync pass means.
205
+ *
206
+ * @param {{pool: object, store: object, account: object, config: object}} deps
207
+ * @returns {Promise<{selection: string, folders: Object.<string, object>}>}
208
+ */
209
+ export async function syncAccount({pool, store, account, config}) {
210
+ const connection = accounts.toConnection(account);
211
+ const poolKey = `${account.userId}:${account.id}`;
212
+
213
+ const list = await pool.withClient(poolKey, connection, client => client.list());
214
+ const folders = list
215
+ .filter(box => !box.flags?.has('\\Noselect'))
216
+ .map(box => ({path: box.path, specialUse: box.specialUse ?? null}));
217
+
218
+ const {paths, source} = resolveSyncFolders(account, folders);
219
+ const results = {};
220
+
221
+ const rules = accounts.rulesToPublic(account).filter(rule => rule.enabled);
222
+ const ruleFolders = new Set(rules.length ? accounts.ruleFolders(account) : []);
223
+
224
+ // The away message answers mail arriving in the same folders rules run
225
+ // over - which is the inbox by default. Answering mail that arrived in
226
+ // Sent, or in an archive a rule had just filed it into, is not what
227
+ // "I am away" means.
228
+ const away = accounts.vacationToPublic(account);
229
+ const awayFolders = new Set(away.enabled ? accounts.ruleFolders(account) : []);
230
+
231
+ for (const folderPath of paths) {
232
+ const filed = ruleFolders.has(folderPath) || awayFolders.has(folderPath);
233
+ try {
234
+ results[folderPath] = await pool.withMailbox(poolKey, connection, folderPath,
235
+ client => syncFolder(client, store, {
236
+ accountId: account.id,
237
+ path: folderPath,
238
+ batchSize: config.syncBatchSize,
239
+ collectAdded: filed
240
+ }));
241
+ } catch (err) {
242
+ // One bad folder must not abandon the rest of the mailbox.
243
+ results[folderPath] = {mode: 'error', reason: err.message};
244
+ continue;
245
+ }
246
+
247
+ // Outside the `withMailbox` above, deliberately: filing a message
248
+ // opens its destination on the same connection, and ImapFlow queues
249
+ // mailbox locks per client - doing it inside would wait on a lock
250
+ // this very call is holding.
251
+ if (!filed) continue;
252
+ const candidates = eligible(results[folderPath].addedMessages, results[folderPath], account.rulesFrom ?? null);
253
+ if (!candidates.length) continue;
254
+
255
+ if (ruleFolders.has(folderPath)) {
256
+ try {
257
+ results[folderPath].rules = await applyRules({
258
+ pool, store, account, poolKey, connection,
259
+ folder: folderPath, messages: candidates, rules
260
+ });
261
+ } catch (err) {
262
+ results[folderPath].rules = {error: err.message};
263
+ }
264
+ }
265
+
266
+ // After the rules, on the same candidates. A message a rule has just
267
+ // filed into Junk should not also get a cheerful note saying when its
268
+ // sender can expect a reply.
269
+ if (awayFolders.has(folderPath)) {
270
+ const junked = new Set(
271
+ (results[folderPath].rules?.actions ?? []).map(action => action.uid)
272
+ );
273
+ try {
274
+ results[folderPath].away = await runVacation({
275
+ pool, store, account, poolKey, connection,
276
+ smtp: accounts.toSmtpConnection(account),
277
+ messages: candidates.filter(message => !junked.has(message.uid)),
278
+ config,
279
+ addresses: accounts.sendingAddresses(account)
280
+ });
281
+ } catch (err) {
282
+ results[folderPath].away = {error: err.message};
283
+ }
284
+ }
285
+ }
286
+
287
+ // The collected records have done their job and would otherwise be
288
+ // reported back to a diagnostics screen as a wall of envelopes.
289
+ for (const result of Object.values(results)) delete result.addedMessages;
290
+
291
+ return {selection: source, folders: results};
292
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Deciding what a folder sync needs to do.
3
+ *
4
+ * Kept pure and separate from the IMAP and database calls, because this is
5
+ * where sync gets subtly wrong and a wrong answer is expensive: too eager and
6
+ * every poll refetches a 40,000-message mailbox, too lazy and someone's inbox
7
+ * quietly stops matching their phone.
8
+ *
9
+ * The inputs are what we recorded last time and what the server says now. The
10
+ * output says which of the four things to do, and why - the reason is carried
11
+ * so a log line can explain a resync rather than just performing one.
12
+ *
13
+ * @module _lib/mail/syncPlan
14
+ */
15
+
16
+ /** How long before a folder is reconciled even when nothing looks changed. */
17
+ export const DEFAULT_RECONCILE_INTERVAL_MS = 3_600_000;
18
+
19
+ /**
20
+ * Compare two IMAP 64-bit counters that may arrive as number, string or BigInt.
21
+ *
22
+ * MODSEQ and UIDVALIDITY are unsigned 64-bit, so a driver may hand them over
23
+ * as any of those. Comparing a BigInt with a Number throws in some operators
24
+ * and silently loses precision in others, so everything is normalised first.
25
+ *
26
+ * @param {number|string|bigint|null|undefined} a
27
+ * @param {number|string|bigint|null|undefined} b
28
+ * @returns {number} -1, 0 or 1; 0 when either is absent
29
+ */
30
+ export function compareCounters(a, b) {
31
+ if (a === null || a === undefined || b === null || b === undefined) return 0;
32
+ const left = BigInt(a);
33
+ const right = BigInt(b);
34
+ if (left < right) return -1;
35
+ if (left > right) return 1;
36
+ return 0;
37
+ }
38
+
39
+ /**
40
+ * Work out what this folder needs.
41
+ *
42
+ * @param {object|null} stored - the folder record, or null if never synced
43
+ * @param {{uidValidity: any, uidNext: number, exists: number, highestModseq?: any}} server
44
+ * @param {{now?: number, reconcileIntervalMs?: number}} [options]
45
+ * @returns {{mode: 'full'|'incremental'|'none', reason: string,
46
+ * fetchFrom: number|null, flagScan: 'none'|'full'|'changed',
47
+ * changedSince: any, reconcile: boolean}}
48
+ */
49
+ export function planFolderSync(stored, server, options = {}) {
50
+ const {now = Date.now(), reconcileIntervalMs = DEFAULT_RECONCILE_INTERVAL_MS} = options;
51
+
52
+ const full = reason => ({
53
+ mode: 'full', reason,
54
+ fetchFrom: null, flagScan: 'none', changedSince: null, reconcile: false
55
+ });
56
+
57
+ if (!stored) return full('never synced');
58
+
59
+ // UIDVALIDITY changing means every UID we hold now refers to a different
60
+ // message. The mirror is not stale, it is wrong, and the only correct
61
+ // response is to throw it away.
62
+ if (compareCounters(stored.uidValidity, server.uidValidity) !== 0) {
63
+ return full('UIDVALIDITY changed - stored UIDs refer to different messages');
64
+ }
65
+
66
+ const storedUidNext = Number(stored.uidNext) || 1;
67
+ const serverUidNext = Number(server.uidNext) || 1;
68
+ const hasArrivals = serverUidNext > storedUidNext;
69
+
70
+ // Without CONDSTORE there is no cheap way to ask "what changed", so flags
71
+ // are rescanned wholesale. With it, only what moved past our MODSEQ.
72
+ let flagScan = 'full';
73
+ let changedSince = null;
74
+ if (server.highestModseq && stored.highestModseq) {
75
+ if (compareCounters(server.highestModseq, stored.highestModseq) > 0) {
76
+ flagScan = 'changed';
77
+ changedSince = stored.highestModseq;
78
+ } else {
79
+ flagScan = 'none';
80
+ }
81
+ }
82
+
83
+ // A count that does not match means something was removed - arrivals alone
84
+ // would have raised it. Two arriving and two vanishing leaves the count
85
+ // unchanged, which is why there is also a periodic reconcile: cheap
86
+ // insurance against the case the counters cannot see.
87
+ const countMismatch = Number(server.exists) !== Number(stored.exists ?? -1);
88
+ const lastReconcile = Number(stored.lastReconcileAt ?? 0);
89
+ const reconcileDue = now - lastReconcile >= reconcileIntervalMs;
90
+ const reconcile = countMismatch || reconcileDue;
91
+
92
+ if (!hasArrivals && flagScan === 'none' && !reconcile) {
93
+ return {
94
+ mode: 'none', reason: 'nothing changed',
95
+ fetchFrom: null, flagScan: 'none', changedSince: null, reconcile: false
96
+ };
97
+ }
98
+
99
+ const reasons = [];
100
+ if (hasArrivals) reasons.push(`${serverUidNext - storedUidNext} new UID(s)`);
101
+ if (flagScan === 'changed') reasons.push('flags changed since last MODSEQ');
102
+ if (flagScan === 'full') reasons.push('no CONDSTORE - rescanning flags');
103
+ if (countMismatch) reasons.push('message count differs');
104
+ else if (reconcileDue) reasons.push('periodic reconcile');
105
+
106
+ return {
107
+ mode: 'incremental',
108
+ reason: reasons.join(', '),
109
+ fetchFrom: hasArrivals ? storedUidNext : null,
110
+ flagScan,
111
+ changedSince,
112
+ reconcile
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Which stored UIDs the server no longer has.
118
+ *
119
+ * @param {number[]} stored
120
+ * @param {number[]} onServer
121
+ * @returns {number[]}
122
+ */
123
+ export function missingUids(stored, onServer) {
124
+ const live = new Set(onServer);
125
+ return stored.filter(uid => !live.has(uid));
126
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Which folders a mailbox keeps mirrored.
3
+ *
4
+ * The free reader fetches everything on demand and holds nothing. The Pro
5
+ * edition mirrors folders into MongoDB so the list opens instantly and search
6
+ * can run locally - but mirroring *every* folder is the wrong default. A real
7
+ * mailbox here has 104 of them; syncing all of those on every poll is a lot of
8
+ * traffic to keep a decade of archive folders warm that nobody opens.
9
+ *
10
+ * So: a sensible default, and an explicit choice that overrides it.
11
+ *
12
+ * @module _lib/mail/syncSelection
13
+ */
14
+
15
+ /**
16
+ * Folders worth mirroring without being asked.
17
+ *
18
+ * The inbox because it is the one that matters, and Sent because replies are
19
+ * how threading gets its other half. Drafts, Trash and Junk are deliberately
20
+ * absent - they churn, and nobody searches them.
21
+ */
22
+ const DEFAULT_SPECIAL_USE = new Set(['\\Inbox', '\\Sent']);
23
+
24
+ /**
25
+ * Resolve the folders to sync for one mailbox.
26
+ *
27
+ * An explicit selection is honoured as given, minus anything the server no
28
+ * longer offers - a folder that was renamed away should not keep failing
29
+ * every poll forever.
30
+ *
31
+ * @param {{syncFolders?: string[]|null}} account
32
+ * @param {{path: string, specialUse?: string|null}[]} folders - as the server lists them
33
+ * @returns {{paths: string[], source: 'default'|'explicit'}}
34
+ */
35
+ export function resolveSyncFolders(account, folders) {
36
+ const available = new Set(folders.map(f => f.path));
37
+
38
+ const chosen = account?.syncFolders;
39
+ if (Array.isArray(chosen)) {
40
+ // Deduplicated here as well as on write: a record could carry
41
+ // duplicates from an older version or an edited file, and each one
42
+ // would otherwise mean syncing the same folder twice every pass.
43
+ return {
44
+ paths: [...new Set(chosen.filter(path => available.has(path)))],
45
+ source: 'explicit'
46
+ };
47
+ }
48
+
49
+ const paths = folders
50
+ .filter(f => DEFAULT_SPECIAL_USE.has(f.specialUse)
51
+ || f.path.toUpperCase() === 'INBOX')
52
+ .map(f => f.path);
53
+
54
+ // A server that advertises no special-use at all still has an inbox worth
55
+ // syncing; falling back to nothing would leave the Pro edition doing less
56
+ // than the free one.
57
+ if (!paths.length && folders.length) {
58
+ const first = folders.find(f => f.path.toUpperCase() === 'INBOX') ?? folders[0];
59
+ paths.push(first.path);
60
+ }
61
+
62
+ return {paths: [...new Set(paths)], source: 'default'};
63
+ }
64
+
65
+ /**
66
+ * Annotate a folder list with whether each one is mirrored.
67
+ *
68
+ * What the settings screen renders: every folder the server offers, each
69
+ * marked according to the current selection.
70
+ *
71
+ * @param {{syncFolders?: string[]|null}} account
72
+ * @param {{path: string, specialUse?: string|null}[]} folders
73
+ * @returns {{folders: object[], source: 'default'|'explicit'}}
74
+ */
75
+ export function describeSyncSelection(account, folders) {
76
+ const {paths, source} = resolveSyncFolders(account, folders);
77
+ const selected = new Set(paths);
78
+ return {
79
+ source,
80
+ folders: folders.map(folder => ({...folder, sync: selected.has(folder.path)}))
81
+ };
82
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Getting off a mailing list.
3
+ *
4
+ * Every bulk sender that follows the conventions puts a `List-Unsubscribe`
5
+ * header on its mail, and almost no webmail client surfaces it usefully -
6
+ * which is why people hunt for the six-point grey link at the bottom instead.
7
+ * Reading the header is a dozen lines; not reading it is why unsubscribing is
8
+ * unpleasant.
9
+ *
10
+ * ## One-click, and why it is not the default
11
+ *
12
+ * RFC 8058 adds `List-Unsubscribe-Post: List-Unsubscribe=One-Click`, which
13
+ * says the HTTPS URL accepts a POST and will action it without further
14
+ * confirmation. Where that is offered, this plugin can do it from the reader.
15
+ * Where it is not, the URL is opened in a tab instead: POSTing to a URL that
16
+ * never advertised one-click is sending an unsolicited request to a third
17
+ * party on the user's behalf, which is not ours to decide.
18
+ *
19
+ * The `mailto:` form is always a real message from the user's own mailbox, so
20
+ * it is offered but never sent without asking - an unsubscribe mail is
21
+ * indistinguishable from any other mail they send.
22
+ *
23
+ * @module _lib/mail/unsubscribe
24
+ */
25
+
26
+ /**
27
+ * Read a header out of either shape headers arrive in.
28
+ *
29
+ * @param {Map|object|null} headers
30
+ * @param {string} name
31
+ * @returns {string}
32
+ */
33
+ function header(headers, name) {
34
+ if (!headers) return '';
35
+ const value = typeof headers.get === 'function' ? headers.get(name) : headers[name];
36
+ // mailparser hands back an object for some structured headers.
37
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
38
+ return String(value.value ?? '');
39
+ }
40
+ return String(Array.isArray(value) ? value[0] ?? '' : value ?? '');
41
+ }
42
+
43
+ /**
44
+ * What a message offers by way of unsubscribing.
45
+ *
46
+ * @param {Map|object|null} headers - the parsed message's headers
47
+ * @returns {{available: boolean, http: string|null, oneClick: boolean,
48
+ * mailto: {to: string, subject: string, body: string}|null}}
49
+ */
50
+ export function readUnsubscribe(headers) {
51
+ const raw = header(headers, 'list-unsubscribe');
52
+ const none = {available: false, http: null, oneClick: false, mailto: null};
53
+ if (!raw.trim()) return none;
54
+
55
+ // The header is a comma-separated list of angle-bracketed URIs. Split on
56
+ // the brackets rather than on commas: a mailto: with a subject containing
57
+ // a comma is legal and would otherwise be torn in half.
58
+ const uris = [...raw.matchAll(/<([^>]+)>/g)].map(match => match[1].trim()).filter(Boolean);
59
+ if (!uris.length) return none;
60
+
61
+ // https only. An http:// unsubscribe link is a plaintext request carrying
62
+ // a token that identifies the recipient, and there is no version of
63
+ // "click here to prove this address is live" worth sending in the clear.
64
+ const http = uris.find(uri => /^https:\/\//i.test(uri)) ?? null;
65
+ const mailtoUri = uris.find(uri => /^mailto:/i.test(uri)) ?? null;
66
+
67
+ const post = header(headers, 'list-unsubscribe-post');
68
+ const oneClick = Boolean(http) && /list-unsubscribe\s*=\s*one-click/i.test(post);
69
+
70
+ return {
71
+ available: Boolean(http || mailtoUri),
72
+ http,
73
+ oneClick,
74
+ mailto: mailtoUri ? parseMailto(mailtoUri) : null
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Pull a mailto: URI apart into something sendable.
80
+ *
81
+ * @param {string} uri
82
+ * @returns {{to: string, subject: string, body: string}|null}
83
+ */
84
+ export function parseMailto(uri) {
85
+ const withoutScheme = String(uri ?? '').replace(/^mailto:/i, '');
86
+ const [addresses, query = ''] = withoutScheme.split('?');
87
+
88
+ const to = decodeURIComponent(addresses ?? '').trim();
89
+ if (!to) return null;
90
+
91
+ const params = new URLSearchParams(query);
92
+ return {
93
+ to,
94
+ // "unsubscribe" is the conventional body and subject when the sender
95
+ // asks for nothing in particular, and an empty message is refused by
96
+ // a fair number of list managers.
97
+ subject: (params.get('subject') ?? 'unsubscribe').trim(),
98
+ body: (params.get('body') ?? 'unsubscribe').trim()
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Is this a host the server may be asked to POST to?
104
+ *
105
+ * One-click unsubscribe is the only place in this plugin where the SERVER
106
+ * makes a request to a URL chosen by whoever sent the message. Without a
107
+ * guard that is a server-side request forgery with a button on it: the
108
+ * sender supplies `https://attacker.example/u`, the admin clicks Unsubscribe,
109
+ * and the attacker's 302 sends the request to `http://127.0.0.1:8192/…` or
110
+ * to a cloud metadata endpoint - from inside the network, with the response
111
+ * status handed back to the caller as a working port scanner.
112
+ *
113
+ * Two halves, and both are needed:
114
+ *
115
+ * - Redirects are NOT followed. A validated host that redirects is a
116
+ * validated host pointing somewhere unvalidated.
117
+ * - The host must resolve to a PUBLIC address. Checking the literal is not
118
+ * enough, because `unsubscribe.attacker.example` can resolve to
119
+ * 127.0.0.1 - so the caller resolves it and checks every answer.
120
+ *
121
+ * This function is the pure half: given resolved addresses, is it allowed?
122
+ *
123
+ * @param {string[]} addresses - every A/AAAA the host resolved to
124
+ * @returns {boolean}
125
+ */
126
+ export function addressesArePublic(addresses) {
127
+ const list = (addresses ?? []).map(a => String(a ?? '').trim().toLowerCase()).filter(Boolean);
128
+ // No answer is not permission. A host that resolves to nothing cannot be
129
+ // reached anyway, and treating "unknown" as "fine" is the wrong default
130
+ // for a security check.
131
+ if (!list.length) return false;
132
+ return list.every(address => !isPrivateAddress(address));
133
+ }
134
+
135
+ /**
136
+ * Is this IP one that must never be reached on a sender's say-so?
137
+ *
138
+ * Loopback, link-local (which covers the cloud metadata endpoints), the
139
+ * RFC 1918 ranges, carrier-grade NAT, and the IPv6 equivalents including
140
+ * IPv4-mapped addresses - `::ffff:127.0.0.1` is loopback wearing a hat.
141
+ *
142
+ * @param {string} address
143
+ * @returns {boolean}
144
+ */
145
+ export function isPrivateAddress(address) {
146
+ const value = String(address ?? '').trim().toLowerCase();
147
+ if (!value) return true;
148
+
149
+ // IPv4-mapped IPv6, so the v4 rules below actually apply to it.
150
+ const mapped = value.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
151
+ const ip = mapped ? mapped[1] : value;
152
+
153
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(ip)) {
154
+ const [a, b] = ip.split('.').map(Number);
155
+ if (a === 0 || a === 10 || a === 127) return true;
156
+ if (a === 169 && b === 254) return true; // link-local, incl. metadata
157
+ if (a === 172 && b >= 16 && b <= 31) return true; // RFC 1918
158
+ if (a === 192 && b === 168) return true; // RFC 1918
159
+ if (a === 100 && b >= 64 && b <= 127) return true; // carrier-grade NAT
160
+ if (a >= 224) return true; // multicast and reserved
161
+ return false;
162
+ }
163
+
164
+ // IPv6.
165
+ if (ip === '::' || ip === '::1') return true;
166
+ if (/^f[cd]/.test(ip)) return true; // unique local
167
+ if (/^fe[89ab]/.test(ip)) return true; // link-local
168
+ return false;
169
+ }
170
+
171
+ /**
172
+ * Names that are never a public unsubscribe endpoint, whatever they resolve
173
+ * to. A cheap first pass, so the common cases are refused without a lookup.
174
+ *
175
+ * @param {string} hostname
176
+ * @returns {boolean}
177
+ */
178
+ export function isLocalName(hostname) {
179
+ const host = String(hostname ?? '').trim().toLowerCase().replace(/\.$/, '');
180
+ if (!host) return true;
181
+ if (host === 'localhost') return true;
182
+ return /\.(local|internal|localhost|home|lan|intranet)$/.test(host);
183
+ }