use std::path::{Path, PathBuf};
use std::time::Duration;
use base64::Engine;
use serde::{Deserialize, Serialize};
use crate::text;
pub const SOCKET: &str = "agent.sock";
pub const DEMO_SOCKET: &str = "demo-agent.sock";
pub const MAX_IDS: usize = 50;
pub const MAX_RESULTS: usize = 50;
pub const TEXT_CHARS: usize = 8_000;
pub const MAX_TEXT_CHARS: usize = 20_000;
pub const CHANGES_PER_HOUR: usize = 300;
pub const TRASH_PER_HOUR: usize = 100;
pub const DOWNLOADS_PER_HOUR: usize = 200;
pub const DOWNLOAD_BYTES_PER_HOUR: u64 = 512 << 20;
pub const ASK_FOR: Duration = Duration::from_secs(5 * 60);
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Request {
pub client: String,
pub call: Call,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "tool", rename_all = "snake_case")]
pub enum Call {
ListAccounts(NoArgs),
ListFolders(FolderArgs),
SearchMessages(SearchArgs),
ReadMessage(ReadArgs),
MarkRead(ReadFlagArgs),
Star(StarArgs),
SetTag(TagArgs),
MoveMessages(MoveArgs),
MarkSpam(SpamArgs),
MoveToTrash(IdsArgs),
ListChanges(ChangesArgs),
UndoChange(UndoArgs),
}
impl Call {
pub fn changes(&self) -> bool {
matches!(
self,
Call::MarkRead(_)
| Call::Star(_)
| Call::SetTag(_)
| Call::MoveMessages(_)
| Call::MarkSpam(_)
| Call::MoveToTrash(_)
| Call::UndoChange(_)
)
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct FolderArgs {
pub account: String,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SearchArgs {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub account: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub folder: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub query: Option<String>,
#[serde(default)]
pub unread_only: bool,
#[serde(default)]
pub starred_only: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ReadArgs {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_chars: Option<usize>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ReadFlagArgs {
pub ids: Vec<String>,
pub read: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct StarArgs {
pub ids: Vec<String>,
pub starred: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TagArgs {
pub ids: Vec<String>,
pub tag: String,
pub on: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct MoveArgs {
pub ids: Vec<String>,
pub folder: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SpamArgs {
pub ids: Vec<String>,
pub spam: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct IdsArgs {
pub ids: Vec<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct NoArgs {}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ChangesArgs {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct UndoArgs {
pub change: i64,
}
pub type Answer = Result<serde_json::Value, String>;
pub fn shown_client(client: &str) -> String {
let name = text::one_line(client, 40);
if name.is_empty() {
"An AI agent".into()
} else {
name
}
}
pub fn handle(account: &str, folder: &str, uidvalidity: u32, uid: u32) -> String {
base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(format!("{account}\n{folder}\n{uidvalidity}\n{uid}"))
}
pub fn unhandle(id: &str) -> Option<(String, String, u32, u32)> {
if id.len() > 2000 {
return None;
}
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(id.trim())
.ok()?;
let text = String::from_utf8(bytes).ok()?;
let mut parts = text.split('\n');
let (account, folder) = (parts.next()?, parts.next()?);
let uidvalidity = parts.next()?.parse().ok()?;
let uid = parts.next()?.parse().ok()?;
if parts.next().is_some() || account.is_empty() || folder.is_empty() {
return None;
}
Some((account.into(), folder.into(), uidvalidity, uid))
}
pub fn hide_links(text: &str) -> String {
let mut out = String::new();
let mut rest = text;
while let Some((start, end)) = crate::mail::find_any_url(rest) {
out.push_str(&rest[..start]);
let link = crate::text::first_chars(&rest[start..end], 4096);
let host = crate::app::link_host(link);
out.push_str(&format!("[link: {host}]"));
rest = &rest[end..];
}
out.push_str(rest);
out
}
pub fn socket(data_dir: &Path, demo: bool) -> PathBuf {
data_dir.join(if demo { DEMO_SOCKET } else { SOCKET })
}
#[cfg(not(unix))]
pub use other::{Listener, ask, listen};
#[cfg(all(unix, test))]
pub use unix::NOT_LISTENING;
#[cfg(unix)]
pub use unix::{Listener, ask, listen};
#[cfg(not(unix))]
mod other {
use std::path::Path;
use anyhow::{Result, bail};
use tokio::sync::mpsc::UnboundedSender;
use super::*;
use crate::app::AppEvent;
pub struct Listener;
pub fn listen(_: &Path, _: UnboundedSender<AppEvent>) -> Result<Listener> {
bail!("AI agents can't reach tuimail on Windows yet")
}
pub async fn ask(_: &Path, _: &Request) -> Answer {
Err("tuimail mcp isn't available on Windows yet".into())
}
}
#[cfg(unix)]
mod unix {
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result, bail};
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::net::{UnixListener, UnixStream};
use tokio::sync::{Semaphore, mpsc::UnboundedSender, oneshot};
use tokio::task::JoinHandle;
use super::*;
use crate::app::AppEvent;
pub const MAX_REQUEST: usize = 64 << 10;
pub const MAX_ANSWER: usize = 4 << 20;
const ANSWER_WITHIN: std::time::Duration = std::time::Duration::from_secs(5 * 60 + 30);
pub const NOT_LISTENING: &str = "tuimail isn't open, or its AI agents setting is off. \
Ask the user to open tuimail and turn on AI agents in its settings (O).";
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
enum Wire {
Ok(serde_json::Value),
Error(String),
}
pub struct Listener {
path: PathBuf,
made: (u64, u64),
task: JoinHandle<()>,
}
impl Drop for Listener {
fn drop(&mut self) {
self.task.abort();
if identity(&self.path) == Some(self.made) {
let _ = std::fs::remove_file(&self.path);
}
}
}
fn identity(path: &Path) -> Option<(u64, u64)> {
use std::os::unix::fs::{FileTypeExt, MetadataExt};
let meta = std::fs::symlink_metadata(path).ok()?;
let me = unsafe { libc::geteuid() };
(meta.file_type().is_socket() && meta.nlink() == 1 && meta.uid() == me)
.then(|| (meta.dev(), meta.ino()))
}
fn answers(path: &Path) -> bool {
let (tx, rx) = std::sync::mpsc::channel();
let path = path.to_path_buf();
std::thread::spawn(move || {
let _ = tx.send(std::os::unix::net::UnixStream::connect(path).is_ok());
});
rx.recv_timeout(std::time::Duration::from_millis(500))
.unwrap_or(true)
}
pub fn listen(path: &Path, tx: UnboundedSender<AppEvent>) -> Result<Listener> {
if std::fs::symlink_metadata(path).is_ok() {
if identity(path).is_some() && answers(path) {
bail!("another tuimail is answering agents");
}
std::fs::remove_file(path).context("cannot remove the old agent socket")?;
}
if path.as_os_str().len() >= 100 {
bail!("the data folder's path is too long for agents to reach tuimail through it");
}
let listener = UnixListener::bind(path).context("cannot listen for agents")?;
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
}
let made = identity(path).context("cannot listen for agents")?;
let slots = Arc::new(Semaphore::new(8));
let task = tokio::spawn(async move {
let mut connections = tokio::task::JoinSet::new();
loop {
while connections.try_join_next().is_some() {}
let stream = match listener.accept().await {
Ok((stream, _)) => stream,
Err(_) => {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
continue;
}
};
let me = unsafe { libc::geteuid() };
if !stream.peer_cred().is_ok_and(|c| c.uid() == me) {
continue;
}
let Ok(slot) = slots.clone().try_acquire_owned() else {
continue;
};
let tx = tx.clone();
connections.spawn(async move {
let _ = serve(stream, tx).await;
drop(slot);
});
}
});
Ok(Listener {
path: path.to_path_buf(),
made,
task,
})
}
async fn serve(stream: UnixStream, tx: UnboundedSender<AppEvent>) -> Result<()> {
let (read, mut write) = stream.into_split();
let mut line = Vec::new();
let mut reader = BufReader::new(read.take(MAX_REQUEST as u64));
tokio::time::timeout(
std::time::Duration::from_secs(10),
reader.read_until(b'\n', &mut line),
)
.await??;
let answer = match serde_json::from_slice::<Request>(&line) {
Ok(request) => {
let (reply, answer) = oneshot::channel();
tx.send(AppEvent::Agent { request, reply })?;
let waited =
tokio::time::timeout(ASK_FOR + std::time::Duration::from_secs(15), answer);
let mut byte = [0u8; 1];
tokio::select! {
answer = waited => match answer {
Ok(Ok(answer)) => answer,
_ => Err("tuimail didn't answer in time".into()),
},
_ = reader.read(&mut byte) => return Ok(()),
}
}
Err(_) => Err("that isn't a request tuimail understands".into()),
};
let wire = match answer {
Ok(value) => Wire::Ok(value),
Err(error) => Wire::Error(error),
};
let mut out = serde_json::to_vec(&wire)?;
out.truncate(MAX_ANSWER);
out.push(b'\n');
tokio::time::timeout(std::time::Duration::from_secs(30), write.write_all(&out)).await??;
Ok(())
}
pub async fn ask(path: &Path, request: &Request) -> Answer {
if identity(path).is_none() {
return Err(NOT_LISTENING.to_string());
}
let mut stream = UnixStream::connect(path)
.await
.map_err(|_| NOT_LISTENING.to_string())?;
let me = unsafe { libc::geteuid() };
if !stream.peer_cred().is_ok_and(|c| c.uid() == me) {
return Err("what answers at tuimail's socket isn't yours: not asked".into());
}
let mut out = serde_json::to_vec(request).map_err(|e| e.to_string())?;
if out.len() >= MAX_REQUEST {
return Err("the request is too long".into());
}
out.push(b'\n');
stream
.write_all(&out)
.await
.map_err(|_| NOT_LISTENING.to_string())?;
let mut line = Vec::new();
let mut reader = BufReader::new(stream.take(MAX_ANSWER as u64 + 2));
tokio::time::timeout(ANSWER_WITHIN, reader.read_until(b'\n', &mut line))
.await
.map_err(|_| "tuimail didn't answer in time".to_string())?
.map_err(|e| e.to_string())?;
match serde_json::from_slice::<Wire>(&line) {
Ok(Wire::Ok(value)) => Ok(value),
Ok(Wire::Error(error)) => Err(error),
Err(_) => Err("tuimail's answer didn't read".into()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_handle_names_one_message_and_nothing_else_reads_as_one() {
let id = handle("me@example.org", "Archive/2026", 7, 42);
assert_eq!(
unhandle(&id),
Some(("me@example.org".into(), "Archive/2026".into(), 7, 42))
);
for bad in ["", "x", &"A".repeat(3000), "bWVAZXhhbXBsZS5vcmcKSU5CT1gKNw"] {
assert_eq!(unhandle(bad), None, "{bad}");
}
}
#[test]
fn a_call_takes_only_its_own_arguments() {
let call = |json: &str| serde_json::from_str::<Call>(json);
assert!(call(r#"{"tool":"list_accounts"}"#).is_ok());
assert!(call(r#"{"tool":"list_accounts","delete":true}"#).is_err());
assert!(call(r#"{"tool":"move_to_trash","ids":[],"forever":true}"#).is_err());
}
#[test]
fn links_show_only_their_site() {
assert_eq!(
hide_links("Reset it: https://evil.example/r?t=1 or mailto:x@y.z now"),
"Reset it: [link: evil.example] or [link: y.z] now"
);
assert_eq!(hide_links("no links"), "no links");
assert_eq!(
hide_links("Clickhttps://evil.example/r?t=2 now"),
"Click[link: evil.example] now"
);
let long = format!("https://evil.example/l?p={}", "a".repeat(5000));
assert_eq!(
hide_links(&format!("Long: {long}.")),
"Long: [link: evil.example]."
);
}
#[test]
fn calls_read_as_json_and_nothing_unknown_does() {
let request: Request = serde_json::from_str(
r#"{"client":"Claude Code","call":{"tool":"move_to_trash","ids":["a"]}}"#,
)
.unwrap();
assert_eq!(
request.call,
Call::MoveToTrash(IdsArgs {
ids: vec!["a".into()]
})
);
assert!(request.call.changes());
for bad in [
r#"{"client":"x","call":{"tool":"delete","ids":["a"]}}"#,
r#"{"client":"x","call":{"tool":"move_to_trash","ids":["a"],"forever":true}}"#,
r#"{"client":"x","call":{"tool":"list_accounts"},"admin":true}"#,
] {
assert!(serde_json::from_str::<Request>(bad).is_err(), "{bad}");
}
}
#[test]
fn a_nameless_agent_is_still_named() {
assert_eq!(shown_client(""), "An AI agent");
assert_eq!(shown_client("Claude\u{1b}[31m Code"), "Claude[31m Code");
}
}