@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,6 @@
1
1
  {
2
2
  "name": "@remit/backend",
3
- "version": "0.0.110",
3
+ "version": "0.0.112",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -1,6 +1,14 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { afterEach, describe, it } from "node:test";
3
- import type { AddressItem, ResultList } from "@remit/data-ports";
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
+ });
@@ -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
- const FLAG_KEYS = [
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
- type FlagKey = (typeof FLAG_KEYS)[number];
37
+ const FLAG_KEYS = Object.values(AddressFlagKey) as readonly FlagKey[];
46
38
 
47
39
  /**
48
- * Translate the wire-format `UpdateAddressFlagsInput` into a service-level
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). `null` becomes the explicit "remove" signal.
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["flags"] | undefined,
52
+ input: UpdateAddressInput | undefined,
55
53
  ): FlagsMergePatch => {
56
54
  if (!input) return {};
57
55
  const patch = {} as Record<FlagKey, unknown>;
58
- for (const key of FLAG_KEYS) {
59
- if (!(key in input)) continue;
60
- const value = (input as Record<FlagKey, unknown>)[key];
61
- if (value === null) {
62
- patch[key] = null;
63
- continue;
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
- if (value === undefined) continue;
66
- patch[key] = value;
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.flags);
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: { listAllByAccountConfig: async () => [account("a1")] },
457
+ account: {
458
+ listAllByAccountConfig: async () => [account("a1"), account("a2")],
459
+ },
456
460
  mailbox: {
457
- listAllByAccount: async () => [
458
- mailbox("m-inbox", "a1", "INBOX"),
459
- mailbox("m-archive", "a1", "Archive"),
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 () => count,
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(calls, []);
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 five, and each says what it does with
557
- // that: the three filters drop out of the predicate, `count` is off
558
- // unless it is asked for, and `results` is on unless it is refused.
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
  });