@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/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
|
+
}
|