@remit/mailbox-service 0.0.16 → 0.0.18
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-move-copy.test.ts +482 -0
- package/src/message-move.ts +15 -6
- 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,482 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type {
|
|
4
|
+
IAddressRepository,
|
|
5
|
+
IEnvelopeRepository,
|
|
6
|
+
IMailboxRepository,
|
|
7
|
+
IMailboxSpecialUseRepository,
|
|
8
|
+
IMessageRepository,
|
|
9
|
+
IThreadMessageRepository,
|
|
10
|
+
IUnitOfWork,
|
|
11
|
+
} from "@remit/data-ports";
|
|
12
|
+
import { CreateFailedConflictError } from "@remit/data-ports/errors";
|
|
13
|
+
import { deriveCopyMessageId, deriveMessageId } from "@remit/data-ports/id";
|
|
14
|
+
import { MailboxCursorState } from "@remit/domain-enums";
|
|
15
|
+
import type { ManagedConnectionFactory } from "./connection-factory.js";
|
|
16
|
+
import { type MessageMoveConfig, MessageMoveService } from "./message-move.js";
|
|
17
|
+
import { MessageSyncService } from "./message-sync.js";
|
|
18
|
+
import type { IImapConnection, ImapMessage } from "./types.js";
|
|
19
|
+
|
|
20
|
+
// A copy is a per-folder placement of the same mail. These tests pin the three
|
|
21
|
+
// properties issue #75 turns on: the copy row is deterministic (idempotent), a
|
|
22
|
+
// delete removes exactly the placement it targets, and neither the copy nor the
|
|
23
|
+
// original leaves an unreachable row behind.
|
|
24
|
+
|
|
25
|
+
const ACCOUNT = "acc-1";
|
|
26
|
+
const ACCOUNT_CONFIG = "cfg-1";
|
|
27
|
+
const SOURCE_MAILBOX = "mbx-inbox";
|
|
28
|
+
const DEST_MAILBOX = "mbx-archive";
|
|
29
|
+
const HEADER = "<abc@example.com>";
|
|
30
|
+
const SOURCE_ID = deriveMessageId(ACCOUNT, HEADER);
|
|
31
|
+
const THREAD_ID = "thread-1";
|
|
32
|
+
|
|
33
|
+
interface ThreadRow {
|
|
34
|
+
accountConfigId: string;
|
|
35
|
+
threadMessageId: string;
|
|
36
|
+
threadId: string;
|
|
37
|
+
messageId: string;
|
|
38
|
+
mailboxId: string;
|
|
39
|
+
messageIdHeader: string;
|
|
40
|
+
isRead?: boolean;
|
|
41
|
+
hasStars?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const buildWorld = () => {
|
|
45
|
+
const messages = new Map<string, Record<string, unknown>>([
|
|
46
|
+
[
|
|
47
|
+
SOURCE_ID,
|
|
48
|
+
{
|
|
49
|
+
messageId: SOURCE_ID,
|
|
50
|
+
mailboxId: SOURCE_MAILBOX,
|
|
51
|
+
uid: 42,
|
|
52
|
+
rfc822Size: 100,
|
|
53
|
+
internalDate: 1_700_000_000_000,
|
|
54
|
+
messageIdHeader: HEADER,
|
|
55
|
+
envelopeId: "env-1",
|
|
56
|
+
rootBodyPartId: "body-1",
|
|
57
|
+
bodyStorageKey: "s3://body-1",
|
|
58
|
+
category: "primary",
|
|
59
|
+
hasListUnsubscribe: false,
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
const threadRows: ThreadRow[] = [
|
|
65
|
+
{
|
|
66
|
+
accountConfigId: ACCOUNT_CONFIG,
|
|
67
|
+
threadMessageId: `tm:${THREAD_ID}::${SOURCE_ID}`,
|
|
68
|
+
threadId: THREAD_ID,
|
|
69
|
+
messageId: SOURCE_ID,
|
|
70
|
+
mailboxId: SOURCE_MAILBOX,
|
|
71
|
+
messageIdHeader: HEADER,
|
|
72
|
+
isRead: false,
|
|
73
|
+
hasStars: false,
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
const mailboxes = new Map<string, Record<string, unknown>>([
|
|
78
|
+
[
|
|
79
|
+
SOURCE_MAILBOX,
|
|
80
|
+
{ mailboxId: SOURCE_MAILBOX, fullPath: "INBOX", accountId: ACCOUNT },
|
|
81
|
+
],
|
|
82
|
+
[
|
|
83
|
+
DEST_MAILBOX,
|
|
84
|
+
{ mailboxId: DEST_MAILBOX, fullPath: "Archive", accountId: ACCOUNT },
|
|
85
|
+
],
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
const messageService = {
|
|
89
|
+
get: async (id: string | string[]) => {
|
|
90
|
+
if (Array.isArray(id)) {
|
|
91
|
+
return id.map((i) => messages.get(i)).filter(Boolean);
|
|
92
|
+
}
|
|
93
|
+
const m = messages.get(id);
|
|
94
|
+
if (!m) throw new Error(`no message ${id}`);
|
|
95
|
+
return m;
|
|
96
|
+
},
|
|
97
|
+
// Faithful to the production repo: a duplicate messageId throws a
|
|
98
|
+
// conflict. create is NOT idempotent — only upsert is — so a copy path
|
|
99
|
+
// that relied on create swallowing the duplicate would strand the second
|
|
100
|
+
// attempt here (issue #75). The copy path uses upsert for exactly this
|
|
101
|
+
// reason.
|
|
102
|
+
create: async (input: Record<string, unknown>) => {
|
|
103
|
+
const id = input.messageId as string;
|
|
104
|
+
if (messages.has(id)) {
|
|
105
|
+
throw new CreateFailedConflictError("Message", input);
|
|
106
|
+
}
|
|
107
|
+
messages.set(id, { ...input });
|
|
108
|
+
return messages.get(id);
|
|
109
|
+
},
|
|
110
|
+
upsert: async (input: Record<string, unknown>) => {
|
|
111
|
+
const id = input.messageId as string;
|
|
112
|
+
if (!messages.has(id)) messages.set(id, { ...input });
|
|
113
|
+
return messages.get(id);
|
|
114
|
+
},
|
|
115
|
+
update: async (id: string, patch: Record<string, unknown>) => {
|
|
116
|
+
const m = messages.get(id);
|
|
117
|
+
if (m) Object.assign(m, patch);
|
|
118
|
+
return m;
|
|
119
|
+
},
|
|
120
|
+
} as unknown as IMessageRepository;
|
|
121
|
+
|
|
122
|
+
const threadMessageService = {
|
|
123
|
+
create: async (input: Record<string, unknown>) => {
|
|
124
|
+
const threadId = input.threadId as string;
|
|
125
|
+
const messageId = input.messageId as string;
|
|
126
|
+
const existing = threadRows.find(
|
|
127
|
+
(r) => r.threadId === threadId && r.messageId === messageId,
|
|
128
|
+
);
|
|
129
|
+
if (existing) return existing;
|
|
130
|
+
const row: ThreadRow = {
|
|
131
|
+
accountConfigId: input.accountConfigId as string,
|
|
132
|
+
threadMessageId: `tm:${threadId}::${messageId}`,
|
|
133
|
+
threadId,
|
|
134
|
+
messageId,
|
|
135
|
+
mailboxId: input.mailboxId as string,
|
|
136
|
+
messageIdHeader: input.messageIdHeader as string,
|
|
137
|
+
};
|
|
138
|
+
threadRows.push(row);
|
|
139
|
+
return row;
|
|
140
|
+
},
|
|
141
|
+
getByMessageId: async (_cfg: string, messageId: string) => {
|
|
142
|
+
const row = threadRows.find((r) => r.messageId === messageId);
|
|
143
|
+
if (!row) throw new Error(`no thread message ${messageId}`);
|
|
144
|
+
return row;
|
|
145
|
+
},
|
|
146
|
+
findByMessageId: async (_cfg: string, messageId: string) =>
|
|
147
|
+
threadRows.find((r) => r.messageId === messageId) ?? null,
|
|
148
|
+
findAllByMessageId: async (_cfg: string, messageId: string) =>
|
|
149
|
+
threadRows.filter((r) => r.messageId === messageId),
|
|
150
|
+
delete: async (_cfg: string, threadMessageId: string) => {
|
|
151
|
+
const idx = threadRows.findIndex(
|
|
152
|
+
(r) => r.threadMessageId === threadMessageId,
|
|
153
|
+
);
|
|
154
|
+
if (idx >= 0) threadRows.splice(idx, 1);
|
|
155
|
+
},
|
|
156
|
+
} as unknown as IThreadMessageRepository;
|
|
157
|
+
|
|
158
|
+
const mailboxService = {
|
|
159
|
+
get: async (_acc: string, id: string | string[]) => {
|
|
160
|
+
if (Array.isArray(id)) {
|
|
161
|
+
return id.map((i) => mailboxes.get(i)).filter(Boolean);
|
|
162
|
+
}
|
|
163
|
+
return mailboxes.get(id);
|
|
164
|
+
},
|
|
165
|
+
} as unknown as IMailboxRepository;
|
|
166
|
+
|
|
167
|
+
// No Trash configured, so deleteMessages takes the permanent-delete path —
|
|
168
|
+
// the branch that eagerly removes ThreadMessage rows (issue #212), which is
|
|
169
|
+
// where a copy's placement row must be reachable.
|
|
170
|
+
const mailboxSpecialUseService = {
|
|
171
|
+
findTrashMailbox: async () => null,
|
|
172
|
+
} as unknown as IMailboxSpecialUseRepository;
|
|
173
|
+
|
|
174
|
+
const config: MessageMoveConfig = {
|
|
175
|
+
messageService,
|
|
176
|
+
mailboxService,
|
|
177
|
+
mailboxSpecialUseService,
|
|
178
|
+
threadMessageService,
|
|
179
|
+
sqsQueueUrl: "http://localhost:9324/000000000000/message-mgmt",
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const service = new MessageMoveService(config);
|
|
183
|
+
// The service builds a real SQS producer in its constructor; a unit test has
|
|
184
|
+
// no queue, so swap in a recorder.
|
|
185
|
+
const sent: unknown[] = [];
|
|
186
|
+
(
|
|
187
|
+
service as unknown as { sqs: { send: (c: unknown) => Promise<unknown> } }
|
|
188
|
+
).sqs = {
|
|
189
|
+
send: async (command: unknown) => {
|
|
190
|
+
sent.push(command);
|
|
191
|
+
return {};
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
service,
|
|
197
|
+
threadRows,
|
|
198
|
+
messages,
|
|
199
|
+
sent,
|
|
200
|
+
messageService,
|
|
201
|
+
threadMessageService,
|
|
202
|
+
};
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// A message copied into the destination folder, as IMAP later enumerates it:
|
|
206
|
+
// same valid Message-ID header as the source, a real server UID. Sync derives
|
|
207
|
+
// its identity from that header (folder-independent), so the id it computes is
|
|
208
|
+
// the ORIGINAL's — not the copy's folder-scoped id.
|
|
209
|
+
const destinationServerMessage = (uid: number): ImapMessage =>
|
|
210
|
+
({
|
|
211
|
+
uid,
|
|
212
|
+
seq: uid,
|
|
213
|
+
flags: [],
|
|
214
|
+
internalDate: new Date("2023-11-14T22:13:20Z"),
|
|
215
|
+
size: 100,
|
|
216
|
+
modseq: "510",
|
|
217
|
+
envelope: {
|
|
218
|
+
date: "Tue, 14 Nov 2023 22:13:20 +0000",
|
|
219
|
+
subject: "Hello",
|
|
220
|
+
from: [{ mailbox: "sender", host: "example.com" }],
|
|
221
|
+
sender: [],
|
|
222
|
+
replyTo: [],
|
|
223
|
+
to: [{ mailbox: "me", host: "example.com" }],
|
|
224
|
+
cc: [],
|
|
225
|
+
bcc: [],
|
|
226
|
+
inReplyTo: "",
|
|
227
|
+
messageId: HEADER,
|
|
228
|
+
},
|
|
229
|
+
}) as ImapMessage;
|
|
230
|
+
|
|
231
|
+
// A MessageSyncService that reads and writes the SAME rows the copy produced,
|
|
232
|
+
// so "copy then sync" is exercised against one shared world. A stored envelope
|
|
233
|
+
// with a valid Message-ID takes the CHANGEDSINCE path; anything the round tries
|
|
234
|
+
// to create lands in the shared maps, so a duplicate would be visible to the
|
|
235
|
+
// caller's row-count assertions.
|
|
236
|
+
const buildSyncOverDestination = (
|
|
237
|
+
world: ReturnType<typeof buildWorld>,
|
|
238
|
+
changed: ImapMessage[],
|
|
239
|
+
) => {
|
|
240
|
+
const destMailbox = {
|
|
241
|
+
mailboxId: DEST_MAILBOX,
|
|
242
|
+
accountId: ACCOUNT,
|
|
243
|
+
fullPath: "Archive",
|
|
244
|
+
uidValidity: 100,
|
|
245
|
+
lastSyncUid: 1,
|
|
246
|
+
highWaterMarkUid: 20,
|
|
247
|
+
highestModseq: "500",
|
|
248
|
+
cursorState: MailboxCursorState.normal,
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const connection = {
|
|
252
|
+
openBox: async () => ({ uidvalidity: 100, uidnext: 99 }),
|
|
253
|
+
getMailboxStatus: async () => ({
|
|
254
|
+
messages: 10,
|
|
255
|
+
recent: 0,
|
|
256
|
+
unseen: 1,
|
|
257
|
+
uidNext: 99,
|
|
258
|
+
uidValidity: 100,
|
|
259
|
+
highestModseq: "600",
|
|
260
|
+
deletedCount: 0,
|
|
261
|
+
}),
|
|
262
|
+
supportsCondstore: () => true,
|
|
263
|
+
fetchMessagesChangedSince: async () => changed,
|
|
264
|
+
search: async () => [],
|
|
265
|
+
fetchMessages: async () => [],
|
|
266
|
+
fetchEnvelopeSnapshots: async () => [],
|
|
267
|
+
} as unknown as IImapConnection;
|
|
268
|
+
|
|
269
|
+
const connectionFactory = {
|
|
270
|
+
getConnection: () => connection,
|
|
271
|
+
close: async () => {},
|
|
272
|
+
} as ManagedConnectionFactory;
|
|
273
|
+
|
|
274
|
+
const mailboxService = {
|
|
275
|
+
get: async () => destMailbox,
|
|
276
|
+
update: async () => destMailbox,
|
|
277
|
+
} as unknown as IMailboxRepository;
|
|
278
|
+
|
|
279
|
+
// A create that reaches DynamoDB would append to the shared maps, so the
|
|
280
|
+
// caller's "nothing accumulated" assertions catch a regression that mints a
|
|
281
|
+
// third row.
|
|
282
|
+
const unitOfWork: IUnitOfWork = {
|
|
283
|
+
transaction: (fn) =>
|
|
284
|
+
fn({
|
|
285
|
+
message: world.messageService,
|
|
286
|
+
envelope: {
|
|
287
|
+
upsertEnvelope: async () => undefined,
|
|
288
|
+
upsertBodyParts: async () => undefined,
|
|
289
|
+
} as unknown as IEnvelopeRepository,
|
|
290
|
+
address: {
|
|
291
|
+
upsertAddress: async () => undefined,
|
|
292
|
+
upsertEnvelopeAddress: async () => undefined,
|
|
293
|
+
} as unknown as IAddressRepository,
|
|
294
|
+
threadMessage: world.threadMessageService,
|
|
295
|
+
}),
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
return new MessageSyncService(
|
|
299
|
+
connectionFactory,
|
|
300
|
+
mailboxService,
|
|
301
|
+
world.messageService,
|
|
302
|
+
{} as IEnvelopeRepository,
|
|
303
|
+
{} as IAddressRepository,
|
|
304
|
+
world.threadMessageService,
|
|
305
|
+
undefined,
|
|
306
|
+
unitOfWork,
|
|
307
|
+
);
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
describe("MessageMoveService.copyMessage — deterministic per-folder identity (#75)", () => {
|
|
311
|
+
it("derives the copy id from source + destination, not at random", async () => {
|
|
312
|
+
const { service, threadRows } = buildWorld();
|
|
313
|
+
|
|
314
|
+
await service.copyMessages(
|
|
315
|
+
ACCOUNT_CONFIG,
|
|
316
|
+
[SOURCE_ID],
|
|
317
|
+
DEST_MAILBOX,
|
|
318
|
+
ACCOUNT,
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
const expected = deriveCopyMessageId(SOURCE_ID, DEST_MAILBOX);
|
|
322
|
+
const copyRow = threadRows.find((r) => r.mailboxId === DEST_MAILBOX);
|
|
323
|
+
assert.ok(copyRow, "a placement row exists in the destination folder");
|
|
324
|
+
assert.equal(copyRow.messageId, expected);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("a replayed copy converges on one row, no duplicate", async () => {
|
|
328
|
+
const { service, threadRows, messages } = buildWorld();
|
|
329
|
+
|
|
330
|
+
await service.copyMessages(
|
|
331
|
+
ACCOUNT_CONFIG,
|
|
332
|
+
[SOURCE_ID],
|
|
333
|
+
DEST_MAILBOX,
|
|
334
|
+
ACCOUNT,
|
|
335
|
+
);
|
|
336
|
+
// A replay (retried COPY event, or the user copying the same mail again)
|
|
337
|
+
// re-derives the same id. The copy path upserts, so the second write is a
|
|
338
|
+
// no-op on the existing row rather than the CreateFailedConflictError a
|
|
339
|
+
// plain create would throw — the operation is idempotent.
|
|
340
|
+
await service.copyMessages(
|
|
341
|
+
ACCOUNT_CONFIG,
|
|
342
|
+
[SOURCE_ID],
|
|
343
|
+
DEST_MAILBOX,
|
|
344
|
+
ACCOUNT,
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
const copyId = deriveCopyMessageId(SOURCE_ID, DEST_MAILBOX);
|
|
348
|
+
assert.equal(
|
|
349
|
+
threadRows.filter((r) => r.messageId === copyId).length,
|
|
350
|
+
1,
|
|
351
|
+
"exactly one copy row after a replay",
|
|
352
|
+
);
|
|
353
|
+
assert.equal(
|
|
354
|
+
threadRows.length,
|
|
355
|
+
2,
|
|
356
|
+
"the original and one copy — nothing accumulates",
|
|
357
|
+
);
|
|
358
|
+
assert.equal(messages.has(copyId), true);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("copy then a destination-folder sync creates no third row", async () => {
|
|
362
|
+
const world = buildWorld();
|
|
363
|
+
|
|
364
|
+
await world.service.copyMessages(
|
|
365
|
+
ACCOUNT_CONFIG,
|
|
366
|
+
[SOURCE_ID],
|
|
367
|
+
DEST_MAILBOX,
|
|
368
|
+
ACCOUNT,
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
const copyId = deriveCopyMessageId(SOURCE_ID, DEST_MAILBOX);
|
|
372
|
+
assert.equal(world.threadRows.length, 2, "original plus one copy");
|
|
373
|
+
assert.equal(world.messages.size, 2, "original plus one copy");
|
|
374
|
+
|
|
375
|
+
// IMAP now enumerates the copied message in the destination folder. Sync
|
|
376
|
+
// derives its id from the valid Message-ID header — the folder-independent
|
|
377
|
+
// original id, not the copy's — finds the existing original row, and takes
|
|
378
|
+
// the flag-only branch. The crux of #75: no third or duplicate row.
|
|
379
|
+
const sync = buildSyncOverDestination(world, [
|
|
380
|
+
destinationServerMessage(77),
|
|
381
|
+
]);
|
|
382
|
+
const result = await sync.syncMessages(
|
|
383
|
+
DEST_MAILBOX,
|
|
384
|
+
ACCOUNT,
|
|
385
|
+
ACCOUNT_CONFIG,
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
assert.equal(result.syncedCount, 0, "sync created no new message");
|
|
389
|
+
assert.equal(
|
|
390
|
+
world.threadRows.length,
|
|
391
|
+
2,
|
|
392
|
+
"still original plus the copy — sync added nothing",
|
|
393
|
+
);
|
|
394
|
+
assert.equal(
|
|
395
|
+
world.messages.size,
|
|
396
|
+
2,
|
|
397
|
+
"still original plus the copy — sync added nothing",
|
|
398
|
+
);
|
|
399
|
+
assert.equal(
|
|
400
|
+
world.threadRows.filter((r) => r.messageId === copyId).length,
|
|
401
|
+
1,
|
|
402
|
+
"the copy row is untouched",
|
|
403
|
+
);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("copy then delete of the copy removes the copy row and leaves the original", async () => {
|
|
407
|
+
const { service, threadRows } = buildWorld();
|
|
408
|
+
|
|
409
|
+
await service.copyMessages(
|
|
410
|
+
ACCOUNT_CONFIG,
|
|
411
|
+
[SOURCE_ID],
|
|
412
|
+
DEST_MAILBOX,
|
|
413
|
+
ACCOUNT,
|
|
414
|
+
);
|
|
415
|
+
const copyId = deriveCopyMessageId(SOURCE_ID, DEST_MAILBOX);
|
|
416
|
+
|
|
417
|
+
await service.deleteMessages(ACCOUNT_CONFIG, [copyId], ACCOUNT, {
|
|
418
|
+
permanent: true,
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
assert.equal(
|
|
422
|
+
threadRows.some((r) => r.messageId === copyId),
|
|
423
|
+
false,
|
|
424
|
+
"copy row removed",
|
|
425
|
+
);
|
|
426
|
+
assert.equal(
|
|
427
|
+
threadRows.some((r) => r.messageId === SOURCE_ID),
|
|
428
|
+
true,
|
|
429
|
+
"original row survives",
|
|
430
|
+
);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("copy then delete of the original removes the original and leaves the copy — no orphan of the wrong row", async () => {
|
|
434
|
+
const { service, threadRows } = buildWorld();
|
|
435
|
+
|
|
436
|
+
await service.copyMessages(
|
|
437
|
+
ACCOUNT_CONFIG,
|
|
438
|
+
[SOURCE_ID],
|
|
439
|
+
DEST_MAILBOX,
|
|
440
|
+
ACCOUNT,
|
|
441
|
+
);
|
|
442
|
+
const copyId = deriveCopyMessageId(SOURCE_ID, DEST_MAILBOX);
|
|
443
|
+
|
|
444
|
+
await service.deleteMessages(ACCOUNT_CONFIG, [SOURCE_ID], ACCOUNT, {
|
|
445
|
+
permanent: true,
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
assert.equal(
|
|
449
|
+
threadRows.some((r) => r.messageId === SOURCE_ID),
|
|
450
|
+
false,
|
|
451
|
+
"original row removed",
|
|
452
|
+
);
|
|
453
|
+
assert.equal(
|
|
454
|
+
threadRows.some((r) => r.messageId === copyId),
|
|
455
|
+
true,
|
|
456
|
+
"copy row survives its own delete boundary",
|
|
457
|
+
);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("a copy into two folders is two distinct, reachable rows", async () => {
|
|
461
|
+
const { service, threadRows } = buildWorld();
|
|
462
|
+
|
|
463
|
+
await service.copyMessages(
|
|
464
|
+
ACCOUNT_CONFIG,
|
|
465
|
+
[SOURCE_ID],
|
|
466
|
+
DEST_MAILBOX,
|
|
467
|
+
ACCOUNT,
|
|
468
|
+
);
|
|
469
|
+
await service.copyMessages(
|
|
470
|
+
ACCOUNT_CONFIG,
|
|
471
|
+
[SOURCE_ID],
|
|
472
|
+
SOURCE_MAILBOX,
|
|
473
|
+
ACCOUNT,
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
const idArchive = deriveCopyMessageId(SOURCE_ID, DEST_MAILBOX);
|
|
477
|
+
const idInbox = deriveCopyMessageId(SOURCE_ID, SOURCE_MAILBOX);
|
|
478
|
+
assert.notEqual(idArchive, idInbox);
|
|
479
|
+
assert.equal(threadRows.filter((r) => r.messageId === idArchive).length, 1);
|
|
480
|
+
assert.equal(threadRows.filter((r) => r.messageId === idInbox).length, 1);
|
|
481
|
+
});
|
|
482
|
+
});
|
package/src/message-move.ts
CHANGED
|
@@ -10,7 +10,7 @@ import type {
|
|
|
10
10
|
IMessageRepository,
|
|
11
11
|
IThreadMessageRepository,
|
|
12
12
|
} from "@remit/data-ports";
|
|
13
|
-
import {
|
|
13
|
+
import { deriveCopyMessageId } from "@remit/data-ports/id";
|
|
14
14
|
import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
|
|
15
15
|
import { createQueueProducer } from "@remit/sqs-client/producer";
|
|
16
16
|
|
|
@@ -450,11 +450,20 @@ export class MessageMoveService {
|
|
|
450
450
|
destinationMailboxId,
|
|
451
451
|
);
|
|
452
452
|
|
|
453
|
-
//
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
//
|
|
457
|
-
|
|
453
|
+
// Deterministic identity for the copy: derived from the source message and
|
|
454
|
+
// the destination mailbox, so a replayed COPY event or a repeated copy
|
|
455
|
+
// resolves to the same row instead of a fresh unreachable duplicate. A
|
|
456
|
+
// random id (the prior behaviour) left rows no sync or delete could reach
|
|
457
|
+
// (issue #75).
|
|
458
|
+
const newMessageId = deriveCopyMessageId(messageId, destinationMailboxId);
|
|
459
|
+
|
|
460
|
+
// Upsert, not create: the id is deterministic, so a repeated copy or a
|
|
461
|
+
// retry after a partial failure (row written, event never delivered)
|
|
462
|
+
// re-derives the same id. create throws on the existing row and strands
|
|
463
|
+
// the copy at uid=0 with an undelivered event; upsert no-ops on the row
|
|
464
|
+
// and lets the event be re-enqueued below, keeping the operation
|
|
465
|
+
// idempotent and the copy re-drivable (issue #75).
|
|
466
|
+
await this.messageService.upsert({
|
|
458
467
|
messageId: newMessageId,
|
|
459
468
|
mailboxId: destinationMailboxId,
|
|
460
469
|
uid: 0, // Will be updated by worker after IMAP COPY
|
|
@@ -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,
|