domma-cms 0.55.1 → 0.69.3
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 +194 -3
- package/README.md +18 -14
- package/admin/css/admin.css +1 -1
- package/admin/css/contacts.css +1 -0
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/index.html +1 -0
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/lib/analytics-shape.js +1 -0
- package/admin/js/lib/contacts-arrange.js +1 -0
- package/admin/js/lib/help-popover.js +1 -0
- package/admin/js/lib/notes-arrange.js +1 -0
- 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/lib/todo-arrange.js +1 -0
- package/admin/js/lib/tool-kit.js +1 -0
- package/admin/js/templates/analytics.html +138 -0
- package/admin/js/templates/contacts.html +156 -0
- package/admin/js/templates/context-menu-editor.html +212 -0
- package/admin/js/templates/context-menus.html +16 -0
- package/admin/js/templates/notes.html +137 -0
- package/admin/js/templates/plugin-code.html +1 -1
- package/admin/js/templates/plugin-marketplace.html +17 -4
- package/admin/js/templates/plugins.html +13 -7
- 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 +211 -0
- package/admin/js/templates/todo.html +113 -0
- package/admin/js/views/analytics.js +62 -0
- package/admin/js/views/contacts.js +174 -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/notes.js +104 -0
- package/admin/js/views/plugin-marketplace.js +1 -1
- package/admin/js/views/plugins.js +28 -24
- 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/admin/js/views/todo.js +96 -0
- package/bin/cli.js +6 -10
- package/bin/lib/plugin-version.js +28 -0
- package/bin/update.js +27 -2
- package/config/menus/admin-sidebar.json +129 -23
- package/config/plugins.json +5 -5
- package/config/search.json +13 -0
- package/config/server.json +3 -1
- package/package.json +14 -10
- 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 +4473 -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 +1022 -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/blog/CLAUDE.md +237 -0
- package/plugins/blog/admin/css/index.css +178 -0
- package/plugins/blog/admin/templates/blog.html +35 -41
- package/plugins/blog/admin/templates/categories.html +19 -6
- package/plugins/blog/admin/templates/comments.html +26 -9
- package/plugins/blog/admin/templates/post-editor.html +63 -40
- package/plugins/blog/admin/templates/settings.html +3 -9
- package/plugins/blog/admin/views/blog.js +288 -159
- package/plugins/blog/admin/views/categories.js +212 -206
- package/plugins/blog/admin/views/comments.js +207 -158
- package/plugins/blog/admin/views/kit.js +372 -0
- package/plugins/blog/admin/views/post-editor.js +306 -39
- package/plugins/blog/admin/views/settings-cog.js +56 -0
- package/plugins/blog/admin/views/settings.js +16 -84
- package/plugins/blog/blocks/blog-card-compact.css +17 -0
- package/plugins/blog/blocks/blog-card-compact.html +8 -0
- package/plugins/blog/blocks/blog-card-row.css +22 -0
- package/plugins/blog/blocks/blog-card-row.html +11 -0
- package/plugins/blog/blocks/blog-card.css +35 -0
- package/plugins/blog/blocks/blog-card.html +16 -0
- package/plugins/blog/blocks/blog-post-classic.css +15 -0
- package/plugins/blog/blocks/blog-post-classic.html +28 -0
- package/plugins/blog/blocks/blog-post-feature.css +20 -0
- package/plugins/blog/blocks/blog-post-feature.html +30 -0
- package/plugins/blog/blocks/blog-post-minimal.css +10 -0
- package/plugins/blog/blocks/blog-post-minimal.html +23 -0
- package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
- package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
- 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 +16 -13
- package/plugins/blog/config.js +6 -0
- package/plugins/blog/lib/layouts.js +304 -0
- package/plugins/blog/lib/page.js +158 -0
- package/plugins/blog/lib/render.js +119 -0
- package/plugins/blog/lib/samples.js +234 -0
- package/plugins/blog/plugin.js +259 -26
- package/plugins/blog/plugin.json +21 -73
- package/plugins/blog/plugin.public.js +217 -0
- package/plugins/blog/public/blog.css +177 -0
- package/plugins/blog/public/blog.js +586 -0
- package/plugins/blog/public/samples/aurora.svg +7 -0
- package/plugins/blog/public/samples/dusk.svg +7 -0
- package/plugins/blog/public/samples/ember.svg +7 -0
- package/plugins/blog/public/samples/harbour.svg +7 -0
- package/plugins/blog/public/samples/meadow.svg +7 -0
- package/plugins/blog/public/samples/rosewood.svg +7 -0
- package/plugins/blog/templates/index.html +9 -8
- package/plugins/blog/templates/post.html +4 -17
- package/plugins/blog/tests/layouts.test.js +97 -0
- package/plugins/blog/tests/public.test.js +71 -0
- package/plugins/free-tier.lock.json +74 -0
- package/plugins/mail-reader/CLAUDE.md +78 -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 +34 -0
- package/public/css/forms.css +1 -1
- package/public/css/search.css +1 -0
- package/public/css/site.css +1 -1
- package/public/css/theme-switcher.css +1 -0
- package/public/js/analytics.js +1 -0
- 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/public/js/theme-switcher.js +1 -0
- package/scripts/build.js +77 -4
- package/scripts/copy-domma.js +48 -0
- package/scripts/run-tests.mjs +92 -0
- package/scripts/seed.js +1996 -0
- package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
- package/server/routes/api/collections.js +34 -0
- package/server/routes/api/contacts.js +506 -0
- package/server/routes/api/context-menus.js +104 -0
- package/server/routes/api/dashboard.js +8 -2
- package/server/routes/api/forms.js +42 -3
- package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
- package/server/routes/api/notifications.js +69 -19
- package/server/routes/api/plugin-marketplace.js +78 -23
- package/server/routes/api/plugins.js +198 -9
- package/server/routes/api/search.js +43 -0
- package/server/routes/api/theme.js +69 -0
- package/server/routes/api/todo.js +178 -0
- package/server/routes/public.js +42 -7
- package/server/server.js +135 -1
- package/server/services/adapters/FileAdapter.js +6 -1
- package/server/services/collections.js +13 -3
- package/server/services/content.js +26 -0
- package/server/services/contextMenus.js +477 -0
- package/server/services/managerClient.js +72 -136
- package/server/services/markdown.js +124 -10
- package/server/services/permissionRegistry.js +74 -0
- package/server/services/pluginEntitlement.js +171 -0
- package/server/services/pluginEnvelope.js +242 -0
- package/server/services/pluginFiles.js +52 -11
- package/server/services/pluginInstaller.js +246 -26
- package/server/services/pluginScaffold.js +5 -0
- package/server/services/plugins.js +340 -7
- package/server/services/presetCollections.js +344 -0
- package/server/services/promoted-plugins-migration.js +254 -0
- package/server/services/publicCors.js +46 -0
- package/server/services/renderer.js +224 -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/sidebarBadges.js +37 -0
- package/server/services/siteGitignore.js +307 -0
- package/server/services/themeSettings.js +580 -0
- package/server/services/users.js +8 -0
- package/server/templates/page.html +4 -2
- package/plugins/analytics/admin/templates/analytics.html +0 -61
- package/plugins/analytics/admin/views/analytics.js +0 -176
- package/plugins/analytics/config.js +0 -14
- package/plugins/analytics/plugin.json +0 -57
- package/plugins/analytics/public/inject-body.html +0 -60
- package/plugins/analytics/public/inject-head.html +0 -1
- package/plugins/blog/templates/author.html +0 -9
- package/plugins/blog/templates/category.html +0 -9
- package/plugins/blog/templates/tag.html +0 -9
- package/plugins/contacts/admin/templates/contacts.html +0 -126
- package/plugins/contacts/admin/views/contacts.js +0 -710
- package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
- package/plugins/contacts/collections/user-contacts/schema.json +0 -71
- package/plugins/contacts/config.js +0 -6
- package/plugins/contacts/data/contacts.json +0 -20
- package/plugins/contacts/plugin.js +0 -291
- package/plugins/contacts/plugin.json +0 -33
- package/plugins/demo-viewer/config.js +0 -4
- package/plugins/demo-viewer/plugin.js +0 -36
- package/plugins/demo-viewer/plugin.json +0 -9
- package/plugins/invoice/admin/templates/editor.html +0 -129
- package/plugins/invoice/admin/templates/index.html +0 -43
- package/plugins/invoice/admin/templates/issuers.html +0 -5
- package/plugins/invoice/admin/templates/receivers.html +0 -5
- package/plugins/invoice/admin/views/editor.js +0 -267
- package/plugins/invoice/admin/views/index.js +0 -155
- package/plugins/invoice/admin/views/issuers.js +0 -23
- package/plugins/invoice/admin/views/party-view.js +0 -148
- package/plugins/invoice/admin/views/receivers.js +0 -22
- package/plugins/invoice/collections/invoice-issuers/schema.json +0 -16
- package/plugins/invoice/collections/invoice-receivers/schema.json +0 -15
- package/plugins/invoice/collections/invoices/schema.json +0 -27
- package/plugins/invoice/config.js +0 -16
- package/plugins/invoice/plugin.js +0 -283
- package/plugins/invoice/plugin.json +0 -85
- package/plugins/invoice/templates/invoice-print.html +0 -213
- package/plugins/notes/admin/templates/notes.html +0 -83
- package/plugins/notes/admin/views/notes.js +0 -281
- package/plugins/notes/collections/user-notes/schema.json +0 -54
- package/plugins/notes/config.js +0 -6
- package/plugins/notes/data/notes.json +0 -1
- package/plugins/notes/plugin.json +0 -33
- 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/surveys/admin/templates/audience.html +0 -47
- package/plugins/surveys/admin/templates/results.html +0 -58
- package/plugins/surveys/admin/templates/survey-editor.html +0 -141
- package/plugins/surveys/admin/templates/surveys.html +0 -25
- package/plugins/surveys/admin/views/audience.js +0 -301
- package/plugins/surveys/admin/views/results.js +0 -172
- package/plugins/surveys/admin/views/survey-editor.js +0 -211
- package/plugins/surveys/admin/views/surveys.js +0 -161
- package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
- package/plugins/surveys/collections/survey-groups/schema.json +0 -11
- package/plugins/surveys/collections/survey-invites/schema.json +0 -16
- package/plugins/surveys/collections/surveys/schema.json +0 -23
- package/plugins/surveys/config.js +0 -8
- package/plugins/surveys/plugin.js +0 -174
- package/plugins/surveys/plugin.json +0 -65
- package/plugins/surveys/public/dist-shared.mjs +0 -1
- package/plugins/surveys/public/survey.css +0 -1
- package/plugins/surveys/public/survey.mjs +0 -1
- package/plugins/surveys/templates/survey-page.html +0 -63
- package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
- package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
- package/plugins/theme-switcher/config.js +0 -10
- package/plugins/theme-switcher/plugin.js +0 -26
- package/plugins/theme-switcher/plugin.json +0 -74
- package/plugins/theme-switcher/public/inject-body.html +0 -153
- package/plugins/theme-switcher/public/inject-head.html +0 -260
- package/plugins/todo/admin/templates/todo.html +0 -158
- package/plugins/todo/admin/views/todo.js +0 -343
- package/plugins/todo/collections/todos/schema.json +0 -60
- package/plugins/todo/config.js +0 -7
- package/plugins/todo/data/todos.json +0 -1
- package/plugins/todo/plugin.js +0 -102
- package/plugins/todo/plugin.json +0 -33
|
@@ -0,0 +1,4473 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three-pane mailbox reader, shared by every edition.
|
|
3
|
+
*
|
|
4
|
+
* `mail-reader` (free) is this view and nothing else. `email-pro` mounts the
|
|
5
|
+
* same three panes and adds composing, flags and a synchronised store, so the
|
|
6
|
+
* folder tree, the pane sizing, the message list and the sandboxed reader all
|
|
7
|
+
* live here rather than being forked.
|
|
8
|
+
*
|
|
9
|
+
* A user may hold several mailboxes. The folders pane lists them, and the
|
|
10
|
+
* selected one expands to show its folders; only the selected mailbox is ever
|
|
11
|
+
* connected to, so adding a second account costs nothing until it is opened.
|
|
12
|
+
*
|
|
13
|
+
* @module _lib/admin/mail/reader-view
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// Imported dynamically, carrying this module's own cache-busting token.
|
|
17
|
+
//
|
|
18
|
+
// A static import has a fixed specifier, so the siblings were fetched without
|
|
19
|
+
// a version while the entry point had one. A browser then kept serving the
|
|
20
|
+
// previous build of the shared code after an update, and the symptom is not
|
|
21
|
+
// "a stale file" - it is a feature that used to work and now does not, on one
|
|
22
|
+
// machine, which is a genuinely horrible thing to debug. Resolving them
|
|
23
|
+
// against import.meta.url means the whole graph moves together.
|
|
24
|
+
const ASSET_VERSION = new URL(import.meta.url).searchParams.get('v') ?? '1';
|
|
25
|
+
const versioned = name => new URL(`./${name}?v=${ASSET_VERSION}`, import.meta.url).href;
|
|
26
|
+
|
|
27
|
+
const {
|
|
28
|
+
buildFolderTree,
|
|
29
|
+
flattenFolderTree,
|
|
30
|
+
folderHeading,
|
|
31
|
+
folderMenuItems,
|
|
32
|
+
iconForFolder,
|
|
33
|
+
pruneToUnread,
|
|
34
|
+
unreadInSubtree
|
|
35
|
+
} = await import(versioned('folder-tree.js'));
|
|
36
|
+
const {imageSendersSection} = await import(versioned('image-senders-section.js'));
|
|
37
|
+
const {createSplitters, fitToViewport} = await import(versioned('panes.js'));
|
|
38
|
+
const {createDiagnosticsSection} = await import(versioned('diagnostics-section.js'));
|
|
39
|
+
const {openCompose} = await import(versioned('compose-window.js'));
|
|
40
|
+
const {makeResizable} = await import(versioned('resizable.js'));
|
|
41
|
+
const {addContact, isKnownAddress, loadContacts} = await import(versioned('contacts.js'));
|
|
42
|
+
const {isPlainKey, isTyping, resolveKey, SHORTCUTS} = await import(versioned('keyboard.js'));
|
|
43
|
+
const {describeWhen, quickTimes} = await import(versioned('scheduling.js'));
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Call a plugin route, returning parsed JSON.
|
|
47
|
+
*
|
|
48
|
+
* Throws an Error carrying the server's own message plus any flags the route
|
|
49
|
+
* set (needsSetup, needsPassword), so callers can branch on them.
|
|
50
|
+
*
|
|
51
|
+
* Deliberately `fetch` rather than Domma's `H`, which is the house rule
|
|
52
|
+
* everywhere else. `H` reduces a failed response to a message string, and this
|
|
53
|
+
* view branches on three things it would throw away: the status code, and the
|
|
54
|
+
* `needsSetup` / `needsPassword` flags that decide whether someone is shown
|
|
55
|
+
* the setup panel, the password prompt, or an error. Using `H` here would mean
|
|
56
|
+
* the reader could no longer tell "no mailbox yet" from "wrong password".
|
|
57
|
+
*
|
|
58
|
+
* @param {string} url
|
|
59
|
+
* @param {string} [method]
|
|
60
|
+
* @param {object} [body]
|
|
61
|
+
* @returns {Promise<object>}
|
|
62
|
+
*/
|
|
63
|
+
async function api(url, method = 'GET', body) {
|
|
64
|
+
const opts = {method, headers: {'Authorization': 'Bearer ' + (S.get('auth_token') || '')}};
|
|
65
|
+
if (body !== undefined) {
|
|
66
|
+
opts.headers['Content-Type'] = 'application/json';
|
|
67
|
+
opts.body = JSON.stringify(body);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const res = await fetch(url, opts);
|
|
71
|
+
const text = await res.text();
|
|
72
|
+
const payload = text ? JSON.parse(text) : {};
|
|
73
|
+
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
const err = new Error(payload.error || res.statusText);
|
|
76
|
+
err.status = res.status;
|
|
77
|
+
err.needsSetup = payload.needsSetup === true;
|
|
78
|
+
err.needsPassword = payload.needsPassword === true;
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
return payload;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Format a message date: time for today, date for anything older.
|
|
86
|
+
*
|
|
87
|
+
* @param {string|null} value
|
|
88
|
+
* @returns {string}
|
|
89
|
+
*/
|
|
90
|
+
function formatDate(value) {
|
|
91
|
+
if (!value) return '';
|
|
92
|
+
const d = D(value);
|
|
93
|
+
return d.format('DD MMM YYYY') === D().format('DD MMM YYYY')
|
|
94
|
+
? d.format('HH:mm')
|
|
95
|
+
: d.format('DD MMM YYYY');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Render an address list as a readable string.
|
|
100
|
+
*
|
|
101
|
+
* @param {{name: string, address: string}[]} list
|
|
102
|
+
* @returns {string}
|
|
103
|
+
*/
|
|
104
|
+
function formatAddresses(list) {
|
|
105
|
+
return (list ?? []).map(a => a.name || a.address).filter(Boolean).join(', ');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Human-readable byte size.
|
|
110
|
+
*
|
|
111
|
+
* @param {number} bytes
|
|
112
|
+
* @returns {string}
|
|
113
|
+
*/
|
|
114
|
+
function formatSize(bytes) {
|
|
115
|
+
if (!bytes) return '';
|
|
116
|
+
const units = ['B', 'KB', 'MB', 'GB'];
|
|
117
|
+
let value = bytes;
|
|
118
|
+
let unit = 0;
|
|
119
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
120
|
+
value /= 1024;
|
|
121
|
+
unit += 1;
|
|
122
|
+
}
|
|
123
|
+
return `${value < 10 && unit > 0 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Build the reader view for one plugin.
|
|
128
|
+
*
|
|
129
|
+
* @param {{pluginName: string, assetVersion?: string, settingsSections?: object[]}} options
|
|
130
|
+
* `settingsSections` lets an edition contribute settings without forking the
|
|
131
|
+
* view: each is `{id, label, render(container, context)}`, and the shell
|
|
132
|
+
* handles the tabs, the lazy drawing and a section that throws.
|
|
133
|
+
* `assetVersion` must come from the CALLING module's own URL: the loader
|
|
134
|
+
* stamps a plugin's view entry with its mtime, and the template needs the
|
|
135
|
+
* same token or a browser keeps serving markup from before the update. This
|
|
136
|
+
* module is imported by a fixed path and carries no stamp of its own.
|
|
137
|
+
* @returns {object} a Domma view
|
|
138
|
+
*/
|
|
139
|
+
export function createReaderView({pluginName, assetVersion = ASSET_VERSION, settingsSections = [], features = {}}) {
|
|
140
|
+
const BASE = `/api/plugins/${pluginName}`;
|
|
141
|
+
|
|
142
|
+
// Deliberately NOT keyed by plugin. The two editions never run together - the
|
|
143
|
+
// Pro one supersedes the free one - so keying per plugin would only mean an
|
|
144
|
+
// upgrade silently forgot your pane widths and which folders you had folded.
|
|
145
|
+
/** Remembered folder-collapse state, keyed by mailbox id. */
|
|
146
|
+
const COLLAPSED_KEY = 'domma-mail.collapsed';
|
|
147
|
+
/** Remembered pane widths. */
|
|
148
|
+
const PANES_KEY = 'domma-mail.panes';
|
|
149
|
+
/** Whether the full headers are showing. */
|
|
150
|
+
const HEADERS_KEY = 'domma-mail.headers-open';
|
|
151
|
+
/** The mailbox last looked at. */
|
|
152
|
+
const ACCOUNT_KEY = 'domma-mail.account';
|
|
153
|
+
/** Whether the list is grouped into conversations. */
|
|
154
|
+
const THREADS_KEY = 'domma-mail.threads';
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* How long a message stays open before it counts as read.
|
|
158
|
+
*
|
|
159
|
+
* Long enough that arrowing through a list does not mark everything it passed
|
|
160
|
+
* over, short enough that actually reading something always marks it.
|
|
161
|
+
*/
|
|
162
|
+
const MARK_READ_DELAY_MS = 1000;
|
|
163
|
+
|
|
164
|
+
/** How long the undo strip stays on screen. */
|
|
165
|
+
const UNDO_WINDOW_MS = 12_000;
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The keyword a spam filter sets, mirroring `JUNK_KEYWORD` in `_lib/mail/write.js`.
|
|
169
|
+
*
|
|
170
|
+
* Spelled out again rather than imported: this is browser code and that is
|
|
171
|
+
* server code, and a shared module for one string would be the longer way to
|
|
172
|
+
* the same place. It is the IMAP-conventional spelling, so it does not move.
|
|
173
|
+
*/
|
|
174
|
+
const JUNK_KEYWORD = '$Junk';
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* What a mailbox that cannot send, or whose settings could not be fetched,
|
|
178
|
+
* looks like.
|
|
179
|
+
*
|
|
180
|
+
* A frozen object rather than null: every reader of it would otherwise need a
|
|
181
|
+
* null branch, and the one that forgot would be the one that threw inside a
|
|
182
|
+
* compose window someone was waiting on.
|
|
183
|
+
*/
|
|
184
|
+
const EMPTY_PROFILE = Object.freeze({
|
|
185
|
+
identities: [], mailboxAddress: '', templates: [], undoSendSeconds: 0
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Make sure the shared stylesheet is on the page.
|
|
190
|
+
*
|
|
191
|
+
* It lives beside this module rather than in either plugin, so it cannot be
|
|
192
|
+
* declared through `plugin.json`'s `admin.css` - that builds hrefs under the
|
|
193
|
+
* plugin's own directory. Injecting it here keeps one copy for both editions,
|
|
194
|
+
* and the id makes it idempotent across mounts.
|
|
195
|
+
*
|
|
196
|
+
* @returns {void}
|
|
197
|
+
*/
|
|
198
|
+
function ensureStylesheet() {
|
|
199
|
+
const id = 'dm-mail-shared-css';
|
|
200
|
+
const href = `/plugins/_lib/admin/mail/mail.css?v=${assetVersion}`;
|
|
201
|
+
const existing = document.getElementById(id);
|
|
202
|
+
if (existing) {
|
|
203
|
+
// A newer plugin build means a newer token; swap rather than stack.
|
|
204
|
+
if (existing.getAttribute('href') !== href) existing.setAttribute('href', href);
|
|
205
|
+
return Promise.resolve();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const link = document.createElement('link');
|
|
209
|
+
link.id = id;
|
|
210
|
+
link.rel = 'stylesheet';
|
|
211
|
+
link.href = href;
|
|
212
|
+
|
|
213
|
+
// Resolved on load, because the layout is measured as soon as the view
|
|
214
|
+
// mounts. Measuring before this arrives forces a layout against styles
|
|
215
|
+
// that are not there yet - the browser says so, and the panes would be
|
|
216
|
+
// sized from the unstyled markup.
|
|
217
|
+
const ready = new Promise(resolve => {
|
|
218
|
+
link.addEventListener('load', resolve, {once: true});
|
|
219
|
+
link.addEventListener('error', resolve, {once: true});
|
|
220
|
+
});
|
|
221
|
+
document.head.appendChild(link);
|
|
222
|
+
return ready;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
templateUrl: `/plugins/_lib/admin/mail/mail.html?v=${assetVersion}`,
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Mount the mail view.
|
|
230
|
+
*
|
|
231
|
+
* @param {object} $container - Domma-wrapped container element
|
|
232
|
+
* @returns {Promise<void>}
|
|
233
|
+
*/
|
|
234
|
+
async onMount($container) {
|
|
235
|
+
const stylesheetReady = ensureStylesheet();
|
|
236
|
+
|
|
237
|
+
// Held so onUnmount can detach the viewport listeners.
|
|
238
|
+
let stopFitting = null;
|
|
239
|
+
// ---------------------------------------------------------------
|
|
240
|
+
// State
|
|
241
|
+
// ---------------------------------------------------------------
|
|
242
|
+
let mailboxes = [];
|
|
243
|
+
let currentAccountId = null;
|
|
244
|
+
let folders = [];
|
|
245
|
+
let currentFolder = 'INBOX';
|
|
246
|
+
let messages = [];
|
|
247
|
+
/**
|
|
248
|
+
* Whether the list groups messages into conversations. Remembered,
|
|
249
|
+
* because it is a way of reading rather than a thing you do to one
|
|
250
|
+
* folder.
|
|
251
|
+
*/
|
|
252
|
+
let threadsOn = (() => {
|
|
253
|
+
try {
|
|
254
|
+
return features.threads === true && S.get(THREADS_KEY) === true;
|
|
255
|
+
} catch {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
})();
|
|
259
|
+
/** Conversation roots the reader has opened out. Not remembered. */
|
|
260
|
+
const expandedThreads = new Set();
|
|
261
|
+
/** The conversations behind the current page, so opening one is free. */
|
|
262
|
+
let threads = [];
|
|
263
|
+
let page = 1;
|
|
264
|
+
let total = 0;
|
|
265
|
+
let limit = 50;
|
|
266
|
+
/**
|
|
267
|
+
* The Contacts plugin's address book, or an empty list where it is not
|
|
268
|
+
* installed. Held here rather than fetched per compose window: the
|
|
269
|
+
* message menu needs it too, and it does not change while someone
|
|
270
|
+
* reads their mail.
|
|
271
|
+
*/
|
|
272
|
+
let folderMenu = null;
|
|
273
|
+
let addressBook = [];
|
|
274
|
+
/**
|
|
275
|
+
* Everything the compose window needs to know about this mailbox, as
|
|
276
|
+
* a promise, fetched once per mailbox.
|
|
277
|
+
*
|
|
278
|
+
* A promise rather than a value because compose can be opened before
|
|
279
|
+
* any fetch would have landed, and a signature that is missing
|
|
280
|
+
* because the request was still in flight is a message sent wrong.
|
|
281
|
+
* Awaiting an in-flight or settled promise costs nothing; guessing
|
|
282
|
+
* costs the signature.
|
|
283
|
+
*
|
|
284
|
+
* One promise for three requests - identities, templates and the
|
|
285
|
+
* sending options - because they are wanted at the same moment, by
|
|
286
|
+
* the same window, and three separate caches would be three chances
|
|
287
|
+
* to invalidate two of them.
|
|
288
|
+
*/
|
|
289
|
+
let profileRequest = null;
|
|
290
|
+
/** Which mailbox `profileRequest` belongs to. */
|
|
291
|
+
let profileFor = null;
|
|
292
|
+
/** Whether the message header block is expanded. Remembered. */
|
|
293
|
+
let headersOpen = (() => {
|
|
294
|
+
try {
|
|
295
|
+
return S.get(HEADERS_KEY) === true;
|
|
296
|
+
} catch {
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
})();
|
|
300
|
+
/** Whether Contacts is installed at all - not the same as it being empty. */
|
|
301
|
+
let contactsAvailable = false;
|
|
302
|
+
/**
|
|
303
|
+
* The open message. Observable because selecting one only changes two
|
|
304
|
+
* rows - the one leaving and the one arriving - and rebuilding the
|
|
305
|
+
* list to express that was always wasteful.
|
|
306
|
+
*/
|
|
307
|
+
const selectedUid$ = M.observable(null);
|
|
308
|
+
/** Per-message "load images" overrides. Intentionally not persisted. */
|
|
309
|
+
const imagesAllowed = new Set();
|
|
310
|
+
/**
|
|
311
|
+
* Folder paths whose children are hidden, per mailbox. Remembered
|
|
312
|
+
* between visits - re-collapsing the same branches every time you open
|
|
313
|
+
* the reader would be the sort of thing that makes people stop using
|
|
314
|
+
* folders at all.
|
|
315
|
+
*/
|
|
316
|
+
let collapsedByAccount = loadCollapsed();
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Read remembered collapse state, tolerating anything stored badly.
|
|
320
|
+
*
|
|
321
|
+
* @returns {Object.<string, string[]>}
|
|
322
|
+
*/
|
|
323
|
+
function loadCollapsed() {
|
|
324
|
+
try {
|
|
325
|
+
const stored = S.get(COLLAPSED_KEY);
|
|
326
|
+
return stored && typeof stored === 'object' ? stored : {};
|
|
327
|
+
} catch {
|
|
328
|
+
return {};
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** @returns {Set<string>} the current mailbox's collapsed paths */
|
|
333
|
+
function collapsedSet() {
|
|
334
|
+
return new Set(collapsedByAccount[currentAccountId] ?? []);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Toggle one folder open or shut and remember it.
|
|
339
|
+
*
|
|
340
|
+
* @param {string} path
|
|
341
|
+
* @returns {void}
|
|
342
|
+
*/
|
|
343
|
+
function toggleFolder(path) {
|
|
344
|
+
const set = collapsedSet();
|
|
345
|
+
if (set.has(path)) set.delete(path);
|
|
346
|
+
else set.add(path);
|
|
347
|
+
collapsedByAccount = {...collapsedByAccount, [currentAccountId]: [...set]};
|
|
348
|
+
try {
|
|
349
|
+
S.set(COLLAPSED_KEY, collapsedByAccount);
|
|
350
|
+
} catch {
|
|
351
|
+
// Storage unavailable - the fold still works for this visit.
|
|
352
|
+
}
|
|
353
|
+
renderFolders();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const foldersEl = $container.find('#mail-folders-list').get(0);
|
|
357
|
+
const listEl = $container.find('#mail-list').get(0);
|
|
358
|
+
const selectAllEl = $container.find('#mail-select-all').get(0);
|
|
359
|
+
const selectAllBox = $container.find('#mail-select-all-box').get(0);
|
|
360
|
+
const pagerEl = $container.find('#mail-pager').get(0);
|
|
361
|
+
const readerHeadEl = $container.find('#mail-reader-head').get(0);
|
|
362
|
+
const readerBodyEl = $container.find('#mail-reader-body').get(0);
|
|
363
|
+
const listTitleEl = $container.find('#mail-list-title').get(0);
|
|
364
|
+
const searchEl = $container.find('#mail-search').get(0);
|
|
365
|
+
const searchInputEl = $container.find('#mail-search-input').get(0);
|
|
366
|
+
const searchClearEl = $container.find('#mail-search-clear').get(0);
|
|
367
|
+
const searchNoteEl = $container.find('#mail-search-note').get(0);
|
|
368
|
+
const undoEl = $container.find('#mail-undo').get(0);
|
|
369
|
+
const selectionEl = $container.find('#mail-selection').get(0);
|
|
370
|
+
const unreadFilterEl = $container.find('#mail-unread-filter').get(0);
|
|
371
|
+
const unreadOnlyEl = $container.find('#mail-unread-only').get(0);
|
|
372
|
+
const threadsBtn = $container.find('#mail-threads-btn').get(0);
|
|
373
|
+
const outboxBtn = $container.find('#mail-outbox-btn').get(0);
|
|
374
|
+
const outboxCountEl = $container.find('#mail-outbox-count').get(0);
|
|
375
|
+
/** Show only folders that lead to unread mail. */
|
|
376
|
+
let unreadOnly = false;
|
|
377
|
+
|
|
378
|
+
/** The active search, or null when browsing a folder normally. */
|
|
379
|
+
let searchQuery = null;
|
|
380
|
+
/** The folder the open message lives in - not always the selected one. */
|
|
381
|
+
let openFolder = null;
|
|
382
|
+
/** Where the last message list came from: the mirror, or the server. */
|
|
383
|
+
let lastListSource = null;
|
|
384
|
+
/** Server-side limits, fetched once so the UI can enforce them early. */
|
|
385
|
+
let limits = {maxAttachmentBytes: 20_000_000};
|
|
386
|
+
/** Which pane the keyboard is driving. */
|
|
387
|
+
let pane = 'messages';
|
|
388
|
+
/** The row the keyboard is on, which is not always the open message. */
|
|
389
|
+
let cursorUid = null;
|
|
390
|
+
/** Held so the document listener can be taken off again. */
|
|
391
|
+
let keyboardHandler = null;
|
|
392
|
+
/** UIDs ticked for a bulk action. */
|
|
393
|
+
const selected = new Set();
|
|
394
|
+
/**
|
|
395
|
+
* Bumped whenever the selection moves.
|
|
396
|
+
*
|
|
397
|
+
* A Set is not observable, so the rows watch this instead - one
|
|
398
|
+
* assignment repaints the class list of every visible row without
|
|
399
|
+
* rebuilding any of them.
|
|
400
|
+
*/
|
|
401
|
+
const selectionVersion$ = M.observable(0);
|
|
402
|
+
/** Where a shift-click measures its range from. */
|
|
403
|
+
let selectionAnchor = null;
|
|
404
|
+
/** Pending "mark as read" for the open message, if it is still unread. */
|
|
405
|
+
let markReadTimer = null;
|
|
406
|
+
/** The registered right-click menu, so it can be destroyed on unmount. */
|
|
407
|
+
let messageMenu = null;
|
|
408
|
+
/** Hides the undo strip once its moment has passed. */
|
|
409
|
+
let undoTimer = null;
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Errors seen while this view has been open.
|
|
413
|
+
*
|
|
414
|
+
* Captured rather than asked for. "Anything in the console?" is a
|
|
415
|
+
* question that costs a round trip and usually gets "I think so" -
|
|
416
|
+
* this puts the actual text in the report.
|
|
417
|
+
*/
|
|
418
|
+
const capturedErrors = [];
|
|
419
|
+
const captureError = event => {
|
|
420
|
+
if (capturedErrors.length >= 25) return;
|
|
421
|
+
capturedErrors.push({
|
|
422
|
+
at: new Date().toISOString(),
|
|
423
|
+
message: String(event?.message ?? event?.reason?.message ?? event?.reason ?? 'unknown'),
|
|
424
|
+
source: event?.filename ? `${event.filename}:${event.lineno ?? '?'}` : null
|
|
425
|
+
});
|
|
426
|
+
};
|
|
427
|
+
window.addEventListener('error', captureError);
|
|
428
|
+
window.addEventListener('unhandledrejection', captureError);
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Live effects belonging to the rows currently on screen.
|
|
432
|
+
*
|
|
433
|
+
* Domma's reactivity is explicit about this: an effect is a node in the
|
|
434
|
+
* dependency graph and dropping its DOM does not drop it. The list is
|
|
435
|
+
* rebuilt on every folder change and every page, so without this the
|
|
436
|
+
* graph would grow by a row per render for the life of the view.
|
|
437
|
+
*/
|
|
438
|
+
let rowEffects = [];
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Tear down the row effects from the previous render.
|
|
442
|
+
*
|
|
443
|
+
* @returns {void}
|
|
444
|
+
*/
|
|
445
|
+
function disposeRowEffects() {
|
|
446
|
+
for (const stop of rowEffects) {
|
|
447
|
+
// Domma's M.effect() hands back a plain disposer function.
|
|
448
|
+
// The standalone domma-reactive documents a Computation with
|
|
449
|
+
// .dispose(), so both shapes are honoured rather than trusting
|
|
450
|
+
// a README over the build actually loaded.
|
|
451
|
+
if (typeof stop === 'function') stop();
|
|
452
|
+
else stop?.dispose?.();
|
|
453
|
+
}
|
|
454
|
+
rowEffects = [];
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ---------------------------------------------------------------
|
|
458
|
+
// Shared UI helpers
|
|
459
|
+
// ---------------------------------------------------------------
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Replace a pane with a centred message and an optional action.
|
|
463
|
+
*
|
|
464
|
+
* @param {HTMLElement} el
|
|
465
|
+
* @param {string} icon
|
|
466
|
+
* @param {string} message
|
|
467
|
+
* @param {{label: string, onClick: Function}} [action]
|
|
468
|
+
* @returns {void}
|
|
469
|
+
*/
|
|
470
|
+
function showEmpty(el, icon, message, action) {
|
|
471
|
+
el.textContent = '';
|
|
472
|
+
const wrap = document.createElement('div');
|
|
473
|
+
wrap.className = 'mail-empty';
|
|
474
|
+
|
|
475
|
+
const iconEl = document.createElement('span');
|
|
476
|
+
iconEl.setAttribute('data-icon', icon);
|
|
477
|
+
iconEl.setAttribute('data-icon-size', '40');
|
|
478
|
+
wrap.appendChild(iconEl);
|
|
479
|
+
|
|
480
|
+
const p = document.createElement('p');
|
|
481
|
+
p.textContent = message;
|
|
482
|
+
wrap.appendChild(p);
|
|
483
|
+
|
|
484
|
+
if (action) {
|
|
485
|
+
const btn = document.createElement('button');
|
|
486
|
+
btn.className = 'btn btn-sm btn-primary';
|
|
487
|
+
btn.textContent = action.label;
|
|
488
|
+
btn.addEventListener('click', action.onClick);
|
|
489
|
+
wrap.appendChild(btn);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
el.appendChild(wrap);
|
|
493
|
+
Domma.icons.scan(el);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Surface an error in the right place: a missing mailbox or an
|
|
498
|
+
* unopenable password are setup problems, everything else is a toast.
|
|
499
|
+
*
|
|
500
|
+
* @param {Error} err
|
|
501
|
+
* @returns {void}
|
|
502
|
+
*/
|
|
503
|
+
function handleError(err) {
|
|
504
|
+
if (err.needsSetup) {
|
|
505
|
+
showEmpty(listEl, 'settings', 'No mailbox is set up yet.', {
|
|
506
|
+
label: 'Add a mailbox',
|
|
507
|
+
onClick: () => openMailboxForm(null)
|
|
508
|
+
});
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
if (err.needsPassword) {
|
|
512
|
+
E.toast(err.message, {type: 'warning', duration: 8000});
|
|
513
|
+
openSettings();
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
E.toast(err.message || 'Something went wrong.', {type: 'error'});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Build one labelled input inside a container.
|
|
521
|
+
*
|
|
522
|
+
* @param {HTMLElement} parent
|
|
523
|
+
* @param {string} name
|
|
524
|
+
* @param {string} label
|
|
525
|
+
* @param {string} type
|
|
526
|
+
* @param {string} value
|
|
527
|
+
* @param {string} [hint]
|
|
528
|
+
* @returns {HTMLInputElement}
|
|
529
|
+
*/
|
|
530
|
+
function field(parent, name, label, type, value, hint) {
|
|
531
|
+
const wrap = document.createElement('div');
|
|
532
|
+
const labelEl = document.createElement('label');
|
|
533
|
+
labelEl.className = 'form-label';
|
|
534
|
+
labelEl.textContent = label;
|
|
535
|
+
const input = document.createElement('input');
|
|
536
|
+
input.type = type;
|
|
537
|
+
input.className = 'form-input';
|
|
538
|
+
input.name = name;
|
|
539
|
+
input.value = value ?? '';
|
|
540
|
+
if (type === 'password') input.autocomplete = 'new-password';
|
|
541
|
+
wrap.appendChild(labelEl);
|
|
542
|
+
wrap.appendChild(input);
|
|
543
|
+
if (hint) {
|
|
544
|
+
const hintEl = document.createElement('div');
|
|
545
|
+
hintEl.className = 'text-xs text-muted mail-hint';
|
|
546
|
+
hintEl.textContent = hint;
|
|
547
|
+
wrap.appendChild(hintEl);
|
|
548
|
+
}
|
|
549
|
+
parent.appendChild(wrap);
|
|
550
|
+
return input;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ---------------------------------------------------------------
|
|
554
|
+
// Mailbox settings
|
|
555
|
+
// ---------------------------------------------------------------
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Open the mailbox list: every mailbox this user holds, plus Add.
|
|
559
|
+
*
|
|
560
|
+
* Content is real DOM appended to `slideover.element`; passing markup
|
|
561
|
+
* through setContent strips inputs and buttons.
|
|
562
|
+
*
|
|
563
|
+
* @returns {void}
|
|
564
|
+
*/
|
|
565
|
+
/**
|
|
566
|
+
* Open the settings slideover.
|
|
567
|
+
*
|
|
568
|
+
* Built as sections rather than one form. The free edition has a
|
|
569
|
+
* single one - the mailbox list - and the Pro edition contributes
|
|
570
|
+
* folder selection, the poll schedule and, in time, sending and
|
|
571
|
+
* signatures. A growing flat form would be unreadable long before
|
|
572
|
+
* that; sections mean a new one is a registration rather than an edit
|
|
573
|
+
* to shared code.
|
|
574
|
+
*
|
|
575
|
+
* Tabs only appear when there is more than one section, so the free
|
|
576
|
+
* edition does not sprout a tab bar with nothing to switch to.
|
|
577
|
+
*
|
|
578
|
+
* @returns {void}
|
|
579
|
+
*/
|
|
580
|
+
function openSettings() {
|
|
581
|
+
const slideover = E.slideover({title: 'Mail Settings', size: 'lg', position: 'right'});
|
|
582
|
+
// Settings has tables of mailboxes and folder lists in it, so the
|
|
583
|
+
// width someone wants depends on their screen - the same reason
|
|
584
|
+
// compose is resizable. Its own remembered width, because the two
|
|
585
|
+
// are opened for different things.
|
|
586
|
+
const stopSettingsResize = makeResizable(slideover, {
|
|
587
|
+
storageKey: 'domma-mail.settings-width',
|
|
588
|
+
defaultWidth: 760
|
|
589
|
+
});
|
|
590
|
+
slideover.on?.('close', () => stopSettingsResize());
|
|
591
|
+
|
|
592
|
+
const sections = [
|
|
593
|
+
{
|
|
594
|
+
id: 'mailboxes',
|
|
595
|
+
label: 'Mailboxes',
|
|
596
|
+
icon: 'inbox',
|
|
597
|
+
render: renderMailboxSection
|
|
598
|
+
},
|
|
599
|
+
// Blocking remote images is not a Pro feature, and neither is
|
|
600
|
+
// choosing to trust a sender, so both editions get this one.
|
|
601
|
+
imageSendersSection,
|
|
602
|
+
...settingsSections,
|
|
603
|
+
// Last, because it is the one you open when something is wrong.
|
|
604
|
+
createDiagnosticsSection({
|
|
605
|
+
recentErrors: () => [...capturedErrors],
|
|
606
|
+
state: () => ({
|
|
607
|
+
mailbox: mailboxes.find(b => b.id === currentAccountId)?.label ?? null,
|
|
608
|
+
mailboxId: currentAccountId,
|
|
609
|
+
folder: currentFolder,
|
|
610
|
+
listSource: lastListSource,
|
|
611
|
+
messagesShown: messages.length,
|
|
612
|
+
totalInFolder: total,
|
|
613
|
+
searching: searchQuery ?? null,
|
|
614
|
+
openMessage: selectedUid$.value
|
|
615
|
+
})
|
|
616
|
+
})
|
|
617
|
+
];
|
|
618
|
+
|
|
619
|
+
const host = document.createElement('div');
|
|
620
|
+
host.className = 'mail-panel';
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Everything a contributed section is handed.
|
|
624
|
+
*
|
|
625
|
+
* Deliberately small: the account it applies to, a way to call the
|
|
626
|
+
* plugin's own API, the form-field helper so sections look alike,
|
|
627
|
+
* and a way to say something changed.
|
|
628
|
+
*
|
|
629
|
+
* @returns {object}
|
|
630
|
+
*/
|
|
631
|
+
function context() {
|
|
632
|
+
return {
|
|
633
|
+
account: mailboxes.find(b => b.id === currentAccountId) ?? mailboxes[0] ?? null,
|
|
634
|
+
mailboxes,
|
|
635
|
+
base: BASE,
|
|
636
|
+
api,
|
|
637
|
+
// So a section can import a sibling module with the same
|
|
638
|
+
// cache-busting token this view was loaded with. Without
|
|
639
|
+
// it the section would fetch an unversioned copy and get
|
|
640
|
+
// whatever the browser had cached.
|
|
641
|
+
assetVersion,
|
|
642
|
+
field,
|
|
643
|
+
close: () => slideover.close(),
|
|
644
|
+
/**
|
|
645
|
+
* @param {{reload?: boolean}} [options]
|
|
646
|
+
* @returns {Promise<void>}
|
|
647
|
+
*/
|
|
648
|
+
async changed({reload = false} = {}) {
|
|
649
|
+
// Unconditionally, whatever changed: several sections
|
|
650
|
+
// now edit part of the sending profile - identities,
|
|
651
|
+
// templates, how long Send is held - and a section
|
|
652
|
+
// that has to remember to name what it invalidated is
|
|
653
|
+
// a section that will one day forget.
|
|
654
|
+
profileFor = null;
|
|
655
|
+
refreshOutbox();
|
|
656
|
+
if (reload) await start();
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Draw one section into a panel, tolerating a section that throws.
|
|
663
|
+
*
|
|
664
|
+
* @param {object} section
|
|
665
|
+
* @param {HTMLElement} panel
|
|
666
|
+
* @returns {Promise<void>}
|
|
667
|
+
*/
|
|
668
|
+
async function draw(section, panel) {
|
|
669
|
+
panel.textContent = '';
|
|
670
|
+
try {
|
|
671
|
+
await section.render(panel, context());
|
|
672
|
+
} catch (err) {
|
|
673
|
+
const message = document.createElement('p');
|
|
674
|
+
message.className = 'text-sm text-danger';
|
|
675
|
+
message.textContent = err.message || 'This section could not be loaded.';
|
|
676
|
+
panel.appendChild(message);
|
|
677
|
+
}
|
|
678
|
+
Domma.icons.scan(panel);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (sections.length === 1) {
|
|
682
|
+
host.appendChild(document.createElement('div'));
|
|
683
|
+
draw(sections[0], host.lastChild);
|
|
684
|
+
} else {
|
|
685
|
+
const tabs = document.createElement('div');
|
|
686
|
+
tabs.className = 'tabs';
|
|
687
|
+
|
|
688
|
+
const list = document.createElement('div');
|
|
689
|
+
list.className = 'tab-list';
|
|
690
|
+
tabs.appendChild(list);
|
|
691
|
+
|
|
692
|
+
const panels = [];
|
|
693
|
+
sections.forEach((section, index) => {
|
|
694
|
+
const trigger = document.createElement('button');
|
|
695
|
+
trigger.className = 'tab-item' + (index === 0 ? ' active' : '');
|
|
696
|
+
trigger.type = 'button';
|
|
697
|
+
trigger.textContent = section.label;
|
|
698
|
+
list.appendChild(trigger);
|
|
699
|
+
|
|
700
|
+
const panel = document.createElement('div');
|
|
701
|
+
panel.className = 'tab-panel' + (index === 0 ? ' active' : '');
|
|
702
|
+
tabs.appendChild(panel);
|
|
703
|
+
panels.push({section, panel, drawn: false});
|
|
704
|
+
|
|
705
|
+
// Sections are drawn on first view, not up front: a folder
|
|
706
|
+
// list means a round trip to the mail server, and opening
|
|
707
|
+
// settings should not wait for tabs nobody looked at.
|
|
708
|
+
trigger.addEventListener('click', () => {
|
|
709
|
+
const entry = panels[index];
|
|
710
|
+
if (entry.drawn) return;
|
|
711
|
+
entry.drawn = true;
|
|
712
|
+
draw(entry.section, entry.panel);
|
|
713
|
+
});
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
host.appendChild(tabs);
|
|
717
|
+
slideover.element.appendChild(host);
|
|
718
|
+
E.tabs(tabs);
|
|
719
|
+
panels[0].drawn = true;
|
|
720
|
+
draw(panels[0].section, panels[0].panel);
|
|
721
|
+
slideover.open();
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
slideover.element.appendChild(host);
|
|
726
|
+
slideover.open();
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* The mailbox list: what the free edition's settings consist of.
|
|
731
|
+
*
|
|
732
|
+
* @param {HTMLElement} container
|
|
733
|
+
* @returns {void}
|
|
734
|
+
*/
|
|
735
|
+
function renderMailboxSection(container) {
|
|
736
|
+
const body = document.createElement('div');
|
|
737
|
+
body.className = 'flex flex-col gap-2';
|
|
738
|
+
|
|
739
|
+
if (!mailboxes.length) {
|
|
740
|
+
const empty = document.createElement('p');
|
|
741
|
+
empty.className = 'text-sm text-muted mail-empty-line';
|
|
742
|
+
empty.textContent = 'No mailboxes yet.';
|
|
743
|
+
body.appendChild(empty);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
for (const box of mailboxes) {
|
|
747
|
+
const row = document.createElement('div');
|
|
748
|
+
row.className = 'mail-box-row';
|
|
749
|
+
|
|
750
|
+
const info = document.createElement('div');
|
|
751
|
+
info.className = 'flex-1 mail-ellipsis';
|
|
752
|
+
const name = document.createElement('div');
|
|
753
|
+
name.className = 'font-semibold text-sm';
|
|
754
|
+
name.textContent = box.label;
|
|
755
|
+
const sub = document.createElement('div');
|
|
756
|
+
sub.className = 'text-xs text-muted mail-box-sub';
|
|
757
|
+
sub.textContent = `${box.username} \u00b7 ${box.host}:${box.port}${box.secure ? '' : ' (no TLS)'}`;
|
|
758
|
+
info.appendChild(name);
|
|
759
|
+
info.appendChild(sub);
|
|
760
|
+
row.appendChild(info);
|
|
761
|
+
|
|
762
|
+
const editBtn = document.createElement('button');
|
|
763
|
+
editBtn.className = 'btn btn-sm btn-ghost';
|
|
764
|
+
editBtn.textContent = 'Edit';
|
|
765
|
+
editBtn.addEventListener('click', () => openMailboxForm(box));
|
|
766
|
+
|
|
767
|
+
const removeBtn = document.createElement('button');
|
|
768
|
+
removeBtn.className = 'btn btn-sm btn-ghost';
|
|
769
|
+
removeBtn.classList.add('text-danger');
|
|
770
|
+
removeBtn.textContent = 'Remove';
|
|
771
|
+
removeBtn.addEventListener('click', async () => {
|
|
772
|
+
if (!await E.confirm(`Remove "${box.label}"? The stored password is deleted.`)) return;
|
|
773
|
+
try {
|
|
774
|
+
await api(`${BASE}/accounts/${encodeURIComponent(box.id)}`, 'DELETE');
|
|
775
|
+
E.toast('Mailbox removed.', {type: 'success'});
|
|
776
|
+
if (currentAccountId === box.id) currentAccountId = null;
|
|
777
|
+
await start();
|
|
778
|
+
row.remove();
|
|
779
|
+
} catch (err) {
|
|
780
|
+
E.toast(err.message, {type: 'error'});
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
row.appendChild(editBtn);
|
|
785
|
+
row.appendChild(removeBtn);
|
|
786
|
+
body.appendChild(row);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const addBtn = document.createElement('button');
|
|
790
|
+
addBtn.className = 'btn btn-sm btn-primary';
|
|
791
|
+
addBtn.classList.add('mail-add-btn');
|
|
792
|
+
addBtn.textContent = 'Add mailbox';
|
|
793
|
+
addBtn.addEventListener('click', () => openMailboxForm(null));
|
|
794
|
+
body.appendChild(addBtn);
|
|
795
|
+
|
|
796
|
+
container.appendChild(body);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Open the add/edit form for one mailbox.
|
|
801
|
+
*
|
|
802
|
+
* @param {object|null} box - null to add
|
|
803
|
+
* @returns {void}
|
|
804
|
+
*/
|
|
805
|
+
function openMailboxForm(box) {
|
|
806
|
+
const slideover = E.slideover({
|
|
807
|
+
title: box ? 'Edit Mailbox' : 'Add Mailbox',
|
|
808
|
+
size: 'md',
|
|
809
|
+
position: 'right'
|
|
810
|
+
});
|
|
811
|
+
const stopFormResize = makeResizable(slideover, {
|
|
812
|
+
storageKey: 'domma-mail.mailbox-form-width',
|
|
813
|
+
defaultWidth: 560
|
|
814
|
+
});
|
|
815
|
+
slideover.on?.('close', () => stopFormResize());
|
|
816
|
+
|
|
817
|
+
const form = document.createElement('div');
|
|
818
|
+
form.className = 'mail-form-stack';
|
|
819
|
+
|
|
820
|
+
const labelInput = field(form, 'label', 'Name', 'text', box?.label ?? '', 'What to call this mailbox.');
|
|
821
|
+
const hostInput = field(form, 'host', 'IMAP server', 'text', box?.host ?? '', 'e.g. mail.example.com');
|
|
822
|
+
const portInput = field(form, 'port', 'Port', 'number', String(box?.port ?? 993));
|
|
823
|
+
const userInput = field(form, 'username', 'Username', 'text', box?.username ?? '');
|
|
824
|
+
const passInput = field(
|
|
825
|
+
form, 'password', 'Password', 'password', '',
|
|
826
|
+
box?.hasPassword ? 'Leave blank to keep the saved password.' : ''
|
|
827
|
+
);
|
|
828
|
+
|
|
829
|
+
const secureWrap = document.createElement('label');
|
|
830
|
+
secureWrap.className = 'flex items-center gap-2 text-sm';
|
|
831
|
+
const secureInput = document.createElement('input');
|
|
832
|
+
secureInput.type = 'checkbox';
|
|
833
|
+
secureInput.checked = box ? box.secure !== false : true;
|
|
834
|
+
secureWrap.appendChild(secureInput);
|
|
835
|
+
secureWrap.appendChild(document.createTextNode('Use TLS (port 993)'));
|
|
836
|
+
form.appendChild(secureWrap);
|
|
837
|
+
|
|
838
|
+
// Keep the port in step with the TLS choice until the user sets one
|
|
839
|
+
// themselves - 993/143 is the only pair that matters here.
|
|
840
|
+
let portTouched = false;
|
|
841
|
+
portInput.addEventListener('input', () => {
|
|
842
|
+
portTouched = true;
|
|
843
|
+
});
|
|
844
|
+
secureInput.addEventListener('change', () => {
|
|
845
|
+
if (!portTouched) portInput.value = secureInput.checked ? '993' : '143';
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
const status = document.createElement('div');
|
|
849
|
+
status.className = 'text-sm mail-status';
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Say something under the outgoing-server form.
|
|
853
|
+
*
|
|
854
|
+
* A class rather than `style.color`, for the reason given
|
|
855
|
+
* throughout this file: an inline colour cannot read a `--dm-*`
|
|
856
|
+
* token that a theme override has redefined, so a status line
|
|
857
|
+
* set that way is the same shade in every theme.
|
|
858
|
+
*
|
|
859
|
+
* @param {string} text
|
|
860
|
+
* @param {'ok'|'bad'|'quiet'} [tone]
|
|
861
|
+
* @returns {void}
|
|
862
|
+
*/
|
|
863
|
+
const say = (text, tone = 'quiet') => {
|
|
864
|
+
status.textContent = text ?? '';
|
|
865
|
+
status.className = 'text-sm mail-status '
|
|
866
|
+
+ ({ok: 'text-success', bad: 'text-danger'}[tone] ?? 'text-muted');
|
|
867
|
+
};
|
|
868
|
+
form.appendChild(status);
|
|
869
|
+
|
|
870
|
+
const actions = document.createElement('div');
|
|
871
|
+
actions.className = 'flex justify-between gap-2 mt-2';
|
|
872
|
+
|
|
873
|
+
const testBtn = document.createElement('button');
|
|
874
|
+
testBtn.className = 'btn btn-sm btn-ghost';
|
|
875
|
+
testBtn.textContent = 'Test connection';
|
|
876
|
+
|
|
877
|
+
const right = document.createElement('div');
|
|
878
|
+
right.className = 'flex gap-2';
|
|
879
|
+
const cancelBtn = document.createElement('button');
|
|
880
|
+
cancelBtn.className = 'btn btn-sm btn-ghost';
|
|
881
|
+
cancelBtn.textContent = 'Cancel';
|
|
882
|
+
const saveBtn = document.createElement('button');
|
|
883
|
+
saveBtn.className = 'btn btn-sm btn-primary';
|
|
884
|
+
saveBtn.textContent = 'Save';
|
|
885
|
+
right.appendChild(cancelBtn);
|
|
886
|
+
right.appendChild(saveBtn);
|
|
887
|
+
|
|
888
|
+
actions.appendChild(testBtn);
|
|
889
|
+
actions.appendChild(right);
|
|
890
|
+
form.appendChild(actions);
|
|
891
|
+
|
|
892
|
+
slideover.element.appendChild(form);
|
|
893
|
+
slideover.open();
|
|
894
|
+
setTimeout(() => hostInput.focus(), 50);
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Collect the form into the shape the API expects.
|
|
898
|
+
*
|
|
899
|
+
* @returns {object}
|
|
900
|
+
*/
|
|
901
|
+
function collect() {
|
|
902
|
+
return {
|
|
903
|
+
...(box ? {id: box.id} : {}),
|
|
904
|
+
label: labelInput.value.trim(),
|
|
905
|
+
host: hostInput.value.trim(),
|
|
906
|
+
port: Number(portInput.value),
|
|
907
|
+
secure: secureInput.checked,
|
|
908
|
+
username: userInput.value.trim(),
|
|
909
|
+
password: passInput.value
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
testBtn.addEventListener('click', async () => {
|
|
914
|
+
testBtn.disabled = true;
|
|
915
|
+
say('Connecting\u2026');
|
|
916
|
+
try {
|
|
917
|
+
const result = await api(`${BASE}/accounts/test`, 'POST', collect());
|
|
918
|
+
say(
|
|
919
|
+
result.ok ? 'Connected successfully.' : result.error,
|
|
920
|
+
result.ok ? 'ok' : 'bad'
|
|
921
|
+
);
|
|
922
|
+
} catch (err) {
|
|
923
|
+
say(err.message, 'bad');
|
|
924
|
+
} finally {
|
|
925
|
+
testBtn.disabled = false;
|
|
926
|
+
}
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
cancelBtn.addEventListener('click', () => slideover.close());
|
|
930
|
+
|
|
931
|
+
saveBtn.addEventListener('click', async () => {
|
|
932
|
+
saveBtn.disabled = true;
|
|
933
|
+
try {
|
|
934
|
+
const result = box
|
|
935
|
+
? await api(`${BASE}/accounts/${encodeURIComponent(box.id)}`, 'PUT', collect())
|
|
936
|
+
: await api(`${BASE}/accounts`, 'POST', collect());
|
|
937
|
+
slideover.close();
|
|
938
|
+
E.toast('Mailbox saved.', {type: 'success'});
|
|
939
|
+
currentAccountId = result.account.id;
|
|
940
|
+
await start();
|
|
941
|
+
} catch (err) {
|
|
942
|
+
say(err.message, 'bad');
|
|
943
|
+
} finally {
|
|
944
|
+
saveBtn.disabled = false;
|
|
945
|
+
}
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// ---------------------------------------------------------------
|
|
950
|
+
// Folders pane - mailboxes, with the selected one expanded
|
|
951
|
+
// ---------------------------------------------------------------
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Draw the mailbox list and the selected mailbox's folders.
|
|
955
|
+
*
|
|
956
|
+
* @returns {void}
|
|
957
|
+
*/
|
|
958
|
+
function renderFolders() {
|
|
959
|
+
foldersEl.textContent = '';
|
|
960
|
+
|
|
961
|
+
for (const box of mailboxes) {
|
|
962
|
+
const isCurrent = box.id === currentAccountId;
|
|
963
|
+
|
|
964
|
+
// With one mailbox the account row is noise, so it is only
|
|
965
|
+
// drawn when there is actually a choice to make.
|
|
966
|
+
if (mailboxes.length > 1) {
|
|
967
|
+
const accountBtn = document.createElement('button');
|
|
968
|
+
accountBtn.className = 'mail-account' + (isCurrent ? ' is-active' : '');
|
|
969
|
+
|
|
970
|
+
const icon = document.createElement('span');
|
|
971
|
+
icon.setAttribute('data-icon', isCurrent ? 'chevron-down' : 'chevron-right');
|
|
972
|
+
icon.setAttribute('data-icon-size', '13');
|
|
973
|
+
accountBtn.appendChild(icon);
|
|
974
|
+
|
|
975
|
+
const name = document.createElement('span');
|
|
976
|
+
name.className = 'mail-account-name';
|
|
977
|
+
name.textContent = box.label;
|
|
978
|
+
name.title = `${box.username} · ${box.host}`;
|
|
979
|
+
accountBtn.appendChild(name);
|
|
980
|
+
|
|
981
|
+
accountBtn.addEventListener('click', () => selectAccount(box.id));
|
|
982
|
+
foldersEl.appendChild(accountBtn);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
if (!isCurrent) continue;
|
|
986
|
+
|
|
987
|
+
const collapsed = collapsedSet();
|
|
988
|
+
const tree = buildFolderTree(folders);
|
|
989
|
+
const shown = unreadOnly ? pruneToUnread(tree) : tree;
|
|
990
|
+
|
|
991
|
+
if (unreadOnly && !shown.length) {
|
|
992
|
+
const none = document.createElement('p');
|
|
993
|
+
none.className = 'text-sm text-muted mail-none';
|
|
994
|
+
none.textContent = 'No unread mail in this mailbox.';
|
|
995
|
+
foldersEl.appendChild(none);
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
for (const folder of flattenFolderTree(shown, collapsed)) {
|
|
1000
|
+
const btn = document.createElement('button');
|
|
1001
|
+
// The folder menu resolves its target from this.
|
|
1002
|
+
btn.dataset.path = folder.path;
|
|
1003
|
+
btn.className = 'mail-folder'
|
|
1004
|
+
+ (folder.path === currentFolder ? ' is-active' : '')
|
|
1005
|
+
+ (folder.selectable ? '' : ' is-unselectable');
|
|
1006
|
+
|
|
1007
|
+
// Indent by depth, plus one level when a mailbox row sits
|
|
1008
|
+
// above these folders to indent them under.
|
|
1009
|
+
const level = folder.depth + (mailboxes.length > 1 ? 1 : 0);
|
|
1010
|
+
if (level) btn.style.paddingLeft = `${0.6 + level * 0.9}rem`;
|
|
1011
|
+
|
|
1012
|
+
// A disclosure control only where there is something to
|
|
1013
|
+
// disclose; a spacer elsewhere so the icons stay in line.
|
|
1014
|
+
const toggle = document.createElement('span');
|
|
1015
|
+
toggle.className = 'mail-folder-toggle';
|
|
1016
|
+
if (folder.children.length) {
|
|
1017
|
+
const isShut = collapsed.has(folder.path);
|
|
1018
|
+
toggle.setAttribute('data-icon', isShut ? 'chevron-right' : 'chevron-down');
|
|
1019
|
+
toggle.setAttribute('data-icon-size', '12');
|
|
1020
|
+
toggle.title = isShut ? `Show folders inside ${folder.name}` : `Hide folders inside ${folder.name}`;
|
|
1021
|
+
}
|
|
1022
|
+
btn.appendChild(toggle);
|
|
1023
|
+
|
|
1024
|
+
const icon = document.createElement('span');
|
|
1025
|
+
icon.setAttribute('data-icon', iconForFolder(folder));
|
|
1026
|
+
icon.setAttribute('data-icon-size', '14');
|
|
1027
|
+
btn.appendChild(icon);
|
|
1028
|
+
|
|
1029
|
+
const name = document.createElement('span');
|
|
1030
|
+
name.className = 'mail-folder-name';
|
|
1031
|
+
name.textContent = folder.name;
|
|
1032
|
+
// The full path is the only way to tell two folders with
|
|
1033
|
+
// the same leaf name apart.
|
|
1034
|
+
// The full path is the only way to tell two folders with
|
|
1035
|
+
// the same leaf name apart - and doubles as the hint.
|
|
1036
|
+
name.title = folder.path;
|
|
1037
|
+
btn.title = features.folders
|
|
1038
|
+
? `${folder.path} - right-click to rename, delete or add a folder inside`
|
|
1039
|
+
: folder.path;
|
|
1040
|
+
btn.appendChild(name);
|
|
1041
|
+
|
|
1042
|
+
if (folder.contextOnly) btn.classList.add('is-context');
|
|
1043
|
+
// Listed but dimmed rather than absent: a folder nobody
|
|
1044
|
+
// can see is a folder nobody can unhide.
|
|
1045
|
+
if (folder.subscribed === false) btn.classList.add('is-hidden-folder');
|
|
1046
|
+
|
|
1047
|
+
// A collapsed folder shows what is unread beneath it too -
|
|
1048
|
+
// its own count alone would claim a hidden branch is read.
|
|
1049
|
+
const isShut = collapsed.has(folder.path);
|
|
1050
|
+
const count = isShut ? unreadInSubtree(folder) : (folder.unseen ?? 0);
|
|
1051
|
+
if (count > 0) {
|
|
1052
|
+
const pill = document.createElement('span');
|
|
1053
|
+
pill.className = 'mail-unread-pill';
|
|
1054
|
+
pill.textContent = count > 999 ? '999+' : String(count);
|
|
1055
|
+
pill.title = isShut && count !== (folder.unseen ?? 0)
|
|
1056
|
+
? `${count} unread in this folder and below`
|
|
1057
|
+
: `${count} unread`;
|
|
1058
|
+
btn.appendChild(pill);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// One handler, two gestures: the chevron folds, the rest of
|
|
1062
|
+
// the row opens the folder. A nested <button> inside a
|
|
1063
|
+
// <button> would be invalid markup, so the target decides.
|
|
1064
|
+
btn.addEventListener('click', event => {
|
|
1065
|
+
if (folder.children.length && event.target.closest('.mail-folder-toggle')) {
|
|
1066
|
+
toggleFolder(folder.path);
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
if (folder.selectable) selectFolder(folder);
|
|
1070
|
+
else if (folder.children.length) toggleFolder(folder.path);
|
|
1071
|
+
});
|
|
1072
|
+
// An unselectable level can still be folded, so it is only
|
|
1073
|
+
// inert when it has no children either.
|
|
1074
|
+
if (!folder.selectable && !folder.children.length) btn.disabled = true;
|
|
1075
|
+
|
|
1076
|
+
if (features.write) makeDropTarget(btn, folder);
|
|
1077
|
+
|
|
1078
|
+
foldersEl.appendChild(btn);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
Domma.icons.scan(foldersEl);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* Remember which mailbox is being looked at.
|
|
1087
|
+
*
|
|
1088
|
+
* Someone with a work mailbox and a personal one is almost always
|
|
1089
|
+
* coming back to the same one, and defaulting to whichever the server
|
|
1090
|
+
* happened to list first means re-choosing it on every visit.
|
|
1091
|
+
*
|
|
1092
|
+
* @param {string|null} accountId
|
|
1093
|
+
* @returns {void}
|
|
1094
|
+
*/
|
|
1095
|
+
function rememberAccount(accountId) {
|
|
1096
|
+
try {
|
|
1097
|
+
if (accountId) S.set(ACCOUNT_KEY, accountId);
|
|
1098
|
+
} catch { /* the choice still holds for this session */ }
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* The mailbox to open with.
|
|
1103
|
+
*
|
|
1104
|
+
* The remembered one, but only if it still exists - a mailbox that has
|
|
1105
|
+
* since been deleted would otherwise leave the view pointing at
|
|
1106
|
+
* nothing, with every request 404ing and no obvious way back.
|
|
1107
|
+
*
|
|
1108
|
+
* @returns {string|null}
|
|
1109
|
+
*/
|
|
1110
|
+
function preferredAccount() {
|
|
1111
|
+
let saved = null;
|
|
1112
|
+
try {
|
|
1113
|
+
saved = S.get(ACCOUNT_KEY);
|
|
1114
|
+
} catch { /* fall through to the first mailbox */ }
|
|
1115
|
+
if (saved && mailboxes.some(b => b.id === saved)) return saved;
|
|
1116
|
+
return mailboxes[0]?.id ?? null;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/**
|
|
1120
|
+
* Switch mailbox: reload its folders and open its inbox.
|
|
1121
|
+
*
|
|
1122
|
+
* @param {string} accountId
|
|
1123
|
+
* @returns {Promise<void>}
|
|
1124
|
+
*/
|
|
1125
|
+
async function selectAccount(accountId) {
|
|
1126
|
+
if (accountId === currentAccountId) return;
|
|
1127
|
+
currentAccountId = accountId;
|
|
1128
|
+
rememberAccount(accountId);
|
|
1129
|
+
selectedUid$.value = null;
|
|
1130
|
+
page = 1;
|
|
1131
|
+
clearReader();
|
|
1132
|
+
await loadFolders();
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/**
|
|
1136
|
+
* Switch folder and reload from page 1.
|
|
1137
|
+
*
|
|
1138
|
+
* @param {object} folder - a node from the folder tree
|
|
1139
|
+
* @returns {Promise<void>}
|
|
1140
|
+
*/
|
|
1141
|
+
async function selectFolder(folder) {
|
|
1142
|
+
clearSelection();
|
|
1143
|
+
searchQuery = null;
|
|
1144
|
+
if (searchInputEl) searchInputEl.value = '';
|
|
1145
|
+
if (searchClearEl) searchClearEl.hidden = true;
|
|
1146
|
+
note(null);
|
|
1147
|
+
currentFolder = folder.path;
|
|
1148
|
+
page = 1;
|
|
1149
|
+
selectedUid$.value = null;
|
|
1150
|
+
listTitleEl.textContent = headingFor(folder);
|
|
1151
|
+
renderFolders();
|
|
1152
|
+
clearReader();
|
|
1153
|
+
await loadMessages();
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/**
|
|
1157
|
+
* Heading for a folder, naming its mailbox when there is more than one.
|
|
1158
|
+
*
|
|
1159
|
+
* @param {object} folder
|
|
1160
|
+
* @returns {string}
|
|
1161
|
+
*/
|
|
1162
|
+
function headingFor(folder) {
|
|
1163
|
+
const label = mailboxes.length > 1
|
|
1164
|
+
? mailboxes.find(b => b.id === currentAccountId)?.label ?? null
|
|
1165
|
+
: null;
|
|
1166
|
+
return folderHeading(folder.path, folder.delimiter, label);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// ---------------------------------------------------------------
|
|
1170
|
+
// Message list
|
|
1171
|
+
// ---------------------------------------------------------------
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* Fetch and draw the current page.
|
|
1175
|
+
*
|
|
1176
|
+
* @returns {Promise<void>}
|
|
1177
|
+
*/
|
|
1178
|
+
async function loadMessages() {
|
|
1179
|
+
if (searchQuery) return runSearch();
|
|
1180
|
+
if (threadsOn) return loadThreads();
|
|
1181
|
+
|
|
1182
|
+
const loader = E.loader(listEl, {type: 'dots'});
|
|
1183
|
+
try {
|
|
1184
|
+
const params = new URLSearchParams({
|
|
1185
|
+
account: currentAccountId ?? '',
|
|
1186
|
+
folder: currentFolder,
|
|
1187
|
+
page: String(page)
|
|
1188
|
+
});
|
|
1189
|
+
const result = await api(`${BASE}/messages?${params}`);
|
|
1190
|
+
lastListSource = result.source ?? 'imap';
|
|
1191
|
+
messages = result.messages;
|
|
1192
|
+
total = result.total;
|
|
1193
|
+
limit = result.limit;
|
|
1194
|
+
renderList();
|
|
1195
|
+
renderPager();
|
|
1196
|
+
} catch (err) {
|
|
1197
|
+
handleError(err);
|
|
1198
|
+
} finally {
|
|
1199
|
+
loader.destroy();
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
/**
|
|
1204
|
+
* Fetch and draw the current page as conversations.
|
|
1205
|
+
*
|
|
1206
|
+
* A conversation can only be assembled from a folder that is
|
|
1207
|
+
* mirrored - it is built from every message in the folder at once,
|
|
1208
|
+
* which is exactly what fetching on demand cannot do. An unmirrored
|
|
1209
|
+
* folder therefore falls back to the flat list and says so, rather
|
|
1210
|
+
* than showing a list that quietly is not grouped.
|
|
1211
|
+
*
|
|
1212
|
+
* @returns {Promise<void>}
|
|
1213
|
+
*/
|
|
1214
|
+
async function loadThreads() {
|
|
1215
|
+
const loader = E.loader(listEl, {type: 'dots'});
|
|
1216
|
+
try {
|
|
1217
|
+
const params = new URLSearchParams({
|
|
1218
|
+
account: currentAccountId ?? '',
|
|
1219
|
+
folder: currentFolder,
|
|
1220
|
+
page: String(page)
|
|
1221
|
+
});
|
|
1222
|
+
const result = await api(`${BASE}/threads?${params}`);
|
|
1223
|
+
|
|
1224
|
+
if (!result.mirrored) {
|
|
1225
|
+
note('This folder is not mirrored, so it cannot be grouped into conversations.');
|
|
1226
|
+
// The flat list, without turning the preference off: the
|
|
1227
|
+
// next folder may well be mirrored, and silently
|
|
1228
|
+
// forgetting the setting because one folder was not is
|
|
1229
|
+
// worse than one line of explanation.
|
|
1230
|
+
const flat = await api(`${BASE}/messages?${params}`);
|
|
1231
|
+
lastListSource = flat.source ?? 'imap';
|
|
1232
|
+
messages = flat.messages;
|
|
1233
|
+
total = flat.total;
|
|
1234
|
+
limit = flat.limit;
|
|
1235
|
+
} else {
|
|
1236
|
+
note(null);
|
|
1237
|
+
lastListSource = 'store';
|
|
1238
|
+
threads = result.threads;
|
|
1239
|
+
messages = flattenThreads(threads);
|
|
1240
|
+
total = result.total;
|
|
1241
|
+
limit = result.limit ?? limit;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
renderList();
|
|
1245
|
+
renderPager();
|
|
1246
|
+
} catch (err) {
|
|
1247
|
+
handleError(err);
|
|
1248
|
+
} finally {
|
|
1249
|
+
loader.destroy();
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
* Conversations as a list of rows.
|
|
1255
|
+
*
|
|
1256
|
+
* The newest message of each, plus the rest of any conversation the
|
|
1257
|
+
* reader has opened out. One flat array, because everything
|
|
1258
|
+
* downstream - the keyboard cursor, the selection, drag and drop -
|
|
1259
|
+
* works on rows, and giving it a tree would mean teaching all of it
|
|
1260
|
+
* about one.
|
|
1261
|
+
*
|
|
1262
|
+
* @param {object[]} list
|
|
1263
|
+
* @returns {object[]}
|
|
1264
|
+
*/
|
|
1265
|
+
function flattenThreads(list) {
|
|
1266
|
+
const rows = [];
|
|
1267
|
+
for (const thread of list ?? []) {
|
|
1268
|
+
if (!thread.latest) continue;
|
|
1269
|
+
rows.push({
|
|
1270
|
+
...thread.latest,
|
|
1271
|
+
thread: {
|
|
1272
|
+
root: thread.root,
|
|
1273
|
+
count: thread.count,
|
|
1274
|
+
unseen: thread.unseen,
|
|
1275
|
+
open: expandedThreads.has(thread.root)
|
|
1276
|
+
}
|
|
1277
|
+
});
|
|
1278
|
+
if (!expandedThreads.has(thread.root)) continue;
|
|
1279
|
+
for (const message of thread.messages.slice(1)) {
|
|
1280
|
+
rows.push({...message, inThread: thread.root});
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
return rows;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
/**
|
|
1287
|
+
* Open or close one conversation.
|
|
1288
|
+
*
|
|
1289
|
+
* Redrawn from the threads already in hand - every message came with
|
|
1290
|
+
* the page, so opening one out is not another round trip.
|
|
1291
|
+
*
|
|
1292
|
+
* @param {string} root
|
|
1293
|
+
* @returns {void}
|
|
1294
|
+
*/
|
|
1295
|
+
function toggleThread(root) {
|
|
1296
|
+
if (expandedThreads.has(root)) expandedThreads.delete(root);
|
|
1297
|
+
else expandedThreads.add(root);
|
|
1298
|
+
messages = flattenThreads(threads);
|
|
1299
|
+
renderList();
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/**
|
|
1303
|
+
* Turn conversation grouping on or off.
|
|
1304
|
+
*
|
|
1305
|
+
* @returns {Promise<void>}
|
|
1306
|
+
*/
|
|
1307
|
+
async function toggleThreads() {
|
|
1308
|
+
threadsOn = !threadsOn;
|
|
1309
|
+
expandedThreads.clear();
|
|
1310
|
+
page = 1;
|
|
1311
|
+
try {
|
|
1312
|
+
S.set(THREADS_KEY, threadsOn);
|
|
1313
|
+
} catch {
|
|
1314
|
+
// A browser refusing storage is not a reason to refuse the
|
|
1315
|
+
// setting for this visit.
|
|
1316
|
+
}
|
|
1317
|
+
paintThreadsButton();
|
|
1318
|
+
await loadMessages();
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/**
|
|
1322
|
+
* @returns {void}
|
|
1323
|
+
*/
|
|
1324
|
+
function paintThreadsButton() {
|
|
1325
|
+
if (!threadsBtn) return;
|
|
1326
|
+
threadsBtn.classList.toggle('is-on', threadsOn);
|
|
1327
|
+
threadsBtn.setAttribute('aria-pressed', String(threadsOn));
|
|
1328
|
+
threadsBtn.title = threadsOn
|
|
1329
|
+
? 'Showing conversations - click for single messages'
|
|
1330
|
+
: 'Group messages into conversations';
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/**
|
|
1334
|
+
* Run the active search and draw its results.
|
|
1335
|
+
*
|
|
1336
|
+
* @returns {Promise<void>}
|
|
1337
|
+
*/
|
|
1338
|
+
async function runSearch() {
|
|
1339
|
+
const loader = E.loader(listEl, {type: 'dots'});
|
|
1340
|
+
try {
|
|
1341
|
+
const params = new URLSearchParams({
|
|
1342
|
+
account: currentAccountId ?? '',
|
|
1343
|
+
q: searchQuery,
|
|
1344
|
+
page: String(page)
|
|
1345
|
+
});
|
|
1346
|
+
const result = await api(`${BASE}/search?${params}`);
|
|
1347
|
+
|
|
1348
|
+
messages = result.messages;
|
|
1349
|
+
total = result.total;
|
|
1350
|
+
limit = result.limit ?? limit;
|
|
1351
|
+
renderList();
|
|
1352
|
+
renderPager();
|
|
1353
|
+
|
|
1354
|
+
if (result.tooShort) {
|
|
1355
|
+
note('Type at least two characters.');
|
|
1356
|
+
} else {
|
|
1357
|
+
// Say what was actually searched. Only mirrored folders are
|
|
1358
|
+
// here, so a silent partial result would be a lie - the
|
|
1359
|
+
// message someone is looking for may be in a folder they
|
|
1360
|
+
// never chose to mirror.
|
|
1361
|
+
const covered = result.searchedFolders ?? [];
|
|
1362
|
+
const scope = covered.length
|
|
1363
|
+
? `Searched ${covered.length} mirrored folder${covered.length === 1 ? '' : 's'}: ${covered.join(', ')}.`
|
|
1364
|
+
: 'No folders are mirrored yet, so there is nothing to search.';
|
|
1365
|
+
note(`${result.total} result${result.total === 1 ? '' : 's'}. ${scope}`);
|
|
1366
|
+
}
|
|
1367
|
+
} catch (err) {
|
|
1368
|
+
handleError(err);
|
|
1369
|
+
} finally {
|
|
1370
|
+
loader.destroy();
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/**
|
|
1375
|
+
* Show or hide the line under the search box.
|
|
1376
|
+
*
|
|
1377
|
+
* @param {string|null} text
|
|
1378
|
+
* @returns {void}
|
|
1379
|
+
*/
|
|
1380
|
+
function note(text) {
|
|
1381
|
+
if (!searchNoteEl) return;
|
|
1382
|
+
searchNoteEl.textContent = text ?? '';
|
|
1383
|
+
searchNoteEl.hidden = !text;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/**
|
|
1387
|
+
* Leave search and go back to the open folder.
|
|
1388
|
+
*
|
|
1389
|
+
* @returns {Promise<void>}
|
|
1390
|
+
*/
|
|
1391
|
+
async function clearSearch() {
|
|
1392
|
+
searchQuery = null;
|
|
1393
|
+
page = 1;
|
|
1394
|
+
if (searchInputEl) searchInputEl.value = '';
|
|
1395
|
+
if (searchClearEl) searchClearEl.hidden = true;
|
|
1396
|
+
note(null);
|
|
1397
|
+
await loadMessages();
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
/**
|
|
1401
|
+
* Draw the envelope list.
|
|
1402
|
+
*
|
|
1403
|
+
* @returns {void}
|
|
1404
|
+
*/
|
|
1405
|
+
function renderList() {
|
|
1406
|
+
disposeRowEffects();
|
|
1407
|
+
listEl.textContent = '';
|
|
1408
|
+
|
|
1409
|
+
// The box describes this page, so it is repainted whenever the
|
|
1410
|
+
// page changes - not only when the selection does.
|
|
1411
|
+
paintSelectAll();
|
|
1412
|
+
listEl.classList.toggle('is-selecting', selected.size > 0);
|
|
1413
|
+
|
|
1414
|
+
if (!messages.length) {
|
|
1415
|
+
showEmpty(listEl, 'inbox', 'This folder is empty.');
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
for (const message of messages) {
|
|
1420
|
+
// One observable per message, made once and kept on it, so a
|
|
1421
|
+
// flag change is a value assignment that repaints a single row
|
|
1422
|
+
// rather than a reason to rebuild the whole list.
|
|
1423
|
+
message.flags$ ??= M.observable(message.flags ?? []);
|
|
1424
|
+
|
|
1425
|
+
const item = document.createElement('button');
|
|
1426
|
+
item.className = 'mail-item';
|
|
1427
|
+
// A reply shown under the conversation it belongs to, rather
|
|
1428
|
+
// than as a row of its own. Indented, so the shape of the
|
|
1429
|
+
// list says which is which without a legend.
|
|
1430
|
+
if (message.inThread) item.classList.add('is-in-thread');
|
|
1431
|
+
item.dataset.uid = String(message.uid);
|
|
1432
|
+
item.dataset.folder = message.folder ?? currentFolder;
|
|
1433
|
+
if (features.write) makeDraggable(item, message);
|
|
1434
|
+
// Rebuilt on every render, so a native title rather than a
|
|
1435
|
+
// registered tooltip instance - see the E.tooltip call for why.
|
|
1436
|
+
item.title = features.write
|
|
1437
|
+
? 'Click to open \u00b7 ctrl-click to select \u00b7 drag to a folder \u00b7 right-click for actions'
|
|
1438
|
+
: 'Click to open';
|
|
1439
|
+
|
|
1440
|
+
// A span with the checkbox role, not an <input>: the row is a
|
|
1441
|
+
// <button>, and a nested interactive element inside one is
|
|
1442
|
+
// invalid markup that the browser will not let you click
|
|
1443
|
+
// anyway. Same resolution as the folder disclosure chevron -
|
|
1444
|
+
// one element, and the click target decides what happened.
|
|
1445
|
+
const tick = document.createElement('span');
|
|
1446
|
+
tick.className = 'mail-item-tick';
|
|
1447
|
+
tick.setAttribute('role', 'checkbox');
|
|
1448
|
+
rowEffects.push(M.effect(() => {
|
|
1449
|
+
const on = selectionVersion$.value >= 0 && selected.has(message.uid);
|
|
1450
|
+
tick.setAttribute('aria-checked', String(on));
|
|
1451
|
+
tick.title = on ? 'Deselect this message' : 'Select this message';
|
|
1452
|
+
|
|
1453
|
+
// The glyph goes in a CHILD and the parent is scanned.
|
|
1454
|
+
// `Domma.icons.scan` REPLACES each `[data-icon]` element
|
|
1455
|
+
// with an <svg>, so putting the attribute on the tick
|
|
1456
|
+
// itself swapped the tick out from under this effect -
|
|
1457
|
+
// and an SVGElement has no `.click()`, so the hit area
|
|
1458
|
+
// stopped being one. Same shape as the row's meta icons.
|
|
1459
|
+
tick.textContent = '';
|
|
1460
|
+
const glyph = document.createElement('span');
|
|
1461
|
+
glyph.setAttribute('data-icon', on ? 'check-square' : 'square');
|
|
1462
|
+
glyph.setAttribute('data-icon-size', '15');
|
|
1463
|
+
tick.appendChild(glyph);
|
|
1464
|
+
Domma.icons.scan(tick);
|
|
1465
|
+
}));
|
|
1466
|
+
item.appendChild(tick);
|
|
1467
|
+
|
|
1468
|
+
const top = document.createElement('div');
|
|
1469
|
+
top.className = 'mail-item-top';
|
|
1470
|
+
|
|
1471
|
+
const from = document.createElement('span');
|
|
1472
|
+
from.className = 'mail-item-from';
|
|
1473
|
+
from.textContent = formatAddresses(message.from) || '(unknown sender)';
|
|
1474
|
+
top.appendChild(from);
|
|
1475
|
+
|
|
1476
|
+
const date = document.createElement('span');
|
|
1477
|
+
date.className = 'mail-item-date';
|
|
1478
|
+
date.textContent = formatDate(message.date);
|
|
1479
|
+
top.appendChild(date);
|
|
1480
|
+
|
|
1481
|
+
item.appendChild(top);
|
|
1482
|
+
|
|
1483
|
+
// Everything about this row that can change while it is on
|
|
1484
|
+
// screen, in one effect: read state, star, and whether it is
|
|
1485
|
+
// the open message.
|
|
1486
|
+
rowEffects.push(M.effect(() => {
|
|
1487
|
+
const flags = message.flags$.value;
|
|
1488
|
+
item.classList.toggle('is-unread', !flags.includes('\\Seen'));
|
|
1489
|
+
item.classList.toggle('is-active', message.uid === selectedUid$.value);
|
|
1490
|
+
item.classList.toggle('is-selected', selectionVersion$.value >= 0 && selected.has(message.uid));
|
|
1491
|
+
}));
|
|
1492
|
+
|
|
1493
|
+
const subject = document.createElement('div');
|
|
1494
|
+
subject.className = 'mail-item-subject';
|
|
1495
|
+
subject.textContent = message.subject || '(no subject)';
|
|
1496
|
+
item.appendChild(subject);
|
|
1497
|
+
|
|
1498
|
+
// How many messages this row stands for, and the way into
|
|
1499
|
+
// them. Only ever drawn on a conversation of more than one -
|
|
1500
|
+
// a chip reading "1" on every row is noise with a number in
|
|
1501
|
+
// it.
|
|
1502
|
+
if (message.thread && message.thread.count > 1) {
|
|
1503
|
+
const chip = document.createElement('span');
|
|
1504
|
+
chip.className = 'mail-thread-chip';
|
|
1505
|
+
chip.setAttribute('role', 'button');
|
|
1506
|
+
chip.textContent = message.thread.open
|
|
1507
|
+
? `\u25be ${message.thread.count}`
|
|
1508
|
+
: `\u25b8 ${message.thread.count}`;
|
|
1509
|
+
chip.title = message.thread.open
|
|
1510
|
+
? 'Hide the rest of this conversation'
|
|
1511
|
+
: `Show all ${message.thread.count} messages in this conversation`;
|
|
1512
|
+
if (message.thread.unseen > 0) chip.classList.add('is-unread');
|
|
1513
|
+
// The row is a <button>, so this cannot be one - same
|
|
1514
|
+
// resolution as the selection tick above. The row's own
|
|
1515
|
+
// click handler checks for it and stops there.
|
|
1516
|
+
chip.dataset.thread = message.thread.root;
|
|
1517
|
+
subject.appendChild(chip);
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
const meta = document.createElement('div');
|
|
1521
|
+
meta.className = 'mail-item-meta';
|
|
1522
|
+
item.appendChild(meta);
|
|
1523
|
+
|
|
1524
|
+
rowEffects.push(M.effect(() => {
|
|
1525
|
+
const flags = message.flags$.value;
|
|
1526
|
+
meta.textContent = '';
|
|
1527
|
+
for (const [condition, icon, title, cls] of [
|
|
1528
|
+
// Only the unusual levels are drawn. A marker on every
|
|
1529
|
+
// ordinary message would say nothing and cost a row of
|
|
1530
|
+
// attention each time.
|
|
1531
|
+
[message.priority === 'high', 'arrow-up', 'High priority', 'is-high'],
|
|
1532
|
+
[message.priority === 'low', 'arrow-down', 'Low priority', 'is-low'],
|
|
1533
|
+
[flags.includes('\\Answered'), 'corner-up-left', 'Replied to', ''],
|
|
1534
|
+
[flags.includes('\\Flagged'), 'flag', 'Starred', ''],
|
|
1535
|
+
[message.hasAttachment, 'paperclip', 'Has an attachment', '']
|
|
1536
|
+
]) {
|
|
1537
|
+
if (!condition) continue;
|
|
1538
|
+
// The glyph goes in a child, again: `icons.scan`
|
|
1539
|
+
// REPLACES each [data-icon] element with an <svg>, so
|
|
1540
|
+
// a title or a class set on that element is thrown
|
|
1541
|
+
// away with it. The markers that came before this
|
|
1542
|
+
// carried neither, which is why it never showed.
|
|
1543
|
+
const holder = document.createElement('span');
|
|
1544
|
+
if (title) holder.title = title;
|
|
1545
|
+
if (cls) holder.classList.add(cls);
|
|
1546
|
+
const iconEl = document.createElement('span');
|
|
1547
|
+
iconEl.setAttribute('data-icon', icon);
|
|
1548
|
+
iconEl.setAttribute('data-icon-size', '12');
|
|
1549
|
+
holder.appendChild(iconEl);
|
|
1550
|
+
meta.appendChild(holder);
|
|
1551
|
+
}
|
|
1552
|
+
Domma.icons.scan(meta);
|
|
1553
|
+
}));
|
|
1554
|
+
|
|
1555
|
+
// A search spans folders, so a result carries its own - opening
|
|
1556
|
+
// it against whatever folder is selected would look for the
|
|
1557
|
+
// UID in the wrong mailbox and 404.
|
|
1558
|
+
item.addEventListener('click', event => {
|
|
1559
|
+
// The conversation chip opens the thread out rather than
|
|
1560
|
+
// opening the message, whatever else is held. Checked
|
|
1561
|
+
// first for the same reason the tick is: aiming at a
|
|
1562
|
+
// control and getting something else is the worst
|
|
1563
|
+
// possible answer.
|
|
1564
|
+
const chipTarget = event.target.closest('.mail-thread-chip');
|
|
1565
|
+
if (chipTarget) {
|
|
1566
|
+
event.stopPropagation();
|
|
1567
|
+
toggleThread(chipTarget.dataset.thread);
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
// The tick is a selection gesture whatever else is held:
|
|
1571
|
+
// aiming at a checkbox and getting the message opened
|
|
1572
|
+
// would be the worst possible answer.
|
|
1573
|
+
if (event.target.closest('.mail-item-tick')) {
|
|
1574
|
+
event.stopPropagation();
|
|
1575
|
+
if (event.shiftKey) selectRangeTo(message.uid);
|
|
1576
|
+
else toggleSelected(message.uid);
|
|
1577
|
+
return;
|
|
1578
|
+
}
|
|
1579
|
+
// Modified clicks build a selection; a plain click opens,
|
|
1580
|
+
// which is the gesture people use ninety times in a
|
|
1581
|
+
// hundred and must stay the simplest one.
|
|
1582
|
+
if (event.ctrlKey || event.metaKey) {
|
|
1583
|
+
toggleSelected(message.uid);
|
|
1584
|
+
return;
|
|
1585
|
+
}
|
|
1586
|
+
if (event.shiftKey) {
|
|
1587
|
+
selectRangeTo(message.uid);
|
|
1588
|
+
return;
|
|
1589
|
+
}
|
|
1590
|
+
if (selected.size) clearSelection();
|
|
1591
|
+
openMessage(message.uid, message.folder);
|
|
1592
|
+
});
|
|
1593
|
+
listEl.appendChild(item);
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
Domma.icons.scan(listEl);
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
/**
|
|
1600
|
+
* Add or remove one message from the selection.
|
|
1601
|
+
*
|
|
1602
|
+
* @param {number} uid
|
|
1603
|
+
* @returns {void}
|
|
1604
|
+
*/
|
|
1605
|
+
function toggleSelected(uid) {
|
|
1606
|
+
if (selected.has(uid)) selected.delete(uid);
|
|
1607
|
+
else selected.add(uid);
|
|
1608
|
+
selectionAnchor = uid;
|
|
1609
|
+
selectionChanged();
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
/**
|
|
1613
|
+
* Select everything between the anchor and this message.
|
|
1614
|
+
*
|
|
1615
|
+
* Uses the order on screen rather than UID order: what a person means
|
|
1616
|
+
* by "everything between these two" is what they can see between them.
|
|
1617
|
+
*
|
|
1618
|
+
* @param {number} uid
|
|
1619
|
+
* @returns {void}
|
|
1620
|
+
*/
|
|
1621
|
+
function selectRangeTo(uid) {
|
|
1622
|
+
if (selectionAnchor === null) {
|
|
1623
|
+
toggleSelected(uid);
|
|
1624
|
+
return;
|
|
1625
|
+
}
|
|
1626
|
+
const order = messages.map(m => m.uid);
|
|
1627
|
+
const from = order.indexOf(selectionAnchor);
|
|
1628
|
+
const to = order.indexOf(uid);
|
|
1629
|
+
if (from === -1 || to === -1) {
|
|
1630
|
+
toggleSelected(uid);
|
|
1631
|
+
return;
|
|
1632
|
+
}
|
|
1633
|
+
for (let i = Math.min(from, to); i <= Math.max(from, to); i++) selected.add(order[i]);
|
|
1634
|
+
selectionChanged();
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
/**
|
|
1638
|
+
* @returns {void}
|
|
1639
|
+
*/
|
|
1640
|
+
function clearSelection() {
|
|
1641
|
+
selected.clear();
|
|
1642
|
+
selectionAnchor = null;
|
|
1643
|
+
selectionChanged();
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
/**
|
|
1647
|
+
* Keep the select-all box in step with the rows.
|
|
1648
|
+
*
|
|
1649
|
+
* Three states, not two: none, some, all. The indeterminate one is
|
|
1650
|
+
* what stops a half-selected list claiming to be empty, and it is the
|
|
1651
|
+
* state someone is in most of the time they look at it.
|
|
1652
|
+
*
|
|
1653
|
+
* @returns {void}
|
|
1654
|
+
*/
|
|
1655
|
+
function paintSelectAll() {
|
|
1656
|
+
if (!selectAllBox || !selectAllEl) return;
|
|
1657
|
+
// Nothing to select all of. Hidden rather than disabled: an empty
|
|
1658
|
+
// list has no selection to describe, so the control says nothing.
|
|
1659
|
+
selectAllEl.hidden = !features.write || messages.length === 0;
|
|
1660
|
+
|
|
1661
|
+
const count = messages.filter(m => selected.has(m.uid)).length;
|
|
1662
|
+
selectAllBox.checked = count > 0 && count === messages.length;
|
|
1663
|
+
selectAllBox.indeterminate = count > 0 && count < messages.length;
|
|
1664
|
+
selectAllBox.title = selectAllBox.checked
|
|
1665
|
+
? 'Deselect every message on this page'
|
|
1666
|
+
: 'Select every message on this page';
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
/**
|
|
1670
|
+
* Select or clear every message on this page.
|
|
1671
|
+
*
|
|
1672
|
+
* This page, deliberately, not every message in the folder - the box
|
|
1673
|
+
* sits above a list of fifty and selecting four thousand unseen
|
|
1674
|
+
* messages is not what ticking it looks like it does.
|
|
1675
|
+
*
|
|
1676
|
+
* @returns {void}
|
|
1677
|
+
*/
|
|
1678
|
+
function toggleSelectAll() {
|
|
1679
|
+
const all = messages.every(m => selected.has(m.uid));
|
|
1680
|
+
if (all) {
|
|
1681
|
+
for (const message of messages) selected.delete(message.uid);
|
|
1682
|
+
selectionAnchor = null;
|
|
1683
|
+
} else {
|
|
1684
|
+
for (const message of messages) selected.add(message.uid);
|
|
1685
|
+
}
|
|
1686
|
+
selectionChanged();
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
/**
|
|
1690
|
+
* Repaint the rows and the bar after the selection moved.
|
|
1691
|
+
*
|
|
1692
|
+
* The rows watch an observable rather than being rebuilt, so a
|
|
1693
|
+
* selection of fifty repaints fifty class lists and nothing else.
|
|
1694
|
+
*
|
|
1695
|
+
* @returns {void}
|
|
1696
|
+
*/
|
|
1697
|
+
function selectionChanged() {
|
|
1698
|
+
selectionVersion$.value = selectionVersion$.value + 1;
|
|
1699
|
+
// Ticks are revealed on hover while nothing is selected, so an
|
|
1700
|
+
// untouched list stays as quiet as it was - and stay put once
|
|
1701
|
+
// something is, because a list being worked on should look like
|
|
1702
|
+
// one.
|
|
1703
|
+
listEl?.classList.toggle('is-selecting', selected.size > 0);
|
|
1704
|
+
paintSelectAll();
|
|
1705
|
+
renderSelectionBar();
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
/**
|
|
1709
|
+
* The bar that appears while something is selected.
|
|
1710
|
+
*
|
|
1711
|
+
* @returns {void}
|
|
1712
|
+
*/
|
|
1713
|
+
function renderSelectionBar() {
|
|
1714
|
+
if (!selectionEl) return;
|
|
1715
|
+
if (!selected.size) {
|
|
1716
|
+
selectionEl.hidden = true;
|
|
1717
|
+
selectionEl.textContent = '';
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
selectionEl.textContent = '';
|
|
1722
|
+
selectionEl.hidden = false;
|
|
1723
|
+
|
|
1724
|
+
const label = document.createElement('span');
|
|
1725
|
+
label.textContent = `${selected.size} selected`;
|
|
1726
|
+
selectionEl.appendChild(label);
|
|
1727
|
+
|
|
1728
|
+
const act = (text, fn) => {
|
|
1729
|
+
const button = document.createElement('button');
|
|
1730
|
+
button.className = 'btn btn-sm btn-ghost';
|
|
1731
|
+
button.textContent = text;
|
|
1732
|
+
button.addEventListener('click', async () => {
|
|
1733
|
+
button.disabled = true;
|
|
1734
|
+
try {
|
|
1735
|
+
await fn();
|
|
1736
|
+
} catch (err) {
|
|
1737
|
+
E.toast(err.message || 'That could not be done.', {type: 'error'});
|
|
1738
|
+
} finally {
|
|
1739
|
+
button.disabled = false;
|
|
1740
|
+
}
|
|
1741
|
+
});
|
|
1742
|
+
selectionEl.appendChild(button);
|
|
1743
|
+
};
|
|
1744
|
+
|
|
1745
|
+
act('Mark read', () => bulkFlags({add: ['\\Seen']}));
|
|
1746
|
+
act('Mark unread', () => bulkFlags({remove: ['\\Seen']}));
|
|
1747
|
+
act('Star', () => bulkFlags({add: ['\\Flagged']}));
|
|
1748
|
+
// Offer each way only where it means something. A selection can
|
|
1749
|
+
// straddle both - some already marked, some not - so both buttons
|
|
1750
|
+
// can appear together, and each acts on the whole selection.
|
|
1751
|
+
const chosen = messages.filter(m => selected.has(m.uid));
|
|
1752
|
+
if (junkFolder() && chosen.some(m => !messageIsSpam(m))) {
|
|
1753
|
+
act('Spam', () => bulkSpam(true));
|
|
1754
|
+
}
|
|
1755
|
+
if (chosen.some(m => messageIsSpam(m))) {
|
|
1756
|
+
act('Not spam', () => bulkSpam(false));
|
|
1757
|
+
}
|
|
1758
|
+
act('Delete', () => bulkDelete());
|
|
1759
|
+
act('Clear', () => clearSelection());
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
/**
|
|
1763
|
+
* Flag everything selected, in one request.
|
|
1764
|
+
*
|
|
1765
|
+
* @param {{add?: string[], remove?: string[]}} change
|
|
1766
|
+
* @returns {Promise<void>}
|
|
1767
|
+
*/
|
|
1768
|
+
async function bulkFlags(change) {
|
|
1769
|
+
const uids = [...selected];
|
|
1770
|
+
const params = new URLSearchParams({account: currentAccountId ?? '', folder: currentFolder});
|
|
1771
|
+
const result = await api(`${BASE}/messages/flags?${params}`, 'POST', {uids, ...change});
|
|
1772
|
+
|
|
1773
|
+
let unreadDelta = 0;
|
|
1774
|
+
for (const {uid, flags} of result.updated ?? []) {
|
|
1775
|
+
const row = messages.find(m => m.uid === uid);
|
|
1776
|
+
if (!row) continue;
|
|
1777
|
+
const wasSeen = row.seen === true;
|
|
1778
|
+
const nowSeen = flags.includes('\\Seen');
|
|
1779
|
+
if (wasSeen !== nowSeen) unreadDelta += nowSeen ? -1 : 1;
|
|
1780
|
+
row.flags = flags;
|
|
1781
|
+
row.seen = nowSeen;
|
|
1782
|
+
row.flagged = flags.includes('\\Flagged');
|
|
1783
|
+
row.flags$ ? (row.flags$.value = flags) : null;
|
|
1784
|
+
}
|
|
1785
|
+
if (unreadDelta) adjustUnread(currentFolder, unreadDelta);
|
|
1786
|
+
clearSelection();
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
/**
|
|
1790
|
+
* Mark everything selected as spam, or as not spam, with one undo.
|
|
1791
|
+
*
|
|
1792
|
+
* @param {boolean} spam
|
|
1793
|
+
* @returns {Promise<void>}
|
|
1794
|
+
*/
|
|
1795
|
+
async function bulkSpam(spam) {
|
|
1796
|
+
const uids = [...selected];
|
|
1797
|
+
const source = currentFolder;
|
|
1798
|
+
const params = new URLSearchParams({account: currentAccountId ?? '', folder: source});
|
|
1799
|
+
const result = await api(`${BASE}/messages/spam?${params}`, 'POST', {uids, spam});
|
|
1800
|
+
|
|
1801
|
+
if (!result.movedTo) {
|
|
1802
|
+
for (const {uid, flags} of result.updated ?? []) applyFlagsToRow(uid, flags);
|
|
1803
|
+
E.toast(`${result.count} marked as ${spam ? 'spam' : 'not spam'}.`, {type: 'success'});
|
|
1804
|
+
clearSelection();
|
|
1805
|
+
return;
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
for (const uid of uids) removeFromList(uid);
|
|
1809
|
+
clearSelection();
|
|
1810
|
+
|
|
1811
|
+
const mapped = Object.values(result.uidMap ?? {});
|
|
1812
|
+
offerUndo(
|
|
1813
|
+
`${result.count} moved to ${result.movedTo}.`,
|
|
1814
|
+
mapped.length
|
|
1815
|
+
? async () => {
|
|
1816
|
+
const back = new URLSearchParams({account: currentAccountId ?? '', folder: result.movedTo});
|
|
1817
|
+
await api(`${BASE}/messages/move?${back}`, 'POST', {uids: mapped, destination: source});
|
|
1818
|
+
await loadMessages();
|
|
1819
|
+
}
|
|
1820
|
+
: null
|
|
1821
|
+
);
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
/**
|
|
1825
|
+
* Delete everything selected, with one undo for the lot.
|
|
1826
|
+
*
|
|
1827
|
+
* @returns {Promise<void>}
|
|
1828
|
+
*/
|
|
1829
|
+
async function bulkDelete() {
|
|
1830
|
+
const uids = [...selected];
|
|
1831
|
+
if (!await E.confirm(`Delete ${uids.length} message${uids.length === 1 ? '' : 's'}?`)) return;
|
|
1832
|
+
|
|
1833
|
+
const source = currentFolder;
|
|
1834
|
+
const params = new URLSearchParams({account: currentAccountId ?? '', folder: source});
|
|
1835
|
+
const result = await api(`${BASE}/messages/delete?${params}`, 'POST', {uids});
|
|
1836
|
+
|
|
1837
|
+
for (const uid of uids) removeFromList(uid);
|
|
1838
|
+
clearSelection();
|
|
1839
|
+
|
|
1840
|
+
const mapped = Object.values(result.uidMap ?? {});
|
|
1841
|
+
if (result.expunged) {
|
|
1842
|
+
offerUndo(`${result.deleted} deleted from the server. This cannot be undone.`, null);
|
|
1843
|
+
} else {
|
|
1844
|
+
offerUndo(
|
|
1845
|
+
`${result.deleted} moved to ${result.movedTo}.`,
|
|
1846
|
+
mapped.length
|
|
1847
|
+
? async () => {
|
|
1848
|
+
const back = new URLSearchParams({account: currentAccountId ?? '', folder: result.movedTo});
|
|
1849
|
+
await api(`${BASE}/messages/move?${back}`, 'POST', {uids: mapped, destination: source});
|
|
1850
|
+
await loadMessages();
|
|
1851
|
+
}
|
|
1852
|
+
: null
|
|
1853
|
+
);
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
/**
|
|
1858
|
+
* Draw the pager.
|
|
1859
|
+
*
|
|
1860
|
+
* @returns {void}
|
|
1861
|
+
*/
|
|
1862
|
+
function renderPager() {
|
|
1863
|
+
pagerEl.textContent = '';
|
|
1864
|
+
const pages = Math.max(1, Math.ceil(total / limit));
|
|
1865
|
+
|
|
1866
|
+
const prev = document.createElement('button');
|
|
1867
|
+
prev.className = 'btn btn-sm btn-ghost';
|
|
1868
|
+
prev.textContent = 'Newer';
|
|
1869
|
+
prev.disabled = page <= 1;
|
|
1870
|
+
prev.addEventListener('click', () => {
|
|
1871
|
+
page -= 1;
|
|
1872
|
+
loadMessages();
|
|
1873
|
+
});
|
|
1874
|
+
|
|
1875
|
+
const label = document.createElement('span');
|
|
1876
|
+
label.textContent = total ? `${page} / ${pages} · ${total} messages` : 'No messages';
|
|
1877
|
+
|
|
1878
|
+
const next = document.createElement('button');
|
|
1879
|
+
next.className = 'btn btn-sm btn-ghost';
|
|
1880
|
+
next.textContent = 'Older';
|
|
1881
|
+
next.disabled = page >= pages;
|
|
1882
|
+
next.addEventListener('click', () => {
|
|
1883
|
+
page += 1;
|
|
1884
|
+
loadMessages();
|
|
1885
|
+
});
|
|
1886
|
+
|
|
1887
|
+
pagerEl.appendChild(prev);
|
|
1888
|
+
pagerEl.appendChild(label);
|
|
1889
|
+
pagerEl.appendChild(next);
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
// ---------------------------------------------------------------
|
|
1893
|
+
// Reader
|
|
1894
|
+
// ---------------------------------------------------------------
|
|
1895
|
+
|
|
1896
|
+
/**
|
|
1897
|
+
* Reset the reader pane to its empty state.
|
|
1898
|
+
*
|
|
1899
|
+
* @returns {void}
|
|
1900
|
+
*/
|
|
1901
|
+
function clearReader() {
|
|
1902
|
+
cancelMarkRead();
|
|
1903
|
+
readerHeadEl.textContent = '';
|
|
1904
|
+
showEmpty(readerBodyEl, 'mail', 'Select a message to read it.');
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
/**
|
|
1908
|
+
* Fetch and display one message.
|
|
1909
|
+
*
|
|
1910
|
+
* @param {number} uid
|
|
1911
|
+
* @returns {Promise<void>}
|
|
1912
|
+
*/
|
|
1913
|
+
async function openMessage(uid, folder = null) {
|
|
1914
|
+
// A draft is not something to read, it is something to finish.
|
|
1915
|
+
// Only where this edition can send: the free reader has no compose
|
|
1916
|
+
// window to open, and showing the raw draft is the honest fallback.
|
|
1917
|
+
const row = messages.find(m => m.uid === uid);
|
|
1918
|
+
if (features.send && isDraft(row ? {...row, folder: folder ?? currentFolder} : null)) {
|
|
1919
|
+
cursorUid = uid;
|
|
1920
|
+
selectedUid$.value = uid;
|
|
1921
|
+
await editDraft({...row, folder: folder ?? currentFolder});
|
|
1922
|
+
return;
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
cancelMarkRead();
|
|
1926
|
+
cursorUid = uid;
|
|
1927
|
+
pane = 'messages';
|
|
1928
|
+
selectedUid$.value = uid;
|
|
1929
|
+
// Remembered so the body, its images and its attachments all
|
|
1930
|
+
// resolve against the folder the message is actually in.
|
|
1931
|
+
openFolder = folder ?? currentFolder;
|
|
1932
|
+
|
|
1933
|
+
const loader = E.loader(readerBodyEl, {type: 'dots'});
|
|
1934
|
+
try {
|
|
1935
|
+
const params = new URLSearchParams({
|
|
1936
|
+
account: currentAccountId ?? '',
|
|
1937
|
+
folder: openFolder
|
|
1938
|
+
});
|
|
1939
|
+
if (imagesAllowed.has(uid)) params.set('images', 'load');
|
|
1940
|
+
const message = await api(`${BASE}/messages/${uid}?${params}`);
|
|
1941
|
+
renderMessage(message);
|
|
1942
|
+
|
|
1943
|
+
// Opening mail marks it read after a pause, not instantly.
|
|
1944
|
+
// Arrowing through a list would otherwise mark everything it
|
|
1945
|
+
// passed over, and the delay is the difference between reading
|
|
1946
|
+
// something and glancing at it.
|
|
1947
|
+
//
|
|
1948
|
+
// Only where the edition can write at all: the free reader's
|
|
1949
|
+
// guarantee is that looking at mail never changes it, and that
|
|
1950
|
+
// must not be quietly lost by sharing this view.
|
|
1951
|
+
const row = messages.find(m => m.uid === uid);
|
|
1952
|
+
if (features.write && row && row.seen === false) {
|
|
1953
|
+
const forFolder = openFolder;
|
|
1954
|
+
markReadTimer = setTimeout(() => {
|
|
1955
|
+
markReadTimer = null;
|
|
1956
|
+
// Still the message on screen? If the user moved on
|
|
1957
|
+
// during the pause, they did not read this one.
|
|
1958
|
+
if (selectedUid$.value !== uid) return;
|
|
1959
|
+
changeFlags({...message, folder: forFolder}, {add: ['\\Seen']})
|
|
1960
|
+
.catch(() => {/* not worth interrupting a read for */});
|
|
1961
|
+
}, MARK_READ_DELAY_MS);
|
|
1962
|
+
}
|
|
1963
|
+
} catch (err) {
|
|
1964
|
+
handleError(err);
|
|
1965
|
+
} finally {
|
|
1966
|
+
loader.destroy();
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
/**
|
|
1971
|
+
* Draw a fetched message into the reader pane.
|
|
1972
|
+
*
|
|
1973
|
+
* @param {object} message
|
|
1974
|
+
* @returns {void}
|
|
1975
|
+
*/
|
|
1976
|
+
function renderMessage(message) {
|
|
1977
|
+
readerHeadEl.textContent = '';
|
|
1978
|
+
|
|
1979
|
+
const subject = document.createElement('h2');
|
|
1980
|
+
subject.className = 'mail-reader-subject';
|
|
1981
|
+
subject.textContent = message.subject || '(no subject)';
|
|
1982
|
+
readerHeadEl.appendChild(subject);
|
|
1983
|
+
|
|
1984
|
+
// Write actions, only for an edition that has them. The free
|
|
1985
|
+
// reader cannot change a mailbox at all, so it must not offer to.
|
|
1986
|
+
if (features.write) {
|
|
1987
|
+
const actions = document.createElement('div');
|
|
1988
|
+
actions.className = 'mail-reader-actions';
|
|
1989
|
+
|
|
1990
|
+
const current = messages.find(m => m.uid === message.uid);
|
|
1991
|
+
const flags = current?.flags$ ? current.flags$.value : (current?.flags ?? []);
|
|
1992
|
+
const isSeen = flags.includes('\\Seen');
|
|
1993
|
+
const isFlagged = flags.includes('\\Flagged');
|
|
1994
|
+
|
|
1995
|
+
actions.appendChild(actionButton(
|
|
1996
|
+
isFlagged ? 'Unstar' : 'Star',
|
|
1997
|
+
isFlagged ? 'star-filled' : 'star',
|
|
1998
|
+
() => changeFlags(message, isFlagged ? {remove: ['\\Flagged']} : {add: ['\\Flagged']}),
|
|
1999
|
+
`${isFlagged ? 'Remove the star from' : 'Star'} this message (s)`
|
|
2000
|
+
));
|
|
2001
|
+
actions.appendChild(actionButton(
|
|
2002
|
+
isSeen ? 'Mark unread' : 'Mark read',
|
|
2003
|
+
'mail',
|
|
2004
|
+
() => changeFlags(message, isSeen ? {remove: ['\\Seen']} : {add: ['\\Seen']}),
|
|
2005
|
+
`Mark this message ${isSeen ? 'unread' : 'read'} (u)`
|
|
2006
|
+
));
|
|
2007
|
+
|
|
2008
|
+
if (features.send) {
|
|
2009
|
+
actions.appendChild(actionButton('Reply', 'corner-up-left',
|
|
2010
|
+
() => compose(message, 'reply'), 'Reply to the sender (r)'));
|
|
2011
|
+
actions.appendChild(actionButton('Reply all', 'corner-up-right',
|
|
2012
|
+
() => compose(message, 'replyAll'), 'Reply to everyone (Shift-R)'));
|
|
2013
|
+
actions.appendChild(actionButton('Forward', 'send',
|
|
2014
|
+
() => compose(message, 'forward'), 'Forward to someone else (f)'));
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
if (features.snooze) {
|
|
2018
|
+
actions.appendChild(actionButton('Snooze', 'clock',
|
|
2019
|
+
() => snooze(message), 'Put this out of sight until later'));
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
// Only on mail that actually offers it. A permanently dead
|
|
2023
|
+
// Unsubscribe button on every personal message would be worse
|
|
2024
|
+
// than not having one: it teaches people it never works.
|
|
2025
|
+
if (message.unsubscribe?.available) {
|
|
2026
|
+
actions.appendChild(actionButton(
|
|
2027
|
+
'Unsubscribe',
|
|
2028
|
+
'user-minus',
|
|
2029
|
+
() => unsubscribe(message),
|
|
2030
|
+
message.unsubscribe.oneClick
|
|
2031
|
+
? 'This list supports one-click unsubscribe'
|
|
2032
|
+
: 'Opens the list\u2019s own unsubscribe page or message'
|
|
2033
|
+
));
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
readerHeadEl.appendChild(actions);
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
readerHeadEl.appendChild(buildHeaderBlock(message));
|
|
2040
|
+
|
|
2041
|
+
readerBodyEl.textContent = '';
|
|
2042
|
+
|
|
2043
|
+
if (message.blockedImages > 0 && !message.remoteImagesLoaded) {
|
|
2044
|
+
const notice = document.createElement('div');
|
|
2045
|
+
notice.className = 'mail-notice';
|
|
2046
|
+
|
|
2047
|
+
const text = document.createElement('span');
|
|
2048
|
+
const count = message.blockedImages;
|
|
2049
|
+
text.textContent = `${count} remote image${count === 1 ? '' : 's'} blocked. Loading them tells the sender you opened this message.`;
|
|
2050
|
+
notice.appendChild(text);
|
|
2051
|
+
|
|
2052
|
+
const loadBtn = document.createElement('button');
|
|
2053
|
+
loadBtn.className = 'btn btn-sm btn-ghost';
|
|
2054
|
+
loadBtn.textContent = 'Load images';
|
|
2055
|
+
loadBtn.addEventListener('click', () => {
|
|
2056
|
+
imagesAllowed.add(message.uid);
|
|
2057
|
+
openMessage(message.uid, message.folder ?? openFolder);
|
|
2058
|
+
});
|
|
2059
|
+
notice.appendChild(loadBtn);
|
|
2060
|
+
|
|
2061
|
+
// The remembered version is a separate, explicit act rather
|
|
2062
|
+
// than a side effect of the button next to it: one is a
|
|
2063
|
+
// decision about this message, the other about every message
|
|
2064
|
+
// this sender ever sends.
|
|
2065
|
+
if (message.canTrustSender && message.trustableSender) {
|
|
2066
|
+
const trustBtn = document.createElement('button');
|
|
2067
|
+
trustBtn.className = 'btn btn-sm btn-ghost';
|
|
2068
|
+
trustBtn.textContent = `Always from ${message.trustableSender}`;
|
|
2069
|
+
trustBtn.addEventListener('click', async () => {
|
|
2070
|
+
trustBtn.disabled = true;
|
|
2071
|
+
try {
|
|
2072
|
+
const params = new URLSearchParams({account: currentAccountId ?? ''});
|
|
2073
|
+
await api(`${BASE}/image-senders?${params}`, 'POST',
|
|
2074
|
+
{address: message.trustableSender});
|
|
2075
|
+
E.toast(`Images from ${message.trustableSender} will load from now on.`,
|
|
2076
|
+
{type: 'success'});
|
|
2077
|
+
openMessage(message.uid, message.folder ?? openFolder);
|
|
2078
|
+
} catch (err) {
|
|
2079
|
+
E.toast(err.message, {type: 'error'});
|
|
2080
|
+
trustBtn.disabled = false;
|
|
2081
|
+
}
|
|
2082
|
+
});
|
|
2083
|
+
notice.appendChild(trustBtn);
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
readerBodyEl.appendChild(notice);
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
if (message.attachments.length) {
|
|
2090
|
+
const wrap = document.createElement('div');
|
|
2091
|
+
wrap.className = 'mail-attachments';
|
|
2092
|
+
for (const attachment of message.attachments) {
|
|
2093
|
+
const group = document.createElement('span');
|
|
2094
|
+
group.className = 'mail-attachment';
|
|
2095
|
+
|
|
2096
|
+
const btn = document.createElement('button');
|
|
2097
|
+
btn.className = 'btn btn-sm btn-ghost';
|
|
2098
|
+
btn.textContent = `${attachment.filename} (${formatSize(attachment.size)})`;
|
|
2099
|
+
btn.title = 'Download this file';
|
|
2100
|
+
btn.addEventListener('click', () => downloadAttachment(message.uid, attachment));
|
|
2101
|
+
group.appendChild(btn);
|
|
2102
|
+
|
|
2103
|
+
// Straight into the media library, which is the thing a
|
|
2104
|
+
// mail client inside a CMS can do and a webmail client
|
|
2105
|
+
// cannot. The server decides whether the type is one that
|
|
2106
|
+
// may be saved - see `_lib/mail/handoff.js`, which is
|
|
2107
|
+
// deliberately stricter than the CMS's own upload rules.
|
|
2108
|
+
if (features.handoff) {
|
|
2109
|
+
const save = document.createElement('button');
|
|
2110
|
+
save.className = 'btn btn-sm btn-ghost mail-attachment-save';
|
|
2111
|
+
save.title = `Save ${attachment.filename} to the media library`;
|
|
2112
|
+
const glyph = document.createElement('span');
|
|
2113
|
+
glyph.setAttribute('data-icon', 'image-plus');
|
|
2114
|
+
glyph.setAttribute('data-icon-size', '13');
|
|
2115
|
+
save.appendChild(glyph);
|
|
2116
|
+
save.addEventListener('click', async () => {
|
|
2117
|
+
save.disabled = true;
|
|
2118
|
+
await attachmentToMedia(message, attachment, attachment.index);
|
|
2119
|
+
save.disabled = false;
|
|
2120
|
+
});
|
|
2121
|
+
group.appendChild(save);
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
wrap.appendChild(group);
|
|
2125
|
+
}
|
|
2126
|
+
readerBodyEl.appendChild(wrap);
|
|
2127
|
+
Domma.icons.scan(wrap);
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
// The message renders in a sandboxed iframe with neither
|
|
2131
|
+
// allow-scripts nor allow-same-origin, so its content has no script
|
|
2132
|
+
// execution and no origin to reach back into the admin with.
|
|
2133
|
+
// allow-popups is there only so its links can open in a new tab.
|
|
2134
|
+
//
|
|
2135
|
+
// Loaded by `src`, never `srcdoc`: a srcdoc document inherits the
|
|
2136
|
+
// admin's CSP, whose img-src is 'self' data: blob:, so every remote
|
|
2137
|
+
// image was blocked before it was even requested and "Load images"
|
|
2138
|
+
// could not work. Fetched from a route, the body carries its own
|
|
2139
|
+
// policy.
|
|
2140
|
+
const frame = document.createElement('iframe');
|
|
2141
|
+
frame.setAttribute('sandbox', 'allow-popups allow-popups-to-escape-sandbox');
|
|
2142
|
+
frame.setAttribute('referrerpolicy', 'no-referrer');
|
|
2143
|
+
frame.src = message.bodyUrl;
|
|
2144
|
+
readerBodyEl.appendChild(frame);
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
/**
|
|
2148
|
+
* Keyboard navigation.
|
|
2149
|
+
*
|
|
2150
|
+
* Bound to the view's own container rather than the document, so it
|
|
2151
|
+
* stops applying the moment the user is somewhere else in the admin.
|
|
2152
|
+
* The mapping itself lives in keyboard.js and is tested there.
|
|
2153
|
+
*
|
|
2154
|
+
* @returns {void}
|
|
2155
|
+
*/
|
|
2156
|
+
function attachKeyboard() {
|
|
2157
|
+
const root = $container.get(0);
|
|
2158
|
+
if (!root) return;
|
|
2159
|
+
|
|
2160
|
+
// On the document, not the container: a keypress lands on whatever
|
|
2161
|
+
// has focus, which at rest is the body - a container listener only
|
|
2162
|
+
// ever hears keys pressed while something inside it already had
|
|
2163
|
+
// focus, which is exactly the case that does not need shortcuts.
|
|
2164
|
+
// Guarded on the container still being in the page, and removed on
|
|
2165
|
+
// unmount, so it never speaks for another admin screen.
|
|
2166
|
+
keyboardHandler = event => {
|
|
2167
|
+
if (!root.isConnected) return;
|
|
2168
|
+
if (isTyping(event.target) || !isPlainKey(event)) return;
|
|
2169
|
+
|
|
2170
|
+
const intent = resolveKey(event, {pane});
|
|
2171
|
+
if (!intent) return;
|
|
2172
|
+
|
|
2173
|
+
// Claimed only once something is going to happen with it -
|
|
2174
|
+
// an unbound key must still scroll the page.
|
|
2175
|
+
event.preventDefault();
|
|
2176
|
+
runIntent(intent);
|
|
2177
|
+
};
|
|
2178
|
+
document.addEventListener('keydown', keyboardHandler);
|
|
2179
|
+
|
|
2180
|
+
// Clicking into a pane is also a statement about where the
|
|
2181
|
+
// keyboard should apply.
|
|
2182
|
+
foldersEl?.addEventListener('mousedown', () => { pane = 'folders'; });
|
|
2183
|
+
listEl?.addEventListener('mousedown', () => { pane = 'messages'; });
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
/**
|
|
2187
|
+
* Carry out what a keypress meant.
|
|
2188
|
+
*
|
|
2189
|
+
* @param {{action: string, delta?: number}} intent
|
|
2190
|
+
* @returns {void}
|
|
2191
|
+
*/
|
|
2192
|
+
function runIntent(intent) {
|
|
2193
|
+
const open = messages.find(m => m.uid === (cursorUid ?? selectedUid$.value));
|
|
2194
|
+
|
|
2195
|
+
switch (intent.action) {
|
|
2196
|
+
case 'moveMessage':
|
|
2197
|
+
moveCursor(intent.delta);
|
|
2198
|
+
break;
|
|
2199
|
+
case 'moveFolder':
|
|
2200
|
+
moveFolderCursor(intent.delta);
|
|
2201
|
+
break;
|
|
2202
|
+
case 'enterFolder': {
|
|
2203
|
+
const focused = foldersEl?.querySelector('.mail-folder:focus');
|
|
2204
|
+
if (focused) focused.click();
|
|
2205
|
+
pane = 'messages';
|
|
2206
|
+
focusCursor();
|
|
2207
|
+
break;
|
|
2208
|
+
}
|
|
2209
|
+
case 'focusFolders':
|
|
2210
|
+
pane = 'folders';
|
|
2211
|
+
(foldersEl?.querySelector('.mail-folder.is-active')
|
|
2212
|
+
?? foldersEl?.querySelector('.mail-folder'))?.focus();
|
|
2213
|
+
break;
|
|
2214
|
+
case 'focusReader':
|
|
2215
|
+
readerBodyEl?.querySelector('iframe')?.focus();
|
|
2216
|
+
break;
|
|
2217
|
+
case 'openMessage':
|
|
2218
|
+
if (cursorUid !== null) {
|
|
2219
|
+
const row = messages.find(m => m.uid === cursorUid);
|
|
2220
|
+
if (row) openMessage(row.uid, row.folder);
|
|
2221
|
+
}
|
|
2222
|
+
break;
|
|
2223
|
+
case 'toggleUnread':
|
|
2224
|
+
if (open && features.write) {
|
|
2225
|
+
const seen = open.seen === true;
|
|
2226
|
+
changeFlags(open, seen ? {remove: ['\\Seen']} : {add: ['\\Seen']});
|
|
2227
|
+
}
|
|
2228
|
+
break;
|
|
2229
|
+
case 'toggleStar':
|
|
2230
|
+
if (open && features.write) {
|
|
2231
|
+
const starred = open.flagged === true;
|
|
2232
|
+
changeFlags(open, starred ? {remove: ['\\Flagged']} : {add: ['\\Flagged']});
|
|
2233
|
+
}
|
|
2234
|
+
break;
|
|
2235
|
+
case 'reply':
|
|
2236
|
+
case 'replyAll':
|
|
2237
|
+
case 'forward':
|
|
2238
|
+
if (open && features.send) compose(open, intent.action);
|
|
2239
|
+
break;
|
|
2240
|
+
case 'compose':
|
|
2241
|
+
if (features.send) {
|
|
2242
|
+
identitySoon().then(profile => openCompose(composeOptions(profile, {
|
|
2243
|
+
mode: 'new'
|
|
2244
|
+
// Without this the window failing to build is an
|
|
2245
|
+
// unhandled rejection in the console and a key that
|
|
2246
|
+
// looks broken.
|
|
2247
|
+
}))).catch(err => E.toast(err.message || 'Compose could not be opened.', {type: 'error'}));
|
|
2248
|
+
}
|
|
2249
|
+
break;
|
|
2250
|
+
case 'toggleSelect':
|
|
2251
|
+
// The keyboard way in, which until now did not exist:
|
|
2252
|
+
// selecting needed a mouse and a modifier, so a tablet
|
|
2253
|
+
// could not select at all.
|
|
2254
|
+
if (features.write && cursorUid !== null) toggleSelected(cursorUid);
|
|
2255
|
+
break;
|
|
2256
|
+
case 'selectAll':
|
|
2257
|
+
if (features.write) toggleSelectAll();
|
|
2258
|
+
break;
|
|
2259
|
+
case 'spam':
|
|
2260
|
+
// Which direction is the message's business, not the
|
|
2261
|
+
// key's: something already marked has only one sensible
|
|
2262
|
+
// meaning left, which is "this isn't spam".
|
|
2263
|
+
if (open && features.write) {
|
|
2264
|
+
const already = messageIsSpam(open);
|
|
2265
|
+
if (already || junkFolder()) markSpam(open, !already);
|
|
2266
|
+
}
|
|
2267
|
+
break;
|
|
2268
|
+
case 'delete':
|
|
2269
|
+
if (open && features.write) deleteMessage(open);
|
|
2270
|
+
break;
|
|
2271
|
+
case 'search':
|
|
2272
|
+
searchInputEl?.focus();
|
|
2273
|
+
break;
|
|
2274
|
+
case 'escape':
|
|
2275
|
+
if (selected.size) clearSelection();
|
|
2276
|
+
else if (searchQuery) clearSearch();
|
|
2277
|
+
break;
|
|
2278
|
+
case 'help':
|
|
2279
|
+
showShortcuts();
|
|
2280
|
+
break;
|
|
2281
|
+
default:
|
|
2282
|
+
break;
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
/**
|
|
2287
|
+
* Move the cursor through the message list.
|
|
2288
|
+
*
|
|
2289
|
+
* Moving is not opening: a cursor that opened every message it passed
|
|
2290
|
+
* would mark a folder read on the way down, which is the whole reason
|
|
2291
|
+
* the read delay exists.
|
|
2292
|
+
*
|
|
2293
|
+
* @param {number} delta
|
|
2294
|
+
* @returns {void}
|
|
2295
|
+
*/
|
|
2296
|
+
function moveCursor(delta) {
|
|
2297
|
+
if (!messages.length) return;
|
|
2298
|
+
const order = messages.map(m => m.uid);
|
|
2299
|
+
const current = order.indexOf(cursorUid ?? selectedUid$.value);
|
|
2300
|
+
const next = current === -1
|
|
2301
|
+
? (delta > 0 ? 0 : order.length - 1)
|
|
2302
|
+
: Math.min(order.length - 1, Math.max(0, current + delta));
|
|
2303
|
+
cursorUid = order[next];
|
|
2304
|
+
focusCursor();
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
/**
|
|
2308
|
+
* Put the browser's focus on the cursor row, so it scrolls into view
|
|
2309
|
+
* and screen readers follow along.
|
|
2310
|
+
*
|
|
2311
|
+
* @returns {void}
|
|
2312
|
+
*/
|
|
2313
|
+
function focusCursor() {
|
|
2314
|
+
if (cursorUid === null && messages.length) cursorUid = messages[0].uid;
|
|
2315
|
+
const row = listEl?.querySelector(`.mail-item[data-uid="${cursorUid}"]`);
|
|
2316
|
+
row?.focus({preventScroll: false});
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
/**
|
|
2320
|
+
* Move through the folder list.
|
|
2321
|
+
*
|
|
2322
|
+
* @param {number} delta
|
|
2323
|
+
* @returns {void}
|
|
2324
|
+
*/
|
|
2325
|
+
function moveFolderCursor(delta) {
|
|
2326
|
+
const rows = [...(foldersEl?.querySelectorAll('.mail-folder:not([disabled])') ?? [])];
|
|
2327
|
+
if (!rows.length) return;
|
|
2328
|
+
const current = rows.findIndex(r => r === document.activeElement);
|
|
2329
|
+
const next = current === -1
|
|
2330
|
+
? (delta > 0 ? 0 : rows.length - 1)
|
|
2331
|
+
: Math.min(rows.length - 1, Math.max(0, current + delta));
|
|
2332
|
+
rows[next].focus();
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
/**
|
|
2336
|
+
* The shortcut list, on demand.
|
|
2337
|
+
*
|
|
2338
|
+
* @returns {void}
|
|
2339
|
+
*/
|
|
2340
|
+
function showShortcuts() {
|
|
2341
|
+
const modal = E.slideover({title: 'Keyboard shortcuts', size: 'sm', position: 'right'});
|
|
2342
|
+
const list = document.createElement('div');
|
|
2343
|
+
list.className = 'mail-kv';
|
|
2344
|
+
for (const [keys, what] of SHORTCUTS) {
|
|
2345
|
+
const row = document.createElement('div');
|
|
2346
|
+
row.className = 'mail-kv-row text-sm';
|
|
2347
|
+
const k = document.createElement('code');
|
|
2348
|
+
k.className = 'mail-kv-key';
|
|
2349
|
+
k.textContent = keys;
|
|
2350
|
+
const d = document.createElement('span');
|
|
2351
|
+
d.textContent = what;
|
|
2352
|
+
row.appendChild(k);
|
|
2353
|
+
row.appendChild(d);
|
|
2354
|
+
list.appendChild(row);
|
|
2355
|
+
}
|
|
2356
|
+
modal.element.appendChild(list);
|
|
2357
|
+
modal.open();
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
/**
|
|
2361
|
+
* Right-click actions on a message.
|
|
2362
|
+
*
|
|
2363
|
+
* Registered with Domma rather than on a private `contextmenu`
|
|
2364
|
+
* listener. Domma keeps one document listener and resolves a
|
|
2365
|
+
* right-click by walking outward from the target, so the nearest menu
|
|
2366
|
+
* answers and script load order cannot decide the winner - a private
|
|
2367
|
+
* listener here would fight whatever else the page has registered.
|
|
2368
|
+
*
|
|
2369
|
+
* `inherit: false` because a message's actions and whatever encloses
|
|
2370
|
+
* the list are not one list. Items are rebuilt per gesture, since what
|
|
2371
|
+
* they say depends on the message under the pointer.
|
|
2372
|
+
*
|
|
2373
|
+
* @returns {void}
|
|
2374
|
+
*/
|
|
2375
|
+
function attachMessageMenu() {
|
|
2376
|
+
// Called as a method, not lifted into a variable: it keeps its
|
|
2377
|
+
// instance registry on `this`, so an unbound reference throws on
|
|
2378
|
+
// the first right-click - and, being inside onMount, takes the
|
|
2379
|
+
// whole view down with it.
|
|
2380
|
+
const elements = (typeof E !== 'undefined' && E?.contextMenu) ? E
|
|
2381
|
+
: (typeof Domma !== 'undefined' ? Domma?.elements : null);
|
|
2382
|
+
if (typeof elements?.contextMenu !== 'function') return;
|
|
2383
|
+
|
|
2384
|
+
messageMenu = elements.contextMenu(listEl, {
|
|
2385
|
+
match: '.mail-item',
|
|
2386
|
+
inherit: false,
|
|
2387
|
+
items: [],
|
|
2388
|
+
onBeforeOpen: (ctx) => {
|
|
2389
|
+
const row = ctx?.target?.closest?.('.mail-item');
|
|
2390
|
+
if (!row) return false;
|
|
2391
|
+
const uid = Number(row.dataset.uid);
|
|
2392
|
+
const message = messages.find(m => m.uid === uid);
|
|
2393
|
+
if (!message) return false;
|
|
2394
|
+
messageMenu.setItems(messageMenuItems(message, row.dataset.folder));
|
|
2395
|
+
return true;
|
|
2396
|
+
}
|
|
2397
|
+
});
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
/**
|
|
2401
|
+
* The items for one message.
|
|
2402
|
+
*
|
|
2403
|
+
* Both halves of each pair are always listed, with the inapplicable
|
|
2404
|
+
* one disabled rather than dropped. A menu that changes shape
|
|
2405
|
+
* depending on what was clicked is disorienting, and greyed-out is
|
|
2406
|
+
* what a native menu does.
|
|
2407
|
+
*
|
|
2408
|
+
* @param {object} message
|
|
2409
|
+
* @param {string} folder
|
|
2410
|
+
* @returns {object[]}
|
|
2411
|
+
*/
|
|
2412
|
+
function messageMenuItems(message, folder) {
|
|
2413
|
+
const flags = message.flags$ ? message.flags$.value : (message.flags ?? []);
|
|
2414
|
+
const isSeen = flags.includes('\\Seen');
|
|
2415
|
+
const isFlagged = flags.includes('\\Flagged');
|
|
2416
|
+
const target = {...message, folder};
|
|
2417
|
+
// Two ways a message is already spam: filed in Junk, or carrying
|
|
2418
|
+
// the keyword where a server-side filter marked it without moving
|
|
2419
|
+
// it. Both need a way back, and only the folder was obvious.
|
|
2420
|
+
const isSpam = messageIsSpam(target);
|
|
2421
|
+
const senderKnown = isKnownAddress(addressBook, (message.from ?? [])[0]?.address);
|
|
2422
|
+
|
|
2423
|
+
// Nested, like the folder pane, rather than a flat list of full
|
|
2424
|
+
// paths: `Work.Contracts.2026` reads as a string to be parsed, and
|
|
2425
|
+
// sorting by root puts siblings pages apart once there are more
|
|
2426
|
+
// than a handful of folders.
|
|
2427
|
+
const moveTargets = folderMenuItems(buildFolderTree(folders), {
|
|
2428
|
+
current: folder,
|
|
2429
|
+
onPick: path => moveTo(target, path)
|
|
2430
|
+
});
|
|
2431
|
+
|
|
2432
|
+
return [
|
|
2433
|
+
// The one item here that is hidden rather than greyed out
|
|
2434
|
+
// when it does not apply. The rest are pairs, where a disabled
|
|
2435
|
+
// half says which way the message already is; this has no
|
|
2436
|
+
// counterpart, so on ordinary mail it would be a permanently
|
|
2437
|
+
// dead row on every right-click in every folder.
|
|
2438
|
+
...(features.send && isDraft(target) ? [
|
|
2439
|
+
{
|
|
2440
|
+
label: 'Edit draft',
|
|
2441
|
+
icon: 'edit-3',
|
|
2442
|
+
action: () => editDraft(target)
|
|
2443
|
+
},
|
|
2444
|
+
{type: 'separator'}
|
|
2445
|
+
] : []),
|
|
2446
|
+
{
|
|
2447
|
+
label: 'Mark as read',
|
|
2448
|
+
icon: 'mail-open',
|
|
2449
|
+
disabled: () => isSeen,
|
|
2450
|
+
action: () => changeFlags(target, {add: ['\\Seen']})
|
|
2451
|
+
},
|
|
2452
|
+
{
|
|
2453
|
+
label: 'Mark as unread',
|
|
2454
|
+
icon: 'mail',
|
|
2455
|
+
disabled: () => !isSeen,
|
|
2456
|
+
action: () => changeFlags(target, {remove: ['\\Seen']})
|
|
2457
|
+
},
|
|
2458
|
+
{type: 'separator'},
|
|
2459
|
+
{
|
|
2460
|
+
label: 'Star',
|
|
2461
|
+
icon: 'star',
|
|
2462
|
+
disabled: () => isFlagged,
|
|
2463
|
+
action: () => changeFlags(target, {add: ['\\Flagged']})
|
|
2464
|
+
},
|
|
2465
|
+
{
|
|
2466
|
+
label: 'Unstar',
|
|
2467
|
+
icon: 'star-filled',
|
|
2468
|
+
disabled: () => !isFlagged,
|
|
2469
|
+
action: () => changeFlags(target, {remove: ['\\Flagged']})
|
|
2470
|
+
},
|
|
2471
|
+
...(contactsAvailable ? [
|
|
2472
|
+
{
|
|
2473
|
+
label: 'Add sender to contacts',
|
|
2474
|
+
icon: 'user-plus',
|
|
2475
|
+
// Greyed rather than hidden once they are known, which
|
|
2476
|
+
// is also how you find out they already are.
|
|
2477
|
+
disabled: () => senderKnown,
|
|
2478
|
+
action: () => addSender(message)
|
|
2479
|
+
},
|
|
2480
|
+
{type: 'separator'}
|
|
2481
|
+
] : []),
|
|
2482
|
+
{type: 'separator'},
|
|
2483
|
+
{
|
|
2484
|
+
label: 'Mark as spam',
|
|
2485
|
+
icon: 'shield-alert',
|
|
2486
|
+
// Greyed out rather than hidden when it is already filed
|
|
2487
|
+
// as junk, the same as every other pair in this menu.
|
|
2488
|
+
disabled: () => isSpam || !junkFolder(),
|
|
2489
|
+
action: () => markSpam(target, true)
|
|
2490
|
+
},
|
|
2491
|
+
{
|
|
2492
|
+
label: 'Not spam',
|
|
2493
|
+
icon: 'shield-check',
|
|
2494
|
+
disabled: () => !isSpam,
|
|
2495
|
+
action: () => markSpam(target, false)
|
|
2496
|
+
},
|
|
2497
|
+
{type: 'separator'},
|
|
2498
|
+
...(features.snooze ? [{
|
|
2499
|
+
label: 'Snooze\u2026',
|
|
2500
|
+
icon: 'clock',
|
|
2501
|
+
action: () => snooze(target)
|
|
2502
|
+
}] : []),
|
|
2503
|
+
{
|
|
2504
|
+
label: 'Move to',
|
|
2505
|
+
icon: 'folder',
|
|
2506
|
+
// Nowhere to move to is a reason to grey it out, not to
|
|
2507
|
+
// hide it and leave someone wondering where it went.
|
|
2508
|
+
disabled: () => moveTargets.length === 0,
|
|
2509
|
+
submenu: moveTargets
|
|
2510
|
+
},
|
|
2511
|
+
...(features.handoff ? [
|
|
2512
|
+
{type: 'separator'},
|
|
2513
|
+
{
|
|
2514
|
+
label: 'Save to the site',
|
|
2515
|
+
icon: 'layout',
|
|
2516
|
+
submenu: [
|
|
2517
|
+
{
|
|
2518
|
+
label: 'As a draft page',
|
|
2519
|
+
icon: 'file-text',
|
|
2520
|
+
action: () => messageToPage(target)
|
|
2521
|
+
},
|
|
2522
|
+
{
|
|
2523
|
+
label: 'As a collection entry\u2026',
|
|
2524
|
+
icon: 'database',
|
|
2525
|
+
action: () => messageToEntry(target)
|
|
2526
|
+
}
|
|
2527
|
+
]
|
|
2528
|
+
}
|
|
2529
|
+
] : []),
|
|
2530
|
+
{type: 'separator'},
|
|
2531
|
+
{
|
|
2532
|
+
label: 'Delete',
|
|
2533
|
+
icon: 'trash',
|
|
2534
|
+
danger: true,
|
|
2535
|
+
action: () => deleteMessage(target)
|
|
2536
|
+
}
|
|
2537
|
+
];
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
/**
|
|
2541
|
+
* Move a message, and take it out of the list it is leaving.
|
|
2542
|
+
*
|
|
2543
|
+
* @param {object} message
|
|
2544
|
+
* @param {string} destination
|
|
2545
|
+
* @returns {Promise<void>}
|
|
2546
|
+
*/
|
|
2547
|
+
async function moveTo(message, destination) {
|
|
2548
|
+
try {
|
|
2549
|
+
const params = new URLSearchParams({
|
|
2550
|
+
account: currentAccountId ?? '',
|
|
2551
|
+
folder: message.folder ?? currentFolder
|
|
2552
|
+
});
|
|
2553
|
+
const source = message.folder ?? currentFolder;
|
|
2554
|
+
const result = await api(`${BASE}/messages/${message.uid}/move?${params}`, 'POST', {destination});
|
|
2555
|
+
removeFromList(message.uid);
|
|
2556
|
+
offerUndo(
|
|
2557
|
+
`Moved to ${destination}.`,
|
|
2558
|
+
result.newUid
|
|
2559
|
+
? () => undoMove({uid: result.newUid, from: destination, to: source})
|
|
2560
|
+
: null
|
|
2561
|
+
);
|
|
2562
|
+
} catch (err) {
|
|
2563
|
+
E.toast(err.message || 'That message could not be moved.', {type: 'error'});
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
/**
|
|
2568
|
+
* Where this account keeps junk, by SPECIAL-USE.
|
|
2569
|
+
*
|
|
2570
|
+
* The folder list already carries it, so the menu can decide whether
|
|
2571
|
+
* marking as spam is even possible without a round trip. The server
|
|
2572
|
+
* resolves it again for itself - this is only what to grey out.
|
|
2573
|
+
*
|
|
2574
|
+
* @returns {string|null}
|
|
2575
|
+
*/
|
|
2576
|
+
function junkFolder() {
|
|
2577
|
+
return folders.find(f => f.specialUse === '\\Junk')?.path ?? null;
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2580
|
+
/**
|
|
2581
|
+
* Fetch the address book, if this site has Contacts at all.
|
|
2582
|
+
*
|
|
2583
|
+
* Never awaited by anything on the critical path: mail must open at
|
|
2584
|
+
* the same speed whether or not an optional plugin answers.
|
|
2585
|
+
*
|
|
2586
|
+
* @returns {Promise<void>}
|
|
2587
|
+
*/
|
|
2588
|
+
async function refreshContacts() {
|
|
2589
|
+
const book = await loadContacts(api);
|
|
2590
|
+
addressBook = book.contacts;
|
|
2591
|
+
contactsAvailable = book.available;
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
/**
|
|
2595
|
+
* The open mailbox's sending identity.
|
|
2596
|
+
*
|
|
2597
|
+
* Cached per mailbox and re-fetched when the mailbox changes, because
|
|
2598
|
+
* a display name and a signature belong to an address, not to a
|
|
2599
|
+
* person - someone writing from two mailboxes signs off differently
|
|
2600
|
+
* from each.
|
|
2601
|
+
*
|
|
2602
|
+
* Never rejects: a mailbox with no identity route (the free edition)
|
|
2603
|
+
* or a request that failed answers null, and a message without a
|
|
2604
|
+
* signature is worth far more than a compose window that would not
|
|
2605
|
+
* open.
|
|
2606
|
+
*
|
|
2607
|
+
* @returns {Promise<object|null>}
|
|
2608
|
+
*/
|
|
2609
|
+
function identity() {
|
|
2610
|
+
if (!features.send || !currentAccountId) return Promise.resolve(EMPTY_PROFILE);
|
|
2611
|
+
if (profileFor !== currentAccountId) {
|
|
2612
|
+
profileFor = currentAccountId;
|
|
2613
|
+
const params = new URLSearchParams({account: currentAccountId});
|
|
2614
|
+
// All three at once, and none of them able to fail the set:
|
|
2615
|
+
// a mailbox with no templates must still get its signature,
|
|
2616
|
+
// and a site whose templates route is somehow unreachable
|
|
2617
|
+
// must still be able to compose.
|
|
2618
|
+
profileRequest = Promise.all([
|
|
2619
|
+
api(`${BASE}/identities?${params}`).catch(() => null),
|
|
2620
|
+
api(`${BASE}/templates?${params}`).catch(() => null),
|
|
2621
|
+
api(`${BASE}/sending?${params}`).catch(() => null)
|
|
2622
|
+
]).then(([identities, templates, sending]) => ({
|
|
2623
|
+
identities: identities?.identities ?? [],
|
|
2624
|
+
mailboxAddress: identities?.mailboxAddress ?? '',
|
|
2625
|
+
templates: templates?.templates ?? [],
|
|
2626
|
+
undoSendSeconds: sending?.undoSendSeconds ?? 0
|
|
2627
|
+
})).catch(() => EMPTY_PROFILE);
|
|
2628
|
+
}
|
|
2629
|
+
return profileRequest;
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
/**
|
|
2633
|
+
* The identity, or null if it is taking too long to matter.
|
|
2634
|
+
*
|
|
2635
|
+
* Compose is a window someone is waiting on, and `api()` has no
|
|
2636
|
+
* timeout - a mail server that hangs would otherwise mean a Compose
|
|
2637
|
+
* button that does nothing at all, with nothing said about why. The
|
|
2638
|
+
* prefetch in `loadFolders` means this has almost always resolved
|
|
2639
|
+
* long before anyone clicks, so the ceiling is a backstop rather
|
|
2640
|
+
* than a delay anybody meets.
|
|
2641
|
+
*
|
|
2642
|
+
* Said out loud when it bites. A message quietly going out without
|
|
2643
|
+
* the signature the sender believes is on it is the one outcome
|
|
2644
|
+
* worse than waiting.
|
|
2645
|
+
*
|
|
2646
|
+
* @returns {Promise<object|null>}
|
|
2647
|
+
*/
|
|
2648
|
+
function identitySoon() {
|
|
2649
|
+
if (!features.send || !currentAccountId) return Promise.resolve(EMPTY_PROFILE);
|
|
2650
|
+
let timer = null;
|
|
2651
|
+
return Promise.race([
|
|
2652
|
+
identity().then(who => { clearTimeout(timer); return who; }),
|
|
2653
|
+
new Promise(resolve => {
|
|
2654
|
+
timer = setTimeout(() => {
|
|
2655
|
+
E.toast('Your signature could not be loaded, so this message does not carry one.',
|
|
2656
|
+
{type: 'warning'});
|
|
2657
|
+
resolve(EMPTY_PROFILE);
|
|
2658
|
+
}, 4000);
|
|
2659
|
+
})
|
|
2660
|
+
]);
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
/**
|
|
2664
|
+
* The common half of every compose window.
|
|
2665
|
+
*
|
|
2666
|
+
* Four different things open one - the button, the keyboard, a reply
|
|
2667
|
+
* and a reopened draft - and every one of them needs the same six
|
|
2668
|
+
* arguments. Spelled out four times, five of them would eventually be
|
|
2669
|
+
* right in three places.
|
|
2670
|
+
*
|
|
2671
|
+
* @param {object} profile - from `identitySoon()`
|
|
2672
|
+
* @param {object} extra - what makes this window different
|
|
2673
|
+
* @returns {object}
|
|
2674
|
+
*/
|
|
2675
|
+
function composeOptions(profile, extra = {}) {
|
|
2676
|
+
return {
|
|
2677
|
+
base: BASE,
|
|
2678
|
+
accountId: currentAccountId,
|
|
2679
|
+
api,
|
|
2680
|
+
maxAttachmentBytes: limits.maxAttachmentBytes,
|
|
2681
|
+
contacts: addressBook,
|
|
2682
|
+
identities: profile?.identities ?? [],
|
|
2683
|
+
mailboxAddress: profile?.mailboxAddress ?? '',
|
|
2684
|
+
templates: profile?.templates ?? [],
|
|
2685
|
+
undoSeconds: profile?.undoSendSeconds ?? 0,
|
|
2686
|
+
onSent: () => loadMessages(),
|
|
2687
|
+
onQueued: (result) => offerRecall(result),
|
|
2688
|
+
...extra
|
|
2689
|
+
};
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
/**
|
|
2693
|
+
* A message has been queued rather than sent. Offer to stop it.
|
|
2694
|
+
*
|
|
2695
|
+
* Reuses the undo strip a move or a delete puts up, because it is the
|
|
2696
|
+
* same promise: something happened, and there is a moment in which it
|
|
2697
|
+
* can be unhappened. The message is handed back whole on cancel and
|
|
2698
|
+
* the window reopens with everything in it - an Undo that destroyed
|
|
2699
|
+
* the message would be worse than no Undo.
|
|
2700
|
+
*
|
|
2701
|
+
* @param {{id: string, at: string, isUndo: boolean}} result
|
|
2702
|
+
* @returns {void}
|
|
2703
|
+
*/
|
|
2704
|
+
function offerRecall(result) {
|
|
2705
|
+
const when = describeWhen(result.at);
|
|
2706
|
+
// On screen until the message actually goes, plus a moment - and
|
|
2707
|
+
// never less than the ordinary window, so a five second hold does
|
|
2708
|
+
// not flash past.
|
|
2709
|
+
const until = new Date(result.at).getTime() - Date.now() + 2000;
|
|
2710
|
+
offerUndo(
|
|
2711
|
+
result.isUndo ? `Sending ${when}\u2026` : `Scheduled to send ${when}.`,
|
|
2712
|
+
async () => {
|
|
2713
|
+
const cancelled = await api(`${BASE}/outbox/${result.id}`, 'DELETE');
|
|
2714
|
+
const profile = await identitySoon();
|
|
2715
|
+
openCompose(composeOptions(profile, {
|
|
2716
|
+
draft: {...cancelled.draft, identityId: cancelled.identityId},
|
|
2717
|
+
mode: 'edit'
|
|
2718
|
+
}));
|
|
2719
|
+
},
|
|
2720
|
+
// A scheduled send can be days out; the strip is not a
|
|
2721
|
+
// countdown, so it keeps the ordinary window in that case.
|
|
2722
|
+
{ms: result.isUndo ? Math.max(UNDO_WINDOW_MS, until) : UNDO_WINDOW_MS}
|
|
2723
|
+
);
|
|
2724
|
+
// A scheduled message is not in Drafts and not in Sent, so
|
|
2725
|
+
// nothing in the list changes - but the outbox count in the
|
|
2726
|
+
// header does.
|
|
2727
|
+
refreshOutbox();
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
// ---------------------------------------------------------------
|
|
2731
|
+
// The outbox
|
|
2732
|
+
// ---------------------------------------------------------------
|
|
2733
|
+
|
|
2734
|
+
/**
|
|
2735
|
+
* Count what is waiting to be sent, and what is put away.
|
|
2736
|
+
*
|
|
2737
|
+
* Cheap, quiet and best-effort: the header badge is a convenience,
|
|
2738
|
+
* and a mailbox whose outbox cannot be read should still read mail.
|
|
2739
|
+
*
|
|
2740
|
+
* @returns {Promise<void>}
|
|
2741
|
+
*/
|
|
2742
|
+
async function refreshOutbox() {
|
|
2743
|
+
if (!features.send || !outboxBtn || !currentAccountId) return;
|
|
2744
|
+
try {
|
|
2745
|
+
const params = new URLSearchParams({account: currentAccountId});
|
|
2746
|
+
const [outbox, snoozed] = await Promise.all([
|
|
2747
|
+
api(`${BASE}/outbox?${params}`).catch(() => null),
|
|
2748
|
+
api(`${BASE}/snoozed?${params}`).catch(() => null)
|
|
2749
|
+
]);
|
|
2750
|
+
|
|
2751
|
+
// `sending` counts as waiting: it is in flight, not gone, and
|
|
2752
|
+
// a row the worker is holding must not disappear from the
|
|
2753
|
+
// badge while it is being retried.
|
|
2754
|
+
const waiting = (outbox?.queued ?? [])
|
|
2755
|
+
.filter(row => ['pending', 'sending'].includes(row.status)).length;
|
|
2756
|
+
const failed = (outbox?.queued ?? []).filter(row => row.status === 'failed').length;
|
|
2757
|
+
const asleep = (snoozed?.snoozed ?? []).length;
|
|
2758
|
+
const total = waiting + asleep;
|
|
2759
|
+
|
|
2760
|
+
outboxBtn.hidden = total === 0 && failed === 0;
|
|
2761
|
+
if (outboxCountEl) outboxCountEl.textContent = total ? ` ${total}` : '';
|
|
2762
|
+
// A failed send is the one thing here worth colouring: it is
|
|
2763
|
+
// a message the sender believes has gone.
|
|
2764
|
+
outboxBtn.classList.toggle('is-failed', failed > 0);
|
|
2765
|
+
outboxBtn.title = failed
|
|
2766
|
+
? `${failed} message${failed === 1 ? '' : 's'} could not be sent`
|
|
2767
|
+
: `${waiting} waiting to send, ${asleep} snoozed`;
|
|
2768
|
+
} catch {
|
|
2769
|
+
// Nothing to say. The badge is a convenience.
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
/**
|
|
2774
|
+
* The outbox and the snoozed list, in one panel.
|
|
2775
|
+
*
|
|
2776
|
+
* Both are "mail that is not where you would look for it", which is
|
|
2777
|
+
* one question however many queues answer it.
|
|
2778
|
+
*
|
|
2779
|
+
* @returns {Promise<void>}
|
|
2780
|
+
*/
|
|
2781
|
+
async function showOutbox() {
|
|
2782
|
+
const slideover = E.slideover({title: 'Waiting', size: 'md', position: 'right'});
|
|
2783
|
+
const host = document.createElement('div');
|
|
2784
|
+
host.className = 'mail-panel-stack';
|
|
2785
|
+
slideover.element.appendChild(host);
|
|
2786
|
+
slideover.open();
|
|
2787
|
+
|
|
2788
|
+
/**
|
|
2789
|
+
* @returns {Promise<void>}
|
|
2790
|
+
*/
|
|
2791
|
+
async function draw() {
|
|
2792
|
+
host.textContent = '';
|
|
2793
|
+
const params = new URLSearchParams({account: currentAccountId ?? ''});
|
|
2794
|
+
const [outbox, snoozed] = await Promise.all([
|
|
2795
|
+
api(`${BASE}/outbox?${params}`).catch(() => ({queued: []})),
|
|
2796
|
+
api(`${BASE}/snoozed?${params}`).catch(() => ({snoozed: []}))
|
|
2797
|
+
]);
|
|
2798
|
+
|
|
2799
|
+
host.appendChild(section('Waiting to send', (outbox.queued ?? []).map(row => ({
|
|
2800
|
+
title: row.subject || '(no subject)',
|
|
2801
|
+
detail: row.status === 'pending'
|
|
2802
|
+
? `To ${formatAddresses(row.to) || 'nobody'} \u00b7 ${describeWhen(row.at)}`
|
|
2803
|
+
: `${row.status}${row.error ? ` \u2013 ${row.error}` : ''}`,
|
|
2804
|
+
failed: row.status === 'failed',
|
|
2805
|
+
// Which buttons appear is the server's decision
|
|
2806
|
+
// (`canRetry` / `canCancel`), so the screen cannot offer
|
|
2807
|
+
// an action the route would then refuse.
|
|
2808
|
+
actions: [
|
|
2809
|
+
...(row.canRetry ? [
|
|
2810
|
+
[row.status === 'failed' ? 'Try again' : 'Send now', async () => {
|
|
2811
|
+
await api(`${BASE}/outbox/${row.id}/send-now`, 'POST');
|
|
2812
|
+
await draw();
|
|
2813
|
+
refreshOutbox();
|
|
2814
|
+
}]] : []),
|
|
2815
|
+
...(row.canCancel ? [
|
|
2816
|
+
['Cancel', async () => {
|
|
2817
|
+
const cancelled = await api(`${BASE}/outbox/${row.id}`, 'DELETE');
|
|
2818
|
+
await draw();
|
|
2819
|
+
refreshOutbox();
|
|
2820
|
+
// Reopened rather than discarded: a cancel that
|
|
2821
|
+
// destroyed the message would be a worse outcome
|
|
2822
|
+
// than the send it prevented.
|
|
2823
|
+
const profile = await identitySoon();
|
|
2824
|
+
openCompose(composeOptions(profile, {
|
|
2825
|
+
draft: {...cancelled.draft, identityId: cancelled.identityId},
|
|
2826
|
+
mode: 'edit'
|
|
2827
|
+
}));
|
|
2828
|
+
}]] : [])
|
|
2829
|
+
]
|
|
2830
|
+
})), 'Nothing is waiting to send.'));
|
|
2831
|
+
|
|
2832
|
+
host.appendChild(section('Snoozed', (snoozed.snoozed ?? []).map(row => ({
|
|
2833
|
+
title: row.subject || '(no subject)',
|
|
2834
|
+
detail: row.status === 'failed'
|
|
2835
|
+
? `Could not be brought back \u2013 ${row.error ?? 'unknown reason'}`
|
|
2836
|
+
: `Back in ${row.returnTo} ${describeWhen(row.at)}`,
|
|
2837
|
+
failed: row.status === 'failed',
|
|
2838
|
+
actions: [
|
|
2839
|
+
['Bring back now', async () => {
|
|
2840
|
+
await api(`${BASE}/snoozed/${row.id}/wake`, 'POST');
|
|
2841
|
+
await draw();
|
|
2842
|
+
refreshOutbox();
|
|
2843
|
+
await loadMessages();
|
|
2844
|
+
}]
|
|
2845
|
+
]
|
|
2846
|
+
})), 'Nothing is snoozed.'));
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
/**
|
|
2850
|
+
* @param {string} heading
|
|
2851
|
+
* @param {object[]} rows
|
|
2852
|
+
* @param {string} empty
|
|
2853
|
+
* @returns {HTMLElement}
|
|
2854
|
+
*/
|
|
2855
|
+
function section(heading, rows, empty) {
|
|
2856
|
+
const wrap = document.createElement('div');
|
|
2857
|
+
|
|
2858
|
+
const title = document.createElement('h4');
|
|
2859
|
+
title.className = 'mail-section-heading';
|
|
2860
|
+
title.textContent = heading;
|
|
2861
|
+
wrap.appendChild(title);
|
|
2862
|
+
|
|
2863
|
+
if (!rows.length) {
|
|
2864
|
+
const none = document.createElement('p');
|
|
2865
|
+
none.className = 'text-sm text-muted mail-none';
|
|
2866
|
+
none.textContent = empty;
|
|
2867
|
+
wrap.appendChild(none);
|
|
2868
|
+
return wrap;
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2871
|
+
for (const row of rows) {
|
|
2872
|
+
const item = document.createElement('div');
|
|
2873
|
+
item.className = 'mail-box-row';
|
|
2874
|
+
|
|
2875
|
+
const text = document.createElement('div');
|
|
2876
|
+
text.className = 'flex-1 mail-ellipsis';
|
|
2877
|
+
const name = document.createElement('div');
|
|
2878
|
+
name.className = 'text-sm mail-ellipsis';
|
|
2879
|
+
name.textContent = row.title;
|
|
2880
|
+
const detail = document.createElement('div');
|
|
2881
|
+
detail.className = 'text-xs text-muted';
|
|
2882
|
+
if (row.failed) detail.className = 'text-xs text-danger';
|
|
2883
|
+
detail.textContent = row.detail;
|
|
2884
|
+
text.appendChild(name);
|
|
2885
|
+
text.appendChild(detail);
|
|
2886
|
+
item.appendChild(text);
|
|
2887
|
+
|
|
2888
|
+
for (const [label, action] of row.actions) {
|
|
2889
|
+
const button = document.createElement('button');
|
|
2890
|
+
button.className = 'btn btn-sm btn-ghost';
|
|
2891
|
+
button.textContent = label;
|
|
2892
|
+
button.addEventListener('click', async () => {
|
|
2893
|
+
button.disabled = true;
|
|
2894
|
+
try {
|
|
2895
|
+
await action();
|
|
2896
|
+
} catch (err) {
|
|
2897
|
+
E.toast(err.message || 'That did not work.', {type: 'error'});
|
|
2898
|
+
button.disabled = false;
|
|
2899
|
+
}
|
|
2900
|
+
});
|
|
2901
|
+
item.appendChild(button);
|
|
2902
|
+
}
|
|
2903
|
+
wrap.appendChild(item);
|
|
2904
|
+
}
|
|
2905
|
+
return wrap;
|
|
2906
|
+
}
|
|
2907
|
+
|
|
2908
|
+
await draw();
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
// ---------------------------------------------------------------
|
|
2912
|
+
// Snooze, unsubscribe, and handing mail to the CMS
|
|
2913
|
+
// ---------------------------------------------------------------
|
|
2914
|
+
|
|
2915
|
+
/**
|
|
2916
|
+
* Ask when something should happen.
|
|
2917
|
+
*
|
|
2918
|
+
* The quick choices come from the same module the server validates
|
|
2919
|
+
* against, so a time offered here cannot be refused there.
|
|
2920
|
+
*
|
|
2921
|
+
* @param {string} title
|
|
2922
|
+
* @returns {Promise<string|null>} an ISO instant, or null if cancelled
|
|
2923
|
+
*/
|
|
2924
|
+
function askForTime(title) {
|
|
2925
|
+
return new Promise((resolve) => {
|
|
2926
|
+
const modal = E.modal({title, size: 'sm'});
|
|
2927
|
+
let answered = false;
|
|
2928
|
+
|
|
2929
|
+
/**
|
|
2930
|
+
* @param {string|null} at
|
|
2931
|
+
* @returns {void}
|
|
2932
|
+
*/
|
|
2933
|
+
const finish = (at) => {
|
|
2934
|
+
answered = true;
|
|
2935
|
+
resolve(at);
|
|
2936
|
+
modal.close();
|
|
2937
|
+
};
|
|
2938
|
+
|
|
2939
|
+
const panel = document.createElement('div');
|
|
2940
|
+
panel.className = 'mail-dialog-stack';
|
|
2941
|
+
|
|
2942
|
+
const choices = document.createElement('div');
|
|
2943
|
+
choices.className = 'flex flex-wrap gap-1';
|
|
2944
|
+
for (const option of quickTimes()) {
|
|
2945
|
+
const button = document.createElement('button');
|
|
2946
|
+
button.className = 'btn btn-sm btn-ghost';
|
|
2947
|
+
button.textContent = option.label;
|
|
2948
|
+
button.title = new Date(option.at).toLocaleString();
|
|
2949
|
+
button.addEventListener('click', () => finish(option.at));
|
|
2950
|
+
choices.appendChild(button);
|
|
2951
|
+
}
|
|
2952
|
+
panel.appendChild(choices);
|
|
2953
|
+
|
|
2954
|
+
const label = document.createElement('label');
|
|
2955
|
+
label.className = 'form-label';
|
|
2956
|
+
label.textContent = 'Or pick a time';
|
|
2957
|
+
const exact = document.createElement('input');
|
|
2958
|
+
exact.type = 'datetime-local';
|
|
2959
|
+
exact.className = 'form-input';
|
|
2960
|
+
panel.appendChild(label);
|
|
2961
|
+
panel.appendChild(exact);
|
|
2962
|
+
|
|
2963
|
+
const row = document.createElement('div');
|
|
2964
|
+
row.className = 'flex justify-end mt-2';
|
|
2965
|
+
const confirm = document.createElement('button');
|
|
2966
|
+
confirm.className = 'btn btn-sm btn-primary';
|
|
2967
|
+
confirm.textContent = 'Use this time';
|
|
2968
|
+
confirm.addEventListener('click', () => {
|
|
2969
|
+
if (!exact.value) return;
|
|
2970
|
+
// `datetime-local` carries no zone, so it is read in the
|
|
2971
|
+
// browser's - which is the one the person picking it is in.
|
|
2972
|
+
finish(new Date(exact.value).toISOString());
|
|
2973
|
+
});
|
|
2974
|
+
row.appendChild(confirm);
|
|
2975
|
+
panel.appendChild(row);
|
|
2976
|
+
|
|
2977
|
+
modal.element.appendChild(panel);
|
|
2978
|
+
// Closing the window any other way is a decision too, and a
|
|
2979
|
+
// promise nobody settles is a handler that never runs again.
|
|
2980
|
+
modal.on?.('close', () => { if (!answered) resolve(null); });
|
|
2981
|
+
modal.open();
|
|
2982
|
+
});
|
|
2983
|
+
}
|
|
2984
|
+
|
|
2985
|
+
/**
|
|
2986
|
+
* Put a message out of sight until a time.
|
|
2987
|
+
*
|
|
2988
|
+
* @param {object} message
|
|
2989
|
+
* @returns {Promise<void>}
|
|
2990
|
+
*/
|
|
2991
|
+
async function snooze(message) {
|
|
2992
|
+
const at = await askForTime('Snooze until');
|
|
2993
|
+
if (!at) return;
|
|
2994
|
+
|
|
2995
|
+
try {
|
|
2996
|
+
const params = new URLSearchParams({
|
|
2997
|
+
account: currentAccountId ?? '',
|
|
2998
|
+
folder: message.folder ?? openFolder ?? currentFolder
|
|
2999
|
+
});
|
|
3000
|
+
const result = await api(`${BASE}/messages/${message.uid}/snooze?${params}`, 'POST', {
|
|
3001
|
+
at,
|
|
3002
|
+
subject: message.subject ?? '',
|
|
3003
|
+
from: message.from ?? []
|
|
3004
|
+
});
|
|
3005
|
+
|
|
3006
|
+
removeFromList(message.uid);
|
|
3007
|
+
offerUndo(
|
|
3008
|
+
`Snoozed until ${new Date(result.at).toLocaleString()}.`,
|
|
3009
|
+
// Bringing it straight back is the undo. The message has
|
|
3010
|
+
// already moved, so there is no "leave it where it was".
|
|
3011
|
+
async () => {
|
|
3012
|
+
await api(`${BASE}/snoozed/${result.id}/wake`, 'POST');
|
|
3013
|
+
await loadMessages();
|
|
3014
|
+
}
|
|
3015
|
+
);
|
|
3016
|
+
refreshOutbox();
|
|
3017
|
+
} catch (err) {
|
|
3018
|
+
E.toast(err.message || 'That message could not be snoozed.', {type: 'error'});
|
|
3019
|
+
}
|
|
3020
|
+
}
|
|
3021
|
+
|
|
3022
|
+
/**
|
|
3023
|
+
* Get off the list a message came from.
|
|
3024
|
+
*
|
|
3025
|
+
* One-click is done by the server (RFC 8058). Anything else is handed
|
|
3026
|
+
* back and acted on here with the user watching - opening a link, or
|
|
3027
|
+
* a compose window addressed to the list's own unsubscribe address.
|
|
3028
|
+
* See `_lib/mail/unsubscribe.js` for why the distinction matters.
|
|
3029
|
+
*
|
|
3030
|
+
* @param {object} message
|
|
3031
|
+
* @returns {Promise<void>}
|
|
3032
|
+
*/
|
|
3033
|
+
async function unsubscribe(message) {
|
|
3034
|
+
if (!await E.confirm(`Unsubscribe from the list "${message.subject || 'this message'}" came from?`)) return;
|
|
3035
|
+
|
|
3036
|
+
try {
|
|
3037
|
+
const params = new URLSearchParams({
|
|
3038
|
+
account: currentAccountId ?? '',
|
|
3039
|
+
folder: message.folder ?? openFolder ?? currentFolder
|
|
3040
|
+
});
|
|
3041
|
+
const result = await api(`${BASE}/messages/${message.uid}/unsubscribe?${params}`, 'POST');
|
|
3042
|
+
|
|
3043
|
+
if (result.done) {
|
|
3044
|
+
E.toast('Unsubscribed. It can take a few days to take effect.', {type: 'success'});
|
|
3045
|
+
return;
|
|
3046
|
+
}
|
|
3047
|
+
if (result.error) {
|
|
3048
|
+
E.toast(result.error, {type: 'warning'});
|
|
3049
|
+
}
|
|
3050
|
+
|
|
3051
|
+
if (result.http) {
|
|
3052
|
+
// Opened rather than requested: this URL never said it
|
|
3053
|
+
// accepts a one-click POST, and sending one on the user's
|
|
3054
|
+
// behalf is not ours to decide.
|
|
3055
|
+
window.open(result.http, '_blank', 'noopener,noreferrer');
|
|
3056
|
+
E.toast('Opened the list\u2019s unsubscribe page in a new tab.', {type: 'info'});
|
|
3057
|
+
return;
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
if (result.mailto) {
|
|
3061
|
+
const profile = await identitySoon();
|
|
3062
|
+
openCompose(composeOptions(profile, {
|
|
3063
|
+
draft: {
|
|
3064
|
+
to: [{name: '', address: result.mailto.to}],
|
|
3065
|
+
subject: result.mailto.subject,
|
|
3066
|
+
body: result.mailto.body
|
|
3067
|
+
},
|
|
3068
|
+
mode: 'new'
|
|
3069
|
+
}));
|
|
3070
|
+
E.toast('Send this message to unsubscribe.', {type: 'info'});
|
|
3071
|
+
}
|
|
3072
|
+
} catch (err) {
|
|
3073
|
+
E.toast(err.message || 'That did not work.', {type: 'error'});
|
|
3074
|
+
}
|
|
3075
|
+
}
|
|
3076
|
+
|
|
3077
|
+
/**
|
|
3078
|
+
* Save an attachment into the site's media library.
|
|
3079
|
+
*
|
|
3080
|
+
* @param {object} message
|
|
3081
|
+
* @param {object} attachment
|
|
3082
|
+
* @param {number} index
|
|
3083
|
+
* @returns {Promise<void>}
|
|
3084
|
+
*/
|
|
3085
|
+
async function attachmentToMedia(message, attachment, index) {
|
|
3086
|
+
try {
|
|
3087
|
+
const params = new URLSearchParams({
|
|
3088
|
+
account: currentAccountId ?? '',
|
|
3089
|
+
folder: message.folder ?? openFolder ?? currentFolder
|
|
3090
|
+
});
|
|
3091
|
+
const result = await api(
|
|
3092
|
+
`${BASE}/messages/${message.uid}/attachments/${index}/to-media?${params}`, 'POST'
|
|
3093
|
+
);
|
|
3094
|
+
E.toast(`Saved to the media library as ${result.name}.`, {type: 'success'});
|
|
3095
|
+
} catch (err) {
|
|
3096
|
+
E.toast(err.message || 'That attachment could not be saved.', {type: 'error'});
|
|
3097
|
+
}
|
|
3098
|
+
}
|
|
3099
|
+
|
|
3100
|
+
/**
|
|
3101
|
+
* Turn a message into a draft page.
|
|
3102
|
+
*
|
|
3103
|
+
* @param {object} message
|
|
3104
|
+
* @returns {Promise<void>}
|
|
3105
|
+
*/
|
|
3106
|
+
async function messageToPage(message) {
|
|
3107
|
+
try {
|
|
3108
|
+
const params = new URLSearchParams({
|
|
3109
|
+
account: currentAccountId ?? '',
|
|
3110
|
+
folder: message.folder ?? openFolder ?? currentFolder
|
|
3111
|
+
});
|
|
3112
|
+
const result = await api(`${BASE}/messages/${message.uid}/to-page?${params}`, 'POST');
|
|
3113
|
+
E.toast(`Created the draft page ${result.urlPath}.`, {type: 'success'});
|
|
3114
|
+
// Offered rather than taken: leaving the mail view loses
|
|
3115
|
+
// whatever was half-read in it.
|
|
3116
|
+
if (await E.confirm(`Open "${result.title}" in the page editor now?`)) {
|
|
3117
|
+
location.hash = `#/pages/edit${result.urlPath}`;
|
|
3118
|
+
}
|
|
3119
|
+
} catch (err) {
|
|
3120
|
+
E.toast(err.message || 'That page could not be created.', {type: 'error'});
|
|
3121
|
+
}
|
|
3122
|
+
}
|
|
3123
|
+
|
|
3124
|
+
/**
|
|
3125
|
+
* File a message as a collection entry.
|
|
3126
|
+
*
|
|
3127
|
+
* The field mapping is shown before anything is written. A route that
|
|
3128
|
+
* mapped fields by itself would write into whichever field happened
|
|
3129
|
+
* to be called `name`.
|
|
3130
|
+
*
|
|
3131
|
+
* @param {object} message
|
|
3132
|
+
* @returns {Promise<void>}
|
|
3133
|
+
*/
|
|
3134
|
+
async function messageToEntry(message) {
|
|
3135
|
+
let available;
|
|
3136
|
+
try {
|
|
3137
|
+
available = await api(`${BASE}/collections`);
|
|
3138
|
+
} catch (err) {
|
|
3139
|
+
E.toast(err.message || 'The collections could not be listed.', {type: 'error'});
|
|
3140
|
+
return;
|
|
3141
|
+
}
|
|
3142
|
+
|
|
3143
|
+
// A collection whose required fields a message cannot fill is not
|
|
3144
|
+
// usable, however good the rest of the mapping looks - choosing
|
|
3145
|
+
// it would produce "Slug is required", which is true and
|
|
3146
|
+
// unfixable from here.
|
|
3147
|
+
const all = (available.collections ?? []).filter(c => Object.keys(c.mapping).length);
|
|
3148
|
+
const usable = all.filter(c => !(c.unfillable ?? []).length);
|
|
3149
|
+
|
|
3150
|
+
if (!usable.length) {
|
|
3151
|
+
const blocked = all.find(c => (c.unfillable ?? []).length);
|
|
3152
|
+
E.toast(
|
|
3153
|
+
blocked
|
|
3154
|
+
? `No collection can take this message. ${blocked.title} needs ${blocked.unfillable.join(' and ')}, which a message cannot supply.`
|
|
3155
|
+
: 'No collection has fields a message could be filed into.',
|
|
3156
|
+
{type: 'warning'}
|
|
3157
|
+
);
|
|
3158
|
+
return;
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3161
|
+
const modal = E.modal({title: 'File as a collection entry', size: 'sm'});
|
|
3162
|
+
const panel = document.createElement('div');
|
|
3163
|
+
panel.className = 'mail-dialog-stack';
|
|
3164
|
+
|
|
3165
|
+
const label = document.createElement('label');
|
|
3166
|
+
label.className = 'form-label';
|
|
3167
|
+
label.textContent = 'Collection';
|
|
3168
|
+
const picker = document.createElement('select');
|
|
3169
|
+
picker.className = 'form-input';
|
|
3170
|
+
for (const collection of usable) {
|
|
3171
|
+
const option = document.createElement('option');
|
|
3172
|
+
option.value = collection.slug;
|
|
3173
|
+
option.textContent = collection.title;
|
|
3174
|
+
picker.appendChild(option);
|
|
3175
|
+
}
|
|
3176
|
+
panel.appendChild(label);
|
|
3177
|
+
panel.appendChild(picker);
|
|
3178
|
+
|
|
3179
|
+
const preview = document.createElement('div');
|
|
3180
|
+
preview.className = 'text-sm text-muted';
|
|
3181
|
+
|
|
3182
|
+
/**
|
|
3183
|
+
* Say what would go where, before it goes there.
|
|
3184
|
+
*
|
|
3185
|
+
* @returns {void}
|
|
3186
|
+
*/
|
|
3187
|
+
const paint = () => {
|
|
3188
|
+
const chosen = usable.find(c => c.slug === picker.value) ?? usable[0];
|
|
3189
|
+
const names = {
|
|
3190
|
+
subject: 'the subject', body: 'the message text',
|
|
3191
|
+
fromName: "the sender's name", fromEmail: "the sender's address",
|
|
3192
|
+
date: 'the date it was sent'
|
|
3193
|
+
};
|
|
3194
|
+
preview.textContent = Object.entries(chosen.mapping)
|
|
3195
|
+
.map(([field, property]) => `${field} \u2190 ${names[property] ?? property}`)
|
|
3196
|
+
.join(', ');
|
|
3197
|
+
};
|
|
3198
|
+
picker.addEventListener('change', paint);
|
|
3199
|
+
paint();
|
|
3200
|
+
panel.appendChild(preview);
|
|
3201
|
+
|
|
3202
|
+
const row = document.createElement('div');
|
|
3203
|
+
row.className = 'flex justify-end mt-2';
|
|
3204
|
+
const confirm = document.createElement('button');
|
|
3205
|
+
confirm.className = 'btn btn-sm btn-primary';
|
|
3206
|
+
confirm.textContent = 'File it';
|
|
3207
|
+
confirm.addEventListener('click', async () => {
|
|
3208
|
+
confirm.disabled = true;
|
|
3209
|
+
const chosen = usable.find(c => c.slug === picker.value) ?? usable[0];
|
|
3210
|
+
try {
|
|
3211
|
+
const params = new URLSearchParams({
|
|
3212
|
+
account: currentAccountId ?? '',
|
|
3213
|
+
folder: message.folder ?? openFolder ?? currentFolder
|
|
3214
|
+
});
|
|
3215
|
+
await api(`${BASE}/messages/${message.uid}/to-entry?${params}`, 'POST', {
|
|
3216
|
+
collection: chosen.slug,
|
|
3217
|
+
mapping: chosen.mapping
|
|
3218
|
+
});
|
|
3219
|
+
modal.close();
|
|
3220
|
+
E.toast(`Filed into ${chosen.title}.`, {type: 'success'});
|
|
3221
|
+
} catch (err) {
|
|
3222
|
+
E.toast(err.message || 'That entry could not be created.', {type: 'error'});
|
|
3223
|
+
confirm.disabled = false;
|
|
3224
|
+
}
|
|
3225
|
+
});
|
|
3226
|
+
row.appendChild(confirm);
|
|
3227
|
+
panel.appendChild(row);
|
|
3228
|
+
|
|
3229
|
+
modal.element.appendChild(panel);
|
|
3230
|
+
modal.open();
|
|
3231
|
+
}
|
|
3232
|
+
|
|
3233
|
+
/**
|
|
3234
|
+
* Put a message's sender in the address book.
|
|
3235
|
+
*
|
|
3236
|
+
* @param {object} message
|
|
3237
|
+
* @returns {Promise<void>}
|
|
3238
|
+
*/
|
|
3239
|
+
async function addSender(message) {
|
|
3240
|
+
const sender = (message?.from ?? [])[0];
|
|
3241
|
+
if (!sender?.address) {
|
|
3242
|
+
E.toast('That message has no sender address to save.', {type: 'error'});
|
|
3243
|
+
return;
|
|
3244
|
+
}
|
|
3245
|
+
|
|
3246
|
+
try {
|
|
3247
|
+
await addContact(api, {name: sender.name, email: sender.address});
|
|
3248
|
+
await refreshContacts();
|
|
3249
|
+
E.toast(`${sender.name || sender.address} added to Contacts.`, {type: 'success'});
|
|
3250
|
+
} catch (err) {
|
|
3251
|
+
E.toast(err.message || 'That contact could not be added.', {type: 'error'});
|
|
3252
|
+
}
|
|
3253
|
+
}
|
|
3254
|
+
|
|
3255
|
+
/**
|
|
3256
|
+
* The summary line above a message, and the full headers behind it.
|
|
3257
|
+
*
|
|
3258
|
+
* One line by default - who it is from and when - because that is what
|
|
3259
|
+
* is wanted nine times in ten and the rest is clutter above the thing
|
|
3260
|
+
* being read. The toggle opens Reply-To, the full recipient lists and
|
|
3261
|
+
* the message id, which are the headers people actually go looking for
|
|
3262
|
+
* when something is odd about a message.
|
|
3263
|
+
*
|
|
3264
|
+
* The open/closed choice is remembered: someone who wants headers
|
|
3265
|
+
* tends to want them for more than one message.
|
|
3266
|
+
*
|
|
3267
|
+
* @param {object} message
|
|
3268
|
+
* @returns {HTMLElement}
|
|
3269
|
+
*/
|
|
3270
|
+
function buildHeaderBlock(message) {
|
|
3271
|
+
const wrap = document.createElement('div');
|
|
3272
|
+
wrap.className = 'mail-reader-meta';
|
|
3273
|
+
|
|
3274
|
+
const summary = document.createElement('div');
|
|
3275
|
+
summary.className = 'mail-reader-summary';
|
|
3276
|
+
|
|
3277
|
+
const text = document.createElement('span');
|
|
3278
|
+
const from = formatAddresses(message.from);
|
|
3279
|
+
text.textContent = [
|
|
3280
|
+
from && `From ${from}`,
|
|
3281
|
+
formatAddresses(message.to) && `to ${formatAddresses(message.to)}`,
|
|
3282
|
+
message.date && D(message.date).format('DD MMM YYYY HH:mm')
|
|
3283
|
+
].filter(Boolean).join(' \u00b7 ');
|
|
3284
|
+
summary.appendChild(text);
|
|
3285
|
+
|
|
3286
|
+
const toggle = document.createElement('button');
|
|
3287
|
+
toggle.className = 'btn btn-sm btn-ghost mail-header-toggle';
|
|
3288
|
+
toggle.type = 'button';
|
|
3289
|
+
summary.appendChild(toggle);
|
|
3290
|
+
wrap.appendChild(summary);
|
|
3291
|
+
|
|
3292
|
+
const details = document.createElement('dl');
|
|
3293
|
+
details.className = 'mail-header-details';
|
|
3294
|
+
|
|
3295
|
+
const rows = [
|
|
3296
|
+
['From', formatAddresses(message.from)],
|
|
3297
|
+
['Reply to', formatAddresses(message.replyTo)],
|
|
3298
|
+
['To', formatAddresses(message.to)],
|
|
3299
|
+
['Cc', formatAddresses(message.cc)],
|
|
3300
|
+
// Only ever present on your own copy in Sent; a received
|
|
3301
|
+
// message carries none, which is the whole point of it.
|
|
3302
|
+
['Bcc', formatAddresses(message.bcc)],
|
|
3303
|
+
// Omitted when normal, like every other absent header: saying
|
|
3304
|
+
// "Priority: Normal" is saying nothing at length.
|
|
3305
|
+
['Priority', message.priority === 'high' ? 'High'
|
|
3306
|
+
: (message.priority === 'low' ? 'Low' : '')],
|
|
3307
|
+
['Date', message.date ? D(message.date).format('DD MMM YYYY HH:mm:ss') : ''],
|
|
3308
|
+
['Subject', message.subject],
|
|
3309
|
+
['Message ID', message.messageId]
|
|
3310
|
+
];
|
|
3311
|
+
for (const [label, value] of rows) {
|
|
3312
|
+
// An absent header is left out rather than shown empty: a row
|
|
3313
|
+
// reading "Cc:" with nothing after it says something untrue.
|
|
3314
|
+
if (!value) continue;
|
|
3315
|
+
const term = document.createElement('dt');
|
|
3316
|
+
term.textContent = label;
|
|
3317
|
+
const definition = document.createElement('dd');
|
|
3318
|
+
definition.textContent = value;
|
|
3319
|
+
details.appendChild(term);
|
|
3320
|
+
details.appendChild(definition);
|
|
3321
|
+
}
|
|
3322
|
+
wrap.appendChild(details);
|
|
3323
|
+
|
|
3324
|
+
const paint = () => {
|
|
3325
|
+
details.hidden = !headersOpen;
|
|
3326
|
+
toggle.textContent = headersOpen ? 'Hide headers' : 'Show headers';
|
|
3327
|
+
toggle.setAttribute('aria-expanded', String(headersOpen));
|
|
3328
|
+
};
|
|
3329
|
+
toggle.addEventListener('click', () => {
|
|
3330
|
+
headersOpen = !headersOpen;
|
|
3331
|
+
try {
|
|
3332
|
+
S.set(HEADERS_KEY, headersOpen);
|
|
3333
|
+
} catch { /* the choice still holds for this session */ }
|
|
3334
|
+
paint();
|
|
3335
|
+
});
|
|
3336
|
+
paint();
|
|
3337
|
+
|
|
3338
|
+
return wrap;
|
|
3339
|
+
}
|
|
3340
|
+
|
|
3341
|
+
/**
|
|
3342
|
+
* What is currently being dragged, if anything.
|
|
3343
|
+
*
|
|
3344
|
+
* Kept here rather than read from the dataTransfer, because a
|
|
3345
|
+
* `dragover` handler is not allowed to read it - only `drop` is - and
|
|
3346
|
+
* deciding whether a folder is a legal target is exactly the thing
|
|
3347
|
+
* that has to happen during the drag. Cleared on dragend so a
|
|
3348
|
+
* cancelled drag leaves nothing behind.
|
|
3349
|
+
*/
|
|
3350
|
+
let dragState = null;
|
|
3351
|
+
|
|
3352
|
+
/**
|
|
3353
|
+
* Let a message row be dragged to a folder.
|
|
3354
|
+
*
|
|
3355
|
+
* Dragging one of several ticked rows moves all of them: the selection
|
|
3356
|
+
* is what someone has said they are working with, and picking one of
|
|
3357
|
+
* them up to move it alone would be a surprise. Dragging an unticked
|
|
3358
|
+
* row moves only that row and leaves the selection as it was.
|
|
3359
|
+
*
|
|
3360
|
+
* @param {HTMLElement} row
|
|
3361
|
+
* @param {object} message
|
|
3362
|
+
* @returns {void}
|
|
3363
|
+
*/
|
|
3364
|
+
function makeDraggable(row, message) {
|
|
3365
|
+
row.draggable = true;
|
|
3366
|
+
|
|
3367
|
+
row.addEventListener('dragstart', event => {
|
|
3368
|
+
const uid = message.uid;
|
|
3369
|
+
const uids = selected.has(uid) ? [...selected] : [uid];
|
|
3370
|
+
dragState = {uids, from: message.folder ?? currentFolder};
|
|
3371
|
+
|
|
3372
|
+
// Written to the transfer as well so the cursor shows a move
|
|
3373
|
+
// rather than a forbidden sign; the payload itself is read
|
|
3374
|
+
// from dragState.
|
|
3375
|
+
event.dataTransfer.effectAllowed = 'move';
|
|
3376
|
+
try {
|
|
3377
|
+
event.dataTransfer.setData('text/plain',
|
|
3378
|
+
uids.length === 1 ? `Message ${uid}` : `${uids.length} messages`);
|
|
3379
|
+
} catch { /* older browsers are fussy about the type */ }
|
|
3380
|
+
|
|
3381
|
+
row.classList.add('is-dragging');
|
|
3382
|
+
});
|
|
3383
|
+
|
|
3384
|
+
row.addEventListener('dragend', () => {
|
|
3385
|
+
row.classList.remove('is-dragging');
|
|
3386
|
+
dragState = null;
|
|
3387
|
+
for (const el of foldersEl?.querySelectorAll('.is-drop-target') ?? []) {
|
|
3388
|
+
el.classList.remove('is-drop-target');
|
|
3389
|
+
}
|
|
3390
|
+
});
|
|
3391
|
+
}
|
|
3392
|
+
|
|
3393
|
+
/**
|
|
3394
|
+
* Let a folder row accept dropped messages.
|
|
3395
|
+
*
|
|
3396
|
+
* A folder that cannot hold mail, and the folder the messages are
|
|
3397
|
+
* already in, both decline - silently, by not calling preventDefault,
|
|
3398
|
+
* which is what makes the cursor say no.
|
|
3399
|
+
*
|
|
3400
|
+
* @param {HTMLElement} btn
|
|
3401
|
+
* @param {object} folder
|
|
3402
|
+
* @returns {void}
|
|
3403
|
+
*/
|
|
3404
|
+
function makeDropTarget(btn, folder) {
|
|
3405
|
+
const accepts = () => Boolean(dragState)
|
|
3406
|
+
&& folder.selectable
|
|
3407
|
+
&& folder.path !== dragState.from;
|
|
3408
|
+
|
|
3409
|
+
btn.addEventListener('dragover', event => {
|
|
3410
|
+
if (!accepts()) return;
|
|
3411
|
+
// Only a handler that cancels the event marks this a valid
|
|
3412
|
+
// drop; without it the browser refuses and no drop fires.
|
|
3413
|
+
event.preventDefault();
|
|
3414
|
+
event.dataTransfer.dropEffect = 'move';
|
|
3415
|
+
btn.classList.add('is-drop-target');
|
|
3416
|
+
});
|
|
3417
|
+
|
|
3418
|
+
btn.addEventListener('dragleave', () => btn.classList.remove('is-drop-target'));
|
|
3419
|
+
|
|
3420
|
+
btn.addEventListener('drop', async event => {
|
|
3421
|
+
btn.classList.remove('is-drop-target');
|
|
3422
|
+
if (!accepts()) return;
|
|
3423
|
+
event.preventDefault();
|
|
3424
|
+
|
|
3425
|
+
const {uids, from} = dragState;
|
|
3426
|
+
dragState = null;
|
|
3427
|
+
await dropInto(uids, from, folder.path);
|
|
3428
|
+
});
|
|
3429
|
+
}
|
|
3430
|
+
|
|
3431
|
+
/**
|
|
3432
|
+
* Move dropped messages, with one undo for the lot.
|
|
3433
|
+
*
|
|
3434
|
+
* @param {number[]} uids
|
|
3435
|
+
* @param {string} from
|
|
3436
|
+
* @param {string} destination
|
|
3437
|
+
* @returns {Promise<void>}
|
|
3438
|
+
*/
|
|
3439
|
+
async function dropInto(uids, from, destination) {
|
|
3440
|
+
try {
|
|
3441
|
+
if (uids.length === 1) {
|
|
3442
|
+
const row = messages.find(m => m.uid === uids[0]);
|
|
3443
|
+
await moveTo({...(row ?? {uid: uids[0]}), folder: from}, destination);
|
|
3444
|
+
return;
|
|
3445
|
+
}
|
|
3446
|
+
|
|
3447
|
+
const params = new URLSearchParams({account: currentAccountId ?? '', folder: from});
|
|
3448
|
+
const result = await api(`${BASE}/messages/move?${params}`, 'POST',
|
|
3449
|
+
{uids, destination});
|
|
3450
|
+
|
|
3451
|
+
for (const uid of uids) removeFromList(uid);
|
|
3452
|
+
clearSelection();
|
|
3453
|
+
|
|
3454
|
+
const mapped = Object.values(result.uidMap ?? {});
|
|
3455
|
+
offerUndo(
|
|
3456
|
+
`${result.moved} moved to ${destination}.`,
|
|
3457
|
+
mapped.length
|
|
3458
|
+
? async () => {
|
|
3459
|
+
const back = new URLSearchParams({
|
|
3460
|
+
account: currentAccountId ?? '', folder: destination
|
|
3461
|
+
});
|
|
3462
|
+
await api(`${BASE}/messages/move?${back}`, 'POST',
|
|
3463
|
+
{uids: mapped, destination: from});
|
|
3464
|
+
await loadMessages();
|
|
3465
|
+
}
|
|
3466
|
+
: null
|
|
3467
|
+
);
|
|
3468
|
+
} catch (err) {
|
|
3469
|
+
E.toast(err.message || 'Those messages could not be moved.', {type: 'error'});
|
|
3470
|
+
}
|
|
3471
|
+
}
|
|
3472
|
+
|
|
3473
|
+
/**
|
|
3474
|
+
* Right-click a folder to manage it.
|
|
3475
|
+
*
|
|
3476
|
+
* Its own registration rather than items added to the message menu:
|
|
3477
|
+
* the two panes are different lists and Domma resolves a right-click
|
|
3478
|
+
* by walking outward from the target, so the nearest menu answers.
|
|
3479
|
+
*
|
|
3480
|
+
* @returns {void}
|
|
3481
|
+
*/
|
|
3482
|
+
function attachFolderMenu() {
|
|
3483
|
+
// Called as a method - it keeps its registry on `this`, and an
|
|
3484
|
+
// unbound reference throws on the first right-click.
|
|
3485
|
+
const elements = (typeof E !== 'undefined' && E?.contextMenu) ? E
|
|
3486
|
+
: (typeof Domma !== 'undefined' ? Domma?.elements : null);
|
|
3487
|
+
if (typeof elements?.contextMenu !== 'function' || !foldersEl) return;
|
|
3488
|
+
|
|
3489
|
+
folderMenu = elements.contextMenu(foldersEl, {
|
|
3490
|
+
match: '.mail-folder',
|
|
3491
|
+
inherit: false,
|
|
3492
|
+
items: [],
|
|
3493
|
+
onBeforeOpen: (ctx) => {
|
|
3494
|
+
const row = ctx?.target?.closest?.('.mail-folder');
|
|
3495
|
+
if (!row) return false;
|
|
3496
|
+
const path = row.dataset.path;
|
|
3497
|
+
const folder = folders.find(f => f.path === path);
|
|
3498
|
+
if (!folder) return false;
|
|
3499
|
+
folderMenu.setItems(folderMenuItemsFor(folder));
|
|
3500
|
+
return true;
|
|
3501
|
+
}
|
|
3502
|
+
});
|
|
3503
|
+
}
|
|
3504
|
+
|
|
3505
|
+
/**
|
|
3506
|
+
* The items for one folder.
|
|
3507
|
+
*
|
|
3508
|
+
* @param {object} folder
|
|
3509
|
+
* @returns {object[]}
|
|
3510
|
+
*/
|
|
3511
|
+
function folderMenuItemsFor(folder) {
|
|
3512
|
+
// The same rules the server enforces, asked here only to decide
|
|
3513
|
+
// what to grey out. The server refuses regardless - this is the
|
|
3514
|
+
// courtesy, not the control.
|
|
3515
|
+
const protectedUse = folder.specialUse
|
|
3516
|
+
&& ['\\Inbox', '\\Sent', '\\Drafts', '\\Trash', '\\Junk', '\\Archive']
|
|
3517
|
+
.includes(folder.specialUse);
|
|
3518
|
+
const isInbox = String(folder.path).toUpperCase() === 'INBOX';
|
|
3519
|
+
const locked = protectedUse || isInbox;
|
|
3520
|
+
|
|
3521
|
+
return [
|
|
3522
|
+
{
|
|
3523
|
+
label: 'New folder',
|
|
3524
|
+
icon: 'folder-plus',
|
|
3525
|
+
action: () => createFolder(null)
|
|
3526
|
+
},
|
|
3527
|
+
{
|
|
3528
|
+
label: `New folder inside ${folder.name}`,
|
|
3529
|
+
icon: 'folder-plus',
|
|
3530
|
+
action: () => createFolder(folder.path)
|
|
3531
|
+
},
|
|
3532
|
+
{type: 'separator'},
|
|
3533
|
+
{
|
|
3534
|
+
label: folder.subscribed === false ? 'Show in the list' : 'Hide from the list',
|
|
3535
|
+
icon: folder.subscribed === false ? 'eye' : 'eye-off',
|
|
3536
|
+
// Hiding throws nothing away - the folder still exists and
|
|
3537
|
+
// still receives mail - so it is offered where deleting is
|
|
3538
|
+
// refused. The Inbox is the exception: the list you would
|
|
3539
|
+
// use to unhide it starts there.
|
|
3540
|
+
disabled: () => isInbox,
|
|
3541
|
+
action: () => setFolderVisible(folder, folder.subscribed === false)
|
|
3542
|
+
},
|
|
3543
|
+
{type: 'separator'},
|
|
3544
|
+
{
|
|
3545
|
+
label: 'Rename',
|
|
3546
|
+
icon: 'edit-3',
|
|
3547
|
+
disabled: () => locked,
|
|
3548
|
+
action: () => renameFolder(folder)
|
|
3549
|
+
},
|
|
3550
|
+
{
|
|
3551
|
+
label: 'Delete',
|
|
3552
|
+
icon: 'trash',
|
|
3553
|
+
danger: true,
|
|
3554
|
+
disabled: () => locked,
|
|
3555
|
+
action: () => deleteFolder(folder)
|
|
3556
|
+
}
|
|
3557
|
+
];
|
|
3558
|
+
}
|
|
3559
|
+
|
|
3560
|
+
/**
|
|
3561
|
+
* Create a folder, optionally inside another.
|
|
3562
|
+
*
|
|
3563
|
+
* @param {string|null} parent
|
|
3564
|
+
* @returns {Promise<void>}
|
|
3565
|
+
*/
|
|
3566
|
+
async function createFolder(parent, {pickParent = false} = {}) {
|
|
3567
|
+
// From the pane-head button there is no folder under the cursor to
|
|
3568
|
+
// infer a parent from, so the parent has to be part of the ask -
|
|
3569
|
+
// otherwise every folder made that way lands at the top level and
|
|
3570
|
+
// has to be moved afterwards.
|
|
3571
|
+
const chosen = pickParent ? await askForFolder(parent) : {parent, name: null};
|
|
3572
|
+
if (!chosen) return;
|
|
3573
|
+
|
|
3574
|
+
const name = chosen.name ?? await E.prompt(
|
|
3575
|
+
chosen.parent
|
|
3576
|
+
? `Name for the new folder inside ${chosen.parent}`
|
|
3577
|
+
: 'Name for the new folder'
|
|
3578
|
+
);
|
|
3579
|
+
if (!name || !String(name).trim()) return;
|
|
3580
|
+
parent = chosen.parent;
|
|
3581
|
+
|
|
3582
|
+
try {
|
|
3583
|
+
const params = new URLSearchParams({account: currentAccountId ?? ''});
|
|
3584
|
+
const result = await api(`${BASE}/folders?${params}`, 'POST',
|
|
3585
|
+
{name: String(name).trim(), parent: parent ?? ''});
|
|
3586
|
+
E.toast(`Created ${result.path}.`, {type: 'success'});
|
|
3587
|
+
await loadFolders();
|
|
3588
|
+
} catch (err) {
|
|
3589
|
+
E.toast(err.message || 'That folder could not be created.', {type: 'error'});
|
|
3590
|
+
}
|
|
3591
|
+
}
|
|
3592
|
+
|
|
3593
|
+
/**
|
|
3594
|
+
* Ask for a name and where to put it.
|
|
3595
|
+
*
|
|
3596
|
+
* A modal rather than two prompts: "which folder" is a choice from a
|
|
3597
|
+
* list of a hundred, not something to type, and asking for the name
|
|
3598
|
+
* first then the parent makes the second question feel like a
|
|
3599
|
+
* correction.
|
|
3600
|
+
*
|
|
3601
|
+
* Built as DOM and appended to `modal.element` - `setContent()` strips
|
|
3602
|
+
* interactive elements, so a form passed through it arrives with no
|
|
3603
|
+
* inputs at all.
|
|
3604
|
+
*
|
|
3605
|
+
* @param {string|null} preselect
|
|
3606
|
+
* @returns {Promise<{parent: string, name: string}|null>}
|
|
3607
|
+
*/
|
|
3608
|
+
function askForFolder(preselect = null) {
|
|
3609
|
+
return new Promise(resolve => {
|
|
3610
|
+
const modal = E.modal({title: 'New folder', size: 'sm'});
|
|
3611
|
+
|
|
3612
|
+
const wrap = document.createElement('div');
|
|
3613
|
+
wrap.className = 'mail-form-stack';
|
|
3614
|
+
|
|
3615
|
+
const nameLabel = document.createElement('label');
|
|
3616
|
+
nameLabel.className = 'form-label';
|
|
3617
|
+
nameLabel.textContent = 'Name';
|
|
3618
|
+
const nameInput = document.createElement('input');
|
|
3619
|
+
nameInput.type = 'text';
|
|
3620
|
+
nameInput.className = 'form-input';
|
|
3621
|
+
nameInput.placeholder = 'Receipts';
|
|
3622
|
+
|
|
3623
|
+
const parentLabel = document.createElement('label');
|
|
3624
|
+
parentLabel.className = 'form-label';
|
|
3625
|
+
parentLabel.textContent = 'Inside';
|
|
3626
|
+
const parentSelect = document.createElement('select');
|
|
3627
|
+
parentSelect.className = 'form-input';
|
|
3628
|
+
|
|
3629
|
+
const top = document.createElement('option');
|
|
3630
|
+
top.value = '';
|
|
3631
|
+
top.textContent = '(top level)';
|
|
3632
|
+
parentSelect.appendChild(top);
|
|
3633
|
+
|
|
3634
|
+
// The same tree as the folder pane, not a flat list of full
|
|
3635
|
+
// paths. `Work.Contracts.2026` reads as a string to be parsed;
|
|
3636
|
+
// indented, it reads as a place - and with a hundred folders
|
|
3637
|
+
// the flat list sorts siblings pages apart.
|
|
3638
|
+
//
|
|
3639
|
+
// Indented with non-breaking spaces: a browser collapses runs
|
|
3640
|
+
// of ordinary spaces in an <option>, so the hierarchy would
|
|
3641
|
+
// simply not appear.
|
|
3642
|
+
for (const node of flattenFolderTree(buildFolderTree(folders), new Set())) {
|
|
3643
|
+
const option = document.createElement('option');
|
|
3644
|
+
option.value = node.path;
|
|
3645
|
+
option.textContent = `${'\u00a0\u00a0'.repeat(node.depth)}${node.name}`;
|
|
3646
|
+
// A level the server never listed cannot hold mail. Shown
|
|
3647
|
+
// anyway, because leaving a hole makes its children look
|
|
3648
|
+
// top-level, but not choosable.
|
|
3649
|
+
if (node.selectable === false) option.disabled = true;
|
|
3650
|
+
// The full path where the indentation is ambiguous.
|
|
3651
|
+
option.title = node.path;
|
|
3652
|
+
if (node.path === preselect) option.selected = true;
|
|
3653
|
+
parentSelect.appendChild(option);
|
|
3654
|
+
}
|
|
3655
|
+
|
|
3656
|
+
const actions = document.createElement('div');
|
|
3657
|
+
actions.className = 'flex justify-end gap-2 mt-1';
|
|
3658
|
+
const cancel = document.createElement('button');
|
|
3659
|
+
cancel.className = 'btn btn-sm btn-ghost';
|
|
3660
|
+
cancel.textContent = 'Cancel';
|
|
3661
|
+
const create = document.createElement('button');
|
|
3662
|
+
create.className = 'btn btn-sm btn-primary';
|
|
3663
|
+
create.textContent = 'Create';
|
|
3664
|
+
|
|
3665
|
+
let settled = false;
|
|
3666
|
+
const finish = (value) => {
|
|
3667
|
+
if (settled) return;
|
|
3668
|
+
settled = true;
|
|
3669
|
+
resolve(value);
|
|
3670
|
+
modal.close?.();
|
|
3671
|
+
};
|
|
3672
|
+
|
|
3673
|
+
cancel.addEventListener('click', () => finish(null));
|
|
3674
|
+
create.addEventListener('click', () => {
|
|
3675
|
+
const name = nameInput.value.trim();
|
|
3676
|
+
if (!name) { nameInput.focus(); return; }
|
|
3677
|
+
finish({parent: parentSelect.value, name});
|
|
3678
|
+
});
|
|
3679
|
+
nameInput.addEventListener('keydown', event => {
|
|
3680
|
+
if (event.key === 'Enter') create.click();
|
|
3681
|
+
});
|
|
3682
|
+
// Closing by the X or the backdrop is a cancel, not a hang.
|
|
3683
|
+
modal.on?.('close', () => finish(null));
|
|
3684
|
+
|
|
3685
|
+
actions.appendChild(cancel);
|
|
3686
|
+
actions.appendChild(create);
|
|
3687
|
+
for (const el of [nameLabel, nameInput, parentLabel, parentSelect, actions]) {
|
|
3688
|
+
wrap.appendChild(el);
|
|
3689
|
+
}
|
|
3690
|
+
modal.element.appendChild(wrap);
|
|
3691
|
+
modal.open?.();
|
|
3692
|
+
setTimeout(() => nameInput.focus(), 60);
|
|
3693
|
+
});
|
|
3694
|
+
}
|
|
3695
|
+
|
|
3696
|
+
/**
|
|
3697
|
+
* Show or hide a folder, by IMAP subscription.
|
|
3698
|
+
*
|
|
3699
|
+
* Hidden folders are still LISTED here, dimmed - hiding them outright
|
|
3700
|
+
* would leave no way to bring one back, which is the trap this whole
|
|
3701
|
+
* feature exists to avoid.
|
|
3702
|
+
*
|
|
3703
|
+
* @param {object} folder
|
|
3704
|
+
* @param {boolean} visible
|
|
3705
|
+
* @returns {Promise<void>}
|
|
3706
|
+
*/
|
|
3707
|
+
async function setFolderVisible(folder, visible) {
|
|
3708
|
+
try {
|
|
3709
|
+
const params = new URLSearchParams({account: currentAccountId ?? ''});
|
|
3710
|
+
await api(`${BASE}/folders/subscription?${params}`, 'PUT',
|
|
3711
|
+
{path: folder.path, subscribed: visible});
|
|
3712
|
+
E.toast(
|
|
3713
|
+
visible ? `${folder.name} will show in the list.` : `${folder.name} is hidden.`,
|
|
3714
|
+
{type: 'success'}
|
|
3715
|
+
);
|
|
3716
|
+
await loadFolders();
|
|
3717
|
+
} catch (err) {
|
|
3718
|
+
E.toast(err.message || 'That could not be changed.', {type: 'error'});
|
|
3719
|
+
}
|
|
3720
|
+
}
|
|
3721
|
+
|
|
3722
|
+
/**
|
|
3723
|
+
* Rename a folder, leaving it where it is.
|
|
3724
|
+
*
|
|
3725
|
+
* @param {object} folder
|
|
3726
|
+
* @returns {Promise<void>}
|
|
3727
|
+
*/
|
|
3728
|
+
async function renameFolder(folder) {
|
|
3729
|
+
const name = await E.prompt(`Rename "${folder.name}" to`, {value: folder.name});
|
|
3730
|
+
if (!name || String(name).trim() === folder.name) return;
|
|
3731
|
+
|
|
3732
|
+
try {
|
|
3733
|
+
const params = new URLSearchParams({account: currentAccountId ?? ''});
|
|
3734
|
+
const result = await api(`${BASE}/folders?${params}`, 'PUT',
|
|
3735
|
+
{path: folder.path, name: String(name).trim()});
|
|
3736
|
+
// The open folder may BE the renamed one, in which case the
|
|
3737
|
+
// list behind it is pointing at a mailbox that no longer
|
|
3738
|
+
// exists under that name.
|
|
3739
|
+
if (currentFolder === folder.path) currentFolder = result.path;
|
|
3740
|
+
E.toast(`Renamed to ${result.path}.`, {type: 'success'});
|
|
3741
|
+
await loadFolders();
|
|
3742
|
+
} catch (err) {
|
|
3743
|
+
E.toast(err.message || 'That folder could not be renamed.', {type: 'error'});
|
|
3744
|
+
}
|
|
3745
|
+
}
|
|
3746
|
+
|
|
3747
|
+
/**
|
|
3748
|
+
* Delete a folder and everything inside it.
|
|
3749
|
+
*
|
|
3750
|
+
* @param {object} folder
|
|
3751
|
+
* @returns {Promise<void>}
|
|
3752
|
+
*/
|
|
3753
|
+
async function deleteFolder(folder) {
|
|
3754
|
+
// Named before agreeing, not after: someone deleting `Work` needs
|
|
3755
|
+
// to know it takes `Work.Contracts.2026` with it.
|
|
3756
|
+
const delimiter = folder.delimiter || '.';
|
|
3757
|
+
const children = folders
|
|
3758
|
+
.filter(f => f.path.startsWith(`${folder.path}${delimiter}`))
|
|
3759
|
+
.map(f => f.path);
|
|
3760
|
+
|
|
3761
|
+
const question = children.length
|
|
3762
|
+
? `Delete "${folder.path}" and the ${children.length} folder${children.length === 1 ? '' : 's'} inside it (${children.join(', ')})? Every message in them is deleted too.`
|
|
3763
|
+
: `Delete "${folder.path}"? Every message in it is deleted too.`;
|
|
3764
|
+
if (!await E.confirm(question)) return;
|
|
3765
|
+
|
|
3766
|
+
try {
|
|
3767
|
+
const params = new URLSearchParams({
|
|
3768
|
+
account: currentAccountId ?? '', path: folder.path
|
|
3769
|
+
});
|
|
3770
|
+
const result = await api(`${BASE}/folders?${params}`, 'DELETE');
|
|
3771
|
+
// Standing in a folder that no longer exists would 404 every
|
|
3772
|
+
// request the list makes.
|
|
3773
|
+
if (currentFolder === folder.path || children.includes(currentFolder)) {
|
|
3774
|
+
currentFolder = 'INBOX';
|
|
3775
|
+
}
|
|
3776
|
+
const extra = result.alsoDeleted?.length ? ` and ${result.alsoDeleted.length} inside it` : '';
|
|
3777
|
+
E.toast(`Deleted ${result.path}${extra}.`, {type: 'success'});
|
|
3778
|
+
await loadFolders();
|
|
3779
|
+
} catch (err) {
|
|
3780
|
+
E.toast(err.message || 'That folder could not be deleted.', {type: 'error'});
|
|
3781
|
+
}
|
|
3782
|
+
}
|
|
3783
|
+
|
|
3784
|
+
/**
|
|
3785
|
+
* Where this account keeps drafts, by SPECIAL-USE.
|
|
3786
|
+
*
|
|
3787
|
+
* @returns {string|null}
|
|
3788
|
+
*/
|
|
3789
|
+
function draftsFolder() {
|
|
3790
|
+
return folders.find(f => f.specialUse === '\\Drafts')?.path ?? null;
|
|
3791
|
+
}
|
|
3792
|
+
|
|
3793
|
+
/**
|
|
3794
|
+
* Is this message an unfinished one of ours?
|
|
3795
|
+
*
|
|
3796
|
+
* The `\\Draft` flag first, because that is what the flag is for and
|
|
3797
|
+
* it survives a message being filed somewhere other than Drafts. The
|
|
3798
|
+
* folder as a fallback, for servers that do not set it.
|
|
3799
|
+
*
|
|
3800
|
+
* @param {object} message
|
|
3801
|
+
* @returns {boolean}
|
|
3802
|
+
*/
|
|
3803
|
+
function isDraft(message) {
|
|
3804
|
+
if (!message) return false;
|
|
3805
|
+
const flags = message.flags$ ? message.flags$.value : (message.flags ?? []);
|
|
3806
|
+
if (flags.includes('\\Draft')) return true;
|
|
3807
|
+
const folder = message.folder ?? currentFolder;
|
|
3808
|
+
return Boolean(draftsFolder()) && folder === draftsFolder();
|
|
3809
|
+
}
|
|
3810
|
+
|
|
3811
|
+
/**
|
|
3812
|
+
* Open a saved draft back into the compose window.
|
|
3813
|
+
*
|
|
3814
|
+
* @param {object} message
|
|
3815
|
+
* @returns {Promise<void>}
|
|
3816
|
+
*/
|
|
3817
|
+
async function editDraft(message) {
|
|
3818
|
+
try {
|
|
3819
|
+
const params = new URLSearchParams({
|
|
3820
|
+
account: currentAccountId ?? '',
|
|
3821
|
+
folder: message.folder ?? openFolder ?? currentFolder,
|
|
3822
|
+
mode: 'edit'
|
|
3823
|
+
});
|
|
3824
|
+
const result = await api(`${BASE}/messages/${message.uid}/draft?${params}`);
|
|
3825
|
+
// The identities and templates still come: a reopened draft
|
|
3826
|
+
// can have a template dropped into it and can change who it
|
|
3827
|
+
// is from. What it does NOT get is a second signature -
|
|
3828
|
+
// `openCompose` reads the saved UID and treats the draft as
|
|
3829
|
+
// already written.
|
|
3830
|
+
openCompose(composeOptions(await identitySoon(), {
|
|
3831
|
+
draft: result.draft,
|
|
3832
|
+
mode: 'edit',
|
|
3833
|
+
// Saving or sending changes which UIDs are in Drafts, so
|
|
3834
|
+
// the list behind the window has to be refetched either
|
|
3835
|
+
// way - not only on send.
|
|
3836
|
+
onSaved: () => loadMessages()
|
|
3837
|
+
}));
|
|
3838
|
+
} catch (err) {
|
|
3839
|
+
E.toast(err.message || 'That draft could not be opened.', {type: 'error'});
|
|
3840
|
+
}
|
|
3841
|
+
}
|
|
3842
|
+
|
|
3843
|
+
/**
|
|
3844
|
+
* Is this message already spam?
|
|
3845
|
+
*
|
|
3846
|
+
* Two ways it can be, and only one of them is the folder: a
|
|
3847
|
+
* server-side filter may set the keyword and leave the message where
|
|
3848
|
+
* it is. Asked by the menu, the key and the selection bar, so they
|
|
3849
|
+
* cannot disagree about it.
|
|
3850
|
+
*
|
|
3851
|
+
* @param {object} message
|
|
3852
|
+
* @returns {boolean}
|
|
3853
|
+
*/
|
|
3854
|
+
function messageIsSpam(message) {
|
|
3855
|
+
if (!message) return false;
|
|
3856
|
+
const folder = message.folder ?? currentFolder;
|
|
3857
|
+
if (junkFolder() && folder === junkFolder()) return true;
|
|
3858
|
+
const flags = message.flags$ ? message.flags$.value : (message.flags ?? []);
|
|
3859
|
+
return flags.includes(JUNK_KEYWORD);
|
|
3860
|
+
}
|
|
3861
|
+
|
|
3862
|
+
/**
|
|
3863
|
+
* Put a server-confirmed flag set onto a row.
|
|
3864
|
+
*
|
|
3865
|
+
* The reactive handle where the row has one, the plain fields either
|
|
3866
|
+
* way - the list renders from both, and updating one is how a row goes
|
|
3867
|
+
* on claiming something the server stopped agreeing with.
|
|
3868
|
+
*
|
|
3869
|
+
* @param {number} uid
|
|
3870
|
+
* @param {string[]} flags
|
|
3871
|
+
* @returns {void}
|
|
3872
|
+
*/
|
|
3873
|
+
function applyFlagsToRow(uid, flags) {
|
|
3874
|
+
const row = messages.find(m => m.uid === uid);
|
|
3875
|
+
if (!row) return;
|
|
3876
|
+
row.flags = flags;
|
|
3877
|
+
row.seen = flags.includes('\\Seen');
|
|
3878
|
+
row.flagged = flags.includes('\\Flagged');
|
|
3879
|
+
if (row.flags$) row.flags$.value = flags;
|
|
3880
|
+
}
|
|
3881
|
+
|
|
3882
|
+
/**
|
|
3883
|
+
* Mark a message as spam, or as not spam.
|
|
3884
|
+
*
|
|
3885
|
+
* @param {object} message
|
|
3886
|
+
* @param {boolean} spam
|
|
3887
|
+
* @returns {Promise<void>}
|
|
3888
|
+
*/
|
|
3889
|
+
async function markSpam(message, spam) {
|
|
3890
|
+
try {
|
|
3891
|
+
const source = message.folder ?? currentFolder;
|
|
3892
|
+
const params = new URLSearchParams({
|
|
3893
|
+
account: currentAccountId ?? '',
|
|
3894
|
+
folder: source
|
|
3895
|
+
});
|
|
3896
|
+
const result = await api(`${BASE}/messages/${message.uid}/spam?${params}`, 'POST', {spam});
|
|
3897
|
+
|
|
3898
|
+
if (!result.movedTo) {
|
|
3899
|
+
// Nothing moved, so the row stays - and has to take the new
|
|
3900
|
+
// keyword with it, or the menu goes on offering the action
|
|
3901
|
+
// that was just taken.
|
|
3902
|
+
if (result.flags) applyFlagsToRow(message.uid, result.flags);
|
|
3903
|
+
E.toast(
|
|
3904
|
+
spam
|
|
3905
|
+
? 'Marked as spam. This server had no Junk folder to move it to.'
|
|
3906
|
+
: 'Marked as not spam.',
|
|
3907
|
+
{type: 'success'}
|
|
3908
|
+
);
|
|
3909
|
+
return;
|
|
3910
|
+
}
|
|
3911
|
+
|
|
3912
|
+
removeFromList(message.uid);
|
|
3913
|
+
offerUndo(
|
|
3914
|
+
spam ? `Moved to ${result.movedTo}.` : `Moved back to ${result.movedTo}.`,
|
|
3915
|
+
result.newUid
|
|
3916
|
+
? () => undoMove({uid: result.newUid, from: result.movedTo, to: source})
|
|
3917
|
+
: null
|
|
3918
|
+
);
|
|
3919
|
+
} catch (err) {
|
|
3920
|
+
E.toast(err.message || 'That message could not be marked.', {type: 'error'});
|
|
3921
|
+
}
|
|
3922
|
+
}
|
|
3923
|
+
|
|
3924
|
+
/**
|
|
3925
|
+
* Delete a message, saying afterwards what actually happened - moved
|
|
3926
|
+
* to Trash, or expunged because there was nowhere to move it.
|
|
3927
|
+
*
|
|
3928
|
+
* @param {object} message
|
|
3929
|
+
* @returns {Promise<void>}
|
|
3930
|
+
*/
|
|
3931
|
+
async function deleteMessage(message) {
|
|
3932
|
+
try {
|
|
3933
|
+
const params = new URLSearchParams({
|
|
3934
|
+
account: currentAccountId ?? '',
|
|
3935
|
+
folder: message.folder ?? currentFolder
|
|
3936
|
+
});
|
|
3937
|
+
const source = message.folder ?? currentFolder;
|
|
3938
|
+
const result = await api(`${BASE}/messages/${message.uid}?${params}`, 'DELETE');
|
|
3939
|
+
removeFromList(message.uid);
|
|
3940
|
+
|
|
3941
|
+
if (result.expunged) {
|
|
3942
|
+
// Nothing to offer: there was no Trash to move it to, so it
|
|
3943
|
+
// is gone from the server and saying otherwise would lie.
|
|
3944
|
+
offerUndo('Deleted from the server. This cannot be undone.', null);
|
|
3945
|
+
} else {
|
|
3946
|
+
offerUndo(
|
|
3947
|
+
`Moved to ${result.movedTo}.`,
|
|
3948
|
+
result.newUid
|
|
3949
|
+
? () => undoMove({uid: result.newUid, from: result.movedTo, to: source})
|
|
3950
|
+
: null
|
|
3951
|
+
);
|
|
3952
|
+
}
|
|
3953
|
+
} catch (err) {
|
|
3954
|
+
E.toast(err.message || 'That message could not be deleted.', {type: 'error'});
|
|
3955
|
+
}
|
|
3956
|
+
}
|
|
3957
|
+
|
|
3958
|
+
/**
|
|
3959
|
+
* Offer to put something back.
|
|
3960
|
+
*
|
|
3961
|
+
* A strip rather than a toast, because Domma's toast carries a message
|
|
3962
|
+
* and nothing to click. It hides itself after a while: an undo that
|
|
3963
|
+
* lingers invites someone to press it long after the context is gone.
|
|
3964
|
+
*
|
|
3965
|
+
* @param {string} text
|
|
3966
|
+
* @param {(() => Promise<void>)|null} undo - null when there is no way back
|
|
3967
|
+
* @param {{ms?: number}} [options] - how long it stays, when the
|
|
3968
|
+
* default is wrong. A recall has to outlast the hold it is
|
|
3969
|
+
* offering to cancel: a mailbox set to a thirty second hold
|
|
3970
|
+
* would otherwise lose its Undo button eighteen seconds
|
|
3971
|
+
* before the message actually went.
|
|
3972
|
+
* @returns {void}
|
|
3973
|
+
*/
|
|
3974
|
+
function offerUndo(text, undo, {ms = UNDO_WINDOW_MS} = {}) {
|
|
3975
|
+
if (!undoEl) return;
|
|
3976
|
+
clearTimeout(undoTimer);
|
|
3977
|
+
undoEl.textContent = '';
|
|
3978
|
+
undoEl.hidden = false;
|
|
3979
|
+
|
|
3980
|
+
const label = document.createElement('span');
|
|
3981
|
+
label.textContent = text;
|
|
3982
|
+
undoEl.appendChild(label);
|
|
3983
|
+
|
|
3984
|
+
if (undo) {
|
|
3985
|
+
const button = document.createElement('button');
|
|
3986
|
+
button.className = 'btn btn-sm btn-ghost';
|
|
3987
|
+
button.textContent = 'Undo';
|
|
3988
|
+
button.addEventListener('click', async () => {
|
|
3989
|
+
button.disabled = true;
|
|
3990
|
+
try {
|
|
3991
|
+
await undo();
|
|
3992
|
+
undoEl.hidden = true;
|
|
3993
|
+
} catch (err) {
|
|
3994
|
+
E.toast(err.message || 'That could not be undone.', {type: 'error'});
|
|
3995
|
+
button.disabled = false;
|
|
3996
|
+
}
|
|
3997
|
+
});
|
|
3998
|
+
undoEl.appendChild(button);
|
|
3999
|
+
}
|
|
4000
|
+
|
|
4001
|
+
const dismiss = document.createElement('button');
|
|
4002
|
+
dismiss.className = 'btn btn-sm btn-ghost';
|
|
4003
|
+
dismiss.textContent = 'Dismiss';
|
|
4004
|
+
dismiss.addEventListener('click', () => { undoEl.hidden = true; });
|
|
4005
|
+
undoEl.appendChild(dismiss);
|
|
4006
|
+
|
|
4007
|
+
undoTimer = setTimeout(() => { undoEl.hidden = true; }, ms);
|
|
4008
|
+
}
|
|
4009
|
+
|
|
4010
|
+
/**
|
|
4011
|
+
* Move a message back where it came from.
|
|
4012
|
+
*
|
|
4013
|
+
* @param {{uid: number, from: string, to: string}} details
|
|
4014
|
+
* @returns {Promise<void>}
|
|
4015
|
+
*/
|
|
4016
|
+
async function undoMove({uid, from, to}) {
|
|
4017
|
+
const params = new URLSearchParams({account: currentAccountId ?? '', folder: from});
|
|
4018
|
+
await api(`${BASE}/messages/${uid}/move?${params}`, 'POST', {destination: to});
|
|
4019
|
+
await loadMessages();
|
|
4020
|
+
}
|
|
4021
|
+
|
|
4022
|
+
/**
|
|
4023
|
+
* Drop a message from the list without a round trip.
|
|
4024
|
+
*
|
|
4025
|
+
* @param {number} uid
|
|
4026
|
+
* @returns {void}
|
|
4027
|
+
*/
|
|
4028
|
+
function removeFromList(uid) {
|
|
4029
|
+
const index = messages.findIndex(m => m.uid === uid);
|
|
4030
|
+
// A message that leaves while unread takes its contribution to the
|
|
4031
|
+
// badge with it.
|
|
4032
|
+
if (index !== -1 && messages[index].seen === false) {
|
|
4033
|
+
adjustUnread(messages[index].folder ?? currentFolder, -1);
|
|
4034
|
+
}
|
|
4035
|
+
if (index !== -1) messages.splice(index, 1);
|
|
4036
|
+
total = Math.max(0, total - 1);
|
|
4037
|
+
if (selectedUid$.value === uid) {
|
|
4038
|
+
selectedUid$.value = null;
|
|
4039
|
+
clearReader();
|
|
4040
|
+
}
|
|
4041
|
+
renderList();
|
|
4042
|
+
renderPager();
|
|
4043
|
+
}
|
|
4044
|
+
|
|
4045
|
+
/**
|
|
4046
|
+
* Drop a pending "mark as read".
|
|
4047
|
+
*
|
|
4048
|
+
* @returns {void}
|
|
4049
|
+
*/
|
|
4050
|
+
function cancelMarkRead() {
|
|
4051
|
+
if (!markReadTimer) return;
|
|
4052
|
+
clearTimeout(markReadTimer);
|
|
4053
|
+
markReadTimer = null;
|
|
4054
|
+
}
|
|
4055
|
+
|
|
4056
|
+
/**
|
|
4057
|
+
* Open a reply, reply-all or forward.
|
|
4058
|
+
*
|
|
4059
|
+
* The draft is built on the server, which has the message text to
|
|
4060
|
+
* quote - the browser only ever sees it as sanitised HTML inside a
|
|
4061
|
+
* sandboxed frame it cannot read.
|
|
4062
|
+
*
|
|
4063
|
+
* @param {object} message
|
|
4064
|
+
* @param {'reply'|'replyAll'|'forward'} mode
|
|
4065
|
+
* @returns {Promise<void>}
|
|
4066
|
+
*/
|
|
4067
|
+
async function compose(message, mode) {
|
|
4068
|
+
try {
|
|
4069
|
+
const params = new URLSearchParams({
|
|
4070
|
+
account: currentAccountId ?? '',
|
|
4071
|
+
folder: message.folder ?? openFolder ?? currentFolder,
|
|
4072
|
+
mode
|
|
4073
|
+
});
|
|
4074
|
+
const result = await api(`${BASE}/messages/${message.uid}/draft?${params}`);
|
|
4075
|
+
openCompose(composeOptions(await identitySoon(), {
|
|
4076
|
+
draft: result.draft,
|
|
4077
|
+
mode
|
|
4078
|
+
}));
|
|
4079
|
+
} catch (err) {
|
|
4080
|
+
E.toast(err.message || 'That reply could not be started.', {type: 'error'});
|
|
4081
|
+
}
|
|
4082
|
+
}
|
|
4083
|
+
|
|
4084
|
+
/**
|
|
4085
|
+
* A small labelled button for the reader header.
|
|
4086
|
+
*
|
|
4087
|
+
* @param {string} label
|
|
4088
|
+
* @param {string} icon
|
|
4089
|
+
* @param {Function} onClick
|
|
4090
|
+
* @returns {HTMLButtonElement}
|
|
4091
|
+
*/
|
|
4092
|
+
function actionButton(label, icon, onClick, hint = null) {
|
|
4093
|
+
const button = document.createElement('button');
|
|
4094
|
+
button.className = 'btn btn-sm btn-ghost';
|
|
4095
|
+
const iconEl = document.createElement('span');
|
|
4096
|
+
iconEl.setAttribute('data-icon', icon);
|
|
4097
|
+
iconEl.setAttribute('data-icon-size', '14');
|
|
4098
|
+
button.appendChild(iconEl);
|
|
4099
|
+
button.appendChild(document.createTextNode(' ' + label));
|
|
4100
|
+
button.addEventListener('click', onClick);
|
|
4101
|
+
return button;
|
|
4102
|
+
}
|
|
4103
|
+
|
|
4104
|
+
/**
|
|
4105
|
+
* Adjust a folder's unread count without asking the server again.
|
|
4106
|
+
*
|
|
4107
|
+
* The counts arrive with the folder listing, so without this a pill
|
|
4108
|
+
* keeps its number until the next reload - and with the Unread filter
|
|
4109
|
+
* on, a folder stays listed after its last unread has been read.
|
|
4110
|
+
*
|
|
4111
|
+
* Clamped at zero: the count can be out of date, and a negative badge
|
|
4112
|
+
* would be worse than a slightly stale one.
|
|
4113
|
+
*
|
|
4114
|
+
* @param {string} path
|
|
4115
|
+
* @param {number} delta
|
|
4116
|
+
* @returns {void}
|
|
4117
|
+
*/
|
|
4118
|
+
function adjustUnread(path, delta) {
|
|
4119
|
+
if (!delta) return;
|
|
4120
|
+
const folder = folders.find(f => f.path === path);
|
|
4121
|
+
if (!folder || !Number.isFinite(folder.unseen)) return;
|
|
4122
|
+
folder.unseen = Math.max(0, folder.unseen + delta);
|
|
4123
|
+
renderFolders();
|
|
4124
|
+
// The sidebar's unread badge counts the mirror; ask it to look
|
|
4125
|
+
// again. Its url is this screen's plugin (#/plugins/<slug>) - the
|
|
4126
|
+
// reader is shared by more than one mail plugin.
|
|
4127
|
+
const pluginUrl = location.hash.split('/').slice(0, 3).join('/');
|
|
4128
|
+
window.dispatchEvent(new CustomEvent('dm:sidebar-count', {detail: {url: pluginUrl}}));
|
|
4129
|
+
}
|
|
4130
|
+
|
|
4131
|
+
/**
|
|
4132
|
+
* Change flags on a message and reflect it in the list.
|
|
4133
|
+
*
|
|
4134
|
+
* The server is asked first and its answer is what the list is updated
|
|
4135
|
+
* from - applying it optimistically would mean a failed write left the
|
|
4136
|
+
* list claiming something untrue until the next sync quietly undid it.
|
|
4137
|
+
*
|
|
4138
|
+
* @param {object} message
|
|
4139
|
+
* @param {{add?: string[], remove?: string[]}} change
|
|
4140
|
+
* @returns {Promise<void>}
|
|
4141
|
+
*/
|
|
4142
|
+
async function changeFlags(message, change) {
|
|
4143
|
+
try {
|
|
4144
|
+
const params = new URLSearchParams({
|
|
4145
|
+
account: currentAccountId ?? '',
|
|
4146
|
+
folder: message.folder ?? openFolder ?? currentFolder
|
|
4147
|
+
});
|
|
4148
|
+
const result = await api(`${BASE}/messages/${message.uid}/flags?${params}`, 'POST', change);
|
|
4149
|
+
|
|
4150
|
+
const row = messages.find(m => m.uid === message.uid);
|
|
4151
|
+
if (row) {
|
|
4152
|
+
const wasSeen = row.seen === true;
|
|
4153
|
+
const nowSeen = result.flags.includes('\\Seen');
|
|
4154
|
+
|
|
4155
|
+
row.flags = result.flags;
|
|
4156
|
+
row.seen = nowSeen;
|
|
4157
|
+
row.flagged = result.flags.includes('\\Flagged');
|
|
4158
|
+
// One assignment; the row's own effect repaints it. No
|
|
4159
|
+
// list rebuild, so scroll position and focus survive.
|
|
4160
|
+
row.flags$ ? (row.flags$.value = result.flags) : null;
|
|
4161
|
+
|
|
4162
|
+
// The folder's badge is drawn from the folder listing, so
|
|
4163
|
+
// it has to be told: reading a message is exactly when the
|
|
4164
|
+
// count people are watching should change.
|
|
4165
|
+
if (wasSeen !== nowSeen) {
|
|
4166
|
+
adjustUnread(message.folder ?? openFolder ?? currentFolder, nowSeen ? -1 : 1);
|
|
4167
|
+
}
|
|
4168
|
+
}
|
|
4169
|
+
renderMessage(message);
|
|
4170
|
+
} catch (err) {
|
|
4171
|
+
E.toast(err.message || 'That change could not be saved.', {type: 'error'});
|
|
4172
|
+
}
|
|
4173
|
+
}
|
|
4174
|
+
|
|
4175
|
+
/**
|
|
4176
|
+
* Download an attachment.
|
|
4177
|
+
*
|
|
4178
|
+
* Goes through fetch rather than a plain link because the route needs
|
|
4179
|
+
* the bearer token, which an <a href> cannot carry.
|
|
4180
|
+
*
|
|
4181
|
+
* @param {number} uid
|
|
4182
|
+
* @param {object} attachment
|
|
4183
|
+
* @returns {Promise<void>}
|
|
4184
|
+
*/
|
|
4185
|
+
async function downloadAttachment(uid, attachment) {
|
|
4186
|
+
try {
|
|
4187
|
+
const params = new URLSearchParams({
|
|
4188
|
+
account: currentAccountId ?? '',
|
|
4189
|
+
folder: openFolder
|
|
4190
|
+
});
|
|
4191
|
+
// `fetch` again rather than `H`, for a different reason: `H`
|
|
4192
|
+
// always parses the response as JSON, and an attachment is
|
|
4193
|
+
// bytes.
|
|
4194
|
+
const res = await fetch(`${BASE}/messages/${uid}/attachments/${attachment.index}?${params}`, {
|
|
4195
|
+
headers: {'Authorization': 'Bearer ' + (S.get('auth_token') || '')}
|
|
4196
|
+
});
|
|
4197
|
+
if (!res.ok) throw new Error('Download failed.');
|
|
4198
|
+
|
|
4199
|
+
const blob = await res.blob();
|
|
4200
|
+
const url = URL.createObjectURL(blob);
|
|
4201
|
+
const link = document.createElement('a');
|
|
4202
|
+
link.href = url;
|
|
4203
|
+
link.download = attachment.filename;
|
|
4204
|
+
document.body.appendChild(link);
|
|
4205
|
+
link.click();
|
|
4206
|
+
link.remove();
|
|
4207
|
+
URL.revokeObjectURL(url);
|
|
4208
|
+
} catch (err) {
|
|
4209
|
+
E.toast(err.message || 'Could not download that attachment.', {type: 'error'});
|
|
4210
|
+
}
|
|
4211
|
+
}
|
|
4212
|
+
|
|
4213
|
+
// ---------------------------------------------------------------
|
|
4214
|
+
// Boot
|
|
4215
|
+
// ---------------------------------------------------------------
|
|
4216
|
+
|
|
4217
|
+
/**
|
|
4218
|
+
* Load the selected mailbox's folders and open its inbox.
|
|
4219
|
+
*
|
|
4220
|
+
* @returns {Promise<void>}
|
|
4221
|
+
*/
|
|
4222
|
+
async function loadFolders() {
|
|
4223
|
+
try {
|
|
4224
|
+
const params = new URLSearchParams({account: currentAccountId ?? '', counts: '1'});
|
|
4225
|
+
const result = await api(`${BASE}/folders?${params}`);
|
|
4226
|
+
folders = result.folders;
|
|
4227
|
+
currentAccountId = result.accountId;
|
|
4228
|
+
rememberAccount(currentAccountId);
|
|
4229
|
+
// Started now, not when Compose is clicked, so the window
|
|
4230
|
+
// opens on an answer that has already arrived. Idempotent per
|
|
4231
|
+
// mailbox and deliberately not awaited: the folder list must
|
|
4232
|
+
// not wait on it.
|
|
4233
|
+
identity();
|
|
4234
|
+
// Likewise: the badge saying two messages are waiting to send
|
|
4235
|
+
// is worth nothing if it only appears after you go looking.
|
|
4236
|
+
refreshOutbox();
|
|
4237
|
+
|
|
4238
|
+
// Prefer the server's own idea of the inbox over the name.
|
|
4239
|
+
const inbox = folders.find(f => f.specialUse === '\\Inbox')
|
|
4240
|
+
?? folders.find(f => f.path.toUpperCase() === 'INBOX')
|
|
4241
|
+
?? folders[0];
|
|
4242
|
+
currentFolder = inbox?.path ?? 'INBOX';
|
|
4243
|
+
listTitleEl.textContent = inbox
|
|
4244
|
+
? headingFor(inbox)
|
|
4245
|
+
: 'Inbox';
|
|
4246
|
+
renderFolders();
|
|
4247
|
+
} catch (err) {
|
|
4248
|
+
handleError(err);
|
|
4249
|
+
return;
|
|
4250
|
+
}
|
|
4251
|
+
|
|
4252
|
+
clearReader();
|
|
4253
|
+
await loadMessages();
|
|
4254
|
+
}
|
|
4255
|
+
|
|
4256
|
+
/**
|
|
4257
|
+
* Load the mailbox list, then the selected mailbox.
|
|
4258
|
+
*
|
|
4259
|
+
* @returns {Promise<void>}
|
|
4260
|
+
*/
|
|
4261
|
+
async function start() {
|
|
4262
|
+
try {
|
|
4263
|
+
// Only the edition that can send has this route, and the
|
|
4264
|
+
// only thing it is read for is the attachment limit - so the
|
|
4265
|
+
// reader asking for it is a 404 on every mount for nothing.
|
|
4266
|
+
if (features.send) {
|
|
4267
|
+
api(`${BASE}/status`)
|
|
4268
|
+
.then(status => {
|
|
4269
|
+
if (status?.limits) limits = {...limits, ...status.limits};
|
|
4270
|
+
})
|
|
4271
|
+
.catch(() => {/* the defaults are serviceable */});
|
|
4272
|
+
}
|
|
4273
|
+
|
|
4274
|
+
// Deliberately not awaited. Mail must open at the same speed
|
|
4275
|
+
// whether or not an optional plugin answers, or answers at
|
|
4276
|
+
// all; the menu and the compose window both read whatever has
|
|
4277
|
+
// arrived by the time they are opened.
|
|
4278
|
+
refreshContacts();
|
|
4279
|
+
|
|
4280
|
+
mailboxes = (await api(`${BASE}/accounts`)).accounts;
|
|
4281
|
+
} catch (err) {
|
|
4282
|
+
E.toast(err.message, {type: 'error'});
|
|
4283
|
+
return;
|
|
4284
|
+
}
|
|
4285
|
+
|
|
4286
|
+
if (!mailboxes.length) {
|
|
4287
|
+
currentAccountId = null;
|
|
4288
|
+
showEmpty(foldersEl, 'folder', 'No mailboxes');
|
|
4289
|
+
showEmpty(listEl, 'settings', 'No mailbox is set up yet.', {
|
|
4290
|
+
label: 'Add a mailbox',
|
|
4291
|
+
onClick: () => openMailboxForm(null)
|
|
4292
|
+
});
|
|
4293
|
+
clearReader();
|
|
4294
|
+
return;
|
|
4295
|
+
}
|
|
4296
|
+
|
|
4297
|
+
// A removed mailbox should not leave the view pointing at nothing,
|
|
4298
|
+
// and a first visit should land on the one last looked at rather
|
|
4299
|
+
// than whichever the server happened to list first.
|
|
4300
|
+
if (!mailboxes.some(b => b.id === currentAccountId)) {
|
|
4301
|
+
currentAccountId = preferredAccount();
|
|
4302
|
+
}
|
|
4303
|
+
rememberAccount(currentAccountId);
|
|
4304
|
+
await loadFolders();
|
|
4305
|
+
}
|
|
4306
|
+
|
|
4307
|
+
if (features.search && searchEl) {
|
|
4308
|
+
searchEl.hidden = false;
|
|
4309
|
+
|
|
4310
|
+
let debounce = null;
|
|
4311
|
+
searchInputEl.addEventListener('input', () => {
|
|
4312
|
+
const value = searchInputEl.value.trim();
|
|
4313
|
+
if (searchClearEl) searchClearEl.hidden = !value;
|
|
4314
|
+
clearTimeout(debounce);
|
|
4315
|
+
// A keystroke per query would mean a database round trip per
|
|
4316
|
+
// keystroke; a short pause is what makes it feel immediate
|
|
4317
|
+
// without being wasteful.
|
|
4318
|
+
debounce = setTimeout(() => {
|
|
4319
|
+
if (!value) {
|
|
4320
|
+
clearSearch();
|
|
4321
|
+
return;
|
|
4322
|
+
}
|
|
4323
|
+
searchQuery = value;
|
|
4324
|
+
page = 1;
|
|
4325
|
+
selectedUid$.value = null;
|
|
4326
|
+
clearReader();
|
|
4327
|
+
runSearch();
|
|
4328
|
+
}, 300);
|
|
4329
|
+
});
|
|
4330
|
+
|
|
4331
|
+
searchInputEl.addEventListener('keydown', event => {
|
|
4332
|
+
if (event.key === 'Escape') clearSearch();
|
|
4333
|
+
});
|
|
4334
|
+
searchClearEl?.addEventListener('click', () => clearSearch());
|
|
4335
|
+
}
|
|
4336
|
+
|
|
4337
|
+
// The badge is free - a reader that cannot show what is unread looks
|
|
4338
|
+
// broken rather than limited. Narrowing a hundred folders down to the
|
|
4339
|
+
// ones that need attention is the part worth paying for.
|
|
4340
|
+
if (features.unreadFilter && unreadFilterEl && unreadOnlyEl) {
|
|
4341
|
+
unreadFilterEl.hidden = false;
|
|
4342
|
+
unreadOnlyEl.addEventListener('change', () => {
|
|
4343
|
+
unreadOnly = unreadOnlyEl.checked;
|
|
4344
|
+
renderFolders();
|
|
4345
|
+
});
|
|
4346
|
+
}
|
|
4347
|
+
|
|
4348
|
+
attachKeyboard();
|
|
4349
|
+
|
|
4350
|
+
if (features.write) attachMessageMenu();
|
|
4351
|
+
if (features.folders) attachFolderMenu();
|
|
4352
|
+
|
|
4353
|
+
$container.find('#mail-settings-btn').on('click', () => openSettings());
|
|
4354
|
+
|
|
4355
|
+
// Settings is a screen-level control, so it belongs in the admin's
|
|
4356
|
+
// plugin banner beside the licence - the same place every other plugin
|
|
4357
|
+
// puts its cog. Imported at RUN time and survivable: an admin that
|
|
4358
|
+
// predates addBannerAction keeps the button in the Folders pane.
|
|
4359
|
+
try {
|
|
4360
|
+
const chrome = await import('/admin/js/lib/plugin-chrome.js');
|
|
4361
|
+
const placed = chrome.addBannerAction?.($container, {
|
|
4362
|
+
id: `${pluginName}-settings`,
|
|
4363
|
+
icon: 'settings',
|
|
4364
|
+
title: 'Mail account settings',
|
|
4365
|
+
onClick: () => openSettings()
|
|
4366
|
+
});
|
|
4367
|
+
if (placed) $container.find('#mail-settings-btn').get(0)?.remove();
|
|
4368
|
+
} catch { /* no banner: the pane button stays */ }
|
|
4369
|
+
$container.find('#mail-refresh-btn').on('click', () => loadMessages());
|
|
4370
|
+
|
|
4371
|
+
// Folder management was right-click only, which is the same as not
|
|
4372
|
+
// existing for anyone who does not already know it is there.
|
|
4373
|
+
if (features.folders) {
|
|
4374
|
+
const newFolder = $container.find('#mail-new-folder-btn').get(0);
|
|
4375
|
+
if (newFolder) {
|
|
4376
|
+
newFolder.hidden = false;
|
|
4377
|
+
newFolder.addEventListener('click', () => createFolder(null, {pickParent: true}));
|
|
4378
|
+
}
|
|
4379
|
+
}
|
|
4380
|
+
|
|
4381
|
+
// A real <input> here, unlike the row ticks: this one is not inside a
|
|
4382
|
+
// button, so it can be what it is.
|
|
4383
|
+
selectAllBox?.addEventListener('change', () => toggleSelectAll());
|
|
4384
|
+
|
|
4385
|
+
if (features.threads && threadsBtn) {
|
|
4386
|
+
threadsBtn.hidden = false;
|
|
4387
|
+
paintThreadsButton();
|
|
4388
|
+
threadsBtn.addEventListener('click', () => {
|
|
4389
|
+
toggleThreads().catch(err =>
|
|
4390
|
+
E.toast(err.message || 'That could not be changed.', {type: 'error'}));
|
|
4391
|
+
});
|
|
4392
|
+
}
|
|
4393
|
+
|
|
4394
|
+
if (features.send && outboxBtn) {
|
|
4395
|
+
outboxBtn.addEventListener('click', () => {
|
|
4396
|
+
showOutbox().catch(err =>
|
|
4397
|
+
E.toast(err.message || 'The outbox could not be opened.', {type: 'error'}));
|
|
4398
|
+
});
|
|
4399
|
+
}
|
|
4400
|
+
|
|
4401
|
+
if (features.send) {
|
|
4402
|
+
const compose = $container.find('#mail-compose-btn').get(0);
|
|
4403
|
+
if (compose) {
|
|
4404
|
+
compose.hidden = false;
|
|
4405
|
+
compose.addEventListener('click', async () => {
|
|
4406
|
+
try {
|
|
4407
|
+
openCompose(composeOptions(await identitySoon(), {mode: 'new'}));
|
|
4408
|
+
} catch (err) {
|
|
4409
|
+
E.toast(err.message || 'Compose could not be opened.', {type: 'error'});
|
|
4410
|
+
}
|
|
4411
|
+
});
|
|
4412
|
+
}
|
|
4413
|
+
}
|
|
4414
|
+
|
|
4415
|
+
// Fill the window and let the dividers move. Both are shared with
|
|
4416
|
+
// Email Pro, which draws the same three panes.
|
|
4417
|
+
// Nothing is measured until the stylesheet is in.
|
|
4418
|
+
await stylesheetReady;
|
|
4419
|
+
|
|
4420
|
+
// Every hint in this view is a native `title`, deliberately, and NOT
|
|
4421
|
+
// `E.tooltip`. That helper WRAPS each target in a <domma-tooltip>
|
|
4422
|
+
// element - which reparented both pane splitters and collapsed them to
|
|
4423
|
+
// zero height, so the panes silently stopped being resizable. A
|
|
4424
|
+
// prettier tooltip is not worth a working feature, and a title cannot
|
|
4425
|
+
// wrap, reparent or resize anything.
|
|
4426
|
+
|
|
4427
|
+
const layoutEl = $container.find('.mail-layout').get(0);
|
|
4428
|
+
// Lift the admin's content width cap for this view only.
|
|
4429
|
+
const containerEl = layoutEl?.closest('#view-container') ?? null;
|
|
4430
|
+
containerEl?.classList.add('mail-fullbleed');
|
|
4431
|
+
|
|
4432
|
+
if (layoutEl) {
|
|
4433
|
+
let storedWidths;
|
|
4434
|
+
try {
|
|
4435
|
+
const stored = S.get(PANES_KEY);
|
|
4436
|
+
if (Array.isArray(stored) && stored.length === 2) storedWidths = stored;
|
|
4437
|
+
} catch {
|
|
4438
|
+
// Storage unavailable - fall back to the defaults.
|
|
4439
|
+
}
|
|
4440
|
+
|
|
4441
|
+
createSplitters(layoutEl, {
|
|
4442
|
+
widths: storedWidths,
|
|
4443
|
+
onChange: next => {
|
|
4444
|
+
try {
|
|
4445
|
+
S.set(PANES_KEY, next);
|
|
4446
|
+
} catch {
|
|
4447
|
+
// The drag still applies for this visit.
|
|
4448
|
+
}
|
|
4449
|
+
}
|
|
4450
|
+
});
|
|
4451
|
+
stopFitting = fitToViewport(layoutEl);
|
|
4452
|
+
}
|
|
4453
|
+
|
|
4454
|
+
await start();
|
|
4455
|
+
Domma.icons.scan($container.get(0));
|
|
4456
|
+
|
|
4457
|
+
this.onUnmount = () => {
|
|
4458
|
+
cancelMarkRead();
|
|
4459
|
+
clearTimeout(undoTimer);
|
|
4460
|
+
if (keyboardHandler) document.removeEventListener('keydown', keyboardHandler);
|
|
4461
|
+
keyboardHandler = null;
|
|
4462
|
+
window.removeEventListener('error', captureError);
|
|
4463
|
+
window.removeEventListener('unhandledrejection', captureError);
|
|
4464
|
+
disposeRowEffects();
|
|
4465
|
+
messageMenu?.destroy?.();
|
|
4466
|
+
messageMenu = null;
|
|
4467
|
+
stopFitting?.();
|
|
4468
|
+
stopFitting = null;
|
|
4469
|
+
containerEl?.classList.remove('mail-fullbleed');
|
|
4470
|
+
};
|
|
4471
|
+
}
|
|
4472
|
+
};
|
|
4473
|
+
}
|