Skip to main content

smtp_test_tool/
config.rs

1//! TOML config file with named profiles.  Auto-load order:
2//!   1. `--config <FILE>` if explicit.
3//!   2. `email_tester.toml` in the executable's directory.
4//!   3. `email_tester.toml` in the current working directory.
5//!   4. OS-standard config dir (e.g. `%APPDATA%/email-tester/email_tester.toml`).
6
7use crate::smtp::AuthMech;
8use crate::tls::Security;
9use anyhow::{Context, Result};
10use base64::{engine::general_purpose::STANDARD as B64, Engine};
11use serde::{Deserialize, Serialize};
12use std::collections::BTreeMap;
13use std::path::{Path, PathBuf};
14use std::{env, fs};
15
16pub const DEFAULT_FILE_NAME: &str = "email_tester.toml";
17
18/// Full config = many named profiles.
19#[derive(Debug, Default, Serialize, Deserialize)]
20pub struct Config {
21    /// Profile selected when none is specified on the command line.
22    #[serde(default = "default_active")]
23    pub active: String,
24    /// All named profiles.  TOML representation: `[profiles.default]`, `[profiles.on-prem]` etc.
25    #[serde(default)]
26    pub profiles: BTreeMap<String, Profile>,
27}
28
29fn default_active() -> String {
30    "default".into()
31}
32
33/// All testable settings.  This is what gets serialised to TOML and what
34/// the GUI/CLI render and edit.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct Profile {
37    // ---- credentials ----
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub user: Option<String>,
40    /// Stored base64 (NOT encryption!) - users opt in via --save-password.
41    #[serde(
42        default,
43        rename = "password_b64",
44        skip_serializing_if = "Option::is_none",
45        serialize_with = "ser_b64",
46        deserialize_with = "de_b64"
47    )]
48    pub password: Option<String>,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub oauth_token: Option<String>,
51
52    // ---- SMTP ----
53    #[serde(default = "yes")]
54    pub smtp_enabled: bool,
55    pub smtp_host: String,
56    pub smtp_port: u16,
57    pub smtp_security: Security,
58    #[serde(default)]
59    pub auth_mech: AuthMech,
60
61    // ---- IMAP ----
62    #[serde(default = "yes")]
63    pub imap_enabled: bool,
64    pub imap_host: String,
65    pub imap_port: u16,
66    pub imap_security: Security,
67    #[serde(default = "inbox")]
68    pub imap_folder: String,
69
70    // ---- POP3 ----
71    #[serde(default)]
72    pub pop_enabled: bool,
73    pub pop_host: String,
74    pub pop_port: u16,
75    pub pop_security: Security,
76
77    // ---- message (only when send_test) ----
78    #[serde(default)]
79    pub send_test: bool,
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub mail_from: Option<String>,
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub from_addr: Option<String>,
84    #[serde(default, skip_serializing_if = "Vec::is_empty")]
85    pub to: Vec<String>,
86    #[serde(default, skip_serializing_if = "Vec::is_empty")]
87    pub cc: Vec<String>,
88    #[serde(default, skip_serializing_if = "Vec::is_empty")]
89    pub bcc: Vec<String>,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub reply_to: Option<String>,
92    #[serde(default = "default_subject")]
93    pub subject: String,
94    #[serde(default = "default_body")]
95    pub body: String,
96
97    // ---- advanced ----
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub ehlo_name: Option<String>,
100    #[serde(default = "default_timeout")]
101    pub timeout_secs: u64,
102    #[serde(default)]
103    pub insecure_tls: bool,
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub ca_file: Option<PathBuf>,
106    #[serde(default = "default_log_level")]
107    pub log_level: String,
108    #[serde(default)]
109    pub wire_trace: bool,
110    #[serde(default = "default_theme")]
111    pub theme: String,
112}
113
114fn yes() -> bool {
115    true
116}
117fn inbox() -> String {
118    "INBOX".into()
119}
120fn default_subject() -> String {
121    "Email server connectivity test".into()
122}
123fn default_body() -> String {
124    "This is a connectivity test sent by email-tester.\n".into()
125}
126fn default_timeout() -> u64 {
127    20
128}
129fn default_log_level() -> String {
130    "info".into()
131}
132fn default_theme() -> String {
133    "auto".into()
134}
135
136impl Default for Profile {
137    fn default() -> Self {
138        crate::outlook_defaults()
139    }
140}
141
142// ---- base64 (de)serialiser for the optional password -----------------
143fn ser_b64<S: serde::Serializer>(v: &Option<String>, s: S) -> Result<S::Ok, S::Error> {
144    match v {
145        Some(p) => s.serialize_str(&B64.encode(p.as_bytes())),
146        None => s.serialize_none(),
147    }
148}
149fn de_b64<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
150    let opt = Option::<String>::deserialize(d)?;
151    match opt {
152        None => Ok(None),
153        Some(s) => {
154            let bytes = B64.decode(s.as_bytes()).map_err(serde::de::Error::custom)?;
155            Ok(Some(
156                String::from_utf8(bytes).map_err(serde::de::Error::custom)?,
157            ))
158        }
159    }
160}
161
162// ===========================================================================
163// File handling
164// ===========================================================================
165impl Config {
166    pub fn load(path: &Path) -> Result<Self> {
167        let text = fs::read_to_string(path)
168            .with_context(|| format!("reading config file {}", path.display()))?;
169        let cfg: Config =
170            toml::from_str(&text).with_context(|| format!("parsing TOML {}", path.display()))?;
171        Ok(cfg)
172    }
173
174    pub fn save(&self, path: &Path) -> Result<()> {
175        if let Some(parent) = path.parent() {
176            fs::create_dir_all(parent).ok();
177        }
178        let mut text = String::from(
179            "# email-tester configuration\n\
180             # Multiple [profiles.<name>] sections can coexist; pick one with --profile.\n\
181             # The file 'email_tester.toml' next to the executable is auto-loaded.\n\n",
182        );
183        text.push_str(&toml::to_string_pretty(self).context("serialising config to TOML")?);
184        fs::write(path, text).with_context(|| format!("writing config file {}", path.display()))?;
185        Ok(())
186    }
187
188    /// Replace (or insert) one profile and persist.
189    pub fn upsert_profile(&mut self, name: &str, p: Profile) {
190        self.profiles.insert(name.to_string(), p);
191    }
192
193    pub fn profile_names(&self) -> Vec<String> {
194        self.profiles.keys().cloned().collect()
195    }
196
197    pub fn profile(&self, name: &str) -> Option<&Profile> {
198        self.profiles.get(name)
199    }
200}
201
202/// Locate the most relevant config file on this machine.
203pub fn discover_config_path() -> Option<PathBuf> {
204    if let Ok(exe) = env::current_exe() {
205        if let Some(dir) = exe.parent() {
206            let p = dir.join(DEFAULT_FILE_NAME);
207            if p.exists() {
208                return Some(p);
209            }
210        }
211    }
212    if let Ok(cwd) = env::current_dir() {
213        let p = cwd.join(DEFAULT_FILE_NAME);
214        if p.exists() {
215            return Some(p);
216        }
217    }
218    if let Some(dir) = dirs::config_dir() {
219        let p = dir.join("email-tester").join(DEFAULT_FILE_NAME);
220        if p.exists() {
221            return Some(p);
222        }
223    }
224    None
225}
226
227/// Where to save a brand-new config when the user clicks 'Save'.
228pub fn default_save_path() -> PathBuf {
229    if let Ok(exe) = env::current_exe() {
230        if let Some(dir) = exe.parent() {
231            return dir.join(DEFAULT_FILE_NAME);
232        }
233    }
234    if let Some(dir) = dirs::config_dir() {
235        return dir.join("email-tester").join(DEFAULT_FILE_NAME);
236    }
237    PathBuf::from(DEFAULT_FILE_NAME)
238}