Add notmuch crate for interacting with email through CLI.
This commit is contained in:
parent
c1eeb38e24
commit
bec6f0f333
@ -6,4 +6,9 @@ edition = "2021"
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
log = "0.4.14"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0.68"
|
||||
thiserror = "1.0.30"
|
||||
[dev-dependencies]
|
||||
pretty_assertions = "1"
|
||||
|
||||
@ -208,12 +208,13 @@
|
||||
|
||||
use std::{
|
||||
ffi::OsStr,
|
||||
io::Result,
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
};
|
||||
|
||||
use serde::Deserialize;
|
||||
use log::info;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// # Number of seconds since the Epoch
|
||||
pub type UnixTime = isize;
|
||||
@ -226,21 +227,21 @@ pub type MessageId = String;
|
||||
|
||||
/// A top-level set of threads (do_show)
|
||||
/// Returned by notmuch show without a --part argument
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq)]
|
||||
pub struct ThreadSet(pub Vec<Thread>);
|
||||
|
||||
/// Top-level messages in a thread (show_messages)
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq)]
|
||||
pub struct Thread(pub Vec<ThreadNode>);
|
||||
|
||||
/// A message and its replies (show_messages)
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct ThreadNode {
|
||||
pub message: Option<Message>, // null if not matched and not --entire-thread
|
||||
pub children: Vec<ThreadNode>, // children of message
|
||||
}
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq)]
|
||||
pub struct ThreadNode(
|
||||
pub Option<Message>, // null if not matched and not --entire-thread
|
||||
pub Vec<ThreadNode>, // children of message
|
||||
);
|
||||
/// A message (format_part_sprinter)
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq)]
|
||||
pub struct Message {
|
||||
pub id: MessageId,
|
||||
pub r#match: bool,
|
||||
@ -251,34 +252,53 @@ pub struct Message {
|
||||
pub tags: Vec<String>,
|
||||
|
||||
pub headers: Headers,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub body: Option<Vec<Part>>, // omitted if --body=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 subject: String,
|
||||
pub from: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub to: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cc: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bcc: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reply_to: Option<String>,
|
||||
pub date: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq)]
|
||||
#[serde(untagged)]
|
||||
pub enum IntOrString {
|
||||
Int(isize),
|
||||
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 headers: Headers,
|
||||
pub body: Vec<Part>,
|
||||
}
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[serde(tag = "type")]
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq)]
|
||||
#[serde(untagged)]
|
||||
pub enum Content {
|
||||
/// if content-type starts with "multipart/":
|
||||
Multipart(Vec<Part>),
|
||||
@ -286,79 +306,110 @@ pub enum Content {
|
||||
Rfc822(Vec<Rfc822>),
|
||||
/// otherwise (leaf parts):
|
||||
Leaf {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
filename: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
content_charset: Option<String>,
|
||||
/// A leaf part's body content is optional, but may be included if
|
||||
/// it can be correctly encoded as a string. Consumers should use
|
||||
/// this in preference to fetching the part content separately.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
content: Option<String>,
|
||||
// If a leaf part's body content is not included, the length of
|
||||
// the encoded content (in bytes) may be given instead.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
content_length: Option<isize>,
|
||||
// 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
|
||||
// possible for the consumer to estimate the decoded content length.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, Default)]
|
||||
pub struct Part {
|
||||
pub id: IntOrString, // part id (currently DFS part number)
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub encstatus: Option<EncStatus>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sigstatus: Option<SigStatus>,
|
||||
|
||||
#[serde(rename = "content-type")]
|
||||
pub content_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "content-id")]
|
||||
pub content_id: Option<String>,
|
||||
//pub content: Content,
|
||||
pub content: Content,
|
||||
}
|
||||
|
||||
/// `encstatus = [{status: "good"|"bad"}]`
|
||||
pub type EncStatus = String;
|
||||
|
||||
/// # Signature status (format_part_sigstatus_sprinter)
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq)]
|
||||
pub struct SigStatus(pub Vec<Signature>);
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq)]
|
||||
pub enum Signature {
|
||||
/// (signature_status_to_string)
|
||||
Good {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
fingerprint: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
created: Option<UnixTime>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
expires: Option<UnixTime>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
userid: Option<String>,
|
||||
},
|
||||
None {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
keyid: Option<String>,
|
||||
},
|
||||
Bad {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
keyid: Option<String>,
|
||||
},
|
||||
Unknown {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
keyid: Option<String>,
|
||||
},
|
||||
Error {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
keyid: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
errors: Option<isize>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq)]
|
||||
pub struct SearchSummary(pub Vec<ThreadSummary>);
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq)]
|
||||
pub struct SearchThreads(pub Vec<ThreadId>);
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq)]
|
||||
pub struct SearchMessages(pub Vec<MessageId>);
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq)]
|
||||
pub struct SearchFiles(pub Vec<String>);
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq)]
|
||||
pub struct SearchTags(pub Vec<String>);
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq)]
|
||||
pub struct ThreadSummary {
|
||||
pub thread: ThreadId,
|
||||
pub timestamp: UnixTime,
|
||||
@ -383,33 +434,55 @@ pub struct ThreadSummary {
|
||||
|
||||
// TODO(wathiede): notmuch reply schema
|
||||
|
||||
struct Notmuch {
|
||||
config_path: PathBuf,
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
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 {
|
||||
pub fn with_config<P: AsRef<Path>>(config_path: P) -> 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"])
|
||||
}
|
||||
|
||||
pub fn no_args(&self) -> Result<Vec<u8>> {
|
||||
pub fn no_args(&self) -> Result<Vec<u8>, NotmuchError> {
|
||||
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
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<OsStr>,
|
||||
{
|
||||
let mut cmd = Command::new("notmuch");
|
||||
let cmd = cmd.arg("--config").arg(&self.config_path);
|
||||
let cmd = cmd.args(args);
|
||||
if let Some(config_path) = &self.config_path {
|
||||
cmd.arg("--config").arg(config_path);
|
||||
}
|
||||
cmd.args(args);
|
||||
info!("{:?}", &cmd);
|
||||
dbg!(&cmd);
|
||||
let out = cmd.output()?;
|
||||
Ok(out.stdout)
|
||||
@ -421,7 +494,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new() -> Result<()> {
|
||||
fn new() -> Result<(), NotmuchError> {
|
||||
let nm = Notmuch::with_config("testdata/notmuch.config");
|
||||
nm.new()?;
|
||||
let output = nm.no_args()?;
|
||||
@ -433,4 +506,86 @@ mod tests {
|
||||
);
|
||||
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
63
notmuch/testdata/thread_set.json
vendored
Normal 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"
|
||||
}
|
||||
},
|
||||
[]
|
||||
]
|
||||
]
|
||||
]
|
||||
Loading…
x
Reference in New Issue
Block a user