Skip to main content

http_smtp_rele/
config.rs

1//! Configuration loading and validation.
2//!
3//! Config is read from a TOML file at startup. Invalid configuration causes
4//! immediate process termination (fail-fast).
5//!
6//! # Schema overview
7//!
8//! ```toml
9//! [server]
10//! bind_address = "127.0.0.1:8080"
11//!
12//! [security]
13//! //! trust_proxy_headers = false
14//! trusted_source_cidrs = ["127.0.0.1/32"]
15//!
16//! [[security.api_keys]]
17//! id = "svc-a"
18//! secret = "tok-...xxxxxxxxxxxxxxxxxxxxxxxxx"
19//! enabled = true
20//!
21//! [mail]
22//! default_from = "relay@example.com"
23//!
24//! [smtp]
25//! host = "127.0.0.1"
26//! port = 25
27//!
28//! [rate_limit]
29//!
30//! [logging]
31//! ```
32
33use std::fmt;
34use std::path::Path;
35
36use lettre::Address;
37use serde::Deserialize;
38use thiserror::Error;
39
40// ---------------------------------------------------------------------------
41// SecretString
42// ---------------------------------------------------------------------------
43
44/// An opaque string that is never printed in logs or debug output.
45///
46/// Used to store API key secrets from config. The underlying value is
47/// accessible only via [`SecretString::expose`].
48#[derive(Clone, Deserialize)]
49#[serde(transparent)]
50pub struct SecretString(String);
51
52impl SecretString {
53    pub fn new(s: impl Into<String>) -> Self {
54        Self(s.into())
55    }
56
57    /// Return the underlying secret value.
58    ///
59    /// Callers must not log, store, or transmit the returned value.
60    pub fn expose(&self) -> &str {
61        &self.0
62    }
63}
64
65impl fmt::Debug for SecretString {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        f.write_str("[REDACTED]")
68    }
69}
70
71impl fmt::Display for SecretString {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        f.write_str("[REDACTED]")
74    }
75}
76
77// ---------------------------------------------------------------------------
78// Config structs
79// ---------------------------------------------------------------------------
80
81/// Top-level application configuration.
82
83/// Submission status tracking configuration (RFC 086, 087).
84#[derive(Debug, Clone, Deserialize)]
85pub struct StatusConfig {
86    /// Enable status tracking. When false, no records are created.
87    /// Requires restart to change.
88    #[serde(default = "default_status_enabled")]
89    pub enabled: bool,
90    /// Backend: "memory" only in MVP. Requires restart to change.
91    #[serde(default = "default_status_store")]
92    pub store: String,
93    /// Record time-to-live in seconds. SIGHUP-reloadable.
94    #[serde(default = "default_status_ttl_seconds")]
95    pub ttl_seconds: u64,
96    /// Maximum records in store. SIGHUP-reloadable.
97    #[serde(default = "default_status_max_records")]
98    pub max_records: usize,
99    /// Background cleanup interval in seconds. SIGHUP-reloadable.
100    #[serde(default = "default_status_cleanup_interval_seconds")]
101    pub cleanup_interval_seconds: u64,
102    /// Path to the SQLite database file. Required when `store = "sqlite"`.
103    /// The parent directory must exist; the file is created on first run.
104    pub db_path: Option<std::path::PathBuf>,
105    /// Redis/Valkey URL for the shared status store. Required when `store = "redis"`.
106    /// Example: `redis://127.0.0.1:6379/0` or `redis+unix:///var/run/redis/redis.sock`.
107    pub redis_url: Option<String>,
108}
109
110fn default_status_enabled() -> bool { true }
111fn default_status_store() -> String { "memory".into() }
112fn default_status_ttl_seconds() -> u64 { 3600 }
113fn default_status_max_records() -> usize { 10_000 }
114fn default_status_cleanup_interval_seconds() -> u64 { 60 }
115
116#[derive(Debug, Clone, Deserialize)]
117pub struct AppConfig {
118    pub server:     ServerConfig,
119    pub security:   SecurityConfig,
120    pub mail:       MailConfig,
121    pub smtp:       SmtpConfig,
122    #[serde(default)]
123    pub rate_limit: RateLimitConfig,
124    #[serde(default)]
125    pub logging:    LoggingConfig,
126    #[serde(default)]
127    pub status:     StatusConfig,
128}
129
130impl Default for StatusConfig {
131    fn default() -> Self {
132        Self {
133            enabled: default_status_enabled(),
134            store: default_status_store(),
135            ttl_seconds: default_status_ttl_seconds(),
136            max_records: default_status_max_records(),
137            cleanup_interval_seconds: default_status_cleanup_interval_seconds(),
138            db_path: None,
139            redis_url: None,
140        }
141    }
142}
143
144#[derive(Debug, Clone, Deserialize)]
145pub struct ServerConfig {
146    pub bind_address: String,
147    #[serde(default = "default_max_request_body_bytes")]
148    pub max_request_body_bytes: usize,
149    #[serde(default = "default_request_timeout_seconds")]
150    pub request_timeout_seconds: u64,
151    #[serde(default = "default_shutdown_timeout_seconds")]
152    pub shutdown_timeout_seconds: u64,
153    /// Maximum concurrent in-flight requests. 0 = unlimited.
154    #[serde(default)]
155    pub concurrency_limit: usize,
156    /// PEM certificate for HTTPS (RFC 712). Both cert and key must be set.
157    pub tls_cert: Option<std::path::PathBuf>,
158    /// PEM private key for HTTPS (RFC 712). Both cert and key must be set.
159    pub tls_key:  Option<std::path::PathBuf>,
160    /// CIDRs allowed to access /metrics and /readyz without authentication.
161    /// Default: loopback only (RFC 822).
162    #[serde(default = "default_monitoring_cidrs")]
163    pub monitoring_cidrs: Vec<String>,
164}
165
166#[derive(Debug, Clone, Deserialize)]
167pub struct SecurityConfig {
168    /// When true, read `X-Forwarded-For` to resolve client IP.
169    /// Only applies when the peer IP is listed in `trusted_source_cidrs`.
170    #[serde(default)]
171    pub trust_proxy_headers: bool,
172    /// CIDRs whose X-Forwarded-For headers may be trusted for IP resolution.
173    /// Distinct from `allowed_source_cidrs` — see security model.
174    #[serde(default)]
175    pub trusted_source_cidrs: Vec<String>,
176    /// CIDRs that are permitted to connect at all (empty = allow all source IPs).
177    /// Applied after IP resolution; independent of proxy header trust.
178    #[serde(default)]
179    pub allowed_source_cidrs: Vec<String>,
180    #[serde(default)]
181    pub api_keys: Vec<ApiKeyConfig>,
182}
183
184/// Per-API-key configuration entry.
185#[derive(Debug, Clone, Deserialize)]
186pub struct ApiKeyConfig {
187    pub id: String,
188    pub secret: SecretString,
189    #[serde(default = "default_true")]
190    pub enabled: bool,
191    pub description: Option<String>,
192    /// Recipient domain allowlist for this key (empty = use global policy).
193    #[serde(default)]
194    pub allowed_recipient_domains: Vec<String>,
195    /// Exact recipient address allowlist (empty = domain-level policy only).
196    /// Takes precedence over `allowed_recipient_domains` when non-empty.
197    #[serde(default)]
198    pub allowed_recipients: Vec<String>,
199    /// Per-key sustained rate (tokens/minute). None = inherit `[rate_limit].per_key_per_min`.
200    pub rate_limit_per_min: Option<u32>,
201    /// Per-key burst override. 0 = inherit `[rate_limit].per_key_burst`.
202    #[serde(default)]
203    pub burst: u32,
204    /// Override global `[logging].mask_recipient` for this key (RFC 603).
205    /// `None` = inherit global setting.
206    pub mask_recipient: Option<bool>,
207}
208
209#[derive(Debug, Clone, Deserialize)]
210pub struct MailConfig {
211    pub default_from: String,
212    pub default_from_name: Option<String>,
213    /// Global recipient domain allowlist (empty = allow all domains).
214    #[serde(default)]
215    pub allowed_recipient_domains: Vec<String>,
216    #[serde(default = "default_max_subject_chars")]
217    pub max_subject_chars: usize,
218    #[serde(default = "default_max_body_bytes")]
219    pub max_body_bytes: usize,
220    /// Maximum number of recipients per request. Default 10.
221    #[serde(default = "default_max_recipients")]
222    pub max_recipients: usize,
223    /// Maximum number of attachments per request (RFC 502).
224    #[serde(default = "default_max_attachments")]
225    pub max_attachments: usize,
226    /// Maximum decoded size per attachment in bytes (RFC 502). Default 10 MiB.
227    #[serde(default = "default_max_attachment_bytes")]
228    pub max_attachment_bytes: usize,
229    /// Maximum number of messages per `POST /v1/send-bulk` request (RFC 701).
230    #[serde(default = "default_max_bulk_messages")]
231    pub max_bulk_messages: usize,
232    /// Allow HTML body in submissions (RFC 823 — default: false for attack surface reduction).
233    #[serde(default)]
234    pub allow_html_body: bool,
235    /// Allow file attachments in submissions (RFC 823 — default: false).
236    #[serde(default)]
237    pub allow_attachments: bool,
238    /// Allow bulk send via POST /v1/send-bulk (RFC 823 — default: false).
239    #[serde(default)]
240    pub allow_bulk_send: bool,
241    /// Aggregate maximum decoded bytes across all attachments in one request (RFC 826).
242    /// `None` = no aggregate limit (only per-attachment limit applies).
243    pub max_total_attachment_bytes: Option<usize>,
244}
245
246#[derive(Debug, Clone, Deserialize)]
247pub struct SmtpConfig {
248    #[serde(default = "default_smtp_mode")]
249    pub mode: String,
250    /// TLS mode: "none" (plain TCP), "starttls" (STARTTLS), or "tls" (implicit TLS).
251    #[serde(default = "default_smtp_tls")]
252    pub tls: String,
253    #[serde(default = "default_smtp_host")]
254    pub host: String,
255    #[serde(default = "default_smtp_port")]
256    pub port: u16,
257    #[serde(default = "default_connect_timeout_seconds")]
258    pub connect_timeout_seconds: u64,
259    #[serde(default = "default_submission_timeout_seconds")]
260    pub submission_timeout_seconds: u64,
261    /// SMTP AUTH username. Must be set together with `auth_password` (RFC 301).
262    pub auth_user: Option<String>,
263    /// SMTP AUTH password. Never logged. Must be set together with `auth_user`.
264    pub auth_password: Option<SecretString>,
265    /// Command for pipe mode. Only used when `mode = "pipe"` (RFC 304).
266    #[serde(default = "default_pipe_command")]
267    pub pipe_command: String,
268    /// Max concurrent SMTP submissions per bulk request (RFC 711).
269    /// 0 = unlimited. Default: 5.
270    #[serde(default = "default_bulk_concurrency")]
271    pub bulk_concurrency: usize,
272}
273
274#[derive(Debug, Clone, Deserialize)]
275pub struct RateLimitConfig {
276    // Sustained rates (tokens/minute)
277    #[serde(default = "default_global_per_min")]
278    pub global_per_min: u32,
279    #[serde(default = "default_per_ip_per_min")]
280    pub per_ip_per_min: u32,
281    /// Default per-key rate. Overridden by `ApiKeyConfig.rate_limit_per_min`.
282    #[serde(default = "default_per_key_per_min")]
283    pub per_key_per_min: u32,
284
285    // Burst capacities (tokens a fresh bucket starts with)
286    #[serde(default = "default_global_burst")]
287    pub global_burst: u32,
288    #[serde(default = "default_per_ip_burst")]
289    pub per_ip_burst: u32,
290    /// Default per-key burst. Overridden by `ApiKeyConfig.burst` when > 0.
291    #[serde(default = "default_per_key_burst")]
292    pub per_key_burst: u32,
293
294    /// Legacy field — sets all three burst values if the per-tier fields are absent.
295    /// Deprecated; use `global_burst`, `per_ip_burst`, `per_key_burst` instead.
296    #[serde(default)]
297    pub burst_size: u32,
298
299    /// Maximum entries in the per-IP bucket map; LRU eviction above this.
300    /// 0 = unlimited (not recommended in production).
301    #[serde(default = "default_ip_table_size")]
302    pub ip_table_size: usize,
303}
304
305impl RateLimitConfig {
306    /// Effective global burst: per-tier value if set, else legacy `burst_size`, else default.
307    pub fn effective_global_burst(&self) -> u32 {
308        if self.global_burst > 0 { self.global_burst }
309        else if self.burst_size > 0 { self.burst_size }
310        else { default_global_burst() }
311    }
312    pub fn effective_per_ip_burst(&self) -> u32 {
313        if self.per_ip_burst > 0 { self.per_ip_burst }
314        else if self.burst_size > 0 { self.burst_size }
315        else { default_per_ip_burst() }
316    }
317    pub fn effective_per_key_burst(&self) -> u32 {
318        if self.per_key_burst > 0 { self.per_key_burst }
319        else if self.burst_size > 0 { self.burst_size }
320        else { default_per_key_burst() }
321    }
322}
323
324#[derive(Debug, Clone, Deserialize)]
325pub struct LoggingConfig {
326    /// Output format: `"text"` (default) or `"json"`.
327    #[serde(default = "default_log_format")]
328    pub format: String,
329    #[serde(default = "default_log_level")]
330    pub level: String,
331    /// When true, mask the recipient address in audit log entries.
332    #[serde(default)]
333    pub mask_recipient: bool,
334}
335
336// ---------------------------------------------------------------------------
337// Default value functions
338// ---------------------------------------------------------------------------
339
340fn default_max_request_body_bytes() -> usize { 1_048_576 }
341fn default_request_timeout_seconds() -> u64 { 30 }
342fn default_shutdown_timeout_seconds() -> u64 { 30 }
343fn default_true() -> bool { true }
344fn default_max_subject_chars() -> usize { 255 }
345fn default_max_body_bytes() -> usize { 65_536 }
346fn default_smtp_mode() -> String { "smtp".into() }
347fn default_smtp_host() -> String { "127.0.0.1".into() }
348fn default_smtp_port() -> u16 { 25 }
349fn default_connect_timeout_seconds() -> u64 { 5 }
350fn default_submission_timeout_seconds() -> u64 { 30 }
351fn default_bulk_concurrency() -> usize { 5 }
352fn default_global_per_min() -> u32 { 60 }
353fn default_per_ip_per_min() -> u32 { 20 }
354#[allow(dead_code)]
355fn default_burst_size() -> u32 { 5 }
356fn default_max_recipients() -> usize { 10 }
357fn default_max_attachments() -> usize { 5 }
358fn default_max_attachment_bytes() -> usize { 10 * 1024 * 1024 } // 10 MiB
359fn default_pipe_command() -> String { "/usr/sbin/sendmail".into() }
360fn default_smtp_tls() -> String { "none".into() }
361fn default_global_burst() -> u32 { 10 }
362fn default_per_ip_burst() -> u32 { 5 }
363fn default_per_key_burst() -> u32 { 5 }
364fn default_per_key_per_min() -> u32 { 30 }
365fn default_ip_table_size() -> usize { 10_000 }
366fn default_log_format() -> String { "text".into() }
367fn default_log_level() -> String { "info".into() }
368
369// ---------------------------------------------------------------------------
370// Config error
371// ---------------------------------------------------------------------------
372
373#[derive(Debug, Error)]
374pub enum ConfigError {
375    #[error("cannot read config file: {0}")]
376    Io(#[from] std::io::Error),
377
378    #[error("config parse error: {0}")]
379    Parse(#[from] toml::de::Error),
380
381    #[error("invalid server.bind_address: must be host:port (e.g. 127.0.0.1:8080)")]
382    InvalidBindAddress,
383
384    #[error("invalid mail.default_from: must be a valid email address")]
385    InvalidDefaultFrom,
386    #[error("no api_keys are configured")]
387    NoApiKeys,
388
389    #[error("no api_keys entries have enabled = true")]
390    NoEnabledApiKeys,
391
392    #[error("invalid CIDR: {0}")]
393    InvalidCidr(String),
394
395    #[error("configuration error: {0}")]
396    Validation(String),
397
398    #[error("invalid smtp.port: must be 1-65535")]
399    InvalidSmtpPort,
400
401    #[error("invalid rate_limit values: all per_min values must be > 0")]
402    InvalidRateLimit,
403
404    #[error("invalid logging.level: must be trace, debug, info, warn, or error")]
405    InvalidLogLevel,
406
407    #[error("invalid logging.format: must be 'text' or 'json'")]
408    InvalidLogFormat,
409}
410
411// ---------------------------------------------------------------------------
412// Default impls (needed for #[serde(default)] on AppConfig fields)
413// ---------------------------------------------------------------------------
414
415pub mod validate;
416pub use validate::{load, load_from_str, validate_config};
417
418fn default_max_bulk_messages() -> usize { 10 }
419
420fn default_monitoring_cidrs() -> Vec<String> { vec!["127.0.0.1/32".into(), "::1/128".into()] }
421
422// ---------------------------------------------------------------------------
423// RFC 811: SIGHUP reload boundary helpers
424// ---------------------------------------------------------------------------
425
426/// Returns a list of field names that changed and require a restart.
427///
428/// Reloadable fields: mail.*, security.api_keys, status.ttl_seconds,
429/// status.max_records, status.cleanup_interval_seconds, logging.*
430pub fn restart_required_changes(old: &AppConfig, new: &AppConfig) -> Vec<String> {
431    let mut changed = Vec::new();
432
433    macro_rules! check {
434        ($field:expr, $label:literal) => {
435            if $field { changed.push($label.into()); }
436        };
437    }
438
439    check!(old.server.bind_address        != new.server.bind_address,        "server.bind_address");
440    check!(old.server.request_timeout_seconds != new.server.request_timeout_seconds, "server.request_timeout_seconds");
441    check!(old.server.max_request_body_bytes  != new.server.max_request_body_bytes,  "server.max_request_body_bytes");
442    check!(old.server.concurrency_limit   != new.server.concurrency_limit,    "server.concurrency_limit");
443    check!(old.smtp.host                  != new.smtp.host,                   "smtp.host");
444    check!(old.smtp.port                  != new.smtp.port,                   "smtp.port");
445    check!(old.smtp.mode                  != new.smtp.mode,                   "smtp.mode");
446    check!(old.rate_limit.global_per_min  != new.rate_limit.global_per_min,   "rate_limit.global_per_min");
447    check!(old.rate_limit.per_ip_per_min  != new.rate_limit.per_ip_per_min,   "rate_limit.per_ip_per_min");
448    check!(old.rate_limit.per_key_per_min != new.rate_limit.per_key_per_min,  "rate_limit.per_key_per_min");
449    check!(old.security.trust_proxy_headers   != new.security.trust_proxy_headers,   "security.trust_proxy_headers");
450    check!(old.security.trusted_source_cidrs  != new.security.trusted_source_cidrs,  "security.trusted_source_cidrs");
451    check!(old.security.allowed_source_cidrs  != new.security.allowed_source_cidrs,  "security.allowed_source_cidrs");
452    check!(old.status.enabled             != new.status.enabled,              "status.enabled");
453    check!(old.status.store               != new.status.store,                "status.store");
454    check!(old.status.db_path             != new.status.db_path,              "status.db_path");
455    check!(old.status.redis_url           != new.status.redis_url,            "status.redis_url");
456
457    changed
458}
459
460/// Build a merged config that applies only reloadable fields from `new`,
461/// keeping restart-required fields from `current`.
462pub fn merge_reloadable(current: &AppConfig, new: &AppConfig) -> AppConfig {
463    AppConfig {
464        // Restart-required: keep current values
465        server:     current.server.clone(),
466        smtp:       current.smtp.clone(),
467        rate_limit: current.rate_limit.clone(),
468        // Reloadable: take new values
469        security:   new.security.clone(),
470        mail:       new.mail.clone(),
471        logging:    new.logging.clone(),
472        status: StatusConfig {
473            // Non-reloadable status fields kept from current
474            enabled:  current.status.enabled,
475            store:    current.status.store.clone(),
476            db_path:  current.status.db_path.clone(),
477            redis_url: current.status.redis_url.clone(),
478            // Reloadable status fields from new
479            ttl_seconds:              new.status.ttl_seconds,
480            max_records:              new.status.max_records,
481            cleanup_interval_seconds: new.status.cleanup_interval_seconds,
482        },
483    }
484}