@remit/mailbox-service 0.0.39 → 0.0.41
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
CHANGED
|
@@ -69,7 +69,7 @@ const MAILBOXES = {
|
|
|
69
69
|
};
|
|
70
70
|
|
|
71
71
|
const buildHarness = (
|
|
72
|
-
message: { messageId: string; mailboxId: string },
|
|
72
|
+
message: { messageId: string; mailboxId: string; movedByRemit?: boolean },
|
|
73
73
|
flags: AddressItem["flags"],
|
|
74
74
|
): Harness => {
|
|
75
75
|
const moves: MoveCall[] = [];
|
|
@@ -79,11 +79,7 @@ const buildHarness = (
|
|
|
79
79
|
}> = [];
|
|
80
80
|
|
|
81
81
|
const messageService = {
|
|
82
|
-
get: async () => ({
|
|
83
|
-
messageId: message.messageId,
|
|
84
|
-
mailboxId: message.mailboxId,
|
|
85
|
-
uid: 1,
|
|
86
|
-
}),
|
|
82
|
+
get: async () => ({ ...message, uid: 1 }),
|
|
87
83
|
update: async (messageId: string, input: UpdateMessageInput) => {
|
|
88
84
|
messageUpdates.push({ messageId, input });
|
|
89
85
|
},
|
|
@@ -300,3 +296,56 @@ describe("Address.flags.autoArchive drives placement (issue #300)", () => {
|
|
|
300
296
|
]);
|
|
301
297
|
});
|
|
302
298
|
});
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Issue #398: not every `leave` verdict means "nothing confident to say".
|
|
302
|
+
* `already-moved-by-remit` is the guard against re-deciding a settled
|
|
303
|
+
* placement, and mail sitting in Junk gets a `leave` because the demote branch
|
|
304
|
+
* only fires outside Junk. Auto-archive is a filing preference relative to the
|
|
305
|
+
* Inbox and must yield to both, so these drive `BodySyncService` end-to-end and
|
|
306
|
+
* assert on the absence of a move.
|
|
307
|
+
*/
|
|
308
|
+
describe("autoArchive respects a leave verdict that decided something (issue #398)", () => {
|
|
309
|
+
it("leaves a blocked autoArchive sender's message in Junk", async () => {
|
|
310
|
+
const harness = buildHarness(
|
|
311
|
+
{ messageId: "m-1", mailboxId: MAILBOXES.junk.mailboxId },
|
|
312
|
+
{
|
|
313
|
+
blocked: { value: true, setAt: 1_000 },
|
|
314
|
+
autoArchive: { value: true, setAt: 1_000 },
|
|
315
|
+
},
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
await readBody(harness.service, "Junk");
|
|
319
|
+
|
|
320
|
+
assert.deepEqual(harness.moves, []);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("leaves an autoArchive sender's provider-junked message in Junk", async () => {
|
|
324
|
+
const harness = buildHarness(
|
|
325
|
+
{ messageId: "m-1", mailboxId: MAILBOXES.junk.mailboxId },
|
|
326
|
+
{ autoArchive: { value: true, setAt: 1_000 } },
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
// Provider-spam + dmarc-pass from an untrusted sender is an unsure
|
|
330
|
+
// `leave`: too weak to rescue, and auto-archive is not a second chance
|
|
331
|
+
// at leaving Junk.
|
|
332
|
+
await readBody(harness.service, "Junk", SPAM_FLAGGED_DMARC_PASS_EML);
|
|
333
|
+
|
|
334
|
+
assert.deepEqual(harness.moves, []);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("leaves a message Remit already placed alone on a re-entrant pass", async () => {
|
|
338
|
+
const harness = buildHarness(
|
|
339
|
+
{
|
|
340
|
+
messageId: "m-1",
|
|
341
|
+
mailboxId: MAILBOXES.inbox.mailboxId,
|
|
342
|
+
movedByRemit: true,
|
|
343
|
+
},
|
|
344
|
+
{ autoArchive: { value: true, setAt: 1_000 } },
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
await readBody(harness.service);
|
|
348
|
+
|
|
349
|
+
assert.deepEqual(harness.moves, []);
|
|
350
|
+
});
|
|
351
|
+
});
|
package/src/body-sync.ts
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
import {
|
|
49
49
|
classifyPlacement,
|
|
50
50
|
type FolderPlacement,
|
|
51
|
+
type PlacementVerdict,
|
|
51
52
|
resolveBlockedVsTrust,
|
|
52
53
|
} from "./heuristics/classifyPlacement.js";
|
|
53
54
|
import type { PlacementMoveService } from "./placement-move.js";
|
|
@@ -199,6 +200,26 @@ const hasDecidedCategory = (
|
|
|
199
200
|
const hasDecidedPlacement = (placementDecidedAt: number | undefined): boolean =>
|
|
200
201
|
placementDecidedAt !== undefined;
|
|
201
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Issue #398: `flags.autoArchive` is a filing preference relative to the Inbox.
|
|
205
|
+
* A `leave` verdict reaches it for two unrelated reasons and only one of them
|
|
206
|
+
* means "nothing confident to say":
|
|
207
|
+
*
|
|
208
|
+
* - `already-moved-by-remit` is {@link classifyPlacement}'s guard against
|
|
209
|
+
* re-deciding a settled placement, and auto-archive is a re-decision. The
|
|
210
|
+
* re-entrant paths `hasDecidedPlacement` names reach it on messages Remit
|
|
211
|
+
* itself placed, or that a user moved back afterwards.
|
|
212
|
+
* - A message already in Junk gets an unsure `leave` whatever the sender's
|
|
213
|
+
* `blocked` flag says, since the demote branch only fires outside Junk.
|
|
214
|
+
* Moving mail out of Junk is a rescue, which this file reserves for a
|
|
215
|
+
* confident signal plus sender trust.
|
|
216
|
+
*/
|
|
217
|
+
const autoArchiveApplies = (
|
|
218
|
+
verdict: PlacementVerdict,
|
|
219
|
+
placement: FolderPlacement,
|
|
220
|
+
): boolean =>
|
|
221
|
+
placement !== "junk" && !verdict.reasons.includes("already-moved-by-remit");
|
|
222
|
+
|
|
202
223
|
export const toParsedBody = (parsed: ParsedMail): ParsedBody => ({
|
|
203
224
|
text: parsed.text ?? null,
|
|
204
225
|
html: typeof parsed.html === "string" ? parsed.html : null,
|
|
@@ -1459,12 +1480,14 @@ export class BodySyncService {
|
|
|
1459
1480
|
// junk/inbox verdict computed above. Still marked decided (issue #383):
|
|
1460
1481
|
// "leave" is itself a verdict, not "not yet evaluated".
|
|
1461
1482
|
if (verdict.action === "leave") {
|
|
1462
|
-
const outcome =
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1483
|
+
const outcome = autoArchiveApplies(verdict, placement)
|
|
1484
|
+
? await this.resolveAutoArchive(
|
|
1485
|
+
mailboxSpecialUseService,
|
|
1486
|
+
message,
|
|
1487
|
+
accountId,
|
|
1488
|
+
signals.autoArchive,
|
|
1489
|
+
)
|
|
1490
|
+
: {};
|
|
1468
1491
|
return { ...outcome, placementDecidedAt: Date.now() };
|
|
1469
1492
|
}
|
|
1470
1493
|
|
|
@@ -1526,7 +1549,8 @@ export class BodySyncService {
|
|
|
1526
1549
|
* straight to Archive, skipping Inbox. Only reached from
|
|
1527
1550
|
* {@link computePlacement} when {@link classifyPlacement} had no confident
|
|
1528
1551
|
* junk/inbox verdict of its own — `blocked`/DKIM/DMARC always take priority
|
|
1529
|
-
* over this filing preference
|
|
1552
|
+
* over this filing preference — and only for the `leave` verdicts
|
|
1553
|
+
* {@link autoArchiveApplies} admits.
|
|
1530
1554
|
*
|
|
1531
1555
|
* No {@link MessagePlacementVerdict} is recorded: `PlacementAction` (the
|
|
1532
1556
|
* audit enum) has only `MoveToInbox`/`MoveToJunk` — issue #300 is scoped to
|
|
@@ -99,7 +99,7 @@ const boxStatus = (path: string): ImapBoxStatus => ({
|
|
|
99
99
|
const recordingRepo = () => {
|
|
100
100
|
const updates: Array<{ mailboxId: string; patch: Record<string, unknown> }> =
|
|
101
101
|
[];
|
|
102
|
-
const repo = {
|
|
102
|
+
const repo: Pick<IMailboxRepository, "update" | "findByPathPrefix"> = {
|
|
103
103
|
update: async (
|
|
104
104
|
_accountId: string,
|
|
105
105
|
mailboxId: string,
|
|
@@ -108,8 +108,9 @@ const recordingRepo = () => {
|
|
|
108
108
|
updates.push({ mailboxId, patch });
|
|
109
109
|
return {} as never;
|
|
110
110
|
},
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
findByPathPrefix: async () => [],
|
|
112
|
+
};
|
|
113
|
+
return { repo: repo as IMailboxRepository, updates };
|
|
113
114
|
};
|
|
114
115
|
|
|
115
116
|
const stubConnection = (
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { IMailboxRepository } from "@remit/data-ports";
|
|
2
2
|
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
3
|
+
import { isNotFoundError } from "./mailbox-presence.js";
|
|
3
4
|
import type { IImapConnection } from "./types.js";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -220,9 +221,66 @@ export class MailboxManagementService {
|
|
|
220
221
|
syncStatus: MailboxSyncStatus.synced,
|
|
221
222
|
});
|
|
222
223
|
|
|
224
|
+
// The server rename is done and the row records it. The settle is repair
|
|
225
|
+
// work on top of that, so nothing it hits — the listing included — may reach
|
|
226
|
+
// the caller's failure path, which rolls the row back to a path the server
|
|
227
|
+
// no longer has and hands it to the reconcile sweep to reap.
|
|
228
|
+
await this.settleRenamedSubtree(accountId, newPath, connection).catch(
|
|
229
|
+
(error: unknown) => {
|
|
230
|
+
this.log.error(
|
|
231
|
+
{ mailboxId, newPath, error },
|
|
232
|
+
"Renamed subtree left pending",
|
|
233
|
+
);
|
|
234
|
+
},
|
|
235
|
+
);
|
|
236
|
+
|
|
223
237
|
return { success: true };
|
|
224
238
|
};
|
|
225
239
|
|
|
240
|
+
/**
|
|
241
|
+
* IMAP RENAME moves the whole subtree in one command, so the descendants the
|
|
242
|
+
* local rename marked pending are on the server the moment the parent's is.
|
|
243
|
+
* Nothing else settles them: the reconcile sweep treats pending as in-flight
|
|
244
|
+
* and leaves it alone, and a descendant left pending is read as off-server, so
|
|
245
|
+
* its sync events are terminally acked and its mail never arrives.
|
|
246
|
+
*
|
|
247
|
+
* A descendant is settled only where the server's own listing holds its path.
|
|
248
|
+
* A local path the server has not materialized is still in flight behind
|
|
249
|
+
* another queued operation, and stripping its pending marker would expose the
|
|
250
|
+
* row to the reconcile sweep.
|
|
251
|
+
*/
|
|
252
|
+
private settleRenamedSubtree = async (
|
|
253
|
+
accountId: string,
|
|
254
|
+
newPath: string,
|
|
255
|
+
connection: IImapConnection,
|
|
256
|
+
): Promise<void> => {
|
|
257
|
+
const listed = await connection.listMailboxes();
|
|
258
|
+
const onServer = new Set(listed.map((mailbox) => mailbox.fullPath));
|
|
259
|
+
const delimiter =
|
|
260
|
+
listed.find((mailbox) => mailbox.fullPath === newPath)?.delimiter ??
|
|
261
|
+
listed[0]?.delimiter ??
|
|
262
|
+
"/";
|
|
263
|
+
|
|
264
|
+
const descendants = await this.mailboxService.findByPathPrefix(
|
|
265
|
+
accountId,
|
|
266
|
+
newPath,
|
|
267
|
+
delimiter,
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
for (const descendant of descendants) {
|
|
271
|
+
if (descendant.syncStatus !== MailboxSyncStatus.pending) continue;
|
|
272
|
+
if (!onServer.has(descendant.fullPath)) continue;
|
|
273
|
+
await this.mailboxService
|
|
274
|
+
.update(accountId, descendant.mailboxId, {
|
|
275
|
+
syncStatus: MailboxSyncStatus.synced,
|
|
276
|
+
})
|
|
277
|
+
.catch((error: unknown) => {
|
|
278
|
+
if (isNotFoundError(error)) return;
|
|
279
|
+
throw error;
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
|
|
226
284
|
/**
|
|
227
285
|
* Sync a DELETE operation to IMAP.
|
|
228
286
|
* Called by worker after dequeuing MAILBOX_DELETE event.
|
package/src/mailbox-presence.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { IMailboxRepository, MailboxItem } from "@remit/data-ports";
|
|
2
2
|
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
3
3
|
|
|
4
|
-
const isNotFoundError = (error: unknown): boolean =>
|
|
4
|
+
export const isNotFoundError = (error: unknown): boolean =>
|
|
5
5
|
error instanceof Error && error.name === "NotFoundError";
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { IMailboxRepository, MailboxItem } from "@remit/data-ports";
|
|
4
|
+
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
5
|
+
import { MailboxManagementService } from "./mailbox-management.js";
|
|
6
|
+
import type { FlatMailboxInfo, IImapConnection } from "./types.js";
|
|
7
|
+
|
|
8
|
+
const row = (
|
|
9
|
+
mailboxId: string,
|
|
10
|
+
fullPath: string,
|
|
11
|
+
syncStatus: MailboxItem["syncStatus"],
|
|
12
|
+
): MailboxItem =>
|
|
13
|
+
({
|
|
14
|
+
mailboxId,
|
|
15
|
+
accountId: "acc-1",
|
|
16
|
+
fullPath,
|
|
17
|
+
hierarchyDelimiter: "/",
|
|
18
|
+
syncStatus,
|
|
19
|
+
}) as MailboxItem;
|
|
20
|
+
|
|
21
|
+
const store = (rows: MailboxItem[], vanishAfterSweep: string[] = []) => {
|
|
22
|
+
const byId = new Map(rows.map((r) => [r.mailboxId, { ...r }]));
|
|
23
|
+
|
|
24
|
+
const repo: Pick<IMailboxRepository, "update" | "findByPathPrefix"> = {
|
|
25
|
+
update: async (_accountId, mailboxId, patch) => {
|
|
26
|
+
const existing = byId.get(mailboxId);
|
|
27
|
+
if (!existing) {
|
|
28
|
+
throw Object.assign(new Error(`Mailbox not found: ${mailboxId}`), {
|
|
29
|
+
name: "NotFoundError",
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
const next = { ...existing, ...patch } as MailboxItem;
|
|
33
|
+
byId.set(mailboxId, next);
|
|
34
|
+
return next;
|
|
35
|
+
},
|
|
36
|
+
findByPathPrefix: async (_accountId, pathPrefix, delimiter = "/") => {
|
|
37
|
+
const prefix = `${pathPrefix}${delimiter}`;
|
|
38
|
+
const found = [...byId.values()].filter((r) =>
|
|
39
|
+
r.fullPath.startsWith(prefix),
|
|
40
|
+
);
|
|
41
|
+
for (const mailboxId of vanishAfterSweep) byId.delete(mailboxId);
|
|
42
|
+
return found;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
repo: repo as IMailboxRepository,
|
|
48
|
+
statusOf: (mailboxId: string) => byId.get(mailboxId)?.syncStatus,
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const connectionListing = (paths: string[]): IImapConnection =>
|
|
53
|
+
({
|
|
54
|
+
renameMailbox: async () => undefined,
|
|
55
|
+
listMailboxes: async (): Promise<FlatMailboxInfo[]> =>
|
|
56
|
+
paths.map((fullPath) => ({
|
|
57
|
+
fullPath,
|
|
58
|
+
name: fullPath.split("/").pop() ?? fullPath,
|
|
59
|
+
delimiter: "/",
|
|
60
|
+
attributes: [],
|
|
61
|
+
parentPath: null,
|
|
62
|
+
})),
|
|
63
|
+
}) as unknown as IImapConnection;
|
|
64
|
+
|
|
65
|
+
const connectionWithoutListing = (): IImapConnection =>
|
|
66
|
+
({
|
|
67
|
+
renameMailbox: async () => undefined,
|
|
68
|
+
listMailboxes: async () => {
|
|
69
|
+
throw new Error("IMAP connection lost");
|
|
70
|
+
},
|
|
71
|
+
}) as unknown as IImapConnection;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The local rename writes the new path across the whole subtree and marks every
|
|
75
|
+
* row pending, so a reconcile in that window cannot reap them (#290). These
|
|
76
|
+
* fixtures are that state at the moment the worker picks the event up: the
|
|
77
|
+
* parent and its descendants already carry their new paths.
|
|
78
|
+
*/
|
|
79
|
+
describe("MailboxManagementService.syncRename — subtree settle", () => {
|
|
80
|
+
it("settles every descendant the rename carried, not just the renamed row", async () => {
|
|
81
|
+
const { repo, statusOf } = store([
|
|
82
|
+
row("mbx-parent", "Projects", MailboxSyncStatus.pending),
|
|
83
|
+
row("mbx-child", "Projects/2026", MailboxSyncStatus.pending),
|
|
84
|
+
row("mbx-grandchild", "Projects/2026/Q1", MailboxSyncStatus.pending),
|
|
85
|
+
]);
|
|
86
|
+
const service = new MailboxManagementService(repo);
|
|
87
|
+
|
|
88
|
+
const result = await service.syncRename(
|
|
89
|
+
"acc-1",
|
|
90
|
+
"mbx-parent",
|
|
91
|
+
"Work",
|
|
92
|
+
"Projects",
|
|
93
|
+
async () =>
|
|
94
|
+
connectionListing(["Projects", "Projects/2026", "Projects/2026/Q1"]),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
assert.equal(result.success, true);
|
|
98
|
+
assert.equal(statusOf("mbx-parent"), MailboxSyncStatus.synced);
|
|
99
|
+
assert.equal(statusOf("mbx-child"), MailboxSyncStatus.synced);
|
|
100
|
+
assert.equal(statusOf("mbx-grandchild"), MailboxSyncStatus.synced);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("leaves a descendant behind a rename of its own pending", async () => {
|
|
104
|
+
// Two renames in one per-account FIFO group: `Projects` → `Work` moves the
|
|
105
|
+
// child row to `Work/2026`, then `Work/2026` → `Work/Archive` moves it
|
|
106
|
+
// again before the first RENAME is worked. When the first one lands the
|
|
107
|
+
// server holds `Work/2026`; `Work/Archive` exists only locally, and
|
|
108
|
+
// stripping its pending marker would let the reconcile sweep reap the row
|
|
109
|
+
// and rebuild it under a fresh mailboxId, dangling every filter bound to
|
|
110
|
+
// the old one.
|
|
111
|
+
const { repo, statusOf } = store([
|
|
112
|
+
row("mbx-parent", "Work", MailboxSyncStatus.pending),
|
|
113
|
+
row("mbx-child", "Work/Archive", MailboxSyncStatus.pending),
|
|
114
|
+
]);
|
|
115
|
+
const service = new MailboxManagementService(repo);
|
|
116
|
+
|
|
117
|
+
await service.syncRename(
|
|
118
|
+
"acc-1",
|
|
119
|
+
"mbx-parent",
|
|
120
|
+
"Projects",
|
|
121
|
+
"Work",
|
|
122
|
+
async () => connectionListing(["Work", "Work/2026"]),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
assert.equal(statusOf("mbx-parent"), MailboxSyncStatus.synced);
|
|
126
|
+
assert.equal(statusOf("mbx-child"), MailboxSyncStatus.pending);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("leaves a descendant the user has asked to delete on its way out", async () => {
|
|
130
|
+
// A delete requested after the local rename wrote the subtree pending: the
|
|
131
|
+
// folder is still on the server, and settling it would undo the request.
|
|
132
|
+
const { repo, statusOf } = store([
|
|
133
|
+
row("mbx-parent", "Projects", MailboxSyncStatus.pending),
|
|
134
|
+
row("mbx-deleting", "Projects/Old", MailboxSyncStatus.deleting),
|
|
135
|
+
row("mbx-child", "Projects/2026", MailboxSyncStatus.pending),
|
|
136
|
+
]);
|
|
137
|
+
const service = new MailboxManagementService(repo);
|
|
138
|
+
|
|
139
|
+
await service.syncRename(
|
|
140
|
+
"acc-1",
|
|
141
|
+
"mbx-parent",
|
|
142
|
+
"Work",
|
|
143
|
+
"Projects",
|
|
144
|
+
async () =>
|
|
145
|
+
connectionListing(["Projects", "Projects/Old", "Projects/2026"]),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
assert.equal(statusOf("mbx-deleting"), MailboxSyncStatus.deleting);
|
|
149
|
+
assert.equal(statusOf("mbx-child"), MailboxSyncStatus.synced);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("settles inside the renamed subtree without reaching a lookalike sibling", async () => {
|
|
153
|
+
const { repo, statusOf } = store([
|
|
154
|
+
row("mbx-parent", "Projects", MailboxSyncStatus.pending),
|
|
155
|
+
row("mbx-child", "Projects/2026", MailboxSyncStatus.pending),
|
|
156
|
+
row("mbx-lookalike", "Projectsy/2026", MailboxSyncStatus.pending),
|
|
157
|
+
]);
|
|
158
|
+
const service = new MailboxManagementService(repo);
|
|
159
|
+
|
|
160
|
+
await service.syncRename(
|
|
161
|
+
"acc-1",
|
|
162
|
+
"mbx-parent",
|
|
163
|
+
"Work",
|
|
164
|
+
"Projects",
|
|
165
|
+
async () =>
|
|
166
|
+
connectionListing(["Projects", "Projects/2026", "Projectsy/2026"]),
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
assert.equal(statusOf("mbx-child"), MailboxSyncStatus.synced);
|
|
170
|
+
assert.equal(statusOf("mbx-lookalike"), MailboxSyncStatus.pending);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("reports the rename as done when a descendant row vanishes mid-settle", async () => {
|
|
174
|
+
// The settle is repair work, not the mutation. A descendant deleted while
|
|
175
|
+
// it runs has nothing left to settle, and must not roll a completed server
|
|
176
|
+
// rename back to failed — the handler's catch treats any throw from here
|
|
177
|
+
// as an IMAP failure and restores the old path.
|
|
178
|
+
const { repo, statusOf } = store(
|
|
179
|
+
[
|
|
180
|
+
row("mbx-parent", "Projects", MailboxSyncStatus.pending),
|
|
181
|
+
row("mbx-gone", "Projects/Gone", MailboxSyncStatus.pending),
|
|
182
|
+
row("mbx-child", "Projects/2026", MailboxSyncStatus.pending),
|
|
183
|
+
],
|
|
184
|
+
["mbx-gone"],
|
|
185
|
+
);
|
|
186
|
+
const service = new MailboxManagementService(repo);
|
|
187
|
+
|
|
188
|
+
const result = await service.syncRename(
|
|
189
|
+
"acc-1",
|
|
190
|
+
"mbx-parent",
|
|
191
|
+
"Work",
|
|
192
|
+
"Projects",
|
|
193
|
+
async () =>
|
|
194
|
+
connectionListing(["Projects", "Projects/Gone", "Projects/2026"]),
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
assert.equal(result.success, true);
|
|
198
|
+
assert.equal(statusOf("mbx-child"), MailboxSyncStatus.synced);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("reports the rename as done when the listing it settles against fails", async () => {
|
|
202
|
+
// The listing runs after the server rename has landed. Letting it fail the
|
|
203
|
+
// rename would send the caller down its IMAP-failure path, restoring a path
|
|
204
|
+
// the server no longer has and marking the row failed — which drops the
|
|
205
|
+
// pending marker that keeps the reconcile sweep from reaping it. A
|
|
206
|
+
// descendant left pending is the safe outcome.
|
|
207
|
+
const { repo, statusOf } = store([
|
|
208
|
+
row("mbx-parent", "Projects", MailboxSyncStatus.pending),
|
|
209
|
+
row("mbx-child", "Projects/2026", MailboxSyncStatus.pending),
|
|
210
|
+
]);
|
|
211
|
+
const service = new MailboxManagementService(repo);
|
|
212
|
+
|
|
213
|
+
const result = await service.syncRename(
|
|
214
|
+
"acc-1",
|
|
215
|
+
"mbx-parent",
|
|
216
|
+
"Work",
|
|
217
|
+
"Projects",
|
|
218
|
+
async () => connectionWithoutListing(),
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
assert.equal(result.success, true);
|
|
222
|
+
assert.equal(statusOf("mbx-parent"), MailboxSyncStatus.synced);
|
|
223
|
+
assert.equal(statusOf("mbx-child"), MailboxSyncStatus.pending);
|
|
224
|
+
});
|
|
225
|
+
});
|