@remit/mailbox-service 0.0.98 → 0.0.99
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
|
@@ -381,10 +381,8 @@ describe("Address.flags.category overrides classification at sync time (issue #2
|
|
|
381
381
|
);
|
|
382
382
|
|
|
383
383
|
assert.equal(harness.messageUpdates.length, 1);
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
MessageCategory.marketing,
|
|
387
|
-
);
|
|
384
|
+
// Re-store writes only bodyStorageKey — category untouched on row + thread.
|
|
385
|
+
assert.equal(harness.messageUpdates[0].input.category, undefined);
|
|
388
386
|
assert.equal(harness.message.category, MessageCategory.marketing);
|
|
389
387
|
assert.equal(harness.rows[0].category, MessageCategory.marketing);
|
|
390
388
|
});
|
|
@@ -422,10 +420,8 @@ describe("Address.flags.category overrides classification at sync time (issue #2
|
|
|
422
420
|
|
|
423
421
|
assert.deepEqual(result.syncedMessageIds, ["m-1"]);
|
|
424
422
|
assert.equal(harness.messageUpdates.length, 1);
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
MessageCategory.marketing,
|
|
428
|
-
);
|
|
423
|
+
// Re-store writes only bodyStorageKey — category untouched on row + thread.
|
|
424
|
+
assert.equal(harness.messageUpdates[0].input.category, undefined);
|
|
429
425
|
assert.equal(harness.message.category, MessageCategory.marketing);
|
|
430
426
|
assert.equal(harness.rows[0].category, MessageCategory.marketing);
|
|
431
427
|
});
|
|
@@ -2,15 +2,13 @@
|
|
|
2
2
|
* RFC 034 Decision 3.1: `Message.category` is written once and never mutated
|
|
3
3
|
* after — RFC 030's message-list GSI sort key depends on it never churning.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* regression that drops the guard shows up even though header classification
|
|
13
|
-
* is otherwise deterministic (issue #355).
|
|
5
|
+
* The re-entrant paths — `fetchAndGetBody`'s `NoSuchKey` fallback and
|
|
6
|
+
* `syncBodies(..., force: true)` — are now body-only re-stores (issue #1011):
|
|
7
|
+
* `applyPostStoreSteps(isReStore: true)` skips the entire decision pass and
|
|
8
|
+
* writes only `bodyStorageKey`, so the existing category on the row is never
|
|
9
|
+
* touched. Each test below feeds a re-store a body that would classify
|
|
10
|
+
* differently, so a regression that drops the isReStore skip shows up as the
|
|
11
|
+
* category field appearing in the update input (or changing on the row).
|
|
14
12
|
*/
|
|
15
13
|
|
|
16
14
|
import assert from "node:assert/strict";
|
|
@@ -157,10 +155,11 @@ describe("Message.category survives a re-entrant classification pass", () => {
|
|
|
157
155
|
);
|
|
158
156
|
|
|
159
157
|
assert.equal(harness.messageUpdates.length, 1);
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
158
|
+
// Re-store writes only bodyStorageKey — category is untouched on the row.
|
|
159
|
+
assert.equal(harness.messageUpdates[0].input.category, undefined);
|
|
160
|
+
assert.deepEqual(Object.keys(harness.messageUpdates[0].input).sort(), [
|
|
161
|
+
"bodyStorageKey",
|
|
162
|
+
]);
|
|
164
163
|
assert.equal(harness.message.category, MessageCategory.marketing);
|
|
165
164
|
});
|
|
166
165
|
|
|
@@ -196,10 +195,11 @@ describe("Message.category survives a re-entrant classification pass", () => {
|
|
|
196
195
|
|
|
197
196
|
assert.deepEqual(result.syncedMessageIds, ["m-1"]);
|
|
198
197
|
assert.equal(harness.messageUpdates.length, 1);
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
198
|
+
// Re-store writes only bodyStorageKey — category is untouched on the row.
|
|
199
|
+
assert.equal(harness.messageUpdates[0].input.category, undefined);
|
|
200
|
+
assert.deepEqual(Object.keys(harness.messageUpdates[0].input).sort(), [
|
|
201
|
+
"bodyStorageKey",
|
|
202
|
+
]);
|
|
203
203
|
assert.equal(harness.message.category, MessageCategory.marketing);
|
|
204
204
|
});
|
|
205
205
|
|
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* RFC 039 Non-goals /
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* `
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* junked it in the first place, and can be silently moved right back.
|
|
2
|
+
* RFC 039 Non-goals / issues #383, #1011: placement is meant to run once per
|
|
3
|
+
* message. The two re-entrant paths — `fetchAndGetBody`'s `NoSuchKey` fallback
|
|
4
|
+
* and `syncBodies(..., force: true)` — are now marked as re-stores by
|
|
5
|
+
* `applyPostStoreSteps(isReStore: true)`, which skips the entire decision pass
|
|
6
|
+
* (placement, filters, classification) and writes only `bodyStorageKey`. This
|
|
7
|
+
* replaces the old per-field guards (`hasDecidedPlacement`,
|
|
8
|
+
* `hasDecidedCategory`, `hasClassifiedBody`) that each individual derivation
|
|
9
|
+
* carried.
|
|
11
10
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* heuristics happening to agree.
|
|
11
|
+
* The last test below previously asserted that a legacy row (movedByRemit: true,
|
|
12
|
+
* no placementDecidedAt) gets backfilled with placementDecidedAt on re-enter —
|
|
13
|
+
* that behavior is gone by design: re-stores never re-decide placement, so a
|
|
14
|
+
* pre-#1011 row that never got placementDecidedAt simply keeps not having it
|
|
15
|
+
* (and never gets moved by Remit either).
|
|
18
16
|
*/
|
|
19
17
|
|
|
20
18
|
import assert from "node:assert/strict";
|
|
@@ -201,6 +199,11 @@ describe("placement survives a re-entrant computePlacement pass (issue #383)", (
|
|
|
201
199
|
assert.equal(harness.message.mailboxId, MAILBOXES.inbox.mailboxId);
|
|
202
200
|
assert.equal(harness.messageUpdates[0]?.input.movedByRemit, undefined);
|
|
203
201
|
assert.equal(harness.messageUpdates[0]?.input.placementVerdict, undefined);
|
|
202
|
+
// The re-store wrote only bodyStorageKey — no placement fields.
|
|
203
|
+
assert.equal(
|
|
204
|
+
harness.messageUpdates[0]?.input.placementDecidedAt,
|
|
205
|
+
undefined,
|
|
206
|
+
);
|
|
204
207
|
});
|
|
205
208
|
|
|
206
209
|
it("keeps a user-rescued message in Inbox when syncBodies re-fetches with force", async () => {
|
|
@@ -279,16 +282,18 @@ describe("placement survives a re-entrant computePlacement pass (issue #383)", (
|
|
|
279
282
|
);
|
|
280
283
|
});
|
|
281
284
|
|
|
282
|
-
it("
|
|
285
|
+
it("a legacy row with movedByRemit: true and no placementDecidedAt is not re-decided on re-store", async () => {
|
|
283
286
|
const harness = buildHarness(
|
|
284
287
|
{
|
|
285
288
|
messageId: "m-1",
|
|
286
289
|
mailboxId: MAILBOXES.inbox.mailboxId,
|
|
287
290
|
bodyStorageKey: "s3://bodies/m-1",
|
|
288
291
|
movedByRemit: true,
|
|
289
|
-
// No placementDecidedAt — a legacy row synced before
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
+
// No placementDecidedAt and no category — a legacy row synced before
|
|
293
|
+
// the write-once guards existed. Under #1011 a re-store skips the
|
|
294
|
+
// decision pass entirely, so placement is never re-evaluated and the
|
|
295
|
+
// row's missing fields are never backfilled — the re-store writes only
|
|
296
|
+
// bodyStorageKey.
|
|
292
297
|
},
|
|
293
298
|
async () => {
|
|
294
299
|
throw noSuchKeyError();
|
|
@@ -313,10 +318,20 @@ describe("placement survives a re-entrant computePlacement pass (issue #383)", (
|
|
|
313
318
|
[],
|
|
314
319
|
"movedByRemit must keep protecting a legacy row with no placementDecidedAt of its own",
|
|
315
320
|
);
|
|
321
|
+
// The re-store wrote only bodyStorageKey — no placement fields were
|
|
322
|
+
// re-decided or backfilled.
|
|
316
323
|
assert.equal(harness.message.mailboxId, MAILBOXES.inbox.mailboxId);
|
|
317
|
-
assert.
|
|
318
|
-
|
|
319
|
-
|
|
324
|
+
assert.equal(
|
|
325
|
+
harness.messageUpdates[0]?.input.placementDecidedAt,
|
|
326
|
+
undefined,
|
|
327
|
+
);
|
|
328
|
+
assert.equal(harness.messageUpdates[0]?.input.movedByRemit, undefined);
|
|
329
|
+
assert.equal(harness.messageUpdates[0]?.input.placementVerdict, undefined);
|
|
330
|
+
// The only field written is bodyStorageKey.
|
|
331
|
+
assert.equal(
|
|
332
|
+
Object.keys(harness.messageUpdates[0]?.input ?? {}).length,
|
|
333
|
+
1,
|
|
334
|
+
"re-store must write only bodyStorageKey",
|
|
320
335
|
);
|
|
321
336
|
});
|
|
322
337
|
});
|
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #1011: a forced body re-sync must not re-fire standing rules on an
|
|
3
|
+
* already-seen message.
|
|
4
|
+
*
|
|
5
|
+
* The two re-entrant paths — `fetchAndGetBody`'s `NoSuchKey` IMAP fallback, and
|
|
6
|
+
* `syncBodies(..., force: true)` (the read-miss re-arm cue) — both re-fetch a
|
|
7
|
+
* body whose row already carries `bodyStorageKey`. That is a body-only fetch:
|
|
8
|
+
* the decision pass (placement, filters, classification) already ran when the
|
|
9
|
+
* body first landed. Re-running it now would move mail the user has since filed
|
|
10
|
+
* by hand, re-fire the engagement counter, re-apply labels, and rewrite the
|
|
11
|
+
* write-once category.
|
|
12
|
+
*
|
|
13
|
+
* Each test below presets a message whose body was already stored and whose
|
|
14
|
+
* headers WOULD trigger a confident placement demote or a standing filter move
|
|
15
|
+
* if the decision pass ran fresh. A regression that drops the `isReStore` skip
|
|
16
|
+
* shows up as an unwanted move, not as a passing test relying on the heuristics
|
|
17
|
+
* happening to agree.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import assert from "node:assert/strict";
|
|
21
|
+
import { Readable } from "node:stream";
|
|
22
|
+
import { describe, it } from "node:test";
|
|
23
|
+
import type {
|
|
24
|
+
FilterItem,
|
|
25
|
+
IAddressRepository,
|
|
26
|
+
IEnvelopeRepository,
|
|
27
|
+
IFilterAnchorRepository,
|
|
28
|
+
IFilterRepository,
|
|
29
|
+
IMailboxSpecialUseRepository,
|
|
30
|
+
IMessageLabelRepository,
|
|
31
|
+
IMessageRepository,
|
|
32
|
+
IThreadMessageRepository,
|
|
33
|
+
MessageItem,
|
|
34
|
+
UpdateMessageInput,
|
|
35
|
+
} from "@remit/data-ports";
|
|
36
|
+
import { NotFoundError } from "@remit/data-ports/errors";
|
|
37
|
+
import {
|
|
38
|
+
FilterClauseField,
|
|
39
|
+
FilterMatchOperator,
|
|
40
|
+
FilterState,
|
|
41
|
+
MessageCategory,
|
|
42
|
+
} from "@remit/domain-enums";
|
|
43
|
+
import type { StorageService } from "@remit/storage-service";
|
|
44
|
+
import type { PlacementConfig } from "./body-sync.js";
|
|
45
|
+
import { BodySyncService } from "./body-sync.js";
|
|
46
|
+
import { NO_ACTION } from "./filters/match.js";
|
|
47
|
+
import type { FilterConfig } from "./filters/pipeline.js";
|
|
48
|
+
import type { PlacementMoveService } from "./placement-move.js";
|
|
49
|
+
import type { IImapConnection } from "./types.js";
|
|
50
|
+
|
|
51
|
+
const MAILBOXES = {
|
|
52
|
+
inbox: { mailboxId: "mb-inbox", fullPath: "INBOX" },
|
|
53
|
+
junk: { mailboxId: "mb-junk", fullPath: "Junk" },
|
|
54
|
+
travel: { mailboxId: "mb-travel", fullPath: "Travel" },
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Demote headers: DKIM domain mismatches From, dmarc=fail, untrusted sender.
|
|
59
|
+
* `classifyPlacement`'s demote branch (inbox → junk, HIGH bar) confidently acts
|
|
60
|
+
* on this set when the message currently sits in Inbox.
|
|
61
|
+
*/
|
|
62
|
+
const DEMOTE_EML = Buffer.from(
|
|
63
|
+
[
|
|
64
|
+
"From: Support <support@evil-mimic.example>",
|
|
65
|
+
"To: me@example.com",
|
|
66
|
+
"Subject: Verify your account",
|
|
67
|
+
"Authentication-Results: mx.example.com; dmarc=fail",
|
|
68
|
+
"DKIM-Signature: v=1; a=rsa-sha256; d=relay.example.net; s=sel; b=xxx",
|
|
69
|
+
"X-Spam-Status: No, score=0.1",
|
|
70
|
+
"Content-Type: text/plain",
|
|
71
|
+
"",
|
|
72
|
+
"body",
|
|
73
|
+
].join("\r\n"),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A standing literal filter that matches subjects containing "invitation" and
|
|
78
|
+
* moves them to `destinationMailboxId`. Used to assert that a re-store does not
|
|
79
|
+
* re-fire the filter on an already-seen message.
|
|
80
|
+
*/
|
|
81
|
+
const buildInvitationFilter = (destinationMailboxId: string): FilterItem =>
|
|
82
|
+
({
|
|
83
|
+
filterId: "flt-invite",
|
|
84
|
+
accountConfigId: "cfg-1",
|
|
85
|
+
name: "Invitations",
|
|
86
|
+
scope: "Standing",
|
|
87
|
+
state: FilterState.Active,
|
|
88
|
+
hasAnchor: false,
|
|
89
|
+
ruleChangedAt: 1,
|
|
90
|
+
matchOperator: FilterMatchOperator.And,
|
|
91
|
+
literalClauses: [{ field: FilterClauseField.Subject, value: "invitation" }],
|
|
92
|
+
actionLabelId: NO_ACTION,
|
|
93
|
+
actionMailboxId: destinationMailboxId,
|
|
94
|
+
createdAt: 1,
|
|
95
|
+
updatedAt: 1,
|
|
96
|
+
}) as unknown as FilterItem;
|
|
97
|
+
|
|
98
|
+
const INVITATION_EML = Buffer.from(
|
|
99
|
+
[
|
|
100
|
+
"From: Someone <someone@example.com>",
|
|
101
|
+
"To: me@example.com",
|
|
102
|
+
"Subject: You have a new invitation to Travel",
|
|
103
|
+
"Content-Type: text/plain",
|
|
104
|
+
"",
|
|
105
|
+
"details",
|
|
106
|
+
].join("\r\n"),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
interface Harness {
|
|
110
|
+
service: BodySyncService;
|
|
111
|
+
message: MessageItem;
|
|
112
|
+
messageUpdates: Array<{ messageId: string; input: UpdateMessageInput }>;
|
|
113
|
+
moves: Array<{ messageId: string; destinationMailboxId: string }>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const noSuchKeyError = () =>
|
|
117
|
+
Object.assign(new Error("missing"), { name: "NoSuchKey" });
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Build a BodySyncService harness with both placement and filter configs wired,
|
|
121
|
+
* so we can assert that neither fires during a re-store.
|
|
122
|
+
*/
|
|
123
|
+
const buildHarness = (
|
|
124
|
+
message: Partial<MessageItem> & Pick<MessageItem, "messageId">,
|
|
125
|
+
retrieve: () => Promise<Buffer>,
|
|
126
|
+
filter: FilterItem | null,
|
|
127
|
+
): Harness => {
|
|
128
|
+
const messageUpdates: Array<{
|
|
129
|
+
messageId: string;
|
|
130
|
+
input: UpdateMessageInput;
|
|
131
|
+
}> = [];
|
|
132
|
+
const moves: Array<{ messageId: string; destinationMailboxId: string }> = [];
|
|
133
|
+
|
|
134
|
+
const messageRow = {
|
|
135
|
+
uid: 1,
|
|
136
|
+
mailboxId: MAILBOXES.inbox.mailboxId,
|
|
137
|
+
...message,
|
|
138
|
+
} as unknown as MessageItem;
|
|
139
|
+
|
|
140
|
+
const messageService = {
|
|
141
|
+
get: async (messageId: string) =>
|
|
142
|
+
messageId === message.messageId ? messageRow : undefined,
|
|
143
|
+
update: async (messageId: string, input: UpdateMessageInput) => {
|
|
144
|
+
messageUpdates.push({ messageId, input });
|
|
145
|
+
Object.assign(messageRow, input);
|
|
146
|
+
},
|
|
147
|
+
} as unknown as IMessageRepository;
|
|
148
|
+
|
|
149
|
+
const threadMessageService = {
|
|
150
|
+
findAllByMessageId: async () => [
|
|
151
|
+
{
|
|
152
|
+
threadMessageId: "tm-1",
|
|
153
|
+
messageId: message.messageId,
|
|
154
|
+
mailboxId: messageRow.mailboxId,
|
|
155
|
+
sentDate: 1,
|
|
156
|
+
isRead: false,
|
|
157
|
+
isDeleted: false,
|
|
158
|
+
hasStars: false,
|
|
159
|
+
hasAttachment: false,
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
update: async () => {},
|
|
163
|
+
} as unknown as IThreadMessageRepository;
|
|
164
|
+
|
|
165
|
+
const storageService = {
|
|
166
|
+
retrieve,
|
|
167
|
+
storeMessageBody: async () => ({ uri: `s3://bodies/${message.messageId}` }),
|
|
168
|
+
storeMessageBodyStream: async () => ({
|
|
169
|
+
uri: `s3://bodies/${message.messageId}`,
|
|
170
|
+
}),
|
|
171
|
+
storeParsedBody: async () => {},
|
|
172
|
+
listBodyParts: async () => [],
|
|
173
|
+
} as unknown as StorageService;
|
|
174
|
+
|
|
175
|
+
const addressService = {
|
|
176
|
+
getAddress: async () => {
|
|
177
|
+
throw new NotFoundError("Address not found");
|
|
178
|
+
},
|
|
179
|
+
incrementInboundCount: async () => {},
|
|
180
|
+
} as unknown as IAddressRepository;
|
|
181
|
+
|
|
182
|
+
const envelopeService = {
|
|
183
|
+
listBodyParts: async () => [],
|
|
184
|
+
} as unknown as IEnvelopeRepository;
|
|
185
|
+
|
|
186
|
+
const mailboxSpecialUseService = {
|
|
187
|
+
findJunkMailbox: async () => MAILBOXES.junk,
|
|
188
|
+
findArchiveMailbox: async () => null,
|
|
189
|
+
findInboxMailbox: async () => MAILBOXES.inbox,
|
|
190
|
+
} as unknown as IMailboxSpecialUseRepository;
|
|
191
|
+
|
|
192
|
+
const placementMoveService = {
|
|
193
|
+
moveMessage: async (
|
|
194
|
+
_accountConfigId: string,
|
|
195
|
+
messageId: string,
|
|
196
|
+
destinationMailboxId: string,
|
|
197
|
+
) => {
|
|
198
|
+
moves.push({ messageId, destinationMailboxId });
|
|
199
|
+
messageRow.mailboxId = destinationMailboxId;
|
|
200
|
+
},
|
|
201
|
+
} as unknown as PlacementMoveService;
|
|
202
|
+
|
|
203
|
+
const placementConfig: PlacementConfig = {
|
|
204
|
+
mailboxSpecialUseService,
|
|
205
|
+
placementMoveService,
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const filterConfig: FilterConfig | undefined = filter
|
|
209
|
+
? {
|
|
210
|
+
filterService: {
|
|
211
|
+
listByAccountAndState: async () => [filter],
|
|
212
|
+
refreshExpiry: async (f: FilterItem) => f,
|
|
213
|
+
} as unknown as IFilterRepository,
|
|
214
|
+
filterAnchorService: {
|
|
215
|
+
get: async () => undefined,
|
|
216
|
+
} as unknown as IFilterAnchorRepository,
|
|
217
|
+
messageLabelService: {
|
|
218
|
+
apply: async () => {},
|
|
219
|
+
} as unknown as IMessageLabelRepository,
|
|
220
|
+
placementMoveService,
|
|
221
|
+
}
|
|
222
|
+
: undefined;
|
|
223
|
+
|
|
224
|
+
const service = new BodySyncService(
|
|
225
|
+
messageService,
|
|
226
|
+
storageService,
|
|
227
|
+
threadMessageService,
|
|
228
|
+
addressService,
|
|
229
|
+
envelopeService,
|
|
230
|
+
{ info: () => {}, error: () => {}, debug: () => {}, warn: () => {} },
|
|
231
|
+
placementConfig,
|
|
232
|
+
filterConfig,
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
return { service, message: messageRow, messageUpdates, moves };
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const noSuchKeyConn = (body: Buffer): IImapConnection =>
|
|
239
|
+
({
|
|
240
|
+
openBox: async () => {},
|
|
241
|
+
fetchMessageBody: async () => body,
|
|
242
|
+
}) as unknown as IImapConnection;
|
|
243
|
+
|
|
244
|
+
const streamBody = (body: Buffer): Readable =>
|
|
245
|
+
Readable.from([body]) as unknown as Readable;
|
|
246
|
+
|
|
247
|
+
describe("issue #1011: forced re-store does not re-fire standing rules", () => {
|
|
248
|
+
it("syncBodies(force: true) does not re-evaluate placement on a stored message", async () => {
|
|
249
|
+
const harness = buildHarness(
|
|
250
|
+
{
|
|
251
|
+
messageId: "m-1",
|
|
252
|
+
mailboxId: MAILBOXES.inbox.mailboxId,
|
|
253
|
+
bodyStorageKey: "s3://bodies/m-1",
|
|
254
|
+
// Already classified and placed — category and placementDecidedAt
|
|
255
|
+
// set by a prior, genuine first-store pass.
|
|
256
|
+
category: MessageCategory.personal,
|
|
257
|
+
placementDecidedAt: 500,
|
|
258
|
+
},
|
|
259
|
+
async () => {
|
|
260
|
+
throw new Error("force path must not retrieve from storage");
|
|
261
|
+
},
|
|
262
|
+
null, // no filter config
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const connection = {
|
|
266
|
+
openBox: async () => {},
|
|
267
|
+
async *fetchMessageBodies(uids: number[]) {
|
|
268
|
+
for (const uid of uids) {
|
|
269
|
+
yield { uid, source: streamBody(DEMOTE_EML) };
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
} as unknown as IImapConnection;
|
|
273
|
+
|
|
274
|
+
const result = await harness.service.syncBodies(
|
|
275
|
+
["m-1"],
|
|
276
|
+
"acc-1",
|
|
277
|
+
"cfg-1",
|
|
278
|
+
"INBOX",
|
|
279
|
+
async () => connection,
|
|
280
|
+
true,
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
assert.deepEqual(result.syncedMessageIds, ["m-1"], "message is synced");
|
|
284
|
+
|
|
285
|
+
// No placement move — a hand-rescued message stays where the user put it.
|
|
286
|
+
assert.deepEqual(
|
|
287
|
+
harness.moves,
|
|
288
|
+
[],
|
|
289
|
+
"a forced re-store must not re-decide placement, even though headers would demote",
|
|
290
|
+
);
|
|
291
|
+
assert.equal(harness.message.mailboxId, MAILBOXES.inbox.mailboxId);
|
|
292
|
+
|
|
293
|
+
// The update writes only bodyStorageKey — no placement or category churn.
|
|
294
|
+
const update = harness.messageUpdates[0];
|
|
295
|
+
assert.equal(update?.input.movedByRemit, undefined);
|
|
296
|
+
assert.equal(update?.input.placementVerdict, undefined);
|
|
297
|
+
assert.equal(update?.input.placementDecidedAt, undefined);
|
|
298
|
+
assert.equal(update?.input.category, undefined);
|
|
299
|
+
assert.deepEqual(
|
|
300
|
+
Object.keys(update?.input ?? {}).sort(),
|
|
301
|
+
["bodyStorageKey"],
|
|
302
|
+
"re-store must write only bodyStorageKey",
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("syncBodies(force: true) does not re-fire a standing filter on a stored message", async () => {
|
|
307
|
+
const harness = buildHarness(
|
|
308
|
+
{
|
|
309
|
+
messageId: "m-1",
|
|
310
|
+
mailboxId: MAILBOXES.travel.mailboxId,
|
|
311
|
+
bodyStorageKey: "s3://bodies/m-1",
|
|
312
|
+
category: MessageCategory.personal,
|
|
313
|
+
},
|
|
314
|
+
async () => {
|
|
315
|
+
throw new Error("force path must not retrieve from storage");
|
|
316
|
+
},
|
|
317
|
+
buildInvitationFilter(MAILBOXES.junk.mailboxId),
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
const connection = {
|
|
321
|
+
openBox: async () => {},
|
|
322
|
+
async *fetchMessageBodies(uids: number[]) {
|
|
323
|
+
for (const uid of uids) {
|
|
324
|
+
yield { uid, source: streamBody(INVITATION_EML) };
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
} as unknown as IImapConnection;
|
|
328
|
+
|
|
329
|
+
const result = await harness.service.syncBodies(
|
|
330
|
+
["m-1"],
|
|
331
|
+
"acc-1",
|
|
332
|
+
"cfg-1",
|
|
333
|
+
"INBOX",
|
|
334
|
+
async () => connection,
|
|
335
|
+
true,
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
assert.deepEqual(result.syncedMessageIds, ["m-1"]);
|
|
339
|
+
|
|
340
|
+
// No filter move — the message already sits in its filter destination
|
|
341
|
+
// (Travel) and a re-store must not re-fire the filter.
|
|
342
|
+
assert.deepEqual(
|
|
343
|
+
harness.moves,
|
|
344
|
+
[],
|
|
345
|
+
"a forced re-store must not re-fire standing filters",
|
|
346
|
+
);
|
|
347
|
+
// The update writes only bodyStorageKey — no filterMove marker.
|
|
348
|
+
const update = harness.messageUpdates[0];
|
|
349
|
+
assert.equal(update?.input.filterMove, undefined);
|
|
350
|
+
assert.equal(update?.input.movedByRemit, undefined);
|
|
351
|
+
assert.deepEqual(
|
|
352
|
+
Object.keys(update?.input ?? {}).sort(),
|
|
353
|
+
["bodyStorageKey"],
|
|
354
|
+
"re-store must write only bodyStorageKey",
|
|
355
|
+
);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("fetchAndGetBody NoSuchKey fallback does not re-fire placement or filters", async () => {
|
|
359
|
+
const harness = buildHarness(
|
|
360
|
+
{
|
|
361
|
+
messageId: "m-1",
|
|
362
|
+
mailboxId: MAILBOXES.inbox.mailboxId,
|
|
363
|
+
bodyStorageKey: "s3://bodies/m-1",
|
|
364
|
+
category: MessageCategory.personal,
|
|
365
|
+
placementDecidedAt: 500,
|
|
366
|
+
},
|
|
367
|
+
async () => {
|
|
368
|
+
throw noSuchKeyError();
|
|
369
|
+
},
|
|
370
|
+
buildInvitationFilter(MAILBOXES.junk.mailboxId),
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
await harness.service.fetchAndGetBody(
|
|
374
|
+
"m-1",
|
|
375
|
+
"acc-1",
|
|
376
|
+
"cfg-1",
|
|
377
|
+
"INBOX",
|
|
378
|
+
async () => noSuchKeyConn(DEMOTE_EML),
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
assert.deepEqual(
|
|
382
|
+
harness.moves,
|
|
383
|
+
[],
|
|
384
|
+
"the NoSuchKey fallback must not re-decide placement or re-fire filters",
|
|
385
|
+
);
|
|
386
|
+
assert.equal(harness.message.mailboxId, MAILBOXES.inbox.mailboxId);
|
|
387
|
+
|
|
388
|
+
const update = harness.messageUpdates[0];
|
|
389
|
+
assert.equal(update?.input.movedByRemit, undefined);
|
|
390
|
+
assert.equal(update?.input.placementVerdict, undefined);
|
|
391
|
+
assert.equal(update?.input.filterMove, undefined);
|
|
392
|
+
assert.deepEqual(
|
|
393
|
+
Object.keys(update?.input ?? {}).sort(),
|
|
394
|
+
["bodyStorageKey"],
|
|
395
|
+
"re-store must write only bodyStorageKey",
|
|
396
|
+
);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("a first-store via fetchAndGetBody (no bodyStorageKey) still runs placement and filters", async () => {
|
|
400
|
+
// Regression guard: the re-store skip must not disable decisions on a
|
|
401
|
+
// genuine first store that happens to arrive via the read path.
|
|
402
|
+
const harness = buildHarness(
|
|
403
|
+
{
|
|
404
|
+
messageId: "m-1",
|
|
405
|
+
mailboxId: MAILBOXES.inbox.mailboxId,
|
|
406
|
+
// No bodyStorageKey — a genuine first store.
|
|
407
|
+
},
|
|
408
|
+
async () => {
|
|
409
|
+
throw new Error("must not retrieve from storage on a first store");
|
|
410
|
+
},
|
|
411
|
+
buildInvitationFilter(MAILBOXES.travel.mailboxId),
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
await harness.service.fetchAndGetBody(
|
|
415
|
+
"m-1",
|
|
416
|
+
"acc-1",
|
|
417
|
+
"cfg-1",
|
|
418
|
+
"INBOX",
|
|
419
|
+
async () => noSuchKeyConn(INVITATION_EML),
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
// The filter fires — it is a first store, not a re-store.
|
|
423
|
+
assert.deepEqual(
|
|
424
|
+
harness.moves,
|
|
425
|
+
[{ messageId: "m-1", destinationMailboxId: MAILBOXES.travel.mailboxId }],
|
|
426
|
+
"a genuine first store must run the standing filter",
|
|
427
|
+
);
|
|
428
|
+
const update = harness.messageUpdates[0];
|
|
429
|
+
assert.equal(update?.input.filterMove?.filterId, "flt-invite");
|
|
430
|
+
assert.equal(update?.input.movedByRemit, true);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("a forced batch re-stores a stored message (bytes only) but first-stores a never-stored one", async () => {
|
|
434
|
+
// A forced batch may contain both never-stored messages (first store —
|
|
435
|
+
// full decisions) and already-stored ones (re-store — bytes only).
|
|
436
|
+
const storedMessage = {
|
|
437
|
+
messageId: "m-stored",
|
|
438
|
+
mailboxId: MAILBOXES.inbox.mailboxId,
|
|
439
|
+
uid: 1,
|
|
440
|
+
bodyStorageKey: "s3://bodies/m-stored",
|
|
441
|
+
category: MessageCategory.personal,
|
|
442
|
+
} as unknown as MessageItem;
|
|
443
|
+
const newMessage = {
|
|
444
|
+
messageId: "m-new",
|
|
445
|
+
mailboxId: MAILBOXES.inbox.mailboxId,
|
|
446
|
+
uid: 2,
|
|
447
|
+
} as unknown as MessageItem;
|
|
448
|
+
|
|
449
|
+
const messageUpdates: Array<{
|
|
450
|
+
messageId: string;
|
|
451
|
+
input: UpdateMessageInput;
|
|
452
|
+
}> = [];
|
|
453
|
+
const moves: Array<{ messageId: string; destinationMailboxId: string }> =
|
|
454
|
+
[];
|
|
455
|
+
|
|
456
|
+
const messageService = {
|
|
457
|
+
get: async (messageId: string) =>
|
|
458
|
+
messageId === "m-stored"
|
|
459
|
+
? storedMessage
|
|
460
|
+
: messageId === "m-new"
|
|
461
|
+
? newMessage
|
|
462
|
+
: undefined,
|
|
463
|
+
update: async (id: string, input: UpdateMessageInput) => {
|
|
464
|
+
messageUpdates.push({ messageId: id, input });
|
|
465
|
+
const row = id === "m-stored" ? storedMessage : newMessage;
|
|
466
|
+
Object.assign(row, input);
|
|
467
|
+
},
|
|
468
|
+
} as unknown as IMessageRepository;
|
|
469
|
+
|
|
470
|
+
const threadMessageService = {
|
|
471
|
+
findAllByMessageId: async (
|
|
472
|
+
_accountConfigId: string,
|
|
473
|
+
messageId: string,
|
|
474
|
+
) => [
|
|
475
|
+
{
|
|
476
|
+
threadMessageId: `tm-${messageId}`,
|
|
477
|
+
messageId,
|
|
478
|
+
mailboxId:
|
|
479
|
+
messageId === "m-stored"
|
|
480
|
+
? storedMessage.mailboxId
|
|
481
|
+
: newMessage.mailboxId,
|
|
482
|
+
sentDate: 1,
|
|
483
|
+
isRead: false,
|
|
484
|
+
isDeleted: false,
|
|
485
|
+
hasStars: false,
|
|
486
|
+
hasAttachment: false,
|
|
487
|
+
},
|
|
488
|
+
],
|
|
489
|
+
update: async () => {},
|
|
490
|
+
} as unknown as IThreadMessageRepository;
|
|
491
|
+
|
|
492
|
+
const storageService = {
|
|
493
|
+
retrieve: async () => {
|
|
494
|
+
throw new Error("must not retrieve on force path");
|
|
495
|
+
},
|
|
496
|
+
storeMessageBodyStream: async () => ({
|
|
497
|
+
uri: "s3://bodies/m",
|
|
498
|
+
}),
|
|
499
|
+
storeParsedBody: async () => {},
|
|
500
|
+
listBodyParts: async () => [],
|
|
501
|
+
} as unknown as StorageService;
|
|
502
|
+
|
|
503
|
+
const addressService = {
|
|
504
|
+
getAddress: async () => {
|
|
505
|
+
throw new NotFoundError("not found");
|
|
506
|
+
},
|
|
507
|
+
incrementInboundCount: async () => {},
|
|
508
|
+
} as unknown as IAddressRepository;
|
|
509
|
+
|
|
510
|
+
const envelopeService = {
|
|
511
|
+
listBodyParts: async () => [],
|
|
512
|
+
} as unknown as IEnvelopeRepository;
|
|
513
|
+
|
|
514
|
+
const mailboxSpecialUseService = {
|
|
515
|
+
findJunkMailbox: async () => MAILBOXES.junk,
|
|
516
|
+
findArchiveMailbox: async () => null,
|
|
517
|
+
findInboxMailbox: async () => MAILBOXES.inbox,
|
|
518
|
+
} as unknown as IMailboxSpecialUseRepository;
|
|
519
|
+
|
|
520
|
+
const placementMoveService = {
|
|
521
|
+
moveMessage: async (
|
|
522
|
+
_accountConfigId: string,
|
|
523
|
+
messageId: string,
|
|
524
|
+
destinationMailboxId: string,
|
|
525
|
+
) => {
|
|
526
|
+
moves.push({ messageId, destinationMailboxId });
|
|
527
|
+
},
|
|
528
|
+
} as unknown as PlacementMoveService;
|
|
529
|
+
|
|
530
|
+
const filterConfig: FilterConfig = {
|
|
531
|
+
filterService: {
|
|
532
|
+
listByAccountAndState: async () => [
|
|
533
|
+
buildInvitationFilter(MAILBOXES.junk.mailboxId),
|
|
534
|
+
],
|
|
535
|
+
refreshExpiry: async (f: FilterItem) => f,
|
|
536
|
+
} as unknown as IFilterRepository,
|
|
537
|
+
filterAnchorService: {
|
|
538
|
+
get: async () => undefined,
|
|
539
|
+
} as unknown as IFilterAnchorRepository,
|
|
540
|
+
messageLabelService: {
|
|
541
|
+
apply: async () => {},
|
|
542
|
+
} as unknown as IMessageLabelRepository,
|
|
543
|
+
placementMoveService,
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
const service = new BodySyncService(
|
|
547
|
+
messageService,
|
|
548
|
+
storageService,
|
|
549
|
+
threadMessageService,
|
|
550
|
+
addressService,
|
|
551
|
+
envelopeService,
|
|
552
|
+
{ info: () => {}, error: () => {}, debug: () => {}, warn: () => {} },
|
|
553
|
+
{ mailboxSpecialUseService, placementMoveService },
|
|
554
|
+
filterConfig,
|
|
555
|
+
);
|
|
556
|
+
|
|
557
|
+
// UID 1 is the stored message, UID 2 is new. fetchMessageBodies yields
|
|
558
|
+
// in order; the stored one (DEMOTE_EML) would demote if placement ran,
|
|
559
|
+
// the new one (INVITATION_EML) would trigger the filter.
|
|
560
|
+
const connection = {
|
|
561
|
+
openBox: async () => {},
|
|
562
|
+
async *fetchMessageBodies() {
|
|
563
|
+
yield { uid: 1, source: streamBody(DEMOTE_EML) };
|
|
564
|
+
yield { uid: 2, source: streamBody(INVITATION_EML) };
|
|
565
|
+
},
|
|
566
|
+
} as unknown as IImapConnection;
|
|
567
|
+
|
|
568
|
+
const result = await service.syncBodies(
|
|
569
|
+
["m-stored", "m-new"],
|
|
570
|
+
"acc-1",
|
|
571
|
+
"cfg-1",
|
|
572
|
+
"INBOX",
|
|
573
|
+
async () => connection,
|
|
574
|
+
true,
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
// Both messages were handled.
|
|
578
|
+
assert.deepEqual(result.syncedMessageIds.sort(), ["m-new", "m-stored"]);
|
|
579
|
+
|
|
580
|
+
// The stored message is a re-store: no placement move, no filter run.
|
|
581
|
+
const storedUpdate = messageUpdates.find((u) => u.messageId === "m-stored");
|
|
582
|
+
assert.equal(storedUpdate?.input.movedByRemit, undefined);
|
|
583
|
+
assert.equal(storedUpdate?.input.filterMove, undefined);
|
|
584
|
+
assert.deepEqual(
|
|
585
|
+
Object.keys(storedUpdate?.input ?? {}).sort(),
|
|
586
|
+
["bodyStorageKey"],
|
|
587
|
+
"stored message in a forced batch must re-store (bytes only)",
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
// The new message is a first-store: the filter fires.
|
|
591
|
+
assert.deepEqual(
|
|
592
|
+
moves,
|
|
593
|
+
[
|
|
594
|
+
{
|
|
595
|
+
messageId: "m-new",
|
|
596
|
+
destinationMailboxId: MAILBOXES.junk.mailboxId,
|
|
597
|
+
},
|
|
598
|
+
],
|
|
599
|
+
"only the never-stored message in the forced batch takes the decision pass",
|
|
600
|
+
);
|
|
601
|
+
const newUpdate = messageUpdates.find((u) => u.messageId === "m-new");
|
|
602
|
+
assert.equal(newUpdate?.input.movedByRemit, true);
|
|
603
|
+
assert.equal(newUpdate?.input.filterMove?.filterId, "flt-invite");
|
|
604
|
+
});
|
|
605
|
+
});
|
package/src/body-sync.ts
CHANGED
|
@@ -166,6 +166,7 @@ const extractSnippet = (parsed: ParsedMail): string =>
|
|
|
166
166
|
SNIPPET_LENGTH,
|
|
167
167
|
);
|
|
168
168
|
|
|
169
|
+
/**
|
|
169
170
|
/**
|
|
170
171
|
* RFC 034 Decision 3.1: `Message.category` is written once and never mutated
|
|
171
172
|
* after — RFC 030's message-list GSI sort key depends on it never churning.
|
|
@@ -183,30 +184,20 @@ const hasDecidedCategory = (
|
|
|
183
184
|
): boolean =>
|
|
184
185
|
category !== undefined && category !== MessageCategory.uncategorized;
|
|
185
186
|
|
|
186
|
-
/**
|
|
187
|
-
* Issue #383 (RFC 039 Non-goals): whether {@link BodySyncService.computePlacement}
|
|
188
|
-
* has already produced a verdict for this message — moved, left in place, or
|
|
189
|
-
* archived, confident or unsure alike. Absence means placement has genuinely
|
|
190
|
-
* never been evaluated. The same two re-entrant paths `hasDecidedCategory`
|
|
191
|
-
* guards (`fetchAndGetBody`'s `NoSuchKey` fallback, `syncBodies(..., force:
|
|
192
|
-
* true)`) also re-enter `computePlacement`; without this guard a message a
|
|
193
|
-
* user manually rescued (never touched by Remit, so `movedByRemit` never
|
|
194
|
-
* recorded anything) can be silently re-evaluated and moved right back.
|
|
195
|
-
*/
|
|
196
|
-
const hasDecidedPlacement = (placementDecidedAt: number | undefined): boolean =>
|
|
197
|
-
placementDecidedAt !== undefined;
|
|
198
|
-
|
|
199
187
|
/**
|
|
200
188
|
* Issue #499: whether a completed body-sync pass has already stored this
|
|
201
189
|
* message's body. `bodyStorageKey` is written last, once every derivation of
|
|
202
|
-
* that pass has run, so its presence means that pass finished.
|
|
203
|
-
* re-entrant paths `hasDecidedCategory` and `hasDecidedPlacement` guard
|
|
204
|
-
* (`fetchAndGetBody`'s `NoSuchKey` fallback, `syncBodies(..., force: true)`)
|
|
205
|
-
* are the ones that reach the derivations again with it already set.
|
|
190
|
+
* that pass has run, so its presence means that pass finished.
|
|
206
191
|
*
|
|
207
192
|
* Key presence, and nothing more. What the classifier decided is
|
|
208
193
|
* `Message.category`; whether it ran at all is `Message.classificationState`.
|
|
209
194
|
* Neither is inferred from here — the field this reads is the storage fact.
|
|
195
|
+
*
|
|
196
|
+
* The re-fetch paths that re-enter with the key already set (`syncBodies(...,
|
|
197
|
+
* force: true)` and `fetchAndGetBody`'s `NoSuchKey` fallback) are re-stores
|
|
198
|
+
* that skip the derivations entirely (issue #1011), so this guard is what
|
|
199
|
+
* keeps a first store that lands on an already-stored row from re-deciding
|
|
200
|
+
* anything.
|
|
210
201
|
*/
|
|
211
202
|
const hasStoredBody = (bodyStorageKey: string | undefined): boolean =>
|
|
212
203
|
Boolean(bodyStorageKey);
|
|
@@ -217,9 +208,9 @@ const hasStoredBody = (bodyStorageKey: string | undefined): boolean =>
|
|
|
217
208
|
* means "nothing confident to say":
|
|
218
209
|
*
|
|
219
210
|
* - `already-moved-by-remit` is {@link classifyPlacement}'s guard against
|
|
220
|
-
* re-deciding a settled placement, and auto-archive is a re-decision.
|
|
221
|
-
*
|
|
222
|
-
*
|
|
211
|
+
* re-deciding a settled placement, and auto-archive is a re-decision. It
|
|
212
|
+
* fires on messages Remit itself placed, or that a user moved back
|
|
213
|
+
* afterwards.
|
|
223
214
|
* - A message already in Junk gets an unsure `leave` whatever the sender's
|
|
224
215
|
* `blocked` flag says, since the demote branch only fires outside Junk.
|
|
225
216
|
* Moving mail out of Junk is a rescue, which this file reserves for a
|
|
@@ -397,6 +388,12 @@ export class BodySyncService {
|
|
|
397
388
|
// messageId so we can match FETCH rows back and re-enqueue any UID the
|
|
398
389
|
// server never returns.
|
|
399
390
|
const pending = new Map<number, string>();
|
|
391
|
+
// Message IDs in this batch whose row already carried `bodyStorageKey`
|
|
392
|
+
// when `force` re-added them to `pending`: a forced re-fetch of an
|
|
393
|
+
// already-stored body is a re-store (issue #1011) — bytes only, no
|
|
394
|
+
// decision re-run. A message with no stored body inside a forced batch
|
|
395
|
+
// is still a first store and takes the full decision pass.
|
|
396
|
+
const reStoredMessageIds = new Set<string>();
|
|
400
397
|
|
|
401
398
|
// One read per round, not per message (issue #72). The list is small by
|
|
402
399
|
// design and almost always empty, so a lookup per message would put a
|
|
@@ -438,6 +435,7 @@ export class BodySyncService {
|
|
|
438
435
|
skippedCount++;
|
|
439
436
|
continue;
|
|
440
437
|
}
|
|
438
|
+
if (message.bodyStorageKey) reStoredMessageIds.add(messageId);
|
|
441
439
|
pending.set(message.uid, messageId);
|
|
442
440
|
}
|
|
443
441
|
|
|
@@ -468,6 +466,7 @@ export class BodySyncService {
|
|
|
468
466
|
accountId,
|
|
469
467
|
accountConfigId,
|
|
470
468
|
source,
|
|
469
|
+
reStoredMessageIds.has(messageId),
|
|
471
470
|
);
|
|
472
471
|
} catch (error) {
|
|
473
472
|
// A per-message store failure (e.g. the parsed-body S3 write) is a
|
|
@@ -644,12 +643,18 @@ export class BodySyncService {
|
|
|
644
643
|
* cache, per-part objects). The S3 upload never sees a whole-body concat —
|
|
645
644
|
* the storage service streams it — but mailparser still needs the full bytes,
|
|
646
645
|
* so we collect them in parallel. Later issues move parsing off the hot path.
|
|
646
|
+
*
|
|
647
|
+
* `isReStore` marks a re-fetch of a message whose row already carried
|
|
648
|
+
* `bodyStorageKey` (the `force` re-arm in {@link syncBodies}): the
|
|
649
|
+
* post-store steps then re-materialize the body artifacts without
|
|
650
|
+
* re-running any decision (issue #1011).
|
|
647
651
|
*/
|
|
648
652
|
private async storeStreamedBody(
|
|
649
653
|
messageId: string,
|
|
650
654
|
accountId: string,
|
|
651
655
|
accountConfigId: string,
|
|
652
656
|
source: Readable,
|
|
657
|
+
isReStore: boolean,
|
|
653
658
|
): Promise<void> {
|
|
654
659
|
const toStorage = new PassThrough();
|
|
655
660
|
const chunks: Buffer[] = [];
|
|
@@ -693,6 +698,7 @@ export class BodySyncService {
|
|
|
693
698
|
{
|
|
694
699
|
uri: ref.uri,
|
|
695
700
|
},
|
|
701
|
+
isReStore,
|
|
696
702
|
);
|
|
697
703
|
}
|
|
698
704
|
|
|
@@ -724,6 +730,16 @@ export class BodySyncService {
|
|
|
724
730
|
accountConfigId: string,
|
|
725
731
|
body: Buffer,
|
|
726
732
|
bodyRef: { uri: string },
|
|
733
|
+
/**
|
|
734
|
+
* Whether this call is a re-store of an already-classified message
|
|
735
|
+
* rather than a first store. The two re-entrant paths (`force: true`
|
|
736
|
+
* in `syncBodies`, and `fetchAndGetBody`'s `NoSuchKey` IMAP fallback) are
|
|
737
|
+
* both body-only re-fetches: a body re-sync fetches and stores bytes and
|
|
738
|
+
* runs no decision logic (issue #1011). When `isReStore` is true the
|
|
739
|
+
* placement/filter/category machinery is skipped entirely and the
|
|
740
|
+
* message's existing category is carried forward unchanged.
|
|
741
|
+
*/
|
|
742
|
+
isReStore: boolean,
|
|
727
743
|
): Promise<ParsedMail> {
|
|
728
744
|
// The one operation on this path that can fail because of how the message
|
|
729
745
|
// is built. Its own try block lives in `parseMessageBody`, so this frame —
|
|
@@ -733,6 +749,43 @@ export class BodySyncService {
|
|
|
733
749
|
// feed happens later, alongside the Message write (see below), once the
|
|
734
750
|
// write-once category is decided.
|
|
735
751
|
const parsed = await parseMessageBody(body);
|
|
752
|
+
|
|
753
|
+
// Body-only re-fetch (issue #1011): the decision pass already ran when
|
|
754
|
+
// this message's body first landed, so a re-store re-materializes only
|
|
755
|
+
// the body-derived artifacts — parsed cache, body parts, the storage
|
|
756
|
+
// key — and re-decides nothing: no classification, placement, filters,
|
|
757
|
+
// labels, read state, and no second engagement-counter increment.
|
|
758
|
+
// Skipping the decisions here is what lets the write-once fields stay
|
|
759
|
+
// safe without per-derivation re-entrancy guards.
|
|
760
|
+
if (isReStore) {
|
|
761
|
+
// Same ordering rule as the first-store path below: the parsed-body
|
|
762
|
+
// cache must be durable before `bodyStorageKey` — the skip-guard
|
|
763
|
+
// signal — is written, so a failure here requeues the message and
|
|
764
|
+
// the retry genuinely re-attempts the artifact writes.
|
|
765
|
+
await this.storeParsedBodyCache(
|
|
766
|
+
accountConfigId,
|
|
767
|
+
accountId,
|
|
768
|
+
messageId,
|
|
769
|
+
parsed,
|
|
770
|
+
);
|
|
771
|
+
if (!isBodyPartDeferralEnabled()) {
|
|
772
|
+
await this.storeBodyPartContents(
|
|
773
|
+
accountConfigId,
|
|
774
|
+
accountId,
|
|
775
|
+
messageId,
|
|
776
|
+
parsed,
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
await this.messageService.update(messageId, {
|
|
780
|
+
bodyStorageKey: bodyRef.uri,
|
|
781
|
+
});
|
|
782
|
+
this.log.info(
|
|
783
|
+
{ messageId, storageKey: bodyRef.uri },
|
|
784
|
+
"Body re-stored without re-running decisions",
|
|
785
|
+
);
|
|
786
|
+
return parsed;
|
|
787
|
+
}
|
|
788
|
+
|
|
736
789
|
const snippet = extractSnippet(parsed);
|
|
737
790
|
const listId = extractListId(parsed);
|
|
738
791
|
|
|
@@ -892,20 +945,19 @@ export class BodySyncService {
|
|
|
892
945
|
// once the parsed cache AND the move (when any) are.
|
|
893
946
|
//
|
|
894
947
|
// `category` is RFC 034 D3.1's write-once field (RFC 030's message-list
|
|
895
|
-
// GSI sort key depends on it never churning).
|
|
896
|
-
//
|
|
897
|
-
//
|
|
898
|
-
// real
|
|
899
|
-
//
|
|
900
|
-
//
|
|
901
|
-
//
|
|
902
|
-
//
|
|
903
|
-
// exactly the churn RFC 030's GSI-safety argument forbids.
|
|
948
|
+
// GSI sort key depends on it never churning). The re-entrant re-store
|
|
949
|
+
// paths (issue #1011) return before this write; the carry-forward keeps
|
|
950
|
+
// the field write-once even when a first store lands on a row that
|
|
951
|
+
// already carries a real category — a previously-decided category wins
|
|
952
|
+
// over the just-recomputed one. It likewise protects a `flags.category`
|
|
953
|
+
// override (issue #299): recomputing would let a *later* override silently
|
|
954
|
+
// rewrite a category already decided, which is exactly the churn RFC
|
|
955
|
+
// 030's GSI-safety argument forbids.
|
|
904
956
|
//
|
|
905
|
-
// `resolved.placementDecidedAt` (issue #383)
|
|
906
|
-
//
|
|
907
|
-
//
|
|
908
|
-
//
|
|
957
|
+
// `resolved.placementDecidedAt` (issue #383) is only ever present on
|
|
958
|
+
// the pass that first decided it: the re-store paths skip placement
|
|
959
|
+
// entirely (issue #1011), so `computePlacement` is reached only on a
|
|
960
|
+
// first store.
|
|
909
961
|
const finalCategory = hasDecidedCategory(existingMessage.category)
|
|
910
962
|
? existingMessage.category
|
|
911
963
|
: classification.category;
|
|
@@ -1026,13 +1078,17 @@ export class BodySyncService {
|
|
|
1026
1078
|
|
|
1027
1079
|
// Same shared step the sync path (storeStreamedBody) runs — issue
|
|
1028
1080
|
// #1271 unified the two body paths so classification/placement no
|
|
1029
|
-
// longer depends on which one fetched the body first.
|
|
1081
|
+
// longer depends on which one fetched the body first. A store from
|
|
1082
|
+
// here is a re-store exactly when the row already claimed a stored
|
|
1083
|
+
// body — the NoSuchKey fallback above; a row with no bodyStorageKey is
|
|
1084
|
+
// a genuine first store and takes the full decision pass.
|
|
1030
1085
|
parsed = await this.applyPostStoreSteps(
|
|
1031
1086
|
messageId,
|
|
1032
1087
|
accountId,
|
|
1033
1088
|
accountConfigId,
|
|
1034
1089
|
body,
|
|
1035
1090
|
{ uri: ref.uri },
|
|
1091
|
+
message.bodyStorageKey != null,
|
|
1036
1092
|
);
|
|
1037
1093
|
} else {
|
|
1038
1094
|
parsed = await simpleParser(body);
|
|
@@ -1492,14 +1548,12 @@ export class BodySyncService {
|
|
|
1492
1548
|
|
|
1493
1549
|
const message = await this.messageService.get(messageId);
|
|
1494
1550
|
|
|
1495
|
-
// Issue #383: placement
|
|
1496
|
-
// Non-goals)
|
|
1497
|
-
//
|
|
1498
|
-
//
|
|
1499
|
-
//
|
|
1500
|
-
//
|
|
1501
|
-
// against the same signals that placed it in the first place.
|
|
1502
|
-
if (hasDecidedPlacement(message.placementDecidedAt)) return {};
|
|
1551
|
+
// Issue #383 / #1011: placement still runs once per message (RFC 039
|
|
1552
|
+
// Non-goals), but the once-per-message guarantee now comes from the
|
|
1553
|
+
// caller rather than a guard here — the re-store paths
|
|
1554
|
+
// (`fetchAndGetBody`'s `NoSuchKey` fallback, `syncBodies(..., force:
|
|
1555
|
+
// true)`) skip the decision steps entirely, so only the pass that first
|
|
1556
|
+
// stores a body ever reaches this method.
|
|
1503
1557
|
|
|
1504
1558
|
const junkMailbox =
|
|
1505
1559
|
await mailboxSpecialUseService.findJunkMailbox(accountId);
|