Add notmuch crate for interacting with email through CLI.

This commit is contained in:
Bill Thiede 2021-10-29 20:09:40 -07:00
parent c1eeb38e24
commit bec6f0f333
3 changed files with 257 additions and 34 deletions

View File

@ -6,4 +6,9 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
log = "0.4.14"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.68"
thiserror = "1.0.30"
[dev-dependencies]
pretty_assertions = "1"

View File

@ -208,12 +208,13 @@
use std::{ use std::{
ffi::OsStr, ffi::OsStr,
io::Result, io,
path::{Path, PathBuf}, path::{Path, PathBuf},
process::Command, process::Command,
}; };
use serde::Deserialize; use log::info;
use serde::{Deserialize, Serialize};
/// # Number of seconds since the Epoch /// # Number of seconds since the Epoch
pub type UnixTime = isize; pub type UnixTime = isize;
@ -226,21 +227,21 @@ pub type MessageId = String;
/// A top-level set of threads (do_show) /// A top-level set of threads (do_show)
/// Returned by notmuch show without a --part argument /// Returned by notmuch show without a --part argument
#[derive(Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct ThreadSet(pub Vec<Thread>); pub struct ThreadSet(pub Vec<Thread>);
/// Top-level messages in a thread (show_messages) /// Top-level messages in a thread (show_messages)
#[derive(Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct Thread(pub Vec<ThreadNode>); pub struct Thread(pub Vec<ThreadNode>);
/// A message and its replies (show_messages) /// A message and its replies (show_messages)
#[derive(Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct ThreadNode { pub struct ThreadNode(
pub message: Option<Message>, // null if not matched and not --entire-thread pub Option<Message>, // null if not matched and not --entire-thread
pub children: Vec<ThreadNode>, // children of message pub Vec<ThreadNode>, // children of message
} );
/// A message (format_part_sprinter) /// A message (format_part_sprinter)
#[derive(Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct Message { pub struct Message {
pub id: MessageId, pub id: MessageId,
pub r#match: bool, pub r#match: bool,
@ -251,34 +252,53 @@ pub struct Message {
pub tags: Vec<String>, pub tags: Vec<String>,
pub headers: Headers, pub headers: Headers,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<Vec<Part>>, // omitted if --body=false pub body: Option<Vec<Part>>, // omitted if --body=false
} }
/// The headers of a message or part (format_headers_sprinter with reply = FALSE) /// The headers of a message or part (format_headers_sprinter with reply = FALSE)
#[derive(Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug, PartialEq, Default)]
#[serde(rename_all = "PascalCase")]
pub struct Headers { pub struct Headers {
pub subject: String, pub subject: String,
pub from: String, pub from: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub to: Option<String>, pub to: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cc: Option<String>, pub cc: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bcc: Option<String>, pub bcc: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reply_to: Option<String>, pub reply_to: Option<String>,
pub date: String, pub date: String,
} }
#[derive(Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug, PartialEq)]
#[serde(untagged)]
pub enum IntOrString { pub enum IntOrString {
Int(isize), Int(isize),
String(String), String(String),
} }
#[derive(Deserialize, Debug)] impl Default for IntOrString {
fn default() -> Self {
IntOrString::Int(0)
}
}
impl From<isize> for IntOrString {
fn from(i: isize) -> Self {
IntOrString::Int(i)
}
}
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct Rfc822 { pub struct Rfc822 {
pub headers: Headers, pub headers: Headers,
pub body: Vec<Part>, pub body: Vec<Part>,
} }
#[derive(Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug, PartialEq)]
#[serde(tag = "type")] #[serde(untagged)]
pub enum Content { pub enum Content {
/// if content-type starts with "multipart/": /// if content-type starts with "multipart/":
Multipart(Vec<Part>), Multipart(Vec<Part>),
@ -286,79 +306,110 @@ pub enum Content {
Rfc822(Vec<Rfc822>), Rfc822(Vec<Rfc822>),
/// otherwise (leaf parts): /// otherwise (leaf parts):
Leaf { Leaf {
#[serde(skip_serializing_if = "Option::is_none")]
filename: Option<String>, filename: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
content_charset: Option<String>, content_charset: Option<String>,
/// A leaf part's body content is optional, but may be included if /// A leaf part's body content is optional, but may be included if
/// it can be correctly encoded as a string. Consumers should use /// it can be correctly encoded as a string. Consumers should use
/// this in preference to fetching the part content separately. /// this in preference to fetching the part content separately.
#[serde(skip_serializing_if = "Option::is_none")]
content: Option<String>, content: Option<String>,
// If a leaf part's body content is not included, the length of // If a leaf part's body content is not included, the length of
// the encoded content (in bytes) may be given instead. // the encoded content (in bytes) may be given instead.
#[serde(skip_serializing_if = "Option::is_none")]
content_length: Option<isize>, content_length: Option<isize>,
// If a leaf part's body content is not included, its transfer encoding // If a leaf part's body content is not included, its transfer encoding
// may be given. Using this and the encoded content length, it is // may be given. Using this and the encoded content length, it is
// possible for the consumer to estimate the decoded content length. // possible for the consumer to estimate the decoded content length.
#[serde(skip_serializing_if = "Option::is_none")]
content_transfer_encoding: Option<String>, content_transfer_encoding: Option<String>,
}, },
// TODO(wathiede): flatten Leaf variant to replace this.
String(String),
}
impl Default for Content {
fn default() -> Self {
Content::Leaf {
filename: None,
content_charset: None,
content: None,
content_length: None,
content_transfer_encoding: None,
}
}
} }
/// A MIME part /// A MIME part
#[derive(Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug, PartialEq, Default)]
pub struct Part { pub struct Part {
pub id: IntOrString, // part id (currently DFS part number) pub id: IntOrString, // part id (currently DFS part number)
#[serde(skip_serializing_if = "Option::is_none")]
pub encstatus: Option<EncStatus>, pub encstatus: Option<EncStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sigstatus: Option<SigStatus>, pub sigstatus: Option<SigStatus>,
#[serde(rename = "content-type")] #[serde(rename = "content-type")]
pub content_type: String, pub content_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "content-id")] #[serde(rename = "content-id")]
pub content_id: Option<String>, pub content_id: Option<String>,
//pub content: Content, pub content: Content,
} }
/// `encstatus = [{status: "good"|"bad"}]` /// `encstatus = [{status: "good"|"bad"}]`
pub type EncStatus = String; pub type EncStatus = String;
/// # Signature status (format_part_sigstatus_sprinter) /// # Signature status (format_part_sigstatus_sprinter)
#[derive(Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct SigStatus(pub Vec<Signature>); pub struct SigStatus(pub Vec<Signature>);
#[derive(Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug, PartialEq)]
pub enum Signature { pub enum Signature {
/// (signature_status_to_string) /// (signature_status_to_string)
Good { Good {
#[serde(skip_serializing_if = "Option::is_none")]
fingerprint: Option<String>, fingerprint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
created: Option<UnixTime>, created: Option<UnixTime>,
#[serde(skip_serializing_if = "Option::is_none")]
expires: Option<UnixTime>, expires: Option<UnixTime>,
#[serde(skip_serializing_if = "Option::is_none")]
userid: Option<String>, userid: Option<String>,
}, },
None { None {
#[serde(skip_serializing_if = "Option::is_none")]
keyid: Option<String>, keyid: Option<String>,
}, },
Bad { Bad {
#[serde(skip_serializing_if = "Option::is_none")]
keyid: Option<String>, keyid: Option<String>,
}, },
Unknown { Unknown {
#[serde(skip_serializing_if = "Option::is_none")]
keyid: Option<String>, keyid: Option<String>,
}, },
Error { Error {
#[serde(skip_serializing_if = "Option::is_none")]
keyid: Option<String>, keyid: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
errors: Option<isize>, errors: Option<isize>,
}, },
} }
#[derive(Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct SearchSummary(pub Vec<ThreadSummary>); pub struct SearchSummary(pub Vec<ThreadSummary>);
#[derive(Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct SearchThreads(pub Vec<ThreadId>); pub struct SearchThreads(pub Vec<ThreadId>);
#[derive(Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct SearchMessages(pub Vec<MessageId>); pub struct SearchMessages(pub Vec<MessageId>);
#[derive(Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct SearchFiles(pub Vec<String>); pub struct SearchFiles(pub Vec<String>);
#[derive(Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct SearchTags(pub Vec<String>); pub struct SearchTags(pub Vec<String>);
#[derive(Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct ThreadSummary { pub struct ThreadSummary {
pub thread: ThreadId, pub thread: ThreadId,
pub timestamp: UnixTime, pub timestamp: UnixTime,
@ -383,33 +434,55 @@ pub struct ThreadSummary {
// TODO(wathiede): notmuch reply schema // TODO(wathiede): notmuch reply schema
struct Notmuch { #[derive(thiserror::Error, Debug)]
config_path: PathBuf, pub enum NotmuchError {
#[error("notmuch execution error")]
Notmuch(#[from] io::Error),
#[error("json decoding error")]
SerdeJson(#[from] serde_json::Error),
}
#[derive(Default)]
pub struct Notmuch {
config_path: Option<PathBuf>,
} }
impl Notmuch { impl Notmuch {
pub fn with_config<P: AsRef<Path>>(config_path: P) -> Notmuch { pub fn with_config<P: AsRef<Path>>(config_path: P) -> Notmuch {
Notmuch { Notmuch {
config_path: config_path.as_ref().into(), config_path: Some(config_path.as_ref().into()),
} }
} }
pub fn new(&self) -> Result<Vec<u8>> { pub fn new(&self) -> Result<Vec<u8>, NotmuchError> {
self.run_notmuch(["new"]) self.run_notmuch(["new"])
} }
pub fn no_args(&self) -> Result<Vec<u8>> { pub fn no_args(&self) -> Result<Vec<u8>, NotmuchError> {
self.run_notmuch(std::iter::empty::<&str>()) self.run_notmuch(std::iter::empty::<&str>())
} }
fn run_notmuch<I, S>(&self, args: I) -> Result<Vec<u8>> pub fn search(&self, query: &str) -> Result<SearchSummary, NotmuchError> {
let res = self.run_notmuch(["search", "--format=json", "--limit=20", query])?;
Ok(serde_json::from_slice(&res)?)
}
pub fn show(&self, query: &str) -> Result<ThreadSet, NotmuchError> {
let res = self.run_notmuch(["show", "--format=json", query])?;
Ok(serde_json::from_slice(&res)?)
}
fn run_notmuch<I, S>(&self, args: I) -> Result<Vec<u8>, NotmuchError>
where where
I: IntoIterator<Item = S>, I: IntoIterator<Item = S>,
S: AsRef<OsStr>, S: AsRef<OsStr>,
{ {
let mut cmd = Command::new("notmuch"); let mut cmd = Command::new("notmuch");
let cmd = cmd.arg("--config").arg(&self.config_path); if let Some(config_path) = &self.config_path {
let cmd = cmd.args(args); cmd.arg("--config").arg(config_path);
}
cmd.args(args);
info!("{:?}", &cmd);
dbg!(&cmd); dbg!(&cmd);
let out = cmd.output()?; let out = cmd.output()?;
Ok(out.stdout) Ok(out.stdout)
@ -421,7 +494,7 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn new() -> Result<()> { fn new() -> Result<(), NotmuchError> {
let nm = Notmuch::with_config("testdata/notmuch.config"); let nm = Notmuch::with_config("testdata/notmuch.config");
nm.new()?; nm.new()?;
let output = nm.no_args()?; let output = nm.no_args()?;
@ -433,4 +506,86 @@ mod tests {
); );
Ok(()) Ok(())
} }
#[test]
fn search() -> Result<(), NotmuchError> {
let nm = Notmuch::with_config("testdata/notmuch.config");
nm.new()?;
let res = nm.search("goof")?;
assert_eq!(res.0.len(), 1);
Ok(())
}
#[test]
fn show() -> Result<(), NotmuchError> {
let nm = Notmuch::with_config("testdata/notmuch.config");
nm.new()?;
let res = nm.show("goof")?;
assert_eq!(res.0.len(), 1);
Ok(())
}
#[test]
fn thread_set_serde() {
let ts = ThreadSet(vec![Thread(vec![ThreadNode(
Some(Message {
id: "4khpM7BF.1187467196.1017920.wathiede.xinu@localhost".to_string(),
r#match: true,
excluded: false,
filename: vec!["/file/path/email.txt".to_string()],
timestamp: 1187467196,
date_relative: "2007-08-18".to_string(),
tags: vec!["inbox".to_string()],
body: Some(vec![Part {
id: 1.into(),
content_type: "multipart/mixed".to_string(),
content: Content::Multipart(vec![
Part {
id: 2.into(),
content_type: "text/plain".to_string(),
content: Content::String("Spam detection software".to_string()),
..Default::default()
},
Part {
id: 3.into(),
content_type: "message/rfc822".to_string(),
content: Content::Rfc822(vec![Rfc822 {
headers: Headers {
subject: "Re: Registration goof".to_string(),
from: "\"Bill Thiede\" <Bill@xinu.tv>".to_string(),
to: Some(
"jimpark@med.umich.edu, registration@a2ultimate.org"
.to_string(),
),
date: "Sat, 18 Aug 2007 15:59:56 -0400".to_string(),
..Default::default()
},
body: vec![Part {
id: 4.into(),
content_type: "text/plain".to_string(),
content: Content::String("Hello".to_string()),
..Default::default()
}],
}]),
..Default::default()
},
]),
..Default::default()
}]),
headers: Headers {
subject: "Re: Registration goof".to_string(),
from: "\"Bill Thiede\" <Bill@xinu.tv>".to_string(),
to: Some("jimpark@med.umich.edu, registration@a2ultimate.org".to_string()),
date: "Sat, 18 Aug 2007 15:59:56 -0400".to_string(),
..Default::default()
},
}),
vec![],
)])]);
let s = serde_json::to_string_pretty(&ts).expect("failed to encode");
println!("{}", s);
let got = serde_json::from_str(include_str!("../testdata/thread_set.json"))
.expect("failed to decode");
use pretty_assertions::assert_eq;
assert_eq!(ts, got);
}
} }

63
notmuch/testdata/thread_set.json vendored Normal file
View File

@ -0,0 +1,63 @@
[
[
[
{
"id": "4khpM7BF.1187467196.1017920.wathiede.xinu@localhost",
"match": true,
"excluded": false,
"filename": [
"/file/path/email.txt"
],
"timestamp": 1187467196,
"date_relative": "2007-08-18",
"tags": [
"inbox"
],
"body": [
{
"id": 1,
"content-type": "multipart/mixed",
"content": [
{
"id": 2,
"content-type": "text/plain",
"content-disposition": "inline",
"content": "Spam detection software"
},
{
"id": 3,
"content-type": "message/rfc822",
"content-disposition": "inline",
"content": [
{
"headers": {
"Subject": "Re: Registration goof",
"From": "\"Bill Thiede\" <Bill@xinu.tv>",
"To": "jimpark@med.umich.edu, registration@a2ultimate.org",
"Date": "Sat, 18 Aug 2007 15:59:56 -0400"
},
"body": [
{
"id": 4,
"content-type": "text/plain",
"content": "Hello"
}
]
}
]
}
]
}
],
"crypto": {},
"headers": {
"Subject": "Re: Registration goof",
"From": "\"Bill Thiede\" <Bill@xinu.tv>",
"To": "jimpark@med.umich.edu, registration@a2ultimate.org",
"Date": "Sat, 18 Aug 2007 15:59:56 -0400"
}
},
[]
]
]
]