@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/src/mailbox.ts ADDED
@@ -0,0 +1,113 @@
1
+ import type { CryptoProvider, MailboxEvent } from "@intx/types/runtime";
2
+
3
+ /**
4
+ * Pre-parsed envelope extracted from MIME headers at delivery time.
5
+ * Avoids re-parsing raw bytes for every search operation.
6
+ */
7
+ export type StoredEnvelope = {
8
+ messageId: string;
9
+ from: string;
10
+ to: string[];
11
+ subject: string;
12
+ date: Date;
13
+ inReplyTo: string | undefined;
14
+ references: string[];
15
+ interchangeType: string | undefined;
16
+ interchangeCorrelationId: string | undefined;
17
+ };
18
+
19
+ /**
20
+ * A single message stored in memory. The `raw` field contains the complete
21
+ * RFC 2822 message bytes (headers + MIME body). All fetch operations parse
22
+ * from these bytes, guaranteeing byte-exact signature verification.
23
+ */
24
+ export type StoredMessage = {
25
+ uid: number;
26
+ modseq: number;
27
+ flags: Set<string>;
28
+ raw: Uint8Array;
29
+ envelope: StoredEnvelope;
30
+ };
31
+
32
+ export type MailboxStore = {
33
+ messages: StoredMessage[];
34
+ uidCounter: number;
35
+ modseqCounter: number;
36
+ uidValidity: number;
37
+ };
38
+
39
+ export type AddressEntry = {
40
+ mailboxes: Map<string, MailboxStore>;
41
+ watchCallbacks: Map<string, Set<(event: MailboxEvent) => void>>;
42
+ crypto: CryptoProvider;
43
+ };
44
+
45
+ export const DEFAULT_MAILBOXES = [
46
+ "INBOX",
47
+ "Sent",
48
+ "Drafts",
49
+ "Archive",
50
+ "Trash",
51
+ ] as const;
52
+
53
+ export function createMailboxStore(): MailboxStore {
54
+ return {
55
+ messages: [],
56
+ uidCounter: 1,
57
+ modseqCounter: 1,
58
+ uidValidity: Date.now(),
59
+ };
60
+ }
61
+
62
+ export function createAddressEntry(crypto: CryptoProvider): AddressEntry {
63
+ const mailboxes = new Map<string, MailboxStore>();
64
+ for (const name of DEFAULT_MAILBOXES) {
65
+ mailboxes.set(name, createMailboxStore());
66
+ }
67
+ return {
68
+ mailboxes,
69
+ watchCallbacks: new Map(),
70
+ crypto,
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Append a message to a mailbox store. Assigns UID and MODSEQ, returns the
76
+ * assigned UID.
77
+ */
78
+ export function appendToMailbox(
79
+ store: MailboxStore,
80
+ raw: Uint8Array,
81
+ envelope: StoredEnvelope,
82
+ flags: string[],
83
+ ): number {
84
+ const uid = store.uidCounter++;
85
+ const modseq = store.modseqCounter++;
86
+ store.messages.push({
87
+ uid,
88
+ modseq,
89
+ flags: new Set(flags),
90
+ raw,
91
+ envelope,
92
+ });
93
+ return uid;
94
+ }
95
+
96
+ export function findMessage(
97
+ store: MailboxStore,
98
+ uid: number,
99
+ ): StoredMessage | undefined {
100
+ return store.messages.find((m) => m.uid === uid);
101
+ }
102
+
103
+ export function requireMessage(
104
+ store: MailboxStore,
105
+ uid: number,
106
+ mailboxName: string,
107
+ ): StoredMessage {
108
+ const msg = findMessage(store, uid);
109
+ if (msg === undefined) {
110
+ throw new Error(`Message UID ${uid} not found in mailbox "${mailboxName}"`);
111
+ }
112
+ return msg;
113
+ }
package/src/search.ts ADDED
@@ -0,0 +1,170 @@
1
+ import type { SearchQuery, MessageRef } from "@intx/types/runtime";
2
+ import type { MailboxStore, StoredMessage } from "./mailbox";
3
+ import { parseHeaderSection } from "@intx/mime";
4
+
5
+ /**
6
+ * Execute an IMAP SEARCH-equivalent query over an in-memory mailbox.
7
+ *
8
+ * Supports: from, to, cc, bcc, header (field match), before/after/on,
9
+ * sentBefore/sentAfter/sentOn, hasFlags, missingFlags, body, text,
10
+ * largerThan, smallerThan, and boolean and/or/not composition.
11
+ *
12
+ * Returns MessageRef[] for all matching messages, ordered by UID.
13
+ */
14
+ export function executeSearch(
15
+ mailboxName: string,
16
+ store: MailboxStore,
17
+ query: SearchQuery,
18
+ ): MessageRef[] {
19
+ const results: MessageRef[] = [];
20
+ for (const msg of store.messages) {
21
+ if (matchMessage(msg, query)) {
22
+ results.push({ uid: msg.uid, mailbox: mailboxName });
23
+ }
24
+ }
25
+ return results;
26
+ }
27
+
28
+ function matchMessage(msg: StoredMessage, query: SearchQuery): boolean {
29
+ if (query.from !== undefined) {
30
+ if (!msg.envelope.from.toLowerCase().includes(query.from.toLowerCase())) {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ if (query.to !== undefined) {
36
+ const queryTo = query.to;
37
+ const toMatch = msg.envelope.to.some((addr) =>
38
+ addr.toLowerCase().includes(queryTo.toLowerCase()),
39
+ );
40
+ if (!toMatch) return false;
41
+ }
42
+
43
+ if (query.cc !== undefined) {
44
+ const headers = lazyHeaders(msg);
45
+ const ccHeader = headers.get("cc") ?? "";
46
+ if (!ccHeader.toLowerCase().includes(query.cc.toLowerCase())) {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ if (query.bcc !== undefined) {
52
+ const headers = lazyHeaders(msg);
53
+ const bccHeader = headers.get("bcc") ?? "";
54
+ if (!bccHeader.toLowerCase().includes(query.bcc.toLowerCase())) {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ if (query.header !== undefined) {
60
+ const { field, contains } = query.header;
61
+ const headers = lazyHeaders(msg);
62
+ const value = headers.get(field.toLowerCase()) ?? "";
63
+ if (!value.toLowerCase().includes(contains.toLowerCase())) {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ if (query.before !== undefined) {
69
+ if (msg.envelope.date >= query.before) return false;
70
+ }
71
+ if (query.after !== undefined) {
72
+ if (msg.envelope.date <= query.after) return false;
73
+ }
74
+ if (query.on !== undefined) {
75
+ const d = msg.envelope.date;
76
+ const q = query.on;
77
+ if (
78
+ d.getUTCFullYear() !== q.getUTCFullYear() ||
79
+ d.getUTCMonth() !== q.getUTCMonth() ||
80
+ d.getUTCDate() !== q.getUTCDate()
81
+ ) {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ // Sent date filters use the Date header (same as envelope date here).
87
+ if (query.sentBefore !== undefined) {
88
+ if (msg.envelope.date >= query.sentBefore) return false;
89
+ }
90
+ if (query.sentAfter !== undefined) {
91
+ if (msg.envelope.date <= query.sentAfter) return false;
92
+ }
93
+ if (query.sentOn !== undefined) {
94
+ const d = msg.envelope.date;
95
+ const q = query.sentOn;
96
+ if (
97
+ d.getUTCFullYear() !== q.getUTCFullYear() ||
98
+ d.getUTCMonth() !== q.getUTCMonth() ||
99
+ d.getUTCDate() !== q.getUTCDate()
100
+ ) {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ if (query.hasFlags !== undefined) {
106
+ for (const flag of query.hasFlags) {
107
+ if (!msg.flags.has(flag)) return false;
108
+ }
109
+ }
110
+
111
+ if (query.missingFlags !== undefined) {
112
+ for (const flag of query.missingFlags) {
113
+ if (msg.flags.has(flag)) return false;
114
+ }
115
+ }
116
+
117
+ if (query.largerThan !== undefined) {
118
+ if (msg.raw.length <= query.largerThan) return false;
119
+ }
120
+ if (query.smallerThan !== undefined) {
121
+ if (msg.raw.length >= query.smallerThan) return false;
122
+ }
123
+
124
+ if (query.body !== undefined || query.text !== undefined) {
125
+ const rawText = new TextDecoder("utf-8", { fatal: false }).decode(msg.raw);
126
+ if (query.body !== undefined) {
127
+ const { bodyOffset } = parseHeaderSection(msg.raw);
128
+ const bodyText = new TextDecoder("utf-8", { fatal: false }).decode(
129
+ msg.raw.slice(bodyOffset),
130
+ );
131
+ if (!bodyText.toLowerCase().includes(query.body.toLowerCase())) {
132
+ return false;
133
+ }
134
+ }
135
+ if (query.text !== undefined) {
136
+ if (!rawText.toLowerCase().includes(query.text.toLowerCase())) {
137
+ return false;
138
+ }
139
+ }
140
+ }
141
+
142
+ if (query.and !== undefined) {
143
+ for (const sub of query.and) {
144
+ if (!matchMessage(msg, sub)) return false;
145
+ }
146
+ }
147
+
148
+ if (query.or !== undefined) {
149
+ if (query.or.length > 0) {
150
+ const anyMatch = query.or.some((sub) => matchMessage(msg, sub));
151
+ if (!anyMatch) return false;
152
+ }
153
+ }
154
+
155
+ if (query.not !== undefined) {
156
+ if (matchMessage(msg, query.not)) return false;
157
+ }
158
+
159
+ return true;
160
+ }
161
+
162
+ const headerCache = new WeakMap<StoredMessage, Map<string, string>>();
163
+
164
+ function lazyHeaders(msg: StoredMessage): Map<string, string> {
165
+ const cached = headerCache.get(msg);
166
+ if (cached !== undefined) return cached;
167
+ const { headers } = parseHeaderSection(msg.raw);
168
+ headerCache.set(msg, headers);
169
+ return headers;
170
+ }
package/src/send.ts ADDED
@@ -0,0 +1,293 @@
1
+ import type {
2
+ OutboundMessage,
3
+ SendReceipt,
4
+ MailboxEvent,
5
+ } from "@intx/types/runtime";
6
+ import type { AddressEntry, StoredEnvelope } from "./mailbox";
7
+ import { appendToMailbox } from "./mailbox";
8
+ import {
9
+ assembleSignedContent,
10
+ assembleMessage,
11
+ generateMessageId,
12
+ parseHeaderSection,
13
+ createDetachedSignatureFromProvider,
14
+ type MessageHeaders as MimeMessageHeaders,
15
+ type ConversationContent,
16
+ type StructuredContent,
17
+ } from "@intx/mime";
18
+ import { buildMessageHeaders } from "./headers";
19
+
20
+ const CONVERSATION_TYPES = new Set([
21
+ "conversation.message",
22
+ "conversation.join",
23
+ "conversation.leave",
24
+ ]);
25
+
26
+ /**
27
+ * Callback for delivering messages to recipients not registered on this
28
+ * transport. The federation layer provides this to forward messages to
29
+ * the hub for remote routing.
30
+ */
31
+ export type RemoteSendHandler = (
32
+ rawMessage: Uint8Array,
33
+ recipients: string[],
34
+ ) => Promise<void>;
35
+
36
+ /**
37
+ * Context passed to MessageSentHandler callbacks after a message is fully
38
+ * assembled and delivered.
39
+ */
40
+ export type MessageSentContext = {
41
+ senderAddress: string;
42
+ rawMessage: Uint8Array;
43
+ messageId: string;
44
+ /** Deduplicated union of to and cc — the full routing set. */
45
+ recipients: string[];
46
+ /** To addresses only (before merging with cc). */
47
+ to: string[];
48
+ /** CC addresses only. Empty array when no CC recipients. */
49
+ cc: string[];
50
+ /** True when all recipients were delivered locally (no remote leg). */
51
+ localOnly: boolean;
52
+ };
53
+
54
+ /**
55
+ * Callback fired after a message is fully assembled and delivered. The
56
+ * send is already complete when this fires — a handler rejection does
57
+ * not mean the message was not delivered.
58
+ *
59
+ * Used by the sidecar to commit outbound wire messages to the git audit
60
+ * trail and forward metadata to the hub.
61
+ */
62
+ export type MessageSentHandler = (ctx: MessageSentContext) => Promise<void>;
63
+
64
+ /**
65
+ * Execute the send() flow:
66
+ * 1. Validate sender registration, split recipients into local/remote
67
+ * 2. Build signed content part (MIME bytes to sign)
68
+ * 3. Sign with sender's CryptoProvider
69
+ * 4. Assemble the complete RFC 2822 message
70
+ * 5. Append to each local recipient's INBOX and sender's Sent mailbox
71
+ * 6. Forward to remote recipients via onRemoteSend
72
+ * 7. Schedule watch callbacks asynchronously via queueMicrotask
73
+ * 8. Fire onMessageSent callback (fire-and-forget)
74
+ *
75
+ * If onRemoteSend is not provided and there are remote recipients, send()
76
+ * throws. If onRemoteSend rejects, the error propagates — local delivery
77
+ * that already completed is not rolled back. This is a known limitation:
78
+ * partial delivery is possible when a message has both local and remote
79
+ * recipients and the remote leg fails.
80
+ */
81
+ export async function executeSend(
82
+ senderAddress: string,
83
+ message: OutboundMessage,
84
+ entries: Map<string, AddressEntry>,
85
+ onRemoteSend?: RemoteSendHandler,
86
+ onMessageSent?: MessageSentHandler,
87
+ ): Promise<SendReceipt> {
88
+ const senderEntry = entries.get(senderAddress);
89
+ if (senderEntry === undefined) {
90
+ throw new Error(
91
+ `Sender "${senderAddress}" is not registered with this transport`,
92
+ );
93
+ }
94
+ const senderCrypto = senderEntry.crypto;
95
+
96
+ const recipients = Array.isArray(message.to) ? message.to : [message.to];
97
+ if (recipients.length === 0) {
98
+ throw new Error("OutboundMessage must have at least one recipient");
99
+ }
100
+
101
+ const ccAddressList =
102
+ message.cc !== undefined
103
+ ? Array.isArray(message.cc)
104
+ ? message.cc
105
+ : [message.cc]
106
+ : [];
107
+
108
+ const allAddressees = [...new Set([...recipients, ...ccAddressList])];
109
+ const remoteRecipients = allAddressees.filter((addr) => !entries.has(addr));
110
+
111
+ if (remoteRecipients.length > 0 && onRemoteSend === undefined) {
112
+ throw new Error(
113
+ `Recipient "${remoteRecipients[0]}" is not registered with this transport`,
114
+ );
115
+ }
116
+
117
+ const isConversation = CONVERSATION_TYPES.has(message.type);
118
+
119
+ if (isConversation && message.payload !== undefined) {
120
+ throw new Error(
121
+ "Conversation messages must not carry a structured payload",
122
+ );
123
+ }
124
+ if (!isConversation && message.content !== undefined) {
125
+ throw new Error("Structured messages must not carry a text content field");
126
+ }
127
+
128
+ const messageId = generateMessageId(senderAddress);
129
+ const now = new Date();
130
+
131
+ let content: ConversationContent | StructuredContent;
132
+ if (isConversation) {
133
+ content = {
134
+ kind: "conversation",
135
+ text: message.content ?? "",
136
+ };
137
+ } else {
138
+ const payload = message.payload ?? {};
139
+ const envelope = {
140
+ type: message.type,
141
+ version: "1",
142
+ body: payload,
143
+ };
144
+ const structured: StructuredContent = {
145
+ kind: "structured",
146
+ json: envelope,
147
+ };
148
+ if (message.summary !== undefined) structured.summary = message.summary;
149
+ content = structured;
150
+ }
151
+
152
+ const signedContentBytes = assembleSignedContent(content);
153
+ const signatureBytes = await createDetachedSignatureFromProvider(
154
+ signedContentBytes,
155
+ senderCrypto,
156
+ );
157
+
158
+ const ccAddresses = ccAddressList.length > 0 ? ccAddressList : undefined;
159
+
160
+ const refs = buildReferences(message.inReplyTo, undefined);
161
+
162
+ const mimeHeaders: MimeMessageHeaders = {
163
+ from: senderAddress,
164
+ to: recipients,
165
+ cc: ccAddresses,
166
+ date: now,
167
+ messageId,
168
+ subject: message.subject,
169
+ inReplyTo: message.inReplyTo,
170
+ references: refs,
171
+ mimeVersion: "1.0",
172
+ interchangeType: message.type,
173
+ interchangeCorrelationId: message.correlationId,
174
+ interchangeTenantId: message.tenantId,
175
+ interchangeAgentId: undefined,
176
+ interchangeSessionId: message.sessionId,
177
+ interchangeOfferingId: undefined,
178
+ interchangeSchemaVersion: undefined,
179
+ traceparent: undefined,
180
+ tracestate: undefined,
181
+ };
182
+
183
+ const rawBytes = assembleMessage(
184
+ mimeHeaders,
185
+ signedContentBytes,
186
+ signatureBytes,
187
+ );
188
+ const envelope: StoredEnvelope = {
189
+ messageId,
190
+ from: senderAddress,
191
+ to: recipients,
192
+ subject: message.subject ?? "",
193
+ date: now,
194
+ inReplyTo: message.inReplyTo,
195
+ references: refs ?? [],
196
+ interchangeType: message.type,
197
+ interchangeCorrelationId: message.correlationId,
198
+ };
199
+
200
+ // Deliver to each local recipient's INBOX.
201
+ const deliveredUids: { address: string; uid: number }[] = [];
202
+ for (const recipient of allAddressees) {
203
+ const entry = entries.get(recipient);
204
+ if (entry === undefined) continue;
205
+ const inbox = entry.mailboxes.get("INBOX");
206
+ if (inbox === undefined) {
207
+ throw new Error(
208
+ `Mailbox "INBOX" does not exist for recipient "${recipient}"`,
209
+ );
210
+ }
211
+ const uid = appendToMailbox(inbox, rawBytes, envelope, []);
212
+ deliveredUids.push({ address: recipient, uid });
213
+ }
214
+
215
+ // Append copy to sender's Sent mailbox.
216
+ const sentStore = senderEntry.mailboxes.get("Sent");
217
+ if (sentStore === undefined) {
218
+ throw new Error(
219
+ `Mailbox "Sent" does not exist for sender "${senderAddress}"`,
220
+ );
221
+ }
222
+ appendToMailbox(sentStore, rawBytes, envelope, ["\\Seen"]);
223
+
224
+ // Fire local recipient watch callbacks ASYNCHRONOUSLY (per MESSAGE.md
225
+ // requirement). queueMicrotask ensures callbacks never run synchronously
226
+ // on the sender's call stack, preserving real IMAP IDLE async delivery
227
+ // semantics. Scheduled before the remote send so local delivery
228
+ // notifications are not delayed by network latency.
229
+ const { headers: parsedHeaders } = parseHeaderSection(rawBytes);
230
+ const msgHeaders = buildMessageHeaders(parsedHeaders);
231
+
232
+ for (const { address, uid } of deliveredUids) {
233
+ const entry = entries.get(address);
234
+ if (entry === undefined) {
235
+ throw new Error(
236
+ `Entry for "${address}" disappeared between delivery and callback dispatch`,
237
+ );
238
+ }
239
+ const callbacks = entry.watchCallbacks.get("INBOX");
240
+ if (callbacks === undefined || callbacks.size === 0) continue;
241
+
242
+ const event: MailboxEvent = {
243
+ type: "exists",
244
+ uid,
245
+ headers: msgHeaders,
246
+ };
247
+
248
+ for (const cb of callbacks) {
249
+ queueMicrotask(() => cb(event));
250
+ }
251
+ }
252
+
253
+ // Forward to remote recipients via federation hook.
254
+ if (remoteRecipients.length > 0 && onRemoteSend !== undefined) {
255
+ await onRemoteSend(rawBytes, remoteRecipients);
256
+ }
257
+
258
+ if (onMessageSent !== undefined) {
259
+ const localOnly = remoteRecipients.length === 0;
260
+ onMessageSent({
261
+ senderAddress,
262
+ rawMessage: rawBytes,
263
+ messageId,
264
+ recipients: allAddressees,
265
+ to: recipients,
266
+ cc: ccAddressList,
267
+ localOnly,
268
+ }).catch((err: unknown) => {
269
+ queueMicrotask(() => {
270
+ throw err instanceof Error
271
+ ? err
272
+ : new Error(`MessageSentHandler failed: ${String(err)}`);
273
+ });
274
+ });
275
+ }
276
+
277
+ return {
278
+ messageId,
279
+ status: remoteRecipients.length > 0 ? "queued" : "delivered",
280
+ };
281
+ }
282
+
283
+ function buildReferences(
284
+ inReplyTo: string | undefined,
285
+ existingReferences: string[] | undefined,
286
+ ): string[] | undefined {
287
+ if (inReplyTo === undefined) return existingReferences;
288
+ const refs = existingReferences ?? [];
289
+ if (!refs.includes(inReplyTo)) {
290
+ return [...refs, inReplyTo];
291
+ }
292
+ return refs;
293
+ }