@remit/mailbox-service 0.0.111 → 0.0.113

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (298) hide show
  1. package/dist/account-credentials.d.ts +83 -0
  2. package/dist/account-credentials.js +104 -0
  3. package/dist/attribute-mapper.d.ts +36 -0
  4. package/dist/attribute-mapper.js +119 -0
  5. package/dist/body-parse.d.ts +28 -0
  6. package/{src/body-parse.ts → dist/body-parse.js} +18 -26
  7. package/dist/body-part-mapper.d.ts +92 -0
  8. package/dist/body-part-mapper.js +301 -0
  9. package/dist/body-sync-queue.d.ts +59 -0
  10. package/dist/body-sync-queue.js +56 -0
  11. package/dist/body-sync-terminal.d.ts +58 -0
  12. package/dist/body-sync-terminal.js +95 -0
  13. package/dist/body-sync.d.ts +351 -0
  14. package/dist/body-sync.js +1204 -0
  15. package/dist/calendar-parts.d.ts +23 -0
  16. package/dist/calendar-parts.js +18 -0
  17. package/dist/classification-backfill.d.ts +91 -0
  18. package/dist/classification-backfill.js +180 -0
  19. package/dist/classify-message.d.ts +31 -0
  20. package/dist/classify-message.js +89 -0
  21. package/dist/connection-factory.d.ts +54 -0
  22. package/dist/connection-factory.js +54 -0
  23. package/dist/connection-test.d.ts +41 -0
  24. package/dist/connection-test.js +152 -0
  25. package/dist/external-move.d.ts +94 -0
  26. package/dist/external-move.js +173 -0
  27. package/dist/filter-operations.d.ts +42 -0
  28. package/dist/filter-operations.js +211 -0
  29. package/dist/filters/filter-config.d.ts +14 -0
  30. package/dist/filters/filter-config.js +10 -0
  31. package/{src/filters/list-id.ts → dist/filters/list-id.d.ts} +2 -16
  32. package/dist/filters/list-id.js +26 -0
  33. package/dist/filters/match.d.ts +69 -0
  34. package/dist/filters/match.js +165 -0
  35. package/dist/filters/pipeline.d.ts +64 -0
  36. package/dist/filters/pipeline.js +100 -0
  37. package/{src/flag-push-terminal.ts → dist/flag-push-terminal.d.ts} +19 -77
  38. package/dist/flag-push-terminal.js +63 -0
  39. package/dist/flag-push.d.ts +69 -0
  40. package/dist/flag-push.js +111 -0
  41. package/dist/flag-queue.d.ts +175 -0
  42. package/dist/flag-queue.js +291 -0
  43. package/dist/folder-operations.d.ts +39 -0
  44. package/dist/folder-operations.js +112 -0
  45. package/dist/folder-placement.d.ts +13 -0
  46. package/dist/folder-placement.js +23 -0
  47. package/dist/heuristics/authenticationResults.d.ts +10 -0
  48. package/dist/heuristics/authenticationResults.js +187 -0
  49. package/dist/heuristics/classifyByHeaders.d.ts +82 -0
  50. package/dist/heuristics/classifyByHeaders.js +365 -0
  51. package/dist/heuristics/classifyPlacement.d.ts +67 -0
  52. package/dist/heuristics/classifyPlacement.js +153 -0
  53. package/dist/heuristics/machineSenders.d.ts +21 -0
  54. package/{src/heuristics/machineSenders.ts → dist/heuristics/machineSenders.js} +34 -40
  55. package/dist/heuristics/senderMismatch.d.ts +81 -0
  56. package/dist/heuristics/senderMismatch.js +192 -0
  57. package/dist/heuristics/socialDomains.d.ts +14 -0
  58. package/{src/heuristics/socialDomains.ts → dist/heuristics/socialDomains.js} +9 -9
  59. package/dist/heuristics/transactionalDomains.d.ts +18 -0
  60. package/{src/heuristics/transactionalDomains.ts → dist/heuristics/transactionalDomains.js} +8 -8
  61. package/dist/imapflow-connection.d.ts +377 -0
  62. package/dist/imapflow-connection.js +1147 -0
  63. package/dist/index.d.ts +63 -0
  64. package/dist/index.js +65 -0
  65. package/dist/list-id-backfill.d.ts +83 -0
  66. package/dist/list-id-backfill.js +132 -0
  67. package/dist/mailbox-cursor-rebuild.d.ts +74 -0
  68. package/dist/mailbox-cursor-rebuild.js +71 -0
  69. package/{src/mailbox-cursor.ts → dist/mailbox-cursor.d.ts} +20 -98
  70. package/dist/mailbox-cursor.js +102 -0
  71. package/dist/mailbox-intent.d.ts +41 -0
  72. package/{src/mailbox-intent.ts → dist/mailbox-intent.js} +39 -63
  73. package/dist/mailbox-management.d.ts +240 -0
  74. package/dist/mailbox-management.js +576 -0
  75. package/dist/mailbox-presence.d.ts +52 -0
  76. package/{src/mailbox-presence.ts → dist/mailbox-presence.js} +19 -30
  77. package/dist/mailbox-queue.d.ts +126 -0
  78. package/dist/mailbox-queue.js +214 -0
  79. package/dist/mailbox-sync.d.ts +93 -0
  80. package/dist/mailbox-sync.js +490 -0
  81. package/dist/mailbox-upstream.d.ts +38 -0
  82. package/{src/mailbox-upstream.ts → dist/mailbox-upstream.js} +37 -47
  83. package/dist/message-category.d.ts +51 -0
  84. package/{src/message-category.ts → dist/message-category.js} +28 -71
  85. package/dist/message-move.d.ts +285 -0
  86. package/dist/message-move.js +858 -0
  87. package/dist/message-parser.d.ts +14 -0
  88. package/dist/message-parser.js +17 -0
  89. package/{src/message-presence.ts → dist/message-presence.d.ts} +1 -13
  90. package/dist/message-presence.js +29 -0
  91. package/dist/message-read.d.ts +51 -0
  92. package/dist/message-read.js +31 -0
  93. package/dist/message-sync.d.ts +345 -0
  94. package/dist/message-sync.js +1358 -0
  95. package/dist/mime-walker.d.ts +102 -0
  96. package/dist/mime-walker.js +198 -0
  97. package/dist/organize-job.d.ts +20 -0
  98. package/dist/organize-job.js +37 -0
  99. package/dist/outbox-attachment-content.d.ts +37 -0
  100. package/dist/outbox-attachment-content.js +50 -0
  101. package/dist/outbox-attachment-filename.d.ts +19 -0
  102. package/dist/outbox-attachment-filename.js +64 -0
  103. package/dist/outbox-attachment.d.ts +148 -0
  104. package/dist/outbox-attachment.js +273 -0
  105. package/dist/outbox-queue.d.ts +73 -0
  106. package/dist/outbox-queue.js +284 -0
  107. package/{src/outbox-status.ts → dist/outbox-status.d.ts} +1 -6
  108. package/dist/outbox-status.js +18 -0
  109. package/dist/ownership.d.ts +24 -0
  110. package/dist/ownership.js +83 -0
  111. package/dist/placement-move-terminal.d.ts +67 -0
  112. package/dist/placement-move-terminal.js +88 -0
  113. package/dist/placement-move.d.ts +83 -0
  114. package/dist/placement-move.js +216 -0
  115. package/{src/placement-settled.ts → dist/placement-settled.d.ts} +19 -50
  116. package/dist/placement-settled.js +75 -0
  117. package/dist/quarantine.d.ts +95 -0
  118. package/dist/quarantine.js +131 -0
  119. package/dist/refusal.d.ts +31 -0
  120. package/dist/refusal.js +3 -0
  121. package/{src/restore-source-placement.ts → dist/restore-source-placement.d.ts} +29 -101
  122. package/dist/restore-source-placement.js +79 -0
  123. package/dist/search-text-backfill.d.ts +15 -0
  124. package/dist/search-text-backfill.js +33 -0
  125. package/dist/snippet.d.ts +61 -0
  126. package/dist/snippet.js +129 -0
  127. package/dist/spam-report.d.ts +86 -0
  128. package/dist/spam-report.js +241 -0
  129. package/dist/sync-warning.d.ts +21 -0
  130. package/dist/sync-warning.js +26 -0
  131. package/dist/sync-watermarks.d.ts +138 -0
  132. package/dist/sync-watermarks.js +205 -0
  133. package/dist/test-helpers/fake-imap.d.ts +151 -0
  134. package/dist/test-helpers/fake-imap.js +508 -0
  135. package/dist/test-helpers/folder-roles.d.ts +34 -0
  136. package/dist/test-helpers/folder-roles.js +47 -0
  137. package/dist/test-helpers/mime-tree-generator.d.ts +82 -0
  138. package/dist/test-helpers/mime-tree-generator.js +329 -0
  139. package/dist/test-helpers/test-store.d.ts +69 -0
  140. package/dist/test-helpers/test-store.js +193 -0
  141. package/dist/text/normalizer.d.ts +20 -0
  142. package/dist/text/normalizer.js +102 -0
  143. package/dist/thread-message-rows.d.ts +69 -0
  144. package/dist/thread-message-rows.js +40 -0
  145. package/dist/thread-root.d.ts +7 -0
  146. package/dist/thread-root.js +25 -0
  147. package/dist/types.d.ts +343 -0
  148. package/dist/types.js +19 -0
  149. package/package.json +86 -73
  150. package/src/account-credentials.ts +0 -210
  151. package/src/adversarial-mime.e2e.test.ts +0 -307
  152. package/src/attribute-mapper.test.ts +0 -104
  153. package/src/attribute-mapper.ts +0 -148
  154. package/src/body-parse.test.ts +0 -55
  155. package/src/body-part-mapper.property.test.ts +0 -354
  156. package/src/body-part-mapper.ts +0 -360
  157. package/src/body-sync-auto-read-writeonce.test.ts +0 -219
  158. package/src/body-sync-calendar-suggestion.test.ts +0 -433
  159. package/src/body-sync-category-denormalize.test.ts +0 -352
  160. package/src/body-sync-category-override.test.ts +0 -501
  161. package/src/body-sync-category-writeonce.test.ts +0 -234
  162. package/src/body-sync-filter.test.ts +0 -237
  163. package/src/body-sync-message-gone.test.ts +0 -139
  164. package/src/body-sync-placement-flags.test.ts +0 -347
  165. package/src/body-sync-placement-writeonce.test.ts +0 -337
  166. package/src/body-sync-quarantine.test.ts +0 -276
  167. package/src/body-sync-queue.test.ts +0 -124
  168. package/src/body-sync-queue.ts +0 -131
  169. package/src/body-sync-resync.test.ts +0 -605
  170. package/src/body-sync-terminal.test.ts +0 -310
  171. package/src/body-sync-terminal.ts +0 -176
  172. package/src/body-sync-unclassifiable-once.test.ts +0 -260
  173. package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
  174. package/src/body-sync.ts +0 -1970
  175. package/src/calendar-parts.ts +0 -41
  176. package/src/classification-backfill.test.ts +0 -615
  177. package/src/classification-backfill.ts +0 -363
  178. package/src/classify-message.test.ts +0 -205
  179. package/src/classify-message.ts +0 -116
  180. package/src/connection-factory.ts +0 -102
  181. package/src/connection-test.ts +0 -208
  182. package/src/credentials.test.ts +0 -181
  183. package/src/external-move.test.ts +0 -369
  184. package/src/external-move.ts +0 -272
  185. package/src/filters/anchor-drift.ts +0 -55
  186. package/src/filters/filter-config.test.ts +0 -229
  187. package/src/filters/filter-config.ts +0 -69
  188. package/src/filters/list-id.test.ts +0 -50
  189. package/src/filters/match.test.ts +0 -429
  190. package/src/filters/match.ts +0 -274
  191. package/src/filters/message-embedder.test.ts +0 -39
  192. package/src/filters/message-embedder.ts +0 -33
  193. package/src/filters/pipeline.test.ts +0 -330
  194. package/src/filters/pipeline.ts +0 -284
  195. package/src/flag-push-terminal.test.ts +0 -209
  196. package/src/flag-push.test.ts +0 -408
  197. package/src/flag-push.ts +0 -198
  198. package/src/flag-queue-stars.test.ts +0 -171
  199. package/src/flag-queue.ts +0 -509
  200. package/src/folder-placement.ts +0 -56
  201. package/src/heuristics/authenticationResults.test.ts +0 -189
  202. package/src/heuristics/authenticationResults.ts +0 -212
  203. package/src/heuristics/classifyByHeaders.test.ts +0 -889
  204. package/src/heuristics/classifyByHeaders.ts +0 -467
  205. package/src/heuristics/classifyPlacement.test.ts +0 -366
  206. package/src/heuristics/classifyPlacement.ts +0 -211
  207. package/src/heuristics/classifyRealMail.test.ts +0 -242
  208. package/src/heuristics/machineSenders.test.ts +0 -101
  209. package/src/heuristics/senderMismatch.test.ts +0 -346
  210. package/src/heuristics/senderMismatch.ts +0 -259
  211. package/src/imapflow-connect-retry.test.ts +0 -134
  212. package/src/imapflow-connection.e2e.test.ts +0 -335
  213. package/src/imapflow-connection.integ.test.ts +0 -316
  214. package/src/imapflow-connection.test.ts +0 -469
  215. package/src/imapflow-connection.ts +0 -1475
  216. package/src/index.ts +0 -350
  217. package/src/list-id-backfill.test.ts +0 -573
  218. package/src/list-id-backfill.ts +0 -274
  219. package/src/mailbox-cursor-rebuild.test.ts +0 -171
  220. package/src/mailbox-cursor-rebuild.ts +0 -146
  221. package/src/mailbox-cursor.test.ts +0 -249
  222. package/src/mailbox-delete-intent.test.ts +0 -246
  223. package/src/mailbox-management.test.ts +0 -356
  224. package/src/mailbox-management.ts +0 -839
  225. package/src/mailbox-presence.test.ts +0 -72
  226. package/src/mailbox-queue.ts +0 -415
  227. package/src/mailbox-rename-intent.test.ts +0 -389
  228. package/src/mailbox-rename-subtree.test.ts +0 -664
  229. package/src/mailbox-sync.test.ts +0 -848
  230. package/src/mailbox-sync.ts +0 -724
  231. package/src/message-delete-junk-reconcile.test.ts +0 -117
  232. package/src/message-delete-no-trash.test.ts +0 -245
  233. package/src/message-delete-unsettled-placement.test.ts +0 -396
  234. package/src/message-move-copy.test.ts +0 -562
  235. package/src/message-move-empty-trash.test.ts +0 -326
  236. package/src/message-move-same-mailbox.test.ts +0 -139
  237. package/src/message-move-unsettled-placement.test.ts +0 -378
  238. package/src/message-move.ts +0 -1348
  239. package/src/message-parser.ts +0 -26
  240. package/src/message-presence.test.ts +0 -50
  241. package/src/message-sync-address-spoof.test.ts +0 -293
  242. package/src/message-sync-category.test.ts +0 -142
  243. package/src/message-sync-changedsince.test.ts +0 -840
  244. package/src/message-sync-flags.test.ts +0 -158
  245. package/src/message-sync-junk-harvest.test.ts +0 -244
  246. package/src/message-sync-quarantine.test.ts +0 -416
  247. package/src/message-sync-repoint.test.ts +0 -419
  248. package/src/message-sync.ts +0 -1963
  249. package/src/mime-walker.test.ts +0 -311
  250. package/src/mime-walker.ts +0 -305
  251. package/src/outbox-attachment-content.test.ts +0 -153
  252. package/src/outbox-attachment-content.ts +0 -104
  253. package/src/outbox-attachment-filename.test.ts +0 -132
  254. package/src/outbox-attachment-filename.ts +0 -76
  255. package/src/outbox-attachment.test.ts +0 -738
  256. package/src/outbox-attachment.ts +0 -536
  257. package/src/outbox-failed-recovery.test.ts +0 -370
  258. package/src/outbox-queue-settled-row.test.ts +0 -140
  259. package/src/outbox-queue.test.ts +0 -291
  260. package/src/outbox-queue.ts +0 -508
  261. package/src/pass-through-unit-of-work.ts +0 -17
  262. package/src/placement-move-terminal.test.ts +0 -300
  263. package/src/placement-move-terminal.ts +0 -148
  264. package/src/placement-move-unsettled.test.ts +0 -275
  265. package/src/placement-move.ts +0 -336
  266. package/src/placement-settled.test.ts +0 -147
  267. package/src/placement-transition-race.test.ts +0 -482
  268. package/src/quarantine.test.ts +0 -191
  269. package/src/quarantine.ts +0 -228
  270. package/src/snippet.test.ts +0 -213
  271. package/src/snippet.ts +0 -152
  272. package/src/spam-report.test.ts +0 -801
  273. package/src/spam-report.ts +0 -357
  274. package/src/stale-message-reconcile.test.ts +0 -82
  275. package/src/stale-message-reconcile.ts +0 -55
  276. package/src/sync-watermarks.test.ts +0 -409
  277. package/src/sync-watermarks.ts +0 -319
  278. package/src/system-flag-wire-format.test.ts +0 -77
  279. package/src/test-helpers/folder-roles.ts +0 -66
  280. package/src/test-helpers/isolated-mailbox.ts +0 -44
  281. package/src/test-helpers/mailfuzz-connection.ts +0 -27
  282. package/src/test-helpers/mime-tree-generator.ts +0 -461
  283. package/src/text/normalizer.test.ts +0 -223
  284. package/src/text/normalizer.ts +0 -140
  285. package/src/thread-message-rows.ts +0 -77
  286. package/src/types/mailparser-augment.d.ts +0 -15
  287. package/src/types/natural-porter-stemmer.d.ts +0 -50
  288. package/src/types/stopword.d.ts +0 -69
  289. package/src/types.ts +0 -371
  290. package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
  291. package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
  292. package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
  293. package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
  294. package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
  295. package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
  296. package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
  297. package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
  298. package/tsconfig.json +0 -8
@@ -0,0 +1,69 @@
1
+ import type { FifoQueueProducer, IMessageFlagPushRepository } from "@remit/data-ports";
2
+ /**
3
+ * Event the reconciler (imap-worker `handleFlagPush`) drains. Carries ONLY
4
+ * our own message id + the flag field — never a UID and never the desired
5
+ * operation (that lives on the marker, resolved fresh at push time, epic
6
+ * #1281 invariant 1). The event stays valid across any amount of queue delay
7
+ * or a later local flip of the SAME field (which replaces the marker before
8
+ * this event is ever processed).
9
+ */
10
+ export interface FlagPushEvent {
11
+ type: "FLAG_PUSH";
12
+ eventId: string;
13
+ timestamp: number;
14
+ accountId: string;
15
+ accountConfigId: string;
16
+ messageId: string;
17
+ flagName: string;
18
+ }
19
+ export interface FlagPushLogger {
20
+ info(obj: Record<string, unknown>, msg: string): void;
21
+ error(obj: Record<string, unknown>, msg: string): void;
22
+ }
23
+ export interface FlagPushConfig {
24
+ markerService: IMessageFlagPushRepository;
25
+ queue: FifoQueueProducer<FlagPushEvent>;
26
+ logger?: FlagPushLogger;
27
+ }
28
+ export type FlagPushOperationValue = "add" | "remove";
29
+ /**
30
+ * Writes a pending flag-push marker and best-effort enqueues a wake-up hint
31
+ * (issue #1273, epic #1281). Called AFTER the caller (`FlagQueueService`) has
32
+ * already durably written the local `MessageFlag`/`ThreadMessage` state in
33
+ * the SAME request — the user's intent is durable the moment `flip` writes
34
+ * the marker, before the enqueue even runs.
35
+ *
36
+ * Mirrors `PlacementMoveService` (#1289)'s marker lifecycle, scoped per
37
+ * (`messageId`, `flagName`) instead of per `messageId` — a message can carry
38
+ * an independent pending read-state marker and pending star marker at once
39
+ * (epic FAQ: "a flag flip replaces a pending flag flip", scoped per field).
40
+ * `put` (not `create`) always resets the marker to `pending`, so a later flip
41
+ * of the SAME field simply replaces the earlier marker — "later intent wins
42
+ * locally".
43
+ *
44
+ * Unlike #1289's placement move (where an enqueue failure must propagate,
45
+ * because nothing besides the marker+enqueue sequence has committed the
46
+ * user's intent), the flag flip here is durable the instant the marker write
47
+ * lands — the enqueue is purely a latency optimization so the push does not
48
+ * have to wait for the next periodic sync tick. An enqueue failure is
49
+ * therefore swallowed on purpose: it must never fail the caller's request.
50
+ * It must also never be silent — logged with an `alert`-shaped entry so a
51
+ * string of failures (SQS itself unhealthy) is operationally visible, same
52
+ * convention as the enqueue this replaces (`flag-queue.ts`'s prior
53
+ * `flag_sync_enqueue_failed`).
54
+ */
55
+ export declare class FlagPushService {
56
+ private markerService;
57
+ private queue;
58
+ private log;
59
+ constructor(config: FlagPushConfig);
60
+ flip: (params: {
61
+ accountId: string;
62
+ accountConfigId: string;
63
+ messageId: string;
64
+ mailboxId: string;
65
+ flagName: string;
66
+ operation: FlagPushOperationValue;
67
+ }) => Promise<void>;
68
+ private enqueueHint;
69
+ }
@@ -0,0 +1,111 @@
1
+ import { randomUUID } from "node:crypto";
2
+ const noopLogger = {
3
+ info: () => { },
4
+ error: () => { },
5
+ };
6
+ /**
7
+ * Writes a pending flag-push marker and best-effort enqueues a wake-up hint
8
+ * (issue #1273, epic #1281). Called AFTER the caller (`FlagQueueService`) has
9
+ * already durably written the local `MessageFlag`/`ThreadMessage` state in
10
+ * the SAME request — the user's intent is durable the moment `flip` writes
11
+ * the marker, before the enqueue even runs.
12
+ *
13
+ * Mirrors `PlacementMoveService` (#1289)'s marker lifecycle, scoped per
14
+ * (`messageId`, `flagName`) instead of per `messageId` — a message can carry
15
+ * an independent pending read-state marker and pending star marker at once
16
+ * (epic FAQ: "a flag flip replaces a pending flag flip", scoped per field).
17
+ * `put` (not `create`) always resets the marker to `pending`, so a later flip
18
+ * of the SAME field simply replaces the earlier marker — "later intent wins
19
+ * locally".
20
+ *
21
+ * Unlike #1289's placement move (where an enqueue failure must propagate,
22
+ * because nothing besides the marker+enqueue sequence has committed the
23
+ * user's intent), the flag flip here is durable the instant the marker write
24
+ * lands — the enqueue is purely a latency optimization so the push does not
25
+ * have to wait for the next periodic sync tick. An enqueue failure is
26
+ * therefore swallowed on purpose: it must never fail the caller's request.
27
+ * It must also never be silent — logged with an `alert`-shaped entry so a
28
+ * string of failures (SQS itself unhealthy) is operationally visible, same
29
+ * convention as the enqueue this replaces (`flag-queue.ts`'s prior
30
+ * `flag_sync_enqueue_failed`).
31
+ */
32
+ export class FlagPushService {
33
+ markerService;
34
+ queue;
35
+ log;
36
+ constructor(config) {
37
+ this.markerService = config.markerService;
38
+ this.queue = config.queue;
39
+ this.log = config.logger ?? noopLogger;
40
+ }
41
+ flip = async (params) => {
42
+ const { accountId, accountConfigId, messageId, mailboxId, flagName, operation, } = params;
43
+ await this.markerService.put({
44
+ messageId,
45
+ flagName,
46
+ accountId,
47
+ accountConfigId,
48
+ mailboxId,
49
+ operation,
50
+ });
51
+ await this.enqueueHint({ accountId, accountConfigId, messageId, flagName });
52
+ };
53
+ enqueueHint = async (params) => {
54
+ const event = {
55
+ type: "FLAG_PUSH",
56
+ eventId: randomUUID(),
57
+ timestamp: Date.now(),
58
+ ...params,
59
+ };
60
+ // The marker persisted in `flip` is the durable record — the queue,
61
+ // per the issue's own words. This send is only a wake-up hint: a
62
+ // queue-down failure must NOT fail the caller's flip (intent is
63
+ // already durable) and must NOT be silent either (standing rule: our
64
+ // infra failing is never a quiet blip). The periodic per-mailbox
65
+ // drain (issue #1273) still finds and pushes the marker regardless.
66
+ await this.queue
67
+ .send({
68
+ body: event,
69
+ groupId: params.accountId,
70
+ deduplicationId: event.eventId,
71
+ })
72
+ .then(async () => {
73
+ // Only advance past `pending` once the hint is CONFIRMED sent — a
74
+ // marker still `pending` is exactly the periodic drain's query
75
+ // target (`sync-messages.ts`'s `drainPendingFlagPushes`), so a
76
+ // failed send below must leave it there, never here.
77
+ await this.markerService
78
+ .updateState(params.messageId, params.flagName, "queued")
79
+ .catch(async (error) => {
80
+ // Fast-path race (review finding on #1292): the worker can
81
+ // drain AND delete the marker before this transition runs —
82
+ // SQS delivery + processing can outrun this .then(). The
83
+ // enqueue itself already succeeded, so re-check whether the
84
+ // marker still exists before treating this as an infra
85
+ // failure: if it's already gone, the push already completed
86
+ // and this is expected, routine — never the
87
+ // flag_push_hint_enqueue_failed alarm shape. Any OTHER
88
+ // failure (e.g. the re-check itself fails because the
89
+ // backend is genuinely down) still propagates to the outer
90
+ // catch below.
91
+ const stillPending = await this.markerService.find(params.messageId, params.flagName);
92
+ if (!stillPending) {
93
+ this.log.info({ eventId: event.eventId, ...params }, "Marker already drained before the queued-state transition (fast-path race, harmless)");
94
+ return;
95
+ }
96
+ throw error;
97
+ });
98
+ this.log.info({ eventId: event.eventId, ...params }, "Enqueued FLAG_PUSH wake-up hint");
99
+ })
100
+ .catch((error) => {
101
+ this.log.error({
102
+ alert: "flag_push_hint_enqueue_failed",
103
+ eventId: event.eventId,
104
+ ...params,
105
+ errorName: error?.name,
106
+ errorCode: error?.Code ??
107
+ error?.code,
108
+ }, "Failed to enqueue FLAG_PUSH wake-up hint (marker persisted; the periodic sync tick will still drain it)");
109
+ });
110
+ };
111
+ }
@@ -0,0 +1,175 @@
1
+ import type { IMessageFlagRepository, IMessageRepository, IThreadMessageRepository } from "@remit/data-ports";
2
+ import { StarColor } from "@remit/domain-enums";
3
+ import type { FlagPushService } from "./flag-push.js";
4
+ /**
5
+ * StarColor type derived from the StarColor const object
6
+ */
7
+ type StarColorValue = (typeof StarColor)[keyof typeof StarColor];
8
+ /**
9
+ * Logger interface
10
+ */
11
+ export interface FlagQueueLogger {
12
+ info(obj: Record<string, unknown>, msg: string): void;
13
+ error(obj: Record<string, unknown>, msg: string): void;
14
+ }
15
+ /**
16
+ * Input for updateFlags API method
17
+ */
18
+ export interface UpdateFlagsInput {
19
+ isRead?: boolean;
20
+ isStarred?: boolean;
21
+ starColor?: StarColorValue;
22
+ }
23
+ /**
24
+ * Result of updateFlags API method
25
+ */
26
+ export interface UpdateFlagsResult {
27
+ messageId: string;
28
+ isRead: boolean;
29
+ isStarred: boolean;
30
+ }
31
+ /**
32
+ * Configuration for FlagQueueService
33
+ */
34
+ export interface FlagQueueConfig {
35
+ messageFlagService: IMessageFlagRepository;
36
+ messageService: IMessageRepository;
37
+ threadMessageService: IThreadMessageRepository;
38
+ flagPushService: FlagPushService;
39
+ logger?: FlagQueueLogger;
40
+ }
41
+ /**
42
+ * Service for marking messages as read/unread/starred, local-first with a
43
+ * durable pending-flag marker (issue #1273, epic #1281).
44
+ *
45
+ * Every flip below follows the SAME sequence: persist the pending marker
46
+ * FIRST via {@link FlagPushService.flip} (the durable record IMAP still owes
47
+ * this push, and best-effort enqueues a wake-up hint), THEN apply locally
48
+ * (MessageFlag + ThreadMessage). The marker write is what makes the user's
49
+ * intent durable — not the enqueue, which may fail freely (see
50
+ * `FlagPushService`'s own doc) — and writing it first means a crash before
51
+ * the local write leaves an unapplied local flip behind an already-durable
52
+ * marker, never a flipped flag with no marker (review finding on #1292;
53
+ * mirrors #1289's `PlacementMoveService.moveMessage` ordering). This class
54
+ * therefore no longer touches
55
+ * `Mailbox.unseenCount` at all: that field is a pure projection, recomputed
56
+ * only from IMAP (`doc/rules/data-flow.md`); the displayed count is adjusted
57
+ * at READ TIME from pending markers (`applyPendingMoveCountPrediction`,
58
+ * `packages/backend/src/derive/pendingMoveCounts.ts`).
59
+ *
60
+ * The service updates BOTH entities locally:
61
+ * - MessageFlag: The canonical flag record
62
+ * - ThreadMessage.isRead / hasStars / star: Denormalized for efficient queries
63
+ */
64
+ export declare class FlagQueueService {
65
+ private messageFlagService;
66
+ private messageService;
67
+ private threadMessageService;
68
+ private flagPushService;
69
+ private log;
70
+ constructor(config: FlagQueueConfig);
71
+ /**
72
+ * Update ThreadMessage.isRead for ALL ThreadMessages matching this messageId.
73
+ *
74
+ * A message can exist in multiple mailboxes (e.g., inbox and archive), so we
75
+ * must update all instances to keep the isRead status consistent.
76
+ *
77
+ * The `composites` map carries the CURRENT values of every attribute that
78
+ * participates in a sort key. ElectroDB uses them for the conditional check
79
+ * on the existing row and combines them with `set()` to recompute the new
80
+ * sort keys. Passing the NEW `isRead` value here would make the conditional
81
+ * check fail (existing row still has the old value) and the patch would be
82
+ * misreported as NotFoundError, silently dropping the update.
83
+ *
84
+ * Handles race condition where ThreadMessage may be deleted between find and update.
85
+ */
86
+ private updateThreadMessageIsRead;
87
+ /**
88
+ * Update ThreadMessage.hasStars and star color for ALL ThreadMessages matching this messageId.
89
+ *
90
+ * A message can exist in multiple mailboxes (e.g., inbox and archive), so we
91
+ * must update all instances to keep the star status consistent.
92
+ *
93
+ * Handles race condition where ThreadMessage may be deleted between find and update.
94
+ */
95
+ private updateThreadMessageStars;
96
+ /**
97
+ * Flip one flag field: persist the pending marker FIRST, then apply
98
+ * locally. Every caller in this class routes through here so the marker
99
+ * write is never skipped. Returns whether a change actually happened —
100
+ * `false` for a redundant flip (already in the desired state), which
101
+ * every caller uses to skip the (otherwise redundant) ThreadMessage
102
+ * update too.
103
+ *
104
+ * Ordering matters (review finding on #1292): a marker written AFTER the
105
+ * local `MessageFlag` flip means a crash between the two awaits strands a
106
+ * flipped flag with no marker — no hint was ever sent, the pending-only
107
+ * drain never finds it (no marker to find), and message-sync never
108
+ * rewrites `MessageFlag` from IMAP for an existing row, so nothing else
109
+ * reconciles it either. That is a permanent, silent divergence — exactly
110
+ * the defect #1273 exists to kill. Writing the marker first (matching
111
+ * #1289's `PlacementMoveService.moveMessage` ordering) makes that
112
+ * scenario impossible: the marker's existence is the record of how far
113
+ * the sequence got, so a crash after it can strand at worst an
114
+ * un-applied LOCAL write behind an already-durable marker — recoverable
115
+ * (the caller's natural retry re-applies both steps; `put` and
116
+ * `addFlag`/`removeFlag` are both idempotent) — never the reverse.
117
+ *
118
+ * The current-state check (review finding on #1292) matters for a
119
+ * different reason: `markAsRead`/`markAsUnread`/`updateFlags` take the
120
+ * DESIRED boolean from the caller rather than deriving it from current
121
+ * state (unlike `toggleFlagged`, which already reads `hasFlag` first and
122
+ * so can never generate a redundant marker). A redundant "mark as read"
123
+ * on an already-read message would otherwise still write a fresh `add
124
+ * \Seen` marker; the read-time unseenCount prediction
125
+ * (`applyPendingMoveCountPrediction`) would then subtract one for a
126
+ * message IMAP already counts as seen, transiently under-counting the
127
+ * badge until the marker clears. Skipping the marker (and the local
128
+ * write) entirely when the flag already matches removes the false
129
+ * prediction at the source, not just its symptom.
130
+ */
131
+ private flipFlag;
132
+ /**
133
+ * Mark a message as read (add \Seen flag).
134
+ * Updates MessageFlag, ThreadMessage.isRead, and persists a pending
135
+ * flag-push marker for IMAP sync.
136
+ *
137
+ * @param accountConfigId - The owning account config (tenant scope)
138
+ * @param messageId - The message to mark as read
139
+ * @param accountId - The account ID for the IMAP sync event
140
+ */
141
+ markAsRead: (accountConfigId: string, messageId: string, accountId: string) => Promise<void>;
142
+ /**
143
+ * Mark a message as unread (remove \Seen flag).
144
+ * Updates MessageFlag, ThreadMessage.isRead, and persists a pending
145
+ * flag-push marker for IMAP sync.
146
+ *
147
+ * @param accountConfigId - The owning account config (tenant scope)
148
+ * @param messageId - The message to mark as unread
149
+ * @param accountId - The account ID for the IMAP sync event
150
+ */
151
+ markAsUnread: (accountConfigId: string, messageId: string, accountId: string) => Promise<void>;
152
+ /**
153
+ * Toggle the starred/flagged status of a message.
154
+ * Updates local state and persists a pending flag-push marker for IMAP
155
+ * sync.
156
+ *
157
+ * @param accountConfigId - The owning account config (tenant scope)
158
+ * @param messageId - The message to toggle
159
+ * @param accountId - The account ID for the IMAP sync event
160
+ * @returns true if flag was added, false if removed
161
+ */
162
+ toggleFlagged: (accountConfigId: string, messageId: string, accountId: string) => Promise<boolean>;
163
+ /**
164
+ * Update message flags using API-friendly input format.
165
+ * Maps isRead/isStarred to IMAP flags and updates ThreadMessage accordingly.
166
+ *
167
+ * @param accountConfigId - The owning account config (tenant scope)
168
+ * @param messageId - The message to update
169
+ * @param accountId - The account ID for the IMAP sync event
170
+ * @param input - The flag updates to apply
171
+ * @returns The current flag state after updates
172
+ */
173
+ updateFlags: (accountConfigId: string, messageId: string, accountId: string, input: UpdateFlagsInput) => Promise<UpdateFlagsResult>;
174
+ }
175
+ export {};
@@ -0,0 +1,291 @@
1
+ import { NotFoundError } from "@remit/data-ports/errors";
2
+ import { MessageSystemFlag, StarColor } from "@remit/domain-enums";
3
+ const noopLogger = {
4
+ info: () => { },
5
+ error: () => { },
6
+ };
7
+ /**
8
+ * Service for marking messages as read/unread/starred, local-first with a
9
+ * durable pending-flag marker (issue #1273, epic #1281).
10
+ *
11
+ * Every flip below follows the SAME sequence: persist the pending marker
12
+ * FIRST via {@link FlagPushService.flip} (the durable record IMAP still owes
13
+ * this push, and best-effort enqueues a wake-up hint), THEN apply locally
14
+ * (MessageFlag + ThreadMessage). The marker write is what makes the user's
15
+ * intent durable — not the enqueue, which may fail freely (see
16
+ * `FlagPushService`'s own doc) — and writing it first means a crash before
17
+ * the local write leaves an unapplied local flip behind an already-durable
18
+ * marker, never a flipped flag with no marker (review finding on #1292;
19
+ * mirrors #1289's `PlacementMoveService.moveMessage` ordering). This class
20
+ * therefore no longer touches
21
+ * `Mailbox.unseenCount` at all: that field is a pure projection, recomputed
22
+ * only from IMAP (`doc/rules/data-flow.md`); the displayed count is adjusted
23
+ * at READ TIME from pending markers (`applyPendingMoveCountPrediction`,
24
+ * `packages/backend/src/derive/pendingMoveCounts.ts`).
25
+ *
26
+ * The service updates BOTH entities locally:
27
+ * - MessageFlag: The canonical flag record
28
+ * - ThreadMessage.isRead / hasStars / star: Denormalized for efficient queries
29
+ */
30
+ export class FlagQueueService {
31
+ messageFlagService;
32
+ messageService;
33
+ threadMessageService;
34
+ flagPushService;
35
+ log;
36
+ constructor(config) {
37
+ const { messageFlagService, messageService, threadMessageService, flagPushService, } = config;
38
+ this.messageFlagService = messageFlagService;
39
+ this.messageService = messageService;
40
+ this.threadMessageService = threadMessageService;
41
+ this.flagPushService = flagPushService;
42
+ this.log = config.logger ?? noopLogger;
43
+ }
44
+ /**
45
+ * Update ThreadMessage.isRead for ALL ThreadMessages matching this messageId.
46
+ *
47
+ * A message can exist in multiple mailboxes (e.g., inbox and archive), so we
48
+ * must update all instances to keep the isRead status consistent.
49
+ *
50
+ * The `composites` map carries the CURRENT values of every attribute that
51
+ * participates in a sort key. ElectroDB uses them for the conditional check
52
+ * on the existing row and combines them with `set()` to recompute the new
53
+ * sort keys. Passing the NEW `isRead` value here would make the conditional
54
+ * check fail (existing row still has the old value) and the patch would be
55
+ * misreported as NotFoundError, silently dropping the update.
56
+ *
57
+ * Handles race condition where ThreadMessage may be deleted between find and update.
58
+ */
59
+ updateThreadMessageIsRead = async (accountConfigId, messageId, isRead) => {
60
+ const threadMessages = await this.threadMessageService.findAllByMessageId(accountConfigId, messageId);
61
+ if (threadMessages.length === 0) {
62
+ this.log.info({ messageId }, "ThreadMessage not found for messageId - skipping isRead update");
63
+ return;
64
+ }
65
+ for (const threadMessage of threadMessages) {
66
+ try {
67
+ await this.threadMessageService.update(threadMessage.accountConfigId, threadMessage.threadMessageId, { isRead }, {
68
+ composites: {
69
+ sentDate: threadMessage.sentDate,
70
+ mailboxId: threadMessage.mailboxId,
71
+ isRead: threadMessage.isRead,
72
+ isDeleted: threadMessage.isDeleted,
73
+ hasStars: threadMessage.hasStars,
74
+ hasAttachment: threadMessage.hasAttachment,
75
+ },
76
+ });
77
+ this.log.info({ messageId, threadMessageId: threadMessage.threadMessageId, isRead }, "Updated ThreadMessage.isRead");
78
+ }
79
+ catch (err) {
80
+ if (err instanceof NotFoundError) {
81
+ this.log.info({ messageId, threadMessageId: threadMessage.threadMessageId }, "ThreadMessage deleted during update - skipping isRead update");
82
+ continue;
83
+ }
84
+ throw err;
85
+ }
86
+ }
87
+ };
88
+ /**
89
+ * Update ThreadMessage.hasStars and star color for ALL ThreadMessages matching this messageId.
90
+ *
91
+ * A message can exist in multiple mailboxes (e.g., inbox and archive), so we
92
+ * must update all instances to keep the star status consistent.
93
+ *
94
+ * Handles race condition where ThreadMessage may be deleted between find and update.
95
+ */
96
+ updateThreadMessageStars = async (accountConfigId, messageId, updates) => {
97
+ if (Object.keys(updates).length === 0)
98
+ return;
99
+ const threadMessages = await this.threadMessageService.findAllByMessageId(accountConfigId, messageId);
100
+ if (threadMessages.length === 0) {
101
+ this.log.info({ messageId }, "ThreadMessage not found for messageId - skipping star update");
102
+ return;
103
+ }
104
+ for (const threadMessage of threadMessages) {
105
+ try {
106
+ await this.threadMessageService.update(threadMessage.accountConfigId, threadMessage.threadMessageId, updates, {
107
+ composites: {
108
+ sentDate: threadMessage.sentDate,
109
+ mailboxId: threadMessage.mailboxId,
110
+ isRead: threadMessage.isRead,
111
+ isDeleted: threadMessage.isDeleted,
112
+ hasStars: threadMessage.hasStars,
113
+ hasAttachment: threadMessage.hasAttachment,
114
+ },
115
+ });
116
+ this.log.info({
117
+ messageId,
118
+ threadMessageId: threadMessage.threadMessageId,
119
+ updates,
120
+ }, "Updated ThreadMessage stars");
121
+ }
122
+ catch (err) {
123
+ if (err instanceof NotFoundError) {
124
+ this.log.info({ messageId, threadMessageId: threadMessage.threadMessageId }, "ThreadMessage deleted during update - skipping star update");
125
+ continue;
126
+ }
127
+ throw err;
128
+ }
129
+ }
130
+ };
131
+ /**
132
+ * Flip one flag field: persist the pending marker FIRST, then apply
133
+ * locally. Every caller in this class routes through here so the marker
134
+ * write is never skipped. Returns whether a change actually happened —
135
+ * `false` for a redundant flip (already in the desired state), which
136
+ * every caller uses to skip the (otherwise redundant) ThreadMessage
137
+ * update too.
138
+ *
139
+ * Ordering matters (review finding on #1292): a marker written AFTER the
140
+ * local `MessageFlag` flip means a crash between the two awaits strands a
141
+ * flipped flag with no marker — no hint was ever sent, the pending-only
142
+ * drain never finds it (no marker to find), and message-sync never
143
+ * rewrites `MessageFlag` from IMAP for an existing row, so nothing else
144
+ * reconciles it either. That is a permanent, silent divergence — exactly
145
+ * the defect #1273 exists to kill. Writing the marker first (matching
146
+ * #1289's `PlacementMoveService.moveMessage` ordering) makes that
147
+ * scenario impossible: the marker's existence is the record of how far
148
+ * the sequence got, so a crash after it can strand at worst an
149
+ * un-applied LOCAL write behind an already-durable marker — recoverable
150
+ * (the caller's natural retry re-applies both steps; `put` and
151
+ * `addFlag`/`removeFlag` are both idempotent) — never the reverse.
152
+ *
153
+ * The current-state check (review finding on #1292) matters for a
154
+ * different reason: `markAsRead`/`markAsUnread`/`updateFlags` take the
155
+ * DESIRED boolean from the caller rather than deriving it from current
156
+ * state (unlike `toggleFlagged`, which already reads `hasFlag` first and
157
+ * so can never generate a redundant marker). A redundant "mark as read"
158
+ * on an already-read message would otherwise still write a fresh `add
159
+ * \Seen` marker; the read-time unseenCount prediction
160
+ * (`applyPendingMoveCountPrediction`) would then subtract one for a
161
+ * message IMAP already counts as seen, transiently under-counting the
162
+ * badge until the marker clears. Skipping the marker (and the local
163
+ * write) entirely when the flag already matches removes the false
164
+ * prediction at the source, not just its symptom.
165
+ */
166
+ flipFlag = async (accountId, accountConfigId, messageId, mailboxId, flagName, operation) => {
167
+ const alreadyInState = await this.messageFlagService.hasFlag(messageId, flagName);
168
+ if ((operation === "add" && alreadyInState) ||
169
+ (operation === "remove" && !alreadyInState)) {
170
+ this.log.info({ messageId, flagName, operation }, "Flag already in the desired state — skipping redundant marker + local write");
171
+ return false;
172
+ }
173
+ await this.flagPushService.flip({
174
+ accountId,
175
+ accountConfigId,
176
+ messageId,
177
+ mailboxId,
178
+ flagName,
179
+ operation,
180
+ });
181
+ if (operation === "add") {
182
+ await this.messageFlagService.addFlag(messageId, flagName);
183
+ }
184
+ else {
185
+ await this.messageFlagService.removeFlag(messageId, flagName);
186
+ }
187
+ return true;
188
+ };
189
+ /**
190
+ * Mark a message as read (add \Seen flag).
191
+ * Updates MessageFlag, ThreadMessage.isRead, and persists a pending
192
+ * flag-push marker for IMAP sync.
193
+ *
194
+ * @param accountConfigId - The owning account config (tenant scope)
195
+ * @param messageId - The message to mark as read
196
+ * @param accountId - The account ID for the IMAP sync event
197
+ */
198
+ markAsRead = async (accountConfigId, messageId, accountId) => {
199
+ const message = await this.messageService.get(messageId);
200
+ const changed = await this.flipFlag(accountId, accountConfigId, messageId, message.mailboxId, MessageSystemFlag.Seen, "add");
201
+ if (changed) {
202
+ await this.updateThreadMessageIsRead(accountConfigId, messageId, true);
203
+ }
204
+ this.log.info({ messageId, changed }, "Marked message as read (local, push pending)");
205
+ };
206
+ /**
207
+ * Mark a message as unread (remove \Seen flag).
208
+ * Updates MessageFlag, ThreadMessage.isRead, and persists a pending
209
+ * flag-push marker for IMAP sync.
210
+ *
211
+ * @param accountConfigId - The owning account config (tenant scope)
212
+ * @param messageId - The message to mark as unread
213
+ * @param accountId - The account ID for the IMAP sync event
214
+ */
215
+ markAsUnread = async (accountConfigId, messageId, accountId) => {
216
+ const message = await this.messageService.get(messageId);
217
+ const changed = await this.flipFlag(accountId, accountConfigId, messageId, message.mailboxId, MessageSystemFlag.Seen, "remove");
218
+ if (changed) {
219
+ await this.updateThreadMessageIsRead(accountConfigId, messageId, false);
220
+ }
221
+ this.log.info({ messageId, changed }, "Marked message as unread (local, push pending)");
222
+ };
223
+ /**
224
+ * Toggle the starred/flagged status of a message.
225
+ * Updates local state and persists a pending flag-push marker for IMAP
226
+ * sync.
227
+ *
228
+ * @param accountConfigId - The owning account config (tenant scope)
229
+ * @param messageId - The message to toggle
230
+ * @param accountId - The account ID for the IMAP sync event
231
+ * @returns true if flag was added, false if removed
232
+ */
233
+ toggleFlagged = async (accountConfigId, messageId, accountId) => {
234
+ const message = await this.messageService.get(messageId);
235
+ const hasFlag = await this.messageFlagService.hasFlag(messageId, MessageSystemFlag.Flagged);
236
+ const operation = hasFlag ? "remove" : "add";
237
+ await this.flipFlag(accountId, accountConfigId, messageId, message.mailboxId, MessageSystemFlag.Flagged, operation);
238
+ this.log.info({ messageId, operation }, "Toggled flagged status (local, push pending)");
239
+ return operation === "add";
240
+ };
241
+ /**
242
+ * Update message flags using API-friendly input format.
243
+ * Maps isRead/isStarred to IMAP flags and updates ThreadMessage accordingly.
244
+ *
245
+ * @param accountConfigId - The owning account config (tenant scope)
246
+ * @param messageId - The message to update
247
+ * @param accountId - The account ID for the IMAP sync event
248
+ * @param input - The flag updates to apply
249
+ * @returns The current flag state after updates
250
+ */
251
+ updateFlags = async (accountConfigId, messageId, accountId, input) => {
252
+ const message = await this.messageService.get(messageId);
253
+ // Handle isRead -> \Seen flag
254
+ if (input.isRead !== undefined) {
255
+ const changed = await this.flipFlag(accountId, accountConfigId, messageId, message.mailboxId, MessageSystemFlag.Seen, input.isRead ? "add" : "remove");
256
+ if (changed) {
257
+ await this.updateThreadMessageIsRead(accountConfigId, messageId, input.isRead);
258
+ }
259
+ }
260
+ // `hasStars` is the boolean of record (the byStarred index sort key) and
261
+ // the server-side \Flagged keyword; `star` is its presentation colour,
262
+ // whose absent state is the None sentinel. The two must never disagree,
263
+ // whichever field the caller sent:
264
+ //
265
+ // isStarred alone → colour follows the boolean (standard colour on,
266
+ // None off)
267
+ // starColor alone → boolean follows the colour, so `none` unstars and
268
+ // any real colour stars
269
+ // both → isStarred decides the boolean, starColor the colour
270
+ //
271
+ // The \Flagged push follows the resulting boolean in every case, so a
272
+ // colour-only request still reaches the server. `flipFlag` no-ops when the
273
+ // flag already holds the desired state.
274
+ if (input.isStarred !== undefined || input.starColor !== undefined) {
275
+ const starred = input.isStarred ??
276
+ (input.starColor ?? StarColor.None) !== StarColor.None;
277
+ const color = input.starColor ?? (starred ? StarColor.Yellow : StarColor.None);
278
+ await this.flipFlag(accountId, accountConfigId, messageId, message.mailboxId, MessageSystemFlag.Flagged, starred ? "add" : "remove");
279
+ // Update ThreadMessage hasStars and star color for ALL instances
280
+ await this.updateThreadMessageStars(accountConfigId, messageId, {
281
+ hasStars: starred,
282
+ star: color,
283
+ });
284
+ }
285
+ // Return current state
286
+ const isRead = await this.messageFlagService.hasFlag(messageId, MessageSystemFlag.Seen);
287
+ const isStarred = await this.messageFlagService.hasFlag(messageId, MessageSystemFlag.Flagged);
288
+ this.log.info({ messageId, isRead, isStarred, input }, "Updated message flags");
289
+ return { messageId, isRead, isStarred };
290
+ };
291
+ }