@remit/web-client 0.0.192 → 0.0.194

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/web-client",
3
- "version": "0.0.192",
3
+ "version": "0.0.194",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -101,6 +101,7 @@ import { useMailFreshness } from "@/lib/mail-freshness";
101
101
  import { relatedSearchResults, rowToSearchResult } from "@/lib/search-result";
102
102
  import { showInlineSearchResults } from "@/lib/search-surface";
103
103
  import { parseSearchTokens } from "@/lib/search-tokens";
104
+ import { resolveSelectionAccountScope } from "@/lib/selection-account-scope";
104
105
  import { spamOfferForResults } from "@/lib/spam-offer";
105
106
  import {
106
107
  type SelectionWizardControl,
@@ -206,30 +207,24 @@ export const resolveBriefSelectionScope = (
206
207
  selectedIds: ReadonlySet<string>,
207
208
  ): BriefSelectionScope => {
208
209
  if (selectedIds.size === 0) return {};
209
- const accountIds = new Set<string>();
210
+ const selected = rows.filter((row) => selectedIds.has(row.id));
211
+ const account = resolveSelectionAccountScope(
212
+ selected.map((row) => row.accountId),
213
+ );
214
+ if (account.restriction) return account;
210
215
  const mailboxIds = new Set<string>();
211
- for (const row of rows) {
212
- if (!selectedIds.has(row.id)) continue;
213
- if (row.accountId) accountIds.add(row.accountId);
216
+ for (const row of selected) {
214
217
  if (row.mailboxId) mailboxIds.add(row.mailboxId);
215
218
  }
216
- if (accountIds.size > 1) {
217
- return {
218
- restriction: "spansAccounts",
219
- moveDisabledHint:
220
- "Move only works within one account — clear selection or pick messages from a single account",
221
- };
222
- }
223
- const accountId = accountIds.size === 1 ? [...accountIds][0] : undefined;
224
219
  if (mailboxIds.size > 1) {
225
220
  return {
226
- accountId,
221
+ accountId: account.accountId,
227
222
  restriction: "spansFolders",
228
223
  moveDisabledHint: `Move only works within one folder — this selection spans ${mailboxIds.size} folders`,
229
224
  };
230
225
  }
231
226
  return {
232
- accountId,
227
+ accountId: account.accountId,
233
228
  mailboxId: mailboxIds.size === 1 ? [...mailboxIds][0] : undefined,
234
229
  };
235
230
  };
@@ -0,0 +1,64 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { makeThreadMessage } from "@/test-support/fixtures";
4
+ import { resolveMoveDisabledHint } from "./MessageList";
5
+
6
+ /**
7
+ * Regression for #456: every account of one user shares an `accountConfigId`,
8
+ * so a guard built from it can never trip. These rows share one and differ only
9
+ * in `accountId`, which is the fact Move is scoped by.
10
+ */
11
+ const work = makeThreadMessage({ messageId: "m1", accountId: "acc-work" });
12
+ const personal = makeThreadMessage({
13
+ messageId: "m2",
14
+ accountId: "acc-personal",
15
+ });
16
+ const alsoWork = makeThreadMessage({ messageId: "m3", accountId: "acc-work" });
17
+ const unattributed = makeThreadMessage({
18
+ messageId: "m4",
19
+ accountId: undefined,
20
+ });
21
+
22
+ const hintFor = (
23
+ threads: ReturnType<typeof makeThreadMessage>[],
24
+ ids: string[],
25
+ listAccountId?: string,
26
+ ) => resolveMoveDisabledHint(threads, new Set(ids), listAccountId);
27
+
28
+ describe("the move guard over a thread-list selection", () => {
29
+ it("withholds Move from rows owned by different accounts", () => {
30
+ assert.match(
31
+ hintFor([work, personal], ["m1", "m2"]) ?? "",
32
+ /only works within one account/,
33
+ );
34
+ });
35
+
36
+ it("offers Move to rows owned by the same account", () => {
37
+ assert.equal(hintFor([work, alsoWork], ["m1", "m3"]), undefined);
38
+ });
39
+
40
+ it("ignores rows the selection does not hold", () => {
41
+ assert.equal(hintFor([work, personal], ["m1"]), undefined);
42
+ });
43
+
44
+ // A per-mailbox list attaches no account to its rows, so the list's own
45
+ // account stands in for them — otherwise the guard sees an empty set and
46
+ // under-fires on a selection that really does span two accounts.
47
+ it("withholds Move when a row without an account sits under another one", () => {
48
+ assert.match(
49
+ hintFor([unattributed, work], ["m4", "m1"], "acc-personal") ?? "",
50
+ /only works within one account/,
51
+ );
52
+ });
53
+
54
+ it("offers Move when the list's account is the one every row falls back to", () => {
55
+ assert.equal(
56
+ hintFor([unattributed, work], ["m4", "m1"], "acc-work"),
57
+ undefined,
58
+ );
59
+ });
60
+
61
+ it("offers Move when neither the rows nor the list name an account", () => {
62
+ assert.equal(hintFor([unattributed], ["m4"], undefined), undefined);
63
+ });
64
+ });
@@ -50,6 +50,7 @@ import { junkDestination } from "@/lib/junk-destination";
50
50
  import { tabStopId } from "@/lib/list-focus";
51
51
  import { useListHeaderChrome } from "@/lib/list-header-chrome";
52
52
  import { listVerbRequest } from "@/lib/list-verb-request";
53
+ import { resolveSelectionAccountScope } from "@/lib/selection-account-scope";
53
54
  import { shouldExitSelectionOnNavigate } from "@/lib/selection-mode";
54
55
  import { useSelectionWizard, useWizardStepValue } from "@/lib/wizard-history";
55
56
  import type { WizardSelectionMessage } from "@/lib/wizard-selection";
@@ -207,6 +208,23 @@ const SearchResultsHeader = ({ query }: { query: string }) => (
207
208
  </div>
208
209
  );
209
210
 
211
+ /**
212
+ * Why Move is withheld from a selection, in the toolbar's own words. Rows from
213
+ * a per-mailbox endpoint carry no account of their own, so the list's own
214
+ * account stands in for them and a row that does carry one is still compared
215
+ * against it. Pure, so it tests without a DOM.
216
+ */
217
+ export const resolveMoveDisabledHint = (
218
+ threads: readonly RemitImapThreadMessageResponse[],
219
+ selectedIds: ReadonlySet<string>,
220
+ listAccountId: string | undefined,
221
+ ): string | undefined =>
222
+ resolveSelectionAccountScope(
223
+ threads
224
+ .filter((thread) => selectedIds.has(thread.messageId))
225
+ .map((thread) => thread.accountId ?? listAccountId),
226
+ ).moveDisabledHint;
227
+
210
228
  export const MessageList = ({
211
229
  mailboxId,
212
230
  threads,
@@ -776,28 +794,13 @@ export const MessageList = ({
776
794
  setFocusedMessageId(restoreTo);
777
795
  }, [setFocusedMessageId]);
778
796
 
779
- // Cross-account guard: every selected thread row must belong to the
780
- // same account as the current mailbox. The list is already scoped to
781
- // one mailbox so in practice this is always single-account, but we
782
- // detect drift defensively (e.g. a future global selection mode) and
783
- // disable Move with an inline hint rather than silently aggregating.
784
- //
785
- // `MessageList` re-renders on every virtualizer scroll tick. Memoize
786
- // the guard so we only walk the selected slice when selection or
787
- // thread identity actually changes.
788
- const moveDisabledHint = useMemo(() => {
789
- if (selectedCount === 0) return undefined;
790
- const selectedAccountConfigIds = new Set<string>();
791
- for (const thread of threads) {
792
- if (selectedIds.has(thread.messageId)) {
793
- selectedAccountConfigIds.add(thread.accountConfigId);
794
- }
795
- }
796
- if (selectedAccountConfigIds.size > 1) {
797
- return "Move only works within one account — clear selection or pick messages from a single account";
798
- }
799
- return undefined;
800
- }, [selectedCount, selectedIds, threads]);
797
+ // `MessageList` re-renders on every virtualizer scroll tick. Memoize the
798
+ // guard so we only walk the selected slice when selection or thread
799
+ // identity actually changes.
800
+ const moveDisabledHint = useMemo(
801
+ () => resolveMoveDisabledHint(threads, selectedIds, accountId),
802
+ [accountId, selectedIds, threads],
803
+ );
801
804
 
802
805
  // Organize builds a rule out of clauses, and a search predicate is not a set
803
806
  // of clauses — its facets have no `ClauseField`. Over an escalated selection
@@ -2,7 +2,6 @@ import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
3
  import {
4
4
  advanceMove,
5
- appointedRole,
6
5
  beginMove,
7
6
  excludeFolder,
8
7
  type FolderNode,
@@ -11,6 +10,7 @@ import {
11
10
  hasChildFolders,
12
11
  initialStage,
13
12
  moveProgressLabel,
13
+ vouchedRole,
14
14
  } from "./delete-folder.js";
15
15
 
16
16
  const folder = (
@@ -36,13 +36,74 @@ describe("guardFolderDeletion", () => {
36
36
  const guard = guardFolderDeletion(
37
37
  target,
38
38
  [target],
39
- [{ role: "Archive", mailboxId: "arch" }],
39
+ [{ role: "Archive", source: "Appointed", mailboxId: "arch" }],
40
40
  );
41
41
  assert.equal(guard.deletable, false);
42
42
  assert.equal(guard.reason, "role");
43
43
  assert.match(guard.message ?? "", /Archive/);
44
44
  });
45
45
 
46
+ it("blocks a folder the mail server flagged for the role", () => {
47
+ const target = folder({ mailboxId: "sent", fullPath: "Sent" });
48
+ const guard = guardFolderDeletion(
49
+ target,
50
+ [target],
51
+ [{ role: "Sent", source: "Flagged", mailboxId: "sent" }],
52
+ );
53
+ assert.equal(guard.deletable, false);
54
+ assert.equal(guard.reason, "role");
55
+ });
56
+
57
+ it("deletes a folder whose only role claim is a name proposal", () => {
58
+ const target = folder({ mailboxId: "arch", fullPath: "Archive" });
59
+ const guard = guardFolderDeletion(
60
+ target,
61
+ [target],
62
+ [{ role: "Archive", source: "Proposed", mailboxId: "arch" }],
63
+ );
64
+ assert.deepEqual(guard, { deletable: true });
65
+ });
66
+
67
+ it("deletes the name-proposed fallback a stale appointment resolves to", () => {
68
+ const target = folder({ mailboxId: "deleted-items", fullPath: "Deleted" });
69
+ const guard = guardFolderDeletion(
70
+ target,
71
+ [target],
72
+ [{ role: "Trash", source: "Stale", mailboxId: "deleted-items" }],
73
+ );
74
+ assert.deepEqual(guard, { deletable: true });
75
+ });
76
+
77
+ it("blocks the flagged folder a stale appointment falls back to", () => {
78
+ const target = folder({
79
+ mailboxId: "server-trash",
80
+ fullPath: "Trash",
81
+ specialUse: ["Trash"],
82
+ });
83
+ const guard = guardFolderDeletion(
84
+ target,
85
+ [target],
86
+ [{ role: "Trash", source: "Stale", mailboxId: "server-trash" }],
87
+ );
88
+ assert.equal(guard.deletable, false);
89
+ assert.equal(guard.reason, "role");
90
+ assert.match(guard.message ?? "", /Trash/);
91
+ });
92
+
93
+ it("reads the flag for the role the entry names, not any flag the folder has", () => {
94
+ const target = folder({
95
+ mailboxId: "archive",
96
+ fullPath: "Archive",
97
+ specialUse: ["Archive"],
98
+ });
99
+ const guard = guardFolderDeletion(
100
+ target,
101
+ [target],
102
+ [{ role: "Trash", source: "Stale", mailboxId: "archive" }],
103
+ );
104
+ assert.deepEqual(guard, { deletable: true });
105
+ });
106
+
46
107
  it("blocks a folder that has subfolders", () => {
47
108
  const target = folder({ mailboxId: "work", fullPath: "Work" });
48
109
  const child = folder({ mailboxId: "sub", fullPath: "Work/Shop" });
@@ -62,14 +123,18 @@ describe("guardFolderDeletion", () => {
62
123
  const guard = guardFolderDeletion(
63
124
  target,
64
125
  [target, other],
65
- [{ role: "Archive", mailboxId: "other-box" }],
126
+ [{ role: "Archive", source: "Appointed", mailboxId: "other-box" }],
66
127
  );
67
128
  assert.deepEqual(guard, { deletable: true });
68
129
  });
69
130
 
70
131
  it("ignores an unfilled role appointment with no mailbox", () => {
71
132
  const target = folder({ mailboxId: "r", fullPath: "Receipts" });
72
- const guard = guardFolderDeletion(target, [target], [{ role: "Junk" }]);
133
+ const guard = guardFolderDeletion(
134
+ target,
135
+ [target],
136
+ [{ role: "Junk", source: "None" }],
137
+ );
73
138
  assert.equal(guard.deletable, true);
74
139
  });
75
140
  });
@@ -106,20 +171,42 @@ describe("hasChildFolders", () => {
106
171
  });
107
172
  });
108
173
 
109
- describe("appointedRole", () => {
174
+ describe("vouchedRole", () => {
110
175
  it("returns the role a mailbox fills", () => {
111
176
  assert.equal(
112
- appointedRole("a", [{ role: "Trash", mailboxId: "a" }]),
177
+ vouchedRole({ mailboxId: "a" }, [
178
+ { role: "Trash", source: "Appointed", mailboxId: "a" },
179
+ ]),
113
180
  "Trash",
114
181
  );
115
182
  });
116
183
 
117
184
  it("returns undefined when the mailbox fills no role", () => {
118
185
  assert.equal(
119
- appointedRole("a", [{ role: "Trash", mailboxId: "b" }]),
186
+ vouchedRole({ mailboxId: "a" }, [
187
+ { role: "Trash", source: "Appointed", mailboxId: "b" },
188
+ ]),
189
+ undefined,
190
+ );
191
+ });
192
+
193
+ it("returns undefined for a role nobody vouched for", () => {
194
+ assert.equal(
195
+ vouchedRole({ mailboxId: "a" }, [
196
+ { role: "Trash", source: "Proposed", mailboxId: "a" },
197
+ ]),
120
198
  undefined,
121
199
  );
122
200
  });
201
+
202
+ it("returns the role a stale entry falls back to when the server flags it", () => {
203
+ assert.equal(
204
+ vouchedRole({ mailboxId: "a", specialUse: ["Trash"] }, [
205
+ { role: "Trash", source: "Stale", mailboxId: "a" },
206
+ ]),
207
+ "Trash",
208
+ );
209
+ });
123
210
  });
124
211
 
125
212
  describe("excludeFolder", () => {
@@ -1,3 +1,5 @@
1
+ import type { RemitImapFolderAppointment } from "@remit/api-http-client/types.gen.ts";
2
+
1
3
  export const MOVE_BATCH_SIZE = 100;
2
4
 
3
5
  export interface FolderNode {
@@ -5,10 +7,16 @@ export interface FolderNode {
5
7
  fullPath: string;
6
8
  hierarchyDelimiter: string;
7
9
  messageCount: number;
10
+ /** RFC 6154 SPECIAL-USE flags, bare (`Trash`, not `\Trash`). */
11
+ specialUse?: readonly string[];
8
12
  }
9
13
 
14
+ /** Where a role's answer came from. */
15
+ export type RoleAppointmentSource = RemitImapFolderAppointment["source"];
16
+
10
17
  export interface RoleAppointment {
11
18
  role: string;
19
+ source: RoleAppointmentSource;
12
20
  mailboxId?: string | null;
13
21
  }
14
22
 
@@ -39,18 +47,48 @@ export const hasChildFolders = (
39
47
  );
40
48
  };
41
49
 
42
- /** The canonical role a mailbox is appointed to, or `undefined` when unfilled. */
43
- export const appointedRole = (
44
- mailboxId: string,
50
+ /**
51
+ * Sources that vouch for the mailbox they name: a person chose it, or the
52
+ * server flagged it. `Proposed` is a guess at a folder's name and vouches for
53
+ * nothing. `Reserved` is unreachable behind the inbox branch below and stays
54
+ * anyway — this set is a statement about evidence, not about branch order.
55
+ */
56
+ const VOUCHED_SOURCES: readonly RoleAppointmentSource[] = [
57
+ "Appointed",
58
+ "Flagged",
59
+ "Reserved",
60
+ ];
61
+
62
+ /**
63
+ * A `Stale` entry resolves to whatever the account would have had with no
64
+ * appointment at all, and that fallback can be the folder the server itself
65
+ * flags for the role. The entry no longer carries which it was, so read the
66
+ * flag off the folder: the mail server's designation is unchanged by the
67
+ * user's own choice going missing, and nothing behind this guard re-checks it.
68
+ * Flag values are the bare RFC 6154 names, identical to the canonical roles.
69
+ */
70
+ const vouchesFor = (
71
+ appointment: RoleAppointment,
72
+ folder: Pick<FolderNode, "mailboxId" | "specialUse">,
73
+ ): boolean => {
74
+ if (appointment.mailboxId !== folder.mailboxId) return false;
75
+ if (appointment.source === "Stale")
76
+ return folder.specialUse?.includes(appointment.role) ?? false;
77
+ return VOUCHED_SOURCES.includes(appointment.source);
78
+ };
79
+
80
+ /** The canonical role a folder is vouched for, or `undefined` when none is. */
81
+ export const vouchedRole = (
82
+ folder: Pick<FolderNode, "mailboxId" | "specialUse">,
45
83
  appointments: readonly RoleAppointment[],
46
- ): string | undefined =>
47
- appointments.find((a) => a.mailboxId != null && a.mailboxId === mailboxId)
48
- ?.role;
84
+ ): string | undefined => appointments.find((a) => vouchesFor(a, folder))?.role;
49
85
 
50
86
  /**
51
87
  * Why a folder can't be deleted, or `{ deletable: true }` when it can. The
52
- * inbox is reserved, a folder that fills a canonical role must be released
53
- * first, and a folder with subfolders must have them handled before it goes.
88
+ * inbox is reserved, a folder somebody vouched for in a canonical role must be
89
+ * released first, and a folder with subfolders must have them handled before it
90
+ * goes. A role a folder only fills because its name reads that way is no reason
91
+ * to keep the folder: the message would name a remedy that does not apply.
54
92
  */
55
93
  export function guardFolderDeletion(
56
94
  folder: FolderNode,
@@ -64,7 +102,7 @@ export function guardFolderDeletion(
64
102
  message: "The inbox can't be deleted.",
65
103
  };
66
104
 
67
- const role = appointedRole(folder.mailboxId, appointments);
105
+ const role = vouchedRole(folder, appointments);
68
106
  if (role)
69
107
  return {
70
108
  deletable: false,
@@ -0,0 +1,40 @@
1
+ import type { SelectionRestriction } from "@remit/ui";
2
+
3
+ /** The account a selection resolves to, and what that costs it. */
4
+ export interface SelectionAccountScope {
5
+ /** The one account holding every selected row, when there is one. */
6
+ accountId: string | undefined;
7
+ /** Which scope the selection spans more of than Move can take. */
8
+ restriction: SelectionRestriction | undefined;
9
+ /** Why Move is withheld, in the toolbar's own words. */
10
+ moveDisabledHint: string | undefined;
11
+ }
12
+
13
+ /**
14
+ * Move applies within one account, so every surface offering it over a
15
+ * selection asks the same question of the same fact — each row's own
16
+ * `accountId`, never `accountConfigId`, which every account of one user shares
17
+ * and so can never differ (#456). A row that carries no account of its own is
18
+ * the caller's to fill in from its list scope before it gets here.
19
+ */
20
+ export const resolveSelectionAccountScope = (
21
+ accountIds: Iterable<string | undefined>,
22
+ ): SelectionAccountScope => {
23
+ const distinct = new Set<string>();
24
+ for (const accountId of accountIds) {
25
+ if (accountId) distinct.add(accountId);
26
+ }
27
+ if (distinct.size > 1) {
28
+ return {
29
+ accountId: undefined,
30
+ restriction: "spansAccounts",
31
+ moveDisabledHint:
32
+ "Move only works within one account — clear selection or pick messages from a single account",
33
+ };
34
+ }
35
+ return {
36
+ accountId: distinct.size === 1 ? [...distinct][0] : undefined,
37
+ restriction: undefined,
38
+ moveDisabledHint: undefined,
39
+ };
40
+ };