@remit/mailbox-service 0.0.16 → 0.0.17

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/mailbox-service",
3
- "version": "0.0.16",
3
+ "version": "0.0.17",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -102,14 +102,26 @@ const buildStorageService = (
102
102
  };
103
103
  };
104
104
 
105
- /** A connection whose `fetchMessages` returns a hit for every uid in `present`. */
106
- const buildConnection = (present: Set<number>): IImapConnection =>
105
+ /**
106
+ * A connection whose `fetchMessages` returns a hit for every uid in `present`,
107
+ * except those in `fetchDrops` — messages the server still has and SEARCH
108
+ * still lists, whose FETCH row imapflow drops (#408).
109
+ */
110
+ const buildConnection = (
111
+ present: Set<number>,
112
+ fetchDrops: Set<number> = new Set(),
113
+ ): IImapConnection =>
107
114
  ({
108
115
  openBox: async () => ({}) as never,
109
116
  fetchMessages: async (uids: number[]) =>
110
117
  uids
111
- .filter((uid) => present.has(uid))
118
+ .filter((uid) => present.has(uid) && !fetchDrops.has(uid))
112
119
  .map((uid) => ({ uid }) as unknown as never),
120
+ search: async (criteria: unknown[]) => {
121
+ const [, value] = (criteria as Array<[string, string]>)[0];
122
+ const uid = Number(value);
123
+ return present.has(uid) ? [uid] : [];
124
+ },
113
125
  }) as unknown as IImapConnection;
114
126
 
115
127
  describe("resolveExhaustedBodySyncFailures — the two terminal outcomes", () => {
@@ -189,6 +201,39 @@ describe("resolveExhaustedBodySyncFailures — the two terminal outcomes", () =>
189
201
  );
190
202
  });
191
203
 
204
+ it("a dropped FETCH row mid-batch is not absence: the live message keeps its rows", async () => {
205
+ const deletedMessages: string[] = [];
206
+ const deletedThreadMessages: Array<{
207
+ accountConfigId: string;
208
+ threadMessageId: string;
209
+ }> = [];
210
+ const { storageService } = buildStorageService();
211
+ const { log } = buildLogger();
212
+
213
+ const deps: ResolveExhaustedBodySyncDeps = {
214
+ messageService: buildMessageService({ m1: 1, m2: 2 }, deletedMessages),
215
+ threadMessageService: buildThreadMessageService(deletedThreadMessages),
216
+ storageService,
217
+ log,
218
+ };
219
+
220
+ // Both messages are live. The FETCH for m1 returns its row; the
221
+ // immediately following FETCH for m2 drops its row (#408).
222
+ const result = await resolveExhaustedBodySyncFailures(deps, {
223
+ accountId: "acc-1",
224
+ accountConfigId: "cfg-1",
225
+ mailboxId: "mbx-1",
226
+ mailboxPath: "INBOX",
227
+ failedMessageIds: ["m1", "m2"],
228
+ getConnection: async () => buildConnection(new Set([1, 2]), new Set([2])),
229
+ });
230
+
231
+ assert.deepEqual(result.reconciledMessageIds, []);
232
+ assert.deepEqual(result.brokenMessageIds, ["m1", "m2"]);
233
+ assert.deepEqual(deletedMessages, []);
234
+ assert.deepEqual(deletedThreadMessages, []);
235
+ });
236
+
192
237
  it("resolves a mixed batch into both outcomes independently", async () => {
193
238
  const deletedMessages: string[] = [];
194
239
  const { storageService } = buildStorageService();
@@ -1,6 +1,7 @@
1
1
  import type { IMessageRepository } from "@remit/data-ports";
2
2
  import type { StorageService } from "@remit/storage-service";
3
3
  import type { BodySyncLogger } from "./body-sync.js";
4
+ import { isMessageGoneFromOpenMailbox } from "./message-presence.js";
4
5
  import {
5
6
  reconcileStaleMessage,
6
7
  type StaleMessageReconcileDeps,
@@ -84,7 +85,11 @@ const markMessageBodySyncFailed = async (
84
85
  * outcome — every failed id lands in one of the two result lists.
85
86
  *
86
87
  * 1. EXPECTED — the message no longer exists on IMAP (expunged, or a
87
- * UIDVALIDITY change moved it, #1272). The stale row is deleted via
88
+ * UIDVALIDITY change moved it, #1272), confirmed by
89
+ * {@link isMessageGoneFromOpenMailbox}. This loop is the most exposed of
90
+ * the four sites that used to read an empty FETCH as absence: it issues
91
+ * back-to-back single-UID FETCHes on one connection, which is the
92
+ * condition imapflow #408 drops rows under. The stale row is deleted via
88
93
  * {@link reconcileStaleMessage} so the existing missing-row 404 path
89
94
  * takes over. Callers should emit a metric only — this is routine, not an
90
95
  * incident.
@@ -123,9 +128,8 @@ export const resolveExhaustedBodySyncFailures = async (
123
128
 
124
129
  for (const messageId of failedMessageIds) {
125
130
  const message = await deps.messageService.get(messageId);
126
- const found = await connection.fetchMessages([message.uid]);
127
131
 
128
- if (found.length === 0) {
132
+ if (await isMessageGoneFromOpenMailbox(connection, message.uid)) {
129
133
  const { threadMessagesDeleted } = await reconcileStaleMessage(
130
134
  deps,
131
135
  accountConfigId,
@@ -33,13 +33,21 @@ const buildLogger = (): {
33
33
  };
34
34
  };
35
35
 
36
- const buildConnection = (present: Set<number>): IImapConnection =>
36
+ const buildConnection = (
37
+ present: Set<number>,
38
+ fetchDrops: Set<number> = new Set(),
39
+ ): IImapConnection =>
37
40
  ({
38
41
  openBox: async () => ({}) as never,
39
42
  fetchMessages: async (uids: number[]) =>
40
43
  uids
41
- .filter((uid) => present.has(uid))
44
+ .filter((uid) => present.has(uid) && !fetchDrops.has(uid))
42
45
  .map((uid) => ({ uid }) as unknown as never),
46
+ search: async (criteria: unknown[]) => {
47
+ const [, value] = (criteria as Array<[string, string]>)[0];
48
+ const uid = Number(value);
49
+ return present.has(uid) ? [uid] : [];
50
+ },
43
51
  }) as unknown as IImapConnection;
44
52
 
45
53
  describe("resolveExhaustedFlagPushFailure — the two terminal outcomes (mirrors #1289/#1270 for flag pushes)", () => {
@@ -104,6 +112,51 @@ describe("resolveExhaustedFlagPushFailure — the two terminal outcomes (mirrors
104
112
  assert.ok(metricLog, "expected a routine reconciliation metric log");
105
113
  });
106
114
 
115
+ it("a dropped FETCH row is not absence: the message is still in the mailbox — marker and local rows survive", async () => {
116
+ const markerDeletes: Array<{ messageId: string; flagName: string }> = [];
117
+ const { log, errors } = buildLogger();
118
+
119
+ const deps: ResolveExhaustedFlagPushDeps = {
120
+ markerService: {
121
+ delete: async (messageId: string, flagName: string) => {
122
+ markerDeletes.push({ messageId, flagName });
123
+ },
124
+ },
125
+ messageService: {
126
+ delete: async () => {
127
+ throw new Error("must not be called — the message still exists");
128
+ },
129
+ } as unknown as Pick<IMessageRepository, "delete">,
130
+ threadMessageService: {
131
+ findAllByMessageId: async () => {
132
+ throw new Error("must not be called — the message still exists");
133
+ },
134
+ deleteMany: async () => {
135
+ throw new Error("must not be called — the message still exists");
136
+ },
137
+ } as unknown as Pick<
138
+ IThreadMessageRepository,
139
+ "findAllByMessageId" | "deleteMany"
140
+ >,
141
+ log,
142
+ };
143
+
144
+ const result = await resolveExhaustedFlagPushFailure(deps, {
145
+ accountId: "acc-1",
146
+ accountConfigId: "cfg-1",
147
+ messageId: "msg-live",
148
+ flagName: "\\Seen",
149
+ uid: 303,
150
+ mailboxPath: "INBOX",
151
+ getConnection: async () =>
152
+ buildConnection(new Set([303]), new Set([303])),
153
+ });
154
+
155
+ assert.equal(result.outcome, "broken");
156
+ assert.deepEqual(markerDeletes, []);
157
+ assert.ok(errors.some((e) => e.obj.alert === "flag_push_failed"));
158
+ });
159
+
107
160
  it("BROKEN (should never happen): the message still exists — marker left in place, alert logged", async () => {
108
161
  const markerDeletes: Array<{ messageId: string; flagName: string }> = [];
109
162
  const { log, errors } = buildLogger();
@@ -1,4 +1,5 @@
1
1
  import type { FlagPushLogger } from "./flag-push.js";
2
+ import { isMessageGoneFromOpenMailbox } from "./message-presence.js";
2
3
  import {
3
4
  reconcileStaleMessage,
4
5
  type StaleMessageReconcileDeps,
@@ -35,10 +36,11 @@ export interface ResolveExhaustedFlagPushResult {
35
36
  * (epic #1281 invariant 3) — no third, softer outcome.
36
37
  *
37
38
  * 1. RECONCILED (expected) — the message no longer exists at its mailbox on
38
- * IMAP. Per invariant 2, an external delete supersedes the marker
39
- * entirely: the marker is dropped and the stale Message/ThreadMessage rows
40
- * are deleted via {@link reconcileStaleMessage}. Metric only, no alarm
41
- * routine.
39
+ * IMAP, confirmed by {@link isMessageGoneFromOpenMailbox} rather than by a
40
+ * FETCH coming back empty. Per invariant 2, an external delete supersedes
41
+ * the marker entirely: the marker is dropped and the stale
42
+ * Message/ThreadMessage rows are deleted via {@link reconcileStaleMessage}.
43
+ * Metric only, no alarm — routine.
42
44
  * 2. BROKEN — the message still exists, but the flag push keeps failing.
43
45
  * Broken code or a broken account, not a transient blip. The marker is
44
46
  * left in place (not cleared) — while pending, resync never reverts the
@@ -48,6 +50,15 @@ export interface ResolveExhaustedFlagPushResult {
48
50
  * entry for an operator alarm; never re-thrown (terminal — the caller acks
49
51
  * either way, since retrying a stale or permanently-broken push can never
50
52
  * succeed).
53
+ *
54
+ * An operator reading `flag_push_failed` should know one case where the
55
+ * message is not actually there: a message another client expunged
56
+ * mid-session can answer an empty FETCH while the server still lists its UID
57
+ * in SEARCH, until it is allowed to send the untagged EXPUNGE. That message
58
+ * lands in BROKEN, and BROKEN is terminal — the marker stays pending and the
59
+ * alert stands until someone clears it. The reverse mistake deletes live
60
+ * mail, so the cost is paid deliberately: a stale alert is recoverable, a
61
+ * deleted message is not.
51
62
  */
52
63
  export const resolveExhaustedFlagPushFailure = async (
53
64
  deps: ResolveExhaustedFlagPushDeps,
@@ -65,9 +76,8 @@ export const resolveExhaustedFlagPushFailure = async (
65
76
 
66
77
  const connection = await getConnection();
67
78
  await connection.openBox(mailboxPath, true);
68
- const found = await connection.fetchMessages([uid]);
69
79
 
70
- if (found.length === 0) {
80
+ if (await isMessageGoneFromOpenMailbox(connection, uid)) {
71
81
  await deps.markerService.delete(messageId, flagName);
72
82
  const { threadMessagesDeleted } = await reconcileStaleMessage(
73
83
  deps,
@@ -112,6 +112,52 @@ const fakeMailbox = (path: string, exists: number) => ({
112
112
  readOnly: true,
113
113
  });
114
114
 
115
+ describe("ImapFlowConnection.search — the probe every stale-row reconcile rests on (#102)", () => {
116
+ const buildSearchConnection = (
117
+ result: unknown,
118
+ calls: Array<{ query: unknown; options: unknown }> = [],
119
+ ): ImapFlowConnection => {
120
+ const connection = buildConnectionWithClient({
121
+ mailboxOpen: async (path: string) => fakeMailbox(path, 1),
122
+ search: async (query: unknown, options: unknown) => {
123
+ calls.push({ query, options });
124
+ return result;
125
+ },
126
+ });
127
+ return connection;
128
+ };
129
+
130
+ it("translates a UID criterion into a UID SEARCH answering UIDs, not sequence numbers", async () => {
131
+ const calls: Array<{ query: unknown; options: unknown }> = [];
132
+ const connection = buildSearchConnection([42], calls);
133
+ await connection.openBox("INBOX", true);
134
+
135
+ const result = await connection.search([["UID", "42"]]);
136
+
137
+ assert.deepStrictEqual(result, [42]);
138
+ assert.deepStrictEqual(calls, [
139
+ { query: { uid: "42" }, options: { uid: true } },
140
+ ]);
141
+ });
142
+
143
+ it("answers an empty array when the server matched nothing", async () => {
144
+ const connection = buildSearchConnection([]);
145
+ await connection.openBox("INBOX", true);
146
+
147
+ assert.deepStrictEqual(await connection.search([["UID", "42"]]), []);
148
+ });
149
+
150
+ it("throws on a failed SEARCH instead of reporting it as no matches", async () => {
151
+ const connection = buildSearchConnection(false);
152
+ await connection.openBox("INBOX", true);
153
+
154
+ await assert.rejects(
155
+ () => connection.search([["UID", "42"]]),
156
+ /SEARCH failed/,
157
+ );
158
+ });
159
+ });
160
+
115
161
  describe("ImapFlowConnection.getMailboxStatus — deletedCount (#1042)", () => {
116
162
  it("returns the SEARCH \\Deleted count alongside the STATUS counts", async () => {
117
163
  const searchQueries: Array<Record<string, unknown>> = [];
@@ -152,7 +198,7 @@ describe("ImapFlowConnection.getMailboxStatus — deletedCount (#1042)", () => {
152
198
  highestModseq: 0n,
153
199
  }),
154
200
  mailboxOpen: async (path: string) => fakeMailbox(path, 4),
155
- search: async () => false,
201
+ search: async () => [],
156
202
  });
157
203
 
158
204
  const status = await connection.getMailboxStatus("INBOX");
@@ -173,7 +219,7 @@ describe("ImapFlowConnection.getMailboxStatus — deletedCount (#1042)", () => {
173
219
  highestModseq: modseq,
174
220
  }),
175
221
  mailboxOpen: async (path: string) => fakeMailbox(path, 1),
176
- search: async () => false,
222
+ search: async () => [],
177
223
  });
178
224
 
179
225
  const status = await connection.getMailboxStatus("INBOX");
@@ -397,7 +397,15 @@ export class ImapFlowConnection {
397
397
  };
398
398
 
399
399
  /**
400
- * Search for messages
400
+ * Search for messages, answering UIDs.
401
+ *
402
+ * imapflow answers a successful SEARCH with an array — empty when the
403
+ * server matched nothing — and `false` when the command failed or no
404
+ * mailbox was selected. Collapsing the two into `[]` makes a failed SEARCH
405
+ * indistinguishable from an empty mailbox, and every caller here acts on
406
+ * emptiness by deleting something: the cursor rebuild reads it as "every
407
+ * local row is stale", and {@link isMessageGoneFromOpenMailbox} reads it as
408
+ * "this message is gone". A failure therefore throws.
401
409
  */
402
410
  search = async (criteria: unknown[]): Promise<number[]> => {
403
411
  this.ensureConnected();
@@ -410,9 +418,8 @@ export class ImapFlowConnection {
410
418
  const searchQuery = this.convertSearchCriteria(criteria);
411
419
 
412
420
  const result = await this.client?.search(searchQuery, { uid: true });
413
- // search can return false if no messages match, or undefined if client is null
414
- if (!result) {
415
- return [];
421
+ if (!Array.isArray(result)) {
422
+ throw new Error(`IMAP SEARCH failed in mailbox ${this.currentMailbox}`);
416
423
  }
417
424
  return result;
418
425
  };
package/src/index.ts CHANGED
@@ -167,6 +167,7 @@ export {
167
167
  type ParsedMessageContent,
168
168
  parseMessageContent,
169
169
  } from "./message-parser.js";
170
+ export { isMessageGoneFromOpenMailbox } from "./message-presence.js";
170
171
  export {
171
172
  type ImapConnectionFactory,
172
173
  MessageSyncService,
@@ -0,0 +1,50 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { isMessageGoneFromOpenMailbox } from "./message-presence.js";
4
+ import type { IImapConnection } from "./types.js";
5
+
6
+ type Probe = Pick<IImapConnection, "fetchMessages" | "search">;
7
+
8
+ const buildProbe = (
9
+ fetched: number[],
10
+ searched: number[],
11
+ searchCalls: unknown[][] = [],
12
+ ): Probe => ({
13
+ fetchMessages: async () => fetched.map((uid) => ({ uid }) as never),
14
+ search: async (criteria: unknown[]) => {
15
+ searchCalls.push(criteria);
16
+ return searched;
17
+ },
18
+ });
19
+
20
+ describe("isMessageGoneFromOpenMailbox", () => {
21
+ it("a FETCH row is proof of presence — no SEARCH needed", async () => {
22
+ const searchCalls: unknown[][] = [];
23
+ const gone = await isMessageGoneFromOpenMailbox(
24
+ buildProbe([7], [], searchCalls),
25
+ 7,
26
+ );
27
+
28
+ assert.equal(gone, false);
29
+ assert.equal(searchCalls.length, 0);
30
+ });
31
+
32
+ it("an empty FETCH the SEARCH contradicts is a dropped row, not an absence", async () => {
33
+ const gone = await isMessageGoneFromOpenMailbox(buildProbe([], [7]), 7);
34
+
35
+ assert.equal(gone, false);
36
+ });
37
+
38
+ it("only a SEARCH that does not list the uid confirms it is gone", async () => {
39
+ const gone = await isMessageGoneFromOpenMailbox(buildProbe([], []), 7);
40
+
41
+ assert.equal(gone, true);
42
+ });
43
+
44
+ it("asks the server for the uid it is about to reconcile", async () => {
45
+ const searchCalls: unknown[][] = [];
46
+ await isMessageGoneFromOpenMailbox(buildProbe([], [], searchCalls), 42);
47
+
48
+ assert.deepEqual(searchCalls, [[["UID", "42"]]]);
49
+ });
50
+ });
@@ -0,0 +1,35 @@
1
+ import type { IImapConnection } from "./types.js";
2
+
3
+ /**
4
+ * Whether a UID is confirmed gone from the currently open mailbox.
5
+ *
6
+ * A FETCH that yields no row is not proof of absence. imapflow drops rows on
7
+ * back-to-back FETCHes (#408) — the same client glitch #100 had to stop
8
+ * reading as authoritative on the sync path — so a transient blip returns an
9
+ * empty result for a message that is still on the server. Anything that
10
+ * deletes local rows on that reading destroys live mail.
11
+ *
12
+ * Absence is therefore confirmed with a UID SEARCH, which the server answers
13
+ * with a plain UID set rather than a stream of message rows, and which
14
+ * `placement-move-push.ts` already uses as its verification probe. Only a
15
+ * SEARCH that does not list the UID counts as gone; an empty FETCH the SEARCH
16
+ * contradicts leaves the message present, and the caller treats it as such.
17
+ *
18
+ * The absence verdict rests on an empty SEARCH meaning the server matched
19
+ * nothing, so `IImapConnection.search` must throw when a SEARCH fails rather
20
+ * than answer `[]` — an implementation that reports failure as an empty array
21
+ * hands this function a deletion authority it cannot tell apart from a genuine
22
+ * miss.
23
+ */
24
+ export const isMessageGoneFromOpenMailbox = async (
25
+ connection: Pick<IImapConnection, "fetchMessages" | "search">,
26
+ uid: number,
27
+ ): Promise<boolean> => {
28
+ const found = await connection.fetchMessages([uid]);
29
+ if (found.length > 0) {
30
+ return false;
31
+ }
32
+
33
+ const matched = await connection.search([["UID", String(uid)]]);
34
+ return !matched.includes(uid);
35
+ };
@@ -33,13 +33,21 @@ const buildLogger = (): {
33
33
  };
34
34
  };
35
35
 
36
- const buildConnection = (present: Set<number>): IImapConnection =>
36
+ const buildConnection = (
37
+ present: Set<number>,
38
+ fetchDrops: Set<number> = new Set(),
39
+ ): IImapConnection =>
37
40
  ({
38
41
  openBox: async () => ({}) as never,
39
42
  fetchMessages: async (uids: number[]) =>
40
43
  uids
41
- .filter((uid) => present.has(uid))
44
+ .filter((uid) => present.has(uid) && !fetchDrops.has(uid))
42
45
  .map((uid) => ({ uid }) as unknown as never),
46
+ search: async (criteria: unknown[]) => {
47
+ const [, value] = (criteria as Array<[string, string]>)[0];
48
+ const uid = Number(value);
49
+ return present.has(uid) ? [uid] : [];
50
+ },
43
51
  }) as unknown as IImapConnection;
44
52
 
45
53
  describe("resolveExhaustedPlacementMoveFailure — the two terminal outcomes (mirrors #1270 for placement moves)", () => {
@@ -148,6 +156,54 @@ describe("resolveExhaustedPlacementMoveFailure — the two terminal outcomes (mi
148
156
  );
149
157
  });
150
158
 
159
+ it("a dropped FETCH row is not absence: the message is still at the source — marker and local rows survive", async () => {
160
+ const deletedMessages: string[] = [];
161
+ const deletedThreadMessages: unknown[] = [];
162
+ const markerDeletes: string[] = [];
163
+ const { log, errors } = buildLogger();
164
+
165
+ const deps: ResolveExhaustedPlacementMoveDeps = {
166
+ markerService: {
167
+ delete: async (id: string) => {
168
+ markerDeletes.push(id);
169
+ },
170
+ },
171
+ messageService: {
172
+ delete: async (id: string) => {
173
+ deletedMessages.push(id);
174
+ },
175
+ } as unknown as Pick<IMessageRepository, "delete">,
176
+ threadMessageService: {
177
+ findAllByMessageId: async () => [
178
+ { accountConfigId: "cfg-1", threadMessageId: "tm-msg-live" },
179
+ ],
180
+ deleteMany: async (keys: unknown[]) => {
181
+ deletedThreadMessages.push(...keys);
182
+ },
183
+ } as unknown as Pick<
184
+ IThreadMessageRepository,
185
+ "findAllByMessageId" | "deleteMany"
186
+ >,
187
+ log,
188
+ };
189
+
190
+ const result = await resolveExhaustedPlacementMoveFailure(deps, {
191
+ accountId: "acc-1",
192
+ accountConfigId: "cfg-1",
193
+ messageId: "msg-live",
194
+ uid: 303,
195
+ sourceMailboxPath: "INBOX",
196
+ getConnection: async () =>
197
+ buildConnection(new Set([303]), new Set([303])),
198
+ });
199
+
200
+ assert.equal(result.outcome, "broken");
201
+ assert.deepEqual(markerDeletes, []);
202
+ assert.deepEqual(deletedMessages, []);
203
+ assert.deepEqual(deletedThreadMessages, []);
204
+ assert.ok(errors.some((e) => e.obj.alert === "placement_move_failed"));
205
+ });
206
+
151
207
  it("never throws — both outcomes are terminal, the caller always acks", async () => {
152
208
  const { log } = buildLogger();
153
209
  const deps: ResolveExhaustedPlacementMoveDeps = {
@@ -1,3 +1,4 @@
1
+ import { isMessageGoneFromOpenMailbox } from "./message-presence.js";
1
2
  import type { PlacementMoveLogger } from "./placement-move.js";
2
3
  import {
3
4
  reconcileStaleMessage,
@@ -33,9 +34,11 @@ export interface ResolveExhaustedPlacementMoveResult {
33
34
  * taxonomy (epic #1281 invariant 3) — no third, softer outcome.
34
35
  *
35
36
  * 1. RECONCILED (expected) — the message no longer exists at its pending-move
36
- * source on IMAP. Per invariant 2, an external delete supersedes the
37
- * marker entirely: the marker is dropped and the stale Message/ThreadMessage
38
- * rows are deleted via {@link reconcileStaleMessage}. This is also the
37
+ * source on IMAP, confirmed by {@link isMessageGoneFromOpenMailbox} rather
38
+ * than by a FETCH coming back empty. Per invariant 2, an external delete
39
+ * supersedes the marker entirely: the marker is dropped and the stale
40
+ * Message/ThreadMessage rows are deleted via
41
+ * {@link reconcileStaleMessage}. This is also the
39
42
  * outcome for the (rarer, functionally indistinguishable from here) case
40
43
  * where a foreign client moved the message elsewhere — either way, our
41
44
  * prediction no longer holds, and the marker cannot be honoured. Metric
@@ -49,6 +52,15 @@ export interface ResolveExhaustedPlacementMoveResult {
49
52
  * `alert`-shaped entry for an operator alarm; never re-thrown (terminal —
50
53
  * the caller acks either way, since retrying a stale or permanently-broken
51
54
  * move can never succeed).
55
+ *
56
+ * An operator reading `placement_move_failed` should know one case where the
57
+ * message is not actually at the source: a message another client expunged
58
+ * mid-session can answer an empty FETCH while the server still lists its UID
59
+ * in SEARCH, until it is allowed to send the untagged EXPUNGE. That message
60
+ * lands in BROKEN, and BROKEN is terminal — the marker stays pending and the
61
+ * alert stands until someone clears it. The reverse mistake deletes live
62
+ * mail, so the cost is paid deliberately: a stale alert is recoverable, a
63
+ * deleted message is not.
52
64
  */
53
65
  export const resolveExhaustedPlacementMoveFailure = async (
54
66
  deps: ResolveExhaustedPlacementMoveDeps,
@@ -65,9 +77,8 @@ export const resolveExhaustedPlacementMoveFailure = async (
65
77
 
66
78
  const connection = await getConnection();
67
79
  await connection.openBox(sourceMailboxPath);
68
- const found = await connection.fetchMessages([uid]);
69
80
 
70
- if (found.length === 0) {
81
+ if (await isMessageGoneFromOpenMailbox(connection, uid)) {
71
82
  await deps.markerService.delete(messageId);
72
83
  const { threadMessagesDeleted } = await reconcileStaleMessage(
73
84
  deps,