@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
@@ -1,664 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import type { IMailboxRepository, MailboxItem } from "@remit/data-ports";
4
- import { MailboxSyncStatus } from "@remit/domain-enums";
5
- import { MailboxManagementService } from "./mailbox-management.js";
6
- import {
7
- FolderGoneUpstreamError,
8
- FolderRenameSettleError,
9
- } from "./mailbox-upstream.js";
10
- import type { IImapConnection } from "./types.js";
11
-
12
- /**
13
- * A folder mid-rename: the confirmed path the server still holds, and the
14
- * target the intent recorded (D2). The settle's row set is exactly the rows
15
- * carrying such a target, which is what makes it independent of whatever the
16
- * subtree looks like by the time the RENAME lands (D15).
17
- */
18
- const renaming = (
19
- mailboxId: string,
20
- fullPath: string,
21
- pendingPath: string,
22
- ): MailboxItem =>
23
- ({
24
- mailboxId,
25
- accountId: "acc-1",
26
- fullPath,
27
- hierarchyDelimiter: "/",
28
- namespacePrefix: "",
29
- syncStatus: MailboxSyncStatus.pending,
30
- pendingPath,
31
- }) as MailboxItem;
32
-
33
- const settled = (
34
- mailboxId: string,
35
- fullPath: string,
36
- syncStatus: MailboxItem["syncStatus"] = MailboxSyncStatus.synced,
37
- ): MailboxItem =>
38
- ({
39
- mailboxId,
40
- accountId: "acc-1",
41
- fullPath,
42
- hierarchyDelimiter: "/",
43
- namespacePrefix: "",
44
- syncStatus,
45
- }) as MailboxItem;
46
-
47
- const store = (rows: MailboxItem[], vanishBeforeSettle: string[] = []) => {
48
- const byId = new Map(rows.map((r) => [r.mailboxId, { ...r }]));
49
-
50
- const repo: Pick<
51
- IMailboxRepository,
52
- "get" | "update" | "transition" | "findBySyncStatus"
53
- > = {
54
- get: (async (_accountId: string, mailboxId: string) => {
55
- const found = byId.get(mailboxId as string);
56
- if (!found) {
57
- throw Object.assign(new Error(`Mailbox not found: ${mailboxId}`), {
58
- name: "NotFoundError",
59
- });
60
- }
61
- return found;
62
- }) as IMailboxRepository["get"],
63
- update: async (_accountId, mailboxId, patch) => {
64
- const existing = byId.get(mailboxId);
65
- if (!existing) {
66
- throw Object.assign(new Error(`Mailbox not found: ${mailboxId}`), {
67
- name: "NotFoundError",
68
- });
69
- }
70
- const next = { ...existing, ...patch } as MailboxItem;
71
- byId.set(mailboxId, next);
72
- return next;
73
- },
74
- transition: async (_accountId, mailboxId, intent) => {
75
- const existing = byId.get(mailboxId);
76
- if (!existing) return null;
77
- if (!intent.from.includes(existing.syncStatus)) return null;
78
- if (
79
- intent.wherePendingPath !== undefined &&
80
- (intent.wherePendingPath ?? undefined) !== existing.pendingPath
81
- ) {
82
- return null;
83
- }
84
- const keepsTarget =
85
- intent.to === MailboxSyncStatus.pending ||
86
- intent.to === MailboxSyncStatus.failed;
87
- const next = {
88
- ...existing,
89
- ...(intent.set?.fullPath !== undefined
90
- ? { fullPath: intent.set.fullPath }
91
- : {}),
92
- syncStatus: intent.to,
93
- } as MailboxItem;
94
- if (!keepsTarget) delete (next as { pendingPath?: string }).pendingPath;
95
- byId.set(mailboxId, next);
96
- return next;
97
- },
98
- findBySyncStatus: async (_accountId, syncStatus) => {
99
- const found = [...byId.values()].filter(
100
- (r) => r.syncStatus === syncStatus,
101
- );
102
- for (const mailboxId of vanishBeforeSettle) byId.delete(mailboxId);
103
- return found;
104
- },
105
- };
106
-
107
- return {
108
- repo: repo as IMailboxRepository,
109
- rowOf: (mailboxId: string) => byId.get(mailboxId),
110
- };
111
- };
112
-
113
- const connection = (newPath?: string): IImapConnection =>
114
- ({
115
- renameMailbox: async (_old: string, requested: string) => ({
116
- path: _old,
117
- newPath: newPath ?? requested,
118
- }),
119
- listMailboxes: async () => [],
120
- }) as unknown as IImapConnection;
121
-
122
- const refusing = (
123
- error: Error,
124
- listed: string[] | "unreadable" = [],
125
- ): IImapConnection =>
126
- ({
127
- renameMailbox: async () => {
128
- throw error;
129
- },
130
- listMailboxes: async () => {
131
- if (listed === "unreadable") throw new Error("IMAP connection lost");
132
- return listed.map((fullPath) => ({ fullPath, delimiter: "/" }));
133
- },
134
- }) as unknown as IImapConnection;
135
-
136
- const nonexistent = (): Error =>
137
- Object.assign(new Error("Command failed"), {
138
- serverResponseCode: "NONEXISTENT",
139
- responseText: "Mailbox doesn't exist: Work",
140
- });
141
-
142
- describe("MailboxManagementService.syncRename — settling one intent", () => {
143
- it("adopts the confirmed path on the folder and every row that carried the intent", async () => {
144
- const { repo, rowOf } = store([
145
- renaming("mbx-parent", "Work", "Projects"),
146
- renaming("mbx-child", "Work/2026", "Projects/2026"),
147
- renaming("mbx-grandchild", "Work/2026/Q1", "Projects/2026/Q1"),
148
- ]);
149
- const service = new MailboxManagementService(repo);
150
-
151
- const result = await service.syncRename(
152
- "acc-1",
153
- "mbx-parent",
154
- "Work",
155
- "Projects",
156
- async () => connection(),
157
- );
158
-
159
- assert.equal(result.success, true);
160
- for (const [id, path] of [
161
- ["mbx-parent", "Projects"],
162
- ["mbx-child", "Projects/2026"],
163
- ["mbx-grandchild", "Projects/2026/Q1"],
164
- ] as const) {
165
- assert.equal(rowOf(id)?.fullPath, path);
166
- assert.equal(rowOf(id)?.syncStatus, MailboxSyncStatus.synced);
167
- assert.equal(rowOf(id)?.pendingPath, undefined);
168
- }
169
- });
170
-
171
- it("adopts the path ImapFlow resolved in preference to the requested one", async () => {
172
- // Not the server echoing a name back — IMAP's RENAME reply carries none.
173
- // It is ImapFlow's own normalization, with the namespace prefix applied,
174
- // and adopting it is what stops the row and the server drifting apart.
175
- const { repo, rowOf } = store([
176
- renaming("mbx-parent", "Work", "Projects"),
177
- renaming("mbx-child", "Work/2026", "Projects/2026"),
178
- ]);
179
- const service = new MailboxManagementService(repo);
180
-
181
- await service.syncRename(
182
- "acc-1",
183
- "mbx-parent",
184
- "Work",
185
- "Projects",
186
- async () => connection("INBOX/Projects"),
187
- );
188
-
189
- assert.equal(rowOf("mbx-parent")?.fullPath, "INBOX/Projects");
190
- assert.equal(rowOf("mbx-child")?.fullPath, "INBOX/Projects/2026");
191
- });
192
-
193
- it("leaves a folder the sweep inserted under the branch after the intent alone", async () => {
194
- // D15: the settle applies to the rows that recorded the intent, and those
195
- // rows identify themselves. Re-resolving the subtree would either throw —
196
- // poisoning the account's FIFO group — or leave every row pending forever.
197
- const { repo, rowOf } = store([
198
- renaming("mbx-parent", "Work", "Projects"),
199
- settled("mbx-interloper", "Projects/Fresh"),
200
- ]);
201
- const service = new MailboxManagementService(repo);
202
-
203
- const result = await service.syncRename(
204
- "acc-1",
205
- "mbx-parent",
206
- "Work",
207
- "Projects",
208
- async () => connection(),
209
- );
210
-
211
- assert.equal(result.success, true);
212
- assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.synced);
213
- assert.equal(rowOf("mbx-interloper")?.fullPath, "Projects/Fresh");
214
- assert.equal(rowOf("mbx-interloper")?.syncStatus, MailboxSyncStatus.synced);
215
- });
216
-
217
- it("settles the rest when a descendant is deleted after the intent", async () => {
218
- const { repo, rowOf } = store(
219
- [
220
- renaming("mbx-parent", "Work", "Projects"),
221
- renaming("mbx-gone", "Work/Gone", "Projects/Gone"),
222
- renaming("mbx-child", "Work/2026", "Projects/2026"),
223
- ],
224
- ["mbx-gone"],
225
- );
226
- const service = new MailboxManagementService(repo);
227
-
228
- const result = await service.syncRename(
229
- "acc-1",
230
- "mbx-parent",
231
- "Work",
232
- "Projects",
233
- async () => connection(),
234
- );
235
-
236
- assert.equal(result.success, true);
237
- assert.equal(rowOf("mbx-parent")?.fullPath, "Projects");
238
- assert.equal(rowOf("mbx-child")?.fullPath, "Projects/2026");
239
- });
240
-
241
- it("does not touch a folder whose own rename is aimed somewhere else", async () => {
242
- const { repo, rowOf } = store([
243
- renaming("mbx-parent", "Work", "Projects"),
244
- renaming("mbx-other", "Admin", "Beheer"),
245
- ]);
246
- const service = new MailboxManagementService(repo);
247
-
248
- await service.syncRename(
249
- "acc-1",
250
- "mbx-parent",
251
- "Work",
252
- "Projects",
253
- async () => connection(),
254
- );
255
-
256
- assert.equal(rowOf("mbx-other")?.fullPath, "Admin");
257
- assert.equal(rowOf("mbx-other")?.pendingPath, "Beheer");
258
- assert.equal(rowOf("mbx-other")?.syncStatus, MailboxSyncStatus.pending);
259
- });
260
-
261
- it("issues no RENAME when the row has already settled", async () => {
262
- const { repo } = store([settled("mbx-parent", "Projects")]);
263
- const service = new MailboxManagementService(repo);
264
- let issued = 0;
265
-
266
- const result = await service.syncRename(
267
- "acc-1",
268
- "mbx-parent",
269
- "Work",
270
- "Projects",
271
- async () =>
272
- ({
273
- renameMailbox: async () => {
274
- issued += 1;
275
- return { path: "Work", newPath: "Projects" };
276
- },
277
- }) as unknown as IImapConnection,
278
- );
279
-
280
- assert.equal(result.success, true);
281
- assert.equal(issued, 0, "a redelivery must not re-issue the rename");
282
- });
283
-
284
- it("issues no RENAME when the row is pending for a different target", async () => {
285
- // A redelivered MAILBOX_RENAME against a folder a second rename has since
286
- // claimed. `pending` alone passes; the recorded target is what refuses it,
287
- // and re-issuing from a path that has already moved is what that prevents.
288
- const { repo, rowOf } = store([renaming("mbx-parent", "Work", "Beheer")]);
289
- const service = new MailboxManagementService(repo);
290
- let issued = 0;
291
-
292
- await service.syncRename(
293
- "acc-1",
294
- "mbx-parent",
295
- "Work",
296
- "Projects",
297
- async () =>
298
- ({
299
- renameMailbox: async () => {
300
- issued += 1;
301
- return { path: "Work", newPath: "Projects" };
302
- },
303
- }) as unknown as IImapConnection,
304
- );
305
-
306
- assert.equal(issued, 0);
307
- assert.equal(rowOf("mbx-parent")?.pendingPath, "Beheer");
308
- });
309
- });
310
-
311
- describe("MailboxManagementService.syncRename — rows it must not claim", () => {
312
- it("leaves a row a different rename recorded under the same branch alone", async () => {
313
- // Two renames aimed under one path. Matching on the recorded target alone
314
- // stamps `Other` onto a path this rename never asked for, leaves its own
315
- // rename with nothing left to settle, and hands the sweep two rows
316
- // claiming one path to reap and re-insert.
317
- const { repo, rowOf } = store([
318
- renaming("mbx-parent", "Work", "Projects"),
319
- renaming("mbx-other", "Other", "Projects/Old"),
320
- ]);
321
- const service = new MailboxManagementService(repo);
322
-
323
- await service.syncRename(
324
- "acc-1",
325
- "mbx-parent",
326
- "Work",
327
- "Projects",
328
- async () => connection(),
329
- );
330
-
331
- assert.equal(rowOf("mbx-parent")?.fullPath, "Projects");
332
- assert.equal(rowOf("mbx-other")?.fullPath, "Other");
333
- assert.equal(rowOf("mbx-other")?.pendingPath, "Projects/Old");
334
- assert.equal(rowOf("mbx-other")?.syncStatus, MailboxSyncStatus.pending);
335
- });
336
-
337
- it("leaves a descendant whose recorded target is not this rename's rebase", async () => {
338
- const { repo, rowOf } = store([
339
- renaming("mbx-parent", "Work", "Projects"),
340
- renaming("mbx-child", "Work/2026", "Projects/Archief"),
341
- ]);
342
- const service = new MailboxManagementService(repo);
343
-
344
- await service.syncRename(
345
- "acc-1",
346
- "mbx-parent",
347
- "Work",
348
- "Projects",
349
- async () => connection(),
350
- );
351
-
352
- assert.equal(rowOf("mbx-parent")?.fullPath, "Projects");
353
- assert.equal(rowOf("mbx-child")?.fullPath, "Work/2026");
354
- assert.equal(rowOf("mbx-child")?.syncStatus, MailboxSyncStatus.pending);
355
- });
356
-
357
- it("fails only the rows this rename recorded", async () => {
358
- const { repo, rowOf } = store([
359
- renaming("mbx-parent", "Work", "Projects"),
360
- renaming("mbx-other", "Other", "Projects/Old"),
361
- ]);
362
- const service = new MailboxManagementService(repo);
363
-
364
- await service.failRename(
365
- "acc-1",
366
- "mbx-parent",
367
- "Work",
368
- "Projects",
369
- "Permission denied",
370
- );
371
-
372
- assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.failed);
373
- assert.equal(rowOf("mbx-other")?.syncStatus, MailboxSyncStatus.pending);
374
- });
375
- });
376
-
377
- describe("MailboxManagementService.syncRename — a rename that already landed", () => {
378
- it("settles rather than deleting when NONEXISTENT means the source has moved", async () => {
379
- // A redelivery after a lost settle re-issues RENAME from a path that has
380
- // moved. Reading that NONEXISTENT as an upstream delete drops the folder
381
- // and its mail while the folder is alive at the target.
382
- const { repo, rowOf } = store([renaming("mbx-parent", "Work", "Projects")]);
383
- const service = new MailboxManagementService(repo);
384
-
385
- const result = await service.syncRename(
386
- "acc-1",
387
- "mbx-parent",
388
- "Work",
389
- "Projects",
390
- async () => refusing(nonexistent(), ["Projects"]),
391
- );
392
-
393
- assert.equal(result.success, true);
394
- assert.equal(rowOf("mbx-parent")?.fullPath, "Projects");
395
- assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.synced);
396
- });
397
-
398
- it("reports the folder as gone, carrying what the server said, when neither path is listed", async () => {
399
- const { repo } = store([renaming("mbx-parent", "Work", "Projects")]);
400
- const service = new MailboxManagementService(repo);
401
-
402
- await assert.rejects(
403
- service.syncRename("acc-1", "mbx-parent", "Work", "Projects", async () =>
404
- refusing(nonexistent(), ["Andere"]),
405
- ),
406
- (error: unknown) =>
407
- error instanceof FolderGoneUpstreamError &&
408
- (error.cause as { serverResponseCode?: string })?.serverResponseCode ===
409
- "NONEXISTENT",
410
- );
411
- });
412
-
413
- it("raises a settle failure as its own kind, never as a refused rename", async () => {
414
- // The server executed the rename. Marking the rows failed here would offer
415
- // a retry of work already done, on a `fullPath` the server no longer holds.
416
- const { repo, rowOf } = store([renaming("mbx-parent", "Work", "Projects")]);
417
- const broken = {
418
- ...repo,
419
- transition: async () => {
420
- throw new Error("database is locked");
421
- },
422
- } as unknown as IMailboxRepository;
423
- const service = new MailboxManagementService(broken);
424
-
425
- await assert.rejects(
426
- service.syncRename("acc-1", "mbx-parent", "Work", "Projects", async () =>
427
- connection(),
428
- ),
429
- (error: unknown) => error instanceof FolderRenameSettleError,
430
- );
431
-
432
- assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.pending);
433
- assert.equal(
434
- rowOf("mbx-parent")?.pendingPath,
435
- "Projects",
436
- "the intent stands, so the redelivery finishes the settle",
437
- );
438
- });
439
- });
440
-
441
- describe("MailboxManagementService.syncRename — a settle that dies part-way", () => {
442
- /**
443
- * The named folder's row is the redelivery's only witness: the guard reads
444
- * it and nothing else. Settling it before its descendants would ack a run
445
- * that stopped half way, and those rows would stay `pending` forever —
446
- * skipped by message sync, and with no route out, because an intent may only
447
- * be recorded from `synced` or `failed`.
448
- */
449
- it("leaves the named folder carrying the intent when a descendant's write fails", async () => {
450
- const { repo, rowOf } = store([
451
- renaming("mbx-parent", "Work", "Projects"),
452
- renaming("mbx-child", "Work/2026", "Projects/2026"),
453
- ]);
454
- let broken = true;
455
- const flaky = {
456
- ...repo,
457
- transition: async (
458
- accountId: string,
459
- mailboxId: string,
460
- intent: Parameters<IMailboxRepository["transition"]>[2],
461
- ) => {
462
- if (broken && mailboxId === "mbx-child") {
463
- throw new Error("database is locked");
464
- }
465
- return repo.transition(accountId, mailboxId, intent);
466
- },
467
- } as unknown as IMailboxRepository;
468
- const service = new MailboxManagementService(flaky);
469
-
470
- await assert.rejects(
471
- service.syncRename("acc-1", "mbx-parent", "Work", "Projects", async () =>
472
- connection(),
473
- ),
474
- (error: unknown) => error instanceof FolderRenameSettleError,
475
- );
476
-
477
- assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.pending);
478
- assert.equal(rowOf("mbx-parent")?.pendingPath, "Projects");
479
- assert.equal(rowOf("mbx-child")?.syncStatus, MailboxSyncStatus.pending);
480
-
481
- // The redelivery re-enters against an intent still standing and finishes.
482
- broken = false;
483
- const redelivered = new MailboxManagementService(flaky);
484
- const result = await redelivered.syncRename(
485
- "acc-1",
486
- "mbx-parent",
487
- "Work",
488
- "Projects",
489
- async () => connection(),
490
- );
491
-
492
- assert.equal(result.success, true);
493
- assert.equal(rowOf("mbx-parent")?.fullPath, "Projects");
494
- assert.equal(rowOf("mbx-child")?.fullPath, "Projects/2026");
495
- assert.equal(rowOf("mbx-child")?.syncStatus, MailboxSyncStatus.synced);
496
- });
497
- });
498
-
499
- describe("MailboxManagementService.syncRename — reading NONEXISTENT", () => {
500
- const prefixed = (): MailboxItem =>
501
- ({
502
- mailboxId: "mbx-parent",
503
- accountId: "acc-1",
504
- fullPath: "Work",
505
- hierarchyDelimiter: ".",
506
- namespacePrefix: "INBOX.",
507
- syncStatus: MailboxSyncStatus.pending,
508
- pendingPath: "Projects",
509
- }) as MailboxItem;
510
-
511
- it("reads the target through the account's namespace prefix", async () => {
512
- // The requested string and the path the server keeps are not the same:
513
- // a Dovecot INBOX namespace stores `Projects` as `INBOX.Projects`.
514
- // Comparing the raw request against the listing reads a folder that is
515
- // plainly there as absent — and that answer deletes the user's mail.
516
- const { repo, rowOf } = store([prefixed()]);
517
- const service = new MailboxManagementService(repo);
518
-
519
- const result = await service.syncRename(
520
- "acc-1",
521
- "mbx-parent",
522
- "Work",
523
- "Projects",
524
- async () => refusing(nonexistent(), ["INBOX.Projects"]),
525
- );
526
-
527
- assert.equal(result.success, true);
528
- assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.synced);
529
- });
530
-
531
- it("raises the confirmed kind only when the listing holds neither path", async () => {
532
- const { repo } = store([renaming("mbx-parent", "Work", "Projects")]);
533
- const service = new MailboxManagementService(repo);
534
-
535
- await assert.rejects(
536
- service.syncRename("acc-1", "mbx-parent", "Work", "Projects", async () =>
537
- refusing(nonexistent(), ["Andere"]),
538
- ),
539
- (error: unknown) => error instanceof FolderGoneUpstreamError,
540
- );
541
- });
542
-
543
- it("refuses the rename rather than confirming a delete when the source is still listed", async () => {
544
- const { repo } = store([renaming("mbx-parent", "Work", "Projects")]);
545
- const service = new MailboxManagementService(repo);
546
-
547
- await assert.rejects(
548
- service.syncRename("acc-1", "mbx-parent", "Work", "Projects", async () =>
549
- refusing(nonexistent(), ["Work"]),
550
- ),
551
- (error: unknown) =>
552
- !(error instanceof FolderGoneUpstreamError) &&
553
- (error as { serverResponseCode?: string }).serverResponseCode ===
554
- "NONEXISTENT",
555
- );
556
- });
557
-
558
- it("refuses the rename when the listing cannot be read at all", async () => {
559
- // A probe that answers nothing decides nothing. A refused rename costs a
560
- // retry; reading an unclear answer as a delete costs the folder's mail.
561
- const { repo } = store([renaming("mbx-parent", "Work", "Projects")]);
562
- const service = new MailboxManagementService(repo);
563
-
564
- await assert.rejects(
565
- service.syncRename("acc-1", "mbx-parent", "Work", "Projects", async () =>
566
- refusing(nonexistent(), "unreadable"),
567
- ),
568
- (error: unknown) => !(error instanceof FolderGoneUpstreamError),
569
- );
570
- });
571
- });
572
-
573
- describe("MailboxManagementService.failRename", () => {
574
- it("marks every intent-carrying row failed, keeping its path and its target", async () => {
575
- const { repo, rowOf } = store([
576
- renaming("mbx-parent", "Work", "Projects"),
577
- renaming("mbx-child", "Work/2026", "Projects/2026"),
578
- ]);
579
- const service = new MailboxManagementService(repo);
580
-
581
- await service.failRename(
582
- "acc-1",
583
- "mbx-parent",
584
- "Work",
585
- "Projects",
586
- "Permission denied",
587
- );
588
-
589
- for (const [id, path, target] of [
590
- ["mbx-parent", "Work", "Projects"],
591
- ["mbx-child", "Work/2026", "Projects/2026"],
592
- ] as const) {
593
- assert.equal(rowOf(id)?.syncStatus, MailboxSyncStatus.failed);
594
- assert.equal(rowOf(id)?.fullPath, path, "nothing is restored (D2)");
595
- assert.equal(
596
- rowOf(id)?.pendingPath,
597
- target,
598
- "the target is kept so the client can name it and offer a retry",
599
- );
600
- }
601
- });
602
-
603
- it("raises a NotFoundError when the folder row itself is gone", async () => {
604
- const { repo } = store([]);
605
- const service = new MailboxManagementService(repo);
606
-
607
- await assert.rejects(
608
- service.failRename(
609
- "acc-1",
610
- "mbx-parent",
611
- "Work",
612
- "Projects",
613
- "Permission denied",
614
- ),
615
- (error: unknown) => (error as Error).name === "NotFoundError",
616
- );
617
- });
618
-
619
- it("records nothing, quietly, when the row is there but carries no intent", async () => {
620
- // Never a NotFoundError: the caller reads that as the user having deleted
621
- // the folder and acks the message, which would swallow the IMAP failure
622
- // that brought us here and leave the subtree `pending` with no retry.
623
- const { repo } = store([settled("mbx-parent", "Work")]);
624
- const service = new MailboxManagementService(repo);
625
-
626
- await assert.doesNotReject(
627
- service.failRename(
628
- "acc-1",
629
- "mbx-parent",
630
- "Work",
631
- "Projects",
632
- "Permission denied",
633
- ),
634
- );
635
- });
636
-
637
- it("settles a rename ImapFlow never issued as a refusal, not a TypeError", async () => {
638
- // `mailboxRename` resolves `undefined` when the connection is not
639
- // AUTHENTICATED or SELECTED. The settle reads the resolved path, so an
640
- // unguarded dereference would raise a TypeError the rename cannot classify.
641
- const { repo, rowOf } = store([renaming("mbx-parent", "Work", "Projects")]);
642
- const service = new MailboxManagementService(repo);
643
- const notReady = new Error(
644
- 'Rename of "Work" was not issued: the IMAP connection is not ready',
645
- );
646
-
647
- await assert.rejects(
648
- service.syncRename("acc-1", "mbx-parent", "Work", "Projects", async () =>
649
- refusing(notReady),
650
- ),
651
- /not ready/,
652
- );
653
-
654
- await service.failRename(
655
- "acc-1",
656
- "mbx-parent",
657
- "Work",
658
- "Projects",
659
- "Permission denied",
660
- );
661
- assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.failed);
662
- assert.equal(rowOf("mbx-parent")?.fullPath, "Work");
663
- });
664
- });