agent-first-mail 0.2.0

Let your AI agent work your inbox — email pulled into plain files it reads, sorts, and drafts on your machine, with nothing sent until you confirm.
Documentation
use super::*;

pub(super) fn find_outbound_item(
    root: &Path,
    case_uid: &str,
    draft_name: &str,
) -> Result<Option<PushItem>> {
    for item in read_items(root)? {
        if item.outbound().is_some_and(|outbound| {
            outbound.case_uid == case_uid && outbound.draft_name == draft_name
        }) {
            return Ok(Some(item));
        }
    }
    Ok(None)
}

pub(super) fn read_items(root: &Path) -> Result<Vec<PushItem>> {
    let dir = root.join(".afmail/push");
    if !dir.exists() {
        return Ok(Vec::new());
    }
    let mut out = Vec::new();
    for entry in fs::read_dir(&dir).map_err(|e| AppError::io("read push queue", &e))? {
        let entry = entry.map_err(|e| AppError::io("read push queue", &e))?;
        if entry.path().extension().and_then(|s| s.to_str()) != Some("json") {
            continue;
        }
        let data =
            fs::read_to_string(entry.path()).map_err(|e| AppError::io("read push item", &e))?;
        out.push(PushItem::parse_json(&data)?);
    }
    Ok(out)
}

pub(super) fn sorted_items(root: &Path) -> Result<Vec<PushItem>> {
    let mut items = read_items(root)?;
    items.sort_by(|a, b| a.created_rfc3339.cmp(&b.created_rfc3339));
    Ok(items)
}

pub(super) fn write_item(root: &Path, item: &PushItem) -> Result<()> {
    let dir = root.join(".afmail/push");
    create_dir_all(&dir)?;
    let data = serde_json::to_string_pretty(item)
        .map_err(|e| AppError::json("serialize push item", &e))?;
    write_string_atomic(&dir.join(format!("{}.json", item.push_id)), &(data + "\n"))
}

pub(super) fn delete_item(root: &Path, item: &PushItem) -> Result<()> {
    let json_path = push_path(root, &item.push_id);
    if json_path.exists() {
        fs::remove_file(json_path).map_err(|e| AppError::io("remove push json", &e))?;
    }
    Ok(())
}

pub(super) fn push_path(root: &Path, push_id: &str) -> PathBuf {
    root.join(".afmail/push").join(format!("{push_id}.json"))
}

pub(super) fn find_case_path(root: &Path, case_uid: &str) -> Result<PathBuf> {
    if let Some(candidate) = find_case_path_any(root, case_uid)? {
        if candidate.starts_with(root.join("cases")) {
            return Ok(candidate);
        }
    }
    Err(AppError::new(
        "case_not_found",
        format!("case not found: {case_uid}"),
    ))
}

pub(super) fn find_case_path_any(root: &Path, case_uid: &str) -> Result<Option<PathBuf>> {
    for dir in case_search_roots(root)? {
        let Some(name) = dir.file_name().and_then(|name| name.to_str()) else {
            continue;
        };
        if dir_case_uid(name) == Some(case_uid) && dir.join("data/case.json").is_file() {
            return Ok(Some(dir));
        }
    }
    Ok(None)
}

pub(super) fn case_search_roots(root: &Path) -> Result<Vec<PathBuf>> {
    let mut out = Vec::new();
    let cases_dir = root.join("cases");
    if cases_dir.is_dir() {
        for group in fs::read_dir(&cases_dir).map_err(|e| AppError::io("read cases", &e))? {
            let group = group.map_err(|e| AppError::io("read cases", &e))?;
            if group.path().is_dir() {
                for case in
                    fs::read_dir(group.path()).map_err(|e| AppError::io("read cases", &e))?
                {
                    let case = case.map_err(|e| AppError::io("read cases", &e))?;
                    if case.path().is_dir() {
                        out.push(case.path());
                    }
                }
            }
        }
    }
    let archive_dir = root.join("archive/cases");
    if archive_dir.is_dir() {
        for case in
            fs::read_dir(&archive_dir).map_err(|e| AppError::io("read archived cases", &e))?
        {
            let case = case.map_err(|e| AppError::io("read archived cases", &e))?;
            if case.path().is_dir() {
                out.push(case.path());
            }
        }
    }
    Ok(out)
}

pub(super) fn dir_case_uid(name: &str) -> Option<&str> {
    let uid = name.split('-').next()?;
    let bytes = uid.as_bytes();
    if bytes.len() >= 10 && bytes[0] == b'c' && bytes[1..].iter().all(u8::is_ascii_digit) {
        Some(uid)
    } else {
        None
    }
}

pub(super) fn unique_push_id(root: &Path) -> String {
    let base = format!(
        "push_{}",
        crate::store::now_rfc3339().replace([':', '-'], "")
    );
    let dir = root.join(".afmail/push");
    if !dir.join(format!("{base}.json")).exists() {
        return base;
    }
    for i in 1..1000 {
        let candidate = format!("{base}_{i}");
        if !dir.join(format!("{candidate}.json")).exists() {
            return candidate;
        }
    }
    base
}

pub(super) fn create_dir_all(path: &Path) -> Result<()> {
    fs::create_dir_all(path).map_err(|e| AppError::io("create directory", &e))
}

pub(super) fn path_to_string(path: &Path) -> String {
    path.to_string_lossy().replace('\\', "/")
}

pub(super) fn rel_path(root: &Path, path: &Path) -> String {
    path.strip_prefix(root)
        .map(path_to_string)
        .unwrap_or_else(|_| path_to_string(path))
}