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,1002 @@
1
+ /**
2
+ * The synchronised message store.
3
+ *
4
+ * `mail-reader` keeps nothing: every folder listing and every message is
5
+ * fetched from IMAP on demand, which is honest but slow and makes search and
6
+ * threading impossible. `email-pro` keeps a local mirror of the envelopes and
7
+ * flags so the list opens instantly, search can run locally, and a flag change
8
+ * can be applied optimistically and reconciled afterwards.
9
+ *
10
+ * Only envelopes, flags and structure live here - never message bodies.
11
+ * Bodies stay on the server and are fetched and rendered per view, so the
12
+ * store holds metadata about someone's mail rather than the mail itself.
13
+ *
14
+ * ## mongodb is optional
15
+ *
16
+ * It is in the engine's `optionalDependencies`, so a free-tier site may not
17
+ * have it installed at all. The driver is therefore imported lazily, inside
18
+ * `connect()`, and never at module load - importing it at the top would break
19
+ * every file-mode site the moment this module was referenced.
20
+ *
21
+ * @module _lib/mail/store
22
+ */
23
+
24
+ /** Collection names. Prefixed like the rest of the CMS's Mongo collections. */
25
+ const FOLDERS = 'cms_mail_folders';
26
+ const MESSAGES = 'cms_mail_messages';
27
+ /** Messages waiting to be sent - scheduled, or held briefly so Send can be taken back. */
28
+ const OUTBOX = 'cms_mail_outbox';
29
+ /** Messages put out of sight until a time. */
30
+ const SNOOZED = 'cms_mail_snoozed';
31
+ /** Who the away message has already answered, and when. */
32
+ const AUTOREPLIES = 'cms_mail_autoreplies';
33
+
34
+ /**
35
+ * The most a queued message may weigh, in bytes of base64.
36
+ *
37
+ * BSON caps a document at 16MB and a queued send carries its attachments
38
+ * inside one. Sized well under that so the envelope, the recipients and the
39
+ * body cannot push an otherwise-legal message over the edge - a rejection at
40
+ * the driver would be an unsendable message sitting in a queue.
41
+ */
42
+ export const MAX_QUEUED_BYTES = 12_000_000;
43
+
44
+ /**
45
+ * How long a claimed row may sit before another tick may take it back.
46
+ *
47
+ * A claim flips a row to `sending` or `waking`, and only the worker finishing
48
+ * moves it out again - so a process that is restarted mid-claim leaves the
49
+ * row in a state nothing ever matches again. It is then in no list, has no
50
+ * button, and the message is neither sent nor reported as failed.
51
+ *
52
+ * Generous, because the point is to recover from a dead process rather than
53
+ * to race a slow mail server: a send that genuinely takes four minutes must
54
+ * not be picked up twice.
55
+ */
56
+ const CLAIM_STALE_MS = 5 * 60_000;
57
+
58
+ /**
59
+ * How many messages per conversation a page of threads carries, on average.
60
+ *
61
+ * A ceiling on the second query rather than a per-thread limit, which Mongo
62
+ * cannot express in one find. The conversation's true size comes from the
63
+ * grouping and is reported regardless; this only bounds how much is sent to
64
+ * a browser drawing fifty rows.
65
+ */
66
+ const THREAD_MESSAGE_CAP = 20;
67
+
68
+ /**
69
+ * A short random id for a queue row.
70
+ *
71
+ * Not `randomUUID`: this module is imported by a plugin that may run on a
72
+ * site without `mongodb` installed, and keeping the import surface to
73
+ * nothing but the driver means it can be loaded and inspected anywhere.
74
+ *
75
+ * @returns {string}
76
+ */
77
+ function randomId() {
78
+ return `${Math.random().toString(36).slice(2, 12)}${Date.now().toString(36)}`;
79
+ }
80
+
81
+ /**
82
+ * The stable id for "this mailbox has answered this address".
83
+ *
84
+ * Lower-cased, because a sender writing from Sam@ and sam@ is one person and
85
+ * must not be told twice.
86
+ *
87
+ * @param {string} accountId
88
+ * @param {string} address
89
+ * @returns {string}
90
+ */
91
+ function autoReplyId(accountId, address) {
92
+ return `${accountId}:${String(address ?? '').trim().toLowerCase()}`;
93
+ }
94
+
95
+ /**
96
+ * The document out of a findOneAndUpdate result.
97
+ *
98
+ * Driver 6 returns the document itself; drivers before it wrapped it in
99
+ * `{value, ok}`. Checked by looking for an `_id` rather than by reaching for
100
+ * `.value` first, because a document that happened to have a field called
101
+ * `value` would otherwise be unwrapped into whatever that field held.
102
+ *
103
+ * @param {object|null} result
104
+ * @returns {object|null}
105
+ */
106
+ function claimed(result) {
107
+ if (!result) return null;
108
+ if (result._id !== undefined) return result;
109
+ return result.value?._id !== undefined ? result.value : null;
110
+ }
111
+
112
+ /**
113
+ * The first message id out of a header that may hold several.
114
+ *
115
+ * @param {string|string[]|null|undefined} value
116
+ * @returns {string|null}
117
+ */
118
+ function firstId(value) {
119
+ if (Array.isArray(value)) return value[0] ?? null;
120
+ const match = String(value ?? '').trim().split(/\s+/)[0];
121
+ return match || null;
122
+ }
123
+
124
+ /**
125
+ * Compose the stable id for a folder record.
126
+ *
127
+ * @param {string} accountId
128
+ * @param {string} path
129
+ * @returns {string}
130
+ */
131
+ export function folderId(accountId, path) {
132
+ return `${accountId}:${path}`;
133
+ }
134
+
135
+ /**
136
+ * Compose the stable id for a message record.
137
+ *
138
+ * UID is unique per folder per UIDVALIDITY, so the id is only stable while
139
+ * UIDVALIDITY holds - which is exactly the condition a resync checks.
140
+ *
141
+ * @param {string} accountId
142
+ * @param {string} path
143
+ * @param {number} uid
144
+ * @returns {string}
145
+ */
146
+ export function messageId(accountId, path, uid) {
147
+ return `${accountId}:${path}:${uid}`;
148
+ }
149
+
150
+ /**
151
+ * Create a store bound to one connection.
152
+ *
153
+ * @param {{uri: string, database: string}} connection
154
+ * @returns {object} store API
155
+ */
156
+ export function createStore(connection) {
157
+ let client = null;
158
+ let db = null;
159
+ let connecting = null;
160
+
161
+ /**
162
+ * Connect once, lazily, and make sure the indexes exist.
163
+ *
164
+ * @returns {Promise<object>} the database handle
165
+ */
166
+ async function connect() {
167
+ if (db) return db;
168
+ if (connecting) return connecting;
169
+
170
+ connecting = (async () => {
171
+ const {MongoClient} = await import('mongodb');
172
+ client = new MongoClient(connection.uri, {serverSelectionTimeoutMS: 8000});
173
+ await client.connect();
174
+ db = client.db(connection.database);
175
+ await ensureIndexes(db);
176
+ return db;
177
+ })();
178
+
179
+ try {
180
+ return await connecting;
181
+ } finally {
182
+ connecting = null;
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Indexes the sync and the reader depend on.
188
+ *
189
+ * Creating them is idempotent, and doing it on connect means a store that
190
+ * has just been provisioned works without a separate migration step.
191
+ *
192
+ * @param {object} database
193
+ * @returns {Promise<void>}
194
+ */
195
+ async function ensureIndexes(database) {
196
+ await database.collection(FOLDERS).createIndex({accountId: 1});
197
+ await Promise.all([
198
+ // The list view: newest first within a folder.
199
+ database.collection(MESSAGES).createIndex({accountId: 1, folder: 1, date: -1}),
200
+ // Sync reconciliation works in UID order.
201
+ database.collection(MESSAGES).createIndex({accountId: 1, folder: 1, uid: 1}),
202
+ // Threading, once replies are being matched up.
203
+ database.collection(MESSAGES).createIndex({accountId: 1, messageId: 1}),
204
+ // Listing a folder by conversation.
205
+ database.collection(MESSAGES).createIndex({accountId: 1, folder: 1, threadRoot: 1}),
206
+ // Search narrows by mailbox first, then scans. Fine at the volumes
207
+ // a mirrored selection reaches; a mailbox with hundreds of
208
+ // thousands of messages would want a text index instead, at the
209
+ // cost of substring matching, which is what people actually type.
210
+ database.collection(MESSAGES).createIndex({accountId: 1, date: -1}),
211
+ // The two queues. The worker asks "what is due" across every
212
+ // mailbox on every tick, so `at` leads; the listing screens ask
213
+ // per mailbox.
214
+ database.collection(OUTBOX).createIndex({status: 1, at: 1}),
215
+ database.collection(OUTBOX).createIndex({accountId: 1, createdAt: -1}),
216
+ database.collection(SNOOZED).createIndex({status: 1, at: 1}),
217
+ database.collection(SNOOZED).createIndex({accountId: 1, at: 1}),
218
+ database.collection(AUTOREPLIES).createIndex({accountId: 1, at: -1})
219
+ ]);
220
+ }
221
+
222
+ /**
223
+ * Folder sync state, or null when this folder has never been synced.
224
+ *
225
+ * @param {string} accountId
226
+ * @param {string} path
227
+ * @returns {Promise<object|null>}
228
+ */
229
+ async function getFolder(accountId, path) {
230
+ const database = await connect();
231
+ return database.collection(FOLDERS).findOne({_id: folderId(accountId, path)});
232
+ }
233
+
234
+ /**
235
+ * Record what a folder looked like after a sync pass.
236
+ *
237
+ * @param {string} accountId
238
+ * @param {string} path
239
+ * @param {object} state
240
+ * @returns {Promise<void>}
241
+ */
242
+ async function saveFolder(accountId, path, state) {
243
+ const database = await connect();
244
+ await database.collection(FOLDERS).updateOne(
245
+ {_id: folderId(accountId, path)},
246
+ {$set: {...state, accountId, path, updatedAt: new Date()}},
247
+ {upsert: true}
248
+ );
249
+ }
250
+
251
+ /**
252
+ * Store a batch of envelopes, replacing any already held for those UIDs.
253
+ *
254
+ * @param {string} accountId
255
+ * @param {string} path
256
+ * @param {object[]} messages
257
+ * @returns {Promise<number>} how many were written
258
+ */
259
+ async function putMessages(accountId, path, messages) {
260
+ if (!messages.length) return 0;
261
+ const database = await connect();
262
+ const now = new Date();
263
+ const rooted = await inheritThreadRoots(database, accountId, messages);
264
+ const operations = rooted.map(message => ({
265
+ replaceOne: {
266
+ filter: {_id: messageId(accountId, path, message.uid)},
267
+ replacement: {
268
+ ...message,
269
+ _id: messageId(accountId, path, message.uid),
270
+ accountId,
271
+ folder: path,
272
+ fetchedAt: now
273
+ },
274
+ upsert: true
275
+ }
276
+ }));
277
+ const result = await database.collection(MESSAGES).bulkWrite(operations, {ordered: false});
278
+ return (result.upsertedCount ?? 0) + (result.modifiedCount ?? 0);
279
+ }
280
+
281
+ /**
282
+ * Give a reply the same thread root as the message it answers.
283
+ *
284
+ * `toRecord` works out a root from the headers alone, which is right
285
+ * whenever `References` carries the whole chain. Plenty of clients put
286
+ * only the immediate parent in it, and those replies each get a
287
+ * different root - so a conversation of six arrives as six conversations.
288
+ *
289
+ * One indexed lookup per batch fixes it: whatever the parent is filed
290
+ * under, the reply is filed under too. Parents inside the same batch are
291
+ * resolved first, because a batch is very often an entire thread
292
+ * arriving at once and neither half is in the database yet.
293
+ *
294
+ * @param {object} database
295
+ * @param {string} accountId
296
+ * @param {object[]} messages
297
+ * @returns {Promise<object[]>}
298
+ */
299
+ async function inheritThreadRoots(database, accountId, messages) {
300
+ const wanted = new Set();
301
+ for (const message of messages) {
302
+ const parent = firstId(message.inReplyTo);
303
+ // Already the root of its own chain - nothing to inherit.
304
+ if (parent && parent !== message.messageId) wanted.add(parent);
305
+ }
306
+ if (!wanted.size) return messages;
307
+
308
+ const known = new Map();
309
+ // Anything arriving in this same batch, first.
310
+ for (const message of messages) {
311
+ if (message.messageId && message.threadRoot) known.set(message.messageId, message.threadRoot);
312
+ }
313
+
314
+ const missing = [...wanted].filter(id => !known.has(id));
315
+ if (missing.length) {
316
+ const rows = await database.collection(MESSAGES)
317
+ .find({accountId, messageId: {$in: missing}},
318
+ {projection: {messageId: 1, threadRoot: 1, _id: 0}})
319
+ .toArray();
320
+ for (const row of rows) {
321
+ if (row.messageId && row.threadRoot) known.set(row.messageId, row.threadRoot);
322
+ }
323
+ }
324
+
325
+ return messages.map(message => {
326
+ const parent = firstId(message.inReplyTo);
327
+ const inherited = parent ? known.get(parent) : null;
328
+ return inherited ? {...message, threadRoot: inherited} : message;
329
+ });
330
+ }
331
+
332
+ /**
333
+ * Apply flag changes without refetching the envelope.
334
+ *
335
+ * @param {string} accountId
336
+ * @param {string} path
337
+ * @param {{uid: number, flags: string[]}[]} updates
338
+ * @returns {Promise<number>}
339
+ */
340
+ async function setFlags(accountId, path, updates) {
341
+ if (!updates.length) return 0;
342
+ const database = await connect();
343
+ const operations = updates.map(({uid, flags}) => ({
344
+ updateOne: {
345
+ filter: {_id: messageId(accountId, path, uid)},
346
+ update: {$set: {flags, seen: flags.includes('\\Seen'), flagged: flags.includes('\\Flagged')}}
347
+ }
348
+ }));
349
+ const result = await database.collection(MESSAGES).bulkWrite(operations, {ordered: false});
350
+ return result.modifiedCount ?? 0;
351
+ }
352
+
353
+ /**
354
+ * Forget messages that are no longer on the server.
355
+ *
356
+ * @param {string} accountId
357
+ * @param {string} path
358
+ * @param {number[]} uids
359
+ * @returns {Promise<number>}
360
+ */
361
+ async function removeMessages(accountId, path, uids) {
362
+ if (!uids.length) return 0;
363
+ const database = await connect();
364
+ const result = await database.collection(MESSAGES).deleteMany({
365
+ _id: {$in: uids.map(uid => messageId(accountId, path, uid))}
366
+ });
367
+ return result.deletedCount ?? 0;
368
+ }
369
+
370
+ /**
371
+ * Drop everything held for a folder.
372
+ *
373
+ * Used when UIDVALIDITY changes, which means every UID we hold now refers
374
+ * to a different message - the one case where throwing the mirror away is
375
+ * the correct response rather than a failure.
376
+ *
377
+ * @param {string} accountId
378
+ * @param {string} path
379
+ * @returns {Promise<number>}
380
+ */
381
+ async function clearFolder(accountId, path) {
382
+ const database = await connect();
383
+ const result = await database.collection(MESSAGES).deleteMany({accountId, folder: path});
384
+ return result.deletedCount ?? 0;
385
+ }
386
+
387
+ /**
388
+ * Every UID held for a folder, ascending - the left-hand side of the
389
+ * reconciliation against what the server still has.
390
+ *
391
+ * @param {string} accountId
392
+ * @param {string} path
393
+ * @returns {Promise<number[]>}
394
+ */
395
+ async function storedUids(accountId, path) {
396
+ const database = await connect();
397
+ const rows = await database.collection(MESSAGES)
398
+ .find({accountId, folder: path}, {projection: {uid: 1, _id: 0}})
399
+ .sort({uid: 1})
400
+ .toArray();
401
+ return rows.map(row => row.uid);
402
+ }
403
+
404
+ /**
405
+ * One page of a folder, newest first.
406
+ *
407
+ * @param {string} accountId
408
+ * @param {string} path
409
+ * @param {{page?: number, limit?: number}} [options]
410
+ * @returns {Promise<{messages: object[], total: number}>}
411
+ */
412
+ async function listMessages(accountId, path, {page = 1, limit = 50} = {}) {
413
+ const database = await connect();
414
+ const collection = database.collection(MESSAGES);
415
+ const filter = {accountId, folder: path};
416
+ const [messages, total] = await Promise.all([
417
+ collection.find(filter, {projection: {_id: 0}})
418
+ .sort({date: -1, uid: -1})
419
+ .skip((page - 1) * limit)
420
+ .limit(limit)
421
+ .toArray(),
422
+ collection.countDocuments(filter)
423
+ ]);
424
+ return {messages, total};
425
+ }
426
+
427
+ /**
428
+ * Search the mirror.
429
+ *
430
+ * Substring, case-insensitive, across the fields a person searches by.
431
+ * Deliberately not a Mongo text index: that matches whole words, so
432
+ * searching "domma" would not find "dommajs", and typing a fragment is
433
+ * what people actually do.
434
+ *
435
+ * Only mirrored folders can be searched - the rest of the mailbox is not
436
+ * here. The caller is told which folders were covered so it can say so
437
+ * rather than quietly returning less than the user expected.
438
+ *
439
+ * @param {string} accountId
440
+ * @param {{query: string, folders?: string[]|null, page?: number, limit?: number}} options
441
+ * @returns {Promise<{messages: object[], total: number, folders: string[]}>}
442
+ */
443
+ async function searchMessages(accountId, {query, folders = null, page = 1, limit = 50}) {
444
+ const database = await connect();
445
+ const collection = database.collection(MESSAGES);
446
+
447
+ const trimmed = String(query ?? '').trim();
448
+ if (!trimmed) return {messages: [], total: 0, folders: []};
449
+
450
+ // Escaped: a search for "a.b" or "c++" is a search, not a pattern.
451
+ const pattern = new RegExp(trimmed.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
452
+
453
+ const filter = {
454
+ accountId,
455
+ $or: [
456
+ {subject: pattern},
457
+ {'from.address': pattern},
458
+ {'from.name': pattern},
459
+ {'to.address': pattern},
460
+ {'to.name': pattern}
461
+ ]
462
+ };
463
+ if (Array.isArray(folders) && folders.length) filter.folder = {$in: folders};
464
+
465
+ const [messages, total, covered] = await Promise.all([
466
+ collection.find(filter, {projection: {_id: 0}})
467
+ .sort({date: -1, uid: -1})
468
+ .skip((page - 1) * limit)
469
+ .limit(limit)
470
+ .toArray(),
471
+ collection.countDocuments(filter),
472
+ collection.distinct('folder', {accountId})
473
+ ]);
474
+
475
+ return {messages, total, folders: covered};
476
+ }
477
+
478
+ /**
479
+ * Remove everything belonging to one mailbox.
480
+ *
481
+ * Called when a mailbox is removed or its owner is deleted: the mirror
482
+ * must not outlive the account any more than the password does.
483
+ *
484
+ * @param {string} accountId
485
+ * @returns {Promise<void>}
486
+ */
487
+ async function forgetAccount(accountId) {
488
+ const database = await connect();
489
+ // Every collection, not just the two that existed first. A queued
490
+ // send outliving its mailbox would try to send from credentials that
491
+ // no longer exist, and the auto-reply log is a record of who wrote to
492
+ // a mailbox that has been removed.
493
+ await Promise.all([
494
+ database.collection(MESSAGES).deleteMany({accountId}),
495
+ database.collection(FOLDERS).deleteMany({accountId}),
496
+ database.collection(OUTBOX).deleteMany({accountId}),
497
+ database.collection(SNOOZED).deleteMany({accountId}),
498
+ database.collection(AUTOREPLIES).deleteMany({accountId})
499
+ ]);
500
+ }
501
+
502
+ /**
503
+ * @returns {Promise<void>}
504
+ */
505
+ async function close() {
506
+ const open = client;
507
+ client = null;
508
+ db = null;
509
+ if (open) await open.close().catch(() => {});
510
+ }
511
+
512
+ /**
513
+ * One page of a folder, grouped into conversations.
514
+ *
515
+ * The page is of CONVERSATIONS, not of messages, which is why this cannot
516
+ * be done by grouping the output of `listMessages`: fifty messages might
517
+ * be four conversations or fifty, and a list that sometimes shows four
518
+ * rows is not a list. The grouping therefore happens in the database,
519
+ * over the whole folder, and the page is taken from the groups.
520
+ *
521
+ * @param {string} accountId
522
+ * @param {string} path
523
+ * @param {{page?: number, limit?: number}} [options]
524
+ * @returns {Promise<{threads: object[], total: number}>}
525
+ */
526
+ async function listThreads(accountId, path, {page = 1, limit = 50} = {}) {
527
+ const database = await connect();
528
+ const collection = database.collection(MESSAGES);
529
+ const match = {accountId, folder: path};
530
+
531
+ await backfillThreadRoots(collection, accountId, path);
532
+
533
+ // A row with no root at all is its own conversation. Grouping them
534
+ // all under null would collapse every message the server gave no
535
+ // Message-ID into one heap.
536
+ const rootOf = {$ifNull: ['$threadRoot', {$concat: ['\u0000uid-', {$toString: '$uid'}]}]};
537
+
538
+ // Two queries, deliberately, rather than one that pushes whole
539
+ // documents into each group.
540
+ //
541
+ // `{$push: '$$ROOT'}` builds one array per conversation in memory and
542
+ // then pages the result - so a folder collecting automated mail under
543
+ // a single thread root (CI alerts, a long mailing list) assembles a
544
+ // document larger than BSON's 16MB ceiling and the whole view 500s.
545
+ // Grouping to counts alone is bounded by the number of conversations;
546
+ // the messages for the page are then a plain indexed find.
547
+ const summaries = await collection.aggregate([
548
+ {$match: match},
549
+ {$group: {
550
+ _id: rootOf,
551
+ latestDate: {$max: '$date'},
552
+ count: {$sum: 1},
553
+ unseen: {$sum: {$cond: [{$eq: ['$seen', false]}, 1, 0]}},
554
+ flagged: {$max: {$cond: ['$flagged', 1, 0]}},
555
+ hasAttachment: {$max: {$cond: ['$hasAttachment', 1, 0]}}
556
+ }},
557
+ {$sort: {latestDate: -1, _id: 1}},
558
+ {$skip: (page - 1) * limit},
559
+ {$limit: limit}
560
+ ], {allowDiskUse: true}).toArray();
561
+
562
+ const totals = await collection.aggregate([
563
+ {$match: match},
564
+ {$group: {_id: rootOf}},
565
+ {$count: 'total'}
566
+ ], {allowDiskUse: true}).toArray();
567
+
568
+ if (!summaries.length) return {threads: [], total: totals[0]?.total ?? 0};
569
+
570
+ // The messages behind this page's conversations, newest first and
571
+ // capped. The cap is a memory bound, not a correctness one: `count`
572
+ // above is authoritative, so a conversation of twelve thousand still
573
+ // SAYS twelve thousand - it just does not hand all of them to a
574
+ // browser that is drawing fifty rows.
575
+ const roots = summaries.map(row => row._id);
576
+ const plain = roots.filter(root => !String(root).startsWith('\u0000'));
577
+ const rows = await collection
578
+ .find(
579
+ plain.length === roots.length
580
+ ? {...match, threadRoot: {$in: plain}}
581
+ // Some of this page's conversations are rootless rows,
582
+ // which are identified by UID rather than by root.
583
+ : {
584
+ ...match,
585
+ $or: [
586
+ ...(plain.length ? [{threadRoot: {$in: plain}}] : []),
587
+ {uid: {$in: roots
588
+ .filter(root => String(root).startsWith('\u0000'))
589
+ .map(root => Number(String(root).slice(String(root).indexOf('-') + 1)))
590
+ .filter(Number.isFinite)}}
591
+ ]
592
+ },
593
+ {projection: {_id: 0}}
594
+ )
595
+ .sort({date: -1, uid: -1})
596
+ .limit(limit * THREAD_MESSAGE_CAP)
597
+ .toArray();
598
+
599
+ const byRoot = new Map(roots.map(root => [String(root), []]));
600
+ for (const row of rows) {
601
+ const key = row.threadRoot ?? `\u0000uid-${row.uid}`;
602
+ if (byRoot.has(String(key))) byRoot.get(String(key)).push(row);
603
+ }
604
+
605
+ const threads = summaries.map(summary => {
606
+ const messages = byRoot.get(String(summary._id)) ?? [];
607
+ return {
608
+ root: summary._id,
609
+ messages,
610
+ latest: messages[0] ?? null,
611
+ count: summary.count,
612
+ unseen: summary.unseen,
613
+ flagged: summary.flagged === 1,
614
+ hasAttachment: summary.hasAttachment === 1
615
+ };
616
+ // A conversation whose messages all fell outside the cap has nothing
617
+ // to draw, and a row with no latest message is a row with no subject
618
+ // and no date.
619
+ }).filter(thread => thread.latest);
620
+
621
+ return {threads, total: totals[0]?.total ?? 0};
622
+ }
623
+
624
+ // -----------------------------------------------------------------------
625
+ // The queues
626
+ // -----------------------------------------------------------------------
627
+
628
+ /**
629
+ * Put a message in the outbox.
630
+ *
631
+ * @param {object} record
632
+ * @returns {Promise<string>} the queue id
633
+ */
634
+ async function queueSend(record) {
635
+ const database = await connect();
636
+ const id = `out-${randomId()}`;
637
+ await database.collection(OUTBOX).insertOne({
638
+ ...record,
639
+ _id: id,
640
+ status: 'pending',
641
+ attempts: 0,
642
+ createdAt: new Date()
643
+ });
644
+ return id;
645
+ }
646
+
647
+ /**
648
+ * What is waiting, or recently went, for one mailbox.
649
+ *
650
+ * Sent and failed entries are kept for a while rather than deleted the
651
+ * moment they go: "did that actually send?" is the question the screen
652
+ * exists to answer, and an empty list answers it ambiguously.
653
+ *
654
+ * @param {string} accountId
655
+ * @param {{limit?: number}} [options]
656
+ * @returns {Promise<object[]>}
657
+ */
658
+ async function listQueuedSends(accountId, {limit = 50} = {}) {
659
+ const database = await connect();
660
+ return database.collection(OUTBOX)
661
+ .find({accountId}, {projection: {'draft.attachments.content': 0}})
662
+ .sort({createdAt: -1})
663
+ .limit(limit)
664
+ .toArray();
665
+ }
666
+
667
+ /**
668
+ * Claim the queued sends that are due.
669
+ *
670
+ * Claimed one at a time with a conditional update, so two processes - or
671
+ * two ticks that overlap because one was slow - cannot both pick up the
672
+ * same message and send it twice. Sending twice is the one failure this
673
+ * queue must not have: a duplicate cannot be recalled.
674
+ *
675
+ * @param {Date|number} [now]
676
+ * @param {number} [max]
677
+ * @returns {Promise<object[]>}
678
+ */
679
+ async function claimDueSends(now = Date.now(), max = 10) {
680
+ const database = await connect();
681
+ const at = new Date(now instanceof Date ? now.getTime() : now);
682
+ const due = [];
683
+
684
+ const stale = new Date(at.getTime() - CLAIM_STALE_MS);
685
+
686
+ for (let i = 0; i < max; i += 1) {
687
+ const result = await database.collection(OUTBOX).findOneAndUpdate(
688
+ {
689
+ $or: [
690
+ {status: 'pending', at: {$lte: at}},
691
+ // Reclaimed. A row left `sending` by a restart matches
692
+ // nothing otherwise, and the message is then neither
693
+ // sent nor reported - see CLAIM_STALE_MS. The attempt
694
+ // counter still applies, so a send that kills the
695
+ // process every time is given up on rather than
696
+ // retried forever.
697
+ {status: 'sending', claimedAt: {$lte: stale}}
698
+ ]
699
+ },
700
+ {$set: {status: 'sending', claimedAt: new Date()}, $inc: {attempts: 1}},
701
+ {sort: {at: 1}, returnDocument: 'after'}
702
+ );
703
+ const doc = claimed(result);
704
+ if (!doc) break;
705
+ due.push(doc);
706
+ }
707
+ return due;
708
+ }
709
+
710
+ /**
711
+ * Record what became of a queued send.
712
+ *
713
+ * @param {string} id
714
+ * @param {object} patch
715
+ * @returns {Promise<void>}
716
+ */
717
+ async function finishSend(id, patch) {
718
+ const database = await connect();
719
+ await database.collection(OUTBOX).updateOne({_id: id}, {$set: {...patch, finishedAt: new Date()}});
720
+ }
721
+
722
+ /**
723
+ * Take a message back out of the outbox.
724
+ *
725
+ * Only while it is still pending. A message already claimed by the worker
726
+ * is on its way to the mail server, and reporting it cancelled would be
727
+ * the one lie this feature cannot afford.
728
+ *
729
+ * @param {string} userId
730
+ * @param {string} id
731
+ * @returns {Promise<object|null>} the cancelled record, or null
732
+ */
733
+ async function cancelSend(userId, id) {
734
+ const database = await connect();
735
+ const result = await database.collection(OUTBOX).findOneAndUpdate(
736
+ {_id: id, userId, status: 'pending'},
737
+ {$set: {status: 'cancelled', finishedAt: new Date()}},
738
+ {returnDocument: 'after'}
739
+ );
740
+ return claimed(result);
741
+ }
742
+
743
+ /**
744
+ * One queued send, if it belongs to this user.
745
+ *
746
+ * @param {string} userId
747
+ * @param {string} id
748
+ * @returns {Promise<object|null>}
749
+ */
750
+ async function getQueuedSend(userId, id) {
751
+ const database = await connect();
752
+ return database.collection(OUTBOX).findOne({_id: id, userId});
753
+ }
754
+
755
+ /**
756
+ * How much unread mail a mailbox holds, across its mirrored folders.
757
+ *
758
+ * Answered from the mirror rather than from IMAP, which is what makes it
759
+ * cheap enough to ask for on every sidebar render: it is a counting query
760
+ * against an index, not a connection to a mail server.
761
+ *
762
+ * @param {string} accountId
763
+ * @param {string[]|null} [folders] - which to count, or null for all mirrored
764
+ * @returns {Promise<number>}
765
+ */
766
+ async function countUnread(accountId, folders = null) {
767
+ const database = await connect();
768
+ const filter = {accountId, seen: false};
769
+ if (Array.isArray(folders) && folders.length) filter.folder = {$in: folders};
770
+ return database.collection(MESSAGES).countDocuments(filter);
771
+ }
772
+
773
+ /** Folders already backfilled in this process, so it runs at most once each. */
774
+ const backfilled = new Set();
775
+
776
+ /**
777
+ * Give rows written before `threadRoot` existed one.
778
+ *
779
+ * A mirror built by an earlier version has no thread root on any row, so
780
+ * every message would group as its own conversation - which is not a
781
+ * feature arriving quietly, it is a feature that visibly does not work on
782
+ * the mail somebody already has.
783
+ *
784
+ * The root is derived from what those rows DO carry: whatever the message
785
+ * is a reply to, or itself. `References` was not stored either, so a deep
786
+ * chain from a client that sends only the immediate parent still lands on
787
+ * several roots - which the page repair in `groupThreads` then joins.
788
+ * Between them the old mail groups; new mail is exact.
789
+ *
790
+ * Run on the read path rather than the sync, because this is exactly the
791
+ * moment it matters, and it is guarded so a folder pays for it once per
792
+ * process. After the first pass the filter matches nothing.
793
+ *
794
+ * @param {object} collection
795
+ * @param {string} accountId
796
+ * @param {string} path
797
+ * @returns {Promise<void>}
798
+ */
799
+ async function backfillThreadRoots(collection, accountId, path) {
800
+ const key = `${accountId}:${path}`;
801
+ if (backfilled.has(key)) return;
802
+ backfilled.add(key);
803
+
804
+ try {
805
+ await collection.updateMany(
806
+ {accountId, folder: path, threadRoot: {$in: [null, undefined]}},
807
+ [{$set: {threadRoot: {$ifNull: ['$inReplyTo', '$messageId']}}}]
808
+ );
809
+ } catch {
810
+ // A backfill that cannot run is a folder that groups less well,
811
+ // not a folder that cannot be listed. The aggregation handles a
812
+ // missing root on its own.
813
+ backfilled.delete(key);
814
+ }
815
+ }
816
+
817
+ // -----------------------------------------------------------------------
818
+ // The away message's memory
819
+ // -----------------------------------------------------------------------
820
+
821
+ /**
822
+ * When this mailbox last answered a given address automatically.
823
+ *
824
+ * The whole point of the once-per-sender rule: somebody who writes four
825
+ * times in an afternoon is told once.
826
+ *
827
+ * @param {string} accountId
828
+ * @param {string} address
829
+ * @returns {Promise<string|null>} an ISO timestamp, or null
830
+ */
831
+ async function lastAutoReply(accountId, address) {
832
+ const database = await connect();
833
+ const row = await database.collection(AUTOREPLIES)
834
+ .findOne({_id: autoReplyId(accountId, address)});
835
+ return row?.at ? new Date(row.at).toISOString() : null;
836
+ }
837
+
838
+ /**
839
+ * Remember that it has been answered.
840
+ *
841
+ * @param {string} accountId
842
+ * @param {string} address
843
+ * @param {Date} [at]
844
+ * @returns {Promise<void>}
845
+ */
846
+ async function noteAutoReply(accountId, address, at = new Date()) {
847
+ const database = await connect();
848
+ await database.collection(AUTOREPLIES).updateOne(
849
+ {_id: autoReplyId(accountId, address)},
850
+ {$set: {accountId, address: String(address).toLowerCase(), at}},
851
+ {upsert: true}
852
+ );
853
+ }
854
+
855
+ /**
856
+ * Who has been answered recently - the responder's audit trail.
857
+ *
858
+ * It exists because the replies are deliberately NOT filed in Sent: a
859
+ * holiday can produce a hundred of them, and a Sent folder that is mostly
860
+ * "Out of office" is one nobody can find anything in. Without this there
861
+ * would be no record at all of what went out on your behalf.
862
+ *
863
+ * @param {string} accountId
864
+ * @param {{limit?: number}} [options]
865
+ * @returns {Promise<object[]>}
866
+ */
867
+ async function listAutoReplies(accountId, {limit = 50} = {}) {
868
+ const database = await connect();
869
+ return database.collection(AUTOREPLIES)
870
+ .find({accountId}, {projection: {_id: 0}})
871
+ .sort({at: -1})
872
+ .limit(limit)
873
+ .toArray();
874
+ }
875
+
876
+ /**
877
+ * Forget who has been answered, so everybody is told again.
878
+ *
879
+ * Wanted whenever the away message is turned on for a NEW absence: the
880
+ * people who wrote during the last one should not be silently skipped
881
+ * because they were told about that one.
882
+ *
883
+ * @param {string} accountId
884
+ * @returns {Promise<number>}
885
+ */
886
+ async function clearAutoReplies(accountId) {
887
+ const database = await connect();
888
+ const result = await database.collection(AUTOREPLIES).deleteMany({accountId});
889
+ return result.deletedCount ?? 0;
890
+ }
891
+
892
+ /**
893
+ * Note that a message has been put away until later.
894
+ *
895
+ * @param {object} record
896
+ * @returns {Promise<string>}
897
+ */
898
+ async function queueSnooze(record) {
899
+ const database = await connect();
900
+ const id = `snz-${randomId()}`;
901
+ await database.collection(SNOOZED).insertOne({
902
+ ...record, _id: id, status: 'pending', createdAt: new Date()
903
+ });
904
+ return id;
905
+ }
906
+
907
+ /**
908
+ * Everything this mailbox has put away, soonest first.
909
+ *
910
+ * Everything NOT finished, rather than everything pending. A row that is
911
+ * mid-wake, or that failed, still has a message sitting in the snooze
912
+ * folder - and a list that quietly omitted those would be a list that
913
+ * said "nothing is snoozed" while the mail was still parked.
914
+ *
915
+ * @param {string} accountId
916
+ * @returns {Promise<object[]>}
917
+ */
918
+ async function listSnoozed(accountId) {
919
+ const database = await connect();
920
+ return database.collection(SNOOZED)
921
+ .find({accountId, status: {$nin: ['woken', 'cancelled']}})
922
+ .sort({at: 1})
923
+ .toArray();
924
+ }
925
+
926
+ /**
927
+ * Claim the snoozes that have come due. Same one-at-a-time claim as sends.
928
+ *
929
+ * @param {Date|number} [now]
930
+ * @param {number} [max]
931
+ * @returns {Promise<object[]>}
932
+ */
933
+ async function claimDueSnoozes(now = Date.now(), max = 20) {
934
+ const database = await connect();
935
+ const at = new Date(now instanceof Date ? now.getTime() : now);
936
+ const due = [];
937
+
938
+ const stale = new Date(at.getTime() - CLAIM_STALE_MS);
939
+
940
+ for (let i = 0; i < max; i += 1) {
941
+ const result = await database.collection(SNOOZED).findOneAndUpdate(
942
+ {
943
+ $or: [
944
+ {status: 'pending', at: {$lte: at}},
945
+ // Same reclaim as the outbox. Worse here if it is
946
+ // missed: `listSnoozed` only shows pending rows, so a
947
+ // row stuck at `waking` vanishes from the Snoozed list
948
+ // while the message sits in a folder nobody reads.
949
+ {status: 'waking', claimedAt: {$lte: stale}}
950
+ ]
951
+ },
952
+ {$set: {status: 'waking', claimedAt: new Date()}, $inc: {attempts: 1}},
953
+ {sort: {at: 1}, returnDocument: 'after'}
954
+ );
955
+ const doc = claimed(result);
956
+ if (!doc) break;
957
+ due.push(doc);
958
+ }
959
+ return due;
960
+ }
961
+
962
+ /**
963
+ * @param {string} id
964
+ * @param {object} patch
965
+ * @returns {Promise<void>}
966
+ */
967
+ async function finishSnooze(id, patch) {
968
+ const database = await connect();
969
+ await database.collection(SNOOZED).updateOne({_id: id}, {$set: {...patch, finishedAt: new Date()}});
970
+ }
971
+
972
+ /**
973
+ * Bring one back early.
974
+ *
975
+ * @param {string} userId
976
+ * @param {string} id
977
+ * @returns {Promise<object|null>}
978
+ */
979
+ async function wakeSnooze(userId, id) {
980
+ const database = await connect();
981
+ const result = await database.collection(SNOOZED).findOneAndUpdate(
982
+ // A failed row is exactly the one somebody most wants to retry by
983
+ // hand, so it is eligible too - it is reset to pending and the
984
+ // attempt counter cleared.
985
+ {_id: id, userId, status: {$in: ['pending', 'failed']}},
986
+ {$set: {at: new Date(0), status: 'pending', attempts: 0, wokeEarly: true}},
987
+ {returnDocument: 'after'}
988
+ );
989
+ return claimed(result);
990
+ }
991
+
992
+ return {
993
+ connect,
994
+ getFolder, saveFolder,
995
+ putMessages, setFlags, removeMessages, clearFolder,
996
+ storedUids, listMessages, listThreads, searchMessages, countUnread, forgetAccount,
997
+ queueSend, listQueuedSends, claimDueSends, finishSend, cancelSend, getQueuedSend,
998
+ queueSnooze, listSnoozed, claimDueSnoozes, finishSnooze, wakeSnooze,
999
+ lastAutoReply, noteAutoReply, listAutoReplies, clearAutoReplies,
1000
+ close
1001
+ };
1002
+ }