@remit/backend 0.0.25 → 0.0.26
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
CHANGED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { MessageItem } from "@remit/data-ports";
|
|
4
|
+
import { PlacementAction, PlacementConfidence } from "@remit/domain-enums";
|
|
5
|
+
import { deriveAutoMoved } from "./autoMoved.js";
|
|
6
|
+
|
|
7
|
+
type AutoMovedInput = Pick<
|
|
8
|
+
MessageItem,
|
|
9
|
+
"movedByRemit" | "placementVerdict" | "filterMove"
|
|
10
|
+
>;
|
|
11
|
+
|
|
12
|
+
const confidentVerdict = {
|
|
13
|
+
action: PlacementAction.MoveToInbox,
|
|
14
|
+
confidence: PlacementConfidence.Confident,
|
|
15
|
+
fromPlacement: "junk",
|
|
16
|
+
reasons: ["provider=spam", "dmarc=pass"],
|
|
17
|
+
dryRun: false,
|
|
18
|
+
decidedAt: 1_700_000_000_000,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const filterMove = {
|
|
22
|
+
filterId: "flt-1",
|
|
23
|
+
sourceMailboxId: "mb-inbox",
|
|
24
|
+
destinationMailboxId: "mb-travel",
|
|
25
|
+
decidedAt: 1_700_000_000_000,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe("deriveAutoMoved", () => {
|
|
29
|
+
it("returns undefined when the message was not moved by Remit", () => {
|
|
30
|
+
const message: AutoMovedInput = {
|
|
31
|
+
movedByRemit: false,
|
|
32
|
+
placementVerdict: confidentVerdict,
|
|
33
|
+
};
|
|
34
|
+
assert.equal(deriveAutoMoved(message), undefined);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("projects a standing-filter move to its mailbox ids and filter id", () => {
|
|
38
|
+
const message: AutoMovedInput = { movedByRemit: true, filterMove };
|
|
39
|
+
assert.deepEqual(deriveAutoMoved(message), {
|
|
40
|
+
fromMailboxId: "mb-inbox",
|
|
41
|
+
destinationMailboxId: "mb-travel",
|
|
42
|
+
filterId: "flt-1",
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("never leaks the classifier action/fromPlacement for a filter move", () => {
|
|
47
|
+
const result = deriveAutoMoved({ movedByRemit: true, filterMove });
|
|
48
|
+
assert.equal(result?.action, undefined);
|
|
49
|
+
assert.equal(result?.fromPlacement, undefined);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("a filter move outranks a co-present placement verdict", () => {
|
|
53
|
+
// Both a filter move and a classifier verdict can be recorded; the filter's
|
|
54
|
+
// exclusive move is what ultimately placed the message (RFC 034 Dec. 3.1).
|
|
55
|
+
const message: AutoMovedInput = {
|
|
56
|
+
movedByRemit: true,
|
|
57
|
+
filterMove,
|
|
58
|
+
placementVerdict: confidentVerdict,
|
|
59
|
+
};
|
|
60
|
+
assert.deepEqual(deriveAutoMoved(message), {
|
|
61
|
+
fromMailboxId: "mb-inbox",
|
|
62
|
+
destinationMailboxId: "mb-travel",
|
|
63
|
+
filterId: "flt-1",
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("projects a confident classifier move to action + fromPlacement", () => {
|
|
68
|
+
const message: AutoMovedInput = {
|
|
69
|
+
movedByRemit: true,
|
|
70
|
+
placementVerdict: confidentVerdict,
|
|
71
|
+
};
|
|
72
|
+
assert.deepEqual(deriveAutoMoved(message), {
|
|
73
|
+
action: PlacementAction.MoveToInbox,
|
|
74
|
+
fromPlacement: "junk",
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("drops an unsure classifier verdict", () => {
|
|
79
|
+
const message: AutoMovedInput = {
|
|
80
|
+
movedByRemit: true,
|
|
81
|
+
placementVerdict: {
|
|
82
|
+
...confidentVerdict,
|
|
83
|
+
confidence: PlacementConfidence.Unsure,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
assert.equal(deriveAutoMoved(message), undefined);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("drops a dry-run classifier verdict", () => {
|
|
90
|
+
const message: AutoMovedInput = {
|
|
91
|
+
movedByRemit: true,
|
|
92
|
+
placementVerdict: { ...confidentVerdict, dryRun: true },
|
|
93
|
+
};
|
|
94
|
+
assert.equal(deriveAutoMoved(message), undefined);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("does not crash on legacy verdict-less, filter-less moved data", () => {
|
|
98
|
+
// A message moved before either the verdict or the filter marker existed:
|
|
99
|
+
// movedByRemit is set but nothing is derivable. Must return undefined, not
|
|
100
|
+
// throw (issue #223 back-compat).
|
|
101
|
+
const message: AutoMovedInput = { movedByRemit: true };
|
|
102
|
+
assert.equal(deriveAutoMoved(message), undefined);
|
|
103
|
+
});
|
|
104
|
+
});
|
package/src/derive/autoMoved.ts
CHANGED
|
@@ -3,19 +3,47 @@ import type { MessageItem } from "@remit/data-ports";
|
|
|
3
3
|
import { PlacementConfidence } from "@remit/domain-enums";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Project a Message's internal
|
|
7
|
-
* shape. Present only for a real, in-effect auto-move
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
6
|
+
* Project a Message's internal auto-move state to the public `AutoMovedInfo`
|
|
7
|
+
* shape. Present only for a real, in-effect auto-move; absent otherwise, so a
|
|
8
|
+
* message synced before either mechanism existed (no `filterMove`, no
|
|
9
|
+
* `placementVerdict`) projects nothing and renders no badge.
|
|
10
|
+
*
|
|
11
|
+
* Two mechanisms move mail and both raise `movedByRemit`:
|
|
12
|
+
*
|
|
13
|
+
* 1. A standing filter (RFC 034) records a `filterMove` marker naming the
|
|
14
|
+
* source folder, the destination folder, and the filter. It has no
|
|
15
|
+
* classifier direction/role, so the projection carries mailbox ids
|
|
16
|
+
* (`fromMailboxId`/`destinationMailboxId`) and the `filterId` for the
|
|
17
|
+
* Settings link — an arbitrary destination the Inbox/Junk-shaped `action`
|
|
18
|
+
* cannot name.
|
|
19
|
+
* 2. The Tier 0 classifier records a `placementVerdict`. Its `action`
|
|
20
|
+
* (MoveToInbox/MoveToJunk) and `fromPlacement` (inbox/junk/other) are the
|
|
21
|
+
* only fields that cross to the wire; confidence, dryRun, decidedAt and
|
|
22
|
+
* reasons stay internal.
|
|
23
|
+
*
|
|
24
|
+
* The filter marker outranks the verdict check: a matched filter's move is
|
|
25
|
+
* exclusive and outranks the classifier's placement move at index time (RFC 034
|
|
26
|
+
* Decision 3.1), so a message carrying both was ultimately moved by the filter.
|
|
27
|
+
* The client derives everything else (whether the move is still in effect, the
|
|
28
|
+
* undo target) from these fields plus current placement.
|
|
13
29
|
*/
|
|
14
30
|
export const deriveAutoMoved = (
|
|
15
|
-
message: Pick<
|
|
31
|
+
message: Pick<
|
|
32
|
+
MessageItem,
|
|
33
|
+
"movedByRemit" | "placementVerdict" | "filterMove"
|
|
34
|
+
>,
|
|
16
35
|
): AutoMovedInfo | undefined => {
|
|
17
36
|
if (message.movedByRemit !== true) return undefined;
|
|
18
37
|
|
|
38
|
+
const filterMove = message.filterMove;
|
|
39
|
+
if (filterMove) {
|
|
40
|
+
return {
|
|
41
|
+
fromMailboxId: filterMove.sourceMailboxId,
|
|
42
|
+
destinationMailboxId: filterMove.destinationMailboxId,
|
|
43
|
+
filterId: filterMove.filterId,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
19
47
|
const verdict = message.placementVerdict;
|
|
20
48
|
if (!verdict) return undefined;
|
|
21
49
|
if (verdict.confidence !== PlacementConfidence.Confident) return undefined;
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
MailboxQueueService,
|
|
34
34
|
MessageMoveService,
|
|
35
35
|
OutboxQueueService,
|
|
36
|
+
PlacementMoveService,
|
|
36
37
|
} from "@remit/mailbox-service";
|
|
37
38
|
import { createSearchService, type SearchService } from "@remit/search-service";
|
|
38
39
|
import {
|
|
@@ -287,6 +288,34 @@ export const createRemitClient = (deps: RemitClientDeps): RemitClient => {
|
|
|
287
288
|
bodySyncQueue,
|
|
288
289
|
} = deps;
|
|
289
290
|
|
|
291
|
+
// The read-path body backfill (describeMessage / getRawMessage →
|
|
292
|
+
// fetchAndGetBody) materializes a body that was never sync-synced, and must
|
|
293
|
+
// run the account's standing filters the same way the imap-worker's sync path
|
|
294
|
+
// does. Without this, a message a user opens before background body-sync is
|
|
295
|
+
// classified but never filter-evaluated — and once its body is stored the
|
|
296
|
+
// filter-capable sync path skips it, so an explicit standing filter silently
|
|
297
|
+
// never fires (issue #223). Only the explicit user-rule half is wired here;
|
|
298
|
+
// heuristic classifier placement stays on the sync path. Wired like
|
|
299
|
+
// `sync-message-body.ts`, over the message-management queue's local-first
|
|
300
|
+
// PlacementMoveService; filters stay off when that queue is unset.
|
|
301
|
+
const placementMoveQueueUrl = process.env.SQS_QUEUE_URL_MESSAGE_MGMT;
|
|
302
|
+
const placementMoveService = placementMoveQueueUrl
|
|
303
|
+
? new PlacementMoveService({
|
|
304
|
+
messageService: repositories.message,
|
|
305
|
+
threadMessageService: repositories.threadMessage,
|
|
306
|
+
markerService: repositories.placementMove,
|
|
307
|
+
sqsQueueUrl: placementMoveQueueUrl,
|
|
308
|
+
})
|
|
309
|
+
: undefined;
|
|
310
|
+
const filterConfig = placementMoveService
|
|
311
|
+
? {
|
|
312
|
+
filterService: repositories.filter,
|
|
313
|
+
filterAnchorService: repositories.filterAnchor,
|
|
314
|
+
messageLabelService: repositories.messageLabel,
|
|
315
|
+
placementMoveService,
|
|
316
|
+
}
|
|
317
|
+
: undefined;
|
|
318
|
+
|
|
290
319
|
const bodySync = new BodySyncService(
|
|
291
320
|
repositories.message,
|
|
292
321
|
storage,
|
|
@@ -294,6 +323,8 @@ export const createRemitClient = (deps: RemitClientDeps): RemitClient => {
|
|
|
294
323
|
repositories.address,
|
|
295
324
|
repositories.envelope,
|
|
296
325
|
logger,
|
|
326
|
+
undefined,
|
|
327
|
+
filterConfig,
|
|
297
328
|
);
|
|
298
329
|
|
|
299
330
|
const flagPushService = new FlagPushService({
|