mirror of
https://github.com/juspay/hyperswitch.git
synced 2025-10-28 04:04:55 +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:
@ -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),
|
||||
}
|
||||
Reference in New Issue
Block a user