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>
This commit is contained in:
Jagan
2024-11-20 19:14:03 +05:30
committed by GitHub
parent 98aa84b7e8
commit 0f563b0699
11 changed files with 670 additions and 36 deletions

359
Cargo.lock generated
View File

@ -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"

View File

@ -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"

View File

@ -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

View File

@ -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"

View File

@ -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<T> = CustomResult<T, EmailError>;
@ -114,14 +120,27 @@ dyn_clone::clone_trait_object!(EmailClient<RichText = Body>);
/// 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<ses::SESConfig>,
/// 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 {

View File

@ -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<Self::RichText, EmailError> {
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(())
}
}

View File

@ -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<impl AsRef<str>>) -> Self {
pub async fn create(
conf: &EmailSettings,
ses_config: &SESConfig,
proxy_url: Option<impl AsRef<str>>,
) -> 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<impl AsRef<str>>,
) -> CustomResult<Client, AwsSesError> {
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)?;

View File

@ -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<SmtpTransport, SmtpError> {
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<Mailbox> {
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<Secret<String>>,
/// Password of the SMTP server
pub password: Option<Secret<String>>,
/// 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<Self::RichText, EmailError> {
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),
}

View File

@ -881,6 +881,10 @@ impl Settings<SecuredSecret> {
.transpose()?;
self.key_manager.get_inner().validate()?;
#[cfg(feature = "email")]
self.email
.validate()
.map_err(|err| ApplicationError::InvalidConfigurationValueError(err.into()))?;
Ok(())
}

View File

@ -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<dyn crate::services::ApiClient>,
pub event_handler: EventsHandler,
#[cfg(feature = "email")]
pub email_client: Arc<dyn EmailService>,
pub email_client: Arc<Box<dyn EmailService>>,
#[cfg(feature = "olap")]
pub pool: AnalyticsProvider,
pub file_storage_client: Arc<dyn FileStorageInterface>,
@ -195,7 +197,7 @@ pub struct AppState {
pub conf: Arc<settings::Settings<RawSecret>>,
pub event_handler: EventsHandler,
#[cfg(feature = "email")]
pub email_client: Arc<dyn EmailService>,
pub email_client: Arc<Box<dyn EmailService>>,
pub api_client: Box<dyn crate::services::ApiClient>,
#[cfg(feature = "olap")]
pub pools: HashMap<String, AnalyticsProvider>,
@ -215,7 +217,7 @@ pub trait AppStateInfo {
fn conf(&self) -> settings::Settings<RawSecret>;
fn event_handler(&self) -> EventsHandler;
#[cfg(feature = "email")]
fn email_client(&self) -> Arc<dyn EmailService>;
fn email_client(&self) -> Arc<Box<dyn EmailService>>;
fn add_request_id(&mut self, request_id: RequestId);
fn add_flow_name(&mut self, flow_name: String);
fn get_request_id(&self) -> Option<String>;
@ -232,7 +234,7 @@ impl AppStateInfo for AppState {
self.conf.as_ref().to_owned()
}
#[cfg(feature = "email")]
fn email_client(&self) -> Arc<dyn EmailService> {
fn email_client(&self) -> Arc<Box<dyn EmailService>> {
self.email_client.to_owned()
}
fn event_handler(&self) -> EventsHandler {
@ -258,11 +260,22 @@ impl AsRef<Self> for AppState {
}
#[cfg(feature = "email")]
pub async fn create_email_client(settings: &settings::Settings<RawSecret>) -> 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<RawSecret>,
) -> Box<dyn EmailService> {
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),
}
}

View File

@ -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"