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.
@@ -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()) return;
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
- function buildSettings(made = () => {}) {
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
- panels[0].drawn = true;
1135
- draw(panels[0].section, panels[0].panel);
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
- export function createSettingsSection({canWrite, load, save}) {
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
- return;
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
- const {state} = kit.formIn(container, {
66
- markReadSeconds: {
67
- type: 'number',
68
- label: 'How long before a message is marked as read? (seconds)',
69
- required: true,
70
- min: 0,
71
- max: 2147483,
72
- formConfig: {hint: '0 marks a message as read immediately. Otherwise, it must stay open for this long.'}
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
- }, {markReadSeconds: load()});
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
- const value = state.markReadSeconds;
78
- const seconds = Number(value);
79
- if (value === '' || value === null || value === undefined || !Number.isFinite(seconds) || seconds < 0 || seconds > 2147483) {
80
- bar.say('Enter a number of seconds between 0 and 2147483.', 'bad');
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
- save(seconds);
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
  }