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. `smtp_test_tool.toml` in the executable's directory.
4//!   3. `smtp_test_tool.toml` in the current working directory.
5//!   4. OS-standard config dir (e.g. `%APPDATA%/smtp-test-tool/smtp_test_tool.toml`).
6
7use crate::smtp::AuthMech;
8use crate::tls::Security;
9use anyhow::{Context, Result};
10use serde::{Deserialize, Serialize};
11use std::collections::BTreeMap;
12use std::path::{Path, PathBuf};
13use std::{env, fs};
14
15pub const DEFAULT_FILE_NAME: &str = "smtp_test_tool.toml";
16
17/// Full config = many named profiles.
18#[derive(Debug, Default, Serialize, Deserialize)]
19pub struct Config {
20    /// Profile selected when none is specified on the command line.
21    #[serde(default = "default_active")]
22    pub active: String,
23    /// All named profiles.  TOML representation: `[profiles.default]`, `[profiles.on-prem]` etc.
24    #[serde(default)]
25    pub profiles: BTreeMap<String, Profile>,
26}
27
28fn default_active() -> String {
29    "default".into()
30}
31
32/// All testable settings.  This is what gets serialised to TOML and what
33/// the GUI/CLI render and edit.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct Profile {
36    // ---- credentials ----
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub user: Option<String>,
39
40    /// Held in memory for the current session only.  **NEVER** persisted
41    /// to disk - the `#[serde(skip)]` attribute makes this structurally
42    /// impossible.  Passwords belong in the user's keyboard or an OS
43    /// keychain (planned), not in a config file.  This rule is documented
44    /// in AGENTS.md and enforced by `tests/config_roundtrip.rs`.
45    #[serde(skip)]
46    pub password: Option<String>,
47
48    /// Same rule as `password`: an OAuth bearer token grants full
49    /// mailbox access until it expires and is therefore a credential.
50    /// Session-only, never written.
51    #[serde(skip)]
52    pub oauth_token: Option<String>,
53
54    // ---- SMTP ----
55    #[serde(default = "yes")]
56    pub smtp_enabled: bool,
57    pub smtp_host: String,
58    pub smtp_port: u16,
59    pub smtp_security: Security,
60    #[serde(default)]
61    pub auth_mech: AuthMech,
62
63    // ---- IMAP ----
64    #[serde(default = "yes")]
65    pub imap_enabled: bool,
66    pub imap_host: String,
67    pub imap_port: u16,
68    pub imap_security: Security,
69    #[serde(default = "inbox")]
70    pub imap_folder: String,
71
72    // ---- POP3 ----
73    #[serde(default)]
74    pub pop_enabled: bool,
75    pub pop_host: String,
76    pub pop_port: u16,
77    pub pop_security: Security,
78
79    // ---- message (only when send_test) ----
80    // Defaults to TRUE so a fresh 'Run Test' click exercises the full
81    // end-to-end path including delivery / Send-As rights / spam
82    // filters, not just AUTH.  Users who want auth-only can untick the
83    // 'Actually send a test email' box on the Send Mail tab.  Existing
84    // v0.1.0 configs without a send_test entry get true on next load,
85    // matching what a fresh install would do.
86    #[serde(default = "yes")]
87    pub send_test: bool,
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub mail_from: Option<String>,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub from_addr: Option<String>,
92    #[serde(default, skip_serializing_if = "Vec::is_empty")]
93    pub to: Vec<String>,
94    #[serde(default, skip_serializing_if = "Vec::is_empty")]
95    pub cc: Vec<String>,
96    #[serde(default, skip_serializing_if = "Vec::is_empty")]
97    pub bcc: Vec<String>,
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub reply_to: Option<String>,
100    #[serde(default = "default_subject")]
101    pub subject: String,
102    #[serde(default = "default_body")]
103    pub body: String,
104
105    // ---- advanced ----
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub ehlo_name: Option<String>,
108    #[serde(default = "default_timeout")]
109    pub timeout_secs: u64,
110    #[serde(default)]
111    pub insecure_tls: bool,
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub ca_file: Option<PathBuf>,
114    #[serde(default = "default_log_level")]
115    pub log_level: String,
116    #[serde(default)]
117    pub wire_trace: bool,
118    #[serde(default = "default_theme")]
119    pub theme: String,
120
121    /// User-selected interface language as a BCP-47 short code
122    /// (`"en"`, `"nl"`, ...).  `None` means "follow the OS appearance
123    /// each launch".  An explicit value survives upgrades that broaden
124    /// or narrow the shipped-translation set; an unsupported code
125    /// silently falls back to the base locale via `i18n::set_locale`.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub locale: Option<String>,
128}
129
130fn yes() -> bool {
131    true
132}
133fn inbox() -> String {
134    "INBOX".into()
135}
136fn default_subject() -> String {
137    "Email server connectivity test".into()
138}
139fn default_body() -> String {
140    "This is a connectivity test sent by email-tester.\n".into()
141}
142fn default_timeout() -> u64 {
143    20
144}
145fn default_log_level() -> String {
146    "info".into()
147}
148fn default_theme() -> String {
149    "auto".into()
150}
151
152impl Default for Profile {
153    fn default() -> Self {
154        crate::outlook_defaults()
155    }
156}
157
158// ===========================================================================
159// File handling
160// ===========================================================================
161impl Config {
162    pub fn load(path: &Path) -> Result<Self> {
163        let text = fs::read_to_string(path)
164            .with_context(|| format!("reading config file {}", path.display()))?;
165        let cfg: Config =
166            toml::from_str(&text).with_context(|| format!("parsing TOML {}", path.display()))?;
167        Ok(cfg)
168    }
169
170    pub fn save(&self, path: &Path) -> Result<()> {
171        if let Some(parent) = path.parent() {
172            fs::create_dir_all(parent).ok();
173        }
174        let mut text = String::from(
175            "# smtp-test-tool configuration\n\
176             # Multiple [profiles.<name>] sections can coexist; pick one with --profile.\n\
177             # The file 'smtp_test_tool.toml' next to the executable is auto-loaded.\n\n",
178        );
179        text.push_str(&toml::to_string_pretty(self).context("serialising config to TOML")?);
180        fs::write(path, text).with_context(|| format!("writing config file {}", path.display()))?;
181        Ok(())
182    }
183
184    /// Replace (or insert) one profile and persist.
185    pub fn upsert_profile(&mut self, name: &str, p: Profile) {
186        self.profiles.insert(name.to_string(), p);
187    }
188
189    pub fn profile_names(&self) -> Vec<String> {
190        self.profiles.keys().cloned().collect()
191    }
192
193    pub fn profile(&self, name: &str) -> Option<&Profile> {
194        self.profiles.get(name)
195    }
196}
197
198/// Locate the most relevant config file on this machine.  Trace-level
199/// events are emitted for each candidate so users can pinpoint a
200/// search miss by running with `RUST_LOG=smtp_test_tool=trace`.
201pub fn discover_config_path() -> Option<PathBuf> {
202    if let Ok(exe) = env::current_exe() {
203        if let Some(dir) = exe.parent() {
204            let p = dir.join(DEFAULT_FILE_NAME);
205            tracing::trace!(
206                "config probe (next-to-exe): {} exists={}",
207                p.display(),
208                p.exists()
209            );
210            if p.exists() {
211                return Some(p);
212            }
213        }
214    }
215    if let Ok(cwd) = env::current_dir() {
216        let p = cwd.join(DEFAULT_FILE_NAME);
217        tracing::trace!("config probe (cwd): {} exists={}", p.display(), p.exists());
218        if p.exists() {
219            return Some(p);
220        }
221    }
222    if let Some(dir) = dirs::config_dir() {
223        let p = dir.join("smtp-test-tool").join(DEFAULT_FILE_NAME);
224        tracing::trace!(
225            "config probe (xdg/appdata): {} exists={}",
226            p.display(),
227            p.exists()
228        );
229        if p.exists() {
230            return Some(p);
231        }
232    }
233    None
234}
235
236/// Where to save a brand-new config when the user clicks 'Save'.
237pub fn default_save_path() -> PathBuf {
238    if let Ok(exe) = env::current_exe() {
239        if let Some(dir) = exe.parent() {
240            return dir.join(DEFAULT_FILE_NAME);
241        }
242    }
243    if let Some(dir) = dirs::config_dir() {
244        return dir.join("smtp-test-tool").join(DEFAULT_FILE_NAME);
245    }
246    PathBuf::from(DEFAULT_FILE_NAME)
247}