domma-cms 0.55.1 → 0.69.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (305) hide show
  1. package/CLAUDE.md +194 -3
  2. package/README.md +18 -14
  3. package/admin/css/admin.css +1 -1
  4. package/admin/css/contacts.css +1 -0
  5. package/admin/dist/domma/domma-tools.css +3 -3
  6. package/admin/dist/domma/domma-tools.min.js +3 -3
  7. package/admin/index.html +1 -0
  8. package/admin/js/api.js +1 -1
  9. package/admin/js/app.js +4 -4
  10. package/admin/js/lib/analytics-shape.js +1 -0
  11. package/admin/js/lib/contacts-arrange.js +1 -0
  12. package/admin/js/lib/help-popover.js +1 -0
  13. package/admin/js/lib/notes-arrange.js +1 -0
  14. package/admin/js/lib/page-picker.js +1 -0
  15. package/admin/js/lib/plugin-accent.js +1 -0
  16. package/admin/js/lib/plugin-chrome.js +1 -0
  17. package/admin/js/lib/shortcode-context-menu.js +2 -2
  18. package/admin/js/lib/sidebar-grouping.js +1 -1
  19. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  20. package/admin/js/lib/sidebar-renderer.js +4 -4
  21. package/admin/js/lib/slideover-resizable.js +1 -0
  22. package/admin/js/lib/todo-arrange.js +1 -0
  23. package/admin/js/lib/tool-kit.js +1 -0
  24. package/admin/js/templates/analytics.html +138 -0
  25. package/admin/js/templates/contacts.html +156 -0
  26. package/admin/js/templates/context-menu-editor.html +212 -0
  27. package/admin/js/templates/context-menus.html +16 -0
  28. package/admin/js/templates/notes.html +137 -0
  29. package/admin/js/templates/plugin-code.html +1 -1
  30. package/admin/js/templates/plugin-marketplace.html +17 -4
  31. package/admin/js/templates/plugins.html +13 -7
  32. package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
  33. package/admin/js/templates/settings.html +16 -97
  34. package/admin/js/templates/theme.html +211 -0
  35. package/admin/js/templates/todo.html +113 -0
  36. package/admin/js/views/analytics.js +62 -0
  37. package/admin/js/views/contacts.js +174 -0
  38. package/admin/js/views/context-menu-editor.js +55 -0
  39. package/admin/js/views/context-menus.js +5 -0
  40. package/admin/js/views/form-editor.js +7 -7
  41. package/admin/js/views/index.js +1 -1
  42. package/admin/js/views/notes.js +104 -0
  43. package/admin/js/views/plugin-marketplace.js +1 -1
  44. package/admin/js/views/plugins.js +28 -24
  45. package/admin/js/views/search.js +1 -0
  46. package/admin/js/views/settings.js +3 -3
  47. package/admin/js/views/theme.js +1 -0
  48. package/admin/js/views/todo.js +96 -0
  49. package/bin/cli.js +6 -10
  50. package/bin/lib/plugin-version.js +28 -0
  51. package/bin/update.js +27 -2
  52. package/config/menus/admin-sidebar.json +129 -23
  53. package/config/plugins.json +5 -5
  54. package/config/search.json +13 -0
  55. package/config/server.json +3 -1
  56. package/package.json +14 -10
  57. package/plugins/_lib/admin/mail/compose-window.js +914 -0
  58. package/plugins/_lib/admin/mail/contacts.js +301 -0
  59. package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
  60. package/plugins/_lib/admin/mail/folder-tree.js +254 -0
  61. package/plugins/_lib/admin/mail/identity.js +480 -0
  62. package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
  63. package/plugins/_lib/admin/mail/keyboard.js +136 -0
  64. package/plugins/_lib/admin/mail/mail.css +1 -0
  65. package/plugins/_lib/admin/mail/mail.html +71 -0
  66. package/plugins/_lib/admin/mail/panes.js +253 -0
  67. package/plugins/_lib/admin/mail/reader-view.js +4473 -0
  68. package/plugins/_lib/admin/mail/resizable.js +26 -0
  69. package/plugins/_lib/admin/mail/rules.js +343 -0
  70. package/plugins/_lib/admin/mail/scheduling.js +203 -0
  71. package/plugins/_lib/admin/mail/section-kit.js +277 -0
  72. package/plugins/_lib/admin/mail/templates.js +238 -0
  73. package/plugins/_lib/admin/mail/threads.js +200 -0
  74. package/plugins/_lib/admin/mail/vacation.js +269 -0
  75. package/plugins/_lib/admin/ui/help.css +1 -0
  76. package/plugins/_lib/admin/ui/help.js +174 -0
  77. package/plugins/_lib/admin/ui/resizable.js +151 -0
  78. package/plugins/_lib/dataStore.js +117 -0
  79. package/plugins/_lib/mail/accounts.js +919 -0
  80. package/plugins/_lib/mail/bodyTokens.js +101 -0
  81. package/plugins/_lib/mail/compose.js +256 -0
  82. package/plugins/_lib/mail/defaults.js +57 -0
  83. package/plugins/_lib/mail/diagnostics.js +154 -0
  84. package/plugins/_lib/mail/envelope.js +161 -0
  85. package/plugins/_lib/mail/folders.js +192 -0
  86. package/plugins/_lib/mail/handoff.js +274 -0
  87. package/plugins/_lib/mail/imapPool.js +293 -0
  88. package/plugins/_lib/mail/mbox.js +74 -0
  89. package/plugins/_lib/mail/pollSchedule.js +79 -0
  90. package/plugins/_lib/mail/poller.js +135 -0
  91. package/plugins/_lib/mail/priority.js +138 -0
  92. package/plugins/_lib/mail/readRoutes.js +680 -0
  93. package/plugins/_lib/mail/render.js +291 -0
  94. package/plugins/_lib/mail/ruleRunner.js +152 -0
  95. package/plugins/_lib/mail/scheduler.js +254 -0
  96. package/plugins/_lib/mail/secretbox.js +229 -0
  97. package/plugins/_lib/mail/send.js +396 -0
  98. package/plugins/_lib/mail/store.js +1022 -0
  99. package/plugins/_lib/mail/sync.js +292 -0
  100. package/plugins/_lib/mail/syncPlan.js +126 -0
  101. package/plugins/_lib/mail/syncSelection.js +82 -0
  102. package/plugins/_lib/mail/unsubscribe.js +183 -0
  103. package/plugins/_lib/mail/vacationRunner.js +114 -0
  104. package/plugins/_lib/mail/write.js +473 -0
  105. package/plugins/_lib/schemaSync.js +83 -0
  106. package/plugins/_template/admin/css/index.css +0 -0
  107. package/plugins/_template/admin/templates/index.html +4 -4
  108. package/plugins/_template/admin/views/index.js +7 -0
  109. package/plugins/blog/CLAUDE.md +237 -0
  110. package/plugins/blog/admin/css/index.css +178 -0
  111. package/plugins/blog/admin/templates/blog.html +35 -41
  112. package/plugins/blog/admin/templates/categories.html +19 -6
  113. package/plugins/blog/admin/templates/comments.html +26 -9
  114. package/plugins/blog/admin/templates/post-editor.html +63 -40
  115. package/plugins/blog/admin/templates/settings.html +3 -9
  116. package/plugins/blog/admin/views/blog.js +288 -159
  117. package/plugins/blog/admin/views/categories.js +212 -206
  118. package/plugins/blog/admin/views/comments.js +207 -158
  119. package/plugins/blog/admin/views/kit.js +372 -0
  120. package/plugins/blog/admin/views/post-editor.js +306 -39
  121. package/plugins/blog/admin/views/settings-cog.js +56 -0
  122. package/plugins/blog/admin/views/settings.js +16 -84
  123. package/plugins/blog/blocks/blog-card-compact.css +17 -0
  124. package/plugins/blog/blocks/blog-card-compact.html +8 -0
  125. package/plugins/blog/blocks/blog-card-row.css +22 -0
  126. package/plugins/blog/blocks/blog-card-row.html +11 -0
  127. package/plugins/blog/blocks/blog-card.css +35 -0
  128. package/plugins/blog/blocks/blog-card.html +16 -0
  129. package/plugins/blog/blocks/blog-post-classic.css +15 -0
  130. package/plugins/blog/blocks/blog-post-classic.html +28 -0
  131. package/plugins/blog/blocks/blog-post-feature.css +20 -0
  132. package/plugins/blog/blocks/blog-post-feature.html +30 -0
  133. package/plugins/blog/blocks/blog-post-minimal.css +10 -0
  134. package/plugins/blog/blocks/blog-post-minimal.html +23 -0
  135. package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
  136. package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
  137. package/plugins/blog/collections/categories/schema.json +7 -6
  138. package/plugins/blog/collections/comments/schema.json +11 -10
  139. package/plugins/blog/collections/posts/schema.json +16 -13
  140. package/plugins/blog/config.js +6 -0
  141. package/plugins/blog/lib/layouts.js +304 -0
  142. package/plugins/blog/lib/page.js +158 -0
  143. package/plugins/blog/lib/render.js +119 -0
  144. package/plugins/blog/lib/samples.js +234 -0
  145. package/plugins/blog/plugin.js +259 -26
  146. package/plugins/blog/plugin.json +21 -73
  147. package/plugins/blog/plugin.public.js +217 -0
  148. package/plugins/blog/public/blog.css +177 -0
  149. package/plugins/blog/public/blog.js +586 -0
  150. package/plugins/blog/public/samples/aurora.svg +7 -0
  151. package/plugins/blog/public/samples/dusk.svg +7 -0
  152. package/plugins/blog/public/samples/ember.svg +7 -0
  153. package/plugins/blog/public/samples/harbour.svg +7 -0
  154. package/plugins/blog/public/samples/meadow.svg +7 -0
  155. package/plugins/blog/public/samples/rosewood.svg +7 -0
  156. package/plugins/blog/templates/index.html +9 -8
  157. package/plugins/blog/templates/post.html +4 -17
  158. package/plugins/blog/tests/layouts.test.js +97 -0
  159. package/plugins/blog/tests/public.test.js +71 -0
  160. package/plugins/free-tier.lock.json +74 -0
  161. package/plugins/mail-reader/CLAUDE.md +78 -0
  162. package/plugins/mail-reader/admin/views/mail.js +19 -0
  163. package/plugins/mail-reader/config.js +7 -0
  164. package/plugins/mail-reader/plugin.js +48 -0
  165. package/plugins/mail-reader/plugin.json +34 -0
  166. package/public/css/forms.css +1 -1
  167. package/public/css/search.css +1 -0
  168. package/public/css/site.css +1 -1
  169. package/public/css/theme-switcher.css +1 -0
  170. package/public/js/analytics.js +1 -0
  171. package/public/js/collection-context.js +2 -2
  172. package/public/js/context-menus.js +1 -0
  173. package/public/js/form-logic-engine.js +1 -1
  174. package/public/js/forms.js +2 -2
  175. package/public/js/search.js +1 -0
  176. package/public/js/site.js +1 -1
  177. package/public/js/theme-switcher.js +1 -0
  178. package/scripts/build.js +77 -4
  179. package/scripts/copy-domma.js +48 -0
  180. package/scripts/run-tests.mjs +92 -0
  181. package/scripts/seed.js +1996 -0
  182. package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
  183. package/server/routes/api/collections.js +34 -0
  184. package/server/routes/api/contacts.js +506 -0
  185. package/server/routes/api/context-menus.js +104 -0
  186. package/server/routes/api/dashboard.js +8 -2
  187. package/server/routes/api/forms.js +42 -3
  188. package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
  189. package/server/routes/api/notifications.js +69 -19
  190. package/server/routes/api/plugin-marketplace.js +78 -23
  191. package/server/routes/api/plugins.js +198 -9
  192. package/server/routes/api/search.js +43 -0
  193. package/server/routes/api/theme.js +69 -0
  194. package/server/routes/api/todo.js +178 -0
  195. package/server/routes/public.js +42 -7
  196. package/server/server.js +135 -1
  197. package/server/services/adapters/FileAdapter.js +6 -1
  198. package/server/services/collections.js +13 -3
  199. package/server/services/content.js +26 -0
  200. package/server/services/contextMenus.js +477 -0
  201. package/server/services/managerClient.js +72 -136
  202. package/server/services/markdown.js +124 -10
  203. package/server/services/permissionRegistry.js +74 -0
  204. package/server/services/pluginEntitlement.js +171 -0
  205. package/server/services/pluginEnvelope.js +242 -0
  206. package/server/services/pluginFiles.js +52 -11
  207. package/server/services/pluginInstaller.js +246 -26
  208. package/server/services/pluginScaffold.js +5 -0
  209. package/server/services/plugins.js +340 -7
  210. package/server/services/presetCollections.js +344 -0
  211. package/server/services/promoted-plugins-migration.js +254 -0
  212. package/server/services/publicCors.js +46 -0
  213. package/server/services/renderer.js +224 -22
  214. package/server/services/roles.js +1 -1
  215. package/server/services/search-migration.js +82 -0
  216. package/server/services/search.js +413 -0
  217. package/server/services/sidebar-migration.js +1 -0
  218. package/server/services/sidebarBadges.js +37 -0
  219. package/server/services/siteGitignore.js +307 -0
  220. package/server/services/themeSettings.js +580 -0
  221. package/server/services/users.js +8 -0
  222. package/server/templates/page.html +4 -2
  223. package/plugins/analytics/admin/templates/analytics.html +0 -61
  224. package/plugins/analytics/admin/views/analytics.js +0 -176
  225. package/plugins/analytics/config.js +0 -14
  226. package/plugins/analytics/plugin.json +0 -57
  227. package/plugins/analytics/public/inject-body.html +0 -60
  228. package/plugins/analytics/public/inject-head.html +0 -1
  229. package/plugins/blog/templates/author.html +0 -9
  230. package/plugins/blog/templates/category.html +0 -9
  231. package/plugins/blog/templates/tag.html +0 -9
  232. package/plugins/contacts/admin/templates/contacts.html +0 -126
  233. package/plugins/contacts/admin/views/contacts.js +0 -710
  234. package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
  235. package/plugins/contacts/collections/user-contacts/schema.json +0 -71
  236. package/plugins/contacts/config.js +0 -6
  237. package/plugins/contacts/data/contacts.json +0 -20
  238. package/plugins/contacts/plugin.js +0 -291
  239. package/plugins/contacts/plugin.json +0 -33
  240. package/plugins/demo-viewer/config.js +0 -4
  241. package/plugins/demo-viewer/plugin.js +0 -36
  242. package/plugins/demo-viewer/plugin.json +0 -9
  243. package/plugins/invoice/admin/templates/editor.html +0 -129
  244. package/plugins/invoice/admin/templates/index.html +0 -43
  245. package/plugins/invoice/admin/templates/issuers.html +0 -5
  246. package/plugins/invoice/admin/templates/receivers.html +0 -5
  247. package/plugins/invoice/admin/views/editor.js +0 -267
  248. package/plugins/invoice/admin/views/index.js +0 -155
  249. package/plugins/invoice/admin/views/issuers.js +0 -23
  250. package/plugins/invoice/admin/views/party-view.js +0 -148
  251. package/plugins/invoice/admin/views/receivers.js +0 -22
  252. package/plugins/invoice/collections/invoice-issuers/schema.json +0 -16
  253. package/plugins/invoice/collections/invoice-receivers/schema.json +0 -15
  254. package/plugins/invoice/collections/invoices/schema.json +0 -27
  255. package/plugins/invoice/config.js +0 -16
  256. package/plugins/invoice/plugin.js +0 -283
  257. package/plugins/invoice/plugin.json +0 -85
  258. package/plugins/invoice/templates/invoice-print.html +0 -213
  259. package/plugins/notes/admin/templates/notes.html +0 -83
  260. package/plugins/notes/admin/views/notes.js +0 -281
  261. package/plugins/notes/collections/user-notes/schema.json +0 -54
  262. package/plugins/notes/config.js +0 -6
  263. package/plugins/notes/data/notes.json +0 -1
  264. package/plugins/notes/plugin.json +0 -33
  265. package/plugins/site-search/admin/views/site-search.js +0 -116
  266. package/plugins/site-search/config.js +0 -15
  267. package/plugins/site-search/plugin.js +0 -188
  268. package/plugins/site-search/plugin.json +0 -40
  269. package/plugins/site-search/public/inject-body.html +0 -17
  270. package/plugins/site-search/public/inject-head.html +0 -1
  271. package/plugins/site-search/public/search.css +0 -1
  272. package/plugins/site-search/public/search.js +0 -1
  273. package/plugins/surveys/admin/templates/audience.html +0 -47
  274. package/plugins/surveys/admin/templates/results.html +0 -58
  275. package/plugins/surveys/admin/templates/survey-editor.html +0 -141
  276. package/plugins/surveys/admin/templates/surveys.html +0 -25
  277. package/plugins/surveys/admin/views/audience.js +0 -301
  278. package/plugins/surveys/admin/views/results.js +0 -172
  279. package/plugins/surveys/admin/views/survey-editor.js +0 -211
  280. package/plugins/surveys/admin/views/surveys.js +0 -161
  281. package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
  282. package/plugins/surveys/collections/survey-groups/schema.json +0 -11
  283. package/plugins/surveys/collections/survey-invites/schema.json +0 -16
  284. package/plugins/surveys/collections/surveys/schema.json +0 -23
  285. package/plugins/surveys/config.js +0 -8
  286. package/plugins/surveys/plugin.js +0 -174
  287. package/plugins/surveys/plugin.json +0 -65
  288. package/plugins/surveys/public/dist-shared.mjs +0 -1
  289. package/plugins/surveys/public/survey.css +0 -1
  290. package/plugins/surveys/public/survey.mjs +0 -1
  291. package/plugins/surveys/templates/survey-page.html +0 -63
  292. package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
  293. package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
  294. package/plugins/theme-switcher/config.js +0 -10
  295. package/plugins/theme-switcher/plugin.js +0 -26
  296. package/plugins/theme-switcher/plugin.json +0 -74
  297. package/plugins/theme-switcher/public/inject-body.html +0 -153
  298. package/plugins/theme-switcher/public/inject-head.html +0 -260
  299. package/plugins/todo/admin/templates/todo.html +0 -158
  300. package/plugins/todo/admin/views/todo.js +0 -343
  301. package/plugins/todo/collections/todos/schema.json +0 -60
  302. package/plugins/todo/config.js +0 -7
  303. package/plugins/todo/data/todos.json +0 -1
  304. package/plugins/todo/plugin.js +0 -102
  305. package/plugins/todo/plugin.json +0 -33
@@ -0,0 +1,914 @@
1
+ /**
2
+ * The compose window.
3
+ *
4
+ * A slideover built as real DOM and appended to `slideover.element`: passing
5
+ * markup through setContent strips inputs and buttons, which is the single
6
+ * most expensive thing to rediscover in this codebase.
7
+ *
8
+ * @module _lib/admin/mail/compose-window
9
+ */
10
+
11
+ /**
12
+ * Render an address list into something editable.
13
+ *
14
+ * @param {Array<{name?: string, address?: string}>|undefined} list
15
+ * @returns {string}
16
+ */
17
+ function addressesToText(list) {
18
+ return (list ?? [])
19
+ .map(entry => (entry.name ? `${entry.name} <${entry.address}>` : entry.address))
20
+ .filter(Boolean)
21
+ .join(', ');
22
+ }
23
+
24
+ /**
25
+ * Parse what was typed back into addresses.
26
+ *
27
+ * Accepts "Name <a@b>" as well as bare addresses, because both are what
28
+ * people paste.
29
+ *
30
+ * @param {string} text
31
+ * @returns {{name: string, address: string}[]}
32
+ */
33
+ function textToAddresses(text) {
34
+ return String(text ?? '')
35
+ .split(/[,;]/)
36
+ .map(part => part.trim())
37
+ .filter(Boolean)
38
+ .map(part => {
39
+ const match = part.match(/^(.*?)\s*<([^>]+)>$/);
40
+ return match
41
+ ? {name: match[1].replace(/^["']|["']$/g, '').trim(), address: match[2].trim()}
42
+ : {name: '', address: part};
43
+ });
44
+ }
45
+
46
+ /**
47
+ * Open a compose window.
48
+ *
49
+ * @param {{draft?: object, base: string, accountId: string, api: Function,
50
+ * onSent?: (result: object) => void}} options
51
+ * @returns {void}
52
+ */
53
+ /**
54
+ * Human-readable byte size.
55
+ *
56
+ * @param {number} bytes
57
+ * @returns {string}
58
+ */
59
+ function formatSize(bytes) {
60
+ if (bytes < 1024) return `${bytes} B`;
61
+ const units = ['KB', 'MB'];
62
+ let value = bytes / 1024;
63
+ let unit = 0;
64
+ while (value >= 1024 && unit < units.length - 1) {
65
+ value /= 1024;
66
+ unit += 1;
67
+ }
68
+ return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
69
+ }
70
+
71
+ import {applyIdentity, findIdentity, identityFrom, swapSignature} from './identity.js';
72
+ import {describeWhen, quickTimes} from './scheduling.js';
73
+ import {applyTemplate, expandTemplate} from './templates.js';
74
+ import {makeResizable} from './resizable.js';
75
+ import {
76
+ collectGroups,
77
+ currentFragment,
78
+ expandGroup,
79
+ formatRecipient,
80
+ replaceFragment,
81
+ suggest
82
+ } from './contacts.js';
83
+
84
+ /**
85
+ * Turn plain text into the minimal HTML that preserves its line breaks.
86
+ *
87
+ * Used when opening the rich editor on a draft that only has a text version.
88
+ *
89
+ * @param {string} text
90
+ * @returns {string}
91
+ */
92
+ function textToHtml(text) {
93
+ const escaped = String(text ?? '')
94
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
95
+ return escaped
96
+ .split(/\n{2,}/)
97
+ .map(block => `<p>${block.replace(/\n/g, '<br>')}</p>`)
98
+ .join('');
99
+ }
100
+
101
+ /**
102
+ * Read a file as base64, without the data-URL preamble.
103
+ *
104
+ * @param {File} file
105
+ * @returns {Promise<string>}
106
+ */
107
+ function readAsBase64(file) {
108
+ return new Promise((resolve, reject) => {
109
+ const reader = new FileReader();
110
+ reader.onload = () => resolve(String(reader.result).split(',')[1] ?? '');
111
+ reader.onerror = () => reject(new Error(`${file.name} could not be read.`));
112
+ reader.readAsDataURL(file);
113
+ });
114
+ }
115
+
116
+ /**
117
+ * Offer contact suggestions on one recipient field.
118
+ *
119
+ * Domma's own autocomplete rather than a bespoke dropdown - it already has the
120
+ * keyboard handling, the positioning and the outside-click close, and three of
121
+ * those are where a hand-rolled one goes wrong.
122
+ *
123
+ * The one thing it does not know is that this field holds a LIST. Its filter
124
+ * is handed the whole input, and selecting an item replaces the whole input,
125
+ * so both are re-pointed at the fragment after the last comma.
126
+ *
127
+ * @param {HTMLInputElement} input
128
+ * @param {object[]} contacts
129
+ * @param {object[]} groups
130
+ * @returns {object|null} the instance, for teardown
131
+ */
132
+ function attachSuggestions(input, contacts, groups) {
133
+ try {
134
+ // The component assigns its own value to the field BEFORE calling
135
+ // onSelect, so by then what was typed is gone. Kept here on every
136
+ // keystroke, which is the only moment it is still true.
137
+ input.dataset.mailValue = input.value;
138
+ input.addEventListener('input', () => { input.dataset.mailValue = input.value; });
139
+
140
+ return E.autocomplete(input, {
141
+ minChars: 1,
142
+ maxResults: 8,
143
+ // Instant: the list is already in memory, and a delay on local
144
+ // filtering is a delay for nothing.
145
+ debounce: 0,
146
+ emptyMessage: 'No matching contacts',
147
+ // `dataSource` rather than `data` + `filterFn`, because a filter
148
+ // is only a predicate: it decides who appears, not in what order,
149
+ // and the component keeps the address book's own ordering. That
150
+ // put "rob@samsung-parts.com" above "Sam Archer" for the query
151
+ // "sam". Returning the list puts the ranking back in charge.
152
+ //
153
+ // `query` is the whole field. What is being typed is only ever the
154
+ // part after the last separator; everything before it is a
155
+ // recipient already chosen, and so must not be offered twice.
156
+ dataSource: (query) => {
157
+ const fragment = currentFragment(query);
158
+ const chosen = textToAddresses(query.slice(0, query.length - fragment.length))
159
+ .map(a => a.address);
160
+ return suggest(contacts, groups, fragment, {exclude: chosen});
161
+ },
162
+ renderItem: entry => {
163
+ const row = document.createElement('div');
164
+ row.className = 'mail-suggest-row';
165
+ const title = document.createElement('span');
166
+ const detail = document.createElement('span');
167
+ detail.className = 'text-xs mail-suggest-detail';
168
+
169
+ if (entry.kind === 'group') {
170
+ // Said plainly, because picking one puts several addresses
171
+ // in the field at once and that should not be a surprise.
172
+ const count = entry.members.length;
173
+ title.textContent = `\u{1F465} ${entry.name}`;
174
+ detail.textContent = `Group \u00b7 ${count} ${count === 1 ? 'person' : 'people'}`;
175
+ } else {
176
+ title.textContent = entry.favourite && entry.name
177
+ ? `\u2605 ${entry.name}`
178
+ : (entry.name || entry.email);
179
+ detail.textContent = entry.name ? entry.email : '';
180
+ }
181
+
182
+ row.appendChild(title);
183
+ if (detail.textContent) row.appendChild(detail);
184
+ return row;
185
+ },
186
+ // The component has already overwritten the field with its own
187
+ // idea of the value by the time this runs, so this puts the list
188
+ // back and appends rather than replaces.
189
+ onSelect: (entry, {target}) => {
190
+ const before = target.dataset.mailValue ?? '';
191
+ const fragment = currentFragment(before);
192
+ const chosen = textToAddresses(before.slice(0, before.length - fragment.length))
193
+ .map(a => a.address);
194
+
195
+ const replacement = entry.kind === 'group'
196
+ ? expandGroup(entry, {exclude: chosen})
197
+ : formatRecipient(entry);
198
+
199
+ target.value = replaceFragment(before, replacement);
200
+ target.dataset.mailValue = target.value;
201
+ target.setSelectionRange(target.value.length, target.value.length);
202
+ }
203
+ });
204
+ } catch {
205
+ // An older Domma without it, or a shape it does not like. The field is
206
+ // a plain text input either way.
207
+ return null;
208
+ }
209
+ }
210
+
211
+ export function openCompose({
212
+ draft: opened = {}, base, accountId, api, maxAttachmentBytes = 20_000_000,
213
+ onSent = () => {}, onSaved = () => {}, onQueued = () => {}, contacts = [],
214
+ identities = [], mailboxAddress = '', templates = [], undoSeconds = 0,
215
+ mode = 'new'
216
+ }) {
217
+ // How this message was started, decided once. A reopened draft is never
218
+ // treated as new: it already carries the signature it was written with,
219
+ // and adding another on reopen is how a draft edited four times goes out
220
+ // with four.
221
+ const openedAs = opened.savedUid ? 'edit' : mode;
222
+
223
+ /** Which identity is sending. Changeable while the window is open. */
224
+ let currentIdentity = findIdentity(identities, opened.identityId) ?? null;
225
+
226
+ // The one place all four ways of starting a message meet - new, reply,
227
+ // forward and reopened draft - so it is the one place the signature goes
228
+ // on. `applyIdentity` decides whether this mode gets one.
229
+ const draft = applyIdentity(opened, currentIdentity, openedAs);
230
+
231
+ /** A time this message should go at, rather than now. */
232
+ let scheduledAt = null;
233
+
234
+ const slideover = E.slideover({
235
+ title: draft.savedUid
236
+ ? 'Draft'
237
+ : (draft.inReplyTo ? 'Reply' : (draft.subject ? 'Forward' : 'New message')),
238
+ size: 'lg',
239
+ position: 'right'
240
+ });
241
+
242
+ const form = document.createElement('div');
243
+ form.className = 'mail-compose-form';
244
+
245
+ /**
246
+ * @param {string} label
247
+ * @param {string} value
248
+ * @param {string} [placeholder]
249
+ * @returns {HTMLInputElement}
250
+ */
251
+ function field(label, value, placeholder = '') {
252
+ const wrap = document.createElement('div');
253
+ const labelEl = document.createElement('label');
254
+ labelEl.className = 'form-label';
255
+ labelEl.textContent = label;
256
+ const input = document.createElement('input');
257
+ input.type = 'text';
258
+ input.className = 'form-input';
259
+ input.value = value ?? '';
260
+ input.placeholder = placeholder;
261
+ wrap.appendChild(labelEl);
262
+ wrap.appendChild(input);
263
+ form.appendChild(wrap);
264
+ return input;
265
+ }
266
+
267
+ // The From picker, and only when there is a choice to make. One identity
268
+ // is not a choice, and a row saying so on every message is a row nobody
269
+ // reads.
270
+ let fromSelect = null;
271
+ if (identities.length > 1) {
272
+ const wrap = document.createElement('div');
273
+ wrap.className = 'form-group';
274
+ const label = document.createElement('label');
275
+ label.className = 'form-label';
276
+ label.textContent = 'From';
277
+ fromSelect = document.createElement('select');
278
+ fromSelect.className = 'form-select';
279
+
280
+ for (const entry of identities) {
281
+ const option = document.createElement('option');
282
+ option.value = entry.id;
283
+ // The address as it will appear, not the label alone - the label
284
+ // is what the owner called it and the address is what the
285
+ // recipient will see, and those are the two different questions
286
+ // "which of these am I?" asks.
287
+ const shown = identityFrom(entry, mailboxAddress);
288
+ option.textContent = entry.label === shown ? shown : `${entry.label} \u2014 ${shown}`;
289
+ fromSelect.appendChild(option);
290
+ }
291
+ fromSelect.value = currentIdentity?.id ?? identities[0].id;
292
+
293
+ fromSelect.addEventListener('change', () => {
294
+ currentIdentity = findIdentity(identities, fromSelect.value);
295
+ // The signature in the body belongs to whoever was sending a
296
+ // moment ago. Swapped where it can be found and left alone where
297
+ // it cannot - see `swapSignature`.
298
+ if (editor) {
299
+ const {html, swapped} = swapSignature(editor.getValue(), currentIdentity, openedAs);
300
+ if (swapped) editor.setValue(html);
301
+ else if (hasSignatureFor(currentIdentity)) {
302
+ E.toast(
303
+ 'The sender changed, but the signature already in this message was left as it is.',
304
+ {type: 'info'}
305
+ );
306
+ }
307
+ }
308
+ });
309
+
310
+ wrap.appendChild(label);
311
+ wrap.appendChild(fromSelect);
312
+ form.appendChild(wrap);
313
+ }
314
+
315
+ /**
316
+ * Does this identity have a signature that would have been added?
317
+ *
318
+ * @param {object|null} entry
319
+ * @returns {boolean}
320
+ */
321
+ function hasSignatureFor(entry) {
322
+ return Boolean(entry?.signatureHtml || entry?.signatureText);
323
+ }
324
+
325
+ const toInput = field('To', addressesToText(draft.to), 'someone@example.com');
326
+ const ccInput = field('Cc', addressesToText(draft.cc));
327
+ const bccInput = field('Bcc', addressesToText(draft.bcc));
328
+
329
+ // Folded away with Cc and Bcc: it belongs with the things most messages
330
+ // do not need, and a picker sitting permanently above every message
331
+ // invites fiddling with a setting whose whole meaning is that it is
332
+ // unusual.
333
+ const priorityWrap = document.createElement('div');
334
+ const priorityLabel = document.createElement('label');
335
+ priorityLabel.className = 'form-label';
336
+ priorityLabel.textContent = 'Priority';
337
+ const prioritySelect = document.createElement('select');
338
+ prioritySelect.className = 'form-select';
339
+ for (const [value, text] of [['normal', 'Normal'], ['high', 'High'], ['low', 'Low']]) {
340
+ const option = document.createElement('option');
341
+ option.value = value;
342
+ option.textContent = text;
343
+ prioritySelect.appendChild(option);
344
+ }
345
+ prioritySelect.value = ['high', 'low'].includes(draft.priority) ? draft.priority : 'normal';
346
+ priorityWrap.appendChild(priorityLabel);
347
+ priorityWrap.appendChild(prioritySelect);
348
+
349
+ // A request, not a mechanism. Every client is free to ignore it and many
350
+ // do, so the hint says so rather than letting someone rely on silence
351
+ // meaning "not read".
352
+ const receiptLabel = document.createElement('label');
353
+ receiptLabel.className = 'form-check mt-2';
354
+ const receiptInput = document.createElement('input');
355
+ receiptInput.type = 'checkbox';
356
+ receiptInput.className = 'form-check-input';
357
+ receiptInput.checked = draft.readReceipt === true;
358
+ const receiptText = document.createElement('span');
359
+ receiptText.className = 'form-check-label';
360
+ receiptText.textContent = 'Ask for a read receipt';
361
+ receiptLabel.appendChild(receiptInput);
362
+ receiptLabel.appendChild(receiptText);
363
+ const receiptHint = document.createElement('div');
364
+ receiptHint.className = 'form-help text-xs text-muted';
365
+ receiptHint.textContent = 'Many mail clients ignore this, or ask the recipient first. No receipt does not mean unread.';
366
+ priorityWrap.appendChild(receiptLabel);
367
+ priorityWrap.appendChild(receiptHint);
368
+
369
+ form.appendChild(priorityWrap);
370
+
371
+ const subjectInput = field('Subject', draft.subject ?? '');
372
+
373
+ // Cc and Bcc are folded away until wanted. Most messages use neither, and
374
+ // two empty fields between the recipient and the subject is two rows of
375
+ // nothing in the part of the window someone looks at first.
376
+ //
377
+ // Opened automatically when the draft already has one, because a reply-all
378
+ // or a reopened draft with a hidden Cc would be a message quietly going
379
+ // somewhere the sender cannot see.
380
+ const ccRow = ccInput.closest('div');
381
+ const bccRow = bccInput.closest('div');
382
+ // Opened when the draft already carries any of them, priority included -
383
+ // a reopened urgent draft with the picker folded away would look ordinary.
384
+ let extrasOpen = Boolean(
385
+ draft.cc?.length || draft.bcc?.length || ['high', 'low'].includes(draft.priority)
386
+ || draft.readReceipt === true
387
+ );
388
+
389
+ const extrasToggle = document.createElement('button');
390
+ extrasToggle.type = 'button';
391
+ extrasToggle.className = 'btn btn-sm btn-ghost mail-extras-toggle';
392
+ const paintExtras = () => {
393
+ ccRow.hidden = !extrasOpen;
394
+ bccRow.hidden = !extrasOpen;
395
+ priorityWrap.hidden = !extrasOpen;
396
+ extrasToggle.textContent = extrasOpen ? 'Hide Cc and Bcc' : 'Cc / Bcc';
397
+ extrasToggle.setAttribute('aria-expanded', String(extrasOpen));
398
+ };
399
+ extrasToggle.addEventListener('click', () => {
400
+ extrasOpen = !extrasOpen;
401
+ paintExtras();
402
+ if (extrasOpen) ccInput.focus();
403
+ });
404
+ // Directly under To, which is where someone reaches for it.
405
+ toInput.closest('div').appendChild(extrasToggle);
406
+ paintExtras();
407
+
408
+ // Suggest recipients from the Contacts plugin, if this site has it. The
409
+ // address book is passed in rather than fetched here: the view already
410
+ // holds it for its own "add sender" action, and two windows open at once
411
+ // should not mean two requests for the same unchanging list.
412
+ const suggesters = [];
413
+ if (contacts.length && typeof E?.autocomplete === 'function') {
414
+ const groups = collectGroups(contacts);
415
+ for (const input of [toInput, ccInput, bccInput]) {
416
+ const suggester = attachSuggestions(input, contacts, groups);
417
+ if (suggester) suggesters.push(suggester);
418
+ }
419
+ }
420
+
421
+ const bodyWrap = document.createElement('div');
422
+ bodyWrap.className = 'mail-compose-body';
423
+ const bodyLabel = document.createElement('label');
424
+ bodyLabel.className = 'form-label';
425
+ bodyLabel.textContent = 'Message';
426
+ bodyWrap.appendChild(bodyLabel);
427
+
428
+ // Canned responses, and only when this mailbox has any. A picker offering
429
+ // nothing is a picker that has to explain itself.
430
+ if (templates.length) {
431
+ const pickerRow = document.createElement('div');
432
+ pickerRow.className = 'flex items-center gap-2 mail-template-row';
433
+
434
+ const picker = document.createElement('select');
435
+ picker.className = 'form-select form-select-sm mail-template-picker';
436
+
437
+ const placeholder = document.createElement('option');
438
+ placeholder.value = '';
439
+ placeholder.textContent = 'Insert a template\u2026';
440
+ picker.appendChild(placeholder);
441
+
442
+ for (const template of templates) {
443
+ const option = document.createElement('option');
444
+ option.value = template.id;
445
+ option.textContent = template.name;
446
+ picker.appendChild(option);
447
+ }
448
+
449
+ picker.addEventListener('change', () => {
450
+ const chosen = templates.find(t => t.id === picker.value);
451
+ // Back to the placeholder straight away: the picker is an action,
452
+ // not a setting, and leaving the last-used one showing reads as
453
+ // "this message is that template".
454
+ picker.value = '';
455
+ if (!chosen) return;
456
+
457
+ const expanded = expandTemplate(chosen, {
458
+ to: textToAddresses(toInput.value),
459
+ subject: subjectInput.value,
460
+ identity: currentIdentity,
461
+ fromAddress: mailboxAddress
462
+ });
463
+
464
+ const current = {
465
+ subject: subjectInput.value,
466
+ html: editor ? editor.getValue() : '',
467
+ body: editor ? editor.getText() : bodyInput.value
468
+ };
469
+ const next = applyTemplate(current, expanded);
470
+
471
+ subjectInput.value = next.subject;
472
+ if (editor) editor.setValue(next.html);
473
+ else bodyInput.value = next.body;
474
+ });
475
+
476
+ const hint = document.createElement('span');
477
+ hint.className = 'text-xs text-muted';
478
+ hint.textContent = 'Added above what is already written.';
479
+
480
+ pickerRow.appendChild(picker);
481
+ pickerRow.appendChild(hint);
482
+ bodyWrap.appendChild(pickerRow);
483
+ }
484
+
485
+ // Domma's own editor, in rich mode. A plain textarea is kept as the
486
+ // fallback rather than assumed away: the editor is a component that may
487
+ // not be present in an older Domma, and a compose window that cannot type
488
+ // is worse than one without bold.
489
+ const editorHost = document.createElement('div');
490
+ editorHost.className = 'mail-compose-editor';
491
+ bodyWrap.appendChild(editorHost);
492
+
493
+ const bodyInput = document.createElement('textarea');
494
+ bodyInput.className = 'form-textarea mail-compose-textarea';
495
+ bodyInput.value = draft.body ?? '';
496
+
497
+ let editor = null;
498
+ if (typeof E?.editor === 'function') {
499
+ try {
500
+ editor = E.editor(editorHost, {mode: 'rich', height: '34vh'});
501
+ editor.setValue(draft.html || textToHtml(draft.body ?? ''));
502
+ } catch {
503
+ editor = null;
504
+ }
505
+ }
506
+ if (!editor) {
507
+ editorHost.remove();
508
+ bodyWrap.appendChild(bodyInput);
509
+ }
510
+ form.appendChild(bodyWrap);
511
+
512
+ // What the window opened showing, read back out of the thing showing it.
513
+ //
514
+ // NOT `draft.body`: the editor is seeded from `draft.html` and derives its
515
+ // own text from that, so the two already differed on every HTML reply -
516
+ // which is why discarding an untouched reply asked whether to lose
517
+ // changes nobody had made. A signature makes that worse, since it means
518
+ // even a new message opens "changed". Comparing against what was actually
519
+ // put in front of the person is both the fix and the only baseline that
520
+ // stays true once anything else seeds the body.
521
+ const openedText = editor ? editor.getText() : bodyInput.value;
522
+
523
+ // Attachments
524
+ // Seeded from the draft, so reopening one keeps the files that were
525
+ // attached to it - normalised to the picker's shape on the way in. The
526
+ // server calls the length `size` and the picker calls it `bytes`, and
527
+ // seeding the server's shape verbatim renders "notes.txt (undefined)" with
528
+ // a NaN running total. Falling back to the base64 length is the honest
529
+ // measure anyway: that is what will actually be sent.
530
+ const attachments = (draft.attachments ?? []).map(file => ({
531
+ filename: file.filename,
532
+ contentType: file.contentType,
533
+ content: file.content,
534
+ bytes: file.bytes ?? file.size ?? Math.floor(String(file.content ?? '').length * 3 / 4)
535
+ }));
536
+
537
+ const attachRow = document.createElement('div');
538
+ attachRow.className = 'flex items-center gap-2 flex-wrap';
539
+
540
+ const fileInput = document.createElement('input');
541
+ fileInput.type = 'file';
542
+ fileInput.multiple = true;
543
+ fileInput.style.display = 'none';
544
+
545
+ const attachBtn = document.createElement('button');
546
+ attachBtn.className = 'btn btn-sm btn-ghost';
547
+ attachBtn.textContent = 'Attach files';
548
+ attachBtn.addEventListener('click', () => fileInput.click());
549
+
550
+ const attachTotal = document.createElement('span');
551
+ attachTotal.className = 'text-xs text-muted';
552
+
553
+ attachRow.appendChild(attachBtn);
554
+ attachRow.appendChild(attachTotal);
555
+ form.appendChild(attachRow);
556
+ form.appendChild(fileInput);
557
+
558
+ const attachList = document.createElement('div');
559
+ attachList.className = 'mail-attach-list';
560
+ form.appendChild(attachList);
561
+
562
+ const status = document.createElement('div');
563
+ status.className = 'text-sm mail-status';
564
+ form.appendChild(status);
565
+
566
+ /**
567
+ * Say something under the message, in the right colour.
568
+ *
569
+ * A class rather than `style.color`: an inline colour cannot read a
570
+ * `--dm-*` token a theme override redefines, so a status line set that
571
+ * way stays the same shade in every theme.
572
+ *
573
+ * @param {string} text
574
+ * @param {'ok'|'bad'|'quiet'} [tone]
575
+ * @returns {void}
576
+ */
577
+ function say(text, tone = 'quiet') {
578
+ status.textContent = text ?? '';
579
+ status.className = 'text-sm mail-status '
580
+ + ({ok: 'text-success', bad: 'text-danger'}[tone] ?? 'text-muted');
581
+ }
582
+
583
+ /**
584
+ * Redraw the attachment list and the running total.
585
+ *
586
+ * The total is shown as it grows rather than only on send, because being
587
+ * told a message is too large after writing it is a poor moment to find
588
+ * out.
589
+ *
590
+ * @returns {void}
591
+ */
592
+ function paintAttachments() {
593
+ attachList.textContent = '';
594
+ const total = attachments.reduce((n, a) => n + a.bytes, 0);
595
+
596
+ attachTotal.textContent = attachments.length
597
+ ? `${attachments.length} file${attachments.length === 1 ? '' : 's'}, ${formatSize(total)} of ${formatSize(maxAttachmentBytes)}`
598
+ : '';
599
+ // Red once the total is over the limit, which is the only reason
600
+ // this number is on screen while a message is being written.
601
+ attachTotal.className = total > maxAttachmentBytes
602
+ ? 'text-xs text-danger'
603
+ : 'text-xs text-muted';
604
+
605
+ for (const file of attachments) {
606
+ const row = document.createElement('div');
607
+ row.className = 'mail-box-row';
608
+
609
+ const name = document.createElement('span');
610
+ name.className = 'flex-1 text-sm mail-ellipsis';
611
+ name.textContent = `${file.filename} (${formatSize(file.bytes)})`;
612
+ row.appendChild(name);
613
+
614
+ const remove = document.createElement('button');
615
+ remove.className = 'btn btn-sm btn-ghost';
616
+ remove.textContent = 'Remove';
617
+ remove.addEventListener('click', () => {
618
+ attachments.splice(attachments.indexOf(file), 1);
619
+ paintAttachments();
620
+ });
621
+ row.appendChild(remove);
622
+ attachList.appendChild(row);
623
+ }
624
+
625
+ send.disabled = total > maxAttachmentBytes;
626
+ }
627
+
628
+ fileInput.addEventListener('change', async () => {
629
+ const chosen = [...fileInput.files];
630
+ fileInput.value = '';
631
+ for (const file of chosen) {
632
+ try {
633
+ attachments.push({
634
+ filename: file.name,
635
+ contentType: file.type || 'application/octet-stream',
636
+ content: await readAsBase64(file),
637
+ bytes: file.size
638
+ });
639
+ } catch (err) {
640
+ E.toast(err.message, {type: 'error'});
641
+ }
642
+ }
643
+ paintAttachments();
644
+ });
645
+
646
+ const actions = document.createElement('div');
647
+ actions.className = 'flex justify-end gap-2';
648
+
649
+ // The UID of this draft's previous version, once it has been saved once,
650
+ // so a second save replaces rather than accumulates.
651
+ let savedUid = draft.savedUid ?? null;
652
+
653
+ const saveDraftBtn = document.createElement('button');
654
+ saveDraftBtn.className = 'btn btn-sm btn-ghost';
655
+ saveDraftBtn.textContent = 'Save draft';
656
+ saveDraftBtn.addEventListener('click', async () => {
657
+ saveDraftBtn.disabled = true;
658
+ say('Saving\u2026');
659
+ try {
660
+ const params = new URLSearchParams({account: accountId});
661
+ const result = await api(`${base}/drafts?${params}`, 'POST', {
662
+ ...collect(),
663
+ replaces: savedUid
664
+ });
665
+ savedUid = result.uid ?? savedUid;
666
+ say(`Saved to ${result.savedTo}.`, 'ok');
667
+ // The Drafts folder now holds a different set of UIDs, so a list
668
+ // showing it is out of date whether or not the window closes.
669
+ onSaved(result);
670
+ } catch (err) {
671
+ say(err.message, 'bad');
672
+ } finally {
673
+ saveDraftBtn.disabled = false;
674
+ }
675
+ });
676
+
677
+ const cancel = document.createElement('button');
678
+ cancel.className = 'btn btn-sm btn-ghost';
679
+ cancel.textContent = 'Discard';
680
+ cancel.addEventListener('click', async () => {
681
+ // Closing on something half-written should ask, because the window is
682
+ // the only place that text exists until it is saved.
683
+ const current = editor ? editor.getText() : bodyInput.value;
684
+ const changed = subjectInput.value.trim() !== (draft.subject ?? '').trim()
685
+ || current.trim() !== openedText.trim();
686
+ // A reopened draft is already saved, so closing it unchanged is not a
687
+ // loss and should not ask. Changed since opening, it is.
688
+ //
689
+ // `changed` is the whole test. A new message that has had a subject
690
+ // typed into it is already changed, because a new draft opens with an
691
+ // empty one - so the extra "or the subject is non-empty" clause this
692
+ // used to carry caught nothing new, and DID catch every reply and
693
+ // forward, which open with `Re:` / `Fwd:` prefilled and so were
694
+ // always treated as unsaved work.
695
+ const unsaved = changed;
696
+ if (unsaved) {
697
+ // Two different losses, and saying the wrong one is worse than
698
+ // saying nothing: one has never been saved, the other has changes
699
+ // since it was.
700
+ const question = savedUid
701
+ ? 'Close without saving? The changes since you opened this draft will be lost.'
702
+ : 'Discard this message? It has not been saved as a draft.';
703
+ if (!await E.confirm(question)) return;
704
+ }
705
+ slideover.close();
706
+ });
707
+
708
+ /**
709
+ * The message as it currently stands.
710
+ *
711
+ * @returns {object}
712
+ */
713
+ function collect() {
714
+ // Both parts, always: the HTML for clients that render it and the text
715
+ // for those that do not. Taking the text from the editor rather than
716
+ // stripping tags means it reads as prose rather than as markup with
717
+ // the angle brackets removed.
718
+ const html = editor ? editor.getValue() : null;
719
+ const text = editor ? editor.getText() : bodyInput.value;
720
+
721
+ return {
722
+ to: textToAddresses(toInput.value),
723
+ cc: textToAddresses(ccInput.value),
724
+ bcc: textToAddresses(bccInput.value),
725
+ priority: prioritySelect.value,
726
+ readReceipt: receiptInput.checked,
727
+ // Which of the mailbox's identities is sending. Null where there
728
+ // is only one, which is what the server reads as "the default".
729
+ identityId: currentIdentity?.id ?? null,
730
+ subject: subjectInput.value,
731
+ body: text,
732
+ html,
733
+ inReplyTo: draft.inReplyTo ?? null,
734
+ references: draft.references ?? [],
735
+ attachments: attachments.map(({filename, contentType, content}) =>
736
+ ({filename, contentType, content}))
737
+ };
738
+ }
739
+
740
+ const send = document.createElement('button');
741
+ send.className = 'btn btn-sm btn-primary';
742
+
743
+ /**
744
+ * Label the Send button for what it will actually do.
745
+ *
746
+ * Three different things wear this button: send now, hold briefly so it
747
+ * can be taken back, and send at a chosen time. Calling all three "Send"
748
+ * is how someone finds out about the ten second hold by watching a
749
+ * message not arrive.
750
+ *
751
+ * @returns {void}
752
+ */
753
+ function paintSend() {
754
+ if (scheduledAt) {
755
+ send.textContent = `Send ${describeWhen(scheduledAt)}`;
756
+ send.title = new Date(scheduledAt).toLocaleString();
757
+ } else {
758
+ send.textContent = 'Send';
759
+ send.title = undoSeconds > 0
760
+ ? `Held for ${undoSeconds} seconds so you can take it back.`
761
+ : '';
762
+ }
763
+ }
764
+
765
+ /**
766
+ * Choose a time to send at.
767
+ *
768
+ * The quick choices come from the same module the server validates
769
+ * against, so a time offered here cannot be refused there.
770
+ *
771
+ * @returns {void}
772
+ */
773
+ function openScheduler() {
774
+ const panel = document.createElement('div');
775
+ panel.className = 'flex flex-col gap-2';
776
+
777
+ const choices = document.createElement('div');
778
+ choices.className = 'flex flex-wrap gap-1';
779
+
780
+ const modal = E.modal({title: 'Send later', size: 'sm'});
781
+
782
+ /**
783
+ * @param {string|null} at
784
+ * @returns {void}
785
+ */
786
+ const choose = (at) => {
787
+ scheduledAt = at;
788
+ paintSend();
789
+ modal.close();
790
+ };
791
+
792
+ for (const option of quickTimes()) {
793
+ const button = document.createElement('button');
794
+ button.className = 'btn btn-sm btn-ghost';
795
+ button.textContent = option.label;
796
+ button.title = new Date(option.at).toLocaleString();
797
+ button.addEventListener('click', () => choose(option.at));
798
+ choices.appendChild(button);
799
+ }
800
+ panel.appendChild(choices);
801
+
802
+ const exactLabel = document.createElement('label');
803
+ exactLabel.className = 'form-label';
804
+ exactLabel.textContent = 'Or pick a time';
805
+ const exact = document.createElement('input');
806
+ exact.type = 'datetime-local';
807
+ exact.className = 'form-input';
808
+ panel.appendChild(exactLabel);
809
+ panel.appendChild(exact);
810
+
811
+ const row = document.createElement('div');
812
+ row.className = 'flex justify-end gap-2 mt-2';
813
+
814
+ if (scheduledAt) {
815
+ const clear = document.createElement('button');
816
+ clear.className = 'btn btn-sm btn-ghost';
817
+ clear.textContent = 'Send normally instead';
818
+ clear.addEventListener('click', () => choose(null));
819
+ row.appendChild(clear);
820
+ }
821
+
822
+ const confirm = document.createElement('button');
823
+ confirm.className = 'btn btn-sm btn-primary';
824
+ confirm.textContent = 'Use this time';
825
+ confirm.addEventListener('click', () => {
826
+ if (!exact.value) return;
827
+ // `datetime-local` has no zone, so it is read in the browser's -
828
+ // which is the one the person picking it is in.
829
+ choose(new Date(exact.value).toISOString());
830
+ });
831
+ row.appendChild(confirm);
832
+ panel.appendChild(row);
833
+
834
+ modal.element.appendChild(panel);
835
+ modal.open();
836
+ }
837
+
838
+ const later = document.createElement('button');
839
+ later.className = 'btn btn-sm btn-ghost';
840
+ later.textContent = 'Later\u2026';
841
+ later.title = 'Send this at a time you choose';
842
+ later.addEventListener('click', openScheduler);
843
+
844
+ send.addEventListener('click', async () => {
845
+ send.disabled = true;
846
+ say(scheduledAt ? 'Scheduling\u2026' : 'Sending\u2026');
847
+
848
+ try {
849
+ const params = new URLSearchParams({account: accountId});
850
+ const result = await api(`${base}/send?${params}`, 'POST', {
851
+ ...collect(),
852
+ sendAt: scheduledAt,
853
+ // So a sent draft does not stay in Drafts. Saved again since
854
+ // opening, this is the latest version's UID, not the original.
855
+ replaces: savedUid
856
+ });
857
+
858
+ slideover.close();
859
+
860
+ // Three outcomes, three different things to say. A message held
861
+ // for ten seconds has NOT been sent, and saying it has is how
862
+ // someone stops trusting the word.
863
+ if (result.queued) {
864
+ onQueued(result);
865
+ } else {
866
+ // Say where the copy went, or that it did not: a sent message
867
+ // with no copy is worth knowing about before you look for it
868
+ // later.
869
+ E.toast(
870
+ result.filedTo
871
+ ? `Sent, and copied to ${result.filedTo}.`
872
+ : 'Sent. No copy could be filed in Sent.',
873
+ {type: result.filedTo ? 'success' : 'warning'}
874
+ );
875
+ onSent(result);
876
+ }
877
+ } catch (err) {
878
+ say(err.message, 'bad');
879
+ send.disabled = false;
880
+ }
881
+ });
882
+
883
+ actions.appendChild(cancel);
884
+ actions.appendChild(saveDraftBtn);
885
+ actions.appendChild(later);
886
+ actions.appendChild(send);
887
+ form.appendChild(actions);
888
+ paintSend();
889
+
890
+ slideover.element.appendChild(form);
891
+ const stopResizing = makeResizable(slideover, {
892
+ storageKey: 'domma-mail.compose-width',
893
+ defaultWidth: 700
894
+ });
895
+ paintAttachments();
896
+ slideover.open();
897
+
898
+ // A reply already knows who it is going to; a new message does not.
899
+ setTimeout(() => {
900
+ if (draft.to?.length) {
901
+ if (editor) editor.focus();
902
+ else bodyInput.focus();
903
+ } else {
904
+ toInput.focus();
905
+ }
906
+ }, 60);
907
+
908
+ // The editor is a live component with its own listeners and autosave.
909
+ slideover.on?.('close', () => {
910
+ editor?.destroy?.();
911
+ stopResizing();
912
+ for (const suggester of suggesters) suggester.destroy?.();
913
+ });
914
+ }