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,114 @@
1
+ /**
2
+ * Sending the away message.
3
+ *
4
+ * The decision - and every reason not to - is `_lib/admin/mail/vacation.js`,
5
+ * which is pure. This is the half that talks to SMTP, and it is deliberately
6
+ * thin: an auto-responder's bugs are decisions, not transport, and a decision
7
+ * that lives behind a mail server is a decision nobody can test.
8
+ *
9
+ * ## The two halves of not causing a loop
10
+ *
11
+ * `shouldAutoReply` refuses to answer anything that announced itself as
12
+ * automatic. `autoReplyHeaders` makes OUR reply announce itself the same way,
13
+ * so the responder at the far end refuses it in turn. Either half alone
14
+ * leaves a loop that only stops when somebody notices.
15
+ *
16
+ * @module _lib/mail/vacationRunner
17
+ */
18
+ import {
19
+ autoReplyHeaders,
20
+ isActive,
21
+ normaliseVacation,
22
+ replySubject,
23
+ shouldAutoReply
24
+ } from '../admin/mail/vacation.js';
25
+ import {textToHtml} from '../admin/mail/identity.js';
26
+ import {sendMessage} from './send.js';
27
+
28
+ /**
29
+ * How many away messages may go out in one sync pass.
30
+ *
31
+ * A ceiling on the blast radius of a mistake. If something has gone wrong -
32
+ * a guard that does not hold, a folder full of mail that suddenly looks new -
33
+ * the damage is bounded at ten messages rather than at the size of the
34
+ * mailbox, and the next pass will do ten more only if it is still right to.
35
+ */
36
+ const MAX_PER_PASS = 10;
37
+
38
+ /**
39
+ * Answer whatever has just arrived and deserves an answer.
40
+ *
41
+ * Must be called with NO mailbox lock held - it sends, which opens its own
42
+ * connections. Failures are collected rather than thrown: one message that
43
+ * could not be answered must not stop the rest, and it must certainly not
44
+ * fail the sync pass it is riding on.
45
+ *
46
+ * @param {{pool: object, store: object, account: object, poolKey: string,
47
+ * connection: object, smtp: object, messages: object[],
48
+ * config: object, addresses: string[], now?: Date}} deps
49
+ * @returns {Promise<{considered: number, replied: number, skipped: object[], errors: string[]}>}
50
+ */
51
+ export async function runVacation({
52
+ pool, store, account, poolKey, connection, smtp, messages, config, addresses, now = new Date()
53
+ }) {
54
+ const report = {considered: messages?.length ?? 0, replied: 0, skipped: [], errors: []};
55
+
56
+ const vacation = normaliseVacation(account);
57
+ if (!isActive(vacation, now)) return report;
58
+ // Nothing to send it with. Reported rather than silent: a mailbox with an
59
+ // away message switched on and no outgoing server is a mailbox whose
60
+ // owner believes people are being answered.
61
+ if (!smtp) {
62
+ report.errors.push('No outgoing server is configured, so nothing could be answered.');
63
+ return report;
64
+ }
65
+
66
+ for (const message of messages ?? []) {
67
+ if (report.replied >= MAX_PER_PASS) break;
68
+
69
+ const sender = (message.from ?? [])[0]?.address ?? '';
70
+ const lastRepliedAt = sender ? await store.lastAutoReply(account.id, sender) : null;
71
+
72
+ const verdict = shouldAutoReply({message, vacation, addresses, lastRepliedAt, now});
73
+ if (!verdict.reply) {
74
+ report.skipped.push({uid: message.uid, reason: verdict.reason});
75
+ continue;
76
+ }
77
+
78
+ try {
79
+ await sendMessage({
80
+ smtp,
81
+ draft: {
82
+ to: [{name: (message.from ?? [])[0]?.name ?? '', address: sender}],
83
+ subject: replySubject(vacation, message.subject),
84
+ body: vacation.bodyText,
85
+ html: vacation.bodyHtml || textToHtml(vacation.bodyText),
86
+ // Threaded onto what they sent, so the answer arrives
87
+ // under their own message rather than as a new one.
88
+ inReplyTo: message.messageId ?? null,
89
+ references: message.messageId ? [message.messageId] : []
90
+ },
91
+ pool,
92
+ poolKey,
93
+ connection,
94
+ allowInsecureTLS: config.allowInsecureTLS === true,
95
+ // The half that protects everybody else.
96
+ headers: autoReplyHeaders(),
97
+ // Not filed. One holiday produces a hundred of these, and a
98
+ // Sent folder that is mostly "Out of office" is one nobody can
99
+ // find anything in - the reply log is the audit trail instead.
100
+ fileCopy: false
101
+ });
102
+
103
+ // Recorded AFTER it has gone. The other order would mean a send
104
+ // that failed still counted as "already told", and that person
105
+ // would never hear anything.
106
+ await store.noteAutoReply(account.id, sender, now);
107
+ report.replied += 1;
108
+ } catch (err) {
109
+ report.errors.push(`${sender}: ${err.message}`);
110
+ }
111
+ }
112
+
113
+ return report;
114
+ }
@@ -0,0 +1,473 @@
1
+ /**
2
+ * Changing mail, rather than reading it.
3
+ *
4
+ * Everything here needs a read-write mailbox, which is why it is a separate
5
+ * module rather than more routes beside the read surface: the free edition
6
+ * registers that surface and must never gain any of this by accident.
7
+ *
8
+ * Each operation applies on the server first and updates the mirror second.
9
+ * The other order is tempting - the UI feels faster - but it means a failed
10
+ * write leaves the mirror claiming something that is not true, and the next
11
+ * sync silently reverts it, which reads as the application losing the change.
12
+ *
13
+ * @module _lib/mail/write
14
+ */
15
+ import {ENVELOPE_QUERY, toRecord} from './envelope.js';
16
+
17
+ /** Flags a caller may set. Anything else is refused rather than passed through. */
18
+ export const WRITABLE_FLAGS = new Set(['\\Seen', '\\Flagged', '\\Answered', '\\Draft']);
19
+
20
+ /**
21
+ * Check a requested flag change is something we are willing to make.
22
+ *
23
+ * `\Deleted` is deliberately absent: deleting is its own operation with its
24
+ * own consequences, not a flag a generic endpoint should be able to set.
25
+ *
26
+ * @param {string[]} flags
27
+ * @returns {{ok: true} | {ok: false, error: string}}
28
+ */
29
+ export function validateFlags(flags) {
30
+ if (!Array.isArray(flags)) return {ok: false, error: 'Flags must be an array.'};
31
+ const bad = flags.filter(flag => !WRITABLE_FLAGS.has(flag));
32
+ if (bad.length) {
33
+ return {ok: false, error: `Unsupported flag(s): ${bad.join(', ')}.`};
34
+ }
35
+ return {ok: true};
36
+ }
37
+
38
+ /**
39
+ * Add or remove flags on one message.
40
+ *
41
+ * @param {{pool: object, store: object|null, account: object, poolKey: string,
42
+ * connection: object, folder: string, uid: number,
43
+ * add?: string[], remove?: string[]}} options
44
+ * @returns {Promise<{uid: number, flags: string[]}>}
45
+ */
46
+ export async function setMessageFlags({
47
+ pool, store, account, poolKey, connection, folder, uid, add = [], remove = []
48
+ }) {
49
+ const flags = await pool.withMailbox(poolKey, connection, folder, async (client) => {
50
+ if (add.length) await client.messageFlagsAdd({uid: String(uid)}, add, {uid: true});
51
+ if (remove.length) await client.messageFlagsRemove({uid: String(uid)}, remove, {uid: true});
52
+
53
+ // Read back rather than assume: another client may have changed
54
+ // something in between, and the mirror should hold what is true on the
55
+ // server rather than what we asked for.
56
+ const fetched = await client.fetchOne(uid, {flags: true}, {uid: true});
57
+ return [...(fetched?.flags ?? [])];
58
+ }, {writable: true});
59
+
60
+ if (store) {
61
+ await store.setFlags(account.id, folder, [{uid, flags}]);
62
+ }
63
+
64
+ return {uid, flags};
65
+ }
66
+
67
+ /**
68
+ * Put the moved messages into the destination's mirror straight away.
69
+ *
70
+ * Without this a move takes a message out of the source mirror and the
71
+ * destination does not learn of it until its next poll - so it is in neither
72
+ * folder for up to the poll interval, which reads as having lost it. Worst
73
+ * where it matters most: "Not spam" is supposed to put mail back in the Inbox,
74
+ * and the whole point is seeing it arrive.
75
+ *
76
+ * Only for a folder the mirror already tracks. An untracked destination is
77
+ * listed live from IMAP, so it is already correct, and writing rows for it
78
+ * would seed a mirror nothing asked for.
79
+ *
80
+ * Failure here is not failure of the move: the message HAS moved, and the
81
+ * next sync will pick it up regardless. Reported, never thrown.
82
+ *
83
+ * @param {{pool: object, store: object|null, account: object, poolKey: string,
84
+ * connection: object, destination: string, uids: number[]}} options
85
+ * @returns {Promise<boolean>} whether the destination mirror was updated
86
+ */
87
+ async function ingestMoved({pool, store, account, poolKey, connection, destination, uids}) {
88
+ if (!store || !uids.length) return false;
89
+
90
+ try {
91
+ const state = await store.getFolder(account.id, destination);
92
+ if (!state?.lastSyncAt) return false;
93
+
94
+ const set = uids.join(',');
95
+ const rows = [];
96
+ await pool.withMailbox(poolKey, connection, destination, async (client) => {
97
+ for await (const message of client.fetch(set, ENVELOPE_QUERY, {uid: true})) {
98
+ rows.push(toRecord(message));
99
+ }
100
+ });
101
+
102
+ if (rows.length) await store.putMessages(account.id, destination, rows);
103
+ return rows.length > 0;
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Move one message to another folder.
111
+ *
112
+ * The mirror entry is removed rather than rewritten: the message gets a new
113
+ * UID in the destination, and inventing one would put a record in the store
114
+ * that matches nothing on the server. The destination picks it up on its next
115
+ * sync, if it is mirrored at all.
116
+ *
117
+ * @param {{pool: object, store: object|null, account: object, poolKey: string,
118
+ * connection: object, folder: string, uid: number, destination: string}} options
119
+ * @returns {Promise<{moved: boolean, destination: string}>}
120
+ */
121
+ export async function moveMessage({
122
+ pool, store, account, poolKey, connection, folder, uid, destination
123
+ }) {
124
+ const result = await pool.withMailbox(poolKey, connection, folder, async (client) => {
125
+ return client.messageMove({uid: String(uid)}, destination, {uid: true});
126
+ }, {writable: true});
127
+
128
+ if (store) {
129
+ await store.removeMessages(account.id, folder, [uid]);
130
+ }
131
+
132
+ // Where the server supports UIDPLUS it tells us the UID the message was
133
+ // given at its destination, which is the only way back: an undo has to
134
+ // name the message it is moving, and it is not the one we sent. Without
135
+ // UIDPLUS there is no new UID to report, and the caller must not offer an
136
+ // undo it cannot perform.
137
+ const raw = result?.uidMap?.get?.(uid) ?? result?.uidMap?.get?.(String(uid)) ?? null;
138
+ const newUid = raw === null ? null : Number(raw);
139
+
140
+ // Read-only mailboxes are opened for this, so it cannot mark anything seen.
141
+ const mirrored = newUid === null
142
+ ? false
143
+ : await ingestMoved({pool, store, account, poolKey, connection, destination, uids: [newUid]});
144
+
145
+ return {moved: true, destination, newUid, mirrored};
146
+ }
147
+
148
+ /**
149
+ * Delete a message.
150
+ *
151
+ * Deleting mail means moving it to Trash, not destroying it - that is what
152
+ * every mail client means by the word, and what a user expects to be able to
153
+ * undo. Only when the server offers no Trash, or the message is already in it,
154
+ * does this fall back to the IMAP meaning: flag `\\Deleted` and expunge, which
155
+ * is irreversible.
156
+ *
157
+ * This is deliberately its own operation rather than a flag the generic
158
+ * endpoint may set. `\\Deleted` through a flags API would let a stray call
159
+ * destroy mail with no destination, no fallback and no name saying so.
160
+ *
161
+ * @param {{pool: object, store: object|null, account: object, poolKey: string,
162
+ * connection: object, folder: string, uid: number, trash?: string|null}} options
163
+ * @returns {Promise<{deleted: true, movedTo: string|null, expunged: boolean}>}
164
+ */
165
+ export async function deleteMessage({
166
+ pool, store, account, poolKey, connection, folder, uid, trash = null
167
+ }) {
168
+ // Find Trash by SPECIAL-USE rather than by name: it is "Bin" on some
169
+ // servers and localised on others, and guessing the name is how mail ends
170
+ // up expunged on a server that had somewhere to put it.
171
+ let destination = trash;
172
+ if (destination === null) {
173
+ const boxes = await pool.withClient(poolKey, connection, client => client.list());
174
+ destination = boxes.find(box => box.specialUse === '\\Trash')?.path ?? null;
175
+ }
176
+
177
+ if (destination && destination !== folder) {
178
+ const moved = await moveMessage({
179
+ pool, store, account, poolKey, connection, folder, uid, destination
180
+ });
181
+ return {deleted: true, movedTo: destination, newUid: moved.newUid, expunged: false, mirrored: moved.mirrored};
182
+ }
183
+
184
+ // No Trash, or already there: this is the irreversible path.
185
+ await pool.withMailbox(poolKey, connection, folder, async (client) => {
186
+ await client.messageFlagsAdd({uid: String(uid)}, ['\\Deleted'], {uid: true});
187
+ await client.messageDelete({uid: String(uid)}, {uid: true});
188
+ }, {writable: true});
189
+
190
+ if (store) await store.removeMessages(account.id, folder, [uid]);
191
+ return {deleted: true, movedTo: null, newUid: null, expunged: true};
192
+ }
193
+
194
+ /**
195
+ * Normalise a UID set for an IMAP command.
196
+ *
197
+ * IMAP takes a comma-separated set, so a hundred messages are one command
198
+ * rather than a hundred round trips. Values are coerced and validated here
199
+ * because they arrive from a request body.
200
+ *
201
+ * @param {unknown} uids
202
+ * @returns {{ok: true, uids: number[], set: string} | {ok: false, error: string}}
203
+ */
204
+ export function normaliseUids(uids) {
205
+ if (!Array.isArray(uids) || !uids.length) {
206
+ return {ok: false, error: 'No messages were selected.'};
207
+ }
208
+ const clean = [...new Set(uids.map(Number))].filter(n => Number.isInteger(n) && n > 0);
209
+ if (!clean.length) return {ok: false, error: 'No valid message ids were given.'};
210
+ if (clean.length > 500) return {ok: false, error: 'Too many messages at once - select fewer than 500.'};
211
+ return {ok: true, uids: clean, set: clean.join(',')};
212
+ }
213
+
214
+ /**
215
+ * Add or remove flags across many messages at once.
216
+ *
217
+ * @param {object} options - as setMessageFlags, but with `uids` and `set`
218
+ * @returns {Promise<{updated: {uid: number, flags: string[]}[]}>}
219
+ */
220
+ export async function setFlagsBulk({
221
+ pool, store, account, poolKey, connection, folder, uids, set, add = [], remove = []
222
+ }) {
223
+ const updated = await pool.withMailbox(poolKey, connection, folder, async (client) => {
224
+ if (add.length) await client.messageFlagsAdd({uid: set}, add, {uid: true});
225
+ if (remove.length) await client.messageFlagsRemove({uid: set}, remove, {uid: true});
226
+
227
+ // Read back what the server now holds, for the same reason the single
228
+ // version does: another client may have changed something, and the
229
+ // mirror should hold what is true rather than what was asked for.
230
+ const rows = [];
231
+ for await (const message of client.fetch(set, {uid: true, flags: true}, {uid: true})) {
232
+ rows.push({uid: message.uid, flags: [...(message.flags ?? [])]});
233
+ }
234
+ return rows;
235
+ }, {writable: true});
236
+
237
+ if (store && updated.length) await store.setFlags(account.id, folder, updated);
238
+ return {updated};
239
+ }
240
+
241
+ /**
242
+ * Move many messages at once.
243
+ *
244
+ * @param {object} options
245
+ * @returns {Promise<{moved: number, destination: string, uidMap: Object.<string, number>}>}
246
+ */
247
+ export async function moveBulk({
248
+ pool, store, account, poolKey, connection, folder, uids, set, destination
249
+ }) {
250
+ const result = await pool.withMailbox(poolKey, connection, folder, async (client) => {
251
+ return client.messageMove({uid: set}, destination, {uid: true});
252
+ }, {writable: true});
253
+
254
+ if (store) await store.removeMessages(account.id, folder, uids);
255
+
256
+ // Carried so a bulk move can be undone the same way a single one is.
257
+ const uidMap = {};
258
+ if (result?.uidMap?.forEach) {
259
+ result.uidMap.forEach((to, from) => { uidMap[String(from)] = Number(to); });
260
+ }
261
+
262
+ const mirrored = await ingestMoved({
263
+ pool, store, account, poolKey, connection, destination,
264
+ uids: Object.values(uidMap)
265
+ });
266
+
267
+ return {moved: uids.length, destination, uidMap, mirrored};
268
+ }
269
+
270
+ /**
271
+ * Delete many messages at once.
272
+ *
273
+ * Same meaning as the single version: move to Trash where there is one, and
274
+ * only expunge where there is not.
275
+ *
276
+ * @param {object} options
277
+ * @returns {Promise<{deleted: number, movedTo: string|null, uidMap: object, expunged: boolean}>}
278
+ */
279
+ export async function deleteBulk({
280
+ pool, store, account, poolKey, connection, folder, uids, set
281
+ }) {
282
+ const boxes = await pool.withClient(poolKey, connection, client => client.list());
283
+ const trash = boxes.find(box => box.specialUse === '\\Trash')?.path ?? null;
284
+
285
+ if (trash && trash !== folder) {
286
+ const moved = await moveBulk({
287
+ pool, store, account, poolKey, connection, folder, uids, set, destination: trash
288
+ });
289
+ return {deleted: uids.length, movedTo: trash, uidMap: moved.uidMap, expunged: false, mirrored: moved.mirrored};
290
+ }
291
+
292
+ await pool.withMailbox(poolKey, connection, folder, async (client) => {
293
+ await client.messageFlagsAdd({uid: set}, ['\\Deleted'], {uid: true});
294
+ await client.messageDelete({uid: set}, {uid: true});
295
+ }, {writable: true});
296
+
297
+ if (store) await store.removeMessages(account.id, folder, uids);
298
+ return {deleted: uids.length, movedTo: null, uidMap: {}, expunged: true};
299
+ }
300
+
301
+ /**
302
+ * The keywords a server-side spam learner watches.
303
+ *
304
+ * These are IMAP keywords, not system flags, so a server may refuse them
305
+ * outright - which is why setting them is best-effort below rather than a
306
+ * precondition for the move.
307
+ */
308
+ export const JUNK_KEYWORD = '$Junk';
309
+ export const NOT_JUNK_KEYWORD = '$NotJunk';
310
+
311
+ /**
312
+ * Decide what marking a message as spam (or not) should actually do.
313
+ *
314
+ * Pure, so the interesting part - which is the folder arithmetic, not the
315
+ * IMAP - can be tested without a server.
316
+ *
317
+ * Marking as spam moves to Junk. Marking as NOT spam only moves when the
318
+ * message is sitting in Junk: saying "this isn't spam" about something already
319
+ * filed in a project folder is a correction to the learner, not a request to
320
+ * have it yanked back to the Inbox.
321
+ *
322
+ * @param {{spam: boolean, folder: string, junkFolder: string|null,
323
+ * inboxFolder?: string}} options
324
+ * @returns {{destination: string|null, add: string[], remove: string[]}}
325
+ */
326
+ export function planSpamMove({spam, folder, junkFolder, inboxFolder = 'INBOX'}) {
327
+ const inJunk = Boolean(junkFolder) && folder === junkFolder;
328
+
329
+ if (spam) {
330
+ return {
331
+ destination: junkFolder && !inJunk ? junkFolder : null,
332
+ add: [JUNK_KEYWORD],
333
+ remove: [NOT_JUNK_KEYWORD]
334
+ };
335
+ }
336
+
337
+ return {
338
+ destination: inJunk && inboxFolder !== folder ? inboxFolder : null,
339
+ add: [NOT_JUNK_KEYWORD],
340
+ remove: [JUNK_KEYWORD]
341
+ };
342
+ }
343
+
344
+ /**
345
+ * Where this server keeps junk mail, by SPECIAL-USE.
346
+ *
347
+ * Never by name, for the reason Trash is not found by name either: it is
348
+ * "Spam" on one server, "Junk E-mail" on the next, and localised on the one
349
+ * after that.
350
+ *
351
+ * @param {object} pool
352
+ * @param {string} poolKey
353
+ * @param {object} connection
354
+ * @returns {Promise<string|null>}
355
+ */
356
+ export async function findJunkFolder(pool, poolKey, connection) {
357
+ const boxes = await pool.withClient(poolKey, connection, client => client.list());
358
+ return boxes.find(box => box.specialUse === '\\Junk')?.path ?? null;
359
+ }
360
+
361
+ /**
362
+ * Apply the keywords, tolerating a server that will not have them.
363
+ *
364
+ * A server without PERMANENTFLAGS for arbitrary keywords rejects these, and
365
+ * that must not cost the move - the folder is what the person asked for, the
366
+ * keyword is a hint to the filter. Reported honestly either way.
367
+ *
368
+ * @param {object} options
369
+ * @returns {Promise<{ok: boolean, rows: {uid: number, flags: string[]}[]}>}
370
+ */
371
+ async function applyJunkKeywords({pool, poolKey, connection, folder, set, add, remove}) {
372
+ try {
373
+ const rows = await pool.withMailbox(poolKey, connection, folder, async (client) => {
374
+ if (add.length) await client.messageFlagsAdd({uid: set}, add, {uid: true});
375
+ if (remove.length) await client.messageFlagsRemove({uid: set}, remove, {uid: true});
376
+
377
+ // Read back rather than assume, exactly as setMessageFlags does:
378
+ // a server may have refused the keyword, and the caller has to be
379
+ // able to tell that from having applied it.
380
+ const out = [];
381
+ for await (const message of client.fetch(set, {uid: true, flags: true}, {uid: true})) {
382
+ out.push({uid: message.uid, flags: [...(message.flags ?? [])]});
383
+ }
384
+ return out;
385
+ }, {writable: true});
386
+ return {ok: true, rows};
387
+ } catch {
388
+ return {ok: false, rows: []};
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Mark one message as spam, or as not spam.
394
+ *
395
+ * Keywords first, move second: after the move the message has a new UID in
396
+ * another folder, and the one held here no longer addresses it.
397
+ *
398
+ * @param {{pool: object, store: object|null, account: object, poolKey: string,
399
+ * connection: object, folder: string, uid: number, spam: boolean,
400
+ * junkFolder?: string|null, inboxFolder?: string}} options
401
+ * @returns {Promise<{spam: boolean, movedTo: string|null, newUid: number|null,
402
+ * keywords: boolean}>}
403
+ */
404
+ export async function setSpam({
405
+ pool, store, account, poolKey, connection, folder, uid,
406
+ spam, junkFolder = null, inboxFolder = 'INBOX'
407
+ }) {
408
+ const junk = junkFolder ?? await findJunkFolder(pool, poolKey, connection);
409
+ const plan = planSpamMove({spam, folder, junkFolder: junk, inboxFolder});
410
+
411
+ const applied = await applyJunkKeywords({
412
+ pool, poolKey, connection, folder, set: String(uid),
413
+ add: plan.add, remove: plan.remove
414
+ });
415
+ const keywords = applied.ok;
416
+
417
+ if (!plan.destination) {
418
+ // Nothing moves, so the mirror keeps the row - and it has to carry the
419
+ // new keyword, or the menu goes on offering the action just taken.
420
+ if (store && applied.rows.length) {
421
+ await store.setFlags(account.id, folder, applied.rows);
422
+ }
423
+ return {
424
+ spam, movedTo: null, newUid: null, keywords,
425
+ flags: applied.rows[0]?.flags ?? null, mirrored: false
426
+ };
427
+ }
428
+
429
+ const moved = await moveMessage({
430
+ pool, store, account, poolKey, connection, folder, uid,
431
+ destination: plan.destination
432
+ });
433
+ return {spam, movedTo: plan.destination, newUid: moved.newUid, keywords, mirrored: moved.mirrored};
434
+ }
435
+
436
+ /**
437
+ * Mark many messages as spam, or as not spam.
438
+ *
439
+ * @param {object} options - as setSpam, but with `uids` and `set`
440
+ * @returns {Promise<{spam: boolean, count: number, movedTo: string|null,
441
+ * uidMap: object, keywords: boolean}>}
442
+ */
443
+ export async function setSpamBulk({
444
+ pool, store, account, poolKey, connection, folder, uids, set,
445
+ spam, junkFolder = null, inboxFolder = 'INBOX'
446
+ }) {
447
+ const junk = junkFolder ?? await findJunkFolder(pool, poolKey, connection);
448
+ const plan = planSpamMove({spam, folder, junkFolder: junk, inboxFolder});
449
+
450
+ const applied = await applyJunkKeywords({
451
+ pool, poolKey, connection, folder, set, add: plan.add, remove: plan.remove
452
+ });
453
+ const keywords = applied.ok;
454
+
455
+ if (!plan.destination) {
456
+ if (store && applied.rows.length) {
457
+ await store.setFlags(account.id, folder, applied.rows);
458
+ }
459
+ return {
460
+ spam, count: uids.length, movedTo: null, uidMap: {}, keywords,
461
+ updated: applied.rows, mirrored: false
462
+ };
463
+ }
464
+
465
+ const moved = await moveBulk({
466
+ pool, store, account, poolKey, connection, folder, uids, set,
467
+ destination: plan.destination
468
+ });
469
+ return {
470
+ spam, count: uids.length, movedTo: plan.destination,
471
+ uidMap: moved.uidMap, keywords, mirrored: moved.mirrored
472
+ };
473
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Keeping an installed collection's schema in step with the one a plugin ships.
3
+ *
4
+ * `setupPlugin()` creates a plugin's collections on first enable and then never
5
+ * looks at them again - `if (!existing) createCollection(...)`. So a field added
6
+ * to a shipped schema reaches a FRESH install and nowhere else: every existing
7
+ * site keeps the field list it was created with. The values still store and read
8
+ * back, because entries are plain objects, but nothing that reads the schema -
9
+ * the collection editor, the entries screen, `required` validation - knows the
10
+ * field is there.
11
+ *
12
+ * This closes that gap, and only ever ADDS. It never removes, renames or
13
+ * reorders, because a site may have edited its own schema and a plugin update
14
+ * is no reason to throw that away.
15
+ *
16
+ * @module _lib/schemaSync
17
+ */
18
+ import fs from 'fs/promises';
19
+
20
+ /**
21
+ * Add any field the shipped schema declares that the installed one lacks.
22
+ *
23
+ * @param {string} slug installed collection slug
24
+ * @param {string} shippedSchemaPath absolute path to the plugin's schema.json
25
+ * @param {object} services `{getCollection, updateCollection}`
26
+ * @returns {Promise<string[]>} the names of the fields that were added
27
+ */
28
+ export async function ensureSchemaFields(slug, shippedSchemaPath, services) {
29
+ const { getCollection, updateCollection } = services;
30
+
31
+ const installed = await getCollection(slug).catch(() => null);
32
+ if (!installed) return []; // not installed yet; setupPlugin will create it
33
+
34
+ let shipped;
35
+ try {
36
+ shipped = JSON.parse(await fs.readFile(shippedSchemaPath, 'utf8'));
37
+ } catch {
38
+ return []; // no shipped schema to compare against
39
+ }
40
+
41
+ const have = new Set((installed.fields ?? []).map(f => f.name));
42
+ const missing = (shipped.fields ?? []).filter(f => f.name && !have.has(f.name));
43
+ if (!missing.length) return [];
44
+
45
+ await updateCollection(slug, { fields: [...(installed.fields ?? []), ...missing] });
46
+ return missing.map(f => f.name);
47
+ }
48
+
49
+ /**
50
+ * The same, for every collection a plugin ships.
51
+ *
52
+ * @param {string} collectionsDir the plugin's `collections/` directory
53
+ * @param {object} services `{getCollection, updateCollection}`
54
+ * @param {object} [log] anything with `.info`; console by default
55
+ * @returns {Promise<void>}
56
+ */
57
+ export async function syncPluginSchemas(collectionsDir, services, log = console) {
58
+ let dirs;
59
+ try {
60
+ dirs = await fs.readdir(collectionsDir, { withFileTypes: true });
61
+ } catch {
62
+ return;
63
+ }
64
+
65
+ for (const dir of dirs.filter(d => d.isDirectory())) {
66
+ const schemaPath = `${collectionsDir}/${dir.name}/schema.json`;
67
+ let slug;
68
+ try {
69
+ slug = JSON.parse(await fs.readFile(schemaPath, 'utf8')).slug;
70
+ } catch {
71
+ continue;
72
+ }
73
+ if (!slug) continue;
74
+
75
+ try {
76
+ const added = await ensureSchemaFields(slug, schemaPath, services);
77
+ if (added.length) log.info?.(`[schemaSync] ${slug}: added ${added.join(', ')}`);
78
+ } catch (err) {
79
+ // A schema that will not update is not a reason to refuse to boot.
80
+ log.warn?.(`[schemaSync] ${slug}: ${err.message}`);
81
+ }
82
+ }
83
+ }
File without changes
@@ -1,9 +1,9 @@
1
- <div class="view-header d-flex justify-content-between align-items-center mb-4">
1
+ <div class="view-header">
2
2
  <div>
3
- <h1 class="view-title">PLUGIN_DISPLAY_NAME</h1>
4
- <p class="view-subtitle text-muted">Manage PLUGIN_DISPLAY_NAME.</p>
3
+ <h1>PLUGIN_DISPLAY_NAME</h1>
4
+ <p class="text-muted text-sm mb-0">Manage PLUGIN_DISPLAY_NAME.</p>
5
5
  </div>
6
- <div class="d-flex gap-2">
6
+ <div class="view-header-actions">
7
7
  <button id="btn-new-item" class="btn btn-primary">
8
8
  <span data-icon="plus"></span> New Item
9
9
  </button>