@remit/web-client 0.0.193 → 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.193",
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": {
@@ -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,