@remit/mailbox-service 0.0.37 → 0.0.38
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
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 039 Non-goals / issue #383: placement is meant to run once per message.
|
|
3
|
+
* #378 (issue #355) guarded `Message.category` against the same two re-entrant
|
|
4
|
+
* paths re-running `applyPostStoreSteps` on an already-processed message —
|
|
5
|
+
* `fetchAndGetBody`'s `NoSuchKey` fallback and `syncBodies(..., force: true)`
|
|
6
|
+
* — but did not guard `resolvePlacement`/`computePlacement`. Without a guard,
|
|
7
|
+
* a message the provider originally junked and a user later rescued by hand
|
|
8
|
+
* (never touched by Remit, so `movedByRemit` records nothing) gets
|
|
9
|
+
* `classifyPlacement` re-evaluated against the same demote signals that
|
|
10
|
+
* junked it in the first place, and can be silently moved right back.
|
|
11
|
+
*
|
|
12
|
+
* Each "already decided" test below presets `placementDecidedAt` on the
|
|
13
|
+
* fixture (as a genuine first pass would have left it) and feeds the
|
|
14
|
+
* re-entrant pass a body whose headers WOULD trigger a confident demote if
|
|
15
|
+
* `classifyPlacement` ran fresh — so a regression that drops the guard shows
|
|
16
|
+
* up as an unwanted move, not as a passing test relying on deterministic
|
|
17
|
+
* heuristics 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
|
+
AddressItem,
|
|
25
|
+
IAddressRepository,
|
|
26
|
+
IEnvelopeRepository,
|
|
27
|
+
IMailboxSpecialUseRepository,
|
|
28
|
+
IMessageRepository,
|
|
29
|
+
IThreadMessageRepository,
|
|
30
|
+
MessageItem,
|
|
31
|
+
UpdateMessageInput,
|
|
32
|
+
} from "@remit/data-ports";
|
|
33
|
+
import { MailboxSpecialUse } from "@remit/domain-enums";
|
|
34
|
+
import type { StorageService } from "@remit/storage-service";
|
|
35
|
+
import type { PlacementConfig } from "./body-sync.js";
|
|
36
|
+
import { BodySyncService } from "./body-sync.js";
|
|
37
|
+
import type { PlacementMoveService } from "./placement-move.js";
|
|
38
|
+
import type { IImapConnection } from "./types.js";
|
|
39
|
+
|
|
40
|
+
const MAILBOXES = {
|
|
41
|
+
inbox: { mailboxId: "mb-inbox", fullPath: "INBOX" },
|
|
42
|
+
junk: { mailboxId: "mb-junk", fullPath: "Junk" },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* DKIM signing domain mismatches the From domain, dmarc=fail, sender
|
|
47
|
+
* untrusted, a provider-spam header present (any value) — exactly the
|
|
48
|
+
* deterministic signal set `classifyPlacement`'s demote branch (inbox → junk,
|
|
49
|
+
* HIGH bar) confidently acts on when the message currently sits in Inbox.
|
|
50
|
+
*/
|
|
51
|
+
const DEMOTE_EML = Buffer.from(
|
|
52
|
+
[
|
|
53
|
+
"From: Support <support@evil-mimic.example>",
|
|
54
|
+
"To: me@example.com",
|
|
55
|
+
"Subject: Verify your account",
|
|
56
|
+
"Authentication-Results: mx.example.com; dmarc=fail",
|
|
57
|
+
"DKIM-Signature: v=1; a=rsa-sha256; d=relay.example.net; s=sel; b=xxx",
|
|
58
|
+
"X-Spam-Status: No, score=0.1",
|
|
59
|
+
"Content-Type: text/plain",
|
|
60
|
+
"",
|
|
61
|
+
"body",
|
|
62
|
+
].join("\r\n"),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
interface Harness {
|
|
66
|
+
service: BodySyncService;
|
|
67
|
+
message: MessageItem;
|
|
68
|
+
messageUpdates: Array<{ messageId: string; input: UpdateMessageInput }>;
|
|
69
|
+
moves: Array<{ messageId: string; destinationMailboxId: string }>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const buildHarness = (
|
|
73
|
+
message: Partial<MessageItem> & Pick<MessageItem, "messageId">,
|
|
74
|
+
retrieve: () => Promise<Buffer>,
|
|
75
|
+
): Harness => {
|
|
76
|
+
const messageUpdates: Array<{
|
|
77
|
+
messageId: string;
|
|
78
|
+
input: UpdateMessageInput;
|
|
79
|
+
}> = [];
|
|
80
|
+
const moves: Array<{ messageId: string; destinationMailboxId: string }> = [];
|
|
81
|
+
|
|
82
|
+
const messageRow = {
|
|
83
|
+
uid: 1,
|
|
84
|
+
mailboxId: MAILBOXES.inbox.mailboxId,
|
|
85
|
+
...message,
|
|
86
|
+
} as unknown as MessageItem;
|
|
87
|
+
|
|
88
|
+
const messageService = {
|
|
89
|
+
get: async () => messageRow,
|
|
90
|
+
update: async (messageId: string, input: UpdateMessageInput) => {
|
|
91
|
+
messageUpdates.push({ messageId, input });
|
|
92
|
+
Object.assign(messageRow, input);
|
|
93
|
+
},
|
|
94
|
+
} as unknown as IMessageRepository;
|
|
95
|
+
|
|
96
|
+
const threadMessageService = {
|
|
97
|
+
findAllByMessageId: async () => [
|
|
98
|
+
{
|
|
99
|
+
threadMessageId: "tm-1",
|
|
100
|
+
messageId: message.messageId,
|
|
101
|
+
mailboxId: messageRow.mailboxId,
|
|
102
|
+
sentDate: 1,
|
|
103
|
+
isRead: false,
|
|
104
|
+
isDeleted: false,
|
|
105
|
+
hasStars: false,
|
|
106
|
+
hasAttachment: false,
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
update: async () => {},
|
|
110
|
+
} as unknown as IThreadMessageRepository;
|
|
111
|
+
|
|
112
|
+
const storageService = {
|
|
113
|
+
retrieve,
|
|
114
|
+
storeMessageBody: async () => ({ uri: `s3://bodies/${message.messageId}` }),
|
|
115
|
+
storeMessageBodyStream: async () => ({
|
|
116
|
+
uri: `s3://bodies/${message.messageId}`,
|
|
117
|
+
}),
|
|
118
|
+
storeParsedBody: async () => {},
|
|
119
|
+
listBodyParts: async () => [],
|
|
120
|
+
} as unknown as StorageService;
|
|
121
|
+
|
|
122
|
+
const addressService = {
|
|
123
|
+
getAddress: async () => ({ flags: {} }) as unknown as AddressItem,
|
|
124
|
+
incrementInboundCount: async () => {},
|
|
125
|
+
} as unknown as IAddressRepository;
|
|
126
|
+
|
|
127
|
+
const envelopeService = {
|
|
128
|
+
listBodyParts: async () => [],
|
|
129
|
+
} as unknown as IEnvelopeRepository;
|
|
130
|
+
|
|
131
|
+
const mailboxSpecialUseService = {
|
|
132
|
+
findBySpecialUse: async (_accountId: string, specialUse: string) =>
|
|
133
|
+
specialUse === MailboxSpecialUse.Junk ? MAILBOXES.junk : null,
|
|
134
|
+
findInboxMailbox: async () => MAILBOXES.inbox,
|
|
135
|
+
} as unknown as IMailboxSpecialUseRepository;
|
|
136
|
+
|
|
137
|
+
const placementMoveService = {
|
|
138
|
+
moveMessage: async (
|
|
139
|
+
_accountConfigId: string,
|
|
140
|
+
messageId: string,
|
|
141
|
+
destinationMailboxId: string,
|
|
142
|
+
) => {
|
|
143
|
+
moves.push({ messageId, destinationMailboxId });
|
|
144
|
+
messageRow.mailboxId = destinationMailboxId;
|
|
145
|
+
},
|
|
146
|
+
} as unknown as PlacementMoveService;
|
|
147
|
+
|
|
148
|
+
const placementConfig: PlacementConfig = {
|
|
149
|
+
mailboxSpecialUseService,
|
|
150
|
+
placementMoveService,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const service = new BodySyncService(
|
|
154
|
+
messageService,
|
|
155
|
+
storageService,
|
|
156
|
+
threadMessageService,
|
|
157
|
+
addressService,
|
|
158
|
+
envelopeService,
|
|
159
|
+
{ info: () => {}, error: () => {} },
|
|
160
|
+
placementConfig,
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
return { service, message: messageRow, messageUpdates, moves };
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const noSuchKeyError = () =>
|
|
167
|
+
Object.assign(new Error("missing"), { name: "NoSuchKey" });
|
|
168
|
+
|
|
169
|
+
describe("placement survives a re-entrant computePlacement pass (issue #383)", () => {
|
|
170
|
+
it("keeps a user-rescued message in Inbox through the NoSuchKey IMAP re-fetch", async () => {
|
|
171
|
+
const harness = buildHarness(
|
|
172
|
+
{
|
|
173
|
+
messageId: "m-1",
|
|
174
|
+
mailboxId: MAILBOXES.inbox.mailboxId,
|
|
175
|
+
bodyStorageKey: "s3://bodies/m-1",
|
|
176
|
+
movedByRemit: false,
|
|
177
|
+
placementDecidedAt: 500,
|
|
178
|
+
},
|
|
179
|
+
async () => {
|
|
180
|
+
throw noSuchKeyError();
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const connection = {
|
|
185
|
+
openBox: async () => {},
|
|
186
|
+
fetchMessageBody: async () => DEMOTE_EML,
|
|
187
|
+
} as unknown as IImapConnection;
|
|
188
|
+
|
|
189
|
+
await harness.service.fetchAndGetBody(
|
|
190
|
+
"m-1",
|
|
191
|
+
"acc-1",
|
|
192
|
+
"cfg-1",
|
|
193
|
+
"INBOX",
|
|
194
|
+
async () => connection,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
assert.deepEqual(
|
|
198
|
+
harness.moves,
|
|
199
|
+
[],
|
|
200
|
+
"an already-decided placement must not be re-evaluated, even though these headers would confidently demote if classifyPlacement ran fresh",
|
|
201
|
+
);
|
|
202
|
+
assert.equal(harness.message.mailboxId, MAILBOXES.inbox.mailboxId);
|
|
203
|
+
assert.equal(harness.messageUpdates[0]?.input.movedByRemit, undefined);
|
|
204
|
+
assert.equal(harness.messageUpdates[0]?.input.placementVerdict, undefined);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("keeps a user-rescued message in Inbox when syncBodies re-fetches with force", async () => {
|
|
208
|
+
const harness = buildHarness(
|
|
209
|
+
{
|
|
210
|
+
messageId: "m-1",
|
|
211
|
+
mailboxId: MAILBOXES.inbox.mailboxId,
|
|
212
|
+
bodyStorageKey: "s3://bodies/m-1",
|
|
213
|
+
movedByRemit: false,
|
|
214
|
+
placementDecidedAt: 500,
|
|
215
|
+
},
|
|
216
|
+
async () => {
|
|
217
|
+
throw new Error("force path must not retrieve from storage");
|
|
218
|
+
},
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
const connection = {
|
|
222
|
+
openBox: async () => {},
|
|
223
|
+
async *fetchMessageBodies(uids: number[]) {
|
|
224
|
+
for (const uid of uids) {
|
|
225
|
+
yield { uid, source: Readable.from([DEMOTE_EML]) };
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
} as unknown as IImapConnection;
|
|
229
|
+
|
|
230
|
+
const result = await harness.service.syncBodies(
|
|
231
|
+
["m-1"],
|
|
232
|
+
"acc-1",
|
|
233
|
+
"cfg-1",
|
|
234
|
+
"INBOX",
|
|
235
|
+
async () => connection,
|
|
236
|
+
true,
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
assert.deepEqual(result.syncedMessageIds, ["m-1"]);
|
|
240
|
+
assert.deepEqual(
|
|
241
|
+
harness.moves,
|
|
242
|
+
[],
|
|
243
|
+
"a forced re-sync must not re-decide an already-decided placement",
|
|
244
|
+
);
|
|
245
|
+
assert.equal(harness.message.mailboxId, MAILBOXES.inbox.mailboxId);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("still evaluates and can confidently move a message that has never been placement-classified", async () => {
|
|
249
|
+
const harness = buildHarness(
|
|
250
|
+
{
|
|
251
|
+
messageId: "m-1",
|
|
252
|
+
mailboxId: MAILBOXES.inbox.mailboxId,
|
|
253
|
+
movedByRemit: false,
|
|
254
|
+
},
|
|
255
|
+
async () => {
|
|
256
|
+
throw new Error("no body stored yet; must not retrieve");
|
|
257
|
+
},
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
const connection = {
|
|
261
|
+
openBox: async () => {},
|
|
262
|
+
fetchMessageBody: async () => DEMOTE_EML,
|
|
263
|
+
} as unknown as IImapConnection;
|
|
264
|
+
|
|
265
|
+
await harness.service.fetchAndGetBody(
|
|
266
|
+
"m-1",
|
|
267
|
+
"acc-1",
|
|
268
|
+
"cfg-1",
|
|
269
|
+
"INBOX",
|
|
270
|
+
async () => connection,
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
assert.deepEqual(harness.moves, [
|
|
274
|
+
{ messageId: "m-1", destinationMailboxId: MAILBOXES.junk.mailboxId },
|
|
275
|
+
]);
|
|
276
|
+
assert.equal(harness.messageUpdates[0]?.input.movedByRemit, true);
|
|
277
|
+
assert.ok(
|
|
278
|
+
typeof harness.messageUpdates[0]?.input.placementDecidedAt === "number",
|
|
279
|
+
"a genuine first evaluation must record placementDecidedAt",
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("continues to protect a message Remit itself already moved (movedByRemit: true), unchanged behavior", async () => {
|
|
284
|
+
const harness = buildHarness(
|
|
285
|
+
{
|
|
286
|
+
messageId: "m-1",
|
|
287
|
+
mailboxId: MAILBOXES.inbox.mailboxId,
|
|
288
|
+
bodyStorageKey: "s3://bodies/m-1",
|
|
289
|
+
movedByRemit: true,
|
|
290
|
+
// No placementDecidedAt — a legacy row synced before issue #383's
|
|
291
|
+
// guard existed. `classifyPlacement`'s own `movedByRemit` check
|
|
292
|
+
// must still hold on its own.
|
|
293
|
+
},
|
|
294
|
+
async () => {
|
|
295
|
+
throw noSuchKeyError();
|
|
296
|
+
},
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
const connection = {
|
|
300
|
+
openBox: async () => {},
|
|
301
|
+
fetchMessageBody: async () => DEMOTE_EML,
|
|
302
|
+
} as unknown as IImapConnection;
|
|
303
|
+
|
|
304
|
+
await harness.service.fetchAndGetBody(
|
|
305
|
+
"m-1",
|
|
306
|
+
"acc-1",
|
|
307
|
+
"cfg-1",
|
|
308
|
+
"INBOX",
|
|
309
|
+
async () => connection,
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
assert.deepEqual(
|
|
313
|
+
harness.moves,
|
|
314
|
+
[],
|
|
315
|
+
"movedByRemit must keep protecting a legacy row with no placementDecidedAt of its own",
|
|
316
|
+
);
|
|
317
|
+
assert.equal(harness.message.mailboxId, MAILBOXES.inbox.mailboxId);
|
|
318
|
+
assert.ok(
|
|
319
|
+
typeof harness.messageUpdates[0]?.input.placementDecidedAt === "number",
|
|
320
|
+
"the legacy row is backfilled with placementDecidedAt going forward, self-healing for future passes",
|
|
321
|
+
);
|
|
322
|
+
});
|
|
323
|
+
});
|
package/src/body-sync.ts
CHANGED
|
@@ -74,6 +74,7 @@ type ThreadMessageCategory = ThreadMessageItem["category"];
|
|
|
74
74
|
interface PlacementOutcome {
|
|
75
75
|
verdict?: MessagePlacementVerdict;
|
|
76
76
|
move?: { destinationMailboxId: string; destinationPath: string };
|
|
77
|
+
placementDecidedAt?: number;
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
/**
|
|
@@ -185,6 +186,19 @@ const hasDecidedCategory = (
|
|
|
185
186
|
): boolean =>
|
|
186
187
|
category !== undefined && category !== MessageCategory.uncategorized;
|
|
187
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Issue #383 (RFC 039 Non-goals): whether {@link BodySyncService.computePlacement}
|
|
191
|
+
* has already produced a verdict for this message — moved, left in place, or
|
|
192
|
+
* archived, confident or unsure alike. Absence means placement has genuinely
|
|
193
|
+
* never been evaluated. The same two re-entrant paths `hasDecidedCategory`
|
|
194
|
+
* guards (`fetchAndGetBody`'s `NoSuchKey` fallback, `syncBodies(..., force:
|
|
195
|
+
* true)`) also re-enter `computePlacement`; without this guard a message a
|
|
196
|
+
* user manually rescued (never touched by Remit, so `movedByRemit` never
|
|
197
|
+
* recorded anything) can be silently re-evaluated and moved right back.
|
|
198
|
+
*/
|
|
199
|
+
const hasDecidedPlacement = (placementDecidedAt: number | undefined): boolean =>
|
|
200
|
+
placementDecidedAt !== undefined;
|
|
201
|
+
|
|
188
202
|
export const toParsedBody = (parsed: ParsedMail): ParsedBody => ({
|
|
189
203
|
text: parsed.text ?? null,
|
|
190
204
|
html: typeof parsed.html === "string" ? parsed.html : null,
|
|
@@ -867,6 +881,11 @@ export class BodySyncService {
|
|
|
867
881
|
// this guard a re-entrant pass would let a *later* override silently
|
|
868
882
|
// rewrite a category already decided on an earlier message, which is
|
|
869
883
|
// exactly the churn RFC 030's GSI-safety argument forbids.
|
|
884
|
+
//
|
|
885
|
+
// `resolved.placementDecidedAt` (issue #383) guards the same two
|
|
886
|
+
// re-entrant paths for placement: `computePlacement` already declined to
|
|
887
|
+
// recompute a verdict once this field is set, so it is only ever present
|
|
888
|
+
// here on the pass that first decided it.
|
|
870
889
|
const existingMessage = await this.messageService.get(messageId);
|
|
871
890
|
const finalCategory = hasDecidedCategory(existingMessage.category)
|
|
872
891
|
? existingMessage.category
|
|
@@ -889,6 +908,9 @@ export class BodySyncService {
|
|
|
889
908
|
...(moved ? { movedByRemit: true } : {}),
|
|
890
909
|
...(resolved.verdict ? { placementVerdict: resolved.verdict } : {}),
|
|
891
910
|
...(filterMove ? { filterMove } : {}),
|
|
911
|
+
...(resolved.placementDecidedAt
|
|
912
|
+
? { placementDecidedAt: resolved.placementDecidedAt }
|
|
913
|
+
: {}),
|
|
892
914
|
};
|
|
893
915
|
await this.messageService.update(messageId, update);
|
|
894
916
|
this.log.info({ messageId, storageKey: bodyRef.uri }, "Body stored");
|
|
@@ -1320,8 +1342,11 @@ export class BodySyncService {
|
|
|
1320
1342
|
*
|
|
1321
1343
|
* Returns a {@link PlacementOutcome}: a `verdict` to persist whenever Remit
|
|
1322
1344
|
* decided to act (action != leave), confident and unsure alike, so the
|
|
1323
|
-
* distribution is queryable on the message;
|
|
1324
|
-
* a
|
|
1345
|
+
* distribution is queryable on the message; a `move` to enqueue only for a
|
|
1346
|
+
* confident verdict; and `placementDecidedAt` whenever a verdict — including
|
|
1347
|
+
* "leave" — was genuinely computed (issue #383), so a re-entrant call short-
|
|
1348
|
+
* circuits instead of re-deciding a placement the user may have since
|
|
1349
|
+
* overridden by hand.
|
|
1325
1350
|
*
|
|
1326
1351
|
* Always logs a structured verdict line for confident, actionable verdicts so
|
|
1327
1352
|
* the real distribution is observable on a live mailbox.
|
|
@@ -1334,7 +1359,7 @@ export class BodySyncService {
|
|
|
1334
1359
|
* alertable field instead of failing the surrounding message store. The empty
|
|
1335
1360
|
* {@link PlacementOutcome} means "no action", whether Remit genuinely decided
|
|
1336
1361
|
* to leave the message alone or placement itself failed; the alert log is
|
|
1337
|
-
* what distinguishes the latter.
|
|
1362
|
+
* what distinguishes the latter, and neither case marks the message decided.
|
|
1338
1363
|
*/
|
|
1339
1364
|
private async resolvePlacement(
|
|
1340
1365
|
messageId: string,
|
|
@@ -1378,6 +1403,16 @@ export class BodySyncService {
|
|
|
1378
1403
|
const { mailboxSpecialUseService } = placementConfig;
|
|
1379
1404
|
|
|
1380
1405
|
const message = await this.messageService.get(messageId);
|
|
1406
|
+
|
|
1407
|
+
// Issue #383: placement is meant to run once per message (RFC 039
|
|
1408
|
+
// Non-goals). Once a verdict has EVER been decided for this message —
|
|
1409
|
+
// moved, left in place, or archived — a re-entrant pass (the `NoSuchKey`
|
|
1410
|
+
// fallback in `fetchAndGetBody`, `syncBodies(..., force: true)`) must not
|
|
1411
|
+
// recompute it: a message a user has since moved by hand (which never
|
|
1412
|
+
// touches `movedByRemit`) would otherwise be silently re-evaluated
|
|
1413
|
+
// against the same signals that placed it in the first place.
|
|
1414
|
+
if (hasDecidedPlacement(message.placementDecidedAt)) return {};
|
|
1415
|
+
|
|
1381
1416
|
const junkMailbox = await mailboxSpecialUseService.findBySpecialUse(
|
|
1382
1417
|
accountId,
|
|
1383
1418
|
MailboxSpecialUse.Junk,
|
|
@@ -1421,14 +1456,16 @@ export class BodySyncService {
|
|
|
1421
1456
|
// audit record of its own. `flags.autoArchive` (issue #300) is a distinct,
|
|
1422
1457
|
// lower-priority filing preference: it only files a message away when
|
|
1423
1458
|
// `blocked`/DKIM/DMARC had nothing to say, never overriding a confident
|
|
1424
|
-
// junk/inbox verdict computed above.
|
|
1459
|
+
// junk/inbox verdict computed above. Still marked decided (issue #383):
|
|
1460
|
+
// "leave" is itself a verdict, not "not yet evaluated".
|
|
1425
1461
|
if (verdict.action === "leave") {
|
|
1426
|
-
|
|
1462
|
+
const outcome = await this.resolveAutoArchive(
|
|
1427
1463
|
mailboxSpecialUseService,
|
|
1428
1464
|
message,
|
|
1429
1465
|
accountId,
|
|
1430
1466
|
signals.autoArchive,
|
|
1431
1467
|
);
|
|
1468
|
+
return { ...outcome, placementDecidedAt: Date.now() };
|
|
1432
1469
|
}
|
|
1433
1470
|
|
|
1434
1471
|
// Audit verdict — recorded for every actionable verdict (both
|
|
@@ -1452,12 +1489,12 @@ export class BodySyncService {
|
|
|
1452
1489
|
// Only a confident verdict moves mail. An unsure verdict is recorded
|
|
1453
1490
|
// but never enqueues a move.
|
|
1454
1491
|
if (verdict.confidence !== "confident") {
|
|
1455
|
-
return { verdict: audit };
|
|
1492
|
+
return { verdict: audit, placementDecidedAt: audit.decidedAt };
|
|
1456
1493
|
}
|
|
1457
1494
|
|
|
1458
1495
|
const target =
|
|
1459
1496
|
verdict.action === "move-to-inbox" ? inboxMailbox : junkMailbox;
|
|
1460
|
-
if (!target) return { verdict: audit };
|
|
1497
|
+
if (!target) return { verdict: audit, placementDecidedAt: audit.decidedAt };
|
|
1461
1498
|
|
|
1462
1499
|
// Structured verdict line — emitted for confident, actionable verdicts
|
|
1463
1500
|
// so the real verdict distribution is observable on a live mailbox.
|
|
@@ -1480,6 +1517,7 @@ export class BodySyncService {
|
|
|
1480
1517
|
destinationMailboxId: target.mailboxId,
|
|
1481
1518
|
destinationPath: target.fullPath,
|
|
1482
1519
|
},
|
|
1520
|
+
placementDecidedAt: audit.decidedAt,
|
|
1483
1521
|
};
|
|
1484
1522
|
}
|
|
1485
1523
|
|