From d9738b4be2add82ff955f061c79316b0dc91768f Mon Sep 17 00:00:00 2001 From: Elvis Claros Castro Date: Sat, 26 Sep 2026 18:47:40 -0300 Subject: posta: generic IMAP/SMTP mail CLI with JSON output --- src/client.rs | 92 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 src/client.rs (limited to 'src/client.rs') diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..3d9b719 --- /dev/null +++ b/src/client.rs @@ -0,0 +1,92 @@ +//! IMAP connection and small protocol helpers. + +use std::net::TcpStream; + +use anyhow::{anyhow, Result}; +use native_tls::{TlsConnector, TlsStream}; + +use crate::config::Account; + +pub type ImapSession = imap::Session>; + +/// Opens a logged-in IMAP session, using STARTTLS or implicit TLS as configured. +pub fn connect(acc: &Account) -> Result { + let (ep, pass) = acc.imap()?; + let tls = TlsConnector::new()?; + let client = if ep.implicit_tls { + imap::connect((ep.host.as_str(), ep.port), ep.host.as_str(), &tls)? + } else { + imap::connect_starttls((ep.host.as_str(), ep.port), ep.host.as_str(), &tls)? + }; + client.login(&ep.user, &pass).map_err(|e| { + anyhow!( + "IMAP login failed for {} (account '{}'): {:?}", + ep.user, + acc.name, + e.0 + ) + }) +} + +/// Quotes a mailbox name when it needs it (spaces, quotes, parentheses). +pub fn quote_mailbox(name: &str) -> String { + if name.starts_with('"') || !name.contains([' ', '"', '(', ')']) { + name.to_string() + } else { + format!("\"{}\"", name.replace('"', "\\\"")) + } +} + +/// Renders a UID list as an IMAP sequence set. +pub fn uid_set(uids: &[u32]) -> String { + uids.iter() + .map(u32::to_string) + .collect::>() + .join(",") +} + +/// Moves messages, preferring `UID MOVE` with a COPY+EXPUNGE fallback for +/// servers without the MOVE capability. +pub fn move_uids(session: &mut ImapSession, uids: &[u32], to: &str) -> Result<()> { + let set = uid_set(uids); + let dest = quote_mailbox(to); + if session.uid_mv(&set, &dest).is_ok() { + return Ok(()); + } + session.uid_copy(&set, &dest)?; + session.uid_store(&set, "+FLAGS (\\Deleted)")?; + session.expunge()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plain_names_stay_unquoted() { + assert_eq!(quote_mailbox("INBOX"), "INBOX"); + assert_eq!(quote_mailbox("Archives/2025"), "Archives/2025"); + } + + #[test] + fn names_with_spaces_get_quoted() { + assert_eq!(quote_mailbox("Sent Mail"), "\"Sent Mail\""); + } + + #[test] + fn embedded_quotes_are_escaped() { + assert_eq!(quote_mailbox("a\"b"), "\"a\\\"b\""); + } + + #[test] + fn already_quoted_names_pass_through() { + assert_eq!(quote_mailbox("\"Sent Mail\""), "\"Sent Mail\""); + } + + #[test] + fn uid_sets_join_with_commas() { + assert_eq!(uid_set(&[7, 9, 11]), "7,9,11"); + assert_eq!(uid_set(&[42]), "42"); + } +} -- cgit v1.2.3