openzeppelin_monitor/services/notification/
email.rs

1//! Email notification implementation.
2//!
3//! Provides functionality to send formatted messages to email addresses
4//! via SMTP, supporting message templates with variable substitution.
5
6use async_trait::async_trait;
7use email_address::EmailAddress;
8use lettre::{
9	message::{
10		header::{self, ContentType},
11		Mailbox, Mailboxes,
12	},
13	Message, SmtpTransport, Transport,
14};
15use std::{collections::HashMap, sync::Arc};
16
17use crate::{
18	models::TriggerTypeConfig,
19	services::notification::{NotificationError, Notifier},
20};
21use pulldown_cmark::{html, Options, Parser};
22
23/// Implementation of email notifications via SMTP
24#[derive(Debug)]
25pub struct EmailNotifier<T: Transport + Send + Sync> {
26	/// Email subject
27	subject: String,
28	/// Message template with variable placeholders
29	body_template: String,
30	/// SMTP client for email delivery
31	client: T,
32	/// Email sender
33	sender: EmailAddress,
34	/// Email recipients
35	recipients: Vec<EmailAddress>,
36}
37
38/// Configuration for SMTP connection
39#[derive(Clone, Debug, Hash, Eq, PartialEq)]
40pub struct SmtpConfig {
41	pub host: String,
42	pub port: u16,
43	pub username: String,
44	pub password: String,
45}
46
47/// Configuration for email content
48#[derive(Clone)]
49pub struct EmailContent {
50	pub subject: String,
51	pub body_template: String,
52	pub sender: EmailAddress,
53	pub recipients: Vec<EmailAddress>,
54}
55
56impl<T: Transport + Send + Sync> EmailNotifier<T>
57where
58	T::Error: std::fmt::Display,
59{
60	/// Creates a new email notifier instance with a custom transport
61	///
62	/// # Arguments
63	/// * `email_content` - Email content configuration
64	/// * `transport` - SMTP transport
65	///
66	/// # Returns
67	/// * `Self` - Email notifier instance
68	pub fn with_transport(email_content: EmailContent, transport: T) -> Self {
69		Self {
70			subject: email_content.subject,
71			body_template: email_content.body_template,
72			sender: email_content.sender,
73			recipients: email_content.recipients,
74			client: transport,
75		}
76	}
77}
78
79impl EmailNotifier<SmtpTransport> {
80	/// Creates a new email notifier instance
81	///
82	/// # Arguments
83	/// * `smtp_client` - SMTP client
84	/// * `email_content` - Email content configuration
85	///
86	/// # Returns
87	/// * `Result<Self, NotificationError>` - Email notifier instance or error
88	pub fn new(
89		smtp_client: Arc<SmtpTransport>,
90		email_content: EmailContent,
91	) -> Result<Self, NotificationError> {
92		Ok(Self {
93			subject: email_content.subject,
94			body_template: email_content.body_template,
95			sender: email_content.sender,
96			recipients: email_content.recipients,
97			client: smtp_client.as_ref().clone(),
98		})
99	}
100
101	/// Formats a message by substituting variables in the template and converts it to HTML
102	///
103	/// # Arguments
104	/// * `variables` - Map of variable names to values
105	///
106	/// # Returns
107	/// * `String` - Formatted message with variables replaced and converted to HTML
108	pub fn format_message(&self, variables: &HashMap<String, String>) -> String {
109		let formatted_message = variables
110			.iter()
111			.fold(self.body_template.clone(), |message, (key, value)| {
112				message.replace(&format!("${{{}}}", key), value)
113			});
114
115		Self::markdown_to_html(&formatted_message)
116	}
117
118	/// Convert a Markdown string into HTML
119	pub fn markdown_to_html(md: &str) -> String {
120		// enable all the extensions you like; or just Parser::new(md) for pure CommonMark
121		let opts = Options::all();
122		let parser = Parser::new_ext(md, opts);
123
124		let mut html_out = String::new();
125		html::push_html(&mut html_out, parser);
126		html_out
127	}
128
129	/// Creates an email notifier from a trigger configuration
130	///
131	/// # Arguments
132	/// * `config` - Trigger configuration containing email parameters
133	///
134	/// # Returns
135	/// * `Result<Self, NotificationError>` - Notifier instance if config is email type
136	pub fn from_config(
137		config: &TriggerTypeConfig,
138		smtp_client: Arc<SmtpTransport>,
139	) -> Result<Self, NotificationError> {
140		if let TriggerTypeConfig::Email {
141			message,
142			sender,
143			recipients,
144			..
145		} = config
146		{
147			let email_content = EmailContent {
148				subject: message.title.clone(),
149				body_template: message.body.clone(),
150				sender: sender.clone(),
151				recipients: recipients.clone(),
152			};
153
154			Self::new(smtp_client, email_content)
155		} else {
156			Err(NotificationError::config_error(
157				format!("Invalid email configuration: {:?}", config),
158				None,
159				None,
160			))
161		}
162	}
163}
164
165#[async_trait]
166impl<T: Transport + Send + Sync> Notifier for EmailNotifier<T>
167where
168	T::Error: std::fmt::Display,
169{
170	/// Sends a formatted message to email
171	///
172	/// # Arguments
173	/// * `message` - The formatted message to send
174	///
175	/// # Returns
176	/// * `Result<(), NotificationError>` - Success or error
177	async fn notify(&self, message: &str) -> Result<(), NotificationError> {
178		let recipients_str = self
179			.recipients
180			.iter()
181			.map(ToString::to_string)
182			.collect::<Vec<_>>()
183			.join(", ");
184
185		let mailboxes: Mailboxes = recipients_str.parse::<Mailboxes>().map_err(|e| {
186			NotificationError::notify_failed(
187				format!("Failed to parse recipients: {}", e),
188				Some(e.into()),
189				None,
190			)
191		})?;
192		let recipients_header: header::To = mailboxes.into();
193
194		let email = Message::builder()
195			.mailbox(recipients_header)
196			.from(self.sender.to_string().parse::<Mailbox>().map_err(|e| {
197				NotificationError::notify_failed(
198					format!("Failed to parse sender: {}", e),
199					Some(e.into()),
200					None,
201				)
202			})?)
203			.reply_to(self.sender.to_string().parse::<Mailbox>().map_err(|e| {
204				NotificationError::notify_failed(
205					format!("Failed to parse reply-to: {}", e),
206					Some(e.into()),
207					None,
208				)
209			})?)
210			.subject(&self.subject)
211			.header(ContentType::TEXT_HTML)
212			.body(message.to_owned())
213			.map_err(|e| {
214				NotificationError::notify_failed(
215					format!("Failed to build email message: {}", e),
216					Some(e.into()),
217					None,
218				)
219			})?;
220
221		self.client.send(&email).map_err(|e| {
222			NotificationError::notify_failed(format!("Failed to send email: {}", e), None, None)
223		})?;
224
225		Ok(())
226	}
227}
228
229#[cfg(test)]
230mod tests {
231	use lettre::transport::smtp::authentication::Credentials;
232
233	use crate::{
234		models::{NotificationMessage, SecretString, SecretValue},
235		services::notification::pool::NotificationClientPool,
236	};
237
238	use super::*;
239
240	fn create_test_notifier() -> EmailNotifier<SmtpTransport> {
241		let smtp_config = SmtpConfig {
242			host: "dummy.smtp.com".to_string(),
243			port: 465,
244			username: "test".to_string(),
245			password: "test".to_string(),
246		};
247
248		let client = SmtpTransport::relay(&smtp_config.host)
249			.unwrap()
250			.port(smtp_config.port)
251			.credentials(Credentials::new(smtp_config.username, smtp_config.password))
252			.build();
253
254		let email_content = EmailContent {
255			subject: "Test Subject".to_string(),
256			body_template: "Hello ${name}, your balance is ${balance}".to_string(),
257			sender: "sender@test.com".parse().unwrap(),
258			recipients: vec!["recipient@test.com".parse().unwrap()],
259		};
260
261		EmailNotifier::new(Arc::new(client), email_content).unwrap()
262	}
263
264	fn create_test_email_config(port: Option<u16>) -> TriggerTypeConfig {
265		TriggerTypeConfig::Email {
266			host: "smtp.test.com".to_string(),
267			port,
268			username: SecretValue::Plain(SecretString::new("testuser".to_string())),
269			password: SecretValue::Plain(SecretString::new("testpass".to_string())),
270			message: NotificationMessage {
271				title: "Test Subject".to_string(),
272				body: "Hello ${name}".to_string(),
273			},
274			sender: "sender@test.com".parse().unwrap(),
275			recipients: vec!["recipient@test.com".parse().unwrap()],
276		}
277	}
278
279	////////////////////////////////////////////////////////////
280	// format_message tests
281	////////////////////////////////////////////////////////////
282
283	#[test]
284	fn test_format_message_basic_substitution() {
285		let notifier = create_test_notifier();
286		let mut variables = HashMap::new();
287		variables.insert("name".to_string(), "Alice".to_string());
288		variables.insert("balance".to_string(), "100".to_string());
289
290		let result = notifier.format_message(&variables);
291		let expected_result = "<p>Hello Alice, your balance is 100</p>\n";
292		assert_eq!(result, expected_result);
293	}
294
295	#[test]
296	fn test_format_message_missing_variable() {
297		let notifier = create_test_notifier();
298		let mut variables = HashMap::new();
299		variables.insert("name".to_string(), "Bob".to_string());
300
301		let result = notifier.format_message(&variables);
302		let expected_result = "<p>Hello Bob, your balance is ${balance}</p>\n";
303		assert_eq!(result, expected_result);
304	}
305
306	#[test]
307	fn test_format_message_empty_variables() {
308		let notifier = create_test_notifier();
309		let variables = HashMap::new();
310
311		let result = notifier.format_message(&variables);
312		let expected_result = "<p>Hello ${name}, your balance is ${balance}</p>\n";
313		assert_eq!(result, expected_result);
314	}
315
316	#[test]
317	fn test_format_message_with_empty_values() {
318		let notifier = create_test_notifier();
319		let mut variables = HashMap::new();
320		variables.insert("name".to_string(), "".to_string());
321		variables.insert("balance".to_string(), "".to_string());
322
323		let result = notifier.format_message(&variables);
324		let expected_result = "<p>Hello , your balance is</p>\n";
325		assert_eq!(result, expected_result);
326	}
327
328	////////////////////////////////////////////////////////////
329	// from_config tests
330	////////////////////////////////////////////////////////////
331
332	#[tokio::test]
333	async fn test_from_config_valid_email_config() {
334		let config = create_test_email_config(Some(587));
335		let smtp_config = match &config {
336			TriggerTypeConfig::Email {
337				host,
338				port,
339				username,
340				password,
341				..
342			} => SmtpConfig {
343				host: host.clone(),
344				port: port.unwrap_or(587),
345				username: username.to_string(),
346				password: password.to_string(),
347			},
348			_ => panic!("Expected Email config"),
349		};
350		let pool = NotificationClientPool::new();
351		let smtp_client = pool.get_or_create_smtp_client(&smtp_config).await.unwrap();
352		let notifier = EmailNotifier::from_config(&config, smtp_client);
353		assert!(notifier.is_ok());
354
355		let notifier = notifier.unwrap();
356		assert_eq!(notifier.subject, "Test Subject");
357		assert_eq!(notifier.body_template, "Hello ${name}");
358		assert_eq!(notifier.sender.to_string(), "sender@test.com");
359		assert_eq!(notifier.recipients.len(), 1);
360		assert_eq!(notifier.recipients[0].to_string(), "recipient@test.com");
361	}
362
363	#[tokio::test]
364	async fn test_from_config_default_port() {
365		let config = create_test_email_config(None);
366		let smtp_config = match &config {
367			TriggerTypeConfig::Email {
368				host,
369				port,
370				username,
371				password,
372				..
373			} => SmtpConfig {
374				host: host.clone(),
375				port: port.unwrap_or(587),
376				username: username.to_string(),
377				password: password.to_string(),
378			},
379			_ => panic!("Expected Email config"),
380		};
381		let pool = NotificationClientPool::new();
382		let smtp_client = pool.get_or_create_smtp_client(&smtp_config).await.unwrap();
383		let notifier = EmailNotifier::from_config(&config, smtp_client);
384		assert!(notifier.is_ok());
385	}
386
387	////////////////////////////////////////////////////////////
388	// notify tests
389	////////////////////////////////////////////////////////////
390
391	#[tokio::test]
392	async fn test_notify_failure() {
393		let notifier = create_test_notifier();
394		let result = notifier.notify("Test message").await;
395		// Expected to fail since we're using a dummy SMTP server
396		assert!(result.is_err());
397
398		let error = result.unwrap_err();
399		assert!(matches!(error, NotificationError::NotifyFailed { .. }));
400	}
401}