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;