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::*;
use agent_first_data::normalize_utc_offset;

pub(super) fn reject_legacy_config(raw: &Value) -> Result<()> {
    let Some(obj) = raw.as_object() else {
        return Err(AppError::new(
            "config_invalid",
            "config must be a JSON object",
        ));
    };
    for legacy_key in [
        "imap_host",
        "imap_port",
        "imap_tls",
        "imap_username",
        "imap_password_secret",
        "smtp_host",
        "smtp_port",
        "smtp_starttls",
        "smtp_tls_wrapper",
        "smtp_username",
        "smtp_password_secret",
        "from",
        "send",
        "folders",
        "special_use",
        "notes",
        "imap_mailboxes",
        "pull",
        "push",
        "ui",
        "timezone",
    ] {
        if obj.contains_key(legacy_key) {
            return Err(AppError::new(
                "config_invalid",
                format!("unsupported legacy config key: {legacy_key}; use mailboxes/actions"),
            ));
        }
    }
    Ok(())
}

pub(super) fn resolve_optional_password_secret(
    secret_label: &str,
    secret: Option<&str>,
    env_label: &str,
    env_name: Option<&str>,
) -> Result<Option<String>> {
    validate_password_secret_source(secret_label, secret, env_label, env_name)?;
    if let Some(secret) = secret {
        return Ok(Some(secret.to_string()));
    }
    env_name
        .map(|name| resolve_secret_env(env_label, name))
        .transpose()
}

pub(super) fn resolve_password_secret(
    secret_label: &str,
    secret: Option<&str>,
    env_label: &str,
    env_name: Option<&str>,
) -> Result<String> {
    resolve_optional_password_secret(secret_label, secret, env_label, env_name)?.ok_or_else(|| {
        AppError::new(
            "config_missing",
            format!("{secret_label} or {env_label} is required"),
        )
    })
}

pub(super) fn resolve_secret_env(label: &str, name: &str) -> Result<String> {
    std::env::var(name).map_err(|_| {
        AppError::new(
            "config_missing",
            format!("{label} references unset environment variable: {name}"),
        )
    })
}

pub(super) fn validate_password_secret_source(
    secret_label: &str,
    secret: Option<&str>,
    env_label: &str,
    env_name: Option<&str>,
) -> Result<()> {
    if secret.is_some() && env_name.is_some() {
        return Err(AppError::new(
            "config_invalid",
            format!("{secret_label} and {env_label} cannot both be set"),
        ));
    }
    if let Some(secret) = secret {
        validate_inline_secret(secret_label, secret, env_label)?;
    }
    if let Some(name) = env_name {
        validate_secret_env_name(env_label, name)?;
    }
    Ok(())
}

pub(super) fn validate_inline_secret(label: &str, value: &str, env_label: &str) -> Result<()> {
    if value.starts_with("env:") || value.starts_with("literal:") {
        Err(AppError::new(
            "config_invalid",
            format!("{label} stores the literal secret; remove env:/literal: prefixes or use {env_label}"),
        ))
    } else {
        Ok(())
    }
}

pub(super) fn validate_secret_env_name(label: &str, name: &str) -> Result<()> {
    let valid = !name.is_empty()
        && name.ends_with("_SECRET")
        && name
            .bytes()
            .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_')
        && !name.starts_with('_')
        && !name.contains("__");
    if valid {
        Ok(())
    } else {
        Err(AppError::new(
            "config_invalid",
            format!("{label} env var must be UPPER_SNAKE_CASE and end with _SECRET"),
        ))
    }
}

pub(super) fn validate_steps(config: &MailConfig, label: &str, steps: &[ActionStep]) -> Result<()> {
    for (index, step) in steps.iter().enumerate() {
        let mut op_count = 0;
        if !step.add_flags.is_empty() {
            op_count += 1;
        }
        if step.move_to_mailbox_id.is_some() {
            op_count += 1;
        }
        if step.append_to_mailbox_id.is_some() {
            op_count += 1;
        }
        if step.smtp_send.is_some() {
            op_count += 1;
        }
        if op_count != 1 {
            return Err(AppError::new(
                "config_invalid",
                format!("{label}[{index}] must define exactly one operation"),
            ));
        }
        if let Some(params) = &step.smtp_send {
            if !params.is_empty() {
                return Err(AppError::new(
                    "config_invalid",
                    format!("{label}[{index}].smtp_send must be an empty object"),
                ));
            }
        }
        for flag in &step.add_flags {
            if flag.trim().is_empty() {
                return Err(AppError::new(
                    "config_invalid",
                    format!("{label}[{index}].add_flags contains an empty flag"),
                ));
            }
        }
        for target in step
            .move_to_mailbox_id
            .iter()
            .chain(step.append_to_mailbox_id.iter())
        {
            validate_config_id("action step mailbox id", target)?;
            let mailbox = config.mailboxes.get(target).ok_or_else(|| {
                AppError::new(
                    "config_invalid",
                    format!("{label}[{index}] references unknown mailbox id: {target}"),
                )
            })?;
            if let Some(kind) = mailbox
                .special_use
                .as_deref()
                .and_then(SpecialUseKind::from_attribute)
            {
                if step.move_to_mailbox_id.is_some() && !kind.can_move_to() {
                    return Err(AppError::new(
                        "config_invalid",
                        format!("{label}[{index}].move_to_mailbox_id cannot target {target}"),
                    ));
                }
            }
        }
    }
    Ok(())
}

pub(super) fn first_move_to_mailbox_id(steps: &[ActionStep]) -> Option<&str> {
    steps
        .iter()
        .find_map(|step| step.move_to_mailbox_id.as_deref())
}

pub(super) fn single(values: &[String], key: &str) -> Result<String> {
    if values.len() != 1 {
        return Err(AppError::new(
            "invalid_request",
            format!("config key {key} expects exactly one value"),
        ));
    }
    Ok(values[0].clone())
}

pub(super) fn parse_bool(value: &str, key: &str) -> Result<bool> {
    match value {
        "true" => Ok(true),
        "false" => Ok(false),
        _ => Err(AppError::new(
            "invalid_request",
            format!("config key {key} expects true or false"),
        )),
    }
}

pub(super) fn parse_u16(value: &str, key: &str) -> Result<u16> {
    value.parse::<u16>().map_err(|_| {
        AppError::new(
            "invalid_request",
            format!("config key {key} expects an integer port"),
        )
    })
}

pub(super) fn validate_config_id(label: &str, value: &str) -> Result<()> {
    let valid = !value.is_empty()
        && value
            .bytes()
            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-');
    if valid {
        Ok(())
    } else {
        Err(AppError::new(
            "invalid_request",
            format!("invalid {label}: {value}"),
        ))
    }
}

pub(super) fn fixed_offset_from_utc_offset(value: &str) -> Option<FixedOffset> {
    let normalized = normalize_utc_offset(value)?;
    if normalized == "UTC" {
        return Some(chrono::Utc.fix());
    }
    let (sign, rest) = match normalized.as_bytes().first()? {
        b'+' => (1, &normalized[1..]),
        b'-' => (-1, &normalized[1..]),
        _ => return None,
    };
    let (hours, minutes) = rest.split_once(':')?;
    let hours = hours.parse::<i32>().ok()?;
    let minutes = minutes.parse::<i32>().ok()?;
    FixedOffset::east_opt(sign * (hours * 3600 + minutes * 60))
}

pub(super) fn validate_language_bcp47(
    label: &str,
    value: &str,
    error_code: &'static str,
) -> Result<()> {
    if is_bcp47_like(value) {
        Ok(())
    } else {
        Err(AppError::new(
            error_code,
            format!("{label} expects a BCP-47-like language tag"),
        ))
    }
}

pub(super) fn is_bcp47_like(value: &str) -> bool {
    if value.trim() != value || value.is_empty() || value.len() > 64 {
        return false;
    }
    let mut parts = value.split('-');
    let Some(primary) = parts.next() else {
        return false;
    };
    if !(2..=8).contains(&primary.len()) || !primary.bytes().all(|b| b.is_ascii_alphabetic()) {
        return false;
    }
    parts.all(|part| {
        (1..=8).contains(&part.len()) && part.bytes().all(|b| b.is_ascii_alphanumeric())
    })
}