@remit/mailbox-service 0.0.96 → 0.0.97
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/imapflow-connection.ts +9 -0
- package/src/index.ts +6 -0
- package/src/mailbox-intent.ts +109 -0
- package/src/mailbox-management.test.ts +28 -2
- package/src/mailbox-management.ts +453 -69
- package/src/mailbox-presence.ts +6 -5
- package/src/mailbox-queue.ts +133 -13
- package/src/mailbox-rename-intent.test.ts +271 -25
- package/src/mailbox-rename-subtree.test.ts +494 -106
- package/src/mailbox-sync.ts +1 -27
- package/src/mailbox-upstream.ts +83 -0
package/package.json
CHANGED
|
@@ -1053,6 +1053,15 @@ export class ImapFlowConnection {
|
|
|
1053
1053
|
}
|
|
1054
1054
|
|
|
1055
1055
|
const result = await client.mailboxRename(oldPath, newPath);
|
|
1056
|
+
// `mailboxRename` resolves `undefined` when the connection is not
|
|
1057
|
+
// AUTHENTICATED or SELECTED — RENAME was never issued. Dereferencing it
|
|
1058
|
+
// raised a `TypeError` where the settle now reads the resolved path, and a
|
|
1059
|
+
// `TypeError` is not an outcome the rename settle can classify.
|
|
1060
|
+
if (!result) {
|
|
1061
|
+
throw new Error(
|
|
1062
|
+
`Rename of "${oldPath}" was not issued: the IMAP connection is not ready`,
|
|
1063
|
+
);
|
|
1064
|
+
}
|
|
1056
1065
|
return {
|
|
1057
1066
|
path: result.path,
|
|
1058
1067
|
newPath: result.newPath,
|
package/src/index.ts
CHANGED
|
@@ -193,6 +193,12 @@ export {
|
|
|
193
193
|
MailboxSyncService,
|
|
194
194
|
type SyncAccountInfo,
|
|
195
195
|
} from "./mailbox-sync.js";
|
|
196
|
+
export {
|
|
197
|
+
FolderGoneUpstreamError,
|
|
198
|
+
FolderRenameSettleError,
|
|
199
|
+
isMailboxAbsentUpstream,
|
|
200
|
+
isMailboxPresentUpstream,
|
|
201
|
+
} from "./mailbox-upstream.js";
|
|
196
202
|
export {
|
|
197
203
|
denormalizeMessageCategory,
|
|
198
204
|
type MessageCategoryDenormalizeDeps,
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { IMailboxRepository, MailboxItem } from "@remit/data-ports";
|
|
2
|
+
import { ConflictError, NotFoundError } from "@remit/data-ports/errors";
|
|
3
|
+
import { rebaseMailboxPath } from "@remit/data-ports/mailbox-name";
|
|
4
|
+
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
5
|
+
import { isNotFoundError } from "./mailbox-presence.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The states a rename or a delete intent may be recorded from (T4, T7). Both
|
|
9
|
+
* failed variants are legal starting points — a retry re-records the same
|
|
10
|
+
* intent on the same row, so no duplicate folder can be created — and every
|
|
11
|
+
* other state means a mutation is already in flight on the folder.
|
|
12
|
+
*/
|
|
13
|
+
export const INTENT_RECORDABLE_FROM = [
|
|
14
|
+
MailboxSyncStatus.synced,
|
|
15
|
+
MailboxSyncStatus.failed,
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
const carriesIntent = (row: Pick<MailboxItem, "syncStatus">): boolean =>
|
|
19
|
+
(INTENT_RECORDABLE_FROM as readonly string[]).includes(row.syncStatus);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Whether a row was written by one particular rename intent
|
|
23
|
+
* (docs/architecture/folder-rename-and-delete.md D15).
|
|
24
|
+
*
|
|
25
|
+
* A recorded target that merely sits at or under the rename's target is not
|
|
26
|
+
* enough to identify one. Two folders can hold targets under the same path —
|
|
27
|
+
* `Work → Projects` in flight while `Other → Projects/Old` is recorded — and a
|
|
28
|
+
* settle that claimed both would stamp a row this rename never touched onto a
|
|
29
|
+
* path it never asked for, leaving that folder's own rename unrepresented and
|
|
30
|
+
* two rows claiming one path for the sweep to reap and re-insert.
|
|
31
|
+
*
|
|
32
|
+
* The identity is the pair: the row sits at or under the path the rename is
|
|
33
|
+
* leaving, **and** the target it recorded is exactly what this rename's own
|
|
34
|
+
* rebase would have written for it. Nothing but this intent writes that pair,
|
|
35
|
+
* because the intent is recorded over one subtree in one transaction (D6) and
|
|
36
|
+
* the transition is the only writer of either field (D3).
|
|
37
|
+
*/
|
|
38
|
+
export const recordedByRename = (
|
|
39
|
+
row: Pick<MailboxItem, "fullPath" | "pendingPath">,
|
|
40
|
+
oldPath: string,
|
|
41
|
+
newPath: string,
|
|
42
|
+
delimiter: string,
|
|
43
|
+
): boolean =>
|
|
44
|
+
row.pendingPath !== undefined &&
|
|
45
|
+
rebaseMailboxPath(row.fullPath, oldPath, newPath, delimiter) ===
|
|
46
|
+
row.pendingPath;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* What is already happening to a folder, as a clause naming the folder. The
|
|
50
|
+
* six states, not the four enum values: `pending` covers a create in flight and
|
|
51
|
+
* a rename in flight, and only `pendingPath` tells them apart.
|
|
52
|
+
*/
|
|
53
|
+
const inFlight = (row: MailboxItem): string => {
|
|
54
|
+
if (row.syncStatus === MailboxSyncStatus.deleting) {
|
|
55
|
+
return `A delete is already in progress for "${row.fullPath}"`;
|
|
56
|
+
}
|
|
57
|
+
if (row.pendingPath !== undefined) {
|
|
58
|
+
return `A rename is in progress for "${row.fullPath}"`;
|
|
59
|
+
}
|
|
60
|
+
return `"${row.fullPath}" is still being created`;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Say why a folder intent lost its compare-and-set (D3).
|
|
65
|
+
*
|
|
66
|
+
* Existence was established by the read the caller took before recording, so a
|
|
67
|
+
* lost predicate is a conflict rather than a 404 — but the row can have gone in
|
|
68
|
+
* between, and one primary-key read tells the two apart. For a subtree intent
|
|
69
|
+
* the blocker may be a descendant, and the message says so: a subtree cannot be
|
|
70
|
+
* half-renamed (D6), so a descendant mid-mutation refuses the whole rename and
|
|
71
|
+
* a user who was not thinking about it has to be told which folder it is.
|
|
72
|
+
*
|
|
73
|
+
* Always rejects.
|
|
74
|
+
*/
|
|
75
|
+
export const refuseContestedIntent = async (
|
|
76
|
+
mailboxService: Pick<IMailboxRepository, "get" | "findByPathPrefix">,
|
|
77
|
+
accountId: string,
|
|
78
|
+
mailboxId: string,
|
|
79
|
+
scope: "folder" | "subtree",
|
|
80
|
+
): Promise<never> => {
|
|
81
|
+
const root = await mailboxService
|
|
82
|
+
.get(accountId, mailboxId)
|
|
83
|
+
.catch((error: unknown) => {
|
|
84
|
+
if (isNotFoundError(error)) return undefined;
|
|
85
|
+
throw error;
|
|
86
|
+
});
|
|
87
|
+
if (!root) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
|
|
88
|
+
|
|
89
|
+
if (!carriesIntent(root)) throw new ConflictError(`${inFlight(root)}.`);
|
|
90
|
+
|
|
91
|
+
const descendants =
|
|
92
|
+
scope === "subtree"
|
|
93
|
+
? await mailboxService.findByPathPrefix(
|
|
94
|
+
accountId,
|
|
95
|
+
root.fullPath,
|
|
96
|
+
root.hierarchyDelimiter,
|
|
97
|
+
)
|
|
98
|
+
: [];
|
|
99
|
+
const blocking = descendants.find((row) => !carriesIntent(row));
|
|
100
|
+
if (blocking) {
|
|
101
|
+
throw new ConflictError(`${inFlight(blocking)}, inside this folder.`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Every row reads recordable now, so the row that blocked has already moved
|
|
105
|
+
// on. Nothing to name, and the same request succeeds on a retry.
|
|
106
|
+
throw new ConflictError(
|
|
107
|
+
`"${root.fullPath}" changed while this request was being recorded. Try again.`,
|
|
108
|
+
);
|
|
109
|
+
};
|
|
@@ -256,6 +256,27 @@ describe("MailboxManagementService.syncCreate — server path normalization", ()
|
|
|
256
256
|
assert.deepStrictEqual(settles[0].intent.set, {});
|
|
257
257
|
assert.strictEqual(settles[0].intent.to, MailboxSyncStatus.synced);
|
|
258
258
|
});
|
|
259
|
+
|
|
260
|
+
it("settles only a row with no rename target on it", async () => {
|
|
261
|
+
// The predicate the seventh state turns on (D3). A create redelivered
|
|
262
|
+
// after a lost acknowledgement, against a row a rename has since claimed,
|
|
263
|
+
// passes a `pending`-only guard, gets `{created: false}` — which #346
|
|
264
|
+
// correctly reads as success — and would settle `synced` with the rename
|
|
265
|
+
// target still on it, killing the rename silently.
|
|
266
|
+
const { repo, settles } = recordingRepo();
|
|
267
|
+
const { connection } = stubConnection("Archive", ["Archive"]);
|
|
268
|
+
const service = new MailboxManagementService(repo);
|
|
269
|
+
|
|
270
|
+
await service.syncCreate(
|
|
271
|
+
"acc-1",
|
|
272
|
+
"mbx-5",
|
|
273
|
+
"Archive",
|
|
274
|
+
async () => connection,
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
assert.strictEqual(settles[0].intent.wherePendingPath, null);
|
|
278
|
+
assert.deepStrictEqual(settles[0].intent.from, [MailboxSyncStatus.pending]);
|
|
279
|
+
});
|
|
259
280
|
});
|
|
260
281
|
|
|
261
282
|
describe("validateMailboxOperation", () => {
|
|
@@ -275,8 +296,13 @@ describe("validateMailboxOperation", () => {
|
|
|
275
296
|
assert.doesNotThrow(() => validateMailboxOperation("delete", "Archive"));
|
|
276
297
|
});
|
|
277
298
|
|
|
278
|
-
it("
|
|
279
|
-
|
|
299
|
+
it("throws when renaming INBOX", () => {
|
|
300
|
+
// The server would move INBOX's mail to the new name and leave an empty
|
|
301
|
+
// INBOX behind — a bulk move, not a path update (D5). The API refuses it;
|
|
302
|
+
// this is the worker's backstop.
|
|
303
|
+
assert.throws(() => validateMailboxOperation("rename", "INBOX"), {
|
|
304
|
+
message: "Cannot rename INBOX",
|
|
305
|
+
});
|
|
280
306
|
});
|
|
281
307
|
|
|
282
308
|
it("allows renaming other mailboxes", () => {
|