diff --git a/Cargo.lock b/Cargo.lock index b504c3db42..da0033c833 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -598,9 +598,9 @@ dependencies = [ [[package]] name = "aws-credential-types" -version = "0.55.1" +version = "0.55.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4232d3729eefc287adc0d5a8adc97b7d94eefffe6bbe94312cc86c7ab6b06ce" +checksum = "4cb57ac6088805821f78d282c0ba8aec809f11cbee10dda19a97b03ab040ccc2" dependencies = [ "aws-smithy-async", "aws-smithy-types", @@ -612,9 +612,9 @@ dependencies = [ [[package]] name = "aws-endpoint" -version = "0.55.1" +version = "0.55.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87f04ab03b3f1cca91f7cccaa213056d732accb14e2e65debfacc1d28627d162" +checksum = "9c5f6f84a4f46f95a9bb71d9300b73cd67eb868bc43ae84f66ad34752299f4ac" dependencies = [ "aws-smithy-http", "aws-smithy-types", @@ -626,9 +626,9 @@ dependencies = [ [[package]] name = "aws-http" -version = "0.55.1" +version = "0.55.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5ad8c53f7560baaf635b6aa811f3213d39b50555d100f83e43801652d4e318e" +checksum = "a754683c322f7dc5167484266489fdebdcd04d26e53c162cad1f3f949f2c5671" dependencies = [ "aws-credential-types", "aws-smithy-http", @@ -701,6 +701,31 @@ dependencies = [ "url", ] +[[package]] +name = "aws-sdk-sesv2" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28ec96086c4bda28c512b10c5c951d031651be454a512e511cf5fe91b21b9cc9" +dependencies = [ + "aws-credential-types", + "aws-endpoint", + "aws-http", + "aws-sig-auth", + "aws-smithy-async", + "aws-smithy-client", + "aws-smithy-http", + "aws-smithy-http-tower", + "aws-smithy-json", + "aws-smithy-types", + "aws-types", + "bytes", + "http", + "regex", + "tokio-stream", + "tower", + "tracing", +] + [[package]] name = "aws-sdk-sso" version = "0.26.0" @@ -754,9 +779,9 @@ dependencies = [ [[package]] name = "aws-sig-auth" -version = "0.55.1" +version = "0.55.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d77d879ab210e958ba65a6d3842969a596738c024989cd3e490cf9f9b560ec" +checksum = "84dc92a63ede3c2cbe43529cb87ffa58763520c96c6a46ca1ced80417afba845" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -769,9 +794,9 @@ dependencies = [ [[package]] name = "aws-sigv4" -version = "0.55.1" +version = "0.55.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ab4eebc8ec484fb9eab04b15a5d1e71f3dc13bee8fdd2d9ed78bcd6ecbd7192" +checksum = "392fefab9d6fcbd76d518eb3b1c040b84728ab50f58df0c3c53ada4bea9d327e" dependencies = [ "aws-smithy-eventstream", "aws-smithy-http", @@ -790,9 +815,9 @@ dependencies = [ [[package]] name = "aws-smithy-async" -version = "0.55.1" +version = "0.55.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88573bcfbe1dcfd54d4912846df028b42d6255cbf9ce07be216b1bbfd11fc4b9" +checksum = "ae23b9fe7a07d0919000116c4c5c0578303fbce6fc8d32efca1f7759d4c20faf" dependencies = [ "futures-util", "pin-project-lite", @@ -823,9 +848,9 @@ dependencies = [ [[package]] name = "aws-smithy-client" -version = "0.55.1" +version = "0.55.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2f52352bae50d3337d5d6151b695d31a8c10ebea113eca5bead531f8301b067" +checksum = "5230d25d244a51339273b8870f0f77874cd4449fb4f8f629b21188ae10cfc0ba" dependencies = [ "aws-smithy-async", "aws-smithy-http", @@ -847,9 +872,9 @@ dependencies = [ [[package]] name = "aws-smithy-eventstream" -version = "0.55.1" +version = "0.55.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "168f08f8439c8b317b578a695e514c5cd7b869e73849a2d6b71ced4de6ce193d" +checksum = "22d2a2bcc16e5c4d949ffd2b851da852b9bbed4bb364ed4ae371b42137ca06d9" dependencies = [ "aws-smithy-types", "bytes", @@ -858,9 +883,9 @@ dependencies = [ [[package]] name = "aws-smithy-http" -version = "0.55.1" +version = "0.55.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03bcc02d7ed9649d855c8ce4a735e9848d7b8f7568aad0504c158e3baa955df8" +checksum = "b60e2133beb9fe6ffe0b70deca57aaeff0a35ad24a9c6fab2fd3b4f45b99fdb5" dependencies = [ "aws-smithy-eventstream", "aws-smithy-types", @@ -881,9 +906,9 @@ dependencies = [ [[package]] name = "aws-smithy-http-tower" -version = "0.55.1" +version = "0.55.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da88b3a860f65505996c29192d800f1aeb9480440f56d63aad33a3c12045017a" +checksum = "3a4d94f556c86a0dd916a5d7c39747157ea8cb909ca469703e20fee33e448b67" dependencies = [ "aws-smithy-http", "aws-smithy-types", @@ -897,9 +922,9 @@ dependencies = [ [[package]] name = "aws-smithy-json" -version = "0.55.1" +version = "0.55.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b0c1e87d75cac889dca2a7f5ba280da2cde8122448e7fec1d614194dfa00c70" +checksum = "5ce3d6e6ebb00b2cce379f079ad5ec508f9bcc3a9510d9b9c1840ed1d6f8af39" dependencies = [ "aws-smithy-types", ] @@ -916,9 +941,9 @@ dependencies = [ [[package]] name = "aws-smithy-types" -version = "0.55.1" +version = "0.55.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd0afc731fd1417d791f9145a1e0c30e23ae0beaab9b4814017708ead2fc20f1" +checksum = "58db46fc1f4f26be01ebdb821751b4e2482cd43aa2b64a0348fb89762defaffa" dependencies = [ "base64-simd", "itoa", @@ -938,9 +963,9 @@ dependencies = [ [[package]] name = "aws-types" -version = "0.55.1" +version = "0.55.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9b082e329d9a304d39e193ad5c7ab363a0d6507aca6965e0673a746686fb0cc" +checksum = "de0869598bfe46ec44ffe17e063ed33336e59df90356ca8ff0e8da6f7c1d994b" dependencies = [ "aws-credential-types", "aws-smithy-async", @@ -1795,11 +1820,16 @@ checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" name = "external_services" version = "0.1.0" dependencies = [ + "async-trait", "aws-config", "aws-sdk-kms", + "aws-sdk-sesv2", + "aws-smithy-client", "base64 0.21.0", "common_utils", + "dyn-clone", "error-stack", + "masking", "once_cell", "router_env", "serde", @@ -2970,7 +3000,7 @@ dependencies = [ [[package]] name = "opentelemetry" version = "0.18.0" -source = "git+https://github.com/open-telemetry/opentelemetry-rust/?rev=44b90202fd744598db8b0ace5b8f0bad7ec45658#44b90202fd744598db8b0ace5b8f0bad7ec45658" +source = "git+https://github.com/open-telemetry/opentelemetry-rust?rev=44b90202fd744598db8b0ace5b8f0bad7ec45658#44b90202fd744598db8b0ace5b8f0bad7ec45658" dependencies = [ "opentelemetry_api", "opentelemetry_sdk", @@ -2979,7 +3009,7 @@ dependencies = [ [[package]] name = "opentelemetry-otlp" version = "0.11.0" -source = "git+https://github.com/open-telemetry/opentelemetry-rust/?rev=44b90202fd744598db8b0ace5b8f0bad7ec45658#44b90202fd744598db8b0ace5b8f0bad7ec45658" +source = "git+https://github.com/open-telemetry/opentelemetry-rust?rev=44b90202fd744598db8b0ace5b8f0bad7ec45658#44b90202fd744598db8b0ace5b8f0bad7ec45658" dependencies = [ "async-trait", "futures", @@ -2996,7 +3026,7 @@ dependencies = [ [[package]] name = "opentelemetry-proto" version = "0.1.0" -source = "git+https://github.com/open-telemetry/opentelemetry-rust/?rev=44b90202fd744598db8b0ace5b8f0bad7ec45658#44b90202fd744598db8b0ace5b8f0bad7ec45658" +source = "git+https://github.com/open-telemetry/opentelemetry-rust?rev=44b90202fd744598db8b0ace5b8f0bad7ec45658#44b90202fd744598db8b0ace5b8f0bad7ec45658" dependencies = [ "futures", "futures-util", @@ -3008,7 +3038,7 @@ dependencies = [ [[package]] name = "opentelemetry_api" version = "0.18.0" -source = "git+https://github.com/open-telemetry/opentelemetry-rust/?rev=44b90202fd744598db8b0ace5b8f0bad7ec45658#44b90202fd744598db8b0ace5b8f0bad7ec45658" +source = "git+https://github.com/open-telemetry/opentelemetry-rust?rev=44b90202fd744598db8b0ace5b8f0bad7ec45658#44b90202fd744598db8b0ace5b8f0bad7ec45658" dependencies = [ "fnv", "futures-channel", @@ -3023,7 +3053,7 @@ dependencies = [ [[package]] name = "opentelemetry_sdk" version = "0.18.0" -source = "git+https://github.com/open-telemetry/opentelemetry-rust/?rev=44b90202fd744598db8b0ace5b8f0bad7ec45658#44b90202fd744598db8b0ace5b8f0bad7ec45658" +source = "git+https://github.com/open-telemetry/opentelemetry-rust?rev=44b90202fd744598db8b0ace5b8f0bad7ec45658#44b90202fd744598db8b0ace5b8f0bad7ec45658" dependencies = [ "async-trait", "crossbeam-channel", diff --git a/config/config.example.toml b/config/config.example.toml index cf656f0ae8..85b7ad83c8 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -242,6 +242,12 @@ paypal = { currency = "USD,INR", country = "US" } key_id = "" # The AWS key ID used by the KMS SDK for decrypting data. region = "" # The AWS region used by the KMS SDK for decrypting data. +# EmailClient configuration. Only applicable when the `email` feature flag is enabled. +[email] +from_email = "notify@example.com" # Sender email +aws_region = "" # AWS region used by AWS SES +base_url = "" # Base url used when adding links that should redirect to self + [dummy_connector] payment_ttl = 172800 # Time to live for dummy connector payment in redis payment_duration = 1000 # Fake delay duration for dummy connector payment diff --git a/config/development.toml b/config/development.toml index 044b5bc616..692712b79c 100644 --- a/config/development.toml +++ b/config/development.toml @@ -149,6 +149,11 @@ stream = "SCHEDULER_STREAM" disabled = false consumer_group = "SCHEDULER_GROUP" +[email] +from_email = "notify@example.com" +aws_region = "" +base_url = "" + [bank_config.eps] stripe = { banks = "arzte_und_apotheker_bank,austrian_anadi_bank_ag,bank_austria,bankhaus_carl_spangler,bankhaus_schelhammer_und_schattera_ag,bawag_psk_ag,bks_bank_ag,brull_kallmus_bank_ag,btv_vier_lander_bank,capital_bank_grawe_gruppe_ag,dolomitenbank,easybank_ag,erste_bank_und_sparkassen,hypo_alpeadriabank_international_ag,hypo_noe_lb_fur_niederosterreich_u_wien,hypo_oberosterreich_salzburg_steiermark,hypo_tirol_bank_ag,hypo_vorarlberg_bank_ag,hypo_bank_burgenland_aktiengesellschaft,marchfelder_bank,oberbank_ag,raiffeisen_bankengruppe_osterreich,schoellerbank_ag,sparda_bank_wien,volksbank_gruppe,volkskreditbank_ag,vr_bank_braunau" } adyen = { banks = "bank_austria,bawag_psk_ag,dolomitenbank,easybank_ag,erste_bank_und_sparkassen,hypo_tirol_bank_ag,posojilnica_bank_e_gen,raiffeisen_bankengruppe_osterreich,schoellerbank_ag,sparda_bank_wien,volksbank_gruppe,volkskreditbank_ag" } diff --git a/crates/external_services/Cargo.toml b/crates/external_services/Cargo.toml index 385c2a70cb..008699b34b 100644 --- a/crates/external_services/Cargo.toml +++ b/crates/external_services/Cargo.toml @@ -9,6 +9,7 @@ license = "Apache-2.0" [features] kms = ["dep:aws-config", "dep:aws-sdk-kms"] +email = ["dep:aws-config"] [dependencies] aws-config = { version = "0.55.1", optional = true } @@ -19,7 +20,12 @@ once_cell = "1.17.1" serde = { version = "1.0.160", features = ["derive"] } thiserror = "1.0.40" tokio = "1.27.0" +dyn-clone = "1.0.11" +async-trait = "0.1.66" +aws-sdk-sesv2 = "0.27.0" +aws-smithy-client = "0.55.0" # First party crates common_utils = { version = "0.1.0", path = "../common_utils" } router_env = { version = "0.1.0", path = "../router_env", features = ["log_extra_implicit_fields", "log_custom_entries_to_extra"] } +masking = { version = "0.1.0", path = "../masking" } diff --git a/crates/external_services/src/email.rs b/crates/external_services/src/email.rs new file mode 100644 index 0000000000..2da15bead5 --- /dev/null +++ b/crates/external_services/src/email.rs @@ -0,0 +1,123 @@ +//! Interactions with the AWS SES SDK + +use aws_config::meta::region::RegionProviderChain; +use aws_sdk_sesv2::{ + config::Region, + operation::send_email::SendEmailError, + types::{Body, Content, Destination, EmailContent, Message}, + Client, +}; +use common_utils::{errors::CustomResult, pii}; +use error_stack::{IntoReport, ResultExt}; +use masking::PeekInterface; +use serde::Deserialize; + +/// Custom Result type alias for Email operations. +pub type EmailResult = CustomResult; + +/// A trait that defines the methods that must be implemented to send email. +#[async_trait::async_trait] +pub trait EmailClient: Sync + Send + dyn_clone::DynClone { + /// Sends an email to the specified recipient with the given subject and body. + async fn send_email( + &self, + recipient: pii::Email, + subject: String, + body: String, + ) -> EmailResult<()>; +} + +dyn_clone::clone_trait_object!(EmailClient); + +/// Struct that contains the settings required to construct an EmailClient. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct EmailSettings { + /// Sender email. + pub from_email: String, + + /// The AWS region to send SES requests to. + pub aws_region: String, + + /// Base-url used when adding links that should redirect to self + pub base_url: String, +} + +/// Client for AWS SES operation +#[derive(Debug, Clone)] +pub struct AwsSes { + ses_client: Client, + from_email: String, +} + +impl AwsSes { + /// Constructs a new AwsSes client + pub async fn new(conf: &EmailSettings) -> Self { + let region_provider = RegionProviderChain::first_try(Region::new(conf.aws_region.clone())); + let sdk_config = aws_config::from_env().region(region_provider).load().await; + + Self { + ses_client: Client::new(&sdk_config), + from_email: conf.from_email.clone(), + } + } +} + +#[async_trait::async_trait] +impl EmailClient for AwsSes { + async fn send_email( + &self, + recipient: pii::Email, + subject: String, + body: String, + ) -> EmailResult<()> { + self.ses_client + .send_email() + .from_email_address(self.from_email.to_owned()) + .destination( + Destination::builder() + .to_addresses(recipient.peek()) + .build(), + ) + .content( + EmailContent::builder() + .simple( + Message::builder() + .subject(Content::builder().data(subject).build()) + .body( + Body::builder() + .text(Content::builder().data(body).charset("UTF-8").build()) + .build(), + ) + .build(), + ) + .build(), + ) + .send() + .await + .map_err(AwsSesError::SendingFailure) + .into_report() + .change_context(EmailError::EmailSendingFailure)?; + + Ok(()) + } +} + +/// Errors that could occur from EmailClient. +#[derive(Debug, thiserror::Error)] +pub enum EmailError { + /// An error occurred when building email client. + #[error("Error building email client")] + ClientBuildingFailure, + + /// An error occurred when sending email + #[error("Error sending email to recipient")] + EmailSendingFailure, +} + +/// Errors that could occur during SES operations. +#[derive(Debug, thiserror::Error)] +pub enum AwsSesError { + /// An error occurred in the SDK while sending email. + #[error("Failed to Send Email {0:?}")] + SendingFailure(aws_smithy_client::SdkError), +} diff --git a/crates/external_services/src/lib.rs b/crates/external_services/src/lib.rs index 0a3b133338..b995276d58 100644 --- a/crates/external_services/src/lib.rs +++ b/crates/external_services/src/lib.rs @@ -3,6 +3,9 @@ #![forbid(unsafe_code)] #![warn(missing_docs, missing_debug_implementations)] +#[cfg(feature = "email")] +pub mod email; + #[cfg(feature = "kms")] pub mod kms; diff --git a/crates/router/Cargo.toml b/crates/router/Cargo.toml index 088f612923..ae59a299c4 100644 --- a/crates/router/Cargo.toml +++ b/crates/router/Cargo.toml @@ -13,10 +13,11 @@ build = "src/build.rs" default = ["kv_store", "stripe", "oltp", "olap", "accounts_cache", "dummy_connector"] s3 = ["dep:aws-sdk-s3","dep:aws-config"] kms = ["external_services/kms","dep:aws-config"] +email = ["external_services/email","dep:aws-config"] basilisk = ["kms"] stripe = ["dep:serde_qs"] -sandbox = ["kms", "stripe", "basilisk", "s3"] -production = ["kms", "stripe", "basilisk", "s3"] +sandbox = ["kms", "stripe", "basilisk", "s3", "email"] +production = ["kms", "stripe", "basilisk", "s3", "email"] olap = [] oltp = [] kv_store = [] diff --git a/crates/router/src/configs/settings.rs b/crates/router/src/configs/settings.rs index 4096096055..d7d0d78816 100644 --- a/crates/router/src/configs/settings.rs +++ b/crates/router/src/configs/settings.rs @@ -7,6 +7,8 @@ use std::{ use api_models::enums; use common_utils::ext_traits::ConfigExt; use config::{Environment, File}; +#[cfg(feature = "email")] +use external_services::email::EmailSettings; #[cfg(feature = "kms")] use external_services::kms; use redis_interface::RedisSettings; @@ -69,6 +71,8 @@ pub struct Settings { pub connector_customer: ConnectorCustomer, #[cfg(feature = "dummy_connector")] pub dummy_connector: DummyConnector, + #[cfg(feature = "email")] + pub email: EmailSettings, } #[derive(Debug, Deserialize, Clone, Default)] diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs index faa8eab994..dbfde139d2 100644 --- a/crates/router/src/routes/app.rs +++ b/crates/router/src/routes/app.rs @@ -1,4 +1,6 @@ use actix_web::{web, Scope}; +#[cfg(feature = "email")] +use external_services::email::{AwsSes, EmailClient}; use tokio::sync::oneshot; #[cfg(feature = "dummy_connector")] @@ -22,12 +24,16 @@ pub struct AppState { pub flow_name: String, pub store: Box, pub conf: Settings, + #[cfg(feature = "email")] + pub email_client: Box, } pub trait AppStateInfo { fn conf(&self) -> Settings; fn flow_name(&self) -> String; fn store(&self) -> Box; + #[cfg(feature = "email")] + fn email_client(&self) -> Box; } impl AppStateInfo for AppState { @@ -40,6 +46,10 @@ impl AppStateInfo for AppState { fn store(&self) -> Box { self.store.to_owned() } + #[cfg(feature = "email")] + fn email_client(&self) -> Box { + self.email_client.to_owned() + } } impl AppState { @@ -56,10 +66,15 @@ impl AppState { StorageImpl::Mock => Box::new(MockDb::new(&conf).await), }; + #[cfg(feature = "email")] + #[allow(clippy::expect_used)] + let email_client = Box::new(AwsSes::new(&conf.email).await); Self { flow_name: String::from("default"), store, conf, + #[cfg(feature = "email")] + email_client, } }