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,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" width="1600" height="900">
2
+ <defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#831843"/><stop offset="1" stop-color="#ec4899"/></linearGradient>
3
+ <filter id="blur"><feGaussianBlur stdDeviation="40"/></filter></defs>
4
+ <rect width="1600" height="900" fill="url(#g)"/>
5
+ <g filter="url(#blur)"><circle cx="1309" cy="431" r="339" fill="#fecdd3" opacity="0.1"/><circle cx="750" cy="714" r="357" fill="#fecdd3" opacity="0.18"/><circle cx="1245" cy="680" r="207" fill="#ffffff" opacity="0.18"/><circle cx="546" cy="727" r="288" fill="#ffffff" opacity="0.18"/><circle cx="1564" cy="596" r="325" fill="#ec4899" opacity="0.14"/><circle cx="172" cy="540" r="370" fill="#ffffff" opacity="0.14"/><circle cx="1078" cy="252" r="125" fill="#ffffff" opacity="0.22"/></g>
6
+ <path d="M0 520 C400 400 1200 640 1600 520" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 580 C400 460 1200 700 1600 580" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 640 C400 520 1200 760 1600 640" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 700 C400 580 1200 820 1600 700" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 760 C400 640 1200 880 1600 760" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 820 C400 700 1200 940 1600 820" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 880 C400 760 1200 1000 1600 880" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/>
7
+ </svg>
@@ -1,9 +1,10 @@
1
- <main class="container" style="padding: 2rem 0;">
2
- <h1 class="mb-4">{{archiveTitle}}</h1>
3
- <div id="blog-posts-list">
4
- {{posts}}
5
- </div>
6
- <div id="blog-pagination" class="mt-4">
7
- {{pagination}}
8
- </div>
1
+ <link rel="stylesheet" href="/plugins/blog/public/blog.css?v={{assetV}}">
2
+ <main class="container blog-page" id="dm-blog">
3
+ <header class="blog-archive-head">
4
+ <h1>{{archiveTitle}}</h1>
5
+ {{archiveIntro}}
6
+ </header>
7
+ {{archive}}
8
+ {{pagination}}
9
9
  </main>
10
+ <script type="module" src="/plugins/blog/public/blog.js?v={{assetV}}"></script>
@@ -1,18 +1,5 @@
1
- <main class="container" style="padding: 2rem 0;">
2
- <article class="blog-post">
3
- <header class="mb-4">
4
- <h1>{{title}}</h1>
5
- <p class="text-muted">by {{authorName}} &bull; <time>{{publishedAt}}</time></p>
6
- <div class="mb-2">{{categories}}{{tags}}</div>
7
- </header>
8
- {{featuredImage}}
9
- <div class="post-content mb-5">
10
- {{content}}
11
- </div>
12
- </article>
13
-
14
- <section id="comments" class="mt-5">
15
- {{comments}}
16
- {{commentForm}}
17
- </section>
1
+ <link rel="stylesheet" href="/plugins/blog/public/blog.css?v={{assetV}}">
2
+ <main class="container blog-page" id="dm-blog">
3
+ {{post}}
18
4
  </main>
5
+ <script type="module" src="/plugins/blog/public/blog.js?v={{assetV}}"></script>
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Blog layouts - the pure half of rendering through blocks (lib/layouts.js).
3
+ *
4
+ * Post fields reach these functions from authors, and the listing's metadata
5
+ * reaches every visitor's browser, so the escaping and the whitelists are
6
+ * pinned here rather than trusted.
7
+ */
8
+ import {test} from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import {
11
+ buildToc, cardMeta, cardVars, interpolate, listDisplays, pickDisplay, pickDisplaySettings,
12
+ pickPostLayout, postLayouts, relatedPosts, safeImageUrl
13
+ } from '../lib/layouts.js';
14
+
15
+ test('interpolate: escaped by default, raw on request, dates formatted, comments dropped', () => {
16
+ const tpl = '<!-- header: {{title}} --><h1>{{title}}</h1>{{{html}}}<time>{{when:D MMM YYYY}}</time>{{missing}}';
17
+ assert.equal(
18
+ interpolate(tpl, {title: '<script>x</script>', html: '<b>ok</b>', when: '2026-09-23T10:00:00Z'}),
19
+ '<h1>&lt;script&gt;x&lt;/script&gt;</h1><b>ok</b><time>23 Sep 2026</time>'
20
+ );
21
+ assert.equal(interpolate('{{when:YYYY}}', {when: 'not a date'}), 'not a date', 'a non-date prints as itself');
22
+ });
23
+
24
+ test('the layouts on offer: shipped ones first, then any blog-post-* / blog-card-* block', () => {
25
+ const names = ['blog-post-long-read', 'blog-card-poster', 'blog-card', 'cta-banner', 'blog-post-classic', 'blog-post-Bad'];
26
+ assert.deepEqual(postLayouts(names).map(l => l.id),
27
+ ['blog-post-classic', 'blog-post-feature', 'blog-post-minimal', 'blog-post-sidebar', 'blog-post-long-read']);
28
+ assert.equal(postLayouts(names).at(-1).label, 'Long read');
29
+ const displays = listDisplays(names);
30
+ assert.deepEqual(displays.map(d => d.id), ['cards', 'list', 'magazine', 'compact', 'blog-card-poster']);
31
+ assert.equal(displays.at(-1).block, 'blog-card-poster');
32
+ });
33
+
34
+ test('a post uses its own layout, else the site default, else Classic', () => {
35
+ const layouts = postLayouts([]);
36
+ assert.equal(pickPostLayout({template: 'blog-post-feature'}, {postLayout: 'blog-post-minimal'}, layouts), 'blog-post-feature');
37
+ assert.equal(pickPostLayout({template: 'blog-post-gone'}, {postLayout: 'blog-post-minimal'}, layouts), 'blog-post-minimal');
38
+ assert.equal(pickPostLayout({}, {postLayout: 'blog-post-gone'}, layouts), 'blog-post-classic');
39
+ const displays = listDisplays([]);
40
+ assert.equal(pickDisplay('list', {listDisplay: 'compact'}, displays).id, 'list');
41
+ assert.equal(pickDisplay('nope', {listDisplay: 'compact'}, displays).id, 'compact');
42
+ assert.equal(pickDisplay('', {}, displays).id, 'cards');
43
+ });
44
+
45
+ test('display settings are whitelisted, and only the keys sent come back', () => {
46
+ assert.deepEqual(pickDisplaySettings({postLayout: 'blog-post-x', listDisplay: 'List!', relatedCount: '99', interactive: 0, other: 1}),
47
+ {postLayout: 'blog-post-x', relatedCount: 6, interactive: false});
48
+ assert.deepEqual(pickDisplaySettings({postLayout: '../etc'}), {});
49
+ assert.deepEqual(pickDisplaySettings({}), {});
50
+ });
51
+
52
+ test('contents: headings get unique ids; one heading is not worth a list', () => {
53
+ const {html, toc, count} = buildToc('<h2>Intro</h2><p>x</p><h3 id="kept">Detail</h3><h2>Intro</h2>');
54
+ assert.equal(count, 3);
55
+ assert.match(html, /<h2 id="intro">Intro<\/h2>/);
56
+ assert.match(html, /<h2 id="intro-2">Intro<\/h2>/);
57
+ assert.match(html, /<h3 id="kept">/);
58
+ assert.match(toc, /href="#intro-2"/);
59
+ assert.equal(buildToc('<h2>Only</h2>').toc, '');
60
+ });
61
+
62
+ test('image addresses are only ever plain URLs - nothing that escapes url() or src=""', () => {
63
+ assert.equal(safeImageUrl('/media/a.jpg'), '/media/a.jpg');
64
+ assert.equal(safeImageUrl('https://x.test/a.png'), 'https://x.test/a.png');
65
+ for (const bad of ["javascript:alert(1)", "/a.jpg') ; background:red", '/a".jpg', 'a.jpg', '']) {
66
+ assert.equal(safeImageUrl(bad), '', bad);
67
+ }
68
+ });
69
+
70
+ test('a card: markup only where the plugin built it; the browser gets plain values', () => {
71
+ const post = {id: 'p1', slug: 'hi there', title: 'A <b>', excerpt: 'e', categories: ['c1', 'gone'], tags: ['x"y'],
72
+ authorId: 'u1', content: 'word '.repeat(450), featuredImage: 'javascript:1', publishedAt: '2026-01-02T00:00:00Z'};
73
+ const catMap = {c1: {name: 'News <i>', slug: 'news'}};
74
+ const v = cardVars(post, {base: '/blog', catMap, authorName: 'Ann'});
75
+ assert.equal(v.url, '/blog/hi%20there');
76
+ assert.equal(v.image, '', 'an unsafe image is dropped, not escaped into place');
77
+ assert.equal(v.imageClass, 'no-image');
78
+ assert.equal(v.readingTime, '2 min read');
79
+ assert.match(v.categories, /News &lt;i&gt;/);
80
+ assert.match(v.tags, /data-blog-tag="x&quot;y"/);
81
+ const m = cardMeta(post, {catMap, authorName: 'Ann'});
82
+ assert.deepEqual(m.cats, [{slug: 'news', name: 'News <i>'}], 'plain text: the browser sets it as text');
83
+ assert.equal(m.minutes, 2);
84
+ });
85
+
86
+ test('related posts: shared categories outweigh shared tags; newer breaks a tie', () => {
87
+ const me = {id: 'me', categories: ['a'], tags: ['t']};
88
+ const all = [
89
+ me,
90
+ {id: 'tag-only', tags: ['t'], publishedAt: '2026-05-01'},
91
+ {id: 'cat-old', categories: ['a'], publishedAt: '2026-01-01'},
92
+ {id: 'cat-new', categories: ['a'], publishedAt: '2026-03-01'},
93
+ {id: 'none', publishedAt: '2026-06-01'}
94
+ ];
95
+ assert.deepEqual(relatedPosts(me, all, 3).map(p => p.id), ['cat-new', 'cat-old', 'tag-only']);
96
+ assert.deepEqual(relatedPosts(me, all, 0), []);
97
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Blog - the routes readers and authors actually hit.
3
+ *
4
+ * The public pages are driven through @fastify/compress with a browser's
5
+ * Accept-Encoding, because that is where they broke: the handlers sent the
6
+ * page without returning the reply, and under compression every browser got a
7
+ * 200 with an EMPTY body. Plain requests (curl, tests without compression)
8
+ * still got the page, which is how it went unnoticed.
9
+ *
10
+ * Nothing here writes to the host CMS: the public plugin only reads, and the
11
+ * post rules are tested on the pure function that applies them.
12
+ */
13
+ import {test} from 'node:test';
14
+ import assert from 'node:assert/strict';
15
+ import zlib from 'zlib';
16
+ import Fastify from 'fastify';
17
+ import compress from '@fastify/compress';
18
+ import blogPublicPlugin from '../plugin.public.js';
19
+ import {postFields} from '../plugin.js';
20
+
21
+ async function buildPublic() {
22
+ const app = Fastify();
23
+ await app.register(compress, {global: true, threshold: 1024, encodings: ['br', 'gzip', 'deflate']});
24
+ await app.register(blogPublicPlugin, {settings: {basePath: '/blog'}, config: {}});
25
+ return app;
26
+ }
27
+
28
+ test('the blog index reaches a browser whole, under every encoding', async () => {
29
+ const app = await buildPublic();
30
+ const decoders = {gzip: zlib.gunzipSync, br: zlib.brotliDecompressSync, deflate: zlib.inflateSync};
31
+ for (const enc of ['gzip, deflate, br', 'gzip', 'br', 'identity']) {
32
+ const res = await app.inject({method: 'GET', url: '/blog', headers: {'accept-encoding': enc}});
33
+ assert.equal(res.statusCode, 200, enc);
34
+ const used = res.headers['content-encoding'];
35
+ const body = (used ? decoders[used](res.rawPayload) : res.rawPayload).toString('utf8');
36
+ assert.ok(body.length > 1000, `${enc}: a page, not an empty body (${body.length} bytes)`);
37
+ assert.match(body, /data-blog-archive/, enc);
38
+ assert.match(body, /\/plugins\/blog\/public\/blog\.js/, `${enc}: the listing script is on the page`);
39
+ }
40
+ const feed = await app.inject({method: 'GET', url: '/blog/feed.xml', headers: {'accept-encoding': 'gzip'}});
41
+ const xml = feed.headers['content-encoding'] === 'gzip' ? zlib.gunzipSync(feed.rawPayload) : feed.rawPayload;
42
+ assert.match(xml.toString('utf8'), /<rss version="2.0">/);
43
+ await app.close();
44
+ });
45
+
46
+ test('a display can be asked for, and an unknown one falls back', async () => {
47
+ const app = await buildPublic();
48
+ const list = await app.inject({method: 'GET', url: '/blog?display=compact'});
49
+ assert.match(list.body, /data-display="compact"/);
50
+ const odd = await app.inject({method: 'GET', url: '/blog?display=%3Cscript%3E'});
51
+ assert.doesNotMatch(odd.body, /data-display="<script>/);
52
+ assert.match(odd.body, /data-display="cards"/);
53
+ await app.close();
54
+ });
55
+
56
+ test('an author cannot publish by writing the status - that is what the publish route is for', () => {
57
+ const author = {id: 'a', role: 'blog-author'};
58
+ const editor = {id: 'e', role: 'blog-editor'};
59
+ assert.equal(postFields({status: 'published'}, author).status, undefined);
60
+ assert.equal(postFields({status: 'scheduled'}, author).status, undefined);
61
+ assert.equal(postFields({status: 'draft'}, author).status, 'draft', 'anyone may move their post back to draft');
62
+ assert.equal(postFields({status: 'published'}, editor).status, 'published');
63
+ assert.equal(postFields({status: 'bogus'}, editor).status, undefined);
64
+ });
65
+
66
+ test('a post keeps only the fields it has - no authorship, no sample flag, a valid layout', () => {
67
+ const out = postFields({title: ' T ', authorId: 'someone-else', sample: true, template: 'blog-post-feature', tags: [' a ', '', 'b']}, {role: 'admin'});
68
+ assert.deepEqual(out, {title: 'T', template: 'blog-post-feature', tags: ['a', 'b']});
69
+ assert.equal(postFields({template: '../../etc/passwd'}, {role: 'admin'}).template, '');
70
+ assert.equal(postFields({template: ''}, {role: 'admin'}).template, '', 'empty is "use the site default"');
71
+ });
@@ -0,0 +1,74 @@
1
+ {
2
+ "note": "Written by `make sync-free-tier` in dcms-marketplace. Do not edit these plugins here - edit them there and sync.",
3
+ "source": "dcms-marketplace@6f9dbf3",
4
+ "plugins": {
5
+ "blog": {
6
+ "version": "1.4.4",
7
+ "files": {
8
+ "CLAUDE.md": "69087729ee22dddef5868e14fd6dd4570bb5387917020de873948785e6919756",
9
+ "admin/css/index.css": "2f39febfe02491f6db0e32b766723c3a2950a6a1139a108b2ec756d395472f30",
10
+ "admin/templates/blog.html": "a6054c1febe764bb1edf5c9e466d44a9080d9c83ba5486437b7a8244acf5ba54",
11
+ "admin/templates/categories.html": "ab7d6fc2fedd48876c585bc216a1532b3bdd78c946dee976c1171bfe4664feb6",
12
+ "admin/templates/comments.html": "095ff03bd5e1ed9ac75edf1ceb9fbe7b2907a1e35b261672da51e00742c2814a",
13
+ "admin/templates/post-editor.html": "c5292ecac2bfe4994ac62b4bf67631b7959dfa068cc3c1332d2268d870d0b467",
14
+ "admin/templates/settings.html": "e0730cd57435e592034328a699198d5e1326084767156bfc2289852f5f1a45af",
15
+ "admin/views/blog.js": "52bf73bfbc2d8736c1217d89dec1e69649f4b2f5334b7da1abf33bb1981dc921",
16
+ "admin/views/categories.js": "dade241e8d70d101366c5c8e9eed721f7b39ffda65025a272ae5679660f0f50c",
17
+ "admin/views/comments.js": "f5055e7b8e0c3fd8f71b2a8aeeb47c68fab5ff7d24ffb5d9e052eedfc1da1df2",
18
+ "admin/views/kit.js": "c7b475fe5920b6429a03976a1463a218751e0ed2e42135920e4c3b1ebf47e79f",
19
+ "admin/views/post-editor.js": "97ea927c539419940fccf4a78fc3098e00beca3454a56ef432fa94b7f3cacb81",
20
+ "admin/views/settings-cog.js": "307efc93835863dd8511f8fb17a224cf45a77532c90b854a86d2cd1d4e5f9f18",
21
+ "admin/views/settings.js": "2b7f775e37e034dee4ae3dfadea5e446c2e526bed8a066ef3ca2e777146ada23",
22
+ "blocks/blog-card-compact.css": "eb8e8ae9a6461c362bc021ea0396c6b4481047b6b36a50eeb59e47375e49be75",
23
+ "blocks/blog-card-compact.html": "7e1e31923808e6108d57d34309d3442b5d93b4aa4396f0e471e870249934ab8f",
24
+ "blocks/blog-card-row.css": "f5f0249c51a40cc71ff8d054bfa887353badc79730b0f323c76ac32f7d893b44",
25
+ "blocks/blog-card-row.html": "eb28d26c86c4cefcf553273e6547885b0dc03fbb6e2543e9c1aa4b132db6216a",
26
+ "blocks/blog-card.css": "d480a80a7378bd55659e9549bb30d8c106bdae1a9cd450a8c0114bfaefa94cd2",
27
+ "blocks/blog-card.html": "cf676e8e9f676ea8b49637cb54a7d6fcd6f3395af8f4a8833a65ba78b35efc76",
28
+ "blocks/blog-post-classic.css": "9acac617847b5a10dabd5efccb601572e2751f792c4038475325f11e24142584",
29
+ "blocks/blog-post-classic.html": "1fab77c7aeb67fe72b73c4db42ff4c3a66466c041104639f4898c390c665fc38",
30
+ "blocks/blog-post-feature.css": "7d8a242b627f3826df21d5470e643b386967122ea3585e69f96cce9ffc2452e9",
31
+ "blocks/blog-post-feature.html": "c0408538cc269e4f2873f9c9c44a000347e4d5cafe3ecb024f62112ea711a9ab",
32
+ "blocks/blog-post-minimal.css": "2323b84a7d0a99e1c85c30faa18bb2ee16763ff08cfe87f181ffadf8cff84dd0",
33
+ "blocks/blog-post-minimal.html": "3a04925980018a13038ebf4bcfecc4f69284c1bac087631ddc0f66e0e95e07fe",
34
+ "blocks/blog-post-sidebar.css": "3c66109fa48ca448605eb5acd0c998f43649fb3747813b4a811992ec89886007",
35
+ "blocks/blog-post-sidebar.html": "d510a9e272e9415941064b4567def47a179b88825e29c6dd340540870ab04a61",
36
+ "collections/categories/schema.json": "0728e522ed8e3c623fd45e137cd03e0960aa5158e70613ec8628c8b3928e0929",
37
+ "collections/comments/schema.json": "29cbd289cbfcfc4158c9357a218db4ce06eddc3370bfbf9afb7d7f52065a849e",
38
+ "collections/posts/schema.json": "19a5a770d3eefea73f4117454bd1312a7a7e3d727cfcdfde141139fbc81cabd7",
39
+ "config.js": "ee67396262762f5c31269d258affd75f7392a3e364f72f7792c2f1cdafd94bba",
40
+ "lib/layouts.js": "e55dfd79227a36e190d648dcbd3c31de538dafed9b5ca68844c98ef79b6c2509",
41
+ "lib/page.js": "1fb9a01c3080e8ecf65c19b1273b23d550a131fe30079a493a9f5b1c90b720ae",
42
+ "lib/render.js": "0e97c6a950bc962c8d8ea4b2f21d06cb73fbcdc738d8942d6935d01309a29b1d",
43
+ "lib/samples.js": "e8cdb77ce22e43937707f6854d3a5cb722437dc584e22ac7b24480bef686cf85",
44
+ "plugin.js": "32e223ff46bfc72a4db6904425ef9488538a901d73c67be41a0fc0cc037e457b",
45
+ "plugin.json": "16104f855c0afa2912d5a96465d6388e4090d56fabbf566401942d8160ea2d1b",
46
+ "plugin.public.js": "053bbc0322ba205640fdbb834340883d0698600e7a637c8165ecac9d49c8d040",
47
+ "public/blog.css": "fd733e6b624190da1e70dc1b6613e2376efd7f7570a28fab311d81006d03d960",
48
+ "public/blog.js": "d01c72a9834dcc7c5241923371f2b164ac30d20ac9f1a5c5dc5b71fa3cb09f08",
49
+ "public/samples/aurora.svg": "feb29e2b46465913fc29de9eda221b00b1541f1fa3ea7120f0c1d1cd11a9a4f6",
50
+ "public/samples/dusk.svg": "92469dcfe2e78ed9bfa6ddeb9f9c4726ec64e04c6e9b40260523a825b582b580",
51
+ "public/samples/ember.svg": "754cb6e1cc2d26274f24bf86baa7658a402448944dcfb10709c658abad9b29d0",
52
+ "public/samples/harbour.svg": "a5f09eafa1406da3c2d017e9bee308a98543df15090ed27227cbbf8341aec460",
53
+ "public/samples/meadow.svg": "90efae21e209b7d48901eed457f47d0a347c58a6b6e1ea81ba98c131c4c559cb",
54
+ "public/samples/rosewood.svg": "0f963fe5633585786271255b7c9df36be67748ae751f9cf7b0bbda6f5b93449d",
55
+ "roles/blog-author.json": "ccdd4d99296c157ddc839af22ef6e79c47c381dd9d937069f7090a4fb76bb41d",
56
+ "roles/blog-editor.json": "bea0b673cf9e3da04c5882d51dd33bdcaf035b047c1cfe87624a9f16e6514ffc",
57
+ "templates/index.html": "3c2cf8a4ffde8599d451f6d00b736aeba42fd073b726ada3b0ffe4d56e1ef0f4",
58
+ "templates/post.html": "28e9f06071115ecc4fbba007b35782732bf12ef72528525893d424e3c4f0313f",
59
+ "tests/layouts.test.js": "70e0e462985d14618838c3897ffff7c453b711b64c6fc9277023268b0755377c",
60
+ "tests/public.test.js": "ef04ba271d03cee86adac332c6834bb51102b20d892d5f84d30eb04507605de5"
61
+ }
62
+ },
63
+ "mail-reader": {
64
+ "version": "1.0.0",
65
+ "files": {
66
+ "CLAUDE.md": "a8dd2cb285e1d24b2f072aa11a644e1c8c1be89013da6f6cc46d9820fe24ccd5",
67
+ "admin/views/mail.js": "c5c6483f9618c75cac8f4be32d3aea99511db76d4b9d38b8ef43bdc1d35db3c5",
68
+ "config.js": "6e65803ad822a9acda40c203631b8eb32a87cf9d9e5f1c7cb59d01aa406075c6",
69
+ "plugin.js": "79b17346ed0192b092040460cc8d762d5207ee6e4af7bfc2d1cf654a51e2fa81",
70
+ "plugin.json": "5f5b98757514aa70d7c7a2be5a1fbfdde8ef21a2ccc7586b3b5aea9b44fbe5e6"
71
+ }
72
+ }
73
+ }
74
+ }
@@ -0,0 +1,78 @@
1
+ # mail-reader — AI Assistant Guide
2
+
3
+ ## Purpose
4
+
5
+ Read-only IMAP mailbox reader. Connects to a **standard IMAP server** and displays mail. It
6
+ deliberately cannot write: no mark-as-read, no flagging, no move, no delete, no send.
7
+
8
+ Full mail (sending, flags, drafts, search, sync) is **`email-pro`** — a separate Pro-tier plugin
9
+ that requires MongoDB. Nothing here should grow toward it by accident — phase 1 keeps **no message store at
10
+ all**, which is what makes the Pro sync layer a clean addition rather than a migration.
11
+
12
+ ## Files
13
+
14
+ - `plugin.json`, `config.js`, `plugin.js` — manifest, defaults, routes
15
+ - `admin/views/mail.js`, `admin/templates/mail.html`, `admin/css/mail.css`
16
+
17
+ The IMAP core — crypto, account store, connection pool, renderer — is **not here**. It lives in
18
+ `plugins/_lib/mail/`, shared with `email-pro`, with its state at `plugins/_lib/data/`. See
19
+ `plugins/_lib/mail/CLAUDE.md` before changing anything about credentials or connections.
20
+
21
+ ## Routes
22
+
23
+ All under `/api/plugins/mail-reader`, all requiring authentication.
24
+
25
+ | Method | Path | Purpose |
26
+ |---|---|---|
27
+ | GET | `/accounts` | Every mailbox this user owns. Never returns passwords, only `hasPassword`. |
28
+ | POST | `/accounts` | Add a mailbox. |
29
+ | PUT | `/accounts/:id` | Update one. A blank password means "keep the stored one". |
30
+ | DELETE | `/accounts/:id` | Forget a mailbox and its password. |
31
+ | POST | `/accounts/test` | Try details without saving. With `id` and a blank password, uses the stored one. |
32
+ | GET | `/folders` | Folder list, `\Noselect` filtered out. `?account=` |
33
+ | GET | `/messages` | One page of envelopes, newest first. `?account=&folder=&page=&limit=` |
34
+ | GET | `/messages/:uid` | One message, rendered. `?account=&folder=&images=load` |
35
+ | GET | `/messages/:uid/attachments/:index` | Streams one part as a download. `?account=&folder=` |
36
+
37
+ A user may hold several mailboxes. Omitting `?account=` selects their first one, so the reader can
38
+ open on something rather than asking which before it has shown anything.
39
+
40
+ ## Gotchas
41
+
42
+ - **Read-only is enforced, not merely omitted.** Mailboxes open `{readOnly: true}` and ImapFlow
43
+ fetches with `BODY.PEEK[]`. Drop either and the server sets `\Seen` as a side effect of *reading*
44
+ a message — the user's phone then shows mail they never opened. There is no route that writes;
45
+ keep it that way until mark-as-read is a deliberate, visible action.
46
+ - **No permission resource, on purpose.** Access control is ownership: every route resolves the
47
+ account from `request.user`, and no route takes someone else's id. Registering a `mail-reader` resource
48
+ would 403 every user *including super-admin* (no bypass since 0.22) from the moment the plugin was
49
+ enabled, because a new resource belongs to no role and nothing back-fills it.
50
+ - **Credentials are not a collection.** `schema.plugin` is a badge, not a visibility gate — a
51
+ plugin-owned collection still appears in admin Collections with a working Entries link, is
52
+ reachable via `/api/v1`, and can be pointed at a shared Mongo. Hence `data/accounts.json`, 0600,
53
+ gitignored, and excluded from the npm `files` list.
54
+ - **Key resolution is a chain**, not a single source: `MAIL_SECRET` → `INSTANCE_SECRET` →
55
+ generated `data/.mailkey`. `INSTANCE_SECRET` only exists on sites that ran setup or registered
56
+ with the manager, so relying on it alone breaks every older site. The sealing source is recorded
57
+ in each token so a key change fails with a sentence, not a bare auth error.
58
+ - **Never derive from `JWT_SECRET`.** Rotating it logs everyone out, which is recoverable; silently
59
+ bricking every stored mailbox password at the same time is not.
60
+ - **`transformTags` runs BEFORE attribute filtering** in sanitize-html. `target` and `rel` must be
61
+ on `allowedAttributes.a` or the values the transform just set are stripped straight back off, and
62
+ links inside the sandboxed iframe silently do nothing. This has already been fixed once.
63
+ - **Remote images are blocked by default** and the per-message override is never persisted — it
64
+ lives in a `Set` in the view. Loading a remote image reports the open back to the sender; that
65
+ decision should be per message, not a preference someone forgets they set.
66
+ - **The iframe sandbox has neither `allow-scripts` nor `allow-same-origin`.** That pair is the whole
67
+ defence. `allow-popups` is there only so links can open in a new tab.
68
+ - **Attachment downloads go through `fetch` + blob**, not a plain `<a href>` — the route needs the
69
+ bearer token, which a link cannot carry.
70
+ - **Single-process only.** The connection pool is module state, like the messages plugin's SSE
71
+ registry. Multiple workers would each open their own connections and hit per-user server limits.
72
+
73
+ ## Testing
74
+
75
+ `services/secretbox.js` and `services/render.js` are pure and unit-tested. Connection behaviour
76
+ should be checked against a **real IMAP server** — `mail.dommajs.org` answers on 993 (TLS) and 143
77
+ (STARTTLS). A wrong-credentials attempt is enough to verify TLS, auth-failure mapping and that the
78
+ pool leaves nothing open; it does not verify folder listing or rendering, which need a real mailbox.
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Mail Reader admin view.
3
+ *
4
+ * The three-pane reader is shared with the other edition and lives in
5
+ * `plugins/_lib/admin/mail/reader-view.js`. It is imported dynamically so the
6
+ * loader's cache-busting token reaches it: a static specifier is fixed, so the
7
+ * shared code would keep being served from cache after an update while this
8
+ * file was refetched.
9
+ *
10
+ * @module mail-reader/admin/views/mail
11
+ */
12
+ const ASSET_VERSION = new URL(import.meta.url).searchParams.get('v') ?? '1';
13
+
14
+ const {createReaderView} = await import(`/plugins/_lib/admin/mail/reader-view.js?v=${ASSET_VERSION}`);
15
+
16
+ export const mailView = createReaderView({
17
+ pluginName: 'mail-reader',
18
+ assetVersion: ASSET_VERSION
19
+ });
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Mail Reader - default configuration.
3
+ *
4
+ * The reading defaults are shared with `email-pro` and live in
5
+ * `plugins/_lib/mail/defaults.js`. The free edition uses them unchanged.
6
+ */
7
+ export {default} from '../_lib/mail/defaults.js';
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Mail Reader plugin - read-only IMAP mailbox reader.
3
+ *
4
+ * Almost nothing lives here. The routes are `registerReadRoutes()` from
5
+ * `plugins/_lib/mail/`, shared with `email-pro`, which registers the same
6
+ * surface and adds writing, sending and a synchronised store on top. This file
7
+ * is the free edition's wiring: its defaults, its connection pool, and the
8
+ * decision to register the read surface and nothing else.
9
+ *
10
+ * Routes mount at /api/plugins/mail-reader. See
11
+ * `plugins/_lib/mail/readRoutes.js` for what they do and why they are
12
+ * read-only at the protocol level rather than merely by omission.
13
+ *
14
+ * @module mail-reader/plugin
15
+ */
16
+ import {createPool} from '../_lib/mail/imapPool.js';
17
+ import {registerReadRoutes} from '../_lib/mail/readRoutes.js';
18
+ import defaultConfig from './config.js';
19
+
20
+ export default async function mailReaderPlugin(fastify, options) {
21
+ const config = {...defaultConfig, ...(options.settings || {})};
22
+ const pool = createPool(config);
23
+
24
+ // Connections are process state; a reload or shutdown should close them
25
+ // rather than leave the mail server holding dead sessions.
26
+ fastify.addHook('onClose', async () => pool.closeAll());
27
+
28
+ registerReadRoutes(fastify, {
29
+ auth: options.auth,
30
+ hooks: options.hooks,
31
+ config,
32
+ pool,
33
+ pluginName: 'mail-reader'
34
+ });
35
+ }
36
+
37
+ /**
38
+ * Nothing to provision - the plugin's only state is the shared mail store,
39
+ * which is created on first save.
40
+ *
41
+ * @returns {Promise<void>}
42
+ */
43
+ export async function onEnable() {}
44
+
45
+ /**
46
+ * @returns {Promise<void>}
47
+ */
48
+ export async function onDisable() {}
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "mail-reader",
3
+ "displayName": "Mail Reader",
4
+ "version": "1.0.0",
5
+ "tier": "free",
6
+ "description": "Read-only IMAP mailbox reader. Connects to a standard IMAP server and reads mail without ever writing to it.",
7
+ "author": "Darryl Waterhouse",
8
+ "date": "2026-09-19",
9
+ "icon": "mail",
10
+ "admin": {
11
+ "sidebar": [
12
+ {
13
+ "id": "mail-reader",
14
+ "text": "Mail Reader",
15
+ "icon": "mail",
16
+ "url": "#/plugins/mail-reader",
17
+ "section": "#/plugins/mail-reader"
18
+ }
19
+ ],
20
+ "routes": [
21
+ {
22
+ "path": "/plugins/mail-reader",
23
+ "view": "plugin-mail-reader",
24
+ "title": "Mail Reader - Domma CMS"
25
+ }
26
+ ],
27
+ "views": {
28
+ "plugin-mail-reader": {
29
+ "entry": "mail-reader/admin/views/mail.js?v=1",
30
+ "exportName": "mailView"
31
+ }
32
+ }
33
+ }
34
+ }
@@ -1 +1 @@
1
- .fb-form-wrapper{max-width:600px;margin:2rem auto}.fb-form-honeypot{position:absolute;left:-9999px;top:-9999px;width:0;height:0;overflow:hidden;opacity:0;z-index:-1}.fb-form-success{padding:1rem 1.25rem;border-radius:var(--dm-radius-md, 6px);background:color-mix(in srgb,var(--dm-success) 12%,transparent);border:1px solid color-mix(in srgb,var(--dm-success) 30%,transparent);color:var(--dm-success);font-size:.95rem;margin-top:.5rem}.fb-form-error{padding:.75rem 1rem;border-radius:var(--dm-radius-md, 6px);background:color-mix(in srgb,var(--dm-danger) 10%,transparent);border:1px solid color-mix(in srgb,var(--dm-danger) 25%,transparent);color:var(--dm-danger);font-size:.875rem;margin-top:.75rem}.fb-form-loading{opacity:.6;pointer-events:none}.wizard-progress{height:4px;background:var(--dm-border);border-radius:2px;margin-bottom:1rem}.wizard-progress-bar{height:100%;background:var(--dm-primary);border-radius:2px;transition:width .3s ease}.wizard-step-title{font-size:1.1rem;font-weight:600;margin-bottom:.25rem}.wizard-step-description{font-size:.875rem;color:var(--dm-text-muted);margin-bottom:1rem}.wizard-nav{display:flex;justify-content:space-between;gap:.5rem;margin-top:1rem}.fb-field-hidden{display:none!important}.fb-validation-error{color:var(--dm-danger);font-size:.8rem;margin-top:.25rem}.fb-form-wrapper .domma-form-field{margin-bottom:2rem}.fb-form-wrapper .form-group:not(.form-buttons){margin-bottom:2rem}.fb-spacer{height:2rem}.fb-field-required .picker-label:after,.fb-field-required>.form-label:after{content:" *";color:var(--dm-danger)}.fb-field-required .picker-label .picker-required{display:none}.fb-field-invalid>.form-label,.fb-field-invalid .picker-label{color:var(--dm-danger)}.fb-field-invalid .form-input,.fb-field-invalid .form-select,.fb-field-invalid textarea,.fb-field-invalid .picker-options{border-color:var(--dm-danger)}@media(max-width:700px){.fb-form-wrapper form.grid,[data-form-inline] form.grid,.fb-form-wrapper .wizard form.grid,[data-form-inline] .wizard form.grid{grid-template-columns:1fr!important}.fb-form-wrapper form.grid>[class*=col-span-],[data-form-inline] form.grid>[class*=col-span-]{grid-column:span 1 / span 1!important}}.wizard-nav{gap:1rem;flex-wrap:wrap}.wizard-nav .btn{min-width:7.5rem}@media(max-width:480px){.wizard-nav{flex-direction:column-reverse}.wizard-nav .btn{width:100%}}.fb-form-wrapper .form-buttons,[data-form-inline] .form-buttons{display:flex;gap:1rem;flex-wrap:wrap;margin-top:1.25rem}
1
+ .fb-form-wrapper{max-width:600px;margin:2rem auto}.fb-form-honeypot{position:absolute;left:-9999px;top:-9999px;width:0;height:0;overflow:hidden;opacity:0;z-index:-1}.fb-form-success{padding:1rem 1.25rem;border-radius:var(--dm-radius-md, 6px);background:color-mix(in srgb,var(--dm-success) 12%,transparent);border:1px solid color-mix(in srgb,var(--dm-success) 30%,transparent);color:var(--dm-success);font-size:.95rem;margin-top:.5rem}.fb-form-error{padding:.75rem 1rem;border-radius:var(--dm-radius-md, 6px);background:color-mix(in srgb,var(--dm-danger) 10%,transparent);border:1px solid color-mix(in srgb,var(--dm-danger) 25%,transparent);color:var(--dm-danger);font-size:.875rem;margin-top:.75rem}.fb-form-loading{opacity:.6;pointer-events:none}.wizard-progress{height:4px;background:var(--dm-border);border-radius:2px;margin-bottom:1rem}.wizard-progress-bar{height:100%;background:var(--dm-primary);border-radius:2px;transition:width .3s ease}.wizard-step-title{font-size:1.1rem;font-weight:600;margin-bottom:.25rem}.wizard-step-description{font-size:.875rem;color:var(--dm-text-muted);margin-bottom:1rem}.wizard-nav{display:flex;justify-content:space-between;gap:.5rem;margin-top:1rem}.fb-field-hidden{display:none!important}.fb-validation-error{color:var(--dm-danger);font-size:.8rem;margin-top:.25rem}.fb-field-trigger-hidden{display:none!important}.fb-field-disabled{opacity:.55;pointer-events:none}.fb-trigger-banner{display:flex;align-items:flex-start;gap:.75rem;margin:.75rem 0;padding:.7rem .9rem;border-radius:var(--dm-radius-md);border-left:3px solid var(--dm-info);background:var(--dm-surface-raised);color:var(--dm-text);font-size:.9rem}.fb-trigger-banner--success{border-left-color:var(--dm-success)}.fb-trigger-banner--warning{border-left-color:var(--dm-warning)}.fb-trigger-banner--error{border-left-color:var(--dm-danger)}.fb-trigger-banner-text{flex:1 1 auto}.fb-trigger-banner-close{flex:0 0 auto;background:none;border:0;color:var(--dm-text-muted);font-size:1.1rem;line-height:1;cursor:pointer;padding:0 .15rem}.fb-trigger-banner-close:hover{color:var(--dm-text)}.fb-trigger-block-note{color:var(--dm-danger);font-size:.85rem;margin:.5rem 0 0}.fb-form-wrapper .grid>.fb-trigger-banner,.cms-form-embed .grid>.fb-trigger-banner{grid-column:1 / -1}.fb-form-wrapper .domma-form-field{margin-bottom:2rem}.fb-form-wrapper .form-group:not(.form-buttons){margin-bottom:2rem}.fb-spacer{height:2rem}.fb-field-required .picker-label:after,.fb-field-required>.form-label:after{content:" *";color:var(--dm-danger)}.fb-field-required .picker-label .picker-required{display:none}.fb-field-invalid>.form-label,.fb-field-invalid .picker-label{color:var(--dm-danger)}.fb-field-invalid .form-input,.fb-field-invalid .form-select,.fb-field-invalid textarea,.fb-field-invalid .picker-options{border-color:var(--dm-danger)}@media(max-width:700px){.fb-form-wrapper form.grid,[data-form-inline] form.grid,.fb-form-wrapper .wizard form.grid,[data-form-inline] .wizard form.grid{grid-template-columns:1fr!important}.fb-form-wrapper form.grid>[class*=col-span-],[data-form-inline] form.grid>[class*=col-span-]{grid-column:span 1 / span 1!important}}.wizard-nav{gap:1rem;flex-wrap:wrap}.wizard-nav .btn{min-width:7.5rem}@media(max-width:480px){.wizard-nav{flex-direction:column-reverse}.wizard-nav .btn{width:100%}}.fb-form-wrapper .form-buttons,[data-form-inline] .form-buttons{display:flex;gap:1rem;flex-wrap:wrap;margin-top:1.25rem}
@@ -0,0 +1 @@
1
+ .site-search-trigger{display:inline-flex;align-items:center;justify-content:center;background:none;border:none;cursor:pointer;padding:6px 8px;border-radius:var(--dm-radius, 6px);color:inherit;opacity:.75;transition:opacity .15s,background .15s;font-size:var(--dm-font-size-sm, 14px)}.site-search-trigger:hover{opacity:1;background:var(--dm-hover-bg, rgba(128, 128, 128, .14))}.site-search-trigger svg,.site-search-trigger span[data-icon]{width:18px;height:18px}.site-search-shortcut-hint{display:inline-block;font-size:11px;line-height:1;padding:2px 5px;border-radius:4px;border:1px solid var(--dm-border, rgba(0, 0, 0, .15));color:var(--dm-text-muted, #888);margin-left:4px;vertical-align:middle;font-family:var(--dm-font-mono, monospace)}.site-search-overlay{position:fixed;inset:0;z-index:10000;background:#00000073;backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);display:flex;align-items:flex-start;justify-content:center;padding-top:10vh;animation:ss-overlay-in .15s ease}@keyframes ss-overlay-in{0%{opacity:0}to{opacity:1}}.site-search-panel{width:100%;max-width:600px;margin:0 16px;border-radius:var(--dm-radius-lg, 10px);background:#1e2030;color:#e8eaf0;border:1px solid rgba(255,255,255,.1);box-shadow:0 24px 64px #0009;overflow:hidden;animation:ss-panel-in .15s ease}@keyframes ss-panel-in{0%{transform:translateY(-12px);opacity:0}to{transform:translateY(0);opacity:1}}.site-search-header{display:flex;align-items:center;padding:12px 16px;border-bottom:1px solid rgba(255,255,255,.08);gap:8px}.site-search-header span[data-icon],.site-search-header svg{width:18px;height:18px;flex-shrink:0;opacity:.45;color:#e8eaf0}.site-search-input{flex:1;border:none;outline:none;background:transparent;font-size:var(--dm-font-size-lg, 16px);color:#e8eaf0;font-family:var(--dm-font-sans, sans-serif);padding:0}.site-search-input::placeholder{color:#e8eaf066;opacity:1}.site-search-close-btn{display:inline-flex;align-items:center;justify-content:center;background:none;border:1px solid rgba(255,255,255,.15);cursor:pointer;color:#e8eaf080;border-radius:var(--dm-radius, 6px);padding:4px 8px;font-size:12px;font-family:var(--dm-font-mono, monospace);transition:color .15s,border-color .15s;flex-shrink:0}.site-search-close-btn:hover{color:#e8eaf0;border-color:#ffffff4d}.site-search-results{max-height:60vh;overflow-y:auto;padding:8px 0}.site-search-result{display:block;padding:10px 16px;text-decoration:none;color:inherit;border-left:3px solid transparent;transition:background .1s,border-color .1s;cursor:pointer}.site-search-result:hover,.site-search-result.active{background:#ffffff0f;border-left-color:var(--dm-primary, #5b8cff)}.site-search-result-title{font-weight:600;font-size:var(--dm-font-size-sm, 14px);margin-bottom:2px;color:#e8eaf0}.site-search-result-snippet{font-size:12px;color:#e8eaf08c;line-height:1.4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.site-search-result-snippet mark{background:#5b8cff40;color:#e8eaf0;border-radius:2px;padding:0 1px}.site-search-loading,.site-search-empty{padding:24px 16px;text-align:center;color:#e8eaf073;font-size:var(--dm-font-size-sm, 14px)}.site-search-loading-dots{display:inline-flex;gap:4px}.site-search-loading-dots span{width:6px;height:6px;border-radius:50%;background:#e8eaf066;animation:ss-dot-pulse 1.2s ease-in-out infinite}.site-search-loading-dots span:nth-child(2){animation-delay:.2s}.site-search-loading-dots span:nth-child(3){animation-delay:.4s}@keyframes ss-dot-pulse{0%,80%,to{transform:scale(.6);opacity:.4}40%{transform:scale(1);opacity:1}}.site-search-results:not(:empty){border-top:1px solid rgba(255,255,255,.06)}.site-search-footer{padding:6px 16px;border-top:1px solid rgba(255,255,255,.06);display:flex;gap:12px;font-size:11px;color:#e8eaf059}.site-search-footer kbd{display:inline-block;padding:1px 4px;border-radius:3px;border:1px solid rgba(255,255,255,.15);font-family:var(--dm-font-mono, monospace);font-size:10px}@media(max-width:640px){.site-search-overlay{padding-top:5vh;align-items:flex-start}.site-search-panel{margin:0 8px;border-radius:var(--dm-radius, 6px)}.site-search-results{max-height:70vh}.site-search-shortcut-hint{display:none}}.site-search-floating-trigger{position:fixed;z-index:9999;width:52px;height:52px;border-radius:50%;border:1px solid var(--dm-border, rgba(0, 0, 0, .12));cursor:pointer;display:flex;align-items:center;justify-content:center;gap:8px;background:var(--dm-surface, #fff);color:var(--dm-text, #111);box-shadow:0 4px 16px #00000024;transition:background .15s,color .15s,border-color .15s,transform .15s,box-shadow .15s;outline:none;white-space:nowrap}.site-search-floating-trigger:hover{background:var(--dm-primary, #5b8cff);color:#fff;border-color:var(--dm-primary, #5b8cff);box-shadow:0 6px 20px #00000038;transform:scale(1.05)}.site-search-floating-trigger:active{transform:scale(.97)}.site-search-floating-trigger svg,.site-search-floating-trigger [data-icon]{width:var(--ss-icon-glyph, 20px);height:var(--ss-icon-glyph, 20px);flex-shrink:0;pointer-events:none}.site-search-floating-trigger--pill{width:auto;height:44px;border-radius:999px;padding:0 18px 0 14px}.site-search-floating-label{font-size:.875rem;font-weight:500;letter-spacing:.01em;pointer-events:none}