@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 +1 -1
- package/src/body-sync-category-writeonce.test.ts +228 -0
- package/src/body-sync.ts +26 -6
package/package.json
CHANGED
|
@@ -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);
|