1use 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#[derive(Debug, Default, Serialize, Deserialize)]
19pub struct Config {
20 #[serde(default = "default_active")]
22 pub active: String,
23 #[serde(default)]
25 pub profiles: BTreeMap<String, Profile>,
26}
27
28fn default_active() -> String {
29 "default".into()
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct Profile {
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub user: Option<String>,
39
40 #[serde(skip)]
46 pub password: Option<String>,
47
48 #[serde(skip)]
52 pub oauth_token: Option<String>,
53
54 #[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 #[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 #[serde(default)]
74 pub pop_enabled: bool,
75 pub pop_host: String,
76 pub pop_port: u16,
77 pub pop_security: Security,
78
79 #[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 #[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 #[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
158impl 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 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
198pub 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
236pub 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}