@remit/imap-worker 0.0.67 → 0.0.69

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.
@@ -271,6 +271,11 @@ interface Call {
271
271
  args: unknown[];
272
272
  }
273
273
 
274
+ interface LogLine {
275
+ level: string;
276
+ fields: Record<string, unknown>;
277
+ }
278
+
274
279
  interface Connection {
275
280
  openBox: (
276
281
  path: string,
@@ -299,12 +304,46 @@ interface Harness {
299
304
  threadMessageUpdateError?: Error;
300
305
  messageRow: { messageIdHeader?: string } | undefined;
301
306
  destinationSearchUids: number[];
307
+ // A Message-ID SEARCH is answered by whichever folder was opened last, so a
308
+ // case can put the same header in both. Default: the source still holds it,
309
+ // the destination does not.
310
+ sourceSearchUids: number[];
311
+ openBoxPath: string;
302
312
  threadMessage: Record<string, unknown> | null;
303
313
  allThreadMessages: Record<string, unknown>[];
304
314
  getConnectionCount: number;
305
315
  disconnectCount: number;
316
+ // Rejections handed to `getConnection`, one per call, oldest first. A
317
+ // dropped connection is how the guarded openBox gets skipped on a delivery
318
+ // that still goes on to settle the row.
319
+ connectionErrors: Error[];
320
+ logs: LogLine[];
306
321
  }
307
322
 
323
+ // The level a give-up is logged at is behaviour: a routine pause must not page
324
+ // anyone, and the local rows going while the server copy survives must stay
325
+ // readable whichever level it lands on.
326
+ const capturingLog = (): Logger => {
327
+ const at =
328
+ (level: string) =>
329
+ (fields: Record<string, unknown>): void => {
330
+ h.logs.push({ level, fields });
331
+ };
332
+ const log = {
333
+ info: at("info"),
334
+ warn: at("warn"),
335
+ error: at("error"),
336
+ debug: at("debug"),
337
+ fatal: at("fatal"),
338
+ trace: at("trace"),
339
+ child: () => log,
340
+ } as unknown as Logger;
341
+ return log;
342
+ };
343
+
344
+ const loggedWith = (key: string, value: string): LogLine[] =>
345
+ h.logs.filter((line) => line.fields[key] === value);
346
+
308
347
  let h: Harness;
309
348
 
310
349
  const record =
@@ -324,7 +363,10 @@ const isMessageIdSearch = (criteria: unknown): boolean =>
324
363
  criteria[0][0] === "HEADER";
325
364
 
326
365
  const buildConnection = (): Connection => ({
327
- openBox: async () => ({ uidvalidity: 1 }),
366
+ openBox: async (path: string) => {
367
+ h.openBoxPath = path;
368
+ return { uidvalidity: 1 };
369
+ },
328
370
  moveMessages: async () => ({ uidMap: new Map([[10, 20]]) }),
329
371
  deleteMessages: record(
330
372
  "connection.deleteMessages",
@@ -334,18 +376,28 @@ const buildConnection = (): Connection => ({
334
376
  ) as Connection["createMailbox"],
335
377
  // The source box still holds the uid unless a case says otherwise, so the
336
378
  // presence probe only reports "gone" where a test made it true.
337
- fetchMessages: async (uids: number[]) => uids.map((uid) => ({ uid })),
379
+ fetchMessages: async (uids: number[]) => {
380
+ h.calls.push({ method: "connection.fetchMessages", args: [uids] });
381
+ return uids.map((uid) => ({ uid }));
382
+ },
338
383
  search: async (...args: unknown[]) => {
339
384
  h.calls.push({ method: "connection.search", args });
340
- return isMessageIdSearch(args[0]) ? h.destinationSearchUids : [10];
385
+ if (!isMessageIdSearch(args[0])) return [10];
386
+ return h.openBoxPath === "INBOX"
387
+ ? h.sourceSearchUids
388
+ : h.destinationSearchUids;
341
389
  },
342
390
  });
343
391
 
344
392
  const sourceNoLongerHoldsTheUid = (): void => {
393
+ h.sourceSearchUids = [];
345
394
  h.connection.fetchMessages = async () => [];
346
395
  h.connection.search = async (...args: unknown[]) => {
347
396
  h.calls.push({ method: "connection.search", args });
348
- return isMessageIdSearch(args[0]) ? h.destinationSearchUids : [];
397
+ if (!isMessageIdSearch(args[0])) return [];
398
+ return h.openBoxPath === "INBOX"
399
+ ? h.sourceSearchUids
400
+ : h.destinationSearchUids;
349
401
  };
350
402
  };
351
403
 
@@ -355,6 +407,8 @@ const fresh = (): Harness => ({
355
407
  mailbox: { mailboxId: "src-mbx", uidValidity: 1, cursorState: undefined },
356
408
  messageRow: { messageIdHeader: MESSAGE_ID_HEADER },
357
409
  destinationSearchUids: [],
410
+ sourceSearchUids: [10],
411
+ openBoxPath: "INBOX",
358
412
  connection: buildConnection(),
359
413
  threadMessage: {
360
414
  ...baseThreadMessage,
@@ -367,6 +421,8 @@ const fresh = (): Harness => ({
367
421
  ],
368
422
  getConnectionCount: 0,
369
423
  disconnectCount: 0,
424
+ connectionErrors: [],
425
+ logs: [],
370
426
  });
371
427
 
372
428
  const deps = (): MessageDeleteDeps =>
@@ -384,12 +440,16 @@ const deps = (): MessageDeleteDeps =>
384
440
  return h.messageRow ? [h.messageRow] : [];
385
441
  },
386
442
  updateUid: record("message.updateUid"),
443
+ updateForMove: record("message.updateForMove"),
387
444
  update: record("message.update"),
388
445
  delete: record("message.delete"),
389
446
  },
390
447
  threadMessage: {
391
448
  findByMessageId: async () => h.threadMessage,
392
- findAllByMessageId: async () => h.allThreadMessages,
449
+ findAllByMessageId: async (...args: unknown[]) => {
450
+ h.calls.push({ method: "threadMessage.findAllByMessageId", args });
451
+ return h.allThreadMessages;
452
+ },
393
453
  update: async (...args: unknown[]) => {
394
454
  h.calls.push({ method: "threadMessage.update", args });
395
455
  if (h.threadMessageUpdateError) throw h.threadMessageUpdateError;
@@ -417,6 +477,8 @@ const deps = (): MessageDeleteDeps =>
417
477
  createConnectionScope: () => ({
418
478
  getConnection: async () => {
419
479
  h.getConnectionCount += 1;
480
+ const failure = h.connectionErrors.shift();
481
+ if (failure) throw failure;
420
482
  return h.connection;
421
483
  },
422
484
  disconnect: async () => {
@@ -452,6 +514,16 @@ const permanentEvent: MessageDeleteEvent = {
452
514
  const called = (method: string): Call[] =>
453
515
  h.calls.filter((c) => c.method === method);
454
516
 
517
+ // The one write that puts a given-up delete back on its source pair. It clears
518
+ // `moving` in the same write as the pair, which is what keeps a later delete
519
+ // from waiting on a mutation that has terminated (#1005), and it goes through
520
+ // `updateForMove` rather than `updateUid` so a row that never moved does not
521
+ // enqueue a search re-index.
522
+ const restoredToSource = (syncStatus: string): unknown[] => [
523
+ "msg-1",
524
+ { mailboxId: "src-mbx", uid: 10, status: "active", syncStatus },
525
+ ];
526
+
455
527
  // Label order in the rendered text is prom-client's, not ours.
456
528
  const imapFailures = async (operation: string): Promise<number> => {
457
529
  const line = (await renderMetrics())
@@ -547,6 +619,7 @@ describe("handleMessageDelete", () => {
547
619
  const opened: unknown[][] = [];
548
620
  h.connection.openBox = (async (...args: unknown[]) => {
549
621
  opened.push(args);
622
+ h.openBoxPath = args[0] as string;
550
623
  return { uidvalidity: 1 };
551
624
  }) as Connection["openBox"];
552
625
 
@@ -729,14 +802,13 @@ describe("handleMessageDelete", () => {
729
802
  assert.equal(called("message.delete").length, 0);
730
803
  assert.equal(called("threadMessage.deleteMany").length, 0);
731
804
 
732
- // `mailboxId` and `uid` name the same message again, and `updateUid`
733
- // settles `status` out of `moving` in the same write, so no later
734
- // delete of this message waits on a mutation that has terminated.
735
- assert.deepEqual(called("message.updateUid").at(-1)?.args, [
736
- "msg-1",
737
- 10,
738
- "src-mbx",
739
- ]);
805
+ // `mailboxId` and `uid` name the same message again, and the same write
806
+ // settles `status` out of `moving`, so no later delete of this message
807
+ // waits on a mutation that has terminated.
808
+ assert.deepEqual(
809
+ called("message.updateForMove").at(-1)?.args,
810
+ restoredToSource("synced"),
811
+ );
740
812
  assert.equal(
741
813
  called("message.update").length,
742
814
  0,
@@ -835,11 +907,10 @@ describe("handleMessageDelete", () => {
835
907
 
836
908
  await handleMessageDelete(moveEvent, noopLog, 3, deps());
837
909
 
838
- assert.deepEqual(called("message.updateUid").at(-1)?.args, [
839
- "msg-1",
840
- 10,
841
- "src-mbx",
842
- ]);
910
+ assert.deepEqual(
911
+ called("message.updateForMove").at(-1)?.args,
912
+ restoredToSource("synced"),
913
+ );
843
914
  assert.equal(await imapFailures("MESSAGE_DELETE_EXHAUSTED"), 1);
844
915
  });
845
916
  });
@@ -899,16 +970,12 @@ describe("handleMessageDelete", () => {
899
970
 
900
971
  // The row goes back to the mailbox the server still holds it in, so
901
972
  // the failure is visible as the message reappearing rather than as an
902
- // invisible syncStatus on a row that claims Trash.
903
- assert.deepEqual(called("message.updateUid")[0]?.args, [
904
- "msg-1",
905
- 10,
906
- "src-mbx",
907
- ]);
908
- assert.deepEqual(called("message.update")[0]?.args[1], {
909
- status: "active",
910
- syncStatus: "failed",
911
- });
973
+ // invisible syncStatus on a row that claims Trash. A refusal keeps
974
+ // `failed`: the product turned down something the user asked for.
975
+ assert.deepEqual(
976
+ called("message.updateForMove")[0]?.args,
977
+ restoredToSource("failed"),
978
+ );
912
979
  assert.deepEqual(called("threadMessage.update")[0]?.args[2], {
913
980
  uid: 10,
914
981
  mailboxId: "src-mbx",
@@ -928,10 +995,10 @@ describe("handleMessageDelete", () => {
928
995
 
929
996
  assert.equal(called("connection.deleteMessages").length, 0);
930
997
  assert.equal(called("message.delete").length, 0);
931
- assert.deepEqual(called("message.update")[0]?.args[1], {
932
- status: "active",
933
- syncStatus: "failed",
934
- });
998
+ assert.deepEqual(
999
+ called("message.updateForMove")[0]?.args,
1000
+ restoredToSource("failed"),
1001
+ );
935
1002
  });
936
1003
 
937
1004
  it("expunges on the server and removes every thread row before the message row", async () => {
@@ -1074,15 +1141,10 @@ describe("handleMessageDelete", () => {
1074
1141
  await handleMessageDelete(moveEvent, noopLog, 1, deps());
1075
1142
 
1076
1143
  assert.equal(called("connection.createMailbox").length, 0);
1077
- assert.deepEqual(called("message.updateUid")[0]?.args, [
1078
- "msg-1",
1079
- 10,
1080
- "src-mbx",
1081
- ]);
1082
- assert.deepEqual(called("message.update")[0]?.args[1], {
1083
- status: "active",
1084
- syncStatus: "failed",
1085
- });
1144
+ assert.deepEqual(
1145
+ called("message.updateForMove")[0]?.args,
1146
+ restoredToSource("failed"),
1147
+ );
1086
1148
  assert.deepEqual(called("threadMessage.update")[0]?.args[2], {
1087
1149
  uid: 10,
1088
1150
  mailboxId: "src-mbx",
@@ -1100,11 +1162,10 @@ describe("handleMessageDelete", () => {
1100
1162
 
1101
1163
  assert.equal(h.getConnectionCount, 0);
1102
1164
  assert.equal(called("connection.deleteMessages").length, 0);
1103
- assert.deepEqual(called("message.updateUid")[0]?.args, [
1104
- "msg-1",
1105
- 10,
1106
- "src-mbx",
1107
- ]);
1165
+ assert.deepEqual(
1166
+ called("message.updateForMove")[0]?.args,
1167
+ restoredToSource("failed"),
1168
+ );
1108
1169
  assert.deepEqual(called("threadMessage.update")[0]?.args[2], {
1109
1170
  uid: 10,
1110
1171
  mailboxId: "src-mbx",
@@ -1165,7 +1226,12 @@ describe("handleMessageDelete", () => {
1165
1226
  );
1166
1227
  });
1167
1228
 
1168
- it("pauses quietly when openBox trips a UIDVALIDITY mismatch", async () => {
1229
+ // Issue #1203. The pause is thrown by the openBox guard before the MOVE is
1230
+ // issued, so acking left the row `moving` on Trash with the source's uid.
1231
+ // Nothing re-enqueues a MESSAGE_DELETE, and the cursor rebuild matches rows
1232
+ // by `(accountConfigId, mailboxId)`, so a row naming Trash is in neither
1233
+ // folder's set — the message stayed undeletable and unmovable for good.
1234
+ it("hands the optimistic trash move back when openBox trips a UIDVALIDITY mismatch", async () => {
1169
1235
  h.connection.openBox = async () => ({ uidvalidity: 999 });
1170
1236
 
1171
1237
  await handleMessageDelete(moveEvent, noopLog, 1, deps());
@@ -1175,7 +1241,281 @@ describe("handleMessageDelete", () => {
1175
1241
  ?.cursorState,
1176
1242
  "cursor_invalid",
1177
1243
  );
1244
+ assert.deepEqual(
1245
+ called("message.updateForMove")[0]?.args,
1246
+ restoredToSource("synced"),
1247
+ "the row goes back to the source pair, which is the set the rebuild adjudicates",
1248
+ );
1249
+ assert.equal(
1250
+ (called("threadMessage.update")[0]?.args[2] as { isDeleted?: boolean })
1251
+ ?.isDeleted,
1252
+ false,
1253
+ "the listing rows lose the deletion mark the optimistic write put on them",
1254
+ );
1255
+ });
1256
+
1257
+ // The terminal resolver opens the source itself, and since #1005 its answer
1258
+ // WRITES a placement. A folder deleted and recreated renumbers every uid, so
1259
+ // a uid-keyed probe finds whatever now sits at this one, reads it as "still
1260
+ // at source", and binds the row to a stranger the next permanent delete
1261
+ // expunges. The dropped connection is what makes it reachable: it skips the
1262
+ // guarded openBox on the very delivery that goes on to settle.
1263
+ it("never settles an exhausted delete off a uid-keyed probe that skipped the cursor guard", async () => {
1264
+ h.connectionErrors = [new Error("ECONNRESET")];
1265
+ h.connection.openBox = async (path: string) => {
1266
+ h.openBoxPath = path;
1267
+ return { uidvalidity: 999 };
1268
+ };
1269
+
1270
+ await handleMessageDelete(
1271
+ moveEvent,
1272
+ noopLog,
1273
+ MESSAGE_DELETE_MAX_ATTEMPTS,
1274
+ deps(),
1275
+ );
1276
+
1277
+ assert.equal(
1278
+ called("connection.fetchMessages").length,
1279
+ 0,
1280
+ "the guard refused the SELECT, so nothing was settled off a uid on the dead axis",
1281
+ );
1282
+ assert.equal(
1283
+ called("message.delete").length,
1284
+ 0,
1285
+ "a renumbered folder answers for a different message; nothing is reconciled away off it",
1286
+ );
1287
+ assert.equal(
1288
+ (called("mailbox.update")[0]?.args[2] as { cursorState?: string })
1289
+ ?.cursorState,
1290
+ "cursor_invalid",
1291
+ "the probe trips the mailbox into a rebuild instead",
1292
+ );
1293
+ // Issue #1203: "leaving it for the cursor rebuild" was never an option —
1294
+ // the rebuild matches rows by `(accountConfigId, mailboxId)`, so it only
1295
+ // sees this row once the row names the source again. The source still
1296
+ // answers to the Message-ID, so the pair the row goes back on is the one
1297
+ // the server has just confirmed on the axis a UIDVALIDITY change leaves
1298
+ // intact.
1299
+ assert.deepEqual(
1300
+ called("message.updateForMove")[0]?.args,
1301
+ restoredToSource("synced"),
1302
+ );
1303
+ });
1304
+
1305
+ // Issue #1203, the redelivery half, and the mail-loss edge in it. INBOX was
1306
+ // recreated (UIDVALIDITY bumped, which is why the cursor is paused), the
1307
+ // MOVE never ran, and INBOX honestly answers "no" for the Message-ID because
1308
+ // it no longer holds anything it used to. Trash holds an older copy of the
1309
+ // same Message-ID at uid 77 — a sieve `fileinto` + `keep`, a resend — which
1310
+ // `deriveMessageId` folds into this one local row. Settling `moved` on 77
1311
+ // binds the row to that copy, and the next permanent delete expunges it:
1312
+ // Trash is not paused, so no rebuild is ever coming to adjudicate there.
1313
+ it("never settles a redelivered paused trash move as moved off a destination sighting", async () => {
1314
+ h.mailbox = {
1315
+ mailboxId: "src-mbx",
1316
+ uidValidity: 1,
1317
+ cursorState: "cursor_invalid",
1318
+ };
1319
+ h.sourceSearchUids = [];
1320
+ h.destinationSearchUids = [77];
1321
+
1322
+ await handleMessageDelete(moveEvent, capturingLog(), 2, deps());
1323
+
1324
+ assert.equal(
1325
+ called("message.updateUid").length,
1326
+ 0,
1327
+ "a sighting off a source whose uid axis has moved is not proof this delete ran",
1328
+ );
1329
+ // The source pair is the set its own cursor rebuild walks and adjudicates
1330
+ // by Message-ID, so handing the row back there is the reconcile path.
1331
+ assert.deepEqual(
1332
+ called("message.updateForMove")[0]?.args,
1333
+ restoredToSource("failed"),
1334
+ );
1335
+ assert.equal(
1336
+ loggedWith("alert", "message_delete_paused_placement_unproven").length,
1337
+ 1,
1338
+ "a placement the server never settled is an operator's to adjudicate",
1339
+ );
1340
+ });
1341
+
1342
+ // Issue #1203 and the taxonomy `message-copy.ts` already applies: an
1343
+ // evidence-free redelivery is broken, not settled. With no Message-ID header
1344
+ // neither folder was asked, and silence is not an answer to write `synced`
1345
+ // on. Route in: attempt 1 issues the MOVE, the confirmation comes back
1346
+ // unprobeable, and the exhausted settle's guarded openBox trips the pause.
1347
+ // `receiveCount === 1` there is not proof the command never left.
1348
+ it("settles a paused delete broken when the MOVE was issued and neither folder can be asked", async () => {
1349
+ h.connection.moveMessages = async () => ({ uidMap: new Map() });
1350
+ sourceNoLongerHoldsTheUid();
1351
+ h.messageRow = {};
1352
+ let opened = 0;
1353
+ h.connection.openBox = async (path: string) => {
1354
+ h.openBoxPath = path;
1355
+ opened += 1;
1356
+ // The folder was recreated between the confirmation probe and the
1357
+ // settle, which is what the terminal resolver's guarded openBox
1358
+ // refuses to settle off.
1359
+ return { uidvalidity: opened > 2 ? 999 : 1 };
1360
+ };
1361
+
1362
+ await handleMessageDelete(moveEvent, capturingLog(), 1, deps());
1363
+
1364
+ assert.deepEqual(
1365
+ called("message.updateForMove")[0]?.args,
1366
+ restoredToSource("failed"),
1367
+ "the first attempt reaches this settle with the MOVE already issued, so `synced` claims what nothing confirmed",
1368
+ );
1369
+ assert.equal(
1370
+ loggedWith("alert", "message_delete_paused_placement_unproven").length,
1371
+ 1,
1372
+ );
1373
+ });
1374
+
1375
+ // The user's delete was silently dropped. Nothing throws, nothing alerts and
1376
+ // the row reads settled, so the only trace it ever leaves is this metric —
1377
+ // the counterpart of copy's `message_copy_not_landed`.
1378
+ it("counts a paused delete the server was never told about", async () => {
1379
+ h.mailbox = {
1380
+ mailboxId: "src-mbx",
1381
+ uidValidity: 1,
1382
+ cursorState: "rebuilding",
1383
+ };
1384
+
1385
+ await handleMessageDelete(moveEvent, capturingLog(), 1, deps());
1386
+
1387
+ const dropped = loggedWith("metric", "message_delete_dropped_on_pause");
1388
+ assert.equal(dropped.length, 1);
1389
+ assert.equal(dropped[0]?.level, "warn");
1390
+ assert.deepEqual(
1391
+ called("message.updateForMove")[0]?.args,
1392
+ restoredToSource("synced"),
1393
+ );
1394
+ });
1395
+
1396
+ // The listing rows decide the outcome, so the caller has already read them.
1397
+ // Reading them a second time inside `restoreSourcePlacement` is a query per
1398
+ // hand-back for an answer already in hand.
1399
+ it("reads the listing rows once per hand-back", async () => {
1400
+ h.mailbox = {
1401
+ mailboxId: "src-mbx",
1402
+ uidValidity: 1,
1403
+ cursorState: "rebuilding",
1404
+ };
1405
+
1406
+ await handleMessageDelete(moveEvent, noopLog, 1, deps());
1407
+
1408
+ assert.equal(called("threadMessage.findAllByMessageId").length, 1);
1409
+ });
1410
+
1411
+ // A permanent delete has no destination, so the resync was gated off
1412
+ // entirely: `handBackDelete` removed the local row, nothing enqueued a
1413
+ // SYNC_MESSAGES, and the mail was invisible until the next scheduled sync
1414
+ // while the server still held it. One folder is all this event touches, and
1415
+ // SYNC_MESSAGES on a paused mailbox routes to the cursor rebuild.
1416
+ it("resyncs the source folder after a paused permanent delete removes the local rows", async () => {
1417
+ h.mailbox = {
1418
+ mailboxId: "src-mbx",
1419
+ uidValidity: 1,
1420
+ cursorState: "rebuilding",
1421
+ };
1422
+ h.allThreadMessages = [];
1423
+
1424
+ await handleMessageDelete(permanentEvent, capturingLog(), 1, deps());
1425
+
1426
+ assert.deepEqual(
1427
+ called("emitEvent").map((c) => c.args[0]),
1428
+ [{ type: "SYNC_MESSAGES", accountId: "acc-1", mailboxId: "src-mbx" }],
1429
+ "the folder whose rows just went must be told to re-project them",
1430
+ );
1431
+ });
1432
+
1433
+ // The pause is the expected skip the guard doc calls never-a-fault, so it
1434
+ // alerted on every routine paused permanent delete. The fact that local mail
1435
+ // went while the server copy survived still has to be readable.
1436
+ it("does not alert on a routine paused permanent delete", async () => {
1437
+ h.mailbox = {
1438
+ mailboxId: "src-mbx",
1439
+ uidValidity: 1,
1440
+ cursorState: "rebuilding",
1441
+ };
1442
+ h.allThreadMessages = [];
1443
+
1444
+ await handleMessageDelete(permanentEvent, capturingLog(), 1, deps());
1445
+
1446
+ assert.deepEqual(
1447
+ h.logs.filter((line) => line.level === "error"),
1448
+ [],
1449
+ "a pause the guard expects never pages anyone",
1450
+ );
1451
+ const dropped = loggedWith("metric", "message_delete_dropped_on_pause");
1452
+ assert.equal(dropped.length, 1);
1453
+ assert.equal(
1454
+ dropped[0]?.fields.localRowsRemoved,
1455
+ true,
1456
+ "local mail disappearing while the server still holds it stays visible",
1457
+ );
1458
+ });
1459
+
1460
+ // A refusal is not a pause: the product declined something the user asked
1461
+ // for, and that still wakes someone.
1462
+ it("keeps alerting when a refused delete removed the last listing rows", async () => {
1463
+ h.allThreadMessages = [];
1464
+ const unversioned = {
1465
+ ...permanentEvent,
1466
+ schemaVersion: undefined,
1467
+ } as unknown as MessageDeleteEvent;
1468
+
1469
+ await handleMessageDelete(unversioned, capturingLog(), 1, deps());
1470
+
1471
+ assert.equal(
1472
+ loggedWith("alert", "message_delete_unknown_schema_version").length,
1473
+ 1,
1474
+ );
1475
+ });
1476
+
1477
+ // Issue #1122 on the same path. Trash already held an older copy of this
1478
+ // Message-ID and the MOVE never ran, so the destination hit is not this
1479
+ // message. The source is asked first for exactly this reason, and its answer
1480
+ // ends it — settling on uid 12 would hand Empty Trash a stranger's uid.
1481
+ it("never binds a redelivered paused trash move to an older copy while the source still answers", async () => {
1482
+ h.mailbox = {
1483
+ mailboxId: "src-mbx",
1484
+ uidValidity: 1,
1485
+ cursorState: "cursor_invalid",
1486
+ };
1487
+ h.sourceSearchUids = [4];
1488
+ h.destinationSearchUids = [12];
1489
+
1490
+ await handleMessageDelete(moveEvent, noopLog, 2, deps());
1491
+
1178
1492
  assert.equal(called("message.updateUid").length, 0);
1493
+ assert.deepEqual(
1494
+ called("message.updateForMove")[0]?.args,
1495
+ restoredToSource("synced"),
1496
+ );
1497
+ });
1498
+
1499
+ // A paused settle is a settle, so both folders re-read their counts from
1500
+ // IMAP — the same resync every other terminal verdict in this handler runs.
1501
+ // Without it the repair depended entirely on someone else arming the
1502
+ // mailbox's rebuild.
1503
+ it("resyncs both folders after a paused delete settles", async () => {
1504
+ h.mailbox = {
1505
+ mailboxId: "src-mbx",
1506
+ uidValidity: 1,
1507
+ cursorState: "rebuilding",
1508
+ };
1509
+
1510
+ await handleMessageDelete(moveEvent, noopLog, 1, deps());
1511
+
1512
+ assert.deepEqual(
1513
+ called("emitEvent").map((c) => c.args[0]),
1514
+ [
1515
+ { type: "SYNC_MESSAGES", accountId: "acc-1", mailboxId: "src-mbx" },
1516
+ { type: "SYNC_MESSAGES", accountId: "acc-1", mailboxId: "trash-mbx" },
1517
+ ],
1518
+ );
1179
1519
  });
1180
1520
 
1181
1521
  it("skips without opening a connection when the cursor is rebuilding", async () => {
@@ -1188,6 +1528,54 @@ describe("handleMessageDelete", () => {
1188
1528
  await handleMessageDelete(moveEvent, noopLog, 1, deps());
1189
1529
 
1190
1530
  assert.equal(h.getConnectionCount, 0);
1531
+ assert.deepEqual(
1532
+ called("message.updateForMove")[0]?.args,
1533
+ restoredToSource("synced"),
1534
+ );
1535
+ });
1536
+
1537
+ // A permanent delete drops its listing rows at enqueue and only the sync
1538
+ // path can shape them, so restoring the Message alone leaves mail nothing
1539
+ // can list. The row is removed instead and the mailbox's own resync
1540
+ // re-projects it — leaving it `deleting` made the message invisible locally
1541
+ // while the server still held it (issue #1203). The rule is the listing
1542
+ // rows, the same one `abandonDelete` resolves this state by, not the
1543
+ // operation on the event.
1544
+ it("removes a paused delete that has no listing rows left to restore", async () => {
1545
+ h.mailbox = {
1546
+ mailboxId: "src-mbx",
1547
+ uidValidity: 1,
1548
+ cursorState: "rebuilding",
1549
+ };
1550
+ h.allThreadMessages = [];
1551
+
1552
+ await handleMessageDelete(permanentEvent, noopLog, 1, deps());
1553
+
1554
+ assert.equal(h.getConnectionCount, 0);
1555
+ assert.deepEqual(called("message.delete")[0]?.args, ["msg-1"]);
1556
+ assert.equal(
1557
+ called("message.updateForMove").length,
1558
+ 0,
1559
+ "a row no listing can reach is never restored",
1560
+ );
1561
+ });
1562
+
1563
+ it("removes a paused move to trash whose listing rows are already gone", async () => {
1564
+ h.mailbox = {
1565
+ mailboxId: "src-mbx",
1566
+ uidValidity: 1,
1567
+ cursorState: "rebuilding",
1568
+ };
1569
+ h.allThreadMessages = [];
1570
+
1571
+ await handleMessageDelete(moveEvent, noopLog, 1, deps());
1572
+
1573
+ assert.deepEqual(called("message.delete")[0]?.args, ["msg-1"]);
1574
+ assert.equal(
1575
+ called("message.updateForMove").length,
1576
+ 0,
1577
+ "restoring a Message no listing can reach is the silent vanish, not a hand-back",
1578
+ );
1191
1579
  });
1192
1580
 
1193
1581
  it("acks terminally without connecting when the mailbox was deleted", async () => {