@aidalinfo/email-mcp 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +148 -65
- package/deploy/clients.example.json +4 -0
- package/deploy/compose.yaml +50 -0
- package/deploy/templates/rapport-hebdo@1.json +13 -0
- package/dist/attachments.js +207 -0
- package/dist/audit.js +4 -31
- package/dist/clients.js +144 -0
- package/dist/config.js +42 -20
- package/dist/errors.js +3 -1
- package/dist/http.js +131 -0
- package/dist/index.js +65 -14
- package/dist/ledger.js +138 -0
- package/dist/quota.js +77 -0
- package/dist/redact.js +23 -0
- package/dist/smtp-client.js +70 -0
- package/dist/tem-client.js +14 -0
- package/dist/templates.js +118 -0
- package/dist/tools.js +440 -102
- package/dist/webhook.js +67 -0
- package/docs/exploitation.md +140 -0
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -1,69 +1,142 @@
|
|
|
1
1
|
# @aidalinfo/email-mcp
|
|
2
2
|
|
|
3
|
-
MCP server that lets
|
|
4
|
-
**one fixed sender** (
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
@aidalinfo/email-mcp
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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` |
|
|
46
|
-
| `
|
|
47
|
-
| `
|
|
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
|
|
84
|
+
### The protocol
|
|
50
85
|
|
|
51
|
-
1. `
|
|
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({
|
|
54
|
-
Any change (one more recipient, a reworded sentence) → `confirmation_invalid
|
|
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
|
-
|
|
58
|
-
|
|
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` · `
|
|
64
|
-
`
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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 |
|
|
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
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
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
|
-
|
|
125
|
-
|
|
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
|
|
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
|
-
}
|