@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,1348 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
- import {
3
- SendMessageBatchCommand,
4
- SendMessageCommand,
5
- type SQSClient,
6
- } from "@aws-sdk/client-sqs";
7
- import type {
8
- IAddressRepository,
9
- IMailboxRepository,
10
- IMailboxSpecialUseRepository,
11
- IMessageRepository,
12
- IThreadMessageRepository,
13
- MessageItem,
14
- } from "@remit/data-ports";
15
- import {
16
- FolderRoleUnresolvedError,
17
- MessagePlacementUnsettledError,
18
- } from "@remit/data-ports/errors";
19
- import {
20
- type FolderRoleUnresolvedReason,
21
- NO_TRASH_FOLDER_REASON,
22
- type RoleResolution,
23
- STALE_TRASH_FOLDER_REASON,
24
- type TrashAssuranceLevel,
25
- trashMailboxAt,
26
- UNCONFIRMED_TRASH_FOLDER_REASON,
27
- } from "@remit/data-ports/folder-role";
28
- import { deriveCopyMessageId } from "@remit/data-ports/id";
29
- import { MUTATION_EVENT_SCHEMA_VERSION } from "@remit/data-ports/mutation-events";
30
- import {
31
- CanonicalMailboxRole,
32
- MessageMutation,
33
- MessageStatus,
34
- MessageSyncStatus,
35
- } from "@remit/domain-enums";
36
- import { createQueueProducer } from "@remit/sqs-client/producer";
37
- import {
38
- carriesForeignUid,
39
- placementBindingOf,
40
- waitForPlacementToSettle,
41
- } from "./placement-settled.js";
42
-
43
- /**
44
- * What a delete batch did not claim. A row whose placement changed between the
45
- * read that decided this delete and the mark that recorded it belongs to
46
- * whoever changed it (docs/architecture/imap-mutations.md R3), so this delete
47
- * leaves it alone — and says which, because the client removed it optimistically
48
- * and a row that reappears unexplained is a failure the user cannot act on.
49
- */
50
- export interface DeleteMessagesOutcome {
51
- refusedMessageIds: string[];
52
- }
53
-
54
- /**
55
- * Event types for message move/delete operations.
56
- * These match the worker event types in remit-imap-worker/events.ts.
57
- */
58
- interface MessageDeleteEvent {
59
- type: "MESSAGE_DELETE";
60
- schemaVersion: typeof MUTATION_EVENT_SCHEMA_VERSION;
61
- eventId: string;
62
- timestamp: number;
63
- accountId: string;
64
- messageId: string;
65
- mailboxId: string;
66
- mailboxPath: string;
67
- uid: number;
68
- operation: "move_to_trash" | "permanent_delete";
69
- destinationMailboxId?: string;
70
- destinationMailboxPath?: string;
71
- }
72
-
73
- interface MessageMoveEvent {
74
- type: "MESSAGE_MOVE";
75
- eventId: string;
76
- timestamp: number;
77
- accountId: string;
78
- messageId: string;
79
- sourceMailboxId: string;
80
- sourceMailboxPath: string;
81
- destinationMailboxId: string;
82
- destinationMailboxPath: string;
83
- uid: number;
84
- }
85
-
86
- interface EmptyTrashEvent {
87
- type: "EMPTY_TRASH";
88
- schemaVersion: typeof MUTATION_EVENT_SCHEMA_VERSION;
89
- eventId: string;
90
- timestamp: number;
91
- accountId: string;
92
- trashMailboxId: string;
93
- trashMailboxPath: string;
94
- /** The folder's UIDVALIDITY at consent time; the worker re-checks it. */
95
- trashUidValidity: number;
96
- }
97
-
98
- interface MessageCopyEvent {
99
- type: "MESSAGE_COPY";
100
- eventId: string;
101
- timestamp: number;
102
- accountId: string;
103
- sourceMessageId: string;
104
- newMessageId: string;
105
- sourceMailboxId: string;
106
- sourceMailboxPath: string;
107
- destinationMailboxId: string;
108
- destinationMailboxPath: string;
109
- uid: number;
110
- }
111
-
112
- type MessageMoveQueueEvent =
113
- | MessageDeleteEvent
114
- | MessageMoveEvent
115
- | EmptyTrashEvent
116
- | MessageCopyEvent;
117
-
118
- /**
119
- * Logger interface
120
- */
121
- export interface MessageMoveLogger {
122
- info(obj: Record<string, unknown>, msg: string): void;
123
- error(obj: Record<string, unknown>, msg: string): void;
124
- warn?(obj: Record<string, unknown>, msg: string): void;
125
- }
126
-
127
- const noopLogger: MessageMoveLogger = {
128
- info: () => {},
129
- error: () => {},
130
- };
131
-
132
- /**
133
- * Configuration for MessageMoveService
134
- */
135
- export interface MessageMoveConfig {
136
- messageService: IMessageRepository;
137
- mailboxService: IMailboxRepository;
138
- mailboxSpecialUseService: IMailboxSpecialUseRepository;
139
- threadMessageService: IThreadMessageRepository;
140
- addressService: IAddressRepository;
141
- sqsQueueUrl: string;
142
- sqsEndpoint?: string;
143
- logger?: MessageMoveLogger;
144
- moveSettleTimeoutMs?: number;
145
- moveSettlePollMs?: number;
146
- }
147
-
148
- const DEFAULT_MOVE_SETTLE_TIMEOUT_MS = 5_000;
149
- const DEFAULT_MOVE_SETTLE_POLL_MS = 250;
150
-
151
- /**
152
- * The account resolves no Trash folder, so a move-to-Trash delete has nowhere
153
- * to put the mail and an Empty Trash has nothing it may empty. A designed,
154
- * user-facing outcome carrying its own text and a 409 the backend returns
155
- * verbatim: the absence of a Trash folder is a blocker the user resolves by
156
- * appointing one, never a licence to expunge.
157
- */
158
- export class NoTrashMailboxError extends FolderRoleUnresolvedError {
159
- name = "NoTrashMailboxError";
160
-
161
- constructor(accountId: string) {
162
- super(
163
- NO_TRASH_FOLDER_REASON,
164
- CanonicalMailboxRole.Trash,
165
- "none",
166
- accountId,
167
- );
168
- }
169
- }
170
-
171
- /**
172
- * The folder the user appointed as Trash is gone from the server. Distinct from
173
- * having no Trash at all: there is a decision on file, and the repair is to
174
- * point it at a folder that exists — not to let reader pick one instead.
175
- */
176
- export class StaleTrashAppointmentError extends FolderRoleUnresolvedError {
177
- name = "StaleTrashAppointmentError";
178
-
179
- constructor(accountId: string) {
180
- super(
181
- STALE_TRASH_FOLDER_REASON,
182
- CanonicalMailboxRole.Trash,
183
- "stale",
184
- accountId,
185
- );
186
- }
187
- }
188
-
189
- /**
190
- * A folder resolves as Trash by its name alone. Enough to move mail into,
191
- * never enough to expunge — raised by Empty Trash and by a delete that would
192
- * expunge a message already sitting in that folder.
193
- */
194
- export class UnconfirmedTrashMailboxError extends FolderRoleUnresolvedError {
195
- name = "UnconfirmedTrashMailboxError";
196
-
197
- constructor(accountId: string) {
198
- super(
199
- UNCONFIRMED_TRASH_FOLDER_REASON,
200
- CanonicalMailboxRole.Trash,
201
- "unconfirmed",
202
- accountId,
203
- );
204
- }
205
- }
206
-
207
- const REFUSAL_BY_REASON: Record<
208
- FolderRoleUnresolvedReason,
209
- new (
210
- accountId: string,
211
- ) => FolderRoleUnresolvedError
212
- > = {
213
- none: NoTrashMailboxError,
214
- stale: StaleTrashAppointmentError,
215
- unconfirmed: UnconfirmedTrashMailboxError,
216
- };
217
-
218
- /** The coded 409 for one refusal reason. The only place a reason becomes an error. */
219
- const trashRefusal = (
220
- reason: FolderRoleUnresolvedReason,
221
- accountId: string,
222
- ): FolderRoleUnresolvedError => new REFUSAL_BY_REASON[reason](accountId);
223
-
224
- /**
225
- * The Trash folder a verb may act on at the assurance it demands, or the coded
226
- * refusal naming which of the three reasons stopped it. Every Trash gate goes
227
- * through here — the delete and the expunge — so no two of them can tell a
228
- * user different things about the same account.
229
- */
230
- export const requireTrashMailbox = <T>(
231
- resolution: RoleResolution<T>,
232
- level: TrashAssuranceLevel,
233
- accountId: string,
234
- ): T => {
235
- const outcome = trashMailboxAt(resolution, level);
236
- if (outcome.allowed) return outcome.mailbox;
237
- throw trashRefusal(outcome.reason, accountId);
238
- };
239
-
240
- /**
241
- * Options for delete operations
242
- */
243
- export interface DeleteOptions {
244
- /** Move to Trash instead of permanent delete. Default: true */
245
- toTrash?: boolean;
246
- /** Permanently delete even if in Trash. Default: false */
247
- permanent?: boolean;
248
- }
249
-
250
- /**
251
- * Service for moving, copying, and deleting messages.
252
- *
253
- * Implements optimistic local-first pattern:
254
- * 1. Updates local state immediately (Message + ThreadMessage)
255
- * 2. Enqueues event to SQS for worker to sync to IMAP
256
- *
257
- * Following RFC 016 for message deletion and moving.
258
- */
259
- export class MessageMoveService {
260
- private messageService: IMessageRepository;
261
- private mailboxService: IMailboxRepository;
262
- private mailboxSpecialUseService: IMailboxSpecialUseRepository;
263
- private threadMessageService: IThreadMessageRepository;
264
- private addressService: IAddressRepository;
265
- private sqs: SQSClient;
266
- private queueUrl: string;
267
- private log: MessageMoveLogger;
268
- private moveSettleTimeoutMs: number;
269
- private moveSettlePollMs: number;
270
-
271
- constructor(config: MessageMoveConfig) {
272
- this.messageService = config.messageService;
273
- this.mailboxService = config.mailboxService;
274
- this.mailboxSpecialUseService = config.mailboxSpecialUseService;
275
- this.threadMessageService = config.threadMessageService;
276
- this.addressService = config.addressService;
277
- this.queueUrl = config.sqsQueueUrl;
278
- this.log = config.logger ?? noopLogger;
279
- this.moveSettleTimeoutMs =
280
- config.moveSettleTimeoutMs ?? DEFAULT_MOVE_SETTLE_TIMEOUT_MS;
281
- this.moveSettlePollMs =
282
- config.moveSettlePollMs ?? DEFAULT_MOVE_SETTLE_POLL_MS;
283
-
284
- this.sqs = createQueueProducer({
285
- queueUrl: config.sqsQueueUrl,
286
- endpoint: config.sqsEndpoint,
287
- });
288
- }
289
-
290
- /**
291
- * Delete a message. By default moves to Trash.
292
- *
293
- * @param messageId - Message to delete
294
- * @param accountId - Account ID
295
- * @param options - Delete options (toTrash, permanent)
296
- */
297
- deleteMessage = async (
298
- accountConfigId: string,
299
- messageId: string,
300
- accountId: string,
301
- options: DeleteOptions = { toTrash: true },
302
- ): Promise<DeleteMessagesOutcome> =>
303
- this.deleteMessages(accountConfigId, [messageId], accountId, options);
304
-
305
- /**
306
- * Delete multiple messages using batch operations.
307
- * By default moves to Trash, unless permanent is specified or already in Trash.
308
- *
309
- * @param messageIds - Messages to delete
310
- * @param accountId - Account ID
311
- * @param options - Delete options (toTrash, permanent)
312
- */
313
- deleteMessages = async (
314
- accountConfigId: string,
315
- messageIds: string[],
316
- accountId: string,
317
- options: DeleteOptions = { toTrash: true },
318
- ): Promise<DeleteMessagesOutcome> => {
319
- if (messageIds.length === 0) return { refusedMessageIds: [] };
320
-
321
- // Batch get all messages
322
- const rows = await this.messageService.get(messageIds);
323
- if (rows.length === 0) return { refusedMessageIds: [] };
324
-
325
- // Ahead of the Trash gates below, which compare `mailboxId` against the
326
- // Trash folder: an in-flight move makes that comparison answer for a
327
- // folder the message has not reached.
328
- const messages = await this.settledPlacements(rows, accountId);
329
-
330
- // Get unique mailbox IDs and batch fetch mailboxes
331
- const uniqueMailboxIds = [...new Set(messages.map((m) => m.mailboxId))];
332
- const mailboxes = await this.mailboxService.get(
333
- accountId,
334
- uniqueMailboxIds,
335
- );
336
- const mailboxMap = new Map(mailboxes.map((m) => [m.mailboxId, m]));
337
-
338
- // A permanent delete is only ever what the caller explicitly asked for.
339
- // Everything else is a move to Trash, and a Trash folder that does not
340
- // resolve blocks it — the user was asked "move to Trash?" and expunging
341
- // their mail instead is unrecoverable and answers a question nobody put
342
- // to them. Thrown before any local write or event, so the server and the
343
- // projection are both left untouched.
344
- const wantsPermanentDelete =
345
- options.permanent === true || options.toTrash === false;
346
-
347
- const trashResolution = wantsPermanentDelete
348
- ? null
349
- : await this.mailboxSpecialUseService.resolveTrashRole(accountId);
350
-
351
- const trashGate = trashResolution
352
- ? trashMailboxAt(trashResolution, "resolved")
353
- : null;
354
-
355
- if (trashGate && !trashGate.allowed) {
356
- this.log.error(
357
- { accountId, messageCount: messages.length, reason: trashGate.reason },
358
- "Refused to delete: this account's Trash is unresolved",
359
- );
360
- throw trashRefusal(trashGate.reason, accountId);
361
- }
362
-
363
- const trashMailbox = trashGate ? trashGate.mailbox : null;
364
-
365
- // A message already sitting in the folder resolved as Trash deletes by
366
- // expunging it, not by moving it — the same unrecoverable act Empty
367
- // Trash performs, so it demands the same confirmed evidence. The name
368
- // guess ("resolved") is enough to file mail into; it is never enough to
369
- // destroy mail that is already there. Checked once, before any local
370
- // write, so a batch that touches such a message is refused whole rather
371
- // than partially expunged.
372
- if (!wantsPermanentDelete && trashResolution && trashMailbox) {
373
- const deletesInPlace = messages.some(
374
- (message) => message.mailboxId === trashMailbox.mailboxId,
375
- );
376
-
377
- if (deletesInPlace) {
378
- const confirmedTrashGate = trashMailboxAt(trashResolution, "confirmed");
379
- if (!confirmedTrashGate.allowed) {
380
- this.log.error(
381
- {
382
- accountId,
383
- messageCount: messages.length,
384
- reason: confirmedTrashGate.reason,
385
- },
386
- "Refused to delete: this account's Trash is not confirmed",
387
- );
388
- throw trashRefusal(confirmedTrashGate.reason, accountId);
389
- }
390
- }
391
- }
392
-
393
- // Group messages by operation type
394
- type DeleteEntry = {
395
- messageId: string;
396
- message: { mailboxId: string; uid: number };
397
- sourceMailbox: { mailboxId: string; fullPath: string };
398
- };
399
- const moveToTrashMessages: DeleteEntry[] = [];
400
- const permanentDeleteMessages: DeleteEntry[] = [];
401
-
402
- // Rows whose placement changed between the read above and the mark below,
403
- // so this delete never claimed them. Reported rather than skipped in
404
- // silence: the client removed them optimistically, and a row that
405
- // reappears with nothing said is the dead-button failure again (#1229).
406
- const refused: string[] = [];
407
-
408
- for (const message of messages) {
409
- const sourceMailbox = mailboxMap.get(message.mailboxId);
410
- if (!sourceMailbox) {
411
- // The folder lookup is scoped to one account, so a selection that
412
- // spans accounts — the daily brief lists every account's mail —
413
- // leaves this row's mailbox unresolved. Nothing about it was
414
- // written and no event was enqueued, so it is a refusal like any
415
- // other; dropping it silently answered "deleted" over a message
416
- // still sitting where it was.
417
- refused.push(message.messageId);
418
- this.log.info(
419
- { accountId, messageId: message.messageId },
420
- "Refused delete: this message's folder does not belong to this account",
421
- );
422
- continue;
423
- }
424
-
425
- const isInTrash =
426
- trashMailbox !== null && message.mailboxId === trashMailbox.mailboxId;
427
- const shouldMoveToTrash = trashMailbox !== null && !isInTrash;
428
-
429
- const entry = {
430
- messageId: message.messageId,
431
- message: { mailboxId: message.mailboxId, uid: message.uid },
432
- sourceMailbox: {
433
- mailboxId: sourceMailbox.mailboxId,
434
- fullPath: sourceMailbox.fullPath,
435
- },
436
- };
437
-
438
- if (shouldMoveToTrash) {
439
- moveToTrashMessages.push(entry);
440
- } else {
441
- permanentDeleteMessages.push(entry);
442
- }
443
- }
444
-
445
- // Collect all events for batch SQS send
446
- const events: MessageDeleteEvent[] = [];
447
-
448
- // Process move to trash
449
- if (trashMailbox && moveToTrashMessages.length > 0) {
450
- // Every message in the batch shares the config, so its Junk and Trash
451
- // folders resolve once rather than once per message.
452
- const configJunkRoles =
453
- await this.mailboxSpecialUseService.resolveJunkRolesForConfig(
454
- accountConfigId,
455
- );
456
- for (const { messageId, message, sourceMailbox } of moveToTrashMessages) {
457
- // The optimistic write is a transition off the row this call read
458
- // (imap-mutations R3). Several folder lookups stand between the read
459
- // and here, and PLACEMENT_MOVE_PUSH runs on a queue this account's
460
- // FIFO group does not order, so the row can settle onto a different
461
- // folder and uid in between. Writing anyway would stamp `moving` and
462
- // the OLD uid over the new placement, and the event enqueued below
463
- // would name a uid the source no longer holds — a delete aimed at
464
- // whatever message has since taken that number.
465
- const marked = await this.messageService.transitionPlacement(
466
- messageId,
467
- {
468
- // `active`, not whatever the row was read as: a row already
469
- // `moving` or `deleting` is mid-mutation, and marking it again
470
- // would enqueue a second event against the same uid.
471
- status: MessageStatus.active,
472
- mailboxId: message.mailboxId,
473
- uid: message.uid,
474
- },
475
- {
476
- mailboxId: trashMailbox.mailboxId,
477
- status: MessageStatus.moving,
478
- syncStatus: MessageSyncStatus.pending,
479
- abandonedMutation: MessageMutation.none,
480
- originalMailboxId: sourceMailbox.mailboxId,
481
- originalUid: message.uid,
482
- },
483
- );
484
- if (!marked) {
485
- refused.push(messageId);
486
- this.log.info(
487
- { accountId, messageId },
488
- "Refused delete: this message's placement changed after it was read",
489
- );
490
- continue;
491
- }
492
-
493
- // Update ThreadMessage
494
- await this.updateThreadMessageForMove(
495
- accountConfigId,
496
- messageId,
497
- trashMailbox.mailboxId,
498
- true,
499
- );
500
-
501
- await this.addressService.reconcileJunkOnlyForMessage(
502
- messageId,
503
- configJunkRoles,
504
- );
505
-
506
- events.push({
507
- type: "MESSAGE_DELETE",
508
- schemaVersion: MUTATION_EVENT_SCHEMA_VERSION,
509
- eventId: randomUUID(),
510
- timestamp: Date.now(),
511
- accountId,
512
- messageId,
513
- mailboxId: sourceMailbox.mailboxId,
514
- mailboxPath: sourceMailbox.fullPath,
515
- uid: message.uid,
516
- operation: "move_to_trash",
517
- destinationMailboxId: trashMailbox.mailboxId,
518
- destinationMailboxPath: trashMailbox.fullPath,
519
- });
520
- }
521
-
522
- this.log.info(
523
- {
524
- count: moveToTrashMessages.length,
525
- trashMailboxId: trashMailbox.mailboxId,
526
- },
527
- "Moved messages to trash (local)",
528
- );
529
- }
530
-
531
- // Process permanent deletes
532
- for (const {
533
- messageId,
534
- message,
535
- sourceMailbox,
536
- } of permanentDeleteMessages) {
537
- // A transition off the row this call read, like the trash path above
538
- // (imap-mutations R3). The expunge below is aimed at a specific uid in
539
- // a specific folder, so a row that has moved since the read must not be
540
- // marked for it: a restore out of Trash settling in between would be
541
- // overwritten with `deleting`, and the event would carry the uid Trash
542
- // no longer holds.
543
- const marked = await this.messageService.transitionPlacement(
544
- messageId,
545
- {
546
- // `active` for the same reason as the trash path above: a second
547
- // press on a row already `deleting` would enqueue a duplicate
548
- // expunge rather than refusing.
549
- status: MessageStatus.active,
550
- mailboxId: message.mailboxId,
551
- uid: message.uid,
552
- },
553
- {
554
- status: MessageStatus.deleting,
555
- syncStatus: MessageSyncStatus.pending,
556
- abandonedMutation: MessageMutation.none,
557
- },
558
- );
559
- if (!marked) {
560
- refused.push(messageId);
561
- this.log.info(
562
- { accountId, messageId },
563
- "Refused permanent delete: this message's placement changed after it was read",
564
- );
565
- continue;
566
- }
567
-
568
- // Delete ThreadMessage rows up-front (one row per mailbox copy).
569
- // The IMAP worker also deletes them once the IMAP DELETE succeeds —
570
- // doing it eagerly here closes the visibility window where the inbox
571
- // list shows a row whose backing Message is being deleted, leading
572
- // to "Message not found: <id>" on click. See issue #212.
573
- await this.deleteThreadMessagesForMessage(accountConfigId, messageId);
574
-
575
- events.push({
576
- type: "MESSAGE_DELETE",
577
- schemaVersion: MUTATION_EVENT_SCHEMA_VERSION,
578
- eventId: randomUUID(),
579
- timestamp: Date.now(),
580
- accountId,
581
- messageId,
582
- mailboxId: sourceMailbox.mailboxId,
583
- mailboxPath: sourceMailbox.fullPath,
584
- uid: message.uid,
585
- operation: "permanent_delete",
586
- });
587
- }
588
-
589
- if (permanentDeleteMessages.length > 0) {
590
- this.log.info(
591
- { count: permanentDeleteMessages.length },
592
- "Marked messages for permanent deletion (local)",
593
- );
594
- }
595
-
596
- // Batch send events to SQS
597
- await this.enqueueEventsBatch(events);
598
-
599
- return { refusedMessageIds: refused };
600
- };
601
-
602
- /**
603
- * Move a message to another mailbox.
604
- *
605
- * Waits, never reconciles (imap-mutations R2): the folder and uid go to the
606
- * server as one pair, and once the worker has moved the destination's own
607
- * message at that uid there is no reference left to repair. The person is
608
- * looking at the result, so blocking for the sub-second a move takes is the
609
- * cheap half; on timeout the move refuses ahead of any local write.
610
- *
611
- * @param messageId - Message to move
612
- * @param destinationMailboxId - Destination mailbox ID
613
- * @param accountId - Account ID
614
- */
615
- moveMessage = async (
616
- accountConfigId: string,
617
- messageId: string,
618
- destinationMailboxId: string,
619
- accountId: string,
620
- ): Promise<void> => {
621
- await this.moveSettledMessage(
622
- accountConfigId,
623
- await this.settledPlacement(
624
- await this.messageService.get(messageId),
625
- accountId,
626
- ),
627
- destinationMailboxId,
628
- accountId,
629
- );
630
- };
631
-
632
- private moveSettledMessage = async (
633
- accountConfigId: string,
634
- message: MessageItem,
635
- destinationMailboxId: string,
636
- accountId: string,
637
- ): Promise<void> => {
638
- const messageId = message.messageId;
639
-
640
- // IMAP has no same-mailbox MOVE: the server copies the message, expunges
641
- // the original and hands back a fresh UID, so a request that asks for the
642
- // mailbox the message is already in destroys its identity to reach the
643
- // state it was already in. The requested end state holds, so there is
644
- // nothing to record as pending and nothing to enqueue. The row is settled
645
- // by the time this compares, so the folder it names is one the message has
646
- // actually reached rather than one an in-flight move wrote optimistically.
647
- if (message.mailboxId === destinationMailboxId) {
648
- this.log.info(
649
- { messageId, mailboxId: destinationMailboxId },
650
- "Skipping move: message is already in the destination mailbox",
651
- );
652
- return;
653
- }
654
-
655
- const sourceMailbox = await this.mailboxService.get(
656
- accountId,
657
- message.mailboxId,
658
- );
659
- const destinationMailbox = await this.mailboxService.get(
660
- accountId,
661
- destinationMailboxId,
662
- );
663
-
664
- // Check if moving to/from Trash
665
- const trashMailbox =
666
- await this.mailboxSpecialUseService.findTrashMailbox(accountId);
667
- const isMovingToTrash = Boolean(
668
- trashMailbox && destinationMailboxId === trashMailbox.mailboxId,
669
- );
670
- const isMovingFromTrash = Boolean(
671
- trashMailbox && message.mailboxId === trashMailbox.mailboxId,
672
- );
673
-
674
- // The optimistic write is a transition off the row this call read
675
- // (imap-mutations R3). Three mailbox lookups stand between the read and
676
- // here, and PLACEMENT_MOVE_PUSH runs on a queue this account's FIFO group
677
- // does not order, so the row can settle onto a different folder and uid in
678
- // between. A blind write would stamp `moving` and the OLD uid over that
679
- // placement, leaving a pair `carriesForeignUid` calls consistent, and the
680
- // event below would send the worker after whatever now holds the old uid.
681
- const moved = await this.messageService.transitionPlacement(
682
- messageId,
683
- {
684
- status: MessageStatus.active,
685
- mailboxId: message.mailboxId,
686
- uid: message.uid,
687
- },
688
- {
689
- mailboxId: destinationMailboxId,
690
- status: MessageStatus.moving,
691
- syncStatus: MessageSyncStatus.pending,
692
- abandonedMutation: MessageMutation.none,
693
- originalMailboxId: sourceMailbox.mailboxId,
694
- originalUid: message.uid,
695
- },
696
- );
697
- if (!moved) {
698
- this.log.error(
699
- { accountId, messageId, destinationMailboxId },
700
- "Refused: this message's placement changed while the move was being prepared",
701
- );
702
- throw new MessagePlacementUnsettledError(
703
- `Message ${messageId} was not moved: something else changed where it is while this move was being prepared`,
704
- accountId,
705
- messageId,
706
- "in_flight",
707
- );
708
- }
709
-
710
- // Update ThreadMessage
711
- await this.updateThreadMessageForMove(
712
- accountConfigId,
713
- messageId,
714
- destinationMailboxId,
715
- isMovingToTrash,
716
- );
717
-
718
- await this.addressService.reconcileJunkOnlyForMessage(
719
- messageId,
720
- await this.mailboxSpecialUseService.resolveJunkRolesForConfig(
721
- accountConfigId,
722
- ),
723
- );
724
-
725
- // If moving FROM Trash, clear isDeleted
726
- if (isMovingFromTrash) {
727
- await this.updateThreadMessageDeleted(accountConfigId, messageId, false);
728
- }
729
-
730
- this.log.info(
731
- {
732
- messageId,
733
- from: sourceMailbox.fullPath,
734
- to: destinationMailbox.fullPath,
735
- },
736
- "Moved message (local)",
737
- );
738
-
739
- // Enqueue IMAP sync
740
- const event: MessageMoveEvent = {
741
- type: "MESSAGE_MOVE",
742
- eventId: randomUUID(),
743
- timestamp: Date.now(),
744
- accountId,
745
- messageId,
746
- sourceMailboxId: sourceMailbox.mailboxId,
747
- sourceMailboxPath: sourceMailbox.fullPath,
748
- destinationMailboxId,
749
- destinationMailboxPath: destinationMailbox.fullPath,
750
- uid: message.uid,
751
- };
752
-
753
- await this.enqueueEvent(event);
754
- };
755
-
756
- /**
757
- * Move multiple messages to another mailbox.
758
- *
759
- * @param messageIds - Messages to move
760
- * @param destinationMailboxId - Destination mailbox ID
761
- * @param accountId - Account ID
762
- */
763
- moveMessages = async (
764
- accountConfigId: string,
765
- messageIds: string[],
766
- destinationMailboxId: string,
767
- accountId: string,
768
- ): Promise<void> => {
769
- // Every row is settled before the first move is written, so the batch's
770
- // wait is one row's ceiling rather than the sum across it, and a refusal
771
- // lands before any part of the batch has committed.
772
- const sources = await this.settledPlacements(
773
- await this.messageService.get(messageIds),
774
- accountId,
775
- );
776
-
777
- for (const source of sources) {
778
- await this.moveSettledMessage(
779
- accountConfigId,
780
- source,
781
- destinationMailboxId,
782
- accountId,
783
- );
784
- }
785
- };
786
-
787
- /**
788
- * Copy a message to another mailbox.
789
- * Creates a new message record locally and enqueues IMAP COPY.
790
- *
791
- * @param messageId - Message to copy
792
- * @param destinationMailboxId - Destination mailbox ID
793
- * @param accountId - Account ID
794
- * @returns The new message ID for the copy
795
- */
796
- copyMessage = async (
797
- accountConfigId: string,
798
- messageId: string,
799
- destinationMailboxId: string,
800
- accountId: string,
801
- ): Promise<string> => {
802
- return this.copySettledMessage(
803
- accountConfigId,
804
- await this.settledPlacement(
805
- await this.messageService.get(messageId),
806
- accountId,
807
- ),
808
- destinationMailboxId,
809
- accountId,
810
- );
811
- };
812
-
813
- private copySettledMessage = async (
814
- accountConfigId: string,
815
- sourceMessage: MessageItem,
816
- destinationMailboxId: string,
817
- accountId: string,
818
- ): Promise<string> => {
819
- const messageId = sourceMessage.messageId;
820
- const sourceMailbox = await this.mailboxService.get(
821
- accountId,
822
- sourceMessage.mailboxId,
823
- );
824
- const destinationMailbox = await this.mailboxService.get(
825
- accountId,
826
- destinationMailboxId,
827
- );
828
-
829
- // Deterministic identity for the copy: derived from the source message and
830
- // the destination mailbox, so a replayed COPY event or a repeated copy
831
- // resolves to the same row instead of a fresh unreachable duplicate. A
832
- // random id (the prior behaviour) left rows no sync or delete could reach
833
- // (issue #75).
834
- const newMessageId = deriveCopyMessageId(messageId, destinationMailboxId);
835
-
836
- // Upsert, not create: the id is deterministic, so a repeated copy or a
837
- // retry after a partial failure (row written, event never delivered)
838
- // re-derives the same id. create throws on the existing row and strands
839
- // the copy at uid=0 with an undelivered event; upsert no-ops on the row
840
- // and lets the event be re-enqueued below, keeping the operation
841
- // idempotent and the copy re-drivable (issue #75).
842
- await this.messageService.upsert({
843
- messageId: newMessageId,
844
- mailboxId: destinationMailboxId,
845
- uid: 0, // Will be updated by worker after IMAP COPY
846
- sequenceNumber: 0, // Will be updated by worker
847
- rfc822Size: sourceMessage.rfc822Size,
848
- internalDate: sourceMessage.internalDate,
849
- messageIdHeader: sourceMessage.messageIdHeader,
850
- envelopeId: sourceMessage.envelopeId, // Share envelope with source
851
- rootBodyPartId: sourceMessage.rootBodyPartId, // Share body parts with source
852
- status: MessageStatus.moving,
853
- syncStatus: MessageSyncStatus.pending,
854
- bodyStorageKey: sourceMessage.bodyStorageKey,
855
- // The copy inherits the source's stored body, so body-sync's skip guard
856
- // will never re-derive these. Carry them across or the copy is
857
- // permanently `uncategorized` while its body says it is fully synced
858
- // (issue #45).
859
- category: sourceMessage.category,
860
- // The record of whether that category was the classifier's answer travels
861
- // with it (issue #331). Defaulting the copy to `NotExamined` would put a
862
- // row no classifier will ever reach — its body is already stored — into
863
- // the cohort a backfill selects as never examined.
864
- classificationState: sourceMessage.classificationState,
865
- hasListUnsubscribe: sourceMessage.hasListUnsubscribe,
866
- });
867
-
868
- // Copy ThreadMessage entry
869
- const sourceThreadMessage = await this.threadMessageService.getByMessageId(
870
- accountConfigId,
871
- messageId,
872
- );
873
-
874
- await this.threadMessageService.create({
875
- accountConfigId: sourceThreadMessage.accountConfigId,
876
- threadId: sourceThreadMessage.threadId,
877
- messageId: newMessageId,
878
- mailboxId: destinationMailboxId,
879
- uid: 0, // Will be updated by worker
880
- messageIdHeader: sourceThreadMessage.messageIdHeader,
881
- inReplyTo: sourceThreadMessage.inReplyTo,
882
- referenceOrder: sourceThreadMessage.referenceOrder,
883
- fromEmail: sourceThreadMessage.fromEmail,
884
- fromName: sourceThreadMessage.fromName,
885
- subject: sourceThreadMessage.subject,
886
- internalDate: sourceThreadMessage.internalDate,
887
- sentDate: sourceThreadMessage.sentDate,
888
- isRead: sourceThreadMessage.isRead,
889
- hasAttachment: sourceThreadMessage.hasAttachment,
890
- star: sourceThreadMessage.star,
891
- hasStars: sourceThreadMessage.hasStars,
892
- isDeleted: false,
893
- snippet: sourceThreadMessage.snippet,
894
- category: sourceThreadMessage.category,
895
- // Same reason as `category` above, and the same source row: the copy
896
- // inherits a stored body, so no classifying pass ever runs over it to
897
- // derive a `listId` of its own (issue #331).
898
- listId: sourceThreadMessage.listId,
899
- });
900
-
901
- this.log.info(
902
- {
903
- sourceMessageId: messageId,
904
- newMessageId,
905
- from: sourceMailbox.fullPath,
906
- to: destinationMailbox.fullPath,
907
- },
908
- "Created message copy (local)",
909
- );
910
-
911
- // Enqueue IMAP sync
912
- const event: MessageCopyEvent = {
913
- type: "MESSAGE_COPY",
914
- eventId: randomUUID(),
915
- timestamp: Date.now(),
916
- accountId,
917
- sourceMessageId: messageId,
918
- newMessageId,
919
- sourceMailboxId: sourceMailbox.mailboxId,
920
- sourceMailboxPath: sourceMailbox.fullPath,
921
- destinationMailboxId,
922
- destinationMailboxPath: destinationMailbox.fullPath,
923
- uid: sourceMessage.uid,
924
- };
925
-
926
- await this.enqueueEvent(event);
927
-
928
- return newMessageId;
929
- };
930
-
931
- /**
932
- * Copy multiple messages to another mailbox.
933
- *
934
- * @param messageIds - Messages to copy
935
- * @param destinationMailboxId - Destination mailbox ID
936
- * @param accountId - Account ID
937
- * @returns Array of new message IDs for the copies
938
- */
939
- copyMessages = async (
940
- accountConfigId: string,
941
- messageIds: string[],
942
- destinationMailboxId: string,
943
- accountId: string,
944
- ): Promise<string[]> => {
945
- // Every row is settled before the first copy is written, so the batch's
946
- // wait is one row's ceiling rather than the sum across it, and a refusal
947
- // lands before any part of the batch has committed.
948
- const sources = await this.settledPlacements(
949
- await this.messageService.get(messageIds),
950
- accountId,
951
- );
952
-
953
- const newMessageIds: string[] = [];
954
- for (const source of sources) {
955
- newMessageIds.push(
956
- await this.copySettledMessage(
957
- accountConfigId,
958
- source,
959
- destinationMailboxId,
960
- accountId,
961
- ),
962
- );
963
- }
964
- return newMessageIds;
965
- };
966
-
967
- /**
968
- * Restore a message from Trash to its original mailbox.
969
- *
970
- * @param messageId - Message to restore
971
- * @param accountId - Account ID
972
- */
973
- restoreMessage = async (
974
- accountConfigId: string,
975
- messageId: string,
976
- accountId: string,
977
- ): Promise<void> => {
978
- const message = await this.messageService.get(messageId);
979
-
980
- if (!message.originalMailboxId) {
981
- throw new Error("Message has no original mailbox to restore to");
982
- }
983
-
984
- await this.moveMessage(
985
- accountConfigId,
986
- messageId,
987
- message.originalMailboxId,
988
- accountId,
989
- );
990
- };
991
-
992
- /**
993
- * Empty the Trash mailbox: an EXPUNGE of everything in it, with no undo.
994
- *
995
- * Demands confirmed evidence: the folder the user appointed or the one the
996
- * server flagged \Trash — never one that merely reads like a trash folder,
997
- * and never the fallback behind an appointment that went missing. A user
998
- * with an ordinary folder called `Deleted` would otherwise lose its contents
999
- * permanently. Each of the three ways that evidence can be absent refuses
1000
- * under its own reason, so the surface can name the remedy instead of
1001
- * offering a silent no-op.
1002
- *
1003
- * Reports the rows it marked, read once here, so the number the caller shows
1004
- * a user is the number this operation acted on. Pressed twice before the
1005
- * worker runs it re-marks the same rows and reports the same number: the
1006
- * folder really does still hold them, and the second expunge finds nothing
1007
- * to do.
1008
- */
1009
- emptyTrash = async (
1010
- accountConfigId: string,
1011
- accountId: string,
1012
- ): Promise<{ deletedCount: number }> => {
1013
- const trashMailbox = requireTrashMailbox(
1014
- await this.mailboxSpecialUseService.resolveTrashRole(accountId),
1015
- "confirmed",
1016
- accountId,
1017
- );
1018
-
1019
- // The identity the user consented to, carried on the event so the worker
1020
- // can tell this folder from a different one that has since taken over its
1021
- // path. Read here rather than re-read there: the value at consent time is
1022
- // the whole point, and the stored row is rewritten by every sync.
1023
- const { uidValidity: trashUidValidity } = await this.mailboxService.get(
1024
- accountId,
1025
- trashMailbox.mailboxId,
1026
- );
1027
-
1028
- const rows = await this.messageService.listAllByMailbox(
1029
- trashMailbox.mailboxId,
1030
- );
1031
-
1032
- // Reconciles, never waits (imap-mutations R2). A row whose move into
1033
- // Trash has not settled names this folder while still carrying the SOURCE
1034
- // folder's uid, and marking it `deleting` overwrites the only marker that
1035
- // says so: its own MESSAGE_MOVE then reads the row as settled and returns
1036
- // without moving anything, and the worker's expunge binds that borrowed
1037
- // uid against whatever Trash really holds at it. Left as it stands, that
1038
- // move settles the row on its own, so the message is in Trash by the time
1039
- // the sweep reaches it and goes with the rest of this same press — only
1040
- // the count below is one short of what the sweep ends up removing.
1041
- //
1042
- // `carriesForeignUid`, the same predicate the sweep applies (#1217).
1043
- // The binding form reads `status` too, and `status` is exactly what this
1044
- // mark is about to overwrite, so the two halves of one gate would have
1045
- // disagreed the moment the mark landed.
1046
- const candidates = rows.filter((message) => !carriesForeignUid(message));
1047
-
1048
- // Each mark is a transition off the row as it was just read
1049
- // (imap-mutations R3), not a blind write onto a snapshot. The rows are
1050
- // held by nothing between the listing and here, and PLACEMENT_MOVE_PUSH
1051
- // runs on a standard queue that this account's FIFO group does not order,
1052
- // so a row can move out from under the sweep. A lost predicate means
1053
- // another lane won: the row is left alone and not counted, rather than
1054
- // marked `deleting` for an expunge that would bind somebody else's uid.
1055
- const messages: MessageItem[] = [];
1056
- for (const message of candidates) {
1057
- const marked = await this.messageService.transitionPlacement(
1058
- message.messageId,
1059
- {
1060
- status: message.status,
1061
- mailboxId: message.mailboxId,
1062
- uid: message.uid,
1063
- },
1064
- {
1065
- status: MessageStatus.deleting,
1066
- syncStatus: MessageSyncStatus.pending,
1067
- },
1068
- );
1069
- if (!marked) {
1070
- this.log.info(
1071
- { accountId, messageId: message.messageId },
1072
- "Empty Trash skipped a message whose placement changed while the folder was being read",
1073
- );
1074
- continue;
1075
- }
1076
- messages.push(marked);
1077
- await this.updateThreadMessageDeleted(
1078
- accountConfigId,
1079
- message.messageId,
1080
- true,
1081
- );
1082
- }
1083
-
1084
- this.log.info(
1085
- {
1086
- accountId,
1087
- trashMailboxId: trashMailbox.mailboxId,
1088
- count: messages.length,
1089
- skipped: rows.length - messages.length,
1090
- },
1091
- "Marked all trash messages for deletion (local)",
1092
- );
1093
-
1094
- // Enqueue single event for worker to handle batch
1095
- const event: EmptyTrashEvent = {
1096
- type: "EMPTY_TRASH",
1097
- schemaVersion: MUTATION_EVENT_SCHEMA_VERSION,
1098
- eventId: randomUUID(),
1099
- timestamp: Date.now(),
1100
- accountId,
1101
- trashMailboxId: trashMailbox.mailboxId,
1102
- trashMailboxPath: trashMailbox.fullPath,
1103
- trashUidValidity,
1104
- };
1105
-
1106
- await this.enqueueEvent(event);
1107
-
1108
- return { deletedCount: messages.length };
1109
- };
1110
-
1111
- private refusePlacement = (
1112
- message: MessageItem,
1113
- accountId: string,
1114
- ): never => {
1115
- this.log.error(
1116
- { accountId, messageId: message.messageId },
1117
- "Refused: this message's folder and uid do not name the same message",
1118
- );
1119
- throw new MessagePlacementUnsettledError(
1120
- `Message ${message.messageId} was not acted on: its folder and uid do not name the same message`,
1121
- accountId,
1122
- message.messageId,
1123
- "in_flight",
1124
- );
1125
- };
1126
-
1127
- private settledPlacement = async (
1128
- message: MessageItem,
1129
- accountId: string,
1130
- ): Promise<MessageItem> => {
1131
- if (placementBindingOf(message) === "consistent") return message;
1132
-
1133
- const settled = await waitForPlacementToSettle(
1134
- this.messageService,
1135
- message.messageId,
1136
- {
1137
- timeoutMs: this.moveSettleTimeoutMs,
1138
- pollMs: this.moveSettlePollMs,
1139
- },
1140
- );
1141
- if (placementBindingOf(settled) === "consistent") return settled;
1142
- return this.refusePlacement(settled, accountId);
1143
- };
1144
-
1145
- /**
1146
- * Settle a whole batch before any of it is acted on, so one unsettled row
1147
- * refuses the batch whole rather than leaving part of it expunged, and the
1148
- * batch's total wait is one row's ceiling rather than the sum across it.
1149
- */
1150
- private settledPlacements = async (
1151
- messages: MessageItem[],
1152
- accountId: string,
1153
- ): Promise<MessageItem[]> =>
1154
- Promise.all(
1155
- messages.map((message) => this.settledPlacement(message, accountId)),
1156
- );
1157
-
1158
- /**
1159
- * Update ThreadMessage for move operations.
1160
- * Updates mailboxId and optionally isDeleted.
1161
- */
1162
- private updateThreadMessageForMove = async (
1163
- accountConfigId: string,
1164
- messageId: string,
1165
- newMailboxId: string,
1166
- isDeleted: boolean,
1167
- ): Promise<void> => {
1168
- const threadMessage = await this.threadMessageService.getByMessageId(
1169
- accountConfigId,
1170
- messageId,
1171
- );
1172
-
1173
- this.log.info(
1174
- {
1175
- messageId,
1176
- accountConfigId: threadMessage.accountConfigId,
1177
- threadMessageId: threadMessage.threadMessageId,
1178
- sentDate: threadMessage.sentDate,
1179
- newMailboxId,
1180
- isDeleted,
1181
- },
1182
- "Updating ThreadMessage for move",
1183
- );
1184
-
1185
- await this.threadMessageService.update(
1186
- threadMessage.accountConfigId,
1187
- threadMessage.threadMessageId,
1188
- { mailboxId: newMailboxId, isDeleted },
1189
- {
1190
- // Composites contain CURRENT values for condition checking
1191
- // ElectroDB uses these to verify the item state before updating
1192
- composites: {
1193
- mailboxId: threadMessage.mailboxId,
1194
- sentDate: threadMessage.sentDate,
1195
- isRead: threadMessage.isRead,
1196
- isDeleted: threadMessage.isDeleted,
1197
- hasStars: threadMessage.hasStars,
1198
- hasAttachment: threadMessage.hasAttachment,
1199
- },
1200
- },
1201
- );
1202
-
1203
- this.log.info(
1204
- {
1205
- messageId,
1206
- threadMessageId: threadMessage.threadMessageId,
1207
- newMailboxId,
1208
- isDeleted,
1209
- },
1210
- "Updated ThreadMessage for move",
1211
- );
1212
- };
1213
-
1214
- /**
1215
- * Delete every ThreadMessage row that points at this messageId.
1216
- *
1217
- * A row is keyed by (threadId, messageId), so this is one row in practice —
1218
- * a message filed in several folders keeps a single row, because its
1219
- * messageId is derived from the Message-ID header and not from the mailbox.
1220
- * The query stays a list because the key permits more than one thread per
1221
- * message and nothing enforces otherwise.
1222
- *
1223
- * Deleting up-front in the permanent-delete optimistic step prevents stale
1224
- * rows from leaking into mailbox listings while IMAP catches up (#212).
1225
- */
1226
- private deleteThreadMessagesForMessage = async (
1227
- accountConfigId: string,
1228
- messageId: string,
1229
- ): Promise<void> => {
1230
- const rows = await this.threadMessageService.findAllByMessageId(
1231
- accountConfigId,
1232
- messageId,
1233
- );
1234
- for (const row of rows) {
1235
- await this.threadMessageService.delete(
1236
- row.accountConfigId,
1237
- row.threadMessageId,
1238
- );
1239
- }
1240
- this.log.info(
1241
- { messageId, deletedRows: rows.length },
1242
- "Deleted ThreadMessage rows for permanent-delete",
1243
- );
1244
- };
1245
-
1246
- /**
1247
- * Update ThreadMessage.isDeleted flag only.
1248
- */
1249
- private updateThreadMessageDeleted = async (
1250
- accountConfigId: string,
1251
- messageId: string,
1252
- isDeleted: boolean,
1253
- ): Promise<void> => {
1254
- const threadMessage = await this.threadMessageService.getByMessageId(
1255
- accountConfigId,
1256
- messageId,
1257
- );
1258
-
1259
- await this.threadMessageService.update(
1260
- threadMessage.accountConfigId,
1261
- threadMessage.threadMessageId,
1262
- { isDeleted },
1263
- {
1264
- // Composites contain CURRENT values for condition checking
1265
- composites: {
1266
- sentDate: threadMessage.sentDate,
1267
- mailboxId: threadMessage.mailboxId,
1268
- isRead: threadMessage.isRead,
1269
- isDeleted: threadMessage.isDeleted,
1270
- hasStars: threadMessage.hasStars,
1271
- hasAttachment: threadMessage.hasAttachment,
1272
- },
1273
- },
1274
- );
1275
-
1276
- this.log.info(
1277
- {
1278
- messageId,
1279
- threadMessageId: threadMessage.threadMessageId,
1280
- isDeleted,
1281
- },
1282
- "Updated ThreadMessage.isDeleted",
1283
- );
1284
- };
1285
-
1286
- /**
1287
- * Enqueue a message move/delete event to SQS.
1288
- *
1289
- * FIFO queues require MessageGroupId; standard queues reject it. We detect
1290
- * FIFO queues by the `.fifo` suffix and group by accountId so events for
1291
- * different accounts can be processed in parallel.
1292
- */
1293
- private enqueueEvent = async (
1294
- event: MessageMoveQueueEvent,
1295
- ): Promise<void> => {
1296
- const useFifo = this.queueUrl.endsWith(".fifo");
1297
- await this.sqs.send(
1298
- new SendMessageCommand({
1299
- QueueUrl: this.queueUrl,
1300
- MessageBody: JSON.stringify(event),
1301
- ...(useFifo && {
1302
- MessageGroupId: event.accountId,
1303
- MessageDeduplicationId: event.eventId,
1304
- }),
1305
- }),
1306
- );
1307
-
1308
- this.log.info(
1309
- { eventId: event.eventId, type: event.type },
1310
- "Enqueued message event",
1311
- );
1312
- };
1313
-
1314
- /**
1315
- * Enqueue multiple events to SQS using batch send.
1316
- * SQS batch limit is 10 messages, so we chunk if needed.
1317
- *
1318
- * FIFO queues require MessageGroupId on each batch entry; standard queues
1319
- * reject it. We detect FIFO queues by the `.fifo` suffix.
1320
- */
1321
- private enqueueEventsBatch = async (
1322
- events: MessageMoveQueueEvent[],
1323
- ): Promise<void> => {
1324
- if (events.length === 0) return;
1325
-
1326
- const SQS_BATCH_SIZE = 10;
1327
- const useFifo = this.queueUrl.endsWith(".fifo");
1328
-
1329
- for (let i = 0; i < events.length; i += SQS_BATCH_SIZE) {
1330
- const batch = events.slice(i, i + SQS_BATCH_SIZE);
1331
- await this.sqs.send(
1332
- new SendMessageBatchCommand({
1333
- QueueUrl: this.queueUrl,
1334
- Entries: batch.map((event, idx) => ({
1335
- Id: `${i + idx}`,
1336
- MessageBody: JSON.stringify(event),
1337
- ...(useFifo && {
1338
- MessageGroupId: event.accountId,
1339
- MessageDeduplicationId: event.eventId,
1340
- }),
1341
- })),
1342
- }),
1343
- );
1344
- }
1345
-
1346
- this.log.info({ count: events.length }, "Enqueued message events batch");
1347
- };
1348
- }