summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorJannis M. Hoffmann <jannis@fehcom.de>2023-08-15 15:13:16 +0200
committerJannis M. Hoffmann <jannis@fehcom.de>2023-08-15 15:13:16 +0200
commit161b052713f5cee08f20ce6ade0881e274c47fdd (patch)
tree4f541f79bb4c4a4089373926ca66783a4af26119 /src
initial commit
Diffstat (limited to 'src')
-rw-r--r--src/arguments.rs125
-rw-r--r--src/cmd.rs90
-rw-r--r--src/cmd/count.rs27
-rw-r--r--src/cmd/folders.rs48
-rw-r--r--src/cmd/list.rs116
-rw-r--r--src/cmd/raw.rs89
-rw-r--r--src/error.rs69
-rw-r--r--src/main.rs89
-rw-r--r--src/rfc822.rs548
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!");
+ }
+ }
+}