@remit/mailbox-service 0.0.42 → 0.0.43

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.42",
3
+ "version": "0.0.43",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Issue #499: `Address.flags.unsubscribed` auto-mark-read is a first-
3
+ * classification decision, like `Message.category` (#355) and the placement
4
+ * verdict (#383). Re-deriving it on the two shipped re-entrant paths —
5
+ * `fetchAndGetBody`'s `NoSuchKey` fallback and `syncBodies(..., force: true)`
6
+ * — undid a user who had deliberately marked such a message unread.
7
+ *
8
+ * The re-entrant fixtures below keep `flags.unsubscribed` set, so a regression
9
+ * that drops the guard shows up as a real `FlagQueueService.markAsRead`
10
+ * round-trip against a message the user owns, not as a fixture that happens to
11
+ * disagree with the flag.
12
+ */
13
+
14
+ import assert from "node:assert/strict";
15
+ import { Readable } from "node:stream";
16
+ import { describe, it } from "node:test";
17
+ import type {
18
+ AddressItem,
19
+ IAddressRepository,
20
+ IEnvelopeRepository,
21
+ IMessageRepository,
22
+ IThreadMessageRepository,
23
+ MessageItem,
24
+ UpdateMessageInput,
25
+ } from "@remit/data-ports";
26
+ import type { StorageService } from "@remit/storage-service";
27
+ import { BodySyncService } from "./body-sync.js";
28
+ import type { FlagQueueService } from "./flag-queue.js";
29
+ import type { IImapConnection } from "./types.js";
30
+
31
+ const UNSUBSCRIBED_SENDER = "newsletter@example.com";
32
+
33
+ const PLAIN_EML = Buffer.from(
34
+ [
35
+ `From: Newsletter <${UNSUBSCRIBED_SENDER}>`,
36
+ "To: me@example.com",
37
+ "Subject: This week",
38
+ "List-Unsubscribe: <mailto:stop@example.com>",
39
+ "Content-Type: text/plain",
40
+ "",
41
+ "body",
42
+ ].join("\r\n"),
43
+ );
44
+
45
+ interface MarkReadCall {
46
+ accountConfigId: string;
47
+ messageId: string;
48
+ accountId: string;
49
+ }
50
+
51
+ interface Harness {
52
+ service: BodySyncService;
53
+ markReadCalls: MarkReadCall[];
54
+ }
55
+
56
+ const buildHarness = (
57
+ message: Partial<MessageItem> & Pick<MessageItem, "messageId">,
58
+ retrieve: () => Promise<Buffer>,
59
+ ): Harness => {
60
+ const markReadCalls: MarkReadCall[] = [];
61
+
62
+ const messageRow = {
63
+ uid: 1,
64
+ mailboxId: "mb-inbox",
65
+ ...message,
66
+ } as unknown as MessageItem;
67
+
68
+ const messageService = {
69
+ get: async () => messageRow,
70
+ update: async (_messageId: string, input: UpdateMessageInput) => {
71
+ Object.assign(messageRow, input);
72
+ },
73
+ } as unknown as IMessageRepository;
74
+
75
+ const threadMessageService = {
76
+ findAllByMessageId: async () => [
77
+ {
78
+ threadMessageId: "tm-1",
79
+ messageId: message.messageId,
80
+ mailboxId: messageRow.mailboxId,
81
+ sentDate: 1,
82
+ isRead: false,
83
+ isDeleted: false,
84
+ hasStars: false,
85
+ hasAttachment: false,
86
+ },
87
+ ],
88
+ update: async () => {},
89
+ } as unknown as IThreadMessageRepository;
90
+
91
+ const storageService = {
92
+ retrieve,
93
+ storeMessageBody: async () => ({ uri: `s3://bodies/${message.messageId}` }),
94
+ storeMessageBodyStream: async () => ({
95
+ uri: `s3://bodies/${message.messageId}`,
96
+ }),
97
+ storeParsedBody: async () => {},
98
+ listBodyParts: async () => [],
99
+ } as unknown as StorageService;
100
+
101
+ const addressService = {
102
+ getAddress: async () =>
103
+ ({
104
+ flags: { unsubscribed: { value: true, setAt: 1 } },
105
+ }) as unknown as AddressItem,
106
+ incrementInboundCount: async () => {},
107
+ } as unknown as IAddressRepository;
108
+
109
+ const envelopeService = {
110
+ listBodyParts: async () => [],
111
+ } as unknown as IEnvelopeRepository;
112
+
113
+ const flagQueueService = {
114
+ markAsRead: async (
115
+ accountConfigId: string,
116
+ messageId: string,
117
+ accountId: string,
118
+ ) => {
119
+ markReadCalls.push({ accountConfigId, messageId, accountId });
120
+ },
121
+ } as unknown as FlagQueueService;
122
+
123
+ const service = new BodySyncService(
124
+ messageService,
125
+ storageService,
126
+ threadMessageService,
127
+ addressService,
128
+ envelopeService,
129
+ { info: () => {}, error: () => {} },
130
+ undefined,
131
+ undefined,
132
+ undefined,
133
+ { flagQueueService },
134
+ );
135
+
136
+ return { service, markReadCalls };
137
+ };
138
+
139
+ const noSuchKeyError = () =>
140
+ Object.assign(new Error("missing"), { name: "NoSuchKey" });
141
+
142
+ const bodyConnection = () =>
143
+ ({
144
+ openBox: async () => {},
145
+ fetchMessageBody: async () => PLAIN_EML,
146
+ async *fetchMessageBodies(uids: number[]) {
147
+ for (const uid of uids) {
148
+ yield { uid, source: Readable.from([PLAIN_EML]) };
149
+ }
150
+ },
151
+ }) as unknown as IImapConnection;
152
+
153
+ describe("unsubscribed auto-mark-read is decided once (issue #499)", () => {
154
+ it("marks a message read on the pass that first classifies it", async () => {
155
+ const harness = buildHarness({ messageId: "m-1" }, async () => {
156
+ throw new Error("no body stored yet; must not retrieve");
157
+ });
158
+
159
+ await harness.service.fetchAndGetBody(
160
+ "m-1",
161
+ "acc-1",
162
+ "cfg-1",
163
+ "INBOX",
164
+ async () => bodyConnection(),
165
+ );
166
+
167
+ assert.deepEqual(harness.markReadCalls, [
168
+ { accountConfigId: "cfg-1", messageId: "m-1", accountId: "acc-1" },
169
+ ]);
170
+ });
171
+
172
+ it("leaves a manually-unread message alone through the NoSuchKey IMAP re-fetch", async () => {
173
+ const harness = buildHarness(
174
+ { messageId: "m-1", bodyStorageKey: "s3://bodies/m-1" },
175
+ async () => {
176
+ throw noSuchKeyError();
177
+ },
178
+ );
179
+
180
+ await harness.service.fetchAndGetBody(
181
+ "m-1",
182
+ "acc-1",
183
+ "cfg-1",
184
+ "INBOX",
185
+ async () => bodyConnection(),
186
+ );
187
+
188
+ assert.deepEqual(
189
+ harness.markReadCalls,
190
+ [],
191
+ "a re-entrant pass must not re-apply auto-read over the user's unread",
192
+ );
193
+ });
194
+
195
+ it("leaves a manually-unread message alone when syncBodies re-fetches with force", async () => {
196
+ const harness = buildHarness(
197
+ { messageId: "m-1", bodyStorageKey: "s3://bodies/m-1" },
198
+ async () => {
199
+ throw new Error("force path must not retrieve from storage");
200
+ },
201
+ );
202
+
203
+ const result = await harness.service.syncBodies(
204
+ ["m-1"],
205
+ "acc-1",
206
+ "cfg-1",
207
+ "INBOX",
208
+ async () => bodyConnection(),
209
+ true,
210
+ );
211
+
212
+ assert.deepEqual(result.syncedMessageIds, ["m-1"]);
213
+ assert.deepEqual(
214
+ harness.markReadCalls,
215
+ [],
216
+ "a forced re-sync must not re-apply auto-read over the user's unread",
217
+ );
218
+ });
219
+ });
package/src/body-sync.ts CHANGED
@@ -200,6 +200,18 @@ const hasDecidedCategory = (
200
200
  const hasDecidedPlacement = (placementDecidedAt: number | undefined): boolean =>
201
201
  placementDecidedAt !== undefined;
202
202
 
203
+ /**
204
+ * Issue #499: whether a completed body-sync pass has already classified this
205
+ * message. `bodyStorageKey` is written last, once every derivation of that pass
206
+ * has run, so its presence means the pass that first classified the message
207
+ * finished. Reads it exactly as `syncBodies`' own skip guard does. The same two
208
+ * re-entrant paths `hasDecidedCategory` and `hasDecidedPlacement` guard
209
+ * (`fetchAndGetBody`'s `NoSuchKey` fallback, `syncBodies(..., force: true)`) are
210
+ * the ones that reach the derivations again with it already set.
211
+ */
212
+ const hasClassifiedBody = (bodyStorageKey: string | undefined): boolean =>
213
+ Boolean(bodyStorageKey);
214
+
203
215
  /**
204
216
  * Issue #398: `flags.autoArchive` is a filing preference relative to the Inbox.
205
217
  * A `leave` verdict reaches it for two unrelated reasons and only one of them
@@ -869,6 +881,12 @@ export class BodySyncService {
869
881
  });
870
882
  }
871
883
 
884
+ // Read once for the two write-once guards below — the auto-read decision
885
+ // and the category carry-forward. Taken here rather than earlier because
886
+ // nothing between this point and the Message update writes either field;
887
+ // the moves above touch `mailboxId` only.
888
+ const existingMessage = await this.messageService.get(messageId);
889
+
872
890
  // `flags.unsubscribed` (issue #302, RFC 039 Decision 3): auto-mark-read,
873
891
  // reusing the same FlagQueueService.markAsRead round-trip a manual
874
892
  // mark-as-read goes through — idempotent on a retry (flipFlag no-ops when
@@ -879,6 +897,7 @@ export class BodySyncService {
879
897
  accountId,
880
898
  accountConfigId,
881
899
  parsed,
900
+ existingMessage.bodyStorageKey,
882
901
  );
883
902
 
884
903
  const moved = Boolean(resolved.move || filterMoved);
@@ -907,7 +926,6 @@ export class BodySyncService {
907
926
  // re-entrant paths for placement: `computePlacement` already declined to
908
927
  // recompute a verdict once this field is set, so it is only ever present
909
928
  // here on the pass that first decided it.
910
- const existingMessage = await this.messageService.get(messageId);
911
929
  const finalCategory = hasDecidedCategory(existingMessage.category)
912
930
  ? existingMessage.category
913
931
  : classification.category;
@@ -1284,14 +1302,22 @@ export class BodySyncService {
1284
1302
  * separate expiry mechanism, and no caching of the decision beyond this
1285
1303
  * per-message `Address` read. A no-op when body sync was built without an
1286
1304
  * {@link UnsubscribeConfig} or the message carries no `From` address.
1305
+ *
1306
+ * Write-once per message (issue #499), like the `category` and placement
1307
+ * derivations alongside it: read state is applied on the pass that first
1308
+ * classifies a message and never re-decided. A user who marks such a message
1309
+ * unread afterwards owns it — a re-entrant pass over an already-classified
1310
+ * body would otherwise silently undo that.
1287
1311
  */
1288
1312
  private async applyUnsubscribedAutoRead(
1289
1313
  messageId: string,
1290
1314
  accountId: string,
1291
1315
  accountConfigId: string,
1292
1316
  parsed: ParsedMail,
1317
+ storedBodyKey: string | undefined,
1293
1318
  ): Promise<void> {
1294
1319
  if (!this.unsubscribeConfig) return;
1320
+ if (hasClassifiedBody(storedBodyKey)) return;
1295
1321
 
1296
1322
  const fromEmail = extractPrimaryFromEmail(parsed);
1297
1323
  if (!fromEmail) return;