@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
|
@@ -2,9 +2,10 @@ import { getClient } from "@remit/backend/client";
|
|
|
2
2
|
import type {
|
|
3
3
|
IMessageRepository,
|
|
4
4
|
IThreadMessageRepository,
|
|
5
|
+
MessageItem,
|
|
5
6
|
} from "@remit/data-ports";
|
|
6
7
|
import { isCurrentSchemaVersion } from "@remit/data-ports/mutation-events";
|
|
7
|
-
import {
|
|
8
|
+
import { MessageSyncStatus } from "@remit/domain-enums";
|
|
8
9
|
import type { Logger } from "@remit/logger-lambda";
|
|
9
10
|
import { recordImapFailure } from "@remit/logger-lambda";
|
|
10
11
|
import {
|
|
@@ -24,13 +25,14 @@ import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
|
24
25
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
25
26
|
import { resolveExhaustedMessageDeleteFailure } from "./message-delete-terminal.js";
|
|
26
27
|
import {
|
|
28
|
+
emitMailboxResync,
|
|
27
29
|
emitMoveResync,
|
|
30
|
+
type PausedPlacement,
|
|
31
|
+
probePausedPlacement,
|
|
28
32
|
searchMailboxForHighestMessageIdUid,
|
|
29
33
|
} from "./message-move.js";
|
|
30
|
-
import {
|
|
31
|
-
|
|
32
|
-
buildThreadMessageTrashUpdate,
|
|
33
|
-
} from "./thread-message-rows.js";
|
|
34
|
+
import { restoreSourcePlacement } from "./restore-source-placement.js";
|
|
35
|
+
import { buildThreadMessageTrashUpdate } from "./thread-message-rows.js";
|
|
34
36
|
|
|
35
37
|
export const getMessageDeleteMaxAttempts = (
|
|
36
38
|
processEnv: NodeJS.ProcessEnv = process.env,
|
|
@@ -194,6 +196,98 @@ export const handleMessageDelete = async (
|
|
|
194
196
|
return;
|
|
195
197
|
}
|
|
196
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Re-read the folders this delete touched. A move to trash has two; a
|
|
201
|
+
* permanent delete has only the source, and it is owed one just as much —
|
|
202
|
+
* the local rows are gone while the server copy survives, so with no resync
|
|
203
|
+
* the mail is invisible until the next scheduled sync. SYNC_MESSAGES on a
|
|
204
|
+
* mailbox whose cursor is not normal routes to the rebuild, which is what
|
|
205
|
+
* re-projects the rows this handler removed (`message-sync.ts`).
|
|
206
|
+
*/
|
|
207
|
+
const emitSettleResync = async (): Promise<void> => {
|
|
208
|
+
if (destinationMailboxId) {
|
|
209
|
+
await emitMoveResync(emitEvent, {
|
|
210
|
+
accountId,
|
|
211
|
+
sourceMailboxId: mailboxId,
|
|
212
|
+
destinationMailboxId,
|
|
213
|
+
});
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
await emitMailboxResync(emitEvent, { accountId, mailboxId });
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Undo the optimistic local delete, for every reason this handler has to
|
|
221
|
+
* give one up, and say so exactly once. The verdict differs — a refused
|
|
222
|
+
* event is a failure the row carries, a paused cursor is a mutation the
|
|
223
|
+
* server never heard of — but the way back is one rule, and it is the
|
|
224
|
+
* listing rows that pick it.
|
|
225
|
+
*
|
|
226
|
+
* Rows still there is the ordinary move to trash: the row goes back on the
|
|
227
|
+
* source pair, which is also the set the cursor rebuild adjudicates.
|
|
228
|
+
*
|
|
229
|
+
* No rows left is a permanent delete, which drops them at enqueue; they are
|
|
230
|
+
* denormalized off an envelope only the sync path shapes and cannot be
|
|
231
|
+
* rebuilt from here, so restoring the Message alone would leave mail nothing
|
|
232
|
+
* can list — the silent vanish rather than a visible failure. The local
|
|
233
|
+
* removal finishes instead. Nothing was expunged, so the server copy
|
|
234
|
+
* survives and the mailbox's own sync re-projects it.
|
|
235
|
+
*
|
|
236
|
+
* `alert` is a refusal or a placement the server never settled, both of
|
|
237
|
+
* which an operator adjudicates. `metric` is the routine pause the guard doc
|
|
238
|
+
* calls never-a-fault: the user's delete was silently dropped, worth
|
|
239
|
+
* counting and not worth waking anyone for. Either way `localRowsRemoved`
|
|
240
|
+
* keeps the disappearance of local mail visible.
|
|
241
|
+
*/
|
|
242
|
+
const handBackDelete = async (
|
|
243
|
+
syncStatus: MessageItem["syncStatus"],
|
|
244
|
+
signal: { alert: string } | { metric: string },
|
|
245
|
+
reason: string,
|
|
246
|
+
): Promise<void> => {
|
|
247
|
+
const threadMessages = await threadMessageService.findAllByMessageId(
|
|
248
|
+
account.accountConfigId,
|
|
249
|
+
messageId,
|
|
250
|
+
);
|
|
251
|
+
const localRowsRemoved = threadMessages.length === 0;
|
|
252
|
+
|
|
253
|
+
const context = {
|
|
254
|
+
...signal,
|
|
255
|
+
accountId,
|
|
256
|
+
messageId,
|
|
257
|
+
uid,
|
|
258
|
+
mailboxPath,
|
|
259
|
+
operation,
|
|
260
|
+
receiveCount,
|
|
261
|
+
reason,
|
|
262
|
+
localRowsRemoved,
|
|
263
|
+
};
|
|
264
|
+
const summary = localRowsRemoved
|
|
265
|
+
? "Delete given up with no listing rows left to restore; the local row was removed and the server copy was not expunged"
|
|
266
|
+
: "Delete given up; the row was handed back to its source pair";
|
|
267
|
+
if ("alert" in signal) {
|
|
268
|
+
log.error(context, summary);
|
|
269
|
+
} else {
|
|
270
|
+
log.warn(context, summary);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (localRowsRemoved) {
|
|
274
|
+
await messageService.delete(messageId);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
await restoreSourcePlacement(
|
|
279
|
+
{ messageService, threadMessageService },
|
|
280
|
+
{
|
|
281
|
+
accountConfigId: account.accountConfigId,
|
|
282
|
+
messageId,
|
|
283
|
+
sourceMailboxId: mailboxId,
|
|
284
|
+
uid,
|
|
285
|
+
syncStatus,
|
|
286
|
+
threadMessages,
|
|
287
|
+
},
|
|
288
|
+
);
|
|
289
|
+
};
|
|
290
|
+
|
|
197
291
|
// Only an operation that explicitly says so destroys mail. The event is
|
|
198
292
|
// `JSON.parse`d and cast in the queue handler with no validation, so a
|
|
199
293
|
// missing, misspelled or future field must abandon the delete — the
|
|
@@ -201,51 +295,27 @@ export const handleMessageDelete = async (
|
|
|
201
295
|
// one that destroyed mail in the service, and an unrecoverable EXPUNGE is
|
|
202
296
|
// not a default. Abandoning hands the row back where the server still has
|
|
203
297
|
// it: an invisible `failed` on a row the user cannot see is the shape of
|
|
204
|
-
// the incident this whole change is about.
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
alert
|
|
298
|
+
// the incident this whole change is about. The row keeps `failed`, because
|
|
299
|
+
// the product refused something the user asked for.
|
|
300
|
+
const abandonDelete = (reason: string, alert: string): Promise<void> =>
|
|
301
|
+
handBackDelete(MessageSyncStatus.failed, { alert }, reason);
|
|
302
|
+
|
|
303
|
+
const settleTrashMoveConfirmed = async (
|
|
304
|
+
newUid: number,
|
|
305
|
+
trashMailboxId: string,
|
|
208
306
|
): Promise<void> => {
|
|
209
|
-
|
|
210
|
-
{ alert, accountId, messageId, uid, mailboxPath, operation },
|
|
211
|
-
reason,
|
|
212
|
-
);
|
|
307
|
+
await messageService.updateUid(messageId, newUid, trashMailboxId);
|
|
213
308
|
|
|
214
|
-
const
|
|
309
|
+
const threadMessage = await threadMessageService.findByMessageId(
|
|
215
310
|
account.accountConfigId,
|
|
216
311
|
messageId,
|
|
217
312
|
);
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
// visible failure, so the local removal finishes instead. The server copy
|
|
224
|
-
// survives (nothing was expunged) and a full sync of the mailbox brings
|
|
225
|
-
// it back. Reachable only through the rollout window, where a v1 event
|
|
226
|
-
// carries no `schemaVersion`.
|
|
227
|
-
if (threadMessages.length === 0) {
|
|
228
|
-
log.error(
|
|
229
|
-
{
|
|
230
|
-
alert: "message_delete_abandoned_after_local_cleanup",
|
|
231
|
-
accountId,
|
|
232
|
-
messageId,
|
|
233
|
-
uid,
|
|
234
|
-
mailboxPath,
|
|
235
|
-
},
|
|
236
|
-
"Abandoned delete had no listing rows left to restore; the server copy was not expunged",
|
|
313
|
+
if (threadMessage) {
|
|
314
|
+
const args = buildThreadMessageTrashUpdate(
|
|
315
|
+
threadMessage,
|
|
316
|
+
newUid,
|
|
317
|
+
trashMailboxId,
|
|
237
318
|
);
|
|
238
|
-
await messageService.delete(messageId);
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
await messageService.updateUid(messageId, uid, mailboxId);
|
|
243
|
-
await messageService.update(messageId, {
|
|
244
|
-
status: MessageStatus.active,
|
|
245
|
-
syncStatus: MessageSyncStatus.failed,
|
|
246
|
-
});
|
|
247
|
-
for (const threadMessage of threadMessages) {
|
|
248
|
-
const args = buildThreadMessageMoveRevert(threadMessage, uid, mailboxId);
|
|
249
319
|
await threadMessageService.update(
|
|
250
320
|
threadMessage.accountConfigId,
|
|
251
321
|
threadMessage.threadMessageId,
|
|
@@ -253,11 +323,93 @@ export const handleMessageDelete = async (
|
|
|
253
323
|
{ composites: args.composites },
|
|
254
324
|
);
|
|
255
325
|
}
|
|
326
|
+
|
|
327
|
+
log.info({ messageId, newUid }, "Message moved to trash");
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Settle the optimistic local delete when a paused cursor stops this round
|
|
332
|
+
* from issuing it. Acking on the optimistic row strands it: nothing
|
|
333
|
+
* re-enqueues a MESSAGE_DELETE, and the cursor rebuild matches rows by
|
|
334
|
+
* `(accountConfigId, mailboxId)`, so it never even sees a move-to-trash row
|
|
335
|
+
* that already names Trash (issue #1203).
|
|
336
|
+
*
|
|
337
|
+
* The delete therefore reconciles rather than waits (R2,
|
|
338
|
+
* docs/architecture/imap-mutations.md): the row is settled onto whichever
|
|
339
|
+
* pair the server can be shown to hold, and the resync plus the source's own
|
|
340
|
+
* cursor rebuild are its repair path.
|
|
341
|
+
*
|
|
342
|
+
* Undoing the local write claims nothing about the server only where the
|
|
343
|
+
* command provably never left. `commandIssued` says what this round did;
|
|
344
|
+
* `receiveCount` adds what an earlier one may have done and lost with its
|
|
345
|
+
* connection. `receiveCount === 1` alone is not that proof: an unprobeable
|
|
346
|
+
* confirmation reaches {@link settleExhaustedDelete} on the first attempt,
|
|
347
|
+
* with the MOVE already issued and the source already saying it no longer
|
|
348
|
+
* holds the message. Where either says the server may have been asked, it is
|
|
349
|
+
* asked again — {@link probePausedPlacement}, on the unguarded handle. A
|
|
350
|
+
* permanent delete has nothing to ask: its answer is the same either way,
|
|
351
|
+
* since the row is unlistable and goes.
|
|
352
|
+
*/
|
|
353
|
+
const settlePausedDelete = async (
|
|
354
|
+
getRawConnection: () => Promise<IImapConnection>,
|
|
355
|
+
commandIssued: boolean,
|
|
356
|
+
): Promise<void> => {
|
|
357
|
+
const mayHaveIssued = commandIssued || receiveCount > 1;
|
|
358
|
+
const isProbeableTrashMove =
|
|
359
|
+
mayHaveIssued &&
|
|
360
|
+
operation === "move_to_trash" &&
|
|
361
|
+
destinationMailboxId !== undefined &&
|
|
362
|
+
destinationMailboxPath !== undefined;
|
|
363
|
+
|
|
364
|
+
const placement: PausedPlacement = isProbeableTrashMove
|
|
365
|
+
? await probePausedPlacement(await getRawConnection(), {
|
|
366
|
+
messageIdHeader: (await messageService.get([messageId]))[0]
|
|
367
|
+
?.messageIdHeader,
|
|
368
|
+
sourceMailboxPath: mailboxPath,
|
|
369
|
+
destinationMailboxPath,
|
|
370
|
+
})
|
|
371
|
+
: { kind: "at-source" };
|
|
372
|
+
|
|
373
|
+
// A sighting in Trash is not proof this delete ran. The pause means the
|
|
374
|
+
// source's UIDVALIDITY has moved, so its "no" can be a recreated folder
|
|
375
|
+
// answering about mail it never held, and the hit can be an older copy of
|
|
376
|
+
// the same Message-ID — a sieve `fileinto` + `keep`, a resend — that
|
|
377
|
+
// `deriveMessageId` folds into this row; settling `moved` on it hands
|
|
378
|
+
// Empty Trash a stranger's uid. `unprobeable` is silence, and silence
|
|
379
|
+
// settles nothing either (the rule `message-copy.ts` already applies).
|
|
380
|
+
// Both hand the row back on the source pair carrying `failed`: that is the
|
|
381
|
+
// set the source's own cursor rebuild walks and adjudicates by Message-ID,
|
|
382
|
+
// which is the reconcile path (R2) Trash — not paused, no rebuild coming —
|
|
383
|
+
// does not have.
|
|
384
|
+
const unproven =
|
|
385
|
+
placement.kind === "at-destination" || placement.kind === "unprobeable";
|
|
386
|
+
|
|
387
|
+
if (unproven) {
|
|
388
|
+
await handBackDelete(
|
|
389
|
+
MessageSyncStatus.failed,
|
|
390
|
+
{ alert: "message_delete_paused_placement_unproven" },
|
|
391
|
+
placement.kind === "at-destination"
|
|
392
|
+
? "the destination holds this Message-ID but the source's uid axis has moved, so the sighting does not prove this delete ran"
|
|
393
|
+
: "no Message-ID header to ask either folder with",
|
|
394
|
+
);
|
|
395
|
+
} else {
|
|
396
|
+
// `gone` lands here with `at-source`: the source pair is the set the
|
|
397
|
+
// rebuild walks, and a row it cannot match against a fresh envelope
|
|
398
|
+
// snapshot is the one thing it reconciles away.
|
|
399
|
+
await handBackDelete(
|
|
400
|
+
MessageSyncStatus.synced,
|
|
401
|
+
{ metric: "message_delete_dropped_on_pause" },
|
|
402
|
+
`source mailbox cursor paused, placement ${placement.kind}`,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
await emitSettleResync();
|
|
256
407
|
};
|
|
257
408
|
|
|
258
409
|
const settleExhaustedDelete = async (
|
|
259
410
|
accountConfigId: string,
|
|
260
411
|
getConnection: () => Promise<IImapConnection>,
|
|
412
|
+
settlePaused: () => Promise<void>,
|
|
261
413
|
): Promise<void> => {
|
|
262
414
|
const settled = await resolveExhaustedMessageDeleteFailure(
|
|
263
415
|
{ messageService, threadMessageService, log },
|
|
@@ -270,17 +422,19 @@ export const handleMessageDelete = async (
|
|
|
270
422
|
sourceMailboxPath: mailboxPath,
|
|
271
423
|
getConnection,
|
|
272
424
|
},
|
|
273
|
-
).catch((settleError: unknown) => {
|
|
274
|
-
// The guarded probe found a mailbox whose UIDVALIDITY has moved.
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
425
|
+
).catch(async (settleError: unknown) => {
|
|
426
|
+
// The guarded probe found a mailbox whose UIDVALIDITY has moved. Nothing
|
|
427
|
+
// may be settled off a uid on a dead axis, and the pause is the routine
|
|
428
|
+
// skip `guardMailboxCursor` documents, not a fault to re-throw out of
|
|
429
|
+
// the caller's catch. The delete is still settled, on the identity axis
|
|
430
|
+
// instead: the paused settle asks by Message-ID, which a UIDVALIDITY
|
|
431
|
+
// change does not invalidate.
|
|
279
432
|
if (settleError instanceof MailboxCursorPausedError) {
|
|
280
433
|
log.info(
|
|
281
434
|
{ accountId, messageId, mailboxId, cursorState: settleError.state },
|
|
282
|
-
"Mailbox cursor not normal;
|
|
435
|
+
"Mailbox cursor not normal; settling the exhausted delete against the server by Message-ID",
|
|
283
436
|
);
|
|
437
|
+
await settlePaused();
|
|
284
438
|
return null;
|
|
285
439
|
}
|
|
286
440
|
throw settleError;
|
|
@@ -298,13 +452,7 @@ export const handleMessageDelete = async (
|
|
|
298
452
|
// rows are gone and the resync rebuilds them. BROKEN, the row has just
|
|
299
453
|
// been put back at the source the server confirmed, and the resync is
|
|
300
454
|
// what carries any drift either folder has picked up since.
|
|
301
|
-
|
|
302
|
-
await emitMoveResync(emitEvent, {
|
|
303
|
-
accountId,
|
|
304
|
-
sourceMailboxId: mailboxId,
|
|
305
|
-
destinationMailboxId,
|
|
306
|
-
});
|
|
307
|
-
}
|
|
455
|
+
await emitSettleResync();
|
|
308
456
|
};
|
|
309
457
|
|
|
310
458
|
/**
|
|
@@ -317,6 +465,7 @@ export const handleMessageDelete = async (
|
|
|
317
465
|
confirmation: Exclude<TrashMoveConfirmation, { outcome: "confirmed" }>,
|
|
318
466
|
accountConfigId: string,
|
|
319
467
|
getConnection: () => Promise<IImapConnection>,
|
|
468
|
+
settlePaused: () => Promise<void>,
|
|
320
469
|
): Promise<void> => {
|
|
321
470
|
const context = {
|
|
322
471
|
accountId,
|
|
@@ -342,7 +491,7 @@ export const handleMessageDelete = async (
|
|
|
342
491
|
context,
|
|
343
492
|
"Move to trash carries no Message-ID header to probe the destination with; settling on the source's answer alone",
|
|
344
493
|
);
|
|
345
|
-
await settleExhaustedDelete(accountConfigId, getConnection);
|
|
494
|
+
await settleExhaustedDelete(accountConfigId, getConnection, settlePaused);
|
|
346
495
|
return;
|
|
347
496
|
}
|
|
348
497
|
|
|
@@ -383,17 +532,6 @@ export const handleMessageDelete = async (
|
|
|
383
532
|
return;
|
|
384
533
|
}
|
|
385
534
|
|
|
386
|
-
// Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
|
|
387
|
-
// paused never even opens a connection. Optimization only — the
|
|
388
|
-
// guardConnectionCursor openBox wrap below is the structural guarantee.
|
|
389
|
-
if (isCursorRebuildNeeded(mailbox.cursorState)) {
|
|
390
|
-
log.info(
|
|
391
|
-
{ accountId, messageId, mailboxId },
|
|
392
|
-
"Mailbox cursor not normal; pausing outbound delete this round",
|
|
393
|
-
);
|
|
394
|
-
return;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
535
|
const scope = createConnectionScopeWithCredentials(account, credentials);
|
|
398
536
|
|
|
399
537
|
// The terminal resolver opens the source itself, and its answer now
|
|
@@ -411,6 +549,26 @@ export const handleMessageDelete = async (
|
|
|
411
549
|
mailbox,
|
|
412
550
|
);
|
|
413
551
|
|
|
552
|
+
const settlePaused = (commandIssued: boolean): Promise<void> =>
|
|
553
|
+
settlePausedDelete(scope.getConnection, commandIssued);
|
|
554
|
+
|
|
555
|
+
// Flipped the instant the MOVE or the EXPUNGE leaves, so every settle
|
|
556
|
+
// downstream of it knows the server was asked.
|
|
557
|
+
let commandIssued = false;
|
|
558
|
+
|
|
559
|
+
// Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
|
|
560
|
+
// paused never opens a connection on a first delivery. Optimization
|
|
561
|
+
// only — the guardConnectionCursor openBox wrap below is the structural
|
|
562
|
+
// guarantee.
|
|
563
|
+
if (isCursorRebuildNeeded(mailbox.cursorState)) {
|
|
564
|
+
log.info(
|
|
565
|
+
{ accountId, messageId, mailboxId },
|
|
566
|
+
"Mailbox cursor not normal; pausing outbound delete this round and settling the row against the server",
|
|
567
|
+
);
|
|
568
|
+
await settlePaused(false).finally(() => scope.disconnect());
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
|
|
414
572
|
await scope
|
|
415
573
|
.getConnection()
|
|
416
574
|
.then(async (rawConnection) => {
|
|
@@ -450,6 +608,7 @@ export const handleMessageDelete = async (
|
|
|
450
608
|
destinationMailboxId
|
|
451
609
|
) {
|
|
452
610
|
// Move to Trash
|
|
611
|
+
commandIssued = true;
|
|
453
612
|
const result = await connection.moveMessages(
|
|
454
613
|
[uid],
|
|
455
614
|
destinationMailboxPath,
|
|
@@ -487,34 +646,10 @@ export const handleMessageDelete = async (
|
|
|
487
646
|
});
|
|
488
647
|
|
|
489
648
|
if (confirmation.outcome === "confirmed") {
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
await messageService.updateUid(
|
|
493
|
-
messageId,
|
|
494
|
-
newUid,
|
|
649
|
+
await settleTrashMoveConfirmed(
|
|
650
|
+
confirmation.uid,
|
|
495
651
|
destinationMailboxId,
|
|
496
652
|
);
|
|
497
|
-
|
|
498
|
-
// Update ThreadMessage with new UID and isDeleted = true
|
|
499
|
-
const threadMessage = await threadMessageService.findByMessageId(
|
|
500
|
-
account.accountConfigId,
|
|
501
|
-
messageId,
|
|
502
|
-
);
|
|
503
|
-
if (threadMessage) {
|
|
504
|
-
const args = buildThreadMessageTrashUpdate(
|
|
505
|
-
threadMessage,
|
|
506
|
-
newUid,
|
|
507
|
-
destinationMailboxId,
|
|
508
|
-
);
|
|
509
|
-
await threadMessageService.update(
|
|
510
|
-
threadMessage.accountConfigId,
|
|
511
|
-
threadMessage.threadMessageId,
|
|
512
|
-
args.set,
|
|
513
|
-
{ composites: args.composites },
|
|
514
|
-
);
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
log.info({ messageId, newUid }, "Message moved to trash");
|
|
518
653
|
return;
|
|
519
654
|
}
|
|
520
655
|
|
|
@@ -522,9 +657,11 @@ export const handleMessageDelete = async (
|
|
|
522
657
|
confirmation,
|
|
523
658
|
account.accountConfigId,
|
|
524
659
|
getGuardedConnection,
|
|
660
|
+
() => settlePaused(true),
|
|
525
661
|
);
|
|
526
662
|
} else {
|
|
527
663
|
// Permanent delete — reached only by `operation === "permanent_delete"`.
|
|
664
|
+
commandIssued = true;
|
|
528
665
|
await connection.deleteMessages([uid]);
|
|
529
666
|
|
|
530
667
|
// Delete ThreadMessage rows BEFORE the Message row to collapse the
|
|
@@ -551,8 +688,9 @@ export const handleMessageDelete = async (
|
|
|
551
688
|
if (error instanceof MailboxCursorPausedError) {
|
|
552
689
|
log.info(
|
|
553
690
|
{ accountId, messageId, mailboxId, cursorState: error.state },
|
|
554
|
-
"Mailbox cursor not normal; pausing outbound delete this round",
|
|
691
|
+
"Mailbox cursor not normal; pausing outbound delete this round and settling the row against the server",
|
|
555
692
|
);
|
|
693
|
+
await settlePaused(commandIssued);
|
|
556
694
|
return;
|
|
557
695
|
}
|
|
558
696
|
|
|
@@ -626,6 +764,7 @@ export const handleMessageDelete = async (
|
|
|
626
764
|
await settleExhaustedDelete(
|
|
627
765
|
account.accountConfigId,
|
|
628
766
|
getGuardedConnection,
|
|
767
|
+
() => settlePaused(commandIssued),
|
|
629
768
|
);
|
|
630
769
|
log.error(
|
|
631
770
|
{ accountId, messageId, uid, mailboxPath, error: errorMessage },
|
|
@@ -75,8 +75,8 @@ interface ThreadRow {
|
|
|
75
75
|
* The move's pending state IS the Message row, so these fakes hold real rows
|
|
76
76
|
* and the assertions read the rows back — a resolver that settled the row on
|
|
77
77
|
* anything but the server's own answer shows up here as a changed row, not as
|
|
78
|
-
* an uncalled mock. `
|
|
79
|
-
*
|
|
78
|
+
* an uncalled mock. `updateForMove` writes what the repository writes: whatever
|
|
79
|
+
* fields the caller set, and nothing it left out.
|
|
80
80
|
*/
|
|
81
81
|
const buildRepositories = (row: MessageRow) => {
|
|
82
82
|
const messages = new Map<string, MessageRow>([[row.messageId, row]]);
|
|
@@ -104,22 +104,11 @@ const buildRepositories = (row: MessageRow) => {
|
|
|
104
104
|
const current = messages.get(messageId);
|
|
105
105
|
if (current) messages.set(messageId, { ...current, ...input });
|
|
106
106
|
},
|
|
107
|
-
|
|
108
|
-
messageId: string,
|
|
109
|
-
newUid: number,
|
|
110
|
-
newMailboxId: string,
|
|
111
|
-
) => {
|
|
107
|
+
updateForMove: async (messageId: string, set: Partial<MessageRow>) => {
|
|
112
108
|
const current = messages.get(messageId);
|
|
113
|
-
if (current)
|
|
114
|
-
messages.set(messageId, {
|
|
115
|
-
...current,
|
|
116
|
-
uid: newUid,
|
|
117
|
-
mailboxId: newMailboxId,
|
|
118
|
-
status: "active",
|
|
119
|
-
syncStatus: "synced",
|
|
120
|
-
});
|
|
109
|
+
if (current) messages.set(messageId, { ...current, ...set });
|
|
121
110
|
},
|
|
122
|
-
} as unknown as Pick<IMessageRepository, "delete" | "
|
|
111
|
+
} as unknown as Pick<IMessageRepository, "delete" | "updateForMove">,
|
|
123
112
|
threadMessageService: {
|
|
124
113
|
findAllByMessageId: async () => [...threadMessages.values()],
|
|
125
114
|
deleteMany: async (
|
|
@@ -215,10 +204,9 @@ describe("resolveExhaustedMessageMoveFailure — the two terminal outcomes (issu
|
|
|
215
204
|
assert.ok(errors.some((e) => e.obj.alert === "message_move_failed"));
|
|
216
205
|
});
|
|
217
206
|
|
|
218
|
-
// Issue #1005:
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
// undeletable and unmovable for good.
|
|
207
|
+
// Issue #1005: a give-up that never writes `status` leaves the row naming
|
|
208
|
+
// the destination with the source's uid — a pair `bindsForeignUid` refuses,
|
|
209
|
+
// which made the message undeletable and unmovable for good.
|
|
222
210
|
it("BROKEN: the settled row is no longer refused by the placement guard (#1005)", async () => {
|
|
223
211
|
const repos = buildRepositories(pendingMoveRow());
|
|
224
212
|
const { log } = buildLogger();
|
|
@@ -281,7 +269,7 @@ describe("resolveExhaustedMessageMoveFailure — the two terminal outcomes (issu
|
|
|
281
269
|
const deps: ResolveExhaustedMessageMoveDeps = {
|
|
282
270
|
messageService: {
|
|
283
271
|
...repos.messageService,
|
|
284
|
-
|
|
272
|
+
updateForMove: async () => {
|
|
285
273
|
throw notFound;
|
|
286
274
|
},
|
|
287
275
|
} as unknown as ResolveExhaustedMessageMoveDeps["messageService"],
|
|
@@ -332,7 +320,7 @@ describe("resolveExhaustedMessageMoveFailure — the two terminal outcomes (issu
|
|
|
332
320
|
const deps: ResolveExhaustedMessageMoveDeps = {
|
|
333
321
|
messageService: {
|
|
334
322
|
...repos.messageService,
|
|
335
|
-
|
|
323
|
+
updateForMove: async () => {
|
|
336
324
|
throw new Error("ProvisionedThroughputExceeded");
|
|
337
325
|
},
|
|
338
326
|
} as unknown as ResolveExhaustedMessageMoveDeps["messageService"],
|
|
@@ -2,6 +2,7 @@ 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,
|
|
@@ -17,7 +18,7 @@ export interface MessageMoveTerminalLogger {
|
|
|
17
18
|
|
|
18
19
|
export interface ResolveExhaustedMessageMoveDeps
|
|
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"
|
|
@@ -147,6 +148,10 @@ export const resolveExhaustedMessageMoveFailure = async (
|
|
|
147
148
|
messageId,
|
|
148
149
|
sourceMailboxId,
|
|
149
150
|
uid,
|
|
151
|
+
// The probe above just confirmed the pair, so the row is a faithful
|
|
152
|
+
// projection of the source again. The move's own failure is the alert, not
|
|
153
|
+
// a `failed` left on a row nothing is still trying to move.
|
|
154
|
+
syncStatus: MessageSyncStatus.synced,
|
|
150
155
|
});
|
|
151
156
|
|
|
152
157
|
return { outcome: "broken" };
|