@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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getClient } from "@remit/backend/client";
|
|
2
|
-
import type { ThreadMessageItem } from "@remit/data-ports";
|
|
2
|
+
import type { MessageItem, ThreadMessageItem } from "@remit/data-ports";
|
|
3
3
|
import { MessageSyncStatus } from "@remit/domain-enums";
|
|
4
4
|
import type { Logger } from "@remit/logger-lambda";
|
|
5
5
|
import { recordImapFailure } from "@remit/logger-lambda";
|
|
@@ -19,6 +19,7 @@ import { isNotFoundError } from "../is-not-found.js";
|
|
|
19
19
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
20
20
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
21
21
|
import { resolveExhaustedMessageMoveFailure } from "./message-move-terminal.js";
|
|
22
|
+
import { restoreSourcePlacement } from "./restore-source-placement.js";
|
|
22
23
|
|
|
23
24
|
export const getMessageMoveMaxAttempts = (
|
|
24
25
|
processEnv: NodeJS.ProcessEnv = process.env,
|
|
@@ -52,6 +53,18 @@ export const emitMoveResync = async (
|
|
|
52
53
|
);
|
|
53
54
|
};
|
|
54
55
|
|
|
56
|
+
/**
|
|
57
|
+
* The one-folder half of {@link emitMoveResync}, for a mutation that has no
|
|
58
|
+
* destination — a permanent delete removed local rows the server still holds,
|
|
59
|
+
* and only a resync of that folder puts them back.
|
|
60
|
+
*/
|
|
61
|
+
export const emitMailboxResync = async (
|
|
62
|
+
emit: EmitSyncMessages,
|
|
63
|
+
params: { accountId: string; mailboxId: string },
|
|
64
|
+
): Promise<void> => {
|
|
65
|
+
await emit({ type: "SYNC_MESSAGES", ...params });
|
|
66
|
+
};
|
|
67
|
+
|
|
55
68
|
/**
|
|
56
69
|
* SEARCH a mailbox for copies of a message identified by its RFC822 Message-ID
|
|
57
70
|
* header and answer the HIGHEST matching uid. Read-only (EXAMINE, not SELECT) —
|
|
@@ -87,6 +100,79 @@ export const searchMailboxForHighestMessageIdUid = async (
|
|
|
87
100
|
return uids.reduce((highest, uid) => (uid > highest ? uid : highest));
|
|
88
101
|
};
|
|
89
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Which side of a paused mutation the server actually holds the message on.
|
|
105
|
+
* `gone` is an answer both folders gave; `unprobeable` is the row that carries
|
|
106
|
+
* no Message-ID header, where the server was never asked and its silence says
|
|
107
|
+
* nothing.
|
|
108
|
+
*/
|
|
109
|
+
export type PausedPlacement =
|
|
110
|
+
| { kind: "at-source" }
|
|
111
|
+
| { kind: "at-destination"; uid: number }
|
|
112
|
+
| { kind: "gone" }
|
|
113
|
+
| { kind: "unprobeable" };
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Ask the server, by Message-ID, which folder holds a message whose mutation a
|
|
117
|
+
* paused cursor interrupted (issue #1203).
|
|
118
|
+
*
|
|
119
|
+
* Reached only on a redelivery, where "the command never left" has stopped
|
|
120
|
+
* being provable: the earlier attempt's tagged OK can be lost with the
|
|
121
|
+
* connection, so restoring the source pair on that assumption would write a
|
|
122
|
+
* settled placement naming a folder the server has already moved the mail out
|
|
123
|
+
* of. `message-copy.ts` asks the same question for the same reason.
|
|
124
|
+
*
|
|
125
|
+
* The SOURCE is asked first and a hit ends it, the gate `confirmTrashMoveUid`
|
|
126
|
+
* documents (#1122): where the mutation never ran, every hit at the destination
|
|
127
|
+
* is an older copy of the same Message-ID — a sieve `fileinto` + `keep`, a
|
|
128
|
+
* multi-label store, a resend — that `deriveMessageId` folds into this one local
|
|
129
|
+
* row, and binding to it hands a later delete somebody else's uid.
|
|
130
|
+
*
|
|
131
|
+
* The source's silence, however, is not the mirror image of its hit. Every
|
|
132
|
+
* caller here reaches this on a paused cursor, which means the source's
|
|
133
|
+
* UIDVALIDITY has moved and the folder may have been recreated holding nothing
|
|
134
|
+
* it held before — so "not at the source" is an answer about the folder, not
|
|
135
|
+
* about our mutation, and `at-destination` is a sighting rather than a
|
|
136
|
+
* confirmation. Callers settle it as unproven; see `settlePausedMove`.
|
|
137
|
+
*
|
|
138
|
+
* Identity, not position, is what makes this askable at all: a paused cursor
|
|
139
|
+
* means the source's UIDVALIDITY has moved, so every stored uid for it names
|
|
140
|
+
* something else, but a SEARCH by header is independent of the axis and answers
|
|
141
|
+
* the same question the cursor rebuild itself matches rows by. For that reason
|
|
142
|
+
* the caller passes the UNGUARDED connection — a `guardConnectionCursor` wrap
|
|
143
|
+
* refuses to open the mailbox at all, and it is bound to the source's snapshot
|
|
144
|
+
* anyway, so opening the destination through it would trip that mailbox too.
|
|
145
|
+
*/
|
|
146
|
+
export const probePausedPlacement = async (
|
|
147
|
+
connection: IImapConnection,
|
|
148
|
+
input: {
|
|
149
|
+
messageIdHeader: string | undefined;
|
|
150
|
+
sourceMailboxPath: string;
|
|
151
|
+
destinationMailboxPath: string | undefined;
|
|
152
|
+
},
|
|
153
|
+
): Promise<PausedPlacement> => {
|
|
154
|
+
const { messageIdHeader, sourceMailboxPath, destinationMailboxPath } = input;
|
|
155
|
+
if (!messageIdHeader) return { kind: "unprobeable" };
|
|
156
|
+
|
|
157
|
+
const atSource = await searchMailboxForHighestMessageIdUid(
|
|
158
|
+
connection,
|
|
159
|
+
sourceMailboxPath,
|
|
160
|
+
messageIdHeader,
|
|
161
|
+
);
|
|
162
|
+
if (atSource !== null) return { kind: "at-source" };
|
|
163
|
+
|
|
164
|
+
if (!destinationMailboxPath) return { kind: "gone" };
|
|
165
|
+
|
|
166
|
+
const atDestination = await searchMailboxForHighestMessageIdUid(
|
|
167
|
+
connection,
|
|
168
|
+
destinationMailboxPath,
|
|
169
|
+
messageIdHeader,
|
|
170
|
+
);
|
|
171
|
+
return atDestination === null
|
|
172
|
+
? { kind: "gone" }
|
|
173
|
+
: { kind: "at-destination", uid: atDestination };
|
|
174
|
+
};
|
|
175
|
+
|
|
90
176
|
/**
|
|
91
177
|
* Resync the affected folders only once the IMAP move has resolved. A move that
|
|
92
178
|
* fails (or is retried) must not refresh counts off a move that didn't happen,
|
|
@@ -140,6 +226,22 @@ export const buildThreadMessageMoveUpdate = (
|
|
|
140
226
|
},
|
|
141
227
|
});
|
|
142
228
|
|
|
229
|
+
export interface MessageMoveDeps {
|
|
230
|
+
getClient: typeof getClient;
|
|
231
|
+
buildLifecycleDeps: typeof buildLifecycleDeps;
|
|
232
|
+
withOAuthLifecycle: typeof withOAuthLifecycle;
|
|
233
|
+
createConnectionScope: typeof createConnectionScopeWithCredentials;
|
|
234
|
+
emitEvent: typeof emitEvent;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const defaultDeps: MessageMoveDeps = {
|
|
238
|
+
getClient,
|
|
239
|
+
buildLifecycleDeps,
|
|
240
|
+
withOAuthLifecycle,
|
|
241
|
+
createConnectionScope: createConnectionScopeWithCredentials,
|
|
242
|
+
emitEvent,
|
|
243
|
+
};
|
|
244
|
+
|
|
143
245
|
/**
|
|
144
246
|
* Handle MESSAGE_MOVE events.
|
|
145
247
|
* Executes IMAP MOVE command and updates local state with new UID.
|
|
@@ -155,7 +257,16 @@ export const handleMessageMove = async (
|
|
|
155
257
|
event: MessageMoveEvent,
|
|
156
258
|
log: Logger,
|
|
157
259
|
receiveCount = 1,
|
|
260
|
+
deps: MessageMoveDeps = defaultDeps,
|
|
158
261
|
): Promise<void> => {
|
|
262
|
+
const {
|
|
263
|
+
getClient,
|
|
264
|
+
buildLifecycleDeps,
|
|
265
|
+
withOAuthLifecycle,
|
|
266
|
+
createConnectionScope: createConnectionScopeWithCredentials,
|
|
267
|
+
emitEvent,
|
|
268
|
+
} = deps;
|
|
269
|
+
|
|
159
270
|
const {
|
|
160
271
|
account: accountService,
|
|
161
272
|
message: messageService,
|
|
@@ -244,18 +355,190 @@ export const handleMessageMove = async (
|
|
|
244
355
|
return;
|
|
245
356
|
}
|
|
246
357
|
|
|
358
|
+
const settleMoved = async (newUid: number): Promise<void> => {
|
|
359
|
+
await messageService.updateUid(messageId, newUid, destinationMailboxId);
|
|
360
|
+
|
|
361
|
+
const threadMessage = await threadMessageService.findByMessageId(
|
|
362
|
+
account.accountConfigId,
|
|
363
|
+
messageId,
|
|
364
|
+
);
|
|
365
|
+
if (threadMessage) {
|
|
366
|
+
const args = buildThreadMessageMoveUpdate(
|
|
367
|
+
threadMessage,
|
|
368
|
+
newUid,
|
|
369
|
+
destinationMailboxId,
|
|
370
|
+
);
|
|
371
|
+
await threadMessageService.update(
|
|
372
|
+
threadMessage.accountConfigId,
|
|
373
|
+
threadMessage.threadMessageId,
|
|
374
|
+
args.set,
|
|
375
|
+
{ composites: args.composites },
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
log.info(
|
|
380
|
+
{
|
|
381
|
+
messageId,
|
|
382
|
+
oldUid: uid,
|
|
383
|
+
newUid,
|
|
384
|
+
destination: destinationMailboxPath,
|
|
385
|
+
},
|
|
386
|
+
"Message moved successfully",
|
|
387
|
+
);
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Put the row back on the pair it was moved off, and say so once.
|
|
392
|
+
*
|
|
393
|
+
* `metric` is the routine give-up the guard doc calls never-a-fault:
|
|
394
|
+
* the user's move was silently dropped, which is worth counting and
|
|
395
|
+
* not worth waking anyone for. `alert` is the give-up the server did
|
|
396
|
+
* not settle — the row carries `failed` and an operator adjudicates.
|
|
397
|
+
*/
|
|
398
|
+
const handBackToSource = async (
|
|
399
|
+
syncStatus: MessageItem["syncStatus"],
|
|
400
|
+
signal: { alert: string } | { metric: string },
|
|
401
|
+
reason: string,
|
|
402
|
+
): Promise<void> => {
|
|
403
|
+
const context = {
|
|
404
|
+
...signal,
|
|
405
|
+
accountId,
|
|
406
|
+
messageId,
|
|
407
|
+
uid,
|
|
408
|
+
sourceMailboxPath,
|
|
409
|
+
destinationMailboxPath,
|
|
410
|
+
receiveCount,
|
|
411
|
+
reason,
|
|
412
|
+
};
|
|
413
|
+
if ("alert" in signal) {
|
|
414
|
+
log.error(
|
|
415
|
+
context,
|
|
416
|
+
"Move given up without a settled placement; row handed back to its source for adjudication",
|
|
417
|
+
);
|
|
418
|
+
} else {
|
|
419
|
+
log.warn(
|
|
420
|
+
context,
|
|
421
|
+
"Move not pushed to the server; row handed back to its source",
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
await restoreSourcePlacement(
|
|
426
|
+
{ messageService, threadMessageService },
|
|
427
|
+
{
|
|
428
|
+
accountConfigId: account.accountConfigId,
|
|
429
|
+
messageId,
|
|
430
|
+
sourceMailboxId,
|
|
431
|
+
uid,
|
|
432
|
+
syncStatus,
|
|
433
|
+
},
|
|
434
|
+
);
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
const scope = createConnectionScopeWithCredentials(account, credentials);
|
|
438
|
+
|
|
439
|
+
// The terminal resolver opens the source itself, and its answer now
|
|
440
|
+
// WRITES a placement, so it has to reach IMAP through the same
|
|
441
|
+
// UIDVALIDITY choke point every other outbound operation does. An
|
|
442
|
+
// unguarded probe on a folder that was deleted and recreated finds
|
|
443
|
+
// whatever the server has renumbered onto this uid, reads it as "still
|
|
444
|
+
// at source", and binds the row to a stranger the next permanent delete
|
|
445
|
+
// would expunge.
|
|
446
|
+
const getGuardedConnection = async (): Promise<IImapConnection> =>
|
|
447
|
+
guardConnectionCursor(
|
|
448
|
+
await scope.getConnection(),
|
|
449
|
+
{ mailboxService },
|
|
450
|
+
accountId,
|
|
451
|
+
mailbox,
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
// A paused cursor is never acked on the optimistic row: `updateForMove`
|
|
455
|
+
// has pointed it at the destination while `uid` still names the source,
|
|
456
|
+
// and that pair strands the row for good — the cursor rebuild matches
|
|
457
|
+
// rows by `(accountConfigId, mailboxId)`, so a row naming the
|
|
458
|
+
// destination is in neither folder's set, and nothing re-enqueues a
|
|
459
|
+
// MESSAGE_MOVE (issue #1203).
|
|
460
|
+
//
|
|
461
|
+
// This move therefore reconciles rather than waits (R2,
|
|
462
|
+
// docs/architecture/imap-mutations.md): the row is settled onto
|
|
463
|
+
// whichever pair the server can be shown to hold, and the resync below
|
|
464
|
+
// plus the source's own cursor rebuild are its repair path.
|
|
465
|
+
//
|
|
466
|
+
// Putting the row back is an undo of this product's own write only where
|
|
467
|
+
// the MOVE provably never left. `commandIssued` says what this round
|
|
468
|
+
// did; `receiveCount` adds what an earlier one may have done and lost
|
|
469
|
+
// with its connection. Inferring the first from the second is what
|
|
470
|
+
// `settlePausedDelete` got wrong — attempt 1 reaches an exhausted settle
|
|
471
|
+
// too, with the MOVE already issued.
|
|
472
|
+
const settlePausedMove = async (
|
|
473
|
+
commandIssued: boolean,
|
|
474
|
+
): Promise<void> => {
|
|
475
|
+
const mayHaveIssued = commandIssued || receiveCount > 1;
|
|
476
|
+
|
|
477
|
+
const placement: PausedPlacement = mayHaveIssued
|
|
478
|
+
? await probePausedPlacement(await scope.getConnection(), {
|
|
479
|
+
messageIdHeader: message.messageIdHeader,
|
|
480
|
+
sourceMailboxPath,
|
|
481
|
+
destinationMailboxPath,
|
|
482
|
+
})
|
|
483
|
+
: { kind: "at-source" };
|
|
484
|
+
|
|
485
|
+
// A sighting at the destination is not proof this move ran. The pause
|
|
486
|
+
// means the source's UIDVALIDITY has moved, so its "no" can be a
|
|
487
|
+
// recreated folder answering about mail it never held, and the hit can
|
|
488
|
+
// be an older copy of the same Message-ID that `deriveMessageId` folds
|
|
489
|
+
// into this row — settling `moved` on it points the row at mail a
|
|
490
|
+
// later permanent delete would expunge. `unprobeable` is silence, and
|
|
491
|
+
// silence settles nothing either (the rule `message-copy.ts` already
|
|
492
|
+
// applies). Both hand the row back on the source pair carrying
|
|
493
|
+
// `failed`: that is the set the source's own cursor rebuild walks and
|
|
494
|
+
// adjudicates by Message-ID, which is the reconcile path (R2) the
|
|
495
|
+
// destination — not paused, no rebuild coming — does not have.
|
|
496
|
+
const unproven =
|
|
497
|
+
placement.kind === "at-destination" ||
|
|
498
|
+
placement.kind === "unprobeable";
|
|
499
|
+
|
|
500
|
+
if (unproven) {
|
|
501
|
+
await handBackToSource(
|
|
502
|
+
MessageSyncStatus.failed,
|
|
503
|
+
{ alert: "message_move_paused_placement_unproven" },
|
|
504
|
+
placement.kind === "at-destination"
|
|
505
|
+
? "destination holds this Message-ID but the source's uid axis has moved, so the sighting does not prove this move ran"
|
|
506
|
+
: "no Message-ID header to ask either folder with",
|
|
507
|
+
);
|
|
508
|
+
} else {
|
|
509
|
+
// `gone` lands here with `at-source`: the source pair is the set the
|
|
510
|
+
// rebuild walks, and a row it cannot match against a fresh envelope
|
|
511
|
+
// snapshot is the one thing it reconciles away.
|
|
512
|
+
await handBackToSource(
|
|
513
|
+
MessageSyncStatus.synced,
|
|
514
|
+
{ metric: "message_move_dropped_on_pause" },
|
|
515
|
+
`source mailbox cursor paused, placement ${placement.kind}`,
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
await emitMoveResync(emitEvent, {
|
|
520
|
+
accountId,
|
|
521
|
+
sourceMailboxId,
|
|
522
|
+
destinationMailboxId,
|
|
523
|
+
});
|
|
524
|
+
};
|
|
525
|
+
|
|
247
526
|
// Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
|
|
248
|
-
// paused never
|
|
249
|
-
// guardConnectionCursor openBox wrap below is the structural
|
|
527
|
+
// paused never opens a connection on a first delivery. Optimization
|
|
528
|
+
// only — the guardConnectionCursor openBox wrap below is the structural
|
|
529
|
+
// guarantee.
|
|
250
530
|
if (isCursorRebuildNeeded(mailbox.cursorState)) {
|
|
251
531
|
log.info(
|
|
252
532
|
{ accountId, messageId, mailboxId: sourceMailboxId },
|
|
253
|
-
"Mailbox cursor not normal; pausing outbound move this round",
|
|
533
|
+
"Mailbox cursor not normal; pausing outbound move this round and settling the row against the server",
|
|
254
534
|
);
|
|
535
|
+
await settlePausedMove(false).finally(() => scope.disconnect());
|
|
255
536
|
return;
|
|
256
537
|
}
|
|
257
538
|
|
|
258
|
-
|
|
539
|
+
// Flipped the instant the MOVE leaves, so every settle downstream of it
|
|
540
|
+
// knows the server was asked.
|
|
541
|
+
let commandIssued = false;
|
|
259
542
|
|
|
260
543
|
await scope
|
|
261
544
|
.getConnection()
|
|
@@ -275,6 +558,7 @@ export const handleMessageMove = async (
|
|
|
275
558
|
await connection.openBox(sourceMailboxPath, false);
|
|
276
559
|
|
|
277
560
|
// Execute IMAP MOVE
|
|
561
|
+
commandIssued = true;
|
|
278
562
|
const result = await connection.moveMessages(
|
|
279
563
|
[uid],
|
|
280
564
|
destinationMailboxPath,
|
|
@@ -304,41 +588,7 @@ export const handleMessageMove = async (
|
|
|
304
588
|
);
|
|
305
589
|
}
|
|
306
590
|
|
|
307
|
-
|
|
308
|
-
await messageService.updateUid(
|
|
309
|
-
messageId,
|
|
310
|
-
newUid,
|
|
311
|
-
destinationMailboxId,
|
|
312
|
-
);
|
|
313
|
-
|
|
314
|
-
// Update ThreadMessage UID and mailboxId
|
|
315
|
-
const threadMessage = await threadMessageService.findByMessageId(
|
|
316
|
-
account.accountConfigId,
|
|
317
|
-
messageId,
|
|
318
|
-
);
|
|
319
|
-
if (threadMessage) {
|
|
320
|
-
const args = buildThreadMessageMoveUpdate(
|
|
321
|
-
threadMessage,
|
|
322
|
-
newUid,
|
|
323
|
-
destinationMailboxId,
|
|
324
|
-
);
|
|
325
|
-
await threadMessageService.update(
|
|
326
|
-
threadMessage.accountConfigId,
|
|
327
|
-
threadMessage.threadMessageId,
|
|
328
|
-
args.set,
|
|
329
|
-
{ composites: args.composites },
|
|
330
|
-
);
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
log.info(
|
|
334
|
-
{
|
|
335
|
-
messageId,
|
|
336
|
-
oldUid: uid,
|
|
337
|
-
newUid,
|
|
338
|
-
destination: destinationMailboxPath,
|
|
339
|
-
},
|
|
340
|
-
"Message moved successfully",
|
|
341
|
-
);
|
|
591
|
+
await settleMoved(newUid);
|
|
342
592
|
},
|
|
343
593
|
() =>
|
|
344
594
|
emitMoveResync(emitEvent, {
|
|
@@ -357,8 +607,9 @@ export const handleMessageMove = async (
|
|
|
357
607
|
mailboxId: sourceMailboxId,
|
|
358
608
|
cursorState: error.state,
|
|
359
609
|
},
|
|
360
|
-
"Mailbox cursor not normal; pausing outbound move this round",
|
|
610
|
+
"Mailbox cursor not normal; pausing outbound move this round and settling the row against the server",
|
|
361
611
|
);
|
|
612
|
+
await settlePausedMove(commandIssued);
|
|
362
613
|
return;
|
|
363
614
|
}
|
|
364
615
|
|
|
@@ -395,36 +646,64 @@ export const handleMessageMove = async (
|
|
|
395
646
|
// Redelivery budget exhausted: resolve into exactly one of the two
|
|
396
647
|
// terminal outcomes instead of dead-lettering with no diagnosis,
|
|
397
648
|
// and never by inferring the server's state from our own failures.
|
|
398
|
-
const
|
|
649
|
+
const settled = await resolveExhaustedMessageMoveFailure(
|
|
399
650
|
{ messageService, threadMessageService, log },
|
|
400
651
|
{
|
|
401
652
|
accountId,
|
|
402
653
|
accountConfigId: account.accountConfigId,
|
|
403
654
|
messageId,
|
|
655
|
+
sourceMailboxId,
|
|
404
656
|
uid,
|
|
405
657
|
sourceMailboxPath,
|
|
406
|
-
getConnection:
|
|
658
|
+
getConnection: getGuardedConnection,
|
|
407
659
|
},
|
|
408
|
-
)
|
|
660
|
+
).catch(async (settleError: unknown) => {
|
|
661
|
+
// The guarded probe found a mailbox whose UIDVALIDITY has moved.
|
|
662
|
+
// Nothing may be settled off a uid on a dead axis, and the pause
|
|
663
|
+
// is the routine skip `guardMailboxCursor` documents, not a fault
|
|
664
|
+
// to re-throw out of this catch. The row is still settled, on the
|
|
665
|
+
// identity axis instead: `settlePausedMove` asks by Message-ID,
|
|
666
|
+
// which a UIDVALIDITY change does not invalidate.
|
|
667
|
+
if (settleError instanceof MailboxCursorPausedError) {
|
|
668
|
+
log.info(
|
|
669
|
+
{
|
|
670
|
+
accountId,
|
|
671
|
+
messageId,
|
|
672
|
+
mailboxId: sourceMailboxId,
|
|
673
|
+
cursorState: settleError.state,
|
|
674
|
+
},
|
|
675
|
+
"Mailbox cursor not normal; settling the exhausted move against the server by Message-ID",
|
|
676
|
+
);
|
|
677
|
+
await settlePausedMove(commandIssued);
|
|
678
|
+
return null;
|
|
679
|
+
}
|
|
680
|
+
throw settleError;
|
|
681
|
+
});
|
|
409
682
|
|
|
410
|
-
if (
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
683
|
+
if (!settled) return;
|
|
684
|
+
|
|
685
|
+
if (settled.outcome === "broken") {
|
|
686
|
+
// Terminal and never re-thrown, so the handler-outcome series
|
|
687
|
+
// records this record as a success. Counted here or it is
|
|
688
|
+
// invisible.
|
|
689
|
+
recordImapFailure("MESSAGE_MOVE_EXHAUSTED", "other");
|
|
690
|
+
log.error(
|
|
691
|
+
{ error: errorMessage },
|
|
692
|
+
"Message move retry exhausted; message still exists at its source",
|
|
693
|
+
);
|
|
419
694
|
}
|
|
420
695
|
|
|
421
|
-
//
|
|
422
|
-
//
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
696
|
+
// Both verdicts end in a row the server has contradicted, so this
|
|
697
|
+
// move reconciles rather than waits (R2). RECONCILED, the local rows
|
|
698
|
+
// are gone and whichever folder actually holds the message
|
|
699
|
+
// re-projects it with the server's own UID. BROKEN, the row has just
|
|
700
|
+
// been put back at the source the server confirmed, and the resync
|
|
701
|
+
// is what carries any drift either folder has picked up since.
|
|
702
|
+
await emitMoveResync(emitEvent, {
|
|
703
|
+
accountId,
|
|
704
|
+
sourceMailboxId,
|
|
705
|
+
destinationMailboxId,
|
|
706
|
+
});
|
|
428
707
|
// Terminal — never re-thrown, so the caller acks either way.
|
|
429
708
|
})
|
|
430
709
|
.finally(() => scope.disconnect());
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IMessageRepository,
|
|
3
|
+
IThreadMessageRepository,
|
|
4
|
+
MessageItem,
|
|
5
|
+
ThreadMessageItem,
|
|
6
|
+
} from "@remit/data-ports";
|
|
7
|
+
import { MessageStatus } from "@remit/domain-enums";
|
|
8
|
+
import { isNotFoundError } from "../is-not-found.js";
|
|
9
|
+
import { buildThreadMessageMoveRevert } from "./thread-message-rows.js";
|
|
10
|
+
|
|
11
|
+
export interface RestoreSourcePlacementDeps {
|
|
12
|
+
messageService: Pick<IMessageRepository, "updateForMove">;
|
|
13
|
+
threadMessageService: Pick<
|
|
14
|
+
IThreadMessageRepository,
|
|
15
|
+
"findAllByMessageId" | "update"
|
|
16
|
+
>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RestoreSourcePlacementInput {
|
|
20
|
+
accountConfigId: string;
|
|
21
|
+
messageId: string;
|
|
22
|
+
sourceMailboxId: string;
|
|
23
|
+
uid: number;
|
|
24
|
+
/**
|
|
25
|
+
* What the row records about the mutation that will now never happen.
|
|
26
|
+
* `synced` where the row is a faithful projection of the source again — the
|
|
27
|
+
* server never received the command, or has just said the message is still
|
|
28
|
+
* there. `failed` where the product refused an operation the user asked for,
|
|
29
|
+
* which is a failure the row has to carry.
|
|
30
|
+
*/
|
|
31
|
+
syncStatus: MessageItem["syncStatus"];
|
|
32
|
+
/**
|
|
33
|
+
* The listing rows, where the caller has already read them to pick this
|
|
34
|
+
* outcome. Omitted, they are read here.
|
|
35
|
+
*/
|
|
36
|
+
threadMessages?: ThreadMessageItem[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Put a row back on the pre-mutation source pair and hand its listing rows back
|
|
41
|
+
* with it.
|
|
42
|
+
*
|
|
43
|
+
* `status: moving` has to be cleared here or the row is stuck: a give-up that
|
|
44
|
+
* writes `syncStatus: failed` and returns leaves the row naming the destination
|
|
45
|
+
* with the source's uid — the pair `bindsForeignUid` calls a lie and
|
|
46
|
+
* `MessagePlacementUnsettledError` refuses to act on. Nothing routine repairs it
|
|
47
|
+
* (sync does not touch `status`, and the cursor rebuild's `updateUid` runs only
|
|
48
|
+
* on a UIDVALIDITY change), so the message is undeletable and unmovable for good
|
|
49
|
+
* (issue #1005).
|
|
50
|
+
*
|
|
51
|
+
* `updateForMove`, not `updateUid`: both write the same settled pair, but
|
|
52
|
+
* `updateUid` also appends a `message.moved` outbox row so the search index can
|
|
53
|
+
* refresh the mailbox it stores per message. Nothing here moved. The optimistic
|
|
54
|
+
* `updateForMove` that pointed the row at the destination never enqueued one
|
|
55
|
+
* either, so the index still holds the source and a revert has nothing to
|
|
56
|
+
* correct — the re-index would be a forced pass over an unchanged document.
|
|
57
|
+
*
|
|
58
|
+
* The caller must have evidence, not an inference: this writes a placement, and
|
|
59
|
+
* a placement written on a guess binds live mail to a uid that names somebody
|
|
60
|
+
* else's message. Two kinds of caller qualify, and nothing else does.
|
|
61
|
+
*
|
|
62
|
+
* A terminal resolver reaches here from {@link isMessageGoneFromOpenMailbox}
|
|
63
|
+
* answering that the message is STILL at `sourceMailboxId`/`uid`, which makes
|
|
64
|
+
* this the server's own answer rather than a revert on ambiguity (what PR #652
|
|
65
|
+
* was pulled for).
|
|
66
|
+
*
|
|
67
|
+
* A paused-cursor caller reaches here from a `MailboxCursorPausedError` the
|
|
68
|
+
* openBox guard threw before the outbound command was issued (issue #1203), and
|
|
69
|
+
* restores the pre-mutation pair off its own event. On a FIRST delivery that is
|
|
70
|
+
* not an inference but an undo: the command provably never left, so the row is
|
|
71
|
+
* put back exactly as it was found. A redelivery cannot claim that — the
|
|
72
|
+
* earlier attempt's tagged OK can be lost with the connection — so it asks
|
|
73
|
+
* `probePausedPlacement` which folder holds the message and only reaches here
|
|
74
|
+
* once the answer rules the destination out. Either way the source mailbox is
|
|
75
|
+
* by definition awaiting a cursor rebuild, which matches its rows by Message-ID
|
|
76
|
+
* and adjudicates the uid: handing the row back is what puts it in the set the
|
|
77
|
+
* rebuild walks, since a row still naming the destination is in neither
|
|
78
|
+
* folder's.
|
|
79
|
+
*
|
|
80
|
+
* A row another path deleted while the probe was in flight has nothing left to
|
|
81
|
+
* restore, and a thread row whose composites have moved on is being rewritten
|
|
82
|
+
* by that other path anyway. Both surface as `NotFoundError` — ElectroDB wraps
|
|
83
|
+
* the conditional-check miss as one — and both are settled states here, not
|
|
84
|
+
* faults. Throwing on them would escape the caller's `.catch()` and cost the
|
|
85
|
+
* record its ack and its resync, which is the contract the terminal resolvers
|
|
86
|
+
* state: never re-thrown.
|
|
87
|
+
*/
|
|
88
|
+
export const restoreSourcePlacement = async (
|
|
89
|
+
deps: RestoreSourcePlacementDeps,
|
|
90
|
+
input: RestoreSourcePlacementInput,
|
|
91
|
+
): Promise<void> => {
|
|
92
|
+
const { accountConfigId, messageId, sourceMailboxId, uid, syncStatus } =
|
|
93
|
+
input;
|
|
94
|
+
|
|
95
|
+
const skipNotFound = (error: unknown): void => {
|
|
96
|
+
if (isNotFoundError(error)) return;
|
|
97
|
+
throw error;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const messageRestored = await deps.messageService
|
|
101
|
+
.updateForMove(messageId, {
|
|
102
|
+
mailboxId: sourceMailboxId,
|
|
103
|
+
uid,
|
|
104
|
+
status: MessageStatus.active,
|
|
105
|
+
syncStatus,
|
|
106
|
+
})
|
|
107
|
+
.then(() => true)
|
|
108
|
+
.catch((error: unknown) => {
|
|
109
|
+
skipNotFound(error);
|
|
110
|
+
return false;
|
|
111
|
+
});
|
|
112
|
+
if (!messageRestored) return;
|
|
113
|
+
|
|
114
|
+
const threadMessages =
|
|
115
|
+
input.threadMessages ??
|
|
116
|
+
(await deps.threadMessageService.findAllByMessageId(
|
|
117
|
+
accountConfigId,
|
|
118
|
+
messageId,
|
|
119
|
+
));
|
|
120
|
+
for (const threadMessage of threadMessages) {
|
|
121
|
+
const args = buildThreadMessageMoveRevert(
|
|
122
|
+
threadMessage,
|
|
123
|
+
uid,
|
|
124
|
+
sourceMailboxId,
|
|
125
|
+
);
|
|
126
|
+
await deps.threadMessageService
|
|
127
|
+
.update(
|
|
128
|
+
threadMessage.accountConfigId,
|
|
129
|
+
threadMessage.threadMessageId,
|
|
130
|
+
args.set,
|
|
131
|
+
{ composites: args.composites },
|
|
132
|
+
)
|
|
133
|
+
.catch(skipNotFound);
|
|
134
|
+
}
|
|
135
|
+
};
|