domma-cms 0.55.1 → 0.69.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +194 -3
- package/README.md +18 -14
- package/admin/css/admin.css +1 -1
- package/admin/css/contacts.css +1 -0
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/index.html +1 -0
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/lib/analytics-shape.js +1 -0
- package/admin/js/lib/contacts-arrange.js +1 -0
- package/admin/js/lib/help-popover.js +1 -0
- package/admin/js/lib/notes-arrange.js +1 -0
- package/admin/js/lib/page-picker.js +1 -0
- package/admin/js/lib/plugin-accent.js +1 -0
- package/admin/js/lib/plugin-chrome.js +1 -0
- package/admin/js/lib/shortcode-context-menu.js +2 -2
- package/admin/js/lib/sidebar-grouping.js +1 -1
- package/admin/js/lib/sidebar-grouping.test.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +4 -4
- package/admin/js/lib/slideover-resizable.js +1 -0
- package/admin/js/lib/todo-arrange.js +1 -0
- package/admin/js/lib/tool-kit.js +1 -0
- package/admin/js/templates/analytics.html +138 -0
- package/admin/js/templates/contacts.html +156 -0
- package/admin/js/templates/context-menu-editor.html +212 -0
- package/admin/js/templates/context-menus.html +16 -0
- package/admin/js/templates/notes.html +137 -0
- package/admin/js/templates/plugin-code.html +1 -1
- package/admin/js/templates/plugin-marketplace.html +17 -4
- package/admin/js/templates/plugins.html +13 -7
- package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
- package/admin/js/templates/settings.html +16 -97
- package/admin/js/templates/theme.html +211 -0
- package/admin/js/templates/todo.html +113 -0
- package/admin/js/views/analytics.js +62 -0
- package/admin/js/views/contacts.js +174 -0
- package/admin/js/views/context-menu-editor.js +55 -0
- package/admin/js/views/context-menus.js +5 -0
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/notes.js +104 -0
- package/admin/js/views/plugin-marketplace.js +1 -1
- package/admin/js/views/plugins.js +28 -24
- package/admin/js/views/search.js +1 -0
- package/admin/js/views/settings.js +3 -3
- package/admin/js/views/theme.js +1 -0
- package/admin/js/views/todo.js +96 -0
- package/bin/cli.js +6 -10
- package/bin/lib/plugin-version.js +28 -0
- package/bin/update.js +27 -2
- package/config/menus/admin-sidebar.json +129 -23
- package/config/plugins.json +5 -5
- package/config/search.json +13 -0
- package/config/server.json +3 -1
- package/package.json +14 -10
- package/plugins/_lib/admin/mail/compose-window.js +914 -0
- package/plugins/_lib/admin/mail/contacts.js +301 -0
- package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
- package/plugins/_lib/admin/mail/folder-tree.js +254 -0
- package/plugins/_lib/admin/mail/identity.js +480 -0
- package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
- package/plugins/_lib/admin/mail/keyboard.js +136 -0
- package/plugins/_lib/admin/mail/mail.css +1 -0
- package/plugins/_lib/admin/mail/mail.html +71 -0
- package/plugins/_lib/admin/mail/panes.js +253 -0
- package/plugins/_lib/admin/mail/reader-view.js +4473 -0
- package/plugins/_lib/admin/mail/resizable.js +26 -0
- package/plugins/_lib/admin/mail/rules.js +343 -0
- package/plugins/_lib/admin/mail/scheduling.js +203 -0
- package/plugins/_lib/admin/mail/section-kit.js +277 -0
- package/plugins/_lib/admin/mail/templates.js +238 -0
- package/plugins/_lib/admin/mail/threads.js +200 -0
- package/plugins/_lib/admin/mail/vacation.js +269 -0
- package/plugins/_lib/admin/ui/help.css +1 -0
- package/plugins/_lib/admin/ui/help.js +174 -0
- package/plugins/_lib/admin/ui/resizable.js +151 -0
- package/plugins/_lib/dataStore.js +117 -0
- package/plugins/_lib/mail/accounts.js +919 -0
- package/plugins/_lib/mail/bodyTokens.js +101 -0
- package/plugins/_lib/mail/compose.js +256 -0
- package/plugins/_lib/mail/defaults.js +57 -0
- package/plugins/_lib/mail/diagnostics.js +154 -0
- package/plugins/_lib/mail/envelope.js +161 -0
- package/plugins/_lib/mail/folders.js +192 -0
- package/plugins/_lib/mail/handoff.js +274 -0
- package/plugins/_lib/mail/imapPool.js +293 -0
- package/plugins/_lib/mail/mbox.js +74 -0
- package/plugins/_lib/mail/pollSchedule.js +79 -0
- package/plugins/_lib/mail/poller.js +135 -0
- package/plugins/_lib/mail/priority.js +138 -0
- package/plugins/_lib/mail/readRoutes.js +680 -0
- package/plugins/_lib/mail/render.js +291 -0
- package/plugins/_lib/mail/ruleRunner.js +152 -0
- package/plugins/_lib/mail/scheduler.js +254 -0
- package/plugins/_lib/mail/secretbox.js +229 -0
- package/plugins/_lib/mail/send.js +396 -0
- package/plugins/_lib/mail/store.js +1022 -0
- package/plugins/_lib/mail/sync.js +292 -0
- package/plugins/_lib/mail/syncPlan.js +126 -0
- package/plugins/_lib/mail/syncSelection.js +82 -0
- package/plugins/_lib/mail/unsubscribe.js +183 -0
- package/plugins/_lib/mail/vacationRunner.js +114 -0
- package/plugins/_lib/mail/write.js +473 -0
- package/plugins/_lib/schemaSync.js +83 -0
- package/plugins/_template/admin/css/index.css +0 -0
- package/plugins/_template/admin/templates/index.html +4 -4
- package/plugins/_template/admin/views/index.js +7 -0
- package/plugins/blog/CLAUDE.md +237 -0
- package/plugins/blog/admin/css/index.css +178 -0
- package/plugins/blog/admin/templates/blog.html +35 -41
- package/plugins/blog/admin/templates/categories.html +19 -6
- package/plugins/blog/admin/templates/comments.html +26 -9
- package/plugins/blog/admin/templates/post-editor.html +63 -40
- package/plugins/blog/admin/templates/settings.html +3 -9
- package/plugins/blog/admin/views/blog.js +288 -159
- package/plugins/blog/admin/views/categories.js +212 -206
- package/plugins/blog/admin/views/comments.js +207 -158
- package/plugins/blog/admin/views/kit.js +372 -0
- package/plugins/blog/admin/views/post-editor.js +306 -39
- package/plugins/blog/admin/views/settings-cog.js +56 -0
- package/plugins/blog/admin/views/settings.js +16 -84
- package/plugins/blog/blocks/blog-card-compact.css +17 -0
- package/plugins/blog/blocks/blog-card-compact.html +8 -0
- package/plugins/blog/blocks/blog-card-row.css +22 -0
- package/plugins/blog/blocks/blog-card-row.html +11 -0
- package/plugins/blog/blocks/blog-card.css +35 -0
- package/plugins/blog/blocks/blog-card.html +16 -0
- package/plugins/blog/blocks/blog-post-classic.css +15 -0
- package/plugins/blog/blocks/blog-post-classic.html +28 -0
- package/plugins/blog/blocks/blog-post-feature.css +20 -0
- package/plugins/blog/blocks/blog-post-feature.html +30 -0
- package/plugins/blog/blocks/blog-post-minimal.css +10 -0
- package/plugins/blog/blocks/blog-post-minimal.html +23 -0
- package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
- package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
- package/plugins/blog/collections/categories/schema.json +7 -6
- package/plugins/blog/collections/comments/schema.json +11 -10
- package/plugins/blog/collections/posts/schema.json +16 -13
- package/plugins/blog/config.js +6 -0
- package/plugins/blog/lib/layouts.js +304 -0
- package/plugins/blog/lib/page.js +158 -0
- package/plugins/blog/lib/render.js +119 -0
- package/plugins/blog/lib/samples.js +234 -0
- package/plugins/blog/plugin.js +259 -26
- package/plugins/blog/plugin.json +21 -73
- package/plugins/blog/plugin.public.js +217 -0
- package/plugins/blog/public/blog.css +177 -0
- package/plugins/blog/public/blog.js +586 -0
- package/plugins/blog/public/samples/aurora.svg +7 -0
- package/plugins/blog/public/samples/dusk.svg +7 -0
- package/plugins/blog/public/samples/ember.svg +7 -0
- package/plugins/blog/public/samples/harbour.svg +7 -0
- package/plugins/blog/public/samples/meadow.svg +7 -0
- package/plugins/blog/public/samples/rosewood.svg +7 -0
- package/plugins/blog/templates/index.html +9 -8
- package/plugins/blog/templates/post.html +4 -17
- package/plugins/blog/tests/layouts.test.js +97 -0
- package/plugins/blog/tests/public.test.js +71 -0
- package/plugins/free-tier.lock.json +74 -0
- package/plugins/mail-reader/CLAUDE.md +78 -0
- package/plugins/mail-reader/admin/views/mail.js +19 -0
- package/plugins/mail-reader/config.js +7 -0
- package/plugins/mail-reader/plugin.js +48 -0
- package/plugins/mail-reader/plugin.json +34 -0
- package/public/css/forms.css +1 -1
- package/public/css/search.css +1 -0
- package/public/css/site.css +1 -1
- package/public/css/theme-switcher.css +1 -0
- package/public/js/analytics.js +1 -0
- package/public/js/collection-context.js +2 -2
- package/public/js/context-menus.js +1 -0
- package/public/js/form-logic-engine.js +1 -1
- package/public/js/forms.js +2 -2
- package/public/js/search.js +1 -0
- package/public/js/site.js +1 -1
- package/public/js/theme-switcher.js +1 -0
- package/scripts/build.js +77 -4
- package/scripts/copy-domma.js +48 -0
- package/scripts/run-tests.mjs +92 -0
- package/scripts/seed.js +1996 -0
- package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
- package/server/routes/api/collections.js +34 -0
- package/server/routes/api/contacts.js +506 -0
- package/server/routes/api/context-menus.js +104 -0
- package/server/routes/api/dashboard.js +8 -2
- package/server/routes/api/forms.js +42 -3
- package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
- package/server/routes/api/notifications.js +69 -19
- package/server/routes/api/plugin-marketplace.js +78 -23
- package/server/routes/api/plugins.js +198 -9
- package/server/routes/api/search.js +43 -0
- package/server/routes/api/theme.js +69 -0
- package/server/routes/api/todo.js +178 -0
- package/server/routes/public.js +42 -7
- package/server/server.js +135 -1
- package/server/services/adapters/FileAdapter.js +6 -1
- package/server/services/collections.js +13 -3
- package/server/services/content.js +26 -0
- package/server/services/contextMenus.js +477 -0
- package/server/services/managerClient.js +72 -136
- package/server/services/markdown.js +124 -10
- package/server/services/permissionRegistry.js +74 -0
- package/server/services/pluginEntitlement.js +171 -0
- package/server/services/pluginEnvelope.js +242 -0
- package/server/services/pluginFiles.js +52 -11
- package/server/services/pluginInstaller.js +246 -26
- package/server/services/pluginScaffold.js +5 -0
- package/server/services/plugins.js +340 -7
- package/server/services/presetCollections.js +344 -0
- package/server/services/promoted-plugins-migration.js +254 -0
- package/server/services/publicCors.js +46 -0
- package/server/services/renderer.js +224 -22
- package/server/services/roles.js +1 -1
- package/server/services/search-migration.js +82 -0
- package/server/services/search.js +413 -0
- package/server/services/sidebar-migration.js +1 -0
- package/server/services/sidebarBadges.js +37 -0
- package/server/services/siteGitignore.js +307 -0
- package/server/services/themeSettings.js +580 -0
- package/server/services/users.js +8 -0
- package/server/templates/page.html +4 -2
- package/plugins/analytics/admin/templates/analytics.html +0 -61
- package/plugins/analytics/admin/views/analytics.js +0 -176
- package/plugins/analytics/config.js +0 -14
- package/plugins/analytics/plugin.json +0 -57
- package/plugins/analytics/public/inject-body.html +0 -60
- package/plugins/analytics/public/inject-head.html +0 -1
- package/plugins/blog/templates/author.html +0 -9
- package/plugins/blog/templates/category.html +0 -9
- package/plugins/blog/templates/tag.html +0 -9
- package/plugins/contacts/admin/templates/contacts.html +0 -126
- package/plugins/contacts/admin/views/contacts.js +0 -710
- package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
- package/plugins/contacts/collections/user-contacts/schema.json +0 -71
- package/plugins/contacts/config.js +0 -6
- package/plugins/contacts/data/contacts.json +0 -20
- package/plugins/contacts/plugin.js +0 -291
- package/plugins/contacts/plugin.json +0 -33
- package/plugins/demo-viewer/config.js +0 -4
- package/plugins/demo-viewer/plugin.js +0 -36
- package/plugins/demo-viewer/plugin.json +0 -9
- package/plugins/invoice/admin/templates/editor.html +0 -129
- package/plugins/invoice/admin/templates/index.html +0 -43
- package/plugins/invoice/admin/templates/issuers.html +0 -5
- package/plugins/invoice/admin/templates/receivers.html +0 -5
- package/plugins/invoice/admin/views/editor.js +0 -267
- package/plugins/invoice/admin/views/index.js +0 -155
- package/plugins/invoice/admin/views/issuers.js +0 -23
- package/plugins/invoice/admin/views/party-view.js +0 -148
- package/plugins/invoice/admin/views/receivers.js +0 -22
- package/plugins/invoice/collections/invoice-issuers/schema.json +0 -16
- package/plugins/invoice/collections/invoice-receivers/schema.json +0 -15
- package/plugins/invoice/collections/invoices/schema.json +0 -27
- package/plugins/invoice/config.js +0 -16
- package/plugins/invoice/plugin.js +0 -283
- package/plugins/invoice/plugin.json +0 -85
- package/plugins/invoice/templates/invoice-print.html +0 -213
- package/plugins/notes/admin/templates/notes.html +0 -83
- package/plugins/notes/admin/views/notes.js +0 -281
- package/plugins/notes/collections/user-notes/schema.json +0 -54
- package/plugins/notes/config.js +0 -6
- package/plugins/notes/data/notes.json +0 -1
- package/plugins/notes/plugin.json +0 -33
- package/plugins/site-search/admin/views/site-search.js +0 -116
- package/plugins/site-search/config.js +0 -15
- package/plugins/site-search/plugin.js +0 -188
- package/plugins/site-search/plugin.json +0 -40
- package/plugins/site-search/public/inject-body.html +0 -17
- package/plugins/site-search/public/inject-head.html +0 -1
- package/plugins/site-search/public/search.css +0 -1
- package/plugins/site-search/public/search.js +0 -1
- package/plugins/surveys/admin/templates/audience.html +0 -47
- package/plugins/surveys/admin/templates/results.html +0 -58
- package/plugins/surveys/admin/templates/survey-editor.html +0 -141
- package/plugins/surveys/admin/templates/surveys.html +0 -25
- package/plugins/surveys/admin/views/audience.js +0 -301
- package/plugins/surveys/admin/views/results.js +0 -172
- package/plugins/surveys/admin/views/survey-editor.js +0 -211
- package/plugins/surveys/admin/views/surveys.js +0 -161
- package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
- package/plugins/surveys/collections/survey-groups/schema.json +0 -11
- package/plugins/surveys/collections/survey-invites/schema.json +0 -16
- package/plugins/surveys/collections/surveys/schema.json +0 -23
- package/plugins/surveys/config.js +0 -8
- package/plugins/surveys/plugin.js +0 -174
- package/plugins/surveys/plugin.json +0 -65
- package/plugins/surveys/public/dist-shared.mjs +0 -1
- package/plugins/surveys/public/survey.css +0 -1
- package/plugins/surveys/public/survey.mjs +0 -1
- package/plugins/surveys/templates/survey-page.html +0 -63
- package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
- package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
- package/plugins/theme-switcher/config.js +0 -10
- package/plugins/theme-switcher/plugin.js +0 -26
- package/plugins/theme-switcher/plugin.json +0 -74
- package/plugins/theme-switcher/public/inject-body.html +0 -153
- package/plugins/theme-switcher/public/inject-head.html +0 -260
- package/plugins/todo/admin/templates/todo.html +0 -158
- package/plugins/todo/admin/views/todo.js +0 -343
- package/plugins/todo/collections/todos/schema.json +0 -60
- package/plugins/todo/config.js +0 -7
- package/plugins/todo/data/todos.json +0 -1
- package/plugins/todo/plugin.js +0 -102
- package/plugins/todo/plugin.json +0 -33
|
@@ -0,0 +1,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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
+
}
|