@remit/imap-worker 0.0.68 → 0.0.70
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/empty-trash.test.ts +138 -0
- package/src/handlers/empty-trash.ts +31 -9
- 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
|
@@ -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,16 +355,84 @@ export const handleMessageMove = async (
|
|
|
244
355
|
return;
|
|
245
356
|
}
|
|
246
357
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
+
|
|
251
379
|
log.info(
|
|
252
|
-
{
|
|
253
|
-
|
|
380
|
+
{
|
|
381
|
+
messageId,
|
|
382
|
+
oldUid: uid,
|
|
383
|
+
newUid,
|
|
384
|
+
destination: destinationMailboxPath,
|
|
385
|
+
},
|
|
386
|
+
"Message moved successfully",
|
|
254
387
|
);
|
|
255
|
-
|
|
256
|
-
|
|
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
|
+
};
|
|
257
436
|
|
|
258
437
|
const scope = createConnectionScopeWithCredentials(account, credentials);
|
|
259
438
|
|
|
@@ -272,6 +451,95 @@ export const handleMessageMove = async (
|
|
|
272
451
|
mailbox,
|
|
273
452
|
);
|
|
274
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
|
+
|
|
526
|
+
// Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
|
|
527
|
+
// paused never opens a connection on a first delivery. Optimization
|
|
528
|
+
// only — the guardConnectionCursor openBox wrap below is the structural
|
|
529
|
+
// guarantee.
|
|
530
|
+
if (isCursorRebuildNeeded(mailbox.cursorState)) {
|
|
531
|
+
log.info(
|
|
532
|
+
{ accountId, messageId, mailboxId: sourceMailboxId },
|
|
533
|
+
"Mailbox cursor not normal; pausing outbound move this round and settling the row against the server",
|
|
534
|
+
);
|
|
535
|
+
await settlePausedMove(false).finally(() => scope.disconnect());
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Flipped the instant the MOVE leaves, so every settle downstream of it
|
|
540
|
+
// knows the server was asked.
|
|
541
|
+
let commandIssued = false;
|
|
542
|
+
|
|
275
543
|
await scope
|
|
276
544
|
.getConnection()
|
|
277
545
|
.then((rawConnection) => {
|
|
@@ -290,6 +558,7 @@ export const handleMessageMove = async (
|
|
|
290
558
|
await connection.openBox(sourceMailboxPath, false);
|
|
291
559
|
|
|
292
560
|
// Execute IMAP MOVE
|
|
561
|
+
commandIssued = true;
|
|
293
562
|
const result = await connection.moveMessages(
|
|
294
563
|
[uid],
|
|
295
564
|
destinationMailboxPath,
|
|
@@ -319,41 +588,7 @@ export const handleMessageMove = async (
|
|
|
319
588
|
);
|
|
320
589
|
}
|
|
321
590
|
|
|
322
|
-
|
|
323
|
-
await messageService.updateUid(
|
|
324
|
-
messageId,
|
|
325
|
-
newUid,
|
|
326
|
-
destinationMailboxId,
|
|
327
|
-
);
|
|
328
|
-
|
|
329
|
-
// Update ThreadMessage UID and mailboxId
|
|
330
|
-
const threadMessage = await threadMessageService.findByMessageId(
|
|
331
|
-
account.accountConfigId,
|
|
332
|
-
messageId,
|
|
333
|
-
);
|
|
334
|
-
if (threadMessage) {
|
|
335
|
-
const args = buildThreadMessageMoveUpdate(
|
|
336
|
-
threadMessage,
|
|
337
|
-
newUid,
|
|
338
|
-
destinationMailboxId,
|
|
339
|
-
);
|
|
340
|
-
await threadMessageService.update(
|
|
341
|
-
threadMessage.accountConfigId,
|
|
342
|
-
threadMessage.threadMessageId,
|
|
343
|
-
args.set,
|
|
344
|
-
{ composites: args.composites },
|
|
345
|
-
);
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
log.info(
|
|
349
|
-
{
|
|
350
|
-
messageId,
|
|
351
|
-
oldUid: uid,
|
|
352
|
-
newUid,
|
|
353
|
-
destination: destinationMailboxPath,
|
|
354
|
-
},
|
|
355
|
-
"Message moved successfully",
|
|
356
|
-
);
|
|
591
|
+
await settleMoved(newUid);
|
|
357
592
|
},
|
|
358
593
|
() =>
|
|
359
594
|
emitMoveResync(emitEvent, {
|
|
@@ -372,8 +607,9 @@ export const handleMessageMove = async (
|
|
|
372
607
|
mailboxId: sourceMailboxId,
|
|
373
608
|
cursorState: error.state,
|
|
374
609
|
},
|
|
375
|
-
"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",
|
|
376
611
|
);
|
|
612
|
+
await settlePausedMove(commandIssued);
|
|
377
613
|
return;
|
|
378
614
|
}
|
|
379
615
|
|
|
@@ -421,12 +657,13 @@ export const handleMessageMove = async (
|
|
|
421
657
|
sourceMailboxPath,
|
|
422
658
|
getConnection: getGuardedConnection,
|
|
423
659
|
},
|
|
424
|
-
).catch((settleError: unknown) => {
|
|
660
|
+
).catch(async (settleError: unknown) => {
|
|
425
661
|
// The guarded probe found a mailbox whose UIDVALIDITY has moved.
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
//
|
|
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.
|
|
430
667
|
if (settleError instanceof MailboxCursorPausedError) {
|
|
431
668
|
log.info(
|
|
432
669
|
{
|
|
@@ -435,8 +672,9 @@ export const handleMessageMove = async (
|
|
|
435
672
|
mailboxId: sourceMailboxId,
|
|
436
673
|
cursorState: settleError.state,
|
|
437
674
|
},
|
|
438
|
-
"Mailbox cursor not normal;
|
|
675
|
+
"Mailbox cursor not normal; settling the exhausted move against the server by Message-ID",
|
|
439
676
|
);
|
|
677
|
+
await settlePausedMove(commandIssued);
|
|
440
678
|
return null;
|
|
441
679
|
}
|
|
442
680
|
throw settleError;
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
IMessageRepository,
|
|
3
3
|
IThreadMessageRepository,
|
|
4
|
+
MessageItem,
|
|
5
|
+
ThreadMessageItem,
|
|
4
6
|
} from "@remit/data-ports";
|
|
7
|
+
import { MessageStatus } from "@remit/domain-enums";
|
|
5
8
|
import { isNotFoundError } from "../is-not-found.js";
|
|
6
9
|
import { buildThreadMessageMoveRevert } from "./thread-message-rows.js";
|
|
7
10
|
|
|
8
11
|
export interface RestoreSourcePlacementDeps {
|
|
9
|
-
messageService: Pick<IMessageRepository, "
|
|
12
|
+
messageService: Pick<IMessageRepository, "updateForMove">;
|
|
10
13
|
threadMessageService: Pick<
|
|
11
14
|
IThreadMessageRepository,
|
|
12
15
|
"findAllByMessageId" | "update"
|
|
@@ -18,26 +21,61 @@ export interface RestoreSourcePlacementInput {
|
|
|
18
21
|
messageId: string;
|
|
19
22
|
sourceMailboxId: string;
|
|
20
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[];
|
|
21
37
|
}
|
|
22
38
|
|
|
23
39
|
/**
|
|
24
|
-
* Put a row back on the source pair
|
|
25
|
-
*
|
|
40
|
+
* Put a row back on the pre-mutation source pair and hand its listing rows back
|
|
41
|
+
* with it.
|
|
26
42
|
*
|
|
27
|
-
*
|
|
28
|
-
* `syncStatus: failed` and returns leaves the row naming the destination
|
|
29
|
-
* the source's uid — the pair `bindsForeignUid` calls a lie and
|
|
30
|
-
* `MessagePlacementUnsettledError` refuses to act on. Nothing routine repairs
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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.
|
|
34
57
|
*
|
|
35
58
|
* The caller must have evidence, not an inference: this writes a placement, and
|
|
36
59
|
* a placement written on a guess binds live mail to a uid that names somebody
|
|
37
|
-
* else's message.
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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.
|
|
41
79
|
*
|
|
42
80
|
* A row another path deleted while the probe was in flight has nothing left to
|
|
43
81
|
* restore, and a thread row whose composites have moved on is being rewritten
|
|
@@ -51,7 +89,8 @@ export const restoreSourcePlacement = async (
|
|
|
51
89
|
deps: RestoreSourcePlacementDeps,
|
|
52
90
|
input: RestoreSourcePlacementInput,
|
|
53
91
|
): Promise<void> => {
|
|
54
|
-
const { accountConfigId, messageId, sourceMailboxId, uid } =
|
|
92
|
+
const { accountConfigId, messageId, sourceMailboxId, uid, syncStatus } =
|
|
93
|
+
input;
|
|
55
94
|
|
|
56
95
|
const skipNotFound = (error: unknown): void => {
|
|
57
96
|
if (isNotFoundError(error)) return;
|
|
@@ -59,7 +98,12 @@ export const restoreSourcePlacement = async (
|
|
|
59
98
|
};
|
|
60
99
|
|
|
61
100
|
const messageRestored = await deps.messageService
|
|
62
|
-
.
|
|
101
|
+
.updateForMove(messageId, {
|
|
102
|
+
mailboxId: sourceMailboxId,
|
|
103
|
+
uid,
|
|
104
|
+
status: MessageStatus.active,
|
|
105
|
+
syncStatus,
|
|
106
|
+
})
|
|
63
107
|
.then(() => true)
|
|
64
108
|
.catch((error: unknown) => {
|
|
65
109
|
skipNotFound(error);
|
|
@@ -67,10 +111,12 @@ export const restoreSourcePlacement = async (
|
|
|
67
111
|
});
|
|
68
112
|
if (!messageRestored) return;
|
|
69
113
|
|
|
70
|
-
const threadMessages =
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
114
|
+
const threadMessages =
|
|
115
|
+
input.threadMessages ??
|
|
116
|
+
(await deps.threadMessageService.findAllByMessageId(
|
|
117
|
+
accountConfigId,
|
|
118
|
+
messageId,
|
|
119
|
+
));
|
|
74
120
|
for (const threadMessage of threadMessages) {
|
|
75
121
|
const args = buildThreadMessageMoveRevert(
|
|
76
122
|
threadMessage,
|