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}