@remit/backend 0.0.111 → 0.0.113
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/package.json
CHANGED
package/src/handlers/mailbox.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type CanonicalMailboxRoleValue,
|
|
9
9
|
composeFolderRoleAppointmentLabelName,
|
|
10
10
|
} from "@remit/data-ports/folder-role";
|
|
11
|
+
import { rebaseMailboxPath } from "@remit/data-ports/mailbox-name";
|
|
11
12
|
import { MailboxSyncStatus, MessageSystemFlag } from "@remit/domain-enums";
|
|
12
13
|
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
13
14
|
import { getAccountConfigIdFromEvent } from "../auth.js";
|
|
@@ -76,24 +77,6 @@ export interface MailboxPatchClient {
|
|
|
76
77
|
accountSetting: Pick<IAccountSettingRepository, "get" | "upsert" | "delete">;
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
/**
|
|
80
|
-
* Where a recorded path lands after the rename, or `undefined` when the rename
|
|
81
|
-
* did not move it. IMAP RENAME moves the whole subtree in one command and
|
|
82
|
-
* `renameChildPaths` rewrites every descendant row with it, so a label under
|
|
83
|
-
* the renamed branch moves exactly as far as its prefix does.
|
|
84
|
-
*/
|
|
85
|
-
const rebasePath = (
|
|
86
|
-
recorded: string,
|
|
87
|
-
oldPath: string,
|
|
88
|
-
newPath: string,
|
|
89
|
-
delimiter: string,
|
|
90
|
-
): string | undefined => {
|
|
91
|
-
if (recorded === oldPath) return newPath;
|
|
92
|
-
const branch = `${oldPath}${delimiter}`;
|
|
93
|
-
if (!recorded.startsWith(branch)) return undefined;
|
|
94
|
-
return `${newPath}${recorded.slice(oldPath.length)}`;
|
|
95
|
-
};
|
|
96
|
-
|
|
97
80
|
/**
|
|
98
81
|
* A reader-side rename keeps every mailboxId, so the appointments survive it —
|
|
99
82
|
* but the paths recorded beside them (#887) would still name where the folders
|
|
@@ -101,8 +84,8 @@ const rebasePath = (
|
|
|
101
84
|
* names a path the user has not seen since the rename.
|
|
102
85
|
*
|
|
103
86
|
* The renamed folder is matched by id; its descendants are matched by the path
|
|
104
|
-
* each label already holds, which is the path their rows carried until
|
|
105
|
-
* rename rewrote them.
|
|
87
|
+
* each label already holds, which is the path their rows carried until the
|
|
88
|
+
* rename intent rewrote them.
|
|
106
89
|
*/
|
|
107
90
|
const refreshAppointmentLabels = async (
|
|
108
91
|
accountSetting: Pick<IAccountSettingRepository, "get" | "upsert">,
|
|
@@ -122,7 +105,7 @@ const refreshAppointmentLabels = async (
|
|
|
122
105
|
? renamed.newPath
|
|
123
106
|
: appointment.lastKnownPath === undefined
|
|
124
107
|
? undefined
|
|
125
|
-
:
|
|
108
|
+
: rebaseMailboxPath(
|
|
126
109
|
appointment.lastKnownPath,
|
|
127
110
|
renamed.oldPath,
|
|
128
111
|
renamed.newPath,
|
|
@@ -144,8 +127,8 @@ const refreshAppointmentLabels = async (
|
|
|
144
127
|
* Apply a mailbox PATCH body: override changes first (mute flag + display-name/
|
|
145
128
|
* role overrides — written to per-mailbox AccountSetting rows, no IMAP
|
|
146
129
|
* machinery), then rename — which triggers the IMAP rename machinery
|
|
147
|
-
* (
|
|
148
|
-
* present. An override-only PATCH therefore never calls
|
|
130
|
+
* (the recorded subtree intent + MAILBOX_RENAME event) — only when `fullPath`
|
|
131
|
+
* is present. An override-only PATCH therefore never calls
|
|
149
132
|
* `mailboxQueue.renameMailbox`.
|
|
150
133
|
*/
|
|
151
134
|
export const applyMailboxPatch = async (
|
|
@@ -160,8 +143,8 @@ export const applyMailboxPatch = async (
|
|
|
160
143
|
// --- Override settings (mute flag + display-name/role overrides) ---
|
|
161
144
|
// Written to per-mailbox AccountSetting rows (RFC 032) with the same
|
|
162
145
|
// null→remove semantics as UpdateAddressFlagsInput. Applied before (and
|
|
163
|
-
// independent of) any rename so an override-only PATCH never touches
|
|
164
|
-
//
|
|
146
|
+
// independent of) any rename so an override-only PATCH never touches the
|
|
147
|
+
// row's mutation state.
|
|
165
148
|
const changes = pickMailboxOverrideChanges(body);
|
|
166
149
|
if (Object.keys(changes).length > 0) {
|
|
167
150
|
await applyMailboxOverrideChanges(
|
|
@@ -268,6 +251,11 @@ const toMailboxResponse = (
|
|
|
268
251
|
highWaterMarkUid: mailbox.highWaterMarkUid,
|
|
269
252
|
lastMessageSyncAt: mailbox.lastMessageSyncAt,
|
|
270
253
|
syncStatus: mailbox.syncStatus,
|
|
254
|
+
// The row is the only thing that knows what a rename is aiming at, and
|
|
255
|
+
// `fullPath` stays the confirmed one throughout (D2), so without this a
|
|
256
|
+
// client cannot tell a create in flight from a rename in flight, name the
|
|
257
|
+
// target of a failed rename, or offer the right retry.
|
|
258
|
+
pendingPath: mailbox.pendingPath,
|
|
271
259
|
muted: overrides.muted,
|
|
272
260
|
displayNameOverride: overrides.displayNameOverride,
|
|
273
261
|
createdAt: mailbox.createdAt,
|
|
@@ -442,22 +442,32 @@ describe("buildSearchAllThreadsOptions", () => {
|
|
|
442
442
|
// the pages a client happened to have loaded. Both are the query's job now.
|
|
443
443
|
describe("executeUnifiedThreadListing", () => {
|
|
444
444
|
interface Call {
|
|
445
|
-
mode: "listByDate" | "listByStarred" | "searchByDate";
|
|
445
|
+
mode: "listByDate" | "listByStarred" | "searchByDate" | "count";
|
|
446
446
|
search?: SearchOptions;
|
|
447
447
|
limit?: number;
|
|
448
448
|
continuationToken?: string;
|
|
449
|
+
inboxMailboxIds?: Set<string>;
|
|
450
|
+
mailboxIds?: Set<string>;
|
|
449
451
|
}
|
|
450
452
|
|
|
451
453
|
const emptyPage = { items: [], continuationToken: undefined };
|
|
452
454
|
|
|
453
455
|
const buildListingClient = (calls: Call[], count = 0) =>
|
|
454
456
|
({
|
|
455
|
-
account: {
|
|
457
|
+
account: {
|
|
458
|
+
listAllByAccountConfig: async () => [account("a1"), account("a2")],
|
|
459
|
+
},
|
|
456
460
|
mailbox: {
|
|
457
|
-
listAllByAccount: async () =>
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
+
listAllByAccount: async (accountId: string) =>
|
|
462
|
+
accountId === "a1"
|
|
463
|
+
? [
|
|
464
|
+
mailbox("m-inbox", "a1", "INBOX"),
|
|
465
|
+
mailbox("m-archive", "a1", "Archive"),
|
|
466
|
+
]
|
|
467
|
+
: [
|
|
468
|
+
mailbox("m2-inbox", "a2", "INBOX"),
|
|
469
|
+
mailbox("m2-archive", "a2", "Archive"),
|
|
470
|
+
],
|
|
461
471
|
},
|
|
462
472
|
accountSetting: { listByAccountConfig: async () => [] },
|
|
463
473
|
message: { get: async () => [] },
|
|
@@ -487,7 +497,14 @@ describe("executeUnifiedThreadListing", () => {
|
|
|
487
497
|
calls.push({ mode: "searchByDate", search, ...options });
|
|
488
498
|
return emptyPage;
|
|
489
499
|
},
|
|
490
|
-
countThreadsInScope: async (
|
|
500
|
+
countThreadsInScope: async (
|
|
501
|
+
_config: string,
|
|
502
|
+
search: SearchOptions,
|
|
503
|
+
options?: { mailboxIds?: Set<string> },
|
|
504
|
+
) => {
|
|
505
|
+
calls.push({ mode: "count", search, ...options });
|
|
506
|
+
return count;
|
|
507
|
+
},
|
|
491
508
|
},
|
|
492
509
|
}) as unknown as UnifiedThreadClient;
|
|
493
510
|
|
|
@@ -579,7 +596,10 @@ describe("executeUnifiedThreadListing", () => {
|
|
|
579
596
|
params({ starredOnly: true, count: true, results: false }),
|
|
580
597
|
);
|
|
581
598
|
|
|
582
|
-
assert.deepEqual(
|
|
599
|
+
assert.deepEqual(
|
|
600
|
+
calls.map((call) => call.mode),
|
|
601
|
+
["count"],
|
|
602
|
+
);
|
|
583
603
|
assert.equal(response.items, undefined);
|
|
584
604
|
assert.equal(response.count, 7);
|
|
585
605
|
});
|
|
@@ -595,4 +615,119 @@ describe("executeUnifiedThreadListing", () => {
|
|
|
595
615
|
assert.equal(response.count, undefined);
|
|
596
616
|
assert.deepEqual(response.items, []);
|
|
597
617
|
});
|
|
618
|
+
|
|
619
|
+
// #1128: the one free-text parameter matches subject and From at once, so
|
|
620
|
+
// `from:` and `subject:` could not be asked for separately and the Flagged
|
|
621
|
+
// view applied both over the pages it had already fetched.
|
|
622
|
+
test("sends from and subject as their own terms", async () => {
|
|
623
|
+
const calls: Call[] = [];
|
|
624
|
+
await executeUnifiedThreadListing(buildListingClient(calls), CONFIG_ID, {
|
|
625
|
+
...params({ starredOnly: true }),
|
|
626
|
+
from: "alice",
|
|
627
|
+
subject: "invoice",
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
assert.deepEqual(calls[0].search, {
|
|
631
|
+
from: "alice",
|
|
632
|
+
subject: "invoice",
|
|
633
|
+
starred: true,
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
test("blank field text asks for nothing", async () => {
|
|
638
|
+
const calls: Call[] = [];
|
|
639
|
+
await executeUnifiedThreadListing(buildListingClient(calls), CONFIG_ID, {
|
|
640
|
+
...params(),
|
|
641
|
+
from: "",
|
|
642
|
+
subject: "",
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
assert.deepEqual(calls[0].search, {});
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
// #1137: the count answered a wider set than the list, because the browser
|
|
649
|
+
// dropped muted senders after the rows arrived and nothing told the server.
|
|
650
|
+
test("the muted term reaches the listing and the count alike", async () => {
|
|
651
|
+
const calls: Call[] = [];
|
|
652
|
+
await executeUnifiedThreadListing(buildListingClient(calls), CONFIG_ID, {
|
|
653
|
+
...params({ count: true }),
|
|
654
|
+
muted: false,
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
const listing = calls.find((call) => call.mode === "listByDate");
|
|
658
|
+
const counting = calls.find((call) => call.mode === "count");
|
|
659
|
+
assert.deepEqual(listing?.search, { muted: false });
|
|
660
|
+
assert.deepEqual(counting?.search, { muted: false });
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
test("an unstated muted term filters nothing", async () => {
|
|
664
|
+
const calls: Call[] = [];
|
|
665
|
+
await executeUnifiedThreadListing(
|
|
666
|
+
buildListingClient(calls),
|
|
667
|
+
CONFIG_ID,
|
|
668
|
+
params(),
|
|
669
|
+
);
|
|
670
|
+
|
|
671
|
+
assert.equal(Object.hasOwn(calls[0].search ?? {}, "muted"), false);
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
// #1136: the account pill narrowed the rows after they arrived, so a count
|
|
675
|
+
// over every account was not the size of a list showing one and the brief
|
|
676
|
+
// withheld every number rather than overstate.
|
|
677
|
+
test("an account scope narrows the listing and the count to that account", async () => {
|
|
678
|
+
const calls: Call[] = [];
|
|
679
|
+
await executeUnifiedThreadListing(
|
|
680
|
+
buildListingClient(calls, 3),
|
|
681
|
+
CONFIG_ID,
|
|
682
|
+
params({ accountId: "a2", count: true }),
|
|
683
|
+
);
|
|
684
|
+
|
|
685
|
+
const listing = calls.find((call) => call.mode === "listByDate");
|
|
686
|
+
const counting = calls.find((call) => call.mode === "count");
|
|
687
|
+
assert.deepEqual([...(listing?.inboxMailboxIds ?? [])], ["m2-inbox"]);
|
|
688
|
+
assert.deepEqual([...(counting?.mailboxIds ?? [])].sort(), ["m2-inbox"]);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
test("an account scope narrows the search scope too", async () => {
|
|
692
|
+
const calls: Call[] = [];
|
|
693
|
+
await executeUnifiedThreadListing(buildListingClient(calls), CONFIG_ID, {
|
|
694
|
+
...params({ accountId: "a2" }),
|
|
695
|
+
searchText: "invoice",
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
assert.equal(calls[0].mode, "searchByDate");
|
|
699
|
+
assert.deepEqual([...(calls[0].mailboxIds ?? [])].sort(), [
|
|
700
|
+
"m2-archive",
|
|
701
|
+
"m2-inbox",
|
|
702
|
+
]);
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
test("no account scope leaves the cross-account aggregate", async () => {
|
|
706
|
+
const calls: Call[] = [];
|
|
707
|
+
await executeUnifiedThreadListing(
|
|
708
|
+
buildListingClient(calls),
|
|
709
|
+
CONFIG_ID,
|
|
710
|
+
params(),
|
|
711
|
+
);
|
|
712
|
+
|
|
713
|
+
assert.deepEqual([...(calls[0].inboxMailboxIds ?? [])].sort(), [
|
|
714
|
+
"m-inbox",
|
|
715
|
+
"m2-inbox",
|
|
716
|
+
]);
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
// An account the config does not read narrows to nothing, which is the
|
|
720
|
+
// honest answer — never the whole aggregate under another account's name.
|
|
721
|
+
test("an unknown account matches nothing", async () => {
|
|
722
|
+
const calls: Call[] = [];
|
|
723
|
+
const response = await executeUnifiedThreadListing(
|
|
724
|
+
buildListingClient(calls, 99),
|
|
725
|
+
CONFIG_ID,
|
|
726
|
+
params({ accountId: "a-other", count: true }),
|
|
727
|
+
);
|
|
728
|
+
|
|
729
|
+
assert.deepEqual(calls, []);
|
|
730
|
+
assert.deepEqual(response.items, []);
|
|
731
|
+
assert.equal(response.count, 0);
|
|
732
|
+
});
|
|
598
733
|
});
|
|
@@ -335,23 +335,62 @@ export const attachAccountIds = (
|
|
|
335
335
|
* (#308). `starred` and `query` are in here too so one `SearchOptions` says
|
|
336
336
|
* what the whole request narrows by, which is what lets the count run the same
|
|
337
337
|
* predicate as the listing.
|
|
338
|
+
*
|
|
339
|
+
* `from` and `subject` name one field each, which `query` cannot — it matches
|
|
340
|
+
* both at once, so neither token could be asked for on its own and both were
|
|
341
|
+
* applied over the loaded pages instead (#1128). `muted` is the one criterion
|
|
342
|
+
* that is not a column at all: it is read from the sender's address, and it is
|
|
343
|
+
* here because a caller that hides muted mail has to be able to count what it
|
|
344
|
+
* renders (#1137).
|
|
338
345
|
*/
|
|
339
346
|
export const buildUnifiedThreadSearch = (params: {
|
|
340
347
|
starredOnly: boolean;
|
|
341
348
|
searchText?: string;
|
|
349
|
+
from?: string;
|
|
350
|
+
subject?: string;
|
|
342
351
|
category?: MessageCategory[];
|
|
343
352
|
unread?: boolean;
|
|
344
353
|
attachments?: boolean;
|
|
354
|
+
muted?: boolean;
|
|
345
355
|
}): SearchOptions => ({
|
|
346
356
|
...(params.searchText ? { query: params.searchText } : {}),
|
|
357
|
+
...(params.from ? { from: params.from } : {}),
|
|
358
|
+
...(params.subject ? { subject: params.subject } : {}),
|
|
347
359
|
...(params.starredOnly ? { starred: true } : {}),
|
|
348
360
|
...(params.category?.length ? { category: params.category } : {}),
|
|
349
361
|
...(params.unread !== undefined ? { unread: params.unread } : {}),
|
|
350
362
|
...(params.attachments !== undefined
|
|
351
363
|
? { attachments: params.attachments }
|
|
352
364
|
: {}),
|
|
365
|
+
...(params.muted !== undefined ? { muted: params.muted } : {}),
|
|
353
366
|
});
|
|
354
367
|
|
|
368
|
+
/**
|
|
369
|
+
* Narrow a mailbox scope to one account's mailboxes.
|
|
370
|
+
*
|
|
371
|
+
* The account pill used to narrow the rows a page returned, which took the
|
|
372
|
+
* number off every section header: a count over every account is not the size
|
|
373
|
+
* of a list showing one, so the brief withheld it rather than overstate (#1136).
|
|
374
|
+
* The scope is where the answer belongs — the listing, the search and the count
|
|
375
|
+
* all read it, so all three agree.
|
|
376
|
+
*
|
|
377
|
+
* An account with no mailboxes in the map — one that is muted, or one belonging
|
|
378
|
+
* to another config — narrows to the empty set, which matches nothing. That is
|
|
379
|
+
* the honest answer: the caller asked for an account this config does not read.
|
|
380
|
+
*/
|
|
381
|
+
export const scopeToAccount = (
|
|
382
|
+
mailboxIds: Set<string>,
|
|
383
|
+
mailboxIdToAccountId: Map<string, string>,
|
|
384
|
+
accountId: string | undefined,
|
|
385
|
+
): Set<string> => {
|
|
386
|
+
if (accountId === undefined) return mailboxIds;
|
|
387
|
+
return new Set(
|
|
388
|
+
[...mailboxIds].filter(
|
|
389
|
+
(mailboxId) => mailboxIdToAccountId.get(mailboxId) === accountId,
|
|
390
|
+
),
|
|
391
|
+
);
|
|
392
|
+
};
|
|
393
|
+
|
|
355
394
|
/**
|
|
356
395
|
* Minimal client surface `executeUnifiedThreadListing` needs, declared
|
|
357
396
|
* structurally (like `ThreadSearchClient`) so the mode selection, the row
|
|
@@ -386,9 +425,13 @@ export type UnifiedThreadParams = {
|
|
|
386
425
|
limit?: number;
|
|
387
426
|
starredOnly: boolean;
|
|
388
427
|
searchText?: string;
|
|
428
|
+
from?: string;
|
|
429
|
+
subject?: string;
|
|
430
|
+
accountId?: string;
|
|
389
431
|
category?: MessageCategory[];
|
|
390
432
|
unread?: boolean;
|
|
391
433
|
attachments?: boolean;
|
|
434
|
+
muted?: boolean;
|
|
392
435
|
count: boolean;
|
|
393
436
|
results: boolean;
|
|
394
437
|
};
|
|
@@ -413,12 +456,20 @@ export const executeUnifiedThreadListing = async (
|
|
|
413
456
|
params.searchText !== undefined && params.searchText.length > 0;
|
|
414
457
|
const {
|
|
415
458
|
mailboxIdToAccountId,
|
|
416
|
-
inboxMailboxIds,
|
|
417
|
-
starredMailboxIds,
|
|
418
|
-
searchMailboxIds,
|
|
459
|
+
inboxMailboxIds: everyInboxMailboxId,
|
|
460
|
+
starredMailboxIds: everyStarredMailboxId,
|
|
461
|
+
searchMailboxIds: everySearchMailboxId,
|
|
419
462
|
virtualCopyMailboxIds,
|
|
420
463
|
} = await buildInboxMailboxMap(accountConfigId, client);
|
|
421
464
|
|
|
465
|
+
// The account scope applies before the mode picks a set, so the listing, the
|
|
466
|
+
// search and the count all narrow by the same rule.
|
|
467
|
+
const narrow = (mailboxIds: Set<string>): Set<string> =>
|
|
468
|
+
scopeToAccount(mailboxIds, mailboxIdToAccountId, params.accountId);
|
|
469
|
+
const inboxMailboxIds = narrow(everyInboxMailboxId);
|
|
470
|
+
const starredMailboxIds = narrow(everyStarredMailboxId);
|
|
471
|
+
const searchMailboxIds = narrow(everySearchMailboxId);
|
|
472
|
+
|
|
422
473
|
// Search widens past INBOX to every folder it may reach; `starred=true`
|
|
423
474
|
// still narrows it to the starred scope, so the two compose.
|
|
424
475
|
const searchScope = params.starredOnly ? starredMailboxIds : searchMailboxIds;
|
|
@@ -527,9 +578,13 @@ export const UnifiedThreadOperations: Record<
|
|
|
527
578
|
limit,
|
|
528
579
|
starred,
|
|
529
580
|
query,
|
|
581
|
+
from,
|
|
582
|
+
subject,
|
|
583
|
+
accountId,
|
|
530
584
|
category,
|
|
531
585
|
unread,
|
|
532
586
|
attachments,
|
|
587
|
+
muted,
|
|
533
588
|
count,
|
|
534
589
|
results,
|
|
535
590
|
} = context.request.query as {
|
|
@@ -538,29 +593,41 @@ export const UnifiedThreadOperations: Record<
|
|
|
538
593
|
limit?: number;
|
|
539
594
|
starred?: boolean | string;
|
|
540
595
|
query?: string;
|
|
596
|
+
from?: string;
|
|
597
|
+
subject?: string;
|
|
598
|
+
accountId?: string;
|
|
541
599
|
category?: MessageCategory | MessageCategory[];
|
|
542
600
|
unread?: boolean | string;
|
|
543
601
|
attachments?: boolean | string;
|
|
602
|
+
muted?: boolean | string;
|
|
544
603
|
count?: boolean | string;
|
|
545
604
|
results?: boolean | string;
|
|
546
605
|
};
|
|
547
606
|
|
|
548
607
|
// Whitespace-only text is not a search: it would widen the scope to every
|
|
549
|
-
// folder while matching nothing in particular.
|
|
608
|
+
// folder while matching nothing in particular. The two field parameters
|
|
609
|
+
// are trimmed on the same rule, and neither widens the scope at all.
|
|
550
610
|
const searchText = query?.trim();
|
|
611
|
+
const fromText = from?.trim();
|
|
612
|
+
const subjectText = subject?.trim();
|
|
551
613
|
|
|
552
614
|
return executeUnifiedThreadListing(await getClient(), accountConfigId, {
|
|
553
615
|
continuationToken,
|
|
554
616
|
order,
|
|
555
617
|
limit,
|
|
556
|
-
// Absent means unstated for all
|
|
557
|
-
// that: the
|
|
558
|
-
//
|
|
618
|
+
// Absent means unstated for all of them, and each says what it does with
|
|
619
|
+
// that: the row filters drop out of the predicate, the account scope
|
|
620
|
+
// stays the cross-account aggregate, `count` is off unless it is asked
|
|
621
|
+
// for, and `results` is on unless it is refused.
|
|
559
622
|
starredOnly: toBoolean(starred) === true,
|
|
560
623
|
searchText: searchText || undefined,
|
|
624
|
+
from: fromText || undefined,
|
|
625
|
+
subject: subjectText || undefined,
|
|
626
|
+
accountId: accountId || undefined,
|
|
561
627
|
category: toArray(category),
|
|
562
628
|
unread: toBoolean(unread),
|
|
563
629
|
attachments: toBoolean(attachments),
|
|
630
|
+
muted: toBoolean(muted),
|
|
564
631
|
count: toBoolean(count) === true,
|
|
565
632
|
results: toBoolean(results) !== false,
|
|
566
633
|
});
|