@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/backend",
3
- "version": "0.0.111",
3
+ "version": "0.0.113",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -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 this
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
- : rebasePath(
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
- * (syncStatus/oldPath + MAILBOX_RENAME event) — only when `fullPath` is
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
- // syncStatus/oldPath.
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: { 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
  });