@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.
- package/README.md +147 -0
- package/package.json +65 -0
- package/src/account-credentials.ts +128 -0
- package/src/adversarial-mime.e2e.test.ts +311 -0
- package/src/attribute-mapper.test.ts +78 -0
- package/src/attribute-mapper.ts +147 -0
- package/src/body-part-mapper.property.test.ts +354 -0
- package/src/body-part-mapper.ts +360 -0
- package/src/body-sync-queue.test.ts +124 -0
- package/src/body-sync-queue.ts +131 -0
- package/src/body-sync-terminal.test.ts +265 -0
- package/src/body-sync-terminal.ts +172 -0
- package/src/body-sync.ts +1202 -0
- package/src/connection-factory.ts +102 -0
- package/src/connection-test.ts +208 -0
- package/src/credentials.test.ts +181 -0
- package/src/filters/match.test.ts +181 -0
- package/src/filters/match.ts +143 -0
- package/src/filters/pipeline.ts +243 -0
- package/src/flag-push-terminal.test.ts +156 -0
- package/src/flag-push-terminal.ts +106 -0
- package/src/flag-push.test.ts +408 -0
- package/src/flag-push.ts +198 -0
- package/src/flag-queue.ts +500 -0
- package/src/heuristics/classifyByHeaders.test.ts +578 -0
- package/src/heuristics/classifyByHeaders.ts +369 -0
- package/src/heuristics/classifyPlacement.ts +96 -0
- package/src/heuristics/socialDomains.ts +23 -0
- package/src/heuristics/transactionalDomains.ts +26 -0
- package/src/imapflow-connect-retry.test.ts +108 -0
- package/src/imapflow-connection.e2e.test.ts +355 -0
- package/src/imapflow-connection.integ.test.ts +327 -0
- package/src/imapflow-connection.test.ts +161 -0
- package/src/imapflow-connection.ts +1374 -0
- package/src/index.ts +214 -0
- package/src/mailbox-cursor-rebuild.test.ts +171 -0
- package/src/mailbox-cursor-rebuild.ts +146 -0
- package/src/mailbox-cursor.test.ts +249 -0
- package/src/mailbox-cursor.ts +177 -0
- package/src/mailbox-management.test.ts +103 -0
- package/src/mailbox-management.ts +235 -0
- package/src/mailbox-queue.ts +258 -0
- package/src/mailbox-sync.test.ts +153 -0
- package/src/mailbox-sync.ts +590 -0
- package/src/message-move.ts +825 -0
- package/src/message-parser.ts +26 -0
- package/src/message-sync.ts +1021 -0
- package/src/mime-walker.test.ts +311 -0
- package/src/mime-walker.ts +305 -0
- package/src/outbox-queue.ts +294 -0
- package/src/pass-through-unit-of-work.ts +17 -0
- package/src/placement-move-terminal.test.ts +179 -0
- package/src/placement-move-terminal.ts +104 -0
- package/src/placement-move.ts +224 -0
- package/src/snippet.test.ts +213 -0
- package/src/snippet.ts +152 -0
- package/src/stale-message-reconcile.test.ts +82 -0
- package/src/stale-message-reconcile.ts +55 -0
- package/src/test-helpers/isolated-mailbox.ts +44 -0
- package/src/test-helpers/mailfuzz-connection.ts +27 -0
- package/src/test-helpers/mime-tree-generator.ts +461 -0
- package/src/text/normalizer.test.ts +223 -0
- package/src/text/normalizer.ts +140 -0
- package/src/types/mailparser-augment.d.ts +15 -0
- package/src/types/natural-porter-stemmer.d.ts +50 -0
- package/src/types/stopword.d.ts +69 -0
- package/src/types.ts +349 -0
- package/test/fixtures/mime/01-flat-text-plain.bodyparts.json +8 -0
- package/test/fixtures/mime/01-flat-text-plain.eml +10 -0
- package/test/fixtures/mime/01-flat-text-plain.expected.json +10 -0
- package/test/fixtures/mime/02-flat-text-html.bodyparts.json +8 -0
- package/test/fixtures/mime/02-flat-text-html.eml +10 -0
- package/test/fixtures/mime/02-flat-text-html.expected.json +10 -0
- package/test/fixtures/mime/03-alternative-text-html.bodyparts.json +20 -0
- package/test/fixtures/mime/03-alternative-text-html.eml +19 -0
- package/test/fixtures/mime/03-alternative-text-html.expected.json +16 -0
- package/test/fixtures/mime/04-mixed-html-pdf-attachment.bodyparts.json +22 -0
- package/test/fixtures/mime/04-mixed-html-pdf-attachment.eml +20 -0
- package/test/fixtures/mime/04-mixed-html-pdf-attachment.expected.json +16 -0
- package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.bodyparts.json +22 -0
- package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.eml +21 -0
- package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.expected.json +16 -0
- package/test/fixtures/mime/06-related-html-cid-image.bodyparts.json +23 -0
- package/test/fixtures/mime/06-related-html-cid-image.eml +21 -0
- package/test/fixtures/mime/06-related-html-cid-image.expected.json +16 -0
- package/test/fixtures/mime/07-mixed-alternative-related-attachment.bodyparts.json +55 -0
- package/test/fixtures/mime/07-mixed-alternative-related-attachment.eml +47 -0
- package/test/fixtures/mime/07-mixed-alternative-related-attachment.expected.json +34 -0
- package/test/fixtures/mime/08-two-pdfs-same-filename.bodyparts.json +30 -0
- package/test/fixtures/mime/08-two-pdfs-same-filename.eml +26 -0
- package/test/fixtures/mime/08-two-pdfs-same-filename.expected.json +22 -0
- package/test/fixtures/mime/09-attachment-no-filename.bodyparts.json +21 -0
- package/test/fixtures/mime/09-attachment-no-filename.eml +20 -0
- package/test/fixtures/mime/09-attachment-no-filename.expected.json +16 -0
- package/test/fixtures/mime/10-calendar-invite.bodyparts.json +22 -0
- package/test/fixtures/mime/10-calendar-invite.eml +33 -0
- package/test/fixtures/mime/10-calendar-invite.expected.json +16 -0
- package/test/fixtures/mime/11-quoted-printable-text.bodyparts.json +8 -0
- package/test/fixtures/mime/11-quoted-printable-text.eml +10 -0
- package/test/fixtures/mime/11-quoted-printable-text.expected.json +10 -0
- package/test/fixtures/mime/12-base64-binary.bodyparts.json +22 -0
- package/test/fixtures/mime/12-base64-binary.eml +20 -0
- package/test/fixtures/mime/12-base64-binary.expected.json +16 -0
- package/test/fixtures/mime/13-message-rfc822-forward.bodyparts.json +22 -0
- package/test/fixtures/mime/13-message-rfc822-forward.eml +28 -0
- package/test/fixtures/mime/13-message-rfc822-forward.expected.json +16 -0
- package/test/fixtures/mime/14-deeply-nested-5-levels.bodyparts.json +38 -0
- package/test/fixtures/mime/14-deeply-nested-5-levels.eml +34 -0
- package/test/fixtures/mime/14-deeply-nested-5-levels.expected.json +10 -0
- package/test/fixtures/mime/15-empty-text-part.bodyparts.json +21 -0
- package/test/fixtures/mime/15-empty-text-part.eml +18 -0
- package/test/fixtures/mime/15-empty-text-part.expected.json +16 -0
- package/test/fixtures/mime/16-empty-non-text-part.bodyparts.json +30 -0
- package/test/fixtures/mime/16-empty-non-text-part.eml +24 -0
- package/test/fixtures/mime/16-empty-non-text-part.expected.json +22 -0
- package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +21 -0
- package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +31 -0
- package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +33 -0
- package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +42 -0
- package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +25 -0
- package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +15 -0
- package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +17 -0
- package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +18 -0
- 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
|
+
});
|
package/src/flag-push.ts
ADDED
|
@@ -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
|
+
}
|