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;