domma-cms 1.10.37 → 1.10.39
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 +10 -0
- package/admin/js/lib/mail-compose.js +1 -1
- package/admin/js/lib/sidebar-notify.js +1 -0
- package/admin/js/lib/sidebar-renderer.js +5 -5
- package/package.json +2 -1
- package/plugins/_lib/admin/mail/compose-window.js +138 -20
- package/plugins/_lib/admin/mail/mail.css +1 -1
- package/plugins/_lib/admin/mail/reader-view.js +74 -10
- package/plugins/_lib/admin/mail/rules.js +81 -0
- package/plugins/_lib/admin/mail/security-note.js +68 -0
- package/plugins/_lib/admin/mail/settings-section.js +205 -18
- package/plugins/_lib/mail/accounts.js +86 -3
- package/plugins/_lib/mail/compose.js +15 -1
- package/plugins/_lib/mail/message-action.js +32 -0
- package/plugins/_lib/mail/messageSecurity.js +111 -0
- package/plugins/_lib/mail/readRoutes.js +36 -6
- package/plugins/_lib/mail/send.js +105 -23
- package/plugins/_lib/mail/store.js +23 -1
- package/plugins/_lib/mail/sync.js +4 -1
- package/plugins/_lib/mail/unreadBadge.js +24 -1
- package/plugins/_lib/mail/vacationRunner.js +4 -1
- package/server/routes/api/sidebar-counts.js +1 -1
|
@@ -35,7 +35,7 @@ const {
|
|
|
35
35
|
} = await import(versioned('folder-tree.js'));
|
|
36
36
|
const {imageSendersSection} = await import(versioned('image-senders-section.js'));
|
|
37
37
|
const {
|
|
38
|
-
createSettingsSection, READ_DELAY_KEY, readDelaySeconds, scheduleMarkRead,
|
|
38
|
+
createSettingsSection, READ_DELAY_KEY, readDelaySeconds, scheduleMarkRead, readComposePrefs,
|
|
39
39
|
PAGE_SIZE_KEY, PAGE_SIZES, listPageSize, pageAfterResize
|
|
40
40
|
} = await import(versioned('settings-section.js'));
|
|
41
41
|
const {createSplitters, fitToViewport} = await import(versioned('panes.js'));
|
|
@@ -50,6 +50,9 @@ const {fillMessageAction, loadMessageActions, messageFacts} = await import(versi
|
|
|
50
50
|
const {isPlainKey, keyBelongsElsewhere, resolveKey, SHORTCUTS} = await import(versioned('keyboard.js'));
|
|
51
51
|
const {menuPoint, quoteSelection, readBridgeMessage, selectionItems} = await import(versioned('body-menu.js'));
|
|
52
52
|
const {describeWhen, quickTimes} = await import(versioned('scheduling.js'));
|
|
53
|
+
const {ruleFromMessage} = await import(versioned('rules.js'));
|
|
54
|
+
// Optional for the same reason as oauth-panel.js below.
|
|
55
|
+
const {securityNote} = await import(versioned('security-note.js')).catch(() => ({securityNote: () => null}));
|
|
53
56
|
// Sign in with Microsoft / Google (Email Pro). Optional: an older copy of this
|
|
54
57
|
// directory without it must not take the reader down.
|
|
55
58
|
const {describeSignIn, mountSignIn} = await import(versioned('oauth-panel.js')).catch(() => ({}));
|
|
@@ -192,6 +195,8 @@ function formatSize(bytes) {
|
|
|
192
195
|
*/
|
|
193
196
|
export function createReaderView({pluginName, assetVersion = ASSET_VERSION, settingsSections = [], features = {},
|
|
194
197
|
extensions = {}}) {
|
|
198
|
+
// Filing rules come with an edition's Rules tab (Email Pro).
|
|
199
|
+
const hasRules = settingsSections.some(section => section?.id === 'rules');
|
|
195
200
|
const BASE = `/api/plugins/${pluginName}`;
|
|
196
201
|
|
|
197
202
|
// Deliberately NOT keyed by plugin. The two editions never run together - the
|
|
@@ -908,13 +913,16 @@ return {
|
|
|
908
913
|
*
|
|
909
914
|
* @returns {void}
|
|
910
915
|
*/
|
|
911
|
-
function openSettings() {
|
|
916
|
+
function openSettings(target = null) {
|
|
912
917
|
// One at a time: a second click, or a needs-password error while
|
|
913
918
|
// it is open, must not stack a second panel on the first.
|
|
914
|
-
if (!settingsOnce.claim())
|
|
919
|
+
if (!settingsOnce.claim()) {
|
|
920
|
+
if (target) E.toast('Mail Settings is already open. Close it first.', {type: 'info'});
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
915
923
|
let panel = null;
|
|
916
924
|
try {
|
|
917
|
-
buildSettings((so) => { panel = so; });
|
|
925
|
+
buildSettings((so) => { panel = so; }, target);
|
|
918
926
|
} catch (err) {
|
|
919
927
|
// Failed before it opened: let go of everything it took.
|
|
920
928
|
panel?.__mailAbandon?.();
|
|
@@ -924,7 +932,13 @@ return {
|
|
|
924
932
|
}
|
|
925
933
|
|
|
926
934
|
/** @returns {void} */
|
|
927
|
-
|
|
935
|
+
/**
|
|
936
|
+
* @param {Function} [made] - handed the slideover once it exists
|
|
937
|
+
* @param {{section: string, request?: object}|null} [target] - open on
|
|
938
|
+
* this tab, and hand its section `ctx.request` (once) - e.g. the
|
|
939
|
+
* rule a message's "Create a rule…" starts
|
|
940
|
+
*/
|
|
941
|
+
function buildSettings(made = () => {}, target = null) {
|
|
928
942
|
// Help popovers per drawn section, let go when the panel closes.
|
|
929
943
|
const helpDisposers = [];
|
|
930
944
|
// What sections registered through ctx.guard / ctx.onClose.
|
|
@@ -958,6 +972,8 @@ return {
|
|
|
958
972
|
const sections = [
|
|
959
973
|
createSettingsSection({
|
|
960
974
|
canWrite: features.write,
|
|
975
|
+
canSend: features.send === true,
|
|
976
|
+
sidebarUrl: sidebarUrl(pluginName),
|
|
961
977
|
load: () => readDelaySeconds(S.get(READ_DELAY_KEY)),
|
|
962
978
|
save: seconds => {
|
|
963
979
|
S.set(READ_DELAY_KEY, seconds);
|
|
@@ -1003,8 +1019,11 @@ return {
|
|
|
1003
1019
|
*
|
|
1004
1020
|
* @returns {object}
|
|
1005
1021
|
*/
|
|
1006
|
-
function context() {
|
|
1022
|
+
function context(section = null) {
|
|
1007
1023
|
return {
|
|
1024
|
+
// What opened this section, when something did (a menu
|
|
1025
|
+
// item asking for a new rule). Only for that section.
|
|
1026
|
+
request: target && section?.id === target.section ? (target.request ?? null) : null,
|
|
1008
1027
|
account: mailboxes.find(b => b.id === currentAccountId) ?? mailboxes[0] ?? null,
|
|
1009
1028
|
mailboxes,
|
|
1010
1029
|
base: BASE,
|
|
@@ -1082,7 +1101,7 @@ return {
|
|
|
1082
1101
|
panel.appendChild(head);
|
|
1083
1102
|
}
|
|
1084
1103
|
try {
|
|
1085
|
-
await section.render(panel, context());
|
|
1104
|
+
await section.render(panel, context(section));
|
|
1086
1105
|
} catch (err) {
|
|
1087
1106
|
const message = document.createElement('p');
|
|
1088
1107
|
message.className = 'text-sm text-danger';
|
|
@@ -1131,8 +1150,15 @@ return {
|
|
|
1131
1150
|
host.appendChild(tabs);
|
|
1132
1151
|
slideover.element.appendChild(host);
|
|
1133
1152
|
E.tabs(tabs);
|
|
1134
|
-
|
|
1135
|
-
|
|
1153
|
+
// The tab asked for, through its own trigger so the tabs
|
|
1154
|
+
// component switches as it would on a click; else the first.
|
|
1155
|
+
const first = panels.findIndex(p => p.section.id === target?.section);
|
|
1156
|
+
if (first > 0) {
|
|
1157
|
+
list.children[first].click();
|
|
1158
|
+
} else {
|
|
1159
|
+
panels[0].drawn = true;
|
|
1160
|
+
draw(panels[0].section, panels[0].panel);
|
|
1161
|
+
}
|
|
1136
1162
|
slideover.open();
|
|
1137
1163
|
return;
|
|
1138
1164
|
}
|
|
@@ -3192,6 +3218,29 @@ return {
|
|
|
3192
3218
|
|
|
3193
3219
|
readerBodyEl.textContent = '';
|
|
3194
3220
|
|
|
3221
|
+
// Signed or encrypted: said first, above the images notice, since
|
|
3222
|
+
// it is about whether to believe the rest of the pane at all.
|
|
3223
|
+
const security = securityNote(message.security);
|
|
3224
|
+
if (security) {
|
|
3225
|
+
const strip = document.createElement('div');
|
|
3226
|
+
strip.className = `mail-notice mail-security mail-security--${security.tone}`;
|
|
3227
|
+
const icon = document.createElement('span');
|
|
3228
|
+
icon.dataset.icon = security.icon;
|
|
3229
|
+
icon.dataset.iconSize = '14';
|
|
3230
|
+
icon.setAttribute('aria-hidden', 'true');
|
|
3231
|
+
const text = document.createElement('span');
|
|
3232
|
+
text.textContent = security.text;
|
|
3233
|
+
strip.append(icon, text);
|
|
3234
|
+
if (security.detail) {
|
|
3235
|
+
const more = document.createElement('span');
|
|
3236
|
+
more.className = 'mail-security-detail';
|
|
3237
|
+
more.textContent = security.detail;
|
|
3238
|
+
strip.appendChild(more);
|
|
3239
|
+
}
|
|
3240
|
+
readerBodyEl.appendChild(strip);
|
|
3241
|
+
Domma.icons.scan(strip);
|
|
3242
|
+
}
|
|
3243
|
+
|
|
3195
3244
|
if (message.blockedImages > 0 && !message.remoteImagesLoaded) {
|
|
3196
3245
|
const notice = document.createElement('div');
|
|
3197
3246
|
notice.className = 'mail-notice';
|
|
@@ -3715,6 +3764,12 @@ return {
|
|
|
3715
3764
|
{type: 'divider'}
|
|
3716
3765
|
] : []),
|
|
3717
3766
|
...toolActionItems(message),
|
|
3767
|
+
// An edition with filing rules: start one from this message.
|
|
3768
|
+
...(hasRules ? [{
|
|
3769
|
+
label: 'Create a rule from this message\u2026',
|
|
3770
|
+
icon: 'filter',
|
|
3771
|
+
action: () => openSettings({section: 'rules', request: {newRule: ruleFromMessage(message)}})
|
|
3772
|
+
}] : []),
|
|
3718
3773
|
{type: 'divider'},
|
|
3719
3774
|
{
|
|
3720
3775
|
label: 'Mark as spam',
|
|
@@ -3923,7 +3978,11 @@ return {
|
|
|
3923
3978
|
identities: identities?.identities ?? [],
|
|
3924
3979
|
mailboxAddress: identities?.mailboxAddress ?? '',
|
|
3925
3980
|
templates: templates?.templates ?? [],
|
|
3926
|
-
undoSendSeconds: sending?.undoSendSeconds ?? 0
|
|
3981
|
+
undoSendSeconds: sending?.undoSendSeconds ?? 0,
|
|
3982
|
+
// Where this mailbox's ticks start, and whether it can
|
|
3983
|
+
// sign or encrypt at all (a certificate is set up).
|
|
3984
|
+
compose: sending?.compose ?? null,
|
|
3985
|
+
security: sending?.security ?? null
|
|
3927
3986
|
})).catch(() => EMPTY_PROFILE);
|
|
3928
3987
|
}
|
|
3929
3988
|
return profileRequest;
|
|
@@ -3983,6 +4042,11 @@ return {
|
|
|
3983
4042
|
mailboxAddress: profile?.mailboxAddress ?? '',
|
|
3984
4043
|
templates: profile?.templates ?? [],
|
|
3985
4044
|
undoSeconds: profile?.undoSendSeconds ?? 0,
|
|
4045
|
+
defaults: profile?.compose ?? null,
|
|
4046
|
+
security: profile?.security ?? null,
|
|
4047
|
+
// Read at each opening, so a change in Settings applies to
|
|
4048
|
+
// the next message without a reload.
|
|
4049
|
+
prefs: readComposePrefs(S),
|
|
3986
4050
|
onSent: () => loadMessages({quiet: true}),
|
|
3987
4051
|
onQueued: (result) => offerRecall(result),
|
|
3988
4052
|
...extra
|
|
@@ -32,6 +32,14 @@ export const MAX_RULES = 50;
|
|
|
32
32
|
/** How many conditions or actions one rule may hold. */
|
|
33
33
|
export const MAX_CLAUSES = 10;
|
|
34
34
|
|
|
35
|
+
/** How many groups one mailbox's rules may be sorted into. */
|
|
36
|
+
export const MAX_GROUPS = 20;
|
|
37
|
+
|
|
38
|
+
/** A group name: one line, short, trimmed. */
|
|
39
|
+
function groupName(value) {
|
|
40
|
+
return String(value ?? '').replace(/[\r\n\t]+/g, ' ').trim().slice(0, 60);
|
|
41
|
+
}
|
|
42
|
+
|
|
35
43
|
/**
|
|
36
44
|
* The parts of a message a rule can look at.
|
|
37
45
|
*
|
|
@@ -217,6 +225,9 @@ export function normaliseRule(input, index = 0) {
|
|
|
217
225
|
: `rule-${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36).slice(-4)}`,
|
|
218
226
|
name: String(source.name ?? '').replace(/[\r\n]+/g, ' ').trim() || `Rule ${index + 1}`,
|
|
219
227
|
enabled: source.enabled !== false,
|
|
228
|
+
// The group it is filed under; '' for none. A group is a NAME, so a
|
|
229
|
+
// rule and the group list can never point at an id that went away.
|
|
230
|
+
group: groupName(source.group),
|
|
220
231
|
match: source.match === 'any' ? 'any' : 'all',
|
|
221
232
|
conditions,
|
|
222
233
|
actions
|
|
@@ -244,6 +255,76 @@ export function normaliseRules(account) {
|
|
|
244
255
|
return list;
|
|
245
256
|
}
|
|
246
257
|
|
|
258
|
+
/**
|
|
259
|
+
* A mailbox's rule groups, in the order they are shown and run: the stored
|
|
260
|
+
* list (`account.ruleGroups`, `[{name, enabled}]`), then any group a rule
|
|
261
|
+
* names that the list lacks - so a group cannot vanish from under its rules.
|
|
262
|
+
* Names compare without case; the first spelling wins.
|
|
263
|
+
*
|
|
264
|
+
* @param {object|null|undefined} account - `{ruleGroups, rules}`
|
|
265
|
+
* @returns {{name: string, enabled: boolean}[]}
|
|
266
|
+
*/
|
|
267
|
+
export function normaliseRuleGroups(account) {
|
|
268
|
+
const source = account && typeof account === 'object' ? account : {};
|
|
269
|
+
const out = [];
|
|
270
|
+
const seen = new Set();
|
|
271
|
+
const add = (name, enabled) => {
|
|
272
|
+
const clean = groupName(name);
|
|
273
|
+
if (!clean || seen.has(clean.toLowerCase()) || out.length >= MAX_GROUPS) return;
|
|
274
|
+
seen.add(clean.toLowerCase());
|
|
275
|
+
out.push({name: clean, enabled: enabled !== false});
|
|
276
|
+
};
|
|
277
|
+
for (const group of Array.isArray(source.ruleGroups) ? source.ruleGroups : []) {
|
|
278
|
+
add(typeof group === 'string' ? group : group?.name, group?.enabled);
|
|
279
|
+
}
|
|
280
|
+
for (const rule of Array.isArray(source.rules) ? source.rules : []) add(rule?.group, true);
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The rules that actually run: switched on, and not in a group that is
|
|
286
|
+
* switched off. Order is kept - it is the order they run in.
|
|
287
|
+
*
|
|
288
|
+
* @param {object[]} rules
|
|
289
|
+
* @param {{name: string, enabled: boolean}[]} [groups]
|
|
290
|
+
* @returns {object[]}
|
|
291
|
+
*/
|
|
292
|
+
export function rulesInEffect(rules, groups = []) {
|
|
293
|
+
const off = new Set((groups ?? []).filter(g => g.enabled === false).map(g => g.name.toLowerCase()));
|
|
294
|
+
return (rules ?? []).filter(rule => rule?.enabled !== false
|
|
295
|
+
&& !(rule.group && off.has(String(rule.group).toLowerCase())));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* A new rule started from a message - "Create a rule from this message".
|
|
300
|
+
*
|
|
301
|
+
* Mail from a mailing list is caught by its list; anything else by its
|
|
302
|
+
* sender's address, the narrowest thing that still catches the next one.
|
|
303
|
+
* The action is a move with the folder left to choose: the rule is not saved
|
|
304
|
+
* until someone has picked where the mail goes and pressed Save.
|
|
305
|
+
*
|
|
306
|
+
* @param {object} message - a list row or opened message
|
|
307
|
+
* @returns {object}
|
|
308
|
+
*/
|
|
309
|
+
export function ruleFromMessage(message) {
|
|
310
|
+
const sender = (message?.from ?? [])[0] ?? {};
|
|
311
|
+
const address = String(sender.address ?? '').trim();
|
|
312
|
+
const list = String(message?.listId ?? '').trim();
|
|
313
|
+
const condition = list
|
|
314
|
+
? {field: 'list', op: 'contains', value: list.replace(/^.*<|>.*$/g, '')}
|
|
315
|
+
: {field: 'from', op: 'contains', value: address};
|
|
316
|
+
const who = list ? `the ${list.replace(/^.*<|>.*$/g, '')} list` : (sender.name || address || 'this sender');
|
|
317
|
+
return {
|
|
318
|
+
id: '',
|
|
319
|
+
name: `Mail from ${who}`.slice(0, 120),
|
|
320
|
+
enabled: true,
|
|
321
|
+
match: 'all',
|
|
322
|
+
group: '',
|
|
323
|
+
conditions: condition.value ? [condition] : [{field: 'from', op: 'contains', value: ''}],
|
|
324
|
+
actions: [{type: 'move', folder: ''}]
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
247
328
|
/**
|
|
248
329
|
* Does this rule apply to this message?
|
|
249
330
|
*
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the reader says about a signed or encrypted (S/MIME) message.
|
|
3
|
+
*
|
|
4
|
+
* Pure: `security` is the `/messages/:uid` answer's field (readRoutes.js
|
|
5
|
+
* `publicSecurity`), and the result is plain text for the strip above the
|
|
6
|
+
* message. The wording separates the three things a signature can mean -
|
|
7
|
+
* the message is unchanged, the signer is who a trusted authority says, and
|
|
8
|
+
* the signer is the sender shown - because "signed" alone promises all three
|
|
9
|
+
* and a self-made certificate gives only the first.
|
|
10
|
+
*
|
|
11
|
+
* @module _lib/admin/mail/security-note
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {object|null} security
|
|
16
|
+
* @returns {{tone: 'ok'|'warn'|'bad', icon: string, text: string, detail: string}|null}
|
|
17
|
+
*/
|
|
18
|
+
export function securityNote(security) {
|
|
19
|
+
if (!security || (!security.signed && !security.encrypted)) return null;
|
|
20
|
+
|
|
21
|
+
if (security.encrypted && !security.decrypted) {
|
|
22
|
+
return {
|
|
23
|
+
tone: 'bad', icon: 'lock',
|
|
24
|
+
text: 'Encrypted - this message could not be opened.',
|
|
25
|
+
detail: security.problem || 'None of your certificates can open it. Add the certificate it was encrypted to in Settings > Security.'
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const parts = [];
|
|
30
|
+
if (security.encrypted) parts.push('Encrypted');
|
|
31
|
+
let tone = 'ok';
|
|
32
|
+
let detail = '';
|
|
33
|
+
|
|
34
|
+
if (security.signed) {
|
|
35
|
+
const who = security.signer?.emails?.[0] || security.signer?.subject || 'an unknown signer';
|
|
36
|
+
if (!security.verified) {
|
|
37
|
+
tone = 'bad';
|
|
38
|
+
parts.push('the signature is NOT valid');
|
|
39
|
+
detail = security.problem || 'The message may have been changed after it was signed.';
|
|
40
|
+
} else if (!security.trusted) {
|
|
41
|
+
tone = 'warn';
|
|
42
|
+
parts.push(`signed by ${who}, whose certificate is not from an authority this server trusts`);
|
|
43
|
+
detail = 'The message is unchanged since it was signed, but anyone can make a certificate with any address in it. '
|
|
44
|
+
+ 'Check the fingerprint with the sender if it matters.';
|
|
45
|
+
} else if (!security.fromMatches) {
|
|
46
|
+
tone = 'warn';
|
|
47
|
+
parts.push(`signed by ${who}, which is not the sender shown`);
|
|
48
|
+
detail = 'The signature is valid, but it belongs to a different address than the From line.';
|
|
49
|
+
} else {
|
|
50
|
+
parts.push(`signed by ${who}`);
|
|
51
|
+
detail = 'The signature is valid and the message is unchanged since it was signed.';
|
|
52
|
+
}
|
|
53
|
+
if (security.verified && security.problem && tone === 'ok') detail = security.problem;
|
|
54
|
+
if (security.signer?.fingerprint) {
|
|
55
|
+
detail += ` Certificate fingerprint (SHA-256): ${security.signer.fingerprint}.`;
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
detail = 'Only you and the other recipients can read it.';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const text = parts.join(', ');
|
|
62
|
+
return {
|
|
63
|
+
tone,
|
|
64
|
+
icon: tone !== 'ok' ? 'shield-alert' : (security.encrypted ? 'lock' : 'shield-check'),
|
|
65
|
+
text: `${text.charAt(0).toUpperCase()}${text.slice(1)}.`,
|
|
66
|
+
detail: detail.trim()
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
/** Browser preferences for the shared mail reader. */
|
|
2
2
|
export const READ_DELAY_KEY = 'domma-mail.mark-read-seconds';
|
|
3
|
+
/** Compose preferences - this browser's, like the rest of this tab. */
|
|
4
|
+
export const EDITOR_KEY = 'domma-mail.compose-editor';
|
|
5
|
+
export const AUTOSAVE_KEY = 'domma-mail.autosave-minutes';
|
|
6
|
+
export const SPELLCHECK_KEY = 'domma-mail.spellcheck';
|
|
7
|
+
/** The choices for saving a draft automatically, in minutes; 0 is never. */
|
|
8
|
+
export const AUTOSAVE_CHOICES = [0, 1, 2, 5, 10];
|
|
3
9
|
export const DEFAULT_READ_DELAY_SECONDS = 1;
|
|
4
10
|
export const PAGE_SIZE_KEY = 'domma-mail.page-size';
|
|
5
11
|
/** The choices offered under the list. 200 is the server's maxListLimit. */
|
|
@@ -50,39 +56,220 @@ export function scheduleMarkRead(markRead, seconds, schedule = setTimeout) {
|
|
|
50
56
|
return schedule(markRead, delay * 1000);
|
|
51
57
|
}
|
|
52
58
|
|
|
53
|
-
|
|
59
|
+
/**
|
|
60
|
+
* The editor a new message opens in: 'rich' (formatting, sent as HTML with a
|
|
61
|
+
* plain-text part) unless this browser chose 'plain'.
|
|
62
|
+
*
|
|
63
|
+
* @param {*} value
|
|
64
|
+
* @returns {'rich'|'plain'}
|
|
65
|
+
*/
|
|
66
|
+
export function composeEditor(value) {
|
|
67
|
+
return value === 'plain' ? 'plain' : 'rich';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Minutes between automatic draft saves; 0 (never) for anything not offered.
|
|
72
|
+
*
|
|
73
|
+
* @param {*} value
|
|
74
|
+
* @returns {number}
|
|
75
|
+
*/
|
|
76
|
+
export function autosaveMinutes(value) {
|
|
77
|
+
const minutes = Number(value);
|
|
78
|
+
return AUTOSAVE_CHOICES.includes(minutes) ? minutes : 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Whether the browser checks spelling as you type. On unless switched off,
|
|
83
|
+
* which is the browser's own default for a text field.
|
|
84
|
+
*
|
|
85
|
+
* @param {*} value
|
|
86
|
+
* @returns {boolean}
|
|
87
|
+
*/
|
|
88
|
+
export function spellcheckOn(value) {
|
|
89
|
+
return value !== false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* This browser's compose preferences, every key present.
|
|
94
|
+
*
|
|
95
|
+
* @param {{get: Function}} store - Domma's S
|
|
96
|
+
* @returns {{editor: 'rich'|'plain', autosaveMinutes: number, spellcheck: boolean}}
|
|
97
|
+
*/
|
|
98
|
+
export function readComposePrefs(store) {
|
|
99
|
+
const read = key => { try { return store?.get(key); } catch { return undefined; } };
|
|
100
|
+
return {
|
|
101
|
+
editor: composeEditor(read(EDITOR_KEY)),
|
|
102
|
+
autosaveMinutes: autosaveMinutes(read(AUTOSAVE_KEY)),
|
|
103
|
+
spellcheck: spellcheckOn(read(SPELLCHECK_KEY))
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The browser's own answer to "may this site show notifications?", in words.
|
|
109
|
+
*
|
|
110
|
+
* @param {string|undefined} permission - Notification.permission
|
|
111
|
+
* @returns {string}
|
|
112
|
+
*/
|
|
113
|
+
export function permissionWords(permission) {
|
|
114
|
+
if (permission === 'granted') return 'This browser allows notifications from this site.';
|
|
115
|
+
if (permission === 'denied') return 'This browser blocks notifications from this site. Allow them in the browser\u2019s site settings (the padlock by the address).';
|
|
116
|
+
if (permission === 'default') return 'This browser has not been asked yet - turning popups on asks it.';
|
|
117
|
+
return 'This browser cannot show notifications.';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The Settings tab: preferences kept in this browser.
|
|
122
|
+
*
|
|
123
|
+
* @param {{canWrite: boolean, canSend?: boolean, load: Function, save: Function,
|
|
124
|
+
* sidebarUrl?: string|null, userId?: string|null}} options
|
|
125
|
+
* `sidebarUrl` is this edition's sidebar entry, which is what new-mail
|
|
126
|
+
* notifications hang off (admin/js/lib/sidebar-notify.js).
|
|
127
|
+
* @returns {object}
|
|
128
|
+
*/
|
|
129
|
+
export function createSettingsSection({canWrite, canSend = false, load, save, sidebarUrl = null, userId = null}) {
|
|
54
130
|
return {
|
|
55
131
|
id: 'settings',
|
|
56
132
|
label: 'Settings',
|
|
57
|
-
help: 'Preferences for reading email in this browser.',
|
|
133
|
+
help: 'Preferences for reading and writing email in this browser.',
|
|
58
134
|
async render(container, ctx) {
|
|
59
135
|
const kit = await import(new URL(`./section-kit.js?v=${ctx.assetVersion ?? '1'}`, import.meta.url).href);
|
|
136
|
+
// Optional: a core older than the notifications leaves that part out.
|
|
137
|
+
const notify = sidebarUrl
|
|
138
|
+
? await import('/admin/js/lib/sidebar-notify.js').catch(() => null)
|
|
139
|
+
: null;
|
|
140
|
+
// The same user id the sidebar keys its notifications by.
|
|
141
|
+
const who = userId ?? (await import('/admin/js/api.js').catch(() => null))?.getUser?.()?.id ?? null;
|
|
60
142
|
kit.note(container, 'Settings are saved in this browser and apply to all your mailboxes.');
|
|
143
|
+
|
|
144
|
+
const saveSteps = [];
|
|
145
|
+
|
|
146
|
+
// ---- Reading -------------------------------------------------
|
|
147
|
+
kit.heading(container, 'Reading');
|
|
61
148
|
if (!canWrite) {
|
|
62
149
|
kit.note(container, 'This reader leaves messages unread. Automatic marking as read requires an edition that can update messages.');
|
|
63
|
-
|
|
150
|
+
} else {
|
|
151
|
+
const {state} = kit.formIn(container, {
|
|
152
|
+
markReadSeconds: {
|
|
153
|
+
type: 'number',
|
|
154
|
+
label: 'How long before a message is marked as read? (seconds)',
|
|
155
|
+
required: true,
|
|
156
|
+
min: 0,
|
|
157
|
+
max: 2147483,
|
|
158
|
+
formConfig: {hint: '0 marks a message as read immediately. Otherwise, it must stay open for this long.'}
|
|
159
|
+
}
|
|
160
|
+
}, {markReadSeconds: load()});
|
|
161
|
+
saveSteps.push(() => {
|
|
162
|
+
const value = state.markReadSeconds;
|
|
163
|
+
const seconds = Number(value);
|
|
164
|
+
if (value === '' || value === null || value === undefined || !Number.isFinite(seconds) || seconds < 0 || seconds > 2147483) {
|
|
165
|
+
throw new Error('Enter a number of seconds between 0 and 2147483.');
|
|
166
|
+
}
|
|
167
|
+
return () => save(seconds);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---- Composing -----------------------------------------------
|
|
172
|
+
if (canSend) {
|
|
173
|
+
kit.heading(container, 'Composing');
|
|
174
|
+
const prefs = readComposePrefs(S);
|
|
175
|
+
const {state} = kit.formIn(container, {
|
|
176
|
+
editor: {
|
|
177
|
+
type: 'select', label: 'Default text editor', required: true,
|
|
178
|
+
options: [
|
|
179
|
+
{value: 'rich', label: 'Rich text (formatting, pictures, links)'},
|
|
180
|
+
{value: 'plain', label: 'Plain text'}
|
|
181
|
+
],
|
|
182
|
+
formConfig: {hint: 'What a new message, reply or forward opens in. Plain text sends text only - what every mail program shows the same way.'}
|
|
183
|
+
},
|
|
184
|
+
autosave: {
|
|
185
|
+
type: 'select', label: 'Automatically save draft', required: true,
|
|
186
|
+
// Strings: Forma reads a 0 as "no value" and shows its
|
|
187
|
+
// "-- Select --" placeholder instead of Never.
|
|
188
|
+
options: AUTOSAVE_CHOICES.map(n => ({
|
|
189
|
+
value: String(n), label: n === 0 ? 'Never' : `Every ${n} minute${n === 1 ? '' : 's'}`
|
|
190
|
+
})),
|
|
191
|
+
formConfig: {hint: 'Saved to the Drafts folder while you write, so a closed tab or a crash loses little. A message you then discard takes its automatic draft with it.'}
|
|
192
|
+
},
|
|
193
|
+
spellcheck: {type: 'boolean', label: 'Check spelling as I type'}
|
|
194
|
+
}, {editor: prefs.editor, autosave: String(prefs.autosaveMinutes), spellcheck: prefs.spellcheck});
|
|
195
|
+
saveSteps.push(() => () => {
|
|
196
|
+
S.set(EDITOR_KEY, composeEditor(state.editor));
|
|
197
|
+
S.set(AUTOSAVE_KEY, autosaveMinutes(state.autosave));
|
|
198
|
+
S.set(SPELLCHECK_KEY, state.spellcheck !== false);
|
|
199
|
+
});
|
|
200
|
+
kit.note(container, 'Receipts, signing, encryption and where replies are filed belong to each mailbox: see the Composing and Security tabs.');
|
|
64
201
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
202
|
+
|
|
203
|
+
// ---- Notifications -------------------------------------------
|
|
204
|
+
if (notify) {
|
|
205
|
+
kit.heading(container, 'Notifications');
|
|
206
|
+
kit.note(container, 'While the admin is open in any tab, new mail in the inbox can pop up a notification and play a sound - checked about once a minute.');
|
|
207
|
+
const current = notify.readNotifyPrefs(who, sidebarUrl);
|
|
208
|
+
const supported = typeof Notification === 'function';
|
|
209
|
+
const {state} = kit.formIn(container, {
|
|
210
|
+
popup: {type: 'boolean', label: 'Show new messages notification popups', formConfig: {disabled: !supported}},
|
|
211
|
+
sound: {type: 'boolean', label: 'Sound notification'}
|
|
212
|
+
}, {popup: current.popup && supported, sound: current.sound});
|
|
213
|
+
const status = kit.note(container, permissionWords(supported ? Notification.permission : undefined));
|
|
214
|
+
const tools = kit.box(container, 'flex gap-2 items-center mb-2');
|
|
215
|
+
kit.button(tools, 'Play the sound', () => notify.playChime(), {icon: 'play'});
|
|
216
|
+
if (supported) {
|
|
217
|
+
kit.button(tools, 'Show a test notification', async () => {
|
|
218
|
+
if (Notification.permission === 'default') await Notification.requestPermission();
|
|
219
|
+
status.textContent = permissionWords(Notification.permission);
|
|
220
|
+
if (Notification.permission === 'granted') {
|
|
221
|
+
new Notification('New mail', {body: 'This is what a new message looks like.', tag: `dm-notify:${sidebarUrl}`});
|
|
222
|
+
}
|
|
223
|
+
}, {icon: 'bell'});
|
|
73
224
|
}
|
|
74
|
-
|
|
225
|
+
saveSteps.push(() => async () => {
|
|
226
|
+
let popup = state.popup === true && supported;
|
|
227
|
+
// Asked here, on the Save click: browsers only let a page
|
|
228
|
+
// ask in answer to something the person did.
|
|
229
|
+
if (popup && Notification.permission === 'default') {
|
|
230
|
+
await Notification.requestPermission();
|
|
231
|
+
status.textContent = permissionWords(Notification.permission);
|
|
232
|
+
}
|
|
233
|
+
if (popup && Notification.permission === 'denied') popup = false;
|
|
234
|
+
notify.writeNotifyPrefs(who, sidebarUrl, {popup, sound: state.sound === true});
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---- Email links ---------------------------------------------
|
|
239
|
+
if (canSend && typeof navigator?.registerProtocolHandler === 'function') {
|
|
240
|
+
kit.heading(container, 'Email links');
|
|
241
|
+
kit.note(container, 'Make this browser open mailto: links - on any website - in a new message here, instead of in a mail program on this computer. The browser asks you to confirm. To undo it, use the browser\u2019s settings (in Chrome: Settings > Privacy and security > Site settings > Protocol handlers).');
|
|
242
|
+
const linkRow = kit.box(container, 'flex gap-2 items-center mb-2');
|
|
243
|
+
const linkStatus = kit.box(linkRow, 'flex-1 text-sm mail-status text-muted', 'span');
|
|
244
|
+
kit.button(linkRow, 'Register as the mailto: links handler', async () => {
|
|
245
|
+
try {
|
|
246
|
+
const mail = await import('/admin/js/lib/mail-compose.js');
|
|
247
|
+
navigator.registerProtocolHandler('mailto', mail.mailtoHandlerUrl(`${location.origin}${location.pathname}`));
|
|
248
|
+
linkStatus.textContent = 'Asked. If the browser showed a question, answer it to finish.';
|
|
249
|
+
} catch (err) {
|
|
250
|
+
linkStatus.textContent = /secure|https/i.test(err?.message ?? '')
|
|
251
|
+
? 'Only a site served over https can handle mailto: links.'
|
|
252
|
+
: (err?.message || 'This browser would not register it.');
|
|
253
|
+
}
|
|
254
|
+
}, {icon: 'mail'});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (!saveSteps.length) return;
|
|
75
258
|
const bar = kit.actionBar(container);
|
|
76
|
-
kit.button(bar.row, 'Save', () => {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
259
|
+
kit.button(bar.row, 'Save', async () => {
|
|
260
|
+
// Every value checked before anything is stored: a bad number
|
|
261
|
+
// must not leave half the tab saved.
|
|
262
|
+
let apply;
|
|
263
|
+
try {
|
|
264
|
+
apply = saveSteps.map(step => step());
|
|
265
|
+
} catch (err) {
|
|
266
|
+
bar.say(err.message, 'bad');
|
|
81
267
|
return;
|
|
82
268
|
}
|
|
83
269
|
try {
|
|
84
|
-
|
|
270
|
+
for (const run of apply) await run();
|
|
85
271
|
bar.say('Saved.', 'ok');
|
|
272
|
+
await ctx.changed?.();
|
|
86
273
|
} catch (err) {
|
|
87
274
|
bar.say(err.message || 'Settings could not be saved.', 'bad');
|
|
88
275
|
}
|