domma-cms 0.55.1 → 0.69.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +194 -3
- package/README.md +18 -14
- package/admin/css/admin.css +1 -1
- package/admin/css/contacts.css +1 -0
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/index.html +1 -0
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/lib/analytics-shape.js +1 -0
- package/admin/js/lib/contacts-arrange.js +1 -0
- package/admin/js/lib/help-popover.js +1 -0
- package/admin/js/lib/notes-arrange.js +1 -0
- package/admin/js/lib/page-picker.js +1 -0
- package/admin/js/lib/plugin-accent.js +1 -0
- package/admin/js/lib/plugin-chrome.js +1 -0
- package/admin/js/lib/shortcode-context-menu.js +2 -2
- package/admin/js/lib/sidebar-grouping.js +1 -1
- package/admin/js/lib/sidebar-grouping.test.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +4 -4
- package/admin/js/lib/slideover-resizable.js +1 -0
- package/admin/js/lib/todo-arrange.js +1 -0
- package/admin/js/lib/tool-kit.js +1 -0
- package/admin/js/templates/analytics.html +138 -0
- package/admin/js/templates/contacts.html +156 -0
- package/admin/js/templates/context-menu-editor.html +212 -0
- package/admin/js/templates/context-menus.html +16 -0
- package/admin/js/templates/notes.html +137 -0
- package/admin/js/templates/plugin-code.html +1 -1
- package/admin/js/templates/plugin-marketplace.html +17 -4
- package/admin/js/templates/plugins.html +13 -7
- package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
- package/admin/js/templates/settings.html +16 -97
- package/admin/js/templates/theme.html +211 -0
- package/admin/js/templates/todo.html +113 -0
- package/admin/js/views/analytics.js +62 -0
- package/admin/js/views/contacts.js +174 -0
- package/admin/js/views/context-menu-editor.js +55 -0
- package/admin/js/views/context-menus.js +5 -0
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/notes.js +104 -0
- package/admin/js/views/plugin-marketplace.js +1 -1
- package/admin/js/views/plugins.js +28 -24
- package/admin/js/views/search.js +1 -0
- package/admin/js/views/settings.js +3 -3
- package/admin/js/views/theme.js +1 -0
- package/admin/js/views/todo.js +96 -0
- package/bin/cli.js +6 -10
- package/bin/lib/plugin-version.js +28 -0
- package/bin/update.js +27 -2
- package/config/menus/admin-sidebar.json +129 -23
- package/config/plugins.json +5 -5
- package/config/search.json +13 -0
- package/config/server.json +3 -1
- package/package.json +14 -10
- package/plugins/_lib/admin/mail/compose-window.js +914 -0
- package/plugins/_lib/admin/mail/contacts.js +301 -0
- package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
- package/plugins/_lib/admin/mail/folder-tree.js +254 -0
- package/plugins/_lib/admin/mail/identity.js +480 -0
- package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
- package/plugins/_lib/admin/mail/keyboard.js +136 -0
- package/plugins/_lib/admin/mail/mail.css +1 -0
- package/plugins/_lib/admin/mail/mail.html +71 -0
- package/plugins/_lib/admin/mail/panes.js +253 -0
- package/plugins/_lib/admin/mail/reader-view.js +4473 -0
- package/plugins/_lib/admin/mail/resizable.js +26 -0
- package/plugins/_lib/admin/mail/rules.js +343 -0
- package/plugins/_lib/admin/mail/scheduling.js +203 -0
- package/plugins/_lib/admin/mail/section-kit.js +277 -0
- package/plugins/_lib/admin/mail/templates.js +238 -0
- package/plugins/_lib/admin/mail/threads.js +200 -0
- package/plugins/_lib/admin/mail/vacation.js +269 -0
- package/plugins/_lib/admin/ui/help.css +1 -0
- package/plugins/_lib/admin/ui/help.js +174 -0
- package/plugins/_lib/admin/ui/resizable.js +151 -0
- package/plugins/_lib/dataStore.js +117 -0
- package/plugins/_lib/mail/accounts.js +919 -0
- package/plugins/_lib/mail/bodyTokens.js +101 -0
- package/plugins/_lib/mail/compose.js +256 -0
- package/plugins/_lib/mail/defaults.js +57 -0
- package/plugins/_lib/mail/diagnostics.js +154 -0
- package/plugins/_lib/mail/envelope.js +161 -0
- package/plugins/_lib/mail/folders.js +192 -0
- package/plugins/_lib/mail/handoff.js +274 -0
- package/plugins/_lib/mail/imapPool.js +293 -0
- package/plugins/_lib/mail/mbox.js +74 -0
- package/plugins/_lib/mail/pollSchedule.js +79 -0
- package/plugins/_lib/mail/poller.js +135 -0
- package/plugins/_lib/mail/priority.js +138 -0
- package/plugins/_lib/mail/readRoutes.js +680 -0
- package/plugins/_lib/mail/render.js +291 -0
- package/plugins/_lib/mail/ruleRunner.js +152 -0
- package/plugins/_lib/mail/scheduler.js +254 -0
- package/plugins/_lib/mail/secretbox.js +229 -0
- package/plugins/_lib/mail/send.js +396 -0
- package/plugins/_lib/mail/store.js +1022 -0
- package/plugins/_lib/mail/sync.js +292 -0
- package/plugins/_lib/mail/syncPlan.js +126 -0
- package/plugins/_lib/mail/syncSelection.js +82 -0
- package/plugins/_lib/mail/unsubscribe.js +183 -0
- package/plugins/_lib/mail/vacationRunner.js +114 -0
- package/plugins/_lib/mail/write.js +473 -0
- package/plugins/_lib/schemaSync.js +83 -0
- package/plugins/_template/admin/css/index.css +0 -0
- package/plugins/_template/admin/templates/index.html +4 -4
- package/plugins/_template/admin/views/index.js +7 -0
- package/plugins/blog/CLAUDE.md +237 -0
- package/plugins/blog/admin/css/index.css +178 -0
- package/plugins/blog/admin/templates/blog.html +35 -41
- package/plugins/blog/admin/templates/categories.html +19 -6
- package/plugins/blog/admin/templates/comments.html +26 -9
- package/plugins/blog/admin/templates/post-editor.html +63 -40
- package/plugins/blog/admin/templates/settings.html +3 -9
- package/plugins/blog/admin/views/blog.js +288 -159
- package/plugins/blog/admin/views/categories.js +212 -206
- package/plugins/blog/admin/views/comments.js +207 -158
- package/plugins/blog/admin/views/kit.js +372 -0
- package/plugins/blog/admin/views/post-editor.js +306 -39
- package/plugins/blog/admin/views/settings-cog.js +56 -0
- package/plugins/blog/admin/views/settings.js +16 -84
- package/plugins/blog/blocks/blog-card-compact.css +17 -0
- package/plugins/blog/blocks/blog-card-compact.html +8 -0
- package/plugins/blog/blocks/blog-card-row.css +22 -0
- package/plugins/blog/blocks/blog-card-row.html +11 -0
- package/plugins/blog/blocks/blog-card.css +35 -0
- package/plugins/blog/blocks/blog-card.html +16 -0
- package/plugins/blog/blocks/blog-post-classic.css +15 -0
- package/plugins/blog/blocks/blog-post-classic.html +28 -0
- package/plugins/blog/blocks/blog-post-feature.css +20 -0
- package/plugins/blog/blocks/blog-post-feature.html +30 -0
- package/plugins/blog/blocks/blog-post-minimal.css +10 -0
- package/plugins/blog/blocks/blog-post-minimal.html +23 -0
- package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
- package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
- package/plugins/blog/collections/categories/schema.json +7 -6
- package/plugins/blog/collections/comments/schema.json +11 -10
- package/plugins/blog/collections/posts/schema.json +16 -13
- package/plugins/blog/config.js +6 -0
- package/plugins/blog/lib/layouts.js +304 -0
- package/plugins/blog/lib/page.js +158 -0
- package/plugins/blog/lib/render.js +119 -0
- package/plugins/blog/lib/samples.js +234 -0
- package/plugins/blog/plugin.js +259 -26
- package/plugins/blog/plugin.json +21 -73
- package/plugins/blog/plugin.public.js +217 -0
- package/plugins/blog/public/blog.css +177 -0
- package/plugins/blog/public/blog.js +586 -0
- package/plugins/blog/public/samples/aurora.svg +7 -0
- package/plugins/blog/public/samples/dusk.svg +7 -0
- package/plugins/blog/public/samples/ember.svg +7 -0
- package/plugins/blog/public/samples/harbour.svg +7 -0
- package/plugins/blog/public/samples/meadow.svg +7 -0
- package/plugins/blog/public/samples/rosewood.svg +7 -0
- package/plugins/blog/templates/index.html +9 -8
- package/plugins/blog/templates/post.html +4 -17
- package/plugins/blog/tests/layouts.test.js +97 -0
- package/plugins/blog/tests/public.test.js +71 -0
- package/plugins/free-tier.lock.json +74 -0
- package/plugins/mail-reader/CLAUDE.md +78 -0
- package/plugins/mail-reader/admin/views/mail.js +19 -0
- package/plugins/mail-reader/config.js +7 -0
- package/plugins/mail-reader/plugin.js +48 -0
- package/plugins/mail-reader/plugin.json +34 -0
- package/public/css/forms.css +1 -1
- package/public/css/search.css +1 -0
- package/public/css/site.css +1 -1
- package/public/css/theme-switcher.css +1 -0
- package/public/js/analytics.js +1 -0
- package/public/js/collection-context.js +2 -2
- package/public/js/context-menus.js +1 -0
- package/public/js/form-logic-engine.js +1 -1
- package/public/js/forms.js +2 -2
- package/public/js/search.js +1 -0
- package/public/js/site.js +1 -1
- package/public/js/theme-switcher.js +1 -0
- package/scripts/build.js +77 -4
- package/scripts/copy-domma.js +48 -0
- package/scripts/run-tests.mjs +92 -0
- package/scripts/seed.js +1996 -0
- package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
- package/server/routes/api/collections.js +34 -0
- package/server/routes/api/contacts.js +506 -0
- package/server/routes/api/context-menus.js +104 -0
- package/server/routes/api/dashboard.js +8 -2
- package/server/routes/api/forms.js +42 -3
- package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
- package/server/routes/api/notifications.js +69 -19
- package/server/routes/api/plugin-marketplace.js +78 -23
- package/server/routes/api/plugins.js +198 -9
- package/server/routes/api/search.js +43 -0
- package/server/routes/api/theme.js +69 -0
- package/server/routes/api/todo.js +178 -0
- package/server/routes/public.js +42 -7
- package/server/server.js +135 -1
- package/server/services/adapters/FileAdapter.js +6 -1
- package/server/services/collections.js +13 -3
- package/server/services/content.js +26 -0
- package/server/services/contextMenus.js +477 -0
- package/server/services/managerClient.js +72 -136
- package/server/services/markdown.js +124 -10
- package/server/services/permissionRegistry.js +74 -0
- package/server/services/pluginEntitlement.js +171 -0
- package/server/services/pluginEnvelope.js +242 -0
- package/server/services/pluginFiles.js +52 -11
- package/server/services/pluginInstaller.js +246 -26
- package/server/services/pluginScaffold.js +5 -0
- package/server/services/plugins.js +340 -7
- package/server/services/presetCollections.js +344 -0
- package/server/services/promoted-plugins-migration.js +254 -0
- package/server/services/publicCors.js +46 -0
- package/server/services/renderer.js +224 -22
- package/server/services/roles.js +1 -1
- package/server/services/search-migration.js +82 -0
- package/server/services/search.js +413 -0
- package/server/services/sidebar-migration.js +1 -0
- package/server/services/sidebarBadges.js +37 -0
- package/server/services/siteGitignore.js +307 -0
- package/server/services/themeSettings.js +580 -0
- package/server/services/users.js +8 -0
- package/server/templates/page.html +4 -2
- package/plugins/analytics/admin/templates/analytics.html +0 -61
- package/plugins/analytics/admin/views/analytics.js +0 -176
- package/plugins/analytics/config.js +0 -14
- package/plugins/analytics/plugin.json +0 -57
- package/plugins/analytics/public/inject-body.html +0 -60
- package/plugins/analytics/public/inject-head.html +0 -1
- package/plugins/blog/templates/author.html +0 -9
- package/plugins/blog/templates/category.html +0 -9
- package/plugins/blog/templates/tag.html +0 -9
- package/plugins/contacts/admin/templates/contacts.html +0 -126
- package/plugins/contacts/admin/views/contacts.js +0 -710
- package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
- package/plugins/contacts/collections/user-contacts/schema.json +0 -71
- package/plugins/contacts/config.js +0 -6
- package/plugins/contacts/data/contacts.json +0 -20
- package/plugins/contacts/plugin.js +0 -291
- package/plugins/contacts/plugin.json +0 -33
- package/plugins/demo-viewer/config.js +0 -4
- package/plugins/demo-viewer/plugin.js +0 -36
- package/plugins/demo-viewer/plugin.json +0 -9
- package/plugins/invoice/admin/templates/editor.html +0 -129
- package/plugins/invoice/admin/templates/index.html +0 -43
- package/plugins/invoice/admin/templates/issuers.html +0 -5
- package/plugins/invoice/admin/templates/receivers.html +0 -5
- package/plugins/invoice/admin/views/editor.js +0 -267
- package/plugins/invoice/admin/views/index.js +0 -155
- package/plugins/invoice/admin/views/issuers.js +0 -23
- package/plugins/invoice/admin/views/party-view.js +0 -148
- package/plugins/invoice/admin/views/receivers.js +0 -22
- package/plugins/invoice/collections/invoice-issuers/schema.json +0 -16
- package/plugins/invoice/collections/invoice-receivers/schema.json +0 -15
- package/plugins/invoice/collections/invoices/schema.json +0 -27
- package/plugins/invoice/config.js +0 -16
- package/plugins/invoice/plugin.js +0 -283
- package/plugins/invoice/plugin.json +0 -85
- package/plugins/invoice/templates/invoice-print.html +0 -213
- package/plugins/notes/admin/templates/notes.html +0 -83
- package/plugins/notes/admin/views/notes.js +0 -281
- package/plugins/notes/collections/user-notes/schema.json +0 -54
- package/plugins/notes/config.js +0 -6
- package/plugins/notes/data/notes.json +0 -1
- package/plugins/notes/plugin.json +0 -33
- package/plugins/site-search/admin/views/site-search.js +0 -116
- package/plugins/site-search/config.js +0 -15
- package/plugins/site-search/plugin.js +0 -188
- package/plugins/site-search/plugin.json +0 -40
- package/plugins/site-search/public/inject-body.html +0 -17
- package/plugins/site-search/public/inject-head.html +0 -1
- package/plugins/site-search/public/search.css +0 -1
- package/plugins/site-search/public/search.js +0 -1
- package/plugins/surveys/admin/templates/audience.html +0 -47
- package/plugins/surveys/admin/templates/results.html +0 -58
- package/plugins/surveys/admin/templates/survey-editor.html +0 -141
- package/plugins/surveys/admin/templates/surveys.html +0 -25
- package/plugins/surveys/admin/views/audience.js +0 -301
- package/plugins/surveys/admin/views/results.js +0 -172
- package/plugins/surveys/admin/views/survey-editor.js +0 -211
- package/plugins/surveys/admin/views/surveys.js +0 -161
- package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
- package/plugins/surveys/collections/survey-groups/schema.json +0 -11
- package/plugins/surveys/collections/survey-invites/schema.json +0 -16
- package/plugins/surveys/collections/surveys/schema.json +0 -23
- package/plugins/surveys/config.js +0 -8
- package/plugins/surveys/plugin.js +0 -174
- package/plugins/surveys/plugin.json +0 -65
- package/plugins/surveys/public/dist-shared.mjs +0 -1
- package/plugins/surveys/public/survey.css +0 -1
- package/plugins/surveys/public/survey.mjs +0 -1
- package/plugins/surveys/templates/survey-page.html +0 -63
- package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
- package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
- package/plugins/theme-switcher/config.js +0 -10
- package/plugins/theme-switcher/plugin.js +0 -26
- package/plugins/theme-switcher/plugin.json +0 -74
- package/plugins/theme-switcher/public/inject-body.html +0 -153
- package/plugins/theme-switcher/public/inject-head.html +0 -260
- package/plugins/todo/admin/templates/todo.html +0 -158
- package/plugins/todo/admin/views/todo.js +0 -343
- package/plugins/todo/collections/todos/schema.json +0 -60
- package/plugins/todo/config.js +0 -7
- package/plugins/todo/data/todos.json +0 -1
- package/plugins/todo/plugin.js +0 -102
- package/plugins/todo/plugin.json +0 -33
|
@@ -0,0 +1,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
|
-
<
|
|
2
|
-
|
|
3
|
-
<
|
|
4
|
-
{{
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
<
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
<h1>{{title}}</h1>
|
|
5
|
-
<p class="text-muted">by {{authorName}} • <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><script>x</script></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 <i>/);
|
|
80
|
+
assert.match(v.tags, /data-blog-tag="x"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,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
|
+
}
|
package/public/css/forms.css
CHANGED
|
@@ -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}
|