@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.
- package/package.json +1 -1
- package/src/handlers/message-copy.test.ts +99 -2
- package/src/handlers/message-copy.ts +82 -12
- package/src/handlers/message-delete-terminal.ts +11 -19
- package/src/handlers/message-delete.test.ts +435 -47
- package/src/handlers/message-delete.ts +259 -89
- package/src/handlers/message-move-terminal.test.ts +163 -18
- package/src/handlers/message-move-terminal.ts +46 -10
- package/src/handlers/message-move.test.ts +363 -1
- package/src/handlers/message-move.ts +339 -60
- package/src/handlers/restore-source-placement.ts +135 -0
|
@@ -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 () =>
|
|
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[]) =>
|
|
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
|
-
|
|
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
|
-
|
|
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 () =>
|
|
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
|
|
733
|
-
// settles `status` out of `moving
|
|
734
|
-
//
|
|
735
|
-
assert.deepEqual(
|
|
736
|
-
"
|
|
737
|
-
|
|
738
|
-
|
|
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(
|
|
839
|
-
"
|
|
840
|
-
|
|
841
|
-
|
|
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
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
"
|
|
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(
|
|
932
|
-
|
|
933
|
-
|
|
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(
|
|
1078
|
-
"
|
|
1079
|
-
|
|
1080
|
-
|
|
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(
|
|
1104
|
-
"
|
|
1105
|
-
|
|
1106
|
-
|
|
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
|
-
|
|
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 () => {
|