@remit/mailbox-service 0.0.112 → 0.0.113

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (270) hide show
  1. package/dist/account-credentials.d.ts +83 -0
  2. package/dist/account-credentials.js +104 -0
  3. package/dist/attribute-mapper.d.ts +36 -0
  4. package/dist/attribute-mapper.js +119 -0
  5. package/dist/body-parse.d.ts +28 -0
  6. package/{src/body-parse.ts → dist/body-parse.js} +18 -26
  7. package/dist/body-part-mapper.d.ts +92 -0
  8. package/dist/body-part-mapper.js +301 -0
  9. package/dist/body-sync-queue.d.ts +59 -0
  10. package/dist/body-sync-queue.js +56 -0
  11. package/dist/body-sync-terminal.d.ts +58 -0
  12. package/dist/body-sync-terminal.js +95 -0
  13. package/dist/body-sync.d.ts +351 -0
  14. package/dist/body-sync.js +1204 -0
  15. package/dist/calendar-parts.d.ts +23 -0
  16. package/dist/calendar-parts.js +18 -0
  17. package/dist/classification-backfill.d.ts +91 -0
  18. package/dist/classification-backfill.js +180 -0
  19. package/dist/classify-message.d.ts +31 -0
  20. package/dist/classify-message.js +89 -0
  21. package/dist/connection-factory.d.ts +54 -0
  22. package/dist/connection-factory.js +54 -0
  23. package/dist/connection-test.d.ts +41 -0
  24. package/dist/connection-test.js +152 -0
  25. package/dist/external-move.d.ts +94 -0
  26. package/dist/external-move.js +173 -0
  27. package/dist/filter-operations.d.ts +42 -0
  28. package/dist/filter-operations.js +211 -0
  29. package/dist/filters/filter-config.d.ts +14 -0
  30. package/dist/filters/filter-config.js +10 -0
  31. package/{src/filters/list-id.ts → dist/filters/list-id.d.ts} +2 -16
  32. package/dist/filters/list-id.js +26 -0
  33. package/dist/filters/match.d.ts +69 -0
  34. package/dist/filters/match.js +165 -0
  35. package/dist/filters/pipeline.d.ts +64 -0
  36. package/dist/filters/pipeline.js +100 -0
  37. package/{src/flag-push-terminal.ts → dist/flag-push-terminal.d.ts} +16 -72
  38. package/dist/flag-push-terminal.js +63 -0
  39. package/dist/flag-push.d.ts +69 -0
  40. package/dist/flag-push.js +111 -0
  41. package/dist/flag-queue.d.ts +175 -0
  42. package/dist/flag-queue.js +291 -0
  43. package/dist/folder-operations.d.ts +39 -0
  44. package/dist/folder-operations.js +112 -0
  45. package/dist/folder-placement.d.ts +13 -0
  46. package/dist/folder-placement.js +23 -0
  47. package/dist/heuristics/authenticationResults.d.ts +10 -0
  48. package/dist/heuristics/authenticationResults.js +187 -0
  49. package/dist/heuristics/classifyByHeaders.d.ts +82 -0
  50. package/dist/heuristics/classifyByHeaders.js +365 -0
  51. package/dist/heuristics/classifyPlacement.d.ts +67 -0
  52. package/dist/heuristics/classifyPlacement.js +153 -0
  53. package/dist/heuristics/machineSenders.d.ts +21 -0
  54. package/{src/heuristics/machineSenders.ts → dist/heuristics/machineSenders.js} +34 -40
  55. package/dist/heuristics/senderMismatch.d.ts +81 -0
  56. package/dist/heuristics/senderMismatch.js +192 -0
  57. package/dist/heuristics/socialDomains.d.ts +14 -0
  58. package/{src/heuristics/socialDomains.ts → dist/heuristics/socialDomains.js} +9 -9
  59. package/dist/heuristics/transactionalDomains.d.ts +18 -0
  60. package/{src/heuristics/transactionalDomains.ts → dist/heuristics/transactionalDomains.js} +8 -8
  61. package/dist/imapflow-connection.d.ts +377 -0
  62. package/dist/imapflow-connection.js +1147 -0
  63. package/dist/index.d.ts +63 -0
  64. package/dist/index.js +65 -0
  65. package/dist/list-id-backfill.d.ts +83 -0
  66. package/dist/list-id-backfill.js +132 -0
  67. package/dist/mailbox-cursor-rebuild.d.ts +74 -0
  68. package/dist/mailbox-cursor-rebuild.js +71 -0
  69. package/{src/mailbox-cursor.ts → dist/mailbox-cursor.d.ts} +20 -98
  70. package/dist/mailbox-cursor.js +102 -0
  71. package/dist/mailbox-intent.d.ts +41 -0
  72. package/{src/mailbox-intent.ts → dist/mailbox-intent.js} +38 -64
  73. package/dist/mailbox-management.d.ts +240 -0
  74. package/dist/mailbox-management.js +576 -0
  75. package/dist/mailbox-presence.d.ts +52 -0
  76. package/{src/mailbox-presence.ts → dist/mailbox-presence.js} +19 -30
  77. package/dist/mailbox-queue.d.ts +126 -0
  78. package/dist/mailbox-queue.js +214 -0
  79. package/dist/mailbox-sync.d.ts +93 -0
  80. package/dist/mailbox-sync.js +490 -0
  81. package/dist/mailbox-upstream.d.ts +38 -0
  82. package/{src/mailbox-upstream.ts → dist/mailbox-upstream.js} +37 -47
  83. package/dist/message-category.d.ts +51 -0
  84. package/{src/message-category.ts → dist/message-category.js} +28 -71
  85. package/dist/message-move.d.ts +285 -0
  86. package/dist/message-move.js +858 -0
  87. package/dist/message-parser.d.ts +14 -0
  88. package/dist/message-parser.js +17 -0
  89. package/{src/message-presence.ts → dist/message-presence.d.ts} +1 -13
  90. package/dist/message-presence.js +29 -0
  91. package/dist/message-read.d.ts +51 -0
  92. package/dist/message-read.js +31 -0
  93. package/dist/message-sync.d.ts +345 -0
  94. package/dist/message-sync.js +1358 -0
  95. package/dist/mime-walker.d.ts +102 -0
  96. package/dist/mime-walker.js +198 -0
  97. package/dist/organize-job.d.ts +20 -0
  98. package/dist/organize-job.js +37 -0
  99. package/dist/outbox-attachment-content.d.ts +37 -0
  100. package/dist/outbox-attachment-content.js +50 -0
  101. package/dist/outbox-attachment-filename.d.ts +19 -0
  102. package/dist/outbox-attachment-filename.js +64 -0
  103. package/dist/outbox-attachment.d.ts +148 -0
  104. package/dist/outbox-attachment.js +273 -0
  105. package/dist/outbox-queue.d.ts +73 -0
  106. package/dist/outbox-queue.js +284 -0
  107. package/{src/outbox-status.ts → dist/outbox-status.d.ts} +1 -6
  108. package/dist/outbox-status.js +18 -0
  109. package/dist/ownership.d.ts +24 -0
  110. package/dist/ownership.js +83 -0
  111. package/dist/placement-move-terminal.d.ts +67 -0
  112. package/dist/placement-move-terminal.js +88 -0
  113. package/dist/placement-move.d.ts +83 -0
  114. package/dist/placement-move.js +216 -0
  115. package/{src/placement-settled.ts → dist/placement-settled.d.ts} +13 -68
  116. package/dist/placement-settled.js +75 -0
  117. package/dist/quarantine.d.ts +95 -0
  118. package/dist/quarantine.js +131 -0
  119. package/dist/refusal.d.ts +31 -0
  120. package/dist/refusal.js +3 -0
  121. package/{src/restore-source-placement.ts → dist/restore-source-placement.d.ts} +29 -97
  122. package/dist/restore-source-placement.js +79 -0
  123. package/dist/search-text-backfill.d.ts +15 -0
  124. package/dist/search-text-backfill.js +33 -0
  125. package/dist/snippet.d.ts +61 -0
  126. package/dist/snippet.js +129 -0
  127. package/dist/spam-report.d.ts +86 -0
  128. package/dist/spam-report.js +241 -0
  129. package/dist/sync-warning.d.ts +21 -0
  130. package/dist/sync-warning.js +26 -0
  131. package/dist/sync-watermarks.d.ts +138 -0
  132. package/dist/sync-watermarks.js +205 -0
  133. package/dist/test-helpers/fake-imap.d.ts +151 -0
  134. package/dist/test-helpers/fake-imap.js +508 -0
  135. package/dist/test-helpers/folder-roles.d.ts +34 -0
  136. package/dist/test-helpers/folder-roles.js +47 -0
  137. package/dist/test-helpers/mime-tree-generator.d.ts +82 -0
  138. package/dist/test-helpers/mime-tree-generator.js +329 -0
  139. package/dist/test-helpers/test-store.d.ts +69 -0
  140. package/dist/test-helpers/test-store.js +193 -0
  141. package/dist/text/normalizer.d.ts +20 -0
  142. package/dist/text/normalizer.js +102 -0
  143. package/dist/thread-message-rows.d.ts +69 -0
  144. package/dist/thread-message-rows.js +40 -0
  145. package/dist/thread-root.d.ts +7 -0
  146. package/dist/thread-root.js +25 -0
  147. package/dist/types.d.ts +343 -0
  148. package/dist/types.js +19 -0
  149. package/package.json +86 -88
  150. package/src/account-credentials.ts +0 -210
  151. package/src/attribute-mapper.test.ts +0 -104
  152. package/src/attribute-mapper.ts +0 -148
  153. package/src/body-parse.test.ts +0 -55
  154. package/src/body-part-mapper.property.test.ts +0 -354
  155. package/src/body-part-mapper.ts +0 -410
  156. package/src/body-sync-queue.test.ts +0 -64
  157. package/src/body-sync-queue.ts +0 -131
  158. package/src/body-sync-terminal.test.ts +0 -148
  159. package/src/body-sync-terminal.ts +0 -174
  160. package/src/body-sync.test.ts +0 -925
  161. package/src/body-sync.ts +0 -1891
  162. package/src/calendar-parts.ts +0 -41
  163. package/src/classification-backfill.test.ts +0 -285
  164. package/src/classification-backfill.ts +0 -363
  165. package/src/classify-message.test.ts +0 -142
  166. package/src/classify-message.ts +0 -116
  167. package/src/connection-factory.ts +0 -102
  168. package/src/connection-test.ts +0 -208
  169. package/src/credentials.test.ts +0 -181
  170. package/src/external-move.test.ts +0 -262
  171. package/src/external-move.ts +0 -285
  172. package/src/filter-operations.test.ts +0 -319
  173. package/src/filter-operations.ts +0 -377
  174. package/src/filter-update.test.ts +0 -313
  175. package/src/filters/filter-config.test.ts +0 -87
  176. package/src/filters/filter-config.ts +0 -34
  177. package/src/filters/list-id.test.ts +0 -50
  178. package/src/filters/match.test.ts +0 -392
  179. package/src/filters/match.ts +0 -225
  180. package/src/filters/pipeline.test.ts +0 -199
  181. package/src/filters/pipeline.ts +0 -170
  182. package/src/flag-push.test.ts +0 -112
  183. package/src/flag-push.ts +0 -198
  184. package/src/flag-queue.test.ts +0 -197
  185. package/src/flag-queue.ts +0 -509
  186. package/src/folder-operations.test.ts +0 -273
  187. package/src/folder-operations.ts +0 -217
  188. package/src/folder-placement.ts +0 -56
  189. package/src/forwarded-message.test.ts +0 -122
  190. package/src/heuristics/authenticationResults.test.ts +0 -189
  191. package/src/heuristics/authenticationResults.ts +0 -212
  192. package/src/heuristics/classifyByHeaders.test.ts +0 -889
  193. package/src/heuristics/classifyByHeaders.ts +0 -467
  194. package/src/heuristics/classifyPlacement.test.ts +0 -366
  195. package/src/heuristics/classifyPlacement.ts +0 -211
  196. package/src/heuristics/classifyRealMail.test.ts +0 -242
  197. package/src/heuristics/machineSenders.test.ts +0 -101
  198. package/src/heuristics/senderMismatch.test.ts +0 -346
  199. package/src/heuristics/senderMismatch.ts +0 -259
  200. package/src/imapflow-connect-retry.test.ts +0 -97
  201. package/src/imapflow-connection.test.ts +0 -503
  202. package/src/imapflow-connection.ts +0 -1476
  203. package/src/index.ts +0 -397
  204. package/src/list-id-backfill.test.ts +0 -207
  205. package/src/list-id-backfill.ts +0 -274
  206. package/src/mailbox-cursor-rebuild.test.ts +0 -171
  207. package/src/mailbox-cursor-rebuild.ts +0 -146
  208. package/src/mailbox-cursor.test.ts +0 -141
  209. package/src/mailbox-delete-intent.test.ts +0 -119
  210. package/src/mailbox-management.test.ts +0 -243
  211. package/src/mailbox-management.ts +0 -838
  212. package/src/mailbox-presence.test.ts +0 -74
  213. package/src/mailbox-queue.ts +0 -397
  214. package/src/mailbox-rename-intent.test.ts +0 -220
  215. package/src/mailbox-rename-subtree.test.ts +0 -287
  216. package/src/mailbox-sync.test.ts +0 -286
  217. package/src/mailbox-sync.ts +0 -724
  218. package/src/message-move.test.ts +0 -726
  219. package/src/message-move.ts +0 -1518
  220. package/src/message-parser.ts +0 -26
  221. package/src/message-presence.test.ts +0 -50
  222. package/src/message-read.ts +0 -87
  223. package/src/message-save-retry.test.ts +0 -90
  224. package/src/message-sync-flags.test.ts +0 -167
  225. package/src/message-sync.test.ts +0 -762
  226. package/src/message-sync.ts +0 -2133
  227. package/src/mime-walker.test.ts +0 -311
  228. package/src/mime-walker.ts +0 -306
  229. package/src/organize-job.ts +0 -93
  230. package/src/outbox-attachment-content.test.ts +0 -153
  231. package/src/outbox-attachment-content.ts +0 -104
  232. package/src/outbox-attachment-filename.test.ts +0 -132
  233. package/src/outbox-attachment-filename.ts +0 -76
  234. package/src/outbox-attachment.test.ts +0 -626
  235. package/src/outbox-attachment.ts +0 -536
  236. package/src/outbox-failed-recovery.test.ts +0 -299
  237. package/src/outbox-queue.test.ts +0 -315
  238. package/src/outbox-queue.ts +0 -525
  239. package/src/ownership.ts +0 -140
  240. package/src/placement-move-terminal.ts +0 -145
  241. package/src/placement-move.ts +0 -363
  242. package/src/placement-settled.test.ts +0 -147
  243. package/src/placement.test.ts +0 -404
  244. package/src/quarantine.test.ts +0 -133
  245. package/src/quarantine.ts +0 -229
  246. package/src/refusal.ts +0 -29
  247. package/src/snippet.test.ts +0 -213
  248. package/src/snippet.ts +0 -152
  249. package/src/spam-report.test.ts +0 -329
  250. package/src/spam-report.ts +0 -357
  251. package/src/sync-warning.test.ts +0 -213
  252. package/src/sync-warning.ts +0 -56
  253. package/src/sync-watermarks.test.ts +0 -409
  254. package/src/sync-watermarks.ts +0 -319
  255. package/src/system-flag-wire-format.test.ts +0 -77
  256. package/src/test-helpers/fake-imap.ts +0 -690
  257. package/src/test-helpers/folder-roles.ts +0 -66
  258. package/src/test-helpers/mime-tree-generator.ts +0 -461
  259. package/src/test-helpers/test-queue.ts +0 -56
  260. package/src/test-helpers/test-store.ts +0 -299
  261. package/src/text/normalizer.test.ts +0 -223
  262. package/src/text/normalizer.ts +0 -140
  263. package/src/thread-message-rows.ts +0 -77
  264. package/src/thread-root.test.ts +0 -29
  265. package/src/thread-root.ts +0 -51
  266. package/src/types/mailparser-augment.d.ts +0 -23
  267. package/src/types/natural-porter-stemmer.d.ts +0 -50
  268. package/src/types/stopword.d.ts +0 -69
  269. package/src/types.ts +0 -371
  270. package/tsconfig.json +0 -8
package/src/flag-queue.ts DELETED
@@ -1,509 +0,0 @@
1
- import type {
2
- IMessageFlagRepository,
3
- IMessageRepository,
4
- IThreadMessageRepository,
5
- } from "@remit/data-ports";
6
- import { NotFoundError } from "@remit/data-ports/errors";
7
- import { MessageSystemFlag, StarColor } from "@remit/domain-enums";
8
- import type { FlagPushOperationValue, FlagPushService } from "./flag-push.js";
9
-
10
- /**
11
- * StarColor type derived from the StarColor const object
12
- */
13
- type StarColorValue = (typeof StarColor)[keyof typeof StarColor];
14
-
15
- /**
16
- * Logger interface
17
- */
18
- export interface FlagQueueLogger {
19
- info(obj: Record<string, unknown>, msg: string): void;
20
- error(obj: Record<string, unknown>, msg: string): void;
21
- }
22
-
23
- const noopLogger: FlagQueueLogger = {
24
- info: () => {},
25
- error: () => {},
26
- };
27
-
28
- /**
29
- * Input for updateFlags API method
30
- */
31
- export interface UpdateFlagsInput {
32
- isRead?: boolean;
33
- isStarred?: boolean;
34
- starColor?: StarColorValue;
35
- }
36
-
37
- /**
38
- * Result of updateFlags API method
39
- */
40
- export interface UpdateFlagsResult {
41
- messageId: string;
42
- isRead: boolean;
43
- isStarred: boolean;
44
- }
45
-
46
- /**
47
- * Configuration for FlagQueueService
48
- */
49
- export interface FlagQueueConfig {
50
- messageFlagService: IMessageFlagRepository;
51
- messageService: IMessageRepository;
52
- threadMessageService: IThreadMessageRepository;
53
- flagPushService: FlagPushService;
54
- logger?: FlagQueueLogger;
55
- }
56
-
57
- /**
58
- * Service for marking messages as read/unread/starred, local-first with a
59
- * durable pending-flag marker (issue #1273, epic #1281).
60
- *
61
- * Every flip below follows the SAME sequence: persist the pending marker
62
- * FIRST via {@link FlagPushService.flip} (the durable record IMAP still owes
63
- * this push, and best-effort enqueues a wake-up hint), THEN apply locally
64
- * (MessageFlag + ThreadMessage). The marker write is what makes the user's
65
- * intent durable — not the enqueue, which may fail freely (see
66
- * `FlagPushService`'s own doc) — and writing it first means a crash before
67
- * the local write leaves an unapplied local flip behind an already-durable
68
- * marker, never a flipped flag with no marker (review finding on #1292;
69
- * mirrors #1289's `PlacementMoveService.moveMessage` ordering). This class
70
- * therefore no longer touches
71
- * `Mailbox.unseenCount` at all: that field is a pure projection, recomputed
72
- * only from IMAP (`doc/rules/data-flow.md`); the displayed count is adjusted
73
- * at READ TIME from pending markers (`applyPendingMoveCountPrediction`,
74
- * `packages/backend/src/derive/pendingMoveCounts.ts`).
75
- *
76
- * The service updates BOTH entities locally:
77
- * - MessageFlag: The canonical flag record
78
- * - ThreadMessage.isRead / hasStars / star: Denormalized for efficient queries
79
- */
80
- export class FlagQueueService {
81
- private messageFlagService: IMessageFlagRepository;
82
- private messageService: IMessageRepository;
83
- private threadMessageService: IThreadMessageRepository;
84
- private flagPushService: FlagPushService;
85
- private log: FlagQueueLogger;
86
-
87
- constructor(config: FlagQueueConfig) {
88
- const {
89
- messageFlagService,
90
- messageService,
91
- threadMessageService,
92
- flagPushService,
93
- } = config;
94
- this.messageFlagService = messageFlagService;
95
- this.messageService = messageService;
96
- this.threadMessageService = threadMessageService;
97
- this.flagPushService = flagPushService;
98
- this.log = config.logger ?? noopLogger;
99
- }
100
-
101
- /**
102
- * Update ThreadMessage.isRead for ALL ThreadMessages matching this messageId.
103
- *
104
- * A message can exist in multiple mailboxes (e.g., inbox and archive), so we
105
- * must update all instances to keep the isRead status consistent.
106
- *
107
- * The `composites` map carries the CURRENT values of every attribute that
108
- * participates in a sort key. ElectroDB uses them for the conditional check
109
- * on the existing row and combines them with `set()` to recompute the new
110
- * sort keys. Passing the NEW `isRead` value here would make the conditional
111
- * check fail (existing row still has the old value) and the patch would be
112
- * misreported as NotFoundError, silently dropping the update.
113
- *
114
- * Handles race condition where ThreadMessage may be deleted between find and update.
115
- */
116
- private updateThreadMessageIsRead = async (
117
- accountConfigId: string,
118
- messageId: string,
119
- isRead: boolean,
120
- ): Promise<void> => {
121
- const threadMessages = await this.threadMessageService.findAllByMessageId(
122
- accountConfigId,
123
- messageId,
124
- );
125
- if (threadMessages.length === 0) {
126
- this.log.info(
127
- { messageId },
128
- "ThreadMessage not found for messageId - skipping isRead update",
129
- );
130
- return;
131
- }
132
-
133
- for (const threadMessage of threadMessages) {
134
- try {
135
- await this.threadMessageService.update(
136
- threadMessage.accountConfigId,
137
- threadMessage.threadMessageId,
138
- { isRead },
139
- {
140
- composites: {
141
- sentDate: threadMessage.sentDate,
142
- mailboxId: threadMessage.mailboxId,
143
- isRead: threadMessage.isRead,
144
- isDeleted: threadMessage.isDeleted,
145
- hasStars: threadMessage.hasStars,
146
- hasAttachment: threadMessage.hasAttachment,
147
- },
148
- },
149
- );
150
- this.log.info(
151
- { messageId, threadMessageId: threadMessage.threadMessageId, isRead },
152
- "Updated ThreadMessage.isRead",
153
- );
154
- } catch (err) {
155
- if (err instanceof NotFoundError) {
156
- this.log.info(
157
- { messageId, threadMessageId: threadMessage.threadMessageId },
158
- "ThreadMessage deleted during update - skipping isRead update",
159
- );
160
- continue;
161
- }
162
- throw err;
163
- }
164
- }
165
- };
166
-
167
- /**
168
- * Update ThreadMessage.hasStars and star color for ALL ThreadMessages matching this messageId.
169
- *
170
- * A message can exist in multiple mailboxes (e.g., inbox and archive), so we
171
- * must update all instances to keep the star status consistent.
172
- *
173
- * Handles race condition where ThreadMessage may be deleted between find and update.
174
- */
175
- private updateThreadMessageStars = async (
176
- accountConfigId: string,
177
- messageId: string,
178
- updates: { hasStars?: boolean; star?: StarColorValue },
179
- ): Promise<void> => {
180
- if (Object.keys(updates).length === 0) return;
181
-
182
- const threadMessages = await this.threadMessageService.findAllByMessageId(
183
- accountConfigId,
184
- messageId,
185
- );
186
- if (threadMessages.length === 0) {
187
- this.log.info(
188
- { messageId },
189
- "ThreadMessage not found for messageId - skipping star update",
190
- );
191
- return;
192
- }
193
-
194
- for (const threadMessage of threadMessages) {
195
- try {
196
- await this.threadMessageService.update(
197
- threadMessage.accountConfigId,
198
- threadMessage.threadMessageId,
199
- updates,
200
- {
201
- composites: {
202
- sentDate: threadMessage.sentDate,
203
- mailboxId: threadMessage.mailboxId,
204
- isRead: threadMessage.isRead,
205
- isDeleted: threadMessage.isDeleted,
206
- hasStars: threadMessage.hasStars,
207
- hasAttachment: threadMessage.hasAttachment,
208
- },
209
- },
210
- );
211
- this.log.info(
212
- {
213
- messageId,
214
- threadMessageId: threadMessage.threadMessageId,
215
- updates,
216
- },
217
- "Updated ThreadMessage stars",
218
- );
219
- } catch (err) {
220
- if (err instanceof NotFoundError) {
221
- this.log.info(
222
- { messageId, threadMessageId: threadMessage.threadMessageId },
223
- "ThreadMessage deleted during update - skipping star update",
224
- );
225
- continue;
226
- }
227
- throw err;
228
- }
229
- }
230
- };
231
-
232
- /**
233
- * Flip one flag field: persist the pending marker FIRST, then apply
234
- * locally. Every caller in this class routes through here so the marker
235
- * write is never skipped. Returns whether a change actually happened —
236
- * `false` for a redundant flip (already in the desired state), which
237
- * every caller uses to skip the (otherwise redundant) ThreadMessage
238
- * update too.
239
- *
240
- * Ordering matters (review finding on #1292): a marker written AFTER the
241
- * local `MessageFlag` flip means a crash between the two awaits strands a
242
- * flipped flag with no marker — no hint was ever sent, the pending-only
243
- * drain never finds it (no marker to find), and message-sync never
244
- * rewrites `MessageFlag` from IMAP for an existing row, so nothing else
245
- * reconciles it either. That is a permanent, silent divergence — exactly
246
- * the defect #1273 exists to kill. Writing the marker first (matching
247
- * #1289's `PlacementMoveService.moveMessage` ordering) makes that
248
- * scenario impossible: the marker's existence is the record of how far
249
- * the sequence got, so a crash after it can strand at worst an
250
- * un-applied LOCAL write behind an already-durable marker — recoverable
251
- * (the caller's natural retry re-applies both steps; `put` and
252
- * `addFlag`/`removeFlag` are both idempotent) — never the reverse.
253
- *
254
- * The current-state check (review finding on #1292) matters for a
255
- * different reason: `markAsRead`/`markAsUnread`/`updateFlags` take the
256
- * DESIRED boolean from the caller rather than deriving it from current
257
- * state (unlike `toggleFlagged`, which already reads `hasFlag` first and
258
- * so can never generate a redundant marker). A redundant "mark as read"
259
- * on an already-read message would otherwise still write a fresh `add
260
- * \Seen` marker; the read-time unseenCount prediction
261
- * (`applyPendingMoveCountPrediction`) would then subtract one for a
262
- * message IMAP already counts as seen, transiently under-counting the
263
- * badge until the marker clears. Skipping the marker (and the local
264
- * write) entirely when the flag already matches removes the false
265
- * prediction at the source, not just its symptom.
266
- */
267
- private flipFlag = async (
268
- accountId: string,
269
- accountConfigId: string,
270
- messageId: string,
271
- mailboxId: string,
272
- flagName: string,
273
- operation: FlagPushOperationValue,
274
- ): Promise<boolean> => {
275
- const alreadyInState = await this.messageFlagService.hasFlag(
276
- messageId,
277
- flagName,
278
- );
279
- if (
280
- (operation === "add" && alreadyInState) ||
281
- (operation === "remove" && !alreadyInState)
282
- ) {
283
- this.log.info(
284
- { messageId, flagName, operation },
285
- "Flag already in the desired state — skipping redundant marker + local write",
286
- );
287
- return false;
288
- }
289
-
290
- await this.flagPushService.flip({
291
- accountId,
292
- accountConfigId,
293
- messageId,
294
- mailboxId,
295
- flagName,
296
- operation,
297
- });
298
-
299
- if (operation === "add") {
300
- await this.messageFlagService.addFlag(messageId, flagName);
301
- } else {
302
- await this.messageFlagService.removeFlag(messageId, flagName);
303
- }
304
-
305
- return true;
306
- };
307
-
308
- /**
309
- * Mark a message as read (add \Seen flag).
310
- * Updates MessageFlag, ThreadMessage.isRead, and persists a pending
311
- * flag-push marker for IMAP sync.
312
- *
313
- * @param accountConfigId - The owning account config (tenant scope)
314
- * @param messageId - The message to mark as read
315
- * @param accountId - The account ID for the IMAP sync event
316
- */
317
- markAsRead = async (
318
- accountConfigId: string,
319
- messageId: string,
320
- accountId: string,
321
- ): Promise<void> => {
322
- const message = await this.messageService.get(messageId);
323
-
324
- const changed = await this.flipFlag(
325
- accountId,
326
- accountConfigId,
327
- messageId,
328
- message.mailboxId,
329
- MessageSystemFlag.Seen,
330
- "add",
331
- );
332
-
333
- if (changed) {
334
- await this.updateThreadMessageIsRead(accountConfigId, messageId, true);
335
- }
336
-
337
- this.log.info(
338
- { messageId, changed },
339
- "Marked message as read (local, push pending)",
340
- );
341
- };
342
-
343
- /**
344
- * Mark a message as unread (remove \Seen flag).
345
- * Updates MessageFlag, ThreadMessage.isRead, and persists a pending
346
- * flag-push marker for IMAP sync.
347
- *
348
- * @param accountConfigId - The owning account config (tenant scope)
349
- * @param messageId - The message to mark as unread
350
- * @param accountId - The account ID for the IMAP sync event
351
- */
352
- markAsUnread = async (
353
- accountConfigId: string,
354
- messageId: string,
355
- accountId: string,
356
- ): Promise<void> => {
357
- const message = await this.messageService.get(messageId);
358
-
359
- const changed = await this.flipFlag(
360
- accountId,
361
- accountConfigId,
362
- messageId,
363
- message.mailboxId,
364
- MessageSystemFlag.Seen,
365
- "remove",
366
- );
367
-
368
- if (changed) {
369
- await this.updateThreadMessageIsRead(accountConfigId, messageId, false);
370
- }
371
-
372
- this.log.info(
373
- { messageId, changed },
374
- "Marked message as unread (local, push pending)",
375
- );
376
- };
377
-
378
- /**
379
- * Toggle the starred/flagged status of a message.
380
- * Updates local state and persists a pending flag-push marker for IMAP
381
- * sync.
382
- *
383
- * @param accountConfigId - The owning account config (tenant scope)
384
- * @param messageId - The message to toggle
385
- * @param accountId - The account ID for the IMAP sync event
386
- * @returns true if flag was added, false if removed
387
- */
388
- toggleFlagged = async (
389
- accountConfigId: string,
390
- messageId: string,
391
- accountId: string,
392
- ): Promise<boolean> => {
393
- const message = await this.messageService.get(messageId);
394
-
395
- const hasFlag = await this.messageFlagService.hasFlag(
396
- messageId,
397
- MessageSystemFlag.Flagged,
398
- );
399
- const operation: FlagPushOperationValue = hasFlag ? "remove" : "add";
400
-
401
- await this.flipFlag(
402
- accountId,
403
- accountConfigId,
404
- messageId,
405
- message.mailboxId,
406
- MessageSystemFlag.Flagged,
407
- operation,
408
- );
409
-
410
- this.log.info(
411
- { messageId, operation },
412
- "Toggled flagged status (local, push pending)",
413
- );
414
-
415
- return operation === "add";
416
- };
417
-
418
- /**
419
- * Update message flags using API-friendly input format.
420
- * Maps isRead/isStarred to IMAP flags and updates ThreadMessage accordingly.
421
- *
422
- * @param accountConfigId - The owning account config (tenant scope)
423
- * @param messageId - The message to update
424
- * @param accountId - The account ID for the IMAP sync event
425
- * @param input - The flag updates to apply
426
- * @returns The current flag state after updates
427
- */
428
- updateFlags = async (
429
- accountConfigId: string,
430
- messageId: string,
431
- accountId: string,
432
- input: UpdateFlagsInput,
433
- ): Promise<UpdateFlagsResult> => {
434
- const message = await this.messageService.get(messageId);
435
-
436
- // Handle isRead -> \Seen flag
437
- if (input.isRead !== undefined) {
438
- const changed = await this.flipFlag(
439
- accountId,
440
- accountConfigId,
441
- messageId,
442
- message.mailboxId,
443
- MessageSystemFlag.Seen,
444
- input.isRead ? "add" : "remove",
445
- );
446
- if (changed) {
447
- await this.updateThreadMessageIsRead(
448
- accountConfigId,
449
- messageId,
450
- input.isRead,
451
- );
452
- }
453
- }
454
-
455
- // `hasStars` is the boolean of record (the byStarred index sort key) and
456
- // the server-side \Flagged keyword; `star` is its presentation colour,
457
- // whose absent state is the None sentinel. The two must never disagree,
458
- // whichever field the caller sent:
459
- //
460
- // isStarred alone → colour follows the boolean (standard colour on,
461
- // None off)
462
- // starColor alone → boolean follows the colour, so `none` unstars and
463
- // any real colour stars
464
- // both → isStarred decides the boolean, starColor the colour
465
- //
466
- // The \Flagged push follows the resulting boolean in every case, so a
467
- // colour-only request still reaches the server. `flipFlag` no-ops when the
468
- // flag already holds the desired state.
469
- if (input.isStarred !== undefined || input.starColor !== undefined) {
470
- const starred =
471
- input.isStarred ??
472
- (input.starColor ?? StarColor.None) !== StarColor.None;
473
- const color =
474
- input.starColor ?? (starred ? StarColor.Yellow : StarColor.None);
475
-
476
- await this.flipFlag(
477
- accountId,
478
- accountConfigId,
479
- messageId,
480
- message.mailboxId,
481
- MessageSystemFlag.Flagged,
482
- starred ? "add" : "remove",
483
- );
484
-
485
- // Update ThreadMessage hasStars and star color for ALL instances
486
- await this.updateThreadMessageStars(accountConfigId, messageId, {
487
- hasStars: starred,
488
- star: color,
489
- });
490
- }
491
-
492
- // Return current state
493
- const isRead = await this.messageFlagService.hasFlag(
494
- messageId,
495
- MessageSystemFlag.Seen,
496
- );
497
- const isStarred = await this.messageFlagService.hasFlag(
498
- messageId,
499
- MessageSystemFlag.Flagged,
500
- );
501
-
502
- this.log.info(
503
- { messageId, isRead, isStarred, input },
504
- "Updated message flags",
505
- );
506
-
507
- return { messageId, isRead, isStarred };
508
- };
509
- }