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.
- package/CHANGELOG.md +6 -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 +38 -2
- 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 +59 -0
- 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/unreadBadge.js +24 -1
- package/plugins/_lib/mail/vacationRunner.js +4 -1
- package/server/routes/api/sidebar-counts.js +1 -1
|
@@ -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,
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
* @param {{label?: string, unseen: number|null, error?: string}[]} boxes - one per mailbox
|
|
18
|
+
* @param {{id?: string, label?: string, unseen: number|null, uidNext?: number|null, error?: string}[]} boxes - one per mailbox
|
|
19
19
|
* @returns {{count: number, label: string, tone: string, details: object}}
|
|
20
20
|
*/
|
|
21
21
|
export function unreadBadge(boxes = []) {
|
|
@@ -31,6 +31,7 @@ export function unreadBadge(boxes = []) {
|
|
|
31
31
|
// shown even at 0 - otherwise a broken password reads as "all read".
|
|
32
32
|
tone: failed ? 'danger' : 'info',
|
|
33
33
|
...(failed ? {showZero: true} : {}),
|
|
34
|
+
...newMailNotice(known),
|
|
34
35
|
details: {
|
|
35
36
|
title: 'Mail',
|
|
36
37
|
rows: boxes.map(b => ({
|
|
@@ -43,3 +44,25 @@ export function unreadBadge(boxes = []) {
|
|
|
43
44
|
}
|
|
44
45
|
};
|
|
45
46
|
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The sidebar's "new mail" signal (admin/js/lib/sidebar-notify.js): one mark
|
|
50
|
+
* per mailbox with unread mail - the inbox's newest UID, which only rises when
|
|
51
|
+
* something arrives. STATUS has no sender or subject, so the notice says how
|
|
52
|
+
* many and where.
|
|
53
|
+
*
|
|
54
|
+
* @param {object[]} boxes - the reachable ones
|
|
55
|
+
* @returns {{notify?: object}}
|
|
56
|
+
*/
|
|
57
|
+
function newMailNotice(boxes) {
|
|
58
|
+
const marks = {};
|
|
59
|
+
const where = [];
|
|
60
|
+
for (const box of boxes) {
|
|
61
|
+
const uidNext = Number(box.uidNext);
|
|
62
|
+
if (!box.id || !(box.unseen > 0) || !Number.isFinite(uidNext) || uidNext < 2) continue;
|
|
63
|
+
marks[box.id] = uidNext - 1;
|
|
64
|
+
where.push(`${plural(box.unseen, 'unread message')} in ${String(box.label || 'your inbox')}`);
|
|
65
|
+
}
|
|
66
|
+
if (!where.length) return {};
|
|
67
|
+
return {notify: {marks, title: 'New mail', body: where.join('; ')}};
|
|
68
|
+
}
|
|
@@ -97,7 +97,10 @@ export async function runVacation({
|
|
|
97
97
|
// Not filed. One holiday produces a hundred of these, and a
|
|
98
98
|
// Sent folder that is mostly "Out of office" is one nobody can
|
|
99
99
|
// find anything in - the reply log is the audit trail instead.
|
|
100
|
-
fileCopy: false
|
|
100
|
+
fileCopy: false,
|
|
101
|
+
// So the mailbox's rules about leaving (secure delivery) apply
|
|
102
|
+
// to its away message too.
|
|
103
|
+
account
|
|
101
104
|
});
|
|
102
105
|
|
|
103
106
|
// Recorded AFTER it has gone. The other order would mean a send
|
|
@@ -55,7 +55,7 @@ export async function sidebarCountRoutes(fastify, opts = {}) {
|
|
|
55
55
|
const count = Number(value);
|
|
56
56
|
if (value == null || !Number.isFinite(count) || count < 0) { answers.set(url, null); continue; }
|
|
57
57
|
if (typeof answer !== 'object') answers.set(url, count);
|
|
58
|
-
else answers.set(url, Object.fromEntries(['count', 'label', 'tone', 'inventory', 'showZero', 'details']
|
|
58
|
+
else answers.set(url, Object.fromEntries(['count', 'label', 'tone', 'inventory', 'showZero', 'details', 'notify']
|
|
59
59
|
.filter(key => answer[key] !== undefined).map(key => [key, answer[key]])));
|
|
60
60
|
} catch { answers.set(url, null); }
|
|
61
61
|
finally { clearTimeout(timer); }
|