@aidalinfo/email-mcp 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +148 -65
- package/deploy/clients.example.json +4 -0
- package/deploy/compose.yaml +50 -0
- package/deploy/templates/rapport-hebdo@1.json +13 -0
- package/dist/attachments.js +207 -0
- package/dist/audit.js +4 -31
- package/dist/clients.js +144 -0
- package/dist/config.js +42 -20
- package/dist/errors.js +3 -1
- package/dist/http.js +131 -0
- package/dist/index.js +65 -14
- package/dist/ledger.js +138 -0
- package/dist/quota.js +77 -0
- package/dist/redact.js +23 -0
- package/dist/smtp-client.js +70 -0
- package/dist/tem-client.js +14 -0
- package/dist/templates.js +118 -0
- package/dist/tools.js +440 -102
- package/dist/webhook.js +67 -0
- package/docs/exploitation.md +140 -0
- package/package.json +7 -3
package/dist/webhook.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { UUID_RE } from "./config.js";
|
|
2
|
+
const EVENT_TYPE_RE = /^[a-z_]{1,40}$/;
|
|
3
|
+
export const CONFIRM_TIMEOUT_MS = 10_000;
|
|
4
|
+
/**
|
|
5
|
+
* Scaleway TEM webhook, delivered through a Topics and Events (SNS) HTTPS subscription.
|
|
6
|
+
* The subscription URL carries the shared secret; the caller checked it before. Only events of
|
|
7
|
+
* emails this server sent (known to the ledger) are kept, and only their type and date.
|
|
8
|
+
*/
|
|
9
|
+
export async function handleTemWebhook(body, opts) {
|
|
10
|
+
let payload;
|
|
11
|
+
try {
|
|
12
|
+
payload = JSON.parse(body);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return { status: 400, log: "tem webhook: body is not JSON" };
|
|
16
|
+
}
|
|
17
|
+
if (!payload || typeof payload !== "object")
|
|
18
|
+
return { status: 400, log: "tem webhook: body is not a JSON object" };
|
|
19
|
+
if (payload.Type === "SubscriptionConfirmation") {
|
|
20
|
+
// confirm only towards the Scaleway SNS endpoint of the configured region: never an arbitrary URL
|
|
21
|
+
let url;
|
|
22
|
+
try {
|
|
23
|
+
url = new URL(String(payload.SubscribeURL));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return { status: 400, log: "tem webhook: SubscriptionConfirmation without a valid SubscribeURL" };
|
|
27
|
+
}
|
|
28
|
+
const expected = `sns.mnq.${opts.region}.scaleway.com`;
|
|
29
|
+
if (url.protocol !== "https:" || url.hostname !== expected || url.port) {
|
|
30
|
+
return { status: 400, log: `tem webhook: SubscribeURL host ${url.hostname} refused (expected ${expected})` };
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const res = await (opts.fetchImpl ?? fetch)(url.href, { method: "GET", redirect: "error", signal: AbortSignal.timeout(CONFIRM_TIMEOUT_MS) });
|
|
34
|
+
if (!res.ok)
|
|
35
|
+
return { status: 502, log: `tem webhook: subscription confirmation answered ${res.status}` };
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
return { status: 502, log: `tem webhook: subscription confirmation failed (${err instanceof Error ? err.name : "error"})` };
|
|
39
|
+
}
|
|
40
|
+
return { status: 200, log: "tem webhook: SNS subscription confirmed" };
|
|
41
|
+
}
|
|
42
|
+
let event = payload;
|
|
43
|
+
if (payload.Type === "Notification") {
|
|
44
|
+
try {
|
|
45
|
+
event = JSON.parse(String(payload.Message));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return { status: 400, log: "tem webhook: Notification whose Message is not JSON" };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
else if (payload.Type !== undefined) {
|
|
52
|
+
return { status: 202, log: `tem webhook: SNS message type ignored` };
|
|
53
|
+
}
|
|
54
|
+
const type = event?.type;
|
|
55
|
+
const emailId = typeof event?.email_id === "string" ? event.email_id.toLowerCase() : undefined;
|
|
56
|
+
if (typeof type !== "string" || !EVENT_TYPE_RE.test(type))
|
|
57
|
+
return { status: 400, log: "tem webhook: event without a valid type" };
|
|
58
|
+
if (event.project_id !== undefined && String(event.project_id).toLowerCase() !== opts.projectId) {
|
|
59
|
+
return { status: 202, log: "tem webhook: event of another project ignored" };
|
|
60
|
+
}
|
|
61
|
+
if (!emailId || !UUID_RE.test(emailId))
|
|
62
|
+
return { status: 202, log: `tem webhook: ${type} without email id ignored` };
|
|
63
|
+
const created = typeof event.created_at === "string" ? Date.parse(event.created_at) : NaN;
|
|
64
|
+
const at = new Date(Number.isFinite(created) ? created : (opts.now ?? Date.now)()).toISOString();
|
|
65
|
+
const kept = opts.ledger.addEvent(emailId, { type, at });
|
|
66
|
+
return kept ? { status: 204, log: `tem webhook: ${type} recorded for ${emailId}` } : { status: 202, log: `tem webhook: ${type} for an email not sent by this server ignored` };
|
|
67
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# Exploitation du MCP email central
|
|
2
|
+
|
|
3
|
+
Serveur `@aidalinfo/email-mcp` en mode HTTP (`EMAIL_MCP_TRANSPORT=http`), partagé par les agents
|
|
4
|
+
Hermes. Ce document couvre le déploiement, les clés clientes, le coupe-circuit, le webhook Scaleway
|
|
5
|
+
TEM et la recette **sans envoi réel**.
|
|
6
|
+
|
|
7
|
+
> Chaque étape qui touche l'infrastructure (identité IAM Scaleway, application Coolify, DNS,
|
|
8
|
+
> Traefik, premier envoi réel) demande une **validation humaine séparée** avant exécution
|
|
9
|
+
> (issue #67, « hors périmètre »). Ce document décrit le « comment », il n'autorise rien.
|
|
10
|
+
|
|
11
|
+
## 1. Prérequis Scaleway
|
|
12
|
+
|
|
13
|
+
1. Domaine `pulsemyit.fr` en statut **`checked`** dans Transactional Email (projet et région choisis).
|
|
14
|
+
2. **Application IAM dédiée** `email-mcp`, politique `TransactionalEmailFullAccess` limitée **à ce
|
|
15
|
+
seul projet**, une clé API. Elle n'est déposée que dans l'environnement du serveur (Coolify,
|
|
16
|
+
variable marquée secrète) — jamais dans la configuration d'un agent.
|
|
17
|
+
|
|
18
|
+
## 2. Déploiement (Coolify)
|
|
19
|
+
|
|
20
|
+
- **Serveur** : un worker Coolify, **jamais** l'hôte de management / routage.
|
|
21
|
+
- **Image** : `docker build -f packages/email-mcp/Dockerfile -t email-mcp:<version> .` depuis la
|
|
22
|
+
racine du dépôt. Rien n'est téléchargé au démarrage ; l'image tourne en utilisateur `node`.
|
|
23
|
+
- **Compose** : [`deploy/compose.yaml`](../deploy/compose.yaml) — système de fichiers en lecture
|
|
24
|
+
seule, `cap_drop: ALL`, `no-new-privileges`, volume `/data` (registre des envois, audit,
|
|
25
|
+
coupe-circuit), `/config` en lecture seule (`clients.json`, `templates/`).
|
|
26
|
+
- **Variables obligatoires** : `SCW_SECRET_KEY`, `EMAIL_PROJECT_ID`, `EMAIL_ALLOWED_RECIPIENTS`, et
|
|
27
|
+
un fichier clients valide. Il en manque une → le conteneur s'arrête avec un message explicite
|
|
28
|
+
(`Missing required env var: …`, `Missing client keys: …`) et ne répond pas. L'expéditeur est fixé
|
|
29
|
+
à `noreply@pulsemyit.fr` dans l'image.
|
|
30
|
+
- **Santé** : `GET /health` → `{"status":"ok","service":"email-mcp","version":"…","sending":"enabled|disabled"}`.
|
|
31
|
+
Aucun secret, aucun identifiant de projet, aucune région.
|
|
32
|
+
|
|
33
|
+
### Exposition publique
|
|
34
|
+
|
|
35
|
+
Tant que l'enregistrement DNS `email.mcp.pulsemyit.fr` n'est **pas créé et validé**, ne déclarez
|
|
36
|
+
**aucun domaine** sur l'application Coolify : le service reste joignable seulement sur le réseau
|
|
37
|
+
interne (`expose: 8080`, pas de `ports:`). Une fois le DNS validé (décision séparée) : domaine
|
|
38
|
+
`https://email.mcp.pulsemyit.fr` dans Coolify, certificat TLS par Traefik, puis recette (§ 6).
|
|
39
|
+
|
|
40
|
+
## 3. Clés clientes
|
|
41
|
+
|
|
42
|
+
Une clé par agent (`emk_` + 256 bits aléatoires). Le serveur ne garde que son **empreinte SHA-256**.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npx -y @aidalinfo/email-mcp keygen hermes-ops
|
|
46
|
+
# client key (give it to "hermes-ops" only, it is not stored anywhere):
|
|
47
|
+
# emk_…
|
|
48
|
+
# clients file entry:
|
|
49
|
+
# {"id":"hermes-ops","keys":["sha256:…"]}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
1. Ajoutez l'entrée dans `/config/clients.json` (voir [`deploy/clients.example.json`](../deploy/clients.example.json)) :
|
|
53
|
+
`maxPerHour`, `maxPerDay` (ne peuvent que baisser les limites du serveur), `allowedRecipients`
|
|
54
|
+
(restreint l'allowlist du serveur), `templatesOnly` (routines), `disabled`.
|
|
55
|
+
2. Déposez la clé dans le coffre de l'agent (secret Hermes), jamais dans un dépôt, un ticket ou un
|
|
56
|
+
message.
|
|
57
|
+
3. Le fichier est relu à chaque modification : **pas de redémarrage**. S'il devient invalide (JSON
|
|
58
|
+
cassé, clé brute au lieu d'une empreinte…), **toutes** les requêtes sont refusées jusqu'à
|
|
59
|
+
correction, et un avertissement part sur stderr.
|
|
60
|
+
|
|
61
|
+
### Rotation sans coupure
|
|
62
|
+
|
|
63
|
+
1. `keygen` pour le même client → ajoutez la nouvelle empreinte **à côté** de l'ancienne (`"keys": [ancienne, nouvelle]`).
|
|
64
|
+
2. Mettez la nouvelle clé dans le coffre de l'agent, vérifiez un appel (`email_config`).
|
|
65
|
+
3. Retirez l'ancienne empreinte du fichier.
|
|
66
|
+
|
|
67
|
+
### Révocation
|
|
68
|
+
|
|
69
|
+
Retirez l'empreinte (ou passez `"disabled": true`) : effet à la requête suivante. Une clé
|
|
70
|
+
compromise se révoque **avant** d'en émettre une nouvelle.
|
|
71
|
+
|
|
72
|
+
## 4. Coupe-circuit
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
docker exec <conteneur> touch /data/STOP # tout envoi est refusé immédiatement (aperçu et envoi)
|
|
76
|
+
docker exec <conteneur> rm /data/STOP # reprise
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`/health` passe à `"sending":"disabled"`. Pour un seul agent : `"disabled": true` dans `clients.json`.
|
|
80
|
+
|
|
81
|
+
## 5. Webhook Scaleway TEM (suivi des livraisons)
|
|
82
|
+
|
|
83
|
+
TEM publie ses événements dans un **topic Topics and Events (SNS)** ; le serveur s'y abonne en HTTPS.
|
|
84
|
+
|
|
85
|
+
1. `EMAIL_WEBHOOK_SECRET=$(openssl rand -hex 32)` dans l'environnement du serveur.
|
|
86
|
+
2. Topics and Events : créer un topic dans la région du domaine, puis un **abonnement HTTPS** vers
|
|
87
|
+
`https://email.mcp.pulsemyit.fr/webhooks/tem/<EMAIL_WEBHOOK_SECRET>`. Le serveur confirme lui-même
|
|
88
|
+
l'abonnement, **uniquement** si l'URL de confirmation est `https://sns.mnq.<région>.scaleway.com/…`.
|
|
89
|
+
3. Transactional Email → Webhooks : créer un webhook sur le domaine, avec l'ARN du topic et les
|
|
90
|
+
types `email_delivered`, `email_dropped`, `email_deferred`, `email_spam`,
|
|
91
|
+
`email_mailbox_not_found`, `email_blocklisted`.
|
|
92
|
+
|
|
93
|
+
Ce qui est gardé : le **type** et la **date** de l'événement, et seulement pour les e-mails envoyés
|
|
94
|
+
par ce serveur (connus du registre). Les événements d'un autre projet ou d'un autre e-mail sont
|
|
95
|
+
ignorés (202). Mauvais secret → 404. Les agents les lisent dans `email_status` (`deliveryEvents`).
|
|
96
|
+
Les suppressions (rebond dur, plainte) se lisent avec `email_suppressions` (adresses autorisées seulement).
|
|
97
|
+
|
|
98
|
+
Sans webhook, `email_status` l'indique (`"deliveryEvents": "webhook not configured"`) au lieu de
|
|
99
|
+
renvoyer une liste vide ambiguë.
|
|
100
|
+
|
|
101
|
+
## 6. Recette sans envoi réel
|
|
102
|
+
|
|
103
|
+
1. **Démarrage en échec** : lancer l'image sans `SCW_SECRET_KEY` → sortie immédiate avec
|
|
104
|
+
`Missing required env var: SCW_SECRET_KEY`. Sans fichier clients → `Missing client keys`.
|
|
105
|
+
2. **Contrôle de déploiement** (aucun envoi : seul un aperçu est demandé, son jeton est jeté) :
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
EMAIL_MCP_URL=https://email.mcp.pulsemyit.fr \
|
|
109
|
+
EMAIL_MCP_KEY=emk_… EMAIL_MCP_CHECK_TO=vous@pulsemyit.fr \
|
|
110
|
+
node packages/email-mcp/scripts/deploy-check.mjs
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Vérifie `/health` (et qu'il ne révèle rien), le refus sans clé et avec une mauvaise clé (401),
|
|
114
|
+
`initialize`, `tools/list`, `email_config` et un `email_preview` dont l'expéditeur effectif est
|
|
115
|
+
`noreply@pulsemyit.fr`.
|
|
116
|
+
3. **Deux clients** : refaire 2. avec la clé d'un second agent ; dans le journal d'audit, chaque
|
|
117
|
+
entrée porte son `client`.
|
|
118
|
+
4. **Coupe-circuit** : `touch /data/STOP`, refaire 2. → l'aperçu est refusé (`kill switch`) ; `rm`.
|
|
119
|
+
5. Le **premier envoi réel** (allowlist réduite à une adresse de l'équipe) est une décision séparée.
|
|
120
|
+
|
|
121
|
+
## 7. Journal d'audit
|
|
122
|
+
|
|
123
|
+
Une ligne JSON par aperçu, envoi, rejeu, refus ou échec, sur stderr et dans `EMAIL_AUDIT_LOG_FILE`
|
|
124
|
+
(`/data/audit.jsonl`, mode 600) : `timestamp`, `tool`, `client`, `result`, `from`, `to`, `cc`,
|
|
125
|
+
`subject`, `messageDigest`, `template`, `attachments` (type, taille, sha256), `temEmailIds`,
|
|
126
|
+
`idempotencyKey`, `errorCode`, `approvalRef`. **Jamais** de corps, de contenu ni de nom de pièce
|
|
127
|
+
jointe, de clé cliente ou de secret ; chaque ligne passe par le masquage central. Les refus
|
|
128
|
+
d'authentification sont journalisés (`tool: "http"`, `errorCode: "unauthorized"`) sans la clé reçue.
|
|
129
|
+
|
|
130
|
+
## 8. Limites connues
|
|
131
|
+
|
|
132
|
+
- Quotas en mémoire : un redémarrage les remet à zéro (le registre empêche toujours les doublons).
|
|
133
|
+
- Une seule instance : le registre est un fichier local, pas de réplication.
|
|
134
|
+
- Pièces jointes : jointes au message (pas de lien de téléchargement temporaire). L'API TEM refuse
|
|
135
|
+
tout e-mail de plus de 2 Mo (non augmentable) : au-delà, le serveur passe par le SMTP de TEM
|
|
136
|
+
(`EMAIL_SEND_VIA=auto`, port sortant 2465 à ouvrir), limité à 50 Mo par e-mail, soit 35 Mo de
|
|
137
|
+
pièces jointes par défaut. Avec `EMAIL_SEND_VIA=api`, la limite reste 2 Mo.
|
|
138
|
+
- Envois SMTP : pas d'identifiant TEM au moment de l'envoi. `email_status` (par
|
|
139
|
+
`idempotency_key`) les retrouve quand TEM liste l'e-mail ; les événements webhook reçus avant
|
|
140
|
+
ne sont pas rattachés.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aidalinfo/email-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Outbound email MCP server over Scaleway Transactional Email (TEM) —
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Outbound email MCP server over Scaleway Transactional Email (TEM) — stdio or central Streamable HTTP with per-client Bearer keys, fixed sender, recipient allowlist, preview then confirmed idempotent sends, quotas, kill switch, versioned templates, controlled attachments, delivery webhook and audit",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"bin": {
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"dist",
|
|
12
|
-
"README.md"
|
|
12
|
+
"README.md",
|
|
13
|
+
"docs",
|
|
14
|
+
"deploy"
|
|
13
15
|
],
|
|
14
16
|
"publishConfig": {
|
|
15
17
|
"access": "public"
|
|
@@ -42,11 +44,13 @@
|
|
|
42
44
|
},
|
|
43
45
|
"dependencies": {
|
|
44
46
|
"@modelcontextprotocol/sdk": "^1.27.0",
|
|
47
|
+
"nodemailer": "^10.5.0",
|
|
45
48
|
"zod": "^4.4.3"
|
|
46
49
|
},
|
|
47
50
|
"devDependencies": {
|
|
48
51
|
"@jest/globals": "^30.3.0",
|
|
49
52
|
"@types/node": "^26.6.5",
|
|
53
|
+
"@types/nodemailer": "^8.0.2",
|
|
50
54
|
"jest": "^30.3.0",
|
|
51
55
|
"ts-jest": "^29.4.6",
|
|
52
56
|
"typescript": "^5.7.0"
|