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

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),
}