@intx/mime 0.1.2
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 +32 -0
- package/package.json +17 -0
- package/src/index.test.ts +978 -0
- package/src/index.ts +37 -0
- package/src/mail-builder.test.ts +740 -0
- package/src/mail-builder.ts +492 -0
- package/src/mime.ts +990 -0
- package/src/pgp-sign.ts +178 -0
- package/tsconfig.json +4 -0
- package/tsconfig.tsbuildinfo +1 -0
package/src/mime.ts
ADDED
|
@@ -0,0 +1,990 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-non-null-assertion -- MIME parser uses bounded array access throughout */
|
|
2
|
+
/**
|
|
3
|
+
* MIME byte construction and parsing for Interchange messages.
|
|
4
|
+
*
|
|
5
|
+
* Implements exactly two message shapes per MESSAGE.md:
|
|
6
|
+
* 1. Conversation: text/plain in multipart/signed
|
|
7
|
+
* 2. Structured: application/vnd.interchange+json in multipart/mixed in multipart/signed
|
|
8
|
+
*
|
|
9
|
+
* Produces real RFC 2822 / RFC 2046 / RFC 3156 bytes. The signed content
|
|
10
|
+
* part is produced in MIME canonical form (CRLF line endings) so PGP/MIME
|
|
11
|
+
* verification operates on the same bytes regardless of platform.
|
|
12
|
+
*
|
|
13
|
+
* RFC references verified:
|
|
14
|
+
* - RFC 2822 §2.1.1: lines MUST NOT exceed 998 chars; recommended 78
|
|
15
|
+
* - RFC 2046 §5.1.1: boundary MUST be <= 70 chars; CRLF before each boundary
|
|
16
|
+
* - RFC 3156 §5: multipart/signed; protocol="application/pgp-signature";
|
|
17
|
+
* micalg=pgp-sha512; first part = signed content; second part = signature
|
|
18
|
+
* - Message-IDs: <uuid@domain> — valid per RFC 2822 §3.6.4 (dot-atom local-part)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { randomUUID } from "node:crypto";
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Types
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
export type MessageHeaders = {
|
|
28
|
+
from: string;
|
|
29
|
+
to: string[];
|
|
30
|
+
cc: string[] | undefined;
|
|
31
|
+
date: Date;
|
|
32
|
+
messageId: string;
|
|
33
|
+
subject: string | undefined;
|
|
34
|
+
inReplyTo: string | undefined;
|
|
35
|
+
references: string[] | undefined;
|
|
36
|
+
mimeVersion: "1.0";
|
|
37
|
+
interchangeType: string | undefined;
|
|
38
|
+
interchangeCorrelationId: string | undefined;
|
|
39
|
+
interchangeTenantId: string | undefined;
|
|
40
|
+
interchangeAgentId: string | undefined;
|
|
41
|
+
interchangeSessionId: string | undefined;
|
|
42
|
+
interchangeOfferingId: string | undefined;
|
|
43
|
+
interchangeSchemaVersion: string | undefined;
|
|
44
|
+
traceparent: string | undefined;
|
|
45
|
+
tracestate: string | undefined;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type ConversationContent = {
|
|
49
|
+
kind: "conversation";
|
|
50
|
+
text: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type StructuredContent = {
|
|
54
|
+
kind: "structured";
|
|
55
|
+
json: Record<string, unknown>;
|
|
56
|
+
summary?: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type MimeAssemblyInput = {
|
|
60
|
+
headers: MessageHeaders;
|
|
61
|
+
content: ConversationContent | StructuredContent;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type ParsedMimePart = {
|
|
65
|
+
contentType: string;
|
|
66
|
+
headers: Map<string, string>;
|
|
67
|
+
body: Uint8Array;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export type ParsedMimeMessage = {
|
|
71
|
+
headers: Map<string, string>;
|
|
72
|
+
parts: ParsedMimePart[];
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// JMAP Email types (RFC 8621)
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
export type JMAPAddress = {
|
|
80
|
+
name: string | null;
|
|
81
|
+
email: string;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export type JMAPBodyValue = {
|
|
85
|
+
value: string;
|
|
86
|
+
isEncodingProblem: boolean;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export type JMAPBodyPart = {
|
|
90
|
+
partId: string;
|
|
91
|
+
type: string;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export type JMAPAttachment = {
|
|
95
|
+
blobId: string;
|
|
96
|
+
name: string | null;
|
|
97
|
+
type: string;
|
|
98
|
+
size: number;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export type JMAPEmail = {
|
|
102
|
+
from: JMAPAddress[];
|
|
103
|
+
to: JMAPAddress[];
|
|
104
|
+
subject: string | null;
|
|
105
|
+
sentAt: string | null;
|
|
106
|
+
bodyValues: Record<string, JMAPBodyValue>;
|
|
107
|
+
textBody: JMAPBodyPart[];
|
|
108
|
+
htmlBody: JMAPBodyPart[];
|
|
109
|
+
attachments: JMAPAttachment[];
|
|
110
|
+
headers: Record<string, string>;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Message-ID generation
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
export function generateMessageId(address: string): string {
|
|
118
|
+
const domain = address.includes("@") ? address.split("@")[1]! : "local";
|
|
119
|
+
const uuid = randomUUID();
|
|
120
|
+
return `<${uuid}@${domain}>`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Address normalization
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Extract the bare addr-spec (local-part@domain) from a single RFC 5322
|
|
129
|
+
* address value. Strips any display name and surrounding angle brackets,
|
|
130
|
+
* then lowercases the result so case-insensitive comparison falls out
|
|
131
|
+
* naturally.
|
|
132
|
+
*
|
|
133
|
+
* Accepted inputs (single-address only — do not pass comma-separated lists):
|
|
134
|
+
* `"Display Name" <user@host>` → `user@host`
|
|
135
|
+
* `Display Name <user@host>` → `user@host`
|
|
136
|
+
* `<user@host>` → `user@host`
|
|
137
|
+
* `user@host` → `user@host`
|
|
138
|
+
* ` User@Host ` → `user@host`
|
|
139
|
+
*
|
|
140
|
+
* Rejected (throws) inputs:
|
|
141
|
+
* - empty or whitespace-only
|
|
142
|
+
* - input with no `@`
|
|
143
|
+
* - input that produces an empty local-part or domain
|
|
144
|
+
* - quoted local-parts (e.g. `"a@b"@host`) — technically valid per RFC
|
|
145
|
+
* 5321 §4.1.2 but rare in practice; the simple split below would
|
|
146
|
+
* misinterpret the inner `@`, so we refuse rather than guess
|
|
147
|
+
* - content after the closing `>` in an angle-bracketed form
|
|
148
|
+
* (e.g. `Name <a@b> (comment)`) — would silently fall through to a
|
|
149
|
+
* misparsed bare-form attempt, so we refuse instead
|
|
150
|
+
*
|
|
151
|
+
* Per RFC 5321 §2.4 the local-part is technically case-sensitive, but no
|
|
152
|
+
* production system honors that; matching case-insensitively is the
|
|
153
|
+
* correct call for routing and identity checks.
|
|
154
|
+
*/
|
|
155
|
+
export function extractAddrSpec(addressLine: string): string {
|
|
156
|
+
const trimmed = addressLine.trim();
|
|
157
|
+
if (trimmed === "") {
|
|
158
|
+
throw new Error("extractAddrSpec: address is empty");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let candidate: string;
|
|
162
|
+
const angleOpen = trimmed.lastIndexOf("<");
|
|
163
|
+
if (angleOpen !== -1) {
|
|
164
|
+
// Angle-bracketed form. Require the `>` to be the trailing
|
|
165
|
+
// non-whitespace character so that input like `Name <a@b> (comment)`
|
|
166
|
+
// is refused rather than re-parsed as a bare addr-spec.
|
|
167
|
+
if (!trimmed.endsWith(">")) {
|
|
168
|
+
throw new Error(
|
|
169
|
+
`extractAddrSpec: trailing content after '>' in ${JSON.stringify(addressLine)}`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
candidate = trimmed.slice(angleOpen + 1, -1).trim();
|
|
173
|
+
} else {
|
|
174
|
+
candidate = trimmed;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Reject quoted local-parts: the parser below splits on the first `@`,
|
|
178
|
+
// which would corrupt a quoted form whose local-part contains `@`.
|
|
179
|
+
if (candidate.includes('"')) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`extractAddrSpec: quoted local-parts are not supported: ${JSON.stringify(addressLine)}`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const atIndex = candidate.indexOf("@");
|
|
186
|
+
if (atIndex === -1) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`extractAddrSpec: address has no '@': ${JSON.stringify(addressLine)}`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Reject any further `@` in the candidate — a well-formed addr-spec
|
|
193
|
+
// has exactly one. Multiple `@` is either a quoted form (rejected
|
|
194
|
+
// above) or simply malformed.
|
|
195
|
+
if (candidate.indexOf("@", atIndex + 1) !== -1) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`extractAddrSpec: multiple '@' in ${JSON.stringify(addressLine)}`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const local = candidate.slice(0, atIndex);
|
|
202
|
+
const domain = candidate.slice(atIndex + 1);
|
|
203
|
+
if (local === "" || domain === "") {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`extractAddrSpec: empty local-part or domain in ${JSON.stringify(addressLine)}`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return `${local.toLowerCase()}@${domain.toLowerCase()}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// RFC 2822 date formatting
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
|
|
217
|
+
const MONTHS = [
|
|
218
|
+
"Jan",
|
|
219
|
+
"Feb",
|
|
220
|
+
"Mar",
|
|
221
|
+
"Apr",
|
|
222
|
+
"May",
|
|
223
|
+
"Jun",
|
|
224
|
+
"Jul",
|
|
225
|
+
"Aug",
|
|
226
|
+
"Sep",
|
|
227
|
+
"Oct",
|
|
228
|
+
"Nov",
|
|
229
|
+
"Dec",
|
|
230
|
+
] as const;
|
|
231
|
+
|
|
232
|
+
export function formatRFC2822Date(date: Date): string {
|
|
233
|
+
const day = DAYS[date.getUTCDay()]!;
|
|
234
|
+
const d = String(date.getUTCDate()).padStart(2, "0");
|
|
235
|
+
const mon = MONTHS[date.getUTCMonth()]!;
|
|
236
|
+
const year = date.getUTCFullYear();
|
|
237
|
+
const h = String(date.getUTCHours()).padStart(2, "0");
|
|
238
|
+
const m = String(date.getUTCMinutes()).padStart(2, "0");
|
|
239
|
+
const s = String(date.getUTCSeconds()).padStart(2, "0");
|
|
240
|
+
return `${day}, ${d} ${mon} ${year} ${h}:${m}:${s} +0000`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// Boundary generation
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
function generateBoundary(): string {
|
|
248
|
+
const bytes = new Uint8Array(18);
|
|
249
|
+
crypto.getRandomValues(bytes);
|
|
250
|
+
return (
|
|
251
|
+
"----=_Part_" +
|
|
252
|
+
Array.from(bytes)
|
|
253
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
254
|
+
.join("")
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
// Header serialization (RFC 2822)
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
const CRLF = "\r\n";
|
|
263
|
+
|
|
264
|
+
function hdr(name: string, value: string): string {
|
|
265
|
+
return `${name}: ${value}${CRLF}`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function serializeMessageHeaders(
|
|
269
|
+
h: MessageHeaders,
|
|
270
|
+
contentType: string,
|
|
271
|
+
): string {
|
|
272
|
+
let out = "";
|
|
273
|
+
out += hdr("From", h.from);
|
|
274
|
+
out += hdr("To", Array.isArray(h.to) ? h.to.join(", ") : (h.to as string));
|
|
275
|
+
if (h.cc && h.cc.length > 0) {
|
|
276
|
+
out += hdr("Cc", h.cc.join(", "));
|
|
277
|
+
}
|
|
278
|
+
out += hdr("Date", formatRFC2822Date(h.date));
|
|
279
|
+
out += hdr("Message-ID", h.messageId);
|
|
280
|
+
if (h.subject !== undefined) {
|
|
281
|
+
out += hdr("Subject", h.subject);
|
|
282
|
+
}
|
|
283
|
+
if (h.inReplyTo !== undefined) {
|
|
284
|
+
out += hdr("In-Reply-To", h.inReplyTo);
|
|
285
|
+
}
|
|
286
|
+
if (h.references !== undefined && h.references.length > 0) {
|
|
287
|
+
out += hdr("References", h.references.join(" "));
|
|
288
|
+
}
|
|
289
|
+
out += hdr("MIME-Version", "1.0");
|
|
290
|
+
out += hdr("Content-Type", contentType);
|
|
291
|
+
|
|
292
|
+
// Interchange headers
|
|
293
|
+
if (h.interchangeType !== undefined) {
|
|
294
|
+
out += hdr("Interchange-Type", h.interchangeType);
|
|
295
|
+
}
|
|
296
|
+
if (h.interchangeCorrelationId !== undefined) {
|
|
297
|
+
out += hdr("Interchange-Correlation-ID", h.interchangeCorrelationId);
|
|
298
|
+
}
|
|
299
|
+
if (h.interchangeTenantId !== undefined) {
|
|
300
|
+
out += hdr("Interchange-Tenant-ID", h.interchangeTenantId);
|
|
301
|
+
}
|
|
302
|
+
if (h.interchangeAgentId !== undefined) {
|
|
303
|
+
out += hdr("Interchange-Agent-ID", h.interchangeAgentId);
|
|
304
|
+
}
|
|
305
|
+
if (h.interchangeSessionId !== undefined) {
|
|
306
|
+
out += hdr("Interchange-Session-ID", h.interchangeSessionId);
|
|
307
|
+
}
|
|
308
|
+
if (h.interchangeOfferingId !== undefined) {
|
|
309
|
+
out += hdr("Interchange-Offering-ID", h.interchangeOfferingId);
|
|
310
|
+
}
|
|
311
|
+
if (h.interchangeSchemaVersion !== undefined) {
|
|
312
|
+
out += hdr("Interchange-Schema-Version", h.interchangeSchemaVersion);
|
|
313
|
+
}
|
|
314
|
+
if (h.traceparent !== undefined) {
|
|
315
|
+
out += hdr("traceparent", h.traceparent);
|
|
316
|
+
}
|
|
317
|
+
if (h.tracestate !== undefined) {
|
|
318
|
+
out += hdr("tracestate", h.tracestate);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return out;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
// MIME part assembly
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Assemble the signed content for a conversation message (text/plain).
|
|
330
|
+
*
|
|
331
|
+
* This is the exact bytes that will be hashed for the PGP/MIME signature.
|
|
332
|
+
* Content-Transfer-Encoding: 7bit (conversation messages are ASCII).
|
|
333
|
+
*/
|
|
334
|
+
function assembleConversationSignedPart(text: string): Uint8Array {
|
|
335
|
+
// Canonicalize: CRLF line endings, strip trailing whitespace per line.
|
|
336
|
+
const lines = text.split(/\r\n|\r|\n/);
|
|
337
|
+
const canonLines = lines.map((l) => l.replace(/[ \t]+$/, ""));
|
|
338
|
+
const canonical = canonLines.join(CRLF);
|
|
339
|
+
|
|
340
|
+
const partHeaders =
|
|
341
|
+
`Content-Type: text/plain; charset=utf-8${CRLF}` +
|
|
342
|
+
`Content-Transfer-Encoding: 7bit${CRLF}`;
|
|
343
|
+
const body = `${partHeaders}${CRLF}${canonical}`;
|
|
344
|
+
return new TextEncoder().encode(body);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Assemble the signed content for a structured message (multipart/mixed).
|
|
349
|
+
*
|
|
350
|
+
* This is the exact bytes that will be hashed for the PGP/MIME signature.
|
|
351
|
+
*/
|
|
352
|
+
function assembleStructuredSignedPart(
|
|
353
|
+
json: Record<string, unknown>,
|
|
354
|
+
summary?: string,
|
|
355
|
+
): Uint8Array {
|
|
356
|
+
const boundary = generateBoundary();
|
|
357
|
+
const jsonStr = JSON.stringify(json);
|
|
358
|
+
|
|
359
|
+
let body = `Content-Type: multipart/mixed; boundary="${boundary}"${CRLF}${CRLF}`;
|
|
360
|
+
|
|
361
|
+
// JSON payload part
|
|
362
|
+
body += `--${boundary}${CRLF}`;
|
|
363
|
+
body += `Content-Type: application/vnd.interchange+json; charset=utf-8${CRLF}`;
|
|
364
|
+
body += `Content-Transfer-Encoding: 7bit${CRLF}`;
|
|
365
|
+
body += `${CRLF}`;
|
|
366
|
+
body += `${jsonStr}${CRLF}`;
|
|
367
|
+
|
|
368
|
+
// Optional human-readable summary
|
|
369
|
+
if (summary !== undefined) {
|
|
370
|
+
body += `--${boundary}${CRLF}`;
|
|
371
|
+
body += `Content-Type: text/plain; charset=utf-8${CRLF}`;
|
|
372
|
+
body += `Content-Transfer-Encoding: 7bit${CRLF}`;
|
|
373
|
+
body += `${CRLF}`;
|
|
374
|
+
const lines = summary.split(/\r\n|\r|\n/);
|
|
375
|
+
const canonLines = lines.map((l) => l.replace(/[ \t]+$/, ""));
|
|
376
|
+
body += `${canonLines.join(CRLF)}${CRLF}`;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
body += `--${boundary}--${CRLF}`;
|
|
380
|
+
return new TextEncoder().encode(body);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Wrap content part and PGP signature into multipart/signed per RFC 3156.
|
|
385
|
+
*
|
|
386
|
+
* RFC 3156 §5: The multipart/signed body MUST consist of exactly two parts.
|
|
387
|
+
* The first part contains the signed data. The second part contains the
|
|
388
|
+
* detached PGP signature in application/pgp-signature.
|
|
389
|
+
*
|
|
390
|
+
* The boundary delimiter lines use CRLF as required by RFC 2046.
|
|
391
|
+
*/
|
|
392
|
+
function wrapInMultipartSigned(
|
|
393
|
+
signedContentBytes: Uint8Array,
|
|
394
|
+
signatureBytes: Uint8Array,
|
|
395
|
+
boundary: string,
|
|
396
|
+
): Uint8Array {
|
|
397
|
+
const signedContent = new TextDecoder().decode(signedContentBytes);
|
|
398
|
+
const signature = new TextDecoder().decode(signatureBytes);
|
|
399
|
+
|
|
400
|
+
const enc = new TextEncoder();
|
|
401
|
+
|
|
402
|
+
// Per RFC 2046: boundary delimiter = "--" + boundary parameter.
|
|
403
|
+
// The CRLF preceding the boundary belongs to the boundary, not the part.
|
|
404
|
+
// Each part is preceded by: CRLF + "--" + boundary + CRLF
|
|
405
|
+
// The closing delimiter: CRLF + "--" + boundary + "--" + CRLF
|
|
406
|
+
const body =
|
|
407
|
+
`--${boundary}${CRLF}` +
|
|
408
|
+
`${signedContent}` +
|
|
409
|
+
`${CRLF}--${boundary}${CRLF}` +
|
|
410
|
+
`Content-Type: application/pgp-signature${CRLF}` +
|
|
411
|
+
`${CRLF}` +
|
|
412
|
+
`${signature}${CRLF}` +
|
|
413
|
+
`--${boundary}--${CRLF}`;
|
|
414
|
+
|
|
415
|
+
return enc.encode(body);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
// Full message assembly
|
|
420
|
+
// ---------------------------------------------------------------------------
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Assemble a complete RFC 2822 message from headers, content, and signature
|
|
424
|
+
* bytes. Returns the raw message bytes for storage.
|
|
425
|
+
*
|
|
426
|
+
* The signature bytes must be produced by signing the signed content part
|
|
427
|
+
* bytes (the result of assembleSignedContentPart below).
|
|
428
|
+
*/
|
|
429
|
+
export function assembleMessage(
|
|
430
|
+
headers: MessageHeaders,
|
|
431
|
+
signedContentBytes: Uint8Array,
|
|
432
|
+
signatureBytes: Uint8Array,
|
|
433
|
+
): Uint8Array {
|
|
434
|
+
const outerBoundary = generateBoundary();
|
|
435
|
+
|
|
436
|
+
const contentType =
|
|
437
|
+
`multipart/signed; protocol="application/pgp-signature"; ` +
|
|
438
|
+
`micalg=pgp-sha512; boundary="${outerBoundary}"`;
|
|
439
|
+
|
|
440
|
+
const headerSection = serializeMessageHeaders(headers, contentType);
|
|
441
|
+
const bodyBytes = wrapInMultipartSigned(
|
|
442
|
+
signedContentBytes,
|
|
443
|
+
signatureBytes,
|
|
444
|
+
outerBoundary,
|
|
445
|
+
);
|
|
446
|
+
|
|
447
|
+
const enc = new TextEncoder();
|
|
448
|
+
const headerBytes = enc.encode(headerSection + CRLF);
|
|
449
|
+
|
|
450
|
+
const result = new Uint8Array(headerBytes.length + bodyBytes.length);
|
|
451
|
+
result.set(headerBytes, 0);
|
|
452
|
+
result.set(bodyBytes, headerBytes.length);
|
|
453
|
+
return result;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Build the signed content bytes for a message. These exact bytes are
|
|
458
|
+
* what the CryptoProvider signs. The transport calls this, then signs,
|
|
459
|
+
* then calls assembleMessage with both.
|
|
460
|
+
*/
|
|
461
|
+
export function assembleSignedContent(
|
|
462
|
+
content: ConversationContent | StructuredContent,
|
|
463
|
+
): Uint8Array {
|
|
464
|
+
if (content.kind === "conversation") {
|
|
465
|
+
return assembleConversationSignedPart(content.text);
|
|
466
|
+
}
|
|
467
|
+
return assembleStructuredSignedPart(content.json, content.summary);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ---------------------------------------------------------------------------
|
|
471
|
+
// MIME parsing (for fetchHeaders, fetchStructure, fetchPart, fetchFull)
|
|
472
|
+
// ---------------------------------------------------------------------------
|
|
473
|
+
|
|
474
|
+
const CRLF_CRLF = new Uint8Array([0x0d, 0x0a, 0x0d, 0x0a]);
|
|
475
|
+
const LF_LF = new Uint8Array([0x0a, 0x0a]);
|
|
476
|
+
|
|
477
|
+
function findByteSequence(haystack: Uint8Array, needle: Uint8Array): number {
|
|
478
|
+
if (needle.length === 0) return 0;
|
|
479
|
+
const limit = haystack.length - needle.length;
|
|
480
|
+
outer: for (let i = 0; i <= limit; i++) {
|
|
481
|
+
for (let j = 0; j < needle.length; j++) {
|
|
482
|
+
if (haystack[i + j] !== needle[j]) continue outer;
|
|
483
|
+
}
|
|
484
|
+
return i;
|
|
485
|
+
}
|
|
486
|
+
return -1;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Parse the header section of a raw RFC 2822 message.
|
|
491
|
+
* Returns a map of lowercase header names to their values, and the
|
|
492
|
+
* byte offset where the body starts.
|
|
493
|
+
*/
|
|
494
|
+
export function parseHeaderSection(raw: Uint8Array): {
|
|
495
|
+
headers: Map<string, string>;
|
|
496
|
+
bodyOffset: number;
|
|
497
|
+
} {
|
|
498
|
+
const headers = new Map<string, string>();
|
|
499
|
+
|
|
500
|
+
// Search for the blank line separator in byte space so the returned
|
|
501
|
+
// offset is valid for Uint8Array.slice() even when headers contain
|
|
502
|
+
// multi-byte UTF-8 characters.
|
|
503
|
+
const crlfIdx = findByteSequence(raw, CRLF_CRLF);
|
|
504
|
+
const lfIdx = findByteSequence(raw, LF_LF);
|
|
505
|
+
|
|
506
|
+
let bodyOffset = raw.length;
|
|
507
|
+
let headerEnd = raw.length;
|
|
508
|
+
|
|
509
|
+
if (crlfIdx !== -1 && (lfIdx === -1 || crlfIdx <= lfIdx)) {
|
|
510
|
+
headerEnd = crlfIdx;
|
|
511
|
+
bodyOffset = crlfIdx + 4;
|
|
512
|
+
} else if (lfIdx !== -1) {
|
|
513
|
+
headerEnd = lfIdx;
|
|
514
|
+
bodyOffset = lfIdx + 2;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const headerText = new TextDecoder("utf-8", { fatal: false }).decode(
|
|
518
|
+
raw.subarray(0, headerEnd),
|
|
519
|
+
);
|
|
520
|
+
parseHeaders(headerText, headers);
|
|
521
|
+
|
|
522
|
+
return { headers, bodyOffset };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function parseHeaders(headerSection: string, out: Map<string, string>): void {
|
|
526
|
+
// Unfold continuation lines (lines starting with whitespace per RFC 2822).
|
|
527
|
+
const unfolded = headerSection
|
|
528
|
+
.replace(/\r\n[ \t]+/g, " ")
|
|
529
|
+
.replace(/\n[ \t]+/g, " ");
|
|
530
|
+
const lines = unfolded.split(/\r\n|\n/);
|
|
531
|
+
for (const line of lines) {
|
|
532
|
+
if (line.trim() === "") continue;
|
|
533
|
+
const colon = line.indexOf(":");
|
|
534
|
+
if (colon === -1) continue;
|
|
535
|
+
const name = line.slice(0, colon).trim().toLowerCase();
|
|
536
|
+
const value = line.slice(colon + 1).trim();
|
|
537
|
+
// For repeated headers (like Received), keep the first value.
|
|
538
|
+
if (!out.has(name)) {
|
|
539
|
+
out.set(name, value);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Extract the boundary parameter from a Content-Type header value.
|
|
546
|
+
*/
|
|
547
|
+
export function extractBoundary(contentTypeValue: string): string | undefined {
|
|
548
|
+
const match =
|
|
549
|
+
contentTypeValue.match(/boundary="([^"]+)"/i) ??
|
|
550
|
+
contentTypeValue.match(/boundary=([^\s;]+)/i);
|
|
551
|
+
return match?.[1];
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Parse a multipart body into individual parts.
|
|
556
|
+
*
|
|
557
|
+
* Each part is returned as raw bytes (headers + blank line + body) for
|
|
558
|
+
* further parsing.
|
|
559
|
+
*/
|
|
560
|
+
export function parseMultipart(
|
|
561
|
+
body: Uint8Array,
|
|
562
|
+
boundary: string,
|
|
563
|
+
): Uint8Array[] {
|
|
564
|
+
const text = new TextDecoder("utf-8", { fatal: false }).decode(body);
|
|
565
|
+
const delimiter = `--${boundary}`;
|
|
566
|
+
const parts: Uint8Array[] = [];
|
|
567
|
+
const enc = new TextEncoder();
|
|
568
|
+
|
|
569
|
+
let pos = 0;
|
|
570
|
+
while (pos < text.length) {
|
|
571
|
+
// Find next delimiter.
|
|
572
|
+
const delimIdx = text.indexOf(delimiter, pos);
|
|
573
|
+
if (delimIdx === -1) break;
|
|
574
|
+
|
|
575
|
+
// Check if it's the closing delimiter.
|
|
576
|
+
const afterDelim = delimIdx + delimiter.length;
|
|
577
|
+
if (text.slice(afterDelim, afterDelim + 2) === "--") break;
|
|
578
|
+
|
|
579
|
+
// Skip past the delimiter line (to end of CRLF or LF).
|
|
580
|
+
let partStart = afterDelim;
|
|
581
|
+
if (text[partStart] === "\r") partStart++;
|
|
582
|
+
if (text[partStart] === "\n") partStart++;
|
|
583
|
+
|
|
584
|
+
// Find the next delimiter to know where this part ends.
|
|
585
|
+
const nextDelimIdx = text.indexOf("\n" + delimiter, partStart);
|
|
586
|
+
if (nextDelimIdx === -1) break;
|
|
587
|
+
|
|
588
|
+
// Part body excludes the trailing CRLF before the next boundary.
|
|
589
|
+
let partEnd = nextDelimIdx;
|
|
590
|
+
// Account for the \n we searched for.
|
|
591
|
+
// We want to include only up to (but not including) the CRLF before "--boundary".
|
|
592
|
+
// nextDelimIdx points to the \n before the delimiter. The part ends before
|
|
593
|
+
// the preceding \r\n (or just \n).
|
|
594
|
+
if (partEnd > partStart && text[partEnd - 1] === "\r") {
|
|
595
|
+
partEnd--;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const partText = text.slice(partStart, partEnd);
|
|
599
|
+
parts.push(enc.encode(partText));
|
|
600
|
+
|
|
601
|
+
pos = nextDelimIdx + 1;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
return parts;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Parse a single MIME part into its headers and body.
|
|
609
|
+
*/
|
|
610
|
+
export function parseMimePart(partBytes: Uint8Array): ParsedMimePart {
|
|
611
|
+
const { headers, bodyOffset } = parseHeaderSection(partBytes);
|
|
612
|
+
const contentType = headers.get("content-type") ?? "application/octet-stream";
|
|
613
|
+
const body = partBytes.slice(bodyOffset);
|
|
614
|
+
return { contentType, headers, body };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Extract a MIME part by dot-separated path from a multipart/signed message.
|
|
619
|
+
*
|
|
620
|
+
* Path "1" returns the signed content part (text/plain or multipart/mixed).
|
|
621
|
+
* Path "1.1" returns the first sub-part of the signed content (JSON payload).
|
|
622
|
+
* Path "2" returns the application/pgp-signature part.
|
|
623
|
+
*
|
|
624
|
+
* This follows IMAP FETCH section specifier semantics (RFC 9051).
|
|
625
|
+
*/
|
|
626
|
+
export function extractPartByPath(
|
|
627
|
+
raw: Uint8Array,
|
|
628
|
+
partPath: string,
|
|
629
|
+
): Uint8Array {
|
|
630
|
+
const { headers, bodyOffset } = parseHeaderSection(raw);
|
|
631
|
+
const body = raw.slice(bodyOffset);
|
|
632
|
+
const contentType = headers.get("content-type") ?? "";
|
|
633
|
+
|
|
634
|
+
const steps = partPath.split(".").map((s) => {
|
|
635
|
+
const n = parseInt(s, 10);
|
|
636
|
+
if (isNaN(n) || n < 1) {
|
|
637
|
+
throw new Error(`Invalid part path segment: "${s}"`);
|
|
638
|
+
}
|
|
639
|
+
return n;
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
return walkParts(body, contentType, steps, 0);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function walkParts(
|
|
646
|
+
body: Uint8Array,
|
|
647
|
+
contentType: string,
|
|
648
|
+
steps: number[],
|
|
649
|
+
depth: number,
|
|
650
|
+
): Uint8Array {
|
|
651
|
+
const step = steps[depth];
|
|
652
|
+
if (step === undefined) {
|
|
653
|
+
throw new Error("Part path has no more segments");
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (!contentType.toLowerCase().startsWith("multipart/")) {
|
|
657
|
+
throw new Error(
|
|
658
|
+
`Cannot index into non-multipart content type: ${contentType}`,
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const boundary = extractBoundary(contentType);
|
|
663
|
+
if (boundary === undefined) {
|
|
664
|
+
throw new Error(`No boundary found in Content-Type: ${contentType}`);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const parts = parseMultipart(body, boundary);
|
|
668
|
+
if (step > parts.length) {
|
|
669
|
+
throw new Error(`Part ${step} does not exist (only ${parts.length} parts)`);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const partBytes = parts[step - 1]!;
|
|
673
|
+
|
|
674
|
+
if (depth + 1 === steps.length) {
|
|
675
|
+
return partBytes;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Need to descend further.
|
|
679
|
+
const part = parseMimePart(partBytes);
|
|
680
|
+
return walkParts(part.body, part.contentType, steps, depth + 1);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// ---------------------------------------------------------------------------
|
|
684
|
+
// JMAP Email parsing
|
|
685
|
+
// ---------------------------------------------------------------------------
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Parse a RFC 2822 address value into structured JMAP address objects.
|
|
689
|
+
*
|
|
690
|
+
* Handles both "Display Name" <email@example.com> and bare email@example.com
|
|
691
|
+
* forms, as well as comma-separated address lists.
|
|
692
|
+
*/
|
|
693
|
+
function parseAddressList(value: string): JMAPAddress[] {
|
|
694
|
+
const results: JMAPAddress[] = [];
|
|
695
|
+
// Split on commas that are not inside quoted strings or angle brackets.
|
|
696
|
+
// We handle the two common forms:
|
|
697
|
+
// 1. "Display Name" <email>
|
|
698
|
+
// 2. Display Name <email>
|
|
699
|
+
// 3. <email>
|
|
700
|
+
// 4. email
|
|
701
|
+
const segments = splitAddressList(value);
|
|
702
|
+
for (const segment of segments) {
|
|
703
|
+
const addr = parseOneAddress(segment.trim());
|
|
704
|
+
if (addr !== null) {
|
|
705
|
+
results.push(addr);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
return results;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function splitAddressList(value: string): string[] {
|
|
712
|
+
const segments: string[] = [];
|
|
713
|
+
let current = "";
|
|
714
|
+
let depth = 0;
|
|
715
|
+
let inQuote = false;
|
|
716
|
+
|
|
717
|
+
for (const ch of value) {
|
|
718
|
+
if (ch === '"' && !inQuote) {
|
|
719
|
+
inQuote = true;
|
|
720
|
+
current += ch;
|
|
721
|
+
} else if (ch === '"' && inQuote) {
|
|
722
|
+
inQuote = false;
|
|
723
|
+
current += ch;
|
|
724
|
+
} else if (ch === "<" && !inQuote) {
|
|
725
|
+
depth++;
|
|
726
|
+
current += ch;
|
|
727
|
+
} else if (ch === ">" && !inQuote) {
|
|
728
|
+
depth--;
|
|
729
|
+
current += ch;
|
|
730
|
+
} else if (ch === "," && depth === 0 && !inQuote) {
|
|
731
|
+
segments.push(current);
|
|
732
|
+
current = "";
|
|
733
|
+
} else {
|
|
734
|
+
current += ch;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
if (current.trim() !== "") {
|
|
738
|
+
segments.push(current);
|
|
739
|
+
}
|
|
740
|
+
return segments;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function parseOneAddress(segment: string): JMAPAddress | null {
|
|
744
|
+
if (segment === "") return null;
|
|
745
|
+
|
|
746
|
+
// "Display Name" <email> or Display Name <email>
|
|
747
|
+
const angleMatch = segment.match(/^(.*?)<([^>]+)>\s*$/);
|
|
748
|
+
if (angleMatch !== null) {
|
|
749
|
+
const rawName = angleMatch[1]!.trim();
|
|
750
|
+
const email = angleMatch[2]!.trim();
|
|
751
|
+
// Strip surrounding quotes from display name if present
|
|
752
|
+
const name =
|
|
753
|
+
rawName === "" ? null : rawName.replace(/^"(.*)"$/, "$1").trim() || null;
|
|
754
|
+
return { name, email };
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Bare email address
|
|
758
|
+
const bare = segment.trim();
|
|
759
|
+
if (bare !== "") {
|
|
760
|
+
return { name: null, email: bare };
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
return null;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Parse the MIME Date header into an ISO 8601 string.
|
|
768
|
+
*
|
|
769
|
+
* Returns null if the header is missing or the value cannot be parsed.
|
|
770
|
+
*/
|
|
771
|
+
function parseDateHeader(value: string | undefined): string | null {
|
|
772
|
+
if (value === undefined) return null;
|
|
773
|
+
const date = new Date(value);
|
|
774
|
+
if (isNaN(date.getTime())) return null;
|
|
775
|
+
return date.toISOString();
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Decode a MIME body part, handling Content-Transfer-Encoding.
|
|
780
|
+
*/
|
|
781
|
+
function decodeBodyBytes(
|
|
782
|
+
body: Uint8Array,
|
|
783
|
+
headers: Map<string, string>,
|
|
784
|
+
): { value: string; isEncodingProblem: boolean } {
|
|
785
|
+
const cte = (headers.get("content-transfer-encoding") ?? "7bit")
|
|
786
|
+
.trim()
|
|
787
|
+
.toLowerCase();
|
|
788
|
+
|
|
789
|
+
if (cte === "base64") {
|
|
790
|
+
try {
|
|
791
|
+
const raw = new TextDecoder("utf-8", { fatal: false }).decode(body);
|
|
792
|
+
const cleaned = raw.replace(/\s+/g, "");
|
|
793
|
+
const binaryStr = atob(cleaned);
|
|
794
|
+
return { value: binaryStr, isEncodingProblem: false };
|
|
795
|
+
} catch {
|
|
796
|
+
return {
|
|
797
|
+
value: new TextDecoder("utf-8", { fatal: false }).decode(body),
|
|
798
|
+
isEncodingProblem: true,
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
if (cte === "quoted-printable") {
|
|
804
|
+
const raw = new TextDecoder("utf-8", { fatal: false }).decode(body);
|
|
805
|
+
return { value: decodeQuotedPrintable(raw), isEncodingProblem: false };
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// 7bit, 8bit, binary — decode as UTF-8
|
|
809
|
+
return {
|
|
810
|
+
value: new TextDecoder("utf-8", { fatal: false }).decode(body),
|
|
811
|
+
isEncodingProblem: false,
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function decodeQuotedPrintable(text: string): string {
|
|
816
|
+
return text
|
|
817
|
+
.replace(/=\r\n/g, "")
|
|
818
|
+
.replace(/=\n/g, "")
|
|
819
|
+
.replace(/=([0-9A-Fa-f]{2})/g, (_match, hex: string) =>
|
|
820
|
+
String.fromCharCode(parseInt(hex, 16)),
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Determine whether a MIME part is an attachment based on Content-Disposition
|
|
826
|
+
* and content type.
|
|
827
|
+
*/
|
|
828
|
+
function isAttachmentPart(
|
|
829
|
+
contentType: string,
|
|
830
|
+
headers: Map<string, string>,
|
|
831
|
+
): boolean {
|
|
832
|
+
const disposition = headers.get("content-disposition") ?? "";
|
|
833
|
+
if (disposition.toLowerCase().startsWith("attachment")) return true;
|
|
834
|
+
|
|
835
|
+
const ct = contentType.toLowerCase().split(";")[0]!.trim();
|
|
836
|
+
if (ct === "text/plain" || ct === "text/html") return false;
|
|
837
|
+
|
|
838
|
+
// Non-text types are treated as attachments unless they are multipart.
|
|
839
|
+
if (ct.startsWith("multipart/")) return false;
|
|
840
|
+
|
|
841
|
+
return true;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function extractContentTypeMime(contentType: string): string {
|
|
845
|
+
return contentType.split(";")[0]!.trim().toLowerCase();
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function extractFilename(headers: Map<string, string>): string | null {
|
|
849
|
+
const disposition = headers.get("content-disposition") ?? "";
|
|
850
|
+
const nameMatch =
|
|
851
|
+
disposition.match(/filename="([^"]+)"/i) ??
|
|
852
|
+
disposition.match(/filename=([^\s;]+)/i);
|
|
853
|
+
if (nameMatch !== null) return nameMatch[1]!;
|
|
854
|
+
|
|
855
|
+
const ct = headers.get("content-type") ?? "";
|
|
856
|
+
const ctNameMatch =
|
|
857
|
+
ct.match(/name="([^"]+)"/i) ?? ct.match(/name=([^\s;]+)/i);
|
|
858
|
+
if (ctNameMatch !== null) return ctNameMatch[1]!;
|
|
859
|
+
|
|
860
|
+
return null;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
type WalkContext = {
|
|
864
|
+
mailId: string;
|
|
865
|
+
bodyValues: Record<string, JMAPBodyValue>;
|
|
866
|
+
textBody: JMAPBodyPart[];
|
|
867
|
+
htmlBody: JMAPBodyPart[];
|
|
868
|
+
attachments: JMAPAttachment[];
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Recursively walk MIME parts, populating body values and attachment lists.
|
|
873
|
+
*
|
|
874
|
+
* partPath uses IMAP-style dot-separated numbering (e.g., "1", "1.1", "2.3").
|
|
875
|
+
*/
|
|
876
|
+
function walkMimePart(
|
|
877
|
+
partBytes: Uint8Array,
|
|
878
|
+
partPath: string,
|
|
879
|
+
ctx: WalkContext,
|
|
880
|
+
): void {
|
|
881
|
+
const part = parseMimePart(partBytes);
|
|
882
|
+
const mime = extractContentTypeMime(part.contentType);
|
|
883
|
+
|
|
884
|
+
if (mime.startsWith("multipart/")) {
|
|
885
|
+
const boundary = extractBoundary(part.contentType);
|
|
886
|
+
if (boundary === undefined) return;
|
|
887
|
+
const subParts = parseMultipart(part.body, boundary);
|
|
888
|
+
subParts.forEach((subPartBytes, idx) => {
|
|
889
|
+
walkMimePart(subPartBytes, `${partPath}.${idx + 1}`, ctx);
|
|
890
|
+
});
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
if (isAttachmentPart(part.contentType, part.headers)) {
|
|
895
|
+
const blobId = `blob_${ctx.mailId}_${partPath}`;
|
|
896
|
+
ctx.attachments.push({
|
|
897
|
+
blobId,
|
|
898
|
+
name: extractFilename(part.headers),
|
|
899
|
+
type: mime,
|
|
900
|
+
size: part.body.length,
|
|
901
|
+
});
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const decoded = decodeBodyBytes(part.body, part.headers);
|
|
906
|
+
ctx.bodyValues[partPath] = decoded;
|
|
907
|
+
|
|
908
|
+
if (mime === "text/plain") {
|
|
909
|
+
ctx.textBody.push({ partId: partPath, type: mime });
|
|
910
|
+
} else if (mime === "text/html") {
|
|
911
|
+
ctx.htmlBody.push({ partId: partPath, type: mime });
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* Convert raw MIME bytes into a JMAP Email-shaped object.
|
|
917
|
+
*
|
|
918
|
+
* Handles text/plain, multipart/mixed, and multipart/signed message shapes.
|
|
919
|
+
* For multipart/signed (RFC 3156), the signed content part (part 1) is
|
|
920
|
+
* parsed for body and attachments. Signature verification is not performed.
|
|
921
|
+
*
|
|
922
|
+
* @param raw - Raw RFC 2822 message bytes
|
|
923
|
+
* @param mailId - Opaque mail record ID used to generate blob IDs
|
|
924
|
+
*/
|
|
925
|
+
export function parseMailToEmail(raw: Uint8Array, mailId: string): JMAPEmail {
|
|
926
|
+
const { headers: msgHeaders, bodyOffset } = parseHeaderSection(raw);
|
|
927
|
+
const body = raw.slice(bodyOffset);
|
|
928
|
+
const contentType = msgHeaders.get("content-type") ?? "text/plain";
|
|
929
|
+
const mime = extractContentTypeMime(contentType);
|
|
930
|
+
|
|
931
|
+
const ctx: WalkContext = {
|
|
932
|
+
mailId,
|
|
933
|
+
bodyValues: {},
|
|
934
|
+
textBody: [],
|
|
935
|
+
htmlBody: [],
|
|
936
|
+
attachments: [],
|
|
937
|
+
};
|
|
938
|
+
|
|
939
|
+
if (mime === "multipart/signed") {
|
|
940
|
+
// RFC 3156: part 1 is the signed content, part 2 is the signature.
|
|
941
|
+
// Parse the content part through to extract body and attachments.
|
|
942
|
+
const boundary = extractBoundary(contentType);
|
|
943
|
+
if (boundary !== undefined) {
|
|
944
|
+
const outerParts = parseMultipart(body, boundary);
|
|
945
|
+
const contentPart = outerParts[0];
|
|
946
|
+
if (contentPart !== undefined) {
|
|
947
|
+
// The content part may itself be text/plain or multipart/mixed.
|
|
948
|
+
// We assign it path "1" and walk it.
|
|
949
|
+
walkMimePart(contentPart, "1", ctx);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
} else if (mime.startsWith("multipart/")) {
|
|
953
|
+
const boundary = extractBoundary(contentType);
|
|
954
|
+
if (boundary !== undefined) {
|
|
955
|
+
const parts = parseMultipart(body, boundary);
|
|
956
|
+
parts.forEach((partBytes, idx) => {
|
|
957
|
+
walkMimePart(partBytes, `${idx + 1}`, ctx);
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
} else {
|
|
961
|
+
// Single-part message (e.g. text/plain).
|
|
962
|
+
// Reconstruct minimal part bytes with content-type header so parseMimePart works.
|
|
963
|
+
const enc = new TextEncoder();
|
|
964
|
+
const ctHeader = `Content-Type: ${contentType}\r\n\r\n`;
|
|
965
|
+
const partBytes = new Uint8Array(enc.encode(ctHeader).length + body.length);
|
|
966
|
+
partBytes.set(enc.encode(ctHeader), 0);
|
|
967
|
+
partBytes.set(body, enc.encode(ctHeader).length);
|
|
968
|
+
walkMimePart(partBytes, "1", ctx);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// Extract Interchange-specific headers.
|
|
972
|
+
const interchangeHeaders: Record<string, string> = {};
|
|
973
|
+
for (const [name, value] of msgHeaders) {
|
|
974
|
+
if (name.startsWith("interchange-")) {
|
|
975
|
+
interchangeHeaders[name] = value;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
return {
|
|
980
|
+
from: parseAddressList(msgHeaders.get("from") ?? ""),
|
|
981
|
+
to: parseAddressList(msgHeaders.get("to") ?? ""),
|
|
982
|
+
subject: msgHeaders.get("subject") ?? null,
|
|
983
|
+
sentAt: parseDateHeader(msgHeaders.get("date")),
|
|
984
|
+
bodyValues: ctx.bodyValues,
|
|
985
|
+
textBody: ctx.textBody,
|
|
986
|
+
htmlBody: ctx.htmlBody,
|
|
987
|
+
attachments: ctx.attachments,
|
|
988
|
+
headers: interchangeHeaders,
|
|
989
|
+
};
|
|
990
|
+
}
|