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::*;

#[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
    )
}