@remit/mailbox-service 0.0.1
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/README.md +147 -0
- package/package.json +65 -0
- package/src/account-credentials.ts +128 -0
- package/src/adversarial-mime.e2e.test.ts +311 -0
- package/src/attribute-mapper.test.ts +78 -0
- package/src/attribute-mapper.ts +147 -0
- package/src/body-part-mapper.property.test.ts +354 -0
- package/src/body-part-mapper.ts +360 -0
- package/src/body-sync-queue.test.ts +124 -0
- package/src/body-sync-queue.ts +131 -0
- package/src/body-sync-terminal.test.ts +265 -0
- package/src/body-sync-terminal.ts +172 -0
- package/src/body-sync.ts +1202 -0
- package/src/connection-factory.ts +102 -0
- package/src/connection-test.ts +208 -0
- package/src/credentials.test.ts +181 -0
- package/src/filters/match.test.ts +181 -0
- package/src/filters/match.ts +143 -0
- package/src/filters/pipeline.ts +243 -0
- package/src/flag-push-terminal.test.ts +156 -0
- package/src/flag-push-terminal.ts +106 -0
- package/src/flag-push.test.ts +408 -0
- package/src/flag-push.ts +198 -0
- package/src/flag-queue.ts +500 -0
- package/src/heuristics/classifyByHeaders.test.ts +578 -0
- package/src/heuristics/classifyByHeaders.ts +369 -0
- package/src/heuristics/classifyPlacement.ts +96 -0
- package/src/heuristics/socialDomains.ts +23 -0
- package/src/heuristics/transactionalDomains.ts +26 -0
- package/src/imapflow-connect-retry.test.ts +108 -0
- package/src/imapflow-connection.e2e.test.ts +355 -0
- package/src/imapflow-connection.integ.test.ts +327 -0
- package/src/imapflow-connection.test.ts +161 -0
- package/src/imapflow-connection.ts +1374 -0
- package/src/index.ts +214 -0
- package/src/mailbox-cursor-rebuild.test.ts +171 -0
- package/src/mailbox-cursor-rebuild.ts +146 -0
- package/src/mailbox-cursor.test.ts +249 -0
- package/src/mailbox-cursor.ts +177 -0
- package/src/mailbox-management.test.ts +103 -0
- package/src/mailbox-management.ts +235 -0
- package/src/mailbox-queue.ts +258 -0
- package/src/mailbox-sync.test.ts +153 -0
- package/src/mailbox-sync.ts +590 -0
- package/src/message-move.ts +825 -0
- package/src/message-parser.ts +26 -0
- package/src/message-sync.ts +1021 -0
- package/src/mime-walker.test.ts +311 -0
- package/src/mime-walker.ts +305 -0
- package/src/outbox-queue.ts +294 -0
- package/src/pass-through-unit-of-work.ts +17 -0
- package/src/placement-move-terminal.test.ts +179 -0
- package/src/placement-move-terminal.ts +104 -0
- package/src/placement-move.ts +224 -0
- package/src/snippet.test.ts +213 -0
- package/src/snippet.ts +152 -0
- package/src/stale-message-reconcile.test.ts +82 -0
- package/src/stale-message-reconcile.ts +55 -0
- package/src/test-helpers/isolated-mailbox.ts +44 -0
- package/src/test-helpers/mailfuzz-connection.ts +27 -0
- package/src/test-helpers/mime-tree-generator.ts +461 -0
- package/src/text/normalizer.test.ts +223 -0
- package/src/text/normalizer.ts +140 -0
- package/src/types/mailparser-augment.d.ts +15 -0
- package/src/types/natural-porter-stemmer.d.ts +50 -0
- package/src/types/stopword.d.ts +69 -0
- package/src/types.ts +349 -0
- package/test/fixtures/mime/01-flat-text-plain.bodyparts.json +8 -0
- package/test/fixtures/mime/01-flat-text-plain.eml +10 -0
- package/test/fixtures/mime/01-flat-text-plain.expected.json +10 -0
- package/test/fixtures/mime/02-flat-text-html.bodyparts.json +8 -0
- package/test/fixtures/mime/02-flat-text-html.eml +10 -0
- package/test/fixtures/mime/02-flat-text-html.expected.json +10 -0
- package/test/fixtures/mime/03-alternative-text-html.bodyparts.json +20 -0
- package/test/fixtures/mime/03-alternative-text-html.eml +19 -0
- package/test/fixtures/mime/03-alternative-text-html.expected.json +16 -0
- package/test/fixtures/mime/04-mixed-html-pdf-attachment.bodyparts.json +22 -0
- package/test/fixtures/mime/04-mixed-html-pdf-attachment.eml +20 -0
- package/test/fixtures/mime/04-mixed-html-pdf-attachment.expected.json +16 -0
- package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.bodyparts.json +22 -0
- package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.eml +21 -0
- package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.expected.json +16 -0
- package/test/fixtures/mime/06-related-html-cid-image.bodyparts.json +23 -0
- package/test/fixtures/mime/06-related-html-cid-image.eml +21 -0
- package/test/fixtures/mime/06-related-html-cid-image.expected.json +16 -0
- package/test/fixtures/mime/07-mixed-alternative-related-attachment.bodyparts.json +55 -0
- package/test/fixtures/mime/07-mixed-alternative-related-attachment.eml +47 -0
- package/test/fixtures/mime/07-mixed-alternative-related-attachment.expected.json +34 -0
- package/test/fixtures/mime/08-two-pdfs-same-filename.bodyparts.json +30 -0
- package/test/fixtures/mime/08-two-pdfs-same-filename.eml +26 -0
- package/test/fixtures/mime/08-two-pdfs-same-filename.expected.json +22 -0
- package/test/fixtures/mime/09-attachment-no-filename.bodyparts.json +21 -0
- package/test/fixtures/mime/09-attachment-no-filename.eml +20 -0
- package/test/fixtures/mime/09-attachment-no-filename.expected.json +16 -0
- package/test/fixtures/mime/10-calendar-invite.bodyparts.json +22 -0
- package/test/fixtures/mime/10-calendar-invite.eml +33 -0
- package/test/fixtures/mime/10-calendar-invite.expected.json +16 -0
- package/test/fixtures/mime/11-quoted-printable-text.bodyparts.json +8 -0
- package/test/fixtures/mime/11-quoted-printable-text.eml +10 -0
- package/test/fixtures/mime/11-quoted-printable-text.expected.json +10 -0
- package/test/fixtures/mime/12-base64-binary.bodyparts.json +22 -0
- package/test/fixtures/mime/12-base64-binary.eml +20 -0
- package/test/fixtures/mime/12-base64-binary.expected.json +16 -0
- package/test/fixtures/mime/13-message-rfc822-forward.bodyparts.json +22 -0
- package/test/fixtures/mime/13-message-rfc822-forward.eml +28 -0
- package/test/fixtures/mime/13-message-rfc822-forward.expected.json +16 -0
- package/test/fixtures/mime/14-deeply-nested-5-levels.bodyparts.json +38 -0
- package/test/fixtures/mime/14-deeply-nested-5-levels.eml +34 -0
- package/test/fixtures/mime/14-deeply-nested-5-levels.expected.json +10 -0
- package/test/fixtures/mime/15-empty-text-part.bodyparts.json +21 -0
- package/test/fixtures/mime/15-empty-text-part.eml +18 -0
- package/test/fixtures/mime/15-empty-text-part.expected.json +16 -0
- package/test/fixtures/mime/16-empty-non-text-part.bodyparts.json +30 -0
- package/test/fixtures/mime/16-empty-non-text-part.eml +24 -0
- package/test/fixtures/mime/16-empty-non-text-part.expected.json +22 -0
- package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +21 -0
- package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +31 -0
- package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +33 -0
- package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +42 -0
- package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +25 -0
- package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +15 -0
- package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +17 -0
- package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +18 -0
- package/tsconfig.json +8 -0
package/src/body-sync.ts
ADDED
|
@@ -0,0 +1,1202 @@
|
|
|
1
|
+
import { PassThrough, type Readable } from "node:stream";
|
|
2
|
+
import { inspect } from "node:util";
|
|
3
|
+
import type {
|
|
4
|
+
IAddressRepository,
|
|
5
|
+
IEnvelopeRepository,
|
|
6
|
+
IMailboxSpecialUseRepository,
|
|
7
|
+
IMessageRepository,
|
|
8
|
+
IThreadMessageRepository,
|
|
9
|
+
UpdateMessageInput,
|
|
10
|
+
} from "@remit/data-ports";
|
|
11
|
+
import { NotFoundError } from "@remit/data-ports/errors";
|
|
12
|
+
import { deriveAddressId } from "@remit/data-ports/id";
|
|
13
|
+
import { isBulkSender } from "@remit/data-ports/wellknown";
|
|
14
|
+
import {
|
|
15
|
+
MailboxSpecialUse,
|
|
16
|
+
PlacementAction,
|
|
17
|
+
PlacementConfidence,
|
|
18
|
+
SenderTrust,
|
|
19
|
+
} from "@remit/domain-enums";
|
|
20
|
+
import {
|
|
21
|
+
isStorageNotFoundError,
|
|
22
|
+
type ParsedBody,
|
|
23
|
+
type StorageService,
|
|
24
|
+
} from "@remit/storage-service";
|
|
25
|
+
import { type ParsedMail, simpleParser } from "mailparser";
|
|
26
|
+
import pMap from "p-map";
|
|
27
|
+
import { mapBodyPartsToContent } from "./body-part-mapper.js";
|
|
28
|
+
import type { FilterMessage } from "./filters/match.js";
|
|
29
|
+
import {
|
|
30
|
+
type FilterConfig,
|
|
31
|
+
type FilterDecision,
|
|
32
|
+
FilterPipeline,
|
|
33
|
+
} from "./filters/pipeline.js";
|
|
34
|
+
import {
|
|
35
|
+
classifyByHeaders,
|
|
36
|
+
extractAuthenticity,
|
|
37
|
+
extractAuthResult,
|
|
38
|
+
extractHasListUnsubscribe,
|
|
39
|
+
extractProviderSpam,
|
|
40
|
+
} from "./heuristics/classifyByHeaders.js";
|
|
41
|
+
import {
|
|
42
|
+
classifyPlacement,
|
|
43
|
+
type FolderPlacement,
|
|
44
|
+
} from "./heuristics/classifyPlacement.js";
|
|
45
|
+
import type { PlacementMoveService } from "./placement-move.js";
|
|
46
|
+
import { extractSnippetFromEmail } from "./snippet.js";
|
|
47
|
+
import { type IImapConnection, MailConnectionError } from "./types.js";
|
|
48
|
+
|
|
49
|
+
const BODY_PART_STORE_CONCURRENCY = 4;
|
|
50
|
+
|
|
51
|
+
type MessagePlacementVerdict = NonNullable<
|
|
52
|
+
UpdateMessageInput["placementVerdict"]
|
|
53
|
+
>;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Outcome of {@link BodySyncService.resolvePlacement}: the audit `verdict` to
|
|
57
|
+
* persist on the Message (present whenever Remit decided to act, i.e. action
|
|
58
|
+
* != leave), and the IMAP `move` to enqueue (present only for a confident,
|
|
59
|
+
* actionable verdict). An unsure verdict carries a `verdict` but no `move`.
|
|
60
|
+
*/
|
|
61
|
+
interface PlacementOutcome {
|
|
62
|
+
verdict?: MessagePlacementVerdict;
|
|
63
|
+
move?: { destinationMailboxId: string; destinationPath: string };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Whether the body-sync hot path defers per-part S3 objects. Each MIME leaf is
|
|
68
|
+
* its own PutObject, so during bulk sync these per-part writes dominate S3 write
|
|
69
|
+
* count and cost. With deferral ON a synced message writes only `body.eml` +
|
|
70
|
+
* `parsed.json.gz` (2 writes); the per-part objects are materialized lazily on
|
|
71
|
+
* the first `contentUrl` read via {@link BodySyncService.ensureBodyPartsStored}.
|
|
72
|
+
*
|
|
73
|
+
* Default ON (cost savings). Set `DEFER_BODY_PARTS=false`/`0`/`off` to restore
|
|
74
|
+
* the original eager per-part writes — a safe, reversible escape hatch.
|
|
75
|
+
*/
|
|
76
|
+
export const isBodyPartDeferralEnabled = (): boolean => {
|
|
77
|
+
const raw = process.env.DEFER_BODY_PARTS;
|
|
78
|
+
if (raw === undefined) return true;
|
|
79
|
+
const normalized = raw.trim().toLowerCase();
|
|
80
|
+
return normalized !== "false" && normalized !== "0" && normalized !== "off";
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Sentinel object written under a message's parts prefix once lazy
|
|
85
|
+
* materialization has stored EVERY leaf. The read path checks it first with a
|
|
86
|
+
* single HEAD and early-returns, skipping the body.eml GET, the MIME re-parse,
|
|
87
|
+
* and the per-leaf HEADs on every warm re-open.
|
|
88
|
+
*
|
|
89
|
+
* Deliberately an S3-only marker (no Message-row flag): a DDB write would
|
|
90
|
+
* re-fire the redundant vector upsert that #607 removed. A leading dot keeps it
|
|
91
|
+
* out of any real IMAP section path (those are dot-separated digits).
|
|
92
|
+
*/
|
|
93
|
+
const MATERIALIZED_SENTINEL_PART_PATH = ".materialized";
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A mid-stream socket drop during the ranged body fetch surfaces as a typed
|
|
97
|
+
* `MailConnectionError` (the connection layer classifies imapflow's
|
|
98
|
+
* `EConnectionClosed`/`NoConnection`). Detect by type/code, never by message
|
|
99
|
+
* text — the underlying library strings vary.
|
|
100
|
+
*/
|
|
101
|
+
const isConnectionDrop = (error: unknown): boolean => {
|
|
102
|
+
if (error instanceof MailConnectionError) {
|
|
103
|
+
return error.kind === "network";
|
|
104
|
+
}
|
|
105
|
+
const code = (error as { code?: string }).code;
|
|
106
|
+
return code === "EConnectionClosed" || code === "NoConnection";
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export const extractPrimaryFromEmail = (parsed: ParsedMail): string | null => {
|
|
110
|
+
const from = parsed.from;
|
|
111
|
+
if (!from || !from.value || from.value.length === 0) return null;
|
|
112
|
+
const address = from.value[0]?.address;
|
|
113
|
+
if (!address) return null;
|
|
114
|
+
return address.toLowerCase();
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Project a parsed message onto the fields a filter matches against (RFC 034) —
|
|
119
|
+
* the literal-clause targets plus the text a semantic anchor embeds. Kept
|
|
120
|
+
* separate from `ParsedMail` so {@link FilterPipeline} stays parser-agnostic.
|
|
121
|
+
*/
|
|
122
|
+
const toFilterMessage = (parsed: ParsedMail): FilterMessage => ({
|
|
123
|
+
from: extractPrimaryFromEmail(parsed) ?? "",
|
|
124
|
+
fromName: parsed.from?.value?.[0]?.name ?? "",
|
|
125
|
+
subject: parsed.subject ?? "",
|
|
126
|
+
text: parsed.text ?? "",
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
export const toParsedBody = (parsed: ParsedMail): ParsedBody => ({
|
|
130
|
+
text: parsed.text ?? null,
|
|
131
|
+
html: typeof parsed.html === "string" ? parsed.html : null,
|
|
132
|
+
attachments: (parsed.attachments ?? []).map((a) => ({
|
|
133
|
+
filename: a.filename ?? null,
|
|
134
|
+
contentType: a.contentType,
|
|
135
|
+
contentDisposition: a.contentDisposition ?? null,
|
|
136
|
+
contentId: a.contentId ?? null,
|
|
137
|
+
size: a.size,
|
|
138
|
+
})),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
export interface BodySyncLogger {
|
|
142
|
+
info(obj: Record<string, unknown>, msg: string): void;
|
|
143
|
+
debug?(obj: Record<string, unknown>, msg: string): void;
|
|
144
|
+
warn?(obj: Record<string, unknown>, msg: string): void;
|
|
145
|
+
error?(obj: Record<string, unknown>, msg: string): void;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const noopLogger: BodySyncLogger = {
|
|
149
|
+
info: () => {},
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export interface SyncBodiesResult {
|
|
153
|
+
syncedCount: number;
|
|
154
|
+
syncedMessageIds: string[];
|
|
155
|
+
skippedCount: number;
|
|
156
|
+
failedCount: number;
|
|
157
|
+
failedMessageIds: string[];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface FetchBodyResult {
|
|
161
|
+
text: string | null;
|
|
162
|
+
html: string | null;
|
|
163
|
+
storedAt: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface StoreBodyPartContentsResult {
|
|
167
|
+
stored: number;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export type ConnectionGetter = () => Promise<IImapConnection>;
|
|
171
|
+
|
|
172
|
+
export interface PlacementConfig {
|
|
173
|
+
mailboxSpecialUseService: IMailboxSpecialUseRepository;
|
|
174
|
+
/**
|
|
175
|
+
* Local-first mover for a confident placement verdict (issue #1271). Same
|
|
176
|
+
* `moveMessage(accountConfigId, messageId, destinationMailboxId, accountId)`
|
|
177
|
+
* signature as the general-purpose `MessageMoveService` it replaced here,
|
|
178
|
+
* but backed by a pending-move marker + its own reconciler queue instead of
|
|
179
|
+
* the general MESSAGE_MOVE event (see `placement-move.ts`).
|
|
180
|
+
*/
|
|
181
|
+
placementMoveService: PlacementMoveService;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export class BodySyncService {
|
|
185
|
+
private log: BodySyncLogger;
|
|
186
|
+
private readonly filterPipeline?: FilterPipeline;
|
|
187
|
+
|
|
188
|
+
constructor(
|
|
189
|
+
private messageService: IMessageRepository,
|
|
190
|
+
private storageService: StorageService,
|
|
191
|
+
private threadMessageService: IThreadMessageRepository,
|
|
192
|
+
private addressService: IAddressRepository,
|
|
193
|
+
private envelopeService: IEnvelopeRepository,
|
|
194
|
+
logger?: BodySyncLogger,
|
|
195
|
+
private readonly placementConfig?: PlacementConfig,
|
|
196
|
+
private readonly filterConfig?: FilterConfig,
|
|
197
|
+
) {
|
|
198
|
+
this.log = logger ?? noopLogger;
|
|
199
|
+
this.filterPipeline = filterConfig
|
|
200
|
+
? new FilterPipeline(filterConfig, this.log)
|
|
201
|
+
: undefined;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Sync message bodies for a batch of messages.
|
|
206
|
+
*
|
|
207
|
+
* Fail-fast on connection errors: if the IMAP connection is lost,
|
|
208
|
+
* immediately stop processing and return all remaining messages as failed
|
|
209
|
+
* so they can be requeued for later retry.
|
|
210
|
+
*
|
|
211
|
+
* @param messageIds - The message IDs to sync bodies for
|
|
212
|
+
* @param accountId - The account ID (for storage path)
|
|
213
|
+
* @param accountConfigId - The account config ID (for thread updates)
|
|
214
|
+
* @param mailboxPath - The IMAP mailbox path
|
|
215
|
+
* @param getConnection - Function to get a (lazy) IMAP connection
|
|
216
|
+
* @param force - Bypass the "already stored" skip guard and re-fetch every
|
|
217
|
+
* message in this batch even though `bodyStorageKey` is already set. Set
|
|
218
|
+
* only by the read-miss re-arm cue (a `/content` read found the storage
|
|
219
|
+
* object missing despite the DB row saying otherwise); bulk metadata-sync
|
|
220
|
+
* never sets this, so it keeps skipping on `bodyStorageKey` with no
|
|
221
|
+
* existence check.
|
|
222
|
+
*/
|
|
223
|
+
async syncBodies(
|
|
224
|
+
messageIds: string[],
|
|
225
|
+
accountId: string,
|
|
226
|
+
accountConfigId: string,
|
|
227
|
+
mailboxPath: string,
|
|
228
|
+
getConnection: ConnectionGetter,
|
|
229
|
+
force = false,
|
|
230
|
+
): Promise<SyncBodiesResult> {
|
|
231
|
+
const syncedMessageIds: string[] = [];
|
|
232
|
+
let skippedCount = 0;
|
|
233
|
+
|
|
234
|
+
// Resolve every message up front so we can issue ONE ranged FETCH for the
|
|
235
|
+
// whole batch (the desktop-client pattern) instead of a SELECT + download
|
|
236
|
+
// per message. Messages whose body is already stored are skipped here and
|
|
237
|
+
// never hit the wire — UNLESS `force` is set, in which case every message
|
|
238
|
+
// is re-fetched regardless of `bodyStorageKey` (the read-miss cue already
|
|
239
|
+
// confirmed the stored object is gone). `pending` maps each UID to its
|
|
240
|
+
// messageId so we can match FETCH rows back and re-enqueue any UID the
|
|
241
|
+
// server never returns.
|
|
242
|
+
const pending = new Map<number, string>();
|
|
243
|
+
for (const messageId of messageIds) {
|
|
244
|
+
const message = await this.messageService.get(messageId);
|
|
245
|
+
if (message.bodyStorageKey && !force) {
|
|
246
|
+
this.log.debug?.({ messageId }, "Body already stored, skipping");
|
|
247
|
+
skippedCount++;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
pending.set(message.uid, messageId);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (pending.size === 0) {
|
|
254
|
+
return this.buildResult(syncedMessageIds, skippedCount, []);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const connection = await getConnection();
|
|
258
|
+
// Single SELECT for the whole batch. openBox is idempotent, so a warm
|
|
259
|
+
// connection already on this mailbox skips the SELECT entirely.
|
|
260
|
+
await connection.openBox(mailboxPath);
|
|
261
|
+
|
|
262
|
+
let connectionLost = false;
|
|
263
|
+
try {
|
|
264
|
+
for await (const { uid, source } of connection.fetchMessageBodies([
|
|
265
|
+
...pending.keys(),
|
|
266
|
+
])) {
|
|
267
|
+
const messageId = pending.get(uid);
|
|
268
|
+
if (!messageId) {
|
|
269
|
+
// A UID we didn't ask for — drain the stream so the connection
|
|
270
|
+
// stays usable, then ignore it.
|
|
271
|
+
source.resume();
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
await this.storeStreamedBody(
|
|
276
|
+
messageId,
|
|
277
|
+
accountId,
|
|
278
|
+
accountConfigId,
|
|
279
|
+
source,
|
|
280
|
+
);
|
|
281
|
+
} catch (error) {
|
|
282
|
+
// A per-message store failure (e.g. the parsed-body S3 write) is a
|
|
283
|
+
// real fault, but it must not silently mark the message synced —
|
|
284
|
+
// that would leave parsedBody null and the message unindexed. Leave
|
|
285
|
+
// the UID in `pending` so it lands in failedMessageIds and the SQS
|
|
286
|
+
// batch requeues just this message. A dropped connection is handled
|
|
287
|
+
// by the outer catch (fail-fast on the whole remaining batch).
|
|
288
|
+
if (isConnectionDrop(error)) throw error;
|
|
289
|
+
this.log.error?.(
|
|
290
|
+
{
|
|
291
|
+
messageId,
|
|
292
|
+
errorName: (error as { name?: string }).name,
|
|
293
|
+
error: (error as Error).message,
|
|
294
|
+
},
|
|
295
|
+
"Body store failed for message; leaving for requeue",
|
|
296
|
+
);
|
|
297
|
+
// Drain the stream so the connection stays usable for the next UID.
|
|
298
|
+
source.resume();
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
pending.delete(uid);
|
|
302
|
+
syncedMessageIds.push(messageId);
|
|
303
|
+
}
|
|
304
|
+
} catch (error) {
|
|
305
|
+
this.log.error?.(
|
|
306
|
+
{ error: (error as Error).message },
|
|
307
|
+
"Body fetch stream failed",
|
|
308
|
+
);
|
|
309
|
+
// Fail-fast: a dropped connection mid-stream leaves every not-yet-yielded
|
|
310
|
+
// UID in `pending`; they fall through to failedMessageIds and re-enqueue.
|
|
311
|
+
// Any other error is a real fault — let it crash.
|
|
312
|
+
if (!isConnectionDrop(error)) {
|
|
313
|
+
throw error;
|
|
314
|
+
}
|
|
315
|
+
connectionLost = true;
|
|
316
|
+
this.log.info?.(
|
|
317
|
+
{ remainingCount: pending.size },
|
|
318
|
+
"Connection lost, aborting batch",
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Anything still pending was never yielded (mid-stream drop or a UID the
|
|
323
|
+
// server silently omitted) — re-enqueue it.
|
|
324
|
+
const failedMessageIds = [...pending.values()];
|
|
325
|
+
|
|
326
|
+
this.log.info(
|
|
327
|
+
{
|
|
328
|
+
synced: syncedMessageIds.length,
|
|
329
|
+
skipped: skippedCount,
|
|
330
|
+
failed: failedMessageIds.length,
|
|
331
|
+
total: messageIds.length,
|
|
332
|
+
aborted: connectionLost,
|
|
333
|
+
},
|
|
334
|
+
"Body sync complete",
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
return this.buildResult(syncedMessageIds, skippedCount, failedMessageIds);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private buildResult(
|
|
341
|
+
syncedMessageIds: string[],
|
|
342
|
+
skippedCount: number,
|
|
343
|
+
failedMessageIds: string[],
|
|
344
|
+
): SyncBodiesResult {
|
|
345
|
+
return {
|
|
346
|
+
syncedCount: syncedMessageIds.length,
|
|
347
|
+
syncedMessageIds,
|
|
348
|
+
skippedCount,
|
|
349
|
+
failedCount: failedMessageIds.length,
|
|
350
|
+
failedMessageIds,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Stream one message body straight to storage while teeing the bytes into a
|
|
356
|
+
* buffer for the parse-dependent steps (snippet, classification, parsed-body
|
|
357
|
+
* cache, per-part objects). The S3 upload never sees a whole-body concat —
|
|
358
|
+
* the storage service streams it — but mailparser still needs the full bytes,
|
|
359
|
+
* so we collect them in parallel. Later issues move parsing off the hot path.
|
|
360
|
+
*/
|
|
361
|
+
private async storeStreamedBody(
|
|
362
|
+
messageId: string,
|
|
363
|
+
accountId: string,
|
|
364
|
+
accountConfigId: string,
|
|
365
|
+
source: Readable,
|
|
366
|
+
): Promise<void> {
|
|
367
|
+
const toStorage = new PassThrough();
|
|
368
|
+
const chunks: Buffer[] = [];
|
|
369
|
+
|
|
370
|
+
// Tee the source: bytes flow to storage as a stream (no whole-body concat
|
|
371
|
+
// on the upload path) while we also collect them for the parse-dependent
|
|
372
|
+
// steps below, which still need the full body for mailparser. The store
|
|
373
|
+
// and the tee are awaited together so neither rejection is orphaned.
|
|
374
|
+
const tee = new Promise<void>((resolve, reject) => {
|
|
375
|
+
source.on("data", (chunk: Buffer) => {
|
|
376
|
+
chunks.push(chunk);
|
|
377
|
+
toStorage.write(chunk);
|
|
378
|
+
});
|
|
379
|
+
source.on("end", () => {
|
|
380
|
+
toStorage.end();
|
|
381
|
+
resolve();
|
|
382
|
+
});
|
|
383
|
+
source.on("error", (err) => {
|
|
384
|
+
toStorage.destroy(err);
|
|
385
|
+
reject(err);
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const [ref] = await Promise.all([
|
|
390
|
+
this.storageService.storeMessageBodyStream({
|
|
391
|
+
accountConfigId,
|
|
392
|
+
accountId,
|
|
393
|
+
messageId,
|
|
394
|
+
content: toStorage,
|
|
395
|
+
}),
|
|
396
|
+
tee,
|
|
397
|
+
]);
|
|
398
|
+
|
|
399
|
+
const body = Buffer.concat(chunks);
|
|
400
|
+
|
|
401
|
+
await this.applyPostStoreSteps(
|
|
402
|
+
messageId,
|
|
403
|
+
accountId,
|
|
404
|
+
accountConfigId,
|
|
405
|
+
body,
|
|
406
|
+
{
|
|
407
|
+
uri: ref.uri,
|
|
408
|
+
},
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* The steps every synced body must go through regardless of which path
|
|
414
|
+
* fetched it first — the sync path ({@link storeStreamedBody}) and the
|
|
415
|
+
* read-path backfill ({@link fetchAndGetBody}) both call this instead of
|
|
416
|
+
* duplicating (and drifting on) their own subset. Before issue #1271,
|
|
417
|
+
* `fetchAndGetBody` skipped classification/placement entirely — a message
|
|
418
|
+
* materialized by a read got different treatment than one synced in bulk.
|
|
419
|
+
*
|
|
420
|
+
* Order matters for two independent reasons, both load-bearing:
|
|
421
|
+
* 1. The parsed-body cache must be durable before `bodyStorageKey` (the
|
|
422
|
+
* skip-guard signal) — see the comment on the write below.
|
|
423
|
+
* 2. A placement move — local mailboxId change, pending marker, IMAP-push
|
|
424
|
+
* enqueue (`PlacementMoveService.moveMessage`, issue #1271) — must
|
|
425
|
+
* complete, or fully fail, BEFORE `bodyStorageKey` is written. That
|
|
426
|
+
* failure is never swallowed: if it throws, `bodyStorageKey` is not yet
|
|
427
|
+
* durable, so a retry reprocesses the message from scratch (marker
|
|
428
|
+
* included) instead of the skip-guard stranding a `movedByRemit` flag
|
|
429
|
+
* with no marker behind it — the defect this issue fixes.
|
|
430
|
+
*
|
|
431
|
+
* Returns the parsed mail so callers that already need it (both do) don't
|
|
432
|
+
* pay for mailparser twice.
|
|
433
|
+
*/
|
|
434
|
+
private async applyPostStoreSteps(
|
|
435
|
+
messageId: string,
|
|
436
|
+
accountId: string,
|
|
437
|
+
accountConfigId: string,
|
|
438
|
+
body: Buffer,
|
|
439
|
+
bodyRef: { uri: string },
|
|
440
|
+
): Promise<ParsedMail> {
|
|
441
|
+
// Snippet + thread update; reuses the parsed mail for the steps below.
|
|
442
|
+
// This is a ThreadMessage write — a different entity — so it does NOT
|
|
443
|
+
// trigger the Message-filtered stream bridge and stays separate.
|
|
444
|
+
const parsed = await this.updateSnippets(messageId, accountConfigId, body);
|
|
445
|
+
|
|
446
|
+
// Compute the header classification once. The derived fields are folded
|
|
447
|
+
// into the single Message update below — they are NOT written here.
|
|
448
|
+
const classification = this.classifyMessage(parsed);
|
|
449
|
+
|
|
450
|
+
// Decide the placement move from the in-memory classification before the
|
|
451
|
+
// write, so its `movedByRemit` flag and audit verdict join the same
|
|
452
|
+
// UpdateItem. The verdict is recorded whenever Remit decided to act
|
|
453
|
+
// (action != leave); the move is enqueued only for a confident,
|
|
454
|
+
// actionable verdict.
|
|
455
|
+
const resolved = await this.resolvePlacement(
|
|
456
|
+
messageId,
|
|
457
|
+
accountId,
|
|
458
|
+
accountConfigId,
|
|
459
|
+
parsed,
|
|
460
|
+
classification,
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
// Index-time filter pass (RFC 034). Isolated the same way placement is —
|
|
464
|
+
// FilterPipeline.evaluate swallows any read/decision failure and returns
|
|
465
|
+
// no actions, so a schema-drifted filter query never fails the body store.
|
|
466
|
+
// The DECISION is computed here; the applies below are unswallowed.
|
|
467
|
+
const filterDecision = await this.evaluateFilters(
|
|
468
|
+
accountConfigId,
|
|
469
|
+
messageId,
|
|
470
|
+
parsed,
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
// Store the parsed-body cache BEFORE persisting bodyStorageKey. The skip
|
|
474
|
+
// guard in syncBodies treats a stored bodyStorageKey as "fully synced",
|
|
475
|
+
// so if we wrote it first and the parsed-cache write then failed, the
|
|
476
|
+
// requeued retry would skip the message — leaving parsedBody null and the
|
|
477
|
+
// search-index upsert bodyless forever. A parsed-cache failure here throws
|
|
478
|
+
// and propagates before bodyStorageKey is written, so the requeued message
|
|
479
|
+
// genuinely re-attempts the parsed write and gets indexed.
|
|
480
|
+
await this.storeParsedBodyCache(
|
|
481
|
+
accountConfigId,
|
|
482
|
+
accountId,
|
|
483
|
+
messageId,
|
|
484
|
+
parsed,
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
// Per-part S3 objects dominate S3 writes during bulk sync. When deferral
|
|
488
|
+
// is enabled we skip them here; they are materialized lazily on the first
|
|
489
|
+
// `contentUrl` read (see ensureBodyPartsStored), keeping bulk sync at 2
|
|
490
|
+
// writes/message (body.eml + parsed.json.gz).
|
|
491
|
+
if (!isBodyPartDeferralEnabled()) {
|
|
492
|
+
await this.storeBodyPartContents(
|
|
493
|
+
accountConfigId,
|
|
494
|
+
accountId,
|
|
495
|
+
messageId,
|
|
496
|
+
parsed,
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Local-first move (issue #1271): local mailboxId change + pending marker +
|
|
501
|
+
// IMAP-push enqueue via PlacementMoveService, UNSWALLOWED — a throw here
|
|
502
|
+
// propagates before bodyStorageKey is written (see the method doc above),
|
|
503
|
+
// so a genuine SQS/DDB write failure requeues the message rather than being
|
|
504
|
+
// absorbed. A matched filter's move is exclusive and outranks the
|
|
505
|
+
// classifier's placement move (RFC 034 Decision 3.1) — an explicit user
|
|
506
|
+
// rule wins the single mailbox a message occupies — so at most one move is
|
|
507
|
+
// enqueued. A non-confident/leave verdict with no filter move is a no-op.
|
|
508
|
+
if (filterDecision.move) {
|
|
509
|
+
await this.filterConfig?.placementMoveService.moveMessage(
|
|
510
|
+
accountConfigId,
|
|
511
|
+
messageId,
|
|
512
|
+
filterDecision.move.destinationMailboxId,
|
|
513
|
+
accountId,
|
|
514
|
+
);
|
|
515
|
+
} else if (resolved.move) {
|
|
516
|
+
await this.placementConfig?.placementMoveService.moveMessage(
|
|
517
|
+
accountConfigId,
|
|
518
|
+
messageId,
|
|
519
|
+
resolved.move.destinationMailboxId,
|
|
520
|
+
accountId,
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Additive label actions (RFC 034 Decision 3.1): every matching filter's
|
|
525
|
+
// label applies. The deterministic MessageLabel upsert is idempotent, so a
|
|
526
|
+
// requeue after a later failure re-applies safely; UNSWALLOWED, so a DDB
|
|
527
|
+
// write failure propagates before bodyStorageKey is written.
|
|
528
|
+
for (const label of filterDecision.labels) {
|
|
529
|
+
await this.filterConfig?.messageLabelService.apply({
|
|
530
|
+
messageId,
|
|
531
|
+
labelId: label.labelId,
|
|
532
|
+
accountConfigId,
|
|
533
|
+
appliedByFilterId: label.filterId,
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const moved = Boolean(resolved.move || filterDecision.move);
|
|
538
|
+
|
|
539
|
+
// ONE Message UpdateItem per synced message: bodyStorageKey + every
|
|
540
|
+
// classification/derived field + the move flag + the audit verdict. Each
|
|
541
|
+
// extra Message mutation emits a DDB stream record that fans out to a
|
|
542
|
+
// redundant S3-Vectors upsert, so we collapse them into a single write.
|
|
543
|
+
// The flag is folded in only when a move WAS applied (by the classifier or
|
|
544
|
+
// a filter); the verdict is folded in whenever Remit decided to act.
|
|
545
|
+
// Written LAST so bodyStorageKey — the skip-guard signal — is only durable
|
|
546
|
+
// once the parsed cache AND the move (when any) are.
|
|
547
|
+
const update: UpdateMessageInput = {
|
|
548
|
+
bodyStorageKey: bodyRef.uri,
|
|
549
|
+
...classification,
|
|
550
|
+
...(moved ? { movedByRemit: true } : {}),
|
|
551
|
+
...(resolved.verdict ? { placementVerdict: resolved.verdict } : {}),
|
|
552
|
+
};
|
|
553
|
+
await this.messageService.update(messageId, update);
|
|
554
|
+
this.log.info({ messageId, storageKey: bodyRef.uri }, "Body stored");
|
|
555
|
+
|
|
556
|
+
// From-Address engagement counter (Address entity, not Message).
|
|
557
|
+
await this.incrementInboundCount(
|
|
558
|
+
messageId,
|
|
559
|
+
accountConfigId,
|
|
560
|
+
parsed,
|
|
561
|
+
classification,
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
return parsed;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Fetch a single message body, store it, and return the parsed content.
|
|
569
|
+
*
|
|
570
|
+
* If the body is already stored, retrieves it from storage.
|
|
571
|
+
* Otherwise fetches from IMAP, stores it, and returns the parsed content.
|
|
572
|
+
*
|
|
573
|
+
* @param messageId - The message ID to fetch
|
|
574
|
+
* @param accountId - The account ID (for storage path)
|
|
575
|
+
* @param accountConfigId - The account config ID (for thread updates)
|
|
576
|
+
* @param mailboxPath - The IMAP mailbox path
|
|
577
|
+
* @param getConnection - Function to get a (lazy) IMAP connection
|
|
578
|
+
* @returns Parsed text and HTML content
|
|
579
|
+
*/
|
|
580
|
+
async fetchAndGetBody(
|
|
581
|
+
messageId: string,
|
|
582
|
+
accountId: string,
|
|
583
|
+
accountConfigId: string,
|
|
584
|
+
mailboxPath: string,
|
|
585
|
+
getConnection: ConnectionGetter,
|
|
586
|
+
): Promise<FetchBodyResult> {
|
|
587
|
+
const message = await this.messageService.get(messageId);
|
|
588
|
+
|
|
589
|
+
let body: Buffer;
|
|
590
|
+
let needsStore = false;
|
|
591
|
+
|
|
592
|
+
if (message.bodyStorageKey) {
|
|
593
|
+
// Body already stored, try to retrieve from storage
|
|
594
|
+
this.log.debug?.({ messageId }, "Retrieving body from storage");
|
|
595
|
+
try {
|
|
596
|
+
body = await this.storageService.retrieve(message.bodyStorageKey);
|
|
597
|
+
} catch (err) {
|
|
598
|
+
// Only a genuinely-missing object (NoSuchKey) is a safe IMAP
|
|
599
|
+
// fallback — that's the cross-environment / never-stored case.
|
|
600
|
+
// A permission/infra error (AccessDenied, throttle) must NOT be
|
|
601
|
+
// masked as a missing object: let it crash so it's observable.
|
|
602
|
+
if (!isStorageNotFoundError(err)) {
|
|
603
|
+
this.log.error?.(
|
|
604
|
+
{
|
|
605
|
+
messageId,
|
|
606
|
+
storageKey: message.bodyStorageKey,
|
|
607
|
+
errorName: (err as { name?: string }).name,
|
|
608
|
+
errorCode: (err as { Code?: string }).Code,
|
|
609
|
+
error: inspect(err),
|
|
610
|
+
},
|
|
611
|
+
"Body storage retrieval failed (non-NoSuchKey); not falling back to IMAP",
|
|
612
|
+
);
|
|
613
|
+
throw err;
|
|
614
|
+
}
|
|
615
|
+
this.log.debug?.(
|
|
616
|
+
{ messageId, error: (err as Error).message },
|
|
617
|
+
"Body object missing (NoSuchKey), falling back to IMAP",
|
|
618
|
+
);
|
|
619
|
+
needsStore = true;
|
|
620
|
+
body = await this.fetchFromImap(
|
|
621
|
+
message.uid,
|
|
622
|
+
mailboxPath,
|
|
623
|
+
getConnection,
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
} else {
|
|
627
|
+
// Fetch from IMAP and store
|
|
628
|
+
needsStore = true;
|
|
629
|
+
body = await this.fetchFromImap(message.uid, mailboxPath, getConnection);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
let parsed: ParsedMail;
|
|
633
|
+
|
|
634
|
+
if (needsStore) {
|
|
635
|
+
const ref = await this.storageService.storeMessageBody({
|
|
636
|
+
accountConfigId,
|
|
637
|
+
accountId,
|
|
638
|
+
messageId,
|
|
639
|
+
content: body,
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
// Same shared step the sync path (storeStreamedBody) runs — issue
|
|
643
|
+
// #1271 unified the two body paths so classification/placement no
|
|
644
|
+
// longer depends on which one fetched the body first.
|
|
645
|
+
parsed = await this.applyPostStoreSteps(
|
|
646
|
+
messageId,
|
|
647
|
+
accountId,
|
|
648
|
+
accountConfigId,
|
|
649
|
+
body,
|
|
650
|
+
{ uri: ref.uri },
|
|
651
|
+
);
|
|
652
|
+
} else {
|
|
653
|
+
parsed = await simpleParser(body);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
return {
|
|
657
|
+
text: parsed.text ?? null,
|
|
658
|
+
html: typeof parsed.html === "string" ? parsed.html : null,
|
|
659
|
+
storedAt: message.bodyStorageKey ?? "newly-stored",
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
private async fetchFromImap(
|
|
664
|
+
uid: number,
|
|
665
|
+
mailboxPath: string,
|
|
666
|
+
getConnection: ConnectionGetter,
|
|
667
|
+
): Promise<Buffer> {
|
|
668
|
+
this.log.debug?.({ uid }, "Fetching body from IMAP");
|
|
669
|
+
const connection = await getConnection();
|
|
670
|
+
await connection.openBox(mailboxPath);
|
|
671
|
+
return connection.fetchMessageBody(uid);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Pure header classification. Returns the subset of the Message update that
|
|
676
|
+
* carries the derived fields; the caller folds it into a single UpdateItem
|
|
677
|
+
* alongside `bodyStorageKey`. Optional signals are omitted when absent so we
|
|
678
|
+
* never overwrite an existing value with `undefined`.
|
|
679
|
+
*/
|
|
680
|
+
private classifyMessage(parsed: ParsedMail): UpdateMessageInput {
|
|
681
|
+
const category = classifyByHeaders(parsed);
|
|
682
|
+
const authenticity = extractAuthenticity(parsed);
|
|
683
|
+
const authResult = extractAuthResult(parsed);
|
|
684
|
+
const providerSpam = extractProviderSpam(parsed);
|
|
685
|
+
const hasListUnsubscribe = extractHasListUnsubscribe(parsed);
|
|
686
|
+
return {
|
|
687
|
+
category,
|
|
688
|
+
hasListUnsubscribe,
|
|
689
|
+
...(authenticity !== null ? { authenticity } : {}),
|
|
690
|
+
...(authResult !== null ? { authResult } : {}),
|
|
691
|
+
...(providerSpam !== null ? { providerSpam } : {}),
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
private async incrementInboundCount(
|
|
696
|
+
messageId: string,
|
|
697
|
+
accountConfigId: string,
|
|
698
|
+
parsed: ParsedMail,
|
|
699
|
+
classification: UpdateMessageInput,
|
|
700
|
+
): Promise<void> {
|
|
701
|
+
const fromEmail = extractPrimaryFromEmail(parsed);
|
|
702
|
+
if (!fromEmail) {
|
|
703
|
+
this.log.debug?.(
|
|
704
|
+
{ messageId },
|
|
705
|
+
"No From address; skipping inbound counter",
|
|
706
|
+
);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const addressId = deriveAddressId(accountConfigId, fromEmail);
|
|
711
|
+
const bulk = isBulkSender(
|
|
712
|
+
classification.category,
|
|
713
|
+
classification.hasListUnsubscribe ?? false,
|
|
714
|
+
);
|
|
715
|
+
await this.addressService.incrementInboundCount(
|
|
716
|
+
accountConfigId,
|
|
717
|
+
addressId,
|
|
718
|
+
Date.now(),
|
|
719
|
+
bulk,
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
private async deriveSenderTrust(
|
|
724
|
+
accountConfigId: string,
|
|
725
|
+
fromEmail: string,
|
|
726
|
+
): Promise<(typeof SenderTrust)[keyof typeof SenderTrust]> {
|
|
727
|
+
try {
|
|
728
|
+
const addressId = deriveAddressId(accountConfigId, fromEmail);
|
|
729
|
+
const address = await this.addressService.getAddress(
|
|
730
|
+
accountConfigId,
|
|
731
|
+
addressId,
|
|
732
|
+
);
|
|
733
|
+
if (address.flags?.vip?.value === true) return SenderTrust.Vip;
|
|
734
|
+
if (address.flags?.wellknown?.value === true)
|
|
735
|
+
return SenderTrust.Wellknown;
|
|
736
|
+
} catch (err) {
|
|
737
|
+
// A genuinely-absent address means "unknown trust". Any other failure
|
|
738
|
+
// (AccessDenied, throttle, infra) must NOT be silently downgraded to
|
|
739
|
+
// Unknown — let it crash so the rescue decision isn't made on bad data.
|
|
740
|
+
if (!(err instanceof NotFoundError)) throw err;
|
|
741
|
+
}
|
|
742
|
+
return SenderTrust.Unknown;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Evaluate the account's active filters against a synced message (RFC 034),
|
|
747
|
+
* BEFORE the single Message update — so a filter's `movedByRemit` flag joins
|
|
748
|
+
* that same UpdateItem. Returns the actions to apply (labels + at most one
|
|
749
|
+
* move); the caller applies them. A no-op returning no actions when no
|
|
750
|
+
* {@link FilterConfig} is wired.
|
|
751
|
+
*
|
|
752
|
+
* Isolation lives in {@link FilterPipeline.evaluate}: this read/decision phase
|
|
753
|
+
* never fails the body store (the #1246 placement precedent). The applies of
|
|
754
|
+
* the returned decision are the caller's and are deliberately unswallowed.
|
|
755
|
+
*/
|
|
756
|
+
private async evaluateFilters(
|
|
757
|
+
accountConfigId: string,
|
|
758
|
+
messageId: string,
|
|
759
|
+
parsed: ParsedMail,
|
|
760
|
+
): Promise<FilterDecision> {
|
|
761
|
+
if (!this.filterPipeline) return { labels: [] };
|
|
762
|
+
return this.filterPipeline.evaluate(
|
|
763
|
+
accountConfigId,
|
|
764
|
+
messageId,
|
|
765
|
+
toFilterMessage(parsed),
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Resolve the placement decision for a message, BEFORE the single Message
|
|
771
|
+
* update — so both the `movedByRemit` flag and the audit verdict join that
|
|
772
|
+
* one UpdateItem instead of a second mutation that would fan out another
|
|
773
|
+
* redundant S3-Vectors upsert. Drives both directions (junk → inbox rescue,
|
|
774
|
+
* inbox → junk demote) off the pure {@link classifyPlacement} verdict.
|
|
775
|
+
*
|
|
776
|
+
* Returns a {@link PlacementOutcome}: a `verdict` to persist whenever Remit
|
|
777
|
+
* decided to act (action != leave), confident and unsure alike, so the
|
|
778
|
+
* distribution is queryable on the message; and a `move` to enqueue only for
|
|
779
|
+
* a confident verdict.
|
|
780
|
+
*
|
|
781
|
+
* Always logs a structured verdict line for confident, actionable verdicts so
|
|
782
|
+
* the real distribution is observable on a live mailbox.
|
|
783
|
+
*
|
|
784
|
+
* The verdict reads the just-computed classification rather than a persisted
|
|
785
|
+
* row, since those fields are not written until the single update. Placement
|
|
786
|
+
* is auxiliary to the body store — the primary artifact (body.eml) is already
|
|
787
|
+
* durable by the time this runs — so a failure here (e.g. a placement-repo
|
|
788
|
+
* query erroring on a schema-drifted DB) is caught and logged loudly with an
|
|
789
|
+
* alertable field instead of failing the surrounding message store. The empty
|
|
790
|
+
* {@link PlacementOutcome} means "no action", whether Remit genuinely decided
|
|
791
|
+
* to leave the message alone or placement itself failed; the alert log is
|
|
792
|
+
* what distinguishes the latter.
|
|
793
|
+
*/
|
|
794
|
+
private async resolvePlacement(
|
|
795
|
+
messageId: string,
|
|
796
|
+
accountId: string,
|
|
797
|
+
accountConfigId: string,
|
|
798
|
+
parsed: ParsedMail,
|
|
799
|
+
classification: UpdateMessageInput,
|
|
800
|
+
): Promise<PlacementOutcome> {
|
|
801
|
+
if (!this.placementConfig) return {};
|
|
802
|
+
return this.computePlacement(
|
|
803
|
+
this.placementConfig,
|
|
804
|
+
messageId,
|
|
805
|
+
accountId,
|
|
806
|
+
accountConfigId,
|
|
807
|
+
parsed,
|
|
808
|
+
classification,
|
|
809
|
+
).catch((error: unknown) => {
|
|
810
|
+
this.log.error?.(
|
|
811
|
+
{
|
|
812
|
+
alert: "body_sync_placement_failed",
|
|
813
|
+
messageId,
|
|
814
|
+
accountId,
|
|
815
|
+
accountConfigId,
|
|
816
|
+
errorName: (error as { name?: string })?.name,
|
|
817
|
+
error: inspect(error),
|
|
818
|
+
},
|
|
819
|
+
"Placement resolution failed; body already stored, continuing without placement (best-effort, non-fatal)",
|
|
820
|
+
);
|
|
821
|
+
return {};
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
private async computePlacement(
|
|
826
|
+
placementConfig: PlacementConfig,
|
|
827
|
+
messageId: string,
|
|
828
|
+
accountId: string,
|
|
829
|
+
accountConfigId: string,
|
|
830
|
+
parsed: ParsedMail,
|
|
831
|
+
classification: UpdateMessageInput,
|
|
832
|
+
): Promise<PlacementOutcome> {
|
|
833
|
+
const { mailboxSpecialUseService } = placementConfig;
|
|
834
|
+
|
|
835
|
+
const message = await this.messageService.get(messageId);
|
|
836
|
+
const junkMailbox = await mailboxSpecialUseService.findBySpecialUse(
|
|
837
|
+
accountId,
|
|
838
|
+
MailboxSpecialUse.Junk,
|
|
839
|
+
);
|
|
840
|
+
const inboxMailbox =
|
|
841
|
+
await mailboxSpecialUseService.findInboxMailbox(accountId);
|
|
842
|
+
|
|
843
|
+
const placement: FolderPlacement =
|
|
844
|
+
junkMailbox && message.mailboxId === junkMailbox.mailboxId
|
|
845
|
+
? "junk"
|
|
846
|
+
: inboxMailbox && message.mailboxId === inboxMailbox.mailboxId
|
|
847
|
+
? "inbox"
|
|
848
|
+
: "other";
|
|
849
|
+
|
|
850
|
+
const fromEmail = extractPrimaryFromEmail(parsed);
|
|
851
|
+
const senderTrust = fromEmail
|
|
852
|
+
? await this.deriveSenderTrust(accountConfigId, fromEmail)
|
|
853
|
+
: SenderTrust.Unknown;
|
|
854
|
+
|
|
855
|
+
// The verdict needs the classification signals (providerSpam,
|
|
856
|
+
// authResult, authenticity) that this body-sync pass just derived; the
|
|
857
|
+
// stored row does not carry them yet, so overlay them onto the message.
|
|
858
|
+
const candidate = { ...message, ...classification };
|
|
859
|
+
const verdict = classifyPlacement(candidate, placement, senderTrust);
|
|
860
|
+
|
|
861
|
+
// A `leave` verdict carries no audit record and no move.
|
|
862
|
+
if (verdict.action === "leave") {
|
|
863
|
+
return {};
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Audit verdict — recorded for every actionable verdict (both
|
|
867
|
+
// confidences), so the distribution is queryable on the message.
|
|
868
|
+
// Independent of whether a move is enqueued.
|
|
869
|
+
const audit: MessagePlacementVerdict = {
|
|
870
|
+
action:
|
|
871
|
+
verdict.action === "move-to-inbox"
|
|
872
|
+
? PlacementAction.MoveToInbox
|
|
873
|
+
: PlacementAction.MoveToJunk,
|
|
874
|
+
confidence:
|
|
875
|
+
verdict.confidence === "confident"
|
|
876
|
+
? PlacementConfidence.Confident
|
|
877
|
+
: PlacementConfidence.Unsure,
|
|
878
|
+
fromPlacement: placement,
|
|
879
|
+
reasons: verdict.reasons,
|
|
880
|
+
dryRun: false,
|
|
881
|
+
decidedAt: Date.now(),
|
|
882
|
+
};
|
|
883
|
+
|
|
884
|
+
// Only a confident verdict moves mail. An unsure verdict is recorded
|
|
885
|
+
// but never enqueues a move.
|
|
886
|
+
if (verdict.confidence !== "confident") {
|
|
887
|
+
return { verdict: audit };
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const target =
|
|
891
|
+
verdict.action === "move-to-inbox" ? inboxMailbox : junkMailbox;
|
|
892
|
+
if (!target) return { verdict: audit };
|
|
893
|
+
|
|
894
|
+
// Structured verdict line — emitted for confident, actionable verdicts
|
|
895
|
+
// so the real verdict distribution is observable on a live mailbox.
|
|
896
|
+
this.log.info(
|
|
897
|
+
{
|
|
898
|
+
messageId,
|
|
899
|
+
accountId,
|
|
900
|
+
placement,
|
|
901
|
+
action: verdict.action,
|
|
902
|
+
confidence: verdict.confidence,
|
|
903
|
+
reasons: verdict.reasons,
|
|
904
|
+
destinationMailboxId: target.mailboxId,
|
|
905
|
+
},
|
|
906
|
+
"Placement verdict",
|
|
907
|
+
);
|
|
908
|
+
|
|
909
|
+
return {
|
|
910
|
+
verdict: audit,
|
|
911
|
+
move: {
|
|
912
|
+
destinationMailboxId: target.mailboxId,
|
|
913
|
+
destinationPath: target.fullPath,
|
|
914
|
+
},
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// The old `enqueuePlacementMove` (best-effort, catch-and-log) lived here.
|
|
919
|
+
// Issue #1271: it ran AFTER `bodyStorageKey` was already durable, so a
|
|
920
|
+
// failure was swallowed to avoid stranding the message behind the
|
|
921
|
+
// body-sync skip guard — but that meant `movedByRemit` could be true with
|
|
922
|
+
// no record the move ever reached IMAP. Replaced by the unswallowed call
|
|
923
|
+
// inside `applyPostStoreSteps`, sequenced BEFORE `bodyStorageKey` is
|
|
924
|
+
// written, backed by `PlacementMoveService`'s pending-move marker.
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Persist one S3 object per non-multipart leaf so the SPA can resolve
|
|
928
|
+
* `BodyPartResponse.contentUrl` (#298). Keys follow the layout
|
|
929
|
+
* `accounts/{accountConfigId}/{accountId}/messages/{messageId}/parts/{partPath}`
|
|
930
|
+
* so they line up with the URL shape `derive/contentUrl.ts` emits.
|
|
931
|
+
*
|
|
932
|
+
* The mapper is total (#395 PR B): every leaf gets a `BodyPartContentPair`,
|
|
933
|
+
* possibly with a zero-byte content for leaves that have no source bytes
|
|
934
|
+
* (genuinely empty parts, or pathological inputs the positional fallback
|
|
935
|
+
* couldn't pair). No try/catch is needed; the only failure surface here is
|
|
936
|
+
* an S3 write itself, which `pMap` surfaces directly.
|
|
937
|
+
*
|
|
938
|
+
* If `listBodyParts` returns an empty list (e.g. a legacy message synced
|
|
939
|
+
* before #133 populated BodyPart rows), this is a no-op.
|
|
940
|
+
*/
|
|
941
|
+
private async storeBodyPartContents(
|
|
942
|
+
accountConfigId: string,
|
|
943
|
+
accountId: string,
|
|
944
|
+
messageId: string,
|
|
945
|
+
parsed: ParsedMail,
|
|
946
|
+
options?: { skipExisting?: boolean },
|
|
947
|
+
): Promise<StoreBodyPartContentsResult> {
|
|
948
|
+
const bodyParts = await this.envelopeService.listBodyParts(messageId);
|
|
949
|
+
if (bodyParts.length === 0) {
|
|
950
|
+
this.log.debug?.(
|
|
951
|
+
{ messageId },
|
|
952
|
+
"No BodyPart rows; skipping per-part storage",
|
|
953
|
+
);
|
|
954
|
+
return { stored: 0 };
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const log = this.log;
|
|
958
|
+
const pairs = mapBodyPartsToContent(bodyParts, parsed, {
|
|
959
|
+
messageId,
|
|
960
|
+
logger:
|
|
961
|
+
log.warn || log.debug
|
|
962
|
+
? {
|
|
963
|
+
warn: (obj, msg) => {
|
|
964
|
+
log.warn?.(obj, msg);
|
|
965
|
+
},
|
|
966
|
+
debug: (obj, msg) => {
|
|
967
|
+
log.debug?.(obj, msg);
|
|
968
|
+
},
|
|
969
|
+
}
|
|
970
|
+
: undefined,
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
if (pairs.length === 0) {
|
|
974
|
+
return { stored: 0 };
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const bodyPartIdByPath = new Map(
|
|
978
|
+
bodyParts.map((bp) => [bp.partPath, bp.bodyPartId]),
|
|
979
|
+
);
|
|
980
|
+
|
|
981
|
+
let stored = 0;
|
|
982
|
+
const textContents: { bodyPartId: string; content: string }[] = [];
|
|
983
|
+
await pMap(
|
|
984
|
+
pairs,
|
|
985
|
+
async (entry) => {
|
|
986
|
+
if (
|
|
987
|
+
options?.skipExisting &&
|
|
988
|
+
(await this.storageService.bodyPartExists(
|
|
989
|
+
accountConfigId,
|
|
990
|
+
accountId,
|
|
991
|
+
messageId,
|
|
992
|
+
entry.partPath,
|
|
993
|
+
))
|
|
994
|
+
) {
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
await this.storageService.storeBodyPart({
|
|
998
|
+
accountConfigId,
|
|
999
|
+
accountId,
|
|
1000
|
+
messageId,
|
|
1001
|
+
partPath: entry.partPath,
|
|
1002
|
+
content: entry.content,
|
|
1003
|
+
contentType: entry.contentType,
|
|
1004
|
+
});
|
|
1005
|
+
stored++;
|
|
1006
|
+
|
|
1007
|
+
if (
|
|
1008
|
+
entry.contentType.toLowerCase().startsWith("text/") &&
|
|
1009
|
+
entry.content.length > 0
|
|
1010
|
+
) {
|
|
1011
|
+
const bodyPartId = bodyPartIdByPath.get(entry.partPath);
|
|
1012
|
+
if (bodyPartId) {
|
|
1013
|
+
textContents.push({
|
|
1014
|
+
bodyPartId,
|
|
1015
|
+
content: entry.content.toString("utf8"),
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
},
|
|
1020
|
+
{ concurrency: BODY_PART_STORE_CONCURRENCY },
|
|
1021
|
+
);
|
|
1022
|
+
|
|
1023
|
+
if (textContents.length > 0) {
|
|
1024
|
+
await this.envelopeService.upsertBodyPartContents(
|
|
1025
|
+
messageId,
|
|
1026
|
+
textContents,
|
|
1027
|
+
);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
this.log.info(
|
|
1031
|
+
{ messageId, partCount: pairs.length, stored },
|
|
1032
|
+
"Body parts stored",
|
|
1033
|
+
);
|
|
1034
|
+
|
|
1035
|
+
return { stored };
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/**
|
|
1039
|
+
* Lazily materialize the per-part S3 objects for an already-synced message
|
|
1040
|
+
* whose parts were deferred during bulk sync (DEFER_BODY_PARTS).
|
|
1041
|
+
*
|
|
1042
|
+
* Called from the read path (the API `describeMessage` handler) before the
|
|
1043
|
+
* SPA fetches any `contentUrl`: the part bytes are served directly from S3 by
|
|
1044
|
+
* CloudFront with no Lambda in the request path, so a missing object would
|
|
1045
|
+
* surface to the SPA as a hard "body-missing" failure. Generating the parts
|
|
1046
|
+
* here — re-parsing the stored `body.eml` with the same `mapBodyPartsToContent`
|
|
1047
|
+
* logic body-sync uses — guarantees every `contentUrl` resolves, while bulk
|
|
1048
|
+
* sync stays at 2 writes/message.
|
|
1049
|
+
*
|
|
1050
|
+
* Idempotent: skips any leaf already on S3, so repeat reads cost only HEAD
|
|
1051
|
+
* checks, and a re-read after a partial failure fills the gaps. Requires the
|
|
1052
|
+
* message body to be stored (`bodyStorageKey`); callers gate on that.
|
|
1053
|
+
*
|
|
1054
|
+
* Warm-open fast path: a single HEAD on the `.materialized` sentinel
|
|
1055
|
+
* short-circuits the whole pass — no body.eml GET, no MIME re-parse, no
|
|
1056
|
+
* per-leaf HEADs. The sentinel is written only after every leaf is confirmed
|
|
1057
|
+
* stored, so its presence guarantees all `contentUrl`s resolve.
|
|
1058
|
+
*/
|
|
1059
|
+
async ensureBodyPartsStored(
|
|
1060
|
+
accountConfigId: string,
|
|
1061
|
+
accountId: string,
|
|
1062
|
+
messageId: string,
|
|
1063
|
+
bodyStorageKey: string,
|
|
1064
|
+
): Promise<StoreBodyPartContentsResult> {
|
|
1065
|
+
if (
|
|
1066
|
+
await this.storageService.bodyPartExists(
|
|
1067
|
+
accountConfigId,
|
|
1068
|
+
accountId,
|
|
1069
|
+
messageId,
|
|
1070
|
+
MATERIALIZED_SENTINEL_PART_PATH,
|
|
1071
|
+
)
|
|
1072
|
+
) {
|
|
1073
|
+
this.log.debug?.(
|
|
1074
|
+
{ messageId },
|
|
1075
|
+
"Body parts already materialized (sentinel hit); skipping",
|
|
1076
|
+
);
|
|
1077
|
+
return { stored: 0 };
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
const body = await this.storageService.retrieve(bodyStorageKey);
|
|
1081
|
+
const parsed = await simpleParser(body);
|
|
1082
|
+
const result = await this.storeBodyPartContents(
|
|
1083
|
+
accountConfigId,
|
|
1084
|
+
accountId,
|
|
1085
|
+
messageId,
|
|
1086
|
+
parsed,
|
|
1087
|
+
{ skipExisting: true },
|
|
1088
|
+
);
|
|
1089
|
+
|
|
1090
|
+
// Reaching here means storeBodyPartContents resolved — `pMap` rejects on
|
|
1091
|
+
// any single failed write, so every leaf is now durably on S3. Drop the
|
|
1092
|
+
// sentinel so subsequent opens take the HEAD-only fast path above.
|
|
1093
|
+
await this.storageService.storeBodyPart({
|
|
1094
|
+
accountConfigId,
|
|
1095
|
+
accountId,
|
|
1096
|
+
messageId,
|
|
1097
|
+
partPath: MATERIALIZED_SENTINEL_PART_PATH,
|
|
1098
|
+
content: Buffer.alloc(0),
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
return result;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/**
|
|
1105
|
+
* Persist the pre-parsed body cache. A failure here MUST fail the
|
|
1106
|
+
* surrounding body-sync: the parsed-body object is what the search-index
|
|
1107
|
+
* pipeline reads, so swallowing a write failure leaves the message marked
|
|
1108
|
+
* synced while `parsedBody` is null and it never gets indexed — a silent
|
|
1109
|
+
* search gap. Propagate so the message lands in failedMessageIds and the
|
|
1110
|
+
* SQS batch requeues it.
|
|
1111
|
+
*/
|
|
1112
|
+
private async storeParsedBodyCache(
|
|
1113
|
+
accountConfigId: string,
|
|
1114
|
+
accountId: string,
|
|
1115
|
+
messageId: string,
|
|
1116
|
+
parsed: ParsedMail,
|
|
1117
|
+
): Promise<void> {
|
|
1118
|
+
const parsedBody = toParsedBody(parsed);
|
|
1119
|
+
try {
|
|
1120
|
+
await this.storageService.storeParsedBody({
|
|
1121
|
+
accountConfigId,
|
|
1122
|
+
accountId,
|
|
1123
|
+
messageId,
|
|
1124
|
+
parsed: parsedBody,
|
|
1125
|
+
});
|
|
1126
|
+
this.log.debug?.({ messageId }, "Parsed body cache stored");
|
|
1127
|
+
} catch (err: unknown) {
|
|
1128
|
+
this.log.error?.(
|
|
1129
|
+
{
|
|
1130
|
+
messageId,
|
|
1131
|
+
errorName: (err as { name?: string }).name,
|
|
1132
|
+
errorCode: (err as { Code?: string }).Code,
|
|
1133
|
+
error: inspect(err),
|
|
1134
|
+
},
|
|
1135
|
+
"Failed to store parsed body cache; failing sync to requeue",
|
|
1136
|
+
);
|
|
1137
|
+
throw err;
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* Extract snippet and header category from the body and denormalize both
|
|
1143
|
+
* onto the ThreadMessage. `category` mirrors the Message: created as
|
|
1144
|
+
* `uncategorized` at metadata-sync and set to the classified value here, so
|
|
1145
|
+
* the list/search read path carries it without a per-row Message fetch.
|
|
1146
|
+
* Returns the parsed mail so callers can reuse it (e.g., to write the
|
|
1147
|
+
* parsed-body cache) without paying for mailparser twice.
|
|
1148
|
+
*/
|
|
1149
|
+
private async updateSnippets(
|
|
1150
|
+
messageId: string,
|
|
1151
|
+
accountConfigId: string,
|
|
1152
|
+
body: Buffer,
|
|
1153
|
+
): Promise<ParsedMail> {
|
|
1154
|
+
// Parse the email body
|
|
1155
|
+
const parsed = await simpleParser(body);
|
|
1156
|
+
|
|
1157
|
+
// Extract snippet from text or HTML content
|
|
1158
|
+
const snippet = extractSnippetFromEmail(
|
|
1159
|
+
parsed.text,
|
|
1160
|
+
typeof parsed.html === "string" ? parsed.html : undefined,
|
|
1161
|
+
256,
|
|
1162
|
+
);
|
|
1163
|
+
|
|
1164
|
+
const category = classifyByHeaders(parsed);
|
|
1165
|
+
|
|
1166
|
+
// Get the ThreadMessage by messageId (efficient GSI lookup). The write is
|
|
1167
|
+
// keyed on messageId, so it does not depend on the RFC822 Message-ID
|
|
1168
|
+
// header — a headerless message still gets its category/snippet
|
|
1169
|
+
// denormalized, matching the unconditional Message.category write.
|
|
1170
|
+
const threadMessage = await this.threadMessageService.getByMessageId(
|
|
1171
|
+
accountConfigId,
|
|
1172
|
+
messageId,
|
|
1173
|
+
);
|
|
1174
|
+
|
|
1175
|
+
// Update ThreadMessage snippet + denormalized category.
|
|
1176
|
+
// Pass the full composite set so that if a future key-attribute addition
|
|
1177
|
+
// touches lsi3/lsi4/lsi5/gsi2 sort keys, the index rows remain consistent.
|
|
1178
|
+
// The threadMessage was fetched just above, so the values are already in scope.
|
|
1179
|
+
await this.threadMessageService.update(
|
|
1180
|
+
accountConfigId,
|
|
1181
|
+
threadMessage.threadMessageId,
|
|
1182
|
+
{ category, ...(snippet ? { snippet } : {}) },
|
|
1183
|
+
{
|
|
1184
|
+
composites: {
|
|
1185
|
+
sentDate: threadMessage.sentDate,
|
|
1186
|
+
mailboxId: threadMessage.mailboxId,
|
|
1187
|
+
isRead: threadMessage.isRead,
|
|
1188
|
+
isDeleted: threadMessage.isDeleted,
|
|
1189
|
+
hasStars: threadMessage.hasStars,
|
|
1190
|
+
hasAttachment: threadMessage.hasAttachment,
|
|
1191
|
+
},
|
|
1192
|
+
},
|
|
1193
|
+
);
|
|
1194
|
+
|
|
1195
|
+
this.log.debug?.(
|
|
1196
|
+
{ messageId, category, snippetLength: snippet?.length ?? 0 },
|
|
1197
|
+
"ThreadMessage snippet + category updated",
|
|
1198
|
+
);
|
|
1199
|
+
|
|
1200
|
+
return parsed;
|
|
1201
|
+
}
|
|
1202
|
+
}
|