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.
383 lines
12 KiB
Rust
383 lines
12 KiB
Rust
//! 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
|
|
}
|