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:
@@ -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
|
||||
];
|
||||
|
||||
|
||||
@@ -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
|
||||
Generated
+3423
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user