@remit/mailbox-service 0.0.15 → 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-parse.test.ts +55 -0
- package/src/body-parse.ts +61 -0
- package/src/body-sync-quarantine.test.ts +219 -0
- package/src/body-sync-terminal.test.ts +48 -3
- package/src/body-sync-terminal.ts +7 -3
- package/src/body-sync.ts +167 -3
- package/src/flag-push-terminal.test.ts +55 -2
- package/src/flag-push-terminal.ts +16 -6
- package/src/imapflow-connection.test.ts +97 -2
- package/src/imapflow-connection.ts +30 -27
- package/src/index.ts +16 -0
- package/src/message-presence.test.ts +50 -0
- package/src/message-presence.ts +35 -0
- package/src/message-sync-quarantine.test.ts +407 -0
- package/src/message-sync.ts +135 -18
- package/src/placement-move-terminal.test.ts +58 -2
- package/src/placement-move-terminal.ts +16 -5
- package/src/quarantine.test.ts +191 -0
- package/src/quarantine.ts +228 -0
|
@@ -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,
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
toInternalDate,
|
|
6
6
|
toIsoDateString,
|
|
7
7
|
} from "./imapflow-connection.js";
|
|
8
|
+
import type { ImapMessage } from "./types.js";
|
|
8
9
|
|
|
9
10
|
describe("toIsoDateString", () => {
|
|
10
11
|
it("converts a Date to an ISO string", () => {
|
|
@@ -111,6 +112,52 @@ const fakeMailbox = (path: string, exists: number) => ({
|
|
|
111
112
|
readOnly: true,
|
|
112
113
|
});
|
|
113
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
|
+
|
|
114
161
|
describe("ImapFlowConnection.getMailboxStatus — deletedCount (#1042)", () => {
|
|
115
162
|
it("returns the SEARCH \\Deleted count alongside the STATUS counts", async () => {
|
|
116
163
|
const searchQueries: Array<Record<string, unknown>> = [];
|
|
@@ -151,7 +198,7 @@ describe("ImapFlowConnection.getMailboxStatus — deletedCount (#1042)", () => {
|
|
|
151
198
|
highestModseq: 0n,
|
|
152
199
|
}),
|
|
153
200
|
mailboxOpen: async (path: string) => fakeMailbox(path, 4),
|
|
154
|
-
search: async () =>
|
|
201
|
+
search: async () => [],
|
|
155
202
|
});
|
|
156
203
|
|
|
157
204
|
const status = await connection.getMailboxStatus("INBOX");
|
|
@@ -172,7 +219,7 @@ describe("ImapFlowConnection.getMailboxStatus — deletedCount (#1042)", () => {
|
|
|
172
219
|
highestModseq: modseq,
|
|
173
220
|
}),
|
|
174
221
|
mailboxOpen: async (path: string) => fakeMailbox(path, 1),
|
|
175
|
-
search: async () =>
|
|
222
|
+
search: async () => [],
|
|
176
223
|
});
|
|
177
224
|
|
|
178
225
|
const status = await connection.getMailboxStatus("INBOX");
|
|
@@ -266,3 +313,51 @@ describe("ImapFlowConnection CONDSTORE (reader#20)", () => {
|
|
|
266
313
|
assert.deepStrictEqual(messages[0].flags, ["\\Seen"]);
|
|
267
314
|
});
|
|
268
315
|
});
|
|
316
|
+
|
|
317
|
+
describe("ImapFlowConnection message envelopes (issue #72)", () => {
|
|
318
|
+
const fetchRows = async (
|
|
319
|
+
rows: Array<Record<string, unknown>>,
|
|
320
|
+
): Promise<ImapMessage[]> => {
|
|
321
|
+
const connection = buildConnectionWithClient({
|
|
322
|
+
enabled: new Set(),
|
|
323
|
+
mailbox: fakeMailbox("INBOX", 1),
|
|
324
|
+
mailboxOpen: async (path: string) => fakeMailbox(path, 1),
|
|
325
|
+
fetch: () =>
|
|
326
|
+
(async function* () {
|
|
327
|
+
for (const row of rows) yield row;
|
|
328
|
+
})(),
|
|
329
|
+
});
|
|
330
|
+
Object.assign(connection as unknown as Record<string, unknown>, {
|
|
331
|
+
currentMailbox: "INBOX",
|
|
332
|
+
});
|
|
333
|
+
return connection.fetchMessages([1]);
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
it("leaves an absent ENVELOPE absent instead of synthesising an empty one", async () => {
|
|
337
|
+
// A synthesised envelope made every `if (!msg.envelope)` guard downstream
|
|
338
|
+
// unreachable, so the row was saved as a message with no sender, subject
|
|
339
|
+
// or date under a `generated:` key — indistinguishable from real mail.
|
|
340
|
+
const [message] = await fetchRows([
|
|
341
|
+
{ uid: 1, seq: 1, internalDate: new Date(0), size: 10 },
|
|
342
|
+
]);
|
|
343
|
+
assert.equal(message?.envelope, undefined);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("still converts an envelope the server did send", async () => {
|
|
347
|
+
const [message] = await fetchRows([
|
|
348
|
+
{
|
|
349
|
+
uid: 1,
|
|
350
|
+
seq: 1,
|
|
351
|
+
internalDate: new Date(0),
|
|
352
|
+
size: 10,
|
|
353
|
+
envelope: {
|
|
354
|
+
subject: "Hello",
|
|
355
|
+
messageId: "<a@example.com>",
|
|
356
|
+
from: [{ address: "sender@example.com" }],
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
]);
|
|
360
|
+
assert.equal(message?.envelope?.subject, "Hello");
|
|
361
|
+
assert.equal(message?.envelope?.from[0]?.mailbox, "sender");
|
|
362
|
+
});
|
|
363
|
+
});
|
|
@@ -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
|
};
|
|
@@ -591,20 +598,23 @@ export class ImapFlowConnection {
|
|
|
591
598
|
for await (const msg of fetchIterator) {
|
|
592
599
|
// imapflow occasionally yields a row with undefined uid or internalDate
|
|
593
600
|
// on back-to-back FETCH calls (e.g. after a body-fetch on the same UID).
|
|
594
|
-
//
|
|
595
|
-
//
|
|
596
|
-
//
|
|
601
|
+
// See #408 for the investigation. This is the client library glitching,
|
|
602
|
+
// not the message being malformed, so the row is dropped rather than
|
|
603
|
+
// quarantined — recording it as a defective message would advance a
|
|
604
|
+
// cursor past mail that is fine (issue #72).
|
|
605
|
+
//
|
|
606
|
+
// Dropping it is only safe because the caller treats a requested UID
|
|
607
|
+
// with no row as unconsumed and holds its watermark below it. It used
|
|
608
|
+
// to advance regardless, which is how a transient client glitch turned
|
|
609
|
+
// into a message never fetched again.
|
|
597
610
|
if (msg.uid == null || msg.internalDate == null) {
|
|
598
611
|
continue;
|
|
599
612
|
}
|
|
600
613
|
|
|
601
614
|
// Coerce INTERNALDATE without ever throwing: a malformed value must not
|
|
602
|
-
// abort the whole fetch batch.
|
|
603
|
-
//
|
|
604
|
-
const internalDate = toInternalDate(msg.internalDate);
|
|
605
|
-
if (internalDate === null) {
|
|
606
|
-
continue;
|
|
607
|
-
}
|
|
615
|
+
// abort the whole fetch batch. A bad value falls back to now; an absent
|
|
616
|
+
// one cannot reach here, having been dropped above.
|
|
617
|
+
const internalDate = toInternalDate(msg.internalDate) ?? new Date();
|
|
608
618
|
|
|
609
619
|
// Parse References header if present
|
|
610
620
|
const references = await this.parseReferencesHeader(msg.headers);
|
|
@@ -840,20 +850,13 @@ export class ImapFlowConnection {
|
|
|
840
850
|
}
|
|
841
851
|
| undefined,
|
|
842
852
|
): ImapMessage["envelope"] => {
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
to: [],
|
|
851
|
-
cc: [],
|
|
852
|
-
bcc: [],
|
|
853
|
-
inReplyTo: "",
|
|
854
|
-
messageId: "",
|
|
855
|
-
};
|
|
856
|
-
}
|
|
853
|
+
// An absent ENVELOPE stays absent. Synthesising an empty one here made
|
|
854
|
+
// every `if (!msg.envelope)` guard downstream unreachable, so a FETCH row
|
|
855
|
+
// that carried no envelope was saved as a row with no sender, no subject
|
|
856
|
+
// and no date, keyed by the `generated:` fallback — indistinguishable
|
|
857
|
+
// from a real message. The field is optional on `ImapMessage` precisely
|
|
858
|
+
// so callers can see the difference (issue #72).
|
|
859
|
+
if (!envelope) return undefined;
|
|
857
860
|
|
|
858
861
|
const convertAddresses = (
|
|
859
862
|
addrs?: Array<{ name?: string; address?: string }>,
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,10 @@ export {
|
|
|
15
15
|
type ParsedAttributes,
|
|
16
16
|
parseImapAttributes,
|
|
17
17
|
} from "./attribute-mapper.js";
|
|
18
|
+
export {
|
|
19
|
+
BodyParseError,
|
|
20
|
+
parseMessageBody,
|
|
21
|
+
} from "./body-parse.js";
|
|
18
22
|
export {
|
|
19
23
|
type BodySyncLogger,
|
|
20
24
|
BodySyncService,
|
|
@@ -22,6 +26,7 @@ export {
|
|
|
22
26
|
extractPrimaryFromEmail,
|
|
23
27
|
type FetchBodyResult,
|
|
24
28
|
type PlacementConfig,
|
|
29
|
+
type QuarantineConfig,
|
|
25
30
|
type SyncBodiesResult,
|
|
26
31
|
toParsedBody,
|
|
27
32
|
} from "./body-sync.js";
|
|
@@ -162,6 +167,7 @@ export {
|
|
|
162
167
|
type ParsedMessageContent,
|
|
163
168
|
parseMessageContent,
|
|
164
169
|
} from "./message-parser.js";
|
|
170
|
+
export { isMessageGoneFromOpenMailbox } from "./message-presence.js";
|
|
165
171
|
export {
|
|
166
172
|
type ImapConnectionFactory,
|
|
167
173
|
MessageSyncService,
|
|
@@ -188,6 +194,16 @@ export {
|
|
|
188
194
|
type ResolveExhaustedPlacementMoveResult,
|
|
189
195
|
resolveExhaustedPlacementMoveFailure,
|
|
190
196
|
} from "./placement-move-terminal.js";
|
|
197
|
+
export {
|
|
198
|
+
type QuarantineContext,
|
|
199
|
+
QuarantinedUids,
|
|
200
|
+
type QuarantineFailure,
|
|
201
|
+
type QuarantineLogger,
|
|
202
|
+
type QuarantineMessageShape,
|
|
203
|
+
QuarantineService,
|
|
204
|
+
resolveMailboxRole,
|
|
205
|
+
shapeFromMessageData,
|
|
206
|
+
} from "./quarantine.js";
|
|
191
207
|
export {
|
|
192
208
|
extractSnippetFromEmail,
|
|
193
209
|
generateSnippet,
|
|
@@ -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
|
+
};
|