@remit/backend 0.0.110 → 0.0.112
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
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { afterEach, describe, it } from "node:test";
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
AddressResponse,
|
|
5
|
+
UpdateAddressInput,
|
|
6
|
+
} from "@remit/api-openapi-types";
|
|
7
|
+
import type {
|
|
8
|
+
AddressItem,
|
|
9
|
+
FlagsMergePatch,
|
|
10
|
+
ResultList,
|
|
11
|
+
} from "@remit/data-ports";
|
|
4
12
|
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
5
13
|
import type { Context } from "openapi-backend";
|
|
6
14
|
import { deriveAccountConfigId } from "../auth.js";
|
|
@@ -9,7 +17,7 @@ import {
|
|
|
9
17
|
type RemitClient,
|
|
10
18
|
setClient,
|
|
11
19
|
} from "../service/data-client.js";
|
|
12
|
-
import { AddressOperations } from "./address.js";
|
|
20
|
+
import { AddressDetailOperations, AddressOperations } from "./address.js";
|
|
13
21
|
|
|
14
22
|
const searchAddresses =
|
|
15
23
|
AddressOperations.AddressOperations_searchAddresses as unknown as (
|
|
@@ -17,6 +25,12 @@ const searchAddresses =
|
|
|
17
25
|
event: APIGatewayProxyEvent,
|
|
18
26
|
) => Promise<ResultList<AddressItem>>;
|
|
19
27
|
|
|
28
|
+
const updateAddress =
|
|
29
|
+
AddressDetailOperations.AddressDetailOperations_updateAddress as unknown as (
|
|
30
|
+
context: Context,
|
|
31
|
+
event: APIGatewayProxyEvent,
|
|
32
|
+
) => Promise<AddressResponse>;
|
|
33
|
+
|
|
20
34
|
const SUB = "cognito-sub-704";
|
|
21
35
|
const ACCOUNT_CONFIG_ID = deriveAccountConfigId(SUB);
|
|
22
36
|
|
|
@@ -130,3 +144,102 @@ describe("AddressOperations_searchAddresses", () => {
|
|
|
130
144
|
assert.equal(seen[0].limit, 10);
|
|
131
145
|
});
|
|
132
146
|
});
|
|
147
|
+
|
|
148
|
+
const updateContextFor = (body: UpdateAddressInput): Context =>
|
|
149
|
+
({
|
|
150
|
+
request: { params: { addressId: "addr-1" }, requestBody: body },
|
|
151
|
+
}) as unknown as Context;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Fake data client that applies the same merge semantics the repo does:
|
|
155
|
+
* a `null` in the patch deletes the key, anything else writes it.
|
|
156
|
+
*/
|
|
157
|
+
const clientHolding = (
|
|
158
|
+
stored: AddressItem,
|
|
159
|
+
seen: FlagsMergePatch[],
|
|
160
|
+
): RemitClient =>
|
|
161
|
+
({
|
|
162
|
+
address: {
|
|
163
|
+
getAddress: async (): Promise<AddressItem> => stored,
|
|
164
|
+
mergeFlags: async (
|
|
165
|
+
_accountConfigId: string,
|
|
166
|
+
_addressId: string,
|
|
167
|
+
patch: FlagsMergePatch,
|
|
168
|
+
): Promise<AddressItem> => {
|
|
169
|
+
seen.push(patch);
|
|
170
|
+
const flags: Record<string, unknown> = { ...(stored.flags ?? {}) };
|
|
171
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
172
|
+
if (value === undefined) continue;
|
|
173
|
+
if (value === null) delete flags[key];
|
|
174
|
+
else flags[key] = value;
|
|
175
|
+
}
|
|
176
|
+
return { ...stored, flags } as AddressItem;
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
}) as unknown as RemitClient;
|
|
180
|
+
|
|
181
|
+
describe("AddressDetailOperations_updateAddress removing a flag (#615)", () => {
|
|
182
|
+
it("clears the category override, which has no false-equivalent value to send", async () => {
|
|
183
|
+
const seen: FlagsMergePatch[] = [];
|
|
184
|
+
setClient(
|
|
185
|
+
clientHolding(
|
|
186
|
+
address({
|
|
187
|
+
flags: { category: { value: "newsletter", setAt: 10 } },
|
|
188
|
+
}),
|
|
189
|
+
seen,
|
|
190
|
+
),
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const response = await updateAddress(
|
|
194
|
+
updateContextFor({ clearFlags: ["category"] }),
|
|
195
|
+
eventFor(SUB),
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
assert.deepEqual(seen, [{ category: null }]);
|
|
199
|
+
assert.equal(response.flags.category, undefined);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("clears a boolean flag by the same route, leaving the untouched ones alone", async () => {
|
|
203
|
+
const seen: FlagsMergePatch[] = [];
|
|
204
|
+
setClient(
|
|
205
|
+
clientHolding(
|
|
206
|
+
address({
|
|
207
|
+
flags: {
|
|
208
|
+
muted: { value: true, setAt: 10 },
|
|
209
|
+
vip: { value: true, setAt: 11 },
|
|
210
|
+
},
|
|
211
|
+
}),
|
|
212
|
+
seen,
|
|
213
|
+
),
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const response = await updateAddress(
|
|
217
|
+
updateContextFor({ clearFlags: ["muted"] }),
|
|
218
|
+
eventFor(SUB),
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
assert.equal(response.flags.muted, undefined);
|
|
222
|
+
assert.deepEqual(response.flags.vip, { value: true, setAt: 11 });
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("removes a key named in both halves, because clearFlags is applied last", async () => {
|
|
226
|
+
const seen: FlagsMergePatch[] = [];
|
|
227
|
+
setClient(
|
|
228
|
+
clientHolding(
|
|
229
|
+
address({ flags: { muted: { value: true, setAt: 10 } } }),
|
|
230
|
+
seen,
|
|
231
|
+
),
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const response = await updateAddress(
|
|
235
|
+
updateContextFor({
|
|
236
|
+
flags: { muted: { value: true, setAt: 20 } },
|
|
237
|
+
clearFlags: ["muted"],
|
|
238
|
+
}),
|
|
239
|
+
eventFor(SUB),
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
assert.deepEqual(seen, [{ muted: null }]);
|
|
243
|
+
assert.equal(response.flags.muted, undefined);
|
|
244
|
+
});
|
|
245
|
+
});
|
package/src/handlers/address.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
} from "@remit/api-openapi-types";
|
|
5
5
|
import type { AddressItem, FlagsMergePatch } from "@remit/data-ports";
|
|
6
6
|
import { ForbiddenError } from "@remit/data-ports/errors";
|
|
7
|
+
import { AddressFlagKey } from "@remit/domain-enums";
|
|
7
8
|
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
8
9
|
import type { Context } from "openapi-backend";
|
|
9
10
|
import { getAccountConfigIdFromEvent } from "../auth.js";
|
|
@@ -31,39 +32,43 @@ export const toAddressResponse = (item: AddressItem): AddressResponse => ({
|
|
|
31
32
|
updatedAt: item.updatedAt,
|
|
32
33
|
});
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
"trusted",
|
|
36
|
-
"blocked",
|
|
37
|
-
"muted",
|
|
38
|
-
"vip",
|
|
39
|
-
"junkOnly",
|
|
40
|
-
"category",
|
|
41
|
-
"autoArchive",
|
|
42
|
-
"unsubscribed",
|
|
43
|
-
] as const;
|
|
35
|
+
type FlagKey = (typeof AddressFlagKey)[keyof typeof AddressFlagKey];
|
|
44
36
|
|
|
45
|
-
|
|
37
|
+
const FLAG_KEYS = Object.values(AddressFlagKey) as readonly FlagKey[];
|
|
46
38
|
|
|
47
39
|
/**
|
|
48
|
-
* Translate the wire-format `
|
|
40
|
+
* Translate the wire-format `UpdateAddressInput` into a service-level
|
|
49
41
|
* `FlagsMergePatch`. Only known flag keys are forwarded; unknown keys are
|
|
50
42
|
* silently dropped (a TypeSpec-only schema means unknown keys are a client
|
|
51
|
-
* bug, not a security risk).
|
|
43
|
+
* bug, not a security risk).
|
|
44
|
+
*
|
|
45
|
+
* `clearFlags` is the removal form for every flag whatever its value type, and
|
|
46
|
+
* is applied after `flags`, so a key named in both ends up removed. The `null`
|
|
47
|
+
* alternative on a flag key stays honoured for callers that can express it,
|
|
48
|
+
* but it is unreachable over OAS 3.0: TypeSpec emits a nullable `$ref` as
|
|
49
|
+
* `allOf`, and the request validator drops the `nullable` there.
|
|
52
50
|
*/
|
|
53
51
|
export const buildFlagsPatch = (
|
|
54
|
-
input: UpdateAddressInput
|
|
52
|
+
input: UpdateAddressInput | undefined,
|
|
55
53
|
): FlagsMergePatch => {
|
|
56
54
|
if (!input) return {};
|
|
57
55
|
const patch = {} as Record<FlagKey, unknown>;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
56
|
+
const flags = input.flags;
|
|
57
|
+
if (flags) {
|
|
58
|
+
for (const key of FLAG_KEYS) {
|
|
59
|
+
if (!(key in flags)) continue;
|
|
60
|
+
const value = (flags as Record<FlagKey, unknown>)[key];
|
|
61
|
+
if (value === null) {
|
|
62
|
+
patch[key] = null;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (value === undefined) continue;
|
|
66
|
+
patch[key] = value;
|
|
64
67
|
}
|
|
65
|
-
|
|
66
|
-
|
|
68
|
+
}
|
|
69
|
+
for (const key of input.clearFlags ?? []) {
|
|
70
|
+
if (!FLAG_KEYS.includes(key)) continue;
|
|
71
|
+
patch[key] = null;
|
|
67
72
|
}
|
|
68
73
|
return patch as FlagsMergePatch;
|
|
69
74
|
};
|
|
@@ -122,7 +127,7 @@ export const AddressDetailOperations: Record<
|
|
|
122
127
|
throw new ForbiddenError(`Address ${addressId} not in account config`);
|
|
123
128
|
}
|
|
124
129
|
|
|
125
|
-
const patch = buildFlagsPatch(body
|
|
130
|
+
const patch = buildFlagsPatch(body);
|
|
126
131
|
if (Object.keys(patch).length === 0) {
|
|
127
132
|
return toAddressResponse(existing);
|
|
128
133
|
}
|
|
@@ -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
|
});
|