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