domma-cms 0.55.1 → 0.69.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (305) hide show
  1. package/CLAUDE.md +194 -3
  2. package/README.md +18 -14
  3. package/admin/css/admin.css +1 -1
  4. package/admin/css/contacts.css +1 -0
  5. package/admin/dist/domma/domma-tools.css +3 -3
  6. package/admin/dist/domma/domma-tools.min.js +3 -3
  7. package/admin/index.html +1 -0
  8. package/admin/js/api.js +1 -1
  9. package/admin/js/app.js +4 -4
  10. package/admin/js/lib/analytics-shape.js +1 -0
  11. package/admin/js/lib/contacts-arrange.js +1 -0
  12. package/admin/js/lib/help-popover.js +1 -0
  13. package/admin/js/lib/notes-arrange.js +1 -0
  14. package/admin/js/lib/page-picker.js +1 -0
  15. package/admin/js/lib/plugin-accent.js +1 -0
  16. package/admin/js/lib/plugin-chrome.js +1 -0
  17. package/admin/js/lib/shortcode-context-menu.js +2 -2
  18. package/admin/js/lib/sidebar-grouping.js +1 -1
  19. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  20. package/admin/js/lib/sidebar-renderer.js +4 -4
  21. package/admin/js/lib/slideover-resizable.js +1 -0
  22. package/admin/js/lib/todo-arrange.js +1 -0
  23. package/admin/js/lib/tool-kit.js +1 -0
  24. package/admin/js/templates/analytics.html +138 -0
  25. package/admin/js/templates/contacts.html +156 -0
  26. package/admin/js/templates/context-menu-editor.html +212 -0
  27. package/admin/js/templates/context-menus.html +16 -0
  28. package/admin/js/templates/notes.html +137 -0
  29. package/admin/js/templates/plugin-code.html +1 -1
  30. package/admin/js/templates/plugin-marketplace.html +17 -4
  31. package/admin/js/templates/plugins.html +13 -7
  32. package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
  33. package/admin/js/templates/settings.html +16 -97
  34. package/admin/js/templates/theme.html +211 -0
  35. package/admin/js/templates/todo.html +113 -0
  36. package/admin/js/views/analytics.js +62 -0
  37. package/admin/js/views/contacts.js +174 -0
  38. package/admin/js/views/context-menu-editor.js +55 -0
  39. package/admin/js/views/context-menus.js +5 -0
  40. package/admin/js/views/form-editor.js +7 -7
  41. package/admin/js/views/index.js +1 -1
  42. package/admin/js/views/notes.js +104 -0
  43. package/admin/js/views/plugin-marketplace.js +1 -1
  44. package/admin/js/views/plugins.js +28 -24
  45. package/admin/js/views/search.js +1 -0
  46. package/admin/js/views/settings.js +3 -3
  47. package/admin/js/views/theme.js +1 -0
  48. package/admin/js/views/todo.js +96 -0
  49. package/bin/cli.js +6 -10
  50. package/bin/lib/plugin-version.js +28 -0
  51. package/bin/update.js +27 -2
  52. package/config/menus/admin-sidebar.json +129 -23
  53. package/config/plugins.json +5 -5
  54. package/config/search.json +13 -0
  55. package/config/server.json +3 -1
  56. package/package.json +14 -10
  57. package/plugins/_lib/admin/mail/compose-window.js +914 -0
  58. package/plugins/_lib/admin/mail/contacts.js +301 -0
  59. package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
  60. package/plugins/_lib/admin/mail/folder-tree.js +254 -0
  61. package/plugins/_lib/admin/mail/identity.js +480 -0
  62. package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
  63. package/plugins/_lib/admin/mail/keyboard.js +136 -0
  64. package/plugins/_lib/admin/mail/mail.css +1 -0
  65. package/plugins/_lib/admin/mail/mail.html +71 -0
  66. package/plugins/_lib/admin/mail/panes.js +253 -0
  67. package/plugins/_lib/admin/mail/reader-view.js +4473 -0
  68. package/plugins/_lib/admin/mail/resizable.js +26 -0
  69. package/plugins/_lib/admin/mail/rules.js +343 -0
  70. package/plugins/_lib/admin/mail/scheduling.js +203 -0
  71. package/plugins/_lib/admin/mail/section-kit.js +277 -0
  72. package/plugins/_lib/admin/mail/templates.js +238 -0
  73. package/plugins/_lib/admin/mail/threads.js +200 -0
  74. package/plugins/_lib/admin/mail/vacation.js +269 -0
  75. package/plugins/_lib/admin/ui/help.css +1 -0
  76. package/plugins/_lib/admin/ui/help.js +174 -0
  77. package/plugins/_lib/admin/ui/resizable.js +151 -0
  78. package/plugins/_lib/dataStore.js +117 -0
  79. package/plugins/_lib/mail/accounts.js +919 -0
  80. package/plugins/_lib/mail/bodyTokens.js +101 -0
  81. package/plugins/_lib/mail/compose.js +256 -0
  82. package/plugins/_lib/mail/defaults.js +57 -0
  83. package/plugins/_lib/mail/diagnostics.js +154 -0
  84. package/plugins/_lib/mail/envelope.js +161 -0
  85. package/plugins/_lib/mail/folders.js +192 -0
  86. package/plugins/_lib/mail/handoff.js +274 -0
  87. package/plugins/_lib/mail/imapPool.js +293 -0
  88. package/plugins/_lib/mail/mbox.js +74 -0
  89. package/plugins/_lib/mail/pollSchedule.js +79 -0
  90. package/plugins/_lib/mail/poller.js +135 -0
  91. package/plugins/_lib/mail/priority.js +138 -0
  92. package/plugins/_lib/mail/readRoutes.js +680 -0
  93. package/plugins/_lib/mail/render.js +291 -0
  94. package/plugins/_lib/mail/ruleRunner.js +152 -0
  95. package/plugins/_lib/mail/scheduler.js +254 -0
  96. package/plugins/_lib/mail/secretbox.js +229 -0
  97. package/plugins/_lib/mail/send.js +396 -0
  98. package/plugins/_lib/mail/store.js +1022 -0
  99. package/plugins/_lib/mail/sync.js +292 -0
  100. package/plugins/_lib/mail/syncPlan.js +126 -0
  101. package/plugins/_lib/mail/syncSelection.js +82 -0
  102. package/plugins/_lib/mail/unsubscribe.js +183 -0
  103. package/plugins/_lib/mail/vacationRunner.js +114 -0
  104. package/plugins/_lib/mail/write.js +473 -0
  105. package/plugins/_lib/schemaSync.js +83 -0
  106. package/plugins/_template/admin/css/index.css +0 -0
  107. package/plugins/_template/admin/templates/index.html +4 -4
  108. package/plugins/_template/admin/views/index.js +7 -0
  109. package/plugins/blog/CLAUDE.md +237 -0
  110. package/plugins/blog/admin/css/index.css +178 -0
  111. package/plugins/blog/admin/templates/blog.html +35 -41
  112. package/plugins/blog/admin/templates/categories.html +19 -6
  113. package/plugins/blog/admin/templates/comments.html +26 -9
  114. package/plugins/blog/admin/templates/post-editor.html +63 -40
  115. package/plugins/blog/admin/templates/settings.html +3 -9
  116. package/plugins/blog/admin/views/blog.js +288 -159
  117. package/plugins/blog/admin/views/categories.js +212 -206
  118. package/plugins/blog/admin/views/comments.js +207 -158
  119. package/plugins/blog/admin/views/kit.js +372 -0
  120. package/plugins/blog/admin/views/post-editor.js +306 -39
  121. package/plugins/blog/admin/views/settings-cog.js +56 -0
  122. package/plugins/blog/admin/views/settings.js +16 -84
  123. package/plugins/blog/blocks/blog-card-compact.css +17 -0
  124. package/plugins/blog/blocks/blog-card-compact.html +8 -0
  125. package/plugins/blog/blocks/blog-card-row.css +22 -0
  126. package/plugins/blog/blocks/blog-card-row.html +11 -0
  127. package/plugins/blog/blocks/blog-card.css +35 -0
  128. package/plugins/blog/blocks/blog-card.html +16 -0
  129. package/plugins/blog/blocks/blog-post-classic.css +15 -0
  130. package/plugins/blog/blocks/blog-post-classic.html +28 -0
  131. package/plugins/blog/blocks/blog-post-feature.css +20 -0
  132. package/plugins/blog/blocks/blog-post-feature.html +30 -0
  133. package/plugins/blog/blocks/blog-post-minimal.css +10 -0
  134. package/plugins/blog/blocks/blog-post-minimal.html +23 -0
  135. package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
  136. package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
  137. package/plugins/blog/collections/categories/schema.json +7 -6
  138. package/plugins/blog/collections/comments/schema.json +11 -10
  139. package/plugins/blog/collections/posts/schema.json +16 -13
  140. package/plugins/blog/config.js +6 -0
  141. package/plugins/blog/lib/layouts.js +304 -0
  142. package/plugins/blog/lib/page.js +158 -0
  143. package/plugins/blog/lib/render.js +119 -0
  144. package/plugins/blog/lib/samples.js +234 -0
  145. package/plugins/blog/plugin.js +259 -26
  146. package/plugins/blog/plugin.json +21 -73
  147. package/plugins/blog/plugin.public.js +217 -0
  148. package/plugins/blog/public/blog.css +177 -0
  149. package/plugins/blog/public/blog.js +586 -0
  150. package/plugins/blog/public/samples/aurora.svg +7 -0
  151. package/plugins/blog/public/samples/dusk.svg +7 -0
  152. package/plugins/blog/public/samples/ember.svg +7 -0
  153. package/plugins/blog/public/samples/harbour.svg +7 -0
  154. package/plugins/blog/public/samples/meadow.svg +7 -0
  155. package/plugins/blog/public/samples/rosewood.svg +7 -0
  156. package/plugins/blog/templates/index.html +9 -8
  157. package/plugins/blog/templates/post.html +4 -17
  158. package/plugins/blog/tests/layouts.test.js +97 -0
  159. package/plugins/blog/tests/public.test.js +71 -0
  160. package/plugins/free-tier.lock.json +74 -0
  161. package/plugins/mail-reader/CLAUDE.md +78 -0
  162. package/plugins/mail-reader/admin/views/mail.js +19 -0
  163. package/plugins/mail-reader/config.js +7 -0
  164. package/plugins/mail-reader/plugin.js +48 -0
  165. package/plugins/mail-reader/plugin.json +34 -0
  166. package/public/css/forms.css +1 -1
  167. package/public/css/search.css +1 -0
  168. package/public/css/site.css +1 -1
  169. package/public/css/theme-switcher.css +1 -0
  170. package/public/js/analytics.js +1 -0
  171. package/public/js/collection-context.js +2 -2
  172. package/public/js/context-menus.js +1 -0
  173. package/public/js/form-logic-engine.js +1 -1
  174. package/public/js/forms.js +2 -2
  175. package/public/js/search.js +1 -0
  176. package/public/js/site.js +1 -1
  177. package/public/js/theme-switcher.js +1 -0
  178. package/scripts/build.js +77 -4
  179. package/scripts/copy-domma.js +48 -0
  180. package/scripts/run-tests.mjs +92 -0
  181. package/scripts/seed.js +1996 -0
  182. package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
  183. package/server/routes/api/collections.js +34 -0
  184. package/server/routes/api/contacts.js +506 -0
  185. package/server/routes/api/context-menus.js +104 -0
  186. package/server/routes/api/dashboard.js +8 -2
  187. package/server/routes/api/forms.js +42 -3
  188. package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
  189. package/server/routes/api/notifications.js +69 -19
  190. package/server/routes/api/plugin-marketplace.js +78 -23
  191. package/server/routes/api/plugins.js +198 -9
  192. package/server/routes/api/search.js +43 -0
  193. package/server/routes/api/theme.js +69 -0
  194. package/server/routes/api/todo.js +178 -0
  195. package/server/routes/public.js +42 -7
  196. package/server/server.js +135 -1
  197. package/server/services/adapters/FileAdapter.js +6 -1
  198. package/server/services/collections.js +13 -3
  199. package/server/services/content.js +26 -0
  200. package/server/services/contextMenus.js +477 -0
  201. package/server/services/managerClient.js +72 -136
  202. package/server/services/markdown.js +124 -10
  203. package/server/services/permissionRegistry.js +74 -0
  204. package/server/services/pluginEntitlement.js +171 -0
  205. package/server/services/pluginEnvelope.js +242 -0
  206. package/server/services/pluginFiles.js +52 -11
  207. package/server/services/pluginInstaller.js +246 -26
  208. package/server/services/pluginScaffold.js +5 -0
  209. package/server/services/plugins.js +340 -7
  210. package/server/services/presetCollections.js +344 -0
  211. package/server/services/promoted-plugins-migration.js +254 -0
  212. package/server/services/publicCors.js +46 -0
  213. package/server/services/renderer.js +224 -22
  214. package/server/services/roles.js +1 -1
  215. package/server/services/search-migration.js +82 -0
  216. package/server/services/search.js +413 -0
  217. package/server/services/sidebar-migration.js +1 -0
  218. package/server/services/sidebarBadges.js +37 -0
  219. package/server/services/siteGitignore.js +307 -0
  220. package/server/services/themeSettings.js +580 -0
  221. package/server/services/users.js +8 -0
  222. package/server/templates/page.html +4 -2
  223. package/plugins/analytics/admin/templates/analytics.html +0 -61
  224. package/plugins/analytics/admin/views/analytics.js +0 -176
  225. package/plugins/analytics/config.js +0 -14
  226. package/plugins/analytics/plugin.json +0 -57
  227. package/plugins/analytics/public/inject-body.html +0 -60
  228. package/plugins/analytics/public/inject-head.html +0 -1
  229. package/plugins/blog/templates/author.html +0 -9
  230. package/plugins/blog/templates/category.html +0 -9
  231. package/plugins/blog/templates/tag.html +0 -9
  232. package/plugins/contacts/admin/templates/contacts.html +0 -126
  233. package/plugins/contacts/admin/views/contacts.js +0 -710
  234. package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
  235. package/plugins/contacts/collections/user-contacts/schema.json +0 -71
  236. package/plugins/contacts/config.js +0 -6
  237. package/plugins/contacts/data/contacts.json +0 -20
  238. package/plugins/contacts/plugin.js +0 -291
  239. package/plugins/contacts/plugin.json +0 -33
  240. package/plugins/demo-viewer/config.js +0 -4
  241. package/plugins/demo-viewer/plugin.js +0 -36
  242. package/plugins/demo-viewer/plugin.json +0 -9
  243. package/plugins/invoice/admin/templates/editor.html +0 -129
  244. package/plugins/invoice/admin/templates/index.html +0 -43
  245. package/plugins/invoice/admin/templates/issuers.html +0 -5
  246. package/plugins/invoice/admin/templates/receivers.html +0 -5
  247. package/plugins/invoice/admin/views/editor.js +0 -267
  248. package/plugins/invoice/admin/views/index.js +0 -155
  249. package/plugins/invoice/admin/views/issuers.js +0 -23
  250. package/plugins/invoice/admin/views/party-view.js +0 -148
  251. package/plugins/invoice/admin/views/receivers.js +0 -22
  252. package/plugins/invoice/collections/invoice-issuers/schema.json +0 -16
  253. package/plugins/invoice/collections/invoice-receivers/schema.json +0 -15
  254. package/plugins/invoice/collections/invoices/schema.json +0 -27
  255. package/plugins/invoice/config.js +0 -16
  256. package/plugins/invoice/plugin.js +0 -283
  257. package/plugins/invoice/plugin.json +0 -85
  258. package/plugins/invoice/templates/invoice-print.html +0 -213
  259. package/plugins/notes/admin/templates/notes.html +0 -83
  260. package/plugins/notes/admin/views/notes.js +0 -281
  261. package/plugins/notes/collections/user-notes/schema.json +0 -54
  262. package/plugins/notes/config.js +0 -6
  263. package/plugins/notes/data/notes.json +0 -1
  264. package/plugins/notes/plugin.json +0 -33
  265. package/plugins/site-search/admin/views/site-search.js +0 -116
  266. package/plugins/site-search/config.js +0 -15
  267. package/plugins/site-search/plugin.js +0 -188
  268. package/plugins/site-search/plugin.json +0 -40
  269. package/plugins/site-search/public/inject-body.html +0 -17
  270. package/plugins/site-search/public/inject-head.html +0 -1
  271. package/plugins/site-search/public/search.css +0 -1
  272. package/plugins/site-search/public/search.js +0 -1
  273. package/plugins/surveys/admin/templates/audience.html +0 -47
  274. package/plugins/surveys/admin/templates/results.html +0 -58
  275. package/plugins/surveys/admin/templates/survey-editor.html +0 -141
  276. package/plugins/surveys/admin/templates/surveys.html +0 -25
  277. package/plugins/surveys/admin/views/audience.js +0 -301
  278. package/plugins/surveys/admin/views/results.js +0 -172
  279. package/plugins/surveys/admin/views/survey-editor.js +0 -211
  280. package/plugins/surveys/admin/views/surveys.js +0 -161
  281. package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
  282. package/plugins/surveys/collections/survey-groups/schema.json +0 -11
  283. package/plugins/surveys/collections/survey-invites/schema.json +0 -16
  284. package/plugins/surveys/collections/surveys/schema.json +0 -23
  285. package/plugins/surveys/config.js +0 -8
  286. package/plugins/surveys/plugin.js +0 -174
  287. package/plugins/surveys/plugin.json +0 -65
  288. package/plugins/surveys/public/dist-shared.mjs +0 -1
  289. package/plugins/surveys/public/survey.css +0 -1
  290. package/plugins/surveys/public/survey.mjs +0 -1
  291. package/plugins/surveys/templates/survey-page.html +0 -63
  292. package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
  293. package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
  294. package/plugins/theme-switcher/config.js +0 -10
  295. package/plugins/theme-switcher/plugin.js +0 -26
  296. package/plugins/theme-switcher/plugin.json +0 -74
  297. package/plugins/theme-switcher/public/inject-body.html +0 -153
  298. package/plugins/theme-switcher/public/inject-head.html +0 -260
  299. package/plugins/todo/admin/templates/todo.html +0 -158
  300. package/plugins/todo/admin/views/todo.js +0 -343
  301. package/plugins/todo/collections/todos/schema.json +0 -60
  302. package/plugins/todo/config.js +0 -7
  303. package/plugins/todo/data/todos.json +0 -1
  304. package/plugins/todo/plugin.js +0 -102
  305. package/plugins/todo/plugin.json +0 -33
@@ -0,0 +1,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, '&amp;')
167
+ .replace(/</g, '&lt;')
168
+ .replace(/>/g, '&gt;')
169
+ .replace(/"/g, '&quot;');
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
+ }