mirror of
https://github.com/juspay/hyperswitch.git
synced 2025-10-29 09:07:09 +08:00
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:
@ -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"
|
||||
|
||||
@ -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 {
|
||||
|
||||
37
crates/external_services/src/email/no_email.rs
Normal file
37
crates/external_services/src/email/no_email.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
@ -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)?;
|
||||
|
||||
|
||||
189
crates/external_services/src/email/smtp.rs
Normal file
189
crates/external_services/src/email/smtp.rs
Normal 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),
|
||||
}
|
||||
@ -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(())
|
||||
}
|
||||
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user