@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.
@@ -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
+ }