1use std::{collections::HashMap, fs, path::Path};
9
10use serde::Deserialize;
11
12#[derive(Debug, Deserialize)]
14pub struct Config {
15 pub smtp: SmtpConfig,
16}
17
18#[derive(Debug, Deserialize)]
20pub struct SmtpConfig {
21 #[serde(default = "default_host")]
22 pub host: String,
23
24 #[serde(default = "default_hostname")]
25 pub hostname: String,
26
27 #[serde(default = "default_port")]
28 pub port: u16,
29
30 #[serde(default)]
31 pub auth_required: bool,
32
33 #[serde(default)]
34 pub tls: TlsConfig,
35
36 #[serde(default)]
37 pub auth: AuthConfig,
38
39 #[serde(default)]
40 pub handlers: HashMap<String, HandlerConfig>,
41
42 #[serde(default)]
43 pub routing: RoutingConfig,
44}
45
46#[derive(Debug, Deserialize)]
48pub struct TlsConfig {
49 #[serde(default = "default_cert")]
50 pub cert: String,
51
52 #[serde(default = "default_key")]
53 pub key: String,
54}
55
56impl Default for TlsConfig {
57 fn default() -> Self {
58 Self {
59 cert: default_cert(),
60 key: default_key(),
61 }
62 }
63}
64
65#[derive(Debug, Deserialize)]
67pub struct AuthConfig {
68 #[serde(default = "default_credentials_file")]
69 pub credentials_file: String,
70}
71
72impl Default for AuthConfig {
73 fn default() -> Self {
74 Self {
75 credentials_file: default_credentials_file(),
76 }
77 }
78}
79
80#[derive(Debug, Clone, Deserialize)]
82#[serde(tag = "type")]
83pub enum HandlerConfig {
84 #[serde(rename = "file_storage")]
86 FileStorage {
87 #[serde(default = "default_mailbox_path")]
88 path: String,
89 #[serde(default = "default_true")]
90 metadata: bool,
91 },
92
93 #[serde(rename = "redis")]
95 Redis {
96 #[serde(default = "default_redis_url")]
97 url: String,
98 #[serde(default = "default_redis_queue")]
99 queue: String,
100 },
101}
102
103#[derive(Debug, Deserialize)]
105pub struct RoutingConfig {
106 #[serde(default = "default_handler_name")]
108 pub default: String,
109
110 #[serde(default)]
113 pub transformers: Vec<TransformerConfig>,
114
115 #[serde(default)]
117 pub rules: Vec<RoutingRuleConfig>,
118}
119
120impl Default for RoutingConfig {
121 fn default() -> Self {
122 Self {
123 default: default_handler_name(),
124 transformers: Vec::new(),
125 rules: Vec::new(),
126 }
127 }
128}
129
130#[derive(Debug, Clone, Deserialize)]
132pub struct RoutingRuleConfig {
133 pub address: Option<String>,
135
136 pub domain: Option<String>,
138
139 pub handler: String,
141
142 pub transformers: Option<Vec<TransformerConfig>>,
144
145 pub auth_required: Option<bool>,
148}
149
150#[derive(Debug, Clone, Deserialize)]
152#[serde(tag = "type")]
153pub enum TransformerConfig {
154 #[serde(rename = "message_id")]
156 MessageId {
157 #[serde(default = "default_host")]
159 domain: String,
160 },
161
162 #[serde(rename = "email_auth")]
164 EmailAuth {
165 #[serde(default)]
168 authserv_id: String,
169 },
170}
171
172pub fn load_config(path: &Path) -> Result<Config, ConfigError> {
174 let content = fs::read_to_string(path).map_err(ConfigError::Io)?;
175 toml::from_str(&content).map_err(ConfigError::Parse)
176}
177
178#[derive(Debug)]
180pub enum ConfigError {
181 Io(std::io::Error),
183 Parse(toml::de::Error),
185}
186
187impl std::fmt::Display for ConfigError {
188 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189 match self {
190 ConfigError::Io(e) => write!(f, "Config I/O error: {e}"),
191 ConfigError::Parse(e) => write!(f, "Config parse error: {e}"),
192 }
193 }
194}
195
196impl std::error::Error for ConfigError {}
197
198fn default_host() -> String {
199 "127.0.0.1".to_string()
200}
201
202fn default_hostname() -> String {
203 "localhost".to_string()
204}
205
206fn default_port() -> u16 {
207 2525
208}
209
210fn default_cert() -> String {
211 "certs/server.cert.pem".to_string()
212}
213
214fn default_key() -> String {
215 "certs/server.key.pem".to_string()
216}
217
218fn default_credentials_file() -> String {
219 "passwords/example.txt".to_string()
220}
221
222fn default_mailbox_path() -> String {
223 "mailbox".to_string()
224}
225
226fn default_true() -> bool {
227 true
228}
229
230fn default_redis_url() -> String {
231 "redis://127.0.0.1:6379".to_string()
232}
233
234fn default_redis_queue() -> String {
235 "incoming_emails".to_string()
236}
237
238fn default_handler_name() -> String {
239 "local".to_string()
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn test_parse_minimal_config() {
248 let toml = r#"
249[smtp]
250host = "0.0.0.0"
251port = 25
252"#;
253 let config: Config = toml::from_str(toml).unwrap();
254 assert_eq!(config.smtp.host, "0.0.0.0");
255 assert_eq!(config.smtp.port, 25);
256 assert!(!config.smtp.auth_required);
257 assert_eq!(config.smtp.routing.default, "local");
258 }
259
260 #[test]
261 fn test_parse_full_config() {
262 let toml = r#"
263[smtp]
264host = "0.0.0.0"
265port = 25
266auth_required = true
267
268[smtp.tls]
269cert = "my/cert.pem"
270key = "my/key.pem"
271
272[smtp.auth]
273credentials_file = "my/passwords.txt"
274
275[smtp.handlers.local]
276type = "file_storage"
277path = "my_mailbox"
278metadata = false
279
280[smtp.handlers.queue]
281type = "redis"
282url = "redis://redis:6379"
283queue = "emails"
284
285[smtp.routing]
286default = "local"
287
288[[smtp.routing.rules]]
289address = "admin@example.com"
290handler = "queue"
291
292[[smtp.routing.rules]]
293domain = "example.com"
294handler = "queue"
295
296[[smtp.routing.rules]]
297domain = "*.internal.org"
298handler = "local"
299"#;
300 let config: Config = toml::from_str(toml).unwrap();
301 assert_eq!(config.smtp.host, "0.0.0.0");
302 assert_eq!(config.smtp.port, 25);
303 assert!(config.smtp.auth_required);
304 assert_eq!(config.smtp.tls.cert, "my/cert.pem");
305 assert_eq!(config.smtp.auth.credentials_file, "my/passwords.txt");
306 assert_eq!(config.smtp.handlers.len(), 2);
307 assert_eq!(config.smtp.routing.rules.len(), 3);
308
309 match &config.smtp.handlers["local"] {
311 HandlerConfig::FileStorage { path, metadata } => {
312 assert_eq!(path, "my_mailbox");
313 assert!(!metadata);
314 }
315 _ => panic!("Expected FileStorage handler"),
316 }
317
318 match &config.smtp.handlers["queue"] {
319 HandlerConfig::Redis { url, queue } => {
320 assert_eq!(url, "redis://redis:6379");
321 assert_eq!(queue, "emails");
322 }
323 _ => panic!("Expected Redis handler"),
324 }
325
326 assert_eq!(
328 config.smtp.routing.rules[0].address.as_deref(),
329 Some("admin@example.com")
330 );
331 assert_eq!(config.smtp.routing.rules[0].handler, "queue");
332 assert_eq!(
333 config.smtp.routing.rules[1].domain.as_deref(),
334 Some("example.com")
335 );
336 assert_eq!(
337 config.smtp.routing.rules[2].domain.as_deref(),
338 Some("*.internal.org")
339 );
340 }
341
342 #[test]
343 fn test_parse_transformers_config() {
344 let toml = r#"
345[smtp]
346
347[[smtp.routing.transformers]]
348type = "message_id"
349domain = "mail.example.com"
350
351[[smtp.routing.rules]]
352domain = "example.com"
353handler = "local"
354
355 [[smtp.routing.rules.transformers]]
356 type = "message_id"
357 domain = "example.com"
358
359[[smtp.routing.rules]]
360domain = "other.com"
361handler = "local"
362"#;
363 let config: Config = toml::from_str(toml).unwrap();
364
365 assert_eq!(config.smtp.routing.transformers.len(), 1);
367 match &config.smtp.routing.transformers[0] {
368 TransformerConfig::MessageId { domain } => {
369 assert_eq!(domain, "mail.example.com");
370 }
371 _ => panic!("Expected MessageId transformer"),
372 }
373
374 assert!(config.smtp.routing.rules[0].transformers.is_some());
376 let rule_transformers = config.smtp.routing.rules[0].transformers.as_ref().unwrap();
377 assert_eq!(rule_transformers.len(), 1);
378 match &rule_transformers[0] {
379 TransformerConfig::MessageId { domain } => {
380 assert_eq!(domain, "example.com");
381 }
382 _ => panic!("Expected MessageId transformer"),
383 }
384
385 assert!(config.smtp.routing.rules[1].transformers.is_none());
387 }
388
389 #[test]
390 fn test_parse_auth_required_per_rule() {
391 let toml = r#"
392[smtp]
393
394[[smtp.routing.rules]]
395address = "secure@example.com"
396handler = "local"
397auth_required = true
398
399[[smtp.routing.rules]]
400domain = "open.com"
401handler = "local"
402auth_required = false
403
404[[smtp.routing.rules]]
405domain = "default.com"
406handler = "local"
407"#;
408 let config: Config = toml::from_str(toml).unwrap();
409 assert_eq!(config.smtp.routing.rules[0].auth_required, Some(true));
410 assert_eq!(config.smtp.routing.rules[1].auth_required, Some(false));
411 assert_eq!(config.smtp.routing.rules[2].auth_required, None);
412 }
413
414 #[test]
415 fn test_parse_defaults() {
416 let toml = r#"
417[smtp]
418"#;
419 let config: Config = toml::from_str(toml).unwrap();
420 assert_eq!(config.smtp.host, "127.0.0.1");
421 assert_eq!(config.smtp.port, 2525);
422 assert_eq!(config.smtp.tls.cert, "certs/server.cert.pem");
423 assert_eq!(config.smtp.tls.key, "certs/server.key.pem");
424 assert_eq!(config.smtp.auth.credentials_file, "passwords/example.txt");
425 }
426
427 #[test]
428 fn test_config_error_display_io() {
429 let error = ConfigError::Io(std::io::Error::new(
430 std::io::ErrorKind::NotFound,
431 "file missing",
432 ));
433 assert!(error.to_string().starts_with("Config I/O error:"));
434 }
435
436 #[test]
437 fn test_config_error_display_parse() {
438 let toml_err = toml::from_str::<Config>("invalid toml {{{{").unwrap_err();
439 let error = ConfigError::Parse(toml_err);
440 assert!(error.to_string().starts_with("Config parse error:"));
441 }
442
443 #[test]
444 fn test_load_config_success() {
445 let temp_dir = tempfile::TempDir::new().unwrap();
446 let config_path = temp_dir.path().join("config.toml");
447 std::fs::write(&config_path, "[smtp]\nhost = \"0.0.0.0\"\nport = 25\n").unwrap();
448
449 let config = load_config(&config_path).unwrap();
450 assert_eq!(config.smtp.host, "0.0.0.0");
451 assert_eq!(config.smtp.port, 25);
452 }
453
454 #[test]
455 fn test_load_config_file_not_found() {
456 let result = load_config(Path::new("/nonexistent/config.toml"));
457 assert!(result.is_err());
458 }
459
460 #[test]
461 fn test_load_config_invalid_toml() {
462 let temp_dir = tempfile::TempDir::new().unwrap();
463 let config_path = temp_dir.path().join("bad.toml");
464 std::fs::write(&config_path, "this is not valid {{{{ toml").unwrap();
465
466 let result = load_config(&config_path);
467 assert!(result.is_err());
468 }
469
470 #[test]
471 fn test_parse_email_auth_transformer() {
472 let toml = r#"
473[smtp]
474
475[[smtp.routing.transformers]]
476type = "email_auth"
477authserv_id = "mx.example.com"
478"#;
479 let config: Config = toml::from_str(toml).unwrap();
480 assert_eq!(config.smtp.routing.transformers.len(), 1);
481 match &config.smtp.routing.transformers[0] {
482 TransformerConfig::EmailAuth { authserv_id } => {
483 assert_eq!(authserv_id, "mx.example.com");
484 }
485 _ => panic!("Expected EmailAuth transformer"),
486 }
487 }
488}