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.
Files changed (138) hide show
  1. package/CLAUDE.md +1 -92
  2. package/README.md +6 -73
  3. package/dist/federation/connection-manager.d.ts +0 -8
  4. package/dist/federation/connection-manager.d.ts.map +1 -1
  5. package/dist/federation/connection-manager.js +0 -12
  6. package/dist/federation/connection-manager.js.map +1 -1
  7. package/dist/federation/delivery-queue.d.ts +3 -11
  8. package/dist/federation/delivery-queue.d.ts.map +1 -1
  9. package/dist/federation/delivery-queue.js +8 -38
  10. package/dist/federation/delivery-queue.js.map +1 -1
  11. package/dist/index.d.ts +0 -17
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +0 -98
  14. package/dist/index.js.map +1 -1
  15. package/dist/jsonrpc/mail-push-types.d.ts +22 -2
  16. package/dist/jsonrpc/mail-push-types.d.ts.map +1 -1
  17. package/dist/jsonrpc/mail-push-types.js +18 -1
  18. package/dist/jsonrpc/mail-push-types.js.map +1 -1
  19. package/dist/jsonrpc/mail-push.d.ts +12 -1
  20. package/dist/jsonrpc/mail-push.d.ts.map +1 -1
  21. package/dist/jsonrpc/mail-push.js +13 -2
  22. package/dist/jsonrpc/mail-push.js.map +1 -1
  23. package/dist/jsonrpc/mail-server.d.ts.map +1 -1
  24. package/dist/jsonrpc/mail-server.js +42 -18
  25. package/dist/jsonrpc/mail-server.js.map +1 -1
  26. package/dist/router/message-router.d.ts +0 -15
  27. package/dist/router/message-router.d.ts.map +1 -1
  28. package/dist/router/message-router.js +3 -25
  29. package/dist/router/message-router.js.map +1 -1
  30. package/dist/storage/interface.d.ts +2 -9
  31. package/dist/storage/interface.d.ts.map +1 -1
  32. package/dist/storage/memory.d.ts +1 -4
  33. package/dist/storage/memory.d.ts.map +1 -1
  34. package/dist/storage/memory.js +6 -12
  35. package/dist/storage/memory.js.map +1 -1
  36. package/dist/storage/sqlite.d.ts +1 -6
  37. package/dist/storage/sqlite.d.ts.map +1 -1
  38. package/dist/storage/sqlite.js +6 -28
  39. package/dist/storage/sqlite.js.map +1 -1
  40. package/dist/types.d.ts +0 -79
  41. package/dist/types.d.ts.map +1 -1
  42. package/docs/DESIGN.md +0 -15
  43. package/package.json +3 -28
  44. package/rules/agent-inbox.md +0 -1
  45. package/src/federation/connection-manager.ts +0 -12
  46. package/src/federation/delivery-queue.ts +8 -38
  47. package/src/index.ts +0 -148
  48. package/src/jsonrpc/mail-push-types.ts +43 -2
  49. package/src/jsonrpc/mail-push.ts +34 -3
  50. package/src/jsonrpc/mail-server.ts +45 -26
  51. package/src/router/message-router.ts +4 -41
  52. package/src/storage/interface.ts +2 -11
  53. package/src/storage/memory.ts +8 -15
  54. package/src/storage/sqlite.ts +9 -36
  55. package/src/types.ts +0 -73
  56. package/test/load.test.ts +1 -1
  57. package/test/mail-push.test.ts +101 -1
  58. package/test/mail-server.test.ts +108 -0
  59. package/AGENTS.md +0 -18
  60. package/dist/federation/queue-store.d.ts +0 -42
  61. package/dist/federation/queue-store.d.ts.map +0 -1
  62. package/dist/federation/queue-store.js +0 -87
  63. package/dist/federation/queue-store.js.map +0 -1
  64. package/dist/index.d.mts +0 -2
  65. package/dist/index.mjs +0 -1
  66. package/dist/index.mjs.map +0 -1
  67. package/dist/mail/address-book.d.ts +0 -43
  68. package/dist/mail/address-book.d.ts.map +0 -1
  69. package/dist/mail/address-book.js +0 -95
  70. package/dist/mail/address-book.js.map +0 -1
  71. package/dist/mail/attachment-store.d.ts +0 -31
  72. package/dist/mail/attachment-store.d.ts.map +0 -1
  73. package/dist/mail/attachment-store.js +0 -74
  74. package/dist/mail/attachment-store.js.map +0 -1
  75. package/dist/mail/email-mapper.d.ts +0 -41
  76. package/dist/mail/email-mapper.d.ts.map +0 -1
  77. package/dist/mail/email-mapper.js +0 -216
  78. package/dist/mail/email-mapper.js.map +0 -1
  79. package/dist/mail/fs-attachment-store.d.ts +0 -38
  80. package/dist/mail/fs-attachment-store.d.ts.map +0 -1
  81. package/dist/mail/fs-attachment-store.js +0 -165
  82. package/dist/mail/fs-attachment-store.js.map +0 -1
  83. package/dist/mail/mail-gateway.d.ts +0 -114
  84. package/dist/mail/mail-gateway.d.ts.map +0 -1
  85. package/dist/mail/mail-gateway.js +0 -402
  86. package/dist/mail/mail-gateway.js.map +0 -1
  87. package/dist/mail/provider-transport.d.ts +0 -138
  88. package/dist/mail/provider-transport.d.ts.map +0 -1
  89. package/dist/mail/provider-transport.js +0 -434
  90. package/dist/mail/provider-transport.js.map +0 -1
  91. package/dist/mail/rate-limiter.d.ts +0 -20
  92. package/dist/mail/rate-limiter.d.ts.map +0 -1
  93. package/dist/mail/rate-limiter.js +0 -56
  94. package/dist/mail/rate-limiter.js.map +0 -1
  95. package/dist/mail/smtp-transport.d.ts +0 -141
  96. package/dist/mail/smtp-transport.d.ts.map +0 -1
  97. package/dist/mail/smtp-transport.js +0 -415
  98. package/dist/mail/smtp-transport.js.map +0 -1
  99. package/dist/mail/types.d.ts +0 -177
  100. package/dist/mail/types.d.ts.map +0 -1
  101. package/dist/mail/types.js +0 -11
  102. package/dist/mail/types.js.map +0 -1
  103. package/dist/router/destination.d.ts +0 -69
  104. package/dist/router/destination.d.ts.map +0 -1
  105. package/dist/router/destination.js +0 -106
  106. package/dist/router/destination.js.map +0 -1
  107. package/docs/MAIL-INTEROP-PLAN.md +0 -660
  108. package/renovate.json5 +0 -6
  109. package/src/federation/queue-store.ts +0 -124
  110. package/src/mail/address-book.ts +0 -111
  111. package/src/mail/attachment-store.ts +0 -90
  112. package/src/mail/email-mapper.ts +0 -288
  113. package/src/mail/fs-attachment-store.ts +0 -163
  114. package/src/mail/mail-gateway.ts +0 -505
  115. package/src/mail/provider-transport.ts +0 -577
  116. package/src/mail/rate-limiter.ts +0 -51
  117. package/src/mail/smtp-transport.ts +0 -589
  118. package/src/mail/types.ts +0 -221
  119. package/src/router/destination.ts +0 -140
  120. package/test/federation/delivery-queue-sqlite.test.ts +0 -158
  121. package/test/mail/address-book.test.ts +0 -111
  122. package/test/mail/attachment-store-contract.test.ts +0 -92
  123. package/test/mail/attachment-store.test.ts +0 -69
  124. package/test/mail/destination.test.ts +0 -115
  125. package/test/mail/dsn-parse.test.ts +0 -239
  126. package/test/mail/email-mapper.test.ts +0 -341
  127. package/test/mail/external-id.test.ts +0 -43
  128. package/test/mail/fs-attachment-store.test.ts +0 -134
  129. package/test/mail/full-flow-e2e.test.ts +0 -200
  130. package/test/mail/mail-gateway.test.ts +0 -419
  131. package/test/mail/mail-transport-contract.test.ts +0 -134
  132. package/test/mail/mock-mail.ts +0 -161
  133. package/test/mail/mock-postmark.ts +0 -66
  134. package/test/mail/provider-transport.test.ts +0 -381
  135. package/test/mail/rate-limiter.test.ts +0 -48
  136. package/test/mail/router-mail-integration.test.ts +0 -138
  137. package/test/mail/smtp-e2e.test.ts +0 -98
  138. package/test/mail/smtp-transport.test.ts +0 -138
@@ -1,92 +0,0 @@
1
- /**
2
- * Shared AttachmentStore contract — both backends (SQLite BLOB + filesystem)
3
- * must satisfy the same behavior, so they are interchangeable behind the
4
- * opaque `contentRef`. Mirrors the mail-transport contract pattern.
5
- */
6
-
7
- import { describe, it, expect, beforeEach, afterEach } from "vitest";
8
- import * as fs from "node:fs/promises";
9
- import * as os from "node:os";
10
- import * as path from "node:path";
11
- import Database from "better-sqlite3";
12
- import type { AttachmentStore } from "../../src/mail/types.js";
13
- import { SqliteAttachmentStore } from "../../src/mail/attachment-store.js";
14
- import { FsAttachmentStore } from "../../src/mail/fs-attachment-store.js";
15
-
16
- interface Harness {
17
- store: AttachmentStore;
18
- cleanup: () => Promise<void> | void;
19
- }
20
-
21
- function runAttachmentStoreContract(
22
- name: string,
23
- makeHarness: () => Promise<Harness>
24
- ): void {
25
- describe(`AttachmentStore contract: ${name}`, () => {
26
- let h: Harness;
27
- beforeEach(async () => {
28
- h = await makeHarness();
29
- });
30
- afterEach(async () => {
31
- await h.cleanup();
32
- });
33
-
34
- it("round-trips stored bytes by ref", async () => {
35
- const bytes = Buffer.from("contract bytes");
36
- const ref = await h.store.put(bytes, { contentType: "text/plain" });
37
- expect((await h.store.get(ref)).equals(bytes)).toBe(true);
38
- });
39
-
40
- it("produces sha256-based content-addressed refs", async () => {
41
- const ref = await h.store.put(Buffer.from("x"), { contentType: "text/plain" });
42
- expect(ref).toMatch(/^sha256:[0-9a-f]{64}$/);
43
- });
44
-
45
- it("dedups identical bytes to the same ref", async () => {
46
- const a = await h.store.put(Buffer.from("same"), { contentType: "text/plain" });
47
- const b = await h.store.put(Buffer.from("same"), { contentType: "text/plain" });
48
- expect(a).toBe(b);
49
- });
50
-
51
- it("gives different refs for different bytes", async () => {
52
- const a = await h.store.put(Buffer.from("one"), { contentType: "text/plain" });
53
- const b = await h.store.put(Buffer.from("two"), { contentType: "text/plain" });
54
- expect(a).not.toBe(b);
55
- });
56
-
57
- it("throws on a missing ref", async () => {
58
- await expect(h.store.get("sha256:" + "a".repeat(64))).rejects.toThrow(/not found/);
59
- });
60
-
61
- it("rejects a malformed ref on get and delete (both backends)", async () => {
62
- await expect(h.store.get("garbage")).rejects.toThrow(/Invalid attachment ref/);
63
- await expect(h.store.get("sha256:../../etc/passwd")).rejects.toThrow(/Invalid attachment ref/);
64
- await expect(h.store.delete("sha256:short")).rejects.toThrow(/Invalid attachment ref/);
65
- });
66
-
67
- it("deletes by ref", async () => {
68
- const ref = await h.store.put(Buffer.from("gone"), { contentType: "text/plain" });
69
- await h.store.delete(ref);
70
- await expect(h.store.get(ref)).rejects.toThrow(/not found/);
71
- });
72
-
73
- it("round-trips arbitrary binary content exactly", async () => {
74
- const bytes = Buffer.from([0x00, 0xff, 0x7f, 0x80, 0x01, 0x00]);
75
- const ref = await h.store.put(bytes, { contentType: "application/octet-stream" });
76
- expect((await h.store.get(ref)).equals(bytes)).toBe(true);
77
- });
78
- });
79
- }
80
-
81
- runAttachmentStoreContract("SqliteAttachmentStore", async () => {
82
- const db = new Database(":memory:");
83
- return { store: new SqliteAttachmentStore(db), cleanup: () => db.close() };
84
- });
85
-
86
- runAttachmentStoreContract("FsAttachmentStore", async () => {
87
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), "att-contract-"));
88
- return {
89
- store: new FsAttachmentStore(dir),
90
- cleanup: () => fs.rm(dir, { recursive: true, force: true }),
91
- };
92
- });
@@ -1,69 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
- import Database from "better-sqlite3";
3
- import { SqliteAttachmentStore } from "../../src/mail/attachment-store.js";
4
-
5
- describe("SqliteAttachmentStore", () => {
6
- let db: Database.Database;
7
- let store: SqliteAttachmentStore;
8
-
9
- beforeEach(() => {
10
- db = new Database(":memory:");
11
- store = new SqliteAttachmentStore(db);
12
- });
13
-
14
- afterEach(() => db.close());
15
-
16
- it("stores and retrieves bytes by ref", async () => {
17
- const bytes = Buffer.from("hello attachment");
18
- const ref = await store.put(bytes, { contentType: "text/plain" });
19
- const got = await store.get(ref);
20
- expect(got.equals(bytes)).toBe(true);
21
- });
22
-
23
- it("produces content-addressed (sha256:) refs", async () => {
24
- const ref = await store.put(Buffer.from("x"), { contentType: "text/plain" });
25
- expect(ref).toMatch(/^sha256:[0-9a-f]{64}$/);
26
- });
27
-
28
- it("dedups identical bytes to the same ref and single row", async () => {
29
- const a = await store.put(Buffer.from("same"), { contentType: "text/plain" });
30
- const b = await store.put(Buffer.from("same"), { contentType: "text/plain" });
31
- expect(a).toBe(b);
32
- const count = db
33
- .prepare("SELECT COUNT(*) AS n FROM mail_attachments")
34
- .get() as { n: number };
35
- expect(count.n).toBe(1);
36
- });
37
-
38
- it("gives different refs for different bytes", async () => {
39
- const a = await store.put(Buffer.from("one"), { contentType: "text/plain" });
40
- const b = await store.put(Buffer.from("two"), { contentType: "text/plain" });
41
- expect(a).not.toBe(b);
42
- });
43
-
44
- it("deletes by ref", async () => {
45
- const ref = await store.put(Buffer.from("gone"), { contentType: "text/plain" });
46
- await store.delete(ref);
47
- await expect(store.get(ref)).rejects.toThrow(/not found/);
48
- });
49
-
50
- it("throws on missing ref", async () => {
51
- await expect(store.get("sha256:" + "0".repeat(64))).rejects.toThrow(/not found/);
52
- });
53
-
54
- it("persists filename and size metadata", async () => {
55
- const bytes = Buffer.from("with-meta");
56
- const ref = await store.put(bytes, {
57
- contentType: "application/pdf",
58
- filename: "doc.pdf",
59
- });
60
- const row = db
61
- .prepare(
62
- "SELECT content_type, filename, size_bytes FROM mail_attachments WHERE ref = ?"
63
- )
64
- .get(ref) as { content_type: string; filename: string; size_bytes: number };
65
- expect(row.content_type).toBe("application/pdf");
66
- expect(row.filename).toBe("doc.pdf");
67
- expect(row.size_bytes).toBe(bytes.length);
68
- });
69
- });
@@ -1,115 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import {
3
- classifyDestination,
4
- findDomainConflicts,
5
- UnknownDestinationError,
6
- type DestinationContext,
7
- } from "../../src/router/destination.js";
8
-
9
- function ctx(overrides: Partial<DestinationContext> = {}): DestinationContext {
10
- return {
11
- isLocalAgent: (id) => ["alice", "bob"].includes(id),
12
- federationPeers: [{ systemId: "system-2" }],
13
- mailDomains: ["example.com"],
14
- ...overrides,
15
- };
16
- }
17
-
18
- describe("classifyDestination", () => {
19
- it("classifies a bare id as local", () => {
20
- expect(classifyDestination("alice", ctx())).toEqual({
21
- class: "local",
22
- agentId: "alice",
23
- });
24
- });
25
-
26
- it("classifies an unknown bare id as local (no domain to disambiguate)", () => {
27
- // No @ means there is no domain to route on — it is a local-namespace id.
28
- expect(classifyDestination("charlie", ctx())).toEqual({
29
- class: "local",
30
- agentId: "charlie",
31
- });
32
- });
33
-
34
- it("classifies a configured federation peer system as federation", () => {
35
- const d = classifyDestination("bob@system-2", ctx());
36
- expect(d.class).toBe("federation");
37
- if (d.class === "federation") {
38
- expect(d.address).toEqual({ agent: "bob", system: "system-2" });
39
- }
40
- });
41
-
42
- it("classifies a configured mail domain as mail", () => {
43
- expect(classifyDestination("bob@example.com", ctx())).toEqual({
44
- class: "mail",
45
- email: "bob@example.com",
46
- });
47
- });
48
-
49
- it("matches subdomains of a configured mail domain", () => {
50
- expect(classifyDestination("bob@mail.example.com", ctx())).toEqual({
51
- class: "mail",
52
- email: "bob@mail.example.com",
53
- });
54
- });
55
-
56
- it("does not match a domain that merely shares a suffix string", () => {
57
- // notexample.com must NOT match example.com
58
- expect(() => classifyDestination("bob@notexample.com", ctx())).toThrow(
59
- UnknownDestinationError
60
- );
61
- });
62
-
63
- it("throws UnknownDestinationError for an unlisted domain", () => {
64
- expect(() => classifyDestination("bob@gmail.com", ctx())).toThrow(
65
- UnknownDestinationError
66
- );
67
- });
68
-
69
- it("lets a federation peer's declared domain win over mail config", () => {
70
- // corp.internal is claimed by BOTH a peer and mail config — federation wins.
71
- const c = ctx({
72
- federationPeers: [{ systemId: "system-2", domains: ["corp.internal"] }],
73
- mailDomains: ["example.com", "corp.internal"],
74
- });
75
- const d = classifyDestination("team@corp.internal", c);
76
- expect(d.class).toBe("federation");
77
- });
78
-
79
- it("routes a dotted federation domain that is not in mail config", () => {
80
- const c = ctx({
81
- federationPeers: [{ systemId: "hub", domains: ["team.corp.internal"] }],
82
- mailDomains: ["example.com"],
83
- });
84
- expect(classifyDestination("svc@team.corp.internal", c).class).toBe(
85
- "federation"
86
- );
87
- });
88
-
89
- it("strips a trailing /scope from a mail address", () => {
90
- expect(classifyDestination("bob@example.com/work", ctx())).toEqual({
91
- class: "mail",
92
- email: "bob@example.com",
93
- });
94
- });
95
-
96
- it("is case-insensitive on domain matching", () => {
97
- expect(classifyDestination("bob@Example.COM", ctx()).class).toBe("mail");
98
- });
99
- });
100
-
101
- describe("findDomainConflicts", () => {
102
- it("reports domains claimed by both a peer and mail config", () => {
103
- const conflicts = findDomainConflicts(
104
- ctx({
105
- federationPeers: [{ systemId: "s", domains: ["corp.internal", "ok.net"] }],
106
- mailDomains: ["example.com", "corp.internal"],
107
- })
108
- );
109
- expect(conflicts).toEqual(["corp.internal"]);
110
- });
111
-
112
- it("returns empty when there is no overlap", () => {
113
- expect(findDomainConflicts(ctx())).toEqual([]);
114
- });
115
- });
@@ -1,239 +0,0 @@
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
- });