domma-cms 0.54.2 → 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/CLAUDE.md +151 -77
  2. package/README.md +4 -4
  3. package/admin/css/admin.css +1 -1
  4. package/admin/dist/domma/domma-tools.css +3 -3
  5. package/admin/dist/domma/domma-tools.min.js +3 -3
  6. package/admin/js/api.js +1 -1
  7. package/admin/js/app.js +3 -3
  8. package/admin/js/lib/page-picker.js +1 -0
  9. package/admin/js/lib/plugin-accent.js +1 -0
  10. package/admin/js/lib/plugin-chrome.js +1 -0
  11. package/admin/js/lib/shortcode-context-menu.js +2 -2
  12. package/admin/js/lib/sidebar-grouping.js +1 -1
  13. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  14. package/admin/js/lib/sidebar-renderer.js +4 -4
  15. package/admin/js/lib/slideover-resizable.js +1 -0
  16. package/admin/js/templates/context-menu-editor.html +212 -0
  17. package/admin/js/templates/context-menus.html +16 -0
  18. package/admin/js/templates/menu-editor.html +19 -25
  19. package/admin/js/templates/plugin-code.html +1 -1
  20. package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
  21. package/admin/js/templates/settings.html +27 -97
  22. package/admin/js/templates/theme.html +173 -0
  23. package/admin/js/views/context-menu-editor.js +55 -0
  24. package/admin/js/views/context-menus.js +5 -0
  25. package/admin/js/views/form-editor.js +7 -7
  26. package/admin/js/views/index.js +1 -1
  27. package/admin/js/views/menu-editor.js +13 -13
  28. package/admin/js/views/plugin-marketplace.js +1 -1
  29. package/admin/js/views/plugins.js +25 -23
  30. package/admin/js/views/search.js +1 -0
  31. package/admin/js/views/settings.js +3 -3
  32. package/admin/js/views/theme.js +1 -0
  33. package/bin/cli.js +11 -2
  34. package/bin/lib/smtp-defaults.js +53 -0
  35. package/bin/update.js +13 -2
  36. package/config/menus/admin-sidebar.json +129 -23
  37. package/config/plugins.json +5 -5
  38. package/config/search.json +13 -0
  39. package/config/theme.json +18 -0
  40. package/package.json +12 -4
  41. package/plugins/_lib/admin/mail/compose-window.js +914 -0
  42. package/plugins/_lib/admin/mail/contacts.js +301 -0
  43. package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
  44. package/plugins/_lib/admin/mail/folder-tree.js +254 -0
  45. package/plugins/_lib/admin/mail/identity.js +480 -0
  46. package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
  47. package/plugins/_lib/admin/mail/keyboard.js +136 -0
  48. package/plugins/_lib/admin/mail/mail.css +1 -0
  49. package/plugins/_lib/admin/mail/mail.html +71 -0
  50. package/plugins/_lib/admin/mail/panes.js +253 -0
  51. package/plugins/_lib/admin/mail/reader-view.js +4453 -0
  52. package/plugins/_lib/admin/mail/resizable.js +26 -0
  53. package/plugins/_lib/admin/mail/rules.js +343 -0
  54. package/plugins/_lib/admin/mail/scheduling.js +203 -0
  55. package/plugins/_lib/admin/mail/section-kit.js +277 -0
  56. package/plugins/_lib/admin/mail/templates.js +238 -0
  57. package/plugins/_lib/admin/mail/threads.js +200 -0
  58. package/plugins/_lib/admin/mail/vacation.js +269 -0
  59. package/plugins/_lib/admin/ui/help.css +1 -0
  60. package/plugins/_lib/admin/ui/help.js +174 -0
  61. package/plugins/_lib/admin/ui/resizable.js +151 -0
  62. package/plugins/_lib/dataStore.js +117 -0
  63. package/plugins/_lib/mail/accounts.js +919 -0
  64. package/plugins/_lib/mail/bodyTokens.js +101 -0
  65. package/plugins/_lib/mail/compose.js +256 -0
  66. package/plugins/_lib/mail/defaults.js +57 -0
  67. package/plugins/_lib/mail/diagnostics.js +154 -0
  68. package/plugins/_lib/mail/envelope.js +161 -0
  69. package/plugins/_lib/mail/folders.js +192 -0
  70. package/plugins/_lib/mail/handoff.js +274 -0
  71. package/plugins/_lib/mail/imapPool.js +293 -0
  72. package/plugins/_lib/mail/mbox.js +74 -0
  73. package/plugins/_lib/mail/pollSchedule.js +79 -0
  74. package/plugins/_lib/mail/poller.js +135 -0
  75. package/plugins/_lib/mail/priority.js +138 -0
  76. package/plugins/_lib/mail/readRoutes.js +680 -0
  77. package/plugins/_lib/mail/render.js +291 -0
  78. package/plugins/_lib/mail/ruleRunner.js +152 -0
  79. package/plugins/_lib/mail/scheduler.js +254 -0
  80. package/plugins/_lib/mail/secretbox.js +229 -0
  81. package/plugins/_lib/mail/send.js +396 -0
  82. package/plugins/_lib/mail/store.js +1002 -0
  83. package/plugins/_lib/mail/sync.js +292 -0
  84. package/plugins/_lib/mail/syncPlan.js +126 -0
  85. package/plugins/_lib/mail/syncSelection.js +82 -0
  86. package/plugins/_lib/mail/unsubscribe.js +183 -0
  87. package/plugins/_lib/mail/vacationRunner.js +114 -0
  88. package/plugins/_lib/mail/write.js +473 -0
  89. package/plugins/_lib/schemaSync.js +83 -0
  90. package/plugins/_template/admin/css/index.css +0 -0
  91. package/plugins/_template/admin/templates/index.html +4 -4
  92. package/plugins/_template/admin/views/index.js +7 -0
  93. package/plugins/analytics/admin/css/index.css +1 -0
  94. package/plugins/analytics/admin/templates/analytics.html +22 -13
  95. package/plugins/analytics/plugin.json +3 -0
  96. package/plugins/blog/admin/css/index.css +1 -0
  97. package/plugins/blog/admin/templates/blog.html +30 -18
  98. package/plugins/blog/admin/templates/categories.html +2 -2
  99. package/plugins/blog/admin/templates/comments.html +2 -2
  100. package/plugins/blog/admin/templates/post-editor.html +34 -34
  101. package/plugins/blog/admin/templates/settings.html +6 -3
  102. package/plugins/blog/admin/views/blog.js +8 -5
  103. package/plugins/blog/admin/views/categories.js +5 -10
  104. package/plugins/blog/admin/views/comments.js +5 -5
  105. package/plugins/blog/admin/views/post-editor.js +39 -20
  106. package/plugins/blog/admin/views/settings.js +52 -50
  107. package/plugins/blog/collections/categories/schema.json +7 -6
  108. package/plugins/blog/collections/comments/schema.json +11 -10
  109. package/plugins/blog/collections/posts/schema.json +14 -13
  110. package/plugins/blog/plugin.js +36 -13
  111. package/plugins/blog/plugin.json +13 -5
  112. package/plugins/blog/plugin.public.js +312 -0
  113. package/plugins/contacts/admin/css/index.css +1 -0
  114. package/plugins/contacts/admin/templates/contacts.html +128 -0
  115. package/plugins/contacts/admin/views/contacts.js +237 -4
  116. package/plugins/contacts/collections/user-contacts/schema.json +108 -0
  117. package/plugins/contacts/plugin.js +214 -27
  118. package/plugins/contacts/plugin.json +4 -1
  119. package/plugins/invoice/admin/css/index.css +1 -0
  120. package/plugins/invoice/admin/templates/editor.html +140 -49
  121. package/plugins/invoice/admin/templates/index.html +153 -23
  122. package/plugins/invoice/admin/templates/issuers.html +2 -5
  123. package/plugins/invoice/admin/templates/receivers.html +2 -5
  124. package/plugins/invoice/admin/views/contacts-source.js +266 -0
  125. package/plugins/invoice/admin/views/editor.js +366 -199
  126. package/plugins/invoice/admin/views/export.js +199 -0
  127. package/plugins/invoice/admin/views/help-content.js +61 -0
  128. package/plugins/invoice/admin/views/index.js +582 -94
  129. package/plugins/invoice/admin/views/issuers.js +24 -17
  130. package/plugins/invoice/admin/views/media.js +172 -0
  131. package/plugins/invoice/admin/views/party-view.js +305 -67
  132. package/plugins/invoice/admin/views/payments.js +127 -0
  133. package/plugins/invoice/admin/views/print.js +130 -0
  134. package/plugins/invoice/admin/views/receivers.js +49 -16
  135. package/plugins/invoice/admin/views/send.js +212 -0
  136. package/plugins/invoice/admin/views/settings.js +594 -0
  137. package/plugins/invoice/admin/views/view-lifecycle.js +33 -0
  138. package/plugins/invoice/collections/invoice-issuers/schema.json +77 -11
  139. package/plugins/invoice/collections/invoice-receivers/schema.json +10 -9
  140. package/plugins/invoice/collections/invoices/schema.json +19 -13
  141. package/plugins/invoice/config.js +27 -6
  142. package/plugins/invoice/pdf.js +164 -0
  143. package/plugins/invoice/plugin.js +1217 -44
  144. package/plugins/invoice/plugin.json +10 -9
  145. package/plugins/invoice/templates/_base.css +1 -0
  146. package/plugins/invoice/templates/classic-nologo.html +100 -0
  147. package/plugins/invoice/templates/classic.html +91 -0
  148. package/plugins/invoice/templates/invoice-print.html +24 -0
  149. package/plugins/invoice/templates/minimal.html +99 -0
  150. package/plugins/invoice/templates/modern-nologo.html +114 -0
  151. package/plugins/invoice/templates/modern.html +113 -0
  152. package/plugins/invoice/templates/templates.json +11 -0
  153. package/plugins/mail-reader/admin/views/mail.js +19 -0
  154. package/plugins/mail-reader/config.js +7 -0
  155. package/plugins/mail-reader/plugin.js +48 -0
  156. package/plugins/mail-reader/plugin.json +33 -0
  157. package/plugins/notes/admin/views/notes.js +1 -1
  158. package/plugins/notes/plugin.json +2 -2
  159. package/plugins/surveys/lib/audience.js +37 -0
  160. package/plugins/surveys/lib/campaigns.js +43 -0
  161. package/plugins/surveys/lib/ledger.js +110 -0
  162. package/plugins/surveys/lib/sending.js +106 -0
  163. package/plugins/surveys/lib/stats.js +62 -0
  164. package/plugins/surveys/lib/submit.js +95 -0
  165. package/plugins/surveys/lib/tokens.js +28 -0
  166. package/plugins/surveys/plugin.public.js +149 -0
  167. package/plugins/theme-switcher/admin/templates/theme-switcher.html +1 -1
  168. package/public/css/forms.css +1 -1
  169. package/public/css/menu-highlight.css +1 -1
  170. package/public/css/search.css +1 -0
  171. package/public/css/site.css +1 -1
  172. package/public/js/collection-context.js +2 -2
  173. package/public/js/context-menus.js +1 -0
  174. package/public/js/form-logic-engine.js +1 -1
  175. package/public/js/forms.js +2 -2
  176. package/public/js/menu-decor.mjs +1 -1
  177. package/public/js/search.js +1 -0
  178. package/public/js/site.js +1 -1
  179. package/scripts/build.js +37 -3
  180. package/scripts/copy-domma.js +48 -0
  181. package/scripts/seed.js +1996 -0
  182. package/scripts/setup.js +8 -0
  183. package/server/routes/api/collections.js +34 -0
  184. package/server/routes/api/context-menus.js +104 -0
  185. package/server/routes/api/forms.js +42 -3
  186. package/server/routes/api/notifications.js +69 -19
  187. package/server/routes/api/plugins.js +50 -6
  188. package/server/routes/api/search.js +43 -0
  189. package/server/routes/api/theme.js +69 -0
  190. package/server/routes/public.js +42 -7
  191. package/server/server.js +74 -0
  192. package/server/services/adapters/FileAdapter.js +6 -1
  193. package/server/services/content.js +26 -0
  194. package/server/services/contextMenus.js +477 -0
  195. package/server/services/email.js +29 -3
  196. package/server/services/health.js +23 -2
  197. package/server/services/markdown.js +70 -9
  198. package/server/services/menuRender.js +28 -4
  199. package/server/services/menus.js +25 -1
  200. package/server/services/permissionRegistry.js +24 -0
  201. package/server/services/pluginFiles.js +52 -11
  202. package/server/services/plugins.js +229 -6
  203. package/server/services/renderer.js +148 -22
  204. package/server/services/roles.js +1 -1
  205. package/server/services/search-migration.js +82 -0
  206. package/server/services/search.js +413 -0
  207. package/server/services/sidebar-migration.js +1 -0
  208. package/server/services/themeSettings.js +541 -0
  209. package/server/services/users.js +8 -0
  210. package/server/templates/page.html +4 -2
  211. package/plugins/contacts/data/contacts.json +0 -20
  212. package/plugins/notes/data/notes.json +0 -1
  213. package/plugins/site-search/admin/views/site-search.js +0 -116
  214. package/plugins/site-search/config.js +0 -15
  215. package/plugins/site-search/plugin.js +0 -188
  216. package/plugins/site-search/plugin.json +0 -40
  217. package/plugins/site-search/public/inject-body.html +0 -17
  218. package/plugins/site-search/public/inject-head.html +0 -1
  219. package/plugins/site-search/public/search.css +0 -1
  220. package/plugins/site-search/public/search.js +0 -1
  221. package/plugins/todo/data/todos.json +0 -1
@@ -0,0 +1,4453 @@
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
+ }
4125
+
4126
+ /**
4127
+ * Change flags on a message and reflect it in the list.
4128
+ *
4129
+ * The server is asked first and its answer is what the list is updated
4130
+ * from - applying it optimistically would mean a failed write left the
4131
+ * list claiming something untrue until the next sync quietly undid it.
4132
+ *
4133
+ * @param {object} message
4134
+ * @param {{add?: string[], remove?: string[]}} change
4135
+ * @returns {Promise<void>}
4136
+ */
4137
+ async function changeFlags(message, change) {
4138
+ try {
4139
+ const params = new URLSearchParams({
4140
+ account: currentAccountId ?? '',
4141
+ folder: message.folder ?? openFolder ?? currentFolder
4142
+ });
4143
+ const result = await api(`${BASE}/messages/${message.uid}/flags?${params}`, 'POST', change);
4144
+
4145
+ const row = messages.find(m => m.uid === message.uid);
4146
+ if (row) {
4147
+ const wasSeen = row.seen === true;
4148
+ const nowSeen = result.flags.includes('\\Seen');
4149
+
4150
+ row.flags = result.flags;
4151
+ row.seen = nowSeen;
4152
+ row.flagged = result.flags.includes('\\Flagged');
4153
+ // One assignment; the row's own effect repaints it. No
4154
+ // list rebuild, so scroll position and focus survive.
4155
+ row.flags$ ? (row.flags$.value = result.flags) : null;
4156
+
4157
+ // The folder's badge is drawn from the folder listing, so
4158
+ // it has to be told: reading a message is exactly when the
4159
+ // count people are watching should change.
4160
+ if (wasSeen !== nowSeen) {
4161
+ adjustUnread(message.folder ?? openFolder ?? currentFolder, nowSeen ? -1 : 1);
4162
+ }
4163
+ }
4164
+ renderMessage(message);
4165
+ } catch (err) {
4166
+ E.toast(err.message || 'That change could not be saved.', {type: 'error'});
4167
+ }
4168
+ }
4169
+
4170
+ /**
4171
+ * Download an attachment.
4172
+ *
4173
+ * Goes through fetch rather than a plain link because the route needs
4174
+ * the bearer token, which an <a href> cannot carry.
4175
+ *
4176
+ * @param {number} uid
4177
+ * @param {object} attachment
4178
+ * @returns {Promise<void>}
4179
+ */
4180
+ async function downloadAttachment(uid, attachment) {
4181
+ try {
4182
+ const params = new URLSearchParams({
4183
+ account: currentAccountId ?? '',
4184
+ folder: openFolder
4185
+ });
4186
+ // `fetch` again rather than `H`, for a different reason: `H`
4187
+ // always parses the response as JSON, and an attachment is
4188
+ // bytes.
4189
+ const res = await fetch(`${BASE}/messages/${uid}/attachments/${attachment.index}?${params}`, {
4190
+ headers: {'Authorization': 'Bearer ' + (S.get('auth_token') || '')}
4191
+ });
4192
+ if (!res.ok) throw new Error('Download failed.');
4193
+
4194
+ const blob = await res.blob();
4195
+ const url = URL.createObjectURL(blob);
4196
+ const link = document.createElement('a');
4197
+ link.href = url;
4198
+ link.download = attachment.filename;
4199
+ document.body.appendChild(link);
4200
+ link.click();
4201
+ link.remove();
4202
+ URL.revokeObjectURL(url);
4203
+ } catch (err) {
4204
+ E.toast(err.message || 'Could not download that attachment.', {type: 'error'});
4205
+ }
4206
+ }
4207
+
4208
+ // ---------------------------------------------------------------
4209
+ // Boot
4210
+ // ---------------------------------------------------------------
4211
+
4212
+ /**
4213
+ * Load the selected mailbox's folders and open its inbox.
4214
+ *
4215
+ * @returns {Promise<void>}
4216
+ */
4217
+ async function loadFolders() {
4218
+ try {
4219
+ const params = new URLSearchParams({account: currentAccountId ?? '', counts: '1'});
4220
+ const result = await api(`${BASE}/folders?${params}`);
4221
+ folders = result.folders;
4222
+ currentAccountId = result.accountId;
4223
+ rememberAccount(currentAccountId);
4224
+ // Started now, not when Compose is clicked, so the window
4225
+ // opens on an answer that has already arrived. Idempotent per
4226
+ // mailbox and deliberately not awaited: the folder list must
4227
+ // not wait on it.
4228
+ identity();
4229
+ // Likewise: the badge saying two messages are waiting to send
4230
+ // is worth nothing if it only appears after you go looking.
4231
+ refreshOutbox();
4232
+
4233
+ // Prefer the server's own idea of the inbox over the name.
4234
+ const inbox = folders.find(f => f.specialUse === '\\Inbox')
4235
+ ?? folders.find(f => f.path.toUpperCase() === 'INBOX')
4236
+ ?? folders[0];
4237
+ currentFolder = inbox?.path ?? 'INBOX';
4238
+ listTitleEl.textContent = inbox
4239
+ ? headingFor(inbox)
4240
+ : 'Inbox';
4241
+ renderFolders();
4242
+ } catch (err) {
4243
+ handleError(err);
4244
+ return;
4245
+ }
4246
+
4247
+ clearReader();
4248
+ await loadMessages();
4249
+ }
4250
+
4251
+ /**
4252
+ * Load the mailbox list, then the selected mailbox.
4253
+ *
4254
+ * @returns {Promise<void>}
4255
+ */
4256
+ async function start() {
4257
+ try {
4258
+ // Only the edition that can send has this route, and the
4259
+ // only thing it is read for is the attachment limit - so the
4260
+ // reader asking for it is a 404 on every mount for nothing.
4261
+ if (features.send) {
4262
+ api(`${BASE}/status`)
4263
+ .then(status => {
4264
+ if (status?.limits) limits = {...limits, ...status.limits};
4265
+ })
4266
+ .catch(() => {/* the defaults are serviceable */});
4267
+ }
4268
+
4269
+ // Deliberately not awaited. Mail must open at the same speed
4270
+ // whether or not an optional plugin answers, or answers at
4271
+ // all; the menu and the compose window both read whatever has
4272
+ // arrived by the time they are opened.
4273
+ refreshContacts();
4274
+
4275
+ mailboxes = (await api(`${BASE}/accounts`)).accounts;
4276
+ } catch (err) {
4277
+ E.toast(err.message, {type: 'error'});
4278
+ return;
4279
+ }
4280
+
4281
+ if (!mailboxes.length) {
4282
+ currentAccountId = null;
4283
+ showEmpty(foldersEl, 'folder', 'No mailboxes');
4284
+ showEmpty(listEl, 'settings', 'No mailbox is set up yet.', {
4285
+ label: 'Add a mailbox',
4286
+ onClick: () => openMailboxForm(null)
4287
+ });
4288
+ clearReader();
4289
+ return;
4290
+ }
4291
+
4292
+ // A removed mailbox should not leave the view pointing at nothing,
4293
+ // and a first visit should land on the one last looked at rather
4294
+ // than whichever the server happened to list first.
4295
+ if (!mailboxes.some(b => b.id === currentAccountId)) {
4296
+ currentAccountId = preferredAccount();
4297
+ }
4298
+ rememberAccount(currentAccountId);
4299
+ await loadFolders();
4300
+ }
4301
+
4302
+ if (features.search && searchEl) {
4303
+ searchEl.hidden = false;
4304
+
4305
+ let debounce = null;
4306
+ searchInputEl.addEventListener('input', () => {
4307
+ const value = searchInputEl.value.trim();
4308
+ if (searchClearEl) searchClearEl.hidden = !value;
4309
+ clearTimeout(debounce);
4310
+ // A keystroke per query would mean a database round trip per
4311
+ // keystroke; a short pause is what makes it feel immediate
4312
+ // without being wasteful.
4313
+ debounce = setTimeout(() => {
4314
+ if (!value) {
4315
+ clearSearch();
4316
+ return;
4317
+ }
4318
+ searchQuery = value;
4319
+ page = 1;
4320
+ selectedUid$.value = null;
4321
+ clearReader();
4322
+ runSearch();
4323
+ }, 300);
4324
+ });
4325
+
4326
+ searchInputEl.addEventListener('keydown', event => {
4327
+ if (event.key === 'Escape') clearSearch();
4328
+ });
4329
+ searchClearEl?.addEventListener('click', () => clearSearch());
4330
+ }
4331
+
4332
+ // The badge is free - a reader that cannot show what is unread looks
4333
+ // broken rather than limited. Narrowing a hundred folders down to the
4334
+ // ones that need attention is the part worth paying for.
4335
+ if (features.unreadFilter && unreadFilterEl && unreadOnlyEl) {
4336
+ unreadFilterEl.hidden = false;
4337
+ unreadOnlyEl.addEventListener('change', () => {
4338
+ unreadOnly = unreadOnlyEl.checked;
4339
+ renderFolders();
4340
+ });
4341
+ }
4342
+
4343
+ attachKeyboard();
4344
+
4345
+ if (features.write) attachMessageMenu();
4346
+ if (features.folders) attachFolderMenu();
4347
+
4348
+ $container.find('#mail-settings-btn').on('click', () => openSettings());
4349
+ $container.find('#mail-refresh-btn').on('click', () => loadMessages());
4350
+
4351
+ // Folder management was right-click only, which is the same as not
4352
+ // existing for anyone who does not already know it is there.
4353
+ if (features.folders) {
4354
+ const newFolder = $container.find('#mail-new-folder-btn').get(0);
4355
+ if (newFolder) {
4356
+ newFolder.hidden = false;
4357
+ newFolder.addEventListener('click', () => createFolder(null, {pickParent: true}));
4358
+ }
4359
+ }
4360
+
4361
+ // A real <input> here, unlike the row ticks: this one is not inside a
4362
+ // button, so it can be what it is.
4363
+ selectAllBox?.addEventListener('change', () => toggleSelectAll());
4364
+
4365
+ if (features.threads && threadsBtn) {
4366
+ threadsBtn.hidden = false;
4367
+ paintThreadsButton();
4368
+ threadsBtn.addEventListener('click', () => {
4369
+ toggleThreads().catch(err =>
4370
+ E.toast(err.message || 'That could not be changed.', {type: 'error'}));
4371
+ });
4372
+ }
4373
+
4374
+ if (features.send && outboxBtn) {
4375
+ outboxBtn.addEventListener('click', () => {
4376
+ showOutbox().catch(err =>
4377
+ E.toast(err.message || 'The outbox could not be opened.', {type: 'error'}));
4378
+ });
4379
+ }
4380
+
4381
+ if (features.send) {
4382
+ const compose = $container.find('#mail-compose-btn').get(0);
4383
+ if (compose) {
4384
+ compose.hidden = false;
4385
+ compose.addEventListener('click', async () => {
4386
+ try {
4387
+ openCompose(composeOptions(await identitySoon(), {mode: 'new'}));
4388
+ } catch (err) {
4389
+ E.toast(err.message || 'Compose could not be opened.', {type: 'error'});
4390
+ }
4391
+ });
4392
+ }
4393
+ }
4394
+
4395
+ // Fill the window and let the dividers move. Both are shared with
4396
+ // Email Pro, which draws the same three panes.
4397
+ // Nothing is measured until the stylesheet is in.
4398
+ await stylesheetReady;
4399
+
4400
+ // Every hint in this view is a native `title`, deliberately, and NOT
4401
+ // `E.tooltip`. That helper WRAPS each target in a <domma-tooltip>
4402
+ // element - which reparented both pane splitters and collapsed them to
4403
+ // zero height, so the panes silently stopped being resizable. A
4404
+ // prettier tooltip is not worth a working feature, and a title cannot
4405
+ // wrap, reparent or resize anything.
4406
+
4407
+ const layoutEl = $container.find('.mail-layout').get(0);
4408
+ // Lift the admin's content width cap for this view only.
4409
+ const containerEl = layoutEl?.closest('#view-container') ?? null;
4410
+ containerEl?.classList.add('mail-fullbleed');
4411
+
4412
+ if (layoutEl) {
4413
+ let storedWidths;
4414
+ try {
4415
+ const stored = S.get(PANES_KEY);
4416
+ if (Array.isArray(stored) && stored.length === 2) storedWidths = stored;
4417
+ } catch {
4418
+ // Storage unavailable - fall back to the defaults.
4419
+ }
4420
+
4421
+ createSplitters(layoutEl, {
4422
+ widths: storedWidths,
4423
+ onChange: next => {
4424
+ try {
4425
+ S.set(PANES_KEY, next);
4426
+ } catch {
4427
+ // The drag still applies for this visit.
4428
+ }
4429
+ }
4430
+ });
4431
+ stopFitting = fitToViewport(layoutEl);
4432
+ }
4433
+
4434
+ await start();
4435
+ Domma.icons.scan($container.get(0));
4436
+
4437
+ this.onUnmount = () => {
4438
+ cancelMarkRead();
4439
+ clearTimeout(undoTimer);
4440
+ if (keyboardHandler) document.removeEventListener('keydown', keyboardHandler);
4441
+ keyboardHandler = null;
4442
+ window.removeEventListener('error', captureError);
4443
+ window.removeEventListener('unhandledrejection', captureError);
4444
+ disposeRowEffects();
4445
+ messageMenu?.destroy?.();
4446
+ messageMenu = null;
4447
+ stopFitting?.();
4448
+ stopFitting = null;
4449
+ containerEl?.classList.remove('mail-fullbleed');
4450
+ };
4451
+ }
4452
+ };
4453
+ }