@remit/mailbox-service 0.0.28 → 0.0.30
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/index.ts +15 -0
- package/src/list-id-backfill.test.ts +416 -0
- package/src/list-id-backfill.ts +252 -0
- package/src/mailbox-presence.ts +57 -0
- package/src/mailbox-sync.test.ts +159 -0
- package/src/mailbox-sync.ts +29 -1
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -122,6 +122,17 @@ export {
|
|
|
122
122
|
createImapFlowConnectionWithCredentials,
|
|
123
123
|
ImapFlowConnection,
|
|
124
124
|
} from "./imapflow-connection.js";
|
|
125
|
+
export {
|
|
126
|
+
backfillListIds,
|
|
127
|
+
type ListIdBackfillCheckpoint,
|
|
128
|
+
type ListIdBackfillCheckpointStore,
|
|
129
|
+
type ListIdBackfillDeps,
|
|
130
|
+
type ListIdBackfillLogger,
|
|
131
|
+
type ListIdBackfillOptions,
|
|
132
|
+
type ListIdBackfillProgress,
|
|
133
|
+
type ListIdBackfillResult,
|
|
134
|
+
type ListIdBackfillTotals,
|
|
135
|
+
} from "./list-id-backfill.js";
|
|
125
136
|
export {
|
|
126
137
|
guardConnectionCursor,
|
|
127
138
|
guardMailboxCursor,
|
|
@@ -148,6 +159,10 @@ export {
|
|
|
148
159
|
validateMailboxOperation,
|
|
149
160
|
validateMailboxPath,
|
|
150
161
|
} from "./mailbox-management.js";
|
|
162
|
+
export {
|
|
163
|
+
isFolderOffServer,
|
|
164
|
+
isMailboxNotOnServer,
|
|
165
|
+
} from "./mailbox-presence.js";
|
|
151
166
|
export {
|
|
152
167
|
type CreateMailboxQueueInput,
|
|
153
168
|
type MailboxQueueConfig,
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ListId is only written at body-sync time, going forward (issue #263). A row
|
|
3
|
+
* synced before header extraction shipped keeps `listId` unset forever, so a
|
|
4
|
+
* `ListId` filter clause silently under-matches the back catalogue.
|
|
5
|
+
*
|
|
6
|
+
* These tests pin the one-time, resumable pass that closes that gap: it reads
|
|
7
|
+
* each candidate's already-stored raw source (never IMAP), extracts
|
|
8
|
+
* `List-Id`, and writes only that field.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { describe, it } from "node:test";
|
|
13
|
+
import type {
|
|
14
|
+
AccountConfigItem,
|
|
15
|
+
IAccountConfigRepository,
|
|
16
|
+
IMessageRepository,
|
|
17
|
+
IThreadMessageRepository,
|
|
18
|
+
MessageItem,
|
|
19
|
+
ResultList,
|
|
20
|
+
ThreadMessageItem,
|
|
21
|
+
UpdateThreadMessageInput,
|
|
22
|
+
} from "@remit/data-ports";
|
|
23
|
+
import type { StorageService } from "@remit/storage-service";
|
|
24
|
+
import {
|
|
25
|
+
backfillListIds,
|
|
26
|
+
type ListIdBackfillCheckpoint,
|
|
27
|
+
type ListIdBackfillProgress,
|
|
28
|
+
} from "./list-id-backfill.js";
|
|
29
|
+
|
|
30
|
+
const LIST_EML = Buffer.from(
|
|
31
|
+
[
|
|
32
|
+
"From: Weekly News <news@example.com>",
|
|
33
|
+
"To: me@example.com",
|
|
34
|
+
"Subject: This week",
|
|
35
|
+
"List-Id: Weekly News <weekly.news.example.com>",
|
|
36
|
+
"Content-Type: text/plain",
|
|
37
|
+
"",
|
|
38
|
+
"news",
|
|
39
|
+
].join("\r\n"),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const PLAIN_EML = Buffer.from(
|
|
43
|
+
[
|
|
44
|
+
"From: Alice <alice@example.com>",
|
|
45
|
+
"To: me@example.com",
|
|
46
|
+
"Subject: Hi",
|
|
47
|
+
"Content-Type: text/plain",
|
|
48
|
+
"",
|
|
49
|
+
"hi",
|
|
50
|
+
].join("\r\n"),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const MALFORMED_LIST_ID_EML = Buffer.from(
|
|
54
|
+
[
|
|
55
|
+
"From: Weird <weird@example.com>",
|
|
56
|
+
"To: me@example.com",
|
|
57
|
+
"Subject: Odd header",
|
|
58
|
+
"List-Id:",
|
|
59
|
+
"Content-Type: text/plain",
|
|
60
|
+
"",
|
|
61
|
+
"body",
|
|
62
|
+
].join("\r\n"),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const row = (overrides: Partial<ThreadMessageItem>): ThreadMessageItem =>
|
|
66
|
+
({
|
|
67
|
+
threadMessageId: "tm-1",
|
|
68
|
+
accountConfigId: "acc-1",
|
|
69
|
+
threadId: "thread-1",
|
|
70
|
+
messageId: "m-1",
|
|
71
|
+
mailboxId: "mb-1",
|
|
72
|
+
uid: 1,
|
|
73
|
+
referenceOrder: 0,
|
|
74
|
+
internalDate: 1,
|
|
75
|
+
sentDate: 1,
|
|
76
|
+
isRead: false,
|
|
77
|
+
hasAttachment: false,
|
|
78
|
+
star: "none",
|
|
79
|
+
hasStars: false,
|
|
80
|
+
isDeleted: false,
|
|
81
|
+
category: "uncategorized",
|
|
82
|
+
createdAt: 1,
|
|
83
|
+
updatedAt: 1,
|
|
84
|
+
...overrides,
|
|
85
|
+
}) as unknown as ThreadMessageItem;
|
|
86
|
+
|
|
87
|
+
const message = (overrides: Partial<MessageItem>): MessageItem =>
|
|
88
|
+
({
|
|
89
|
+
messageId: "m-1",
|
|
90
|
+
mailboxId: "mb-1",
|
|
91
|
+
uid: 1,
|
|
92
|
+
status: "active",
|
|
93
|
+
syncStatus: "synced",
|
|
94
|
+
category: "uncategorized",
|
|
95
|
+
hasListUnsubscribe: false,
|
|
96
|
+
movedByRemit: false,
|
|
97
|
+
createdAt: 1,
|
|
98
|
+
updatedAt: 1,
|
|
99
|
+
...overrides,
|
|
100
|
+
}) as unknown as MessageItem;
|
|
101
|
+
|
|
102
|
+
interface Harness {
|
|
103
|
+
accountConfigService: Pick<IAccountConfigRepository, "listAll">;
|
|
104
|
+
threadMessageService: Pick<
|
|
105
|
+
IThreadMessageRepository,
|
|
106
|
+
"listByAccount" | "update"
|
|
107
|
+
>;
|
|
108
|
+
messageService: Pick<IMessageRepository, "get">;
|
|
109
|
+
storageService: Pick<StorageService, "retrieve">;
|
|
110
|
+
updates: Array<{ threadMessageId: string; input: UpdateThreadMessageInput }>;
|
|
111
|
+
retrieved: string[];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const buildHarness = (options: {
|
|
115
|
+
accounts?: AccountConfigItem[];
|
|
116
|
+
rows: ThreadMessageItem[];
|
|
117
|
+
messages: MessageItem[];
|
|
118
|
+
retrieve?: (key: string) => Promise<Buffer>;
|
|
119
|
+
pageSize?: number;
|
|
120
|
+
}): Harness => {
|
|
121
|
+
const accounts = options.accounts ?? [
|
|
122
|
+
{ accountConfigId: "acc-1" } as unknown as AccountConfigItem,
|
|
123
|
+
];
|
|
124
|
+
const messagesById = new Map(options.messages.map((m) => [m.messageId, m]));
|
|
125
|
+
const updates: Array<{
|
|
126
|
+
threadMessageId: string;
|
|
127
|
+
input: UpdateThreadMessageInput;
|
|
128
|
+
}> = [];
|
|
129
|
+
const retrieved: string[] = [];
|
|
130
|
+
const pageSize = options.pageSize ?? 200;
|
|
131
|
+
|
|
132
|
+
const accountConfigService: Pick<IAccountConfigRepository, "listAll"> = {
|
|
133
|
+
listAll: async () => accounts,
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const threadMessageService: Pick<
|
|
137
|
+
IThreadMessageRepository,
|
|
138
|
+
"listByAccount" | "update"
|
|
139
|
+
> = {
|
|
140
|
+
listByAccount: async (
|
|
141
|
+
accountConfigId: string,
|
|
142
|
+
opts?: { limit?: number; continuationToken?: string },
|
|
143
|
+
): Promise<ResultList<ThreadMessageItem>> => {
|
|
144
|
+
const scoped = options.rows.filter(
|
|
145
|
+
(r) => r.accountConfigId === accountConfigId,
|
|
146
|
+
);
|
|
147
|
+
const start = opts?.continuationToken
|
|
148
|
+
? Number(opts.continuationToken)
|
|
149
|
+
: 0;
|
|
150
|
+
const limit = opts?.limit ?? pageSize;
|
|
151
|
+
const page = scoped.slice(start, start + limit);
|
|
152
|
+
const nextStart = start + page.length;
|
|
153
|
+
return {
|
|
154
|
+
items: page,
|
|
155
|
+
continuationToken:
|
|
156
|
+
nextStart < scoped.length ? String(nextStart) : undefined,
|
|
157
|
+
};
|
|
158
|
+
},
|
|
159
|
+
update: async (
|
|
160
|
+
_accountConfigId: string,
|
|
161
|
+
threadMessageId: string,
|
|
162
|
+
input: UpdateThreadMessageInput,
|
|
163
|
+
) => {
|
|
164
|
+
updates.push({ threadMessageId, input });
|
|
165
|
+
return row({ threadMessageId, ...input });
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const messageService: Pick<IMessageRepository, "get"> = {
|
|
170
|
+
get: (async (messageIds: string | string[]) => {
|
|
171
|
+
if (Array.isArray(messageIds)) {
|
|
172
|
+
return messageIds
|
|
173
|
+
.map((id) => messagesById.get(id))
|
|
174
|
+
.filter((m): m is MessageItem => m !== undefined);
|
|
175
|
+
}
|
|
176
|
+
const found = messagesById.get(messageIds);
|
|
177
|
+
if (!found) throw new Error(`no fixture for ${messageIds}`);
|
|
178
|
+
return found;
|
|
179
|
+
}) as IMessageRepository["get"],
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const storageService: Pick<StorageService, "retrieve"> = {
|
|
183
|
+
retrieve: async (key: string) => {
|
|
184
|
+
retrieved.push(key);
|
|
185
|
+
return options.retrieve ? options.retrieve(key) : LIST_EML;
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
accountConfigService,
|
|
191
|
+
threadMessageService,
|
|
192
|
+
messageService,
|
|
193
|
+
storageService,
|
|
194
|
+
updates,
|
|
195
|
+
retrieved,
|
|
196
|
+
};
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
describe("backfillListIds", () => {
|
|
200
|
+
it("writes the extracted List-Id for a candidate row", async () => {
|
|
201
|
+
const harness = buildHarness({
|
|
202
|
+
rows: [row({ threadMessageId: "tm-1", messageId: "m-1" })],
|
|
203
|
+
messages: [message({ messageId: "m-1", bodyStorageKey: "s3://m-1" })],
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const result = await backfillListIds(harness);
|
|
207
|
+
|
|
208
|
+
assert.equal(result.backfilled, 1);
|
|
209
|
+
assert.equal(result.failed, 0);
|
|
210
|
+
assert.equal(harness.updates.length, 1);
|
|
211
|
+
assert.equal(harness.updates[0].input.listId, "weekly.news.example.com");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("leaves listId empty, without error, when the message has no List-Id header", async () => {
|
|
215
|
+
const harness = buildHarness({
|
|
216
|
+
rows: [row({ threadMessageId: "tm-1", messageId: "m-1" })],
|
|
217
|
+
messages: [message({ messageId: "m-1", bodyStorageKey: "s3://m-1" })],
|
|
218
|
+
retrieve: async () => PLAIN_EML,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const result = await backfillListIds(harness);
|
|
222
|
+
|
|
223
|
+
assert.equal(result.noListId, 1);
|
|
224
|
+
assert.equal(result.failed, 0);
|
|
225
|
+
assert.deepEqual(harness.updates, []);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("does not error on a malformed List-Id header", async () => {
|
|
229
|
+
const harness = buildHarness({
|
|
230
|
+
rows: [row({ threadMessageId: "tm-1", messageId: "m-1" })],
|
|
231
|
+
messages: [message({ messageId: "m-1", bodyStorageKey: "s3://m-1" })],
|
|
232
|
+
retrieve: async () => MALFORMED_LIST_ID_EML,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const result = await backfillListIds(harness);
|
|
236
|
+
|
|
237
|
+
assert.equal(result.failed, 0);
|
|
238
|
+
assert.equal(result.noListId, 1);
|
|
239
|
+
assert.deepEqual(harness.updates, []);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("skips a row whose listId is already set, without reading storage", async () => {
|
|
243
|
+
const harness = buildHarness({
|
|
244
|
+
rows: [
|
|
245
|
+
row({
|
|
246
|
+
threadMessageId: "tm-1",
|
|
247
|
+
messageId: "m-1",
|
|
248
|
+
listId: "already.set",
|
|
249
|
+
}),
|
|
250
|
+
],
|
|
251
|
+
messages: [message({ messageId: "m-1", bodyStorageKey: "s3://m-1" })],
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const result = await backfillListIds(harness);
|
|
255
|
+
|
|
256
|
+
assert.equal(result.alreadySet, 1);
|
|
257
|
+
assert.equal(result.backfilled, 0);
|
|
258
|
+
assert.deepEqual(harness.retrieved, []);
|
|
259
|
+
assert.deepEqual(harness.updates, []);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("skips a candidate whose body was never synced, without reading storage", async () => {
|
|
263
|
+
const harness = buildHarness({
|
|
264
|
+
rows: [row({ threadMessageId: "tm-1", messageId: "m-1" })],
|
|
265
|
+
messages: [message({ messageId: "m-1", bodyStorageKey: undefined })],
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const result = await backfillListIds(harness);
|
|
269
|
+
|
|
270
|
+
assert.equal(result.skippedNoBody, 1);
|
|
271
|
+
assert.deepEqual(harness.retrieved, []);
|
|
272
|
+
assert.deepEqual(harness.updates, []);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("contains a storage failure to the one message and keeps going", async () => {
|
|
276
|
+
const harness = buildHarness({
|
|
277
|
+
rows: [
|
|
278
|
+
row({ threadMessageId: "tm-bad", messageId: "m-bad" }),
|
|
279
|
+
row({ threadMessageId: "tm-good", messageId: "m-good" }),
|
|
280
|
+
],
|
|
281
|
+
messages: [
|
|
282
|
+
message({ messageId: "m-bad", bodyStorageKey: "s3://m-bad" }),
|
|
283
|
+
message({ messageId: "m-good", bodyStorageKey: "s3://m-good" }),
|
|
284
|
+
],
|
|
285
|
+
retrieve: async (key) => {
|
|
286
|
+
if (key === "s3://m-bad") throw new Error("AccessDenied");
|
|
287
|
+
return LIST_EML;
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
const result = await backfillListIds(harness);
|
|
292
|
+
|
|
293
|
+
assert.equal(result.failed, 1);
|
|
294
|
+
assert.deepEqual(result.failedThreadMessageIds, ["tm-bad"]);
|
|
295
|
+
assert.equal(result.backfilled, 1);
|
|
296
|
+
assert.equal(harness.updates.length, 1);
|
|
297
|
+
assert.equal(harness.updates[0].threadMessageId, "tm-good");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("reports progress as pages are processed", async () => {
|
|
301
|
+
const rows = Array.from({ length: 3 }, (_, i) =>
|
|
302
|
+
row({ threadMessageId: `tm-${i}`, messageId: `m-${i}` }),
|
|
303
|
+
);
|
|
304
|
+
const messages = rows.map((r) =>
|
|
305
|
+
message({
|
|
306
|
+
messageId: r.messageId,
|
|
307
|
+
bodyStorageKey: `s3://${r.messageId}`,
|
|
308
|
+
}),
|
|
309
|
+
);
|
|
310
|
+
const harness = buildHarness({ rows, messages, pageSize: 2 });
|
|
311
|
+
const progress: ListIdBackfillProgress[] = [];
|
|
312
|
+
|
|
313
|
+
const result = await backfillListIds(harness, {
|
|
314
|
+
batchSize: 2,
|
|
315
|
+
onProgress: (p) => progress.push({ ...p }),
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
assert.equal(result.backfilled, 3);
|
|
319
|
+
assert.equal(progress.length, 2);
|
|
320
|
+
assert.equal(progress[0].scanned, 2);
|
|
321
|
+
assert.equal(progress[1].scanned, 3);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("scans every account returned by listAll", async () => {
|
|
325
|
+
const harness = buildHarness({
|
|
326
|
+
accounts: [
|
|
327
|
+
{ accountConfigId: "acc-1" } as unknown as AccountConfigItem,
|
|
328
|
+
{ accountConfigId: "acc-2" } as unknown as AccountConfigItem,
|
|
329
|
+
],
|
|
330
|
+
rows: [
|
|
331
|
+
row({
|
|
332
|
+
threadMessageId: "tm-1",
|
|
333
|
+
messageId: "m-1",
|
|
334
|
+
accountConfigId: "acc-1",
|
|
335
|
+
}),
|
|
336
|
+
row({
|
|
337
|
+
threadMessageId: "tm-2",
|
|
338
|
+
messageId: "m-2",
|
|
339
|
+
accountConfigId: "acc-2",
|
|
340
|
+
}),
|
|
341
|
+
],
|
|
342
|
+
messages: [
|
|
343
|
+
message({ messageId: "m-1", bodyStorageKey: "s3://m-1" }),
|
|
344
|
+
message({ messageId: "m-2", bodyStorageKey: "s3://m-2" }),
|
|
345
|
+
],
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const result = await backfillListIds(harness);
|
|
349
|
+
|
|
350
|
+
assert.equal(result.backfilled, 2);
|
|
351
|
+
assert.equal(harness.updates.length, 2);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("checkpoints after each page and clears it on completion", async () => {
|
|
355
|
+
const rows = Array.from({ length: 3 }, (_, i) =>
|
|
356
|
+
row({ threadMessageId: `tm-${i}`, messageId: `m-${i}` }),
|
|
357
|
+
);
|
|
358
|
+
const messages = rows.map((r) =>
|
|
359
|
+
message({
|
|
360
|
+
messageId: r.messageId,
|
|
361
|
+
bodyStorageKey: `s3://${r.messageId}`,
|
|
362
|
+
}),
|
|
363
|
+
);
|
|
364
|
+
const harness = buildHarness({ rows, messages, pageSize: 2 });
|
|
365
|
+
|
|
366
|
+
const saved: ListIdBackfillCheckpoint[] = [];
|
|
367
|
+
let cleared = false;
|
|
368
|
+
|
|
369
|
+
await backfillListIds(harness, {
|
|
370
|
+
batchSize: 2,
|
|
371
|
+
checkpointStore: {
|
|
372
|
+
load: async () => undefined,
|
|
373
|
+
save: async (checkpoint) => {
|
|
374
|
+
saved.push(checkpoint);
|
|
375
|
+
},
|
|
376
|
+
clear: async () => {
|
|
377
|
+
cleared = true;
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
assert.equal(saved.length, 2);
|
|
383
|
+
assert.equal(saved[0].continuationToken, "2");
|
|
384
|
+
assert.equal(saved[1].continuationToken, undefined);
|
|
385
|
+
assert.equal(cleared, true);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("resumes from a saved checkpoint instead of rescanning from the start", async () => {
|
|
389
|
+
const rows = Array.from({ length: 3 }, (_, i) =>
|
|
390
|
+
row({ threadMessageId: `tm-${i}`, messageId: `m-${i}` }),
|
|
391
|
+
);
|
|
392
|
+
const messages = rows.map((r) =>
|
|
393
|
+
message({
|
|
394
|
+
messageId: r.messageId,
|
|
395
|
+
bodyStorageKey: `s3://${r.messageId}`,
|
|
396
|
+
}),
|
|
397
|
+
);
|
|
398
|
+
const harness = buildHarness({ rows, messages, pageSize: 2 });
|
|
399
|
+
|
|
400
|
+
const result = await backfillListIds(harness, {
|
|
401
|
+
batchSize: 2,
|
|
402
|
+
checkpointStore: {
|
|
403
|
+
load: async () => ({ accountIndex: 0, continuationToken: "2" }),
|
|
404
|
+
save: async () => {},
|
|
405
|
+
clear: async () => {},
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
assert.equal(result.scanned, 1);
|
|
410
|
+
assert.equal(result.backfilled, 1);
|
|
411
|
+
assert.deepEqual(
|
|
412
|
+
harness.updates.map((u) => u.threadMessageId),
|
|
413
|
+
["tm-2"],
|
|
414
|
+
);
|
|
415
|
+
});
|
|
416
|
+
});
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IAccountConfigRepository,
|
|
3
|
+
IMessageRepository,
|
|
4
|
+
IThreadMessageRepository,
|
|
5
|
+
ThreadMessageItem,
|
|
6
|
+
} from "@remit/data-ports";
|
|
7
|
+
import type { StorageService } from "@remit/storage-service";
|
|
8
|
+
import { parseMessageBody } from "./body-parse.js";
|
|
9
|
+
import { extractListId } from "./filters/list-id.js";
|
|
10
|
+
|
|
11
|
+
const DEFAULT_BATCH_SIZE = 200;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Where the full-corpus pass left off: the index into the account list (in
|
|
15
|
+
* `listAll()` order) it was working through, and the page cursor within that
|
|
16
|
+
* account. Resuming skips every account before it entirely and re-opens the
|
|
17
|
+
* in-flight one at its saved cursor, rather than re-scanning the whole corpus
|
|
18
|
+
* from the top after an interruption.
|
|
19
|
+
*/
|
|
20
|
+
export interface ListIdBackfillCheckpoint {
|
|
21
|
+
accountIndex: number;
|
|
22
|
+
continuationToken?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Persistence for {@link ListIdBackfillCheckpoint}, injected so the pass stays
|
|
27
|
+
* testable with an in-memory fake; the real entrypoint backs it with a file.
|
|
28
|
+
* `clear()` runs once the whole corpus has been scanned, so a later run starts
|
|
29
|
+
* fresh rather than reading a stale finished-run checkpoint.
|
|
30
|
+
*/
|
|
31
|
+
export interface ListIdBackfillCheckpointStore {
|
|
32
|
+
load(): Promise<ListIdBackfillCheckpoint | undefined>;
|
|
33
|
+
save(checkpoint: ListIdBackfillCheckpoint): Promise<void>;
|
|
34
|
+
clear(): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ListIdBackfillDeps {
|
|
38
|
+
accountConfigService: Pick<IAccountConfigRepository, "listAll">;
|
|
39
|
+
threadMessageService: Pick<
|
|
40
|
+
IThreadMessageRepository,
|
|
41
|
+
"listByAccount" | "update"
|
|
42
|
+
>;
|
|
43
|
+
messageService: Pick<IMessageRepository, "get">;
|
|
44
|
+
storageService: Pick<StorageService, "retrieve">;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ListIdBackfillLogger {
|
|
48
|
+
info(obj: Record<string, unknown>, msg: string): void;
|
|
49
|
+
error?(obj: Record<string, unknown>, msg: string): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ListIdBackfillTotals {
|
|
53
|
+
scanned: number;
|
|
54
|
+
alreadySet: number;
|
|
55
|
+
skippedNoBody: number;
|
|
56
|
+
backfilled: number;
|
|
57
|
+
noListId: number;
|
|
58
|
+
failed: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ListIdBackfillResult extends ListIdBackfillTotals {
|
|
62
|
+
failedThreadMessageIds: string[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ListIdBackfillProgress extends ListIdBackfillTotals {
|
|
66
|
+
accountConfigId: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ListIdBackfillOptions {
|
|
70
|
+
/** Rows fetched per `listByAccount` page. */
|
|
71
|
+
batchSize?: number;
|
|
72
|
+
checkpointStore?: ListIdBackfillCheckpointStore;
|
|
73
|
+
logger?: ListIdBackfillLogger;
|
|
74
|
+
/** Called once per page, after that page's rows are settled. */
|
|
75
|
+
onProgress?: (progress: ListIdBackfillProgress) => void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const emptyTotals = (): ListIdBackfillTotals => ({
|
|
79
|
+
scanned: 0,
|
|
80
|
+
alreadySet: 0,
|
|
81
|
+
skippedNoBody: 0,
|
|
82
|
+
backfilled: 0,
|
|
83
|
+
noListId: 0,
|
|
84
|
+
failed: 0,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Read the stored raw source, derive `List-Id`, and — only when one is
|
|
89
|
+
* present — write it. Kept as its own function (rather than inline in a
|
|
90
|
+
* try/catch) so the caller can contain a failure with `.then(fulfilled,
|
|
91
|
+
* rejected)` instead of a block catch, matching how `BodySyncService`
|
|
92
|
+
* contains a per-message backfill failure.
|
|
93
|
+
*/
|
|
94
|
+
const deriveAndApplyListId = async (
|
|
95
|
+
deps: ListIdBackfillDeps,
|
|
96
|
+
accountConfigId: string,
|
|
97
|
+
row: ThreadMessageItem,
|
|
98
|
+
bodyStorageKey: string,
|
|
99
|
+
): Promise<string> => {
|
|
100
|
+
const body = await deps.storageService.retrieve(bodyStorageKey);
|
|
101
|
+
const parsed = await parseMessageBody(body);
|
|
102
|
+
const listId = extractListId(parsed);
|
|
103
|
+
|
|
104
|
+
if (listId) {
|
|
105
|
+
await deps.threadMessageService.update(
|
|
106
|
+
accountConfigId,
|
|
107
|
+
row.threadMessageId,
|
|
108
|
+
{ listId },
|
|
109
|
+
{
|
|
110
|
+
composites: {
|
|
111
|
+
sentDate: row.sentDate,
|
|
112
|
+
mailboxId: row.mailboxId,
|
|
113
|
+
isRead: row.isRead,
|
|
114
|
+
isDeleted: row.isDeleted,
|
|
115
|
+
hasStars: row.hasStars,
|
|
116
|
+
hasAttachment: row.hasAttachment,
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return listId;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* One-time, resumable pass that derives `ThreadMessage.listId` for rows synced
|
|
127
|
+
* before header extraction shipped (issue #263). Read-only against the stored
|
|
128
|
+
* raw source: it never opens IMAP and never touches anything but the single
|
|
129
|
+
* `listId` field.
|
|
130
|
+
*
|
|
131
|
+
* A row is a candidate when `listId` is `undefined` — written before the
|
|
132
|
+
* column existed, or written since by a path that found no `List-Id` header
|
|
133
|
+
* (body-sync only sets the field when the header is present, so "no header"
|
|
134
|
+
* and "not yet backfilled" look the same on the row; re-checking an
|
|
135
|
+
* already-correct empty row is wasted work, never a wrong answer). A
|
|
136
|
+
* candidate whose Message has no `bodyStorageKey` yet is left alone — its
|
|
137
|
+
* body was never synced, so there is nothing local to read, and the ordinary
|
|
138
|
+
* sync path will populate `listId` for it once the body lands.
|
|
139
|
+
*
|
|
140
|
+
* Chunked by `listByAccount`'s existing keyset pagination, one account at a
|
|
141
|
+
* time in `listAll()` order. A failure reading or parsing one message's
|
|
142
|
+
* stored body is contained to that message — logged, counted, and the pass
|
|
143
|
+
* continues — the same containment `BodySyncService`'s classification
|
|
144
|
+
* backfill uses for the same reason: one unreadable object must not strand
|
|
145
|
+
* the rest of the corpus.
|
|
146
|
+
*/
|
|
147
|
+
export const backfillListIds = async (
|
|
148
|
+
deps: ListIdBackfillDeps,
|
|
149
|
+
options: ListIdBackfillOptions = {},
|
|
150
|
+
): Promise<ListIdBackfillResult> => {
|
|
151
|
+
const batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
152
|
+
const { logger, checkpointStore } = options;
|
|
153
|
+
|
|
154
|
+
const accounts = await deps.accountConfigService.listAll();
|
|
155
|
+
const startingCheckpoint = await checkpointStore?.load();
|
|
156
|
+
const startIndex = startingCheckpoint?.accountIndex ?? 0;
|
|
157
|
+
|
|
158
|
+
const totals = emptyTotals();
|
|
159
|
+
const failedThreadMessageIds: string[] = [];
|
|
160
|
+
|
|
161
|
+
for (
|
|
162
|
+
let accountIndex = startIndex;
|
|
163
|
+
accountIndex < accounts.length;
|
|
164
|
+
accountIndex++
|
|
165
|
+
) {
|
|
166
|
+
const account = accounts[accountIndex];
|
|
167
|
+
let continuationToken: string | undefined =
|
|
168
|
+
accountIndex === startIndex
|
|
169
|
+
? startingCheckpoint?.continuationToken
|
|
170
|
+
: undefined;
|
|
171
|
+
|
|
172
|
+
do {
|
|
173
|
+
const page = await deps.threadMessageService.listByAccount(
|
|
174
|
+
account.accountConfigId,
|
|
175
|
+
{ limit: batchSize, continuationToken },
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
totals.scanned += page.items.length;
|
|
179
|
+
const candidates = page.items.filter((row) => row.listId === undefined);
|
|
180
|
+
totals.alreadySet += page.items.length - candidates.length;
|
|
181
|
+
|
|
182
|
+
if (candidates.length > 0) {
|
|
183
|
+
const messages = await deps.messageService.get(
|
|
184
|
+
candidates.map((row) => row.messageId),
|
|
185
|
+
);
|
|
186
|
+
const messageByMessageId = new Map(
|
|
187
|
+
messages.map((message) => [message.messageId, message]),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
for (const row of candidates) {
|
|
191
|
+
const message = messageByMessageId.get(row.messageId);
|
|
192
|
+
if (!message?.bodyStorageKey) {
|
|
193
|
+
totals.skippedNoBody++;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const outcome = await deriveAndApplyListId(
|
|
198
|
+
deps,
|
|
199
|
+
account.accountConfigId,
|
|
200
|
+
row,
|
|
201
|
+
message.bodyStorageKey,
|
|
202
|
+
).then(
|
|
203
|
+
(listId) => ({ error: null, listId }) as const,
|
|
204
|
+
(error: unknown) => ({ error, listId: null }) as const,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
if (outcome.error !== null) {
|
|
208
|
+
totals.failed++;
|
|
209
|
+
failedThreadMessageIds.push(row.threadMessageId);
|
|
210
|
+
logger?.error?.(
|
|
211
|
+
{
|
|
212
|
+
threadMessageId: row.threadMessageId,
|
|
213
|
+
messageId: row.messageId,
|
|
214
|
+
error:
|
|
215
|
+
outcome.error instanceof Error
|
|
216
|
+
? outcome.error.message
|
|
217
|
+
: String(outcome.error),
|
|
218
|
+
},
|
|
219
|
+
"ListId backfill failed for a message; leaving it for a later pass",
|
|
220
|
+
);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (outcome.listId) {
|
|
225
|
+
totals.backfilled++;
|
|
226
|
+
} else {
|
|
227
|
+
totals.noListId++;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
continuationToken = page.continuationToken;
|
|
233
|
+
await checkpointStore?.save({
|
|
234
|
+
accountIndex,
|
|
235
|
+
continuationToken,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
logger?.info(
|
|
239
|
+
{ accountConfigId: account.accountConfigId, ...totals },
|
|
240
|
+
"ListId backfill progress",
|
|
241
|
+
);
|
|
242
|
+
options.onProgress?.({
|
|
243
|
+
accountConfigId: account.accountConfigId,
|
|
244
|
+
...totals,
|
|
245
|
+
});
|
|
246
|
+
} while (continuationToken);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
await checkpointStore?.clear();
|
|
250
|
+
|
|
251
|
+
return { ...totals, failedThreadMessageIds };
|
|
252
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { IMailboxRepository, MailboxItem } from "@remit/data-ports";
|
|
2
|
+
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
3
|
+
|
|
4
|
+
const isNotFoundError = (error: unknown): boolean =>
|
|
5
|
+
error instanceof Error && error.name === "NotFoundError";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Whether the IMAP server is not known to hold this folder — the row is gone,
|
|
9
|
+
* its creation has not reached the server yet, or its deletion has been asked
|
|
10
|
+
* for and not finished. Folders are written locally first and reconciled to the
|
|
11
|
+
* server by the worker, so both ends of that lifecycle are states in which
|
|
12
|
+
* syncing the folder can only fail.
|
|
13
|
+
*
|
|
14
|
+
* This is the terminal test for a sync event whose folder is not there. The
|
|
15
|
+
* row's absence alone does not decide it, at either end: on the way out the row
|
|
16
|
+
* is removed only after the IMAP folder is, so an event that fails against the
|
|
17
|
+
* server still sees a live row for as long as that write takes; on the way in
|
|
18
|
+
* the row exists before the folder does. `pending` and `deleting` are written by
|
|
19
|
+
* the request itself, before the event that resolves it is enqueued, which is
|
|
20
|
+
* what makes them sound proxies for presence: neither can be observed unless a
|
|
21
|
+
* create has yet to land or a delete has been asked for.
|
|
22
|
+
*
|
|
23
|
+
* `failed` is deliberately not one of them, though it is the fourth state a
|
|
24
|
+
* mailbox row can carry. It records that the last management operation failed
|
|
25
|
+
* and says nothing about whether the folder is on the server: `handleDelete` and
|
|
26
|
+
* `handleRename` set it on folders that exist and hold the user's mail, and
|
|
27
|
+
* nothing ever clears it — `syncStatus` returns to `synced` only from a
|
|
28
|
+
* subsequent successful create or rename, never from the sweep. Treating it as
|
|
29
|
+
* off-server would stop syncing such a folder permanently and silently, which is
|
|
30
|
+
* worse than the bounded stall it would remove. A `failed` row whose folder is
|
|
31
|
+
* genuinely absent is reaped by the sweep's own cleanup, which runs before the
|
|
32
|
+
* fan-out that would enqueue work for it.
|
|
33
|
+
*
|
|
34
|
+
* Mailbox management is judged differently — it is what establishes and removes
|
|
35
|
+
* the folder, so it terminates on a not-found row alone.
|
|
36
|
+
*/
|
|
37
|
+
export const isFolderOffServer = (
|
|
38
|
+
mailbox: Pick<MailboxItem, "syncStatus">,
|
|
39
|
+
): boolean =>
|
|
40
|
+
mailbox.syncStatus === MailboxSyncStatus.pending ||
|
|
41
|
+
mailbox.syncStatus === MailboxSyncStatus.deleting;
|
|
42
|
+
|
|
43
|
+
/** {@link isFolderOffServer} for a mailbox that has to be read first, an absent row included. */
|
|
44
|
+
export const isMailboxNotOnServer = async (
|
|
45
|
+
mailboxService: Pick<IMailboxRepository, "get">,
|
|
46
|
+
accountId: string,
|
|
47
|
+
mailboxId: string,
|
|
48
|
+
): Promise<boolean> => {
|
|
49
|
+
const mailbox = await mailboxService
|
|
50
|
+
.get(accountId, mailboxId)
|
|
51
|
+
.catch((error: unknown) => {
|
|
52
|
+
if (isNotFoundError(error)) return undefined;
|
|
53
|
+
throw error;
|
|
54
|
+
});
|
|
55
|
+
if (!mailbox) return true;
|
|
56
|
+
return isFolderOffServer(mailbox);
|
|
57
|
+
};
|
package/src/mailbox-sync.test.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
IMailboxRepository,
|
|
5
5
|
IMailboxSpecialUseRepository,
|
|
6
6
|
} from "@remit/data-ports";
|
|
7
|
+
import { NotFoundError } from "@remit/data-ports/errors";
|
|
7
8
|
import {
|
|
8
9
|
MailboxCursorState,
|
|
9
10
|
MailboxSpecialUse,
|
|
@@ -309,3 +310,161 @@ describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending
|
|
|
309
310
|
assert.equal(result.deleted, 1);
|
|
310
311
|
});
|
|
311
312
|
});
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The folder set can also change under a running sweep: a delete asked for while
|
|
316
|
+
* the account is enumerating lands between the LIST and one folder's STATUS.
|
|
317
|
+
* Failing the whole account's enumeration over that one folder stalls every
|
|
318
|
+
* later sync for the account, on a per-account FIFO queue, for the queue's whole
|
|
319
|
+
* visibility window (issue #339).
|
|
320
|
+
*/
|
|
321
|
+
describe("MailboxSyncService.syncMailboxes — a folder leaving mid-sweep (#339)", () => {
|
|
322
|
+
const namespaces: ImapNamespaces = {
|
|
323
|
+
personal: [{ prefix: "", delimiter: "/" }],
|
|
324
|
+
other: [],
|
|
325
|
+
shared: [],
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const status = () => ({
|
|
329
|
+
messages: 3,
|
|
330
|
+
recent: 0,
|
|
331
|
+
unseen: 0,
|
|
332
|
+
uidNext: 100,
|
|
333
|
+
uidValidity: 1,
|
|
334
|
+
highestModseq: "0",
|
|
335
|
+
deletedCount: 0,
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const buildConnection = (
|
|
339
|
+
statusFor: (path: string) => Promise<ReturnType<typeof status>>,
|
|
340
|
+
): { connection: IImapConnection; statusPaths: string[] } => {
|
|
341
|
+
const statusPaths: string[] = [];
|
|
342
|
+
const connection = {
|
|
343
|
+
getNamespaces: async () => namespaces,
|
|
344
|
+
listMailboxes: async () =>
|
|
345
|
+
["INBOX", "Doomed"].map((fullPath) => ({
|
|
346
|
+
fullPath,
|
|
347
|
+
name: fullPath,
|
|
348
|
+
delimiter: "/",
|
|
349
|
+
attributes: [],
|
|
350
|
+
parentPath: null,
|
|
351
|
+
})),
|
|
352
|
+
getMailboxStatus: async (path: string) => {
|
|
353
|
+
statusPaths.push(path);
|
|
354
|
+
return statusFor(path);
|
|
355
|
+
},
|
|
356
|
+
} as unknown as IImapConnection;
|
|
357
|
+
return { connection, statusPaths };
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const buildServices = (options: {
|
|
361
|
+
doomedSyncStatus?: string;
|
|
362
|
+
getDoomed?: () => Promise<unknown>;
|
|
363
|
+
}) => {
|
|
364
|
+
const updatedIds: string[] = [];
|
|
365
|
+
const row = (mailboxId: string, fullPath: string, syncStatus?: string) => ({
|
|
366
|
+
mailboxId,
|
|
367
|
+
fullPath,
|
|
368
|
+
uidNext: 1,
|
|
369
|
+
uidValidity: 1,
|
|
370
|
+
messageCount: 0,
|
|
371
|
+
unseenCount: 0,
|
|
372
|
+
deletedCount: 0,
|
|
373
|
+
highestModseq: "0",
|
|
374
|
+
specialUse: undefined,
|
|
375
|
+
syncStatus,
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
const mailboxService = {
|
|
379
|
+
listByAccount: async () => ({
|
|
380
|
+
items: [
|
|
381
|
+
row("mbx-inbox", "INBOX", MailboxSyncStatus.synced),
|
|
382
|
+
row("mbx-doomed", "Doomed", options.doomedSyncStatus),
|
|
383
|
+
],
|
|
384
|
+
continuationToken: undefined,
|
|
385
|
+
}),
|
|
386
|
+
get: async (_accountId: string, mailboxId: string) => {
|
|
387
|
+
if (mailboxId === "mbx-doomed" && options.getDoomed) {
|
|
388
|
+
return options.getDoomed();
|
|
389
|
+
}
|
|
390
|
+
return row(mailboxId, mailboxId, MailboxSyncStatus.synced);
|
|
391
|
+
},
|
|
392
|
+
update: async (
|
|
393
|
+
_accountId: string,
|
|
394
|
+
mailboxId: string,
|
|
395
|
+
_patch: Record<string, unknown>,
|
|
396
|
+
) => {
|
|
397
|
+
updatedIds.push(mailboxId);
|
|
398
|
+
return {};
|
|
399
|
+
},
|
|
400
|
+
delete: async () => undefined,
|
|
401
|
+
create: async () => ({}),
|
|
402
|
+
} as unknown as IMailboxRepository;
|
|
403
|
+
|
|
404
|
+
const specialUseService = {
|
|
405
|
+
listByMailboxId: async () => [],
|
|
406
|
+
deleteByMailboxId: async () => undefined,
|
|
407
|
+
createMany: async () => undefined,
|
|
408
|
+
} as unknown as IMailboxSpecialUseRepository;
|
|
409
|
+
|
|
410
|
+
return { mailboxService, specialUseService, updatedIds };
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
for (const syncStatus of [
|
|
414
|
+
MailboxSyncStatus.pending,
|
|
415
|
+
MailboxSyncStatus.deleting,
|
|
416
|
+
]) {
|
|
417
|
+
it(`leaves a \`${syncStatus}\` folder untouched — no STATUS, no write`, async () => {
|
|
418
|
+
const { mailboxService, specialUseService, updatedIds } = buildServices({
|
|
419
|
+
doomedSyncStatus: syncStatus,
|
|
420
|
+
});
|
|
421
|
+
const { connection, statusPaths } = buildConnection(async () => status());
|
|
422
|
+
const service = new MailboxSyncService(mailboxService, specialUseService);
|
|
423
|
+
|
|
424
|
+
await service.syncMailboxes({ accountId: "acc-1" }, connection);
|
|
425
|
+
|
|
426
|
+
assert.deepEqual(statusPaths, ["INBOX"]);
|
|
427
|
+
assert.deepEqual(updatedIds, ["mbx-inbox"]);
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
it("finishes the sweep when a folder's STATUS fails and that folder has since been deleted", async () => {
|
|
432
|
+
const { mailboxService, specialUseService, updatedIds } = buildServices({
|
|
433
|
+
doomedSyncStatus: MailboxSyncStatus.synced,
|
|
434
|
+
getDoomed: async () => {
|
|
435
|
+
throw new NotFoundError("Mailbox not found: mbx-doomed");
|
|
436
|
+
},
|
|
437
|
+
});
|
|
438
|
+
const { connection } = buildConnection(async (path) => {
|
|
439
|
+
if (path === "Doomed") throw new Error("Mailbox doesn't exist: Doomed");
|
|
440
|
+
return status();
|
|
441
|
+
});
|
|
442
|
+
const service = new MailboxSyncService(mailboxService, specialUseService);
|
|
443
|
+
|
|
444
|
+
await assert.doesNotReject(
|
|
445
|
+
service.syncMailboxes({ accountId: "acc-1" }, connection),
|
|
446
|
+
);
|
|
447
|
+
assert.deepEqual(updatedIds, ["mbx-inbox"]);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it("fails the sweep when a folder's STATUS fails and the folder is still live", async () => {
|
|
451
|
+
const { mailboxService, specialUseService } = buildServices({
|
|
452
|
+
doomedSyncStatus: MailboxSyncStatus.synced,
|
|
453
|
+
getDoomed: async () => ({
|
|
454
|
+
mailboxId: "mbx-doomed",
|
|
455
|
+
fullPath: "Doomed",
|
|
456
|
+
syncStatus: MailboxSyncStatus.synced,
|
|
457
|
+
}),
|
|
458
|
+
});
|
|
459
|
+
const { connection } = buildConnection(async (path) => {
|
|
460
|
+
if (path === "Doomed") throw new Error("connection reset by peer");
|
|
461
|
+
return status();
|
|
462
|
+
});
|
|
463
|
+
const service = new MailboxSyncService(mailboxService, specialUseService);
|
|
464
|
+
|
|
465
|
+
await assert.rejects(
|
|
466
|
+
service.syncMailboxes({ accountId: "acc-1" }, connection),
|
|
467
|
+
/connection reset by peer/,
|
|
468
|
+
);
|
|
469
|
+
});
|
|
470
|
+
});
|
package/src/mailbox-sync.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import pMap from "p-map";
|
|
20
20
|
import { isNoSelect, parseImapAttributes } from "./attribute-mapper.js";
|
|
21
21
|
import { isCursorRebuildNeeded } from "./mailbox-cursor.js";
|
|
22
|
+
import { isMailboxNotOnServer } from "./mailbox-presence.js";
|
|
22
23
|
import type {
|
|
23
24
|
FlatMailboxInfo,
|
|
24
25
|
IImapConnection,
|
|
@@ -175,12 +176,39 @@ export class MailboxSyncService {
|
|
|
175
176
|
const existing = existingByPath.get(mailboxInfo.fullPath);
|
|
176
177
|
|
|
177
178
|
if (existing) {
|
|
179
|
+
// A folder at either end of its lifecycle is left alone: the worker
|
|
180
|
+
// that establishes or removes it writes its own identity back, and
|
|
181
|
+
// reading its status meanwhile is work whose only possible outcome is
|
|
182
|
+
// a failure that fails this whole account's fan-out with it.
|
|
183
|
+
if (
|
|
184
|
+
existing.syncStatus === MailboxSyncStatus.pending ||
|
|
185
|
+
existing.syncStatus === MailboxSyncStatus.deleting
|
|
186
|
+
) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
// The folder set can change under this sweep. A delete that lands
|
|
190
|
+
// between the LIST above and this mailbox's STATUS fails on the
|
|
191
|
+
// server or on the row, and that would abort the enumeration of every
|
|
192
|
+
// other folder and the SYNC_MESSAGES fan-out behind it — on a
|
|
193
|
+
// per-account FIFO queue, for the whole visibility window (issue
|
|
194
|
+
// #339). One folder leaving is not a failed account sync. Anything
|
|
195
|
+
// else still propagates.
|
|
178
196
|
const updated = await this.updateMailbox(
|
|
179
197
|
account.accountId,
|
|
180
198
|
existing,
|
|
181
199
|
mailboxInfo,
|
|
182
200
|
connection,
|
|
183
|
-
)
|
|
201
|
+
).catch(async (error: unknown) => {
|
|
202
|
+
// The read only classifies the failure in hand; one that cannot
|
|
203
|
+
// answer must not replace it.
|
|
204
|
+
const gone = await isMailboxNotOnServer(
|
|
205
|
+
this.mailboxService,
|
|
206
|
+
account.accountId,
|
|
207
|
+
existing.mailboxId,
|
|
208
|
+
).catch(() => false);
|
|
209
|
+
if (gone) return null;
|
|
210
|
+
throw error;
|
|
211
|
+
});
|
|
184
212
|
if (updated) {
|
|
185
213
|
result.updated++;
|
|
186
214
|
}
|