Skip to main content

rusmes_config/
lib.rs

1//! # rusmes-config
2//!
3//! Configuration management for the RusMES mail server.
4//!
5//! ## Overview
6//!
7//! `rusmes-config` provides the [`ServerConfig`] struct and supporting types that model
8//! the complete runtime configuration of a RusMES installation.  Configuration is
9//! normally loaded from a TOML or YAML file on disk, with optional overrides from
10//! environment variables (prefix `RUSMES_`).
11//!
12//! ## File format auto-detection
13//!
14//! [`ServerConfig::from_file`] inspects the file extension:
15//!
16//! | Extension | Format |
17//! |-----------|--------|
18//! | `.toml`   | TOML   |
19//! | `.yaml` / `.yml` | YAML |
20//!
21//! Both formats expose identical semantics; see the crate tests for concrete examples.
22//!
23//! ## Environment variable overrides
24//!
25//! Every significant configuration key has a corresponding `RUSMES_*` environment
26//! variable that takes precedence over the file value.  A full list is documented on
27//! [`ServerConfig::apply_env_overrides`].  This enables twelve-factor-style deployments
28//! where the base config is baked into a container image and secrets are injected at
29//! runtime.
30//!
31//! ## Sections
32//!
33//! | Struct | Field | Description |
34//! |--------|-------|-------------|
35//! | [`SmtpServerConfig`] | `smtp` | Listening addresses, TLS ports, rate limits |
36//! | [`ImapServerConfig`] | `imap` | IMAP4rev1 listener |
37//! | [`JmapServerConfig`] | `jmap` | JMAP HTTP listener |
38//! | [`Pop3ServerConfig`] | `pop3` | POP3 listener |
39//! | [`StorageConfig`] | `storage` | Filesystem, Postgres, or AmateRS backend |
40//! | [`AuthConfig`] | `auth` | File, LDAP, SQL, or OAuth2 auth backend config |
41//! | [`QueueConfig`] | `queue` | Retry queue with exponential back-off |
42//! | [`SecurityConfig`] | `security` | Relay networks, blocked IPs |
43//! | [`DomainsConfig`] | `domains` | Local domains and address aliases |
44//! | [`MetricsConfig`] | `metrics` | Prometheus scrape endpoint |
45//! | [`TracingConfig`] | `tracing` | OpenTelemetry OTLP exporter |
46//! | [`ConnectionLimitsConfig`] | `connection_limits` | Per-IP and global connection caps |
47//! | [`LoggingConfig`] | `logging` | Log level / format / output routing |
48//!
49//! The [`logging`] module provides [`logging::init_logging`] for initialising the global
50//! `tracing` subscriber from a [`logging::LogConfig`], including file rotation and optional
51//! gzip compression of rotated files.
52//!
53//! ## Validation
54//!
55//! [`ServerConfig::validate`] is called automatically during [`ServerConfig::from_file`].
56//! It checks domain syntax, email addresses, port numbers, storage path accessibility,
57//! and processor uniqueness.
58//!
59//! ## Example
60//!
61//! ```rust,no_run
62//! use rusmes_config::ServerConfig;
63//!
64//! let cfg = ServerConfig::from_file("/etc/rusmes/rusmes.toml")?;
65//! println!("Serving domain {}", cfg.domain);
66//! # Ok::<(), anyhow::Error>(())
67//! ```
68
69mod env_overrides;
70mod listeners;
71pub mod logging;
72mod parse;
73pub mod performance;
74mod runtime;
75pub mod tls;
76mod unknown_keys;
77mod validation;
78
79use rusmes_proto::MailAddress;
80use serde::{Deserialize, Serialize};
81use std::path::Path;
82use unknown_keys::collect_unknown_toml_keys;
83use validation::{
84    validate_domain, validate_email, validate_port, validate_processors, validate_storage_path,
85};
86
87// Re-export all public types from sub-modules so downstream crates see no change.
88pub use listeners::{
89    ConnectionLimitsConfig, ImapServerConfig, JmapPushConfig, JmapServerConfig, Pop3ServerConfig,
90    RateLimitConfig, RelayConfig, SmtpOutboundConfig, SmtpServerConfig,
91};
92pub use performance::PerformanceConfig;
93pub use runtime::{
94    AuthConfig, DomainsConfig, FileAuthConfig, LdapAuthConfig, LogFileConfig, LoggingConfig,
95    MailetConfig, MetricsBasicAuthConfig, MetricsConfig, OAuth2AuthConfig, OtlpProtocol,
96    ProcessorConfig, QueueConfig, SecurityConfig, SqlAuthConfig, StorageConfig, TracingConfig,
97};
98pub use tls::{ClientAuthMode, ProtocolKind, TlsConfig, TlsEndpointConfig};
99
100/// Main server configuration.
101///
102/// Loaded from a TOML or YAML file via [`ServerConfig::from_file`].
103/// All optional sections default to `None`; required fields (`domain`,
104/// `postmaster`, `smtp`, `storage`, `processors`) must be present.
105#[derive(Debug, Clone, Deserialize, Serialize)]
106pub struct ServerConfig {
107    /// Required. Primary mail domain served by this RusMES installation
108    /// (e.g. `"mail.example.com"`). Must be a syntactically valid domain name.
109    pub domain: String,
110
111    /// Required. RFC 5321 postmaster email address (e.g. `"postmaster@example.com"`).
112    /// Used as the envelope sender for system-generated bounce messages.
113    pub postmaster: String,
114
115    /// Required. SMTP listener configuration (host, port, TLS port, size limits).
116    pub smtp: SmtpServerConfig,
117
118    /// Default: `None`. IMAP4rev1 listener configuration. When absent the IMAP
119    /// service is not started.
120    pub imap: Option<ImapServerConfig>,
121
122    /// Default: `None`. JMAP HTTP listener configuration. When absent the JMAP
123    /// service is not started.
124    pub jmap: Option<JmapServerConfig>,
125
126    /// Default: `None`. POP3 listener configuration. When absent the POP3
127    /// service is not started.
128    pub pop3: Option<Pop3ServerConfig>,
129
130    /// Required. Mail storage backend (filesystem, PostgreSQL, or AmateRS).
131    pub storage: StorageConfig,
132
133    /// Required. Ordered list of processor chains. At least one processor
134    /// named `"root"` must be present.
135    pub processors: Vec<ProcessorConfig>,
136
137    /// Default: `"/var/run/rusmes"`. Per-process runtime directory used for
138    /// the PID file, the rate-limiter snapshot, and any other ephemeral state
139    /// files. Must be writable by the user running `rusmes-server`.
140    #[serde(default = "default_runtime_dir")]
141    pub runtime_dir: String,
142
143    /// Default: `None`. Outbound SMTP relay configuration. When absent,
144    /// rusmes delivers directly via DNS MX lookup.
145    #[serde(default)]
146    pub relay: Option<RelayConfig>,
147
148    /// Default: `None`. Authentication backend configuration (file, LDAP,
149    /// SQL, or OAuth2). When absent the server falls back to no-auth mode.
150    #[serde(default)]
151    pub auth: Option<AuthConfig>,
152
153    /// Default: `None`. Logging configuration (level, format, output, file
154    /// rotation). When absent the server logs `info`-level messages to stdout
155    /// in text format.
156    #[serde(default)]
157    pub logging: Option<LoggingConfig>,
158
159    /// Default: `None`. Outbound queue configuration (retry delays, back-off).
160    /// When absent, reasonable built-in defaults are used.
161    #[serde(default)]
162    pub queue: Option<QueueConfig>,
163
164    /// Default: `None`. Security configuration (relay networks, blocked IPs,
165    /// recipient validation). When absent all security checks are disabled.
166    #[serde(default)]
167    pub security: Option<SecurityConfig>,
168
169    /// Default: `None`. Local domain and alias mapping configuration.
170    /// When absent only the primary `domain` is considered local.
171    #[serde(default)]
172    pub domains: Option<DomainsConfig>,
173
174    /// Default: `None`. Prometheus metrics endpoint configuration.
175    /// When absent the `/metrics` endpoint is not exposed.
176    #[serde(default)]
177    pub metrics: Option<MetricsConfig>,
178
179    /// Default: `None`. OpenTelemetry OTLP tracing configuration.
180    /// When absent distributed tracing is disabled.
181    #[serde(default)]
182    pub tracing: Option<TracingConfig>,
183
184    /// Default: `None`. Per-IP and global connection limit configuration.
185    /// When absent no connection caps are enforced.
186    #[serde(default)]
187    pub connection_limits: Option<ConnectionLimitsConfig>,
188
189    /// Default: `PerformanceConfig::default()`. Runtime performance tuning:
190    /// Tokio worker threads, connection pool sizes, and per-connection buffer
191    /// sizes. Omitting `[performance]` uses conservative built-in defaults.
192    #[serde(default)]
193    pub performance: PerformanceConfig,
194
195    /// Default: `None`. TLS certificate and key paths. Supports a shared
196    /// `[tls.default]` endpoint and optional per-protocol overrides
197    /// (`[tls.smtp]`, `[tls.imap]`, `[tls.pop3]`, `[tls.jmap]`).
198    #[serde(default)]
199    pub tls: Option<TlsConfig>,
200
201    /// Default: `false`. When `true`, call `chroot(runtime_dir)` after binding
202    /// all sockets and loading TLS material, before dropping privileges.
203    /// Has effect only on Linux; on other platforms a `tracing::warn!` is
204    /// emitted and this field is otherwise ignored.
205    #[serde(default)]
206    pub chroot: bool,
207
208    /// Default: `""` (no-op). System user name to `setuid` to after binding
209    /// all sockets.  The empty string means "do not change UID".  Only
210    /// effective on Linux; ignored on other platforms (with a warning).
211    #[serde(default)]
212    pub run_as_user: String,
213
214    /// Default: `""` (no-op). System group name to `setgid` to after binding
215    /// all sockets.  The empty string means "do not change GID".  Only
216    /// effective on Linux; ignored on other platforms (with a warning).
217    #[serde(default)]
218    pub run_as_group: String,
219
220    /// Unknown TOML/YAML keys captured for diagnostic warnings.
221    ///
222    /// Not serialized to output. Populated by [`ServerConfig::from_file`]
223    /// via a two-phase parse (raw `toml::Value` → known-key diff) so that
224    /// `warn_unknown_keys` can emit [`tracing::warn!`] for each entry.
225    /// Exposed as `pub` so tests can assert on which keys were captured
226    /// without relying on subscriber interception.
227    #[serde(skip)]
228    pub extra: Vec<String>,
229}
230
231/// Default runtime directory used when `runtime_dir` is omitted from the
232/// configuration file. This path is conventionally writable by the user
233/// running `rusmes-server`.
234fn default_runtime_dir() -> String {
235    "/var/run/rusmes".to_string()
236}
237
238impl ServerConfig {
239    /// Load configuration from a TOML or YAML file.
240    ///
241    /// The format is auto-detected based on file extension:
242    /// - `.toml` files are parsed as TOML
243    /// - `.yaml` or `.yml` files are parsed as YAML
244    pub fn from_file(path: impl AsRef<Path>) -> anyhow::Result<Self> {
245        let path = path.as_ref();
246        let content = std::fs::read_to_string(path)?;
247
248        // Auto-detect format based on file extension
249        let mut config: ServerConfig = match path.extension().and_then(|ext| ext.to_str()) {
250            Some("yaml") | Some("yml") => serde_yaml::from_str(&content)?,
251            Some("toml") => {
252                // Two-phase: first parse to raw Value so we can detect
253                // unknown top-level keys, then deserialize into the struct.
254                let raw: toml::Value = toml::from_str(&content)?;
255                let unknown = collect_unknown_toml_keys(&raw);
256                let mut cfg: ServerConfig = toml::from_str(&content)?;
257                cfg.extra = unknown;
258                cfg
259            }
260            Some(ext) => {
261                return Err(anyhow::anyhow!(
262                    "Unsupported configuration file extension: .{}. Use .toml, .yaml, or .yml",
263                    ext
264                ));
265            }
266            None => {
267                return Err(anyhow::anyhow!(
268                    "Configuration file must have a .toml, .yaml, or .yml extension"
269                ));
270            }
271        };
272
273        // Apply environment variable overrides
274        config.apply_env_overrides();
275
276        // Warn about unknown keys before validation so operators get actionable
277        // output even when validation subsequently fails.
278        config.warn_unknown_keys();
279
280        // Validate configuration
281        config.validate()?;
282
283        Ok(config)
284    }
285
286    /// Validate the entire configuration.
287    ///
288    /// This method is called automatically when loading configuration from a file.
289    /// It validates:
290    /// - Domain name format
291    /// - Postmaster email address
292    /// - Port numbers for SMTP, IMAP, JMAP
293    /// - Storage path accessibility
294    /// - Processor uniqueness
295    /// - Local domain names (if configured)
296    pub fn validate(&self) -> anyhow::Result<()> {
297        // Validate main domain
298        validate_domain(&self.domain)
299            .map_err(|e| anyhow::anyhow!("Invalid server domain: {}", e))?;
300
301        // Validate postmaster email
302        validate_email(&self.postmaster)
303            .map_err(|e| anyhow::anyhow!("Invalid postmaster email: {}", e))?;
304
305        // Validate SMTP configuration
306        validate_port(self.smtp.port, "SMTP port")?;
307        if let Some(tls_port) = self.smtp.tls_port {
308            validate_port(tls_port, "SMTP TLS port")?;
309        }
310
311        // Validate IMAP configuration
312        if let Some(ref imap) = self.imap {
313            validate_port(imap.port, "IMAP port")?;
314            if let Some(tls_port) = imap.tls_port {
315                validate_port(tls_port, "IMAP TLS port")?;
316            }
317        }
318
319        // Validate JMAP configuration
320        if let Some(ref jmap) = self.jmap {
321            validate_port(jmap.port, "JMAP port")?;
322        }
323
324        // Validate POP3 configuration
325        if let Some(ref pop3) = self.pop3 {
326            validate_port(pop3.port, "POP3 port")?;
327            if let Some(tls_port) = pop3.tls_port {
328                validate_port(tls_port, "POP3 TLS port")?;
329            }
330        }
331
332        // Validate storage path
333        match &self.storage {
334            StorageConfig::Filesystem { path } => {
335                validate_storage_path(path)?;
336            }
337            StorageConfig::Postgres { connection_string } => {
338                if connection_string.is_empty() {
339                    anyhow::bail!("Postgres connection string cannot be empty");
340                }
341            }
342            StorageConfig::AmateRS {
343                endpoints,
344                replication_factor,
345            } => {
346                if endpoints.is_empty() {
347                    anyhow::bail!("AmateRS endpoints cannot be empty");
348                }
349                if *replication_factor == 0 {
350                    anyhow::bail!("AmateRS replication factor must be greater than 0");
351                }
352            }
353        }
354
355        // Validate processors
356        validate_processors(&self.processors)?;
357
358        // Validate local domains if configured
359        if let Some(ref domains) = self.domains {
360            for domain in &domains.local_domains {
361                validate_domain(domain)
362                    .map_err(|e| anyhow::anyhow!("Invalid local domain '{}': {}", domain, e))?;
363            }
364
365            // Validate aliases
366            for (from, to) in &domains.aliases {
367                validate_email(from)
368                    .map_err(|e| anyhow::anyhow!("Invalid alias source '{}': {}", from, e))?;
369                validate_email(to)
370                    .map_err(|e| anyhow::anyhow!("Invalid alias destination '{}': {}", to, e))?;
371            }
372        }
373
374        // Validate logging configuration
375        if let Some(ref logging) = self.logging {
376            logging.validate_level()?;
377            logging.validate_format()?;
378        }
379
380        // Validate queue configuration
381        if let Some(ref queue) = self.queue {
382            queue.validate_backoff_multiplier()?;
383            queue.validate_worker_threads()?;
384        }
385
386        // Validate security configuration
387        if let Some(ref security) = self.security {
388            security.validate_relay_networks()?;
389            security.validate_blocked_ips()?;
390        }
391
392        // Validate metrics configuration
393        if let Some(ref metrics) = self.metrics {
394            metrics.validate_bind_address()?;
395            metrics.validate_path()?;
396        }
397
398        // Validate performance configuration
399        self.performance.validate()?;
400
401        // Validate TLS configuration (if present)
402        if let Some(ref tls) = self.tls {
403            tls.validate()?;
404        }
405
406        Ok(())
407    }
408
409    /// Get postmaster address.
410    pub fn postmaster_address(&self) -> anyhow::Result<MailAddress> {
411        self.postmaster
412            .parse()
413            .map_err(|e| anyhow::anyhow!("Invalid postmaster address: {}", e))
414    }
415
416    /// Return the [`TlsEndpointConfig`] for `proto`, or `None` if no TLS is
417    /// configured.
418    ///
419    /// Delegates to [`TlsConfig::tls_for_protocol`] which returns the
420    /// per-protocol override when present and falls back to `tls.default`.
421    pub fn tls_for_protocol(&self, proto: ProtocolKind) -> Option<&TlsEndpointConfig> {
422        self.tls.as_ref().map(|t| t.tls_for_protocol(proto))
423    }
424
425    /// Emit `tracing::warn!` for every unknown top-level configuration key.
426    ///
427    /// Called automatically by [`ServerConfig::from_file`] after
428    /// deserialization. Operators can use the warnings to detect typos or
429    /// stale keys without causing a hard failure.
430    pub fn warn_unknown_keys(&self) {
431        for key in &self.extra {
432            tracing::warn!(
433                "unknown configuration key '{}' will be ignored; check your config file for typos",
434                key
435            );
436        }
437    }
438}