diff --git a/Cargo.lock b/Cargo.lock index 197bda3f..0ffa3447 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -405,9 +405,9 @@ dependencies = [ [[package]] name = "aws-runtime" -version = "1.5.4" +version = "1.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee7643696e7fdd74c10f9eb42848a87fe469d35eae9c3323f80aa98f350baac" +checksum = "76dd04d39cc12844c0994f2c9c5a6f5184c22e9188ec1ff723de41910a21dcad" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -488,6 +488,28 @@ dependencies = [ "url", ] +[[package]] +name = "aws-sdk-sesv2" +version = "1.65.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5771f0ab7f960545a569d2a916363423e2faebeb01f0cc22716d25c6088f0a7b" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "http 0.2.12", + "once_cell", + "regex-lite", + "tracing", +] + [[package]] name = "aws-sdk-sso" version = "1.52.0" @@ -557,9 +579,9 @@ dependencies = [ [[package]] name = "aws-sigv4" -version = "1.2.7" +version = "1.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "690118821e46967b3c4501d67d7d52dd75106a9c54cf36cefa1985cedbe94e05" +checksum = "0bc5bbd1e4a2648fd8c5982af03935972c24a2f9846b396de661d351ee3ce837" dependencies = [ "aws-credential-types", "aws-smithy-eventstream", @@ -749,9 +771,9 @@ dependencies = [ [[package]] name = "aws-types" -version = "1.3.4" +version = "1.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0df5a18c4f951c645300d365fec53a61418bcf4650f604f85fe2a665bfaa0c2" +checksum = "dfbd0a668309ec1f66c0f6bda4840dd6d4796ae26d699ebc266d7cc95c6d040f" dependencies = [ "aws-credential-types", "aws-smithy-async", @@ -4936,6 +4958,7 @@ dependencies = [ "aws-config", "aws-sdk-dsql", "aws-sdk-s3", + "aws-sdk-sesv2", "bigdecimal", "bytes", "cached", diff --git a/Cargo.toml b/Cargo.toml index 7ff4393a..0b49dca2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,10 +20,11 @@ build = "build.rs" enable_syslog = [] mysql = ["diesel/mysql", "diesel_migrations/mysql"] postgresql = ["diesel/postgres", "diesel_migrations/postgres"] -aws = ["dsql", "s3"] +aws = ["dsql", "s3", "ses"] dsql = ["postgresql", "dep:aws-config", "dep:aws-sdk-dsql"] sqlite = ["diesel/sqlite", "diesel_migrations/sqlite", "dep:libsqlite3-sys"] s3 = ["dep:aws-config", "dep:aws-sdk-s3"] +ses = ["dep:aws-config", "dep:aws-sdk-sesv2"] # Enable to use a vendored and statically linked openssl vendored_openssl = ["openssl/vendored"] # Enable MiMalloc memory allocator to replace the default malloc @@ -91,10 +92,11 @@ diesel-derive-newtype = "2.1.2" # Bundled/Static SQLite libsqlite3-sys = { version = "0.31.0", features = ["bundled"], optional = true } -# AWS / Amazon Aurora DSQL +# AWS Services aws-config = { version = "1.5.12", features = ["behavior-version-latest"], optional = true } aws-sdk-s3 = { version = "1.72.0", features = ["behavior-version-latest"], optional = true } aws-sdk-dsql = { version = "1.2.0", features = ["behavior-version-latest"], optional = true } +aws-sdk-sesv2 = { version = "1.65.0", features = ["behavior-version-latest"], optional = true } # Crypto-related libraries rand = "0.9.0" diff --git a/build.rs b/build.rs index f068eb49..0587559b 100644 --- a/build.rs +++ b/build.rs @@ -15,6 +15,8 @@ fn main() { println!("cargo:rustc-cfg=query_logger"); #[cfg(feature = "s3")] println!("cargo:rustc-cfg=s3"); + #[cfg(feature = "ses")] + println!("cargo:rustc-cfg=ses"); #[cfg(feature = "aws")] println!("cargo:rustc-cfg=aws"); @@ -31,6 +33,7 @@ fn main() { println!("cargo::rustc-check-cfg=cfg(dsql)"); println!("cargo::rustc-check-cfg=cfg(query_logger)"); println!("cargo::rustc-check-cfg=cfg(s3)"); + println!("cargo::rustc-check-cfg=cfg(ses)"); println!("cargo::rustc-check-cfg=cfg(aws)"); // Rerun when these paths are changed. diff --git a/src/config.rs b/src/config.rs index 698b97dd..759b1efa 100644 --- a/src/config.rs +++ b/src/config.rs @@ -741,12 +741,14 @@ make_config! { smtp_accept_invalid_certs: bool, true, def, false; /// Accept Invalid Hostnames (Know the risks!) |> DANGEROUS: Allow invalid hostnames. This option introduces significant vulnerabilities to man-in-the-middle attacks! smtp_accept_invalid_hostnames: bool, true, def, false; + /// Use AWS SES |> Whether to send mail via AWS Simple Email Service (SES) + use_aws_ses: bool, true, def, false; }, /// 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() || c.use_sendmail); + _enable_email_2fa: bool, true, auto, |c| c._enable_smtp && (c.smtp_host.is_some() || c.use_sendmail || c.use_aws_ses); /// 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. @@ -951,6 +953,9 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> { } } } + } else if cfg.use_aws_ses { + #[cfg(not(ses))] + err!("`USE_AWS_SES` is set, but the `ses` feature is not enabled in this build"); } 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`") @@ -961,7 +966,7 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> { } } - if (cfg.smtp_host.is_some() || cfg.use_sendmail) && !is_valid_email(&cfg.smtp_from) { + if (cfg.smtp_host.is_some() || cfg.use_sendmail || cfg.use_aws_ses) && !is_valid_email(&cfg.smtp_from) { err!(format!("SMTP_FROM '{}' is not a valid email address", cfg.smtp_from)) } @@ -970,7 +975,7 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> { } } - if cfg._enable_email_2fa && !(cfg.smtp_host.is_some() || cfg.use_sendmail) { + if cfg._enable_email_2fa && !(cfg.smtp_host.is_some() || cfg.use_sendmail || cfg.use_aws_ses) { err!("To enable email 2FA, a mail transport must be configured") } @@ -1288,7 +1293,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.use_sendmail) + inner._enable_smtp && (inner.smtp_host.is_some() || inner.use_sendmail || inner.use_aws_ses) } pub async fn get_duo_akey(&self) -> String { diff --git a/src/mail.rs b/src/mail.rs index d074995a..ab1984a7 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -95,6 +95,44 @@ fn smtp_transport() -> AsyncSmtpTransport { smtp_client.build() } +#[cfg(ses)] +async fn send_with_aws_ses(email: Message) -> std::io::Result<()> { + use std::io::Error; + use aws_sdk_sesv2::{types::{EmailContent, RawMessage}, Client}; + use crate::aws::aws_sdk_config; + + fn sesv2_client() -> std::io::Result { + static AWS_SESV2_CLIENT: std::sync::LazyLock> = std::sync::LazyLock::new(|| { + Ok(Client::new(aws_sdk_config()?)) + }); + + (*AWS_SESV2_CLIENT) + .as_ref() + .map(|client| client.clone()) + .map_err(|e| match e.get_ref() { + Some(inner) => Error::new(e.kind(), inner), + None => Error::from(e.kind()), + }) + } + + sesv2_client()? + .send_email() + .content( + EmailContent::builder().raw( + RawMessage::builder() + .data(email.formatted().into()) + .build() + .map_err(|e| Error::other(format!("Failed to build AWS SESv2 RawMessage: {e:#?}")))? + ) + .build() + ) + .send() + .await + .map_err(|e| Error::other(e))?; + + Ok(()) +} + // This will sanitize the string values by stripping all the html tags to prevent XSS and HTML Injections fn sanitize_data(data: &mut serde_json::Value) { use regex::Regex; @@ -605,6 +643,15 @@ async fn send_with_selected_transport(email: Message) -> EmptyResult { } } } + } else if CONFIG.use_aws_ses() { + #[cfg(ses)] + match send_with_aws_ses(email).await { + Ok(_) => Ok(()), + Err(e) => err!("Failed to send email", format!("Failed to send email using AWS SES: {e:?}")) + } + + #[cfg(not(ses))] + err!("Failed to send email", "Failed to send email using AWS SES: `ses` feature is not enabled"); } else { match smtp_transport().send(email).await { Ok(_) => Ok(()), diff --git a/src/main.rs b/src/main.rs index e202cc1c..ba66a2df 100644 --- a/src/main.rs +++ b/src/main.rs @@ -45,7 +45,7 @@ use tokio::{ #[cfg(unix)] use tokio::signal::unix::SignalKind; -#[cfg(any(dsql, s3))] +#[cfg(any(dsql, s3, ses))] mod aws; #[macro_use]