domma-cms 0.55.1 → 0.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +130 -2
- package/README.md +4 -4
- package/admin/css/admin.css +1 -1
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +3 -3
- package/admin/js/lib/page-picker.js +1 -0
- package/admin/js/lib/plugin-accent.js +1 -0
- package/admin/js/lib/plugin-chrome.js +1 -0
- package/admin/js/lib/shortcode-context-menu.js +2 -2
- package/admin/js/lib/sidebar-grouping.js +1 -1
- package/admin/js/lib/sidebar-grouping.test.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +4 -4
- package/admin/js/lib/slideover-resizable.js +1 -0
- package/admin/js/templates/context-menu-editor.html +212 -0
- package/admin/js/templates/context-menus.html +16 -0
- package/admin/js/templates/plugin-code.html +1 -1
- package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
- package/admin/js/templates/settings.html +16 -97
- package/admin/js/templates/theme.html +173 -0
- package/admin/js/views/context-menu-editor.js +55 -0
- package/admin/js/views/context-menus.js +5 -0
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/plugin-marketplace.js +1 -1
- package/admin/js/views/plugins.js +25 -23
- package/admin/js/views/search.js +1 -0
- package/admin/js/views/settings.js +3 -3
- package/admin/js/views/theme.js +1 -0
- package/bin/cli.js +2 -0
- package/bin/update.js +13 -2
- package/config/menus/admin-sidebar.json +129 -23
- package/config/plugins.json +5 -5
- package/config/search.json +13 -0
- package/config/theme.json +18 -0
- package/package.json +12 -4
- package/plugins/_lib/admin/mail/compose-window.js +914 -0
- package/plugins/_lib/admin/mail/contacts.js +301 -0
- package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
- package/plugins/_lib/admin/mail/folder-tree.js +254 -0
- package/plugins/_lib/admin/mail/identity.js +480 -0
- package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
- package/plugins/_lib/admin/mail/keyboard.js +136 -0
- package/plugins/_lib/admin/mail/mail.css +1 -0
- package/plugins/_lib/admin/mail/mail.html +71 -0
- package/plugins/_lib/admin/mail/panes.js +253 -0
- package/plugins/_lib/admin/mail/reader-view.js +4453 -0
- package/plugins/_lib/admin/mail/resizable.js +26 -0
- package/plugins/_lib/admin/mail/rules.js +343 -0
- package/plugins/_lib/admin/mail/scheduling.js +203 -0
- package/plugins/_lib/admin/mail/section-kit.js +277 -0
- package/plugins/_lib/admin/mail/templates.js +238 -0
- package/plugins/_lib/admin/mail/threads.js +200 -0
- package/plugins/_lib/admin/mail/vacation.js +269 -0
- package/plugins/_lib/admin/ui/help.css +1 -0
- package/plugins/_lib/admin/ui/help.js +174 -0
- package/plugins/_lib/admin/ui/resizable.js +151 -0
- package/plugins/_lib/dataStore.js +117 -0
- package/plugins/_lib/mail/accounts.js +919 -0
- package/plugins/_lib/mail/bodyTokens.js +101 -0
- package/plugins/_lib/mail/compose.js +256 -0
- package/plugins/_lib/mail/defaults.js +57 -0
- package/plugins/_lib/mail/diagnostics.js +154 -0
- package/plugins/_lib/mail/envelope.js +161 -0
- package/plugins/_lib/mail/folders.js +192 -0
- package/plugins/_lib/mail/handoff.js +274 -0
- package/plugins/_lib/mail/imapPool.js +293 -0
- package/plugins/_lib/mail/mbox.js +74 -0
- package/plugins/_lib/mail/pollSchedule.js +79 -0
- package/plugins/_lib/mail/poller.js +135 -0
- package/plugins/_lib/mail/priority.js +138 -0
- package/plugins/_lib/mail/readRoutes.js +680 -0
- package/plugins/_lib/mail/render.js +291 -0
- package/plugins/_lib/mail/ruleRunner.js +152 -0
- package/plugins/_lib/mail/scheduler.js +254 -0
- package/plugins/_lib/mail/secretbox.js +229 -0
- package/plugins/_lib/mail/send.js +396 -0
- package/plugins/_lib/mail/store.js +1002 -0
- package/plugins/_lib/mail/sync.js +292 -0
- package/plugins/_lib/mail/syncPlan.js +126 -0
- package/plugins/_lib/mail/syncSelection.js +82 -0
- package/plugins/_lib/mail/unsubscribe.js +183 -0
- package/plugins/_lib/mail/vacationRunner.js +114 -0
- package/plugins/_lib/mail/write.js +473 -0
- package/plugins/_lib/schemaSync.js +83 -0
- package/plugins/_template/admin/css/index.css +0 -0
- package/plugins/_template/admin/templates/index.html +4 -4
- package/plugins/_template/admin/views/index.js +7 -0
- package/plugins/analytics/admin/css/index.css +1 -0
- package/plugins/analytics/admin/templates/analytics.html +22 -13
- package/plugins/analytics/plugin.json +3 -0
- package/plugins/blog/admin/css/index.css +1 -0
- package/plugins/blog/admin/templates/blog.html +30 -18
- package/plugins/blog/admin/templates/categories.html +2 -2
- package/plugins/blog/admin/templates/comments.html +2 -2
- package/plugins/blog/admin/templates/post-editor.html +34 -34
- package/plugins/blog/admin/templates/settings.html +6 -3
- package/plugins/blog/admin/views/blog.js +8 -5
- package/plugins/blog/admin/views/categories.js +5 -10
- package/plugins/blog/admin/views/comments.js +5 -5
- package/plugins/blog/admin/views/post-editor.js +39 -20
- package/plugins/blog/admin/views/settings.js +52 -50
- package/plugins/blog/collections/categories/schema.json +7 -6
- package/plugins/blog/collections/comments/schema.json +11 -10
- package/plugins/blog/collections/posts/schema.json +14 -13
- package/plugins/blog/plugin.js +36 -13
- package/plugins/blog/plugin.json +13 -5
- package/plugins/blog/plugin.public.js +312 -0
- package/plugins/contacts/admin/css/index.css +1 -0
- package/plugins/contacts/admin/templates/contacts.html +128 -0
- package/plugins/contacts/admin/views/contacts.js +237 -4
- package/plugins/contacts/collections/user-contacts/schema.json +108 -0
- package/plugins/contacts/plugin.js +214 -27
- package/plugins/contacts/plugin.json +4 -1
- package/plugins/invoice/admin/css/index.css +1 -0
- package/plugins/invoice/admin/templates/editor.html +140 -49
- package/plugins/invoice/admin/templates/index.html +153 -23
- package/plugins/invoice/admin/templates/issuers.html +2 -5
- package/plugins/invoice/admin/templates/receivers.html +2 -5
- package/plugins/invoice/admin/views/contacts-source.js +266 -0
- package/plugins/invoice/admin/views/editor.js +366 -199
- package/plugins/invoice/admin/views/export.js +199 -0
- package/plugins/invoice/admin/views/help-content.js +61 -0
- package/plugins/invoice/admin/views/index.js +582 -94
- package/plugins/invoice/admin/views/issuers.js +24 -17
- package/plugins/invoice/admin/views/media.js +172 -0
- package/plugins/invoice/admin/views/party-view.js +305 -67
- package/plugins/invoice/admin/views/payments.js +127 -0
- package/plugins/invoice/admin/views/print.js +130 -0
- package/plugins/invoice/admin/views/receivers.js +49 -16
- package/plugins/invoice/admin/views/send.js +212 -0
- package/plugins/invoice/admin/views/settings.js +594 -0
- package/plugins/invoice/admin/views/view-lifecycle.js +33 -0
- package/plugins/invoice/collections/invoice-issuers/schema.json +77 -11
- package/plugins/invoice/collections/invoice-receivers/schema.json +10 -9
- package/plugins/invoice/collections/invoices/schema.json +19 -13
- package/plugins/invoice/config.js +27 -6
- package/plugins/invoice/pdf.js +164 -0
- package/plugins/invoice/plugin.js +1217 -44
- package/plugins/invoice/plugin.json +10 -9
- package/plugins/invoice/templates/_base.css +1 -0
- package/plugins/invoice/templates/classic-nologo.html +100 -0
- package/plugins/invoice/templates/classic.html +91 -0
- package/plugins/invoice/templates/invoice-print.html +24 -0
- package/plugins/invoice/templates/minimal.html +99 -0
- package/plugins/invoice/templates/modern-nologo.html +114 -0
- package/plugins/invoice/templates/modern.html +113 -0
- package/plugins/invoice/templates/templates.json +11 -0
- package/plugins/mail-reader/admin/views/mail.js +19 -0
- package/plugins/mail-reader/config.js +7 -0
- package/plugins/mail-reader/plugin.js +48 -0
- package/plugins/mail-reader/plugin.json +33 -0
- package/plugins/notes/admin/views/notes.js +1 -1
- package/plugins/notes/plugin.json +2 -2
- package/plugins/surveys/lib/audience.js +37 -0
- package/plugins/surveys/lib/campaigns.js +43 -0
- package/plugins/surveys/lib/ledger.js +110 -0
- package/plugins/surveys/lib/sending.js +106 -0
- package/plugins/surveys/lib/stats.js +62 -0
- package/plugins/surveys/lib/submit.js +95 -0
- package/plugins/surveys/lib/tokens.js +28 -0
- package/plugins/surveys/plugin.public.js +149 -0
- package/plugins/theme-switcher/admin/templates/theme-switcher.html +1 -1
- package/public/css/forms.css +1 -1
- package/public/css/search.css +1 -0
- package/public/css/site.css +1 -1
- package/public/js/collection-context.js +2 -2
- package/public/js/context-menus.js +1 -0
- package/public/js/form-logic-engine.js +1 -1
- package/public/js/forms.js +2 -2
- package/public/js/search.js +1 -0
- package/public/js/site.js +1 -1
- package/scripts/build.js +37 -3
- package/scripts/copy-domma.js +48 -0
- package/scripts/seed.js +1996 -0
- package/server/routes/api/collections.js +34 -0
- package/server/routes/api/context-menus.js +104 -0
- package/server/routes/api/forms.js +42 -3
- package/server/routes/api/notifications.js +69 -19
- package/server/routes/api/plugins.js +50 -6
- package/server/routes/api/search.js +43 -0
- package/server/routes/api/theme.js +69 -0
- package/server/routes/public.js +42 -7
- package/server/server.js +74 -0
- package/server/services/adapters/FileAdapter.js +6 -1
- package/server/services/content.js +26 -0
- package/server/services/contextMenus.js +477 -0
- package/server/services/markdown.js +70 -9
- package/server/services/permissionRegistry.js +24 -0
- package/server/services/pluginFiles.js +52 -11
- package/server/services/plugins.js +229 -6
- package/server/services/renderer.js +144 -22
- package/server/services/roles.js +1 -1
- package/server/services/search-migration.js +82 -0
- package/server/services/search.js +413 -0
- package/server/services/sidebar-migration.js +1 -0
- package/server/services/themeSettings.js +541 -0
- package/server/services/users.js +8 -0
- package/server/templates/page.html +4 -2
- package/plugins/contacts/data/contacts.json +0 -20
- package/plugins/notes/data/notes.json +0 -1
- package/plugins/site-search/admin/views/site-search.js +0 -116
- package/plugins/site-search/config.js +0 -15
- package/plugins/site-search/plugin.js +0 -188
- package/plugins/site-search/plugin.json +0 -40
- package/plugins/site-search/public/inject-body.html +0 -17
- package/plugins/site-search/public/inject-head.html +0 -1
- package/plugins/site-search/public/search.css +0 -1
- package/plugins/site-search/public/search.js +0 -1
- 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
|
+
}
|