@remit/mailbox-service 0.0.92 → 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.92",
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,
@@ -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 },
@@ -0,0 +1,143 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { IMailboxRepository, MailboxItem } from "@remit/data-ports";
4
+ import { MailboxSyncStatus } from "@remit/domain-enums";
5
+ import { MailboxQueueService } from "./mailbox-queue.js";
6
+
7
+ const row = (mailboxId: string, fullPath: string): MailboxItem =>
8
+ ({
9
+ mailboxId,
10
+ accountId: "acc-1",
11
+ fullPath,
12
+ hierarchyDelimiter: "/",
13
+ syncStatus: MailboxSyncStatus.synced,
14
+ }) as MailboxItem;
15
+
16
+ /**
17
+ * A repository that resolves the subtree and applies `rowSet` the way the real
18
+ * one does, so the paths the intent records are the paths under test.
19
+ */
20
+ const store = (rows: MailboxItem[]) => {
21
+ const byId = new Map(rows.map((r) => [r.mailboxId, { ...r }]));
22
+
23
+ const repo: Pick<IMailboxRepository, "get" | "transitionSubtree"> = {
24
+ get: (async (_accountId: string, mailboxId: string) => {
25
+ const found = byId.get(mailboxId as string);
26
+ if (!found) {
27
+ throw Object.assign(new Error(`Mailbox not found: ${mailboxId}`), {
28
+ name: "NotFoundError",
29
+ });
30
+ }
31
+ return found;
32
+ }) as IMailboxRepository["get"],
33
+ transitionSubtree: async (_accountId, mailboxId, intent) => {
34
+ const root = byId.get(mailboxId);
35
+ if (!root) return null;
36
+ const prefix = `${root.fullPath}${root.hierarchyDelimiter}`;
37
+ const subtree = [
38
+ root,
39
+ ...[...byId.values()].filter(
40
+ (r) => r !== root && r.fullPath.startsWith(prefix),
41
+ ),
42
+ ];
43
+ const written: MailboxItem[] = [];
44
+ for (const candidate of subtree) {
45
+ if (!intent.from.includes(candidate.syncStatus)) return null;
46
+ const next = {
47
+ ...candidate,
48
+ ...intent.rowSet(candidate),
49
+ syncStatus: intent.to,
50
+ } as MailboxItem;
51
+ byId.set(candidate.mailboxId, next);
52
+ written.push(next);
53
+ }
54
+ // The port promises no ordering beyond "the named folder first", so
55
+ // hand the caller the least convenient order it is allowed to return.
56
+ return written.reverse();
57
+ },
58
+ };
59
+
60
+ return {
61
+ repo: repo as IMailboxRepository,
62
+ pathOf: (id: string) => byId.get(id)?.fullPath,
63
+ };
64
+ };
65
+
66
+ const queueOver = (repo: IMailboxRepository) => {
67
+ const sent: string[] = [];
68
+ const service = new MailboxQueueService({
69
+ mailboxService: repo,
70
+ sqsQueueUrl: "http://localhost:4566/000000000000/mailbox",
71
+ });
72
+ (
73
+ service as unknown as {
74
+ sqs: {
75
+ send: (command: { input: { MessageBody: string } }) => Promise<void>;
76
+ };
77
+ }
78
+ ).sqs = {
79
+ send: async (command) => {
80
+ sent.push(command.input.MessageBody);
81
+ },
82
+ };
83
+ return { service, sent };
84
+ };
85
+
86
+ describe("MailboxQueueService.renameMailbox — the recorded intent", () => {
87
+ it("records the path the RENAME will carry, `$` patterns and all", async () => {
88
+ // `String.prototype.replace` expands `$&`, "$`", `$'` and `$$` in the
89
+ // replacement, so a folder renamed to a name containing one of them was
90
+ // stored under a different path from the one the event carries to the
91
+ // server. The row and the server then disagree, and the next sweep finds
92
+ // no row for the real path and inserts a second one.
93
+ const { repo, pathOf } = store([
94
+ row("mbx-parent", "Work"),
95
+ row("mbx-child", "Work/2026"),
96
+ ]);
97
+ const { service, sent } = queueOver(repo);
98
+
99
+ const renamed = await service.renameMailbox(
100
+ "mbx-parent",
101
+ "Q1$&Q2",
102
+ "acc-1",
103
+ );
104
+
105
+ assert.equal(renamed.fullPath, "Q1$&Q2");
106
+ assert.equal(pathOf("mbx-parent"), "Q1$&Q2");
107
+ assert.equal(pathOf("mbx-child"), "Q1$&Q2/2026");
108
+ assert.equal(
109
+ (JSON.parse(sent[0]) as { newPath: string }).newPath,
110
+ "Q1$&Q2",
111
+ "the event and the row name the same folder",
112
+ );
113
+ });
114
+
115
+ it("moves only what is under the renamed branch", async () => {
116
+ const { repo, pathOf } = store([
117
+ row("mbx-parent", "Work"),
118
+ row("mbx-lookalike", "Workshop"),
119
+ ]);
120
+ const { service } = queueOver(repo);
121
+
122
+ await service.renameMailbox("mbx-parent", "Projects", "acc-1");
123
+
124
+ assert.equal(pathOf("mbx-parent"), "Projects");
125
+ assert.equal(pathOf("mbx-lookalike"), "Workshop");
126
+ });
127
+
128
+ it("returns the folder that was asked for, not whichever row came back first", async () => {
129
+ const { repo } = store([
130
+ row("mbx-parent", "Work"),
131
+ row("mbx-child", "Work/2026"),
132
+ ]);
133
+ const { service } = queueOver(repo);
134
+
135
+ const renamed = await service.renameMailbox(
136
+ "mbx-parent",
137
+ "Projects",
138
+ "acc-1",
139
+ );
140
+
141
+ assert.equal(renamed.mailboxId, "mbx-parent");
142
+ });
143
+ });
@@ -21,7 +21,10 @@ const row = (
21
21
  const store = (rows: MailboxItem[], vanishAfterSweep: string[] = []) => {
22
22
  const byId = new Map(rows.map((r) => [r.mailboxId, { ...r }]));
23
23
 
24
- const repo: Pick<IMailboxRepository, "update" | "findByPathPrefix"> = {
24
+ const repo: Pick<
25
+ IMailboxRepository,
26
+ "update" | "transition" | "findByPathPrefix"
27
+ > = {
25
28
  update: async (_accountId, mailboxId, patch) => {
26
29
  const existing = byId.get(mailboxId);
27
30
  if (!existing) {
@@ -33,6 +36,24 @@ const store = (rows: MailboxItem[], vanishAfterSweep: string[] = []) => {
33
36
  byId.set(mailboxId, next);
34
37
  return next;
35
38
  },
39
+ transition: async (_accountId, mailboxId, intent) => {
40
+ const existing = byId.get(mailboxId);
41
+ if (!existing) return null;
42
+ if (!intent.from.includes(existing.syncStatus)) return null;
43
+ if (
44
+ intent.wherePendingPath !== undefined &&
45
+ (intent.wherePendingPath ?? undefined) !== existing.pendingPath
46
+ ) {
47
+ return null;
48
+ }
49
+ const next = {
50
+ ...existing,
51
+ ...intent.set,
52
+ syncStatus: intent.to,
53
+ } as MailboxItem;
54
+ byId.set(mailboxId, next);
55
+ return next;
56
+ },
36
57
  findByPathPrefix: async (_accountId, pathPrefix, delimiter = "/") => {
37
58
  const prefix = `${pathPrefix}${delimiter}`;
38
59
  const found = [...byId.values()].filter((r) =>
@@ -216,19 +216,23 @@ describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending
216
216
  shared: [],
217
217
  };
218
218
 
219
- // The server lists only INBOX: neither user folder is on it yet.
220
- const serverConnection = (): IImapConnection =>
219
+ // The server lists only INBOX unless a test says otherwise: neither user
220
+ // folder is on it yet.
221
+ const serverConnection = (
222
+ folders: Array<{ fullPath: string; attributes?: string[] }> = [
223
+ { fullPath: "INBOX" },
224
+ ],
225
+ ): IImapConnection =>
221
226
  ({
222
227
  getNamespaces: async () => namespaces,
223
- listMailboxes: async () => [
224
- {
225
- fullPath: "INBOX",
226
- name: "INBOX",
228
+ listMailboxes: async () =>
229
+ folders.map((folder) => ({
230
+ fullPath: folder.fullPath,
231
+ name: folder.fullPath.split("/").pop() ?? folder.fullPath,
227
232
  delimiter: "/",
228
- attributes: [],
233
+ attributes: folder.attributes ?? [],
229
234
  parentPath: null,
230
- },
231
- ],
235
+ })),
232
236
  getMailboxStatus: async () => ({
233
237
  messages: 0,
234
238
  recent: 0,
@@ -238,6 +242,12 @@ describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending
238
242
  highestModseq: "0",
239
243
  deletedCount: 0,
240
244
  }),
245
+ openBox: async () => ({
246
+ uidvalidity: 1,
247
+ uidnext: 1,
248
+ messages: { total: 0 },
249
+ }),
250
+ closeBox: async () => undefined,
241
251
  }) as unknown as IImapConnection;
242
252
 
243
253
  const buildServices = (
@@ -245,9 +255,11 @@ describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending
245
255
  mailboxId: string;
246
256
  fullPath: string;
247
257
  syncStatus?: string;
258
+ pendingPath?: string;
248
259
  }>,
249
260
  ) => {
250
261
  const deleted: string[] = [];
262
+ const created: Array<Record<string, unknown>> = [];
251
263
  const mailboxService = {
252
264
  listByAccount: async () => ({
253
265
  items: existing.map((m) => ({
@@ -261,6 +273,7 @@ describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending
261
273
  highestModseq: "0",
262
274
  specialUse: undefined,
263
275
  syncStatus: m.syncStatus,
276
+ pendingPath: m.pendingPath,
264
277
  })),
265
278
  continuationToken: undefined,
266
279
  }),
@@ -268,7 +281,10 @@ describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending
268
281
  delete: async (_accountId: string, mailboxId: string) => {
269
282
  deleted.push(mailboxId);
270
283
  },
271
- create: async () => ({}),
284
+ create: async (input: Record<string, unknown>) => {
285
+ created.push(input);
286
+ return { ...input, mailboxId: `new-${created.length}` };
287
+ },
272
288
  } as unknown as IMailboxRepository;
273
289
 
274
290
  const specialUseService = {
@@ -277,23 +293,126 @@ describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending
277
293
  createMany: async () => undefined,
278
294
  } as unknown as IMailboxSpecialUseRepository;
279
295
 
280
- return { mailboxService, specialUseService, deleted };
296
+ return { mailboxService, specialUseService, deleted, created };
281
297
  };
282
298
 
283
- it("keeps a pending folder the server has not listed yet", async () => {
284
- // The folder was just created locally; MAILBOX_CREATE has not reached the
285
- // server, so the LIST omits it. Deleting the row here races the create and
286
- // wedges the account's mailbox-sync FIFO group for a full visibility window.
287
- const { mailboxService, specialUseService, deleted } = buildServices([
288
- {
289
- mailboxId: "inbox",
290
- fullPath: "INBOX",
291
- syncStatus: MailboxSyncStatus.synced,
292
- },
299
+ for (const syncStatus of [
300
+ MailboxSyncStatus.pending,
301
+ MailboxSyncStatus.deleting,
302
+ ]) {
303
+ it(`keeps a \`${syncStatus}\` folder the server has not listed`, async () => {
304
+ // `pending`: the folder was just created locally and MAILBOX_CREATE has
305
+ // not reached the server, so the LIST omits it — deleting the row races
306
+ // the create and wedges the account's FIFO group for a visibility
307
+ // window (#290).
308
+ //
309
+ // `deleting`: the delete has landed on the server and the worker has not
310
+ // written back. Hard-deleting the row removes it under the client
311
+ // holding its id — the "Mailbox not found" of #333 — and removes it
312
+ // without the folder's mail, which the worker owns (D8).
313
+ const { mailboxService, specialUseService, deleted } = buildServices([
314
+ {
315
+ mailboxId: "inbox",
316
+ fullPath: "INBOX",
317
+ syncStatus: MailboxSyncStatus.synced,
318
+ },
319
+ {
320
+ mailboxId: "in-flight",
321
+ fullPath: "New Folder",
322
+ syncStatus,
323
+ },
324
+ ]);
325
+ const service = new MailboxSyncService(
326
+ mailboxService,
327
+ specialUseService,
328
+ silentLogger,
329
+ );
330
+
331
+ const result = await service.syncMailboxes(
332
+ { accountId: "acc-1" },
333
+ serverConnection(),
334
+ );
335
+
336
+ assert.deepEqual(deleted, []);
337
+ assert.equal(result.deleted, 0);
338
+ });
339
+
340
+ it(`keeps a \`${syncStatus}\` folder the server lists as \\Noselect`, async () => {
341
+ // A folder mid-create can transiently LIST as `\Noselect`, and the
342
+ // non-selectable branch deleted the row before looking at its state.
343
+ const { mailboxService, specialUseService, deleted } = buildServices([
344
+ {
345
+ mailboxId: "in-flight",
346
+ fullPath: "New Folder",
347
+ syncStatus,
348
+ },
349
+ ]);
350
+ const service = new MailboxSyncService(
351
+ mailboxService,
352
+ specialUseService,
353
+ silentLogger,
354
+ );
355
+
356
+ await service.syncMailboxes(
357
+ { accountId: "acc-1" },
358
+ serverConnection([
359
+ { fullPath: "INBOX" },
360
+ { fullPath: "New Folder", attributes: ["\\Noselect"] },
361
+ ]),
362
+ );
363
+
364
+ assert.deepEqual(deleted, []);
365
+ });
366
+ }
367
+
368
+ it("does not insert a second row for a path a recorded rename claims", async () => {
369
+ // D14. `fullPath` stays the path the server held when the intent was
370
+ // recorded, so once the server executes RENAME the LIST returns a path no
371
+ // row is keyed to. Without the pendingPath index the insert branch makes a
372
+ // second row under a fresh mailboxId and initial-syncs the folder's mail
373
+ // into it, leaving the original as a permanent phantom.
374
+ const { mailboxService, specialUseService, deleted, created } =
375
+ buildServices([
376
+ {
377
+ mailboxId: "inbox",
378
+ fullPath: "INBOX",
379
+ syncStatus: MailboxSyncStatus.synced,
380
+ },
381
+ {
382
+ mailboxId: "renaming",
383
+ fullPath: "Archive/2025",
384
+ syncStatus: MailboxSyncStatus.pending,
385
+ pendingPath: "Records/2025",
386
+ },
387
+ ]);
388
+ const service = new MailboxSyncService(
389
+ mailboxService,
390
+ specialUseService,
391
+ silentLogger,
392
+ );
393
+
394
+ const result = await service.syncMailboxes(
395
+ { accountId: "acc-1" },
396
+ serverConnection([{ fullPath: "INBOX" }, { fullPath: "Records/2025" }]),
397
+ );
398
+
399
+ assert.deepEqual(created, []);
400
+ assert.equal(result.created, 0);
401
+ assert.deepEqual(deleted, []);
402
+ });
403
+
404
+ it("lets a failed rename's target be discovered as a folder of its own", async () => {
405
+ // A failed rename keeps its target so the UI can name what it was aiming
406
+ // at (T6), and nothing clears it until the user retries or dismisses. If
407
+ // the guard honoured that claim, one stuck folder would block the sweep
408
+ // from ever seeing a real folder another client later created at that
409
+ // path — permanently, and silently.
410
+ const { mailboxService, specialUseService, created } = buildServices([
293
411
  {
294
- mailboxId: "pending-folder",
295
- fullPath: "New Folder",
296
- syncStatus: MailboxSyncStatus.pending,
412
+ mailboxId: "stuck",
413
+ fullPath: "Archive/2025",
414
+ syncStatus: MailboxSyncStatus.failed,
415
+ pendingPath: "Records/2025",
297
416
  },
298
417
  ]);
299
418
  const service = new MailboxSyncService(
@@ -302,13 +421,36 @@ describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending
302
421
  silentLogger,
303
422
  );
304
423
 
305
- const result = await service.syncMailboxes(
424
+ await service.syncMailboxes(
306
425
  { accountId: "acc-1" },
307
- serverConnection(),
426
+ serverConnection([
427
+ { fullPath: "INBOX" },
428
+ { fullPath: "Archive/2025" },
429
+ { fullPath: "Records/2025" },
430
+ ]),
308
431
  );
309
432
 
310
- assert.deepEqual(deleted, []);
311
- assert.equal(result.deleted, 0);
433
+ assert.deepEqual(
434
+ created.map((input) => input.fullPath),
435
+ ["INBOX", "Records/2025"],
436
+ );
437
+ });
438
+
439
+ it("writes syncStatus synced on the row it discovers", async () => {
440
+ const { mailboxService, specialUseService, created } = buildServices([]);
441
+ const service = new MailboxSyncService(
442
+ mailboxService,
443
+ specialUseService,
444
+ silentLogger,
445
+ );
446
+
447
+ await service.syncMailboxes(
448
+ { accountId: "acc-1" },
449
+ serverConnection([{ fullPath: "INBOX" }]),
450
+ );
451
+
452
+ assert.equal(created.length, 1);
453
+ assert.equal(created[0].syncStatus, MailboxSyncStatus.synced);
312
454
  });
313
455
 
314
456
  it("still deletes a synced folder that has left the server", async () => {
@@ -25,7 +25,10 @@ import {
25
25
  import pMap from "p-map";
26
26
  import { isNoSelect, parseImapAttributes } from "./attribute-mapper.js";
27
27
  import { isCursorRebuildNeeded } from "./mailbox-cursor.js";
28
- import { isMailboxNotOnServer } from "./mailbox-presence.js";
28
+ import {
29
+ isFolderMutationInFlight,
30
+ isMailboxMutationInFlight,
31
+ } from "./mailbox-presence.js";
29
32
  import type {
30
33
  FlatMailboxInfo,
31
34
  IImapConnection,
@@ -149,6 +152,28 @@ export class MailboxSyncService {
149
152
  const existingByPath = new Map(
150
153
  existingMailboxes.map((m) => [m.fullPath, m]),
151
154
  );
155
+ // The paths a rename is on its way to (D14). Under D2 `fullPath` stays the
156
+ // path the server held when the intent was recorded, so between the server
157
+ // executing RENAME and the settle, LIST returns a path no row is keyed to
158
+ // — and the insert branch below has no state guard at all. Without this it
159
+ // inserts a second row under a fresh mailboxId and initial-syncs the
160
+ // folder's mail into it, leaving the original as a permanent phantom still
161
+ // holding that folder's messages, filters and role appointments. No unique
162
+ // index on (accountId, fullPath) exists to catch it; #386 adds one.
163
+ //
164
+ // `pending` only. A failed rename keeps its target so the UI can name what
165
+ // it was aiming at (T6), and nothing clears it until the user retries or
166
+ // dismisses — so honouring a `failed` row's claim would let one stuck
167
+ // folder block the sweep from ever discovering a real folder another
168
+ // client later creates at that path.
169
+ const claimedByPendingRename = new Set(
170
+ existingMailboxes.flatMap((m) =>
171
+ m.syncStatus === MailboxSyncStatus.pending &&
172
+ m.pendingPath !== undefined
173
+ ? [m.pendingPath]
174
+ : [],
175
+ ),
176
+ );
152
177
 
153
178
  // Get namespaces and mailboxes from IMAP
154
179
  const namespaces = await connection.getNamespaces();
@@ -177,7 +202,15 @@ export class MailboxSyncService {
177
202
  seenPaths.add(mailboxInfo.fullPath);
178
203
  // If this mailbox exists in DB, delete it
179
204
  const existing = existingByPath.get(mailboxInfo.fullPath);
180
- if (existing) {
205
+ // A folder mid-create can transiently LIST as `\Noselect`, and
206
+ // deleting the row then races the create exactly as #290
207
+ // documented. The state check goes before the delete, through the
208
+ // same predicate the rest of the sweep asks.
209
+ if (existing && !isFolderMutationInFlight(existing)) {
210
+ // The duplicate-special-use branch below clears the special-use
211
+ // entries too; this one skipped them, orphaning a row per
212
+ // deleted mailbox.
213
+ await this.specialUseService.deleteByMailboxId(existing.mailboxId);
181
214
  await this.mailboxService.delete(
182
215
  account.accountId,
183
216
  existing.mailboxId,
@@ -199,7 +232,7 @@ export class MailboxSyncService {
199
232
  if (this.isDuplicateSpecialUse(mailboxInfo, claimedSpecialUse)) {
200
233
  seenPaths.add(mailboxInfo.fullPath);
201
234
  const existing = existingByPath.get(mailboxInfo.fullPath);
202
- if (existing) {
235
+ if (existing && !isFolderMutationInFlight(existing)) {
203
236
  await this.specialUseService.deleteByMailboxId(existing.mailboxId);
204
237
  await this.mailboxService.delete(
205
238
  account.accountId,
@@ -226,10 +259,7 @@ export class MailboxSyncService {
226
259
  // that establishes or removes it writes its own identity back, and
227
260
  // reading its status meanwhile is work whose only possible outcome is
228
261
  // a failure that fails this whole account's fan-out with it.
229
- if (
230
- existing.syncStatus === MailboxSyncStatus.pending ||
231
- existing.syncStatus === MailboxSyncStatus.deleting
232
- ) {
262
+ if (isFolderMutationInFlight(existing)) {
233
263
  return;
234
264
  }
235
265
  // The folder set can change under this sweep. A delete that lands
@@ -247,7 +277,7 @@ export class MailboxSyncService {
247
277
  ).catch(async (error: unknown) => {
248
278
  // The read only classifies the failure in hand; one that cannot
249
279
  // answer must not replace it.
250
- const gone = await isMailboxNotOnServer(
280
+ const gone = await isMailboxMutationInFlight(
251
281
  this.mailboxService,
252
282
  account.accountId,
253
283
  existing.mailboxId,
@@ -258,7 +288,7 @@ export class MailboxSyncService {
258
288
  if (updated) {
259
289
  result.updated++;
260
290
  }
261
- } else {
291
+ } else if (!claimedByPendingRename.has(mailboxInfo.fullPath)) {
262
292
  await this.createMailbox(
263
293
  account.accountId,
264
294
  mailboxInfo,
@@ -274,15 +304,20 @@ export class MailboxSyncService {
274
304
  // Handle deleted mailboxes (exist in DB but not on server)
275
305
  for (const existing of existingMailboxes) {
276
306
  if (seenPaths.has(existing.fullPath)) continue;
277
- // A `pending` row is a folder the user just created (or renamed) whose
278
- // MAILBOX_CREATE/RENAME has not yet reached the server, so its absence
279
- // from the LIST is expected, not a server-side deletion. Deleting it
280
- // races the create: the row vanishes, then MAILBOX_CREATE fails with
281
- // NotFoundError trying to mark it synced, and — sharing this account's
282
- // mailboxes FIFO group — that un-acked failure stalls every later
283
- // mailbox sync for the queue's whole visibility window (#290). Leave
284
- // pending rows to the create/rename flow that owns them.
285
- if (existing.syncStatus === MailboxSyncStatus.pending) continue;
307
+ // A row with a mutation in flight is a folder whose absence from the
308
+ // LIST is expected, not a server-side deletion. Deleting it races the
309
+ // worker: the row vanishes, then MAILBOX_CREATE fails with NotFoundError
310
+ // trying to mark it synced, and — sharing this account's mailboxes FIFO
311
+ // group — that un-acked failure stalls every later mailbox sync for the
312
+ // queue's whole visibility window (#290).
313
+ //
314
+ // `deleting` is here for a second reason. That window is exactly when a
315
+ // delete has landed on the server and the worker has not written back,
316
+ // so hard-deleting the row removes it under the client holding its id —
317
+ // the "Mailbox not found: <old id>" of #333 — and removes it without the
318
+ // folder's mail, which is the worker's job (D8). Leave both to the flow
319
+ // that owns them.
320
+ if (isFolderMutationInFlight(existing)) continue;
286
321
  await this.mailboxService.delete(account.accountId, existing.mailboxId);
287
322
  this.log.info(
288
323
  {
@@ -456,6 +491,10 @@ export class MailboxSyncService {
456
491
  highWaterMarkUid: 0,
457
492
  lastMessageSyncAt: 0,
458
493
  specialUse: parsed.specialUse.length > 0 ? parsed.specialUse : undefined,
494
+ // A folder the server just told us about is confirmed. The column
495
+ // defaults to this, so the row is right either way; the field is total
496
+ // now (D1) and the insert says what it means.
497
+ syncStatus: MailboxSyncStatus.synced,
459
498
  // parentMailboxId would need to be resolved from parentPath
460
499
  };
461
500