Skip to content

Commit

Permalink
feat(email): Add SMTP support to allow mails through self hosted/cust…
Browse files Browse the repository at this point in the history
…om SMTP server (#6617)

Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com>
  • Loading branch information
jagan-jaya and hyperswitch-bot[bot] authored Nov 20, 2024
1 parent 98aa84b commit 0f563b0
Show file tree
Hide file tree
Showing 11 changed files with 670 additions and 36 deletions.
359 changes: 353 additions & 6 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion config/development.toml
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ wildcard_origin = true
sender_email = "[email protected]"
aws_region = ""
allowed_unverified_days = 1
active_email_client = "SES"
active_email_client = "NO_EMAIL_CLIENT"
recon_recipient_email = "[email protected]"
prod_intent_recipient_email = "[email protected]"

Expand Down
2 changes: 1 addition & 1 deletion config/docker_compose.toml
Original file line number Diff line number Diff line change
Expand Up @@ -676,7 +676,7 @@ connector_list = "cybersource"
sender_email = "[email protected]" # 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 = "[email protected]" # Recipient email for recon request email
prod_intent_recipient_email = "[email protected]" # Recipient email for prod intent email

Expand Down
1 change: 1 addition & 0 deletions crates/external_services/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
42 changes: 35 additions & 7 deletions crates/external_services/src/email.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>;

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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 {
Expand Down
37 changes: 37 additions & 0 deletions crates/external_services/src/email/no_email.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
}
37 changes: 26 additions & 11 deletions crates/external_services/src/email/ses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
}

Expand All @@ -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 {
Expand Down Expand Up @@ -67,35 +83,34 @@ 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(),
}
}

/// 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)
Expand Down Expand Up @@ -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)?;

Expand Down
Loading

0 comments on commit 0f563b0

Please sign in to comment.