agent-inbox 0.2.4 → 0.2.5
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/CLAUDE.md +1 -92
- package/README.md +6 -73
- package/dist/federation/connection-manager.d.ts +0 -8
- package/dist/federation/connection-manager.d.ts.map +1 -1
- package/dist/federation/connection-manager.js +0 -12
- package/dist/federation/connection-manager.js.map +1 -1
- package/dist/federation/delivery-queue.d.ts +3 -11
- package/dist/federation/delivery-queue.d.ts.map +1 -1
- package/dist/federation/delivery-queue.js +8 -38
- package/dist/federation/delivery-queue.js.map +1 -1
- package/dist/index.d.ts +0 -17
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -98
- package/dist/index.js.map +1 -1
- package/dist/jsonrpc/mail-push-types.d.ts +22 -2
- package/dist/jsonrpc/mail-push-types.d.ts.map +1 -1
- package/dist/jsonrpc/mail-push-types.js +18 -1
- package/dist/jsonrpc/mail-push-types.js.map +1 -1
- package/dist/jsonrpc/mail-push.d.ts +12 -1
- package/dist/jsonrpc/mail-push.d.ts.map +1 -1
- package/dist/jsonrpc/mail-push.js +13 -2
- package/dist/jsonrpc/mail-push.js.map +1 -1
- package/dist/jsonrpc/mail-server.d.ts.map +1 -1
- package/dist/jsonrpc/mail-server.js +42 -18
- package/dist/jsonrpc/mail-server.js.map +1 -1
- package/dist/router/message-router.d.ts +0 -15
- package/dist/router/message-router.d.ts.map +1 -1
- package/dist/router/message-router.js +3 -25
- package/dist/router/message-router.js.map +1 -1
- package/dist/storage/interface.d.ts +2 -9
- package/dist/storage/interface.d.ts.map +1 -1
- package/dist/storage/memory.d.ts +1 -4
- package/dist/storage/memory.d.ts.map +1 -1
- package/dist/storage/memory.js +6 -12
- package/dist/storage/memory.js.map +1 -1
- package/dist/storage/sqlite.d.ts +1 -6
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +6 -28
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/types.d.ts +0 -79
- package/dist/types.d.ts.map +1 -1
- package/docs/DESIGN.md +0 -15
- package/package.json +3 -28
- package/rules/agent-inbox.md +0 -1
- package/src/federation/connection-manager.ts +0 -12
- package/src/federation/delivery-queue.ts +8 -38
- package/src/index.ts +0 -148
- package/src/jsonrpc/mail-push-types.ts +43 -2
- package/src/jsonrpc/mail-push.ts +34 -3
- package/src/jsonrpc/mail-server.ts +45 -26
- package/src/router/message-router.ts +4 -41
- package/src/storage/interface.ts +2 -11
- package/src/storage/memory.ts +8 -15
- package/src/storage/sqlite.ts +9 -36
- package/src/types.ts +0 -73
- package/test/load.test.ts +1 -1
- package/test/mail-push.test.ts +101 -1
- package/test/mail-server.test.ts +108 -0
- package/AGENTS.md +0 -18
- package/dist/federation/queue-store.d.ts +0 -42
- package/dist/federation/queue-store.d.ts.map +0 -1
- package/dist/federation/queue-store.js +0 -87
- package/dist/federation/queue-store.js.map +0 -1
- package/dist/index.d.mts +0 -2
- package/dist/index.mjs +0 -1
- package/dist/index.mjs.map +0 -1
- package/dist/mail/address-book.d.ts +0 -43
- package/dist/mail/address-book.d.ts.map +0 -1
- package/dist/mail/address-book.js +0 -95
- package/dist/mail/address-book.js.map +0 -1
- package/dist/mail/attachment-store.d.ts +0 -31
- package/dist/mail/attachment-store.d.ts.map +0 -1
- package/dist/mail/attachment-store.js +0 -74
- package/dist/mail/attachment-store.js.map +0 -1
- package/dist/mail/email-mapper.d.ts +0 -41
- package/dist/mail/email-mapper.d.ts.map +0 -1
- package/dist/mail/email-mapper.js +0 -216
- package/dist/mail/email-mapper.js.map +0 -1
- package/dist/mail/fs-attachment-store.d.ts +0 -38
- package/dist/mail/fs-attachment-store.d.ts.map +0 -1
- package/dist/mail/fs-attachment-store.js +0 -165
- package/dist/mail/fs-attachment-store.js.map +0 -1
- package/dist/mail/mail-gateway.d.ts +0 -114
- package/dist/mail/mail-gateway.d.ts.map +0 -1
- package/dist/mail/mail-gateway.js +0 -402
- package/dist/mail/mail-gateway.js.map +0 -1
- package/dist/mail/provider-transport.d.ts +0 -138
- package/dist/mail/provider-transport.d.ts.map +0 -1
- package/dist/mail/provider-transport.js +0 -434
- package/dist/mail/provider-transport.js.map +0 -1
- package/dist/mail/rate-limiter.d.ts +0 -20
- package/dist/mail/rate-limiter.d.ts.map +0 -1
- package/dist/mail/rate-limiter.js +0 -56
- package/dist/mail/rate-limiter.js.map +0 -1
- package/dist/mail/smtp-transport.d.ts +0 -141
- package/dist/mail/smtp-transport.d.ts.map +0 -1
- package/dist/mail/smtp-transport.js +0 -415
- package/dist/mail/smtp-transport.js.map +0 -1
- package/dist/mail/types.d.ts +0 -177
- package/dist/mail/types.d.ts.map +0 -1
- package/dist/mail/types.js +0 -11
- package/dist/mail/types.js.map +0 -1
- package/dist/router/destination.d.ts +0 -69
- package/dist/router/destination.d.ts.map +0 -1
- package/dist/router/destination.js +0 -106
- package/dist/router/destination.js.map +0 -1
- package/docs/MAIL-INTEROP-PLAN.md +0 -660
- package/renovate.json5 +0 -6
- package/src/federation/queue-store.ts +0 -124
- package/src/mail/address-book.ts +0 -111
- package/src/mail/attachment-store.ts +0 -90
- package/src/mail/email-mapper.ts +0 -288
- package/src/mail/fs-attachment-store.ts +0 -163
- package/src/mail/mail-gateway.ts +0 -505
- package/src/mail/provider-transport.ts +0 -577
- package/src/mail/rate-limiter.ts +0 -51
- package/src/mail/smtp-transport.ts +0 -589
- package/src/mail/types.ts +0 -221
- package/src/router/destination.ts +0 -140
- package/test/federation/delivery-queue-sqlite.test.ts +0 -158
- package/test/mail/address-book.test.ts +0 -111
- package/test/mail/attachment-store-contract.test.ts +0 -92
- package/test/mail/attachment-store.test.ts +0 -69
- package/test/mail/destination.test.ts +0 -115
- package/test/mail/dsn-parse.test.ts +0 -239
- package/test/mail/email-mapper.test.ts +0 -341
- package/test/mail/external-id.test.ts +0 -43
- package/test/mail/fs-attachment-store.test.ts +0 -134
- package/test/mail/full-flow-e2e.test.ts +0 -200
- package/test/mail/mail-gateway.test.ts +0 -419
- package/test/mail/mail-transport-contract.test.ts +0 -134
- package/test/mail/mock-mail.ts +0 -161
- package/test/mail/mock-postmark.ts +0 -66
- package/test/mail/provider-transport.test.ts +0 -381
- package/test/mail/rate-limiter.test.ts +0 -48
- package/test/mail/router-mail-integration.test.ts +0 -138
- package/test/mail/smtp-e2e.test.ts +0 -98
- package/test/mail/smtp-transport.test.ts +0 -138
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Durable persistence for the delivery queue.
|
|
3
|
-
*
|
|
4
|
-
* The DeliveryQueue keeps an in-memory index for fast reads and write-through
|
|
5
|
-
* journals every mutation to a QueueStore. On construction it hydrates the
|
|
6
|
-
* in-memory index from the store, so queued messages survive a restart.
|
|
7
|
-
*
|
|
8
|
-
* Two impls: the default in-memory behavior needs no store (pass none), and
|
|
9
|
-
* SqliteQueueStore backs durable mode (DeliveryQueueConfig.persistence = "sqlite").
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import type Database from "better-sqlite3";
|
|
13
|
-
import type { QueuedMessage } from "../types.js";
|
|
14
|
-
|
|
15
|
-
export interface QueueStore {
|
|
16
|
-
/** Insert or replace an entry. */
|
|
17
|
-
upsert(entry: QueuedMessage): void;
|
|
18
|
-
/** Remove a single entry by peer + message id. */
|
|
19
|
-
remove(peerId: string, messageId: string): void;
|
|
20
|
-
/** Remove all entries for a peer (e.g. on flush). */
|
|
21
|
-
removePeer(peerId: string): void;
|
|
22
|
-
/** Load all persisted entries (called once on hydrate). */
|
|
23
|
-
loadAll(): QueuedMessage[];
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** SQLite-backed durable queue store using a borrowed better-sqlite3 handle. */
|
|
27
|
-
export class SqliteQueueStore implements QueueStore {
|
|
28
|
-
private table: string;
|
|
29
|
-
private upsertStmt: Database.Statement;
|
|
30
|
-
private removeStmt: Database.Statement;
|
|
31
|
-
private removePeerStmt: Database.Statement;
|
|
32
|
-
private loadStmt: Database.Statement;
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* @param db A better-sqlite3 handle (lifecycle owned by the caller).
|
|
36
|
-
* @param prefix Table name prefix to match the Storage convention. Default "".
|
|
37
|
-
*/
|
|
38
|
-
constructor(
|
|
39
|
-
private db: Database.Database,
|
|
40
|
-
prefix: string = ""
|
|
41
|
-
) {
|
|
42
|
-
this.table = `${prefix}delivery_queue`;
|
|
43
|
-
this.migrate();
|
|
44
|
-
this.upsertStmt = this.db.prepare(
|
|
45
|
-
`INSERT INTO ${this.table}
|
|
46
|
-
(id, peer_id, message, enqueued_at, attempts, last_attempt, next_retry)
|
|
47
|
-
VALUES (@id, @peer_id, @message, @enqueued_at, @attempts, @last_attempt, @next_retry)
|
|
48
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
49
|
-
peer_id = excluded.peer_id,
|
|
50
|
-
message = excluded.message,
|
|
51
|
-
enqueued_at = excluded.enqueued_at,
|
|
52
|
-
attempts = excluded.attempts,
|
|
53
|
-
last_attempt = excluded.last_attempt,
|
|
54
|
-
next_retry = excluded.next_retry`
|
|
55
|
-
);
|
|
56
|
-
this.removeStmt = this.db.prepare(
|
|
57
|
-
`DELETE FROM ${this.table} WHERE peer_id = ? AND id = ?`
|
|
58
|
-
);
|
|
59
|
-
this.removePeerStmt = this.db.prepare(
|
|
60
|
-
`DELETE FROM ${this.table} WHERE peer_id = ?`
|
|
61
|
-
);
|
|
62
|
-
this.loadStmt = this.db.prepare(
|
|
63
|
-
`SELECT id, peer_id, message, enqueued_at, attempts, last_attempt, next_retry
|
|
64
|
-
FROM ${this.table} ORDER BY enqueued_at ASC`
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
private migrate(): void {
|
|
69
|
-
this.db.exec(`
|
|
70
|
-
CREATE TABLE IF NOT EXISTS ${this.table} (
|
|
71
|
-
id TEXT PRIMARY KEY,
|
|
72
|
-
peer_id TEXT NOT NULL,
|
|
73
|
-
message TEXT NOT NULL,
|
|
74
|
-
enqueued_at TEXT NOT NULL,
|
|
75
|
-
attempts INTEGER NOT NULL DEFAULT 0,
|
|
76
|
-
last_attempt TEXT,
|
|
77
|
-
next_retry TEXT
|
|
78
|
-
);
|
|
79
|
-
CREATE INDEX IF NOT EXISTS ${this.table}_peer_idx
|
|
80
|
-
ON ${this.table}(peer_id);
|
|
81
|
-
`);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
upsert(entry: QueuedMessage): void {
|
|
85
|
-
this.upsertStmt.run({
|
|
86
|
-
id: entry.id,
|
|
87
|
-
peer_id: entry.peerId,
|
|
88
|
-
message: JSON.stringify(entry.message),
|
|
89
|
-
enqueued_at: entry.enqueuedAt,
|
|
90
|
-
attempts: entry.attempts,
|
|
91
|
-
last_attempt: entry.lastAttempt ?? null,
|
|
92
|
-
next_retry: entry.nextRetry ?? null,
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
remove(peerId: string, messageId: string): void {
|
|
97
|
-
this.removeStmt.run(peerId, messageId);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
removePeer(peerId: string): void {
|
|
101
|
-
this.removePeerStmt.run(peerId);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
loadAll(): QueuedMessage[] {
|
|
105
|
-
const rows = this.loadStmt.all() as Array<{
|
|
106
|
-
id: string;
|
|
107
|
-
peer_id: string;
|
|
108
|
-
message: string;
|
|
109
|
-
enqueued_at: string;
|
|
110
|
-
attempts: number;
|
|
111
|
-
last_attempt: string | null;
|
|
112
|
-
next_retry: string | null;
|
|
113
|
-
}>;
|
|
114
|
-
return rows.map((r) => ({
|
|
115
|
-
id: r.id,
|
|
116
|
-
peerId: r.peer_id,
|
|
117
|
-
message: JSON.parse(r.message),
|
|
118
|
-
enqueuedAt: r.enqueued_at,
|
|
119
|
-
attempts: r.attempts,
|
|
120
|
-
lastAttempt: r.last_attempt ?? undefined,
|
|
121
|
-
nextRetry: r.next_retry ?? undefined,
|
|
122
|
-
}));
|
|
123
|
-
}
|
|
124
|
-
}
|
package/src/mail/address-book.ts
DELETED
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Mail identity / address book.
|
|
3
|
-
*
|
|
4
|
-
* Maps between agent ids and external email addresses, and declares which
|
|
5
|
-
* domains we own (for inbound RCPT acceptance). Plus-addressing aware:
|
|
6
|
-
* `agent+scope@domain` resolves to agent `agent` in inbox scope `scope`.
|
|
7
|
-
*
|
|
8
|
-
* See docs/MAIL-INTEROP-PLAN.md §6.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import type { MailIdentityConfig } from "../types.js";
|
|
12
|
-
import type { MailAddress } from "./types.js";
|
|
13
|
-
|
|
14
|
-
export interface ResolvedInbound {
|
|
15
|
-
agentId: string;
|
|
16
|
-
/** Inbox scope from plus-addressing, if present. */
|
|
17
|
-
scope?: string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export class AddressBook {
|
|
21
|
-
private localDomains: string[];
|
|
22
|
-
/** lower-cased address → agentId */
|
|
23
|
-
private byAddress = new Map<string, string>();
|
|
24
|
-
/** agentId → canonical address */
|
|
25
|
-
private byAgent = new Map<string, string>();
|
|
26
|
-
private catchAllAgentId?: string;
|
|
27
|
-
/** Address mappings that collided (same address → multiple agents). */
|
|
28
|
-
readonly conflicts: string[] = [];
|
|
29
|
-
|
|
30
|
-
constructor(config: MailIdentityConfig) {
|
|
31
|
-
this.localDomains = config.localDomains.map((d) => d.toLowerCase());
|
|
32
|
-
this.catchAllAgentId = config.catchAllAgentId;
|
|
33
|
-
for (const { agentId, address } of config.mappings) {
|
|
34
|
-
const lower = address.toLowerCase();
|
|
35
|
-
const existing = this.byAddress.get(lower);
|
|
36
|
-
if (existing !== undefined && existing !== agentId) {
|
|
37
|
-
// Two agents mapped to the same address — inbound routing would be
|
|
38
|
-
// ambiguous. Keep the first and record the collision for surfacing.
|
|
39
|
-
this.conflicts.push(`${address} → ${existing} & ${agentId}`);
|
|
40
|
-
continue;
|
|
41
|
-
}
|
|
42
|
-
this.byAddress.set(lower, agentId);
|
|
43
|
-
// First mapping for an agent is its canonical from-address.
|
|
44
|
-
if (!this.byAgent.has(agentId)) this.byAgent.set(agentId, address);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** Primary local domain — used to mint Message-IDs and default from-addresses. */
|
|
49
|
-
primaryDomain(): string {
|
|
50
|
-
return this.localDomains[0] ?? "localhost";
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** True if `domain` (or a parent of it) is one we receive mail for. */
|
|
54
|
-
ownsDomain(domain: string): boolean {
|
|
55
|
-
const d = domain.toLowerCase();
|
|
56
|
-
return this.localDomains.some(
|
|
57
|
-
(own) => d === own || d.endsWith(`.${own}`)
|
|
58
|
-
);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Resolve an inbound recipient email to a local agent (+ scope).
|
|
63
|
-
* Returns undefined if the domain is not ours, or the mailbox is unknown and
|
|
64
|
-
* no catch-all is configured.
|
|
65
|
-
*/
|
|
66
|
-
resolveInbound(address: string): ResolvedInbound | undefined {
|
|
67
|
-
const parsed = splitAddress(address);
|
|
68
|
-
if (!parsed) return undefined;
|
|
69
|
-
const { local, domain } = parsed;
|
|
70
|
-
|
|
71
|
-
if (!this.ownsDomain(domain)) return undefined;
|
|
72
|
-
|
|
73
|
-
// Plus-addressing: agent+scope → base agent, scope.
|
|
74
|
-
const plusIdx = local.indexOf("+");
|
|
75
|
-
const base = plusIdx === -1 ? local : local.slice(0, plusIdx);
|
|
76
|
-
const scope = plusIdx === -1 ? undefined : local.slice(plusIdx + 1) || undefined;
|
|
77
|
-
|
|
78
|
-
const canonical = `${base}@${domain}`.toLowerCase();
|
|
79
|
-
const agentId = this.byAddress.get(canonical);
|
|
80
|
-
if (agentId) return { agentId, scope };
|
|
81
|
-
|
|
82
|
-
if (this.catchAllAgentId) return { agentId: this.catchAllAgentId, scope };
|
|
83
|
-
return undefined;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Resolve the from-address for an outbound sender agent. Falls back to
|
|
88
|
-
* `<agentId>@<primaryDomain>` when the agent has no explicit mapping.
|
|
89
|
-
*/
|
|
90
|
-
resolveFrom(agentId: string): MailAddress {
|
|
91
|
-
const mapped = this.byAgent.get(agentId);
|
|
92
|
-
if (mapped) return { address: mapped };
|
|
93
|
-
return { address: `${agentId}@${this.primaryDomain()}` };
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** Split an addr-spec into local-part and domain. Returns undefined if malformed. */
|
|
98
|
-
function splitAddress(
|
|
99
|
-
address: string
|
|
100
|
-
): { local: string; domain: string } | undefined {
|
|
101
|
-
const at = address.indexOf("@");
|
|
102
|
-
if (at <= 0 || at !== address.lastIndexOf("@") || at === address.length - 1) {
|
|
103
|
-
// Reject empty local/domain and unquoted multiple "@" (e.g. "a@b@c"), so
|
|
104
|
-
// this layer agrees with parseAddress (which splits on the first "@").
|
|
105
|
-
return undefined;
|
|
106
|
-
}
|
|
107
|
-
const domain = address.slice(at + 1);
|
|
108
|
-
// A domain must not contain "/" (a federation scope separator) — defensive.
|
|
109
|
-
if (domain.includes("/")) return undefined;
|
|
110
|
-
return { local: address.slice(0, at), domain };
|
|
111
|
-
}
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Attachment byte stores.
|
|
3
|
-
*
|
|
4
|
-
* `MailAttachment.contentRef` is opaque; bytes live behind an AttachmentStore.
|
|
5
|
-
* Default impl is SQLite-backed (atomic with the message, single-file backup,
|
|
6
|
-
* works with the in-memory DB used in tests). A filesystem impl is opt-in for
|
|
7
|
-
* large/high-volume deployments (added later). Refs are content-addressed
|
|
8
|
-
* (sha256) so identical bytes dedup. See docs/MAIL-INTEROP-PLAN.md §4c.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import type Database from "better-sqlite3";
|
|
12
|
-
import { createHash } from "node:crypto";
|
|
13
|
-
import type { AttachmentStore } from "./types.js";
|
|
14
|
-
|
|
15
|
-
export class SqliteAttachmentStore implements AttachmentStore {
|
|
16
|
-
private table: string;
|
|
17
|
-
private putStmt: Database.Statement;
|
|
18
|
-
private getStmt: Database.Statement;
|
|
19
|
-
private delStmt: Database.Statement;
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* @param db A better-sqlite3 handle (lifecycle owned by the caller).
|
|
23
|
-
* @param prefix Table name prefix to match the Storage convention. Default "".
|
|
24
|
-
*/
|
|
25
|
-
constructor(
|
|
26
|
-
private db: Database.Database,
|
|
27
|
-
prefix: string = ""
|
|
28
|
-
) {
|
|
29
|
-
this.table = `${prefix}mail_attachments`;
|
|
30
|
-
this.migrate();
|
|
31
|
-
this.putStmt = this.db.prepare(
|
|
32
|
-
`INSERT INTO ${this.table} (ref, content_type, filename, bytes, size_bytes, created_at)
|
|
33
|
-
VALUES (@ref, @content_type, @filename, @bytes, @size_bytes, @created_at)
|
|
34
|
-
ON CONFLICT(ref) DO NOTHING`
|
|
35
|
-
);
|
|
36
|
-
this.getStmt = this.db.prepare(
|
|
37
|
-
`SELECT bytes FROM ${this.table} WHERE ref = ?`
|
|
38
|
-
);
|
|
39
|
-
this.delStmt = this.db.prepare(`DELETE FROM ${this.table} WHERE ref = ?`);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
private migrate(): void {
|
|
43
|
-
this.db.exec(`
|
|
44
|
-
CREATE TABLE IF NOT EXISTS ${this.table} (
|
|
45
|
-
ref TEXT PRIMARY KEY,
|
|
46
|
-
content_type TEXT NOT NULL,
|
|
47
|
-
filename TEXT,
|
|
48
|
-
bytes BLOB NOT NULL,
|
|
49
|
-
size_bytes INTEGER NOT NULL,
|
|
50
|
-
created_at TEXT NOT NULL
|
|
51
|
-
);
|
|
52
|
-
`);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async put(
|
|
56
|
-
bytes: Buffer,
|
|
57
|
-
meta: { contentType: string; filename?: string }
|
|
58
|
-
): Promise<string> {
|
|
59
|
-
const ref = "sha256:" + createHash("sha256").update(bytes).digest("hex");
|
|
60
|
-
// ON CONFLICT DO NOTHING — content-addressed, so identical bytes are deduped.
|
|
61
|
-
this.putStmt.run({
|
|
62
|
-
ref,
|
|
63
|
-
content_type: meta.contentType,
|
|
64
|
-
filename: meta.filename ?? null,
|
|
65
|
-
bytes,
|
|
66
|
-
size_bytes: bytes.length,
|
|
67
|
-
created_at: new Date().toISOString(),
|
|
68
|
-
});
|
|
69
|
-
return ref;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
async get(ref: string): Promise<Buffer> {
|
|
73
|
-
assertValidRef(ref);
|
|
74
|
-
const row = this.getStmt.get(ref) as { bytes: Buffer } | undefined;
|
|
75
|
-
if (!row) throw new Error(`Attachment not found: ${ref}`);
|
|
76
|
-
return row.bytes;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
async delete(ref: string): Promise<void> {
|
|
80
|
-
assertValidRef(ref);
|
|
81
|
-
this.delStmt.run(ref);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** Refs are always sha256-of-content; reject anything else (parity with FS store). */
|
|
86
|
-
function assertValidRef(ref: string): void {
|
|
87
|
-
if (!/^sha256:[0-9a-f]{64}$/.test(ref)) {
|
|
88
|
-
throw new Error(`Invalid attachment ref: ${ref}`);
|
|
89
|
-
}
|
|
90
|
-
}
|
package/src/mail/email-mapper.ts
DELETED
|
@@ -1,288 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Bidirectional mapping between RFC 5322 email and agent-inbox Messages.
|
|
3
|
-
*
|
|
4
|
-
* Parallels src/mesh/type-mapper.ts. Two pure functions:
|
|
5
|
-
* - inboundMailToMessage: external email → inbox Message
|
|
6
|
-
* - messageToOutboundMail: inbox Message → rendered OutboundMail
|
|
7
|
-
*
|
|
8
|
-
* Threading: RFC Message-ID / In-Reply-To / References map onto the inbox's
|
|
9
|
-
* thread_tag + in_reply_to. The RFC Message-ID is preserved in metadata so a
|
|
10
|
-
* later reply can reconstruct the In-Reply-To / References chain. See §5.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { ulid } from "ulid";
|
|
14
|
-
import { createHash } from "node:crypto";
|
|
15
|
-
import { normalizeContent } from "../router/message-router.js";
|
|
16
|
-
import type { Message, Recipient, RecipientKind, Importance } from "../types.js";
|
|
17
|
-
import type { AddressBook } from "./address-book.js";
|
|
18
|
-
import type {
|
|
19
|
-
InboundMail,
|
|
20
|
-
OutboundMail,
|
|
21
|
-
MailAddress,
|
|
22
|
-
MailAttachment,
|
|
23
|
-
} from "./types.js";
|
|
24
|
-
|
|
25
|
-
// ---------------------------------------------------------------------------
|
|
26
|
-
// Contexts
|
|
27
|
-
// ---------------------------------------------------------------------------
|
|
28
|
-
|
|
29
|
-
export interface InboundMapContext {
|
|
30
|
-
addressBook: AddressBook;
|
|
31
|
-
/** Scope assigned when plus-addressing does not specify one. */
|
|
32
|
-
defaultScope?: string;
|
|
33
|
-
/**
|
|
34
|
-
* Resolve an RFC Message-ID we have already stored to its inbox Message id,
|
|
35
|
-
* so an inbound reply can set `in_reply_to` to the real parent.
|
|
36
|
-
*/
|
|
37
|
-
lookupInboxIdByRfcMessageId?: (rfcMessageId: string) => string | undefined;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface OutboundMapContext {
|
|
41
|
-
addressBook: AddressBook;
|
|
42
|
-
/** Domain used to mint Message-IDs. Defaults to the address book primary domain. */
|
|
43
|
-
messageIdDomain?: string;
|
|
44
|
-
/** The RFC Message-ID we recorded when the parent inbox message was sent/received. */
|
|
45
|
-
lookupRfcMessageId?: (inboxId: string) => string | undefined;
|
|
46
|
-
/** The References chain recorded for the parent inbox message. */
|
|
47
|
-
lookupReferences?: (inboxId: string) => string[] | undefined;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** Thrown when no inbound recipient resolves to a local agent. */
|
|
51
|
-
export class UnknownRecipientError extends Error {
|
|
52
|
-
constructor(public readonly recipients: string[]) {
|
|
53
|
-
super(
|
|
54
|
-
`No inbound recipient resolved to a local agent: ${recipients.join(", ")}`
|
|
55
|
-
);
|
|
56
|
-
this.name = "UnknownRecipientError";
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// ---------------------------------------------------------------------------
|
|
61
|
-
// Inbound: email → Message
|
|
62
|
-
// ---------------------------------------------------------------------------
|
|
63
|
-
|
|
64
|
-
export function inboundMailToMessage(
|
|
65
|
-
mail: InboundMail,
|
|
66
|
-
ctx: InboundMapContext
|
|
67
|
-
): Message {
|
|
68
|
-
const recipients: Recipient[] = [];
|
|
69
|
-
const unresolved: string[] = [];
|
|
70
|
-
let scope: string | undefined;
|
|
71
|
-
|
|
72
|
-
const addKind = (addrs: MailAddress[] | undefined, kind: RecipientKind) => {
|
|
73
|
-
for (const a of addrs ?? []) {
|
|
74
|
-
const resolved = ctx.addressBook.resolveInbound(a.address);
|
|
75
|
-
if (!resolved) {
|
|
76
|
-
unresolved.push(a.address);
|
|
77
|
-
continue;
|
|
78
|
-
}
|
|
79
|
-
recipients.push({ agent_id: resolved.agentId, kind });
|
|
80
|
-
// First resolved recipient with a plus-address scope wins. "to" is
|
|
81
|
-
// processed before "cc", so a to-scope still takes precedence, but a
|
|
82
|
-
// cc-only scoped recipient is no longer silently dropped.
|
|
83
|
-
if (scope === undefined && resolved.scope) {
|
|
84
|
-
scope = resolved.scope;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
addKind(mail.to, "to");
|
|
90
|
-
addKind(mail.cc, "cc");
|
|
91
|
-
|
|
92
|
-
if (recipients.length === 0) {
|
|
93
|
-
throw new UnknownRecipientError(unresolved);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const rfcMessageId = mail.headers.messageId;
|
|
97
|
-
const rfcInReplyTo = mail.headers.inReplyTo;
|
|
98
|
-
const rfcReferences = mail.headers.references;
|
|
99
|
-
|
|
100
|
-
const metadata: Record<string, unknown> = {};
|
|
101
|
-
if (rfcMessageId) metadata.rfcMessageId = rfcMessageId;
|
|
102
|
-
if (rfcInReplyTo) metadata.rfcInReplyTo = rfcInReplyTo;
|
|
103
|
-
if (rfcReferences) metadata.rfcReferences = rfcReferences;
|
|
104
|
-
if (mail.html) metadata.htmlBody = mail.html;
|
|
105
|
-
if (mail.authResults) metadata.authResults = mail.authResults;
|
|
106
|
-
if (mail.remote) metadata.remote = mail.remote;
|
|
107
|
-
if (mail.envelopeFrom) metadata.envelopeFrom = mail.envelopeFrom;
|
|
108
|
-
if (mail.bounce) metadata.bounce = mail.bounce;
|
|
109
|
-
if (mail.attachments?.length) metadata.mailAttachments = mail.attachments;
|
|
110
|
-
|
|
111
|
-
// Resolve in_reply_to to a real inbox id when we can; otherwise leave it unset
|
|
112
|
-
// (the RFC id is retained in metadata for later correlation).
|
|
113
|
-
let inReplyTo: string | undefined;
|
|
114
|
-
if (rfcInReplyTo && ctx.lookupInboxIdByRfcMessageId) {
|
|
115
|
-
inReplyTo = ctx.lookupInboxIdByRfcMessageId(rfcInReplyTo);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const text = mail.text ?? "";
|
|
119
|
-
|
|
120
|
-
return {
|
|
121
|
-
id: ulid(),
|
|
122
|
-
scope: scope ?? ctx.defaultScope ?? "default",
|
|
123
|
-
sender_id: mail.from.address,
|
|
124
|
-
recipients,
|
|
125
|
-
subject: mail.subject,
|
|
126
|
-
content: normalizeContent(text),
|
|
127
|
-
thread_tag: deriveThreadTag(rfcReferences, rfcInReplyTo, rfcMessageId),
|
|
128
|
-
in_reply_to: inReplyTo,
|
|
129
|
-
importance: importanceFromHeaders(mail.headers.raw),
|
|
130
|
-
metadata,
|
|
131
|
-
created_at: mail.receivedAt,
|
|
132
|
-
};
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// ---------------------------------------------------------------------------
|
|
136
|
-
// Outbound: Message → email
|
|
137
|
-
// ---------------------------------------------------------------------------
|
|
138
|
-
|
|
139
|
-
export function messageToOutboundMail(
|
|
140
|
-
msg: Message,
|
|
141
|
-
ctx: OutboundMapContext
|
|
142
|
-
): OutboundMail {
|
|
143
|
-
const domain = ctx.messageIdDomain ?? ctx.addressBook.primaryDomain();
|
|
144
|
-
|
|
145
|
-
const to = recipientsOfKind(msg, "to");
|
|
146
|
-
const cc = recipientsOfKind(msg, "cc");
|
|
147
|
-
const bcc = recipientsOfKind(msg, "bcc");
|
|
148
|
-
|
|
149
|
-
const { inReplyTo, references } = buildThreadingHeaders(msg, ctx);
|
|
150
|
-
|
|
151
|
-
const text = textFromContent(msg);
|
|
152
|
-
const html = typeof msg.metadata.htmlBody === "string"
|
|
153
|
-
? (msg.metadata.htmlBody as string)
|
|
154
|
-
: undefined;
|
|
155
|
-
|
|
156
|
-
const attachments = validateAttachments(msg.metadata.mailAttachments);
|
|
157
|
-
|
|
158
|
-
// Only forward an explicit, opt-in hints object to the backend. We do NOT
|
|
159
|
-
// dump arbitrary internal message metadata to the external mail provider.
|
|
160
|
-
const backendHints =
|
|
161
|
-
msg.metadata.mailBackendHints &&
|
|
162
|
-
typeof msg.metadata.mailBackendHints === "object"
|
|
163
|
-
? (msg.metadata.mailBackendHints as Record<string, unknown>)
|
|
164
|
-
: undefined;
|
|
165
|
-
|
|
166
|
-
return {
|
|
167
|
-
idempotencyKey: msg.id,
|
|
168
|
-
from: ctx.addressBook.resolveFrom(msg.sender_id),
|
|
169
|
-
to,
|
|
170
|
-
...(cc.length ? { cc } : {}),
|
|
171
|
-
...(bcc.length ? { bcc } : {}),
|
|
172
|
-
subject: msg.subject,
|
|
173
|
-
text,
|
|
174
|
-
...(html ? { html } : {}),
|
|
175
|
-
headers: {
|
|
176
|
-
messageId: mintMessageId(msg.id, domain),
|
|
177
|
-
...(inReplyTo ? { inReplyTo } : {}),
|
|
178
|
-
...(references && references.length ? { references } : {}),
|
|
179
|
-
},
|
|
180
|
-
...(attachments ? { attachments } : {}),
|
|
181
|
-
...(backendHints ? { backendHints } : {}),
|
|
182
|
-
};
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/** Validate untrusted attachment metadata, dropping malformed entries. */
|
|
186
|
-
function validateAttachments(value: unknown): MailAttachment[] | undefined {
|
|
187
|
-
if (!Array.isArray(value)) return undefined;
|
|
188
|
-
const out: MailAttachment[] = [];
|
|
189
|
-
for (const a of value) {
|
|
190
|
-
if (
|
|
191
|
-
a &&
|
|
192
|
-
typeof a === "object" &&
|
|
193
|
-
typeof (a as MailAttachment).contentRef === "string" &&
|
|
194
|
-
typeof (a as MailAttachment).contentType === "string"
|
|
195
|
-
) {
|
|
196
|
-
const att = a as MailAttachment;
|
|
197
|
-
out.push({
|
|
198
|
-
contentRef: att.contentRef,
|
|
199
|
-
contentType: att.contentType,
|
|
200
|
-
sizeBytes: typeof att.sizeBytes === "number" ? att.sizeBytes : 0,
|
|
201
|
-
...(typeof att.filename === "string" ? { filename: att.filename } : {}),
|
|
202
|
-
...(typeof att.contentId === "string" ? { contentId: att.contentId } : {}),
|
|
203
|
-
});
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
return out.length ? out : undefined;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// ---------------------------------------------------------------------------
|
|
210
|
-
// Helpers
|
|
211
|
-
// ---------------------------------------------------------------------------
|
|
212
|
-
|
|
213
|
-
function recipientsOfKind(msg: Message, kind: RecipientKind): MailAddress[] {
|
|
214
|
-
return msg.recipients
|
|
215
|
-
.filter((r) => r.kind === kind)
|
|
216
|
-
.map((r) => ({ address: r.agent_id }));
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/** Stable thread tag from the root of the References chain. */
|
|
220
|
-
function deriveThreadTag(
|
|
221
|
-
references: string[] | undefined,
|
|
222
|
-
inReplyTo: string | undefined,
|
|
223
|
-
messageId: string | undefined
|
|
224
|
-
): string | undefined {
|
|
225
|
-
const root =
|
|
226
|
-
(references && references.length > 0 ? references[0] : undefined) ??
|
|
227
|
-
inReplyTo ??
|
|
228
|
-
messageId;
|
|
229
|
-
if (!root) return undefined;
|
|
230
|
-
const hash = createHash("sha1").update(root).digest("hex").slice(0, 12);
|
|
231
|
-
return `mail-${hash}`;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
function buildThreadingHeaders(
|
|
235
|
-
msg: Message,
|
|
236
|
-
ctx: OutboundMapContext
|
|
237
|
-
): { inReplyTo?: string; references?: string[] } {
|
|
238
|
-
if (!msg.in_reply_to) {
|
|
239
|
-
// Fresh thread, but a recorded RFC References chain (e.g. from the inbound
|
|
240
|
-
// side) should still be carried if present in metadata.
|
|
241
|
-
const meta = msg.metadata.rfcReferences;
|
|
242
|
-
return { references: Array.isArray(meta) ? (meta as string[]) : undefined };
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
const domain = ctx.messageIdDomain ?? ctx.addressBook.primaryDomain();
|
|
246
|
-
const parentRfcId =
|
|
247
|
-
ctx.lookupRfcMessageId?.(msg.in_reply_to) ??
|
|
248
|
-
mintMessageId(msg.in_reply_to, domain);
|
|
249
|
-
|
|
250
|
-
const parentRefs =
|
|
251
|
-
ctx.lookupReferences?.(msg.in_reply_to) ??
|
|
252
|
-
(Array.isArray(msg.metadata.rfcReferences)
|
|
253
|
-
? (msg.metadata.rfcReferences as string[])
|
|
254
|
-
: []);
|
|
255
|
-
|
|
256
|
-
return {
|
|
257
|
-
inReplyTo: parentRfcId,
|
|
258
|
-
references: [...parentRefs, parentRfcId],
|
|
259
|
-
};
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
function mintMessageId(inboxId: string, domain: string): string {
|
|
263
|
-
return `<${inboxId}@${domain}>`;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
function textFromContent(msg: Message): string {
|
|
267
|
-
const c = msg.content;
|
|
268
|
-
if (c.type === "text" && typeof c.text === "string") return c.text;
|
|
269
|
-
return JSON.stringify(c);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
function importanceFromHeaders(
|
|
273
|
-
raw: Record<string, string | string[]>
|
|
274
|
-
): Importance {
|
|
275
|
-
const header = (k: string): string => {
|
|
276
|
-
const v = raw[k];
|
|
277
|
-
return (Array.isArray(v) ? v[0] : v ?? "").toString().toLowerCase();
|
|
278
|
-
};
|
|
279
|
-
const importance = header("importance");
|
|
280
|
-
const priority = header("x-priority");
|
|
281
|
-
if (importance === "high" || priority.startsWith("1") || priority.startsWith("2")) {
|
|
282
|
-
return "high";
|
|
283
|
-
}
|
|
284
|
-
if (importance === "low" || priority.startsWith("4") || priority.startsWith("5")) {
|
|
285
|
-
return "low";
|
|
286
|
-
}
|
|
287
|
-
return "normal";
|
|
288
|
-
}
|