@remit/imap-worker 0.0.62 → 0.0.63

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/imap-worker",
3
- "version": "0.0.62",
3
+ "version": "0.0.63",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -15,7 +15,7 @@ import type { EmptyTrashEvent } from "../events.js";
15
15
  import { isNotFoundError } from "../is-not-found.js";
16
16
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
17
17
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
18
- import { buildThreadMessageUndelete } from "./message-delete.js";
18
+ import { buildThreadMessageUndelete } from "./thread-message-rows.js";
19
19
 
20
20
  export interface EmptyTrashDeps {
21
21
  getClient: typeof getClient;
@@ -1,11 +1,14 @@
1
- import type { IMessageRepository } from "@remit/data-ports";
2
- import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
1
+ import type {
2
+ IMessageRepository,
3
+ IThreadMessageRepository,
4
+ } from "@remit/data-ports";
3
5
  import {
4
6
  type IImapConnection,
5
7
  isMessageGoneFromOpenMailbox,
6
8
  reconcileStaleMessage,
7
9
  type StaleMessageReconcileDeps,
8
10
  } from "@remit/mailbox-service";
11
+ import { buildThreadMessageMoveRevert } from "./thread-message-rows.js";
9
12
 
10
13
  export interface MessageDeleteTerminalLogger {
11
14
  info(obj: Record<string, unknown>, msg: string): void;
@@ -14,7 +17,11 @@ export interface MessageDeleteTerminalLogger {
14
17
 
15
18
  export interface ResolveExhaustedMessageDeleteDeps
16
19
  extends StaleMessageReconcileDeps {
17
- messageService: Pick<IMessageRepository, "delete" | "update">;
20
+ messageService: Pick<IMessageRepository, "delete" | "updateUid">;
21
+ threadMessageService: Pick<
22
+ IThreadMessageRepository,
23
+ "findAllByMessageId" | "deleteMany" | "update"
24
+ >;
18
25
  log: MessageDeleteTerminalLogger;
19
26
  }
20
27
 
@@ -22,6 +29,7 @@ export interface ResolveExhaustedMessageDeleteInput {
22
29
  accountId: string;
23
30
  accountConfigId: string;
24
31
  messageId: string;
32
+ sourceMailboxId: string;
25
33
  uid: number;
26
34
  sourceMailboxPath: string;
27
35
  getConnection: () => Promise<IImapConnection>;
@@ -49,15 +57,18 @@ export interface ResolveExhaustedMessageDeleteResult {
49
57
  * server's own UID. Metric only, no alarm — routine.
50
58
  * 2. BROKEN — the message is still at the source, so the delete never took
51
59
  * effect, but it keeps failing: broken code or a broken account, not a
52
- * transient blip. The row's mailbox and uid are left exactly as they stand,
53
- * because reverting the optimistic move on this ambiguity is what PR #652
54
- * was pulled for.
60
+ * transient blip. The row is put back where the server has just said the
61
+ * message is: `mailboxId` and `uid` return to the delete's source pair, and
62
+ * the thread rows lose the deletion mark the optimistic `updateForMove`
63
+ * wrote (issue #1098).
55
64
  *
56
- * `status` does settle, to `active`. It is not a claim about where the
57
- * message is — only that this row is no longer mid-mutation. Leaving it
58
- * `moving` makes `isPlacementUnsettled` true forever, and every later delete
59
- * of that message then waits on a mutation that has already terminated, so
60
- * the user is left with mail they cannot delete.
65
+ * Settling `status` to `active` while leaving `mailboxId` on Trash is what
66
+ * this replaces. `bindsForeignUid` refuses a mismatched pair only while
67
+ * `status` is `moving`, so clearing it read as consistent, and the UI then
68
+ * offered "Delete permanently" on a Trash mailbox with the source folder's
69
+ * uid — an expunge naming a different message. Restoring the pair is not a
70
+ * revert on ambiguity (what PR #652 was pulled for): the presence probe
71
+ * above has just answered, so this writes a placement the server confirmed.
61
72
  *
62
73
  * A server that cannot be reached at exhaustion time never reaches either
63
74
  * verdict: the probe throws and the record dead-letters with the row untouched.
@@ -71,6 +82,7 @@ export const resolveExhaustedMessageDeleteFailure = async (
71
82
  accountId,
72
83
  accountConfigId,
73
84
  messageId,
85
+ sourceMailboxId,
74
86
  uid,
75
87
  sourceMailboxPath,
76
88
  getConnection,
@@ -100,10 +112,6 @@ export const resolveExhaustedMessageDeleteFailure = async (
100
112
  return { outcome: "reconciled" };
101
113
  }
102
114
 
103
- await deps.messageService.update(messageId, {
104
- status: MessageStatus.active,
105
- syncStatus: MessageSyncStatus.failed,
106
- });
107
115
  deps.log.error(
108
116
  {
109
117
  alert: "message_delete_failed",
@@ -111,9 +119,31 @@ export const resolveExhaustedMessageDeleteFailure = async (
111
119
  accountConfigId,
112
120
  messageId,
113
121
  uid,
122
+ sourceMailboxId,
114
123
  sourceMailboxPath,
115
124
  },
116
- "Delete could not be pushed to IMAP after retry exhaustion; the message is still at its source — row settled out of `moving` and left in place for operator investigation",
125
+ "Delete could not be pushed to IMAP after retry exhaustion; the message is still at its source — row moved back to the source folder",
117
126
  );
127
+
128
+ await deps.messageService.updateUid(messageId, uid, sourceMailboxId);
129
+
130
+ const threadMessages = await deps.threadMessageService.findAllByMessageId(
131
+ accountConfigId,
132
+ messageId,
133
+ );
134
+ for (const threadMessage of threadMessages) {
135
+ const args = buildThreadMessageMoveRevert(
136
+ threadMessage,
137
+ uid,
138
+ sourceMailboxId,
139
+ );
140
+ await deps.threadMessageService.update(
141
+ threadMessage.accountConfigId,
142
+ threadMessage.threadMessageId,
143
+ args.set,
144
+ { composites: args.composites },
145
+ );
146
+ }
147
+
118
148
  return { outcome: "broken" };
119
149
  };
@@ -5,14 +5,16 @@ import type { Logger } from "@remit/logger-lambda";
5
5
  import { renderMetrics, resetMetrics } from "@remit/logger-lambda";
6
6
  import type { MessageDeleteEvent } from "../events.js";
7
7
  import {
8
- buildThreadMessageTrashUpdate,
9
- buildThreadMessageUndelete,
10
8
  deleteAllThreadMessagesForMessage,
11
9
  getMessageDeleteMaxAttempts,
12
10
  handleMessageDelete,
13
11
  MESSAGE_DELETE_MAX_ATTEMPTS,
14
12
  type MessageDeleteDeps,
15
13
  } from "./message-delete.js";
14
+ import {
15
+ buildThreadMessageTrashUpdate,
16
+ buildThreadMessageUndelete,
17
+ } from "./thread-message-rows.js";
16
18
 
17
19
  describe("getMessageDeleteMaxAttempts — env-derived threshold (#980)", () => {
18
20
  it("parses the injected env var", () => {
@@ -676,28 +678,65 @@ describe("handleMessageDelete", () => {
676
678
  );
677
679
  });
678
680
 
679
- // A source that still holds the uid at the ceiling means the MOVE never
680
- // took effect. Local state is left exactly as it stands — the row is the
681
- // only record that this delete is still owed, and a revert races a MOVE
682
- // that may yet have landed (#652, #655).
683
- it("leaves a message still at its source untouched at the ceiling and never re-throws", async () => {
681
+ // Issue #1098. A source that still holds the uid at the ceiling means the
682
+ // MOVE never took effect, so the row goes back to the source folder: the
683
+ // optimistic `updateForMove` left `mailboxId` on Trash while `uid` still
684
+ // named the source's message, and settling `status` to `active` on top of
685
+ // that pair switched off every guard that reads `status === "moving"`.
686
+ // The UI then resolved a Trash `mailboxId` to "Delete permanently", an
687
+ // expunge of Trash by a uid belonging to a different message. This is not
688
+ // a revert on ambiguity (#652, #655): the presence probe has just
689
+ // answered, so the pair written here is the placement the server holds.
690
+ it("moves a message still at its source back to the source folder at the ceiling", async () => {
684
691
  h.connection.moveMessages = async () => ({ uidMap: new Map() });
685
692
  h.destinationSearchUids = [100];
693
+ h.allThreadMessages = [
694
+ {
695
+ ...baseThreadMessage,
696
+ accountConfigId: "cfg-1",
697
+ threadMessageId: "tm-1",
698
+ mailboxId: "trash-mbx",
699
+ isDeleted: true,
700
+ },
701
+ ];
686
702
 
687
703
  await handleMessageDelete(moveEvent, noopLog, 3, deps());
688
704
 
689
705
  assert.equal(called("message.delete").length, 0);
690
706
  assert.equal(called("threadMessage.deleteMany").length, 0);
691
- assert.equal(called("threadMessage.update").length, 0);
692
707
 
693
- // The row's mailbox and uid stay put, but `status` must leave
694
- // `moving`: `isPlacementUnsettled` reads exactly that value, so a row
695
- // left mid-mutation makes every later delete of this message wait on
696
- // a mutation that has already terminated.
697
- assert.deepEqual(called("message.update").at(-1)?.args[1], {
698
- status: "active",
699
- syncStatus: "failed",
700
- });
708
+ // `mailboxId` and `uid` name the same message again, and `updateUid`
709
+ // settles `status` out of `moving` in the same write, so no later
710
+ // delete of this message waits on a mutation that has terminated.
711
+ assert.deepEqual(called("message.updateUid").at(-1)?.args, [
712
+ "msg-1",
713
+ 10,
714
+ "src-mbx",
715
+ ]);
716
+ assert.equal(
717
+ called("message.update").length,
718
+ 0,
719
+ "settling `status` without repairing the pair is what left the row addressable as a Trash expunge",
720
+ );
721
+
722
+ // The listing row follows the Message row out of Trash, which is what
723
+ // makes "Delete permanently" unreachable: the client resolves that
724
+ // outcome from a target whose `mailboxId` is the Trash mailbox.
725
+ assert.deepEqual(called("threadMessage.update").at(-1)?.args, [
726
+ "cfg-1",
727
+ "tm-1",
728
+ { uid: 10, mailboxId: "src-mbx", isDeleted: false },
729
+ {
730
+ composites: {
731
+ sentDate: baseThreadMessage.sentDate,
732
+ mailboxId: "trash-mbx",
733
+ isRead: true,
734
+ isDeleted: true,
735
+ hasStars: true,
736
+ hasAttachment: false,
737
+ },
738
+ },
739
+ ]);
701
740
  assert.equal(await imapFailures("MESSAGE_DELETE_EXHAUSTED"), 1);
702
741
  });
703
742
 
@@ -767,15 +806,16 @@ describe("handleMessageDelete", () => {
767
806
  assert.equal(called("emitEvent").length, 2);
768
807
  });
769
808
 
770
- it("settles a still-present message at the ceiling out of `moving`", async () => {
809
+ it("settles a still-present message at the ceiling back onto its source", async () => {
771
810
  moveThrows();
772
811
 
773
812
  await handleMessageDelete(moveEvent, noopLog, 3, deps());
774
813
 
775
- assert.deepEqual(called("message.update").at(-1)?.args[1], {
776
- status: "active",
777
- syncStatus: "failed",
778
- });
814
+ assert.deepEqual(called("message.updateUid").at(-1)?.args, [
815
+ "msg-1",
816
+ 10,
817
+ "src-mbx",
818
+ ]);
779
819
  assert.equal(await imapFailures("MESSAGE_DELETE_EXHAUSTED"), 1);
780
820
  });
781
821
  });
@@ -2,7 +2,6 @@ import { getClient } from "@remit/backend/client";
2
2
  import type {
3
3
  IMessageRepository,
4
4
  IThreadMessageRepository,
5
- ThreadMessageItem,
6
5
  } from "@remit/data-ports";
7
6
  import { isCurrentSchemaVersion } from "@remit/data-ports/mutation-events";
8
7
  import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
@@ -24,6 +23,10 @@ import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
24
23
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
25
24
  import { resolveExhaustedMessageDeleteFailure } from "./message-delete-terminal.js";
26
25
  import { emitMoveResync, searchMailboxByMessageId } from "./message-move.js";
26
+ import {
27
+ buildThreadMessageMoveRevert,
28
+ buildThreadMessageTrashUpdate,
29
+ } from "./thread-message-rows.js";
27
30
 
28
31
  /**
29
32
  * Fallback when `MESSAGE_DELETE_MAX_ATTEMPTS` is unset (local dev, unit tests).
@@ -73,82 +76,6 @@ export const deleteAllThreadMessagesForMessage = async (
73
76
  return rows.length;
74
77
  };
75
78
 
76
- /**
77
- * Build the `set` and `composites` payload for the ThreadMessage update on a
78
- * MESSAGE_DELETE move-to-trash.
79
- *
80
- * The CURRENT row state goes in `composites`; the NEW values go in `set`.
81
- * ElectroDB uses `composites` to run the conditional check on the existing row
82
- * AND to compute the previous sort-key values needed to recompute the new ones.
83
- * Passing the NEW values in `composites` makes the conditional check fail with
84
- * ConditionalCheckFailedException, which ElectroDB wraps as NotFoundError, and
85
- * the caller silently drops the update. Same root cause as PR #186 fixed for
86
- * `flag-queue.ts`.
87
- */
88
- type ThreadMessageRowState = Pick<
89
- ThreadMessageItem,
90
- | "sentDate"
91
- | "mailboxId"
92
- | "isRead"
93
- | "isDeleted"
94
- | "hasStars"
95
- | "hasAttachment"
96
- >;
97
-
98
- const currentComposites = (threadMessage: ThreadMessageRowState) => ({
99
- sentDate: threadMessage.sentDate,
100
- mailboxId: threadMessage.mailboxId,
101
- isRead: threadMessage.isRead,
102
- isDeleted: threadMessage.isDeleted,
103
- hasStars: threadMessage.hasStars,
104
- hasAttachment: threadMessage.hasAttachment,
105
- });
106
-
107
- export const buildThreadMessageTrashUpdate = (
108
- threadMessage: ThreadMessageRowState,
109
- newUid: number,
110
- destinationMailboxId: string,
111
- ) => ({
112
- set: {
113
- uid: newUid,
114
- mailboxId: destinationMailboxId,
115
- isDeleted: true,
116
- },
117
- composites: currentComposites(threadMessage),
118
- });
119
-
120
- /**
121
- * The inverse payload: put the thread row back where the message still is on
122
- * the server. The delete was recorded optimistically, so a delete abandoned
123
- * before any IMAP write has to hand the row back rather than leave it claiming
124
- * Trash — an invisible `failed` on a row the user cannot see is the shape of
125
- * the incident this whole change is about.
126
- */
127
- export const buildThreadMessageMoveRevert = (
128
- threadMessage: ThreadMessageRowState,
129
- sourceUid: number,
130
- sourceMailboxId: string,
131
- ) => ({
132
- set: {
133
- uid: sourceUid,
134
- mailboxId: sourceMailboxId,
135
- isDeleted: false,
136
- },
137
- composites: currentComposites(threadMessage),
138
- });
139
-
140
- /**
141
- * Hand a row back after an abandoned expunge. The mail never left Trash, so
142
- * only the deletion mark reverts — the uid and mailbox on the row are still
143
- * where the server has it.
144
- */
145
- export const buildThreadMessageUndelete = (
146
- threadMessage: ThreadMessageRowState,
147
- ) => ({
148
- set: { isDeleted: false },
149
- composites: currentComposites(threadMessage),
150
- });
151
-
152
79
  /**
153
80
  * Settle a move to Trash the server left unconfirmed, by asking it two
154
81
  * read-only questions instead of one. Both handles wrap the SAME connection but
@@ -349,6 +276,7 @@ export const handleMessageDelete = async (
349
276
  accountId,
350
277
  accountConfigId,
351
278
  messageId,
279
+ sourceMailboxId: mailboxId,
352
280
  uid,
353
281
  sourceMailboxPath: mailboxPath,
354
282
  getConnection,
@@ -362,11 +290,9 @@ export const handleMessageDelete = async (
362
290
  // Both verdicts end in a row the server has contradicted, so this delete
363
291
  // reconciles rather than waits (R2): whichever folder actually holds the
364
292
  // message re-projects it with the server's own uid. RECONCILED, the local
365
- // rows are gone and the resync rebuilds them. BROKEN, the server has just
366
- // confirmed the message is still at the source while the optimistic
367
- // `updateForMove` left the row pointing at Trash — without the resync the
368
- // user sees it in Trash for as long as that row stands, which is the
369
- // silent misdirection the settle exists to end.
293
+ // rows are gone and the resync rebuilds them. BROKEN, the row has just
294
+ // been put back at the source the server confirmed, and the resync is
295
+ // what carries any drift either folder has picked up since.
370
296
  if (destinationMailboxId) {
371
297
  await emitMoveResync(emitEvent, {
372
298
  accountId,
@@ -0,0 +1,77 @@
1
+ import type { ThreadMessageItem } from "@remit/data-ports";
2
+
3
+ /**
4
+ * Build the `set` and `composites` payload for the ThreadMessage update on a
5
+ * MESSAGE_DELETE move-to-trash.
6
+ *
7
+ * The CURRENT row state goes in `composites`; the NEW values go in `set`.
8
+ * ElectroDB uses `composites` to run the conditional check on the existing row
9
+ * AND to compute the previous sort-key values needed to recompute the new ones.
10
+ * Passing the NEW values in `composites` makes the conditional check fail with
11
+ * ConditionalCheckFailedException, which ElectroDB wraps as NotFoundError, and
12
+ * the caller silently drops the update. Same root cause as PR #186 fixed for
13
+ * `flag-queue.ts`.
14
+ */
15
+ export type ThreadMessageRowState = Pick<
16
+ ThreadMessageItem,
17
+ | "sentDate"
18
+ | "mailboxId"
19
+ | "isRead"
20
+ | "isDeleted"
21
+ | "hasStars"
22
+ | "hasAttachment"
23
+ >;
24
+
25
+ const currentComposites = (threadMessage: ThreadMessageRowState) => ({
26
+ sentDate: threadMessage.sentDate,
27
+ mailboxId: threadMessage.mailboxId,
28
+ isRead: threadMessage.isRead,
29
+ isDeleted: threadMessage.isDeleted,
30
+ hasStars: threadMessage.hasStars,
31
+ hasAttachment: threadMessage.hasAttachment,
32
+ });
33
+
34
+ export const buildThreadMessageTrashUpdate = (
35
+ threadMessage: ThreadMessageRowState,
36
+ newUid: number,
37
+ destinationMailboxId: string,
38
+ ) => ({
39
+ set: {
40
+ uid: newUid,
41
+ mailboxId: destinationMailboxId,
42
+ isDeleted: true,
43
+ },
44
+ composites: currentComposites(threadMessage),
45
+ });
46
+
47
+ /**
48
+ * The inverse payload: put the thread row back where the message still is on
49
+ * the server. The delete was recorded optimistically, so a delete that gives up
50
+ * with the message still at its source has to hand the row back rather than
51
+ * leave it claiming Trash — an invisible `failed` on a row the user cannot see
52
+ * is the shape of the incident this whole change is about.
53
+ */
54
+ export const buildThreadMessageMoveRevert = (
55
+ threadMessage: ThreadMessageRowState,
56
+ sourceUid: number,
57
+ sourceMailboxId: string,
58
+ ) => ({
59
+ set: {
60
+ uid: sourceUid,
61
+ mailboxId: sourceMailboxId,
62
+ isDeleted: false,
63
+ },
64
+ composites: currentComposites(threadMessage),
65
+ });
66
+
67
+ /**
68
+ * Hand a row back after an abandoned expunge. The mail never left Trash, so
69
+ * only the deletion mark reverts — the uid and mailbox on the row are still
70
+ * where the server has it.
71
+ */
72
+ export const buildThreadMessageUndelete = (
73
+ threadMessage: ThreadMessageRowState,
74
+ ) => ({
75
+ set: { isDeleted: false },
76
+ composites: currentComposites(threadMessage),
77
+ });