use anyhow::{Context, Result};
use chrono::{Local, NaiveDateTime};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct Message {
pub from: String,
pub subject: String,
pub body: String,
pub timestamp: NaiveDateTime,
pub metadata: BTreeMap<String, String>,
}
pub fn ensure_dirs(dir: &Path) -> Result<()> {
let messages = dir.join("messages");
std::fs::create_dir_all(messages.join("inbox").join("archive"))?;
std::fs::create_dir_all(messages.join("outbox"))?;
Ok(())
}
pub fn write_message(dir: &Path, box_name: &str, msg: &Message) -> Result<PathBuf> {
let box_dir = dir.join("messages").join(box_name);
std::fs::create_dir_all(&box_dir)?;
let slug = slugify(&msg.subject);
let ts = msg.timestamp.format("%Y-%m-%dT%H-%M-%S");
let disambig = if slug.is_empty() {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
msg.body.hash(&mut hasher);
msg.from.hash(&mut hasher);
format!("{:08x}", hasher.finish() as u32)
} else {
slug
};
let filename = format!("{ts}_{disambig}.md");
let path = box_dir.join(&filename);
let tmp_path = box_dir.join(format!(".tmp_{filename}"));
let content = message_to_markdown(msg);
std::fs::write(&tmp_path, &content)?;
std::fs::rename(&tmp_path, &path)?;
Ok(path)
}
pub fn read_inbox(dir: &Path) -> Result<Vec<(String, Message)>> {
let inbox = dir.join("messages").join("inbox");
if !inbox.exists() {
return Ok(Vec::new());
}
let mut entries: Vec<_> = std::fs::read_dir(&inbox)?
.filter_map(|e| e.ok())
.filter(|e| {
e.path().extension().is_some_and(|ext| ext == "md")
&& e.file_type().is_ok_and(|ft| ft.is_file())
})
.collect();
entries.sort_by_key(|e| e.file_name());
let mut messages = Vec::new();
for entry in entries {
let content = std::fs::read_to_string(entry.path())
.with_context(|| format!("Failed to read {}", entry.path().display()))?;
match parse_message(&content) {
Ok(msg) => {
let filename = entry.file_name().to_string_lossy().to_string();
messages.push((filename, msg));
}
Err(e) => {
eprintln!(
"Warning: skipping malformed message {}: {e}",
entry.path().display()
);
}
}
}
Ok(messages)
}
pub fn list_inbox(dir: &Path) -> Result<Vec<String>> {
let inbox = dir.join("messages").join("inbox");
if !inbox.exists() {
return Ok(Vec::new());
}
let mut entries: Vec<_> = std::fs::read_dir(&inbox)?
.filter_map(|e| e.ok())
.filter(|e| {
e.path().extension().is_some_and(|ext| ext == "md")
&& e.file_type().is_ok_and(|ft| ft.is_file())
})
.collect();
entries.sort_by_key(|e| e.file_name());
Ok(entries
.into_iter()
.map(|e| e.file_name().to_string_lossy().to_string())
.collect())
}
pub fn read_outbox(dir: &Path) -> Result<Vec<(String, Message)>> {
let outbox = dir.join("messages").join("outbox");
if !outbox.exists() {
return Ok(Vec::new());
}
let mut entries: Vec<_> = std::fs::read_dir(&outbox)?
.filter_map(|e| e.ok())
.filter(|e| {
e.path().extension().is_some_and(|ext| ext == "md")
&& e.file_type().is_ok_and(|ft| ft.is_file())
})
.collect();
entries.sort_by_key(|e| e.file_name());
let mut messages = Vec::new();
for entry in entries {
let content = std::fs::read_to_string(entry.path())
.with_context(|| format!("Failed to read {}", entry.path().display()))?;
match parse_message(&content) {
Ok(msg) => {
let filename = entry.file_name().to_string_lossy().to_string();
messages.push((filename, msg));
}
Err(e) => {
eprintln!(
"Warning: skipping malformed message {}: {e}",
entry.path().display()
);
}
}
}
Ok(messages)
}
pub fn read_inbox_archive(dir: &Path) -> Result<Vec<(String, Message)>> {
let archive = dir.join("messages").join("inbox").join("archive");
if !archive.exists() {
return Ok(Vec::new());
}
let mut entries: Vec<_> = std::fs::read_dir(&archive)?
.filter_map(|e| e.ok())
.filter(|e| {
e.path().extension().is_some_and(|ext| ext == "md")
&& e.file_type().is_ok_and(|ft| ft.is_file())
})
.collect();
entries.sort_by_key(|e| e.file_name());
let mut messages = Vec::new();
for entry in entries {
let content = std::fs::read_to_string(entry.path())
.with_context(|| format!("Failed to read {}", entry.path().display()))?;
match parse_message(&content) {
Ok(msg) => {
let filename = entry.file_name().to_string_lossy().to_string();
messages.push((filename, msg));
}
Err(e) => {
eprintln!(
"Warning: skipping malformed archived message {}: {e}",
entry.path().display()
);
}
}
}
Ok(messages)
}
pub fn archive_messages(dir: &Path, filenames: &[String]) -> Result<()> {
let inbox = dir.join("messages").join("inbox");
let archive = inbox.join("archive");
std::fs::create_dir_all(&archive)?;
for filename in filenames {
let src = inbox.join(filename);
let dst = archive.join(filename);
if src.exists() {
std::fs::rename(&src, &dst).with_context(|| format!("Failed to archive {filename}"))?;
}
}
Ok(())
}
pub fn message_to_markdown(msg: &Message) -> String {
let mut lines = Vec::new();
lines.push("---".to_string());
lines.push(format!("from: {}", msg.from));
lines.push(format!("subject: {}", msg.subject));
lines.push(format!(
"timestamp: {}",
msg.timestamp.format("%Y-%m-%dT%H:%M:%S")
));
for (key, value) in &msg.metadata {
lines.push(format!("{key}: {value}"));
}
lines.push("---".to_string());
lines.push(String::new());
lines.push(msg.body.clone());
lines.push(String::new());
lines.join("\n")
}
pub fn parse_message(content: &str) -> Result<Message> {
let content = content.trim();
if !content.starts_with("---") {
anyhow::bail!("Message missing frontmatter delimiter");
}
let rest = &content[3..];
let end = rest
.find("\n---")
.context("Message missing closing frontmatter delimiter")?;
let frontmatter = &rest[..end];
let body = rest[end + 4..].trim().to_string();
let mut from = String::new();
let mut subject = String::new();
let mut timestamp = Local::now().naive_local();
let mut metadata = BTreeMap::new();
for line in frontmatter.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some((key, value)) = line.split_once(':') {
let key = key.trim();
let value = value.trim();
match key {
"from" => from = value.to_string(),
"subject" => subject = value.to_string(),
"timestamp" => {
if let Ok(ts) = NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S") {
timestamp = ts;
}
}
_ => {
metadata.insert(key.to_string(), value.to_string());
}
}
}
}
Ok(Message {
from,
subject,
body,
timestamp,
metadata,
})
}
fn slugify(text: &str) -> String {
text.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.trim_matches('-')
.to_string()
}