@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.
@@ -4,7 +4,10 @@ import type {
4
4
  IMessageRepository,
5
5
  IThreadMessageRepository,
6
6
  } from "@remit/data-ports";
7
- import type { IImapConnection } from "@remit/mailbox-service";
7
+ import {
8
+ type IImapConnection,
9
+ placementBindingOf,
10
+ } from "@remit/mailbox-service";
8
11
  import {
9
12
  type MessageMoveTerminalLogger,
10
13
  type ResolveExhaustedMessageMoveDeps,
@@ -60,21 +63,33 @@ interface MessageRow {
60
63
  originalUid: number;
61
64
  }
62
65
 
66
+ interface ThreadRow {
67
+ accountConfigId: string;
68
+ threadMessageId: string;
69
+ mailboxId: string;
70
+ uid: number;
71
+ isDeleted: boolean;
72
+ }
73
+
63
74
  /**
64
75
  * The move's pending state IS the Message row, so these fakes hold real rows
65
- * and the assertions read the rows back — a resolver that reverted the
66
- * optimistic move (PR #652's defect) would show up here as a changed row, not
67
- * as an uncalled mock.
76
+ * and the assertions read the rows back — a resolver that settled the row on
77
+ * anything but the server's own answer shows up here as a changed row, not as
78
+ * an uncalled mock. `updateForMove` writes what the repository writes: whatever
79
+ * fields the caller set, and nothing it left out.
68
80
  */
69
81
  const buildRepositories = (row: MessageRow) => {
70
82
  const messages = new Map<string, MessageRow>([[row.messageId, row]]);
71
- const threadMessages = new Map<
72
- string,
73
- { accountConfigId: string; threadMessageId: string }
74
- >([
83
+ const threadMessages = new Map<string, ThreadRow>([
75
84
  [
76
85
  `tm-${row.messageId}`,
77
- { accountConfigId: "cfg-1", threadMessageId: `tm-${row.messageId}` },
86
+ {
87
+ accountConfigId: "cfg-1",
88
+ threadMessageId: `tm-${row.messageId}`,
89
+ mailboxId: row.mailboxId,
90
+ uid: row.uid,
91
+ isDeleted: false,
92
+ },
78
93
  ],
79
94
  ]);
80
95
 
@@ -89,7 +104,11 @@ const buildRepositories = (row: MessageRow) => {
89
104
  const current = messages.get(messageId);
90
105
  if (current) messages.set(messageId, { ...current, ...input });
91
106
  },
92
- } as unknown as Pick<IMessageRepository, "delete">,
107
+ updateForMove: async (messageId: string, set: Partial<MessageRow>) => {
108
+ const current = messages.get(messageId);
109
+ if (current) messages.set(messageId, { ...current, ...set });
110
+ },
111
+ } as unknown as Pick<IMessageRepository, "delete" | "updateForMove">,
93
112
  threadMessageService: {
94
113
  findAllByMessageId: async () => [...threadMessages.values()],
95
114
  deleteMany: async (
@@ -97,9 +116,18 @@ const buildRepositories = (row: MessageRow) => {
97
116
  ) => {
98
117
  for (const key of keys) threadMessages.delete(key.threadMessageId);
99
118
  },
119
+ update: async (
120
+ _accountConfigId: string,
121
+ threadMessageId: string,
122
+ set: Partial<ThreadRow>,
123
+ ) => {
124
+ const current = threadMessages.get(threadMessageId);
125
+ if (current)
126
+ threadMessages.set(threadMessageId, { ...current, ...set });
127
+ },
100
128
  } as unknown as Pick<
101
129
  IThreadMessageRepository,
102
- "findAllByMessageId" | "deleteMany"
130
+ "findAllByMessageId" | "deleteMany" | "update"
103
131
  >,
104
132
  };
105
133
  };
@@ -118,6 +146,7 @@ const input = {
118
146
  accountId: "acc-1",
119
147
  accountConfigId: "cfg-1",
120
148
  messageId: "msg-1",
149
+ sourceMailboxId: "mbx-inbox",
121
150
  uid: 101,
122
151
  sourceMailboxPath: "INBOX",
123
152
  };
@@ -150,7 +179,7 @@ describe("resolveExhaustedMessageMoveFailure — the two terminal outcomes (issu
150
179
  );
151
180
  });
152
181
 
153
- it("BROKEN: the message is still at the source — the row is left EXACTLY as it stands, alarm logged, never re-thrown", async () => {
182
+ it("BROKEN: the message is still at the source — the row is put back on the pair the server confirmed, alarm logged, never re-thrown", async () => {
154
183
  const repos = buildRepositories(pendingMoveRow());
155
184
  const { log, errors } = buildLogger();
156
185
  const deps: ResolveExhaustedMessageMoveDeps = {
@@ -165,15 +194,45 @@ describe("resolveExhaustedMessageMoveFailure — the two terminal outcomes (issu
165
194
  });
166
195
 
167
196
  assert.equal(result.outcome, "broken");
168
- assert.deepEqual(
169
- repos.messages.get("msg-1"),
170
- pendingMoveRow(),
171
- "a move that never reached the server is never reverted locally (PR #652)",
197
+ assert.equal(repos.messages.get("msg-1")?.mailboxId, "mbx-inbox");
198
+ assert.equal(repos.messages.get("msg-1")?.uid, 101);
199
+ assert.equal(
200
+ repos.threadMessages.get("tm-msg-1")?.mailboxId,
201
+ "mbx-inbox",
202
+ "the listing row follows the message back to the folder it never left",
172
203
  );
173
- assert.equal(repos.threadMessages.size, 1);
174
204
  assert.ok(errors.some((e) => e.obj.alert === "message_move_failed"));
175
205
  });
176
206
 
207
+ // Issue #1005: a give-up that never writes `status` leaves the row naming
208
+ // the destination with the source's uid — a pair `bindsForeignUid` refuses,
209
+ // which made the message undeletable and unmovable for good.
210
+ it("BROKEN: the settled row is no longer refused by the placement guard (#1005)", async () => {
211
+ const repos = buildRepositories(pendingMoveRow());
212
+ const { log } = buildLogger();
213
+ const deps: ResolveExhaustedMessageMoveDeps = {
214
+ messageService: repos.messageService,
215
+ threadMessageService: repos.threadMessageService,
216
+ log,
217
+ };
218
+
219
+ assert.equal(
220
+ placementBindingOf(pendingMoveRow() as never),
221
+ "abandoned",
222
+ "the row starts in the state the delete route refuses as unverified",
223
+ );
224
+
225
+ await resolveExhaustedMessageMoveFailure(deps, {
226
+ ...input,
227
+ getConnection: async () => buildConnection(new Set([101])),
228
+ });
229
+
230
+ const settled = repos.messages.get("msg-1");
231
+ assert.ok(settled);
232
+ assert.equal(placementBindingOf(settled as never), "consistent");
233
+ assert.equal(settled.status, "active");
234
+ });
235
+
177
236
  it("BROKEN: an empty FETCH the SEARCH contradicts never counts as gone", async () => {
178
237
  const repos = buildRepositories(pendingMoveRow());
179
238
  const { log } = buildLogger();
@@ -190,7 +249,93 @@ describe("resolveExhaustedMessageMoveFailure — the two terminal outcomes (issu
190
249
  });
191
250
 
192
251
  assert.equal(result.outcome, "broken");
193
- assert.deepEqual(repos.messages.get("msg-1"), pendingMoveRow());
252
+ assert.equal(
253
+ repos.messages.get("msg-1")?.mailboxId,
254
+ "mbx-inbox",
255
+ "a message the SEARCH still lists is at the source, and the row says so",
256
+ );
257
+ });
258
+
259
+ // The resolver's contract is that it is never re-thrown, so the caller can
260
+ // ack the record and emit its resync. A row another path deleted while the
261
+ // probe was in flight has nothing left to restore, and the same NotFound
262
+ // wraps an ElectroDB composites miss on the listing row.
263
+ it("BROKEN: a row deleted underneath the probe settles instead of throwing", async () => {
264
+ const repos = buildRepositories(pendingMoveRow());
265
+ const notFound = Object.assign(new Error("Message not found: msg-1"), {
266
+ name: "NotFoundError",
267
+ });
268
+ const { log } = buildLogger();
269
+ const deps: ResolveExhaustedMessageMoveDeps = {
270
+ messageService: {
271
+ ...repos.messageService,
272
+ updateForMove: async () => {
273
+ throw notFound;
274
+ },
275
+ } as unknown as ResolveExhaustedMessageMoveDeps["messageService"],
276
+ threadMessageService: repos.threadMessageService,
277
+ log,
278
+ };
279
+
280
+ const result = await resolveExhaustedMessageMoveFailure(deps, {
281
+ ...input,
282
+ getConnection: async () => buildConnection(new Set([101])),
283
+ });
284
+
285
+ assert.equal(result.outcome, "broken");
286
+ });
287
+
288
+ it("BROKEN: a listing row whose composites moved on does not fail the settle", async () => {
289
+ const repos = buildRepositories(pendingMoveRow());
290
+ const { log } = buildLogger();
291
+ const deps: ResolveExhaustedMessageMoveDeps = {
292
+ messageService: repos.messageService,
293
+ threadMessageService: {
294
+ ...repos.threadMessageService,
295
+ update: async () => {
296
+ throw Object.assign(new Error("Thread message not found"), {
297
+ name: "NotFoundError",
298
+ });
299
+ },
300
+ } as unknown as ResolveExhaustedMessageMoveDeps["threadMessageService"],
301
+ log,
302
+ };
303
+
304
+ const result = await resolveExhaustedMessageMoveFailure(deps, {
305
+ ...input,
306
+ getConnection: async () => buildConnection(new Set([101])),
307
+ });
308
+
309
+ assert.equal(result.outcome, "broken");
310
+ assert.equal(
311
+ repos.messages.get("msg-1")?.mailboxId,
312
+ "mbx-inbox",
313
+ "the Message row still lands on the pair the server confirmed",
314
+ );
315
+ });
316
+
317
+ it("a repository fault that is not a missing row still propagates", async () => {
318
+ const repos = buildRepositories(pendingMoveRow());
319
+ const { log } = buildLogger();
320
+ const deps: ResolveExhaustedMessageMoveDeps = {
321
+ messageService: {
322
+ ...repos.messageService,
323
+ updateForMove: async () => {
324
+ throw new Error("ProvisionedThroughputExceeded");
325
+ },
326
+ } as unknown as ResolveExhaustedMessageMoveDeps["messageService"],
327
+ threadMessageService: repos.threadMessageService,
328
+ log,
329
+ };
330
+
331
+ await assert.rejects(
332
+ () =>
333
+ resolveExhaustedMessageMoveFailure(deps, {
334
+ ...input,
335
+ getConnection: async () => buildConnection(new Set([101])),
336
+ }),
337
+ /ProvisionedThroughputExceeded/,
338
+ );
194
339
  });
195
340
 
196
341
  it("an unreachable server reaches no verdict at all — the probe propagates and the row is untouched", async () => {
@@ -1,9 +1,15 @@
1
+ import type {
2
+ IMessageRepository,
3
+ IThreadMessageRepository,
4
+ } from "@remit/data-ports";
5
+ import { MessageSyncStatus } from "@remit/domain-enums";
1
6
  import {
2
7
  type IImapConnection,
3
8
  isMessageGoneFromOpenMailbox,
4
9
  reconcileStaleMessage,
5
10
  type StaleMessageReconcileDeps,
6
11
  } from "@remit/mailbox-service";
12
+ import { restoreSourcePlacement } from "./restore-source-placement.js";
7
13
 
8
14
  export interface MessageMoveTerminalLogger {
9
15
  info(obj: Record<string, unknown>, msg: string): void;
@@ -12,6 +18,11 @@ export interface MessageMoveTerminalLogger {
12
18
 
13
19
  export interface ResolveExhaustedMessageMoveDeps
14
20
  extends StaleMessageReconcileDeps {
21
+ messageService: Pick<IMessageRepository, "delete" | "updateForMove">;
22
+ threadMessageService: Pick<
23
+ IThreadMessageRepository,
24
+ "findAllByMessageId" | "deleteMany" | "update"
25
+ >;
15
26
  log: MessageMoveTerminalLogger;
16
27
  }
17
28
 
@@ -19,6 +30,7 @@ export interface ResolveExhaustedMessageMoveInput {
19
30
  accountId: string;
20
31
  accountConfigId: string;
21
32
  messageId: string;
33
+ sourceMailboxId: string;
22
34
  uid: number;
23
35
  sourceMailboxPath: string;
24
36
  getConnection: () => Promise<IImapConnection>;
@@ -51,12 +63,21 @@ export interface ResolveExhaustedMessageMoveResult {
51
63
  * server's own UID. Metric only, no alarm — routine.
52
64
  * 2. BROKEN — the message is still at the source, so the move never took
53
65
  * effect, but it keeps failing: broken code or a broken account, not a
54
- * transient blip. Local state is left exactly as it stands. Reverting the
55
- * optimistic move here is what PR #652 was pulled for: the local row is the
56
- * only record that this move is still owed, and a revert races a MOVE that
57
- * may yet have landed. Logged with an `alert`-shaped entry for an operator
58
- * alarm; never re-thrown (terminal — the caller acks either way, since
59
- * retrying a permanently-broken move can never succeed).
66
+ * transient blip. The row is put back where the server has just said the
67
+ * message is, as `resolveExhaustedMessageDeleteFailure` does (#1098). Logged
68
+ * with an `alert`-shaped entry for an operator alarm; never re-thrown
69
+ * (terminal — the caller acks either way, since retrying a
70
+ * permanently-broken move can never succeed).
71
+ *
72
+ * Leaving the row `moving` is what this replaces. BROKEN is terminal, so
73
+ * nothing was ever going to come back and settle it, and only `updateUid`
74
+ * clears `moving` — the row kept a `mailboxId` naming the destination and a
75
+ * `uid` belonging to the source, which `bindsForeignUid` refuses to act on,
76
+ * leaving the message undeletable and unmovable for good (issue #1005).
77
+ * Restoring the pair is not the revert-on-ambiguity PR #652 was pulled for:
78
+ * the presence probe above has just answered, so this writes a placement the
79
+ * server confirmed, and a MOVE that lands after all is re-projected by the
80
+ * resync the caller runs.
60
81
  *
61
82
  * A server that cannot be reached at exhaustion time never reaches either
62
83
  * verdict: the probe throws and the record dead-letters with the row untouched.
@@ -66,9 +87,10 @@ export interface ResolveExhaustedMessageMoveResult {
66
87
  * message is not actually at the source: a message another client expunged
67
88
  * mid-session can answer an empty FETCH while the server still lists its UID
68
89
  * in SEARCH, until it is allowed to send the untagged EXPUNGE. That message
69
- * lands in BROKEN, and BROKEN is terminal — the row stays pending and the
70
- * alert stands until someone clears it. The reverse mistake discards the row
71
- * for live mail, so the cost is paid deliberately.
90
+ * lands in BROKEN, and BROKEN is terminal — the row is bound to a source the
91
+ * message has already left and the alert stands until someone clears it. The
92
+ * reverse mistake discards the row for live mail, so the cost is paid
93
+ * deliberately.
72
94
  */
73
95
  export const resolveExhaustedMessageMoveFailure = async (
74
96
  deps: ResolveExhaustedMessageMoveDeps,
@@ -78,6 +100,7 @@ export const resolveExhaustedMessageMoveFailure = async (
78
100
  accountId,
79
101
  accountConfigId,
80
102
  messageId,
103
+ sourceMailboxId,
81
104
  uid,
82
105
  sourceMailboxPath,
83
106
  getConnection,
@@ -114,9 +137,22 @@ export const resolveExhaustedMessageMoveFailure = async (
114
137
  accountConfigId,
115
138
  messageId,
116
139
  uid,
140
+ sourceMailboxId,
117
141
  sourceMailboxPath,
118
142
  },
119
- "Message move could not be pushed to IMAP after retry exhaustion; message still exists at its source — local state left pending for operator investigation",
143
+ "Message move could not be pushed to IMAP after retry exhaustion; the message is still at its source — row moved back to the source folder",
120
144
  );
145
+
146
+ await restoreSourcePlacement(deps, {
147
+ accountConfigId,
148
+ messageId,
149
+ sourceMailboxId,
150
+ uid,
151
+ // The probe above just confirmed the pair, so the row is a faithful
152
+ // projection of the source again. The move's own failure is the alert, not
153
+ // a `failed` left on a row nothing is still trying to move.
154
+ syncStatus: MessageSyncStatus.synced,
155
+ });
156
+
121
157
  return { outcome: "broken" };
122
158
  };