@intx/mail-memory 0.3.0 → 0.4.0

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 CHANGED
@@ -33,5 +33,7 @@ await alphaMail.send({
33
33
 
34
34
  Install a `RemoteSendHandler` via `transport.setRemoteSendHandler`
35
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.
36
+ locally. The handler receives the raw message, remote recipients, and
37
+ the sender address already validated by the scoped transport. Add one or
38
+ more `MessageSentHandler`s via `transport.addMessageSentHandler` for
39
+ post-send observability.
package/dist/mailbox.d.ts CHANGED
@@ -1,49 +1,13 @@
1
1
  import type { CryptoProvider, MailboxEvent } from "@intx/types/runtime";
2
+ import { type MailboxStore } from "@intx/mailbox";
2
3
  /**
3
- * Pre-parsed envelope extracted from MIME headers at delivery time.
4
- * Avoids re-parsing raw bytes for every search operation.
4
+ * Per-address state for the in-memory transport: one `MailboxStore` per
5
+ * mailbox, the watch callbacks registered against each mailbox, and the
6
+ * address's `CryptoProvider`.
5
7
  */
6
- export type StoredEnvelope = {
7
- messageId: string;
8
- from: string;
9
- to: string[];
10
- subject: string;
11
- date: Date;
12
- inReplyTo: string | undefined;
13
- references: string[];
14
- interchangeType: string | undefined;
15
- interchangeCorrelationId: string | undefined;
16
- };
17
- /**
18
- * A single message stored in memory. The `raw` field contains the complete
19
- * RFC 2822 message bytes (headers + MIME body). All fetch operations parse
20
- * from these bytes, guaranteeing byte-exact signature verification.
21
- */
22
- export type StoredMessage = {
23
- uid: number;
24
- modseq: number;
25
- flags: Set<string>;
26
- raw: Uint8Array;
27
- envelope: StoredEnvelope;
28
- };
29
- export type MailboxStore = {
30
- messages: StoredMessage[];
31
- uidCounter: number;
32
- modseqCounter: number;
33
- uidValidity: number;
34
- };
35
8
  export type AddressEntry = {
36
9
  mailboxes: Map<string, MailboxStore>;
37
10
  watchCallbacks: Map<string, Set<(event: MailboxEvent) => void>>;
38
11
  crypto: CryptoProvider;
39
12
  };
40
- export declare const DEFAULT_MAILBOXES: readonly ["INBOX", "Sent", "Drafts", "Archive", "Trash"];
41
- export declare function createMailboxStore(): MailboxStore;
42
13
  export declare function createAddressEntry(crypto: CryptoProvider): AddressEntry;
43
- /**
44
- * Append a message to a mailbox store. Assigns UID and MODSEQ, returns the
45
- * assigned UID.
46
- */
47
- export declare function appendToMailbox(store: MailboxStore, raw: Uint8Array, envelope: StoredEnvelope, flags: string[]): number;
48
- export declare function findMessage(store: MailboxStore, uid: number): StoredMessage | undefined;
49
- export declare function requireMessage(store: MailboxStore, uid: number, mailboxName: string): StoredMessage;
package/dist/mailbox.js CHANGED
@@ -1,22 +1,8 @@
1
- export const DEFAULT_MAILBOXES = [
2
- "INBOX",
3
- "Sent",
4
- "Drafts",
5
- "Archive",
6
- "Trash",
7
- ];
8
- export function createMailboxStore() {
9
- return {
10
- messages: [],
11
- uidCounter: 1,
12
- modseqCounter: 1,
13
- uidValidity: Date.now(),
14
- };
15
- }
1
+ import { DEFAULT_MAILBOXES, createInMemoryMailboxStore, } from "@intx/mailbox";
16
2
  export function createAddressEntry(crypto) {
17
3
  const mailboxes = new Map();
18
4
  for (const name of DEFAULT_MAILBOXES) {
19
- mailboxes.set(name, createMailboxStore());
5
+ mailboxes.set(name, createInMemoryMailboxStore());
20
6
  }
21
7
  return {
22
8
  mailboxes,
@@ -24,29 +10,3 @@ export function createAddressEntry(crypto) {
24
10
  crypto,
25
11
  };
26
12
  }
27
- /**
28
- * Append a message to a mailbox store. Assigns UID and MODSEQ, returns the
29
- * assigned UID.
30
- */
31
- export function appendToMailbox(store, raw, envelope, flags) {
32
- const uid = store.uidCounter++;
33
- const modseq = store.modseqCounter++;
34
- store.messages.push({
35
- uid,
36
- modseq,
37
- flags: new Set(flags),
38
- raw,
39
- envelope,
40
- });
41
- return uid;
42
- }
43
- export function findMessage(store, uid) {
44
- return store.messages.find((m) => m.uid === uid);
45
- }
46
- export function requireMessage(store, uid, mailboxName) {
47
- const msg = findMessage(store, uid);
48
- if (msg === undefined) {
49
- throw new Error(`Message UID ${uid} not found in mailbox "${mailboxName}"`);
50
- }
51
- return msg;
52
- }
package/dist/send.d.ts CHANGED
@@ -2,10 +2,10 @@ import type { OutboundMessage, SendReceipt } from "@intx/types/runtime";
2
2
  import type { AddressEntry } from "./mailbox.js";
3
3
  /**
4
4
  * Callback for delivering messages to recipients not registered on this
5
- * transport. The federation layer provides this to forward messages to
6
- * the hub for remote routing.
5
+ * transport. The federation layer provides this to forward messages and the
6
+ * transport-validated sender address to the hub for remote routing.
7
7
  */
8
- export type RemoteSendHandler = (rawMessage: Uint8Array, recipients: string[]) => Promise<void>;
8
+ export type RemoteSendHandler = (rawMessage: Uint8Array, recipients: string[], senderAddress: string) => Promise<void>;
9
9
  /**
10
10
  * Context passed to MessageSentHandler callbacks after a message is fully
11
11
  * assembled and delivered.
package/dist/send.js CHANGED
@@ -1,6 +1,5 @@
1
- import { appendToMailbox } from "./mailbox.js";
2
- import { assembleSignedContent, assembleMessage, generateMessageId, parseHeaderSection, createDetachedSignatureFromProvider, } from "@intx/mime";
3
- import { buildMessageHeaders } from "./headers.js";
1
+ import { buildMessageHeaders } from "@intx/mailbox";
2
+ import { assembleSignedContent, assembleMessage, generateMessageId, isMessageId, parseHeaderSection, createDetachedSignatureFromProvider, } from "@intx/mime";
4
3
  const CONVERSATION_TYPES = new Set([
5
4
  "conversation.message",
6
5
  "conversation.join",
@@ -77,7 +76,7 @@ export async function executeSend(senderAddress, message, entries, onRemoteSend,
77
76
  const signedContentBytes = assembleSignedContent(content);
78
77
  const signatureBytes = await createDetachedSignatureFromProvider(signedContentBytes, senderCrypto);
79
78
  const ccAddresses = ccAddressList.length > 0 ? ccAddressList : undefined;
80
- const refs = buildReferences(message.inReplyTo, undefined);
79
+ const refs = buildReferences(message.inReplyTo, message.references);
81
80
  const mimeHeaders = {
82
81
  from: senderAddress,
83
82
  to: recipients,
@@ -120,7 +119,7 @@ export async function executeSend(senderAddress, message, entries, onRemoteSend,
120
119
  if (inbox === undefined) {
121
120
  throw new Error(`Mailbox "INBOX" does not exist for recipient "${recipient}"`);
122
121
  }
123
- const uid = appendToMailbox(inbox, rawBytes, envelope, []);
122
+ const uid = inbox.append(rawBytes, envelope, []);
124
123
  deliveredUids.push({ address: recipient, uid });
125
124
  }
126
125
  // Append copy to sender's Sent mailbox.
@@ -128,7 +127,7 @@ export async function executeSend(senderAddress, message, entries, onRemoteSend,
128
127
  if (sentStore === undefined) {
129
128
  throw new Error(`Mailbox "Sent" does not exist for sender "${senderAddress}"`);
130
129
  }
131
- appendToMailbox(sentStore, rawBytes, envelope, ["\\Seen"]);
130
+ sentStore.append(rawBytes, envelope, ["\\Seen"]);
132
131
  // Fire local recipient watch callbacks ASYNCHRONOUSLY (per MESSAGE.md
133
132
  // requirement). queueMicrotask ensures callbacks never run synchronously
134
133
  // on the sender's call stack, preserving real IMAP IDLE async delivery
@@ -155,7 +154,7 @@ export async function executeSend(senderAddress, message, entries, onRemoteSend,
155
154
  }
156
155
  // Forward to remote recipients via federation hook.
157
156
  if (remoteRecipients.length > 0 && onRemoteSend !== undefined) {
158
- await onRemoteSend(rawBytes, remoteRecipients);
157
+ await onRemoteSend(rawBytes, remoteRecipients, senderAddress);
159
158
  }
160
159
  if (onMessageSent !== undefined) {
161
160
  const localOnly = remoteRecipients.length === 0;
@@ -180,10 +179,26 @@ export async function executeSend(senderAddress, message, entries, onRemoteSend,
180
179
  status: remoteRecipients.length > 0 ? "queued" : "delivered",
181
180
  };
182
181
  }
182
+ /**
183
+ * Assemble the wire `References` chain for an outbound message.
184
+ *
185
+ * When the caller supplies a full ancestry (`existingReferences` -- the
186
+ * parent's References plus the parent's Message-Id, built by the threaded
187
+ * reply path), that chain is used and `inReplyTo` is appended only when it is
188
+ * not already the tail. Otherwise the chain is derived from `inReplyTo` alone,
189
+ * preserving the pre-existing single-element behavior.
190
+ *
191
+ * The caller-supplied chain is filtered to RFC 2822 message identifiers:
192
+ * inbound mail can carry a headerless-derived (sha256) or otherwise malformed
193
+ * Message-Id that is a valid claim-check key but not a valid `<id@host>`
194
+ * identifier, and such a value must not leak into a `References` header. The
195
+ * `inReplyTo` value is appended without filtering, matching the pre-existing
196
+ * `In-Reply-To`/`References` behavior for a bare reply.
197
+ */
183
198
  function buildReferences(inReplyTo, existingReferences) {
199
+ const refs = (existingReferences ?? []).filter(isMessageId);
184
200
  if (inReplyTo === undefined)
185
- return existingReferences;
186
- const refs = existingReferences ?? [];
201
+ return refs.length > 0 ? refs : undefined;
187
202
  if (!refs.includes(inReplyTo)) {
188
203
  return [...refs, inReplyTo];
189
204
  }
@@ -73,7 +73,9 @@ export declare class InMemoryTransport implements MessageTransport, HubTransport
73
73
  clearFlags(_ref: MessageRef, _flags: string[], _signal?: AbortSignal): Promise<void>;
74
74
  move(_ref: MessageRef, _toMailbox: string, _signal?: AbortSignal): Promise<void>;
75
75
  copy(_ref: MessageRef, _toMailbox: string, _signal?: AbortSignal): Promise<void>;
76
- expunge(_mailbox: string, _signal?: AbortSignal): Promise<void>;
76
+ expunge(_mailbox: string, _signal?: AbortSignal): Promise<{
77
+ expungedUids: number[];
78
+ }>;
77
79
  watch(_mailbox: string, _callback: (event: MailboxEvent) => void): Unsubscribe;
78
80
  sync(_mailbox: string, _knownState: SyncState, _signal?: AbortSignal): Promise<SyncResult>;
79
81
  createList(_address: string, _name: string, _signal?: AbortSignal): Promise<ListInfo>;
package/dist/transport.js CHANGED
@@ -1,10 +1,7 @@
1
- import { createAddressEntry, createMailboxStore, requireMessage, appendToMailbox, } from "./mailbox.js";
2
1
  import { parseHeaderSection } from "@intx/mime";
3
- import { buildMessageHeaders } from "./headers.js";
2
+ import { buildMessageHeaders, createInMemoryMailboxStore, executeSearch, executeThread, fetchHeaders as doFetchHeaders, fetchStructure as doFetchStructure, fetchPart as doFetchPart, fetchFull as doFetchFull, requireMessage, } from "@intx/mailbox";
3
+ import { createAddressEntry } from "./mailbox.js";
4
4
  import { executeSend, } from "./send.js";
5
- import { executeSearch } from "./search.js";
6
- import { executeThread } from "./thread.js";
7
- import { fetchHeaders as doFetchHeaders, fetchStructure as doFetchStructure, fetchPart as doFetchPart, fetchFull as doFetchFull, } from "./fetch.js";
8
5
  /**
9
6
  * In-memory MessageTransport implementing full IMAP semantics within a
10
7
  * single process. Messages are stored as real RFC 2822 MIME byte buffers.
@@ -185,7 +182,7 @@ export class InMemoryTransport {
185
182
  interchangeType: headers.get("interchange-type"),
186
183
  interchangeCorrelationId: headers.get("interchange-correlation-id"),
187
184
  };
188
- const uid = appendToMailbox(inbox, message, envelope, []);
185
+ const uid = inbox.append(message, envelope, []);
189
186
  const callbacks = entry.watchCallbacks.get("INBOX");
190
187
  if (callbacks !== undefined && callbacks.size > 0) {
191
188
  const event = {
@@ -274,7 +271,7 @@ class ScopedMessageTransport {
274
271
  interchangeType: message.headers.interchangeType,
275
272
  interchangeCorrelationId: message.headers.interchangeCorrelationId,
276
273
  };
277
- const uid = appendToMailbox(store, raw, envelope, flags ?? []);
274
+ const uid = store.append(raw, envelope, flags ?? []);
278
275
  return { uid, mailbox };
279
276
  }
280
277
  async listMailboxes(_signal) {
@@ -286,7 +283,7 @@ class ScopedMessageTransport {
286
283
  if (this.#entry.mailboxes.has(name)) {
287
284
  throw new Error(`Mailbox "${name}" already exists for address "${this.#address}"`);
288
285
  }
289
- this.#entry.mailboxes.set(name, createMailboxStore());
286
+ this.#entry.mailboxes.set(name, createInMemoryMailboxStore());
290
287
  return { name };
291
288
  }
292
289
  async deleteMailbox(name, _signal) {
@@ -302,42 +299,38 @@ class ScopedMessageTransport {
302
299
  total: store.messages.length,
303
300
  unseen,
304
301
  recent: 0,
305
- uidNext: store.uidCounter,
302
+ uidNext: store.uidNext,
306
303
  uidValidity: store.uidValidity,
307
- highestModSeq: store.modseqCounter - 1,
304
+ highestModSeq: store.highestModSeq,
308
305
  };
309
306
  }
310
307
  async search(mailbox, query, _signal) {
311
308
  const store = this.#requireMailbox(mailbox);
312
- return executeSearch(mailbox, store, query);
309
+ return await executeSearch(mailbox, store, query);
313
310
  }
314
311
  async thread(mailbox, algorithm, query, _signal) {
315
312
  const store = this.#requireMailbox(mailbox);
316
- return executeThread(mailbox, store, algorithm, query);
313
+ return await executeThread(mailbox, store, algorithm, query);
317
314
  }
318
315
  async fetchHeaders(ref, _signal) {
319
316
  const store = this.#requireMailbox(ref.mailbox);
320
- return doFetchHeaders(ref, store);
317
+ return await doFetchHeaders(ref, store);
321
318
  }
322
319
  async fetchStructure(ref, _signal) {
323
320
  const store = this.#requireMailbox(ref.mailbox);
324
- return doFetchStructure(ref, store);
321
+ return await doFetchStructure(ref, store);
325
322
  }
326
323
  async fetchPart(ref, partPath, _signal) {
327
324
  const store = this.#requireMailbox(ref.mailbox);
328
- return doFetchPart(ref, partPath, store);
325
+ return await doFetchPart(ref, partPath, store);
329
326
  }
330
327
  async fetchFull(ref, _signal) {
331
328
  const store = this.#requireMailbox(ref.mailbox);
332
- return doFetchFull(ref, store, (addr) => this.#entries.get(addr)?.crypto);
329
+ return await doFetchFull(ref, store, (addr) => this.#entries.get(addr)?.crypto);
333
330
  }
334
331
  async setFlags(ref, flags, _signal) {
335
332
  const store = this.#requireMailbox(ref.mailbox);
336
- const msg = requireMessage(store, ref.uid, ref.mailbox);
337
- for (const flag of flags) {
338
- msg.flags.add(flag);
339
- }
340
- msg.modseq = store.modseqCounter++;
333
+ const msg = store.addFlags(ref.uid, flags);
341
334
  this.#fireWatchCallbacks(ref.mailbox, {
342
335
  type: "flagsChanged",
343
336
  uid: ref.uid,
@@ -346,11 +339,7 @@ class ScopedMessageTransport {
346
339
  }
347
340
  async clearFlags(ref, flags, _signal) {
348
341
  const store = this.#requireMailbox(ref.mailbox);
349
- const msg = requireMessage(store, ref.uid, ref.mailbox);
350
- for (const flag of flags) {
351
- msg.flags.delete(flag);
352
- }
353
- msg.modseq = store.modseqCounter++;
342
+ const msg = store.removeFlags(ref.uid, flags);
354
343
  this.#fireWatchCallbacks(ref.mailbox, {
355
344
  type: "flagsChanged",
356
345
  uid: ref.uid,
@@ -360,20 +349,16 @@ class ScopedMessageTransport {
360
349
  async move(ref, toMailbox, _signal) {
361
350
  const fromStore = this.#requireMailbox(ref.mailbox);
362
351
  const toStore = this.#requireMailbox(toMailbox);
363
- const msgIdx = fromStore.messages.findIndex((m) => m.uid === ref.uid);
364
- if (msgIdx === -1) {
365
- throw new Error(`Message UID ${ref.uid} not found in mailbox "${ref.mailbox}"`);
366
- }
367
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by findIndex !== -1 above
368
- const msg = fromStore.messages[msgIdx];
369
- fromStore.messages.splice(msgIdx, 1);
370
- const newUid = appendToMailbox(toStore, msg.raw, msg.envelope, Array.from(msg.flags));
352
+ const msg = requireMessage(fromStore, ref.uid, ref.mailbox);
353
+ const raw = await fromStore.readRaw(ref.uid);
354
+ fromStore.remove(ref.uid);
355
+ const newUid = toStore.append(raw, msg.envelope, Array.from(msg.flags));
371
356
  this.#fireWatchCallbacks(ref.mailbox, {
372
357
  type: "expunged",
373
358
  uid: ref.uid,
374
359
  });
375
360
  // Notify watchers of the new message in the destination mailbox.
376
- const { headers: parsedHeaders } = parseHeaderSection(msg.raw);
361
+ const { headers: parsedHeaders } = parseHeaderSection(raw);
377
362
  const msgHeaders = this.#buildMessageHeaders(parsedHeaders);
378
363
  this.#fireWatchCallbacks(toMailbox, {
379
364
  type: "exists",
@@ -385,8 +370,9 @@ class ScopedMessageTransport {
385
370
  const fromStore = this.#requireMailbox(ref.mailbox);
386
371
  const toStore = this.#requireMailbox(toMailbox);
387
372
  const msg = requireMessage(fromStore, ref.uid, ref.mailbox);
388
- const newUid = appendToMailbox(toStore, msg.raw, msg.envelope, Array.from(msg.flags));
389
- const { headers: parsedHeaders } = parseHeaderSection(msg.raw);
373
+ const raw = await fromStore.readRaw(ref.uid);
374
+ const newUid = toStore.append(raw, msg.envelope, Array.from(msg.flags));
375
+ const { headers: parsedHeaders } = parseHeaderSection(raw);
390
376
  const msgHeaders = this.#buildMessageHeaders(parsedHeaders);
391
377
  this.#fireWatchCallbacks(toMailbox, {
392
378
  type: "exists",
@@ -397,13 +383,16 @@ class ScopedMessageTransport {
397
383
  async expunge(mailbox, _signal) {
398
384
  const store = this.#requireMailbox(mailbox);
399
385
  const toExpunge = store.messages.filter((m) => m.flags.has("\\Deleted"));
400
- store.messages = store.messages.filter((m) => !m.flags.has("\\Deleted"));
386
+ for (const msg of toExpunge) {
387
+ store.remove(msg.uid);
388
+ }
401
389
  for (const msg of toExpunge) {
402
390
  this.#fireWatchCallbacks(mailbox, {
403
391
  type: "expunged",
404
392
  uid: msg.uid,
405
393
  });
406
394
  }
395
+ return { expungedUids: toExpunge.map((m) => m.uid) };
407
396
  }
408
397
  watch(mailbox, callback) {
409
398
  this.#requireMailbox(mailbox);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@intx/mail-memory",
3
3
  "description": "In-memory MessageTransport for single-process and test environments",
4
- "version": "0.3.0",
4
+ "version": "0.4.0",
5
5
  "license": "LGPL-2.1-only",
6
6
  "type": "module",
7
7
  "exports": {
@@ -12,10 +12,11 @@
12
12
  }
13
13
  },
14
14
  "dependencies": {
15
- "@intx/crypto": "0.3.0",
16
- "@intx/log": "0.3.0",
17
- "@intx/mime": "0.3.0",
18
- "@intx/types": "0.3.0",
15
+ "@intx/crypto": "0.4.0",
16
+ "@intx/log": "0.4.0",
17
+ "@intx/mailbox": "0.4.0",
18
+ "@intx/mime": "0.4.0",
19
+ "@intx/types": "0.4.0",
19
20
  "arktype": "^2.1.29"
20
21
  },
21
22
  "files": [
package/dist/fetch.d.ts DELETED
@@ -1,19 +0,0 @@
1
- import type { MessageHeaders, BodyStructure, MessagePart, InboundMessage, CryptoProvider, MessageRef } from "@intx/types/runtime";
2
- import type { MailboxStore } from "./mailbox.js";
3
- /**
4
- * Parse raw RFC 2822 headers from a stored message.
5
- */
6
- export declare function fetchHeaders(ref: MessageRef, store: MailboxStore): MessageHeaders;
7
- /**
8
- * Compute the MIME tree structure (BODYSTRUCTURE) without transferring content.
9
- */
10
- export declare function fetchStructure(ref: MessageRef, store: MailboxStore): BodyStructure;
11
- /**
12
- * Fetch a single MIME part by dot-separated path.
13
- */
14
- export declare function fetchPart(ref: MessageRef, partPath: string, store: MailboxStore): MessagePart;
15
- /**
16
- * Fetch a complete message, verify its PGP/MIME signature, and return
17
- * a fully parsed InboundMessage.
18
- */
19
- export declare function fetchFull(ref: MessageRef, store: MailboxStore, getCrypto: (fromAddress: string) => CryptoProvider | undefined): Promise<InboundMessage>;
package/dist/fetch.js DELETED
@@ -1,171 +0,0 @@
1
- /* eslint-disable @typescript-eslint/no-non-null-assertion -- MIME multipart parsing with bounds checks */
2
- import { type } from "arktype";
3
- import { InterchangeType } from "@intx/types/runtime";
4
- import { base64Decode } from "@intx/types";
5
- import { requireMessage } from "./mailbox.js";
6
- import { parseHeaderSection, parseMimePart, extractBoundary, parseMultipart, extractPartByPath, extractAttachments, } from "@intx/mime";
7
- import { buildMessageHeaders } from "./headers.js";
8
- import { verifyDetachedSignature } from "@intx/crypto";
9
- const MessagePayload = type({
10
- type: InterchangeType,
11
- version: "string",
12
- body: "Record<string, unknown>",
13
- });
14
- /**
15
- * Parse raw RFC 2822 headers from a stored message.
16
- */
17
- export function fetchHeaders(ref, store) {
18
- const msg = requireMessage(store, ref.uid, ref.mailbox);
19
- const { headers } = parseHeaderSection(msg.raw);
20
- return buildMessageHeaders(headers);
21
- }
22
- /**
23
- * Compute the MIME tree structure (BODYSTRUCTURE) without transferring content.
24
- */
25
- export function fetchStructure(ref, store) {
26
- const msg = requireMessage(store, ref.uid, ref.mailbox);
27
- const { headers, bodyOffset } = parseHeaderSection(msg.raw);
28
- const body = msg.raw.slice(bodyOffset);
29
- const contentType = headers.get("content-type") ?? "application/octet-stream";
30
- return buildStructure(body, contentType);
31
- }
32
- /**
33
- * Fetch a single MIME part by dot-separated path.
34
- */
35
- export function fetchPart(ref, partPath, store) {
36
- const msg = requireMessage(store, ref.uid, ref.mailbox);
37
- const partBytes = extractPartByPath(msg.raw, partPath);
38
- const part = parseMimePart(partBytes);
39
- const enc = part.headers.get("content-transfer-encoding") ?? "7bit";
40
- let content;
41
- if (enc.toLowerCase() === "base64") {
42
- const b64 = new TextDecoder().decode(part.body).replace(/\s/g, "");
43
- content = base64Decode(b64);
44
- }
45
- else {
46
- content = part.body;
47
- }
48
- const result = {
49
- contentType: part.contentType,
50
- content,
51
- };
52
- if (enc !== "7bit")
53
- result.encoding = enc;
54
- return result;
55
- }
56
- /**
57
- * Fetch a complete message, verify its PGP/MIME signature, and return
58
- * a fully parsed InboundMessage.
59
- */
60
- export async function fetchFull(ref, store, getCrypto) {
61
- const msg = requireMessage(store, ref.uid, ref.mailbox);
62
- const { headers } = parseHeaderSection(msg.raw);
63
- const parsedHeaders = buildMessageHeaders(headers);
64
- const rawType = parsedHeaders.interchangeType;
65
- const isConversation = rawType === "conversation.message" ||
66
- rawType === "conversation.join" ||
67
- rawType === "conversation.leave" ||
68
- rawType === undefined;
69
- const signatureStatus = await verifyMessageSignature(msg.raw, parsedHeaders.from, getCrypto);
70
- const result = {
71
- ref,
72
- headers: parsedHeaders,
73
- flags: Array.from(msg.flags),
74
- signatureStatus,
75
- };
76
- try {
77
- if (isConversation) {
78
- const part1 = parseMimePart(extractPartByPath(msg.raw, "1"));
79
- const part1Mime = part1.contentType.split(";")[0].trim().toLowerCase();
80
- if (part1Mime.startsWith("multipart/")) {
81
- // Conversation shape: multipart/mixed with the text body at 1.1.
82
- const textPart = parseMimePart(extractPartByPath(msg.raw, "1.1"));
83
- result.content = new TextDecoder("utf-8", { fatal: false }).decode(textPart.body);
84
- }
85
- else {
86
- // A conversation message is "literally a signed email", so a sender
87
- // (e.g. a plain mail client) may sign a bare text/plain part with no
88
- // multipart/mixed wrapper. This branch reads that body directly. Our
89
- // own assembler always emits multipart/mixed; without this branch a
90
- // bare text/plain message would fail the 1.1 lookup and silently lose
91
- // its content to the catch below.
92
- result.content = new TextDecoder("utf-8", { fatal: false }).decode(part1.body);
93
- }
94
- }
95
- else {
96
- // Structured messages carry their JSON payload at 1.1. Attachments on
97
- // structured messages are intentionally not parsed: they have no
98
- // producer today, so parsing them would handle a shape nobody sends.
99
- const part11Bytes = extractPartByPath(msg.raw, "1.1");
100
- const part11 = parseMimePart(part11Bytes);
101
- const jsonText = new TextDecoder("utf-8", { fatal: false }).decode(part11.body);
102
- const validated = MessagePayload(JSON.parse(jsonText));
103
- if (validated instanceof type.errors) {
104
- throw new Error(`invalid message payload: ${validated.summary}`);
105
- }
106
- result.payload = validated;
107
- }
108
- }
109
- catch {
110
- // If we can't parse the content, return what we have with the signature status.
111
- }
112
- // Attachment parsing is deliberately outside the catch above: a malformed
113
- // attachment must surface as a thrown error, not be silently dropped.
114
- if (isConversation) {
115
- const attachments = extractAttachments(msg.raw);
116
- if (attachments.length > 0) {
117
- result.attachments = attachments;
118
- }
119
- }
120
- return result;
121
- }
122
- async function verifyMessageSignature(raw, fromAddress, getCrypto) {
123
- const senderCrypto = getCrypto(fromAddress);
124
- if (senderCrypto === undefined) {
125
- return "unknown";
126
- }
127
- try {
128
- const { headers, bodyOffset } = parseHeaderSection(raw);
129
- const body = raw.slice(bodyOffset);
130
- const contentType = headers.get("content-type") ?? "";
131
- if (!contentType.toLowerCase().includes("multipart/signed")) {
132
- return "missing";
133
- }
134
- const boundary = extractBoundary(contentType);
135
- if (boundary === undefined)
136
- return "missing";
137
- const parts = parseMultipart(body, boundary);
138
- if (parts.length < 2)
139
- return "missing";
140
- const signedContentBytes = parts[0];
141
- const sigPartBytes = parts[1];
142
- const sigPart = parseMimePart(sigPartBytes);
143
- if (!sigPart.contentType.toLowerCase().includes("application/pgp-signature")) {
144
- return "missing";
145
- }
146
- const publicKey = senderCrypto.getPublicKey();
147
- const valid = await verifyDetachedSignature(signedContentBytes, sigPart.body, publicKey);
148
- return valid ? "valid" : "invalid";
149
- }
150
- catch {
151
- return "invalid";
152
- }
153
- }
154
- function buildStructure(body, contentType) {
155
- const ct = contentType.toLowerCase();
156
- if (!ct.startsWith("multipart/")) {
157
- return { contentType, size: body.length };
158
- }
159
- const boundary = extractBoundary(contentType);
160
- if (boundary === undefined) {
161
- return { contentType, size: body.length };
162
- }
163
- const parts = parseMultipart(body, boundary);
164
- const subStructures = parts.map((partBytes) => {
165
- const { headers, bodyOffset } = parseHeaderSection(partBytes);
166
- const partBody = partBytes.slice(bodyOffset);
167
- const partContentType = headers.get("content-type") ?? "application/octet-stream";
168
- return buildStructure(partBody, partContentType);
169
- });
170
- return { contentType, parts: subStructures };
171
- }
package/dist/headers.d.ts DELETED
@@ -1,8 +0,0 @@
1
- import type { MessageHeaders } from "@intx/types/runtime";
2
- /**
3
- * Build a MessageHeaders object from a parsed header map.
4
- *
5
- * Uses exactOptionalPropertyTypes-safe construction: optional fields are
6
- * only included in the returned object when they carry actual values.
7
- */
8
- export declare function buildMessageHeaders(headers: Map<string, string>): MessageHeaders;
package/dist/headers.js DELETED
@@ -1,77 +0,0 @@
1
- import { type } from "arktype";
2
- import { InterchangeType } from "@intx/types/runtime";
3
- function isInterchangeType(s) {
4
- return !(InterchangeType(s) instanceof type.errors);
5
- }
6
- /**
7
- * Build a MessageHeaders object from a parsed header map.
8
- *
9
- * Uses exactOptionalPropertyTypes-safe construction: optional fields are
10
- * only included in the returned object when they carry actual values.
11
- */
12
- export function buildMessageHeaders(headers) {
13
- const from = headers.get("from") ?? "";
14
- const toRaw = headers.get("to") ?? "";
15
- const to = toRaw
16
- ? toRaw
17
- .split(",")
18
- .map((s) => s.trim())
19
- .filter(Boolean)
20
- : [];
21
- const date = headers.get("date") ?? "";
22
- const messageId = headers.get("message-id") ?? "";
23
- const result = { from, to, date, messageId };
24
- const ccRaw = headers.get("cc");
25
- if (ccRaw !== undefined) {
26
- const cc = ccRaw
27
- .split(",")
28
- .map((s) => s.trim())
29
- .filter(Boolean);
30
- if (cc.length > 0)
31
- result.cc = cc;
32
- }
33
- const refsRaw = headers.get("references");
34
- if (refsRaw !== undefined) {
35
- const refs = refsRaw.split(/\s+/).filter(Boolean);
36
- if (refs.length > 0)
37
- result.references = refs;
38
- }
39
- const inReplyTo = headers.get("in-reply-to");
40
- if (inReplyTo !== undefined)
41
- result.inReplyTo = inReplyTo;
42
- const subject = headers.get("subject");
43
- if (subject !== undefined)
44
- result.subject = subject;
45
- const listId = headers.get("list-id");
46
- if (listId !== undefined)
47
- result.listId = listId;
48
- const rawType = headers.get("interchange-type");
49
- if (rawType !== undefined && isInterchangeType(rawType)) {
50
- result.interchangeType = rawType;
51
- }
52
- const corrId = headers.get("interchange-correlation-id");
53
- if (corrId !== undefined)
54
- result.interchangeCorrelationId = corrId;
55
- const tenantId = headers.get("interchange-tenant-id");
56
- if (tenantId !== undefined)
57
- result.interchangeTenantId = tenantId;
58
- const agentId = headers.get("interchange-agent-id");
59
- if (agentId !== undefined)
60
- result.interchangeAgentId = agentId;
61
- const sessionId = headers.get("interchange-session-id");
62
- if (sessionId !== undefined)
63
- result.interchangeSessionId = sessionId;
64
- const offeringId = headers.get("interchange-offering-id");
65
- if (offeringId !== undefined)
66
- result.interchangeOfferingId = offeringId;
67
- const schemaVersion = headers.get("interchange-schema-version");
68
- if (schemaVersion !== undefined)
69
- result.interchangeSchemaVersion = schemaVersion;
70
- const traceparent = headers.get("traceparent");
71
- if (traceparent !== undefined)
72
- result.traceparent = traceparent;
73
- const tracestate = headers.get("tracestate");
74
- if (tracestate !== undefined)
75
- result.tracestate = tracestate;
76
- return result;
77
- }
package/dist/search.d.ts DELETED
@@ -1,12 +0,0 @@
1
- import type { SearchQuery, MessageRef } from "@intx/types/runtime";
2
- import type { MailboxStore } from "./mailbox.js";
3
- /**
4
- * Execute an IMAP SEARCH-equivalent query over an in-memory mailbox.
5
- *
6
- * Supports: from, to, cc, bcc, header (field match), before/after/on,
7
- * sentBefore/sentAfter/sentOn, hasFlags, missingFlags, body, text,
8
- * largerThan, smallerThan, and boolean and/or/not composition.
9
- *
10
- * Returns MessageRef[] for all matching messages, ordered by UID.
11
- */
12
- export declare function executeSearch(mailboxName: string, store: MailboxStore, query: SearchQuery): MessageRef[];
package/dist/search.js DELETED
@@ -1,151 +0,0 @@
1
- import { parseHeaderSection } from "@intx/mime";
2
- /**
3
- * Execute an IMAP SEARCH-equivalent query over an in-memory mailbox.
4
- *
5
- * Supports: from, to, cc, bcc, header (field match), before/after/on,
6
- * sentBefore/sentAfter/sentOn, hasFlags, missingFlags, body, text,
7
- * largerThan, smallerThan, and boolean and/or/not composition.
8
- *
9
- * Returns MessageRef[] for all matching messages, ordered by UID.
10
- */
11
- export function executeSearch(mailboxName, store, query) {
12
- const results = [];
13
- for (const msg of store.messages) {
14
- if (matchMessage(msg, query)) {
15
- results.push({ uid: msg.uid, mailbox: mailboxName });
16
- }
17
- }
18
- return results;
19
- }
20
- function matchMessage(msg, query) {
21
- if (query.from !== undefined) {
22
- if (!msg.envelope.from.toLowerCase().includes(query.from.toLowerCase())) {
23
- return false;
24
- }
25
- }
26
- if (query.to !== undefined) {
27
- const queryTo = query.to;
28
- const toMatch = msg.envelope.to.some((addr) => addr.toLowerCase().includes(queryTo.toLowerCase()));
29
- if (!toMatch)
30
- return false;
31
- }
32
- if (query.cc !== undefined) {
33
- const headers = lazyHeaders(msg);
34
- const ccHeader = headers.get("cc") ?? "";
35
- if (!ccHeader.toLowerCase().includes(query.cc.toLowerCase())) {
36
- return false;
37
- }
38
- }
39
- if (query.bcc !== undefined) {
40
- const headers = lazyHeaders(msg);
41
- const bccHeader = headers.get("bcc") ?? "";
42
- if (!bccHeader.toLowerCase().includes(query.bcc.toLowerCase())) {
43
- return false;
44
- }
45
- }
46
- if (query.header !== undefined) {
47
- const { field, contains } = query.header;
48
- const headers = lazyHeaders(msg);
49
- const value = headers.get(field.toLowerCase()) ?? "";
50
- if (!value.toLowerCase().includes(contains.toLowerCase())) {
51
- return false;
52
- }
53
- }
54
- if (query.before !== undefined) {
55
- if (msg.envelope.date >= query.before)
56
- return false;
57
- }
58
- if (query.after !== undefined) {
59
- if (msg.envelope.date <= query.after)
60
- return false;
61
- }
62
- if (query.on !== undefined) {
63
- const d = msg.envelope.date;
64
- const q = query.on;
65
- if (d.getUTCFullYear() !== q.getUTCFullYear() ||
66
- d.getUTCMonth() !== q.getUTCMonth() ||
67
- d.getUTCDate() !== q.getUTCDate()) {
68
- return false;
69
- }
70
- }
71
- // Sent date filters use the Date header (same as envelope date here).
72
- if (query.sentBefore !== undefined) {
73
- if (msg.envelope.date >= query.sentBefore)
74
- return false;
75
- }
76
- if (query.sentAfter !== undefined) {
77
- if (msg.envelope.date <= query.sentAfter)
78
- return false;
79
- }
80
- if (query.sentOn !== undefined) {
81
- const d = msg.envelope.date;
82
- const q = query.sentOn;
83
- if (d.getUTCFullYear() !== q.getUTCFullYear() ||
84
- d.getUTCMonth() !== q.getUTCMonth() ||
85
- d.getUTCDate() !== q.getUTCDate()) {
86
- return false;
87
- }
88
- }
89
- if (query.hasFlags !== undefined) {
90
- for (const flag of query.hasFlags) {
91
- if (!msg.flags.has(flag))
92
- return false;
93
- }
94
- }
95
- if (query.missingFlags !== undefined) {
96
- for (const flag of query.missingFlags) {
97
- if (msg.flags.has(flag))
98
- return false;
99
- }
100
- }
101
- if (query.largerThan !== undefined) {
102
- if (msg.raw.length <= query.largerThan)
103
- return false;
104
- }
105
- if (query.smallerThan !== undefined) {
106
- if (msg.raw.length >= query.smallerThan)
107
- return false;
108
- }
109
- if (query.body !== undefined || query.text !== undefined) {
110
- const rawText = new TextDecoder("utf-8", { fatal: false }).decode(msg.raw);
111
- if (query.body !== undefined) {
112
- const { bodyOffset } = parseHeaderSection(msg.raw);
113
- const bodyText = new TextDecoder("utf-8", { fatal: false }).decode(msg.raw.slice(bodyOffset));
114
- if (!bodyText.toLowerCase().includes(query.body.toLowerCase())) {
115
- return false;
116
- }
117
- }
118
- if (query.text !== undefined) {
119
- if (!rawText.toLowerCase().includes(query.text.toLowerCase())) {
120
- return false;
121
- }
122
- }
123
- }
124
- if (query.and !== undefined) {
125
- for (const sub of query.and) {
126
- if (!matchMessage(msg, sub))
127
- return false;
128
- }
129
- }
130
- if (query.or !== undefined) {
131
- if (query.or.length > 0) {
132
- const anyMatch = query.or.some((sub) => matchMessage(msg, sub));
133
- if (!anyMatch)
134
- return false;
135
- }
136
- }
137
- if (query.not !== undefined) {
138
- if (matchMessage(msg, query.not))
139
- return false;
140
- }
141
- return true;
142
- }
143
- const headerCache = new WeakMap();
144
- function lazyHeaders(msg) {
145
- const cached = headerCache.get(msg);
146
- if (cached !== undefined)
147
- return cached;
148
- const { headers } = parseHeaderSection(msg.raw);
149
- headerCache.set(msg, headers);
150
- return headers;
151
- }
package/dist/thread.d.ts DELETED
@@ -1,3 +0,0 @@
1
- import type { Thread, SearchQuery } from "@intx/types/runtime";
2
- import type { MailboxStore } from "./mailbox.js";
3
- export declare function executeThread(mailboxName: string, store: MailboxStore, algorithm: "references" | "orderedsubject", query?: SearchQuery): Thread[];
package/dist/thread.js DELETED
@@ -1,204 +0,0 @@
1
- import { executeSearch } from "./search.js";
2
- export function executeThread(mailboxName, store, algorithm, query) {
3
- let messages;
4
- if (query !== undefined) {
5
- const refs = executeSearch(mailboxName, store, query);
6
- const uidSet = new Set(refs.map((r) => r.uid));
7
- messages = store.messages.filter((m) => uidSet.has(m.uid));
8
- }
9
- else {
10
- messages = [...store.messages];
11
- }
12
- if (messages.length === 0)
13
- return [];
14
- if (algorithm === "orderedsubject") {
15
- return orderedSubjectThread(mailboxName, messages);
16
- }
17
- return referencesThread(mailboxName, messages);
18
- }
19
- /**
20
- * RFC 5256 ORDEREDSUBJECT: sort by base subject, then date.
21
- * All messages with the same base subject form one thread; the first by date
22
- * is the root, the rest are direct children.
23
- */
24
- function orderedSubjectThread(mailboxName, messages) {
25
- const bySubject = new Map();
26
- for (const msg of messages) {
27
- const base = baseSubject(msg.envelope.subject);
28
- const bucket = bySubject.get(base);
29
- if (bucket === undefined) {
30
- bySubject.set(base, [msg]);
31
- }
32
- else {
33
- bucket.push(msg);
34
- }
35
- }
36
- const threads = [];
37
- for (const [, msgs] of bySubject) {
38
- const sorted = msgs.sort((a, b) => a.envelope.date.getTime() - b.envelope.date.getTime());
39
- const root = sorted[0];
40
- const rootThread = {
41
- ref: { uid: root.uid, mailbox: mailboxName },
42
- children: sorted.slice(1).map((m) => ({
43
- ref: { uid: m.uid, mailbox: mailboxName },
44
- children: [],
45
- })),
46
- };
47
- threads.push(rootThread);
48
- }
49
- return threads.sort((a, b) => {
50
- const aMsg = messages.find((m) => m.uid === a.ref.uid);
51
- const bMsg = messages.find((m) => m.uid === b.ref.uid);
52
- return aMsg.envelope.date.getTime() - bMsg.envelope.date.getTime();
53
- });
54
- }
55
- /**
56
- * RFC 5256 REFERENCES algorithm.
57
- *
58
- * Step 1: For each message, create a container. Walk its References list
59
- * (and In-Reply-To if not already in References) and link containers
60
- * as parent-child in left-to-right order.
61
- *
62
- * Step 2: Build the id_table mapping Message-IDs to containers.
63
- *
64
- * Step 3: Prune empty containers (those with no message).
65
- *
66
- * Step 4: Collect root containers.
67
- *
68
- * Step 5: Sort each container's children by date.
69
- */
70
- function referencesThread(mailboxName, messages) {
71
- const idTable = new Map();
72
- function getOrCreate(msgId) {
73
- const existing = idTable.get(msgId);
74
- if (existing !== undefined)
75
- return existing;
76
- const c = {
77
- messageId: msgId,
78
- message: null,
79
- parent: null,
80
- children: [],
81
- };
82
- idTable.set(msgId, c);
83
- return c;
84
- }
85
- // Step 1 & 2: Build containers and link parent-child relationships.
86
- for (const msg of messages) {
87
- const container = getOrCreate(msg.envelope.messageId);
88
- container.message = msg;
89
- // Build the reference list: References + In-Reply-To (deduplicated).
90
- const refs = buildRefList(msg.envelope.references, msg.envelope.inReplyTo);
91
- // Link: refs[i] is parent of refs[i+1], last ref is parent of this message.
92
- let prevContainer = null;
93
- for (const refId of refs) {
94
- const refContainer = getOrCreate(refId);
95
- if (prevContainer !== null &&
96
- refContainer.parent === null &&
97
- !isAncestor(refContainer, prevContainer)) {
98
- prevContainer.children.push(refContainer);
99
- refContainer.parent = prevContainer;
100
- }
101
- prevContainer = refContainer;
102
- }
103
- // Link the last reference as parent of this message (if no circular reference).
104
- if (prevContainer !== null &&
105
- container.parent === null &&
106
- !isAncestor(container, prevContainer)) {
107
- prevContainer.children.push(container);
108
- container.parent = prevContainer;
109
- }
110
- }
111
- // Step 3: Find root containers (no parent).
112
- const roots = [];
113
- for (const [, c] of idTable) {
114
- if (c.parent === null) {
115
- roots.push(c);
116
- }
117
- }
118
- // Step 4: Prune dummy containers (containers with no message).
119
- // A dummy with no children is dropped.
120
- // A dummy with children: the children are promoted to the dummy's parent level.
121
- const prunedRoots = pruneContainers(roots);
122
- // Step 5: Sort and convert to Thread[].
123
- return containersToThreads(mailboxName, prunedRoots);
124
- }
125
- function buildRefList(references, inReplyTo) {
126
- const seen = new Set();
127
- const result = [];
128
- for (const ref of references) {
129
- if (ref && !seen.has(ref)) {
130
- seen.add(ref);
131
- result.push(ref);
132
- }
133
- }
134
- if (inReplyTo !== undefined && inReplyTo !== "" && !seen.has(inReplyTo)) {
135
- result.push(inReplyTo);
136
- }
137
- return result;
138
- }
139
- function isAncestor(potentialAncestor, of) {
140
- let cur = of;
141
- while (cur !== null) {
142
- if (cur === potentialAncestor)
143
- return true;
144
- cur = cur.parent;
145
- }
146
- return false;
147
- }
148
- function pruneContainers(containers) {
149
- const result = [];
150
- for (const c of containers) {
151
- if (c.message === null && c.children.length === 0) {
152
- // Dummy with no children: drop it.
153
- continue;
154
- }
155
- if (c.message === null && c.children.length > 0) {
156
- // Dummy with children: promote children (skip the dummy).
157
- const promotedChildren = pruneContainers(c.children);
158
- result.push(...promotedChildren);
159
- }
160
- else {
161
- // Real message: recurse into children.
162
- c.children = pruneContainers(c.children);
163
- result.push(c);
164
- }
165
- }
166
- return result;
167
- }
168
- function containerDate(c) {
169
- if (c.message !== null) {
170
- return c.message.envelope.date.getTime();
171
- }
172
- // For dummy containers, use the earliest child date.
173
- let earliest = Infinity;
174
- for (const child of c.children) {
175
- const d = containerDate(child);
176
- if (d < earliest)
177
- earliest = d;
178
- }
179
- return earliest === Infinity ? 0 : earliest;
180
- }
181
- function containersToThreads(mailboxName, containers) {
182
- // Sort by date of the container (or earliest descendant for dummies).
183
- const sorted = containers.sort((a, b) => containerDate(a) - containerDate(b));
184
- return sorted
185
- .filter((c) => c.message !== null)
186
- .map((c) => ({
187
- ref: { uid: c.message.uid, mailbox: mailboxName },
188
- children: containersToThreads(mailboxName, c.children),
189
- }));
190
- }
191
- function baseSubject(subject) {
192
- // Strip "Re:", "Fwd:", "Fw:" prefixes (case-insensitive) repeatedly.
193
- let s = subject.trim();
194
- let changed = true;
195
- while (changed) {
196
- changed = false;
197
- const m = s.match(/^(?:re|fwd?)\s*:\s*/i);
198
- if (m !== null) {
199
- s = s.slice(m[0].length).trim();
200
- changed = true;
201
- }
202
- }
203
- return s;
204
- }