Skip to main content

agent_first_mail/config/
mod.rs

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