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
103/// Routing configuration with rules and a default handler.
104#[derive(Debug, Deserialize)]
105pub struct RoutingConfig {
106    /// Default handler name for routed messages.
107    #[serde(default = "default_handler_name")]
108    pub default: String,
109
110    /// Default transformers applied to all routed messages unless
111    /// overridden per rule.
112    #[serde(default)]
113    pub transformers: Vec<TransformerConfig>,
114
115    /// Sequence of routing rules to be applied according to specificity.
116    #[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/// A single routing rule that matches by address or domain.
131#[derive(Debug, Clone, Deserialize)]
132pub struct RoutingRuleConfig {
133    /// Exact email address match (e.g. "admin@example.com").
134    pub address: Option<String>,
135
136    /// Domain match, supports wildcard prefix (e.g. "example.com" or "*.example.com").
137    pub domain: Option<String>,
138
139    /// Name of the handler to route to.
140    pub handler: String,
141
142    /// Transformers for this rule, overrides the default transformers if present.
143    pub transformers: Option<Vec<TransformerConfig>>,
144
145    /// Whether authentication is required for recipients matching this rule.
146    /// Overrides the global `smtp.auth_required` setting when present.
147    pub auth_required: Option<bool>,
148}
149
150/// Configuration for a message transformer.
151#[derive(Debug, Clone, Deserialize)]
152#[serde(tag = "type")]
153pub enum TransformerConfig {
154    /// Ensures a Message-ID header exists in the email body.
155    #[serde(rename = "message_id")]
156    MessageId {
157        /// Domain used when generating new Message-ID values.
158        #[serde(default = "default_host")]
159        domain: String,
160    },
161
162    /// Verifies SPF, DKIM, and DMARC; adds an Authentication-Results header.
163    #[serde(rename = "email_auth")]
164    EmailAuth {
165        /// The authserv-id for the Authentication-Results header.
166        /// Defaults to the global `hostname` if not specified.
167        #[serde(default)]
168        authserv_id: String,
169    },
170}
171
172/// Loads configuration from a TOML file.
173pub 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/// Errors that can occur while loading configuration.
179#[derive(Debug)]
180pub enum ConfigError {
181    /// An I/O error occurred reading the file.
182    Io(std::io::Error),
183    /// A parse error occurred deserializing TOML.
184    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        // Verify handler types
310        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        // Verify routing rules
327        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        // Default transformers
366        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        // Per-rule transformers
375        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        // Rule without transformers
386        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}