feat(stalwart-mcp): serveur MCP JMAP pour tower

stalwart-mcp (Rust) parle JMAP au Stalwart du VPS, patche pour l'URL
API et l'envoi depuis l'identite marc. Mot de passe dedie dans
secrets/stalwart-mcp.yml ; importe par tower seulement, pour eviter
le conflit de cle sops avec services/stalwart sur anywhere.
This commit is contained in:
Hermes Agent
2026-10-19 02:07:33 -04:00
parent 4f5acfaf61
commit 7615d98b36
6 changed files with 3571 additions and 28 deletions
+2
View File
@@ -13,6 +13,8 @@ in {
../../hosts/tower/disko.nix
../../hosts/laptop/hardware-configuration.nix
../../modules/nixos/herdr
# MCP Stalwart (JMAP vers le VPS) : tower uniquement, hermes y tourne.
../../services/stalwart-mcp
../../services/monero-miner
];
+16
View File
@@ -0,0 +1,16 @@
stalwart_mcp_password: ENC[AES256_GCM,data:lr1CrpDpC5vtsCsol4bsm6IGah8=,iv:+9wtsjQE9i2meJx/tjZoQYma0aa/7P0O9xjkiR74LWI=,tag:RSHmDBQKADBlV0a220w5nQ==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBnQUJ0am9tTjE3UUU3RGJW
QnVjN1hvNW1yS05wMGJUTFRpMTNsTGIyMW40CnRFNFBJOEhYSm5vNzFDSGNCTDBV
TktkYStKdGZzUUh4N0w5RnNHTE5kSWsKLS0tIExQZ1RmOFoweFN1KzU1YTUyNVFQ
elpJVGtsQTN1VXBjdU4wK2d3Z2JNRGMKVJr4io/6ZocaKqux8Vs7ZmufT+vNhxb0
L5bs91y28fCG1gr5B0nyqMruSy5+Oo90e5zgCb3NUoOCt1qU56uxOQ==
-----END AGE ENCRYPTED FILE-----
recipient: age1usm3v0ugducrzcwud60dda7xfnj49fdhecj7kcsfm7dzwzrzeyqq2jc2hm
lastmodified: "2026-10-18T18:08:29Z"
mac: ENC[AES256_GCM,data:sT/3JnWwdcA0SVWmOCcLlCfVYcY8mHkUeC9NYaAGpAZIOU/09VTcR1GOsZ7FqSyTO6q5h/tUiUyQ37/zS2/RMucnBFXeDtcdlLPNmk8+Ttj4QWy6fcN5BCh6Kp55wZ2R00/KPf4l6dloZ/zeOVEVSnJnjKt/q+ratQHM/dXJO5Q=,iv:yTvu6Sb5I2ZfmDG+81rzGdTLaMRim//FU+j4tvqMUvk=,tag:7gxnUBB+8u6tvTnmJ0UTsw==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.3
+3423
View File
File diff suppressed because it is too large Load Diff
+58 -28
View File
@@ -1,52 +1,82 @@
# services/stalwart-mcp/default.nix
# Rust MCP server pour Stalwart Mail Server (webpatser/stalwart-mcp).
# Expose les outils mail via JMAP (lecture, recherche, notifications temps réel).
# Transport stdio : hermes spawn le binaire en subprocess.
# Compte utilisé : "marc" (déjà créé dans services/stalwart/default.nix).
# JMAP endpoint : https://mail.vps.marcpartensky.com (Traefik -> 127.0.0.1:8390).
# Serveur MCP pour Stalwart Mail Server (webpatser/stalwart-mcp), en Rust.
# Parle JMAP à https://mail.vps.marcpartensky.com (Traefik -> 127.0.0.1:8390
# sur le VPS) : aucune ouverture de port supplémentaire, aucun mail ne transite
# par un tiers. Transport stdio : hermes spawn le binaire en subprocess.
#
# Le binaire upstream n'a PAS de Cargo.lock : celui du dépôt a été généré
# depuis le commit épinglé dans flake.nix (cargo generate-lockfile) et doit
# être régénéré à chaque bump de l'input. Sans lui, buildRustPackage échoue
# (fetch-cargo-vendor exige un lock).
{ pkgs, inputs, config, lib, ... }: let
# Build depuis le repo GitHub (Rust, Cargo.toml) via flake input
stalwart-mcp = pkgs.rustPlatform.buildRustPackage {
pname = "stalwart-mcp";
version = "0.1.0";
src = inputs.stalwart-mcp;
# Hash SRI du dossier vendor/ calculé localement (recursive hash)
cargoHash = "sha256-3eMzoPZPO0S9D3Z0Tkkz894QHygU/osMFDTYQ6S+F04=";
version = "0.1.0-unstable-2026-04-14";
# L'upstream n'a pas de Cargo.lock : on l'injecte dans la source pour que
# le hook cargo le trouve (et pour figer les versions résolues).
src = pkgs.runCommandLocal "stalwart-mcp-source" {} ''
cp -r ${inputs.stalwart-mcp} $out
chmod -R u+w $out
cp ${./Cargo.lock} $out/Cargo.lock
'';
cargoHash = "sha256-JVuPj9UTA+77JCeVKRZTBbYOxe8l7KmcyyayfxR0VDc=";
# Stalwart 0.15 annonce son adresse de bind (mx1:8390) dans apiUrl quand il
# est derrière un reverse proxy ; le client JMAP suit cette URL et échoue.
# Le patch garde le chemin annoncé mais réutilise l'origine configurée.
preBuild = ''
patch -p1 -d ../$name-vendor/source-registry-0/jmap-client-0.4.3 < ${./jmap-api-url.patch}
patch -p1 < ${./send-from-identity.patch}
'';
meta = {
description = "Rust MCP server for Stalwart Mail Server — AI mail access via JMAP";
homepage = "https://github.com/webpatser/stalwart-mcp";
license = lib.licenses.mit;
license = lib.licenses.agpl3Only;
mainProgram = "stalwart-mcp";
};
};
# Config file pour stalwart-mcp (TOML), injecté via variable d'env
configContent = ''
# Le mot de passe n'est lu que depuis l'environnement (password_env), jamais
# depuis le TOML : le fichier de config peut donc vivre dans le /nix/store.
configFile = pkgs.writeText "stalwart-mcp-config.toml" ''
[[accounts]]
name = "marc"
url = "https://mail.vps.marcpartensky.com"
username = "marc@marcpartensky.com"
secret = "${config.sops.secrets.\"stalwart/mail_pw2\".path}"
# Stalwart authentifie le PRINCIPAL ("marc"), pas l'adresse email.
username = "marc"
password_env = "STALWART_PASSWORD"
[capabilities]
# mail_send / mail_draft / mail_flag / mail_move activés.
send = true
[notifications]
# Pas de flux SSE permanent : inutile pour un client stdio piloté par hermes.
enabled = false
'';
# Le secret sops est un fichier ; le serveur attend une variable d'env.
# Le wrapper évite de recopier le mot de passe dans config.yaml ou dans le store.
wrapper = pkgs.writeShellScriptBin "stalwart-mcp-hermes" ''
set -eu
STALWART_PASSWORD="$(cat ${config.sops.secrets."stalwart-mcp/password".path})"
export STALWART_PASSWORD
exec ${stalwart-mcp}/bin/stalwart-mcp -c ${configFile} "$@"
'';
in {
# Référence le secret existant dans secrets/common.yml (clé stalwart_mail_pw2)
sops.secrets."stalwart/mail_pw2" = {
sopsFile = ../../secrets/common.yml;
key = "stalwart_mail_pw2";
# Secret dédié au MCP, chiffré pour tower uniquement (cf. secrets/stalwart-mcp.yml).
# Il contient le même mot de passe que stalwart_mail_password sur le VPS,
# mais tower ne peut pas déchiffrer anywhere.yml.
sops.secrets."stalwart-mcp/password" = {
sopsFile = ../../secrets/stalwart-mcp.yml;
key = "stalwart_mcp_password";
owner = "hermes";
group = "hermes";
mode = "0400";
};
# Écrit le fichier de config dans un endroit lisible par hermes
environment.etc."stalwart-mcp/config.toml".text = configContent;
services.hermes-agent.mcpServers.stalwart = {
command = "${stalwart-mcp}/bin/stalwart-mcp";
env = {
STALWART_MCP_CONFIG = "/etc/stalwart-mcp/config.toml";
MCP_TRANSPORT = "stdio";
};
command = "${wrapper}/bin/stalwart-mcp-hermes";
args = [ "serve" ];
timeout = 60;
};
}
}
+25
View File
@@ -0,0 +1,25 @@
--- a/src/client.rs
+++ b/src/client.rs
@@ -254,7 +254,21 @@
upload_url: URLPart::parse(session.upload_url())?,
#[cfg(feature = "async")]
event_source_url: URLPart::parse(session.event_source_url())?,
- api_url: session.api_url().to_string(),
+ // Stalwart 0.15 behind a reverse proxy advertises its bind address
+ // (mx1:8390) in apiUrl. Keep the advertised path but use the origin
+ // we actually connected to.
+ api_url: {
+ let api = session.api_url();
+ let origin_end = session_url
+ .find("://")
+ .and_then(|i| session_url[i + 3..].find('/').map(|j| i + 3 + j))
+ .unwrap_or(session_url.len());
+ let path_start = api
+ .find("://")
+ .and_then(|i| api[i + 3..].find('/').map(|j| i + 3 + j))
+ .unwrap_or(api.len());
+ format!("{}{}", &session_url[..origin_end], &api[path_start..])
+ },
session: parking_lot::Mutex::new(Arc::new(session)),
session_url,
session_updated: true.into(),
@@ -0,0 +1,47 @@
--- a/src/jmap/mail.rs
+++ b/src/jmap/mail.rs
@@ -418,11 +418,14 @@
/// Send an email via EmailSubmission
pub async fn send_email(&self, compose: &ComposeEmail<'_>) -> Result<String, AppError> {
- // Find the Sent mailbox
- let sent_id = self
- .resolve_mailbox_id("Sent")
- .await?
- .ok_or_else(|| AppError::NotFound("Sent mailbox not found".into()))?;
+ // Find the Sent mailbox (par rôle d'abord : le nom peut être "Sent Items")
+ let sent_id = match self.resolve_mailbox_by_role("Sent").await? {
+ Some(id) => id,
+ None => self
+ .resolve_mailbox_id("Sent")
+ .await?
+ .ok_or_else(|| AppError::NotFound("Sent mailbox not found".into()))?,
+ };
// Get identity ID by listing all identities
let mut request = self.client.build();
@@ -439,6 +442,15 @@
.ok_or_else(|| AppError::JmapRequest("No identity found for sending".into()))?
.to_string();
+ // Le username JMAP de Stalwart est le principal ("marc"), pas une
+ // adresse : le header From doit venir de l'identité, pas de username().
+ let identity_email = identity_response
+ .list()
+ .first()
+ .and_then(|i| i.email())
+ .ok_or_else(|| AppError::JmapRequest("No identity email found for sending".into()))?
+ .to_string();
+
let mut request = self.client.build();
// Create the email in Sent
@@ -446,7 +458,7 @@
email
.mailbox_ids([&sent_id])
.keywords(["$seen"])
- .from([compose.from])
+ .from([identity_email.as_str()])
.to(compose.to.iter().map(|a| a.as_str()))
.subject(compose.subject)
.body_value("1".to_string(), compose.body)