@remit/mailbox-service 0.0.34 → 0.0.36
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/package.json +1 -1
- package/src/body-sync-backfill.test.ts +6 -1
- package/src/body-sync-category-denormalize.test.ts +7 -1
- package/src/body-sync-category-override.test.ts +460 -0
- package/src/body-sync-category-writeonce.test.ts +7 -1
- package/src/body-sync-filter.test.ts +4 -0
- package/src/body-sync.ts +98 -66
- package/src/mailbox-sync.test.ts +54 -11
- package/src/mailbox-sync.ts +74 -16
package/package.json
CHANGED
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
UpdateMessageInput,
|
|
21
21
|
UpdateThreadMessageInput,
|
|
22
22
|
} from "@remit/data-ports";
|
|
23
|
+
import { NotFoundError } from "@remit/data-ports/errors";
|
|
23
24
|
import { MessageCategory } from "@remit/domain-enums";
|
|
24
25
|
import type { StorageService } from "@remit/storage-service";
|
|
25
26
|
import { BodySyncService } from "./body-sync.js";
|
|
@@ -103,7 +104,11 @@ const buildHarness = (
|
|
|
103
104
|
messageService,
|
|
104
105
|
storageService,
|
|
105
106
|
threadMessageService,
|
|
106
|
-
{
|
|
107
|
+
{
|
|
108
|
+
getAddress: async () => {
|
|
109
|
+
throw new NotFoundError("Address not found");
|
|
110
|
+
},
|
|
111
|
+
} as unknown as IAddressRepository,
|
|
107
112
|
{} as unknown as IEnvelopeRepository,
|
|
108
113
|
{
|
|
109
114
|
info: () => {},
|
|
@@ -29,6 +29,7 @@ import type {
|
|
|
29
29
|
UpdateMessageInput,
|
|
30
30
|
UpdateThreadMessageInput,
|
|
31
31
|
} from "@remit/data-ports";
|
|
32
|
+
import { NotFoundError } from "@remit/data-ports/errors";
|
|
32
33
|
import { MessageCategory } from "@remit/domain-enums";
|
|
33
34
|
import type { StorageService } from "@remit/storage-service";
|
|
34
35
|
import { BodySyncService } from "./body-sync.js";
|
|
@@ -171,7 +172,12 @@ const buildHarness = (
|
|
|
171
172
|
messageService,
|
|
172
173
|
storageService,
|
|
173
174
|
threadMessageService,
|
|
174
|
-
{
|
|
175
|
+
{
|
|
176
|
+
getAddress: async () => {
|
|
177
|
+
throw new NotFoundError("Address not found");
|
|
178
|
+
},
|
|
179
|
+
incrementInboundCount: async () => {},
|
|
180
|
+
} as unknown as IAddressRepository,
|
|
175
181
|
{ listBodyParts: async () => [] } as unknown as IEnvelopeRepository,
|
|
176
182
|
{ info: () => {}, error: () => {} },
|
|
177
183
|
);
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Address.flags.category` overrides classification at sync time (issue #299,
|
|
3
|
+
* RFC 039 Decision 3, closing item 3 of RFC 039). A user who has told Remit
|
|
4
|
+
* "this sender is actually Personal, not Marketing" via the Reclassify dialog
|
|
5
|
+
* (`PATCH /addresses/{id}`) gets that category on the sender's next message,
|
|
6
|
+
* instead of `classifyByHeaders` re-deriving the same header-driven verdict
|
|
7
|
+
* every time.
|
|
8
|
+
*
|
|
9
|
+
* The override sits behind the same `hasDecidedCategory` write-once gate #378
|
|
10
|
+
* added for `Message.category` (RFC 034 Decision 3.1, RFC 030's message-list
|
|
11
|
+
* GSI sort key depends on it never churning): a message already carrying a
|
|
12
|
+
* real category is never re-touched, override present or not. The two tests
|
|
13
|
+
* under "survives a re-entrant pass" feed a *differing* override to an
|
|
14
|
+
* already-classified message through both shipped re-entry paths — the
|
|
15
|
+
* `NoSuchKey` IMAP re-fetch and `syncBodies(..., force: true)` — so a
|
|
16
|
+
* regression that let the override bypass the guard shows up here, not just
|
|
17
|
+
* in an isolated unit check.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import assert from "node:assert/strict";
|
|
21
|
+
import { Readable } from "node:stream";
|
|
22
|
+
import { describe, it } from "node:test";
|
|
23
|
+
import type {
|
|
24
|
+
AddressItem,
|
|
25
|
+
IAddressRepository,
|
|
26
|
+
IEnvelopeRepository,
|
|
27
|
+
IMessageRepository,
|
|
28
|
+
IThreadMessageRepository,
|
|
29
|
+
MessageItem,
|
|
30
|
+
ThreadMessageItem,
|
|
31
|
+
UpdateMessageInput,
|
|
32
|
+
UpdateThreadMessageInput,
|
|
33
|
+
} from "@remit/data-ports";
|
|
34
|
+
import { NotFoundError } from "@remit/data-ports/errors";
|
|
35
|
+
import { MessageCategory } from "@remit/domain-enums";
|
|
36
|
+
import type { StorageService } from "@remit/storage-service";
|
|
37
|
+
import { BodySyncService } from "./body-sync.js";
|
|
38
|
+
import type { IImapConnection } from "./types.js";
|
|
39
|
+
|
|
40
|
+
const LINKEDIN_EML = Buffer.from(
|
|
41
|
+
[
|
|
42
|
+
"From: LinkedIn <messages-noreply@linkedin.com>",
|
|
43
|
+
"To: me@example.com",
|
|
44
|
+
"Subject: You have a new invitation",
|
|
45
|
+
"List-Unsubscribe: <https://www.linkedin.com/e/unsub>",
|
|
46
|
+
"Content-Type: text/plain",
|
|
47
|
+
"",
|
|
48
|
+
"invitation",
|
|
49
|
+
].join("\r\n"),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
interface ThreadUpdate {
|
|
53
|
+
threadMessageId: string;
|
|
54
|
+
input: UpdateThreadMessageInput;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface Harness {
|
|
58
|
+
service: BodySyncService;
|
|
59
|
+
message: MessageItem;
|
|
60
|
+
rows: ThreadMessageItem[];
|
|
61
|
+
messageUpdates: Array<{ messageId: string; input: UpdateMessageInput }>;
|
|
62
|
+
threadUpdates: ThreadUpdate[];
|
|
63
|
+
getAddressCalls: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const buildHarness = (
|
|
67
|
+
message: Partial<MessageItem> & Pick<MessageItem, "messageId">,
|
|
68
|
+
flags: AddressItem["flags"] | undefined,
|
|
69
|
+
retrieve: () => Promise<Buffer> = async () => {
|
|
70
|
+
throw new Error("no body configured for retrieve()");
|
|
71
|
+
},
|
|
72
|
+
): Harness => {
|
|
73
|
+
const messageUpdates: Array<{
|
|
74
|
+
messageId: string;
|
|
75
|
+
input: UpdateMessageInput;
|
|
76
|
+
}> = [];
|
|
77
|
+
const threadUpdates: ThreadUpdate[] = [];
|
|
78
|
+
let getAddressCalls = 0;
|
|
79
|
+
|
|
80
|
+
const messageRow = {
|
|
81
|
+
uid: 1,
|
|
82
|
+
mailboxId: "mb-1",
|
|
83
|
+
category: MessageCategory.uncategorized,
|
|
84
|
+
...message,
|
|
85
|
+
} as unknown as MessageItem;
|
|
86
|
+
|
|
87
|
+
const rows: ThreadMessageItem[] = [
|
|
88
|
+
{
|
|
89
|
+
threadMessageId: "tm-1",
|
|
90
|
+
messageId: message.messageId,
|
|
91
|
+
mailboxId: "mb-1",
|
|
92
|
+
sentDate: 1,
|
|
93
|
+
isRead: false,
|
|
94
|
+
isDeleted: false,
|
|
95
|
+
hasStars: false,
|
|
96
|
+
hasAttachment: false,
|
|
97
|
+
category: messageRow.category,
|
|
98
|
+
} as unknown as ThreadMessageItem,
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
const messageService = {
|
|
102
|
+
get: async () => messageRow,
|
|
103
|
+
update: async (messageId: string, input: UpdateMessageInput) => {
|
|
104
|
+
messageUpdates.push({ messageId, input });
|
|
105
|
+
Object.assign(messageRow, input);
|
|
106
|
+
},
|
|
107
|
+
} as unknown as IMessageRepository;
|
|
108
|
+
|
|
109
|
+
const threadMessageService = {
|
|
110
|
+
findAllByMessageId: async () => rows,
|
|
111
|
+
update: async (
|
|
112
|
+
_accountConfigId: string,
|
|
113
|
+
threadMessageId: string,
|
|
114
|
+
input: UpdateThreadMessageInput,
|
|
115
|
+
) => {
|
|
116
|
+
threadUpdates.push({ threadMessageId, input });
|
|
117
|
+
const row = rows.find((r) => r.threadMessageId === threadMessageId);
|
|
118
|
+
if (row) Object.assign(row, input);
|
|
119
|
+
},
|
|
120
|
+
} as unknown as IThreadMessageRepository;
|
|
121
|
+
|
|
122
|
+
const storageService = {
|
|
123
|
+
retrieve,
|
|
124
|
+
storeMessageBody: async () => ({ uri: `s3://bodies/${message.messageId}` }),
|
|
125
|
+
storeMessageBodyStream: async () => ({
|
|
126
|
+
uri: `s3://bodies/${message.messageId}`,
|
|
127
|
+
}),
|
|
128
|
+
storeParsedBody: async () => {},
|
|
129
|
+
listBodyParts: async () => [],
|
|
130
|
+
} as unknown as StorageService;
|
|
131
|
+
|
|
132
|
+
const addressService = {
|
|
133
|
+
getAddress: async () => {
|
|
134
|
+
getAddressCalls++;
|
|
135
|
+
if (flags === undefined) {
|
|
136
|
+
throw new NotFoundError("Address not found");
|
|
137
|
+
}
|
|
138
|
+
return { flags } as unknown as AddressItem;
|
|
139
|
+
},
|
|
140
|
+
incrementInboundCount: async () => {},
|
|
141
|
+
} as unknown as IAddressRepository;
|
|
142
|
+
|
|
143
|
+
const envelopeService = {
|
|
144
|
+
listBodyParts: async () => [],
|
|
145
|
+
} as unknown as IEnvelopeRepository;
|
|
146
|
+
|
|
147
|
+
const service = new BodySyncService(
|
|
148
|
+
messageService,
|
|
149
|
+
storageService,
|
|
150
|
+
threadMessageService,
|
|
151
|
+
addressService,
|
|
152
|
+
envelopeService,
|
|
153
|
+
{ info: () => {}, error: () => {} },
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
service,
|
|
158
|
+
message: messageRow,
|
|
159
|
+
rows,
|
|
160
|
+
messageUpdates,
|
|
161
|
+
threadUpdates,
|
|
162
|
+
get getAddressCalls() {
|
|
163
|
+
return getAddressCalls;
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const noSuchKeyError = () =>
|
|
169
|
+
Object.assign(new Error("missing"), { name: "NoSuchKey" });
|
|
170
|
+
|
|
171
|
+
const overrideFlags = (
|
|
172
|
+
category: (typeof MessageCategory)[keyof typeof MessageCategory],
|
|
173
|
+
): AddressItem["flags"] =>
|
|
174
|
+
({
|
|
175
|
+
category: { value: category, setAt: 1_000 },
|
|
176
|
+
}) as unknown as AddressItem["flags"];
|
|
177
|
+
|
|
178
|
+
describe("Address.flags.category overrides classification at sync time (issue #299)", () => {
|
|
179
|
+
it("classifies a new message by the sender's override, not the header-derived category", async () => {
|
|
180
|
+
const harness = buildHarness(
|
|
181
|
+
{ messageId: "m-1", category: MessageCategory.uncategorized },
|
|
182
|
+
overrideFlags(MessageCategory.personal),
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const connection = {
|
|
186
|
+
openBox: async () => {},
|
|
187
|
+
fetchMessageBody: async () => LINKEDIN_EML,
|
|
188
|
+
} as unknown as IImapConnection;
|
|
189
|
+
|
|
190
|
+
await harness.service.fetchAndGetBody(
|
|
191
|
+
"m-1",
|
|
192
|
+
"acc-1",
|
|
193
|
+
"cfg-1",
|
|
194
|
+
"INBOX",
|
|
195
|
+
async () => connection,
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
assert.equal(harness.messageUpdates.length, 1);
|
|
199
|
+
assert.equal(
|
|
200
|
+
harness.messageUpdates[0].input.category,
|
|
201
|
+
MessageCategory.personal,
|
|
202
|
+
);
|
|
203
|
+
assert.equal(harness.message.category, MessageCategory.personal);
|
|
204
|
+
// No placementConfig is wired in this harness, so the override's own
|
|
205
|
+
// Address read is the only one classification makes per message.
|
|
206
|
+
assert.equal(harness.getAddressCalls, 1);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("denormalizes the override onto the ThreadMessage, matching the Message row", async () => {
|
|
210
|
+
const harness = buildHarness(
|
|
211
|
+
{ messageId: "m-1", category: MessageCategory.uncategorized },
|
|
212
|
+
overrideFlags(MessageCategory.personal),
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
const connection = {
|
|
216
|
+
openBox: async () => {},
|
|
217
|
+
fetchMessageBody: async () => LINKEDIN_EML,
|
|
218
|
+
} as unknown as IImapConnection;
|
|
219
|
+
|
|
220
|
+
await harness.service.fetchAndGetBody(
|
|
221
|
+
"m-1",
|
|
222
|
+
"acc-1",
|
|
223
|
+
"cfg-1",
|
|
224
|
+
"INBOX",
|
|
225
|
+
async () => connection,
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
assert.equal(harness.rows[0].category, MessageCategory.personal);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("classifies by headers as usual when the sender has no category override", async () => {
|
|
232
|
+
const harness = buildHarness(
|
|
233
|
+
{ messageId: "m-1", category: MessageCategory.uncategorized },
|
|
234
|
+
undefined,
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
const connection = {
|
|
238
|
+
openBox: async () => {},
|
|
239
|
+
fetchMessageBody: async () => LINKEDIN_EML,
|
|
240
|
+
} as unknown as IImapConnection;
|
|
241
|
+
|
|
242
|
+
await harness.service.fetchAndGetBody(
|
|
243
|
+
"m-1",
|
|
244
|
+
"acc-1",
|
|
245
|
+
"cfg-1",
|
|
246
|
+
"INBOX",
|
|
247
|
+
async () => connection,
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
assert.equal(
|
|
251
|
+
harness.messageUpdates[0].input.category,
|
|
252
|
+
MessageCategory.social,
|
|
253
|
+
);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("classifies by headers as usual when the sender has flags but no category entry", async () => {
|
|
257
|
+
const harness = buildHarness(
|
|
258
|
+
{ messageId: "m-1", category: MessageCategory.uncategorized },
|
|
259
|
+
{ vip: { value: true, setAt: 1 } } as unknown as AddressItem["flags"],
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const connection = {
|
|
263
|
+
openBox: async () => {},
|
|
264
|
+
fetchMessageBody: async () => LINKEDIN_EML,
|
|
265
|
+
} as unknown as IImapConnection;
|
|
266
|
+
|
|
267
|
+
await harness.service.fetchAndGetBody(
|
|
268
|
+
"m-1",
|
|
269
|
+
"acc-1",
|
|
270
|
+
"cfg-1",
|
|
271
|
+
"INBOX",
|
|
272
|
+
async () => connection,
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
assert.equal(
|
|
276
|
+
harness.messageUpdates[0].input.category,
|
|
277
|
+
MessageCategory.social,
|
|
278
|
+
);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("backfillClassification picks up the same override for a body stored before classification existed", async () => {
|
|
282
|
+
const harness = buildHarness(
|
|
283
|
+
{
|
|
284
|
+
messageId: "m-1",
|
|
285
|
+
bodyStorageKey: "s3://bodies/m-1",
|
|
286
|
+
category: MessageCategory.uncategorized,
|
|
287
|
+
},
|
|
288
|
+
overrideFlags(MessageCategory.transactional),
|
|
289
|
+
async () => LINKEDIN_EML,
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
const result = await harness.service.syncBodies(
|
|
293
|
+
["m-1"],
|
|
294
|
+
"acc-1",
|
|
295
|
+
"cfg-1",
|
|
296
|
+
"INBOX",
|
|
297
|
+
async () => {
|
|
298
|
+
throw new Error("backfill must not open IMAP");
|
|
299
|
+
},
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
assert.equal(result.skippedCount, 1);
|
|
303
|
+
assert.equal(harness.messageUpdates.length, 1);
|
|
304
|
+
assert.equal(
|
|
305
|
+
harness.messageUpdates[0].input.category,
|
|
306
|
+
MessageCategory.transactional,
|
|
307
|
+
);
|
|
308
|
+
assert.equal(harness.rows[0].category, MessageCategory.transactional);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
describe("survives a re-entrant pass with a differing override already set", () => {
|
|
312
|
+
it("keeps the already-decided category through the NoSuchKey IMAP re-fetch", async () => {
|
|
313
|
+
const harness = buildHarness(
|
|
314
|
+
{
|
|
315
|
+
messageId: "m-1",
|
|
316
|
+
bodyStorageKey: "s3://bodies/m-1",
|
|
317
|
+
category: MessageCategory.marketing,
|
|
318
|
+
},
|
|
319
|
+
overrideFlags(MessageCategory.personal),
|
|
320
|
+
async () => {
|
|
321
|
+
throw noSuchKeyError();
|
|
322
|
+
},
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
const connection = {
|
|
326
|
+
openBox: async () => {},
|
|
327
|
+
fetchMessageBody: async () => LINKEDIN_EML,
|
|
328
|
+
} as unknown as IImapConnection;
|
|
329
|
+
|
|
330
|
+
await harness.service.fetchAndGetBody(
|
|
331
|
+
"m-1",
|
|
332
|
+
"acc-1",
|
|
333
|
+
"cfg-1",
|
|
334
|
+
"INBOX",
|
|
335
|
+
async () => connection,
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
assert.equal(harness.messageUpdates.length, 1);
|
|
339
|
+
assert.equal(
|
|
340
|
+
harness.messageUpdates[0].input.category,
|
|
341
|
+
MessageCategory.marketing,
|
|
342
|
+
);
|
|
343
|
+
assert.equal(harness.message.category, MessageCategory.marketing);
|
|
344
|
+
assert.equal(harness.rows[0].category, MessageCategory.marketing);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("keeps the already-decided category when syncBodies re-fetches with force", async () => {
|
|
348
|
+
const harness = buildHarness(
|
|
349
|
+
{
|
|
350
|
+
messageId: "m-1",
|
|
351
|
+
bodyStorageKey: "s3://bodies/m-1",
|
|
352
|
+
category: MessageCategory.marketing,
|
|
353
|
+
},
|
|
354
|
+
overrideFlags(MessageCategory.personal),
|
|
355
|
+
async () => {
|
|
356
|
+
throw new Error("force path must not retrieve from storage");
|
|
357
|
+
},
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
const connection = {
|
|
361
|
+
openBox: async () => {},
|
|
362
|
+
async *fetchMessageBodies(uids: number[]) {
|
|
363
|
+
for (const uid of uids) {
|
|
364
|
+
yield { uid, source: Readable.from([LINKEDIN_EML]) };
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
} as unknown as IImapConnection;
|
|
368
|
+
|
|
369
|
+
const result = await harness.service.syncBodies(
|
|
370
|
+
["m-1"],
|
|
371
|
+
"acc-1",
|
|
372
|
+
"cfg-1",
|
|
373
|
+
"INBOX",
|
|
374
|
+
async () => connection,
|
|
375
|
+
true,
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
assert.deepEqual(result.syncedMessageIds, ["m-1"]);
|
|
379
|
+
assert.equal(harness.messageUpdates.length, 1);
|
|
380
|
+
assert.equal(
|
|
381
|
+
harness.messageUpdates[0].input.category,
|
|
382
|
+
MessageCategory.marketing,
|
|
383
|
+
);
|
|
384
|
+
assert.equal(harness.message.category, MessageCategory.marketing);
|
|
385
|
+
assert.equal(harness.rows[0].category, MessageCategory.marketing);
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("propagates a non-NotFound Address lookup failure instead of silently classifying by headers", async () => {
|
|
390
|
+
const message = {
|
|
391
|
+
messageId: "m-1",
|
|
392
|
+
category: MessageCategory.uncategorized,
|
|
393
|
+
};
|
|
394
|
+
const messageRow = { uid: 1, mailboxId: "mb-1", ...message } as MessageItem;
|
|
395
|
+
|
|
396
|
+
const messageService = {
|
|
397
|
+
get: async () => messageRow,
|
|
398
|
+
update: async () => {},
|
|
399
|
+
} as unknown as IMessageRepository;
|
|
400
|
+
|
|
401
|
+
const threadMessageService = {
|
|
402
|
+
findAllByMessageId: async () => [
|
|
403
|
+
{
|
|
404
|
+
threadMessageId: "tm-1",
|
|
405
|
+
mailboxId: "mb-1",
|
|
406
|
+
sentDate: 1,
|
|
407
|
+
isRead: false,
|
|
408
|
+
isDeleted: false,
|
|
409
|
+
hasStars: false,
|
|
410
|
+
hasAttachment: false,
|
|
411
|
+
},
|
|
412
|
+
],
|
|
413
|
+
update: async () => {},
|
|
414
|
+
} as unknown as IThreadMessageRepository;
|
|
415
|
+
|
|
416
|
+
const storageService = {
|
|
417
|
+
storeMessageBody: async () => ({ uri: "s3://bodies/m-1" }),
|
|
418
|
+
storeParsedBody: async () => {},
|
|
419
|
+
listBodyParts: async () => [],
|
|
420
|
+
} as unknown as StorageService;
|
|
421
|
+
|
|
422
|
+
const addressService = {
|
|
423
|
+
getAddress: async () => {
|
|
424
|
+
const error = new Error("ProvisionedThroughputExceededException");
|
|
425
|
+
error.name = "ProvisionedThroughputExceededException";
|
|
426
|
+
throw error;
|
|
427
|
+
},
|
|
428
|
+
incrementInboundCount: async () => {},
|
|
429
|
+
} as unknown as IAddressRepository;
|
|
430
|
+
|
|
431
|
+
const envelopeService = {
|
|
432
|
+
listBodyParts: async () => [],
|
|
433
|
+
} as unknown as IEnvelopeRepository;
|
|
434
|
+
|
|
435
|
+
const service = new BodySyncService(
|
|
436
|
+
messageService,
|
|
437
|
+
storageService,
|
|
438
|
+
threadMessageService,
|
|
439
|
+
addressService,
|
|
440
|
+
envelopeService,
|
|
441
|
+
{ info: () => {}, error: () => {} },
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
const connection = {
|
|
445
|
+
openBox: async () => {},
|
|
446
|
+
fetchMessageBody: async () => LINKEDIN_EML,
|
|
447
|
+
} as unknown as IImapConnection;
|
|
448
|
+
|
|
449
|
+
await assert.rejects(
|
|
450
|
+
service.fetchAndGetBody(
|
|
451
|
+
"m-1",
|
|
452
|
+
"acc-1",
|
|
453
|
+
"cfg-1",
|
|
454
|
+
"INBOX",
|
|
455
|
+
async () => connection,
|
|
456
|
+
),
|
|
457
|
+
/ProvisionedThroughputExceededException/,
|
|
458
|
+
);
|
|
459
|
+
});
|
|
460
|
+
});
|
|
@@ -24,6 +24,7 @@ import type {
|
|
|
24
24
|
MessageItem,
|
|
25
25
|
UpdateMessageInput,
|
|
26
26
|
} from "@remit/data-ports";
|
|
27
|
+
import { NotFoundError } from "@remit/data-ports/errors";
|
|
27
28
|
import { MessageCategory } from "@remit/domain-enums";
|
|
28
29
|
import type { StorageService } from "@remit/storage-service";
|
|
29
30
|
import { BodySyncService } from "./body-sync.js";
|
|
@@ -111,7 +112,12 @@ const buildHarness = (
|
|
|
111
112
|
messageService,
|
|
112
113
|
storageService,
|
|
113
114
|
threadMessageService,
|
|
114
|
-
{
|
|
115
|
+
{
|
|
116
|
+
getAddress: async () => {
|
|
117
|
+
throw new NotFoundError("Address not found");
|
|
118
|
+
},
|
|
119
|
+
incrementInboundCount: async () => {},
|
|
120
|
+
} as unknown as IAddressRepository,
|
|
115
121
|
{ listBodyParts: async () => [] } as unknown as IEnvelopeRepository,
|
|
116
122
|
{ info: () => {}, error: () => {} },
|
|
117
123
|
);
|
|
@@ -28,6 +28,7 @@ import type {
|
|
|
28
28
|
MessageItem,
|
|
29
29
|
UpdateMessageInput,
|
|
30
30
|
} from "@remit/data-ports";
|
|
31
|
+
import { NotFoundError } from "@remit/data-ports/errors";
|
|
31
32
|
import {
|
|
32
33
|
FilterClauseField,
|
|
33
34
|
FilterMatchOperator,
|
|
@@ -123,6 +124,9 @@ const buildHarness = (
|
|
|
123
124
|
} as unknown as StorageService;
|
|
124
125
|
|
|
125
126
|
const addressService = {
|
|
127
|
+
getAddress: async () => {
|
|
128
|
+
throw new NotFoundError("Address not found");
|
|
129
|
+
},
|
|
126
130
|
incrementInboundCount: async () => {},
|
|
127
131
|
} as unknown as IAddressRepository;
|
|
128
132
|
|
package/src/body-sync.ts
CHANGED
|
@@ -705,14 +705,22 @@ export class BodySyncService {
|
|
|
705
705
|
body: Buffer,
|
|
706
706
|
bodyRef: { uri: string },
|
|
707
707
|
): Promise<ParsedMail> {
|
|
708
|
-
//
|
|
709
|
-
//
|
|
710
|
-
//
|
|
711
|
-
|
|
708
|
+
// The one operation on this path that can fail because of how the message
|
|
709
|
+
// is built. Its own try block lives in `parseMessageBody`, so this frame —
|
|
710
|
+
// which also wraps the S3 body write, the parsed-body cache, the placement
|
|
711
|
+
// move, the label writes and the counter update — can tell the two apart
|
|
712
|
+
// and quarantine only this one (issue #72). The ThreadMessage write these
|
|
713
|
+
// feed happens later, alongside the Message write (see below), once the
|
|
714
|
+
// write-once category is decided.
|
|
715
|
+
const parsed = await parseMessageBody(body);
|
|
716
|
+
const snippet = extractSnippet(parsed);
|
|
717
|
+
const listId = extractListId(parsed);
|
|
712
718
|
|
|
713
|
-
// Compute the header classification once
|
|
714
|
-
//
|
|
715
|
-
|
|
719
|
+
// Compute the header classification once, folding in the sender's
|
|
720
|
+
// `flags.category` override when one applies (issue #299). The derived
|
|
721
|
+
// fields are folded into the single Message update below — they are NOT
|
|
722
|
+
// written here.
|
|
723
|
+
const classification = await this.classifyMessage(accountConfigId, parsed);
|
|
716
724
|
|
|
717
725
|
// Decide the placement move from the in-memory classification before the
|
|
718
726
|
// write, so its `movedByRemit` flag and audit verdict join the same
|
|
@@ -855,13 +863,29 @@ export class BodySyncService {
|
|
|
855
863
|
// fallback in `fetchAndGetBody` and `syncBodies(..., force: true)` — so a
|
|
856
864
|
// real, previously-decided category is carried forward unchanged instead
|
|
857
865
|
// of the just-recomputed one, the same rule `backfillClassification` uses.
|
|
866
|
+
// This also protects a `flags.category` override (issue #299): without
|
|
867
|
+
// this guard a re-entrant pass would let a *later* override silently
|
|
868
|
+
// rewrite a category already decided on an earlier message, which is
|
|
869
|
+
// exactly the churn RFC 030's GSI-safety argument forbids.
|
|
858
870
|
const existingMessage = await this.messageService.get(messageId);
|
|
871
|
+
const finalCategory = hasDecidedCategory(existingMessage.category)
|
|
872
|
+
? existingMessage.category
|
|
873
|
+
: classification.category;
|
|
874
|
+
|
|
875
|
+
// Thread-list denormalization, using the same write-once category as the
|
|
876
|
+
// Message update below — the two rows must never disagree on category.
|
|
877
|
+
await this.denormalizeCategory(
|
|
878
|
+
accountConfigId,
|
|
879
|
+
messageId,
|
|
880
|
+
finalCategory,
|
|
881
|
+
snippet,
|
|
882
|
+
listId,
|
|
883
|
+
);
|
|
884
|
+
|
|
859
885
|
const update: UpdateMessageInput = {
|
|
860
886
|
bodyStorageKey: bodyRef.uri,
|
|
861
887
|
...classification,
|
|
862
|
-
category:
|
|
863
|
-
? existingMessage.category
|
|
864
|
-
: classification.category,
|
|
888
|
+
category: finalCategory,
|
|
865
889
|
...(moved ? { movedByRemit: true } : {}),
|
|
866
890
|
...(resolved.verdict ? { placementVerdict: resolved.verdict } : {}),
|
|
867
891
|
...(filterMove ? { filterMove } : {}),
|
|
@@ -1017,7 +1041,7 @@ export class BodySyncService {
|
|
|
1017
1041
|
|
|
1018
1042
|
const body = await this.storageService.retrieve(message.bodyStorageKey);
|
|
1019
1043
|
const parsed = await parseMessageBody(body);
|
|
1020
|
-
const classification = this.classifyMessage(parsed);
|
|
1044
|
+
const classification = await this.classifyMessage(accountConfigId, parsed);
|
|
1021
1045
|
|
|
1022
1046
|
// Same order as {@link applyPostStoreSteps}, for the same reason: the
|
|
1023
1047
|
// signal the skip guard reads is written last. `message.category` is that
|
|
@@ -1025,12 +1049,13 @@ export class BodySyncService {
|
|
|
1025
1049
|
// the requeued retry redoes both. Writing the Message first strands the
|
|
1026
1050
|
// denormalized row at `uncategorized` forever — the guard is satisfied and
|
|
1027
1051
|
// the retry returns early (issue #320).
|
|
1028
|
-
// The same three denormalized fields
|
|
1029
|
-
//
|
|
1030
|
-
// `bodyStorageKey` and a decided category from its source, so it
|
|
1031
|
-
// neither that path nor this one's classification — but nothing
|
|
1032
|
-
// writes `listId`, so leaving it out here made a copy's
|
|
1033
|
-
// permanently NULL. Both are derived from the same bytes
|
|
1052
|
+
// The same three denormalized fields the full body-store path writes (see
|
|
1053
|
+
// `applyPostStoreSteps`), not just the category. A copied message
|
|
1054
|
+
// inherits `bodyStorageKey` and a decided category from its source, so it
|
|
1055
|
+
// reaches neither that path nor this one's classification — but nothing
|
|
1056
|
+
// else ever writes `listId`, so leaving it out here made a copy's
|
|
1057
|
+
// `list_id` permanently NULL. Both are derived from the same bytes
|
|
1058
|
+
// already in hand.
|
|
1034
1059
|
await this.denormalizeCategory(
|
|
1035
1060
|
accountConfigId,
|
|
1036
1061
|
message.messageId,
|
|
@@ -1047,21 +1072,39 @@ export class BodySyncService {
|
|
|
1047
1072
|
}
|
|
1048
1073
|
|
|
1049
1074
|
/**
|
|
1050
|
-
*
|
|
1051
|
-
*
|
|
1052
|
-
*
|
|
1053
|
-
*
|
|
1075
|
+
* Header classification, with the sender's `Address.flags.category`
|
|
1076
|
+
* override (issue #299, RFC 039 Decision 3) substituted for the
|
|
1077
|
+
* header-derived category when one is set. Returns the subset of the
|
|
1078
|
+
* Message update that carries the derived fields; the caller folds it into
|
|
1079
|
+
* a single UpdateItem alongside `bodyStorageKey`. Optional signals are
|
|
1080
|
+
* omitted when absent so we never overwrite an existing value with
|
|
1081
|
+
* `undefined`.
|
|
1082
|
+
*
|
|
1083
|
+
* The override wins outright rather than blending with the heuristic — RFC
|
|
1084
|
+
* 039 Decision 3 treats a direct reclassification as final, the same as
|
|
1085
|
+
* `flags.blocked`/`vip` already override placement. Both callers
|
|
1086
|
+
* (`applyPostStoreSteps`, `backfillClassification`) already gate on
|
|
1087
|
+
* `hasDecidedCategory` before this result reaches a write, so a message
|
|
1088
|
+
* that already carries a real category is never re-touched regardless of
|
|
1089
|
+
* what this returns.
|
|
1054
1090
|
*/
|
|
1055
|
-
private classifyMessage(
|
|
1091
|
+
private async classifyMessage(
|
|
1092
|
+
accountConfigId: string,
|
|
1056
1093
|
parsed: ParsedMail,
|
|
1057
|
-
): UpdateMessageInput & { category: ThreadMessageCategory } {
|
|
1058
|
-
const
|
|
1094
|
+
): Promise<UpdateMessageInput & { category: ThreadMessageCategory }> {
|
|
1095
|
+
const headerCategory = classifyByHeaders(parsed);
|
|
1059
1096
|
const authenticity = extractAuthenticity(parsed);
|
|
1060
1097
|
const authResult = extractAuthResult(parsed);
|
|
1061
1098
|
const providerSpam = extractProviderSpam(parsed);
|
|
1062
1099
|
const hasListUnsubscribe = extractHasListUnsubscribe(parsed);
|
|
1100
|
+
|
|
1101
|
+
const fromEmail = extractPrimaryFromEmail(parsed);
|
|
1102
|
+
const categoryOverride = fromEmail
|
|
1103
|
+
? await this.resolveCategoryOverride(accountConfigId, fromEmail)
|
|
1104
|
+
: undefined;
|
|
1105
|
+
|
|
1063
1106
|
return {
|
|
1064
|
-
category,
|
|
1107
|
+
category: categoryOverride ?? headerCategory,
|
|
1065
1108
|
hasListUnsubscribe,
|
|
1066
1109
|
...(authenticity !== null ? { authenticity } : {}),
|
|
1067
1110
|
...(authResult !== null ? { authResult } : {}),
|
|
@@ -1069,6 +1112,36 @@ export class BodySyncService {
|
|
|
1069
1112
|
};
|
|
1070
1113
|
}
|
|
1071
1114
|
|
|
1115
|
+
/**
|
|
1116
|
+
* The one-sided half of `Address.flags.category` (issue #299, RFC 039
|
|
1117
|
+
* Decision 3): a real value here overrides `classifyByHeaders` outright.
|
|
1118
|
+
* A second, separate `Address` read from `deriveSenderPlacementSignals`'s
|
|
1119
|
+
* (issue #300) — sharing one fetch was not practical, because the two have
|
|
1120
|
+
* different failure contracts: placement's read is best-effort (a failure
|
|
1121
|
+
* is caught and logged, never failing the sync), while this one feeds the
|
|
1122
|
+
* write-once `Message.category` at the moment it is decided, where a
|
|
1123
|
+
* masked infra failure would silently classify by headers when the user
|
|
1124
|
+
* asked for something else. Only a genuinely-absent Address is "no
|
|
1125
|
+
* override" — any other failure (throttle, infra) propagates, matching the
|
|
1126
|
+
* existing `deriveSenderPlacementSignals` convention.
|
|
1127
|
+
*/
|
|
1128
|
+
private async resolveCategoryOverride(
|
|
1129
|
+
accountConfigId: string,
|
|
1130
|
+
fromEmail: string,
|
|
1131
|
+
): Promise<ThreadMessageCategory | undefined> {
|
|
1132
|
+
try {
|
|
1133
|
+
const addressId = deriveAddressId(accountConfigId, fromEmail);
|
|
1134
|
+
const address = await this.addressService.getAddress(
|
|
1135
|
+
accountConfigId,
|
|
1136
|
+
addressId,
|
|
1137
|
+
);
|
|
1138
|
+
return address.flags?.category?.value;
|
|
1139
|
+
} catch (err) {
|
|
1140
|
+
if (!(err instanceof NotFoundError)) throw err;
|
|
1141
|
+
return undefined;
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1072
1145
|
private async incrementInboundCount(
|
|
1073
1146
|
messageId: string,
|
|
1074
1147
|
accountConfigId: string,
|
|
@@ -1682,47 +1755,6 @@ export class BodySyncService {
|
|
|
1682
1755
|
}
|
|
1683
1756
|
}
|
|
1684
1757
|
|
|
1685
|
-
/**
|
|
1686
|
-
* Extract snippet, header category and the normalized `List-Id` from the body
|
|
1687
|
-
* and denormalize them onto the ThreadMessage. `category` mirrors the Message:
|
|
1688
|
-
* created as `uncategorized` at metadata-sync and set to the classified value
|
|
1689
|
-
* here; `listId` is written so the back-apply corpus projection can match a
|
|
1690
|
-
* `ListId` clause vector-free, off the same row the list/search path reads.
|
|
1691
|
-
* Returns the parsed mail so callers can reuse it (e.g., to write the
|
|
1692
|
-
* parsed-body cache) without paying for mailparser twice.
|
|
1693
|
-
*/
|
|
1694
|
-
private async updateSnippets(
|
|
1695
|
-
messageId: string,
|
|
1696
|
-
accountConfigId: string,
|
|
1697
|
-
body: Buffer,
|
|
1698
|
-
): Promise<ParsedMail> {
|
|
1699
|
-
// The one operation on this path that can fail because of how the message
|
|
1700
|
-
// is built. Its own try block lives in `parseMessageBody`, so the caller's
|
|
1701
|
-
// frame — which also wraps the S3 body write, the parsed-body cache, the
|
|
1702
|
-
// placement move, the label writes and the counter update — can tell the
|
|
1703
|
-
// two apart and quarantine only this one (issue #72).
|
|
1704
|
-
const parsed = await parseMessageBody(body);
|
|
1705
|
-
|
|
1706
|
-
const snippet = extractSnippet(parsed);
|
|
1707
|
-
const category = classifyByHeaders(parsed);
|
|
1708
|
-
const listId = extractListId(parsed);
|
|
1709
|
-
|
|
1710
|
-
await this.denormalizeCategory(
|
|
1711
|
-
accountConfigId,
|
|
1712
|
-
messageId,
|
|
1713
|
-
category,
|
|
1714
|
-
snippet,
|
|
1715
|
-
listId,
|
|
1716
|
-
);
|
|
1717
|
-
|
|
1718
|
-
this.log.debug?.(
|
|
1719
|
-
{ messageId, category, snippetLength: snippet?.length ?? 0 },
|
|
1720
|
-
"ThreadMessage snippet + category updated",
|
|
1721
|
-
);
|
|
1722
|
-
|
|
1723
|
-
return parsed;
|
|
1724
|
-
}
|
|
1725
|
-
|
|
1726
1758
|
/**
|
|
1727
1759
|
* Write the denormalized `category` (and optionally the snippet and the
|
|
1728
1760
|
* normalized `List-Id`) onto EVERY ThreadMessage row the message has — the
|
package/src/mailbox-sync.test.ts
CHANGED
|
@@ -11,7 +11,10 @@ import {
|
|
|
11
11
|
MailboxSyncStatus,
|
|
12
12
|
} from "@remit/domain-enums";
|
|
13
13
|
import { parseImapAttributes } from "./attribute-mapper.js";
|
|
14
|
-
import { MailboxSyncService } from "./mailbox-sync.js";
|
|
14
|
+
import { type MailboxSyncLogger, MailboxSyncService } from "./mailbox-sync.js";
|
|
15
|
+
|
|
16
|
+
const silentLogger: MailboxSyncLogger = { info: () => {}, debug: () => {} };
|
|
17
|
+
|
|
15
18
|
import type { IImapConnection, ImapNamespaces } from "./types.js";
|
|
16
19
|
|
|
17
20
|
describe("parseImapAttributes – locale invariance (#194)", () => {
|
|
@@ -121,7 +124,11 @@ describe("MailboxSyncService.syncMailboxes — UIDVALIDITY cursor detection (#12
|
|
|
121
124
|
|
|
122
125
|
it("trips cursor_invalid when the STATUS sweep observes a changed UIDVALIDITY", async () => {
|
|
123
126
|
const { mailboxService, specialUseService, updateCalls } = buildServices(1);
|
|
124
|
-
const service = new MailboxSyncService(
|
|
127
|
+
const service = new MailboxSyncService(
|
|
128
|
+
mailboxService,
|
|
129
|
+
specialUseService,
|
|
130
|
+
silentLogger,
|
|
131
|
+
);
|
|
125
132
|
const connection = buildConnection(2);
|
|
126
133
|
|
|
127
134
|
await service.syncMailboxes({ accountId: "acc-1" }, connection);
|
|
@@ -136,7 +143,11 @@ describe("MailboxSyncService.syncMailboxes — UIDVALIDITY cursor detection (#12
|
|
|
136
143
|
|
|
137
144
|
it("does not write anything when UIDVALIDITY (and everything else) is unchanged", async () => {
|
|
138
145
|
const { mailboxService, specialUseService, updateCalls } = buildServices(1);
|
|
139
|
-
const service = new MailboxSyncService(
|
|
146
|
+
const service = new MailboxSyncService(
|
|
147
|
+
mailboxService,
|
|
148
|
+
specialUseService,
|
|
149
|
+
silentLogger,
|
|
150
|
+
);
|
|
140
151
|
const connection = buildConnection(1);
|
|
141
152
|
|
|
142
153
|
await service.syncMailboxes({ accountId: "acc-1" }, connection);
|
|
@@ -150,7 +161,11 @@ describe("MailboxSyncService.syncMailboxes — UIDVALIDITY cursor detection (#12
|
|
|
150
161
|
// the server's current value would step it over every change message sync
|
|
151
162
|
// had not yet applied.
|
|
152
163
|
const { mailboxService, specialUseService, updateCalls } = buildServices(1);
|
|
153
|
-
const service = new MailboxSyncService(
|
|
164
|
+
const service = new MailboxSyncService(
|
|
165
|
+
mailboxService,
|
|
166
|
+
specialUseService,
|
|
167
|
+
silentLogger,
|
|
168
|
+
);
|
|
154
169
|
const connection = buildConnection(2, "99999");
|
|
155
170
|
|
|
156
171
|
await service.syncMailboxes({ accountId: "acc-1" }, connection);
|
|
@@ -162,7 +177,11 @@ describe("MailboxSyncService.syncMailboxes — UIDVALIDITY cursor detection (#12
|
|
|
162
177
|
|
|
163
178
|
it("does not sweep-write a mailbox whose only difference is the server mod-sequence", async () => {
|
|
164
179
|
const { mailboxService, specialUseService, updateCalls } = buildServices(1);
|
|
165
|
-
const service = new MailboxSyncService(
|
|
180
|
+
const service = new MailboxSyncService(
|
|
181
|
+
mailboxService,
|
|
182
|
+
specialUseService,
|
|
183
|
+
silentLogger,
|
|
184
|
+
);
|
|
166
185
|
const connection = buildConnection(1, "4242");
|
|
167
186
|
|
|
168
187
|
await service.syncMailboxes({ accountId: "acc-1" }, connection);
|
|
@@ -175,7 +194,11 @@ describe("MailboxSyncService.syncMailboxes — UIDVALIDITY cursor detection (#12
|
|
|
175
194
|
1,
|
|
176
195
|
MailboxCursorState.cursor_invalid,
|
|
177
196
|
);
|
|
178
|
-
const service = new MailboxSyncService(
|
|
197
|
+
const service = new MailboxSyncService(
|
|
198
|
+
mailboxService,
|
|
199
|
+
specialUseService,
|
|
200
|
+
silentLogger,
|
|
201
|
+
);
|
|
179
202
|
const connection = buildConnection(2);
|
|
180
203
|
|
|
181
204
|
await service.syncMailboxes({ accountId: "acc-1" }, connection);
|
|
@@ -273,7 +296,11 @@ describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending
|
|
|
273
296
|
syncStatus: MailboxSyncStatus.pending,
|
|
274
297
|
},
|
|
275
298
|
]);
|
|
276
|
-
const service = new MailboxSyncService(
|
|
299
|
+
const service = new MailboxSyncService(
|
|
300
|
+
mailboxService,
|
|
301
|
+
specialUseService,
|
|
302
|
+
silentLogger,
|
|
303
|
+
);
|
|
277
304
|
|
|
278
305
|
const result = await service.syncMailboxes(
|
|
279
306
|
{ accountId: "acc-1" },
|
|
@@ -299,7 +326,11 @@ describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending
|
|
|
299
326
|
syncStatus: MailboxSyncStatus.synced,
|
|
300
327
|
},
|
|
301
328
|
]);
|
|
302
|
-
const service = new MailboxSyncService(
|
|
329
|
+
const service = new MailboxSyncService(
|
|
330
|
+
mailboxService,
|
|
331
|
+
specialUseService,
|
|
332
|
+
silentLogger,
|
|
333
|
+
);
|
|
303
334
|
|
|
304
335
|
const result = await service.syncMailboxes(
|
|
305
336
|
{ accountId: "acc-1" },
|
|
@@ -419,7 +450,11 @@ describe("MailboxSyncService.syncMailboxes — a folder leaving mid-sweep (#339)
|
|
|
419
450
|
doomedSyncStatus: syncStatus,
|
|
420
451
|
});
|
|
421
452
|
const { connection, statusPaths } = buildConnection(async () => status());
|
|
422
|
-
const service = new MailboxSyncService(
|
|
453
|
+
const service = new MailboxSyncService(
|
|
454
|
+
mailboxService,
|
|
455
|
+
specialUseService,
|
|
456
|
+
silentLogger,
|
|
457
|
+
);
|
|
423
458
|
|
|
424
459
|
await service.syncMailboxes({ accountId: "acc-1" }, connection);
|
|
425
460
|
|
|
@@ -439,7 +474,11 @@ describe("MailboxSyncService.syncMailboxes — a folder leaving mid-sweep (#339)
|
|
|
439
474
|
if (path === "Doomed") throw new Error("Mailbox doesn't exist: Doomed");
|
|
440
475
|
return status();
|
|
441
476
|
});
|
|
442
|
-
const service = new MailboxSyncService(
|
|
477
|
+
const service = new MailboxSyncService(
|
|
478
|
+
mailboxService,
|
|
479
|
+
specialUseService,
|
|
480
|
+
silentLogger,
|
|
481
|
+
);
|
|
443
482
|
|
|
444
483
|
await assert.doesNotReject(
|
|
445
484
|
service.syncMailboxes({ accountId: "acc-1" }, connection),
|
|
@@ -460,7 +499,11 @@ describe("MailboxSyncService.syncMailboxes — a folder leaving mid-sweep (#339)
|
|
|
460
499
|
if (path === "Doomed") throw new Error("connection reset by peer");
|
|
461
500
|
return status();
|
|
462
501
|
});
|
|
463
|
-
const service = new MailboxSyncService(
|
|
502
|
+
const service = new MailboxSyncService(
|
|
503
|
+
mailboxService,
|
|
504
|
+
specialUseService,
|
|
505
|
+
silentLogger,
|
|
506
|
+
);
|
|
464
507
|
|
|
465
508
|
await assert.rejects(
|
|
466
509
|
service.syncMailboxes({ accountId: "acc-1" }, connection),
|
package/src/mailbox-sync.ts
CHANGED
|
@@ -77,19 +77,45 @@ export interface SyncAccountInfo {
|
|
|
77
77
|
accountId: string;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Logger interface for MailboxSyncService.
|
|
82
|
+
*
|
|
83
|
+
* `info` is reserved here for the three mailbox lifecycle events — created,
|
|
84
|
+
* updated, deleted — and is the argued exception to `plugins/no-logger-info.grit`
|
|
85
|
+
* that the rest of this repo writes as a suppression. (The plugin matches the
|
|
86
|
+
* bare `logger` identifier, so it does not see an injected logger; the reasoning
|
|
87
|
+
* is written out instead of being silently absent.) A folder disappearing from
|
|
88
|
+
* someone's mail is destructive and irreversible from this service's side, and
|
|
89
|
+
* the only record that this process did it is the line it writes. That must not
|
|
90
|
+
* sit below the default threshold.
|
|
91
|
+
*
|
|
92
|
+
* Everything else this service has to say — a special-use flag it synced, a
|
|
93
|
+
* duplicate folder it skipped — is a routine trace and goes to `debug`.
|
|
94
|
+
*/
|
|
95
|
+
export interface MailboxSyncLogger {
|
|
96
|
+
info(obj: Record<string, unknown>, msg: string): void;
|
|
97
|
+
debug(obj: Record<string, unknown>, msg: string): void;
|
|
98
|
+
}
|
|
99
|
+
|
|
80
100
|
/**
|
|
81
101
|
* Service for synchronizing mailbox metadata between IMAP and the data backend.
|
|
82
102
|
*/
|
|
83
103
|
export class MailboxSyncService {
|
|
84
104
|
private mailboxService: IMailboxRepository;
|
|
85
105
|
private specialUseService: IMailboxSpecialUseRepository;
|
|
106
|
+
private log: MailboxSyncLogger;
|
|
86
107
|
|
|
108
|
+
// Required, with no no-op default: the lines below are the only record that a
|
|
109
|
+
// mailbox was created or destroyed, and a default would let the next call site
|
|
110
|
+
// lose them with no error and no failing test.
|
|
87
111
|
constructor(
|
|
88
112
|
mailboxService: IMailboxRepository,
|
|
89
113
|
specialUseService: IMailboxSpecialUseRepository,
|
|
114
|
+
logger: MailboxSyncLogger,
|
|
90
115
|
) {
|
|
91
116
|
this.mailboxService = mailboxService;
|
|
92
117
|
this.specialUseService = specialUseService;
|
|
118
|
+
this.log = logger;
|
|
93
119
|
}
|
|
94
120
|
|
|
95
121
|
/**
|
|
@@ -146,8 +172,13 @@ export class MailboxSyncService {
|
|
|
146
172
|
account.accountId,
|
|
147
173
|
existing.mailboxId,
|
|
148
174
|
);
|
|
149
|
-
|
|
150
|
-
|
|
175
|
+
this.log.info(
|
|
176
|
+
{
|
|
177
|
+
mailboxId: existing.mailboxId,
|
|
178
|
+
fullPath: existing.fullPath,
|
|
179
|
+
reason: "non-selectable",
|
|
180
|
+
},
|
|
181
|
+
"Deleted mailbox",
|
|
151
182
|
);
|
|
152
183
|
result.deleted++;
|
|
153
184
|
}
|
|
@@ -164,8 +195,13 @@ export class MailboxSyncService {
|
|
|
164
195
|
account.accountId,
|
|
165
196
|
existing.mailboxId,
|
|
166
197
|
);
|
|
167
|
-
|
|
168
|
-
|
|
198
|
+
this.log.info(
|
|
199
|
+
{
|
|
200
|
+
mailboxId: existing.mailboxId,
|
|
201
|
+
fullPath: existing.fullPath,
|
|
202
|
+
reason: "duplicate-special-use",
|
|
203
|
+
},
|
|
204
|
+
"Deleted mailbox",
|
|
169
205
|
);
|
|
170
206
|
result.deleted++;
|
|
171
207
|
}
|
|
@@ -238,8 +274,13 @@ export class MailboxSyncService {
|
|
|
238
274
|
// pending rows to the create/rename flow that owns them.
|
|
239
275
|
if (existing.syncStatus === MailboxSyncStatus.pending) continue;
|
|
240
276
|
await this.mailboxService.delete(account.accountId, existing.mailboxId);
|
|
241
|
-
|
|
242
|
-
|
|
277
|
+
this.log.info(
|
|
278
|
+
{
|
|
279
|
+
mailboxId: existing.mailboxId,
|
|
280
|
+
fullPath: existing.fullPath,
|
|
281
|
+
reason: "not-on-server",
|
|
282
|
+
},
|
|
283
|
+
"Deleted mailbox",
|
|
243
284
|
);
|
|
244
285
|
result.deleted++;
|
|
245
286
|
}
|
|
@@ -419,12 +460,18 @@ export class MailboxSyncService {
|
|
|
419
460
|
mailbox.mailboxId,
|
|
420
461
|
parsed.specialUse,
|
|
421
462
|
);
|
|
422
|
-
|
|
423
|
-
|
|
463
|
+
this.log.info(
|
|
464
|
+
{
|
|
465
|
+
mailboxId: mailbox.mailboxId,
|
|
466
|
+
fullPath: mailboxInfo.fullPath,
|
|
467
|
+
specialUse: parsed.specialUse,
|
|
468
|
+
},
|
|
469
|
+
"Created mailbox",
|
|
424
470
|
);
|
|
425
471
|
} else {
|
|
426
|
-
|
|
427
|
-
|
|
472
|
+
this.log.info(
|
|
473
|
+
{ mailboxId: mailbox.mailboxId, fullPath: mailboxInfo.fullPath },
|
|
474
|
+
"Created mailbox",
|
|
428
475
|
);
|
|
429
476
|
}
|
|
430
477
|
|
|
@@ -504,8 +551,13 @@ export class MailboxSyncService {
|
|
|
504
551
|
`specialUse: [${(existing.specialUse ?? []).join(",")}] -> [${parsed.specialUse.join(",")}]`,
|
|
505
552
|
);
|
|
506
553
|
|
|
507
|
-
|
|
508
|
-
|
|
554
|
+
this.log.info(
|
|
555
|
+
{
|
|
556
|
+
mailboxId: existing.mailboxId,
|
|
557
|
+
fullPath: mailboxInfo.fullPath,
|
|
558
|
+
changes,
|
|
559
|
+
},
|
|
560
|
+
"Updating mailbox",
|
|
509
561
|
);
|
|
510
562
|
|
|
511
563
|
// Update mailbox with fresh status. ElectroDB rejects empty sets, so we
|
|
@@ -557,8 +609,9 @@ export class MailboxSyncService {
|
|
|
557
609
|
|
|
558
610
|
if (parsed.specialUse.length > 0) {
|
|
559
611
|
await this.specialUseService.createMany(mailboxId, parsed.specialUse);
|
|
560
|
-
|
|
561
|
-
|
|
612
|
+
this.log.debug(
|
|
613
|
+
{ fullPath: mailboxInfo.fullPath, specialUse: parsed.specialUse },
|
|
614
|
+
"Synced special-use",
|
|
562
615
|
);
|
|
563
616
|
}
|
|
564
617
|
};
|
|
@@ -619,8 +672,13 @@ export class MailboxSyncService {
|
|
|
619
672
|
}
|
|
620
673
|
|
|
621
674
|
// This is a duplicate - another folder has the attribute
|
|
622
|
-
|
|
623
|
-
|
|
675
|
+
this.log.debug(
|
|
676
|
+
{
|
|
677
|
+
fullPath: mailbox.fullPath,
|
|
678
|
+
claimedPath,
|
|
679
|
+
specialUse: expectedSpecialUse,
|
|
680
|
+
},
|
|
681
|
+
"Skipping duplicate folder",
|
|
624
682
|
);
|
|
625
683
|
return true;
|
|
626
684
|
};
|