@remit/backend 0.0.119 → 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.
@@ -3,12 +3,14 @@ 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
+ MailboxNotSettledError,
10
+ NotFoundError,
11
+ } from "@remit/data-ports/errors";
12
+ import { isReservedFolderName } from "@remit/data-ports/folder-role";
13
+ import { mailboxLeafName } from "@remit/data-ports/mailbox-name";
12
14
  import { MailboxSyncStatus, MessageSystemFlag } from "@remit/domain-enums";
13
15
  import type { APIGatewayProxyEvent } from "aws-lambda";
14
16
  import { getAccountConfigIdFromEvent } from "../auth.js";
@@ -30,7 +32,7 @@ import {
30
32
  type MailboxOverrides,
31
33
  } from "./account-overrides.js";
32
34
  import { assertAccountOwnership } from "./account-ownership.js";
33
- import { loadFolderAppointmentsForAccount } from "./folder-role-appointments.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
@@ -66,6 +68,7 @@ export const pickMailboxOverrideChanges = (
66
68
  export interface MailboxPatchClient {
67
69
  mailbox: {
68
70
  get(accountId: string, mailboxId: string): Promise<MailboxItem>;
71
+ listAllByAccount(accountId: string): Promise<MailboxItem[]>;
69
72
  };
70
73
  mailboxQueue: {
71
74
  renameMailbox(
@@ -73,53 +76,80 @@ export interface MailboxPatchClient {
73
76
  newPath: string,
74
77
  accountId: string,
75
78
  ): Promise<MailboxItem>;
79
+ dismissMailboxIntent(
80
+ mailboxId: string,
81
+ accountId: string,
82
+ ): Promise<MailboxItem>;
76
83
  };
77
84
  accountSetting: Pick<IAccountSettingRepository, "get" | "upsert" | "delete">;
78
85
  }
79
86
 
80
87
  /**
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.
88
+ * Refuse a rename that no retry of the same request could ever satisfy (D4).
85
89
  *
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.
90
+ * `INBOX` is refused at both ends: renaming it moves its mail to the new name
91
+ * and leaves an empty INBOX behind, and renaming anything *to* it collides with
92
+ * the one name RFC 3501 reserves (D5).
93
+ *
94
+ * A reserved leaf name is refused for a different reason. The mailbox sweep
95
+ * reads a folder whose leaf name is a role's conventional name but which lacks
96
+ * the server's flag as a duplicate, and deletes its row — under D8, with the
97
+ * folder's mail — as soon as another folder holds the flag. A rename to such a
98
+ * name settles `synced` and is then reaped. Refusing at the API is the same
99
+ * class of check as the rest of D4 and does not require teaching that heuristic
100
+ * about renames.
89
101
  */
90
- const refreshAppointmentLabels = async (
91
- accountSetting: Pick<IAccountSettingRepository, "get" | "upsert">,
92
- accountConfigId: string,
102
+ const assertRenameTargetAllowed = async (
103
+ client: Pick<MailboxPatchClient, "mailbox">,
93
104
  accountId: string,
94
- renamed: { mailboxId: string; oldPath: string; newPath: string },
95
- delimiter: string,
105
+ before: MailboxItem,
106
+ target: string,
96
107
  ): 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
- });
108
+ if (before.fullPath.toUpperCase() === "INBOX") {
109
+ throw new BadRequestError("The inbox can't be renamed.");
110
+ }
111
+ if (target.trim().length === 0) {
112
+ throw new BadRequestError("A folder needs a name.");
113
+ }
114
+ if (target.toUpperCase() === "INBOX") {
115
+ throw new BadRequestError("“INBOX” is reserved for the inbox.");
116
+ }
117
+
118
+ const leaf = mailboxLeafName({
119
+ fullPath: target,
120
+ hierarchyDelimiter: before.hierarchyDelimiter,
121
+ });
122
+ if (isReservedFolderName(leaf)) {
123
+ throw new BadRequestError(
124
+ `“${leaf}” is reserved for a system folder. Pick another name.`,
125
+ );
126
+ }
127
+
128
+ // A path a rename is on its way to is as taken as one a folder already sits
129
+ // at (D2). Reading `fullPath` alone accepts a second rename onto a target
130
+ // another is already recorded for: both settle, both write the same path,
131
+ // and the sweep then reaps one row — with its mail — and inserts a duplicate
132
+ // for the survivor's path. The account's folders are few and the sweep
133
+ // already reads them all, so one pass answers both questions.
134
+ const folders = await client.mailbox.listAllByAccount(accountId);
135
+ for (const folder of folders) {
136
+ if (folder.mailboxId === before.mailboxId) continue;
137
+ if (folder.fullPath === target) {
138
+ throw new BadRequestError(`A folder named “${target}” is already there.`);
139
+ }
140
+ // `pending` only. A failed rename keeps its target so the client can name
141
+ // what it was aiming at and offer a retry (T6), and nothing clears it
142
+ // until the user retries or dismisses — so honouring a `failed` row's
143
+ // claim would tell everyone else the path is taken by a rename that is
144
+ // not happening, with no way to find that out and nothing to wait for.
145
+ if (
146
+ folder.syncStatus === MailboxSyncStatus.pending &&
147
+ folder.pendingPath === target
148
+ ) {
149
+ throw new BadRequestError(
150
+ `“${folder.fullPath}” is already being renamed to “${target}”.`,
151
+ );
152
+ }
123
153
  }
124
154
  };
125
155
 
@@ -130,6 +160,11 @@ const refreshAppointmentLabels = async (
130
160
  * (the recorded subtree intent + MAILBOX_RENAME event) — only when `fullPath`
131
161
  * is present. An override-only PATCH therefore never calls
132
162
  * `mailboxQueue.renameMailbox`.
163
+ *
164
+ * A `fullPath` equal to the row's confirmed one is not a rename: it is the
165
+ * dismissal of a failed intent (T10, T11), which is how a user keeps the name
166
+ * the folder already has without new API surface. It is checked first, because
167
+ * from `failed` it is the one route out that enqueues nothing.
133
168
  */
134
169
  export const applyMailboxPatch = async (
135
170
  client: MailboxPatchClient,
@@ -161,26 +196,17 @@ export const applyMailboxPatch = async (
161
196
  return client.mailbox.get(accountId, mailboxId);
162
197
  }
163
198
 
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
199
  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;
200
+ if (fullPath === before.fullPath) {
201
+ return client.mailboxQueue.dismissMailboxIntent(mailboxId, accountId);
202
+ }
203
+
204
+ await assertRenameTargetAllowed(client, accountId, before, fullPath);
205
+
206
+ // The row keeps the path the server holds until the rename settles (D2), so
207
+ // nothing that names a folder by path moves here — the appointment labels
208
+ // recorded beside each role (#887) included. They move with the settle.
209
+ return client.mailboxQueue.renameMailbox(mailboxId, fullPath, accountId);
184
210
  };
185
211
 
186
212
  /**
@@ -203,6 +229,51 @@ export const assertMailboxInAccount = (
203
229
  throw new ForbiddenError(`Mailbox ${mailbox.mailboxId} not in account`);
204
230
  };
205
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
+
206
277
  /**
207
278
  * Every pending placement move (issue #1271) for an account, on whichever
208
279
  * backend is active (`RemitClient.placementMove` is present on both — see
@@ -262,24 +333,6 @@ const toMailboxResponse = (
262
333
  updatedAt: mailbox.updatedAt,
263
334
  });
264
335
 
265
- /**
266
- * Drop folders the user has deleted but the imap-worker has not yet reaped.
267
- *
268
- * A delete is a soft delete: the row lives on with syncStatus=deleting until the
269
- * worker confirms the IMAP delete and removes it (mailbox-queue.deleteMailbox).
270
- * The list is the settings view a client refetches right after confirming, so a
271
- * still-`deleting` row here would keep a deleted folder on screen until the
272
- * worker finishes. Hiding it makes the folder leave the list the moment the
273
- * delete is confirmed; a delete that fails restores the row off `deleting`,
274
- * which brings the folder back on the next read.
275
- */
276
- export const excludeDeletingMailboxes = (
277
- mailboxes: readonly MailboxItem[],
278
- ): MailboxItem[] =>
279
- mailboxes.filter(
280
- (mailbox) => mailbox.syncStatus !== MailboxSyncStatus.deleting,
281
- );
282
-
283
336
  export const MailboxOperations: Record<
284
337
  MailboxOperationIds,
285
338
  OperationHandler<MailboxOperationIds>
@@ -300,8 +353,6 @@ export const MailboxOperations: Record<
300
353
  continuationToken,
301
354
  });
302
355
 
303
- const visibleItems = excludeDeletingMailboxes(result.items);
304
-
305
356
  // Overrides (mute / display-name / role) live in per-mailbox AccountSetting
306
357
  // rows (RFC 032). Load the whole config's set in one query and key it by
307
358
  // mailboxId so each mailbox surfaces its overrides without an N+1.
@@ -318,8 +369,11 @@ export const MailboxOperations: Record<
318
369
  client,
319
370
  accountId,
320
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.
321
375
  const items = applyPendingMoveCountPrediction(
322
- visibleItems,
376
+ result.items,
323
377
  pendingMoves,
324
378
  pendingUnseenFlagPushes,
325
379
  );
@@ -468,6 +522,31 @@ export const MailboxDetailOperations: Record<
468
522
  const mailbox = await client.mailbox.get(accountId, mailboxId);
469
523
  assertMailboxInAccount(mailbox, accountId, "act");
470
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
+
471
550
  await client.mailboxQueue.deleteMailbox(mailboxId, accountId);
472
551
  return { statusCode: 204 };
473
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(