@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 +37 -0
- package/package.json +18 -0
- package/src/fetch.ts +215 -0
- package/src/headers.ts +87 -0
- package/src/index.test.ts +684 -0
- package/src/index.ts +25 -0
- package/src/mailbox.ts +113 -0
- package/src/search.ts +170 -0
- package/src/send.ts +293 -0
- package/src/thread.ts +275 -0
- package/src/transport.ts +798 -0
- package/tsconfig.json +4 -0
- package/tsconfig.tsbuildinfo +1 -0
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
|
+
}
|