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
|
@@ -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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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:
|
|
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,
|
|
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
|
-
|
|
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
|