@remit/mailbox-service 0.0.98 → 0.0.100
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-override.test.ts +4 -8
- package/src/body-sync-category-writeonce.test.ts +17 -17
- package/src/body-sync-placement-writeonce.test.ts +37 -22
- package/src/body-sync-resync.test.ts +605 -0
- package/src/body-sync.ts +111 -154
- package/src/classification-backfill.test.ts +543 -0
- package/src/classification-backfill.ts +345 -0
- package/src/classify-message.test.ts +146 -0
- package/src/classify-message.ts +113 -0
- package/src/index.ts +16 -1
- package/src/message-category.ts +18 -0
package/src/body-sync.ts
CHANGED
|
@@ -23,7 +23,6 @@ import { deriveAddressId, deriveBodyPartId } from "@remit/data-ports/id";
|
|
|
23
23
|
import { isBulkSender } from "@remit/data-ports/wellknown";
|
|
24
24
|
import {
|
|
25
25
|
FilterState,
|
|
26
|
-
MessageCategory,
|
|
27
26
|
MessageClassificationState,
|
|
28
27
|
PlacementAction,
|
|
29
28
|
PlacementConfidence,
|
|
@@ -40,6 +39,10 @@ import pMap from "p-map";
|
|
|
40
39
|
import { BodyParseError, parseMessageBody } from "./body-parse.js";
|
|
41
40
|
import { mapBodyPartsToContent } from "./body-part-mapper.js";
|
|
42
41
|
import { calendarParts } from "./calendar-parts.js";
|
|
42
|
+
import {
|
|
43
|
+
classifyParsedMessage,
|
|
44
|
+
extractPrimaryFromEmail,
|
|
45
|
+
} from "./classify-message.js";
|
|
43
46
|
import { extractListId } from "./filters/list-id.js";
|
|
44
47
|
import type { FilterMessage } from "./filters/match.js";
|
|
45
48
|
import {
|
|
@@ -48,21 +51,16 @@ import {
|
|
|
48
51
|
FilterPipeline,
|
|
49
52
|
} from "./filters/pipeline.js";
|
|
50
53
|
import type { FlagQueueService } from "./flag-queue.js";
|
|
51
|
-
import {
|
|
52
|
-
classifyByHeaders,
|
|
53
|
-
extractAuthenticity,
|
|
54
|
-
extractAuthResult,
|
|
55
|
-
extractHasListUnsubscribe,
|
|
56
|
-
extractProviderSpam,
|
|
57
|
-
} from "./heuristics/classifyByHeaders.js";
|
|
58
54
|
import {
|
|
59
55
|
classifyPlacement,
|
|
60
56
|
type FolderPlacement,
|
|
61
57
|
type PlacementVerdict,
|
|
62
58
|
resolveSenderPlacement,
|
|
63
59
|
} from "./heuristics/classifyPlacement.js";
|
|
64
|
-
import {
|
|
65
|
-
|
|
60
|
+
import {
|
|
61
|
+
denormalizeMessageCategory,
|
|
62
|
+
hasDecidedCategory,
|
|
63
|
+
} from "./message-category.js";
|
|
66
64
|
import type { PlacementMoveService } from "./placement-move.js";
|
|
67
65
|
import { type QuarantineService, shapeFromMessageData } from "./quarantine.js";
|
|
68
66
|
import { extractSnippetFromEmail } from "./snippet.js";
|
|
@@ -133,14 +131,6 @@ const isConnectionDrop = (error: unknown): boolean => {
|
|
|
133
131
|
return code === "EConnectionClosed" || code === "NoConnection";
|
|
134
132
|
};
|
|
135
133
|
|
|
136
|
-
export const extractPrimaryFromEmail = (parsed: ParsedMail): string | null => {
|
|
137
|
-
const from = parsed.from;
|
|
138
|
-
if (!from || !from.value || from.value.length === 0) return null;
|
|
139
|
-
const address = from.value[0]?.address;
|
|
140
|
-
if (!address) return null;
|
|
141
|
-
return address.toLowerCase();
|
|
142
|
-
};
|
|
143
|
-
|
|
144
134
|
/**
|
|
145
135
|
* Project a parsed message onto the fields a filter matches against (RFC 034) —
|
|
146
136
|
* the literal-clause targets plus the text a semantic anchor embeds. Kept
|
|
@@ -158,55 +148,30 @@ const SNIPPET_LENGTH = 256;
|
|
|
158
148
|
|
|
159
149
|
/**
|
|
160
150
|
* The snippet the list row shows, from whichever body part carries text.
|
|
151
|
+
* Exported for the classification backfill (issue #1197), which denormalizes
|
|
152
|
+
* the same fields the body-store path does, from the same bytes.
|
|
161
153
|
*/
|
|
162
|
-
const extractSnippet = (parsed: ParsedMail): string =>
|
|
154
|
+
export const extractSnippet = (parsed: ParsedMail): string =>
|
|
163
155
|
extractSnippetFromEmail(
|
|
164
156
|
parsed.text,
|
|
165
157
|
typeof parsed.html === "string" ? parsed.html : undefined,
|
|
166
158
|
SNIPPET_LENGTH,
|
|
167
159
|
);
|
|
168
160
|
|
|
169
|
-
/**
|
|
170
|
-
* RFC 034 Decision 3.1: `Message.category` is written once and never mutated
|
|
171
|
-
* after — RFC 030's message-list GSI sort key depends on it never churning.
|
|
172
|
-
* "Already decided" is any real category, so a message that carries one is
|
|
173
|
-
* never re-categorized by a re-entrant pass.
|
|
174
|
-
*
|
|
175
|
-
* `uncategorized` fails this test whether or not the classifier has run, which
|
|
176
|
-
* is deliberate: this asks what the row holds, not what was done to it. Whether
|
|
177
|
-
* the classifier has run is {@link Message.classificationState}, and it is the
|
|
178
|
-
* skip guard in {@link BodySyncService.syncBodies} — not this one — that keeps
|
|
179
|
-
* a declined message from being examined twice.
|
|
180
|
-
*/
|
|
181
|
-
const hasDecidedCategory = (
|
|
182
|
-
category: ThreadMessageCategory | undefined,
|
|
183
|
-
): boolean =>
|
|
184
|
-
category !== undefined && category !== MessageCategory.uncategorized;
|
|
185
|
-
|
|
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
161
|
/**
|
|
200
162
|
* Issue #499: whether a completed body-sync pass has already stored this
|
|
201
163
|
* 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.
|
|
164
|
+
* that pass has run, so its presence means that pass finished.
|
|
206
165
|
*
|
|
207
166
|
* Key presence, and nothing more. What the classifier decided is
|
|
208
167
|
* `Message.category`; whether it ran at all is `Message.classificationState`.
|
|
209
168
|
* Neither is inferred from here — the field this reads is the storage fact.
|
|
169
|
+
*
|
|
170
|
+
* The re-fetch paths that re-enter with the key already set (`syncBodies(...,
|
|
171
|
+
* force: true)` and `fetchAndGetBody`'s `NoSuchKey` fallback) are re-stores
|
|
172
|
+
* that skip the derivations entirely (issue #1011), so this guard is what
|
|
173
|
+
* keeps a first store that lands on an already-stored row from re-deciding
|
|
174
|
+
* anything.
|
|
210
175
|
*/
|
|
211
176
|
const hasStoredBody = (bodyStorageKey: string | undefined): boolean =>
|
|
212
177
|
Boolean(bodyStorageKey);
|
|
@@ -217,9 +182,9 @@ const hasStoredBody = (bodyStorageKey: string | undefined): boolean =>
|
|
|
217
182
|
* means "nothing confident to say":
|
|
218
183
|
*
|
|
219
184
|
* - `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
|
-
*
|
|
185
|
+
* re-deciding a settled placement, and auto-archive is a re-decision. It
|
|
186
|
+
* fires on messages Remit itself placed, or that a user moved back
|
|
187
|
+
* afterwards.
|
|
223
188
|
* - A message already in Junk gets an unsure `leave` whatever the sender's
|
|
224
189
|
* `blocked` flag says, since the demote branch only fires outside Junk.
|
|
225
190
|
* Moving mail out of Junk is a rescue, which this file reserves for a
|
|
@@ -397,6 +362,12 @@ export class BodySyncService {
|
|
|
397
362
|
// messageId so we can match FETCH rows back and re-enqueue any UID the
|
|
398
363
|
// server never returns.
|
|
399
364
|
const pending = new Map<number, string>();
|
|
365
|
+
// Message IDs in this batch whose row already carried `bodyStorageKey`
|
|
366
|
+
// when `force` re-added them to `pending`: a forced re-fetch of an
|
|
367
|
+
// already-stored body is a re-store (issue #1011) — bytes only, no
|
|
368
|
+
// decision re-run. A message with no stored body inside a forced batch
|
|
369
|
+
// is still a first store and takes the full decision pass.
|
|
370
|
+
const reStoredMessageIds = new Set<string>();
|
|
400
371
|
|
|
401
372
|
// One read per round, not per message (issue #72). The list is small by
|
|
402
373
|
// design and almost always empty, so a lookup per message would put a
|
|
@@ -438,6 +409,7 @@ export class BodySyncService {
|
|
|
438
409
|
skippedCount++;
|
|
439
410
|
continue;
|
|
440
411
|
}
|
|
412
|
+
if (message.bodyStorageKey) reStoredMessageIds.add(messageId);
|
|
441
413
|
pending.set(message.uid, messageId);
|
|
442
414
|
}
|
|
443
415
|
|
|
@@ -468,6 +440,7 @@ export class BodySyncService {
|
|
|
468
440
|
accountId,
|
|
469
441
|
accountConfigId,
|
|
470
442
|
source,
|
|
443
|
+
reStoredMessageIds.has(messageId),
|
|
471
444
|
);
|
|
472
445
|
} catch (error) {
|
|
473
446
|
// A per-message store failure (e.g. the parsed-body S3 write) is a
|
|
@@ -644,12 +617,18 @@ export class BodySyncService {
|
|
|
644
617
|
* cache, per-part objects). The S3 upload never sees a whole-body concat —
|
|
645
618
|
* the storage service streams it — but mailparser still needs the full bytes,
|
|
646
619
|
* so we collect them in parallel. Later issues move parsing off the hot path.
|
|
620
|
+
*
|
|
621
|
+
* `isReStore` marks a re-fetch of a message whose row already carried
|
|
622
|
+
* `bodyStorageKey` (the `force` re-arm in {@link syncBodies}): the
|
|
623
|
+
* post-store steps then re-materialize the body artifacts without
|
|
624
|
+
* re-running any decision (issue #1011).
|
|
647
625
|
*/
|
|
648
626
|
private async storeStreamedBody(
|
|
649
627
|
messageId: string,
|
|
650
628
|
accountId: string,
|
|
651
629
|
accountConfigId: string,
|
|
652
630
|
source: Readable,
|
|
631
|
+
isReStore: boolean,
|
|
653
632
|
): Promise<void> {
|
|
654
633
|
const toStorage = new PassThrough();
|
|
655
634
|
const chunks: Buffer[] = [];
|
|
@@ -693,6 +672,7 @@ export class BodySyncService {
|
|
|
693
672
|
{
|
|
694
673
|
uri: ref.uri,
|
|
695
674
|
},
|
|
675
|
+
isReStore,
|
|
696
676
|
);
|
|
697
677
|
}
|
|
698
678
|
|
|
@@ -724,6 +704,16 @@ export class BodySyncService {
|
|
|
724
704
|
accountConfigId: string,
|
|
725
705
|
body: Buffer,
|
|
726
706
|
bodyRef: { uri: string },
|
|
707
|
+
/**
|
|
708
|
+
* Whether this call is a re-store of an already-classified message
|
|
709
|
+
* rather than a first store. The two re-entrant paths (`force: true`
|
|
710
|
+
* in `syncBodies`, and `fetchAndGetBody`'s `NoSuchKey` IMAP fallback) are
|
|
711
|
+
* both body-only re-fetches: a body re-sync fetches and stores bytes and
|
|
712
|
+
* runs no decision logic (issue #1011). When `isReStore` is true the
|
|
713
|
+
* placement/filter/category machinery is skipped entirely and the
|
|
714
|
+
* message's existing category is carried forward unchanged.
|
|
715
|
+
*/
|
|
716
|
+
isReStore: boolean,
|
|
727
717
|
): Promise<ParsedMail> {
|
|
728
718
|
// The one operation on this path that can fail because of how the message
|
|
729
719
|
// is built. Its own try block lives in `parseMessageBody`, so this frame —
|
|
@@ -733,6 +723,43 @@ export class BodySyncService {
|
|
|
733
723
|
// feed happens later, alongside the Message write (see below), once the
|
|
734
724
|
// write-once category is decided.
|
|
735
725
|
const parsed = await parseMessageBody(body);
|
|
726
|
+
|
|
727
|
+
// Body-only re-fetch (issue #1011): the decision pass already ran when
|
|
728
|
+
// this message's body first landed, so a re-store re-materializes only
|
|
729
|
+
// the body-derived artifacts — parsed cache, body parts, the storage
|
|
730
|
+
// key — and re-decides nothing: no classification, placement, filters,
|
|
731
|
+
// labels, read state, and no second engagement-counter increment.
|
|
732
|
+
// Skipping the decisions here is what lets the write-once fields stay
|
|
733
|
+
// safe without per-derivation re-entrancy guards.
|
|
734
|
+
if (isReStore) {
|
|
735
|
+
// Same ordering rule as the first-store path below: the parsed-body
|
|
736
|
+
// cache must be durable before `bodyStorageKey` — the skip-guard
|
|
737
|
+
// signal — is written, so a failure here requeues the message and
|
|
738
|
+
// the retry genuinely re-attempts the artifact writes.
|
|
739
|
+
await this.storeParsedBodyCache(
|
|
740
|
+
accountConfigId,
|
|
741
|
+
accountId,
|
|
742
|
+
messageId,
|
|
743
|
+
parsed,
|
|
744
|
+
);
|
|
745
|
+
if (!isBodyPartDeferralEnabled()) {
|
|
746
|
+
await this.storeBodyPartContents(
|
|
747
|
+
accountConfigId,
|
|
748
|
+
accountId,
|
|
749
|
+
messageId,
|
|
750
|
+
parsed,
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
await this.messageService.update(messageId, {
|
|
754
|
+
bodyStorageKey: bodyRef.uri,
|
|
755
|
+
});
|
|
756
|
+
this.log.info(
|
|
757
|
+
{ messageId, storageKey: bodyRef.uri },
|
|
758
|
+
"Body re-stored without re-running decisions",
|
|
759
|
+
);
|
|
760
|
+
return parsed;
|
|
761
|
+
}
|
|
762
|
+
|
|
736
763
|
const snippet = extractSnippet(parsed);
|
|
737
764
|
const listId = extractListId(parsed);
|
|
738
765
|
|
|
@@ -892,20 +919,19 @@ export class BodySyncService {
|
|
|
892
919
|
// once the parsed cache AND the move (when any) are.
|
|
893
920
|
//
|
|
894
921
|
// `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.
|
|
922
|
+
// GSI sort key depends on it never churning). The re-entrant re-store
|
|
923
|
+
// paths (issue #1011) return before this write; the carry-forward keeps
|
|
924
|
+
// the field write-once even when a first store lands on a row that
|
|
925
|
+
// already carries a real category — a previously-decided category wins
|
|
926
|
+
// over the just-recomputed one. It likewise protects a `flags.category`
|
|
927
|
+
// override (issue #299): recomputing would let a *later* override silently
|
|
928
|
+
// rewrite a category already decided, which is exactly the churn RFC
|
|
929
|
+
// 030's GSI-safety argument forbids.
|
|
904
930
|
//
|
|
905
|
-
// `resolved.placementDecidedAt` (issue #383)
|
|
906
|
-
//
|
|
907
|
-
//
|
|
908
|
-
//
|
|
931
|
+
// `resolved.placementDecidedAt` (issue #383) is only ever present on
|
|
932
|
+
// the pass that first decided it: the re-store paths skip placement
|
|
933
|
+
// entirely (issue #1011), so `computePlacement` is reached only on a
|
|
934
|
+
// first store.
|
|
909
935
|
const finalCategory = hasDecidedCategory(existingMessage.category)
|
|
910
936
|
? existingMessage.category
|
|
911
937
|
: classification.category;
|
|
@@ -1026,13 +1052,17 @@ export class BodySyncService {
|
|
|
1026
1052
|
|
|
1027
1053
|
// Same shared step the sync path (storeStreamedBody) runs — issue
|
|
1028
1054
|
// #1271 unified the two body paths so classification/placement no
|
|
1029
|
-
// longer depends on which one fetched the body first.
|
|
1055
|
+
// longer depends on which one fetched the body first. A store from
|
|
1056
|
+
// here is a re-store exactly when the row already claimed a stored
|
|
1057
|
+
// body — the NoSuchKey fallback above; a row with no bodyStorageKey is
|
|
1058
|
+
// a genuine first store and takes the full decision pass.
|
|
1030
1059
|
parsed = await this.applyPostStoreSteps(
|
|
1031
1060
|
messageId,
|
|
1032
1061
|
accountId,
|
|
1033
1062
|
accountConfigId,
|
|
1034
1063
|
body,
|
|
1035
1064
|
{ uri: ref.uri },
|
|
1065
|
+
message.bodyStorageKey != null,
|
|
1036
1066
|
);
|
|
1037
1067
|
} else {
|
|
1038
1068
|
parsed = await simpleParser(body);
|
|
@@ -1059,87 +1089,16 @@ export class BodySyncService {
|
|
|
1059
1089
|
/**
|
|
1060
1090
|
* Header classification, with the sender's `Address.flags.category`
|
|
1061
1091
|
* override (issue #299, RFC 039 Decision 3) substituted for the
|
|
1062
|
-
* header-derived category when one is set.
|
|
1063
|
-
*
|
|
1064
|
-
*
|
|
1065
|
-
*
|
|
1066
|
-
* `undefined`.
|
|
1067
|
-
*
|
|
1068
|
-
* The override wins outright rather than blending with the heuristic — RFC
|
|
1069
|
-
* 039 Decision 3 treats a direct reclassification as final, the same as
|
|
1070
|
-
* `flags.blocked`/`vip` already override placement. The caller
|
|
1071
|
-
* (`applyPostStoreSteps`) gates on `hasDecidedCategory` before this result
|
|
1072
|
-
* reaches a write, so a message that already carries a real category is
|
|
1073
|
-
* never re-touched regardless of what this returns.
|
|
1092
|
+
* header-derived category when one is set. Shared with the classification
|
|
1093
|
+
* backfill (issue #1197) as {@link classifyParsedMessage}, so the one-time
|
|
1094
|
+
* pass and the body-store path can never classify the same bytes
|
|
1095
|
+
* differently.
|
|
1074
1096
|
*/
|
|
1075
1097
|
private async classifyMessage(
|
|
1076
1098
|
accountConfigId: string,
|
|
1077
1099
|
parsed: ParsedMail,
|
|
1078
1100
|
): Promise<UpdateMessageInput & { category: ThreadMessageCategory }> {
|
|
1079
|
-
|
|
1080
|
-
const authenticity = extractAuthenticity(parsed);
|
|
1081
|
-
const authResult = extractAuthResult(parsed);
|
|
1082
|
-
const providerSpam = extractProviderSpam(parsed);
|
|
1083
|
-
const hasListUnsubscribe = extractHasListUnsubscribe(parsed);
|
|
1084
|
-
|
|
1085
|
-
// A passing SPF/DKIM/DMARC check proves the sending domain, not the
|
|
1086
|
-
// identity the message claims — on a shared-tenant host the verified
|
|
1087
|
-
// subdomain belongs to whoever signed up. These two comparisons say
|
|
1088
|
-
// whether the claim holds, and run only over mail the provider already
|
|
1089
|
-
// called spam.
|
|
1090
|
-
const senderMismatch =
|
|
1091
|
-
authenticity === null
|
|
1092
|
-
? {}
|
|
1093
|
-
: extractSenderMismatch(parsed, {
|
|
1094
|
-
fromDomain: authenticity.fromDomain,
|
|
1095
|
-
spamClassified: providerSpam?.classified === true,
|
|
1096
|
-
bulkSender: hasListUnsubscribe,
|
|
1097
|
-
});
|
|
1098
|
-
|
|
1099
|
-
const fromEmail = extractPrimaryFromEmail(parsed);
|
|
1100
|
-
const categoryOverride = fromEmail
|
|
1101
|
-
? await this.resolveCategoryOverride(accountConfigId, fromEmail)
|
|
1102
|
-
: undefined;
|
|
1103
|
-
|
|
1104
|
-
return {
|
|
1105
|
-
category: categoryOverride ?? headerCategory,
|
|
1106
|
-
hasListUnsubscribe,
|
|
1107
|
-
...(authenticity !== null
|
|
1108
|
-
? { authenticity: { ...authenticity, ...senderMismatch } }
|
|
1109
|
-
: {}),
|
|
1110
|
-
...(authResult !== null ? { authResult } : {}),
|
|
1111
|
-
...(providerSpam !== null ? { providerSpam } : {}),
|
|
1112
|
-
};
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
/**
|
|
1116
|
-
* The one-sided half of `Address.flags.category` (issue #299, RFC 039
|
|
1117
|
-
* Decision 3): a real value here overrides `classifyByHeaders` outright.
|
|
1118
|
-
* A second, separate `Address` read from `deriveSenderPlacementSignals`'s
|
|
1119
|
-
* (issue #300) — sharing one fetch was not practical, because the two have
|
|
1120
|
-
* different failure contracts: placement's read is best-effort (a failure
|
|
1121
|
-
* is caught and logged, never failing the sync), while this one feeds the
|
|
1122
|
-
* write-once `Message.category` at the moment it is decided, where a
|
|
1123
|
-
* masked infra failure would silently classify by headers when the user
|
|
1124
|
-
* asked for something else. Only a genuinely-absent Address is "no
|
|
1125
|
-
* override" — any other failure (throttle, infra) propagates, matching the
|
|
1126
|
-
* existing `deriveSenderPlacementSignals` convention.
|
|
1127
|
-
*/
|
|
1128
|
-
private async resolveCategoryOverride(
|
|
1129
|
-
accountConfigId: string,
|
|
1130
|
-
fromEmail: string,
|
|
1131
|
-
): Promise<ThreadMessageCategory | undefined> {
|
|
1132
|
-
try {
|
|
1133
|
-
const addressId = deriveAddressId(accountConfigId, fromEmail);
|
|
1134
|
-
const address = await this.addressService.getAddress(
|
|
1135
|
-
accountConfigId,
|
|
1136
|
-
addressId,
|
|
1137
|
-
);
|
|
1138
|
-
return address.flags?.category?.value;
|
|
1139
|
-
} catch (err) {
|
|
1140
|
-
if (!(err instanceof NotFoundError)) throw err;
|
|
1141
|
-
return undefined;
|
|
1142
|
-
}
|
|
1101
|
+
return classifyParsedMessage(this.addressService, accountConfigId, parsed);
|
|
1143
1102
|
}
|
|
1144
1103
|
|
|
1145
1104
|
private async incrementInboundCount(
|
|
@@ -1492,14 +1451,12 @@ export class BodySyncService {
|
|
|
1492
1451
|
|
|
1493
1452
|
const message = await this.messageService.get(messageId);
|
|
1494
1453
|
|
|
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 {};
|
|
1454
|
+
// Issue #383 / #1011: placement still runs once per message (RFC 039
|
|
1455
|
+
// Non-goals), but the once-per-message guarantee now comes from the
|
|
1456
|
+
// caller rather than a guard here — the re-store paths
|
|
1457
|
+
// (`fetchAndGetBody`'s `NoSuchKey` fallback, `syncBodies(..., force:
|
|
1458
|
+
// true)`) skip the decision steps entirely, so only the pass that first
|
|
1459
|
+
// stores a body ever reaches this method.
|
|
1503
1460
|
|
|
1504
1461
|
const junkMailbox =
|
|
1505
1462
|
await mailboxSpecialUseService.findJunkMailbox(accountId);
|