@remit/imap-worker 0.0.66 → 0.0.68

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.66",
3
+ "version": "0.0.68",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -25,6 +25,7 @@
25
25
  "dev": "node --import tsx src/e2e-processor-shim.ts"
26
26
  },
27
27
  "devDependencies": {
28
+ "@remit/drizzle-service": "*",
28
29
  "@remit/queue-sidecar": "*",
29
30
  "@remit/storage-service": "*",
30
31
  "aws-sdk-client-mock": "*",
@@ -8,7 +8,7 @@ import {
8
8
  reconcileStaleMessage,
9
9
  type StaleMessageReconcileDeps,
10
10
  } from "@remit/mailbox-service";
11
- import { buildThreadMessageMoveRevert } from "./thread-message-rows.js";
11
+ import { restoreSourcePlacement } from "./restore-source-placement.js";
12
12
 
13
13
  export interface MessageDeleteTerminalLogger {
14
14
  info(obj: Record<string, unknown>, msg: string): void;
@@ -125,25 +125,12 @@ export const resolveExhaustedMessageDeleteFailure = async (
125
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",
126
126
  );
127
127
 
128
- await deps.messageService.updateUid(messageId, uid, sourceMailboxId);
129
-
130
- const threadMessages = await deps.threadMessageService.findAllByMessageId(
128
+ await restoreSourcePlacement(deps, {
131
129
  accountConfigId,
132
130
  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
- }
131
+ sourceMailboxId,
132
+ uid,
133
+ });
147
134
 
148
135
  return { outcome: "broken" };
149
136
  };
@@ -303,6 +303,10 @@ interface Harness {
303
303
  allThreadMessages: Record<string, unknown>[];
304
304
  getConnectionCount: number;
305
305
  disconnectCount: number;
306
+ // Rejections handed to `getConnection`, one per call, oldest first. A
307
+ // dropped connection is how the guarded openBox gets skipped on a delivery
308
+ // that still goes on to settle the row.
309
+ connectionErrors: Error[];
306
310
  }
307
311
 
308
312
  let h: Harness;
@@ -367,6 +371,7 @@ const fresh = (): Harness => ({
367
371
  ],
368
372
  getConnectionCount: 0,
369
373
  disconnectCount: 0,
374
+ connectionErrors: [],
370
375
  });
371
376
 
372
377
  const deps = (): MessageDeleteDeps =>
@@ -417,6 +422,8 @@ const deps = (): MessageDeleteDeps =>
417
422
  createConnectionScope: () => ({
418
423
  getConnection: async () => {
419
424
  h.getConnectionCount += 1;
425
+ const failure = h.connectionErrors.shift();
426
+ if (failure) throw failure;
420
427
  return h.connection;
421
428
  },
422
429
  disconnect: async () => {
@@ -1178,6 +1185,41 @@ describe("handleMessageDelete", () => {
1178
1185
  assert.equal(called("message.updateUid").length, 0);
1179
1186
  });
1180
1187
 
1188
+ // The terminal resolver opens the source itself, and since #1005 its answer
1189
+ // WRITES a placement. A folder deleted and recreated renumbers every uid, so
1190
+ // an unguarded probe finds whatever now sits at this one, reads it as "still
1191
+ // at source", and binds the row to a stranger the next permanent delete
1192
+ // expunges. The dropped connection is what makes it reachable: it skips the
1193
+ // guarded openBox on the very delivery that goes on to settle.
1194
+ it("never settles an exhausted delete off a probe that skipped the cursor guard", async () => {
1195
+ h.connectionErrors = [new Error("ECONNRESET")];
1196
+ h.connection.openBox = async () => ({ uidvalidity: 999 });
1197
+
1198
+ await handleMessageDelete(
1199
+ moveEvent,
1200
+ noopLog,
1201
+ MESSAGE_DELETE_MAX_ATTEMPTS,
1202
+ deps(),
1203
+ );
1204
+
1205
+ assert.equal(
1206
+ called("message.updateUid").length,
1207
+ 0,
1208
+ "a renumbered folder answers for a different message; nothing settles off it",
1209
+ );
1210
+ assert.equal(
1211
+ called("message.delete").length,
1212
+ 0,
1213
+ "and nothing is reconciled away off it either",
1214
+ );
1215
+ assert.equal(
1216
+ (called("mailbox.update")[0]?.args[2] as { cursorState?: string })
1217
+ ?.cursorState,
1218
+ "cursor_invalid",
1219
+ "the probe trips the mailbox into a rebuild instead",
1220
+ );
1221
+ });
1222
+
1181
1223
  it("skips without opening a connection when the cursor is rebuilding", async () => {
1182
1224
  h.mailbox = {
1183
1225
  mailboxId: "src-mbx",
@@ -259,7 +259,7 @@ export const handleMessageDelete = async (
259
259
  accountConfigId: string,
260
260
  getConnection: () => Promise<IImapConnection>,
261
261
  ): Promise<void> => {
262
- const { outcome } = await resolveExhaustedMessageDeleteFailure(
262
+ const settled = await resolveExhaustedMessageDeleteFailure(
263
263
  { messageService, threadMessageService, log },
264
264
  {
265
265
  accountId,
@@ -270,9 +270,25 @@ export const handleMessageDelete = async (
270
270
  sourceMailboxPath: mailboxPath,
271
271
  getConnection,
272
272
  },
273
- );
273
+ ).catch((settleError: unknown) => {
274
+ // The guarded probe found a mailbox whose UIDVALIDITY has moved. Every
275
+ // stored uid for this folder now names something else, so there is
276
+ // nothing here that may be settled off them, and the pause is the
277
+ // routine skip `guardMailboxCursor` documents, not a fault to re-throw
278
+ // out of the caller's catch.
279
+ if (settleError instanceof MailboxCursorPausedError) {
280
+ log.info(
281
+ { accountId, messageId, mailboxId, cursorState: settleError.state },
282
+ "Mailbox cursor not normal; leaving the exhausted delete for the cursor rebuild",
283
+ );
284
+ return null;
285
+ }
286
+ throw settleError;
287
+ });
274
288
 
275
- if (outcome === "broken") {
289
+ if (!settled) return;
290
+
291
+ if (settled.outcome === "broken") {
276
292
  recordImapFailure("MESSAGE_DELETE_EXHAUSTED", "other");
277
293
  }
278
294
 
@@ -380,6 +396,21 @@ export const handleMessageDelete = async (
380
396
 
381
397
  const scope = createConnectionScopeWithCredentials(account, credentials);
382
398
 
399
+ // The terminal resolver opens the source itself, and its answer now
400
+ // WRITES a placement, so it has to reach IMAP through the same
401
+ // UIDVALIDITY choke point every other outbound operation does. An
402
+ // unguarded probe on a folder that was deleted and recreated finds
403
+ // whatever the server has renumbered onto this uid, reads it as "still
404
+ // at source", and binds the row to a stranger the next permanent delete
405
+ // would expunge.
406
+ const getGuardedConnection = async (): Promise<IImapConnection> =>
407
+ guardConnectionCursor(
408
+ await scope.getConnection(),
409
+ { mailboxService },
410
+ accountId,
411
+ mailbox,
412
+ );
413
+
383
414
  await scope
384
415
  .getConnection()
385
416
  .then(async (rawConnection) => {
@@ -490,7 +521,7 @@ export const handleMessageDelete = async (
490
521
  await settleUnconfirmedTrashMove(
491
522
  confirmation,
492
523
  account.accountConfigId,
493
- scope.getConnection,
524
+ getGuardedConnection,
494
525
  );
495
526
  } else {
496
527
  // Permanent delete — reached only by `operation === "permanent_delete"`.
@@ -594,7 +625,7 @@ export const handleMessageDelete = async (
594
625
  // failure this budget exists for.
595
626
  await settleExhaustedDelete(
596
627
  account.accountConfigId,
597
- scope.getConnection,
628
+ getGuardedConnection,
598
629
  );
599
630
  log.error(
600
631
  { accountId, messageId, uid, mailboxPath, error: errorMessage },
@@ -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. `updateUid` writes what the repository writes: the settled
79
+ * pair AND the `active`/`synced` that clears `moving`.
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,22 @@ 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
+ updateUid: async (
108
+ messageId: string,
109
+ newUid: number,
110
+ newMailboxId: string,
111
+ ) => {
112
+ const current = messages.get(messageId);
113
+ if (current)
114
+ messages.set(messageId, {
115
+ ...current,
116
+ uid: newUid,
117
+ mailboxId: newMailboxId,
118
+ status: "active",
119
+ syncStatus: "synced",
120
+ });
121
+ },
122
+ } as unknown as Pick<IMessageRepository, "delete" | "updateUid">,
93
123
  threadMessageService: {
94
124
  findAllByMessageId: async () => [...threadMessages.values()],
95
125
  deleteMany: async (
@@ -97,9 +127,18 @@ const buildRepositories = (row: MessageRow) => {
97
127
  ) => {
98
128
  for (const key of keys) threadMessages.delete(key.threadMessageId);
99
129
  },
130
+ update: async (
131
+ _accountConfigId: string,
132
+ threadMessageId: string,
133
+ set: Partial<ThreadRow>,
134
+ ) => {
135
+ const current = threadMessages.get(threadMessageId);
136
+ if (current)
137
+ threadMessages.set(threadMessageId, { ...current, ...set });
138
+ },
100
139
  } as unknown as Pick<
101
140
  IThreadMessageRepository,
102
- "findAllByMessageId" | "deleteMany"
141
+ "findAllByMessageId" | "deleteMany" | "update"
103
142
  >,
104
143
  };
105
144
  };
@@ -118,6 +157,7 @@ const input = {
118
157
  accountId: "acc-1",
119
158
  accountConfigId: "cfg-1",
120
159
  messageId: "msg-1",
160
+ sourceMailboxId: "mbx-inbox",
121
161
  uid: 101,
122
162
  sourceMailboxPath: "INBOX",
123
163
  };
@@ -150,7 +190,7 @@ describe("resolveExhaustedMessageMoveFailure — the two terminal outcomes (issu
150
190
  );
151
191
  });
152
192
 
153
- it("BROKEN: the message is still at the source — the row is left EXACTLY as it stands, alarm logged, never re-thrown", async () => {
193
+ 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
194
  const repos = buildRepositories(pendingMoveRow());
155
195
  const { log, errors } = buildLogger();
156
196
  const deps: ResolveExhaustedMessageMoveDeps = {
@@ -165,15 +205,46 @@ describe("resolveExhaustedMessageMoveFailure — the two terminal outcomes (issu
165
205
  });
166
206
 
167
207
  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)",
208
+ assert.equal(repos.messages.get("msg-1")?.mailboxId, "mbx-inbox");
209
+ assert.equal(repos.messages.get("msg-1")?.uid, 101);
210
+ assert.equal(
211
+ repos.threadMessages.get("tm-msg-1")?.mailboxId,
212
+ "mbx-inbox",
213
+ "the listing row follows the message back to the folder it never left",
172
214
  );
173
- assert.equal(repos.threadMessages.size, 1);
174
215
  assert.ok(errors.some((e) => e.obj.alert === "message_move_failed"));
175
216
  });
176
217
 
218
+ // Issue #1005: only `updateUid` clears `moving`, so a give-up that returned
219
+ // without calling it left the row naming the destination with the source's
220
+ // uid — a pair `bindsForeignUid` refuses, which made the message
221
+ // undeletable and unmovable for good.
222
+ it("BROKEN: the settled row is no longer refused by the placement guard (#1005)", async () => {
223
+ const repos = buildRepositories(pendingMoveRow());
224
+ const { log } = buildLogger();
225
+ const deps: ResolveExhaustedMessageMoveDeps = {
226
+ messageService: repos.messageService,
227
+ threadMessageService: repos.threadMessageService,
228
+ log,
229
+ };
230
+
231
+ assert.equal(
232
+ placementBindingOf(pendingMoveRow() as never),
233
+ "abandoned",
234
+ "the row starts in the state the delete route refuses as unverified",
235
+ );
236
+
237
+ await resolveExhaustedMessageMoveFailure(deps, {
238
+ ...input,
239
+ getConnection: async () => buildConnection(new Set([101])),
240
+ });
241
+
242
+ const settled = repos.messages.get("msg-1");
243
+ assert.ok(settled);
244
+ assert.equal(placementBindingOf(settled as never), "consistent");
245
+ assert.equal(settled.status, "active");
246
+ });
247
+
177
248
  it("BROKEN: an empty FETCH the SEARCH contradicts never counts as gone", async () => {
178
249
  const repos = buildRepositories(pendingMoveRow());
179
250
  const { log } = buildLogger();
@@ -190,7 +261,93 @@ describe("resolveExhaustedMessageMoveFailure — the two terminal outcomes (issu
190
261
  });
191
262
 
192
263
  assert.equal(result.outcome, "broken");
193
- assert.deepEqual(repos.messages.get("msg-1"), pendingMoveRow());
264
+ assert.equal(
265
+ repos.messages.get("msg-1")?.mailboxId,
266
+ "mbx-inbox",
267
+ "a message the SEARCH still lists is at the source, and the row says so",
268
+ );
269
+ });
270
+
271
+ // The resolver's contract is that it is never re-thrown, so the caller can
272
+ // ack the record and emit its resync. A row another path deleted while the
273
+ // probe was in flight has nothing left to restore, and the same NotFound
274
+ // wraps an ElectroDB composites miss on the listing row.
275
+ it("BROKEN: a row deleted underneath the probe settles instead of throwing", async () => {
276
+ const repos = buildRepositories(pendingMoveRow());
277
+ const notFound = Object.assign(new Error("Message not found: msg-1"), {
278
+ name: "NotFoundError",
279
+ });
280
+ const { log } = buildLogger();
281
+ const deps: ResolveExhaustedMessageMoveDeps = {
282
+ messageService: {
283
+ ...repos.messageService,
284
+ updateUid: async () => {
285
+ throw notFound;
286
+ },
287
+ } as unknown as ResolveExhaustedMessageMoveDeps["messageService"],
288
+ threadMessageService: repos.threadMessageService,
289
+ log,
290
+ };
291
+
292
+ const result = await resolveExhaustedMessageMoveFailure(deps, {
293
+ ...input,
294
+ getConnection: async () => buildConnection(new Set([101])),
295
+ });
296
+
297
+ assert.equal(result.outcome, "broken");
298
+ });
299
+
300
+ it("BROKEN: a listing row whose composites moved on does not fail the settle", async () => {
301
+ const repos = buildRepositories(pendingMoveRow());
302
+ const { log } = buildLogger();
303
+ const deps: ResolveExhaustedMessageMoveDeps = {
304
+ messageService: repos.messageService,
305
+ threadMessageService: {
306
+ ...repos.threadMessageService,
307
+ update: async () => {
308
+ throw Object.assign(new Error("Thread message not found"), {
309
+ name: "NotFoundError",
310
+ });
311
+ },
312
+ } as unknown as ResolveExhaustedMessageMoveDeps["threadMessageService"],
313
+ log,
314
+ };
315
+
316
+ const result = await resolveExhaustedMessageMoveFailure(deps, {
317
+ ...input,
318
+ getConnection: async () => buildConnection(new Set([101])),
319
+ });
320
+
321
+ assert.equal(result.outcome, "broken");
322
+ assert.equal(
323
+ repos.messages.get("msg-1")?.mailboxId,
324
+ "mbx-inbox",
325
+ "the Message row still lands on the pair the server confirmed",
326
+ );
327
+ });
328
+
329
+ it("a repository fault that is not a missing row still propagates", async () => {
330
+ const repos = buildRepositories(pendingMoveRow());
331
+ const { log } = buildLogger();
332
+ const deps: ResolveExhaustedMessageMoveDeps = {
333
+ messageService: {
334
+ ...repos.messageService,
335
+ updateUid: async () => {
336
+ throw new Error("ProvisionedThroughputExceeded");
337
+ },
338
+ } as unknown as ResolveExhaustedMessageMoveDeps["messageService"],
339
+ threadMessageService: repos.threadMessageService,
340
+ log,
341
+ };
342
+
343
+ await assert.rejects(
344
+ () =>
345
+ resolveExhaustedMessageMoveFailure(deps, {
346
+ ...input,
347
+ getConnection: async () => buildConnection(new Set([101])),
348
+ }),
349
+ /ProvisionedThroughputExceeded/,
350
+ );
194
351
  });
195
352
 
196
353
  it("an unreachable server reaches no verdict at all — the probe propagates and the row is untouched", async () => {
@@ -1,9 +1,14 @@
1
+ import type {
2
+ IMessageRepository,
3
+ IThreadMessageRepository,
4
+ } from "@remit/data-ports";
1
5
  import {
2
6
  type IImapConnection,
3
7
  isMessageGoneFromOpenMailbox,
4
8
  reconcileStaleMessage,
5
9
  type StaleMessageReconcileDeps,
6
10
  } from "@remit/mailbox-service";
11
+ import { restoreSourcePlacement } from "./restore-source-placement.js";
7
12
 
8
13
  export interface MessageMoveTerminalLogger {
9
14
  info(obj: Record<string, unknown>, msg: string): void;
@@ -12,6 +17,11 @@ export interface MessageMoveTerminalLogger {
12
17
 
13
18
  export interface ResolveExhaustedMessageMoveDeps
14
19
  extends StaleMessageReconcileDeps {
20
+ messageService: Pick<IMessageRepository, "delete" | "updateUid">;
21
+ threadMessageService: Pick<
22
+ IThreadMessageRepository,
23
+ "findAllByMessageId" | "deleteMany" | "update"
24
+ >;
15
25
  log: MessageMoveTerminalLogger;
16
26
  }
17
27
 
@@ -19,6 +29,7 @@ export interface ResolveExhaustedMessageMoveInput {
19
29
  accountId: string;
20
30
  accountConfigId: string;
21
31
  messageId: string;
32
+ sourceMailboxId: string;
22
33
  uid: number;
23
34
  sourceMailboxPath: string;
24
35
  getConnection: () => Promise<IImapConnection>;
@@ -51,12 +62,21 @@ export interface ResolveExhaustedMessageMoveResult {
51
62
  * server's own UID. Metric only, no alarm — routine.
52
63
  * 2. BROKEN — the message is still at the source, so the move never took
53
64
  * 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).
65
+ * transient blip. The row is put back where the server has just said the
66
+ * message is, as `resolveExhaustedMessageDeleteFailure` does (#1098). Logged
67
+ * with an `alert`-shaped entry for an operator alarm; never re-thrown
68
+ * (terminal — the caller acks either way, since retrying a
69
+ * permanently-broken move can never succeed).
70
+ *
71
+ * Leaving the row `moving` is what this replaces. BROKEN is terminal, so
72
+ * nothing was ever going to come back and settle it, and only `updateUid`
73
+ * clears `moving` — the row kept a `mailboxId` naming the destination and a
74
+ * `uid` belonging to the source, which `bindsForeignUid` refuses to act on,
75
+ * leaving the message undeletable and unmovable for good (issue #1005).
76
+ * Restoring the pair is not the revert-on-ambiguity PR #652 was pulled for:
77
+ * the presence probe above has just answered, so this writes a placement the
78
+ * server confirmed, and a MOVE that lands after all is re-projected by the
79
+ * resync the caller runs.
60
80
  *
61
81
  * A server that cannot be reached at exhaustion time never reaches either
62
82
  * verdict: the probe throws and the record dead-letters with the row untouched.
@@ -66,9 +86,10 @@ export interface ResolveExhaustedMessageMoveResult {
66
86
  * message is not actually at the source: a message another client expunged
67
87
  * mid-session can answer an empty FETCH while the server still lists its UID
68
88
  * 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.
89
+ * lands in BROKEN, and BROKEN is terminal — the row is bound to a source the
90
+ * message has already left and the alert stands until someone clears it. The
91
+ * reverse mistake discards the row for live mail, so the cost is paid
92
+ * deliberately.
72
93
  */
73
94
  export const resolveExhaustedMessageMoveFailure = async (
74
95
  deps: ResolveExhaustedMessageMoveDeps,
@@ -78,6 +99,7 @@ export const resolveExhaustedMessageMoveFailure = async (
78
99
  accountId,
79
100
  accountConfigId,
80
101
  messageId,
102
+ sourceMailboxId,
81
103
  uid,
82
104
  sourceMailboxPath,
83
105
  getConnection,
@@ -114,9 +136,18 @@ export const resolveExhaustedMessageMoveFailure = async (
114
136
  accountConfigId,
115
137
  messageId,
116
138
  uid,
139
+ sourceMailboxId,
117
140
  sourceMailboxPath,
118
141
  },
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",
142
+ "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
143
  );
144
+
145
+ await restoreSourcePlacement(deps, {
146
+ accountConfigId,
147
+ messageId,
148
+ sourceMailboxId,
149
+ uid,
150
+ });
151
+
121
152
  return { outcome: "broken" };
122
153
  };
@@ -257,6 +257,21 @@ export const handleMessageMove = async (
257
257
 
258
258
  const scope = createConnectionScopeWithCredentials(account, credentials);
259
259
 
260
+ // The terminal resolver opens the source itself, and its answer now
261
+ // WRITES a placement, so it has to reach IMAP through the same
262
+ // UIDVALIDITY choke point every other outbound operation does. An
263
+ // unguarded probe on a folder that was deleted and recreated finds
264
+ // whatever the server has renumbered onto this uid, reads it as "still
265
+ // at source", and binds the row to a stranger the next permanent delete
266
+ // would expunge.
267
+ const getGuardedConnection = async (): Promise<IImapConnection> =>
268
+ guardConnectionCursor(
269
+ await scope.getConnection(),
270
+ { mailboxService },
271
+ accountId,
272
+ mailbox,
273
+ );
274
+
260
275
  await scope
261
276
  .getConnection()
262
277
  .then((rawConnection) => {
@@ -395,36 +410,62 @@ export const handleMessageMove = async (
395
410
  // Redelivery budget exhausted: resolve into exactly one of the two
396
411
  // terminal outcomes instead of dead-lettering with no diagnosis,
397
412
  // and never by inferring the server's state from our own failures.
398
- const { outcome } = await resolveExhaustedMessageMoveFailure(
413
+ const settled = await resolveExhaustedMessageMoveFailure(
399
414
  { messageService, threadMessageService, log },
400
415
  {
401
416
  accountId,
402
417
  accountConfigId: account.accountConfigId,
403
418
  messageId,
419
+ sourceMailboxId,
404
420
  uid,
405
421
  sourceMailboxPath,
406
- getConnection: scope.getConnection,
422
+ getConnection: getGuardedConnection,
407
423
  },
408
- );
424
+ ).catch((settleError: unknown) => {
425
+ // The guarded probe found a mailbox whose UIDVALIDITY has moved.
426
+ // Every stored uid for this folder now names something else, so
427
+ // there is nothing here that may be settled off them, and the
428
+ // pause is the routine skip `guardMailboxCursor` documents, not
429
+ // a fault to re-throw out of this catch.
430
+ if (settleError instanceof MailboxCursorPausedError) {
431
+ log.info(
432
+ {
433
+ accountId,
434
+ messageId,
435
+ mailboxId: sourceMailboxId,
436
+ cursorState: settleError.state,
437
+ },
438
+ "Mailbox cursor not normal; leaving the exhausted move for the cursor rebuild",
439
+ );
440
+ return null;
441
+ }
442
+ throw settleError;
443
+ });
409
444
 
410
- if (outcome === "reconciled") {
411
- // Whichever folder the message actually sits in re-projects it
412
- // with the server's own UID.
413
- await emitMoveResync(emitEvent, {
414
- accountId,
415
- sourceMailboxId,
416
- destinationMailboxId,
417
- });
418
- return;
445
+ if (!settled) return;
446
+
447
+ if (settled.outcome === "broken") {
448
+ // Terminal and never re-thrown, so the handler-outcome series
449
+ // records this record as a success. Counted here or it is
450
+ // invisible.
451
+ recordImapFailure("MESSAGE_MOVE_EXHAUSTED", "other");
452
+ log.error(
453
+ { error: errorMessage },
454
+ "Message move retry exhausted; message still exists at its source",
455
+ );
419
456
  }
420
457
 
421
- // Terminal and never re-thrown, so the handler-outcome series
422
- // records this record as a success. Counted here or it is invisible.
423
- recordImapFailure("MESSAGE_MOVE_EXHAUSTED", "other");
424
- log.error(
425
- { error: errorMessage },
426
- "Message move retry exhausted; message still exists at its source",
427
- );
458
+ // Both verdicts end in a row the server has contradicted, so this
459
+ // move reconciles rather than waits (R2). RECONCILED, the local rows
460
+ // are gone and whichever folder actually holds the message
461
+ // re-projects it with the server's own UID. BROKEN, the row has just
462
+ // been put back at the source the server confirmed, and the resync
463
+ // is what carries any drift either folder has picked up since.
464
+ await emitMoveResync(emitEvent, {
465
+ accountId,
466
+ sourceMailboxId,
467
+ destinationMailboxId,
468
+ });
428
469
  // Terminal — never re-thrown, so the caller acks either way.
429
470
  })
430
471
  .finally(() => scope.disconnect());
@@ -0,0 +1,89 @@
1
+ import type {
2
+ IMessageRepository,
3
+ IThreadMessageRepository,
4
+ } from "@remit/data-ports";
5
+ import { isNotFoundError } from "../is-not-found.js";
6
+ import { buildThreadMessageMoveRevert } from "./thread-message-rows.js";
7
+
8
+ export interface RestoreSourcePlacementDeps {
9
+ messageService: Pick<IMessageRepository, "updateUid">;
10
+ threadMessageService: Pick<
11
+ IThreadMessageRepository,
12
+ "findAllByMessageId" | "update"
13
+ >;
14
+ }
15
+
16
+ export interface RestoreSourcePlacementInput {
17
+ accountConfigId: string;
18
+ messageId: string;
19
+ sourceMailboxId: string;
20
+ uid: number;
21
+ }
22
+
23
+ /**
24
+ * Put a row back on the source pair a presence probe has just confirmed, and
25
+ * hand its listing rows back with it.
26
+ *
27
+ * Only `updateUid` clears `status: moving`, so a give-up that writes
28
+ * `syncStatus: failed` and returns leaves the row naming the destination with
29
+ * the source's uid — the pair `bindsForeignUid` calls a lie and
30
+ * `MessagePlacementUnsettledError` refuses to act on. Nothing routine repairs
31
+ * it (sync does not touch `status`, and the cursor rebuild's `updateUid` runs
32
+ * only on a UIDVALIDITY change), so the message is undeletable and unmovable
33
+ * for good (issue #1005).
34
+ *
35
+ * The caller must have evidence, not an inference: this writes a placement, and
36
+ * a placement written on a guess binds live mail to a uid that names somebody
37
+ * else's message. Every caller reaches here from
38
+ * {@link isMessageGoneFromOpenMailbox} answering that the message is STILL at
39
+ * `sourceMailboxId`/`uid`, which makes this the server's own answer rather than
40
+ * a revert on ambiguity (what PR #652 was pulled for).
41
+ *
42
+ * A row another path deleted while the probe was in flight has nothing left to
43
+ * restore, and a thread row whose composites have moved on is being rewritten
44
+ * by that other path anyway. Both surface as `NotFoundError` — ElectroDB wraps
45
+ * the conditional-check miss as one — and both are settled states here, not
46
+ * faults. Throwing on them would escape the caller's `.catch()` and cost the
47
+ * record its ack and its resync, which is the contract the terminal resolvers
48
+ * state: never re-thrown.
49
+ */
50
+ export const restoreSourcePlacement = async (
51
+ deps: RestoreSourcePlacementDeps,
52
+ input: RestoreSourcePlacementInput,
53
+ ): Promise<void> => {
54
+ const { accountConfigId, messageId, sourceMailboxId, uid } = input;
55
+
56
+ const skipNotFound = (error: unknown): void => {
57
+ if (isNotFoundError(error)) return;
58
+ throw error;
59
+ };
60
+
61
+ const messageRestored = await deps.messageService
62
+ .updateUid(messageId, uid, sourceMailboxId)
63
+ .then(() => true)
64
+ .catch((error: unknown) => {
65
+ skipNotFound(error);
66
+ return false;
67
+ });
68
+ if (!messageRestored) return;
69
+
70
+ const threadMessages = await deps.threadMessageService.findAllByMessageId(
71
+ accountConfigId,
72
+ messageId,
73
+ );
74
+ for (const threadMessage of threadMessages) {
75
+ const args = buildThreadMessageMoveRevert(
76
+ threadMessage,
77
+ uid,
78
+ sourceMailboxId,
79
+ );
80
+ await deps.threadMessageService
81
+ .update(
82
+ threadMessage.accountConfigId,
83
+ threadMessage.threadMessageId,
84
+ args.set,
85
+ { composites: args.composites },
86
+ )
87
+ .catch(skipNotFound);
88
+ }
89
+ };
@@ -0,0 +1,138 @@
1
+ /**
2
+ * What the account row holds after the lifecycle has written to it.
3
+ *
4
+ * The deps builder is the half that talks to the store, so it is tested against
5
+ * the real repository over the shipped SQLite schema rather than a stand-in
6
+ * that would only agree with itself: whether a stored reason survives, and
7
+ * whether one that no longer applies is gone, is decided by the repository's
8
+ * update semantics, where an absent field means "leave it alone".
9
+ */
10
+
11
+ import assert from "node:assert/strict";
12
+ import { after, before, describe, it } from "node:test";
13
+ import type { AccountItem } from "@remit/data-ports";
14
+ import { AccountAuthType, ConnectionState } from "@remit/domain-enums";
15
+ import { AccountRepo } from "@remit/drizzle-service";
16
+ import { createShippedSqliteDb } from "@remit/drizzle-service/test-sqlite";
17
+ import { RefreshTokenError } from "@remit/mail-oauth-service";
18
+ import { MailConnectionError } from "@remit/mailbox-service/types";
19
+ import type { SecretsService } from "@remit/secrets-service";
20
+ import {
21
+ type OAuthLifecycleDeps,
22
+ withOAuthLifecycle,
23
+ } from "./with-oauth-lifecycle.js";
24
+ import { buildLifecycleDeps } from "./with-oauth-lifecycle-deps.js";
25
+
26
+ const silentLogger = {
27
+ info: () => {},
28
+ warn: () => {},
29
+ error: () => {},
30
+ debug: () => {},
31
+ trace: () => {},
32
+ fatal: () => {},
33
+ child: () => silentLogger,
34
+ } as never;
35
+
36
+ const secrets = {
37
+ decrypt: async () => "decrypted",
38
+ encrypt: async () => ({}) as never,
39
+ } as unknown as SecretsService;
40
+
41
+ /**
42
+ * The real deps, with only credential resolution stubbed — minting a token is
43
+ * a call to Microsoft and not what these tests are about. `updateConnectionState`
44
+ * is the one under test and stays exactly as the handlers build it.
45
+ */
46
+ const deps = (): OAuthLifecycleDeps => ({
47
+ ...buildLifecycleDeps(secrets, repo),
48
+ resolveCredentials: async () => ({
49
+ status: "resolved",
50
+ credentials: { kind: "accessToken", accessToken: "access-token" },
51
+ }),
52
+ });
53
+
54
+ const STALE_SYNC_ERROR = "Mailbox does not exist: [NONEXISTENT]";
55
+ const REFUSAL =
56
+ "IMAP authentication failed: LOGIN failed. User is authenticated but not connected.";
57
+
58
+ let repo: AccountRepo;
59
+ let close: () => void;
60
+
61
+ const account = async (): Promise<AccountItem> =>
62
+ repo.create({
63
+ accountConfigId: "cfg-1",
64
+ username: "matthijs@example.com",
65
+ email: "matthijs@example.com",
66
+ authType: AccountAuthType.OauthMicrosoft,
67
+ oauthRefreshTokenHash: "encrypted-refresh-token",
68
+ imapHost: "outlook.office365.com",
69
+ imapPort: 993,
70
+ imapTls: true,
71
+ imapStartTls: false,
72
+ isActive: true,
73
+ connectionState: ConnectionState.Authenticated,
74
+ });
75
+
76
+ before(() => {
77
+ const db = createShippedSqliteDb();
78
+ close = db.close;
79
+ repo = new AccountRepo(db.db);
80
+ });
81
+
82
+ after(() => {
83
+ close();
84
+ });
85
+
86
+ describe("the reason an account card reads back", () => {
87
+ it("keeps the refusal the mail server gave", async () => {
88
+ const created = await account();
89
+
90
+ await withOAuthLifecycle(deps(), created, silentLogger, async () => {
91
+ throw new MailConnectionError("auth", REFUSAL);
92
+ });
93
+
94
+ const stored = await repo.get(created.accountId);
95
+ assert.equal(stored.connectionState, ConnectionState.ReauthRequired);
96
+ assert.equal(stored.lastError, REFUSAL);
97
+ });
98
+
99
+ it("drops a reason the account has moved on from", async () => {
100
+ const created = await account();
101
+ // What a failed sync leaves behind (sync-mailboxes.ts): a folder that went
102
+ // away, nothing to do with signing in.
103
+ await repo.update(created.accountId, { lastError: STALE_SYNC_ERROR });
104
+
105
+ // Hours later the refresh token is revoked. Re-authenticating is now the
106
+ // whole story, and the mailbox that went missing is not it.
107
+ await withOAuthLifecycle(
108
+ deps(),
109
+ await repo.get(created.accountId),
110
+ silentLogger,
111
+ async () => {
112
+ throw new RefreshTokenError({
113
+ kind: "reauth-required",
114
+ code: "invalid_grant",
115
+ });
116
+ },
117
+ );
118
+
119
+ const stored = await repo.get(created.accountId);
120
+ assert.equal(stored.connectionState, ConnectionState.ReauthRequired);
121
+ assert.equal(
122
+ stored.lastError,
123
+ undefined,
124
+ "a stale sync error beside a Reconnect button reads as the reason for it",
125
+ );
126
+ });
127
+
128
+ it("stores a chatty refusal at a length a card can hold", async () => {
129
+ const created = await account();
130
+
131
+ await withOAuthLifecycle(deps(), created, silentLogger, async () => {
132
+ throw new MailConnectionError("auth", "x".repeat(4000));
133
+ });
134
+
135
+ const stored = await repo.get(created.accountId);
136
+ assert.equal(stored.lastError?.length, 500);
137
+ });
138
+ });
@@ -53,10 +53,19 @@ export const buildLifecycleDeps = (
53
53
  oauthTokenUpdatedAt: updatedAt,
54
54
  });
55
55
  },
56
+ // A state flip with no reason of its own removes the stored one. Left in
57
+ // place it would describe a failure the account has moved on from — the
58
+ // mailbox a sync could not find, still on the card beside a Reconnect
59
+ // button, after the token was revoked hours later.
56
60
  updateConnectionState: async (
57
61
  accountId: string,
58
62
  state: ConnectionStateValue,
63
+ lastError?: string,
59
64
  ) => {
60
- await accountService.update(accountId, { connectionState: state });
65
+ await accountService.update(
66
+ accountId,
67
+ { connectionState: state, lastError },
68
+ lastError === undefined ? ["lastError"] : undefined,
69
+ );
61
70
  },
62
71
  });
@@ -1,8 +1,9 @@
1
1
  import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
2
+ import { describe, it, mock } from "node:test";
3
3
  import type { AccountItem } from "@remit/data-ports";
4
4
  import { AccountAuthType, ConnectionState } from "@remit/domain-enums";
5
5
  import { RefreshTokenError } from "@remit/mail-oauth-service";
6
+ import { ImapFlowConnection } from "@remit/mailbox-service/imapflow-connection";
6
7
  import {
7
8
  MailConnectionError,
8
9
  type MailCredentials,
@@ -41,11 +42,19 @@ const buildAccount = (overrides: Partial<AccountItem> = {}): AccountItem =>
41
42
  }) as unknown as AccountItem;
42
43
 
43
44
  interface Recorded {
44
- stateUpdates: Array<{ accountId: string; state: ConnectionStateValue }>;
45
+ stateUpdates: Array<{
46
+ accountId: string;
47
+ state: ConnectionStateValue;
48
+ lastError?: string;
49
+ }>;
45
50
  resolveCalls: number;
46
51
  workCalls: number;
47
52
  }
48
53
 
54
+ /** What Microsoft says to a mailbox whose tenant has IMAP switched off. */
55
+ const REFUSAL =
56
+ "LOGIN failed. User is authenticated but not connected. SmtpClientAuthentication is disabled for the Tenant.";
57
+
49
58
  const passwordCreds: MailCredentials = {
50
59
  kind: "password",
51
60
  password: "secret",
@@ -68,8 +77,8 @@ const buildDeps = (
68
77
  },
69
78
  tokenService: { getAccessToken: async () => ({}) as never },
70
79
  persistRotatedToken: async () => {},
71
- updateConnectionState: async (accountId, state) => {
72
- recorded.stateUpdates.push({ accountId, state });
80
+ updateConnectionState: async (accountId, state, lastError) => {
81
+ recorded.stateUpdates.push({ accountId, state, lastError });
73
82
  },
74
83
  resolveCredentials:
75
84
  options.resolveCredentials ??
@@ -124,9 +133,12 @@ describe("withOAuthLifecycle", () => {
124
133
  "work must not run after resolve fails",
125
134
  );
126
135
  assert.equal(recorded.stateUpdates.length, 1);
136
+ // A revoked token has nothing to add: re-authenticating is the whole of
137
+ // what the account needs, and the card says so on its own.
127
138
  assert.deepEqual(recorded.stateUpdates[0], {
128
139
  accountId: "acc-1",
129
140
  state: ConnectionState.ReauthRequired,
141
+ lastError: undefined,
130
142
  });
131
143
  });
132
144
 
@@ -142,7 +154,48 @@ describe("withOAuthLifecycle", () => {
142
154
  assert.deepEqual(recorded.stateUpdates[0], {
143
155
  accountId: "acc-1",
144
156
  state: ConnectionState.ReauthRequired,
157
+ lastError: "auth failed",
158
+ });
159
+ });
160
+
161
+ it("keeps what the server said when it refused, so the card can repeat it", async () => {
162
+ const { deps, recorded } = buildDeps();
163
+ const account = buildAccount({ authType: AccountAuthType.OauthMicrosoft });
164
+
165
+ // The error an Exchange mailbox with IMAP switched off hands back, built
166
+ // the way imapflow builds it and classified by the code that ships.
167
+ const connection = new ImapFlowConnection({
168
+ host: "outlook.office365.com",
169
+ port: 993,
170
+ user: "matthijs@example.com",
171
+ credentials: { kind: "accessToken", accessToken: "access-token" },
172
+ tls: true,
173
+ });
174
+ mock.method(
175
+ connection as unknown as { attemptConnect: () => Promise<void> },
176
+ "attemptConnect",
177
+ async () => {
178
+ throw Object.assign(new Error("Command failed"), {
179
+ authenticationFailed: true,
180
+ serverResponseCode: "AUTHENTICATIONFAILED",
181
+ responseText: REFUSAL,
182
+ });
183
+ },
184
+ );
185
+
186
+ await withOAuthLifecycle(deps, account, silentLogger, async () => {
187
+ await connection.connect();
145
188
  });
189
+
190
+ assert.equal(recorded.stateUpdates.length, 1);
191
+ assert.equal(
192
+ recorded.stateUpdates[0].state,
193
+ ConnectionState.ReauthRequired,
194
+ );
195
+ assert.ok(
196
+ recorded.stateUpdates[0].lastError?.includes(REFUSAL),
197
+ `the refusal must survive to the account row, got: ${recorded.stateUpdates[0].lastError}`,
198
+ );
146
199
  });
147
200
 
148
201
  it("on MailConnectionError auth for password account: rethrows (batch item failure, no state flip)", async () => {
@@ -221,7 +274,11 @@ describe("withOAuthLifecycle", () => {
221
274
 
222
275
  assert.equal(recorded.workCalls, 0, "no IMAP traffic without a credential");
223
276
  assert.deepEqual(recorded.stateUpdates, [
224
- { accountId: "acc-1", state: ConnectionState.CredentialsMissing },
277
+ {
278
+ accountId: "acc-1",
279
+ state: ConnectionState.CredentialsMissing,
280
+ lastError: undefined,
281
+ },
225
282
  ]);
226
283
  });
227
284
 
@@ -236,7 +293,11 @@ describe("withOAuthLifecycle", () => {
236
293
 
237
294
  assert.equal(recorded.workCalls, 0, "no IMAP traffic without a credential");
238
295
  assert.deepEqual(recorded.stateUpdates, [
239
- { accountId: "acc-1", state: ConnectionState.ReauthRequired },
296
+ {
297
+ accountId: "acc-1",
298
+ state: ConnectionState.ReauthRequired,
299
+ lastError: undefined,
300
+ },
240
301
  ]);
241
302
  });
242
303
 
@@ -16,6 +16,10 @@
16
16
  * - On a terminal auth failure (RefreshTokenError reauth-required, or
17
17
  * MailConnectionError auth), flip the account to reauth_required and return
18
18
  * WITHOUT rethrowing — this ACKs the SQS message so it is not retried.
19
+ * An IMAP refusal is stored with it, because not every refusal a mail server
20
+ * hands back is one re-authenticating clears: "SmtpClientAuthentication is
21
+ * disabled" needs a tenant setting changed, and an account card that only
22
+ * ever says "Re-authentication required" sends the user around a loop.
19
23
  * - On transient / config / network errors, rethrow so SQS retries with
20
24
  * backoff (let-it-crash).
21
25
  *
@@ -42,12 +46,17 @@ export type { ConnectionStateValue };
42
46
 
43
47
  export interface OAuthLifecycleDeps extends AccountCredentialsDeps {
44
48
  /**
45
- * Persist the account's connectionState. Called when a terminal auth
46
- * failure is detected so the account is fenced off until the user re-auths.
49
+ * Persist the account's connectionState, and with it the sentence the
50
+ * account card shows for the failure. `lastError` carries the server's own
51
+ * words when the failure came with any; it is absent when nothing was said,
52
+ * as for a credential that was never stored or a token simply revoked, and
53
+ * the stored reason is then cleared rather than left to describe an older
54
+ * failure the account has moved on from.
47
55
  */
48
56
  updateConnectionState: (
49
57
  accountId: string,
50
58
  state: ConnectionStateValue,
59
+ lastError?: string,
51
60
  ) => Promise<void>;
52
61
  /**
53
62
  * Resolve credentials for the account. Defaults to
@@ -131,12 +140,14 @@ export const withOAuthLifecycle = async (
131
140
  {
132
141
  accountId: account.accountId,
133
142
  errorKind: err.kind,
143
+ error: err.message,
134
144
  },
135
145
  "IMAP auth rejected; marking account reauth_required",
136
146
  );
137
147
  await deps.updateConnectionState(
138
148
  account.accountId,
139
149
  ConnectionState.ReauthRequired,
150
+ err.message,
140
151
  );
141
152
  return; // ACK — do not retry
142
153
  }