@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
package/package.json
CHANGED
|
@@ -157,6 +157,7 @@ const deps = (): MessageCopyDeps =>
|
|
|
157
157
|
_log: unknown,
|
|
158
158
|
cb: (credentials: unknown) => Promise<void>,
|
|
159
159
|
) => cb({}),
|
|
160
|
+
emitEvent: record("emitEvent"),
|
|
160
161
|
createConnectionScope: () => ({
|
|
161
162
|
getConnection: async () => {
|
|
162
163
|
h.getConnectionCount += 1;
|
|
@@ -369,7 +370,12 @@ describe("handleMessageCopy", () => {
|
|
|
369
370
|
assert.equal(called("message.update").length, 0);
|
|
370
371
|
});
|
|
371
372
|
|
|
372
|
-
|
|
373
|
+
// Issue #1203. Both pauses are reached before `copyMessages`, so the COPY
|
|
374
|
+
// was never issued. Acking left the optimistic row at `uid: 0`/`moving`
|
|
375
|
+
// with nothing to re-enqueue it and no folder set for the cursor rebuild to
|
|
376
|
+
// find it in, which is `holdsCopyOf`'s row-existence answer turning into a
|
|
377
|
+
// copy that can never settle.
|
|
378
|
+
it("reconciles the optimistic copy row away when the cursor is rebuilding", async () => {
|
|
373
379
|
h.mailbox = {
|
|
374
380
|
mailboxId: "src-mbx",
|
|
375
381
|
uidValidity: 1,
|
|
@@ -380,9 +386,99 @@ describe("handleMessageCopy", () => {
|
|
|
380
386
|
|
|
381
387
|
assert.equal(h.getConnectionCount, 0);
|
|
382
388
|
assert.equal(called("message.updateUid").length, 0);
|
|
389
|
+
assert.deepEqual(called("message.delete")[0]?.args, ["new-msg"]);
|
|
390
|
+
assert.equal(called("threadMessage.deleteMany").length, 1);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// A redelivery's earlier attempt may have copied: the tagged OK can be lost
|
|
394
|
+
// with the connection. Deleting this row on the pause alone would leave the
|
|
395
|
+
// landed copy with no row of its own — `holdsCopyOf` answers on row
|
|
396
|
+
// existence, so no sync makes a second one — and the next destination sync
|
|
397
|
+
// would repoint the SOURCE row out of the folder the server still holds it
|
|
398
|
+
// in. The pause is on the source; the destination is asked before anything
|
|
399
|
+
// is deleted.
|
|
400
|
+
it("asks the destination before reconciling a redelivered copy onto a paused cursor", async () => {
|
|
401
|
+
h.mailbox = {
|
|
402
|
+
mailboxId: "src-mbx",
|
|
403
|
+
uidValidity: 1,
|
|
404
|
+
cursorState: "rebuilding",
|
|
405
|
+
};
|
|
406
|
+
h.destinationHolds = [42];
|
|
407
|
+
|
|
408
|
+
await handleMessageCopy(event, noopLog, 2, deps());
|
|
409
|
+
|
|
410
|
+
assert.ok(askedTheDestination(), "the destination was asked for the copy");
|
|
411
|
+
assert.deepEqual(called("message.updateUid")[0]?.args, [
|
|
412
|
+
"new-msg",
|
|
413
|
+
42,
|
|
414
|
+
"dst-mbx",
|
|
415
|
+
]);
|
|
416
|
+
assert.equal(
|
|
417
|
+
called("message.delete").length,
|
|
418
|
+
0,
|
|
419
|
+
"a copy the destination confirms is never reconciled away",
|
|
420
|
+
);
|
|
421
|
+
assert.equal(h.disconnectCount, 1);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("settles a redelivered copy broken when the destination cannot be asked", async () => {
|
|
425
|
+
h.mailbox = {
|
|
426
|
+
mailboxId: "src-mbx",
|
|
427
|
+
uidValidity: 1,
|
|
428
|
+
cursorState: "rebuilding",
|
|
429
|
+
};
|
|
430
|
+
h.copyRow = { ...unsettledCopyRow(), messageIdHeader: undefined };
|
|
431
|
+
|
|
432
|
+
await handleMessageCopy(event, noopLog, 2, deps());
|
|
433
|
+
|
|
434
|
+
assert.equal(
|
|
435
|
+
called("message.delete").length,
|
|
436
|
+
0,
|
|
437
|
+
"silence is never grounds to delete a row that may describe real mail",
|
|
438
|
+
);
|
|
439
|
+
assert.equal(
|
|
440
|
+
(called("message.update")[0]?.args[1] as { status?: string })?.status,
|
|
441
|
+
"deleted",
|
|
442
|
+
);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// A paused settle is a settle, and this handler had no way to say so: it
|
|
446
|
+
// carried no `emitEvent` at all, so the destination — which is not paused
|
|
447
|
+
// and has no rebuild coming — was never told to re-project. The move and
|
|
448
|
+
// delete handlers have run this resync since #1203.
|
|
449
|
+
it("resyncs both folders after a paused copy settles", async () => {
|
|
450
|
+
h.mailbox = {
|
|
451
|
+
mailboxId: "src-mbx",
|
|
452
|
+
uidValidity: 1,
|
|
453
|
+
cursorState: "rebuilding",
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
await handleMessageCopy(event, noopLog, 1, deps());
|
|
457
|
+
|
|
458
|
+
assert.deepEqual(
|
|
459
|
+
called("emitEvent").map((c) => c.args[0]),
|
|
460
|
+
[
|
|
461
|
+
{ type: "SYNC_MESSAGES", accountId: "acc-1", mailboxId: "src-mbx" },
|
|
462
|
+
{ type: "SYNC_MESSAGES", accountId: "acc-1", mailboxId: "dst-mbx" },
|
|
463
|
+
],
|
|
464
|
+
);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("reconciles a redelivered copy away when the destination denies holding it", async () => {
|
|
468
|
+
h.mailbox = {
|
|
469
|
+
mailboxId: "src-mbx",
|
|
470
|
+
uidValidity: 1,
|
|
471
|
+
cursorState: "rebuilding",
|
|
472
|
+
};
|
|
473
|
+
h.destinationHolds = [];
|
|
474
|
+
|
|
475
|
+
await handleMessageCopy(event, noopLog, 2, deps());
|
|
476
|
+
|
|
477
|
+
assert.ok(askedTheDestination());
|
|
478
|
+
assert.deepEqual(called("message.delete")[0]?.args, ["new-msg"]);
|
|
383
479
|
});
|
|
384
480
|
|
|
385
|
-
it("
|
|
481
|
+
it("reconciles the optimistic copy row away when openBox trips a UIDVALIDITY mismatch", async () => {
|
|
386
482
|
h.connection.openBox = async () => ({ uidvalidity: 999 });
|
|
387
483
|
|
|
388
484
|
await handleMessageCopy(event, noopLog, 1, deps());
|
|
@@ -394,6 +490,7 @@ describe("handleMessageCopy", () => {
|
|
|
394
490
|
"the mismatch trips the mailbox cursor",
|
|
395
491
|
);
|
|
396
492
|
assert.equal(called("message.updateUid").length, 0);
|
|
493
|
+
assert.deepEqual(called("message.delete")[0]?.args, ["new-msg"]);
|
|
397
494
|
assert.equal(h.disconnectCount, 1);
|
|
398
495
|
});
|
|
399
496
|
|
|
@@ -12,17 +12,22 @@ import {
|
|
|
12
12
|
import { attemptBudget } from "@remit/sqs-client/attempt-budget";
|
|
13
13
|
import { isAccountDeleted } from "../account-check.js";
|
|
14
14
|
import { createConnectionScopeWithCredentials } from "../connection-scope.js";
|
|
15
|
+
import { emitEvent } from "../emit.js";
|
|
15
16
|
import type { MessageCopyEvent } from "../events.js";
|
|
16
17
|
import { isNotFoundError } from "../is-not-found.js";
|
|
17
18
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
18
19
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
19
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
emitMoveResync,
|
|
22
|
+
searchMailboxForHighestMessageIdUid,
|
|
23
|
+
} from "./message-move.js";
|
|
20
24
|
|
|
21
25
|
export interface MessageCopyDeps {
|
|
22
26
|
getClient: typeof getClient;
|
|
23
27
|
buildLifecycleDeps: typeof buildLifecycleDeps;
|
|
24
28
|
withOAuthLifecycle: typeof withOAuthLifecycle;
|
|
25
29
|
createConnectionScope: typeof createConnectionScopeWithCredentials;
|
|
30
|
+
emitEvent: typeof emitEvent;
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
const defaultDeps: MessageCopyDeps = {
|
|
@@ -30,6 +35,7 @@ const defaultDeps: MessageCopyDeps = {
|
|
|
30
35
|
buildLifecycleDeps,
|
|
31
36
|
withOAuthLifecycle,
|
|
32
37
|
createConnectionScope: createConnectionScopeWithCredentials,
|
|
38
|
+
emitEvent,
|
|
33
39
|
};
|
|
34
40
|
|
|
35
41
|
export const getMessageCopyMaxAttempts = (
|
|
@@ -83,6 +89,7 @@ export const handleMessageCopy = async (
|
|
|
83
89
|
buildLifecycleDeps,
|
|
84
90
|
withOAuthLifecycle,
|
|
85
91
|
createConnectionScope: createConnectionScopeWithCredentials,
|
|
92
|
+
emitEvent,
|
|
86
93
|
} = deps;
|
|
87
94
|
|
|
88
95
|
const {
|
|
@@ -172,17 +179,6 @@ export const handleMessageCopy = async (
|
|
|
172
179
|
return;
|
|
173
180
|
}
|
|
174
181
|
|
|
175
|
-
// Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
|
|
176
|
-
// paused never even opens a connection. Optimization only — the
|
|
177
|
-
// guardConnectionCursor openBox wrap below is the structural guarantee.
|
|
178
|
-
if (isCursorRebuildNeeded(mailbox.cursorState)) {
|
|
179
|
-
log.info(
|
|
180
|
-
{ accountId, sourceMessageId, mailboxId: sourceMailboxId },
|
|
181
|
-
"Mailbox cursor not normal; pausing outbound copy this round",
|
|
182
|
-
);
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
182
|
// The copy row carries the source's Message-ID header, so the
|
|
187
183
|
// destination can be asked where this copy is. Always on the UNGUARDED
|
|
188
184
|
// handle: a `guardConnectionCursor` wrap is bound to the SOURCE mailbox
|
|
@@ -317,6 +313,78 @@ export const handleMessageCopy = async (
|
|
|
317
313
|
|
|
318
314
|
const scope = createConnectionScopeWithCredentials(account, credentials);
|
|
319
315
|
|
|
316
|
+
// A paused cursor settles rather than acks: acking leaves the optimistic
|
|
317
|
+
// row at `uid: 0`/`moving` with nothing to re-enqueue it and no folder
|
|
318
|
+
// set for the cursor rebuild to find it in (issue #1203). Like every
|
|
319
|
+
// other verdict on this row, it reconciles rather than waits (R2,
|
|
320
|
+
// docs/architecture/imap-mutations.md) — the settle below IS the
|
|
321
|
+
// reconciliation path, since the destination is not paused and no
|
|
322
|
+
// rebuild is coming for it.
|
|
323
|
+
//
|
|
324
|
+
// What may be concluded from the pause depends on what was issued, and
|
|
325
|
+
// `receiveCount` alone does not say: `commandIssued` is what this round
|
|
326
|
+
// did, a redelivery is what an earlier one may have done and lost with
|
|
327
|
+
// its connection. Where neither says the server may have been asked, the
|
|
328
|
+
// row records something the server was never told about and reconciling
|
|
329
|
+
// it away is exact. Otherwise this row may be the only record real mail
|
|
330
|
+
// will ever have, and deleting it would let the next destination sync
|
|
331
|
+
// repoint the SOURCE row out of the folder the server still holds it in.
|
|
332
|
+
// The pause is on the source and says nothing about the destination,
|
|
333
|
+
// which is probed on the unguarded handle anyway, so the destination is
|
|
334
|
+
// asked before anything is deleted and silence settles broken.
|
|
335
|
+
//
|
|
336
|
+
// A settle is a settle, so both folders re-read their counts from IMAP,
|
|
337
|
+
// the same resync the move and delete handlers run: the destination is
|
|
338
|
+
// not paused, so nothing else is coming to re-project it.
|
|
339
|
+
const settlePausedCopy = async (
|
|
340
|
+
commandIssued: boolean,
|
|
341
|
+
): Promise<void> => {
|
|
342
|
+
const mayHaveIssued = commandIssued || receiveCount > 1;
|
|
343
|
+
|
|
344
|
+
if (!mayHaveIssued) {
|
|
345
|
+
await settleNeverLanded(
|
|
346
|
+
"source mailbox cursor paused before the copy",
|
|
347
|
+
);
|
|
348
|
+
} else {
|
|
349
|
+
const probe = await probeDestination(await scope.getConnection());
|
|
350
|
+
if (probe.kind === "confirmed") {
|
|
351
|
+
await settleCopied(probe.uid);
|
|
352
|
+
} else if (probe.kind === "absent") {
|
|
353
|
+
await settleNeverLanded(
|
|
354
|
+
"redelivered onto a paused source cursor, absent from destination",
|
|
355
|
+
);
|
|
356
|
+
} else {
|
|
357
|
+
await settleBroken(
|
|
358
|
+
"redelivered onto a paused source cursor with no Message-ID to ask the destination with",
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
await emitMoveResync(emitEvent, {
|
|
364
|
+
accountId,
|
|
365
|
+
sourceMailboxId,
|
|
366
|
+
destinationMailboxId,
|
|
367
|
+
});
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
// Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
|
|
371
|
+
// paused never even opens a connection on a first delivery. Optimization
|
|
372
|
+
// only — the guardConnectionCursor openBox wrap below is the structural
|
|
373
|
+
// guarantee.
|
|
374
|
+
if (isCursorRebuildNeeded(mailbox.cursorState)) {
|
|
375
|
+
log.info(
|
|
376
|
+
{ accountId, sourceMessageId, mailboxId: sourceMailboxId },
|
|
377
|
+
"Mailbox cursor not normal; pausing outbound copy this round",
|
|
378
|
+
);
|
|
379
|
+
await settlePausedCopy(false).finally(() => scope.disconnect());
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Flipped the instant the COPY leaves, so every settle downstream of it
|
|
384
|
+
// knows the server was asked. IMAP COPY is not idempotent, so this is
|
|
385
|
+
// the flag that must never be inferred.
|
|
386
|
+
let commandIssued = false;
|
|
387
|
+
|
|
320
388
|
await scope
|
|
321
389
|
.getConnection()
|
|
322
390
|
.then(async (rawConnection) => {
|
|
@@ -354,6 +422,7 @@ export const handleMessageCopy = async (
|
|
|
354
422
|
await connection.openBox(sourceMailboxPath, true);
|
|
355
423
|
|
|
356
424
|
// Execute IMAP COPY
|
|
425
|
+
commandIssued = true;
|
|
357
426
|
const result = await connection.copyMessages(
|
|
358
427
|
[uid],
|
|
359
428
|
destinationMailboxPath,
|
|
@@ -395,6 +464,7 @@ export const handleMessageCopy = async (
|
|
|
395
464
|
},
|
|
396
465
|
"Mailbox cursor not normal; pausing outbound copy this round",
|
|
397
466
|
);
|
|
467
|
+
await settlePausedCopy(commandIssued);
|
|
398
468
|
return;
|
|
399
469
|
}
|
|
400
470
|
|
|
@@ -2,13 +2,14 @@ import type {
|
|
|
2
2
|
IMessageRepository,
|
|
3
3
|
IThreadMessageRepository,
|
|
4
4
|
} from "@remit/data-ports";
|
|
5
|
+
import { MessageSyncStatus } from "@remit/domain-enums";
|
|
5
6
|
import {
|
|
6
7
|
type IImapConnection,
|
|
7
8
|
isMessageGoneFromOpenMailbox,
|
|
8
9
|
reconcileStaleMessage,
|
|
9
10
|
type StaleMessageReconcileDeps,
|
|
10
11
|
} from "@remit/mailbox-service";
|
|
11
|
-
import {
|
|
12
|
+
import { restoreSourcePlacement } from "./restore-source-placement.js";
|
|
12
13
|
|
|
13
14
|
export interface MessageDeleteTerminalLogger {
|
|
14
15
|
info(obj: Record<string, unknown>, msg: string): void;
|
|
@@ -17,7 +18,7 @@ export interface MessageDeleteTerminalLogger {
|
|
|
17
18
|
|
|
18
19
|
export interface ResolveExhaustedMessageDeleteDeps
|
|
19
20
|
extends StaleMessageReconcileDeps {
|
|
20
|
-
messageService: Pick<IMessageRepository, "delete" | "
|
|
21
|
+
messageService: Pick<IMessageRepository, "delete" | "updateForMove">;
|
|
21
22
|
threadMessageService: Pick<
|
|
22
23
|
IThreadMessageRepository,
|
|
23
24
|
"findAllByMessageId" | "deleteMany" | "update"
|
|
@@ -125,25 +126,16 @@ export const resolveExhaustedMessageDeleteFailure = async (
|
|
|
125
126
|
"Delete could not be pushed to IMAP after retry exhaustion; the message is still at its source — row moved back to the source folder",
|
|
126
127
|
);
|
|
127
128
|
|
|
128
|
-
await deps
|
|
129
|
-
|
|
130
|
-
const threadMessages = await deps.threadMessageService.findAllByMessageId(
|
|
129
|
+
await restoreSourcePlacement(deps, {
|
|
131
130
|
accountConfigId,
|
|
132
131
|
messageId,
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
await deps.threadMessageService.update(
|
|
141
|
-
threadMessage.accountConfigId,
|
|
142
|
-
threadMessage.threadMessageId,
|
|
143
|
-
args.set,
|
|
144
|
-
{ composites: args.composites },
|
|
145
|
-
);
|
|
146
|
-
}
|
|
132
|
+
sourceMailboxId,
|
|
133
|
+
uid,
|
|
134
|
+
// The probe above just confirmed the pair, so the row is a faithful
|
|
135
|
+
// projection of the source again. The delete's own failure is the alert,
|
|
136
|
+
// not a `failed` left on a row nothing is still trying to delete.
|
|
137
|
+
syncStatus: MessageSyncStatus.synced,
|
|
138
|
+
});
|
|
147
139
|
|
|
148
140
|
return { outcome: "broken" };
|
|
149
141
|
};
|