@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 +1 -1
- package/src/body-sync-terminal.test.ts +48 -3
- package/src/body-sync-terminal.ts +7 -3
- package/src/flag-push-terminal.test.ts +55 -2
- package/src/flag-push-terminal.ts +16 -6
- package/src/imapflow-connection.test.ts +48 -2
- package/src/imapflow-connection.ts +11 -4
- package/src/index.ts +1 -0
- package/src/message-presence.test.ts +50 -0
- package/src/message-presence.ts +35 -0
- package/src/placement-move-terminal.test.ts +58 -2
- package/src/placement-move-terminal.ts +16 -5
package/package.json
CHANGED
|
@@ -102,14 +102,26 @@ const buildStorageService = (
|
|
|
102
102
|
};
|
|
103
103
|
};
|
|
104
104
|
|
|
105
|
-
/**
|
|
106
|
-
|
|
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)
|
|
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 (
|
|
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 = (
|
|
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
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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 (
|
|
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 () =>
|
|
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 () =>
|
|
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
|
-
|
|
414
|
-
|
|
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
|
@@ -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 = (
|
|
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
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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 (
|
|
81
|
+
if (await isMessageGoneFromOpenMailbox(connection, uid)) {
|
|
71
82
|
await deps.markerService.delete(messageId);
|
|
72
83
|
const { threadMessagesDeleted } = await reconcileStaleMessage(
|
|
73
84
|
deps,
|