@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 CHANGED
@@ -1,69 +1,142 @@
1
1
  # @aidalinfo/email-mcp
2
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.
3
+ MCP server that lets agents **send email** through **Scaleway Transactional Email (TEM)**, from
4
+ **one fixed sender** (`noreply@pulsemyit.fr`) to an **allowlist of recipients** only.
5
+
6
+ It runs either **locally over stdio** (one agent, as before) or as a **central server over
7
+ Streamable HTTP** shared by the Hermes agents: one revocable key per agent, Scaleway credentials
8
+ held by the server only.
5
9
 
6
10
  ```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
11
+ Agent Hermes A ─┐ Streamable HTTP + Authorization: Bearer emk_… (one key per agent)
12
+ Agent Hermes B ─┤
13
+ ▼
14
+ @aidalinfo/email-mcp (central, Coolify worker)
15
+ auth ── allowlist ── preview → human approval → send (token + idempotency_key) ── quotas ── ledger ── audit
16
+ │ X-Auth-Token (dedicated IAM application, TEM on one project) ▲
17
+ ▼ │ webhook (Topics and Events)
18
+ Scaleway TEM /transactional-email/v1alpha1/regions/{region}/… ───────────┘
19
+ smtp.tem.scaleway.com:2465 (emails above the 2 MB of the API)
14
20
  ```
15
21
 
22
+ **Two ways out.** The TEM API refuses any email above **2 MB** (attachments included, base64
23
+ counted, not raisable); TEM over SMTP accepts **50 MB**. By default (`EMAIL_SEND_VIA=auto`) an email
24
+ leaves through the API up to ~1.9 MB and through SMTP above, with the same credentials (SMTP user =
25
+ project id, password = the secret key) — attachments then go up to **35 MB** in total. `api` keeps
26
+ everything on the API (2 MB), `smtp` sends everything over SMTP. SMTP returns no TEM id:
27
+ `email_status` with the `idempotency_key` finds them once TEM lists the email (by the message id of
28
+ the SMTP reply, else by sender, subject and date), usually within a minute; webhook events of an
29
+ SMTP send are kept from then on.
30
+
16
31
  Guarantees, all enforced server-side:
17
32
 
18
33
  - **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.
34
+ come from the environment. Unknown tool arguments (`from`, `headers`, `Return-Path`…) are discarded;
35
+ there is no custom header and no bcc.
20
36
  - **Allowlisted recipients only.** `EMAIL_ALLOWED_RECIPIENTS` lists exact addresses and whole
21
37
  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.
38
+ A client may be narrowed further (`allowedRecipients`), never widened. One recipient outside → the
39
+ whole email is refused.
40
+ - **Preview, then send.** `email_preview` checks everything and sends nothing; it returns a token —
41
+ HMAC bound to the client, the `idempotency_key`, the **exact message** (sender, recipients,
42
+ subject, bodies, attachment contents) and the sender domain's state — valid 5 minutes, single use,
43
+ for this process only. `email_send` refuses without it.
44
+ - **Idempotency.** `idempotency_key` is required. A key sends at most once per client: a retry with
45
+ the same message returns the original TEM ids, a different message under the same key is
46
+ refused, a send with an unknown outcome blocks its key. Over HTTP the ledger is persisted
47
+ (`EMAIL_STATE_DIR`), so a restart does not forget it.
48
+ - **Client keys** (HTTP): `Authorization: Bearer emk_…`, 256 random bits, one per agent. The server
49
+ stores **hashes only**, re-reads the clients file on change (add, rotate, revoke without restart)
50
+ and refuses everything while that file is invalid (fail-closed). Keys are never logged.
51
+ - **Quotas:** per client and server-wide, per sliding hour and per 24 h (`EMAIL_MAX_PER_HOUR`,
52
+ `EMAIL_MAX_PER_DAY`, client `maxPerHour` / `maxPerDay`), in memory.
53
+ - **Kill switch:** when `EMAIL_KILL_SWITCH_FILE` exists, every send is refused — checked at the
54
+ preview and again right before sending.
55
+ - **Attachments by reference only.** The agent names a configured **source** and a **reference**
56
+ inside it (e.g. a Pulse Chat vault file id) — never a local path nor a URL. The server fetches it
57
+ (https, no redirect, timeout, size cap while reading), checks the type against an allowlist, the
58
+ extension and the leading bytes, and binds its sha256 to the preview. A document changed between
59
+ preview and send → refused. No source configured → `capability_unavailable`.
60
+ - **Versioned templates** for routines: `name@version` JSON files, variables declared and bounded,
61
+ escaped in HTML, refused with control characters in the subject. A client can be **templates-only**.
62
+ - **Audit:** one JSON line per preview / send / replay / refusal / failure on stderr (and
63
+ `EMAIL_AUDIT_LOG_FILE`): client, sender, recipients, subject, message digest, template, attachment
64
+ type/size/sha256, TEM ids, idempotency key, error code, Pulse approval id. **No body, no attachment
65
+ content or name, no key.** Every line and every error goes through a central redactor (Scaleway
66
+ key, client keys, source tokens, webhook secret, Authorization headers).
67
+ - **Bounded reads:** `email_status` (own sends only, 10 emails per key, 5 last attempts, 20 events),
68
+ `email_suppressions` (20 allowed addresses at most — no probing of arbitrary addresses).
69
+ - **No raw API tool**: the server makes exactly five Scaleway API calls (find the sender domain,
70
+ create an email, read an email, list its own emails after an SMTP send, read the blocklist of one
71
+ address), plus the SMTP submission of emails above 2 MB.
40
72
 
41
73
  ## Tools
42
74
 
43
75
  | Tool | What |
44
76
  |---|---|
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`. |
77
+ | `email_config` | Client id, sender, Reply-To, domain status, allowed recipients, quotas left, templates, attachment sources, webhook, kill switch. **Start here.** |
78
+ | `email_preview` | `to[]`, `cc[]?`, `subject` + `text` (+ `html?`) **or** `template {name, version, variables}`, `attachments[]?` (`{source, ref, filename?}`), `idempotency_key`, `approvalId?`. Nothing is sent; returns the `confirmToken`. |
79
+ | `email_send` | Same arguments + `confirmToken`. Sends once per `idempotency_key`. |
80
+ | `email_status` | By `emailId` or `idempotency_key`: TEM status (`new`, `sending`, `sent`, `failed`, `canceled`), last attempts, webhook events (`email_delivered`, `email_dropped`, `email_spam`, `email_mailbox_not_found`…). Own sends only. |
81
+ | `email_templates` | Templates this client may use, with their variables. |
82
+ | `email_suppressions` | Whether allowed addresses are on TEM's blocklist (bounce, full mailbox…). |
48
83
 
49
- ### The two-step protocol
84
+ ### The protocol
50
85
 
51
- 1. `email_send({ to, subject, text })` → preview + `confirmToken`. **Nothing is sent.**
86
+ 1. `email_preview({ to, subject, text, idempotency_key })` → preview + `confirmToken`. **Nothing is sent.**
52
87
  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.
88
+ 3. `email_send({ …same arguments, idempotency_key, confirmToken, approvalId })` → sent.
89
+ Any change (one more recipient, a reworded sentence, another attachment version) → `confirmation_invalid`.
90
+ 4. `email_status({ idempotency_key })` to follow delivery.
55
91
 
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.
92
+ The token proves the message is the one previewed; it does **not** prove a human approved it (the
93
+ agent holds the token). The human approval is Pulse's own gate — `approvalId` is recorded in the
94
+ audit trail for correlation (`verified: false`), it is not an authorisation.
59
95
 
60
96
  ### Error codes
61
97
 
62
98
  `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.
99
+ (kill switch, templates-only, ledger not writable) · `confirmation_invalid` · `idempotency_conflict` ·
100
+ `attachment_refused` · `capability_unavailable` (attachments, templates or webhook not configured) ·
101
+ `permission_missing` (API key lacks TEM rights) · `not_found` · `transient` · **`unknown_outcome`** —
102
+ the send request was cut off (timeout, network, 5xx): the email **may have left**. The key is burnt
103
+ and the quota slot kept; check `email_status` or the TEM console — never resend blindly under a new key.
104
+
105
+ ## Running it
106
+
107
+ ### Locally (stdio)
108
+
109
+ ```bash
110
+ claude mcp add email --scope user \
111
+ -e SCW_SECRET_KEY=... \
112
+ -e EMAIL_PROJECT_ID=<project-uuid> \
113
+ -e EMAIL_FROM=noreply@pulsemyit.fr -e EMAIL_FROM_NAME="Pulse myIT" \
114
+ -e EMAIL_ALLOWED_RECIPIENTS=@pulsemyit.fr,@aidalinfo.fr \
115
+ -e EMAIL_KILL_SWITCH_FILE=$HOME/.email-mcp.stop \
116
+ -e EMAIL_STATE_DIR=$HOME/.email-mcp \
117
+ -- npx -y @aidalinfo/email-mcp
118
+ ```
119
+
120
+ ### Central (HTTP)
121
+
122
+ ```bash
123
+ npx -y @aidalinfo/email-mcp keygen hermes-ops # prints the key (once) and the clients-file entry
124
+ EMAIL_MCP_TRANSPORT=http EMAIL_STATE_DIR=/data EMAIL_MCP_CLIENTS_FILE=/config/clients.json … npx -y @aidalinfo/email-mcp
125
+ ```
126
+
127
+ Container image: [`Dockerfile`](Dockerfile) (build from the repo root), Coolify compose:
128
+ [`deploy/compose.yaml`](deploy/compose.yaml). Endpoints: `POST /mcp` (Streamable HTTP, stateless,
129
+ JSON responses), `GET /health`, `POST /webhooks/tem/<secret>`.
130
+
131
+ Operations — deployment, key rotation, kill switch, webhook, acceptance without a real send:
132
+ [`docs/exploitation.md`](docs/exploitation.md).
133
+
134
+ An agent registers it with its own key:
135
+
136
+ ```bash
137
+ claude mcp add --transport http email https://email.mcp.pulsemyit.fr/mcp \
138
+ --header "Authorization: Bearer emk_…"
139
+ ```
67
140
 
68
141
  ## Scaleway setup
69
142
 
@@ -71,8 +144,9 @@ in the audit trail for correlation (`verified: false`), it is not an authorisati
71
144
  and region, publish the SPF, DKIM (and MX/DMARC recommended) records, wait for status
72
145
  **`checked`**. `email_config` (or `list_tem_domains` of `@aidalinfo/scaleway-mcp`) shows it.
73
146
  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.
147
+ `TransactionalEmailFullAccess` (send, read emails, read blocklists) **scoped to that project
148
+ only**, and an API key for it. Do not reuse a personal key or the key of another MCP. This key
149
+ lives in the server's environment only, never in an agent's configuration.
76
150
  3. **Allowlist:** start narrow (`@pulsemyit.fr,@aidalinfo.fr`), add external addresses one by one.
77
151
 
78
152
  ## Configuration
@@ -85,41 +159,50 @@ startup (fail-closed) instead of guessing.
85
159
  | `SCW_SECRET_KEY` | yes | | Secret key of the dedicated IAM application |
86
160
  | `EMAIL_PROJECT_ID` | yes | `SCW_DEFAULT_PROJECT_ID` | Project of the TEM domain |
87
161
  | `EMAIL_REGION` | | `fr-par` | `fr-par`, `nl-ams`, `pl-waw` |
88
- | `EMAIL_FROM` | yes | | Bare address, e.g. `noreply@pulsemyit.fr` |
162
+ | `EMAIL_FROM` | yes | (`noreply@pulsemyit.fr` in the image) | Bare address |
89
163
  | `EMAIL_FROM_NAME` | | | Display name, e.g. `Pulse myIT` |
90
164
  | `EMAIL_REPLY_TO` | | | Fixed Reply-To (sent as an additional header) |
91
165
  | `EMAIL_ALLOWED_RECIPIENTS` | yes | | `a@x.fr,@domain.fr` — no wildcard |
92
166
  | `EMAIL_MAX_RECIPIENTS` | | 10 | to + cc, hard cap 50 |
93
- | `EMAIL_MAX_PER_HOUR` | | 20 | sliding hour, per process, hard cap 500 |
167
+ | `EMAIL_MAX_PER_HOUR` / `EMAIL_MAX_PER_DAY` | | 20 / 200 | server-wide, hard caps 500 / 5000 |
94
168
  | `EMAIL_KILL_SWITCH_FILE` | | | `touch` it to stop all sends |
95
169
  | `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
170
+ | `EMAIL_STATE_DIR` | HTTP | | Send ledger `ledger.json` (mode 600, 30-day retention) |
171
+ | `EMAIL_TEMPLATES_DIR` | | | `*.json` templates, loaded at startup |
172
+ | `EMAIL_ATTACHMENT_SOURCES` | | | JSON `[{id, baseUrl (https), tokenEnv?, authScheme?, clients?}]` |
173
+ | `EMAIL_ATTACHMENT_TYPES` | | pdf, png, jpeg, txt, csv | also docx, xlsx |
174
+ | `EMAIL_SEND_VIA` | | `auto` | `auto` (API ≤ 2 MB, SMTP above), `api`, `smtp` |
175
+ | `EMAIL_SMTP_PORT` | | 2465 | 2465 / 465 (TLS), 2587 / 587 (STARTTLS) — Scaleway Instances block 465 and 587 by default |
176
+ | `EMAIL_MAX_ATTACHMENTS` / `EMAIL_MAX_ATTACHMENT_BYTES` | | 5 / 35 000 000 (2 000 000 with `api`) | caps 10 / 35 000 000 (10 000 000 with `api`), total |
177
+ | `EMAIL_MCP_TRANSPORT` | | `stdio` | `http` in the image |
178
+ | `EMAIL_MCP_HOST` / `EMAIL_MCP_PORT` | | `127.0.0.1` / 8080 | `0.0.0.0` in the image |
179
+ | `EMAIL_MCP_CLIENTS_FILE` or `EMAIL_MCP_CLIENTS` | HTTP | | clients JSON (below) |
180
+ | `EMAIL_WEBHOOK_SECRET` | | | 32–128 chars, enables `POST /webhooks/tem/<secret>` |
181
+ | `EMAIL_MCP_ALLOWED_ORIGINS` | | | Origins allowed to call `/mcp` (none by default) |
182
+
183
+ Clients file — hashes only, as printed by `email-mcp keygen`:
184
+
185
+ ```json
186
+ [
187
+ { "id": "hermes-ops", "keys": ["sha256:<hex>"], "maxPerHour": 10, "maxPerDay": 50 },
188
+ { "id": "hermes-routines", "keys": ["sha256:<hex>"], "templatesOnly": true, "allowedRecipients": "@pulsemyit.fr" },
189
+ { "id": "old-agent", "keys": ["sha256:<hex>"], "disabled": true }
190
+ ]
107
191
  ```
108
192
 
109
- Emergency stop, no restart needed:
110
-
111
- ```bash
112
- touch ~/.email-mcp.stop
113
- ```
193
+ Template file (`EMAIL_TEMPLATES_DIR/rapport-hebdo@1.json`, see [`deploy/templates/`](deploy/templates/)):
194
+ `name`, `version`, `subject`, `text`, `html?`, `variables {name: {maxLength, required?, description?}}`,
195
+ `clients?`. A new wording is a **new version**; routines pin the version they were approved with.
114
196
 
115
197
  ## Development
116
198
 
117
199
  ```bash
118
200
  npm install # at the repo root (npm workspaces)
119
- npm test -w @aidalinfo/email-mcp # tsc + jest (TEM is faked, nothing is sent)
201
+ npm test -w @aidalinfo/email-mcp # tsc + jest (TEM faked, nothing is sent; HTTP tests on a local port)
120
202
  npm run build -w @aidalinfo/email-mcp
121
203
  node packages/email-mcp/smoke.mjs # starts the server over stdio and lists its tools; SMOKE_CALL_CONFIG=1 also calls email_config
204
+ node packages/email-mcp/scripts/deploy-check.mjs # against a running HTTP server, preview only (see docs/exploitation.md)
122
205
  ```
123
206
 
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.
207
+ Neither script sends anything. To test a real send, use an allowlist holding only your own
208
+ address and go through preview → confirmation.
@@ -0,0 +1,4 @@
1
+ [
2
+ { "id": "hermes-ops", "keys": ["sha256:0000000000000000000000000000000000000000000000000000000000000000"], "maxPerHour": 10, "maxPerDay": 50 },
3
+ { "id": "hermes-routines", "keys": ["sha256:1111111111111111111111111111111111111111111111111111111111111111"], "templatesOnly": true, "allowedRecipients": "@pulsemyit.fr" }
4
+ ]
@@ -0,0 +1,50 @@
1
+ # Central email MCP — Coolify (docker compose) on a WORKER server, never on the management/routing host.
2
+ # No secret here: set them in Coolify's environment variables (marked secret), see ../.env.example.
3
+ #
4
+ # Public exposure stays OFF until the DNS record email.mcp.pulsemyit.fr exists and is validated:
5
+ # do not set a domain in Coolify before that (see docs/exploitation.md → Exposure).
6
+ services:
7
+ email-mcp:
8
+ image: ${EMAIL_MCP_IMAGE:-email-mcp:0.2.0}
9
+ restart: unless-stopped
10
+ environment:
11
+ EMAIL_MCP_TRANSPORT: http
12
+ EMAIL_MCP_HOST: 0.0.0.0
13
+ EMAIL_MCP_PORT: 8080
14
+ EMAIL_STATE_DIR: /data
15
+ EMAIL_MCP_CLIENTS_FILE: /config/clients.json
16
+ EMAIL_TEMPLATES_DIR: /config/templates
17
+ EMAIL_KILL_SWITCH_FILE: /data/STOP
18
+ EMAIL_AUDIT_LOG_FILE: /data/audit.jsonl
19
+ EMAIL_FROM: noreply@pulsemyit.fr
20
+ EMAIL_FROM_NAME: ${EMAIL_FROM_NAME:-Pulse myIT}
21
+ EMAIL_REGION: ${EMAIL_REGION:-fr-par}
22
+ # API up to 2 MB per email, SMTP (port 2465, outbound) above — see README
23
+ EMAIL_SEND_VIA: ${EMAIL_SEND_VIA:-auto}
24
+ EMAIL_SMTP_PORT: ${EMAIL_SMTP_PORT:-2465}
25
+ # required, no default — the container exits with a clear message when one is missing
26
+ SCW_SECRET_KEY: ${SCW_SECRET_KEY:?SCW_SECRET_KEY is required}
27
+ EMAIL_PROJECT_ID: ${EMAIL_PROJECT_ID:?EMAIL_PROJECT_ID is required}
28
+ EMAIL_ALLOWED_RECIPIENTS: ${EMAIL_ALLOWED_RECIPIENTS:?EMAIL_ALLOWED_RECIPIENTS is required}
29
+ # optional
30
+ EMAIL_REPLY_TO: ${EMAIL_REPLY_TO:-}
31
+ EMAIL_MAX_PER_HOUR: ${EMAIL_MAX_PER_HOUR:-20}
32
+ EMAIL_MAX_PER_DAY: ${EMAIL_MAX_PER_DAY:-200}
33
+ EMAIL_WEBHOOK_SECRET: ${EMAIL_WEBHOOK_SECRET:-}
34
+ EMAIL_ATTACHMENT_SOURCES: ${EMAIL_ATTACHMENT_SOURCES:-}
35
+ PULSE_VAULT_TOKEN: ${PULSE_VAULT_TOKEN:-}
36
+ volumes:
37
+ - email-mcp-data:/data
38
+ # clients.json (key hashes only) and templates/*.json, read-only
39
+ - ./config:/config:ro
40
+ expose:
41
+ - "8080"
42
+ read_only: true
43
+ tmpfs:
44
+ - /tmp
45
+ cap_drop: [ALL]
46
+ security_opt: ["no-new-privileges:true"]
47
+ mem_limit: 256m
48
+
49
+ volumes:
50
+ email-mcp-data:
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "rapport-hebdo",
3
+ "version": 1,
4
+ "description": "Rapport hebdomadaire d'une routine Hermes",
5
+ "subject": "Rapport de la semaine {{semaine}}",
6
+ "text": "Bonjour {{prenom}},\n\nle rapport de la semaine {{semaine}} est disponible :\n{{resume}}\n\n— Pulse myIT (message automatique, ne pas répondre)",
7
+ "html": "<p>Bonjour {{prenom}},</p><p>Le rapport de la semaine {{semaine}} est disponible :</p><p>{{resume}}</p><p>— Pulse myIT (message automatique, ne pas répondre)</p>",
8
+ "variables": {
9
+ "semaine": { "maxLength": 10, "description": "Numéro de semaine, ex. 45" },
10
+ "prenom": { "maxLength": 60 },
11
+ "resume": { "maxLength": 4000, "description": "Résumé en texte brut (échappé dans le HTML)" }
12
+ }
13
+ }
@@ -0,0 +1,207 @@
1
+ import { createHash } from "node:crypto";
2
+ import { intIn } from "./config.js";
3
+ import { EmailError } from "./errors.js";
4
+ import { redactor } from "./redact.js";
5
+ /** Types an attachment may have, with their extensions and leading bytes (checked, not trusted from the source). */
6
+ export const KNOWN_TYPES = {
7
+ "application/pdf": { exts: ["pdf"], magic: [0x25, 0x50, 0x44, 0x46, 0x2d] },
8
+ "image/png": { exts: ["png"], magic: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
9
+ "image/jpeg": { exts: ["jpg", "jpeg"], magic: [0xff, 0xd8, 0xff] },
10
+ "text/plain": { exts: ["txt"], text: true },
11
+ "text/csv": { exts: ["csv"], text: true },
12
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": { exts: ["docx"], magic: [0x50, 0x4b, 0x03, 0x04] },
13
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { exts: ["xlsx"], magic: [0x50, 0x4b, 0x03, 0x04] },
14
+ };
15
+ export const DEFAULT_TYPES = ["application/pdf", "image/png", "image/jpeg", "text/plain", "text/csv"];
16
+ export const DEFAULT_MAX_ATTACHMENTS = 5;
17
+ export const HARD_MAX_ATTACHMENTS = 10;
18
+ export const DEFAULT_MAX_ATTACHMENT_BYTES = 2_000_000;
19
+ export const HARD_MAX_ATTACHMENT_BYTES = 10_000_000;
20
+ /** With SMTP (TEM: 50 MB per email): 35 MB of files, ~47 MB once base64-encoded, leaves room for the bodies. */
21
+ export const SMTP_MAX_ATTACHMENT_BYTES = 35_000_000;
22
+ export const FETCH_TIMEOUT_MS = 60_000;
23
+ const SOURCE_ID_RE = /^[a-z0-9][a-z0-9-]{0,39}$/;
24
+ /** Path segments starting with a letter or digit: no "..", no "/x", no "//", no query. */
25
+ export const REF_RE = /^[A-Za-z0-9][A-Za-z0-9._~-]*(?:\/[A-Za-z0-9][A-Za-z0-9._~-]*)*$/;
26
+ const FILENAME_RE = /^[A-Za-z0-9][A-Za-z0-9 ._()-]{0,99}$/;
27
+ /**
28
+ * EMAIL_ATTACHMENT_SOURCES: JSON list of `{ "id", "baseUrl", "tokenEnv"?, "authScheme"?, "clients"? }`.
29
+ * The token itself never sits in that JSON: `tokenEnv` names the env var holding it.
30
+ */
31
+ export function loadAttachmentConfig(env, warnings = [], sendVia = "api") {
32
+ const raw = env.EMAIL_ATTACHMENT_SOURCES?.trim();
33
+ const sources = [];
34
+ if (raw) {
35
+ let list;
36
+ try {
37
+ list = JSON.parse(raw);
38
+ }
39
+ catch {
40
+ throw new Error("EMAIL_ATTACHMENT_SOURCES is not valid JSON");
41
+ }
42
+ if (!Array.isArray(list))
43
+ throw new Error("EMAIL_ATTACHMENT_SOURCES must be a JSON list");
44
+ for (const s of list) {
45
+ const id = s?.id;
46
+ if (typeof id !== "string" || !SOURCE_ID_RE.test(id))
47
+ throw new Error("EMAIL_ATTACHMENT_SOURCES: each source needs an id matching [a-z0-9-]");
48
+ if (sources.some((x) => x.id === id))
49
+ throw new Error(`EMAIL_ATTACHMENT_SOURCES: duplicate source "${id}"`);
50
+ let url;
51
+ try {
52
+ url = new URL(String(s.baseUrl));
53
+ }
54
+ catch {
55
+ throw new Error(`EMAIL_ATTACHMENT_SOURCES[${id}]: baseUrl is not a URL`);
56
+ }
57
+ if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) {
58
+ throw new Error(`EMAIL_ATTACHMENT_SOURCES[${id}]: baseUrl must be https, without credentials, query or fragment`);
59
+ }
60
+ const baseUrl = url.href.endsWith("/") ? url.href : `${url.href}/`;
61
+ let authorization;
62
+ if (s.tokenEnv !== undefined) {
63
+ const token = typeof s.tokenEnv === "string" ? env[s.tokenEnv]?.trim() : undefined;
64
+ if (!token)
65
+ throw new Error(`EMAIL_ATTACHMENT_SOURCES[${id}]: env var ${String(s.tokenEnv)} (tokenEnv) is missing or empty`);
66
+ redactor.add(token);
67
+ const scheme = typeof s.authScheme === "string" ? s.authScheme : "Bearer";
68
+ if (!/^[A-Za-z]{1,20}$/.test(scheme))
69
+ throw new Error(`EMAIL_ATTACHMENT_SOURCES[${id}]: invalid authScheme`);
70
+ authorization = `${scheme} ${token}`;
71
+ }
72
+ if (s.clients !== undefined && (!Array.isArray(s.clients) || s.clients.some((c) => typeof c !== "string"))) {
73
+ throw new Error(`EMAIL_ATTACHMENT_SOURCES[${id}]: clients must be a list of client ids`);
74
+ }
75
+ sources.push({ id, baseUrl, authorization, clients: s.clients });
76
+ }
77
+ }
78
+ const types = (env.EMAIL_ATTACHMENT_TYPES?.trim() ? env.EMAIL_ATTACHMENT_TYPES.split(",") : DEFAULT_TYPES).map((t) => t.trim().toLowerCase()).filter(Boolean);
79
+ for (const t of types)
80
+ if (!Object.hasOwn(KNOWN_TYPES, t))
81
+ throw new Error(`EMAIL_ATTACHMENT_TYPES: unsupported type "${t}" (supported: ${Object.keys(KNOWN_TYPES).join(", ")})`);
82
+ return {
83
+ sources,
84
+ types,
85
+ maxCount: intIn(env.EMAIL_MAX_ATTACHMENTS?.trim() || undefined, "EMAIL_MAX_ATTACHMENTS", DEFAULT_MAX_ATTACHMENTS, HARD_MAX_ATTACHMENTS, warnings),
86
+ maxTotalBytes: sendVia === "api"
87
+ ? intIn(env.EMAIL_MAX_ATTACHMENT_BYTES?.trim() || undefined, "EMAIL_MAX_ATTACHMENT_BYTES", DEFAULT_MAX_ATTACHMENT_BYTES, HARD_MAX_ATTACHMENT_BYTES, warnings)
88
+ : intIn(env.EMAIL_MAX_ATTACHMENT_BYTES?.trim() || undefined, "EMAIL_MAX_ATTACHMENT_BYTES", SMTP_MAX_ATTACHMENT_BYTES, SMTP_MAX_ATTACHMENT_BYTES, warnings),
89
+ };
90
+ }
91
+ const refused = (m) => new EmailError("attachment_refused", `attachment_refused: ${m}`);
92
+ function startsWith(buf, magic) {
93
+ return buf.length >= magic.length && magic.every((b, i) => buf[i] === b);
94
+ }
95
+ /**
96
+ * Resolves attachment references itself: the agent names a configured source and a reference
97
+ * inside it — never a local path nor a URL. The type is checked against the allowlist, the
98
+ * extension and the leading bytes; the total size is capped while reading.
99
+ */
100
+ export class AttachmentResolver {
101
+ config;
102
+ fetchImpl;
103
+ constructor(config, fetchImpl = fetch) {
104
+ this.config = config;
105
+ this.fetchImpl = fetchImpl;
106
+ }
107
+ get configured() {
108
+ return this.config.sources.length > 0;
109
+ }
110
+ sourcesFor(clientId) {
111
+ return this.config.sources.filter((s) => !s.clients || s.clients.includes(clientId)).map((s) => s.id);
112
+ }
113
+ async resolve(clientId, refs) {
114
+ if (!refs.length)
115
+ return [];
116
+ if (!this.configured)
117
+ throw new EmailError("capability_unavailable", "capability_unavailable: no attachment source is configured (EMAIL_ATTACHMENT_SOURCES) — send without attachments");
118
+ if (refs.length > this.config.maxCount)
119
+ throw refused(`${refs.length} attachments, the maximum is ${this.config.maxCount}`);
120
+ const out = [];
121
+ let budget = this.config.maxTotalBytes;
122
+ for (const r of refs) {
123
+ const a = await this.fetchOne(clientId, r, budget);
124
+ budget -= a.size;
125
+ out.push(a);
126
+ }
127
+ return out;
128
+ }
129
+ async fetchOne(clientId, r, budget) {
130
+ const src = this.config.sources.find((s) => s.id === r.source && (!s.clients || s.clients.includes(clientId)));
131
+ if (!src)
132
+ throw refused(`unknown source "${r.source.slice(0, 40)}" — see email_config for the sources open to this client`);
133
+ if (r.ref.length > 512 || !REF_RE.test(r.ref))
134
+ throw refused("the reference must be a relative path of plain segments (no \"..\", no leading \"/\", no URL, no query)");
135
+ const url = src.baseUrl + r.ref.split("/").map(encodeURIComponent).join("/");
136
+ if (!url.startsWith(src.baseUrl))
137
+ throw refused("the reference leaves its source");
138
+ const name = r.filename ?? r.ref.slice(r.ref.lastIndexOf("/") + 1);
139
+ if (!FILENAME_RE.test(name))
140
+ throw refused(`file name "${name.slice(0, 60)}" is not allowed (letters, digits, space . _ ( ) -, 100 characters max)`);
141
+ const ext = name.includes(".") ? name.slice(name.lastIndexOf(".") + 1).toLowerCase() : "";
142
+ let res;
143
+ try {
144
+ res = await this.fetchImpl(url, {
145
+ method: "GET",
146
+ redirect: "error",
147
+ headers: { Accept: "*/*", ...(src.authorization ? { Authorization: src.authorization } : {}) },
148
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
149
+ });
150
+ }
151
+ catch (err) {
152
+ throw new EmailError("transient", `transient: attachment ${r.source}:${r.ref} could not be fetched (${redactor.apply(err instanceof Error ? err.message : String(err))})`);
153
+ }
154
+ if (res.status === 404)
155
+ throw new EmailError("not_found", `not_found: attachment ${r.source}:${r.ref} does not exist`);
156
+ if (res.status === 401 || res.status === 403)
157
+ throw refused(`source ${r.source} refused access to ${r.ref} (${res.status})`);
158
+ if (!res.ok)
159
+ throw new EmailError(res.status >= 500 ? "transient" : "attachment_refused", `${res.status >= 500 ? "transient" : "attachment_refused"}: source ${r.source} answered ${res.status} for ${r.ref}`);
160
+ const declared = (res.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
161
+ const type = declared && declared !== "application/octet-stream" ? declared : Object.keys(KNOWN_TYPES).find((t) => KNOWN_TYPES[t].exts.includes(ext));
162
+ if (!type || !this.config.types.includes(type))
163
+ throw refused(`type ${type ?? "unknown"} is not allowed (allowed: ${this.config.types.join(", ")})`);
164
+ const spec = KNOWN_TYPES[type];
165
+ if (!spec.exts.includes(ext))
166
+ throw refused(`file name "${name}" does not match type ${type} (.${spec.exts.join(", .")})`);
167
+ const length = Number(res.headers.get("content-length") ?? NaN);
168
+ if (Number.isFinite(length) && length > budget)
169
+ throw refused(`${r.ref} is ${length} bytes, above the ${this.config.maxTotalBytes}-byte total (EMAIL_MAX_ATTACHMENT_BYTES)`);
170
+ const content = await readCapped(res, budget, () => refused(`attachments exceed ${this.config.maxTotalBytes} bytes in total (EMAIL_MAX_ATTACHMENT_BYTES)`));
171
+ if (!content.length)
172
+ throw refused(`${r.ref} is empty`);
173
+ if (spec.magic && !startsWith(content, spec.magic))
174
+ throw refused(`${r.ref} is not a real ${type} file`);
175
+ if (spec.text && (content.includes(0) || !isUtf8(content)))
176
+ throw refused(`${r.ref} is not UTF-8 text`);
177
+ return { name, type, size: content.length, sha256: createHash("sha256").update(content).digest("hex"), content };
178
+ }
179
+ }
180
+ function isUtf8(b) {
181
+ try {
182
+ new TextDecoder("utf-8", { fatal: true }).decode(b);
183
+ return true;
184
+ }
185
+ catch {
186
+ return false;
187
+ }
188
+ }
189
+ async function readCapped(res, max, tooBig) {
190
+ if (!res.body)
191
+ return Buffer.alloc(0);
192
+ const reader = res.body.getReader();
193
+ const chunks = [];
194
+ let total = 0;
195
+ for (;;) {
196
+ const { done, value } = await reader.read();
197
+ if (done)
198
+ break;
199
+ total += value.byteLength;
200
+ if (total > max) {
201
+ await reader.cancel().catch(() => { });
202
+ throw tooBig();
203
+ }
204
+ chunks.push(Buffer.from(value));
205
+ }
206
+ return Buffer.concat(chunks);
207
+ }
package/dist/audit.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { appendFileSync } from "node:fs";
2
+ import { redactor } from "./redact.js";
2
3
  /**
3
4
  * 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
+ * EMAIL_AUDIT_LOG_FILE. Bodies and attachment contents are never logged: the digest is enough to
6
+ * match an entry to its preview. Every line goes through the central redactor.
5
7
  */
6
8
  export class AuditLogger {
7
9
  file;
@@ -20,7 +22,7 @@ export class AuditLogger {
20
22
  ...rest,
21
23
  approvalRef: approvalId ? { id: approvalId, verified: false } : undefined,
22
24
  };
23
- const line = JSON.stringify(entry);
25
+ const line = redactor.apply(JSON.stringify(entry));
24
26
  this.sink(line);
25
27
  if (this.file) {
26
28
  try {
@@ -36,32 +38,3 @@ export class AuditLogger {
36
38
  return entry;
37
39
  }
38
40
  }
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
- }