@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.
@@ -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
 
@@ -30,8 +30,11 @@ const buildWorld = (trashExists: boolean) => {
30
30
  get: async () => [message],
31
31
  update: async (_id: string, patch: Record<string, unknown>) =>
32
32
  Object.assign(message, patch),
33
- updateForMove: async (_id: string, patch: Record<string, unknown>) =>
34
- Object.assign(message, patch),
33
+ transitionPlacement: async (
34
+ _id: string,
35
+ _expected: Record<string, unknown>,
36
+ patch: Record<string, unknown>,
37
+ ) => Object.assign(message, patch),
35
38
  } as unknown as IMessageRepository;
36
39
 
37
40
  const threadMessageService = {
@@ -52,7 +52,11 @@ const buildWorld = (
52
52
  patches.push(patch);
53
53
  return Object.assign(message, patch);
54
54
  },
55
- updateForMove: async (_id: string, patch: Record<string, unknown>) => {
55
+ transitionPlacement: async (
56
+ _id: string,
57
+ _expected: Record<string, unknown>,
58
+ patch: Record<string, unknown>,
59
+ ) => {
56
60
  patches.push(patch);
57
61
  return Object.assign(message, patch);
58
62
  },
@@ -68,11 +68,20 @@ const movingRow = () => ({
68
68
  originalUid: INBOX_UID,
69
69
  });
70
70
 
71
- /** The same row after the trash-move handler gave up without confirming. */
71
+ /**
72
+ * A row a delete gave up on. R3 defines the give-up as a hand-back: the row
73
+ * goes back on the placement the mail server confirmed, and only then carries
74
+ * `abandoned`. The pair is honest, so nothing downstream refuses it — the
75
+ * failure is a statement the reading pane makes, never a gate.
76
+ */
72
77
  const strandedRow = () => ({
73
- ...movingRow(),
74
78
  messageId: STRANDED_ID,
75
- syncStatus: "failed",
79
+ mailboxId: INBOX,
80
+ uid: INBOX_UID,
81
+ status: "active",
82
+ syncStatus: "abandoned",
83
+ abandonedMutation: "delete",
84
+ originalMailboxId: INBOX,
76
85
  });
77
86
 
78
87
  /** An ordinary settled row. */
@@ -127,7 +136,11 @@ const buildWorld = (seed: Array<Record<string, unknown>>) => {
127
136
  patches.push({ messageId: id, patch });
128
137
  return Object.assign(rows.get(id) ?? {}, patch);
129
138
  },
130
- updateForMove: async (id: string, patch: Record<string, unknown>) => {
139
+ transitionPlacement: async (
140
+ id: string,
141
+ _expected: Record<string, unknown>,
142
+ patch: Record<string, unknown>,
143
+ ) => {
131
144
  patches.push({ messageId: id, patch });
132
145
  return Object.assign(rows.get(id) ?? {}, patch);
133
146
  },
@@ -329,23 +342,18 @@ describe("the gate refuses only a uid that names somebody else (#845.3)", () =>
329
342
  assert.ok(Date.now() - startedAt < 100, "and never entered the wait");
330
343
  });
331
344
 
332
- it("refuses a row stranded by a move that gave up, without spending the ceiling", async () => {
333
- // `syncStatus: failed` with `status: moving` is the shape every handler's
334
- // give-up path leaves behind, and only `updateUid` clears it. The pair is
335
- // still a lie, so the delete is still refused — but under a reason whose
336
- // remedy is a resync, and without a wait that could never succeed.
345
+ it("acts on a row a delete gave up on, rather than refusing it", async () => {
346
+ // A give-up hands the row back to the placement the server confirmed
347
+ // before it stops (R3), so the "Delete again" the reading pane offers is
348
+ // an ordinary delete. Refusing it — which is what a give-up left `moving`
349
+ // used to earn — made that button a dead end.
337
350
  const { service, events } = buildWorld([strandedRow()]);
338
351
 
339
352
  const startedAt = Date.now();
340
- await assert.rejects(
341
- () => service.deleteMessages(ACCOUNT_CONFIG, [STRANDED_ID], ACCOUNT),
342
- (error: unknown) =>
343
- error instanceof MessagePlacementUnsettledError &&
344
- error.publicApiError?.details?.reason === "unverified",
345
- );
353
+ await service.deleteMessages(ACCOUNT_CONFIG, [STRANDED_ID], ACCOUNT);
346
354
 
347
- assert.deepEqual(events, []);
348
- assert.ok(Date.now() - startedAt < 100, "refused without waiting");
355
+ assert.equal(events.length, 1);
356
+ assert.ok(Date.now() - startedAt < 100, "and never entered the wait");
349
357
  });
350
358
  });
351
359