@mailslot/core 0.0.1
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/README.md +9 -0
- package/package.json +47 -0
- package/src/api.ts +96 -0
- package/src/auth.ts +31 -0
- package/src/env.ts +25 -0
- package/src/extract.ts +98 -0
- package/src/inbox.ts +287 -0
- package/src/index.ts +58 -0
- package/src/mcp.ts +125 -0
- package/src/shims/ai.ts +8 -0
- package/wrangler.jsonc +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# @mailslot/core
|
|
2
|
+
|
|
3
|
+
The Mailslot worker: self-hosted email inbox for AI agents on your own
|
|
4
|
+
Cloudflare account. One Durable Object per address, MCP + HTTP + webhook
|
|
5
|
+
surfaces, read-once OTP extraction.
|
|
6
|
+
|
|
7
|
+
This package is deployed from source with wrangler — see the
|
|
8
|
+
[setup guide](https://github.com/mailslot/mailslot#quick-start). For the
|
|
9
|
+
guided deploy (coming soon): `npx create-mailslot`.
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mailslot/core",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Self-hosted email inbox for AI agents, on your own Cloudflare account",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"wrangler.jsonc",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://mailslot.dev",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"ai-agents",
|
|
18
|
+
"email",
|
|
19
|
+
"mcp",
|
|
20
|
+
"cloudflare-workers",
|
|
21
|
+
"durable-objects",
|
|
22
|
+
"self-hosted"
|
|
23
|
+
],
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/mailslot/mailslot.git"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"dev": "wrangler dev",
|
|
30
|
+
"deploy": "wrangler deploy",
|
|
31
|
+
"typecheck": "tsc --noEmit",
|
|
32
|
+
"test": "vitest run",
|
|
33
|
+
"cf-typegen": "wrangler types"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@modelcontextprotocol/sdk": "1.23.0",
|
|
37
|
+
"agents": "^0.2.0",
|
|
38
|
+
"postal-mime": "^2.0.0",
|
|
39
|
+
"zod": "^3.24.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@cloudflare/workers-types": "^4.0.0",
|
|
43
|
+
"typescript": "^5.7.0",
|
|
44
|
+
"vitest": "^3.0.0",
|
|
45
|
+
"wrangler": "^4.0.0"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { getAgentByName } from "agents";
|
|
2
|
+
import type { Env } from "./env";
|
|
3
|
+
import { mintLocalPart } from "./auth";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Plain HTTP surface mirroring the MCP tools.
|
|
7
|
+
*
|
|
8
|
+
* POST /v1/addresses {prefix?}
|
|
9
|
+
* GET /v1/inboxes/:address/messages ?q&from_contains&subject_contains&limit
|
|
10
|
+
* GET /v1/inboxes/:address/messages/:id
|
|
11
|
+
* POST /v1/inboxes/:address/extract-otp {message_id?}
|
|
12
|
+
* POST /v1/inboxes/:address/extract-links {message_id?}
|
|
13
|
+
* GET /v1/inboxes/:address/wait ?timeout_s&since_s&from_contains&subject_contains
|
|
14
|
+
*/
|
|
15
|
+
export async function handleApi(request: Request, env: Env): Promise<Response> {
|
|
16
|
+
const url = new URL(request.url);
|
|
17
|
+
const parts = url.pathname.split("/").filter(Boolean); // ["v1", ...]
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
if (parts[1] === "addresses" && request.method === "POST") {
|
|
21
|
+
if (!env.EMAIL_DOMAIN) {
|
|
22
|
+
return Response.json({ error: "EMAIL_DOMAIN is not configured" }, { status: 500 });
|
|
23
|
+
}
|
|
24
|
+
const body = await readJson(request);
|
|
25
|
+
const address = `${mintLocalPart(body.prefix)}@${env.EMAIL_DOMAIN.toLowerCase()}`;
|
|
26
|
+
return Response.json({ address }, { status: 201 });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (parts[1] === "inboxes" && parts[2]) {
|
|
30
|
+
const address = decodeURIComponent(parts[2]).toLowerCase();
|
|
31
|
+
if (!address.includes("@")) {
|
|
32
|
+
return Response.json({ error: "address must be a full email address" }, { status: 400 });
|
|
33
|
+
}
|
|
34
|
+
const inbox = await getAgentByName(env.Inbox, address);
|
|
35
|
+
const rest = parts.slice(3);
|
|
36
|
+
|
|
37
|
+
if (rest[0] === "messages" && !rest[1] && request.method === "GET") {
|
|
38
|
+
const messages = await inbox.list({
|
|
39
|
+
q: url.searchParams.get("q") ?? undefined,
|
|
40
|
+
fromContains: url.searchParams.get("from_contains") ?? undefined,
|
|
41
|
+
subjectContains: url.searchParams.get("subject_contains") ?? undefined,
|
|
42
|
+
limit: numParam(url, "limit")
|
|
43
|
+
});
|
|
44
|
+
return Response.json({ messages });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (rest[0] === "messages" && rest[1] && request.method === "GET") {
|
|
48
|
+
const message = await inbox.get(rest[1]);
|
|
49
|
+
return message
|
|
50
|
+
? Response.json({ message })
|
|
51
|
+
: Response.json({ error: "message not found" }, { status: 404 });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (rest[0] === "extract-otp" && request.method === "POST") {
|
|
55
|
+
const body = await readJson(request);
|
|
56
|
+
return Response.json(await inbox.extractOtp(body.message_id));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (rest[0] === "extract-links" && request.method === "POST") {
|
|
60
|
+
const body = await readJson(request);
|
|
61
|
+
return Response.json(await inbox.extractLinks(body.message_id));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (rest[0] === "wait" && request.method === "GET") {
|
|
65
|
+
const message = await inbox.waitForMessage({
|
|
66
|
+
timeoutMs: (numParam(url, "timeout_s") ?? 60) * 1000,
|
|
67
|
+
sinceSecondsAgo: numParam(url, "since_s"),
|
|
68
|
+
fromContains: url.searchParams.get("from_contains") ?? undefined,
|
|
69
|
+
subjectContains: url.searchParams.get("subject_contains") ?? undefined
|
|
70
|
+
});
|
|
71
|
+
return Response.json({ message }); // message: null on timeout
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return Response.json({ error: "not found" }, { status: 404 });
|
|
76
|
+
} catch (e) {
|
|
77
|
+
console.error("api error:", e);
|
|
78
|
+
return Response.json({ error: "internal error" }, { status: 500 });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function readJson(request: Request): Promise<Record<string, string | undefined>> {
|
|
83
|
+
try {
|
|
84
|
+
const body = await request.json();
|
|
85
|
+
return typeof body === "object" && body !== null ? (body as Record<string, string>) : {};
|
|
86
|
+
} catch {
|
|
87
|
+
return {};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function numParam(url: URL, name: string): number | undefined {
|
|
92
|
+
const value = url.searchParams.get(name);
|
|
93
|
+
if (value === null) return undefined;
|
|
94
|
+
const n = Number(value);
|
|
95
|
+
return Number.isFinite(n) ? n : undefined;
|
|
96
|
+
}
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Constant-time bearer token check (compares SHA-256 digests). */
|
|
2
|
+
export async function checkBearerToken(request: Request, expected: string | undefined): Promise<boolean> {
|
|
3
|
+
if (!expected) return false; // unset token = locked, never open
|
|
4
|
+
const header = request.headers.get("authorization") ?? "";
|
|
5
|
+
const match = header.match(/^Bearer\s+(.+)$/i);
|
|
6
|
+
if (!match) return false;
|
|
7
|
+
|
|
8
|
+
const enc = new TextEncoder();
|
|
9
|
+
const [a, b] = await Promise.all([
|
|
10
|
+
crypto.subtle.digest("SHA-256", enc.encode(match[1])),
|
|
11
|
+
crypto.subtle.digest("SHA-256", enc.encode(expected))
|
|
12
|
+
]);
|
|
13
|
+
return timingSafeEqual(new Uint8Array(a), new Uint8Array(b));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
17
|
+
if (a.length !== b.length) return false;
|
|
18
|
+
let diff = 0;
|
|
19
|
+
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
|
|
20
|
+
return diff === 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Mint a random address local part: prefix-x7k2f9 (lowercase, unambiguous). */
|
|
24
|
+
export function mintLocalPart(prefix?: string): string {
|
|
25
|
+
const safePrefix = (prefix ?? "agent").toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 20) || "agent";
|
|
26
|
+
const alphabet = "abcdefghjkmnpqrstuvwxyz23456789"; // no 0/o/1/l/i
|
|
27
|
+
const bytes = crypto.getRandomValues(new Uint8Array(6));
|
|
28
|
+
let suffix = "";
|
|
29
|
+
for (const byte of bytes) suffix += alphabet[byte % alphabet.length];
|
|
30
|
+
return `${safePrefix}-${suffix}`;
|
|
31
|
+
}
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Inbox } from "./inbox";
|
|
2
|
+
|
|
3
|
+
declare global {
|
|
4
|
+
namespace Cloudflare {
|
|
5
|
+
interface Env {
|
|
6
|
+
Inbox: DurableObjectNamespace<Inbox>;
|
|
7
|
+
MailslotMcp: DurableObjectNamespace;
|
|
8
|
+
RAW: R2Bucket;
|
|
9
|
+
/** Bearer token for the HTTP API and MCP endpoint (secret). */
|
|
10
|
+
MAILSLOT_TOKEN: string;
|
|
11
|
+
/** Domain receiving agent mail, e.g. agents.example.com. */
|
|
12
|
+
EMAIL_DOMAIN?: string;
|
|
13
|
+
/** Verified Email Routing destination to forward copies to. */
|
|
14
|
+
FORWARD_TO?: string;
|
|
15
|
+
/** "all" forwards every inbound message to FORWARD_TO; default "none". */
|
|
16
|
+
FORWARD_MODE?: string;
|
|
17
|
+
/** URL receiving message.received webhook events. */
|
|
18
|
+
WEBHOOK_URL?: string;
|
|
19
|
+
/** If set, webhook payloads are HMAC-SHA256 signed (X-Mailslot-Signature). */
|
|
20
|
+
WEBHOOK_SECRET?: string;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type Env = Cloudflare.Env;
|
package/src/extract.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure extraction helpers. No I/O — unit-tested directly.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const OTP_KEYWORDS =
|
|
6
|
+
/\b(otp|one[- ]?time|verification|verify|confirm(?:ation)?|security|login|sign[- ]?in|access|auth(?:entication)?|pass)\b|code|코드|認証|驗證|验证|kod/i;
|
|
7
|
+
|
|
8
|
+
/** Strip HTML to text, crudely but safely (no DOM on Workers). */
|
|
9
|
+
export function htmlToText(html: string): string {
|
|
10
|
+
return html
|
|
11
|
+
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
|
12
|
+
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
|
13
|
+
.replace(/<[^>]+>/g, " ")
|
|
14
|
+
.replace(/ /gi, " ")
|
|
15
|
+
.replace(/&/gi, "&")
|
|
16
|
+
.replace(/</gi, "<")
|
|
17
|
+
.replace(/>/gi, ">")
|
|
18
|
+
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
|
|
19
|
+
.replace(/\s+/g, " ")
|
|
20
|
+
.trim();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type OtpCandidate = { code: string; score: number };
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Find the most likely OTP in a message.
|
|
27
|
+
* Heuristics: 4-8 digit (or dash/space-grouped) runs, scored by proximity to
|
|
28
|
+
* OTP-ish keywords and penalized when they look like dates, phones, or prices.
|
|
29
|
+
*/
|
|
30
|
+
export function extractOtp(subject: string, body: string): string | null {
|
|
31
|
+
const haystacks = [
|
|
32
|
+
{ text: subject ?? "", bonus: 2 },
|
|
33
|
+
{ text: body ?? "", bonus: 0 }
|
|
34
|
+
];
|
|
35
|
+
const candidates: OtpCandidate[] = [];
|
|
36
|
+
|
|
37
|
+
for (const { text, bonus } of haystacks) {
|
|
38
|
+
// 123456 | 123-456 | 123 456 | A1B2C3-style codes
|
|
39
|
+
// Trailing sentence punctuation is fine; reject only digit/decimal continuations.
|
|
40
|
+
const re = /(?<![\d.,-])(\d{4,8}|\d{3}[- ]\d{3}|[A-Z0-9]{6,8})(?!\d|[.,]\d)/g;
|
|
41
|
+
let m: RegExpExecArray | null;
|
|
42
|
+
while ((m = re.exec(text)) !== null) {
|
|
43
|
+
const raw = m[1];
|
|
44
|
+
const code = raw.replace(/[- ]/g, "");
|
|
45
|
+
// Alphanumeric candidates must mix letters and digits (else it's a word)
|
|
46
|
+
if (/[A-Z]/.test(code) && !/\d/.test(code)) continue;
|
|
47
|
+
// All-digit length sanity
|
|
48
|
+
if (/^\d+$/.test(code) && (code.length < 4 || code.length > 8)) continue;
|
|
49
|
+
|
|
50
|
+
let score = bonus;
|
|
51
|
+
const ctx = text.slice(Math.max(0, m.index - 60), m.index + raw.length + 60);
|
|
52
|
+
if (OTP_KEYWORDS.test(ctx)) score += 4;
|
|
53
|
+
// Penalties: looks like a year, a date fragment, a price, or a phone
|
|
54
|
+
if (/^(19|20)\d{2}$/.test(code)) score -= 3;
|
|
55
|
+
if (/[$€£¥]\s*$/.test(text.slice(Math.max(0, m.index - 4), m.index))) score -= 3;
|
|
56
|
+
if (/\d[\d\s().-]{8,}/.test(text.slice(Math.max(0, m.index - 12), m.index + raw.length + 4))) score -= 2;
|
|
57
|
+
// 6 digits is the archetypal OTP
|
|
58
|
+
if (/^\d{6}$/.test(code)) score += 2;
|
|
59
|
+
|
|
60
|
+
candidates.push({ code, score });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
65
|
+
const best = candidates[0];
|
|
66
|
+
return best && best.score >= 2 ? best.code : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Extract unique http(s) links, hrefs first (HTML), then bare URLs (text). */
|
|
70
|
+
export function extractLinks(text: string, html: string): string[] {
|
|
71
|
+
const out: string[] = [];
|
|
72
|
+
const seen = new Set<string>();
|
|
73
|
+
const push = (u: string) => {
|
|
74
|
+
const cleaned = u.replace(/[)\].,;'"!>]+$/, "");
|
|
75
|
+
if (!seen.has(cleaned)) {
|
|
76
|
+
seen.add(cleaned);
|
|
77
|
+
out.push(cleaned);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
if (html) {
|
|
82
|
+
const hrefRe = /href\s*=\s*["']?(https?:\/\/[^"'\s>]+)/gi;
|
|
83
|
+
let m: RegExpExecArray | null;
|
|
84
|
+
while ((m = hrefRe.exec(html)) !== null) push(m[1]);
|
|
85
|
+
}
|
|
86
|
+
if (text) {
|
|
87
|
+
const urlRe = /https?:\/\/[^\s<>"']+/gi;
|
|
88
|
+
let m: RegExpExecArray | null;
|
|
89
|
+
while ((m = urlRe.exec(text)) !== null) push(m[0]);
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** First ~140 chars of body text for list views and webhook payloads. */
|
|
95
|
+
export function makeSnippet(text: string, html: string): string {
|
|
96
|
+
const source = (text && text.trim()) || htmlToText(html || "");
|
|
97
|
+
return source.replace(/\s+/g, " ").trim().slice(0, 140);
|
|
98
|
+
}
|
package/src/inbox.ts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { Agent } from "agents";
|
|
2
|
+
import PostalMime from "postal-mime";
|
|
3
|
+
import type { Env } from "./env";
|
|
4
|
+
import { extractLinks, extractOtp, htmlToText, makeSnippet } from "./extract";
|
|
5
|
+
|
|
6
|
+
/** Shape of the AgentEmail proxy passed by routeAgentEmail (agents SDK). */
|
|
7
|
+
type AgentEmail = {
|
|
8
|
+
from: string;
|
|
9
|
+
to: string;
|
|
10
|
+
headers: Headers;
|
|
11
|
+
rawSize: number;
|
|
12
|
+
getRaw: () => Promise<Uint8Array>;
|
|
13
|
+
setReject: (reason: string) => void;
|
|
14
|
+
forward: (rcptTo: string, headers?: Headers) => Promise<void>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type MessageSummary = {
|
|
18
|
+
id: string;
|
|
19
|
+
from: string;
|
|
20
|
+
subject: string;
|
|
21
|
+
snippet: string;
|
|
22
|
+
receivedAt: number;
|
|
23
|
+
consumed: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type MessageDetail = MessageSummary & {
|
|
27
|
+
text: string;
|
|
28
|
+
html: string;
|
|
29
|
+
rawKey: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type MessageRow = {
|
|
33
|
+
id: string;
|
|
34
|
+
from_addr: string;
|
|
35
|
+
subject: string;
|
|
36
|
+
snippet: string;
|
|
37
|
+
text_body: string;
|
|
38
|
+
html_body: string;
|
|
39
|
+
raw_key: string;
|
|
40
|
+
received_at: number;
|
|
41
|
+
consumed_at: number | null;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type ListOptions = {
|
|
45
|
+
limit?: number;
|
|
46
|
+
q?: string;
|
|
47
|
+
fromContains?: string;
|
|
48
|
+
subjectContains?: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type WaitOptions = {
|
|
52
|
+
timeoutMs?: number;
|
|
53
|
+
/** Only match messages received in the last N seconds (default 60) or later. */
|
|
54
|
+
sinceSecondsAgo?: number;
|
|
55
|
+
fromContains?: string;
|
|
56
|
+
subjectContains?: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const MAX_WAIT_MS = 120_000;
|
|
60
|
+
const WAIT_POLL_MS = 1_000;
|
|
61
|
+
const WEBHOOK_MAX_ATTEMPTS = 3;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* One Durable Object per email address. Instance name = lowercased address.
|
|
65
|
+
* Receives mail via routeAgentEmail → onEmail, exposes read tools over RPC.
|
|
66
|
+
*/
|
|
67
|
+
export class Inbox extends Agent<Env> {
|
|
68
|
+
onStart() {
|
|
69
|
+
this.sql`
|
|
70
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
71
|
+
id TEXT PRIMARY KEY,
|
|
72
|
+
from_addr TEXT NOT NULL,
|
|
73
|
+
subject TEXT NOT NULL DEFAULT '',
|
|
74
|
+
snippet TEXT NOT NULL DEFAULT '',
|
|
75
|
+
text_body TEXT NOT NULL DEFAULT '',
|
|
76
|
+
html_body TEXT NOT NULL DEFAULT '',
|
|
77
|
+
raw_key TEXT NOT NULL,
|
|
78
|
+
received_at INTEGER NOT NULL,
|
|
79
|
+
consumed_at INTEGER
|
|
80
|
+
)
|
|
81
|
+
`;
|
|
82
|
+
this.sql`CREATE INDEX IF NOT EXISTS idx_messages_received ON messages (received_at DESC)`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The address this inbox serves (the DO instance name). */
|
|
86
|
+
get address(): string {
|
|
87
|
+
return this.name;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async onEmail(email: AgentEmail) {
|
|
91
|
+
const raw = await email.getRaw();
|
|
92
|
+
const parsed = await PostalMime.parse(raw);
|
|
93
|
+
|
|
94
|
+
const id = crypto.randomUUID();
|
|
95
|
+
const receivedAt = Date.now();
|
|
96
|
+
const subject = parsed.subject ?? "";
|
|
97
|
+
const text = parsed.text ?? "";
|
|
98
|
+
const html = parsed.html ?? "";
|
|
99
|
+
const snippet = makeSnippet(text, html);
|
|
100
|
+
const rawKey = `raw/${this.address}/${id}.eml`;
|
|
101
|
+
|
|
102
|
+
await this.env.RAW.put(rawKey, raw);
|
|
103
|
+
this.sql`
|
|
104
|
+
INSERT INTO messages (id, from_addr, subject, snippet, text_body, html_body, raw_key, received_at)
|
|
105
|
+
VALUES (${id}, ${email.from}, ${subject}, ${snippet}, ${text}, ${html}, ${rawKey}, ${receivedAt})
|
|
106
|
+
`;
|
|
107
|
+
|
|
108
|
+
// Forward decision must happen here: the email proxy is only valid
|
|
109
|
+
// during this invocation.
|
|
110
|
+
if (this.env.FORWARD_MODE === "all" && this.env.FORWARD_TO) {
|
|
111
|
+
try {
|
|
112
|
+
await email.forward(this.env.FORWARD_TO);
|
|
113
|
+
} catch (e) {
|
|
114
|
+
console.error(`forward to ${this.env.FORWARD_TO} failed:`, e);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (this.env.WEBHOOK_URL) {
|
|
119
|
+
await this.deliverWebhook({
|
|
120
|
+
v: 1,
|
|
121
|
+
event: "message.received",
|
|
122
|
+
inbox: this.address,
|
|
123
|
+
message: {
|
|
124
|
+
id,
|
|
125
|
+
from: email.from,
|
|
126
|
+
subject,
|
|
127
|
+
snippet,
|
|
128
|
+
receivedAt
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
list(opts: ListOptions = {}): MessageSummary[] {
|
|
135
|
+
const limit = Math.min(Math.max(opts.limit ?? 20, 1), 100);
|
|
136
|
+
// Small per-address volumes: fetch recent window, filter in JS.
|
|
137
|
+
const rows = this.sql<MessageRow>`
|
|
138
|
+
SELECT * FROM messages ORDER BY received_at DESC LIMIT 200
|
|
139
|
+
`;
|
|
140
|
+
const match = (row: MessageRow) => {
|
|
141
|
+
const q = opts.q?.toLowerCase();
|
|
142
|
+
if (
|
|
143
|
+
q &&
|
|
144
|
+
!row.subject.toLowerCase().includes(q) &&
|
|
145
|
+
!row.from_addr.toLowerCase().includes(q) &&
|
|
146
|
+
!row.text_body.toLowerCase().includes(q)
|
|
147
|
+
)
|
|
148
|
+
return false;
|
|
149
|
+
if (opts.fromContains && !row.from_addr.toLowerCase().includes(opts.fromContains.toLowerCase()))
|
|
150
|
+
return false;
|
|
151
|
+
if (opts.subjectContains && !row.subject.toLowerCase().includes(opts.subjectContains.toLowerCase()))
|
|
152
|
+
return false;
|
|
153
|
+
return true;
|
|
154
|
+
};
|
|
155
|
+
return rows.filter(match).slice(0, limit).map(toSummary);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
get(id: string): MessageDetail | null {
|
|
159
|
+
const rows = this.sql<MessageRow>`SELECT * FROM messages WHERE id = ${id}`;
|
|
160
|
+
const row = rows[0];
|
|
161
|
+
return row ? toDetail(row) : null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Extract an OTP. Read-once: succeeds at most once per message.
|
|
166
|
+
* Without messageId, uses the newest unconsumed message.
|
|
167
|
+
*/
|
|
168
|
+
extractOtp(messageId?: string): { otp: string | null; messageId: string | null; error?: string } {
|
|
169
|
+
const row = messageId
|
|
170
|
+
? this.sql<MessageRow>`SELECT * FROM messages WHERE id = ${messageId}`[0]
|
|
171
|
+
: this.sql<MessageRow>`
|
|
172
|
+
SELECT * FROM messages WHERE consumed_at IS NULL ORDER BY received_at DESC LIMIT 1
|
|
173
|
+
`[0];
|
|
174
|
+
|
|
175
|
+
if (!row) {
|
|
176
|
+
return { otp: null, messageId: messageId ?? null, error: messageId ? "message not found" : "no unconsumed messages" };
|
|
177
|
+
}
|
|
178
|
+
if (row.consumed_at !== null) {
|
|
179
|
+
return { otp: null, messageId: row.id, error: "already consumed (read-once)" };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const body = row.text_body || htmlToText(row.html_body);
|
|
183
|
+
const otp = extractOtp(row.subject, body);
|
|
184
|
+
if (!otp) {
|
|
185
|
+
return { otp: null, messageId: row.id, error: "no OTP-like code found" };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
this.sql`UPDATE messages SET consumed_at = ${Date.now()} WHERE id = ${row.id}`;
|
|
189
|
+
return { otp, messageId: row.id };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
extractLinks(messageId?: string): { links: string[]; messageId: string | null; error?: string } {
|
|
193
|
+
const row = messageId
|
|
194
|
+
? this.sql<MessageRow>`SELECT * FROM messages WHERE id = ${messageId}`[0]
|
|
195
|
+
: this.sql<MessageRow>`SELECT * FROM messages ORDER BY received_at DESC LIMIT 1`[0];
|
|
196
|
+
if (!row) {
|
|
197
|
+
return { links: [], messageId: messageId ?? null, error: messageId ? "message not found" : "inbox is empty" };
|
|
198
|
+
}
|
|
199
|
+
return { links: extractLinks(row.text_body, row.html_body), messageId: row.id };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Long-poll for a matching message. Returns null on timeout. */
|
|
203
|
+
async waitForMessage(opts: WaitOptions = {}): Promise<MessageSummary | null> {
|
|
204
|
+
const timeoutMs = Math.min(Math.max(opts.timeoutMs ?? 60_000, 1_000), MAX_WAIT_MS);
|
|
205
|
+
const sinceTs = Date.now() - (opts.sinceSecondsAgo ?? 60) * 1000;
|
|
206
|
+
const deadline = Date.now() + timeoutMs;
|
|
207
|
+
|
|
208
|
+
while (true) {
|
|
209
|
+
const rows = this.sql<MessageRow>`
|
|
210
|
+
SELECT * FROM messages WHERE received_at >= ${sinceTs} ORDER BY received_at DESC LIMIT 50
|
|
211
|
+
`;
|
|
212
|
+
const hit = rows.find((row) => {
|
|
213
|
+
if (opts.fromContains && !row.from_addr.toLowerCase().includes(opts.fromContains.toLowerCase()))
|
|
214
|
+
return false;
|
|
215
|
+
if (opts.subjectContains && !row.subject.toLowerCase().includes(opts.subjectContains.toLowerCase()))
|
|
216
|
+
return false;
|
|
217
|
+
return true;
|
|
218
|
+
});
|
|
219
|
+
if (hit) return toSummary(hit);
|
|
220
|
+
if (Date.now() + WAIT_POLL_MS > deadline) return null;
|
|
221
|
+
await new Promise((r) => setTimeout(r, WAIT_POLL_MS));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
info(): { address: string; messageCount: number } {
|
|
226
|
+
const rows = this.sql<{ n: number }>`SELECT COUNT(*) AS n FROM messages`;
|
|
227
|
+
return { address: this.address, messageCount: rows[0]?.n ?? 0 };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// --- webhook delivery -----------------------------------------------------
|
|
231
|
+
|
|
232
|
+
private async deliverWebhook(payload: Record<string, unknown>, attempt = 1) {
|
|
233
|
+
const url = this.env.WEBHOOK_URL;
|
|
234
|
+
if (!url) return;
|
|
235
|
+
const body = JSON.stringify(payload);
|
|
236
|
+
try {
|
|
237
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
238
|
+
if (this.env.WEBHOOK_SECRET) {
|
|
239
|
+
headers["x-mailslot-signature"] = await hmacHex(this.env.WEBHOOK_SECRET, body);
|
|
240
|
+
}
|
|
241
|
+
const res = await fetch(url, { method: "POST", headers, body });
|
|
242
|
+
if (!res.ok) throw new Error(`webhook responded ${res.status}`);
|
|
243
|
+
} catch (e) {
|
|
244
|
+
console.error(`webhook attempt ${attempt} failed:`, e);
|
|
245
|
+
if (attempt < WEBHOOK_MAX_ATTEMPTS) {
|
|
246
|
+
await this.schedule(30 * attempt, "retryWebhook", { payload, attempt: attempt + 1 });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Scheduled-task callback for webhook retries (must be public for schedule()). */
|
|
252
|
+
async retryWebhook(data: { payload: Record<string, unknown>; attempt: number }) {
|
|
253
|
+
await this.deliverWebhook(data.payload, data.attempt);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function toSummary(row: MessageRow): MessageSummary {
|
|
258
|
+
return {
|
|
259
|
+
id: row.id,
|
|
260
|
+
from: row.from_addr,
|
|
261
|
+
subject: row.subject,
|
|
262
|
+
snippet: row.snippet,
|
|
263
|
+
receivedAt: row.received_at,
|
|
264
|
+
consumed: row.consumed_at !== null
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function toDetail(row: MessageRow): MessageDetail {
|
|
269
|
+
return {
|
|
270
|
+
...toSummary(row),
|
|
271
|
+
text: row.text_body,
|
|
272
|
+
html: row.html_body,
|
|
273
|
+
rawKey: row.raw_key
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function hmacHex(secret: string, body: string): Promise<string> {
|
|
278
|
+
const key = await crypto.subtle.importKey(
|
|
279
|
+
"raw",
|
|
280
|
+
new TextEncoder().encode(secret),
|
|
281
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
282
|
+
false,
|
|
283
|
+
["sign"]
|
|
284
|
+
);
|
|
285
|
+
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(body));
|
|
286
|
+
return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
287
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { routeAgentEmail } from "agents";
|
|
2
|
+
import type { Env } from "./env";
|
|
3
|
+
import { Inbox } from "./inbox";
|
|
4
|
+
import { MailslotMcp } from "./mcp";
|
|
5
|
+
import { handleApi } from "./api";
|
|
6
|
+
import { checkBearerToken } from "./auth";
|
|
7
|
+
|
|
8
|
+
export { Inbox, MailslotMcp };
|
|
9
|
+
|
|
10
|
+
const mcpHandler = MailslotMcp.serve("/mcp", { binding: "MailslotMcp" });
|
|
11
|
+
|
|
12
|
+
export default {
|
|
13
|
+
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
|
14
|
+
const url = new URL(request.url);
|
|
15
|
+
|
|
16
|
+
if (url.pathname === "/v1/health") {
|
|
17
|
+
return Response.json({ ok: true, service: "mailslot" });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const authorized = await checkBearerToken(request, env.MAILSLOT_TOKEN);
|
|
21
|
+
if (!authorized) {
|
|
22
|
+
return Response.json({ error: "unauthorized" }, { status: 401 });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (url.pathname === "/mcp" || url.pathname.startsWith("/mcp/")) {
|
|
26
|
+
return mcpHandler.fetch(request, env, ctx);
|
|
27
|
+
}
|
|
28
|
+
if (url.pathname.startsWith("/v1/")) {
|
|
29
|
+
return handleApi(request, env);
|
|
30
|
+
}
|
|
31
|
+
return Response.json({ error: "not found" }, { status: 404 });
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
async email(message: ForwardableEmailMessage, env: Env) {
|
|
35
|
+
await routeAgentEmail(message, env, {
|
|
36
|
+
// Catch-all: every address on the domain gets its own Inbox DO,
|
|
37
|
+
// named by the full lowercased recipient address.
|
|
38
|
+
resolver: async (email: ForwardableEmailMessage) => {
|
|
39
|
+
const to = email.to?.toLowerCase();
|
|
40
|
+
if (!to || !isAcceptableAddress(to, env.EMAIL_DOMAIN)) {
|
|
41
|
+
email.setReject("Unknown recipient");
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return { agentName: "Inbox", agentId: to };
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
} satisfies ExportedHandler<Env>;
|
|
49
|
+
|
|
50
|
+
function isAcceptableAddress(to: string, domain?: string): boolean {
|
|
51
|
+
const at = to.lastIndexOf("@");
|
|
52
|
+
if (at < 1) return false;
|
|
53
|
+
const local = to.slice(0, at);
|
|
54
|
+
if (local.length > 64) return false;
|
|
55
|
+
// If EMAIL_DOMAIN is configured, only accept mail for that domain.
|
|
56
|
+
if (domain && to.slice(at + 1) !== domain.toLowerCase()) return false;
|
|
57
|
+
return true;
|
|
58
|
+
}
|
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { getAgentByName } from "agents";
|
|
2
|
+
import { McpAgent } from "agents/mcp";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import type { Env } from "./env";
|
|
6
|
+
import { mintLocalPart } from "./auth";
|
|
7
|
+
|
|
8
|
+
const ADDRESS = z.string().email().describe("Full inbox address, e.g. agent-x7k2f9@agents.example.com");
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* MCP surface. Tools are thin wrappers over Inbox DO RPC — the same engine
|
|
12
|
+
* the HTTP API uses. One stateless server; inbox state lives in Inbox DOs.
|
|
13
|
+
*/
|
|
14
|
+
export class MailslotMcp extends McpAgent<Env> {
|
|
15
|
+
server = new McpServer({ name: "mailslot", version: "0.0.1" });
|
|
16
|
+
|
|
17
|
+
async init() {
|
|
18
|
+
const env = this.env;
|
|
19
|
+
|
|
20
|
+
const inbox = (address: string) => getAgentByName(env.Inbox, address.toLowerCase());
|
|
21
|
+
const json = (value: unknown) => ({
|
|
22
|
+
content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }]
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
this.server.registerTool(
|
|
26
|
+
"create_address",
|
|
27
|
+
{
|
|
28
|
+
description:
|
|
29
|
+
"Mint a fresh, never-used email address for a task. Use one address per signup/task; do not reuse.",
|
|
30
|
+
inputSchema: {
|
|
31
|
+
prefix: z.string().optional().describe("Optional address prefix, e.g. 'signup' → signup-x7k2f9@…")
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
async ({ prefix }) => {
|
|
35
|
+
if (!env.EMAIL_DOMAIN) return json({ error: "EMAIL_DOMAIN is not configured on the server" });
|
|
36
|
+
return json({ address: `${mintLocalPart(prefix)}@${env.EMAIL_DOMAIN.toLowerCase()}` });
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
this.server.registerTool(
|
|
41
|
+
"list_messages",
|
|
42
|
+
{
|
|
43
|
+
description: "List recent messages in an inbox, newest first. Optional substring filters.",
|
|
44
|
+
inputSchema: {
|
|
45
|
+
address: ADDRESS,
|
|
46
|
+
q: z.string().optional().describe("Match in subject, sender, or body"),
|
|
47
|
+
from_contains: z.string().optional(),
|
|
48
|
+
subject_contains: z.string().optional(),
|
|
49
|
+
limit: z.number().int().min(1).max(100).optional()
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
async ({ address, q, from_contains, subject_contains, limit }) => {
|
|
53
|
+
const stub = await inbox(address);
|
|
54
|
+
return json(await stub.list({ q, fromContains: from_contains, subjectContains: subject_contains, limit }));
|
|
55
|
+
}
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
this.server.registerTool(
|
|
59
|
+
"get_message",
|
|
60
|
+
{
|
|
61
|
+
description: "Fetch a full message (text, HTML, headers reference) by id.",
|
|
62
|
+
inputSchema: { address: ADDRESS, message_id: z.string() }
|
|
63
|
+
},
|
|
64
|
+
async ({ address, message_id }) => {
|
|
65
|
+
const stub = await inbox(address);
|
|
66
|
+
const message = await stub.get(message_id);
|
|
67
|
+
return json(message ?? { error: "message not found" });
|
|
68
|
+
}
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
this.server.registerTool(
|
|
72
|
+
"extract_otp",
|
|
73
|
+
{
|
|
74
|
+
description:
|
|
75
|
+
"Extract the one-time code from a message. READ-ONCE: each message yields its OTP at most once. " +
|
|
76
|
+
"Omit message_id to use the newest unconsumed message.",
|
|
77
|
+
inputSchema: { address: ADDRESS, message_id: z.string().optional() }
|
|
78
|
+
},
|
|
79
|
+
async ({ address, message_id }) => {
|
|
80
|
+
const stub = await inbox(address);
|
|
81
|
+
return json(await stub.extractOtp(message_id));
|
|
82
|
+
}
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
this.server.registerTool(
|
|
86
|
+
"extract_links",
|
|
87
|
+
{
|
|
88
|
+
description:
|
|
89
|
+
"Extract links (verification/magic links first, by document order) from a message. " +
|
|
90
|
+
"Omit message_id to use the newest message.",
|
|
91
|
+
inputSchema: { address: ADDRESS, message_id: z.string().optional() }
|
|
92
|
+
},
|
|
93
|
+
async ({ address, message_id }) => {
|
|
94
|
+
const stub = await inbox(address);
|
|
95
|
+
return json(await stub.extractLinks(message_id));
|
|
96
|
+
}
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
this.server.registerTool(
|
|
100
|
+
"wait_for_message",
|
|
101
|
+
{
|
|
102
|
+
description:
|
|
103
|
+
"Block until a matching message arrives (long-poll), or timeout. Call right after triggering the email " +
|
|
104
|
+
"(signup, password reset). Returns the message summary, or null on timeout.",
|
|
105
|
+
inputSchema: {
|
|
106
|
+
address: ADDRESS,
|
|
107
|
+
timeout_s: z.number().int().min(1).max(120).optional().describe("Default 60"),
|
|
108
|
+
since_s: z.number().int().min(0).optional().describe("Also match messages up to N seconds old (default 60)"),
|
|
109
|
+
from_contains: z.string().optional(),
|
|
110
|
+
subject_contains: z.string().optional()
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
async ({ address, timeout_s, since_s, from_contains, subject_contains }) => {
|
|
114
|
+
const stub = await inbox(address);
|
|
115
|
+
const message = await stub.waitForMessage({
|
|
116
|
+
timeoutMs: (timeout_s ?? 60) * 1000,
|
|
117
|
+
sinceSecondsAgo: since_s,
|
|
118
|
+
fromContains: from_contains,
|
|
119
|
+
subjectContains: subject_contains
|
|
120
|
+
});
|
|
121
|
+
return json({ message });
|
|
122
|
+
}
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
package/src/shims/ai.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stub for the optional "ai" (Vercel AI SDK) dependency that the agents SDK
|
|
3
|
+
* imports dynamically in code paths Mailslot never uses. Aliased in
|
|
4
|
+
* wrangler.jsonc so the bundler can resolve it without shipping the SDK.
|
|
5
|
+
*/
|
|
6
|
+
export function jsonSchema(): never {
|
|
7
|
+
throw new Error("The 'ai' package is not installed — this code path is unused by Mailslot.");
|
|
8
|
+
}
|
package/wrangler.jsonc
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "node_modules/wrangler/config-schema.json",
|
|
3
|
+
"name": "mailslot",
|
|
4
|
+
"main": "src/index.ts",
|
|
5
|
+
"compatibility_date": "2026-04-01",
|
|
6
|
+
"compatibility_flags": ["nodejs_compat"],
|
|
7
|
+
"durable_objects": {
|
|
8
|
+
"bindings": [
|
|
9
|
+
{ "name": "Inbox", "class_name": "Inbox" },
|
|
10
|
+
{ "name": "MailslotMcp", "class_name": "MailslotMcp" }
|
|
11
|
+
]
|
|
12
|
+
},
|
|
13
|
+
"migrations": [
|
|
14
|
+
{ "tag": "v1", "new_sqlite_classes": ["Inbox", "MailslotMcp"] }
|
|
15
|
+
],
|
|
16
|
+
"r2_buckets": [
|
|
17
|
+
{ "binding": "RAW", "bucket_name": "mailslot-raw" }
|
|
18
|
+
],
|
|
19
|
+
"vars": {
|
|
20
|
+
// "all" forwards every inbound message to FORWARD_TO (a verified destination); "none" disables
|
|
21
|
+
"FORWARD_MODE": "none"
|
|
22
|
+
},
|
|
23
|
+
// Instance-specific values are NOT in this file (it ships generic in the repo).
|
|
24
|
+
// Set once per instance; keep_vars makes them survive every deploy:
|
|
25
|
+
// EMAIL_DOMAIN — var, set in dashboard (Workers → mailslot → Settings → Variables)
|
|
26
|
+
// or first deploy: wrangler deploy --var EMAIL_DOMAIN:mail.example.com
|
|
27
|
+
// MAILSLOT_TOKEN — secret: openssl rand -hex 24 | wrangler secret put MAILSLOT_TOKEN
|
|
28
|
+
// Optional dashboard vars: FORWARD_TO, WEBHOOK_URL; optional secret: WEBHOOK_SECRET
|
|
29
|
+
"keep_vars": true,
|
|
30
|
+
"alias": {
|
|
31
|
+
// Optional dep of the agents SDK, dynamically imported in paths we don't use
|
|
32
|
+
"ai": "./src/shims/ai.ts"
|
|
33
|
+
},
|
|
34
|
+
"observability": { "enabled": true }
|
|
35
|
+
// Secrets (set via `wrangler secret put`):
|
|
36
|
+
// MAILSLOT_TOKEN — bearer token for HTTP API + MCP
|
|
37
|
+
// WEBHOOK_SECRET — optional, HMAC-signs webhook payloads
|
|
38
|
+
// Optional vars: FORWARD_TO, WEBHOOK_URL
|
|
39
|
+
}
|