@remit/mailbox-service 0.0.85 → 0.0.87

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.
@@ -44,10 +44,12 @@ export const bindsForeignUid = (
44
44
 
45
45
  /**
46
46
  * `in_flight` — a mover is still working on the row, so waiting resolves it.
47
- * `abandoned` — the mover gave up without confirming (`syncStatus: failed`,
48
- * written by every handler's give-up path while `status` stays `moving`, since
49
- * only `updateUid` clears it). Nothing routine settles such a row, so waiting
50
- * on one is a wait that never ends, and the pair stays a lie either way.
47
+ * `abandoned` — the last attempt failed (`syncStatus: failed`) and the row is
48
+ * still `moving`: either a redelivery is pending, or the record dead-lettered
49
+ * and nothing is coming for it. The two are indistinguishable from the row
50
+ * (`message-settlement.ts`), and the pair is a lie either way, so neither is
51
+ * worth spending the settle ceiling on. A mover that exhausts its budget
52
+ * settles the row against IMAP instead of leaving this state behind (#1005).
51
53
  */
52
54
  export type PlacementBinding = "consistent" | "in_flight" | "abandoned";
53
55
 
@@ -1,292 +0,0 @@
1
- /**
2
- * Body-sync's skip guard keys on `bodyStorageKey`, but classification is a
3
- * separate derived field written by the same pass. A message that got its body
4
- * before it got a classifier — or whose classifying pass failed after the body
5
- * landed — is skipped forever and stays `uncategorized` (issue #45).
6
- *
7
- * These tests pin the backfill that closes that gap: it reads the stored body
8
- * rather than IMAP, writes only the classification, and never silently absorbs
9
- * a storage failure.
10
- */
11
-
12
- import assert from "node:assert/strict";
13
- import { describe, it } from "node:test";
14
- import type {
15
- IAddressRepository,
16
- IEnvelopeRepository,
17
- IMessageRepository,
18
- IThreadMessageRepository,
19
- MessageItem,
20
- UpdateMessageInput,
21
- UpdateThreadMessageInput,
22
- } from "@remit/data-ports";
23
- import { NotFoundError } from "@remit/data-ports/errors";
24
- import { MessageCategory } from "@remit/domain-enums";
25
- import type { StorageService } from "@remit/storage-service";
26
- import { BodySyncService } from "./body-sync.js";
27
-
28
- const LINKEDIN_EML = Buffer.from(
29
- [
30
- "From: LinkedIn <messages-noreply@linkedin.com>",
31
- "To: me@example.com",
32
- "Subject: You have a new invitation",
33
- "List-Unsubscribe: <https://www.linkedin.com/e/unsub>",
34
- "Content-Type: text/plain",
35
- "",
36
- "invitation",
37
- ].join("\r\n"),
38
- );
39
-
40
- interface Harness {
41
- service: BodySyncService;
42
- messageUpdates: Array<{ messageId: string; input: UpdateMessageInput }>;
43
- threadUpdates: UpdateThreadMessageInput[];
44
- retrieved: string[];
45
- loggedErrors: Array<Record<string, unknown>>;
46
- }
47
-
48
- type MessageFixture = Partial<MessageItem> & Pick<MessageItem, "messageId">;
49
-
50
- const buildHarness = (
51
- messages: MessageFixture[],
52
- retrieve: (key: string) => Promise<Buffer> = async () => LINKEDIN_EML,
53
- ): Harness => {
54
- const messageUpdates: Array<{
55
- messageId: string;
56
- input: UpdateMessageInput;
57
- }> = [];
58
- const threadUpdates: UpdateThreadMessageInput[] = [];
59
- const retrieved: string[] = [];
60
- const loggedErrors: Array<Record<string, unknown>> = [];
61
-
62
- const byId = new Map(messages.map((m) => [m.messageId, m]));
63
-
64
- const messageService = {
65
- get: async (messageId: string) => {
66
- const message = byId.get(messageId);
67
- if (!message) throw new Error(`no fixture for ${messageId}`);
68
- return { uid: 1, category: MessageCategory.uncategorized, ...message };
69
- },
70
- update: async (messageId: string, input: UpdateMessageInput) => {
71
- messageUpdates.push({ messageId, input });
72
- },
73
- } as unknown as IMessageRepository;
74
-
75
- const threadMessageService = {
76
- findAllByMessageId: async () => [
77
- {
78
- threadMessageId: "tm-1",
79
- sentDate: 1,
80
- mailboxId: "mb-1",
81
- isRead: false,
82
- isDeleted: false,
83
- hasStars: false,
84
- hasAttachment: false,
85
- },
86
- ],
87
- update: async (
88
- _accountConfigId: string,
89
- _threadMessageId: string,
90
- input: UpdateThreadMessageInput,
91
- ) => {
92
- threadUpdates.push(input);
93
- },
94
- } as unknown as IThreadMessageRepository;
95
-
96
- const storageService = {
97
- retrieve: async (key: string) => {
98
- retrieved.push(key);
99
- return retrieve(key);
100
- },
101
- } as unknown as StorageService;
102
-
103
- const service = new BodySyncService(
104
- messageService,
105
- storageService,
106
- threadMessageService,
107
- {
108
- getAddress: async () => {
109
- throw new NotFoundError("Address not found");
110
- },
111
- } as unknown as IAddressRepository,
112
- {} as unknown as IEnvelopeRepository,
113
- {
114
- info: () => {},
115
- error: (obj: Record<string, unknown>) => {
116
- loggedErrors.push(obj);
117
- },
118
- },
119
- );
120
-
121
- return { service, messageUpdates, threadUpdates, retrieved, loggedErrors };
122
- };
123
-
124
- const failingConnection = async () => {
125
- throw new Error("body-sync must not open IMAP to backfill a classification");
126
- };
127
-
128
- const stored = (messageId: string) => ({
129
- messageId,
130
- bodyStorageKey: `s3://bodies/${messageId}`,
131
- category: MessageCategory.uncategorized,
132
- });
133
-
134
- describe("body-sync classification backfill", () => {
135
- it("classifies a skipped message whose body is stored but category is uncategorized", async () => {
136
- const harness = buildHarness([stored("m-1")]);
137
-
138
- const result = await harness.service.syncBodies(
139
- ["m-1"],
140
- "acc-1",
141
- "cfg-1",
142
- "INBOX",
143
- failingConnection,
144
- );
145
-
146
- assert.equal(result.skippedCount, 1);
147
- assert.equal(harness.messageUpdates.length, 1);
148
- assert.equal(
149
- harness.messageUpdates[0].input.category,
150
- MessageCategory.social,
151
- );
152
- });
153
-
154
- it("denormalizes the backfilled category onto the ThreadMessage", async () => {
155
- const harness = buildHarness([stored("m-1")]);
156
-
157
- await harness.service.syncBodies(
158
- ["m-1"],
159
- "acc-1",
160
- "cfg-1",
161
- "INBOX",
162
- failingConnection,
163
- );
164
-
165
- assert.equal(harness.threadUpdates.length, 1);
166
- assert.equal(harness.threadUpdates[0].category, MessageCategory.social);
167
- });
168
-
169
- it("reads the stored body instead of reconnecting to IMAP", async () => {
170
- const harness = buildHarness([stored("m-1")]);
171
-
172
- await harness.service.syncBodies(
173
- ["m-1"],
174
- "acc-1",
175
- "cfg-1",
176
- "INBOX",
177
- failingConnection,
178
- );
179
-
180
- assert.deepEqual(harness.retrieved, ["s3://bodies/m-1"]);
181
- });
182
-
183
- it("classifies a message whose category field is absent entirely", async () => {
184
- // Rows written before the column existed carry no value at all. Reading
185
- // that as "already classified" would strand the oldest mail — exactly
186
- // what this backfill exists to reach.
187
- const harness = buildHarness([
188
- {
189
- messageId: "m-1",
190
- bodyStorageKey: "s3://bodies/m-1",
191
- category: undefined,
192
- },
193
- ]);
194
-
195
- await harness.service.syncBodies(
196
- ["m-1"],
197
- "acc-1",
198
- "cfg-1",
199
- "INBOX",
200
- failingConnection,
201
- );
202
-
203
- assert.equal(harness.messageUpdates.length, 1);
204
- assert.equal(
205
- harness.messageUpdates[0].input.category,
206
- MessageCategory.social,
207
- );
208
- });
209
-
210
- it("leaves an already-classified message untouched", async () => {
211
- const harness = buildHarness([
212
- { ...stored("m-1"), category: MessageCategory.marketing },
213
- ]);
214
-
215
- const result = await harness.service.syncBodies(
216
- ["m-1"],
217
- "acc-1",
218
- "cfg-1",
219
- "INBOX",
220
- failingConnection,
221
- );
222
-
223
- assert.equal(result.skippedCount, 1);
224
- assert.deepEqual(harness.messageUpdates, []);
225
- assert.deepEqual(harness.retrieved, []);
226
- });
227
-
228
- it("requeues the message when its stored body cannot be read", async () => {
229
- // An unreadable body object is an infra fault. Absorbing it would leave
230
- // the message permanently unclassified with nothing to show for it.
231
- const harness = buildHarness([stored("m-1")], async () => {
232
- throw new Error("AccessDenied");
233
- });
234
-
235
- const result = await harness.service.syncBodies(
236
- ["m-1"],
237
- "acc-1",
238
- "cfg-1",
239
- "INBOX",
240
- failingConnection,
241
- );
242
-
243
- assert.deepEqual(result.failedMessageIds, ["m-1"]);
244
- assert.equal(result.skippedCount, 0);
245
- });
246
-
247
- it("logs a backfill failure rather than swallowing it", async () => {
248
- const harness = buildHarness([stored("m-1")], async () => {
249
- throw new Error("AccessDenied");
250
- });
251
-
252
- await harness.service.syncBodies(
253
- ["m-1"],
254
- "acc-1",
255
- "cfg-1",
256
- "INBOX",
257
- failingConnection,
258
- );
259
-
260
- assert.equal(harness.loggedErrors.length, 1);
261
- assert.equal(harness.loggedErrors[0].messageId, "m-1");
262
- });
263
-
264
- it("does not abort the batch when one message's backfill fails", async () => {
265
- // The failure happens in the message-resolution loop, before any body is
266
- // fetched. Letting it escape would strand every other message in the
267
- // batch — including ones that need a genuine IMAP fetch — behind one
268
- // unreadable S3 object.
269
- const harness = buildHarness(
270
- [stored("m-bad"), stored("m-good")],
271
- async (key) => {
272
- if (key.endsWith("m-bad")) throw new Error("AccessDenied");
273
- return LINKEDIN_EML;
274
- },
275
- );
276
-
277
- const result = await harness.service.syncBodies(
278
- ["m-bad", "m-good"],
279
- "acc-1",
280
- "cfg-1",
281
- "INBOX",
282
- failingConnection,
283
- );
284
-
285
- assert.deepEqual(result.failedMessageIds, ["m-bad"]);
286
- assert.equal(result.skippedCount, 1);
287
- assert.deepEqual(
288
- harness.messageUpdates.map((u) => u.messageId),
289
- ["m-good"],
290
- );
291
- });
292
- });