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 serde_json::{json, Value};
use std::fmt::{Display, Formatter};

#[derive(Debug, Clone, PartialEq)]
pub struct AppError {
    pub error_code: &'static str,
    pub message: String,
    pub retryable: bool,
    pub hint: Option<String>,
    pub details: Option<Value>,
}

impl AppError {
    pub fn new(error_code: &'static str, message: impl Into<String>) -> Self {
        Self {
            error_code,
            message: message.into(),
            retryable: false,
            hint: None,
            details: None,
        }
    }

    pub fn retryable(error_code: &'static str, message: impl Into<String>) -> Self {
        Self {
            error_code,
            message: message.into(),
            retryable: true,
            hint: None,
            details: None,
        }
    }

    pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
        self.hint = Some(hint.into());
        self
    }

    pub fn with_details(mut self, details: Value) -> Self {
        self.details = Some(details);
        self
    }

    pub fn io(context: &str, err: &std::io::Error) -> Self {
        Self::new("store_error", format!("{context}: {err}"))
    }

    pub fn json(context: &str, err: &serde_json::Error) -> Self {
        Self::new("store_error", format!("{context}: {err}"))
    }

    pub fn to_value(&self) -> serde_json::Value {
        let mut value = json!({
            "code": "error",
            "error_code": self.error_code,
            "error": self.message,
            "retryable": self.retryable,
            "trace": {"duration_ms": 0}
        });
        if let Value::Object(map) = &mut value {
            if let Some(hint) = self
                .hint
                .as_deref()
                .or_else(|| default_hint(self.error_code))
            {
                map.insert("hint".to_string(), json!(hint));
            }
            if let Some(details) = &self.details {
                map.insert("details".to_string(), details.clone());
            }
        }
        value
    }
}

fn default_hint(error_code: &str) -> Option<&'static str> {
    match error_code {
        "invalid_request" => {
            Some("Run the nearest `afmail ... --help` command and retry with the documented action-first syntax.")
        }
        "reason_required" => Some("Repeat the command with `--reason TEXT`, or change `audit.reason_mode` if this workspace should not require reasons."),
        "confirm_required" => Some("Review the dry-run or status output, then rerun with `--confirm` when you want to apply changes."),
        "transaction_incomplete" => Some("Run `afmail doctor` to inspect incomplete transactions before making more workspace changes."),
        "config_missing" => Some("Run `afmail config show` and set the missing config key before retrying."),
        "config_invalid" => Some("Run `afmail config show`, fix `.afmail/config.json`, or update the key with `afmail config set`."),
        "unknown_mailbox_id" => Some("Run `afmail config show` to inspect configured mailbox ids, then retry with one of those ids."),
        "workspace_locked" => Some("Wait for the running afmail command to finish, then retry."),
        "case_not_found" => Some("Check the case ref and whether it is active or archived with `afmail status` and `afmail archive list cases`."),
        "archive_not_found" | "archive_entry_not_found" => {
            Some("Check archive refs with `afmail archive list messages`, then retry with the correct ref and message id.")
        }
        "draft_not_found" => Some("List the case drafts directory or recreate the draft with `afmail case reply` / `afmail case draft new`."),
        "draft_invalid" => Some("Fix the draft markdown/frontmatter, then run `afmail case draft validate CASE_REF DRAFT_NAME`."),
        "draft_validation_required" | "draft_changed_since_validation"
        | "draft_changed_since_compose" => {
            Some("Run `afmail case draft validate CASE_REF DRAFT_NAME`, then retry compose.")
        }
        "imap_connect_failed" | "imap_greeting_failed" | "imap_login_failed" | "imap_tls_failed"
        | "imap_select_failed" | "imap_fetch_failed" | "imap_search_failed" | "imap_list_failed"
        | "imap_capability_failed" | "imap_create_failed" | "imap_append_failed"
        | "imap_move_failed" | "imap_store_failed" | "imap_uid_missing" => {
            Some("Check IMAP config with `afmail config show`; use `afmail remote test` and `afmail remote folders` to diagnose.")
        }
        "smtp_connect_failed" | "smtp_send_failed" => {
            Some("Check SMTP config with `afmail config show`; preview queued mail with `afmail push drafts-send --dry-run` before retrying.")
        }
        _ => None,
    }
}

impl Display for AppError {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}: {}", self.error_code, self.message)
    }
}

impl std::error::Error for AppError {}

pub type Result<T> = std::result::Result<T, AppError>;