agent-first-mail 0.1.0

Give your AI agent a mailbox it can actually work in — your mail pulled down into plain files it reads, triages, drafts, and files entirely on your machine, with nothing sent or changed on the real mailbox until you confirm.
Documentation
use super::*;

#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(super) struct TransactionFile {
    pub(super) schema_name: String,
    pub(super) schema_version: u64,
    pub(super) transaction_id: String,
    pub(super) kind: String,
    pub(super) created_rfc3339: String,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub(super) paths: Vec<String>,
}

#[derive(Debug)]
pub(crate) struct LocalTransaction {
    path: PathBuf,
    finished: bool,
}

impl Workspace {
    pub(crate) fn begin_transaction(
        &self,
        kind: &str,
        paths: impl IntoIterator<Item = String>,
    ) -> Result<LocalTransaction> {
        validate_id("transaction kind", kind)?;
        let transactions_dir = self.root.join(".afmail/transactions");
        create_dir_all(&transactions_dir)?;
        let transaction_id = unique_transaction_id(kind);
        let path = transactions_dir.join(format!("{transaction_id}.json"));
        let transaction = TransactionFile {
            schema_name: "local_transaction".to_string(),
            schema_version: 1,
            transaction_id,
            kind: kind.to_string(),
            created_rfc3339: now_rfc3339(),
            paths: paths.into_iter().collect(),
        };
        write_json_pretty(&path, &transaction)?;
        Ok(LocalTransaction {
            path,
            finished: false,
        })
    }

    pub fn ensure_no_incomplete_transactions(&self) -> Result<()> {
        let transactions = incomplete_transaction_paths(&self.root)?;
        if transactions.is_empty() {
            return Ok(());
        }
        Err(AppError::new(
            "transaction_incomplete",
            format!(
                "incomplete afmail transaction(s) detected: {}; run `afmail doctor` for details",
                transactions.join(", ")
            ),
        )
        .with_hint("Run `afmail doctor` to inspect incomplete transactions; use `afmail doctor repair --confirm` only after reviewing the issue.")
        .with_details(json!({
            "transaction_paths": transactions,
            "suggested_commands": [
                "afmail doctor",
                "afmail doctor repair --confirm"
            ]
        })))
    }

    pub(super) fn incomplete_transactions(&self) -> Result<Vec<TransactionFile>> {
        let dir = self.root.join(".afmail/transactions");
        if !dir.exists() {
            return Ok(Vec::new());
        }
        let mut out = Vec::new();
        for entry in read_dir(&dir, "read transactions")? {
            let path = entry.path();
            if path.extension().and_then(|s| s.to_str()) != Some("json") {
                continue;
            }
            let data = read_to_string(&path, "read transaction")?;
            let transaction: TransactionFile =
                serde_json::from_str(&data).map_err(|e| AppError::json("parse transaction", &e))?;
            if transaction.schema_name != "local_transaction" || transaction.schema_version != 1 {
                return Err(AppError::new(
                    "transaction_invalid",
                    format!(
                        "invalid local transaction schema: {}",
                        rel_path(&self.root, &path)
                    ),
                ));
            }
            out.push(transaction);
        }
        out.sort_by(|a, b| a.transaction_id.cmp(&b.transaction_id));
        Ok(out)
    }
}

impl LocalTransaction {
    pub(crate) fn commit(mut self) -> Result<()> {
        if self.path.exists() {
            remove_file(&self.path)?;
        }
        self.finished = true;
        Ok(())
    }
}

impl Drop for LocalTransaction {
    fn drop(&mut self) {
        if !self.finished && !std::thread::panicking() {
            let _ = fs::remove_file(&self.path);
        }
    }
}

fn incomplete_transaction_paths(root: &Path) -> Result<Vec<String>> {
    let dir = root.join(".afmail/transactions");
    if !dir.exists() {
        return Ok(Vec::new());
    }
    let mut out = Vec::new();
    for entry in read_dir(&dir, "read transactions")? {
        let path = entry.path();
        if path.extension().and_then(|s| s.to_str()) == Some("json") {
            out.push(rel_path(root, &path));
        }
    }
    out.sort();
    Ok(out)
}

fn unique_transaction_id(kind: &str) -> String {
    let safe_kind = kind.replace('.', "_");
    format!(
        "transaction_{}_{}",
        now_rfc3339().replace([':', '-'], ""),
        safe_kind
    )
}