@remit/mailbox-service 0.0.111 → 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 (298) 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} +19 -77
  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} +39 -63
  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} +19 -50
  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 -101
  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 -73
  150. package/src/account-credentials.ts +0 -210
  151. package/src/adversarial-mime.e2e.test.ts +0 -307
  152. package/src/attribute-mapper.test.ts +0 -104
  153. package/src/attribute-mapper.ts +0 -148
  154. package/src/body-parse.test.ts +0 -55
  155. package/src/body-part-mapper.property.test.ts +0 -354
  156. package/src/body-part-mapper.ts +0 -360
  157. package/src/body-sync-auto-read-writeonce.test.ts +0 -219
  158. package/src/body-sync-calendar-suggestion.test.ts +0 -433
  159. package/src/body-sync-category-denormalize.test.ts +0 -352
  160. package/src/body-sync-category-override.test.ts +0 -501
  161. package/src/body-sync-category-writeonce.test.ts +0 -234
  162. package/src/body-sync-filter.test.ts +0 -237
  163. package/src/body-sync-message-gone.test.ts +0 -139
  164. package/src/body-sync-placement-flags.test.ts +0 -347
  165. package/src/body-sync-placement-writeonce.test.ts +0 -337
  166. package/src/body-sync-quarantine.test.ts +0 -276
  167. package/src/body-sync-queue.test.ts +0 -124
  168. package/src/body-sync-queue.ts +0 -131
  169. package/src/body-sync-resync.test.ts +0 -605
  170. package/src/body-sync-terminal.test.ts +0 -310
  171. package/src/body-sync-terminal.ts +0 -176
  172. package/src/body-sync-unclassifiable-once.test.ts +0 -260
  173. package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
  174. package/src/body-sync.ts +0 -1970
  175. package/src/calendar-parts.ts +0 -41
  176. package/src/classification-backfill.test.ts +0 -615
  177. package/src/classification-backfill.ts +0 -363
  178. package/src/classify-message.test.ts +0 -205
  179. package/src/classify-message.ts +0 -116
  180. package/src/connection-factory.ts +0 -102
  181. package/src/connection-test.ts +0 -208
  182. package/src/credentials.test.ts +0 -181
  183. package/src/external-move.test.ts +0 -369
  184. package/src/external-move.ts +0 -272
  185. package/src/filters/anchor-drift.ts +0 -55
  186. package/src/filters/filter-config.test.ts +0 -229
  187. package/src/filters/filter-config.ts +0 -69
  188. package/src/filters/list-id.test.ts +0 -50
  189. package/src/filters/match.test.ts +0 -429
  190. package/src/filters/match.ts +0 -274
  191. package/src/filters/message-embedder.test.ts +0 -39
  192. package/src/filters/message-embedder.ts +0 -33
  193. package/src/filters/pipeline.test.ts +0 -330
  194. package/src/filters/pipeline.ts +0 -284
  195. package/src/flag-push-terminal.test.ts +0 -209
  196. package/src/flag-push.test.ts +0 -408
  197. package/src/flag-push.ts +0 -198
  198. package/src/flag-queue-stars.test.ts +0 -171
  199. package/src/flag-queue.ts +0 -509
  200. package/src/folder-placement.ts +0 -56
  201. package/src/heuristics/authenticationResults.test.ts +0 -189
  202. package/src/heuristics/authenticationResults.ts +0 -212
  203. package/src/heuristics/classifyByHeaders.test.ts +0 -889
  204. package/src/heuristics/classifyByHeaders.ts +0 -467
  205. package/src/heuristics/classifyPlacement.test.ts +0 -366
  206. package/src/heuristics/classifyPlacement.ts +0 -211
  207. package/src/heuristics/classifyRealMail.test.ts +0 -242
  208. package/src/heuristics/machineSenders.test.ts +0 -101
  209. package/src/heuristics/senderMismatch.test.ts +0 -346
  210. package/src/heuristics/senderMismatch.ts +0 -259
  211. package/src/imapflow-connect-retry.test.ts +0 -134
  212. package/src/imapflow-connection.e2e.test.ts +0 -335
  213. package/src/imapflow-connection.integ.test.ts +0 -316
  214. package/src/imapflow-connection.test.ts +0 -469
  215. package/src/imapflow-connection.ts +0 -1475
  216. package/src/index.ts +0 -350
  217. package/src/list-id-backfill.test.ts +0 -573
  218. package/src/list-id-backfill.ts +0 -274
  219. package/src/mailbox-cursor-rebuild.test.ts +0 -171
  220. package/src/mailbox-cursor-rebuild.ts +0 -146
  221. package/src/mailbox-cursor.test.ts +0 -249
  222. package/src/mailbox-delete-intent.test.ts +0 -246
  223. package/src/mailbox-management.test.ts +0 -356
  224. package/src/mailbox-management.ts +0 -839
  225. package/src/mailbox-presence.test.ts +0 -72
  226. package/src/mailbox-queue.ts +0 -415
  227. package/src/mailbox-rename-intent.test.ts +0 -389
  228. package/src/mailbox-rename-subtree.test.ts +0 -664
  229. package/src/mailbox-sync.test.ts +0 -848
  230. package/src/mailbox-sync.ts +0 -724
  231. package/src/message-delete-junk-reconcile.test.ts +0 -117
  232. package/src/message-delete-no-trash.test.ts +0 -245
  233. package/src/message-delete-unsettled-placement.test.ts +0 -396
  234. package/src/message-move-copy.test.ts +0 -562
  235. package/src/message-move-empty-trash.test.ts +0 -326
  236. package/src/message-move-same-mailbox.test.ts +0 -139
  237. package/src/message-move-unsettled-placement.test.ts +0 -378
  238. package/src/message-move.ts +0 -1348
  239. package/src/message-parser.ts +0 -26
  240. package/src/message-presence.test.ts +0 -50
  241. package/src/message-sync-address-spoof.test.ts +0 -293
  242. package/src/message-sync-category.test.ts +0 -142
  243. package/src/message-sync-changedsince.test.ts +0 -840
  244. package/src/message-sync-flags.test.ts +0 -158
  245. package/src/message-sync-junk-harvest.test.ts +0 -244
  246. package/src/message-sync-quarantine.test.ts +0 -416
  247. package/src/message-sync-repoint.test.ts +0 -419
  248. package/src/message-sync.ts +0 -1963
  249. package/src/mime-walker.test.ts +0 -311
  250. package/src/mime-walker.ts +0 -305
  251. package/src/outbox-attachment-content.test.ts +0 -153
  252. package/src/outbox-attachment-content.ts +0 -104
  253. package/src/outbox-attachment-filename.test.ts +0 -132
  254. package/src/outbox-attachment-filename.ts +0 -76
  255. package/src/outbox-attachment.test.ts +0 -738
  256. package/src/outbox-attachment.ts +0 -536
  257. package/src/outbox-failed-recovery.test.ts +0 -370
  258. package/src/outbox-queue-settled-row.test.ts +0 -140
  259. package/src/outbox-queue.test.ts +0 -291
  260. package/src/outbox-queue.ts +0 -508
  261. package/src/pass-through-unit-of-work.ts +0 -17
  262. package/src/placement-move-terminal.test.ts +0 -300
  263. package/src/placement-move-terminal.ts +0 -148
  264. package/src/placement-move-unsettled.test.ts +0 -275
  265. package/src/placement-move.ts +0 -336
  266. package/src/placement-settled.test.ts +0 -147
  267. package/src/placement-transition-race.test.ts +0 -482
  268. package/src/quarantine.test.ts +0 -191
  269. package/src/quarantine.ts +0 -228
  270. package/src/snippet.test.ts +0 -213
  271. package/src/snippet.ts +0 -152
  272. package/src/spam-report.test.ts +0 -801
  273. package/src/spam-report.ts +0 -357
  274. package/src/stale-message-reconcile.test.ts +0 -82
  275. package/src/stale-message-reconcile.ts +0 -55
  276. package/src/sync-watermarks.test.ts +0 -409
  277. package/src/sync-watermarks.ts +0 -319
  278. package/src/system-flag-wire-format.test.ts +0 -77
  279. package/src/test-helpers/folder-roles.ts +0 -66
  280. package/src/test-helpers/isolated-mailbox.ts +0 -44
  281. package/src/test-helpers/mailfuzz-connection.ts +0 -27
  282. package/src/test-helpers/mime-tree-generator.ts +0 -461
  283. package/src/text/normalizer.test.ts +0 -223
  284. package/src/text/normalizer.ts +0 -140
  285. package/src/thread-message-rows.ts +0 -77
  286. package/src/types/mailparser-augment.d.ts +0 -15
  287. package/src/types/natural-porter-stemmer.d.ts +0 -50
  288. package/src/types/stopword.d.ts +0 -69
  289. package/src/types.ts +0 -371
  290. package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
  291. package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
  292. package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
  293. package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
  294. package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
  295. package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
  296. package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
  297. package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
  298. 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
- 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).
@@ -32,34 +27,27 @@ const carriesIntent = (row: Pick<MailboxItem, "syncStatus">): boolean =>
32
27
  * The identity is the pair: the row sits at or under the path the rename is
33
28
  * leaving, **and** the target it recorded is exactly what this rename's own
34
29
  * 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
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
-
50
+ export const folderInFlight = (row) => `${inFlight(row)}.`;
63
51
  /**
64
52
  * Say why a folder intent lost its compare-and-set (D3).
65
53
  *
@@ -72,38 +60,26 @@ const inFlight = (row: MailboxItem): string => {
72
60
  *
73
61
  * Always rejects.
74
62
  */
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
- );
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.`);
109
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
+ }