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,577 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Postmark provider MailTransport — the managed swap-in backend.
|
|
3
|
-
*
|
|
4
|
-
* Proves the MailTransport contract holds for an API/webhook world (vs the
|
|
5
|
-
* raw-SMTP world of SmtpTransport). The same shared contract suite validates it
|
|
6
|
-
* (test/mail/mail-transport-contract.test.ts) via a harness.
|
|
7
|
-
*
|
|
8
|
-
* Outbound: Postmark REST API. The SDK is an optional peer dep, loaded via
|
|
9
|
-
* dynamic import; a client can also be injected (tests / custom).
|
|
10
|
-
* Inbound: Postmark webhooks POST parsed JSON. `ingestInboundWebhook` /
|
|
11
|
-
* `ingestBounceWebhook` map the payload and await the handler BEFORE
|
|
12
|
-
* the caller returns 200 (commit-before-ACK); a throw → 5xx so
|
|
13
|
-
* Postmark retries. An optional built-in HTTP listener is provided.
|
|
14
|
-
*
|
|
15
|
-
* The pure mapping helpers are exported for unit testing without the SDK,
|
|
16
|
-
* HTTP, or network.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import * as http from "node:http";
|
|
20
|
-
import { timingSafeEqual } from "node:crypto";
|
|
21
|
-
import type {
|
|
22
|
-
MailTransport,
|
|
23
|
-
MailCapabilities,
|
|
24
|
-
MailTransportState,
|
|
25
|
-
MailHealth,
|
|
26
|
-
OutboundMail,
|
|
27
|
-
MailSendResult,
|
|
28
|
-
InboundMail,
|
|
29
|
-
InboundHandler,
|
|
30
|
-
InboundAuthResults,
|
|
31
|
-
InboundBounce,
|
|
32
|
-
MailAttachment,
|
|
33
|
-
AttachmentStore,
|
|
34
|
-
} from "./types.js";
|
|
35
|
-
|
|
36
|
-
const DEFAULT_MAX_BYTES = 25 * 1024 * 1024;
|
|
37
|
-
|
|
38
|
-
// ---------------------------------------------------------------------------
|
|
39
|
-
// Injectable client (real impl wraps the `postmark` SDK)
|
|
40
|
-
// ---------------------------------------------------------------------------
|
|
41
|
-
|
|
42
|
-
export interface PostmarkSendResponse {
|
|
43
|
-
ErrorCode: number;
|
|
44
|
-
MessageID?: string;
|
|
45
|
-
Message?: string;
|
|
46
|
-
SubmittedAt?: string;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export interface PostmarkClient {
|
|
50
|
-
sendEmail(message: Record<string, unknown>): Promise<PostmarkSendResponse>;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export interface PostmarkTransportOptions {
|
|
54
|
-
/** Postmark server API token. Required unless a client is injected. */
|
|
55
|
-
serverToken?: string;
|
|
56
|
-
maxMessageBytes?: number;
|
|
57
|
-
attachmentStore?: AttachmentStore;
|
|
58
|
-
/** Optional built-in webhook HTTP listener. */
|
|
59
|
-
webhook?: {
|
|
60
|
-
port: number;
|
|
61
|
-
host?: string;
|
|
62
|
-
inboundPath?: string;
|
|
63
|
-
bouncePath?: string;
|
|
64
|
-
/**
|
|
65
|
-
* Optional HTTP Basic Auth. When set, webhook requests must present
|
|
66
|
-
* matching credentials (Postmark supports basic-auth in the webhook URL),
|
|
67
|
-
* else they are rejected 401. Strongly recommended if the port is exposed.
|
|
68
|
-
*/
|
|
69
|
-
basicAuth?: { user: string; pass: string };
|
|
70
|
-
};
|
|
71
|
-
/** Inject a client (tests / custom). Defaults to the postmark SDK. */
|
|
72
|
-
client?: PostmarkClient;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export class PostmarkTransport implements MailTransport {
|
|
76
|
-
readonly capabilities: MailCapabilities;
|
|
77
|
-
private _state: MailTransportState = "stopped";
|
|
78
|
-
private handler?: InboundHandler;
|
|
79
|
-
private client?: PostmarkClient;
|
|
80
|
-
private server?: http.Server;
|
|
81
|
-
private delivered = new Set<string>();
|
|
82
|
-
private static readonly MAX_DELIVERED = 50_000;
|
|
83
|
-
|
|
84
|
-
constructor(private opts: PostmarkTransportOptions) {
|
|
85
|
-
this.capabilities = {
|
|
86
|
-
outbound: "api",
|
|
87
|
-
signsDkim: true, // Postmark signs DKIM for verified sending domains
|
|
88
|
-
verifiesInboundAuth: true,
|
|
89
|
-
inbound: "webhook",
|
|
90
|
-
maxMessageBytes: opts.maxMessageBytes ?? DEFAULT_MAX_BYTES,
|
|
91
|
-
};
|
|
92
|
-
this.client = opts.client;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
get state(): MailTransportState {
|
|
96
|
-
return this._state;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
onReceive(handler: InboundHandler): void {
|
|
100
|
-
this.handler = handler;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
async health(): Promise<MailHealth> {
|
|
104
|
-
return { state: this._state };
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
async start(): Promise<void> {
|
|
108
|
-
if (this._state === "ready") return;
|
|
109
|
-
this._state = "starting";
|
|
110
|
-
if (this.opts.webhook) await this.startHttp(this.opts.webhook);
|
|
111
|
-
this._state = "ready";
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
async stop(): Promise<void> {
|
|
115
|
-
this._state = "stopping";
|
|
116
|
-
if (this.server) {
|
|
117
|
-
await new Promise<void>((resolve) => this.server!.close(() => resolve()));
|
|
118
|
-
this.server = undefined;
|
|
119
|
-
}
|
|
120
|
-
this._state = "stopped";
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// -- Outbound ------------------------------------------------------------
|
|
124
|
-
|
|
125
|
-
async send(envelope: OutboundMail): Promise<MailSendResult> {
|
|
126
|
-
if (this._state !== "ready") {
|
|
127
|
-
throw new Error(`send() called while transport state is "${this._state}"`);
|
|
128
|
-
}
|
|
129
|
-
if (this.delivered.has(envelope.idempotencyKey)) {
|
|
130
|
-
return {
|
|
131
|
-
disposition: "delivered",
|
|
132
|
-
remoteMessageId: envelope.headers.messageId,
|
|
133
|
-
detail: "idempotent replay",
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const client = await this.getClient();
|
|
138
|
-
const contents = await this.resolveAttachmentContents(envelope);
|
|
139
|
-
try {
|
|
140
|
-
const res = await client.sendEmail(outboundToPostmark(envelope, contents));
|
|
141
|
-
const result = postmarkResponseToResult(res);
|
|
142
|
-
if (result.disposition === "delivered") {
|
|
143
|
-
this.rememberDelivered(envelope.idempotencyKey);
|
|
144
|
-
}
|
|
145
|
-
return result;
|
|
146
|
-
} catch (err) {
|
|
147
|
-
return postmarkErrorToResult(err);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** Record a delivered idempotency key, evicting the oldest when over cap. */
|
|
152
|
-
private rememberDelivered(key: string): void {
|
|
153
|
-
if (this.delivered.size >= PostmarkTransport.MAX_DELIVERED) {
|
|
154
|
-
const oldest = this.delivered.values().next().value;
|
|
155
|
-
if (oldest !== undefined) this.delivered.delete(oldest);
|
|
156
|
-
}
|
|
157
|
-
this.delivered.add(key);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
private async getClient(): Promise<PostmarkClient> {
|
|
161
|
-
if (this.client) return this.client;
|
|
162
|
-
if (!this.opts.serverToken) {
|
|
163
|
-
throw new Error("PostmarkTransport requires a serverToken or an injected client");
|
|
164
|
-
}
|
|
165
|
-
this.client = await loadPostmarkClient(this.opts.serverToken);
|
|
166
|
-
return this.client;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
private async resolveAttachmentContents(
|
|
170
|
-
env: OutboundMail
|
|
171
|
-
): Promise<Map<string, string>> {
|
|
172
|
-
const out = new Map<string, string>();
|
|
173
|
-
const store = this.opts.attachmentStore;
|
|
174
|
-
if (!store || !env.attachments?.length) return out;
|
|
175
|
-
for (const a of env.attachments) {
|
|
176
|
-
const bytes = await store.get(a.contentRef);
|
|
177
|
-
out.set(a.contentRef, bytes.toString("base64"));
|
|
178
|
-
}
|
|
179
|
-
return out;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// -- Inbound webhooks ----------------------------------------------------
|
|
183
|
-
|
|
184
|
-
/** Map + ingest a Postmark inbound webhook payload. Awaits the handler. */
|
|
185
|
-
async ingestInboundWebhook(payload: PostmarkInbound): Promise<void> {
|
|
186
|
-
const attachments = await this.storeAttachments(payload.Attachments);
|
|
187
|
-
const mail = postmarkInboundToMail(payload, { attachments });
|
|
188
|
-
await this.receiveInbound(mail);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/** Map + ingest a Postmark bounce webhook payload. Awaits the handler. */
|
|
192
|
-
async ingestBounceWebhook(payload: PostmarkBounce): Promise<void> {
|
|
193
|
-
await this.receiveInbound(postmarkBounceToInbound(payload));
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* Deliver an already-mapped inbound message to the handler (commit-before-ACK).
|
|
198
|
-
* Public so embedders using their own webhook parser can inject directly.
|
|
199
|
-
*/
|
|
200
|
-
async receiveInbound(mail: InboundMail): Promise<void> {
|
|
201
|
-
if (!this.handler) throw new Error("no inbound handler registered");
|
|
202
|
-
await this.handler(mail);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
private async storeAttachments(
|
|
206
|
-
atts: PostmarkInbound["Attachments"]
|
|
207
|
-
): Promise<MailAttachment[] | undefined> {
|
|
208
|
-
const store = this.opts.attachmentStore;
|
|
209
|
-
if (!store || !atts?.length) return undefined;
|
|
210
|
-
const out: MailAttachment[] = [];
|
|
211
|
-
for (const a of atts) {
|
|
212
|
-
const bytes = Buffer.from(a.Content, "base64");
|
|
213
|
-
const ref = await store.put(bytes, {
|
|
214
|
-
contentType: a.ContentType,
|
|
215
|
-
filename: a.Name,
|
|
216
|
-
});
|
|
217
|
-
out.push({
|
|
218
|
-
filename: a.Name,
|
|
219
|
-
contentType: a.ContentType,
|
|
220
|
-
contentId: a.ContentID || undefined,
|
|
221
|
-
contentRef: ref,
|
|
222
|
-
sizeBytes: a.ContentLength ?? bytes.length,
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
return out;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// -- Built-in HTTP webhook listener (optional) ---------------------------
|
|
229
|
-
|
|
230
|
-
private async startHttp(cfg: NonNullable<PostmarkTransportOptions["webhook"]>): Promise<void> {
|
|
231
|
-
const inboundPath = cfg.inboundPath ?? "/webhooks/postmark/inbound";
|
|
232
|
-
const bouncePath = cfg.bouncePath ?? "/webhooks/postmark/bounce";
|
|
233
|
-
// Cap webhook bodies so an unauthenticated POST can't exhaust memory.
|
|
234
|
-
const maxBody = Math.max(
|
|
235
|
-
this.capabilities.maxMessageBytes * 2,
|
|
236
|
-
1 * 1024 * 1024
|
|
237
|
-
);
|
|
238
|
-
const expectedAuth = cfg.basicAuth
|
|
239
|
-
? "Basic " +
|
|
240
|
-
Buffer.from(`${cfg.basicAuth.user}:${cfg.basicAuth.pass}`).toString(
|
|
241
|
-
"base64"
|
|
242
|
-
)
|
|
243
|
-
: undefined;
|
|
244
|
-
const server = http.createServer((req, res) => {
|
|
245
|
-
if (req.method !== "POST") {
|
|
246
|
-
res.writeHead(405).end();
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
if (expectedAuth && !timingSafeEqualStr(req.headers.authorization, expectedAuth)) {
|
|
250
|
-
res.writeHead(401).end();
|
|
251
|
-
return;
|
|
252
|
-
}
|
|
253
|
-
const chunks: Buffer[] = [];
|
|
254
|
-
let total = 0;
|
|
255
|
-
let aborted = false;
|
|
256
|
-
req.on("data", (c) => {
|
|
257
|
-
total += c.length;
|
|
258
|
-
if (total > maxBody) {
|
|
259
|
-
aborted = true;
|
|
260
|
-
res.writeHead(413).end();
|
|
261
|
-
req.destroy();
|
|
262
|
-
return;
|
|
263
|
-
}
|
|
264
|
-
chunks.push(c);
|
|
265
|
-
});
|
|
266
|
-
req.on("end", async () => {
|
|
267
|
-
if (aborted) return;
|
|
268
|
-
let payload: unknown;
|
|
269
|
-
try {
|
|
270
|
-
payload = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
271
|
-
} catch {
|
|
272
|
-
res.writeHead(400).end();
|
|
273
|
-
return;
|
|
274
|
-
}
|
|
275
|
-
try {
|
|
276
|
-
if (req.url?.startsWith(bouncePath)) {
|
|
277
|
-
await this.ingestBounceWebhook(payload as PostmarkBounce);
|
|
278
|
-
} else if (req.url?.startsWith(inboundPath)) {
|
|
279
|
-
await this.ingestInboundWebhook(payload as PostmarkInbound);
|
|
280
|
-
} else {
|
|
281
|
-
res.writeHead(404).end();
|
|
282
|
-
return;
|
|
283
|
-
}
|
|
284
|
-
res.writeHead(200).end(); // ACK only after the handler committed
|
|
285
|
-
} catch {
|
|
286
|
-
res.writeHead(500).end(); // NACK — Postmark retries
|
|
287
|
-
}
|
|
288
|
-
});
|
|
289
|
-
});
|
|
290
|
-
await new Promise<void>((resolve) => {
|
|
291
|
-
this.server = server;
|
|
292
|
-
server.listen(cfg.port, cfg.host ?? "0.0.0.0", () => resolve());
|
|
293
|
-
});
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// ---------------------------------------------------------------------------
|
|
298
|
-
// Postmark payload types (minimal structural subset)
|
|
299
|
-
// ---------------------------------------------------------------------------
|
|
300
|
-
|
|
301
|
-
export interface PostmarkInbound {
|
|
302
|
-
FromFull?: { Email: string; Name?: string };
|
|
303
|
-
ToFull?: Array<{ Email: string; Name?: string }>;
|
|
304
|
-
CcFull?: Array<{ Email: string; Name?: string }>;
|
|
305
|
-
OriginalRecipient?: string;
|
|
306
|
-
Subject?: string;
|
|
307
|
-
TextBody?: string;
|
|
308
|
-
HtmlBody?: string;
|
|
309
|
-
Date?: string;
|
|
310
|
-
Headers?: Array<{ Name: string; Value: string }>;
|
|
311
|
-
Attachments?: Array<{
|
|
312
|
-
Name: string;
|
|
313
|
-
Content: string; // base64
|
|
314
|
-
ContentType: string;
|
|
315
|
-
ContentLength?: number;
|
|
316
|
-
ContentID?: string;
|
|
317
|
-
}>;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
export interface PostmarkBounce {
|
|
321
|
-
RecordType?: string;
|
|
322
|
-
Type?: string;
|
|
323
|
-
TypeCode?: number;
|
|
324
|
-
Email?: string;
|
|
325
|
-
MessageID?: string;
|
|
326
|
-
Description?: string;
|
|
327
|
-
Details?: string;
|
|
328
|
-
BouncedAt?: string;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
// ---------------------------------------------------------------------------
|
|
332
|
-
// Pure mapping helpers (exported for unit tests)
|
|
333
|
-
// ---------------------------------------------------------------------------
|
|
334
|
-
|
|
335
|
-
/** Classify a resolved Postmark send response. */
|
|
336
|
-
export function postmarkResponseToResult(
|
|
337
|
-
res: PostmarkSendResponse
|
|
338
|
-
): MailSendResult {
|
|
339
|
-
if (res.ErrorCode === 0) {
|
|
340
|
-
return { disposition: "delivered", remoteMessageId: res.MessageID };
|
|
341
|
-
}
|
|
342
|
-
// Non-zero ErrorCode in a 200 body — classify by the same code table.
|
|
343
|
-
return classifyPostmarkCode(res.ErrorCode, undefined, res.Message);
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
/** Classify a thrown Postmark error (rejected promise). */
|
|
347
|
-
export function postmarkErrorToResult(err: unknown): MailSendResult {
|
|
348
|
-
const e = err as {
|
|
349
|
-
code?: number;
|
|
350
|
-
statusCode?: number;
|
|
351
|
-
message?: string;
|
|
352
|
-
};
|
|
353
|
-
return classifyPostmarkCode(e?.code, e?.statusCode, e?.message);
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
// Postmark API error codes that are permanent (won't succeed on retry).
|
|
357
|
-
const PERMANENT_CODES = new Set([
|
|
358
|
-
300, // Invalid email request (bad address)
|
|
359
|
-
406, // Inactive recipient
|
|
360
|
-
409, // Malformed JSON / required field missing
|
|
361
|
-
]);
|
|
362
|
-
|
|
363
|
-
function classifyPostmarkCode(
|
|
364
|
-
code: number | undefined,
|
|
365
|
-
statusCode: number | undefined,
|
|
366
|
-
message?: string
|
|
367
|
-
): MailSendResult {
|
|
368
|
-
// Rate limited (HTTP 429, or the in-body ErrorCode 429 "too many messages")
|
|
369
|
-
// or server-side error → transient.
|
|
370
|
-
if (statusCode === 429 || code === 429 || (statusCode && statusCode >= 500)) {
|
|
371
|
-
return { disposition: "transient", code: code ?? statusCode, detail: message };
|
|
372
|
-
}
|
|
373
|
-
// Permanent only for known non-retryable application error codes. We do NOT
|
|
374
|
-
// treat every HTTP 422 as permanent — Postmark returns 422 for transient
|
|
375
|
-
// conditions too (e.g. pending account approval), and hard-bouncing those
|
|
376
|
-
// would lose deliverable mail. Unknown codes default to transient (retry).
|
|
377
|
-
if (code !== undefined && PERMANENT_CODES.has(code)) {
|
|
378
|
-
return { disposition: "permanent", code, detail: message };
|
|
379
|
-
}
|
|
380
|
-
return { disposition: "transient", code: code ?? statusCode, detail: message };
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
/** Map an OutboundMail to a Postmark sendEmail message object. */
|
|
384
|
-
export function outboundToPostmark(
|
|
385
|
-
env: OutboundMail,
|
|
386
|
-
attachmentContents?: Map<string, string>
|
|
387
|
-
): Record<string, unknown> {
|
|
388
|
-
const fmt = (a: { address: string; name?: string }) =>
|
|
389
|
-
a.name ? `"${a.name}" <${a.address}>` : a.address;
|
|
390
|
-
const headers: Array<{ Name: string; Value: string }> = [
|
|
391
|
-
{ Name: "Message-ID", Value: env.headers.messageId },
|
|
392
|
-
];
|
|
393
|
-
if (env.headers.inReplyTo) {
|
|
394
|
-
headers.push({ Name: "In-Reply-To", Value: env.headers.inReplyTo });
|
|
395
|
-
}
|
|
396
|
-
if (env.headers.references?.length) {
|
|
397
|
-
headers.push({ Name: "References", Value: env.headers.references.join(" ") });
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
const msg: Record<string, unknown> = {
|
|
401
|
-
From: fmt(env.from),
|
|
402
|
-
To: env.to.map(fmt).join(", "),
|
|
403
|
-
Subject: env.subject ?? "",
|
|
404
|
-
TextBody: env.text,
|
|
405
|
-
HtmlBody: env.html,
|
|
406
|
-
Headers: headers,
|
|
407
|
-
};
|
|
408
|
-
if (env.cc?.length) msg.Cc = env.cc.map(fmt).join(", ");
|
|
409
|
-
if (env.bcc?.length) msg.Bcc = env.bcc.map(fmt).join(", ");
|
|
410
|
-
if (env.attachments?.length) {
|
|
411
|
-
msg.Attachments = env.attachments.map((a) => ({
|
|
412
|
-
Name: a.filename ?? "attachment",
|
|
413
|
-
ContentType: a.contentType,
|
|
414
|
-
ContentID: a.contentId,
|
|
415
|
-
Content: attachmentContents?.get(a.contentRef),
|
|
416
|
-
}));
|
|
417
|
-
}
|
|
418
|
-
return msg;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
/** Map a Postmark inbound webhook payload to an InboundMail. */
|
|
422
|
-
export function postmarkInboundToMail(
|
|
423
|
-
payload: PostmarkInbound,
|
|
424
|
-
ctx: { attachments?: MailAttachment[] } = {}
|
|
425
|
-
): InboundMail {
|
|
426
|
-
const raw: Record<string, string | string[]> = {};
|
|
427
|
-
for (const h of payload.Headers ?? []) {
|
|
428
|
-
raw[h.Name.toLowerCase()] = h.Value;
|
|
429
|
-
}
|
|
430
|
-
const header = (name: string): string | undefined => raw[name.toLowerCase()] as string | undefined;
|
|
431
|
-
|
|
432
|
-
const refsHeader = header("references");
|
|
433
|
-
const references = refsHeader
|
|
434
|
-
? refsHeader.split(/\s+/).filter(Boolean)
|
|
435
|
-
: undefined;
|
|
436
|
-
|
|
437
|
-
const from = payload.FromFull
|
|
438
|
-
? { address: payload.FromFull.Email, name: payload.FromFull.Name }
|
|
439
|
-
: { address: "" };
|
|
440
|
-
|
|
441
|
-
const to = (payload.ToFull ?? []).map((t) => ({
|
|
442
|
-
address: t.Email,
|
|
443
|
-
name: t.Name,
|
|
444
|
-
}));
|
|
445
|
-
const cc = (payload.CcFull ?? []).map((c) => ({
|
|
446
|
-
address: c.Email,
|
|
447
|
-
name: c.Name,
|
|
448
|
-
}));
|
|
449
|
-
|
|
450
|
-
const text = payload.TextBody ?? "";
|
|
451
|
-
// Include attachment bytes so the gateway's maxMessageBytes check matches the
|
|
452
|
-
// SMTP path (which counts the full raw message), not just the body length.
|
|
453
|
-
const attachmentBytes = (payload.Attachments ?? []).reduce(
|
|
454
|
-
(sum, a) => sum + (a.ContentLength ?? Math.floor((a.Content?.length ?? 0) * 0.75)),
|
|
455
|
-
0
|
|
456
|
-
);
|
|
457
|
-
const sizeBytes =
|
|
458
|
-
Buffer.byteLength(text) +
|
|
459
|
-
Buffer.byteLength(payload.HtmlBody ?? "") +
|
|
460
|
-
attachmentBytes;
|
|
461
|
-
|
|
462
|
-
return {
|
|
463
|
-
envelopeFrom: from.address,
|
|
464
|
-
envelopeTo: payload.OriginalRecipient
|
|
465
|
-
? [payload.OriginalRecipient]
|
|
466
|
-
: to.map((t) => t.address),
|
|
467
|
-
from,
|
|
468
|
-
to,
|
|
469
|
-
cc,
|
|
470
|
-
subject: payload.Subject,
|
|
471
|
-
text,
|
|
472
|
-
html: payload.HtmlBody || undefined,
|
|
473
|
-
headers: {
|
|
474
|
-
messageId: header("message-id"),
|
|
475
|
-
inReplyTo: header("in-reply-to"),
|
|
476
|
-
references,
|
|
477
|
-
raw,
|
|
478
|
-
},
|
|
479
|
-
attachments: ctx.attachments,
|
|
480
|
-
authResults: parseAuthResults(header("authentication-results")),
|
|
481
|
-
sizeBytes,
|
|
482
|
-
receivedAt: payload.Date
|
|
483
|
-
? new Date(payload.Date).toISOString()
|
|
484
|
-
: new Date().toISOString(),
|
|
485
|
-
};
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
// Postmark bounce Types that mean "still might deliver" (transient/delayed).
|
|
489
|
-
const DELAYED_BOUNCE_TYPES = new Set([
|
|
490
|
-
"SoftBounce",
|
|
491
|
-
"Transient",
|
|
492
|
-
"DnsError",
|
|
493
|
-
"Blocked",
|
|
494
|
-
]);
|
|
495
|
-
|
|
496
|
-
/** Map a Postmark bounce webhook payload to an InboundMail carrying a bounce. */
|
|
497
|
-
export function postmarkBounceToInbound(payload: PostmarkBounce): InboundMail {
|
|
498
|
-
const action: InboundBounce["action"] = DELAYED_BOUNCE_TYPES.has(
|
|
499
|
-
payload.Type ?? ""
|
|
500
|
-
)
|
|
501
|
-
? "delayed"
|
|
502
|
-
: "failed";
|
|
503
|
-
return {
|
|
504
|
-
envelopeFrom: "",
|
|
505
|
-
envelopeTo: [],
|
|
506
|
-
from: { address: payload.Email ?? "" },
|
|
507
|
-
to: [],
|
|
508
|
-
headers: { raw: {} },
|
|
509
|
-
bounce: {
|
|
510
|
-
action,
|
|
511
|
-
recipient: payload.Email ?? "",
|
|
512
|
-
status: payload.TypeCode ? String(payload.TypeCode) : undefined,
|
|
513
|
-
originalMessageId: payload.MessageID,
|
|
514
|
-
diagnostic: payload.Details ?? payload.Description,
|
|
515
|
-
},
|
|
516
|
-
sizeBytes: 0,
|
|
517
|
-
receivedAt: payload.BouncedAt
|
|
518
|
-
? new Date(payload.BouncedAt).toISOString()
|
|
519
|
-
: new Date().toISOString(),
|
|
520
|
-
};
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
/** Best-effort parse of an Authentication-Results header into verdicts. */
|
|
524
|
-
export function parseAuthResults(
|
|
525
|
-
header: string | undefined
|
|
526
|
-
): InboundAuthResults | undefined {
|
|
527
|
-
if (!header) return undefined;
|
|
528
|
-
const h = header.toLowerCase();
|
|
529
|
-
const grab = (re: RegExp): string => {
|
|
530
|
-
const m = h.match(re);
|
|
531
|
-
return m ? m[1] : "none";
|
|
532
|
-
};
|
|
533
|
-
const spf = grab(/spf=(\w+)/);
|
|
534
|
-
const dkim = grab(/dkim=(\w+)/);
|
|
535
|
-
const dmarc = grab(/dmarc=(\w+)/);
|
|
536
|
-
const spfVal: InboundAuthResults["spf"] =
|
|
537
|
-
spf === "pass" || spf === "fail" || spf === "softfail" || spf === "neutral"
|
|
538
|
-
? (spf as InboundAuthResults["spf"])
|
|
539
|
-
: "none";
|
|
540
|
-
return {
|
|
541
|
-
spf: spfVal,
|
|
542
|
-
dkim: dkim === "pass" ? "pass" : dkim === "fail" ? "fail" : "none",
|
|
543
|
-
dmarc: dmarc === "pass" ? "pass" : dmarc === "fail" ? "fail" : "none",
|
|
544
|
-
};
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
/** Constant-time string compare for the webhook Authorization header. */
|
|
548
|
-
function timingSafeEqualStr(actual: string | undefined, expected: string): boolean {
|
|
549
|
-
if (!actual) return false;
|
|
550
|
-
const a = Buffer.from(actual);
|
|
551
|
-
const b = Buffer.from(expected);
|
|
552
|
-
if (a.length !== b.length) return false;
|
|
553
|
-
return timingSafeEqual(a, b);
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
// ---------------------------------------------------------------------------
|
|
557
|
-
// Optional dynamic import of the postmark SDK
|
|
558
|
-
// ---------------------------------------------------------------------------
|
|
559
|
-
|
|
560
|
-
async function loadPostmarkClient(token: string): Promise<PostmarkClient> {
|
|
561
|
-
let mod: {
|
|
562
|
-
ServerClient?: new (t: string) => { sendEmail: PostmarkClient["sendEmail"] };
|
|
563
|
-
default?: { ServerClient?: new (t: string) => { sendEmail: PostmarkClient["sendEmail"] } };
|
|
564
|
-
};
|
|
565
|
-
try {
|
|
566
|
-
const spec = "postmark";
|
|
567
|
-
mod = await import(/* @vite-ignore */ spec);
|
|
568
|
-
} catch {
|
|
569
|
-
throw new Error(
|
|
570
|
-
'PostmarkTransport requires the optional "postmark" package. Install it to enable the Postmark backend.'
|
|
571
|
-
);
|
|
572
|
-
}
|
|
573
|
-
const ServerClient = mod.ServerClient ?? mod.default?.ServerClient;
|
|
574
|
-
if (!ServerClient) throw new Error("postmark SDK: ServerClient not found");
|
|
575
|
-
const sdk = new ServerClient(token);
|
|
576
|
-
return { sendEmail: (m) => sdk.sendEmail(m) };
|
|
577
|
-
}
|
package/src/mail/rate-limiter.ts
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Sliding-window rate limiter for inbound mail abuse control.
|
|
3
|
-
*
|
|
4
|
-
* Keyed (e.g. by sender domain or IP). `tryAcquire` returns false when the key
|
|
5
|
-
* has reached `max` events within the trailing `windowMs`. Memory is bounded by
|
|
6
|
-
* pruning empty keys; call `prune()` periodically for long-lived processes.
|
|
7
|
-
*/
|
|
8
|
-
export class RateLimiter {
|
|
9
|
-
private hits = new Map<string, number[]>();
|
|
10
|
-
|
|
11
|
-
constructor(
|
|
12
|
-
private windowMs: number,
|
|
13
|
-
private max: number
|
|
14
|
-
) {}
|
|
15
|
-
|
|
16
|
-
/** Returns true if the event is allowed (and records it), false if limited. */
|
|
17
|
-
tryAcquire(key: string, now: number = Date.now()): boolean {
|
|
18
|
-
if (this.max <= 0) return false;
|
|
19
|
-
let arr = this.hits.get(key);
|
|
20
|
-
if (!arr) {
|
|
21
|
-
arr = [];
|
|
22
|
-
this.hits.set(key, arr);
|
|
23
|
-
}
|
|
24
|
-
const cutoff = now - this.windowMs;
|
|
25
|
-
// Drop timestamps outside the window (oldest first).
|
|
26
|
-
let i = 0;
|
|
27
|
-
while (i < arr.length && arr[i] <= cutoff) i++;
|
|
28
|
-
if (i > 0) arr.splice(0, i);
|
|
29
|
-
|
|
30
|
-
if (arr.length >= this.max) return false;
|
|
31
|
-
arr.push(now);
|
|
32
|
-
return true;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Remove keys with no events in the current window. */
|
|
36
|
-
prune(now: number = Date.now()): void {
|
|
37
|
-
const cutoff = now - this.windowMs;
|
|
38
|
-
for (const [key, arr] of this.hits) {
|
|
39
|
-
while (arr.length && arr[0] <= cutoff) arr.shift();
|
|
40
|
-
if (arr.length === 0) this.hits.delete(key);
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** Current recorded events for a key (for tests/metrics). */
|
|
45
|
-
count(key: string, now: number = Date.now()): number {
|
|
46
|
-
const arr = this.hits.get(key);
|
|
47
|
-
if (!arr) return 0;
|
|
48
|
-
const cutoff = now - this.windowMs;
|
|
49
|
-
return arr.filter((t) => t > cutoff).length;
|
|
50
|
-
}
|
|
51
|
-
}
|