@remit/mailbox-service 0.0.28 → 0.0.29
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 +4 -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
|
@@ -148,6 +148,10 @@ export {
|
|
|
148
148
|
validateMailboxOperation,
|
|
149
149
|
validateMailboxPath,
|
|
150
150
|
} from "./mailbox-management.js";
|
|
151
|
+
export {
|
|
152
|
+
isFolderOffServer,
|
|
153
|
+
isMailboxNotOnServer,
|
|
154
|
+
} from "./mailbox-presence.js";
|
|
151
155
|
export {
|
|
152
156
|
type CreateMailboxQueueInput,
|
|
153
157
|
type MailboxQueueConfig,
|
|
@@ -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
|
}
|