agent-first-mail 0.3.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::defaults::{
    default_case_group, default_contact_group, default_reason_mode, default_smtp_port,
    default_timezone_utc_offset, default_timezone_utc_offset_option, default_true,
};
use super::validation::validate_language_bcp47;
use crate::error::{AppError, Result};
use agent_first_data::normalize_utc_offset;
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CaseSection {
    #[serde(default = "default_case_group")]
    pub default_group: String,
}

impl Default for CaseSection {
    fn default() -> Self {
        Self {
            default_group: default_case_group(),
        }
    }
}

#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ContactSection {
    #[serde(default = "default_contact_group")]
    pub default_group: String,
}

impl Default for ContactSection {
    fn default() -> Self {
        Self {
            default_group: default_contact_group(),
        }
    }
}

#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct AuditSection {
    #[serde(default = "default_reason_mode")]
    pub reason_mode: ReasonMode,
}

impl Default for AuditSection {
    fn default() -> Self {
        Self {
            reason_mode: default_reason_mode(),
        }
    }
}

#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ReasonMode {
    Required,
    Optional,
}

impl ReasonMode {
    pub fn as_str(self) -> &'static str {
        match self {
            ReasonMode::Required => "required",
            ReasonMode::Optional => "optional",
        }
    }

    pub(super) fn parse(value: &str) -> Result<Self> {
        match value {
            "required" => Ok(Self::Required),
            "optional" => Ok(Self::Optional),
            _ => Err(AppError::new(
                "invalid_request",
                "audit.reason_mode expects required or optional",
            )),
        }
    }
}

#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct SmtpSection {
    pub host: Option<String>,
    #[serde(default = "default_smtp_port")]
    pub port: u16,
    #[serde(default = "default_true")]
    pub starttls: bool,
    #[serde(default)]
    pub tls_wrapper: bool,
    pub username: Option<String>,
    pub password_secret: Option<String>,
    pub password_secret_env: Option<String>,
}

impl Default for SmtpSection {
    fn default() -> Self {
        Self {
            host: None,
            port: default_smtp_port(),
            starttls: true,
            tls_wrapper: false,
            username: None,
            password_secret: None,
            password_secret_env: None,
        }
    }
}

#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceSection {
    #[serde(default)]
    pub language_bcp47: Option<String>,
    #[serde(default = "default_timezone_utc_offset_option")]
    pub timezone_utc_offset: Option<String>,
}

impl Default for WorkspaceSection {
    fn default() -> Self {
        Self {
            language_bcp47: None,
            timezone_utc_offset: Some(default_timezone_utc_offset()),
        }
    }
}

impl WorkspaceSection {
    pub(super) fn validate(&self) -> Result<()> {
        if let Some(language) = self.language_bcp47.as_deref() {
            validate_language_bcp47("workspace.language_bcp47", language, "config_invalid")?;
        }
        if let Some(offset) = self.timezone_utc_offset.as_deref() {
            let normalized = normalize_utc_offset(offset).ok_or_else(|| {
                AppError::new(
                    "config_invalid",
                    "workspace.timezone_utc_offset expects UTC or a fixed offset like +08:00",
                )
            })?;
            if normalized != offset {
                return Err(AppError::new(
                    "config_invalid",
                    "workspace.timezone_utc_offset must be canonical UTC or ±HH:MM",
                ));
            }
        }
        Ok(())
    }
}

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum TemplateLanguage {
    #[default]
    EnUs,
    ZhCn,
}

impl TemplateLanguage {
    pub const ALL: [Self; 2] = [Self::EnUs, Self::ZhCn];

    pub fn as_str(self) -> &'static str {
        match self {
            TemplateLanguage::EnUs => "en-US",
            TemplateLanguage::ZhCn => "zh-CN",
        }
    }

    pub fn from_bcp47(value: &str) -> Self {
        let lower = value.trim().to_ascii_lowercase();
        if lower == "zh" || lower.starts_with("zh-") {
            Self::ZhCn
        } else {
            Self::EnUs
        }
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SmtpConfig {
    pub host: String,
    pub port: u16,
    pub starttls: bool,
    pub tls_wrapper: bool,
    pub username: Option<String>,
    pub password_secret: Option<String>,
}