domma-cms 1.10.7 → 1.10.9

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.10.9] - 2026-10-24
4
+
5
+ - **Built against domma-js 0.56.0** - see [its release notes](https://github.com/pinpointzero73/domma/releases/tag/v0.56.0). Seven new building blocks are available to pages and plugins: `E.dropzone` (file drop and upload with progress), `E.rangeSlider` (one or two thumbs, also as `type: 'slider'` form fields), `E.commandPalette` (a Cmd/Ctrl+K launcher), `E.focusTrap`, `E.splitPane`, `E.virtualList` (large and infinite lists) and `Domma.i18n` translations. Nothing existing changes.
6
+
7
+ ## [1.10.8] - 2026-10-24
8
+
9
+ - **Buttons in email show again.** The mail reader (Email and Mail Reader) stripped the ways most mail colours its buttons - a cell's `bgcolor`, the `background:` style and rules in the message's own `<style>` block - leaving white text on white, so sign-in and confirm buttons (Anthropic's among them) looked missing. Stylesheet rules are now applied to the elements they match before sanitising (not `@media` or `:hover` rules), and table colour and layout attributes, `<center>`, `<font>`, `border-radius` and the like are kept. A background still never loads anything, so blocked remote images stay blocked.
10
+
3
11
  ## [1.10.7] - 2026-10-24
4
12
 
5
13
  - **Built against domma-js 0.55.0** - see [its release notes](https://github.com/pinpointzero73/domma/releases/tag/v0.55.0). Modals, toasts, badges, tooltips, loaders and back-to-top buttons now follow the site's theme colours (they drew Domma's defaults inside their old shadow roots), and a tooltip no longer sticks on screen when the row it belongs to re-renders. Every component's `onX` option also arrives as `.on('x')` and a `dm:<component>:x` event, every overlay answers `open()`/`close()`, and slideovers, dropdowns and modals take a cancelable `onBeforeClose`.
@@ -1,9 +1,9 @@
1
1
  /*!
2
- * Domma Tools CSS v0.55.0
2
+ * Domma Tools CSS v0.56.0
3
3
  * Dynamic Object Manipulation & Modeling API
4
4
  * (c) 2026 Darryl Waterhouse & DCBW-IT
5
- * Built: 2026-10-24T15:11:03.951Z
6
- * Commit: 253e259
5
+ * Built: 2026-10-24T16:15:31.974Z
6
+ * Commit: f768320
7
7
  */
8
8
 
9
9
  /*!
@@ -1,9 +1,9 @@
1
1
  /*!
2
- * Domma Tools v0.55.0
2
+ * Domma Tools v0.56.0
3
3
  * Developer tools: Theme Roller & Page Roller
4
4
  * (c) 2026 Darryl Waterhouse & DCBW-IT
5
- * Built: 2026-10-24T15:10:49.146Z
6
- * Commit: 253e259
5
+ * Built: 2026-10-24T16:15:16.245Z
6
+ * Commit: f768320
7
7
  *
8
8
  * Requires: domma.min.js
9
9
  */
@@ -2072,6 +2072,7 @@ Read your own IMAP mailboxes inside the Domma CMS admin without anything ever ch
2072
2072
  - Read-only enforced at protocol level: mailboxes opened with EXAMINE/`readOnly`, bodies fetched with `BODY.PEEK[]` - reading never sets `\Seen`.
2073
2073
  - Folder list (`\Noselect` folders hidden); paged message list, newest first (50 per page by default, capped at 200); opening with no account chosen shows the first mailbox.
2074
2074
  - Message view rendered in a sandboxed iframe (no scripts, no same-origin), HTML sanitised; "Show headers" toggle; icons for priority, replied-to, attachments.
2075
+ - Message HTML is sanitised, but keeps what makes mail look right: the message's own `<style>` rules are applied to the elements they match first (not `@media` or `:hover`), and table `bgcolor`/`cellpadding`, `<center>`, `<font>`, `background` (colour and gradients only - never `url()`), `border-radius` and similar survive, so buttons keep their fill (since 1.10.8).
2075
2076
  - Remote images blocked by default (tracking pixels); per-message "Load images" (never persisted); optional per-mailbox list of trusted image senders ("remember senders" switch, add/remove senders) in settings.
2076
2077
  - Attachments downloaded through an authenticated fetch-to-blob.
2077
2078
  - Right-click / context menu on a message: Open, Copy sender's address, Copy subject, Add sender to contacts (core Contacts; hidden when the Contacts Tool is off or the sender is already known).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.10.7",
3
+ "version": "1.10.9",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -84,14 +84,18 @@
84
84
  "@fastify/rate-limit": "^10.3.0",
85
85
  "@fastify/static": "10.1.7",
86
86
  "bcryptjs": "^3.0.3",
87
- "domma-js": "0.55.0",
87
+ "css-select": "^7.0.0",
88
+ "dom-serializer": "^3.1.1",
89
+ "domma-js": "0.56.0",
88
90
  "dotenv": "^17.2.3",
89
91
  "fastify": "5.12.7",
90
92
  "gray-matter": "^4.0.3",
93
+ "htmlparser2": "^12.0.0",
91
94
  "imapflow": "^2.8.0",
92
95
  "mailparser": "^3.12.2",
93
96
  "marked": "^15.0.0",
94
97
  "nodemailer": "10.2.0",
98
+ "postcss": "^8.5.31",
95
99
  "sanitize-html": "^2.18.1",
96
100
  "sharp": "^0.35.5",
97
101
  "uuid": "13.0.1"
@@ -26,6 +26,10 @@
26
26
  * @module mail-reader/services/render
27
27
  */
28
28
  import {createHash} from 'node:crypto';
29
+ import {selectAll} from 'css-select';
30
+ import {render as renderDom} from 'dom-serializer';
31
+ import {DomUtils, parseDocument} from 'htmlparser2';
32
+ import postcss from 'postcss';
29
33
  import sanitizeHtml from 'sanitize-html';
30
34
 
31
35
  /** A 1x1 transparent GIF - stands in for a blocked remote image. */
@@ -36,12 +40,140 @@ const BLOCKED_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAA
36
40
  * unwrapped or dropped by sanitize-html.
37
41
  */
38
42
  const ALLOWED_TAGS = [
39
- 'a', 'b', 'blockquote', 'br', 'caption', 'code', 'col', 'colgroup', 'dd', 'div', 'dl', 'dt',
40
- 'em', 'figcaption', 'figure', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li',
43
+ 'a', 'b', 'blockquote', 'br', 'caption', 'center', 'code', 'col', 'colgroup', 'dd', 'div', 'dl', 'dt',
44
+ 'em', 'figcaption', 'figure', 'font', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li',
41
45
  'ol', 'p', 'pre', 'q', 's', 'small', 'span', 'strike', 'strong', 'sub', 'sup', 'table',
42
46
  'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'u', 'ul'
43
47
  ];
44
48
 
49
+ /** Ceilings on the stylesheet inliner, so a hostile message cannot make it the slow part. */
50
+ const MAX_STYLESHEET_CHARS = 200_000;
51
+ const MAX_INLINED_DECLARATIONS = 50_000;
52
+
53
+ /**
54
+ * Rough selector specificity - ids, then classes/attributes, then tags.
55
+ * Close enough to order a newsletter's rules; nothing depends on it for safety.
56
+ *
57
+ * @param {string} selector
58
+ * @returns {number}
59
+ */
60
+ function specificity(selector) {
61
+ const ids = (selector.match(/#[\w-]+/g) ?? []).length;
62
+ const classes = (selector.match(/\.[\w-]+|\[[^\]]*\]/g) ?? []).length;
63
+ const tags = (selector.match(/(^|[\s>+~])[a-z][\w-]*/gi) ?? []).length;
64
+ return ids * 10000 + classes * 100 + tags;
65
+ }
66
+
67
+ /**
68
+ * Drop the parts of a `background` shorthand that load something, keeping its
69
+ * colour. `background:#d97757 url(x.png) no-repeat` becomes `#d97757 no-repeat`.
70
+ *
71
+ * @param {string} value
72
+ * @returns {string}
73
+ */
74
+ function stripBackgroundLoads(value) {
75
+ if (value.includes('\\')) return '';
76
+ return value.replace(/\b(url|image|image-set|element|cross-fade|expression)\s*\((?:[^()]|\([^()]*\))*\)/gi, '').trim();
77
+ }
78
+
79
+ /**
80
+ * Move a message's `<style>` rules onto the elements they match, and tidy the
81
+ * inline styles the sanitiser is about to judge.
82
+ *
83
+ * Mail clients have done this for years because so much mail needs it: a
84
+ * button is often `<a class="button">` with its colours in the head, plus a
85
+ * white `color` inline. The sanitiser drops `<style>` and `class`, so without
86
+ * this the background goes and the label is white on white - an invisible
87
+ * button. Only plain top-level rules are applied; `@media` blocks (mobile and
88
+ * dark-mode overrides) and pseudo-classes (`:hover`) are not states the
89
+ * reading pane is in.
90
+ *
91
+ * Nothing here decides what is safe - every property still goes through the
92
+ * sanitiser's allowlist afterwards.
93
+ *
94
+ * @param {string} html
95
+ * @returns {string}
96
+ */
97
+ function inlineStyles(html) {
98
+ let dom;
99
+ try {
100
+ dom = parseDocument(html);
101
+ } catch {
102
+ return html;
103
+ }
104
+
105
+ const pending = new Map();
106
+ const styleElements = DomUtils.getElementsByTagName('style', dom, true);
107
+ const css = styleElements.map((element) => DomUtils.textContent(element)).join('\n')
108
+ .slice(0, MAX_STYLESHEET_CHARS);
109
+
110
+ if (css.trim()) {
111
+ let root = null;
112
+ try {
113
+ root = postcss.parse(css);
114
+ } catch {
115
+ // A stylesheet postcss cannot read is one we do not apply.
116
+ }
117
+
118
+ const rules = [];
119
+ root?.each((node) => {
120
+ if (node.type !== 'rule') return;
121
+ const declarations = [];
122
+ node.each((decl) => {
123
+ if (decl.type === 'decl') declarations.push({prop: decl.prop.toLowerCase(), value: decl.value, important: decl.important});
124
+ });
125
+ if (!declarations.length) return;
126
+ for (const selector of node.selectors ?? []) {
127
+ if (selector.includes(':')) continue;
128
+ rules.push({selector, declarations, weight: specificity(selector), order: rules.length});
129
+ }
130
+ });
131
+ rules.sort((a, b) => a.weight - b.weight || a.order - b.order);
132
+
133
+ let budget = MAX_INLINED_DECLARATIONS;
134
+ for (const rule of rules) {
135
+ let matches;
136
+ try {
137
+ matches = selectAll(rule.selector, dom);
138
+ } catch {
139
+ continue;
140
+ }
141
+ for (const element of matches) {
142
+ budget -= rule.declarations.length;
143
+ if (budget < 0) break;
144
+ if (!pending.has(element)) pending.set(element, {normal: [], important: []});
145
+ const slot = pending.get(element);
146
+ for (const decl of rule.declarations) {
147
+ (decl.important ? slot.important : slot.normal).push(`${decl.prop}:${decl.value}`);
148
+ }
149
+ }
150
+ if (budget < 0) break;
151
+ }
152
+ }
153
+
154
+ // Inline styles beat the sheet, except where the sheet said !important.
155
+ for (const [element, slot] of pending) {
156
+ element.attribs.style = [...slot.normal, element.attribs.style ?? '', ...slot.important]
157
+ .filter(Boolean).join(';');
158
+ }
159
+
160
+ // A background shorthand loses only what would load; its colour stays.
161
+ const backgrounds = DomUtils.findAll((node) => /background\s*:/i.test(node.attribs?.style ?? ''), dom.children);
162
+ for (const element of backgrounds) {
163
+ element.attribs.style = element.attribs.style.replace(
164
+ /(^|;)(\s*background\s*:)([^;]*)/gi,
165
+ (match, lead, prop, value) => {
166
+ const kept = stripBackgroundLoads(value);
167
+ return kept ? `${lead}${prop}${kept}` : lead;
168
+ }
169
+ );
170
+ }
171
+
172
+ // Untouched mail goes to the sanitiser exactly as it arrived.
173
+ if (!pending.size && !backgrounds.length) return html;
174
+ return renderDom(dom);
175
+ }
176
+
45
177
  /**
46
178
  * Build a cid → data URI map from a parsed message's inline attachments.
47
179
  *
@@ -74,17 +206,23 @@ function inlineImageMap(attachments, maxBytes) {
74
206
  function sanitiseBody(html, options) {
75
207
  let blockedImages = 0;
76
208
 
77
- const clean = sanitizeHtml(html, {
209
+ const clean = sanitizeHtml(inlineStyles(html), {
78
210
  allowedTags: ALLOWED_TAGS,
79
211
  allowedAttributes: {
80
212
  // target/rel are added by transformTags below, and attribute
81
213
  // filtering runs AFTER that - leave them off this list and the
82
214
  // rel we just set is stripped straight back off again.
83
215
  a: ['href', 'title', 'target', 'rel'],
84
- img: ['src', 'alt', 'title', 'width', 'height', 'data-blocked-src'],
85
- td: ['colspan', 'rowspan', 'align', 'valign'],
86
- th: ['colspan', 'rowspan', 'align', 'valign'],
87
- table: ['width', 'align'],
216
+ img: ['src', 'alt', 'title', 'width', 'height', 'align', 'border', 'data-blocked-src'],
217
+ // Table mail is laid out - and its buttons coloured - with these
218
+ // attributes. None of them can load anything.
219
+ td: ['colspan', 'rowspan', 'align', 'valign', 'width', 'height', 'bgcolor'],
220
+ th: ['colspan', 'rowspan', 'align', 'valign', 'width', 'height', 'bgcolor'],
221
+ tr: ['align', 'valign', 'bgcolor'],
222
+ table: ['width', 'height', 'align', 'bgcolor', 'border', 'cellpadding', 'cellspacing'],
223
+ font: ['color', 'face', 'size'],
224
+ p: ['align'],
225
+ div: ['align'],
88
226
  '*': ['style']
89
227
  },
90
228
  // Inline styles are what makes mail readable, so they stay - but only
@@ -93,6 +231,24 @@ function sanitiseBody(html, options) {
93
231
  '*': {
94
232
  'color': [/^.*$/],
95
233
  'background-color': [/^.*$/],
234
+ // The shorthand is how a lot of mail colours its buttons.
235
+ // inlineStyles() has already cut out anything that loads; this
236
+ // refuses whatever got past it.
237
+ 'background': [/^(?![\s\S]*(\\|\b(url|image|image-set|element|cross-fade|expression)\s*\())[\s\S]*$/i],
238
+ 'border-radius': [/^.*$/],
239
+ 'border-top': [/^.*$/],
240
+ 'border-right': [/^.*$/],
241
+ 'border-bottom': [/^.*$/],
242
+ 'border-left': [/^.*$/],
243
+ 'border-color': [/^.*$/],
244
+ 'border-style': [/^.*$/],
245
+ 'border-width': [/^.*$/],
246
+ 'letter-spacing': [/^.*$/],
247
+ 'text-transform': [/^.*$/],
248
+ 'white-space': [/^.*$/],
249
+ 'word-break': [/^.*$/],
250
+ 'min-width': [/^.*$/],
251
+ 'min-height': [/^.*$/],
96
252
  'font-size': [/^.*$/],
97
253
  'font-weight': [/^.*$/],
98
254
  'font-style': [/^.*$/],
@@ -1,6 +1,6 @@
1
1
  {
2
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@4201b7a",
3
+ "source": "dcms-marketplace@397f460",
4
4
  "plugins": {
5
5
  "blog": {
6
6
  "version": "1.10.1",
@@ -220,7 +220,7 @@
220
220
  "CLAUDE.md": "32132446515e66f6784d01b11b1ded8913b0778087472edb667bfd83cc3a871c",
221
221
  "admin/views/mail.js": "c5c6483f9618c75cac8f4be32d3aea99511db76d4b9d38b8ef43bdc1d35db3c5",
222
222
  "config.js": "6e65803ad822a9acda40c203631b8eb32a87cf9d9e5f1c7cb59d01aa406075c6",
223
- "docs/guide.md": "d6b5944740ea1bbd689034ef726d8bc0f9bfeb28ea62b63c258292962d45cf18",
223
+ "docs/guide.md": "75e5742c16b2230fb4943ae3214d946436de556eda719e22cde50d8a13a9a88f",
224
224
  "plugin.js": "7f602a60c4eca7d3637659d03d5de9e242584666846b16c9e5a1b758e4856c9f",
225
225
  "plugin.json": "e5ac7f3496d9a535fa4899d1de87a514535084d13acecd6a5897744b6174807a"
226
226
  }
@@ -49,7 +49,7 @@ You need your mailbox's IMAP details from your email provider: the server name,
49
49
  ### Message
50
50
 
51
51
  - The header shows who sent it and when. Click **Show headers** for Reply-To, all recipients and the message id; **Hide headers** folds them again. Your choice is remembered.
52
- - Messages are shown in a locked-down frame: scripts in email never run.
52
+ - Messages are shown in a locked-down frame: scripts in email never run. Colours, buttons and layout from the message's own styles are kept.
53
53
  - If a message has remote images, a notice says how many were blocked. Click **Load images** to load them for this message only. Loading them tells the sender you opened it.
54
54
  - **Always from** (followed by the sender's address) trusts that sender, so their images load straight away in future. It appears only when **Offer to remember senders** is on (see Settings).
55
55
  - Click an attachment to download it.