@remit/mailbox-service 0.0.112 → 0.0.113

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.
Files changed (270) hide show
  1. package/dist/account-credentials.d.ts +83 -0
  2. package/dist/account-credentials.js +104 -0
  3. package/dist/attribute-mapper.d.ts +36 -0
  4. package/dist/attribute-mapper.js +119 -0
  5. package/dist/body-parse.d.ts +28 -0
  6. package/{src/body-parse.ts → dist/body-parse.js} +18 -26
  7. package/dist/body-part-mapper.d.ts +92 -0
  8. package/dist/body-part-mapper.js +301 -0
  9. package/dist/body-sync-queue.d.ts +59 -0
  10. package/dist/body-sync-queue.js +56 -0
  11. package/dist/body-sync-terminal.d.ts +58 -0
  12. package/dist/body-sync-terminal.js +95 -0
  13. package/dist/body-sync.d.ts +351 -0
  14. package/dist/body-sync.js +1204 -0
  15. package/dist/calendar-parts.d.ts +23 -0
  16. package/dist/calendar-parts.js +18 -0
  17. package/dist/classification-backfill.d.ts +91 -0
  18. package/dist/classification-backfill.js +180 -0
  19. package/dist/classify-message.d.ts +31 -0
  20. package/dist/classify-message.js +89 -0
  21. package/dist/connection-factory.d.ts +54 -0
  22. package/dist/connection-factory.js +54 -0
  23. package/dist/connection-test.d.ts +41 -0
  24. package/dist/connection-test.js +152 -0
  25. package/dist/external-move.d.ts +94 -0
  26. package/dist/external-move.js +173 -0
  27. package/dist/filter-operations.d.ts +42 -0
  28. package/dist/filter-operations.js +211 -0
  29. package/dist/filters/filter-config.d.ts +14 -0
  30. package/dist/filters/filter-config.js +10 -0
  31. package/{src/filters/list-id.ts → dist/filters/list-id.d.ts} +2 -16
  32. package/dist/filters/list-id.js +26 -0
  33. package/dist/filters/match.d.ts +69 -0
  34. package/dist/filters/match.js +165 -0
  35. package/dist/filters/pipeline.d.ts +64 -0
  36. package/dist/filters/pipeline.js +100 -0
  37. package/{src/flag-push-terminal.ts → dist/flag-push-terminal.d.ts} +16 -72
  38. package/dist/flag-push-terminal.js +63 -0
  39. package/dist/flag-push.d.ts +69 -0
  40. package/dist/flag-push.js +111 -0
  41. package/dist/flag-queue.d.ts +175 -0
  42. package/dist/flag-queue.js +291 -0
  43. package/dist/folder-operations.d.ts +39 -0
  44. package/dist/folder-operations.js +112 -0
  45. package/dist/folder-placement.d.ts +13 -0
  46. package/dist/folder-placement.js +23 -0
  47. package/dist/heuristics/authenticationResults.d.ts +10 -0
  48. package/dist/heuristics/authenticationResults.js +187 -0
  49. package/dist/heuristics/classifyByHeaders.d.ts +82 -0
  50. package/dist/heuristics/classifyByHeaders.js +365 -0
  51. package/dist/heuristics/classifyPlacement.d.ts +67 -0
  52. package/dist/heuristics/classifyPlacement.js +153 -0
  53. package/dist/heuristics/machineSenders.d.ts +21 -0
  54. package/{src/heuristics/machineSenders.ts → dist/heuristics/machineSenders.js} +34 -40
  55. package/dist/heuristics/senderMismatch.d.ts +81 -0
  56. package/dist/heuristics/senderMismatch.js +192 -0
  57. package/dist/heuristics/socialDomains.d.ts +14 -0
  58. package/{src/heuristics/socialDomains.ts → dist/heuristics/socialDomains.js} +9 -9
  59. package/dist/heuristics/transactionalDomains.d.ts +18 -0
  60. package/{src/heuristics/transactionalDomains.ts → dist/heuristics/transactionalDomains.js} +8 -8
  61. package/dist/imapflow-connection.d.ts +377 -0
  62. package/dist/imapflow-connection.js +1147 -0
  63. package/dist/index.d.ts +63 -0
  64. package/dist/index.js +65 -0
  65. package/dist/list-id-backfill.d.ts +83 -0
  66. package/dist/list-id-backfill.js +132 -0
  67. package/dist/mailbox-cursor-rebuild.d.ts +74 -0
  68. package/dist/mailbox-cursor-rebuild.js +71 -0
  69. package/{src/mailbox-cursor.ts → dist/mailbox-cursor.d.ts} +20 -98
  70. package/dist/mailbox-cursor.js +102 -0
  71. package/dist/mailbox-intent.d.ts +41 -0
  72. package/{src/mailbox-intent.ts → dist/mailbox-intent.js} +38 -64
  73. package/dist/mailbox-management.d.ts +240 -0
  74. package/dist/mailbox-management.js +576 -0
  75. package/dist/mailbox-presence.d.ts +52 -0
  76. package/{src/mailbox-presence.ts → dist/mailbox-presence.js} +19 -30
  77. package/dist/mailbox-queue.d.ts +126 -0
  78. package/dist/mailbox-queue.js +214 -0
  79. package/dist/mailbox-sync.d.ts +93 -0
  80. package/dist/mailbox-sync.js +490 -0
  81. package/dist/mailbox-upstream.d.ts +38 -0
  82. package/{src/mailbox-upstream.ts → dist/mailbox-upstream.js} +37 -47
  83. package/dist/message-category.d.ts +51 -0
  84. package/{src/message-category.ts → dist/message-category.js} +28 -71
  85. package/dist/message-move.d.ts +285 -0
  86. package/dist/message-move.js +858 -0
  87. package/dist/message-parser.d.ts +14 -0
  88. package/dist/message-parser.js +17 -0
  89. package/{src/message-presence.ts → dist/message-presence.d.ts} +1 -13
  90. package/dist/message-presence.js +29 -0
  91. package/dist/message-read.d.ts +51 -0
  92. package/dist/message-read.js +31 -0
  93. package/dist/message-sync.d.ts +345 -0
  94. package/dist/message-sync.js +1358 -0
  95. package/dist/mime-walker.d.ts +102 -0
  96. package/dist/mime-walker.js +198 -0
  97. package/dist/organize-job.d.ts +20 -0
  98. package/dist/organize-job.js +37 -0
  99. package/dist/outbox-attachment-content.d.ts +37 -0
  100. package/dist/outbox-attachment-content.js +50 -0
  101. package/dist/outbox-attachment-filename.d.ts +19 -0
  102. package/dist/outbox-attachment-filename.js +64 -0
  103. package/dist/outbox-attachment.d.ts +148 -0
  104. package/dist/outbox-attachment.js +273 -0
  105. package/dist/outbox-queue.d.ts +73 -0
  106. package/dist/outbox-queue.js +284 -0
  107. package/{src/outbox-status.ts → dist/outbox-status.d.ts} +1 -6
  108. package/dist/outbox-status.js +18 -0
  109. package/dist/ownership.d.ts +24 -0
  110. package/dist/ownership.js +83 -0
  111. package/dist/placement-move-terminal.d.ts +67 -0
  112. package/dist/placement-move-terminal.js +88 -0
  113. package/dist/placement-move.d.ts +83 -0
  114. package/dist/placement-move.js +216 -0
  115. package/{src/placement-settled.ts → dist/placement-settled.d.ts} +13 -68
  116. package/dist/placement-settled.js +75 -0
  117. package/dist/quarantine.d.ts +95 -0
  118. package/dist/quarantine.js +131 -0
  119. package/dist/refusal.d.ts +31 -0
  120. package/dist/refusal.js +3 -0
  121. package/{src/restore-source-placement.ts → dist/restore-source-placement.d.ts} +29 -97
  122. package/dist/restore-source-placement.js +79 -0
  123. package/dist/search-text-backfill.d.ts +15 -0
  124. package/dist/search-text-backfill.js +33 -0
  125. package/dist/snippet.d.ts +61 -0
  126. package/dist/snippet.js +129 -0
  127. package/dist/spam-report.d.ts +86 -0
  128. package/dist/spam-report.js +241 -0
  129. package/dist/sync-warning.d.ts +21 -0
  130. package/dist/sync-warning.js +26 -0
  131. package/dist/sync-watermarks.d.ts +138 -0
  132. package/dist/sync-watermarks.js +205 -0
  133. package/dist/test-helpers/fake-imap.d.ts +151 -0
  134. package/dist/test-helpers/fake-imap.js +508 -0
  135. package/dist/test-helpers/folder-roles.d.ts +34 -0
  136. package/dist/test-helpers/folder-roles.js +47 -0
  137. package/dist/test-helpers/mime-tree-generator.d.ts +82 -0
  138. package/dist/test-helpers/mime-tree-generator.js +329 -0
  139. package/dist/test-helpers/test-store.d.ts +69 -0
  140. package/dist/test-helpers/test-store.js +193 -0
  141. package/dist/text/normalizer.d.ts +20 -0
  142. package/dist/text/normalizer.js +102 -0
  143. package/dist/thread-message-rows.d.ts +69 -0
  144. package/dist/thread-message-rows.js +40 -0
  145. package/dist/thread-root.d.ts +7 -0
  146. package/dist/thread-root.js +25 -0
  147. package/dist/types.d.ts +343 -0
  148. package/dist/types.js +19 -0
  149. package/package.json +86 -88
  150. package/src/account-credentials.ts +0 -210
  151. package/src/attribute-mapper.test.ts +0 -104
  152. package/src/attribute-mapper.ts +0 -148
  153. package/src/body-parse.test.ts +0 -55
  154. package/src/body-part-mapper.property.test.ts +0 -354
  155. package/src/body-part-mapper.ts +0 -410
  156. package/src/body-sync-queue.test.ts +0 -64
  157. package/src/body-sync-queue.ts +0 -131
  158. package/src/body-sync-terminal.test.ts +0 -148
  159. package/src/body-sync-terminal.ts +0 -174
  160. package/src/body-sync.test.ts +0 -925
  161. package/src/body-sync.ts +0 -1891
  162. package/src/calendar-parts.ts +0 -41
  163. package/src/classification-backfill.test.ts +0 -285
  164. package/src/classification-backfill.ts +0 -363
  165. package/src/classify-message.test.ts +0 -142
  166. package/src/classify-message.ts +0 -116
  167. package/src/connection-factory.ts +0 -102
  168. package/src/connection-test.ts +0 -208
  169. package/src/credentials.test.ts +0 -181
  170. package/src/external-move.test.ts +0 -262
  171. package/src/external-move.ts +0 -285
  172. package/src/filter-operations.test.ts +0 -319
  173. package/src/filter-operations.ts +0 -377
  174. package/src/filter-update.test.ts +0 -313
  175. package/src/filters/filter-config.test.ts +0 -87
  176. package/src/filters/filter-config.ts +0 -34
  177. package/src/filters/list-id.test.ts +0 -50
  178. package/src/filters/match.test.ts +0 -392
  179. package/src/filters/match.ts +0 -225
  180. package/src/filters/pipeline.test.ts +0 -199
  181. package/src/filters/pipeline.ts +0 -170
  182. package/src/flag-push.test.ts +0 -112
  183. package/src/flag-push.ts +0 -198
  184. package/src/flag-queue.test.ts +0 -197
  185. package/src/flag-queue.ts +0 -509
  186. package/src/folder-operations.test.ts +0 -273
  187. package/src/folder-operations.ts +0 -217
  188. package/src/folder-placement.ts +0 -56
  189. package/src/forwarded-message.test.ts +0 -122
  190. package/src/heuristics/authenticationResults.test.ts +0 -189
  191. package/src/heuristics/authenticationResults.ts +0 -212
  192. package/src/heuristics/classifyByHeaders.test.ts +0 -889
  193. package/src/heuristics/classifyByHeaders.ts +0 -467
  194. package/src/heuristics/classifyPlacement.test.ts +0 -366
  195. package/src/heuristics/classifyPlacement.ts +0 -211
  196. package/src/heuristics/classifyRealMail.test.ts +0 -242
  197. package/src/heuristics/machineSenders.test.ts +0 -101
  198. package/src/heuristics/senderMismatch.test.ts +0 -346
  199. package/src/heuristics/senderMismatch.ts +0 -259
  200. package/src/imapflow-connect-retry.test.ts +0 -97
  201. package/src/imapflow-connection.test.ts +0 -503
  202. package/src/imapflow-connection.ts +0 -1476
  203. package/src/index.ts +0 -397
  204. package/src/list-id-backfill.test.ts +0 -207
  205. package/src/list-id-backfill.ts +0 -274
  206. package/src/mailbox-cursor-rebuild.test.ts +0 -171
  207. package/src/mailbox-cursor-rebuild.ts +0 -146
  208. package/src/mailbox-cursor.test.ts +0 -141
  209. package/src/mailbox-delete-intent.test.ts +0 -119
  210. package/src/mailbox-management.test.ts +0 -243
  211. package/src/mailbox-management.ts +0 -838
  212. package/src/mailbox-presence.test.ts +0 -74
  213. package/src/mailbox-queue.ts +0 -397
  214. package/src/mailbox-rename-intent.test.ts +0 -220
  215. package/src/mailbox-rename-subtree.test.ts +0 -287
  216. package/src/mailbox-sync.test.ts +0 -286
  217. package/src/mailbox-sync.ts +0 -724
  218. package/src/message-move.test.ts +0 -726
  219. package/src/message-move.ts +0 -1518
  220. package/src/message-parser.ts +0 -26
  221. package/src/message-presence.test.ts +0 -50
  222. package/src/message-read.ts +0 -87
  223. package/src/message-save-retry.test.ts +0 -90
  224. package/src/message-sync-flags.test.ts +0 -167
  225. package/src/message-sync.test.ts +0 -762
  226. package/src/message-sync.ts +0 -2133
  227. package/src/mime-walker.test.ts +0 -311
  228. package/src/mime-walker.ts +0 -306
  229. package/src/organize-job.ts +0 -93
  230. package/src/outbox-attachment-content.test.ts +0 -153
  231. package/src/outbox-attachment-content.ts +0 -104
  232. package/src/outbox-attachment-filename.test.ts +0 -132
  233. package/src/outbox-attachment-filename.ts +0 -76
  234. package/src/outbox-attachment.test.ts +0 -626
  235. package/src/outbox-attachment.ts +0 -536
  236. package/src/outbox-failed-recovery.test.ts +0 -299
  237. package/src/outbox-queue.test.ts +0 -315
  238. package/src/outbox-queue.ts +0 -525
  239. package/src/ownership.ts +0 -140
  240. package/src/placement-move-terminal.ts +0 -145
  241. package/src/placement-move.ts +0 -363
  242. package/src/placement-settled.test.ts +0 -147
  243. package/src/placement.test.ts +0 -404
  244. package/src/quarantine.test.ts +0 -133
  245. package/src/quarantine.ts +0 -229
  246. package/src/refusal.ts +0 -29
  247. package/src/snippet.test.ts +0 -213
  248. package/src/snippet.ts +0 -152
  249. package/src/spam-report.test.ts +0 -329
  250. package/src/spam-report.ts +0 -357
  251. package/src/sync-warning.test.ts +0 -213
  252. package/src/sync-warning.ts +0 -56
  253. package/src/sync-watermarks.test.ts +0 -409
  254. package/src/sync-watermarks.ts +0 -319
  255. package/src/system-flag-wire-format.test.ts +0 -77
  256. package/src/test-helpers/fake-imap.ts +0 -690
  257. package/src/test-helpers/folder-roles.ts +0 -66
  258. package/src/test-helpers/mime-tree-generator.ts +0 -461
  259. package/src/test-helpers/test-queue.ts +0 -56
  260. package/src/test-helpers/test-store.ts +0 -299
  261. package/src/text/normalizer.test.ts +0 -223
  262. package/src/text/normalizer.ts +0 -140
  263. package/src/thread-message-rows.ts +0 -77
  264. package/src/thread-root.test.ts +0 -29
  265. package/src/thread-root.ts +0 -51
  266. package/src/types/mailparser-augment.d.ts +0 -23
  267. package/src/types/natural-porter-stemmer.d.ts +0 -50
  268. package/src/types/stopword.d.ts +0 -69
  269. package/src/types.ts +0 -371
  270. package/tsconfig.json +0 -8
@@ -0,0 +1,102 @@
1
+ import { MailboxCursorState } from "@remit/domain-enums";
2
+ const isNormal = (state) => state === undefined || state === MailboxCursorState.normal;
3
+ /**
4
+ * Compare a mailbox's stored UIDVALIDITY against what the server just served
5
+ * (from an `openBox`/`STATUS` response) and act:
6
+ *
7
+ * - Already `cursor_invalid` or `rebuilding` — outbound IMAP is already
8
+ * paused for this mailbox; short-circuit without a write (frugal — epic
9
+ * #1281 invariant 6) and report why.
10
+ * - `normal` but the served value disagrees with the stored one — trip the
11
+ * mailbox to `cursor_invalid` (the only write this function performs) and
12
+ * report the pause. This is the detection edge: whichever worker path
13
+ * notices the mismatch first is the one that flips the switch.
14
+ * - `normal` and the values agree — proceed, nothing to do.
15
+ *
16
+ * Callers must treat a `{ ok: false }` result as an expected, routine pause
17
+ * (epic #1281 invariant 3), not a fault: log/metric and skip the outbound
18
+ * operation for this round rather than throwing.
19
+ */
20
+ export const guardMailboxCursor = async (deps, accountId, mailbox, servedUidValidity) => {
21
+ if (!isNormal(mailbox.cursorState)) {
22
+ return {
23
+ ok: false,
24
+ state: mailbox.cursorState === MailboxCursorState.rebuilding
25
+ ? "rebuilding"
26
+ : "cursor_invalid",
27
+ };
28
+ }
29
+ if (mailbox.uidValidity !== servedUidValidity) {
30
+ await deps.mailboxService.update(accountId, mailbox.mailboxId, {
31
+ cursorState: MailboxCursorState.cursor_invalid,
32
+ });
33
+ return { ok: false, state: "cursor_invalid" };
34
+ }
35
+ return { ok: true };
36
+ };
37
+ /** True when a mailbox row is in either non-`normal` cursor state. */
38
+ export const isCursorRebuildNeeded = (cursorState) => cursorState === MailboxCursorState.cursor_invalid ||
39
+ cursorState === MailboxCursorState.rebuilding;
40
+ /**
41
+ * Thrown by the `openBox` override on a {@link guardConnectionCursor}-wrapped
42
+ * connection when the mailbox cursor is (or just became) non-`normal`. Every
43
+ * caller must treat this as the routine, expected pause described on {@link
44
+ * guardMailboxCursor} — catch it around the outbound operation and skip
45
+ * (ack/log/return), never let it surface as an infrastructure fault.
46
+ */
47
+ export class MailboxCursorPausedError extends Error {
48
+ state;
49
+ constructor(state) {
50
+ super(`Mailbox cursor is ${state}`);
51
+ this.state = state;
52
+ this.name = "MailboxCursorPausedError";
53
+ }
54
+ }
55
+ /**
56
+ * Wrap a live `IImapConnection` so its `openBox` is the single, structural
57
+ * choke point for UIDVALIDITY cursor guarding (epic #1281 invariants 3 & 5).
58
+ *
59
+ * Every outbound IMAP operation that touches a stored UID requires an
60
+ * `openBox`/SELECT first — that is an IMAP protocol requirement, not a
61
+ * convention — so gating there means a caller cannot reach `fetchMessages`,
62
+ * `addFlags`, `moveMessages`, `fetchMessageBody`, etc. against a paused
63
+ * mailbox's stale axis without going through the same wrapped `openBox` this
64
+ * function defines. A handler that forgets to call `guardMailboxCursor`
65
+ * manually can no longer skip the check by omission; wiring a connection
66
+ * through here makes that structural instead of per-callsite.
67
+ *
68
+ * - Already `cursor_invalid`/`rebuilding` — throws {@link
69
+ * MailboxCursorPausedError} immediately, without calling the real
70
+ * `openBox` (frugal — invariant 6, no network round-trip for a mailbox
71
+ * already known paused).
72
+ * - `normal` but the served UIDVALIDITY disagrees — trips the mailbox (via
73
+ * {@link guardMailboxCursor}) and throws the same error.
74
+ * - `normal` and it matches — delegates to the real `openBox` and returns
75
+ * normally.
76
+ *
77
+ * `mailbox` is a snapshot taken once by the caller (they already need it for
78
+ * `mailboxPath`) — not re-fetched per call, so this adds no extra read.
79
+ */
80
+ export const guardConnectionCursor = (connection, deps, accountId, mailbox) => {
81
+ const guardedOpenBox = async (mailboxPath, readOnly) => {
82
+ if (!isNormal(mailbox.cursorState)) {
83
+ throw new MailboxCursorPausedError(mailbox.cursorState === MailboxCursorState.rebuilding
84
+ ? "rebuilding"
85
+ : "cursor_invalid");
86
+ }
87
+ const boxStatus = await connection.openBox(mailboxPath, readOnly);
88
+ const check = await guardMailboxCursor(deps, accountId, mailbox, boxStatus.uidvalidity);
89
+ if (!check.ok) {
90
+ throw new MailboxCursorPausedError(check.state);
91
+ }
92
+ return boxStatus;
93
+ };
94
+ return new Proxy(connection, {
95
+ get(target, prop, receiver) {
96
+ if (prop === "openBox")
97
+ return guardedOpenBox;
98
+ const value = Reflect.get(target, prop, receiver);
99
+ return typeof value === "function" ? value.bind(target) : value;
100
+ },
101
+ });
102
+ };
@@ -0,0 +1,41 @@
1
+ import type { IMailboxRepository, MailboxItem } from "@remit/data-ports";
2
+ /**
3
+ * The states a rename or a delete intent may be recorded from (T4, T7). Both
4
+ * failed variants are legal starting points — a retry re-records the same
5
+ * intent on the same row, so no duplicate folder can be created — and every
6
+ * other state means a mutation is already in flight on the folder.
7
+ */
8
+ export declare const INTENT_RECORDABLE_FROM: readonly ["synced", "failed"];
9
+ export declare const carriesIntent: (row: Pick<MailboxItem, "syncStatus">) => boolean;
10
+ /**
11
+ * Whether a row was written by one particular rename intent
12
+ * (docs/architecture/folder-rename-and-delete.md D15).
13
+ *
14
+ * A recorded target that merely sits at or under the rename's target is not
15
+ * enough to identify one. Two folders can hold targets under the same path —
16
+ * `Work → Projects` in flight while `Other → Projects/Old` is recorded — and a
17
+ * settle that claimed both would stamp a row this rename never touched onto a
18
+ * path it never asked for, leaving that folder's own rename unrepresented and
19
+ * two rows claiming one path for the sweep to reap and re-insert.
20
+ *
21
+ * The identity is the pair: the row sits at or under the path the rename is
22
+ * leaving, **and** the target it recorded is exactly what this rename's own
23
+ * rebase would have written for it. Nothing but this intent writes that pair,
24
+ * because the intent is recorded over one subtree in one batch (D6) and
25
+ * the transition is the only writer of either field (D3).
26
+ */
27
+ export declare const recordedByRename: (row: Pick<MailboxItem, "fullPath" | "pendingPath">, oldPath: string, newPath: string, delimiter: string) => boolean;
28
+ export declare const folderInFlight: (row: MailboxItem) => string;
29
+ /**
30
+ * Say why a folder intent lost its compare-and-set (D3).
31
+ *
32
+ * Existence was established by the read the caller took before recording, so a
33
+ * lost predicate is a conflict rather than a 404 — but the row can have gone in
34
+ * between, and one primary-key read tells the two apart. For a subtree intent
35
+ * the blocker may be a descendant, and the message says so: a subtree cannot be
36
+ * half-renamed (D6), so a descendant mid-mutation refuses the whole rename and
37
+ * a user who was not thinking about it has to be told which folder it is.
38
+ *
39
+ * Always rejects.
40
+ */
41
+ export declare const refuseContestedIntent: (mailboxService: Pick<IMailboxRepository, "get" | "findByPathPrefix">, accountId: string, mailboxId: string, scope: "folder" | "subtree") => Promise<never>;
@@ -1,9 +1,7 @@
1
- import type { IMailboxRepository, MailboxItem } from "@remit/data-ports";
2
1
  import { ConflictError, NotFoundError } from "@remit/data-ports/errors";
3
2
  import { rebaseMailboxPath } from "@remit/data-ports/mailbox-name";
4
3
  import { MailboxSyncStatus } from "@remit/domain-enums";
5
4
  import { isNotFoundError } from "./mailbox-presence.js";
6
-
7
5
  /**
8
6
  * The states a rename or a delete intent may be recorded from (T4, T7). Both
9
7
  * failed variants are legal starting points — a retry re-records the same
@@ -11,13 +9,10 @@ import { isNotFoundError } from "./mailbox-presence.js";
11
9
  * other state means a mutation is already in flight on the folder.
12
10
  */
13
11
  export const INTENT_RECORDABLE_FROM = [
14
- MailboxSyncStatus.synced,
15
- MailboxSyncStatus.failed,
16
- ] as const;
17
-
18
- export const carriesIntent = (row: Pick<MailboxItem, "syncStatus">): boolean =>
19
- (INTENT_RECORDABLE_FROM as readonly string[]).includes(row.syncStatus);
20
-
12
+ MailboxSyncStatus.synced,
13
+ MailboxSyncStatus.failed,
14
+ ];
15
+ export const carriesIntent = (row) => INTENT_RECORDABLE_FROM.includes(row.syncStatus);
21
16
  /**
22
17
  * Whether a row was written by one particular rename intent
23
18
  * (docs/architecture/folder-rename-and-delete.md D15).
@@ -35,33 +30,24 @@ export const carriesIntent = (row: Pick<MailboxItem, "syncStatus">): boolean =>
35
30
  * because the intent is recorded over one subtree in one batch (D6) and
36
31
  * the transition is the only writer of either field (D3).
37
32
  */
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
-
33
+ export const recordedByRename = (row, oldPath, newPath, delimiter) => row.pendingPath !== undefined &&
34
+ rebaseMailboxPath(row.fullPath, oldPath, newPath, delimiter) ===
35
+ row.pendingPath;
48
36
  /**
49
37
  * What is already happening to a folder, as a clause naming the folder. The
50
38
  * six states, not the four enum values: `pending` covers a create in flight and
51
39
  * a rename in flight, and only `pendingPath` tells them apart.
52
40
  */
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`;
41
+ const inFlight = (row) => {
42
+ if (row.syncStatus === MailboxSyncStatus.deleting) {
43
+ return `A delete is already in progress for "${row.fullPath}"`;
44
+ }
45
+ if (row.pendingPath !== undefined) {
46
+ return `A rename is in progress for "${row.fullPath}"`;
47
+ }
48
+ return `"${row.fullPath}" is still being created`;
61
49
  };
62
-
63
- export const folderInFlight = (row: MailboxItem): string => `${inFlight(row)}.`;
64
-
50
+ export const folderInFlight = (row) => `${inFlight(row)}.`;
65
51
  /**
66
52
  * Say why a folder intent lost its compare-and-set (D3).
67
53
  *
@@ -74,38 +60,26 @@ export const folderInFlight = (row: MailboxItem): string => `${inFlight(row)}.`;
74
60
  *
75
61
  * Always rejects.
76
62
  */
77
- export const refuseContestedIntent = async (
78
- mailboxService: Pick<IMailboxRepository, "get" | "findByPathPrefix">,
79
- accountId: string,
80
- mailboxId: string,
81
- scope: "folder" | "subtree",
82
- ): Promise<never> => {
83
- const root = await mailboxService
84
- .get(accountId, mailboxId)
85
- .catch((error: unknown) => {
86
- if (isNotFoundError(error)) return undefined;
87
- throw error;
88
- });
89
- if (!root) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
90
-
91
- if (!carriesIntent(root)) throw new ConflictError(folderInFlight(root));
92
-
93
- const descendants =
94
- scope === "subtree"
95
- ? await mailboxService.findByPathPrefix(
96
- accountId,
97
- root.fullPath,
98
- root.hierarchyDelimiter,
99
- )
100
- : [];
101
- const blocking = descendants.find((row) => !carriesIntent(row));
102
- if (blocking) {
103
- throw new ConflictError(`${inFlight(blocking)}, inside this folder.`);
104
- }
105
-
106
- // Every row reads recordable now, so the row that blocked has already moved
107
- // on. Nothing to name, and the same request succeeds on a retry.
108
- throw new ConflictError(
109
- `"${root.fullPath}" changed while this request was being recorded. Try again.`,
110
- );
63
+ export const refuseContestedIntent = async (mailboxService, accountId, mailboxId, scope) => {
64
+ const root = await mailboxService
65
+ .get(accountId, mailboxId)
66
+ .catch((error) => {
67
+ if (isNotFoundError(error))
68
+ return undefined;
69
+ throw error;
70
+ });
71
+ if (!root)
72
+ throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
73
+ if (!carriesIntent(root))
74
+ throw new ConflictError(folderInFlight(root));
75
+ const descendants = scope === "subtree"
76
+ ? await mailboxService.findByPathPrefix(accountId, root.fullPath, root.hierarchyDelimiter)
77
+ : [];
78
+ const blocking = descendants.find((row) => !carriesIntent(row));
79
+ if (blocking) {
80
+ throw new ConflictError(`${inFlight(blocking)}, inside this folder.`);
81
+ }
82
+ // Every row reads recordable now, so the row that blocked has already moved
83
+ // on. Nothing to name, and the same request succeeds on a retry.
84
+ throw new ConflictError(`"${root.fullPath}" changed while this request was being recorded. Try again.`);
111
85
  };
@@ -0,0 +1,240 @@
1
+ import type { IMailboxRepository } from "@remit/data-ports";
2
+ import type { IImapConnection } from "./types.js";
3
+ /**
4
+ * Input for creating a mailbox
5
+ */
6
+ export interface CreateMailboxInput {
7
+ accountId: string;
8
+ path: string;
9
+ subscribe?: boolean;
10
+ }
11
+ /**
12
+ * Input for renaming a mailbox
13
+ */
14
+ export interface RenameMailboxInput {
15
+ mailboxId: string;
16
+ newPath: string;
17
+ }
18
+ /**
19
+ * Input for deleting a mailbox
20
+ */
21
+ export interface DeleteMailboxInput {
22
+ mailboxId: string;
23
+ force?: boolean;
24
+ }
25
+ /**
26
+ * Result of syncing mailbox operation to IMAP
27
+ */
28
+ export interface MailboxManagementSyncResult {
29
+ success: boolean;
30
+ error?: string;
31
+ /**
32
+ * Where the folder ended up, on a rename the server confirmed. Absent when
33
+ * the recorded intent had already moved on, so no RENAME was issued —
34
+ * which is how a caller tells the two apart without a second read.
35
+ */
36
+ renamed?: {
37
+ oldPath: string;
38
+ newPath: string;
39
+ delimiter: string;
40
+ };
41
+ existing?: {
42
+ mailboxId: string;
43
+ fullPath: string;
44
+ };
45
+ }
46
+ /**
47
+ * Logger interface for MailboxManagementService
48
+ */
49
+ export interface MailboxManagementLogger {
50
+ info(obj: Record<string, unknown>, msg: string): void;
51
+ error(obj: Record<string, unknown>, msg: string): void;
52
+ }
53
+ /**
54
+ * Parse a mailbox path to extract name and parent path
55
+ */
56
+ export declare const parseMailboxPath: (path: string, delimiter?: string) => {
57
+ name: string;
58
+ parent: string | null;
59
+ depth: number;
60
+ };
61
+ /**
62
+ * Validate mailbox path for invalid characters and operations
63
+ */
64
+ export declare const validateMailboxPath: (path: string) => void;
65
+ /**
66
+ * Validate that an operation can be performed on a mailbox path
67
+ */
68
+ export declare const validateMailboxOperation: (operation: "delete" | "rename", path: string) => void;
69
+ /**
70
+ * Service for managing mailbox operations (create, rename, delete).
71
+ *
72
+ * Implements an optimistic local-first pattern:
73
+ * 1. Updates are applied locally first
74
+ * 2. Changes are queued for IMAP sync via SQS
75
+ * 3. Worker processes queue and syncs to IMAP server
76
+ */
77
+ export declare class MailboxManagementService {
78
+ private mailboxService;
79
+ private log;
80
+ constructor(mailboxService: IMailboxRepository, logger?: MailboxManagementLogger);
81
+ /**
82
+ * Settle a create: `pending` with **no** recorded rename target, which is the
83
+ * state a create in flight is (D3, D10).
84
+ *
85
+ * The absence check is what the whole `wherePendingPath` predicate exists
86
+ * for. A create whose acknowledgement was lost, redelivered against a row a
87
+ * rename has since claimed, passes a `pending`-only guard; the CREATE
88
+ * collides, ImapFlow reports the collision as `{created: false}` rather than
89
+ * an error, and this write then strands the row `synced` with a rename
90
+ * target on it — the seventh combination — leaving the rename's own settle
91
+ * with nothing to match and the RENAME never running.
92
+ *
93
+ * A null means the row is gone or the intent moved on. The handlers'
94
+ * #289-class terminal guards classify the NotFoundError as the user having
95
+ * deleted the folder mid-sync.
96
+ */
97
+ private settleCreate;
98
+ /**
99
+ * The rows one rename intent recorded, each with the path it settles to.
100
+ *
101
+ * The settle deliberately does not re-resolve the subtree (D15): membership
102
+ * changes between the intent and the settle — the sweep inserts a row,
103
+ * another client deletes a descendant — and an all-or-nothing settle then
104
+ * either throws, poisoning the account's FIFO group (#339), or resolves
105
+ * leaving every row `pending` forever with no route out, because `failed` is
106
+ * only reachable from a settle that ran.
107
+ *
108
+ * So the rows identify themselves, by the pair `recordedByRename` tests: a
109
+ * target at or under the rename's target is not on its own enough, because a
110
+ * second folder renamed under the same branch would be claimed by a rename
111
+ * that never touched it. A row that appeared afterwards carries no such pair
112
+ * and is not touched.
113
+ *
114
+ * `confirmedPath` re-prefixes each recorded target off the path the rename
115
+ * actually resolved to, so a normalization the requested string did not carry
116
+ * moves the whole branch rather than the folder alone.
117
+ *
118
+ * `delimiter` is the renamed folder's own, read from the row the caller
119
+ * already holds — deriving one from whatever the listing happens to contain
120
+ * strands every descendant when that row has gone.
121
+ */
122
+ private intentCarryingRows;
123
+ /**
124
+ * Sync a CREATE operation to IMAP.
125
+ * Called by worker after dequeuing MAILBOX_CREATE event.
126
+ *
127
+ * @param accountId - Account that owns the mailbox (tenant scope)
128
+ * @param mailboxId - ID of the mailbox to create
129
+ * @param path - Path of the mailbox to create
130
+ * @param getConnection - Factory to get IMAP connection
131
+ * @param subscribe - Whether to subscribe after creation
132
+ */
133
+ syncCreate: (accountId: string, mailboxId: string, path: string, getConnection: () => Promise<IImapConnection>, subscribe?: boolean) => Promise<MailboxManagementSyncResult>;
134
+ /**
135
+ * Sync a RENAME operation to IMAP and settle the rows that recorded it (T5).
136
+ * Called by worker after dequeuing MAILBOX_RENAME event.
137
+ *
138
+ * Guarded on the intent, not on the event (D10): the row must still be
139
+ * `pending` with this event's target recorded, or the rename resolves with no
140
+ * IMAP call at all. That is what stops a redelivered MAILBOX_RENAME re-issuing
141
+ * a RENAME from a path that has already moved.
142
+ *
143
+ * @param accountId - Account that owns the mailbox (tenant scope)
144
+ * @param mailboxId - ID of the mailbox to rename
145
+ * @param oldPath - Path the intent was recorded against
146
+ * @param newPath - Target the intent recorded
147
+ * @param getConnection - Factory to get IMAP connection
148
+ */
149
+ syncRename: (accountId: string, mailboxId: string, oldPath: string, newPath: string, getConnection: () => Promise<IImapConnection>) => Promise<MailboxManagementSyncResult>;
150
+ /**
151
+ * Issue the RENAME and read back the path it resolved to.
152
+ *
153
+ * A `NONEXISTENT` here has three readings, and only one of them may destroy
154
+ * anything. Either another client deleted the folder; or this rename already
155
+ * landed and only its settle was lost, in which case the folder is alive at
156
+ * the target; or the server is saying something this code cannot classify.
157
+ * The listing separates the first two, and everything it cannot answer is a
158
+ * refused rename (T6) — a rename that failed costs the user a retry, while
159
+ * reading an unclear answer as a delete costs them the folder's mail.
160
+ *
161
+ * So the caller is told which of the two it is by the *kind* of error, never
162
+ * by re-reading the server's code: only `FolderGoneUpstreamError` removes a
163
+ * folder, and it is raised exactly where the listing held neither path.
164
+ */
165
+ private issueRename;
166
+ /**
167
+ * Whether the rename this event was enqueued for is still the folder's live
168
+ * intent. `pending` alone cannot answer it — that state covers a create in
169
+ * flight too — so the recorded target is half the predicate, and it is the
170
+ * same predicate the settling write carries, so the guard and the write
171
+ * cannot disagree between the read and the write.
172
+ */
173
+ private renameIntentStanding;
174
+ /**
175
+ * T5, per row (D15): each intent-carrying row adopts its own confirmed path,
176
+ * drops the recorded target and settles `synced`, on its own conditional
177
+ * write. A row another client moved in between fails its predicate and is
178
+ * skipped rather than overwritten; a row that is gone is skipped for free.
179
+ *
180
+ * **The named folder is written last, and that ordering is load-bearing.**
181
+ * The redelivery's guard reads that row and nothing else, so it is the
182
+ * witness for the whole settle: while it still carries the intent, a
183
+ * redelivery re-enters and finishes whatever the last attempt left. Settling
184
+ * it first would ack a run that died part-way through its descendants, and
185
+ * those rows would stay `pending` forever — invisible to message sync, and
186
+ * with no route out, because an intent may only be recorded from `synced` or
187
+ * `failed`, so neither a retry nor a dismissal can reach them.
188
+ */
189
+ private settleRenameIntent;
190
+ /**
191
+ * T6: the rename did not land, so every row that recorded it goes to
192
+ * `failed`, keeping its target so the client can name what the rename was
193
+ * aiming at and offer a retry. Nothing is restored — `fullPath` was never
194
+ * written, which is the whole point of recording the target instead (D2).
195
+ */
196
+ failRename: (accountId: string, mailboxId: string, oldPath: string, newPath: string, reason: string) => Promise<void>;
197
+ /**
198
+ * The folder the rename was to move was found gone from the server, and
199
+ * the target was not there either, so it was deleted by another client
200
+ * mid-rename (D8). Every row that recorded this rename's intent — the
201
+ * root and each descendant — goes with its mail, the same walk `failRename`
202
+ * uses: deleting the root alone strands every descendant `pending` with
203
+ * nothing left to revisit it, which is the orphaning bug this closes.
204
+ */
205
+ abandonRenameSubtree: (accountId: string, mailboxId: string, oldPath: string, newPath: string) => Promise<void>;
206
+ /**
207
+ * Sync a DELETE operation to IMAP.
208
+ * Called by worker after dequeuing MAILBOX_DELETE event.
209
+ *
210
+ * @param accountId - Account that owns the mailbox (tenant scope)
211
+ * @param mailboxId - ID of the mailbox to delete
212
+ * @param path - Path of the mailbox to delete
213
+ * @param getConnection - Factory to get IMAP connection
214
+ */
215
+ syncDelete: (accountId: string, mailboxId: string, path: string, getConnection: () => Promise<IImapConnection>) => Promise<MailboxManagementSyncResult>;
216
+ /**
217
+ * Whether the delete this event was enqueued for is still the folder's live
218
+ * intent (D10). Anything else resolves the job with no IMAP call — never a
219
+ * throw, which would hold back every later job in the account's FIFO group
220
+ * (#339).
221
+ */
222
+ private deleteIntentStanding;
223
+ /**
224
+ * T8: the confirmed delete takes the folder's local mail with it (D8).
225
+ * Removing the row alone leaves every `message` and `thread_message` row
226
+ * keyed to a dead mailboxId — out of every reader.
227
+ *
228
+ * The removal is batched and the mailbox row goes last, so the row stays
229
+ * `deleting` throughout and an SQS redelivery re-enters the handler, passes
230
+ * the guard above, and resumes where it stopped.
231
+ */
232
+ settleDelete: (accountId: string, mailboxId: string) => Promise<void>;
233
+ /**
234
+ * T9: the server refused the delete. Nothing is unwound — DELETE is
235
+ * all-or-nothing, so `fullPath` is still what the server holds, and no rename
236
+ * target is written, which is how the client tells a failed delete from a
237
+ * failed rename.
238
+ */
239
+ failDelete: (accountId: string, mailboxId: string, reason: string) => Promise<void>;
240
+ }