@remit/backend 0.0.118 → 0.0.120

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.118",
3
+ "version": "0.0.120",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -23,6 +23,10 @@
23
23
  "types": "./src/handlers/folder-role-appointments.ts",
24
24
  "default": "./src/handlers/folder-role-appointments.ts"
25
25
  },
26
+ "./folder-role-labels": {
27
+ "types": "./src/handlers/folder-role-labels.ts",
28
+ "default": "./src/handlers/folder-role-labels.ts"
29
+ },
26
30
  "./organize": {
27
31
  "types": "./src/service/organize.ts",
28
32
  "default": "./src/service/organize.ts"
@@ -181,6 +181,44 @@ const clientHolding = (
181
181
  },
182
182
  }) as unknown as RemitClient;
183
183
 
184
+ /**
185
+ * The never-spam grant (#605) travels the same route every other flag does. The
186
+ * exclusivity against `blocked` is not asserted here: it lives in the repo's
187
+ * merge fold, the only place that sees both keys at once.
188
+ */
189
+ describe("AddressDetailOperations_updateAddress never-spam (#605)", () => {
190
+ it("forwards the grant to the merge patch and hands it back on the address", async () => {
191
+ const seen: FlagsMergePatch[] = [];
192
+ setClient(clientHolding(address({ flags: {} }), seen));
193
+
194
+ const response = await updateAddress(
195
+ updateContextFor({ flags: { neverSpam: { value: true, setAt: 20 } } }),
196
+ eventFor(SUB),
197
+ );
198
+
199
+ assert.deepEqual(seen, [{ neverSpam: { value: true, setAt: 20 } }]);
200
+ assert.deepEqual(response.flags.neverSpam, { value: true, setAt: 20 });
201
+ });
202
+
203
+ it("clears the grant by naming the key, the removal form every flag shares", async () => {
204
+ const seen: FlagsMergePatch[] = [];
205
+ setClient(
206
+ clientHolding(
207
+ address({ flags: { neverSpam: { value: true, setAt: 10 } } }),
208
+ seen,
209
+ ),
210
+ );
211
+
212
+ const response = await updateAddress(
213
+ updateContextFor({ clearFlags: ["neverSpam"] }),
214
+ eventFor(SUB),
215
+ );
216
+
217
+ assert.deepEqual(seen, [{ neverSpam: null }]);
218
+ assert.equal(response.flags.neverSpam, undefined);
219
+ });
220
+ });
221
+
184
222
  describe("AddressDetailOperations_updateAddress removing a flag (#615)", () => {
185
223
  it("clears the category override, which has no false-equivalent value to send", async () => {
186
224
  const seen: FlagsMergePatch[] = [];
@@ -0,0 +1,63 @@
1
+ import type { IAccountSettingRepository } from "@remit/data-ports";
2
+ import {
3
+ type CanonicalMailboxRoleValue,
4
+ composeFolderRoleAppointmentLabelName,
5
+ } from "@remit/data-ports/folder-role";
6
+ import { rebaseMailboxPath } from "@remit/data-ports/mailbox-name";
7
+ import { loadFolderAppointmentsForAccount } from "./folder-role-appointments.js";
8
+
9
+ /**
10
+ * The label row beside each role appointment records the path its folder had at
11
+ * the time (#887), so a folder another client later deletes can still be named
12
+ * back to the user. It is display only, and it lives here rather than beside
13
+ * the appointment writer because that writer is guarded as the only writer of
14
+ * the appointment row itself.
15
+ *
16
+ * A rename keeps every mailboxId, so an appointment survives it — but the path
17
+ * recorded beside it still names where the folder was. Move the labels with the
18
+ * branch, or a later third-party delete names a path the user has not seen
19
+ * since the rename.
20
+ *
21
+ * Driven by the settle, not by the intent: under D2 the row keeps the path the
22
+ * mail server holds until the rename lands, so a label moved when the intent
23
+ * was recorded would name a path that does not exist — permanently, if the
24
+ * rename then fails.
25
+ *
26
+ * The renamed folder is matched by id; its descendants are matched by the path
27
+ * each label already holds.
28
+ */
29
+ export const refreshFolderAppointmentLabels = async (
30
+ accountSetting: Pick<IAccountSettingRepository, "get" | "upsert">,
31
+ accountConfigId: string,
32
+ accountId: string,
33
+ renamed: { mailboxId: string; oldPath: string; newPath: string },
34
+ delimiter: string,
35
+ ): Promise<void> => {
36
+ const persisted = await loadFolderAppointmentsForAccount(
37
+ accountSetting,
38
+ accountConfigId,
39
+ accountId,
40
+ );
41
+ for (const [role, appointment] of persisted) {
42
+ const moved =
43
+ appointment.mailboxId === renamed.mailboxId
44
+ ? renamed.newPath
45
+ : appointment.lastKnownPath === undefined
46
+ ? undefined
47
+ : rebaseMailboxPath(
48
+ appointment.lastKnownPath,
49
+ renamed.oldPath,
50
+ renamed.newPath,
51
+ delimiter,
52
+ );
53
+ if (moved === undefined || moved === appointment.lastKnownPath) continue;
54
+ await accountSetting.upsert({
55
+ accountConfigId,
56
+ name: composeFolderRoleAppointmentLabelName(
57
+ accountId,
58
+ role as CanonicalMailboxRoleValue,
59
+ ),
60
+ value: { kind: "String", value: moved },
61
+ });
62
+ }
63
+ };
@@ -5,7 +5,7 @@ import { isPublicApiError } from "@remit/data-ports/errors";
5
5
  import { composeFolderRoleAppointmentLabelName } from "@remit/data-ports/folder-role";
6
6
  import { CanonicalMailboxRole, MailboxSyncStatus } from "@remit/domain-enums";
7
7
  import { assertMailboxSettled } from "./folder-role.js";
8
- import { applyMailboxPatch, type MailboxPatchClient } from "./mailbox.js";
8
+ import { refreshFolderAppointmentLabels } from "./folder-role-labels.js";
9
9
 
10
10
  const mailbox = (over: Partial<MailboxItem>): MailboxItem =>
11
11
  ({
@@ -68,7 +68,7 @@ describe("assertMailboxSettled", () => {
68
68
  });
69
69
  });
70
70
 
71
- describe("applyMailboxPatch — the appointment label follows a rename", () => {
71
+ describe("refreshFolderAppointmentLabels — the label follows a settled rename", () => {
72
72
  const labelName = composeFolderRoleAppointmentLabelName(
73
73
  "acc-1",
74
74
  CanonicalMailboxRole.Trash,
@@ -82,68 +82,68 @@ describe("applyMailboxPatch — the appointment label follows a rename", () => {
82
82
 
83
83
  const settingsFor = (
84
84
  rows: Record<string, string>,
85
- renamed = "INBOX",
86
- ): { store: Record<string, string>; client: MailboxPatchClient } => {
85
+ ): {
86
+ store: Record<string, string>;
87
+ accountSetting: Parameters<typeof refreshFolderAppointmentLabels>[0];
88
+ } => {
87
89
  const store: Record<string, string> = { ...rows };
88
- const client = {
89
- mailbox: {
90
- get: async () => mailbox({ fullPath: renamed }),
90
+ const accountSetting = {
91
+ get: async (_configId: string, name: string) =>
92
+ store[name] === undefined
93
+ ? undefined
94
+ : ({
95
+ name,
96
+ value: { kind: "String", value: store[name] },
97
+ } as AccountSettingItem),
98
+ upsert: async (item: AccountSettingItem) => {
99
+ if (item.value.kind === "String") store[item.name] = item.value.value;
100
+ return item;
91
101
  },
92
- mailboxQueue: {
93
- renameMailbox: async (mailboxId: string, newPath: string) =>
94
- mailbox({ mailboxId, fullPath: newPath }),
95
- },
96
- accountSetting: {
97
- get: async (_configId: string, name: string) =>
98
- store[name] === undefined
99
- ? undefined
100
- : ({
101
- name,
102
- value: { kind: "String", value: store[name] },
103
- } as AccountSettingItem),
104
- upsert: async (item: AccountSettingItem) => {
105
- if (item.value.kind === "String") store[item.name] = item.value.value;
106
- return item;
107
- },
108
- delete: async (_configId: string, name: string) => {
109
- delete store[name];
110
- },
111
- },
112
- } as unknown as MailboxPatchClient;
113
- return { store, client };
102
+ } as unknown as Parameters<typeof refreshFolderAppointmentLabels>[0];
103
+ return { store, accountSetting };
114
104
  };
115
105
 
116
- it("rewrites the recorded path for the folder that was renamed", async () => {
117
- const { store, client } = settingsFor(
118
- {
119
- [appointmentName(CanonicalMailboxRole.Trash)]: "mb-1",
120
- [labelName]: "INBOX/Prullenbak",
121
- },
122
- "INBOX/Prullenbak",
106
+ const settle = (
107
+ accountSetting: Parameters<typeof refreshFolderAppointmentLabels>[0],
108
+ renamed: { mailboxId: string; oldPath: string; newPath: string },
109
+ ) =>
110
+ refreshFolderAppointmentLabels(
111
+ accountSetting,
112
+ "cfg-1",
113
+ "acc-1",
114
+ renamed,
115
+ "/",
123
116
  );
124
117
 
125
- await applyMailboxPatch(client, "cfg-1", "mb-1", "acc-1", {
126
- fullPath: "INBOX/Verwijderd",
118
+ it("rewrites the recorded path for the folder that was renamed", async () => {
119
+ const { store, accountSetting } = settingsFor({
120
+ [appointmentName(CanonicalMailboxRole.Trash)]: "mb-1",
121
+ [labelName]: "INBOX/Prullenbak",
122
+ });
123
+
124
+ await settle(accountSetting, {
125
+ mailboxId: "mb-1",
126
+ oldPath: "INBOX/Prullenbak",
127
+ newPath: "INBOX/Verwijderd",
127
128
  });
128
129
 
129
130
  assert.equal(store[labelName], "INBOX/Verwijderd");
130
131
  });
131
132
 
132
- // IMAP RENAME moves the subtree in one command and `renameChildPaths`
133
- // rewrites every descendant row, so every label under the branch moves too.
133
+ // IMAP RENAME moves the subtree in one command, so every label under the
134
+ // branch moves with it.
134
135
  it("carries every appointed folder under the renamed branch with it", async () => {
135
- const { store, client } = settingsFor(
136
- {
137
- [appointmentName(CanonicalMailboxRole.Trash)]: "mb-trash",
138
- [labelName]: "INBOX/Prullenbak",
139
- [appointmentName(CanonicalMailboxRole.Sent)]: "mb-sent",
140
- [sentLabelName]: "INBOX/Verzonden",
141
- },
142
- "INBOX",
143
- );
136
+ const { store, accountSetting } = settingsFor({
137
+ [appointmentName(CanonicalMailboxRole.Trash)]: "mb-trash",
138
+ [labelName]: "INBOX/Prullenbak",
139
+ [appointmentName(CanonicalMailboxRole.Sent)]: "mb-sent",
140
+ [sentLabelName]: "INBOX/Verzonden",
141
+ });
144
142
 
145
- await applyMailboxPatch(client, "cfg-1", "mb-1", "acc-1", {
146
- fullPath: "Mail",
143
+ await settle(accountSetting, {
144
+ mailboxId: "mb-1",
145
+ oldPath: "INBOX",
146
+ newPath: "Mail",
147
147
  });
148
148
 
149
149
  assert.equal(store[labelName], "Mail/Prullenbak");
@@ -151,32 +151,30 @@ describe("applyMailboxPatch — the appointment label follows a rename", () => {
151
151
  });
152
152
 
153
153
  it("leaves a folder outside the renamed branch alone", async () => {
154
- const { store, client } = settingsFor(
155
- {
156
- [appointmentName(CanonicalMailboxRole.Trash)]: "mb-trash",
157
- [labelName]: "Archief/Prullenbak",
158
- },
159
- "INBOX",
160
- );
154
+ const { store, accountSetting } = settingsFor({
155
+ [appointmentName(CanonicalMailboxRole.Trash)]: "mb-trash",
156
+ [labelName]: "Archief/Prullenbak",
157
+ });
161
158
 
162
- await applyMailboxPatch(client, "cfg-1", "mb-1", "acc-1", {
163
- fullPath: "Mail",
159
+ await settle(accountSetting, {
160
+ mailboxId: "mb-1",
161
+ oldPath: "INBOX",
162
+ newPath: "Mail",
164
163
  });
165
164
 
166
165
  assert.equal(store[labelName], "Archief/Prullenbak");
167
166
  });
168
167
 
169
168
  it("never rebases a sibling that merely shares the prefix", async () => {
170
- const { store, client } = settingsFor(
171
- {
172
- [appointmentName(CanonicalMailboxRole.Trash)]: "mb-trash",
173
- [labelName]: "INBOXES/Prullenbak",
174
- },
175
- "INBOX",
176
- );
169
+ const { store, accountSetting } = settingsFor({
170
+ [appointmentName(CanonicalMailboxRole.Trash)]: "mb-trash",
171
+ [labelName]: "INBOXES/Prullenbak",
172
+ });
177
173
 
178
- await applyMailboxPatch(client, "cfg-1", "mb-1", "acc-1", {
179
- fullPath: "Mail",
174
+ await settle(accountSetting, {
175
+ mailboxId: "mb-1",
176
+ oldPath: "INBOX",
177
+ newPath: "Mail",
180
178
  });
181
179
 
182
180
  assert.equal(store[labelName], "INBOXES/Prullenbak");
@@ -0,0 +1,247 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { MailboxItem } from "@remit/data-ports";
4
+ import { MailboxSyncStatus } from "@remit/domain-enums";
5
+ import { applyMailboxPatch, type MailboxPatchClient } from "./mailbox.js";
6
+
7
+ const ACCOUNT_ID = "acc-1";
8
+ const CONFIG_ID = "cfg-1";
9
+
10
+ const mailbox = (over: Partial<MailboxItem> = {}): MailboxItem =>
11
+ ({
12
+ mailboxId: "mbx-1",
13
+ accountId: ACCOUNT_ID,
14
+ fullPath: "Work",
15
+ hierarchyDelimiter: "/",
16
+ syncStatus: MailboxSyncStatus.synced,
17
+ ...over,
18
+ }) as MailboxItem;
19
+
20
+ const clientOver = (
21
+ row: MailboxItem,
22
+ others: MailboxItem[] = [],
23
+ ): {
24
+ client: MailboxPatchClient;
25
+ renames: { mailboxId: string; newPath: string }[];
26
+ dismissals: string[];
27
+ settings: string[];
28
+ } => {
29
+ const renames: { mailboxId: string; newPath: string }[] = [];
30
+ const dismissals: string[] = [];
31
+ const settings: string[] = [];
32
+ const client: MailboxPatchClient = {
33
+ mailbox: {
34
+ get: async () => row,
35
+ listAllByAccount: async () => [row, ...others],
36
+ },
37
+ mailboxQueue: {
38
+ renameMailbox: async (mailboxId, newPath) => {
39
+ renames.push({ mailboxId, newPath });
40
+ return mailbox({ pendingPath: newPath });
41
+ },
42
+ dismissMailboxIntent: async (mailboxId) => {
43
+ dismissals.push(mailboxId);
44
+ return mailbox();
45
+ },
46
+ },
47
+ accountSetting: {
48
+ get: async () => null,
49
+ upsert: async (item) => {
50
+ settings.push(item.name);
51
+ return item as never;
52
+ },
53
+ delete: async (_configId, name) => {
54
+ settings.push(name);
55
+ },
56
+ },
57
+ };
58
+ return { client, renames, dismissals, settings };
59
+ };
60
+
61
+ const patch = (
62
+ client: MailboxPatchClient,
63
+ body: Record<string, unknown>,
64
+ ): Promise<MailboxItem> =>
65
+ applyMailboxPatch(client, CONFIG_ID, "mbx-1", ACCOUNT_ID, body);
66
+
67
+ const refusedWith = (message: string) => (error: unknown) =>
68
+ (error as { statusCode?: number }).statusCode === 400 &&
69
+ (error as Error).message === message;
70
+
71
+ describe("applyMailboxPatch — renames that can never succeed (D4, D5)", () => {
72
+ it("refuses to rename the inbox", async () => {
73
+ const { client, renames } = clientOver(mailbox({ fullPath: "INBOX" }));
74
+
75
+ await assert.rejects(
76
+ patch(client, { fullPath: "Postvak" }),
77
+ refusedWith("The inbox can't be renamed."),
78
+ );
79
+ assert.equal(renames.length, 0);
80
+ });
81
+
82
+ it("refuses a target that canonicalizes to the inbox", async () => {
83
+ const { client, renames } = clientOver(mailbox());
84
+
85
+ await assert.rejects(
86
+ patch(client, { fullPath: "inbox" }),
87
+ refusedWith("“INBOX” is reserved for the inbox."),
88
+ );
89
+ assert.equal(renames.length, 0);
90
+ });
91
+
92
+ it("refuses an empty target", async () => {
93
+ const { client, renames } = clientOver(mailbox());
94
+
95
+ await assert.rejects(
96
+ patch(client, { fullPath: " " }),
97
+ refusedWith("A folder needs a name."),
98
+ );
99
+ assert.equal(renames.length, 0);
100
+ });
101
+
102
+ it("refuses a target another folder already holds", async () => {
103
+ const { client, renames } = clientOver(mailbox(), [
104
+ mailbox({ mailboxId: "mbx-2", fullPath: "Projects" }),
105
+ ]);
106
+
107
+ await assert.rejects(
108
+ patch(client, { fullPath: "Projects" }),
109
+ refusedWith("A folder named “Projects” is already there."),
110
+ );
111
+ assert.equal(renames.length, 0);
112
+ });
113
+
114
+ /**
115
+ * The sweep reads a folder whose leaf name is a role's conventional name but
116
+ * which lacks the server's flag as a duplicate, and deletes its row — under
117
+ * D8, with the folder's mail — as soon as another folder holds the flag. A
118
+ * rename to one of those settles `synced` and is then reaped.
119
+ */
120
+ for (const [name, leaf] of [
121
+ ["Archive", "Archive"],
122
+ ["Trash", "Trash"],
123
+ ["Drafts", "Drafts"],
124
+ ["Sent", "Sent"],
125
+ ["Junk", "Spam"],
126
+ ] as const) {
127
+ it(`refuses a rename to the reserved ${name} name`, async () => {
128
+ const { client, renames } = clientOver(mailbox());
129
+
130
+ await assert.rejects(
131
+ patch(client, { fullPath: `Work/${leaf}` }),
132
+ refusedWith(
133
+ `“${leaf}” is reserved for a system folder. Pick another name.`,
134
+ ),
135
+ );
136
+ assert.equal(renames.length, 0);
137
+ });
138
+ }
139
+
140
+ it("refuses a target another rename is already on its way to", async () => {
141
+ // A path a rename is aiming at is as taken as one a folder sits at (D2).
142
+ // Two renames onto one target both settle, both write it, and the sweep
143
+ // then reaps one row — with its mail — and inserts a duplicate.
144
+ const { client, renames } = clientOver(mailbox(), [
145
+ mailbox({
146
+ mailboxId: "mbx-2",
147
+ fullPath: "Admin",
148
+ syncStatus: MailboxSyncStatus.pending,
149
+ pendingPath: "Projects",
150
+ }),
151
+ ]);
152
+
153
+ await assert.rejects(
154
+ patch(client, { fullPath: "Projects" }),
155
+ refusedWith("“Admin” is already being renamed to “Projects”."),
156
+ );
157
+ assert.equal(renames.length, 0);
158
+ });
159
+
160
+ it("lets a failed rename's stale target go to somebody else", async () => {
161
+ // `failed` keeps its target so the client can name what the rename was
162
+ // aiming at and offer a retry (T6), and nothing clears it until the user
163
+ // retries or dismisses. Honouring that claim would tell everyone else the
164
+ // path is taken by a rename that is not happening, with nothing to wait
165
+ // for and no way to find out.
166
+ const { client, renames } = clientOver(mailbox(), [
167
+ mailbox({
168
+ mailboxId: "mbx-2",
169
+ fullPath: "Admin",
170
+ syncStatus: MailboxSyncStatus.failed,
171
+ pendingPath: "Projects",
172
+ }),
173
+ ]);
174
+
175
+ await patch(client, { fullPath: "Projects" });
176
+
177
+ assert.deepEqual(renames, [{ mailboxId: "mbx-1", newPath: "Projects" }]);
178
+ });
179
+
180
+ it("lets a retry aim at the target its own failed rename recorded", async () => {
181
+ const { client, renames } = clientOver(
182
+ mailbox({
183
+ syncStatus: MailboxSyncStatus.failed,
184
+ pendingPath: "Projects",
185
+ }),
186
+ );
187
+
188
+ await patch(client, { fullPath: "Projects" });
189
+
190
+ assert.deepEqual(renames, [{ mailboxId: "mbx-1", newPath: "Projects" }]);
191
+ });
192
+
193
+ it("records the intent for an ordinary target", async () => {
194
+ const { client, renames } = clientOver(mailbox());
195
+
196
+ const renamed = await patch(client, { fullPath: "Projects" });
197
+
198
+ assert.deepEqual(renames, [{ mailboxId: "mbx-1", newPath: "Projects" }]);
199
+ assert.equal(renamed.pendingPath, "Projects");
200
+ });
201
+ });
202
+
203
+ describe("applyMailboxPatch — dismissing a failed intent (T10, T11)", () => {
204
+ it("reads a target equal to the confirmed path as a dismissal, not a rename", async () => {
205
+ const { client, renames, dismissals } = clientOver(
206
+ mailbox({
207
+ syncStatus: MailboxSyncStatus.failed,
208
+ pendingPath: "Projects",
209
+ }),
210
+ );
211
+
212
+ const dismissed = await patch(client, { fullPath: "Work" });
213
+
214
+ assert.deepEqual(dismissals, ["mbx-1"]);
215
+ assert.equal(renames.length, 0, "nothing is enqueued for a dismissal");
216
+ assert.equal(dismissed.syncStatus, MailboxSyncStatus.synced);
217
+ });
218
+
219
+ it("dismisses even where the folder's own name is reserved", async () => {
220
+ // The refusals gate a rename's target. Keeping the name a folder already
221
+ // has is not one, and a folder called `Archive` must still be able to
222
+ // dismiss a rename that failed.
223
+ const { client, dismissals } = clientOver(
224
+ mailbox({ fullPath: "Archive", syncStatus: MailboxSyncStatus.failed }),
225
+ );
226
+
227
+ await patch(client, { fullPath: "Archive" });
228
+
229
+ assert.deepEqual(dismissals, ["mbx-1"]);
230
+ });
231
+ });
232
+
233
+ describe("applyMailboxPatch — an override-only PATCH", () => {
234
+ it("touches no mutation state", async () => {
235
+ const { client, renames, dismissals, settings } = clientOver(mailbox());
236
+
237
+ await patch(client, { muted: true });
238
+
239
+ assert.equal(renames.length, 0);
240
+ assert.equal(dismissals.length, 0);
241
+ assert.equal(
242
+ settings.length > 0,
243
+ true,
244
+ "the override row is still written",
245
+ );
246
+ });
247
+ });
@@ -3,12 +3,13 @@ import type {
3
3
  RenameMailboxInput,
4
4
  } from "@remit/api-openapi-types";
5
5
  import type { IAccountSettingRepository, MailboxItem } from "@remit/data-ports";
6
- import { ForbiddenError, NotFoundError } from "@remit/data-ports/errors";
7
6
  import {
8
- type CanonicalMailboxRoleValue,
9
- composeFolderRoleAppointmentLabelName,
10
- } from "@remit/data-ports/folder-role";
11
- import { rebaseMailboxPath } from "@remit/data-ports/mailbox-name";
7
+ BadRequestError,
8
+ ForbiddenError,
9
+ NotFoundError,
10
+ } from "@remit/data-ports/errors";
11
+ import { isReservedFolderName } from "@remit/data-ports/folder-role";
12
+ import { mailboxLeafName } from "@remit/data-ports/mailbox-name";
12
13
  import { MailboxSyncStatus, MessageSystemFlag } from "@remit/domain-enums";
13
14
  import type { APIGatewayProxyEvent } from "aws-lambda";
14
15
  import { getAccountConfigIdFromEvent } from "../auth.js";
@@ -30,7 +31,6 @@ import {
30
31
  type MailboxOverrides,
31
32
  } from "./account-overrides.js";
32
33
  import { assertAccountOwnership } from "./account-ownership.js";
33
- import { loadFolderAppointmentsForAccount } from "./folder-role-appointments.js";
34
34
 
35
35
  /**
36
36
  * The mute flag and the display-name override are user preferences that live
@@ -66,6 +66,7 @@ export const pickMailboxOverrideChanges = (
66
66
  export interface MailboxPatchClient {
67
67
  mailbox: {
68
68
  get(accountId: string, mailboxId: string): Promise<MailboxItem>;
69
+ listAllByAccount(accountId: string): Promise<MailboxItem[]>;
69
70
  };
70
71
  mailboxQueue: {
71
72
  renameMailbox(
@@ -73,53 +74,80 @@ export interface MailboxPatchClient {
73
74
  newPath: string,
74
75
  accountId: string,
75
76
  ): Promise<MailboxItem>;
77
+ dismissMailboxIntent(
78
+ mailboxId: string,
79
+ accountId: string,
80
+ ): Promise<MailboxItem>;
76
81
  };
77
82
  accountSetting: Pick<IAccountSettingRepository, "get" | "upsert" | "delete">;
78
83
  }
79
84
 
80
85
  /**
81
- * A reader-side rename keeps every mailboxId, so the appointments survive it —
82
- * but the paths recorded beside them (#887) would still name where the folders
83
- * were before. Move the labels with the branch, or a later third-party delete
84
- * names a path the user has not seen since the rename.
86
+ * Refuse a rename that no retry of the same request could ever satisfy (D4).
85
87
  *
86
- * The renamed folder is matched by id; its descendants are matched by the path
87
- * each label already holds, which is the path their rows carried until the
88
- * rename intent rewrote them.
88
+ * `INBOX` is refused at both ends: renaming it moves its mail to the new name
89
+ * and leaves an empty INBOX behind, and renaming anything *to* it collides with
90
+ * the one name RFC 3501 reserves (D5).
91
+ *
92
+ * A reserved leaf name is refused for a different reason. The mailbox sweep
93
+ * reads a folder whose leaf name is a role's conventional name but which lacks
94
+ * the server's flag as a duplicate, and deletes its row — under D8, with the
95
+ * folder's mail — as soon as another folder holds the flag. A rename to such a
96
+ * name settles `synced` and is then reaped. Refusing at the API is the same
97
+ * class of check as the rest of D4 and does not require teaching that heuristic
98
+ * about renames.
89
99
  */
90
- const refreshAppointmentLabels = async (
91
- accountSetting: Pick<IAccountSettingRepository, "get" | "upsert">,
92
- accountConfigId: string,
100
+ const assertRenameTargetAllowed = async (
101
+ client: Pick<MailboxPatchClient, "mailbox">,
93
102
  accountId: string,
94
- renamed: { mailboxId: string; oldPath: string; newPath: string },
95
- delimiter: string,
103
+ before: MailboxItem,
104
+ target: string,
96
105
  ): Promise<void> => {
97
- const persisted = await loadFolderAppointmentsForAccount(
98
- accountSetting,
99
- accountConfigId,
100
- accountId,
101
- );
102
- for (const [role, appointment] of persisted) {
103
- const moved =
104
- appointment.mailboxId === renamed.mailboxId
105
- ? renamed.newPath
106
- : appointment.lastKnownPath === undefined
107
- ? undefined
108
- : rebaseMailboxPath(
109
- appointment.lastKnownPath,
110
- renamed.oldPath,
111
- renamed.newPath,
112
- delimiter,
113
- );
114
- if (moved === undefined || moved === appointment.lastKnownPath) continue;
115
- await accountSetting.upsert({
116
- accountConfigId,
117
- name: composeFolderRoleAppointmentLabelName(
118
- accountId,
119
- role as CanonicalMailboxRoleValue,
120
- ),
121
- value: { kind: "String", value: moved },
122
- });
106
+ if (before.fullPath.toUpperCase() === "INBOX") {
107
+ throw new BadRequestError("The inbox can't be renamed.");
108
+ }
109
+ if (target.trim().length === 0) {
110
+ throw new BadRequestError("A folder needs a name.");
111
+ }
112
+ if (target.toUpperCase() === "INBOX") {
113
+ throw new BadRequestError("“INBOX” is reserved for the inbox.");
114
+ }
115
+
116
+ const leaf = mailboxLeafName({
117
+ fullPath: target,
118
+ hierarchyDelimiter: before.hierarchyDelimiter,
119
+ });
120
+ if (isReservedFolderName(leaf)) {
121
+ throw new BadRequestError(
122
+ `“${leaf}” is reserved for a system folder. Pick another name.`,
123
+ );
124
+ }
125
+
126
+ // A path a rename is on its way to is as taken as one a folder already sits
127
+ // at (D2). Reading `fullPath` alone accepts a second rename onto a target
128
+ // another is already recorded for: both settle, both write the same path,
129
+ // and the sweep then reaps one row — with its mail — and inserts a duplicate
130
+ // for the survivor's path. The account's folders are few and the sweep
131
+ // already reads them all, so one pass answers both questions.
132
+ const folders = await client.mailbox.listAllByAccount(accountId);
133
+ for (const folder of folders) {
134
+ if (folder.mailboxId === before.mailboxId) continue;
135
+ if (folder.fullPath === target) {
136
+ throw new BadRequestError(`A folder named “${target}” is already there.`);
137
+ }
138
+ // `pending` only. A failed rename keeps its target so the client can name
139
+ // what it was aiming at and offer a retry (T6), and nothing clears it
140
+ // until the user retries or dismisses — so honouring a `failed` row's
141
+ // claim would tell everyone else the path is taken by a rename that is
142
+ // not happening, with no way to find that out and nothing to wait for.
143
+ if (
144
+ folder.syncStatus === MailboxSyncStatus.pending &&
145
+ folder.pendingPath === target
146
+ ) {
147
+ throw new BadRequestError(
148
+ `“${folder.fullPath}” is already being renamed to “${target}”.`,
149
+ );
150
+ }
123
151
  }
124
152
  };
125
153
 
@@ -130,6 +158,11 @@ const refreshAppointmentLabels = async (
130
158
  * (the recorded subtree intent + MAILBOX_RENAME event) — only when `fullPath`
131
159
  * is present. An override-only PATCH therefore never calls
132
160
  * `mailboxQueue.renameMailbox`.
161
+ *
162
+ * A `fullPath` equal to the row's confirmed one is not a rename: it is the
163
+ * dismissal of a failed intent (T10, T11), which is how a user keeps the name
164
+ * the folder already has without new API surface. It is checked first, because
165
+ * from `failed` it is the one route out that enqueues nothing.
133
166
  */
134
167
  export const applyMailboxPatch = async (
135
168
  client: MailboxPatchClient,
@@ -161,26 +194,17 @@ export const applyMailboxPatch = async (
161
194
  return client.mailbox.get(accountId, mailboxId);
162
195
  }
163
196
 
164
- // Read before the rename: the labels of the folders under this one are
165
- // rebased off the path it is leaving, which the row no longer carries after.
166
197
  const before = await client.mailbox.get(accountId, mailboxId);
167
- const renamed = await client.mailboxQueue.renameMailbox(
168
- mailboxId,
169
- fullPath,
170
- accountId,
171
- );
172
- await refreshAppointmentLabels(
173
- client.accountSetting,
174
- accountConfigId,
175
- accountId,
176
- {
177
- mailboxId,
178
- oldPath: before.fullPath,
179
- newPath: renamed.fullPath,
180
- },
181
- before.hierarchyDelimiter,
182
- );
183
- return renamed;
198
+ if (fullPath === before.fullPath) {
199
+ return client.mailboxQueue.dismissMailboxIntent(mailboxId, accountId);
200
+ }
201
+
202
+ await assertRenameTargetAllowed(client, accountId, before, fullPath);
203
+
204
+ // The row keeps the path the server holds until the rename settles (D2), so
205
+ // nothing that names a folder by path moves here — the appointment labels
206
+ // recorded beside each role (#887) included. They move with the settle.
207
+ return client.mailboxQueue.renameMailbox(mailboxId, fullPath, accountId);
184
208
  };
185
209
 
186
210
  /**