@remit/backend 0.0.120 → 0.0.121

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.120",
3
+ "version": "0.0.121",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -5,8 +5,10 @@ import type {
5
5
  } from "@remit/api-openapi-types";
6
6
  import {
7
7
  deriveFilterTtl,
8
+ FILTER_NO_ACTION,
8
9
  type FilterItem,
9
10
  type IFilterAnchorTransaction,
11
+ type IMailboxRepository,
10
12
  type UpdateFilterInput,
11
13
  } from "@remit/data-ports";
12
14
  import { BadRequestError } from "@remit/data-ports/errors";
@@ -22,6 +24,7 @@ import type {
22
24
  OperationHandler,
23
25
  } from "../types.js";
24
26
  import { assertAccountOwnership } from "./account-ownership.js";
27
+ import { assertMailboxInAccount, assertMailboxSettled } from "./mailbox.js";
25
28
 
26
29
  /**
27
30
  * Minimal filter-service surface the create handler needs — declared as a
@@ -222,6 +225,25 @@ export const createFilterWithAnchor = async (
222
225
  );
223
226
  };
224
227
 
228
+ /**
229
+ * A filter's `actionMailboxId` is a durable reference, so its target has to be
230
+ * a folder the mail server has settled (D12, first row) — 422 otherwise. The
231
+ * sentinel means "no move action" and binds to nothing, so it is not gated,
232
+ * and neither is a filter that touches the field at all.
233
+ */
234
+ const assertActionMailboxSettled = async (
235
+ client: { mailbox: Pick<IMailboxRepository, "get"> },
236
+ accountId: string,
237
+ actionMailboxId: string | undefined,
238
+ ): Promise<void> => {
239
+ if (actionMailboxId === undefined || actionMailboxId === FILTER_NO_ACTION) {
240
+ return;
241
+ }
242
+ const target = await client.mailbox.get(accountId, actionMailboxId);
243
+ assertMailboxInAccount(target, accountId, "act");
244
+ assertMailboxSettled(target);
245
+ };
246
+
225
247
  export const FilterOperations: Record<
226
248
  FilterOperationIds,
227
249
  OperationHandler<FilterOperationIds>
@@ -265,6 +287,7 @@ export const FilterOperations: Record<
265
287
  const client = await getClient();
266
288
  const account = await client.account.get(accountId);
267
289
  assertAccountOwnership(account, accountConfigId, "act");
290
+ await assertActionMailboxSettled(client, accountId, input.actionMailboxId);
268
291
 
269
292
  const filter = await createFilterWithAnchor(
270
293
  {
@@ -316,6 +339,7 @@ export const FilterDetailOperations: Record<
316
339
 
317
340
  const { filter } = client;
318
341
  const patch = pickFilterUpdate(body as Partial<UpdateFilterRequestBody>);
342
+ await assertActionMailboxSettled(client, accountId, patch.actionMailboxId);
319
343
  const touchesScopeOrExpiry =
320
344
  Object.hasOwn(patch, "scope") || Object.hasOwn(patch, "expiresAt");
321
345
  const resolvedPatch: Partial<UpdateFilterInput> = touchesScopeOrExpiry
@@ -4,8 +4,8 @@ import type { AccountSettingItem, MailboxItem } from "@remit/data-ports";
4
4
  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
- import { assertMailboxSettled } from "./folder-role.js";
8
7
  import { refreshFolderAppointmentLabels } from "./folder-role-labels.js";
8
+ import { assertMailboxSettled } from "./mailbox.js";
9
9
 
10
10
  const mailbox = (over: Partial<MailboxItem>): MailboxItem =>
11
11
  ({
@@ -57,14 +57,41 @@ describe("assertMailboxSettled", () => {
57
57
  });
58
58
  });
59
59
 
60
- it("allows a settled folder, and one whose delete failed", () => {
61
- assert.doesNotThrow(() =>
62
- assertMailboxSettled(mailbox({ syncStatus: MailboxSyncStatus.synced })),
60
+ it("refuses a folder whose last change failed", () => {
61
+ // `failed` means the folder exists at `fullPath` and the last rename or
62
+ // delete did not land, so it is exactly as unready to be bound to as a
63
+ // folder mid-mutation — and the user has a retry or a dismissal to make
64
+ // before anything should point at it.
65
+ const error = caught(() =>
66
+ assertMailboxSettled(mailbox({ syncStatus: MailboxSyncStatus.failed })),
63
67
  );
68
+ assert.equal(publicErrorOf(error)?.code, "mailbox_not_settled");
69
+ });
70
+
71
+ it("answers 422, not 409", () => {
72
+ // 409 already means "this folder is being changed right now, refresh".
73
+ // This means "the folder you are pointing at has not settled yet", which
74
+ // is different copy and a different remedy (D4).
75
+ const error = caught(() =>
76
+ assertMailboxSettled(mailbox({ syncStatus: MailboxSyncStatus.pending })),
77
+ );
78
+ assert.equal((error as { statusCode?: number }).statusCode, 422);
79
+ });
80
+
81
+ it("words each state differently, because each has its own remedy", () => {
82
+ const messageFor = (syncStatus: MailboxItem["syncStatus"]) =>
83
+ (caught(() => assertMailboxSettled(mailbox({ syncStatus }))) as Error)
84
+ .message;
85
+
86
+ assert.match(messageFor(MailboxSyncStatus.pending), /hasn.t confirmed it/);
87
+ assert.match(messageFor(MailboxSyncStatus.deleting), /being deleted/);
88
+ assert.match(messageFor(MailboxSyncStatus.failed), /Retry or dismiss/);
89
+ });
90
+
91
+ it("allows a settled folder", () => {
64
92
  assert.doesNotThrow(() =>
65
- assertMailboxSettled(mailbox({ syncStatus: MailboxSyncStatus.failed })),
93
+ assertMailboxSettled(mailbox({ syncStatus: MailboxSyncStatus.synced })),
66
94
  );
67
- assert.doesNotThrow(() => assertMailboxSettled(mailbox({})));
68
95
  });
69
96
  });
70
97
 
@@ -2,9 +2,6 @@ import type {
2
2
  AppointFolderRoleInput,
3
3
  CanonicalMailboxRole,
4
4
  } from "@remit/api-openapi-types";
5
- import type { MailboxItem } from "@remit/data-ports";
6
- import { MailboxNotSettledError } from "@remit/data-ports/errors";
7
- import { MailboxSyncStatus } from "@remit/domain-enums";
8
5
  import type { APIGatewayProxyEvent } from "aws-lambda";
9
6
  import { getAccountConfigIdFromEvent } from "../auth.js";
10
7
  import { getClient } from "../service/data-client.js";
@@ -17,31 +14,7 @@ import {
17
14
  resolveAccountFolderAppointments,
18
15
  writeFolderRoleAppointment,
19
16
  } from "./folder-role-appointments.js";
20
- import { assertMailboxInAccount } from "./mailbox.js";
21
-
22
- /** Mailbox states in which the mail server has not settled the folder yet. */
23
- const UNSETTLED: ReadonlySet<string> = new Set([
24
- MailboxSyncStatus.pending,
25
- MailboxSyncStatus.deleting,
26
- ]);
27
-
28
- /**
29
- * Appointing a role to a folder the mail server is still creating or deleting
30
- * would bind the account's Trash to a folder that may never exist (D16 item 3,
31
- * imap-mutations R2: wait). A `\Noselect` container is refused by construction
32
- * — mailbox-sync keeps no row for one — so there is nothing to appoint.
33
- */
34
- export const assertMailboxSettled = (
35
- target: Pick<MailboxItem, "mailboxId" | "fullPath" | "syncStatus">,
36
- ): void => {
37
- const syncStatus = target.syncStatus;
38
- if (!syncStatus || !UNSETTLED.has(syncStatus)) return;
39
- throw new MailboxNotSettledError(
40
- `Mailbox ${target.fullPath} is not settled on the mail server yet`,
41
- target.mailboxId,
42
- syncStatus,
43
- );
44
- };
17
+ import { assertMailboxInAccount, assertMailboxSettled } from "./mailbox.js";
45
18
 
46
19
  /**
47
20
  * RFC 032 exclusive-folder-appointment (#976): the single write operation for
@@ -0,0 +1,111 @@
1
+ import type {
2
+ FilterItem,
3
+ IAccountSettingRepository,
4
+ IFilterRepository,
5
+ MailboxItem,
6
+ } from "@remit/data-ports";
7
+ import { FILTER_NO_ACTION } from "@remit/data-ports";
8
+ import { BadRequestError } from "@remit/data-ports/errors";
9
+ import type { CanonicalMailboxRoleValue } from "@remit/data-ports/folder-role";
10
+ import { loadFolderAppointmentsForAccount } from "./folder-role-appointments.js";
11
+
12
+ /**
13
+ * What a folder is bound to: the filters that move mail into it, and the
14
+ * canonical roles it has been appointed to.
15
+ */
16
+ export interface MailboxBindings {
17
+ filterNames: string[];
18
+ roles: CanonicalMailboxRoleValue[];
19
+ }
20
+
21
+ /**
22
+ * `filter.actionMailboxId` has no reverse index, so the filters are listed for
23
+ * the account config and matched — they are few and per-config, so this is not
24
+ * a mailbox scan. Role appointments are `account_setting` rows whose *value* is
25
+ * a mailboxId, which a `mailbox_id` column scan cannot find, so the existing
26
+ * per-account appointment map answers for them.
27
+ */
28
+ export const loadMailboxBindings = async (
29
+ client: {
30
+ filter: Pick<IFilterRepository, "listByAccountConfig">;
31
+ accountSetting: Pick<IAccountSettingRepository, "get">;
32
+ },
33
+ accountConfigId: string,
34
+ accountId: string,
35
+ mailboxId: string,
36
+ ): Promise<MailboxBindings> => {
37
+ const [filters, appointments] = await Promise.all([
38
+ client.filter.listByAccountConfig(accountConfigId),
39
+ loadFolderAppointmentsForAccount(
40
+ client.accountSetting,
41
+ accountConfigId,
42
+ accountId,
43
+ ),
44
+ ]);
45
+
46
+ const filterNames = filters
47
+ .filter(
48
+ (filter: FilterItem) =>
49
+ filter.actionMailboxId !== FILTER_NO_ACTION &&
50
+ filter.actionMailboxId === mailboxId,
51
+ )
52
+ .map((filter: FilterItem) => filter.name);
53
+
54
+ const roles: CanonicalMailboxRoleValue[] = [];
55
+ for (const [role, appointment] of appointments) {
56
+ if (appointment.mailboxId === mailboxId) {
57
+ roles.push(role as CanonicalMailboxRoleValue);
58
+ }
59
+ }
60
+
61
+ return { filterNames, roles };
62
+ };
63
+
64
+ const asList = (items: readonly string[]): string =>
65
+ items.length === 1
66
+ ? items[0]
67
+ : `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
68
+
69
+ /**
70
+ * Refuse a delete while any durable reference is bound to the folder (D16).
71
+ *
72
+ * This is what makes the settled-target rule total: a binding is only created
73
+ * against a settled folder and only removed by the user, so a `mailboxId` that
74
+ * names nothing never exists and no reader of a filter's `actionMailboxId` or a
75
+ * role appointment needs a missing-target branch. It removes code rather than
76
+ * adding a check.
77
+ *
78
+ * The alternatives both cost more than they look. `Filter` has no enabled flag
79
+ * and its `actionMailboxId` sentinel means "no move action", so clearing the
80
+ * target silently turns a move-filter into one that does something else;
81
+ * deleting the user's filters outright is not a decision a folder delete gets
82
+ * to make.
83
+ *
84
+ * The message **names** each binding rather than counting them, because a count
85
+ * is not actionable — and this fires on ordinary configurations, since pointing
86
+ * a filter at a folder is what filters are for.
87
+ */
88
+ export const assertNoBindings = (
89
+ mailbox: Pick<MailboxItem, "fullPath">,
90
+ bindings: MailboxBindings,
91
+ ): void => {
92
+ const clauses: string[] = [];
93
+ if (bindings.filterNames.length > 0) {
94
+ clauses.push(
95
+ `the ${bindings.filterNames.length === 1 ? "filter" : "filters"} ${asList(
96
+ bindings.filterNames.map((name) => `“${name}”`),
97
+ )}`,
98
+ );
99
+ }
100
+ if (bindings.roles.length > 0) {
101
+ clauses.push(
102
+ `the ${asList(bindings.roles)} ${
103
+ bindings.roles.length === 1 ? "role" : "roles"
104
+ }`,
105
+ );
106
+ }
107
+ if (clauses.length === 0) return;
108
+ throw new BadRequestError(
109
+ `“${mailbox.fullPath}” is used by ${asList(clauses)}.`,
110
+ );
111
+ };
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Every API write that binds a durable reference to a folder requires that
3
+ * folder to be settled (D12, first row; imap-mutations R2: wait). Driven
4
+ * through the real handlers rather than the helper, because the claim is that
5
+ * each call site is wired to it — a helper that nothing calls refuses nothing.
6
+ *
7
+ * The other half of the same invariant is the delete refusal (D16), in
8
+ * `mailbox.test.ts`: a binding is only created against a settled folder and
9
+ * only removed by the user, so a `mailboxId` that names nothing never exists.
10
+ */
11
+ import assert from "node:assert/strict";
12
+ import { afterEach, describe, it } from "node:test";
13
+ import type { MailboxItem } from "@remit/data-ports";
14
+ import { CanonicalMailboxRole, MailboxSyncStatus } from "@remit/domain-enums";
15
+ import type { APIGatewayProxyEvent } from "aws-lambda";
16
+ import type { Context } from "openapi-backend";
17
+ import { deriveAccountConfigId } from "../auth.js";
18
+ import {
19
+ _resetForTest,
20
+ type RemitClient,
21
+ setClient,
22
+ } from "../service/data-client.js";
23
+ import { FilterDetailOperations, FilterOperations } from "./filter.js";
24
+ import { FolderRoleOperations } from "./folder-role.js";
25
+ import { MessageBulkOperations } from "./message.js";
26
+
27
+ const SUB = "cognito-sub-365";
28
+ const ACCOUNT_ID = "acc-365";
29
+ const CONFIG_ID = deriveAccountConfigId(SUB);
30
+ const TARGET = "mbx-target";
31
+
32
+ const target = (syncStatus: MailboxItem["syncStatus"]): MailboxItem =>
33
+ ({
34
+ mailboxId: TARGET,
35
+ accountId: ACCOUNT_ID,
36
+ fullPath: "Receipts",
37
+ hierarchyDelimiter: "/",
38
+ syncStatus,
39
+ }) as MailboxItem;
40
+
41
+ interface Writes {
42
+ filters: unknown[];
43
+ filterPatches: unknown[];
44
+ appointments: unknown[];
45
+ moves: unknown[];
46
+ copies: unknown[];
47
+ }
48
+
49
+ const world = (syncStatus: MailboxItem["syncStatus"]): Writes => {
50
+ const writes: Writes = {
51
+ filters: [],
52
+ filterPatches: [],
53
+ appointments: [],
54
+ moves: [],
55
+ copies: [],
56
+ };
57
+ const row = target(syncStatus);
58
+
59
+ setClient({
60
+ account: {
61
+ get: async () => ({ accountId: ACCOUNT_ID, accountConfigId: CONFIG_ID }),
62
+ listAllByAccountConfig: async () => [{ accountId: ACCOUNT_ID }],
63
+ },
64
+ mailbox: {
65
+ get: async (_accountId: string, mailboxId: string | string[]) =>
66
+ Array.isArray(mailboxId)
67
+ ? mailboxId.map((id) => ({ mailboxId: id, accountId: ACCOUNT_ID }))
68
+ : row,
69
+ listAllByAccount: async () => [row],
70
+ },
71
+ message: {
72
+ get: async () => ({ mailboxId: "mbx-source" }),
73
+ },
74
+ filter: {
75
+ get: async () => ({ filterId: "flt-1", scope: "Standing" }),
76
+ update: async (...args: unknown[]) => {
77
+ writes.filterPatches.push(args);
78
+ return { filterId: "flt-1" };
79
+ },
80
+ listByAccountConfig: async () => [],
81
+ },
82
+ filterAnchorTransaction: {
83
+ createWithAnchor: async (input: unknown) => {
84
+ writes.filters.push(input);
85
+ return { filterId: "flt-1" };
86
+ },
87
+ },
88
+ accountSetting: {
89
+ get: async () => null,
90
+ upsert: async (item: unknown) => {
91
+ writes.appointments.push(item);
92
+ return item;
93
+ },
94
+ delete: async () => undefined,
95
+ },
96
+ messageMove: {
97
+ moveMessages: async (...args: unknown[]) => {
98
+ writes.moves.push(args);
99
+ },
100
+ copyMessages: async (...args: unknown[]) => {
101
+ writes.copies.push(args);
102
+ },
103
+ },
104
+ } as unknown as RemitClient);
105
+
106
+ return writes;
107
+ };
108
+
109
+ const event = {
110
+ requestContext: { authorizer: { claims: { sub: SUB } } },
111
+ } as unknown as APIGatewayProxyEvent;
112
+
113
+ const call = <T>(
114
+ handler: unknown,
115
+ request: Record<string, unknown>,
116
+ ): Promise<T> =>
117
+ (handler as (context: Context, event: APIGatewayProxyEvent) => Promise<T>)(
118
+ { request } as unknown as Context,
119
+ event,
120
+ );
121
+
122
+ const appoint = () =>
123
+ call(FolderRoleOperations.FolderRoleOperations_appointFolderRole, {
124
+ params: { accountId: ACCOUNT_ID, role: CanonicalMailboxRole.Archive },
125
+ requestBody: { mailboxId: TARGET },
126
+ });
127
+
128
+ const createFilter = () =>
129
+ call(FilterOperations.FilterOperations_createFilter, {
130
+ params: { accountId: ACCOUNT_ID },
131
+ requestBody: {
132
+ name: "Invoices",
133
+ scope: "Standing",
134
+ actionMailboxId: TARGET,
135
+ },
136
+ });
137
+
138
+ const updateFilter = () =>
139
+ call(FilterDetailOperations.FilterDetailOperations_updateFilter, {
140
+ params: { accountId: ACCOUNT_ID, filterId: "flt-1" },
141
+ requestBody: { actionMailboxId: TARGET },
142
+ });
143
+
144
+ const moveMessages = () =>
145
+ call(MessageBulkOperations.MessageBulkOperations_moveMessages, {
146
+ params: {},
147
+ requestBody: { messageIds: ["msg-1"], destinationMailboxId: TARGET },
148
+ });
149
+
150
+ const copyMessages = () =>
151
+ call(MessageBulkOperations.MessageBulkOperations_copyMessages, {
152
+ params: {},
153
+ requestBody: { messageIds: ["msg-1"], destinationMailboxId: TARGET },
154
+ });
155
+
156
+ const unready = (error: unknown): boolean =>
157
+ (error as { statusCode?: number }).statusCode === 422 &&
158
+ (error as { publicApiError?: { code?: string } }).publicApiError?.code ===
159
+ "mailbox_not_settled";
160
+
161
+ const SITES = [
162
+ ["appointing a role", appoint, (w: Writes) => w.appointments],
163
+ ["creating a filter", createFilter, (w: Writes) => w.filters],
164
+ ["updating a filter", updateFilter, (w: Writes) => w.filterPatches],
165
+ ["moving messages", moveMessages, (w: Writes) => w.moves],
166
+ ["copying messages", copyMessages, (w: Writes) => w.copies],
167
+ ] as const;
168
+
169
+ describe("a write that binds to a folder requires a settled folder", () => {
170
+ afterEach(() => {
171
+ _resetForTest();
172
+ });
173
+
174
+ for (const [what, run, written] of SITES) {
175
+ for (const state of [
176
+ MailboxSyncStatus.pending,
177
+ MailboxSyncStatus.deleting,
178
+ MailboxSyncStatus.failed,
179
+ ]) {
180
+ it(`refuses ${what} against a ${state} folder, writing nothing`, async () => {
181
+ const writes = world(state);
182
+
183
+ await assert.rejects(run(), unready);
184
+ assert.deepEqual(written(writes), []);
185
+ });
186
+ }
187
+
188
+ it(`accepts ${what} against a settled folder`, async () => {
189
+ const writes = world(MailboxSyncStatus.synced);
190
+
191
+ await run();
192
+ assert.equal(written(writes).length > 0, true);
193
+ });
194
+ }
195
+
196
+ it("never answers 409 from these paths", async () => {
197
+ // 409 belongs to the folder's own mutation losing a compare-and-set
198
+ // (#362, #363). Reusing it here would make "somebody else is changing this
199
+ // folder" and "the folder you are pointing at is not ready" the same
200
+ // answer, with different copy and different remedies behind it.
201
+ world(MailboxSyncStatus.pending);
202
+
203
+ await assert.rejects(
204
+ appoint(),
205
+ (error: unknown) => (error as { statusCode?: number }).statusCode !== 409,
206
+ );
207
+ });
208
+
209
+ it("does not gate clearing a role whose folder is on its way out", async () => {
210
+ const writes = world(MailboxSyncStatus.deleting);
211
+
212
+ await call(FolderRoleOperations.FolderRoleOperations_appointFolderRole, {
213
+ params: { accountId: ACCOUNT_ID, role: CanonicalMailboxRole.Archive },
214
+ requestBody: { mailboxId: null },
215
+ });
216
+
217
+ assert.deepEqual(
218
+ writes.appointments,
219
+ [],
220
+ "the row is deleted, not upserted",
221
+ );
222
+ });
223
+
224
+ it("does not gate a filter that applies no move action", async () => {
225
+ // `None` is the sentinel for "no move action", so it binds to nothing.
226
+ const writes = world(MailboxSyncStatus.deleting);
227
+
228
+ await call(FilterOperations.FilterOperations_createFilter, {
229
+ params: { accountId: ACCOUNT_ID },
230
+ requestBody: {
231
+ name: "Just a label",
232
+ scope: "Standing",
233
+ actionMailboxId: "None",
234
+ },
235
+ });
236
+
237
+ assert.equal(writes.filters.length, 1);
238
+ });
239
+ });
@@ -11,36 +11,7 @@ import {
11
11
  type RemitClient,
12
12
  setClient,
13
13
  } from "../service/data-client.js";
14
- import { excludeDeletingMailboxes, TrashOperations } from "./mailbox.js";
15
-
16
- const mailbox = (
17
- over: Partial<MailboxItem> & { mailboxId: string },
18
- ): MailboxItem =>
19
- ({ fullPath: over.mailboxId, ...over }) as unknown as MailboxItem;
20
-
21
- describe("excludeDeletingMailboxes", () => {
22
- it("drops a folder being deleted so it leaves the list before the worker reaps it", () => {
23
- const items = [
24
- mailbox({ mailboxId: "inbox", syncStatus: MailboxSyncStatus.synced }),
25
- mailbox({ mailboxId: "gone", syncStatus: MailboxSyncStatus.deleting }),
26
- mailbox({ mailboxId: "new", syncStatus: MailboxSyncStatus.pending }),
27
- ];
28
- assert.deepEqual(
29
- excludeDeletingMailboxes(items).map((m) => m.mailboxId),
30
- ["inbox", "new"],
31
- );
32
- });
33
-
34
- it("keeps a folder whose delete failed and was restored off `deleting`", () => {
35
- const items = [
36
- mailbox({ mailboxId: "restored", syncStatus: MailboxSyncStatus.failed }),
37
- ];
38
- assert.deepEqual(
39
- excludeDeletingMailboxes(items).map((m) => m.mailboxId),
40
- ["restored"],
41
- );
42
- });
43
- });
14
+ import { MailboxDetailOperations, TrashOperations } from "./mailbox.js";
44
15
 
45
16
  const SUB = "cognito-sub-887";
46
17
  const ACCOUNT_ID = "acc-887";
@@ -96,3 +67,166 @@ describe("TrashOperations_emptyTrash", () => {
96
67
  );
97
68
  });
98
69
  });
70
+
71
+ const deleteMailbox =
72
+ MailboxDetailOperations.MailboxDetailOperations_deleteMailbox as unknown as (
73
+ context: Context,
74
+ event: APIGatewayProxyEvent,
75
+ ) => Promise<{ statusCode: number }>;
76
+
77
+ const folder = (over: Partial<MailboxItem> = {}): MailboxItem =>
78
+ ({
79
+ mailboxId: "mbx-1",
80
+ accountId: ACCOUNT_ID,
81
+ fullPath: "Receipts",
82
+ hierarchyDelimiter: "/",
83
+ syncStatus: MailboxSyncStatus.synced,
84
+ ...over,
85
+ }) as MailboxItem;
86
+
87
+ interface DeleteWorld {
88
+ mailbox?: MailboxItem;
89
+ children?: MailboxItem[];
90
+ filters?: { name: string; actionMailboxId: string }[];
91
+ appointments?: Record<string, string>;
92
+ }
93
+
94
+ const callDelete = (
95
+ world: DeleteWorld,
96
+ ): { run: () => Promise<{ statusCode: number }>; deleted: string[] } => {
97
+ const deleted: string[] = [];
98
+ setClient({
99
+ account: {
100
+ get: async () => ({
101
+ accountId: ACCOUNT_ID,
102
+ accountConfigId: deriveAccountConfigId(SUB),
103
+ }),
104
+ },
105
+ mailbox: {
106
+ get: async () => world.mailbox ?? folder(),
107
+ findByPathPrefix: async () => world.children ?? [],
108
+ },
109
+ filter: {
110
+ listByAccountConfig: async () => world.filters ?? [],
111
+ },
112
+ accountSetting: {
113
+ get: async (_configId: string, name: string) => {
114
+ for (const [role, mailboxId] of Object.entries(
115
+ world.appointments ?? {},
116
+ )) {
117
+ if (name === `FolderRoleAppointment#${ACCOUNT_ID}#${role}`) {
118
+ return { value: { kind: "String", value: mailboxId } };
119
+ }
120
+ }
121
+ return null;
122
+ },
123
+ },
124
+ mailboxQueue: {
125
+ deleteMailbox: async (mailboxId: string) => {
126
+ deleted.push(mailboxId);
127
+ },
128
+ },
129
+ } as unknown as RemitClient);
130
+
131
+ return {
132
+ deleted,
133
+ run: () =>
134
+ deleteMailbox(
135
+ {
136
+ request: { params: { accountId: ACCOUNT_ID, mailboxId: "mbx-1" } },
137
+ } as unknown as Context,
138
+ {
139
+ requestContext: { authorizer: { claims: { sub: SUB } } },
140
+ } as unknown as APIGatewayProxyEvent,
141
+ ),
142
+ };
143
+ };
144
+
145
+ const refusal = (match: RegExp) => (error: unknown) =>
146
+ (error as { statusCode?: number }).statusCode === 400 &&
147
+ match.test((error as Error).message);
148
+
149
+ describe("MailboxDetailOperations_deleteMailbox — what it refuses (D4, D16)", () => {
150
+ afterEach(() => {
151
+ _resetForTest();
152
+ });
153
+
154
+ it("refuses the inbox", async () => {
155
+ const { run, deleted } = callDelete({
156
+ mailbox: folder({ fullPath: "INBOX" }),
157
+ });
158
+
159
+ await assert.rejects(run(), refusal(/inbox can't be deleted/));
160
+ assert.deepEqual(deleted, []);
161
+ });
162
+
163
+ it("refuses a folder that has folders inside it", async () => {
164
+ // The server would keep the parent's name as a placeholder and leave the
165
+ // children untouched — an outcome no local row describes.
166
+ const { run, deleted } = callDelete({
167
+ children: [folder({ mailboxId: "mbx-2", fullPath: "Receipts/2026" })],
168
+ });
169
+
170
+ await assert.rejects(run(), refusal(/has folders inside it/));
171
+ assert.deepEqual(deleted, []);
172
+ });
173
+
174
+ /**
175
+ * D16. Refusing is what makes the settled-target rule total: a binding is
176
+ * only created against a settled folder and only removed by the user, so a
177
+ * mailboxId that names nothing never exists and no reader needs a
178
+ * missing-target branch.
179
+ */
180
+ it("refuses a folder a filter moves mail into, naming the filter", async () => {
181
+ const { run, deleted } = callDelete({
182
+ filters: [{ name: "Invoices → Receipts", actionMailboxId: "mbx-1" }],
183
+ });
184
+
185
+ await assert.rejects(run(), refusal(/the filter “Invoices → Receipts”/));
186
+ assert.deepEqual(deleted, []);
187
+ });
188
+
189
+ it("refuses a folder appointed to a role, naming the role", async () => {
190
+ const { run, deleted } = callDelete({ appointments: { Archive: "mbx-1" } });
191
+
192
+ await assert.rejects(run(), refusal(/the Archive role/));
193
+ assert.deepEqual(deleted, []);
194
+ });
195
+
196
+ it("names both when both are bound", async () => {
197
+ const { run } = callDelete({
198
+ filters: [{ name: "Invoices", actionMailboxId: "mbx-1" }],
199
+ appointments: { Archive: "mbx-1" },
200
+ });
201
+
202
+ await assert.rejects(
203
+ run(),
204
+ refusal(/the filter “Invoices” and the Archive role/),
205
+ );
206
+ });
207
+
208
+ it("ignores a filter that moves mail into a different folder", async () => {
209
+ const { run, deleted } = callDelete({
210
+ filters: [{ name: "Elsewhere", actionMailboxId: "mbx-9" }],
211
+ });
212
+
213
+ assert.deepEqual(await run(), { statusCode: 204 });
214
+ assert.deepEqual(deleted, ["mbx-1"]);
215
+ });
216
+
217
+ it("ignores a filter with no move action at all", async () => {
218
+ const { run, deleted } = callDelete({
219
+ filters: [{ name: "Just a label", actionMailboxId: "None" }],
220
+ });
221
+
222
+ assert.deepEqual(await run(), { statusCode: 204 });
223
+ assert.deepEqual(deleted, ["mbx-1"]);
224
+ });
225
+
226
+ it("records the intent for an ordinary folder", async () => {
227
+ const { run, deleted } = callDelete({});
228
+
229
+ assert.deepEqual(await run(), { statusCode: 204 });
230
+ assert.deepEqual(deleted, ["mbx-1"]);
231
+ });
232
+ });
@@ -6,6 +6,7 @@ import type { IAccountSettingRepository, MailboxItem } from "@remit/data-ports";
6
6
  import {
7
7
  BadRequestError,
8
8
  ForbiddenError,
9
+ MailboxNotSettledError,
9
10
  NotFoundError,
10
11
  } from "@remit/data-ports/errors";
11
12
  import { isReservedFolderName } from "@remit/data-ports/folder-role";
@@ -31,6 +32,7 @@ import {
31
32
  type MailboxOverrides,
32
33
  } from "./account-overrides.js";
33
34
  import { assertAccountOwnership } from "./account-ownership.js";
35
+ import { assertNoBindings, loadMailboxBindings } from "./mailbox-bindings.js";
34
36
 
35
37
  /**
36
38
  * The mute flag and the display-name override are user preferences that live
@@ -227,6 +229,51 @@ export const assertMailboxInAccount = (
227
229
  throw new ForbiddenError(`Mailbox ${mailbox.mailboxId} not in account`);
228
230
  };
229
231
 
232
+ /**
233
+ * Why a folder cannot be bound to yet, per state — one wording in one place,
234
+ * because the remedy differs and the client has only the sentence to show.
235
+ */
236
+ const UNSETTLED_REASON: Record<string, (path: string) => string> = {
237
+ [MailboxSyncStatus.pending]: (path) =>
238
+ `“${path}” isn’t ready yet — the mail server hasn’t confirmed it.`,
239
+ [MailboxSyncStatus.deleting]: (path) => `“${path}” is being deleted.`,
240
+ [MailboxSyncStatus.failed]: (path) =>
241
+ `The last change to “${path}” failed. Retry or dismiss it first.`,
242
+ };
243
+
244
+ /**
245
+ * Every API write that binds a durable reference to a mailbox requires that
246
+ * mailbox to be `synced` (D12, first row; imap-mutations R2: wait). A dangling
247
+ * reference is permanent; blocking costs seconds.
248
+ *
249
+ * This is the server-side floor under the client-side wait, and it is not
250
+ * redundant with it: there is more than one client, and a script hitting the
251
+ * API directly gets no wait at all.
252
+ *
253
+ * It is the other half of D16. A binding is only ever created against a settled
254
+ * folder (here) and only ever removed by the user (the delete refusal below),
255
+ * so a `mailboxId` that names nothing never exists — which is what lets every
256
+ * reader of a filter's `actionMailboxId` or a role appointment treat the
257
+ * reference as resolvable, with no missing-target branch.
258
+ *
259
+ * A `\Noselect` container is refused by construction — mailbox-sync keeps no
260
+ * row for one — so there is nothing to bind to. Clearing a reference is not a
261
+ * bind and is never gated, and neither is a move *out of* a folder: a
262
+ * `deleting` folder still holds its mail until the DELETE lands, which is the
263
+ * delete-with-move flow (D13).
264
+ */
265
+ export const assertMailboxSettled = (
266
+ target: Pick<MailboxItem, "mailboxId" | "fullPath" | "syncStatus">,
267
+ ): void => {
268
+ const reason = UNSETTLED_REASON[target.syncStatus];
269
+ if (!reason) return;
270
+ throw new MailboxNotSettledError(
271
+ reason(target.fullPath),
272
+ target.mailboxId,
273
+ target.syncStatus,
274
+ );
275
+ };
276
+
230
277
  /**
231
278
  * Every pending placement move (issue #1271) for an account, on whichever
232
279
  * backend is active (`RemitClient.placementMove` is present on both — see
@@ -286,24 +333,6 @@ const toMailboxResponse = (
286
333
  updatedAt: mailbox.updatedAt,
287
334
  });
288
335
 
289
- /**
290
- * Drop folders the user has deleted but the imap-worker has not yet reaped.
291
- *
292
- * A delete is a soft delete: the row lives on with syncStatus=deleting until the
293
- * worker confirms the IMAP delete and removes it (mailbox-queue.deleteMailbox).
294
- * The list is the settings view a client refetches right after confirming, so a
295
- * still-`deleting` row here would keep a deleted folder on screen until the
296
- * worker finishes. Hiding it makes the folder leave the list the moment the
297
- * delete is confirmed; a delete that fails restores the row off `deleting`,
298
- * which brings the folder back on the next read.
299
- */
300
- export const excludeDeletingMailboxes = (
301
- mailboxes: readonly MailboxItem[],
302
- ): MailboxItem[] =>
303
- mailboxes.filter(
304
- (mailbox) => mailbox.syncStatus !== MailboxSyncStatus.deleting,
305
- );
306
-
307
336
  export const MailboxOperations: Record<
308
337
  MailboxOperationIds,
309
338
  OperationHandler<MailboxOperationIds>
@@ -324,8 +353,6 @@ export const MailboxOperations: Record<
324
353
  continuationToken,
325
354
  });
326
355
 
327
- const visibleItems = excludeDeletingMailboxes(result.items);
328
-
329
356
  // Overrides (mute / display-name / role) live in per-mailbox AccountSetting
330
357
  // rows (RFC 032). Load the whole config's set in one query and key it by
331
358
  // mailboxId so each mailbox surfaces its overrides without an N+1.
@@ -342,8 +369,11 @@ export const MailboxOperations: Record<
342
369
  client,
343
370
  accountId,
344
371
  );
372
+ // A folder mid-mutation is listed, labelled (D11). Hiding a `deleting` one
373
+ // made a delete that does not settle look like a folder that silently
374
+ // vanished, while the server still held it.
345
375
  const items = applyPendingMoveCountPrediction(
346
- visibleItems,
376
+ result.items,
347
377
  pendingMoves,
348
378
  pendingUnseenFlagPushes,
349
379
  );
@@ -492,6 +522,31 @@ export const MailboxDetailOperations: Record<
492
522
  const mailbox = await client.mailbox.get(accountId, mailboxId);
493
523
  assertMailboxInAccount(mailbox, accountId, "act");
494
524
 
525
+ // Cheapest first (D4, D13). None of the three becomes possible by
526
+ // retrying the same request, so all three are 400 and all three run
527
+ // before anything is recorded.
528
+ if (mailbox.fullPath.toUpperCase() === "INBOX") {
529
+ throw new BadRequestError("The inbox can't be deleted.");
530
+ }
531
+
532
+ const children = await client.mailbox.findByPathPrefix(
533
+ accountId,
534
+ mailbox.fullPath,
535
+ mailbox.hierarchyDelimiter,
536
+ );
537
+ if (children.length > 0) {
538
+ // The server would keep the parent's name as a placeholder and leave the
539
+ // children untouched — an outcome no local row describes (D9).
540
+ throw new BadRequestError(
541
+ `“${mailbox.fullPath}” has folders inside it. Delete those first.`,
542
+ );
543
+ }
544
+
545
+ assertNoBindings(
546
+ mailbox,
547
+ await loadMailboxBindings(client, accountConfigId, accountId, mailboxId),
548
+ );
549
+
495
550
  await client.mailboxQueue.deleteMailbox(mailboxId, accountId);
496
551
  return { statusCode: 204 };
497
552
  },
@@ -50,6 +50,7 @@ import type {
50
50
  MessageOperationIds,
51
51
  OperationHandler,
52
52
  } from "../types.js";
53
+ import { assertMailboxSettled } from "./mailbox.js";
53
54
 
54
55
  type StarColorValue = (typeof StarColor)[keyof typeof StarColor];
55
56
 
@@ -939,7 +940,10 @@ export const MessageBulkOperations: Record<
939
940
  "act",
940
941
  );
941
942
 
942
- // Destination must belong to the same (caller-owned) account.
943
+ // Destination must belong to the same (caller-owned) account, and must be
944
+ // a folder the mail server has settled: the row records the destination
945
+ // before the move confirms, so binding to a folder that may never exist
946
+ // leaves the message pointing at nothing (D12, first row).
943
947
  const destination = await client.mailbox.get(
944
948
  accountId,
945
949
  destinationMailboxId,
@@ -949,6 +953,7 @@ export const MessageBulkOperations: Record<
949
953
  `Destination mailbox ${destinationMailboxId} not in account`,
950
954
  );
951
955
  }
956
+ assertMailboxSettled(destination);
952
957
 
953
958
  // MessageMoveService handles: Message + ThreadMessage updates + SQS events
954
959
  await client.messageMove.moveMessages(
@@ -985,7 +990,10 @@ export const MessageBulkOperations: Record<
985
990
  "act",
986
991
  );
987
992
 
988
- // Destination must belong to the same (caller-owned) account.
993
+ // Destination must belong to the same (caller-owned) account, and must be
994
+ // a folder the mail server has settled: the row records the destination
995
+ // before the move confirms, so binding to a folder that may never exist
996
+ // leaves the message pointing at nothing (D12, first row).
989
997
  const destination = await client.mailbox.get(
990
998
  accountId,
991
999
  destinationMailboxId,
@@ -995,6 +1003,7 @@ export const MessageBulkOperations: Record<
995
1003
  `Destination mailbox ${destinationMailboxId} not in account`,
996
1004
  );
997
1005
  }
1006
+ assertMailboxSettled(destination);
998
1007
 
999
1008
  // MessageMoveService handles: Message copies + ThreadMessage creation + SQS events
1000
1009
  await client.messageMove.copyMessages(