From 0f563b069994f47bba1ba77c79fef6307f3760e8 Mon Sep 17 00:00:00 2001 From: Jagan Date: Wed, 20 Nov 2024 19:14:03 +0530 Subject: [PATCH] feat(email): Add SMTP support to allow mails through self hosted/custom SMTP server (#6617) Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com> --- Cargo.lock | 359 +++++++++++++++++- config/development.toml | 2 +- config/docker_compose.toml | 2 +- crates/external_services/Cargo.toml | 1 + crates/external_services/src/email.rs | 42 +- .../external_services/src/email/no_email.rs | 37 ++ crates/external_services/src/email/ses.rs | 37 +- crates/external_services/src/email/smtp.rs | 189 +++++++++ crates/router/src/configs/settings.rs | 4 + crates/router/src/routes/app.rs | 31 +- loadtest/config/development.toml | 2 +- 11 files changed, 670 insertions(+), 36 deletions(-) create mode 100644 crates/external_services/src/email/no_email.rs create mode 100644 crates/external_services/src/email/smtp.rs diff --git a/Cargo.lock b/Cargo.lock index 27b4158ba6..a2ede8c08c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1888,9 +1888,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.1.18" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b62ac837cdb5cb22e10a256099b4fc502b1dfe560cb282963a974d7abd80e476" +checksum = "fd9de9f2205d5ef3fd67e685b0df337994ddd4495e2a28d185500d0e1edfea47" dependencies = [ "jobserver", "libc", @@ -1963,6 +1963,16 @@ dependencies = [ "phf_codegen", ] +[[package]] +name = "chumsky" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" +dependencies = [ + "hashbrown 0.14.5", + "stacker", +] + [[package]] name = "ciborium" version = "0.2.2" @@ -2962,6 +2972,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "email-encoding" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d1d33cdaede7e24091f039632eb5d3c7469fe5b066a985281a34fc70fa317f" +dependencies = [ + "base64 0.22.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "encoding_rs" version = "0.8.34" @@ -3134,6 +3160,7 @@ dependencies = [ "hyper-proxy", "hyper-util", "hyperswitch_interfaces", + "lettre", "masking", "once_cell", "prost 0.13.2", @@ -3732,6 +3759,17 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "hostname" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c7c7c8ac16c798734b8a24560c1362120597c40d5e1459f09498f8f6c8f2ba" +dependencies = [ + "cfg-if 1.0.0", + "libc", + "windows", +] + [[package]] name = "hsdev" version = "0.1.0" @@ -4117,6 +4155,124 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec 1.13.2", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] + [[package]] name = "ident_case" version = "1.0.1" @@ -4133,6 +4289,27 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec 1.13.2", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "ignore" version = "0.4.22" @@ -4456,6 +4633,31 @@ dependencies = [ "spin 0.9.8", ] +[[package]] +name = "lettre" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0161e452348e399deb685ba05e55ee116cae9410f4f51fe42d597361444521d9" +dependencies = [ + "base64 0.22.1", + "chumsky", + "email-encoding", + "email_address", + "fastrand 2.1.1", + "futures-util", + "hostname", + "httpdate", + "idna 1.0.3", + "mime", + "native-tls", + "nom", + "percent-encoding", + "quoted_printable", + "socket2", + "tokio 1.40.0", + "url", +] + [[package]] name = "libc" version = "0.2.158" @@ -4531,6 +4733,12 @@ version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +[[package]] +name = "litemap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "643cb0b8d4fcc284004d5fd0d67ccf61dfffadb7f75e1e71bc420f4688a3a704" + [[package]] name = "local-channel" version = "0.1.5" @@ -5832,7 +6040,7 @@ checksum = "f8650aabb6c35b860610e9cff5dc1af886c9e25073b7b1712a68972af4281302" dependencies = [ "bytes 1.7.1", "heck 0.5.0", - "itertools 0.12.1", + "itertools 0.13.0", "log", "multimap", "once_cell", @@ -5865,7 +6073,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acf0c195eebb4af52c752bec4f52f645da98b6e92077a04110c7f349477ae5ac" dependencies = [ "anyhow", - "itertools 0.12.1", + "itertools 0.13.0", "proc-macro2", "quote", "syn 2.0.77", @@ -5880,6 +6088,15 @@ dependencies = [ "prost 0.13.2", ] +[[package]] +name = "psm" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200b9ff220857e53e184257720a14553b2f4aa02577d2ed9842d45d4b9654810" +dependencies = [ + "cc", +] + [[package]] name = "ptr_meta" version = "0.1.4" @@ -5949,6 +6166,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" + [[package]] name = "r2d2" version = "0.8.10" @@ -7669,6 +7892,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "stacker" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799c883d55abdb5e98af1a7b3f23b9b6de8ecada0ecac058672d7635eb48ca7b" +dependencies = [ + "cc", + "cfg-if 1.0.0", + "libc", + "psm", + "windows-sys 0.59.0", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -8092,6 +8328,16 @@ dependencies = [ "crunchy", ] +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinytemplate" version = "1.2.1" @@ -8939,7 +9185,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" dependencies = [ "form_urlencoded", - "idna", + "idna 0.5.0", "percent-encoding", "serde", ] @@ -8956,6 +9202,18 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "110352d4e9076c67839003c7788d8604e24dcded13e0b375af3efaa8cf468517" +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -9001,7 +9259,7 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da339118f018cc70ebf01fafc103360528aad53717e4bf311db929cb01cb9345" dependencies = [ - "idna", + "idna 0.5.0", "once_cell", "regex", "serde", @@ -9290,6 +9548,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.52.0" @@ -9529,6 +9797,18 @@ dependencies = [ "url", ] +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + [[package]] name = "ws2_32-sys" version = "0.2.1" @@ -9580,6 +9860,30 @@ dependencies = [ "linked-hash-map", ] +[[package]] +name = "yoke" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5b1314b079b0930c31e3af543d8ee1757b1951ae1e1565ec704403a7240ca5" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28cc31741b18cb6f1d5ff12f5b7523e3d6eb0852bbbad19d73905511d9849b95" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", + "synstructure 0.13.1", +] + [[package]] name = "zerocopy" version = "0.7.35" @@ -9601,12 +9905,55 @@ dependencies = [ "syn 2.0.77", ] +[[package]] +name = "zerofrom" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ec111ce797d0e0784a1116d0ddcdbea84322cd79e5d5ad173daeba4f93ab55" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ea7b4a3637ea8669cedf0f1fd5c286a17f3de97b8dd5a70a6c167a1730e63a5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", + "synstructure 0.13.1", +] + [[package]] name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] + [[package]] name = "zstd" version = "0.13.2" diff --git a/config/development.toml b/config/development.toml index 6a6fda6755..97739c3f5c 100644 --- a/config/development.toml +++ b/config/development.toml @@ -311,7 +311,7 @@ wildcard_origin = true sender_email = "example@example.com" aws_region = "" allowed_unverified_days = 1 -active_email_client = "SES" +active_email_client = "NO_EMAIL_CLIENT" recon_recipient_email = "recon@example.com" prod_intent_recipient_email = "business@example.com" diff --git a/config/docker_compose.toml b/config/docker_compose.toml index 7adeee8a37..d71be95848 100644 --- a/config/docker_compose.toml +++ b/config/docker_compose.toml @@ -676,7 +676,7 @@ connector_list = "cybersource" sender_email = "example@example.com" # Sender email aws_region = "" # AWS region used by AWS SES allowed_unverified_days = 1 # Number of days the api calls ( with jwt token ) can be made without verifying the email -active_email_client = "SES" # The currently active email client +active_email_client = "NO_EMAIL_CLIENT" # The currently active email client recon_recipient_email = "recon@example.com" # Recipient email for recon request email prod_intent_recipient_email = "business@example.com" # Recipient email for prod intent email diff --git a/crates/external_services/Cargo.toml b/crates/external_services/Cargo.toml index 12b9dd3b0f..e617dbe835 100644 --- a/crates/external_services/Cargo.toml +++ b/crates/external_services/Cargo.toml @@ -29,6 +29,7 @@ error-stack = "0.4.1" hex = "0.4.3" hyper = "0.14.28" hyper-proxy = "0.9.1" +lettre = "0.11.10" once_cell = "1.19.0" serde = { version = "1.0.197", features = ["derive"] } thiserror = "1.0.58" diff --git a/crates/external_services/src/email.rs b/crates/external_services/src/email.rs index 5751de95c1..2e05a26e1f 100644 --- a/crates/external_services/src/email.rs +++ b/crates/external_services/src/email.rs @@ -7,6 +7,12 @@ use serde::Deserialize; /// Implementation of aws ses client pub mod ses; +/// Implementation of SMTP server client +pub mod smtp; + +/// Implementation of Email client when email support is disabled +pub mod no_email; + /// Custom Result type alias for Email operations. pub type EmailResult = CustomResult; @@ -114,14 +120,27 @@ dyn_clone::clone_trait_object!(EmailClient); /// List of available email clients to choose from #[derive(Debug, Clone, Default, Deserialize)] -pub enum AvailableEmailClients { +#[serde(tag = "active_email_client")] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum EmailClientConfigs { #[default] + /// Default Email client to use when no client is specified + NoEmailClient, /// AWS ses email client - SES, + Ses { + /// AWS SES client configuration + aws_ses: ses::SESConfig, + }, + /// Other Simple SMTP server + Smtp { + /// SMTP server configuration + smtp: smtp::SmtpServerConfig, + }, } /// Struct that contains the settings required to construct an EmailClient. #[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] pub struct EmailSettings { /// The AWS region to send SES requests to. pub aws_region: String, @@ -132,11 +151,9 @@ pub struct EmailSettings { /// Sender email pub sender_email: String, - /// Configs related to AWS Simple Email Service - pub aws_ses: Option, - - /// The active email client to use - pub active_email_client: AvailableEmailClients, + #[serde(flatten)] + /// The client specific configurations + pub client_config: EmailClientConfigs, /// Recipient email for recon emails pub recon_recipient_email: pii::Email, @@ -145,6 +162,17 @@ pub struct EmailSettings { pub prod_intent_recipient_email: pii::Email, } +impl EmailSettings { + /// Validation for the Email client specific configurations + pub fn validate(&self) -> Result<(), &'static str> { + match &self.client_config { + EmailClientConfigs::Ses { ref aws_ses } => aws_ses.validate(), + EmailClientConfigs::Smtp { ref smtp } => smtp.validate(), + EmailClientConfigs::NoEmailClient => Ok(()), + } + } +} + /// Errors that could occur from EmailClient. #[derive(Debug, thiserror::Error)] pub enum EmailError { diff --git a/crates/external_services/src/email/no_email.rs b/crates/external_services/src/email/no_email.rs new file mode 100644 index 0000000000..6ec5d69e1a --- /dev/null +++ b/crates/external_services/src/email/no_email.rs @@ -0,0 +1,37 @@ +use common_utils::{errors::CustomResult, pii}; +use router_env::logger; + +use crate::email::{EmailClient, EmailError, EmailResult, IntermediateString}; + +/// Client when email support is disabled +#[derive(Debug, Clone, Default, serde::Deserialize)] +pub struct NoEmailClient {} + +impl NoEmailClient { + /// Constructs a new client when email is disabled + pub async fn create() -> Self { + Self {} + } +} + +#[async_trait::async_trait] +impl EmailClient for NoEmailClient { + type RichText = String; + fn convert_to_rich_text( + &self, + intermediate_string: IntermediateString, + ) -> CustomResult { + Ok(intermediate_string.into_inner()) + } + + async fn send_email( + &self, + _recipient: pii::Email, + _subject: String, + _body: Self::RichText, + _proxy_url: Option<&String>, + ) -> EmailResult<()> { + logger::info!("Email not sent as email support is disabled, please enable any of the supported email clients to send emails"); + Ok(()) + } +} diff --git a/crates/external_services/src/email/ses.rs b/crates/external_services/src/email/ses.rs index 73599b344c..f9dcc8f26a 100644 --- a/crates/external_services/src/email/ses.rs +++ b/crates/external_services/src/email/ses.rs @@ -7,7 +7,7 @@ use aws_sdk_sesv2::{ Client, }; use aws_sdk_sts::config::Credentials; -use common_utils::{errors::CustomResult, ext_traits::OptionExt, pii}; +use common_utils::{errors::CustomResult, pii}; use error_stack::{report, ResultExt}; use hyper::Uri; use masking::PeekInterface; @@ -19,6 +19,7 @@ use crate::email::{EmailClient, EmailError, EmailResult, EmailSettings, Intermed #[derive(Debug, Clone)] pub struct AwsSes { sender: String, + ses_config: SESConfig, settings: EmailSettings, } @@ -32,6 +33,21 @@ pub struct SESConfig { pub sts_role_session_name: String, } +impl SESConfig { + /// Validation for the SES client specific configs + pub fn validate(&self) -> Result<(), &'static str> { + use common_utils::{ext_traits::ConfigExt, fp_utils::when}; + + when(self.email_role_arn.is_default_or_empty(), || { + Err("email.aws_ses.email_role_arn must not be empty") + })?; + + when(self.sts_role_session_name.is_default_or_empty(), || { + Err("email.aws_ses.sts_role_session_name must not be empty") + }) + } +} + /// Errors that could occur during SES operations. #[derive(Debug, thiserror::Error)] pub enum AwsSesError { @@ -67,15 +83,20 @@ pub enum AwsSesError { impl AwsSes { /// Constructs a new AwsSes client - pub async fn create(conf: &EmailSettings, proxy_url: Option>) -> Self { + pub async fn create( + conf: &EmailSettings, + ses_config: &SESConfig, + proxy_url: Option>, + ) -> Self { // Build the client initially which will help us know if the email configuration is correct - Self::create_client(conf, proxy_url) + Self::create_client(conf, ses_config, proxy_url) .await .map_err(|error| logger::error!(?error, "Failed to initialize SES Client")) .ok(); Self { sender: conf.sender_email.clone(), + ses_config: ses_config.clone(), settings: conf.clone(), } } @@ -83,19 +104,13 @@ impl AwsSes { /// A helper function to create ses client pub async fn create_client( conf: &EmailSettings, + ses_config: &SESConfig, proxy_url: Option>, ) -> CustomResult { let sts_config = Self::get_shared_config(conf.aws_region.to_owned(), proxy_url.as_ref())? .load() .await; - let ses_config = conf - .aws_ses - .as_ref() - .get_required_value("aws ses configuration") - .attach_printable("The selected email client is aws ses, but configuration is missing") - .change_context(AwsSesError::MissingConfigurationVariable("aws_ses"))?; - let role = aws_sdk_sts::Client::new(&sts_config) .assume_role() .role_arn(&ses_config.email_role_arn) @@ -219,7 +234,7 @@ impl EmailClient for AwsSes { ) -> EmailResult<()> { // Not using the same email client which was created at startup as the role session would expire // Create a client every time when the email is being sent - let email_client = Self::create_client(&self.settings, proxy_url) + let email_client = Self::create_client(&self.settings, &self.ses_config, proxy_url) .await .change_context(EmailError::ClientBuildingFailure)?; diff --git a/crates/external_services/src/email/smtp.rs b/crates/external_services/src/email/smtp.rs new file mode 100644 index 0000000000..33ab89f457 --- /dev/null +++ b/crates/external_services/src/email/smtp.rs @@ -0,0 +1,189 @@ +use std::time::Duration; + +use common_utils::{errors::CustomResult, pii}; +use error_stack::ResultExt; +use lettre::{ + address::AddressError, + error, + message::{header::ContentType, Mailbox}, + transport::smtp::{self, authentication::Credentials}, + Message, SmtpTransport, Transport, +}; +use masking::{PeekInterface, Secret}; + +use crate::email::{EmailClient, EmailError, EmailResult, EmailSettings, IntermediateString}; + +/// Client for SMTP server operation +#[derive(Debug, Clone, Default, serde::Deserialize)] +pub struct SmtpServer { + /// sender email id + pub sender: String, + /// SMTP server specific configs + pub smtp_config: SmtpServerConfig, +} + +impl SmtpServer { + /// A helper function to create SMTP server client + pub fn create_client(&self) -> Result { + let host = self.smtp_config.host.clone(); + let port = self.smtp_config.port; + let timeout = Some(Duration::from_secs(self.smtp_config.timeout)); + let credentials = self + .smtp_config + .username + .clone() + .zip(self.smtp_config.password.clone()) + .map(|(username, password)| { + Credentials::new(username.peek().to_owned(), password.peek().to_owned()) + }); + match &self.smtp_config.connection { + SmtpConnection::StartTls => match credentials { + Some(credentials) => Ok(SmtpTransport::starttls_relay(&host) + .map_err(SmtpError::ConnectionFailure)? + .port(port) + .timeout(timeout) + .credentials(credentials) + .build()), + None => Ok(SmtpTransport::starttls_relay(&host) + .map_err(SmtpError::ConnectionFailure)? + .port(port) + .timeout(timeout) + .build()), + }, + SmtpConnection::Plaintext => match credentials { + Some(credentials) => Ok(SmtpTransport::builder_dangerous(&host) + .port(port) + .timeout(timeout) + .credentials(credentials) + .build()), + None => Ok(SmtpTransport::builder_dangerous(&host) + .port(port) + .timeout(timeout) + .build()), + }, + } + } + /// Constructs a new SMTP client + pub async fn create(conf: &EmailSettings, smtp_config: SmtpServerConfig) -> Self { + Self { + sender: conf.sender_email.clone(), + smtp_config: smtp_config.clone(), + } + } + /// helper function to convert email id into Mailbox + fn to_mail_box(email: String) -> EmailResult { + Ok(Mailbox::new( + None, + email + .parse() + .map_err(SmtpError::EmailParsingFailed) + .change_context(EmailError::EmailSendingFailure)?, + )) + } +} +/// Struct that contains the SMTP server specific configs required +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct SmtpServerConfig { + /// hostname of the SMTP server eg: smtp.gmail.com + pub host: String, + /// portname of the SMTP server eg: 25 + pub port: u16, + /// timeout for the SMTP server connection in seconds eg: 10 + pub timeout: u64, + /// Username name of the SMTP server + pub username: Option>, + /// Password of the SMTP server + pub password: Option>, + /// Connection type of the SMTP server + #[serde(default)] + pub connection: SmtpConnection, +} + +/// Enum that contains the connection types of the SMTP server +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SmtpConnection { + #[default] + /// Plaintext connection which MUST then successfully upgrade to TLS via STARTTLS + StartTls, + /// Plaintext connection (very insecure) + Plaintext, +} + +impl SmtpServerConfig { + /// Validation for the SMTP server client specific configs + pub fn validate(&self) -> Result<(), &'static str> { + use common_utils::{ext_traits::ConfigExt, fp_utils::when}; + when(self.host.is_default_or_empty(), || { + Err("email.smtp.host must not be empty") + })?; + self.username.clone().zip(self.password.clone()).map_or( + Ok(()), + |(username, password)| { + when(username.peek().is_default_or_empty(), || { + Err("email.smtp.username must not be empty") + })?; + when(password.peek().is_default_or_empty(), || { + Err("email.smtp.password must not be empty") + }) + }, + )?; + Ok(()) + } +} + +#[async_trait::async_trait] +impl EmailClient for SmtpServer { + type RichText = String; + fn convert_to_rich_text( + &self, + intermediate_string: IntermediateString, + ) -> CustomResult { + Ok(intermediate_string.into_inner()) + } + + async fn send_email( + &self, + recipient: pii::Email, + subject: String, + body: Self::RichText, + _proxy_url: Option<&String>, + ) -> EmailResult<()> { + // Create a client every time when the email is being sent + let email_client = + Self::create_client(self).change_context(EmailError::EmailSendingFailure)?; + + let email = Message::builder() + .to(Self::to_mail_box(recipient.peek().to_string())?) + .from(Self::to_mail_box(self.sender.clone())?) + .subject(subject) + .header(ContentType::TEXT_HTML) + .body(body) + .map_err(SmtpError::MessageBuildingFailed) + .change_context(EmailError::EmailSendingFailure)?; + + email_client + .send(&email) + .map_err(SmtpError::SendingFailure) + .change_context(EmailError::EmailSendingFailure)?; + Ok(()) + } +} + +/// Errors that could occur during SES operations. +#[derive(Debug, thiserror::Error)] +pub enum SmtpError { + /// An error occurred in the SMTP while sending email. + #[error("Failed to Send Email {0:?}")] + SendingFailure(smtp::Error), + /// An error occurred in the SMTP while building the message content. + #[error("Failed to create connection {0:?}")] + ConnectionFailure(smtp::Error), + /// An error occurred in the SMTP while building the message content. + #[error("Failed to Build Email content {0:?}")] + MessageBuildingFailed(error::Error), + /// An error occurred in the SMTP while building the message content. + #[error("Failed to parse given email {0:?}")] + EmailParsingFailed(AddressError), +} diff --git a/crates/router/src/configs/settings.rs b/crates/router/src/configs/settings.rs index f675aad11a..76b58f5b67 100644 --- a/crates/router/src/configs/settings.rs +++ b/crates/router/src/configs/settings.rs @@ -881,6 +881,10 @@ impl Settings { .transpose()?; self.key_manager.get_inner().validate()?; + #[cfg(feature = "email")] + self.email + .validate() + .map_err(|err| ApplicationError::InvalidConfigurationValueError(err.into()))?; Ok(()) } diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs index 1d7e9727cf..baa2ba4ae1 100644 --- a/crates/router/src/routes/app.rs +++ b/crates/router/src/routes/app.rs @@ -8,7 +8,9 @@ use common_enums::TransactionType; #[cfg(feature = "partial-auth")] use common_utils::crypto::Blake3; #[cfg(feature = "email")] -use external_services::email::{ses::AwsSes, EmailService}; +use external_services::email::{ + no_email::NoEmailClient, ses::AwsSes, smtp::SmtpServer, EmailClientConfigs, EmailService, +}; use external_services::{file_storage::FileStorageInterface, grpc_client::GrpcClients}; use hyperswitch_interfaces::{ encryption_interface::EncryptionManagementInterface, @@ -97,7 +99,7 @@ pub struct SessionState { pub api_client: Box, pub event_handler: EventsHandler, #[cfg(feature = "email")] - pub email_client: Arc, + pub email_client: Arc>, #[cfg(feature = "olap")] pub pool: AnalyticsProvider, pub file_storage_client: Arc, @@ -195,7 +197,7 @@ pub struct AppState { pub conf: Arc>, pub event_handler: EventsHandler, #[cfg(feature = "email")] - pub email_client: Arc, + pub email_client: Arc>, pub api_client: Box, #[cfg(feature = "olap")] pub pools: HashMap, @@ -215,7 +217,7 @@ pub trait AppStateInfo { fn conf(&self) -> settings::Settings; fn event_handler(&self) -> EventsHandler; #[cfg(feature = "email")] - fn email_client(&self) -> Arc; + fn email_client(&self) -> Arc>; fn add_request_id(&mut self, request_id: RequestId); fn add_flow_name(&mut self, flow_name: String); fn get_request_id(&self) -> Option; @@ -232,7 +234,7 @@ impl AppStateInfo for AppState { self.conf.as_ref().to_owned() } #[cfg(feature = "email")] - fn email_client(&self) -> Arc { + fn email_client(&self) -> Arc> { self.email_client.to_owned() } fn event_handler(&self) -> EventsHandler { @@ -258,11 +260,22 @@ impl AsRef for AppState { } #[cfg(feature = "email")] -pub async fn create_email_client(settings: &settings::Settings) -> impl EmailService { - match settings.email.active_email_client { - external_services::email::AvailableEmailClients::SES => { - AwsSes::create(&settings.email, settings.proxy.https_url.to_owned()).await +pub async fn create_email_client( + settings: &settings::Settings, +) -> Box { + match &settings.email.client_config { + EmailClientConfigs::Ses { aws_ses } => Box::new( + AwsSes::create( + &settings.email, + aws_ses, + settings.proxy.https_url.to_owned(), + ) + .await, + ), + EmailClientConfigs::Smtp { smtp } => { + Box::new(SmtpServer::create(&settings.email, smtp.clone()).await) } + EmailClientConfigs::NoEmailClient => Box::new(NoEmailClient::create().await), } } diff --git a/loadtest/config/development.toml b/loadtest/config/development.toml index 934b072f14..288e72164c 100644 --- a/loadtest/config/development.toml +++ b/loadtest/config/development.toml @@ -384,7 +384,7 @@ public = { base_url = "http://localhost:8080", schema = "public", redis_key_pref sender_email = "example@example.com" aws_region = "" allowed_unverified_days = 1 -active_email_client = "SES" +active_email_client = "NO_EMAIL_CLIENT" recon_recipient_email = "recon@example.com" prod_intent_recipient_email = "business@example.com"