@remit/mailbox-service 0.0.32 → 0.0.34

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/mailbox-service",
3
- "version": "0.0.32",
3
+ "version": "0.0.34",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Issue #302 (RFC 039 Decision 3): `Address.flags.unsubscribed` is
3
+ * documented as "auto-mark-read until sender stops" and settable from
4
+ * `IntelligencePane.tsx`, but nothing consumed it — every message from an
5
+ * unsubscribed sender still arrived unread like any other. These tests drive
6
+ * `BodySyncService` end-to-end (read-path body materialization →
7
+ * `applyPostStoreSteps`) and assert on the actual `FlagQueueService.markAsRead`
8
+ * call, so a regression that drops the flag read — not just a helper in
9
+ * isolation — shows up here.
10
+ */
11
+
12
+ import assert from "node:assert/strict";
13
+ import { describe, it } from "node:test";
14
+ import type {
15
+ AddressItem,
16
+ IAddressRepository,
17
+ IEnvelopeRepository,
18
+ IMessageRepository,
19
+ IThreadMessageRepository,
20
+ } from "@remit/data-ports";
21
+ import type { StorageService } from "@remit/storage-service";
22
+ import { BodySyncService } from "./body-sync.js";
23
+ import type { FlagQueueService } from "./flag-queue.js";
24
+ import type { IImapConnection } from "./types.js";
25
+
26
+ const PLAIN_EML = (fromEmail: string) =>
27
+ Buffer.from(
28
+ [
29
+ `From: Sender <${fromEmail}>`,
30
+ "To: me@example.com",
31
+ "Subject: Hello",
32
+ "Content-Type: text/plain",
33
+ "",
34
+ "body",
35
+ ].join("\r\n"),
36
+ );
37
+
38
+ interface MarkReadCall {
39
+ accountConfigId: string;
40
+ messageId: string;
41
+ accountId: string;
42
+ }
43
+
44
+ interface Harness {
45
+ service: BodySyncService;
46
+ markReadCalls: MarkReadCall[];
47
+ }
48
+
49
+ const buildHarness = (
50
+ flags: AddressItem["flags"],
51
+ withUnsubscribeConfig = true,
52
+ ): Harness => {
53
+ const markReadCalls: MarkReadCall[] = [];
54
+
55
+ const messageService = {
56
+ get: async () => ({
57
+ messageId: "m-1",
58
+ mailboxId: "mb-inbox",
59
+ uid: 1,
60
+ }),
61
+ update: async () => {},
62
+ } as unknown as IMessageRepository;
63
+
64
+ const threadMessageService = {
65
+ findAllByMessageId: async () => [
66
+ {
67
+ threadMessageId: "tm-1",
68
+ sentDate: 1,
69
+ mailboxId: "mb-inbox",
70
+ isRead: false,
71
+ isDeleted: false,
72
+ hasStars: false,
73
+ hasAttachment: false,
74
+ },
75
+ ],
76
+ update: async () => {},
77
+ } as unknown as IThreadMessageRepository;
78
+
79
+ const storageService = {
80
+ storeMessageBody: async () => ({ uri: "s3://bodies/m-1" }),
81
+ storeParsedBody: async () => {},
82
+ listBodyParts: async () => [],
83
+ } as unknown as StorageService;
84
+
85
+ const addressService = {
86
+ getAddress: async () => ({ flags }) as unknown as AddressItem,
87
+ incrementInboundCount: async () => {},
88
+ } as unknown as IAddressRepository;
89
+
90
+ const envelopeService = {
91
+ listBodyParts: async () => [],
92
+ } as unknown as IEnvelopeRepository;
93
+
94
+ const flagQueueService = {
95
+ markAsRead: async (
96
+ accountConfigId: string,
97
+ messageId: string,
98
+ accountId: string,
99
+ ) => {
100
+ markReadCalls.push({ accountConfigId, messageId, accountId });
101
+ },
102
+ } as unknown as FlagQueueService;
103
+
104
+ const service = new BodySyncService(
105
+ messageService,
106
+ storageService,
107
+ threadMessageService,
108
+ addressService,
109
+ envelopeService,
110
+ { info: () => {}, error: () => {} },
111
+ undefined,
112
+ undefined,
113
+ undefined,
114
+ withUnsubscribeConfig ? { flagQueueService } : undefined,
115
+ );
116
+
117
+ return { service, markReadCalls };
118
+ };
119
+
120
+ const readBody = async (
121
+ service: BodySyncService,
122
+ fromEmail = "someone@example.com",
123
+ ) => {
124
+ const connection = {
125
+ openBox: async () => {},
126
+ fetchMessageBody: async () => PLAIN_EML(fromEmail),
127
+ } as unknown as IImapConnection;
128
+ return service.fetchAndGetBody(
129
+ "m-1",
130
+ "acc-1",
131
+ "cfg-1",
132
+ "INBOX",
133
+ async () => connection,
134
+ );
135
+ };
136
+
137
+ describe("Address.flags.unsubscribed drives auto-mark-read (issue #302)", () => {
138
+ it("marks a message from an unsubscribed sender as read at sync time", async () => {
139
+ const harness = buildHarness({ unsubscribed: { value: true, setAt: 1 } });
140
+
141
+ await readBody(harness.service);
142
+
143
+ assert.deepEqual(harness.markReadCalls, [
144
+ { accountConfigId: "cfg-1", messageId: "m-1", accountId: "acc-1" },
145
+ ]);
146
+ });
147
+
148
+ it("leaves read state alone for a sender without the flag", async () => {
149
+ const harness = buildHarness({});
150
+
151
+ await readBody(harness.service);
152
+
153
+ assert.deepEqual(harness.markReadCalls, []);
154
+ });
155
+
156
+ it("leaves read state alone once the flag is unset (no caching of the decision)", async () => {
157
+ const harness = buildHarness({
158
+ unsubscribed: { value: false, setAt: 1 },
159
+ });
160
+
161
+ await readBody(harness.service);
162
+
163
+ assert.deepEqual(harness.markReadCalls, []);
164
+ });
165
+
166
+ it("is a no-op when body sync was built without an UnsubscribeConfig", async () => {
167
+ const harness = buildHarness(
168
+ { unsubscribed: { value: true, setAt: 1 } },
169
+ false,
170
+ );
171
+
172
+ await readBody(harness.service);
173
+
174
+ assert.deepEqual(harness.markReadCalls, []);
175
+ });
176
+ });
package/src/body-sync.ts CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  type FilterDecision,
38
38
  FilterPipeline,
39
39
  } from "./filters/pipeline.js";
40
+ import type { FlagQueueService } from "./flag-queue.js";
40
41
  import {
41
42
  classifyByHeaders,
42
43
  extractAuthenticity,
@@ -257,6 +258,17 @@ export interface QuarantineConfig {
257
258
  attempts: number;
258
259
  }
259
260
 
261
+ /**
262
+ * What body sync needs to auto-mark-read a message from an unsubscribed
263
+ * sender (issue #302, RFC 039 Decision 3). Reuses the same
264
+ * `FlagQueueService.markAsRead` a manual mark-as-read already goes through —
265
+ * local `\Seen` + `ThreadMessage.isRead` + a durable pending IMAP flag-push
266
+ * marker — so this fires the same round-trip, not a second primitive.
267
+ */
268
+ export interface UnsubscribeConfig {
269
+ flagQueueService: FlagQueueService;
270
+ }
271
+
260
272
  export class BodySyncService {
261
273
  private log: BodySyncLogger;
262
274
  private readonly filterPipeline?: FilterPipeline;
@@ -271,6 +283,7 @@ export class BodySyncService {
271
283
  private readonly placementConfig?: PlacementConfig,
272
284
  private readonly filterConfig?: FilterConfig,
273
285
  private readonly quarantineConfig?: QuarantineConfig,
286
+ private readonly unsubscribeConfig?: UnsubscribeConfig,
274
287
  ) {
275
288
  this.log = logger ?? noopLogger;
276
289
  this.filterPipeline = filterConfig
@@ -813,6 +826,18 @@ export class BodySyncService {
813
826
  });
814
827
  }
815
828
 
829
+ // `flags.unsubscribed` (issue #302, RFC 039 Decision 3): auto-mark-read,
830
+ // reusing the same FlagQueueService.markAsRead round-trip a manual
831
+ // mark-as-read goes through — idempotent on a retry (flipFlag no-ops when
832
+ // the message is already \Seen), so a failure here safely re-fires on the
833
+ // next attempt rather than being lost behind the bodyStorageKey skip guard.
834
+ await this.applyUnsubscribedAutoRead(
835
+ messageId,
836
+ accountId,
837
+ accountConfigId,
838
+ parsed,
839
+ );
840
+
816
841
  const moved = Boolean(resolved.move || filterMoved);
817
842
 
818
843
  // ONE Message UpdateItem per synced message: bodyStorageKey + every
@@ -1136,6 +1161,59 @@ export class BodySyncService {
1136
1161
  return unknown;
1137
1162
  }
1138
1163
 
1164
+ /**
1165
+ * `flags.unsubscribed` (issue #302, RFC 039 Decision 3): "auto-mark-read
1166
+ * until sender stops" — fires on every new message from that sender for as
1167
+ * long as the flag stays set, per the flag's own doc comment; there is no
1168
+ * separate expiry mechanism, and no caching of the decision beyond this
1169
+ * per-message `Address` read. A no-op when body sync was built without an
1170
+ * {@link UnsubscribeConfig} or the message carries no `From` address.
1171
+ */
1172
+ private async applyUnsubscribedAutoRead(
1173
+ messageId: string,
1174
+ accountId: string,
1175
+ accountConfigId: string,
1176
+ parsed: ParsedMail,
1177
+ ): Promise<void> {
1178
+ if (!this.unsubscribeConfig) return;
1179
+
1180
+ const fromEmail = extractPrimaryFromEmail(parsed);
1181
+ if (!fromEmail) return;
1182
+
1183
+ const unsubscribed = await this.deriveSenderUnsubscribed(
1184
+ accountConfigId,
1185
+ fromEmail,
1186
+ );
1187
+ if (!unsubscribed) return;
1188
+
1189
+ await this.unsubscribeConfig.flagQueueService.markAsRead(
1190
+ accountConfigId,
1191
+ messageId,
1192
+ accountId,
1193
+ );
1194
+ }
1195
+
1196
+ private async deriveSenderUnsubscribed(
1197
+ accountConfigId: string,
1198
+ fromEmail: string,
1199
+ ): Promise<boolean> {
1200
+ try {
1201
+ const addressId = deriveAddressId(accountConfigId, fromEmail);
1202
+ const address = await this.addressService.getAddress(
1203
+ accountConfigId,
1204
+ addressId,
1205
+ );
1206
+ return address.flags?.unsubscribed?.value === true;
1207
+ } catch (err) {
1208
+ // A genuinely-absent address means "not unsubscribed". Any other
1209
+ // failure (AccessDenied, throttle, infra) must NOT be silently
1210
+ // downgraded — let it crash so the read-state decision isn't made on
1211
+ // bad data.
1212
+ if (!(err instanceof NotFoundError)) throw err;
1213
+ return false;
1214
+ }
1215
+ }
1216
+
1139
1217
  /**
1140
1218
  * Evaluate the account's active filters against a synced message (RFC 034),
1141
1219
  * BEFORE the single Message update — so a filter's `movedByRemit` flag joins
@@ -61,6 +61,7 @@ const buildPipeline = (filter: FilterItem) => {
61
61
  state.embedCalls += 1;
62
62
  return [];
63
63
  },
64
+ embeddingId: "test-model@0",
64
65
  },
65
66
  };
66
67
  return { pipeline: new FilterPipeline(config, { info: () => {} }), state };
@@ -101,3 +102,193 @@ describe("FilterPipeline — anchorless From/Or filter at index time", () => {
101
102
  assert.equal(state.embedCalls, 0);
102
103
  });
103
104
  });
105
+
106
+ describe("FilterPipeline — anchor lazy re-embed on model drift (reader #295)", () => {
107
+ const anchoredFilter = (): FilterItem =>
108
+ ({
109
+ filterId: "flt-semantic",
110
+ accountConfigId: "cfg-1",
111
+ name: "receipts",
112
+ scope: "Standing",
113
+ state: FilterState.Active,
114
+ hasAnchor: true,
115
+ ruleChangedAt: 1,
116
+ matchOperator: FilterMatchOperator.And,
117
+ literalClauses: [],
118
+ actionLabelId: "lbl-1",
119
+ actionMailboxId: NO_ACTION,
120
+ createdAt: 1,
121
+ updatedAt: 1,
122
+ }) as unknown as FilterItem;
123
+
124
+ const message = {
125
+ from: "billing@stripe.com",
126
+ fromName: "Stripe",
127
+ subject: "Your receipt",
128
+ text: "Thanks for your payment",
129
+ listId: "",
130
+ };
131
+
132
+ it("re-embeds a stale anchor and matches on the very next evaluation, persisting the refreshed row", async () => {
133
+ const putCalls: Array<Record<string, unknown>> = [];
134
+ let embedCalls = 0;
135
+ const config: FilterConfig = {
136
+ filterService: {
137
+ listByAccountAndState: async () => [anchoredFilter()],
138
+ refreshExpiry: async (f: FilterItem) => f,
139
+ } as unknown as IFilterRepository,
140
+ filterAnchorService: {
141
+ get: async () => ({
142
+ accountConfigId: "cfg-1",
143
+ filterId: "flt-semantic",
144
+ // Stale-model space — orthogonal to what the current model
145
+ // produces, so a raw comparison against it would not match.
146
+ anchorEmbedding: [0, 1, 0],
147
+ anchorEmbeddingId: "old-model@3",
148
+ anchorSourceText: "your receipt is ready",
149
+ anchorMessageId: "msg-anchor",
150
+ }),
151
+ put: async (input: Record<string, unknown>) => {
152
+ putCalls.push(input);
153
+ return { ...input, createdAt: 1, updatedAt: 2 };
154
+ },
155
+ } as unknown as IFilterAnchorRepository,
156
+ messageLabelService: {} as unknown as IMessageLabelRepository,
157
+ placementMoveService: {} as unknown as PlacementMoveService,
158
+ embedder: {
159
+ embed: async () => {
160
+ embedCalls += 1;
161
+ return [1, 0, 0];
162
+ },
163
+ embeddingId: "new-model@3",
164
+ },
165
+ };
166
+
167
+ const decision = await new FilterPipeline(config, {
168
+ info: () => {},
169
+ }).evaluate("cfg-1", "m-1", message);
170
+
171
+ assert.deepEqual(
172
+ decision.labels,
173
+ [{ labelId: "lbl-1", filterId: "flt-semantic" }],
174
+ "the anchor matches once re-embedded into the current model's space",
175
+ );
176
+ assert.equal(
177
+ putCalls.length,
178
+ 1,
179
+ "the refreshed anchor is written back exactly once",
180
+ );
181
+ assert.deepEqual(putCalls[0], {
182
+ accountConfigId: "cfg-1",
183
+ filterId: "flt-semantic",
184
+ anchorEmbedding: [1, 0, 0],
185
+ anchorEmbeddingId: "new-model@3",
186
+ anchorSourceText: "your receipt is ready",
187
+ anchorMessageId: "msg-anchor",
188
+ });
189
+ assert.equal(
190
+ embedCalls,
191
+ 2,
192
+ "one embed re-embeds the anchor's source text, one embeds the candidate message",
193
+ );
194
+ });
195
+
196
+ it("never re-embeds when the anchor's embeddingId is already current", async () => {
197
+ let putCalls = 0;
198
+ let embedCalls = 0;
199
+ const config: FilterConfig = {
200
+ filterService: {
201
+ listByAccountAndState: async () => [anchoredFilter()],
202
+ refreshExpiry: async (f: FilterItem) => f,
203
+ } as unknown as IFilterRepository,
204
+ filterAnchorService: {
205
+ get: async () => ({
206
+ anchorEmbedding: [1, 0, 0],
207
+ anchorEmbeddingId: "current-model@3",
208
+ anchorSourceText: "your receipt is ready",
209
+ anchorMessageId: "msg-anchor",
210
+ }),
211
+ put: async () => {
212
+ putCalls += 1;
213
+ throw new Error("must not be called");
214
+ },
215
+ } as unknown as IFilterAnchorRepository,
216
+ messageLabelService: {} as unknown as IMessageLabelRepository,
217
+ placementMoveService: {} as unknown as PlacementMoveService,
218
+ embedder: {
219
+ embed: async () => {
220
+ embedCalls += 1;
221
+ return [1, 0, 0];
222
+ },
223
+ embeddingId: "current-model@3",
224
+ },
225
+ };
226
+
227
+ const decision = await new FilterPipeline(config, {
228
+ info: () => {},
229
+ }).evaluate("cfg-1", "m-1", message);
230
+
231
+ assert.deepEqual(decision.labels, [
232
+ { labelId: "lbl-1", filterId: "flt-semantic" },
233
+ ]);
234
+ assert.equal(putCalls, 0, "a current anchor is never rewritten");
235
+ assert.equal(
236
+ embedCalls,
237
+ 1,
238
+ "only the candidate message is embedded — no wasted re-embed call",
239
+ );
240
+ });
241
+
242
+ it("degrades a re-embed failure to skipping just this filter, not the whole pass", async () => {
243
+ const literalFilter: FilterItem = {
244
+ filterId: "flt-literal",
245
+ accountConfigId: "cfg-1",
246
+ name: "stripe receipts",
247
+ scope: "Standing",
248
+ state: FilterState.Active,
249
+ hasAnchor: false,
250
+ ruleChangedAt: 1,
251
+ matchOperator: FilterMatchOperator.Or,
252
+ literalClauses: [{ field: FilterClauseField.From, value: "stripe.com" }],
253
+ actionLabelId: "lbl-literal",
254
+ actionMailboxId: NO_ACTION,
255
+ createdAt: 1,
256
+ updatedAt: 1,
257
+ } as unknown as FilterItem;
258
+
259
+ const config: FilterConfig = {
260
+ filterService: {
261
+ listByAccountAndState: async () => [anchoredFilter(), literalFilter],
262
+ refreshExpiry: async (f: FilterItem) => f,
263
+ } as unknown as IFilterRepository,
264
+ filterAnchorService: {
265
+ get: async () => ({
266
+ anchorEmbedding: [0, 1, 0],
267
+ anchorEmbeddingId: "old-model@3",
268
+ anchorSourceText: "your receipt is ready",
269
+ anchorMessageId: "msg-anchor",
270
+ }),
271
+ put: async () => {
272
+ throw new Error("SQLITE_BUSY");
273
+ },
274
+ } as unknown as IFilterAnchorRepository,
275
+ messageLabelService: {} as unknown as IMessageLabelRepository,
276
+ placementMoveService: {} as unknown as PlacementMoveService,
277
+ embedder: {
278
+ embed: async () => [1, 0, 0],
279
+ embeddingId: "new-model@3",
280
+ },
281
+ };
282
+
283
+ const decision = await new FilterPipeline(config, {
284
+ info: () => {},
285
+ error: () => {},
286
+ }).evaluate("cfg-1", "m-1", message);
287
+
288
+ assert.deepEqual(
289
+ decision.labels,
290
+ [{ labelId: "lbl-literal", filterId: "flt-literal" }],
291
+ "the anchored filter's re-embed failure is isolated — the literal filter still applies",
292
+ );
293
+ });
294
+ });
@@ -26,6 +26,15 @@ import {
26
26
  */
27
27
  export interface MessageEmbedder {
28
28
  embed(text: string): Promise<number[]>;
29
+ /**
30
+ * `<modelId>@<dimensions>` identifier of the model currently configured —
31
+ * the same scheme `EmbeddingService.embeddingId`
32
+ * (`packages/search-service/src/embeddings.ts`) derives. Compared against
33
+ * `FilterAnchor.anchorEmbeddingId` to detect a model drift a same-dimension
34
+ * swap would otherwise pass through silently (RFC 039 Decision 1a, reader
35
+ * #295).
36
+ */
37
+ readonly embeddingId: string;
29
38
  }
30
39
 
31
40
  export interface FilterLogger {
@@ -215,7 +224,7 @@ export class FilterPipeline {
215
224
  // One deterministic point read per semantic candidate that survived the
216
225
  // literal pre-filter — never a scan, never the anchor message itself
217
226
  // (RFC 034 Decision 2.3).
218
- const anchor = await this.config.filterAnchorService.get(
227
+ let anchor = await this.config.filterAnchorService.get(
219
228
  accountConfigId,
220
229
  filter.filterId,
221
230
  );
@@ -227,6 +236,27 @@ export class FilterPipeline {
227
236
  return false;
228
237
  }
229
238
 
239
+ const embedder = this.config.embedder;
240
+ if (embedder && anchor.anchorEmbeddingId !== embedder.embeddingId) {
241
+ // The embedding model has drifted since this anchor was last written
242
+ // (RFC 039 Decision 1a) — re-embed the already-persisted
243
+ // `anchorSourceText` and write it back in place, lazily, on this read.
244
+ // No migration job walks these rows proactively; a re-embed failure
245
+ // here propagates to the per-filter catch in `match()`, which logs and
246
+ // skips this filter for this one evaluation exactly as an unrecoverable
247
+ // stale anchor does today — never a terminal state, so the next
248
+ // message that reaches this filter retries.
249
+ const anchorEmbedding = await embedder.embed(anchor.anchorSourceText);
250
+ anchor = await this.config.filterAnchorService.put({
251
+ accountConfigId,
252
+ filterId: filter.filterId,
253
+ anchorEmbedding,
254
+ anchorEmbeddingId: embedder.embeddingId,
255
+ anchorSourceText: anchor.anchorSourceText,
256
+ anchorMessageId: anchor.anchorMessageId,
257
+ });
258
+ }
259
+
230
260
  const vector = await embed();
231
261
  if (!vector) {
232
262
  this.log.debug?.(