agent-inbox 0.2.3 → 0.2.4

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.
Files changed (122) hide show
  1. package/AGENTS.md +18 -0
  2. package/CLAUDE.md +92 -1
  3. package/README.md +73 -6
  4. package/dist/federation/connection-manager.d.ts +8 -0
  5. package/dist/federation/connection-manager.d.ts.map +1 -1
  6. package/dist/federation/connection-manager.js +12 -0
  7. package/dist/federation/connection-manager.js.map +1 -1
  8. package/dist/federation/delivery-queue.d.ts +11 -3
  9. package/dist/federation/delivery-queue.d.ts.map +1 -1
  10. package/dist/federation/delivery-queue.js +38 -8
  11. package/dist/federation/delivery-queue.js.map +1 -1
  12. package/dist/federation/queue-store.d.ts +42 -0
  13. package/dist/federation/queue-store.d.ts.map +1 -0
  14. package/dist/federation/queue-store.js +87 -0
  15. package/dist/federation/queue-store.js.map +1 -0
  16. package/dist/index.d.mts +2 -0
  17. package/dist/index.d.ts +17 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +98 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/index.mjs +1 -0
  22. package/dist/index.mjs.map +1 -0
  23. package/dist/mail/address-book.d.ts +43 -0
  24. package/dist/mail/address-book.d.ts.map +1 -0
  25. package/dist/mail/address-book.js +95 -0
  26. package/dist/mail/address-book.js.map +1 -0
  27. package/dist/mail/attachment-store.d.ts +31 -0
  28. package/dist/mail/attachment-store.d.ts.map +1 -0
  29. package/dist/mail/attachment-store.js +74 -0
  30. package/dist/mail/attachment-store.js.map +1 -0
  31. package/dist/mail/email-mapper.d.ts +41 -0
  32. package/dist/mail/email-mapper.d.ts.map +1 -0
  33. package/dist/mail/email-mapper.js +216 -0
  34. package/dist/mail/email-mapper.js.map +1 -0
  35. package/dist/mail/fs-attachment-store.d.ts +38 -0
  36. package/dist/mail/fs-attachment-store.d.ts.map +1 -0
  37. package/dist/mail/fs-attachment-store.js +165 -0
  38. package/dist/mail/fs-attachment-store.js.map +1 -0
  39. package/dist/mail/mail-gateway.d.ts +114 -0
  40. package/dist/mail/mail-gateway.d.ts.map +1 -0
  41. package/dist/mail/mail-gateway.js +402 -0
  42. package/dist/mail/mail-gateway.js.map +1 -0
  43. package/dist/mail/provider-transport.d.ts +138 -0
  44. package/dist/mail/provider-transport.d.ts.map +1 -0
  45. package/dist/mail/provider-transport.js +434 -0
  46. package/dist/mail/provider-transport.js.map +1 -0
  47. package/dist/mail/rate-limiter.d.ts +20 -0
  48. package/dist/mail/rate-limiter.d.ts.map +1 -0
  49. package/dist/mail/rate-limiter.js +56 -0
  50. package/dist/mail/rate-limiter.js.map +1 -0
  51. package/dist/mail/smtp-transport.d.ts +141 -0
  52. package/dist/mail/smtp-transport.d.ts.map +1 -0
  53. package/dist/mail/smtp-transport.js +415 -0
  54. package/dist/mail/smtp-transport.js.map +1 -0
  55. package/dist/mail/types.d.ts +177 -0
  56. package/dist/mail/types.d.ts.map +1 -0
  57. package/dist/mail/types.js +11 -0
  58. package/dist/mail/types.js.map +1 -0
  59. package/dist/router/destination.d.ts +69 -0
  60. package/dist/router/destination.d.ts.map +1 -0
  61. package/dist/router/destination.js +106 -0
  62. package/dist/router/destination.js.map +1 -0
  63. package/dist/router/message-router.d.ts +15 -0
  64. package/dist/router/message-router.d.ts.map +1 -1
  65. package/dist/router/message-router.js +25 -3
  66. package/dist/router/message-router.js.map +1 -1
  67. package/dist/storage/interface.d.ts +9 -0
  68. package/dist/storage/interface.d.ts.map +1 -1
  69. package/dist/storage/memory.d.ts +4 -0
  70. package/dist/storage/memory.d.ts.map +1 -1
  71. package/dist/storage/memory.js +12 -0
  72. package/dist/storage/memory.js.map +1 -1
  73. package/dist/storage/sqlite.d.ts +6 -0
  74. package/dist/storage/sqlite.d.ts.map +1 -1
  75. package/dist/storage/sqlite.js +28 -0
  76. package/dist/storage/sqlite.js.map +1 -1
  77. package/dist/types.d.ts +79 -0
  78. package/dist/types.d.ts.map +1 -1
  79. package/docs/DESIGN.md +15 -0
  80. package/docs/MAIL-INTEROP-PLAN.md +660 -0
  81. package/package.json +28 -3
  82. package/renovate.json5 +6 -0
  83. package/rules/agent-inbox.md +1 -0
  84. package/src/federation/connection-manager.ts +12 -0
  85. package/src/federation/delivery-queue.ts +38 -8
  86. package/src/federation/queue-store.ts +124 -0
  87. package/src/index.ts +148 -0
  88. package/src/mail/address-book.ts +111 -0
  89. package/src/mail/attachment-store.ts +90 -0
  90. package/src/mail/email-mapper.ts +288 -0
  91. package/src/mail/fs-attachment-store.ts +163 -0
  92. package/src/mail/mail-gateway.ts +505 -0
  93. package/src/mail/provider-transport.ts +577 -0
  94. package/src/mail/rate-limiter.ts +51 -0
  95. package/src/mail/smtp-transport.ts +589 -0
  96. package/src/mail/types.ts +221 -0
  97. package/src/router/destination.ts +140 -0
  98. package/src/router/message-router.ts +41 -4
  99. package/src/storage/interface.ts +11 -0
  100. package/src/storage/memory.ts +15 -0
  101. package/src/storage/sqlite.ts +36 -0
  102. package/src/types.ts +73 -0
  103. package/test/federation/delivery-queue-sqlite.test.ts +158 -0
  104. package/test/load.test.ts +1 -1
  105. package/test/mail/address-book.test.ts +111 -0
  106. package/test/mail/attachment-store-contract.test.ts +92 -0
  107. package/test/mail/attachment-store.test.ts +69 -0
  108. package/test/mail/destination.test.ts +115 -0
  109. package/test/mail/dsn-parse.test.ts +239 -0
  110. package/test/mail/email-mapper.test.ts +341 -0
  111. package/test/mail/external-id.test.ts +43 -0
  112. package/test/mail/fs-attachment-store.test.ts +134 -0
  113. package/test/mail/full-flow-e2e.test.ts +200 -0
  114. package/test/mail/mail-gateway.test.ts +419 -0
  115. package/test/mail/mail-transport-contract.test.ts +134 -0
  116. package/test/mail/mock-mail.ts +161 -0
  117. package/test/mail/mock-postmark.ts +66 -0
  118. package/test/mail/provider-transport.test.ts +381 -0
  119. package/test/mail/rate-limiter.test.ts +48 -0
  120. package/test/mail/router-mail-integration.test.ts +138 -0
  121. package/test/mail/smtp-e2e.test.ts +98 -0
  122. package/test/mail/smtp-transport.test.ts +138 -0
@@ -0,0 +1,239 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parseDsnFromParsed, parsedToInboundMail } from "../../src/mail/smtp-transport.js";
3
+
4
+ // Build a mailparser-like object with a delivery-status part and (optionally) a
5
+ // returned message/rfc822 part.
6
+ function dsnParsed(opts: {
7
+ contentType?: string;
8
+ statusText: string;
9
+ originalRfc822?: string;
10
+ }) {
11
+ const attachments = [
12
+ {
13
+ content: Buffer.from(opts.statusText),
14
+ contentType: "message/delivery-status",
15
+ size: opts.statusText.length,
16
+ },
17
+ ];
18
+ if (opts.originalRfc822) {
19
+ attachments.push({
20
+ content: Buffer.from(opts.originalRfc822),
21
+ contentType: "message/rfc822",
22
+ size: opts.originalRfc822.length,
23
+ });
24
+ }
25
+ return {
26
+ headers: new Map<string, unknown>([
27
+ [
28
+ "content-type",
29
+ opts.contentType ?? "multipart/report; report-type=delivery-status",
30
+ ],
31
+ ]),
32
+ attachments,
33
+ };
34
+ }
35
+
36
+ describe("parseDsnFromParsed", () => {
37
+ it("returns undefined for ordinary mail", () => {
38
+ expect(
39
+ parseDsnFromParsed({ subject: "hello", text: "hi", attachments: [] })
40
+ ).toBeUndefined();
41
+ });
42
+
43
+ it("parses a hard-failure DSN", () => {
44
+ const dsn = parseDsnFromParsed(
45
+ dsnParsed({
46
+ statusText:
47
+ "Reporting-MTA: dns; mail.example.com\r\n" +
48
+ "Final-Recipient: rfc822; gone@nowhere.example\r\n" +
49
+ "Action: failed\r\n" +
50
+ "Status: 5.1.1\r\n" +
51
+ "Diagnostic-Code: smtp; 550 5.1.1 No such user\r\n",
52
+ originalRfc822: "Message-ID: <orig-123@example.com>\r\nSubject: hi\r\n",
53
+ })
54
+ );
55
+ expect(dsn).toEqual({
56
+ action: "failed",
57
+ recipient: "gone@nowhere.example",
58
+ status: "5.1.1",
59
+ originalMessageId: "<orig-123@example.com>",
60
+ diagnostic: "smtp; 550 5.1.1 No such user",
61
+ });
62
+ });
63
+
64
+ it("classifies a 4.x.x status as delayed even without Action", () => {
65
+ const dsn = parseDsnFromParsed(
66
+ dsnParsed({
67
+ statusText:
68
+ "Final-Recipient: rfc822; slow@example.com\r\nStatus: 4.4.1\r\n",
69
+ })
70
+ );
71
+ expect(dsn?.action).toBe("delayed");
72
+ });
73
+
74
+ it("honors Action: delayed", () => {
75
+ const dsn = parseDsnFromParsed(
76
+ dsnParsed({
77
+ statusText:
78
+ "Final-Recipient: rfc822; slow@example.com\r\nAction: delayed\r\nStatus: 4.2.2\r\n",
79
+ })
80
+ );
81
+ expect(dsn?.action).toBe("delayed");
82
+ });
83
+
84
+ it("maps a positive (relayed/delivered) DSN to delivered", () => {
85
+ const dsn = parseDsnFromParsed(
86
+ dsnParsed({
87
+ statusText:
88
+ "Final-Recipient: rfc822; ok@example.com\r\nAction: relayed\r\nStatus: 2.0.0\r\n",
89
+ })
90
+ );
91
+ expect(dsn?.action).toBe("delivered");
92
+ });
93
+
94
+ it("falls back to Original-Recipient when Final-Recipient is absent", () => {
95
+ const dsn = parseDsnFromParsed(
96
+ dsnParsed({
97
+ statusText:
98
+ "Original-Recipient: rfc822; orig@example.com\r\nAction: failed\r\nStatus: 5.0.0\r\n",
99
+ })
100
+ );
101
+ expect(dsn?.recipient).toBe("orig@example.com");
102
+ });
103
+
104
+ it("does NOT classify an ordinary message that merely carries an attached DSN", () => {
105
+ // Top-level content-type is not multipart/report (e.g. a user forwarding a
106
+ // bounce to support). Detecting on the attachment alone would misroute it.
107
+ const dsn = parseDsnFromParsed(
108
+ dsnParsed({
109
+ contentType: "multipart/mixed; boundary=x",
110
+ statusText: "Final-Recipient: rfc822; x@y.com\r\nAction: failed\r\nStatus: 5.0.0\r\n",
111
+ })
112
+ );
113
+ expect(dsn).toBeUndefined();
114
+ });
115
+
116
+ it("returns undefined for a multipart/report with no delivery-status part", () => {
117
+ expect(
118
+ parseDsnFromParsed({
119
+ headers: new Map([["content-type", "multipart/report; report-type=delivery-status"]]),
120
+ attachments: [],
121
+ })
122
+ ).toBeUndefined();
123
+ });
124
+
125
+ it("flows through parsedToInboundMail into the bounce field", () => {
126
+ const mail = parsedToInboundMail(
127
+ dsnParsed({
128
+ statusText:
129
+ "Final-Recipient: rfc822; gone@x.com\r\nAction: failed\r\nStatus: 5.1.1\r\n",
130
+ }),
131
+ { envelopeFrom: "mailer-daemon@mta", envelopeTo: [], sizeBytes: 100 }
132
+ );
133
+ expect(mail.bounce).toMatchObject({ action: "failed", recipient: "gone@x.com" });
134
+ });
135
+ });
136
+
137
+ // Integration: parse a realistic DSN with the REAL mailparser, since synthetic
138
+ // fixtures above don't match how mailparser surfaces a multipart/report (it
139
+ // folds the delivery-status block into `text`, not `attachments`, when there is
140
+ // no Content-Disposition). This guards against the regression where real
141
+ // Postfix/Gmail bounces were silently dropped.
142
+ const haveMailparser = await import("mailparser")
143
+ .then(() => true)
144
+ .catch(() => false);
145
+
146
+ describe.skipIf(!haveMailparser)("parseDsnFromParsed (real mailparser)", () => {
147
+ // A Postfix-style hard bounce: multipart/report, delivery-status part has NO
148
+ // Content-Disposition, plus a text/rfc822-headers part with the original id.
149
+ const RAW_HARD_BOUNCE = [
150
+ "From: MAILER-DAEMON@mail.example.com",
151
+ "To: alice@example.com",
152
+ "Subject: Undelivered Mail Returned to Sender",
153
+ "MIME-Version: 1.0",
154
+ 'Content-Type: multipart/report; report-type=delivery-status; boundary="BOUND"',
155
+ "",
156
+ "--BOUND",
157
+ "Content-Type: text/plain; charset=us-ascii",
158
+ "",
159
+ "This is the mail system at host mail.example.com.",
160
+ "Your message could not be delivered.",
161
+ "",
162
+ "--BOUND",
163
+ "Content-Type: message/delivery-status",
164
+ "",
165
+ "Reporting-MTA: dns; mail.example.com",
166
+ "",
167
+ "Final-Recipient: rfc822; gone@nowhere.example",
168
+ "Action: failed",
169
+ "Status: 5.1.1",
170
+ "Diagnostic-Code: smtp; 550 5.1.1 <gone@nowhere.example>: User unknown",
171
+ "",
172
+ "--BOUND",
173
+ "Content-Type: text/rfc822-headers",
174
+ "",
175
+ "Message-ID: <orig-abc@example.com>",
176
+ "Subject: hi there",
177
+ "",
178
+ "--BOUND--",
179
+ "",
180
+ ].join("\r\n");
181
+
182
+ async function parse(raw: string) {
183
+ const { simpleParser } = await import("mailparser");
184
+ return simpleParser(Buffer.from(raw));
185
+ }
186
+
187
+ it("detects a real Postfix-style hard bounce folded into the text body", async () => {
188
+ const parsed = await parse(RAW_HARD_BOUNCE);
189
+ const dsn = parseDsnFromParsed(parsed as never);
190
+ expect(dsn).toBeTruthy();
191
+ expect(dsn!.action).toBe("failed");
192
+ expect(dsn!.recipient).toBe("gone@nowhere.example");
193
+ expect(dsn!.status).toBe("5.1.1");
194
+ expect(dsn!.originalMessageId).toBe("<orig-abc@example.com>");
195
+ expect(dsn!.diagnostic).toMatch(/User unknown/);
196
+ });
197
+
198
+ it("classifies a real 4.x.x soft bounce as delayed", async () => {
199
+ const soft = RAW_HARD_BOUNCE.replace("Action: failed", "Action: delayed").replace(
200
+ "Status: 5.1.1",
201
+ "Status: 4.4.1"
202
+ );
203
+ const dsn = parseDsnFromParsed((await parse(soft)) as never);
204
+ expect(dsn?.action).toBe("delayed");
205
+ });
206
+
207
+ it("does not classify an ordinary multipart/mixed email as a bounce", async () => {
208
+ const ordinary = [
209
+ "From: bob@example.com",
210
+ "To: alice@example.com",
211
+ "Subject: hi",
212
+ "MIME-Version: 1.0",
213
+ 'Content-Type: multipart/mixed; boundary="B"',
214
+ "",
215
+ "--B",
216
+ "Content-Type: text/plain",
217
+ "",
218
+ "just a normal message",
219
+ "--B--",
220
+ "",
221
+ ].join("\r\n");
222
+ const dsn = parseDsnFromParsed((await parse(ordinary)) as never);
223
+ expect(dsn).toBeUndefined();
224
+ });
225
+
226
+ it("flows a real bounce through parsedToInboundMail into the bounce field", async () => {
227
+ const parsed = await parse(RAW_HARD_BOUNCE);
228
+ const mail = parsedToInboundMail(parsed as never, {
229
+ envelopeFrom: "MAILER-DAEMON@mail.example.com",
230
+ envelopeTo: ["alice@example.com"],
231
+ sizeBytes: RAW_HARD_BOUNCE.length,
232
+ });
233
+ expect(mail.bounce).toMatchObject({
234
+ action: "failed",
235
+ recipient: "gone@nowhere.example",
236
+ originalMessageId: "<orig-abc@example.com>",
237
+ });
238
+ });
239
+ });
@@ -0,0 +1,341 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { AddressBook } from "../../src/mail/address-book.js";
3
+ import {
4
+ inboundMailToMessage,
5
+ messageToOutboundMail,
6
+ UnknownRecipientError,
7
+ type InboundMapContext,
8
+ type OutboundMapContext,
9
+ } from "../../src/mail/email-mapper.js";
10
+ import { makeInboundMail } from "./mock-mail.js";
11
+ import type { Message } from "../../src/types.js";
12
+
13
+ function addressBook(): AddressBook {
14
+ return new AddressBook({
15
+ localDomains: ["example.com"],
16
+ mappings: [
17
+ { agentId: "alice", address: "alice@example.com" },
18
+ { agentId: "bob", address: "bob@example.com" },
19
+ ],
20
+ });
21
+ }
22
+
23
+ function inCtx(over: Partial<InboundMapContext> = {}): InboundMapContext {
24
+ return { addressBook: addressBook(), defaultScope: "default", ...over };
25
+ }
26
+
27
+ function outCtx(over: Partial<OutboundMapContext> = {}): OutboundMapContext {
28
+ return { addressBook: addressBook(), ...over };
29
+ }
30
+
31
+ describe("inboundMailToMessage", () => {
32
+ it("maps a basic inbound email to a Message", () => {
33
+ const msg = inboundMailToMessage(
34
+ makeInboundMail({
35
+ from: { address: "ext@other.org", name: "Ext" },
36
+ to: [{ address: "alice@example.com" }],
37
+ subject: "hi",
38
+ text: "body text",
39
+ headers: { messageId: "<abc@other.org>", raw: {} },
40
+ }),
41
+ inCtx()
42
+ );
43
+ expect(msg.sender_id).toBe("ext@other.org");
44
+ expect(msg.recipients).toEqual([{ agent_id: "alice", kind: "to" }]);
45
+ expect(msg.subject).toBe("hi");
46
+ expect(msg.content).toEqual({ type: "text", text: "body text" });
47
+ expect(msg.metadata.rfcMessageId).toBe("<abc@other.org>");
48
+ expect(msg.metadata.envelopeFrom).toBe("ext@example.org");
49
+ });
50
+
51
+ it("resolves cc recipients and preserves kind", () => {
52
+ const msg = inboundMailToMessage(
53
+ makeInboundMail({
54
+ to: [{ address: "alice@example.com" }],
55
+ cc: [{ address: "bob@example.com" }],
56
+ }),
57
+ inCtx()
58
+ );
59
+ expect(msg.recipients).toContainEqual({ agent_id: "alice", kind: "to" });
60
+ expect(msg.recipients).toContainEqual({ agent_id: "bob", kind: "cc" });
61
+ });
62
+
63
+ it("derives scope from plus-addressing on the matched to recipient", () => {
64
+ const msg = inboundMailToMessage(
65
+ makeInboundMail({ to: [{ address: "alice+project-x@example.com" }] }),
66
+ inCtx()
67
+ );
68
+ expect(msg.scope).toBe("project-x");
69
+ expect(msg.recipients[0].agent_id).toBe("alice");
70
+ });
71
+
72
+ it("skips unresolvable recipients but keeps resolvable ones", () => {
73
+ const msg = inboundMailToMessage(
74
+ makeInboundMail({
75
+ to: [
76
+ { address: "alice@example.com" },
77
+ { address: "stranger@elsewhere.net" },
78
+ ],
79
+ }),
80
+ inCtx()
81
+ );
82
+ expect(msg.recipients).toEqual([{ agent_id: "alice", kind: "to" }]);
83
+ });
84
+
85
+ it("throws UnknownRecipientError when nothing resolves", () => {
86
+ expect(() =>
87
+ inboundMailToMessage(
88
+ makeInboundMail({ to: [{ address: "nobody@elsewhere.net" }] }),
89
+ inCtx()
90
+ )
91
+ ).toThrow(UnknownRecipientError);
92
+ });
93
+
94
+ it("uses catch-all when configured and mailbox is unknown", () => {
95
+ const ab = new AddressBook({
96
+ localDomains: ["example.com"],
97
+ mappings: [],
98
+ catchAllAgentId: "triage",
99
+ });
100
+ const msg = inboundMailToMessage(
101
+ makeInboundMail({ to: [{ address: "whoever@example.com" }] }),
102
+ inCtx({ addressBook: ab })
103
+ );
104
+ expect(msg.recipients[0].agent_id).toBe("triage");
105
+ });
106
+
107
+ it("derives a stable thread_tag from the References root", () => {
108
+ const a = inboundMailToMessage(
109
+ makeInboundMail({
110
+ to: [{ address: "alice@example.com" }],
111
+ headers: {
112
+ messageId: "<m2@x>",
113
+ references: ["<root@x>", "<m1@x>"],
114
+ raw: {},
115
+ },
116
+ }),
117
+ inCtx()
118
+ );
119
+ const b = inboundMailToMessage(
120
+ makeInboundMail({
121
+ to: [{ address: "alice@example.com" }],
122
+ headers: {
123
+ messageId: "<m3@x>",
124
+ references: ["<root@x>"],
125
+ raw: {},
126
+ },
127
+ }),
128
+ inCtx()
129
+ );
130
+ expect(a.thread_tag).toBeTruthy();
131
+ expect(a.thread_tag).toBe(b.thread_tag); // same root → same thread
132
+ });
133
+
134
+ it("resolves in_reply_to to an inbox id when the RFC id is known", () => {
135
+ const msg = inboundMailToMessage(
136
+ makeInboundMail({
137
+ to: [{ address: "alice@example.com" }],
138
+ headers: { messageId: "<reply@x>", inReplyTo: "<parent@x>", raw: {} },
139
+ }),
140
+ inCtx({
141
+ lookupInboxIdByRfcMessageId: (id) =>
142
+ id === "<parent@x>" ? "inbox-parent-123" : undefined,
143
+ })
144
+ );
145
+ expect(msg.in_reply_to).toBe("inbox-parent-123");
146
+ expect(msg.metadata.rfcInReplyTo).toBe("<parent@x>");
147
+ });
148
+
149
+ it("maps X-Priority / Importance headers to importance", () => {
150
+ const high = inboundMailToMessage(
151
+ makeInboundMail({
152
+ to: [{ address: "alice@example.com" }],
153
+ headers: { raw: { "x-priority": "1" } },
154
+ }),
155
+ inCtx()
156
+ );
157
+ expect(high.importance).toBe("high");
158
+ const low = inboundMailToMessage(
159
+ makeInboundMail({
160
+ to: [{ address: "alice@example.com" }],
161
+ headers: { raw: { importance: "low" } },
162
+ }),
163
+ inCtx()
164
+ );
165
+ expect(low.importance).toBe("low");
166
+ });
167
+
168
+ it("retains an inbound bounce in metadata", () => {
169
+ const msg = inboundMailToMessage(
170
+ makeInboundMail({
171
+ to: [{ address: "alice@example.com" }],
172
+ bounce: {
173
+ action: "failed",
174
+ recipient: "gone@nowhere.net",
175
+ status: "5.1.1",
176
+ originalMessageId: "<orig@example.com>",
177
+ },
178
+ }),
179
+ inCtx()
180
+ );
181
+ expect(msg.metadata.bounce).toMatchObject({ action: "failed" });
182
+ });
183
+ });
184
+
185
+ describe("messageToOutboundMail", () => {
186
+ function baseMessage(over: Partial<Message> = {}): Message {
187
+ return {
188
+ id: "01ABCDEF",
189
+ scope: "default",
190
+ sender_id: "alice",
191
+ recipients: [{ agent_id: "ext@other.org", kind: "to" }],
192
+ subject: "subject",
193
+ content: { type: "text", text: "hello world" },
194
+ importance: "normal",
195
+ metadata: {},
196
+ created_at: new Date().toISOString(),
197
+ ...over,
198
+ };
199
+ }
200
+
201
+ it("renders a Message into an OutboundMail", () => {
202
+ const out = messageToOutboundMail(baseMessage(), outCtx());
203
+ expect(out.idempotencyKey).toBe("01ABCDEF");
204
+ expect(out.from).toEqual({ address: "alice@example.com" });
205
+ expect(out.to).toEqual([{ address: "ext@other.org" }]);
206
+ expect(out.text).toBe("hello world");
207
+ expect(out.headers.messageId).toBe("<01ABCDEF@example.com>");
208
+ });
209
+
210
+ it("falls back to <agentId>@primaryDomain for an unmapped sender", () => {
211
+ const out = messageToOutboundMail(
212
+ baseMessage({ sender_id: "ghost" }),
213
+ outCtx()
214
+ );
215
+ expect(out.from).toEqual({ address: "ghost@example.com" });
216
+ });
217
+
218
+ it("splits recipients by kind into to/cc/bcc", () => {
219
+ const out = messageToOutboundMail(
220
+ baseMessage({
221
+ recipients: [
222
+ { agent_id: "a@x.org", kind: "to" },
223
+ { agent_id: "b@x.org", kind: "cc" },
224
+ { agent_id: "c@x.org", kind: "bcc" },
225
+ ],
226
+ }),
227
+ outCtx()
228
+ );
229
+ expect(out.to).toEqual([{ address: "a@x.org" }]);
230
+ expect(out.cc).toEqual([{ address: "b@x.org" }]);
231
+ expect(out.bcc).toEqual([{ address: "c@x.org" }]);
232
+ });
233
+
234
+ it("builds In-Reply-To / References from a known parent", () => {
235
+ const out = messageToOutboundMail(
236
+ baseMessage({ in_reply_to: "inbox-parent" }),
237
+ outCtx({
238
+ lookupRfcMessageId: (id) =>
239
+ id === "inbox-parent" ? "<parent@other.org>" : undefined,
240
+ lookupReferences: () => ["<root@other.org>"],
241
+ })
242
+ );
243
+ expect(out.headers.inReplyTo).toBe("<parent@other.org>");
244
+ expect(out.headers.references).toEqual([
245
+ "<root@other.org>",
246
+ "<parent@other.org>",
247
+ ]);
248
+ });
249
+
250
+ it("synthesizes a parent Message-ID when the RFC id is unknown", () => {
251
+ const out = messageToOutboundMail(
252
+ baseMessage({ in_reply_to: "inbox-parent" }),
253
+ outCtx()
254
+ );
255
+ expect(out.headers.inReplyTo).toBe("<inbox-parent@example.com>");
256
+ });
257
+
258
+ it("carries html body from metadata but does NOT leak arbitrary metadata to the backend", () => {
259
+ const out = messageToOutboundMail(
260
+ baseMessage({
261
+ metadata: { htmlBody: "<p>hi</p>", rfcMessageId: "<x@y>", userTag: "internal" },
262
+ }),
263
+ outCtx()
264
+ );
265
+ expect(out.html).toBe("<p>hi</p>");
266
+ // Internal metadata must not be forwarded to the external mail provider.
267
+ expect(out.backendHints).toBeUndefined();
268
+ });
269
+
270
+ it("forwards only an explicit mailBackendHints object to the backend", () => {
271
+ const out = messageToOutboundMail(
272
+ baseMessage({
273
+ metadata: { secret: "no", mailBackendHints: { tag: "newsletter" } },
274
+ }),
275
+ outCtx()
276
+ );
277
+ expect(out.backendHints).toEqual({ tag: "newsletter" });
278
+ });
279
+
280
+ it("validates and filters malformed attachment metadata", () => {
281
+ const out = messageToOutboundMail(
282
+ baseMessage({
283
+ metadata: {
284
+ mailAttachments: [
285
+ { contentRef: "sha256:a", contentType: "text/plain", sizeBytes: 3, filename: "ok.txt" },
286
+ { bogus: true }, // dropped — missing contentRef/contentType
287
+ "not-an-object", // dropped
288
+ ],
289
+ },
290
+ }),
291
+ outCtx()
292
+ );
293
+ expect(out.attachments).toEqual([
294
+ { contentRef: "sha256:a", contentType: "text/plain", sizeBytes: 3, filename: "ok.txt" },
295
+ ]);
296
+ });
297
+ });
298
+
299
+ describe("round-trip", () => {
300
+ it("preserves subject, body, and threading across inbound→outbound reply", () => {
301
+ const ab = addressBook();
302
+ // Inbound arrives for alice.
303
+ const inbound = inboundMailToMessage(
304
+ makeInboundMail({
305
+ from: { address: "ext@other.org" },
306
+ to: [{ address: "alice@example.com" }],
307
+ subject: "Question",
308
+ text: "what is the status?",
309
+ headers: { messageId: "<q1@other.org>", raw: {} },
310
+ }),
311
+ { addressBook: ab }
312
+ );
313
+
314
+ // Alice replies: a new message in_reply_to the inbound one, to the sender.
315
+ const reply: Message = {
316
+ id: "REPLY1",
317
+ scope: inbound.scope,
318
+ sender_id: "alice",
319
+ recipients: [{ agent_id: "ext@other.org", kind: "to" }],
320
+ subject: "Re: Question",
321
+ content: { type: "text", text: "all good" },
322
+ in_reply_to: inbound.id,
323
+ importance: "normal",
324
+ metadata: {},
325
+ created_at: new Date().toISOString(),
326
+ };
327
+
328
+ const out = messageToOutboundMail(reply, {
329
+ addressBook: ab,
330
+ // The reply's parent is the inbound message; its recorded RFC id:
331
+ lookupRfcMessageId: (id) =>
332
+ id === inbound.id ? (inbound.metadata.rfcMessageId as string) : undefined,
333
+ });
334
+
335
+ expect(out.to).toEqual([{ address: "ext@other.org" }]);
336
+ expect(out.subject).toBe("Re: Question");
337
+ expect(out.text).toBe("all good");
338
+ expect(out.headers.inReplyTo).toBe("<q1@other.org>");
339
+ expect(out.headers.references).toEqual(["<q1@other.org>"]);
340
+ });
341
+ });
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import Database from "better-sqlite3";
3
+ import { InMemoryStorage } from "../../src/storage/memory.js";
4
+ import { SqliteStorage } from "../../src/storage/sqlite.js";
5
+ import type { Storage } from "../../src/storage/interface.js";
6
+
7
+ const dbs: Database.Database[] = [];
8
+ afterEach(() => {
9
+ while (dbs.length) dbs.pop()!.close();
10
+ });
11
+
12
+ function sqlite(): Storage {
13
+ const db = new Database(":memory:");
14
+ dbs.push(db);
15
+ return new SqliteStorage({ db });
16
+ }
17
+
18
+ const impls: Array<[string, () => Storage]> = [
19
+ ["InMemoryStorage", () => new InMemoryStorage()],
20
+ ["SqliteStorage", () => sqlite()],
21
+ ];
22
+
23
+ describe.each(impls)("external-id mapping (%s)", (_name, make) => {
24
+ it("records and looks up an external id", () => {
25
+ const s = make();
26
+ s.recordExternalId("<abc@x>", "msg-1");
27
+ expect(s.getMessageIdByExternalId("<abc@x>")).toBe("msg-1");
28
+ expect(s.hasSeenExternalId("<abc@x>")).toBe(true);
29
+ });
30
+
31
+ it("reports unseen ids", () => {
32
+ const s = make();
33
+ expect(s.hasSeenExternalId("<nope@x>")).toBe(false);
34
+ expect(s.getMessageIdByExternalId("<nope@x>")).toBeUndefined();
35
+ });
36
+
37
+ it("is idempotent: first mapping wins", () => {
38
+ const s = make();
39
+ s.recordExternalId("<dup@x>", "msg-1");
40
+ s.recordExternalId("<dup@x>", "msg-2");
41
+ expect(s.getMessageIdByExternalId("<dup@x>")).toBe("msg-1");
42
+ });
43
+ });