@remit/mailbox-service 0.0.110 → 0.0.112
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 +21 -6
- package/src/body-part-mapper.ts +51 -1
- package/src/body-sync-queue.test.ts +39 -99
- package/src/body-sync-terminal.test.ts +115 -277
- package/src/body-sync-terminal.ts +10 -12
- package/src/body-sync.test.ts +925 -0
- package/src/body-sync.ts +226 -200
- package/src/classification-backfill.test.ts +197 -527
- package/src/classify-message.test.ts +11 -74
- package/src/external-move.test.ts +102 -209
- package/src/external-move.ts +13 -0
- package/src/filter-operations.test.ts +319 -0
- package/src/filter-operations.ts +377 -0
- package/src/filter-update.test.ts +313 -0
- package/src/filters/filter-config.test.ts +55 -197
- package/src/filters/filter-config.ts +4 -39
- package/src/filters/match.test.ts +1 -38
- package/src/filters/match.ts +3 -52
- package/src/filters/pipeline.test.ts +158 -289
- package/src/filters/pipeline.ts +56 -170
- package/src/flag-push-terminal.ts +9 -11
- package/src/flag-push.test.ts +76 -372
- package/src/flag-queue.test.ts +197 -0
- package/src/folder-operations.test.ts +273 -0
- package/src/folder-operations.ts +217 -0
- package/src/forwarded-message.test.ts +122 -0
- package/src/imapflow-connect-retry.test.ts +76 -113
- package/src/imapflow-connection.test.ts +34 -0
- package/src/imapflow-connection.ts +19 -15
- package/src/index.ts +59 -12
- package/src/list-id-backfill.test.ts +133 -499
- package/src/mailbox-cursor.test.ts +81 -189
- package/src/mailbox-delete-intent.test.ts +78 -205
- package/src/mailbox-intent.ts +5 -3
- package/src/mailbox-management.test.ts +107 -220
- package/src/mailbox-management.ts +1 -2
- package/src/mailbox-presence.test.ts +36 -34
- package/src/mailbox-queue.ts +2 -20
- package/src/mailbox-rename-intent.test.ts +156 -325
- package/src/mailbox-rename-subtree.test.ts +205 -582
- package/src/mailbox-sync.test.ts +182 -744
- package/src/message-move.test.ts +726 -0
- package/src/message-move.ts +227 -57
- package/src/message-read.ts +87 -0
- package/src/message-save-retry.test.ts +90 -0
- package/src/message-sync-flags.test.ts +10 -1
- package/src/message-sync.test.ts +762 -0
- package/src/message-sync.ts +409 -239
- package/src/mime-walker.ts +16 -15
- package/src/organize-job.ts +93 -0
- package/src/outbox-attachment.test.ts +97 -209
- package/src/outbox-failed-recovery.test.ts +198 -269
- package/src/outbox-queue.test.ts +24 -0
- package/src/outbox-queue.ts +25 -8
- package/src/ownership.ts +140 -0
- package/src/placement-move-terminal.ts +9 -12
- package/src/placement-move.ts +33 -6
- package/src/placement-settled.ts +28 -4
- package/src/placement.test.ts +404 -0
- package/src/quarantine.test.ts +90 -148
- package/src/quarantine.ts +3 -2
- package/src/refusal.ts +29 -0
- package/src/restore-source-placement.ts +0 -4
- package/src/spam-report.test.ts +238 -710
- package/src/sync-warning.test.ts +213 -0
- package/src/sync-warning.ts +56 -0
- package/src/test-helpers/fake-imap.ts +690 -0
- package/src/test-helpers/test-queue.ts +56 -0
- package/src/test-helpers/test-store.ts +299 -0
- package/src/thread-root.test.ts +29 -0
- package/src/thread-root.ts +51 -0
- package/src/types/mailparser-augment.d.ts +8 -0
- package/src/types.ts +1 -1
- package/src/adversarial-mime.e2e.test.ts +0 -307
- package/src/body-sync-auto-read-writeonce.test.ts +0 -219
- package/src/body-sync-calendar-suggestion.test.ts +0 -433
- package/src/body-sync-category-denormalize.test.ts +0 -352
- package/src/body-sync-category-override.test.ts +0 -501
- package/src/body-sync-category-writeonce.test.ts +0 -234
- package/src/body-sync-filter.test.ts +0 -237
- package/src/body-sync-placement-flags.test.ts +0 -347
- package/src/body-sync-placement-writeonce.test.ts +0 -337
- package/src/body-sync-quarantine.test.ts +0 -276
- package/src/body-sync-resync.test.ts +0 -605
- package/src/body-sync-unclassifiable-once.test.ts +0 -260
- package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
- package/src/filters/anchor-drift.ts +0 -55
- package/src/filters/message-embedder.test.ts +0 -39
- package/src/filters/message-embedder.ts +0 -33
- package/src/flag-push-terminal.test.ts +0 -209
- package/src/flag-queue-stars.test.ts +0 -171
- package/src/imapflow-connection.e2e.test.ts +0 -335
- package/src/imapflow-connection.integ.test.ts +0 -316
- package/src/message-delete-junk-reconcile.test.ts +0 -117
- package/src/message-delete-no-trash.test.ts +0 -245
- package/src/message-delete-unsettled-placement.test.ts +0 -396
- package/src/message-move-copy.test.ts +0 -562
- package/src/message-move-empty-trash.test.ts +0 -326
- package/src/message-move-same-mailbox.test.ts +0 -139
- package/src/message-move-unsettled-placement.test.ts +0 -378
- package/src/message-sync-address-spoof.test.ts +0 -293
- package/src/message-sync-category.test.ts +0 -142
- package/src/message-sync-changedsince.test.ts +0 -840
- package/src/message-sync-junk-harvest.test.ts +0 -244
- package/src/message-sync-quarantine.test.ts +0 -416
- package/src/message-sync-repoint.test.ts +0 -419
- package/src/outbox-queue-settled-row.test.ts +0 -140
- package/src/pass-through-unit-of-work.ts +0 -17
- package/src/placement-move-terminal.test.ts +0 -300
- package/src/placement-move-unsettled.test.ts +0 -275
- package/src/placement-transition-race.test.ts +0 -482
- package/src/stale-message-reconcile.test.ts +0 -82
- package/src/stale-message-reconcile.ts +0 -55
- package/src/test-helpers/isolated-mailbox.ts +0 -44
- package/src/test-helpers/mailfuzz-connection.ts +0 -27
- package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
- package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
- package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
- package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
- package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
- package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
- package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
- package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
package/src/mailbox-sync.test.ts
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import type {
|
|
4
|
-
IMailboxRepository,
|
|
5
|
-
IMailboxSpecialUseRepository,
|
|
6
|
-
} from "@remit/data-ports";
|
|
7
|
-
import { NotFoundError } from "@remit/data-ports/errors";
|
|
2
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
3
|
+
import type { AccountItem, MailboxItem } from "@remit/data-ports";
|
|
8
4
|
import {
|
|
9
5
|
MailboxCursorState,
|
|
10
6
|
MailboxSpecialUse,
|
|
11
7
|
MailboxSyncStatus,
|
|
12
8
|
} from "@remit/domain-enums";
|
|
13
9
|
import { parseImapAttributes } from "./attribute-mapper.js";
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
10
|
+
import { MailboxSyncService } from "./mailbox-sync.js";
|
|
11
|
+
import {
|
|
12
|
+
eml,
|
|
13
|
+
type FakeImapConnection,
|
|
14
|
+
FakeImapServer,
|
|
15
|
+
} from "./test-helpers/fake-imap.js";
|
|
16
|
+
import {
|
|
17
|
+
openStore,
|
|
18
|
+
type Store,
|
|
19
|
+
seedAccount,
|
|
20
|
+
seedMailbox,
|
|
21
|
+
silentLog,
|
|
22
|
+
} from "./test-helpers/test-store.js";
|
|
23
23
|
|
|
24
24
|
describe("parseImapAttributes – locale invariance (#194)", () => {
|
|
25
25
|
it("recognizes \\Sent regardless of folder name", () => {
|
|
@@ -31,14 +31,11 @@ describe("parseImapAttributes – locale invariance (#194)", () => {
|
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
it("recognizes \\Drafts on a localized 'Concepten' folder", () => {
|
|
34
|
-
// IMAP server tells us the flag — the folder name is irrelevant.
|
|
35
34
|
const parsed = parseImapAttributes(["\\Drafts"]);
|
|
36
35
|
assert.deepEqual(parsed.specialUse, [MailboxSpecialUse.Drafts]);
|
|
37
36
|
});
|
|
38
37
|
|
|
39
38
|
it("returns an empty list when no flag is present", () => {
|
|
40
|
-
// A real user folder like Outlook NL's "Nieuwsbrieven" carries no
|
|
41
|
-
// SPECIAL-USE attributes — leave it untouched downstream.
|
|
42
39
|
const parsed = parseImapAttributes(["\\HasNoChildren"]);
|
|
43
40
|
assert.deepEqual(parsed.specialUse, []);
|
|
44
41
|
});
|
|
@@ -49,799 +46,240 @@ describe("parseImapAttributes – locale invariance (#194)", () => {
|
|
|
49
46
|
});
|
|
50
47
|
});
|
|
51
48
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
other: [],
|
|
56
|
-
shared: [],
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
const buildConnection = (
|
|
60
|
-
uidValidity: number,
|
|
61
|
-
highestModseq = "0",
|
|
62
|
-
): IImapConnection =>
|
|
63
|
-
({
|
|
64
|
-
getNamespaces: async () => namespaces,
|
|
65
|
-
listMailboxes: async () => [
|
|
66
|
-
{
|
|
67
|
-
fullPath: "INBOX",
|
|
68
|
-
name: "INBOX",
|
|
69
|
-
delimiter: "/",
|
|
70
|
-
attributes: [],
|
|
71
|
-
parentPath: null,
|
|
72
|
-
},
|
|
73
|
-
],
|
|
74
|
-
getMailboxStatus: async () => ({
|
|
75
|
-
messages: 5,
|
|
76
|
-
recent: 0,
|
|
77
|
-
unseen: 1,
|
|
78
|
-
uidNext: 100,
|
|
79
|
-
uidValidity,
|
|
80
|
-
highestModseq,
|
|
81
|
-
deletedCount: 0,
|
|
82
|
-
}),
|
|
83
|
-
}) as unknown as IImapConnection;
|
|
84
|
-
|
|
85
|
-
const buildServices = (
|
|
86
|
-
existingUidValidity: number,
|
|
87
|
-
existingCursorState?: string,
|
|
88
|
-
) => {
|
|
89
|
-
const updateCalls: Array<Record<string, unknown>> = [];
|
|
90
|
-
const mailboxService = {
|
|
91
|
-
listByAccount: async () => ({
|
|
92
|
-
items: [
|
|
93
|
-
{
|
|
94
|
-
mailboxId: "mbx-1",
|
|
95
|
-
fullPath: "INBOX",
|
|
96
|
-
uidNext: 100,
|
|
97
|
-
uidValidity: existingUidValidity,
|
|
98
|
-
messageCount: 5,
|
|
99
|
-
unseenCount: 1,
|
|
100
|
-
deletedCount: 0,
|
|
101
|
-
highestModseq: "0",
|
|
102
|
-
specialUse: undefined,
|
|
103
|
-
cursorState: existingCursorState,
|
|
104
|
-
},
|
|
105
|
-
],
|
|
106
|
-
continuationToken: undefined,
|
|
107
|
-
}),
|
|
108
|
-
update: async (
|
|
109
|
-
_accountId: string,
|
|
110
|
-
_mailboxId: string,
|
|
111
|
-
patch: Record<string, unknown>,
|
|
112
|
-
) => {
|
|
113
|
-
updateCalls.push(patch);
|
|
114
|
-
return {};
|
|
115
|
-
},
|
|
116
|
-
delete: async () => undefined,
|
|
117
|
-
create: async () => ({
|
|
118
|
-
outcome: "Created",
|
|
119
|
-
mailbox: { mailboxId: "new" },
|
|
120
|
-
}),
|
|
121
|
-
} as unknown as IMailboxRepository;
|
|
122
|
-
|
|
123
|
-
const specialUseService = {
|
|
124
|
-
listByMailboxId: async () => [],
|
|
125
|
-
deleteByMailboxId: async () => undefined,
|
|
126
|
-
createMany: async () => undefined,
|
|
127
|
-
} as unknown as IMailboxSpecialUseRepository;
|
|
128
|
-
|
|
129
|
-
return { mailboxService, specialUseService, updateCalls };
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
it("trips cursor_invalid when the STATUS sweep observes a changed UIDVALIDITY", async () => {
|
|
133
|
-
const { mailboxService, specialUseService, updateCalls } = buildServices(1);
|
|
134
|
-
const service = new MailboxSyncService(
|
|
135
|
-
mailboxService,
|
|
136
|
-
specialUseService,
|
|
137
|
-
silentLogger,
|
|
138
|
-
);
|
|
139
|
-
const connection = buildConnection(2);
|
|
49
|
+
let store: Store;
|
|
50
|
+
let imap: FakeImapServer;
|
|
51
|
+
let account: AccountItem;
|
|
140
52
|
|
|
141
|
-
|
|
53
|
+
beforeEach(async () => {
|
|
54
|
+
store = await openStore();
|
|
55
|
+
imap = new FakeImapServer();
|
|
56
|
+
account = await seedAccount(store);
|
|
57
|
+
});
|
|
142
58
|
|
|
143
|
-
|
|
144
|
-
assert.ok(uidValidityUpdate, "expected the sweep to write the mailbox");
|
|
145
|
-
assert.equal(
|
|
146
|
-
uidValidityUpdate?.cursorState,
|
|
147
|
-
MailboxCursorState.cursor_invalid,
|
|
148
|
-
);
|
|
149
|
-
});
|
|
59
|
+
afterEach(() => store.close());
|
|
150
60
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
);
|
|
158
|
-
const connection = buildConnection(1);
|
|
61
|
+
const sweep = (connection: FakeImapConnection = imap.connection()) =>
|
|
62
|
+
new MailboxSyncService(
|
|
63
|
+
store.mailbox,
|
|
64
|
+
store.mailboxSpecialUse,
|
|
65
|
+
silentLog,
|
|
66
|
+
).syncMailboxes({ accountId: account.accountId }, connection);
|
|
159
67
|
|
|
160
|
-
|
|
68
|
+
const rows = async () =>
|
|
69
|
+
(await store.mailbox.listAllByAccount(account.accountId)).sort((a, b) =>
|
|
70
|
+
a.fullPath.localeCompare(b.fullPath),
|
|
71
|
+
);
|
|
161
72
|
|
|
162
|
-
|
|
163
|
-
});
|
|
73
|
+
const paths = async () => (await rows()).map((mailbox) => mailbox.fullPath);
|
|
164
74
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
// applied changes, not a status projection. The sweep overwriting it with
|
|
168
|
-
// the server's current value would step it over every change message sync
|
|
169
|
-
// had not yet applied.
|
|
170
|
-
const { mailboxService, specialUseService, updateCalls } = buildServices(1);
|
|
171
|
-
const service = new MailboxSyncService(
|
|
172
|
-
mailboxService,
|
|
173
|
-
specialUseService,
|
|
174
|
-
silentLogger,
|
|
175
|
-
);
|
|
176
|
-
const connection = buildConnection(2, "99999");
|
|
75
|
+
const row = (mailbox: MailboxItem) =>
|
|
76
|
+
store.mailbox.get(account.accountId, mailbox.mailboxId);
|
|
177
77
|
|
|
178
|
-
|
|
78
|
+
const byPath = async (fullPath: string) => {
|
|
79
|
+
const found = await store.mailbox.findByPath(account.accountId, fullPath);
|
|
80
|
+
if (!found) throw new Error(`no row at ${fullPath}`);
|
|
81
|
+
return found;
|
|
82
|
+
};
|
|
179
83
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
84
|
+
const inState = async (
|
|
85
|
+
fullPath: string,
|
|
86
|
+
syncStatus: MailboxItem["syncStatus"],
|
|
87
|
+
pendingPath?: string,
|
|
88
|
+
) => {
|
|
89
|
+
const mailbox = await seedMailbox(store, account, fullPath, {
|
|
90
|
+
overrides: { syncStatus },
|
|
183
91
|
});
|
|
92
|
+
if (!pendingPath) return mailbox;
|
|
93
|
+
const recorded = await store.mailbox.transition(
|
|
94
|
+
account.accountId,
|
|
95
|
+
mailbox.mailboxId,
|
|
96
|
+
{ from: [syncStatus], to: syncStatus, set: { pendingPath } },
|
|
97
|
+
);
|
|
98
|
+
if (!recorded) throw new Error(`could not record ${pendingPath}`);
|
|
99
|
+
return recorded;
|
|
100
|
+
};
|
|
184
101
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const connection = buildConnection(1, "4242");
|
|
102
|
+
describe("the STATUS sweep and the mailbox cursor", () => {
|
|
103
|
+
const swept = async () => {
|
|
104
|
+
imap.addFolder("INBOX", { uidValidity: 1 });
|
|
105
|
+
imap.deliver("INBOX", eml({}));
|
|
106
|
+
await sweep();
|
|
107
|
+
return byPath("INBOX");
|
|
108
|
+
};
|
|
193
109
|
|
|
194
|
-
|
|
110
|
+
it("trips cursor_invalid without touching the message-sync cursor", async () => {
|
|
111
|
+
const inbox = await swept();
|
|
112
|
+
imap.renumber("INBOX", 2);
|
|
195
113
|
|
|
196
|
-
|
|
114
|
+
await sweep();
|
|
115
|
+
|
|
116
|
+
const after = await row(inbox);
|
|
117
|
+
assert.equal(after.cursorState, MailboxCursorState.cursor_invalid);
|
|
118
|
+
assert.equal(after.highestModseq, inbox.highestModseq);
|
|
197
119
|
});
|
|
198
120
|
|
|
199
|
-
it("
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
MailboxCursorState.cursor_invalid,
|
|
203
|
-
);
|
|
204
|
-
const service = new MailboxSyncService(
|
|
205
|
-
mailboxService,
|
|
206
|
-
specialUseService,
|
|
207
|
-
silentLogger,
|
|
208
|
-
);
|
|
209
|
-
const connection = buildConnection(2);
|
|
121
|
+
it("writes nothing when only the server mod-sequence moved", async () => {
|
|
122
|
+
const inbox = await swept();
|
|
123
|
+
imap.store("INBOX", [1], ["\\Flagged"]);
|
|
210
124
|
|
|
211
|
-
await
|
|
125
|
+
await sweep();
|
|
212
126
|
|
|
213
|
-
|
|
214
|
-
assert.ok(uidValidityUpdate);
|
|
215
|
-
assert.equal("cursorState" in (uidValidityUpdate ?? {}), false);
|
|
127
|
+
assert.deepEqual(await row(inbox), inbox);
|
|
216
128
|
});
|
|
217
129
|
});
|
|
218
130
|
|
|
219
|
-
describe("
|
|
220
|
-
const namespaces: ImapNamespaces = {
|
|
221
|
-
personal: [{ prefix: "", delimiter: "/" }],
|
|
222
|
-
other: [],
|
|
223
|
-
shared: [],
|
|
224
|
-
};
|
|
225
|
-
|
|
226
|
-
// The server lists only INBOX unless a test says otherwise: neither user
|
|
227
|
-
// folder is on it yet.
|
|
228
|
-
const serverConnection = (
|
|
229
|
-
folders: Array<{ fullPath: string; attributes?: string[] }> = [
|
|
230
|
-
{ fullPath: "INBOX" },
|
|
231
|
-
],
|
|
232
|
-
): IImapConnection =>
|
|
233
|
-
({
|
|
234
|
-
getNamespaces: async () => namespaces,
|
|
235
|
-
listMailboxes: async () =>
|
|
236
|
-
folders.map((folder) => ({
|
|
237
|
-
fullPath: folder.fullPath,
|
|
238
|
-
name: folder.fullPath.split("/").pop() ?? folder.fullPath,
|
|
239
|
-
delimiter: "/",
|
|
240
|
-
attributes: folder.attributes ?? [],
|
|
241
|
-
parentPath: null,
|
|
242
|
-
})),
|
|
243
|
-
getMailboxStatus: async () => ({
|
|
244
|
-
messages: 0,
|
|
245
|
-
recent: 0,
|
|
246
|
-
unseen: 0,
|
|
247
|
-
uidNext: 1,
|
|
248
|
-
uidValidity: 1,
|
|
249
|
-
highestModseq: "0",
|
|
250
|
-
deletedCount: 0,
|
|
251
|
-
}),
|
|
252
|
-
openBox: async () => ({
|
|
253
|
-
uidvalidity: 1,
|
|
254
|
-
uidnext: 1,
|
|
255
|
-
messages: { total: 0 },
|
|
256
|
-
}),
|
|
257
|
-
closeBox: async () => undefined,
|
|
258
|
-
}) as unknown as IImapConnection;
|
|
259
|
-
|
|
260
|
-
const buildServices = (
|
|
261
|
-
existing: Array<{
|
|
262
|
-
mailboxId: string;
|
|
263
|
-
fullPath: string;
|
|
264
|
-
syncStatus?: string;
|
|
265
|
-
pendingPath?: string;
|
|
266
|
-
}>,
|
|
267
|
-
createOutcome: "Created" | "PathTaken" = "Created",
|
|
268
|
-
) => {
|
|
269
|
-
const deleted: string[] = [];
|
|
270
|
-
const created: Array<Record<string, unknown>> = [];
|
|
271
|
-
const mailboxService = {
|
|
272
|
-
listByAccount: async () => ({
|
|
273
|
-
items: existing.map((m) => ({
|
|
274
|
-
mailboxId: m.mailboxId,
|
|
275
|
-
fullPath: m.fullPath,
|
|
276
|
-
uidNext: 1,
|
|
277
|
-
uidValidity: 1,
|
|
278
|
-
messageCount: 0,
|
|
279
|
-
unseenCount: 0,
|
|
280
|
-
deletedCount: 0,
|
|
281
|
-
highestModseq: "0",
|
|
282
|
-
specialUse: undefined,
|
|
283
|
-
syncStatus: m.syncStatus,
|
|
284
|
-
pendingPath: m.pendingPath,
|
|
285
|
-
})),
|
|
286
|
-
continuationToken: undefined,
|
|
287
|
-
}),
|
|
288
|
-
update: async () => ({}),
|
|
289
|
-
delete: async (_accountId: string, mailboxId: string) => {
|
|
290
|
-
deleted.push(mailboxId);
|
|
291
|
-
},
|
|
292
|
-
create: async (input: Record<string, unknown>) => {
|
|
293
|
-
created.push(input);
|
|
294
|
-
if (createOutcome === "PathTaken") return { outcome: "PathTaken" };
|
|
295
|
-
return {
|
|
296
|
-
outcome: "Created",
|
|
297
|
-
mailbox: { ...input, mailboxId: `new-${created.length}` },
|
|
298
|
-
};
|
|
299
|
-
},
|
|
300
|
-
} as unknown as IMailboxRepository;
|
|
301
|
-
|
|
302
|
-
const specialUseService = {
|
|
303
|
-
listByMailboxId: async () => [],
|
|
304
|
-
deleteByMailboxId: async () => undefined,
|
|
305
|
-
createMany: async () => undefined,
|
|
306
|
-
} as unknown as IMailboxSpecialUseRepository;
|
|
307
|
-
|
|
308
|
-
return { mailboxService, specialUseService, deleted, created };
|
|
309
|
-
};
|
|
310
|
-
|
|
131
|
+
describe("reconciling the folder list", () => {
|
|
311
132
|
for (const syncStatus of [
|
|
312
133
|
MailboxSyncStatus.pending,
|
|
313
134
|
MailboxSyncStatus.deleting,
|
|
314
135
|
]) {
|
|
315
|
-
it(`keeps a
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
// written back. Hard-deleting the row removes it under the client
|
|
323
|
-
// holding its id — the "Mailbox not found" of #333 — and removes it
|
|
324
|
-
// without the folder's mail, which the worker owns (D8).
|
|
325
|
-
const { mailboxService, specialUseService, deleted } = buildServices([
|
|
326
|
-
{
|
|
327
|
-
mailboxId: "inbox",
|
|
328
|
-
fullPath: "INBOX",
|
|
329
|
-
syncStatus: MailboxSyncStatus.synced,
|
|
330
|
-
},
|
|
331
|
-
{
|
|
332
|
-
mailboxId: "in-flight",
|
|
333
|
-
fullPath: "New Folder",
|
|
334
|
-
syncStatus,
|
|
335
|
-
},
|
|
336
|
-
]);
|
|
337
|
-
const service = new MailboxSyncService(
|
|
338
|
-
mailboxService,
|
|
339
|
-
specialUseService,
|
|
340
|
-
silentLogger,
|
|
341
|
-
);
|
|
342
|
-
|
|
343
|
-
const result = await service.syncMailboxes(
|
|
344
|
-
{ accountId: "acc-1" },
|
|
345
|
-
serverConnection(),
|
|
346
|
-
);
|
|
347
|
-
|
|
348
|
-
assert.deepEqual(deleted, []);
|
|
349
|
-
assert.equal(result.deleted, 0);
|
|
350
|
-
});
|
|
136
|
+
it(`keeps a ${syncStatus} folder the server omits or lists as \\Noselect`, async () => {
|
|
137
|
+
imap.addFolder("INBOX");
|
|
138
|
+
imap.addFolder("Half", { attributes: ["\\Noselect"] });
|
|
139
|
+
await inState("New Folder", syncStatus);
|
|
140
|
+
await inState("Half", syncStatus);
|
|
141
|
+
|
|
142
|
+
const result = await sweep();
|
|
351
143
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
// non-selectable branch deleted the row before looking at its state.
|
|
355
|
-
const { mailboxService, specialUseService, deleted } = buildServices([
|
|
356
|
-
{
|
|
357
|
-
mailboxId: "in-flight",
|
|
358
|
-
fullPath: "New Folder",
|
|
359
|
-
syncStatus,
|
|
360
|
-
},
|
|
361
|
-
]);
|
|
362
|
-
const service = new MailboxSyncService(
|
|
363
|
-
mailboxService,
|
|
364
|
-
specialUseService,
|
|
365
|
-
silentLogger,
|
|
366
|
-
);
|
|
367
|
-
|
|
368
|
-
await service.syncMailboxes(
|
|
369
|
-
{ accountId: "acc-1" },
|
|
370
|
-
serverConnection([
|
|
371
|
-
{ fullPath: "INBOX" },
|
|
372
|
-
{ fullPath: "New Folder", attributes: ["\\Noselect"] },
|
|
373
|
-
]),
|
|
374
|
-
);
|
|
375
|
-
|
|
376
|
-
assert.deepEqual(deleted, []);
|
|
144
|
+
assert.equal(result.deleted, 0);
|
|
145
|
+
assert.deepEqual(await paths(), ["Half", "INBOX", "New Folder"]);
|
|
377
146
|
});
|
|
378
147
|
}
|
|
379
148
|
|
|
380
|
-
it("
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
// row is keyed to. Without the pendingPath index the insert branch makes a
|
|
384
|
-
// second row under a fresh mailboxId and initial-syncs the folder's mail
|
|
385
|
-
// into it, leaving the original as a permanent phantom.
|
|
386
|
-
const { mailboxService, specialUseService, deleted, created } =
|
|
387
|
-
buildServices([
|
|
388
|
-
{
|
|
389
|
-
mailboxId: "inbox",
|
|
390
|
-
fullPath: "INBOX",
|
|
391
|
-
syncStatus: MailboxSyncStatus.synced,
|
|
392
|
-
},
|
|
393
|
-
{
|
|
394
|
-
mailboxId: "renaming",
|
|
395
|
-
fullPath: "Archive/2025",
|
|
396
|
-
syncStatus: MailboxSyncStatus.pending,
|
|
397
|
-
pendingPath: "Records/2025",
|
|
398
|
-
},
|
|
399
|
-
]);
|
|
400
|
-
const service = new MailboxSyncService(
|
|
401
|
-
mailboxService,
|
|
402
|
-
specialUseService,
|
|
403
|
-
silentLogger,
|
|
404
|
-
);
|
|
405
|
-
|
|
406
|
-
const result = await service.syncMailboxes(
|
|
407
|
-
{ accountId: "acc-1" },
|
|
408
|
-
serverConnection([{ fullPath: "INBOX" }, { fullPath: "Records/2025" }]),
|
|
409
|
-
);
|
|
410
|
-
|
|
411
|
-
assert.deepEqual(created, []);
|
|
412
|
-
assert.equal(result.created, 0);
|
|
413
|
-
assert.deepEqual(deleted, []);
|
|
414
|
-
});
|
|
149
|
+
it("deletes a synced folder that has left the server", async () => {
|
|
150
|
+
imap.addFolder("INBOX");
|
|
151
|
+
await seedMailbox(store, account, "Old Folder");
|
|
415
152
|
|
|
416
|
-
|
|
417
|
-
const { mailboxService, specialUseService, created } = buildServices(
|
|
418
|
-
[
|
|
419
|
-
{
|
|
420
|
-
mailboxId: "inbox",
|
|
421
|
-
fullPath: "INBOX",
|
|
422
|
-
syncStatus: MailboxSyncStatus.synced,
|
|
423
|
-
},
|
|
424
|
-
],
|
|
425
|
-
"PathTaken",
|
|
426
|
-
);
|
|
427
|
-
const errors: Array<Record<string, unknown>> = [];
|
|
428
|
-
const service = new MailboxSyncService(mailboxService, specialUseService, {
|
|
429
|
-
...silentLogger,
|
|
430
|
-
error: (obj) => {
|
|
431
|
-
errors.push(obj);
|
|
432
|
-
},
|
|
433
|
-
});
|
|
434
|
-
|
|
435
|
-
const result = await service.syncMailboxes(
|
|
436
|
-
{ accountId: "acc-1" },
|
|
437
|
-
serverConnection([{ fullPath: "INBOX" }, { fullPath: "Projects" }]),
|
|
438
|
-
);
|
|
153
|
+
const result = await sweep();
|
|
439
154
|
|
|
440
|
-
assert.equal(
|
|
441
|
-
assert.
|
|
442
|
-
assert.deepEqual(errors, [{ accountId: "acc-1", fullPath: "Projects" }]);
|
|
155
|
+
assert.equal(result.deleted, 1);
|
|
156
|
+
assert.deepEqual(await paths(), ["INBOX"]);
|
|
443
157
|
});
|
|
444
158
|
|
|
445
|
-
it("
|
|
446
|
-
|
|
447
|
-
// at (T6), and nothing clears it until the user retries or dismisses. If
|
|
448
|
-
// the guard honoured that claim, one stuck folder would block the sweep
|
|
449
|
-
// from ever seeing a real folder another client later created at that
|
|
450
|
-
// path — permanently, and silently.
|
|
451
|
-
const { mailboxService, specialUseService, created } = buildServices([
|
|
452
|
-
{
|
|
453
|
-
mailboxId: "stuck",
|
|
454
|
-
fullPath: "Archive/2025",
|
|
455
|
-
syncStatus: MailboxSyncStatus.failed,
|
|
456
|
-
pendingPath: "Records/2025",
|
|
457
|
-
},
|
|
458
|
-
]);
|
|
459
|
-
const service = new MailboxSyncService(
|
|
460
|
-
mailboxService,
|
|
461
|
-
specialUseService,
|
|
462
|
-
silentLogger,
|
|
463
|
-
);
|
|
159
|
+
it("discovers a new folder as synced", async () => {
|
|
160
|
+
imap.addFolder("INBOX");
|
|
464
161
|
|
|
465
|
-
await
|
|
466
|
-
{ accountId: "acc-1" },
|
|
467
|
-
serverConnection([
|
|
468
|
-
{ fullPath: "INBOX" },
|
|
469
|
-
{ fullPath: "Archive/2025" },
|
|
470
|
-
{ fullPath: "Records/2025" },
|
|
471
|
-
]),
|
|
472
|
-
);
|
|
162
|
+
await sweep();
|
|
473
163
|
|
|
474
|
-
assert.
|
|
475
|
-
created.map((input) => input.fullPath),
|
|
476
|
-
["INBOX", "Records/2025"],
|
|
477
|
-
);
|
|
164
|
+
assert.equal((await byPath("INBOX")).syncStatus, MailboxSyncStatus.synced);
|
|
478
165
|
});
|
|
479
166
|
|
|
480
|
-
it("
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
specialUseService,
|
|
485
|
-
silentLogger,
|
|
486
|
-
);
|
|
167
|
+
it("inserts no second row for the path a pending rename is on its way to", async () => {
|
|
168
|
+
imap.addFolder("INBOX");
|
|
169
|
+
imap.addFolder("Records/2025");
|
|
170
|
+
await inState("Archive/2025", MailboxSyncStatus.pending, "Records/2025");
|
|
487
171
|
|
|
488
|
-
await
|
|
489
|
-
{ accountId: "acc-1" },
|
|
490
|
-
serverConnection([{ fullPath: "INBOX" }]),
|
|
491
|
-
);
|
|
172
|
+
const result = await sweep();
|
|
492
173
|
|
|
493
|
-
assert.equal(created
|
|
494
|
-
assert.
|
|
174
|
+
assert.equal(result.created, 1);
|
|
175
|
+
assert.deepEqual(await paths(), ["Archive/2025", "INBOX"]);
|
|
495
176
|
});
|
|
496
177
|
|
|
497
|
-
it("
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
mailboxId: "inbox",
|
|
503
|
-
fullPath: "INBOX",
|
|
504
|
-
syncStatus: MailboxSyncStatus.synced,
|
|
505
|
-
},
|
|
506
|
-
{
|
|
507
|
-
mailboxId: "synced-gone",
|
|
508
|
-
fullPath: "Old Folder",
|
|
509
|
-
syncStatus: MailboxSyncStatus.synced,
|
|
510
|
-
},
|
|
511
|
-
]);
|
|
512
|
-
const service = new MailboxSyncService(
|
|
513
|
-
mailboxService,
|
|
514
|
-
specialUseService,
|
|
515
|
-
silentLogger,
|
|
516
|
-
);
|
|
178
|
+
it("discovers a failed rename's target as a folder of its own", async () => {
|
|
179
|
+
imap.addFolder("INBOX");
|
|
180
|
+
imap.addFolder("Archive/2025");
|
|
181
|
+
imap.addFolder("Records/2025");
|
|
182
|
+
await inState("Archive/2025", MailboxSyncStatus.failed, "Records/2025");
|
|
517
183
|
|
|
518
|
-
|
|
519
|
-
{ accountId: "acc-1" },
|
|
520
|
-
serverConnection(),
|
|
521
|
-
);
|
|
184
|
+
await sweep();
|
|
522
185
|
|
|
523
|
-
assert.deepEqual(
|
|
524
|
-
assert.equal(result.deleted, 1);
|
|
186
|
+
assert.deepEqual(await paths(), ["Archive/2025", "INBOX", "Records/2025"]);
|
|
525
187
|
});
|
|
526
188
|
});
|
|
527
189
|
|
|
528
|
-
describe("
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
190
|
+
describe("a lookalike of a flagged folder", () => {
|
|
191
|
+
it("keeps folders called Deleted and Bin beside the flagged Trash", async () => {
|
|
192
|
+
imap.addFolder("INBOX");
|
|
193
|
+
imap.addFolder("Trash", { attributes: ["\\Trash"] });
|
|
194
|
+
imap.addFolder("Deleted");
|
|
195
|
+
imap.addFolder("Bin");
|
|
534
196
|
|
|
535
|
-
|
|
536
|
-
folders: Array<{ fullPath: string; attributes: string[] }>,
|
|
537
|
-
): IImapConnection =>
|
|
538
|
-
({
|
|
539
|
-
getNamespaces: async () => namespaces,
|
|
540
|
-
listMailboxes: async () =>
|
|
541
|
-
folders.map((folder) => ({
|
|
542
|
-
fullPath: folder.fullPath,
|
|
543
|
-
name: folder.fullPath.split("/").pop() ?? folder.fullPath,
|
|
544
|
-
delimiter: "/",
|
|
545
|
-
attributes: folder.attributes,
|
|
546
|
-
parentPath: null,
|
|
547
|
-
})),
|
|
548
|
-
getMailboxStatus: async () => ({
|
|
549
|
-
messages: 0,
|
|
550
|
-
recent: 0,
|
|
551
|
-
unseen: 0,
|
|
552
|
-
uidNext: 1,
|
|
553
|
-
uidValidity: 1,
|
|
554
|
-
highestModseq: "0",
|
|
555
|
-
deletedCount: 0,
|
|
556
|
-
}),
|
|
557
|
-
}) as unknown as IImapConnection;
|
|
558
|
-
|
|
559
|
-
const buildServices = (
|
|
560
|
-
existing: Array<{ mailboxId: string; fullPath: string }>,
|
|
561
|
-
) => {
|
|
562
|
-
const deleted: string[] = [];
|
|
563
|
-
const mailboxService = {
|
|
564
|
-
listByAccount: async () => ({
|
|
565
|
-
items: existing.map((m) => ({
|
|
566
|
-
mailboxId: m.mailboxId,
|
|
567
|
-
fullPath: m.fullPath,
|
|
568
|
-
uidNext: 1,
|
|
569
|
-
uidValidity: 1,
|
|
570
|
-
messageCount: 0,
|
|
571
|
-
unseenCount: 0,
|
|
572
|
-
deletedCount: 0,
|
|
573
|
-
highestModseq: "0",
|
|
574
|
-
specialUse: undefined,
|
|
575
|
-
syncStatus: MailboxSyncStatus.synced,
|
|
576
|
-
})),
|
|
577
|
-
continuationToken: undefined,
|
|
578
|
-
}),
|
|
579
|
-
update: async () => ({}),
|
|
580
|
-
delete: async (_accountId: string, mailboxId: string) => {
|
|
581
|
-
deleted.push(mailboxId);
|
|
582
|
-
},
|
|
583
|
-
create: async () => ({
|
|
584
|
-
outcome: "Created",
|
|
585
|
-
mailbox: { mailboxId: "new" },
|
|
586
|
-
}),
|
|
587
|
-
} as unknown as IMailboxRepository;
|
|
588
|
-
|
|
589
|
-
const specialUseService = {
|
|
590
|
-
listByMailboxId: async () => [],
|
|
591
|
-
deleteByMailboxId: async () => undefined,
|
|
592
|
-
createMany: async () => undefined,
|
|
593
|
-
} as unknown as IMailboxSpecialUseRepository;
|
|
594
|
-
|
|
595
|
-
return { mailboxService, specialUseService, deleted };
|
|
596
|
-
};
|
|
597
|
-
|
|
598
|
-
const syncWith = async (
|
|
599
|
-
folders: Array<{ fullPath: string; attributes: string[] }>,
|
|
600
|
-
existing: Array<{ mailboxId: string; fullPath: string }>,
|
|
601
|
-
): Promise<string[]> => {
|
|
602
|
-
const { mailboxService, specialUseService, deleted } =
|
|
603
|
-
buildServices(existing);
|
|
604
|
-
const service = new MailboxSyncService(
|
|
605
|
-
mailboxService,
|
|
606
|
-
specialUseService,
|
|
607
|
-
silentLogger,
|
|
608
|
-
);
|
|
609
|
-
await service.syncMailboxes(
|
|
610
|
-
{ accountId: "acc-1" },
|
|
611
|
-
serverConnection(folders),
|
|
612
|
-
);
|
|
613
|
-
return deleted;
|
|
614
|
-
};
|
|
197
|
+
await sweep();
|
|
615
198
|
|
|
616
|
-
|
|
617
|
-
// `deleted` and `bin` were Trash names in this file's own copy of the hint
|
|
618
|
-
// table long after #843 dropped them from the shared one. Every sync of an
|
|
619
|
-
// account holding a flagged Trash and a user folder called `Deleted` took
|
|
620
|
-
// the user's folder, and its mail, out of the client.
|
|
621
|
-
const deleted = await syncWith(
|
|
622
|
-
[
|
|
623
|
-
{ fullPath: "INBOX", attributes: [] },
|
|
624
|
-
{ fullPath: "Trash", attributes: ["\\Trash"] },
|
|
625
|
-
{ fullPath: "Deleted", attributes: [] },
|
|
626
|
-
{ fullPath: "Bin", attributes: [] },
|
|
627
|
-
],
|
|
628
|
-
[
|
|
629
|
-
{ mailboxId: "inbox", fullPath: "INBOX" },
|
|
630
|
-
{ mailboxId: "trash", fullPath: "Trash" },
|
|
631
|
-
{ mailboxId: "keepsakes", fullPath: "Deleted" },
|
|
632
|
-
{ mailboxId: "bin", fullPath: "Bin" },
|
|
633
|
-
],
|
|
634
|
-
);
|
|
635
|
-
|
|
636
|
-
assert.deepEqual(deleted, []);
|
|
199
|
+
assert.deepEqual(await paths(), ["Bin", "Deleted", "INBOX", "Trash"]);
|
|
637
200
|
});
|
|
638
201
|
|
|
639
|
-
it("
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
{ fullPath: "[Gmail]/Trash", attributes: ["\\Trash"] },
|
|
644
|
-
{ fullPath: "Trash", attributes: [] },
|
|
645
|
-
],
|
|
646
|
-
[
|
|
647
|
-
{ mailboxId: "inbox", fullPath: "INBOX" },
|
|
648
|
-
{ mailboxId: "gmail-trash", fullPath: "[Gmail]/Trash" },
|
|
649
|
-
{ mailboxId: "lookalike", fullPath: "Trash" },
|
|
650
|
-
],
|
|
651
|
-
);
|
|
202
|
+
it("hides the unflagged twin of a folder the server flagged", async () => {
|
|
203
|
+
imap.addFolder("INBOX");
|
|
204
|
+
imap.addFolder("[Gmail]/Trash", { attributes: ["\\Trash"] });
|
|
205
|
+
imap.addFolder("Trash");
|
|
652
206
|
|
|
653
|
-
|
|
207
|
+
await sweep();
|
|
208
|
+
|
|
209
|
+
assert.deepEqual(await paths(), ["[Gmail]/Trash", "INBOX"]);
|
|
654
210
|
});
|
|
655
211
|
|
|
656
212
|
it("keeps a folder that holds a role of its own", async () => {
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
const deleted = await syncWith(
|
|
661
|
-
[
|
|
662
|
-
{ fullPath: "INBOX", attributes: [] },
|
|
663
|
-
{ fullPath: "Archive", attributes: ["\\Archive"] },
|
|
664
|
-
{ fullPath: "INBOX/All Mail", attributes: [] },
|
|
665
|
-
],
|
|
666
|
-
[
|
|
667
|
-
{ mailboxId: "inbox", fullPath: "INBOX" },
|
|
668
|
-
{ mailboxId: "archive", fullPath: "Archive" },
|
|
669
|
-
{ mailboxId: "all-mail", fullPath: "INBOX/All Mail" },
|
|
670
|
-
],
|
|
671
|
-
);
|
|
213
|
+
imap.addFolder("INBOX");
|
|
214
|
+
imap.addFolder("Archive", { attributes: ["\\Archive"] });
|
|
215
|
+
imap.addFolder("INBOX/All Mail");
|
|
672
216
|
|
|
673
|
-
|
|
217
|
+
await sweep();
|
|
218
|
+
|
|
219
|
+
assert.deepEqual(await paths(), ["Archive", "INBOX", "INBOX/All Mail"]);
|
|
674
220
|
});
|
|
675
221
|
});
|
|
676
222
|
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
223
|
+
describe("a folder leaving mid-sweep", () => {
|
|
224
|
+
const statusFailingFor = (
|
|
225
|
+
path: string,
|
|
226
|
+
before: () => Promise<unknown>,
|
|
227
|
+
message: string,
|
|
228
|
+
) => {
|
|
229
|
+
const connection = imap.connection();
|
|
230
|
+
const status = connection.getMailboxStatus;
|
|
231
|
+
connection.getMailboxStatus = async (asked) => {
|
|
232
|
+
if (asked !== path) return status(asked);
|
|
233
|
+
await before();
|
|
234
|
+
throw new Error(message);
|
|
235
|
+
};
|
|
236
|
+
return connection;
|
|
689
237
|
};
|
|
690
238
|
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
239
|
+
it("asks no STATUS of a folder mid-mutation and leaves its row alone", async () => {
|
|
240
|
+
imap.addFolder("INBOX");
|
|
241
|
+
imap.addFolder("Doomed");
|
|
242
|
+
const doomed = await inState("Doomed", MailboxSyncStatus.deleting);
|
|
243
|
+
const connection = imap.connection();
|
|
244
|
+
const asked: string[] = [];
|
|
245
|
+
const status = connection.getMailboxStatus;
|
|
246
|
+
connection.getMailboxStatus = (path) => {
|
|
247
|
+
asked.push(path);
|
|
248
|
+
return status(path);
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
await sweep(connection);
|
|
252
|
+
|
|
253
|
+
assert.deepEqual(asked, ["INBOX"]);
|
|
254
|
+
assert.deepEqual(await row(doomed), doomed);
|
|
699
255
|
});
|
|
700
256
|
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
const
|
|
706
|
-
getNamespaces: async () => namespaces,
|
|
707
|
-
listMailboxes: async () =>
|
|
708
|
-
["INBOX", "Doomed"].map((fullPath) => ({
|
|
709
|
-
fullPath,
|
|
710
|
-
name: fullPath,
|
|
711
|
-
delimiter: "/",
|
|
712
|
-
attributes: [],
|
|
713
|
-
parentPath: null,
|
|
714
|
-
})),
|
|
715
|
-
getMailboxStatus: async (path: string) => {
|
|
716
|
-
statusPaths.push(path);
|
|
717
|
-
return statusFor(path);
|
|
718
|
-
},
|
|
719
|
-
} as unknown as IImapConnection;
|
|
720
|
-
return { connection, statusPaths };
|
|
721
|
-
};
|
|
722
|
-
|
|
723
|
-
const buildServices = (options: {
|
|
724
|
-
doomedSyncStatus?: string;
|
|
725
|
-
getDoomed?: () => Promise<unknown>;
|
|
726
|
-
}) => {
|
|
727
|
-
const updatedIds: string[] = [];
|
|
728
|
-
const row = (mailboxId: string, fullPath: string, syncStatus?: string) => ({
|
|
729
|
-
mailboxId,
|
|
730
|
-
fullPath,
|
|
731
|
-
uidNext: 1,
|
|
732
|
-
uidValidity: 1,
|
|
733
|
-
messageCount: 0,
|
|
734
|
-
unseenCount: 0,
|
|
735
|
-
deletedCount: 0,
|
|
736
|
-
highestModseq: "0",
|
|
737
|
-
specialUse: undefined,
|
|
738
|
-
syncStatus,
|
|
739
|
-
});
|
|
740
|
-
|
|
741
|
-
const mailboxService = {
|
|
742
|
-
listByAccount: async () => ({
|
|
743
|
-
items: [
|
|
744
|
-
row("mbx-inbox", "INBOX", MailboxSyncStatus.synced),
|
|
745
|
-
row("mbx-doomed", "Doomed", options.doomedSyncStatus),
|
|
746
|
-
],
|
|
747
|
-
continuationToken: undefined,
|
|
748
|
-
}),
|
|
749
|
-
get: async (_accountId: string, mailboxId: string) => {
|
|
750
|
-
if (mailboxId === "mbx-doomed" && options.getDoomed) {
|
|
751
|
-
return options.getDoomed();
|
|
752
|
-
}
|
|
753
|
-
return row(mailboxId, mailboxId, MailboxSyncStatus.synced);
|
|
754
|
-
},
|
|
755
|
-
update: async (
|
|
756
|
-
_accountId: string,
|
|
757
|
-
mailboxId: string,
|
|
758
|
-
_patch: Record<string, unknown>,
|
|
759
|
-
) => {
|
|
760
|
-
updatedIds.push(mailboxId);
|
|
761
|
-
return {};
|
|
762
|
-
},
|
|
763
|
-
delete: async () => undefined,
|
|
764
|
-
create: async () => ({
|
|
765
|
-
outcome: "Created",
|
|
766
|
-
mailbox: { mailboxId: "new" },
|
|
767
|
-
}),
|
|
768
|
-
} as unknown as IMailboxRepository;
|
|
769
|
-
|
|
770
|
-
const specialUseService = {
|
|
771
|
-
listByMailboxId: async () => [],
|
|
772
|
-
deleteByMailboxId: async () => undefined,
|
|
773
|
-
createMany: async () => undefined,
|
|
774
|
-
} as unknown as IMailboxSpecialUseRepository;
|
|
775
|
-
|
|
776
|
-
return { mailboxService, specialUseService, updatedIds };
|
|
777
|
-
};
|
|
778
|
-
|
|
779
|
-
for (const syncStatus of [
|
|
780
|
-
MailboxSyncStatus.pending,
|
|
781
|
-
MailboxSyncStatus.deleting,
|
|
782
|
-
]) {
|
|
783
|
-
it(`leaves a \`${syncStatus}\` folder untouched — no STATUS, no write`, async () => {
|
|
784
|
-
const { mailboxService, specialUseService, updatedIds } = buildServices({
|
|
785
|
-
doomedSyncStatus: syncStatus,
|
|
786
|
-
});
|
|
787
|
-
const { connection, statusPaths } = buildConnection(async () => status());
|
|
788
|
-
const service = new MailboxSyncService(
|
|
789
|
-
mailboxService,
|
|
790
|
-
specialUseService,
|
|
791
|
-
silentLogger,
|
|
792
|
-
);
|
|
793
|
-
|
|
794
|
-
await service.syncMailboxes({ accountId: "acc-1" }, connection);
|
|
795
|
-
|
|
796
|
-
assert.deepEqual(statusPaths, ["INBOX"]);
|
|
797
|
-
assert.deepEqual(updatedIds, ["mbx-inbox"]);
|
|
798
|
-
});
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
it("finishes the sweep when a folder's STATUS fails and that folder has since been deleted", async () => {
|
|
802
|
-
const { mailboxService, specialUseService, updatedIds } = buildServices({
|
|
803
|
-
doomedSyncStatus: MailboxSyncStatus.synced,
|
|
804
|
-
getDoomed: async () => {
|
|
805
|
-
throw new NotFoundError("Mailbox not found: mbx-doomed");
|
|
806
|
-
},
|
|
807
|
-
});
|
|
808
|
-
const { connection } = buildConnection(async (path) => {
|
|
809
|
-
if (path === "Doomed") throw new Error("Mailbox doesn't exist: Doomed");
|
|
810
|
-
return status();
|
|
811
|
-
});
|
|
812
|
-
const service = new MailboxSyncService(
|
|
813
|
-
mailboxService,
|
|
814
|
-
specialUseService,
|
|
815
|
-
silentLogger,
|
|
816
|
-
);
|
|
257
|
+
it("finishes the sweep when the folder whose STATUS failed has been deleted", async () => {
|
|
258
|
+
imap.addFolder("INBOX");
|
|
259
|
+
imap.addFolder("Doomed");
|
|
260
|
+
await sweep();
|
|
261
|
+
const doomed = await byPath("Doomed");
|
|
817
262
|
|
|
818
263
|
await assert.doesNotReject(
|
|
819
|
-
|
|
264
|
+
sweep(
|
|
265
|
+
statusFailingFor(
|
|
266
|
+
"Doomed",
|
|
267
|
+
() => store.mailbox.delete(account.accountId, doomed.mailboxId),
|
|
268
|
+
"Mailbox doesn't exist: Doomed",
|
|
269
|
+
),
|
|
270
|
+
),
|
|
820
271
|
);
|
|
821
|
-
assert.deepEqual(updatedIds, ["mbx-inbox"]);
|
|
822
272
|
});
|
|
823
273
|
|
|
824
|
-
it("fails the sweep when
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
mailboxId: "mbx-doomed",
|
|
829
|
-
fullPath: "Doomed",
|
|
830
|
-
syncStatus: MailboxSyncStatus.synced,
|
|
831
|
-
}),
|
|
832
|
-
});
|
|
833
|
-
const { connection } = buildConnection(async (path) => {
|
|
834
|
-
if (path === "Doomed") throw new Error("connection reset by peer");
|
|
835
|
-
return status();
|
|
836
|
-
});
|
|
837
|
-
const service = new MailboxSyncService(
|
|
838
|
-
mailboxService,
|
|
839
|
-
specialUseService,
|
|
840
|
-
silentLogger,
|
|
841
|
-
);
|
|
274
|
+
it("fails the sweep when the folder whose STATUS failed is still live", async () => {
|
|
275
|
+
imap.addFolder("INBOX");
|
|
276
|
+
imap.addFolder("Doomed");
|
|
277
|
+
await sweep();
|
|
842
278
|
|
|
843
279
|
await assert.rejects(
|
|
844
|
-
|
|
280
|
+
sweep(
|
|
281
|
+
statusFailingFor("Doomed", async () => {}, "connection reset by peer"),
|
|
282
|
+
),
|
|
845
283
|
/connection reset by peer/,
|
|
846
284
|
);
|
|
847
285
|
});
|