@remit/mailbox-service 0.0.91 → 0.0.93

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/mailbox-service",
3
- "version": "0.0.91",
3
+ "version": "0.0.93",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
package/src/index.ts CHANGED
@@ -179,8 +179,9 @@ export {
179
179
  validateMailboxPath,
180
180
  } from "./mailbox-management.js";
181
181
  export {
182
- isFolderOffServer,
183
- isMailboxNotOnServer,
182
+ EVERY_MAILBOX_STATE,
183
+ isFolderMutationInFlight,
184
+ isMailboxMutationInFlight,
184
185
  } from "./mailbox-presence.js";
185
186
  export {
186
187
  type CreateMailboxQueueInput,
@@ -193,6 +194,7 @@ export {
193
194
  type SyncAccountInfo,
194
195
  } from "./mailbox-sync.js";
195
196
  export {
197
+ type DeleteMessagesOutcome,
196
198
  type DeleteOptions,
197
199
  type MessageMoveConfig,
198
200
  type MessageMoveLogger,
@@ -267,6 +269,12 @@ export {
267
269
  resolveMailboxRole,
268
270
  shapeFromMessageData,
269
271
  } from "./quarantine.js";
272
+ export {
273
+ type RestoreSourcePlacementDeps,
274
+ type RestoreSourcePlacementInput,
275
+ type RestoreSourcePlacementOutcome,
276
+ restoreSourcePlacement,
277
+ } from "./restore-source-placement.js";
270
278
  export {
271
279
  extractSnippetFromEmail,
272
280
  generateSnippet,
@@ -286,6 +294,12 @@ export {
286
294
  type StaleMessageReconcileDeps,
287
295
  type StaleMessageReconcileResult,
288
296
  } from "./stale-message-reconcile.js";
297
+ export {
298
+ buildThreadMessageMoveRevert,
299
+ buildThreadMessageTrashUpdate,
300
+ buildThreadMessageUndelete,
301
+ type ThreadMessageRowState,
302
+ } from "./thread-message-rows.js";
289
303
  export type {
290
304
  FlatMailboxInfo,
291
305
  IImapConnection,
@@ -1,6 +1,9 @@
1
1
  import assert from "node:assert";
2
2
  import { describe, it } from "node:test";
3
- import type { IMailboxRepository } from "@remit/data-ports";
3
+ import type {
4
+ IMailboxRepository,
5
+ MailboxTransitionIntent,
6
+ } from "@remit/data-ports";
4
7
  import { MailboxSyncStatus } from "@remit/domain-enums";
5
8
  import {
6
9
  MailboxManagementService,
@@ -99,7 +102,14 @@ const boxStatus = (path: string): ImapBoxStatus => ({
99
102
  const recordingRepo = () => {
100
103
  const updates: Array<{ mailboxId: string; patch: Record<string, unknown> }> =
101
104
  [];
102
- const repo: Pick<IMailboxRepository, "update" | "findByPathPrefix"> = {
105
+ const settles: Array<{
106
+ mailboxId: string;
107
+ intent: MailboxTransitionIntent;
108
+ }> = [];
109
+ const repo: Pick<
110
+ IMailboxRepository,
111
+ "update" | "transition" | "findByPathPrefix"
112
+ > = {
103
113
  update: async (
104
114
  _accountId: string,
105
115
  mailboxId: string,
@@ -108,9 +118,17 @@ const recordingRepo = () => {
108
118
  updates.push({ mailboxId, patch });
109
119
  return {} as never;
110
120
  },
121
+ transition: async (
122
+ _accountId: string,
123
+ mailboxId: string,
124
+ intent: MailboxTransitionIntent,
125
+ ) => {
126
+ settles.push({ mailboxId, intent });
127
+ return {} as never;
128
+ },
111
129
  findByPathPrefix: async () => [],
112
130
  };
113
- return { repo: repo as IMailboxRepository, updates };
131
+ return { repo: repo as IMailboxRepository, updates, settles };
114
132
  };
115
133
 
116
134
  const stubConnection = (
@@ -143,7 +161,7 @@ const stubConnection = (
143
161
 
144
162
  describe("MailboxManagementService.syncCreate — server path normalization", () => {
145
163
  it("adopts the server-materialized path as the row's identity when it is prefixed", async () => {
146
- const { repo, updates } = recordingRepo();
164
+ const { repo, settles } = recordingRepo();
147
165
  const { connection, opened } = stubConnection("INBOX/Notifications", [
148
166
  "INBOX/Notifications",
149
167
  ]);
@@ -159,28 +177,28 @@ describe("MailboxManagementService.syncCreate — server path normalization", ()
159
177
  assert.strictEqual(result.success, true);
160
178
  // The mailbox is opened at the server's path, not the requested leaf.
161
179
  assert.deepStrictEqual(opened, ["INBOX/Notifications"]);
162
- assert.strictEqual(updates.length, 1);
180
+ assert.strictEqual(settles.length, 1);
163
181
  // The row keeps its id and takes on the prefixed path, so the next reconcile
164
182
  // updates it in place instead of insert+delete — every reference survives.
165
- assert.strictEqual(updates[0].mailboxId, "mbx-1");
166
- assert.strictEqual(updates[0].patch.fullPath, "INBOX/Notifications");
167
- assert.strictEqual(updates[0].patch.syncStatus, MailboxSyncStatus.synced);
183
+ assert.strictEqual(settles[0].mailboxId, "mbx-1");
184
+ assert.strictEqual(settles[0].intent.set?.fullPath, "INBOX/Notifications");
185
+ assert.strictEqual(settles[0].intent.to, MailboxSyncStatus.synced);
168
186
  });
169
187
 
170
188
  it("leaves the path untouched when the server keeps it as requested", async () => {
171
- const { repo, updates } = recordingRepo();
189
+ const { repo, settles } = recordingRepo();
172
190
  const { connection } = stubConnection("Work", ["Work"]);
173
191
  const service = new MailboxManagementService(repo);
174
192
 
175
193
  await service.syncCreate("acc-1", "mbx-2", "Work", async () => connection);
176
194
 
177
- assert.strictEqual(updates.length, 1);
178
- assert.ok(!("fullPath" in updates[0].patch));
179
- assert.strictEqual(updates[0].patch.syncStatus, MailboxSyncStatus.synced);
195
+ assert.strictEqual(settles.length, 1);
196
+ assert.deepStrictEqual(settles[0].intent.set, {});
197
+ assert.strictEqual(settles[0].intent.to, MailboxSyncStatus.synced);
180
198
  });
181
199
 
182
200
  it("still adopts the server path when the fresh list cannot resolve it", async () => {
183
- const { repo, updates } = recordingRepo();
201
+ const { repo, settles } = recordingRepo();
184
202
  const { connection, opened } = stubConnection("INBOX/Notifications", []);
185
203
  const service = new MailboxManagementService(repo);
186
204
 
@@ -192,13 +210,13 @@ describe("MailboxManagementService.syncCreate — server path normalization", ()
192
210
  );
193
211
 
194
212
  assert.deepStrictEqual(opened, []);
195
- assert.strictEqual(updates.length, 1);
196
- assert.strictEqual(updates[0].patch.fullPath, "INBOX/Notifications");
197
- assert.strictEqual(updates[0].patch.syncStatus, MailboxSyncStatus.synced);
213
+ assert.strictEqual(settles.length, 1);
214
+ assert.strictEqual(settles[0].intent.set?.fullPath, "INBOX/Notifications");
215
+ assert.strictEqual(settles[0].intent.to, MailboxSyncStatus.synced);
198
216
  });
199
217
 
200
218
  it("falls back to the requested path when the create result carries none", async () => {
201
- const { repo, updates } = recordingRepo();
219
+ const { repo, settles } = recordingRepo();
202
220
  const opened: string[] = [];
203
221
  const subscribed: string[] = [];
204
222
  const connection = {
@@ -234,9 +252,9 @@ describe("MailboxManagementService.syncCreate — server path normalization", ()
234
252
  // A thin result never blanks fullPath — the requested path is used throughout.
235
253
  assert.deepStrictEqual(subscribed, ["Archive"]);
236
254
  assert.deepStrictEqual(opened, ["Archive"]);
237
- assert.strictEqual(updates.length, 1);
238
- assert.ok(!("fullPath" in updates[0].patch));
239
- assert.strictEqual(updates[0].patch.syncStatus, MailboxSyncStatus.synced);
255
+ assert.strictEqual(settles.length, 1);
256
+ assert.deepStrictEqual(settles[0].intent.set, {});
257
+ assert.strictEqual(settles[0].intent.to, MailboxSyncStatus.synced);
240
258
  });
241
259
  });
242
260
 
@@ -1,6 +1,7 @@
1
1
  import type { IMailboxRepository } from "@remit/data-ports";
2
+ import { NotFoundError } from "@remit/data-ports/errors";
2
3
  import { MailboxSyncStatus } from "@remit/domain-enums";
3
- import { isNotFoundError } from "./mailbox-presence.js";
4
+ import { EVERY_MAILBOX_STATE, isNotFoundError } from "./mailbox-presence.js";
4
5
  import type { IImapConnection } from "./types.js";
5
6
 
6
7
  /**
@@ -115,6 +116,30 @@ export class MailboxManagementService {
115
116
  this.log = logger ?? noopLogger;
116
117
  }
117
118
 
119
+ /**
120
+ * Write the settled state back, as the conditional write that is now the
121
+ * only way to write it at all (D3). The from-set is every state because that
122
+ * is what this settle decides against today — it is the unconditional write
123
+ * it replaces, moved onto the door; #363 narrows it to the state its intent
124
+ * recorded and gives a lost predicate its `already-settled` outcome.
125
+ *
126
+ * A null means the row is gone, which is what the unconditional write raised
127
+ * a NotFoundError for, and what the handlers' #289-class terminal guards
128
+ * classify as the user having deleted the folder mid-sync.
129
+ */
130
+ private settle = async (
131
+ accountId: string,
132
+ mailboxId: string,
133
+ confirmedPath?: string,
134
+ ): Promise<void> => {
135
+ const settled = await this.mailboxService.transition(accountId, mailboxId, {
136
+ from: EVERY_MAILBOX_STATE,
137
+ to: MailboxSyncStatus.synced,
138
+ set: confirmedPath !== undefined ? { fullPath: confirmedPath } : {},
139
+ });
140
+ if (!settled) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
141
+ };
142
+
118
143
  /**
119
144
  * Sync a CREATE operation to IMAP.
120
145
  * Called by worker after dequeuing MAILBOX_CREATE event.
@@ -149,7 +174,7 @@ export class MailboxManagementService {
149
174
  typeof result.path === "string" && result.path.length > 0
150
175
  ? result.path
151
176
  : path;
152
- const pathUpdate = serverPath !== path ? { fullPath: serverPath } : {};
177
+ const confirmedPath = serverPath !== path ? serverPath : undefined;
153
178
 
154
179
  this.log.info(
155
180
  { mailboxId, path, serverPath, created: result.created },
@@ -169,21 +194,24 @@ export class MailboxManagementService {
169
194
  // Open the mailbox to get UIDVALIDITY and other status info
170
195
  const status = await connection.openBox(serverPath, true);
171
196
 
197
+ // Two writes where there was one, and the settle is deliberately the
198
+ // second: counters are ordinary metadata and stay off the transition,
199
+ // whose whole point is that its write-set is the state (D3). A crash in
200
+ // between leaves the row `pending` with fresh counters, and the row not
201
+ // yet settled is what keeps the queue message unacked — the redelivery
202
+ // re-issues CREATE, the server answers ALREADYEXISTS, and #339's
203
+ // classification settles it. Settling first would be the unsafe order.
172
204
  await this.mailboxService.update(accountId, mailboxId, {
173
- ...pathUpdate,
174
205
  uidValidity: status.uidvalidity,
175
206
  uidNext: status.uidnext,
176
207
  messageCount: status.messages.total,
177
- syncStatus: MailboxSyncStatus.synced,
178
208
  });
209
+ await this.settle(accountId, mailboxId, confirmedPath);
179
210
 
180
211
  await connection.closeBox();
181
212
  } else {
182
213
  // Mark as synced even if we couldn't get full info
183
- await this.mailboxService.update(accountId, mailboxId, {
184
- ...pathUpdate,
185
- syncStatus: MailboxSyncStatus.synced,
186
- });
214
+ await this.settle(accountId, mailboxId, confirmedPath);
187
215
  }
188
216
 
189
217
  return { success: true };
@@ -215,11 +243,7 @@ export class MailboxManagementService {
215
243
  "Renamed mailbox on IMAP server",
216
244
  );
217
245
 
218
- // Clear oldPath and mark as synced
219
- await this.mailboxService.update(accountId, mailboxId, {
220
- oldPath: undefined,
221
- syncStatus: MailboxSyncStatus.synced,
222
- });
246
+ await this.settle(accountId, mailboxId);
223
247
 
224
248
  // The server rename is done and the row records it. The settle is repair
225
249
  // work on top of that, so nothing it hits — the listing included — may reach
@@ -268,16 +292,17 @@ export class MailboxManagementService {
268
292
  );
269
293
 
270
294
  for (const descendant of descendants) {
271
- if (descendant.syncStatus !== MailboxSyncStatus.pending) continue;
272
295
  if (!onServer.has(descendant.fullPath)) continue;
273
- await this.mailboxService
274
- .update(accountId, descendant.mailboxId, {
275
- syncStatus: MailboxSyncStatus.synced,
276
- })
277
- .catch((error: unknown) => {
278
- if (isNotFoundError(error)) return;
279
- throw error;
280
- });
296
+ // The per-row settle of D15: `pending` is the state that carries the
297
+ // intent, and the predicate is on the UPDATE, so a row another client
298
+ // moved in between is skipped rather than overwritten. A row that is
299
+ // gone is skipped for free — a null is not an error here, which is what
300
+ // keeps a descendant deleted mid-settle out of the handler's
301
+ // whole-chain NotFoundError guard.
302
+ await this.mailboxService.transition(accountId, descendant.mailboxId, {
303
+ from: [MailboxSyncStatus.pending],
304
+ to: MailboxSyncStatus.synced,
305
+ });
281
306
  }
282
307
  };
283
308
 
@@ -0,0 +1,72 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { IMailboxRepository } from "@remit/data-ports";
4
+ import { NotFoundError } from "@remit/data-ports/errors";
5
+ import { MailboxSyncStatus } from "@remit/domain-enums";
6
+ import {
7
+ isFolderMutationInFlight,
8
+ isMailboxMutationInFlight,
9
+ } from "./mailbox-presence.js";
10
+
11
+ const reader = (
12
+ get: IMailboxRepository["get"],
13
+ ): Pick<IMailboxRepository, "get"> => ({ get });
14
+
15
+ describe("isFolderMutationInFlight", () => {
16
+ for (const [syncStatus, inFlight] of [
17
+ [MailboxSyncStatus.synced, false],
18
+ [MailboxSyncStatus.pending, true],
19
+ [MailboxSyncStatus.deleting, true],
20
+ [MailboxSyncStatus.failed, false],
21
+ ] as const) {
22
+ it(`${syncStatus} is ${inFlight ? "" : "not "}a mutation in flight`, () => {
23
+ assert.equal(isFolderMutationInFlight({ syncStatus }), inFlight);
24
+ });
25
+ }
26
+
27
+ it("does not read failed as a folder to leave alone", () => {
28
+ // `failed` means the folder exists at `fullPath` and the last rename or
29
+ // delete intent did not land — so it holds the user's mail and syncing it
30
+ // is right. D7 upholds that by leaving no row at all for a create the
31
+ // server refused. Reading it as in-flight would stop syncing a live folder
32
+ // permanently and silently.
33
+ assert.equal(
34
+ isFolderMutationInFlight({ syncStatus: MailboxSyncStatus.failed }),
35
+ false,
36
+ );
37
+ });
38
+ });
39
+
40
+ describe("isMailboxMutationInFlight", () => {
41
+ it("reads the row it is given", async () => {
42
+ const service = reader((async () => ({
43
+ syncStatus: MailboxSyncStatus.deleting,
44
+ })) as unknown as IMailboxRepository["get"]);
45
+
46
+ assert.equal(await isMailboxMutationInFlight(service, "acc", "mbx"), true);
47
+ });
48
+
49
+ it("treats an absent row as in flight", async () => {
50
+ // The row's absence does not decide it at either end: on the way out the
51
+ // row is removed only after the IMAP folder is, and on the way in the row
52
+ // exists before the folder does.
53
+ const service = reader((async () => {
54
+ throw new NotFoundError("Mailbox not found: mbx");
55
+ }) as unknown as IMailboxRepository["get"]);
56
+
57
+ assert.equal(await isMailboxMutationInFlight(service, "acc", "mbx"), true);
58
+ });
59
+
60
+ it("rethrows a read that cannot answer", async () => {
61
+ // The caller uses this to classify a failure in hand; a read that fails
62
+ // for its own reasons must not be reported as "the folder is busy".
63
+ const service = reader((async () => {
64
+ throw new Error("connection reset by peer");
65
+ }) as unknown as IMailboxRepository["get"]);
66
+
67
+ await assert.rejects(
68
+ isMailboxMutationInFlight(service, "acc", "mbx"),
69
+ /connection reset by peer/,
70
+ );
71
+ });
72
+ });
@@ -5,43 +5,65 @@ export const isNotFoundError = (error: unknown): boolean =>
5
5
  error instanceof Error && error.name === "NotFoundError";
6
6
 
7
7
  /**
8
- * Whether the IMAP server is not known to hold this folder — the row is gone,
9
- * its creation has not reached the server yet, or its deletion has been asked
10
- * for and not finished. Folders are written locally first and reconciled to the
11
- * server by the worker, so both ends of that lifecycle are states in which
12
- * syncing the folder can only fail.
8
+ * Every state a mailbox row can carry, for a transition whose caller decided
9
+ * against none of them.
13
10
  *
14
- * This is the terminal test for a sync event whose folder is not there. The
15
- * row's absence alone does not decide it, at either end: on the way out the row
16
- * is removed only after the IMAP folder is, so an event that fails against the
17
- * server still sees a live row for as long as that write takes; on the way in
18
- * the row exists before the folder does. `pending` and `deleting` are written by
19
- * the request itself, before the event that resolves it is enqueued, which is
20
- * what makes them sound proxies for presence: neither can be observed unless a
21
- * create has yet to land or a delete has been asked for.
11
+ * A transition predicate is supposed to name the state the caller read
12
+ * (docs/architecture/folder-rename-and-delete.md D3), and the intent recorders
13
+ * and settles that use this one name nothing yet: they are today's
14
+ * unconditional writes, moved onto the only door that can write a folder state
15
+ * at all. Each is narrowed to the from-set its transition-table row allows when
16
+ * the intents themselves land (#362, #363), which is also where a loser gets
17
+ * its 409.
18
+ */
19
+ export const EVERY_MAILBOX_STATE = [
20
+ MailboxSyncStatus.synced,
21
+ MailboxSyncStatus.pending,
22
+ MailboxSyncStatus.failed,
23
+ MailboxSyncStatus.deleting,
24
+ ] as const;
25
+
26
+ /**
27
+ * Whether a folder mutation is in flight, so nothing else may touch the folder
28
+ * over IMAP.
29
+ *
30
+ * It used to be spelled as a presence test — is the folder off the server —
31
+ * and that is no longer what it asks. Under D2 `fullPath` is always a path the
32
+ * server holds, rename target and all, so a rename-pending folder *is* on the
33
+ * server; what the two states share is that a worker owns the folder until its
34
+ * mutation settles. `pending` and `deleting` are written by the request itself,
35
+ * before the event that resolves it is enqueued, so neither can be observed
36
+ * unless a create or rename has yet to land or a delete has been asked for.
37
+ *
38
+ * This is the terminal test for a sync event whose folder is not the sync's to
39
+ * touch. The row's absence alone does not decide it, at either end: on the way
40
+ * out the row is removed only after the IMAP folder is, so an event that fails
41
+ * against the server still sees a live row for as long as that write takes; on
42
+ * the way in the row exists before the folder does.
43
+ *
44
+ * `failed` is deliberately not one of them. The invariant is that a `failed`
45
+ * row's folder exists at `fullPath` and the last rename or delete intent did
46
+ * not land — so the folder holds the user's mail and syncing it is right.
47
+ * D7 is what upholds that: a create the server refuses leaves no row at all,
48
+ * rather than a `failed` one standing for a folder that was never made. Reading
49
+ * `failed` as off-server would stop syncing a live folder permanently and
50
+ * silently, which is worse than the bounded stall it would remove.
22
51
  *
23
- * `failed` is deliberately not one of them, though it is the fourth state a
24
- * mailbox row can carry. It records that the last management operation failed
25
- * and says nothing about whether the folder is on the server: `handleDelete` and
26
- * `handleRename` set it on folders that exist and hold the user's mail, and
27
- * nothing ever clears it — `syncStatus` returns to `synced` only from a
28
- * subsequent successful create or rename, never from the sweep. Treating it as
29
- * off-server would stop syncing such a folder permanently and silently, which is
30
- * worse than the bounded stall it would remove. A `failed` row whose folder is
31
- * genuinely absent is reaped by the sweep's own cleanup, which runs before the
32
- * fan-out that would enqueue work for it.
52
+ * A rename-pending folder is skipped even though its `fullPath` is live. That
53
+ * is conservative rather than necessary (D12): it keeps this a single
54
+ * expression and costs a few seconds of sync for one folder.
33
55
  *
34
56
  * Mailbox management is judged differently — it is what establishes and removes
35
57
  * the folder, so it terminates on a not-found row alone.
36
58
  */
37
- export const isFolderOffServer = (
59
+ export const isFolderMutationInFlight = (
38
60
  mailbox: Pick<MailboxItem, "syncStatus">,
39
61
  ): boolean =>
40
62
  mailbox.syncStatus === MailboxSyncStatus.pending ||
41
63
  mailbox.syncStatus === MailboxSyncStatus.deleting;
42
64
 
43
- /** {@link isFolderOffServer} for a mailbox that has to be read first, an absent row included. */
44
- export const isMailboxNotOnServer = async (
65
+ /** {@link isFolderMutationInFlight} for a mailbox that has to be read first, an absent row included. */
66
+ export const isMailboxMutationInFlight = async (
45
67
  mailboxService: Pick<IMailboxRepository, "get">,
46
68
  accountId: string,
47
69
  mailboxId: string,
@@ -53,5 +75,5 @@ export const isMailboxNotOnServer = async (
53
75
  throw error;
54
76
  });
55
77
  if (!mailbox) return true;
56
- return isFolderOffServer(mailbox);
78
+ return isFolderMutationInFlight(mailbox);
57
79
  };
@@ -5,8 +5,11 @@ import type {
5
5
  IMailboxRepository,
6
6
  MailboxItem,
7
7
  } from "@remit/data-ports";
8
+ import { NotFoundError } from "@remit/data-ports/errors";
9
+ import { rebaseMailboxPath } from "@remit/data-ports/mailbox-name";
8
10
  import { MailboxSyncStatus } from "@remit/domain-enums";
9
11
  import { createQueueProducer } from "@remit/sqs-client/producer";
12
+ import { EVERY_MAILBOX_STATE } from "./mailbox-presence.js";
10
13
 
11
14
  /**
12
15
  * MAILBOX_CREATE event structure (matches remit-imap-worker/events.ts)
@@ -163,19 +166,32 @@ export class MailboxQueueService {
163
166
  const mailbox = await this.mailboxService.get(accountId, mailboxId);
164
167
  const oldPath = mailbox.fullPath;
165
168
 
166
- // Update the mailbox path and set syncStatus to pending
167
- const updated = await this.mailboxService.update(accountId, mailboxId, {
168
- fullPath: newPath,
169
- syncStatus: MailboxSyncStatus.pending,
170
- });
171
-
172
- // Update child mailbox paths
173
- await this.mailboxService.renameChildPaths(
174
- mailbox.accountId,
175
- oldPath,
176
- newPath,
177
- mailbox.hierarchyDelimiter,
169
+ // One intent over the folder and every descendant, in one transaction:
170
+ // IMAP RENAME moves the subtree in one command, so a partially recorded
171
+ // rename is the state that produced today's phantom rows (D6). Each
172
+ // descendant's new path is equally absent from the server until
173
+ // MAILBOX_RENAME lands, so each is `pending` too — otherwise a reconcile
174
+ // in that window reaps it as server-deleted (#290).
175
+ const written = await this.mailboxService.transitionSubtree(
176
+ accountId,
177
+ mailboxId,
178
+ {
179
+ from: EVERY_MAILBOX_STATE,
180
+ to: MailboxSyncStatus.pending,
181
+ rowSet: (row) => {
182
+ const moved = rebaseMailboxPath(
183
+ row.fullPath,
184
+ oldPath,
185
+ newPath,
186
+ mailbox.hierarchyDelimiter,
187
+ );
188
+ return moved === undefined ? {} : { fullPath: moved };
189
+ },
190
+ },
178
191
  );
192
+ if (!written) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
193
+ const updated = written.find((row) => row.mailboxId === mailboxId);
194
+ if (!updated) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
179
195
 
180
196
  this.log.info({ mailboxId, oldPath, newPath }, "Renamed mailbox (local)");
181
197
 
@@ -208,9 +224,12 @@ export class MailboxQueueService {
208
224
  const mailbox = await this.mailboxService.get(accountId, mailboxId);
209
225
 
210
226
  // Mark as deleting (soft delete - worker will do actual delete after IMAP sync)
211
- await this.mailboxService.update(accountId, mailboxId, {
212
- syncStatus: MailboxSyncStatus.deleting,
213
- });
227
+ const recorded = await this.mailboxService.transition(
228
+ accountId,
229
+ mailboxId,
230
+ { from: EVERY_MAILBOX_STATE, to: MailboxSyncStatus.deleting },
231
+ );
232
+ if (!recorded) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
214
233
 
215
234
  this.log.info(
216
235
  { mailboxId, path: mailbox.fullPath },