domma-cms 1.10.37 → 1.10.38

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,8 @@ 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
+ // Optional for the same reason as oauth-panel.js below.
54
+ const {securityNote} = await import(versioned('security-note.js')).catch(() => ({securityNote: () => null}));
53
55
  // Sign in with Microsoft / Google (Email Pro). Optional: an older copy of this
54
56
  // directory without it must not take the reader down.
55
57
  const {describeSignIn, mountSignIn} = await import(versioned('oauth-panel.js')).catch(() => ({}));
@@ -958,6 +960,8 @@ return {
958
960
  const sections = [
959
961
  createSettingsSection({
960
962
  canWrite: features.write,
963
+ canSend: features.send === true,
964
+ sidebarUrl: sidebarUrl(pluginName),
961
965
  load: () => readDelaySeconds(S.get(READ_DELAY_KEY)),
962
966
  save: seconds => {
963
967
  S.set(READ_DELAY_KEY, seconds);
@@ -3192,6 +3196,29 @@ return {
3192
3196
 
3193
3197
  readerBodyEl.textContent = '';
3194
3198
 
3199
+ // Signed or encrypted: said first, above the images notice, since
3200
+ // it is about whether to believe the rest of the pane at all.
3201
+ const security = securityNote(message.security);
3202
+ if (security) {
3203
+ const strip = document.createElement('div');
3204
+ strip.className = `mail-notice mail-security mail-security--${security.tone}`;
3205
+ const icon = document.createElement('span');
3206
+ icon.dataset.icon = security.icon;
3207
+ icon.dataset.iconSize = '14';
3208
+ icon.setAttribute('aria-hidden', 'true');
3209
+ const text = document.createElement('span');
3210
+ text.textContent = security.text;
3211
+ strip.append(icon, text);
3212
+ if (security.detail) {
3213
+ const more = document.createElement('span');
3214
+ more.className = 'mail-security-detail';
3215
+ more.textContent = security.detail;
3216
+ strip.appendChild(more);
3217
+ }
3218
+ readerBodyEl.appendChild(strip);
3219
+ Domma.icons.scan(strip);
3220
+ }
3221
+
3195
3222
  if (message.blockedImages > 0 && !message.remoteImagesLoaded) {
3196
3223
  const notice = document.createElement('div');
3197
3224
  notice.className = 'mail-notice';
@@ -3923,7 +3950,11 @@ return {
3923
3950
  identities: identities?.identities ?? [],
3924
3951
  mailboxAddress: identities?.mailboxAddress ?? '',
3925
3952
  templates: templates?.templates ?? [],
3926
- undoSendSeconds: sending?.undoSendSeconds ?? 0
3953
+ undoSendSeconds: sending?.undoSendSeconds ?? 0,
3954
+ // Where this mailbox's ticks start, and whether it can
3955
+ // sign or encrypt at all (a certificate is set up).
3956
+ compose: sending?.compose ?? null,
3957
+ security: sending?.security ?? null
3927
3958
  })).catch(() => EMPTY_PROFILE);
3928
3959
  }
3929
3960
  return profileRequest;
@@ -3983,6 +4014,11 @@ return {
3983
4014
  mailboxAddress: profile?.mailboxAddress ?? '',
3984
4015
  templates: profile?.templates ?? [],
3985
4016
  undoSeconds: profile?.undoSendSeconds ?? 0,
4017
+ defaults: profile?.compose ?? null,
4018
+ security: profile?.security ?? null,
4019
+ // Read at each opening, so a change in Settings applies to
4020
+ // the next message without a reload.
4021
+ prefs: readComposePrefs(S),
3986
4022
  onSent: () => loadMessages({quiet: true}),
3987
4023
  onQueued: (result) => offerRecall(result),
3988
4024
  ...extra
@@ -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
  }
@@ -725,6 +725,65 @@ export async function setSendingOptions(userId, accountId, options) {
725
725
  });
726
726
  }
727
727
 
728
+ /**
729
+ * How a new message from this mailbox starts out, and two rules about where
730
+ * and how it leaves.
731
+ *
732
+ * All off by default, which is what every message sent before these existed
733
+ * did. The receipts, signing and encryption are only where the compose
734
+ * window's ticks START - each message can still change them. The other two
735
+ * are the mailbox's rules and apply to every message:
736
+ * `repliesInSourceFolder` files the copy of a reply beside the message it
737
+ * answers instead of in Sent, and `requireSecureDelivery` refuses to send
738
+ * unless the outgoing server promises TLS all the way (REQUIRETLS, RFC 8689).
739
+ */
740
+ export const COMPOSE_DEFAULTS = Object.freeze({
741
+ readReceipt: false,
742
+ deliveryReceipt: false,
743
+ sign: false,
744
+ encrypt: false,
745
+ repliesInSourceFolder: false,
746
+ requireSecureDelivery: false
747
+ });
748
+
749
+ /**
750
+ * This mailbox's compose options, every key present.
751
+ *
752
+ * @param {object|null} account
753
+ * @returns {typeof COMPOSE_DEFAULTS}
754
+ */
755
+ export function composeOptions(account) {
756
+ const stored = account?.compose && typeof account.compose === 'object' ? account.compose : {};
757
+ return Object.fromEntries(Object.keys(COMPOSE_DEFAULTS)
758
+ .map(key => [key, stored[key] === true]));
759
+ }
760
+
761
+ /**
762
+ * Store the compose options. Only the known switches, only as booleans; a key
763
+ * left out keeps its stored value, so a screen that knows half of them cannot
764
+ * switch the other half off.
765
+ *
766
+ * @param {string} userId
767
+ * @param {string} accountId
768
+ * @param {object} options
769
+ * @returns {Promise<object|null>}
770
+ */
771
+ export async function setComposeOptions(userId, accountId, options) {
772
+ return withStoreLock(async () => {
773
+ const all = await readAll();
774
+ const index = all.findIndex(a => a.id === accountId && a.userId === userId);
775
+ if (index === -1) return null;
776
+
777
+ const next = composeOptions(all[index]);
778
+ for (const key of Object.keys(COMPOSE_DEFAULTS)) {
779
+ if (typeof options?.[key] === 'boolean') next[key] = options[key];
780
+ }
781
+ all[index] = {...all[index], compose: next, updatedAt: new Date().toISOString()};
782
+ await writeAll(all);
783
+ return all[index];
784
+ });
785
+ }
786
+
728
787
  /**
729
788
  * Every address this mailbox sends as.
730
789
  *
@@ -10,7 +10,7 @@
10
10
  * @module _lib/mail/compose
11
11
  */
12
12
  import {readPriority} from './priority.js';
13
- import {normaliseMessageSource, readMessageSource} from './message-action.js';
13
+ import {normaliseMessageSource, readComposeFlags, readMessageSource} from './message-action.js';
14
14
 
15
15
  /**
16
16
  * Addresses to reply to.
@@ -237,6 +237,10 @@ export function editableDraft(message, uid = null) {
237
237
  // Kept, or a draft marked urgent would quietly become ordinary the
238
238
  // moment it was reopened.
239
239
  priority: readPriority(message.headers),
240
+ // Both kept for the same reason: a draft that asked for a receipt, or
241
+ // to be signed or encrypted, must not quietly stop asking on reopen.
242
+ readReceipt: hasHeader(message.headers, 'disposition-notification-to'),
243
+ ...readComposeFlags(message.headers),
240
244
  attachments: (message.attachments ?? []).map(file => ({
241
245
  filename: file.filename ?? 'attachment',
242
246
  contentType: file.contentType ?? 'application/octet-stream',
@@ -248,6 +252,16 @@ export function editableDraft(message, uid = null) {
248
252
  };
249
253
  }
250
254
 
255
+ /**
256
+ * @param {Map|object|undefined} headers - mailparser's headers
257
+ * @param {string} name - lower case
258
+ * @returns {boolean}
259
+ */
260
+ function hasHeader(headers, name) {
261
+ const value = typeof headers?.get === 'function' ? headers.get(name) : headers?.[name];
262
+ return value !== undefined && value !== null && value !== '';
263
+ }
264
+
251
265
  /**
252
266
  * References arrive as a string on one server and an array on the next.
253
267
  *
@@ -22,6 +22,38 @@ export function readMessageSource(headers) {
22
22
  } catch { return null; }
23
23
  }
24
24
 
25
+ /**
26
+ * The compose ticks a saved draft carries - delivery receipt, sign, encrypt -
27
+ * none of which leaves a trace a reopened draft could read back otherwise
28
+ * (the read receipt does: its own header). Private, like SOURCE_HEADER: only
29
+ * ever written into a draft, never onto a message that is sent.
30
+ */
31
+ export const COMPOSE_HEADER = 'X-Domma-Compose';
32
+ const COMPOSE_FLAGS = ['deliveryReceipt', 'sign', 'encrypt'];
33
+
34
+ /**
35
+ * @param {object} draft
36
+ * @returns {string|null} the header value, or null when nothing is ticked
37
+ */
38
+ export function composeFlagsHeader(draft) {
39
+ const on = COMPOSE_FLAGS.filter(key => draft?.[key] === true);
40
+ return on.length ? Buffer.from(JSON.stringify(Object.fromEntries(on.map(k => [k, true])))).toString('base64url') : null;
41
+ }
42
+
43
+ /**
44
+ * @param {Map|object} headers - mailparser's headers
45
+ * @returns {{deliveryReceipt: boolean, sign: boolean, encrypt: boolean}}
46
+ */
47
+ export function readComposeFlags(headers) {
48
+ const value = typeof headers?.get === 'function'
49
+ ? headers.get(COMPOSE_HEADER.toLowerCase()) : headers?.[COMPOSE_HEADER.toLowerCase()];
50
+ let parsed = {};
51
+ if (typeof value === 'string' && value.length <= 512) {
52
+ try { parsed = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) ?? {}; } catch { parsed = {}; }
53
+ }
54
+ return Object.fromEntries(COMPOSE_FLAGS.map(key => [key, parsed[key] === true]));
55
+ }
56
+
25
57
  /** Best effort: recording a delivered message must never turn it into a failed send. */
26
58
  export async function recordMessageAction({source, account, pool, poolKey, connection, store = null}) {
27
59
  source = normaliseMessageSource(source);
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Where signing and encryption plug into the mail core.
3
+ *
4
+ * The core builds, sends, files and reads raw messages; it does not know
5
+ * S/MIME. An edition that does (Email Pro) registers a provider here at boot,
6
+ * and every path that sends - the send route, the scheduler behind undo and
7
+ * send-later - or reads a message body goes through it. A registry rather
8
+ * than an option threaded through each caller, because the scheduler is core
9
+ * code started by the edition and would otherwise need the provider passed
10
+ * down three layers to reach the one call that uses it.
11
+ *
12
+ * The provider:
13
+ * protect({raw, draft, account, userId}) -> Promise<Buffer>
14
+ * the message as it should leave and be filed; called only when the
15
+ * draft asks for `sign` or `encrypt`. Throws a plain Error to refuse.
16
+ * protectDraft({raw, draft, account, userId}) -> Promise<Buffer> (optional)
17
+ * a draft of a message to be encrypted, encrypted to the sender alone.
18
+ * unwrap({raw, account, userId}) -> Promise<{raw: Buffer, security: object|null}>
19
+ * the message as it should be read; never throws for a bad message,
20
+ * it says so in `security.problem` and hands the original back.
21
+ *
22
+ * @module _lib/mail/messageSecurity
23
+ */
24
+ import {simpleParser} from 'mailparser';
25
+
26
+ let provider = null;
27
+
28
+ /**
29
+ * Register (or, with null, remove) the provider. One at a time: the editions
30
+ * never run together.
31
+ *
32
+ * @param {{protect: Function, unwrap: Function}|null} next
33
+ * @returns {void}
34
+ */
35
+ export function setMessageSecurity(next) {
36
+ provider = next && typeof next.protect === 'function' && typeof next.unwrap === 'function' ? next : null;
37
+ }
38
+
39
+ /** @returns {boolean} whether a message can be signed or encrypted here */
40
+ export function canProtect() {
41
+ return provider !== null;
42
+ }
43
+
44
+ /**
45
+ * The message as it should leave. A draft that asks for neither comes back as
46
+ * it was; one that asks with no provider registered is refused, because
47
+ * sending it unprotected is the one outcome the sender ruled out.
48
+ *
49
+ * @param {{raw: Buffer, draft: object, account?: object|null, userId?: string|null}} input
50
+ * @returns {Promise<Buffer>}
51
+ */
52
+ export async function protectRaw(input) {
53
+ const wanted = input.draft?.sign === true || input.draft?.encrypt === true;
54
+ if (!wanted) return input.raw;
55
+ if (!provider) {
56
+ throw new Error('Signing and encryption need Email Pro with a certificate set up. The message was not sent.');
57
+ }
58
+ return provider.protect(input);
59
+ }
60
+
61
+ /**
62
+ * A draft as it should be stored: encrypted to the sender alone when the
63
+ * message is to be encrypted and the provider can (`protectDraft`), so the
64
+ * Drafts folder - which every client syncs - does not hold the plain text of
65
+ * a message meant to be secret. Otherwise, and on any failure, as it was: a
66
+ * draft that cannot be saved is worse, and the send itself still refuses to
67
+ * go out unencrypted.
68
+ *
69
+ * @param {{raw: Buffer, draft: object, account?: object|null, userId?: string|null}} input
70
+ * @returns {Promise<Buffer>}
71
+ */
72
+ export async function protectDraftRaw(input) {
73
+ if (input.draft?.encrypt !== true || typeof provider?.protectDraft !== 'function') return input.raw;
74
+ try {
75
+ return (await provider.protectDraft(input)) ?? input.raw;
76
+ } catch {
77
+ return input.raw;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * The message as it should be read.
83
+ *
84
+ * @param {{raw: Buffer, account?: object|null, userId?: string|null}} input
85
+ * @returns {Promise<{raw: Buffer, security: object|null}>}
86
+ */
87
+ export async function unwrapRaw(input) {
88
+ if (!provider) return {raw: input.raw, security: null};
89
+ try {
90
+ const result = await provider.unwrap(input);
91
+ return result?.raw ? result : {raw: input.raw, security: null};
92
+ } catch {
93
+ // A provider fault must never cost the reader the message itself.
94
+ return {raw: input.raw, security: null};
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Parse a message as it should be read: unwrapped first, then mailparser.
100
+ * Every route that shows, quotes or hands off a message's content goes
101
+ * through this, so an encrypted message reads the same in the pane, in a
102
+ * reply's quote and in its attachments.
103
+ *
104
+ * @param {Buffer} raw
105
+ * @param {{account?: object|null, userId?: string|null}} [context]
106
+ * @returns {Promise<{parsed: object, security: object|null}>}
107
+ */
108
+ export async function parseReadable(raw, {account = null, userId = null} = {}) {
109
+ const {raw: readable, security} = await unwrapRaw({raw, account, userId});
110
+ return {parsed: await simpleParser(readable), security};
111
+ }