domma-cms 0.109.1 → 0.109.2
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/package.json
CHANGED
|
@@ -34,6 +34,7 @@ const {
|
|
|
34
34
|
unreadInSubtree
|
|
35
35
|
} = await import(versioned('folder-tree.js'));
|
|
36
36
|
const {imageSendersSection} = await import(versioned('image-senders-section.js'));
|
|
37
|
+
const {createSettingsSection, READ_DELAY_KEY, readDelaySeconds, scheduleMarkRead} = await import(versioned('settings-section.js'));
|
|
37
38
|
const {createSplitters, fitToViewport} = await import(versioned('panes.js'));
|
|
38
39
|
const {createDiagnosticsSection} = await import(versioned('diagnostics-section.js'));
|
|
39
40
|
const {fmtDate, fmtDateTime} = await import(new URL(`../ui/dates.js?v=${ASSET_VERSION}`, import.meta.url).href);
|
|
@@ -184,7 +185,6 @@ const THREADS_KEY = 'domma-mail.threads';
|
|
|
184
185
|
* Long enough that arrowing through a list does not mark everything it passed
|
|
185
186
|
* over, short enough that actually reading something always marks it.
|
|
186
187
|
*/
|
|
187
|
-
const MARK_READ_DELAY_MS = 1000;
|
|
188
188
|
|
|
189
189
|
/** How long the undo strip stays on screen. */
|
|
190
190
|
const UNDO_WINDOW_MS = 12_000;
|
|
@@ -740,6 +740,14 @@ return {
|
|
|
740
740
|
});
|
|
741
741
|
|
|
742
742
|
const sections = [
|
|
743
|
+
createSettingsSection({
|
|
744
|
+
canWrite: features.write,
|
|
745
|
+
load: () => readDelaySeconds(S.get(READ_DELAY_KEY)),
|
|
746
|
+
save: seconds => {
|
|
747
|
+
S.set(READ_DELAY_KEY, seconds);
|
|
748
|
+
cancelMarkRead();
|
|
749
|
+
}
|
|
750
|
+
}),
|
|
743
751
|
{
|
|
744
752
|
id: 'mailboxes',
|
|
745
753
|
label: 'Mailboxes',
|
|
@@ -2126,10 +2134,8 @@ return {
|
|
|
2126
2134
|
const message = await api(`${BASE}/messages/${uid}?${params}`);
|
|
2127
2135
|
renderMessage(message);
|
|
2128
2136
|
|
|
2129
|
-
//
|
|
2130
|
-
//
|
|
2131
|
-
// passed over, and the delay is the difference between reading
|
|
2132
|
-
// something and glancing at it.
|
|
2137
|
+
// The reading preference controls how long mail stays open
|
|
2138
|
+
// before marking it read; zero applies the flag immediately.
|
|
2133
2139
|
//
|
|
2134
2140
|
// Only where the edition can write at all: the free reader's
|
|
2135
2141
|
// guarantee is that looking at mail never changes it, and that
|
|
@@ -2137,14 +2143,14 @@ return {
|
|
|
2137
2143
|
const row = messages.find(m => m.uid === uid);
|
|
2138
2144
|
if (features.write && row && row.seen === false) {
|
|
2139
2145
|
const forFolder = openFolder;
|
|
2140
|
-
markReadTimer =
|
|
2146
|
+
markReadTimer = scheduleMarkRead(() => {
|
|
2141
2147
|
markReadTimer = null;
|
|
2142
2148
|
// Still the message on screen? If the user moved on
|
|
2143
2149
|
// during the pause, they did not read this one.
|
|
2144
2150
|
if (selectedUid$.value !== uid) return;
|
|
2145
2151
|
changeFlags({...message, folder: forFolder}, {add: ['\\Seen']})
|
|
2146
2152
|
.catch(() => {/* not worth interrupting a read for */});
|
|
2147
|
-
},
|
|
2153
|
+
}, readDelaySeconds(S.get(READ_DELAY_KEY)));
|
|
2148
2154
|
}
|
|
2149
2155
|
} catch (err) {
|
|
2150
2156
|
handleError(err);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** Browser preferences for the shared mail reader. */
|
|
2
|
+
export const READ_DELAY_KEY = 'domma-mail.mark-read-seconds';
|
|
3
|
+
export const DEFAULT_READ_DELAY_SECONDS = 1;
|
|
4
|
+
|
|
5
|
+
export function readDelaySeconds(value) {
|
|
6
|
+
if (value === null || value === undefined || value === '') return DEFAULT_READ_DELAY_SECONDS;
|
|
7
|
+
const seconds = Number(value);
|
|
8
|
+
return Number.isFinite(seconds) && seconds >= 0 && seconds <= 2147483
|
|
9
|
+
? seconds : DEFAULT_READ_DELAY_SECONDS;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function scheduleMarkRead(markRead, seconds, schedule = setTimeout) {
|
|
13
|
+
const delay = readDelaySeconds(seconds);
|
|
14
|
+
if (delay === 0) {
|
|
15
|
+
markRead();
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
return schedule(markRead, delay * 1000);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createSettingsSection({canWrite, load, save}) {
|
|
22
|
+
return {
|
|
23
|
+
id: 'settings',
|
|
24
|
+
label: 'Settings',
|
|
25
|
+
help: 'Preferences for reading email in this browser.',
|
|
26
|
+
async render(container, ctx) {
|
|
27
|
+
const kit = await import(new URL(`./section-kit.js?v=${ctx.assetVersion ?? '1'}`, import.meta.url).href);
|
|
28
|
+
kit.note(container, 'Settings are saved in this browser and apply to all your mailboxes.');
|
|
29
|
+
if (!canWrite) {
|
|
30
|
+
kit.note(container, 'This reader leaves messages unread. Automatic marking as read requires an edition that can update messages.');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const {state} = kit.formIn(container, {
|
|
34
|
+
markReadSeconds: {
|
|
35
|
+
type: 'number',
|
|
36
|
+
label: 'How long before a message is marked as read? (seconds)',
|
|
37
|
+
required: true,
|
|
38
|
+
min: 0,
|
|
39
|
+
max: 2147483,
|
|
40
|
+
formConfig: {hint: '0 marks a message as read immediately. Otherwise, it must stay open for this long.'}
|
|
41
|
+
}
|
|
42
|
+
}, {markReadSeconds: load()});
|
|
43
|
+
const bar = kit.actionBar(container);
|
|
44
|
+
kit.button(bar.row, 'Save', () => {
|
|
45
|
+
const value = state.markReadSeconds;
|
|
46
|
+
const seconds = Number(value);
|
|
47
|
+
if (value === '' || value === null || value === undefined || !Number.isFinite(seconds) || seconds < 0 || seconds > 2147483) {
|
|
48
|
+
bar.say('Enter a number of seconds between 0 and 2147483.', 'bad');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
save(seconds);
|
|
53
|
+
bar.say('Saved.', 'ok');
|
|
54
|
+
} catch (err) {
|
|
55
|
+
bar.say(err.message || 'Settings could not be saved.', 'bad');
|
|
56
|
+
}
|
|
57
|
+
}, {variant: 'primary'});
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
package/server/server.js
CHANGED
|
@@ -29,7 +29,8 @@ import {seedDefaultBlocks} from './services/blocks.js';
|
|
|
29
29
|
import {seedDefaultComponents} from './services/components.js';
|
|
30
30
|
import {refreshComponentTagAllowlist} from './services/markdown.js';
|
|
31
31
|
import * as cache from './services/cache/index.js';
|
|
32
|
-
import {
|
|
32
|
+
import {parseTrustProxy} from './services/network.js';
|
|
33
|
+
import {rateLimitOptions} from './services/rate-limit.js';
|
|
33
34
|
|
|
34
35
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
35
36
|
const ROOT = path.resolve(__dirname, '..');
|
|
@@ -148,15 +149,7 @@ if (publicCorsPrefixes.length) {
|
|
|
148
149
|
app.log.info(`[cors] open reads on ${publicCorsPrefixes.join(', ')}`);
|
|
149
150
|
}
|
|
150
151
|
await app.register(multipart, { limits: { fileSize: serverConfig.uploads.maxFileSize } });
|
|
151
|
-
await app.register(rateLimit,
|
|
152
|
-
global: true, // apply default limit to all routes; stricter per-route limits override this
|
|
153
|
-
max: 500,
|
|
154
|
-
timeWindow: '1 minute',
|
|
155
|
-
// Only a genuinely local call is exempt (loopback socket, no X-Forwarded-For).
|
|
156
|
-
// A static loopback list exempted every visitor relayed by a proxy that is
|
|
157
|
-
// not trusted - on dcm, everyone, the login included. See services/network.js.
|
|
158
|
-
allowList: (request) => isGenuinelyLocal(request),
|
|
159
|
-
});
|
|
152
|
+
await app.register(rateLimit, rateLimitOptions());
|
|
160
153
|
|
|
161
154
|
// Negotiates brotli/gzip/deflate per Accept-Encoding. Skips already-compressed
|
|
162
155
|
// content types (images, fonts, video) and responses under threshold.
|
|
@@ -744,4 +737,3 @@ try {
|
|
|
744
737
|
process.exit(1);
|
|
745
738
|
}
|
|
746
739
|
// scaffold-restart 1779615684
|
|
747
|
-
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import {isGenuinelyLocal} from './network.js';
|
|
2
|
+
|
|
3
|
+
/** Browser assets should never spend the API's request budget. */
|
|
4
|
+
export function isBrowserAsset(request) {
|
|
5
|
+
if (!['GET', 'HEAD'].includes(request.method)) return false;
|
|
6
|
+
const path = request.url.split('?')[0];
|
|
7
|
+
return /^\/(?:admin|plugins|dist|public)\//.test(path)
|
|
8
|
+
&& /\.(?:m?js|css|map|png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|otf|html)$/i.test(path);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function rateLimitOptions({publicMax = 500, authenticatedMax = 3000} = {}) {
|
|
12
|
+
// Keep verified identities separate from public IP buckets. A supplied
|
|
13
|
+
// bearer string alone is never enough to receive the larger allowance.
|
|
14
|
+
const identities = new WeakMap();
|
|
15
|
+
return {
|
|
16
|
+
global: true,
|
|
17
|
+
timeWindow: '1 minute',
|
|
18
|
+
async keyGenerator(request) {
|
|
19
|
+
if (request.url.startsWith('/api/') && request.headers.authorization) {
|
|
20
|
+
try {
|
|
21
|
+
const user = await request.jwtVerify();
|
|
22
|
+
if (user.type === 'access' && user.id) {
|
|
23
|
+
identities.set(request, user.id);
|
|
24
|
+
return `user:${user.id}`;
|
|
25
|
+
}
|
|
26
|
+
} catch { /* invalid tokens retain the public limit */ }
|
|
27
|
+
}
|
|
28
|
+
return request.ip;
|
|
29
|
+
},
|
|
30
|
+
max: request => identities.has(request) ? authenticatedMax : publicMax,
|
|
31
|
+
allowList: request => isGenuinelyLocal(request)
|
|
32
|
+
|| (!request.routeOptions.config.rateLimit && isBrowserAsset(request))
|
|
33
|
+
};
|
|
34
|
+
}
|