1use 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#[derive(Debug, Default, Serialize, Deserialize)]
20pub struct Config {
21 #[serde(default = "default_active")]
23 pub active: String,
24 #[serde(default)]
26 pub profiles: BTreeMap<String, Profile>,
27}
28
29fn default_active() -> String {
30 "default".into()
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct Profile {
37 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub user: Option<String>,
40 #[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 #[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 #[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 #[serde(default)]
72 pub pop_enabled: bool,
73 pub pop_host: String,
74 pub pop_port: u16,
75 pub pop_security: Security,
76
77 #[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 #[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
142fn 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
162impl 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 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
202pub 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
227pub 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}