@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/backend",
3
- "version": "0.0.25",
3
+ "version": "0.0.26",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -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
+ });
@@ -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 placement verdict to the public `AutoMovedInfo`
7
- * shape. Present only for a real, in-effect auto-move `movedByRemit` is
8
- * true, the verdict is `Confident`, and it was not a dry run. Confidence,
9
- * dryRun, decidedAt and reasons are internal diagnostics and never cross to
10
- * the wire; the client derives everything else (whether the move is still in
11
- * effect, the undo target) from `action`/`fromPlacement` plus current
12
- * placement.
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<MessageItem, "movedByRemit" | "placementVerdict">,
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({