Skip to main content

mailsis_utils/
config.rs

1//! TOML configuration loading for the SMTP server.
2//!
3//! The server reads a single `config.toml` file at startup to determine
4//! bind address, TLS certificates, credential sources, handler backends,
5//! and per-domain routing rules. [`load_config`] parses the file into
6//! a strongly-typed [`Config`] hierarchy.
7
8use std::{collections::HashMap, fs, path::Path};
9
10use serde::Deserialize;
11
12/// Top-level configuration for the Mailsis SMTP server.
13#[derive(Debug, Deserialize)]
14pub struct Config {
15    pub smtp: SmtpConfig,
16}
17
18/// SMTP server configuration.
19#[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/// TLS certificate configuration.
47#[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/// Authentication configuration.
66#[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/// Configuration for a named message handler.
81#[derive(Debug, Clone, Deserialize)]
82#[serde(tag = "type")]
83pub enum HandlerConfig {
84    /// File-based storage handler.
85    #[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    /// Redis queue handler.
94    #[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    /// Void handler that always refuses delivery with a fixed SMTP reply.
103    ///
104    /// Intended as a default routing target to deny everything that does not
105    /// match an explicit routing rule.
106    #[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/// Routing configuration with rules and a default handler.
116#[derive(Debug, Deserialize)]
117pub struct RoutingConfig {
118    /// Default handler name for routed messages.
119    #[serde(default = "default_handler_name")]
120    pub default: String,
121
122    /// Default transformers applied to all routed messages unless
123    /// overridden per rule.
124    #[serde(default)]
125    pub transformers: Vec<TransformerConfig>,
126
127    /// Sequence of routing rules to be applied according to specificity.
128    #[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/// A single routing rule that matches by address or domain.
143#[derive(Debug, Clone, Deserialize)]
144pub struct RoutingRuleConfig {
145    /// Exact email address match (e.g. "admin@example.com").
146    pub address: Option<String>,
147
148    /// Domain match, supports wildcard prefix (e.g. "example.com" or "*.example.com").
149    pub domain: Option<String>,
150
151    /// Name of the handler to route to.
152    pub handler: String,
153
154    /// Transformers for this rule, overrides the default transformers if present.
155    pub transformers: Option<Vec<TransformerConfig>>,
156
157    /// Whether authentication is required for recipients matching this rule.
158    /// Overrides the global `smtp.auth_required` setting when present.
159    pub auth_required: Option<bool>,
160}
161
162/// Configuration for a message transformer.
163#[derive(Debug, Clone, Deserialize)]
164#[serde(tag = "type")]
165pub enum TransformerConfig {
166    /// Ensures a Message-ID header exists in the email body.
167    #[serde(rename = "message_id")]
168    MessageId {
169        /// Domain used when generating new Message-ID values.
170        #[serde(default = "default_host")]
171        domain: String,
172    },
173
174    /// Verifies SPF, DKIM, and DMARC; adds an Authentication-Results header.
175    #[serde(rename = "email_auth")]
176    EmailAuth {
177        /// The authserv-id for the Authentication-Results header.
178        /// Defaults to the global `hostname` if not specified.
179        #[serde(default)]
180        authserv_id: String,
181    },
182}
183
184/// Loads configuration from a TOML file.
185pub 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/// Errors that can occur while loading configuration.
191#[derive(Debug)]
192pub enum ConfigError {
193    /// An I/O error occurred reading the file.
194    Io(std::io::Error),
195    /// A parse error occurred deserializing TOML.
196    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        // Verify handler types
330        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        // Verify routing rules
347        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        // Default transformers
386        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        // Per-rule transformers
395        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        // Rule without transformers
406        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}