@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.
@@ -4,6 +4,10 @@ import type { ThreadMessageItem } from "@remit/data-ports";
4
4
  import type { Logger } from "@remit/logger-lambda";
5
5
  import { renderMetrics, resetMetrics } from "@remit/logger-lambda";
6
6
  import { noopLogger } from "@remit/logger-lambda/noop-logger";
7
+ import {
8
+ buildThreadMessageTrashUpdate,
9
+ buildThreadMessageUndelete,
10
+ } from "@remit/mailbox-service";
7
11
  import type { MessageDeleteEvent } from "../events.js";
8
12
  import {
9
13
  deleteAllThreadMessagesForMessage,
@@ -12,10 +16,6 @@ import {
12
16
  MESSAGE_DELETE_MAX_ATTEMPTS,
13
17
  type MessageDeleteDeps,
14
18
  } from "./message-delete.js";
15
- import {
16
- buildThreadMessageTrashUpdate,
17
- buildThreadMessageUndelete,
18
- } from "./thread-message-rows.js";
19
19
 
20
20
  describe("getMessageDeleteMaxAttempts — env-derived threshold (#980)", () => {
21
21
  it("parses the injected env var", () => {
@@ -293,7 +293,9 @@ interface Harness {
293
293
  mailboxError?: Error;
294
294
  connection: Connection;
295
295
  threadMessageUpdateError?: Error;
296
- messageRow: { messageIdHeader?: string } | undefined;
296
+ messageRow: { messageIdHeader?: string; status?: string } | undefined;
297
+ /** The row was settled by another lane, so every transition loses. */
298
+ transitionLost?: boolean;
297
299
  destinationSearchUids: number[];
298
300
  // A Message-ID SEARCH is answered by whichever folder was opened last, so a
299
301
  // case can put the same header in both. Default: the source still holds it,
@@ -396,7 +398,10 @@ const fresh = (): Harness => ({
396
398
  calls: [],
397
399
  account: { accountId: "acc-1", accountConfigId: "cfg-1" },
398
400
  mailbox: { mailboxId: "src-mbx", uidValidity: 1, cursorState: undefined },
399
- messageRow: { messageIdHeader: MESSAGE_ID_HEADER },
401
+ // What the API writes before it enqueues: a move to Trash records `moving`,
402
+ // a permanent delete records `deleting`, and the handler's guard asks one
403
+ // question of both — is a mutation still outstanding on this row.
404
+ messageRow: { messageIdHeader: MESSAGE_ID_HEADER, status: "moving" },
400
405
  destinationSearchUids: [],
401
406
  sourceSearchUids: [10],
402
407
  openBoxPath: "INBOX",
@@ -431,7 +436,10 @@ const deps = (): MessageDeleteDeps =>
431
436
  return h.messageRow ? [h.messageRow] : [];
432
437
  },
433
438
  updateUid: record("message.updateUid"),
434
- updateForMove: record("message.updateForMove"),
439
+ transitionPlacement: async (...args: unknown[]) => {
440
+ h.calls.push({ method: "message.transitionPlacement", args });
441
+ return h.transitionLost ? undefined : { messageId: "msg-1" };
442
+ },
435
443
  update: record("message.update"),
436
444
  delete: record("message.delete"),
437
445
  },
@@ -502,17 +510,60 @@ const permanentEvent: MessageDeleteEvent = {
502
510
  operation: "permanent_delete",
503
511
  } as MessageDeleteEvent;
504
512
 
513
+ /**
514
+ * A permanent delete's row carries `deleting`, which is the state its own guard
515
+ * asks for — the move to Trash's is `moving`, and the default fixture is that.
516
+ * Called at the point of the handler call so the row is right for the event.
517
+ */
518
+ const permanent = (): MessageDeleteEvent => {
519
+ if (h.messageRow) h.messageRow.status = "deleting";
520
+ return permanentEvent;
521
+ };
522
+
505
523
  const called = (method: string): Call[] =>
506
524
  h.calls.filter((c) => c.method === method);
507
525
 
526
+ /**
527
+ * The transient attempt marker: the one transition that writes nothing but the
528
+ * attempt's own sync status, predicated on this delete still being outstanding.
529
+ * Told apart from the hand-back transitions, which write a whole placement.
530
+ */
531
+ /**
532
+ * The hand-back transitions: the ones that write a whole placement back onto
533
+ * the row. Told apart from the attempt marker, which writes only a sync status,
534
+ * and from the claim that precedes a removal, which writes only a status.
535
+ */
536
+ const restoreCalls = (): Call[] =>
537
+ called("message.transitionPlacement").filter(
538
+ (candidate) =>
539
+ (candidate.args[2] as { mailboxId?: string }).mailboxId !== undefined,
540
+ );
541
+
542
+ const attemptMarkerSyncStatus = (): string | undefined => {
543
+ const call = called("message.transitionPlacement").find(
544
+ (candidate) => Object.keys(candidate.args[2] as object).length === 1,
545
+ );
546
+ return (call?.args[2] as { syncStatus?: string })?.syncStatus;
547
+ };
548
+
508
549
  // The one write that puts a given-up delete back on its source pair. It clears
509
550
  // `moving` in the same write as the pair, which is what keeps a later delete
510
- // from waiting on a mutation that has terminated (#1005), and it goes through
511
- // `updateForMove` rather than `updateUid` so a row that never moved does not
512
- // enqueue a search re-index.
551
+ // from waiting on a mutation that has terminated (#1005), and it is a
552
+ // transition rather than a plain update so a row another lane already settled
553
+ // keeps its own placement (imap-mutations R3).
513
554
  const restoredToSource = (syncStatus: string): unknown[] => [
514
555
  "msg-1",
515
- { mailboxId: "src-mbx", uid: 10, status: "active", syncStatus },
556
+ { status: ["moving", "deleting"] },
557
+ {
558
+ mailboxId: "src-mbx",
559
+ uid: 10,
560
+ status: "active",
561
+ syncStatus,
562
+ // A give-up names the mutation it gave up on, so the reading pane can
563
+ // offer the way out for THAT one (#1229). `none` rides along with every
564
+ // other sync status, so the field never outlives its gate.
565
+ abandonedMutation: syncStatus === "abandoned" ? "delete" : "none",
566
+ },
516
567
  ];
517
568
 
518
569
  // Label order in the rendered text is prom-client's, not ours.
@@ -661,11 +712,7 @@ describe("handleMessageDelete", () => {
661
712
  0,
662
713
  "an unconfirmed move must never revert the listing row to the source",
663
714
  );
664
- assert.equal(
665
- (called("message.update")[0]?.args[1] as { syncStatus?: string })
666
- ?.syncStatus,
667
- "failed",
668
- );
715
+ assert.equal(attemptMarkerSyncStatus(), "failed");
669
716
  assert.equal(h.disconnectCount, 1);
670
717
  });
671
718
 
@@ -699,7 +746,7 @@ describe("handleMessageDelete", () => {
699
746
  it("settles on the first attempt when the row carries no Message-ID header", async () => {
700
747
  h.connection.moveMessages = async () => ({ uidMap: new Map() });
701
748
  sourceNoLongerHoldsTheUid();
702
- h.messageRow = {};
749
+ h.messageRow = { status: "moving" };
703
750
 
704
751
  await handleMessageDelete(moveEvent, noopLogger, 1, deps());
705
752
 
@@ -738,6 +785,79 @@ describe("handleMessageDelete", () => {
738
785
  assert.equal(called("emitEvent").length, 0);
739
786
  });
740
787
 
788
+ // A lost SQS acknowledgement is the ordinary way this happens: the delete
789
+ // ran, `settleTrashMoveConfirmed` wrote the Trash uid, and the record came
790
+ // back anyway. Without the guard its three siblings carry (MESSAGE_MOVE,
791
+ // MESSAGE_COPY, FLAG_PUSH) the redelivery MOVEs a uid the source no longer
792
+ // holds, exhausts, and the terminal resolver reads the source's honest
793
+ // "gone" as grounds to delete rows that are correct — taking spamReport,
794
+ // classificationState, category and the Undo target's `originalMailboxId`
795
+ // with them, none of which the resync re-projection can rebuild.
796
+ it("skips a redelivery of a delete that already settled, rather than re-running it", async () => {
797
+ h.messageRow = { messageIdHeader: MESSAGE_ID_HEADER, status: "active" };
798
+
799
+ await handleMessageDelete(moveEvent, noopLogger, 1, deps());
800
+
801
+ assert.equal(h.getConnectionCount, 0, "no IMAP command is issued");
802
+ assert.equal(called("message.updateUid").length, 0);
803
+ assert.equal(called("message.delete").length, 0);
804
+ assert.equal(called("threadMessage.delete").length, 0);
805
+ assert.equal(called("threadMessage.deleteMany").length, 0);
806
+ assert.equal(called("message.transitionPlacement").length, 0);
807
+ assert.equal(called("emitEvent").length, 0);
808
+ });
809
+
810
+ // The guard asks for the state THIS delete wrote, not for "is anything
811
+ // outstanding". A move to Trash records `moving`, a permanent delete
812
+ // records `deleting`, and a row in the other one belongs to a different
813
+ // mutation — reading it as unsettled is what would let a redelivery
814
+ // finish somebody else's work against a uid it never owned.
815
+ it("proceeds on a move-to-trash row still marked moving, which is its own work", async () => {
816
+ h.messageRow = { messageIdHeader: MESSAGE_ID_HEADER, status: "moving" };
817
+
818
+ await handleMessageDelete(moveEvent, noopLogger, 1, deps());
819
+
820
+ assert.equal(h.getConnectionCount, 1, "the delete runs");
821
+ });
822
+
823
+ it("skips a move-to-trash whose row a permanent delete has claimed", async () => {
824
+ h.messageRow = { messageIdHeader: MESSAGE_ID_HEADER, status: "deleting" };
825
+
826
+ await handleMessageDelete(moveEvent, noopLogger, 1, deps());
827
+
828
+ assert.equal(h.getConnectionCount, 0, "no IMAP command is issued");
829
+ assert.equal(called("message.updateUid").length, 0);
830
+ assert.equal(called("emitEvent").length, 0);
831
+ });
832
+
833
+ it("proceeds on a permanent delete's own deleting row", async () => {
834
+ h.messageRow = { messageIdHeader: MESSAGE_ID_HEADER, status: "deleting" };
835
+
836
+ await handleMessageDelete(permanent(), noopLogger, 1, deps());
837
+
838
+ assert.equal(h.getConnectionCount, 1, "the expunge runs");
839
+ });
840
+
841
+ it("skips a permanent delete whose row a move has claimed", async () => {
842
+ h.messageRow = { messageIdHeader: MESSAGE_ID_HEADER, status: "moving" };
843
+
844
+ await handleMessageDelete(permanentEvent, noopLogger, 1, deps());
845
+
846
+ assert.equal(h.getConnectionCount, 0, "no expunge is issued");
847
+ assert.equal(called("message.delete").length, 0);
848
+ assert.equal(called("emitEvent").length, 0);
849
+ });
850
+
851
+ it("skips a redelivered permanent delete the same way", async () => {
852
+ h.messageRow = { messageIdHeader: MESSAGE_ID_HEADER, status: "active" };
853
+
854
+ await handleMessageDelete(permanentEvent, noopLogger, 1, deps());
855
+
856
+ assert.equal(h.getConnectionCount, 0);
857
+ assert.equal(called("message.delete").length, 0);
858
+ assert.equal(called("emitEvent").length, 0);
859
+ });
860
+
741
861
  // One Message-ID can have several server copies in one account while
742
862
  // `deriveMessageId` gives them one local row. A source that still holds
743
863
  // the uid proves the MOVE did not happen, so any hit at the destination
@@ -759,16 +879,12 @@ describe("handleMessageDelete", () => {
759
879
  );
760
880
  assert.equal(called("threadMessage.update").length, 0);
761
881
  assert.equal(called("message.delete").length, 0);
762
- assert.equal(
763
- (called("message.update")[0]?.args[1] as { syncStatus?: string })
764
- ?.syncStatus,
765
- "failed",
766
- );
882
+ assert.equal(attemptMarkerSyncStatus(), "failed");
767
883
  });
768
884
 
769
885
  // Issue #1098. A source that still holds the uid at the ceiling means the
770
886
  // MOVE never took effect, so the row goes back to the source folder: the
771
- // optimistic `updateForMove` left `mailboxId` on Trash while `uid` still
887
+ // optimistic transition left `mailboxId` on Trash while `uid` still
772
888
  // named the source's message, and settling `status` to `active` on top of
773
889
  // that pair switched off every guard that reads `status === "moving"`.
774
890
  // The UI then resolved a Trash `mailboxId` to "Delete permanently", an
@@ -795,10 +911,12 @@ describe("handleMessageDelete", () => {
795
911
 
796
912
  // `mailboxId` and `uid` name the same message again, and the same write
797
913
  // settles `status` out of `moving`, so no later delete of this message
798
- // waits on a mutation that has terminated.
914
+ // waits on a mutation that has terminated. It also carries the give-up
915
+ // (#1153) and names it a delete (#1229): the user asked for one, it did
916
+ // not happen, and nothing is coming to try again.
799
917
  assert.deepEqual(
800
- called("message.updateForMove").at(-1)?.args,
801
- restoredToSource("synced"),
918
+ called("message.transitionPlacement").at(-1)?.args,
919
+ restoredToSource("abandoned"),
802
920
  );
803
921
  assert.equal(
804
922
  called("message.update").length,
@@ -827,7 +945,7 @@ describe("handleMessageDelete", () => {
827
945
  assert.equal(await imapFailures("MESSAGE_DELETE_EXHAUSTED"), 1);
828
946
  });
829
947
 
830
- // The optimistic `updateForMove` already pointed the row at Trash. The
948
+ // The optimistic transition already pointed the row at Trash. The
831
949
  // server has now confirmed the message never left the source, so the row
832
950
  // and the server disagree about where the mail is, and only a resync of
833
951
  // both folders settles that. Without it the user is shown the message in
@@ -871,11 +989,7 @@ describe("handleMessageDelete", () => {
871
989
 
872
990
  assert.equal(called("message.delete").length, 0);
873
991
  assert.equal(called("threadMessage.deleteMany").length, 0);
874
- assert.equal(
875
- (called("message.update")[0]?.args[1] as { syncStatus?: string })
876
- ?.syncStatus,
877
- "failed",
878
- );
992
+ assert.equal(attemptMarkerSyncStatus(), "failed");
879
993
  });
880
994
 
881
995
  it("settles at the ceiling instead of dead-lettering undiagnosed", async () => {
@@ -899,8 +1013,8 @@ describe("handleMessageDelete", () => {
899
1013
  await handleMessageDelete(moveEvent, noopLogger, 3, deps());
900
1014
 
901
1015
  assert.deepEqual(
902
- called("message.updateForMove").at(-1)?.args,
903
- restoredToSource("synced"),
1016
+ called("message.transitionPlacement").at(-1)?.args,
1017
+ restoredToSource("abandoned"),
904
1018
  );
905
1019
  assert.equal(await imapFailures("MESSAGE_DELETE_EXHAUSTED"), 1);
906
1020
  });
@@ -926,11 +1040,7 @@ describe("handleMessageDelete", () => {
926
1040
  assert.equal(called("message.delete").length, 0);
927
1041
  assert.equal(called("threadMessage.deleteMany").length, 0);
928
1042
  assert.equal(called("threadMessage.update").length, 0);
929
- assert.equal(
930
- (called("message.update")[0]?.args[1] as { syncStatus?: string })
931
- ?.syncStatus,
932
- "failed",
933
- );
1043
+ assert.equal(attemptMarkerSyncStatus(), "failed");
934
1044
  assert.equal(h.disconnectCount, 1);
935
1045
  });
936
1046
  });
@@ -964,8 +1074,8 @@ describe("handleMessageDelete", () => {
964
1074
  // invisible syncStatus on a row that claims Trash. A refusal keeps
965
1075
  // `failed`: the product turned down something the user asked for.
966
1076
  assert.deepEqual(
967
- called("message.updateForMove")[0]?.args,
968
- restoredToSource("failed"),
1077
+ called("message.transitionPlacement")[0]?.args,
1078
+ restoredToSource("abandoned"),
969
1079
  );
970
1080
  assert.deepEqual(called("threadMessage.update")[0]?.args[2], {
971
1081
  uid: 10,
@@ -987,13 +1097,13 @@ describe("handleMessageDelete", () => {
987
1097
  assert.equal(called("connection.deleteMessages").length, 0);
988
1098
  assert.equal(called("message.delete").length, 0);
989
1099
  assert.deepEqual(
990
- called("message.updateForMove")[0]?.args,
991
- restoredToSource("failed"),
1100
+ called("message.transitionPlacement")[0]?.args,
1101
+ restoredToSource("abandoned"),
992
1102
  );
993
1103
  });
994
1104
 
995
1105
  it("expunges on the server and removes every thread row before the message row", async () => {
996
- await handleMessageDelete(permanentEvent, noopLogger, 1, deps());
1106
+ await handleMessageDelete(permanent(), noopLogger, 1, deps());
997
1107
 
998
1108
  assert.deepEqual(called("connection.deleteMessages")[0]?.args, [[10]]);
999
1109
  assert.equal(called("threadMessage.delete").length, 2);
@@ -1012,7 +1122,7 @@ describe("handleMessageDelete", () => {
1012
1122
  };
1013
1123
  sourceNoLongerHoldsTheUid();
1014
1124
 
1015
- await handleMessageDelete(permanentEvent, noopLogger, 1, deps());
1125
+ await handleMessageDelete(permanent(), noopLogger, 1, deps());
1016
1126
 
1017
1127
  assert.equal(called("message.delete").length, 1);
1018
1128
  assert.equal(called("threadMessage.delete").length, 2);
@@ -1048,11 +1158,7 @@ describe("handleMessageDelete", () => {
1048
1158
  0,
1049
1159
  "a move-to-trash never probes: absence from the source is its success signature",
1050
1160
  );
1051
- assert.equal(
1052
- (called("message.update")[0]?.args[1] as { syncStatus?: string })
1053
- ?.syncStatus,
1054
- "failed",
1055
- );
1161
+ assert.equal(attemptMarkerSyncStatus(), "failed");
1056
1162
  });
1057
1163
 
1058
1164
  it("keeps the rows on a NONEXISTENT move-to-trash whose source still holds the uid", async () => {
@@ -1067,11 +1173,7 @@ describe("handleMessageDelete", () => {
1067
1173
 
1068
1174
  assert.equal(called("message.delete").length, 0);
1069
1175
  assert.equal(called("threadMessage.delete").length, 0);
1070
- assert.equal(
1071
- (called("message.update")[0]?.args[1] as { syncStatus?: string })
1072
- ?.syncStatus,
1073
- "failed",
1074
- );
1176
+ assert.equal(attemptMarkerSyncStatus(), "failed");
1075
1177
  });
1076
1178
 
1077
1179
  it("keeps the rows when a permanent delete's source still holds the uid", async () => {
@@ -1083,7 +1185,7 @@ describe("handleMessageDelete", () => {
1083
1185
  h.connection.fetchMessages = async () => [];
1084
1186
 
1085
1187
  await assert.rejects(
1086
- handleMessageDelete(permanentEvent, noopLogger, 1, deps()),
1188
+ handleMessageDelete(permanent(), noopLogger, 1, deps()),
1087
1189
  /NONEXISTENT/,
1088
1190
  );
1089
1191
 
@@ -1108,17 +1210,13 @@ describe("handleMessageDelete", () => {
1108
1210
  };
1109
1211
 
1110
1212
  await assert.rejects(
1111
- handleMessageDelete(permanentEvent, noopLogger, 1, deps()),
1213
+ handleMessageDelete(permanent(), noopLogger, 1, deps()),
1112
1214
  /NONEXISTENT mailbox does not exist/,
1113
1215
  );
1114
1216
 
1115
1217
  assert.equal(called("message.delete").length, 0);
1116
1218
  assert.equal(called("threadMessage.delete").length, 0);
1117
- assert.equal(
1118
- (called("message.update")[0]?.args[1] as { syncStatus?: string })
1119
- ?.syncStatus,
1120
- "failed",
1121
- );
1219
+ assert.equal(attemptMarkerSyncStatus(), "failed");
1122
1220
  });
1123
1221
 
1124
1222
  it("abandons rather than creating the destination on TRYCREATE", async () => {
@@ -1133,8 +1231,8 @@ describe("handleMessageDelete", () => {
1133
1231
 
1134
1232
  assert.equal(called("connection.createMailbox").length, 0);
1135
1233
  assert.deepEqual(
1136
- called("message.updateForMove")[0]?.args,
1137
- restoredToSource("failed"),
1234
+ called("message.transitionPlacement")[0]?.args,
1235
+ restoredToSource("abandoned"),
1138
1236
  );
1139
1237
  assert.deepEqual(called("threadMessage.update")[0]?.args[2], {
1140
1238
  uid: 10,
@@ -1143,6 +1241,49 @@ describe("handleMessageDelete", () => {
1143
1241
  });
1144
1242
  });
1145
1243
 
1244
+ // Removing the Message row is destructive, and the refusal paths reach it on
1245
+ // whatever the row happens to be — an unrecognised operation is answered on
1246
+ // an `active` row as readily as on a `deleting` one. So the removal is
1247
+ // claimed first, and a row with no delete outstanding is left alone.
1248
+ it("never removes a row no delete is outstanding on", async () => {
1249
+ h.messageRow = { messageIdHeader: MESSAGE_ID_HEADER, status: "active" };
1250
+ h.allThreadMessages = [];
1251
+ h.transitionLost = true;
1252
+ const malformed = {
1253
+ ...moveEvent,
1254
+ operation: undefined,
1255
+ } as unknown as MessageDeleteEvent;
1256
+
1257
+ await handleMessageDelete(malformed, noopLogger, 1, deps());
1258
+
1259
+ assert.deepEqual(
1260
+ called("message.transitionPlacement")[0]?.args[1],
1261
+ { status: ["moving", "deleting"] },
1262
+ "the removal is claimed off a placement this delete owns",
1263
+ );
1264
+ assert.equal(
1265
+ called("message.delete").length,
1266
+ 0,
1267
+ "and a lost claim removes nothing",
1268
+ );
1269
+ });
1270
+
1271
+ it("removes the row once the claim on it wins", async () => {
1272
+ h.messageRow = { messageIdHeader: MESSAGE_ID_HEADER, status: "deleting" };
1273
+ h.allThreadMessages = [];
1274
+ const malformed = {
1275
+ ...moveEvent,
1276
+ operation: undefined,
1277
+ } as unknown as MessageDeleteEvent;
1278
+
1279
+ await handleMessageDelete(malformed, noopLogger, 1, deps());
1280
+
1281
+ assert.deepEqual(
1282
+ called("message.delete").map((c) => c.args[0]),
1283
+ ["msg-1"],
1284
+ );
1285
+ });
1286
+
1146
1287
  it("abandons an event minted under an unknown contract, before connecting", async () => {
1147
1288
  const unversioned = {
1148
1289
  ...moveEvent,
@@ -1154,8 +1295,8 @@ describe("handleMessageDelete", () => {
1154
1295
  assert.equal(h.getConnectionCount, 0);
1155
1296
  assert.equal(called("connection.deleteMessages").length, 0);
1156
1297
  assert.deepEqual(
1157
- called("message.updateForMove")[0]?.args,
1158
- restoredToSource("failed"),
1298
+ called("message.transitionPlacement")[0]?.args,
1299
+ restoredToSource("abandoned"),
1159
1300
  );
1160
1301
  assert.deepEqual(called("threadMessage.update")[0]?.args[2], {
1161
1302
  uid: 10,
@@ -1185,7 +1326,7 @@ describe("handleMessageDelete", () => {
1185
1326
  // and a full sync brings it back.
1186
1327
  h.allThreadMessages = [];
1187
1328
  const unversioned = {
1188
- ...permanentEvent,
1329
+ ...permanent(),
1189
1330
  schemaVersion: undefined,
1190
1331
  } as unknown as MessageDeleteEvent;
1191
1332
 
@@ -1210,11 +1351,7 @@ describe("handleMessageDelete", () => {
1210
1351
  /server exploded/,
1211
1352
  );
1212
1353
 
1213
- assert.equal(
1214
- (called("message.update")[0]?.args[1] as { syncStatus?: string })
1215
- ?.syncStatus,
1216
- "failed",
1217
- );
1354
+ assert.equal(attemptMarkerSyncStatus(), "failed");
1218
1355
  });
1219
1356
 
1220
1357
  // Issue #1203. The pause is thrown by the openBox guard before the MOVE is
@@ -1233,7 +1370,7 @@ describe("handleMessageDelete", () => {
1233
1370
  "cursor_invalid",
1234
1371
  );
1235
1372
  assert.deepEqual(
1236
- called("message.updateForMove")[0]?.args,
1373
+ called("message.transitionPlacement")[0]?.args,
1237
1374
  restoredToSource("synced"),
1238
1375
  "the row goes back to the source pair, which is the set the rebuild adjudicates",
1239
1376
  );
@@ -1288,7 +1425,7 @@ describe("handleMessageDelete", () => {
1288
1425
  // the server has just confirmed on the axis a UIDVALIDITY change leaves
1289
1426
  // intact.
1290
1427
  assert.deepEqual(
1291
- called("message.updateForMove")[0]?.args,
1428
+ called("message.transitionPlacement")[0]?.args,
1292
1429
  restoredToSource("synced"),
1293
1430
  );
1294
1431
  });
@@ -1320,8 +1457,8 @@ describe("handleMessageDelete", () => {
1320
1457
  // The source pair is the set its own cursor rebuild walks and adjudicates
1321
1458
  // by Message-ID, so handing the row back there is the reconcile path.
1322
1459
  assert.deepEqual(
1323
- called("message.updateForMove")[0]?.args,
1324
- restoredToSource("failed"),
1460
+ called("message.transitionPlacement")[0]?.args,
1461
+ restoredToSource("abandoned"),
1325
1462
  );
1326
1463
  assert.equal(
1327
1464
  loggedWith("alert", "message_delete_paused_placement_unproven").length,
@@ -1339,7 +1476,7 @@ describe("handleMessageDelete", () => {
1339
1476
  it("settles a paused delete broken when the MOVE was issued and neither folder can be asked", async () => {
1340
1477
  h.connection.moveMessages = async () => ({ uidMap: new Map() });
1341
1478
  sourceNoLongerHoldsTheUid();
1342
- h.messageRow = {};
1479
+ h.messageRow = { status: "moving" };
1343
1480
  let opened = 0;
1344
1481
  h.connection.openBox = async (path: string) => {
1345
1482
  h.openBoxPath = path;
@@ -1353,8 +1490,8 @@ describe("handleMessageDelete", () => {
1353
1490
  await handleMessageDelete(moveEvent, capturingLog(), 1, deps());
1354
1491
 
1355
1492
  assert.deepEqual(
1356
- called("message.updateForMove")[0]?.args,
1357
- restoredToSource("failed"),
1493
+ called("message.transitionPlacement")[0]?.args,
1494
+ restoredToSource("abandoned"),
1358
1495
  "the first attempt reaches this settle with the MOVE already issued, so `synced` claims what nothing confirmed",
1359
1496
  );
1360
1497
  assert.equal(
@@ -1379,7 +1516,7 @@ describe("handleMessageDelete", () => {
1379
1516
  assert.equal(dropped.length, 1);
1380
1517
  assert.equal(dropped[0]?.level, "warn");
1381
1518
  assert.deepEqual(
1382
- called("message.updateForMove")[0]?.args,
1519
+ called("message.transitionPlacement")[0]?.args,
1383
1520
  restoredToSource("synced"),
1384
1521
  );
1385
1522
  });
@@ -1412,7 +1549,7 @@ describe("handleMessageDelete", () => {
1412
1549
  };
1413
1550
  h.allThreadMessages = [];
1414
1551
 
1415
- await handleMessageDelete(permanentEvent, capturingLog(), 1, deps());
1552
+ await handleMessageDelete(permanent(), capturingLog(), 1, deps());
1416
1553
 
1417
1554
  assert.deepEqual(
1418
1555
  called("emitEvent").map((c) => c.args[0]),
@@ -1432,7 +1569,7 @@ describe("handleMessageDelete", () => {
1432
1569
  };
1433
1570
  h.allThreadMessages = [];
1434
1571
 
1435
- await handleMessageDelete(permanentEvent, capturingLog(), 1, deps());
1572
+ await handleMessageDelete(permanent(), capturingLog(), 1, deps());
1436
1573
 
1437
1574
  assert.deepEqual(
1438
1575
  h.logs.filter((line) => line.level === "error"),
@@ -1453,7 +1590,7 @@ describe("handleMessageDelete", () => {
1453
1590
  it("keeps alerting when a refused delete removed the last listing rows", async () => {
1454
1591
  h.allThreadMessages = [];
1455
1592
  const unversioned = {
1456
- ...permanentEvent,
1593
+ ...permanent(),
1457
1594
  schemaVersion: undefined,
1458
1595
  } as unknown as MessageDeleteEvent;
1459
1596
 
@@ -1482,7 +1619,7 @@ describe("handleMessageDelete", () => {
1482
1619
 
1483
1620
  assert.equal(called("message.updateUid").length, 0);
1484
1621
  assert.deepEqual(
1485
- called("message.updateForMove")[0]?.args,
1622
+ called("message.transitionPlacement")[0]?.args,
1486
1623
  restoredToSource("synced"),
1487
1624
  );
1488
1625
  });
@@ -1520,7 +1657,7 @@ describe("handleMessageDelete", () => {
1520
1657
 
1521
1658
  assert.equal(h.getConnectionCount, 0);
1522
1659
  assert.deepEqual(
1523
- called("message.updateForMove")[0]?.args,
1660
+ called("message.transitionPlacement")[0]?.args,
1524
1661
  restoredToSource("synced"),
1525
1662
  );
1526
1663
  });
@@ -1540,12 +1677,12 @@ describe("handleMessageDelete", () => {
1540
1677
  };
1541
1678
  h.allThreadMessages = [];
1542
1679
 
1543
- await handleMessageDelete(permanentEvent, noopLogger, 1, deps());
1680
+ await handleMessageDelete(permanent(), noopLogger, 1, deps());
1544
1681
 
1545
1682
  assert.equal(h.getConnectionCount, 0);
1546
1683
  assert.deepEqual(called("message.delete")[0]?.args, ["msg-1"]);
1547
1684
  assert.equal(
1548
- called("message.updateForMove").length,
1685
+ restoreCalls().length,
1549
1686
  0,
1550
1687
  "a row no listing can reach is never restored",
1551
1688
  );
@@ -1563,7 +1700,7 @@ describe("handleMessageDelete", () => {
1563
1700
 
1564
1701
  assert.deepEqual(called("message.delete")[0]?.args, ["msg-1"]);
1565
1702
  assert.equal(
1566
- called("message.updateForMove").length,
1703
+ restoreCalls().length,
1567
1704
  0,
1568
1705
  "restoring a Message no listing can reach is the silent vanish, not a hand-back",
1569
1706
  );