@intx/mail-memory 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 ADDED
@@ -0,0 +1,37 @@
1
+ # @intx/mail-memory
2
+
3
+ In-memory `MessageTransport` for single-process and test
4
+ environments. A single transport instance hosts every address in
5
+ the process; each registered address gets its own per-recipient
6
+ transport handle that signs outbound mail with the provided
7
+ `CryptoProvider` and delivers inbound mail straight to the
8
+ recipient's INBOX.
9
+
10
+ Consumed by examples, tests, and the in-process `@intx/agent`
11
+ runtime. The on-the-wire format matches `@intx/mime` so a fixture
12
+ captured here can be replayed against a real transport without
13
+ modification.
14
+
15
+ ```ts
16
+ import { createInMemoryTransport } from "@intx/mail-memory";
17
+ import { createNodeCrypto, generateKeyPair } from "@intx/crypto-node";
18
+
19
+ const transport = createInMemoryTransport();
20
+ const alpha = createNodeCrypto(await generateKeyPair());
21
+ const beta = createNodeCrypto(await generateKeyPair());
22
+
23
+ transport.register("alpha@local.interchange", alpha);
24
+ transport.register("beta@local.interchange", beta);
25
+
26
+ const alphaMail = transport.getTransportFor("alpha@local.interchange");
27
+ await alphaMail.send({
28
+ to: "beta@local.interchange",
29
+ type: "conversation.message",
30
+ content: "hello",
31
+ });
32
+ ```
33
+
34
+ Install a `RemoteSendHandler` via `transport.setRemoteSendHandler`
35
+ to forward outbound mail for addresses the transport does not host
36
+ locally, and add one or more `MessageSentHandler`s via
37
+ `transport.addMessageSentHandler` for post-send observability.
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@intx/mail-memory",
3
+ "version": "0.1.2",
4
+ "license": "LGPL-2.1-only",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./src/index.ts",
9
+ "default": "./src/index.ts"
10
+ }
11
+ },
12
+ "dependencies": {
13
+ "@intx/crypto-node": "0.0.0",
14
+ "@intx/log": "0.0.0",
15
+ "@intx/mime": "0.0.0",
16
+ "@intx/types": "0.0.0"
17
+ }
18
+ }
package/src/fetch.ts ADDED
@@ -0,0 +1,215 @@
1
+ /* eslint-disable @typescript-eslint/no-non-null-assertion -- MIME multipart parsing with bounds checks */
2
+ import { type } from "arktype";
3
+ import type {
4
+ MessageHeaders,
5
+ BodyStructure,
6
+ MessagePart,
7
+ InboundMessage,
8
+ SignatureStatus,
9
+ CryptoProvider,
10
+ MessageRef,
11
+ } from "@intx/types/runtime";
12
+ import { InterchangeType } from "@intx/types/runtime";
13
+ import type { MailboxStore } from "./mailbox";
14
+ import { requireMessage } from "./mailbox";
15
+ import {
16
+ parseHeaderSection,
17
+ parseMimePart,
18
+ extractBoundary,
19
+ parseMultipart,
20
+ extractPartByPath,
21
+ } from "@intx/mime";
22
+ import { buildMessageHeaders } from "./headers";
23
+ import { verifyDetachedSignature } from "@intx/crypto-node";
24
+
25
+ const MessagePayload = type({
26
+ type: InterchangeType,
27
+ version: "string",
28
+ body: "Record<string, unknown>",
29
+ });
30
+
31
+ /**
32
+ * Parse raw RFC 2822 headers from a stored message.
33
+ */
34
+ export function fetchHeaders(
35
+ ref: MessageRef,
36
+ store: MailboxStore,
37
+ ): MessageHeaders {
38
+ const msg = requireMessage(store, ref.uid, ref.mailbox);
39
+ const { headers } = parseHeaderSection(msg.raw);
40
+ return buildMessageHeaders(headers);
41
+ }
42
+
43
+ /**
44
+ * Compute the MIME tree structure (BODYSTRUCTURE) without transferring content.
45
+ */
46
+ export function fetchStructure(
47
+ ref: MessageRef,
48
+ store: MailboxStore,
49
+ ): BodyStructure {
50
+ const msg = requireMessage(store, ref.uid, ref.mailbox);
51
+ const { headers, bodyOffset } = parseHeaderSection(msg.raw);
52
+ const body = msg.raw.slice(bodyOffset);
53
+ const contentType = headers.get("content-type") ?? "application/octet-stream";
54
+ return buildStructure(body, contentType);
55
+ }
56
+
57
+ /**
58
+ * Fetch a single MIME part by dot-separated path.
59
+ */
60
+ export function fetchPart(
61
+ ref: MessageRef,
62
+ partPath: string,
63
+ store: MailboxStore,
64
+ ): MessagePart {
65
+ const msg = requireMessage(store, ref.uid, ref.mailbox);
66
+ const partBytes = extractPartByPath(msg.raw, partPath);
67
+ const part = parseMimePart(partBytes);
68
+
69
+ const enc = part.headers.get("content-transfer-encoding") ?? "7bit";
70
+ let content: Uint8Array;
71
+
72
+ if (enc.toLowerCase() === "base64") {
73
+ const b64 = new TextDecoder().decode(part.body).replace(/\s/g, "");
74
+ content = new Uint8Array(Buffer.from(b64, "base64"));
75
+ } else {
76
+ content = part.body;
77
+ }
78
+
79
+ const result: MessagePart = {
80
+ contentType: part.contentType,
81
+ content,
82
+ };
83
+ if (enc !== "7bit") result.encoding = enc;
84
+ return result;
85
+ }
86
+
87
+ /**
88
+ * Fetch a complete message, verify its PGP/MIME signature, and return
89
+ * a fully parsed InboundMessage.
90
+ */
91
+ export async function fetchFull(
92
+ ref: MessageRef,
93
+ store: MailboxStore,
94
+ getCrypto: (fromAddress: string) => CryptoProvider | undefined,
95
+ ): Promise<InboundMessage> {
96
+ const msg = requireMessage(store, ref.uid, ref.mailbox);
97
+ const { headers } = parseHeaderSection(msg.raw);
98
+ const parsedHeaders = buildMessageHeaders(headers);
99
+
100
+ const rawType = parsedHeaders.interchangeType;
101
+ const isConversation =
102
+ rawType === "conversation.message" ||
103
+ rawType === "conversation.join" ||
104
+ rawType === "conversation.leave" ||
105
+ rawType === undefined;
106
+
107
+ const signatureStatus = await verifyMessageSignature(
108
+ msg.raw,
109
+ parsedHeaders.from,
110
+ getCrypto,
111
+ );
112
+
113
+ const result: InboundMessage = {
114
+ ref,
115
+ headers: parsedHeaders,
116
+ flags: Array.from(msg.flags),
117
+ signatureStatus,
118
+ };
119
+
120
+ try {
121
+ if (isConversation) {
122
+ const part1Bytes = extractPartByPath(msg.raw, "1");
123
+ const part1 = parseMimePart(part1Bytes);
124
+ result.content = new TextDecoder("utf-8", { fatal: false }).decode(
125
+ part1.body,
126
+ );
127
+ } else {
128
+ const part11Bytes = extractPartByPath(msg.raw, "1.1");
129
+ const part11 = parseMimePart(part11Bytes);
130
+ const jsonText = new TextDecoder("utf-8", { fatal: false }).decode(
131
+ part11.body,
132
+ );
133
+ const validated = MessagePayload(JSON.parse(jsonText));
134
+ if (validated instanceof type.errors) {
135
+ throw new Error(`invalid message payload: ${validated.summary}`);
136
+ }
137
+ result.payload = validated;
138
+ }
139
+ } catch {
140
+ // If we can't parse the content, return what we have with the signature status.
141
+ }
142
+
143
+ return result;
144
+ }
145
+
146
+ async function verifyMessageSignature(
147
+ raw: Uint8Array,
148
+ fromAddress: string,
149
+ getCrypto: (fromAddress: string) => CryptoProvider | undefined,
150
+ ): Promise<SignatureStatus> {
151
+ const senderCrypto = getCrypto(fromAddress);
152
+ if (senderCrypto === undefined) {
153
+ return "unknown";
154
+ }
155
+
156
+ try {
157
+ const { headers, bodyOffset } = parseHeaderSection(raw);
158
+ const body = raw.slice(bodyOffset);
159
+ const contentType = headers.get("content-type") ?? "";
160
+
161
+ if (!contentType.toLowerCase().includes("multipart/signed")) {
162
+ return "missing";
163
+ }
164
+
165
+ const boundary = extractBoundary(contentType);
166
+ if (boundary === undefined) return "missing";
167
+
168
+ const parts = parseMultipart(body, boundary);
169
+ if (parts.length < 2) return "missing";
170
+
171
+ const signedContentBytes = parts[0]!;
172
+ const sigPartBytes = parts[1]!;
173
+ const sigPart = parseMimePart(sigPartBytes);
174
+
175
+ if (
176
+ !sigPart.contentType.toLowerCase().includes("application/pgp-signature")
177
+ ) {
178
+ return "missing";
179
+ }
180
+
181
+ const publicKey = senderCrypto.getPublicKey();
182
+ const valid = await verifyDetachedSignature(
183
+ signedContentBytes,
184
+ sigPart.body,
185
+ publicKey,
186
+ );
187
+
188
+ return valid ? "valid" : "invalid";
189
+ } catch {
190
+ return "invalid";
191
+ }
192
+ }
193
+
194
+ function buildStructure(body: Uint8Array, contentType: string): BodyStructure {
195
+ const ct = contentType.toLowerCase();
196
+ if (!ct.startsWith("multipart/")) {
197
+ return { contentType, size: body.length };
198
+ }
199
+
200
+ const boundary = extractBoundary(contentType);
201
+ if (boundary === undefined) {
202
+ return { contentType, size: body.length };
203
+ }
204
+
205
+ const parts = parseMultipart(body, boundary);
206
+ const subStructures: BodyStructure[] = parts.map((partBytes) => {
207
+ const { headers, bodyOffset } = parseHeaderSection(partBytes);
208
+ const partBody = partBytes.slice(bodyOffset);
209
+ const partContentType =
210
+ headers.get("content-type") ?? "application/octet-stream";
211
+ return buildStructure(partBody, partContentType);
212
+ });
213
+
214
+ return { contentType, parts: subStructures };
215
+ }
package/src/headers.ts ADDED
@@ -0,0 +1,87 @@
1
+ import { type } from "arktype";
2
+ import type { MessageHeaders } from "@intx/types/runtime";
3
+ import { InterchangeType } from "@intx/types/runtime";
4
+
5
+ function isInterchangeType(s: string): s is InterchangeType {
6
+ return !(InterchangeType(s) instanceof type.errors);
7
+ }
8
+
9
+ /**
10
+ * Build a MessageHeaders object from a parsed header map.
11
+ *
12
+ * Uses exactOptionalPropertyTypes-safe construction: optional fields are
13
+ * only included in the returned object when they carry actual values.
14
+ */
15
+ export function buildMessageHeaders(
16
+ headers: Map<string, string>,
17
+ ): MessageHeaders {
18
+ const from = headers.get("from") ?? "";
19
+ const toRaw = headers.get("to") ?? "";
20
+ const to = toRaw
21
+ ? toRaw
22
+ .split(",")
23
+ .map((s) => s.trim())
24
+ .filter(Boolean)
25
+ : [];
26
+
27
+ const date = headers.get("date") ?? "";
28
+ const messageId = headers.get("message-id") ?? "";
29
+
30
+ const result: MessageHeaders = { from, to, date, messageId };
31
+
32
+ const ccRaw = headers.get("cc");
33
+ if (ccRaw !== undefined) {
34
+ const cc = ccRaw
35
+ .split(",")
36
+ .map((s) => s.trim())
37
+ .filter(Boolean);
38
+ if (cc.length > 0) result.cc = cc;
39
+ }
40
+
41
+ const refsRaw = headers.get("references");
42
+ if (refsRaw !== undefined) {
43
+ const refs = refsRaw.split(/\s+/).filter(Boolean);
44
+ if (refs.length > 0) result.references = refs;
45
+ }
46
+
47
+ const inReplyTo = headers.get("in-reply-to");
48
+ if (inReplyTo !== undefined) result.inReplyTo = inReplyTo;
49
+
50
+ const subject = headers.get("subject");
51
+ if (subject !== undefined) result.subject = subject;
52
+
53
+ const listId = headers.get("list-id");
54
+ if (listId !== undefined) result.listId = listId;
55
+
56
+ const rawType = headers.get("interchange-type");
57
+ if (rawType !== undefined && isInterchangeType(rawType)) {
58
+ result.interchangeType = rawType;
59
+ }
60
+
61
+ const corrId = headers.get("interchange-correlation-id");
62
+ if (corrId !== undefined) result.interchangeCorrelationId = corrId;
63
+
64
+ const tenantId = headers.get("interchange-tenant-id");
65
+ if (tenantId !== undefined) result.interchangeTenantId = tenantId;
66
+
67
+ const agentId = headers.get("interchange-agent-id");
68
+ if (agentId !== undefined) result.interchangeAgentId = agentId;
69
+
70
+ const sessionId = headers.get("interchange-session-id");
71
+ if (sessionId !== undefined) result.interchangeSessionId = sessionId;
72
+
73
+ const offeringId = headers.get("interchange-offering-id");
74
+ if (offeringId !== undefined) result.interchangeOfferingId = offeringId;
75
+
76
+ const schemaVersion = headers.get("interchange-schema-version");
77
+ if (schemaVersion !== undefined)
78
+ result.interchangeSchemaVersion = schemaVersion;
79
+
80
+ const traceparent = headers.get("traceparent");
81
+ if (traceparent !== undefined) result.traceparent = traceparent;
82
+
83
+ const tracestate = headers.get("tracestate");
84
+ if (tracestate !== undefined) result.tracestate = tracestate;
85
+
86
+ return result;
87
+ }