openzeppelin_monitor/services/notification/
email.rsuse async_trait::async_trait;
use email_address::EmailAddress;
use lettre::{
message::{
header::{self, ContentType},
Mailbox, Mailboxes,
},
transport::smtp::authentication::Credentials,
Message, SmtpTransport, Transport,
};
use std::collections::HashMap;
use crate::{
models::TriggerTypeConfig,
services::notification::{NotificationError, Notifier},
};
use pulldown_cmark::{html, Options, Parser};
pub struct EmailNotifier<T: Transport + Send + Sync> {
subject: String,
body_template: String,
client: T,
sender: EmailAddress,
recipients: Vec<EmailAddress>,
}
#[derive(Clone)]
pub struct SmtpConfig {
pub host: String,
pub port: u16,
pub username: String,
pub password: String,
}
#[derive(Clone)]
pub struct EmailContent {
pub subject: String,
pub body_template: String,
pub sender: EmailAddress,
pub recipients: Vec<EmailAddress>,
}
impl<T: Transport + Send + Sync> EmailNotifier<T>
where
T::Error: std::fmt::Display,
{
pub fn with_transport(email_content: EmailContent, transport: T) -> Self {
Self {
subject: email_content.subject,
body_template: email_content.body_template,
sender: email_content.sender,
recipients: email_content.recipients,
client: transport,
}
}
}
impl EmailNotifier<SmtpTransport> {
pub fn new(
smtp_config: SmtpConfig,
email_content: EmailContent,
) -> Result<Self, Box<NotificationError>> {
let client = SmtpTransport::relay(&smtp_config.host)
.map_err(|e| {
NotificationError::internal_error(
format!("Failed to create SMTP relay: {}", e),
None,
None,
)
})?
.port(smtp_config.port)
.credentials(Credentials::new(smtp_config.username, smtp_config.password))
.build();
Ok(Self {
subject: email_content.subject,
body_template: email_content.body_template,
sender: email_content.sender,
recipients: email_content.recipients,
client,
})
}
pub fn format_message(&self, variables: &HashMap<String, String>) -> String {
let formatted_message = variables
.iter()
.fold(self.body_template.clone(), |message, (key, value)| {
message.replace(&format!("${{{}}}", key), value)
});
Self::markdown_to_html(&formatted_message)
}
pub fn markdown_to_html(md: &str) -> String {
let opts = Options::all();
let parser = Parser::new_ext(md, opts);
let mut html_out = String::new();
html::push_html(&mut html_out, parser);
html_out
}
pub fn from_config(config: &TriggerTypeConfig) -> Option<Self> {
match config {
TriggerTypeConfig::Email {
host,
port,
username,
password,
message,
sender,
recipients,
} => {
let smtp_config = SmtpConfig {
host: host.clone(),
port: port.unwrap_or(465),
username: username.as_ref().to_string(),
password: password.as_ref().to_string(),
};
let email_content = EmailContent {
subject: message.title.clone(),
body_template: message.body.clone(),
sender: sender.clone(),
recipients: recipients.clone(),
};
Self::new(smtp_config, email_content).ok()
}
_ => None,
}
}
}
#[async_trait]
impl<T: Transport + Send + Sync> Notifier for EmailNotifier<T>
where
T::Error: std::fmt::Display,
{
async fn notify(&self, message: &str) -> Result<(), anyhow::Error> {
let recipients_str = self
.recipients
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ");
let mailboxes: Mailboxes = recipients_str
.parse::<Mailboxes>()
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
let recipients_header: header::To = mailboxes.into();
let email = Message::builder()
.mailbox(recipients_header)
.from(
self.sender
.to_string()
.parse::<Mailbox>()
.map_err(|e| anyhow::anyhow!(e.to_string()))?,
)
.reply_to(
self.sender
.to_string()
.parse::<Mailbox>()
.map_err(|e| anyhow::anyhow!(e.to_string()))?,
)
.subject(&self.subject)
.header(ContentType::TEXT_HTML)
.body(message.to_owned())
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
self.client
.send(&email)
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::models::{NotificationMessage, SecretString, SecretValue};
use super::*;
fn create_test_notifier() -> EmailNotifier<SmtpTransport> {
let smtp_config = SmtpConfig {
host: "dummy.smtp.com".to_string(),
port: 465,
username: "test".to_string(),
password: "test".to_string(),
};
let email_content = EmailContent {
subject: "Test Subject".to_string(),
body_template: "Hello ${name}, your balance is ${balance}".to_string(),
sender: "sender@test.com".parse().unwrap(),
recipients: vec!["recipient@test.com".parse().unwrap()],
};
EmailNotifier::new(smtp_config, email_content).unwrap()
}
fn create_test_email_config(port: Option<u16>) -> TriggerTypeConfig {
TriggerTypeConfig::Email {
host: "smtp.test.com".to_string(),
port,
username: SecretValue::Plain(SecretString::new("testuser".to_string())),
password: SecretValue::Plain(SecretString::new("testpass".to_string())),
message: NotificationMessage {
title: "Test Subject".to_string(),
body: "Hello ${name}".to_string(),
},
sender: "sender@test.com".parse().unwrap(),
recipients: vec!["recipient@test.com".parse().unwrap()],
}
}
#[test]
fn test_format_message_basic_substitution() {
let notifier = create_test_notifier();
let mut variables = HashMap::new();
variables.insert("name".to_string(), "Alice".to_string());
variables.insert("balance".to_string(), "100".to_string());
let result = notifier.format_message(&variables);
let expected_result = "<p>Hello Alice, your balance is 100</p>\n";
assert_eq!(result, expected_result);
}
#[test]
fn test_format_message_missing_variable() {
let notifier = create_test_notifier();
let mut variables = HashMap::new();
variables.insert("name".to_string(), "Bob".to_string());
let result = notifier.format_message(&variables);
let expected_result = "<p>Hello Bob, your balance is ${balance}</p>\n";
assert_eq!(result, expected_result);
}
#[test]
fn test_format_message_empty_variables() {
let notifier = create_test_notifier();
let variables = HashMap::new();
let result = notifier.format_message(&variables);
let expected_result = "<p>Hello ${name}, your balance is ${balance}</p>\n";
assert_eq!(result, expected_result);
}
#[test]
fn test_format_message_with_empty_values() {
let notifier = create_test_notifier();
let mut variables = HashMap::new();
variables.insert("name".to_string(), "".to_string());
variables.insert("balance".to_string(), "".to_string());
let result = notifier.format_message(&variables);
let expected_result = "<p>Hello , your balance is</p>\n";
assert_eq!(result, expected_result);
}
#[test]
fn test_from_config_valid_email_config() {
let config = create_test_email_config(Some(587));
let notifier = EmailNotifier::from_config(&config);
assert!(notifier.is_some());
let notifier = notifier.unwrap();
assert_eq!(notifier.subject, "Test Subject");
assert_eq!(notifier.body_template, "Hello ${name}");
assert_eq!(notifier.sender.to_string(), "sender@test.com");
assert_eq!(notifier.recipients.len(), 1);
assert_eq!(notifier.recipients[0].to_string(), "recipient@test.com");
}
#[test]
fn test_from_config_default_port() {
let config = create_test_email_config(None);
let notifier = EmailNotifier::from_config(&config);
assert!(notifier.is_some());
}
#[tokio::test]
async fn test_notify_failure() {
let notifier = create_test_notifier();
let result = notifier.notify("Test message").await;
assert!(result.is_err());
}
}