diff options
author | Jannis M. Hoffmann <jannis@fehcom.de> | 2023-08-15 15:13:16 +0200 |
---|---|---|
committer | Jannis M. Hoffmann <jannis@fehcom.de> | 2023-08-15 15:13:16 +0200 |
commit | 161b052713f5cee08f20ce6ade0881e274c47fdd (patch) | |
tree | 4f541f79bb4c4a4089373926ca66783a4af26119 /src |
initial commit
Diffstat (limited to 'src')
-rw-r--r-- | src/arguments.rs | 125 | ||||
-rw-r--r-- | src/cmd.rs | 90 | ||||
-rw-r--r-- | src/cmd/count.rs | 27 | ||||
-rw-r--r-- | src/cmd/folders.rs | 48 | ||||
-rw-r--r-- | src/cmd/list.rs | 116 | ||||
-rw-r--r-- | src/cmd/raw.rs | 89 | ||||
-rw-r--r-- | src/error.rs | 69 | ||||
-rw-r--r-- | src/main.rs | 89 | ||||
-rw-r--r-- | src/rfc822.rs | 548 |
9 files changed, 1201 insertions, 0 deletions
diff --git a/src/arguments.rs b/src/arguments.rs new file mode 100644 index 0000000..505cc18 --- /dev/null +++ b/src/arguments.rs @@ -0,0 +1,125 @@ +use std::path::PathBuf; + +use clap::{value_parser, Parser, Subcommand}; + +use crate::error::Error; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SortKey { + Date, + Sender, + Subject, + Size, +} + +#[derive(PartialEq, Eq, Clone)] +pub enum SortOrder { + Ascending, + Descending, +} + +#[derive(PartialEq, Eq, Clone)] +pub struct SortInfo { + pub key: SortKey, + pub order: SortOrder, +} + +impl std::str::FromStr for SortInfo { + type Err = Error; + + fn from_str(mut value: &str) -> Result<Self, Self::Err> { + if value == "" { + return Ok(SortInfo { + key: SortKey::Date, + order: SortOrder::Ascending, + }); + } + let order = if value.starts_with('!') { + value = &value[1..]; + SortOrder::Descending + } else { + SortOrder::Ascending + }; + let key = match value.to_ascii_lowercase().as_str() { + "date" => SortKey::Date, + "sender" => SortKey::Sender, + "subject" => SortKey::Subject, + "size" => SortKey::Size, + v => return Err(Error::SortOrder(format!("invalid sort order {:}", v))), + }; + Ok(SortInfo { key, order }) + } +} + +impl std::fmt::Debug for SortInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> { + write!( + f, + "{}{:?}", + match self.order { + SortOrder::Descending => "!", + SortOrder::Ascending => "", + }, + self.key + ) + } +} + +#[derive(Subcommand)] +pub enum Mode { + List { + subfolder: String, + start: usize, + end: usize, + #[arg(value_parser = value_parser!(SortInfo))] + sortby: SortInfo, + }, + Search { + pattern: String, + subfolder: String, + }, + Count { + subfolder: String, + }, + Read { + subfolder: String, + mid: String, + }, + Raw { + subfolder: String, + mid: String, + mime_path: String, + }, + Folders, + Move { + mid: String, + from: String, + to: String, + }, +} + +#[derive(Parser)] +#[command(author, version, about, long_about = None)] +pub struct Arguments { + pub maildir_path: PathBuf, + pub sys_user: String, + pub mail_user: String, + #[command(subcommand)] + pub mode: Mode, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sort_info() { + assert_eq!( + "!date".parse::<SortInfo>().unwrap(), + SortInfo { + key: SortKey::Date, + order: SortOrder::Descending + } + ); + } +} diff --git a/src/cmd.rs b/src/cmd.rs new file mode 100644 index 0000000..364fcfb --- /dev/null +++ b/src/cmd.rs @@ -0,0 +1,90 @@ +use std::io::ErrorKind as IOErrKind; +use std::path::PathBuf; + +use maildir::Maildir; +use serde::Serialize as _; +use serde::Serializer as _; + +mod count; +mod folders; +mod list; +mod raw; + +use crate::error::Result; +use crate::rfc822::{MIMEHeader, Mail, TopMailHeader}; + +pub use count::{count, CountInfo}; +pub use folders::folders; +pub use list::list; +pub use raw::raw; + +pub enum Return { + Read(Mail), + Raw(MIMEHeader, Vec<u8>), + List(Vec<TopMailHeader>), + Folders(Vec<String>), + Count(CountInfo), + Search(Vec<Mail>), + Move, +} + +pub fn serialize_to<W>(res: Result<Return>, mut write: W) -> std::io::Result<()> +where + W: std::io::Write + Copy, +{ + let ser = &mut serde_json::Serializer::new(write); + + match res { + Err(e) => { + e.serialize(ser)?; + std::process::exit(3) + } + Ok(r) => { + match r { + Return::Folders(fs) => fs.serialize(ser), + Return::Count(ci) => ci.serialize(ser), + Return::List(tmhs) => tmhs.serialize(ser), + Return::Search(ms) => ms.serialize(ser), + Return::Read(m) => m.serialize(ser), + Return::Raw(mh, b) => { + let r = match mh.serialize(ser) { + Ok(x) => x, + Err(e) => return Err(e.into()), + }; + write.write_all(b"\n")?; + write.write_all(&b)?; + Ok(r) + } + Return::Move => ser.serialize_unit(), + }?; + Ok(()) + } + } +} + +pub fn open_submaildir(mut path: PathBuf, sub: &str) -> Maildir { + if sub != "" { + path.push(String::from(".") + sub); + } + Maildir::from(path) +} + +pub fn read(md: &Maildir, mid: &str) -> Result<Mail> { + md.add_flags(mid, "S")?; + + let mut mail = md.find(mid).ok_or_else(|| { + std::io::Error::new(IOErrKind::NotFound, format!("mail {} not found", mid)) + })?; + + Ok(mail.parsed()?.try_into()?) +} + +pub fn move_mail(p: PathBuf, mid: &str, from_f: &str, to_f: &str) -> Result<()> { + let from = open_submaildir(p.clone(), from_f); + let to = open_submaildir(p, to_f); + from.move_to(mid, &to).map_err(|e| e.into()) +} + +pub fn search(_md: &Maildir, _pattern: &str) -> Result<Vec<Mail>> { + todo!() +} diff --git a/src/cmd/count.rs b/src/cmd/count.rs new file mode 100644 index 0000000..0a6e883 --- /dev/null +++ b/src/cmd/count.rs @@ -0,0 +1,27 @@ +use maildir::Maildir; +use serde::Serialize; + +use crate::error::Result; + +#[derive(Serialize)] +pub struct CountInfo { + total_mails: u32, + byte_size: u64, + unread_mails: u32, +} + +pub fn count(md: &Maildir) -> Result<CountInfo> { + Ok(CountInfo { + total_mails: md.count_cur() as u32, + unread_mails: md + .list_cur() + .filter(|x| x.as_ref().map_or(false, |z| !z.is_seen())) + .count() as u32, + byte_size: md + .path() + .join("cur") + .read_dir()? + .map(|x| x.map_or(0, |z| z.metadata().map_or(0, |y| y.len()))) + .sum(), + }) +} diff --git a/src/cmd/folders.rs b/src/cmd/folders.rs new file mode 100644 index 0000000..7bf93f1 --- /dev/null +++ b/src/cmd/folders.rs @@ -0,0 +1,48 @@ +use std::collections::BTreeSet; +use std::ffi::{OsStr, OsString}; +use std::path::Path; + +use lazy_static::lazy_static; +use maildir::Maildir; + +use crate::error::Result; + +lazy_static! { + static ref REQUIRED_MAILDIR_DIRS: BTreeSet<OsString> = [ + OsString::from("cur"), + "new".into(), + "tmp".into(), + "maildirfolder".into(), + ] + .into(); +} + +fn is_mailsubdir(p: &Path) -> bool { + p.is_dir() + && p.file_name() + .map_or(false, |fname| fname.to_string_lossy().starts_with('.')) + && p.read_dir() + .map(|dir| { + dir.filter_map(|child| { + child + .ok() + .and_then(|dir_entry| dir_entry.path().file_name().map(OsStr::to_owned)) + }) + .collect::<BTreeSet<_>>() + .is_superset(&REQUIRED_MAILDIR_DIRS) + }) + .unwrap_or_default() +} + +pub fn folders(md: &Maildir) -> Result<Vec<String>> { + let root_path = md.path(); + + let subdirs = root_path + .read_dir()? + .filter_map(|d| d.ok()) + .filter(|d| is_mailsubdir(&d.path())) + .filter_map(|d| Some(d.path().file_name()?.to_string_lossy()[1..].to_owned())) + .collect(); + + Ok(subdirs) +} diff --git a/src/cmd/list.rs b/src/cmd/list.rs new file mode 100644 index 0000000..0ec0389 --- /dev/null +++ b/src/cmd/list.rs @@ -0,0 +1,116 @@ +use std::cmp::Reverse; + +use log::warn; +use maildir::Maildir; + +use crate::arguments::{SortInfo, SortKey, SortOrder}; +use crate::error::Result; +use crate::rfc822::{MailHeader, TopMailHeader}; + +fn from_or_sender<'a>(mh: &'a MailHeader) -> &'a str { + if mh.from.len() == 0 { + warn!("mail without from"); + panic!() + } + if mh.from.len() == 1 { + &mh.from[0].address + } else { + &mh.sender.as_ref().unwrap().address + } +} + +fn mid_to_rec_time(mid: &str) -> f64 { + let Some(dec) = mid.find('.') else { + warn!("Invaild mail-id {}", mid); + return 0.0; + }; + let Some(sep) = mid[dec+1..].find('.') else { + return 0.0; + }; + mid[..dec + 1 + sep].parse().unwrap() +} + +fn sort_by_and_take( + mut entries: Vec<maildir::MailEntry>, + sortby: &SortInfo, + s: usize, + e: usize, +) -> Vec<TopMailHeader> { + match sortby.key { + SortKey::Date => { + match sortby.order { + SortOrder::Ascending => entries.sort_by(|a, b| { + mid_to_rec_time(a.id()) + .partial_cmp(&mid_to_rec_time(b.id())) + .unwrap() + }), + SortOrder::Descending => entries.sort_by(|b, a| { + mid_to_rec_time(a.id()) + .partial_cmp(&mid_to_rec_time(b.id())) + .unwrap() + }), + } + entries + .drain(s..e) + .filter_map(|me| me.try_into().map_err(|e| warn!("{}", e)).ok()) + .collect() + } + SortKey::Size => { + match sortby.order { + SortOrder::Ascending => { + entries.sort_by_cached_key(|a| a.path().metadata().map_or(0, |m| m.len())) + } + SortOrder::Descending => entries + .sort_by_cached_key(|a| Reverse(a.path().metadata().map_or(0, |m| m.len()))), + } + entries + .drain(s..e) + .filter_map(|me| me.try_into().map_err(|e| warn!("{}", e)).ok()) + .collect() + } + SortKey::Subject => { + let mut x: Vec<TopMailHeader> = entries + .drain(..) + .filter_map(|me| me.try_into().map_err(|e| warn!("{}", e)).ok()) + .collect(); + match sortby.order { + SortOrder::Ascending => x.sort_by(|a, b| a.head.subject.cmp(&b.head.subject)), + SortOrder::Descending => x.sort_by(|b, a| a.head.subject.cmp(&b.head.subject)), + } + x.drain(s..e).collect() + } + SortKey::Sender => { + let mut x: Vec<TopMailHeader> = entries + .drain(..) + .filter_map(|me| me.try_into().map_err(|e| warn!("{}", e)).ok()) + .collect(); + match sortby.order { + SortOrder::Ascending => { + x.sort_by(|a, b| from_or_sender(&a.head).cmp(from_or_sender(&b.head))) + } + SortOrder::Descending => { + x.sort_by(|b, a| from_or_sender(&a.head).cmp(from_or_sender(&b.head))) + } + } + x.drain(s..e).collect() + } + } +} + +pub fn list(md: &Maildir, i: usize, j: usize, sortby: &SortInfo) -> Result<Vec<TopMailHeader>> { + for r in md.list_new() { + match r { + Err(e) => warn!("{}", e), + Ok(me) => { + if let Err(e) = md.move_new_to_cur(me.id()) { + warn!("{}", e); + } + } + }; + } + + let a: Vec<_> = md.list_cur().filter_map(std::result::Result::ok).collect(); + let start = std::cmp::min(a.len(), i); + let end = std::cmp::min(a.len(), j); + Ok(sort_by_and_take(a, sortby, start, end)) +} diff --git a/src/cmd/raw.rs b/src/cmd/raw.rs new file mode 100644 index 0000000..70e2632 --- /dev/null +++ b/src/cmd/raw.rs @@ -0,0 +1,89 @@ +use std::fs::read; +use std::io::ErrorKind as IOErrKind; + +use maildir::Maildir; + +use crate::error::{Error, Result}; +use crate::rfc822::{parse_mail_content, MIMEHeader}; + +pub fn raw(md: &Maildir, mid: &str, mime_path: &str) -> Result<(MIMEHeader, Vec<u8>)> { + let mut mail = md.find(mid).ok_or_else(|| { + std::io::Error::new(IOErrKind::NotFound, format!("mail {} not found", mid)) + })?; + + if mime_path.is_empty() { + let mh = MIMEHeader { + maintype: "message".to_owned(), + subtype: "rfc822".to_owned(), + filename: mail.id().to_owned(), + content_disposition: "".to_owned(), + }; + + return Ok((mh, read(mail.path())?)); + } + + let path = mime_path + .split('.') + .map(|x| { + x.parse() + .map_err(|pe: std::num::ParseIntError| Error::PathError { + msg: pe.to_string(), + path: mime_path.to_owned(), + }) + }) + .collect::<Result<Vec<_>>>()?; + let mut m = mail.parsed()?; + + if path[0] != 0 { + return Err(Error::PathError { + msg: "Message must be accessed by a 0".to_owned(), + path: mime_path.to_owned(), + }); + } + + for i in &path[1..] { + match &m.ctype.mimetype { + x if x.starts_with("message/") => { + if *i != 0 { + return Err(Error::PathError { + msg: "Message must be accessed by a 0".to_owned(), + path: mime_path.to_owned(), + }); + } + let s: &'static _ = m.get_body_raw()?.leak(); + m = mailparse::parse_mail(s)?; + } + x if x.starts_with("multipart/") => { + if *i >= m.subparts.len() { + return Err(Error::PathError { + msg: "Out of bounds access".to_owned(), + path: mime_path.to_owned(), + }); + } + m = m.subparts.swap_remove(*i); + } + _ => { + return Err(Error::PathError { + msg: "Unable to descent into leaf component".to_owned(), + path: mime_path.to_owned(), + }) + } + } + } + + if m.ctype.mimetype.starts_with("multipart/") { + return Err(Error::PathError { + msg: "Can not show multipart component".to_owned(), + path: mime_path.to_owned(), + }); + } + + let mime_part = parse_mail_content(&m)?; + let content = if m.ctype.mimetype.starts_with("text/") { + m.get_body()?.into_bytes() + } else { + m.get_body_raw()? + }; + + Ok((mime_part, content)) +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..fc0a21a --- /dev/null +++ b/src/error.rs @@ -0,0 +1,69 @@ +use std::borrow::Cow; + +use maildir::MailEntryError; +use mailparse::MailParseError; +use serde::ser::SerializeStruct as _; +use serde_json::Error as JSONError; + +pub type Result<T> = std::result::Result<T, Error>; + +#[derive(Debug)] +pub enum Error { + IoError(std::io::Error), + MailEntryError(MailEntryError), + SortOrder(String), + Setuid(String), + JSONError(JSONError), + PathError { msg: String, path: String }, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> { + write!(f, "{:?}", self) + } +} + +impl std::error::Error for Error {} + +impl serde::Serialize for Error { + fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + let mut state = serializer.serialize_struct("Error", 1)?; + let err_str: Cow<str> = match self { + Error::IoError(e) => Cow::Owned(e.to_string()), + Error::MailEntryError(e) => Cow::Owned(e.to_string()), + Error::SortOrder(s) => Cow::Borrowed(s), + Error::Setuid(s) => Cow::Borrowed(s), + Error::JSONError(e) => Cow::Owned(e.to_string()), + Error::PathError { msg, path } => Cow::Owned(format!("{} {:?}", msg, path)), + }; + state.serialize_field("error", &err_str)?; + state.end() + } +} + +impl From<std::io::Error> for Error { + fn from(io_err: std::io::Error) -> Self { + Error::IoError(io_err) + } +} + +impl From<MailEntryError> for Error { + fn from(me_err: MailEntryError) -> Self { + Error::MailEntryError(me_err) + } +} + +impl From<MailParseError> for Error { + fn from(mp_err: MailParseError) -> Self { + Error::MailEntryError(MailEntryError::ParseError(mp_err)) + } +} + +impl From<JSONError> for Error { + fn from(j_err: JSONError) -> Self { + Error::JSONError(j_err) + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..8a8b84e --- /dev/null +++ b/src/main.rs @@ -0,0 +1,89 @@ +use std::ffi::{CStr, CString}; +use std::io::stdout; + +use clap::Parser as _; +use maildir::Maildir; + +mod arguments; +mod cmd; +mod error; +mod rfc822; + +use arguments::{Arguments, Mode}; +use cmd::{ + count, folders, list, move_mail, open_submaildir, raw, read, search, serialize_to, Return, +}; +use error::{Error, Result}; + +fn switch_to_user(sys_user: &str) -> Result<()> { + unsafe { + *libc::__errno_location() = 0; + } + let c_sys_user = + CString::new(sys_user).map_err(|e| Error::Setuid(format!("nul char in user, {}", e)))?; + let user_info: *const libc::passwd = unsafe { libc::getpwnam(c_sys_user.as_ptr()) }; + let err = unsafe { *libc::__errno_location() }; + if err != 0 { + return Err(Error::Setuid(format!( + "error calling getpwnam {:?}", + unsafe { libc::strerror(err) } + ))); + }; + if user_info.is_null() { + return Err(Error::Setuid(format!("user {:?} does not exist", sys_user))); + }; + let rc = unsafe { libc::setuid((*user_info).pw_uid) }; + if rc != 0 { + let err = unsafe { *libc::__errno_location() }; + return Err(Error::Setuid(format!( + "error calling setuid {:?}", + unsafe { CStr::from_ptr(libc::strerror(err)) } + ))); + } + Ok(()) +} + +fn main() -> std::io::Result<()> { + simplelog::TermLogger::init( + simplelog::LevelFilter::Info, + simplelog::Config::default(), + simplelog::TerminalMode::Stderr, + simplelog::ColorChoice::Never, + ) + .unwrap(); + + let args = Arguments::parse(); + + std::env::remove_var("PATH"); + if let Err(e) = switch_to_user(&args.sys_user) { + serialize_to(Err(e), &stdout())? + } + + let path = args.maildir_path.join(args.mail_user); + + let res = match args.mode { + Mode::Read { subfolder, mid } => { + read(&open_submaildir(path, &subfolder), &mid).map(Return::Read) + } + Mode::Raw { + subfolder, + mid, + mime_path, + } => raw(&open_submaildir(path, &subfolder), &mid, &mime_path) + .map(|(h, t)| Return::Raw(h, t)), + Mode::List { + subfolder, + start, + end, + ref sortby, + } => list(&open_submaildir(path, &subfolder), start, end, sortby).map(Return::List), + Mode::Folders => folders(&Maildir::from(path)).map(Return::Folders), + Mode::Count { subfolder } => count(&open_submaildir(path, &subfolder)).map(Return::Count), + Mode::Search { pattern, subfolder } => { + search(&open_submaildir(path, &subfolder), &pattern).map(Return::Search) + } + Mode::Move { mid, from, to } => move_mail(path, &mid, &from, &to).map(|()| Return::Move), + }; + + serialize_to(res, &stdout()) +} diff --git a/src/rfc822.rs b/src/rfc822.rs new file mode 100644 index 0000000..f6e9287 --- /dev/null +++ b/src/rfc822.rs @@ -0,0 +1,548 @@ +use chrono::{DateTime, NaiveDateTime, Utc}; +use mailparse::{addrparse_header, body::Body, dateparse, DispositionType, ParsedMail}; +use serde::{ser::SerializeSeq, Serialize, Serializer}; + +use crate::error::Error; + +#[derive(Serialize, Eq, Ord, Debug)] +pub struct MailAddr { + pub display_name: String, + pub address: String, +} + +impl PartialEq for MailAddr { + fn eq(&self, r: &Self) -> bool { + self.address == r.address + } +} + +impl PartialOrd for MailAddr { + fn partial_cmp(&self, r: &Self) -> Option<std::cmp::Ordering> { + Some(self.cmp(r)) + } +} + +fn parse_mail_addrs( + inp: &mailparse::MailHeader, +) -> Result<Vec<MailAddr>, mailparse::MailParseError> { + let mut mal = addrparse_header(inp)?; + + Ok(mal + .drain(..) + .flat_map(|mail_addr| match mail_addr { + mailparse::MailAddr::Group(mut g) => g + .addrs + .drain(..) + .map(|s| MailAddr { + display_name: s.display_name.unwrap_or_default(), + address: s.addr, + }) + .collect(), + mailparse::MailAddr::Single(s) => vec![MailAddr { + display_name: s.display_name.unwrap_or_default(), + address: s.addr, + }], + }) + .collect()) +} + +// ---------------- + +fn serialize_date_time<S>(dt: &DateTime<Utc>, s: S) -> Result<S::Ok, S::Error> +where + S: Serializer, +{ + s.serialize_str(&dt.to_rfc3339()) +} + +fn serialize_sender<S>(oma: &Option<MailAddr>, s: S) -> Result<S::Ok, S::Error> +where + S: Serializer, +{ + if let Some(ma) = oma { + let mut seq = s.serialize_seq(Some(1))?; + seq.serialize_element(ma)?; + seq.end() + } else { + let seq = s.serialize_seq(Some(0))?; + seq.end() + } +} + +#[derive(Serialize, Debug)] +pub struct MailHeader { + #[serde(serialize_with = "serialize_date_time")] + #[serde(rename = "date")] + pub orig_date: DateTime<Utc>, + + // originator fields + pub from: Vec<MailAddr>, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(serialize_with = "serialize_sender")] + pub sender: Option<MailAddr>, + #[serde(skip_serializing_if = "Option::is_none")] + reply_to: Option<Vec<MailAddr>>, + + // destination fields + #[serde(skip_serializing_if = "Vec::is_empty")] + to: Vec<MailAddr>, + #[serde(skip_serializing_if = "Vec::is_empty")] + cc: Vec<MailAddr>, + #[serde(skip_serializing_if = "Option::is_none")] + bcc: Option<Vec<MailAddr>>, + + /* identification fields + #[serde(skip_serializing_if = "String::is_empty")] + message_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + in_reply_to: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + references: Option<String>, + */ + // informational fields + pub subject: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + comments: Vec<String>, + #[serde(skip_serializing_if = "Vec::is_empty")] + keywords: Vec<String>, + + mime: MIMEHeader, +} + +#[derive(Serialize, Debug)] +pub struct MIMEHeader { + #[serde(rename = "content_maintype")] + pub maintype: String, + #[serde(rename = "content_subtype")] + pub subtype: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub content_disposition: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub filename: String, +} + +enum ContentDisposition { + None, + Inline, + Attachment { filename: Option<String> }, +} + +#[derive(Serialize)] +pub struct MIMEPart { + pub head: MIMEHeader, + body: MailBody, +} + +#[derive(Serialize)] +#[serde(untagged)] +pub enum MailBody { + Discrete(String), + Multipart { + #[serde(skip_serializing_if = "String::is_empty")] + preamble: String, + parts: Vec<MIMEPart>, + #[serde(skip_serializing_if = "String::is_empty")] + epilogue: String, + }, + Message(Box<Mail>), +} + +#[derive(Serialize)] +pub struct Mail { + head: MailHeader, + pub body: MailBody, +} + +#[derive(Serialize, Debug)] +pub struct TopMailHeader { + byte_size: u64, + unread: bool, + #[serde(serialize_with = "serialize_date_time")] + pub date_received: DateTime<Utc>, + message_handle: String, + pub head: MailHeader, +} + +fn get_received(me: &mut maildir::MailEntry) -> i64 { + me.received().unwrap_or_else(|_| { + let mut id = me.id(); + id = &id[..id.find('.').unwrap()]; + id.parse().unwrap_or_default() + }) +} + +impl TryFrom<maildir::MailEntry> for TopMailHeader { + type Error = Error; + + fn try_from(mut me: maildir::MailEntry) -> Result<Self, Self::Error> { + Ok(TopMailHeader { + byte_size: me.path().metadata()?.len(), + unread: !me.is_seen(), + date_received: DateTime::<Utc>::from_utc( + NaiveDateTime::from_timestamp_opt(get_received(&mut me), 0).unwrap(), + Utc, + ), + message_handle: me.id().to_owned(), + head: parse_mail_header(&me.parsed()?)?, + }) + } +} + +pub fn parse_mail_content(v: &ParsedMail) -> Result<MIMEHeader, maildir::MailEntryError> { + let mut c = MIMEHeader { + maintype: String::new(), + subtype: String::new(), + content_disposition: String::new(), + filename: String::new(), + }; + + { + let mut val = v.ctype.mimetype.clone(); + if let Some(i) = val.find(';') { + val.truncate(i); + } + let j = val.find('/').unwrap(); + c.subtype = val.split_off(j + 1); + val.pop(); + c.maintype = val; + } + + match v.get_content_disposition().disposition { + DispositionType::Inline => c.content_disposition = "inline".to_owned(), + DispositionType::Attachment => { + c.content_disposition = "attachment".to_owned(); + if let Some(fname) = v.get_content_disposition().params.remove("filename") { + c.filename = fname; + } + } + _ => {} + } + + for h in &v.headers { + let mut key = h.get_key(); + let val = h.get_value(); + + key.make_ascii_lowercase(); + + match key.as_ref() { + "filename" => { + c.filename = val; + } + _ => {} + } + } + + Ok(c) +} + +fn parse_mail_header(pm: &ParsedMail) -> Result<MailHeader, maildir::MailEntryError> { + let v = &pm.headers; + + let mut mh = MailHeader { + orig_date: Utc::now(), + from: Vec::new(), + sender: None, + reply_to: None, + to: Vec::new(), + cc: Vec::new(), + bcc: None, + subject: String::new(), + comments: Vec::new(), + keywords: Vec::new(), + mime: MIMEHeader { + maintype: String::new(), + subtype: String::new(), + content_disposition: String::new(), + filename: String::new(), + }, + }; + + { + let mut val = pm.ctype.mimetype.clone(); + if let Some(i) = val.find(';') { + val.truncate(i); + } + let j = val.find('/').unwrap(); + mh.mime.subtype = val.split_off(j + 1); + val.pop(); + mh.mime.maintype = val; + } + + let mut key = String::new(); + + for y in v { + key.push_str(&y.get_key_ref()); + let mut val = y.get_value(); + + key.make_ascii_lowercase(); + + match key.as_str() { + "date" => { + mh.orig_date = DateTime::<Utc>::from_utc( + NaiveDateTime::from_timestamp_opt(dateparse(&val)?, 0).unwrap(), + Utc, + ) + } + "from" => { + if !mh.from.is_empty() { + return Err("from already set".into()); + } + mh.from = parse_mail_addrs(y)? + } + "sender" => mh.sender = parse_mail_addrs(y)?.drain(0..1).next(), + "reply-to" => mh.reply_to = Some(parse_mail_addrs(y)?), + "to" => mh.to = parse_mail_addrs(y)?, + "cc" => mh.cc = parse_mail_addrs(y)?, + "bcc" => mh.bcc = Some(parse_mail_addrs(y)?), + "subject" => { + mh.subject = val; + } + "comments" => { + mh.comments.push(val); + } + "keywords" => { + mh.keywords.push(val); + } + "mime-version" => { + strip_comments(&mut val); + if val.trim() != "1.0" { + return Err(maildir::MailEntryError::DateError("unknown mime version")); + } + } + "content-disposition" => { + mh.mime.content_disposition = val; + } + "filename" => { + mh.mime.filename = val; + } + _ => {} + }; + + key.clear(); + } + + Ok(mh) +} + +fn parse_mail_body(pm: &ParsedMail) -> Result<MailBody, maildir::MailEntryError> { + let body = if pm.ctype.mimetype.starts_with("message/") { + MailBody::Message(Box::new( + mailparse::parse_mail(pm.get_body()?.as_ref())?.try_into()?, + )) + } else if pm.subparts.is_empty() && pm.ctype.mimetype.starts_with("text/") { + let b = pm.get_body()?; + MailBody::Discrete(b) + } else if pm.subparts.is_empty() { + let b = match pm.get_body_encoded() { + Body::Base64(eb) => { + let db = eb.get_raw(); + if db.len() < 512 * 1024 { + String::from_utf8_lossy(db).into_owned() + } else { + String::new() + } + } + Body::SevenBit(eb) => eb.get_as_string()?, + _ => todo!(), + }; + MailBody::Discrete(b) + } else { + MailBody::Multipart { + preamble: String::new(), + parts: pm + .subparts + .iter() + .map(|part| { + Ok(MIMEPart { + head: parse_mail_content(part)?, + body: parse_mail_body(part)?, + }) + }) + .filter_map(|p: Result<MIMEPart, maildir::MailEntryError>| p.ok()) + .collect(), + epilogue: String::new(), + } + }; + Ok(body) +} + +enum FindMatchParen { + Open, + Close, +} + +impl FindMatchParen { + fn value(&self) -> char { + match self { + FindMatchParen::Open => '(', + FindMatchParen::Close => ')', + } + } + + fn len(&self) -> usize { + 1 + } + + fn of_char(c: char) -> Option<Self> { + match c { + '(' => Some(FindMatchParen::Open), + ')' => Some(FindMatchParen::Close), + _ => None, + } + } +} + +fn find_in_header(s: &str, f: FindMatchParen) -> Option<usize> { + let mut in_q = false; + let mut q_pair = false; + let mut open_p = 0; + + for (i, c) in s.char_indices() { + if q_pair { + q_pair = false; + continue; + } + match c { + '\\' => { + q_pair = true; + } + '"' => { + in_q = !in_q; + } + _ if !in_q => { + if open_p == 0 { + if c == f.value() { + return Some(i); + } + if c == FindMatchParen::Open.value() { + open_p += 1; + } + } else { + match FindMatchParen::of_char(c) { + Some(FindMatchParen::Open) => open_p += 1, + Some(FindMatchParen::Close) => open_p -= 1, + None => {} + } + } + } + _ => {} + }; + } + None +} + +fn find_pair(offset: usize, s: &str) -> Option<std::ops::Range<usize>> { + if let Some(open) = find_in_header(s, FindMatchParen::Open) { + if let Some(mut close) = find_in_header( + &s[open + FindMatchParen::Open.len()..], + FindMatchParen::Close, + ) { + close += open + FindMatchParen::Open.len(); + Some(offset + open..offset + close + FindMatchParen::Close.len()) + } else { + find_pair( + offset + open + FindMatchParen::Open.len(), + &s[open + FindMatchParen::Open.len()..], + ) + } + } else { + None + } +} + +fn strip_comments(s: &mut String) { + let mut off = 0; + loop { + if let Some(r) = find_pair(off, &s[off..]) { + s.drain(r.clone()); + off = r.start; + } else { + break; + } + } +} + +impl TryFrom<ParsedMail<'_>> for Mail { + type Error = maildir::MailEntryError; + + fn try_from(m: ParsedMail) -> Result<Self, Self::Error> { + let head = parse_mail_header(&m)?; + let body = parse_mail_body(&m)?; + + Ok(Mail { head, body }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn comment() { + let mut x = r#"(this is ((some) text)) a "some text with (comment \" in) quotes)(" (example) \( included) (xx)b()"#.to_owned(); + strip_comments(&mut x); + assert_eq!( + &x, + r#" a "some text with (comment \" in) quotes)(" \( included) b"# + ); + } + + #[test] + fn unclosed_comment() { + let mut x = "(this is (some text) example b".to_owned(); + strip_comments(&mut x); + assert_eq!(&x, "(this is example b"); + } + + #[test] + fn find_first_pair() { + let mut r = find_pair(0, "abc def"); + assert_eq!(r, None); + + r = find_pair(0, "abc ( def"); + assert_eq!(r, None); + + r = find_pair(0, "abc ) def"); + assert_eq!(r, None); + + let s = "(abc) def"; + if let Some(i) = find_pair(0, s) { + assert_eq!(i, 0..5); + assert_eq!(&s[i], "(abc)"); + } else { + assert!(false, "Got None expected Some!"); + } + + let s = "abc (def) ghi"; + if let Some(i) = find_pair(0, s) { + assert_eq!(i, 4..9); + assert_eq!(&s[i], "(def)"); + } else { + assert!(false, "Got None expected Some!"); + } + + let s = "(abc (def) ghi"; + if let Some(i) = find_pair(0, s) { + assert_eq!(i, 5..10); + assert_eq!(&s[i], "(def)"); + } else { + assert!(false, "Got None expected Some!"); + } + + let s = "abc ((def) ghi)"; + if let Some(i) = find_pair(0, s) { + assert_eq!(i, 4..15); + assert_eq!(&s[i], "((def) ghi)"); + } else { + assert!(false, "Got None expected Some!"); + } + + let s = r#" a "some text with (comment \" in) quotes)(" (example)"#; + if let Some(i) = find_pair(0, s) { + assert_eq!(i, 45..54); + assert_eq!(&s[i], "(example)"); + } else { + assert!(false, "Got None expected Some!"); + } + } +} |