@remit/mailbox-service 0.0.111 → 0.0.113

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (298) hide show
  1. package/dist/account-credentials.d.ts +83 -0
  2. package/dist/account-credentials.js +104 -0
  3. package/dist/attribute-mapper.d.ts +36 -0
  4. package/dist/attribute-mapper.js +119 -0
  5. package/dist/body-parse.d.ts +28 -0
  6. package/{src/body-parse.ts → dist/body-parse.js} +18 -26
  7. package/dist/body-part-mapper.d.ts +92 -0
  8. package/dist/body-part-mapper.js +301 -0
  9. package/dist/body-sync-queue.d.ts +59 -0
  10. package/dist/body-sync-queue.js +56 -0
  11. package/dist/body-sync-terminal.d.ts +58 -0
  12. package/dist/body-sync-terminal.js +95 -0
  13. package/dist/body-sync.d.ts +351 -0
  14. package/dist/body-sync.js +1204 -0
  15. package/dist/calendar-parts.d.ts +23 -0
  16. package/dist/calendar-parts.js +18 -0
  17. package/dist/classification-backfill.d.ts +91 -0
  18. package/dist/classification-backfill.js +180 -0
  19. package/dist/classify-message.d.ts +31 -0
  20. package/dist/classify-message.js +89 -0
  21. package/dist/connection-factory.d.ts +54 -0
  22. package/dist/connection-factory.js +54 -0
  23. package/dist/connection-test.d.ts +41 -0
  24. package/dist/connection-test.js +152 -0
  25. package/dist/external-move.d.ts +94 -0
  26. package/dist/external-move.js +173 -0
  27. package/dist/filter-operations.d.ts +42 -0
  28. package/dist/filter-operations.js +211 -0
  29. package/dist/filters/filter-config.d.ts +14 -0
  30. package/dist/filters/filter-config.js +10 -0
  31. package/{src/filters/list-id.ts → dist/filters/list-id.d.ts} +2 -16
  32. package/dist/filters/list-id.js +26 -0
  33. package/dist/filters/match.d.ts +69 -0
  34. package/dist/filters/match.js +165 -0
  35. package/dist/filters/pipeline.d.ts +64 -0
  36. package/dist/filters/pipeline.js +100 -0
  37. package/{src/flag-push-terminal.ts → dist/flag-push-terminal.d.ts} +19 -77
  38. package/dist/flag-push-terminal.js +63 -0
  39. package/dist/flag-push.d.ts +69 -0
  40. package/dist/flag-push.js +111 -0
  41. package/dist/flag-queue.d.ts +175 -0
  42. package/dist/flag-queue.js +291 -0
  43. package/dist/folder-operations.d.ts +39 -0
  44. package/dist/folder-operations.js +112 -0
  45. package/dist/folder-placement.d.ts +13 -0
  46. package/dist/folder-placement.js +23 -0
  47. package/dist/heuristics/authenticationResults.d.ts +10 -0
  48. package/dist/heuristics/authenticationResults.js +187 -0
  49. package/dist/heuristics/classifyByHeaders.d.ts +82 -0
  50. package/dist/heuristics/classifyByHeaders.js +365 -0
  51. package/dist/heuristics/classifyPlacement.d.ts +67 -0
  52. package/dist/heuristics/classifyPlacement.js +153 -0
  53. package/dist/heuristics/machineSenders.d.ts +21 -0
  54. package/{src/heuristics/machineSenders.ts → dist/heuristics/machineSenders.js} +34 -40
  55. package/dist/heuristics/senderMismatch.d.ts +81 -0
  56. package/dist/heuristics/senderMismatch.js +192 -0
  57. package/dist/heuristics/socialDomains.d.ts +14 -0
  58. package/{src/heuristics/socialDomains.ts → dist/heuristics/socialDomains.js} +9 -9
  59. package/dist/heuristics/transactionalDomains.d.ts +18 -0
  60. package/{src/heuristics/transactionalDomains.ts → dist/heuristics/transactionalDomains.js} +8 -8
  61. package/dist/imapflow-connection.d.ts +377 -0
  62. package/dist/imapflow-connection.js +1147 -0
  63. package/dist/index.d.ts +63 -0
  64. package/dist/index.js +65 -0
  65. package/dist/list-id-backfill.d.ts +83 -0
  66. package/dist/list-id-backfill.js +132 -0
  67. package/dist/mailbox-cursor-rebuild.d.ts +74 -0
  68. package/dist/mailbox-cursor-rebuild.js +71 -0
  69. package/{src/mailbox-cursor.ts → dist/mailbox-cursor.d.ts} +20 -98
  70. package/dist/mailbox-cursor.js +102 -0
  71. package/dist/mailbox-intent.d.ts +41 -0
  72. package/{src/mailbox-intent.ts → dist/mailbox-intent.js} +39 -63
  73. package/dist/mailbox-management.d.ts +240 -0
  74. package/dist/mailbox-management.js +576 -0
  75. package/dist/mailbox-presence.d.ts +52 -0
  76. package/{src/mailbox-presence.ts → dist/mailbox-presence.js} +19 -30
  77. package/dist/mailbox-queue.d.ts +126 -0
  78. package/dist/mailbox-queue.js +214 -0
  79. package/dist/mailbox-sync.d.ts +93 -0
  80. package/dist/mailbox-sync.js +490 -0
  81. package/dist/mailbox-upstream.d.ts +38 -0
  82. package/{src/mailbox-upstream.ts → dist/mailbox-upstream.js} +37 -47
  83. package/dist/message-category.d.ts +51 -0
  84. package/{src/message-category.ts → dist/message-category.js} +28 -71
  85. package/dist/message-move.d.ts +285 -0
  86. package/dist/message-move.js +858 -0
  87. package/dist/message-parser.d.ts +14 -0
  88. package/dist/message-parser.js +17 -0
  89. package/{src/message-presence.ts → dist/message-presence.d.ts} +1 -13
  90. package/dist/message-presence.js +29 -0
  91. package/dist/message-read.d.ts +51 -0
  92. package/dist/message-read.js +31 -0
  93. package/dist/message-sync.d.ts +345 -0
  94. package/dist/message-sync.js +1358 -0
  95. package/dist/mime-walker.d.ts +102 -0
  96. package/dist/mime-walker.js +198 -0
  97. package/dist/organize-job.d.ts +20 -0
  98. package/dist/organize-job.js +37 -0
  99. package/dist/outbox-attachment-content.d.ts +37 -0
  100. package/dist/outbox-attachment-content.js +50 -0
  101. package/dist/outbox-attachment-filename.d.ts +19 -0
  102. package/dist/outbox-attachment-filename.js +64 -0
  103. package/dist/outbox-attachment.d.ts +148 -0
  104. package/dist/outbox-attachment.js +273 -0
  105. package/dist/outbox-queue.d.ts +73 -0
  106. package/dist/outbox-queue.js +284 -0
  107. package/{src/outbox-status.ts → dist/outbox-status.d.ts} +1 -6
  108. package/dist/outbox-status.js +18 -0
  109. package/dist/ownership.d.ts +24 -0
  110. package/dist/ownership.js +83 -0
  111. package/dist/placement-move-terminal.d.ts +67 -0
  112. package/dist/placement-move-terminal.js +88 -0
  113. package/dist/placement-move.d.ts +83 -0
  114. package/dist/placement-move.js +216 -0
  115. package/{src/placement-settled.ts → dist/placement-settled.d.ts} +19 -50
  116. package/dist/placement-settled.js +75 -0
  117. package/dist/quarantine.d.ts +95 -0
  118. package/dist/quarantine.js +131 -0
  119. package/dist/refusal.d.ts +31 -0
  120. package/dist/refusal.js +3 -0
  121. package/{src/restore-source-placement.ts → dist/restore-source-placement.d.ts} +29 -101
  122. package/dist/restore-source-placement.js +79 -0
  123. package/dist/search-text-backfill.d.ts +15 -0
  124. package/dist/search-text-backfill.js +33 -0
  125. package/dist/snippet.d.ts +61 -0
  126. package/dist/snippet.js +129 -0
  127. package/dist/spam-report.d.ts +86 -0
  128. package/dist/spam-report.js +241 -0
  129. package/dist/sync-warning.d.ts +21 -0
  130. package/dist/sync-warning.js +26 -0
  131. package/dist/sync-watermarks.d.ts +138 -0
  132. package/dist/sync-watermarks.js +205 -0
  133. package/dist/test-helpers/fake-imap.d.ts +151 -0
  134. package/dist/test-helpers/fake-imap.js +508 -0
  135. package/dist/test-helpers/folder-roles.d.ts +34 -0
  136. package/dist/test-helpers/folder-roles.js +47 -0
  137. package/dist/test-helpers/mime-tree-generator.d.ts +82 -0
  138. package/dist/test-helpers/mime-tree-generator.js +329 -0
  139. package/dist/test-helpers/test-store.d.ts +69 -0
  140. package/dist/test-helpers/test-store.js +193 -0
  141. package/dist/text/normalizer.d.ts +20 -0
  142. package/dist/text/normalizer.js +102 -0
  143. package/dist/thread-message-rows.d.ts +69 -0
  144. package/dist/thread-message-rows.js +40 -0
  145. package/dist/thread-root.d.ts +7 -0
  146. package/dist/thread-root.js +25 -0
  147. package/dist/types.d.ts +343 -0
  148. package/dist/types.js +19 -0
  149. package/package.json +86 -73
  150. package/src/account-credentials.ts +0 -210
  151. package/src/adversarial-mime.e2e.test.ts +0 -307
  152. package/src/attribute-mapper.test.ts +0 -104
  153. package/src/attribute-mapper.ts +0 -148
  154. package/src/body-parse.test.ts +0 -55
  155. package/src/body-part-mapper.property.test.ts +0 -354
  156. package/src/body-part-mapper.ts +0 -360
  157. package/src/body-sync-auto-read-writeonce.test.ts +0 -219
  158. package/src/body-sync-calendar-suggestion.test.ts +0 -433
  159. package/src/body-sync-category-denormalize.test.ts +0 -352
  160. package/src/body-sync-category-override.test.ts +0 -501
  161. package/src/body-sync-category-writeonce.test.ts +0 -234
  162. package/src/body-sync-filter.test.ts +0 -237
  163. package/src/body-sync-message-gone.test.ts +0 -139
  164. package/src/body-sync-placement-flags.test.ts +0 -347
  165. package/src/body-sync-placement-writeonce.test.ts +0 -337
  166. package/src/body-sync-quarantine.test.ts +0 -276
  167. package/src/body-sync-queue.test.ts +0 -124
  168. package/src/body-sync-queue.ts +0 -131
  169. package/src/body-sync-resync.test.ts +0 -605
  170. package/src/body-sync-terminal.test.ts +0 -310
  171. package/src/body-sync-terminal.ts +0 -176
  172. package/src/body-sync-unclassifiable-once.test.ts +0 -260
  173. package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
  174. package/src/body-sync.ts +0 -1970
  175. package/src/calendar-parts.ts +0 -41
  176. package/src/classification-backfill.test.ts +0 -615
  177. package/src/classification-backfill.ts +0 -363
  178. package/src/classify-message.test.ts +0 -205
  179. package/src/classify-message.ts +0 -116
  180. package/src/connection-factory.ts +0 -102
  181. package/src/connection-test.ts +0 -208
  182. package/src/credentials.test.ts +0 -181
  183. package/src/external-move.test.ts +0 -369
  184. package/src/external-move.ts +0 -272
  185. package/src/filters/anchor-drift.ts +0 -55
  186. package/src/filters/filter-config.test.ts +0 -229
  187. package/src/filters/filter-config.ts +0 -69
  188. package/src/filters/list-id.test.ts +0 -50
  189. package/src/filters/match.test.ts +0 -429
  190. package/src/filters/match.ts +0 -274
  191. package/src/filters/message-embedder.test.ts +0 -39
  192. package/src/filters/message-embedder.ts +0 -33
  193. package/src/filters/pipeline.test.ts +0 -330
  194. package/src/filters/pipeline.ts +0 -284
  195. package/src/flag-push-terminal.test.ts +0 -209
  196. package/src/flag-push.test.ts +0 -408
  197. package/src/flag-push.ts +0 -198
  198. package/src/flag-queue-stars.test.ts +0 -171
  199. package/src/flag-queue.ts +0 -509
  200. package/src/folder-placement.ts +0 -56
  201. package/src/heuristics/authenticationResults.test.ts +0 -189
  202. package/src/heuristics/authenticationResults.ts +0 -212
  203. package/src/heuristics/classifyByHeaders.test.ts +0 -889
  204. package/src/heuristics/classifyByHeaders.ts +0 -467
  205. package/src/heuristics/classifyPlacement.test.ts +0 -366
  206. package/src/heuristics/classifyPlacement.ts +0 -211
  207. package/src/heuristics/classifyRealMail.test.ts +0 -242
  208. package/src/heuristics/machineSenders.test.ts +0 -101
  209. package/src/heuristics/senderMismatch.test.ts +0 -346
  210. package/src/heuristics/senderMismatch.ts +0 -259
  211. package/src/imapflow-connect-retry.test.ts +0 -134
  212. package/src/imapflow-connection.e2e.test.ts +0 -335
  213. package/src/imapflow-connection.integ.test.ts +0 -316
  214. package/src/imapflow-connection.test.ts +0 -469
  215. package/src/imapflow-connection.ts +0 -1475
  216. package/src/index.ts +0 -350
  217. package/src/list-id-backfill.test.ts +0 -573
  218. package/src/list-id-backfill.ts +0 -274
  219. package/src/mailbox-cursor-rebuild.test.ts +0 -171
  220. package/src/mailbox-cursor-rebuild.ts +0 -146
  221. package/src/mailbox-cursor.test.ts +0 -249
  222. package/src/mailbox-delete-intent.test.ts +0 -246
  223. package/src/mailbox-management.test.ts +0 -356
  224. package/src/mailbox-management.ts +0 -839
  225. package/src/mailbox-presence.test.ts +0 -72
  226. package/src/mailbox-queue.ts +0 -415
  227. package/src/mailbox-rename-intent.test.ts +0 -389
  228. package/src/mailbox-rename-subtree.test.ts +0 -664
  229. package/src/mailbox-sync.test.ts +0 -848
  230. package/src/mailbox-sync.ts +0 -724
  231. package/src/message-delete-junk-reconcile.test.ts +0 -117
  232. package/src/message-delete-no-trash.test.ts +0 -245
  233. package/src/message-delete-unsettled-placement.test.ts +0 -396
  234. package/src/message-move-copy.test.ts +0 -562
  235. package/src/message-move-empty-trash.test.ts +0 -326
  236. package/src/message-move-same-mailbox.test.ts +0 -139
  237. package/src/message-move-unsettled-placement.test.ts +0 -378
  238. package/src/message-move.ts +0 -1348
  239. package/src/message-parser.ts +0 -26
  240. package/src/message-presence.test.ts +0 -50
  241. package/src/message-sync-address-spoof.test.ts +0 -293
  242. package/src/message-sync-category.test.ts +0 -142
  243. package/src/message-sync-changedsince.test.ts +0 -840
  244. package/src/message-sync-flags.test.ts +0 -158
  245. package/src/message-sync-junk-harvest.test.ts +0 -244
  246. package/src/message-sync-quarantine.test.ts +0 -416
  247. package/src/message-sync-repoint.test.ts +0 -419
  248. package/src/message-sync.ts +0 -1963
  249. package/src/mime-walker.test.ts +0 -311
  250. package/src/mime-walker.ts +0 -305
  251. package/src/outbox-attachment-content.test.ts +0 -153
  252. package/src/outbox-attachment-content.ts +0 -104
  253. package/src/outbox-attachment-filename.test.ts +0 -132
  254. package/src/outbox-attachment-filename.ts +0 -76
  255. package/src/outbox-attachment.test.ts +0 -738
  256. package/src/outbox-attachment.ts +0 -536
  257. package/src/outbox-failed-recovery.test.ts +0 -370
  258. package/src/outbox-queue-settled-row.test.ts +0 -140
  259. package/src/outbox-queue.test.ts +0 -291
  260. package/src/outbox-queue.ts +0 -508
  261. package/src/pass-through-unit-of-work.ts +0 -17
  262. package/src/placement-move-terminal.test.ts +0 -300
  263. package/src/placement-move-terminal.ts +0 -148
  264. package/src/placement-move-unsettled.test.ts +0 -275
  265. package/src/placement-move.ts +0 -336
  266. package/src/placement-settled.test.ts +0 -147
  267. package/src/placement-transition-race.test.ts +0 -482
  268. package/src/quarantine.test.ts +0 -191
  269. package/src/quarantine.ts +0 -228
  270. package/src/snippet.test.ts +0 -213
  271. package/src/snippet.ts +0 -152
  272. package/src/spam-report.test.ts +0 -801
  273. package/src/spam-report.ts +0 -357
  274. package/src/stale-message-reconcile.test.ts +0 -82
  275. package/src/stale-message-reconcile.ts +0 -55
  276. package/src/sync-watermarks.test.ts +0 -409
  277. package/src/sync-watermarks.ts +0 -319
  278. package/src/system-flag-wire-format.test.ts +0 -77
  279. package/src/test-helpers/folder-roles.ts +0 -66
  280. package/src/test-helpers/isolated-mailbox.ts +0 -44
  281. package/src/test-helpers/mailfuzz-connection.ts +0 -27
  282. package/src/test-helpers/mime-tree-generator.ts +0 -461
  283. package/src/text/normalizer.test.ts +0 -223
  284. package/src/text/normalizer.ts +0 -140
  285. package/src/thread-message-rows.ts +0 -77
  286. package/src/types/mailparser-augment.d.ts +0 -15
  287. package/src/types/natural-porter-stemmer.d.ts +0 -50
  288. package/src/types/stopword.d.ts +0 -69
  289. package/src/types.ts +0 -371
  290. package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
  291. package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
  292. package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
  293. package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
  294. package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
  295. package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
  296. package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
  297. package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
  298. package/tsconfig.json +0 -8
@@ -0,0 +1,24 @@
1
+ import type { AccountItem, MailboxItem } from "@remit/data-ports";
2
+ import { type AccessMode, type Refusable } from "./refusal.js";
3
+ export declare const ownedAccount: <A extends Pick<AccountItem, "accountId" | "accountConfigId">>(account: A, accountConfigId: string, mode: AccessMode) => Refusable<A>;
4
+ export declare const folderInAccount: <M extends Pick<MailboxItem, "mailboxId" | "accountId">>(mailbox: M, accountId: string, mode: AccessMode) => Refusable<M>;
5
+ export declare const settledFolder: <M extends Pick<MailboxItem, "mailboxId" | "fullPath" | "syncStatus">>(target: M) => Refusable<M>;
6
+ export interface MessageOwnershipClient {
7
+ message: {
8
+ get(messageId: string): Promise<{
9
+ mailboxId: string;
10
+ }>;
11
+ };
12
+ mailbox: {
13
+ get(accountId: string, mailboxIds: string[]): Promise<{
14
+ mailboxId: string;
15
+ accountId: string;
16
+ }[]>;
17
+ };
18
+ account: {
19
+ listAllByAccountConfig(accountConfigId: string): Promise<{
20
+ accountId: string;
21
+ }[]>;
22
+ };
23
+ }
24
+ export declare const ownedMessages: (client: MessageOwnershipClient, messageIds: string[], callerAccountConfigId: string, mode: AccessMode) => Promise<Refusable<string>>;
@@ -0,0 +1,83 @@
1
+ import { MailboxSyncStatus } from "@remit/domain-enums";
2
+ import { accept, invalid, refuse, } from "./refusal.js";
3
+ // A read refuses with NotFound so another tenant's account does not leak its
4
+ // existence; an action names the resource already, so it is refused outright.
5
+ export const ownedAccount = (account, accountConfigId, mode) => {
6
+ if (account.accountConfigId === accountConfigId)
7
+ return accept(account);
8
+ if (mode === "read") {
9
+ return refuse({
10
+ kind: "NotFound",
11
+ message: `Account not found: ${account.accountId}`,
12
+ });
13
+ }
14
+ return refuse({
15
+ kind: "Forbidden",
16
+ message: `Account ${account.accountId} not in account config`,
17
+ });
18
+ };
19
+ // Owning the account in a path proves nothing about the mailboxId beside it.
20
+ export const folderInAccount = (mailbox, accountId, mode) => {
21
+ if (mailbox.accountId === accountId)
22
+ return accept(mailbox);
23
+ if (mode === "read") {
24
+ return refuse({
25
+ kind: "NotFound",
26
+ message: `Mailbox not found: ${mailbox.mailboxId}`,
27
+ });
28
+ }
29
+ return refuse({
30
+ kind: "Forbidden",
31
+ message: `Mailbox ${mailbox.mailboxId} not in account`,
32
+ });
33
+ };
34
+ const UNSETTLED_REASON = {
35
+ [MailboxSyncStatus.pending]: (path) => `“${path}” isn’t ready yet — the mail server hasn’t confirmed it.`,
36
+ [MailboxSyncStatus.deleting]: (path) => `“${path}” is being deleted.`,
37
+ [MailboxSyncStatus.failed]: (path) => `The last change to “${path}” failed. Retry or dismiss it first.`,
38
+ };
39
+ // A durable reference binds only to a folder the mail server has confirmed
40
+ // (imap-mutations R2: wait). This is the server-side floor under the client's
41
+ // own wait, because a script calling the API directly gets no wait at all.
42
+ export const settledFolder = (target) => {
43
+ const reason = UNSETTLED_REASON[target.syncStatus];
44
+ if (!reason)
45
+ return accept(target);
46
+ return refuse({
47
+ kind: "NotSettled",
48
+ message: reason(target.fullPath),
49
+ mailboxId: target.mailboxId,
50
+ syncStatus: target.syncStatus,
51
+ });
52
+ };
53
+ // Messages carry no tenant, so ownership resolves through the caller's own
54
+ // accounts: a mailbox resolves only under the account that owns it, and the
55
+ // whole batch must land on one account because the downstream services take
56
+ // a single accountId.
57
+ export const ownedMessages = async (client, messageIds, callerAccountConfigId, mode) => {
58
+ const messages = await Promise.all(messageIds.map((id) => client.message.get(id)));
59
+ const mailboxIds = [...new Set(messages.map((m) => m.mailboxId))];
60
+ const ownedAccounts = await client.account.listAllByAccountConfig(callerAccountConfigId);
61
+ const accountByMailbox = new Map();
62
+ for (const { accountId } of ownedAccounts) {
63
+ const rows = await client.mailbox.get(accountId, mailboxIds);
64
+ for (const row of rows) {
65
+ accountByMailbox.set(row.mailboxId, row.accountId);
66
+ }
67
+ }
68
+ if (mailboxIds.some((id) => !accountByMailbox.has(id))) {
69
+ if (mode === "read") {
70
+ return refuse({ kind: "NotFound", message: "Message not found" });
71
+ }
72
+ return refuse({
73
+ kind: "Forbidden",
74
+ message: "Message not in account config",
75
+ });
76
+ }
77
+ const accountIds = [...new Set(accountByMailbox.values())];
78
+ const [accountId] = accountIds;
79
+ if (accountIds.length !== 1 || accountId === undefined) {
80
+ return invalid("All messages must belong to the same account");
81
+ }
82
+ return accept(accountId);
83
+ };
@@ -0,0 +1,67 @@
1
+ import type { IMessageRepository, MessageItem } from "@remit/data-ports";
2
+ import type { PlacementMoveLogger } from "./placement-move.js";
3
+ import { type RestoreSourcePlacementDeps } from "./restore-source-placement.js";
4
+ import type { IImapConnection } from "./types.js";
5
+ export interface ResolveExhaustedPlacementMoveDeps {
6
+ messageService: Pick<IMessageRepository, "deleteIfPlacement"> & RestoreSourcePlacementDeps["messageService"];
7
+ threadMessageService: RestoreSourcePlacementDeps["threadMessageService"];
8
+ markerService: {
9
+ delete(messageId: string): Promise<void>;
10
+ };
11
+ log: PlacementMoveLogger;
12
+ }
13
+ export interface ResolveExhaustedPlacementMoveInput {
14
+ /** Where the row stood when the caller read it; the delete holds only while it still does. */
15
+ placement: Pick<MessageItem, "status" | "mailboxId" | "uid">;
16
+ accountId: string;
17
+ accountConfigId: string;
18
+ messageId: string;
19
+ uid: number;
20
+ sourceMailboxId: string;
21
+ sourceMailboxPath: string;
22
+ getConnection: () => Promise<IImapConnection>;
23
+ }
24
+ export type PlacementMoveTerminalOutcome = "reconciled" | "broken";
25
+ export interface ResolveExhaustedPlacementMoveResult {
26
+ outcome: PlacementMoveTerminalOutcome;
27
+ }
28
+ /**
29
+ * Resolve a PLACEMENT_MOVE_PUSH failure that has exhausted the placement-move
30
+ * queue's redelivery budget into exactly one of two terminal outcomes,
31
+ * mirroring `resolveExhaustedBodySyncFailures` (#1270) for the same failure
32
+ * taxonomy (epic #1281 invariant 3) — no third, softer outcome.
33
+ *
34
+ * 1. RECONCILED (expected) — the message no longer exists at its pending-move
35
+ * source on IMAP, confirmed by {@link isMessageGoneFromOpenMailbox} rather
36
+ * than by a FETCH coming back empty. Per invariant 2, an external delete
37
+ * supersedes the marker entirely: the marker is dropped and the stale
38
+ * Message/ThreadMessage rows are deleted via
39
+ * `deleteIfPlacement`. This is also the
40
+ * outcome for the (rarer, functionally indistinguishable from here) case
41
+ * where a foreign client moved the message elsewhere — either way, our
42
+ * prediction no longer holds, and the marker cannot be honoured. Metric
43
+ * only, no alarm — routine.
44
+ * 2. BROKEN — the message still exists at the source, but the move keeps
45
+ * failing. This indicates broken code or a broken account (issue #1271),
46
+ * not a transient blip. The row is put back on the source pair the server
47
+ * has just confirmed, exactly as the move and delete resolvers do, and the
48
+ * marker goes with it: an auto-file whose push gave up must not leave
49
+ * `moving` plus the destination folder plus the source's uid standing
50
+ * forever, because that pair refuses every later delete and move with a
51
+ * 409, blocks the sighting repair, and nothing else clears it. The failure
52
+ * is an operator's to read, so it lives in the `alert`-shaped log entry and
53
+ * the alarm on it — not in a row the user's own mail is stuck behind.
54
+ * Never re-thrown (terminal — the caller acks either way, since retrying a
55
+ * stale or permanently-broken move can never succeed).
56
+ *
57
+ * An operator reading `placement_move_failed` should know one case where the
58
+ * message is not actually at the source: a message another client expunged
59
+ * mid-session can answer an empty FETCH while the server still lists its UID
60
+ * in SEARCH, until it is allowed to send the untagged EXPUNGE. That message
61
+ * lands in BROKEN, and BROKEN is terminal — the alert stands until someone
62
+ * clears it, and the row goes back to a source the message may already have
63
+ * left. The reverse mistake deletes live mail, so the cost is paid
64
+ * deliberately: a stale alert over a row the next sync re-points is
65
+ * recoverable, a deleted message is not.
66
+ */
67
+ export declare const resolveExhaustedPlacementMoveFailure: (deps: ResolveExhaustedPlacementMoveDeps, input: ResolveExhaustedPlacementMoveInput) => Promise<ResolveExhaustedPlacementMoveResult>;
@@ -0,0 +1,88 @@
1
+ import { MessageMutation, MessageSyncStatus } from "@remit/domain-enums";
2
+ import { isMessageGoneFromOpenMailbox } from "./message-presence.js";
3
+ import { restoreSourcePlacement, } from "./restore-source-placement.js";
4
+ /**
5
+ * Resolve a PLACEMENT_MOVE_PUSH failure that has exhausted the placement-move
6
+ * queue's redelivery budget into exactly one of two terminal outcomes,
7
+ * mirroring `resolveExhaustedBodySyncFailures` (#1270) for the same failure
8
+ * taxonomy (epic #1281 invariant 3) — no third, softer outcome.
9
+ *
10
+ * 1. RECONCILED (expected) — the message no longer exists at its pending-move
11
+ * source on IMAP, confirmed by {@link isMessageGoneFromOpenMailbox} rather
12
+ * than by a FETCH coming back empty. Per invariant 2, an external delete
13
+ * supersedes the marker entirely: the marker is dropped and the stale
14
+ * Message/ThreadMessage rows are deleted via
15
+ * `deleteIfPlacement`. This is also the
16
+ * outcome for the (rarer, functionally indistinguishable from here) case
17
+ * where a foreign client moved the message elsewhere — either way, our
18
+ * prediction no longer holds, and the marker cannot be honoured. Metric
19
+ * only, no alarm — routine.
20
+ * 2. BROKEN — the message still exists at the source, but the move keeps
21
+ * failing. This indicates broken code or a broken account (issue #1271),
22
+ * not a transient blip. The row is put back on the source pair the server
23
+ * has just confirmed, exactly as the move and delete resolvers do, and the
24
+ * marker goes with it: an auto-file whose push gave up must not leave
25
+ * `moving` plus the destination folder plus the source's uid standing
26
+ * forever, because that pair refuses every later delete and move with a
27
+ * 409, blocks the sighting repair, and nothing else clears it. The failure
28
+ * is an operator's to read, so it lives in the `alert`-shaped log entry and
29
+ * the alarm on it — not in a row the user's own mail is stuck behind.
30
+ * Never re-thrown (terminal — the caller acks either way, since retrying a
31
+ * stale or permanently-broken move can never succeed).
32
+ *
33
+ * An operator reading `placement_move_failed` should know one case where the
34
+ * message is not actually at the source: a message another client expunged
35
+ * mid-session can answer an empty FETCH while the server still lists its UID
36
+ * in SEARCH, until it is allowed to send the untagged EXPUNGE. That message
37
+ * lands in BROKEN, and BROKEN is terminal — the alert stands until someone
38
+ * clears it, and the row goes back to a source the message may already have
39
+ * left. The reverse mistake deletes live mail, so the cost is paid
40
+ * deliberately: a stale alert over a row the next sync re-points is
41
+ * recoverable, a deleted message is not.
42
+ */
43
+ export const resolveExhaustedPlacementMoveFailure = async (deps, input) => {
44
+ const { accountId, accountConfigId, messageId, uid, sourceMailboxId, sourceMailboxPath, getConnection, } = input;
45
+ const connection = await getConnection();
46
+ await connection.openBox(sourceMailboxPath);
47
+ if (await isMessageGoneFromOpenMailbox(connection, uid)) {
48
+ await deps.markerService.delete(messageId);
49
+ const reconciled = await deps.messageService.deleteIfPlacement(messageId, input.placement);
50
+ deps.log.info({
51
+ metric: "placement_move_stale_row_reconciled",
52
+ accountId,
53
+ accountConfigId,
54
+ messageId,
55
+ uid,
56
+ sourceMailboxPath,
57
+ reconciled,
58
+ }, "Message no longer at its pending-move source after retry exhaustion (external delete or move); marker dropped, stale row reconciled");
59
+ return { outcome: "reconciled" };
60
+ }
61
+ // The row first, the marker second. The marker is the operator's evidence
62
+ // that this move still owed IMAP a push; dropping it before the restore
63
+ // means a restore that throws leaves the row stuck `moving` with nothing
64
+ // left to say why.
65
+ await restoreSourcePlacement(deps, {
66
+ accountConfigId,
67
+ messageId,
68
+ sourceMailboxId,
69
+ uid,
70
+ // No give-up marker: this move is Remit's own classification filing, not
71
+ // something the user asked for, so a per-message "your move failed" would
72
+ // report a failure against an intent nobody formed. The alert below is the
73
+ // whole surface, and it is an operator's.
74
+ syncStatus: MessageSyncStatus.synced,
75
+ abandonedMutation: MessageMutation.none,
76
+ });
77
+ await deps.markerService.delete(messageId);
78
+ deps.log.error({
79
+ alert: "placement_move_failed",
80
+ accountId,
81
+ accountConfigId,
82
+ messageId,
83
+ uid,
84
+ sourceMailboxId,
85
+ sourceMailboxPath,
86
+ }, "Placement move could not be pushed to IMAP after retry exhaustion; message still exists at its source — row restored to the source pair, marker dropped, alert left for operator investigation");
87
+ return { outcome: "broken" };
88
+ };
@@ -0,0 +1,83 @@
1
+ import type { IAddressRepository, IMailboxRepository, IMailboxSpecialUseRepository, IMessagePlacementMoveRepository, IMessageRepository, IThreadMessageRepository, QueueProducer } from "@remit/data-ports";
2
+ /**
3
+ * Event the reconciler (imap-worker `handlePlacementMovePush`) drains. Carries
4
+ * ONLY our own message id — never a UID (epic #1281 invariant 1). The UID is
5
+ * resolved fresh from the Message row at push time, so the event stays valid
6
+ * across any amount of queue delay or an unrelated UIDVALIDITY rebuild
7
+ * (#1272).
8
+ */
9
+ export interface PlacementMovePushEvent {
10
+ type: "PLACEMENT_MOVE_PUSH";
11
+ eventId: string;
12
+ timestamp: number;
13
+ accountId: string;
14
+ accountConfigId: string;
15
+ messageId: string;
16
+ }
17
+ /** Whether a filing happened. `refused` names a destination outside the message's account. */
18
+ export type PlacementMoveOutcome = "filed" | "skipped" | "refused";
19
+ export interface PlacementMoveLogger {
20
+ info(obj: Record<string, unknown>, msg: string): void;
21
+ error(obj: Record<string, unknown>, msg: string): void;
22
+ }
23
+ export interface PlacementMoveConfig {
24
+ messageService: IMessageRepository;
25
+ threadMessageService: IThreadMessageRepository;
26
+ markerService: IMessagePlacementMoveRepository;
27
+ addressService: IAddressRepository;
28
+ /**
29
+ * Required, not optional: this mover carries automatic spam placement, and
30
+ * one built without the folder-role seam would silently leave the sender of
31
+ * every auto-filed spam suggestible in the compose picker.
32
+ */
33
+ mailboxSpecialUseService: IMailboxSpecialUseRepository;
34
+ mailboxService: Pick<IMailboxRepository, "resolveAccountId">;
35
+ queue: QueueProducer<PlacementMovePushEvent>;
36
+ logger?: PlacementMoveLogger;
37
+ moveSettleTimeoutMs?: number;
38
+ moveSettlePollMs?: number;
39
+ }
40
+ /**
41
+ * Local-first mover for a classification-driven placement move (issue #1271,
42
+ * epic #1281). Distinct from {@link MessageMoveService} (user-initiated
43
+ * moves/deletes/copies), which is untouched by this fix — the epic's UID
44
+ * staleness invariant (1) only applies to the NEW reconciler this class
45
+ * feeds; generalizing it to every move type is out of scope here.
46
+ *
47
+ * `moveMessage` matches {@link MessageMoveService.moveMessage}'s signature so
48
+ * `BodySyncService`'s existing rescue/demote call site and its tests need no
49
+ * shape changes beyond the dependency's name.
50
+ *
51
+ * Ordering is the fix: every step below (marker, ThreadMessage, Message row,
52
+ * SQS enqueue) completes — or the whole call rejects — BEFORE the caller
53
+ * (`BodySyncService.storeStreamedBody`) writes `bodyStorageKey`. A rejection
54
+ * here therefore always happens before the body-sync skip-guard can trip, so
55
+ * a retry reprocesses the message from scratch. Nothing here is swallowed —
56
+ * let it crash, same as any other infra failure (an SQS enqueue failure is a
57
+ * serious operational failure, not a routine blip: it propagates so a
58
+ * repeated failure reaches the alarmed message-mgmt DLQ, never absorbed).
59
+ *
60
+ * Recovery is driven by the marker's explicit state engine (`pending ->
61
+ * queued -> processing -> processed`, `MessagePlacementMoveState`), not by
62
+ * comparing the message's current location against the requested
63
+ * destination — that comparison alone cannot tell "already confirmed" apart
64
+ * from "local move landed but the enqueue never did" (the defect this issue
65
+ * originally fixes). A retry always re-fetches the marker and drives it
66
+ * forward from whichever state it is actually in.
67
+ */
68
+ export declare class PlacementMoveService {
69
+ private messageService;
70
+ private threadMessageService;
71
+ private markerService;
72
+ private addressService;
73
+ private mailboxSpecialUseService;
74
+ private mailboxService;
75
+ private queue;
76
+ private log;
77
+ private moveSettleTimeoutMs;
78
+ private moveSettlePollMs;
79
+ constructor(config: PlacementMoveConfig);
80
+ moveMessage: (accountConfigId: string, messageId: string, destinationMailboxId: string, accountId: string) => Promise<PlacementMoveOutcome>;
81
+ private updateThreadMessageMailbox;
82
+ private enqueuePush;
83
+ }
@@ -0,0 +1,216 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { MessageMutation, MessageStatus, MessageSyncStatus, } from "@remit/domain-enums";
3
+ import { isPlacementUnsettled, waitForPlacementToSettle, } from "./placement-settled.js";
4
+ const noopLogger = {
5
+ info: () => { },
6
+ error: () => { },
7
+ };
8
+ const DEFAULT_MOVE_SETTLE_TIMEOUT_MS = 5_000;
9
+ const DEFAULT_MOVE_SETTLE_POLL_MS = 250;
10
+ /**
11
+ * Local-first mover for a classification-driven placement move (issue #1271,
12
+ * epic #1281). Distinct from {@link MessageMoveService} (user-initiated
13
+ * moves/deletes/copies), which is untouched by this fix — the epic's UID
14
+ * staleness invariant (1) only applies to the NEW reconciler this class
15
+ * feeds; generalizing it to every move type is out of scope here.
16
+ *
17
+ * `moveMessage` matches {@link MessageMoveService.moveMessage}'s signature so
18
+ * `BodySyncService`'s existing rescue/demote call site and its tests need no
19
+ * shape changes beyond the dependency's name.
20
+ *
21
+ * Ordering is the fix: every step below (marker, ThreadMessage, Message row,
22
+ * SQS enqueue) completes — or the whole call rejects — BEFORE the caller
23
+ * (`BodySyncService.storeStreamedBody`) writes `bodyStorageKey`. A rejection
24
+ * here therefore always happens before the body-sync skip-guard can trip, so
25
+ * a retry reprocesses the message from scratch. Nothing here is swallowed —
26
+ * let it crash, same as any other infra failure (an SQS enqueue failure is a
27
+ * serious operational failure, not a routine blip: it propagates so a
28
+ * repeated failure reaches the alarmed message-mgmt DLQ, never absorbed).
29
+ *
30
+ * Recovery is driven by the marker's explicit state engine (`pending ->
31
+ * queued -> processing -> processed`, `MessagePlacementMoveState`), not by
32
+ * comparing the message's current location against the requested
33
+ * destination — that comparison alone cannot tell "already confirmed" apart
34
+ * from "local move landed but the enqueue never did" (the defect this issue
35
+ * originally fixes). A retry always re-fetches the marker and drives it
36
+ * forward from whichever state it is actually in.
37
+ */
38
+ export class PlacementMoveService {
39
+ messageService;
40
+ threadMessageService;
41
+ markerService;
42
+ addressService;
43
+ mailboxSpecialUseService;
44
+ mailboxService;
45
+ queue;
46
+ log;
47
+ moveSettleTimeoutMs;
48
+ moveSettlePollMs;
49
+ constructor(config) {
50
+ this.messageService = config.messageService;
51
+ this.threadMessageService = config.threadMessageService;
52
+ this.markerService = config.markerService;
53
+ this.addressService = config.addressService;
54
+ this.mailboxSpecialUseService = config.mailboxSpecialUseService;
55
+ this.mailboxService = config.mailboxService;
56
+ this.queue = config.queue;
57
+ this.log = config.logger ?? noopLogger;
58
+ this.moveSettleTimeoutMs =
59
+ config.moveSettleTimeoutMs ?? DEFAULT_MOVE_SETTLE_TIMEOUT_MS;
60
+ this.moveSettlePollMs =
61
+ config.moveSettlePollMs ?? DEFAULT_MOVE_SETTLE_POLL_MS;
62
+ }
63
+ moveMessage = async (accountConfigId, messageId, destinationMailboxId, accountId) => {
64
+ let message = await this.messageService.get(messageId);
65
+ // Recovery: a marker already exists for THIS message and destination —
66
+ // from an earlier (possibly partially-failed) call. The marker's STATE,
67
+ // not a comparison against the message's current location, decides what
68
+ // happens next (PR #1289 review finding 1 + design amendment: an
69
+ // explicit pending -> queued -> processing -> processed engine).
70
+ const existingMarker = await this.markerService.find(messageId);
71
+ if (existingMarker &&
72
+ existingMarker.destinationMailboxId === destinationMailboxId) {
73
+ if (existingMarker.state === "pending") {
74
+ // Local move + marker already committed; only the enqueue is
75
+ // missing (or a further retry after the enqueue itself failed
76
+ // again). Drive the SAME marker forward — never re-derive the
77
+ // local move, which already happened.
78
+ await this.enqueuePush({ accountId, accountConfigId, messageId });
79
+ await this.markerService.updateState(messageId, "queued");
80
+ this.log.info({ messageId, accountId, destinationMailboxId }, "Pending marker survived an earlier enqueue failure; re-enqueued and advanced to queued");
81
+ }
82
+ // queued / processing / processed already has its own driver in
83
+ // flight (the SQS message, or the reconciler currently running) —
84
+ // nothing to do here either way.
85
+ return "filed";
86
+ }
87
+ // A different destination, decided while an earlier move is still
88
+ // unconfirmed (issue #496): the row names that move's destination but its
89
+ // `uid` still belongs to the folder before it, so a marker bound to the
90
+ // pair as it stands sends the reconciler to move the destination folder's
91
+ // OWN message at that uid. Wait for the earlier move to confirm and bind
92
+ // to the settled row (docs/architecture/imap-mutations.md R2). Blocking is
93
+ // cheap — a move settles in well under a second — and on timeout the
94
+ // dependent write is simply not made.
95
+ // A delete in flight is not a move to wait out: the user has asked for the
96
+ // message to go, and a classification filing behind it has nothing left to
97
+ // file. Skipping is the answer, and it is a clean one — throwing would
98
+ // burn the ceiling and then drive an ordinary sequence toward the DLQ.
99
+ if (message.status === MessageStatus.deleting) {
100
+ this.log.info({ messageId, accountId, destinationMailboxId }, "Placement move not applied: this message is being deleted");
101
+ return "skipped";
102
+ }
103
+ if (isPlacementUnsettled(message)) {
104
+ message = await waitForPlacementToSettle(this.messageService, messageId, {
105
+ timeoutMs: this.moveSettleTimeoutMs,
106
+ pollMs: this.moveSettlePollMs,
107
+ });
108
+ if (message.status === MessageStatus.deleting) {
109
+ this.log.info({ messageId, accountId, destinationMailboxId }, "Placement move not applied: a delete claimed this message while the earlier move settled");
110
+ return "skipped";
111
+ }
112
+ if (isPlacementUnsettled(message)) {
113
+ throw new Error(`Placement move for ${messageId} to ${destinationMailboxId} not applied: an earlier move has not settled`);
114
+ }
115
+ }
116
+ const sourceMailboxId = message.mailboxId;
117
+ // Genuine no-op: nothing pending for this destination, and the message
118
+ // is already there (a duplicate verdict recomputed after a confirmed
119
+ // move).
120
+ if (sourceMailboxId === destinationMailboxId)
121
+ return "skipped";
122
+ // IMAP has no MOVE between accounts, so a filing into a folder this
123
+ // account does not own would only ever be handed back.
124
+ if ((await this.mailboxService.resolveAccountId(destinationMailboxId)) !==
125
+ accountId) {
126
+ this.log.error({
127
+ alert: "placement_move_foreign_folder",
128
+ messageId,
129
+ accountId,
130
+ sourceMailboxId,
131
+ destinationMailboxId,
132
+ }, "Placement move refused: the destination is not a folder of this message's account");
133
+ return "refused";
134
+ }
135
+ // Fresh move. Strictly sequential — nothing here is parallel: the local
136
+ // move (marker + ThreadMessage + Message row) fully commits BEFORE the
137
+ // queue kick runs. `put` is idempotent and always resets `state` back to
138
+ // `pending`, since a fresh decision starts a new lifecycle regardless of
139
+ // what a stale row (different destination) held.
140
+ await this.markerService.put({
141
+ messageId,
142
+ accountId,
143
+ accountConfigId,
144
+ sourceMailboxId,
145
+ destinationMailboxId,
146
+ });
147
+ // The Message row first, the listing row after it — the order the user
148
+ // move and the delete already use. A transition off the row the wait above
149
+ // settled on (imap-mutations R3): this lane rides a standard queue that
150
+ // the account's FIFO group does not order, so a user's own move or delete
151
+ // can claim the row between that read and this write, and writing anyway
152
+ // would stamp Remit's classification over a folder the user just chose,
153
+ // with the old uid attached.
154
+ //
155
+ // Writing the listing row first meant a loser left the two disagreeing —
156
+ // the listing showing the classification folder while the Message row sat
157
+ // where the winner put it — and nothing routine reconciles that.
158
+ //
159
+ // The marker goes with the loss. It is the record that a move owes IMAP a
160
+ // push, and this move is not happening, so leaving it would send the
161
+ // reconciler after a placement nobody wrote and would inflate the folder
162
+ // counts it predicts.
163
+ const filed = await this.messageService.transitionPlacement(messageId, {
164
+ status: MessageStatus.active,
165
+ mailboxId: sourceMailboxId,
166
+ uid: message.uid,
167
+ }, {
168
+ mailboxId: destinationMailboxId,
169
+ status: MessageStatus.moving,
170
+ syncStatus: MessageSyncStatus.pending,
171
+ abandonedMutation: MessageMutation.none,
172
+ originalMailboxId: sourceMailboxId,
173
+ originalUid: message.uid,
174
+ });
175
+ if (!filed) {
176
+ await this.markerService.delete(messageId);
177
+ this.log.info({ messageId, accountId, sourceMailboxId, destinationMailboxId }, "Placement move not applied: something else claimed this message's placement first");
178
+ return "skipped";
179
+ }
180
+ await this.updateThreadMessageMailbox(accountConfigId, messageId, destinationMailboxId);
181
+ await this.addressService.reconcileJunkOnlyForMessage(messageId, await this.mailboxSpecialUseService.resolveJunkRolesForConfig(accountConfigId));
182
+ // The queue kick is a serious operational step, not a routine one — a
183
+ // failure here MUST propagate (never swallowed): the marker stays
184
+ // `pending`, the surrounding body-sync call fails, SQS redelivers, and
185
+ // a repeated failure reaches the alarmed message-mgmt DLQ. The state
186
+ // engine makes this *recoverable* (the recovery branch above drives a
187
+ // surviving `pending` marker forward on the next call) — it must never
188
+ // make it *quiet*.
189
+ await this.enqueuePush({ accountId, accountConfigId, messageId });
190
+ await this.markerService.updateState(messageId, "queued");
191
+ this.log.info({ messageId, accountId, sourceMailboxId, destinationMailboxId }, "Placement move applied locally; pending marker queued for IMAP push");
192
+ return "filed";
193
+ };
194
+ updateThreadMessageMailbox = async (accountConfigId, messageId, destinationMailboxId) => {
195
+ const threadMessage = await this.threadMessageService.getByMessageId(accountConfigId, messageId);
196
+ await this.threadMessageService.update(threadMessage.accountConfigId, threadMessage.threadMessageId, { mailboxId: destinationMailboxId }, {
197
+ composites: {
198
+ sentDate: threadMessage.sentDate,
199
+ mailboxId: threadMessage.mailboxId,
200
+ isRead: threadMessage.isRead,
201
+ isDeleted: threadMessage.isDeleted,
202
+ hasStars: threadMessage.hasStars,
203
+ hasAttachment: threadMessage.hasAttachment,
204
+ },
205
+ });
206
+ };
207
+ enqueuePush = async (params) => {
208
+ const event = {
209
+ type: "PLACEMENT_MOVE_PUSH",
210
+ eventId: randomUUID(),
211
+ timestamp: Date.now(),
212
+ ...params,
213
+ };
214
+ await this.queue.send({ body: event });
215
+ };
216
+ }