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,301 @@
1
+ /**
2
+ * Recipient suggestions, from the Contacts plugin.
3
+ *
4
+ * The mail client does not own an address book and should not grow one: the
5
+ * CMS already has Contacts, per-user, with groups and favourites. This is the
6
+ * thin bridge between them.
7
+ *
8
+ * ## An optional neighbour, never a dependency
9
+ *
10
+ * Contacts may be absent, disabled, or forbidden to this user. Every function
11
+ * here treats that as an ordinary answer - an empty list - rather than an
12
+ * error, and the compose window simply has no suggestions. A mail client that
13
+ * refuses to open because an unrelated plugin is off would be a poor trade for
14
+ * a convenience.
15
+ *
16
+ * The matching is pure and lives here rather than in the window, because
17
+ * "which contact did they mean" is the part worth testing and the part most
18
+ * likely to be wrong.
19
+ *
20
+ * @module _lib/admin/mail/contacts
21
+ */
22
+
23
+ /**
24
+ * Where the Contacts plugin serves its per-user address book.
25
+ *
26
+ * Hardcoded, which is a coupling worth naming: there is no plugin-to-plugin
27
+ * discovery API, and probing `/api/plugins` needs a permission a mail user may
28
+ * not hold. A 404 here is indistinguishable from "not installed", which is
29
+ * exactly the behaviour wanted.
30
+ */
31
+ const CONTACTS_ENDPOINT = '/api/plugins/contacts/contacts';
32
+
33
+ /**
34
+ * Fetch the address book once.
35
+ *
36
+ * Once rather than per keystroke: an address book is small and a person's own
37
+ * contacts do not change while they write one email, so filtering locally is
38
+ * both instant and kinder to the server than a debounced request per letter.
39
+ *
40
+ * Returns `available` separately from the list, because an empty address book
41
+ * and no address book are different answers to different questions: an empty
42
+ * one still wants "Add sender to contacts" offered - that is how it stops
43
+ * being empty - while no plugin wants the action absent entirely.
44
+ *
45
+ * @param {Function} api - the view's request helper
46
+ * @returns {Promise<{available: boolean, contacts: object[]}>}
47
+ */
48
+ export async function loadContacts(api) {
49
+ try {
50
+ const rows = await api(CONTACTS_ENDPOINT);
51
+ if (!Array.isArray(rows)) return {available: false, contacts: []};
52
+
53
+ const contacts = rows
54
+ // Someone with no email address cannot be written to, and offering
55
+ // them is an invitation to send a message that cannot be sent.
56
+ .filter(row => String(row?.email ?? '').trim())
57
+ .map(row => ({
58
+ name: String(row.name ?? '').trim(),
59
+ email: String(row.email).trim(),
60
+ favourite: row.favourite === true,
61
+ groups: Array.isArray(row.groups) ? row.groups.filter(Boolean).map(String) : []
62
+ }));
63
+
64
+ return {available: true, contacts};
65
+ } catch {
66
+ // Not installed, disabled, or not ours to read. All the same answer.
67
+ return {available: false, contacts: []};
68
+ }
69
+ }
70
+
71
+ /**
72
+ * The part of a recipient field the caret is currently in.
73
+ *
74
+ * A recipient field holds a list, so what is being typed is only ever the
75
+ * text after the last separator - matching on the whole field would stop
76
+ * suggesting anything the moment a first recipient was entered.
77
+ *
78
+ * @param {string} value
79
+ * @returns {string}
80
+ */
81
+ export function currentFragment(value) {
82
+ const text = String(value ?? '');
83
+ const from = Math.max(text.lastIndexOf(','), text.lastIndexOf(';'));
84
+ return text.slice(from + 1).trim();
85
+ }
86
+
87
+ /**
88
+ * Put a chosen recipient in place of the fragment being typed.
89
+ *
90
+ * Keeps whatever was already entered and leaves a trailing separator, so the
91
+ * next name can be typed straight away without reaching for the comma.
92
+ *
93
+ * @param {string} value
94
+ * @param {string} replacement
95
+ * @returns {string}
96
+ */
97
+ export function replaceFragment(value, replacement) {
98
+ const text = String(value ?? '');
99
+ const from = Math.max(text.lastIndexOf(','), text.lastIndexOf(';'));
100
+ const kept = from === -1 ? '' : `${text.slice(0, from + 1)} `;
101
+ return `${kept}${replacement}, `;
102
+ }
103
+
104
+ /**
105
+ * How a contact is written into a recipient field.
106
+ *
107
+ * `Name <email>` where there is a name, which `textToAddresses` parses back
108
+ * into exactly the pair this started from. A bare address where there is not,
109
+ * rather than an empty `<>` pair.
110
+ *
111
+ * @param {{name: string, email: string}} contact
112
+ * @returns {string}
113
+ */
114
+ export function formatRecipient(contact) {
115
+ const name = String(contact?.name ?? '').trim();
116
+ const email = String(contact?.email ?? '').trim();
117
+ if (!name || name === email) return email;
118
+ // A name containing a comma would otherwise read as two recipients.
119
+ return /[,;<>"]/.test(name) ? `"${name.replace(/"/g, '')}" <${email}>` : `${name} <${email}>`;
120
+ }
121
+
122
+ /**
123
+ * Find the contacts someone probably meant.
124
+ *
125
+ * Ranked rather than merely filtered, because the difference between the right
126
+ * answer first and the right answer fourth is the difference between the
127
+ * feature being used and being tolerated. In order: a name or address that
128
+ * STARTS with what was typed beats one that merely contains it, and a
129
+ * favourite beats a non-favourite at equal footing.
130
+ *
131
+ * @param {{name: string, email: string, favourite: boolean}[]} contacts
132
+ * @param {string} fragment
133
+ * @param {{limit?: number, exclude?: string[]}} [options]
134
+ * @returns {{name: string, email: string, favourite: boolean}[]}
135
+ */
136
+ export function matchContacts(contacts, fragment, {limit = 8, exclude = []} = {}) {
137
+ const query = String(fragment ?? '').trim().toLowerCase();
138
+ if (!query) return [];
139
+
140
+ const already = new Set(exclude.map(a => String(a).trim().toLowerCase()).filter(Boolean));
141
+
142
+ const scored = [];
143
+ for (const contact of contacts ?? []) {
144
+ const email = contact.email.toLowerCase();
145
+ // Someone already on the list is not a suggestion, it is a duplicate.
146
+ if (already.has(email)) continue;
147
+
148
+ const name = contact.name.toLowerCase();
149
+ let score = 0;
150
+ if (email.startsWith(query)) score = 4;
151
+ else if (name.startsWith(query)) score = 3;
152
+ // A match on the local part is worth more than one on the domain:
153
+ // "sam" should find sam@… before it finds anyone at samsung.com.
154
+ else if (email.split('@')[0].includes(query)) score = 2;
155
+ else if (name.includes(query) || email.includes(query)) score = 1;
156
+ if (!score) continue;
157
+
158
+ scored.push({contact, score: score * 2 + (contact.favourite ? 1 : 0)});
159
+ }
160
+
161
+ return scored
162
+ .sort((a, b) => b.score - a.score
163
+ || a.contact.name.localeCompare(b.contact.name)
164
+ || a.contact.email.localeCompare(b.contact.email))
165
+ .slice(0, limit)
166
+ .map(entry => entry.contact);
167
+ }
168
+
169
+ /**
170
+ * The groups worth suggesting, derived from the contacts themselves.
171
+ *
172
+ * Derived rather than fetched from `/groups`: a group with no members cannot
173
+ * be expanded into anything, so offering it would be offering a recipient list
174
+ * that resolves to nobody. This also keeps the whole feature on the one
175
+ * request the address book already costs.
176
+ *
177
+ * @param {{name: string, email: string, groups: string[]}[]} contacts
178
+ * @returns {{name: string, members: object[]}[]}
179
+ */
180
+ export function collectGroups(contacts) {
181
+ const byName = new Map();
182
+ for (const contact of contacts ?? []) {
183
+ for (const group of contact.groups ?? []) {
184
+ const key = String(group).trim();
185
+ if (!key) continue;
186
+ if (!byName.has(key)) byName.set(key, []);
187
+ byName.get(key).push(contact);
188
+ }
189
+ }
190
+
191
+ return [...byName.entries()]
192
+ .map(([name, members]) => ({name, members}))
193
+ .sort((a, b) => a.name.localeCompare(b.name));
194
+ }
195
+
196
+ /**
197
+ * Find the groups someone probably meant.
198
+ *
199
+ * Same rule as contacts - starts-with beats contains - so the two lists can be
200
+ * merged without one of them systematically outranking the other.
201
+ *
202
+ * @param {{name: string, members: object[]}[]} groups
203
+ * @param {string} fragment
204
+ * @param {{limit?: number}} [options]
205
+ * @returns {{name: string, members: object[], score: number}[]}
206
+ */
207
+ export function matchGroups(groups, fragment, {limit = 4} = {}) {
208
+ const query = String(fragment ?? '').trim().toLowerCase();
209
+ if (!query) return [];
210
+
211
+ return (groups ?? [])
212
+ .map(group => {
213
+ const name = group.name.toLowerCase();
214
+ if (name.startsWith(query)) return {...group, score: 4};
215
+ if (name.includes(query)) return {...group, score: 1};
216
+ return null;
217
+ })
218
+ .filter(Boolean)
219
+ .sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))
220
+ .slice(0, limit);
221
+ }
222
+
223
+ /**
224
+ * One ranked list of both, for a single dropdown.
225
+ *
226
+ * Groups are scored on the same scale as contacts and sit fractionally above a
227
+ * person matching equally well, because someone who has typed a group's name
228
+ * has almost certainly typed it on purpose - a group is a deliberate thing to
229
+ * reach for in a way one contact among several is not.
230
+ *
231
+ * @param {object[]} contacts
232
+ * @param {object[]} groups
233
+ * @param {string} fragment
234
+ * @param {{limit?: number, exclude?: string[]}} [options]
235
+ * @returns {object[]} entries tagged `kind: 'contact' | 'group'`
236
+ */
237
+ export function suggest(contacts, groups, fragment, {limit = 8, exclude = []} = {}) {
238
+ const people = matchContacts(contacts, fragment, {limit, exclude})
239
+ .map(contact => ({kind: 'contact', ...contact}));
240
+
241
+ const already = new Set(exclude.map(a => String(a).trim().toLowerCase()).filter(Boolean));
242
+ const lists = matchGroups(groups, fragment)
243
+ // A group everyone in which is already a recipient has nothing left to
244
+ // add, and picking it would appear to do nothing.
245
+ .filter(group => group.members.some(m => !already.has(m.email.toLowerCase())))
246
+ .map(group => ({kind: 'group', ...group}));
247
+
248
+ return [...lists, ...people].slice(0, limit);
249
+ }
250
+
251
+ /**
252
+ * Turn a group into the recipients it stands for.
253
+ *
254
+ * Members already in the field are left out rather than repeated: expanding a
255
+ * group twice, or expanding one that overlaps another, should not produce the
256
+ * same person three times.
257
+ *
258
+ * @param {{members: object[]}} group
259
+ * @param {{exclude?: string[]}} [options]
260
+ * @returns {string} ready to drop into a recipient field
261
+ */
262
+ export function expandGroup(group, {exclude = []} = {}) {
263
+ const already = new Set(exclude.map(a => String(a).trim().toLowerCase()).filter(Boolean));
264
+ return (group?.members ?? [])
265
+ .filter(member => !already.has(member.email.toLowerCase()))
266
+ .map(formatRecipient)
267
+ .join(', ');
268
+ }
269
+
270
+ /**
271
+ * Is this address already in the address book?
272
+ *
273
+ * @param {object[]} contacts
274
+ * @param {string} email
275
+ * @returns {boolean}
276
+ */
277
+ export function isKnownAddress(contacts, email) {
278
+ const needle = String(email ?? '').trim().toLowerCase();
279
+ if (!needle) return false;
280
+ return (contacts ?? []).some(c => c.email.toLowerCase() === needle);
281
+ }
282
+
283
+ /**
284
+ * Add someone to the address book.
285
+ *
286
+ * Throws on failure rather than swallowing it. Reading contacts is a
287
+ * convenience that may quietly not be available; being told a contact was
288
+ * added when it was not is a different thing entirely.
289
+ *
290
+ * @param {Function} api
291
+ * @param {{name?: string, email: string}} person
292
+ * @returns {Promise<object>}
293
+ */
294
+ export function addContact(api, {name = '', email}) {
295
+ return api(CONTACTS_ENDPOINT, 'POST', {
296
+ // The API requires a name; an address is the only honest stand-in when
297
+ // the message carried no display name.
298
+ name: String(name).trim() || String(email).trim(),
299
+ email: String(email).trim()
300
+ });
301
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * The settings section that answers "which build is this, and what went wrong".
3
+ *
4
+ * The expensive failures in this plugin were never about what the code does.
5
+ * They were about which build a particular browser was running, and they were
6
+ * diagnosed by describing symptoms back and forth. This turns that into one
7
+ * button: the page reports what it loaded, the server reports what it would
8
+ * serve, and the difference between the two is the answer.
9
+ *
10
+ * @module _lib/admin/mail/diagnostics-section
11
+ */
12
+
13
+
14
+ /**
15
+ * Which versions of the shared modules this page actually loaded.
16
+ *
17
+ * Read from the browser's own resource timings rather than from what the code
18
+ * believes it asked for - a cached module answers here with the URL it was
19
+ * fetched under, which is exactly the fact in dispute.
20
+ *
21
+ * @returns {Object.<string, string>}
22
+ */
23
+ function loadedAssetVersions() {
24
+ const out = {};
25
+ let entries = [];
26
+ try {
27
+ entries = performance.getEntriesByType('resource');
28
+ } catch {
29
+ return out;
30
+ }
31
+
32
+ for (const entry of entries) {
33
+ const match = String(entry.name).match(/\/plugins\/[^/]+\/(?:admin\/(?:mail|views|templates|css)\/)?([^/?]+\.(?:js|css|html))(?:\?v=(\d+))?/);
34
+ if (!match) continue;
35
+ out[match[1]] = match[2] ?? '(unversioned)';
36
+ }
37
+ return out;
38
+ }
39
+
40
+ /**
41
+ * Build the section.
42
+ *
43
+ * @param {{recentErrors: () => object[], state: () => object}} host
44
+ * @returns {object} a settings section
45
+ */
46
+ export function createDiagnosticsSection(host) {
47
+ /**
48
+ * @param {HTMLElement} container
49
+ * @param {object} ctx
50
+ * @returns {Promise<void>}
51
+ */
52
+ async function render(container, ctx) {
53
+ const server = await ctx.api(`${ctx.base}/diagnostics`);
54
+ const loaded = loadedAssetVersions();
55
+
56
+ const stamp = document.createElement('div');
57
+ stamp.className = 'text-sm font-semibold mb-2';
58
+ const entryVersion = loaded['mail.js'] ?? '(unknown)';
59
+ stamp.textContent = `${server.plugin.name} ${server.plugin.version ?? '?'} · build ${entryVersion}`;
60
+ container.appendChild(stamp);
61
+
62
+ // The comparison that matters: the token this page was served under
63
+ // against the token the server would issue now. Comparing a module's
64
+ // own mtime would be wrong - the shared files are fetched under the
65
+ // entry's token, not their own.
66
+ const served = server.assets.expectedStamp;
67
+ const running = loaded['mail.js'];
68
+ const stale = running && running !== '(unversioned)' && String(served) !== String(running);
69
+
70
+ const verdict = document.createElement('p');
71
+ verdict.className = 'text-sm mb-3';
72
+ if (stale) {
73
+ verdict.className = 'text-sm mb-3 text-danger';
74
+ verdict.textContent = `This page is running an older build than the server has (${running} vs ${served}). Reload with a hard refresh before reporting anything else.`;
75
+ } else {
76
+ verdict.className = 'text-sm mb-3 text-muted';
77
+ verdict.textContent = 'This page is running the build the server is serving.';
78
+ }
79
+ container.appendChild(verdict);
80
+
81
+ const errors = host.recentErrors();
82
+ const errorLine = document.createElement('p');
83
+ errorLine.className = 'text-sm text-muted mb-3';
84
+ errorLine.textContent = errors.length
85
+ ? `${errors.length} error(s) captured since this view opened - included in the report.`
86
+ : 'No errors captured since this view opened.';
87
+ container.appendChild(errorLine);
88
+
89
+ const actions = document.createElement('div');
90
+ actions.className = 'flex gap-2 items-center';
91
+
92
+ const status = document.createElement('span');
93
+ status.className = 'flex-1 text-sm text-muted';
94
+
95
+ const copy = document.createElement('button');
96
+ copy.className = 'btn btn-sm btn-primary';
97
+ copy.textContent = 'Copy diagnostics';
98
+ copy.addEventListener('click', async () => {
99
+ const report = {
100
+ page: {
101
+ url: location.href,
102
+ userAgent: navigator.userAgent,
103
+ loadedAssets: loaded,
104
+ view: host.state()
105
+ },
106
+ server,
107
+ errors
108
+ };
109
+ const text = JSON.stringify(report, null, 2);
110
+ try {
111
+ await navigator.clipboard.writeText(text);
112
+ status.className = 'flex-1 text-sm text-success';
113
+ status.textContent = 'Copied. Paste it into the report.';
114
+ } catch {
115
+ // Clipboard access can be refused; showing the text is still
116
+ // better than losing it.
117
+ const box = document.createElement('textarea');
118
+ box.value = text;
119
+ box.className = 'form-textarea mail-diag-log';
120
+ container.appendChild(box);
121
+ box.select();
122
+ status.className = 'flex-1 text-sm text-muted';
123
+ status.textContent = 'Clipboard unavailable - select and copy the text below.';
124
+ }
125
+ });
126
+
127
+ actions.appendChild(status);
128
+ actions.appendChild(copy);
129
+ container.appendChild(actions);
130
+ }
131
+
132
+ return {id: 'diagnostics', label: 'Diagnostics', render};
133
+ }
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Mail folder presentation, shared by the Mail Reader and Email Pro.
3
+ *
4
+ * Both plugins draw the same folder pane, so the logic that turns IMAP's flat
5
+ * mailbox list into something a person can read lives here rather than in
6
+ * either of them. Served to the browser from /plugins/_lib/admin/mail/ - the
7
+ * static mount only exposes a plugin's `admin` and `public` directories, which
8
+ * is why this sits under admin/ and the server-side core does not.
9
+ *
10
+ * Pure functions, no DOM: the plugins own the rendering, this owns the shape.
11
+ *
12
+ * @module _lib/admin/mail/folder-tree
13
+ */
14
+
15
+ /**
16
+ * Arrange a flat mailbox list into the hierarchy the server described.
17
+ *
18
+ * IMAP returns every mailbox as a full path plus the delimiter that separates
19
+ * its levels ('.' on Dovecot, '/' elsewhere), so `Work.Contracts` arrives as
20
+ * one entry. Rendering `folder.name` alone would show it as a top-level
21
+ * "Contracts" with nothing to tie it to Work, and two folders sharing a leaf
22
+ * name would be indistinguishable.
23
+ *
24
+ * Intermediate levels a server does not itself list - possible, since a parent
25
+ * may be unselectable - are synthesised so a child is never orphaned.
26
+ *
27
+ * @param {object[]} folders
28
+ * Exported for tests; the router only ever reads `mailView`.
29
+ *
30
+ * @returns {object[]} roots, each with a `children` array and a `depth`
31
+ */
32
+ export function buildFolderTree(folders) {
33
+ const nodes = new Map();
34
+ const roots = [];
35
+
36
+ /**
37
+ * Find or create the node for a full mailbox path.
38
+ *
39
+ * @param {string} path
40
+ * @param {string} delimiter
41
+ * @returns {object}
42
+ */
43
+ function ensure(path, delimiter) {
44
+ const existing = nodes.get(path);
45
+ if (existing) return existing;
46
+
47
+ const cut = delimiter ? path.lastIndexOf(delimiter) : -1;
48
+ const node = {
49
+ path,
50
+ name: cut === -1 ? path : path.slice(cut + delimiter.length),
51
+ delimiter,
52
+ specialUse: null,
53
+ // A level the server never listed: it exists only to hold children,
54
+ // so it is drawn but cannot be opened.
55
+ selectable: false,
56
+ subscribed: true,
57
+ depth: 0,
58
+ children: []
59
+ };
60
+ nodes.set(path, node);
61
+
62
+ if (cut === -1) {
63
+ roots.push(node);
64
+ } else {
65
+ const parent = ensure(path.slice(0, cut), delimiter);
66
+ node.depth = parent.depth + 1;
67
+ parent.children.push(node);
68
+ }
69
+ return node;
70
+ }
71
+
72
+ for (const folder of folders) {
73
+ const node = ensure(folder.path, folder.delimiter || '');
74
+ node.specialUse = folder.specialUse ?? null;
75
+ node.selectable = true;
76
+ node.name = folder.name || node.name;
77
+ // IMAP subscription. A folder exists whether or not you are
78
+ // subscribed to it; subscription is what most clients use to decide
79
+ // whether to SHOW it, which makes it the closest thing IMAP has to
80
+ // "visible". Absent means subscribed - an older server that does not
81
+ // report it should not have every folder read as hidden.
82
+ node.subscribed = folder.subscribed !== false;
83
+ node.unseen = Number.isFinite(folder.unseen) ? folder.unseen : null;
84
+ node.messages = Number.isFinite(folder.messages) ? folder.messages : null;
85
+ }
86
+
87
+ // INBOX first, then the rest alphabetically - the order a server lists
88
+ // mailboxes in is not one anybody wants to read.
89
+ const sort = list => {
90
+ list.sort((a, b) => {
91
+ const aInbox = a.specialUse === '\\Inbox' || a.path.toUpperCase() === 'INBOX';
92
+ const bInbox = b.specialUse === '\\Inbox' || b.path.toUpperCase() === 'INBOX';
93
+ if (aInbox !== bInbox) return aInbox ? -1 : 1;
94
+ return a.name.localeCompare(b.name);
95
+ });
96
+ for (const node of list) sort(node.children);
97
+ };
98
+ sort(roots);
99
+
100
+ return roots;
101
+ }
102
+
103
+ /**
104
+ * Flatten a folder tree into draw order, depth preserved.
105
+ *
106
+ * A collapsed folder is still drawn - it is its descendants that are left out,
107
+ * so the folder itself remains there to be reopened.
108
+ *
109
+ * @param {object[]} roots
110
+ * @param {Set<string>} [collapsed] - paths whose children are hidden
111
+ * @returns {object[]}
112
+ */
113
+ export function flattenFolderTree(roots, collapsed = new Set()) {
114
+ const out = [];
115
+ const walk = nodes => {
116
+ for (const node of nodes) {
117
+ out.push(node);
118
+ if (!collapsed.has(node.path)) walk(node.children);
119
+ }
120
+ };
121
+ walk(roots);
122
+ return out;
123
+ }
124
+
125
+ /**
126
+ * A heading that says exactly which folder, in which mailbox, is open.
127
+ *
128
+ * The leaf name alone is ambiguous the moment two folders share one - an
129
+ * "Archive" under Work and another under Personal are indistinguishable, and
130
+ * that is precisely the case a reader with several mailboxes runs into.
131
+ *
132
+ * @param {string} path
133
+ * @param {string} delimiter
134
+ * @param {string|null} mailboxLabel - included only when there is a choice
135
+ * @returns {string}
136
+ */
137
+ export function folderHeading(path, delimiter, mailboxLabel) {
138
+ const parts = delimiter ? path.split(delimiter) : [path];
139
+ if (mailboxLabel) parts.unshift(mailboxLabel);
140
+ return parts.join(' \u203a ');
141
+ }
142
+
143
+ /**
144
+ * Pick an icon from a folder's SPECIAL-USE attribute.
145
+ *
146
+ * @param {object} folder
147
+ * @returns {string}
148
+ */
149
+ export function iconForFolder(folder) {
150
+ switch (folder.specialUse) {
151
+ case '\\Sent': return 'send';
152
+ case '\\Drafts': return 'file-text';
153
+ case '\\Trash': return 'trash';
154
+ case '\\Junk': return 'alert-triangle';
155
+ case '\\Archive': return 'archive';
156
+ default: return 'folder';
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Unread in a folder and everything beneath it.
162
+ *
163
+ * A collapsed folder hides its children, so showing only its own count would
164
+ * claim a branch is read when it is not. The aggregate is what a collapsed
165
+ * row needs; the folder's own count is what an expanded one shows.
166
+ *
167
+ * @param {object} node
168
+ * @returns {number}
169
+ */
170
+ export function unreadInSubtree(node) {
171
+ const own = Number.isFinite(node.unseen) ? node.unseen : 0;
172
+ return node.children.reduce((total, child) => total + unreadInSubtree(child), own);
173
+ }
174
+
175
+ /**
176
+ * Keep only the branches that lead to unread mail.
177
+ *
178
+ * Ancestors of a folder with unread are kept even when they have none of
179
+ * their own - dropping them would leave "2026" floating with no indication
180
+ * that it lives under Work > Contracts. They are marked so the renderer can
181
+ * show them as context rather than as results.
182
+ *
183
+ * @param {object[]} roots
184
+ * @returns {object[]} a pruned copy; the original tree is untouched
185
+ */
186
+ export function pruneToUnread(roots) {
187
+ const visit = node => {
188
+ const children = node.children.map(visit).filter(Boolean);
189
+ const own = Number.isFinite(node.unseen) ? node.unseen : 0;
190
+ if (!own && !children.length) return null;
191
+ return {...node, children, contextOnly: own === 0};
192
+ };
193
+ return roots.map(visit).filter(Boolean);
194
+ }
195
+
196
+ /**
197
+ * The folder tree as nested context-menu items.
198
+ *
199
+ * A flat list of full paths is fine with six folders and unusable with sixty:
200
+ * `Work.Contracts.2026` reads as a string to be parsed rather than a place,
201
+ * and everything sorts by its root so siblings end up pages apart. Nested, it
202
+ * is the same shape as the folder pane, which is the shape someone already has
203
+ * in their head.
204
+ *
205
+ * A folder with children cannot simply be clicked: Domma opens the submenu on
206
+ * click and never fires the item's own action. So a parent that can itself
207
+ * hold mail gets an explicit entry at the top of its own submenu, which is
208
+ * also how every desktop mail client does it.
209
+ *
210
+ * Pure, and the interesting part - which destinations are legal - is the part
211
+ * worth testing.
212
+ *
213
+ * @param {object[]} roots - from `buildFolderTree`
214
+ * @param {{current?: string|null, onPick: (path: string) => void,
215
+ * label?: (node: object) => string}} options
216
+ * @returns {object[]} context-menu items
217
+ */
218
+ export function folderMenuItems(roots, {current = null, onPick, label = null}) {
219
+ const naming = label ?? (node => `Move to ${node.name}`);
220
+
221
+ /**
222
+ * @param {object} node
223
+ * @returns {object}
224
+ */
225
+ function target(node) {
226
+ return {
227
+ label: naming(node),
228
+ icon: iconForFolder(node),
229
+ // A level the server never listed cannot hold mail, and the folder
230
+ // the message is already in is not a move. Both are greyed rather
231
+ // than dropped: a tree with holes in it is harder to read than one
232
+ // with a few dim rows, and "already here" is worth knowing.
233
+ disabled: () => !node.selectable || node.path === current,
234
+ action: () => onPick(node.path)
235
+ };
236
+ }
237
+
238
+ /**
239
+ * @param {object[]} nodes
240
+ * @returns {object[]}
241
+ */
242
+ function walk(nodes) {
243
+ return nodes.map(node => {
244
+ if (!node.children.length) return target(node);
245
+ return {
246
+ label: node.name,
247
+ icon: iconForFolder(node),
248
+ submenu: [target(node), {type: 'separator'}, ...walk(node.children)]
249
+ };
250
+ });
251
+ }
252
+
253
+ return walk(roots);
254
+ }