@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,576 @@
1
+ import { NotFoundError } from "@remit/data-ports/errors";
2
+ import { rebaseMailboxPath } from "@remit/data-ports/mailbox-name";
3
+ import { MailboxSyncStatus } from "@remit/domain-enums";
4
+ import { recordedByRename } from "./mailbox-intent.js";
5
+ import { isNotFoundError } from "./mailbox-presence.js";
6
+ import { FolderGoneUpstreamError, FolderRenameSettleError, isMailboxAbsentUpstream, } from "./mailbox-upstream.js";
7
+ const noopLogger = {
8
+ info: () => { },
9
+ error: () => { },
10
+ };
11
+ /**
12
+ * Parse a mailbox path to extract name and parent path
13
+ */
14
+ export const parseMailboxPath = (path, delimiter = "/") => {
15
+ const parts = path.split(delimiter);
16
+ return {
17
+ name: parts[parts.length - 1],
18
+ parent: parts.length > 1 ? parts.slice(0, -1).join(delimiter) : null,
19
+ depth: parts.length,
20
+ };
21
+ };
22
+ /**
23
+ * Validate mailbox path for invalid characters and operations
24
+ */
25
+ export const validateMailboxPath = (path) => {
26
+ if (!path || path.trim().length === 0) {
27
+ throw new Error("Mailbox path cannot be empty");
28
+ }
29
+ // Check for double delimiters
30
+ if (path.includes("//")) {
31
+ throw new Error("Mailbox path cannot contain empty hierarchy levels");
32
+ }
33
+ // Check for leading/trailing delimiters
34
+ if (path.startsWith("/") || path.endsWith("/")) {
35
+ throw new Error("Mailbox path cannot start or end with hierarchy delimiter");
36
+ }
37
+ };
38
+ /**
39
+ * Validate that an operation can be performed on a mailbox path
40
+ */
41
+ export const validateMailboxOperation = (operation, path) => {
42
+ if (path.toUpperCase() !== "INBOX")
43
+ return;
44
+ // A RENAME of INBOX moves its mail to the new name and leaves an empty INBOX
45
+ // behind — a bulk move wearing a rename's clothes, which strands the
46
+ // account's inbox appointment and voids the row's UID cursor (D5). The API
47
+ // refuses it; this is the backstop for anything that reaches the worker
48
+ // another way.
49
+ throw new Error(operation === "delete" ? "Cannot delete INBOX" : "Cannot rename INBOX");
50
+ };
51
+ /**
52
+ * Whether the server's own listing holds a path this code named.
53
+ *
54
+ * The requested string and the path the server keeps are not the same thing.
55
+ * Under a namespace prefix a Dovecot INBOX namespace stores `Projects` as
56
+ * `INBOX.Projects`, which is why the settle adopts the path ImapFlow resolved
57
+ * rather than the one that was asked for (D2) — and a probe comparing the raw
58
+ * request against the listing would read a folder that is plainly there as
59
+ * absent. There is no resolved path to adopt on the failure path, so the
60
+ * account's own prefix stands in for the normalization.
61
+ */
62
+ const holdsPath = (onServer, mailbox, path) => {
63
+ if (onServer.has(path))
64
+ return true;
65
+ const { namespacePrefix } = mailbox;
66
+ if (namespacePrefix.length === 0)
67
+ return false;
68
+ if (path.startsWith(namespacePrefix))
69
+ return false;
70
+ return onServer.has(`${namespacePrefix}${path}`);
71
+ };
72
+ /**
73
+ * Service for managing mailbox operations (create, rename, delete).
74
+ *
75
+ * Implements an optimistic local-first pattern:
76
+ * 1. Updates are applied locally first
77
+ * 2. Changes are queued for IMAP sync via SQS
78
+ * 3. Worker processes queue and syncs to IMAP server
79
+ */
80
+ export class MailboxManagementService {
81
+ mailboxService;
82
+ log;
83
+ constructor(mailboxService, logger) {
84
+ this.mailboxService = mailboxService;
85
+ this.log = logger ?? noopLogger;
86
+ }
87
+ /**
88
+ * Settle a create: `pending` with **no** recorded rename target, which is the
89
+ * state a create in flight is (D3, D10).
90
+ *
91
+ * The absence check is what the whole `wherePendingPath` predicate exists
92
+ * for. A create whose acknowledgement was lost, redelivered against a row a
93
+ * rename has since claimed, passes a `pending`-only guard; the CREATE
94
+ * collides, ImapFlow reports the collision as `{created: false}` rather than
95
+ * an error, and this write then strands the row `synced` with a rename
96
+ * target on it — the seventh combination — leaving the rename's own settle
97
+ * with nothing to match and the RENAME never running.
98
+ *
99
+ * A null means the row is gone or the intent moved on. The handlers'
100
+ * #289-class terminal guards classify the NotFoundError as the user having
101
+ * deleted the folder mid-sync.
102
+ */
103
+ settleCreate = async (accountId, mailboxId, confirmedPath) => {
104
+ const settled = await this.mailboxService.transition(accountId, mailboxId, {
105
+ from: [MailboxSyncStatus.pending],
106
+ wherePendingPath: null,
107
+ to: MailboxSyncStatus.synced,
108
+ set: confirmedPath !== undefined ? { fullPath: confirmedPath } : {},
109
+ });
110
+ if (settled)
111
+ return;
112
+ // Either the row is gone, or a rename claimed it while this create was in
113
+ // flight. Both resolve the job: the folder exists, and the rename is the
114
+ // live intent.
115
+ const current = await this.mailboxService
116
+ .get(accountId, mailboxId)
117
+ .catch((error) => {
118
+ if (isNotFoundError(error))
119
+ return undefined;
120
+ throw error;
121
+ });
122
+ if (!current)
123
+ throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
124
+ this.log.info({
125
+ accountId,
126
+ mailboxId,
127
+ intent: "create",
128
+ from: current.syncStatus,
129
+ outcome: "superseded",
130
+ }, "Folder create superseded");
131
+ };
132
+ /**
133
+ * The rows one rename intent recorded, each with the path it settles to.
134
+ *
135
+ * The settle deliberately does not re-resolve the subtree (D15): membership
136
+ * changes between the intent and the settle — the sweep inserts a row,
137
+ * another client deletes a descendant — and an all-or-nothing settle then
138
+ * either throws, poisoning the account's FIFO group (#339), or resolves
139
+ * leaving every row `pending` forever with no route out, because `failed` is
140
+ * only reachable from a settle that ran.
141
+ *
142
+ * So the rows identify themselves, by the pair `recordedByRename` tests: a
143
+ * target at or under the rename's target is not on its own enough, because a
144
+ * second folder renamed under the same branch would be claimed by a rename
145
+ * that never touched it. A row that appeared afterwards carries no such pair
146
+ * and is not touched.
147
+ *
148
+ * `confirmedPath` re-prefixes each recorded target off the path the rename
149
+ * actually resolved to, so a normalization the requested string did not carry
150
+ * moves the whole branch rather than the folder alone.
151
+ *
152
+ * `delimiter` is the renamed folder's own, read from the row the caller
153
+ * already holds — deriving one from whatever the listing happens to contain
154
+ * strands every descendant when that row has gone.
155
+ */
156
+ intentCarryingRows = async (accountId, oldPath, target, confirmed, delimiter) => {
157
+ const pending = await this.mailboxService.findBySyncStatus(accountId, MailboxSyncStatus.pending);
158
+ const carrying = [];
159
+ for (const row of pending) {
160
+ if (!recordedByRename(row, oldPath, target, delimiter))
161
+ continue;
162
+ const confirmedPath = rebaseMailboxPath(row.pendingPath, target, confirmed, delimiter);
163
+ if (confirmedPath === undefined)
164
+ continue;
165
+ carrying.push({ row, confirmedPath });
166
+ }
167
+ return carrying;
168
+ };
169
+ /**
170
+ * Sync a CREATE operation to IMAP.
171
+ * Called by worker after dequeuing MAILBOX_CREATE event.
172
+ *
173
+ * @param accountId - Account that owns the mailbox (tenant scope)
174
+ * @param mailboxId - ID of the mailbox to create
175
+ * @param path - Path of the mailbox to create
176
+ * @param getConnection - Factory to get IMAP connection
177
+ * @param subscribe - Whether to subscribe after creation
178
+ */
179
+ syncCreate = async (accountId, mailboxId, path, getConnection, subscribe) => {
180
+ const connection = await getConnection();
181
+ const result = await connection.createMailbox(path);
182
+ // The server is free to materialize the requested path under a namespace
183
+ // prefix — a Dovecot INBOX namespace turns "Notifications" into
184
+ // "INBOX/Notifications". `mailboxCreate` reports the canonical path the
185
+ // server assigned; adopt it as this row's identity so the next mailbox
186
+ // reconcile matches it by fullPath and updates it in place, rather than
187
+ // inserting a fresh row for the prefixed path and deleting this one — which
188
+ // would strand every filter and placement that references this mailboxId.
189
+ // Fall back to the requested path when the result carries no usable path,
190
+ // so a thin result never blanks the row's fullPath to undefined.
191
+ const serverPath = typeof result.path === "string" && result.path.length > 0
192
+ ? result.path
193
+ : path;
194
+ const confirmedPath = serverPath !== path ? serverPath : undefined;
195
+ this.log.info({ mailboxId, path, serverPath, created: result.created }, "Created mailbox on IMAP server");
196
+ if (subscribe) {
197
+ await connection.subscribeMailbox(serverPath);
198
+ this.log.info({ mailboxId, path: serverPath }, "Subscribed to mailbox");
199
+ }
200
+ const holder = confirmedPath === undefined
201
+ ? null
202
+ : await this.mailboxService.findByPath(accountId, confirmedPath);
203
+ if (holder && holder.mailboxId !== mailboxId) {
204
+ await this.mailboxService.deleteMailboxWithMail(accountId, mailboxId);
205
+ this.log.info({
206
+ accountId,
207
+ mailboxId,
208
+ intent: "create",
209
+ path,
210
+ serverPath,
211
+ existingMailboxId: holder.mailboxId,
212
+ outcome: "already-exists",
213
+ }, "Folder already exists at the path the server resolved");
214
+ return {
215
+ success: true,
216
+ existing: { mailboxId: holder.mailboxId, fullPath: holder.fullPath },
217
+ };
218
+ }
219
+ // Refresh mailbox list to get UIDVALIDITY and other attributes
220
+ const mailboxes = await connection.listMailboxes();
221
+ const mailboxInfo = mailboxes.find((m) => m.fullPath === serverPath);
222
+ if (mailboxInfo) {
223
+ // Open the mailbox to get UIDVALIDITY and other status info
224
+ const status = await connection.openBox(serverPath, true);
225
+ // Two writes where there was one, and the settle is deliberately the
226
+ // second: counters are ordinary metadata and stay off the transition,
227
+ // whose whole point is that its write-set is the state (D3). A crash in
228
+ // between leaves the row `pending` with fresh counters, and the row not
229
+ // yet settled is what keeps the queue message unacked — the redelivery
230
+ // re-issues CREATE, the server answers ALREADYEXISTS, and #339's
231
+ // classification settles it. Settling first would be the unsafe order.
232
+ await this.mailboxService.update(accountId, mailboxId, {
233
+ uidValidity: status.uidvalidity,
234
+ uidNext: status.uidnext,
235
+ messageCount: status.messages.total,
236
+ });
237
+ await this.settleCreate(accountId, mailboxId, confirmedPath);
238
+ await connection.closeBox();
239
+ }
240
+ else {
241
+ // Mark as synced even if we couldn't get full info
242
+ await this.settleCreate(accountId, mailboxId, confirmedPath);
243
+ }
244
+ return { success: true };
245
+ };
246
+ /**
247
+ * Sync a RENAME operation to IMAP and settle the rows that recorded it (T5).
248
+ * Called by worker after dequeuing MAILBOX_RENAME event.
249
+ *
250
+ * Guarded on the intent, not on the event (D10): the row must still be
251
+ * `pending` with this event's target recorded, or the rename resolves with no
252
+ * IMAP call at all. That is what stops a redelivered MAILBOX_RENAME re-issuing
253
+ * a RENAME from a path that has already moved.
254
+ *
255
+ * @param accountId - Account that owns the mailbox (tenant scope)
256
+ * @param mailboxId - ID of the mailbox to rename
257
+ * @param oldPath - Path the intent was recorded against
258
+ * @param newPath - Target the intent recorded
259
+ * @param getConnection - Factory to get IMAP connection
260
+ */
261
+ syncRename = async (accountId, mailboxId, oldPath, newPath, getConnection) => {
262
+ const standing = await this.renameIntentStanding(accountId, mailboxId, newPath);
263
+ if (!standing)
264
+ return { success: true };
265
+ const connection = await getConnection();
266
+ const delimiter = standing.hierarchyDelimiter;
267
+ const confirmed = await this.issueRename(connection, standing, accountId, mailboxId, oldPath, newPath);
268
+ this.log.info({ accountId, mailboxId, intent: "rename", oldPath, newPath, confirmed }, "Renamed mailbox on IMAP server");
269
+ // The server has executed the rename. Nothing after this point may reach
270
+ // the caller's failure path: `failRename` would mark the rows refused for
271
+ // a rename that landed, and the folder would sit at `failed` offering a
272
+ // retry of something already done. A settle that cannot finish is wrapped
273
+ // so the caller can tell the two apart, and rethrown so SQS redelivers —
274
+ // the rows are still `pending` with their targets, so the redelivery's
275
+ // guard passes and the settle runs again.
276
+ await this.settleRenameIntent(accountId, mailboxId, oldPath, newPath, confirmed, delimiter).catch((error) => {
277
+ throw new FolderRenameSettleError(mailboxId, confirmed, error);
278
+ });
279
+ return {
280
+ success: true,
281
+ renamed: { oldPath, newPath: confirmed, delimiter },
282
+ };
283
+ };
284
+ /**
285
+ * Issue the RENAME and read back the path it resolved to.
286
+ *
287
+ * A `NONEXISTENT` here has three readings, and only one of them may destroy
288
+ * anything. Either another client deleted the folder; or this rename already
289
+ * landed and only its settle was lost, in which case the folder is alive at
290
+ * the target; or the server is saying something this code cannot classify.
291
+ * The listing separates the first two, and everything it cannot answer is a
292
+ * refused rename (T6) — a rename that failed costs the user a retry, while
293
+ * reading an unclear answer as a delete costs them the folder's mail.
294
+ *
295
+ * So the caller is told which of the two it is by the *kind* of error, never
296
+ * by re-reading the server's code: only `FolderGoneUpstreamError` removes a
297
+ * folder, and it is raised exactly where the listing held neither path.
298
+ */
299
+ issueRename = async (connection, standing, accountId, mailboxId, oldPath, newPath) => {
300
+ const result = await connection
301
+ .renameMailbox(oldPath, newPath)
302
+ .catch(async (error) => {
303
+ if (!isMailboxAbsentUpstream(error))
304
+ throw error;
305
+ const listed = await connection
306
+ .listMailboxes()
307
+ .catch(() => undefined);
308
+ // A listing that cannot be read answers nothing, so it decides
309
+ // nothing: the rename is refused and the folder is left alone.
310
+ if (!listed)
311
+ throw error;
312
+ const onServer = new Set(listed.map((mailbox) => mailbox.fullPath));
313
+ if (holdsPath(onServer, standing, newPath)) {
314
+ this.log.info({ accountId, mailboxId, intent: "rename", oldPath, newPath }, "Source folder gone and the target is on the server: this rename already landed");
315
+ return undefined;
316
+ }
317
+ if (holdsPath(onServer, standing, oldPath))
318
+ throw error;
319
+ throw new FolderGoneUpstreamError(mailboxId, oldPath, error);
320
+ });
321
+ // ImapFlow's own normalization of the requested path — namespace prefix
322
+ // applied, special names resolved, delimiter joined. IMAP's RENAME reply
323
+ // carries no path, so this is not the server echoing a name back and it
324
+ // cannot detect a server that stored a different one; adopting it is still
325
+ // right, because it fixes the prefix and delimiter drift the requested
326
+ // string carries (D2).
327
+ return typeof result?.newPath === "string" && result.newPath.length > 0
328
+ ? result.newPath
329
+ : newPath;
330
+ };
331
+ /**
332
+ * Whether the rename this event was enqueued for is still the folder's live
333
+ * intent. `pending` alone cannot answer it — that state covers a create in
334
+ * flight too — so the recorded target is half the predicate, and it is the
335
+ * same predicate the settling write carries, so the guard and the write
336
+ * cannot disagree between the read and the write.
337
+ */
338
+ renameIntentStanding = async (accountId, mailboxId, newPath) => {
339
+ const row = await this.mailboxService
340
+ .get(accountId, mailboxId)
341
+ .catch((error) => {
342
+ if (isNotFoundError(error))
343
+ return undefined;
344
+ throw error;
345
+ });
346
+ if (row &&
347
+ row.syncStatus === MailboxSyncStatus.pending &&
348
+ row.pendingPath === newPath) {
349
+ return row;
350
+ }
351
+ this.log.info({
352
+ accountId,
353
+ mailboxId,
354
+ intent: "rename",
355
+ from: row?.syncStatus,
356
+ newPath,
357
+ outcome: row ? "superseded" : "already-settled",
358
+ }, "Skipping MAILBOX_RENAME: the recorded intent has moved on");
359
+ return undefined;
360
+ };
361
+ /**
362
+ * T5, per row (D15): each intent-carrying row adopts its own confirmed path,
363
+ * drops the recorded target and settles `synced`, on its own conditional
364
+ * write. A row another client moved in between fails its predicate and is
365
+ * skipped rather than overwritten; a row that is gone is skipped for free.
366
+ *
367
+ * **The named folder is written last, and that ordering is load-bearing.**
368
+ * The redelivery's guard reads that row and nothing else, so it is the
369
+ * witness for the whole settle: while it still carries the intent, a
370
+ * redelivery re-enters and finishes whatever the last attempt left. Settling
371
+ * it first would ack a run that died part-way through its descendants, and
372
+ * those rows would stay `pending` forever — invisible to message sync, and
373
+ * with no route out, because an intent may only be recorded from `synced` or
374
+ * `failed`, so neither a retry nor a dismissal can reach them.
375
+ */
376
+ settleRenameIntent = async (accountId, mailboxId, oldPath, target, confirmed, delimiter) => {
377
+ const carrying = await this.intentCarryingRows(accountId, oldPath, target, confirmed, delimiter);
378
+ const ordered = [
379
+ ...carrying.filter((entry) => entry.row.mailboxId !== mailboxId),
380
+ ...carrying.filter((entry) => entry.row.mailboxId === mailboxId),
381
+ ];
382
+ for (const { row, confirmedPath } of ordered) {
383
+ const settled = await this.mailboxService.transition(accountId, row.mailboxId, {
384
+ from: [MailboxSyncStatus.pending],
385
+ wherePendingPath: row.pendingPath,
386
+ to: MailboxSyncStatus.synced,
387
+ set: { fullPath: confirmedPath },
388
+ });
389
+ this.log.info({
390
+ accountId,
391
+ mailboxId: row.mailboxId,
392
+ intent: "rename",
393
+ from: MailboxSyncStatus.pending,
394
+ to: MailboxSyncStatus.synced,
395
+ fullPath: confirmedPath,
396
+ outcome: settled ? "settled" : "superseded",
397
+ }, "Settled renamed folder");
398
+ }
399
+ };
400
+ /**
401
+ * T6: the rename did not land, so every row that recorded it goes to
402
+ * `failed`, keeping its target so the client can name what the rename was
403
+ * aiming at and offer a retry. Nothing is restored — `fullPath` was never
404
+ * written, which is the whole point of recording the target instead (D2).
405
+ */
406
+ failRename = async (accountId, mailboxId, oldPath, newPath, reason) => {
407
+ const root = await this.mailboxService
408
+ .get(accountId, mailboxId)
409
+ .catch((error) => {
410
+ if (isNotFoundError(error))
411
+ return undefined;
412
+ throw error;
413
+ });
414
+ if (!root)
415
+ throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
416
+ const carrying = await this.intentCarryingRows(accountId, oldPath, newPath, newPath, root.hierarchyDelimiter);
417
+ // The row is there and no row carries the intent, so this refusal has
418
+ // nothing to record. It must not be reported as a missing mailbox: the
419
+ // caller's #289-class guard reads a NotFoundError as the user having
420
+ // deleted the folder and acks the message, which would swallow the IMAP
421
+ // error that brought us here and leave the subtree `pending`. Say so and
422
+ // let the caller rethrow what actually failed.
423
+ if (carrying.length === 0) {
424
+ this.log.error({
425
+ accountId,
426
+ mailboxId,
427
+ intent: "rename",
428
+ from: root.syncStatus,
429
+ oldPath,
430
+ newPath,
431
+ outcome: "superseded",
432
+ }, "Refused rename recorded nothing: no row carries this intent");
433
+ return;
434
+ }
435
+ for (const { row } of carrying) {
436
+ const failed = await this.mailboxService.transition(accountId, row.mailboxId, {
437
+ from: [MailboxSyncStatus.pending],
438
+ wherePendingPath: row.pendingPath,
439
+ to: MailboxSyncStatus.failed,
440
+ set: { syncFailureReason: reason },
441
+ });
442
+ this.log.info({
443
+ accountId,
444
+ mailboxId: row.mailboxId,
445
+ intent: "rename",
446
+ from: MailboxSyncStatus.pending,
447
+ to: MailboxSyncStatus.failed,
448
+ outcome: failed ? "refused" : "superseded",
449
+ }, "Recorded a refused rename");
450
+ }
451
+ };
452
+ /**
453
+ * The folder the rename was to move was found gone from the server, and
454
+ * the target was not there either, so it was deleted by another client
455
+ * mid-rename (D8). Every row that recorded this rename's intent — the
456
+ * root and each descendant — goes with its mail, the same walk `failRename`
457
+ * uses: deleting the root alone strands every descendant `pending` with
458
+ * nothing left to revisit it, which is the orphaning bug this closes.
459
+ */
460
+ abandonRenameSubtree = async (accountId, mailboxId, oldPath, newPath) => {
461
+ const root = await this.mailboxService
462
+ .get(accountId, mailboxId)
463
+ .catch((error) => {
464
+ if (isNotFoundError(error))
465
+ return undefined;
466
+ throw error;
467
+ });
468
+ if (!root) {
469
+ this.log.info({ accountId, mailboxId, intent: "rename", outcome: "already-settled" }, "Nothing to abandon: the folder row is already gone");
470
+ return;
471
+ }
472
+ const carrying = await this.intentCarryingRows(accountId, oldPath, newPath, newPath, root.hierarchyDelimiter);
473
+ // A removal that throws part-way abandons the rest into a redelivery, and
474
+ // that is safe rather than merely tolerable: `deleteMailboxWithMail` is
475
+ // idempotent — it returns without touching anything when the row is gone,
476
+ // and resumes from whatever is left when it is not, because the mailbox
477
+ // row is removed last. The rows still standing carry the intent, so the
478
+ // next attempt resolves the same set and finishes it.
479
+ for (const { row } of carrying) {
480
+ await this.mailboxService.deleteMailboxWithMail(accountId, row.mailboxId);
481
+ this.log.info({
482
+ accountId,
483
+ mailboxId: row.mailboxId,
484
+ intent: "rename",
485
+ from: MailboxSyncStatus.pending,
486
+ to: "deleted",
487
+ }, "Source mailbox not found, deleted local folder and its mail");
488
+ }
489
+ };
490
+ /**
491
+ * Sync a DELETE operation to IMAP.
492
+ * Called by worker after dequeuing MAILBOX_DELETE event.
493
+ *
494
+ * @param accountId - Account that owns the mailbox (tenant scope)
495
+ * @param mailboxId - ID of the mailbox to delete
496
+ * @param path - Path of the mailbox to delete
497
+ * @param getConnection - Factory to get IMAP connection
498
+ */
499
+ syncDelete = async (accountId, mailboxId, path, getConnection) => {
500
+ if (!(await this.deleteIntentStanding(accountId, mailboxId))) {
501
+ return { success: true };
502
+ }
503
+ const connection = await getConnection();
504
+ await connection.deleteMailbox(path);
505
+ this.log.info({ accountId, mailboxId, intent: "delete", path }, "Deleted mailbox on IMAP server");
506
+ await this.settleDelete(accountId, mailboxId);
507
+ return { success: true };
508
+ };
509
+ /**
510
+ * Whether the delete this event was enqueued for is still the folder's live
511
+ * intent (D10). Anything else resolves the job with no IMAP call — never a
512
+ * throw, which would hold back every later job in the account's FIFO group
513
+ * (#339).
514
+ */
515
+ deleteIntentStanding = async (accountId, mailboxId) => {
516
+ const row = await this.mailboxService
517
+ .get(accountId, mailboxId)
518
+ .catch((error) => {
519
+ if (isNotFoundError(error))
520
+ return undefined;
521
+ throw error;
522
+ });
523
+ if (row?.syncStatus === MailboxSyncStatus.deleting)
524
+ return true;
525
+ this.log.info({
526
+ accountId,
527
+ mailboxId,
528
+ intent: "delete",
529
+ from: row?.syncStatus,
530
+ outcome: row ? "superseded" : "already-settled",
531
+ }, "Skipping MAILBOX_DELETE: the recorded intent has moved on");
532
+ return false;
533
+ };
534
+ /**
535
+ * T8: the confirmed delete takes the folder's local mail with it (D8).
536
+ * Removing the row alone leaves every `message` and `thread_message` row
537
+ * keyed to a dead mailboxId — out of every reader.
538
+ *
539
+ * The removal is batched and the mailbox row goes last, so the row stays
540
+ * `deleting` throughout and an SQS redelivery re-enters the handler, passes
541
+ * the guard above, and resumes where it stopped.
542
+ */
543
+ settleDelete = async (accountId, mailboxId) => {
544
+ await this.mailboxService.deleteMailboxWithMail(accountId, mailboxId);
545
+ this.log.info({
546
+ accountId,
547
+ mailboxId,
548
+ intent: "delete",
549
+ from: MailboxSyncStatus.deleting,
550
+ outcome: "settled",
551
+ }, "Removed folder and its mail");
552
+ };
553
+ /**
554
+ * T9: the server refused the delete. Nothing is unwound — DELETE is
555
+ * all-or-nothing, so `fullPath` is still what the server holds, and no rename
556
+ * target is written, which is how the client tells a failed delete from a
557
+ * failed rename.
558
+ */
559
+ failDelete = async (accountId, mailboxId, reason) => {
560
+ const failed = await this.mailboxService.transition(accountId, mailboxId, {
561
+ from: [MailboxSyncStatus.deleting],
562
+ to: MailboxSyncStatus.failed,
563
+ set: { syncFailureReason: reason },
564
+ });
565
+ if (!failed)
566
+ throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
567
+ this.log.info({
568
+ accountId,
569
+ mailboxId,
570
+ intent: "delete",
571
+ from: MailboxSyncStatus.deleting,
572
+ to: MailboxSyncStatus.failed,
573
+ outcome: "refused",
574
+ }, "Recorded a refused delete");
575
+ };
576
+ }
@@ -0,0 +1,52 @@
1
+ import type { IMailboxRepository, MailboxItem } from "@remit/data-ports";
2
+ export declare const isNotFoundError: (error: unknown) => boolean;
3
+ /**
4
+ * Every state a mailbox row can carry, for a transition whose caller decided
5
+ * against none of them.
6
+ *
7
+ * A transition predicate is supposed to name the state the caller read
8
+ * (docs/architecture/folder-rename-and-delete.md D3). What still uses this one
9
+ * is the delete path — its intent recorder and its settle are today's
10
+ * unconditional writes, moved onto the only door that can write a folder state
11
+ * at all, and #362 narrows each to the from-set its transition-table row
12
+ * allows, which is also where its loser gets a 409. The rename path is already
13
+ * narrowed: `INTENT_RECORDABLE_FROM` for the intent, and `pending` plus the
14
+ * recorded target for the settle.
15
+ */
16
+ export declare const EVERY_MAILBOX_STATE: readonly ["synced", "pending", "failed", "deleting"];
17
+ /**
18
+ * Whether a folder mutation is in flight, so nothing else may touch the folder
19
+ * over IMAP.
20
+ *
21
+ * It used to be spelled as a presence test — is the folder off the server —
22
+ * and that is no longer what it asks. Under D2 `fullPath` is always a path the
23
+ * server holds, rename target and all, so a rename-pending folder *is* on the
24
+ * server; what the two states share is that a worker owns the folder until its
25
+ * mutation settles. `pending` and `deleting` are written by the request itself,
26
+ * before the event that resolves it is enqueued, so neither can be observed
27
+ * unless a create or rename has yet to land or a delete has been asked for.
28
+ *
29
+ * This is the terminal test for a sync event whose folder is not the sync's to
30
+ * touch. The row's absence alone does not decide it, at either end: on the way
31
+ * out the row is removed only after the IMAP folder is, so an event that fails
32
+ * against the server still sees a live row for as long as that write takes; on
33
+ * the way in the row exists before the folder does.
34
+ *
35
+ * `failed` is deliberately not one of them. The invariant is that a `failed`
36
+ * row's folder exists at `fullPath` and the last rename or delete intent did
37
+ * not land — so the folder holds the user's mail and syncing it is right.
38
+ * D7 is what upholds that: a create the server refuses leaves no row at all,
39
+ * rather than a `failed` one standing for a folder that was never made. Reading
40
+ * `failed` as off-server would stop syncing a live folder permanently and
41
+ * silently, which is worse than the bounded stall it would remove.
42
+ *
43
+ * A rename-pending folder is skipped even though its `fullPath` is live. That
44
+ * is conservative rather than necessary (D12): it keeps this a single
45
+ * expression and costs a few seconds of sync for one folder.
46
+ *
47
+ * Mailbox management is judged differently — it is what establishes and removes
48
+ * the folder, so it terminates on a not-found row alone.
49
+ */
50
+ export declare const isFolderMutationInFlight: (mailbox: Pick<MailboxItem, "syncStatus">) => boolean;
51
+ /** {@link isFolderMutationInFlight} for a mailbox that has to be read first, an absent row included. */
52
+ export declare const isMailboxMutationInFlight: (mailboxService: Pick<IMailboxRepository, "get">, accountId: string, mailboxId: string) => Promise<boolean>;