@remit/imap-worker 0.0.72 → 0.0.74

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.
@@ -13,12 +13,13 @@ import type {
13
13
  IThreadMessageRepository,
14
14
  IUnitOfWork,
15
15
  } from "@remit/data-ports";
16
+ import { isNotFoundError } from "@remit/data-ports/errors";
16
17
  import { SyncPhase } from "@remit/domain-enums";
17
18
  import { type Logger, recordImapFailure } from "@remit/logger-lambda";
18
19
  import { RefreshTokenError } from "@remit/mail-oauth-service";
19
20
  import {
20
21
  createManagedConnectionFactory,
21
- isMailboxNotOnServer,
22
+ isMailboxMutationInFlight,
22
23
  MailConnectionError,
23
24
  type MailCredentials,
24
25
  MessageSyncService,
@@ -33,7 +34,6 @@ import type {
33
34
  SyncMessageBodyEvent,
34
35
  SyncMessagesEvent,
35
36
  } from "../events.js";
36
- import { isNotFoundError } from "../is-not-found.js";
37
37
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
38
38
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
39
39
  import { workerVersion } from "../worker-version.js";
@@ -133,17 +133,23 @@ export const syncMessages = async (
133
133
  return;
134
134
  }
135
135
 
136
- // A SYNC_MESSAGES trigger can address a folder the server does not hold: the
137
- // fan-out enqueues one for a folder whose own create has not landed yet, and
138
- // a `hasMore` next-batch or a periodic tick outlives a folder the user
139
- // deleted. Such an event can never succeed on retry, and the account's
140
- // per-group FIFO ordering lets that one poison message stall the
141
- // whole account's message pipeline for the full visibility window (issue
142
- // #287, #339). A folder the server does not hold — not yet created, or being
143
- // deleted — is an expected terminal outcome, not an infra failure: ack the
144
- // event with a WARN.
136
+ // A SYNC_MESSAGES trigger can address a folder a mutation still owns: the
137
+ // fan-out enqueues one for a folder whose own create has not landed yet, a
138
+ // `hasMore` next-batch or a periodic tick outlives a folder the user
139
+ // deleted, and a rename in flight leaves the folder on the server but under
140
+ // the worker that is moving it. Such an event can never succeed on retry,
141
+ // and the account's per-group FIFO ordering lets that one poison message
142
+ // stall the whole account's message pipeline for the full visibility window
143
+ // (issue #287, #339). A folder with a create, rename or delete outstanding
144
+ // is an expected terminal outcome, not an infra failure: ack the event with
145
+ // a WARN. The rename case is conservative rather than necessary — syncing it
146
+ // would be safe — and keeps the guard a single expression (D12).
145
147
  if (
146
- await isMailboxNotOnServer(mailboxService, event.accountId, event.mailboxId)
148
+ await isMailboxMutationInFlight(
149
+ mailboxService,
150
+ event.accountId,
151
+ event.mailboxId,
152
+ )
147
153
  ) {
148
154
  log.warn(
149
155
  {
@@ -152,7 +158,7 @@ export const syncMessages = async (
152
158
  eventId: event.eventId,
153
159
  event: event.type,
154
160
  },
155
- "Skipping SYNC_MESSAGES: the server does not hold this folder",
161
+ "Skipping SYNC_MESSAGES: a folder mutation is in flight",
156
162
  );
157
163
  return;
158
164
  }
@@ -214,7 +220,7 @@ export const syncMessages = async (
214
220
  // never become the failure that is reported. A read that cannot answer
215
221
  // leaves the round on the loud path below, with the real error and the
216
222
  // state write that goes with it intact.
217
- const folderIsGone = await isMailboxNotOnServer(
223
+ const folderIsGone = await isMailboxMutationInFlight(
218
224
  mailboxService,
219
225
  event.accountId,
220
226
  event.mailboxId,
@@ -1,135 +0,0 @@
1
- import type {
2
- IMessageRepository,
3
- IThreadMessageRepository,
4
- MessageItem,
5
- ThreadMessageItem,
6
- } from "@remit/data-ports";
7
- import { MessageStatus } from "@remit/domain-enums";
8
- import { isNotFoundError } from "../is-not-found.js";
9
- import { buildThreadMessageMoveRevert } from "./thread-message-rows.js";
10
-
11
- export interface RestoreSourcePlacementDeps {
12
- messageService: Pick<IMessageRepository, "updateForMove">;
13
- threadMessageService: Pick<
14
- IThreadMessageRepository,
15
- "findAllByMessageId" | "update"
16
- >;
17
- }
18
-
19
- export interface RestoreSourcePlacementInput {
20
- accountConfigId: string;
21
- messageId: string;
22
- sourceMailboxId: string;
23
- uid: number;
24
- /**
25
- * What the row records about the mutation that will now never happen.
26
- * `synced` where the row is a faithful projection of the source again — the
27
- * server never received the command, or has just said the message is still
28
- * there. `failed` where the product refused an operation the user asked for,
29
- * which is a failure the row has to carry.
30
- */
31
- syncStatus: MessageItem["syncStatus"];
32
- /**
33
- * The listing rows, where the caller has already read them to pick this
34
- * outcome. Omitted, they are read here.
35
- */
36
- threadMessages?: ThreadMessageItem[];
37
- }
38
-
39
- /**
40
- * Put a row back on the pre-mutation source pair and hand its listing rows back
41
- * with it.
42
- *
43
- * `status: moving` has to be cleared here or the row is stuck: a give-up that
44
- * writes `syncStatus: failed` and returns leaves the row naming the destination
45
- * with the source's uid — the pair `bindsForeignUid` calls a lie and
46
- * `MessagePlacementUnsettledError` refuses to act on. Nothing routine repairs it
47
- * (sync does not touch `status`, and the cursor rebuild's `updateUid` runs only
48
- * on a UIDVALIDITY change), so the message is undeletable and unmovable for good
49
- * (issue #1005).
50
- *
51
- * `updateForMove`, not `updateUid`: both write the same settled pair, but
52
- * `updateUid` also appends a `message.moved` outbox row so the search index can
53
- * refresh the mailbox it stores per message. Nothing here moved. The optimistic
54
- * `updateForMove` that pointed the row at the destination never enqueued one
55
- * either, so the index still holds the source and a revert has nothing to
56
- * correct — the re-index would be a forced pass over an unchanged document.
57
- *
58
- * The caller must have evidence, not an inference: this writes a placement, and
59
- * a placement written on a guess binds live mail to a uid that names somebody
60
- * else's message. Two kinds of caller qualify, and nothing else does.
61
- *
62
- * A terminal resolver reaches here from {@link isMessageGoneFromOpenMailbox}
63
- * answering that the message is STILL at `sourceMailboxId`/`uid`, which makes
64
- * this the server's own answer rather than a revert on ambiguity (what PR #652
65
- * was pulled for).
66
- *
67
- * A paused-cursor caller reaches here from a `MailboxCursorPausedError` the
68
- * openBox guard threw before the outbound command was issued (issue #1203), and
69
- * restores the pre-mutation pair off its own event. On a FIRST delivery that is
70
- * not an inference but an undo: the command provably never left, so the row is
71
- * put back exactly as it was found. A redelivery cannot claim that — the
72
- * earlier attempt's tagged OK can be lost with the connection — so it asks
73
- * `probePausedPlacement` which folder holds the message and only reaches here
74
- * once the answer rules the destination out. Either way the source mailbox is
75
- * by definition awaiting a cursor rebuild, which matches its rows by Message-ID
76
- * and adjudicates the uid: handing the row back is what puts it in the set the
77
- * rebuild walks, since a row still naming the destination is in neither
78
- * folder's.
79
- *
80
- * A row another path deleted while the probe was in flight has nothing left to
81
- * restore, and a thread row whose composites have moved on is being rewritten
82
- * by that other path anyway. Both surface as `NotFoundError` — ElectroDB wraps
83
- * the conditional-check miss as one — and both are settled states here, not
84
- * faults. Throwing on them would escape the caller's `.catch()` and cost the
85
- * record its ack and its resync, which is the contract the terminal resolvers
86
- * state: never re-thrown.
87
- */
88
- export const restoreSourcePlacement = async (
89
- deps: RestoreSourcePlacementDeps,
90
- input: RestoreSourcePlacementInput,
91
- ): Promise<void> => {
92
- const { accountConfigId, messageId, sourceMailboxId, uid, syncStatus } =
93
- input;
94
-
95
- const skipNotFound = (error: unknown): void => {
96
- if (isNotFoundError(error)) return;
97
- throw error;
98
- };
99
-
100
- const messageRestored = await deps.messageService
101
- .updateForMove(messageId, {
102
- mailboxId: sourceMailboxId,
103
- uid,
104
- status: MessageStatus.active,
105
- syncStatus,
106
- })
107
- .then(() => true)
108
- .catch((error: unknown) => {
109
- skipNotFound(error);
110
- return false;
111
- });
112
- if (!messageRestored) return;
113
-
114
- const threadMessages =
115
- input.threadMessages ??
116
- (await deps.threadMessageService.findAllByMessageId(
117
- accountConfigId,
118
- messageId,
119
- ));
120
- for (const threadMessage of threadMessages) {
121
- const args = buildThreadMessageMoveRevert(
122
- threadMessage,
123
- uid,
124
- sourceMailboxId,
125
- );
126
- await deps.threadMessageService
127
- .update(
128
- threadMessage.accountConfigId,
129
- threadMessage.threadMessageId,
130
- args.set,
131
- { composites: args.composites },
132
- )
133
- .catch(skipNotFound);
134
- }
135
- };
@@ -1,77 +0,0 @@
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
- });
@@ -1,17 +0,0 @@
1
- /**
2
- * A repository lookup or write for a row that no longer exists rejects with a
3
- * `NotFoundError` (name-matched, since the class crosses the data-ports adapter
4
- * boundary — see `@remit/data-ports/errors`).
5
- *
6
- * A worker event that references a deliberately-deleted mailbox is completed or
7
- * moot work, never a transient fault: every redelivery re-throws the same
8
- * `NotFoundError`, and because the sync queues carry `MessageGroupId=accountId`
9
- * that permanently-failing head message stalls the whole account's per-group
10
- * FIFO. Handlers use this predicate to resolve such an event terminally (ack
11
- * with a WARN) instead of retrying forever (issues #287, #289, #290).
12
- *
13
- * The guard is narrow on purpose: only a genuine not-found terminates. Real
14
- * IMAP/infra failures carry other errors and must still propagate to be retried.
15
- */
16
- export const isNotFoundError = (error: unknown): boolean =>
17
- (error as { name?: string })?.name === "NotFoundError";