@mailkite/mcp 0.1.0
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 +88 -0
- package/package.json +46 -0
- package/server.mjs +189 -0
- package/spec/api.json +121 -0
- package/spec/cases.json +244 -0
- package/spec/schemas/create-domain-request.json +11 -0
- package/spec/schemas/create-route-request.json +13 -0
- package/spec/schemas/send-request.json +38 -0
- package/spec/schemas/send-response.json +11 -0
- package/spec/schemas/set-webhook-request.json +11 -0
- package/spec/schemas/verify-webhook-request.json +28 -0
package/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# @mailkite/mcp
|
|
2
|
+
|
|
3
|
+
[Model Context Protocol](https://modelcontextprotocol.io) server for
|
|
4
|
+
[MailKite](https://mailkite.dev). It exposes the MailKite API to LLM agents
|
|
5
|
+
(Claude Desktop, Claude Code, Cursor, …) as tools — send mail, manage domains,
|
|
6
|
+
webhooks, routes, and inbound messages, all from a chat.
|
|
7
|
+
|
|
8
|
+
It's a **thin layer**. The tools, their input schemas, and validation all come
|
|
9
|
+
from the shared SDK contract in [`../spec`](../spec); transport, auth, and error
|
|
10
|
+
handling come from the [MailKite Node SDK](../node). Nothing about the API is
|
|
11
|
+
duplicated here — update the spec and the MCP follows.
|
|
12
|
+
|
|
13
|
+
## Install / configure
|
|
14
|
+
|
|
15
|
+
Point your MCP client at the server and give it your MailKite credential. The
|
|
16
|
+
token is the same one the SDKs take: an `mk_live_…` API key (for sending) or a
|
|
17
|
+
management session token.
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{
|
|
21
|
+
"mcpServers": {
|
|
22
|
+
"mailkite": {
|
|
23
|
+
"command": "npx",
|
|
24
|
+
"args": ["-y", "@mailkite/mcp"],
|
|
25
|
+
"env": { "MAILKITE_API_KEY": "mk_live_…" }
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
| Env var | Required | Default | Notes |
|
|
32
|
+
| --- | --- | --- | --- |
|
|
33
|
+
| `MAILKITE_API_KEY` | yes | — | Bearer credential (`mk_live_…` API key or session token) |
|
|
34
|
+
| `MAILKITE_BASE_URL` | no | `https://api.mailkite.dev` | Override for local/staging |
|
|
35
|
+
|
|
36
|
+
## Tools
|
|
37
|
+
|
|
38
|
+
One tool per MailKite API operation (generated from [`../spec/api.json`](../spec/api.json)):
|
|
39
|
+
|
|
40
|
+
| Tool | Operation |
|
|
41
|
+
| --- | --- |
|
|
42
|
+
| `mailkite_send` | Send a message over a verified domain |
|
|
43
|
+
| `mailkite_list_domains` | List your domains |
|
|
44
|
+
| `mailkite_create_domain` | Add a domain (returns DNS records) |
|
|
45
|
+
| `mailkite_get_domain` | Get one domain with DNS + webhook |
|
|
46
|
+
| `mailkite_delete_domain` | Remove a domain |
|
|
47
|
+
| `mailkite_verify_domain` | Check DNS and update status |
|
|
48
|
+
| `mailkite_set_webhook` | Set/replace the domain catch-all webhook |
|
|
49
|
+
| `mailkite_delete_webhook` | Remove the domain webhook |
|
|
50
|
+
| `mailkite_test_webhook` | Send a signed test event to the webhook |
|
|
51
|
+
| `mailkite_list_routes` | List inbound routing rules |
|
|
52
|
+
| `mailkite_create_route` | Create a route (match, action, destination) |
|
|
53
|
+
| `mailkite_list_messages` | List stored messages |
|
|
54
|
+
| `mailkite_get_message` | Get a message with deliveries + attachments |
|
|
55
|
+
| `mailkite_retry_delivery` | Re-deliver a stored message to its webhook |
|
|
56
|
+
| `mailkite_verify_webhook` | Verify an `x-mailkite-signature` header (local — no API call) |
|
|
57
|
+
|
|
58
|
+
`mailkite_verify_webhook` is a **local** tool: it runs the SDK's `verifyWebhook`
|
|
59
|
+
in-process (no credential, no network) and returns `{ "valid": true | false }`.
|
|
60
|
+
Every other tool is one MailKite API operation.
|
|
61
|
+
|
|
62
|
+
Each tool's input is a flat object with the real field names (e.g.
|
|
63
|
+
`mailkite_send` takes `from`, `to`, `subject`, `html`, `text`, …). Required
|
|
64
|
+
fields and types are enforced with [`ajv`](https://ajv.js.org) against the same
|
|
65
|
+
JSON Schemas in [`../spec/schemas`](../spec/schemas) that the SDKs and
|
|
66
|
+
conformance harness use — invalid calls are rejected before any HTTP request.
|
|
67
|
+
|
|
68
|
+
## How it works
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
api.json ──▶ one MCP tool per method (name, description, input schema)
|
|
72
|
+
schemas/* ──▶ ajv validators (validate the call before dispatch)
|
|
73
|
+
Node SDK ──▶ MailKite.request(method, path, body) (auth, HTTP, errors)
|
|
74
|
+
local methods (verifyWebhook) dispatch in-process — no HTTP
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
See [`PLAN.md`](./PLAN.md) for the full design.
|
|
78
|
+
|
|
79
|
+
## Develop
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
npm install
|
|
83
|
+
npm test # boots the server over stdio, lists tools, checks validation + wire bytes
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mailkite/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Model Context Protocol server for MailKite — exposes the MailKite API to LLM agents as tools. A thin layer over the MailKite Node SDK and the shared sdks/spec contract.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mailkite-mcp": "server.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"server.mjs",
|
|
11
|
+
"spec",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"prepack": "node prepack.mjs",
|
|
19
|
+
"postpack": "node -e \"require('node:fs').rmSync('spec',{recursive:true,force:true})\"",
|
|
20
|
+
"test": "node smoke.mjs && node wire-test.mjs"
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"mailkite",
|
|
27
|
+
"mcp",
|
|
28
|
+
"model-context-protocol",
|
|
29
|
+
"email",
|
|
30
|
+
"ai",
|
|
31
|
+
"agent",
|
|
32
|
+
"tools"
|
|
33
|
+
],
|
|
34
|
+
"homepage": "https://mailkite.dev/docs/libraries",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "https://github.com/fijiwebdesign/mailkite.git",
|
|
38
|
+
"directory": "sdks/mcp"
|
|
39
|
+
},
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
43
|
+
"ajv": "^8.17.1",
|
|
44
|
+
"mailkite": "^0.1.0"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/server.mjs
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// MailKite MCP server.
|
|
3
|
+
//
|
|
4
|
+
// This is *only* the MCP layer. Everything else is reused:
|
|
5
|
+
// • Operation ↔ endpoint mapping → ../spec/api.json (the canonical contract)
|
|
6
|
+
// • Input validation → ../spec/schemas/*.json compiled with ajv
|
|
7
|
+
// (the same files + validator as conformance)
|
|
8
|
+
// • Transport / auth / errors → the MailKite Node SDK (../node)
|
|
9
|
+
//
|
|
10
|
+
// We read api.json at startup and register one MCP tool per method. A call is
|
|
11
|
+
// validated against the shared JSON Schema, then dispatched through the SDK's
|
|
12
|
+
// low-level request(). No endpoint list, schema, or HTTP logic is duplicated.
|
|
13
|
+
|
|
14
|
+
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
|
|
18
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
19
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
20
|
+
import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
21
|
+
import Ajv from "ajv";
|
|
22
|
+
import { MailKite, MailKiteError } from "mailkite";
|
|
23
|
+
|
|
24
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
// Prefer a spec bundled into the package (copied in at publish time by prepack);
|
|
26
|
+
// fall back to the sibling sdks/spec when running straight from the repo.
|
|
27
|
+
const SPEC = existsSync(path.join(HERE, "spec")) ? path.join(HERE, "spec") : path.resolve(HERE, "..", "spec");
|
|
28
|
+
|
|
29
|
+
// ---- load the canonical contract --------------------------------------------
|
|
30
|
+
const api = JSON.parse(readFileSync(path.join(SPEC, "api.json"), "utf8"));
|
|
31
|
+
|
|
32
|
+
// Compile one ajv validator per body schema — same config + files as
|
|
33
|
+
// sdks/conformance/run.mjs, so the MCP rejects exactly what the SDKs reject.
|
|
34
|
+
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
35
|
+
const schemas = {}; // id -> parsed schema
|
|
36
|
+
const validators = {}; // id -> compiled validator
|
|
37
|
+
for (const file of readdirSync(path.join(SPEC, "schemas"))) {
|
|
38
|
+
if (!file.endsWith(".json")) continue;
|
|
39
|
+
const id = file.replace(/\.json$/, "");
|
|
40
|
+
const schema = JSON.parse(readFileSync(path.join(SPEC, "schemas", file), "utf8"));
|
|
41
|
+
schemas[id] = schema;
|
|
42
|
+
validators[id] = ajv.compile(schema);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---- turn each api.json method into an MCP tool -----------------------------
|
|
46
|
+
function toSnake(name) {
|
|
47
|
+
return name.replace(/([A-Z])/g, "_$1").toLowerCase();
|
|
48
|
+
}
|
|
49
|
+
const toolName = (method) => `mailkite_${toSnake(method.name)}`;
|
|
50
|
+
|
|
51
|
+
// Build the flat input schema for a method: path params (as strings) merged with
|
|
52
|
+
// the referenced body schema's properties, so the model sees real field names.
|
|
53
|
+
function buildInputSchema(method) {
|
|
54
|
+
const properties = {};
|
|
55
|
+
const required = [];
|
|
56
|
+
|
|
57
|
+
for (const arg of method.args || []) {
|
|
58
|
+
if (arg.in === "path") {
|
|
59
|
+
properties[arg.name] = { type: "string", description: `Path parameter \`${arg.name}\`.` };
|
|
60
|
+
required.push(arg.name);
|
|
61
|
+
} else if (arg.in === "body" && arg.schema && schemas[arg.schema]) {
|
|
62
|
+
const body = schemas[arg.schema];
|
|
63
|
+
Object.assign(properties, body.properties || {});
|
|
64
|
+
for (const r of body.required || []) required.push(r);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { type: "object", properties, required, additionalProperties: false };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const credentialNote = (method) => {
|
|
72
|
+
if (method.local) return "Runs locally (no API call) — no credentials needed.";
|
|
73
|
+
return method.http.path.startsWith("/v1/")
|
|
74
|
+
? "Requires an API key (mk_live_…)."
|
|
75
|
+
: "Requires a management session token.";
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const tools = api.methods.map((method) => ({
|
|
79
|
+
name: toolName(method),
|
|
80
|
+
description: `${method.summary} ${credentialNote(method)}`,
|
|
81
|
+
inputSchema: buildInputSchema(method),
|
|
82
|
+
_method: method, // kept server-side for dispatch; not sent to the client
|
|
83
|
+
}));
|
|
84
|
+
|
|
85
|
+
const byToolName = new Map(tools.map((t) => [t.name, t]));
|
|
86
|
+
|
|
87
|
+
// ---- dispatch ---------------------------------------------------------------
|
|
88
|
+
const apiKey = process.env.MAILKITE_API_KEY;
|
|
89
|
+
const baseUrl = process.env.MAILKITE_BASE_URL;
|
|
90
|
+
const mk = new MailKite(apiKey || "", baseUrl || undefined);
|
|
91
|
+
|
|
92
|
+
// Split a tool's flat input into (resolved path, validated body).
|
|
93
|
+
function resolveCall(method, input) {
|
|
94
|
+
input = input || {};
|
|
95
|
+
let urlPath = method.http.path;
|
|
96
|
+
let bodySchemaId = null;
|
|
97
|
+
|
|
98
|
+
for (const arg of method.args || []) {
|
|
99
|
+
if (arg.in === "path") {
|
|
100
|
+
const value = input[arg.name];
|
|
101
|
+
if (value == null || value === "") throw new Error(`Missing required path parameter: ${arg.name}`);
|
|
102
|
+
urlPath = urlPath.replace(`{${arg.name}}`, encodeURIComponent(String(value)));
|
|
103
|
+
} else if (arg.in === "body" && arg.schema) {
|
|
104
|
+
bodySchemaId = arg.schema;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let body;
|
|
109
|
+
if (bodySchemaId) {
|
|
110
|
+
// The body is exactly the schema's own properties (path params are excluded,
|
|
111
|
+
// since every body schema is additionalProperties:false).
|
|
112
|
+
const keys = Object.keys(schemas[bodySchemaId].properties || {});
|
|
113
|
+
body = {};
|
|
114
|
+
for (const k of keys) if (input[k] !== undefined) body[k] = input[k];
|
|
115
|
+
|
|
116
|
+
const validate = validators[bodySchemaId];
|
|
117
|
+
if (!validate(body)) {
|
|
118
|
+
throw new Error(`Invalid input for ${bodySchemaId}: ${ajv.errorsText(validate.errors)}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { method: method.http.method, urlPath, body };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---- MCP server -------------------------------------------------------------
|
|
126
|
+
const server = new Server(
|
|
127
|
+
{ name: "mailkite", version: api.version || "0.1.0" },
|
|
128
|
+
{ capabilities: { tools: {} } }
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
132
|
+
tools: tools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
|
|
133
|
+
}));
|
|
134
|
+
|
|
135
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
136
|
+
const tool = byToolName.get(req.params.name);
|
|
137
|
+
if (!tool) {
|
|
138
|
+
return { isError: true, content: [{ type: "text", text: `Unknown tool: ${req.params.name}` }] };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Local methods (e.g. verifyWebhook) run in-process — no API key, no network.
|
|
142
|
+
// Validate against the same JSON Schema, then dispatch through the SDK.
|
|
143
|
+
if (tool._method.local) {
|
|
144
|
+
try {
|
|
145
|
+
const input = req.params.arguments || {};
|
|
146
|
+
const bodyArg = (tool._method.args || []).find((a) => a.in === "body");
|
|
147
|
+
if (bodyArg?.schema) {
|
|
148
|
+
const validate = validators[bodyArg.schema];
|
|
149
|
+
if (!validate(input)) {
|
|
150
|
+
return {
|
|
151
|
+
isError: true,
|
|
152
|
+
content: [{ type: "text", text: `Invalid input for ${bodyArg.schema}: ${ajv.errorsText(validate.errors)}` }],
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (tool._method.name !== "verifyWebhook") {
|
|
157
|
+
throw new Error(`No local handler for ${tool._method.name}`);
|
|
158
|
+
}
|
|
159
|
+
const valid = mk.verifyWebhook(input.signature, input.payload, input.secret, input.toleranceMs);
|
|
160
|
+
return { content: [{ type: "text", text: JSON.stringify({ valid }, null, 2) }] };
|
|
161
|
+
} catch (err) {
|
|
162
|
+
return { isError: true, content: [{ type: "text", text: `Error: ${err.message}` }] };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!apiKey) {
|
|
167
|
+
return {
|
|
168
|
+
isError: true,
|
|
169
|
+
content: [{ type: "text", text: "MAILKITE_API_KEY is not set. Provide it in the MCP server env." }],
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
const { method, urlPath, body } = resolveCall(tool._method, req.params.arguments);
|
|
175
|
+
const result = await mk.request(method, urlPath, body);
|
|
176
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
177
|
+
} catch (err) {
|
|
178
|
+
const text =
|
|
179
|
+
err instanceof MailKiteError
|
|
180
|
+
? `MailKite API error ${err.status}: ${err.message}`
|
|
181
|
+
: `Error: ${err.message}`;
|
|
182
|
+
return { isError: true, content: [{ type: "text", text }] };
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const transport = new StdioServerTransport();
|
|
187
|
+
await server.connect(transport);
|
|
188
|
+
// Note: stdout is the MCP transport — never console.log here. Use stderr.
|
|
189
|
+
console.error(`mailkite mcp server ready — ${tools.length} tools, base ${mk.baseUrl}`);
|
package/spec/api.json
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mailkite",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Canonical interface contract for every MailKite SDK. One low-level request() plus one function per endpoint. All languages expose the same shape; only naming adapts to each language's convention (e.g. Go exports PascalCase).",
|
|
5
|
+
"baseUrl": "https://api.mailkite.dev",
|
|
6
|
+
"auth": {
|
|
7
|
+
"scheme": "bearer",
|
|
8
|
+
"header": "Authorization",
|
|
9
|
+
"note": "Send uses an API key (mk_live_…); management endpoints use a session token. Both are Bearer credentials, so the client takes one token."
|
|
10
|
+
},
|
|
11
|
+
"methods": [
|
|
12
|
+
{
|
|
13
|
+
"name": "send",
|
|
14
|
+
"summary": "Send a message over a verified domain.",
|
|
15
|
+
"http": { "method": "POST", "path": "/v1/send" },
|
|
16
|
+
"args": [{ "name": "message", "in": "body", "schema": "send-request" }],
|
|
17
|
+
"returns": "send-response"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"name": "listDomains",
|
|
21
|
+
"summary": "List your domains, each with its webhook URL.",
|
|
22
|
+
"http": { "method": "GET", "path": "/api/domains" },
|
|
23
|
+
"args": [],
|
|
24
|
+
"returns": "any"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"name": "createDomain",
|
|
28
|
+
"summary": "Add a domain. Returns the domain + DNS records.",
|
|
29
|
+
"http": { "method": "POST", "path": "/api/domains" },
|
|
30
|
+
"args": [{ "name": "body", "in": "body", "schema": "create-domain-request" }],
|
|
31
|
+
"returns": "any"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"name": "getDomain",
|
|
35
|
+
"summary": "Get one domain with DNS records + webhook.",
|
|
36
|
+
"http": { "method": "GET", "path": "/api/domains/{id}" },
|
|
37
|
+
"args": [{ "name": "id", "in": "path" }],
|
|
38
|
+
"returns": "any"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"name": "deleteDomain",
|
|
42
|
+
"summary": "Remove a domain.",
|
|
43
|
+
"http": { "method": "DELETE", "path": "/api/domains/{id}" },
|
|
44
|
+
"args": [{ "name": "id", "in": "path" }],
|
|
45
|
+
"returns": "any"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"name": "verifyDomain",
|
|
49
|
+
"summary": "Check DNS and update status.",
|
|
50
|
+
"http": { "method": "POST", "path": "/api/domains/{id}/verify" },
|
|
51
|
+
"args": [{ "name": "id", "in": "path" }],
|
|
52
|
+
"returns": "any"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"name": "setWebhook",
|
|
56
|
+
"summary": "Set or replace the domain's catch-all webhook.",
|
|
57
|
+
"http": { "method": "PUT", "path": "/api/domains/{id}/webhook" },
|
|
58
|
+
"args": [
|
|
59
|
+
{ "name": "id", "in": "path" },
|
|
60
|
+
{ "name": "body", "in": "body", "schema": "set-webhook-request" }
|
|
61
|
+
],
|
|
62
|
+
"returns": "any"
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"name": "deleteWebhook",
|
|
66
|
+
"summary": "Remove the domain's webhook.",
|
|
67
|
+
"http": { "method": "DELETE", "path": "/api/domains/{id}/webhook" },
|
|
68
|
+
"args": [{ "name": "id", "in": "path" }],
|
|
69
|
+
"returns": "any"
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"name": "testWebhook",
|
|
73
|
+
"summary": "Send a signed test event to the domain's webhook.",
|
|
74
|
+
"http": { "method": "POST", "path": "/api/domains/{id}/webhook/test" },
|
|
75
|
+
"args": [{ "name": "id", "in": "path" }],
|
|
76
|
+
"returns": "any"
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"name": "listRoutes",
|
|
80
|
+
"summary": "List inbound routing rules.",
|
|
81
|
+
"http": { "method": "GET", "path": "/api/routes" },
|
|
82
|
+
"args": [],
|
|
83
|
+
"returns": "any"
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"name": "createRoute",
|
|
87
|
+
"summary": "Create a route (match, action, destination).",
|
|
88
|
+
"http": { "method": "POST", "path": "/api/routes" },
|
|
89
|
+
"args": [{ "name": "body", "in": "body", "schema": "create-route-request" }],
|
|
90
|
+
"returns": "any"
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"name": "listMessages",
|
|
94
|
+
"summary": "List stored messages.",
|
|
95
|
+
"http": { "method": "GET", "path": "/api/messages" },
|
|
96
|
+
"args": [],
|
|
97
|
+
"returns": "any"
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"name": "getMessage",
|
|
101
|
+
"summary": "Get a message with deliveries + attachments.",
|
|
102
|
+
"http": { "method": "GET", "path": "/api/messages/{id}" },
|
|
103
|
+
"args": [{ "name": "id", "in": "path" }],
|
|
104
|
+
"returns": "any"
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"name": "retryDelivery",
|
|
108
|
+
"summary": "Re-deliver a stored message to its webhook.",
|
|
109
|
+
"http": { "method": "POST", "path": "/api/deliveries/{id}/retry" },
|
|
110
|
+
"args": [{ "name": "id", "in": "path" }],
|
|
111
|
+
"returns": "any"
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
"name": "verifyWebhook",
|
|
115
|
+
"summary": "Verify the `x-mailkite-signature` header on an inbound webhook delivery. Runs entirely locally (HMAC-SHA256 over `${t}.${payload}`) — no network call. Returns true only when the signature matches and the event is within the freshness window.",
|
|
116
|
+
"local": true,
|
|
117
|
+
"args": [{ "name": "body", "in": "body", "schema": "verify-webhook-request" }],
|
|
118
|
+
"returns": "boolean"
|
|
119
|
+
}
|
|
120
|
+
]
|
|
121
|
+
}
|
package/spec/cases.json
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
{
|
|
2
|
+
"comment": "Shared cross-language conformance cases. Each SDK's conformance runner reads this file, calls the named method with `args`, and reports the parsed result (or error). The Node harness in sdks/conformance asserts the outgoing HTTP request matches `request` (and validates the body against the referenced JSON Schema) and that the SDK's result matches `result`/`error`. Cases with `\"local\": true` make no HTTP call (e.g. verifyWebhook); for those the harness validates `args` against `argsSchema` and only checks the returned `result`.",
|
|
3
|
+
"apiKey": "test_key",
|
|
4
|
+
"cases": [
|
|
5
|
+
{
|
|
6
|
+
"name": "send_minimal",
|
|
7
|
+
"method": "send",
|
|
8
|
+
"args": { "from": "hello@app.mailkite.dev", "to": "ada@example.com", "subject": "Hi", "text": "It works." },
|
|
9
|
+
"request": {
|
|
10
|
+
"method": "POST",
|
|
11
|
+
"path": "/v1/send",
|
|
12
|
+
"bodySchema": "send-request",
|
|
13
|
+
"body": { "from": "hello@app.mailkite.dev", "to": "ada@example.com", "subject": "Hi", "text": "It works." }
|
|
14
|
+
},
|
|
15
|
+
"response": { "status": 202, "body": { "id": "msg_minimal", "status": "queued" } },
|
|
16
|
+
"result": { "id": "msg_minimal", "status": "queued" }
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"name": "send_full",
|
|
20
|
+
"method": "send",
|
|
21
|
+
"args": {
|
|
22
|
+
"from": "hello@app.mailkite.dev",
|
|
23
|
+
"to": ["ada@example.com", "grace@example.com"],
|
|
24
|
+
"cc": "cc@example.com",
|
|
25
|
+
"subject": "Your invoice #1042",
|
|
26
|
+
"html": "<p>Thanks!</p>",
|
|
27
|
+
"text": "Thanks!",
|
|
28
|
+
"replyTo": "support@app.mailkite.dev",
|
|
29
|
+
"inReplyTo": "<a1b2@mail.example.com>",
|
|
30
|
+
"attachments": [{ "filename": "receipt.pdf", "url": "https://files.app.com/receipt.pdf" }]
|
|
31
|
+
},
|
|
32
|
+
"request": {
|
|
33
|
+
"method": "POST",
|
|
34
|
+
"path": "/v1/send",
|
|
35
|
+
"bodySchema": "send-request",
|
|
36
|
+
"body": {
|
|
37
|
+
"from": "hello@app.mailkite.dev",
|
|
38
|
+
"to": ["ada@example.com", "grace@example.com"],
|
|
39
|
+
"cc": "cc@example.com",
|
|
40
|
+
"subject": "Your invoice #1042",
|
|
41
|
+
"html": "<p>Thanks!</p>",
|
|
42
|
+
"text": "Thanks!",
|
|
43
|
+
"replyTo": "support@app.mailkite.dev",
|
|
44
|
+
"inReplyTo": "<a1b2@mail.example.com>",
|
|
45
|
+
"attachments": [{ "filename": "receipt.pdf", "url": "https://files.app.com/receipt.pdf" }]
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"response": { "status": 202, "body": { "id": "msg_full", "status": "queued" } },
|
|
49
|
+
"result": { "id": "msg_full", "status": "queued" }
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"name": "list_domains",
|
|
53
|
+
"method": "listDomains",
|
|
54
|
+
"args": {},
|
|
55
|
+
"request": { "method": "GET", "path": "/api/domains" },
|
|
56
|
+
"response": { "status": 200, "body": [{ "id": "dom_1", "domain": "app.mailkite.dev" }] },
|
|
57
|
+
"result": [{ "id": "dom_1", "domain": "app.mailkite.dev" }]
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"name": "create_domain",
|
|
61
|
+
"method": "createDomain",
|
|
62
|
+
"args": { "domain": "app.mailkite.dev" },
|
|
63
|
+
"request": {
|
|
64
|
+
"method": "POST",
|
|
65
|
+
"path": "/api/domains",
|
|
66
|
+
"bodySchema": "create-domain-request",
|
|
67
|
+
"body": { "domain": "app.mailkite.dev" }
|
|
68
|
+
},
|
|
69
|
+
"response": { "status": 200, "body": { "domain": { "id": "dom_1", "status": "pending" }, "dns": [] } },
|
|
70
|
+
"result": { "domain": { "id": "dom_1", "status": "pending" }, "dns": [] }
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"name": "get_domain",
|
|
74
|
+
"method": "getDomain",
|
|
75
|
+
"args": { "id": "dom_1" },
|
|
76
|
+
"request": { "method": "GET", "path": "/api/domains/dom_1" },
|
|
77
|
+
"response": { "status": 200, "body": { "id": "dom_1", "domain": "app.mailkite.dev" } },
|
|
78
|
+
"result": { "id": "dom_1", "domain": "app.mailkite.dev" }
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"name": "delete_domain",
|
|
82
|
+
"method": "deleteDomain",
|
|
83
|
+
"args": { "id": "dom_1" },
|
|
84
|
+
"request": { "method": "DELETE", "path": "/api/domains/dom_1" },
|
|
85
|
+
"response": { "status": 200, "body": { "ok": true } },
|
|
86
|
+
"result": { "ok": true }
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"name": "verify_domain",
|
|
90
|
+
"method": "verifyDomain",
|
|
91
|
+
"args": { "id": "dom_1" },
|
|
92
|
+
"request": { "method": "POST", "path": "/api/domains/dom_1/verify" },
|
|
93
|
+
"response": { "status": 200, "body": { "id": "dom_1", "status": "verified" } },
|
|
94
|
+
"result": { "id": "dom_1", "status": "verified" }
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"name": "set_webhook",
|
|
98
|
+
"method": "setWebhook",
|
|
99
|
+
"args": { "id": "dom_1", "url": "https://app.com/hooks/mailkite" },
|
|
100
|
+
"request": {
|
|
101
|
+
"method": "PUT",
|
|
102
|
+
"path": "/api/domains/dom_1/webhook",
|
|
103
|
+
"bodySchema": "set-webhook-request",
|
|
104
|
+
"body": { "url": "https://app.com/hooks/mailkite" }
|
|
105
|
+
},
|
|
106
|
+
"response": { "status": 200, "body": { "ok": true, "url": "https://app.com/hooks/mailkite" } },
|
|
107
|
+
"result": { "ok": true, "url": "https://app.com/hooks/mailkite" }
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"name": "delete_webhook",
|
|
111
|
+
"method": "deleteWebhook",
|
|
112
|
+
"args": { "id": "dom_1" },
|
|
113
|
+
"request": { "method": "DELETE", "path": "/api/domains/dom_1/webhook" },
|
|
114
|
+
"response": { "status": 200, "body": { "ok": true } },
|
|
115
|
+
"result": { "ok": true }
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"name": "test_webhook",
|
|
119
|
+
"method": "testWebhook",
|
|
120
|
+
"args": { "id": "dom_1" },
|
|
121
|
+
"request": { "method": "POST", "path": "/api/domains/dom_1/webhook/test" },
|
|
122
|
+
"response": { "status": 200, "body": { "ok": true } },
|
|
123
|
+
"result": { "ok": true }
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
"name": "list_routes",
|
|
127
|
+
"method": "listRoutes",
|
|
128
|
+
"args": {},
|
|
129
|
+
"request": { "method": "GET", "path": "/api/routes" },
|
|
130
|
+
"response": { "status": 200, "body": [] },
|
|
131
|
+
"result": []
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
"name": "create_route",
|
|
135
|
+
"method": "createRoute",
|
|
136
|
+
"args": { "match": "*@app.mailkite.dev", "action": "webhook", "destination": "https://app.com/hooks" },
|
|
137
|
+
"request": {
|
|
138
|
+
"method": "POST",
|
|
139
|
+
"path": "/api/routes",
|
|
140
|
+
"bodySchema": "create-route-request",
|
|
141
|
+
"body": { "match": "*@app.mailkite.dev", "action": "webhook", "destination": "https://app.com/hooks" }
|
|
142
|
+
},
|
|
143
|
+
"response": { "status": 200, "body": { "id": "rte_1" } },
|
|
144
|
+
"result": { "id": "rte_1" }
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
"name": "list_messages",
|
|
148
|
+
"method": "listMessages",
|
|
149
|
+
"args": {},
|
|
150
|
+
"request": { "method": "GET", "path": "/api/messages" },
|
|
151
|
+
"response": { "status": 200, "body": [{ "id": "msg_1" }] },
|
|
152
|
+
"result": [{ "id": "msg_1" }]
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
"name": "get_message",
|
|
156
|
+
"method": "getMessage",
|
|
157
|
+
"args": { "id": "msg_1" },
|
|
158
|
+
"request": { "method": "GET", "path": "/api/messages/msg_1" },
|
|
159
|
+
"response": { "status": 200, "body": { "id": "msg_1", "deliveries": [], "attachments": [] } },
|
|
160
|
+
"result": { "id": "msg_1", "deliveries": [], "attachments": [] }
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
"name": "retry_delivery",
|
|
164
|
+
"method": "retryDelivery",
|
|
165
|
+
"args": { "id": "dlv_1" },
|
|
166
|
+
"request": { "method": "POST", "path": "/api/deliveries/dlv_1/retry" },
|
|
167
|
+
"response": { "status": 200, "body": { "ok": true } },
|
|
168
|
+
"result": { "ok": true }
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
"name": "get_message_not_found",
|
|
172
|
+
"method": "getMessage",
|
|
173
|
+
"args": { "id": "msg_missing" },
|
|
174
|
+
"request": { "method": "GET", "path": "/api/messages/msg_missing" },
|
|
175
|
+
"response": { "status": 404, "body": { "error": "not found" } },
|
|
176
|
+
"error": { "status": 404, "message": "not found" }
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
"name": "send_upstream_error",
|
|
180
|
+
"method": "send",
|
|
181
|
+
"args": { "from": "hello@app.mailkite.dev", "to": "ada@example.com", "subject": "Hi", "text": "x" },
|
|
182
|
+
"request": {
|
|
183
|
+
"method": "POST",
|
|
184
|
+
"path": "/v1/send",
|
|
185
|
+
"bodySchema": "send-request",
|
|
186
|
+
"body": { "from": "hello@app.mailkite.dev", "to": "ada@example.com", "subject": "Hi", "text": "x" }
|
|
187
|
+
},
|
|
188
|
+
"response": { "status": 502, "body": { "error": "an upstream send failed" } },
|
|
189
|
+
"error": { "status": 502, "message": "an upstream send failed" }
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
"name": "verify_webhook_valid",
|
|
193
|
+
"method": "verifyWebhook",
|
|
194
|
+
"local": true,
|
|
195
|
+
"argsSchema": "verify-webhook-request",
|
|
196
|
+
"args": {
|
|
197
|
+
"signature": "t=1750000000000,v1=3d790f831e170ddba4d001f27532bf2c1fc68ebed52eef72fe453dfa1196b03c",
|
|
198
|
+
"payload": "{\"type\":\"email.received\",\"id\":\"evt_123\",\"message\":\"It works.\"}",
|
|
199
|
+
"secret": "whsec_mailkite_test",
|
|
200
|
+
"toleranceMs": 0
|
|
201
|
+
},
|
|
202
|
+
"result": true
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
"name": "verify_webhook_tampered_body",
|
|
206
|
+
"method": "verifyWebhook",
|
|
207
|
+
"local": true,
|
|
208
|
+
"argsSchema": "verify-webhook-request",
|
|
209
|
+
"args": {
|
|
210
|
+
"signature": "t=1750000000000,v1=3d790f831e170ddba4d001f27532bf2c1fc68ebed52eef72fe453dfa1196b03c",
|
|
211
|
+
"payload": "{\"type\":\"email.received\",\"id\":\"evt_123\",\"message\":\"It WORKS.\"}",
|
|
212
|
+
"secret": "whsec_mailkite_test",
|
|
213
|
+
"toleranceMs": 0
|
|
214
|
+
},
|
|
215
|
+
"result": false
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
"name": "verify_webhook_wrong_secret",
|
|
219
|
+
"method": "verifyWebhook",
|
|
220
|
+
"local": true,
|
|
221
|
+
"argsSchema": "verify-webhook-request",
|
|
222
|
+
"args": {
|
|
223
|
+
"signature": "t=1750000000000,v1=3d790f831e170ddba4d001f27532bf2c1fc68ebed52eef72fe453dfa1196b03c",
|
|
224
|
+
"payload": "{\"type\":\"email.received\",\"id\":\"evt_123\",\"message\":\"It works.\"}",
|
|
225
|
+
"secret": "whsec_wrong",
|
|
226
|
+
"toleranceMs": 0
|
|
227
|
+
},
|
|
228
|
+
"result": false
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
"name": "verify_webhook_malformed_header",
|
|
232
|
+
"method": "verifyWebhook",
|
|
233
|
+
"local": true,
|
|
234
|
+
"argsSchema": "verify-webhook-request",
|
|
235
|
+
"args": {
|
|
236
|
+
"signature": "not-a-valid-signature",
|
|
237
|
+
"payload": "{\"type\":\"email.received\",\"id\":\"evt_123\",\"message\":\"It works.\"}",
|
|
238
|
+
"secret": "whsec_mailkite_test",
|
|
239
|
+
"toleranceMs": 0
|
|
240
|
+
},
|
|
241
|
+
"result": false
|
|
242
|
+
}
|
|
243
|
+
]
|
|
244
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "create-domain-request",
|
|
4
|
+
"title": "Create domain request body",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["domain"],
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {
|
|
9
|
+
"domain": { "type": "string" }
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "create-route-request",
|
|
4
|
+
"title": "Create route request body",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["match", "action", "destination"],
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {
|
|
9
|
+
"match": { "type": "string" },
|
|
10
|
+
"action": { "type": "string" },
|
|
11
|
+
"destination": { "type": "string" }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "send-request",
|
|
4
|
+
"title": "Send request body",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["from", "to", "subject"],
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {
|
|
9
|
+
"from": { "type": "string", "description": "An address on a verified domain." },
|
|
10
|
+
"to": {
|
|
11
|
+
"description": "One recipient or a list.",
|
|
12
|
+
"oneOf": [
|
|
13
|
+
{ "type": "string" },
|
|
14
|
+
{ "type": "array", "items": { "type": "string" }, "minItems": 1 }
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
"subject": { "type": "string" },
|
|
18
|
+
"html": { "type": "string" },
|
|
19
|
+
"text": { "type": "string" },
|
|
20
|
+
"cc": { "oneOf": [{ "type": "string" }, { "type": "array", "items": { "type": "string" } }] },
|
|
21
|
+
"bcc": { "oneOf": [{ "type": "string" }, { "type": "array", "items": { "type": "string" } }] },
|
|
22
|
+
"replyTo": { "type": "string" },
|
|
23
|
+
"inReplyTo": { "type": "string" },
|
|
24
|
+
"attachments": {
|
|
25
|
+
"type": "array",
|
|
26
|
+
"items": {
|
|
27
|
+
"type": "object",
|
|
28
|
+
"required": ["filename"],
|
|
29
|
+
"properties": {
|
|
30
|
+
"filename": { "type": "string" },
|
|
31
|
+
"url": { "type": "string" },
|
|
32
|
+
"content": { "type": "string" },
|
|
33
|
+
"contentType": { "type": "string" }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "send-response",
|
|
4
|
+
"title": "Send response body",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["id", "status"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"id": { "type": "string" },
|
|
9
|
+
"status": { "type": "string" }
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "set-webhook-request",
|
|
4
|
+
"title": "Set webhook request body",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["url"],
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {
|
|
9
|
+
"url": { "type": "string" }
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "verify-webhook-request",
|
|
4
|
+
"title": "Verify webhook request",
|
|
5
|
+
"description": "Inputs to verifyWebhook — a local HMAC check, no API call.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["payload", "signature", "secret"],
|
|
8
|
+
"additionalProperties": false,
|
|
9
|
+
"properties": {
|
|
10
|
+
"payload": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"description": "The raw, unparsed webhook request body — the exact bytes you received."
|
|
13
|
+
},
|
|
14
|
+
"signature": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"description": "The `x-mailkite-signature` header value, e.g. `t=1750000000000,v1=4f1a9c…`."
|
|
17
|
+
},
|
|
18
|
+
"secret": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"description": "Your webhook signing secret (from the dashboard)."
|
|
21
|
+
},
|
|
22
|
+
"toleranceMs": {
|
|
23
|
+
"type": "integer",
|
|
24
|
+
"minimum": 0,
|
|
25
|
+
"description": "Reject events whose timestamp is more than this many milliseconds old, to block replays. Defaults to 300000 (5 minutes). Pass 0 to disable the freshness check."
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|