@remit/mailbox-service 0.0.111 → 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 +156 -235
- 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 +5 -4
- 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/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-message-gone.test.ts +0 -139
- 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
|
@@ -1,408 +1,191 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import type {
|
|
2
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
3
|
+
import type { AccountItem, MailboxItem } from "@remit/data-ports";
|
|
4
4
|
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
5
5
|
import { MailboxManagementService } from "./mailbox-management.js";
|
|
6
|
+
import { FolderGoneUpstreamError } from "./mailbox-upstream.js";
|
|
6
7
|
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
} from "./
|
|
10
|
-
import
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
)
|
|
23
|
-
(
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}) as MailboxItem;
|
|
32
|
-
|
|
33
|
-
const settled = (
|
|
34
|
-
mailboxId: string,
|
|
8
|
+
type FakeImapConnection,
|
|
9
|
+
FakeImapServer,
|
|
10
|
+
} from "./test-helpers/fake-imap.js";
|
|
11
|
+
import {
|
|
12
|
+
beforeNext,
|
|
13
|
+
openStore,
|
|
14
|
+
type Store,
|
|
15
|
+
seedAccount,
|
|
16
|
+
seedMailbox,
|
|
17
|
+
} from "./test-helpers/test-store.js";
|
|
18
|
+
|
|
19
|
+
let store: Store;
|
|
20
|
+
let imap: FakeImapServer;
|
|
21
|
+
let account: AccountItem;
|
|
22
|
+
|
|
23
|
+
beforeEach(async () => {
|
|
24
|
+
store = await openStore();
|
|
25
|
+
imap = new FakeImapServer();
|
|
26
|
+
account = await seedAccount(store);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => store.close());
|
|
30
|
+
|
|
31
|
+
const folder = async (
|
|
35
32
|
fullPath: string,
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const repo: Pick<
|
|
51
|
-
IMailboxRepository,
|
|
52
|
-
"get" | "update" | "transition" | "findBySyncStatus"
|
|
53
|
-
> = {
|
|
54
|
-
get: (async (_accountId: string, mailboxId: string) => {
|
|
55
|
-
const found = byId.get(mailboxId as string);
|
|
56
|
-
if (!found) {
|
|
57
|
-
throw Object.assign(new Error(`Mailbox not found: ${mailboxId}`), {
|
|
58
|
-
name: "NotFoundError",
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
return found;
|
|
62
|
-
}) as IMailboxRepository["get"],
|
|
63
|
-
update: async (_accountId, mailboxId, patch) => {
|
|
64
|
-
const existing = byId.get(mailboxId);
|
|
65
|
-
if (!existing) {
|
|
66
|
-
throw Object.assign(new Error(`Mailbox not found: ${mailboxId}`), {
|
|
67
|
-
name: "NotFoundError",
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
const next = { ...existing, ...patch } as MailboxItem;
|
|
71
|
-
byId.set(mailboxId, next);
|
|
72
|
-
return next;
|
|
73
|
-
},
|
|
74
|
-
transition: async (_accountId, mailboxId, intent) => {
|
|
75
|
-
const existing = byId.get(mailboxId);
|
|
76
|
-
if (!existing) return null;
|
|
77
|
-
if (!intent.from.includes(existing.syncStatus)) return null;
|
|
78
|
-
if (
|
|
79
|
-
intent.wherePendingPath !== undefined &&
|
|
80
|
-
(intent.wherePendingPath ?? undefined) !== existing.pendingPath
|
|
81
|
-
) {
|
|
82
|
-
return null;
|
|
83
|
-
}
|
|
84
|
-
const keepsTarget =
|
|
85
|
-
intent.to === MailboxSyncStatus.pending ||
|
|
86
|
-
intent.to === MailboxSyncStatus.failed;
|
|
87
|
-
const next = {
|
|
88
|
-
...existing,
|
|
89
|
-
...(intent.set?.fullPath !== undefined
|
|
90
|
-
? { fullPath: intent.set.fullPath }
|
|
91
|
-
: {}),
|
|
92
|
-
syncStatus: intent.to,
|
|
93
|
-
} as MailboxItem;
|
|
94
|
-
if (!keepsTarget) delete (next as { pendingPath?: string }).pendingPath;
|
|
95
|
-
byId.set(mailboxId, next);
|
|
96
|
-
return next;
|
|
97
|
-
},
|
|
98
|
-
findBySyncStatus: async (_accountId, syncStatus) => {
|
|
99
|
-
const found = [...byId.values()].filter(
|
|
100
|
-
(r) => r.syncStatus === syncStatus,
|
|
101
|
-
);
|
|
102
|
-
for (const mailboxId of vanishBeforeSettle) byId.delete(mailboxId);
|
|
103
|
-
return found;
|
|
33
|
+
pendingPath?: string,
|
|
34
|
+
overrides: Partial<MailboxItem> = {},
|
|
35
|
+
): Promise<MailboxItem> => {
|
|
36
|
+
imap.addFolder(fullPath);
|
|
37
|
+
const mailbox = await seedMailbox(store, account, fullPath, { overrides });
|
|
38
|
+
if (!pendingPath) return mailbox;
|
|
39
|
+
const recorded = await store.mailbox.transition(
|
|
40
|
+
account.accountId,
|
|
41
|
+
mailbox.mailboxId,
|
|
42
|
+
{
|
|
43
|
+
from: [MailboxSyncStatus.synced],
|
|
44
|
+
to: MailboxSyncStatus.pending,
|
|
45
|
+
set: { pendingPath },
|
|
104
46
|
},
|
|
105
|
-
|
|
47
|
+
);
|
|
48
|
+
if (!recorded) throw new Error(`could not record a rename on ${fullPath}`);
|
|
49
|
+
return recorded;
|
|
50
|
+
};
|
|
106
51
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
52
|
+
const current = (mailbox: MailboxItem) =>
|
|
53
|
+
store.mailbox.get(account.accountId, mailbox.mailboxId);
|
|
54
|
+
|
|
55
|
+
const management = () => new MailboxManagementService(store.mailbox);
|
|
56
|
+
|
|
57
|
+
const renameWork = (connection: FakeImapConnection = imap.connection()) =>
|
|
58
|
+
management().syncRename(
|
|
59
|
+
account.accountId,
|
|
60
|
+
workId as string,
|
|
61
|
+
"Work",
|
|
62
|
+
"Projects",
|
|
63
|
+
async () => connection,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
let workId: string | undefined;
|
|
67
|
+
|
|
68
|
+
const work = async (
|
|
69
|
+
pendingPath: string | null = "Projects",
|
|
70
|
+
overrides: Partial<MailboxItem> = {},
|
|
71
|
+
) => {
|
|
72
|
+
const mailbox = await folder("Work", pendingPath ?? undefined, overrides);
|
|
73
|
+
workId = mailbox.mailboxId;
|
|
74
|
+
return mailbox;
|
|
111
75
|
};
|
|
112
76
|
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
listed: string[] | "unreadable" = [],
|
|
125
|
-
): IImapConnection =>
|
|
126
|
-
({
|
|
127
|
-
renameMailbox: async () => {
|
|
128
|
-
throw error;
|
|
129
|
-
},
|
|
130
|
-
listMailboxes: async () => {
|
|
131
|
-
if (listed === "unreadable") throw new Error("IMAP connection lost");
|
|
132
|
-
return listed.map((fullPath) => ({ fullPath, delimiter: "/" }));
|
|
133
|
-
},
|
|
134
|
-
}) as unknown as IImapConnection;
|
|
77
|
+
const assertAt = async (
|
|
78
|
+
mailbox: MailboxItem,
|
|
79
|
+
fullPath: string,
|
|
80
|
+
syncStatus: MailboxItem["syncStatus"],
|
|
81
|
+
pendingPath?: string,
|
|
82
|
+
) => {
|
|
83
|
+
const row = await current(mailbox);
|
|
84
|
+
assert.equal(row.fullPath, fullPath);
|
|
85
|
+
assert.equal(row.syncStatus, syncStatus);
|
|
86
|
+
assert.equal(row.pendingPath, pendingPath);
|
|
87
|
+
};
|
|
135
88
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
89
|
+
describe("MailboxManagementService.syncRename settles one intent", () => {
|
|
90
|
+
it("adopts the path the server resolved over the one requested", async () => {
|
|
91
|
+
const parent = await work();
|
|
92
|
+
const child = await folder("Work/2026", "Projects/2026");
|
|
93
|
+
imap.namespacePrefix = "INBOX/";
|
|
141
94
|
|
|
142
|
-
|
|
143
|
-
it("adopts the confirmed path on the folder and every row that carried the intent", async () => {
|
|
144
|
-
const { repo, rowOf } = store([
|
|
145
|
-
renaming("mbx-parent", "Work", "Projects"),
|
|
146
|
-
renaming("mbx-child", "Work/2026", "Projects/2026"),
|
|
147
|
-
renaming("mbx-grandchild", "Work/2026/Q1", "Projects/2026/Q1"),
|
|
148
|
-
]);
|
|
149
|
-
const service = new MailboxManagementService(repo);
|
|
150
|
-
|
|
151
|
-
const result = await service.syncRename(
|
|
152
|
-
"acc-1",
|
|
153
|
-
"mbx-parent",
|
|
154
|
-
"Work",
|
|
155
|
-
"Projects",
|
|
156
|
-
async () => connection(),
|
|
157
|
-
);
|
|
95
|
+
await renameWork();
|
|
158
96
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
["mbx-parent", "Projects"],
|
|
162
|
-
["mbx-child", "Projects/2026"],
|
|
163
|
-
["mbx-grandchild", "Projects/2026/Q1"],
|
|
164
|
-
] as const) {
|
|
165
|
-
assert.equal(rowOf(id)?.fullPath, path);
|
|
166
|
-
assert.equal(rowOf(id)?.syncStatus, MailboxSyncStatus.synced);
|
|
167
|
-
assert.equal(rowOf(id)?.pendingPath, undefined);
|
|
168
|
-
}
|
|
97
|
+
await assertAt(parent, "INBOX/Projects", MailboxSyncStatus.synced);
|
|
98
|
+
await assertAt(child, "INBOX/Projects/2026", MailboxSyncStatus.synced);
|
|
169
99
|
});
|
|
170
100
|
|
|
171
|
-
it("
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
await
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
"
|
|
186
|
-
|
|
101
|
+
it("leaves rows it must not claim alone", async () => {
|
|
102
|
+
const parent = await work();
|
|
103
|
+
const interloper = await folder("Projects/Fresh");
|
|
104
|
+
const elsewhere = await folder("Admin", "Beheer");
|
|
105
|
+
const sameBranch = await folder("Other", "Projects/Old");
|
|
106
|
+
const offRebase = await folder("Work/2026", "Projects/Archief");
|
|
107
|
+
|
|
108
|
+
await renameWork();
|
|
109
|
+
|
|
110
|
+
await assertAt(parent, "Projects", MailboxSyncStatus.synced);
|
|
111
|
+
await assertAt(interloper, "Projects/Fresh", MailboxSyncStatus.synced);
|
|
112
|
+
await assertAt(elsewhere, "Admin", MailboxSyncStatus.pending, "Beheer");
|
|
113
|
+
await assertAt(
|
|
114
|
+
sameBranch,
|
|
115
|
+
"Other",
|
|
116
|
+
MailboxSyncStatus.pending,
|
|
117
|
+
"Projects/Old",
|
|
187
118
|
);
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
it("leaves a folder the sweep inserted under the branch after the intent alone", async () => {
|
|
194
|
-
// D15: the settle applies to the rows that recorded the intent, and those
|
|
195
|
-
// rows identify themselves. Re-resolving the subtree would either throw —
|
|
196
|
-
// poisoning the account's FIFO group — or leave every row pending forever.
|
|
197
|
-
const { repo, rowOf } = store([
|
|
198
|
-
renaming("mbx-parent", "Work", "Projects"),
|
|
199
|
-
settled("mbx-interloper", "Projects/Fresh"),
|
|
200
|
-
]);
|
|
201
|
-
const service = new MailboxManagementService(repo);
|
|
202
|
-
|
|
203
|
-
const result = await service.syncRename(
|
|
204
|
-
"acc-1",
|
|
205
|
-
"mbx-parent",
|
|
206
|
-
"Work",
|
|
207
|
-
"Projects",
|
|
208
|
-
async () => connection(),
|
|
119
|
+
await assertAt(
|
|
120
|
+
offRebase,
|
|
121
|
+
"Work/2026",
|
|
122
|
+
MailboxSyncStatus.pending,
|
|
123
|
+
"Projects/Archief",
|
|
209
124
|
);
|
|
210
|
-
|
|
211
|
-
assert.equal(result.success, true);
|
|
212
|
-
assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.synced);
|
|
213
|
-
assert.equal(rowOf("mbx-interloper")?.fullPath, "Projects/Fresh");
|
|
214
|
-
assert.equal(rowOf("mbx-interloper")?.syncStatus, MailboxSyncStatus.synced);
|
|
215
125
|
});
|
|
216
126
|
|
|
217
|
-
it("settles the rest when a descendant is deleted
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
],
|
|
224
|
-
["mbx-gone"],
|
|
127
|
+
it("settles the rest when a descendant is deleted mid-settle", async () => {
|
|
128
|
+
const parent = await work();
|
|
129
|
+
const gone = await folder("Work/Gone", "Projects/Gone");
|
|
130
|
+
const child = await folder("Work/2026", "Projects/2026");
|
|
131
|
+
beforeNext(store.mailbox, "transition", () =>
|
|
132
|
+
store.mailbox.delete(account.accountId, gone.mailboxId),
|
|
225
133
|
);
|
|
226
|
-
const service = new MailboxManagementService(repo);
|
|
227
134
|
|
|
228
|
-
const result = await
|
|
229
|
-
"acc-1",
|
|
230
|
-
"mbx-parent",
|
|
231
|
-
"Work",
|
|
232
|
-
"Projects",
|
|
233
|
-
async () => connection(),
|
|
234
|
-
);
|
|
135
|
+
const result = await renameWork();
|
|
235
136
|
|
|
236
137
|
assert.equal(result.success, true);
|
|
237
|
-
|
|
238
|
-
|
|
138
|
+
await assertAt(parent, "Projects", MailboxSyncStatus.synced);
|
|
139
|
+
await assertAt(child, "Projects/2026", MailboxSyncStatus.synced);
|
|
239
140
|
});
|
|
240
141
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
142
|
+
for (const [label, pendingPath] of [
|
|
143
|
+
["already settled", null],
|
|
144
|
+
["pending for another target", "Beheer"],
|
|
145
|
+
] as const) {
|
|
146
|
+
it(`issues no RENAME when the row is ${label}`, async () => {
|
|
147
|
+
await work(pendingPath);
|
|
247
148
|
|
|
248
|
-
|
|
249
|
-
"acc-1",
|
|
250
|
-
"mbx-parent",
|
|
251
|
-
"Work",
|
|
252
|
-
"Projects",
|
|
253
|
-
async () => connection(),
|
|
254
|
-
);
|
|
255
|
-
|
|
256
|
-
assert.equal(rowOf("mbx-other")?.fullPath, "Admin");
|
|
257
|
-
assert.equal(rowOf("mbx-other")?.pendingPath, "Beheer");
|
|
258
|
-
assert.equal(rowOf("mbx-other")?.syncStatus, MailboxSyncStatus.pending);
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
it("issues no RENAME when the row has already settled", async () => {
|
|
262
|
-
const { repo } = store([settled("mbx-parent", "Projects")]);
|
|
263
|
-
const service = new MailboxManagementService(repo);
|
|
264
|
-
let issued = 0;
|
|
265
|
-
|
|
266
|
-
const result = await service.syncRename(
|
|
267
|
-
"acc-1",
|
|
268
|
-
"mbx-parent",
|
|
269
|
-
"Work",
|
|
270
|
-
"Projects",
|
|
271
|
-
async () =>
|
|
272
|
-
({
|
|
273
|
-
renameMailbox: async () => {
|
|
274
|
-
issued += 1;
|
|
275
|
-
return { path: "Work", newPath: "Projects" };
|
|
276
|
-
},
|
|
277
|
-
}) as unknown as IImapConnection,
|
|
278
|
-
);
|
|
149
|
+
const result = await renameWork();
|
|
279
150
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
// A redelivered MAILBOX_RENAME against a folder a second rename has since
|
|
286
|
-
// claimed. `pending` alone passes; the recorded target is what refuses it,
|
|
287
|
-
// and re-issuing from a path that has already moved is what that prevents.
|
|
288
|
-
const { repo, rowOf } = store([renaming("mbx-parent", "Work", "Beheer")]);
|
|
289
|
-
const service = new MailboxManagementService(repo);
|
|
290
|
-
let issued = 0;
|
|
291
|
-
|
|
292
|
-
await service.syncRename(
|
|
293
|
-
"acc-1",
|
|
294
|
-
"mbx-parent",
|
|
295
|
-
"Work",
|
|
296
|
-
"Projects",
|
|
297
|
-
async () =>
|
|
298
|
-
({
|
|
299
|
-
renameMailbox: async () => {
|
|
300
|
-
issued += 1;
|
|
301
|
-
return { path: "Work", newPath: "Projects" };
|
|
302
|
-
},
|
|
303
|
-
}) as unknown as IImapConnection,
|
|
304
|
-
);
|
|
305
|
-
|
|
306
|
-
assert.equal(issued, 0);
|
|
307
|
-
assert.equal(rowOf("mbx-parent")?.pendingPath, "Beheer");
|
|
308
|
-
});
|
|
151
|
+
assert.equal(result.success, true);
|
|
152
|
+
assert.ok(imap.hasFolder("Work"));
|
|
153
|
+
assert.equal(imap.hasFolder("Projects"), false);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
309
156
|
});
|
|
310
157
|
|
|
311
|
-
describe("MailboxManagementService.syncRename
|
|
312
|
-
it("
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
// rename with nothing left to settle, and hands the sweep two rows
|
|
316
|
-
// claiming one path to reap and re-insert.
|
|
317
|
-
const { repo, rowOf } = store([
|
|
318
|
-
renaming("mbx-parent", "Work", "Projects"),
|
|
319
|
-
renaming("mbx-other", "Other", "Projects/Old"),
|
|
320
|
-
]);
|
|
321
|
-
const service = new MailboxManagementService(repo);
|
|
322
|
-
|
|
323
|
-
await service.syncRename(
|
|
324
|
-
"acc-1",
|
|
325
|
-
"mbx-parent",
|
|
326
|
-
"Work",
|
|
327
|
-
"Projects",
|
|
328
|
-
async () => connection(),
|
|
329
|
-
);
|
|
330
|
-
|
|
331
|
-
assert.equal(rowOf("mbx-parent")?.fullPath, "Projects");
|
|
332
|
-
assert.equal(rowOf("mbx-other")?.fullPath, "Other");
|
|
333
|
-
assert.equal(rowOf("mbx-other")?.pendingPath, "Projects/Old");
|
|
334
|
-
assert.equal(rowOf("mbx-other")?.syncStatus, MailboxSyncStatus.pending);
|
|
335
|
-
});
|
|
158
|
+
describe("MailboxManagementService.syncRename reads a NONEXISTENT answer", () => {
|
|
159
|
+
it("settles when the source has moved because the rename already landed", async () => {
|
|
160
|
+
const parent = await work();
|
|
161
|
+
await imap.connection().renameMailbox("Work", "Projects");
|
|
336
162
|
|
|
337
|
-
|
|
338
|
-
const { repo, rowOf } = store([
|
|
339
|
-
renaming("mbx-parent", "Work", "Projects"),
|
|
340
|
-
renaming("mbx-child", "Work/2026", "Projects/Archief"),
|
|
341
|
-
]);
|
|
342
|
-
const service = new MailboxManagementService(repo);
|
|
163
|
+
const result = await renameWork();
|
|
343
164
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
"mbx-parent",
|
|
347
|
-
"Work",
|
|
348
|
-
"Projects",
|
|
349
|
-
async () => connection(),
|
|
350
|
-
);
|
|
351
|
-
|
|
352
|
-
assert.equal(rowOf("mbx-parent")?.fullPath, "Projects");
|
|
353
|
-
assert.equal(rowOf("mbx-child")?.fullPath, "Work/2026");
|
|
354
|
-
assert.equal(rowOf("mbx-child")?.syncStatus, MailboxSyncStatus.pending);
|
|
165
|
+
assert.equal(result.success, true);
|
|
166
|
+
await assertAt(parent, "Projects", MailboxSyncStatus.synced);
|
|
355
167
|
});
|
|
356
168
|
|
|
357
|
-
it("
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
await service.failRename(
|
|
365
|
-
"acc-1",
|
|
366
|
-
"mbx-parent",
|
|
367
|
-
"Work",
|
|
368
|
-
"Projects",
|
|
369
|
-
"Permission denied",
|
|
370
|
-
);
|
|
371
|
-
|
|
372
|
-
assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.failed);
|
|
373
|
-
assert.equal(rowOf("mbx-other")?.syncStatus, MailboxSyncStatus.pending);
|
|
374
|
-
});
|
|
375
|
-
});
|
|
169
|
+
it("reads the target through the account's namespace prefix", async () => {
|
|
170
|
+
const parent = await work("Projects", {
|
|
171
|
+
hierarchyDelimiter: ".",
|
|
172
|
+
namespacePrefix: "INBOX.",
|
|
173
|
+
});
|
|
174
|
+
imap.removeFolder("Work");
|
|
175
|
+
imap.addFolder("INBOX.Projects");
|
|
376
176
|
|
|
377
|
-
|
|
378
|
-
it("settles rather than deleting when NONEXISTENT means the source has moved", async () => {
|
|
379
|
-
// A redelivery after a lost settle re-issues RENAME from a path that has
|
|
380
|
-
// moved. Reading that NONEXISTENT as an upstream delete drops the folder
|
|
381
|
-
// and its mail while the folder is alive at the target.
|
|
382
|
-
const { repo, rowOf } = store([renaming("mbx-parent", "Work", "Projects")]);
|
|
383
|
-
const service = new MailboxManagementService(repo);
|
|
384
|
-
|
|
385
|
-
const result = await service.syncRename(
|
|
386
|
-
"acc-1",
|
|
387
|
-
"mbx-parent",
|
|
388
|
-
"Work",
|
|
389
|
-
"Projects",
|
|
390
|
-
async () => refusing(nonexistent(), ["Projects"]),
|
|
391
|
-
);
|
|
177
|
+
const result = await renameWork();
|
|
392
178
|
|
|
393
179
|
assert.equal(result.success, true);
|
|
394
|
-
assert.equal(
|
|
395
|
-
assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.synced);
|
|
180
|
+
assert.equal((await current(parent)).syncStatus, MailboxSyncStatus.synced);
|
|
396
181
|
});
|
|
397
182
|
|
|
398
|
-
it("reports the folder
|
|
399
|
-
|
|
400
|
-
|
|
183
|
+
it("reports the folder gone, carrying the server's answer, when neither path is listed", async () => {
|
|
184
|
+
await work();
|
|
185
|
+
imap.removeFolder("Work");
|
|
401
186
|
|
|
402
187
|
await assert.rejects(
|
|
403
|
-
|
|
404
|
-
refusing(nonexistent(), ["Andere"]),
|
|
405
|
-
),
|
|
188
|
+
renameWork(),
|
|
406
189
|
(error: unknown) =>
|
|
407
190
|
error instanceof FolderGoneUpstreamError &&
|
|
408
191
|
(error.cause as { serverResponseCode?: string })?.serverResponseCode ===
|
|
@@ -410,144 +193,17 @@ describe("MailboxManagementService.syncRename — a rename that already landed",
|
|
|
410
193
|
);
|
|
411
194
|
});
|
|
412
195
|
|
|
413
|
-
it("
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
},
|
|
422
|
-
} as unknown as IMailboxRepository;
|
|
423
|
-
const service = new MailboxManagementService(broken);
|
|
424
|
-
|
|
425
|
-
await assert.rejects(
|
|
426
|
-
service.syncRename("acc-1", "mbx-parent", "Work", "Projects", async () =>
|
|
427
|
-
connection(),
|
|
428
|
-
),
|
|
429
|
-
(error: unknown) => error instanceof FolderRenameSettleError,
|
|
430
|
-
);
|
|
431
|
-
|
|
432
|
-
assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.pending);
|
|
433
|
-
assert.equal(
|
|
434
|
-
rowOf("mbx-parent")?.pendingPath,
|
|
435
|
-
"Projects",
|
|
436
|
-
"the intent stands, so the redelivery finishes the settle",
|
|
437
|
-
);
|
|
438
|
-
});
|
|
439
|
-
});
|
|
440
|
-
|
|
441
|
-
describe("MailboxManagementService.syncRename — a settle that dies part-way", () => {
|
|
442
|
-
/**
|
|
443
|
-
* The named folder's row is the redelivery's only witness: the guard reads
|
|
444
|
-
* it and nothing else. Settling it before its descendants would ack a run
|
|
445
|
-
* that stopped half way, and those rows would stay `pending` forever —
|
|
446
|
-
* skipped by message sync, and with no route out, because an intent may only
|
|
447
|
-
* be recorded from `synced` or `failed`.
|
|
448
|
-
*/
|
|
449
|
-
it("leaves the named folder carrying the intent when a descendant's write fails", async () => {
|
|
450
|
-
const { repo, rowOf } = store([
|
|
451
|
-
renaming("mbx-parent", "Work", "Projects"),
|
|
452
|
-
renaming("mbx-child", "Work/2026", "Projects/2026"),
|
|
453
|
-
]);
|
|
454
|
-
let broken = true;
|
|
455
|
-
const flaky = {
|
|
456
|
-
...repo,
|
|
457
|
-
transition: async (
|
|
458
|
-
accountId: string,
|
|
459
|
-
mailboxId: string,
|
|
460
|
-
intent: Parameters<IMailboxRepository["transition"]>[2],
|
|
461
|
-
) => {
|
|
462
|
-
if (broken && mailboxId === "mbx-child") {
|
|
463
|
-
throw new Error("database is locked");
|
|
464
|
-
}
|
|
465
|
-
return repo.transition(accountId, mailboxId, intent);
|
|
466
|
-
},
|
|
467
|
-
} as unknown as IMailboxRepository;
|
|
468
|
-
const service = new MailboxManagementService(flaky);
|
|
469
|
-
|
|
470
|
-
await assert.rejects(
|
|
471
|
-
service.syncRename("acc-1", "mbx-parent", "Work", "Projects", async () =>
|
|
472
|
-
connection(),
|
|
473
|
-
),
|
|
474
|
-
(error: unknown) => error instanceof FolderRenameSettleError,
|
|
475
|
-
);
|
|
476
|
-
|
|
477
|
-
assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.pending);
|
|
478
|
-
assert.equal(rowOf("mbx-parent")?.pendingPath, "Projects");
|
|
479
|
-
assert.equal(rowOf("mbx-child")?.syncStatus, MailboxSyncStatus.pending);
|
|
480
|
-
|
|
481
|
-
// The redelivery re-enters against an intent still standing and finishes.
|
|
482
|
-
broken = false;
|
|
483
|
-
const redelivered = new MailboxManagementService(flaky);
|
|
484
|
-
const result = await redelivered.syncRename(
|
|
485
|
-
"acc-1",
|
|
486
|
-
"mbx-parent",
|
|
487
|
-
"Work",
|
|
488
|
-
"Projects",
|
|
489
|
-
async () => connection(),
|
|
490
|
-
);
|
|
491
|
-
|
|
492
|
-
assert.equal(result.success, true);
|
|
493
|
-
assert.equal(rowOf("mbx-parent")?.fullPath, "Projects");
|
|
494
|
-
assert.equal(rowOf("mbx-child")?.fullPath, "Projects/2026");
|
|
495
|
-
assert.equal(rowOf("mbx-child")?.syncStatus, MailboxSyncStatus.synced);
|
|
496
|
-
});
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
describe("MailboxManagementService.syncRename — reading NONEXISTENT", () => {
|
|
500
|
-
const prefixed = (): MailboxItem =>
|
|
501
|
-
({
|
|
502
|
-
mailboxId: "mbx-parent",
|
|
503
|
-
accountId: "acc-1",
|
|
504
|
-
fullPath: "Work",
|
|
505
|
-
hierarchyDelimiter: ".",
|
|
506
|
-
namespacePrefix: "INBOX.",
|
|
507
|
-
syncStatus: MailboxSyncStatus.pending,
|
|
508
|
-
pendingPath: "Projects",
|
|
509
|
-
}) as MailboxItem;
|
|
510
|
-
|
|
511
|
-
it("reads the target through the account's namespace prefix", async () => {
|
|
512
|
-
// The requested string and the path the server keeps are not the same:
|
|
513
|
-
// a Dovecot INBOX namespace stores `Projects` as `INBOX.Projects`.
|
|
514
|
-
// Comparing the raw request against the listing reads a folder that is
|
|
515
|
-
// plainly there as absent — and that answer deletes the user's mail.
|
|
516
|
-
const { repo, rowOf } = store([prefixed()]);
|
|
517
|
-
const service = new MailboxManagementService(repo);
|
|
518
|
-
|
|
519
|
-
const result = await service.syncRename(
|
|
520
|
-
"acc-1",
|
|
521
|
-
"mbx-parent",
|
|
522
|
-
"Work",
|
|
523
|
-
"Projects",
|
|
524
|
-
async () => refusing(nonexistent(), ["INBOX.Projects"]),
|
|
525
|
-
);
|
|
526
|
-
|
|
527
|
-
assert.equal(result.success, true);
|
|
528
|
-
assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.synced);
|
|
529
|
-
});
|
|
530
|
-
|
|
531
|
-
it("raises the confirmed kind only when the listing holds neither path", async () => {
|
|
532
|
-
const { repo } = store([renaming("mbx-parent", "Work", "Projects")]);
|
|
533
|
-
const service = new MailboxManagementService(repo);
|
|
534
|
-
|
|
535
|
-
await assert.rejects(
|
|
536
|
-
service.syncRename("acc-1", "mbx-parent", "Work", "Projects", async () =>
|
|
537
|
-
refusing(nonexistent(), ["Andere"]),
|
|
538
|
-
),
|
|
539
|
-
(error: unknown) => error instanceof FolderGoneUpstreamError,
|
|
540
|
-
);
|
|
541
|
-
});
|
|
542
|
-
|
|
543
|
-
it("refuses the rename rather than confirming a delete when the source is still listed", async () => {
|
|
544
|
-
const { repo } = store([renaming("mbx-parent", "Work", "Projects")]);
|
|
545
|
-
const service = new MailboxManagementService(repo);
|
|
196
|
+
it("refuses rather than confirming a delete when the source is still listed", async () => {
|
|
197
|
+
await work();
|
|
198
|
+
const connection = imap.connection();
|
|
199
|
+
connection.renameMailbox = async () => {
|
|
200
|
+
throw Object.assign(new Error("Command failed"), {
|
|
201
|
+
serverResponseCode: "NONEXISTENT",
|
|
202
|
+
});
|
|
203
|
+
};
|
|
546
204
|
|
|
547
205
|
await assert.rejects(
|
|
548
|
-
|
|
549
|
-
refusing(nonexistent(), ["Work"]),
|
|
550
|
-
),
|
|
206
|
+
renameWork(connection),
|
|
551
207
|
(error: unknown) =>
|
|
552
208
|
!(error instanceof FolderGoneUpstreamError) &&
|
|
553
209
|
(error as { serverResponseCode?: string }).serverResponseCode ===
|
|
@@ -555,110 +211,77 @@ describe("MailboxManagementService.syncRename — reading NONEXISTENT", () => {
|
|
|
555
211
|
);
|
|
556
212
|
});
|
|
557
213
|
|
|
558
|
-
it("refuses
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
const
|
|
562
|
-
|
|
214
|
+
it("refuses rather than confirming a delete when the listing cannot be read", async () => {
|
|
215
|
+
await work();
|
|
216
|
+
imap.removeFolder("Work");
|
|
217
|
+
const connection = imap.connection();
|
|
218
|
+
connection.listMailboxes = async () => {
|
|
219
|
+
throw new Error("IMAP connection lost");
|
|
220
|
+
};
|
|
563
221
|
|
|
564
222
|
await assert.rejects(
|
|
565
|
-
|
|
566
|
-
refusing(nonexistent(), "unreadable"),
|
|
567
|
-
),
|
|
223
|
+
renameWork(connection),
|
|
568
224
|
(error: unknown) => !(error instanceof FolderGoneUpstreamError),
|
|
569
225
|
);
|
|
570
226
|
});
|
|
571
227
|
});
|
|
572
228
|
|
|
573
229
|
describe("MailboxManagementService.failRename", () => {
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
]);
|
|
579
|
-
const service = new MailboxManagementService(repo);
|
|
580
|
-
|
|
581
|
-
await service.failRename(
|
|
582
|
-
"acc-1",
|
|
583
|
-
"mbx-parent",
|
|
230
|
+
const fail = () =>
|
|
231
|
+
management().failRename(
|
|
232
|
+
account.accountId,
|
|
233
|
+
workId as string,
|
|
584
234
|
"Work",
|
|
585
235
|
"Projects",
|
|
586
236
|
"Permission denied",
|
|
587
237
|
);
|
|
588
238
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
239
|
+
it("marks every row this rename recorded failed, keeping path and target", async () => {
|
|
240
|
+
const parent = await work();
|
|
241
|
+
const child = await folder("Work/2026", "Projects/2026");
|
|
242
|
+
const other = await folder("Other", "Projects/Old");
|
|
243
|
+
|
|
244
|
+
await fail();
|
|
245
|
+
|
|
246
|
+
await assertAt(parent, "Work", MailboxSyncStatus.failed, "Projects");
|
|
247
|
+
await assertAt(
|
|
248
|
+
child,
|
|
249
|
+
"Work/2026",
|
|
250
|
+
MailboxSyncStatus.failed,
|
|
251
|
+
"Projects/2026",
|
|
252
|
+
);
|
|
253
|
+
await assertAt(other, "Other", MailboxSyncStatus.pending, "Projects/Old");
|
|
601
254
|
});
|
|
602
255
|
|
|
603
|
-
it("raises a NotFoundError when the folder row
|
|
604
|
-
|
|
605
|
-
const service = new MailboxManagementService(repo);
|
|
256
|
+
it("raises a NotFoundError when the folder row is gone", async () => {
|
|
257
|
+
workId = "mbx-gone";
|
|
606
258
|
|
|
607
259
|
await assert.rejects(
|
|
608
|
-
|
|
609
|
-
"acc-1",
|
|
610
|
-
"mbx-parent",
|
|
611
|
-
"Work",
|
|
612
|
-
"Projects",
|
|
613
|
-
"Permission denied",
|
|
614
|
-
),
|
|
260
|
+
fail(),
|
|
615
261
|
(error: unknown) => (error as Error).name === "NotFoundError",
|
|
616
262
|
);
|
|
617
263
|
});
|
|
618
264
|
|
|
619
|
-
it("records nothing, quietly,
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
await assert.doesNotReject(
|
|
627
|
-
service.failRename(
|
|
628
|
-
"acc-1",
|
|
629
|
-
"mbx-parent",
|
|
630
|
-
"Work",
|
|
631
|
-
"Projects",
|
|
632
|
-
"Permission denied",
|
|
633
|
-
),
|
|
634
|
-
);
|
|
265
|
+
it("records nothing, quietly, for a row that carries no intent", async () => {
|
|
266
|
+
const parent = await work(null);
|
|
267
|
+
|
|
268
|
+
await fail();
|
|
269
|
+
|
|
270
|
+
await assertAt(parent, "Work", MailboxSyncStatus.synced);
|
|
635
271
|
});
|
|
636
272
|
|
|
637
|
-
it("
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
);
|
|
273
|
+
it("fails a rename the connection never issued as a refusal", async () => {
|
|
274
|
+
const parent = await work();
|
|
275
|
+
const connection = imap.connection();
|
|
276
|
+
connection.renameMailbox = async () => {
|
|
277
|
+
throw new Error(
|
|
278
|
+
'Rename of "Work" was not issued: the IMAP connection is not ready',
|
|
279
|
+
);
|
|
280
|
+
};
|
|
646
281
|
|
|
647
|
-
await assert.rejects(
|
|
648
|
-
|
|
649
|
-
refusing(notReady),
|
|
650
|
-
),
|
|
651
|
-
/not ready/,
|
|
652
|
-
);
|
|
282
|
+
await assert.rejects(renameWork(connection), /not ready/);
|
|
283
|
+
await fail();
|
|
653
284
|
|
|
654
|
-
await
|
|
655
|
-
"acc-1",
|
|
656
|
-
"mbx-parent",
|
|
657
|
-
"Work",
|
|
658
|
-
"Projects",
|
|
659
|
-
"Permission denied",
|
|
660
|
-
);
|
|
661
|
-
assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.failed);
|
|
662
|
-
assert.equal(rowOf("mbx-parent")?.fullPath, "Work");
|
|
285
|
+
await assertAt(parent, "Work", MailboxSyncStatus.failed, "Projects");
|
|
663
286
|
});
|
|
664
287
|
});
|