@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
|
@@ -0,0 +1,978 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
generateKeyPair,
|
|
4
|
+
createNodeCrypto,
|
|
5
|
+
verifyDetachedSignature,
|
|
6
|
+
} from "@intx/crypto-node";
|
|
7
|
+
import {
|
|
8
|
+
assembleSignedContent,
|
|
9
|
+
assembleMessage,
|
|
10
|
+
createDetachedSignatureFromProvider,
|
|
11
|
+
extractAddrSpec,
|
|
12
|
+
formatRFC2822Date,
|
|
13
|
+
generateMessageId,
|
|
14
|
+
parseHeaderSection,
|
|
15
|
+
parseMimePart,
|
|
16
|
+
parseMultipart,
|
|
17
|
+
extractBoundary,
|
|
18
|
+
extractPartByPath,
|
|
19
|
+
parseMailToEmail,
|
|
20
|
+
type MessageHeaders,
|
|
21
|
+
} from "./index";
|
|
22
|
+
|
|
23
|
+
const enc = new TextEncoder();
|
|
24
|
+
const dec = new TextDecoder();
|
|
25
|
+
|
|
26
|
+
function defined<T>(value: T | undefined | null): T {
|
|
27
|
+
if (value === undefined || value === null) {
|
|
28
|
+
throw new Error("Expected a defined value but got undefined/null");
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeHeaders(overrides?: Partial<MessageHeaders>): MessageHeaders {
|
|
34
|
+
return {
|
|
35
|
+
from: "alice@test.interchange",
|
|
36
|
+
to: ["bob@test.interchange"],
|
|
37
|
+
cc: undefined,
|
|
38
|
+
date: new Date("2026-04-21T12:00:00Z"),
|
|
39
|
+
messageId: "<test-1@test.interchange>",
|
|
40
|
+
subject: undefined,
|
|
41
|
+
inReplyTo: undefined,
|
|
42
|
+
references: undefined,
|
|
43
|
+
mimeVersion: "1.0",
|
|
44
|
+
interchangeType: undefined,
|
|
45
|
+
interchangeCorrelationId: undefined,
|
|
46
|
+
interchangeTenantId: undefined,
|
|
47
|
+
interchangeAgentId: undefined,
|
|
48
|
+
interchangeSessionId: undefined,
|
|
49
|
+
interchangeOfferingId: undefined,
|
|
50
|
+
interchangeSchemaVersion: undefined,
|
|
51
|
+
traceparent: undefined,
|
|
52
|
+
tracestate: undefined,
|
|
53
|
+
...overrides,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// generateMessageId
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
describe("generateMessageId", () => {
|
|
62
|
+
test("extracts domain from address", () => {
|
|
63
|
+
const id = generateMessageId("alice@example.com");
|
|
64
|
+
expect(id).toMatch(/^<[0-9a-f-]+@example\.com>$/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("uses local when address has no domain", () => {
|
|
68
|
+
const id = generateMessageId("alice");
|
|
69
|
+
expect(id).toMatch(/^<[0-9a-f-]+@local>$/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("produces unique IDs", () => {
|
|
73
|
+
const a = generateMessageId("x@y");
|
|
74
|
+
const b = generateMessageId("x@y");
|
|
75
|
+
expect(a).not.toBe(b);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// formatRFC2822Date
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
describe("extractAddrSpec", () => {
|
|
84
|
+
test("strips quoted display name and angle brackets", () => {
|
|
85
|
+
expect(extractAddrSpec('"Alice Doe" <alice@example.com>')).toBe(
|
|
86
|
+
"alice@example.com",
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("strips unquoted display name and angle brackets", () => {
|
|
91
|
+
expect(extractAddrSpec("Alice Doe <alice@example.com>")).toBe(
|
|
92
|
+
"alice@example.com",
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("strips bare angle brackets", () => {
|
|
97
|
+
expect(extractAddrSpec("<alice@example.com>")).toBe("alice@example.com");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("passes through a bare addr-spec", () => {
|
|
101
|
+
expect(extractAddrSpec("alice@example.com")).toBe("alice@example.com");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("lowercases local-part and domain", () => {
|
|
105
|
+
expect(extractAddrSpec("Alice@Example.COM")).toBe("alice@example.com");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("trims surrounding whitespace", () => {
|
|
109
|
+
expect(extractAddrSpec(" Alice@Example.com ")).toBe(
|
|
110
|
+
"alice@example.com",
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("throws on empty input", () => {
|
|
115
|
+
expect(() => extractAddrSpec(" ")).toThrow();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("throws on input with no '@'", () => {
|
|
119
|
+
expect(() => extractAddrSpec("Alice Doe")).toThrow();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("throws on missing local-part", () => {
|
|
123
|
+
expect(() => extractAddrSpec("<@example.com>")).toThrow();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("throws on missing domain", () => {
|
|
127
|
+
expect(() => extractAddrSpec("<alice@>")).toThrow();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("throws on trailing content after the closing '>'", () => {
|
|
131
|
+
expect(() =>
|
|
132
|
+
extractAddrSpec("Alice <alice@example.com> (comment)"),
|
|
133
|
+
).toThrow();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("throws on a quoted local-part", () => {
|
|
137
|
+
expect(() => extractAddrSpec('"a@b"@example.com')).toThrow();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("throws on multiple '@' in an unquoted form", () => {
|
|
141
|
+
expect(() => extractAddrSpec("a@b@example.com")).toThrow();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("formatRFC2822Date", () => {
|
|
146
|
+
test("formats a known date correctly", () => {
|
|
147
|
+
const date = new Date("2026-04-21T14:30:05Z");
|
|
148
|
+
expect(formatRFC2822Date(date)).toBe("Tue, 21 Apr 2026 14:30:05 +0000");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("zero-pads single-digit day and time components", () => {
|
|
152
|
+
const date = new Date("2026-01-05T03:04:09Z");
|
|
153
|
+
expect(formatRFC2822Date(date)).toBe("Mon, 05 Jan 2026 03:04:09 +0000");
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// assembleSignedContent — conversation
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
describe("assembleSignedContent", () => {
|
|
162
|
+
test("conversation produces text/plain with CRLF", () => {
|
|
163
|
+
const bytes = assembleSignedContent({
|
|
164
|
+
kind: "conversation",
|
|
165
|
+
text: "Hello\nWorld",
|
|
166
|
+
});
|
|
167
|
+
const text = dec.decode(bytes);
|
|
168
|
+
expect(text).toContain("Content-Type: text/plain; charset=utf-8\r\n");
|
|
169
|
+
expect(text).toContain("Content-Transfer-Encoding: 7bit\r\n");
|
|
170
|
+
expect(text).toContain("\r\nHello\r\nWorld");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("conversation strips trailing whitespace but preserves leading", () => {
|
|
174
|
+
const bytes = assembleSignedContent({
|
|
175
|
+
kind: "conversation",
|
|
176
|
+
text: " leading \nindented ",
|
|
177
|
+
});
|
|
178
|
+
const text = dec.decode(bytes);
|
|
179
|
+
expect(text).toContain("\r\n leading\r\nindented");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("conversation with empty text produces headers only", () => {
|
|
183
|
+
const bytes = assembleSignedContent({
|
|
184
|
+
kind: "conversation",
|
|
185
|
+
text: "",
|
|
186
|
+
});
|
|
187
|
+
const text = dec.decode(bytes);
|
|
188
|
+
expect(text).toMatch(/Content-Transfer-Encoding: 7bit\r\n\r\n$/);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("conversation normalizes CRLF input without doubling", () => {
|
|
192
|
+
const bytes = assembleSignedContent({
|
|
193
|
+
kind: "conversation",
|
|
194
|
+
text: "line1\r\nline2",
|
|
195
|
+
});
|
|
196
|
+
const text = dec.decode(bytes);
|
|
197
|
+
expect(text).toContain("line1\r\nline2");
|
|
198
|
+
expect(text).not.toContain("line1\r\n\r\nline2");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("structured produces multipart/mixed with JSON part", () => {
|
|
202
|
+
const bytes = assembleSignedContent({
|
|
203
|
+
kind: "structured",
|
|
204
|
+
json: { action: "deploy" },
|
|
205
|
+
});
|
|
206
|
+
const text = dec.decode(bytes);
|
|
207
|
+
expect(text).toContain("Content-Type: multipart/mixed;");
|
|
208
|
+
expect(text).toContain(
|
|
209
|
+
"Content-Type: application/vnd.interchange+json; charset=utf-8",
|
|
210
|
+
);
|
|
211
|
+
expect(text).toContain('{"action":"deploy"}');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("structured without summary produces only the JSON part", () => {
|
|
215
|
+
const bytes = assembleSignedContent({
|
|
216
|
+
kind: "structured",
|
|
217
|
+
json: { x: 1 },
|
|
218
|
+
});
|
|
219
|
+
const text = dec.decode(bytes);
|
|
220
|
+
expect(text).toContain('{"x":1}');
|
|
221
|
+
expect(text).not.toContain("Content-Type: text/plain");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("structured includes optional summary as text/plain part", () => {
|
|
225
|
+
const bytes = assembleSignedContent({
|
|
226
|
+
kind: "structured",
|
|
227
|
+
json: { x: 1 },
|
|
228
|
+
summary: "A summary",
|
|
229
|
+
});
|
|
230
|
+
const text = dec.decode(bytes);
|
|
231
|
+
const plainMatches = text.match(
|
|
232
|
+
/Content-Type: text\/plain; charset=utf-8/g,
|
|
233
|
+
);
|
|
234
|
+
expect(plainMatches).toHaveLength(1);
|
|
235
|
+
expect(text).toContain("A summary");
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// assembleMessage
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
describe("assembleMessage", () => {
|
|
244
|
+
test("produces multipart/signed with correct headers", () => {
|
|
245
|
+
const content = assembleSignedContent({
|
|
246
|
+
kind: "conversation",
|
|
247
|
+
text: "test",
|
|
248
|
+
});
|
|
249
|
+
const fakeSig = enc.encode("FAKE-SIGNATURE");
|
|
250
|
+
const msg = assembleMessage(makeHeaders(), content, fakeSig);
|
|
251
|
+
const text = dec.decode(msg);
|
|
252
|
+
|
|
253
|
+
expect(text).toContain("From: alice@test.interchange\r\n");
|
|
254
|
+
expect(text).toContain("To: bob@test.interchange\r\n");
|
|
255
|
+
expect(text).toContain("Message-ID: <test-1@test.interchange>\r\n");
|
|
256
|
+
expect(text).toContain("MIME-Version: 1.0\r\n");
|
|
257
|
+
expect(text).toContain(
|
|
258
|
+
'multipart/signed; protocol="application/pgp-signature"',
|
|
259
|
+
);
|
|
260
|
+
expect(text).toContain("micalg=pgp-sha512");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("includes optional headers when provided", () => {
|
|
264
|
+
const content = assembleSignedContent({
|
|
265
|
+
kind: "conversation",
|
|
266
|
+
text: "test",
|
|
267
|
+
});
|
|
268
|
+
const headers = makeHeaders({
|
|
269
|
+
subject: "Test Subject",
|
|
270
|
+
cc: ["charlie@test.interchange"],
|
|
271
|
+
inReplyTo: "<prev@test.interchange>",
|
|
272
|
+
references: ["<first@test.interchange>", "<prev@test.interchange>"],
|
|
273
|
+
interchangeType: "conversation.message",
|
|
274
|
+
interchangeSessionId: "sess-123",
|
|
275
|
+
});
|
|
276
|
+
const msg = assembleMessage(headers, content, enc.encode("SIG"));
|
|
277
|
+
const text = dec.decode(msg);
|
|
278
|
+
|
|
279
|
+
expect(text).toContain("Subject: Test Subject\r\n");
|
|
280
|
+
expect(text).toContain("Cc: charlie@test.interchange\r\n");
|
|
281
|
+
expect(text).toContain("In-Reply-To: <prev@test.interchange>\r\n");
|
|
282
|
+
expect(text).toContain(
|
|
283
|
+
"References: <first@test.interchange> <prev@test.interchange>\r\n",
|
|
284
|
+
);
|
|
285
|
+
expect(text).toContain("Interchange-Type: conversation.message\r\n");
|
|
286
|
+
expect(text).toContain("Interchange-Session-ID: sess-123\r\n");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("body has exactly two multipart/signed parts", () => {
|
|
290
|
+
const content = assembleSignedContent({
|
|
291
|
+
kind: "conversation",
|
|
292
|
+
text: "test",
|
|
293
|
+
});
|
|
294
|
+
const msg = assembleMessage(makeHeaders(), content, enc.encode("FAKE-SIG"));
|
|
295
|
+
const { headers, bodyOffset } = parseHeaderSection(msg);
|
|
296
|
+
const ct = defined(headers.get("content-type"));
|
|
297
|
+
const boundary = defined(extractBoundary(ct));
|
|
298
|
+
const body = msg.slice(bodyOffset);
|
|
299
|
+
const parts = parseMultipart(body, boundary);
|
|
300
|
+
expect(parts).toHaveLength(2);
|
|
301
|
+
|
|
302
|
+
const sigPart = parseMimePart(defined(parts[1]));
|
|
303
|
+
expect(sigPart.contentType).toBe("application/pgp-signature");
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
// parseHeaderSection
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
describe("parseHeaderSection", () => {
|
|
312
|
+
test("parses CRLF-terminated headers", () => {
|
|
313
|
+
const raw = enc.encode("From: alice@test\r\nTo: bob@test\r\n\r\nBody here");
|
|
314
|
+
const { headers, bodyOffset } = parseHeaderSection(raw);
|
|
315
|
+
expect(headers.get("from")).toBe("alice@test");
|
|
316
|
+
expect(headers.get("to")).toBe("bob@test");
|
|
317
|
+
expect(dec.decode(raw.slice(bodyOffset))).toBe("Body here");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("parses LF-terminated headers", () => {
|
|
321
|
+
const raw = enc.encode("From: alice@test\nTo: bob@test\n\nBody");
|
|
322
|
+
const { headers, bodyOffset } = parseHeaderSection(raw);
|
|
323
|
+
expect(headers.get("from")).toBe("alice@test");
|
|
324
|
+
expect(dec.decode(raw.slice(bodyOffset))).toBe("Body");
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("unfolds continuation lines", () => {
|
|
328
|
+
const raw = enc.encode("References: <a@test>\r\n <b@test>\r\n\r\nBody");
|
|
329
|
+
const { headers } = parseHeaderSection(raw);
|
|
330
|
+
expect(headers.get("references")).toBe("<a@test> <b@test>");
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("keeps first value for repeated headers", () => {
|
|
334
|
+
const raw = enc.encode("Received: first\r\nReceived: second\r\n\r\nBody");
|
|
335
|
+
const { headers } = parseHeaderSection(raw);
|
|
336
|
+
expect(headers.get("received")).toBe("first");
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("lowercases header names", () => {
|
|
340
|
+
const raw = enc.encode("Content-Type: text/plain\r\n\r\n");
|
|
341
|
+
const { headers } = parseHeaderSection(raw);
|
|
342
|
+
expect(headers.has("content-type")).toBe(true);
|
|
343
|
+
expect(headers.has("Content-Type")).toBe(false);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("bodyOffset is byte-accurate with 2-byte UTF-8 characters", () => {
|
|
347
|
+
const raw = enc.encode("Subject: héllo\r\n\r\nBody here");
|
|
348
|
+
const { bodyOffset } = parseHeaderSection(raw);
|
|
349
|
+
expect(dec.decode(raw.slice(bodyOffset))).toBe("Body here");
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("bodyOffset is byte-accurate with 3-byte UTF-8 characters", () => {
|
|
353
|
+
const raw = enc.encode("Subject: \u20ACuro\r\n\r\nBody here");
|
|
354
|
+
const { bodyOffset } = parseHeaderSection(raw);
|
|
355
|
+
expect(dec.decode(raw.slice(bodyOffset))).toBe("Body here");
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("bodyOffset is byte-accurate with 4-byte UTF-8 characters", () => {
|
|
359
|
+
const raw = enc.encode("Subject: \u{1F600}face\r\n\r\nBody here");
|
|
360
|
+
const { bodyOffset } = parseHeaderSection(raw);
|
|
361
|
+
expect(dec.decode(raw.slice(bodyOffset))).toBe("Body here");
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("no separator treats entire input as headers", () => {
|
|
365
|
+
const raw = enc.encode("From: alice\r\nTo: bob");
|
|
366
|
+
const { headers, bodyOffset } = parseHeaderSection(raw);
|
|
367
|
+
expect(bodyOffset).toBe(raw.length);
|
|
368
|
+
expect(dec.decode(raw.slice(bodyOffset))).toBe("");
|
|
369
|
+
expect(headers.get("from")).toBe("alice");
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("empty input returns empty headers and zero offset", () => {
|
|
373
|
+
const raw = enc.encode("");
|
|
374
|
+
const { headers, bodyOffset } = parseHeaderSection(raw);
|
|
375
|
+
expect(bodyOffset).toBe(0);
|
|
376
|
+
expect(headers.size).toBe(0);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("separator-only input returns empty headers", () => {
|
|
380
|
+
const raw = enc.encode("\r\n\r\n");
|
|
381
|
+
const { headers, bodyOffset } = parseHeaderSection(raw);
|
|
382
|
+
expect(bodyOffset).toBe(4);
|
|
383
|
+
expect(headers.size).toBe(0);
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
// extractBoundary
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
describe("extractBoundary", () => {
|
|
392
|
+
test("extracts quoted boundary", () => {
|
|
393
|
+
const ct = 'multipart/signed; boundary="----=_Part_abc123"';
|
|
394
|
+
expect(extractBoundary(ct)).toBe("----=_Part_abc123");
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("extracts unquoted boundary", () => {
|
|
398
|
+
const ct = "multipart/mixed; boundary=simple_boundary";
|
|
399
|
+
expect(extractBoundary(ct)).toBe("simple_boundary");
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("returns undefined when no boundary", () => {
|
|
403
|
+
expect(extractBoundary("text/plain")).toBeUndefined();
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
// parseMultipart
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
describe("parseMultipart", () => {
|
|
412
|
+
test("splits two parts correctly", () => {
|
|
413
|
+
const body = enc.encode(
|
|
414
|
+
[
|
|
415
|
+
"--boundary",
|
|
416
|
+
"Content-Type: text/plain",
|
|
417
|
+
"",
|
|
418
|
+
"Part one",
|
|
419
|
+
"--boundary",
|
|
420
|
+
"Content-Type: text/html",
|
|
421
|
+
"",
|
|
422
|
+
"<p>Part two</p>",
|
|
423
|
+
"--boundary--",
|
|
424
|
+
].join("\r\n"),
|
|
425
|
+
);
|
|
426
|
+
const parts = parseMultipart(body, "boundary");
|
|
427
|
+
expect(parts).toHaveLength(2);
|
|
428
|
+
expect(dec.decode(defined(parts[0]))).toContain("Part one");
|
|
429
|
+
expect(dec.decode(defined(parts[1]))).toContain("<p>Part two</p>");
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("handles LF-only line endings", () => {
|
|
433
|
+
const body = enc.encode(
|
|
434
|
+
"--boundary\nContent-Type: text/plain\n\nPart one\n--boundary\nContent-Type: text/html\n\n<p>Part two</p>\n--boundary--\n",
|
|
435
|
+
);
|
|
436
|
+
const parts = parseMultipart(body, "boundary");
|
|
437
|
+
expect(parts).toHaveLength(2);
|
|
438
|
+
const p1 = parseMimePart(defined(parts[0]));
|
|
439
|
+
const p2 = parseMimePart(defined(parts[1]));
|
|
440
|
+
expect(dec.decode(p1.body)).toContain("Part one");
|
|
441
|
+
expect(dec.decode(p2.body)).toContain("<p>Part two</p>");
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
// parseMimePart
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
describe("parseMimePart", () => {
|
|
450
|
+
test("separates headers from body", () => {
|
|
451
|
+
const raw = enc.encode("Content-Type: text/plain\r\n\r\nThe body text");
|
|
452
|
+
const part = parseMimePart(raw);
|
|
453
|
+
expect(part.contentType).toBe("text/plain");
|
|
454
|
+
expect(dec.decode(part.body)).toBe("The body text");
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test("defaults to application/octet-stream", () => {
|
|
458
|
+
const raw = enc.encode("X-Custom: value\r\n\r\ndata");
|
|
459
|
+
const part = parseMimePart(raw);
|
|
460
|
+
expect(part.contentType).toBe("application/octet-stream");
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
// ---------------------------------------------------------------------------
|
|
465
|
+
// extractPartByPath
|
|
466
|
+
// ---------------------------------------------------------------------------
|
|
467
|
+
|
|
468
|
+
describe("extractPartByPath", () => {
|
|
469
|
+
test("extracts parts from an assembled message", () => {
|
|
470
|
+
const content = assembleSignedContent({
|
|
471
|
+
kind: "conversation",
|
|
472
|
+
text: "Hello world",
|
|
473
|
+
});
|
|
474
|
+
const msg = assembleMessage(makeHeaders(), content, enc.encode("SIG"));
|
|
475
|
+
|
|
476
|
+
const part1 = extractPartByPath(msg, "1");
|
|
477
|
+
expect(dec.decode(part1)).toContain("Hello world");
|
|
478
|
+
|
|
479
|
+
const part2 = extractPartByPath(msg, "2");
|
|
480
|
+
const sigPart = parseMimePart(part2);
|
|
481
|
+
expect(sigPart.contentType).toBe("application/pgp-signature");
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test("throws on invalid path segment", () => {
|
|
485
|
+
const msg = assembleMessage(
|
|
486
|
+
makeHeaders(),
|
|
487
|
+
assembleSignedContent({ kind: "conversation", text: "x" }),
|
|
488
|
+
enc.encode("SIG"),
|
|
489
|
+
);
|
|
490
|
+
expect(() => extractPartByPath(msg, "0")).toThrow(/Invalid part path/);
|
|
491
|
+
expect(() => extractPartByPath(msg, "abc")).toThrow(/Invalid part path/);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test("throws when part index exceeds part count", () => {
|
|
495
|
+
const msg = assembleMessage(
|
|
496
|
+
makeHeaders(),
|
|
497
|
+
assembleSignedContent({ kind: "conversation", text: "x" }),
|
|
498
|
+
enc.encode("SIG"),
|
|
499
|
+
);
|
|
500
|
+
expect(() => extractPartByPath(msg, "5")).toThrow(/does not exist/);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test("throws when indexing into a non-multipart message", () => {
|
|
504
|
+
const raw = enc.encode("Content-Type: text/plain\r\n\r\nJust a body");
|
|
505
|
+
expect(() => extractPartByPath(raw, "1")).toThrow(/non-multipart/);
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// ---------------------------------------------------------------------------
|
|
510
|
+
// createDetachedSignatureFromProvider — round-trip with verify
|
|
511
|
+
// ---------------------------------------------------------------------------
|
|
512
|
+
|
|
513
|
+
describe("createDetachedSignatureFromProvider", () => {
|
|
514
|
+
test("signature verifies against the signed content", async () => {
|
|
515
|
+
const kp = await generateKeyPair();
|
|
516
|
+
const provider = createNodeCrypto(kp);
|
|
517
|
+
const content = assembleSignedContent({
|
|
518
|
+
kind: "conversation",
|
|
519
|
+
text: "Round-trip test",
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
const sig = await createDetachedSignatureFromProvider(content, provider);
|
|
523
|
+
const valid = await verifyDetachedSignature(
|
|
524
|
+
content,
|
|
525
|
+
sig,
|
|
526
|
+
provider.getPublicKey(),
|
|
527
|
+
);
|
|
528
|
+
expect(valid).toBe(true);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test("signature is ASCII-armored", async () => {
|
|
532
|
+
const kp = await generateKeyPair();
|
|
533
|
+
const provider = createNodeCrypto(kp);
|
|
534
|
+
const content = assembleSignedContent({
|
|
535
|
+
kind: "conversation",
|
|
536
|
+
text: "test",
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
const sig = await createDetachedSignatureFromProvider(content, provider);
|
|
540
|
+
const text = dec.decode(sig);
|
|
541
|
+
expect(text).toContain("-----BEGIN PGP SIGNATURE-----");
|
|
542
|
+
expect(text).toContain("-----END PGP SIGNATURE-----");
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
test("verification fails with wrong public key", async () => {
|
|
546
|
+
const kp1 = await generateKeyPair();
|
|
547
|
+
const kp2 = await generateKeyPair();
|
|
548
|
+
const provider = createNodeCrypto(kp1);
|
|
549
|
+
const wrongKey = createNodeCrypto(kp2);
|
|
550
|
+
const content = assembleSignedContent({
|
|
551
|
+
kind: "conversation",
|
|
552
|
+
text: "test",
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
const sig = await createDetachedSignatureFromProvider(content, provider);
|
|
556
|
+
const valid = await verifyDetachedSignature(
|
|
557
|
+
content,
|
|
558
|
+
sig,
|
|
559
|
+
wrongKey.getPublicKey(),
|
|
560
|
+
);
|
|
561
|
+
expect(valid).toBe(false);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test("verification fails with tampered content", async () => {
|
|
565
|
+
const kp = await generateKeyPair();
|
|
566
|
+
const provider = createNodeCrypto(kp);
|
|
567
|
+
const content = assembleSignedContent({
|
|
568
|
+
kind: "conversation",
|
|
569
|
+
text: "original",
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
const sig = await createDetachedSignatureFromProvider(content, provider);
|
|
573
|
+
const tampered = assembleSignedContent({
|
|
574
|
+
kind: "conversation",
|
|
575
|
+
text: "modified",
|
|
576
|
+
});
|
|
577
|
+
const valid = await verifyDetachedSignature(
|
|
578
|
+
tampered,
|
|
579
|
+
sig,
|
|
580
|
+
provider.getPublicKey(),
|
|
581
|
+
);
|
|
582
|
+
expect(valid).toBe(false);
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
// ---------------------------------------------------------------------------
|
|
587
|
+
// parseMailToEmail
|
|
588
|
+
// ---------------------------------------------------------------------------
|
|
589
|
+
|
|
590
|
+
describe("parseMailToEmail", () => {
|
|
591
|
+
test("parses a simple text/plain message", () => {
|
|
592
|
+
const raw = enc.encode(
|
|
593
|
+
[
|
|
594
|
+
"From: Alice <alice@example.com>",
|
|
595
|
+
"To: Bob <bob@example.com>",
|
|
596
|
+
"Subject: Hello",
|
|
597
|
+
"Date: Tue, 21 Apr 2026 12:00:00 +0000",
|
|
598
|
+
"MIME-Version: 1.0",
|
|
599
|
+
"Content-Type: text/plain; charset=utf-8",
|
|
600
|
+
"",
|
|
601
|
+
"Hello from Alice",
|
|
602
|
+
].join("\r\n"),
|
|
603
|
+
);
|
|
604
|
+
|
|
605
|
+
const email = parseMailToEmail(raw, "sml_abc123");
|
|
606
|
+
|
|
607
|
+
expect(email.from).toEqual([{ name: "Alice", email: "alice@example.com" }]);
|
|
608
|
+
expect(email.to).toEqual([{ name: "Bob", email: "bob@example.com" }]);
|
|
609
|
+
expect(email.subject).toBe("Hello");
|
|
610
|
+
expect(email.sentAt).toBe("2026-04-21T12:00:00.000Z");
|
|
611
|
+
expect(Object.keys(email.bodyValues)).toHaveLength(1);
|
|
612
|
+
expect(email.bodyValues["1"]?.value).toContain("Hello from Alice");
|
|
613
|
+
expect(email.textBody).toEqual([{ partId: "1", type: "text/plain" }]);
|
|
614
|
+
expect(email.htmlBody).toHaveLength(0);
|
|
615
|
+
expect(email.attachments).toHaveLength(0);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
test("parses from/to with bare email addresses", () => {
|
|
619
|
+
const raw = enc.encode(
|
|
620
|
+
[
|
|
621
|
+
"From: alice@example.com",
|
|
622
|
+
"To: bob@example.com, charlie@example.com",
|
|
623
|
+
"Date: Tue, 21 Apr 2026 12:00:00 +0000",
|
|
624
|
+
"Content-Type: text/plain",
|
|
625
|
+
"",
|
|
626
|
+
"body",
|
|
627
|
+
].join("\r\n"),
|
|
628
|
+
);
|
|
629
|
+
|
|
630
|
+
const email = parseMailToEmail(raw, "sml_1");
|
|
631
|
+
|
|
632
|
+
expect(email.from).toEqual([{ name: null, email: "alice@example.com" }]);
|
|
633
|
+
expect(email.to).toEqual([
|
|
634
|
+
{ name: null, email: "bob@example.com" },
|
|
635
|
+
{ name: null, email: "charlie@example.com" },
|
|
636
|
+
]);
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
test("returns null subject when header is absent", () => {
|
|
640
|
+
const raw = enc.encode(
|
|
641
|
+
[
|
|
642
|
+
"From: alice@example.com",
|
|
643
|
+
"To: bob@example.com",
|
|
644
|
+
"Date: Tue, 21 Apr 2026 12:00:00 +0000",
|
|
645
|
+
"Content-Type: text/plain",
|
|
646
|
+
"",
|
|
647
|
+
"body",
|
|
648
|
+
].join("\r\n"),
|
|
649
|
+
);
|
|
650
|
+
|
|
651
|
+
const email = parseMailToEmail(raw, "sml_1");
|
|
652
|
+
expect(email.subject).toBeNull();
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
test("returns null sentAt when Date header is absent", () => {
|
|
656
|
+
const raw = enc.encode(
|
|
657
|
+
[
|
|
658
|
+
"From: alice@example.com",
|
|
659
|
+
"To: bob@example.com",
|
|
660
|
+
"Content-Type: text/plain",
|
|
661
|
+
"",
|
|
662
|
+
"body",
|
|
663
|
+
].join("\r\n"),
|
|
664
|
+
);
|
|
665
|
+
|
|
666
|
+
const email = parseMailToEmail(raw, "sml_1");
|
|
667
|
+
expect(email.sentAt).toBeNull();
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
test("returns null sentAt when Date header is unparseable", () => {
|
|
671
|
+
const raw = enc.encode(
|
|
672
|
+
[
|
|
673
|
+
"From: alice@example.com",
|
|
674
|
+
"To: bob@example.com",
|
|
675
|
+
"Date: not-a-date",
|
|
676
|
+
"Content-Type: text/plain",
|
|
677
|
+
"",
|
|
678
|
+
"body",
|
|
679
|
+
].join("\r\n"),
|
|
680
|
+
);
|
|
681
|
+
|
|
682
|
+
const email = parseMailToEmail(raw, "sml_1");
|
|
683
|
+
expect(email.sentAt).toBeNull();
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
test("parses multipart/mixed with text and attachment", () => {
|
|
687
|
+
const boundary = "test_boundary_xyz";
|
|
688
|
+
const raw = enc.encode(
|
|
689
|
+
[
|
|
690
|
+
"From: alice@example.com",
|
|
691
|
+
"To: bob@example.com",
|
|
692
|
+
"Date: Tue, 21 Apr 2026 12:00:00 +0000",
|
|
693
|
+
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
|
694
|
+
"",
|
|
695
|
+
`--${boundary}`,
|
|
696
|
+
"Content-Type: text/plain; charset=utf-8",
|
|
697
|
+
"",
|
|
698
|
+
"The message body",
|
|
699
|
+
`--${boundary}`,
|
|
700
|
+
"Content-Type: application/pdf",
|
|
701
|
+
'Content-Disposition: attachment; filename="report.pdf"',
|
|
702
|
+
"",
|
|
703
|
+
"PDF-BYTES-HERE",
|
|
704
|
+
`--${boundary}--`,
|
|
705
|
+
].join("\r\n"),
|
|
706
|
+
);
|
|
707
|
+
|
|
708
|
+
const email = parseMailToEmail(raw, "sml_multi");
|
|
709
|
+
|
|
710
|
+
expect(email.textBody).toEqual([{ partId: "1", type: "text/plain" }]);
|
|
711
|
+
expect(email.bodyValues["1"]?.value).toContain("The message body");
|
|
712
|
+
expect(email.attachments).toHaveLength(1);
|
|
713
|
+
expect(email.attachments[0]).toEqual({
|
|
714
|
+
blobId: "blob_sml_multi_2",
|
|
715
|
+
name: "report.pdf",
|
|
716
|
+
type: "application/pdf",
|
|
717
|
+
size: "PDF-BYTES-HERE".length,
|
|
718
|
+
});
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
test("parses multipart/mixed with html part", () => {
|
|
722
|
+
const boundary = "mixed_html_boundary";
|
|
723
|
+
const raw = enc.encode(
|
|
724
|
+
[
|
|
725
|
+
"From: alice@example.com",
|
|
726
|
+
"To: bob@example.com",
|
|
727
|
+
"Date: Tue, 21 Apr 2026 12:00:00 +0000",
|
|
728
|
+
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
|
729
|
+
"",
|
|
730
|
+
`--${boundary}`,
|
|
731
|
+
"Content-Type: text/html; charset=utf-8",
|
|
732
|
+
"",
|
|
733
|
+
"<p>Hello</p>",
|
|
734
|
+
`--${boundary}--`,
|
|
735
|
+
].join("\r\n"),
|
|
736
|
+
);
|
|
737
|
+
|
|
738
|
+
const email = parseMailToEmail(raw, "sml_html");
|
|
739
|
+
|
|
740
|
+
expect(email.htmlBody).toEqual([{ partId: "1", type: "text/html" }]);
|
|
741
|
+
expect(email.bodyValues["1"]?.value).toContain("<p>Hello</p>");
|
|
742
|
+
expect(email.textBody).toHaveLength(0);
|
|
743
|
+
expect(email.attachments).toHaveLength(0);
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
test("parses a multipart/signed conversation message assembled by this library", () => {
|
|
747
|
+
const content = assembleSignedContent({
|
|
748
|
+
kind: "conversation",
|
|
749
|
+
text: "Hello from a signed message",
|
|
750
|
+
});
|
|
751
|
+
const fakeSig = enc.encode("FAKE-SIGNATURE");
|
|
752
|
+
const msg = assembleMessage(
|
|
753
|
+
makeHeaders({
|
|
754
|
+
subject: "Signed Convo",
|
|
755
|
+
interchangeType: "conversation.message",
|
|
756
|
+
interchangeSessionId: "sess-42",
|
|
757
|
+
}),
|
|
758
|
+
content,
|
|
759
|
+
fakeSig,
|
|
760
|
+
);
|
|
761
|
+
|
|
762
|
+
const email = parseMailToEmail(msg, "sml_signed_plain");
|
|
763
|
+
|
|
764
|
+
expect(email.from).toEqual([
|
|
765
|
+
{ name: null, email: "alice@test.interchange" },
|
|
766
|
+
]);
|
|
767
|
+
expect(email.to).toEqual([{ name: null, email: "bob@test.interchange" }]);
|
|
768
|
+
expect(email.subject).toBe("Signed Convo");
|
|
769
|
+
expect(email.sentAt).toBe("2026-04-21T12:00:00.000Z");
|
|
770
|
+
expect(email.textBody).toHaveLength(1);
|
|
771
|
+
expect(
|
|
772
|
+
email.bodyValues[defined(email.textBody[0]).partId]?.value,
|
|
773
|
+
).toContain("Hello from a signed message");
|
|
774
|
+
expect(email.attachments).toHaveLength(0);
|
|
775
|
+
expect(email.headers["interchange-type"]).toBe("conversation.message");
|
|
776
|
+
expect(email.headers["interchange-session-id"]).toBe("sess-42");
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
test("parses a multipart/signed structured message assembled by this library", () => {
|
|
780
|
+
const payload = { action: "deploy", env: "staging" };
|
|
781
|
+
const content = assembleSignedContent({
|
|
782
|
+
kind: "structured",
|
|
783
|
+
json: payload,
|
|
784
|
+
summary: "Deploying to staging",
|
|
785
|
+
});
|
|
786
|
+
const fakeSig = enc.encode("FAKE-SIG");
|
|
787
|
+
const msg = assembleMessage(
|
|
788
|
+
makeHeaders({ interchangeType: "structured.message" }),
|
|
789
|
+
content,
|
|
790
|
+
fakeSig,
|
|
791
|
+
);
|
|
792
|
+
|
|
793
|
+
const email = parseMailToEmail(msg, "sml_signed_structured");
|
|
794
|
+
|
|
795
|
+
// The structured message is multipart/mixed inside multipart/signed.
|
|
796
|
+
// textBody should include the summary text/plain part.
|
|
797
|
+
expect(email.textBody).toHaveLength(1);
|
|
798
|
+
const textPartId = defined(email.textBody[0]).partId;
|
|
799
|
+
expect(email.bodyValues[textPartId]?.value).toContain(
|
|
800
|
+
"Deploying to staging",
|
|
801
|
+
);
|
|
802
|
+
// The application/vnd.interchange+json part is a non-text blob attachment.
|
|
803
|
+
expect(email.attachments).toHaveLength(1);
|
|
804
|
+
expect(email.attachments[0]?.type).toBe("application/vnd.interchange+json");
|
|
805
|
+
expect(email.headers["interchange-type"]).toBe("structured.message");
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
test("blob IDs use the correct scheme", () => {
|
|
809
|
+
const boundary = "blob_id_boundary";
|
|
810
|
+
const raw = enc.encode(
|
|
811
|
+
[
|
|
812
|
+
"From: alice@example.com",
|
|
813
|
+
"To: bob@example.com",
|
|
814
|
+
"Date: Tue, 21 Apr 2026 12:00:00 +0000",
|
|
815
|
+
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
|
816
|
+
"",
|
|
817
|
+
`--${boundary}`,
|
|
818
|
+
"Content-Type: text/plain",
|
|
819
|
+
"",
|
|
820
|
+
"Text body",
|
|
821
|
+
`--${boundary}`,
|
|
822
|
+
"Content-Type: image/png",
|
|
823
|
+
'Content-Disposition: attachment; filename="photo.png"',
|
|
824
|
+
"",
|
|
825
|
+
"PNG-DATA",
|
|
826
|
+
`--${boundary}`,
|
|
827
|
+
"Content-Type: application/zip",
|
|
828
|
+
'Content-Disposition: attachment; filename="archive.zip"',
|
|
829
|
+
"",
|
|
830
|
+
"ZIP-DATA",
|
|
831
|
+
`--${boundary}--`,
|
|
832
|
+
].join("\r\n"),
|
|
833
|
+
);
|
|
834
|
+
|
|
835
|
+
const email = parseMailToEmail(raw, "sml_xyz");
|
|
836
|
+
|
|
837
|
+
expect(email.attachments[0]?.blobId).toBe("blob_sml_xyz_2");
|
|
838
|
+
expect(email.attachments[1]?.blobId).toBe("blob_sml_xyz_3");
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
test("extracts Interchange-specific headers into headers field", () => {
|
|
842
|
+
const raw = enc.encode(
|
|
843
|
+
[
|
|
844
|
+
"From: alice@example.com",
|
|
845
|
+
"To: bob@example.com",
|
|
846
|
+
"Date: Tue, 21 Apr 2026 12:00:00 +0000",
|
|
847
|
+
"Content-Type: text/plain",
|
|
848
|
+
"Interchange-Type: conversation.message",
|
|
849
|
+
"Interchange-Tenant-ID: tenant-99",
|
|
850
|
+
"Interchange-Agent-ID: agent-42",
|
|
851
|
+
"X-Custom: should-not-appear",
|
|
852
|
+
"",
|
|
853
|
+
"body",
|
|
854
|
+
].join("\r\n"),
|
|
855
|
+
);
|
|
856
|
+
|
|
857
|
+
const email = parseMailToEmail(raw, "sml_hdrs");
|
|
858
|
+
|
|
859
|
+
expect(email.headers["interchange-type"]).toBe("conversation.message");
|
|
860
|
+
expect(email.headers["interchange-tenant-id"]).toBe("tenant-99");
|
|
861
|
+
expect(email.headers["interchange-agent-id"]).toBe("agent-42");
|
|
862
|
+
expect(email.headers["x-custom"]).toBeUndefined();
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
test("non-text non-attachment parts are treated as attachments", () => {
|
|
866
|
+
const boundary = "mixed_types";
|
|
867
|
+
const raw = enc.encode(
|
|
868
|
+
[
|
|
869
|
+
"From: alice@example.com",
|
|
870
|
+
"To: bob@example.com",
|
|
871
|
+
"Date: Tue, 21 Apr 2026 12:00:00 +0000",
|
|
872
|
+
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
|
873
|
+
"",
|
|
874
|
+
`--${boundary}`,
|
|
875
|
+
"Content-Type: text/plain",
|
|
876
|
+
"",
|
|
877
|
+
"Text here",
|
|
878
|
+
`--${boundary}`,
|
|
879
|
+
"Content-Type: application/octet-stream",
|
|
880
|
+
'Content-Disposition: attachment; filename="data.bin"',
|
|
881
|
+
"",
|
|
882
|
+
"BINARY",
|
|
883
|
+
`--${boundary}--`,
|
|
884
|
+
].join("\r\n"),
|
|
885
|
+
);
|
|
886
|
+
|
|
887
|
+
const email = parseMailToEmail(raw, "sml_bin");
|
|
888
|
+
|
|
889
|
+
expect(email.textBody).toHaveLength(1);
|
|
890
|
+
expect(email.attachments).toHaveLength(1);
|
|
891
|
+
expect(email.attachments[0]?.type).toBe("application/octet-stream");
|
|
892
|
+
expect(email.attachments[0]?.name).toBe("data.bin");
|
|
893
|
+
});
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
// ---------------------------------------------------------------------------
|
|
897
|
+
// Full round-trip: assemble → parse → verify
|
|
898
|
+
// ---------------------------------------------------------------------------
|
|
899
|
+
|
|
900
|
+
describe("assemble then parse round-trip", () => {
|
|
901
|
+
test("conversation message survives assemble/parse cycle", async () => {
|
|
902
|
+
const kp = await generateKeyPair();
|
|
903
|
+
const provider = createNodeCrypto(kp);
|
|
904
|
+
const content = assembleSignedContent({
|
|
905
|
+
kind: "conversation",
|
|
906
|
+
text: "Hello from the round-trip test",
|
|
907
|
+
});
|
|
908
|
+
const sig = await createDetachedSignatureFromProvider(content, provider);
|
|
909
|
+
const msg = assembleMessage(
|
|
910
|
+
makeHeaders({ interchangeType: "conversation.message" }),
|
|
911
|
+
content,
|
|
912
|
+
sig,
|
|
913
|
+
);
|
|
914
|
+
|
|
915
|
+
const { headers, bodyOffset } = parseHeaderSection(msg);
|
|
916
|
+
expect(headers.get("from")).toBe("alice@test.interchange");
|
|
917
|
+
expect(headers.get("interchange-type")).toBe("conversation.message");
|
|
918
|
+
|
|
919
|
+
const ct = defined(headers.get("content-type"));
|
|
920
|
+
expect(ct).toContain("multipart/signed");
|
|
921
|
+
const boundary = defined(extractBoundary(ct));
|
|
922
|
+
|
|
923
|
+
const body = msg.slice(bodyOffset);
|
|
924
|
+
const parts = parseMultipart(body, boundary);
|
|
925
|
+
expect(parts).toHaveLength(2);
|
|
926
|
+
|
|
927
|
+
const signedPart = defined(parts[0]);
|
|
928
|
+
const sigPart = parseMimePart(defined(parts[1]));
|
|
929
|
+
expect(sigPart.contentType).toBe("application/pgp-signature");
|
|
930
|
+
|
|
931
|
+
const valid = await verifyDetachedSignature(
|
|
932
|
+
signedPart,
|
|
933
|
+
sigPart.body,
|
|
934
|
+
provider.getPublicKey(),
|
|
935
|
+
);
|
|
936
|
+
expect(valid).toBe(true);
|
|
937
|
+
|
|
938
|
+
const parsed = parseMimePart(signedPart);
|
|
939
|
+
expect(parsed.contentType).toContain("text/plain");
|
|
940
|
+
expect(dec.decode(parsed.body)).toContain("Hello from the round-trip test");
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
test("structured message survives assemble/parse cycle", async () => {
|
|
944
|
+
const kp = await generateKeyPair();
|
|
945
|
+
const provider = createNodeCrypto(kp);
|
|
946
|
+
const payload = { action: "deploy", target: "prod" };
|
|
947
|
+
const content = assembleSignedContent({
|
|
948
|
+
kind: "structured",
|
|
949
|
+
json: payload,
|
|
950
|
+
summary: "Deploying to prod",
|
|
951
|
+
});
|
|
952
|
+
const sig = await createDetachedSignatureFromProvider(content, provider);
|
|
953
|
+
const msg = assembleMessage(makeHeaders(), content, sig);
|
|
954
|
+
|
|
955
|
+
const { headers, bodyOffset } = parseHeaderSection(msg);
|
|
956
|
+
const outerBoundary = defined(
|
|
957
|
+
extractBoundary(defined(headers.get("content-type"))),
|
|
958
|
+
);
|
|
959
|
+
const outerParts = parseMultipart(msg.slice(bodyOffset), outerBoundary);
|
|
960
|
+
expect(outerParts).toHaveLength(2);
|
|
961
|
+
|
|
962
|
+
const signedPart = defined(outerParts[0]);
|
|
963
|
+
const innerParsed = parseMimePart(signedPart);
|
|
964
|
+
expect(innerParsed.contentType).toContain("multipart/mixed");
|
|
965
|
+
|
|
966
|
+
const innerBoundary = defined(extractBoundary(innerParsed.contentType));
|
|
967
|
+
const innerParts = parseMultipart(innerParsed.body, innerBoundary);
|
|
968
|
+
expect(innerParts).toHaveLength(2);
|
|
969
|
+
|
|
970
|
+
const jsonPart = parseMimePart(defined(innerParts[0]));
|
|
971
|
+
expect(jsonPart.contentType).toContain("application/vnd.interchange+json");
|
|
972
|
+
const parsed = JSON.parse(dec.decode(jsonPart.body));
|
|
973
|
+
expect(parsed).toEqual(payload);
|
|
974
|
+
|
|
975
|
+
const summaryPart = parseMimePart(defined(innerParts[1]));
|
|
976
|
+
expect(dec.decode(summaryPart.body)).toContain("Deploying to prod");
|
|
977
|
+
});
|
|
978
|
+
});
|