@remit/mailbox-service 0.0.97 → 0.0.99
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-category-override.test.ts +4 -8
- package/src/body-sync-category-writeonce.test.ts +17 -17
- package/src/body-sync-placement-writeonce.test.ts +37 -22
- package/src/body-sync-resync.test.ts +605 -0
- package/src/body-sync.ts +96 -42
- package/src/mailbox-delete-intent.test.ts +246 -0
- package/src/mailbox-management.ts +91 -3
- package/src/mailbox-queue.ts +24 -8
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #1011: a forced body re-sync must not re-fire standing rules on an
|
|
3
|
+
* already-seen message.
|
|
4
|
+
*
|
|
5
|
+
* The two re-entrant paths — `fetchAndGetBody`'s `NoSuchKey` IMAP fallback, and
|
|
6
|
+
* `syncBodies(..., force: true)` (the read-miss re-arm cue) — both re-fetch a
|
|
7
|
+
* body whose row already carries `bodyStorageKey`. That is a body-only fetch:
|
|
8
|
+
* the decision pass (placement, filters, classification) already ran when the
|
|
9
|
+
* body first landed. Re-running it now would move mail the user has since filed
|
|
10
|
+
* by hand, re-fire the engagement counter, re-apply labels, and rewrite the
|
|
11
|
+
* write-once category.
|
|
12
|
+
*
|
|
13
|
+
* Each test below presets a message whose body was already stored and whose
|
|
14
|
+
* headers WOULD trigger a confident placement demote or a standing filter move
|
|
15
|
+
* if the decision pass ran fresh. A regression that drops the `isReStore` skip
|
|
16
|
+
* shows up as an unwanted move, not as a passing test relying on the heuristics
|
|
17
|
+
* happening to agree.
|
|
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
|
+
FilterItem,
|
|
25
|
+
IAddressRepository,
|
|
26
|
+
IEnvelopeRepository,
|
|
27
|
+
IFilterAnchorRepository,
|
|
28
|
+
IFilterRepository,
|
|
29
|
+
IMailboxSpecialUseRepository,
|
|
30
|
+
IMessageLabelRepository,
|
|
31
|
+
IMessageRepository,
|
|
32
|
+
IThreadMessageRepository,
|
|
33
|
+
MessageItem,
|
|
34
|
+
UpdateMessageInput,
|
|
35
|
+
} from "@remit/data-ports";
|
|
36
|
+
import { NotFoundError } from "@remit/data-ports/errors";
|
|
37
|
+
import {
|
|
38
|
+
FilterClauseField,
|
|
39
|
+
FilterMatchOperator,
|
|
40
|
+
FilterState,
|
|
41
|
+
MessageCategory,
|
|
42
|
+
} from "@remit/domain-enums";
|
|
43
|
+
import type { StorageService } from "@remit/storage-service";
|
|
44
|
+
import type { PlacementConfig } from "./body-sync.js";
|
|
45
|
+
import { BodySyncService } from "./body-sync.js";
|
|
46
|
+
import { NO_ACTION } from "./filters/match.js";
|
|
47
|
+
import type { FilterConfig } from "./filters/pipeline.js";
|
|
48
|
+
import type { PlacementMoveService } from "./placement-move.js";
|
|
49
|
+
import type { IImapConnection } from "./types.js";
|
|
50
|
+
|
|
51
|
+
const MAILBOXES = {
|
|
52
|
+
inbox: { mailboxId: "mb-inbox", fullPath: "INBOX" },
|
|
53
|
+
junk: { mailboxId: "mb-junk", fullPath: "Junk" },
|
|
54
|
+
travel: { mailboxId: "mb-travel", fullPath: "Travel" },
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Demote headers: DKIM domain mismatches From, dmarc=fail, untrusted sender.
|
|
59
|
+
* `classifyPlacement`'s demote branch (inbox → junk, HIGH bar) confidently acts
|
|
60
|
+
* on this set when the message currently sits in Inbox.
|
|
61
|
+
*/
|
|
62
|
+
const DEMOTE_EML = Buffer.from(
|
|
63
|
+
[
|
|
64
|
+
"From: Support <support@evil-mimic.example>",
|
|
65
|
+
"To: me@example.com",
|
|
66
|
+
"Subject: Verify your account",
|
|
67
|
+
"Authentication-Results: mx.example.com; dmarc=fail",
|
|
68
|
+
"DKIM-Signature: v=1; a=rsa-sha256; d=relay.example.net; s=sel; b=xxx",
|
|
69
|
+
"X-Spam-Status: No, score=0.1",
|
|
70
|
+
"Content-Type: text/plain",
|
|
71
|
+
"",
|
|
72
|
+
"body",
|
|
73
|
+
].join("\r\n"),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A standing literal filter that matches subjects containing "invitation" and
|
|
78
|
+
* moves them to `destinationMailboxId`. Used to assert that a re-store does not
|
|
79
|
+
* re-fire the filter on an already-seen message.
|
|
80
|
+
*/
|
|
81
|
+
const buildInvitationFilter = (destinationMailboxId: string): FilterItem =>
|
|
82
|
+
({
|
|
83
|
+
filterId: "flt-invite",
|
|
84
|
+
accountConfigId: "cfg-1",
|
|
85
|
+
name: "Invitations",
|
|
86
|
+
scope: "Standing",
|
|
87
|
+
state: FilterState.Active,
|
|
88
|
+
hasAnchor: false,
|
|
89
|
+
ruleChangedAt: 1,
|
|
90
|
+
matchOperator: FilterMatchOperator.And,
|
|
91
|
+
literalClauses: [{ field: FilterClauseField.Subject, value: "invitation" }],
|
|
92
|
+
actionLabelId: NO_ACTION,
|
|
93
|
+
actionMailboxId: destinationMailboxId,
|
|
94
|
+
createdAt: 1,
|
|
95
|
+
updatedAt: 1,
|
|
96
|
+
}) as unknown as FilterItem;
|
|
97
|
+
|
|
98
|
+
const INVITATION_EML = Buffer.from(
|
|
99
|
+
[
|
|
100
|
+
"From: Someone <someone@example.com>",
|
|
101
|
+
"To: me@example.com",
|
|
102
|
+
"Subject: You have a new invitation to Travel",
|
|
103
|
+
"Content-Type: text/plain",
|
|
104
|
+
"",
|
|
105
|
+
"details",
|
|
106
|
+
].join("\r\n"),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
interface Harness {
|
|
110
|
+
service: BodySyncService;
|
|
111
|
+
message: MessageItem;
|
|
112
|
+
messageUpdates: Array<{ messageId: string; input: UpdateMessageInput }>;
|
|
113
|
+
moves: Array<{ messageId: string; destinationMailboxId: string }>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const noSuchKeyError = () =>
|
|
117
|
+
Object.assign(new Error("missing"), { name: "NoSuchKey" });
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Build a BodySyncService harness with both placement and filter configs wired,
|
|
121
|
+
* so we can assert that neither fires during a re-store.
|
|
122
|
+
*/
|
|
123
|
+
const buildHarness = (
|
|
124
|
+
message: Partial<MessageItem> & Pick<MessageItem, "messageId">,
|
|
125
|
+
retrieve: () => Promise<Buffer>,
|
|
126
|
+
filter: FilterItem | null,
|
|
127
|
+
): Harness => {
|
|
128
|
+
const messageUpdates: Array<{
|
|
129
|
+
messageId: string;
|
|
130
|
+
input: UpdateMessageInput;
|
|
131
|
+
}> = [];
|
|
132
|
+
const moves: Array<{ messageId: string; destinationMailboxId: string }> = [];
|
|
133
|
+
|
|
134
|
+
const messageRow = {
|
|
135
|
+
uid: 1,
|
|
136
|
+
mailboxId: MAILBOXES.inbox.mailboxId,
|
|
137
|
+
...message,
|
|
138
|
+
} as unknown as MessageItem;
|
|
139
|
+
|
|
140
|
+
const messageService = {
|
|
141
|
+
get: async (messageId: string) =>
|
|
142
|
+
messageId === message.messageId ? messageRow : undefined,
|
|
143
|
+
update: async (messageId: string, input: UpdateMessageInput) => {
|
|
144
|
+
messageUpdates.push({ messageId, input });
|
|
145
|
+
Object.assign(messageRow, input);
|
|
146
|
+
},
|
|
147
|
+
} as unknown as IMessageRepository;
|
|
148
|
+
|
|
149
|
+
const threadMessageService = {
|
|
150
|
+
findAllByMessageId: async () => [
|
|
151
|
+
{
|
|
152
|
+
threadMessageId: "tm-1",
|
|
153
|
+
messageId: message.messageId,
|
|
154
|
+
mailboxId: messageRow.mailboxId,
|
|
155
|
+
sentDate: 1,
|
|
156
|
+
isRead: false,
|
|
157
|
+
isDeleted: false,
|
|
158
|
+
hasStars: false,
|
|
159
|
+
hasAttachment: false,
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
update: async () => {},
|
|
163
|
+
} as unknown as IThreadMessageRepository;
|
|
164
|
+
|
|
165
|
+
const storageService = {
|
|
166
|
+
retrieve,
|
|
167
|
+
storeMessageBody: async () => ({ uri: `s3://bodies/${message.messageId}` }),
|
|
168
|
+
storeMessageBodyStream: async () => ({
|
|
169
|
+
uri: `s3://bodies/${message.messageId}`,
|
|
170
|
+
}),
|
|
171
|
+
storeParsedBody: async () => {},
|
|
172
|
+
listBodyParts: async () => [],
|
|
173
|
+
} as unknown as StorageService;
|
|
174
|
+
|
|
175
|
+
const addressService = {
|
|
176
|
+
getAddress: async () => {
|
|
177
|
+
throw new NotFoundError("Address not found");
|
|
178
|
+
},
|
|
179
|
+
incrementInboundCount: async () => {},
|
|
180
|
+
} as unknown as IAddressRepository;
|
|
181
|
+
|
|
182
|
+
const envelopeService = {
|
|
183
|
+
listBodyParts: async () => [],
|
|
184
|
+
} as unknown as IEnvelopeRepository;
|
|
185
|
+
|
|
186
|
+
const mailboxSpecialUseService = {
|
|
187
|
+
findJunkMailbox: async () => MAILBOXES.junk,
|
|
188
|
+
findArchiveMailbox: async () => null,
|
|
189
|
+
findInboxMailbox: async () => MAILBOXES.inbox,
|
|
190
|
+
} as unknown as IMailboxSpecialUseRepository;
|
|
191
|
+
|
|
192
|
+
const placementMoveService = {
|
|
193
|
+
moveMessage: async (
|
|
194
|
+
_accountConfigId: string,
|
|
195
|
+
messageId: string,
|
|
196
|
+
destinationMailboxId: string,
|
|
197
|
+
) => {
|
|
198
|
+
moves.push({ messageId, destinationMailboxId });
|
|
199
|
+
messageRow.mailboxId = destinationMailboxId;
|
|
200
|
+
},
|
|
201
|
+
} as unknown as PlacementMoveService;
|
|
202
|
+
|
|
203
|
+
const placementConfig: PlacementConfig = {
|
|
204
|
+
mailboxSpecialUseService,
|
|
205
|
+
placementMoveService,
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const filterConfig: FilterConfig | undefined = filter
|
|
209
|
+
? {
|
|
210
|
+
filterService: {
|
|
211
|
+
listByAccountAndState: async () => [filter],
|
|
212
|
+
refreshExpiry: async (f: FilterItem) => f,
|
|
213
|
+
} as unknown as IFilterRepository,
|
|
214
|
+
filterAnchorService: {
|
|
215
|
+
get: async () => undefined,
|
|
216
|
+
} as unknown as IFilterAnchorRepository,
|
|
217
|
+
messageLabelService: {
|
|
218
|
+
apply: async () => {},
|
|
219
|
+
} as unknown as IMessageLabelRepository,
|
|
220
|
+
placementMoveService,
|
|
221
|
+
}
|
|
222
|
+
: undefined;
|
|
223
|
+
|
|
224
|
+
const service = new BodySyncService(
|
|
225
|
+
messageService,
|
|
226
|
+
storageService,
|
|
227
|
+
threadMessageService,
|
|
228
|
+
addressService,
|
|
229
|
+
envelopeService,
|
|
230
|
+
{ info: () => {}, error: () => {}, debug: () => {}, warn: () => {} },
|
|
231
|
+
placementConfig,
|
|
232
|
+
filterConfig,
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
return { service, message: messageRow, messageUpdates, moves };
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const noSuchKeyConn = (body: Buffer): IImapConnection =>
|
|
239
|
+
({
|
|
240
|
+
openBox: async () => {},
|
|
241
|
+
fetchMessageBody: async () => body,
|
|
242
|
+
}) as unknown as IImapConnection;
|
|
243
|
+
|
|
244
|
+
const streamBody = (body: Buffer): Readable =>
|
|
245
|
+
Readable.from([body]) as unknown as Readable;
|
|
246
|
+
|
|
247
|
+
describe("issue #1011: forced re-store does not re-fire standing rules", () => {
|
|
248
|
+
it("syncBodies(force: true) does not re-evaluate placement on a stored message", async () => {
|
|
249
|
+
const harness = buildHarness(
|
|
250
|
+
{
|
|
251
|
+
messageId: "m-1",
|
|
252
|
+
mailboxId: MAILBOXES.inbox.mailboxId,
|
|
253
|
+
bodyStorageKey: "s3://bodies/m-1",
|
|
254
|
+
// Already classified and placed — category and placementDecidedAt
|
|
255
|
+
// set by a prior, genuine first-store pass.
|
|
256
|
+
category: MessageCategory.personal,
|
|
257
|
+
placementDecidedAt: 500,
|
|
258
|
+
},
|
|
259
|
+
async () => {
|
|
260
|
+
throw new Error("force path must not retrieve from storage");
|
|
261
|
+
},
|
|
262
|
+
null, // no filter config
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const connection = {
|
|
266
|
+
openBox: async () => {},
|
|
267
|
+
async *fetchMessageBodies(uids: number[]) {
|
|
268
|
+
for (const uid of uids) {
|
|
269
|
+
yield { uid, source: streamBody(DEMOTE_EML) };
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
} as unknown as IImapConnection;
|
|
273
|
+
|
|
274
|
+
const result = await harness.service.syncBodies(
|
|
275
|
+
["m-1"],
|
|
276
|
+
"acc-1",
|
|
277
|
+
"cfg-1",
|
|
278
|
+
"INBOX",
|
|
279
|
+
async () => connection,
|
|
280
|
+
true,
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
assert.deepEqual(result.syncedMessageIds, ["m-1"], "message is synced");
|
|
284
|
+
|
|
285
|
+
// No placement move — a hand-rescued message stays where the user put it.
|
|
286
|
+
assert.deepEqual(
|
|
287
|
+
harness.moves,
|
|
288
|
+
[],
|
|
289
|
+
"a forced re-store must not re-decide placement, even though headers would demote",
|
|
290
|
+
);
|
|
291
|
+
assert.equal(harness.message.mailboxId, MAILBOXES.inbox.mailboxId);
|
|
292
|
+
|
|
293
|
+
// The update writes only bodyStorageKey — no placement or category churn.
|
|
294
|
+
const update = harness.messageUpdates[0];
|
|
295
|
+
assert.equal(update?.input.movedByRemit, undefined);
|
|
296
|
+
assert.equal(update?.input.placementVerdict, undefined);
|
|
297
|
+
assert.equal(update?.input.placementDecidedAt, undefined);
|
|
298
|
+
assert.equal(update?.input.category, undefined);
|
|
299
|
+
assert.deepEqual(
|
|
300
|
+
Object.keys(update?.input ?? {}).sort(),
|
|
301
|
+
["bodyStorageKey"],
|
|
302
|
+
"re-store must write only bodyStorageKey",
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("syncBodies(force: true) does not re-fire a standing filter on a stored message", async () => {
|
|
307
|
+
const harness = buildHarness(
|
|
308
|
+
{
|
|
309
|
+
messageId: "m-1",
|
|
310
|
+
mailboxId: MAILBOXES.travel.mailboxId,
|
|
311
|
+
bodyStorageKey: "s3://bodies/m-1",
|
|
312
|
+
category: MessageCategory.personal,
|
|
313
|
+
},
|
|
314
|
+
async () => {
|
|
315
|
+
throw new Error("force path must not retrieve from storage");
|
|
316
|
+
},
|
|
317
|
+
buildInvitationFilter(MAILBOXES.junk.mailboxId),
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
const connection = {
|
|
321
|
+
openBox: async () => {},
|
|
322
|
+
async *fetchMessageBodies(uids: number[]) {
|
|
323
|
+
for (const uid of uids) {
|
|
324
|
+
yield { uid, source: streamBody(INVITATION_EML) };
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
} as unknown as IImapConnection;
|
|
328
|
+
|
|
329
|
+
const result = await harness.service.syncBodies(
|
|
330
|
+
["m-1"],
|
|
331
|
+
"acc-1",
|
|
332
|
+
"cfg-1",
|
|
333
|
+
"INBOX",
|
|
334
|
+
async () => connection,
|
|
335
|
+
true,
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
assert.deepEqual(result.syncedMessageIds, ["m-1"]);
|
|
339
|
+
|
|
340
|
+
// No filter move — the message already sits in its filter destination
|
|
341
|
+
// (Travel) and a re-store must not re-fire the filter.
|
|
342
|
+
assert.deepEqual(
|
|
343
|
+
harness.moves,
|
|
344
|
+
[],
|
|
345
|
+
"a forced re-store must not re-fire standing filters",
|
|
346
|
+
);
|
|
347
|
+
// The update writes only bodyStorageKey — no filterMove marker.
|
|
348
|
+
const update = harness.messageUpdates[0];
|
|
349
|
+
assert.equal(update?.input.filterMove, undefined);
|
|
350
|
+
assert.equal(update?.input.movedByRemit, undefined);
|
|
351
|
+
assert.deepEqual(
|
|
352
|
+
Object.keys(update?.input ?? {}).sort(),
|
|
353
|
+
["bodyStorageKey"],
|
|
354
|
+
"re-store must write only bodyStorageKey",
|
|
355
|
+
);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("fetchAndGetBody NoSuchKey fallback does not re-fire placement or filters", async () => {
|
|
359
|
+
const harness = buildHarness(
|
|
360
|
+
{
|
|
361
|
+
messageId: "m-1",
|
|
362
|
+
mailboxId: MAILBOXES.inbox.mailboxId,
|
|
363
|
+
bodyStorageKey: "s3://bodies/m-1",
|
|
364
|
+
category: MessageCategory.personal,
|
|
365
|
+
placementDecidedAt: 500,
|
|
366
|
+
},
|
|
367
|
+
async () => {
|
|
368
|
+
throw noSuchKeyError();
|
|
369
|
+
},
|
|
370
|
+
buildInvitationFilter(MAILBOXES.junk.mailboxId),
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
await harness.service.fetchAndGetBody(
|
|
374
|
+
"m-1",
|
|
375
|
+
"acc-1",
|
|
376
|
+
"cfg-1",
|
|
377
|
+
"INBOX",
|
|
378
|
+
async () => noSuchKeyConn(DEMOTE_EML),
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
assert.deepEqual(
|
|
382
|
+
harness.moves,
|
|
383
|
+
[],
|
|
384
|
+
"the NoSuchKey fallback must not re-decide placement or re-fire filters",
|
|
385
|
+
);
|
|
386
|
+
assert.equal(harness.message.mailboxId, MAILBOXES.inbox.mailboxId);
|
|
387
|
+
|
|
388
|
+
const update = harness.messageUpdates[0];
|
|
389
|
+
assert.equal(update?.input.movedByRemit, undefined);
|
|
390
|
+
assert.equal(update?.input.placementVerdict, undefined);
|
|
391
|
+
assert.equal(update?.input.filterMove, undefined);
|
|
392
|
+
assert.deepEqual(
|
|
393
|
+
Object.keys(update?.input ?? {}).sort(),
|
|
394
|
+
["bodyStorageKey"],
|
|
395
|
+
"re-store must write only bodyStorageKey",
|
|
396
|
+
);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("a first-store via fetchAndGetBody (no bodyStorageKey) still runs placement and filters", async () => {
|
|
400
|
+
// Regression guard: the re-store skip must not disable decisions on a
|
|
401
|
+
// genuine first store that happens to arrive via the read path.
|
|
402
|
+
const harness = buildHarness(
|
|
403
|
+
{
|
|
404
|
+
messageId: "m-1",
|
|
405
|
+
mailboxId: MAILBOXES.inbox.mailboxId,
|
|
406
|
+
// No bodyStorageKey — a genuine first store.
|
|
407
|
+
},
|
|
408
|
+
async () => {
|
|
409
|
+
throw new Error("must not retrieve from storage on a first store");
|
|
410
|
+
},
|
|
411
|
+
buildInvitationFilter(MAILBOXES.travel.mailboxId),
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
await harness.service.fetchAndGetBody(
|
|
415
|
+
"m-1",
|
|
416
|
+
"acc-1",
|
|
417
|
+
"cfg-1",
|
|
418
|
+
"INBOX",
|
|
419
|
+
async () => noSuchKeyConn(INVITATION_EML),
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
// The filter fires — it is a first store, not a re-store.
|
|
423
|
+
assert.deepEqual(
|
|
424
|
+
harness.moves,
|
|
425
|
+
[{ messageId: "m-1", destinationMailboxId: MAILBOXES.travel.mailboxId }],
|
|
426
|
+
"a genuine first store must run the standing filter",
|
|
427
|
+
);
|
|
428
|
+
const update = harness.messageUpdates[0];
|
|
429
|
+
assert.equal(update?.input.filterMove?.filterId, "flt-invite");
|
|
430
|
+
assert.equal(update?.input.movedByRemit, true);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("a forced batch re-stores a stored message (bytes only) but first-stores a never-stored one", async () => {
|
|
434
|
+
// A forced batch may contain both never-stored messages (first store —
|
|
435
|
+
// full decisions) and already-stored ones (re-store — bytes only).
|
|
436
|
+
const storedMessage = {
|
|
437
|
+
messageId: "m-stored",
|
|
438
|
+
mailboxId: MAILBOXES.inbox.mailboxId,
|
|
439
|
+
uid: 1,
|
|
440
|
+
bodyStorageKey: "s3://bodies/m-stored",
|
|
441
|
+
category: MessageCategory.personal,
|
|
442
|
+
} as unknown as MessageItem;
|
|
443
|
+
const newMessage = {
|
|
444
|
+
messageId: "m-new",
|
|
445
|
+
mailboxId: MAILBOXES.inbox.mailboxId,
|
|
446
|
+
uid: 2,
|
|
447
|
+
} as unknown as MessageItem;
|
|
448
|
+
|
|
449
|
+
const messageUpdates: Array<{
|
|
450
|
+
messageId: string;
|
|
451
|
+
input: UpdateMessageInput;
|
|
452
|
+
}> = [];
|
|
453
|
+
const moves: Array<{ messageId: string; destinationMailboxId: string }> =
|
|
454
|
+
[];
|
|
455
|
+
|
|
456
|
+
const messageService = {
|
|
457
|
+
get: async (messageId: string) =>
|
|
458
|
+
messageId === "m-stored"
|
|
459
|
+
? storedMessage
|
|
460
|
+
: messageId === "m-new"
|
|
461
|
+
? newMessage
|
|
462
|
+
: undefined,
|
|
463
|
+
update: async (id: string, input: UpdateMessageInput) => {
|
|
464
|
+
messageUpdates.push({ messageId: id, input });
|
|
465
|
+
const row = id === "m-stored" ? storedMessage : newMessage;
|
|
466
|
+
Object.assign(row, input);
|
|
467
|
+
},
|
|
468
|
+
} as unknown as IMessageRepository;
|
|
469
|
+
|
|
470
|
+
const threadMessageService = {
|
|
471
|
+
findAllByMessageId: async (
|
|
472
|
+
_accountConfigId: string,
|
|
473
|
+
messageId: string,
|
|
474
|
+
) => [
|
|
475
|
+
{
|
|
476
|
+
threadMessageId: `tm-${messageId}`,
|
|
477
|
+
messageId,
|
|
478
|
+
mailboxId:
|
|
479
|
+
messageId === "m-stored"
|
|
480
|
+
? storedMessage.mailboxId
|
|
481
|
+
: newMessage.mailboxId,
|
|
482
|
+
sentDate: 1,
|
|
483
|
+
isRead: false,
|
|
484
|
+
isDeleted: false,
|
|
485
|
+
hasStars: false,
|
|
486
|
+
hasAttachment: false,
|
|
487
|
+
},
|
|
488
|
+
],
|
|
489
|
+
update: async () => {},
|
|
490
|
+
} as unknown as IThreadMessageRepository;
|
|
491
|
+
|
|
492
|
+
const storageService = {
|
|
493
|
+
retrieve: async () => {
|
|
494
|
+
throw new Error("must not retrieve on force path");
|
|
495
|
+
},
|
|
496
|
+
storeMessageBodyStream: async () => ({
|
|
497
|
+
uri: "s3://bodies/m",
|
|
498
|
+
}),
|
|
499
|
+
storeParsedBody: async () => {},
|
|
500
|
+
listBodyParts: async () => [],
|
|
501
|
+
} as unknown as StorageService;
|
|
502
|
+
|
|
503
|
+
const addressService = {
|
|
504
|
+
getAddress: async () => {
|
|
505
|
+
throw new NotFoundError("not found");
|
|
506
|
+
},
|
|
507
|
+
incrementInboundCount: async () => {},
|
|
508
|
+
} as unknown as IAddressRepository;
|
|
509
|
+
|
|
510
|
+
const envelopeService = {
|
|
511
|
+
listBodyParts: async () => [],
|
|
512
|
+
} as unknown as IEnvelopeRepository;
|
|
513
|
+
|
|
514
|
+
const mailboxSpecialUseService = {
|
|
515
|
+
findJunkMailbox: async () => MAILBOXES.junk,
|
|
516
|
+
findArchiveMailbox: async () => null,
|
|
517
|
+
findInboxMailbox: async () => MAILBOXES.inbox,
|
|
518
|
+
} as unknown as IMailboxSpecialUseRepository;
|
|
519
|
+
|
|
520
|
+
const placementMoveService = {
|
|
521
|
+
moveMessage: async (
|
|
522
|
+
_accountConfigId: string,
|
|
523
|
+
messageId: string,
|
|
524
|
+
destinationMailboxId: string,
|
|
525
|
+
) => {
|
|
526
|
+
moves.push({ messageId, destinationMailboxId });
|
|
527
|
+
},
|
|
528
|
+
} as unknown as PlacementMoveService;
|
|
529
|
+
|
|
530
|
+
const filterConfig: FilterConfig = {
|
|
531
|
+
filterService: {
|
|
532
|
+
listByAccountAndState: async () => [
|
|
533
|
+
buildInvitationFilter(MAILBOXES.junk.mailboxId),
|
|
534
|
+
],
|
|
535
|
+
refreshExpiry: async (f: FilterItem) => f,
|
|
536
|
+
} as unknown as IFilterRepository,
|
|
537
|
+
filterAnchorService: {
|
|
538
|
+
get: async () => undefined,
|
|
539
|
+
} as unknown as IFilterAnchorRepository,
|
|
540
|
+
messageLabelService: {
|
|
541
|
+
apply: async () => {},
|
|
542
|
+
} as unknown as IMessageLabelRepository,
|
|
543
|
+
placementMoveService,
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
const service = new BodySyncService(
|
|
547
|
+
messageService,
|
|
548
|
+
storageService,
|
|
549
|
+
threadMessageService,
|
|
550
|
+
addressService,
|
|
551
|
+
envelopeService,
|
|
552
|
+
{ info: () => {}, error: () => {}, debug: () => {}, warn: () => {} },
|
|
553
|
+
{ mailboxSpecialUseService, placementMoveService },
|
|
554
|
+
filterConfig,
|
|
555
|
+
);
|
|
556
|
+
|
|
557
|
+
// UID 1 is the stored message, UID 2 is new. fetchMessageBodies yields
|
|
558
|
+
// in order; the stored one (DEMOTE_EML) would demote if placement ran,
|
|
559
|
+
// the new one (INVITATION_EML) would trigger the filter.
|
|
560
|
+
const connection = {
|
|
561
|
+
openBox: async () => {},
|
|
562
|
+
async *fetchMessageBodies() {
|
|
563
|
+
yield { uid: 1, source: streamBody(DEMOTE_EML) };
|
|
564
|
+
yield { uid: 2, source: streamBody(INVITATION_EML) };
|
|
565
|
+
},
|
|
566
|
+
} as unknown as IImapConnection;
|
|
567
|
+
|
|
568
|
+
const result = await service.syncBodies(
|
|
569
|
+
["m-stored", "m-new"],
|
|
570
|
+
"acc-1",
|
|
571
|
+
"cfg-1",
|
|
572
|
+
"INBOX",
|
|
573
|
+
async () => connection,
|
|
574
|
+
true,
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
// Both messages were handled.
|
|
578
|
+
assert.deepEqual(result.syncedMessageIds.sort(), ["m-new", "m-stored"]);
|
|
579
|
+
|
|
580
|
+
// The stored message is a re-store: no placement move, no filter run.
|
|
581
|
+
const storedUpdate = messageUpdates.find((u) => u.messageId === "m-stored");
|
|
582
|
+
assert.equal(storedUpdate?.input.movedByRemit, undefined);
|
|
583
|
+
assert.equal(storedUpdate?.input.filterMove, undefined);
|
|
584
|
+
assert.deepEqual(
|
|
585
|
+
Object.keys(storedUpdate?.input ?? {}).sort(),
|
|
586
|
+
["bodyStorageKey"],
|
|
587
|
+
"stored message in a forced batch must re-store (bytes only)",
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
// The new message is a first-store: the filter fires.
|
|
591
|
+
assert.deepEqual(
|
|
592
|
+
moves,
|
|
593
|
+
[
|
|
594
|
+
{
|
|
595
|
+
messageId: "m-new",
|
|
596
|
+
destinationMailboxId: MAILBOXES.junk.mailboxId,
|
|
597
|
+
},
|
|
598
|
+
],
|
|
599
|
+
"only the never-stored message in the forced batch takes the decision pass",
|
|
600
|
+
);
|
|
601
|
+
const newUpdate = messageUpdates.find((u) => u.messageId === "m-new");
|
|
602
|
+
assert.equal(newUpdate?.input.movedByRemit, true);
|
|
603
|
+
assert.equal(newUpdate?.input.filterMove?.filterId, "flt-invite");
|
|
604
|
+
});
|
|
605
|
+
});
|