@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.
@@ -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.1.1",
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",
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"