//! 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, // YYYY-MM-DD pub unread_only: bool, pub limit: u32, // 0 means default (50) pub folder: Option, // None → INBOX } #[derive(Debug, Clone, Serialize)] pub struct ListEntry { pub uid: u32, pub message_id: Option, pub from: Vec, pub to: Vec, pub subject: String, pub date: Option, pub snippet: String, pub has_attachments: bool, pub flags: Vec, } #[derive(Debug, Clone, Serialize)] pub struct ReadOutput { pub uid: u32, pub message_id: Option, pub from: Vec, pub to: Vec, pub cc: Vec, pub subject: String, pub date: Option, pub headers: serde_json::Value, pub body: String, pub format: String, pub attachments: Vec, } #[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> { 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 = 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 = { let set = session .uid_search(&query) .await .with_context(|| format!("UID SEARCH {query}"))?; let mut v: Vec = 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 = 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::>() .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 = 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 { 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 = 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 { 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 { // 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>> { 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 }