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,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Building a settings section out of Domma rather than out of `createElement`.
|
|
3
|
+
*
|
|
4
|
+
* The mail settings sections were originally hand-built DOM with inline
|
|
5
|
+
* `style.cssText` on every node - which works, and is exactly what the house
|
|
6
|
+
* rules say not to do. Domma already owns forms (`F`), reactivity (`M`),
|
|
7
|
+
* cards, badges and list groups (`E`), and a full set of layout classes; a
|
|
8
|
+
* plugin that re-implements them has five copies of the same bugs and no
|
|
9
|
+
* theme support, because inline styles cannot read `--dm-*` tokens that a
|
|
10
|
+
* theme override redefines.
|
|
11
|
+
*
|
|
12
|
+
* So a section here declares a SCHEMA and this renders it. The things a
|
|
13
|
+
* section still builds by hand are the things Domma has no component for: the
|
|
14
|
+
* rich editor host, and the rule builder's dynamic clause rows.
|
|
15
|
+
*
|
|
16
|
+
* ## The one real constraint
|
|
17
|
+
*
|
|
18
|
+
* `F.create(...).renderTo(el)` assigns `innerHTML`. Anything live inside that
|
|
19
|
+
* element - a rich editor, an autocomplete - is destroyed on every render. So
|
|
20
|
+
* a form and an editor are always SIBLINGS here, never nested, and `formIn`
|
|
21
|
+
* returns the host it rendered into so a caller can tell them apart.
|
|
22
|
+
*
|
|
23
|
+
* @module _lib/admin/mail/section-kit
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A `<div>` with Domma classes on it, appended to a parent.
|
|
28
|
+
*
|
|
29
|
+
* The workhorse. Every layout decision in a section goes through this, so
|
|
30
|
+
* there is one place that knows the class vocabulary and no section reaches
|
|
31
|
+
* for `style.cssText`.
|
|
32
|
+
*
|
|
33
|
+
* @param {HTMLElement} parent
|
|
34
|
+
* @param {string} [className]
|
|
35
|
+
* @param {string} [tag]
|
|
36
|
+
* @returns {HTMLElement}
|
|
37
|
+
*/
|
|
38
|
+
export function box(parent, className = '', tag = 'div') {
|
|
39
|
+
const el = document.createElement(tag);
|
|
40
|
+
if (className) el.className = className;
|
|
41
|
+
parent.appendChild(el);
|
|
42
|
+
return el;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A small heading between groups of fields.
|
|
47
|
+
*
|
|
48
|
+
* @param {HTMLElement} parent
|
|
49
|
+
* @param {string} text
|
|
50
|
+
* @returns {HTMLElement}
|
|
51
|
+
*/
|
|
52
|
+
export function heading(parent, text) {
|
|
53
|
+
const el = box(parent, 'mail-section-heading', 'h4');
|
|
54
|
+
el.textContent = text;
|
|
55
|
+
return el;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A line of explanation under a heading or a control.
|
|
60
|
+
*
|
|
61
|
+
* @param {HTMLElement} parent
|
|
62
|
+
* @param {string} text
|
|
63
|
+
* @returns {HTMLElement}
|
|
64
|
+
*/
|
|
65
|
+
export function note(parent, text) {
|
|
66
|
+
const el = box(parent, 'text-sm text-muted mb-2', 'p');
|
|
67
|
+
el.textContent = text;
|
|
68
|
+
return el;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Render a Domma form from a schema, and hand back its reactive state.
|
|
73
|
+
*
|
|
74
|
+
* `state` is the model's tracked proxy: reading a field inside `M.effect`
|
|
75
|
+
* subscribes to it, and the form's own change handling writes through the
|
|
76
|
+
* same model - so a summary line that reads `state.subject` re-renders itself
|
|
77
|
+
* when somebody types, with nothing to wire up and nothing to remember to
|
|
78
|
+
* call.
|
|
79
|
+
*
|
|
80
|
+
* @param {HTMLElement} parent
|
|
81
|
+
* @param {object} schema - Forma schema, keyed BY FIELD NAME (not an array)
|
|
82
|
+
* @param {object} data
|
|
83
|
+
* @param {object} [options] - passed to F.create
|
|
84
|
+
* @returns {{form: object, state: object, host: HTMLElement}}
|
|
85
|
+
*/
|
|
86
|
+
export function formIn(parent, schema, data = {}, options = {}) {
|
|
87
|
+
const host = box(parent, 'mail-section-form');
|
|
88
|
+
const form = F.create(schema, data, {
|
|
89
|
+
// Labels above, one column: a settings slideover is narrow, and a two
|
|
90
|
+
// column grid puts a hint under a field it does not belong to.
|
|
91
|
+
layout: 'stacked',
|
|
92
|
+
labelPosition: 'top',
|
|
93
|
+
validationMode: 'change',
|
|
94
|
+
// Forma adds its own "Submit" unless told not to. Every section here
|
|
95
|
+
// has a Save in its action bar - and several have more than one form
|
|
96
|
+
// on screen at once, so the default would put a Submit button after
|
|
97
|
+
// each group of fields.
|
|
98
|
+
showSubmitButton: false,
|
|
99
|
+
...options
|
|
100
|
+
});
|
|
101
|
+
form.renderTo(host);
|
|
102
|
+
return {form, state: form.model.tracked(), host};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* One item in a list of them, as a Domma card.
|
|
107
|
+
*
|
|
108
|
+
* Returns the body to fill and the header actions area, so a caller never
|
|
109
|
+
* touches the card's own structure.
|
|
110
|
+
*
|
|
111
|
+
* @param {HTMLElement} parent
|
|
112
|
+
* @param {{title: string, badge?: string, badgeVariant?: string}} options
|
|
113
|
+
* @returns {{card: HTMLElement, head: HTMLElement, title: HTMLElement, body: HTMLElement}}
|
|
114
|
+
*/
|
|
115
|
+
export function itemCard(parent, {title, badge = '', badgeVariant = 'primary'}) {
|
|
116
|
+
const card = box(parent, 'card mail-item-card');
|
|
117
|
+
|
|
118
|
+
const head = box(card, 'card-header flex items-center gap-2');
|
|
119
|
+
const titleEl = box(head, 'flex-1 font-semibold text-sm mail-ellipsis', 'span');
|
|
120
|
+
titleEl.textContent = title;
|
|
121
|
+
|
|
122
|
+
if (badge) {
|
|
123
|
+
const badgeEl = box(head, `badge badge-${badgeVariant}`, 'span');
|
|
124
|
+
badgeEl.textContent = badge;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const body = box(card, 'card-body flex flex-col gap-2');
|
|
128
|
+
return {card, head, title: titleEl, body};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* A button, with Domma's classes and nothing inline.
|
|
133
|
+
*
|
|
134
|
+
* @param {HTMLElement} parent
|
|
135
|
+
* @param {string} label
|
|
136
|
+
* @param {Function} onClick
|
|
137
|
+
* @param {{variant?: string, title?: string, icon?: string}} [options]
|
|
138
|
+
* @returns {HTMLButtonElement}
|
|
139
|
+
*/
|
|
140
|
+
export function button(parent, label, onClick, {variant = 'ghost', title = '', icon = ''} = {}) {
|
|
141
|
+
const el = box(parent, `btn btn-sm btn-${variant}`, 'button');
|
|
142
|
+
el.type = 'button';
|
|
143
|
+
if (title) el.title = title;
|
|
144
|
+
|
|
145
|
+
if (icon) {
|
|
146
|
+
// Glyph in a CHILD, then scan the parent. `Domma.icons.scan` REPLACES
|
|
147
|
+
// the element carrying `data-icon`, so putting it on the button itself
|
|
148
|
+
// swaps the button out from under this listener.
|
|
149
|
+
const glyph = box(el, '', 'span');
|
|
150
|
+
glyph.setAttribute('data-icon', icon);
|
|
151
|
+
glyph.setAttribute('data-icon-size', '13');
|
|
152
|
+
}
|
|
153
|
+
if (label) el.appendChild(document.createTextNode(icon ? ` ${label}` : label));
|
|
154
|
+
|
|
155
|
+
el.addEventListener('click', onClick);
|
|
156
|
+
if (icon) Domma.icons.scan(el);
|
|
157
|
+
return el;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The row of buttons at the foot of a section, with a status line.
|
|
162
|
+
*
|
|
163
|
+
* The status line is the only feedback most of these screens give, so it is
|
|
164
|
+
* part of the furniture rather than something each section invents.
|
|
165
|
+
*
|
|
166
|
+
* @param {HTMLElement} parent
|
|
167
|
+
* @returns {{row: HTMLElement, say: Function, busy: Function}}
|
|
168
|
+
*/
|
|
169
|
+
export function actionBar(parent) {
|
|
170
|
+
const row = box(parent, 'flex items-center gap-2 mt-4');
|
|
171
|
+
const status = box(row, 'flex-1 text-sm mail-status');
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
row,
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* @param {string} text
|
|
178
|
+
* @param {'ok'|'bad'|'quiet'} [tone]
|
|
179
|
+
* @returns {void}
|
|
180
|
+
*/
|
|
181
|
+
say(text, tone = 'quiet') {
|
|
182
|
+
status.textContent = text ?? '';
|
|
183
|
+
status.className = 'flex-1 text-sm mail-status '
|
|
184
|
+
+ ({ok: 'text-success', bad: 'text-danger'}[tone] ?? 'text-muted');
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Disable every button in the bar while something is in flight.
|
|
189
|
+
*
|
|
190
|
+
* @param {boolean} on
|
|
191
|
+
* @returns {void}
|
|
192
|
+
*/
|
|
193
|
+
busy(on) {
|
|
194
|
+
for (const el of row.querySelectorAll('button')) el.disabled = on;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* A rich editor, with a plain textarea where the component is absent.
|
|
201
|
+
*
|
|
202
|
+
* Kept out of `formIn` deliberately: `renderTo` assigns `innerHTML`, so an
|
|
203
|
+
* editor rendered inside a Forma-owned element is destroyed the next time
|
|
204
|
+
* that form renders.
|
|
205
|
+
*
|
|
206
|
+
* @param {HTMLElement} parent
|
|
207
|
+
* @param {string} html
|
|
208
|
+
* @param {{height?: string, text?: string}} [options]
|
|
209
|
+
* @returns {{get: Function, getText: Function, set: Function, destroy: Function, rich: boolean}}
|
|
210
|
+
*/
|
|
211
|
+
export function richEditor(parent, html, {height = '10rem', text = ''} = {}) {
|
|
212
|
+
const host = box(parent, 'mail-editor-host');
|
|
213
|
+
|
|
214
|
+
if (typeof E?.editor === 'function') {
|
|
215
|
+
try {
|
|
216
|
+
const editor = E.editor(host, {mode: 'rich', height});
|
|
217
|
+
editor.setValue(html ?? '');
|
|
218
|
+
return {
|
|
219
|
+
rich: true,
|
|
220
|
+
get: () => editor.getValue(),
|
|
221
|
+
getText: () => editor.getText(),
|
|
222
|
+
set: value => editor.setValue(value ?? ''),
|
|
223
|
+
destroy: () => editor.destroy?.()
|
|
224
|
+
};
|
|
225
|
+
} catch {
|
|
226
|
+
host.textContent = '';
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// The fallback shows the TEXT it came from rather than markup to
|
|
231
|
+
// hand-edit: without the editor, raw HTML in a textarea is not something
|
|
232
|
+
// anybody can reasonably maintain.
|
|
233
|
+
const area = box(host, 'form-textarea mail-editor-fallback', 'textarea');
|
|
234
|
+
area.value = text ?? '';
|
|
235
|
+
return {
|
|
236
|
+
rich: false,
|
|
237
|
+
get: () => area.value,
|
|
238
|
+
getText: () => area.value,
|
|
239
|
+
set: value => { area.value = value ?? ''; },
|
|
240
|
+
destroy: () => {}
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* A `<select>` with Domma's class and a list of options.
|
|
246
|
+
*
|
|
247
|
+
* Used where a select is a CONTROL rather than a form field - a picker that
|
|
248
|
+
* acts immediately, which is not something a schema describes.
|
|
249
|
+
*
|
|
250
|
+
* @param {HTMLElement} parent
|
|
251
|
+
* @param {Array<{value: string, label: string}>} options
|
|
252
|
+
* @param {string} [value]
|
|
253
|
+
* @returns {HTMLSelectElement}
|
|
254
|
+
*/
|
|
255
|
+
export function select(parent, options, value) {
|
|
256
|
+
const el = box(parent, 'form-select form-select-sm', 'select');
|
|
257
|
+
for (const option of options) {
|
|
258
|
+
const node = box(el, '', 'option');
|
|
259
|
+
node.value = option.value;
|
|
260
|
+
node.textContent = option.label;
|
|
261
|
+
}
|
|
262
|
+
if (value !== undefined) el.value = value;
|
|
263
|
+
return el;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* An empty-state line, in the same voice everywhere.
|
|
268
|
+
*
|
|
269
|
+
* @param {HTMLElement} parent
|
|
270
|
+
* @param {string} text
|
|
271
|
+
* @returns {HTMLElement}
|
|
272
|
+
*/
|
|
273
|
+
export function empty(parent, text) {
|
|
274
|
+
const el = box(parent, 'text-sm text-muted mail-empty-line', 'p');
|
|
275
|
+
el.textContent = text;
|
|
276
|
+
return el;
|
|
277
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canned responses.
|
|
3
|
+
*
|
|
4
|
+
* A template is a signature with a subject on it, and it is kept deliberately
|
|
5
|
+
* close to one: the same editor writes it, the same sanitiser cleans it, and
|
|
6
|
+
* the same derived-text rule applies, so a mailbox has one idea of "stored
|
|
7
|
+
* markup I wrote and will send" rather than two that drift.
|
|
8
|
+
*
|
|
9
|
+
* ## Why this sits under `admin/`
|
|
10
|
+
*
|
|
11
|
+
* The same reason `identity.js` does - the static mount serves
|
|
12
|
+
* `{plugin}/{admin|public}/**` and nothing else, and both the compose window
|
|
13
|
+
* and the store need this. See the note at the top of `identity.js`.
|
|
14
|
+
*
|
|
15
|
+
* ## Placeholders
|
|
16
|
+
*
|
|
17
|
+
* A template that cannot say the recipient's name is a template people stop
|
|
18
|
+
* using after the second time they retype it. The token set is small and
|
|
19
|
+
* fixed: anything bigger becomes a language, and a language in a text box is
|
|
20
|
+
* a support burden.
|
|
21
|
+
*
|
|
22
|
+
* An UNRECOGNISED token is left exactly as it was written. Silently emptying
|
|
23
|
+
* it would send `Dear ,` to a customer; leaving `{{to.frist}}` visible means
|
|
24
|
+
* the mistake is found while the message is still in the window.
|
|
25
|
+
*
|
|
26
|
+
* @module _lib/admin/mail/templates
|
|
27
|
+
*/
|
|
28
|
+
import {signatureToText, textToHtml} from './identity.js';
|
|
29
|
+
|
|
30
|
+
/** How many templates one mailbox may hold. */
|
|
31
|
+
export const MAX_TEMPLATES = 50;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The tokens a template may use, and what each one means.
|
|
35
|
+
*
|
|
36
|
+
* Exported because the settings screen lists them: a placeholder feature
|
|
37
|
+
* whose placeholders are only documented in a comment is a placeholder
|
|
38
|
+
* feature nobody discovers.
|
|
39
|
+
*/
|
|
40
|
+
export const TEMPLATE_TOKENS = [
|
|
41
|
+
{token: '{{to.name}}', describes: "The first recipient's name, or their address if it has none"},
|
|
42
|
+
{token: '{{to.first}}', describes: "The first recipient's first name"},
|
|
43
|
+
{token: '{{to.email}}', describes: "The first recipient's address"},
|
|
44
|
+
{token: '{{my.name}}', describes: 'Your display name on this identity'},
|
|
45
|
+
{token: '{{my.email}}', describes: 'The address this message sends from'},
|
|
46
|
+
{token: '{{subject}}', describes: "The message's subject, as it currently stands"},
|
|
47
|
+
{token: '{{date}}', describes: "Today's date"},
|
|
48
|
+
{token: '{{time}}', describes: 'The time now'}
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Strip anything that would end a header line.
|
|
53
|
+
*
|
|
54
|
+
* A template's subject is interpolated into the Subject header, so it is held
|
|
55
|
+
* to the same rule the display name is - a newline there is a second header.
|
|
56
|
+
*
|
|
57
|
+
* @param {*} value
|
|
58
|
+
* @returns {string}
|
|
59
|
+
*/
|
|
60
|
+
function headerSafe(value) {
|
|
61
|
+
return String(value ?? '').replace(/[\r\n]+/g, ' ').trim();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A stable id for a template. See the note on `newId` in `identity.js`.
|
|
66
|
+
*
|
|
67
|
+
* @returns {string}
|
|
68
|
+
*/
|
|
69
|
+
function newId() {
|
|
70
|
+
return `tpl-${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36).slice(-4)}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Put one stored template into shape.
|
|
75
|
+
*
|
|
76
|
+
* Whitelisted rather than merged, for the same reason identities are: this
|
|
77
|
+
* markup is embedded in outgoing mail and handed back to an editor that sets
|
|
78
|
+
* it as innerHTML, so an unexpected key must not survive the round trip.
|
|
79
|
+
*
|
|
80
|
+
* @param {object} input
|
|
81
|
+
* @param {number} index
|
|
82
|
+
* @returns {object}
|
|
83
|
+
*/
|
|
84
|
+
export function normaliseTemplate(input, index = 0) {
|
|
85
|
+
const source = input && typeof input === 'object' ? input : {};
|
|
86
|
+
|
|
87
|
+
const bodyHtml = String(source.bodyHtml ?? '').trim();
|
|
88
|
+
// Derived, never accepted from the caller - the same rule the signature
|
|
89
|
+
// text follows, and for the same reason: two copies of one piece of
|
|
90
|
+
// writing is two copies to keep in step, and nobody does.
|
|
91
|
+
const bodyText = bodyHtml
|
|
92
|
+
? signatureToText(bodyHtml)
|
|
93
|
+
: String(source.bodyText ?? '').trim();
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
id: typeof source.id === 'string' && source.id.trim() ? source.id.trim() : newId(),
|
|
97
|
+
name: headerSafe(source.name) || `Template ${index + 1}`,
|
|
98
|
+
// Optional. A template that only supplies a body is the common case -
|
|
99
|
+
// it is being dropped into a reply that already has its subject.
|
|
100
|
+
subject: headerSafe(source.subject),
|
|
101
|
+
bodyHtml,
|
|
102
|
+
bodyText
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Every template a mailbox holds, in the order they were arranged.
|
|
108
|
+
*
|
|
109
|
+
* @param {object|null|undefined} account
|
|
110
|
+
* @returns {object[]}
|
|
111
|
+
*/
|
|
112
|
+
export function normaliseTemplates(account) {
|
|
113
|
+
const source = account && typeof account === 'object' ? account : {};
|
|
114
|
+
const raw = Array.isArray(source.templates) ? source.templates : [];
|
|
115
|
+
const list = raw.slice(0, MAX_TEMPLATES).map(normaliseTemplate);
|
|
116
|
+
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
for (const entry of list) {
|
|
119
|
+
while (seen.has(entry.id)) entry.id = newId();
|
|
120
|
+
seen.add(entry.id);
|
|
121
|
+
}
|
|
122
|
+
return list;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The values a template's tokens are filled from.
|
|
127
|
+
*
|
|
128
|
+
* @param {{to?: object[], subject?: string, identity?: object|null,
|
|
129
|
+
* fromAddress?: string, now?: Date}} context
|
|
130
|
+
* @returns {Object.<string, string>}
|
|
131
|
+
*/
|
|
132
|
+
export function templateValues({to = [], subject = '', identity = null, fromAddress = '', now = new Date()} = {}) {
|
|
133
|
+
const first = (to ?? [])[0] ?? {};
|
|
134
|
+
const name = String(first.name ?? '').trim();
|
|
135
|
+
const email = String(first.address ?? '').trim();
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
'to.name': name || email,
|
|
139
|
+
// The part before the first space, which is what a greeting wants.
|
|
140
|
+
// Falls back to the whole name rather than to nothing, because a
|
|
141
|
+
// one-word name is a name.
|
|
142
|
+
'to.first': (name.split(/\s+/)[0] || '').trim() || name || email,
|
|
143
|
+
'to.email': email,
|
|
144
|
+
'my.name': String(identity?.displayName ?? '').trim(),
|
|
145
|
+
'my.email': String(identity?.address || fromAddress || '').trim(),
|
|
146
|
+
subject: String(subject ?? '').trim(),
|
|
147
|
+
// The reader's own locale, not the server's - a template written in
|
|
148
|
+
// London and read in London should not say 9/20/2026.
|
|
149
|
+
date: now.toLocaleDateString(),
|
|
150
|
+
time: now.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Escape text for interpolation into HTML.
|
|
156
|
+
*
|
|
157
|
+
* Every value substituted into the HTML body goes through this. A recipient
|
|
158
|
+
* whose display name is `<script>` is not a likely attack, but it is a
|
|
159
|
+
* likely way to break the markup of every message sent to them.
|
|
160
|
+
*
|
|
161
|
+
* @param {*} value
|
|
162
|
+
* @returns {string}
|
|
163
|
+
*/
|
|
164
|
+
function escapeHtml(value) {
|
|
165
|
+
return String(value ?? '')
|
|
166
|
+
.replace(/&/g, '&')
|
|
167
|
+
.replace(/</g, '<')
|
|
168
|
+
.replace(/>/g, '>')
|
|
169
|
+
.replace(/"/g, '"');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Substitute the tokens in one string.
|
|
174
|
+
*
|
|
175
|
+
* @param {string} text
|
|
176
|
+
* @param {Object.<string, string>} values
|
|
177
|
+
* @param {(value: string) => string} escape
|
|
178
|
+
* @returns {string}
|
|
179
|
+
*/
|
|
180
|
+
function fill(text, values, escape) {
|
|
181
|
+
return String(text ?? '').replace(/\{\{\s*([a-z][a-z.]*)\s*\}\}/gi, (whole, token) => {
|
|
182
|
+
const key = token.toLowerCase();
|
|
183
|
+
// Left alone when it is not one of ours. See the module note: a typo
|
|
184
|
+
// that shows is a typo that gets fixed before it is sent.
|
|
185
|
+
return Object.prototype.hasOwnProperty.call(values, key) ? escape(values[key]) : whole;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* A template, with its placeholders filled in.
|
|
191
|
+
*
|
|
192
|
+
* @param {object} template
|
|
193
|
+
* @param {object} [context] - passed to `templateValues`
|
|
194
|
+
* @returns {{subject: string, html: string, text: string}}
|
|
195
|
+
*/
|
|
196
|
+
export function expandTemplate(template, context = {}) {
|
|
197
|
+
const entry = normaliseTemplate(template);
|
|
198
|
+
const values = templateValues(context);
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
subject: fill(entry.subject, values, v => v),
|
|
202
|
+
html: fill(entry.bodyHtml, values, escapeHtml),
|
|
203
|
+
text: fill(entry.bodyText, values, v => v)
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Put an expanded template into a draft.
|
|
209
|
+
*
|
|
210
|
+
* Inserted ABOVE whatever is already there rather than replacing it. A
|
|
211
|
+
* template is nearly always dropped into a reply, and a reply already holds
|
|
212
|
+
* the quoted message and - depending on the identity - a signature. Replacing
|
|
213
|
+
* would throw both away; appending would put the greeting under the quote.
|
|
214
|
+
*
|
|
215
|
+
* An empty body is the one case where it simply becomes the body, because
|
|
216
|
+
* there is nothing to preserve and the leading blank line would be the only
|
|
217
|
+
* thing the insertion achieved.
|
|
218
|
+
*
|
|
219
|
+
* The subject is only taken when the draft has none. A template that
|
|
220
|
+
* overwrote a reply's `Re:` line would be renaming someone else's thread.
|
|
221
|
+
*
|
|
222
|
+
* @param {{subject?: string, html?: string, body?: string}} draft
|
|
223
|
+
* @param {{subject: string, html: string, text: string}} expanded
|
|
224
|
+
* @returns {object}
|
|
225
|
+
*/
|
|
226
|
+
export function applyTemplate(draft, expanded) {
|
|
227
|
+
const existingHtml = String(draft?.html ?? '');
|
|
228
|
+
const existingText = String(draft?.body ?? '');
|
|
229
|
+
const html = expanded.html || textToHtml(expanded.text);
|
|
230
|
+
const empty = !existingHtml.trim() && !existingText.trim();
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
...draft,
|
|
234
|
+
subject: String(draft?.subject ?? '').trim() || expanded.subject,
|
|
235
|
+
html: empty ? html : `${html}${existingHtml}`,
|
|
236
|
+
body: empty ? expanded.text : `${expanded.text}\n\n${existingText}`
|
|
237
|
+
};
|
|
238
|
+
}
|