846 lines
29 KiB
Rust

//! Below is the file `devel/schemata` from the notmuch source tree.
//!
//! This file describes the schemata used for notmuch's structured output
//! format (currently JSON and S-Expressions).
//!
//! []'s indicate lists. List items can be marked with a '?', meaning
//! they are optional; or a '*', meaning there can be zero or more of that
//! item. {}'s indicate an object that maps from field identifiers to
//! values. An object field marked '?' is optional. |'s indicate
//! alternates (e.g., int|string means something can be an int or a
//! string).
//!
//! For S-Expression output, lists are printed delimited by () instead of
//! []. Objects are printed as p-lists, i.e. lists where the keys and values
//! are interleaved. Keys are printed as keywords (symbols preceded by a
//! colon), e.g. (:id "123" :time 54321 :from "foobar"). Null is printed as
//! nil, true as t and false as nil.
//!
//! This is version 2 of the structured output format.
//!
//! Version history
//! ---------------
//!
//! v1
//! - First versioned schema release.
//! - Added part.content-length and part.content-transfer-encoding fields.
//!
//! v2
//! - Added the thread_summary.query field.
//!
//! Common non-terminals
//! --------------------
//!
//! # Number of seconds since the Epoch
//! `unix_time = int`
//!
//! # Thread ID, sans "thread:"
//! `threadid = string`
//!
//! # Message ID, sans "id:"
//! `messageid = string`
//!
//! notmuch show schema
//! -------------------
//!
//! # A top-level set of threads (do_show)
//! # Returned by notmuch show without a --part argument
//! `thread_set = [thread*]`
//!
//! # Top-level messages in a thread (show_messages)
//! `thread = [thread_node*]`
//!
//! # A message and its replies (show_messages)
//! ```text
//! thread_node = [
//! message|null, # null if not matched and not --entire-thread
//! [thread_node*] # children of message
//! ]
//! ```
//!
//! # A message (format_part_sprinter)
//! ```text
//! message = {
//! # (format_message_sprinter)
//! id: messageid,
//! match: bool,
//! filename: string,
//! timestamp: unix_time, # date header as unix time
//! date_relative: string, # user-friendly timestamp
//! tags: [string*],
//!
//! headers: headers,
//! body?: [part] # omitted if --body=false
//! }
//! ```
//!
//! # A MIME part (format_part_sprinter)
//! ```text
//! part = {
//! id: int|string, # part id (currently DFS part number)
//!
//! encstatus?: encstatus,
//! sigstatus?: sigstatus,
//!
//! content-type: string,
//! content-id?: string,
//! # if content-type starts with "multipart/":
//! content: [part*],
//! # if content-type is "message/rfc822":
//! content: [{headers: headers, body: [part]}],
//! # otherwise (leaf parts):
//! filename?: string,
//! content-charset?: 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.
//! content?: string,
//! # If a leaf part's body content is not included, the length of
//! # the encoded content (in bytes) may be given instead.
//! content-length?: int,
//! # 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.
//! content-transfer-encoding?: string
//! }
//! ```
//!
//! # The headers of a message or part (format_headers_sprinter with reply = FALSE)
//! ```text
//! headers = {
//! Subject: string,
//! From: string,
//! To?: string,
//! Cc?: string,
//! Bcc?: string,
//! Reply-To?: string,
//! Date: string
//! }
//! ```
//!
//! # Encryption status (format_part_sprinter)
//! `encstatus = [{status: "good"|"bad"}]`
//!
//! # Signature status (format_part_sigstatus_sprinter)
//! `sigstatus = [signature*]`
//!
//! ```text
//! signature = {
//! # (signature_status_to_string)
//! status: "none"|"good"|"bad"|"error"|"unknown",
//! # if status is "good":
//! fingerprint?: string,
//! created?: unix_time,
//! expires?: unix_time,
//! userid?: string
//! # if status is not "good":
//! keyid?: string
//! # if the signature has errors:
//! errors?: int
//! }
//! ```
//!
//! notmuch search schema
//! ---------------------
//!
//! # --output=summary
//! `search_summary = [thread_summary*]`
//!
//! # --output=threads
//! `search_threads = [threadid*]`
//!
//! # --output=messages
//! `search_messages = [messageid*]`
//!
//! # --output=files
//! `search_files = [string*]`
//!
//! # --output=tags
//! `search_tags = [string*]`
//!
//! ```text
//! thread_summary = {
//! thread: threadid,
//! timestamp: unix_time,
//! date_relative: string, # user-friendly timestamp
//! matched: int, # number of matched messages
//! total: int, # total messages in thread
//! authors: string, # comma-separated names with | between
//! # matched and unmatched
//! subject: string,
//! tags: [string*],
//!
//! # Two stable query strings identifying exactly the matched and
//! # unmatched messages currently in this thread. The messages
//! # matched by these queries will not change even if more messages
//! # arrive in the thread. If there are no matched or unmatched
//! # messages, the corresponding query will be null (there is no
//! # query that matches nothing). (Added in schema version 2.)
//! query: [string|null, string|null],
//! }
//! ```
//!
//! notmuch reply schema
//! --------------------
//!
//! ```text
//! reply = {
//! # The headers of the constructed reply
//! reply-headers: reply_headers,
//!
//! # As in the show format (format_part_sprinter)
//! original: message
//! }
//! ```
//!
//! # Reply headers (format_headers_sprinter with reply = TRUE)
//! ```text
//! reply_headers = {
//! Subject: string,
//! From: string,
//! To?: string,
//! Cc?: string,
//! Bcc?: string,
//! In-reply-to: string,
//! References: string
//! }
//! ```
use std::{
collections::HashMap,
ffi::OsStr,
io::{self},
path::{Path, PathBuf},
process::Command,
};
use serde::{Deserialize, Serialize};
use tracing::{error, info, instrument, warn};
/// # Number of seconds since the Epoch
pub type UnixTime = isize;
/// # Thread ID, sans "thread:"
pub type ThreadId = String;
/// # Message ID, sans "id:"
pub type MessageId = String;
/// A top-level set of threads (do_show)
/// Returned by notmuch show without a --part argument
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct ThreadSet(pub Vec<Thread>);
/// Top-level messages in a thread (show_messages)
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct Thread(pub Vec<ThreadNode>);
/// A message and its replies (show_messages)
#[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(Serialize, Deserialize, Debug, PartialEq)]
pub struct Message {
pub id: MessageId,
pub r#match: bool,
pub excluded: bool,
pub filename: Vec<String>,
pub timestamp: UnixTime, // date header as unix time
pub date_relative: String, // user-friendly timestamp
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(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")]
#[serde(alias = "Delivered-To")]
pub delivered_to: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(alias = "X-Original-To")]
pub x_original_to: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reply_to: Option<String>,
pub date: String,
}
#[derive(Serialize, Deserialize, Debug, PartialEq)]
#[serde(untagged)]
pub enum IntOrString {
Int(isize),
String(String),
}
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(Serialize, Deserialize, Debug, PartialEq)]
#[serde(untagged)]
pub enum Content {
/// if content-type starts with "multipart/":
Multipart(Vec<Part>),
/// if content-type is "message/rfc822":
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(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-disposition")]
pub content_disposition: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "content-transfer-encoding")]
pub content_transfer_encoding: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "content-length")]
pub content_length: Option<isize>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "content-id")]
pub content_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<Content>,
#[serde(skip_serializing_if = "Option::is_none")]
pub filename: Option<String>,
}
/// `encstatus = [{status: "good"|"bad"}]`
pub type EncStatus = String;
/// # Signature status (format_part_sigstatus_sprinter)
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct SigStatus(pub Vec<Signature>);
#[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(Serialize, Deserialize, Debug, PartialEq)]
pub struct SearchSummary(pub Vec<ThreadSummary>);
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct SearchThreads(pub Vec<ThreadId>);
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct SearchMessages(pub Vec<MessageId>);
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct SearchFiles(pub Vec<String>);
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct SearchTags(pub Vec<String>);
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct ThreadSummary {
pub thread: ThreadId,
pub timestamp: UnixTime,
/// user-friendly timestamp
pub date_relative: String,
/// number of matched messages
pub matched: isize,
/// total messages in thread
pub total: isize,
/// comma-separated names with | between matched and unmatched
pub authors: String,
pub subject: String,
pub tags: Vec<String>,
/// Two stable query strings identifying exactly the matched and unmatched messages currently
/// in this thread. The messages matched by these queries will not change even if more
/// messages arrive in the thread. If there are no matched or unmatched messages, the
/// corresponding query will be null (there is no query that matches nothing). (Added in
/// schema version 2.)
pub query: (Option<String>, Option<String>),
}
// TODO(wathiede): notmuch reply schema
#[derive(thiserror::Error, Debug)]
pub enum NotmuchError {
#[error("notmuch execution error")]
Notmuch(#[from] io::Error),
#[error("json decoding error")]
SerdeJson(#[from] serde_json::Error),
#[error("failed to parse bytes as str")]
Utf8Error(#[from] std::str::Utf8Error),
#[error("failed to parse bytes as String")]
StringUtf8Error(#[from] std::string::FromUtf8Error),
#[error("failed to parse str as int")]
ParseIntError(#[from] std::num::ParseIntError),
#[error("failed to parse mail: {0}")]
MailParseError(#[from] mailparse::MailParseError),
}
#[derive(Clone, Default)]
pub struct Notmuch {
config_path: Option<PathBuf>,
}
// TODO: rewrite to use tokio::process::Command and make everything async to see if that helps with
// concurrency being more parallel.
impl Notmuch {
pub fn with_config<P: AsRef<Path>>(config_path: P) -> Notmuch {
Notmuch {
config_path: Some(config_path.as_ref().into()),
}
}
#[instrument(skip_all)]
pub fn new(&self) -> Result<Vec<u8>, NotmuchError> {
self.run_notmuch(["new"])
}
pub fn no_args(&self) -> Result<Vec<u8>, NotmuchError> {
self.run_notmuch(std::iter::empty::<&str>())
}
#[instrument(skip_all, fields(query=query))]
pub fn tags_for_query(&self, query: &str) -> Result<Vec<String>, NotmuchError> {
let res = self.run_notmuch(["search", "--format=json", "--output=tags", query])?;
Ok(serde_json::from_slice(&res)?)
}
pub fn tags(&self) -> Result<Vec<String>, NotmuchError> {
self.tags_for_query("*")
}
pub fn tag_add(&self, tag: &str, search_term: &str) -> Result<(), NotmuchError> {
self.tags_add(tag, &[search_term])
}
#[instrument(skip_all, fields(tag=tag,search_term=?search_term))]
pub fn tags_add(&self, tag: &str, search_term: &[&str]) -> Result<(), NotmuchError> {
let tag = format!("+{tag}");
let mut args = vec!["tag", &tag];
args.extend(search_term);
self.run_notmuch(&args)?;
Ok(())
}
pub fn tag_remove(&self, tag: &str, search_term: &str) -> Result<(), NotmuchError> {
self.tags_remove(tag, &[search_term])
}
#[instrument(skip_all, fields(tag=tag,search_term=?search_term))]
pub fn tags_remove(&self, tag: &str, search_term: &[&str]) -> Result<(), NotmuchError> {
let tag = format!("-{tag}");
let mut args = vec!["tag", &tag];
args.extend(search_term);
self.run_notmuch(&args)?;
Ok(())
}
#[instrument(skip_all, fields(query=query,offset=offset,limit=limit))]
pub fn search(
&self,
query: &str,
offset: usize,
limit: usize,
) -> Result<SearchSummary, NotmuchError> {
let query = if query.is_empty() { "*" } else { query };
let res = self
.run_notmuch([
"search",
"--format=json",
&format!("--offset={offset}"),
&format!("--limit={limit}"),
query,
])
.inspect_err(|err| error!("failed to notmuch search for query '{query}': {err}"))?;
Ok(serde_json::from_slice(&res).unwrap_or_else(|err| {
error!("failed to decode search result for query '{query}': {err}");
SearchSummary(Vec::new())
}))
}
#[instrument(skip_all, fields(query=query))]
pub fn count(&self, query: &str) -> Result<usize, NotmuchError> {
// NOTE: --output=threads is technically more correct, but really slow
// TODO: find a fast thread count path
// let res = self.run_notmuch(["count", "--output=threads", query])?;
let res = self.run_notmuch(["count", query])?;
// Strip '\n' from res.
let s = std::str::from_utf8(&res)?.trim();
Ok(s.parse()
.inspect_err(|err| error!("failed to parse count for query '{query}': {err}"))
.unwrap_or(0))
}
#[instrument(skip_all, fields(query=query))]
pub fn show(&self, query: &str) -> Result<ThreadSet, NotmuchError> {
let slice = self.run_notmuch([
"show",
"--include-html=true",
"--entire-thread=false",
"--format=json",
query,
])?;
// Notmuch returns JSON with invalid unicode. So we lossy convert it to a string here and
// use that for parsing in rust.
let s = String::from_utf8_lossy(&slice);
let mut deserializer = serde_json::Deserializer::from_str(&s);
deserializer.disable_recursion_limit();
let val = serde::de::Deserialize::deserialize(&mut deserializer)?;
deserializer.end()?;
Ok(val)
}
#[instrument(skip_all, fields(query=query,part=part))]
pub fn show_part(&self, query: &str, part: usize) -> Result<Part, NotmuchError> {
let slice = self.run_notmuch([
"show",
"--include-html=true",
"--entire-thread=true",
"--format=json",
&format!("--part={}", part),
query,
])?;
// Notmuch returns JSON with invalid unicode. So we lossy convert it to a string here and
// use that for parsing in rust.
let s = String::from_utf8_lossy(&slice);
let mut deserializer = serde_json::Deserializer::from_str(&s);
deserializer.disable_recursion_limit();
let val = serde::de::Deserialize::deserialize(&mut deserializer)?;
deserializer.end()?;
Ok(val)
}
#[instrument(skip_all, fields(id=id))]
pub fn show_original(&self, id: &MessageId) -> Result<Vec<u8>, NotmuchError> {
self.show_original_part(id, 0)
}
#[instrument(skip_all, fields(id=id,part=part))]
pub fn show_original_part(&self, id: &MessageId, part: usize) -> Result<Vec<u8>, NotmuchError> {
let id = if id.starts_with("id:") {
id
} else {
&format!("id:{id}")
};
let res = self.run_notmuch(["show", "--part", &part.to_string(), id])?;
Ok(res)
}
#[instrument(skip_all, fields(query=query))]
pub fn message_ids(&self, query: &str) -> Result<Vec<String>, NotmuchError> {
let res = self.run_notmuch(["search", "--output=messages", "--format=json", query])?;
Ok(serde_json::from_slice(&res)?)
}
#[instrument(skip_all, fields(query=query))]
pub fn files(&self, query: &str) -> Result<Vec<String>, NotmuchError> {
let res = self.run_notmuch(["search", "--output=files", "--format=json", query])?;
Ok(serde_json::from_slice(&res)?)
}
#[instrument(skip_all)]
pub fn unread_recipients(&self) -> Result<HashMap<String, usize>, NotmuchError> {
let slice = self.run_notmuch([
"show",
"--include-html=false",
"--entire-thread=false",
"--body=false",
"--format=json",
// Arbitrary limit to prevent too much work
"--limit=1000",
"is:unread",
])?;
// Notmuch returns JSON with invalid unicode. So we lossy convert it to a string here and
// use that for parsing in rust.
let s = String::from_utf8_lossy(&slice);
let mut deserializer = serde_json::Deserializer::from_str(&s);
deserializer.disable_recursion_limit();
let ts: ThreadSet = serde::de::Deserialize::deserialize(&mut deserializer)?;
deserializer.end()?;
let mut r = HashMap::new();
fn collect_from_thread_node(
r: &mut HashMap<String, usize>,
tn: &ThreadNode,
) -> Result<(), NotmuchError> {
let Some(msg) = &tn.0 else {
return Ok(());
};
let mut addrs = vec![];
let hdr = &msg.headers.to;
if let Some(to) = hdr {
addrs.push(to);
} else {
let hdr = &msg.headers.x_original_to;
if let Some(to) = hdr {
addrs.push(to);
} else {
let hdr = &msg.headers.delivered_to;
if let Some(to) = hdr {
addrs.push(to);
};
};
};
let hdr = &msg.headers.cc;
if let Some(cc) = hdr {
addrs.push(cc);
};
for recipient in addrs {
mailparse::addrparse(&recipient)?
.into_inner()
.iter()
.for_each(|a| {
let mailparse::MailAddr::Single(si) = a else {
return;
};
let addr = &si.addr;
if addr == "couchmoney@gmail.com" || addr.ends_with("@xinu.tv") {
*r.entry(addr.to_lowercase()).or_default() += 1;
}
});
}
Ok(())
}
for t in ts.0 {
for tn in t.0 {
collect_from_thread_node(&mut r, &tn)?;
for sub_tn in tn.1 {
collect_from_thread_node(&mut r, &sub_tn)?;
}
}
}
Ok(r)
}
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");
if let Some(config_path) = &self.config_path {
cmd.arg("--config").arg(config_path);
}
cmd.args(args);
info!("{:?}", &cmd);
let out = cmd.output()?;
if !out.stderr.is_empty() {
warn!(
"{:?}: STDERR:\n{}",
&cmd,
String::from_utf8_lossy(&out.stderr)
);
}
Ok(out.stdout)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[ignore] // skip because notmuch config is relative to $HOME
fn new() -> Result<(), NotmuchError> {
let nm = Notmuch::with_config("testdata/notmuch.config");
nm.new()?;
let output = nm.no_args()?;
let s = String::from_utf8_lossy(&output);
assert!(
s.contains("Notmuch is configured and appears to have a database. Excellent!"),
"output:\n```\n{}```",
s
);
Ok(())
}
#[test]
#[ignore] // skip because notmuch config is relative to $HOME
fn search() -> Result<(), NotmuchError> {
let nm = Notmuch::with_config("testdata/notmuch.config");
nm.new()?;
let res = nm.search("goof", 0, 100)?;
assert_eq!(res.0.len(), 1);
Ok(())
}
#[test]
#[ignore] // skip because notmuch config is relative to $HOME
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]
#[ignore] // skip because notmuch config is relative to $HOME
fn count() -> Result<(), NotmuchError> {
let nm = Notmuch::with_config("testdata/notmuch.config");
nm.new()?;
let c = nm.count("*")?;
assert_eq!(c, 14);
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: Some(Content::Multipart(vec![
Part {
id: 2.into(),
content_type: "text/plain".to_string(),
content_disposition: Some("inline".to_string()),
content: Some(Content::String("Spam detection software".to_string())),
..Default::default()
},
Part {
id: 3.into(),
content_type: "message/rfc822".to_string(),
content_disposition: Some("inline".to_string()),
content: Some(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: Some(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);
}
}