@aidalinfo/email-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +125 -0
- package/dist/audit.js +67 -0
- package/dist/config.js +102 -0
- package/dist/confirm.js +71 -0
- package/dist/errors.js +19 -0
- package/dist/index.js +27 -0
- package/dist/tem-client.js +76 -0
- package/dist/tools.js +212 -0
- package/package.json +54 -0
package/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# @aidalinfo/email-mcp
|
|
2
|
+
|
|
3
|
+
MCP server that lets an agent **send email** through **Scaleway Transactional Email (TEM)**, from
|
|
4
|
+
**one fixed sender** (e.g. `noreply@pulsemyit.fr`) to an **allowlist of recipients** only.
|
|
5
|
+
|
|
6
|
+
```text
|
|
7
|
+
Agent Pulse / Claude
|
|
8
|
+
│ MCP (stdio)
|
|
9
|
+
▼
|
|
10
|
+
@aidalinfo/email-mcp ── allowlist ── preview → human approval → confirm ── quota ── audit (JSON lines)
|
|
11
|
+
│ X-Auth-Token (dedicated IAM application)
|
|
12
|
+
▼
|
|
13
|
+
Scaleway TEM POST /transactional-email/v1alpha1/regions/{region}/emails
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Guarantees, all enforced server-side:
|
|
17
|
+
|
|
18
|
+
- **The sender is configuration, not an argument.** `EMAIL_FROM` / `EMAIL_FROM_NAME` / `EMAIL_REPLY_TO`
|
|
19
|
+
come from the environment; no tool parameter can change them.
|
|
20
|
+
- **Allowlisted recipients only.** `EMAIL_ALLOWED_RECIPIENTS` lists exact addresses and whole
|
|
21
|
+
domains (`@pulsemyit.fr` — not its subdomains). No wildcard. Empty → the server refuses to start.
|
|
22
|
+
A single recipient outside the list (in `to` or `cc`) refuses the whole email. There is **no bcc**.
|
|
23
|
+
- **Two steps.** Without `confirmToken`, `email_send` only **previews**: full headers, full text
|
|
24
|
+
body, HTML source, message digest — nothing is sent. The token it returns is an HMAC bound to the
|
|
25
|
+
**exact message** (sender, recipients, subject, bodies) and to the sender domain's state, valid
|
|
26
|
+
5 minutes, **single use** (a replayed token never sends twice), and only for this process.
|
|
27
|
+
- **Sender domain checked** before every preview and every send: it must exist in the configured
|
|
28
|
+
project and be `checked` (SPF + DKIM verified).
|
|
29
|
+
- **Limits:** recipients per email (`EMAIL_MAX_RECIPIENTS`, default 10, hard cap 50) and sends per
|
|
30
|
+
sliding hour (`EMAIL_MAX_PER_HOUR`, default 20, hard cap 500), per process.
|
|
31
|
+
- **Kill switch:** when `EMAIL_KILL_SWITCH_FILE` exists, every send is refused at once — checked
|
|
32
|
+
at the preview and again right before sending.
|
|
33
|
+
- **Audit:** one JSON line per preview / send / refusal / failure on stderr (and `EMAIL_AUDIT_LOG_FILE`):
|
|
34
|
+
sender, recipients, subject, message digest, TEM email ids, error code, Pulse approval id.
|
|
35
|
+
**Bodies are never logged** — the digest ties an entry to its preview.
|
|
36
|
+
- **No raw API tool, no attachments, no custom headers**: the server can make exactly three
|
|
37
|
+
Scaleway calls (find the sender domain, create an email, read an email).
|
|
38
|
+
- **Header injection:** bare addresses only (no display names), subjects with line breaks or
|
|
39
|
+
control characters are refused.
|
|
40
|
+
|
|
41
|
+
## Tools
|
|
42
|
+
|
|
43
|
+
| Tool | What |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `email_config` | Sender, Reply-To, sender domain status, allowed recipients, limits, quota left this hour, kill switch. **Start here.** |
|
|
46
|
+
| `email_send` | `to[]`, `cc[]?`, `subject`, `text` (required), `html?`, `approvalId?`, `confirmToken?`. Preview, then send with the token. |
|
|
47
|
+
| `email_status` | Delivery status of an email **sent by this sender** (TEM id from `email_send`): `new`, `sending`, `sent`, `failed`, `canceled`, with the last attempts. Other emails of the project (e.g. an application's) are reported `not_found`. |
|
|
48
|
+
|
|
49
|
+
### The two-step protocol
|
|
50
|
+
|
|
51
|
+
1. `email_send({ to, subject, text })` → preview + `confirmToken`. **Nothing is sent.**
|
|
52
|
+
2. Show the preview to the human (in Pulse Chat) and wait for an explicit approval.
|
|
53
|
+
3. `email_send({ to, subject, text, confirmToken, approvalId })` with the **same arguments** → sent.
|
|
54
|
+
Any change (one more recipient, a reworded sentence) → `confirmation_invalid`: preview again.
|
|
55
|
+
|
|
56
|
+
The token proves the message is the one previewed; it does **not** prove a human approved it
|
|
57
|
+
(the agent holds the token). The human approval is Pulse's own gate — `approvalId` is recorded
|
|
58
|
+
in the audit trail for correlation (`verified: false`), it is not an authorisation.
|
|
59
|
+
|
|
60
|
+
### Error codes
|
|
61
|
+
|
|
62
|
+
`invalid_request` · `recipient_not_allowed` · `domain_not_ready` · `rate_limited` · `refused`
|
|
63
|
+
(kill switch) · `confirmation_invalid` · `permission_missing` (API key lacks TEM rights) ·
|
|
64
|
+
`not_found` · `transient` · **`unknown_outcome`** — the send request was cut off (timeout, network,
|
|
65
|
+
5xx): the email **may have left**. The token is consumed and the quota slot kept; check
|
|
66
|
+
`email_status` or the TEM console before sending again — never retry blindly.
|
|
67
|
+
|
|
68
|
+
## Scaleway setup
|
|
69
|
+
|
|
70
|
+
1. **Domain:** Scaleway console → Transactional Email → add `pulsemyit.fr` in the chosen project
|
|
71
|
+
and region, publish the SPF, DKIM (and MX/DMARC recommended) records, wait for status
|
|
72
|
+
**`checked`**. `email_config` (or `list_tem_domains` of `@aidalinfo/scaleway-mcp`) shows it.
|
|
73
|
+
2. **Identity:** IAM → create a **dedicated application** (e.g. `email-mcp`), a policy granting it
|
|
74
|
+
Transactional Email permissions **scoped to that project only**, and an API key for it. Do not
|
|
75
|
+
reuse a personal key or the key of another MCP.
|
|
76
|
+
3. **Allowlist:** start narrow (`@pulsemyit.fr,@aidalinfo.fr`), add external addresses one by one.
|
|
77
|
+
|
|
78
|
+
## Configuration
|
|
79
|
+
|
|
80
|
+
See [`.env.example`](.env.example). Every missing or ambiguous setting stops the server at
|
|
81
|
+
startup (fail-closed) instead of guessing.
|
|
82
|
+
|
|
83
|
+
| Variable | Required | Default | |
|
|
84
|
+
|---|---|---|---|
|
|
85
|
+
| `SCW_SECRET_KEY` | yes | | Secret key of the dedicated IAM application |
|
|
86
|
+
| `EMAIL_PROJECT_ID` | yes | `SCW_DEFAULT_PROJECT_ID` | Project of the TEM domain |
|
|
87
|
+
| `EMAIL_REGION` | | `fr-par` | `fr-par`, `nl-ams`, `pl-waw` |
|
|
88
|
+
| `EMAIL_FROM` | yes | | Bare address, e.g. `noreply@pulsemyit.fr` |
|
|
89
|
+
| `EMAIL_FROM_NAME` | | | Display name, e.g. `Pulse myIT` |
|
|
90
|
+
| `EMAIL_REPLY_TO` | | | Fixed Reply-To (sent as an additional header) |
|
|
91
|
+
| `EMAIL_ALLOWED_RECIPIENTS` | yes | | `a@x.fr,@domain.fr` — no wildcard |
|
|
92
|
+
| `EMAIL_MAX_RECIPIENTS` | | 10 | to + cc, hard cap 50 |
|
|
93
|
+
| `EMAIL_MAX_PER_HOUR` | | 20 | sliding hour, per process, hard cap 500 |
|
|
94
|
+
| `EMAIL_KILL_SWITCH_FILE` | | | `touch` it to stop all sends |
|
|
95
|
+
| `EMAIL_AUDIT_LOG_FILE` | | | JSON lines, mode 600 |
|
|
96
|
+
|
|
97
|
+
## Register in Claude Code
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
claude mcp add email --scope user \
|
|
101
|
+
-e SCW_SECRET_KEY=... \
|
|
102
|
+
-e EMAIL_PROJECT_ID=<project-uuid> \
|
|
103
|
+
-e EMAIL_FROM=noreply@pulsemyit.fr -e EMAIL_FROM_NAME="Pulse myIT" \
|
|
104
|
+
-e EMAIL_ALLOWED_RECIPIENTS=@pulsemyit.fr,@aidalinfo.fr \
|
|
105
|
+
-e EMAIL_KILL_SWITCH_FILE=$HOME/.email-mcp.stop \
|
|
106
|
+
-- npx -y @aidalinfo/email-mcp
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Emergency stop, no restart needed:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
touch ~/.email-mcp.stop
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Development
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
npm install # at the repo root (npm workspaces)
|
|
119
|
+
npm test -w @aidalinfo/email-mcp # tsc + jest (TEM is faked, nothing is sent)
|
|
120
|
+
npm run build -w @aidalinfo/email-mcp
|
|
121
|
+
node packages/email-mcp/smoke.mjs # starts the server over stdio and lists its tools; SMOKE_CALL_CONFIG=1 also calls email_config
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`smoke.mjs` never sends anything. To test a real send, register the server with an allowlist
|
|
125
|
+
holding only your own address and go through the preview → confirm flow.
|
package/dist/audit.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { appendFileSync } from "node:fs";
|
|
2
|
+
/**
|
|
3
|
+
* One JSON object per line on stderr (stdout is the MCP transport) and, optionally, appended to
|
|
4
|
+
* EMAIL_AUDIT_LOG_FILE. Bodies are never logged: the digest is enough to match an entry to its preview.
|
|
5
|
+
*/
|
|
6
|
+
export class AuditLogger {
|
|
7
|
+
file;
|
|
8
|
+
sink;
|
|
9
|
+
now;
|
|
10
|
+
entries = [];
|
|
11
|
+
constructor(file, sink = (l) => process.stderr.write(`${l}\n`), now = () => new Date()) {
|
|
12
|
+
this.file = file;
|
|
13
|
+
this.sink = sink;
|
|
14
|
+
this.now = now;
|
|
15
|
+
}
|
|
16
|
+
log(e) {
|
|
17
|
+
const { approvalId, ...rest } = e;
|
|
18
|
+
const entry = {
|
|
19
|
+
timestamp: this.now().toISOString(),
|
|
20
|
+
...rest,
|
|
21
|
+
approvalRef: approvalId ? { id: approvalId, verified: false } : undefined,
|
|
22
|
+
};
|
|
23
|
+
const line = JSON.stringify(entry);
|
|
24
|
+
this.sink(line);
|
|
25
|
+
if (this.file) {
|
|
26
|
+
try {
|
|
27
|
+
appendFileSync(this.file, `${line}\n`, { mode: 0o600 });
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
process.stderr.write(`⚠ audit log file not writable (${err.code ?? "error"}) — entry kept on stderr only\n`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
this.entries.push(entry);
|
|
34
|
+
if (this.entries.length > 200)
|
|
35
|
+
this.entries.shift();
|
|
36
|
+
return entry;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Sliding one-hour window of sends, per process. */
|
|
40
|
+
export class RateLimiter {
|
|
41
|
+
maxPerHour;
|
|
42
|
+
now;
|
|
43
|
+
sends = [];
|
|
44
|
+
constructor(maxPerHour, now = Date.now) {
|
|
45
|
+
this.maxPerHour = maxPerHour;
|
|
46
|
+
this.now = now;
|
|
47
|
+
}
|
|
48
|
+
prune() {
|
|
49
|
+
const floor = this.now() - 3_600_000;
|
|
50
|
+
while (this.sends.length && this.sends[0] <= floor)
|
|
51
|
+
this.sends.shift();
|
|
52
|
+
}
|
|
53
|
+
remaining() {
|
|
54
|
+
this.prune();
|
|
55
|
+
return Math.max(0, this.maxPerHour - this.sends.length);
|
|
56
|
+
}
|
|
57
|
+
/** Takes a slot; returns a function giving it back (for a send that certainly did not leave). */
|
|
58
|
+
reserve() {
|
|
59
|
+
const t = this.now();
|
|
60
|
+
this.sends.push(t);
|
|
61
|
+
return () => {
|
|
62
|
+
const i = this.sends.lastIndexOf(t);
|
|
63
|
+
if (i >= 0)
|
|
64
|
+
this.sends.splice(i, 1);
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
export const REGIONS = ["fr-par", "nl-ams", "pl-waw"];
|
|
2
|
+
export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
3
|
+
const DOMAIN = "[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+";
|
|
4
|
+
const DOMAIN_RE = new RegExp(`^${DOMAIN}$`, "i");
|
|
5
|
+
/** Bare address only: no display name, no comment, no quoted local part. */
|
|
6
|
+
const ADDRESS_RE = new RegExp(`^[a-z0-9.!#$%&'*+/=?^_\`{|}~-]{1,64}@${DOMAIN}$`, "i");
|
|
7
|
+
/** Lower-cased address, or null when it is not a plain, sane address. */
|
|
8
|
+
export function normalizeAddress(raw) {
|
|
9
|
+
const a = raw.trim().toLowerCase();
|
|
10
|
+
if (a.length > 254 || !ADDRESS_RE.test(a))
|
|
11
|
+
return null;
|
|
12
|
+
const local = a.slice(0, a.indexOf("@"));
|
|
13
|
+
if (local.startsWith(".") || local.endsWith(".") || local.includes(".."))
|
|
14
|
+
return null;
|
|
15
|
+
return a;
|
|
16
|
+
}
|
|
17
|
+
export const domainOf = (address) => address.slice(address.lastIndexOf("@") + 1);
|
|
18
|
+
export function isAllowed(list, address) {
|
|
19
|
+
return list.addresses.has(address) || list.domains.has(domainOf(address));
|
|
20
|
+
}
|
|
21
|
+
export const DEFAULT_MAX_RECIPIENTS = 10;
|
|
22
|
+
export const HARD_MAX_RECIPIENTS = 50;
|
|
23
|
+
export const DEFAULT_MAX_PER_HOUR = 20;
|
|
24
|
+
export const HARD_MAX_PER_HOUR = 500;
|
|
25
|
+
const get = (env, k) => env[k]?.trim() || undefined;
|
|
26
|
+
function intIn(raw, name, def, max, warnings) {
|
|
27
|
+
if (raw === undefined)
|
|
28
|
+
return def;
|
|
29
|
+
const n = Number(raw);
|
|
30
|
+
if (!Number.isInteger(n) || n < 1)
|
|
31
|
+
throw new Error(`${name} must be a positive integer (got "${raw}")`);
|
|
32
|
+
if (n > max) {
|
|
33
|
+
warnings.push(`⚠ ${name}=${n} is above the hard limit — capped to ${max}`);
|
|
34
|
+
return max;
|
|
35
|
+
}
|
|
36
|
+
return n;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Fail-closed: any missing or ambiguous setting stops the server at startup instead of
|
|
40
|
+
* guessing. The sender and the allowlist come from here only — no tool argument can change them.
|
|
41
|
+
*/
|
|
42
|
+
export function loadConfig(env) {
|
|
43
|
+
const warnings = [];
|
|
44
|
+
const secretKey = get(env, "SCW_SECRET_KEY");
|
|
45
|
+
if (!secretKey)
|
|
46
|
+
throw new Error("Missing required env var: SCW_SECRET_KEY");
|
|
47
|
+
const projectId = get(env, "EMAIL_PROJECT_ID") ?? get(env, "SCW_DEFAULT_PROJECT_ID");
|
|
48
|
+
if (!projectId)
|
|
49
|
+
throw new Error("Missing required env var: EMAIL_PROJECT_ID (Scaleway project holding the TEM domain)");
|
|
50
|
+
if (!UUID_RE.test(projectId))
|
|
51
|
+
throw new Error(`EMAIL_PROJECT_ID is not a Scaleway project id: "${projectId}"`);
|
|
52
|
+
const region = (get(env, "EMAIL_REGION") ?? "fr-par");
|
|
53
|
+
if (!REGIONS.includes(region))
|
|
54
|
+
throw new Error(`EMAIL_REGION must be one of ${REGIONS.join(", ")} (got "${region}")`);
|
|
55
|
+
const fromRaw = get(env, "EMAIL_FROM");
|
|
56
|
+
if (!fromRaw)
|
|
57
|
+
throw new Error("Missing required env var: EMAIL_FROM (e.g. noreply@pulsemyit.fr)");
|
|
58
|
+
const fromEmail = normalizeAddress(fromRaw);
|
|
59
|
+
if (!fromEmail)
|
|
60
|
+
throw new Error(`EMAIL_FROM is not a plain email address: "${fromRaw}" (put the display name in EMAIL_FROM_NAME)`);
|
|
61
|
+
const fromName = get(env, "EMAIL_FROM_NAME");
|
|
62
|
+
if (fromName && (fromName.length > 100 || /[\u0000-\u001f\u007f"<>]/.test(fromName))) {
|
|
63
|
+
throw new Error("EMAIL_FROM_NAME must be ≤ 100 characters without control characters, quotes or angle brackets");
|
|
64
|
+
}
|
|
65
|
+
const replyToRaw = get(env, "EMAIL_REPLY_TO");
|
|
66
|
+
const replyTo = replyToRaw ? normalizeAddress(replyToRaw) : undefined;
|
|
67
|
+
if (replyToRaw && !replyTo)
|
|
68
|
+
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) {
|
|
87
|
+
throw new Error("EMAIL_ALLOWED_RECIPIENTS is empty — list the addresses and/or @domains the agent may write to");
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
secretKey,
|
|
91
|
+
projectId: projectId.toLowerCase(),
|
|
92
|
+
region,
|
|
93
|
+
from: { email: fromEmail, name: fromName },
|
|
94
|
+
replyTo: replyTo ?? undefined,
|
|
95
|
+
allowlist: { addresses, domains },
|
|
96
|
+
maxRecipients: intIn(get(env, "EMAIL_MAX_RECIPIENTS"), "EMAIL_MAX_RECIPIENTS", DEFAULT_MAX_RECIPIENTS, HARD_MAX_RECIPIENTS, warnings),
|
|
97
|
+
maxPerHour: intIn(get(env, "EMAIL_MAX_PER_HOUR"), "EMAIL_MAX_PER_HOUR", DEFAULT_MAX_PER_HOUR, HARD_MAX_PER_HOUR, warnings),
|
|
98
|
+
killSwitchFile: get(env, "EMAIL_KILL_SWITCH_FILE"),
|
|
99
|
+
auditFile: get(env, "EMAIL_AUDIT_LOG_FILE"),
|
|
100
|
+
warnings,
|
|
101
|
+
};
|
|
102
|
+
}
|
package/dist/confirm.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { EmailError } from "./errors.js";
|
|
3
|
+
export const CONFIRM_TTL_MS = 5 * 60_000;
|
|
4
|
+
/** JSON with sorted keys, so the same message always hashes the same. */
|
|
5
|
+
export function stableStringify(v) {
|
|
6
|
+
if (Array.isArray(v))
|
|
7
|
+
return `[${v.map(stableStringify).join(",")}]`;
|
|
8
|
+
if (v && typeof v === "object") {
|
|
9
|
+
return `{${Object.keys(v)
|
|
10
|
+
.sort()
|
|
11
|
+
.filter((k) => v[k] !== undefined)
|
|
12
|
+
.map((k) => `${JSON.stringify(k)}:${stableStringify(v[k])}`)
|
|
13
|
+
.join(",")}}`;
|
|
14
|
+
}
|
|
15
|
+
return JSON.stringify(v ?? null);
|
|
16
|
+
}
|
|
17
|
+
export const digest = (v) => createHash("sha256").update(stableStringify(v)).digest("base64url");
|
|
18
|
+
/**
|
|
19
|
+
* Two-step confirmation tokens, same scheme as @aidalinfo/m365-mcp: the preview issues a short-lived
|
|
20
|
+
* HMAC token (per-process random key) bound to the tool, the sender state and the full message;
|
|
21
|
+
* the send must present it with the same arguments. Single use — a replayed token never sends twice.
|
|
22
|
+
*/
|
|
23
|
+
export class Confirmations {
|
|
24
|
+
now;
|
|
25
|
+
key;
|
|
26
|
+
used = new Map();
|
|
27
|
+
constructor(now = Date.now, key) {
|
|
28
|
+
this.now = now;
|
|
29
|
+
this.key = key ?? randomBytes(32);
|
|
30
|
+
}
|
|
31
|
+
issue(b) {
|
|
32
|
+
const exp = this.now() + CONFIRM_TTL_MS;
|
|
33
|
+
const nonce = randomBytes(6).toString("base64url");
|
|
34
|
+
const token = `c1.${exp.toString(36)}.${nonce}.${this.mac(b, exp, nonce)}`;
|
|
35
|
+
return { token, expiresAt: new Date(exp).toISOString() };
|
|
36
|
+
}
|
|
37
|
+
/** Throws `confirmation_invalid` unless the token was issued for exactly this binding and is still valid. */
|
|
38
|
+
verify(token, b) {
|
|
39
|
+
const m = /^c1\.([0-9a-z]+)\.([\w-]+)\.([\w-]+)$/.exec(token);
|
|
40
|
+
if (!m)
|
|
41
|
+
throw new EmailError("confirmation_invalid", "confirmation_invalid: malformed confirmation token");
|
|
42
|
+
const exp = parseInt(m[1], 36);
|
|
43
|
+
// one canonical spelling only: "0abc" and "abc" decode to the same expiry and must not be two tokens
|
|
44
|
+
if (!Number.isSafeInteger(exp) || exp.toString(36) !== m[1]) {
|
|
45
|
+
throw new EmailError("confirmation_invalid", "confirmation_invalid: malformed confirmation token");
|
|
46
|
+
}
|
|
47
|
+
const expected = Buffer.from(this.mac(b, exp, m[2]));
|
|
48
|
+
const given = Buffer.from(m[3]);
|
|
49
|
+
if (expected.length !== given.length || !timingSafeEqual(expected, given)) {
|
|
50
|
+
throw new EmailError("confirmation_invalid", "confirmation_invalid: the message, its recipients or the sender state differ from the preview — run the preview again");
|
|
51
|
+
}
|
|
52
|
+
if (this.now() > exp)
|
|
53
|
+
throw new EmailError("confirmation_invalid", "confirmation_invalid: the confirmation token expired — run the preview again");
|
|
54
|
+
// replay protection keyed by the MAC (unique per issued token), not by the string received
|
|
55
|
+
if (this.used.has(m[3]))
|
|
56
|
+
throw new EmailError("confirmation_invalid", "confirmation_invalid: this confirmation token was already used");
|
|
57
|
+
this.gc();
|
|
58
|
+
this.used.set(m[3], exp);
|
|
59
|
+
}
|
|
60
|
+
mac(b, exp, nonce) {
|
|
61
|
+
return createHmac("sha256", this.key)
|
|
62
|
+
.update(stableStringify({ tool: b.tool, state: digest(b.state), plan: digest(b.plan), exp, nonce }))
|
|
63
|
+
.digest("base64url");
|
|
64
|
+
}
|
|
65
|
+
gc() {
|
|
66
|
+
const now = this.now();
|
|
67
|
+
for (const [t, exp] of this.used)
|
|
68
|
+
if (exp < now)
|
|
69
|
+
this.used.delete(t);
|
|
70
|
+
}
|
|
71
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export class EmailError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
status;
|
|
4
|
+
constructor(code, message, status) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.name = "EmailError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function toEmailError(err) {
|
|
12
|
+
if (err instanceof EmailError)
|
|
13
|
+
return err;
|
|
14
|
+
return new EmailError("unexpected", `unexpected: ${err instanceof Error ? err.message : String(err)}`);
|
|
15
|
+
}
|
|
16
|
+
export function errorResult(err) {
|
|
17
|
+
const e = toEmailError(err);
|
|
18
|
+
return { isError: true, content: [{ type: "text", text: e.message.startsWith(`${e.code}:`) ? e.message : `${e.code}: ${e.message}` }] };
|
|
19
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import { loadConfig } from "./config.js";
|
|
6
|
+
import { TemClient } from "./tem-client.js";
|
|
7
|
+
import { createContext, registerTools, SERVER_INSTRUCTIONS } from "./tools.js";
|
|
8
|
+
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.
|
|
10
|
+
const log = (msg) => process.stderr.write(`${msg}\n`);
|
|
11
|
+
try {
|
|
12
|
+
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);
|
|
16
|
+
for (const w of config.warnings)
|
|
17
|
+
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"})`);
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
log(`email-mcp: ${err instanceof Error ? err.message : String(err)}`);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { EmailError } from "./errors.js";
|
|
2
|
+
const API_BASE = "https://api.scaleway.com";
|
|
3
|
+
export const TIMEOUT_MS = 30_000;
|
|
4
|
+
export function redactSecret(text, secret) {
|
|
5
|
+
return (secret.length >= 8 ? text.split(secret).join("[REDACTED]") : text).replace(/(x-auth-token["']?\s*[:=]\s*["']?)[^"'\s,}]+/gi, "$1[REDACTED]");
|
|
6
|
+
}
|
|
7
|
+
function httpError(status, what, body) {
|
|
8
|
+
const detail = body.slice(0, 300);
|
|
9
|
+
if (status === 401 || status === 403) {
|
|
10
|
+
return new EmailError("permission_missing", `permission_missing: ${what} → ${status} — the API key needs Transactional Email permissions on the project (${detail})`, status);
|
|
11
|
+
}
|
|
12
|
+
if (status === 404)
|
|
13
|
+
return new EmailError("not_found", `not_found: ${what} → 404`, status);
|
|
14
|
+
if (status === 429 || status >= 500)
|
|
15
|
+
return new EmailError("transient", `transient: ${what} → ${status} — retry later (${detail})`, status);
|
|
16
|
+
return new EmailError("invalid_request", `invalid_request: ${what} → ${status}: ${detail}`, status);
|
|
17
|
+
}
|
|
18
|
+
export class TemClient {
|
|
19
|
+
secretKey;
|
|
20
|
+
projectId;
|
|
21
|
+
region;
|
|
22
|
+
fetchImpl;
|
|
23
|
+
constructor(secretKey, projectId, region, fetchImpl = fetch) {
|
|
24
|
+
this.secretKey = secretKey;
|
|
25
|
+
this.projectId = projectId;
|
|
26
|
+
this.region = region;
|
|
27
|
+
this.fetchImpl = fetchImpl;
|
|
28
|
+
}
|
|
29
|
+
get base() {
|
|
30
|
+
return `${API_BASE}/transactional-email/v1alpha1/regions/${this.region}`;
|
|
31
|
+
}
|
|
32
|
+
async call(method, url, what, body) {
|
|
33
|
+
let res;
|
|
34
|
+
try {
|
|
35
|
+
res = await this.fetchImpl(url, {
|
|
36
|
+
method,
|
|
37
|
+
headers: {
|
|
38
|
+
"X-Auth-Token": this.secretKey,
|
|
39
|
+
Accept: "application/json",
|
|
40
|
+
...(body ? { "Content-Type": "application/json" } : {}),
|
|
41
|
+
},
|
|
42
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
43
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
const msg = redactSecret(err instanceof Error ? err.message : String(err), this.secretKey);
|
|
48
|
+
// a POST cut off in flight may have been accepted: never let the caller assume it was not
|
|
49
|
+
if (method === "POST") {
|
|
50
|
+
throw new EmailError("unknown_outcome", `unknown_outcome: ${what} was interrupted (${msg}) — the email may have been sent; check the TEM console before sending again`);
|
|
51
|
+
}
|
|
52
|
+
throw new EmailError("transient", `transient: ${what} failed (${msg})`);
|
|
53
|
+
}
|
|
54
|
+
const text = await res.text();
|
|
55
|
+
if (!res.ok) {
|
|
56
|
+
const e = httpError(res.status, what, redactSecret(text, this.secretKey));
|
|
57
|
+
if (method === "POST" && res.status >= 500) {
|
|
58
|
+
throw new EmailError("unknown_outcome", `unknown_outcome: ${what} → ${res.status} — the email may have been sent; check the TEM console before sending again`, res.status);
|
|
59
|
+
}
|
|
60
|
+
throw e;
|
|
61
|
+
}
|
|
62
|
+
return (text ? JSON.parse(text) : {});
|
|
63
|
+
}
|
|
64
|
+
async findDomain(name) {
|
|
65
|
+
const qs = new URLSearchParams({ project_id: this.projectId, name, page_size: "10" });
|
|
66
|
+
const res = await this.call("GET", `${this.base}/domains?${qs}`, "list TEM domains");
|
|
67
|
+
return (res.domains ?? []).find((d) => d.name.toLowerCase() === name && d.project_id === this.projectId) ?? null;
|
|
68
|
+
}
|
|
69
|
+
async createEmail(body) {
|
|
70
|
+
const res = await this.call("POST", `${this.base}/emails`, "send email", body);
|
|
71
|
+
return res.emails ?? [];
|
|
72
|
+
}
|
|
73
|
+
async getEmail(id) {
|
|
74
|
+
return this.call("GET", `${this.base}/emails/${encodeURIComponent(id)}`, "get email");
|
|
75
|
+
}
|
|
76
|
+
}
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { domainOf, isAllowed, normalizeAddress, UUID_RE } from "./config.js";
|
|
4
|
+
import { Confirmations, digest } from "./confirm.js";
|
|
5
|
+
import { AuditLogger, RateLimiter } from "./audit.js";
|
|
6
|
+
import { EmailError, errorResult, toEmailError } from "./errors.js";
|
|
7
|
+
export const MAX_SUBJECT = 200;
|
|
8
|
+
export const MAX_TEXT = 100_000;
|
|
9
|
+
export const MAX_HTML = 200_000;
|
|
10
|
+
/** Above this, the preview shows the start of the HTML only (the digest still covers all of it). */
|
|
11
|
+
const PREVIEW_HTML = 20_000;
|
|
12
|
+
export const SERVER_INSTRUCTIONS = "Sends email from ONE fixed sender (set by the operator, not by you) to an allowlist of recipients, through Scaleway Transactional Email. " +
|
|
13
|
+
"Call email_config first to see the sender, the allowed recipients and the remaining hourly quota. " +
|
|
14
|
+
"email_send works in two steps: without confirmToken it only previews (nothing is sent) and returns a token; show the preview to the human, " +
|
|
15
|
+
"and only after their explicit approval call email_send again with the SAME arguments plus confirmToken. Never confirm on your own initiative. " +
|
|
16
|
+
"Recipients outside the allowlist are refused — do not try to work around it.";
|
|
17
|
+
export function createContext(config, tem) {
|
|
18
|
+
return {
|
|
19
|
+
config,
|
|
20
|
+
tem,
|
|
21
|
+
confirmations: new Confirmations(),
|
|
22
|
+
audit: new AuditLogger(config.auditFile),
|
|
23
|
+
limiter: new RateLimiter(config.maxPerHour),
|
|
24
|
+
killSwitchActive: () => !!config.killSwitchFile && existsSync(config.killSwitchFile),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
const text = (t) => ({ content: [{ type: "text", text: t }] });
|
|
28
|
+
const json = (v) => "```json\n" + JSON.stringify(v, null, 2) + "\n```";
|
|
29
|
+
const fence = (s) => {
|
|
30
|
+
const ticks = "`".repeat(Math.max(3, ...[...s.matchAll(/`+/g)].map((m) => m[0].length + 1)));
|
|
31
|
+
return `${ticks}\n${s}\n${ticks}`;
|
|
32
|
+
};
|
|
33
|
+
/** Validates the arguments into the exact message that will be sent. Throws on any guard. */
|
|
34
|
+
export function buildMessage(config, a) {
|
|
35
|
+
const seen = new Set();
|
|
36
|
+
const norm = (list, field) => list.map((raw) => {
|
|
37
|
+
const addr = normalizeAddress(raw);
|
|
38
|
+
if (!addr)
|
|
39
|
+
throw new EmailError("invalid_request", `invalid_request: ${field} contains an invalid address "${raw.slice(0, 100)}" (bare addresses only, no display name)`);
|
|
40
|
+
if (seen.has(addr))
|
|
41
|
+
throw new EmailError("invalid_request", `invalid_request: ${addr} appears more than once`);
|
|
42
|
+
seen.add(addr);
|
|
43
|
+
return addr;
|
|
44
|
+
});
|
|
45
|
+
const to = norm(a.to, "to");
|
|
46
|
+
const cc = norm(a.cc ?? [], "cc");
|
|
47
|
+
if (!to.length)
|
|
48
|
+
throw new EmailError("invalid_request", "invalid_request: at least one recipient in to");
|
|
49
|
+
if (seen.size > config.maxRecipients) {
|
|
50
|
+
throw new EmailError("invalid_request", `invalid_request: ${seen.size} recipients, the maximum is ${config.maxRecipients} (EMAIL_MAX_RECIPIENTS)`);
|
|
51
|
+
}
|
|
52
|
+
const denied = [...seen].filter((r) => !isAllowed(config.allowlist, r));
|
|
53
|
+
if (denied.length) {
|
|
54
|
+
throw new EmailError("recipient_not_allowed", `recipient_not_allowed: ${denied.join(", ")} not in EMAIL_ALLOWED_RECIPIENTS — nothing was sent`);
|
|
55
|
+
}
|
|
56
|
+
if (!a.subject.trim())
|
|
57
|
+
throw new EmailError("invalid_request", "invalid_request: empty subject");
|
|
58
|
+
// a line break in a header value is how header injection starts
|
|
59
|
+
if (/[\u0000-\u001f\u007f]/.test(a.subject))
|
|
60
|
+
throw new EmailError("invalid_request", "invalid_request: the subject must not contain line breaks or control characters");
|
|
61
|
+
if (!a.text.trim())
|
|
62
|
+
throw new EmailError("invalid_request", "invalid_request: empty text body (a plain-text version is always required)");
|
|
63
|
+
if (a.html !== undefined && !a.html.trim())
|
|
64
|
+
throw new EmailError("invalid_request", "invalid_request: html is empty — omit it instead");
|
|
65
|
+
return { from: config.from, replyTo: config.replyTo, to, cc, subject: a.subject, text: a.text, html: a.html };
|
|
66
|
+
}
|
|
67
|
+
function header(m) {
|
|
68
|
+
const from = m.from.name ? `${m.from.name} <${m.from.email}>` : m.from.email;
|
|
69
|
+
return [`From: ${from}`, m.replyTo ? `Reply-To: ${m.replyTo}` : "", `To: ${m.to.join(", ")}`, m.cc.length ? `Cc: ${m.cc.join(", ")}` : "", `Subject: ${m.subject}`]
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
.join("\n");
|
|
72
|
+
}
|
|
73
|
+
export async function sendEmail(ctx, a) {
|
|
74
|
+
const tool = "email_send";
|
|
75
|
+
const { config } = ctx;
|
|
76
|
+
const base = { tool, from: config.from.email, approvalId: a.approvalId };
|
|
77
|
+
let msg;
|
|
78
|
+
let messageDigest;
|
|
79
|
+
try {
|
|
80
|
+
if (ctx.killSwitchActive())
|
|
81
|
+
throw new EmailError("refused", `refused: sending is disabled by the kill switch (${config.killSwitchFile} exists)`);
|
|
82
|
+
msg = buildMessage(config, a);
|
|
83
|
+
messageDigest = digest(msg);
|
|
84
|
+
const domain = await ctx.tem.findDomain(domainOf(config.from.email));
|
|
85
|
+
if (!domain) {
|
|
86
|
+
throw new EmailError("domain_not_ready", `domain_not_ready: ${domainOf(config.from.email)} is not a TEM domain of project ${config.projectId} (${config.region})`);
|
|
87
|
+
}
|
|
88
|
+
if (domain.status !== "checked") {
|
|
89
|
+
throw new EmailError("domain_not_ready", `domain_not_ready: TEM domain ${domain.name} is "${domain.status}", it must be "checked" (SPF/DKIM verified) before sending`);
|
|
90
|
+
}
|
|
91
|
+
if (ctx.limiter.remaining() <= 0) {
|
|
92
|
+
throw new EmailError("rate_limited", `rate_limited: ${config.maxPerHour} emails already sent in the last hour (EMAIL_MAX_PER_HOUR)`);
|
|
93
|
+
}
|
|
94
|
+
const binding = { tool, state: { domainId: domain.id, status: domain.status }, plan: msg };
|
|
95
|
+
const who = { to: msg.to, cc: msg.cc.length ? msg.cc : undefined, subject: msg.subject, messageDigest };
|
|
96
|
+
if (!a.confirmToken) {
|
|
97
|
+
const { token, expiresAt } = ctx.confirmations.issue(binding);
|
|
98
|
+
ctx.audit.log({ ...base, ...who, result: "preview" });
|
|
99
|
+
const html = msg.html === undefined
|
|
100
|
+
? ""
|
|
101
|
+
: `\n\nHTML body (${msg.html.length} characters${msg.html.length > PREVIEW_HTML ? `, first ${PREVIEW_HTML} shown` : ""}):\n${fence(msg.html.slice(0, PREVIEW_HTML))}`;
|
|
102
|
+
return text(`# Preview — ${tool} (nothing sent yet)\n\n${fence(header(msg))}\n\nText body:\n${fence(msg.text)}${html}\n\n` +
|
|
103
|
+
`Message digest: sha256:${messageDigest} · quota left this hour: ${ctx.limiter.remaining()}/${config.maxPerHour}\n\n` +
|
|
104
|
+
`Show this preview to the human. Only after their explicit approval, call ${tool} again with the same arguments and ` +
|
|
105
|
+
`confirmToken: "${token}" (valid until ${expiresAt}, single use).`);
|
|
106
|
+
}
|
|
107
|
+
ctx.confirmations.verify(a.confirmToken, binding);
|
|
108
|
+
// the switch may have been set while the human was reading the preview
|
|
109
|
+
if (ctx.killSwitchActive())
|
|
110
|
+
throw new EmailError("refused", `refused: sending is disabled by the kill switch (${config.killSwitchFile} exists)`);
|
|
111
|
+
const release = ctx.limiter.reserve();
|
|
112
|
+
let emails;
|
|
113
|
+
try {
|
|
114
|
+
emails = await ctx.tem.createEmail({
|
|
115
|
+
from: msg.from,
|
|
116
|
+
to: msg.to.map((email) => ({ email })),
|
|
117
|
+
cc: msg.cc.map((email) => ({ email })),
|
|
118
|
+
subject: msg.subject,
|
|
119
|
+
text: msg.text,
|
|
120
|
+
html: msg.html,
|
|
121
|
+
project_id: config.projectId,
|
|
122
|
+
additional_headers: msg.replyTo ? [{ key: "Reply-To", value: msg.replyTo }] : undefined,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
// a send that may have left keeps its quota slot
|
|
127
|
+
if (toEmailError(err).code !== "unknown_outcome")
|
|
128
|
+
release();
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
const ids = emails.map((e) => e.id);
|
|
132
|
+
ctx.audit.log({ ...base, ...who, result: "sent", temEmailIds: ids });
|
|
133
|
+
return text(`# ${tool} — accepted by Scaleway TEM\n\n${fence(header(msg))}\n\n` +
|
|
134
|
+
json(emails.map((e) => ({ id: e.id, rcptTo: e.rcpt_to, rcptType: e.rcpt_type, status: e.status }))) +
|
|
135
|
+
`\n\nDelivery is asynchronous: check it with email_status. Quota left this hour: ${ctx.limiter.remaining()}/${config.maxPerHour}.`);
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
const e = toEmailError(err);
|
|
139
|
+
const result = e.code === "unknown_outcome" ? "unknown_outcome" : ["invalid_request", "recipient_not_allowed", "domain_not_ready", "rate_limited", "refused", "confirmation_invalid"].includes(e.code) ? "refused" : "failed";
|
|
140
|
+
ctx.audit.log({ ...base, to: msg?.to, cc: msg?.cc.length ? msg.cc : undefined, subject: msg?.subject, messageDigest, result, errorCode: e.code });
|
|
141
|
+
return errorResult(e);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const addressOf = (s) => (/<([^>]+)>/.exec(s)?.[1] ?? s).trim().toLowerCase();
|
|
145
|
+
export async function emailStatus(ctx, emailId) {
|
|
146
|
+
try {
|
|
147
|
+
if (!UUID_RE.test(emailId))
|
|
148
|
+
throw new EmailError("invalid_request", "invalid_request: emailId must be a TEM email id (UUID) as returned by email_send");
|
|
149
|
+
const e = await ctx.tem.getEmail(emailId.toLowerCase());
|
|
150
|
+
// only this server's own sends: the project may also carry other applications' mail
|
|
151
|
+
if (e.project_id !== ctx.config.projectId || addressOf(e.mail_from ?? "") !== ctx.config.from.email) {
|
|
152
|
+
throw new EmailError("not_found", "not_found: no email with this id was sent by this sender");
|
|
153
|
+
}
|
|
154
|
+
return text(json({
|
|
155
|
+
id: e.id,
|
|
156
|
+
status: e.status,
|
|
157
|
+
statusDetails: e.status_details || undefined,
|
|
158
|
+
rcptTo: e.rcpt_to,
|
|
159
|
+
rcptType: e.rcpt_type,
|
|
160
|
+
subject: e.subject,
|
|
161
|
+
createdAt: e.created_at,
|
|
162
|
+
updatedAt: e.updated_at,
|
|
163
|
+
tryCount: e.try_count,
|
|
164
|
+
lastTries: e.last_tries?.map((t) => ({ triedAt: t.tried_at, code: t.code, message: t.message })),
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
return errorResult(err);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
export async function emailConfig(ctx) {
|
|
172
|
+
const c = ctx.config;
|
|
173
|
+
let domain;
|
|
174
|
+
try {
|
|
175
|
+
const d = await ctx.tem.findDomain(domainOf(c.from.email));
|
|
176
|
+
domain = d ? { name: d.name, status: d.status, readyToSend: d.status === "checked" } : { name: domainOf(c.from.email), status: "not found in project" };
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
domain = { name: domainOf(c.from.email), error: toEmailError(err).message };
|
|
180
|
+
}
|
|
181
|
+
return text(json({
|
|
182
|
+
sender: c.from.name ? `${c.from.name} <${c.from.email}>` : c.from.email,
|
|
183
|
+
replyTo: c.replyTo,
|
|
184
|
+
domain,
|
|
185
|
+
provider: { name: "Scaleway Transactional Email", region: c.region, projectId: c.projectId },
|
|
186
|
+
allowedRecipients: { addresses: [...c.allowlist.addresses], domains: [...c.allowlist.domains].map((d) => `@${d}`) },
|
|
187
|
+
limits: { maxRecipientsPerEmail: c.maxRecipients, maxPerHour: c.maxPerHour, remainingThisHour: ctx.limiter.remaining(), subjectChars: MAX_SUBJECT, textChars: MAX_TEXT, htmlChars: MAX_HTML },
|
|
188
|
+
killSwitch: c.killSwitchFile ? { file: c.killSwitchFile, active: ctx.killSwitchActive() } : "not configured",
|
|
189
|
+
}));
|
|
190
|
+
}
|
|
191
|
+
const address = z.string().min(3).max(254);
|
|
192
|
+
export function registerTools(server, ctx) {
|
|
193
|
+
server.tool("email_config", "Sender address, allowed recipients, per-email and hourly limits, sender domain status and kill switch. Start here.", {}, async () => emailConfig(ctx));
|
|
194
|
+
server.tool("email_send", `[SEND] Send an email from the fixed sender ${ctx.config.from.email} to allowlisted recipients. Two steps: call without confirmToken to preview (nothing is sent), show the preview to the human, then — only after their explicit approval — call again with the same arguments and the returned confirmToken.`, {
|
|
195
|
+
to: z.array(address).min(1).max(50).describe("Recipient addresses (bare addresses, each must be allowed by EMAIL_ALLOWED_RECIPIENTS)"),
|
|
196
|
+
cc: z.array(address).max(50).optional().describe("Carbon-copy addresses, same rules as to. There is no bcc."),
|
|
197
|
+
subject: z.string().min(1).max(MAX_SUBJECT),
|
|
198
|
+
text: z.string().min(1).max(MAX_TEXT).describe("Plain-text body (always required)"),
|
|
199
|
+
html: z.string().max(MAX_HTML).optional().describe("Optional HTML body; the plain-text body stays the fallback"),
|
|
200
|
+
confirmToken: z
|
|
201
|
+
.string()
|
|
202
|
+
.max(200)
|
|
203
|
+
.optional()
|
|
204
|
+
.describe("Omit for the preview. Then the token from the preview, with the SAME arguments, to send. Single use, valid 5 minutes."),
|
|
205
|
+
approvalId: z
|
|
206
|
+
.string()
|
|
207
|
+
.regex(/^[\w.:-]{1,128}$/)
|
|
208
|
+
.optional()
|
|
209
|
+
.describe("Pulse Chat approval id, recorded in the audit trail for correlation (not an authorisation)."),
|
|
210
|
+
}, async (args) => sendEmail(ctx, args));
|
|
211
|
+
server.tool("email_status", "Delivery status of an email sent by this server (TEM email id returned by email_send): new, sending, sent, failed, canceled, with the last delivery attempts.", { emailId: z.string().max(64).describe("TEM email id (UUID)") }, async ({ emailId }) => emailStatus(ctx, emailId));
|
|
212
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aidalinfo/email-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Outbound email MCP server over Scaleway Transactional Email (TEM) — fixed sender from config, recipient allowlist, two-step confirmed, rate-limited and audited sends",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"email-mcp": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "Aidalinfo / Pulse myIT",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/aidalinfo/cloud-mcp.git",
|
|
22
|
+
"directory": "packages/email-mcp"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=20"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"mcp",
|
|
29
|
+
"mcp-server",
|
|
30
|
+
"email",
|
|
31
|
+
"scaleway",
|
|
32
|
+
"transactional-email",
|
|
33
|
+
"tem",
|
|
34
|
+
"typescript"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc",
|
|
38
|
+
"prepack": "npm run build",
|
|
39
|
+
"start": "node dist/index.js",
|
|
40
|
+
"test": "tsc && tsc -p tsconfig.test.json && NODE_OPTIONS=--experimental-vm-modules jest",
|
|
41
|
+
"typecheck": "tsc -p tsconfig.test.json"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@modelcontextprotocol/sdk": "^1.27.0",
|
|
45
|
+
"zod": "^4.4.3"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@jest/globals": "^30.3.0",
|
|
49
|
+
"@types/node": "^22.0.0",
|
|
50
|
+
"jest": "^30.3.0",
|
|
51
|
+
"ts-jest": "^29.4.6",
|
|
52
|
+
"typescript": "^5.7.0"
|
|
53
|
+
}
|
|
54
|
+
}
|