domma-cms 0.116.4 → 0.116.6
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 +4 -0
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/body-menu.js +99 -0
- package/plugins/_lib/admin/mail/reader-view.js +89 -7
- package/plugins/_lib/mail/readRoutes.js +2 -2
- package/plugins/_lib/mail/render.js +39 -2
- package/plugins/free-tier.lock.json +5 -5
- package/plugins/mail-reader/CLAUDE.md +5 -0
- package/plugins/mail-reader/docs/guide.md +1 -0
- package/plugins/mail-reader/plugin.json +1 -1
- package/server/services/adapters/FileAdapter.js +42 -22
- package/server/services/adapters/fileLock.js +63 -0
- package/server/services/userProfiles.js +20 -6
package/CLAUDE.md
CHANGED
|
@@ -368,6 +368,10 @@ changes shape depending on where you clicked is disorienting, and greyed-out is
|
|
|
368
368
|
`back_forward` navigation - an honest heuristic, not a real capability check. `site-search` calls
|
|
369
369
|
`window.DommaSiteSearch.open()`, the one handle `public/js/search.js` exports.
|
|
370
370
|
|
|
371
|
+
A menu over a **sandboxed iframe** cannot hear the right-click at all - the mail reader's message pane
|
|
372
|
+
solves it with a hash-allowed bridge script inside the frame that posts the click to the parent, which
|
|
373
|
+
calls the menu's `open(x, y, target)` (see `plugins/_lib/mail/CLAUDE.md`, "The reading pane's menu").
|
|
374
|
+
|
|
371
375
|
## Collection displays & public filtering
|
|
372
376
|
|
|
373
377
|
`[collection]` and `[view]` render in any of table, cards, list, block, accordion, timeline,
|
package/package.json
CHANGED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reading pane's right-click menu - the pure parts.
|
|
3
|
+
*
|
|
4
|
+
* The message itself renders in a sandboxed frame, and a right-click inside a
|
|
5
|
+
* frame never reaches the admin. The frame's document carries a few lines of
|
|
6
|
+
* our own (MENU_BRIDGE in `_lib/mail/render.js`) that post where the click was
|
|
7
|
+
* and what was selected; this module checks that message and builds what the
|
|
8
|
+
* menu offers for a selection. The rest of the menu is the message list's own,
|
|
9
|
+
* so the two never drift apart.
|
|
10
|
+
*
|
|
11
|
+
* @module _lib/admin/mail/body-menu
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Must match MENU_BRIDGE_TYPE in `_lib/mail/render.js`. */
|
|
15
|
+
export const BODY_MENU_TYPE = 'dm-mail-body-menu';
|
|
16
|
+
|
|
17
|
+
/** The most selected text carried over - the bridge caps it too. */
|
|
18
|
+
export const SELECTION_MAX = 20000;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A message event from the message frame, or null for anything else.
|
|
22
|
+
*
|
|
23
|
+
* Only the frame we put on screen may open the menu: any other window can
|
|
24
|
+
* post to this one, and a message is not trusted for being shaped right.
|
|
25
|
+
*
|
|
26
|
+
* @param {MessageEvent} event
|
|
27
|
+
* @param {Window|null} frameWindow - the reading pane's iframe contentWindow
|
|
28
|
+
* @returns {{x: number, y: number, selection: string}|{close: true}|null}
|
|
29
|
+
*/
|
|
30
|
+
export function readBridgeMessage(event, frameWindow) {
|
|
31
|
+
if (!event || !frameWindow || event.source !== frameWindow) return null;
|
|
32
|
+
const data = event.data;
|
|
33
|
+
if (!data || typeof data !== 'object' || data.type !== BODY_MENU_TYPE) return null;
|
|
34
|
+
// A click or Escape inside the frame: close an open menu.
|
|
35
|
+
if (data.close === true) return {close: true};
|
|
36
|
+
const num = (v) => (Number.isFinite(v) ? Math.max(0, Math.min(v, 100000)) : 0);
|
|
37
|
+
return {
|
|
38
|
+
x: num(data.x),
|
|
39
|
+
y: num(data.y),
|
|
40
|
+
selection: typeof data.selection === 'string' ? data.selection.slice(0, SELECTION_MAX) : ''
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Where the menu opens, in the admin's coordinates. A keyboard menu (no
|
|
46
|
+
* pointer, so 0,0) opens just inside the top of the message.
|
|
47
|
+
*
|
|
48
|
+
* @param {{left: number, top: number}} frameRect
|
|
49
|
+
* @param {{x: number, y: number}} at
|
|
50
|
+
* @returns {{x: number, y: number}}
|
|
51
|
+
*/
|
|
52
|
+
export function menuPoint(frameRect, at) {
|
|
53
|
+
const keyboard = !at.x && !at.y;
|
|
54
|
+
return {
|
|
55
|
+
x: Math.round(frameRect.left + (keyboard ? 16 : at.x)),
|
|
56
|
+
y: Math.round(frameRect.top + (keyboard ? 16 : at.y))
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Replace a reply draft's quote with just the selected text, keeping the
|
|
62
|
+
* attribution line the server wrote ("On …, Sam wrote:").
|
|
63
|
+
*
|
|
64
|
+
* @param {{body?: string, html?: string|null}} draft
|
|
65
|
+
* @param {string} selection
|
|
66
|
+
* @returns {object} a new draft
|
|
67
|
+
*/
|
|
68
|
+
export function quoteSelection(draft, selection) {
|
|
69
|
+
const text = String(selection ?? '').replace(/\r\n/g, '\n').trim();
|
|
70
|
+
if (!text) return draft;
|
|
71
|
+
const attribution = String(draft?.body ?? '').trim().split('\n')[0] || '';
|
|
72
|
+
const quotedText = text.split('\n').map(line => (line.startsWith('>') ? `>${line}` : `> ${line}`)).join('\n');
|
|
73
|
+
const escape = value => String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
74
|
+
return {
|
|
75
|
+
...draft,
|
|
76
|
+
body: `\n\n${attribution ? `${attribution}\n` : ''}${quotedText}\n`,
|
|
77
|
+
html: `<p><br></p>${attribution ? `<p>${escape(attribution)}</p>` : ''}`
|
|
78
|
+
+ `<blockquote style="margin:0 0 0 .8em;padding-left:.8em;border-left:3px solid #d0d0d0">`
|
|
79
|
+
+ `${escape(text).replace(/\n/g, '<br>')}</blockquote>`
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The items a selection adds at the top of the menu. Copy is always listed
|
|
85
|
+
* (greyed with nothing selected, as a native menu does); Quote in reply only
|
|
86
|
+
* where this edition can write mail.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} selection
|
|
89
|
+
* @param {{canSend: boolean, copy: Function, quoteReply: Function}} actions
|
|
90
|
+
* @returns {object[]}
|
|
91
|
+
*/
|
|
92
|
+
export function selectionItems(selection, {canSend, copy, quoteReply}) {
|
|
93
|
+
const has = Boolean(String(selection ?? '').trim());
|
|
94
|
+
return [
|
|
95
|
+
{label: 'Copy', icon: 'copy', disabled: () => !has, action: () => copy(selection)},
|
|
96
|
+
...(canSend ? [{label: 'Quote in reply', icon: 'message-square', disabled: () => !has, action: () => quoteReply(selection)}] : []),
|
|
97
|
+
{type: 'divider'}
|
|
98
|
+
];
|
|
99
|
+
}
|
|
@@ -45,6 +45,7 @@ const {whenClosed} = await import(versioned('when-closed.js'));
|
|
|
45
45
|
const {addContact, isKnownAddress, loadContacts} = await import(versioned('contacts.js'));
|
|
46
46
|
const {fillMessageAction, loadMessageActions, messageFacts} = await import(versioned('message-actions.js'));
|
|
47
47
|
const {isPlainKey, isTyping, resolveKey, SHORTCUTS} = await import(versioned('keyboard.js'));
|
|
48
|
+
const {menuPoint, quoteSelection, readBridgeMessage, selectionItems} = await import(versioned('body-menu.js'));
|
|
48
49
|
const {describeWhen, quickTimes} = await import(versioned('scheduling.js'));
|
|
49
50
|
|
|
50
51
|
/**
|
|
@@ -447,6 +448,12 @@ return {
|
|
|
447
448
|
let markReadTimer = null;
|
|
448
449
|
/** The registered right-click menu, so it can be destroyed on unmount. */
|
|
449
450
|
let messageMenu = null;
|
|
451
|
+
/** The reading pane's menu, the frame the message is in, and its listener. */
|
|
452
|
+
let bodyMenu = null;
|
|
453
|
+
let bodyFrame = null;
|
|
454
|
+
let onBodyBridge = null;
|
|
455
|
+
/** Text selected inside the frame, handed from its message to the menu. */
|
|
456
|
+
let pendingSelection = null;
|
|
450
457
|
/** Hides the undo strip once its moment has passed. */
|
|
451
458
|
let undoTimer = null;
|
|
452
459
|
|
|
@@ -567,6 +574,11 @@ return {
|
|
|
567
574
|
disposeRowEffects();
|
|
568
575
|
messageMenu?.destroy?.();
|
|
569
576
|
messageMenu = null;
|
|
577
|
+
bodyMenu?.destroy?.();
|
|
578
|
+
bodyMenu = null;
|
|
579
|
+
if (onBodyBridge) window.removeEventListener('message', onBodyBridge);
|
|
580
|
+
onBodyBridge = null;
|
|
581
|
+
bodyFrame = null;
|
|
570
582
|
folderMenu?.destroy?.();
|
|
571
583
|
folderMenu = null;
|
|
572
584
|
stopFitting?.();
|
|
@@ -2353,10 +2365,13 @@ return {
|
|
|
2353
2365
|
Domma.icons.scan(wrap);
|
|
2354
2366
|
}
|
|
2355
2367
|
|
|
2356
|
-
// The message renders in a sandboxed iframe
|
|
2357
|
-
// allow-
|
|
2358
|
-
//
|
|
2359
|
-
//
|
|
2368
|
+
// The message renders in a sandboxed iframe without
|
|
2369
|
+
// allow-same-origin, so its content has no origin to reach back
|
|
2370
|
+
// into the admin with. allow-popups is there only so its links can
|
|
2371
|
+
// open in a new tab. allow-scripts runs exactly one script: the
|
|
2372
|
+
// menu bridge render.js adds, allowed by its hash in the body's
|
|
2373
|
+
// CSP - the mail's own scripts are stripped and refused by it -
|
|
2374
|
+
// which posts a right-click to attachBodyMenu() below.
|
|
2360
2375
|
//
|
|
2361
2376
|
// Loaded by `src`, never `srcdoc`: a srcdoc document inherits the
|
|
2362
2377
|
// admin's CSP, whose img-src is 'self' data: blob:, so every remote
|
|
@@ -2364,10 +2379,11 @@ return {
|
|
|
2364
2379
|
// could not work. Fetched from a route, the body carries its own
|
|
2365
2380
|
// policy.
|
|
2366
2381
|
const frame = document.createElement('iframe');
|
|
2367
|
-
frame.setAttribute('sandbox', 'allow-popups allow-popups-to-escape-sandbox');
|
|
2382
|
+
frame.setAttribute('sandbox', 'allow-scripts allow-popups allow-popups-to-escape-sandbox');
|
|
2368
2383
|
frame.setAttribute('referrerpolicy', 'no-referrer');
|
|
2369
2384
|
frame.src = message.bodyUrl;
|
|
2370
2385
|
readerBodyEl.appendChild(frame);
|
|
2386
|
+
bodyFrame = frame;
|
|
2371
2387
|
}
|
|
2372
2388
|
|
|
2373
2389
|
/**
|
|
@@ -2624,6 +2640,69 @@ return {
|
|
|
2624
2640
|
});
|
|
2625
2641
|
}
|
|
2626
2642
|
|
|
2643
|
+
/**
|
|
2644
|
+
* The reading pane's right-click menu: what a selection allows (Copy,
|
|
2645
|
+
* Quote in reply), then the message list's own menu for the message on
|
|
2646
|
+
* screen, so the two always offer the same.
|
|
2647
|
+
*
|
|
2648
|
+
* Registered on the pane, so a right-click beside the message (the
|
|
2649
|
+
* attachments, the margin) opens it directly; inside the message's
|
|
2650
|
+
* frame the bridge posts the click here and it is opened at that point.
|
|
2651
|
+
* Links and images in the message keep the browser's own menu.
|
|
2652
|
+
*
|
|
2653
|
+
* @returns {void}
|
|
2654
|
+
*/
|
|
2655
|
+
function attachBodyMenu() {
|
|
2656
|
+
const elements = (typeof E !== 'undefined' && E?.contextMenu) ? E
|
|
2657
|
+
: (typeof Domma !== 'undefined' ? Domma?.elements : null);
|
|
2658
|
+
if (typeof elements?.contextMenu !== 'function' || !readerBodyEl) return;
|
|
2659
|
+
|
|
2660
|
+
bodyMenu = elements.contextMenu(readerBodyEl, {
|
|
2661
|
+
cursor: 'glyph',
|
|
2662
|
+
inherit: false,
|
|
2663
|
+
items: [],
|
|
2664
|
+
onBeforeOpen: () => {
|
|
2665
|
+
const selection = pendingSelection ?? String(window.getSelection?.() ?? '');
|
|
2666
|
+
pendingSelection = null;
|
|
2667
|
+
const message = messages.find(m => m.uid === selectedUid$.value);
|
|
2668
|
+
if (!message || !bodyFrame?.isConnected) return false;
|
|
2669
|
+
bodyMenu.setItems(bodyMenuItems(message, openFolder ?? currentFolder, selection));
|
|
2670
|
+
return true;
|
|
2671
|
+
}
|
|
2672
|
+
});
|
|
2673
|
+
|
|
2674
|
+
onBodyBridge = (event) => {
|
|
2675
|
+
const at = readBridgeMessage(event, bodyFrame?.contentWindow ?? null);
|
|
2676
|
+
if (!at || !bodyMenu) return;
|
|
2677
|
+
if (at.close) { if (bodyMenu.isOpen?.()) bodyMenu.close(); return; }
|
|
2678
|
+
pendingSelection = at.selection;
|
|
2679
|
+
const point = menuPoint(bodyFrame.getBoundingClientRect(), at);
|
|
2680
|
+
bodyMenu.open(point.x, point.y, bodyFrame);
|
|
2681
|
+
};
|
|
2682
|
+
window.addEventListener('message', onBodyBridge);
|
|
2683
|
+
}
|
|
2684
|
+
|
|
2685
|
+
/**
|
|
2686
|
+
* The items for the message on screen, with what a selection adds.
|
|
2687
|
+
*
|
|
2688
|
+
* @param {object} message
|
|
2689
|
+
* @param {string} folder
|
|
2690
|
+
* @param {string} selection
|
|
2691
|
+
* @returns {object[]}
|
|
2692
|
+
*/
|
|
2693
|
+
function bodyMenuItems(message, folder, selection) {
|
|
2694
|
+
const target = {...message, folder};
|
|
2695
|
+
return [
|
|
2696
|
+
...selectionItems(selection, {
|
|
2697
|
+
canSend: features.send && !isDraft(target),
|
|
2698
|
+
copy: text => copyText(text, 'Copied.'),
|
|
2699
|
+
quoteReply: text => compose(target, 'reply', {quote: text})
|
|
2700
|
+
}),
|
|
2701
|
+
// "Open" is the list's way in; here the message is already open.
|
|
2702
|
+
...messageMenuItems(message, folder).filter(item => item.label !== 'Open')
|
|
2703
|
+
];
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2627
2706
|
/**
|
|
2628
2707
|
* The items for one message.
|
|
2629
2708
|
*
|
|
@@ -4366,7 +4445,7 @@ return {
|
|
|
4366
4445
|
* @param {'reply'|'replyAll'|'forward'} mode
|
|
4367
4446
|
* @returns {Promise<void>}
|
|
4368
4447
|
*/
|
|
4369
|
-
async function compose(message, mode) {
|
|
4448
|
+
async function compose(message, mode, {quote = null} = {}) {
|
|
4370
4449
|
try {
|
|
4371
4450
|
const params = new URLSearchParams({
|
|
4372
4451
|
account: currentAccountId ?? '',
|
|
@@ -4375,7 +4454,9 @@ return {
|
|
|
4375
4454
|
});
|
|
4376
4455
|
const result = await api(`${BASE}/messages/${message.uid}/draft?${params}`);
|
|
4377
4456
|
openCompose(composeOptions(await identitySoon(), {
|
|
4378
|
-
|
|
4457
|
+
// Quote in reply: just the selected text under the
|
|
4458
|
+
// attribution line, not the whole message.
|
|
4459
|
+
draft: quote ? quoteSelection(result.draft, quote) : result.draft,
|
|
4379
4460
|
mode
|
|
4380
4461
|
}));
|
|
4381
4462
|
} catch (err) {
|
|
@@ -4653,6 +4734,7 @@ return {
|
|
|
4653
4734
|
// Every edition gets the message menu; what is in it depends on what
|
|
4654
4735
|
// this one can do (readOnlyMenuItems for the free reader).
|
|
4655
4736
|
attachMessageMenu();
|
|
4737
|
+
attachBodyMenu();
|
|
4656
4738
|
if (features.folders) attachFolderMenu();
|
|
4657
4739
|
|
|
4658
4740
|
// Shift+F10 opens the menu of the focused row, as the keyboard's
|
|
@@ -35,7 +35,7 @@ import {ENVELOPE_QUERY, hasAttachment, parseHeaderBlock} from './envelope.js';
|
|
|
35
35
|
import {describeError} from './imapPool.js';
|
|
36
36
|
import {resolveSubscriptions} from './folders.js';
|
|
37
37
|
import {readPriority} from './priority.js';
|
|
38
|
-
import {listAttachments, renderBody} from './render.js';
|
|
38
|
+
import {listAttachments, MENU_BRIDGE_CSP, renderBody} from './render.js';
|
|
39
39
|
import {readUnsubscribe} from './unsubscribe.js';
|
|
40
40
|
import {MailKeyError} from './secretbox.js';
|
|
41
41
|
import {unreadBadge} from './unreadBadge.js';
|
|
@@ -592,7 +592,7 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
|
|
|
592
592
|
.header('Content-Security-Policy',
|
|
593
593
|
"default-src 'none'; " +
|
|
594
594
|
(grant.images ? "img-src data: http: https:; " : "img-src data:; ") +
|
|
595
|
-
|
|
595
|
+
`style-src 'unsafe-inline'; font-src data:; script-src ${MENU_BRIDGE_CSP}; frame-ancestors 'self'`)
|
|
596
596
|
.header('X-Content-Type-Options', 'nosniff')
|
|
597
597
|
.header('Referrer-Policy', 'no-referrer')
|
|
598
598
|
// Someone else's mail has no business in a cache.
|
|
@@ -15,8 +15,17 @@
|
|
|
15
15
|
* The CSP meta below is a fourth layer that costs nothing. None of these are
|
|
16
16
|
* individually sufficient, which is the point.
|
|
17
17
|
*
|
|
18
|
+
* One exception to "no scripts" since 0.117: MENU_BRIDGE, our own few lines,
|
|
19
|
+
* allowed by its hash and nothing else. A right-click inside a sandboxed frame
|
|
20
|
+
* never reaches the admin, so the reading pane had no menu on the message
|
|
21
|
+
* itself; the bridge posts where the click was (and any selected text) to the
|
|
22
|
+
* parent, which opens the menu. The frame still has no `allow-same-origin`, so
|
|
23
|
+
* the script runs in an opaque origin with nothing of the admin's to reach, and
|
|
24
|
+
* a script in the mail itself is stripped by layer 1 and refused by the hash.
|
|
25
|
+
*
|
|
18
26
|
* @module mail-reader/services/render
|
|
19
27
|
*/
|
|
28
|
+
import {createHash} from 'node:crypto';
|
|
20
29
|
import sanitizeHtml from 'sanitize-html';
|
|
21
30
|
|
|
22
31
|
/** A 1x1 transparent GIF - stands in for a blocked remote image. */
|
|
@@ -174,6 +183,34 @@ function escapeHtml(value) {
|
|
|
174
183
|
.replace(/'/g, ''');
|
|
175
184
|
}
|
|
176
185
|
|
|
186
|
+
/** What the bridge posts to the reading pane; the parent checks for it. */
|
|
187
|
+
export const MENU_BRIDGE_TYPE = 'dm-mail-body-menu';
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The reading pane's menu, from inside the message frame. Links and images
|
|
191
|
+
* keep the browser's own menu (open in a new tab, copy the address); anywhere
|
|
192
|
+
* else the click is posted to the parent with the selection, capped. The
|
|
193
|
+
* keyboard's menu key and Shift+F10 fire `contextmenu` too, with no pointer
|
|
194
|
+
* position, which the parent reads as "at the top of the message" - and are
|
|
195
|
+
* also caught as keys, since not every browser turns them into a contextmenu
|
|
196
|
+
* event (preventDefault on the key stops a second one where it does). A click or
|
|
197
|
+
* Escape inside the frame posts `close`, since neither reaches the parent's
|
|
198
|
+
* document, where an open menu listens for them.
|
|
199
|
+
*/
|
|
200
|
+
export const MENU_BRIDGE = `(function(){document.addEventListener('contextmenu',function(e){`
|
|
201
|
+
+ `var t=e.target;if(t&&t.closest&&t.closest('a[href],img'))return;e.preventDefault();`
|
|
202
|
+
+ `var s=String(window.getSelection?window.getSelection():'').slice(0,20000);`
|
|
203
|
+
+ `parent.postMessage({type:'${MENU_BRIDGE_TYPE}',x:e.clientX||0,y:e.clientY||0,selection:s},'*');});`
|
|
204
|
+
+ `function c(){parent.postMessage({type:'${MENU_BRIDGE_TYPE}',close:true},'*');}`
|
|
205
|
+
+ `document.addEventListener('mousedown',function(e){if(e.button===0)c();});`
|
|
206
|
+
+ `document.addEventListener('keydown',function(e){if(e.key==='Escape')c();`
|
|
207
|
+
+ `if((e.key==='F10'&&e.shiftKey)||e.key==='ContextMenu'){e.preventDefault();`
|
|
208
|
+
+ `var s=String(window.getSelection?window.getSelection():'').slice(0,20000);`
|
|
209
|
+
+ `parent.postMessage({type:'${MENU_BRIDGE_TYPE}',x:0,y:0,selection:s},'*');}});})();`;
|
|
210
|
+
|
|
211
|
+
/** The CSP source that allows MENU_BRIDGE and no other script. */
|
|
212
|
+
export const MENU_BRIDGE_CSP = `'sha256-${createHash('sha256').update(MENU_BRIDGE).digest('base64')}'`;
|
|
213
|
+
|
|
177
214
|
/**
|
|
178
215
|
* Wrap a sanitised body in a self-contained document for iframe srcdoc.
|
|
179
216
|
*
|
|
@@ -188,7 +225,7 @@ function wrapDocument(bodyHtml) {
|
|
|
188
225
|
return `<!DOCTYPE html>
|
|
189
226
|
<html><head>
|
|
190
227
|
<meta charset="utf-8">
|
|
191
|
-
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data: http: https:; style-src 'unsafe-inline'; font-src data:;">
|
|
228
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data: http: https:; style-src 'unsafe-inline'; font-src data:; script-src ${MENU_BRIDGE_CSP};">
|
|
192
229
|
<base target="_blank">
|
|
193
230
|
<style>
|
|
194
231
|
html, body { margin: 0; padding: 16px; }
|
|
@@ -204,7 +241,7 @@ function wrapDocument(bodyHtml) {
|
|
|
204
241
|
border-left: 3px solid #d0d0d0; color: #555;
|
|
205
242
|
}
|
|
206
243
|
</style>
|
|
207
|
-
</head><body>${bodyHtml}</body></html>`;
|
|
244
|
+
</head><body>${bodyHtml}<script>${MENU_BRIDGE}</script></body></html>`;
|
|
208
245
|
}
|
|
209
246
|
|
|
210
247
|
/**
|
|
@@ -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@
|
|
3
|
+
"source": "dcms-marketplace@c9103e9",
|
|
4
4
|
"plugins": {
|
|
5
5
|
"blog": {
|
|
6
6
|
"version": "1.9.4",
|
|
@@ -138,14 +138,14 @@
|
|
|
138
138
|
}
|
|
139
139
|
},
|
|
140
140
|
"mail-reader": {
|
|
141
|
-
"version": "1.2.
|
|
141
|
+
"version": "1.2.4",
|
|
142
142
|
"files": {
|
|
143
|
-
"CLAUDE.md": "
|
|
143
|
+
"CLAUDE.md": "29a46b6113dafb37c5e4c728a0d1f7286e805301e7d3d2bbae0c72eb43d5f456",
|
|
144
144
|
"admin/views/mail.js": "c5c6483f9618c75cac8f4be32d3aea99511db76d4b9d38b8ef43bdc1d35db3c5",
|
|
145
145
|
"config.js": "6e65803ad822a9acda40c203631b8eb32a87cf9d9e5f1c7cb59d01aa406075c6",
|
|
146
|
-
"docs/guide.md": "
|
|
146
|
+
"docs/guide.md": "14f8c45663c1325f681cce418d59d4093cc858d605a98630cce0030fff5e479d",
|
|
147
147
|
"plugin.js": "7f602a60c4eca7d3637659d03d5de9e242584666846b16c9e5a1b758e4856c9f",
|
|
148
|
-
"plugin.json": "
|
|
148
|
+
"plugin.json": "0d928f4a6dc5bcca9e23e35280d026eb133a8bb1120daea54cbe754c2b4e7773"
|
|
149
149
|
}
|
|
150
150
|
},
|
|
151
151
|
"security": {
|
|
@@ -97,3 +97,8 @@ pool leaves nothing open; it does not verify folder listing or rendering, which
|
|
|
97
97
|
|
|
98
98
|
plugin.json `uses: {contacts}` - Add sender to contacts comes from core Contacts (`_lib/admin/mail/contacts.js`).
|
|
99
99
|
With the Tool switched off its API 404s and the action is simply absent.
|
|
100
|
+
|
|
101
|
+
## Reading pane menu (guide only, 1.2.4; core domma-cms PR #93)
|
|
102
|
+
|
|
103
|
+
Right-click on the open message is engine code (`_lib/admin/mail/body-menu.js`, the `MENU_BRIDGE` in
|
|
104
|
+
`_lib/mail/render.js`); this version only documents it in docs/guide.md. Ship after the core release.
|
|
@@ -53,6 +53,7 @@ You need your mailbox's IMAP details from your email provider: the server name,
|
|
|
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.
|
|
56
|
+
- Right-click the message itself (or press Shift+F10 or the menu key while reading it) for **Copy** when you have selected some text, plus the message list's menu. Right-clicking a link or picture in the message gives your browser's own menu.
|
|
56
57
|
|
|
57
58
|
### Keyboard
|
|
58
59
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mail-reader",
|
|
3
3
|
"displayName": "Mail Reader",
|
|
4
|
-
"version": "1.2.
|
|
4
|
+
"version": "1.2.4",
|
|
5
5
|
"tier": "free",
|
|
6
6
|
"description": "Read-only IMAP mailbox reader. Connects to a standard IMAP server and reads mail without ever writing to it.",
|
|
7
7
|
"author": "Darryl Waterhouse",
|
|
@@ -22,6 +22,7 @@ import fs from 'fs/promises';
|
|
|
22
22
|
import path from 'path';
|
|
23
23
|
import { config } from '../../config.js';
|
|
24
24
|
import { applyFilter } from '../filterEngine.js';
|
|
25
|
+
import { withFileLock, writeFileAtomic } from './fileLock.js';
|
|
25
26
|
|
|
26
27
|
const COLLECTIONS_DIR = path.resolve(config.content.collectionsDir);
|
|
27
28
|
|
|
@@ -43,9 +44,16 @@ async function read(slug) {
|
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
async function write(slug, entries) {
|
|
46
|
-
await
|
|
47
|
+
await writeFileAtomic(dataPath(slug), JSON.stringify(entries, null, 2) + '\n');
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Every change is read-modify-write of the whole file, so changes to one
|
|
52
|
+
* collection run one at a time (./fileLock.js) - overlapping ones used to
|
|
53
|
+
* lose all but the last. Reads need no lock: writes are atomic renames.
|
|
54
|
+
*/
|
|
55
|
+
const locked = (slug, fn) => withFileLock(dataPath(slug), fn);
|
|
56
|
+
|
|
49
57
|
// ---------------------------------------------------------------------------
|
|
50
58
|
// Adapter implementation
|
|
51
59
|
// ---------------------------------------------------------------------------
|
|
@@ -125,10 +133,12 @@ export class FileAdapter {
|
|
|
125
133
|
* @returns {Promise<object>}
|
|
126
134
|
*/
|
|
127
135
|
async insert(slug, entry) {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
136
|
+
return locked(slug, async () => {
|
|
137
|
+
const entries = await read(slug);
|
|
138
|
+
entries.push(entry);
|
|
139
|
+
await write(slug, entries);
|
|
140
|
+
return entry;
|
|
141
|
+
});
|
|
132
142
|
}
|
|
133
143
|
|
|
134
144
|
/**
|
|
@@ -141,12 +151,14 @@ export class FileAdapter {
|
|
|
141
151
|
* @throws {Error} If entry not found
|
|
142
152
|
*/
|
|
143
153
|
async update(slug, entryId, updated) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
154
|
+
return locked(slug, async () => {
|
|
155
|
+
const entries = await read(slug);
|
|
156
|
+
const idx = entries.findIndex(e => e.id === entryId);
|
|
157
|
+
if (idx === -1) throw new Error('Entry not found');
|
|
158
|
+
entries[idx] = updated;
|
|
159
|
+
await write(slug, entries);
|
|
160
|
+
return updated;
|
|
161
|
+
});
|
|
150
162
|
}
|
|
151
163
|
|
|
152
164
|
/**
|
|
@@ -158,11 +170,13 @@ export class FileAdapter {
|
|
|
158
170
|
* @throws {Error} If entry not found
|
|
159
171
|
*/
|
|
160
172
|
async remove(slug, entryId) {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
173
|
+
return locked(slug, async () => {
|
|
174
|
+
const entries = await read(slug);
|
|
175
|
+
const idx = entries.findIndex(e => e.id === entryId);
|
|
176
|
+
if (idx === -1) throw new Error('Entry not found');
|
|
177
|
+
entries.splice(idx, 1);
|
|
178
|
+
await write(slug, entries);
|
|
179
|
+
});
|
|
166
180
|
}
|
|
167
181
|
|
|
168
182
|
/**
|
|
@@ -172,7 +186,7 @@ export class FileAdapter {
|
|
|
172
186
|
* @returns {Promise<void>}
|
|
173
187
|
*/
|
|
174
188
|
async clear(slug) {
|
|
175
|
-
|
|
189
|
+
return locked(slug, () => write(slug, []));
|
|
176
190
|
}
|
|
177
191
|
|
|
178
192
|
/**
|
|
@@ -193,10 +207,12 @@ export class FileAdapter {
|
|
|
193
207
|
* @returns {Promise<{ imported: number, skipped: number, errors: string[] }>}
|
|
194
208
|
*/
|
|
195
209
|
async insertMany(slug, entries) {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
210
|
+
return locked(slug, async () => {
|
|
211
|
+
const existing = await read(slug);
|
|
212
|
+
existing.push(...entries);
|
|
213
|
+
await write(slug, existing);
|
|
214
|
+
return { imported: entries.length, skipped: 0, errors: [] };
|
|
215
|
+
});
|
|
200
216
|
}
|
|
201
217
|
|
|
202
218
|
/**
|
|
@@ -219,7 +235,7 @@ export class FileAdapter {
|
|
|
219
235
|
* @returns {Promise<void>}
|
|
220
236
|
*/
|
|
221
237
|
async replaceAll(slug, entries) {
|
|
222
|
-
|
|
238
|
+
return locked(slug, () => write(slug, entries));
|
|
223
239
|
}
|
|
224
240
|
|
|
225
241
|
/**
|
|
@@ -231,6 +247,10 @@ export class FileAdapter {
|
|
|
231
247
|
* @returns {Promise<string|null>} The backup's file name, or null if there was no data.json
|
|
232
248
|
*/
|
|
233
249
|
async archive(slug) {
|
|
250
|
+
return locked(slug, () => this.#archive(slug));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async #archive(slug) {
|
|
234
254
|
const from = dataPath(slug);
|
|
235
255
|
let to = `${from}.bak`;
|
|
236
256
|
try {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One writer at a time per JSON file, and no half-written file ever on disk.
|
|
3
|
+
*
|
|
4
|
+
* A collection's `data.json` is changed by reading the whole file, changing it
|
|
5
|
+
* in memory and writing it back. Two of those overlapping - two form
|
|
6
|
+
* submissions, two imports, a save during a migration - each read the same
|
|
7
|
+
* starting file, and the second write silently drops the first one's change.
|
|
8
|
+
* `withFileLock()` queues them per file, so each read sees the last write.
|
|
9
|
+
*
|
|
10
|
+
* `writeFileAtomic()` writes a temporary file beside the target and renames
|
|
11
|
+
* it over, so a crash mid-write leaves the old file whole rather than a
|
|
12
|
+
* truncated one that reads as "no entries".
|
|
13
|
+
*
|
|
14
|
+
* The queue is in-process: one site is one Node process, which is how every
|
|
15
|
+
* site runs. Anything else writing the same file must go through here too
|
|
16
|
+
* (userProfiles.js does).
|
|
17
|
+
*
|
|
18
|
+
* @module server/services/adapters/fileLock
|
|
19
|
+
*/
|
|
20
|
+
import fs from 'fs/promises';
|
|
21
|
+
import path from 'path';
|
|
22
|
+
|
|
23
|
+
/** file path -> the tail of its queue */
|
|
24
|
+
const queues = new Map();
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Run `fn` once every earlier call for the same file has finished (resolved
|
|
28
|
+
* or thrown). A failure is passed to this caller only; the queue goes on.
|
|
29
|
+
*
|
|
30
|
+
* @template T
|
|
31
|
+
* @param {string} file
|
|
32
|
+
* @param {() => Promise<T>} fn
|
|
33
|
+
* @returns {Promise<T>}
|
|
34
|
+
*/
|
|
35
|
+
export function withFileLock(file, fn) {
|
|
36
|
+
const key = path.resolve(file);
|
|
37
|
+
const prev = queues.get(key) ?? Promise.resolve();
|
|
38
|
+
const run = prev.then(fn, fn);
|
|
39
|
+
const tail = run.then(() => {}, () => {});
|
|
40
|
+
queues.set(key, tail);
|
|
41
|
+
tail.then(() => { if (queues.get(key) === tail) queues.delete(key); });
|
|
42
|
+
return run;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let seq = 0;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Write `text` to `file` atomically: a temporary file in the same directory,
|
|
49
|
+
* then a rename over the target.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} file
|
|
52
|
+
* @param {string} text
|
|
53
|
+
*/
|
|
54
|
+
export async function writeFileAtomic(file, text) {
|
|
55
|
+
const tmp = `${file}.${process.pid}.${Date.now().toString(36)}${(seq++).toString(36)}.tmp`;
|
|
56
|
+
try {
|
|
57
|
+
await fs.writeFile(tmp, text, 'utf8');
|
|
58
|
+
await fs.rename(tmp, file);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
await fs.rm(tmp, {force: true}).catch(() => {});
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import fs from 'fs/promises';
|
|
7
7
|
import path from 'path';
|
|
8
8
|
import {config} from '../config.js';
|
|
9
|
+
import {withFileLock, writeFileAtomic} from './adapters/fileLock.js';
|
|
9
10
|
|
|
10
11
|
const COLLECTIONS_DIR = path.resolve(config.content.collectionsDir);
|
|
11
12
|
const SLUG = 'user-profiles';
|
|
@@ -54,9 +55,12 @@ async function readData() {
|
|
|
54
55
|
* @returns {Promise<void>}
|
|
55
56
|
*/
|
|
56
57
|
async function writeData(entries) {
|
|
57
|
-
await
|
|
58
|
+
await writeFileAtomic(DATA_PATH, JSON.stringify(entries, null, 2) + '\n');
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
/** Changes go one at a time, sharing the FileAdapter's queue for this file (adapters/fileLock.js). */
|
|
62
|
+
const locked = (fn) => withFileLock(DATA_PATH, fn);
|
|
63
|
+
|
|
60
64
|
// ---------------------------------------------------------------------------
|
|
61
65
|
// Public API
|
|
62
66
|
// ---------------------------------------------------------------------------
|
|
@@ -93,6 +97,10 @@ export async function seed() {
|
|
|
93
97
|
* @returns {Promise<void>}
|
|
94
98
|
*/
|
|
95
99
|
export async function ensureProfile(userId) {
|
|
100
|
+
return locked(() => ensureProfileUnlocked(userId));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function ensureProfileUnlocked(userId) {
|
|
96
104
|
const entries = await readData();
|
|
97
105
|
const exists = entries.some(e => e.id === userId);
|
|
98
106
|
if (!exists) {
|
|
@@ -125,6 +133,10 @@ export async function getProfile(userId) {
|
|
|
125
133
|
* @returns {Promise<object>} Updated profile entry
|
|
126
134
|
*/
|
|
127
135
|
export async function updateProfile(userId, data) {
|
|
136
|
+
return locked(() => updateProfileUnlocked(userId, data));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function updateProfileUnlocked(userId, data) {
|
|
128
140
|
const entries = await readData();
|
|
129
141
|
const index = entries.findIndex(e => e.id === userId);
|
|
130
142
|
|
|
@@ -155,11 +167,13 @@ export async function updateProfile(userId, data) {
|
|
|
155
167
|
* @returns {Promise<void>}
|
|
156
168
|
*/
|
|
157
169
|
export async function deleteProfile(userId) {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
170
|
+
return locked(async () => {
|
|
171
|
+
const entries = await readData();
|
|
172
|
+
const filtered = entries.filter(e => e.id !== userId);
|
|
173
|
+
if (filtered.length !== entries.length) {
|
|
174
|
+
await writeData(filtered);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
163
177
|
}
|
|
164
178
|
|
|
165
179
|
/**
|