@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,72 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import type { IMailboxRepository } from "@remit/data-ports";
4
- import { NotFoundError } from "@remit/data-ports/errors";
5
- import { MailboxSyncStatus } from "@remit/domain-enums";
6
- import {
7
- isFolderMutationInFlight,
8
- isMailboxMutationInFlight,
9
- } from "./mailbox-presence.js";
10
-
11
- const reader = (
12
- get: IMailboxRepository["get"],
13
- ): Pick<IMailboxRepository, "get"> => ({ get });
14
-
15
- describe("isFolderMutationInFlight", () => {
16
- for (const [syncStatus, inFlight] of [
17
- [MailboxSyncStatus.synced, false],
18
- [MailboxSyncStatus.pending, true],
19
- [MailboxSyncStatus.deleting, true],
20
- [MailboxSyncStatus.failed, false],
21
- ] as const) {
22
- it(`${syncStatus} is ${inFlight ? "" : "not "}a mutation in flight`, () => {
23
- assert.equal(isFolderMutationInFlight({ syncStatus }), inFlight);
24
- });
25
- }
26
-
27
- it("does not read failed as a folder to leave alone", () => {
28
- // `failed` means the folder exists at `fullPath` and the last rename or
29
- // delete intent did not land — so it holds the user's mail and syncing it
30
- // is right. D7 upholds that by leaving no row at all for a create the
31
- // server refused. Reading it as in-flight would stop syncing a live folder
32
- // permanently and silently.
33
- assert.equal(
34
- isFolderMutationInFlight({ syncStatus: MailboxSyncStatus.failed }),
35
- false,
36
- );
37
- });
38
- });
39
-
40
- describe("isMailboxMutationInFlight", () => {
41
- it("reads the row it is given", async () => {
42
- const service = reader((async () => ({
43
- syncStatus: MailboxSyncStatus.deleting,
44
- })) as unknown as IMailboxRepository["get"]);
45
-
46
- assert.equal(await isMailboxMutationInFlight(service, "acc", "mbx"), true);
47
- });
48
-
49
- it("treats an absent row as in flight", async () => {
50
- // The row's absence does not decide it at either end: on the way out the
51
- // row is removed only after the IMAP folder is, and on the way in the row
52
- // exists before the folder does.
53
- const service = reader((async () => {
54
- throw new NotFoundError("Mailbox not found: mbx");
55
- }) as unknown as IMailboxRepository["get"]);
56
-
57
- assert.equal(await isMailboxMutationInFlight(service, "acc", "mbx"), true);
58
- });
59
-
60
- it("rethrows a read that cannot answer", async () => {
61
- // The caller uses this to classify a failure in hand; a read that fails
62
- // for its own reasons must not be reported as "the folder is busy".
63
- const service = reader((async () => {
64
- throw new Error("connection reset by peer");
65
- }) as unknown as IMailboxRepository["get"]);
66
-
67
- await assert.rejects(
68
- isMailboxMutationInFlight(service, "acc", "mbx"),
69
- /connection reset by peer/,
70
- );
71
- });
72
- });
@@ -1,415 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
- import { SendMessageCommand, type SQSClient } from "@aws-sdk/client-sqs";
3
- import type {
4
- CreateMailboxInput,
5
- CreateMailboxResult,
6
- IMailboxRepository,
7
- MailboxItem,
8
- } from "@remit/data-ports";
9
- import { NotFoundError } from "@remit/data-ports/errors";
10
- import { rebaseMailboxPath } from "@remit/data-ports/mailbox-name";
11
- import { MailboxSyncStatus } from "@remit/domain-enums";
12
- import { createQueueProducer } from "@remit/sqs-client/producer";
13
- import {
14
- INTENT_RECORDABLE_FROM,
15
- recordedByRename,
16
- refuseContestedIntent,
17
- } from "./mailbox-intent.js";
18
-
19
- /**
20
- * MAILBOX_CREATE event structure (matches remit-imap-worker/events.ts)
21
- */
22
- interface MailboxCreateEvent {
23
- type: "MAILBOX_CREATE";
24
- eventId: string;
25
- timestamp: number;
26
- accountId: string;
27
- mailboxId: string;
28
- path: string;
29
- subscribe?: boolean;
30
- }
31
-
32
- /**
33
- * MAILBOX_RENAME event structure (matches remit-imap-worker/events.ts)
34
- */
35
- interface MailboxRenameEvent {
36
- type: "MAILBOX_RENAME";
37
- eventId: string;
38
- timestamp: number;
39
- accountId: string;
40
- mailboxId: string;
41
- oldPath: string;
42
- newPath: string;
43
- }
44
-
45
- /**
46
- * MAILBOX_DELETE event structure (matches remit-imap-worker/events.ts)
47
- */
48
- interface MailboxDeleteEvent {
49
- type: "MAILBOX_DELETE";
50
- eventId: string;
51
- timestamp: number;
52
- accountId: string;
53
- mailboxId: string;
54
- path: string;
55
- }
56
-
57
- type MailboxManagementEvent =
58
- | MailboxCreateEvent
59
- | MailboxRenameEvent
60
- | MailboxDeleteEvent;
61
-
62
- /**
63
- * Logger interface
64
- */
65
- export interface MailboxQueueLogger {
66
- info(obj: Record<string, unknown>, msg: string): void;
67
- error(obj: Record<string, unknown>, msg: string): void;
68
- }
69
-
70
- const noopLogger: MailboxQueueLogger = {
71
- info: () => {},
72
- error: () => {},
73
- };
74
-
75
- /**
76
- * Input for creating a mailbox via the queue service
77
- */
78
- export type CreateMailboxQueueInput = Omit<CreateMailboxInput, "syncStatus">;
79
-
80
- /**
81
- * Configuration for MailboxQueueService
82
- */
83
- export interface MailboxQueueConfig {
84
- mailboxService: IMailboxRepository;
85
- sqsQueueUrl: string;
86
- sqsEndpoint?: string;
87
- logger?: MailboxQueueLogger;
88
- }
89
-
90
- /**
91
- * Service for mailbox management with automatic IMAP sync queueing.
92
- *
93
- * Implements optimistic local-first pattern:
94
- * 1. Updates local state immediately
95
- * 2. Enqueues mailbox management event to SQS for worker to sync to IMAP
96
- *
97
- * This follows the same pattern as FlagQueueService (RFC 014).
98
- */
99
- export class MailboxQueueService {
100
- private mailboxService: IMailboxRepository;
101
- private sqs: SQSClient;
102
- private queueUrl: string;
103
- private log: MailboxQueueLogger;
104
-
105
- constructor(config: MailboxQueueConfig) {
106
- const { mailboxService, sqsQueueUrl, sqsEndpoint } = config;
107
- this.mailboxService = mailboxService;
108
- this.queueUrl = sqsQueueUrl;
109
- this.log = config.logger ?? noopLogger;
110
-
111
- this.sqs = createQueueProducer({
112
- queueUrl: sqsQueueUrl,
113
- endpoint: sqsEndpoint,
114
- });
115
- }
116
-
117
- /**
118
- * Create a new mailbox.
119
- * Updates local state (with syncStatus=pending) and enqueues IMAP CREATE.
120
- *
121
- * @param input - The mailbox creation input (without syncStatus)
122
- * @param accountId - The account ID for the IMAP sync event
123
- * @param subscribe - Whether to subscribe to the mailbox after creation
124
- * @returns The created mailbox, or `PathTaken` when the account already has a folder at that path
125
- */
126
- createMailbox = async (
127
- input: CreateMailboxQueueInput,
128
- accountId: string,
129
- subscribe?: boolean,
130
- ): Promise<CreateMailboxResult> => {
131
- const created = await this.mailboxService.create({
132
- ...input,
133
- syncStatus: MailboxSyncStatus.pending,
134
- });
135
- if (created.outcome === "PathTaken") return created;
136
- const { mailbox } = created;
137
-
138
- this.log.info(
139
- { mailboxId: mailbox.mailboxId, path: mailbox.fullPath },
140
- "Created mailbox (local)",
141
- );
142
-
143
- // Enqueue IMAP sync
144
- await this.enqueueEvent({
145
- type: "MAILBOX_CREATE",
146
- eventId: randomUUID(),
147
- timestamp: Date.now(),
148
- accountId,
149
- mailboxId: mailbox.mailboxId,
150
- path: mailbox.fullPath,
151
- subscribe,
152
- });
153
-
154
- return created;
155
- };
156
-
157
- /**
158
- * Record a rename intent over the folder and every descendant, then enqueue
159
- * IMAP RENAME.
160
- *
161
- * The target is recorded, not the path (D2): every row keeps the `fullPath`
162
- * the server holds and takes its own rewritten target in `pendingPath`, so a
163
- * rename that never lands needs no unwinding — dropping `pendingPath` is the
164
- * whole revert — and nothing that resolves a folder path from a row can
165
- * resolve one the server does not have.
166
- *
167
- * @param mailboxId - The mailbox to rename
168
- * @param newPath - The path the rename is aiming at
169
- * @param accountId - The account ID for the IMAP sync event
170
- * @returns The folder that was named, carrying its recorded target
171
- */
172
- renameMailbox = async (
173
- mailboxId: string,
174
- newPath: string,
175
- accountId: string,
176
- ): Promise<MailboxItem> => {
177
- // Get current mailbox to capture old path
178
- const mailbox = await this.mailboxService.get(accountId, mailboxId);
179
- const oldPath = mailbox.fullPath;
180
-
181
- // One intent over the folder and every descendant, in one transaction:
182
- // IMAP RENAME moves the subtree in one command, so a partially recorded
183
- // rename is the state that produced today's phantom rows (D6). A
184
- // descendant already mid-mutation refuses the whole rename.
185
- const written = await this.mailboxService.transitionSubtree(
186
- accountId,
187
- mailboxId,
188
- {
189
- from: INTENT_RECORDABLE_FROM,
190
- to: MailboxSyncStatus.pending,
191
- rowSet: (row) => {
192
- const target = rebaseMailboxPath(
193
- row.fullPath,
194
- oldPath,
195
- newPath,
196
- mailbox.hierarchyDelimiter,
197
- );
198
- // The subtree is the folder and the rows under its own prefix, so
199
- // every one of them rebases. A row that does not is a resolution
200
- // bug, and recording an intent with no target for it would strand
201
- // it `pending` with nothing able to settle it.
202
- if (target === undefined) {
203
- throw new Error(
204
- `Mailbox ${row.mailboxId} at "${row.fullPath}" is not under "${oldPath}"`,
205
- );
206
- }
207
- return { pendingPath: target };
208
- },
209
- },
210
- );
211
- if (!written) {
212
- return refuseContestedIntent(
213
- this.mailboxService,
214
- accountId,
215
- mailboxId,
216
- "subtree",
217
- );
218
- }
219
- const updated = written.find((row) => row.mailboxId === mailboxId);
220
- if (!updated) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
221
-
222
- this.log.info(
223
- {
224
- accountId,
225
- mailboxId,
226
- intent: "rename",
227
- from: mailbox.syncStatus,
228
- to: MailboxSyncStatus.pending,
229
- oldPath,
230
- newPath,
231
- subtreeSize: written.length,
232
- },
233
- "Recorded rename intent",
234
- );
235
-
236
- // Enqueue IMAP sync
237
- await this.enqueueEvent({
238
- type: "MAILBOX_RENAME",
239
- eventId: randomUUID(),
240
- timestamp: Date.now(),
241
- accountId,
242
- mailboxId,
243
- oldPath,
244
- newPath,
245
- });
246
-
247
- return updated;
248
- };
249
-
250
- /**
251
- * The route out of a failed rename or a failed delete that keeps the folder
252
- * as it is (T10, T11): the recorded target is dropped and the row settles
253
- * back to `synced`. Nothing is enqueued — `fullPath` was never written, so
254
- * the server has nothing to undo.
255
- *
256
- * Reached without new API surface, from a PATCH whose `fullPath` equals the
257
- * row's confirmed one. From `synced` it is a no-op; from a state with a
258
- * mutation in flight it is a 409, because dismissing an intent that is still
259
- * running would leave the settle with no row to write.
260
- */
261
- dismissMailboxIntent = async (
262
- mailboxId: string,
263
- accountId: string,
264
- ): Promise<MailboxItem> => {
265
- const current = await this.mailboxService.get(accountId, mailboxId);
266
- if (current.syncStatus === MailboxSyncStatus.synced) return current;
267
- if (current.syncStatus !== MailboxSyncStatus.failed) {
268
- return refuseContestedIntent(
269
- this.mailboxService,
270
- accountId,
271
- mailboxId,
272
- "folder",
273
- );
274
- }
275
-
276
- // A failed rename was recorded over a subtree, so it is dismissed over the
277
- // same one. A failed delete carries no target and was recorded on the
278
- // folder alone, so there is nothing else to clear.
279
- const target = current.pendingPath;
280
- const alsoRecorded =
281
- target === undefined
282
- ? []
283
- : (
284
- await this.mailboxService.findBySyncStatus(
285
- accountId,
286
- MailboxSyncStatus.failed,
287
- )
288
- ).filter(
289
- (row) =>
290
- row.mailboxId !== mailboxId &&
291
- recordedByRename(
292
- row,
293
- current.fullPath,
294
- target,
295
- current.hierarchyDelimiter,
296
- ),
297
- );
298
-
299
- let dismissed: MailboxItem | undefined;
300
- // The named folder first, so a caller always gets back the row it asked
301
- // about. Each write carries its own predicate, so a row somebody else
302
- // moved between the read and the write is skipped, not overwritten (D3).
303
- for (const row of [current, ...alsoRecorded]) {
304
- const written = await this.mailboxService.transition(
305
- accountId,
306
- row.mailboxId,
307
- {
308
- from: [MailboxSyncStatus.failed],
309
- wherePendingPath: row.pendingPath ?? null,
310
- to: MailboxSyncStatus.synced,
311
- },
312
- );
313
- if (row.mailboxId === mailboxId) dismissed = written ?? undefined;
314
- this.log.info(
315
- {
316
- accountId,
317
- mailboxId: row.mailboxId,
318
- intent: "dismiss",
319
- from: MailboxSyncStatus.failed,
320
- to: MailboxSyncStatus.synced,
321
- outcome: written ? "settled" : "superseded",
322
- },
323
- "Dismissed folder intent",
324
- );
325
- }
326
-
327
- if (dismissed) return dismissed;
328
- // The row moved between the read and its write. Re-read rather than
329
- // reporting a dismissal that did not happen.
330
- return this.mailboxService.get(accountId, mailboxId);
331
- };
332
-
333
- /**
334
- * Record a delete intent (T7) and enqueue IMAP DELETE.
335
- *
336
- * The row stays at `deleting` and stays visible until the worker confirms the
337
- * server delete and removes it with the folder's mail (D8, D11). The enqueue
338
- * happens only after the transition wins, and an enqueue failure stays thrown
339
- * — a 500 to the caller, loud — rather than being swallowed or rolled back.
340
- *
341
- * @param mailboxId - The mailbox to delete
342
- * @param accountId - The account ID for the IMAP sync event
343
- */
344
- deleteMailbox = async (
345
- mailboxId: string,
346
- accountId: string,
347
- ): Promise<void> => {
348
- // Get current mailbox to capture path
349
- const mailbox = await this.mailboxService.get(accountId, mailboxId);
350
-
351
- const recorded = await this.mailboxService.transition(
352
- accountId,
353
- mailboxId,
354
- { from: INTENT_RECORDABLE_FROM, to: MailboxSyncStatus.deleting },
355
- );
356
- if (!recorded) {
357
- await refuseContestedIntent(
358
- this.mailboxService,
359
- accountId,
360
- mailboxId,
361
- "folder",
362
- );
363
- }
364
-
365
- this.log.info(
366
- {
367
- accountId,
368
- mailboxId,
369
- intent: "delete",
370
- from: mailbox.syncStatus,
371
- to: MailboxSyncStatus.deleting,
372
- path: mailbox.fullPath,
373
- },
374
- "Recorded delete intent",
375
- );
376
-
377
- // Enqueue IMAP sync
378
- await this.enqueueEvent({
379
- type: "MAILBOX_DELETE",
380
- eventId: randomUUID(),
381
- timestamp: Date.now(),
382
- accountId,
383
- mailboxId,
384
- path: mailbox.fullPath,
385
- });
386
- };
387
-
388
- /**
389
- * Enqueue a mailbox management event to SQS.
390
- *
391
- * FIFO queues require MessageGroupId; standard queues reject it. We detect
392
- * FIFO queues by the `.fifo` suffix on the queue URL and scope ordering to
393
- * the account so events for different accounts can be processed in parallel.
394
- */
395
- private enqueueEvent = async (
396
- event: MailboxManagementEvent,
397
- ): Promise<void> => {
398
- const useFifo = this.queueUrl.endsWith(".fifo");
399
- await this.sqs.send(
400
- new SendMessageCommand({
401
- QueueUrl: this.queueUrl,
402
- MessageBody: JSON.stringify(event),
403
- ...(useFifo && {
404
- MessageGroupId: event.accountId,
405
- MessageDeduplicationId: `${event.type}:${event.mailboxId}:${event.eventId}`,
406
- }),
407
- }),
408
- );
409
-
410
- this.log.info(
411
- { eventId: event.eventId, type: event.type, mailboxId: event.mailboxId },
412
- `Enqueued ${event.type} event`,
413
- );
414
- };
415
- }