@remit/imap-worker 0.0.68 → 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,6 +304,11 @@ 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;
@@ -307,8 +317,33 @@ interface Harness {
307
317
  // dropped connection is how the guarded openBox gets skipped on a delivery
308
318
  // that still goes on to settle the row.
309
319
  connectionErrors: Error[];
320
+ logs: LogLine[];
310
321
  }
311
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
+
312
347
  let h: Harness;
313
348
 
314
349
  const record =
@@ -328,7 +363,10 @@ const isMessageIdSearch = (criteria: unknown): boolean =>
328
363
  criteria[0][0] === "HEADER";
329
364
 
330
365
  const buildConnection = (): Connection => ({
331
- openBox: async () => ({ uidvalidity: 1 }),
366
+ openBox: async (path: string) => {
367
+ h.openBoxPath = path;
368
+ return { uidvalidity: 1 };
369
+ },
332
370
  moveMessages: async () => ({ uidMap: new Map([[10, 20]]) }),
333
371
  deleteMessages: record(
334
372
  "connection.deleteMessages",
@@ -338,18 +376,28 @@ const buildConnection = (): Connection => ({
338
376
  ) as Connection["createMailbox"],
339
377
  // The source box still holds the uid unless a case says otherwise, so the
340
378
  // presence probe only reports "gone" where a test made it true.
341
- 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
+ },
342
383
  search: async (...args: unknown[]) => {
343
384
  h.calls.push({ method: "connection.search", args });
344
- 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;
345
389
  },
346
390
  });
347
391
 
348
392
  const sourceNoLongerHoldsTheUid = (): void => {
393
+ h.sourceSearchUids = [];
349
394
  h.connection.fetchMessages = async () => [];
350
395
  h.connection.search = async (...args: unknown[]) => {
351
396
  h.calls.push({ method: "connection.search", args });
352
- return isMessageIdSearch(args[0]) ? h.destinationSearchUids : [];
397
+ if (!isMessageIdSearch(args[0])) return [];
398
+ return h.openBoxPath === "INBOX"
399
+ ? h.sourceSearchUids
400
+ : h.destinationSearchUids;
353
401
  };
354
402
  };
355
403
 
@@ -359,6 +407,8 @@ const fresh = (): Harness => ({
359
407
  mailbox: { mailboxId: "src-mbx", uidValidity: 1, cursorState: undefined },
360
408
  messageRow: { messageIdHeader: MESSAGE_ID_HEADER },
361
409
  destinationSearchUids: [],
410
+ sourceSearchUids: [10],
411
+ openBoxPath: "INBOX",
362
412
  connection: buildConnection(),
363
413
  threadMessage: {
364
414
  ...baseThreadMessage,
@@ -372,6 +422,7 @@ const fresh = (): Harness => ({
372
422
  getConnectionCount: 0,
373
423
  disconnectCount: 0,
374
424
  connectionErrors: [],
425
+ logs: [],
375
426
  });
376
427
 
377
428
  const deps = (): MessageDeleteDeps =>
@@ -389,12 +440,16 @@ const deps = (): MessageDeleteDeps =>
389
440
  return h.messageRow ? [h.messageRow] : [];
390
441
  },
391
442
  updateUid: record("message.updateUid"),
443
+ updateForMove: record("message.updateForMove"),
392
444
  update: record("message.update"),
393
445
  delete: record("message.delete"),
394
446
  },
395
447
  threadMessage: {
396
448
  findByMessageId: async () => h.threadMessage,
397
- findAllByMessageId: async () => h.allThreadMessages,
449
+ findAllByMessageId: async (...args: unknown[]) => {
450
+ h.calls.push({ method: "threadMessage.findAllByMessageId", args });
451
+ return h.allThreadMessages;
452
+ },
398
453
  update: async (...args: unknown[]) => {
399
454
  h.calls.push({ method: "threadMessage.update", args });
400
455
  if (h.threadMessageUpdateError) throw h.threadMessageUpdateError;
@@ -459,6 +514,16 @@ const permanentEvent: MessageDeleteEvent = {
459
514
  const called = (method: string): Call[] =>
460
515
  h.calls.filter((c) => c.method === method);
461
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
+
462
527
  // Label order in the rendered text is prom-client's, not ours.
463
528
  const imapFailures = async (operation: string): Promise<number> => {
464
529
  const line = (await renderMetrics())
@@ -554,6 +619,7 @@ describe("handleMessageDelete", () => {
554
619
  const opened: unknown[][] = [];
555
620
  h.connection.openBox = (async (...args: unknown[]) => {
556
621
  opened.push(args);
622
+ h.openBoxPath = args[0] as string;
557
623
  return { uidvalidity: 1 };
558
624
  }) as Connection["openBox"];
559
625
 
@@ -736,14 +802,13 @@ describe("handleMessageDelete", () => {
736
802
  assert.equal(called("message.delete").length, 0);
737
803
  assert.equal(called("threadMessage.deleteMany").length, 0);
738
804
 
739
- // `mailboxId` and `uid` name the same message again, and `updateUid`
740
- // settles `status` out of `moving` in the same write, so no later
741
- // delete of this message waits on a mutation that has terminated.
742
- assert.deepEqual(called("message.updateUid").at(-1)?.args, [
743
- "msg-1",
744
- 10,
745
- "src-mbx",
746
- ]);
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
+ );
747
812
  assert.equal(
748
813
  called("message.update").length,
749
814
  0,
@@ -842,11 +907,10 @@ describe("handleMessageDelete", () => {
842
907
 
843
908
  await handleMessageDelete(moveEvent, noopLog, 3, deps());
844
909
 
845
- assert.deepEqual(called("message.updateUid").at(-1)?.args, [
846
- "msg-1",
847
- 10,
848
- "src-mbx",
849
- ]);
910
+ assert.deepEqual(
911
+ called("message.updateForMove").at(-1)?.args,
912
+ restoredToSource("synced"),
913
+ );
850
914
  assert.equal(await imapFailures("MESSAGE_DELETE_EXHAUSTED"), 1);
851
915
  });
852
916
  });
@@ -906,16 +970,12 @@ describe("handleMessageDelete", () => {
906
970
 
907
971
  // The row goes back to the mailbox the server still holds it in, so
908
972
  // the failure is visible as the message reappearing rather than as an
909
- // invisible syncStatus on a row that claims Trash.
910
- assert.deepEqual(called("message.updateUid")[0]?.args, [
911
- "msg-1",
912
- 10,
913
- "src-mbx",
914
- ]);
915
- assert.deepEqual(called("message.update")[0]?.args[1], {
916
- status: "active",
917
- syncStatus: "failed",
918
- });
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
+ );
919
979
  assert.deepEqual(called("threadMessage.update")[0]?.args[2], {
920
980
  uid: 10,
921
981
  mailboxId: "src-mbx",
@@ -935,10 +995,10 @@ describe("handleMessageDelete", () => {
935
995
 
936
996
  assert.equal(called("connection.deleteMessages").length, 0);
937
997
  assert.equal(called("message.delete").length, 0);
938
- assert.deepEqual(called("message.update")[0]?.args[1], {
939
- status: "active",
940
- syncStatus: "failed",
941
- });
998
+ assert.deepEqual(
999
+ called("message.updateForMove")[0]?.args,
1000
+ restoredToSource("failed"),
1001
+ );
942
1002
  });
943
1003
 
944
1004
  it("expunges on the server and removes every thread row before the message row", async () => {
@@ -1081,15 +1141,10 @@ describe("handleMessageDelete", () => {
1081
1141
  await handleMessageDelete(moveEvent, noopLog, 1, deps());
1082
1142
 
1083
1143
  assert.equal(called("connection.createMailbox").length, 0);
1084
- assert.deepEqual(called("message.updateUid")[0]?.args, [
1085
- "msg-1",
1086
- 10,
1087
- "src-mbx",
1088
- ]);
1089
- assert.deepEqual(called("message.update")[0]?.args[1], {
1090
- status: "active",
1091
- syncStatus: "failed",
1092
- });
1144
+ assert.deepEqual(
1145
+ called("message.updateForMove")[0]?.args,
1146
+ restoredToSource("failed"),
1147
+ );
1093
1148
  assert.deepEqual(called("threadMessage.update")[0]?.args[2], {
1094
1149
  uid: 10,
1095
1150
  mailboxId: "src-mbx",
@@ -1107,11 +1162,10 @@ describe("handleMessageDelete", () => {
1107
1162
 
1108
1163
  assert.equal(h.getConnectionCount, 0);
1109
1164
  assert.equal(called("connection.deleteMessages").length, 0);
1110
- assert.deepEqual(called("message.updateUid")[0]?.args, [
1111
- "msg-1",
1112
- 10,
1113
- "src-mbx",
1114
- ]);
1165
+ assert.deepEqual(
1166
+ called("message.updateForMove")[0]?.args,
1167
+ restoredToSource("failed"),
1168
+ );
1115
1169
  assert.deepEqual(called("threadMessage.update")[0]?.args[2], {
1116
1170
  uid: 10,
1117
1171
  mailboxId: "src-mbx",
@@ -1172,7 +1226,12 @@ describe("handleMessageDelete", () => {
1172
1226
  );
1173
1227
  });
1174
1228
 
1175
- 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 () => {
1176
1235
  h.connection.openBox = async () => ({ uidvalidity: 999 });
1177
1236
 
1178
1237
  await handleMessageDelete(moveEvent, noopLog, 1, deps());
@@ -1182,18 +1241,31 @@ describe("handleMessageDelete", () => {
1182
1241
  ?.cursorState,
1183
1242
  "cursor_invalid",
1184
1243
  );
1185
- assert.equal(called("message.updateUid").length, 0);
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
+ );
1186
1255
  });
1187
1256
 
1188
1257
  // The terminal resolver opens the source itself, and since #1005 its answer
1189
1258
  // WRITES a placement. A folder deleted and recreated renumbers every uid, so
1190
- // an unguarded probe finds whatever now sits at this one, reads it as "still
1259
+ // a uid-keyed probe finds whatever now sits at this one, reads it as "still
1191
1260
  // at source", and binds the row to a stranger the next permanent delete
1192
1261
  // expunges. The dropped connection is what makes it reachable: it skips the
1193
1262
  // guarded openBox on the very delivery that goes on to settle.
1194
- it("never settles an exhausted delete off a probe that skipped the cursor guard", async () => {
1263
+ it("never settles an exhausted delete off a uid-keyed probe that skipped the cursor guard", async () => {
1195
1264
  h.connectionErrors = [new Error("ECONNRESET")];
1196
- h.connection.openBox = async () => ({ uidvalidity: 999 });
1265
+ h.connection.openBox = async (path: string) => {
1266
+ h.openBoxPath = path;
1267
+ return { uidvalidity: 999 };
1268
+ };
1197
1269
 
1198
1270
  await handleMessageDelete(
1199
1271
  moveEvent,
@@ -1203,14 +1275,14 @@ describe("handleMessageDelete", () => {
1203
1275
  );
1204
1276
 
1205
1277
  assert.equal(
1206
- called("message.updateUid").length,
1278
+ called("connection.fetchMessages").length,
1207
1279
  0,
1208
- "a renumbered folder answers for a different message; nothing settles off it",
1280
+ "the guard refused the SELECT, so nothing was settled off a uid on the dead axis",
1209
1281
  );
1210
1282
  assert.equal(
1211
1283
  called("message.delete").length,
1212
1284
  0,
1213
- "and nothing is reconciled away off it either",
1285
+ "a renumbered folder answers for a different message; nothing is reconciled away off it",
1214
1286
  );
1215
1287
  assert.equal(
1216
1288
  (called("mailbox.update")[0]?.args[2] as { cursorState?: string })
@@ -1218,6 +1290,232 @@ describe("handleMessageDelete", () => {
1218
1290
  "cursor_invalid",
1219
1291
  "the probe trips the mailbox into a rebuild instead",
1220
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
+
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
+ );
1221
1519
  });
1222
1520
 
1223
1521
  it("skips without opening a connection when the cursor is rebuilding", async () => {
@@ -1230,6 +1528,54 @@ describe("handleMessageDelete", () => {
1230
1528
  await handleMessageDelete(moveEvent, noopLog, 1, deps());
1231
1529
 
1232
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
+ );
1233
1579
  });
1234
1580
 
1235
1581
  it("acks terminally without connecting when the mailbox was deleted", async () => {