@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
@@ -1,838 +0,0 @@
1
- import type { IMailboxRepository, MailboxItem } from "@remit/data-ports";
2
- import { NotFoundError } from "@remit/data-ports/errors";
3
- import { rebaseMailboxPath } from "@remit/data-ports/mailbox-name";
4
- import { MailboxSyncStatus } from "@remit/domain-enums";
5
- import { recordedByRename } from "./mailbox-intent.js";
6
- import { isNotFoundError } from "./mailbox-presence.js";
7
- import {
8
- FolderGoneUpstreamError,
9
- FolderRenameSettleError,
10
- isMailboxAbsentUpstream,
11
- } from "./mailbox-upstream.js";
12
- import type { FlatMailboxInfo, IImapConnection } from "./types.js";
13
-
14
- /**
15
- * Input for creating a mailbox
16
- */
17
- export interface CreateMailboxInput {
18
- accountId: string;
19
- path: string;
20
- subscribe?: boolean;
21
- }
22
-
23
- /**
24
- * Input for renaming a mailbox
25
- */
26
- export interface RenameMailboxInput {
27
- mailboxId: string;
28
- newPath: string;
29
- }
30
-
31
- /**
32
- * Input for deleting a mailbox
33
- */
34
- export interface DeleteMailboxInput {
35
- mailboxId: string;
36
- force?: boolean;
37
- }
38
-
39
- /**
40
- * Result of syncing mailbox operation to IMAP
41
- */
42
- export interface MailboxManagementSyncResult {
43
- success: boolean;
44
- error?: string;
45
- /**
46
- * Where the folder ended up, on a rename the server confirmed. Absent when
47
- * the recorded intent had already moved on, so no RENAME was issued —
48
- * which is how a caller tells the two apart without a second read.
49
- */
50
- renamed?: { oldPath: string; newPath: string; delimiter: string };
51
- existing?: { mailboxId: string; fullPath: string };
52
- }
53
-
54
- /**
55
- * Logger interface for MailboxManagementService
56
- */
57
- export interface MailboxManagementLogger {
58
- info(obj: Record<string, unknown>, msg: string): void;
59
- error(obj: Record<string, unknown>, msg: string): void;
60
- }
61
-
62
- const noopLogger: MailboxManagementLogger = {
63
- info: () => {},
64
- error: () => {},
65
- };
66
-
67
- /**
68
- * Parse a mailbox path to extract name and parent path
69
- */
70
- export const parseMailboxPath = (
71
- path: string,
72
- delimiter = "/",
73
- ): { name: string; parent: string | null; depth: number } => {
74
- const parts = path.split(delimiter);
75
- return {
76
- name: parts[parts.length - 1],
77
- parent: parts.length > 1 ? parts.slice(0, -1).join(delimiter) : null,
78
- depth: parts.length,
79
- };
80
- };
81
-
82
- /**
83
- * Validate mailbox path for invalid characters and operations
84
- */
85
- export const validateMailboxPath = (path: string): void => {
86
- if (!path || path.trim().length === 0) {
87
- throw new Error("Mailbox path cannot be empty");
88
- }
89
-
90
- // Check for double delimiters
91
- if (path.includes("//")) {
92
- throw new Error("Mailbox path cannot contain empty hierarchy levels");
93
- }
94
-
95
- // Check for leading/trailing delimiters
96
- if (path.startsWith("/") || path.endsWith("/")) {
97
- throw new Error(
98
- "Mailbox path cannot start or end with hierarchy delimiter",
99
- );
100
- }
101
- };
102
-
103
- /**
104
- * Validate that an operation can be performed on a mailbox path
105
- */
106
- export const validateMailboxOperation = (
107
- operation: "delete" | "rename",
108
- path: string,
109
- ): void => {
110
- if (path.toUpperCase() !== "INBOX") return;
111
- // A RENAME of INBOX moves its mail to the new name and leaves an empty INBOX
112
- // behind — a bulk move wearing a rename's clothes, which strands the
113
- // account's inbox appointment and voids the row's UID cursor (D5). The API
114
- // refuses it; this is the backstop for anything that reaches the worker
115
- // another way.
116
- throw new Error(
117
- operation === "delete" ? "Cannot delete INBOX" : "Cannot rename INBOX",
118
- );
119
- };
120
-
121
- /**
122
- * Whether the server's own listing holds a path this code named.
123
- *
124
- * The requested string and the path the server keeps are not the same thing.
125
- * Under a namespace prefix a Dovecot INBOX namespace stores `Projects` as
126
- * `INBOX.Projects`, which is why the settle adopts the path ImapFlow resolved
127
- * rather than the one that was asked for (D2) — and a probe comparing the raw
128
- * request against the listing would read a folder that is plainly there as
129
- * absent. There is no resolved path to adopt on the failure path, so the
130
- * account's own prefix stands in for the normalization.
131
- */
132
- const holdsPath = (
133
- onServer: ReadonlySet<string>,
134
- mailbox: Pick<MailboxItem, "namespacePrefix">,
135
- path: string,
136
- ): boolean => {
137
- if (onServer.has(path)) return true;
138
- const { namespacePrefix } = mailbox;
139
- if (namespacePrefix.length === 0) return false;
140
- if (path.startsWith(namespacePrefix)) return false;
141
- return onServer.has(`${namespacePrefix}${path}`);
142
- };
143
-
144
- /**
145
- * Service for managing mailbox operations (create, rename, delete).
146
- *
147
- * Implements an optimistic local-first pattern:
148
- * 1. Updates are applied locally first
149
- * 2. Changes are queued for IMAP sync via SQS
150
- * 3. Worker processes queue and syncs to IMAP server
151
- */
152
- export class MailboxManagementService {
153
- private log: MailboxManagementLogger;
154
-
155
- constructor(
156
- private mailboxService: IMailboxRepository,
157
- logger?: MailboxManagementLogger,
158
- ) {
159
- this.log = logger ?? noopLogger;
160
- }
161
-
162
- /**
163
- * Settle a create: `pending` with **no** recorded rename target, which is the
164
- * state a create in flight is (D3, D10).
165
- *
166
- * The absence check is what the whole `wherePendingPath` predicate exists
167
- * for. A create whose acknowledgement was lost, redelivered against a row a
168
- * rename has since claimed, passes a `pending`-only guard; the CREATE
169
- * collides, ImapFlow reports the collision as `{created: false}` rather than
170
- * an error, and this write then strands the row `synced` with a rename
171
- * target on it — the seventh combination — leaving the rename's own settle
172
- * with nothing to match and the RENAME never running.
173
- *
174
- * A null means the row is gone or the intent moved on. The handlers'
175
- * #289-class terminal guards classify the NotFoundError as the user having
176
- * deleted the folder mid-sync.
177
- */
178
- private settleCreate = async (
179
- accountId: string,
180
- mailboxId: string,
181
- confirmedPath?: string,
182
- ): Promise<void> => {
183
- const settled = await this.mailboxService.transition(accountId, mailboxId, {
184
- from: [MailboxSyncStatus.pending],
185
- wherePendingPath: null,
186
- to: MailboxSyncStatus.synced,
187
- set: confirmedPath !== undefined ? { fullPath: confirmedPath } : {},
188
- });
189
- if (settled) return;
190
- // Either the row is gone, or a rename claimed it while this create was in
191
- // flight. Both resolve the job: the folder exists, and the rename is the
192
- // live intent.
193
- const current = await this.mailboxService
194
- .get(accountId, mailboxId)
195
- .catch((error: unknown) => {
196
- if (isNotFoundError(error)) return undefined;
197
- throw error;
198
- });
199
- if (!current) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
200
- this.log.info(
201
- {
202
- accountId,
203
- mailboxId,
204
- intent: "create",
205
- from: current.syncStatus,
206
- outcome: "superseded",
207
- },
208
- "Folder create superseded",
209
- );
210
- };
211
-
212
- /**
213
- * The rows one rename intent recorded, each with the path it settles to.
214
- *
215
- * The settle deliberately does not re-resolve the subtree (D15): membership
216
- * changes between the intent and the settle — the sweep inserts a row,
217
- * another client deletes a descendant — and an all-or-nothing settle then
218
- * either throws, poisoning the account's FIFO group (#339), or resolves
219
- * leaving every row `pending` forever with no route out, because `failed` is
220
- * only reachable from a settle that ran.
221
- *
222
- * So the rows identify themselves, by the pair `recordedByRename` tests: a
223
- * target at or under the rename's target is not on its own enough, because a
224
- * second folder renamed under the same branch would be claimed by a rename
225
- * that never touched it. A row that appeared afterwards carries no such pair
226
- * and is not touched.
227
- *
228
- * `confirmedPath` re-prefixes each recorded target off the path the rename
229
- * actually resolved to, so a normalization the requested string did not carry
230
- * moves the whole branch rather than the folder alone.
231
- *
232
- * `delimiter` is the renamed folder's own, read from the row the caller
233
- * already holds — deriving one from whatever the listing happens to contain
234
- * strands every descendant when that row has gone.
235
- */
236
- private intentCarryingRows = async (
237
- accountId: string,
238
- oldPath: string,
239
- target: string,
240
- confirmed: string,
241
- delimiter: string,
242
- ): Promise<{ row: MailboxItem; confirmedPath: string }[]> => {
243
- const pending = await this.mailboxService.findBySyncStatus(
244
- accountId,
245
- MailboxSyncStatus.pending,
246
- );
247
- const carrying: { row: MailboxItem; confirmedPath: string }[] = [];
248
- for (const row of pending) {
249
- if (!recordedByRename(row, oldPath, target, delimiter)) continue;
250
- const confirmedPath = rebaseMailboxPath(
251
- row.pendingPath as string,
252
- target,
253
- confirmed,
254
- delimiter,
255
- );
256
- if (confirmedPath === undefined) continue;
257
- carrying.push({ row, confirmedPath });
258
- }
259
- return carrying;
260
- };
261
-
262
- /**
263
- * Sync a CREATE operation to IMAP.
264
- * Called by worker after dequeuing MAILBOX_CREATE event.
265
- *
266
- * @param accountId - Account that owns the mailbox (tenant scope)
267
- * @param mailboxId - ID of the mailbox to create
268
- * @param path - Path of the mailbox to create
269
- * @param getConnection - Factory to get IMAP connection
270
- * @param subscribe - Whether to subscribe after creation
271
- */
272
- syncCreate = async (
273
- accountId: string,
274
- mailboxId: string,
275
- path: string,
276
- getConnection: () => Promise<IImapConnection>,
277
- subscribe?: boolean,
278
- ): Promise<MailboxManagementSyncResult> => {
279
- const connection = await getConnection();
280
-
281
- const result = await connection.createMailbox(path);
282
-
283
- // The server is free to materialize the requested path under a namespace
284
- // prefix — a Dovecot INBOX namespace turns "Notifications" into
285
- // "INBOX/Notifications". `mailboxCreate` reports the canonical path the
286
- // server assigned; adopt it as this row's identity so the next mailbox
287
- // reconcile matches it by fullPath and updates it in place, rather than
288
- // inserting a fresh row for the prefixed path and deleting this one — which
289
- // would strand every filter and placement that references this mailboxId.
290
- // Fall back to the requested path when the result carries no usable path,
291
- // so a thin result never blanks the row's fullPath to undefined.
292
- const serverPath =
293
- typeof result.path === "string" && result.path.length > 0
294
- ? result.path
295
- : path;
296
- const confirmedPath = serverPath !== path ? serverPath : undefined;
297
-
298
- this.log.info(
299
- { mailboxId, path, serverPath, created: result.created },
300
- "Created mailbox on IMAP server",
301
- );
302
-
303
- if (subscribe) {
304
- await connection.subscribeMailbox(serverPath);
305
- this.log.info({ mailboxId, path: serverPath }, "Subscribed to mailbox");
306
- }
307
-
308
- const holder =
309
- confirmedPath === undefined
310
- ? null
311
- : await this.mailboxService.findByPath(accountId, confirmedPath);
312
- if (holder && holder.mailboxId !== mailboxId) {
313
- await this.mailboxService.deleteMailboxWithMail(accountId, mailboxId);
314
- this.log.info(
315
- {
316
- accountId,
317
- mailboxId,
318
- intent: "create",
319
- path,
320
- serverPath,
321
- existingMailboxId: holder.mailboxId,
322
- outcome: "already-exists",
323
- },
324
- "Folder already exists at the path the server resolved",
325
- );
326
- return {
327
- success: true,
328
- existing: { mailboxId: holder.mailboxId, fullPath: holder.fullPath },
329
- };
330
- }
331
-
332
- // Refresh mailbox list to get UIDVALIDITY and other attributes
333
- const mailboxes = await connection.listMailboxes();
334
- const mailboxInfo = mailboxes.find((m) => m.fullPath === serverPath);
335
-
336
- if (mailboxInfo) {
337
- // Open the mailbox to get UIDVALIDITY and other status info
338
- const status = await connection.openBox(serverPath, true);
339
-
340
- // Two writes where there was one, and the settle is deliberately the
341
- // second: counters are ordinary metadata and stay off the transition,
342
- // whose whole point is that its write-set is the state (D3). A crash in
343
- // between leaves the row `pending` with fresh counters, and the row not
344
- // yet settled is what keeps the queue message unacked — the redelivery
345
- // re-issues CREATE, the server answers ALREADYEXISTS, and #339's
346
- // classification settles it. Settling first would be the unsafe order.
347
- await this.mailboxService.update(accountId, mailboxId, {
348
- uidValidity: status.uidvalidity,
349
- uidNext: status.uidnext,
350
- messageCount: status.messages.total,
351
- });
352
- await this.settleCreate(accountId, mailboxId, confirmedPath);
353
-
354
- await connection.closeBox();
355
- } else {
356
- // Mark as synced even if we couldn't get full info
357
- await this.settleCreate(accountId, mailboxId, confirmedPath);
358
- }
359
-
360
- return { success: true };
361
- };
362
-
363
- /**
364
- * Sync a RENAME operation to IMAP and settle the rows that recorded it (T5).
365
- * Called by worker after dequeuing MAILBOX_RENAME event.
366
- *
367
- * Guarded on the intent, not on the event (D10): the row must still be
368
- * `pending` with this event's target recorded, or the rename resolves with no
369
- * IMAP call at all. That is what stops a redelivered MAILBOX_RENAME re-issuing
370
- * a RENAME from a path that has already moved.
371
- *
372
- * @param accountId - Account that owns the mailbox (tenant scope)
373
- * @param mailboxId - ID of the mailbox to rename
374
- * @param oldPath - Path the intent was recorded against
375
- * @param newPath - Target the intent recorded
376
- * @param getConnection - Factory to get IMAP connection
377
- */
378
- syncRename = async (
379
- accountId: string,
380
- mailboxId: string,
381
- oldPath: string,
382
- newPath: string,
383
- getConnection: () => Promise<IImapConnection>,
384
- ): Promise<MailboxManagementSyncResult> => {
385
- const standing = await this.renameIntentStanding(
386
- accountId,
387
- mailboxId,
388
- newPath,
389
- );
390
- if (!standing) return { success: true };
391
-
392
- const connection = await getConnection();
393
- const delimiter = standing.hierarchyDelimiter;
394
-
395
- const confirmed = await this.issueRename(
396
- connection,
397
- standing,
398
- accountId,
399
- mailboxId,
400
- oldPath,
401
- newPath,
402
- );
403
-
404
- this.log.info(
405
- { accountId, mailboxId, intent: "rename", oldPath, newPath, confirmed },
406
- "Renamed mailbox on IMAP server",
407
- );
408
-
409
- // The server has executed the rename. Nothing after this point may reach
410
- // the caller's failure path: `failRename` would mark the rows refused for
411
- // a rename that landed, and the folder would sit at `failed` offering a
412
- // retry of something already done. A settle that cannot finish is wrapped
413
- // so the caller can tell the two apart, and rethrown so SQS redelivers —
414
- // the rows are still `pending` with their targets, so the redelivery's
415
- // guard passes and the settle runs again.
416
- await this.settleRenameIntent(
417
- accountId,
418
- mailboxId,
419
- oldPath,
420
- newPath,
421
- confirmed,
422
- delimiter,
423
- ).catch((error: unknown) => {
424
- throw new FolderRenameSettleError(mailboxId, confirmed, error);
425
- });
426
-
427
- return {
428
- success: true,
429
- renamed: { oldPath, newPath: confirmed, delimiter },
430
- };
431
- };
432
-
433
- /**
434
- * Issue the RENAME and read back the path it resolved to.
435
- *
436
- * A `NONEXISTENT` here has three readings, and only one of them may destroy
437
- * anything. Either another client deleted the folder; or this rename already
438
- * landed and only its settle was lost, in which case the folder is alive at
439
- * the target; or the server is saying something this code cannot classify.
440
- * The listing separates the first two, and everything it cannot answer is a
441
- * refused rename (T6) — a rename that failed costs the user a retry, while
442
- * reading an unclear answer as a delete costs them the folder's mail.
443
- *
444
- * So the caller is told which of the two it is by the *kind* of error, never
445
- * by re-reading the server's code: only `FolderGoneUpstreamError` removes a
446
- * folder, and it is raised exactly where the listing held neither path.
447
- */
448
- private issueRename = async (
449
- connection: IImapConnection,
450
- standing: MailboxItem,
451
- accountId: string,
452
- mailboxId: string,
453
- oldPath: string,
454
- newPath: string,
455
- ): Promise<string> => {
456
- const result = await connection
457
- .renameMailbox(oldPath, newPath)
458
- .catch(async (error: unknown) => {
459
- if (!isMailboxAbsentUpstream(error)) throw error;
460
- const listed = await connection
461
- .listMailboxes()
462
- .catch((): FlatMailboxInfo[] | undefined => undefined);
463
- // A listing that cannot be read answers nothing, so it decides
464
- // nothing: the rename is refused and the folder is left alone.
465
- if (!listed) throw error;
466
-
467
- const onServer = new Set(listed.map((mailbox) => mailbox.fullPath));
468
- if (holdsPath(onServer, standing, newPath)) {
469
- this.log.info(
470
- { accountId, mailboxId, intent: "rename", oldPath, newPath },
471
- "Source folder gone and the target is on the server: this rename already landed",
472
- );
473
- return undefined;
474
- }
475
- if (holdsPath(onServer, standing, oldPath)) throw error;
476
-
477
- throw new FolderGoneUpstreamError(mailboxId, oldPath, error);
478
- });
479
-
480
- // ImapFlow's own normalization of the requested path — namespace prefix
481
- // applied, special names resolved, delimiter joined. IMAP's RENAME reply
482
- // carries no path, so this is not the server echoing a name back and it
483
- // cannot detect a server that stored a different one; adopting it is still
484
- // right, because it fixes the prefix and delimiter drift the requested
485
- // string carries (D2).
486
- return typeof result?.newPath === "string" && result.newPath.length > 0
487
- ? result.newPath
488
- : newPath;
489
- };
490
-
491
- /**
492
- * Whether the rename this event was enqueued for is still the folder's live
493
- * intent. `pending` alone cannot answer it — that state covers a create in
494
- * flight too — so the recorded target is half the predicate, and it is the
495
- * same predicate the settling write carries, so the guard and the write
496
- * cannot disagree between the read and the write.
497
- */
498
- private renameIntentStanding = async (
499
- accountId: string,
500
- mailboxId: string,
501
- newPath: string,
502
- ): Promise<MailboxItem | undefined> => {
503
- const row = await this.mailboxService
504
- .get(accountId, mailboxId)
505
- .catch((error: unknown) => {
506
- if (isNotFoundError(error)) return undefined;
507
- throw error;
508
- });
509
- if (
510
- row &&
511
- row.syncStatus === MailboxSyncStatus.pending &&
512
- row.pendingPath === newPath
513
- ) {
514
- return row;
515
- }
516
- this.log.info(
517
- {
518
- accountId,
519
- mailboxId,
520
- intent: "rename",
521
- from: row?.syncStatus,
522
- newPath,
523
- outcome: row ? "superseded" : "already-settled",
524
- },
525
- "Skipping MAILBOX_RENAME: the recorded intent has moved on",
526
- );
527
- return undefined;
528
- };
529
-
530
- /**
531
- * T5, per row (D15): each intent-carrying row adopts its own confirmed path,
532
- * drops the recorded target and settles `synced`, on its own conditional
533
- * write. A row another client moved in between fails its predicate and is
534
- * skipped rather than overwritten; a row that is gone is skipped for free.
535
- *
536
- * **The named folder is written last, and that ordering is load-bearing.**
537
- * The redelivery's guard reads that row and nothing else, so it is the
538
- * witness for the whole settle: while it still carries the intent, a
539
- * redelivery re-enters and finishes whatever the last attempt left. Settling
540
- * it first would ack a run that died part-way through its descendants, and
541
- * those rows would stay `pending` forever — invisible to message sync, and
542
- * with no route out, because an intent may only be recorded from `synced` or
543
- * `failed`, so neither a retry nor a dismissal can reach them.
544
- */
545
- private settleRenameIntent = async (
546
- accountId: string,
547
- mailboxId: string,
548
- oldPath: string,
549
- target: string,
550
- confirmed: string,
551
- delimiter: string,
552
- ): Promise<void> => {
553
- const carrying = await this.intentCarryingRows(
554
- accountId,
555
- oldPath,
556
- target,
557
- confirmed,
558
- delimiter,
559
- );
560
- const ordered = [
561
- ...carrying.filter((entry) => entry.row.mailboxId !== mailboxId),
562
- ...carrying.filter((entry) => entry.row.mailboxId === mailboxId),
563
- ];
564
- for (const { row, confirmedPath } of ordered) {
565
- const settled = await this.mailboxService.transition(
566
- accountId,
567
- row.mailboxId,
568
- {
569
- from: [MailboxSyncStatus.pending],
570
- wherePendingPath: row.pendingPath,
571
- to: MailboxSyncStatus.synced,
572
- set: { fullPath: confirmedPath },
573
- },
574
- );
575
- this.log.info(
576
- {
577
- accountId,
578
- mailboxId: row.mailboxId,
579
- intent: "rename",
580
- from: MailboxSyncStatus.pending,
581
- to: MailboxSyncStatus.synced,
582
- fullPath: confirmedPath,
583
- outcome: settled ? "settled" : "superseded",
584
- },
585
- "Settled renamed folder",
586
- );
587
- }
588
- };
589
-
590
- /**
591
- * T6: the rename did not land, so every row that recorded it goes to
592
- * `failed`, keeping its target so the client can name what the rename was
593
- * aiming at and offer a retry. Nothing is restored — `fullPath` was never
594
- * written, which is the whole point of recording the target instead (D2).
595
- */
596
- failRename = async (
597
- accountId: string,
598
- mailboxId: string,
599
- oldPath: string,
600
- newPath: string,
601
- reason: string,
602
- ): Promise<void> => {
603
- const root = await this.mailboxService
604
- .get(accountId, mailboxId)
605
- .catch((error: unknown) => {
606
- if (isNotFoundError(error)) return undefined;
607
- throw error;
608
- });
609
- if (!root) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
610
-
611
- const carrying = await this.intentCarryingRows(
612
- accountId,
613
- oldPath,
614
- newPath,
615
- newPath,
616
- root.hierarchyDelimiter,
617
- );
618
- // The row is there and no row carries the intent, so this refusal has
619
- // nothing to record. It must not be reported as a missing mailbox: the
620
- // caller's #289-class guard reads a NotFoundError as the user having
621
- // deleted the folder and acks the message, which would swallow the IMAP
622
- // error that brought us here and leave the subtree `pending`. Say so and
623
- // let the caller rethrow what actually failed.
624
- if (carrying.length === 0) {
625
- this.log.error(
626
- {
627
- accountId,
628
- mailboxId,
629
- intent: "rename",
630
- from: root.syncStatus,
631
- oldPath,
632
- newPath,
633
- outcome: "superseded",
634
- },
635
- "Refused rename recorded nothing: no row carries this intent",
636
- );
637
- return;
638
- }
639
- for (const { row } of carrying) {
640
- const failed = await this.mailboxService.transition(
641
- accountId,
642
- row.mailboxId,
643
- {
644
- from: [MailboxSyncStatus.pending],
645
- wherePendingPath: row.pendingPath,
646
- to: MailboxSyncStatus.failed,
647
- set: { syncFailureReason: reason },
648
- },
649
- );
650
- this.log.info(
651
- {
652
- accountId,
653
- mailboxId: row.mailboxId,
654
- intent: "rename",
655
- from: MailboxSyncStatus.pending,
656
- to: MailboxSyncStatus.failed,
657
- outcome: failed ? "refused" : "superseded",
658
- },
659
- "Recorded a refused rename",
660
- );
661
- }
662
- };
663
-
664
- /**
665
- * The folder the rename was to move was found gone from the server, and
666
- * the target was not there either, so it was deleted by another client
667
- * mid-rename (D8). Every row that recorded this rename's intent — the
668
- * root and each descendant — goes with its mail, the same walk `failRename`
669
- * uses: deleting the root alone strands every descendant `pending` with
670
- * nothing left to revisit it, which is the orphaning bug this closes.
671
- */
672
- abandonRenameSubtree = async (
673
- accountId: string,
674
- mailboxId: string,
675
- oldPath: string,
676
- newPath: string,
677
- ): Promise<void> => {
678
- const root = await this.mailboxService
679
- .get(accountId, mailboxId)
680
- .catch((error: unknown) => {
681
- if (isNotFoundError(error)) return undefined;
682
- throw error;
683
- });
684
- if (!root) {
685
- this.log.info(
686
- { accountId, mailboxId, intent: "rename", outcome: "already-settled" },
687
- "Nothing to abandon: the folder row is already gone",
688
- );
689
- return;
690
- }
691
-
692
- const carrying = await this.intentCarryingRows(
693
- accountId,
694
- oldPath,
695
- newPath,
696
- newPath,
697
- root.hierarchyDelimiter,
698
- );
699
- // A removal that throws part-way abandons the rest into a redelivery, and
700
- // that is safe rather than merely tolerable: `deleteMailboxWithMail` is
701
- // idempotent — it returns without touching anything when the row is gone,
702
- // and resumes from whatever is left when it is not, because the mailbox
703
- // row is removed last. The rows still standing carry the intent, so the
704
- // next attempt resolves the same set and finishes it.
705
- for (const { row } of carrying) {
706
- await this.mailboxService.deleteMailboxWithMail(accountId, row.mailboxId);
707
- this.log.info(
708
- {
709
- accountId,
710
- mailboxId: row.mailboxId,
711
- intent: "rename",
712
- from: MailboxSyncStatus.pending,
713
- to: "deleted",
714
- },
715
- "Source mailbox not found, deleted local folder and its mail",
716
- );
717
- }
718
- };
719
-
720
- /**
721
- * Sync a DELETE operation to IMAP.
722
- * Called by worker after dequeuing MAILBOX_DELETE event.
723
- *
724
- * @param accountId - Account that owns the mailbox (tenant scope)
725
- * @param mailboxId - ID of the mailbox to delete
726
- * @param path - Path of the mailbox to delete
727
- * @param getConnection - Factory to get IMAP connection
728
- */
729
- syncDelete = async (
730
- accountId: string,
731
- mailboxId: string,
732
- path: string,
733
- getConnection: () => Promise<IImapConnection>,
734
- ): Promise<MailboxManagementSyncResult> => {
735
- if (!(await this.deleteIntentStanding(accountId, mailboxId))) {
736
- return { success: true };
737
- }
738
-
739
- const connection = await getConnection();
740
-
741
- await connection.deleteMailbox(path);
742
-
743
- this.log.info(
744
- { accountId, mailboxId, intent: "delete", path },
745
- "Deleted mailbox on IMAP server",
746
- );
747
-
748
- await this.settleDelete(accountId, mailboxId);
749
-
750
- return { success: true };
751
- };
752
-
753
- /**
754
- * Whether the delete this event was enqueued for is still the folder's live
755
- * intent (D10). Anything else resolves the job with no IMAP call — never a
756
- * throw, which would hold back every later job in the account's FIFO group
757
- * (#339).
758
- */
759
- private deleteIntentStanding = async (
760
- accountId: string,
761
- mailboxId: string,
762
- ): Promise<boolean> => {
763
- const row = await this.mailboxService
764
- .get(accountId, mailboxId)
765
- .catch((error: unknown) => {
766
- if (isNotFoundError(error)) return undefined;
767
- throw error;
768
- });
769
- if (row?.syncStatus === MailboxSyncStatus.deleting) return true;
770
- this.log.info(
771
- {
772
- accountId,
773
- mailboxId,
774
- intent: "delete",
775
- from: row?.syncStatus,
776
- outcome: row ? "superseded" : "already-settled",
777
- },
778
- "Skipping MAILBOX_DELETE: the recorded intent has moved on",
779
- );
780
- return false;
781
- };
782
-
783
- /**
784
- * T8: the confirmed delete takes the folder's local mail with it (D8).
785
- * Removing the row alone leaves every `message` and `thread_message` row
786
- * keyed to a dead mailboxId — out of every reader.
787
- *
788
- * The removal is batched and the mailbox row goes last, so the row stays
789
- * `deleting` throughout and an SQS redelivery re-enters the handler, passes
790
- * the guard above, and resumes where it stopped.
791
- */
792
- settleDelete = async (
793
- accountId: string,
794
- mailboxId: string,
795
- ): Promise<void> => {
796
- await this.mailboxService.deleteMailboxWithMail(accountId, mailboxId);
797
- this.log.info(
798
- {
799
- accountId,
800
- mailboxId,
801
- intent: "delete",
802
- from: MailboxSyncStatus.deleting,
803
- outcome: "settled",
804
- },
805
- "Removed folder and its mail",
806
- );
807
- };
808
-
809
- /**
810
- * T9: the server refused the delete. Nothing is unwound — DELETE is
811
- * all-or-nothing, so `fullPath` is still what the server holds, and no rename
812
- * target is written, which is how the client tells a failed delete from a
813
- * failed rename.
814
- */
815
- failDelete = async (
816
- accountId: string,
817
- mailboxId: string,
818
- reason: string,
819
- ): Promise<void> => {
820
- const failed = await this.mailboxService.transition(accountId, mailboxId, {
821
- from: [MailboxSyncStatus.deleting],
822
- to: MailboxSyncStatus.failed,
823
- set: { syncFailureReason: reason },
824
- });
825
- if (!failed) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
826
- this.log.info(
827
- {
828
- accountId,
829
- mailboxId,
830
- intent: "delete",
831
- from: MailboxSyncStatus.deleting,
832
- to: MailboxSyncStatus.failed,
833
- outcome: "refused",
834
- },
835
- "Recorded a refused delete",
836
- );
837
- };
838
- }