@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/src/mailbox-sync.ts
CHANGED
|
@@ -11,10 +11,8 @@ import type {
|
|
|
11
11
|
MailboxItem,
|
|
12
12
|
} from "@remit/data-ports";
|
|
13
13
|
import {
|
|
14
|
-
CANONICAL_ROLES,
|
|
15
|
-
type CanonicalMailboxRoleValue,
|
|
16
|
-
ROLE_NAME_HINTS,
|
|
17
14
|
ROLE_SPECIAL_USE,
|
|
15
|
+
roleForFolderName,
|
|
18
16
|
} from "@remit/data-ports/folder-role";
|
|
19
17
|
import {
|
|
20
18
|
MailboxCursorState,
|
|
@@ -59,30 +57,6 @@ const areSpecialUseSetsEqual = (
|
|
|
59
57
|
return true;
|
|
60
58
|
};
|
|
61
59
|
|
|
62
|
-
/**
|
|
63
|
-
* The role a folder's own leaf name is most conventionally for, read from the
|
|
64
|
-
* one hint table every special-folder lookup shares (`@remit/data-ports`,
|
|
65
|
-
* #837). A name that several roles list goes to the role that ranks it highest,
|
|
66
|
-
* so `All Mail` is an All folder rather than a lookalike of Archive.
|
|
67
|
-
*
|
|
68
|
-
* A second copy of these names lived here, and it is the one that could destroy
|
|
69
|
-
* something: it still called `Deleted` and `Bin` Trash names, which #843 dropped
|
|
70
|
-
* precisely because they are ordinary folders a user keeps mail in — and this
|
|
71
|
-
* lookup does not merely skip a folder, it deletes the row and everything the
|
|
72
|
-
* client can see in it.
|
|
73
|
-
*/
|
|
74
|
-
const roleForFolderName = (
|
|
75
|
-
name: string,
|
|
76
|
-
): CanonicalMailboxRoleValue | undefined => {
|
|
77
|
-
let best: { role: CanonicalMailboxRoleValue; rank: number } | undefined;
|
|
78
|
-
for (const role of CANONICAL_ROLES) {
|
|
79
|
-
const rank = ROLE_NAME_HINTS[role]?.indexOf(name) ?? -1;
|
|
80
|
-
if (rank < 0) continue;
|
|
81
|
-
if (!best || rank < best.rank) best = { role, rank };
|
|
82
|
-
}
|
|
83
|
-
return best?.role;
|
|
84
|
-
};
|
|
85
|
-
|
|
86
60
|
/**
|
|
87
61
|
* Account info needed for mailbox sync
|
|
88
62
|
*/
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
const stringField = (value: unknown, key: string): string => {
|
|
2
|
+
if (!(value instanceof Object)) return "";
|
|
3
|
+
const field = Reflect.get(value, key);
|
|
4
|
+
return typeof field === "string" ? field : "";
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Read a tagged-NO outcome out of an IMAP failure.
|
|
9
|
+
*
|
|
10
|
+
* The two outcomes below are each a folder operation finding the server already
|
|
11
|
+
* in the state it was asked for — the operation having happened, not failing.
|
|
12
|
+
* Reading them as failures marks the row `failed` and rethrows, and since folder
|
|
13
|
+
* management shares the account's per-account FIFO group with mailbox sync, that
|
|
14
|
+
* un-acked rethrow holds back every later sync for the account.
|
|
15
|
+
*
|
|
16
|
+
* Both places the server can say so are read. `message` carries it when the
|
|
17
|
+
* client raises the error itself; RFC 5530's response code and its text carry it
|
|
18
|
+
* when the server does. ImapFlow surfaces a tagged NO as a bare "Command failed"
|
|
19
|
+
* with the code on the error, which is why matching the message alone never
|
|
20
|
+
* caught a real Dovecot answer.
|
|
21
|
+
*/
|
|
22
|
+
const saidByServer = (error: Error): string =>
|
|
23
|
+
`${error.message} ${stringField(error, "responseText")}`;
|
|
24
|
+
|
|
25
|
+
/** The folder is not on the server: a delete has nothing left to do. */
|
|
26
|
+
export const isMailboxAbsentUpstream = (error: unknown): boolean => {
|
|
27
|
+
if (!(error instanceof Error)) return false;
|
|
28
|
+
if (stringField(error, "serverResponseCode") === "NONEXISTENT") return true;
|
|
29
|
+
return /not found|does ?n.?t exist/i.test(saidByServer(error));
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** The folder is already on the server: a create has nothing left to do. */
|
|
33
|
+
export const isMailboxPresentUpstream = (error: unknown): boolean => {
|
|
34
|
+
if (!(error instanceof Error)) return false;
|
|
35
|
+
if (stringField(error, "serverResponseCode") === "ALREADYEXISTS") return true;
|
|
36
|
+
return /already exists/i.test(saidByServer(error));
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Something after the server RENAME failed — the local settle, not the
|
|
41
|
+
* mutation. The rename landed, so this is never a refused rename: marking the
|
|
42
|
+
* rows `failed` would offer a retry of work the server has already done, and
|
|
43
|
+
* `fullPath` would go on naming a path the server no longer holds.
|
|
44
|
+
*
|
|
45
|
+
* It is rethrown rather than swallowed. The rows are still `pending` with their
|
|
46
|
+
* targets recorded, so the redelivery's guard passes and the settle runs again;
|
|
47
|
+
* swallowing it would ack the message and leave the subtree `pending` forever,
|
|
48
|
+
* skipped by message sync with no route out.
|
|
49
|
+
*/
|
|
50
|
+
/**
|
|
51
|
+
* The folder a rename was to move is confirmed gone: the server refused the
|
|
52
|
+
* RENAME as non-existent, and its own listing holds neither the path being left
|
|
53
|
+
* nor the path being aimed at.
|
|
54
|
+
*
|
|
55
|
+
* It is a distinct kind because it is the only answer that may remove a folder
|
|
56
|
+
* and its mail. Re-reading the server's response code at the call site would
|
|
57
|
+
* make every unclassifiable `NONEXISTENT` — a listing that could not be read, a
|
|
58
|
+
* path the comparison normalized wrongly — destroy the user's mail; here the
|
|
59
|
+
* decision is made once, where the evidence is.
|
|
60
|
+
*/
|
|
61
|
+
export class FolderGoneUpstreamError extends Error {
|
|
62
|
+
name = "FolderGoneUpstreamError";
|
|
63
|
+
readonly cause: unknown;
|
|
64
|
+
|
|
65
|
+
constructor(mailboxId: string, oldPath: string, cause: unknown) {
|
|
66
|
+
super(
|
|
67
|
+
`Folder ${mailboxId} is gone from the mail server: neither "${oldPath}" nor its rename target is listed`,
|
|
68
|
+
);
|
|
69
|
+
this.cause = cause;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class FolderRenameSettleError extends Error {
|
|
74
|
+
name = "FolderRenameSettleError";
|
|
75
|
+
readonly cause: unknown;
|
|
76
|
+
|
|
77
|
+
constructor(mailboxId: string, confirmedPath: string, cause: unknown) {
|
|
78
|
+
super(
|
|
79
|
+
`Folder ${mailboxId} was renamed to "${confirmedPath}" but the local settle did not finish`,
|
|
80
|
+
);
|
|
81
|
+
this.cause = cause;
|
|
82
|
+
}
|
|
83
|
+
}
|