@mailkite/mcp 0.1.0 → 0.3.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 CHANGED
@@ -10,6 +10,19 @@ from the shared SDK contract in [`../spec`](../spec); transport, auth, and error
10
10
  handling come from the [MailKite Node SDK](../node). Nothing about the API is
11
11
  duplicated here — update the spec and the MCP follows.
12
12
 
13
+ > ### Hosted vs local — which should I use?
14
+ >
15
+ > Most users should connect to the **hosted remote MCP** instead of running this
16
+ > package: `https://mcp.mailkite.dev/mcp` (Streamable HTTP, one-click **OAuth** — no
17
+ > key to copy, no local process). In Claude Code that's the **plugin**
18
+ > (`/plugin marketplace add mailkite/claude-code` → `/plugin install mailkite@mailkite`)
19
+ > or `claude mcp add --transport http mailkite https://mcp.mailkite.dev/mcp`. See
20
+ > <https://mailkite.dev/docs/ai-agents>.
21
+ >
22
+ > Run **this local server** when you want a **static key** (no browser OAuth), **offline /
23
+ > CI** use, a stdio-only client, a custom `MAILKITE_BASE_URL`, or `verifyWebhook` to run
24
+ > fully locally. Both expose the exact same tools (same `../spec`).
25
+
13
26
  ## Install / configure
14
27
 
15
28
  Point your MCP client at the server and give it your MailKite credential. The
@@ -40,6 +53,8 @@ One tool per MailKite API operation (generated from [`../spec/api.json`](../spec
40
53
  | Tool | Operation |
41
54
  | --- | --- |
42
55
  | `mailkite_send` | Send a message over a verified domain |
56
+ | `mailkite_agent` | Send a message to an inbox agent and get its reply |
57
+ | `mailkite_route` | Route a message to a registered route and run its action |
43
58
  | `mailkite_list_domains` | List your domains |
44
59
  | `mailkite_create_domain` | Add a domain (returns DNS records) |
45
60
  | `mailkite_get_domain` | Get one domain with DNS + webhook |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mailkite/mcp",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
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
5
  "type": "module",
6
6
  "bin": {
@@ -34,13 +34,13 @@
34
34
  "homepage": "https://mailkite.dev/docs/libraries",
35
35
  "repository": {
36
36
  "type": "git",
37
- "url": "https://github.com/fijiwebdesign/mailkite.git",
37
+ "url": "git+https://github.com/mailkite/mailkite.git",
38
38
  "directory": "sdks/mcp"
39
39
  },
40
40
  "license": "MIT",
41
41
  "dependencies": {
42
42
  "@modelcontextprotocol/sdk": "^1.0.0",
43
43
  "ajv": "^8.17.1",
44
- "mailkite": "^0.1.0"
44
+ "mailkite": "^0.3.0"
45
45
  }
46
46
  }
package/server.mjs CHANGED
@@ -58,6 +58,9 @@ function buildInputSchema(method) {
58
58
  if (arg.in === "path") {
59
59
  properties[arg.name] = { type: "string", description: `Path parameter \`${arg.name}\`.` };
60
60
  required.push(arg.name);
61
+ } else if (arg.in === "query") {
62
+ properties[arg.name] = { type: "string", description: `Query parameter \`${arg.name}\`.` };
63
+ required.push(arg.name);
61
64
  } else if (arg.in === "body" && arg.schema && schemas[arg.schema]) {
62
65
  const body = schemas[arg.schema];
63
66
  Object.assign(properties, body.properties || {});
@@ -77,7 +80,11 @@ const credentialNote = (method) => {
77
80
 
78
81
  const tools = api.methods.map((method) => ({
79
82
  name: toolName(method),
80
- description: `${method.summary} ${credentialNote(method)}`,
83
+ description: `${method.summary} ${credentialNote(method)}${
84
+ method.agentConfirm
85
+ ? " Does NOT register automatically — returns a dashboard link for the user to review the price and confirm. An assistant can never purchase a domain on its own."
86
+ : ""
87
+ }`,
81
88
  inputSchema: buildInputSchema(method),
82
89
  _method: method, // kept server-side for dispatch; not sent to the client
83
90
  }));
@@ -94,16 +101,22 @@ function resolveCall(method, input) {
94
101
  input = input || {};
95
102
  let urlPath = method.http.path;
96
103
  let bodySchemaId = null;
104
+ const query = [];
97
105
 
98
106
  for (const arg of method.args || []) {
99
107
  if (arg.in === "path") {
100
108
  const value = input[arg.name];
101
109
  if (value == null || value === "") throw new Error(`Missing required path parameter: ${arg.name}`);
102
110
  urlPath = urlPath.replace(`{${arg.name}}`, encodeURIComponent(String(value)));
111
+ } else if (arg.in === "query") {
112
+ const value = input[arg.name];
113
+ if (value == null || value === "") throw new Error(`Missing required query parameter: ${arg.name}`);
114
+ query.push(`${encodeURIComponent(arg.name)}=${encodeURIComponent(String(value))}`);
103
115
  } else if (arg.in === "body" && arg.schema) {
104
116
  bodySchemaId = arg.schema;
105
117
  }
106
118
  }
119
+ if (query.length) urlPath += (urlPath.includes("?") ? "&" : "?") + query.join("&");
107
120
 
108
121
  let body;
109
122
  if (bodySchemaId) {
@@ -163,6 +176,25 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
163
176
  }
164
177
  }
165
178
 
179
+ // Gated methods (e.g. domain registration) must be approved by a human and are never run
180
+ // automatically by an AI agent. Return the dashboard URL to confirm instead of calling the API.
181
+ if (tool._method.agentConfirm) {
182
+ const dash = (process.env.MAILKITE_DASHBOARD_URL || "https://app.mailkite.dev").replace(/\/+$/, "");
183
+ const domain = (req.params.arguments && req.params.arguments.domain) || "the domain";
184
+ return {
185
+ content: [
186
+ {
187
+ type: "text",
188
+ text:
189
+ `Domain registration can't be completed automatically — it has to be approved by you.\n\n` +
190
+ `Open the dashboard to review the price and confirm the purchase of ${domain}:\n` +
191
+ `${dash}/domains\n\n` +
192
+ `Once it's registered there, tell me to continue.`,
193
+ },
194
+ ],
195
+ };
196
+ }
197
+
166
198
  if (!apiKey) {
167
199
  return {
168
200
  isError: true,
package/spec/api.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mailkite",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
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
5
  "baseUrl": "https://api.mailkite.dev",
6
6
  "auth": {
@@ -11,11 +11,39 @@
11
11
  "methods": [
12
12
  {
13
13
  "name": "send",
14
- "summary": "Send a message over a verified domain.",
14
+ "summary": "Send a message over a verified domain. Pass `templateId` (+ optional `templateData`) to send from a saved or base template.",
15
15
  "http": { "method": "POST", "path": "/v1/send" },
16
16
  "args": [{ "name": "message", "in": "body", "schema": "send-request" }],
17
17
  "returns": "send-response"
18
18
  },
19
+ {
20
+ "name": "listTemplates",
21
+ "summary": "List your saved email templates (light metadata only — no body). Use getTemplate for the full template.",
22
+ "http": { "method": "GET", "path": "/api/templates" },
23
+ "args": [],
24
+ "returns": "any"
25
+ },
26
+ {
27
+ "name": "listBaseTemplates",
28
+ "summary": "List the premade base templates (light metadata). Clone one with createTemplate({ baseId }) or send from it directly via send({ templateId }).",
29
+ "http": { "method": "GET", "path": "/api/templates/base" },
30
+ "args": [],
31
+ "returns": "any"
32
+ },
33
+ {
34
+ "name": "getTemplate",
35
+ "summary": "Get one template (full: subject, html, text, theme). Works for your templates (tpl_…) and base templates (base_…).",
36
+ "http": { "method": "GET", "path": "/api/templates/{id}" },
37
+ "args": [{ "name": "id", "in": "path" }],
38
+ "returns": "any"
39
+ },
40
+ {
41
+ "name": "createTemplate",
42
+ "summary": "Create a template. Pass `baseId` to clone a base template into your own, or provide name/subject/html/text/theme directly.",
43
+ "http": { "method": "POST", "path": "/api/templates" },
44
+ "args": [{ "name": "body", "in": "body", "schema": "create-template-request" }],
45
+ "returns": "any"
46
+ },
19
47
  {
20
48
  "name": "listDomains",
21
49
  "summary": "List your domains, each with its webhook URL.",
@@ -75,6 +103,21 @@
75
103
  "args": [{ "name": "id", "in": "path" }],
76
104
  "returns": "any"
77
105
  },
106
+ {
107
+ "name": "checkDomainAvailability",
108
+ "summary": "Check whether a domain is available to register, and at what price. Read-only — no charge.",
109
+ "http": { "method": "GET", "path": "/api/domains/register/check" },
110
+ "args": [{ "name": "domain", "in": "query" }],
111
+ "returns": "any"
112
+ },
113
+ {
114
+ "name": "registerDomain",
115
+ "summary": "Register (buy) a domain on the customer's behalf; provisions mail DNS and adds it to the account in one call. Charges the registrar.",
116
+ "http": { "method": "POST", "path": "/api/domains/register" },
117
+ "args": [{ "name": "body", "in": "body", "schema": "register-domain-request" }],
118
+ "returns": "any",
119
+ "agentConfirm": true
120
+ },
78
121
  {
79
122
  "name": "listRoutes",
80
123
  "summary": "List inbound routing rules.",
@@ -89,6 +132,20 @@
89
132
  "args": [{ "name": "body", "in": "body", "schema": "create-route-request" }],
90
133
  "returns": "any"
91
134
  },
135
+ {
136
+ "name": "agent",
137
+ "summary": "Send a message to one of your inbox agents and get its reply. Defaults to the account's default agent; pass `routeId` or `address` to target a specific agent, or `model` to override the model. This is separate from inbound routing — it does not match or override routes.",
138
+ "http": { "method": "POST", "path": "/v1/agent" },
139
+ "args": [{ "name": "message", "in": "body", "schema": "agent-request" }],
140
+ "returns": "agent-response"
141
+ },
142
+ {
143
+ "name": "route",
144
+ "summary": "Route a message to one of your registered routes (by `routeId` or `address`), running that route's action — agent, webhook, or forward. The route must already exist on your account; arbitrary destinations are not allowed.",
145
+ "http": { "method": "POST", "path": "/v1/route" },
146
+ "args": [{ "name": "message", "in": "body", "schema": "route-message-request" }],
147
+ "returns": "route-response"
148
+ },
92
149
  {
93
150
  "name": "listMessages",
94
151
  "summary": "List stored messages.",
package/spec/cases.json CHANGED
@@ -122,6 +122,81 @@
122
122
  "response": { "status": 200, "body": { "ok": true } },
123
123
  "result": { "ok": true }
124
124
  },
125
+ {
126
+ "name": "check_domain_availability",
127
+ "method": "checkDomainAvailability",
128
+ "args": { "domain": "acme.com" },
129
+ "request": { "method": "GET", "path": "/api/domains/register/check" },
130
+ "response": {
131
+ "status": 200,
132
+ "body": {
133
+ "configured": true,
134
+ "domain": "acme.com",
135
+ "available": true,
136
+ "premium": false,
137
+ "price": { "amount": 12.99, "currency": "USD", "period": 1, "periodUnit": "y" }
138
+ }
139
+ },
140
+ "result": {
141
+ "configured": true,
142
+ "domain": "acme.com",
143
+ "available": true,
144
+ "premium": false,
145
+ "price": { "amount": 12.99, "currency": "USD", "period": 1, "periodUnit": "y" }
146
+ }
147
+ },
148
+ {
149
+ "name": "register_domain",
150
+ "method": "registerDomain",
151
+ "args": {
152
+ "domain": "acme.com",
153
+ "contact": {
154
+ "firstName": "Jane",
155
+ "lastName": "Doe",
156
+ "email": "jane@example.com",
157
+ "phone": "+1.4155551234",
158
+ "address": "123 Main St",
159
+ "city": "SF",
160
+ "zip": "94016",
161
+ "country": "US"
162
+ },
163
+ "years": 1
164
+ },
165
+ "request": {
166
+ "method": "POST",
167
+ "path": "/api/domains/register",
168
+ "bodySchema": "register-domain-request",
169
+ "body": {
170
+ "domain": "acme.com",
171
+ "contact": {
172
+ "firstName": "Jane",
173
+ "lastName": "Doe",
174
+ "email": "jane@example.com",
175
+ "phone": "+1.4155551234",
176
+ "address": "123 Main St",
177
+ "city": "SF",
178
+ "zip": "94016",
179
+ "country": "US"
180
+ },
181
+ "years": 1
182
+ }
183
+ },
184
+ "response": {
185
+ "status": 201,
186
+ "body": {
187
+ "domain": { "id": "dom_1", "domain": "acme.com", "status": "verified" },
188
+ "dns": [],
189
+ "registration": { "status": "registered", "reference": "ref_1" },
190
+ "dnsProvisioned": true
191
+ }
192
+ },
193
+ "result": {
194
+ "domain": { "id": "dom_1", "domain": "acme.com", "status": "verified" },
195
+ "dns": [],
196
+ "registration": { "status": "registered", "reference": "ref_1" },
197
+ "dnsProvisioned": true
198
+ }
199
+ },
125
200
  {
126
201
  "name": "list_routes",
127
202
  "method": "listRoutes",
@@ -143,6 +218,58 @@
143
218
  "response": { "status": 200, "body": { "id": "rte_1" } },
144
219
  "result": { "id": "rte_1" }
145
220
  },
221
+ {
222
+ "name": "agent_default",
223
+ "method": "agent",
224
+ "args": { "text": "What's my current balance?" },
225
+ "request": {
226
+ "method": "POST",
227
+ "path": "/v1/agent",
228
+ "bodySchema": "agent-request",
229
+ "body": { "text": "What's my current balance?" }
230
+ },
231
+ "response": { "status": 200, "body": { "ok": true, "text": "Your balance is $0.00.", "messageId": "msg_agent" } },
232
+ "result": { "ok": true, "text": "Your balance is $0.00.", "messageId": "msg_agent" }
233
+ },
234
+ {
235
+ "name": "agent_by_route",
236
+ "method": "agent",
237
+ "args": { "text": "ping", "routeId": "rte_1", "model": "claude-sonnet-4-6" },
238
+ "request": {
239
+ "method": "POST",
240
+ "path": "/v1/agent",
241
+ "bodySchema": "agent-request",
242
+ "body": { "text": "ping", "routeId": "rte_1", "model": "claude-sonnet-4-6" }
243
+ },
244
+ "response": { "status": 200, "body": { "ok": true, "text": "pong", "messageId": "msg_agent2" } },
245
+ "result": { "ok": true, "text": "pong", "messageId": "msg_agent2" }
246
+ },
247
+ {
248
+ "name": "route_by_id",
249
+ "method": "route",
250
+ "args": { "routeId": "rte_1", "from": "ops@example.com", "subject": "Process this", "text": "Please handle." },
251
+ "request": {
252
+ "method": "POST",
253
+ "path": "/v1/route",
254
+ "bodySchema": "route-message-request",
255
+ "body": { "routeId": "rte_1", "from": "ops@example.com", "subject": "Process this", "text": "Please handle." }
256
+ },
257
+ "response": { "status": 202, "body": { "id": "msg_r1", "routed": true, "action": "webhook" } },
258
+ "result": { "id": "msg_r1", "routed": true, "action": "webhook" }
259
+ },
260
+ {
261
+ "name": "route_by_address",
262
+ "method": "route",
263
+ "args": { "address": "support@app.mailkite.dev", "from": "ops@example.com", "text": "hey there" },
264
+ "request": {
265
+ "method": "POST",
266
+ "path": "/v1/route",
267
+ "bodySchema": "route-message-request",
268
+ "body": { "address": "support@app.mailkite.dev", "from": "ops@example.com", "text": "hey there" }
269
+ },
270
+ "response": { "status": 202, "body": { "id": "msg_r2", "routed": true, "action": "agent" } },
271
+ "result": { "id": "msg_r2", "routed": true, "action": "agent" }
272
+ },
146
273
  {
147
274
  "name": "list_messages",
148
275
  "method": "listMessages",
@@ -167,6 +294,56 @@
167
294
  "response": { "status": 200, "body": { "ok": true } },
168
295
  "result": { "ok": true }
169
296
  },
297
+ {
298
+ "name": "send_with_template",
299
+ "method": "send",
300
+ "args": { "from": "hello@app.mailkite.dev", "to": "ada@example.com", "templateId": "base_welcome-dark", "templateData": { "name": "Ada" } },
301
+ "request": {
302
+ "method": "POST",
303
+ "path": "/v1/send",
304
+ "bodySchema": "send-request",
305
+ "body": { "from": "hello@app.mailkite.dev", "to": "ada@example.com", "templateId": "base_welcome-dark", "templateData": { "name": "Ada" } }
306
+ },
307
+ "response": { "status": 202, "body": { "id": "msg_tpl", "status": "queued" } },
308
+ "result": { "id": "msg_tpl", "status": "queued" }
309
+ },
310
+ {
311
+ "name": "list_templates",
312
+ "method": "listTemplates",
313
+ "args": {},
314
+ "request": { "method": "GET", "path": "/api/templates" },
315
+ "response": { "status": 200, "body": [{ "id": "tpl_1", "name": "Welcome", "category": "", "subject": "Hi", "is_base": 0 }] },
316
+ "result": [{ "id": "tpl_1", "name": "Welcome", "category": "", "subject": "Hi", "is_base": 0 }]
317
+ },
318
+ {
319
+ "name": "list_base_templates",
320
+ "method": "listBaseTemplates",
321
+ "args": {},
322
+ "request": { "method": "GET", "path": "/api/templates/base" },
323
+ "response": { "status": 200, "body": [{ "id": "base_welcome-dark", "name": "Welcome — Dark", "category": "Welcome", "subject": "Welcome to MailKite", "is_base": 1 }] },
324
+ "result": [{ "id": "base_welcome-dark", "name": "Welcome — Dark", "category": "Welcome", "subject": "Welcome to MailKite", "is_base": 1 }]
325
+ },
326
+ {
327
+ "name": "get_template",
328
+ "method": "getTemplate",
329
+ "args": { "id": "tpl_1" },
330
+ "request": { "method": "GET", "path": "/api/templates/tpl_1" },
331
+ "response": { "status": 200, "body": { "id": "tpl_1", "name": "Welcome", "html": "<p>hi</p>" } },
332
+ "result": { "id": "tpl_1", "name": "Welcome", "html": "<p>hi</p>" }
333
+ },
334
+ {
335
+ "name": "create_template",
336
+ "method": "createTemplate",
337
+ "args": { "baseId": "base_welcome-dark", "name": "My Welcome" },
338
+ "request": {
339
+ "method": "POST",
340
+ "path": "/api/templates",
341
+ "bodySchema": "create-template-request",
342
+ "body": { "baseId": "base_welcome-dark", "name": "My Welcome" }
343
+ },
344
+ "response": { "status": 201, "body": { "id": "tpl_2", "name": "My Welcome" } },
345
+ "result": { "id": "tpl_2", "name": "My Welcome" }
346
+ },
170
347
  {
171
348
  "name": "get_message_not_found",
172
349
  "method": "getMessage",
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env node
2
+ // ---------------------------------------------------------------------------
3
+ // check-docs — validate that the website docs match our request SCHEMAS.
4
+ //
5
+ // "Our schema" = sdks/spec/schemas/*.json (the JSON Schemas every SDK + the MCP
6
+ // server validate against). The website docs (website/src/pages/docs/*.astro)
7
+ // are hand-written, so their field tables and request examples can drift from
8
+ // the schema. This script is the agent-runnable guard: run it, read the report.
9
+ //
10
+ // node sdks/spec/check-docs.mjs # human/agent: prints ✓/✗, exits 1 on drift
11
+ // import { runChecks } from './check-docs.mjs' # CI: see api/test/docs-schema.test.ts
12
+ //
13
+ // It checks, per schema-backed operation:
14
+ // 1. the doc "Request fields" table lists exactly the schema's properties,
15
+ // with the same required set;
16
+ // 2. every JSON request example in the docs uses only schema fields and
17
+ // includes the required ones;
18
+ // 3. the SDK code samples include the required fields and use no wrong alias.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ import { readFileSync, readdirSync } from 'node:fs';
22
+ import { resolve, dirname } from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+
25
+ const SPEC_DIR = dirname(fileURLToPath(import.meta.url)); // sdks/spec
26
+ const ROOT = resolve(SPEC_DIR, '../..'); // repo root
27
+ const read = (p) => readFileSync(p, 'utf8');
28
+ const readJson = (p) => JSON.parse(read(p));
29
+
30
+ const DOCS_DIR = resolve(ROOT, 'website/src/pages/docs');
31
+ const docText = Object.fromEntries(
32
+ readdirSync(DOCS_DIR)
33
+ .filter((f) => f.endsWith('.astro'))
34
+ .map((f) => [f, read(resolve(DOCS_DIR, f))]),
35
+ );
36
+ const samplesTs = read(resolve(ROOT, 'website/src/lib/samples.ts'));
37
+
38
+ const schema = (name) => readJson(resolve(SPEC_DIR, 'schemas', `${name}.json`));
39
+ const eqSet = (a, b) => a.size === b.size && [...a].every((x) => b.has(x));
40
+
41
+ // ---- helpers --------------------------------------------------------------
42
+
43
+ // All quoted keys (`"key":`) inside a JSON-ish block — includes nested keys,
44
+ // which is what we want (attachment sub-fields are valid send fields).
45
+ const jsonKeys = (block) => new Set([...block.matchAll(/"([a-zA-Z]+)"\s*:/g)].map((m) => m[1]));
46
+
47
+ // Every backtick template literal in a file (docs put JSON examples in these).
48
+ const templateLiterals = (src) => [...src.matchAll(/`([^`]*)`/g)].map((m) => m[1]);
49
+
50
+ // ---- the checks -----------------------------------------------------------
51
+
52
+ // Each entry ties a doc-visible operation to its schema + how to spot its
53
+ // request examples among the docs' JSON blocks.
54
+ const OPS = [
55
+ {
56
+ label: 'send',
57
+ schema: 'send-request',
58
+ // The "Request fields" table lives in sending.astro under this heading.
59
+ fieldTable: { file: 'sending.astro', heading: 'Request fields' },
60
+ // A send body has from+subject. Exclude inbound webhook payloads (which also
61
+ // carry from/subject but add type/auth/threadId) and responses (status).
62
+ isRequestExample: (k) =>
63
+ k.has('from') &&
64
+ k.has('subject') &&
65
+ !k.has('status') &&
66
+ !k.has('type') &&
67
+ !k.has('auth') &&
68
+ !k.has('threadId'),
69
+ nestedSchemaKeys: ['filename', 'url', 'content', 'contentType'], // attachments[].*
70
+ sample: { source: samplesTs, slice: ['sendSamples', 'initSamples'] },
71
+ wrongAliases: ['reply_to', 'in_reply_to', 'html_body', 'text_body', 'from_addr', 'to_addr'],
72
+ },
73
+ {
74
+ label: 'createRoute',
75
+ schema: 'create-route-request',
76
+ isRequestExample: (k) => k.has('match') && k.has('action'),
77
+ },
78
+ {
79
+ label: 'createDomain',
80
+ schema: 'create-domain-request',
81
+ isRequestExample: (k) => k.has('domain') && k.size <= 2,
82
+ },
83
+ ];
84
+
85
+ function runChecks() {
86
+ const results = [];
87
+ const ok = (name) => results.push({ name, ok: true });
88
+ const fail = (name, detail) => results.push({ name, ok: false, detail });
89
+
90
+ for (const op of OPS) {
91
+ const s = schema(op.schema);
92
+ const props = new Set(Object.keys(s.properties));
93
+ const required = new Set(s.required ?? []);
94
+ const allowed = new Set([...props, ...(op.nestedSchemaKeys ?? [])]);
95
+
96
+ // ── Check 1: the "Request fields" table mirrors the schema exactly ──────
97
+ if (op.fieldTable) {
98
+ const src = docText[op.fieldTable.file] ?? '';
99
+ const table = src.match(
100
+ new RegExp(`<h2>${op.fieldTable.heading}</h2>([\\s\\S]*?)</table>`),
101
+ );
102
+ if (!table) {
103
+ fail(`${op.label}: fields table present`, `no "${op.fieldTable.heading}" table in ${op.fieldTable.file}`);
104
+ } else {
105
+ const docFields = new Set();
106
+ const docRequired = new Set();
107
+ for (const row of table[1].matchAll(/<tr>([\s\S]*?)<\/tr>/g)) {
108
+ const firstCell = row[1].match(/<td>([\s\S]*?)<\/td>/);
109
+ if (!firstCell) continue; // header row (uses <th>)
110
+ const names = [...firstCell[1].matchAll(/<code>([a-zA-Z]+)<\/code>/g)].map((m) => m[1]);
111
+ const isRequired = /Required\./.test(row[1]);
112
+ for (const n of names) {
113
+ docFields.add(n);
114
+ if (isRequired) docRequired.add(n);
115
+ }
116
+ }
117
+ if (eqSet(docFields, props)) ok(`${op.label}: fields table = schema properties`);
118
+ else
119
+ fail(
120
+ `${op.label}: fields table = schema properties`,
121
+ `table=${[...docFields].sort()} schema=${[...props].sort()}`,
122
+ );
123
+ if (eqSet(docRequired, required)) ok(`${op.label}: fields table required = schema required`);
124
+ else
125
+ fail(
126
+ `${op.label}: fields table required = schema required`,
127
+ `table=${[...docRequired].sort()} schema=${[...required].sort()}`,
128
+ );
129
+ }
130
+ }
131
+
132
+ // ── Check 2: every JSON request example uses only schema fields ─────────
133
+ let examplesSeen = 0;
134
+ for (const [file, src] of Object.entries(docText)) {
135
+ for (const block of templateLiterals(src)) {
136
+ const keys = jsonKeys(block);
137
+ if (keys.size === 0 || !op.isRequestExample(keys)) continue;
138
+ examplesSeen++;
139
+ const unknown = [...keys].filter((k) => !allowed.has(k));
140
+ if (unknown.length)
141
+ fail(`${op.label}: JSON example fields ⊆ schema (${file})`, `unknown: ${unknown}`);
142
+ const missing = [...required].filter((r) => !keys.has(r));
143
+ if (missing.length)
144
+ fail(`${op.label}: JSON example has required fields (${file})`, `missing: ${missing}`);
145
+ }
146
+ }
147
+ if (examplesSeen) ok(`${op.label}: ${examplesSeen} JSON request example(s) validated`);
148
+
149
+ // ── Check 3: SDK code samples include required fields, no wrong aliases ──
150
+ if (op.sample) {
151
+ // Pull each named sample object's body out of samples.ts.
152
+ for (const name of op.sample.slice) {
153
+ const m = op.sample.source.match(new RegExp(`export const ${name}[\\s\\S]*?\\n};`));
154
+ if (!m) continue;
155
+ const body = m[0].toLowerCase();
156
+ for (const alias of op.wrongAliases ?? []) {
157
+ if (body.includes(alias))
158
+ fail(`${op.label}: sample uses wrong alias (${name})`, `found "${alias}" — use the schema field`);
159
+ }
160
+ }
161
+ }
162
+ }
163
+ return results;
164
+ }
165
+
166
+ // ---- CLI ------------------------------------------------------------------
167
+
168
+ const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
169
+ if (isMain) {
170
+ const results = runChecks();
171
+ const failures = results.filter((r) => !r.ok);
172
+ for (const r of results) {
173
+ console.log(`${r.ok ? '✓' : '✗'} ${r.name}${r.detail ? `\n ${r.detail}` : ''}`);
174
+ }
175
+ console.log(`\n${results.length - failures.length}/${results.length} checks passed.`);
176
+ if (failures.length) {
177
+ console.error(`\n${failures.length} doc/schema mismatch(es) — fix the .astro doc or the schema.`);
178
+ process.exit(1);
179
+ }
180
+ }
181
+
182
+ export { runChecks };
@@ -0,0 +1,17 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "agent-request",
4
+ "title": "Send-to-agent request body",
5
+ "type": "object",
6
+ "required": ["text"],
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "text": { "type": "string", "description": "The message for the agent — it reads this as the incoming email body and decides what to do." },
10
+ "subject": { "type": "string", "description": "Optional subject line the agent sees on the message." },
11
+ "from": { "type": "string", "description": "Optional sender address the agent sees as the originator. Defaults to the account's API caller address." },
12
+ "html": { "type": "string", "description": "Optional HTML body. `text` is still required — the agent reasons over the plain-text content." },
13
+ "routeId": { "type": "string", "description": "Target a specific agent by its route id (rte_…). Omit to use the account's default agent (its most recently created agent route)." },
14
+ "address": { "type": "string", "description": "Target the agent whose route matches this address. Alternative to routeId." },
15
+ "model": { "type": "string", "description": "Override the model the agent runs on for this call (e.g. claude-sonnet-4-6)." }
16
+ }
17
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "agent-response",
4
+ "title": "Send-to-agent response body",
5
+ "type": "object",
6
+ "required": ["ok"],
7
+ "properties": {
8
+ "ok": { "type": "boolean", "description": "Whether the agent ran to completion." },
9
+ "text": { "type": "string", "description": "The agent's reply — its final message after running." },
10
+ "messageId": { "type": "string", "description": "Id of the stored message the agent processed (msg_…)." },
11
+ "error": { "type": "string", "description": "Error detail when `ok` is false." }
12
+ }
13
+ }
@@ -3,11 +3,16 @@
3
3
  "$id": "create-route-request",
4
4
  "title": "Create route request body",
5
5
  "type": "object",
6
- "required": ["match", "action", "destination"],
6
+ "required": ["match"],
7
7
  "additionalProperties": false,
8
8
  "properties": {
9
- "match": { "type": "string" },
10
- "action": { "type": "string" },
11
- "destination": { "type": "string" }
9
+ "match": { "type": "string", "description": "Address pattern: exact, *@domain, addr+*@domain, or /regex/." },
10
+ "action": {
11
+ "type": "string",
12
+ "enum": ["webhook", "forward", "store", "drop", "agent"],
13
+ "description": "What to do with matching mail. Defaults to webhook."
14
+ },
15
+ "destination": { "type": "string", "description": "Required for action webhook (URL) or forward (address)." },
16
+ "agentPrompt": { "type": "string", "description": "Required for action agent — instructions for the inbox agent." }
12
17
  }
13
18
  }
@@ -0,0 +1,16 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "create-template-request",
4
+ "title": "Create template request body",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "properties": {
8
+ "baseId": { "type": "string", "description": "Clone this base template (base_…) into your own. When set, name is optional (defaults to the base's name)." },
9
+ "name": { "type": "string", "description": "Template name. Required unless baseId is given." },
10
+ "subject": { "type": "string", "description": "Default subject line for sends." },
11
+ "html": { "type": "string", "description": "Rendered, send-ready HTML." },
12
+ "text": { "type": "string", "description": "Plaintext fallback." },
13
+ "json": { "type": "string", "description": "Editor (TipTap) JSON source, for re-editing in the dashboard." },
14
+ "theme": { "type": "string", "description": "Brand tokens JSON (bg, surface, primary, text, logo, …)." }
15
+ }
16
+ }
@@ -0,0 +1,40 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "register-domain-request",
4
+ "title": "Register domain request body",
5
+ "type": "object",
6
+ "required": ["domain", "contact"],
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "domain": { "type": "string" },
10
+ "contact": {
11
+ "type": "object",
12
+ "required": [
13
+ "firstName",
14
+ "lastName",
15
+ "email",
16
+ "phone",
17
+ "address",
18
+ "city",
19
+ "zip",
20
+ "country"
21
+ ],
22
+ "additionalProperties": false,
23
+ "properties": {
24
+ "firstName": { "type": "string" },
25
+ "lastName": { "type": "string" },
26
+ "email": { "type": "string" },
27
+ "phone": { "type": "string", "description": "E.164-ish, +<cc>.<number>, e.g. +1.4155551234" },
28
+ "address": { "type": "string" },
29
+ "city": { "type": "string" },
30
+ "zip": { "type": "string" },
31
+ "country": { "type": "string", "description": "ISO 3166-1 alpha-2, e.g. US" },
32
+ "state": { "type": "string", "description": "ISO 3166-2 subdivision, e.g. US-CA" },
33
+ "organization": { "type": "string" },
34
+ "type": { "type": "string", "enum": ["individual", "company", "association", "publicbody"] }
35
+ }
36
+ },
37
+ "years": { "type": "integer", "minimum": 1 },
38
+ "dryRun": { "type": "boolean" }
39
+ }
40
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "route-message-request",
4
+ "title": "Route-message request body",
5
+ "type": "object",
6
+ "required": ["from"],
7
+ "additionalProperties": false,
8
+ "anyOf": [{ "required": ["routeId"] }, { "required": ["address"] }],
9
+ "properties": {
10
+ "routeId": { "type": "string", "description": "Target route by id (rte_…). One of routeId or address is required. The route must already be registered on this account." },
11
+ "address": { "type": "string", "description": "Target route by the address it matches. One of routeId or address is required." },
12
+ "from": { "type": "string", "description": "Sender address recorded on the message." },
13
+ "subject": { "type": "string", "description": "Optional subject line." },
14
+ "text": { "type": "string", "description": "Plain-text body." },
15
+ "html": { "type": "string", "description": "HTML body." }
16
+ }
17
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "route-response",
4
+ "title": "Route-message response body",
5
+ "type": "object",
6
+ "required": ["id", "routed", "action"],
7
+ "properties": {
8
+ "id": { "type": "string", "description": "Stored message id (msg_…)." },
9
+ "routed": { "type": "boolean", "description": "Whether the message was handed to the route's action." },
10
+ "action": { "type": "string", "description": "The action the matched route performed: webhook, forward, agent, store, or drop." }
11
+ }
12
+ }
@@ -3,7 +3,7 @@
3
3
  "$id": "send-request",
4
4
  "title": "Send request body",
5
5
  "type": "object",
6
- "required": ["from", "to", "subject"],
6
+ "required": ["from", "to"],
7
7
  "additionalProperties": false,
8
8
  "properties": {
9
9
  "from": { "type": "string", "description": "An address on a verified domain." },
@@ -14,9 +14,18 @@
14
14
  { "type": "array", "items": { "type": "string" }, "minItems": 1 }
15
15
  ]
16
16
  },
17
- "subject": { "type": "string" },
17
+ "subject": { "type": "string", "description": "Required unless supplied by a template." },
18
18
  "html": { "type": "string" },
19
19
  "text": { "type": "string" },
20
+ "templateId": {
21
+ "type": "string",
22
+ "description": "Send using a saved template — a user template (tpl_…) or a base template (base_…). Its subject/html/text seed the message; explicit subject/html/text here override them."
23
+ },
24
+ "templateData": {
25
+ "type": "object",
26
+ "description": "Values substituted into the template's {{merge_tags}} (e.g. {\"name\":\"Ann\"} fills {{name}}). HTML values are auto-escaped.",
27
+ "additionalProperties": { "type": ["string", "number", "boolean", "null"] }
28
+ },
20
29
  "cc": { "oneOf": [{ "type": "string" }, { "type": "array", "items": { "type": "string" } }] },
21
30
  "bcc": { "oneOf": [{ "type": "string" }, { "type": "array", "items": { "type": "string" } }] },
22
31
  "replyTo": { "type": "string" },