Skip to main content

rusmes_config/
runtime.rs

1//! Runtime configuration types: storage, auth, processors, logging, queue,
2//! security, domains, metrics, tracing, and observability settings.
3
4use crate::parse::{parse_duration, parse_size};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8// --------------------------------------------------------------------------
9// StorageConfig
10// --------------------------------------------------------------------------
11
12/// Storage backend configuration.
13#[derive(Debug, Clone, Deserialize, Serialize)]
14#[serde(tag = "backend")]
15pub enum StorageConfig {
16    #[serde(rename = "filesystem")]
17    Filesystem { path: String },
18    #[serde(rename = "postgres")]
19    Postgres { connection_string: String },
20    #[serde(rename = "amaters")]
21    AmateRS {
22        endpoints: Vec<String>,
23        replication_factor: usize,
24    },
25}
26
27// --------------------------------------------------------------------------
28// ProcessorConfig / MailetConfig
29// --------------------------------------------------------------------------
30
31/// A named processor chain containing an ordered list of mailets.
32///
33/// Processors are the top-level mail-processing pipeline stages. At least one
34/// processor with `state = "root"` must be present.
35#[derive(Debug, Clone, Deserialize, Serialize)]
36pub struct ProcessorConfig {
37    /// Required. Unique name for this processor chain (e.g. `"root"`,
38    /// `"spam"`, `"virus"`).
39    pub name: String,
40
41    /// Required. State label used to route messages into this chain
42    /// (e.g. `"root"`, `"transport"`).
43    pub state: String,
44
45    /// Required. Ordered list of mailet rules applied to each message
46    /// entering this processor.
47    pub mailets: Vec<MailetConfig>,
48}
49
50/// A single matcher + mailet rule within a processor chain.
51#[derive(Debug, Clone, Deserialize, Serialize)]
52pub struct MailetConfig {
53    /// Required. Name of the matcher that selects messages for this mailet
54    /// (e.g. `"All"`, `"RecipientIsLocal"`, `"HasHeader=X-Spam-Flag,YES"`).
55    pub matcher: String,
56
57    /// Required. Name of the mailet to execute on matching messages
58    /// (e.g. `"LocalDelivery"`, `"RemoteDelivery"`, `"Null"`).
59    pub mailet: String,
60
61    /// Default: `{}`. Arbitrary key-value parameters passed to the mailet
62    /// at initialization time.
63    #[serde(default)]
64    pub params: HashMap<String, String>,
65}
66
67// --------------------------------------------------------------------------
68// AuthConfig and variants
69// --------------------------------------------------------------------------
70
71/// Authentication backend configuration.
72#[derive(Debug, Clone, Deserialize, Serialize)]
73#[serde(tag = "backend")]
74pub enum AuthConfig {
75    #[serde(rename = "file")]
76    File {
77        #[serde(flatten)]
78        config: FileAuthConfig,
79    },
80    #[serde(rename = "ldap")]
81    Ldap {
82        #[serde(flatten)]
83        config: LdapAuthConfig,
84    },
85    #[serde(rename = "sql")]
86    Sql {
87        #[serde(flatten)]
88        config: SqlAuthConfig,
89    },
90    #[serde(rename = "oauth2")]
91    OAuth2 {
92        #[serde(flatten)]
93        config: OAuth2AuthConfig,
94    },
95}
96
97/// File-based authentication configuration.
98///
99/// `hash_algorithm` selects the password-hashing algorithm used for **new**
100/// password writes (`create_user`, `change_password`). Existing hashes
101/// continue to verify regardless of this setting (auto-detected by their
102/// PHC prefix). Accepted values: `"bcrypt"` (default) or `"argon2"` /
103/// `"argon2id"`.
104#[derive(Debug, Clone, Deserialize, Serialize)]
105pub struct FileAuthConfig {
106    pub path: String,
107    /// Optional algorithm name; defaults to `"bcrypt"` when omitted for
108    /// backwards compatibility with existing on-disk configurations.
109    #[serde(default = "default_hash_algorithm")]
110    pub hash_algorithm: String,
111}
112
113fn default_hash_algorithm() -> String {
114    "bcrypt".to_string()
115}
116
117/// LDAP authentication configuration.
118#[derive(Debug, Clone, Deserialize, Serialize)]
119pub struct LdapAuthConfig {
120    pub url: String,
121    pub base_dn: String,
122    pub bind_dn: String,
123    pub bind_password: String,
124    pub user_filter: String,
125}
126
127/// SQL authentication configuration.
128#[derive(Debug, Clone, Deserialize, Serialize)]
129pub struct SqlAuthConfig {
130    pub connection_string: String,
131    pub query: String,
132}
133
134/// OAuth2 authentication configuration.
135#[derive(Debug, Clone, Deserialize, Serialize)]
136pub struct OAuth2AuthConfig {
137    pub client_id: String,
138    pub client_secret: String,
139    pub token_url: String,
140    pub authorization_url: String,
141}
142
143// --------------------------------------------------------------------------
144// LoggingConfig / LogFileConfig
145// --------------------------------------------------------------------------
146
147/// Structured logging configuration.
148#[derive(Debug, Clone, Deserialize, Serialize)]
149pub struct LoggingConfig {
150    /// Required. Minimum log level to emit. Valid values: `"trace"`,
151    /// `"debug"`, `"info"`, `"warn"`, `"error"`.
152    pub level: String,
153
154    /// Required. Log output format. Valid values: `"text"` (human-readable),
155    /// `"json"` (structured JSON for log aggregators).
156    pub format: String,
157
158    /// Required. Log output destination. Valid values: `"stdout"`, `"stderr"`,
159    /// or an absolute file path for file-based output.
160    pub output: String,
161
162    /// Default: `None`. Log file rotation settings. Only meaningful when
163    /// `output` is a file path; ignored for `"stdout"` / `"stderr"`.
164    #[serde(default)]
165    pub file: Option<LogFileConfig>,
166}
167
168impl LoggingConfig {
169    /// Validate log level.
170    pub fn validate_level(&self) -> anyhow::Result<()> {
171        match self.level.as_str() {
172            "trace" | "debug" | "info" | "warn" | "error" => Ok(()),
173            _ => Err(anyhow::anyhow!("Invalid log level: {}", self.level)),
174        }
175    }
176
177    /// Validate log format.
178    pub fn validate_format(&self) -> anyhow::Result<()> {
179        match self.format.as_str() {
180            "json" | "text" => Ok(()),
181            _ => Err(anyhow::anyhow!("Invalid log format: {}", self.format)),
182        }
183    }
184}
185
186/// Log file rotation configuration.
187#[derive(Debug, Clone, Deserialize, Serialize)]
188pub struct LogFileConfig {
189    /// Required. Absolute path to the log file (e.g. `"/var/log/rusmes/server.log"`).
190    pub path: String,
191
192    /// Required. Maximum log file size before rotation, expressed as a
193    /// human-readable string (e.g. `"100MB"`, `"1GB"`).
194    pub max_size: String,
195
196    /// Required. Number of rotated log backup files to retain. Older files
197    /// beyond this limit are deleted automatically.
198    pub max_backups: u32,
199
200    /// Required. When `true`, rotated log files are compressed using deflate
201    /// to reduce disk usage.
202    pub compress: bool,
203}
204
205impl LogFileConfig {
206    /// Parse max file size to bytes.
207    pub fn max_size_bytes(&self) -> anyhow::Result<usize> {
208        parse_size(&self.max_size)
209    }
210}
211
212// --------------------------------------------------------------------------
213// QueueConfig
214// --------------------------------------------------------------------------
215
216/// Outbound mail queue and retry configuration.
217#[derive(Debug, Clone, Deserialize, Serialize)]
218pub struct QueueConfig {
219    /// Required. Initial retry delay after the first failed delivery attempt,
220    /// expressed as a human-readable duration (e.g. `"60s"`, `"1m"`).
221    pub initial_delay: String,
222
223    /// Required. Maximum retry delay after many consecutive failures,
224    /// expressed as a human-readable duration (e.g. `"3600s"`, `"1h"`).
225    pub max_delay: String,
226
227    /// Required. Exponential back-off multiplier applied between retries.
228    /// Must be positive. A value of `2.0` doubles the delay after each
229    /// failure up to `max_delay`.
230    pub backoff_multiplier: f64,
231
232    /// Required. Maximum number of delivery attempts before the message is
233    /// bounced back to the sender.
234    pub max_attempts: u32,
235
236    /// Required. Number of threads in the queue worker pool. Must be `> 0`.
237    pub worker_threads: usize,
238
239    /// Required. Maximum number of messages to dequeue and attempt in a
240    /// single batch. Larger values increase throughput at the cost of latency.
241    pub batch_size: usize,
242}
243
244impl QueueConfig {
245    /// Parse initial delay to seconds.
246    pub fn initial_delay_seconds(&self) -> anyhow::Result<u64> {
247        parse_duration(&self.initial_delay)
248    }
249
250    /// Parse max delay to seconds.
251    pub fn max_delay_seconds(&self) -> anyhow::Result<u64> {
252        parse_duration(&self.max_delay)
253    }
254
255    /// Validate backoff multiplier.
256    pub fn validate_backoff_multiplier(&self) -> anyhow::Result<()> {
257        if self.backoff_multiplier <= 0.0 {
258            return Err(anyhow::anyhow!("backoff_multiplier must be positive"));
259        }
260        Ok(())
261    }
262
263    /// Validate worker threads.
264    pub fn validate_worker_threads(&self) -> anyhow::Result<()> {
265        if self.worker_threads == 0 {
266            return Err(anyhow::anyhow!("worker_threads must be greater than 0"));
267        }
268        Ok(())
269    }
270}
271
272// --------------------------------------------------------------------------
273// SecurityConfig
274// --------------------------------------------------------------------------
275
276/// Inbound relay and IP-filtering security configuration.
277#[derive(Debug, Clone, Deserialize, Serialize)]
278pub struct SecurityConfig {
279    /// Required. List of CIDR network ranges whose senders are allowed to
280    /// relay mail through this server without authentication
281    /// (e.g. `["127.0.0.0/8", "10.0.0.0/8"]`).
282    pub relay_networks: Vec<String>,
283
284    /// Required. List of IP addresses that are unconditionally blocked from
285    /// connecting. Both IPv4 and IPv6 addresses are accepted.
286    pub blocked_ips: Vec<String>,
287
288    /// Required. When `true`, incoming mail is checked to verify the recipient
289    /// mailbox exists before accepting the message.
290    pub check_recipient_exists: bool,
291
292    /// Required. When `true`, connections from senders whose reverse DNS
293    /// lookup fails or does not match are rejected.
294    pub reject_unknown_recipients: bool,
295}
296
297impl SecurityConfig {
298    /// Validate CIDR notation for relay networks.
299    pub fn validate_relay_networks(&self) -> anyhow::Result<()> {
300        for network in &self.relay_networks {
301            // Basic validation - should contain a slash for CIDR notation
302            if !network.contains('/') {
303                return Err(anyhow::anyhow!("Invalid CIDR notation: {}", network));
304            }
305        }
306        Ok(())
307    }
308
309    /// Validate IP addresses in blocked list.
310    pub fn validate_blocked_ips(&self) -> anyhow::Result<()> {
311        for ip in &self.blocked_ips {
312            // Basic validation - should contain dots (IPv4) or colons (IPv6)
313            if !ip.contains('.') && !ip.contains(':') {
314                return Err(anyhow::anyhow!("Invalid IP address: {}", ip));
315            }
316        }
317        Ok(())
318    }
319}
320
321// --------------------------------------------------------------------------
322// DomainsConfig
323// --------------------------------------------------------------------------
324
325/// Local domain and address alias configuration.
326#[derive(Debug, Clone, Deserialize, Serialize)]
327pub struct DomainsConfig {
328    /// Required. List of domain names for which this server accepts mail as
329    /// the final destination (e.g. `["example.com", "mail.example.com"]`).
330    pub local_domains: Vec<String>,
331
332    /// Default: `{}`. Mapping of source email address to destination email
333    /// address for simple address rewriting (e.g.
334    /// `"abuse@example.com" = "postmaster@example.com"`).
335    #[serde(default)]
336    pub aliases: HashMap<String, String>,
337}
338
339impl DomainsConfig {
340    /// Validate domain names.
341    pub fn validate_local_domains(&self) -> anyhow::Result<()> {
342        for domain in &self.local_domains {
343            if domain.is_empty() {
344                return Err(anyhow::anyhow!("Domain name cannot be empty"));
345            }
346            // Basic validation - should contain at least one dot
347            if !domain.contains('.') {
348                return Err(anyhow::anyhow!("Invalid domain name: {}", domain));
349            }
350        }
351        Ok(())
352    }
353
354    /// Validate alias email addresses.
355    pub fn validate_aliases(&self) -> anyhow::Result<()> {
356        for (from, to) in &self.aliases {
357            if !from.contains('@') {
358                return Err(anyhow::anyhow!("Invalid alias source: {}", from));
359            }
360            if !to.contains('@') {
361                return Err(anyhow::anyhow!("Invalid alias destination: {}", to));
362            }
363        }
364        Ok(())
365    }
366}
367
368// --------------------------------------------------------------------------
369// MetricsConfig / MetricsBasicAuthConfig
370// --------------------------------------------------------------------------
371
372/// Prometheus metrics scrape endpoint configuration.
373#[derive(Debug, Clone, Deserialize, Serialize)]
374pub struct MetricsConfig {
375    /// Required. When `true`, the metrics HTTP endpoint is started on
376    /// `bind_address`.
377    pub enabled: bool,
378
379    /// Required. Socket address on which the metrics HTTP server listens
380    /// (e.g. `"0.0.0.0:9090"`). Must contain a colon separating host and port.
381    pub bind_address: String,
382
383    /// Required. URL path at which Prometheus can scrape metrics
384    /// (e.g. `"/metrics"`). Must start with `'/'`.
385    pub path: String,
386
387    /// Optional HTTP Basic auth on the scrape endpoint.
388    ///
389    /// When present, the metrics handler verifies the `Authorization: Basic`
390    /// header against the configured bcrypt hash (RFC 7617). Returns 401 on
391    /// missing/invalid credentials.
392    #[serde(default, skip_serializing_if = "Option::is_none")]
393    pub basic_auth: Option<MetricsBasicAuthConfig>,
394}
395
396/// Optional HTTP Basic authentication for the metrics scrape endpoint.
397///
398/// The password is stored as a bcrypt hash (RFC 7617 + bcrypt §3) so the
399/// plaintext password never lives at rest. Use `bcrypt::hash(password,
400/// bcrypt::DEFAULT_COST)` to generate or `htpasswd -B -n username` from a
401/// shell.
402#[derive(Debug, Clone, Deserialize, Serialize)]
403pub struct MetricsBasicAuthConfig {
404    /// Required username.
405    pub username: String,
406    /// bcrypt-hashed password.
407    pub password_hash: String,
408}
409
410impl MetricsConfig {
411    /// Validate bind address format.
412    pub fn validate_bind_address(&self) -> anyhow::Result<()> {
413        if !self.bind_address.contains(':') {
414            return Err(anyhow::anyhow!(
415                "Invalid bind address format: {}",
416                self.bind_address
417            ));
418        }
419        Ok(())
420    }
421
422    /// Validate path format.
423    pub fn validate_path(&self) -> anyhow::Result<()> {
424        if !self.path.starts_with('/') {
425            return Err(anyhow::anyhow!(
426                "Metrics path must start with '/': {}",
427                self.path
428            ));
429        }
430        Ok(())
431    }
432}
433
434// --------------------------------------------------------------------------
435// TracingConfig / OtlpProtocol
436// --------------------------------------------------------------------------
437
438/// OpenTelemetry OTLP distributed tracing configuration.
439#[derive(Debug, Clone, Deserialize, Serialize)]
440pub struct TracingConfig {
441    /// Required. When `true`, span data is exported to the configured
442    /// OTLP `endpoint`.
443    pub enabled: bool,
444
445    /// Required. OTLP exporter endpoint URL (e.g. `"http://localhost:4317"`
446    /// for gRPC or `"http://localhost:4318"` for HTTP). Must start with
447    /// `http://` or `https://`.
448    pub endpoint: String,
449
450    /// Required. OTLP transport protocol. Valid values: `"grpc"`, `"http"`.
451    pub protocol: OtlpProtocol,
452
453    /// Required. Service name recorded on every emitted span.
454    pub service_name: String,
455
456    /// Default: `1.0`. Fraction of traces to sample, in the range `0.0`
457    /// (no traces) to `1.0` (all traces).
458    #[serde(default)]
459    pub sample_ratio: f64,
460}
461
462/// OTLP protocol type.
463#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
464#[serde(rename_all = "lowercase")]
465pub enum OtlpProtocol {
466    Grpc,
467    Http,
468}
469
470impl Default for TracingConfig {
471    fn default() -> Self {
472        Self {
473            enabled: false,
474            endpoint: "http://localhost:4317".to_string(),
475            protocol: OtlpProtocol::Grpc,
476            service_name: "rusmes".to_string(),
477            sample_ratio: 1.0,
478        }
479    }
480}
481
482impl TracingConfig {
483    /// Validate endpoint URL format.
484    pub fn validate_endpoint(&self) -> anyhow::Result<()> {
485        if !self.endpoint.starts_with("http://") && !self.endpoint.starts_with("https://") {
486            return Err(anyhow::anyhow!(
487                "Endpoint must start with http:// or https://: {}",
488                self.endpoint
489            ));
490        }
491        Ok(())
492    }
493
494    /// Validate sample ratio.
495    pub fn validate_sample_ratio(&self) -> anyhow::Result<()> {
496        if !(0.0..=1.0).contains(&self.sample_ratio) {
497            return Err(anyhow::anyhow!(
498                "Sample ratio must be between 0.0 and 1.0: {}",
499                self.sample_ratio
500            ));
501        }
502        Ok(())
503    }
504
505    /// Validate service name.
506    pub fn validate_service_name(&self) -> anyhow::Result<()> {
507        if self.service_name.trim().is_empty() {
508            return Err(anyhow::anyhow!("Service name cannot be empty"));
509        }
510        Ok(())
511    }
512}