@remit/mailbox-service 0.0.1

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 (124) hide show
  1. package/README.md +147 -0
  2. package/package.json +65 -0
  3. package/src/account-credentials.ts +128 -0
  4. package/src/adversarial-mime.e2e.test.ts +311 -0
  5. package/src/attribute-mapper.test.ts +78 -0
  6. package/src/attribute-mapper.ts +147 -0
  7. package/src/body-part-mapper.property.test.ts +354 -0
  8. package/src/body-part-mapper.ts +360 -0
  9. package/src/body-sync-queue.test.ts +124 -0
  10. package/src/body-sync-queue.ts +131 -0
  11. package/src/body-sync-terminal.test.ts +265 -0
  12. package/src/body-sync-terminal.ts +172 -0
  13. package/src/body-sync.ts +1202 -0
  14. package/src/connection-factory.ts +102 -0
  15. package/src/connection-test.ts +208 -0
  16. package/src/credentials.test.ts +181 -0
  17. package/src/filters/match.test.ts +181 -0
  18. package/src/filters/match.ts +143 -0
  19. package/src/filters/pipeline.ts +243 -0
  20. package/src/flag-push-terminal.test.ts +156 -0
  21. package/src/flag-push-terminal.ts +106 -0
  22. package/src/flag-push.test.ts +408 -0
  23. package/src/flag-push.ts +198 -0
  24. package/src/flag-queue.ts +500 -0
  25. package/src/heuristics/classifyByHeaders.test.ts +578 -0
  26. package/src/heuristics/classifyByHeaders.ts +369 -0
  27. package/src/heuristics/classifyPlacement.ts +96 -0
  28. package/src/heuristics/socialDomains.ts +23 -0
  29. package/src/heuristics/transactionalDomains.ts +26 -0
  30. package/src/imapflow-connect-retry.test.ts +108 -0
  31. package/src/imapflow-connection.e2e.test.ts +355 -0
  32. package/src/imapflow-connection.integ.test.ts +327 -0
  33. package/src/imapflow-connection.test.ts +161 -0
  34. package/src/imapflow-connection.ts +1374 -0
  35. package/src/index.ts +214 -0
  36. package/src/mailbox-cursor-rebuild.test.ts +171 -0
  37. package/src/mailbox-cursor-rebuild.ts +146 -0
  38. package/src/mailbox-cursor.test.ts +249 -0
  39. package/src/mailbox-cursor.ts +177 -0
  40. package/src/mailbox-management.test.ts +103 -0
  41. package/src/mailbox-management.ts +235 -0
  42. package/src/mailbox-queue.ts +258 -0
  43. package/src/mailbox-sync.test.ts +153 -0
  44. package/src/mailbox-sync.ts +590 -0
  45. package/src/message-move.ts +825 -0
  46. package/src/message-parser.ts +26 -0
  47. package/src/message-sync.ts +1021 -0
  48. package/src/mime-walker.test.ts +311 -0
  49. package/src/mime-walker.ts +305 -0
  50. package/src/outbox-queue.ts +294 -0
  51. package/src/pass-through-unit-of-work.ts +17 -0
  52. package/src/placement-move-terminal.test.ts +179 -0
  53. package/src/placement-move-terminal.ts +104 -0
  54. package/src/placement-move.ts +224 -0
  55. package/src/snippet.test.ts +213 -0
  56. package/src/snippet.ts +152 -0
  57. package/src/stale-message-reconcile.test.ts +82 -0
  58. package/src/stale-message-reconcile.ts +55 -0
  59. package/src/test-helpers/isolated-mailbox.ts +44 -0
  60. package/src/test-helpers/mailfuzz-connection.ts +27 -0
  61. package/src/test-helpers/mime-tree-generator.ts +461 -0
  62. package/src/text/normalizer.test.ts +223 -0
  63. package/src/text/normalizer.ts +140 -0
  64. package/src/types/mailparser-augment.d.ts +15 -0
  65. package/src/types/natural-porter-stemmer.d.ts +50 -0
  66. package/src/types/stopword.d.ts +69 -0
  67. package/src/types.ts +349 -0
  68. package/test/fixtures/mime/01-flat-text-plain.bodyparts.json +8 -0
  69. package/test/fixtures/mime/01-flat-text-plain.eml +10 -0
  70. package/test/fixtures/mime/01-flat-text-plain.expected.json +10 -0
  71. package/test/fixtures/mime/02-flat-text-html.bodyparts.json +8 -0
  72. package/test/fixtures/mime/02-flat-text-html.eml +10 -0
  73. package/test/fixtures/mime/02-flat-text-html.expected.json +10 -0
  74. package/test/fixtures/mime/03-alternative-text-html.bodyparts.json +20 -0
  75. package/test/fixtures/mime/03-alternative-text-html.eml +19 -0
  76. package/test/fixtures/mime/03-alternative-text-html.expected.json +16 -0
  77. package/test/fixtures/mime/04-mixed-html-pdf-attachment.bodyparts.json +22 -0
  78. package/test/fixtures/mime/04-mixed-html-pdf-attachment.eml +20 -0
  79. package/test/fixtures/mime/04-mixed-html-pdf-attachment.expected.json +16 -0
  80. package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.bodyparts.json +22 -0
  81. package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.eml +21 -0
  82. package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.expected.json +16 -0
  83. package/test/fixtures/mime/06-related-html-cid-image.bodyparts.json +23 -0
  84. package/test/fixtures/mime/06-related-html-cid-image.eml +21 -0
  85. package/test/fixtures/mime/06-related-html-cid-image.expected.json +16 -0
  86. package/test/fixtures/mime/07-mixed-alternative-related-attachment.bodyparts.json +55 -0
  87. package/test/fixtures/mime/07-mixed-alternative-related-attachment.eml +47 -0
  88. package/test/fixtures/mime/07-mixed-alternative-related-attachment.expected.json +34 -0
  89. package/test/fixtures/mime/08-two-pdfs-same-filename.bodyparts.json +30 -0
  90. package/test/fixtures/mime/08-two-pdfs-same-filename.eml +26 -0
  91. package/test/fixtures/mime/08-two-pdfs-same-filename.expected.json +22 -0
  92. package/test/fixtures/mime/09-attachment-no-filename.bodyparts.json +21 -0
  93. package/test/fixtures/mime/09-attachment-no-filename.eml +20 -0
  94. package/test/fixtures/mime/09-attachment-no-filename.expected.json +16 -0
  95. package/test/fixtures/mime/10-calendar-invite.bodyparts.json +22 -0
  96. package/test/fixtures/mime/10-calendar-invite.eml +33 -0
  97. package/test/fixtures/mime/10-calendar-invite.expected.json +16 -0
  98. package/test/fixtures/mime/11-quoted-printable-text.bodyparts.json +8 -0
  99. package/test/fixtures/mime/11-quoted-printable-text.eml +10 -0
  100. package/test/fixtures/mime/11-quoted-printable-text.expected.json +10 -0
  101. package/test/fixtures/mime/12-base64-binary.bodyparts.json +22 -0
  102. package/test/fixtures/mime/12-base64-binary.eml +20 -0
  103. package/test/fixtures/mime/12-base64-binary.expected.json +16 -0
  104. package/test/fixtures/mime/13-message-rfc822-forward.bodyparts.json +22 -0
  105. package/test/fixtures/mime/13-message-rfc822-forward.eml +28 -0
  106. package/test/fixtures/mime/13-message-rfc822-forward.expected.json +16 -0
  107. package/test/fixtures/mime/14-deeply-nested-5-levels.bodyparts.json +38 -0
  108. package/test/fixtures/mime/14-deeply-nested-5-levels.eml +34 -0
  109. package/test/fixtures/mime/14-deeply-nested-5-levels.expected.json +10 -0
  110. package/test/fixtures/mime/15-empty-text-part.bodyparts.json +21 -0
  111. package/test/fixtures/mime/15-empty-text-part.eml +18 -0
  112. package/test/fixtures/mime/15-empty-text-part.expected.json +16 -0
  113. package/test/fixtures/mime/16-empty-non-text-part.bodyparts.json +30 -0
  114. package/test/fixtures/mime/16-empty-non-text-part.eml +24 -0
  115. package/test/fixtures/mime/16-empty-non-text-part.expected.json +22 -0
  116. package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +21 -0
  117. package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +31 -0
  118. package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +33 -0
  119. package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +42 -0
  120. package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +25 -0
  121. package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +15 -0
  122. package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +17 -0
  123. package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +18 -0
  124. package/tsconfig.json +8 -0
@@ -0,0 +1,408 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it, mock } from "node:test";
3
+ import type { IMessageFlagPushRepository } from "@remit/data-ports";
4
+ import { MessageSystemFlag } from "@remit/domain-enums";
5
+ import { type FlagPushConfig, FlagPushService } from "./flag-push.js";
6
+
7
+ const accountId = "acc-1";
8
+ const accountConfigId = "acc-cfg-1";
9
+ const messageId = "msg-1";
10
+ const mailboxId = "mbx-1";
11
+
12
+ const createMockSqs = (impl?: () => Promise<unknown>) => {
13
+ const sent: unknown[] = [];
14
+ return {
15
+ send: mock.fn(async (cmd: { input: unknown }) => {
16
+ if (impl) await impl();
17
+ sent.push(cmd.input);
18
+ return { MessageId: "ok" };
19
+ }),
20
+ _sent: sent,
21
+ };
22
+ };
23
+
24
+ interface Harness {
25
+ service: FlagPushService;
26
+ markerPuts: Array<Record<string, unknown>>;
27
+ markerStateUpdates: Array<{
28
+ messageId: string;
29
+ flagName: string;
30
+ state: string;
31
+ }>;
32
+ mockSqs: ReturnType<typeof createMockSqs>;
33
+ logs: { info: unknown[]; error: unknown[] };
34
+ }
35
+
36
+ const createHarness = (opts?: {
37
+ sqsImpl?: () => Promise<unknown>;
38
+ queueUrl?: string;
39
+ updateStateImpl?: () => Promise<unknown>;
40
+ findImpl?: () => Promise<unknown>;
41
+ }): Harness => {
42
+ const markerPuts: Array<Record<string, unknown>> = [];
43
+ const markerStateUpdates: Array<{
44
+ messageId: string;
45
+ flagName: string;
46
+ state: string;
47
+ }> = [];
48
+ const logs = { info: [] as unknown[], error: [] as unknown[] };
49
+
50
+ const markerService: IMessageFlagPushRepository = {
51
+ put: mock.fn(async (input: Record<string, unknown>) => {
52
+ markerPuts.push(input);
53
+ return {
54
+ ...input,
55
+ state: "pending",
56
+ createdAt: 1,
57
+ updatedAt: 1,
58
+ } as never;
59
+ }),
60
+ find: mock.fn(async () =>
61
+ opts?.findImpl ? ((await opts.findImpl()) as never) : null,
62
+ ),
63
+ updateState: mock.fn(
64
+ async (id: string, flagName: string, state: string) => {
65
+ if (opts?.updateStateImpl) return opts.updateStateImpl() as never;
66
+ markerStateUpdates.push({ messageId: id, flagName, state });
67
+ return {} as never;
68
+ },
69
+ ),
70
+ delete: mock.fn(async () => {}),
71
+ listByAccountId: mock.fn(async () => []),
72
+ listByMailboxId: mock.fn(async () => []),
73
+ };
74
+
75
+ const mockSqs = createMockSqs(opts?.sqsImpl);
76
+
77
+ const config: FlagPushConfig = {
78
+ markerService,
79
+ sqsQueueUrl: opts?.queueUrl ?? "http://localhost:4566/test-queue",
80
+ logger: {
81
+ info: (fields: Record<string, unknown>) => {
82
+ logs.info.push(fields);
83
+ },
84
+ error: (fields: Record<string, unknown>) => {
85
+ logs.error.push(fields);
86
+ },
87
+ },
88
+ };
89
+
90
+ const service = new FlagPushService(config);
91
+ // @ts-expect-error - inject mock SQS client for testing
92
+ service.sqs = mockSqs;
93
+
94
+ return { service, markerPuts, markerStateUpdates, mockSqs, logs };
95
+ };
96
+
97
+ describe("FlagPushService.flip", () => {
98
+ it("persists a pending marker before enqueueing the wake-up hint", async () => {
99
+ const { service, markerPuts } = createHarness();
100
+
101
+ await service.flip({
102
+ accountId,
103
+ accountConfigId,
104
+ messageId,
105
+ mailboxId,
106
+ flagName: MessageSystemFlag.Seen,
107
+ operation: "add",
108
+ });
109
+
110
+ assert.equal(markerPuts.length, 1);
111
+ assert.deepEqual(markerPuts[0], {
112
+ messageId,
113
+ flagName: MessageSystemFlag.Seen,
114
+ accountId,
115
+ accountConfigId,
116
+ mailboxId,
117
+ operation: "add",
118
+ });
119
+ });
120
+
121
+ it("advances the marker to queued once the SQS hint is confirmed sent", async () => {
122
+ const { service, markerStateUpdates } = createHarness();
123
+
124
+ await service.flip({
125
+ accountId,
126
+ accountConfigId,
127
+ messageId,
128
+ mailboxId,
129
+ flagName: MessageSystemFlag.Flagged,
130
+ operation: "remove",
131
+ });
132
+
133
+ assert.deepEqual(markerStateUpdates, [
134
+ { messageId, flagName: MessageSystemFlag.Flagged, state: "queued" },
135
+ ]);
136
+ });
137
+
138
+ it("does NOT reject flip when the wake-up hint enqueue fails (queue down)", async () => {
139
+ const { service } = createHarness({
140
+ sqsImpl: () => {
141
+ throw Object.assign(new Error(""), {
142
+ name: "AggregateError",
143
+ code: "ECONNREFUSED",
144
+ });
145
+ },
146
+ });
147
+
148
+ await assert.doesNotReject(
149
+ service.flip({
150
+ accountId,
151
+ accountConfigId,
152
+ messageId,
153
+ mailboxId,
154
+ flagName: MessageSystemFlag.Seen,
155
+ operation: "add",
156
+ }),
157
+ );
158
+ });
159
+
160
+ it("leaves the marker in `pending` (never advances to queued) when the hint enqueue fails", async () => {
161
+ const { service, markerStateUpdates } = createHarness({
162
+ sqsImpl: () => {
163
+ throw new Error("ECONNREFUSED");
164
+ },
165
+ });
166
+
167
+ await service.flip({
168
+ accountId,
169
+ accountConfigId,
170
+ messageId,
171
+ mailboxId,
172
+ flagName: MessageSystemFlag.Seen,
173
+ operation: "add",
174
+ });
175
+
176
+ assert.deepEqual(markerStateUpdates, []);
177
+ });
178
+
179
+ it("logs the enqueue failure loudly with an alertable field (not silent)", async () => {
180
+ const { service, logs } = createHarness({
181
+ sqsImpl: () => {
182
+ throw Object.assign(new Error(""), {
183
+ name: "AggregateError",
184
+ code: "ECONNREFUSED",
185
+ });
186
+ },
187
+ });
188
+
189
+ await service.flip({
190
+ accountId,
191
+ accountConfigId,
192
+ messageId,
193
+ mailboxId,
194
+ flagName: MessageSystemFlag.Seen,
195
+ operation: "add",
196
+ });
197
+
198
+ const alerted = logs.error.find(
199
+ (entry) =>
200
+ (entry as { alert?: string }).alert === "flag_push_hint_enqueue_failed",
201
+ );
202
+ assert.ok(
203
+ alerted,
204
+ "expected an alertable flag_push_hint_enqueue_failed log",
205
+ );
206
+ assert.equal((alerted as { errorCode?: string }).errorCode, "ECONNREFUSED");
207
+ });
208
+
209
+ it("does NOT leak an unhandled rejection onto a concurrent caller when the enqueue fails", async () => {
210
+ const leaked: unknown[] = [];
211
+ const onUnhandled = (reason: unknown): void => {
212
+ leaked.push(reason);
213
+ };
214
+ process.on("unhandledRejection", onUnhandled);
215
+
216
+ const { service } = createHarness({
217
+ sqsImpl: () =>
218
+ new Promise((_resolve, reject) => {
219
+ setImmediate(() =>
220
+ reject(
221
+ Object.assign(new Error(""), {
222
+ name: "AggregateError",
223
+ code: "ECONNREFUSED",
224
+ }),
225
+ ),
226
+ );
227
+ }),
228
+ });
229
+
230
+ try {
231
+ void service.flip({
232
+ accountId,
233
+ accountConfigId,
234
+ messageId,
235
+ mailboxId,
236
+ flagName: MessageSystemFlag.Seen,
237
+ operation: "add",
238
+ });
239
+ await new Promise((resolve) => setImmediate(resolve));
240
+ await new Promise((resolve) => setImmediate(resolve));
241
+ await new Promise((resolve) => setImmediate(resolve));
242
+ } finally {
243
+ process.off("unhandledRejection", onUnhandled);
244
+ }
245
+
246
+ assert.equal(leaked.length, 0);
247
+ });
248
+
249
+ it("sets MessageGroupId=accountId for FIFO queue URLs", async () => {
250
+ const { service, mockSqs } = createHarness({
251
+ queueUrl:
252
+ "https://sqs.eu-west-1.amazonaws.com/123456789012/remit-dev-mailboxes.fifo",
253
+ });
254
+
255
+ await service.flip({
256
+ accountId,
257
+ accountConfigId,
258
+ messageId,
259
+ mailboxId,
260
+ flagName: MessageSystemFlag.Seen,
261
+ operation: "add",
262
+ });
263
+
264
+ assert.equal(mockSqs._sent.length, 1);
265
+ const sent = mockSqs._sent[0] as { MessageGroupId?: string };
266
+ assert.equal(sent.MessageGroupId, accountId);
267
+ });
268
+
269
+ it("omits FIFO params for standard queue URLs", async () => {
270
+ const { service, mockSqs } = createHarness({
271
+ queueUrl:
272
+ "https://sqs.eu-west-1.amazonaws.com/123456789012/remit-dev-mailboxes",
273
+ });
274
+
275
+ await service.flip({
276
+ accountId,
277
+ accountConfigId,
278
+ messageId,
279
+ mailboxId,
280
+ flagName: MessageSystemFlag.Seen,
281
+ operation: "add",
282
+ });
283
+
284
+ assert.equal(mockSqs._sent.length, 1);
285
+ const sent = mockSqs._sent[0] as { MessageGroupId?: string };
286
+ assert.equal(sent.MessageGroupId, undefined);
287
+ });
288
+
289
+ it("event body carries only messageId + flagName — never the operation or a UID", async () => {
290
+ const { service, mockSqs } = createHarness();
291
+
292
+ await service.flip({
293
+ accountId,
294
+ accountConfigId,
295
+ messageId,
296
+ mailboxId,
297
+ flagName: MessageSystemFlag.Flagged,
298
+ operation: "remove",
299
+ });
300
+
301
+ const sent = mockSqs._sent[0] as { MessageBody: string };
302
+ const event = JSON.parse(sent.MessageBody);
303
+ assert.equal(event.type, "FLAG_PUSH");
304
+ assert.equal(event.messageId, messageId);
305
+ assert.equal(event.flagName, MessageSystemFlag.Flagged);
306
+ assert.equal(event.accountId, accountId);
307
+ assert.equal(event.accountConfigId, accountConfigId);
308
+ assert.equal("operation" in event, false);
309
+ assert.equal("uid" in event, false);
310
+ });
311
+
312
+ describe("fast-path race: the worker drains and deletes the marker before the queued-state transition runs (review finding on #1292)", () => {
313
+ it("does NOT log the flag_push_hint_enqueue_failed alarm when the marker is already gone", async () => {
314
+ const { service, logs } = createHarness({
315
+ updateStateImpl: () => {
316
+ throw new Error(
317
+ "Cannot update state on a MessageFlagPush that does not exist: msg-1/\\Seen",
318
+ );
319
+ },
320
+ findImpl: () => Promise.resolve(null),
321
+ });
322
+
323
+ await service.flip({
324
+ accountId,
325
+ accountConfigId,
326
+ messageId,
327
+ mailboxId,
328
+ flagName: MessageSystemFlag.Seen,
329
+ operation: "add",
330
+ });
331
+
332
+ assert.equal(
333
+ logs.error.length,
334
+ 0,
335
+ "the enqueue succeeded; a fast-path race must never be logged as flag_push_hint_enqueue_failed",
336
+ );
337
+ assert.ok(
338
+ logs.info.length > 0,
339
+ "the race is still visible as routine info, not silent",
340
+ );
341
+ });
342
+
343
+ it("still logs the flag_push_hint_enqueue_failed alarm when updateState fails for a REAL reason (marker still present)", async () => {
344
+ const { service, logs } = createHarness({
345
+ updateStateImpl: () => {
346
+ throw new Error("ProvisionedThroughputExceededException");
347
+ },
348
+ findImpl: () =>
349
+ Promise.resolve({
350
+ messageId,
351
+ flagName: MessageSystemFlag.Seen,
352
+ state: "pending",
353
+ }),
354
+ });
355
+
356
+ await service.flip({
357
+ accountId,
358
+ accountConfigId,
359
+ messageId,
360
+ mailboxId,
361
+ flagName: MessageSystemFlag.Seen,
362
+ operation: "add",
363
+ });
364
+
365
+ const alerted = logs.error.find(
366
+ (entry) =>
367
+ (entry as { alert?: string }).alert ===
368
+ "flag_push_hint_enqueue_failed",
369
+ );
370
+ assert.ok(
371
+ alerted,
372
+ "a genuine failure behind the race-check must still alarm",
373
+ );
374
+ });
375
+
376
+ it("still logs the flag_push_hint_enqueue_failed alarm when the re-check itself fails (backend genuinely down)", async () => {
377
+ const { service, logs } = createHarness({
378
+ updateStateImpl: () => {
379
+ throw new Error(
380
+ "Cannot update state on a MessageFlagPush that does not exist: msg-1/\\Seen",
381
+ );
382
+ },
383
+ findImpl: () => {
384
+ throw new Error("ECONNREFUSED");
385
+ },
386
+ });
387
+
388
+ await service.flip({
389
+ accountId,
390
+ accountConfigId,
391
+ messageId,
392
+ mailboxId,
393
+ flagName: MessageSystemFlag.Seen,
394
+ operation: "add",
395
+ });
396
+
397
+ const alerted = logs.error.find(
398
+ (entry) =>
399
+ (entry as { alert?: string }).alert ===
400
+ "flag_push_hint_enqueue_failed",
401
+ );
402
+ assert.ok(
403
+ alerted,
404
+ "a re-check failure is a real infra problem — must alarm",
405
+ );
406
+ });
407
+ });
408
+ });
@@ -0,0 +1,198 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { SendMessageCommand, type SQSClient } from "@aws-sdk/client-sqs";
3
+ import type { IMessageFlagPushRepository } from "@remit/data-ports";
4
+ import { createQueueProducer } from "@remit/sqs-client/producer";
5
+
6
+ /**
7
+ * Event the reconciler (imap-worker `handleFlagPush`) drains. Carries ONLY
8
+ * our own message id + the flag field — never a UID and never the desired
9
+ * operation (that lives on the marker, resolved fresh at push time, epic
10
+ * #1281 invariant 1). The event stays valid across any amount of queue delay
11
+ * or a later local flip of the SAME field (which replaces the marker before
12
+ * this event is ever processed).
13
+ */
14
+ export interface FlagPushEvent {
15
+ type: "FLAG_PUSH";
16
+ eventId: string;
17
+ timestamp: number;
18
+ accountId: string;
19
+ accountConfigId: string;
20
+ messageId: string;
21
+ flagName: string;
22
+ }
23
+
24
+ export interface FlagPushLogger {
25
+ info(obj: Record<string, unknown>, msg: string): void;
26
+ error(obj: Record<string, unknown>, msg: string): void;
27
+ }
28
+
29
+ const noopLogger: FlagPushLogger = {
30
+ info: () => {},
31
+ error: () => {},
32
+ };
33
+
34
+ export interface FlagPushConfig {
35
+ markerService: IMessageFlagPushRepository;
36
+ sqsQueueUrl: string;
37
+ sqsEndpoint?: string;
38
+ logger?: FlagPushLogger;
39
+ }
40
+
41
+ export type FlagPushOperationValue = "add" | "remove";
42
+
43
+ /**
44
+ * Writes a pending flag-push marker and best-effort enqueues a wake-up hint
45
+ * (issue #1273, epic #1281). Called AFTER the caller (`FlagQueueService`) has
46
+ * already durably written the local `MessageFlag`/`ThreadMessage` state in
47
+ * the SAME request — the user's intent is durable the moment `flip` writes
48
+ * the marker, before the enqueue even runs.
49
+ *
50
+ * Mirrors `PlacementMoveService` (#1289)'s marker lifecycle, scoped per
51
+ * (`messageId`, `flagName`) instead of per `messageId` — a message can carry
52
+ * an independent pending read-state marker and pending star marker at once
53
+ * (epic FAQ: "a flag flip replaces a pending flag flip", scoped per field).
54
+ * `put` (not `create`) always resets the marker to `pending`, so a later flip
55
+ * of the SAME field simply replaces the earlier marker — "later intent wins
56
+ * locally".
57
+ *
58
+ * Unlike #1289's placement move (where an enqueue failure must propagate,
59
+ * because nothing besides the marker+enqueue sequence has committed the
60
+ * user's intent), the flag flip here is durable the instant the marker write
61
+ * lands — the enqueue is purely a latency optimization so the push does not
62
+ * have to wait for the next periodic sync tick. An enqueue failure is
63
+ * therefore swallowed on purpose: it must never fail the caller's request.
64
+ * It must also never be silent — logged with an `alert`-shaped entry so a
65
+ * string of failures (SQS itself unhealthy) is operationally visible, same
66
+ * convention as the enqueue this replaces (`flag-queue.ts`'s prior
67
+ * `flag_sync_enqueue_failed`).
68
+ */
69
+ export class FlagPushService {
70
+ private markerService: IMessageFlagPushRepository;
71
+ private sqs: SQSClient;
72
+ private queueUrl: string;
73
+ private log: FlagPushLogger;
74
+
75
+ constructor(config: FlagPushConfig) {
76
+ this.markerService = config.markerService;
77
+ this.queueUrl = config.sqsQueueUrl;
78
+ this.log = config.logger ?? noopLogger;
79
+ this.sqs = createQueueProducer({
80
+ queueUrl: config.sqsQueueUrl,
81
+ endpoint: config.sqsEndpoint,
82
+ });
83
+ }
84
+
85
+ flip = async (params: {
86
+ accountId: string;
87
+ accountConfigId: string;
88
+ messageId: string;
89
+ mailboxId: string;
90
+ flagName: string;
91
+ operation: FlagPushOperationValue;
92
+ }): Promise<void> => {
93
+ const {
94
+ accountId,
95
+ accountConfigId,
96
+ messageId,
97
+ mailboxId,
98
+ flagName,
99
+ operation,
100
+ } = params;
101
+
102
+ await this.markerService.put({
103
+ messageId,
104
+ flagName,
105
+ accountId,
106
+ accountConfigId,
107
+ mailboxId,
108
+ operation,
109
+ });
110
+
111
+ await this.enqueueHint({ accountId, accountConfigId, messageId, flagName });
112
+ };
113
+
114
+ private enqueueHint = async (params: {
115
+ accountId: string;
116
+ accountConfigId: string;
117
+ messageId: string;
118
+ flagName: string;
119
+ }): Promise<void> => {
120
+ const event: FlagPushEvent = {
121
+ type: "FLAG_PUSH",
122
+ eventId: randomUUID(),
123
+ timestamp: Date.now(),
124
+ ...params,
125
+ };
126
+
127
+ const useFifo = this.queueUrl.endsWith(".fifo");
128
+
129
+ // The marker persisted in `flip` is the durable record — the queue,
130
+ // per the issue's own words. This send is only a wake-up hint: a
131
+ // queue-down failure must NOT fail the caller's flip (intent is
132
+ // already durable) and must NOT be silent either (standing rule: our
133
+ // infra failing is never a quiet blip). The periodic per-mailbox
134
+ // drain (issue #1273) still finds and pushes the marker regardless.
135
+ await this.sqs
136
+ .send(
137
+ new SendMessageCommand({
138
+ QueueUrl: this.queueUrl,
139
+ MessageBody: JSON.stringify(event),
140
+ ...(useFifo && {
141
+ MessageGroupId: params.accountId,
142
+ MessageDeduplicationId: event.eventId,
143
+ }),
144
+ }),
145
+ )
146
+ .then(async () => {
147
+ // Only advance past `pending` once the hint is CONFIRMED sent — a
148
+ // marker still `pending` is exactly the periodic drain's query
149
+ // target (`sync-messages.ts`'s `drainPendingFlagPushes`), so a
150
+ // failed send below must leave it there, never here.
151
+ await this.markerService
152
+ .updateState(params.messageId, params.flagName, "queued")
153
+ .catch(async (error: unknown) => {
154
+ // Fast-path race (review finding on #1292): the worker can
155
+ // drain AND delete the marker before this transition runs —
156
+ // SQS delivery + processing can outrun this .then(). The
157
+ // enqueue itself already succeeded, so re-check whether the
158
+ // marker still exists before treating this as an infra
159
+ // failure: if it's already gone, the push already completed
160
+ // and this is expected, routine — never the
161
+ // flag_push_hint_enqueue_failed alarm shape. Any OTHER
162
+ // failure (e.g. the re-check itself fails because the
163
+ // backend is genuinely down) still propagates to the outer
164
+ // catch below.
165
+ const stillPending = await this.markerService.find(
166
+ params.messageId,
167
+ params.flagName,
168
+ );
169
+ if (!stillPending) {
170
+ this.log.info(
171
+ { eventId: event.eventId, ...params },
172
+ "Marker already drained before the queued-state transition (fast-path race, harmless)",
173
+ );
174
+ return;
175
+ }
176
+ throw error;
177
+ });
178
+ this.log.info(
179
+ { eventId: event.eventId, ...params },
180
+ "Enqueued FLAG_PUSH wake-up hint",
181
+ );
182
+ })
183
+ .catch((error: unknown) => {
184
+ this.log.error(
185
+ {
186
+ alert: "flag_push_hint_enqueue_failed",
187
+ eventId: event.eventId,
188
+ ...params,
189
+ errorName: (error as { name?: string })?.name,
190
+ errorCode:
191
+ (error as { Code?: string })?.Code ??
192
+ (error as { code?: string })?.code,
193
+ },
194
+ "Failed to enqueue FLAG_PUSH wake-up hint (marker persisted; the periodic sync tick will still drain it)",
195
+ );
196
+ });
197
+ };
198
+ }