Skip to main content

agent_first_mail/config/
mod.rs

1mod access;
2mod actions;
3mod archive;
4mod defaults;
5mod identity;
6mod mailbox;
7mod validation;
8mod workspace;
9
10pub use actions::{
11    ActionRule, ActionStep, ActionStepOn, ActionsSection, MailDirection, MessageArchiveAction,
12    PullActionSection, PullImportAs, PullMailboxAction,
13};
14pub use archive::{
15    ArchiveMessageIndexField, ArchiveMessageIndexSection, ArchiveMessageIndexSort, ArchiveSection,
16};
17pub use identity::{IdentityConfig, MessageIdentityMatch, ResolvedIdentity};
18pub use mailbox::{
19    special_use_kinds, ImapConfig, ImapMailboxConfig, ImapSection, SpecialUseKind,
20    SpecialUseSource, SpecialUseTarget,
21};
22pub use workspace::{
23    AuditSection, CaseSection, ContactSection, ReasonMode, SmtpConfig, SmtpSection,
24    TemplateLanguage, WorkspaceSection,
25};
26
27use crate::error::{AppError, Result};
28use chrono::{FixedOffset, Offset};
29use defaults::*;
30use serde::{Deserialize, Serialize};
31use serde_json::{json, Value};
32use std::collections::BTreeMap;
33use std::fs;
34use std::path::Path;
35use validation::*;
36
37const DEFAULT_LANGUAGE_BCP47: &str = "en-US";
38
39#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
40#[serde(deny_unknown_fields)]
41pub struct MailConfig {
42    pub schema_name: String,
43    pub schema_version: u64,
44    pub workspace: WorkspaceSection,
45    pub imap: ImapSection,
46    pub mailboxes: BTreeMap<String, ImapMailboxConfig>,
47    pub actions: ActionsSection,
48    pub identities: Vec<IdentityConfig>,
49    #[serde(default)]
50    pub case: CaseSection,
51    #[serde(default)]
52    pub archive: ArchiveSection,
53    pub audit: AuditSection,
54    pub smtp: SmtpSection,
55    #[serde(default)]
56    pub contact: ContactSection,
57}
58
59impl Default for MailConfig {
60    fn default() -> Self {
61        Self {
62            schema_name: default_schema_name(),
63            schema_version: 1,
64            workspace: WorkspaceSection::default(),
65            imap: ImapSection::default(),
66            mailboxes: default_mailbox_configs(),
67            actions: ActionsSection::default(),
68            identities: default_identities(),
69            case: CaseSection::default(),
70            archive: ArchiveSection::default(),
71            audit: AuditSection::default(),
72            smtp: SmtpSection::default(),
73            contact: ContactSection::default(),
74        }
75    }
76}
77
78impl MailConfig {
79    pub fn load(workspace_root: &Path) -> Result<Self> {
80        let path = workspace_root.join(".afmail/config.json");
81        let data = fs::read_to_string(&path).map_err(|e| AppError::io("read config", &e))?;
82        let raw: Value =
83            serde_json::from_str(&data).map_err(|e| AppError::json("parse config", &e))?;
84        reject_legacy_config(&raw)?;
85        let config: Self = serde_json::from_value(raw)
86            .map_err(|e| AppError::new("config_invalid", format!("invalid config schema: {e}")))?;
87        config.validate()?;
88        config.validate_identity_files(workspace_root)?;
89        Ok(config)
90    }
91
92    pub fn write(&self, workspace_root: &Path) -> Result<()> {
93        self.validate()?;
94        let path = workspace_root.join(".afmail/config.json");
95        let data = serde_json::to_string_pretty(self)
96            .map_err(|e| AppError::json("serialize config", &e))?;
97        fs::write(path, data + "\n").map_err(|e| AppError::io("write config", &e))
98    }
99
100    pub fn validate(&self) -> Result<()> {
101        if self.schema_name != "config" || self.schema_version != 1 {
102            return Err(AppError::new(
103                "config_invalid",
104                format!(
105                    "unsupported config schema: {} v{}",
106                    self.schema_name, self.schema_version
107                ),
108            ));
109        }
110        for (id, mailbox) in &self.mailboxes {
111            validate_config_id("mailboxes id", id)?;
112            mailbox.validate(id)?;
113        }
114        self.workspace.validate()?;
115        self.actions.validate(self)?;
116        self.validate_identities()?;
117        self.archive.validate()?;
118        validate_password_secret_source(
119            "imap.password_secret",
120            self.imap.password_secret.as_deref(),
121            "imap.password_secret_env",
122            self.imap.password_secret_env.as_deref(),
123        )?;
124        validate_password_secret_source(
125            "smtp.password_secret",
126            self.smtp.password_secret.as_deref(),
127            "smtp.password_secret_env",
128            self.smtp.password_secret_env.as_deref(),
129        )?;
130        Ok(())
131    }
132
133    pub fn require_imap(&self) -> Result<ImapConfig> {
134        self.require_imap_with_mailboxes(Vec::new())
135    }
136
137    pub fn require_imap_with_mailboxes(&self, mailboxes: Vec<String>) -> Result<ImapConfig> {
138        Ok(ImapConfig {
139            host: self
140                .imap
141                .host
142                .clone()
143                .ok_or_else(|| AppError::new("config_missing", "imap.host is required"))?,
144            port: self.imap.port,
145            tls: self.imap.tls,
146            username: self
147                .imap
148                .username
149                .clone()
150                .ok_or_else(|| AppError::new("config_missing", "imap.username is required"))?,
151            password_secret: resolve_password_secret(
152                "imap.password_secret",
153                self.imap.password_secret.as_deref(),
154                "imap.password_secret_env",
155                self.imap.password_secret_env.as_deref(),
156            )?,
157            mailboxes,
158        })
159    }
160
161    pub fn require_smtp(&self) -> Result<SmtpConfig> {
162        Ok(SmtpConfig {
163            host: self
164                .smtp
165                .host
166                .clone()
167                .ok_or_else(|| AppError::new("config_missing", "smtp.host is required"))?,
168            port: self.smtp.port,
169            starttls: self.smtp.starttls,
170            tls_wrapper: self.smtp.tls_wrapper,
171            username: self.smtp.username.clone(),
172            password_secret: resolve_optional_password_secret(
173                "smtp.password_secret",
174                self.smtp.password_secret.as_deref(),
175                "smtp.password_secret_env",
176                self.smtp.password_secret_env.as_deref(),
177            )?,
178        })
179    }
180
181    pub fn mailbox_ids(&self) -> Vec<String> {
182        self.mailboxes.keys().cloned().collect()
183    }
184
185    pub fn default_pull_ids(&self) -> Vec<String> {
186        let mut out = Vec::new();
187        for id in &self.actions.pull.default_mailbox_ids {
188            if self.mailboxes.contains_key(id) && !out.iter().any(|existing| existing == id) {
189                out.push(id.clone());
190            }
191        }
192        out
193    }
194
195    pub fn selected_pull_ids(&self, ids: &[String]) -> Result<Vec<String>> {
196        let selected = if ids.is_empty() {
197            self.default_pull_ids()
198        } else {
199            let mut out = Vec::new();
200            for id in ids {
201                if !self.mailboxes.contains_key(id) {
202                    return Err(AppError::new(
203                        "unknown_mailbox_id",
204                        format!(
205                            "unknown IMAP mailbox id: {id}; available ids: {}",
206                            self.mailbox_ids().join(", ")
207                        ),
208                    ));
209                }
210                if !out.iter().any(|existing| existing == id) {
211                    out.push(id.clone());
212                }
213            }
214            out
215        };
216        if selected.is_empty() {
217            return Err(AppError::new(
218                "config_invalid",
219                "actions.pull.default_mailbox_ids is empty; pass configured ids explicitly",
220            ));
221        }
222        Ok(selected)
223    }
224
225    pub fn mailbox(&self, id: &str) -> Result<&ImapMailboxConfig> {
226        self.mailboxes.get(id).ok_or_else(|| {
227            AppError::new(
228                "unknown_mailbox_id",
229                format!(
230                    "unknown IMAP mailbox id: {id}; available ids: {}",
231                    self.mailbox_ids().join(", ")
232                ),
233            )
234        })
235    }
236
237    pub fn offline_mailbox_name(&self, id: &str) -> Result<String> {
238        let mailbox = self.mailbox(id)?;
239        mailbox.offline_mailbox_name().ok_or_else(|| {
240            AppError::new(
241                "config_invalid",
242                format!("mailboxes.{id} does not resolve to a mailbox name offline"),
243            )
244        })
245    }
246
247    pub fn pull_action(&self, id: &str) -> Result<&PullMailboxAction> {
248        self.actions.pull.by_mailbox_id.get(id).ok_or_else(|| {
249            AppError::new(
250                "config_invalid",
251                format!("actions.pull.by_mailbox_id.{id} is missing"),
252            )
253        })
254    }
255
256    pub fn mailbox_id_for_special_use(&self, kind: SpecialUseKind) -> Option<String> {
257        let attribute = kind.attribute();
258        self.mailboxes
259            .iter()
260            .find(|(id, mailbox)| {
261                id.as_str() == kind.as_str()
262                    || mailbox
263                        .special_use
264                        .as_deref()
265                        .is_some_and(|value| value.eq_ignore_ascii_case(attribute))
266            })
267            .map(|(id, _)| id.clone())
268    }
269
270    pub fn offline_mailbox_name_for_special_use(&self, kind: SpecialUseKind) -> Result<String> {
271        if let Some(id) = self.mailbox_id_for_special_use(kind) {
272            return self.offline_mailbox_name(&id);
273        }
274        Ok(kind.fallback_names()[0].to_string())
275    }
276
277    pub fn special_use_folder(&self, kind: SpecialUseKind) -> Option<String> {
278        self.offline_mailbox_name_for_special_use(kind).ok()
279    }
280
281    pub fn special_use_flag(&self, kind: SpecialUseKind) -> Option<String> {
282        match kind {
283            SpecialUseKind::Flagged => Some("\\Flagged".to_string()),
284            SpecialUseKind::Junk => Some("$Junk".to_string()),
285            _ => None,
286        }
287    }
288
289    pub fn matching_mailbox_ids_offline(&self, mailbox_name: &str) -> Vec<String> {
290        self.mailboxes
291            .iter()
292            .filter_map(|(id, mailbox)| {
293                mailbox
294                    .matches_mailbox_offline(mailbox_name)
295                    .then_some(id.clone())
296            })
297            .collect()
298    }
299
300    pub fn resolved_language_bcp47(&self) -> &str {
301        self.workspace
302            .language_bcp47
303            .as_deref()
304            .unwrap_or(DEFAULT_LANGUAGE_BCP47)
305    }
306
307    pub fn template_language(&self) -> TemplateLanguage {
308        TemplateLanguage::from_bcp47(self.resolved_language_bcp47())
309    }
310
311    pub fn resolved_timezone_utc_offset(&self) -> String {
312        self.workspace
313            .timezone_utc_offset
314            .clone()
315            .unwrap_or_else(default_timezone_utc_offset)
316    }
317
318    pub fn resolved_timezone_offset(&self) -> FixedOffset {
319        fixed_offset_from_utc_offset(&self.resolved_timezone_utc_offset())
320            .unwrap_or_else(|| chrono::Utc.fix())
321    }
322}
323
324#[cfg(test)]
325mod tests;