Skip to main content

mail_laser/config/
mod.rs

1//! Manages application configuration loaded from environment variables.
2//!
3//! This module defines the `Config` struct which holds all runtime settings
4//! and provides the `from_env` function to populate this struct. It supports
5//! loading variables from a `.env` file via the `dotenv` crate and provides
6//! default values for optional settings.
7
8use anyhow::{anyhow, Result};
9use serde::{Deserialize, Serialize};
10use std::env;
11
12/// Holds the application's runtime configuration settings.
13///
14/// These settings are typically loaded from environment variables via `from_env`.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Config {
17    /// The list of email addresses MailLaser will accept mail for. (Required: `MAIL_LASER_TARGET_EMAILS`, comma-separated)
18    pub target_emails: Vec<String>,
19
20    /// The URL where the extracted email payload will be sent via POST request. (Required: `MAIL_LASER_WEBHOOK_URL`)
21    pub webhook_url: String,
22
23    /// The IP address the SMTP server should listen on. (Optional: `MAIL_LASER_BIND_ADDRESS`, Default: "0.0.0.0")
24    pub smtp_bind_address: String,
25
26    /// The network port the SMTP server should listen on. (Optional: `MAIL_LASER_PORT`, Default: 2525)
27    pub smtp_port: u16,
28
29    /// The IP address the health check HTTP server should listen on. (Optional: `MAIL_LASER_HEALTH_BIND_ADDRESS`, Default: "0.0.0.0")
30    pub health_check_bind_address: String,
31
32    /// The network port the health check HTTP server should listen on. (Optional: `MAIL_LASER_HEALTH_PORT`, Default: 8080)
33    pub health_check_port: u16,
34
35    /// Header name prefixes to match and forward in the webhook payload.
36    /// (Optional: `MAIL_LASER_HEADER_PREFIX`, comma-separated, Default: empty)
37    pub header_prefixes: Vec<String>,
38
39    /// Webhook request timeout in seconds. (Optional: `MAIL_LASER_WEBHOOK_TIMEOUT`, Default: 30)
40    pub webhook_timeout_secs: u64,
41
42    /// Max retry attempts on webhook delivery failure. (Optional: `MAIL_LASER_WEBHOOK_MAX_RETRIES`, Default: 3)
43    pub webhook_max_retries: u32,
44
45    /// Consecutive failures required to open the circuit breaker. (Optional: `MAIL_LASER_CIRCUIT_BREAKER_THRESHOLD`, Default: 5)
46    pub circuit_breaker_threshold: u32,
47
48    /// Seconds before a tripped circuit breaker half-opens. (Optional: `MAIL_LASER_CIRCUIT_BREAKER_RESET`, Default: 60)
49    pub circuit_breaker_reset_secs: u64,
50}
51
52impl Config {
53    /// Loads configuration settings from environment variables.
54    ///
55    /// Reads variables prefixed with `MAIL_LASER_`. Supports loading from a `.env` file
56    /// if present. Provides default values for bind addresses and ports if not specified.
57    /// Logs the configuration values being used.
58    ///
59    /// # Errors
60    ///
61    /// Returns an `Err` if:
62    /// - Required environment variables (`MAIL_LASER_TARGET_EMAILS`, `MAIL_LASER_WEBHOOK_URL`) are missing or `MAIL_LASER_TARGET_EMAILS` is empty/invalid.
63    /// - Optional port variables (`MAIL_LASER_PORT`, `MAIL_LASER_HEALTH_PORT`) are set but cannot be parsed as `u16`.
64    pub fn from_env() -> Result<Self> {
65        // Attempt to load variables from a .env file, if it exists. Ignore errors.
66        let _ = dotenv::dotenv();
67
68        // --- Required Variables ---
69        // --- Required Variables ---
70        let target_emails_str = match env::var("MAIL_LASER_TARGET_EMAILS") {
71            Ok(val) => val,
72            Err(e) => {
73                let err_msg = "MAIL_LASER_TARGET_EMAILS environment variable must be set";
74                log::error!("{}: {}", err_msg, e);
75                return Err(anyhow!(e).context(err_msg));
76            }
77        };
78
79        // Parse the comma-separated string into a Vec<String>, trimming whitespace
80        let target_emails: Vec<String> = target_emails_str
81            .split(',')
82            .map(|email| email.trim().to_string()) // Trim whitespace from each part
83            .filter(|email| !email.is_empty()) // Remove any empty strings resulting from extra commas or whitespace
84            .collect();
85
86        // Ensure at least one valid email was provided
87        if target_emails.is_empty() {
88            let err_msg = if target_emails_str.trim().is_empty() {
89                "MAIL_LASER_TARGET_EMAILS cannot be empty"
90            } else {
91                "MAIL_LASER_TARGET_EMAILS must contain at least one valid email after trimming and splitting"
92            };
93            log::error!("{}", err_msg);
94            return Err(anyhow!(err_msg.to_string()));
95        }
96
97        log::info!("Config: Using target_emails: {:?}", target_emails);
98
99        let webhook_url = match env::var("MAIL_LASER_WEBHOOK_URL") {
100            Ok(val) => val,
101            Err(e) => {
102                let err_msg = "MAIL_LASER_WEBHOOK_URL environment variable must be set";
103                log::error!("{}: {}", err_msg, e); // Log specific error before returning
104                return Err(anyhow!(e).context(err_msg));
105            }
106        };
107        log::info!("Config: Using webhook_url: {}", webhook_url);
108
109        // --- Optional Variables with Defaults ---
110        let smtp_bind_address = env::var("MAIL_LASER_BIND_ADDRESS")
111            .map(|val| {
112                log::info!("Config: Using smtp_bind_address from env: {}", val);
113                val
114            })
115            .unwrap_or_else(|_| {
116                let default_val = "0.0.0.0".to_string();
117                log::info!("Config: Using default smtp_bind_address: {}", default_val);
118                default_val // Default: Listen on all interfaces
119            });
120
121        let smtp_port_str = env::var("MAIL_LASER_PORT").unwrap_or_else(|_| "2525".to_string()); // Default SMTP port
122        let smtp_port = match smtp_port_str.parse::<u16>() {
123            Ok(port) => port,
124            Err(e) => {
125                let err_msg = format!(
126                    "MAIL_LASER_PORT ('{}') must be a valid u16 port number",
127                    smtp_port_str
128                );
129                log::error!("{}: {}", err_msg, e); // Log specific error before returning
130                return Err(anyhow!(e).context(err_msg));
131            }
132        };
133        log::info!("Config: Using smtp_port: {}", smtp_port);
134
135        let health_check_bind_address = env::var("MAIL_LASER_HEALTH_BIND_ADDRESS")
136            .map(|val| {
137                log::info!("Config: Using health_check_bind_address from env: {}", val);
138                val
139            })
140            .unwrap_or_else(|_| {
141                let default_val = "0.0.0.0".to_string();
142                log::info!(
143                    "Config: Using default health_check_bind_address: {}",
144                    default_val
145                );
146                default_val // Default: Listen on all interfaces
147            });
148
149        let health_check_port_str =
150            env::var("MAIL_LASER_HEALTH_PORT").unwrap_or_else(|_| "8080".to_string()); // Default health check port
151        let health_check_port = match health_check_port_str.parse::<u16>() {
152            Ok(port) => port,
153            Err(e) => {
154                let err_msg = format!(
155                    "MAIL_LASER_HEALTH_PORT ('{}') must be a valid u16 port number",
156                    health_check_port_str
157                );
158                log::error!("{}: {}", err_msg, e); // Log specific error before returning
159                return Err(anyhow!(e).context(err_msg));
160            }
161        };
162        log::info!("Config: Using health_check_port: {}", health_check_port);
163
164        // --- Optional: Header Prefixes ---
165        let header_prefixes: Vec<String> = env::var("MAIL_LASER_HEADER_PREFIX")
166            .map(|val| {
167                val.split(',')
168                    .map(|prefix| prefix.trim().to_string())
169                    .filter(|prefix| !prefix.is_empty())
170                    .collect()
171            })
172            .unwrap_or_default();
173        log::info!("Config: Using header_prefixes: {:?}", header_prefixes);
174
175        // --- Optional: Resilience settings ---
176        let webhook_timeout_secs: u64 = env::var("MAIL_LASER_WEBHOOK_TIMEOUT")
177            .unwrap_or_else(|_| "30".to_string())
178            .parse()
179            .map_err(|e| anyhow!("MAIL_LASER_WEBHOOK_TIMEOUT must be a valid u64: {}", e))?;
180        log::info!(
181            "Config: Using webhook_timeout_secs: {}",
182            webhook_timeout_secs
183        );
184
185        let webhook_max_retries: u32 = env::var("MAIL_LASER_WEBHOOK_MAX_RETRIES")
186            .unwrap_or_else(|_| "3".to_string())
187            .parse()
188            .map_err(|e| anyhow!("MAIL_LASER_WEBHOOK_MAX_RETRIES must be a valid u32: {}", e))?;
189        log::info!("Config: Using webhook_max_retries: {}", webhook_max_retries);
190
191        let circuit_breaker_threshold: u32 = env::var("MAIL_LASER_CIRCUIT_BREAKER_THRESHOLD")
192            .unwrap_or_else(|_| "5".to_string())
193            .parse()
194            .map_err(|e| {
195                anyhow!(
196                    "MAIL_LASER_CIRCUIT_BREAKER_THRESHOLD must be a valid u32: {}",
197                    e
198                )
199            })?;
200        log::info!(
201            "Config: Using circuit_breaker_threshold: {}",
202            circuit_breaker_threshold
203        );
204
205        let circuit_breaker_reset_secs: u64 = env::var("MAIL_LASER_CIRCUIT_BREAKER_RESET")
206            .unwrap_or_else(|_| "60".to_string())
207            .parse()
208            .map_err(|e| {
209                anyhow!(
210                    "MAIL_LASER_CIRCUIT_BREAKER_RESET must be a valid u64: {}",
211                    e
212                )
213            })?;
214        log::info!(
215            "Config: Using circuit_breaker_reset_secs: {}",
216            circuit_breaker_reset_secs
217        );
218
219        // Construct the final Config object
220        Ok(Config {
221            target_emails,
222            webhook_url,
223            smtp_bind_address,
224            smtp_port,
225            health_check_bind_address,
226            health_check_port,
227            header_prefixes,
228            webhook_timeout_secs,
229            webhook_max_retries,
230            circuit_breaker_threshold,
231            circuit_breaker_reset_secs,
232        })
233    }
234}
235
236// The inline tests module has been moved to src/config/tests.rs
237// and is included via `mod tests;` below.
238
239// Include the tests defined in tests.rs
240mod tests;