@remit/mailbox-service 0.0.91 → 0.0.92

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.
@@ -29,17 +29,28 @@ import { deriveCopyMessageId } from "@remit/data-ports/id";
29
29
  import { MUTATION_EVENT_SCHEMA_VERSION } from "@remit/data-ports/mutation-events";
30
30
  import {
31
31
  CanonicalMailboxRole,
32
+ MessageMutation,
32
33
  MessageStatus,
33
34
  MessageSyncStatus,
34
35
  } from "@remit/domain-enums";
35
36
  import { createQueueProducer } from "@remit/sqs-client/producer";
36
37
  import {
37
- bindsForeignUid,
38
- type PlacementBinding,
38
+ carriesForeignUid,
39
39
  placementBindingOf,
40
40
  waitForPlacementToSettle,
41
41
  } from "./placement-settled.js";
42
42
 
43
+ /**
44
+ * What a delete batch did not claim. A row whose placement changed between the
45
+ * read that decided this delete and the mark that recorded it belongs to
46
+ * whoever changed it (docs/architecture/imap-mutations.md R3), so this delete
47
+ * leaves it alone — and says which, because the client removed it optimistically
48
+ * and a row that reappears unexplained is a failure the user cannot act on.
49
+ */
50
+ export interface DeleteMessagesOutcome {
51
+ refusedMessageIds: string[];
52
+ }
53
+
43
54
  /**
44
55
  * Event types for message move/delete operations.
45
56
  * These match the worker event types in remit-imap-worker/events.ts.
@@ -288,9 +299,8 @@ export class MessageMoveService {
288
299
  messageId: string,
289
300
  accountId: string,
290
301
  options: DeleteOptions = { toTrash: true },
291
- ): Promise<void> => {
292
- await this.deleteMessages(accountConfigId, [messageId], accountId, options);
293
- };
302
+ ): Promise<DeleteMessagesOutcome> =>
303
+ this.deleteMessages(accountConfigId, [messageId], accountId, options);
294
304
 
295
305
  /**
296
306
  * Delete multiple messages using batch operations.
@@ -305,12 +315,12 @@ export class MessageMoveService {
305
315
  messageIds: string[],
306
316
  accountId: string,
307
317
  options: DeleteOptions = { toTrash: true },
308
- ): Promise<void> => {
309
- if (messageIds.length === 0) return;
318
+ ): Promise<DeleteMessagesOutcome> => {
319
+ if (messageIds.length === 0) return { refusedMessageIds: [] };
310
320
 
311
321
  // Batch get all messages
312
322
  const rows = await this.messageService.get(messageIds);
313
- if (rows.length === 0) return;
323
+ if (rows.length === 0) return { refusedMessageIds: [] };
314
324
 
315
325
  // Ahead of the Trash gates below, which compare `mailboxId` against the
316
326
  // Trash folder: an in-flight move makes that comparison answer for a
@@ -381,20 +391,36 @@ export class MessageMoveService {
381
391
  }
382
392
 
383
393
  // Group messages by operation type
384
- const moveToTrashMessages: Array<{
394
+ type DeleteEntry = {
385
395
  messageId: string;
386
396
  message: { mailboxId: string; uid: number };
387
397
  sourceMailbox: { mailboxId: string; fullPath: string };
388
- }> = [];
389
- const permanentDeleteMessages: Array<{
390
- messageId: string;
391
- message: { mailboxId: string; uid: number };
392
- sourceMailbox: { mailboxId: string; fullPath: string };
393
- }> = [];
398
+ };
399
+ const moveToTrashMessages: DeleteEntry[] = [];
400
+ const permanentDeleteMessages: DeleteEntry[] = [];
401
+
402
+ // Rows whose placement changed between the read above and the mark below,
403
+ // so this delete never claimed them. Reported rather than skipped in
404
+ // silence: the client removed them optimistically, and a row that
405
+ // reappears with nothing said is the dead-button failure again (#1229).
406
+ const refused: string[] = [];
394
407
 
395
408
  for (const message of messages) {
396
409
  const sourceMailbox = mailboxMap.get(message.mailboxId);
397
- if (!sourceMailbox) continue;
410
+ if (!sourceMailbox) {
411
+ // The folder lookup is scoped to one account, so a selection that
412
+ // spans accounts — the daily brief lists every account's mail —
413
+ // leaves this row's mailbox unresolved. Nothing about it was
414
+ // written and no event was enqueued, so it is a refusal like any
415
+ // other; dropping it silently answered "deleted" over a message
416
+ // still sitting where it was.
417
+ refused.push(message.messageId);
418
+ this.log.info(
419
+ { accountId, messageId: message.messageId },
420
+ "Refused delete: this message's folder does not belong to this account",
421
+ );
422
+ continue;
423
+ }
398
424
 
399
425
  const isInTrash =
400
426
  trashMailbox !== null && message.mailboxId === trashMailbox.mailboxId;
@@ -428,14 +454,41 @@ export class MessageMoveService {
428
454
  accountConfigId,
429
455
  );
430
456
  for (const { messageId, message, sourceMailbox } of moveToTrashMessages) {
431
- // Update local state optimistically
432
- await this.messageService.updateForMove(messageId, {
433
- mailboxId: trashMailbox.mailboxId,
434
- status: MessageStatus.moving,
435
- syncStatus: MessageSyncStatus.pending,
436
- originalMailboxId: sourceMailbox.mailboxId,
437
- originalUid: message.uid,
438
- });
457
+ // The optimistic write is a transition off the row this call read
458
+ // (imap-mutations R3). Several folder lookups stand between the read
459
+ // and here, and PLACEMENT_MOVE_PUSH runs on a queue this account's
460
+ // FIFO group does not order, so the row can settle onto a different
461
+ // folder and uid in between. Writing anyway would stamp `moving` and
462
+ // the OLD uid over the new placement, and the event enqueued below
463
+ // would name a uid the source no longer holds — a delete aimed at
464
+ // whatever message has since taken that number.
465
+ const marked = await this.messageService.transitionPlacement(
466
+ messageId,
467
+ {
468
+ // `active`, not whatever the row was read as: a row already
469
+ // `moving` or `deleting` is mid-mutation, and marking it again
470
+ // would enqueue a second event against the same uid.
471
+ status: MessageStatus.active,
472
+ mailboxId: message.mailboxId,
473
+ uid: message.uid,
474
+ },
475
+ {
476
+ mailboxId: trashMailbox.mailboxId,
477
+ status: MessageStatus.moving,
478
+ syncStatus: MessageSyncStatus.pending,
479
+ abandonedMutation: MessageMutation.none,
480
+ originalMailboxId: sourceMailbox.mailboxId,
481
+ originalUid: message.uid,
482
+ },
483
+ );
484
+ if (!marked) {
485
+ refused.push(messageId);
486
+ this.log.info(
487
+ { accountId, messageId },
488
+ "Refused delete: this message's placement changed after it was read",
489
+ );
490
+ continue;
491
+ }
439
492
 
440
493
  // Update ThreadMessage
441
494
  await this.updateThreadMessageForMove(
@@ -481,11 +534,36 @@ export class MessageMoveService {
481
534
  message,
482
535
  sourceMailbox,
483
536
  } of permanentDeleteMessages) {
484
- // Update local state optimistically
485
- await this.messageService.update(messageId, {
486
- status: MessageStatus.deleting,
487
- syncStatus: MessageSyncStatus.pending,
488
- });
537
+ // A transition off the row this call read, like the trash path above
538
+ // (imap-mutations R3). The expunge below is aimed at a specific uid in
539
+ // a specific folder, so a row that has moved since the read must not be
540
+ // marked for it: a restore out of Trash settling in between would be
541
+ // overwritten with `deleting`, and the event would carry the uid Trash
542
+ // no longer holds.
543
+ const marked = await this.messageService.transitionPlacement(
544
+ messageId,
545
+ {
546
+ // `active` for the same reason as the trash path above: a second
547
+ // press on a row already `deleting` would enqueue a duplicate
548
+ // expunge rather than refusing.
549
+ status: MessageStatus.active,
550
+ mailboxId: message.mailboxId,
551
+ uid: message.uid,
552
+ },
553
+ {
554
+ status: MessageStatus.deleting,
555
+ syncStatus: MessageSyncStatus.pending,
556
+ abandonedMutation: MessageMutation.none,
557
+ },
558
+ );
559
+ if (!marked) {
560
+ refused.push(messageId);
561
+ this.log.info(
562
+ { accountId, messageId },
563
+ "Refused permanent delete: this message's placement changed after it was read",
564
+ );
565
+ continue;
566
+ }
489
567
 
490
568
  // Delete ThreadMessage rows up-front (one row per mailbox copy).
491
569
  // The IMAP worker also deletes them once the IMAP DELETE succeeds —
@@ -517,6 +595,8 @@ export class MessageMoveService {
517
595
 
518
596
  // Batch send events to SQS
519
597
  await this.enqueueEventsBatch(events);
598
+
599
+ return { refusedMessageIds: refused };
520
600
  };
521
601
 
522
602
  /**
@@ -591,14 +671,41 @@ export class MessageMoveService {
591
671
  trashMailbox && message.mailboxId === trashMailbox.mailboxId,
592
672
  );
593
673
 
594
- // Update local state optimistically
595
- await this.messageService.updateForMove(messageId, {
596
- mailboxId: destinationMailboxId,
597
- status: MessageStatus.moving,
598
- syncStatus: MessageSyncStatus.pending,
599
- originalMailboxId: sourceMailbox.mailboxId,
600
- originalUid: message.uid,
601
- });
674
+ // The optimistic write is a transition off the row this call read
675
+ // (imap-mutations R3). Three mailbox lookups stand between the read and
676
+ // here, and PLACEMENT_MOVE_PUSH runs on a queue this account's FIFO group
677
+ // does not order, so the row can settle onto a different folder and uid in
678
+ // between. A blind write would stamp `moving` and the OLD uid over that
679
+ // placement, leaving a pair `carriesForeignUid` calls consistent, and the
680
+ // event below would send the worker after whatever now holds the old uid.
681
+ const moved = await this.messageService.transitionPlacement(
682
+ messageId,
683
+ {
684
+ status: MessageStatus.active,
685
+ mailboxId: message.mailboxId,
686
+ uid: message.uid,
687
+ },
688
+ {
689
+ mailboxId: destinationMailboxId,
690
+ status: MessageStatus.moving,
691
+ syncStatus: MessageSyncStatus.pending,
692
+ abandonedMutation: MessageMutation.none,
693
+ originalMailboxId: sourceMailbox.mailboxId,
694
+ originalUid: message.uid,
695
+ },
696
+ );
697
+ if (!moved) {
698
+ this.log.error(
699
+ { accountId, messageId, destinationMailboxId },
700
+ "Refused: this message's placement changed while the move was being prepared",
701
+ );
702
+ throw new MessagePlacementUnsettledError(
703
+ `Message ${messageId} was not moved: something else changed where it is while this move was being prepared`,
704
+ accountId,
705
+ messageId,
706
+ "in_flight",
707
+ );
708
+ }
602
709
 
603
710
  // Update ThreadMessage
604
711
  await this.updateThreadMessageForMove(
@@ -928,18 +1035,45 @@ export class MessageMoveService {
928
1035
  // says so: its own MESSAGE_MOVE then reads the row as settled and returns
929
1036
  // without moving anything, and the worker's expunge binds that borrowed
930
1037
  // uid against whatever Trash really holds at it. Left as it stands, that
931
- // move runs ahead of this expunge in the account's FIFO group and settles
932
- // the row, so the message is in Trash by the time the sweep reaches it and
933
- // goes with the rest of this same press — only the count below is one
934
- // short of what the sweep ends up removing.
935
- const messages = rows.filter((message) => !bindsForeignUid(message));
936
-
937
- // Mark all as deleting locally
938
- for (const message of messages) {
939
- await this.messageService.update(message.messageId, {
940
- status: MessageStatus.deleting,
941
- syncStatus: MessageSyncStatus.pending,
942
- });
1038
+ // move settles the row on its own, so the message is in Trash by the time
1039
+ // the sweep reaches it and goes with the rest of this same press — only
1040
+ // the count below is one short of what the sweep ends up removing.
1041
+ //
1042
+ // `carriesForeignUid`, the same predicate the sweep applies (#1217).
1043
+ // The binding form reads `status` too, and `status` is exactly what this
1044
+ // mark is about to overwrite, so the two halves of one gate would have
1045
+ // disagreed the moment the mark landed.
1046
+ const candidates = rows.filter((message) => !carriesForeignUid(message));
1047
+
1048
+ // Each mark is a transition off the row as it was just read
1049
+ // (imap-mutations R3), not a blind write onto a snapshot. The rows are
1050
+ // held by nothing between the listing and here, and PLACEMENT_MOVE_PUSH
1051
+ // runs on a standard queue that this account's FIFO group does not order,
1052
+ // so a row can move out from under the sweep. A lost predicate means
1053
+ // another lane won: the row is left alone and not counted, rather than
1054
+ // marked `deleting` for an expunge that would bind somebody else's uid.
1055
+ const messages: MessageItem[] = [];
1056
+ for (const message of candidates) {
1057
+ const marked = await this.messageService.transitionPlacement(
1058
+ message.messageId,
1059
+ {
1060
+ status: message.status,
1061
+ mailboxId: message.mailboxId,
1062
+ uid: message.uid,
1063
+ },
1064
+ {
1065
+ status: MessageStatus.deleting,
1066
+ syncStatus: MessageSyncStatus.pending,
1067
+ },
1068
+ );
1069
+ if (!marked) {
1070
+ this.log.info(
1071
+ { accountId, messageId: message.messageId },
1072
+ "Empty Trash skipped a message whose placement changed while the folder was being read",
1073
+ );
1074
+ continue;
1075
+ }
1076
+ messages.push(marked);
943
1077
  await this.updateThreadMessageDeleted(
944
1078
  accountConfigId,
945
1079
  message.messageId,
@@ -952,6 +1086,7 @@ export class MessageMoveService {
952
1086
  accountId,
953
1087
  trashMailboxId: trashMailbox.mailboxId,
954
1088
  count: messages.length,
1089
+ skipped: rows.length - messages.length,
955
1090
  },
956
1091
  "Marked all trash messages for deletion (local)",
957
1092
  );
@@ -976,17 +1111,16 @@ export class MessageMoveService {
976
1111
  private refusePlacement = (
977
1112
  message: MessageItem,
978
1113
  accountId: string,
979
- binding: Exclude<PlacementBinding, "consistent">,
980
1114
  ): never => {
981
1115
  this.log.error(
982
- { accountId, messageId: message.messageId, binding },
1116
+ { accountId, messageId: message.messageId },
983
1117
  "Refused: this message's folder and uid do not name the same message",
984
1118
  );
985
1119
  throw new MessagePlacementUnsettledError(
986
1120
  `Message ${message.messageId} was not acted on: its folder and uid do not name the same message`,
987
1121
  accountId,
988
1122
  message.messageId,
989
- binding === "abandoned" ? "unverified" : "in_flight",
1123
+ "in_flight",
990
1124
  );
991
1125
  };
992
1126
 
@@ -994,13 +1128,7 @@ export class MessageMoveService {
994
1128
  message: MessageItem,
995
1129
  accountId: string,
996
1130
  ): Promise<MessageItem> => {
997
- const binding = placementBindingOf(message);
998
- if (binding === "consistent") return message;
999
-
1000
- // An abandoned move is never coming back, so the ceiling would be spent
1001
- // only to reach the same answer.
1002
- if (binding === "abandoned")
1003
- return this.refusePlacement(message, accountId, binding);
1131
+ if (placementBindingOf(message) === "consistent") return message;
1004
1132
 
1005
1133
  const settled = await waitForPlacementToSettle(
1006
1134
  this.messageService,
@@ -1010,9 +1138,8 @@ export class MessageMoveService {
1010
1138
  pollMs: this.moveSettlePollMs,
1011
1139
  },
1012
1140
  );
1013
- const settledBinding = placementBindingOf(settled);
1014
- if (settledBinding === "consistent") return settled;
1015
- return this.refusePlacement(settled, accountId, settledBinding);
1141
+ if (placementBindingOf(settled) === "consistent") return settled;
1142
+ return this.refusePlacement(settled, accountId);
1016
1143
  };
1017
1144
 
1018
1145
  /**
@@ -336,13 +336,33 @@ describe("repointsOnSighting", () => {
336
336
  );
337
337
  });
338
338
 
339
- it("refuses a row whose move failed and has not been re-tried", () => {
339
+ /**
340
+ * The row `abandonDelete` hands back: reader refused the delete, put the
341
+ * message back where the server still has it, and nothing else is coming
342
+ * for the row. Reader shares its mailboxes, so the user moving that same
343
+ * message in another client is ordinary — and before R3 the sighting was
344
+ * refused on `syncStatus` alone and the row never followed the move.
345
+ */
346
+ it("accepts a row whose delete was abandoned, so a move made elsewhere still lands", () => {
347
+ assert.equal(
348
+ repointsOnSighting(
349
+ JUNK,
350
+ storedIn(INBOX, {
351
+ status: MessageStatus.active,
352
+ syncStatus: MessageSyncStatus.abandoned,
353
+ }),
354
+ ),
355
+ true,
356
+ );
357
+ });
358
+
359
+ it("accepts a row left `failed` by a transient attempt that has since settled", () => {
340
360
  assert.equal(
341
361
  repointsOnSighting(
342
362
  JUNK,
343
363
  storedIn(INBOX, { syncStatus: MessageSyncStatus.failed }),
344
364
  ),
345
- false,
365
+ true,
346
366
  );
347
367
  });
348
368
 
@@ -30,7 +30,6 @@ import {
30
30
  MailboxCursorState,
31
31
  MessageKeywordFlag,
32
32
  MessageStatus,
33
- MessageSyncStatus,
34
33
  MessageSystemFlag,
35
34
  StarColor,
36
35
  } from "@remit/domain-enums";
@@ -149,22 +148,22 @@ export const addressSightingIn = (
149
148
  * reconciles rather than waits (imap-mutations R2): it never blocks on a
150
149
  * mutation, it re-reads the row on the next sighting after the move settles.
151
150
  *
152
- * In flight is read off `status`, never off `syncStatus` (#1096). Every
151
+ * In flight is `status`, and `status` alone (#1096, imap-mutations R3). Every
153
152
  * outbound mutation writes `moving` or `deleting` and only settling returns the
154
- * row to `active`, while an ordinary inbound row stays `pending` for its whole
155
- * life because nothing on the sync path promotes it — so demanding `synced`
156
- * here meant ordinary mail never re-pointed at all. `failed` is still refused:
157
- * a mutation that gave up leaves it set on a row put back to `active`, and
158
- * nothing routine settles that row afterwards.
153
+ * row to `active`, so `active` is precisely the set nothing is coming for —
154
+ * whatever `syncStatus` says about how the row got there. A row put back by
155
+ * `abandonDelete` carries `abandoned`, and it needs this repair more than any
156
+ * other row does: nothing routine settles it, and reader shares its mailboxes,
157
+ * so refusing it left a message whose delete was refused unable to follow a
158
+ * move the user then made in another client.
159
159
  */
160
160
  export const repointsOnSighting = (
161
161
  mailbox: MailboxItem,
162
- message: Pick<MessageItem, "mailboxId" | "status" | "syncStatus">,
162
+ message: Pick<MessageItem, "mailboxId" | "status">,
163
163
  ): boolean => {
164
164
  if (message.mailboxId === mailbox.mailboxId) return false;
165
165
  if (isVirtualCopyMailbox(mailbox)) return false;
166
- if (message.status !== MessageStatus.active) return false;
167
- return message.syncStatus !== MessageSyncStatus.failed;
166
+ return message.status === MessageStatus.active;
168
167
  };
169
168
 
170
169
  /**