@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
|
@@ -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,13 +323,95 @@ 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
|
-
const
|
|
414
|
+
const settled = await resolveExhaustedMessageDeleteFailure(
|
|
263
415
|
{ messageService, threadMessageService, log },
|
|
264
416
|
{
|
|
265
417
|
accountId,
|
|
@@ -270,9 +422,27 @@ export const handleMessageDelete = async (
|
|
|
270
422
|
sourceMailboxPath: mailboxPath,
|
|
271
423
|
getConnection,
|
|
272
424
|
},
|
|
273
|
-
)
|
|
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.
|
|
432
|
+
if (settleError instanceof MailboxCursorPausedError) {
|
|
433
|
+
log.info(
|
|
434
|
+
{ accountId, messageId, mailboxId, cursorState: settleError.state },
|
|
435
|
+
"Mailbox cursor not normal; settling the exhausted delete against the server by Message-ID",
|
|
436
|
+
);
|
|
437
|
+
await settlePaused();
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
throw settleError;
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
if (!settled) return;
|
|
274
444
|
|
|
275
|
-
if (outcome === "broken") {
|
|
445
|
+
if (settled.outcome === "broken") {
|
|
276
446
|
recordImapFailure("MESSAGE_DELETE_EXHAUSTED", "other");
|
|
277
447
|
}
|
|
278
448
|
|
|
@@ -282,13 +452,7 @@ export const handleMessageDelete = async (
|
|
|
282
452
|
// rows are gone and the resync rebuilds them. BROKEN, the row has just
|
|
283
453
|
// been put back at the source the server confirmed, and the resync is
|
|
284
454
|
// what carries any drift either folder has picked up since.
|
|
285
|
-
|
|
286
|
-
await emitMoveResync(emitEvent, {
|
|
287
|
-
accountId,
|
|
288
|
-
sourceMailboxId: mailboxId,
|
|
289
|
-
destinationMailboxId,
|
|
290
|
-
});
|
|
291
|
-
}
|
|
455
|
+
await emitSettleResync();
|
|
292
456
|
};
|
|
293
457
|
|
|
294
458
|
/**
|
|
@@ -301,6 +465,7 @@ export const handleMessageDelete = async (
|
|
|
301
465
|
confirmation: Exclude<TrashMoveConfirmation, { outcome: "confirmed" }>,
|
|
302
466
|
accountConfigId: string,
|
|
303
467
|
getConnection: () => Promise<IImapConnection>,
|
|
468
|
+
settlePaused: () => Promise<void>,
|
|
304
469
|
): Promise<void> => {
|
|
305
470
|
const context = {
|
|
306
471
|
accountId,
|
|
@@ -326,7 +491,7 @@ export const handleMessageDelete = async (
|
|
|
326
491
|
context,
|
|
327
492
|
"Move to trash carries no Message-ID header to probe the destination with; settling on the source's answer alone",
|
|
328
493
|
);
|
|
329
|
-
await settleExhaustedDelete(accountConfigId, getConnection);
|
|
494
|
+
await settleExhaustedDelete(accountConfigId, getConnection, settlePaused);
|
|
330
495
|
return;
|
|
331
496
|
}
|
|
332
497
|
|
|
@@ -367,19 +532,43 @@ export const handleMessageDelete = async (
|
|
|
367
532
|
return;
|
|
368
533
|
}
|
|
369
534
|
|
|
535
|
+
const scope = createConnectionScopeWithCredentials(account, credentials);
|
|
536
|
+
|
|
537
|
+
// The terminal resolver opens the source itself, and its answer now
|
|
538
|
+
// WRITES a placement, so it has to reach IMAP through the same
|
|
539
|
+
// UIDVALIDITY choke point every other outbound operation does. An
|
|
540
|
+
// unguarded probe on a folder that was deleted and recreated finds
|
|
541
|
+
// whatever the server has renumbered onto this uid, reads it as "still
|
|
542
|
+
// at source", and binds the row to a stranger the next permanent delete
|
|
543
|
+
// would expunge.
|
|
544
|
+
const getGuardedConnection = async (): Promise<IImapConnection> =>
|
|
545
|
+
guardConnectionCursor(
|
|
546
|
+
await scope.getConnection(),
|
|
547
|
+
{ mailboxService },
|
|
548
|
+
accountId,
|
|
549
|
+
mailbox,
|
|
550
|
+
);
|
|
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
|
+
|
|
370
559
|
// Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
|
|
371
|
-
// paused never
|
|
372
|
-
// guardConnectionCursor openBox wrap below is the structural
|
|
560
|
+
// paused never opens a connection on a first delivery. Optimization
|
|
561
|
+
// only — the guardConnectionCursor openBox wrap below is the structural
|
|
562
|
+
// guarantee.
|
|
373
563
|
if (isCursorRebuildNeeded(mailbox.cursorState)) {
|
|
374
564
|
log.info(
|
|
375
565
|
{ accountId, messageId, mailboxId },
|
|
376
|
-
"Mailbox cursor not normal; pausing outbound delete this round",
|
|
566
|
+
"Mailbox cursor not normal; pausing outbound delete this round and settling the row against the server",
|
|
377
567
|
);
|
|
568
|
+
await settlePaused(false).finally(() => scope.disconnect());
|
|
378
569
|
return;
|
|
379
570
|
}
|
|
380
571
|
|
|
381
|
-
const scope = createConnectionScopeWithCredentials(account, credentials);
|
|
382
|
-
|
|
383
572
|
await scope
|
|
384
573
|
.getConnection()
|
|
385
574
|
.then(async (rawConnection) => {
|
|
@@ -419,6 +608,7 @@ export const handleMessageDelete = async (
|
|
|
419
608
|
destinationMailboxId
|
|
420
609
|
) {
|
|
421
610
|
// Move to Trash
|
|
611
|
+
commandIssued = true;
|
|
422
612
|
const result = await connection.moveMessages(
|
|
423
613
|
[uid],
|
|
424
614
|
destinationMailboxPath,
|
|
@@ -456,44 +646,22 @@ export const handleMessageDelete = async (
|
|
|
456
646
|
});
|
|
457
647
|
|
|
458
648
|
if (confirmation.outcome === "confirmed") {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
await messageService.updateUid(
|
|
462
|
-
messageId,
|
|
463
|
-
newUid,
|
|
649
|
+
await settleTrashMoveConfirmed(
|
|
650
|
+
confirmation.uid,
|
|
464
651
|
destinationMailboxId,
|
|
465
652
|
);
|
|
466
|
-
|
|
467
|
-
// Update ThreadMessage with new UID and isDeleted = true
|
|
468
|
-
const threadMessage = await threadMessageService.findByMessageId(
|
|
469
|
-
account.accountConfigId,
|
|
470
|
-
messageId,
|
|
471
|
-
);
|
|
472
|
-
if (threadMessage) {
|
|
473
|
-
const args = buildThreadMessageTrashUpdate(
|
|
474
|
-
threadMessage,
|
|
475
|
-
newUid,
|
|
476
|
-
destinationMailboxId,
|
|
477
|
-
);
|
|
478
|
-
await threadMessageService.update(
|
|
479
|
-
threadMessage.accountConfigId,
|
|
480
|
-
threadMessage.threadMessageId,
|
|
481
|
-
args.set,
|
|
482
|
-
{ composites: args.composites },
|
|
483
|
-
);
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
log.info({ messageId, newUid }, "Message moved to trash");
|
|
487
653
|
return;
|
|
488
654
|
}
|
|
489
655
|
|
|
490
656
|
await settleUnconfirmedTrashMove(
|
|
491
657
|
confirmation,
|
|
492
658
|
account.accountConfigId,
|
|
493
|
-
|
|
659
|
+
getGuardedConnection,
|
|
660
|
+
() => settlePaused(true),
|
|
494
661
|
);
|
|
495
662
|
} else {
|
|
496
663
|
// Permanent delete — reached only by `operation === "permanent_delete"`.
|
|
664
|
+
commandIssued = true;
|
|
497
665
|
await connection.deleteMessages([uid]);
|
|
498
666
|
|
|
499
667
|
// Delete ThreadMessage rows BEFORE the Message row to collapse the
|
|
@@ -520,8 +688,9 @@ export const handleMessageDelete = async (
|
|
|
520
688
|
if (error instanceof MailboxCursorPausedError) {
|
|
521
689
|
log.info(
|
|
522
690
|
{ accountId, messageId, mailboxId, cursorState: error.state },
|
|
523
|
-
"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",
|
|
524
692
|
);
|
|
693
|
+
await settlePaused(commandIssued);
|
|
525
694
|
return;
|
|
526
695
|
}
|
|
527
696
|
|
|
@@ -594,7 +763,8 @@ export const handleMessageDelete = async (
|
|
|
594
763
|
// failure this budget exists for.
|
|
595
764
|
await settleExhaustedDelete(
|
|
596
765
|
account.accountConfigId,
|
|
597
|
-
|
|
766
|
+
getGuardedConnection,
|
|
767
|
+
() => settlePaused(commandIssued),
|
|
598
768
|
);
|
|
599
769
|
log.error(
|
|
600
770
|
{ accountId, messageId, uid, mailboxPath, error: errorMessage },
|