use std::fmt;
use std::path::Path;
use lettre::Address;
use serde::Deserialize;
use thiserror::Error;
#[derive(Clone, Deserialize)]
#[serde(transparent)]
pub struct SecretString(String);
impl SecretString {
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn expose(&self) -> &str {
&self.0
}
}
impl fmt::Debug for SecretString {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("[REDACTED]")
}
}
impl fmt::Display for SecretString {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("[REDACTED]")
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct StatusConfig {
#[serde(default = "default_status_enabled")]
pub enabled: bool,
#[serde(default = "default_status_store")]
pub store: String,
#[serde(default = "default_status_ttl_seconds")]
pub ttl_seconds: u64,
#[serde(default = "default_status_max_records")]
pub max_records: usize,
#[serde(default = "default_status_cleanup_interval_seconds")]
pub cleanup_interval_seconds: u64,
pub db_path: Option<std::path::PathBuf>,
pub redis_url: Option<String>,
}
fn default_status_enabled() -> bool { true }
fn default_status_store() -> String { "memory".into() }
fn default_status_ttl_seconds() -> u64 { 3600 }
fn default_status_max_records() -> usize { 10_000 }
fn default_status_cleanup_interval_seconds() -> u64 { 60 }
#[derive(Debug, Clone, Deserialize)]
pub struct AppConfig {
pub server: ServerConfig,
pub security: SecurityConfig,
pub mail: MailConfig,
pub smtp: SmtpConfig,
#[serde(default)]
pub rate_limit: RateLimitConfig,
#[serde(default)]
pub logging: LoggingConfig,
#[serde(default)]
pub status: StatusConfig,
}
impl Default for StatusConfig {
fn default() -> Self {
Self {
enabled: default_status_enabled(),
store: default_status_store(),
ttl_seconds: default_status_ttl_seconds(),
max_records: default_status_max_records(),
cleanup_interval_seconds: default_status_cleanup_interval_seconds(),
db_path: None,
redis_url: None,
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct ServerConfig {
pub bind_address: String,
#[serde(default = "default_max_request_body_bytes")]
pub max_request_body_bytes: usize,
#[serde(default = "default_request_timeout_seconds")]
pub request_timeout_seconds: u64,
#[serde(default = "default_shutdown_timeout_seconds")]
pub shutdown_timeout_seconds: u64,
#[serde(default)]
pub concurrency_limit: usize,
pub tls_cert: Option<std::path::PathBuf>,
pub tls_key: Option<std::path::PathBuf>,
#[serde(default = "default_monitoring_cidrs")]
pub monitoring_cidrs: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SecurityConfig {
#[serde(default)]
pub trust_proxy_headers: bool,
#[serde(default)]
pub trusted_source_cidrs: Vec<String>,
#[serde(default)]
pub allowed_source_cidrs: Vec<String>,
#[serde(default)]
pub api_keys: Vec<ApiKeyConfig>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ApiKeyConfig {
pub id: String,
pub secret: SecretString,
#[serde(default = "default_true")]
pub enabled: bool,
pub description: Option<String>,
#[serde(default)]
pub allowed_recipient_domains: Vec<String>,
#[serde(default)]
pub allowed_recipients: Vec<String>,
pub rate_limit_per_min: Option<u32>,
#[serde(default)]
pub burst: u32,
pub mask_recipient: Option<bool>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct MailConfig {
pub default_from: String,
pub default_from_name: Option<String>,
#[serde(default)]
pub allowed_recipient_domains: Vec<String>,
#[serde(default = "default_max_subject_chars")]
pub max_subject_chars: usize,
#[serde(default = "default_max_body_bytes")]
pub max_body_bytes: usize,
#[serde(default = "default_max_recipients")]
pub max_recipients: usize,
#[serde(default = "default_max_attachments")]
pub max_attachments: usize,
#[serde(default = "default_max_attachment_bytes")]
pub max_attachment_bytes: usize,
#[serde(default = "default_max_bulk_messages")]
pub max_bulk_messages: usize,
#[serde(default)]
pub allow_html_body: bool,
#[serde(default)]
pub allow_attachments: bool,
#[serde(default)]
pub allow_bulk_send: bool,
pub max_total_attachment_bytes: Option<usize>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SmtpConfig {
#[serde(default = "default_smtp_mode")]
pub mode: String,
#[serde(default = "default_smtp_tls")]
pub tls: String,
#[serde(default = "default_smtp_host")]
pub host: String,
#[serde(default = "default_smtp_port")]
pub port: u16,
#[serde(default = "default_connect_timeout_seconds")]
pub connect_timeout_seconds: u64,
#[serde(default = "default_submission_timeout_seconds")]
pub submission_timeout_seconds: u64,
pub auth_user: Option<String>,
pub auth_password: Option<SecretString>,
#[serde(default = "default_pipe_command")]
pub pipe_command: String,
#[serde(default = "default_bulk_concurrency")]
pub bulk_concurrency: usize,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RateLimitConfig {
#[serde(default = "default_global_per_min")]
pub global_per_min: u32,
#[serde(default = "default_per_ip_per_min")]
pub per_ip_per_min: u32,
#[serde(default = "default_per_key_per_min")]
pub per_key_per_min: u32,
#[serde(default = "default_global_burst")]
pub global_burst: u32,
#[serde(default = "default_per_ip_burst")]
pub per_ip_burst: u32,
#[serde(default = "default_per_key_burst")]
pub per_key_burst: u32,
#[serde(default)]
pub burst_size: u32,
#[serde(default = "default_ip_table_size")]
pub ip_table_size: usize,
}
impl RateLimitConfig {
pub fn effective_global_burst(&self) -> u32 {
if self.global_burst > 0 { self.global_burst }
else if self.burst_size > 0 { self.burst_size }
else { default_global_burst() }
}
pub fn effective_per_ip_burst(&self) -> u32 {
if self.per_ip_burst > 0 { self.per_ip_burst }
else if self.burst_size > 0 { self.burst_size }
else { default_per_ip_burst() }
}
pub fn effective_per_key_burst(&self) -> u32 {
if self.per_key_burst > 0 { self.per_key_burst }
else if self.burst_size > 0 { self.burst_size }
else { default_per_key_burst() }
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct LoggingConfig {
#[serde(default = "default_log_format")]
pub format: String,
#[serde(default = "default_log_level")]
pub level: String,
#[serde(default)]
pub mask_recipient: bool,
}
fn default_max_request_body_bytes() -> usize { 1_048_576 }
fn default_request_timeout_seconds() -> u64 { 30 }
fn default_shutdown_timeout_seconds() -> u64 { 30 }
fn default_true() -> bool { true }
fn default_max_subject_chars() -> usize { 255 }
fn default_max_body_bytes() -> usize { 65_536 }
fn default_smtp_mode() -> String { "smtp".into() }
fn default_smtp_host() -> String { "127.0.0.1".into() }
fn default_smtp_port() -> u16 { 25 }
fn default_connect_timeout_seconds() -> u64 { 5 }
fn default_submission_timeout_seconds() -> u64 { 30 }
fn default_bulk_concurrency() -> usize { 5 }
fn default_global_per_min() -> u32 { 60 }
fn default_per_ip_per_min() -> u32 { 20 }
#[allow(dead_code)]
fn default_burst_size() -> u32 { 5 }
fn default_max_recipients() -> usize { 10 }
fn default_max_attachments() -> usize { 5 }
fn default_max_attachment_bytes() -> usize { 10 * 1024 * 1024 } fn default_pipe_command() -> String { "/usr/sbin/sendmail".into() }
fn default_smtp_tls() -> String { "none".into() }
fn default_global_burst() -> u32 { 10 }
fn default_per_ip_burst() -> u32 { 5 }
fn default_per_key_burst() -> u32 { 5 }
fn default_per_key_per_min() -> u32 { 30 }
fn default_ip_table_size() -> usize { 10_000 }
fn default_log_format() -> String { "text".into() }
fn default_log_level() -> String { "info".into() }
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("cannot read config file: {0}")]
Io(#[from] std::io::Error),
#[error("config parse error: {0}")]
Parse(#[from] toml::de::Error),
#[error("invalid server.bind_address: must be host:port (e.g. 127.0.0.1:8080)")]
InvalidBindAddress,
#[error("invalid mail.default_from: must be a valid email address")]
InvalidDefaultFrom,
#[error("no api_keys are configured")]
NoApiKeys,
#[error("no api_keys entries have enabled = true")]
NoEnabledApiKeys,
#[error("invalid CIDR: {0}")]
InvalidCidr(String),
#[error("configuration error: {0}")]
Validation(String),
#[error("invalid smtp.port: must be 1-65535")]
InvalidSmtpPort,
#[error("invalid rate_limit values: all per_min values must be > 0")]
InvalidRateLimit,
#[error("invalid logging.level: must be trace, debug, info, warn, or error")]
InvalidLogLevel,
#[error("invalid logging.format: must be 'text' or 'json'")]
InvalidLogFormat,
}
pub mod validate;
pub use validate::{load, load_from_str, validate_config};
fn default_max_bulk_messages() -> usize { 10 }
fn default_monitoring_cidrs() -> Vec<String> { vec!["127.0.0.1/32".into(), "::1/128".into()] }
pub fn restart_required_changes(old: &AppConfig, new: &AppConfig) -> Vec<String> {
let mut changed = Vec::new();
macro_rules! check {
($field:expr, $label:literal) => {
if $field { changed.push($label.into()); }
};
}
check!(old.server.bind_address != new.server.bind_address, "server.bind_address");
check!(old.server.request_timeout_seconds != new.server.request_timeout_seconds, "server.request_timeout_seconds");
check!(old.server.max_request_body_bytes != new.server.max_request_body_bytes, "server.max_request_body_bytes");
check!(old.server.concurrency_limit != new.server.concurrency_limit, "server.concurrency_limit");
check!(old.smtp.host != new.smtp.host, "smtp.host");
check!(old.smtp.port != new.smtp.port, "smtp.port");
check!(old.smtp.mode != new.smtp.mode, "smtp.mode");
check!(old.rate_limit.global_per_min != new.rate_limit.global_per_min, "rate_limit.global_per_min");
check!(old.rate_limit.per_ip_per_min != new.rate_limit.per_ip_per_min, "rate_limit.per_ip_per_min");
check!(old.rate_limit.per_key_per_min != new.rate_limit.per_key_per_min, "rate_limit.per_key_per_min");
check!(old.security.trust_proxy_headers != new.security.trust_proxy_headers, "security.trust_proxy_headers");
check!(old.security.trusted_source_cidrs != new.security.trusted_source_cidrs, "security.trusted_source_cidrs");
check!(old.security.allowed_source_cidrs != new.security.allowed_source_cidrs, "security.allowed_source_cidrs");
check!(old.status.enabled != new.status.enabled, "status.enabled");
check!(old.status.store != new.status.store, "status.store");
check!(old.status.db_path != new.status.db_path, "status.db_path");
check!(old.status.redis_url != new.status.redis_url, "status.redis_url");
changed
}
pub fn merge_reloadable(current: &AppConfig, new: &AppConfig) -> AppConfig {
AppConfig {
server: current.server.clone(),
smtp: current.smtp.clone(),
rate_limit: current.rate_limit.clone(),
security: new.security.clone(),
mail: new.mail.clone(),
logging: new.logging.clone(),
status: StatusConfig {
enabled: current.status.enabled,
store: current.status.store.clone(),
db_path: current.status.db_path.clone(),
redis_url: current.status.redis_url.clone(),
ttl_seconds: new.status.ttl_seconds,
max_records: new.status.max_records,
cleanup_interval_seconds: new.status.cleanup_interval_seconds,
},
}
}