email.rs 444 lignes · 15913 octets
//! Email configuration
//!
//! This module loads and validates email/SMTP configuration from environment variables:
//! - SMTP_HOST: SMTP server hostname (required)
//! - SMTP_PORT: SMTP server port (default: 587)
//! - SMTP_USER: SMTP username (required)
//! - SMTP_PASSWORD: SMTP password (required)
//! - SMTP_FROM: Sender email address (required)
//! - SMTP_FROM_NAME: Sender name (optional)
//! - SMTP_USE_TLS: Use TLS/SSL for SMTP connection (default: true)
//! - SMTP_USE_STARTTLS: Use STARTTLS (default: true)
//! - EMAIL_QUEUE_BATCH_SIZE: Batch size for queue processing (default: 10)
//! - EMAIL_QUEUE_RETRY_ATTEMPTS: Max retry attempts (default: 5)
//! - EMAIL_QUEUE_RETRY_DELAY_SECONDS: Delay between retries (default: 300)
//! - EMAIL_BASE_URL: Base URL for email links (required)
//!
//! IMAP configuration for bounce processing (Feature 012):
//! - IMAP_HOST: IMAP server hostname (optional, enables bounce processing if set)
//! - IMAP_PORT: IMAP server port (default: 993). Ports 25, 465 and 587 are rejected: they are SMTP.
//! - IMAP_USER: IMAP username (defaults to SMTP_USER)
//! - IMAP_PASSWORD: IMAP password (defaults to SMTP_PASSWORD)
//! - IMAP_USE_TLS: Use TLS for IMAP connection (default: true)
//! - IMAP_MAILBOX: Mailbox to check for bounces (default: INBOX)
//! - IMAP_POLL_INTERVAL_SECONDS: Interval between IMAP polls (default: 300)

use std::env;

/// Email configuration loaded from environment variables
#[derive(Debug, Clone)]
pub struct EmailConfig {
    /// SMTP server hostname
    pub smtp_host: String,
    /// SMTP server port
    pub smtp_port: u16,
    /// SMTP username (usually the email address)
    pub smtp_user: String,
    /// SMTP password
    pub smtp_password: String,
    /// Sender email address
    pub smtp_from: String,
    /// Sender name (optional)
    pub smtp_from_name: Option<String>,
    /// Use TLS/SSL for SMTP connection
    pub smtp_use_tls: bool,
    /// Use STARTTLS
    pub smtp_use_starttls: bool,
    /// Accept invalid/self-signed certificates for SMTP (⚠️ INSECURE - test only)
    pub smtp_accept_invalid_certs: bool,
    /// Path to custom CA certificate file (PEM format) for SMTP
    pub smtp_ca_cert_path: Option<String>,
    /// Email queue batch size
    pub queue_batch_size: usize,
    /// Maximum retry attempts for failed emails
    pub queue_retry_attempts: u32,
    /// Delay between retries in seconds
    pub queue_retry_delay_seconds: u64,
    /// Base URL for email links (e.g., `https://rustwarden.nuage.ebii:3000`)
    pub email_base_url: String,
    /// Request DSN (Delivery Status Notifications) when sending
    pub request_dsn: bool,

    // IMAP configuration for bounce processing
    /// IMAP server hostname (None = bounce processing disabled)
    pub imap_host: Option<String>,
    /// IMAP server port
    pub imap_port: u16,
    /// IMAP username
    pub imap_user: Option<String>,
    /// IMAP password
    pub imap_password: Option<String>,
    /// Use TLS for IMAP connection (direct TLS on port 993)
    pub imap_use_tls: bool,
    /// Use STARTTLS for IMAP connection (upgrade from plain, typically port 143)
    pub imap_use_starttls: bool,
    /// Accept invalid/self-signed certificates for IMAP (⚠️ INSECURE - test only)
    pub imap_accept_invalid_certs: bool,
    /// Path to custom CA certificate file (PEM format) for IMAP
    pub imap_ca_cert_path: Option<String>,
    /// Mailbox to check for bounces
    pub imap_mailbox: String,
    /// Interval between IMAP polls in seconds
    pub imap_poll_interval_seconds: u64,
}

impl EmailConfig {
    /// Load email configuration from environment variables
    ///
    /// # Environment Variables
    ///
    /// - `SMTP_HOST`: Required, SMTP server hostname
    /// - `SMTP_PORT`: Optional, default 587
    /// - `SMTP_USER`: Required, SMTP username (usually email address)
    /// - `SMTP_PASSWORD`: Required, SMTP password
    /// - `SMTP_FROM`: Required, sender email address
    /// - `SMTP_FROM_NAME`: Optional, sender name
    /// - `SMTP_USE_TLS`: Optional, default true
    /// - `SMTP_USE_STARTTLS`: Optional, default true
    /// - `EMAIL_QUEUE_BATCH_SIZE`: Optional, default 10
    /// - `EMAIL_QUEUE_RETRY_ATTEMPTS`: Optional, default 5
    /// - `EMAIL_QUEUE_RETRY_DELAY_SECONDS`: Optional, default 300
    /// - `EMAIL_BASE_URL`: Required, base URL for email links
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - Required environment variables are not set
    /// - SMTP_PORT is not a valid number
    /// - Configuration values are invalid
    ///
    /// # Examples
    ///
    /// ```no_run
    /// use rustwarden_core::config::email::EmailConfig;
    ///
    /// // Load configuration from environment
    /// let config = EmailConfig::from_env().expect("Failed to load email config");
    /// println!("SMTP host: {}", config.smtp_host);
    /// ```
    pub fn from_env() -> Result<Self, String> {
        // Required variables
        let smtp_host = env::var("SMTP_HOST")
            .map_err(|_| "SMTP_HOST environment variable is required".to_string())?;

        let smtp_user = env::var("SMTP_USER")
            .map_err(|_| "SMTP_USER environment variable is required".to_string())?;

        let smtp_password = env::var("SMTP_PASSWORD")
            .map_err(|_| "SMTP_PASSWORD environment variable is required".to_string())?;

        let smtp_from = env::var("SMTP_FROM")
            .map_err(|_| "SMTP_FROM environment variable is required".to_string())?;

        let email_base_url = env::var("EMAIL_BASE_URL").unwrap_or_else(|_| {
            let host = env::var("SERVER_HOST").unwrap_or_else(|_| "localhost".to_string());
            let port = env::var("SERVER_PORT").unwrap_or_else(|_| "3000".to_string());
            let default_url = format!("http://{}:{}", host, port);
            tracing::info!(
                "EMAIL_BASE_URL not set, defaulting to {} (from SERVER_HOST/SERVER_PORT)",
                default_url
            );
            default_url
        });

        // Optional variables with defaults
        let smtp_port = env::var("SMTP_PORT")
            .ok()
            .and_then(|v| v.parse::<u16>().ok())
            .unwrap_or(587);

        let smtp_from_name = env::var("SMTP_FROM_NAME").ok();

        let smtp_use_tls = env::var("SMTP_USE_TLS")
            .ok()
            .and_then(|v| v.parse::<bool>().ok())
            .unwrap_or(true);

        let smtp_use_starttls = env::var("SMTP_USE_STARTTLS")
            .ok()
            .and_then(|v| v.parse::<bool>().ok())
            .unwrap_or(true);

        let smtp_accept_invalid_certs = env::var("SMTP_ACCEPT_INVALID_CERTS")
            .ok()
            .and_then(|v| v.parse::<bool>().ok())
            .unwrap_or(false);

        let smtp_ca_cert_path = env::var("SMTP_CA_CERT_PATH").ok();

        let queue_batch_size = env::var("EMAIL_QUEUE_BATCH_SIZE")
            .ok()
            .and_then(|v| v.parse::<usize>().ok())
            .unwrap_or(10);

        let queue_retry_attempts = env::var("EMAIL_QUEUE_RETRY_ATTEMPTS")
            .ok()
            .and_then(|v| v.parse::<u32>().ok())
            .unwrap_or(5);

        let queue_retry_delay_seconds = env::var("EMAIL_QUEUE_RETRY_DELAY_SECONDS")
            .ok()
            .and_then(|v| v.parse::<u64>().ok())
            .unwrap_or(300);

        // DSN request (default: true if IMAP is configured)
        let request_dsn = env::var("EMAIL_REQUEST_DSN")
            .ok()
            .and_then(|v| v.parse::<bool>().ok())
            .unwrap_or(true);

        // IMAP configuration for bounce processing
        let imap_host = env::var("IMAP_HOST").ok();
        let imap_port = env::var("IMAP_PORT")
            .ok()
            .and_then(|v| v.parse::<u16>().ok())
            .unwrap_or(993);
        let imap_user = env::var("IMAP_USER")
            .ok()
            .or_else(|| Some(smtp_user.clone()));
        let imap_password = env::var("IMAP_PASSWORD")
            .ok()
            .or_else(|| Some(smtp_password.clone()));
        let imap_use_tls = env::var("IMAP_USE_TLS")
            .ok()
            .and_then(|v| v.parse::<bool>().ok())
            .unwrap_or(true);
        let imap_use_starttls = env::var("IMAP_USE_STARTTLS")
            .ok()
            .and_then(|v| v.parse::<bool>().ok())
            .unwrap_or(false);
        let imap_accept_invalid_certs = env::var("IMAP_ACCEPT_INVALID_CERTS")
            .ok()
            .and_then(|v| v.parse::<bool>().ok())
            .unwrap_or(false);
        let imap_ca_cert_path = env::var("IMAP_CA_CERT_PATH").ok();
        let imap_mailbox = env::var("IMAP_MAILBOX").unwrap_or_else(|_| "INBOX".to_string());
        let imap_poll_interval_seconds = env::var("IMAP_POLL_INTERVAL_SECONDS")
            .ok()
            .and_then(|v| v.parse::<u64>().ok())
            .unwrap_or(300);

        // Validate configuration
        Self::validate_config(
            &smtp_host,
            smtp_port,
            &smtp_user,
            &smtp_from,
            &email_base_url,
        )?;

        tracing::info!(
            "Email configuration loaded: SMTP host={}:{}, from={}, queue batch size={}",
            smtp_host,
            smtp_port,
            smtp_from,
            queue_batch_size
        );

        if !smtp_use_tls && !smtp_use_starttls {
            tracing::warn!(
                "SMTP connection is not encrypted (SMTP_USE_TLS=false and SMTP_USE_STARTTLS=false). \
                This is insecure for production. Enable TLS/STARTTLS in production."
            );
        }

        if smtp_accept_invalid_certs {
            tracing::warn!(
                "⚠️  SMTP_ACCEPT_INVALID_CERTS=true - Accepting invalid/self-signed certificates. \
                This is INSECURE and should only be used for testing!"
            );
        }

        if let Some(ref path) = smtp_ca_cert_path {
            tracing::info!("SMTP custom CA certificate configured: {}", path);
        }

        if imap_accept_invalid_certs {
            tracing::warn!(
                "⚠️  IMAP_ACCEPT_INVALID_CERTS=true - Accepting invalid/self-signed certificates. \
                This is INSECURE and should only be used for testing!"
            );
        }

        if let Some(ref path) = imap_ca_cert_path {
            tracing::info!("IMAP custom CA certificate configured: {}", path);
        }

        if let Some(ref host) = imap_host {
            // 25 (SMTP), 465 (SMTPS) and 587 (submission) speak SMTP. An IMAP
            // client cannot parse their greeting, which surfaces as a STARTTLS
            // "Unable to parse status response".
            Self::validate_imap_port(imap_port)?;
            let mode = if imap_use_tls {
                "direct TLS"
            } else if imap_use_starttls {
                "STARTTLS"
            } else {
                "plain"
            };
            tracing::info!(
                "IMAP bounce processing enabled: host={host}:{imap_port}, mode={mode}, mailbox={imap_mailbox}, poll interval={imap_poll_interval_seconds}s"
            );
        }

        Ok(Self {
            smtp_host,
            smtp_port,
            smtp_user,
            smtp_password,
            smtp_from,
            smtp_from_name,
            smtp_use_tls,
            smtp_use_starttls,
            smtp_accept_invalid_certs,
            smtp_ca_cert_path,
            queue_batch_size,
            queue_retry_attempts,
            queue_retry_delay_seconds,
            email_base_url,
            request_dsn,
            imap_host,
            imap_port,
            imap_user,
            imap_password,
            imap_use_tls,
            imap_use_starttls,
            imap_accept_invalid_certs,
            imap_ca_cert_path,
            imap_mailbox,
            imap_poll_interval_seconds,
        })
    }

    /// Validate SMTP configuration
    ///
    /// # Arguments
    ///
    /// * `smtp_host` - SMTP server hostname
    /// * `smtp_port` - SMTP server port
    /// * `smtp_user` - SMTP username
    /// * `smtp_from` - Sender email address
    /// * `email_base_url` - Base URL for email links
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - SMTP host is empty
    /// - SMTP port is not in valid range (1-65535)
    /// - SMTP user is empty
    /// - SMTP from email is not a valid email format
    /// - Email base URL is not a valid URL
    fn validate_config(
        smtp_host: &str,
        smtp_port: u16,
        smtp_user: &str,
        smtp_from: &str,
        email_base_url: &str,
    ) -> Result<(), String> {
        // Validate SMTP host
        if smtp_host.is_empty() {
            return Err("SMTP_HOST cannot be empty".to_string());
        }

        // Validate SMTP port
        if smtp_port == 0 {
            return Err("SMTP_PORT must be between 1 and 65535".to_string());
        }

        // Validate SMTP user
        if smtp_user.is_empty() {
            return Err("SMTP_USER cannot be empty".to_string());
        }

        // Validate email format for SMTP_FROM
        if !Self::is_valid_email(smtp_from) {
            return Err(format!(
                "SMTP_FROM '{}' is not a valid email address",
                smtp_from
            ));
        }

        // Validate email base URL
        if email_base_url.is_empty() {
            return Err("EMAIL_BASE_URL cannot be empty".to_string());
        }

        // Basic URL validation (must start with http:// or https://)
        if !email_base_url.starts_with("http://") && !email_base_url.starts_with("https://") {
            return Err(format!(
                "EMAIL_BASE_URL '{}' must start with http:// or https://",
                email_base_url
            ));
        }

        Ok(())
    }

    /// Reject SMTP ports for the IMAP bounce reader.
    ///
    /// Ports 25, 465 and 587 speak SMTP. Connecting an IMAP client there fails
    /// while parsing the greeting.
    fn validate_imap_port(port: u16) -> Result<(), String> {
        if matches!(port, 25 | 465 | 587) {
            return Err(format!(
                "IMAP_PORT {port} is an SMTP port. Use 143 with IMAP_USE_STARTTLS=true or 993 with IMAP_USE_TLS=true"
            ));
        }
        Ok(())
    }

    /// Basic email format validation
    ///
    /// Validates that the email contains an @ symbol and has a valid format.
    /// This is a basic check - full RFC 5322 validation should be done at the service layer.
    fn is_valid_email(email: &str) -> bool {
        if email.is_empty() {
            return false;
        }

        let parts: Vec<&str> = email.split('@').collect();
        if parts.len() != 2 {
            return false;
        }

        let local = parts[0];
        let domain = parts[1];

        // Local part must not be empty
        if local.is_empty() {
            return false;
        }

        // Domain must not be empty and must contain at least one dot
        if domain.is_empty() || !domain.contains('.') {
            return false;
        }

        true
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_is_valid_email() {
        assert!(EmailConfig::is_valid_email("user@example.com"));
        assert!(EmailConfig::is_valid_email("test@nuage.ebii"));
        assert!(!EmailConfig::is_valid_email("invalid"));
        assert!(!EmailConfig::is_valid_email("@example.com"));
        assert!(!EmailConfig::is_valid_email("user@"));
        assert!(!EmailConfig::is_valid_email(""));
    }

    #[test]
    fn test_validate_imap_port_rejects_smtp_ports() {
        for port in [25_u16, 465, 587] {
            let err = EmailConfig::validate_imap_port(port).unwrap_err();
            assert!(err.contains("SMTP port"), "{err}");
        }
        assert!(EmailConfig::validate_imap_port(143).is_ok());
        assert!(EmailConfig::validate_imap_port(993).is_ok());
    }
}