diff --git a/.env.template b/.env.template index 1b691298..d2eb768e 100644 --- a/.env.template +++ b/.env.template @@ -373,7 +373,7 @@ # ROCKET_WORKERS=10 # ROCKET_TLS={certs="/path/to/certs.pem",key="/path/to/key.pem"} -## Mail specific settings, set SMTP_HOST and SMTP_FROM to enable the mail service. +## Mail specific settings, set SMTP_FROM and either SMTP_HOST or USE_SENDMAIL to enable the mail service. ## To make sure the email links are pointing to the correct host, set the DOMAIN variable. ## Note: if SMTP_USERNAME is specified, SMTP_PASSWORD is mandatory # SMTP_HOST=smtp.domain.tld @@ -385,6 +385,11 @@ # SMTP_PASSWORD=password # SMTP_TIMEOUT=15 +# Whether to send mail via the `sendmail` command +# USE_SENDMAIL=false +# Which sendmail command to use. The one found in the $PATH is used if not specified. +# SENDMAIL_COMMAND="/path/to/sendmail" + ## Defaults for SSL is "Plain" and "Login" and nothing for Non-SSL connections. ## Possible values: ["Plain", "Login", "Xoauth2"]. ## Multiple options need to be separated by a comma ','. diff --git a/Cargo.toml b/Cargo.toml index c83f8fed..19825465 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -114,7 +114,7 @@ webauthn-rs = "0.3.2" url = "2.3.1" # Email librariese-Base, Update crates and small change. -lettre = { version = "0.10.1", features = ["smtp-transport", "builder", "serde", "tokio1-native-tls", "hostname", "tracing", "tokio1"], default-features = false } +lettre = { version = "0.10.1", features = ["smtp-transport", "sendmail-transport", "builder", "serde", "tokio1-native-tls", "hostname", "tracing", "tokio1"], default-features = false } percent-encoding = "2.2.0" # URL encoding library used for URL's in the emails email_address = "0.2.4" diff --git a/src/config.rs b/src/config.rs index 61700c8f..b7eabd36 100644 --- a/src/config.rs +++ b/src/config.rs @@ -614,6 +614,10 @@ make_config! { smtp: _enable_smtp { /// Enabled _enable_smtp: bool, true, def, true; + /// Use Sendmail |> Whether to send mail via the `sendmail` command + use_sendmail: bool, true, def, false; + /// Sendmail Command |> Which sendmail command to use. The one found in the $PATH is used if not specified. + sendmail_command: String, true, option; /// Host smtp_host: String, true, option; /// DEPRECATED smtp_ssl |> DEPRECATED - Please use SMTP_SECURITY @@ -653,7 +657,7 @@ make_config! { /// Email 2FA Settings email_2fa: _enable_email_2fa { /// Enabled |> Disabling will prevent users from setting up new email 2FA and using existing email 2FA configured - _enable_email_2fa: bool, true, auto, |c| c._enable_smtp && c.smtp_host.is_some(); + _enable_email_2fa: bool, true, auto, |c| c._enable_smtp && (c.smtp_host.is_some() || c.use_sendmail); /// Email token size |> Number of digits in an email 2FA token (min: 6, max: 255). Note that the Bitwarden clients are hardcoded to mention 6 digit codes regardless of this setting. email_token_size: u8, true, def, 6; /// Token expiration time |> Maximum time in seconds a token is valid. The time the user has to open email client and copy token. @@ -744,20 +748,48 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> { ), } - if cfg.smtp_host.is_some() == cfg.smtp_from.is_empty() { - err!("Both `SMTP_HOST` and `SMTP_FROM` need to be set for email support") - } + if cfg.use_sendmail { + if let Some(ref command) = cfg.sendmail_command { + let path = std::path::Path::new(&command); - if cfg.smtp_host.is_some() && !cfg.smtp_from.contains('@') { - err!("SMTP_FROM does not contain a mandatory @ sign") - } + if !path.is_absolute() { + err!(format!("path to sendmail command `{path:?}` is not absolute")); + } + + match path.metadata() { + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + err!(format!("sendmail command not found at `{path:?}`")) + } + Err(err) => { + err!(format!("failed to access sendmail command at `{path:?}`: {err}")) + } + Ok(metadata) => { + if metadata.is_dir() { + err!(format!("sendmail command at `{path:?}` isn't a directory")); + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if !metadata.permissions().mode() & 0o111 != 0 { + err!(format!("sendmail command at `{path:?}` isn't executable")); + } + } + } + } + } + } else { + if cfg.smtp_host.is_some() == cfg.smtp_from.is_empty() { + err!("Both `SMTP_HOST` and `SMTP_FROM` need to be set for email support without `USE_SENDMAIL`") + } - if cfg.smtp_username.is_some() != cfg.smtp_password.is_some() { - err!("Both `SMTP_USERNAME` and `SMTP_PASSWORD` need to be set to enable email authentication") + if cfg.smtp_username.is_some() != cfg.smtp_password.is_some() { + err!("Both `SMTP_USERNAME` and `SMTP_PASSWORD` need to be set to enable email authentication without `USE_SENDMAIL`") + } } - if cfg._enable_email_2fa && (!cfg._enable_smtp || cfg.smtp_host.is_none()) { - err!("To enable email 2FA, SMTP must be configured") + if (cfg.smtp_host.is_some() || cfg.use_sendmail) && !cfg.smtp_from.contains('@') { + err!("SMTP_FROM does not contain a mandatory @ sign") } if cfg._enable_email_2fa && cfg.email_token_size < 6 { @@ -765,6 +797,10 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> { } } + if cfg._enable_email_2fa && !(cfg.smtp_host.is_some() || cfg.use_sendmail) { + err!("To enable email 2FA, a mail transport must be configured") + } + // Check if the icon blacklist regex is valid if let Some(ref r) = cfg.icon_blacklist_regex { let validate_regex = regex::Regex::new(r); @@ -1045,7 +1081,7 @@ impl Config { } pub fn mail_enabled(&self) -> bool { let inner = &self.inner.read().unwrap().config; - inner._enable_smtp && inner.smtp_host.is_some() + inner._enable_smtp && (inner.smtp_host.is_some() || inner.use_sendmail) } pub fn get_duo_akey(&self) -> String { diff --git a/src/mail.rs b/src/mail.rs index cffa65fb..565a1f2c 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -8,7 +8,7 @@ use lettre::{ transport::smtp::authentication::{Credentials, Mechanism as SmtpAuthMechanism}, transport::smtp::client::{Tls, TlsParameters}, transport::smtp::extension::ClientId, - Address, AsyncSmtpTransport, AsyncTransport, Tokio1Executor, + Address, AsyncSendmailTransport, AsyncSmtpTransport, AsyncTransport, Tokio1Executor, }; use crate::{ @@ -21,7 +21,15 @@ use crate::{ CONFIG, }; -fn mailer() -> AsyncSmtpTransport { +fn sendmail_transport() -> AsyncSendmailTransport { + if let Some(command) = CONFIG.sendmail_command() { + AsyncSendmailTransport::new_with_command(command) + } else { + AsyncSendmailTransport::new() + } +} + +fn smtp_transport() -> AsyncSmtpTransport { use std::time::Duration; let host = CONFIG.smtp_host().unwrap(); @@ -509,6 +517,58 @@ pub async fn send_admin_reset_password(address: &str, user_name: &str, org_name: send_email(address, &subject, body_html, body_text).await } +async fn send_with_selected_transport(email: Message) -> EmptyResult { + if CONFIG.use_sendmail() { + match sendmail_transport().send(email).await { + Ok(_) => Ok(()), + // Match some common errors and make them more user friendly + Err(e) => { + if e.is_client() { + debug!("Sendmail client error: {:#?}", e); + err!(format!("Sendmail client error: {e}")); + } else if e.is_response() { + debug!("Sendmail response error: {:#?}", e); + err!(format!("Sendmail response error: {e}")); + } else { + debug!("Sendmail error: {:#?}", e); + err!(format!("Sendmail error: {e}")); + } + } + } + } else { + match smtp_transport().send(email).await { + Ok(_) => Ok(()), + // Match some common errors and make them more user friendly + Err(e) => { + if e.is_client() { + debug!("SMTP client error: {:#?}", e); + err!(format!("SMTP client error: {e}")); + } else if e.is_transient() { + debug!("SMTP 4xx error: {:#?}", e); + err!(format!("SMTP 4xx error: {e}")); + } else if e.is_permanent() { + debug!("SMTP 5xx error: {:#?}", e); + let mut msg = e.to_string(); + // Add a special check for 535 to add a more descriptive message + if msg.contains("(535)") { + msg = format!("{msg} - Authentication credentials invalid"); + } + err!(format!("SMTP 5xx error: {msg}")); + } else if e.is_timeout() { + debug!("SMTP timeout error: {:#?}", e); + err!(format!("SMTP timeout error: {e}")); + } else if e.is_tls() { + debug!("SMTP encryption error: {:#?}", e); + err!(format!("SMTP encryption error: {e}")); + } else { + debug!("SMTP error: {:#?}", e); + err!(format!("SMTP error: {e}")); + } + } + } + } +} + async fn send_email(address: &str, subject: &str, body_html: String, body_text: String) -> EmptyResult { let smtp_from = &CONFIG.smtp_from(); @@ -538,34 +598,5 @@ async fn send_email(address: &str, subject: &str, body_html: String, body_text: .subject(subject) .multipart(body)?; - match mailer().send(email).await { - Ok(_) => Ok(()), - // Match some common errors and make them more user friendly - Err(e) => { - if e.is_client() { - debug!("SMTP Client error: {:#?}", e); - err!(format!("SMTP Client error: {e}")); - } else if e.is_transient() { - debug!("SMTP 4xx error: {:#?}", e); - err!(format!("SMTP 4xx error: {e}")); - } else if e.is_permanent() { - debug!("SMTP 5xx error: {:#?}", e); - let mut msg = e.to_string(); - // Add a special check for 535 to add a more descriptive message - if msg.contains("(535)") { - msg = format!("{msg} - Authentication credentials invalid"); - } - err!(format!("SMTP 5xx error: {msg}")); - } else if e.is_timeout() { - debug!("SMTP timeout error: {:#?}", e); - err!(format!("SMTP timeout error: {e}")); - } else if e.is_tls() { - debug!("SMTP Encryption error: {:#?}", e); - err!(format!("SMTP Encryption error: {e}")); - } else { - debug!("SMTP {:#?}", e); - err!(format!("SMTP {e}")); - } - } - } + send_with_selected_transport(email).await }