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.
@@ -30,7 +30,7 @@ import path from 'node:path';
30
30
  import {fileURLToPath} from 'node:url';
31
31
 
32
32
  import {normaliseIdentity, normaliseIdentityList, findIdentity} from '../admin/mail/identity.js';
33
- import {normaliseRules} from '../admin/mail/rules.js';
33
+ import {normaliseRuleGroups, normaliseRules, rulesInEffect} from '../admin/mail/rules.js';
34
34
  import {normaliseUndoSeconds} from '../admin/mail/scheduling.js';
35
35
  import {normaliseVacation} from '../admin/mail/vacation.js';
36
36
  import {normaliseTemplates} from '../admin/mail/templates.js';
@@ -639,6 +639,26 @@ export function rulesToPublic(account) {
639
639
  return normaliseRules(account);
640
640
  }
641
641
 
642
+ /**
643
+ * The groups this mailbox's rules are sorted into, in order.
644
+ *
645
+ * @param {object|null} account
646
+ * @returns {{name: string, enabled: boolean}[]}
647
+ */
648
+ export function ruleGroupsToPublic(account) {
649
+ return normaliseRuleGroups({ruleGroups: account?.ruleGroups, rules: normaliseRules(account)});
650
+ }
651
+
652
+ /**
653
+ * The rules a sync pass runs: switched on, and not in a switched-off group.
654
+ *
655
+ * @param {object|null} account
656
+ * @returns {object[]}
657
+ */
658
+ export function rulesToRun(account) {
659
+ return rulesInEffect(normaliseRules(account), ruleGroupsToPublic(account));
660
+ }
661
+
642
662
  /**
643
663
  * Store the filing rules for a mailbox.
644
664
  *
@@ -653,15 +673,19 @@ export function rulesToPublic(account) {
653
673
  * @param {object[]} rules
654
674
  * @returns {Promise<object|null>}
655
675
  */
656
- export async function setRules(userId, accountId, rules) {
676
+ export async function setRules(userId, accountId, rules, groups = undefined) {
657
677
  return withStoreLock(async () => {
658
678
  const all = await readAll();
659
679
  const index = all.findIndex(a => a.id === accountId && a.userId === userId);
660
680
  if (index === -1) return null;
661
681
 
682
+ const saved = normaliseRules({rules});
662
683
  all[index] = {
663
684
  ...all[index],
664
- rules: normaliseRules({rules}),
685
+ rules: saved,
686
+ // Only when given: a caller that knows nothing of groups (an older
687
+ // screen) keeps the stored ones.
688
+ ...(Array.isArray(groups) ? {ruleGroups: normaliseRuleGroups({ruleGroups: groups, rules: saved})} : {}),
665
689
  rulesFrom: all[index].rulesFrom ?? new Date().toISOString(),
666
690
  updatedAt: new Date().toISOString()
667
691
  };
@@ -725,6 +749,65 @@ export async function setSendingOptions(userId, accountId, options) {
725
749
  });
726
750
  }
727
751
 
752
+ /**
753
+ * How a new message from this mailbox starts out, and two rules about where
754
+ * and how it leaves.
755
+ *
756
+ * All off by default, which is what every message sent before these existed
757
+ * did. The receipts, signing and encryption are only where the compose
758
+ * window's ticks START - each message can still change them. The other two
759
+ * are the mailbox's rules and apply to every message:
760
+ * `repliesInSourceFolder` files the copy of a reply beside the message it
761
+ * answers instead of in Sent, and `requireSecureDelivery` refuses to send
762
+ * unless the outgoing server promises TLS all the way (REQUIRETLS, RFC 8689).
763
+ */
764
+ export const COMPOSE_DEFAULTS = Object.freeze({
765
+ readReceipt: false,
766
+ deliveryReceipt: false,
767
+ sign: false,
768
+ encrypt: false,
769
+ repliesInSourceFolder: false,
770
+ requireSecureDelivery: false
771
+ });
772
+
773
+ /**
774
+ * This mailbox's compose options, every key present.
775
+ *
776
+ * @param {object|null} account
777
+ * @returns {typeof COMPOSE_DEFAULTS}
778
+ */
779
+ export function composeOptions(account) {
780
+ const stored = account?.compose && typeof account.compose === 'object' ? account.compose : {};
781
+ return Object.fromEntries(Object.keys(COMPOSE_DEFAULTS)
782
+ .map(key => [key, stored[key] === true]));
783
+ }
784
+
785
+ /**
786
+ * Store the compose options. Only the known switches, only as booleans; a key
787
+ * left out keeps its stored value, so a screen that knows half of them cannot
788
+ * switch the other half off.
789
+ *
790
+ * @param {string} userId
791
+ * @param {string} accountId
792
+ * @param {object} options
793
+ * @returns {Promise<object|null>}
794
+ */
795
+ export async function setComposeOptions(userId, accountId, options) {
796
+ return withStoreLock(async () => {
797
+ const all = await readAll();
798
+ const index = all.findIndex(a => a.id === accountId && a.userId === userId);
799
+ if (index === -1) return null;
800
+
801
+ const next = composeOptions(all[index]);
802
+ for (const key of Object.keys(COMPOSE_DEFAULTS)) {
803
+ if (typeof options?.[key] === 'boolean') next[key] = options[key];
804
+ }
805
+ all[index] = {...all[index], compose: next, updatedAt: new Date().toISOString()};
806
+ await writeAll(all);
807
+ return all[index];
808
+ });
809
+ }
810
+
728
811
  /**
729
812
  * Every address this mailbox sends as.
730
813
  *
@@ -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
+ }
@@ -25,7 +25,6 @@
25
25
  *
26
26
  * @module _lib/mail/readRoutes
27
27
  */
28
- import {simpleParser} from 'mailparser';
29
28
 
30
29
  import {listUsers} from '../../../server/services/users.js';
31
30
  import {getConfig} from '../../../server/config.js';
@@ -41,6 +40,34 @@ import {listAttachments, MENU_BRIDGE_CSP, renderBody} from './render.js';
41
40
  import {readUnsubscribe} from './unsubscribe.js';
42
41
  import {MailKeyError} from './secretbox.js';
43
42
  import {unreadBadge} from './unreadBadge.js';
43
+ import {parseReadable} from './messageSecurity.js';
44
+
45
+ /**
46
+ * The security facts the reader shows - nothing else of the provider's
47
+ * answer (certificates, chains) goes to the browser.
48
+ *
49
+ * @param {object|null} security
50
+ * @returns {object|null}
51
+ */
52
+ export function publicSecurity(security) {
53
+ if (!security) return null;
54
+ const signer = security.signer ?? null;
55
+ return {
56
+ encrypted: security.encrypted === true,
57
+ decrypted: security.decrypted === true,
58
+ signed: security.signed === true,
59
+ verified: security.verified === true,
60
+ trusted: security.trusted === true,
61
+ fromMatches: security.fromMatches === true,
62
+ signer: signer ? {
63
+ emails: Array.isArray(signer.emails) ? signer.emails.map(String).slice(0, 10) : [],
64
+ subject: String(signer.subject ?? ''),
65
+ fingerprint: String(signer.fingerprint ?? ''),
66
+ notAfter: signer.notAfter ?? null
67
+ } : null,
68
+ problem: security.problem ? String(security.problem) : null
69
+ };
70
+ }
44
71
 
45
72
  /**
46
73
  * Flatten an envelope address list into something the browser can render.
@@ -223,9 +250,9 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
223
250
  if (failed && Date.now() < failed.until) { boxes.push({label: account.label, unseen: null}); continue; }
224
251
  try {
225
252
  const status = await pool.withClient(`${uid}:${account.id}`, accounts.toConnection(account),
226
- client => client.status('INBOX', {unseen: true}));
253
+ client => client.status('INBOX', {unseen: true, uidNext: true}));
227
254
  failures.delete(key);
228
- boxes.push({label: account.label, unseen: status?.unseen ?? 0});
255
+ boxes.push({id: account.id, label: account.label, unseen: status?.unseen ?? 0, uidNext: status?.uidNext ?? null});
229
256
  } catch {
230
257
  const wait = Math.min((failed?.wait || 150_000) * 2, 3_600_000);
231
258
  failures.set(key, {wait, until: Date.now() + wait});
@@ -550,7 +577,7 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
550
577
  return null;
551
578
  }
552
579
 
553
- const parsed = await simpleParser(fetched.source);
580
+ const {parsed, security} = await parseReadable(fetched.source, {account: ctx.account, userId: ctx.uid});
554
581
  const from = addresses(parsed.from?.value);
555
582
  const senderTrusted = accounts.sendersImagesTrusted(ctx.account, from);
556
583
  const allowRemoteImages = requestedNow || config.allowRemoteImages || senderTrusted;
@@ -606,6 +633,9 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
606
633
  // an unsubscribe link is reading; acting on it is a write and
607
634
  // lives in the Pro edition.
608
635
  unsubscribe: readUnsubscribe(parsed.headers),
636
+ // Signed and/or encrypted (S/MIME), and what came of checking -
637
+ // null for an ordinary message, or where no edition can tell.
638
+ security: publicSecurity(security),
609
639
  attachments: listAttachments(parsed.attachments)
610
640
  };
611
641
  }));
@@ -660,7 +690,7 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
660
690
  if (!fetched || !fetched.source) return null;
661
691
  if (fetched.source.length > config.maxMessageBytes) return null;
662
692
 
663
- const parsed = await simpleParser(fetched.source);
693
+ const {parsed} = await parseReadable(fetched.source, {account, userId: grant.userId});
664
694
  return renderBody(parsed, {
665
695
  allowRemoteImages: grant.images,
666
696
  inlineImageMaxBytes: config.inlineImageMaxBytes
@@ -790,7 +820,7 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
790
820
  return null;
791
821
  }
792
822
 
793
- const parsed = await simpleParser(fetched.source);
823
+ const {parsed} = await parseReadable(fetched.source, {account: ctx.account, userId: ctx.uid});
794
824
  const attachment = parsed.attachments?.[index];
795
825
  if (!attachment) {
796
826
  reply.code(404).send({error: 'That attachment is not on this message.'});
@@ -16,7 +16,9 @@ import {bareAddress, formatFrom} from '../admin/mail/identity.js';
16
16
  import {describeError} from './imapPool.js';
17
17
  import {pinMailHost} from './hostGuard.js';
18
18
  import {normalisePriority, priorityHeaders} from './priority.js';
19
- import {normaliseMessageSource, recordMessageAction, SOURCE_HEADER} from './message-action.js';
19
+ import {composeFlagsHeader, COMPOSE_HEADER, normaliseMessageSource, recordMessageAction, SOURCE_HEADER} from './message-action.js';
20
+ import {composeOptions} from './accounts.js';
21
+ import {protectDraftRaw, protectRaw} from './messageSecurity.js';
20
22
 
21
23
  // MailComposer is not on nodemailer's public export, but it is the piece that
22
24
  // turns a message object into the bytes that go on the wire - which is what a
@@ -195,10 +197,12 @@ export async function sendMessage({
195
197
  smtp, draft, pool, poolKey, connection, sentFolder = null, allowInsecureTLS = false,
196
198
  replaces = null, draftsFolder = null, headers = {}, fileCopy = true, account = null, store = null
197
199
  }) {
200
+ const options = composeOptions(account);
201
+
198
202
  // The header carries the display name; the envelope never can. They are
199
203
  // built from the same stored address so they cannot name two different
200
204
  // mailboxes, but they are not the same string.
201
- const raw = await buildRaw(draft, formatFrom(smtp.from, smtp.fromName), {
205
+ const built = await buildRaw(draft, formatFrom(smtp.from, smtp.fromName), {
202
206
  replyTo: smtp.replyTo,
203
207
  // Asked for per message, addressed to wherever replies would go -
204
208
  // which is the Reply-To when there is one and the From otherwise.
@@ -210,6 +214,13 @@ export async function sendMessage({
210
214
  headers
211
215
  });
212
216
 
217
+ // Signed and/or encrypted when the message asks - after it is built and
218
+ // before it is sent, so what is sent and what is filed are one buffer, as
219
+ // ever. Encrypted, the copy in Sent is encrypted too (to the sender as
220
+ // well as the recipients), which is the point: a copy readable by anyone
221
+ // with the mailbox password would undo it.
222
+ const raw = await protectRaw({raw: built, draft, account, userId: account?.userId ?? null});
223
+
213
224
  // The same setting that governs incoming TLS governs outgoing: a site
214
225
  // running its own mail server with a self-signed certificate has the same
215
226
  // problem in both directions, and two switches for one decision is how
@@ -221,6 +232,8 @@ export async function sendMessage({
221
232
  try {
222
233
  transport = nodemailer.createTransport({
223
234
  ...await pinnedTransport(smtp.transport, pool?.mailHostOptions),
235
+ // Secure delivery starts with this hop: STARTTLS or nothing.
236
+ ...(options.requireSecureDelivery ? {requireTLS: true} : {}),
224
237
  tls: {rejectUnauthorized: !allowInsecureTLS}
225
238
  });
226
239
  info = await transport.sendMail({
@@ -232,10 +245,11 @@ export async function sendMessage({
232
245
  to: [...addresses(draft.to), ...addresses(draft.cc), ...addresses(draft.bcc)]
233
246
  .map(a => a.address)
234
247
  },
248
+ ...deliveryOptions(draft, options),
235
249
  raw
236
250
  });
237
251
  } catch (err) {
238
- throw plainSendError(err, smtp.transport);
252
+ throw plainSendError(err, {...smtp.transport, requireTLS: options.requireSecureDelivery});
239
253
  } finally {
240
254
  transport?.close();
241
255
  }
@@ -252,7 +266,11 @@ export async function sendMessage({
252
266
  // is a Sent folder nobody can find anything in - the responder keeps
253
267
  // its own log instead.
254
268
  if (!fileCopy) throw new SkipFiling();
255
- const destination = sentFolder ?? await findSentFolder(pool, poolKey, connection);
269
+ const destination = await filingFolder({
270
+ pool, poolKey, connection, sentFolder,
271
+ source: options.repliesInSourceFolder && account ? draft.sourceMessage : null,
272
+ accountId: account?.id ?? null
273
+ });
256
274
  if (destination) {
257
275
  await pool.withClient(poolKey, connection,
258
276
  client => client.append(destination, raw, ['\\Seen']));
@@ -280,6 +298,66 @@ export async function sendMessage({
280
298
  };
281
299
  }
282
300
 
301
+ /**
302
+ * The SMTP extras a message asks for: a delivery status notification (DSN,
303
+ * RFC 3461) when it wants a delivery receipt, and REQUIRETLS (RFC 8689) when
304
+ * the mailbox requires secure delivery.
305
+ *
306
+ * A delivery receipt is the outgoing server's to give: one that does not offer
307
+ * DSN sends the message without asking, which is the most a client can do.
308
+ * REQUIRETLS is the opposite - asked for, it must be honoured, and a server
309
+ * that cannot is refused before the message leaves (EREQUIRETLS).
310
+ *
311
+ * @param {object} draft
312
+ * @param {{requireSecureDelivery?: boolean}} options
313
+ * @returns {object}
314
+ */
315
+ export function deliveryOptions(draft, options = {}) {
316
+ return {
317
+ // Headers, not the whole message, come back with the report: the
318
+ // sender has the message already, and a full copy doubles the mail.
319
+ ...(draft?.deliveryReceipt === true
320
+ ? {dsn: {ret: 'HDRS', notify: ['SUCCESS', 'FAILURE', 'DELAY']}}
321
+ : {}),
322
+ ...(options.requireSecureDelivery === true ? {requireTLSExtensionEnabled: true} : {})
323
+ };
324
+ }
325
+
326
+ /**
327
+ * Where the copy of a sent message is filed.
328
+ *
329
+ * Sent, normally. A reply goes beside the message it answers when the mailbox
330
+ * says so - the folder it was replied from, so a conversation filed under a
331
+ * project keeps both halves there. Never into Trash, Junk or Drafts (a reply
332
+ * written from the bin is not meant to live in it), nor into a folder that
333
+ * has gone, nor for another mailbox's message: those fall back to Sent.
334
+ *
335
+ * @param {{folders: Array<{path: string, specialUse?: string}>, sentFolder?: string|null,
336
+ * source?: object|null, accountId?: string|null}} input
337
+ * @returns {string|null}
338
+ */
339
+ export function chooseFilingFolder({folders = [], sentFolder = null, source = null, accountId = null}) {
340
+ const sent = sentFolder ?? folders.find(box => box.specialUse === '\\Sent')?.path ?? null;
341
+ const from = normaliseMessageSource(source);
342
+ if (!from || from.action !== 'reply' || !accountId || from.accountId !== accountId) return sent;
343
+ const box = folders.find(entry => entry.path === from.folder);
344
+ if (!box || ['\\Trash', '\\Junk', '\\Drafts'].includes(box.specialUse)) return sent;
345
+ return box.path;
346
+ }
347
+
348
+ /**
349
+ * The folder list, then `chooseFilingFolder`. Skips the listing when there is
350
+ * nothing to choose: Sent is already known and this is not a reply to file
351
+ * elsewhere.
352
+ *
353
+ * @returns {Promise<string|null>}
354
+ */
355
+ async function filingFolder({pool, poolKey, connection, sentFolder, source, accountId}) {
356
+ if (sentFolder && !source) return sentFolder;
357
+ const folders = await pool.withClient(poolKey, connection, client => client.list());
358
+ return chooseFilingFolder({folders, sentFolder, source, accountId});
359
+ }
360
+
283
361
  /**
284
362
  * Take a superseded draft out of the Drafts folder.
285
363
  *
@@ -295,7 +373,7 @@ export async function sendMessage({
295
373
  * draftsFolder?: string|null}} options
296
374
  * @returns {Promise<boolean>}
297
375
  */
298
- async function discardDraft({pool, poolKey, connection, uid, draftsFolder = null}) {
376
+ export async function discardDraft({pool, poolKey, connection, uid, draftsFolder = null}) {
299
377
  try {
300
378
  const destination = draftsFolder
301
379
  ?? await findSpecialFolder(pool, poolKey, connection, '\\Drafts');
@@ -327,16 +405,20 @@ async function discardDraft({pool, poolKey, connection, uid, draftsFolder = null
327
405
  * @returns {Promise<{savedTo: string, uid: number|null, replaced: number|null}>}
328
406
  */
329
407
  export async function saveDraft({
330
- draft, from, replyTo = '', pool, poolKey, connection, draftsFolder = null, replaces = null
408
+ draft, from, replyTo = '', pool, poolKey, connection, draftsFolder = null, replaces = null, account = null
331
409
  }) {
332
410
  // Written into the draft, not applied when it is eventually sent: a draft
333
411
  // opened on the phone should show the same headers it will go out with.
334
412
  const source = normaliseMessageSource(draft.sourceMessage);
413
+ const flags = composeFlagsHeader(draft);
335
414
  const raw = await buildRaw(draft, from, {
336
415
  replyTo,
337
416
  readReceiptTo: draft.readReceipt ? (replyTo || bareAddress(from)) : '',
338
417
  // Private draft metadata: it is omitted from SMTP and the Sent copy.
339
- headers: source ? {[SOURCE_HEADER]: Buffer.from(JSON.stringify(source)).toString('base64url')} : {}
418
+ headers: {
419
+ ...(source ? {[SOURCE_HEADER]: Buffer.from(JSON.stringify(source)).toString('base64url')} : {}),
420
+ ...(flags ? {[COMPOSE_HEADER]: flags} : {})
421
+ }
340
422
  });
341
423
 
342
424
  const destination = draftsFolder ?? await findSpecialFolder(pool, poolKey, connection, '\\Drafts');
@@ -344,8 +426,12 @@ export async function saveDraft({
344
426
  throw new Error('This server has no Drafts folder to save into.');
345
427
  }
346
428
 
429
+ // A message that is to be encrypted is not left readable in Drafts: it
430
+ // is encrypted to the sender alone, and opened again when reopened.
431
+ const stored = await protectDraftRaw({raw, draft, account, userId: account?.userId ?? null});
432
+
347
433
  const appended = await pool.withClient(poolKey, connection,
348
- client => client.append(destination, raw, ['\\Draft', '\\Seen']));
434
+ client => client.append(destination, stored, ['\\Draft', '\\Seen']));
349
435
 
350
436
  // Only now is the previous version expendable.
351
437
  let replaced = null;
@@ -378,21 +464,6 @@ async function findSpecialFolder(pool, poolKey, connection, use) {
378
464
  return boxes.find(box => box.specialUse === use)?.path ?? null;
379
465
  }
380
466
 
381
- /**
382
- * Where this server keeps sent mail.
383
- *
384
- * By SPECIAL-USE, never by name: it is localised on plenty of servers, and
385
- * guessing wrong means the copy lands in a folder nobody looks at.
386
- *
387
- * @param {object} pool
388
- * @param {string} poolKey
389
- * @param {object} connection
390
- * @returns {Promise<string|null>}
391
- */
392
- async function findSentFolder(pool, poolKey, connection) {
393
- return findSpecialFolder(pool, poolKey, connection, '\\Sent');
394
- }
395
-
396
467
  /**
397
468
  * A send failure as plain words. Our own errors (a refused host, a mailbox that
398
469
  * needs signing in again) pass as they are; anything from the server or the
@@ -405,6 +476,17 @@ async function findSentFolder(pool, poolKey, connection) {
405
476
  */
406
477
  export function plainSendError(err, transportConfig = {}) {
407
478
  if (!err || err.needsReconnect || err.mailHostRefused || err.name === 'MailKeyError') return err;
479
+ // Asked for, and the outgoing server could not promise it - said in words,
480
+ // because "not sent" is the right answer and the reason is the useful part.
481
+ // A certificate problem is NOT this - it keeps describeError's own words.
482
+ if (err.code === 'EREQUIRETLS'
483
+ || (err.code === 'ETLS' && transportConfig.requireTLS && /upgrading connection with STARTTLS/i.test(err.message ?? ''))) {
484
+ const plain = new Error('Not sent: this mailbox requires secure delivery, and its outgoing server '
485
+ + 'cannot promise it (it does not offer REQUIRETLS over an encrypted connection). '
486
+ + 'Switch "Require secure delivery" off in Settings > Composing to send it normally.');
487
+ plain.code = 'EREQUIRETLS';
488
+ return plain;
489
+ }
408
490
  const plain = new Error(describeError(err, transportConfig));
409
491
  if (err.code) plain.code = err.code;
410
492
  if (err.responseCode) plain.responseCode = err.responseCode;
@@ -826,6 +826,28 @@ export function createStore(connection) {
826
826
  .toArray();
827
827
  }
828
828
 
829
+ /**
830
+ * The highest UID among a mailbox's unread messages in these folders -
831
+ * the "new mail arrived" mark for the sidebar's notifications. A UID only
832
+ * grows as mail arrives, so reading the newest message lowers this and
833
+ * a new arrival raises it past anything seen before.
834
+ *
835
+ * @param {string} accountId
836
+ * @param {string[]|null} [folders]
837
+ * @returns {Promise<number|null>}
838
+ */
839
+ async function newestUnreadUid(accountId, folders = null) {
840
+ const database = await connect();
841
+ const filter = {accountId, seen: false};
842
+ if (Array.isArray(folders) && folders.length) filter.folder = {$in: folders};
843
+ const row = await database.collection(MESSAGES)
844
+ .find(filter, {projection: {_id: 0, uid: 1}})
845
+ .sort({uid: -1})
846
+ .limit(1)
847
+ .next();
848
+ return Number.isFinite(row?.uid) ? row.uid : null;
849
+ }
850
+
829
851
  /**
830
852
  * The newest mirrored messages to or from any of these addresses, across
831
853
  * the given mailboxes - a contact's emails, for the card in Contacts.
@@ -1081,7 +1103,7 @@ export function createStore(connection) {
1081
1103
  connect,
1082
1104
  getFolder, saveFolder,
1083
1105
  putMessages, setFlags, setMessageAction, removeMessages, clearFolder,
1084
- storedUids, listMessages, listThreads, searchMessages, countUnread, listUnread, listByAddresses, forgetAccount,
1106
+ storedUids, listMessages, listThreads, searchMessages, countUnread, listUnread, newestUnreadUid, listByAddresses, forgetAccount,
1085
1107
  queueSend, listQueuedSends, claimDueSends, finishSend, cancelSend, getQueuedSend,
1086
1108
  queueSnooze, listSnoozed, claimDueSnoozes, finishSnooze, wakeSnooze,
1087
1109
  lastAutoReply, noteAutoReply, listAutoReplies, clearAutoReplies,
@@ -218,7 +218,10 @@ export async function syncAccount({pool, store, account, config}) {
218
218
  const {paths, source} = resolveSyncFolders(account, folders);
219
219
  const results = {};
220
220
 
221
- const rules = accounts.rulesToPublic(account).filter(rule => rule.enabled);
221
+ // Switched on, and not in a switched-off group.
222
+ const rules = accounts.rulesToRun
223
+ ? accounts.rulesToRun(account)
224
+ : accounts.rulesToPublic(account).filter(rule => rule.enabled);
222
225
  const ruleFolders = new Set(rules.length ? accounts.ruleFolders(account) : []);
223
226
 
224
227
  // The away message answers mail arriving in the same folders rules run