@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.
- 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 +6 -1
- package/src/handlers/message-delete.test.ts +400 -54
- package/src/handlers/message-delete.ts +236 -97
- package/src/handlers/message-move-terminal.test.ts +10 -22
- package/src/handlers/message-move-terminal.ts +6 -1
- package/src/handlers/message-move.test.ts +363 -1
- package/src/handlers/message-move.ts +289 -51
- package/src/handlers/restore-source-placement.ts +66 -20
|
@@ -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 () =>
|
|
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[]) =>
|
|
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
|
-
|
|
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
|
-
|
|
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 () =>
|
|
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
|
|
740
|
-
// settles `status` out of `moving
|
|
741
|
-
//
|
|
742
|
-
assert.deepEqual(
|
|
743
|
-
"
|
|
744
|
-
|
|
745
|
-
|
|
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(
|
|
846
|
-
"
|
|
847
|
-
|
|
848
|
-
|
|
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
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
"
|
|
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(
|
|
939
|
-
|
|
940
|
-
|
|
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(
|
|
1085
|
-
"
|
|
1086
|
-
|
|
1087
|
-
|
|
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(
|
|
1111
|
-
"
|
|
1112
|
-
|
|
1113
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
//
|
|
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 () =>
|
|
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("
|
|
1278
|
+
called("connection.fetchMessages").length,
|
|
1207
1279
|
0,
|
|
1208
|
-
"
|
|
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
|
-
"
|
|
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 () => {
|