@remit/backend 0.0.45 → 0.0.47

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.45",
3
+ "version": "0.0.47",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -0,0 +1,34 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { AddressFlags } from "@remit/api-openapi-types";
4
+ import { deriveMuted } from "./deriveMuted.js";
5
+
6
+ const SET_AT = 1_700_000_000_000;
7
+
8
+ describe("deriveMuted", () => {
9
+ it("returns false when flags is undefined", () => {
10
+ assert.equal(deriveMuted(undefined), false);
11
+ });
12
+
13
+ it("returns false for an empty flags object", () => {
14
+ assert.equal(deriveMuted({}), false);
15
+ });
16
+
17
+ it("returns false when muted.value is false", () => {
18
+ const flags: AddressFlags = { muted: { value: false, setAt: SET_AT } };
19
+ assert.equal(deriveMuted(flags), false);
20
+ });
21
+
22
+ it("returns true when muted.value is true", () => {
23
+ const flags: AddressFlags = { muted: { value: true, setAt: SET_AT } };
24
+ assert.equal(deriveMuted(flags), true);
25
+ });
26
+
27
+ it("ignores other flags (orthogonal axis)", () => {
28
+ const flags: AddressFlags = {
29
+ vip: { value: true, setAt: SET_AT },
30
+ wellknown: { value: true, setAt: SET_AT },
31
+ };
32
+ assert.equal(deriveMuted(flags), false);
33
+ });
34
+ });
@@ -0,0 +1,10 @@
1
+ import type { AddressFlags } from "@remit/api-openapi-types";
2
+
3
+ /**
4
+ * Derive whether the From address is muted from an Address's flags map.
5
+ *
6
+ * Pure function, no I/O. Frontend never derives this — single source of
7
+ * truth for filtering the daily brief.
8
+ */
9
+ export const deriveMuted = (flags: AddressFlags | undefined): boolean =>
10
+ flags?.muted?.value === true;
@@ -7,11 +7,13 @@ import type {
7
7
  MessageLabelItem,
8
8
  ThreadMessageItem,
9
9
  } from "@remit/data-ports";
10
+ import { deriveAddressId } from "@remit/data-ports/id";
10
11
  import { type EnrichClient, enrichThreadRows } from "./enrichThreadRows.js";
11
12
 
12
13
  const threadRow = (
13
14
  threadMessageId: string,
14
15
  messageId: string,
16
+ fromEmail?: string,
15
17
  ): ThreadMessageItem =>
16
18
  ({
17
19
  threadMessageId,
@@ -19,6 +21,7 @@ const threadRow = (
19
21
  messageId,
20
22
  accountConfigId: "acc-1",
21
23
  mailboxId: "mbx-1",
24
+ fromEmail,
22
25
  sentDate: 1,
23
26
  isRead: true,
24
27
  hasAttachment: false,
@@ -31,9 +34,10 @@ const threadRow = (
31
34
  const buildClient = (
32
35
  messageLabels: MessageLabelItem[],
33
36
  labels: LabelItem[],
37
+ addresses: AddressItem[] = [],
34
38
  ): EnrichClient => ({
35
39
  message: { get: async () => [] as MessageItem[] },
36
- address: { getAddress: async () => [] as AddressItem[] },
40
+ address: { getAddress: async () => addresses },
37
41
  messageLabel: {
38
42
  listByMessageIds: async (messageIds: string[]) =>
39
43
  messageLabels.filter((row) => messageIds.includes(row.messageId)),
@@ -161,3 +165,59 @@ describe("enrichThreadRows — labels", () => {
161
165
  assert.equal(second?.labels, undefined);
162
166
  });
163
167
  });
168
+
169
+ describe("enrichThreadRows — muted", () => {
170
+ const SET_AT = 1_700_000_000_000;
171
+
172
+ test("sets muted true from the batch-fetched Address's flags, no extra query", async () => {
173
+ const fromEmail = "muted@example.com";
174
+ const addressId = deriveAddressId("acc-1", fromEmail);
175
+ const rows = [threadRow("tm-1", "msg-1", fromEmail)];
176
+ const addresses = [
177
+ {
178
+ addressId,
179
+ accountConfigId: "acc-1",
180
+ flags: { muted: { value: true, setAt: SET_AT } },
181
+ },
182
+ ] as unknown as AddressItem[];
183
+
184
+ let addressCalls = 0;
185
+ const client: EnrichClient = {
186
+ message: { get: async () => [] as MessageItem[] },
187
+ address: {
188
+ getAddress: async () => {
189
+ addressCalls += 1;
190
+ return addresses;
191
+ },
192
+ },
193
+ messageLabel: { listByMessageIds: async () => [] },
194
+ label: { listByAccountConfig: async () => [] },
195
+ };
196
+
197
+ const [result] = await enrichThreadRows(rows, client, "acc-1");
198
+ assert.equal(result?.muted, true);
199
+ assert.equal(addressCalls, 1);
200
+ });
201
+
202
+ test("defaults muted to false when the Address has no muted flag", async () => {
203
+ const fromEmail = "not-muted@example.com";
204
+ const addressId = deriveAddressId("acc-1", fromEmail);
205
+ const rows = [threadRow("tm-1", "msg-1", fromEmail)];
206
+ const addresses = [
207
+ { addressId, accountConfigId: "acc-1", flags: {} },
208
+ ] as unknown as AddressItem[];
209
+
210
+ const [result] = await enrichThreadRows(
211
+ rows,
212
+ buildClient([], [], addresses),
213
+ "acc-1",
214
+ );
215
+ assert.equal(result?.muted, false);
216
+ });
217
+
218
+ test("defaults muted to false when no Address row resolves", async () => {
219
+ const rows = [threadRow("tm-1", "msg-1")];
220
+ const [result] = await enrichThreadRows(rows, buildClient([], []), "acc-1");
221
+ assert.equal(result?.muted, false);
222
+ });
223
+ });
@@ -9,6 +9,7 @@ import type {
9
9
  import { deriveAddressId } from "@remit/data-ports/id";
10
10
  import { SenderTrust, StarColor } from "@remit/domain-enums";
11
11
  import { deriveAutoMoved } from "./autoMoved.js";
12
+ import { deriveMuted } from "./deriveMuted.js";
12
13
  import { deriveSenderTrust } from "./senderTrust.js";
13
14
 
14
15
  /**
@@ -54,6 +55,7 @@ const toResponse = (item: ThreadMessageItem): ThreadMessageResponse => ({
54
55
  createdAt: item.createdAt,
55
56
  updatedAt: item.updatedAt,
56
57
  senderTrust: SenderTrust.Unknown,
58
+ muted: false,
57
59
  });
58
60
 
59
61
  /**
@@ -98,20 +100,22 @@ export const planBatchFetch = (rows: ThreadMessageItem[]): BatchPlan => {
98
100
  };
99
101
 
100
102
  /**
101
- * Enrich a page of ThreadMessage rows with `senderTrust` (derived from the From
102
- * Address's flags map), `authenticity` and `autoMoved` (both projected from the
103
- * Message row, see `deriveAutoMoved`).
103
+ * Enrich a page of ThreadMessage rows with `senderTrust` and `muted` (both
104
+ * derived from the From Address's flags map), `authenticity` and `autoMoved`
105
+ * (both projected from the Message row, see `deriveAutoMoved`).
104
106
  *
105
- * `category` is not enriched: it is denormalized onto the ThreadMessage row and
106
- * carried straight through by `toResponse`, so the value a client renders is the
107
- * value the category filter matched.
107
+ * `category` is not enriched: it is denormalized onto the ThreadMessage row
108
+ * (shared with `Message.category`'s write-once value, see body-sync.ts) and
109
+ * carried straight through by `toResponse`, so the value a client renders is
110
+ * the value the category filter matched.
108
111
  *
109
112
  * Two BatchGetItem calls per page, regardless of page size — see
110
113
  * `planBatchFetch` for the dedup contract.
111
114
  *
112
- * Missing rows fall back gracefully: `senderTrust` defaults to `"unknown"`, and
113
- * `authenticity` / `autoMoved` are omitted whenever the Message row is absent or
114
- * the move isn't a real, in-effect auto-move.
115
+ * Missing rows fall back gracefully: `senderTrust` defaults to `"unknown"`,
116
+ * `muted` defaults to `false`, and `authenticity` / `autoMoved` are omitted
117
+ * whenever the Message row is absent or the move isn't a real, in-effect
118
+ * auto-move.
115
119
  *
116
120
  * Not annotated `Promise<ThreadMessageResponse[]>`: `labels` is a new field on
117
121
  * it in this same PR, and that package publishes separately from this repo —
@@ -169,6 +173,9 @@ export const enrichThreadRows = async (
169
173
  const trustByAddressId = new Map(
170
174
  addresses.map((a) => [a.addressId, deriveSenderTrust(a.flags)]),
171
175
  );
176
+ const mutedByAddressId = new Map(
177
+ addresses.map((a) => [a.addressId, deriveMuted(a.flags)]),
178
+ );
172
179
 
173
180
  return rows.map((row) => {
174
181
  const base = toResponse(row);
@@ -178,6 +185,9 @@ export const enrichThreadRows = async (
178
185
  const senderTrust = addressId
179
186
  ? (trustByAddressId.get(addressId) ?? SenderTrust.Unknown)
180
187
  : SenderTrust.Unknown;
188
+ const muted = addressId
189
+ ? (mutedByAddressId.get(addressId) ?? false)
190
+ : false;
181
191
  const labels = labelsByMessageId.get(row.messageId);
182
192
  return {
183
193
  ...base,
@@ -185,6 +195,7 @@ export const enrichThreadRows = async (
185
195
  ...(autoMoved !== undefined ? { autoMoved } : {}),
186
196
  ...(labels !== undefined ? { labels } : {}),
187
197
  senderTrust,
198
+ muted,
188
199
  };
189
200
  });
190
201
  };
@@ -29,6 +29,7 @@ const row = (
29
29
  createdAt: 0,
30
30
  updatedAt: 0,
31
31
  senderTrust: SenderTrust.Unknown,
32
+ muted: false,
32
33
  ...overrides,
33
34
  });
34
35
 
@@ -199,6 +199,7 @@ describe("createFilterWithAnchor (#351)", () => {
199
199
  state: FilterState.Active,
200
200
  hasAnchor: false,
201
201
  ruleChangedAt: 1_700_000_000,
202
+ actionChangedAt: 1_700_000_000,
202
203
  matchOperator: FilterMatchOperator.And,
203
204
  literalClauses: [],
204
205
  actionLabelId: "None",
@@ -187,6 +187,7 @@ const toFilterResponse = (item: FilterItem): FilterResponse => ({
187
187
  state: item.state,
188
188
  hasAnchor: item.hasAnchor,
189
189
  ruleChangedAt: item.ruleChangedAt,
190
+ actionChangedAt: item.actionChangedAt,
190
191
  matchOperator: item.matchOperator,
191
192
  literalClauses: item.literalClauses,
192
193
  actionLabelId: item.actionLabelId,
@@ -79,6 +79,7 @@ const filterItem = (over: Partial<FilterItem> = {}): FilterItem => ({
79
79
  state: FilterState.Active,
80
80
  hasAnchor: false,
81
81
  ruleChangedAt: 0,
82
+ actionChangedAt: 0,
82
83
  matchOperator: FilterMatchOperator.And,
83
84
  literalClauses: [],
84
85
  actionLabelId: "None",
@@ -780,6 +781,7 @@ describe("applyOrganize resolves move precedence against current Active filters
780
781
  const newerFilter = filterItem({
781
782
  filterId: "filter-newer",
782
783
  ruleChangedAt: 1_000,
784
+ actionChangedAt: 1_000,
783
785
  actionMailboxId: "mbox-new",
784
786
  literalClauses: [{ field: "Subject", value: "reservation" }],
785
787
  });
@@ -828,6 +830,7 @@ describe("applyOrganize resolves move precedence against current Active filters
828
830
  const agreeingFilter = filterItem({
829
831
  filterId: "filter-agrees",
830
832
  ruleChangedAt: 1_000,
833
+ actionChangedAt: 1_000,
831
834
  actionMailboxId: "mbox-target",
832
835
  literalClauses: [{ field: "Subject", value: "reservation" }],
833
836
  });
@@ -869,6 +872,7 @@ describe("applyOrganize resolves move precedence against current Active filters
869
872
  const newerSemanticFilter = filterItem({
870
873
  filterId: "filter-newer-semantic",
871
874
  ruleChangedAt: 1_000,
875
+ actionChangedAt: 1_000,
872
876
  actionMailboxId: "mbox-new",
873
877
  hasAnchor: true,
874
878
  });