@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
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pair every persisted `BodyPart` row with the decoded bytes mailparser
|
|
3
|
+
* produced for the same MIME node. Total function: returns one
|
|
4
|
+
* `BodyPartContentPair` per non-multipart leaf, never undefined, never
|
|
5
|
+
* throws on well-formed input.
|
|
6
|
+
*
|
|
7
|
+
* Source of truth (per issue #395):
|
|
8
|
+
* - `partPath`, `contentType`, `contentId`, `dispositionFilename`,
|
|
9
|
+
* `sizeOctets` — owned by BODYSTRUCTURE (the `BodyPart` row).
|
|
10
|
+
* - Decoded bytes — owned by mailparser (`parsed.text`, `parsed.html`,
|
|
11
|
+
* `parsed.attachments[i].content`).
|
|
12
|
+
* - `parsed.attachments[i].partId` is also dot-numbered (set at runtime
|
|
13
|
+
* in `mail-parser.js:860`; missing from `@types/mailparser`; surfaced
|
|
14
|
+
* here via `./types/mailparser-augment.d.ts`). Structural pairing on
|
|
15
|
+
* `partId === partPath` is the dominant strategy for non-text leaves.
|
|
16
|
+
*
|
|
17
|
+
* Pipeline (per leaf in declaration order; each step consumes one
|
|
18
|
+
* attachment when it pairs, subsequent steps work on the residual set):
|
|
19
|
+
* 1. text/plain → first leaf gets `parsed.text` (empty Buffer if
|
|
20
|
+
* `sizeOctets === 0`); subsequent text/plain leaves get empty.
|
|
21
|
+
* 2. text/html → first leaf gets `parsed.html` (empty Buffer if
|
|
22
|
+
* `sizeOctets === 0`); subsequent text/html leaves get empty.
|
|
23
|
+
* 3. Non-text leaves with `sizeOctets === 0` short-circuit to an empty
|
|
24
|
+
* Buffer without consuming an attachment — symmetric with the text
|
|
25
|
+
* path, so the positional fallback can't hand a zero-sized leaf real
|
|
26
|
+
* bytes that belong to another leaf.
|
|
27
|
+
* 4. Remaining non-text leaves try, in order:
|
|
28
|
+
* a. `attachment.partId === bodyPart.partPath`
|
|
29
|
+
* b. contentId match (case + angle-insensitive)
|
|
30
|
+
* c. dispositionFilename match (case-insensitive)
|
|
31
|
+
* d. content-type compatibility class: exact → binary-family
|
|
32
|
+
* (any non-text/*) → text-family
|
|
33
|
+
* e. positional fallback: next unconsumed attachment in declaration
|
|
34
|
+
* order. Logs at `debug` so operators can see "we guessed"
|
|
35
|
+
* pairings without spamming higher levels.
|
|
36
|
+
* 5. Anything still unpaired → zero-byte Buffer + structured `warn` log.
|
|
37
|
+
*
|
|
38
|
+
* The mapper assumes well-formed input — malformed MIME is rejected
|
|
39
|
+
* upstream in `mime-walker.ts`. If a leaf truly has no source bytes
|
|
40
|
+
* available (e.g. mailparser tolerated a malformed message that
|
|
41
|
+
* BODYSTRUCTURE disagrees with on leaf count), it gets the empty-Buffer
|
|
42
|
+
* fallback rather than a throw, so the call site stays simple.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/// <reference path="./types/mailparser-augment.d.ts" />
|
|
46
|
+
|
|
47
|
+
import { Buffer } from "node:buffer";
|
|
48
|
+
import type { BodyPartItem } from "@remit/data-ports";
|
|
49
|
+
import type { Attachment, ParsedMail } from "mailparser";
|
|
50
|
+
|
|
51
|
+
export interface BodyPartContentPair {
|
|
52
|
+
partPath: string;
|
|
53
|
+
contentType: string;
|
|
54
|
+
content: Buffer;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type MapperInput = Pick<
|
|
58
|
+
BodyPartItem,
|
|
59
|
+
| "partPath"
|
|
60
|
+
| "isMultipart"
|
|
61
|
+
| "mediaType"
|
|
62
|
+
| "mediaSubtype"
|
|
63
|
+
| "contentId"
|
|
64
|
+
| "dispositionFilename"
|
|
65
|
+
| "disposition"
|
|
66
|
+
> & {
|
|
67
|
+
/**
|
|
68
|
+
* Bytes the BODYSTRUCTURE leaf declares. Optional so hand-built test
|
|
69
|
+
* inputs don't have to fabricate it — in production every BodyPart row
|
|
70
|
+
* carries this field. `sizeOctets === 0` is the explicit empty-leaf
|
|
71
|
+
* signal that text-leaf routing uses to produce a zero-byte Buffer
|
|
72
|
+
* (see fixture 15-empty-text-part).
|
|
73
|
+
*/
|
|
74
|
+
sizeOctets?: number;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export interface MapperLogger {
|
|
78
|
+
warn(obj: Record<string, unknown>, msg: string): void;
|
|
79
|
+
debug?(obj: Record<string, unknown>, msg: string): void;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface MapperContext {
|
|
83
|
+
messageId?: string;
|
|
84
|
+
logger?: MapperLogger;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const EMPTY_BUFFER = Buffer.alloc(0);
|
|
88
|
+
|
|
89
|
+
const stripAngles = (value: string): string => value.replace(/^<+|>+$/g, "");
|
|
90
|
+
|
|
91
|
+
const buildContentType = (
|
|
92
|
+
part: Pick<BodyPartItem, "mediaType" | "mediaSubtype">,
|
|
93
|
+
): string => `${part.mediaType.toLowerCase()}/${part.mediaSubtype}`;
|
|
94
|
+
|
|
95
|
+
const textToBuffer = (value: string | false | undefined): Buffer => {
|
|
96
|
+
if (typeof value !== "string" || value.length === 0) return EMPTY_BUFFER;
|
|
97
|
+
return Buffer.from(value, "utf8");
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const isTextStar = (contentType: string): boolean =>
|
|
101
|
+
contentType.toLowerCase().startsWith("text/");
|
|
102
|
+
|
|
103
|
+
/** Match `partId === partPath` against an unconsumed attachment. */
|
|
104
|
+
const findByPartId = (
|
|
105
|
+
attachments: readonly Attachment[],
|
|
106
|
+
consumed: ReadonlySet<number>,
|
|
107
|
+
partPath: string,
|
|
108
|
+
): number | null => {
|
|
109
|
+
for (let i = 0; i < attachments.length; i++) {
|
|
110
|
+
if (consumed.has(i)) continue;
|
|
111
|
+
const att = attachments[i];
|
|
112
|
+
if (typeof att.partId !== "string") continue;
|
|
113
|
+
if (att.partId === partPath) return i;
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** Match by contentId (case + angle-insensitive). */
|
|
119
|
+
const findByContentId = (
|
|
120
|
+
attachments: readonly Attachment[],
|
|
121
|
+
consumed: ReadonlySet<number>,
|
|
122
|
+
contentId: string,
|
|
123
|
+
): number | null => {
|
|
124
|
+
const target = stripAngles(contentId).toLowerCase();
|
|
125
|
+
for (let i = 0; i < attachments.length; i++) {
|
|
126
|
+
if (consumed.has(i)) continue;
|
|
127
|
+
const att = attachments[i];
|
|
128
|
+
const candidates = [att.cid, att.contentId];
|
|
129
|
+
for (const cand of candidates) {
|
|
130
|
+
if (typeof cand !== "string") continue;
|
|
131
|
+
if (stripAngles(cand).toLowerCase() === target) return i;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/** Match by dispositionFilename (case-insensitive). */
|
|
138
|
+
const findByFilename = (
|
|
139
|
+
attachments: readonly Attachment[],
|
|
140
|
+
consumed: ReadonlySet<number>,
|
|
141
|
+
filename: string,
|
|
142
|
+
): number | null => {
|
|
143
|
+
const target = filename.toLowerCase();
|
|
144
|
+
for (let i = 0; i < attachments.length; i++) {
|
|
145
|
+
if (consumed.has(i)) continue;
|
|
146
|
+
const att = attachments[i];
|
|
147
|
+
if (typeof att.filename !== "string") continue;
|
|
148
|
+
if (att.filename.toLowerCase() === target) return i;
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Content-type compatibility class. The BodyPart row's content-type is the
|
|
155
|
+
* sender's verbatim label; mailparser's `att.contentType` is sniffed from
|
|
156
|
+
* filename extension. They often disagree (the Odido bug — postmortem
|
|
157
|
+
* #394 — is `application/octet-stream` vs `application/pdf`).
|
|
158
|
+
*
|
|
159
|
+
* Order:
|
|
160
|
+
* 1. Exact match (`application/pdf` ↔ `application/pdf`).
|
|
161
|
+
* 2. Binary family — leaf is non-`text/*`, attachment is non-`text/*`.
|
|
162
|
+
* Covers octet-stream ↔ pdf, octet-stream ↔ image, etc.
|
|
163
|
+
* 3. Text family — leaf is `text/*`, attachment is `text/*`. Catches
|
|
164
|
+
* `text/calendar` / `text/csv` after the inline `text/plain` and
|
|
165
|
+
* `text/html` slots are taken.
|
|
166
|
+
*/
|
|
167
|
+
const findByContentTypeClass = (
|
|
168
|
+
attachments: readonly Attachment[],
|
|
169
|
+
consumed: ReadonlySet<number>,
|
|
170
|
+
contentType: string,
|
|
171
|
+
): number | null => {
|
|
172
|
+
const target = contentType.toLowerCase();
|
|
173
|
+
const leafIsText = isTextStar(target);
|
|
174
|
+
|
|
175
|
+
for (let i = 0; i < attachments.length; i++) {
|
|
176
|
+
if (consumed.has(i)) continue;
|
|
177
|
+
const att = attachments[i];
|
|
178
|
+
if (att.contentType?.toLowerCase() === target) return i;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (!leafIsText) {
|
|
182
|
+
for (let i = 0; i < attachments.length; i++) {
|
|
183
|
+
if (consumed.has(i)) continue;
|
|
184
|
+
const att = attachments[i];
|
|
185
|
+
const attType = att.contentType?.toLowerCase();
|
|
186
|
+
if (!attType) continue;
|
|
187
|
+
if (!isTextStar(attType)) return i;
|
|
188
|
+
}
|
|
189
|
+
} else {
|
|
190
|
+
for (let i = 0; i < attachments.length; i++) {
|
|
191
|
+
if (consumed.has(i)) continue;
|
|
192
|
+
const att = attachments[i];
|
|
193
|
+
const attType = att.contentType?.toLowerCase();
|
|
194
|
+
if (!attType) continue;
|
|
195
|
+
if (isTextStar(attType)) return i;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return null;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/** Positional fallback: next unconsumed attachment in declaration order. */
|
|
203
|
+
const findPositional = (
|
|
204
|
+
attachments: readonly Attachment[],
|
|
205
|
+
consumed: ReadonlySet<number>,
|
|
206
|
+
): number | null => {
|
|
207
|
+
for (let i = 0; i < attachments.length; i++) {
|
|
208
|
+
if (!consumed.has(i)) return i;
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const pairNonTextLeaf = (
|
|
214
|
+
part: MapperInput,
|
|
215
|
+
attachments: readonly Attachment[],
|
|
216
|
+
consumed: Set<number>,
|
|
217
|
+
context: MapperContext,
|
|
218
|
+
): { content: Buffer; paired: true } | { paired: false } => {
|
|
219
|
+
const contentType = buildContentType(part);
|
|
220
|
+
|
|
221
|
+
// Symmetry with the text-leaf path: a leaf whose BODYSTRUCTURE-declared
|
|
222
|
+
// size is zero has no bytes to find, so do not consume a residual
|
|
223
|
+
// attachment for it (the positional fallback would otherwise hand it
|
|
224
|
+
// real bytes belonging to another leaf — see PR D, review note b).
|
|
225
|
+
if (part.sizeOctets === 0) {
|
|
226
|
+
return { content: EMPTY_BUFFER, paired: true };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const byPartId = findByPartId(attachments, consumed, part.partPath);
|
|
230
|
+
if (byPartId !== null) {
|
|
231
|
+
consumed.add(byPartId);
|
|
232
|
+
return { content: attachments[byPartId].content, paired: true };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (part.contentId) {
|
|
236
|
+
const byCid = findByContentId(attachments, consumed, part.contentId);
|
|
237
|
+
if (byCid !== null) {
|
|
238
|
+
consumed.add(byCid);
|
|
239
|
+
return { content: attachments[byCid].content, paired: true };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (part.dispositionFilename) {
|
|
244
|
+
const byName = findByFilename(
|
|
245
|
+
attachments,
|
|
246
|
+
consumed,
|
|
247
|
+
part.dispositionFilename,
|
|
248
|
+
);
|
|
249
|
+
if (byName !== null) {
|
|
250
|
+
consumed.add(byName);
|
|
251
|
+
return { content: attachments[byName].content, paired: true };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const byType = findByContentTypeClass(attachments, consumed, contentType);
|
|
256
|
+
if (byType !== null) {
|
|
257
|
+
consumed.add(byType);
|
|
258
|
+
return { content: attachments[byType].content, paired: true };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const positional = findPositional(attachments, consumed);
|
|
262
|
+
if (positional !== null) {
|
|
263
|
+
consumed.add(positional);
|
|
264
|
+
const chosen = attachments[positional];
|
|
265
|
+
// Audit log so operators can see "we guessed" pairings when a
|
|
266
|
+
// well-formed message takes the structural-fallback path. Helps
|
|
267
|
+
// triage cases where mailparser and BODYSTRUCTURE disagree more
|
|
268
|
+
// than the earlier matchers can handle.
|
|
269
|
+
context.logger?.debug?.(
|
|
270
|
+
{
|
|
271
|
+
messageId: context.messageId,
|
|
272
|
+
partPath: part.partPath,
|
|
273
|
+
leafContentType: contentType,
|
|
274
|
+
chosenAttachmentPartId: chosen.partId ?? null,
|
|
275
|
+
chosenAttachmentContentType: chosen.contentType ?? null,
|
|
276
|
+
chosenAttachmentFilename: chosen.filename ?? null,
|
|
277
|
+
},
|
|
278
|
+
"body-part-mapper: positional fallback paired leaf with next residual attachment",
|
|
279
|
+
);
|
|
280
|
+
return { content: chosen.content, paired: true };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return { paired: false };
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Pair body-part rows with mailparser bytes. Returns one pair per
|
|
288
|
+
* non-multipart leaf, in the order the leaves appear in `bodyParts`.
|
|
289
|
+
*
|
|
290
|
+
* Never throws on well-formed input. Leaves with no source bytes get an
|
|
291
|
+
* empty Buffer; the call site does not need a try/catch.
|
|
292
|
+
*/
|
|
293
|
+
export const mapBodyPartsToContent = (
|
|
294
|
+
bodyParts: readonly MapperInput[],
|
|
295
|
+
parsed: ParsedMail,
|
|
296
|
+
context: MapperContext = {},
|
|
297
|
+
): BodyPartContentPair[] => {
|
|
298
|
+
const pairs: BodyPartContentPair[] = [];
|
|
299
|
+
const attachments = parsed.attachments ?? [];
|
|
300
|
+
const consumed = new Set<number>();
|
|
301
|
+
let textRouted = false;
|
|
302
|
+
let htmlRouted = false;
|
|
303
|
+
|
|
304
|
+
for (const part of bodyParts) {
|
|
305
|
+
if (part.isMultipart) continue;
|
|
306
|
+
|
|
307
|
+
const contentType = buildContentType(part);
|
|
308
|
+
const isPlain = part.mediaType === "TEXT" && part.mediaSubtype === "plain";
|
|
309
|
+
const isHtml = part.mediaType === "TEXT" && part.mediaSubtype === "html";
|
|
310
|
+
|
|
311
|
+
// `sizeOctets === 0` is the explicit empty-leaf signal; an undefined
|
|
312
|
+
// sizeOctets (hand-built test input) is treated as non-empty so we
|
|
313
|
+
// fall through to the text-routing path.
|
|
314
|
+
const isEmptyLeaf = part.sizeOctets === 0;
|
|
315
|
+
|
|
316
|
+
if (isPlain) {
|
|
317
|
+
const content =
|
|
318
|
+
!textRouted && !isEmptyLeaf ? textToBuffer(parsed.text) : EMPTY_BUFFER;
|
|
319
|
+
textRouted = true;
|
|
320
|
+
pairs.push({ partPath: part.partPath, contentType, content });
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (isHtml) {
|
|
325
|
+
const content =
|
|
326
|
+
!htmlRouted && !isEmptyLeaf
|
|
327
|
+
? textToBuffer(
|
|
328
|
+
typeof parsed.html === "string" ? parsed.html : undefined,
|
|
329
|
+
)
|
|
330
|
+
: EMPTY_BUFFER;
|
|
331
|
+
htmlRouted = true;
|
|
332
|
+
pairs.push({ partPath: part.partPath, contentType, content });
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const result = pairNonTextLeaf(part, attachments, consumed, context);
|
|
337
|
+
if (result.paired) {
|
|
338
|
+
pairs.push({
|
|
339
|
+
partPath: part.partPath,
|
|
340
|
+
contentType,
|
|
341
|
+
content: result.content,
|
|
342
|
+
});
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
context.logger?.warn(
|
|
347
|
+
{
|
|
348
|
+
messageId: context.messageId,
|
|
349
|
+
partPath: part.partPath,
|
|
350
|
+
contentType,
|
|
351
|
+
contentId: part.contentId,
|
|
352
|
+
filename: part.dispositionFilename,
|
|
353
|
+
},
|
|
354
|
+
"body-part-mapper: no source bytes for leaf; pairing with zero-byte buffer",
|
|
355
|
+
);
|
|
356
|
+
pairs.push({ partPath: part.partPath, contentType, content: EMPTY_BUFFER });
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return pairs;
|
|
360
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { describe, it, mock } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
type BodySyncQueueLogger,
|
|
5
|
+
BodySyncQueueService,
|
|
6
|
+
} from "./body-sync-queue.js";
|
|
7
|
+
|
|
8
|
+
interface CapturedInput {
|
|
9
|
+
QueueUrl: string;
|
|
10
|
+
MessageBody: string;
|
|
11
|
+
MessageGroupId?: string;
|
|
12
|
+
MessageDeduplicationId?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const buildService = (
|
|
16
|
+
overrides: {
|
|
17
|
+
sqsQueueUrl?: string;
|
|
18
|
+
send?: (command: { input: CapturedInput }) => Promise<unknown>;
|
|
19
|
+
logger?: BodySyncQueueLogger;
|
|
20
|
+
} = {},
|
|
21
|
+
) => {
|
|
22
|
+
const sent: CapturedInput[] = [];
|
|
23
|
+
const send = mock.fn(
|
|
24
|
+
overrides.send ??
|
|
25
|
+
(async (command: { input: CapturedInput }) => {
|
|
26
|
+
sent.push(command.input);
|
|
27
|
+
return { MessageId: "id" };
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
const service = new BodySyncQueueService({
|
|
31
|
+
sqsQueueUrl:
|
|
32
|
+
overrides.sqsQueueUrl ?? "http://localhost:9324/000/remit-body",
|
|
33
|
+
logger: overrides.logger,
|
|
34
|
+
});
|
|
35
|
+
// @ts-expect-error - inject mock SQS client for the test
|
|
36
|
+
service.sqs = { send };
|
|
37
|
+
return { service, send, sent };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
describe("BodySyncQueueService.requestBodySync", () => {
|
|
41
|
+
it("emits a SYNC_MESSAGE_BODY event with the message id and uid pair", async () => {
|
|
42
|
+
const { service, send, sent } = buildService();
|
|
43
|
+
|
|
44
|
+
await service.requestBodySync({
|
|
45
|
+
accountId: "acc-1",
|
|
46
|
+
mailboxId: "mbx-1",
|
|
47
|
+
messageId: "msg-1",
|
|
48
|
+
uid: 42,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
assert.equal(send.mock.calls.length, 1);
|
|
52
|
+
const event = JSON.parse(sent[0].MessageBody);
|
|
53
|
+
assert.equal(event.type, "SYNC_MESSAGE_BODY");
|
|
54
|
+
assert.equal(event.accountId, "acc-1");
|
|
55
|
+
assert.equal(event.mailboxId, "mbx-1");
|
|
56
|
+
assert.deepEqual(event.messageIds, ["msg-1"]);
|
|
57
|
+
assert.deepEqual(event.messages, [{ messageId: "msg-1", uid: 42 }]);
|
|
58
|
+
// The worker's skip guard must be bypassed for this message — a read
|
|
59
|
+
// miss already proved bodyStorageKey is stale.
|
|
60
|
+
assert.equal(event.force, true);
|
|
61
|
+
// A standard queue must not carry FIFO parameters.
|
|
62
|
+
assert.equal(sent[0].MessageGroupId, undefined);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("omits the uid pair when no uid is known", async () => {
|
|
66
|
+
const { service, sent } = buildService();
|
|
67
|
+
|
|
68
|
+
await service.requestBodySync({
|
|
69
|
+
accountId: "acc-1",
|
|
70
|
+
mailboxId: "mbx-1",
|
|
71
|
+
messageId: "msg-1",
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const event = JSON.parse(sent[0].MessageBody);
|
|
75
|
+
assert.deepEqual(event.messageIds, ["msg-1"]);
|
|
76
|
+
assert.equal(event.messages, undefined);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("never rejects when the queue send fails — the read path already returned 202", async () => {
|
|
80
|
+
const { service } = buildService({
|
|
81
|
+
send: async () => {
|
|
82
|
+
throw new Error("queue down");
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await assert.doesNotReject(() =>
|
|
87
|
+
service.requestBodySync({
|
|
88
|
+
accountId: "acc-1",
|
|
89
|
+
mailboxId: "mbx-1",
|
|
90
|
+
messageId: "msg-1",
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("logs an alertable body_sync_cue_enqueue_failed error instead of rejecting — the swallow is not silent operationally, only silent to the caller (RFC 033 D5)", async () => {
|
|
96
|
+
const errorCalls: Array<[Record<string, unknown>, string]> = [];
|
|
97
|
+
const logger: BodySyncQueueLogger = {
|
|
98
|
+
info: () => {},
|
|
99
|
+
error: (obj, msg) => {
|
|
100
|
+
errorCalls.push([obj, msg]);
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
const { service } = buildService({
|
|
104
|
+
send: async () => {
|
|
105
|
+
throw new Error("queue down");
|
|
106
|
+
},
|
|
107
|
+
logger,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await service.requestBodySync({
|
|
111
|
+
accountId: "acc-1",
|
|
112
|
+
mailboxId: "mbx-1",
|
|
113
|
+
messageId: "msg-1",
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
assert.equal(errorCalls.length, 1);
|
|
117
|
+
const [fields, message] = errorCalls[0];
|
|
118
|
+
assert.equal(fields.alert, "body_sync_cue_enqueue_failed");
|
|
119
|
+
assert.equal(fields.accountId, "acc-1");
|
|
120
|
+
assert.equal(fields.mailboxId, "mbx-1");
|
|
121
|
+
assert.equal(fields.messageId, "msg-1");
|
|
122
|
+
assert.match(message, /read path returned a retryable 202/);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { SendMessageCommand, type SQSClient } from "@aws-sdk/client-sqs";
|
|
3
|
+
import { createQueueProducer } from "@remit/sqs-client/producer";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* SYNC_MESSAGE_BODY event structure (matches remit-imap-worker/events.ts). The
|
|
7
|
+
* consumer falls back to `messageIds` when `messages` (id+uid pairs) is absent.
|
|
8
|
+
*/
|
|
9
|
+
interface SyncMessageBodyTarget {
|
|
10
|
+
messageId: string;
|
|
11
|
+
uid: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface SyncMessageBodyEvent {
|
|
15
|
+
type: "SYNC_MESSAGE_BODY";
|
|
16
|
+
eventId: string;
|
|
17
|
+
timestamp: number;
|
|
18
|
+
accountId: string;
|
|
19
|
+
mailboxId: string;
|
|
20
|
+
messageIds: string[];
|
|
21
|
+
messages?: SyncMessageBodyTarget[];
|
|
22
|
+
/**
|
|
23
|
+
* Always true for this read-miss re-arm cue: the caller only reaches
|
|
24
|
+
* `requestBodySync` when a `/content` read found `bodyStorageKey` set but
|
|
25
|
+
* the storage object missing, so the worker must bypass its "already
|
|
26
|
+
* stored" skip guard and re-fetch even though the DB row is stale.
|
|
27
|
+
*/
|
|
28
|
+
force: true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface BodySyncQueueLogger {
|
|
32
|
+
info(obj: Record<string, unknown>, msg: string): void;
|
|
33
|
+
error(obj: Record<string, unknown>, msg: string): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const noopLogger: BodySyncQueueLogger = {
|
|
37
|
+
info: () => {},
|
|
38
|
+
error: () => {},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export interface BodySyncQueueConfig {
|
|
42
|
+
sqsQueueUrl: string;
|
|
43
|
+
sqsEndpoint?: string;
|
|
44
|
+
logger?: BodySyncQueueLogger;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface RequestBodySyncInput {
|
|
48
|
+
accountId: string;
|
|
49
|
+
mailboxId: string;
|
|
50
|
+
messageId: string;
|
|
51
|
+
/** UID resolved at read time so the worker issues one FETCH without a lookup. */
|
|
52
|
+
uid?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Re-arms the body-sync cue for a single message from the read path.
|
|
57
|
+
*
|
|
58
|
+
* The worker emits SYNC_MESSAGE_BODY during metadata sync; this service lets the
|
|
59
|
+
* API re-emit that cue on demand when a body-fetch finds the storage object
|
|
60
|
+
* missing (never synced, or lost). The subsequent client retry then resolves
|
|
61
|
+
* once the worker has stored the body.
|
|
62
|
+
*
|
|
63
|
+
* A queue-send failure never rejects: the read path already returns a retryable
|
|
64
|
+
* 202 to the caller, and a rejection here would land on the shared API event
|
|
65
|
+
* loop and fail an unrelated in-flight request. It is logged loudly with an
|
|
66
|
+
* alertable field instead.
|
|
67
|
+
*/
|
|
68
|
+
export class BodySyncQueueService {
|
|
69
|
+
private sqs: SQSClient;
|
|
70
|
+
private queueUrl: string;
|
|
71
|
+
private log: BodySyncQueueLogger;
|
|
72
|
+
|
|
73
|
+
constructor(config: BodySyncQueueConfig) {
|
|
74
|
+
this.queueUrl = config.sqsQueueUrl;
|
|
75
|
+
this.log = config.logger ?? noopLogger;
|
|
76
|
+
this.sqs = createQueueProducer({
|
|
77
|
+
queueUrl: config.sqsQueueUrl,
|
|
78
|
+
endpoint: config.sqsEndpoint,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
requestBodySync = async (input: RequestBodySyncInput): Promise<void> => {
|
|
83
|
+
const { accountId, mailboxId, messageId, uid } = input;
|
|
84
|
+
const event: SyncMessageBodyEvent = {
|
|
85
|
+
type: "SYNC_MESSAGE_BODY",
|
|
86
|
+
eventId: randomUUID(),
|
|
87
|
+
timestamp: Date.now(),
|
|
88
|
+
accountId,
|
|
89
|
+
mailboxId,
|
|
90
|
+
messageIds: [messageId],
|
|
91
|
+
...(uid !== undefined && { messages: [{ messageId, uid }] }),
|
|
92
|
+
force: true,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const useFifo = this.queueUrl.endsWith(".fifo");
|
|
96
|
+
|
|
97
|
+
await this.sqs
|
|
98
|
+
.send(
|
|
99
|
+
new SendMessageCommand({
|
|
100
|
+
QueueUrl: this.queueUrl,
|
|
101
|
+
MessageBody: JSON.stringify(event),
|
|
102
|
+
...(useFifo && {
|
|
103
|
+
MessageGroupId: accountId,
|
|
104
|
+
MessageDeduplicationId: event.eventId,
|
|
105
|
+
}),
|
|
106
|
+
}),
|
|
107
|
+
)
|
|
108
|
+
.then(() => {
|
|
109
|
+
this.log.info(
|
|
110
|
+
{ eventId: event.eventId, accountId, mailboxId, messageId },
|
|
111
|
+
"Re-armed SYNC_MESSAGE_BODY cue",
|
|
112
|
+
);
|
|
113
|
+
})
|
|
114
|
+
.catch((error: unknown) => {
|
|
115
|
+
this.log.error(
|
|
116
|
+
{
|
|
117
|
+
alert: "body_sync_cue_enqueue_failed",
|
|
118
|
+
eventId: event.eventId,
|
|
119
|
+
accountId,
|
|
120
|
+
mailboxId,
|
|
121
|
+
messageId,
|
|
122
|
+
errorName: (error as { name?: string })?.name,
|
|
123
|
+
errorCode:
|
|
124
|
+
(error as { Code?: string })?.Code ??
|
|
125
|
+
(error as { code?: string })?.code,
|
|
126
|
+
},
|
|
127
|
+
"Failed to re-arm SYNC_MESSAGE_BODY cue (read path returned a retryable 202)",
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
}
|