agent-first-mail 0.2.1

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 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 draft reply` / `afmail case draft new`."),
        "draft_invalid" => Some("Fix the draft markdown/frontmatter, then run `afmail case draft validate CASE_REF DRAFT_NAME`."),
        "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 --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>;