@remit/imap-worker 0.0.71 → 0.0.73

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/imap-worker",
3
- "version": "0.0.71",
3
+ "version": "0.0.73",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -39,7 +39,6 @@
39
39
  "@remit/logger-lambda": "*",
40
40
  "@remit/mailbox-service": "*",
41
41
  "@remit/search-index-worker": "*",
42
- "@remit/search-service": "*",
43
42
  "@remit/sqs-client": "*",
44
43
  "@remit/backend": "*",
45
44
  "@remit/domain-enums": "*",
@@ -4,6 +4,7 @@ import {
4
4
  APPENDED_UID_UNREPORTED,
5
5
  isSentCopyFiled,
6
6
  } from "@remit/data-ports";
7
+ import { isNotFoundError } from "@remit/data-ports/errors";
7
8
  import { OutboxMessageStatus } from "@remit/domain-enums";
8
9
  import type { Logger } from "@remit/logger-lambda";
9
10
  import {
@@ -14,7 +15,6 @@ import { attemptBudget } from "@remit/sqs-client/attempt-budget";
14
15
  import { isAccountDeleted } from "../account-check.js";
15
16
  import { createConnectionScopeWithCredentials } from "../connection-scope.js";
16
17
  import type { AppendSentMessageEvent } from "../events.js";
17
- import { isNotFoundError } from "../is-not-found.js";
18
18
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
19
19
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
20
20
 
@@ -45,6 +45,8 @@ interface Harness {
45
45
  localMessages: LocalMessage[];
46
46
  threadMessage: boolean;
47
47
  messagesWithoutListingRow: string[];
48
+ /** Message ids whose placement another lane has changed under the sweep. */
49
+ transitionsLost: string[];
48
50
  getConnectionCount: number;
49
51
  disconnectCount: number;
50
52
  }
@@ -88,6 +90,25 @@ const retryingIntoTrash = (messageId: string, uid: number): LocalMessage => ({
88
90
  syncStatus: "failed",
89
91
  });
90
92
 
93
+ /**
94
+ * A row whose move into Trash SETTLED, on a database written before #1217
95
+ * taught `updateUid` to clear `originalUid`. Two folders count uids
96
+ * independently, so Trash handing back the source's own number is ordinary —
97
+ * and the pair it leaves is indistinguishable from a move still in flight.
98
+ */
99
+ const settledWithStaleOriginalUid = (
100
+ messageId: string,
101
+ uid: number,
102
+ ): LocalMessage => ({
103
+ messageId,
104
+ uid,
105
+ status: "deleting",
106
+ syncStatus: "pending",
107
+ mailboxId: "trash-mbx",
108
+ originalMailboxId: "inbox-mbx",
109
+ originalUid: uid,
110
+ });
111
+
91
112
  /** The same row after an empty marked the folder, `status` overwritten. */
92
113
  const markedMidMove = (messageId: string, uid: number): LocalMessage => ({
93
114
  ...movingIntoTrash(messageId, uid),
@@ -106,6 +127,7 @@ const fresh = (): Harness => ({
106
127
  localMessages: [deleting("msg-1", 10), deleting("msg-2", 11)],
107
128
  threadMessage: true,
108
129
  messagesWithoutListingRow: [],
130
+ transitionsLost: [],
109
131
  getConnectionCount: 0,
110
132
  disconnectCount: 0,
111
133
  });
@@ -140,6 +162,18 @@ const deps = (): EmptyTrashDeps =>
140
162
  listAllByMailbox: async () => h.localMessages,
141
163
  delete: record("message.delete"),
142
164
  update: record("message.update"),
165
+ transitionPlacement: async (
166
+ messageId: string,
167
+ expected: unknown,
168
+ next: unknown,
169
+ ) => {
170
+ const won = !h.transitionsLost.includes(messageId);
171
+ h.calls.push({
172
+ method: "message.transitionPlacement",
173
+ args: [messageId, expected, next, won],
174
+ });
175
+ return won ? { messageId } : undefined;
176
+ },
143
177
  },
144
178
  threadMessage: {
145
179
  findByMessageId: async (_cfg: string, messageId: string) =>
@@ -193,13 +227,16 @@ const event: EmptyTrashEvent = {
193
227
  const called = (method: string): Call[] =>
194
228
  h.calls.filter((c) => c.method === method);
195
229
 
230
+ // Only a transition that WON reverted anything; a lost predicate is an offered
231
+ // write that the row refused.
196
232
  const revertedMessageIds = (): string[] =>
197
- called("message.update")
233
+ called("message.transitionPlacement")
198
234
  .filter(
199
235
  (c) =>
200
- (c.args[1] as { status?: string; syncStatus?: string }).status ===
236
+ c.args[3] === true &&
237
+ (c.args[2] as { status?: string; syncStatus?: string }).status ===
201
238
  "active" &&
202
- (c.args[1] as { syncStatus?: string }).syncStatus === "synced",
239
+ (c.args[2] as { syncStatus?: string }).syncStatus === "synced",
203
240
  )
204
241
  .map((c) => c.args[0] as string);
205
242
 
@@ -498,6 +535,84 @@ describe("handleEmptyTrash and an unsettled placement", () => {
498
535
  assert.deepEqual(undeletedThreadMessageIds(), ["tm-msg-marked"]);
499
536
  });
500
537
 
538
+ it("hands nothing back for a row another lane settled after the sweep read it", async () => {
539
+ // The hand-back is a transition off the row as this sweep read it
540
+ // (imap-mutations R3). PLACEMENT_MOVE_PUSH rides a standard queue that
541
+ // this account's FIFO group does not order, so it can settle a row
542
+ // mid-sweep; writing `active` + `synced` over that would declare a
543
+ // mutation settled that nobody confirmed, and take the listing row with
544
+ // it.
545
+ h.localMessages = [deleting("msg-1", 10), markedMidMove("msg-raced", 11)];
546
+ h.transitionsLost = ["msg-raced"];
547
+
548
+ await handleEmptyTrash(event, noopLogger, deps());
549
+
550
+ assert.deepEqual(
551
+ called("message.transitionPlacement").map((c) => c.args[0]),
552
+ ["msg-raced"],
553
+ "the predicate was still offered — the row just lost it",
554
+ );
555
+ assert.deepEqual(revertedMessageIds(), []);
556
+ assert.deepEqual(
557
+ undeletedThreadMessageIds(),
558
+ [],
559
+ "a lost predicate writes nothing at all, listing rows included",
560
+ );
561
+ });
562
+
563
+ it("predicates the hand-back on the placement the sweep actually read", async () => {
564
+ h.localMessages = [markedMidMove("msg-marked", 11)];
565
+
566
+ await handleEmptyTrash(event, noopLogger, deps());
567
+
568
+ const [call] = called("message.transitionPlacement");
569
+ assert.deepEqual(call?.args[1], {
570
+ status: "deleting",
571
+ mailboxId: "trash-mbx",
572
+ uid: 11,
573
+ });
574
+ });
575
+
576
+ // Issue #1230. The exclusion used to run after `deleteMessages`, so a row
577
+ // the sweep refused had already lost its server copy: mail that no longer
578
+ // exists, visible in Trash, with nothing able to clear it — pressing Empty
579
+ // Trash again skips it too, because the server no longer lists the uid.
580
+ it("never expunges a uid whose row it will refuse to remove", async () => {
581
+ h.localMessages = [
582
+ deleting("msg-1", 10),
583
+ settledWithStaleOriginalUid("msg-stale", 11),
584
+ ];
585
+
586
+ await handleEmptyTrash(event, noopLogger, deps());
587
+
588
+ assert.deepEqual(
589
+ called("connection.deleteMessages")[0]?.args,
590
+ [[10]],
591
+ "uid 11 survives on the server, because its row survives locally",
592
+ );
593
+ assert.deepEqual(
594
+ called("message.delete").map((c) => c.args[0]),
595
+ ["msg-1"],
596
+ );
597
+ assert.deepEqual(
598
+ revertedMessageIds(),
599
+ ["msg-stale"],
600
+ "and the refused row is handed back rather than left marked",
601
+ );
602
+ });
603
+
604
+ it("issues no expunge at all when every uid in the folder is refused", async () => {
605
+ h.localMessages = [
606
+ settledWithStaleOriginalUid("msg-stale-a", 10),
607
+ settledWithStaleOriginalUid("msg-stale-b", 11),
608
+ ];
609
+
610
+ await handleEmptyTrash(event, noopLogger, deps());
611
+
612
+ assert.equal(called("connection.deleteMessages").length, 0);
613
+ assert.equal(called("message.delete").length, 0);
614
+ });
615
+
501
616
  it("sweeps a settled row whose Trash uid matches the one it left behind", async () => {
502
617
  // Two folders count uids independently, so a move can land on the same
503
618
  // number it started from. `updateUid` drops `originalUid` when it settles,
@@ -1,10 +1,12 @@
1
1
  import { getClient } from "@remit/backend/client";
2
2
  import type { MessageItem } from "@remit/data-ports";
3
+ import { isNotFoundError } from "@remit/data-ports/errors";
3
4
  import { trashMailboxAt } from "@remit/data-ports/folder-role";
4
5
  import { isCurrentSchemaVersion } from "@remit/data-ports/mutation-events";
5
6
  import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
6
7
  import type { Logger } from "@remit/logger-lambda";
7
8
  import {
9
+ buildThreadMessageUndelete,
8
10
  carriesForeignUid,
9
11
  guardConnectionCursor,
10
12
  isCursorRebuildNeeded,
@@ -13,10 +15,8 @@ import {
13
15
  import { isAccountDeleted } from "../account-check.js";
14
16
  import { createConnectionScopeWithCredentials } from "../connection-scope.js";
15
17
  import type { EmptyTrashEvent } from "../events.js";
16
- import { isNotFoundError } from "../is-not-found.js";
17
18
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
18
19
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
19
- import { buildThreadMessageUndelete } from "./thread-message-rows.js";
20
20
 
21
21
  export interface EmptyTrashDeps {
22
22
  getClient: typeof getClient;
@@ -89,7 +89,10 @@ export const handleEmptyTrash = async (
89
89
  // an unsettled move is rewritten by its own MESSAGE_DELETE. Not `failed`:
90
90
  // the mail is intact, and saying otherwise about a whole folder is a lie.
91
91
  const handBackMarkedRows = async (
92
- rows: readonly { messageId: string; status: MessageItem["status"] }[],
92
+ rows: readonly Pick<
93
+ MessageItem,
94
+ "messageId" | "status" | "mailboxId" | "uid"
95
+ >[],
93
96
  ): Promise<number> => {
94
97
  let revertedCount = 0;
95
98
  for (const message of rows) {
@@ -105,10 +108,30 @@ export const handleEmptyTrash = async (
105
108
  );
106
109
  if (threadMessages.length === 0) continue;
107
110
 
108
- await messageService.update(message.messageId, {
109
- status: MessageStatus.active,
110
- syncStatus: MessageSyncStatus.synced,
111
- });
111
+ // The hand-back is a transition off the row as this sweep read it
112
+ // (imap-mutations R3), because the snapshot it walks holds nothing:
113
+ // between the listing and here another lane can settle the row onto a
114
+ // placement of its own, and writing `active` + `synced` over that would
115
+ // declare a mutation settled that nobody confirmed.
116
+ const restored = await messageService.transitionPlacement(
117
+ message.messageId,
118
+ {
119
+ status: MessageStatus.deleting,
120
+ mailboxId: message.mailboxId,
121
+ uid: message.uid,
122
+ },
123
+ {
124
+ status: MessageStatus.active,
125
+ syncStatus: MessageSyncStatus.synced,
126
+ },
127
+ );
128
+ if (!restored) {
129
+ log.info(
130
+ { accountId, messageId: message.messageId },
131
+ "Empty Trash left a row alone: its placement changed after the sweep read it",
132
+ );
133
+ continue;
134
+ }
112
135
  for (const threadMessage of threadMessages) {
113
136
  const args = buildThreadMessageUndelete(threadMessage);
114
137
  await threadMessageService.update(
@@ -234,39 +257,75 @@ export const handleEmptyTrash = async (
234
257
  return;
235
258
  }
236
259
 
237
- const uids = await connection.search(["ALL"]);
260
+ // One read, before the expunge, decides both what is destroyed on
261
+ // the server and what is removed locally — so the two sets are the
262
+ // same set by construction and no message can be expunged that the
263
+ // sweep then declines to remove (issue #1230). Reading afterwards
264
+ // put the exclusion downstream of the destruction: a refused row
265
+ // had already lost its server copy, could not be refused into
266
+ // existence again, and nothing routine repaired it.
267
+ //
268
+ // Reconciles, never waits (imap-mutations R2). A row whose move
269
+ // into Trash has not settled names this folder while still
270
+ // carrying the SOURCE folder's uid, so matching it against the
271
+ // expunge answers for whatever Trash held at that uid — another
272
+ // message, deleted here in both its rows. Waiting is not open to
273
+ // this handler the way it is to the API-side mutators: a move on
274
+ // this account's own FIFO group cannot run until this returns, so
275
+ // the ceiling would be spent to reach the same answer. It buys
276
+ // nothing against the other lane either — PLACEMENT_MOVE_PUSH
277
+ // rides a standard queue with no group at all
278
+ // (`deploy/vps/queues.json`), so it is ordered against nothing
279
+ // here and can settle a row mid-sweep. That is why the writes
280
+ // below are transitions rather than snapshot writes (R3).
281
+ //
282
+ // `carriesForeignUid`, not the placement binding: the binding reads
283
+ // `status`, and `status` is exactly what an operation marking this
284
+ // folder overwrites. This is the same predicate `emptyTrash`
285
+ // applies before it marks anything — one gate, one answer, now
286
+ // asked once and honoured on both sides of the expunge.
287
+ const localMessages =
288
+ await messageService.listAllByMailbox(trashMailboxId);
289
+ const sweepable = localMessages.filter(
290
+ (message) => !carriesForeignUid(message),
291
+ );
292
+
293
+ // A refused row spares its uid only where no row the sweep will
294
+ // carry claims the same number. The two cases the predicate cannot
295
+ // tell apart part here: a row genuinely mid-move borrows a uid that
296
+ // belongs to a different message in this folder, and sparing that
297
+ // number would strand the message that owns it; a settled row whose
298
+ // `originalUid` a pre-#1217 build left behind owns its uid outright,
299
+ // and expunging it destroys mail the sweep then refuses to remove.
300
+ const sweepableUids = new Set(
301
+ sweepable.map((message) => message.uid),
302
+ );
303
+ const refusedUids = new Set(
304
+ localMessages
305
+ .filter(
306
+ (message) =>
307
+ carriesForeignUid(message) && !sweepableUids.has(message.uid),
308
+ )
309
+ .map((message) => message.uid),
310
+ );
311
+
312
+ const uids = (await connection.search(["ALL"])).filter(
313
+ (uid) => !refusedUids.has(uid),
314
+ );
238
315
 
239
316
  if (uids.length > 0) {
240
317
  await connection.deleteMessages(uids);
241
318
  log.info(
242
- { count: uids.length },
319
+ { count: uids.length, refused: refusedUids.size },
243
320
  "Deleted messages from IMAP trash",
244
321
  );
245
322
  }
246
323
 
247
- // Local cleanup follows the expunge, uid by uid. What was expunged
248
- // is a fact this connection observed, and only those rows go.
324
+ // What was expunged is a fact this connection observed, and only
325
+ // those rows go.
249
326
  const expunged = new Set(uids);
250
- const localMessages =
251
- await messageService.listAllByMailbox(trashMailboxId);
252
-
253
- // Reconciles, never waits (imap-mutations R2). A row whose move
254
- // into Trash has not settled names this folder while still
255
- // carrying the SOURCE folder's uid, so matching it against the
256
- // expunge answers for whatever Trash held at that uid — another
257
- // message, deleted here in both its rows. Waiting is not open to
258
- // this handler the way it is to the API-side mutators: every event
259
- // of an account shares one FIFO group, so the move that would
260
- // settle the row cannot run until this returns, and the ceiling
261
- // would be spent to reach the same answer.
262
- //
263
- // `carriesForeignUid`, not the placement binding: the binding reads
264
- // `status`, and `status` is exactly what an operation marking this
265
- // folder overwrites. This is the second half of the gate
266
- // `emptyTrash` applies before it marks anything.
267
- const swept = localMessages.filter(
268
- (message) =>
269
- !carriesForeignUid(message) && expunged.has(message.uid),
327
+ const swept = sweepable.filter((message) =>
328
+ expunged.has(message.uid),
270
329
  );
271
330
  const sweptIds = new Set(swept.map((message) => message.messageId));
272
331
  const deletedCount = swept.length;
@@ -26,7 +26,8 @@ import type {
26
26
  MessageFlagPushItem,
27
27
  MessageItem,
28
28
  MessagePlacementMoveItem,
29
- UpdateMessageMoveInput,
29
+ PlacementPredicate,
30
+ PlacementTransitionInput,
30
31
  } from "@remit/data-ports";
31
32
  import type { Logger } from "@remit/logger-lambda";
32
33
  import {
@@ -87,11 +88,24 @@ const buildHarness = (): Harness => {
87
88
  assert.equal(messageId, MESSAGE_ID);
88
89
  return row;
89
90
  },
90
- updateForMove: async (
91
+ transitionPlacement: async (
91
92
  _messageId: string,
92
- input: UpdateMessageMoveInput,
93
+ expected: PlacementPredicate,
94
+ next: PlacementTransitionInput,
93
95
  ) => {
94
- Object.assign(row, input);
96
+ if (expected.status !== undefined && expected.status !== row.status) {
97
+ return undefined;
98
+ }
99
+ if (expected.uid !== undefined && expected.uid !== row.uid) {
100
+ return undefined;
101
+ }
102
+ if (
103
+ expected.mailboxId !== undefined &&
104
+ expected.mailboxId !== row.mailboxId
105
+ ) {
106
+ return undefined;
107
+ }
108
+ Object.assign(row, next);
95
109
  return row;
96
110
  },
97
111
  } as unknown as IMessageRepository;
@@ -1,4 +1,5 @@
1
1
  import { getClient } from "@remit/backend/client";
2
+ import { isNotFoundError } from "@remit/data-ports/errors";
2
3
  import type { Logger } from "@remit/logger-lambda";
3
4
  import { recordImapFailure } from "@remit/logger-lambda";
4
5
  import {
@@ -12,7 +13,6 @@ import { attemptBudget } from "@remit/sqs-client/attempt-budget";
12
13
  import { isAccountDeleted } from "../account-check.js";
13
14
  import { createConnectionScopeWithCredentials } from "../connection-scope.js";
14
15
  import type { FlagPushEvent } from "../events.js";
15
- import { isNotFoundError } from "../is-not-found.js";
16
16
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
17
17
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
18
18
 
@@ -1,4 +1,5 @@
1
1
  import { getClient } from "@remit/backend/client";
2
+ import { isNotFoundError } from "@remit/data-ports/errors";
2
3
  import { MailboxSyncStatus } from "@remit/domain-enums";
3
4
  import type { Logger } from "@remit/logger-lambda";
4
5
  import { MailboxManagementService } from "@remit/mailbox-service";
@@ -10,7 +11,6 @@ import type {
10
11
  MailboxManagementEvent,
11
12
  MailboxRenameEvent,
12
13
  } from "../events.js";
13
- import { isNotFoundError } from "../is-not-found.js";
14
14
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
15
15
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
16
16
 
@@ -112,6 +112,10 @@ const deps = (): MessageCopyDeps =>
112
112
  },
113
113
  updateUid: record("message.updateUid"),
114
114
  update: record("message.update"),
115
+ transitionPlacement: async (...args: unknown[]) => {
116
+ h.calls.push({ method: "message.transitionPlacement", args });
117
+ return { messageId: "new-msg" };
118
+ },
115
119
  delete: record("message.delete"),
116
120
  },
117
121
  threadMessage: {
@@ -191,11 +195,9 @@ describe("handleMessageCopy", () => {
191
195
  20,
192
196
  "dst-mbx",
193
197
  ]);
194
- const statusUpdate = called("message.update")[0];
195
- assert.equal(
196
- (statusUpdate?.args[1] as { syncStatus?: string })?.syncStatus,
197
- "synced",
198
- );
198
+ // `updateUid` writes the confirmed uid, `active` and `synced` in one
199
+ // statement, so there is no second, unpredicated write behind it.
200
+ assert.equal(called("message.update").length, 0);
199
201
  assert.equal(called("threadMessage.update").length, 1);
200
202
  assert.equal(h.disconnectCount, 1, "the scope is always disconnected");
201
203
  });
@@ -212,11 +214,7 @@ describe("handleMessageCopy", () => {
212
214
  42,
213
215
  "dst-mbx",
214
216
  ]);
215
- assert.equal(
216
- (called("message.update")[0]?.args[1] as { syncStatus?: string })
217
- ?.syncStatus,
218
- "synced",
219
- );
217
+ assert.equal(called("message.update").length, 0);
220
218
  assert.equal(called("threadMessage.update").length, 1);
221
219
  });
222
220
 
@@ -263,11 +261,15 @@ describe("handleMessageCopy", () => {
263
261
 
264
262
  assert.equal(copies, 1, "the COPY is issued once");
265
263
  assert.equal(called("search").length, 0, "there is nothing to ask with");
266
- const update = called("message.update")[0];
267
- assert.equal((update?.args[1] as { status?: string })?.status, "deleted");
268
- assert.equal(
269
- (update?.args[1] as { syncStatus?: string })?.syncStatus,
270
- "failed",
264
+ const [givenUp] = called("message.transitionPlacement");
265
+ assert.deepEqual(
266
+ givenUp?.args[2],
267
+ {
268
+ status: "deleted",
269
+ syncStatus: "abandoned",
270
+ abandonedMutation: "copy",
271
+ },
272
+ "a copy that gave up carries the give-up value and names itself",
271
273
  );
272
274
  assert.equal(called("message.delete").length, 0, "no row is thrown away");
273
275
  });
@@ -302,8 +304,8 @@ describe("handleMessageCopy", () => {
302
304
 
303
305
  assert.equal(copies, 1, "the redelivery issues no second COPY");
304
306
  assert.equal(called("search").length, 0);
305
- const settled = called("message.update").at(-1);
306
- assert.equal((settled?.args[1] as { status?: string })?.status, "deleted");
307
+ const settled = called("message.transitionPlacement").at(-1);
308
+ assert.equal((settled?.args[2] as { status?: string })?.status, "deleted");
307
309
  });
308
310
 
309
311
  it("acks a copy that already settled without touching IMAP", async () => {
@@ -327,6 +329,7 @@ describe("handleMessageCopy", () => {
327
329
 
328
330
  assert.equal(h.getConnectionCount, 0);
329
331
  assert.equal(called("message.update").length, 0);
332
+ assert.equal(called("message.transitionPlacement").length, 0);
330
333
  });
331
334
 
332
335
  it("returns early without connecting when the account is soft-deleted", async () => {
@@ -358,6 +361,7 @@ describe("handleMessageCopy", () => {
358
361
  assert.equal(h.getConnectionCount, 0);
359
362
  assert.equal(called("message.updateUid").length, 0);
360
363
  assert.equal(called("message.update").length, 0);
364
+ assert.equal(called("message.transitionPlacement").length, 0);
361
365
  });
362
366
 
363
367
  // Issue #1203. Both pauses are reached before `copyMessages`, so the COPY
@@ -427,7 +431,11 @@ describe("handleMessageCopy", () => {
427
431
  "silence is never grounds to delete a row that may describe real mail",
428
432
  );
429
433
  assert.equal(
430
- (called("message.update")[0]?.args[1] as { status?: string })?.status,
434
+ (
435
+ called("message.transitionPlacement")[0]?.args[2] as {
436
+ status?: string;
437
+ }
438
+ )?.status,
431
439
  "deleted",
432
440
  );
433
441
  });
@@ -505,11 +513,33 @@ describe("handleMessageCopy", () => {
505
513
 
506
514
  await handleMessageCopy(event, noopLogger, 1, deps());
507
515
 
508
- const update = called("message.update")[0];
509
- assert.equal((update?.args[1] as { status?: string })?.status, "deleted");
516
+ const [givenUp] = called("message.transitionPlacement");
517
+ assert.equal((givenUp?.args[2] as { status?: string })?.status, "deleted");
518
+ assert.equal(
519
+ (givenUp?.args[2] as { abandonedMutation?: string })?.abandonedMutation,
520
+ "copy",
521
+ );
510
522
  assert.equal(called("createMailbox").length, 0);
511
523
  });
512
524
 
525
+ // The guard asks whether THIS copy is still outstanding, not whether anything
526
+ // is. A row a delete has claimed is `deleting`, and reading that as unsettled
527
+ // let a redelivery COPY the message a second time — COPY has no source-side
528
+ // effect to make that a no-op, so the duplicate is permanent.
529
+ it("skips a redelivered copy whose row a delete has claimed", async () => {
530
+ let copies = 0;
531
+ h.connection.copyMessages = async () => {
532
+ copies += 1;
533
+ return { uidMap: new Map([[10, 20]]) };
534
+ };
535
+ h.copyRow = { ...unsettledCopyRow(), status: "deleting" };
536
+
537
+ await handleMessageCopy(event, noopLogger, 2, deps());
538
+
539
+ assert.equal(copies, 0, "no second copy is issued");
540
+ assert.equal(called("message.updateUid").length, 0);
541
+ });
542
+
513
543
  it("marks failed and rethrows on an unclassified IMAP error within the budget", async () => {
514
544
  h.connection.copyMessages = async () => {
515
545
  throw new Error("server exploded");
@@ -520,11 +550,12 @@ describe("handleMessageCopy", () => {
520
550
  /server exploded/,
521
551
  );
522
552
 
523
- const update = called("message.update")[0];
524
- assert.equal(
525
- (update?.args[1] as { syncStatus?: string })?.syncStatus,
526
- "failed",
527
- );
553
+ // The attempt marker is a transition too, predicated on this copy still
554
+ // being outstanding: a row another mutation has claimed keeps its own
555
+ // placement rather than picking up this attempt's failure.
556
+ const [marker] = called("message.transitionPlacement");
557
+ assert.deepEqual(marker?.args[1], { status: "moving" });
558
+ assert.deepEqual(marker?.args[2], { syncStatus: "failed" });
528
559
  });
529
560
 
530
561
  it("settles on the destination UID instead of dead-lettering the last attempt", async () => {