@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.
@@ -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 { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
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
- buildThreadMessageMoveRevert,
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
- const abandonDelete = async (
206
- reason: string,
207
- alert: string,
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
- log.error(
210
- { alert, accountId, messageId, uid, mailboxPath, operation },
211
- reason,
212
- );
307
+ await messageService.updateUid(messageId, newUid, trashMailboxId);
213
308
 
214
- const threadMessages = await threadMessageService.findAllByMessageId(
309
+ const threadMessage = await threadMessageService.findByMessageId(
215
310
  account.accountConfigId,
216
311
  messageId,
217
312
  );
218
-
219
- // A permanent delete removes the listing rows before it enqueues, and
220
- // they cannot be rebuilt from here — the row is denormalized off an
221
- // envelope only the sync path shapes. Restoring the Message alone would
222
- // leave mail nothing can list, which is the silent vanish rather than a
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 { outcome } = await resolveExhaustedMessageDeleteFailure(
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
- if (destinationMailboxId) {
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 even opens a connection. Optimization only — the
372
- // guardConnectionCursor openBox wrap below is the structural guarantee.
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
- const newUid = confirmation.uid;
460
- // Update message with new UID in Trash
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
- scope.getConnection,
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
- scope.getConnection,
766
+ getGuardedConnection,
767
+ () => settlePaused(commandIssued),
598
768
  );
599
769
  log.error(
600
770
  { accountId, messageId, uid, mailboxPath, error: errorMessage },