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

#[test]
fn config_defaults_pull_and_tls() {
    let cfg = MailConfig::default();
    assert_eq!(
        cfg.default_pull_ids(),
        vec!["inbox", "sent", "archive", "junk", "trash"]
    );
    assert!(cfg.imap.tls);
    assert!(cfg.smtp.starttls);
    assert_eq!(cfg.case.default_group, "open");
    assert_eq!(cfg.audit.reason_mode, ReasonMode::Required);
    assert_eq!(cfg.workspace.language_bcp47, None);
    assert_eq!(cfg.template_language(), TemplateLanguage::EnUs);
    assert!(cfg.workspace.timezone_utc_offset.is_some());
    assert_eq!(
        cfg.actions
            .message_archive
            .by_source_mailbox_id
            .get("inbox")
            .map(|m| first_move_to_mailbox_id(&m.steps)),
        Some(Some("archive"))
    );
    assert_eq!(
        cfg.pull_action("sent").ok().map(|a| a.direction),
        Some(MailDirection::Outbound)
    );
    assert_eq!(
        cfg.pull_action("sent").ok().map(|a| a.import_as),
        Some(PullImportAs::Triage)
    );
    assert_eq!(
        cfg.pull_action("junk").ok().map(|a| a.import_as),
        Some(PullImportAs::Spam)
    );
}

#[test]
fn default_config_serializes_explicit_mailboxes_and_actions() {
    let cfg = MailConfig::default();
    let value = serde_json::to_value(&cfg).unwrap_or(Value::Null);
    assert_eq!(value["schema_name"], json!("config"));
    assert_eq!(value["schema_version"], json!(1));
    assert!(value.get("code").is_none());
    assert_eq!(
        value["actions"]["pull"]["by_mailbox_id"]["sent"],
        json!({
            "import_as": "triage",
            "direction": "outbound"
        })
    );
    assert_eq!(
        value["actions"]["draft.save"]["steps"],
        json!([{"append_to_mailbox_id": "drafts"}])
    );
    assert_eq!(
        value["actions"]["pull"]["default_mailbox_ids"],
        json!(["inbox", "sent", "archive", "junk", "trash"])
    );
    assert_eq!(value["mailboxes"]["sent"]["mailbox_name"], Value::Null);
    assert_eq!(value["mailboxes"]["sent"]["special_use"], json!("\\Sent"));
    assert!(value["mailboxes"].get("all").is_none());
    assert!(value["mailboxes"].get("flagged").is_none());
    assert!(value.get("imap_mailboxes").is_none());
    assert!(value.get("pull").is_none());
    assert!(value.get("push").is_none());
    assert!(value.get("ui").is_none());
    assert!(value.get("timezone").is_none());
    assert_eq!(value["workspace"]["language_bcp47"], Value::Null);
    assert!(value["workspace"].get("timezone_utc_offset").is_some());
}

#[test]
fn config_rejects_legacy_schema() {
    let raw = serde_json::json!({"schema_name":"config","schema_version":1,"imap_host":"imap.example.com"});
    let err = reject_legacy_config(&raw);
    assert!(err.is_err());
    assert_eq!(err.err().map(|e| e.error_code), Some("config_invalid"));
    let raw =
        serde_json::json!({"schema_name":"config","schema_version":1,"pull":{"folders":["INBOX"]}});
    let err = reject_legacy_config(&raw);
    assert!(err.is_err());
    assert_eq!(err.err().map(|e| e.error_code), Some("config_invalid"));
    let raw = serde_json::json!({"schema_name":"config","schema_version":1,"special_use":{}});
    let err = reject_legacy_config(&raw);
    assert!(err.is_err());
    assert_eq!(err.err().map(|e| e.error_code), Some("config_invalid"));
    let raw = serde_json::json!({"schema_name":"config","schema_version":1,"imap_mailboxes":{}});
    let err = reject_legacy_config(&raw);
    assert!(err.is_err());
    assert_eq!(err.err().map(|e| e.error_code), Some("config_invalid"));
    let raw = serde_json::json!({"schema_name":"config","schema_version":1,"push":{}});
    let err = reject_legacy_config(&raw);
    assert!(err.is_err());
    assert_eq!(err.err().map(|e| e.error_code), Some("config_invalid"));
    let raw =
        serde_json::json!({"schema_name":"config","schema_version":1,"ui":{"language":"en-US"}});
    let err = reject_legacy_config(&raw);
    assert!(err.is_err());
    assert_eq!(err.err().map(|e| e.error_code), Some("config_invalid"));
    let raw = serde_json::json!({"schema_name":"config","schema_version":1,"timezone":"UTC"});
    let err = reject_legacy_config(&raw);
    assert!(err.is_err());
    assert_eq!(err.err().map(|e| e.error_code), Some("config_invalid"));
}

#[test]
fn config_set_and_get_key() {
    let mut cfg = MailConfig::default();
    assert!(cfg.set_key("imap.tls", &["false".to_string()]).is_ok());
    assert_eq!(cfg.get_key("imap.tls"), Ok(json!(false)));
    assert!(cfg
        .set_key("mailboxes.extra.mailbox_name", &["Extra".to_string()])
        .is_ok());
    assert!(cfg
        .set_key(
            "actions.pull.by_mailbox_id.extra.direction",
            &["outbound".to_string()]
        )
        .is_ok());
    assert_eq!(
        cfg.get_key("actions.pull.by_mailbox_id.extra.direction"),
        Ok(json!("outbound"))
    );
    assert!(cfg
        .set_key(
            "actions.pull.by_mailbox_id.junk.import_as",
            &["spam".to_string()]
        )
        .is_ok());
    assert_eq!(
        cfg.get_key("actions.pull.by_mailbox_id.junk.import_as"),
        Ok(json!("spam"))
    );
    assert!(cfg
        .set_key(
            "actions.message.archive.by_source_mailbox_id.inbox.move_to_mailbox_id",
            &["archive".to_string()]
        )
        .is_ok());
    assert_eq!(
        cfg.get_key("actions.message.archive.by_source_mailbox_id.inbox.move_to_mailbox_id"),
        Ok(json!("archive"))
    );
    assert!(cfg
        .set_key(
            "archive.message_index.item_fields",
            &["summary".to_string(), "link".to_string()]
        )
        .is_ok());
    assert_eq!(
        cfg.get_key("archive.message_index.item_fields"),
        Ok(json!(["summary", "link"]))
    );
    assert!(cfg
        .set_key("audit.reason_mode", &["optional".to_string()])
        .is_ok());
    assert_eq!(cfg.get_key("audit.reason_mode"), Ok(json!("optional")));
    assert!(cfg
        .set_key("workspace.language_bcp47", &["zh-CN".to_string()])
        .is_ok());
    assert_eq!(cfg.get_key("workspace.language_bcp47"), Ok(json!("zh-CN")));
    assert!(cfg
        .set_key("workspace.language_bcp47", &["fr-FR".to_string()])
        .is_ok());
    assert_eq!(cfg.template_language(), TemplateLanguage::EnUs);
    assert!(cfg
        .set_key("workspace.language_bcp47", &["not a tag".to_string()])
        .is_err());
    assert!(cfg
        .set_key("workspace.language_bcp47", &["null".to_string()])
        .is_ok());
    assert_eq!(cfg.get_key("workspace.language_bcp47"), Ok(Value::Null));
    assert!(cfg
        .set_key("imap.password_secret", &["secret".to_string()])
        .is_ok());
    assert_eq!(cfg.get_key("imap.password_secret"), Ok(json!("secret")));
    assert!(cfg
        .set_key(
            "imap.password_secret_env",
            &["AFMAIL_IMAP_PASSWORD_SECRET".to_string()]
        )
        .is_ok());
    assert_eq!(
        cfg.get_key("imap.password_secret_env"),
        Ok(json!("AFMAIL_IMAP_PASSWORD_SECRET"))
    );
    assert_eq!(cfg.get_key("imap.password_secret"), Ok(Value::Null));
}

#[test]
#[allow(clippy::field_reassign_with_default)]
fn workspace_timezone_parses_sets_and_resolves() {
    let mut cfg = MailConfig::default();
    cfg.workspace.timezone_utc_offset = Some("+08:00".to_string());
    assert_eq!(cfg.resolved_timezone_offset().local_minus_utc(), 8 * 3600);
    cfg.workspace.timezone_utc_offset = Some("-05:30".to_string());
    assert_eq!(
        cfg.resolved_timezone_offset().local_minus_utc(),
        -(5 * 3600 + 30 * 60)
    );
    cfg.workspace.timezone_utc_offset = Some("UTC".to_string());
    assert_eq!(cfg.resolved_timezone_offset().local_minus_utc(), 0);
    assert!(cfg
        .set_key("workspace.timezone_utc_offset", &["+0530".to_string()])
        .is_ok());
    assert_eq!(
        cfg.get_key("workspace.timezone_utc_offset"),
        Ok(json!("+05:30"))
    );
    assert!(cfg
        .set_key("workspace.timezone_utc_offset", &["+00:00".to_string()])
        .is_ok());
    assert_eq!(
        cfg.get_key("workspace.timezone_utc_offset"),
        Ok(json!("UTC"))
    );
    assert!(cfg
        .set_key("workspace.timezone_utc_offset", &["null".to_string()])
        .is_ok());
    assert_eq!(
        cfg.get_key("workspace.timezone_utc_offset"),
        Ok(Value::Null)
    );
    assert!(cfg
        .set_key(
            "workspace.timezone_utc_offset",
            &["Asia/Shanghai".to_string()]
        )
        .is_err());
    cfg.workspace.timezone_utc_offset = Some("+0530".to_string());
    assert!(cfg.validate().is_err());
}

#[test]
fn invalid_mailbox_config_fails() {
    let mut cfg = MailConfig::default();
    assert!(cfg
        .set_key(
            "actions.pull.by_mailbox_id.junk.import_as",
            &["bogus".to_string()]
        )
        .is_err());
    assert!(cfg
        .set_key(
            "actions.pull.by_mailbox_id.sent.direction",
            &["sideways".to_string()]
        )
        .is_err());
    if let Some(entry) = cfg
        .actions
        .message_archive
        .by_source_mailbox_id
        .get_mut("inbox")
    {
        entry.steps = vec![ActionStep::move_to_mailbox_id("missing")];
    }
    let err = cfg.validate();
    assert!(err.is_err());
    assert_eq!(err.err().map(|e| e.error_code), Some("config_invalid"));
}

#[test]
fn empty_mailboxes_cannot_default_pull() {
    let mut cfg = MailConfig::default();
    cfg.imap.host = Some("imap.example.com".to_string());
    cfg.imap.username = Some("me@example.com".to_string());
    cfg.imap.password_secret = Some("secret".to_string());
    cfg.mailboxes.clear();
    assert!(cfg.require_imap().is_ok());
    assert!(cfg.selected_pull_ids(&[]).is_err());
}

#[test]
fn password_secret_sources_are_explicit_by_field() {
    assert!(validate_password_secret_source(
        "imap.password_secret",
        Some("secret"),
        "imap.password_secret_env",
        None
    )
    .is_ok());
    assert!(validate_password_secret_source(
        "imap.password_secret",
        None,
        "imap.password_secret_env",
        Some("AFMAIL_IMAP_PASSWORD_SECRET")
    )
    .is_ok());
    assert!(validate_password_secret_source(
        "imap.password_secret",
        Some("secret"),
        "imap.password_secret_env",
        Some("AFMAIL_IMAP_PASSWORD_SECRET")
    )
    .is_err());
    assert!(validate_password_secret_source(
        "imap.password_secret",
        Some("literal:secret"),
        "imap.password_secret_env",
        None
    )
    .is_err());
    assert!(validate_password_secret_source(
        "imap.password_secret",
        None,
        "imap.password_secret_env",
        Some("IMAP_PASSWORD")
    )
    .is_err());
}