@aidalinfo/email-mcp 0.1.1 → 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 +148 -65
- package/deploy/clients.example.json +4 -0
- package/deploy/compose.yaml +50 -0
- package/deploy/templates/rapport-hebdo@1.json +13 -0
- package/dist/attachments.js +207 -0
- package/dist/audit.js +4 -31
- package/dist/clients.js +144 -0
- package/dist/config.js +42 -20
- package/dist/errors.js +3 -1
- package/dist/http.js +131 -0
- package/dist/index.js +65 -14
- package/dist/ledger.js +138 -0
- package/dist/quota.js +77 -0
- package/dist/redact.js +23 -0
- package/dist/smtp-client.js +70 -0
- package/dist/tem-client.js +14 -0
- package/dist/templates.js +118 -0
- package/dist/tools.js +440 -102
- package/dist/webhook.js +67 -0
- package/docs/exploitation.md +140 -0
- package/package.json +7 -3
package/dist/clients.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { HARD_MAX_PER_DAY, HARD_MAX_PER_HOUR, parseAllowlist } from "./config.js";
|
|
4
|
+
export const CLIENT_ID_RE = /^[a-z0-9][a-z0-9_.-]{0,63}$/;
|
|
5
|
+
const KEY_HASH_RE = /^sha256:[0-9a-f]{64}$/;
|
|
6
|
+
/** What a client key looks like: `emk_` + 43 base64url characters (256 random bits). */
|
|
7
|
+
export const CLIENT_KEY_RE = /^emk_[A-Za-z0-9_-]{43}$/;
|
|
8
|
+
export const hashKey = (key) => createHash("sha256").update(key).digest("hex");
|
|
9
|
+
/** A new client key and the line to put in the clients file. The key is shown once, the file keeps its hash only. */
|
|
10
|
+
export function generateKey() {
|
|
11
|
+
const key = `emk_${randomBytes(32).toString("base64url")}`;
|
|
12
|
+
return { key, hash: `sha256:${hashKey(key)}` };
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Clients JSON (EMAIL_MCP_CLIENTS_FILE or EMAIL_MCP_CLIENTS): a list of
|
|
16
|
+
* `{ "id", "keys": ["sha256:<hex>", …], "maxPerHour"?, "maxPerDay"?, "allowedRecipients"?, "templatesOnly"?, "disabled"? }`.
|
|
17
|
+
* Several keys per client allow a rotation without downtime. Only hashes are stored.
|
|
18
|
+
*/
|
|
19
|
+
export function parseClients(raw, config) {
|
|
20
|
+
let list;
|
|
21
|
+
try {
|
|
22
|
+
list = JSON.parse(raw);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
throw new Error("clients: not valid JSON");
|
|
26
|
+
}
|
|
27
|
+
if (!Array.isArray(list))
|
|
28
|
+
throw new Error("clients: expected a JSON list");
|
|
29
|
+
const seenKeys = new Set();
|
|
30
|
+
const out = [];
|
|
31
|
+
for (const c of list) {
|
|
32
|
+
const id = c?.id;
|
|
33
|
+
if (typeof id !== "string" || !CLIENT_ID_RE.test(id))
|
|
34
|
+
throw new Error("clients: each client needs an id matching [a-z0-9_.-], 64 characters max");
|
|
35
|
+
if (out.some((x) => x.id === id))
|
|
36
|
+
throw new Error(`clients: duplicate id "${id}"`);
|
|
37
|
+
if (!Array.isArray(c.keys) || !c.keys.length)
|
|
38
|
+
throw new Error(`clients[${id}]: keys must list at least one "sha256:<hex>" hash`);
|
|
39
|
+
const keyHashes = c.keys.map((k) => {
|
|
40
|
+
if (typeof k !== "string" || !KEY_HASH_RE.test(k))
|
|
41
|
+
throw new Error(`clients[${id}]: keys must be "sha256:<64 hex>" hashes, never raw keys`);
|
|
42
|
+
if (seenKeys.has(k))
|
|
43
|
+
throw new Error(`clients[${id}]: a key hash is used twice`);
|
|
44
|
+
seenKeys.add(k);
|
|
45
|
+
return Buffer.from(k.slice(7), "hex");
|
|
46
|
+
});
|
|
47
|
+
const limit = (v, name, def, max) => {
|
|
48
|
+
if (v === undefined)
|
|
49
|
+
return def;
|
|
50
|
+
if (!Number.isInteger(v) || v < 1 || v > max)
|
|
51
|
+
throw new Error(`clients[${id}]: ${name} must be an integer 1..${max}`);
|
|
52
|
+
return Math.min(v, def);
|
|
53
|
+
};
|
|
54
|
+
let allowlist;
|
|
55
|
+
if (c.allowedRecipients !== undefined) {
|
|
56
|
+
if (typeof c.allowedRecipients !== "string")
|
|
57
|
+
throw new Error(`clients[${id}]: allowedRecipients must be a string like "a@x.fr,@domain.fr"`);
|
|
58
|
+
allowlist = parseAllowlist(c.allowedRecipients, `clients[${id}].allowedRecipients`);
|
|
59
|
+
if (!allowlist.addresses.size && !allowlist.domains.size)
|
|
60
|
+
throw new Error(`clients[${id}]: allowedRecipients is empty — remove it to use the server allowlist`);
|
|
61
|
+
}
|
|
62
|
+
out.push({
|
|
63
|
+
id,
|
|
64
|
+
keyHashes,
|
|
65
|
+
// a client limit can only lower the server-wide one
|
|
66
|
+
maxPerHour: limit(c.maxPerHour, "maxPerHour", config.maxPerHour, HARD_MAX_PER_HOUR),
|
|
67
|
+
maxPerDay: limit(c.maxPerDay, "maxPerDay", config.maxPerDay, HARD_MAX_PER_DAY),
|
|
68
|
+
allowlist,
|
|
69
|
+
templatesOnly: c.templatesOnly === true,
|
|
70
|
+
disabled: c.disabled === true,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
if (!out.some((c) => !c.disabled))
|
|
74
|
+
throw new Error("clients: no enabled client — the server would refuse every request");
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Bearer authentication against the clients list. The file is re-read when it changes, so a key
|
|
79
|
+
* is added, rotated or revoked without restart; while it is unreadable or invalid every request is
|
|
80
|
+
* refused (fail-closed) rather than served with a stale list.
|
|
81
|
+
*/
|
|
82
|
+
export class ClientRegistry {
|
|
83
|
+
source;
|
|
84
|
+
config;
|
|
85
|
+
warn;
|
|
86
|
+
clients = [];
|
|
87
|
+
mtime = -1;
|
|
88
|
+
broken;
|
|
89
|
+
constructor(source, config, warn = (m) => process.stderr.write(`${m}\n`)) {
|
|
90
|
+
this.source = source;
|
|
91
|
+
this.config = config;
|
|
92
|
+
this.warn = warn;
|
|
93
|
+
if (!source.file && !source.inline)
|
|
94
|
+
throw new Error("Missing client keys: set EMAIL_MCP_CLIENTS_FILE (or EMAIL_MCP_CLIENTS) — see `email-mcp keygen`");
|
|
95
|
+
if (source.file)
|
|
96
|
+
this.reload(true);
|
|
97
|
+
else
|
|
98
|
+
this.clients = parseClients(source.inline, config);
|
|
99
|
+
}
|
|
100
|
+
reload(strict = false) {
|
|
101
|
+
const file = this.source.file;
|
|
102
|
+
try {
|
|
103
|
+
const m = statSync(file).mtimeMs;
|
|
104
|
+
if (m === this.mtime && !this.broken)
|
|
105
|
+
return;
|
|
106
|
+
this.clients = parseClients(readFileSync(file, "utf-8"), this.config);
|
|
107
|
+
this.mtime = m;
|
|
108
|
+
if (this.broken)
|
|
109
|
+
this.warn(`clients file ${file} is valid again`);
|
|
110
|
+
this.broken = undefined;
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
114
|
+
if (strict)
|
|
115
|
+
throw new Error(`clients file ${file}: ${msg}`);
|
|
116
|
+
if (this.broken !== msg)
|
|
117
|
+
this.warn(`⚠ clients file ${file} unusable (${msg}) — every request is refused until it is fixed`);
|
|
118
|
+
this.broken = msg;
|
|
119
|
+
this.clients = [];
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
get count() {
|
|
123
|
+
return this.clients.filter((c) => !c.disabled).length;
|
|
124
|
+
}
|
|
125
|
+
/** The client owning this `Authorization` header, or null (missing, malformed, unknown, revoked or disabled). */
|
|
126
|
+
authenticate(header) {
|
|
127
|
+
if (this.source.file)
|
|
128
|
+
this.reload();
|
|
129
|
+
const m = /^Bearer (\S+)$/.exec(header ?? "");
|
|
130
|
+
if (!m || !CLIENT_KEY_RE.test(m[1]))
|
|
131
|
+
return null;
|
|
132
|
+
const given = Buffer.from(hashKey(m[1]), "hex");
|
|
133
|
+
let found = null;
|
|
134
|
+
// compare against every hash, without early exit
|
|
135
|
+
for (const c of this.clients)
|
|
136
|
+
for (const h of c.keyHashes)
|
|
137
|
+
if (timingSafeEqual(h, given) && !found)
|
|
138
|
+
found = c;
|
|
139
|
+
if (!found || found.disabled)
|
|
140
|
+
return null;
|
|
141
|
+
const { keyHashes: _k, disabled: _d, ...profile } = found;
|
|
142
|
+
return profile;
|
|
143
|
+
}
|
|
144
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -18,12 +18,39 @@ export const domainOf = (address) => address.slice(address.lastIndexOf("@") + 1)
|
|
|
18
18
|
export function isAllowed(list, address) {
|
|
19
19
|
return list.addresses.has(address) || list.domains.has(domainOf(address));
|
|
20
20
|
}
|
|
21
|
+
/** `a@x.fr,@domain.fr` → allowlist. Throws on wildcards and malformed entries; may return an empty list. */
|
|
22
|
+
export function parseAllowlist(raw, name) {
|
|
23
|
+
const addresses = new Set();
|
|
24
|
+
const domains = new Set();
|
|
25
|
+
for (const entry of raw.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean)) {
|
|
26
|
+
if (entry.includes("*"))
|
|
27
|
+
throw new Error(`${name}: wildcards are not supported ("${entry}") — list addresses or @domains`);
|
|
28
|
+
if (entry.startsWith("@")) {
|
|
29
|
+
if (!DOMAIN_RE.test(entry.slice(1)))
|
|
30
|
+
throw new Error(`${name}: invalid domain "${entry}"`);
|
|
31
|
+
domains.add(entry.slice(1));
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
const a = normalizeAddress(entry);
|
|
35
|
+
if (!a)
|
|
36
|
+
throw new Error(`${name}: invalid address "${entry}"`);
|
|
37
|
+
addresses.add(a);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { addresses, domains };
|
|
41
|
+
}
|
|
21
42
|
export const DEFAULT_MAX_RECIPIENTS = 10;
|
|
22
43
|
export const HARD_MAX_RECIPIENTS = 50;
|
|
23
44
|
export const DEFAULT_MAX_PER_HOUR = 20;
|
|
24
45
|
export const HARD_MAX_PER_HOUR = 500;
|
|
46
|
+
export const DEFAULT_MAX_PER_DAY = 200;
|
|
47
|
+
export const HARD_MAX_PER_DAY = 5000;
|
|
48
|
+
/** How a message leaves: the TEM API (2 MB max per email, not raisable), SMTP (50 MB), or the API up to its limit then SMTP. */
|
|
49
|
+
export const SEND_VIA = ["auto", "api", "smtp"];
|
|
50
|
+
/** Implicit TLS (465, 2465) or STARTTLS (587, 2587). 2465 and 2587 stay open on Scaleway Instances, which block 465 and 587 by default. */
|
|
51
|
+
export const SMTP_PORTS = [2465, 465, 2587, 587];
|
|
25
52
|
const get = (env, k) => env[k]?.trim() || undefined;
|
|
26
|
-
function intIn(raw, name, def, max, warnings) {
|
|
53
|
+
export function intIn(raw, name, def, max, warnings) {
|
|
27
54
|
if (raw === undefined)
|
|
28
55
|
return def;
|
|
29
56
|
const n = Number(raw);
|
|
@@ -66,37 +93,32 @@ export function loadConfig(env) {
|
|
|
66
93
|
const replyTo = replyToRaw ? normalizeAddress(replyToRaw) : undefined;
|
|
67
94
|
if (replyToRaw && !replyTo)
|
|
68
95
|
throw new Error(`EMAIL_REPLY_TO is not a plain email address: "${replyToRaw}"`);
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
for (const entry of (get(env, "EMAIL_ALLOWED_RECIPIENTS") ?? "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean)) {
|
|
72
|
-
if (entry.includes("*"))
|
|
73
|
-
throw new Error(`EMAIL_ALLOWED_RECIPIENTS: wildcards are not supported ("${entry}") — list addresses or @domains`);
|
|
74
|
-
if (entry.startsWith("@")) {
|
|
75
|
-
if (!DOMAIN_RE.test(entry.slice(1)))
|
|
76
|
-
throw new Error(`EMAIL_ALLOWED_RECIPIENTS: invalid domain "${entry}"`);
|
|
77
|
-
domains.add(entry.slice(1));
|
|
78
|
-
}
|
|
79
|
-
else {
|
|
80
|
-
const a = normalizeAddress(entry);
|
|
81
|
-
if (!a)
|
|
82
|
-
throw new Error(`EMAIL_ALLOWED_RECIPIENTS: invalid address "${entry}"`);
|
|
83
|
-
addresses.add(a);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
if (!addresses.size && !domains.size) {
|
|
96
|
+
const allowlist = parseAllowlist(get(env, "EMAIL_ALLOWED_RECIPIENTS") ?? "", "EMAIL_ALLOWED_RECIPIENTS");
|
|
97
|
+
if (!allowlist.addresses.size && !allowlist.domains.size) {
|
|
87
98
|
throw new Error("EMAIL_ALLOWED_RECIPIENTS is empty — list the addresses and/or @domains the agent may write to");
|
|
88
99
|
}
|
|
100
|
+
const sendVia = (get(env, "EMAIL_SEND_VIA") ?? "auto");
|
|
101
|
+
if (!SEND_VIA.includes(sendVia))
|
|
102
|
+
throw new Error(`EMAIL_SEND_VIA must be one of ${SEND_VIA.join(", ")} (got "${sendVia}")`);
|
|
103
|
+
const smtpPort = Number(get(env, "EMAIL_SMTP_PORT") ?? SMTP_PORTS[0]);
|
|
104
|
+
if (!SMTP_PORTS.includes(smtpPort))
|
|
105
|
+
throw new Error(`EMAIL_SMTP_PORT must be one of ${SMTP_PORTS.join(", ")} (got "${get(env, "EMAIL_SMTP_PORT")}")`);
|
|
89
106
|
return {
|
|
90
107
|
secretKey,
|
|
91
108
|
projectId: projectId.toLowerCase(),
|
|
92
109
|
region,
|
|
93
110
|
from: { email: fromEmail, name: fromName },
|
|
94
111
|
replyTo: replyTo ?? undefined,
|
|
95
|
-
allowlist
|
|
112
|
+
allowlist,
|
|
96
113
|
maxRecipients: intIn(get(env, "EMAIL_MAX_RECIPIENTS"), "EMAIL_MAX_RECIPIENTS", DEFAULT_MAX_RECIPIENTS, HARD_MAX_RECIPIENTS, warnings),
|
|
97
114
|
maxPerHour: intIn(get(env, "EMAIL_MAX_PER_HOUR"), "EMAIL_MAX_PER_HOUR", DEFAULT_MAX_PER_HOUR, HARD_MAX_PER_HOUR, warnings),
|
|
115
|
+
maxPerDay: intIn(get(env, "EMAIL_MAX_PER_DAY"), "EMAIL_MAX_PER_DAY", DEFAULT_MAX_PER_DAY, HARD_MAX_PER_DAY, warnings),
|
|
116
|
+
sendVia,
|
|
117
|
+
smtpPort,
|
|
98
118
|
killSwitchFile: get(env, "EMAIL_KILL_SWITCH_FILE"),
|
|
99
119
|
auditFile: get(env, "EMAIL_AUDIT_LOG_FILE"),
|
|
120
|
+
stateDir: get(env, "EMAIL_STATE_DIR"),
|
|
121
|
+
templatesDir: get(env, "EMAIL_TEMPLATES_DIR"),
|
|
100
122
|
warnings,
|
|
101
123
|
};
|
|
102
124
|
}
|
package/dist/errors.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { redactor } from "./redact.js";
|
|
1
2
|
export class EmailError extends Error {
|
|
2
3
|
code;
|
|
3
4
|
status;
|
|
@@ -15,5 +16,6 @@ export function toEmailError(err) {
|
|
|
15
16
|
}
|
|
16
17
|
export function errorResult(err) {
|
|
17
18
|
const e = toEmailError(err);
|
|
18
|
-
|
|
19
|
+
const text = e.message.startsWith(`${e.code}:`) ? e.message : `${e.code}: ${e.message}`;
|
|
20
|
+
return { isError: true, content: [{ type: "text", text: redactor.apply(text) }] };
|
|
19
21
|
}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
5
|
+
import { redactor } from "./redact.js";
|
|
6
|
+
import { registerTools, SERVER_INSTRUCTIONS } from "./tools.js";
|
|
7
|
+
import { handleTemWebhook } from "./webhook.js";
|
|
8
|
+
/** JSON-RPC bodies only carry text and references (attachments are fetched by the server). */
|
|
9
|
+
export const MAX_BODY_BYTES = 1_048_576;
|
|
10
|
+
const WEBHOOK_MAX_BODY_BYTES = 262_144;
|
|
11
|
+
const WEBHOOK_SECRET_RE = /^[A-Za-z0-9_-]{32,128}$/;
|
|
12
|
+
export function loadHttpConfig(env) {
|
|
13
|
+
const get = (k) => env[k]?.trim() || undefined;
|
|
14
|
+
const port = Number(get("EMAIL_MCP_PORT") ?? 8080);
|
|
15
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
16
|
+
throw new Error(`EMAIL_MCP_PORT must be a TCP port (got "${env.EMAIL_MCP_PORT}")`);
|
|
17
|
+
const webhookSecret = get("EMAIL_WEBHOOK_SECRET");
|
|
18
|
+
if (webhookSecret && !WEBHOOK_SECRET_RE.test(webhookSecret)) {
|
|
19
|
+
throw new Error("EMAIL_WEBHOOK_SECRET must be 32 to 128 characters among letters, digits, _ and - (e.g. openssl rand -hex 32)");
|
|
20
|
+
}
|
|
21
|
+
redactor.add(webhookSecret);
|
|
22
|
+
return {
|
|
23
|
+
host: get("EMAIL_MCP_HOST") ?? "127.0.0.1",
|
|
24
|
+
port,
|
|
25
|
+
clientsFile: get("EMAIL_MCP_CLIENTS_FILE"),
|
|
26
|
+
clientsInline: get("EMAIL_MCP_CLIENTS"),
|
|
27
|
+
webhookSecret,
|
|
28
|
+
allowedOrigins: (get("EMAIL_MCP_ALLOWED_ORIGINS") ?? "").split(",").map((s) => s.trim()).filter(Boolean),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const sha = (s) => createHash("sha256").update(s).digest();
|
|
32
|
+
function send(res, status, body, headers = {}) {
|
|
33
|
+
res.writeHead(status, {
|
|
34
|
+
"Cache-Control": "no-store",
|
|
35
|
+
"X-Content-Type-Options": "nosniff",
|
|
36
|
+
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
|
37
|
+
...headers,
|
|
38
|
+
});
|
|
39
|
+
res.end(body === undefined ? undefined : JSON.stringify(body));
|
|
40
|
+
}
|
|
41
|
+
const rpcError = (code, message) => ({ jsonrpc: "2.0", error: { code, message }, id: null });
|
|
42
|
+
async function readBody(req, max) {
|
|
43
|
+
const chunks = [];
|
|
44
|
+
let size = 0;
|
|
45
|
+
for await (const c of req) {
|
|
46
|
+
size += c.length;
|
|
47
|
+
if (size > max)
|
|
48
|
+
return null;
|
|
49
|
+
chunks.push(c);
|
|
50
|
+
}
|
|
51
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Streamable HTTP, stateless: every POST /mcp is authenticated, then served by a fresh MCP server
|
|
55
|
+
* bound to that client (its quotas, its allowlist, its sends). Also /health and the TEM webhook.
|
|
56
|
+
*/
|
|
57
|
+
export function createHttpHandler(svc, clients, http, version, log = (m) => process.stderr.write(`${m}\n`)) {
|
|
58
|
+
const webhookPath = http.webhookSecret ? sha(`/webhooks/tem/${http.webhookSecret}`) : undefined;
|
|
59
|
+
return async (req, res) => {
|
|
60
|
+
try {
|
|
61
|
+
const path = (req.url ?? "/").split("?")[0];
|
|
62
|
+
if (path === "/health") {
|
|
63
|
+
if (req.method !== "GET" && req.method !== "HEAD")
|
|
64
|
+
return send(res, 405, { error: "method not allowed" }, { Allow: "GET, HEAD" });
|
|
65
|
+
return send(res, 200, { status: "ok", service: "email-mcp", version, sending: svc.killSwitchActive() ? "disabled" : "enabled" });
|
|
66
|
+
}
|
|
67
|
+
if (path.startsWith("/webhooks/tem/")) {
|
|
68
|
+
if (!webhookPath || !timingSafeEqual(sha(path), webhookPath))
|
|
69
|
+
return send(res, 404, { error: "not found" });
|
|
70
|
+
if (req.method !== "POST")
|
|
71
|
+
return send(res, 405, { error: "method not allowed" }, { Allow: "POST" });
|
|
72
|
+
const body = await readBody(req, WEBHOOK_MAX_BODY_BYTES);
|
|
73
|
+
if (body === null)
|
|
74
|
+
return send(res, 413, { error: "payload too large" }, { Connection: "close" });
|
|
75
|
+
const r = await handleTemWebhook(body, { projectId: svc.config.projectId, region: svc.config.region, ledger: svc.ledger });
|
|
76
|
+
if (r.log)
|
|
77
|
+
log(redactor.apply(r.log));
|
|
78
|
+
return send(res, r.status);
|
|
79
|
+
}
|
|
80
|
+
if (path !== "/mcp")
|
|
81
|
+
return send(res, 404, { error: "not found" });
|
|
82
|
+
const origin = req.headers.origin;
|
|
83
|
+
if (origin && !http.allowedOrigins.includes(origin))
|
|
84
|
+
return send(res, 403, rpcError(-32000, "origin not allowed"));
|
|
85
|
+
const client = clients.authenticate(req.headers.authorization);
|
|
86
|
+
if (!client) {
|
|
87
|
+
svc.audit.log({ tool: "http", result: "refused", from: svc.config.from.email, errorCode: "unauthorized" });
|
|
88
|
+
return send(res, 401, rpcError(-32001, "unauthorized: a valid client key is required (Authorization: Bearer emk_…)"), { "WWW-Authenticate": 'Bearer realm="email-mcp"' });
|
|
89
|
+
}
|
|
90
|
+
// stateless server: no SSE stream to resume, no session to delete
|
|
91
|
+
if (req.method !== "POST")
|
|
92
|
+
return send(res, 405, rpcError(-32000, "method not allowed: POST JSON-RPC to /mcp"), { Allow: "POST" });
|
|
93
|
+
if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json"))
|
|
94
|
+
return send(res, 415, rpcError(-32700, "content-type must be application/json"));
|
|
95
|
+
if (Number(req.headers["content-length"] ?? 0) > MAX_BODY_BYTES)
|
|
96
|
+
return send(res, 413, rpcError(-32600, `request larger than ${MAX_BODY_BYTES} bytes`), { Connection: "close" });
|
|
97
|
+
const raw = await readBody(req, MAX_BODY_BYTES);
|
|
98
|
+
if (raw === null)
|
|
99
|
+
return send(res, 413, rpcError(-32600, `request larger than ${MAX_BODY_BYTES} bytes`), { Connection: "close" });
|
|
100
|
+
let parsed;
|
|
101
|
+
try {
|
|
102
|
+
parsed = JSON.parse(raw);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return send(res, 400, rpcError(-32700, "parse error"));
|
|
106
|
+
}
|
|
107
|
+
const server = new McpServer({ name: "email", version }, { instructions: SERVER_INSTRUCTIONS });
|
|
108
|
+
registerTools(server, { ...svc, client });
|
|
109
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
110
|
+
res.on("close", () => {
|
|
111
|
+
void transport.close();
|
|
112
|
+
void server.close();
|
|
113
|
+
});
|
|
114
|
+
await server.connect(transport);
|
|
115
|
+
await transport.handleRequest(req, res, parsed);
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
log(redactor.apply(`email-mcp http: ${err instanceof Error ? err.message : String(err)}`));
|
|
119
|
+
if (!res.headersSent)
|
|
120
|
+
send(res, 500, rpcError(-32603, "internal error"));
|
|
121
|
+
else
|
|
122
|
+
res.end();
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
export function createHttpServer(svc, clients, http, version) {
|
|
127
|
+
const server = createServer(createHttpHandler(svc, clients, http, version));
|
|
128
|
+
server.headersTimeout = 15_000;
|
|
129
|
+
server.requestTimeout = 120_000;
|
|
130
|
+
return server;
|
|
131
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -2,26 +2,77 @@
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import { loadAttachmentConfig } from "./attachments.js";
|
|
6
|
+
import { CLIENT_ID_RE, ClientRegistry, generateKey } from "./clients.js";
|
|
5
7
|
import { loadConfig } from "./config.js";
|
|
8
|
+
import { createHttpServer, loadHttpConfig } from "./http.js";
|
|
9
|
+
import { redactor } from "./redact.js";
|
|
10
|
+
import { SmtpClient } from "./smtp-client.js";
|
|
6
11
|
import { TemClient } from "./tem-client.js";
|
|
7
|
-
import {
|
|
12
|
+
import { createService, localClient, registerTools, SERVER_INSTRUCTIONS } from "./tools.js";
|
|
8
13
|
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
|
|
9
|
-
// stdout is the MCP transport: everything human-readable goes to stderr.
|
|
14
|
+
// stdout is the MCP transport over stdio: everything human-readable goes to stderr.
|
|
10
15
|
const log = (msg) => process.stderr.write(`${msg}\n`);
|
|
11
|
-
|
|
16
|
+
function keygen(id) {
|
|
17
|
+
if (!id || !CLIENT_ID_RE.test(id)) {
|
|
18
|
+
log("usage: email-mcp keygen <client-id> (client id: [a-z0-9_.-], 64 characters max)");
|
|
19
|
+
process.exit(2);
|
|
20
|
+
}
|
|
21
|
+
const { key, hash } = generateKey();
|
|
22
|
+
// the key goes to stdout once, for the agent's secret store; the server only keeps the hash
|
|
23
|
+
process.stdout.write(`client key (give it to "${id}" only, it is not stored anywhere):\n${key}\n\n` +
|
|
24
|
+
`clients file entry:\n${JSON.stringify({ id, keys: [hash] })}\n`);
|
|
25
|
+
}
|
|
26
|
+
async function main() {
|
|
27
|
+
if (process.argv[2] === "keygen")
|
|
28
|
+
return keygen(process.argv[3]);
|
|
29
|
+
const mode = process.env.EMAIL_MCP_TRANSPORT?.trim() || "stdio";
|
|
30
|
+
if (mode !== "stdio" && mode !== "http")
|
|
31
|
+
throw new Error(`EMAIL_MCP_TRANSPORT must be stdio or http (got "${mode}")`);
|
|
12
32
|
const config = loadConfig(process.env);
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
33
|
+
redactor.add(config.secretKey);
|
|
34
|
+
const attachments = loadAttachmentConfig(process.env, config.warnings, config.sendVia);
|
|
35
|
+
const tem = new TemClient(config.secretKey, config.projectId, config.region);
|
|
36
|
+
const smtp = config.sendVia === "api" ? undefined : new SmtpClient(config.projectId, config.secretKey, config.smtpPort);
|
|
37
|
+
const a = config.allowlist;
|
|
38
|
+
const summary = `sender: ${config.from.email}, TEM ${config.region} via ${config.sendVia === "auto" ? `API ≤ 2 MB, SMTP :${config.smtpPort} above` : config.sendVia === "smtp" ? `SMTP :${config.smtpPort}` : "API"}, allowlist: ${a.addresses.size} address(es) + ${a.domains.size} domain(s), ` +
|
|
39
|
+
`${config.maxRecipients} recipients/email, ${config.maxPerHour}/hour, ${config.maxPerDay}/day, ` +
|
|
40
|
+
`kill switch: ${config.killSwitchFile ?? "not configured"}, attachment sources: ${attachments.sources.length}, ` +
|
|
41
|
+
`templates: ${config.templatesDir ?? "not configured"}, state: ${config.stateDir ?? "memory only"}`;
|
|
42
|
+
if (mode === "stdio") {
|
|
43
|
+
const svc = createService(config, tem, { attachments, smtp });
|
|
44
|
+
const server = new McpServer({ name: "email", version: pkg.version }, { instructions: SERVER_INSTRUCTIONS });
|
|
45
|
+
registerTools(server, { ...svc, client: localClient(config) });
|
|
46
|
+
for (const w of config.warnings)
|
|
47
|
+
log(w);
|
|
48
|
+
await server.connect(new StdioServerTransport());
|
|
49
|
+
log(`email-mcp v${pkg.version} ready over stdio (${summary})`);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const http = loadHttpConfig(process.env);
|
|
53
|
+
// a restart must not forget which idempotency keys already sent
|
|
54
|
+
if (!config.stateDir)
|
|
55
|
+
throw new Error("Missing required env var for HTTP: EMAIL_STATE_DIR (persistent volume for the send ledger)");
|
|
56
|
+
const clients = new ClientRegistry({ file: http.clientsFile, inline: http.clientsInline }, config);
|
|
57
|
+
const svc = createService(config, tem, { attachments, smtp, multiClient: true, webhookConfigured: !!http.webhookSecret });
|
|
58
|
+
const server = createHttpServer(svc, clients, http, pkg.version);
|
|
16
59
|
for (const w of config.warnings)
|
|
17
60
|
log(w);
|
|
18
|
-
await
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
61
|
+
await new Promise((resolve, reject) => {
|
|
62
|
+
server.once("error", reject);
|
|
63
|
+
server.listen(http.port, http.host, resolve);
|
|
64
|
+
});
|
|
65
|
+
log(`email-mcp v${pkg.version} ready over HTTP on ${http.host}:${http.port} (/mcp, /health${http.webhookSecret ? ", TEM webhook" : ""}; ${clients.count} client(s); ${summary})`);
|
|
66
|
+
// finish the requests in flight (a send must get its answer), then exit; 10 s at most
|
|
67
|
+
const stop = () => {
|
|
68
|
+
server.close(() => process.exit(0));
|
|
69
|
+
server.closeIdleConnections();
|
|
70
|
+
setTimeout(() => process.exit(0), 10_000).unref();
|
|
71
|
+
};
|
|
72
|
+
process.on("SIGTERM", stop);
|
|
73
|
+
process.on("SIGINT", stop);
|
|
23
74
|
}
|
|
24
|
-
catch
|
|
25
|
-
log(`email-mcp: ${err instanceof Error ? err.message : String(err)}`);
|
|
75
|
+
main().catch((err) => {
|
|
76
|
+
log(redactor.apply(`email-mcp: ${err instanceof Error ? err.message : String(err)}`));
|
|
26
77
|
process.exit(1);
|
|
27
|
-
}
|
|
78
|
+
});
|
package/dist/ledger.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { EmailError } from "./errors.js";
|
|
4
|
+
import { DAY_MS } from "./quota.js";
|
|
5
|
+
export const IDEMPOTENCY_KEY_RE = /^[A-Za-z0-9._:-]{8,128}$/;
|
|
6
|
+
export const RETENTION_MS = 30 * DAY_MS;
|
|
7
|
+
export const MAX_RECORDS = 20_000;
|
|
8
|
+
export const MAX_EVENTS_PER_EMAIL = 20;
|
|
9
|
+
/**
|
|
10
|
+
* Every send, keyed by (client, idempotency key): what makes a key never send twice, which client
|
|
11
|
+
* owns which TEM email id (a client only reads its own) and the delivery events received by webhook.
|
|
12
|
+
* Persisted as one JSON file (atomic rename, mode 600) when a file is given; in memory otherwise.
|
|
13
|
+
* A record still `in_flight` when the file is loaded (crash during a send) becomes `unknown_outcome`.
|
|
14
|
+
*/
|
|
15
|
+
export class SendLedger {
|
|
16
|
+
file;
|
|
17
|
+
now;
|
|
18
|
+
records = new Map();
|
|
19
|
+
owners = new Map();
|
|
20
|
+
constructor(file, now = Date.now) {
|
|
21
|
+
this.file = file;
|
|
22
|
+
this.now = now;
|
|
23
|
+
if (file && existsSync(file)) {
|
|
24
|
+
let data;
|
|
25
|
+
try {
|
|
26
|
+
data = JSON.parse(readFileSync(file, "utf-8"));
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
throw new Error(`send ledger ${file} is unreadable (${err instanceof Error ? err.message : String(err)}) — fix or move it; refusing to start without it`);
|
|
30
|
+
}
|
|
31
|
+
for (const r of data.records ?? []) {
|
|
32
|
+
if (r.state === "in_flight")
|
|
33
|
+
r.state = "unknown_outcome";
|
|
34
|
+
this.index(r);
|
|
35
|
+
}
|
|
36
|
+
this.prune();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
static id(client, key) {
|
|
40
|
+
return `${client}\u0000${key}`;
|
|
41
|
+
}
|
|
42
|
+
index(r) {
|
|
43
|
+
this.records.set(SendLedger.id(r.client, r.key), r);
|
|
44
|
+
for (const id of r.emailIds)
|
|
45
|
+
this.owners.set(id, r);
|
|
46
|
+
}
|
|
47
|
+
prune() {
|
|
48
|
+
const floor = this.now() - RETENTION_MS;
|
|
49
|
+
const all = [...this.records.values()].sort((a, b) => a.updatedAt.localeCompare(b.updatedAt));
|
|
50
|
+
const excess = all.length - MAX_RECORDS;
|
|
51
|
+
all.forEach((r, i) => {
|
|
52
|
+
if (i < excess || Date.parse(r.updatedAt) < floor) {
|
|
53
|
+
this.records.delete(SendLedger.id(r.client, r.key));
|
|
54
|
+
for (const id of r.emailIds)
|
|
55
|
+
this.owners.delete(id);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
persist() {
|
|
60
|
+
if (!this.file)
|
|
61
|
+
return;
|
|
62
|
+
mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 });
|
|
63
|
+
const tmp = `${this.file}.${process.pid}.tmp`;
|
|
64
|
+
writeFileSync(tmp, JSON.stringify({ version: 1, records: [...this.records.values()] }), { mode: 0o600 });
|
|
65
|
+
renameSync(tmp, this.file);
|
|
66
|
+
}
|
|
67
|
+
get size() {
|
|
68
|
+
return this.records.size;
|
|
69
|
+
}
|
|
70
|
+
find(client, key) {
|
|
71
|
+
return this.records.get(SendLedger.id(client, key));
|
|
72
|
+
}
|
|
73
|
+
/** Records the send as in flight before it leaves. Throws `refused` when that cannot be persisted. */
|
|
74
|
+
begin(client, key, digest) {
|
|
75
|
+
if (this.find(client, key))
|
|
76
|
+
throw new EmailError("idempotency_conflict", "idempotency_conflict: this idempotency_key was already used");
|
|
77
|
+
const at = new Date(this.now()).toISOString();
|
|
78
|
+
const r = { client, key, digest, state: "in_flight", createdAt: at, updatedAt: at, emailIds: [], events: {} };
|
|
79
|
+
this.prune();
|
|
80
|
+
this.index(r);
|
|
81
|
+
try {
|
|
82
|
+
this.persist();
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
this.records.delete(SendLedger.id(client, key));
|
|
86
|
+
throw new EmailError("refused", `refused: the send ledger cannot be written (${err.code ?? "error"}) — nothing was sent`);
|
|
87
|
+
}
|
|
88
|
+
return r;
|
|
89
|
+
}
|
|
90
|
+
/** After the provider answered. Never throws: the email has left, a disk error must not hide it. */
|
|
91
|
+
settle(r, state, emailIds = [], smtp) {
|
|
92
|
+
r.state = state;
|
|
93
|
+
r.emailIds = emailIds;
|
|
94
|
+
r.smtp = smtp;
|
|
95
|
+
r.updatedAt = new Date(this.now()).toISOString();
|
|
96
|
+
for (const id of emailIds)
|
|
97
|
+
this.owners.set(id, r);
|
|
98
|
+
this.safePersist();
|
|
99
|
+
}
|
|
100
|
+
/** The TEM ids of an SMTP send, once TEM lists them. */
|
|
101
|
+
attach(r, emailIds) {
|
|
102
|
+
r.emailIds = emailIds;
|
|
103
|
+
delete r.smtp;
|
|
104
|
+
r.updatedAt = new Date(this.now()).toISOString();
|
|
105
|
+
for (const id of emailIds)
|
|
106
|
+
this.owners.set(id, r);
|
|
107
|
+
this.safePersist();
|
|
108
|
+
}
|
|
109
|
+
/** The provider refused the send for certain: the key may be used again. */
|
|
110
|
+
drop(r) {
|
|
111
|
+
this.records.delete(SendLedger.id(r.client, r.key));
|
|
112
|
+
this.safePersist();
|
|
113
|
+
}
|
|
114
|
+
ownerOf(emailId) {
|
|
115
|
+
return this.owners.get(emailId);
|
|
116
|
+
}
|
|
117
|
+
/** Stores a webhook event for one of our emails. Returns false (nothing stored) for any other email. */
|
|
118
|
+
addEvent(emailId, e) {
|
|
119
|
+
const r = this.owners.get(emailId);
|
|
120
|
+
if (!r)
|
|
121
|
+
return false;
|
|
122
|
+
const list = (r.events[emailId] ??= []);
|
|
123
|
+
list.push(e);
|
|
124
|
+
if (list.length > MAX_EVENTS_PER_EMAIL)
|
|
125
|
+
list.splice(0, list.length - MAX_EVENTS_PER_EMAIL);
|
|
126
|
+
r.updatedAt = new Date(this.now()).toISOString();
|
|
127
|
+
this.safePersist();
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
safePersist() {
|
|
131
|
+
try {
|
|
132
|
+
this.persist();
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
process.stderr.write(`⚠ send ledger not writable (${err.code ?? "error"}) — kept in memory only\n`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|