agent-inbox 0.2.2 → 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.
- package/AGENTS.md +18 -0
- package/CLAUDE.md +92 -1
- package/README.md +73 -6
- package/bench/inbox-growth.bench.ts +224 -0
- package/dist/federation/connection-manager.d.ts +8 -0
- package/dist/federation/connection-manager.d.ts.map +1 -1
- package/dist/federation/connection-manager.js +12 -0
- package/dist/federation/connection-manager.js.map +1 -1
- package/dist/federation/delivery-queue.d.ts +11 -3
- package/dist/federation/delivery-queue.d.ts.map +1 -1
- package/dist/federation/delivery-queue.js +38 -8
- package/dist/federation/delivery-queue.js.map +1 -1
- package/dist/federation/queue-store.d.ts +42 -0
- package/dist/federation/queue-store.d.ts.map +1 -0
- package/dist/federation/queue-store.js +87 -0
- package/dist/federation/queue-store.js.map +1 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +124 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -0
- package/dist/index.mjs.map +1 -0
- package/dist/jsonrpc/mail-push-types.d.ts +9 -0
- package/dist/jsonrpc/mail-push-types.d.ts.map +1 -1
- package/dist/jsonrpc/mail-push-types.js +1 -0
- package/dist/jsonrpc/mail-push-types.js.map +1 -1
- package/dist/jsonrpc/mail-server.d.ts +8 -1
- package/dist/jsonrpc/mail-server.d.ts.map +1 -1
- package/dist/jsonrpc/mail-server.js +42 -1
- package/dist/jsonrpc/mail-server.js.map +1 -1
- package/dist/mail/address-book.d.ts +43 -0
- package/dist/mail/address-book.d.ts.map +1 -0
- package/dist/mail/address-book.js +95 -0
- package/dist/mail/address-book.js.map +1 -0
- package/dist/mail/attachment-store.d.ts +31 -0
- package/dist/mail/attachment-store.d.ts.map +1 -0
- package/dist/mail/attachment-store.js +74 -0
- package/dist/mail/attachment-store.js.map +1 -0
- package/dist/mail/email-mapper.d.ts +41 -0
- package/dist/mail/email-mapper.d.ts.map +1 -0
- package/dist/mail/email-mapper.js +216 -0
- package/dist/mail/email-mapper.js.map +1 -0
- package/dist/mail/fs-attachment-store.d.ts +38 -0
- package/dist/mail/fs-attachment-store.d.ts.map +1 -0
- package/dist/mail/fs-attachment-store.js +165 -0
- package/dist/mail/fs-attachment-store.js.map +1 -0
- package/dist/mail/mail-gateway.d.ts +114 -0
- package/dist/mail/mail-gateway.d.ts.map +1 -0
- package/dist/mail/mail-gateway.js +402 -0
- package/dist/mail/mail-gateway.js.map +1 -0
- package/dist/mail/provider-transport.d.ts +138 -0
- package/dist/mail/provider-transport.d.ts.map +1 -0
- package/dist/mail/provider-transport.js +434 -0
- package/dist/mail/provider-transport.js.map +1 -0
- package/dist/mail/rate-limiter.d.ts +20 -0
- package/dist/mail/rate-limiter.d.ts.map +1 -0
- package/dist/mail/rate-limiter.js +56 -0
- package/dist/mail/rate-limiter.js.map +1 -0
- package/dist/mail/smtp-transport.d.ts +141 -0
- package/dist/mail/smtp-transport.d.ts.map +1 -0
- package/dist/mail/smtp-transport.js +415 -0
- package/dist/mail/smtp-transport.js.map +1 -0
- package/dist/mail/types.d.ts +177 -0
- package/dist/mail/types.d.ts.map +1 -0
- package/dist/mail/types.js +11 -0
- package/dist/mail/types.js.map +1 -0
- package/dist/push/notifier.d.ts +21 -0
- package/dist/push/notifier.d.ts.map +1 -1
- package/dist/push/notifier.js +84 -2
- package/dist/push/notifier.js.map +1 -1
- package/dist/router/destination.d.ts +69 -0
- package/dist/router/destination.d.ts.map +1 -0
- package/dist/router/destination.js +106 -0
- package/dist/router/destination.js.map +1 -0
- package/dist/router/message-router.d.ts +15 -0
- package/dist/router/message-router.d.ts.map +1 -1
- package/dist/router/message-router.js +25 -3
- package/dist/router/message-router.js.map +1 -1
- package/dist/storage/interface.d.ts +21 -0
- package/dist/storage/interface.d.ts.map +1 -1
- package/dist/storage/memory.d.ts +12 -0
- package/dist/storage/memory.d.ts.map +1 -1
- package/dist/storage/memory.js +50 -0
- package/dist/storage/memory.js.map +1 -1
- package/dist/storage/sqlite.d.ts +14 -0
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +79 -1
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/traceability/traceability.d.ts.map +1 -1
- package/dist/traceability/traceability.js +7 -17
- package/dist/traceability/traceability.js.map +1 -1
- package/dist/types.d.ts +80 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/DESIGN.md +15 -0
- package/docs/MAIL-INTEROP-PLAN.md +660 -0
- package/package.json +29 -3
- package/renovate.json5 +6 -0
- package/rules/agent-inbox.md +1 -0
- package/src/federation/connection-manager.ts +12 -0
- package/src/federation/delivery-queue.ts +38 -8
- package/src/federation/queue-store.ts +124 -0
- package/src/index.ts +186 -1
- package/src/jsonrpc/mail-push-types.ts +10 -0
- package/src/jsonrpc/mail-server.ts +48 -1
- package/src/mail/address-book.ts +111 -0
- package/src/mail/attachment-store.ts +90 -0
- package/src/mail/email-mapper.ts +288 -0
- package/src/mail/fs-attachment-store.ts +163 -0
- package/src/mail/mail-gateway.ts +505 -0
- package/src/mail/provider-transport.ts +577 -0
- package/src/mail/rate-limiter.ts +51 -0
- package/src/mail/smtp-transport.ts +589 -0
- package/src/mail/types.ts +221 -0
- package/src/push/notifier.ts +98 -2
- package/src/router/destination.ts +140 -0
- package/src/router/message-router.ts +41 -4
- package/src/storage/interface.ts +22 -0
- package/src/storage/memory.ts +59 -0
- package/src/storage/sqlite.ts +114 -1
- package/src/traceability/traceability.ts +7 -16
- package/src/types.ts +74 -0
- package/test/federation/delivery-queue-sqlite.test.ts +158 -0
- package/test/load.test.ts +288 -0
- package/test/mail/address-book.test.ts +111 -0
- package/test/mail/attachment-store-contract.test.ts +92 -0
- package/test/mail/attachment-store.test.ts +69 -0
- package/test/mail/destination.test.ts +115 -0
- package/test/mail/dsn-parse.test.ts +239 -0
- package/test/mail/email-mapper.test.ts +341 -0
- package/test/mail/external-id.test.ts +43 -0
- package/test/mail/fs-attachment-store.test.ts +134 -0
- package/test/mail/full-flow-e2e.test.ts +200 -0
- package/test/mail/mail-gateway.test.ts +419 -0
- package/test/mail/mail-transport-contract.test.ts +134 -0
- package/test/mail/mock-mail.ts +161 -0
- package/test/mail/mock-postmark.ts +66 -0
- package/test/mail/provider-transport.test.ts +381 -0
- package/test/mail/rate-limiter.test.ts +48 -0
- package/test/mail/router-mail-integration.test.ts +138 -0
- package/test/mail/smtp-e2e.test.ts +98 -0
- package/test/mail/smtp-transport.test.ts +138 -0
- package/test/mail-presence.test.ts +149 -0
- package/test/mail-push.test.ts +44 -0
- package/test/mail-server.test.ts +25 -0
- package/test/push-notifier.test.ts +81 -0
- package/test/sqlite-storage.test.ts +106 -0
- package/test/storage.test.ts +92 -0
- package/vitest.bench.config.ts +8 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process mail transport for tests, plus the harness abstraction the shared
|
|
3
|
+
* contract suite runs against.
|
|
4
|
+
*
|
|
5
|
+
* A real backend's test wrapper implements MailTransportHarness too, so the
|
|
6
|
+
* same contract suite (mail-transport-contract.test.ts) validates it without
|
|
7
|
+
* modification. See docs/MAIL-INTEROP-PLAN.md §11.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
MailTransport,
|
|
12
|
+
MailCapabilities,
|
|
13
|
+
MailTransportState,
|
|
14
|
+
MailHealth,
|
|
15
|
+
OutboundMail,
|
|
16
|
+
MailSendResult,
|
|
17
|
+
InboundMail,
|
|
18
|
+
InboundHandler,
|
|
19
|
+
} from "../../src/mail/types.js";
|
|
20
|
+
|
|
21
|
+
const DEFAULT_CAPS: MailCapabilities = {
|
|
22
|
+
outbound: "api",
|
|
23
|
+
signsDkim: true,
|
|
24
|
+
verifiesInboundAuth: true,
|
|
25
|
+
inbound: "webhook",
|
|
26
|
+
maxMessageBytes: 25 * 1024 * 1024,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The control surface a contract test needs over any transport: drive inbound,
|
|
31
|
+
* and program the next outbound outcome. Real backends provide their own.
|
|
32
|
+
*/
|
|
33
|
+
export interface MailTransportHarness {
|
|
34
|
+
transport: MailTransport;
|
|
35
|
+
/** Drive one inbound message; resolves on ACK, rejects on NACK (handler throw). */
|
|
36
|
+
injectInbound(mail: InboundMail): Promise<void>;
|
|
37
|
+
/** Force the next send() to produce this result. */
|
|
38
|
+
programNextSend(result: MailSendResult): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class MockMailTransport implements MailTransport {
|
|
42
|
+
readonly capabilities: MailCapabilities;
|
|
43
|
+
private _state: MailTransportState = "stopped";
|
|
44
|
+
private handler?: InboundHandler;
|
|
45
|
+
|
|
46
|
+
/** Recorded successfully-issued sends (one entry per external email produced). */
|
|
47
|
+
readonly sent: OutboundMail[] = [];
|
|
48
|
+
/** idempotencyKeys that have already produced a delivered external email. */
|
|
49
|
+
private delivered = new Set<string>();
|
|
50
|
+
private programmed: MailSendResult[] = [];
|
|
51
|
+
|
|
52
|
+
constructor(caps: Partial<MailCapabilities> = {}) {
|
|
53
|
+
this.capabilities = { ...DEFAULT_CAPS, ...caps };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get state(): MailTransportState {
|
|
57
|
+
return this._state;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async start(): Promise<void> {
|
|
61
|
+
if (this._state === "ready") return; // idempotent
|
|
62
|
+
this._state = "starting";
|
|
63
|
+
this._state = "ready";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async stop(): Promise<void> {
|
|
67
|
+
this._state = "stopping";
|
|
68
|
+
this._state = "stopped";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async health(): Promise<MailHealth> {
|
|
72
|
+
return { state: this._state };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
onReceive(handler: InboundHandler): void {
|
|
76
|
+
this.handler = handler;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
programNextSend(result: MailSendResult): void {
|
|
80
|
+
this.programmed.push(result);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async send(envelope: OutboundMail): Promise<MailSendResult> {
|
|
84
|
+
if (this._state !== "ready") {
|
|
85
|
+
throw new Error(`send() called while transport state is "${this._state}"`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Idempotent: a duplicate key for an already-delivered message must not
|
|
89
|
+
// produce a second external email.
|
|
90
|
+
if (this.delivered.has(envelope.idempotencyKey)) {
|
|
91
|
+
return {
|
|
92
|
+
disposition: "delivered",
|
|
93
|
+
remoteMessageId: `mock-dup-${envelope.idempotencyKey}`,
|
|
94
|
+
detail: "idempotent replay",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const result: MailSendResult =
|
|
99
|
+
this.programmed.shift() ?? {
|
|
100
|
+
disposition: "delivered",
|
|
101
|
+
remoteMessageId: `mock-${envelope.idempotencyKey}`,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
if (result.disposition === "delivered") {
|
|
105
|
+
this.sent.push(envelope);
|
|
106
|
+
this.delivered.add(envelope.idempotencyKey);
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Test helper: drive an inbound message through the registered handler. */
|
|
112
|
+
async deliver(mail: InboundMail): Promise<void> {
|
|
113
|
+
if (!this.handler) throw new Error("no inbound handler registered");
|
|
114
|
+
if (this._state !== "ready") {
|
|
115
|
+
throw new Error(`deliver() while state is "${this._state}"`);
|
|
116
|
+
}
|
|
117
|
+
await this.handler(mail); // throw bubbles up as a NACK
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Build a MailTransportHarness around a MockMailTransport. */
|
|
122
|
+
export function mockMailHarness(
|
|
123
|
+
caps?: Partial<MailCapabilities>
|
|
124
|
+
): MailTransportHarness & { mock: MockMailTransport } {
|
|
125
|
+
const mock = new MockMailTransport(caps);
|
|
126
|
+
return {
|
|
127
|
+
mock,
|
|
128
|
+
transport: mock,
|
|
129
|
+
injectInbound: (mail) => mock.deliver(mail),
|
|
130
|
+
programNextSend: (result) => mock.programNextSend(result),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Minimal valid InboundMail for tests; override fields as needed. */
|
|
135
|
+
export function makeInboundMail(over: Partial<InboundMail> = {}): InboundMail {
|
|
136
|
+
return {
|
|
137
|
+
envelopeFrom: "ext@example.org",
|
|
138
|
+
envelopeTo: ["agent@example.com"],
|
|
139
|
+
from: { address: "ext@example.org" },
|
|
140
|
+
to: [{ address: "agent@example.com" }],
|
|
141
|
+
subject: "hello",
|
|
142
|
+
text: "hi there",
|
|
143
|
+
headers: { raw: {} },
|
|
144
|
+
sizeBytes: 100,
|
|
145
|
+
receivedAt: new Date().toISOString(),
|
|
146
|
+
...over,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Minimal valid OutboundMail for tests. */
|
|
151
|
+
export function makeOutboundMail(over: Partial<OutboundMail> = {}): OutboundMail {
|
|
152
|
+
return {
|
|
153
|
+
idempotencyKey: "msg-1",
|
|
154
|
+
from: { address: "agent@example.com" },
|
|
155
|
+
to: [{ address: "ext@example.org" }],
|
|
156
|
+
subject: "re: hello",
|
|
157
|
+
text: "reply body",
|
|
158
|
+
headers: { messageId: "<msg-1@example.com>" },
|
|
159
|
+
...over,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fake Postmark client + harness so the shared MailTransport contract suite can
|
|
3
|
+
* run against PostmarkTransport without the SDK or network. The fake produces
|
|
4
|
+
* raw Postmark responses/errors keyed by a desired disposition, and the real
|
|
5
|
+
* transport mapping turns them into MailSendResults — so the contract suite
|
|
6
|
+
* also exercises postmarkResponseToResult / postmarkErrorToResult.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
PostmarkTransport,
|
|
11
|
+
type PostmarkClient,
|
|
12
|
+
type PostmarkSendResponse,
|
|
13
|
+
} from "../../src/mail/provider-transport.js";
|
|
14
|
+
import type { MailSendResult } from "../../src/mail/types.js";
|
|
15
|
+
import type { MailTransportHarness } from "./mock-mail.js";
|
|
16
|
+
|
|
17
|
+
type Disposition = MailSendResult["disposition"];
|
|
18
|
+
|
|
19
|
+
export interface FakePostmarkClient extends PostmarkClient {
|
|
20
|
+
/** Raw messages passed to sendEmail. */
|
|
21
|
+
sent: Record<string, unknown>[];
|
|
22
|
+
/** Program the next send's outcome. */
|
|
23
|
+
program(disposition: Disposition): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function fakePostmarkClient(): FakePostmarkClient {
|
|
27
|
+
const queue: Disposition[] = [];
|
|
28
|
+
const sent: Record<string, unknown>[] = [];
|
|
29
|
+
let counter = 0;
|
|
30
|
+
return {
|
|
31
|
+
sent,
|
|
32
|
+
program(d) {
|
|
33
|
+
queue.push(d);
|
|
34
|
+
},
|
|
35
|
+
async sendEmail(msg): Promise<PostmarkSendResponse> {
|
|
36
|
+
sent.push(msg);
|
|
37
|
+
const d = queue.shift() ?? "delivered";
|
|
38
|
+
if (d === "delivered") {
|
|
39
|
+
return { ErrorCode: 0, MessageID: `pm-${++counter}` };
|
|
40
|
+
}
|
|
41
|
+
if (d === "transient") {
|
|
42
|
+
throw Object.assign(new Error("rate limited"), { statusCode: 429 });
|
|
43
|
+
}
|
|
44
|
+
// permanent — inactive recipient (Postmark code 406, HTTP 422)
|
|
45
|
+
throw Object.assign(new Error("inactive recipient"), {
|
|
46
|
+
statusCode: 422,
|
|
47
|
+
code: 406,
|
|
48
|
+
});
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function postmarkHarness(): MailTransportHarness & {
|
|
54
|
+
client: FakePostmarkClient;
|
|
55
|
+
postmark: PostmarkTransport;
|
|
56
|
+
} {
|
|
57
|
+
const client = fakePostmarkClient();
|
|
58
|
+
const transport = new PostmarkTransport({ client });
|
|
59
|
+
return {
|
|
60
|
+
client,
|
|
61
|
+
postmark: transport,
|
|
62
|
+
transport,
|
|
63
|
+
injectInbound: (mail) => transport.receiveInbound(mail),
|
|
64
|
+
programNextSend: (result) => client.program(result.disposition),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
PostmarkTransport,
|
|
4
|
+
outboundToPostmark,
|
|
5
|
+
postmarkInboundToMail,
|
|
6
|
+
postmarkBounceToInbound,
|
|
7
|
+
postmarkResponseToResult,
|
|
8
|
+
postmarkErrorToResult,
|
|
9
|
+
parseAuthResults,
|
|
10
|
+
type PostmarkInbound,
|
|
11
|
+
} from "../../src/mail/provider-transport.js";
|
|
12
|
+
import * as http from "node:http";
|
|
13
|
+
import { makeOutboundMail } from "./mock-mail.js";
|
|
14
|
+
import { fakePostmarkClient } from "./mock-postmark.js";
|
|
15
|
+
|
|
16
|
+
function post(
|
|
17
|
+
port: number,
|
|
18
|
+
path: string,
|
|
19
|
+
body: unknown,
|
|
20
|
+
headers: Record<string, string> = {}
|
|
21
|
+
): Promise<number> {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const data = Buffer.from(JSON.stringify(body));
|
|
24
|
+
const req = http.request(
|
|
25
|
+
{
|
|
26
|
+
host: "127.0.0.1",
|
|
27
|
+
port,
|
|
28
|
+
path,
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: {
|
|
31
|
+
"content-type": "application/json",
|
|
32
|
+
"content-length": data.length,
|
|
33
|
+
...headers,
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
(res) => {
|
|
37
|
+
res.resume();
|
|
38
|
+
res.on("end", () => resolve(res.statusCode ?? 0));
|
|
39
|
+
}
|
|
40
|
+
);
|
|
41
|
+
req.on("error", reject);
|
|
42
|
+
req.end(data);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("postmarkResponseToResult / postmarkErrorToResult", () => {
|
|
47
|
+
it("maps ErrorCode 0 to delivered with the provider message id", () => {
|
|
48
|
+
expect(postmarkResponseToResult({ ErrorCode: 0, MessageID: "pm-9" })).toEqual({
|
|
49
|
+
disposition: "delivered",
|
|
50
|
+
remoteMessageId: "pm-9",
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("maps 429 to transient", () => {
|
|
55
|
+
expect(postmarkErrorToResult({ statusCode: 429, message: "rate" }).disposition).toBe(
|
|
56
|
+
"transient"
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("maps 5xx to transient", () => {
|
|
61
|
+
expect(postmarkErrorToResult({ statusCode: 500 }).disposition).toBe("transient");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("maps inactive-recipient (422/406) to permanent", () => {
|
|
65
|
+
const r = postmarkErrorToResult({ statusCode: 422, code: 406, message: "inactive" });
|
|
66
|
+
expect(r.disposition).toBe("permanent");
|
|
67
|
+
expect(r.code).toBe(406);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("maps invalid-email (300) to permanent", () => {
|
|
71
|
+
expect(postmarkErrorToResult({ statusCode: 422, code: 300 }).disposition).toBe(
|
|
72
|
+
"permanent"
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("treats unknown errors as transient", () => {
|
|
77
|
+
expect(postmarkErrorToResult({ message: "boom" }).disposition).toBe("transient");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("outboundToPostmark", () => {
|
|
82
|
+
it("maps addresses, body, and threading headers", () => {
|
|
83
|
+
const msg = outboundToPostmark(
|
|
84
|
+
makeOutboundMail({
|
|
85
|
+
from: { address: "alice@example.com", name: "Alice" },
|
|
86
|
+
to: [{ address: "ext@other.org" }],
|
|
87
|
+
cc: [{ address: "c@other.org" }],
|
|
88
|
+
subject: "subject",
|
|
89
|
+
text: "body",
|
|
90
|
+
html: "<p>body</p>",
|
|
91
|
+
headers: {
|
|
92
|
+
messageId: "<m@example.com>",
|
|
93
|
+
inReplyTo: "<p@other.org>",
|
|
94
|
+
references: ["<r1@other.org>", "<p@other.org>"],
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
);
|
|
98
|
+
expect(msg.From).toBe('"Alice" <alice@example.com>');
|
|
99
|
+
expect(msg.To).toBe("ext@other.org");
|
|
100
|
+
expect(msg.Cc).toBe("c@other.org");
|
|
101
|
+
expect(msg.Subject).toBe("subject");
|
|
102
|
+
expect(msg.TextBody).toBe("body");
|
|
103
|
+
expect(msg.HtmlBody).toBe("<p>body</p>");
|
|
104
|
+
expect(msg.Headers).toContainEqual({ Name: "Message-ID", Value: "<m@example.com>" });
|
|
105
|
+
expect(msg.Headers).toContainEqual({ Name: "In-Reply-To", Value: "<p@other.org>" });
|
|
106
|
+
expect(msg.Headers).toContainEqual({
|
|
107
|
+
Name: "References",
|
|
108
|
+
Value: "<r1@other.org> <p@other.org>",
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("includes base64 attachment content when resolved", () => {
|
|
113
|
+
const msg = outboundToPostmark(
|
|
114
|
+
makeOutboundMail({
|
|
115
|
+
attachments: [
|
|
116
|
+
{
|
|
117
|
+
filename: "a.txt",
|
|
118
|
+
contentType: "text/plain",
|
|
119
|
+
contentRef: "sha256:abc",
|
|
120
|
+
sizeBytes: 3,
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
}),
|
|
124
|
+
new Map([["sha256:abc", "Zm9v"]])
|
|
125
|
+
);
|
|
126
|
+
expect(msg.Attachments).toEqual([
|
|
127
|
+
{ Name: "a.txt", ContentType: "text/plain", ContentID: undefined, Content: "Zm9v" },
|
|
128
|
+
]);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("postmarkInboundToMail", () => {
|
|
133
|
+
const payload: PostmarkInbound = {
|
|
134
|
+
FromFull: { Email: "ext@other.org", Name: "Ext" },
|
|
135
|
+
ToFull: [{ Email: "alice@example.com" }],
|
|
136
|
+
CcFull: [{ Email: "bob@example.com" }],
|
|
137
|
+
OriginalRecipient: "alice+scope@example.com",
|
|
138
|
+
Subject: "hi",
|
|
139
|
+
TextBody: "hello",
|
|
140
|
+
HtmlBody: "<p>hello</p>",
|
|
141
|
+
Date: "2026-02-02T10:00:00Z",
|
|
142
|
+
Headers: [
|
|
143
|
+
{ Name: "Message-ID", Value: "<m1@other.org>" },
|
|
144
|
+
{ Name: "In-Reply-To", Value: "<p0@other.org>" },
|
|
145
|
+
{ Name: "References", Value: "<root@other.org> <p0@other.org>" },
|
|
146
|
+
{
|
|
147
|
+
Name: "Authentication-Results",
|
|
148
|
+
Value: "mx.example.com; spf=pass; dkim=pass; dmarc=pass",
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
it("maps core fields and threading headers", () => {
|
|
154
|
+
const mail = postmarkInboundToMail(payload);
|
|
155
|
+
expect(mail.from).toEqual({ address: "ext@other.org", name: "Ext" });
|
|
156
|
+
expect(mail.to).toEqual([{ address: "alice@example.com", name: undefined }]);
|
|
157
|
+
expect(mail.cc).toEqual([{ address: "bob@example.com", name: undefined }]);
|
|
158
|
+
expect(mail.subject).toBe("hi");
|
|
159
|
+
expect(mail.text).toBe("hello");
|
|
160
|
+
expect(mail.html).toBe("<p>hello</p>");
|
|
161
|
+
expect(mail.headers.messageId).toBe("<m1@other.org>");
|
|
162
|
+
expect(mail.headers.inReplyTo).toBe("<p0@other.org>");
|
|
163
|
+
expect(mail.headers.references).toEqual(["<root@other.org>", "<p0@other.org>"]);
|
|
164
|
+
expect(mail.envelopeTo).toEqual(["alice+scope@example.com"]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("extracts auth results from the Authentication-Results header", () => {
|
|
168
|
+
const mail = postmarkInboundToMail(payload);
|
|
169
|
+
expect(mail.authResults).toEqual({ spf: "pass", dkim: "pass", dmarc: "pass" });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("attaches stored attachment metadata", () => {
|
|
173
|
+
const mail = postmarkInboundToMail(payload, {
|
|
174
|
+
attachments: [
|
|
175
|
+
{ filename: "x.pdf", contentType: "application/pdf", contentRef: "sha256:z", sizeBytes: 9 },
|
|
176
|
+
],
|
|
177
|
+
});
|
|
178
|
+
expect(mail.attachments).toHaveLength(1);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("counts attachment bytes toward sizeBytes (consistent with the SMTP path)", () => {
|
|
182
|
+
const bodyOnly = postmarkInboundToMail({ TextBody: "hi" });
|
|
183
|
+
const withAttachment = postmarkInboundToMail({
|
|
184
|
+
TextBody: "hi",
|
|
185
|
+
Attachments: [
|
|
186
|
+
{ Name: "big.bin", Content: "", ContentType: "application/octet-stream", ContentLength: 100_000 },
|
|
187
|
+
],
|
|
188
|
+
});
|
|
189
|
+
expect(withAttachment.sizeBytes).toBe(bodyOnly.sizeBytes + 100_000);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("postmarkBounceToInbound", () => {
|
|
194
|
+
it("maps a hard bounce to a failed DSN", () => {
|
|
195
|
+
const mail = postmarkBounceToInbound({
|
|
196
|
+
RecordType: "Bounce",
|
|
197
|
+
Type: "HardBounce",
|
|
198
|
+
TypeCode: 1,
|
|
199
|
+
Email: "user@dead.example",
|
|
200
|
+
MessageID: "pm-123",
|
|
201
|
+
Description: "The server was unable to deliver",
|
|
202
|
+
Details: "550 no such user",
|
|
203
|
+
});
|
|
204
|
+
expect(mail.bounce).toEqual({
|
|
205
|
+
action: "failed",
|
|
206
|
+
recipient: "user@dead.example",
|
|
207
|
+
status: "1",
|
|
208
|
+
originalMessageId: "pm-123",
|
|
209
|
+
diagnostic: "550 no such user",
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("maps a soft bounce to a delayed DSN", () => {
|
|
214
|
+
const mail = postmarkBounceToInbound({ Type: "SoftBounce", Email: "u@x" });
|
|
215
|
+
expect(mail.bounce?.action).toBe("delayed");
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("parseAuthResults", () => {
|
|
220
|
+
it("returns undefined without a header", () => {
|
|
221
|
+
expect(parseAuthResults(undefined)).toBeUndefined();
|
|
222
|
+
});
|
|
223
|
+
it("parses a mixed verdict", () => {
|
|
224
|
+
expect(parseAuthResults("x; spf=fail; dkim=none; dmarc=fail")).toEqual({
|
|
225
|
+
spf: "fail",
|
|
226
|
+
dkim: "none",
|
|
227
|
+
dmarc: "fail",
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe("PostmarkTransport send (fake client)", () => {
|
|
233
|
+
it("sends via the client and maps a delivered response", async () => {
|
|
234
|
+
const client = fakePostmarkClient();
|
|
235
|
+
const t = new PostmarkTransport({ client });
|
|
236
|
+
await t.start();
|
|
237
|
+
const res = await t.send(makeOutboundMail({ idempotencyKey: "k1" }));
|
|
238
|
+
expect(res.disposition).toBe("delivered");
|
|
239
|
+
expect(client.sent).toHaveLength(1);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("is idempotent: a duplicate key does not re-send", async () => {
|
|
243
|
+
const client = fakePostmarkClient();
|
|
244
|
+
const t = new PostmarkTransport({ client });
|
|
245
|
+
await t.start();
|
|
246
|
+
const env = makeOutboundMail({ idempotencyKey: "dup" });
|
|
247
|
+
await t.send(env);
|
|
248
|
+
const second = await t.send(env);
|
|
249
|
+
expect(second.detail).toMatch(/idempotent|replay/i);
|
|
250
|
+
expect(client.sent).toHaveLength(1);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("ingests an inbound webhook payload and ACKs after the handler", async () => {
|
|
254
|
+
const client = fakePostmarkClient();
|
|
255
|
+
const t = new PostmarkTransport({ client });
|
|
256
|
+
const order: string[] = [];
|
|
257
|
+
t.onReceive(async (mail) => {
|
|
258
|
+
order.push(`handler:${mail.subject}`);
|
|
259
|
+
});
|
|
260
|
+
await t.start();
|
|
261
|
+
await t.ingestInboundWebhook({
|
|
262
|
+
FromFull: { Email: "ext@other.org" },
|
|
263
|
+
ToFull: [{ Email: "alice@example.com" }],
|
|
264
|
+
Subject: "webhook subject",
|
|
265
|
+
TextBody: "body",
|
|
266
|
+
Headers: [{ Name: "Message-ID", Value: "<w1@other.org>" }],
|
|
267
|
+
});
|
|
268
|
+
expect(order).toEqual(["handler:webhook subject"]);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("propagates a handler throw from a bounce webhook (NACK)", async () => {
|
|
272
|
+
const client = fakePostmarkClient();
|
|
273
|
+
const t = new PostmarkTransport({ client });
|
|
274
|
+
t.onReceive(async () => {
|
|
275
|
+
throw new Error("commit failed");
|
|
276
|
+
});
|
|
277
|
+
await t.start();
|
|
278
|
+
await expect(
|
|
279
|
+
t.ingestBounceWebhook({ Type: "HardBounce", Email: "u@x", MessageID: "pm-1" })
|
|
280
|
+
).rejects.toThrow(/commit failed/);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe("PostmarkTransport built-in webhook listener", () => {
|
|
285
|
+
it("returns 200 after the inbound handler commits, 500 on throw", async () => {
|
|
286
|
+
const port = 31000 + Math.floor(Math.random() * 4000);
|
|
287
|
+
const client = fakePostmarkClient();
|
|
288
|
+
let shouldThrow = false;
|
|
289
|
+
const received: string[] = [];
|
|
290
|
+
const t = new PostmarkTransport({
|
|
291
|
+
client,
|
|
292
|
+
webhook: { port, inboundPath: "/in", bouncePath: "/bounce" },
|
|
293
|
+
});
|
|
294
|
+
t.onReceive(async (mail) => {
|
|
295
|
+
if (shouldThrow) throw new Error("commit failed");
|
|
296
|
+
received.push(mail.subject ?? "");
|
|
297
|
+
});
|
|
298
|
+
await t.start();
|
|
299
|
+
try {
|
|
300
|
+
const ok = await post(port, "/in", {
|
|
301
|
+
FromFull: { Email: "ext@other.org" },
|
|
302
|
+
ToFull: [{ Email: "alice@example.com" }],
|
|
303
|
+
Subject: "http subject",
|
|
304
|
+
TextBody: "b",
|
|
305
|
+
Headers: [{ Name: "Message-ID", Value: "<h1@x>" }],
|
|
306
|
+
});
|
|
307
|
+
expect(ok).toBe(200);
|
|
308
|
+
expect(received).toEqual(["http subject"]);
|
|
309
|
+
|
|
310
|
+
shouldThrow = true;
|
|
311
|
+
const fail = await post(port, "/in", {
|
|
312
|
+
FromFull: { Email: "ext@other.org" },
|
|
313
|
+
ToFull: [{ Email: "alice@example.com" }],
|
|
314
|
+
Subject: "will fail",
|
|
315
|
+
Headers: [{ Name: "Message-ID", Value: "<h2@x>" }],
|
|
316
|
+
});
|
|
317
|
+
expect(fail).toBe(500);
|
|
318
|
+
} finally {
|
|
319
|
+
await t.stop();
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("enforces Basic Auth on the webhook when configured", async () => {
|
|
324
|
+
const port = 31000 + Math.floor(Math.random() * 4000);
|
|
325
|
+
const received: string[] = [];
|
|
326
|
+
const t = new PostmarkTransport({
|
|
327
|
+
client: fakePostmarkClient(),
|
|
328
|
+
webhook: {
|
|
329
|
+
port,
|
|
330
|
+
inboundPath: "/in",
|
|
331
|
+
basicAuth: { user: "pm", pass: "secret" },
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
t.onReceive(async (mail) => {
|
|
335
|
+
received.push(mail.subject ?? "");
|
|
336
|
+
});
|
|
337
|
+
await t.start();
|
|
338
|
+
try {
|
|
339
|
+
const body = {
|
|
340
|
+
FromFull: { Email: "ext@other.org" },
|
|
341
|
+
ToFull: [{ Email: "alice@example.com" }],
|
|
342
|
+
Subject: "authed",
|
|
343
|
+
Headers: [{ Name: "Message-ID", Value: "<a1@x>" }],
|
|
344
|
+
};
|
|
345
|
+
// No credentials → 401, handler not called.
|
|
346
|
+
expect(await post(port, "/in", body)).toBe(401);
|
|
347
|
+
// Wrong credentials → 401.
|
|
348
|
+
const wrong = "Basic " + Buffer.from("pm:nope").toString("base64");
|
|
349
|
+
expect(await post(port, "/in", body, { authorization: wrong })).toBe(401);
|
|
350
|
+
expect(received).toHaveLength(0);
|
|
351
|
+
// Correct credentials → 200, handler called.
|
|
352
|
+
const right = "Basic " + Buffer.from("pm:secret").toString("base64");
|
|
353
|
+
expect(await post(port, "/in", body, { authorization: right })).toBe(200);
|
|
354
|
+
expect(received).toEqual(["authed"]);
|
|
355
|
+
} finally {
|
|
356
|
+
await t.stop();
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("rejects an oversized webhook body with 413", async () => {
|
|
361
|
+
const port = 31000 + Math.floor(Math.random() * 4000);
|
|
362
|
+
const received: string[] = [];
|
|
363
|
+
// Small maxMessageBytes → maxBody falls to the 1 MiB floor.
|
|
364
|
+
const t = new PostmarkTransport({
|
|
365
|
+
client: fakePostmarkClient(),
|
|
366
|
+
maxMessageBytes: 1000,
|
|
367
|
+
webhook: { port, inboundPath: "/in" },
|
|
368
|
+
});
|
|
369
|
+
t.onReceive(async (mail) => {
|
|
370
|
+
received.push(mail.subject ?? "");
|
|
371
|
+
});
|
|
372
|
+
await t.start();
|
|
373
|
+
try {
|
|
374
|
+
const huge = { TextBody: "x".repeat(1_200_000), Subject: "too big" };
|
|
375
|
+
expect(await post(port, "/in", huge)).toBe(413);
|
|
376
|
+
expect(received).toHaveLength(0);
|
|
377
|
+
} finally {
|
|
378
|
+
await t.stop();
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { RateLimiter } from "../../src/mail/rate-limiter.js";
|
|
3
|
+
|
|
4
|
+
describe("RateLimiter", () => {
|
|
5
|
+
it("allows up to max events in the window", () => {
|
|
6
|
+
const rl = new RateLimiter(1000, 3);
|
|
7
|
+
expect(rl.tryAcquire("a", 0)).toBe(true);
|
|
8
|
+
expect(rl.tryAcquire("a", 1)).toBe(true);
|
|
9
|
+
expect(rl.tryAcquire("a", 2)).toBe(true);
|
|
10
|
+
expect(rl.tryAcquire("a", 3)).toBe(false); // 4th in window
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("tracks keys independently", () => {
|
|
14
|
+
const rl = new RateLimiter(1000, 1);
|
|
15
|
+
expect(rl.tryAcquire("a", 0)).toBe(true);
|
|
16
|
+
expect(rl.tryAcquire("b", 0)).toBe(true);
|
|
17
|
+
expect(rl.tryAcquire("a", 0)).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("frees capacity as events age out of the window", () => {
|
|
21
|
+
const rl = new RateLimiter(1000, 2);
|
|
22
|
+
expect(rl.tryAcquire("a", 0)).toBe(true);
|
|
23
|
+
expect(rl.tryAcquire("a", 500)).toBe(true);
|
|
24
|
+
expect(rl.tryAcquire("a", 600)).toBe(false);
|
|
25
|
+
// At t=1001 the t=0 event has aged out (cutoff = 1, 0 <= 1).
|
|
26
|
+
expect(rl.tryAcquire("a", 1001)).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("treats max <= 0 as fully closed", () => {
|
|
30
|
+
const rl = new RateLimiter(1000, 0);
|
|
31
|
+
expect(rl.tryAcquire("a", 0)).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("prune drops idle keys", () => {
|
|
35
|
+
const rl = new RateLimiter(1000, 5);
|
|
36
|
+
rl.tryAcquire("a", 0);
|
|
37
|
+
rl.prune(5000);
|
|
38
|
+
expect(rl.count("a", 5000)).toBe(0);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("count reflects events within the window", () => {
|
|
42
|
+
const rl = new RateLimiter(1000, 5);
|
|
43
|
+
rl.tryAcquire("a", 0);
|
|
44
|
+
rl.tryAcquire("a", 100);
|
|
45
|
+
expect(rl.count("a", 200)).toBe(2);
|
|
46
|
+
expect(rl.count("a", 1050)).toBe(1); // t=0 aged out (cutoff=50), t=100 remains
|
|
47
|
+
});
|
|
48
|
+
});
|