openzeppelin_monitor/services/notification/
email.rs1use 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#[derive(Debug)]
25pub struct EmailNotifier<T: Transport + Send + Sync> {
26 subject: String,
28 body_template: String,
30 client: T,
32 sender: EmailAddress,
34 recipients: Vec<EmailAddress>,
36}
37
38#[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#[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 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 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 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 pub fn markdown_to_html(md: &str) -> String {
120 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 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 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 #[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 #[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 #[tokio::test]
392 async fn test_notify_failure() {
393 let notifier = create_test_notifier();
394 let result = notifier.notify("Test message").await;
395 assert!(result.is_err());
397
398 let error = result.unwrap_err();
399 assert!(matches!(error, NotificationError::NotifyFailed { .. }));
400 }
401}