mail-mcp v0.1 — Rust MCP server for Sulkta email
Phase A: mail_send + mail_inbox_list + mail_inbox_read. Replaces scripts/kayos_mail.py with a typed MCP server. Outbound guarantees Date, Message-ID (own-domain), User-Agent, MIME-Version, multipart/alternative for HTML+text, multipart/mixed for attachments, In-Reply-To + References for threading. Single account in v0.1 (default_account from config). Phase B adds multi-account + threading + search; Phase C adds mark + attachments + reply helper. Stack: rmcp 0.1 (matches aldabra), lettre 0.11 + tokio-rustls, async-imap 0.10, mail-parser 0.9. Stderr-only logging (stdout is the MCP transport). Smoke verified 2026-05-21: send -> land -> read kayos@sulkta.com round trip, DKIM-Signature + Authentication-Results pass at the rspamd relay.
This commit is contained in:
commit
2240bf745e
11 changed files with 3552 additions and 0 deletions
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
target/
|
||||||
|
**/*.rs.bk
|
||||||
|
Cargo.lock.bak
|
||||||
|
|
||||||
|
# Local config files MUST NOT be tracked — they carry credentials.
|
||||||
|
config.toml
|
||||||
|
**/config.toml
|
||||||
|
!config.example.toml
|
||||||
|
|
||||||
|
# Editor / OS junk
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
2246
Cargo.lock
generated
Normal file
2246
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
77
Cargo.toml
Normal file
77
Cargo.toml
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
# Cargo workspace root for mail-mcp.
|
||||||
|
#
|
||||||
|
# One crate today (mail-mcp), workspace shape so we can grow without
|
||||||
|
# rework. Same pattern as aldabra.
|
||||||
|
#
|
||||||
|
# Workspace deps pinned here; each crate references with `foo = { workspace = true }`.
|
||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = ["crates/mail-mcp"]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
license = "MIT"
|
||||||
|
repository = "http://192.168.0.5:3001/Sulkta-Coop/mail-mcp"
|
||||||
|
authors = ["Cobb <cobb@sulkta.com>", "Kayos <kayos@sulkta.com>"]
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|
||||||
|
# MCP — same crate aldabra uses. Pinned to 0.1 series; bump together
|
||||||
|
# across repos when we move.
|
||||||
|
rmcp = { version = "0.1", features = ["server", "transport-io"] }
|
||||||
|
schemars = "0.8"
|
||||||
|
|
||||||
|
# SMTP — lettre handles RFC-5322 headers (Date, Message-ID), STARTTLS,
|
||||||
|
# multipart/alternative + multipart/mixed natively. rustls-tls so we
|
||||||
|
# don't pull openssl.
|
||||||
|
lettre = { version = "0.11", default-features = false, features = [
|
||||||
|
"tokio1-rustls-tls",
|
||||||
|
"smtp-transport",
|
||||||
|
"builder",
|
||||||
|
"hostname",
|
||||||
|
] }
|
||||||
|
|
||||||
|
# IMAP — async-imap is tokio-native and supports UID-based addressing
|
||||||
|
# (which we use throughout the API surface).
|
||||||
|
async-imap = { version = "0.10", default-features = false, features = ["runtime-tokio"] }
|
||||||
|
tokio-rustls = "0.26"
|
||||||
|
rustls = { version = "0.23", default-features = false, features = ["std", "tls12", "ring"] }
|
||||||
|
rustls-pki-types = "1"
|
||||||
|
webpki-roots = "0.26"
|
||||||
|
|
||||||
|
# Email parsing on the read side. mail-parser is fast, no_std-friendly,
|
||||||
|
# and handles the RFC-5322 + MIME zoo without surprises.
|
||||||
|
mail-parser = "0.9"
|
||||||
|
|
||||||
|
# Config + serde
|
||||||
|
toml = "0.8"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
# UUID for Message-ID generation when lettre's auto isn't appropriate
|
||||||
|
# (we want our own domain in the Message-ID, not lettre's local-hostname
|
||||||
|
# default).
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
|
||||||
|
# Base64 for attachments
|
||||||
|
base64 = "0.22"
|
||||||
|
|
||||||
|
# Errors
|
||||||
|
anyhow = "1"
|
||||||
|
thiserror = "1"
|
||||||
|
|
||||||
|
# Stream adapter (.next() on async-imap fetch streams)
|
||||||
|
futures = "0.3"
|
||||||
|
|
||||||
|
# Logging — stderr only, never stdout (stdio is the MCP transport).
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
|
# Dirs lookup for `~/.config/mail-mcp/config.toml` default path
|
||||||
|
dirs = "5"
|
||||||
|
|
||||||
|
# Shell-style env-var expansion for the `password_file` setting
|
||||||
|
# (`~/.config/...` paths). shellexpand is small + maintained.
|
||||||
|
shellexpand = "3"
|
||||||
73
README.md
Normal file
73
README.md
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
# mail-mcp
|
||||||
|
|
||||||
|
Rust MCP server for Sulkta-hosted email. SMTP send + IMAP read with RFC-correct headers, multipart/alternative when HTML is included, multipart/mixed for attachments, threading via `In-Reply-To`/`References`.
|
||||||
|
|
||||||
|
Replaces the `scripts/kayos_mail.py` CLI path that lived in `kayos/openclaw-workspace` since 2026-04-23.
|
||||||
|
|
||||||
|
## Why a server, not a CLI
|
||||||
|
|
||||||
|
`kayos_mail.py` shipped without `Date` or `Message-ID` headers until a 2026-05-18 patch — exactly the kind of header-discipline regression a typed Rust server prevents at compile time. The "no spam bin" framing is mostly upstream of any client (Rackham postfix + rspamd DKIM-sign at the relay; mail-tester scored 10/10 and port25 SpamAssassin −7.31 on 2026-05-20), but a correct client doesn't trip filters with bad MIME structure, broken threading, or missing headers.
|
||||||
|
|
||||||
|
## Tools (v0.1)
|
||||||
|
|
||||||
|
- `mail_send` — send mail. Args: `account?`, `to`, `cc[]?`, `bcc[]?`, `subject`, `body`, `body_html?`, `attachments[]?`, `in_reply_to?`, `references[]?`. Returns `{message_id, sent_at}`.
|
||||||
|
- `mail_inbox_list` — list folder messages newest-first. Args: `account?`, `since?` (YYYY-MM-DD), `unread_only?`, `limit?` (default 50, max 500), `folder?` (default INBOX). Uses `BODY.PEEK` so it does not toggle `\Seen`.
|
||||||
|
- `mail_inbox_read` — fetch one message by UID. Args: `account?`, `uid`, `folder?`, `format?` (`text`|`html`|`raw_eml`). Attachment payloads are not inlined — only filename/mime_type/size metadata.
|
||||||
|
|
||||||
|
## Headers we guarantee on outbound
|
||||||
|
|
||||||
|
- `Date` — UTC, RFC 5322 (lettre auto)
|
||||||
|
- `Message-ID` — `<UUIDv4@<from_addr_domain>>` — own-domain, never the container hostname
|
||||||
|
- `From` — `name <addr>`
|
||||||
|
- `MIME-Version: 1.0`
|
||||||
|
- `User-Agent: mail-mcp/<version>`
|
||||||
|
- `In-Reply-To` + `References` when threading args present
|
||||||
|
- `Content-Type` correct for the body shape (text-only / alternative / mixed)
|
||||||
|
|
||||||
|
DKIM-Signature is applied by the relay (rspamd on Rackham), not the client.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
Binary lands at `target/release/mail-mcp`.
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.config/mail-mcp
|
||||||
|
cp config.example.toml ~/.config/mail-mcp/config.toml
|
||||||
|
chmod 600 ~/.config/mail-mcp/config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit accounts as needed. Passwords are NEVER inline:
|
||||||
|
|
||||||
|
1. Looked up from the env var named in `password_env`
|
||||||
|
2. Falling back to `password_file` (shell-format: `KEY=VALUE` per line)
|
||||||
|
3. Hard-failing with a vault-pointer hint if neither resolves
|
||||||
|
|
||||||
|
Vault canonical: `bw.sulkta.com` → `kayos@sulkta.com — IMAP/SMTP`.
|
||||||
|
|
||||||
|
## MCP wiring (Claude Code / kayos-house)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"mail-mcp": {
|
||||||
|
"command": "/usr/local/bin/mail-mcp",
|
||||||
|
"args": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Logging is stderr-only — stdout is the JSON-RPC transport.
|
||||||
|
|
||||||
|
## Future phases
|
||||||
|
|
||||||
|
- **Phase B** (~200 LOC): multi-account routing across all configured `[accounts.*]`, plus `mail_thread` and `mail_search`.
|
||||||
|
- **Phase C** (~150 LOC): `mail_mark` (read/unread/flag/trash/archive), `mail_attachment_get`, `mail_reply` helper.
|
||||||
|
|
||||||
|
Full locked spec: `kayos/openclaw-workspace` → `memory/spec-mail-mcp.md`.
|
||||||
27
config.example.toml
Normal file
27
config.example.toml
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# mail-mcp config — copy to ~/.config/mail-mcp/config.toml, chmod 600.
|
||||||
|
#
|
||||||
|
# Passwords are NEVER inline. Each account names an env var (`password_env`)
|
||||||
|
# AND a fallback file (`password_file`). Lookup order:
|
||||||
|
# 1. env var
|
||||||
|
# 2. shell-format file (`KEY=VALUE` per line)
|
||||||
|
# 3. hard fail with a vault-pointer hint
|
||||||
|
#
|
||||||
|
# Vault canonical: bw.sulkta.com → "kayos@sulkta.com — IMAP/SMTP".
|
||||||
|
|
||||||
|
default_account = "kayos"
|
||||||
|
|
||||||
|
[accounts.kayos]
|
||||||
|
from_name = "Kayos"
|
||||||
|
from_addr = "kayos@sulkta.com"
|
||||||
|
smtp_host = "mail.sulkta.com"
|
||||||
|
smtp_port = 587
|
||||||
|
smtp_starttls = true
|
||||||
|
imap_host = "mail.sulkta.com"
|
||||||
|
imap_port = 993
|
||||||
|
imap_tls = true
|
||||||
|
username = "kayos@sulkta.com"
|
||||||
|
password_env = "KAYOS_SMTP_PASS"
|
||||||
|
password_file = "~/.config/kayos-mail/smtp.env"
|
||||||
|
# Optional: pin Message-ID domain. Defaults to the part of `from_addr`
|
||||||
|
# after the @ if unset.
|
||||||
|
# message_id_domain = "sulkta.com"
|
||||||
43
crates/mail-mcp/Cargo.toml
Normal file
43
crates/mail-mcp/Cargo.toml
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# mail-mcp — the binary. Stdio MCP server exposing SMTP send + IMAP
|
||||||
|
# read tools. Spawned per-session by any MCP client.
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "mail-mcp"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
description = "Rust MCP server for Sulkta-hosted email (SMTP send + IMAP read)"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "mail-mcp"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tokio = { workspace = true }
|
||||||
|
rmcp = { workspace = true }
|
||||||
|
schemars = { workspace = true }
|
||||||
|
|
||||||
|
lettre = { workspace = true }
|
||||||
|
async-imap = { workspace = true }
|
||||||
|
tokio-rustls = { workspace = true }
|
||||||
|
rustls = { workspace = true }
|
||||||
|
rustls-pki-types = { workspace = true }
|
||||||
|
webpki-roots = { workspace = true }
|
||||||
|
mail-parser = { workspace = true }
|
||||||
|
|
||||||
|
toml = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
base64 = { workspace = true }
|
||||||
|
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
futures = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
tracing-subscriber = { workspace = true }
|
||||||
|
|
||||||
|
dirs = { workspace = true }
|
||||||
|
shellexpand = { workspace = true }
|
||||||
134
crates/mail-mcp/src/config.rs
Normal file
134
crates/mail-mcp/src/config.rs
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
//! TOML config + password resolution.
|
||||||
|
//!
|
||||||
|
//! Config path: `$MAIL_MCP_CONFIG` env, or `~/.config/mail-mcp/config.toml`.
|
||||||
|
//!
|
||||||
|
//! Password lookup per account:
|
||||||
|
//! 1. env var named by `password_env`
|
||||||
|
//! 2. file at `password_file` (shell-format: `KEY=VALUE`)
|
||||||
|
//! 3. hard fail with a vault-pointer hint — never silent
|
||||||
|
//!
|
||||||
|
//! Vault canonical: `bw.sulkta.com` → `kayos@sulkta.com — IMAP/SMTP`.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
pub default_account: String,
|
||||||
|
pub accounts: HashMap<String, Account>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Account {
|
||||||
|
pub from_name: String,
|
||||||
|
pub from_addr: String,
|
||||||
|
pub smtp_host: String,
|
||||||
|
pub smtp_port: u16,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub smtp_starttls: bool,
|
||||||
|
pub imap_host: String,
|
||||||
|
pub imap_port: u16,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub imap_tls: bool,
|
||||||
|
pub username: String,
|
||||||
|
pub password_env: String,
|
||||||
|
pub password_file: Option<String>,
|
||||||
|
/// If unset, derived from the part of `from_addr` after `@`.
|
||||||
|
pub message_id_domain: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn load() -> Result<Self> {
|
||||||
|
let path = config_path()?;
|
||||||
|
let text = std::fs::read_to_string(&path)
|
||||||
|
.with_context(|| format!("read config {}", path.display()))?;
|
||||||
|
let cfg: Self = toml::from_str(&text)
|
||||||
|
.with_context(|| format!("parse config {}", path.display()))?;
|
||||||
|
if !cfg.accounts.contains_key(&cfg.default_account) {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"default_account `{}` not in [accounts.*]",
|
||||||
|
cfg.default_account
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the named account (or `default_account` if `None`).
|
||||||
|
pub fn account<'a>(&'a self, name: Option<&str>) -> Result<&'a Account> {
|
||||||
|
let key = name.unwrap_or(&self.default_account);
|
||||||
|
self.accounts
|
||||||
|
.get(key)
|
||||||
|
.ok_or_else(|| anyhow!("unknown account `{key}` — check [accounts.*] in config.toml"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Account {
|
||||||
|
/// Resolve the password from env, then from `password_file`, then fail.
|
||||||
|
pub fn resolve_password(&self) -> Result<String> {
|
||||||
|
if let Ok(v) = std::env::var(&self.password_env) {
|
||||||
|
if !v.is_empty() {
|
||||||
|
return Ok(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(path) = &self.password_file {
|
||||||
|
let expanded = shellexpand::tilde(path).into_owned();
|
||||||
|
if std::path::Path::new(&expanded).is_file() {
|
||||||
|
let text = std::fs::read_to_string(&expanded)
|
||||||
|
.with_context(|| format!("read password_file {expanded}"))?;
|
||||||
|
for raw in text.lines() {
|
||||||
|
let line = raw.trim();
|
||||||
|
if line.is_empty() || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some((k, v)) = line.split_once('=') {
|
||||||
|
if k.trim() == self.password_env {
|
||||||
|
return Ok(strip_quotes(v.trim()).to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(anyhow!(
|
||||||
|
"no password for `{}`. Set ${} or write {}. Vault: bw.sulkta.com → `{} — IMAP/SMTP`",
|
||||||
|
self.username,
|
||||||
|
self.password_env,
|
||||||
|
self.password_file.as_deref().unwrap_or("(no file configured)"),
|
||||||
|
self.from_addr,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Domain used to qualify Message-IDs (so they read as `<uuid@sulkta.com>`,
|
||||||
|
/// not `<uuid@<container-hostname>>`).
|
||||||
|
pub fn msgid_domain(&self) -> &str {
|
||||||
|
if let Some(d) = &self.message_id_domain {
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
match self.from_addr.split_once('@') {
|
||||||
|
Some((_, d)) => d,
|
||||||
|
None => "localhost",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_quotes(s: &str) -> &str {
|
||||||
|
let s = s.strip_prefix('"').unwrap_or(s);
|
||||||
|
let s = s.strip_suffix('"').unwrap_or(s);
|
||||||
|
let s = s.strip_prefix('\'').unwrap_or(s);
|
||||||
|
s.strip_suffix('\'').unwrap_or(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_path() -> Result<PathBuf> {
|
||||||
|
if let Ok(p) = std::env::var("MAIL_MCP_CONFIG") {
|
||||||
|
return Ok(PathBuf::from(shellexpand::tilde(&p).into_owned()));
|
||||||
|
}
|
||||||
|
let home = dirs::config_dir()
|
||||||
|
.ok_or_else(|| anyhow!("could not resolve $XDG_CONFIG_HOME / ~/.config"))?;
|
||||||
|
Ok(home.join("mail-mcp").join("config.toml"))
|
||||||
|
}
|
||||||
383
crates/mail-mcp/src/imap.rs
Normal file
383
crates/mail-mcp/src/imap.rs
Normal file
|
|
@ -0,0 +1,383 @@
|
||||||
|
//! Inbound IMAP via `async-imap` + `tokio-rustls`.
|
||||||
|
//!
|
||||||
|
//! Two surfaces:
|
||||||
|
//! - `list(account, opts)` → newest-first summary array
|
||||||
|
//! - `read(account, uid, folder, format)` → full message
|
||||||
|
//!
|
||||||
|
//! UID-based addressing throughout (UID stays stable across folder selects,
|
||||||
|
//! sequence numbers don't).
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use async_imap::types::Fetch;
|
||||||
|
use futures::StreamExt;
|
||||||
|
use mail_parser::{MessageParser, MimeHeaders};
|
||||||
|
use rustls::pki_types::ServerName;
|
||||||
|
use serde::Serialize;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
use tokio_rustls::TlsConnector;
|
||||||
|
|
||||||
|
use crate::config::Account;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ListOpts {
|
||||||
|
pub since: Option<String>, // YYYY-MM-DD
|
||||||
|
pub unread_only: bool,
|
||||||
|
pub limit: u32, // 0 means default (50)
|
||||||
|
pub folder: Option<String>, // None → INBOX
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ListEntry {
|
||||||
|
pub uid: u32,
|
||||||
|
pub message_id: Option<String>,
|
||||||
|
pub from: Vec<String>,
|
||||||
|
pub to: Vec<String>,
|
||||||
|
pub subject: String,
|
||||||
|
pub date: Option<String>,
|
||||||
|
pub snippet: String,
|
||||||
|
pub has_attachments: bool,
|
||||||
|
pub flags: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ReadOutput {
|
||||||
|
pub uid: u32,
|
||||||
|
pub message_id: Option<String>,
|
||||||
|
pub from: Vec<String>,
|
||||||
|
pub to: Vec<String>,
|
||||||
|
pub cc: Vec<String>,
|
||||||
|
pub subject: String,
|
||||||
|
pub date: Option<String>,
|
||||||
|
pub headers: serde_json::Value,
|
||||||
|
pub body: String,
|
||||||
|
pub format: String,
|
||||||
|
pub attachments: Vec<AttachmentMeta>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct AttachmentMeta {
|
||||||
|
pub filename: String,
|
||||||
|
pub mime_type: String,
|
||||||
|
pub size: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_LIMIT: u32 = 50;
|
||||||
|
const MAX_LIMIT: u32 = 500;
|
||||||
|
const SNIPPET_LEN: usize = 240;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// list
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
pub async fn list(account: &Account, opts: ListOpts) -> Result<Vec<ListEntry>> {
|
||||||
|
let folder = opts.folder.as_deref().unwrap_or("INBOX");
|
||||||
|
let limit = match opts.limit {
|
||||||
|
0 => DEFAULT_LIMIT,
|
||||||
|
n if n > MAX_LIMIT => MAX_LIMIT,
|
||||||
|
n => n,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut session = open_session(account).await?;
|
||||||
|
session
|
||||||
|
.select(folder)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("SELECT {folder}"))?;
|
||||||
|
|
||||||
|
// Build the SEARCH query.
|
||||||
|
let mut search_terms: Vec<String> = vec!["ALL".into()];
|
||||||
|
if opts.unread_only {
|
||||||
|
search_terms = vec!["UNSEEN".into()];
|
||||||
|
}
|
||||||
|
if let Some(since) = &opts.since {
|
||||||
|
let imap_date = format_imap_since(since)
|
||||||
|
.with_context(|| format!("`since` must be YYYY-MM-DD, got `{since}`"))?;
|
||||||
|
search_terms.push(format!("SINCE {imap_date}"));
|
||||||
|
}
|
||||||
|
let query = search_terms.join(" ");
|
||||||
|
|
||||||
|
let uids: Vec<u32> = {
|
||||||
|
let set = session
|
||||||
|
.uid_search(&query)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("UID SEARCH {query}"))?;
|
||||||
|
let mut v: Vec<u32> = set.into_iter().collect();
|
||||||
|
v.sort_unstable_by(|a, b| b.cmp(a)); // newest UID first
|
||||||
|
v.truncate(limit as usize);
|
||||||
|
v
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut out: Vec<ListEntry> = Vec::with_capacity(uids.len());
|
||||||
|
if uids.is_empty() {
|
||||||
|
session.logout().await.ok();
|
||||||
|
return Ok(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
let seq = uids
|
||||||
|
.iter()
|
||||||
|
.map(|u| u.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",");
|
||||||
|
// BODY.PEEK so we don't toggle \Seen as a side effect of listing.
|
||||||
|
let fetch_query = "(UID FLAGS INTERNALDATE BODY.PEEK[HEADER] RFC822.SIZE)";
|
||||||
|
let mut stream = session
|
||||||
|
.uid_fetch(&seq, fetch_query)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("UID FETCH {seq}"))?;
|
||||||
|
|
||||||
|
while let Some(msg_res) = stream.next().await {
|
||||||
|
let msg = msg_res.context("UID FETCH stream item")?;
|
||||||
|
let entry = fetch_to_list_entry(&msg);
|
||||||
|
out.push(entry);
|
||||||
|
}
|
||||||
|
drop(stream);
|
||||||
|
|
||||||
|
session.logout().await.ok();
|
||||||
|
// Preserve newest-first ordering even if the server reordered.
|
||||||
|
out.sort_by(|a, b| b.uid.cmp(&a.uid));
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_to_list_entry(msg: &Fetch) -> ListEntry {
|
||||||
|
let uid = msg.uid.unwrap_or(0);
|
||||||
|
let flags: Vec<String> = msg.flags().map(|f| format!("{f:?}")).collect();
|
||||||
|
let header_bytes = msg.header().unwrap_or(&[]);
|
||||||
|
|
||||||
|
let parser = MessageParser::default();
|
||||||
|
let parsed = parser.parse(header_bytes);
|
||||||
|
|
||||||
|
let (from, to, subject, date, message_id) = if let Some(m) = parsed.as_ref() {
|
||||||
|
(
|
||||||
|
addr_list(m.from()),
|
||||||
|
addr_list(m.to()),
|
||||||
|
m.subject().unwrap_or_default().to_string(),
|
||||||
|
m.date().map(|d| d.to_rfc3339()),
|
||||||
|
m.message_id().map(|s| s.to_string()),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(vec![], vec![], String::new(), None, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
// We didn't fetch the body for the list view — snippet stays empty.
|
||||||
|
// (read() fetches the body separately.)
|
||||||
|
let snippet = String::new();
|
||||||
|
|
||||||
|
// has_attachments is best-guessed from Content-Type in the header
|
||||||
|
// block, since we don't pull the body. multipart/mixed almost always
|
||||||
|
// means attachments are present.
|
||||||
|
let has_attachments = parsed
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|m| m.content_type())
|
||||||
|
.map(|ct| {
|
||||||
|
let main = ct.ctype().to_ascii_lowercase();
|
||||||
|
let sub = ct.subtype().map(|s| s.to_ascii_lowercase());
|
||||||
|
main == "multipart" && sub.as_deref() == Some("mixed")
|
||||||
|
})
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
ListEntry {
|
||||||
|
uid,
|
||||||
|
message_id,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
date,
|
||||||
|
snippet,
|
||||||
|
has_attachments,
|
||||||
|
flags,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// read
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
pub async fn read(
|
||||||
|
account: &Account,
|
||||||
|
uid: u32,
|
||||||
|
folder: Option<&str>,
|
||||||
|
format: &str,
|
||||||
|
) -> Result<ReadOutput> {
|
||||||
|
let folder = folder.unwrap_or("INBOX");
|
||||||
|
let format = match format {
|
||||||
|
"text" | "html" | "raw_eml" => format,
|
||||||
|
other => {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"format must be one of `text`, `html`, `raw_eml` — got `{other}`"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut session = open_session(account).await?;
|
||||||
|
session
|
||||||
|
.select(folder)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("SELECT {folder}"))?;
|
||||||
|
|
||||||
|
// BODY[] = full RFC822 message. We parse with mail-parser, then either
|
||||||
|
// return the text part, html part, or raw.
|
||||||
|
let mut stream = session
|
||||||
|
.uid_fetch(uid.to_string(), "(UID FLAGS BODY.PEEK[])")
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("UID FETCH {uid}"))?;
|
||||||
|
|
||||||
|
let first = stream
|
||||||
|
.next()
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow!("no message at UID {uid} in {folder}"))?
|
||||||
|
.context("UID FETCH stream")?;
|
||||||
|
|
||||||
|
let raw_body = first.body().unwrap_or(&[]).to_vec();
|
||||||
|
drop(stream);
|
||||||
|
session.logout().await.ok();
|
||||||
|
|
||||||
|
let parser = MessageParser::default();
|
||||||
|
let parsed = parser
|
||||||
|
.parse(&raw_body)
|
||||||
|
.ok_or_else(|| anyhow!("could not parse message bytes"))?;
|
||||||
|
|
||||||
|
let body = match format {
|
||||||
|
"raw_eml" => String::from_utf8_lossy(&raw_body).into_owned(),
|
||||||
|
"html" => parsed
|
||||||
|
.body_html(0)
|
||||||
|
.map(|s| s.into_owned())
|
||||||
|
.or_else(|| parsed.body_text(0).map(|s| s.into_owned()))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
_ => parsed
|
||||||
|
.body_text(0)
|
||||||
|
.map(|s| s.into_owned())
|
||||||
|
.or_else(|| parsed.body_html(0).map(|s| s.into_owned()))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let attachments: Vec<AttachmentMeta> = parsed
|
||||||
|
.attachments()
|
||||||
|
.map(|att| AttachmentMeta {
|
||||||
|
filename: att.attachment_name().unwrap_or("attachment").to_string(),
|
||||||
|
mime_type: format!(
|
||||||
|
"{}/{}",
|
||||||
|
att.content_type()
|
||||||
|
.map(|ct| ct.ctype().to_string())
|
||||||
|
.unwrap_or_else(|| "application".into()),
|
||||||
|
att.content_type()
|
||||||
|
.and_then(|ct| ct.subtype().map(|s| s.to_string()))
|
||||||
|
.unwrap_or_else(|| "octet-stream".into()),
|
||||||
|
),
|
||||||
|
size: att.contents().len(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Headers as a flat JSON map (last-write-wins on duplicates is fine for v0.1).
|
||||||
|
let mut headers = serde_json::Map::new();
|
||||||
|
for h in parsed.headers() {
|
||||||
|
let name = h.name();
|
||||||
|
let val = h.value().as_text().map(|s| s.to_string()).unwrap_or_default();
|
||||||
|
headers.insert(name.to_string(), serde_json::Value::String(val));
|
||||||
|
}
|
||||||
|
|
||||||
|
let subject = parsed.subject().unwrap_or_default().to_string();
|
||||||
|
let snippet_unused: String = body.chars().take(SNIPPET_LEN).collect();
|
||||||
|
let _ = snippet_unused; // suppress unused (kept structure-wise for symmetry)
|
||||||
|
|
||||||
|
Ok(ReadOutput {
|
||||||
|
uid,
|
||||||
|
message_id: parsed.message_id().map(|s| s.to_string()),
|
||||||
|
from: addr_list(parsed.from()),
|
||||||
|
to: addr_list(parsed.to()),
|
||||||
|
cc: addr_list(parsed.cc()),
|
||||||
|
subject,
|
||||||
|
date: parsed.date().map(|d| d.to_rfc3339()),
|
||||||
|
headers: serde_json::Value::Object(headers),
|
||||||
|
body,
|
||||||
|
format: format.to_string(),
|
||||||
|
attachments,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// helpers
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
fn addr_list(addrs: Option<&mail_parser::Address>) -> Vec<String> {
|
||||||
|
let Some(addrs) = addrs else { return vec![] };
|
||||||
|
let mut out = vec![];
|
||||||
|
for a in addrs.iter() {
|
||||||
|
let email = a.address().unwrap_or("");
|
||||||
|
if email.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match a.name() {
|
||||||
|
Some(n) if !n.is_empty() => out.push(format!("{n} <{email}>")),
|
||||||
|
_ => out.push(email.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_imap_since(iso_date: &str) -> Result<String> {
|
||||||
|
// YYYY-MM-DD → DD-Mon-YYYY (IMAP requires uppercase 3-letter month).
|
||||||
|
let parts: Vec<&str> = iso_date.split('-').collect();
|
||||||
|
if parts.len() != 3 {
|
||||||
|
return Err(anyhow!("expected YYYY-MM-DD"));
|
||||||
|
}
|
||||||
|
let y: u32 = parts[0].parse().context("year")?;
|
||||||
|
let m: u32 = parts[1].parse().context("month")?;
|
||||||
|
let d: u32 = parts[2].parse().context("day")?;
|
||||||
|
let mon = match m {
|
||||||
|
1 => "Jan",
|
||||||
|
2 => "Feb",
|
||||||
|
3 => "Mar",
|
||||||
|
4 => "Apr",
|
||||||
|
5 => "May",
|
||||||
|
6 => "Jun",
|
||||||
|
7 => "Jul",
|
||||||
|
8 => "Aug",
|
||||||
|
9 => "Sep",
|
||||||
|
10 => "Oct",
|
||||||
|
11 => "Nov",
|
||||||
|
12 => "Dec",
|
||||||
|
_ => return Err(anyhow!("month must be 1..=12")),
|
||||||
|
};
|
||||||
|
Ok(format!("{d:02}-{mon}-{y:04}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn open_session(
|
||||||
|
account: &Account,
|
||||||
|
) -> Result<async_imap::Session<tokio_rustls::client::TlsStream<TcpStream>>> {
|
||||||
|
if !account.imap_tls {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"plain-IMAP (no TLS) not supported in v0.1 — set imap_tls=true and imap_port=993"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let addr = format!("{}:{}", account.imap_host, account.imap_port);
|
||||||
|
let tcp = TcpStream::connect(&addr)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("tcp connect {addr}"))?;
|
||||||
|
|
||||||
|
let root_store = rustls_roots();
|
||||||
|
let cfg = rustls::ClientConfig::builder()
|
||||||
|
.with_root_certificates(root_store)
|
||||||
|
.with_no_client_auth();
|
||||||
|
let connector = TlsConnector::from(Arc::new(cfg));
|
||||||
|
let server_name = ServerName::try_from(account.imap_host.clone())
|
||||||
|
.with_context(|| format!("server name `{}`", account.imap_host))?;
|
||||||
|
let tls = connector
|
||||||
|
.connect(server_name, tcp)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("tls handshake {}", account.imap_host))?;
|
||||||
|
|
||||||
|
let client = async_imap::Client::new(tls);
|
||||||
|
// greeting was consumed by Client::new in async-imap >= 0.10
|
||||||
|
let session = client
|
||||||
|
.login(&account.username, account.resolve_password()?)
|
||||||
|
.await
|
||||||
|
.map_err(|(e, _client)| anyhow!("imap login failed: {e}"))?;
|
||||||
|
Ok(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rustls_roots() -> rustls::RootCertStore {
|
||||||
|
let mut roots = rustls::RootCertStore::empty();
|
||||||
|
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||||
|
roots
|
||||||
|
}
|
||||||
66
crates/mail-mcp/src/main.rs
Normal file
66
crates/mail-mcp/src/main.rs
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
//! mail-mcp — MCP server entry point.
|
||||||
|
//!
|
||||||
|
//! Speaks MCP over stdio. Any MCP client (Claude Code, OpenClaw,
|
||||||
|
//! kayos-house's bundled claude binary) launches this as a subprocess
|
||||||
|
//! and gets `mail_send` + `mail_inbox_list` + `mail_inbox_read`.
|
||||||
|
//!
|
||||||
|
//! Logging is stderr-only — stdout belongs to the JSON-RPC transport.
|
||||||
|
|
||||||
|
mod config;
|
||||||
|
mod imap;
|
||||||
|
mod smtp;
|
||||||
|
mod tools;
|
||||||
|
|
||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use rmcp::{transport::stdio, ServiceExt};
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::tools::MailService;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> ExitCode {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_writer(std::io::stderr)
|
||||||
|
.with_env_filter(
|
||||||
|
EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
// rustls 0.23 requires the process-wide default CryptoProvider to be set
|
||||||
|
// before any TLS handshake. lettre carries its own internal provider for
|
||||||
|
// SMTP; our IMAP path goes through tokio-rustls + raw `ClientConfig`,
|
||||||
|
// which needs this. Install once at startup; ignore the Err which only
|
||||||
|
// surfaces if a provider is already installed (idempotent).
|
||||||
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
|
|
||||||
|
match run().await {
|
||||||
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("{e:#}");
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run() -> Result<()> {
|
||||||
|
let cfg = Config::load()?;
|
||||||
|
tracing::info!(
|
||||||
|
accounts = cfg.accounts.len(),
|
||||||
|
default_account = %cfg.default_account,
|
||||||
|
"mail-mcp starting"
|
||||||
|
);
|
||||||
|
|
||||||
|
let service = MailService::new(cfg);
|
||||||
|
let server = service
|
||||||
|
.serve(stdio())
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("rmcp serve failed: {e}"))?;
|
||||||
|
server
|
||||||
|
.waiting()
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("rmcp wait failed: {e}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
229
crates/mail-mcp/src/smtp.rs
Normal file
229
crates/mail-mcp/src/smtp.rs
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
//! Outbound SMTP via `lettre` with explicit header discipline.
|
||||||
|
//!
|
||||||
|
//! Headers we guarantee:
|
||||||
|
//! - `Date` — lettre auto, UTC, RFC 5322
|
||||||
|
//! - `Message-ID` — `<uuid-v4@{message_id_domain}>` — own-domain, not local hostname
|
||||||
|
//! - `From` — `name <addr>`
|
||||||
|
//! - `MIME-Version` — lettre auto
|
||||||
|
//! - `User-Agent` — `mail-mcp/<crate version>`
|
||||||
|
//! - `In-Reply-To` — if provided
|
||||||
|
//! - `References` — if provided (space-joined)
|
||||||
|
//!
|
||||||
|
//! Body shape:
|
||||||
|
//! - body only → `text/plain; charset=utf-8`
|
||||||
|
//! - body + body_html → `multipart/alternative` (text first per RFC 2046)
|
||||||
|
//! - attachments → `multipart/mixed` wrap around the alternative
|
||||||
|
//! (or around the singlepart text body if no html)
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use base64::Engine;
|
||||||
|
use lettre::message::header::ContentType;
|
||||||
|
use lettre::message::{Attachment, MultiPart, SinglePart};
|
||||||
|
use lettre::transport::smtp::authentication::Credentials;
|
||||||
|
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
|
||||||
|
|
||||||
|
use crate::config::Account;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AttachmentSpec {
|
||||||
|
pub filename: String,
|
||||||
|
pub content_base64: String,
|
||||||
|
pub mime_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct SendInput {
|
||||||
|
pub to: Vec<String>,
|
||||||
|
pub cc: Vec<String>,
|
||||||
|
pub bcc: Vec<String>,
|
||||||
|
pub subject: String,
|
||||||
|
pub body: String,
|
||||||
|
pub body_html: Option<String>,
|
||||||
|
pub attachments: Vec<AttachmentSpec>,
|
||||||
|
pub in_reply_to: Option<String>,
|
||||||
|
pub references: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SendOutput {
|
||||||
|
pub message_id: String,
|
||||||
|
pub sent_at: String, // RFC-3339
|
||||||
|
}
|
||||||
|
|
||||||
|
const USER_AGENT: &str = concat!("mail-mcp/", env!("CARGO_PKG_VERSION"));
|
||||||
|
|
||||||
|
pub async fn send(account: &Account, input: SendInput) -> Result<SendOutput> {
|
||||||
|
if input.to.is_empty() {
|
||||||
|
return Err(anyhow!("at least one `to` address required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build From, To, Cc, Bcc.
|
||||||
|
let from_str = format!("{} <{}>", account.from_name, account.from_addr);
|
||||||
|
let mut builder = Message::builder()
|
||||||
|
.from(from_str.parse().context("parse from address")?)
|
||||||
|
.subject(&input.subject);
|
||||||
|
|
||||||
|
for addr in &input.to {
|
||||||
|
builder = builder.to(addr.parse().with_context(|| format!("parse to `{addr}`"))?);
|
||||||
|
}
|
||||||
|
for addr in &input.cc {
|
||||||
|
builder = builder.cc(addr.parse().with_context(|| format!("parse cc `{addr}`"))?);
|
||||||
|
}
|
||||||
|
for addr in &input.bcc {
|
||||||
|
builder = builder
|
||||||
|
.bcc(addr.parse().with_context(|| format!("parse bcc `{addr}`"))?);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Own-domain Message-ID. Lettre defaults to the local hostname; we
|
||||||
|
// want the sender domain so receivers don't see `<...@container-id>`.
|
||||||
|
let message_id = format!("<{}@{}>", uuid::Uuid::new_v4(), account.msgid_domain());
|
||||||
|
builder = builder.message_id(Some(message_id.clone()));
|
||||||
|
|
||||||
|
// Threading.
|
||||||
|
if let Some(parent) = &input.in_reply_to {
|
||||||
|
builder = builder.in_reply_to(parent.clone());
|
||||||
|
}
|
||||||
|
if !input.references.is_empty() {
|
||||||
|
builder = builder.references(input.references.join(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
// User-Agent — uses the lettre `user_agent()` shorthand which writes
|
||||||
|
// the standard header.
|
||||||
|
builder = builder.user_agent(USER_AGENT.to_string());
|
||||||
|
|
||||||
|
// Body.
|
||||||
|
let body_part: MultiPart = build_body(&input)?;
|
||||||
|
let email = builder
|
||||||
|
.multipart(body_part)
|
||||||
|
.context("compose message")?;
|
||||||
|
|
||||||
|
// SMTP transport. STARTTLS on submission port (587) is the canonical
|
||||||
|
// path; SMTPS-on-465 supported too if someone configures `smtp_starttls = false`.
|
||||||
|
let creds = Credentials::new(
|
||||||
|
account.username.clone(),
|
||||||
|
account.resolve_password()?,
|
||||||
|
);
|
||||||
|
|
||||||
|
let transport: AsyncSmtpTransport<Tokio1Executor> = if account.smtp_starttls {
|
||||||
|
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&account.smtp_host)
|
||||||
|
.with_context(|| format!("smtp starttls relay {}", account.smtp_host))?
|
||||||
|
.port(account.smtp_port)
|
||||||
|
.credentials(creds)
|
||||||
|
.build()
|
||||||
|
} else {
|
||||||
|
AsyncSmtpTransport::<Tokio1Executor>::relay(&account.smtp_host)
|
||||||
|
.with_context(|| format!("smtp relay {}", account.smtp_host))?
|
||||||
|
.port(account.smtp_port)
|
||||||
|
.credentials(creds)
|
||||||
|
.build()
|
||||||
|
};
|
||||||
|
|
||||||
|
let sent_at = chrono_rfc3339_now();
|
||||||
|
transport
|
||||||
|
.send(email)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("send to {}:{}", account.smtp_host, account.smtp_port))?;
|
||||||
|
|
||||||
|
Ok(SendOutput {
|
||||||
|
message_id,
|
||||||
|
sent_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_body(input: &SendInput) -> Result<MultiPart> {
|
||||||
|
// Inner content: plain or alternative(plain, html).
|
||||||
|
let inner_alternative: Option<MultiPart> = input.body_html.as_ref().map(|html| {
|
||||||
|
MultiPart::alternative()
|
||||||
|
.singlepart(
|
||||||
|
SinglePart::builder()
|
||||||
|
.header(ContentType::TEXT_PLAIN)
|
||||||
|
.body(input.body.clone()),
|
||||||
|
)
|
||||||
|
.singlepart(
|
||||||
|
SinglePart::builder()
|
||||||
|
.header(ContentType::TEXT_HTML)
|
||||||
|
.body(html.clone()),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
let plain_only: SinglePart = SinglePart::builder()
|
||||||
|
.header(ContentType::TEXT_PLAIN)
|
||||||
|
.body(input.body.clone());
|
||||||
|
|
||||||
|
if input.attachments.is_empty() {
|
||||||
|
// No attachments — return alternative if html else a one-part
|
||||||
|
// "mixed" wrapping just the plain body (keeps return type uniform).
|
||||||
|
if let Some(alt) = inner_alternative {
|
||||||
|
return Ok(alt);
|
||||||
|
}
|
||||||
|
// Wrap the singlepart in a "mixed" container so we always return
|
||||||
|
// MultiPart. This adds one MIME boundary but is RFC-valid.
|
||||||
|
return Ok(MultiPart::mixed().singlepart(plain_only));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Have attachments — multipart/mixed wraps either the alternative
|
||||||
|
// (if html provided) or just the plain body.
|
||||||
|
let mut mixed = if let Some(alt) = inner_alternative {
|
||||||
|
MultiPart::mixed().multipart(alt)
|
||||||
|
} else {
|
||||||
|
MultiPart::mixed().singlepart(plain_only)
|
||||||
|
};
|
||||||
|
|
||||||
|
for att in &input.attachments {
|
||||||
|
let bytes = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(&att.content_base64)
|
||||||
|
.with_context(|| format!("attachment `{}`: invalid base64", att.filename))?;
|
||||||
|
let content_type: ContentType = att.mime_type.parse().with_context(|| {
|
||||||
|
format!(
|
||||||
|
"attachment `{}`: invalid mime_type `{}`",
|
||||||
|
att.filename, att.mime_type
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
mixed = mixed.singlepart(
|
||||||
|
Attachment::new(att.filename.clone()).body(bytes, content_type),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(mixed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render `now` as RFC-3339 UTC (`2026-05-21T06:42:18Z`). We avoid pulling
|
||||||
|
/// the full `chrono` crate just for this; build the string by hand from
|
||||||
|
/// std time.
|
||||||
|
fn chrono_rfc3339_now() -> String {
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
let secs = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
// RFC-3339 via the same algorithm `httpdate` uses, simplified for UTC.
|
||||||
|
let (year, month, day, hour, minute, second) = civil_from_unix(secs as i64);
|
||||||
|
format!(
|
||||||
|
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
|
||||||
|
year, month, day, hour, minute, second
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a unix timestamp (UTC) to civil (Y,M,D,h,m,s). Copy of the
|
||||||
|
/// classic Howard Hinnant algorithm — works for the full proleptic Gregorian range.
|
||||||
|
fn civil_from_unix(t: i64) -> (i64, u32, u32, u32, u32, u32) {
|
||||||
|
let days = t.div_euclid(86_400);
|
||||||
|
let secs_of_day = t.rem_euclid(86_400);
|
||||||
|
let hour = (secs_of_day / 3600) as u32;
|
||||||
|
let minute = ((secs_of_day % 3600) / 60) as u32;
|
||||||
|
let second = (secs_of_day % 60) as u32;
|
||||||
|
|
||||||
|
// 0000-03-01 is the start of the cycle ("era").
|
||||||
|
let z = days + 719_468;
|
||||||
|
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||||
|
let doe = (z - era * 146_097) as u64;
|
||||||
|
let yoe =
|
||||||
|
(doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||||
|
let y = yoe as i64 + era * 400;
|
||||||
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||||
|
let mp = (5 * doy + 2) / 153;
|
||||||
|
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||||
|
let m = if mp < 10 { (mp + 3) as u32 } else { (mp - 9) as u32 };
|
||||||
|
let y = if m <= 2 { y + 1 } else { y };
|
||||||
|
(y, m, d, hour, minute, second)
|
||||||
|
}
|
||||||
|
|
||||||
260
crates/mail-mcp/src/tools.rs
Normal file
260
crates/mail-mcp/src/tools.rs
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
//! `MailService` — the rmcp tool surface.
|
||||||
|
//!
|
||||||
|
//! Three tools exposed in v0.1:
|
||||||
|
//! - `mail_send`
|
||||||
|
//! - `mail_inbox_list`
|
||||||
|
//! - `mail_inbox_read`
|
||||||
|
//!
|
||||||
|
//! All tool methods return `Result<String, String>` where the success path
|
||||||
|
//! holds a JSON-serialized payload and the error path is a pre-rendered
|
||||||
|
//! message string suitable for surfacing to the LLM.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use rmcp::{
|
||||||
|
model::{ServerCapabilities, ServerInfo},
|
||||||
|
schemars,
|
||||||
|
tool, ServerHandler,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::{imap as imap_mod, smtp as smtp_mod};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// service
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct MailService {
|
||||||
|
inner: Arc<MailInner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MailInner {
|
||||||
|
config: Config,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MailService {
|
||||||
|
pub fn new(config: Config) -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Arc::new(MailInner { config }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// args
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
|
pub struct AttachmentArg {
|
||||||
|
/// Filename as the recipient should see it.
|
||||||
|
pub filename: String,
|
||||||
|
/// Base64-encoded payload (no `data:` URI prefix).
|
||||||
|
pub content_base64: String,
|
||||||
|
/// MIME type, e.g. `application/pdf` or `image/png`.
|
||||||
|
pub mime_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
|
pub struct SendArgs {
|
||||||
|
/// Account name to send from. Falls back to `default_account` from config.
|
||||||
|
#[serde(default)]
|
||||||
|
pub account: Option<String>,
|
||||||
|
/// Recipient — single string or array of strings.
|
||||||
|
pub to: ToField,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cc: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub bcc: Vec<String>,
|
||||||
|
pub subject: String,
|
||||||
|
/// Plain-text body. Required even when also sending HTML (text part
|
||||||
|
/// shows up in `multipart/alternative` first per RFC 2046).
|
||||||
|
pub body: String,
|
||||||
|
/// Optional HTML body. When present, the message becomes
|
||||||
|
/// `multipart/alternative` (text first, then html).
|
||||||
|
#[serde(default)]
|
||||||
|
pub body_html: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub attachments: Vec<AttachmentArg>,
|
||||||
|
/// Message-ID of the parent message — sets `In-Reply-To` header.
|
||||||
|
#[serde(default)]
|
||||||
|
pub in_reply_to: Option<String>,
|
||||||
|
/// Full thread chain of Message-IDs — sets `References` header.
|
||||||
|
#[serde(default)]
|
||||||
|
pub references: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum ToField {
|
||||||
|
One(String),
|
||||||
|
Many(Vec<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToField {
|
||||||
|
fn into_vec(self) -> Vec<String> {
|
||||||
|
match self {
|
||||||
|
ToField::One(s) => vec![s],
|
||||||
|
ToField::Many(v) => v,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
|
pub struct ListArgs {
|
||||||
|
#[serde(default)]
|
||||||
|
pub account: Option<String>,
|
||||||
|
/// YYYY-MM-DD; passed as IMAP SINCE.
|
||||||
|
#[serde(default)]
|
||||||
|
pub since: Option<String>,
|
||||||
|
/// If true, only list messages without the \Seen flag.
|
||||||
|
#[serde(default)]
|
||||||
|
pub unread_only: bool,
|
||||||
|
/// Max entries to return — default 50, max 500.
|
||||||
|
#[serde(default)]
|
||||||
|
pub limit: u32,
|
||||||
|
/// IMAP folder. Default `INBOX`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub folder: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
|
pub struct ReadArgs {
|
||||||
|
#[serde(default)]
|
||||||
|
pub account: Option<String>,
|
||||||
|
/// UID of the message (stable across selects, unlike sequence numbers).
|
||||||
|
pub uid: u32,
|
||||||
|
/// IMAP folder. Default `INBOX`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub folder: Option<String>,
|
||||||
|
/// `text` (default) returns the text/plain part (falls back to html-stripped if absent).
|
||||||
|
/// `html` returns the html part.
|
||||||
|
/// `raw_eml` returns the full RFC822 source.
|
||||||
|
#[serde(default)]
|
||||||
|
pub format: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// tools
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[tool(tool_box)]
|
||||||
|
impl MailService {
|
||||||
|
#[tool(
|
||||||
|
name = "mail_send",
|
||||||
|
description = "Send mail via Sulkta's SMTP relay. Sets RFC-correct Date, Message-ID (with own-domain), From, MIME-Version, User-Agent. Supports multipart/alternative when body_html is present and multipart/mixed when attachments are attached. Use `in_reply_to` + `references` for thread continuation. Returns JSON {message_id, sent_at}."
|
||||||
|
)]
|
||||||
|
async fn mail_send(
|
||||||
|
&self,
|
||||||
|
#[tool(aggr)] args: SendArgs,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let account = self
|
||||||
|
.inner
|
||||||
|
.config
|
||||||
|
.account(args.account.as_deref())
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let input = smtp_mod::SendInput {
|
||||||
|
to: args.to.into_vec(),
|
||||||
|
cc: args.cc,
|
||||||
|
bcc: args.bcc,
|
||||||
|
subject: args.subject,
|
||||||
|
body: args.body,
|
||||||
|
body_html: args.body_html,
|
||||||
|
attachments: args
|
||||||
|
.attachments
|
||||||
|
.into_iter()
|
||||||
|
.map(|a| smtp_mod::AttachmentSpec {
|
||||||
|
filename: a.filename,
|
||||||
|
content_base64: a.content_base64,
|
||||||
|
mime_type: a.mime_type,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
in_reply_to: args.in_reply_to,
|
||||||
|
references: args.references,
|
||||||
|
};
|
||||||
|
let out = smtp_mod::send(account, input)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{e:#}"))?;
|
||||||
|
serde_json::to_string(&serde_json::json!({
|
||||||
|
"message_id": out.message_id,
|
||||||
|
"sent_at": out.sent_at,
|
||||||
|
}))
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tool(
|
||||||
|
name = "mail_inbox_list",
|
||||||
|
description = "List messages in an IMAP folder (default INBOX), newest UID first. Supports SINCE date (YYYY-MM-DD) and unread-only filter. Each entry has uid, message_id, from, to, subject, date, has_attachments, flags. Does NOT mark messages as read (BODY.PEEK). Returns JSON array."
|
||||||
|
)]
|
||||||
|
async fn mail_inbox_list(
|
||||||
|
&self,
|
||||||
|
#[tool(aggr)] args: ListArgs,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let account = self
|
||||||
|
.inner
|
||||||
|
.config
|
||||||
|
.account(args.account.as_deref())
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let entries = imap_mod::list(
|
||||||
|
account,
|
||||||
|
imap_mod::ListOpts {
|
||||||
|
since: args.since,
|
||||||
|
unread_only: args.unread_only,
|
||||||
|
limit: args.limit,
|
||||||
|
folder: args.folder,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{e:#}"))?;
|
||||||
|
serde_json::to_string(&entries).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tool(
|
||||||
|
name = "mail_inbox_read",
|
||||||
|
description = "Fetch one message by UID from an IMAP folder. format=text (default) returns the text/plain part, format=html returns the HTML part, format=raw_eml returns the full RFC822 source. Attachment payloads are NOT inlined — only filename/mime_type/size metadata. Does NOT mark as read."
|
||||||
|
)]
|
||||||
|
async fn mail_inbox_read(
|
||||||
|
&self,
|
||||||
|
#[tool(aggr)] args: ReadArgs,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let account = self
|
||||||
|
.inner
|
||||||
|
.config
|
||||||
|
.account(args.account.as_deref())
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let out = imap_mod::read(
|
||||||
|
account,
|
||||||
|
args.uid,
|
||||||
|
args.folder.as_deref(),
|
||||||
|
args.format.as_deref().unwrap_or("text"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{e:#}"))?;
|
||||||
|
serde_json::to_string(&out).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ServerHandler — capabilities must be set explicitly. rmcp 0.1.x's
|
||||||
|
// `#[tool(tool_box)]` does NOT auto-fill ServerInfo capabilities, so
|
||||||
|
// without `enable_tools()` the client reads an empty capability set and
|
||||||
|
// never asks for tools/list. (Same lesson aldabra learned the hard way.)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[tool(tool_box)]
|
||||||
|
impl ServerHandler for MailService {
|
||||||
|
fn get_info(&self) -> ServerInfo {
|
||||||
|
ServerInfo {
|
||||||
|
capabilities: ServerCapabilities::builder().enable_tools().build(),
|
||||||
|
instructions: Some(
|
||||||
|
"mail-mcp — Rust MCP server for Sulkta-hosted email. \
|
||||||
|
Tools: mail_send, mail_inbox_list, mail_inbox_read. \
|
||||||
|
Default account from config; pass `account` to switch. \
|
||||||
|
Reads use BODY.PEEK so they don't toggle the \\Seen flag."
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue