@remit/mailbox-service 0.0.30 → 0.0.31

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.30",
3
+ "version": "0.0.31",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -0,0 +1,228 @@
1
+ /**
2
+ * RFC 034 Decision 3.1: `Message.category` is written once and never mutated
3
+ * after — RFC 030's message-list GSI sort key depends on it never churning.
4
+ *
5
+ * `applyPostStoreSteps` computes a fresh classification on every pass and
6
+ * folds it into the single Message update, with no check for whether the
7
+ * message already carries a decided category. Two shipped paths re-enter it
8
+ * on an already-classified message — the `NoSuchKey` fallback in
9
+ * `fetchAndGetBody`, and `syncBodies(..., force: true)` — and both must leave
10
+ * `category` untouched. Each test below feeds a re-entrant pass a body that
11
+ * would classify differently from the message's existing category, so a
12
+ * regression that drops the guard shows up even though header classification
13
+ * is otherwise deterministic (issue #355).
14
+ */
15
+
16
+ import assert from "node:assert/strict";
17
+ import { Readable } from "node:stream";
18
+ import { describe, it } from "node:test";
19
+ import type {
20
+ IAddressRepository,
21
+ IEnvelopeRepository,
22
+ IMessageRepository,
23
+ IThreadMessageRepository,
24
+ MessageItem,
25
+ UpdateMessageInput,
26
+ } from "@remit/data-ports";
27
+ import { MessageCategory } from "@remit/domain-enums";
28
+ import type { StorageService } from "@remit/storage-service";
29
+ import { BodySyncService } from "./body-sync.js";
30
+ import type { IImapConnection } from "./types.js";
31
+
32
+ const LINKEDIN_EML = Buffer.from(
33
+ [
34
+ "From: LinkedIn <messages-noreply@linkedin.com>",
35
+ "To: me@example.com",
36
+ "Subject: You have a new invitation",
37
+ "Content-Type: text/plain",
38
+ "",
39
+ "invitation",
40
+ ].join("\r\n"),
41
+ );
42
+
43
+ const PERSONAL_EML = Buffer.from(
44
+ [
45
+ "From: Alex <alex@example.com>",
46
+ "To: me@example.com",
47
+ "Subject: Dinner Friday?",
48
+ "Content-Type: text/plain",
49
+ "",
50
+ "Are you free Friday night?",
51
+ ].join("\r\n"),
52
+ );
53
+
54
+ interface Harness {
55
+ service: BodySyncService;
56
+ message: MessageItem;
57
+ messageUpdates: Array<{ messageId: string; input: UpdateMessageInput }>;
58
+ }
59
+
60
+ const buildHarness = (
61
+ message: Partial<MessageItem> & Pick<MessageItem, "messageId">,
62
+ retrieve: () => Promise<Buffer>,
63
+ ): Harness => {
64
+ const messageUpdates: Array<{
65
+ messageId: string;
66
+ input: UpdateMessageInput;
67
+ }> = [];
68
+
69
+ const messageRow = {
70
+ uid: 1,
71
+ mailboxId: "mb-1",
72
+ ...message,
73
+ } as unknown as MessageItem;
74
+
75
+ const messageService = {
76
+ get: async () => messageRow,
77
+ update: async (messageId: string, input: UpdateMessageInput) => {
78
+ messageUpdates.push({ messageId, input });
79
+ Object.assign(messageRow, input);
80
+ },
81
+ } as unknown as IMessageRepository;
82
+
83
+ const threadMessageService = {
84
+ findAllByMessageId: async () => [
85
+ {
86
+ threadMessageId: "tm-1",
87
+ messageId: message.messageId,
88
+ mailboxId: "mb-1",
89
+ sentDate: 1,
90
+ isRead: false,
91
+ isDeleted: false,
92
+ hasStars: false,
93
+ hasAttachment: false,
94
+ category: MessageCategory.uncategorized,
95
+ },
96
+ ],
97
+ update: async () => {},
98
+ } as unknown as IThreadMessageRepository;
99
+
100
+ const storageService = {
101
+ retrieve,
102
+ storeMessageBody: async () => ({ uri: `s3://bodies/${message.messageId}` }),
103
+ storeMessageBodyStream: async () => ({
104
+ uri: `s3://bodies/${message.messageId}`,
105
+ }),
106
+ storeParsedBody: async () => {},
107
+ listBodyParts: async () => [],
108
+ } as unknown as StorageService;
109
+
110
+ const service = new BodySyncService(
111
+ messageService,
112
+ storageService,
113
+ threadMessageService,
114
+ { incrementInboundCount: async () => {} } as unknown as IAddressRepository,
115
+ { listBodyParts: async () => [] } as unknown as IEnvelopeRepository,
116
+ { info: () => {}, error: () => {} },
117
+ );
118
+
119
+ return { service, message: messageRow, messageUpdates };
120
+ };
121
+
122
+ const noSuchKeyError = () =>
123
+ Object.assign(new Error("missing"), {
124
+ name: "NoSuchKey",
125
+ });
126
+
127
+ describe("Message.category survives a re-entrant classification pass", () => {
128
+ it("keeps the existing category through the NoSuchKey IMAP re-fetch", async () => {
129
+ const harness = buildHarness(
130
+ {
131
+ messageId: "m-1",
132
+ bodyStorageKey: "s3://bodies/m-1",
133
+ category: MessageCategory.marketing,
134
+ },
135
+ async () => {
136
+ throw noSuchKeyError();
137
+ },
138
+ );
139
+
140
+ const connection = {
141
+ openBox: async () => {},
142
+ fetchMessageBody: async () => LINKEDIN_EML,
143
+ } as unknown as IImapConnection;
144
+
145
+ await harness.service.fetchAndGetBody(
146
+ "m-1",
147
+ "acc-1",
148
+ "cfg-1",
149
+ "INBOX",
150
+ async () => connection,
151
+ );
152
+
153
+ assert.equal(harness.messageUpdates.length, 1);
154
+ assert.equal(
155
+ harness.messageUpdates[0].input.category,
156
+ MessageCategory.marketing,
157
+ );
158
+ assert.equal(harness.message.category, MessageCategory.marketing);
159
+ });
160
+
161
+ it("keeps the existing category when syncBodies re-fetches with force", async () => {
162
+ const harness = buildHarness(
163
+ {
164
+ messageId: "m-1",
165
+ bodyStorageKey: "s3://bodies/m-1",
166
+ category: MessageCategory.marketing,
167
+ },
168
+ async () => {
169
+ throw new Error("force path must not retrieve from storage");
170
+ },
171
+ );
172
+
173
+ const connection = {
174
+ openBox: async () => {},
175
+ async *fetchMessageBodies(uids: number[]) {
176
+ for (const uid of uids) {
177
+ yield { uid, source: Readable.from([LINKEDIN_EML]) };
178
+ }
179
+ },
180
+ } as unknown as IImapConnection;
181
+
182
+ const result = await harness.service.syncBodies(
183
+ ["m-1"],
184
+ "acc-1",
185
+ "cfg-1",
186
+ "INBOX",
187
+ async () => connection,
188
+ true,
189
+ );
190
+
191
+ assert.deepEqual(result.syncedMessageIds, ["m-1"]);
192
+ assert.equal(harness.messageUpdates.length, 1);
193
+ assert.equal(
194
+ harness.messageUpdates[0].input.category,
195
+ MessageCategory.marketing,
196
+ );
197
+ assert.equal(harness.message.category, MessageCategory.marketing);
198
+ });
199
+
200
+ it("still writes the category on a first classification", async () => {
201
+ const harness = buildHarness(
202
+ { messageId: "m-1", category: MessageCategory.uncategorized },
203
+ async () => {
204
+ throw new Error("no body stored yet; must not retrieve");
205
+ },
206
+ );
207
+
208
+ const connection = {
209
+ openBox: async () => {},
210
+ fetchMessageBody: async () => PERSONAL_EML,
211
+ } as unknown as IImapConnection;
212
+
213
+ await harness.service.fetchAndGetBody(
214
+ "m-1",
215
+ "acc-1",
216
+ "cfg-1",
217
+ "INBOX",
218
+ async () => connection,
219
+ );
220
+
221
+ assert.equal(harness.messageUpdates.length, 1);
222
+ assert.equal(
223
+ harness.messageUpdates[0].input.category,
224
+ MessageCategory.personal,
225
+ );
226
+ assert.equal(harness.message.category, MessageCategory.personal);
227
+ });
228
+ });
package/src/body-sync.ts CHANGED
@@ -169,6 +169,20 @@ const alreadyDenormalized = (
169
169
  (update.snippet === undefined || row.snippet === update.snippet) &&
170
170
  (update.listId === undefined || row.listId === update.listId);
171
171
 
172
+ /**
173
+ * RFC 034 Decision 3.1: `Message.category` is written once and never mutated
174
+ * after — RFC 030's message-list GSI sort key depends on it never churning.
175
+ * "Already decided" is any real category; `uncategorized` and the field being
176
+ * absent (rows written before the column existed) both mean "not yet decided"
177
+ * and must still classify. The one rule every re-entrant classification path
178
+ * shares — {@link BodySyncService.backfillClassification} and
179
+ * {@link BodySyncService.applyPostStoreSteps} both defer to it.
180
+ */
181
+ const hasDecidedCategory = (
182
+ category: ThreadMessageCategory | undefined,
183
+ ): boolean =>
184
+ category !== undefined && category !== MessageCategory.uncategorized;
185
+
172
186
  export const toParsedBody = (parsed: ParsedMail): ParsedBody => ({
173
187
  text: parsed.text ?? null,
174
188
  html: typeof parsed.html === "string" ? parsed.html : null,
@@ -808,9 +822,20 @@ export class BodySyncService {
808
822
  // a filter); the verdict is folded in whenever Remit decided to act.
809
823
  // Written LAST so bodyStorageKey — the skip-guard signal — is only durable
810
824
  // once the parsed cache AND the move (when any) are.
825
+ //
826
+ // `category` is RFC 034 D3.1's write-once field (RFC 030's message-list
827
+ // GSI sort key depends on it never churning). This step re-enters on an
828
+ // already-classified message through two shipped paths — the `NoSuchKey`
829
+ // fallback in `fetchAndGetBody` and `syncBodies(..., force: true)` — so a
830
+ // real, previously-decided category is carried forward unchanged instead
831
+ // of the just-recomputed one, the same rule `backfillClassification` uses.
832
+ const existingMessage = await this.messageService.get(messageId);
811
833
  const update: UpdateMessageInput = {
812
834
  bodyStorageKey: bodyRef.uri,
813
835
  ...classification,
836
+ category: hasDecidedCategory(existingMessage.category)
837
+ ? existingMessage.category
838
+ : classification.category,
814
839
  ...(moved ? { movedByRemit: true } : {}),
815
840
  ...(resolved.verdict ? { placementVerdict: resolved.verdict } : {}),
816
841
  ...(filterMove ? { filterMove } : {}),
@@ -962,12 +987,7 @@ export class BodySyncService {
962
987
  accountConfigId: string,
963
988
  ): Promise<void> {
964
989
  if (!message.bodyStorageKey) return;
965
- if (
966
- message.category !== undefined &&
967
- message.category !== MessageCategory.uncategorized
968
- ) {
969
- return;
970
- }
990
+ if (hasDecidedCategory(message.category)) return;
971
991
 
972
992
  const body = await this.storageService.retrieve(message.bodyStorageKey);
973
993
  const parsed = await parseMessageBody(body);