use std::fs;
use std::path::{Path, PathBuf};
use mail_parser::{Addr, DateTime, MessageParser, MimeHeaders};
use crate::commands::keyring::email::identity::{self, Identity};
use crate::core::data::{Transform, sanitize_filename, unique_path, yaml_quote};
pub(crate) const MESSAGE_HASHES_FILE: &str = ".message-hashes";
pub(crate) const ATTACHMENT_HASHES_FILE: &str = ".attachment-hashes";
pub(crate) struct StagedAttachment {
pub hash: String,
pub staged_path: PathBuf,
pub staged_relpath: String,
}
pub(crate) struct TransformOutcome {
pub message_hash: String,
pub md_staged_path: PathBuf,
pub md_staged_relpath: String,
pub desired_md_name: String,
pub mailbox_tag: String,
pub attachments: Vec<StagedAttachment>,
}
pub(crate) struct EmailTransform {
pub identity: Identity,
pub input_root: PathBuf,
pub staging_root: PathBuf,
}
impl Transform for EmailTransform {
type Input = PathBuf;
type Output = TransformOutcome;
fn transform(&self, eml_path: PathBuf) -> Result<Option<TransformOutcome>, String> {
let Some(uid) = eml_path
.file_stem()
.and_then(|stem| stem.to_str())
.and_then(|stem| stem.parse::<u32>().ok())
else {
eprintln!(
"Warning: {} is not named <uid>.eml, skipping",
eml_path.display()
);
return Ok(None);
};
let bytes = match fs::read(&eml_path) {
Ok(bytes) => bytes,
Err(err) => {
eprintln!("Warning: failed to read {}: {err}", eml_path.display());
return Ok(None);
}
};
let message_hash = format!("{:x}", md5::compute(&bytes));
let Some(message) = MessageParser::default().parse(&bytes) else {
eprintln!("Warning: failed to parse {}, skipping", eml_path.display());
return Ok(None);
};
let Some(date) = message.date() else {
eprintln!(
"Warning: {} has no Date header, skipping",
eml_path.display()
);
return Ok(None);
};
let mailbox = mailbox_tag(&eml_path, &self.input_root);
let relative_dir = eml_path
.strip_prefix(&self.input_root)
.ok()
.and_then(|path| path.parent())
.map(Path::to_path_buf)
.unwrap_or_default();
let staged_dir = self.staging_root.join("transformed").join(&relative_dir);
fs::create_dir_all(&staged_dir)
.map_err(|err| format!("failed to create {}: {err}", staged_dir.display()))?;
let subject = message.subject().unwrap_or("(no subject)");
let from_addr = message.from().and_then(|address| address.first());
let to_addr = message.to().and_then(|address| address.first());
let body = if message.html_body_count() > 0 {
let html = message.body_html(0).unwrap_or_default();
htmd::convert(&html)
.unwrap_or_else(|_| message.body_text(0).unwrap_or_default().to_string())
} else if message.text_body_count() > 0 {
message.body_text(0).unwrap_or_default().to_string()
} else {
String::new()
};
let stem = format!(
"{}-{}",
format_date_prefix(date),
identity::sanitize_segment(subject)
);
let attachments_dir = staged_dir.join(uid.to_string()).join("attachments");
let mut attachments = Vec::new();
let mut attachment_relpaths = Vec::new();
for part in message.attachments() {
let contents = part.contents();
let hash = format!("{:x}", md5::compute(contents));
fs::create_dir_all(&attachments_dir)
.map_err(|err| format!("failed to create {}: {err}", attachments_dir.display()))?;
let original_name = sanitize_filename(part.attachment_name().unwrap_or("attachment"));
let staged_path = unique_path(&attachments_dir.join(format!("{stem}-{original_name}")));
fs::write(&staged_path, contents)
.map_err(|err| format!("failed to write {}: {err}", staged_path.display()))?;
let relpath = format!(
"attachments/{}",
staged_path.file_name().unwrap().to_string_lossy()
);
attachments.push(StagedAttachment {
hash,
staged_path,
staged_relpath: relpath.clone(),
});
attachment_relpaths.push(relpath);
}
let mut tags = vec![
mailbox.clone(),
format!("identity/{}", self.identity.alias),
format!("year/{}", date.year),
];
if let Some(domain_tag) =
sender_domain_tag(from_addr.and_then(|addr| addr.address.as_deref()))
{
tags.push(domain_tag);
}
let frontmatter = render_frontmatter(
&format_address(from_addr),
&format_address(to_addr),
subject,
&date.to_rfc3339(),
&tags,
&attachment_relpaths,
uid,
);
let md_staged_path = staged_dir.join(format!("{uid}.md"));
fs::write(&md_staged_path, format!("{frontmatter}\n{body}"))
.map_err(|err| format!("failed to write {}: {err}", md_staged_path.display()))?;
let md_staged_relpath = relpath_string(&self.staging_root, &md_staged_path);
Ok(Some(TransformOutcome {
message_hash,
md_staged_path,
md_staged_relpath,
desired_md_name: format!("{stem}.md"),
mailbox_tag: mailbox,
attachments,
}))
}
}
pub(crate) fn verify_transformed(outcome: &TransformOutcome) -> bool {
let Ok(contents) = fs::read(&outcome.md_staged_path) else {
return false;
};
if contents.is_empty() || !contents.starts_with(b"---") {
return false;
}
outcome
.attachments
.iter()
.all(|attachment| fs::metadata(&attachment.staged_path).is_ok_and(|meta| meta.len() > 0))
}
fn relpath_string(base: &Path, path: &Path) -> String {
path.strip_prefix(base)
.unwrap_or(path)
.components()
.map(|component| component.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/")
}
fn format_address(addr: Option<&Addr>) -> String {
let Some(addr) = addr else {
return String::new();
};
match (addr.name.as_deref(), addr.address.as_deref()) {
(Some(name), Some(email)) => format!("{name} <{email}>"),
(None, Some(email)) => email.to_string(),
(Some(name), None) => name.to_string(),
(None, None) => String::new(),
}
}
fn mailbox_tag(eml_path: &Path, input_root: &Path) -> String {
let relative_dir = eml_path
.strip_prefix(input_root)
.ok()
.and_then(|path| path.parent())
.unwrap_or_else(|| Path::new(""));
let joined: Vec<String> = relative_dir
.components()
.map(|component| component.as_os_str().to_string_lossy().into_owned())
.collect();
format!("mailbox/{}", joined.join("/"))
}
fn sender_domain_tag(address: Option<&str>) -> Option<String> {
let domain = address.and_then(|addr| addr.rsplit('@').next())?;
Some(format!("sender/{}", identity::sanitize_segment(domain)))
}
fn format_date_prefix(date: &DateTime) -> String {
format!("{:04}-{:02}-{:02}", date.year, date.month, date.day)
}
fn render_frontmatter(
from: &str,
to: &str,
subject: &str,
date_rfc3339: &str,
tags: &[String],
attachments: &[String],
uid: u32,
) -> String {
let mut out = String::from("---\n");
out.push_str(&format!("from: {}\n", yaml_quote(from)));
out.push_str(&format!("to: {}\n", yaml_quote(to)));
out.push_str(&format!("subject: {}\n", yaml_quote(subject)));
out.push_str(&format!("date: {date_rfc3339}\n"));
out.push_str("tags:\n");
for tag in tags {
out.push_str(&format!(" - {tag}\n"));
}
if !attachments.is_empty() {
out.push_str("attachments:\n");
for attachment in attachments {
out.push_str(&format!(" - {attachment}\n"));
}
}
out.push_str(&format!("uid: {uid}\n"));
out.push_str("---\n");
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mailbox_tag_joins_nested_path() {
assert_eq!(
mailbox_tag(
Path::new("/tmp/in/archive/2020/5.eml"),
Path::new("/tmp/in")
),
"mailbox/archive/2020"
);
}
#[test]
fn mailbox_tag_single_level() {
assert_eq!(
mailbox_tag(Path::new("/tmp/in/inbox/1.eml"), Path::new("/tmp/in")),
"mailbox/inbox"
);
}
#[test]
fn sender_domain_tag_extracts_and_sanitizes_domain() {
assert_eq!(
sender_domain_tag(Some("jane.doe@example.com")),
Some("sender/example-com".to_string())
);
}
#[test]
fn sender_domain_tag_none_when_no_address() {
assert_eq!(sender_domain_tag(None), None);
}
#[test]
fn format_date_prefix_pads_single_digits() {
let date = DateTime::parse_rfc822("Fri, 26 Jan 2024 09:15:00 +0000").unwrap();
assert_eq!(format_date_prefix(&date), "2024-01-26");
}
#[test]
fn render_frontmatter_matches_expected_shape() {
let out = render_frontmatter(
"Jane Doe <jane.doe@example.com>",
"first.last@example.com",
"Hello, World!",
"2024-01-26T09:15:00+00:00",
&[
"mailbox/inbox".to_string(),
"identity/first-last".to_string(),
],
&["attachments/2024-01-26-hello-world-bingo.pdf".to_string()],
482,
);
assert_eq!(
out,
"---\n\
from: \"Jane Doe <jane.doe@example.com>\"\n\
to: \"first.last@example.com\"\n\
subject: \"Hello, World!\"\n\
date: 2024-01-26T09:15:00+00:00\n\
tags:\n\
\x20\x20- mailbox/inbox\n\
\x20\x20- identity/first-last\n\
attachments:\n\
\x20\x20- attachments/2024-01-26-hello-world-bingo.pdf\n\
uid: 482\n\
---\n"
);
}
fn dummy_outcome(
md_staged_path: PathBuf,
attachments: Vec<StagedAttachment>,
) -> TransformOutcome {
TransformOutcome {
message_hash: "irrelevant".to_string(),
md_staged_path,
md_staged_relpath: "irrelevant".to_string(),
desired_md_name: "irrelevant.md".to_string(),
mailbox_tag: "mailbox/irrelevant".to_string(),
attachments,
}
}
#[test]
fn verify_transformed_true_for_valid_output() {
let dir = tempfile::tempdir().unwrap();
let md_path = dir.path().join("5.md");
fs::write(&md_path, "---\nfrom: \"a\"\n---\nbody").unwrap();
let attachment_path = dir.path().join("5").join("attachments").join("a.pdf");
fs::create_dir_all(attachment_path.parent().unwrap()).unwrap();
fs::write(&attachment_path, b"content").unwrap();
assert!(verify_transformed(&dummy_outcome(
md_path,
vec![StagedAttachment {
hash: "h".to_string(),
staged_path: attachment_path,
staged_relpath: "attachments/a.pdf".to_string(),
}]
)));
}
#[test]
fn verify_transformed_false_when_md_missing() {
let dir = tempfile::tempdir().unwrap();
let md_path = dir.path().join("does-not-exist.md");
assert!(!verify_transformed(&dummy_outcome(md_path, vec![])));
}
#[test]
fn verify_transformed_false_when_attachment_missing() {
let dir = tempfile::tempdir().unwrap();
let md_path = dir.path().join("5.md");
fs::write(&md_path, "---\nfrom: \"a\"\n---\nbody").unwrap();
assert!(!verify_transformed(&dummy_outcome(
md_path,
vec![StagedAttachment {
hash: "h".to_string(),
staged_path: dir.path().join("missing.pdf"),
staged_relpath: "attachments/missing.pdf".to_string(),
}]
)));
}
fn test_identity() -> Identity {
Identity {
alias: "first-last".to_string(),
email: "first.last@example.com".to_string(),
provider: crate::commands::keyring::email::provider::Provider::Gmail,
host: "imap.gmail.com".to_string(),
port: 993,
}
}
fn transformer(input_root: &Path, staging_root: &Path) -> EmailTransform {
EmailTransform {
identity: test_identity(),
input_root: input_root.to_path_buf(),
staging_root: staging_root.to_path_buf(),
}
}
fn plain_text_eml(subject: &str) -> String {
format!(
"From: Jane Doe <jane.doe@example.com>\r\n\
To: first.last@example.com\r\n\
Subject: {subject}\r\n\
Date: Fri, 26 Jan 2024 09:15:00 +0000\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
Hello there!\r\n"
)
}
fn eml_with_attachment(subject: &str, filename: &str, attachment_base64: &str) -> String {
format!(
"From: Jane Doe <jane.doe@example.com>\r\n\
To: first.last@example.com\r\n\
Subject: {subject}\r\n\
Date: Fri, 26 Jan 2024 09:15:00 +0000\r\n\
MIME-Version: 1.0\r\n\
Content-Type: multipart/mixed; boundary=\"BOUNDARY\"\r\n\
\r\n\
--BOUNDARY\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
Hello there!\r\n\
--BOUNDARY\r\n\
Content-Type: application/pdf\r\n\
Content-Disposition: attachment; filename=\"{filename}\"\r\n\
Content-Transfer-Encoding: base64\r\n\
\r\n\
{attachment_base64}\r\n\
--BOUNDARY--\r\n"
)
}
#[test]
fn transform_one_writes_to_uid_keyed_staging_tree() {
let staging = tempfile::tempdir().unwrap();
let input = tempfile::tempdir().unwrap();
let inbox = input.path().join("inbox");
fs::create_dir_all(&inbox).unwrap();
fs::write(inbox.join("5.eml"), plain_text_eml("Hello")).unwrap();
let outcome = transformer(input.path(), staging.path())
.transform(inbox.join("5.eml"))
.unwrap()
.unwrap();
assert_eq!(
outcome.md_staged_path,
staging
.path()
.join("transformed")
.join("inbox")
.join("5.md")
);
assert_eq!(outcome.md_staged_relpath, "transformed/inbox/5.md");
assert!(fs::metadata(&outcome.md_staged_path).is_ok());
}
#[test]
fn transform_one_does_not_dedupe_byte_identical_messages() {
let staging = tempfile::tempdir().unwrap();
let input = tempfile::tempdir().unwrap();
let inbox = input.path().join("inbox");
let archive = input.path().join("archive");
fs::create_dir_all(&inbox).unwrap();
fs::create_dir_all(&archive).unwrap();
let raw = plain_text_eml("Hello");
fs::write(inbox.join("1.eml"), &raw).unwrap();
fs::write(archive.join("2.eml"), &raw).unwrap();
let transformer = transformer(input.path(), staging.path());
let first = transformer.transform(inbox.join("1.eml")).unwrap().unwrap();
let second = transformer
.transform(archive.join("2.eml"))
.unwrap()
.unwrap();
assert_eq!(first.message_hash, second.message_hash);
assert_ne!(first.md_staged_path, second.md_staged_path);
assert!(fs::metadata(&first.md_staged_path).is_ok());
assert!(fs::metadata(&second.md_staged_path).is_ok());
}
#[test]
fn transform_one_disambiguates_same_named_attachments_within_one_message() {
let staging = tempfile::tempdir().unwrap();
let input = tempfile::tempdir().unwrap();
let inbox = input.path().join("inbox");
fs::create_dir_all(&inbox).unwrap();
fs::write(
inbox.join("1.eml"),
eml_with_attachment("Shipping", "a.pdf", "JVBERi0xLjQK"),
)
.unwrap();
let outcome = transformer(input.path(), staging.path())
.transform(inbox.join("1.eml"))
.unwrap()
.unwrap();
assert_eq!(outcome.attachments.len(), 1);
assert!(fs::metadata(&outcome.attachments[0].staged_path).is_ok_and(|m| m.len() > 0));
}
#[test]
fn transform_one_sanitizes_attachment_name_with_path_separators() {
let staging = tempfile::tempdir().unwrap();
let input = tempfile::tempdir().unwrap();
let inbox = input.path().join("inbox");
fs::create_dir_all(&inbox).unwrap();
fs::write(
inbox.join("1.eml"),
eml_with_attachment("Shipping", "/img0.png", "JVBERi0xLjQK"),
)
.unwrap();
let outcome = transformer(input.path(), staging.path())
.transform(inbox.join("1.eml"))
.unwrap()
.unwrap();
let attachment_path = &outcome.attachments[0].staged_path;
assert!(
!attachment_path
.file_name()
.unwrap()
.to_string_lossy()
.contains('/')
);
assert!(fs::metadata(attachment_path).is_ok_and(|meta| meta.len() > 0));
}
#[test]
fn transform_one_truncates_a_very_long_subject() {
let staging = tempfile::tempdir().unwrap();
let input = tempfile::tempdir().unwrap();
let inbox = input.path().join("inbox");
fs::create_dir_all(&inbox).unwrap();
let long_subject = "word ".repeat(60);
fs::write(inbox.join("1.eml"), plain_text_eml(&long_subject)).unwrap();
let outcome = transformer(input.path(), staging.path())
.transform(inbox.join("1.eml"))
.unwrap()
.unwrap();
assert!(outcome.desired_md_name.len() <= 255);
assert!(fs::metadata(&outcome.md_staged_path).is_ok());
}
}