@aidalinfo/email-mcp 0.1.0 → 0.2.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.
@@ -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,35 @@ 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;
25
48
  const get = (env, k) => env[k]?.trim() || undefined;
26
- function intIn(raw, name, def, max, warnings) {
49
+ export function intIn(raw, name, def, max, warnings) {
27
50
  if (raw === undefined)
28
51
  return def;
29
52
  const n = Number(raw);
@@ -66,24 +89,8 @@ export function loadConfig(env) {
66
89
  const replyTo = replyToRaw ? normalizeAddress(replyToRaw) : undefined;
67
90
  if (replyToRaw && !replyTo)
68
91
  throw new Error(`EMAIL_REPLY_TO is not a plain email address: "${replyToRaw}"`);
69
- const addresses = new Set();
70
- const domains = new Set();
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) {
92
+ const allowlist = parseAllowlist(get(env, "EMAIL_ALLOWED_RECIPIENTS") ?? "", "EMAIL_ALLOWED_RECIPIENTS");
93
+ if (!allowlist.addresses.size && !allowlist.domains.size) {
87
94
  throw new Error("EMAIL_ALLOWED_RECIPIENTS is empty — list the addresses and/or @domains the agent may write to");
88
95
  }
89
96
  return {
@@ -92,11 +99,14 @@ export function loadConfig(env) {
92
99
  region,
93
100
  from: { email: fromEmail, name: fromName },
94
101
  replyTo: replyTo ?? undefined,
95
- allowlist: { addresses, domains },
102
+ allowlist,
96
103
  maxRecipients: intIn(get(env, "EMAIL_MAX_RECIPIENTS"), "EMAIL_MAX_RECIPIENTS", DEFAULT_MAX_RECIPIENTS, HARD_MAX_RECIPIENTS, warnings),
97
104
  maxPerHour: intIn(get(env, "EMAIL_MAX_PER_HOUR"), "EMAIL_MAX_PER_HOUR", DEFAULT_MAX_PER_HOUR, HARD_MAX_PER_HOUR, warnings),
105
+ maxPerDay: intIn(get(env, "EMAIL_MAX_PER_DAY"), "EMAIL_MAX_PER_DAY", DEFAULT_MAX_PER_DAY, HARD_MAX_PER_DAY, warnings),
98
106
  killSwitchFile: get(env, "EMAIL_KILL_SWITCH_FILE"),
99
107
  auditFile: get(env, "EMAIL_AUDIT_LOG_FILE"),
108
+ stateDir: get(env, "EMAIL_STATE_DIR"),
109
+ templatesDir: get(env, "EMAIL_TEMPLATES_DIR"),
100
110
  warnings,
101
111
  };
102
112
  }
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
- return { isError: true, content: [{ type: "text", text: e.message.startsWith(`${e.code}:`) ? e.message : `${e.code}: ${e.message}` }] };
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,75 @@
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";
6
10
  import { TemClient } from "./tem-client.js";
7
- import { createContext, registerTools, SERVER_INSTRUCTIONS } from "./tools.js";
11
+ import { createService, localClient, registerTools, SERVER_INSTRUCTIONS } from "./tools.js";
8
12
  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.
13
+ // stdout is the MCP transport over stdio: everything human-readable goes to stderr.
10
14
  const log = (msg) => process.stderr.write(`${msg}\n`);
11
- try {
15
+ function keygen(id) {
16
+ if (!id || !CLIENT_ID_RE.test(id)) {
17
+ log("usage: email-mcp keygen <client-id> (client id: [a-z0-9_.-], 64 characters max)");
18
+ process.exit(2);
19
+ }
20
+ const { key, hash } = generateKey();
21
+ // the key goes to stdout once, for the agent's secret store; the server only keeps the hash
22
+ process.stdout.write(`client key (give it to "${id}" only, it is not stored anywhere):\n${key}\n\n` +
23
+ `clients file entry:\n${JSON.stringify({ id, keys: [hash] })}\n`);
24
+ }
25
+ async function main() {
26
+ if (process.argv[2] === "keygen")
27
+ return keygen(process.argv[3]);
28
+ const mode = process.env.EMAIL_MCP_TRANSPORT?.trim() || "stdio";
29
+ if (mode !== "stdio" && mode !== "http")
30
+ throw new Error(`EMAIL_MCP_TRANSPORT must be stdio or http (got "${mode}")`);
12
31
  const config = loadConfig(process.env);
13
- const ctx = createContext(config, new TemClient(config.secretKey, config.projectId, config.region));
14
- const server = new McpServer({ name: "email", version: pkg.version }, { instructions: SERVER_INSTRUCTIONS });
15
- registerTools(server, ctx);
32
+ redactor.add(config.secretKey);
33
+ const attachments = loadAttachmentConfig(process.env, config.warnings);
34
+ const tem = new TemClient(config.secretKey, config.projectId, config.region);
35
+ const a = config.allowlist;
36
+ const summary = `sender: ${config.from.email}, TEM ${config.region}, allowlist: ${a.addresses.size} address(es) + ${a.domains.size} domain(s), ` +
37
+ `${config.maxRecipients} recipients/email, ${config.maxPerHour}/hour, ${config.maxPerDay}/day, ` +
38
+ `kill switch: ${config.killSwitchFile ?? "not configured"}, attachment sources: ${attachments.sources.length}, ` +
39
+ `templates: ${config.templatesDir ?? "not configured"}, state: ${config.stateDir ?? "memory only"}`;
40
+ if (mode === "stdio") {
41
+ const svc = createService(config, tem, { attachments });
42
+ const server = new McpServer({ name: "email", version: pkg.version }, { instructions: SERVER_INSTRUCTIONS });
43
+ registerTools(server, { ...svc, client: localClient(config) });
44
+ for (const w of config.warnings)
45
+ log(w);
46
+ await server.connect(new StdioServerTransport());
47
+ log(`email-mcp v${pkg.version} ready over stdio (${summary})`);
48
+ return;
49
+ }
50
+ const http = loadHttpConfig(process.env);
51
+ // a restart must not forget which idempotency keys already sent
52
+ if (!config.stateDir)
53
+ throw new Error("Missing required env var for HTTP: EMAIL_STATE_DIR (persistent volume for the send ledger)");
54
+ const clients = new ClientRegistry({ file: http.clientsFile, inline: http.clientsInline }, config);
55
+ const svc = createService(config, tem, { attachments, multiClient: true, webhookConfigured: !!http.webhookSecret });
56
+ const server = createHttpServer(svc, clients, http, pkg.version);
16
57
  for (const w of config.warnings)
17
58
  log(w);
18
- await server.connect(new StdioServerTransport());
19
- const a = config.allowlist;
20
- log(`email-mcp v${pkg.version} ready (sender: ${config.from.email}, TEM ${config.region} project ${config.projectId}, ` +
21
- `allowlist: ${a.addresses.size} address(es) + ${a.domains.size} domain(s), ${config.maxRecipients} recipients/email, ${config.maxPerHour}/hour, ` +
22
- `kill switch: ${config.killSwitchFile ?? "not configured"})`);
59
+ await new Promise((resolve, reject) => {
60
+ server.once("error", reject);
61
+ server.listen(http.port, http.host, resolve);
62
+ });
63
+ 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})`);
64
+ // finish the requests in flight (a send must get its answer), then exit; 10 s at most
65
+ const stop = () => {
66
+ server.close(() => process.exit(0));
67
+ server.closeIdleConnections();
68
+ setTimeout(() => process.exit(0), 10_000).unref();
69
+ };
70
+ process.on("SIGTERM", stop);
71
+ process.on("SIGINT", stop);
23
72
  }
24
- catch (err) {
25
- log(`email-mcp: ${err instanceof Error ? err.message : String(err)}`);
73
+ main().catch((err) => {
74
+ log(redactor.apply(`email-mcp: ${err instanceof Error ? err.message : String(err)}`));
26
75
  process.exit(1);
27
- }
76
+ });
package/dist/ledger.js ADDED
@@ -0,0 +1,128 @@
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 = []) {
92
+ r.state = state;
93
+ r.emailIds = emailIds;
94
+ r.updatedAt = new Date(this.now()).toISOString();
95
+ for (const id of emailIds)
96
+ this.owners.set(id, r);
97
+ this.safePersist();
98
+ }
99
+ /** The provider refused the send for certain: the key may be used again. */
100
+ drop(r) {
101
+ this.records.delete(SendLedger.id(r.client, r.key));
102
+ this.safePersist();
103
+ }
104
+ ownerOf(emailId) {
105
+ return this.owners.get(emailId);
106
+ }
107
+ /** Stores a webhook event for one of our emails. Returns false (nothing stored) for any other email. */
108
+ addEvent(emailId, e) {
109
+ const r = this.owners.get(emailId);
110
+ if (!r)
111
+ return false;
112
+ const list = (r.events[emailId] ??= []);
113
+ list.push(e);
114
+ if (list.length > MAX_EVENTS_PER_EMAIL)
115
+ list.splice(0, list.length - MAX_EVENTS_PER_EMAIL);
116
+ r.updatedAt = new Date(this.now()).toISOString();
117
+ this.safePersist();
118
+ return true;
119
+ }
120
+ safePersist() {
121
+ try {
122
+ this.persist();
123
+ }
124
+ catch (err) {
125
+ process.stderr.write(`⚠ send ledger not writable (${err.code ?? "error"}) — kept in memory only\n`);
126
+ }
127
+ }
128
+ }
package/dist/quota.js ADDED
@@ -0,0 +1,77 @@
1
+ import { EmailError } from "./errors.js";
2
+ export const HOUR_MS = 3_600_000;
3
+ export const DAY_MS = 24 * HOUR_MS;
4
+ /** Timestamps of the sends inside a sliding window, per process. */
5
+ export class SlidingWindow {
6
+ windowMs;
7
+ now;
8
+ sends = [];
9
+ constructor(windowMs, now = Date.now) {
10
+ this.windowMs = windowMs;
11
+ this.now = now;
12
+ }
13
+ count() {
14
+ const floor = this.now() - this.windowMs;
15
+ while (this.sends.length && this.sends[0] <= floor)
16
+ this.sends.shift();
17
+ return this.sends.length;
18
+ }
19
+ /** Takes a slot; returns a function giving it back (for a send that certainly did not leave). */
20
+ reserve() {
21
+ const t = this.now();
22
+ this.sends.push(t);
23
+ return () => {
24
+ const i = this.sends.lastIndexOf(t);
25
+ if (i >= 0)
26
+ this.sends.splice(i, 1);
27
+ };
28
+ }
29
+ }
30
+ /**
31
+ * Two windows (hour, day) for the whole server and two for each client: a send needs a slot in
32
+ * all four. Counters live in memory — a restart resets them (the ledger still blocks duplicates).
33
+ */
34
+ export class Quotas {
35
+ limits;
36
+ now;
37
+ global;
38
+ clients = new Map();
39
+ constructor(limits, now = Date.now) {
40
+ this.limits = limits;
41
+ this.now = now;
42
+ this.global = this.pair();
43
+ }
44
+ pair() {
45
+ return { hour: new SlidingWindow(HOUR_MS, this.now), day: new SlidingWindow(DAY_MS, this.now) };
46
+ }
47
+ of(clientId) {
48
+ let p = this.clients.get(clientId);
49
+ if (!p)
50
+ this.clients.set(clientId, (p = this.pair()));
51
+ return p;
52
+ }
53
+ remaining(clientId, client) {
54
+ const c = this.of(clientId);
55
+ return {
56
+ client: { hour: Math.max(0, client.maxPerHour - c.hour.count()), day: Math.max(0, client.maxPerDay - c.day.count()) },
57
+ global: { hour: Math.max(0, this.limits.maxPerHour - this.global.hour.count()), day: Math.max(0, this.limits.maxPerDay - this.global.day.count()) },
58
+ };
59
+ }
60
+ /** Throws `rate_limited` naming the exhausted window. */
61
+ check(clientId, client) {
62
+ const r = this.remaining(clientId, client);
63
+ if (r.client.hour <= 0)
64
+ throw new EmailError("rate_limited", `rate_limited: client "${clientId}" reached ${client.maxPerHour} emails in the last hour`);
65
+ if (r.client.day <= 0)
66
+ throw new EmailError("rate_limited", `rate_limited: client "${clientId}" reached ${client.maxPerDay} emails in the last 24 hours`);
67
+ if (r.global.hour <= 0)
68
+ throw new EmailError("rate_limited", `rate_limited: ${this.limits.maxPerHour} emails already sent in the last hour (EMAIL_MAX_PER_HOUR)`);
69
+ if (r.global.day <= 0)
70
+ throw new EmailError("rate_limited", `rate_limited: ${this.limits.maxPerDay} emails already sent in the last 24 hours (EMAIL_MAX_PER_DAY)`);
71
+ }
72
+ reserve(clientId) {
73
+ const c = this.of(clientId);
74
+ const releases = [c.hour.reserve(), c.day.reserve(), this.global.hour.reserve(), this.global.day.reserve()];
75
+ return () => releases.forEach((r) => r());
76
+ }
77
+ }