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 #[serde(rename = "reject")]
107 Reject {
108 #[serde(default = "default_reject_code")]
109 code: u16,
110 #[serde(default = "default_reject_message")]
111 message: String,
112 },
113}
114
115#[derive(Debug, Deserialize)]
117pub struct RoutingConfig {
118 #[serde(default = "default_handler_name")]
120 pub default: String,
121
122 #[serde(default)]
125 pub transformers: Vec<TransformerConfig>,
126
127 #[serde(default)]
129 pub rules: Vec<RoutingRuleConfig>,
130}
131
132impl Default for RoutingConfig {
133 fn default() -> Self {
134 Self {
135 default: default_handler_name(),
136 transformers: Vec::new(),
137 rules: Vec::new(),
138 }
139 }
140}
141
142#[derive(Debug, Clone, Deserialize)]
144pub struct RoutingRuleConfig {
145 pub address: Option<String>,
147
148 pub domain: Option<String>,
150
151 pub handler: String,
153
154 pub transformers: Option<Vec<TransformerConfig>>,
156
157 pub auth_required: Option<bool>,
160}
161
162#[derive(Debug, Clone, Deserialize)]
164#[serde(tag = "type")]
165pub enum TransformerConfig {
166 #[serde(rename = "message_id")]
168 MessageId {
169 #[serde(default = "default_host")]
171 domain: String,
172 },
173
174 #[serde(rename = "email_auth")]
176 EmailAuth {
177 #[serde(default)]
180 authserv_id: String,
181 },
182}
183
184pub fn load_config(path: &Path) -> Result<Config, ConfigError> {
186 let content = fs::read_to_string(path).map_err(ConfigError::Io)?;
187 toml::from_str(&content).map_err(ConfigError::Parse)
188}
189
190#[derive(Debug)]
192pub enum ConfigError {
193 Io(std::io::Error),
195 Parse(toml::de::Error),
197}
198
199impl std::fmt::Display for ConfigError {
200 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201 match self {
202 ConfigError::Io(e) => write!(f, "Config I/O error: {e}"),
203 ConfigError::Parse(e) => write!(f, "Config parse error: {e}"),
204 }
205 }
206}
207
208impl std::error::Error for ConfigError {}
209
210fn default_host() -> String {
211 "127.0.0.1".to_string()
212}
213
214fn default_hostname() -> String {
215 "localhost".to_string()
216}
217
218fn default_port() -> u16 {
219 2525
220}
221
222fn default_cert() -> String {
223 "certs/server.cert.pem".to_string()
224}
225
226fn default_key() -> String {
227 "certs/server.key.pem".to_string()
228}
229
230fn default_credentials_file() -> String {
231 "passwords/example.txt".to_string()
232}
233
234fn default_mailbox_path() -> String {
235 "mailbox".to_string()
236}
237
238fn default_true() -> bool {
239 true
240}
241
242fn default_redis_url() -> String {
243 "redis://127.0.0.1:6379".to_string()
244}
245
246fn default_redis_queue() -> String {
247 "incoming_emails".to_string()
248}
249
250fn default_handler_name() -> String {
251 "local".to_string()
252}
253
254fn default_reject_code() -> u16 {
255 550
256}
257
258fn default_reject_message() -> String {
259 "Relay access denied".to_string()
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 #[test]
267 fn test_parse_minimal_config() {
268 let toml = r#"
269[smtp]
270host = "0.0.0.0"
271port = 25
272"#;
273 let config: Config = toml::from_str(toml).unwrap();
274 assert_eq!(config.smtp.host, "0.0.0.0");
275 assert_eq!(config.smtp.port, 25);
276 assert!(!config.smtp.auth_required);
277 assert_eq!(config.smtp.routing.default, "local");
278 }
279
280 #[test]
281 fn test_parse_full_config() {
282 let toml = r#"
283[smtp]
284host = "0.0.0.0"
285port = 25
286auth_required = true
287
288[smtp.tls]
289cert = "my/cert.pem"
290key = "my/key.pem"
291
292[smtp.auth]
293credentials_file = "my/passwords.txt"
294
295[smtp.handlers.local]
296type = "file_storage"
297path = "my_mailbox"
298metadata = false
299
300[smtp.handlers.queue]
301type = "redis"
302url = "redis://redis:6379"
303queue = "emails"
304
305[smtp.routing]
306default = "local"
307
308[[smtp.routing.rules]]
309address = "admin@example.com"
310handler = "queue"
311
312[[smtp.routing.rules]]
313domain = "example.com"
314handler = "queue"
315
316[[smtp.routing.rules]]
317domain = "*.internal.org"
318handler = "local"
319"#;
320 let config: Config = toml::from_str(toml).unwrap();
321 assert_eq!(config.smtp.host, "0.0.0.0");
322 assert_eq!(config.smtp.port, 25);
323 assert!(config.smtp.auth_required);
324 assert_eq!(config.smtp.tls.cert, "my/cert.pem");
325 assert_eq!(config.smtp.auth.credentials_file, "my/passwords.txt");
326 assert_eq!(config.smtp.handlers.len(), 2);
327 assert_eq!(config.smtp.routing.rules.len(), 3);
328
329 match &config.smtp.handlers["local"] {
331 HandlerConfig::FileStorage { path, metadata } => {
332 assert_eq!(path, "my_mailbox");
333 assert!(!metadata);
334 }
335 _ => panic!("Expected FileStorage handler"),
336 }
337
338 match &config.smtp.handlers["queue"] {
339 HandlerConfig::Redis { url, queue } => {
340 assert_eq!(url, "redis://redis:6379");
341 assert_eq!(queue, "emails");
342 }
343 _ => panic!("Expected Redis handler"),
344 }
345
346 assert_eq!(
348 config.smtp.routing.rules[0].address.as_deref(),
349 Some("admin@example.com")
350 );
351 assert_eq!(config.smtp.routing.rules[0].handler, "queue");
352 assert_eq!(
353 config.smtp.routing.rules[1].domain.as_deref(),
354 Some("example.com")
355 );
356 assert_eq!(
357 config.smtp.routing.rules[2].domain.as_deref(),
358 Some("*.internal.org")
359 );
360 }
361
362 #[test]
363 fn test_parse_transformers_config() {
364 let toml = r#"
365[smtp]
366
367[[smtp.routing.transformers]]
368type = "message_id"
369domain = "mail.example.com"
370
371[[smtp.routing.rules]]
372domain = "example.com"
373handler = "local"
374
375 [[smtp.routing.rules.transformers]]
376 type = "message_id"
377 domain = "example.com"
378
379[[smtp.routing.rules]]
380domain = "other.com"
381handler = "local"
382"#;
383 let config: Config = toml::from_str(toml).unwrap();
384
385 assert_eq!(config.smtp.routing.transformers.len(), 1);
387 match &config.smtp.routing.transformers[0] {
388 TransformerConfig::MessageId { domain } => {
389 assert_eq!(domain, "mail.example.com");
390 }
391 _ => panic!("Expected MessageId transformer"),
392 }
393
394 assert!(config.smtp.routing.rules[0].transformers.is_some());
396 let rule_transformers = config.smtp.routing.rules[0].transformers.as_ref().unwrap();
397 assert_eq!(rule_transformers.len(), 1);
398 match &rule_transformers[0] {
399 TransformerConfig::MessageId { domain } => {
400 assert_eq!(domain, "example.com");
401 }
402 _ => panic!("Expected MessageId transformer"),
403 }
404
405 assert!(config.smtp.routing.rules[1].transformers.is_none());
407 }
408
409 #[test]
410 fn test_parse_auth_required_per_rule() {
411 let toml = r#"
412[smtp]
413
414[[smtp.routing.rules]]
415address = "secure@example.com"
416handler = "local"
417auth_required = true
418
419[[smtp.routing.rules]]
420domain = "open.com"
421handler = "local"
422auth_required = false
423
424[[smtp.routing.rules]]
425domain = "default.com"
426handler = "local"
427"#;
428 let config: Config = toml::from_str(toml).unwrap();
429 assert_eq!(config.smtp.routing.rules[0].auth_required, Some(true));
430 assert_eq!(config.smtp.routing.rules[1].auth_required, Some(false));
431 assert_eq!(config.smtp.routing.rules[2].auth_required, None);
432 }
433
434 #[test]
435 fn test_parse_defaults() {
436 let toml = r#"
437[smtp]
438"#;
439 let config: Config = toml::from_str(toml).unwrap();
440 assert_eq!(config.smtp.host, "127.0.0.1");
441 assert_eq!(config.smtp.port, 2525);
442 assert_eq!(config.smtp.tls.cert, "certs/server.cert.pem");
443 assert_eq!(config.smtp.tls.key, "certs/server.key.pem");
444 assert_eq!(config.smtp.auth.credentials_file, "passwords/example.txt");
445 }
446
447 #[test]
448 fn test_config_error_display_io() {
449 let error = ConfigError::Io(std::io::Error::new(
450 std::io::ErrorKind::NotFound,
451 "file missing",
452 ));
453 assert!(error.to_string().starts_with("Config I/O error:"));
454 }
455
456 #[test]
457 fn test_config_error_display_parse() {
458 let toml_err = toml::from_str::<Config>("invalid toml {{{{").unwrap_err();
459 let error = ConfigError::Parse(toml_err);
460 assert!(error.to_string().starts_with("Config parse error:"));
461 }
462
463 #[test]
464 fn test_load_config_success() {
465 let temp_dir = tempfile::TempDir::new().unwrap();
466 let config_path = temp_dir.path().join("config.toml");
467 std::fs::write(&config_path, "[smtp]\nhost = \"0.0.0.0\"\nport = 25\n").unwrap();
468
469 let config = load_config(&config_path).unwrap();
470 assert_eq!(config.smtp.host, "0.0.0.0");
471 assert_eq!(config.smtp.port, 25);
472 }
473
474 #[test]
475 fn test_load_config_file_not_found() {
476 let result = load_config(Path::new("/nonexistent/config.toml"));
477 assert!(result.is_err());
478 }
479
480 #[test]
481 fn test_load_config_invalid_toml() {
482 let temp_dir = tempfile::TempDir::new().unwrap();
483 let config_path = temp_dir.path().join("bad.toml");
484 std::fs::write(&config_path, "this is not valid {{{{ toml").unwrap();
485
486 let result = load_config(&config_path);
487 assert!(result.is_err());
488 }
489
490 #[test]
491 fn test_parse_reject_handler_defaults() {
492 let toml = r#"
493[smtp]
494
495[smtp.handlers.block]
496type = "reject"
497"#;
498 let config: Config = toml::from_str(toml).unwrap();
499 match &config.smtp.handlers["block"] {
500 HandlerConfig::Reject { code, message } => {
501 assert_eq!(*code, 550);
502 assert_eq!(message, "Relay access denied");
503 }
504 other => panic!("Expected Reject handler, got {other:?}"),
505 }
506 }
507
508 #[test]
509 fn test_parse_reject_handler_custom() {
510 let toml = r#"
511[smtp]
512
513[smtp.handlers.deny]
514type = "reject"
515code = 521
516message = "No mail accepted here"
517"#;
518 let config: Config = toml::from_str(toml).unwrap();
519 match &config.smtp.handlers["deny"] {
520 HandlerConfig::Reject { code, message } => {
521 assert_eq!(*code, 521);
522 assert_eq!(message, "No mail accepted here");
523 }
524 other => panic!("Expected Reject handler, got {other:?}"),
525 }
526 }
527
528 #[test]
529 fn test_parse_email_auth_transformer() {
530 let toml = r#"
531[smtp]
532
533[[smtp.routing.transformers]]
534type = "email_auth"
535authserv_id = "mx.example.com"
536"#;
537 let config: Config = toml::from_str(toml).unwrap();
538 assert_eq!(config.smtp.routing.transformers.len(), 1);
539 match &config.smtp.routing.transformers[0] {
540 TransformerConfig::EmailAuth { authserv_id } => {
541 assert_eq!(authserv_id, "mx.example.com");
542 }
543 _ => panic!("Expected EmailAuth transformer"),
544 }
545 }
546}