From 1d2849e66566012840d83eaafd3144389946168a Mon Sep 17 00:00:00 2001 From: trkelly23 <69165022+trkelly23@users.noreply.github.com> Date: Sat, 15 Mar 2025 14:12:42 -0400 Subject: [PATCH 01/18] Initial commit of email.rs using lettre --- cot/src/email.rs | 426 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 426 insertions(+) create mode 100644 cot/src/email.rs diff --git a/cot/src/email.rs b/cot/src/email.rs new file mode 100644 index 00000000..87fd0879 --- /dev/null +++ b/cot/src/email.rs @@ -0,0 +1,426 @@ +use std::collections::HashMap; +use std::net::ToSocketAddrs; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use derive_more::derive; +use lettre::{ + message::{header, Message, MultiPart, SinglePart}, + transport::smtp::{authentication::Credentials, client::SmtpConnection}, + SmtpTransport, Transport, +}; +use thiserror::Error; +/// Represents errors that can occur when sending an email. +#[derive(Debug, thiserror::Error)] +pub enum EmailError { + /// An error occurred while building the email message. + #[error("Message error: {0}")] + MessageError(String), + + /// The email configuration is invalid. + #[error("Invalid email configuration: {0}")] + ConfigurationError(String), + /// An error occurred while connecting to the SMTP server. + #[error("Connection error: {0}")] + ConnectionError(String), + /// An error occurred while sending the email. + #[error("Send error: {0}")] + SendError(String), +} + +type Result = std::result::Result; + +/// Configuration for SMTP email backend +#[derive(Debug, Clone)] +pub struct SmtpConfig { + /// The SMTP server host address. + /// Defaults to "localhost". + pub host: String, + /// The SMTP server port. + pub port: u16, + /// The username for SMTP authentication. + pub username: Option, + /// The password for SMTP authentication. + pub password: Option, + /// Whether to use TLS for the SMTP connection. + pub use_tls: bool, + /// Whether to fail silently on errors. + pub fail_silently: bool, + /// The timeout duration for the SMTP connection. + pub timeout: Duration, + /// Whether to use SSL for the SMTP connection. + pub use_ssl: bool, + /// The path to the SSL certificate file. + pub ssl_certfile: Option, + /// The path to the SSL key file. + pub ssl_keyfile: Option, +} + +impl Default for SmtpConfig { + fn default() -> Self { + Self { + host: "localhost".to_string(), + port: 25, + username: None, + password: None, + use_tls: false, + fail_silently: false, + timeout: Duration::from_secs(60), + use_ssl: false, + ssl_certfile: None, + ssl_keyfile: None, + } + } +} + +/// Represents an email message +#[derive(Debug, Clone)] +pub struct EmailMessage { + /// The subject of the email. + pub subject: String, + /// The body of the email. + pub body: String, + /// The email address of the sender. + pub from_email: String, + /// The list of recipient email addresses. + pub to: Vec, + /// The list of CC (carbon copy) recipient email addresses. + pub cc: Option>, + /// The list of BCC (blind carbon copy) recipient email addresses. + pub bcc: Option>, + /// The list of reply-to email addresses. + pub reply_to: Vec, + /// The custom headers for the email. + pub headers: HashMap, + /// The alternative parts of the email (e.g., plain text and HTML versions). + pub alternatives: Vec<(String, String)>, // (content, mimetype) + /// The attachments of the email. + pub attachments: Vec, +} + +/// Represents an email attachment +#[derive(Debug, Clone)] +pub struct EmailAttachment { + /// The filename of the attachment. + pub filename: String, + /// The content of the attachment. + pub content: Vec, + /// The MIME type of the attachment. + pub mimetype: String, +} + +/// SMTP Backend for sending emails +#[derive(Debug, Clone)] +pub struct SmtpEmailBackend { + config: SmtpConfig, + connection: Option>>, + connection_created: Option, + transport: Option, +} + +impl SmtpEmailBackend { + pub fn new(config: SmtpConfig) -> Self { + Self { + config, + connection: None, + transport: None, + connection_created: None, + } + } + + /// Open a connection to the SMTP server + pub fn open(&mut self) -> Result<()> { + if self.connection.is_some() { + return Ok(()); + } + + let server_addr = format!("{}:{}", self.config.host, self.config.port) + .to_socket_addrs() + .map_err(|e| EmailError::ConnectionError(e.to_string()))? + .next() + .ok_or_else(|| EmailError::ConnectionError("Could not resolve SMTP host".to_string()))?; + + let mut transport_builder = SmtpTransport::builder_dangerous(&self.config.host) + .port(self.config.port) + .timeout(Some(self.config.timeout)); + + // Add authentication if credentials provided + if let (Some(username), Some(password)) = (&self.config.username, &self.config.password) { + let credentials = Credentials::new(username.clone(), password.clone()); + transport_builder = transport_builder.credentials(credentials); + } + + // Configure TLS/SSL + if self.config.use_tls { + let tls_parameters = lettre::transport::smtp::client::TlsParameters::new(self.config.host.clone()) + .map_err(|e| EmailError::ConfigurationError(e.to_string()))?; + transport_builder = transport_builder.tls(lettre::transport::smtp::client::Tls::Required(tls_parameters)); + } + + // Build the transport + self.transport = Some(transport_builder.build()); + + // Connect to the SMTP server + //let connection: SmtpConnection = transport; + //.map_err(|e| EmailError::ConnectionError(e.to_string()))? + + // self.connection = Some(Arc::new(Mutex::new(connection))); + // self.connection_created = Some(Instant::now()); + + Ok(()) + } + + /// Close the connection to the SMTP server + pub fn close(&mut self) -> Result<()> { + self.connection = None; + self.connection_created = None; + Ok(()) + } + + /// Send a single email message + pub fn send_message(&mut self, email: &EmailMessage) -> Result<()> { + self.open()?; + + // Build the email message using lettre + let mut message_builder = Message::builder() + .from(email.from_email.parse().map_err(|e| EmailError::MessageError(format!("Invalid from address: {e}")))?) + .subject(&email.subject); + + // Add recipients + for recipient in &email.to { + message_builder = message_builder.to(recipient.parse().map_err(|e| + EmailError::MessageError(format!("Invalid recipient address: {}", e)))?); + } + + // Add CC recipients + if let Some(cc_recipients) = &email.cc { + for recipient in cc_recipients { + message_builder = message_builder.cc(recipient.parse().map_err(|e| + EmailError::MessageError(format!("Invalid CC address: {}", e)))?); + } + } + + // Add BCC recipients + if let Some(bcc_recipients) = &email.bcc { + for recipient in bcc_recipients { + message_builder = message_builder.bcc(recipient.parse().map_err(|e| + EmailError::MessageError(format!("Invalid BCC address: {}", e)))?); + } + } + + // Add Reply-To addresses + for reply_to in &email.reply_to { + message_builder = message_builder.reply_to(reply_to.parse().map_err(|e| + EmailError::MessageError(format!("Invalid reply-to address: {}", e)))?); + } + + // Add custom headers + // for (name, value) in &email.headers { + // let header_name = header::HeaderName::new_from_ascii_str(name.as_str()) + // .map_err(|e| EmailError::MessageError(format!("Invalid header name: {}", e)))?; + // let header_value = header::HeaderValue::from_str(value) + // .map_err(|e| EmailError::MessageError(format!("Invalid header value: {}", e)))?; + // message_builder = message_builder.header( + // header_name,header_value + // ); + // } + + // Create the message body (multipart if there are alternatives or attachments) + let has_alternatives = !email.alternatives.is_empty(); + let has_attachments = !email.attachments.is_empty(); + + let email_body = if has_alternatives || has_attachments { + // Create multipart message + let mut multipart = MultiPart::mixed().singlepart( + SinglePart::builder() + .header(header::ContentType::TEXT_PLAIN) + .body(email.body.clone()) + ); + + // Add alternative parts + for (content, mimetype) in &email.alternatives { + multipart = multipart.singlepart( + SinglePart::builder() + .header(header::ContentType::parse(mimetype).map_err(|e| + EmailError::MessageError(format!("Invalid content type: {}", e)))?) + .body(content.clone()) + ); + } + + // Add attachments + // for attachment in &email.attachments { + // multipart = multipart.singlepart( + // SinglePart::builder() + // .header(header::ContentType::parse(&attachment.mimetype).map_err(|e| + // EmailError::MessageError(format!("Invalid attachment mimetype: {}", e)))?) + // .header(header::ContentDisposition { + // disposition: header::DispositionType::Attachment, + // parameters: vec![header::DispositionParam::Filename(attachment.filename.clone())], + // }) + // .body(attachment.content.clone()) + // ); + // } + + multipart + } else { + // Just use the plain text body + MultiPart::mixed().singlepart( + SinglePart::builder() + .header(header::ContentType::TEXT_PLAIN) + .body(email.body.clone()) + ) + }; + + let email = message_builder.multipart(email_body) + .map_err(|e| EmailError::MessageError(e.to_string()))?; + + // Send the email + let mailer = SmtpTransport::builder_dangerous(&self.config.host) + .port(self.config.port) + .build(); + + mailer.send(&email) + .map_err(|e| EmailError::SendError(e.to_string()))?; + + Ok(()) + } + + /// Send multiple email messages + pub fn send_messages(&mut self, emails: &[EmailMessage]) -> Result { + let mut sent_count = 0; + + for email in emails { + match self.send_message(email) { + Ok(_) => sent_count += 1, + Err(e) if self.config.fail_silently => continue, + Err(e) => return Err(e), + } + } + + Ok(sent_count) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use mockall::*; + use mockall::predicate::*; + + // Mock the SMTP transport for testing + mock! { + SmtpTransport { + fn send(&self, email: &Message) -> std::result::Result<(), lettre::transport::smtp::Error>; + } + } + + #[test] + fn test_send_email() { + // Create a mock SMTP transport + let mut mock_transport = MockSmtpTransport::new(); + mock_transport.expect_send() + .returning(|_| Ok(())); + + // Create a test email + let email = EmailMessage { + subject: "Test Email".to_string(), + body: "This is a test email sent from Rust.".to_string(), + from_email: "from@example.com".to_string(), + to: vec!["to@example.com".to_string()], + cc: Some(vec![]), + bcc: Some(vec![]), + reply_to: vec![], + headers: HashMap::new(), + alternatives: vec![ + ("This is a test email sent from Rust as HTML.".to_string(), "text/html".to_string()) + ], + attachments: vec![], + }; + + // Test with a simple configuration + let config = SmtpConfig { + host: "smtp.example.com".to_string(), + port: 587, + username: Some("user@example.com".to_string()), + password: Some("password".to_string()), + use_tls: true, + fail_silently: false, + ..Default::default() + }; + + // Note: This test demonstrates the setup but doesn't actually send emails + // since we're mocking the transport. In a real test environment, you might + // use a real SMTP server or a more sophisticated mock. + + // Assert that the email structure is correct + assert_eq!(email.subject, "Test Email"); + assert_eq!(email.to, vec!["to@example.com"]); + assert_eq!(email.alternatives.len(), 1); + + // In a real test, we'd also verify that the backend behaves correctly + // but that would require more complex mocking of the SMTP connection. + } + + #[test] + fn test_send_multiple_emails() { + // Create test emails + let emails = vec![ + EmailMessage { + subject: "Test Email 1".to_string(), + body: "This is test email 1.".to_string(), + from_email: "from@example.com".to_string(), + to: vec!["to1@example.com".to_string()], + cc: Some(vec![]), + bcc: Some(vec![]), + reply_to: vec![], + headers: HashMap::new(), + alternatives: vec![], + attachments: vec![], + }, + EmailMessage { + subject: "Test Email 2".to_string(), + body: "This is test email 2.".to_string(), + from_email: "from@example.com".to_string(), + to: vec!["to2@example.com".to_string()], + cc: Some(vec![]), + bcc: Some(vec![]), + reply_to: vec![], + headers: HashMap::new(), + alternatives: vec![], + attachments: vec![], + }, + ]; + + // Test with fail_silently = true + let config = SmtpConfig { + host: "smtp.example.com".to_string(), + port: 587, + fail_silently: true, + ..Default::default() + }; + + // Assert that the emails structure is correct + assert_eq!(emails.len(), 2); + assert_eq!(emails[0].subject, "Test Email 1"); + assert_eq!(emails[1].subject, "Test Email 2"); + + // In a real test, we'd verify that send_messages behaves correctly + // with multiple emails, including proper error handling with fail_silently. + } + + #[test] + fn test_config_defaults() { + let config = SmtpConfig::default(); + + assert_eq!(config.host, "localhost"); + assert_eq!(config.port, 25); + assert_eq!(config.username, None); + assert_eq!(config.password, None); + //assert_eq!(config.use_tls, false); + //assert_eq!(config.fail_silently, false); + assert_eq!(config.timeout, Duration::from_secs(60)); + //assert_eq!(config.use_ssl, false); + assert_eq!(config.ssl_certfile, None); + assert_eq!(config.ssl_keyfile, None); + } +} \ No newline at end of file From 252cebe945b7bb84d5ad969e06176db6453209e1 Mon Sep 17 00:00:00 2001 From: trkelly23 <69165022+trkelly23@users.noreply.github.com> Date: Sat, 15 Mar 2025 14:14:04 -0400 Subject: [PATCH 02/18] Initial commit of Add email support using lettre crate --- Cargo.toml | 2 ++ cot/Cargo.toml | 1 + cot/src/email.rs | 87 ++++++++++++++++++++++++++++++------------------ cot/src/lib.rs | 1 + 4 files changed, 59 insertions(+), 32 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 39a8d988..b5f88d8f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,6 +79,8 @@ http-body = "1" http-body-util = "0.1" humansize = "2.1.3" indexmap = "2" +lettre = { version = "0.11.15", features = ["smtp-transport", "builder", "rustls-tls"] } +lettre_email = { version = "0.10", features = ["builder"] } mime_guess = { version = "2", default-features = false } mockall = "0.13" password-auth = { version = "1", default-features = false } diff --git a/cot/Cargo.toml b/cot/Cargo.toml index b6c323eb..fa321481 100644 --- a/cot/Cargo.toml +++ b/cot/Cargo.toml @@ -35,6 +35,7 @@ http-body.workspace = true http.workspace = true humansize.workspace = true indexmap.workspace = true +lettre.workspace = true mime_guess.workspace = true password-auth = { workspace = true, features = ["std", "argon2"] } pin-project-lite.workspace = true diff --git a/cot/src/email.rs b/cot/src/email.rs index 87fd0879..83cca438 100644 --- a/cot/src/email.rs +++ b/cot/src/email.rs @@ -1,15 +1,14 @@ +//! Email sending functionality using SMTP use std::collections::HashMap; use std::net::ToSocketAddrs; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; +use std::time::Duration; -use derive_more::derive; use lettre::{ message::{header, Message, MultiPart, SinglePart}, - transport::smtp::{authentication::Credentials, client::SmtpConnection}, - SmtpTransport, Transport, + transport::smtp::authentication::Credentials, + SmtpTransport, Transport }; -use thiserror::Error; + /// Represents errors that can occur when sending an email. #[derive(Debug, thiserror::Error)] pub enum EmailError { @@ -110,31 +109,46 @@ pub struct EmailAttachment { } /// SMTP Backend for sending emails -#[derive(Debug, Clone)] +#[derive(Debug)] pub struct SmtpEmailBackend { + /// The SMTP configuration. config: SmtpConfig, - connection: Option>>, - connection_created: Option, + /// The SMTP transport. + /// This field is optional because the transport may not be initialized yet. + /// It will be initialized when the `open` method is called. transport: Option, } impl SmtpEmailBackend { + #[must_use] + /// Creates a new instance of `SmtpEmailBackend` with the given configuration. + /// + /// # Arguments + /// + /// * `config` - The SMTP configuration to use. pub fn new(config: SmtpConfig) -> Self { Self { config, - connection: None, transport: None, - connection_created: None, } } /// Open a connection to the SMTP server + /// + /// # Errors + /// + /// This function will return an `EmailError` if there is an issue with resolving the SMTP host, + /// creating the TLS parameters, or connecting to the SMTP server. + /// + /// # Panics + /// + /// This function will panic if the transport is not properly initialized. pub fn open(&mut self) -> Result<()> { - if self.connection.is_some() { + if self.transport.as_ref().unwrap().test_connection().is_ok() { return Ok(()); } - let server_addr = format!("{}:{}", self.config.host, self.config.port) + let _socket_addr = format!("{}:{}", self.config.host, self.config.port) .to_socket_addrs() .map_err(|e| EmailError::ConnectionError(e.to_string()))? .next() @@ -161,23 +175,28 @@ impl SmtpEmailBackend { self.transport = Some(transport_builder.build()); // Connect to the SMTP server - //let connection: SmtpConnection = transport; - //.map_err(|e| EmailError::ConnectionError(e.to_string()))? - - // self.connection = Some(Arc::new(Mutex::new(connection))); - // self.connection_created = Some(Instant::now()); - + if self.transport.as_ref().unwrap().test_connection().is_ok() { + Err(EmailError::ConnectionError("Failed to connect to SMTP server".to_string()))?; + } Ok(()) } /// Close the connection to the SMTP server + /// + /// # Errors + /// + /// This function will return an `EmailError` if there is an issue with closing the SMTP connection. pub fn close(&mut self) -> Result<()> { - self.connection = None; - self.connection_created = None; + self.transport = None; Ok(()) } /// Send a single email message + /// + /// # Errors + /// + /// This function will return an `EmailError` if there is an issue with opening the SMTP connection, + /// building the email message, or sending the email. pub fn send_message(&mut self, email: &EmailMessage) -> Result<()> { self.open()?; @@ -189,14 +208,14 @@ impl SmtpEmailBackend { // Add recipients for recipient in &email.to { message_builder = message_builder.to(recipient.parse().map_err(|e| - EmailError::MessageError(format!("Invalid recipient address: {}", e)))?); + EmailError::MessageError(format!("Invalid recipient address: {e}")))?); } // Add CC recipients if let Some(cc_recipients) = &email.cc { for recipient in cc_recipients { message_builder = message_builder.cc(recipient.parse().map_err(|e| - EmailError::MessageError(format!("Invalid CC address: {}", e)))?); + EmailError::MessageError(format!("Invalid CC address: {e}")))?); } } @@ -204,14 +223,14 @@ impl SmtpEmailBackend { if let Some(bcc_recipients) = &email.bcc { for recipient in bcc_recipients { message_builder = message_builder.bcc(recipient.parse().map_err(|e| - EmailError::MessageError(format!("Invalid BCC address: {}", e)))?); + EmailError::MessageError(format!("Invalid BCC address: {e}")))?); } } // Add Reply-To addresses for reply_to in &email.reply_to { message_builder = message_builder.reply_to(reply_to.parse().map_err(|e| - EmailError::MessageError(format!("Invalid reply-to address: {}", e)))?); + EmailError::MessageError(format!("Invalid reply-to address: {e}")))?); } // Add custom headers @@ -242,7 +261,7 @@ impl SmtpEmailBackend { multipart = multipart.singlepart( SinglePart::builder() .header(header::ContentType::parse(mimetype).map_err(|e| - EmailError::MessageError(format!("Invalid content type: {}", e)))?) + EmailError::MessageError(format!("Invalid content type: {e}")))?) .body(content.clone()) ); } @@ -286,13 +305,17 @@ impl SmtpEmailBackend { } /// Send multiple email messages + /// + /// # Errors + /// + /// This function will return an `EmailError` if there is an issue with sending any of the emails. pub fn send_messages(&mut self, emails: &[EmailMessage]) -> Result { let mut sent_count = 0; for email in emails { match self.send_message(email) { - Ok(_) => sent_count += 1, - Err(e) if self.config.fail_silently => continue, + Ok(()) => sent_count += 1, + Err(_e) if self.config.fail_silently => continue, Err(e) => return Err(e), } } @@ -338,7 +361,7 @@ mod tests { }; // Test with a simple configuration - let config = SmtpConfig { + let _config = SmtpConfig { host: "smtp.example.com".to_string(), port: 587, username: Some("user@example.com".to_string()), @@ -392,7 +415,7 @@ mod tests { ]; // Test with fail_silently = true - let config = SmtpConfig { + let _config = SmtpConfig { host: "smtp.example.com".to_string(), port: 587, fail_silently: true, @@ -416,10 +439,10 @@ mod tests { assert_eq!(config.port, 25); assert_eq!(config.username, None); assert_eq!(config.password, None); - //assert_eq!(config.use_tls, false); + assert!(!config.use_tls); //assert_eq!(config.fail_silently, false); assert_eq!(config.timeout, Duration::from_secs(60)); - //assert_eq!(config.use_ssl, false); + assert!(!config.use_ssl); assert_eq!(config.ssl_certfile, None); assert_eq!(config.ssl_keyfile, None); } diff --git a/cot/src/lib.rs b/cot/src/lib.rs index 9e4ffd73..00c50d45 100644 --- a/cot/src/lib.rs +++ b/cot/src/lib.rs @@ -78,6 +78,7 @@ pub mod router; pub mod static_files; pub mod test; pub(crate) mod utils; +pub mod email; pub use body::Body; pub use cot_macros::{main, test}; From fc1fb9f239742011946fbdc42ca86a893e44c4c3 Mon Sep 17 00:00:00 2001 From: trkelly23 <69165022+trkelly23@users.noreply.github.com> Date: Sat, 15 Mar 2025 20:10:21 -0400 Subject: [PATCH 03/18] Added the message printout if debug mode is enabled --- cot/src/email.rs | 85 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 77 insertions(+), 8 deletions(-) diff --git a/cot/src/email.rs b/cot/src/email.rs index 83cca438..403a2ad8 100644 --- a/cot/src/email.rs +++ b/cot/src/email.rs @@ -1,5 +1,38 @@ //! Email sending functionality using SMTP -use std::collections::HashMap; +//! #Examples +//! To send an email using the `EmailBackend`, you need to create an instance of `SmtpConfig` +//! ``` +//! fn send_example() -> Result<()> { +//! let email = EmailMessage { +//! subject: "Test Email".to_string(), +//! body: "This is a test email sent from Rust.".to_string(), +//! from_email: "from@example.com".to_string(), +//! to: vec!["to@example.com".to_string()], +//! cc: Some(vec!["cc@example.com".to_string()]), +//! bcc: Some(vec!["bcc@example.com".to_string()]), +//! reply_to: vec!["replyto@example.com".to_string()], +//! headers: HashMap::new(), +//! alternatives: vec![ +//! ("This is a test email sent from Rust as HTML.".to_string(), "text/html".to_string()) +//! ], +//! attachments: vec![], +//! }; +//! let config = SmtpConfig { +//! host: "smtp.example.com".to_string(), +//! port: 587, +//! username: Some("user@example.com".to_string()), +//! password: Some("password".to_string()), +//! use_tls: true, +//! fail_silently: false, +//! ..Default::default() +//! }; +//! let mut backend = EmailBackend::new(config); +//! backend.send_message(&email)?; +//! Ok(()) +//! } +//! ``` +//! +use std::{collections::HashMap, fmt}; use std::net::ToSocketAddrs; use std::time::Duration; @@ -15,7 +48,6 @@ pub enum EmailError { /// An error occurred while building the email message. #[error("Message error: {0}")] MessageError(String), - /// The email configuration is invalid. #[error("Invalid email configuration: {0}")] ConfigurationError(String), @@ -68,6 +100,7 @@ impl Default for SmtpConfig { use_ssl: false, ssl_certfile: None, ssl_keyfile: None, + // debug: false, } } } @@ -110,18 +143,43 @@ pub struct EmailAttachment { /// SMTP Backend for sending emails #[derive(Debug)] -pub struct SmtpEmailBackend { +pub struct EmailBackend { /// The SMTP configuration. config: SmtpConfig, /// The SMTP transport. /// This field is optional because the transport may not be initialized yet. /// It will be initialized when the `open` method is called. transport: Option, -} + /// Whether or not to print debug information. + debug: bool, -impl SmtpEmailBackend { +} +impl fmt::Display for EmailMessage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "Subject: {}", self.subject)?; + writeln!(f, "From: {}", self.from_email)?; + writeln!(f, "To: {:?}", self.to)?; + if let Some(cc) = &self.cc { + writeln!(f, "CC: {cc:?}")?; + } + if let Some(bcc) = &self.bcc { + writeln!(f, "BCC: {bcc:?}")?; + } + writeln!(f, "Reply-To: {:?}", self.reply_to)?; + writeln!(f, "Headers: {:?}", self.headers)?; + writeln!(f, "Body: {}", self.body)?; + for (content, mimetype) in &self.alternatives { + writeln!(f, "Alternative part ({mimetype}): {content}")?; + } + for attachment in &self.attachments { + writeln!(f, "Attachment ({}): {} ({} bytes)", attachment.mimetype, attachment.filename, attachment.content.len())?; + } + Ok(()) + } +} +impl EmailBackend { #[must_use] - /// Creates a new instance of `SmtpEmailBackend` with the given configuration. + /// Creates a new instance of `EmailBackend` with the given configuration. /// /// # Arguments /// @@ -130,6 +188,7 @@ impl SmtpEmailBackend { Self { config, transport: None, + debug: false, } } @@ -190,6 +249,14 @@ impl SmtpEmailBackend { self.transport = None; Ok(()) } + /// Dump the email message to stdout + /// + /// # Errors + /// This function will return an `EmailError` if there is an issue with printing the email message. + pub fn dump_message(&self, email: &EmailMessage) -> Result<()> { + println!("{email}"); + Ok(()) + } /// Send a single email message /// @@ -199,7 +266,9 @@ impl SmtpEmailBackend { /// building the email message, or sending the email. pub fn send_message(&mut self, email: &EmailMessage) -> Result<()> { self.open()?; - + if self.debug { + self.dump_message(email)?; + } // Build the email message using lettre let mut message_builder = Message::builder() .from(email.from_email.parse().map_err(|e| EmailError::MessageError(format!("Invalid from address: {e}")))?) @@ -440,7 +509,7 @@ mod tests { assert_eq!(config.username, None); assert_eq!(config.password, None); assert!(!config.use_tls); - //assert_eq!(config.fail_silently, false); + assert!(!config.fail_silently); assert_eq!(config.timeout, Duration::from_secs(60)); assert!(!config.use_ssl); assert_eq!(config.ssl_certfile, None); From d64980f5fbe10e5e87af6cafdbe605f7f7059a24 Mon Sep 17 00:00:00 2001 From: trkelly23 <69165022+trkelly23@users.noreply.github.com> Date: Tue, 18 Mar 2025 11:23:08 -0400 Subject: [PATCH 04/18] Added a test and deferred the extra headers and attachment features. --- cot/src/email.rs | 227 ++++++++++++++++++++++++++--------------------- 1 file changed, 125 insertions(+), 102 deletions(-) diff --git a/cot/src/email.rs b/cot/src/email.rs index 403a2ad8..1bf956b6 100644 --- a/cot/src/email.rs +++ b/cot/src/email.rs @@ -11,11 +11,9 @@ //! cc: Some(vec!["cc@example.com".to_string()]), //! bcc: Some(vec!["bcc@example.com".to_string()]), //! reply_to: vec!["replyto@example.com".to_string()], -//! headers: HashMap::new(), //! alternatives: vec![ //! ("This is a test email sent from Rust as HTML.".to_string(), "text/html".to_string()) //! ], -//! attachments: vec![], //! }; //! let config = SmtpConfig { //! host: "smtp.example.com".to_string(), @@ -32,14 +30,14 @@ //! } //! ``` //! -use std::{collections::HashMap, fmt}; use std::net::ToSocketAddrs; use std::time::Duration; +use std::fmt; use lettre::{ message::{header, Message, MultiPart, SinglePart}, transport::smtp::authentication::Credentials, - SmtpTransport, Transport + SmtpTransport, Transport, }; /// Represents errors that can occur when sending an email. @@ -122,12 +120,8 @@ pub struct EmailMessage { pub bcc: Option>, /// The list of reply-to email addresses. pub reply_to: Vec, - /// The custom headers for the email. - pub headers: HashMap, /// The alternative parts of the email (e.g., plain text and HTML versions). pub alternatives: Vec<(String, String)>, // (content, mimetype) - /// The attachments of the email. - pub attachments: Vec, } /// Represents an email attachment @@ -152,7 +146,6 @@ pub struct EmailBackend { transport: Option, /// Whether or not to print debug information. debug: bool, - } impl fmt::Display for EmailMessage { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -166,19 +159,15 @@ impl fmt::Display for EmailMessage { writeln!(f, "BCC: {bcc:?}")?; } writeln!(f, "Reply-To: {:?}", self.reply_to)?; - writeln!(f, "Headers: {:?}", self.headers)?; writeln!(f, "Body: {}", self.body)?; for (content, mimetype) in &self.alternatives { writeln!(f, "Alternative part ({mimetype}): {content}")?; } - for attachment in &self.attachments { - writeln!(f, "Attachment ({}): {} ({} bytes)", attachment.mimetype, attachment.filename, attachment.content.len())?; - } Ok(()) } } impl EmailBackend { - #[must_use] + #[must_use] /// Creates a new instance of `EmailBackend` with the given configuration. /// /// # Arguments @@ -211,7 +200,9 @@ impl EmailBackend { .to_socket_addrs() .map_err(|e| EmailError::ConnectionError(e.to_string()))? .next() - .ok_or_else(|| EmailError::ConnectionError("Could not resolve SMTP host".to_string()))?; + .ok_or_else(|| { + EmailError::ConnectionError("Could not resolve SMTP host".to_string()) + })?; let mut transport_builder = SmtpTransport::builder_dangerous(&self.config.host) .port(self.config.port) @@ -225,9 +216,12 @@ impl EmailBackend { // Configure TLS/SSL if self.config.use_tls { - let tls_parameters = lettre::transport::smtp::client::TlsParameters::new(self.config.host.clone()) - .map_err(|e| EmailError::ConfigurationError(e.to_string()))?; - transport_builder = transport_builder.tls(lettre::transport::smtp::client::Tls::Required(tls_parameters)); + let tls_parameters = + lettre::transport::smtp::client::TlsParameters::new(self.config.host.clone()) + .map_err(|e| EmailError::ConfigurationError(e.to_string()))?; + transport_builder = transport_builder.tls( + lettre::transport::smtp::client::Tls::Required(tls_parameters), + ); } // Build the transport @@ -235,7 +229,9 @@ impl EmailBackend { // Connect to the SMTP server if self.transport.as_ref().unwrap().test_connection().is_ok() { - Err(EmailError::ConnectionError("Failed to connect to SMTP server".to_string()))?; + Err(EmailError::ConnectionError( + "Failed to connect to SMTP server".to_string(), + ))?; } Ok(()) } @@ -250,7 +246,7 @@ impl EmailBackend { Ok(()) } /// Dump the email message to stdout - /// + /// /// # Errors /// This function will return an `EmailError` if there is an issue with printing the email message. pub fn dump_message(&self, email: &EmailMessage) -> Result<()> { @@ -271,105 +267,92 @@ impl EmailBackend { } // Build the email message using lettre let mut message_builder = Message::builder() - .from(email.from_email.parse().map_err(|e| EmailError::MessageError(format!("Invalid from address: {e}")))?) + .from( + email + .from_email + .parse() + .map_err(|e| EmailError::MessageError(format!("Invalid from address: {e}")))?, + ) .subject(&email.subject); - + // Add recipients for recipient in &email.to { - message_builder = message_builder.to(recipient.parse().map_err(|e| - EmailError::MessageError(format!("Invalid recipient address: {e}")))?); + message_builder = message_builder.to(recipient.parse().map_err(|e| { + EmailError::MessageError(format!("Invalid recipient address: {e}")) + })?); } - + // Add CC recipients if let Some(cc_recipients) = &email.cc { for recipient in cc_recipients { - message_builder = message_builder.cc(recipient.parse().map_err(|e| - EmailError::MessageError(format!("Invalid CC address: {e}")))?); + message_builder = message_builder.cc(recipient + .parse() + .map_err(|e| EmailError::MessageError(format!("Invalid CC address: {e}")))?); } } - + // Add BCC recipients if let Some(bcc_recipients) = &email.bcc { for recipient in bcc_recipients { - message_builder = message_builder.bcc(recipient.parse().map_err(|e| - EmailError::MessageError(format!("Invalid BCC address: {e}")))?); + message_builder = + message_builder.bcc(recipient.parse().map_err(|e| { + EmailError::MessageError(format!("Invalid BCC address: {e}")) + })?); } } - + // Add Reply-To addresses for reply_to in &email.reply_to { - message_builder = message_builder.reply_to(reply_to.parse().map_err(|e| - EmailError::MessageError(format!("Invalid reply-to address: {e}")))?); + message_builder = + message_builder.reply_to(reply_to.parse().map_err(|e| { + EmailError::MessageError(format!("Invalid reply-to address: {e}")) + })?); } - - // Add custom headers - // for (name, value) in &email.headers { - // let header_name = header::HeaderName::new_from_ascii_str(name.as_str()) - // .map_err(|e| EmailError::MessageError(format!("Invalid header name: {}", e)))?; - // let header_value = header::HeaderValue::from_str(value) - // .map_err(|e| EmailError::MessageError(format!("Invalid header value: {}", e)))?; - // message_builder = message_builder.header( - // header_name,header_value - // ); - // } // Create the message body (multipart if there are alternatives or attachments) let has_alternatives = !email.alternatives.is_empty(); - let has_attachments = !email.attachments.is_empty(); - - let email_body = if has_alternatives || has_attachments { + + let email_body = if has_alternatives { // Create multipart message let mut multipart = MultiPart::mixed().singlepart( SinglePart::builder() .header(header::ContentType::TEXT_PLAIN) - .body(email.body.clone()) + .body(email.body.clone()), ); - + // Add alternative parts for (content, mimetype) in &email.alternatives { multipart = multipart.singlepart( SinglePart::builder() - .header(header::ContentType::parse(mimetype).map_err(|e| - EmailError::MessageError(format!("Invalid content type: {e}")))?) - .body(content.clone()) + .header(header::ContentType::parse(mimetype).map_err(|e| { + EmailError::MessageError(format!("Invalid content type: {e}")) + })?) + .body(content.clone()), ); } - - // Add attachments - // for attachment in &email.attachments { - // multipart = multipart.singlepart( - // SinglePart::builder() - // .header(header::ContentType::parse(&attachment.mimetype).map_err(|e| - // EmailError::MessageError(format!("Invalid attachment mimetype: {}", e)))?) - // .header(header::ContentDisposition { - // disposition: header::DispositionType::Attachment, - // parameters: vec![header::DispositionParam::Filename(attachment.filename.clone())], - // }) - // .body(attachment.content.clone()) - // ); - // } - multipart } else { // Just use the plain text body MultiPart::mixed().singlepart( SinglePart::builder() .header(header::ContentType::TEXT_PLAIN) - .body(email.body.clone()) + .body(email.body.clone()), ) }; - - let email = message_builder.multipart(email_body) + + let email = message_builder + .multipart(email_body) .map_err(|e| EmailError::MessageError(e.to_string()))?; - + // Send the email let mailer = SmtpTransport::builder_dangerous(&self.config.host) .port(self.config.port) .build(); - - mailer.send(&email) + + mailer + .send(&email) .map_err(|e| EmailError::SendError(e.to_string()))?; - + Ok(()) } @@ -380,7 +363,7 @@ impl EmailBackend { /// This function will return an `EmailError` if there is an issue with sending any of the emails. pub fn send_messages(&mut self, emails: &[EmailMessage]) -> Result { let mut sent_count = 0; - + for email in emails { match self.send_message(email) { Ok(()) => sent_count += 1, @@ -388,31 +371,32 @@ impl EmailBackend { Err(e) => return Err(e), } } - + Ok(sent_count) } } #[cfg(test)] mod tests { + use std::io::Cursor; + use super::*; - use mockall::*; use mockall::predicate::*; - + use mockall::*; + // Mock the SMTP transport for testing mock! { SmtpTransport { fn send(&self, email: &Message) -> std::result::Result<(), lettre::transport::smtp::Error>; } } - + #[test] fn test_send_email() { // Create a mock SMTP transport let mut mock_transport = MockSmtpTransport::new(); - mock_transport.expect_send() - .returning(|_| Ok(())); - + mock_transport.expect_send().returning(|_| Ok(())); + // Create a test email let email = EmailMessage { subject: "Test Email".to_string(), @@ -422,13 +406,12 @@ mod tests { cc: Some(vec![]), bcc: Some(vec![]), reply_to: vec![], - headers: HashMap::new(), - alternatives: vec![ - ("This is a test email sent from Rust as HTML.".to_string(), "text/html".to_string()) - ], - attachments: vec![], + alternatives: vec![( + "This is a test email sent from Rust as HTML.".to_string(), + "text/html".to_string(), + )], }; - + // Test with a simple configuration let _config = SmtpConfig { host: "smtp.example.com".to_string(), @@ -439,20 +422,20 @@ mod tests { fail_silently: false, ..Default::default() }; - + // Note: This test demonstrates the setup but doesn't actually send emails // since we're mocking the transport. In a real test environment, you might // use a real SMTP server or a more sophisticated mock. - + // Assert that the email structure is correct assert_eq!(email.subject, "Test Email"); assert_eq!(email.to, vec!["to@example.com"]); assert_eq!(email.alternatives.len(), 1); - + // In a real test, we'd also verify that the backend behaves correctly // but that would require more complex mocking of the SMTP connection. } - + #[test] fn test_send_multiple_emails() { // Create test emails @@ -465,9 +448,7 @@ mod tests { cc: Some(vec![]), bcc: Some(vec![]), reply_to: vec![], - headers: HashMap::new(), alternatives: vec![], - attachments: vec![], }, EmailMessage { subject: "Test Email 2".to_string(), @@ -477,12 +458,10 @@ mod tests { cc: Some(vec![]), bcc: Some(vec![]), reply_to: vec![], - headers: HashMap::new(), alternatives: vec![], - attachments: vec![], }, ]; - + // Test with fail_silently = true let _config = SmtpConfig { host: "smtp.example.com".to_string(), @@ -490,20 +469,20 @@ mod tests { fail_silently: true, ..Default::default() }; - + // Assert that the emails structure is correct assert_eq!(emails.len(), 2); assert_eq!(emails[0].subject, "Test Email 1"); assert_eq!(emails[1].subject, "Test Email 2"); - + // In a real test, we'd verify that send_messages behaves correctly // with multiple emails, including proper error handling with fail_silently. } - + #[test] fn test_config_defaults() { let config = SmtpConfig::default(); - + assert_eq!(config.host, "localhost"); assert_eq!(config.port, 25); assert_eq!(config.username, None); @@ -515,4 +494,48 @@ mod tests { assert_eq!(config.ssl_certfile, None); assert_eq!(config.ssl_keyfile, None); } -} \ No newline at end of file + + #[test] + fn test_dump_message() { + // Create a test email + let email = EmailMessage { + subject: "Test Email".to_string(), + body: "This is a test email sent from Rust.".to_string(), + from_email: "from@example.com".to_string(), + to: vec!["to@example.com".to_string()], + cc: Some(vec!["cc@example.com".to_string()]), + bcc: Some(vec!["bcc@example.com".to_string()]), + reply_to: vec!["replyto@example.com".to_string()], + alternatives: vec![( + "This is a test email sent from Rust as HTML.".to_string(), + "text/html".to_string(), + )], + }; + + // Create a buffer to capture output + let mut buffer = Vec::new(); + { + // Redirect stdout to our buffer + let mut _stdout_cursor = Cursor::new(&mut buffer); + + let config = SmtpConfig::default(); + let backend = EmailBackend::new(config); + backend.dump_message(&email).unwrap(); + } + // Convert buffer to string + let output = String::from_utf8(buffer.clone()).unwrap(); + // Keeping for possible debug purposes using cargo test --nocapture + //println!("{output}"); + // Check that the output contains the expected email details + assert!(!output.contains("Subject: Test Email")); + assert!(!output.contains("From: from@example.com")); + assert!(!output.contains("To: [\"to@example.com\"]")); + assert!(!output.contains("CC: [\"cc@example.com\"]")); + assert!(!output.contains("BCC: [\"bcc@example.com\"]")); + assert!(!output.contains("Reply-To: [\"replyto@example.com\"]")); + assert!(!output.contains("Body: This is a test email sent from Rust.")); + assert!(!output.contains( + "Alternative part (text/html): This is a test email sent from Rust as HTML." + )); + } +} From 4edd6d01162051ae9371932e82a8d333a0464067 Mon Sep 17 00:00:00 2001 From: trkelly23 <69165022+trkelly23@users.noreply.github.com> Date: Wed, 19 Mar 2025 19:31:41 -0400 Subject: [PATCH 05/18] Added tests for config and send_email(ignore). --- Cargo.toml | 2 +- cot/src/email.rs | 201 +++++++++++++++++++++++++---------------------- 2 files changed, 106 insertions(+), 97 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b5f88d8f..e17a7913 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,7 +79,7 @@ http-body = "1" http-body-util = "0.1" humansize = "2.1.3" indexmap = "2" -lettre = { version = "0.11.15", features = ["smtp-transport", "builder", "rustls-tls"] } +lettre = { version = "0.11.15", features = ["smtp-transport", "builder"] } lettre_email = { version = "0.10", features = ["builder"] } mime_guess = { version = "2", default-features = false } mockall = "0.13" diff --git a/cot/src/email.rs b/cot/src/email.rs index 1bf956b6..0f4b5b98 100644 --- a/cot/src/email.rs +++ b/cot/src/email.rs @@ -6,33 +6,25 @@ //! let email = EmailMessage { //! subject: "Test Email".to_string(), //! body: "This is a test email sent from Rust.".to_string(), -//! from_email: "from@example.com".to_string(), -//! to: vec!["to@example.com".to_string()], -//! cc: Some(vec!["cc@example.com".to_string()]), -//! bcc: Some(vec!["bcc@example.com".to_string()]), -//! reply_to: vec!["replyto@example.com".to_string()], +//! from_email: "from@cotexample.com".to_string(), +//! to: vec!["to@cotexample.com".to_string()], +//! cc: Some(vec!["cc@cotexample.com".to_string()]), +//! bcc: Some(vec!["bcc@cotexample.com".to_string()]), +//! reply_to: vec!["replyto@cotexample.com".to_string()], //! alternatives: vec![ //! ("This is a test email sent from Rust as HTML.".to_string(), "text/html".to_string()) //! ], //! }; -//! let config = SmtpConfig { -//! host: "smtp.example.com".to_string(), -//! port: 587, -//! username: Some("user@example.com".to_string()), -//! password: Some("password".to_string()), -//! use_tls: true, -//! fail_silently: false, -//! ..Default::default() -//! }; +//! let config = SmtpConfig::default(); //! let mut backend = EmailBackend::new(config); //! backend.send_message(&email)?; //! Ok(()) //! } //! ``` //! +use std::fmt; use std::net::ToSocketAddrs; use std::time::Duration; -use std::fmt; use lettre::{ message::{header, Message, MultiPart, SinglePart}, @@ -71,18 +63,10 @@ pub struct SmtpConfig { pub username: Option, /// The password for SMTP authentication. pub password: Option, - /// Whether to use TLS for the SMTP connection. - pub use_tls: bool, /// Whether to fail silently on errors. pub fail_silently: bool, /// The timeout duration for the SMTP connection. pub timeout: Duration, - /// Whether to use SSL for the SMTP connection. - pub use_ssl: bool, - /// The path to the SSL certificate file. - pub ssl_certfile: Option, - /// The path to the SSL key file. - pub ssl_keyfile: Option, } impl Default for SmtpConfig { @@ -92,13 +76,8 @@ impl Default for SmtpConfig { port: 25, username: None, password: None, - use_tls: false, fail_silently: false, timeout: Duration::from_secs(60), - use_ssl: false, - ssl_certfile: None, - ssl_keyfile: None, - // debug: false, } } } @@ -124,17 +103,6 @@ pub struct EmailMessage { pub alternatives: Vec<(String, String)>, // (content, mimetype) } -/// Represents an email attachment -#[derive(Debug, Clone)] -pub struct EmailAttachment { - /// The filename of the attachment. - pub filename: String, - /// The content of the attachment. - pub content: Vec, - /// The MIME type of the attachment. - pub mimetype: String, -} - /// SMTP Backend for sending emails #[derive(Debug)] pub struct EmailBackend { @@ -166,6 +134,7 @@ impl fmt::Display for EmailMessage { Ok(()) } } + impl EmailBackend { #[must_use] /// Creates a new instance of `EmailBackend` with the given configuration. @@ -192,10 +161,19 @@ impl EmailBackend { /// /// This function will panic if the transport is not properly initialized. pub fn open(&mut self) -> Result<()> { - if self.transport.as_ref().unwrap().test_connection().is_ok() { + // Test if self.transport is None or if the connection is not working + if self.transport.is_some() && self.transport.as_ref().unwrap().test_connection().is_ok() { return Ok(()); } - + if self.config.host.is_empty() { + return Err(EmailError::ConfigurationError( + "SMTP host is required".to_string(), + )); + } else if self.config.port == 0 { + return Err(EmailError::ConfigurationError( + "SMTP port is required".to_string(), + )); + } let _socket_addr = format!("{}:{}", self.config.host, self.config.port) .to_socket_addrs() .map_err(|e| EmailError::ConnectionError(e.to_string()))? @@ -214,25 +192,14 @@ impl EmailBackend { transport_builder = transport_builder.credentials(credentials); } - // Configure TLS/SSL - if self.config.use_tls { - let tls_parameters = - lettre::transport::smtp::client::TlsParameters::new(self.config.host.clone()) - .map_err(|e| EmailError::ConfigurationError(e.to_string()))?; - transport_builder = transport_builder.tls( - lettre::transport::smtp::client::Tls::Required(tls_parameters), - ); - } - - // Build the transport - self.transport = Some(transport_builder.build()); - // Connect to the SMTP server - if self.transport.as_ref().unwrap().test_connection().is_ok() { - Err(EmailError::ConnectionError( + let transport = transport_builder.build(); + if transport.test_connection().is_err() { + return Err(EmailError::ConnectionError( "Failed to connect to SMTP server".to_string(), - ))?; + )); } + self.transport = Some(transport); Ok(()) } @@ -344,11 +311,11 @@ impl EmailBackend { .multipart(email_body) .map_err(|e| EmailError::MessageError(e.to_string()))?; - // Send the email let mailer = SmtpTransport::builder_dangerous(&self.config.host) .port(self.config.port) .build(); + // Send the email mailer .send(&email) .map_err(|e| EmailError::SendError(e.to_string()))?; @@ -381,28 +348,15 @@ mod tests { use std::io::Cursor; use super::*; - use mockall::predicate::*; - use mockall::*; - - // Mock the SMTP transport for testing - mock! { - SmtpTransport { - fn send(&self, email: &Message) -> std::result::Result<(), lettre::transport::smtp::Error>; - } - } #[test] fn test_send_email() { - // Create a mock SMTP transport - let mut mock_transport = MockSmtpTransport::new(); - mock_transport.expect_send().returning(|_| Ok(())); - // Create a test email let email = EmailMessage { subject: "Test Email".to_string(), body: "This is a test email sent from Rust.".to_string(), - from_email: "from@example.com".to_string(), - to: vec!["to@example.com".to_string()], + from_email: "from@cotexample.com".to_string(), + to: vec!["to@cotexample.com".to_string()], cc: Some(vec![]), bcc: Some(vec![]), reply_to: vec![], @@ -414,11 +368,10 @@ mod tests { // Test with a simple configuration let _config = SmtpConfig { - host: "smtp.example.com".to_string(), + host: "smtp.cotexample.com".to_string(), port: 587, - username: Some("user@example.com".to_string()), + username: Some("user@cotexample.com".to_string()), password: Some("password".to_string()), - use_tls: true, fail_silently: false, ..Default::default() }; @@ -429,7 +382,7 @@ mod tests { // Assert that the email structure is correct assert_eq!(email.subject, "Test Email"); - assert_eq!(email.to, vec!["to@example.com"]); + assert_eq!(email.to, vec!["to@cotexample.com"]); assert_eq!(email.alternatives.len(), 1); // In a real test, we'd also verify that the backend behaves correctly @@ -443,8 +396,8 @@ mod tests { EmailMessage { subject: "Test Email 1".to_string(), body: "This is test email 1.".to_string(), - from_email: "from@example.com".to_string(), - to: vec!["to1@example.com".to_string()], + from_email: "from@cotexample.com".to_string(), + to: vec!["to1@cotexample.com".to_string()], cc: Some(vec![]), bcc: Some(vec![]), reply_to: vec![], @@ -453,8 +406,8 @@ mod tests { EmailMessage { subject: "Test Email 2".to_string(), body: "This is test email 2.".to_string(), - from_email: "from@example.com".to_string(), - to: vec!["to2@example.com".to_string()], + from_email: "from@cotexample.com".to_string(), + to: vec!["to2@cotexample.com".to_string()], cc: Some(vec![]), bcc: Some(vec![]), reply_to: vec![], @@ -464,7 +417,7 @@ mod tests { // Test with fail_silently = true let _config = SmtpConfig { - host: "smtp.example.com".to_string(), + host: "smtp.cotexample.com".to_string(), port: 587, fail_silently: true, ..Default::default() @@ -487,12 +440,8 @@ mod tests { assert_eq!(config.port, 25); assert_eq!(config.username, None); assert_eq!(config.password, None); - assert!(!config.use_tls); assert!(!config.fail_silently); assert_eq!(config.timeout, Duration::from_secs(60)); - assert!(!config.use_ssl); - assert_eq!(config.ssl_certfile, None); - assert_eq!(config.ssl_keyfile, None); } #[test] @@ -501,11 +450,11 @@ mod tests { let email = EmailMessage { subject: "Test Email".to_string(), body: "This is a test email sent from Rust.".to_string(), - from_email: "from@example.com".to_string(), - to: vec!["to@example.com".to_string()], - cc: Some(vec!["cc@example.com".to_string()]), - bcc: Some(vec!["bcc@example.com".to_string()]), - reply_to: vec!["replyto@example.com".to_string()], + from_email: "from@cotexample.com".to_string(), + to: vec!["to@cotexample.com".to_string()], + cc: Some(vec!["cc@cotexample.com".to_string()]), + bcc: Some(vec!["bcc@cotexample.com".to_string()]), + reply_to: vec!["replyto@cotexample.com".to_string()], alternatives: vec![( "This is a test email sent from Rust as HTML.".to_string(), "text/html".to_string(), @@ -528,14 +477,74 @@ mod tests { //println!("{output}"); // Check that the output contains the expected email details assert!(!output.contains("Subject: Test Email")); - assert!(!output.contains("From: from@example.com")); - assert!(!output.contains("To: [\"to@example.com\"]")); - assert!(!output.contains("CC: [\"cc@example.com\"]")); - assert!(!output.contains("BCC: [\"bcc@example.com\"]")); - assert!(!output.contains("Reply-To: [\"replyto@example.com\"]")); + assert!(!output.contains("From: from@cotexample.com")); + assert!(!output.contains("To: [\"to@cotexample.com\"]")); + assert!(!output.contains("CC: [\"cc@cotexample.com\"]")); + assert!(!output.contains("BCC: [\"bcc@cotexample.com\"]")); + assert!(!output.contains("Reply-To: [\"replyto@cotexample.com\"]")); assert!(!output.contains("Body: This is a test email sent from Rust.")); assert!(!output.contains( "Alternative part (text/html): This is a test email sent from Rust as HTML." )); } + #[test] + fn test_open_connection() { + let config = SmtpConfig { + host: "invalid-host".to_string(), + port: 587, + username: Some("user@cotexample.com".to_string()), + password: Some("password".to_string()), + ..Default::default() + }; + + let result = EmailBackend::new(config).open(); + assert!(matches!(result, Err(EmailError::ConnectionError(_)))); + } + + #[test] + fn test_configuration_error() { + let config = SmtpConfig { + host: "localhost".to_string(), + port: 0, + username: Some("user@cotexample.com".to_string()), + password: Some("password".to_string()), + ..Default::default() + }; + + let result = EmailBackend::new(config).open(); + assert!(matches!(result, Err(EmailError::ConfigurationError(_)))); + } + // An integration test to send an email to localhost using the default configuration. + // TODO: Overcome compilation errors due to async_smtp + // use cot::email::{EmailBackend, EmailMessage, SmtpConfig}; + // use async_smtp::smtp::server::MockServer; + #[test] + #[ignore] + fn test_send_email_localhsot() { + // Create a test email + let email = EmailMessage { + subject: "Test Email".to_string(), + body: "This is a test email sent from Rust.".to_string(), + from_email: "from@cotexample.com".to_string(), + to: vec!["to@cotexample.com".to_string()], + cc: Some(vec!["cc@cotexample.com".to_string()]), + bcc: Some(vec!["bcc@cotexample.com".to_string()]), + reply_to: vec!["replyto@cotexample.com".to_string()], + alternatives: vec![( + "This is a test email sent from Rust as HTML.".to_string(), + "text/html".to_string(), + )], + }; + // Get the port it's running on + let port = 1025; //Mailhog default smtp port + // Create a new email backend + let config = SmtpConfig { + host: "localhost".to_string(), + port, + ..Default::default() + }; + let mut backend = EmailBackend::new(config); + let _ = backend.open(); + let _ = backend.send_message(&email); + } } From dbbf50f5fbc2ceb0095c576375858919b62afa8d Mon Sep 17 00:00:00 2001 From: trkelly23 <69165022+trkelly23@users.noreply.github.com> Date: Wed, 19 Mar 2025 19:50:43 -0400 Subject: [PATCH 06/18] Fixed the email example and removed minor version from cargo. --- Cargo.toml | 2 +- cot/src/email.rs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e17a7913..f693a15f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,7 +79,7 @@ http-body = "1" http-body-util = "0.1" humansize = "2.1.3" indexmap = "2" -lettre = { version = "0.11.15", features = ["smtp-transport", "builder"] } +lettre = { version = "0.11", features = ["smtp-transport", "builder"] } lettre_email = { version = "0.10", features = ["builder"] } mime_guess = { version = "2", default-features = false } mockall = "0.13" diff --git a/cot/src/email.rs b/cot/src/email.rs index 0f4b5b98..0fc7cd26 100644 --- a/cot/src/email.rs +++ b/cot/src/email.rs @@ -2,7 +2,8 @@ //! #Examples //! To send an email using the `EmailBackend`, you need to create an instance of `SmtpConfig` //! ``` -//! fn send_example() -> Result<()> { +//! use cot::email::{EmailBackend, EmailMessage, SmtpConfig, EmailError}; +//! fn send_example() -> Result<(), EmailError> { //! let email = EmailMessage { //! subject: "Test Email".to_string(), //! body: "This is a test email sent from Rust.".to_string(), From 0587340e3769a3d81de3ada6ff52ca1b80dac677 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 20 Mar 2025 00:02:43 +0000 Subject: [PATCH 07/18] chore(pre-commit.ci): auto fixes from pre-commit hooks --- cot/src/email.rs | 4 ++-- cot/src/lib.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cot/src/email.rs b/cot/src/email.rs index 0fc7cd26..2219051e 100644 --- a/cot/src/email.rs +++ b/cot/src/email.rs @@ -28,9 +28,9 @@ use std::net::ToSocketAddrs; use std::time::Duration; use lettre::{ - message::{header, Message, MultiPart, SinglePart}, - transport::smtp::authentication::Credentials, SmtpTransport, Transport, + message::{Message, MultiPart, SinglePart, header}, + transport::smtp::authentication::Credentials, }; /// Represents errors that can occur when sending an email. diff --git a/cot/src/lib.rs b/cot/src/lib.rs index 00c50d45..494c0dba 100644 --- a/cot/src/lib.rs +++ b/cot/src/lib.rs @@ -67,6 +67,7 @@ pub mod auth; mod body; pub mod cli; pub mod config; +pub mod email; mod error_page; mod handler; pub mod html; @@ -78,7 +79,6 @@ pub mod router; pub mod static_files; pub mod test; pub(crate) mod utils; -pub mod email; pub use body::Body; pub use cot_macros::{main, test}; From a1a25da2831636b362e989cf7778cdf169268e75 Mon Sep 17 00:00:00 2001 From: trkelly23 <69165022+trkelly23@users.noreply.github.com> Date: Sat, 15 Mar 2025 14:12:42 -0400 Subject: [PATCH 08/18] Fixed the email example and removed minor version from cargo. Initial commit of email.rs using lettre Initial commit of Add email support using lettre crate Added the message printout if debug mode is enabled Added a test and deferred the extra headers and attachment features. Added tests for config and send_email(ignore). --- Cargo.toml | 2 + cot/Cargo.toml | 1 + cot/src/email.rs | 551 +++++++++++++++++++++++++++++++++++++++++++++++ cot/src/lib.rs | 1 + 4 files changed, 555 insertions(+) create mode 100644 cot/src/email.rs diff --git a/Cargo.toml b/Cargo.toml index 39a8d988..f693a15f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,6 +79,8 @@ http-body = "1" http-body-util = "0.1" humansize = "2.1.3" indexmap = "2" +lettre = { version = "0.11", features = ["smtp-transport", "builder"] } +lettre_email = { version = "0.10", features = ["builder"] } mime_guess = { version = "2", default-features = false } mockall = "0.13" password-auth = { version = "1", default-features = false } diff --git a/cot/Cargo.toml b/cot/Cargo.toml index b6c323eb..fa321481 100644 --- a/cot/Cargo.toml +++ b/cot/Cargo.toml @@ -35,6 +35,7 @@ http-body.workspace = true http.workspace = true humansize.workspace = true indexmap.workspace = true +lettre.workspace = true mime_guess.workspace = true password-auth = { workspace = true, features = ["std", "argon2"] } pin-project-lite.workspace = true diff --git a/cot/src/email.rs b/cot/src/email.rs new file mode 100644 index 00000000..0fc7cd26 --- /dev/null +++ b/cot/src/email.rs @@ -0,0 +1,551 @@ +//! Email sending functionality using SMTP +//! #Examples +//! To send an email using the `EmailBackend`, you need to create an instance of `SmtpConfig` +//! ``` +//! use cot::email::{EmailBackend, EmailMessage, SmtpConfig, EmailError}; +//! fn send_example() -> Result<(), EmailError> { +//! let email = EmailMessage { +//! subject: "Test Email".to_string(), +//! body: "This is a test email sent from Rust.".to_string(), +//! from_email: "from@cotexample.com".to_string(), +//! to: vec!["to@cotexample.com".to_string()], +//! cc: Some(vec!["cc@cotexample.com".to_string()]), +//! bcc: Some(vec!["bcc@cotexample.com".to_string()]), +//! reply_to: vec!["replyto@cotexample.com".to_string()], +//! alternatives: vec![ +//! ("This is a test email sent from Rust as HTML.".to_string(), "text/html".to_string()) +//! ], +//! }; +//! let config = SmtpConfig::default(); +//! let mut backend = EmailBackend::new(config); +//! backend.send_message(&email)?; +//! Ok(()) +//! } +//! ``` +//! +use std::fmt; +use std::net::ToSocketAddrs; +use std::time::Duration; + +use lettre::{ + message::{header, Message, MultiPart, SinglePart}, + transport::smtp::authentication::Credentials, + SmtpTransport, Transport, +}; + +/// Represents errors that can occur when sending an email. +#[derive(Debug, thiserror::Error)] +pub enum EmailError { + /// An error occurred while building the email message. + #[error("Message error: {0}")] + MessageError(String), + /// The email configuration is invalid. + #[error("Invalid email configuration: {0}")] + ConfigurationError(String), + /// An error occurred while connecting to the SMTP server. + #[error("Connection error: {0}")] + ConnectionError(String), + /// An error occurred while sending the email. + #[error("Send error: {0}")] + SendError(String), +} + +type Result = std::result::Result; + +/// Configuration for SMTP email backend +#[derive(Debug, Clone)] +pub struct SmtpConfig { + /// The SMTP server host address. + /// Defaults to "localhost". + pub host: String, + /// The SMTP server port. + pub port: u16, + /// The username for SMTP authentication. + pub username: Option, + /// The password for SMTP authentication. + pub password: Option, + /// Whether to fail silently on errors. + pub fail_silently: bool, + /// The timeout duration for the SMTP connection. + pub timeout: Duration, +} + +impl Default for SmtpConfig { + fn default() -> Self { + Self { + host: "localhost".to_string(), + port: 25, + username: None, + password: None, + fail_silently: false, + timeout: Duration::from_secs(60), + } + } +} + +/// Represents an email message +#[derive(Debug, Clone)] +pub struct EmailMessage { + /// The subject of the email. + pub subject: String, + /// The body of the email. + pub body: String, + /// The email address of the sender. + pub from_email: String, + /// The list of recipient email addresses. + pub to: Vec, + /// The list of CC (carbon copy) recipient email addresses. + pub cc: Option>, + /// The list of BCC (blind carbon copy) recipient email addresses. + pub bcc: Option>, + /// The list of reply-to email addresses. + pub reply_to: Vec, + /// The alternative parts of the email (e.g., plain text and HTML versions). + pub alternatives: Vec<(String, String)>, // (content, mimetype) +} + +/// SMTP Backend for sending emails +#[derive(Debug)] +pub struct EmailBackend { + /// The SMTP configuration. + config: SmtpConfig, + /// The SMTP transport. + /// This field is optional because the transport may not be initialized yet. + /// It will be initialized when the `open` method is called. + transport: Option, + /// Whether or not to print debug information. + debug: bool, +} +impl fmt::Display for EmailMessage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "Subject: {}", self.subject)?; + writeln!(f, "From: {}", self.from_email)?; + writeln!(f, "To: {:?}", self.to)?; + if let Some(cc) = &self.cc { + writeln!(f, "CC: {cc:?}")?; + } + if let Some(bcc) = &self.bcc { + writeln!(f, "BCC: {bcc:?}")?; + } + writeln!(f, "Reply-To: {:?}", self.reply_to)?; + writeln!(f, "Body: {}", self.body)?; + for (content, mimetype) in &self.alternatives { + writeln!(f, "Alternative part ({mimetype}): {content}")?; + } + Ok(()) + } +} + +impl EmailBackend { + #[must_use] + /// Creates a new instance of `EmailBackend` with the given configuration. + /// + /// # Arguments + /// + /// * `config` - The SMTP configuration to use. + pub fn new(config: SmtpConfig) -> Self { + Self { + config, + transport: None, + debug: false, + } + } + + /// Open a connection to the SMTP server + /// + /// # Errors + /// + /// This function will return an `EmailError` if there is an issue with resolving the SMTP host, + /// creating the TLS parameters, or connecting to the SMTP server. + /// + /// # Panics + /// + /// This function will panic if the transport is not properly initialized. + pub fn open(&mut self) -> Result<()> { + // Test if self.transport is None or if the connection is not working + if self.transport.is_some() && self.transport.as_ref().unwrap().test_connection().is_ok() { + return Ok(()); + } + if self.config.host.is_empty() { + return Err(EmailError::ConfigurationError( + "SMTP host is required".to_string(), + )); + } else if self.config.port == 0 { + return Err(EmailError::ConfigurationError( + "SMTP port is required".to_string(), + )); + } + let _socket_addr = format!("{}:{}", self.config.host, self.config.port) + .to_socket_addrs() + .map_err(|e| EmailError::ConnectionError(e.to_string()))? + .next() + .ok_or_else(|| { + EmailError::ConnectionError("Could not resolve SMTP host".to_string()) + })?; + + let mut transport_builder = SmtpTransport::builder_dangerous(&self.config.host) + .port(self.config.port) + .timeout(Some(self.config.timeout)); + + // Add authentication if credentials provided + if let (Some(username), Some(password)) = (&self.config.username, &self.config.password) { + let credentials = Credentials::new(username.clone(), password.clone()); + transport_builder = transport_builder.credentials(credentials); + } + + // Connect to the SMTP server + let transport = transport_builder.build(); + if transport.test_connection().is_err() { + return Err(EmailError::ConnectionError( + "Failed to connect to SMTP server".to_string(), + )); + } + self.transport = Some(transport); + Ok(()) + } + + /// Close the connection to the SMTP server + /// + /// # Errors + /// + /// This function will return an `EmailError` if there is an issue with closing the SMTP connection. + pub fn close(&mut self) -> Result<()> { + self.transport = None; + Ok(()) + } + /// Dump the email message to stdout + /// + /// # Errors + /// This function will return an `EmailError` if there is an issue with printing the email message. + pub fn dump_message(&self, email: &EmailMessage) -> Result<()> { + println!("{email}"); + Ok(()) + } + + /// Send a single email message + /// + /// # Errors + /// + /// This function will return an `EmailError` if there is an issue with opening the SMTP connection, + /// building the email message, or sending the email. + pub fn send_message(&mut self, email: &EmailMessage) -> Result<()> { + self.open()?; + if self.debug { + self.dump_message(email)?; + } + // Build the email message using lettre + let mut message_builder = Message::builder() + .from( + email + .from_email + .parse() + .map_err(|e| EmailError::MessageError(format!("Invalid from address: {e}")))?, + ) + .subject(&email.subject); + + // Add recipients + for recipient in &email.to { + message_builder = message_builder.to(recipient.parse().map_err(|e| { + EmailError::MessageError(format!("Invalid recipient address: {e}")) + })?); + } + + // Add CC recipients + if let Some(cc_recipients) = &email.cc { + for recipient in cc_recipients { + message_builder = message_builder.cc(recipient + .parse() + .map_err(|e| EmailError::MessageError(format!("Invalid CC address: {e}")))?); + } + } + + // Add BCC recipients + if let Some(bcc_recipients) = &email.bcc { + for recipient in bcc_recipients { + message_builder = + message_builder.bcc(recipient.parse().map_err(|e| { + EmailError::MessageError(format!("Invalid BCC address: {e}")) + })?); + } + } + + // Add Reply-To addresses + for reply_to in &email.reply_to { + message_builder = + message_builder.reply_to(reply_to.parse().map_err(|e| { + EmailError::MessageError(format!("Invalid reply-to address: {e}")) + })?); + } + + // Create the message body (multipart if there are alternatives or attachments) + let has_alternatives = !email.alternatives.is_empty(); + + let email_body = if has_alternatives { + // Create multipart message + let mut multipart = MultiPart::mixed().singlepart( + SinglePart::builder() + .header(header::ContentType::TEXT_PLAIN) + .body(email.body.clone()), + ); + + // Add alternative parts + for (content, mimetype) in &email.alternatives { + multipart = multipart.singlepart( + SinglePart::builder() + .header(header::ContentType::parse(mimetype).map_err(|e| { + EmailError::MessageError(format!("Invalid content type: {e}")) + })?) + .body(content.clone()), + ); + } + multipart + } else { + // Just use the plain text body + MultiPart::mixed().singlepart( + SinglePart::builder() + .header(header::ContentType::TEXT_PLAIN) + .body(email.body.clone()), + ) + }; + + let email = message_builder + .multipart(email_body) + .map_err(|e| EmailError::MessageError(e.to_string()))?; + + let mailer = SmtpTransport::builder_dangerous(&self.config.host) + .port(self.config.port) + .build(); + + // Send the email + mailer + .send(&email) + .map_err(|e| EmailError::SendError(e.to_string()))?; + + Ok(()) + } + + /// Send multiple email messages + /// + /// # Errors + /// + /// This function will return an `EmailError` if there is an issue with sending any of the emails. + pub fn send_messages(&mut self, emails: &[EmailMessage]) -> Result { + let mut sent_count = 0; + + for email in emails { + match self.send_message(email) { + Ok(()) => sent_count += 1, + Err(_e) if self.config.fail_silently => continue, + Err(e) => return Err(e), + } + } + + Ok(sent_count) + } +} + +#[cfg(test)] +mod tests { + use std::io::Cursor; + + use super::*; + + #[test] + fn test_send_email() { + // Create a test email + let email = EmailMessage { + subject: "Test Email".to_string(), + body: "This is a test email sent from Rust.".to_string(), + from_email: "from@cotexample.com".to_string(), + to: vec!["to@cotexample.com".to_string()], + cc: Some(vec![]), + bcc: Some(vec![]), + reply_to: vec![], + alternatives: vec![( + "This is a test email sent from Rust as HTML.".to_string(), + "text/html".to_string(), + )], + }; + + // Test with a simple configuration + let _config = SmtpConfig { + host: "smtp.cotexample.com".to_string(), + port: 587, + username: Some("user@cotexample.com".to_string()), + password: Some("password".to_string()), + fail_silently: false, + ..Default::default() + }; + + // Note: This test demonstrates the setup but doesn't actually send emails + // since we're mocking the transport. In a real test environment, you might + // use a real SMTP server or a more sophisticated mock. + + // Assert that the email structure is correct + assert_eq!(email.subject, "Test Email"); + assert_eq!(email.to, vec!["to@cotexample.com"]); + assert_eq!(email.alternatives.len(), 1); + + // In a real test, we'd also verify that the backend behaves correctly + // but that would require more complex mocking of the SMTP connection. + } + + #[test] + fn test_send_multiple_emails() { + // Create test emails + let emails = vec![ + EmailMessage { + subject: "Test Email 1".to_string(), + body: "This is test email 1.".to_string(), + from_email: "from@cotexample.com".to_string(), + to: vec!["to1@cotexample.com".to_string()], + cc: Some(vec![]), + bcc: Some(vec![]), + reply_to: vec![], + alternatives: vec![], + }, + EmailMessage { + subject: "Test Email 2".to_string(), + body: "This is test email 2.".to_string(), + from_email: "from@cotexample.com".to_string(), + to: vec!["to2@cotexample.com".to_string()], + cc: Some(vec![]), + bcc: Some(vec![]), + reply_to: vec![], + alternatives: vec![], + }, + ]; + + // Test with fail_silently = true + let _config = SmtpConfig { + host: "smtp.cotexample.com".to_string(), + port: 587, + fail_silently: true, + ..Default::default() + }; + + // Assert that the emails structure is correct + assert_eq!(emails.len(), 2); + assert_eq!(emails[0].subject, "Test Email 1"); + assert_eq!(emails[1].subject, "Test Email 2"); + + // In a real test, we'd verify that send_messages behaves correctly + // with multiple emails, including proper error handling with fail_silently. + } + + #[test] + fn test_config_defaults() { + let config = SmtpConfig::default(); + + assert_eq!(config.host, "localhost"); + assert_eq!(config.port, 25); + assert_eq!(config.username, None); + assert_eq!(config.password, None); + assert!(!config.fail_silently); + assert_eq!(config.timeout, Duration::from_secs(60)); + } + + #[test] + fn test_dump_message() { + // Create a test email + let email = EmailMessage { + subject: "Test Email".to_string(), + body: "This is a test email sent from Rust.".to_string(), + from_email: "from@cotexample.com".to_string(), + to: vec!["to@cotexample.com".to_string()], + cc: Some(vec!["cc@cotexample.com".to_string()]), + bcc: Some(vec!["bcc@cotexample.com".to_string()]), + reply_to: vec!["replyto@cotexample.com".to_string()], + alternatives: vec![( + "This is a test email sent from Rust as HTML.".to_string(), + "text/html".to_string(), + )], + }; + + // Create a buffer to capture output + let mut buffer = Vec::new(); + { + // Redirect stdout to our buffer + let mut _stdout_cursor = Cursor::new(&mut buffer); + + let config = SmtpConfig::default(); + let backend = EmailBackend::new(config); + backend.dump_message(&email).unwrap(); + } + // Convert buffer to string + let output = String::from_utf8(buffer.clone()).unwrap(); + // Keeping for possible debug purposes using cargo test --nocapture + //println!("{output}"); + // Check that the output contains the expected email details + assert!(!output.contains("Subject: Test Email")); + assert!(!output.contains("From: from@cotexample.com")); + assert!(!output.contains("To: [\"to@cotexample.com\"]")); + assert!(!output.contains("CC: [\"cc@cotexample.com\"]")); + assert!(!output.contains("BCC: [\"bcc@cotexample.com\"]")); + assert!(!output.contains("Reply-To: [\"replyto@cotexample.com\"]")); + assert!(!output.contains("Body: This is a test email sent from Rust.")); + assert!(!output.contains( + "Alternative part (text/html): This is a test email sent from Rust as HTML." + )); + } + #[test] + fn test_open_connection() { + let config = SmtpConfig { + host: "invalid-host".to_string(), + port: 587, + username: Some("user@cotexample.com".to_string()), + password: Some("password".to_string()), + ..Default::default() + }; + + let result = EmailBackend::new(config).open(); + assert!(matches!(result, Err(EmailError::ConnectionError(_)))); + } + + #[test] + fn test_configuration_error() { + let config = SmtpConfig { + host: "localhost".to_string(), + port: 0, + username: Some("user@cotexample.com".to_string()), + password: Some("password".to_string()), + ..Default::default() + }; + + let result = EmailBackend::new(config).open(); + assert!(matches!(result, Err(EmailError::ConfigurationError(_)))); + } + // An integration test to send an email to localhost using the default configuration. + // TODO: Overcome compilation errors due to async_smtp + // use cot::email::{EmailBackend, EmailMessage, SmtpConfig}; + // use async_smtp::smtp::server::MockServer; + #[test] + #[ignore] + fn test_send_email_localhsot() { + // Create a test email + let email = EmailMessage { + subject: "Test Email".to_string(), + body: "This is a test email sent from Rust.".to_string(), + from_email: "from@cotexample.com".to_string(), + to: vec!["to@cotexample.com".to_string()], + cc: Some(vec!["cc@cotexample.com".to_string()]), + bcc: Some(vec!["bcc@cotexample.com".to_string()]), + reply_to: vec!["replyto@cotexample.com".to_string()], + alternatives: vec![( + "This is a test email sent from Rust as HTML.".to_string(), + "text/html".to_string(), + )], + }; + // Get the port it's running on + let port = 1025; //Mailhog default smtp port + // Create a new email backend + let config = SmtpConfig { + host: "localhost".to_string(), + port, + ..Default::default() + }; + let mut backend = EmailBackend::new(config); + let _ = backend.open(); + let _ = backend.send_message(&email); + } +} diff --git a/cot/src/lib.rs b/cot/src/lib.rs index 9e4ffd73..00c50d45 100644 --- a/cot/src/lib.rs +++ b/cot/src/lib.rs @@ -78,6 +78,7 @@ pub mod router; pub mod static_files; pub mod test; pub(crate) mod utils; +pub mod email; pub use body::Body; pub use cot_macros::{main, test}; From c964e4559e17943990277feccb6a5dad722ad78c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 20 Mar 2025 00:59:59 +0000 Subject: [PATCH 09/18] chore(pre-commit.ci): auto fixes from pre-commit hooks --- cot/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cot/src/lib.rs b/cot/src/lib.rs index 00c50d45..494c0dba 100644 --- a/cot/src/lib.rs +++ b/cot/src/lib.rs @@ -67,6 +67,7 @@ pub mod auth; mod body; pub mod cli; pub mod config; +pub mod email; mod error_page; mod handler; pub mod html; @@ -78,7 +79,6 @@ pub mod router; pub mod static_files; pub mod test; pub(crate) mod utils; -pub mod email; pub use body::Body; pub use cot_macros::{main, test}; From a13552e6bb6994908e377505cb590a725aeffd38 Mon Sep 17 00:00:00 2001 From: trkelly23 <69165022+trkelly23@users.noreply.github.com> Date: Thu, 20 Mar 2025 17:08:25 -0400 Subject: [PATCH 10/18] Implemented a trait impl for the EmailBackend --- cot/src/email.rs | 138 ++++++++++++++++++++++++++++++----------------- 1 file changed, 90 insertions(+), 48 deletions(-) diff --git a/cot/src/email.rs b/cot/src/email.rs index 2219051e..7893b636 100644 --- a/cot/src/email.rs +++ b/cot/src/email.rs @@ -2,7 +2,7 @@ //! #Examples //! To send an email using the `EmailBackend`, you need to create an instance of `SmtpConfig` //! ``` -//! use cot::email::{EmailBackend, EmailMessage, SmtpConfig, EmailError}; +//! use cot::email::{SmtpEmailBackend, EmailBackend, EmailMessage, SmtpConfig, EmailError}; //! fn send_example() -> Result<(), EmailError> { //! let email = EmailMessage { //! subject: "Test Email".to_string(), @@ -17,7 +17,7 @@ //! ], //! }; //! let config = SmtpConfig::default(); -//! let mut backend = EmailBackend::new(config); +//! let mut backend = SmtpEmailBackend::new(config); //! backend.send_message(&email)?; //! Ok(()) //! } @@ -103,19 +103,6 @@ pub struct EmailMessage { /// The alternative parts of the email (e.g., plain text and HTML versions). pub alternatives: Vec<(String, String)>, // (content, mimetype) } - -/// SMTP Backend for sending emails -#[derive(Debug)] -pub struct EmailBackend { - /// The SMTP configuration. - config: SmtpConfig, - /// The SMTP transport. - /// This field is optional because the transport may not be initialized yet. - /// It will be initialized when the `open` method is called. - transport: Option, - /// Whether or not to print debug information. - debug: bool, -} impl fmt::Display for EmailMessage { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { writeln!(f, "Subject: {}", self.subject)?; @@ -136,14 +123,84 @@ impl fmt::Display for EmailMessage { } } -impl EmailBackend { +/// SMTP Backend for sending emails +#[derive(Debug)] +pub struct SmtpEmailBackend { + /// The SMTP configuration. + config: SmtpConfig, + /// The SMTP transport. + /// This field is optional because the transport may not be initialized yet. + /// It will be initialized when the `open` method is called. + transport: Option, + /// Whether or not to print debug information. + debug: bool, +} +/// Trait representing an email backend for sending emails. +pub trait EmailBackend { + + /// Creates a new instance of the email backend with the given configuration. + /// + /// # Arguments + /// + /// * `config` - The SMTP configuration to use. + fn new(config: SmtpConfig) -> Self; + /// Open a connection to the SMTP server. + /// + /// # Errors + /// + /// This function will return an `EmailError` if there is an issue with resolving the SMTP host, + /// creating the TLS parameters, or connecting to the SMTP server. + fn open(&mut self) -> Result<()>; + /// Close the connection to the SMTP server. + /// + /// # Errors + /// + /// This function will return an `EmailError` if there is an issue with closing the SMTP connection. + /// + /// # Errors + /// + /// This function will return an `EmailError` if there is an issue with closing the SMTP connection. + fn close(&mut self) -> Result<()>; + + /// Send a single email message + /// + /// # Errors + /// + /// This function will return an `EmailError` if there is an issue with opening the SMTP connection, + /// building the email message, or sending the email. + fn send_message(&mut self, message: &EmailMessage) -> Result<()>; + + /// Send multiple email messages + /// + /// # Errors + /// + /// This function will return an `EmailError` if there is an issue with sending any of the emails. + /// + /// # Errors + /// + /// This function will return an `EmailError` if there is an issue with sending any of the emails. + fn send_messages(&mut self, emails: &[EmailMessage]) -> Result { + let mut sent_count = 0; + + for email in emails { + match self.send_message(email) { + Ok(()) => sent_count += 1, + Err(e) => return Err(e), + } + } + + Ok(sent_count) + } +} + +impl EmailBackend for SmtpEmailBackend { #[must_use] /// Creates a new instance of `EmailBackend` with the given configuration. /// /// # Arguments /// /// * `config` - The SMTP configuration to use. - pub fn new(config: SmtpConfig) -> Self { + fn new(config: SmtpConfig) -> Self { Self { config, transport: None, @@ -161,7 +218,7 @@ impl EmailBackend { /// # Panics /// /// This function will panic if the transport is not properly initialized. - pub fn open(&mut self) -> Result<()> { + fn open(&mut self) -> Result<()> { // Test if self.transport is None or if the connection is not working if self.transport.is_some() && self.transport.as_ref().unwrap().test_connection().is_ok() { return Ok(()); @@ -209,26 +266,18 @@ impl EmailBackend { /// # Errors /// /// This function will return an `EmailError` if there is an issue with closing the SMTP connection. - pub fn close(&mut self) -> Result<()> { + fn close(&mut self) -> Result<()> { self.transport = None; Ok(()) } - /// Dump the email message to stdout - /// - /// # Errors - /// This function will return an `EmailError` if there is an issue with printing the email message. - pub fn dump_message(&self, email: &EmailMessage) -> Result<()> { - println!("{email}"); - Ok(()) - } - + /// Send a single email message /// /// # Errors /// /// This function will return an `EmailError` if there is an issue with opening the SMTP connection, /// building the email message, or sending the email. - pub fn send_message(&mut self, email: &EmailMessage) -> Result<()> { + fn send_message(&mut self, email: &EmailMessage) -> Result<()> { self.open()?; if self.debug { self.dump_message(email)?; @@ -324,26 +373,19 @@ impl EmailBackend { Ok(()) } - /// Send multiple email messages + } +impl SmtpEmailBackend { + /// Dump the email message to the console for debugging purposes. /// /// # Errors /// - /// This function will return an `EmailError` if there is an issue with sending any of the emails. - pub fn send_messages(&mut self, emails: &[EmailMessage]) -> Result { - let mut sent_count = 0; - - for email in emails { - match self.send_message(email) { - Ok(()) => sent_count += 1, - Err(_e) if self.config.fail_silently => continue, - Err(e) => return Err(e), - } - } - - Ok(sent_count) + /// This function will return an `EmailError` if there is an issue with writing the email message to the console. + pub fn dump_message(&self, email: &EmailMessage) -> Result<()> { + println!("{}", email); + Ok(()) } + } - #[cfg(test)] mod tests { use std::io::Cursor; @@ -469,7 +511,7 @@ mod tests { let mut _stdout_cursor = Cursor::new(&mut buffer); let config = SmtpConfig::default(); - let backend = EmailBackend::new(config); + let backend = SmtpEmailBackend::new(config); backend.dump_message(&email).unwrap(); } // Convert buffer to string @@ -498,7 +540,7 @@ mod tests { ..Default::default() }; - let result = EmailBackend::new(config).open(); + let result = SmtpEmailBackend::new(config).open(); assert!(matches!(result, Err(EmailError::ConnectionError(_)))); } @@ -512,7 +554,7 @@ mod tests { ..Default::default() }; - let result = EmailBackend::new(config).open(); + let result = SmtpEmailBackend::new(config).open(); assert!(matches!(result, Err(EmailError::ConfigurationError(_)))); } // An integration test to send an email to localhost using the default configuration. @@ -544,7 +586,7 @@ mod tests { port, ..Default::default() }; - let mut backend = EmailBackend::new(config); + let mut backend = SmtpEmailBackend::new(config); let _ = backend.open(); let _ = backend.send_message(&email); } From 6ac6e92a8f66e100b85a7c317d15bea9bcf9c7dd Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 20 Mar 2025 21:10:54 +0000 Subject: [PATCH 11/18] chore(pre-commit.ci): auto fixes from pre-commit hooks --- cot/src/email.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/cot/src/email.rs b/cot/src/email.rs index 7893b636..b0370c6c 100644 --- a/cot/src/email.rs +++ b/cot/src/email.rs @@ -137,7 +137,6 @@ pub struct SmtpEmailBackend { } /// Trait representing an email backend for sending emails. pub trait EmailBackend { - /// Creates a new instance of the email backend with the given configuration. /// /// # Arguments @@ -161,7 +160,7 @@ pub trait EmailBackend { /// /// This function will return an `EmailError` if there is an issue with closing the SMTP connection. fn close(&mut self) -> Result<()>; - + /// Send a single email message /// /// # Errors @@ -169,7 +168,7 @@ pub trait EmailBackend { /// This function will return an `EmailError` if there is an issue with opening the SMTP connection, /// building the email message, or sending the email. fn send_message(&mut self, message: &EmailMessage) -> Result<()>; - + /// Send multiple email messages /// /// # Errors @@ -270,7 +269,7 @@ impl EmailBackend for SmtpEmailBackend { self.transport = None; Ok(()) } - + /// Send a single email message /// /// # Errors @@ -372,8 +371,7 @@ impl EmailBackend for SmtpEmailBackend { Ok(()) } - - } +} impl SmtpEmailBackend { /// Dump the email message to the console for debugging purposes. /// @@ -384,7 +382,6 @@ impl SmtpEmailBackend { println!("{}", email); Ok(()) } - } #[cfg(test)] mod tests { From d7ac14a56e7d2f59dea46d305f30540e840f4528 Mon Sep 17 00:00:00 2001 From: trkelly23 <69165022+trkelly23@users.noreply.github.com> Date: Thu, 27 Mar 2025 20:57:36 -0400 Subject: [PATCH 12/18] Refactor to insure multiple email backends could be added. Mocking added to improve test coverage. --- Cargo.toml | 4 +- cot/src/email.rs | 1104 +++++++++++++++++++++---------- examples/send-email/Cargo.toml | 10 + examples/send-email/src/main.rs | 44 ++ 4 files changed, 793 insertions(+), 369 deletions(-) create mode 100644 examples/send-email/Cargo.toml create mode 100644 examples/send-email/src/main.rs diff --git a/Cargo.toml b/Cargo.toml index f693a15f..f8287258 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "examples/json", "examples/custom-task", "examples/custom-error-pages", + "examples/send-email", ] resolver = "2" @@ -79,8 +80,7 @@ http-body = "1" http-body-util = "0.1" humansize = "2.1.3" indexmap = "2" -lettre = { version = "0.11", features = ["smtp-transport", "builder"] } -lettre_email = { version = "0.10", features = ["builder"] } +lettre = { version = "0.11", features = ["smtp-transport", "builder", "native-tls"] } mime_guess = { version = "2", default-features = false } mockall = "0.13" password-auth = { version = "1", default-features = false } diff --git a/cot/src/email.rs b/cot/src/email.rs index b0370c6c..47d9e7ab 100644 --- a/cot/src/email.rs +++ b/cot/src/email.rs @@ -1,37 +1,48 @@ -//! Email sending functionality using SMTP +//! Email sending functionality using SMTP and other backends +//! //! #Examples //! To send an email using the `EmailBackend`, you need to create an instance of `SmtpConfig` //! ``` -//! use cot::email::{SmtpEmailBackend, EmailBackend, EmailMessage, SmtpConfig, EmailError}; -//! fn send_example() -> Result<(), EmailError> { -//! let email = EmailMessage { -//! subject: "Test Email".to_string(), -//! body: "This is a test email sent from Rust.".to_string(), -//! from_email: "from@cotexample.com".to_string(), -//! to: vec!["to@cotexample.com".to_string()], -//! cc: Some(vec!["cc@cotexample.com".to_string()]), -//! bcc: Some(vec!["bcc@cotexample.com".to_string()]), -//! reply_to: vec!["replyto@cotexample.com".to_string()], -//! alternatives: vec![ -//! ("This is a test email sent from Rust as HTML.".to_string(), "text/html".to_string()) -//! ], -//! }; +//! use cot::email::{EmailBackend, SmtpConfig, SmtpEmailBackend}; +//! use lettre::message::{Message, SinglePart, MultiPart}; +//! use lettre::message::header; +//!fn test_send_email_localhsot() { +//! let parts = MultiPart::related() +//! .singlepart( +//! SinglePart::builder() +//! .header(header::ContentType::TEXT_PLAIN) +//! .body("This is a test email sent from Rust.".to_string()), +//! ) +//! .singlepart( +//! SinglePart::builder() +//! .header(header::ContentType::TEXT_HTML) +//! .body("This is a test email sent from Rust as HTML.".to_string()), +//! ); +//! // Create a test email +//! let email = Message::builder() +//! .subject("Test Email".to_string()) +//! .from("".parse().unwrap()) +//! .to("".parse().unwrap()) +//! .cc("".parse().unwrap()) +//! .bcc("".parse().unwrap()) +//! .reply_to("".parse().unwrap()) +//! .multipart(parts) +//! .unwrap(); +//! // Get the port it's running on +//! let port = 1025; //Mailhog default smtp port //! let config = SmtpConfig::default(); +//! // Create a new email backend //! let mut backend = SmtpEmailBackend::new(config); -//! backend.send_message(&email)?; -//! Ok(()) +//! let _ = backend.send_message(&email); //! } //! ``` //! -use std::fmt; -use std::net::ToSocketAddrs; -use std::time::Duration; - use lettre::{ - SmtpTransport, Transport, - message::{Message, MultiPart, SinglePart, header}, - transport::smtp::authentication::Credentials, + message::Message, transport::smtp::authentication::Credentials, SmtpTransport, Transport, }; +#[cfg(test)] +use mockall::{automock, predicate::*}; +use std::time::Duration; /// Represents errors that can occur when sending an email. #[derive(Debug, thiserror::Error)] @@ -42,99 +53,194 @@ pub enum EmailError { /// The email configuration is invalid. #[error("Invalid email configuration: {0}")] ConfigurationError(String), - /// An error occurred while connecting to the SMTP server. - #[error("Connection error: {0}")] - ConnectionError(String), /// An error occurred while sending the email. #[error("Send error: {0}")] SendError(String), + /// An error occurred while connecting to the SMTP server. + #[error("Connection error: {0}")] + ConnectionError(String), } type Result = std::result::Result; +/// Represents the mode of SMTP transport to initialize the backend with. +#[derive(Default, Debug, Clone, PartialEq, Eq)] +pub enum SmtpTransportMode { + /// Use the default SMTP transport for localhost. + #[default] + Localhost, + /// Use an unencrypted SMTP connection to the specified host. + Unencrypted(String), + /// Use a relay SMTP connection to the specified host. + Relay(String), + /// Use a STARTTLS relay SMTP connection to the specified host. + StartTlsRelay(String), +} + +/// Represents the state of a transport mechanism for SMTP communication. +/// +/// The `TransportState` enum is used to define whether the transport is +/// uninitialized (default state) or initialized with specific settings. +/// +/// # Examples +/// +/// ``` +/// use cot::email::TransportState; +/// +/// let state = TransportState::Uninitialized; // Default state +/// match state { +/// TransportState::Uninitialized => println!("Transport is not initialized."), +/// TransportState::Initialized => println!("Transport is initialized."), +/// } +/// ``` +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransportState { + /// Use the default SMTP transport for localhost. + #[default] + Uninitialized, + /// Use an unencrypted SMTP connection to the specified host. + Initialized, +} + /// Configuration for SMTP email backend #[derive(Debug, Clone)] pub struct SmtpConfig { /// The SMTP server host address. /// Defaults to "localhost". - pub host: String, + pub mode: SmtpTransportMode, /// The SMTP server port. - pub port: u16, + /// Defaults to None, which means the default port for the transport will be used. + /// For example, 587 for STARTTLS or 25 for unencrypted. + pub port: Option, /// The username for SMTP authentication. pub username: Option, /// The password for SMTP authentication. pub password: Option, - /// Whether to fail silently on errors. - pub fail_silently: bool, /// The timeout duration for the SMTP connection. - pub timeout: Duration, + pub timeout: Option, +} + +/// SMTP Backend for sending emails +#[allow(missing_debug_implementations)] +pub struct SmtpEmailBackend { + /// The SMTP configuration. + config: SmtpConfig, + /// The SMTP transport. + /// This field is optional because the transport may not be initialized yet. + /// It will be initialized when the `open` method is called. + transport: Option>, + /// Whether or not to print debug information. + debug: bool, + transport_state: TransportState, } +/// Default implementation for `SmtpConfig`. +/// This provides default values for the SMTP configuration fields. +/// The default mode is `Localhost`, with no port, username, or password. +/// The default timeout is set to 60 seconds. +/// This allows for easy creation of a default SMTP configuration +/// without needing to specify all the fields explicitly. impl Default for SmtpConfig { fn default() -> Self { Self { - host: "localhost".to_string(), - port: 25, + mode: SmtpTransportMode::Localhost, + port: None, username: None, password: None, - fail_silently: false, - timeout: Duration::from_secs(60), + timeout: Some(Duration::from_secs(60)), } } } -/// Represents an email message -#[derive(Debug, Clone)] -pub struct EmailMessage { - /// The subject of the email. - pub subject: String, - /// The body of the email. - pub body: String, - /// The email address of the sender. - pub from_email: String, - /// The list of recipient email addresses. - pub to: Vec, - /// The list of CC (carbon copy) recipient email addresses. - pub cc: Option>, - /// The list of BCC (blind carbon copy) recipient email addresses. - pub bcc: Option>, - /// The list of reply-to email addresses. - pub reply_to: Vec, - /// The alternative parts of the email (e.g., plain text and HTML versions). - pub alternatives: Vec<(String, String)>, // (content, mimetype) -} -impl fmt::Display for EmailMessage { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - writeln!(f, "Subject: {}", self.subject)?; - writeln!(f, "From: {}", self.from_email)?; - writeln!(f, "To: {:?}", self.to)?; - if let Some(cc) = &self.cc { - writeln!(f, "CC: {cc:?}")?; +impl SmtpConfig { + /// Create a new instance of the SMTP configuration with the given mode. + #[must_use] + pub fn new(mode: SmtpTransportMode) -> Self { + Self { + mode, + ..Default::default() } - if let Some(bcc) = &self.bcc { - writeln!(f, "BCC: {bcc:?}")?; + } + fn validate(&self) -> Result<&Self> { + // Check if username and password are both provided both must be Some or both None + if self.username.is_some() && self.password.is_none() + || self.username.is_none() && self.password.is_some() + { + return Err(EmailError::ConfigurationError( + "Proper credentials require both Username and Password is required".to_string(), + )); } - writeln!(f, "Reply-To: {:?}", self.reply_to)?; - writeln!(f, "Body: {}", self.body)?; - for (content, mimetype) in &self.alternatives { - writeln!(f, "Alternative part ({mimetype}): {content}")?; + let host = match &self.mode { + SmtpTransportMode::Unencrypted(host) => host, + SmtpTransportMode::Relay(host_relay) => host_relay, + SmtpTransportMode::StartTlsRelay(host_tls) => host_tls, + SmtpTransportMode::Localhost => &"localhost".to_string(), + }; + if host.is_empty() { + return Err(EmailError::ConfigurationError( + "Host cannot be empty or blank".to_string(), + )); } - Ok(()) + Ok(self) } } +/// Convert ``SmtpConfig`` to Credentials using ``TryFrom`` trait +impl TryFrom<&SmtpConfig> for Credentials { + type Error = EmailError; + + fn try_from(config: &SmtpConfig) -> Result { + match (&config.username, &config.password) { + (Some(username), Some(password)) => { + Ok(Credentials::new(username.clone(), password.clone())) + } + (Some(_), None) | (None, Some(_)) => Err(EmailError::ConfigurationError( + "Both username and password must be provided for SMTP authentication".to_string(), + )), + (None, None) => Ok(Credentials::new(String::new(), String::new())), + } + } +} +/// Trait for sending emails using SMTP transport +/// This trait provides methods for testing connection, +/// sending a single email, and building the transport. +/// It is implemented for `SmtpTransport`. +/// This trait is useful for abstracting the email sending functionality +/// and allows for easier testing and mocking. +/// It can be used in applications that need to send emails +/// using SMTP protocol. +/// #Errors +/// - `EmailError::ConnectionError` if there is an issue with the SMTP connection. +/// - `EmailError::SendError` if there is an issue with sending the email. +/// - `EmailError::ConfigurationError` if the SMTP configuration is invalid. +/// +#[cfg_attr(test, automock)] +pub trait EmailTransport { + /// Test the connection to the SMTP server. + /// # Errors + /// - Returns `Ok(true)` if the connection is successful, otherwise `EmailError::ConnectionError`. + fn test_connection(&self) -> Result; -/// SMTP Backend for sending emails -#[derive(Debug)] -pub struct SmtpEmailBackend { - /// The SMTP configuration. - config: SmtpConfig, - /// The SMTP transport. - /// This field is optional because the transport may not be initialized yet. - /// It will be initialized when the `open` method is called. - transport: Option, - /// Whether or not to print debug information. - debug: bool, + /// Send an email message. + /// # Errors + /// - Returns `Ok(true)` if the connection is successful, otherwise `EmailError::ConnectionError or SendError`. + fn send_email(&self, email: &Message) -> Result<()>; } + +impl EmailTransport for SmtpTransport { + fn test_connection(&self) -> Result { + Ok(self.test_connection().is_ok()) + } + + fn send_email(&self, email: &Message) -> Result<()> { + // Call the actual Transport::send method + match self.send(email) { + //.map_err(|e| EmailError::SendError(e.to_string())) + Ok(_) => Ok(()), + Err(e) => Err(EmailError::SendError(e.to_string())), + } + } +} + /// Trait representing an email backend for sending emails. pub trait EmailBackend { /// Creates a new instance of the email backend with the given configuration. @@ -143,13 +249,29 @@ pub trait EmailBackend { /// /// * `config` - The SMTP configuration to use. fn new(config: SmtpConfig) -> Self; + + /// Initialize the backend for any specialization for any backend such as `FileTransport` ``SmtpTransport`` + /// + /// # Errors + /// + /// - `EmailError::ConfigurationError`: + /// - If the SMTP configuration is invalid (e.g., missing required fields like username and password). + /// - If the host is empty or blank in the configuration. + /// - If the credentials cannot be created from the configuration. + /// + /// - `EmailError::ConnectionError`: + /// - If the transport cannot be created for the specified mode (e.g., invalid host or unsupported configuration). + /// - If the transport fails to connect to the SMTP server. + /// + fn init(&mut self) -> Result<()>; + /// Open a connection to the SMTP server. /// /// # Errors /// /// This function will return an `EmailError` if there is an issue with resolving the SMTP host, /// creating the TLS parameters, or connecting to the SMTP server. - fn open(&mut self) -> Result<()>; + fn open(&mut self) -> Result<&Self>; /// Close the connection to the SMTP server. /// /// # Errors @@ -167,7 +289,7 @@ pub trait EmailBackend { /// /// This function will return an `EmailError` if there is an issue with opening the SMTP connection, /// building the email message, or sending the email. - fn send_message(&mut self, message: &EmailMessage) -> Result<()>; + fn send_message(&mut self, message: &Message) -> Result<()>; /// Send multiple email messages /// @@ -178,7 +300,7 @@ pub trait EmailBackend { /// # Errors /// /// This function will return an `EmailError` if there is an issue with sending any of the emails. - fn send_messages(&mut self, emails: &[EmailMessage]) -> Result { + fn send_messages(&mut self, emails: &[Message]) -> Result { let mut sent_count = 0; for email in emails { @@ -193,7 +315,7 @@ pub trait EmailBackend { } impl EmailBackend for SmtpEmailBackend { - #[must_use] + /// Creates a new instance of `EmailBackend` with the given configuration. /// /// # Arguments @@ -204,60 +326,116 @@ impl EmailBackend for SmtpEmailBackend { config, transport: None, debug: false, + transport_state: TransportState::Uninitialized, } } - /// Open a connection to the SMTP server + /// Safely initializes the SMTP transport based on the configured mode. + /// + /// This function validates the SMTP configuration and creates the appropriate + /// transport based on the mode (e.g., Localhost, Unencrypted, Relay, or ``StartTlsRelay``). + /// It also sets the timeout, port, and credentials if provided. /// /// # Errors /// - /// This function will return an `EmailError` if there is an issue with resolving the SMTP host, - /// creating the TLS parameters, or connecting to the SMTP server. + /// - `EmailError::ConfigurationError`: + /// - If the SMTP configuration is invalid (e.g., missing required fields like username and password). + /// - If the host is empty or blank in the configuration. + /// - If the credentials cannot be created from the configuration. /// - /// # Panics + /// - `EmailError::ConnectionError`: + /// - If the transport cannot be created for the specified mode (e.g., invalid host or unsupported configuration). + /// - If the transport fails to connect to the SMTP server. /// - /// This function will panic if the transport is not properly initialized. - fn open(&mut self) -> Result<()> { - // Test if self.transport is None or if the connection is not working - if self.transport.is_some() && self.transport.as_ref().unwrap().test_connection().is_ok() { - return Ok(()); + fn init(&mut self) -> Result<()> { + if self.transport_state == TransportState::Initialized { + return Ok(()) } - if self.config.host.is_empty() { - return Err(EmailError::ConfigurationError( - "SMTP host is required".to_string(), - )); - } else if self.config.port == 0 { - return Err(EmailError::ConfigurationError( - "SMTP port is required".to_string(), - )); + self.config.validate().map_err(|e| { + EmailError::ConfigurationError(format!( + "Failed to validate SMTP configuration,error: {e}" + )) + })?; + let mut transport_builder = match &self.config.mode { + SmtpTransportMode::Localhost => SmtpTransport::relay("localhost").map_err(|e| { + EmailError::ConnectionError(format!( + "Failed to create SMTP localhost transport,error: {e}" + )) + })?, + SmtpTransportMode::Unencrypted(host) => SmtpTransport::builder_dangerous(host), + SmtpTransportMode::Relay(host) => SmtpTransport::relay(host).map_err(|e| { + EmailError::ConnectionError(format!( + "Failed to create SMTP relay transport host:{host},error: {e}" + )) + })?, + SmtpTransportMode::StartTlsRelay(host) => { + SmtpTransport::starttls_relay(host).map_err(|e| { + EmailError::ConnectionError(format!( + "Failed to create SMTP tls_relay transport host:{host},error: {e}" + )) + })? + } + }; + // Set the timeout for the transport + transport_builder = transport_builder.timeout(self.config.timeout); + + // Set the port if provided in the configuration + // The port is optional, so we check if it's Some before setting it + // If the port is None, the default port for the transport will be used + if self.config.port.is_some() { + transport_builder = transport_builder.port(self.config.port.unwrap()); } - let _socket_addr = format!("{}:{}", self.config.host, self.config.port) - .to_socket_addrs() - .map_err(|e| EmailError::ConnectionError(e.to_string()))? - .next() - .ok_or_else(|| { - EmailError::ConnectionError("Could not resolve SMTP host".to_string()) - })?; - - let mut transport_builder = SmtpTransport::builder_dangerous(&self.config.host) - .port(self.config.port) - .timeout(Some(self.config.timeout)); + + // Create the credentials using the provided configuration + let credentials = Credentials::try_from(&self.config).map_err(|e| { + EmailError::ConfigurationError(format!("Failed to create SMTP credentials,error: {e}")) + })?; // Add authentication if credentials provided - if let (Some(username), Some(password)) = (&self.config.username, &self.config.password) { - let credentials = Credentials::new(username.clone(), password.clone()); - transport_builder = transport_builder.credentials(credentials); + let transport = if self.config.username.is_some() && self.config.password.is_some() { + transport_builder.credentials(credentials).build() + } else { + transport_builder.build() + }; + self.transport = Some(Box::new(transport)); + self.transport_state = TransportState::Initialized; + Ok(()) + } + /// Opens a connection to the SMTP server or return the active connection. + /// + /// This method ensures that the SMTP transport is properly initialized and + /// tests the connection to the SMTP server. If the transport is already + /// initialized and the connection is working, it will reuse the existing + /// transport. Otherwise, it will initialize a new transport and test the + /// connection. + /// + /// # Errors + /// + /// This function can return the following errors: + /// + /// - `EmailError::ConfigurationError`: + /// - If the SMTP configuration is invalid (e.g., missing required fields like username and password). + /// - If the host is empty or blank in the configuration. + /// - If the credentials cannot be created from the configuration. + /// + /// - `EmailError::ConnectionError`: + /// - If the transport cannot be created for the specified mode (e.g., invalid host or unsupported configuration). + /// - If the transport fails to connect to the SMTP server. + /// + fn open(&mut self) -> Result<&Self> { + // Test if self.transport is None or if the connection is not working + if self.transport.is_some() && self.transport.as_ref().unwrap().test_connection().is_ok() { + return Ok(self); } - - // Connect to the SMTP server - let transport = transport_builder.build(); - if transport.test_connection().is_err() { + // Initialize the transport + self.init()?; + // Test connection to the SMTP server + if self.transport.as_ref().unwrap().test_connection().is_err() { return Err(EmailError::ConnectionError( "Failed to connect to SMTP server".to_string(), )); } - self.transport = Some(transport); - Ok(()) + Ok(self) } /// Close the connection to the SMTP server @@ -267,6 +445,7 @@ impl EmailBackend for SmtpEmailBackend { /// This function will return an `EmailError` if there is an issue with closing the SMTP connection. fn close(&mut self) -> Result<()> { self.transport = None; + self.transport_state = TransportState::Uninitialized; Ok(()) } @@ -276,315 +455,506 @@ impl EmailBackend for SmtpEmailBackend { /// /// This function will return an `EmailError` if there is an issue with opening the SMTP connection, /// building the email message, or sending the email. - fn send_message(&mut self, email: &EmailMessage) -> Result<()> { + fn send_message(&mut self, email: &Message) -> Result<()> { self.open()?; if self.debug { - self.dump_message(email)?; - } - // Build the email message using lettre - let mut message_builder = Message::builder() - .from( - email - .from_email - .parse() - .map_err(|e| EmailError::MessageError(format!("Invalid from address: {e}")))?, - ) - .subject(&email.subject); - - // Add recipients - for recipient in &email.to { - message_builder = message_builder.to(recipient.parse().map_err(|e| { - EmailError::MessageError(format!("Invalid recipient address: {e}")) - })?); - } - - // Add CC recipients - if let Some(cc_recipients) = &email.cc { - for recipient in cc_recipients { - message_builder = message_builder.cc(recipient - .parse() - .map_err(|e| EmailError::MessageError(format!("Invalid CC address: {e}")))?); - } - } - - // Add BCC recipients - if let Some(bcc_recipients) = &email.bcc { - for recipient in bcc_recipients { - message_builder = - message_builder.bcc(recipient.parse().map_err(|e| { - EmailError::MessageError(format!("Invalid BCC address: {e}")) - })?); - } - } - - // Add Reply-To addresses - for reply_to in &email.reply_to { - message_builder = - message_builder.reply_to(reply_to.parse().map_err(|e| { - EmailError::MessageError(format!("Invalid reply-to address: {e}")) - })?); + println!("Dump email: {email:#?}"); } - // Create the message body (multipart if there are alternatives or attachments) - let has_alternatives = !email.alternatives.is_empty(); - - let email_body = if has_alternatives { - // Create multipart message - let mut multipart = MultiPart::mixed().singlepart( - SinglePart::builder() - .header(header::ContentType::TEXT_PLAIN) - .body(email.body.clone()), - ); - - // Add alternative parts - for (content, mimetype) in &email.alternatives { - multipart = multipart.singlepart( - SinglePart::builder() - .header(header::ContentType::parse(mimetype).map_err(|e| { - EmailError::MessageError(format!("Invalid content type: {e}")) - })?) - .body(content.clone()), - ); - } - multipart - } else { - // Just use the plain text body - MultiPart::mixed().singlepart( - SinglePart::builder() - .header(header::ContentType::TEXT_PLAIN) - .body(email.body.clone()), - ) - }; - - let email = message_builder - .multipart(email_body) - .map_err(|e| EmailError::MessageError(e.to_string()))?; - - let mailer = SmtpTransport::builder_dangerous(&self.config.host) - .port(self.config.port) - .build(); - // Send the email - mailer - .send(&email) + self.transport + .as_ref() + .ok_or(EmailError::ConnectionError( + "SMTP transport is not initialized".to_string(), + ))? + .send_email(email) .map_err(|e| EmailError::SendError(e.to_string()))?; Ok(()) } + } impl SmtpEmailBackend { - /// Dump the email message to the console for debugging purposes. + /// Creates a new instance of `SmtpEmailBackend` from the given configuration and transport. /// - /// # Errors + /// # Arguments /// - /// This function will return an `EmailError` if there is an issue with writing the email message to the console. - pub fn dump_message(&self, email: &EmailMessage) -> Result<()> { - println!("{}", email); - Ok(()) + /// * `config` - The SMTP configuration to use. + /// * `transport` - An optional transport to use for sending emails. + /// + /// # Returns + /// + /// A new instance of `SmtpEmailBackend`. + #[allow(clippy::must_use_candidate)] + pub fn from_config(config: SmtpConfig, transport: Box) -> Self { + Self { + config, + transport: Some(transport), + debug: false, + transport_state: TransportState::Uninitialized + } } } #[cfg(test)] mod tests { - use std::io::Cursor; - + //use std::io::Cursor; use super::*; + use lettre::message::SinglePart; + use lettre::message::{header, MultiPart}; #[test] - fn test_send_email() { - // Create a test email - let email = EmailMessage { - subject: "Test Email".to_string(), - body: "This is a test email sent from Rust.".to_string(), - from_email: "from@cotexample.com".to_string(), - to: vec!["to@cotexample.com".to_string()], - cc: Some(vec![]), - bcc: Some(vec![]), - reply_to: vec![], - alternatives: vec![( - "This is a test email sent from Rust as HTML.".to_string(), - "text/html".to_string(), - )], - }; + fn test_config_defaults_values() { + let config = SmtpConfig::default(); + + assert_eq!(config.mode, SmtpTransportMode::Localhost); + assert_eq!(config.port, None); + assert_eq!(config.username, None); + assert_eq!(config.password, None); + assert_eq!(config.timeout, Some(Duration::from_secs(60))); + } - // Test with a simple configuration - let _config = SmtpConfig { - host: "smtp.cotexample.com".to_string(), - port: 587, + #[test] + fn test_config_default_ok() { + let config = SmtpConfig::default(); + let result = config.validate(); + assert!(result.is_ok()); + } + #[test] + fn test_config_unencrypted_localhost_ok() { + let config = SmtpConfig::new(SmtpTransportMode::Unencrypted("localhost".to_string())); + let result = config.validate(); + assert!(result.is_ok()); + } + + #[test] + fn test_config_blankhost_unencrypted_ok() { + let config = SmtpConfig::new(SmtpTransportMode::Unencrypted(String::new())); + let result = config.validate(); + assert!(matches!(result, Err(EmailError::ConfigurationError(_)))); + } + + #[test] + fn test_config_blankhost_relay_ok() { + let config = SmtpConfig::new(SmtpTransportMode::Relay(String::new())); + let result = config.validate(); + assert!(matches!(result, Err(EmailError::ConfigurationError(_)))); + } + + #[test] + fn test_config_blankhost_starttls_ok() { + let config = SmtpConfig::new(SmtpTransportMode::StartTlsRelay(String::new())); + let result = config.validate(); + assert!(matches!(result, Err(EmailError::ConfigurationError(_)))); + } + + #[test] + fn test_config_relay_password_failure() { + // Create the backend with our mock transport + let config = SmtpConfig { + mode: SmtpTransportMode::Relay("127.0.0.1".to_string()), username: Some("user@cotexample.com".to_string()), - password: Some("password".to_string()), - fail_silently: false, ..Default::default() }; + let result = config.validate(); + assert!(matches!(result, Err(EmailError::ConfigurationError(_)))); + } - // Note: This test demonstrates the setup but doesn't actually send emails - // since we're mocking the transport. In a real test environment, you might - // use a real SMTP server or a more sophisticated mock. + #[test] + fn test_config_credentials_password_failure() { + // Create the backend with our mock transport + let config = SmtpConfig { + mode: SmtpTransportMode::Relay("127.0.0.1".to_string()), + username: Some("user@cotexample.com".to_string()), + ..Default::default() + }; + let result = Credentials::try_from(&config); + assert!(matches!(result, Err(EmailError::ConfigurationError(_)))); + } + #[test] + fn test_config_credentials_username_failure() { + // Create the backend with our mock transport + let config = SmtpConfig { + mode: SmtpTransportMode::Relay("127.0.0.1".to_string()), + password: Some("user@cotexample.com".to_string()), + ..Default::default() + }; + let result = Credentials::try_from(&config); + assert!(matches!(result, Err(EmailError::ConfigurationError(_)))); + } - // Assert that the email structure is correct - assert_eq!(email.subject, "Test Email"); - assert_eq!(email.to, vec!["to@cotexample.com"]); - assert_eq!(email.alternatives.len(), 1); + #[test] + fn test_config_credentials_ok() { + // Create the backend with our mock transport + let config = SmtpConfig { + mode: SmtpTransportMode::Relay("127.0.0.1".to_string()), + username: Some("user@cotexample.com".to_string()), + password: Some("asdDSasd87".to_string()), + ..Default::default() + }; + let result = Credentials::try_from(&config); + assert!(result.is_ok()); + } - // In a real test, we'd also verify that the backend behaves correctly - // but that would require more complex mocking of the SMTP connection. + #[test] + fn test_config_credentials_err() { + // Create the backend with our mock transport + let config = SmtpConfig { + mode: SmtpTransportMode::Relay("127.0.0.1".to_string()), + username: None, + password: None, + ..Default::default() + }; + let result = Credentials::try_from(&config); + assert!(result.is_ok()); } #[test] - fn test_send_multiple_emails() { - // Create test emails - let emails = vec![ - EmailMessage { - subject: "Test Email 1".to_string(), - body: "This is test email 1.".to_string(), - from_email: "from@cotexample.com".to_string(), - to: vec!["to1@cotexample.com".to_string()], - cc: Some(vec![]), - bcc: Some(vec![]), - reply_to: vec![], - alternatives: vec![], - }, - EmailMessage { - subject: "Test Email 2".to_string(), - body: "This is test email 2.".to_string(), - from_email: "from@cotexample.com".to_string(), - to: vec!["to2@cotexample.com".to_string()], - cc: Some(vec![]), - bcc: Some(vec![]), - reply_to: vec![], - alternatives: vec![], - }, - ]; + fn test_backend_config_ok() { + // Create the backend with our mock transport + let config = SmtpConfig::default(); + let backend = SmtpEmailBackend::new(config); + assert!(backend.transport.is_none()); + } - // Test with fail_silently = true - let _config = SmtpConfig { - host: "smtp.cotexample.com".to_string(), - port: 587, - fail_silently: true, + #[test] + fn test_config_localhost_username_failure() { + // Create the backend with our mock transport + let config = SmtpConfig { + mode: SmtpTransportMode::Localhost, + password: Some("asdDSasd87".to_string()), ..Default::default() }; + let result = config.validate(); + assert!(matches!(result, Err(EmailError::ConfigurationError(_)))); + } - // Assert that the emails structure is correct - assert_eq!(emails.len(), 2); - assert_eq!(emails[0].subject, "Test Email 1"); - assert_eq!(emails[1].subject, "Test Email 2"); + #[test] + fn test_send_email() { + // Create a mock transport + let mut mock_transport = MockEmailTransport::new(); + + // Set expectations on the mock + // Expect test_connection to be called once and return Ok(true) + mock_transport + .expect_test_connection() + .times(1) + .returning(|| Ok(true)); + + // Expect send_email to be called once with any Message and return Ok(()) + mock_transport + .expect_send_email() + .times(1) + .returning(|_| Ok(())); + + // Create a simple email for testing + let email = Message::builder() + .subject("Test Email") + .from("".parse().unwrap()) + .to("".parse().unwrap()) + .singlepart( + SinglePart::builder() + .header(header::ContentType::TEXT_PLAIN) + .body("This is a test email sent from Rust.".to_string()), + ) + .unwrap(); + + // Create SmtpConfig (the actual config doesn't matter as we're using a mock) + let config = SmtpConfig::default(); - // In a real test, we'd verify that send_messages behaves correctly - // with multiple emails, including proper error handling with fail_silently. + // Create the backend with our mock transport + let mut backend = SmtpEmailBackend::from_config(config, Box::new(mock_transport)); + + // Send the email - this should succeed with our mock + let result = backend.send_message(&email); + + // Assert that the email was sent successfully + assert!(result.is_ok()); } #[test] - fn test_config_defaults() { + fn test_backend_clode() { + // Create a mock transport + let mock_transport = MockEmailTransport::new(); let config = SmtpConfig::default(); + let mut backend = SmtpEmailBackend::from_config(config, Box::new(mock_transport)); - assert_eq!(config.host, "localhost"); - assert_eq!(config.port, 25); - assert_eq!(config.username, None); - assert_eq!(config.password, None); - assert!(!config.fail_silently); - assert_eq!(config.timeout, Duration::from_secs(60)); + let result = backend.close(); + assert!(result.is_ok()); } #[test] - fn test_dump_message() { - // Create a test email - let email = EmailMessage { - subject: "Test Email".to_string(), - body: "This is a test email sent from Rust.".to_string(), - from_email: "from@cotexample.com".to_string(), - to: vec!["to@cotexample.com".to_string()], - cc: Some(vec!["cc@cotexample.com".to_string()]), - bcc: Some(vec!["bcc@cotexample.com".to_string()]), - reply_to: vec!["replyto@cotexample.com".to_string()], - alternatives: vec![( - "This is a test email sent from Rust as HTML.".to_string(), - "text/html".to_string(), - )], + fn test_send_email_send_failure() { + // Create a mock transport + let mut mock_transport = MockEmailTransport::new(); + + // Set expectations - test_connection succeeds but send_email fails + mock_transport + .expect_test_connection() + .times(1) + .returning(|| Ok(true)); + + mock_transport + .expect_send_email() + .times(1) + .returning(|_| Err(EmailError::SendError("Mock send failure".to_string()))); + + // Create a simple email for testing + let email = Message::builder() + .subject("Test Email") + .from("".parse().unwrap()) + .to("".parse().unwrap()) + .singlepart( + SinglePart::builder() + .header(header::ContentType::TEXT_PLAIN) + .body("This is a test email sent from Rust.".to_string()), + ) + .unwrap(); + + // Create the backend with our mock transport + let config = SmtpConfig { + mode: SmtpTransportMode::Relay("invalid-host".to_string()), + port: Some(587), + username: Some("user@cotexample.com".to_string()), + ..Default::default() }; - // Create a buffer to capture output - let mut buffer = Vec::new(); - { - // Redirect stdout to our buffer - let mut _stdout_cursor = Cursor::new(&mut buffer); + //let mut backend = SmtpEmailBackend::build(config, mock_transport).unwrap(); + let mut backend = SmtpEmailBackend::from_config(config, Box::new(mock_transport)); - let config = SmtpConfig::default(); - let backend = SmtpEmailBackend::new(config); - backend.dump_message(&email).unwrap(); - } - // Convert buffer to string - let output = String::from_utf8(buffer.clone()).unwrap(); - // Keeping for possible debug purposes using cargo test --nocapture - //println!("{output}"); - // Check that the output contains the expected email details - assert!(!output.contains("Subject: Test Email")); - assert!(!output.contains("From: from@cotexample.com")); - assert!(!output.contains("To: [\"to@cotexample.com\"]")); - assert!(!output.contains("CC: [\"cc@cotexample.com\"]")); - assert!(!output.contains("BCC: [\"bcc@cotexample.com\"]")); - assert!(!output.contains("Reply-To: [\"replyto@cotexample.com\"]")); - assert!(!output.contains("Body: This is a test email sent from Rust.")); - assert!(!output.contains( - "Alternative part (text/html): This is a test email sent from Rust as HTML." - )); + // Try to send the email - this should fail + let result = backend.send_message(&email); + + // Verify that we got a send error + assert!(matches!(result, Err(EmailError::SendError(_)))); } + #[test] - fn test_open_connection() { + fn test_send_multiple_emails() { + // Create a mock transport + let mut mock_transport = MockEmailTransport::new(); + + // Set expectations - test_connection succeeds and send_email succeeds for both emails + mock_transport + .expect_test_connection() + .times(1..) + .returning(|| Ok(true)); + + mock_transport + .expect_send_email() + .times(2) + .returning(|_| Ok(())); + + // Create test emails + let emails = vec![ + Message::builder() + .subject("Test Email 1") + .from("".parse().unwrap()) + .to("".parse().unwrap()) + .singlepart( + SinglePart::builder() + .header(header::ContentType::TEXT_PLAIN) + .body("This is test email 1.".to_string()), + ) + .unwrap(), + Message::builder() + .subject("Test Email 2") + .from("".parse().unwrap()) + .to("".parse().unwrap()) + .singlepart( + SinglePart::builder() + .header(header::ContentType::TEXT_PLAIN) + .body("This is test email 2.".to_string()), + ) + .unwrap(), + ]; + + // Create the backend with our mock transport + let config = SmtpConfig::default(); + let mut backend = SmtpEmailBackend::from_config(config, Box::new(mock_transport)); + + // Send the emails + let result = backend.send_messages(&emails); + + // Verify that both emails were sent successfully + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 2); + } + + // An integration test to send an email to localhost using the default configuration. + // Dependent on the mail server running on localhost, this test may fail/hang if the server is not available. + #[test] + #[ignore] + fn test_send_email_localhost() { + let parts = MultiPart::related() + .singlepart( + SinglePart::builder() + .header(header::ContentType::TEXT_PLAIN) + .body("This is a test email sent from Rust.".to_string()), + ) + .singlepart( + SinglePart::builder() + .header(header::ContentType::TEXT_HTML) + .body("This is a test email sent from Rust as HTML.".to_string()), + ); + // Create a test email + let email = Message::builder() + .subject("Test Email".to_string()) + .from("".parse().unwrap()) + .to("".parse().unwrap()) + .cc("".parse().unwrap()) + .bcc("".parse().unwrap()) + .reply_to("".parse().unwrap()) + .multipart(parts) + .unwrap(); + // Get the port it's running on + let port = 1025; //Mailhog default smtp port let config = SmtpConfig { - host: "invalid-host".to_string(), - port: 587, - username: Some("user@cotexample.com".to_string()), - password: Some("password".to_string()), + mode: SmtpTransportMode::Unencrypted("localhost".to_string()), + port: Some(port), ..Default::default() }; + // Create a new email backend + let mut backend = SmtpEmailBackend::new(config); + + let result = backend.send_message(&email); + assert!(result.is_ok()); + } + #[test] + fn test_open_method_with_existing_working_transport() { + // Create a mock transport that will pass connection test + let mut mock_transport = MockEmailTransport::new(); + mock_transport + .expect_test_connection() + .times(2) + .returning(|| Ok(true)); + + // Create config and backend + let config = SmtpConfig::default(); + let mut backend = SmtpEmailBackend::from_config(config, Box::new(mock_transport)); + + // First open should succeed + let result = backend.open(); + assert!(result.is_ok()); + + // Second open should also succeed without reinitializing + let result = backend.open(); + assert!(result.is_ok()); + } - let result = SmtpEmailBackend::new(config).open(); - assert!(matches!(result, Err(EmailError::ConnectionError(_)))); + #[test] + fn test_open_method_with_failed_connection() { + // Create a mock transport that will fail connection test + let mut mock_transport = MockEmailTransport::new(); + mock_transport + .expect_test_connection() + .times(1) + .returning(|| { + Err(EmailError::ConnectionError( + "Mock connection failure".to_string(), + )) + }); + //Mock the from_config method to return a transport + // Create config and backend + let config = SmtpConfig::default(); + let mut backend = SmtpEmailBackend::from_config(config, Box::new(mock_transport)); + // Open should fail due to connection error + let result = backend.open(); + assert!(result.is_ok()); + assert!(backend.transport_state == TransportState::Initialized); } #[test] - fn test_configuration_error() { + fn test_init_only_username_connection() { + // Create a mock transport that will fail connection test + let mock_transport = MockEmailTransport::new(); + //Mock the from_config method to return a transport + // Create config and backend let config = SmtpConfig { - host: "localhost".to_string(), - port: 0, - username: Some("user@cotexample.com".to_string()), - password: Some("password".to_string()), + mode: SmtpTransportMode::Unencrypted("localhost".to_string()), + username:Some("justtheruser".to_string()), ..Default::default() }; - - let result = SmtpEmailBackend::new(config).open(); + let mut backend = SmtpEmailBackend::from_config(config, Box::new(mock_transport)); + assert!(backend.transport_state == TransportState::Uninitialized); + let result = backend.init(); assert!(matches!(result, Err(EmailError::ConfigurationError(_)))); + assert!(backend.transport_state == TransportState::Uninitialized); } - // An integration test to send an email to localhost using the default configuration. - // TODO: Overcome compilation errors due to async_smtp - // use cot::email::{EmailBackend, EmailMessage, SmtpConfig}; - // use async_smtp::smtp::server::MockServer; + #[test] - #[ignore] - fn test_send_email_localhsot() { - // Create a test email - let email = EmailMessage { - subject: "Test Email".to_string(), - body: "This is a test email sent from Rust.".to_string(), - from_email: "from@cotexample.com".to_string(), - to: vec!["to@cotexample.com".to_string()], - cc: Some(vec!["cc@cotexample.com".to_string()]), - bcc: Some(vec!["bcc@cotexample.com".to_string()]), - reply_to: vec!["replyto@cotexample.com".to_string()], - alternatives: vec![( - "This is a test email sent from Rust as HTML.".to_string(), - "text/html".to_string(), - )], + fn test_init_ok_unencrypted_connection() { + // Create a mock transport that will fail connection test + let mock_transport = MockEmailTransport::new(); + // Create config and backend + let config = SmtpConfig { + mode: SmtpTransportMode::Unencrypted("localhost".to_string()), + ..Default::default() }; - // Get the port it's running on - let port = 1025; //Mailhog default smtp port - // Create a new email backend + let mut backend = SmtpEmailBackend::from_config(config, Box::new(mock_transport)); + assert!(backend.transport_state == TransportState::Uninitialized); + let result = backend.init(); + assert!(result.is_ok()); + assert!(backend.transport_state == TransportState::Initialized); + } + + #[test] + fn test_init_with_relay_credentials() { + // Create a mock transport that will fail connection test + let mock_transport = MockEmailTransport::new(); + //Mock the from_config method to return a transport + // Create config and backend let config = SmtpConfig { - host: "localhost".to_string(), - port, + mode: SmtpTransportMode::Relay("localhost".to_string()), + username:Some("justtheruser".to_string()), + password:Some("asdf877DF".to_string()), + port: Some(25), ..Default::default() }; - let mut backend = SmtpEmailBackend::new(config); - let _ = backend.open(); - let _ = backend.send_message(&email); + let mut backend = SmtpEmailBackend::from_config(config, Box::new(mock_transport)); + // Open should fail due to connection error + assert!(backend.transport_state == TransportState::Uninitialized); + let result = backend.init(); + assert!(result.is_ok()); + assert!(backend.transport_state == TransportState::Initialized); + } + + #[test] + fn test_init_with_tlsrelay_credentials() { + // Create a mock transport that will fail connection test + let mock_transport = MockEmailTransport::new(); + //Mock the from_config method to return a transport + // Create config and backend + let config = SmtpConfig { + mode: SmtpTransportMode::StartTlsRelay("junkyhost".to_string()), + username:Some("justtheruser".to_string()), + password:Some("asdf877DF".to_string()), + ..Default::default() + }; + let mut backend = SmtpEmailBackend::from_config(config, Box::new(mock_transport)); + assert!(backend.transport_state == TransportState::Uninitialized); + let result = backend.init(); + assert!(result.is_ok()); + assert!(backend.transport_state == TransportState::Initialized); + } + + #[test] + fn test_email_error_variants() { + let message_error = EmailError::MessageError("Invalid message".to_string()); + assert_eq!(format!("{message_error}"), "Message error: Invalid message"); + + let config_error = EmailError::ConfigurationError("Invalid config".to_string()); + assert_eq!( + format!("{config_error}"), + "Invalid email configuration: Invalid config" + ); + + let send_error = EmailError::SendError("Failed to send".to_string()); + assert_eq!(format!("{send_error}"), "Send error: Failed to send"); + + let connection_error = EmailError::ConnectionError("Failed to connect".to_string()); + assert_eq!( + format!("{connection_error}"), + "Connection error: Failed to connect" + ); } } diff --git a/examples/send-email/Cargo.toml b/examples/send-email/Cargo.toml new file mode 100644 index 00000000..1a2d990b --- /dev/null +++ b/examples/send-email/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "send-email" +version = "0.1.0" +publish = false +description = "Send email - Cot example." +edition = "2021" + +[dependencies] +cot = { path = "../../cot" } +lettre = { version = "0.11.15", features = ["native-tls"] } diff --git a/examples/send-email/src/main.rs b/examples/send-email/src/main.rs new file mode 100644 index 00000000..d157b408 --- /dev/null +++ b/examples/send-email/src/main.rs @@ -0,0 +1,44 @@ +use cot::email::{EmailBackend, SmtpConfig, SmtpEmailBackend,SmtpTransportMode}; +use lettre::message::header; +use lettre::message::{Message, MultiPart,SinglePart}; +/// This example demonstrates how to send an email using the `cot` library with a multi-part message +/// containing both plain text and HTML content. +/// It uses the `lettre` library for email transport and `MailHog` for testing. +/// Make sure you have MailHog running on port 1025 before executing this example. +/// You can run MailHog using Docker with the following command: +/// `docker run -p 1025:1025 -p 8025:8025 mailhog/mailhog` +/// After running the example, you can check the MailHog web interface at `http://localhost:8025` +/// to see the sent email. +fn main() { + let parts = MultiPart::related() + .singlepart( + SinglePart::builder() + .header(header::ContentType::TEXT_PLAIN) + .body("This is a test email sent from Rust.".to_string()), + ) + .singlepart( + SinglePart::builder() + .header(header::ContentType::TEXT_HTML) + .body("This is a test email sent from examples as HTML.".to_string()), + ); + // Create a test email + let email = Message::builder() + .subject("Test Email".to_string()) + .from("".parse().unwrap()) + .to("".parse().unwrap()) + .cc("".parse().unwrap()) + .bcc("".parse().unwrap()) + .reply_to("".parse().unwrap()) + .multipart(parts) + .unwrap(); + // Get the port it's running on + let port = 1025; //Mailhog default smtp port + // Create a new email backend + let config = SmtpConfig { + mode: SmtpTransportMode::Unencrypted("localhost".to_string()), + port: Some(port), + ..Default::default() + }; + let mut backend = SmtpEmailBackend::new(config); + let _ = backend.send_message(&email); +} From 4ee5c6a7e44f4f65b1b02249e573c8145118e0e3 Mon Sep 17 00:00:00 2001 From: trkelly23 <69165022+trkelly23@users.noreply.github.com> Date: Fri, 28 Mar 2025 10:16:36 -0400 Subject: [PATCH 13/18] Pushing lock since there seems to be a conflict --- Cargo.lock | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index e9c0e7cd..43b2a162 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2325,6 +2325,14 @@ dependencies = [ "libc", ] +[[package]] +name = "send-email" +version = "0.1.0" +dependencies = [ + "cot", + "lettre", +] + [[package]] name = "serde" version = "1.0.218" From 553c2e976fb735ce32ecfd130fb22f4e82b999e9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 28 Mar 2025 14:28:19 +0000 Subject: [PATCH 14/18] chore(pre-commit.ci): auto fixes from pre-commit hooks --- cot/src/email.rs | 30 ++++++++++++++---------------- examples/send-email/src/main.rs | 4 ++-- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/cot/src/email.rs b/cot/src/email.rs index 47d9e7ab..582ddf6b 100644 --- a/cot/src/email.rs +++ b/cot/src/email.rs @@ -1,5 +1,5 @@ //! Email sending functionality using SMTP and other backends -//! +//! //! #Examples //! To send an email using the `EmailBackend`, you need to create an instance of `SmtpConfig` //! ``` @@ -38,7 +38,7 @@ //! ``` //! use lettre::{ - message::Message, transport::smtp::authentication::Credentials, SmtpTransport, Transport, + SmtpTransport, Transport, message::Message, transport::smtp::authentication::Credentials, }; #[cfg(test)] use mockall::{automock, predicate::*}; @@ -315,7 +315,6 @@ pub trait EmailBackend { } impl EmailBackend for SmtpEmailBackend { - /// Creates a new instance of `EmailBackend` with the given configuration. /// /// # Arguments @@ -349,7 +348,7 @@ impl EmailBackend for SmtpEmailBackend { /// fn init(&mut self) -> Result<()> { if self.transport_state == TransportState::Initialized { - return Ok(()) + return Ok(()); } self.config.validate().map_err(|e| { EmailError::ConfigurationError(format!( @@ -472,7 +471,6 @@ impl EmailBackend for SmtpEmailBackend { Ok(()) } - } impl SmtpEmailBackend { /// Creates a new instance of `SmtpEmailBackend` from the given configuration and transport. @@ -491,7 +489,7 @@ impl SmtpEmailBackend { config, transport: Some(transport), debug: false, - transport_state: TransportState::Uninitialized + transport_state: TransportState::Uninitialized, } } } @@ -500,7 +498,7 @@ mod tests { //use std::io::Cursor; use super::*; use lettre::message::SinglePart; - use lettre::message::{header, MultiPart}; + use lettre::message::{MultiPart, header}; #[test] fn test_config_defaults_values() { @@ -866,12 +864,12 @@ mod tests { #[test] fn test_init_only_username_connection() { // Create a mock transport that will fail connection test - let mock_transport = MockEmailTransport::new(); + let mock_transport = MockEmailTransport::new(); //Mock the from_config method to return a transport // Create config and backend let config = SmtpConfig { mode: SmtpTransportMode::Unencrypted("localhost".to_string()), - username:Some("justtheruser".to_string()), + username: Some("justtheruser".to_string()), ..Default::default() }; let mut backend = SmtpEmailBackend::from_config(config, Box::new(mock_transport)); @@ -884,7 +882,7 @@ mod tests { #[test] fn test_init_ok_unencrypted_connection() { // Create a mock transport that will fail connection test - let mock_transport = MockEmailTransport::new(); + let mock_transport = MockEmailTransport::new(); // Create config and backend let config = SmtpConfig { mode: SmtpTransportMode::Unencrypted("localhost".to_string()), @@ -900,13 +898,13 @@ mod tests { #[test] fn test_init_with_relay_credentials() { // Create a mock transport that will fail connection test - let mock_transport = MockEmailTransport::new(); + let mock_transport = MockEmailTransport::new(); //Mock the from_config method to return a transport // Create config and backend let config = SmtpConfig { mode: SmtpTransportMode::Relay("localhost".to_string()), - username:Some("justtheruser".to_string()), - password:Some("asdf877DF".to_string()), + username: Some("justtheruser".to_string()), + password: Some("asdf877DF".to_string()), port: Some(25), ..Default::default() }; @@ -921,13 +919,13 @@ mod tests { #[test] fn test_init_with_tlsrelay_credentials() { // Create a mock transport that will fail connection test - let mock_transport = MockEmailTransport::new(); + let mock_transport = MockEmailTransport::new(); //Mock the from_config method to return a transport // Create config and backend let config = SmtpConfig { mode: SmtpTransportMode::StartTlsRelay("junkyhost".to_string()), - username:Some("justtheruser".to_string()), - password:Some("asdf877DF".to_string()), + username: Some("justtheruser".to_string()), + password: Some("asdf877DF".to_string()), ..Default::default() }; let mut backend = SmtpEmailBackend::from_config(config, Box::new(mock_transport)); diff --git a/examples/send-email/src/main.rs b/examples/send-email/src/main.rs index d157b408..5c2c3bf7 100644 --- a/examples/send-email/src/main.rs +++ b/examples/send-email/src/main.rs @@ -1,6 +1,6 @@ -use cot::email::{EmailBackend, SmtpConfig, SmtpEmailBackend,SmtpTransportMode}; +use cot::email::{EmailBackend, SmtpConfig, SmtpEmailBackend, SmtpTransportMode}; use lettre::message::header; -use lettre::message::{Message, MultiPart,SinglePart}; +use lettre::message::{Message, MultiPart, SinglePart}; /// This example demonstrates how to send an email using the `cot` library with a multi-part message /// containing both plain text and HTML content. /// It uses the `lettre` library for email transport and `MailHog` for testing. From cb8093aed02d4641942d74a0d705bb0722477f11 Mon Sep 17 00:00:00 2001 From: trkelly23 <69165022+trkelly23@users.noreply.github.com> Date: Wed, 7 May 2025 20:04:33 -0400 Subject: [PATCH 15/18] Adding the reworked smtp implementation and NOT working example for help. --- Cargo.lock | 26 +- cot/src/config.rs | 123 ++++++ cot/src/email.rs | 498 +++++++++++++++-------- cot/src/project.rs | 132 +++++- cot/src/test.rs | 1 + examples/send-email/config/dev.toml | 5 + examples/send-email/src/main.rs | 156 +++++-- examples/send-email/templates/index.html | 28 ++ examples/send-email/templates/sent.html | 11 + 9 files changed, 759 insertions(+), 221 deletions(-) create mode 100644 examples/send-email/config/dev.toml create mode 100644 examples/send-email/templates/index.html create mode 100644 examples/send-email/templates/sent.html diff --git a/Cargo.lock b/Cargo.lock index 3fd70e57..462a2f63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1996,7 +1996,7 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "zerocopy", + "zerocopy 0.8.24", ] [[package]] @@ -2102,7 +2102,7 @@ checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.3", - "zerocopy", + "zerocopy 0.8.24", ] [[package]] @@ -3656,13 +3656,33 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "zerocopy-derive 0.7.35", +] + [[package]] name = "zerocopy" version = "0.8.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879" dependencies = [ - "zerocopy-derive", + "zerocopy-derive 0.8.24", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] diff --git a/cot/src/config.rs b/cot/src/config.rs index 8d74d865..951d4dc4 100644 --- a/cot/src/config.rs +++ b/cot/src/config.rs @@ -15,11 +15,15 @@ // not implementing Copy for them #![allow(missing_copy_implementations)] +use std::time::Duration; + use derive_builder::Builder; use derive_more::with_trait::{Debug, From}; use serde::{Deserialize, Serialize}; use subtle::ConstantTimeEq; +use crate::email; + /// The configuration for a project. /// /// This is all the project-specific configuration data that can (and makes @@ -179,6 +183,24 @@ pub struct ProjectConfig { /// # Ok::<(), cot::Error>(()) /// ``` pub middlewares: MiddlewareConfig, + /// Configuration related to the email backend. + /// + /// # Examples + /// + /// ``` + /// use cot::config::{EmailBackendConfig, ProjectConfig}; + /// + /// let config = ProjectConfig::from_toml( + /// r#" + /// [email_backend] + /// type = "none" + /// "#, + /// )?; + /// + /// assert_eq!(config.email_backend, EmailBackendConfig::default()); + /// # Ok::<(), cot::Error>(()) + /// ``` + pub email_backend: EmailBackendConfig, } const fn default_debug() -> bool { @@ -280,6 +302,7 @@ impl ProjectConfigBuilder { #[cfg(feature = "db")] database: self.database.clone().unwrap_or_default(), middlewares: self.middlewares.clone().unwrap_or_default(), + email_backend: self.email_backend.clone().unwrap_or_default(), } } } @@ -567,7 +590,104 @@ impl SessionMiddlewareConfigBuilder { } } } +/// The type of email backend to use. +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum EmailBackendType { + /// No email backend. + #[default] + None, + /// SMTP email backend. + Smtp, +} +/// The configuration for the SMTP backend. +/// +/// This is used as part of the [`EmailBackendConfig`] enum. +/// +/// # Examples +/// +/// ``` +/// use cot::config::EmailBackendConfig; +/// +/// let config = EmailBackendConfig::builder().build(); +/// ``` +#[derive(Debug, Default, Clone, PartialEq, Eq, Builder, Serialize, Deserialize)] +#[builder(build_fn(skip, error = std::convert::Infallible))] +#[serde(default)] +pub struct EmailBackendConfig { + /// The type of email backend to use. + /// Defaults to `None`. + #[builder(setter(into, strip_option), default)] + pub backend_type: EmailBackendType, + /// The SMTP server host address. + /// Defaults to "localhost". + #[builder(setter(into, strip_option), default)] + pub smtp_mode: email::SmtpTransportMode, + /// The SMTP server port. + /// Overwrites the default standard port when specified. + #[builder(setter(into, strip_option), default)] + pub port: Option, + /// The username for SMTP authentication. + #[builder(setter(into, strip_option), default)] + pub username: Option, + /// The password for SMTP authentication. + #[builder(setter(into, strip_option), default)] + pub password: Option, + /// The timeout duration for the SMTP connection. + #[builder(setter(into, strip_option), default)] + pub timeout: Option, +} +impl EmailBackendConfig { + /// Create a new [`EmailBackendConfigBuilder`] to build a + /// [`EmailBackendConfig`]. + /// + /// # Examples + /// + /// ``` + /// use cot::config::EmailBackendConfig; + /// + /// let config = EmailBackendConfig::builder().build(); + /// ``` + #[must_use] + pub fn builder() -> EmailBackendConfigBuilder { + EmailBackendConfigBuilder::default() + } +} +impl EmailBackendConfigBuilder { + /// Builds the email configuration. + /// + /// # Examples + /// + /// ``` + /// use cot::config::EmailBackendConfig; + /// + /// let config = EmailBackendConfig::builder().build(); + /// ``` + #[must_use] + pub fn build(&self) -> EmailBackendConfig { + match self.backend_type.clone().unwrap_or(EmailBackendType::None) { + EmailBackendType::Smtp => EmailBackendConfig { + backend_type: EmailBackendType::Smtp, + smtp_mode: self + .smtp_mode + .clone() + .unwrap_or(email::SmtpTransportMode::Localhost), + port: self.port.unwrap_or_default(), + username: self.username.clone().unwrap_or_default(), + password: self.password.clone().unwrap_or_default(), + timeout: self.timeout.unwrap_or_default(), + }, + EmailBackendType::None => EmailBackendConfig { + backend_type: EmailBackendType::None, + smtp_mode: email::SmtpTransportMode::Localhost, + port: None, + username: None, + password: None, + timeout: None, + }, + } + } +} /// A secret key. /// /// This is a wrapper over a byte array, which is used to store a cryptographic @@ -776,6 +896,8 @@ mod tests { live_reload.enabled = true [middlewares.session] secure = false + [email_backend] + type = "none" "#; let config = ProjectConfig::from_toml(toml_content).unwrap(); @@ -789,6 +911,7 @@ mod tests { assert_eq!(config.auth_backend, AuthBackendConfig::None); assert!(config.middlewares.live_reload.enabled); assert!(!config.middlewares.session.secure); + assert_eq!(config.email_backend.backend_type, EmailBackendType::None); } #[test] diff --git a/cot/src/email.rs b/cot/src/email.rs index 582ddf6b..1e343ff1 100644 --- a/cot/src/email.rs +++ b/cot/src/email.rs @@ -1,48 +1,37 @@ //! Email sending functionality using SMTP and other backends //! //! #Examples -//! To send an email using the `EmailBackend`, you need to create an instance of `SmtpConfig` +//! To send an email using the `EmailBackend`, you need to create an instance of +//! `SmtpConfig` //! ``` -//! use cot::email::{EmailBackend, SmtpConfig, SmtpEmailBackend}; -//! use lettre::message::{Message, SinglePart, MultiPart}; -//! use lettre::message::header; -//!fn test_send_email_localhsot() { -//! let parts = MultiPart::related() -//! .singlepart( -//! SinglePart::builder() -//! .header(header::ContentType::TEXT_PLAIN) -//! .body("This is a test email sent from Rust.".to_string()), -//! ) -//! .singlepart( -//! SinglePart::builder() -//! .header(header::ContentType::TEXT_HTML) -//! .body("This is a test email sent from Rust as HTML.".to_string()), -//! ); +//! use cot::email::{EmailBackend, EmailMessage, SmtpConfig, SmtpEmailBackend}; +//! fn test_send_email_localhsot() { //! // Create a test email -//! let email = Message::builder() -//! .subject("Test Email".to_string()) -//! .from("".parse().unwrap()) -//! .to("".parse().unwrap()) -//! .cc("".parse().unwrap()) -//! .bcc("".parse().unwrap()) -//! .reply_to("".parse().unwrap()) -//! .multipart(parts) -//! .unwrap(); -//! // Get the port it's running on -//! let port = 1025; //Mailhog default smtp port +//! let email = EmailMessage { +//! subject: "Test Email".to_string(), +//! from: String::from("").into(), +//! to: vec!["".to_string()], +//! body: "This is a test email sent from Rust.".to_string(), +//! alternative_html: Some( +//! "

This is a test email sent from Rust as HTML.

".to_string(), +//! ), +//! ..Default::default() +//! }; //! let config = SmtpConfig::default(); //! // Create a new email backend //! let mut backend = SmtpEmailBackend::new(config); //! let _ = backend.send_message(&email); //! } //! ``` -//! -use lettre::{ - SmtpTransport, Transport, message::Message, transport::smtp::authentication::Credentials, -}; +use std::time::Duration; + +use derive_builder::Builder; +use lettre::message::{Mailbox, Message, MultiPart}; +use lettre::transport::smtp::authentication::Credentials; +use lettre::{SmtpTransport, Transport}; #[cfg(test)] use mockall::{automock, predicate::*}; -use std::time::Duration; +use serde::{Deserialize, Serialize}; /// Represents errors that can occur when sending an email. #[derive(Debug, thiserror::Error)] @@ -64,8 +53,10 @@ pub enum EmailError { type Result = std::result::Result; /// Represents the mode of SMTP transport to initialize the backend with. -#[derive(Default, Debug, Clone, PartialEq, Eq)] +#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum SmtpTransportMode { + /// No SMTP transport. + None, /// Use the default SMTP transport for localhost. #[default] Localhost, @@ -101,16 +92,44 @@ pub enum TransportState { /// Use an unencrypted SMTP connection to the specified host. Initialized, } +/// Represents an email address with an optional name. +#[derive(Debug, Clone, Default)] +pub struct EmailAddress { + /// The email address. + pub address: String, + /// The optional name associated with the email address. + pub name: Option, +} +/// Holds the contents of the email prior to converting to +/// a lettre Message. +#[derive(Debug, Clone, Default)] +pub struct EmailMessage { + /// The subject of the email. + pub subject: String, + /// The body of the email. + pub body: String, + /// The email address of the sender. + pub from: EmailAddress, + /// The list of recipient email addresses. + pub to: Vec, + /// The list of CC (carbon copy) recipient email addresses. + pub cc: Option>, + /// The list of BCC (blind carbon copy) recipient email addresses. + pub bcc: Option>, + /// The list of reply-to email addresses. + pub reply_to: Option>, + /// The alternative parts of the email (e.g., plain text and HTML versions). + pub alternative_html: Option, // (content, mimetype) +} /// Configuration for SMTP email backend -#[derive(Debug, Clone)] +#[derive(Debug, Builder, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SmtpConfig { /// The SMTP server host address. /// Defaults to "localhost". pub mode: SmtpTransportMode, /// The SMTP server port. - /// Defaults to None, which means the default port for the transport will be used. - /// For example, 587 for STARTTLS or 25 for unencrypted. + /// Overwrites the default standard port when specified. pub port: Option, /// The username for SMTP authentication. pub username: Option, @@ -121,7 +140,8 @@ pub struct SmtpConfig { } /// SMTP Backend for sending emails -#[allow(missing_debug_implementations)] +//#[allow(missing_debug_implementations)] +#[derive(Debug)] pub struct SmtpEmailBackend { /// The SMTP configuration. config: SmtpConfig, @@ -133,17 +153,21 @@ pub struct SmtpEmailBackend { debug: bool, transport_state: TransportState, } - +impl std::fmt::Debug for dyn EmailTransport + 'static { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EmailTransport").finish() + } +} /// Default implementation for `SmtpConfig`. /// This provides default values for the SMTP configuration fields. /// The default mode is `Localhost`, with no port, username, or password. /// The default timeout is set to 60 seconds. -/// This allows for easy creation of a default SMTP configuration -/// without needing to specify all the fields explicitly. +/// This allows for easy creation of a default SMTP configuration +/// without needing to specify all the fields explicitly. impl Default for SmtpConfig { fn default() -> Self { Self { - mode: SmtpTransportMode::Localhost, + mode: SmtpTransportMode::None, port: None, username: None, password: None, @@ -162,12 +186,13 @@ impl SmtpConfig { } } fn validate(&self) -> Result<&Self> { - // Check if username and password are both provided both must be Some or both None + // Check if username and password are both provided both must be Some or both + // None if self.username.is_some() && self.password.is_none() || self.username.is_none() && self.password.is_some() { return Err(EmailError::ConfigurationError( - "Proper credentials require both Username and Password is required".to_string(), + "Both username and password must be provided for SMTP authentication".to_string(), )); } let host = match &self.mode { @@ -175,8 +200,9 @@ impl SmtpConfig { SmtpTransportMode::Relay(host_relay) => host_relay, SmtpTransportMode::StartTlsRelay(host_tls) => host_tls, SmtpTransportMode::Localhost => &"localhost".to_string(), + SmtpTransportMode::None => &String::new(), }; - if host.is_empty() { + if host.is_empty() && self.mode != SmtpTransportMode::None { return Err(EmailError::ConfigurationError( "Host cannot be empty or blank".to_string(), )); @@ -184,6 +210,39 @@ impl SmtpConfig { Ok(self) } } +/// Convert ``AddressError`` to ``EmailError`` using ``From`` trait +impl From for EmailError { + fn from(error: lettre::address::AddressError) -> Self { + EmailError::MessageError(format!("Invalid email address: {error}")) + } +} +/// Convert ``EmailAddress`` to ``Mailbox`` using ``TryFrom`` trait +impl TryFrom<&EmailAddress> for Mailbox { + type Error = EmailError; + + fn try_from(email: &EmailAddress) -> Result { + if email.address.is_empty() { + return Err(EmailError::ConfigurationError( + "Email address cannot be empty".to_string(), + )); + } + + if email.name.is_none() { + Ok(format!("<{}>", email.address).parse()?) + } else { + Ok(format!("\"{}\" <{}>", email.name.as_ref().unwrap(), email.address).parse()?) + } + } +} +/// Convert ``String`` to ``EmailAddress`` using ``From`` trait +impl From for EmailAddress { + fn from(address: String) -> Self { + Self { + address, + name: None, + } + } +} /// Convert ``SmtpConfig`` to Credentials using ``TryFrom`` trait impl TryFrom<&SmtpConfig> for Credentials { type Error = EmailError; @@ -200,6 +259,55 @@ impl TryFrom<&SmtpConfig> for Credentials { } } } +/// Convert ``EmailMessage`` to ``Message`` using ``TryFrom`` trait +impl TryFrom<&EmailMessage> for Message { + type Error = EmailError; + + fn try_from(email: &EmailMessage) -> Result { + // Create a simple email for testing + let mut builder = Message::builder() + .subject(email.subject.clone()) + .from(Mailbox::try_from(&email.from)?); + + // Add recipients + for to in &email.to { + builder = builder.to(to.parse()?); + } + if let Some(cc) = &email.cc { + for c in cc { + builder = builder.cc(c.parse()?); + } + } + + // Add BCC recipients if present + if let Some(bcc) = &email.bcc { + for bc in bcc { + builder = builder.cc(bc.parse()?); + } + } + + // Add reply-to if present + if let Some(reply_to) = &email.reply_to { + for r in reply_to { + builder = builder.reply_to(r.parse()?); + } + } + if email.alternative_html.is_some() { + builder + .multipart(MultiPart::alternative_plain_html( + String::from(email.body.clone()), + String::from(email.alternative_html.clone().unwrap()), + )) + .map_err(|e| { + EmailError::MessageError(format!("Failed to create email message: {e}")) + }) + } else { + builder + .body(email.body.clone()) + .map_err(|e| EmailError::MessageError(format!("Failed email body:{e}"))) + } + } +} /// Trait for sending emails using SMTP transport /// This trait provides methods for testing connection, /// sending a single email, and building the transport. @@ -209,20 +317,21 @@ impl TryFrom<&SmtpConfig> for Credentials { /// It can be used in applications that need to send emails /// using SMTP protocol. /// #Errors -/// - `EmailError::ConnectionError` if there is an issue with the SMTP connection. -/// - `EmailError::SendError` if there is an issue with sending the email. -/// - `EmailError::ConfigurationError` if the SMTP configuration is invalid. -/// +/// `EmailError::ConnectionError` if there is an issue with the SMTP connection. +/// `EmailError::SendError` if there is an issue with sending the email. +/// `EmailError::ConfigurationError` if the SMTP configuration is invalid. #[cfg_attr(test, automock)] -pub trait EmailTransport { +pub trait EmailTransport: Send +Sync { /// Test the connection to the SMTP server. /// # Errors - /// - Returns `Ok(true)` if the connection is successful, otherwise `EmailError::ConnectionError`. + /// Returns Ok(true) if the connection is successful, otherwise + /// ``EmailError::ConnectionError``. fn test_connection(&self) -> Result; /// Send an email message. /// # Errors - /// - Returns `Ok(true)` if the connection is successful, otherwise `EmailError::ConnectionError or SendError`. + /// Returns Ok(true) if the connection is successful, otherwise + /// ``EmailError::ConnectionError or SendError``. fn send_email(&self, email: &Message) -> Result<()>; } @@ -234,7 +343,6 @@ impl EmailTransport for SmtpTransport { fn send_email(&self, email: &Message) -> Result<()> { // Call the actual Transport::send method match self.send(email) { - //.map_err(|e| EmailError::SendError(e.to_string())) Ok(_) => Ok(()), Err(e) => Err(EmailError::SendError(e.to_string())), } @@ -242,65 +350,69 @@ impl EmailTransport for SmtpTransport { } /// Trait representing an email backend for sending emails. -pub trait EmailBackend { - /// Creates a new instance of the email backend with the given configuration. +pub trait EmailBackend: Send + Sync + 'static { + /// Creates a new instance of the email backend with the given + /// configuration. /// /// # Arguments /// /// * `config` - The SMTP configuration to use. fn new(config: SmtpConfig) -> Self; - /// Initialize the backend for any specialization for any backend such as `FileTransport` ``SmtpTransport`` + /// Initialize the backend for any specialization for any backend such as + /// `FileTransport` ``SmtpTransport`` /// /// # Errors /// - /// - `EmailError::ConfigurationError`: - /// - If the SMTP configuration is invalid (e.g., missing required fields like username and password). - /// - If the host is empty or blank in the configuration. - /// - If the credentials cannot be created from the configuration. - /// - /// - `EmailError::ConnectionError`: - /// - If the transport cannot be created for the specified mode (e.g., invalid host or unsupported configuration). - /// - If the transport fails to connect to the SMTP server. + /// `EmailError::ConfigurationError`: + /// If the SMTP configuration is invalid (e.g., missing required fields like + /// username and password). + /// If the host is empty or blank in the configuration. + /// If the credentials cannot be created from the configuration. /// + /// `EmailError::ConnectionError`: + /// If the transport cannot be created for the specified mode (e.g., + /// invalid host or unsupported configuration). + /// If the transport fails to connect to the SMTP server. fn init(&mut self) -> Result<()>; /// Open a connection to the SMTP server. /// /// # Errors /// - /// This function will return an `EmailError` if there is an issue with resolving the SMTP host, + /// This function will return an `EmailError` if there is an issue with + /// resolving the SMTP host, /// creating the TLS parameters, or connecting to the SMTP server. fn open(&mut self) -> Result<&Self>; /// Close the connection to the SMTP server. /// /// # Errors /// - /// This function will return an `EmailError` if there is an issue with closing the SMTP connection. + /// This function will return an `EmailError` if there is an issue with + /// closing the SMTP connection. /// /// # Errors /// - /// This function will return an `EmailError` if there is an issue with closing the SMTP connection. + /// This function will return an `EmailError` if there is an issue with + /// closing the SMTP connection. fn close(&mut self) -> Result<()>; /// Send a single email message /// /// # Errors /// - /// This function will return an `EmailError` if there is an issue with opening the SMTP connection, + /// This function will return an `EmailError` if there is an issue with + /// opening the SMTP connection, /// building the email message, or sending the email. - fn send_message(&mut self, message: &Message) -> Result<()>; + fn send_message(&mut self, message: &EmailMessage) -> Result<()>; /// Send multiple email messages /// /// # Errors /// - /// This function will return an `EmailError` if there is an issue with sending any of the emails. - /// - /// # Errors - /// - /// This function will return an `EmailError` if there is an issue with sending any of the emails. - fn send_messages(&mut self, emails: &[Message]) -> Result { + /// This function will return an `EmailError` if there is an issue with + /// sending any of the emails. + fn send_messages(&mut self, emails: &[EmailMessage]) -> Result { let mut sent_count = 0; for email in emails { @@ -331,21 +443,23 @@ impl EmailBackend for SmtpEmailBackend { /// Safely initializes the SMTP transport based on the configured mode. /// - /// This function validates the SMTP configuration and creates the appropriate - /// transport based on the mode (e.g., Localhost, Unencrypted, Relay, or ``StartTlsRelay``). + /// This function validates the SMTP configuration and creates the + /// appropriate transport based on the mode (e.g., Localhost, + /// Unencrypted, Relay, or ``StartTlsRelay``). /// It also sets the timeout, port, and credentials if provided. /// /// # Errors /// - /// - `EmailError::ConfigurationError`: - /// - If the SMTP configuration is invalid (e.g., missing required fields like username and password). - /// - If the host is empty or blank in the configuration. - /// - If the credentials cannot be created from the configuration. - /// - /// - `EmailError::ConnectionError`: - /// - If the transport cannot be created for the specified mode (e.g., invalid host or unsupported configuration). - /// - If the transport fails to connect to the SMTP server. + /// `EmailError::ConfigurationError`: + /// If the SMTP configuration is invalid (e.g., missing required fields + /// like username and password). + /// If the host is empty or blank in the configuration. + /// If the credentials cannot be created from the configuration. /// + /// `EmailError::ConnectionError`: + /// If the transport cannot be created for the specified mode (e.g., + /// invalid host or unsupported configuration). + /// If the transport fails to connect to the SMTP server. fn init(&mut self) -> Result<()> { if self.transport_state == TransportState::Initialized { return Ok(()); @@ -356,6 +470,11 @@ impl EmailBackend for SmtpEmailBackend { )) })?; let mut transport_builder = match &self.config.mode { + SmtpTransportMode::None => { + return Err(EmailError::ConfigurationError( + "SMTP transport mode is not specified".to_string(), + )); + } SmtpTransportMode::Localhost => SmtpTransport::relay("localhost").map_err(|e| { EmailError::ConnectionError(format!( "Failed to create SMTP localhost transport,error: {e}" @@ -412,15 +531,16 @@ impl EmailBackend for SmtpEmailBackend { /// /// This function can return the following errors: /// - /// - `EmailError::ConfigurationError`: - /// - If the SMTP configuration is invalid (e.g., missing required fields like username and password). - /// - If the host is empty or blank in the configuration. - /// - If the credentials cannot be created from the configuration. - /// - /// - `EmailError::ConnectionError`: - /// - If the transport cannot be created for the specified mode (e.g., invalid host or unsupported configuration). - /// - If the transport fails to connect to the SMTP server. + /// `EmailError::ConfigurationError`: + /// If the SMTP configuration is invalid (e.g., missing required fields + /// like username and password). + /// If the host is empty or blank in the configuration. + /// If the credentials cannot be created from the configuration. /// + /// `EmailError::ConnectionError`: + /// If the transport cannot be created for the specified mode (e.g., + /// invalid host or unsupported configuration). + /// If the transport fails to connect to the SMTP server. fn open(&mut self) -> Result<&Self> { // Test if self.transport is None or if the connection is not working if self.transport.is_some() && self.transport.as_ref().unwrap().test_connection().is_ok() { @@ -441,7 +561,13 @@ impl EmailBackend for SmtpEmailBackend { /// /// # Errors /// - /// This function will return an `EmailError` if there is an issue with closing the SMTP connection. + /// This function will return an `EmailError` if there is an issue with + /// closing the SMTP connection. + /// + /// # Errors + /// + /// This function will return an `EmailError` if there is an issue with + /// closing the SMTP connection. fn close(&mut self) -> Result<()> { self.transport = None; self.transport_state = TransportState::Uninitialized; @@ -452,28 +578,29 @@ impl EmailBackend for SmtpEmailBackend { /// /// # Errors /// - /// This function will return an `EmailError` if there is an issue with opening the SMTP connection, + /// This function will return an `EmailError` if there is an issue with + /// opening the SMTP connection, /// building the email message, or sending the email. - fn send_message(&mut self, email: &Message) -> Result<()> { + fn send_message(&mut self, email: &EmailMessage) -> Result<()> { self.open()?; if self.debug { println!("Dump email: {email:#?}"); } - // Send the email self.transport .as_ref() .ok_or(EmailError::ConnectionError( "SMTP transport is not initialized".to_string(), ))? - .send_email(email) + .send_email(&email.try_into()?) .map_err(|e| EmailError::SendError(e.to_string()))?; Ok(()) } } impl SmtpEmailBackend { - /// Creates a new instance of `SmtpEmailBackend` from the given configuration and transport. + /// Creates a new instance of `SmtpEmailBackend` from the given + /// configuration and transport. /// /// # Arguments /// @@ -495,16 +622,13 @@ impl SmtpEmailBackend { } #[cfg(test)] mod tests { - //use std::io::Cursor; use super::*; - use lettre::message::SinglePart; - use lettre::message::{MultiPart, header}; #[test] fn test_config_defaults_values() { let config = SmtpConfig::default(); - assert_eq!(config.mode, SmtpTransportMode::Localhost); + assert_eq!(config.mode, SmtpTransportMode::None); assert_eq!(config.port, None); assert_eq!(config.username, None); assert_eq!(config.password, None); @@ -645,16 +769,56 @@ mod tests { .returning(|_| Ok(())); // Create a simple email for testing - let email = Message::builder() - .subject("Test Email") - .from("".parse().unwrap()) - .to("".parse().unwrap()) - .singlepart( - SinglePart::builder() - .header(header::ContentType::TEXT_PLAIN) - .body("This is a test email sent from Rust.".to_string()), - ) - .unwrap(); + let email = EmailMessage { + subject: "Test Email".to_string(), + from: EmailAddress { + address: "from@cotexample.com".to_string(), + name: None, + }, + to: vec!["to@cotexample.com".to_string()], + body: "This is a test email sent from Rust.".to_string(), + ..Default::default() + }; + // Create SmtpConfig (the actual config doesn't matter as we're using a mock) + let config = SmtpConfig::default(); + + // Create the backend with our mock transport + let mut backend = SmtpEmailBackend::from_config(config, Box::new(mock_transport)); + + // Try to send the email - this should succeed + let result = backend.send_message(&email); + + // Verify that the email was sent successfully + assert!(result.is_ok()); + } + + #[test] + fn test_send_email_send_ok() { + // Create a mock transport + let mut mock_transport = MockEmailTransport::new(); + + // Set expectations - test_connection succeeds but send_email fails + mock_transport + .expect_test_connection() + .times(1) + .returning(|| Ok(true)); + + mock_transport + .expect_send_email() + .times(1) + .returning(|_| Ok(())); + + // Create a simple email for testing + let email = EmailMessage { + subject: "Test Email".to_string(), + from: EmailAddress { + address: "from@cotexample.com".to_string(), + name: None, + }, + to: vec!["to@cotexample.com".to_string()], + body: "This is a test email #1.".to_string(), + ..Default::default() + }; // Create SmtpConfig (the actual config doesn't matter as we're using a mock) let config = SmtpConfig::default(); @@ -664,13 +828,14 @@ mod tests { // Send the email - this should succeed with our mock let result = backend.send_message(&email); + eprintln!("Result: {:?}", result); // Assert that the email was sent successfully assert!(result.is_ok()); } #[test] - fn test_backend_clode() { + fn test_backend_close() { // Create a mock transport let mock_transport = MockEmailTransport::new(); let config = SmtpConfig::default(); @@ -697,16 +862,16 @@ mod tests { .returning(|_| Err(EmailError::SendError("Mock send failure".to_string()))); // Create a simple email for testing - let email = Message::builder() - .subject("Test Email") - .from("".parse().unwrap()) - .to("".parse().unwrap()) - .singlepart( - SinglePart::builder() - .header(header::ContentType::TEXT_PLAIN) - .body("This is a test email sent from Rust.".to_string()), - ) - .unwrap(); + let email = EmailMessage { + subject: "Test Email".to_string(), + from: String::from("from@cotexample.com").into(), + to: vec!["to@cotexample.com".to_string()], + cc: Some(vec!["cc@cotexample.com".to_string()]), + bcc: Some(vec!["bcc@cotexample.com".to_string()]), + reply_to: Some(vec!["anonymous@cotexample.com".to_string()]), + body: "This is a test email sent from Rust.".to_string(), + alternative_html: Some("This is a test email sent from Rust as HTML.".to_string()), + }; // Create the backend with our mock transport let config = SmtpConfig { @@ -716,11 +881,11 @@ mod tests { ..Default::default() }; - //let mut backend = SmtpEmailBackend::build(config, mock_transport).unwrap(); let mut backend = SmtpEmailBackend::from_config(config, Box::new(mock_transport)); // Try to send the email - this should fail let result = backend.send_message(&email); + eprintln!("Result: {:?}", result); // Verify that we got a send error assert!(matches!(result, Err(EmailError::SendError(_)))); @@ -731,7 +896,8 @@ mod tests { // Create a mock transport let mut mock_transport = MockEmailTransport::new(); - // Set expectations - test_connection succeeds and send_email succeeds for both emails + // Set expectations - test_connection succeeds and send_email succeeds for both + // emails mock_transport .expect_test_connection() .times(1..) @@ -744,26 +910,20 @@ mod tests { // Create test emails let emails = vec![ - Message::builder() - .subject("Test Email 1") - .from("".parse().unwrap()) - .to("".parse().unwrap()) - .singlepart( - SinglePart::builder() - .header(header::ContentType::TEXT_PLAIN) - .body("This is test email 1.".to_string()), - ) - .unwrap(), - Message::builder() - .subject("Test Email 2") - .from("".parse().unwrap()) - .to("".parse().unwrap()) - .singlepart( - SinglePart::builder() - .header(header::ContentType::TEXT_PLAIN) - .body("This is test email 2.".to_string()), - ) - .unwrap(), + EmailMessage { + subject: "Test Email".to_string(), + from: String::from("from@cotexample.com").into(), + to: vec!["to@cotexample.com".to_string()], + body: "This is a test email #1.".to_string(), + ..Default::default() + }, + EmailMessage { + subject: "Test Email".to_string(), + from: String::from("from@cotexample.com").into(), + to: vec!["to@cotexample.com".to_string()], + body: "This is a test email #2.".to_string(), + ..Default::default() + }, ]; // Create the backend with our mock transport @@ -778,32 +938,24 @@ mod tests { assert_eq!(result.unwrap(), 2); } - // An integration test to send an email to localhost using the default configuration. - // Dependent on the mail server running on localhost, this test may fail/hang if the server is not available. + // An integration test to send an email to localhost using the default + // configuration. Dependent on the mail server running on localhost, this + // test may fail/hang if the server is not available. #[test] #[ignore] fn test_send_email_localhost() { - let parts = MultiPart::related() - .singlepart( - SinglePart::builder() - .header(header::ContentType::TEXT_PLAIN) - .body("This is a test email sent from Rust.".to_string()), - ) - .singlepart( - SinglePart::builder() - .header(header::ContentType::TEXT_HTML) - .body("This is a test email sent from Rust as HTML.".to_string()), - ); // Create a test email - let email = Message::builder() - .subject("Test Email".to_string()) - .from("".parse().unwrap()) - .to("".parse().unwrap()) - .cc("".parse().unwrap()) - .bcc("".parse().unwrap()) - .reply_to("".parse().unwrap()) - .multipart(parts) - .unwrap(); + let email = EmailMessage { + subject: "Test Email".to_string(), + from: String::from("from@cotexample.com").into(), + to: vec!["to@cotexample.com".to_string()], + cc: Some(vec!["cc@cotexample.com".to_string()]), + bcc: Some(vec!["bcc@cotexample.com".to_string()]), + reply_to: Some(vec!["anonymous@cotexample.com".to_string()]), + body: "This is a test email sent from Rust.".to_string(), + alternative_html: Some("This is a test email sent from Rust as HTML.".to_string()), + }; + // Get the port it's running on let port = 1025; //Mailhog default smtp port let config = SmtpConfig { @@ -851,21 +1003,21 @@ mod tests { "Mock connection failure".to_string(), )) }); - //Mock the from_config method to return a transport + // Mock the from_config method to return a transport // Create config and backend let config = SmtpConfig::default(); let mut backend = SmtpEmailBackend::from_config(config, Box::new(mock_transport)); // Open should fail due to connection error let result = backend.open(); - assert!(result.is_ok()); - assert!(backend.transport_state == TransportState::Initialized); + assert!(result.is_err()); + assert!(backend.transport_state == TransportState::Uninitialized); } #[test] fn test_init_only_username_connection() { // Create a mock transport that will fail connection test let mock_transport = MockEmailTransport::new(); - //Mock the from_config method to return a transport + // Mock the from_config method to return a transport // Create config and backend let config = SmtpConfig { mode: SmtpTransportMode::Unencrypted("localhost".to_string()), @@ -899,7 +1051,7 @@ mod tests { fn test_init_with_relay_credentials() { // Create a mock transport that will fail connection test let mock_transport = MockEmailTransport::new(); - //Mock the from_config method to return a transport + // Mock the from_config method to return a transport // Create config and backend let config = SmtpConfig { mode: SmtpTransportMode::Relay("localhost".to_string()), @@ -920,7 +1072,7 @@ mod tests { fn test_init_with_tlsrelay_credentials() { // Create a mock transport that will fail connection test let mock_transport = MockEmailTransport::new(); - //Mock the from_config method to return a transport + // Mock the from_config method to return a transport // Create config and backend let config = SmtpConfig { mode: SmtpTransportMode::StartTlsRelay("junkyhost".to_string()), diff --git a/cot/src/project.rs b/cot/src/project.rs index 3845b876..db402731 100644 --- a/cot/src/project.rs +++ b/cot/src/project.rs @@ -23,7 +23,7 @@ use std::future::poll_fn; use std::panic::AssertUnwindSafe; use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use async_trait::async_trait; use axum::handler::HandlerWithoutStateExt; @@ -41,11 +41,12 @@ use crate::auth::{AuthBackend, NoAuthBackend}; use crate::cli::Cli; #[cfg(feature = "db")] use crate::config::DatabaseConfig; -use crate::config::{AuthBackendConfig, ProjectConfig}; +use crate::config::{AuthBackendConfig, EmailBackendConfig, EmailBackendType, ProjectConfig}; #[cfg(feature = "db")] use crate::db::Database; #[cfg(feature = "db")] use crate::db::migrations::{MigrationEngine, SyncDynMigration}; +use crate::email::{EmailBackend, SmtpConfig, SmtpEmailBackend}; use crate::error::ErrorRepr; use crate::error_page::{Diagnostics, ErrorPageTrigger}; use crate::handler::BoxedHandler; @@ -1248,6 +1249,36 @@ impl Bootstrapper { } } +impl Bootstrapper { + async fn init_email_backend(config: &EmailBackendConfig) -> cot::Result>>> { + match &config.backend_type { + EmailBackendType::None => Ok(None), + EmailBackendType::Smtp => { + let smtp_config = SmtpConfig{ + mode: config.smtp_mode.clone(), + port: config.port, + username: config.username.clone(), + password: config.password.clone(), + timeout: config.timeout + }; + let backend = SmtpEmailBackend::new(smtp_config); + Ok(Some(Arc::new(Mutex::new(backend)))) + } + } + } + /// Moves forward to the next phase of bootstrapping, the with-email phase. + + pub async fn with_email(self) -> cot::Result> { + let email_backend = Self::init_email_backend(&self.context.config.email_backend).await?; + let context = self.context.with_email(email_backend); + + Ok(Bootstrapper { + project: self.project, + context, + handler: self.handler, + }) + } +} impl Bootstrapper { /// Returns the context and handler of the bootstrapper. /// @@ -1293,7 +1324,8 @@ mod sealed { /// 2. [`WithConfig`] /// 3. [`WithApps`] /// 4. [`WithDatabase`] -/// 5. [`Initialized`] +/// 5. [`WithEmail`] +/// 6. [`Initialized`] /// /// # Sealed /// @@ -1340,6 +1372,8 @@ pub trait BootstrapPhase: sealed::Sealed { type Database: Debug; /// The type of the auth backend. type AuthBackend; + /// The type of the email backend. + type EmailBackend: Debug; } /// First phase of bootstrapping a Cot project, the uninitialized phase. @@ -1360,6 +1394,7 @@ impl BootstrapPhase for Uninitialized { #[cfg(feature = "db")] type Database = (); type AuthBackend = (); + type EmailBackend = (); } /// Second phase of bootstrapping a Cot project, the with-config phase. @@ -1380,6 +1415,7 @@ impl BootstrapPhase for WithConfig { #[cfg(feature = "db")] type Database = (); type AuthBackend = (); + type EmailBackend = (); } /// Third phase of bootstrapping a Cot project, the with-apps phase. @@ -1400,6 +1436,7 @@ impl BootstrapPhase for WithApps { #[cfg(feature = "db")] type Database = (); type AuthBackend = (); + type EmailBackend = (); } /// Fourth phase of bootstrapping a Cot project, the with-database phase. @@ -1420,6 +1457,27 @@ impl BootstrapPhase for WithDatabase { #[cfg(feature = "db")] type Database = Option>; type AuthBackend = ::AuthBackend; + type EmailBackend = (); +} +/// Fifth phase of bootstrapping a Cot project, the with-email phase. +/// +/// # See also +/// +/// See the details about the different bootstrap phases in the +/// [`BootstrapPhase`] trait documentation. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub enum WithEmail {} + +impl sealed::Sealed for WithEmail {} +impl BootstrapPhase for WithEmail { + type RequestHandler = (); + type Config = ::Config; + type Apps = ::Apps; + type Router = ::Router; + #[cfg(feature = "db")] + type Database = ::Database; + type AuthBackend = ::AuthBackend; + type EmailBackend = Option>>; } /// The final phase of bootstrapping a Cot project, the initialized phase. @@ -1440,6 +1498,7 @@ impl BootstrapPhase for Initialized { #[cfg(feature = "db")] type Database = ::Database; type AuthBackend = Arc; + type EmailBackend = Option>>; } /// Shared context and configs for all apps. Used in conjunction with the @@ -1454,6 +1513,8 @@ pub struct ProjectContext { database: S::Database, #[debug("..")] auth_backend: S::AuthBackend, + #[debug("..")] + email_backend: S::EmailBackend, } impl ProjectContext { @@ -1466,6 +1527,7 @@ impl ProjectContext { #[cfg(feature = "db")] database: (), auth_backend: (), + email_backend: (), } } @@ -1477,6 +1539,7 @@ impl ProjectContext { #[cfg(feature = "db")] database: self.database, auth_backend: self.auth_backend, + email_backend: self.email_backend, } } } @@ -1517,6 +1580,7 @@ impl ProjectContext { #[cfg(feature = "db")] database: self.database, auth_backend: self.auth_backend, + email_backend: self.email_backend, } } } @@ -1556,6 +1620,7 @@ impl ProjectContext { #[cfg(feature = "db")] database, auth_backend: self.auth_backend, + email_backend: self.email_backend, } } } @@ -1570,10 +1635,35 @@ impl ProjectContext { auth_backend, #[cfg(feature = "db")] database: self.database, + email_backend: None, } } } - +impl ProjectContext { + #[must_use] + fn with_email(self, email_backend: Option>>) -> ProjectContext { + match email_backend { + Some(email_backend) => ProjectContext { + config: self.config, + apps: self.apps, + router: self.router, + auth_backend: self.auth_backend, + #[cfg(feature = "db")] + database: self.database, + email_backend: Some(email_backend), + }, + None => ProjectContext { + config: self.config, + apps: self.apps, + router: self.router, + auth_backend: self.auth_backend, + #[cfg(feature = "db")] + database: self.database, + email_backend: None, + } + } +} +} impl ProjectContext { pub(crate) fn initialized( config: ::Config, @@ -1581,6 +1671,7 @@ impl ProjectContext { router: ::Router, auth_backend: ::AuthBackend, #[cfg(feature = "db")] database: ::Database, + email_backend: ::EmailBackend, ) -> Self { Self { config, @@ -1589,6 +1680,7 @@ impl ProjectContext { #[cfg(feature = "db")] database, auth_backend, + email_backend, } } } @@ -1695,6 +1787,38 @@ impl>>> ProjectContext { ) } } +impl>>>> ProjectContext { + /// Returns the email backend for the project, if it is enabled. + /// + /// # Examples + /// + /// ``` + /// use cot::request::{Request, RequestExt}; + /// use cot::response::Response; + /// + /// async fn index(request: Request) -> cot::Result { + /// let email_backend = request.context().try_email_backend(); + /// if let Some(email_backend) = email_backend { + /// // do something with the email backend + /// } else { + /// // email backend is not enabled + /// } + /// # todo!() + /// } + /// ``` + #[must_use] + pub fn try_email_backend(&self) -> Option<&Arc>> { + self.email_backend.as_ref() + } + /// Returns the email backend for the project, if it is enabled. + #[must_use] + #[track_caller] + pub fn email_backend(&self) -> &Arc> { + self.try_email_backend().expect( + "Email backend missing. Did you forget to add the email backend when configuring CotProject?", + ) + } +} /// Runs the Cot project on the given address. /// diff --git a/cot/src/test.rs b/cot/src/test.rs index 035c6b87..6268b90c 100644 --- a/cot/src/test.rs +++ b/cot/src/test.rs @@ -688,6 +688,7 @@ impl TestRequestBuilder { auth_backend, #[cfg(feature = "db")] self.database.clone(), + None, ); prepare_request(&mut request, Arc::new(context)); diff --git a/examples/send-email/config/dev.toml b/examples/send-email/config/dev.toml new file mode 100644 index 00000000..8375b5e8 --- /dev/null +++ b/examples/send-email/config/dev.toml @@ -0,0 +1,5 @@ +[email_backend] +backend_type = "smtp" +smtp_mode = "encrypted" +host = "localhost" +port = 1025 diff --git a/examples/send-email/src/main.rs b/examples/send-email/src/main.rs index 5c2c3bf7..4633fb81 100644 --- a/examples/send-email/src/main.rs +++ b/examples/send-email/src/main.rs @@ -1,44 +1,118 @@ -use cot::email::{EmailBackend, SmtpConfig, SmtpEmailBackend, SmtpTransportMode}; -use lettre::message::header; -use lettre::message::{Message, MultiPart, SinglePart}; -/// This example demonstrates how to send an email using the `cot` library with a multi-part message -/// containing both plain text and HTML content. -/// It uses the `lettre` library for email transport and `MailHog` for testing. -/// Make sure you have MailHog running on port 1025 before executing this example. -/// You can run MailHog using Docker with the following command: -/// `docker run -p 1025:1025 -p 8025:8025 mailhog/mailhog` -/// After running the example, you can check the MailHog web interface at `http://localhost:8025` -/// to see the sent email. -fn main() { - let parts = MultiPart::related() - .singlepart( - SinglePart::builder() - .header(header::ContentType::TEXT_PLAIN) - .body("This is a test email sent from Rust.".to_string()), - ) - .singlepart( - SinglePart::builder() - .header(header::ContentType::TEXT_HTML) - .body("This is a test email sent from examples as HTML.".to_string()), - ); - // Create a test email - let email = Message::builder() - .subject("Test Email".to_string()) - .from("".parse().unwrap()) - .to("".parse().unwrap()) - .cc("".parse().unwrap()) - .bcc("".parse().unwrap()) - .reply_to("".parse().unwrap()) - .multipart(parts) - .unwrap(); - // Get the port it's running on - let port = 1025; //Mailhog default smtp port - // Create a new email backend - let config = SmtpConfig { - mode: SmtpTransportMode::Unencrypted("localhost".to_string()), - port: Some(port), +use cot::email::{EmailBackend, EmailMessage,SmtpTransportMode}; +use cot::form::Form; +use cot::project::RegisterAppsContext; +use cot::request::{Request, RequestExt}; +use cot::response::{Response, ResponseExt}; +use cot::router::{Route, Router}; +use cot::{App, AppBuilder}; +use cot::Body; +use cot::StatusCode; +use cot::Project; +use cot::cli::CliMetadata; +use cot::config::{DatabaseConfig, EmailBackendConfig, EmailBackendType, ProjectConfig}; + +struct EmailApp; + +impl App for EmailApp { + fn name(&self) -> &str { + "email" + } + + fn router(&self) -> Router { + Router::with_urls([ + Route::with_handler_and_name("/", email_form, "email_form"), + Route::with_handler_and_name("/send", send_email, "send_email"), + ]) + } +} + +async fn email_form(_request: Request) -> cot::Result { + let template = include_str!("../templates/index.html"); + Ok(Response::new_html(StatusCode::OK, Body::fixed(template))) +} +#[derive(Debug, Form)] +struct EmailForm { + from: String, + to: String, + subject: String, + body: String, +} +async fn send_email(mut request: Request) -> cot::Result { + let form = EmailForm::from_request(&mut request).await?.unwrap(); + + let from = form.from; + let to = form.to; + let subject = form.subject; + let body = form.body; + + // Create the email + let email = EmailMessage { + subject, + from: from.into(), + to: vec![to], + body, + alternative_html: None, ..Default::default() }; - let mut backend = SmtpEmailBackend::new(config); - let _ = backend.send_message(&email); + let _database = request.context().database(); + let email_backend = request.context().email_backend(); + let backend_clone = email_backend.clone(); + { + let backend = &backend_clone; + let _x= backend.lock().unwrap().send_message(&email); + } + //let template = include_str!("../templates/sent.html"); + //Ok(Response::new_html(StatusCode::OK, Body::fixed(template))) + let template = include_str!("../templates/sent.html"); + + Ok(Response::new_html(StatusCode::OK, Body::fixed(template))) +} +struct MyProject; +impl Project for MyProject { + fn cli_metadata(&self) -> CliMetadata { + cot::cli::metadata!() + } + + fn config(&self, _config_name: &str) -> cot::Result { + //Create the email backend + // let config = ProjectConfig::from_toml( + // r#" + // [database] + // url = "sqlite::memory:" + + // [email_backend] + // backend_type = "Smtp" + // smtp_mode = "Localhost" + // port = 1025 + // "#, + // )?; + let mut email_config = EmailBackendConfig::builder(); + email_config.backend_type(EmailBackendType::Smtp); + email_config.smtp_mode(SmtpTransportMode::Localhost); + email_config.port(1025_u16); + let config = ProjectConfig::builder() + .debug(true) + .database(DatabaseConfig::builder().url("sqlite::memory:").build()) + .email_backend(email_config.build()) + .build(); + Ok(config) + } + fn register_apps(&self, apps: &mut AppBuilder, _context: &RegisterAppsContext) { + apps.register_with_views(EmailApp, ""); + + } + + fn middlewares( + &self, + handler: cot::project::RootHandlerBuilder, + _context: &cot::project::MiddlewareContext, + ) -> cot::BoxedHandler { + //context.config().email_backend().unwrap(); + handler.build() + } +} + +#[cot::main] +fn main() -> impl Project { + MyProject } diff --git a/examples/send-email/templates/index.html b/examples/send-email/templates/index.html new file mode 100644 index 00000000..bd06a3ea --- /dev/null +++ b/examples/send-email/templates/index.html @@ -0,0 +1,28 @@ + + + + Send Email + + +

Send Email

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + \ No newline at end of file diff --git a/examples/send-email/templates/sent.html b/examples/send-email/templates/sent.html new file mode 100644 index 00000000..68326588 --- /dev/null +++ b/examples/send-email/templates/sent.html @@ -0,0 +1,11 @@ + + + + Email Sent + + +

Email Sent Successfully

+

The email has been sent successfully.

+ Send another email + + \ No newline at end of file From 749ef05a8208b427a7623f729a459f2719748784 Mon Sep 17 00:00:00 2001 From: Marek 'seqre' Grzelak Date: Thu, 15 May 2025 10:19:22 +0200 Subject: [PATCH 16/18] chore: cargo fmt --- cot/src/email.rs | 2 +- cot/src/project.rs | 49 ++++++++++++++++++--------------- examples/send-email/src/main.rs | 26 ++++++++--------- 3 files changed, 39 insertions(+), 38 deletions(-) diff --git a/cot/src/email.rs b/cot/src/email.rs index 1e343ff1..7a661219 100644 --- a/cot/src/email.rs +++ b/cot/src/email.rs @@ -321,7 +321,7 @@ impl TryFrom<&EmailMessage> for Message { /// `EmailError::SendError` if there is an issue with sending the email. /// `EmailError::ConfigurationError` if the SMTP configuration is invalid. #[cfg_attr(test, automock)] -pub trait EmailTransport: Send +Sync { +pub trait EmailTransport: Send + Sync { /// Test the connection to the SMTP server. /// # Errors /// Returns Ok(true) if the connection is successful, otherwise diff --git a/cot/src/project.rs b/cot/src/project.rs index db402731..ef9e2b65 100644 --- a/cot/src/project.rs +++ b/cot/src/project.rs @@ -1250,16 +1250,18 @@ impl Bootstrapper { } impl Bootstrapper { - async fn init_email_backend(config: &EmailBackendConfig) -> cot::Result>>> { + async fn init_email_backend( + config: &EmailBackendConfig, + ) -> cot::Result>>> { match &config.backend_type { EmailBackendType::None => Ok(None), EmailBackendType::Smtp => { - let smtp_config = SmtpConfig{ + let smtp_config = SmtpConfig { mode: config.smtp_mode.clone(), port: config.port, username: config.username.clone(), password: config.password.clone(), - timeout: config.timeout + timeout: config.timeout, }; let backend = SmtpEmailBackend::new(smtp_config); Ok(Some(Arc::new(Mutex::new(backend)))) @@ -1267,16 +1269,16 @@ impl Bootstrapper { } } /// Moves forward to the next phase of bootstrapping, the with-email phase. - + pub async fn with_email(self) -> cot::Result> { let email_backend = Self::init_email_backend(&self.context.config.email_backend).await?; let context = self.context.with_email(email_backend); Ok(Bootstrapper { - project: self.project, - context, - handler: self.handler, - }) + project: self.project, + context, + handler: self.handler, + }) } } impl Bootstrapper { @@ -1641,28 +1643,31 @@ impl ProjectContext { } impl ProjectContext { #[must_use] - fn with_email(self, email_backend: Option>>) -> ProjectContext { + fn with_email( + self, + email_backend: Option>>, + ) -> ProjectContext { match email_backend { Some(email_backend) => ProjectContext { - config: self.config, - apps: self.apps, - router: self.router, - auth_backend: self.auth_backend, - #[cfg(feature = "db")] - database: self.database, - email_backend: Some(email_backend), - }, + config: self.config, + apps: self.apps, + router: self.router, + auth_backend: self.auth_backend, + #[cfg(feature = "db")] + database: self.database, + email_backend: Some(email_backend), + }, None => ProjectContext { config: self.config, apps: self.apps, router: self.router, auth_backend: self.auth_backend, - #[cfg(feature = "db")] - database: self.database, - email_backend: None, + #[cfg(feature = "db")] + database: self.database, + email_backend: None, + }, } - } -} + } } impl ProjectContext { pub(crate) fn initialized( diff --git a/examples/send-email/src/main.rs b/examples/send-email/src/main.rs index 4633fb81..15f40158 100644 --- a/examples/send-email/src/main.rs +++ b/examples/send-email/src/main.rs @@ -1,15 +1,12 @@ -use cot::email::{EmailBackend, EmailMessage,SmtpTransportMode}; +use cot::cli::CliMetadata; +use cot::config::{DatabaseConfig, EmailBackendConfig, EmailBackendType, ProjectConfig}; +use cot::email::{EmailBackend, EmailMessage, SmtpTransportMode}; use cot::form::Form; use cot::project::RegisterAppsContext; use cot::request::{Request, RequestExt}; use cot::response::{Response, ResponseExt}; use cot::router::{Route, Router}; -use cot::{App, AppBuilder}; -use cot::Body; -use cot::StatusCode; -use cot::Project; -use cot::cli::CliMetadata; -use cot::config::{DatabaseConfig, EmailBackendConfig, EmailBackendType, ProjectConfig}; +use cot::{App, AppBuilder, Body, Project, StatusCode}; struct EmailApp; @@ -39,7 +36,7 @@ struct EmailForm { } async fn send_email(mut request: Request) -> cot::Result { let form = EmailForm::from_request(&mut request).await?.unwrap(); - + let from = form.from; let to = form.to; let subject = form.subject; @@ -59,10 +56,10 @@ async fn send_email(mut request: Request) -> cot::Result { let backend_clone = email_backend.clone(); { let backend = &backend_clone; - let _x= backend.lock().unwrap().send_message(&email); + let _x = backend.lock().unwrap().send_message(&email); } - //let template = include_str!("../templates/sent.html"); - //Ok(Response::new_html(StatusCode::OK, Body::fixed(template))) + // let template = include_str!("../templates/sent.html"); + // Ok(Response::new_html(StatusCode::OK, Body::fixed(template))) let template = include_str!("../templates/sent.html"); Ok(Response::new_html(StatusCode::OK, Body::fixed(template))) @@ -74,7 +71,7 @@ impl Project for MyProject { } fn config(&self, _config_name: &str) -> cot::Result { - //Create the email backend + // Create the email backend // let config = ProjectConfig::from_toml( // r#" // [database] @@ -99,15 +96,14 @@ impl Project for MyProject { } fn register_apps(&self, apps: &mut AppBuilder, _context: &RegisterAppsContext) { apps.register_with_views(EmailApp, ""); - } - + fn middlewares( &self, handler: cot::project::RootHandlerBuilder, _context: &cot::project::MiddlewareContext, ) -> cot::BoxedHandler { - //context.config().email_backend().unwrap(); + // context.config().email_backend().unwrap(); handler.build() } } From 3a042af096e0a54b5c789692c549b51fa3839dee Mon Sep 17 00:00:00 2001 From: Marek 'seqre' Grzelak Date: Thu, 15 May 2025 11:22:53 +0200 Subject: [PATCH 17/18] fix bootstrapper --- cot/src/project.rs | 83 ++++++++-------------------------------------- 1 file changed, 14 insertions(+), 69 deletions(-) diff --git a/cot/src/project.rs b/cot/src/project.rs index ef9e2b65..68b5c7d6 100644 --- a/cot/src/project.rs +++ b/cot/src/project.rs @@ -1239,7 +1239,10 @@ impl Bootstrapper { let handler = self.project.middlewares(handler, &self.context); let auth_backend = self.project.auth_backend(&self.context); - let context = self.context.with_auth(auth_backend); + let email_backend = Self::init_email_backend(&self.context.config.email_backend).await; + let context = self + .context + .with_auth_and_email(auth_backend, email_backend); Ok(Bootstrapper { project: self.project, @@ -1247,14 +1250,12 @@ impl Bootstrapper { handler, }) } -} -impl Bootstrapper { async fn init_email_backend( config: &EmailBackendConfig, - ) -> cot::Result>>> { + ) -> Option>> { match &config.backend_type { - EmailBackendType::None => Ok(None), + EmailBackendType::None => None, EmailBackendType::Smtp => { let smtp_config = SmtpConfig { mode: config.smtp_mode.clone(), @@ -1264,22 +1265,10 @@ impl Bootstrapper { timeout: config.timeout, }; let backend = SmtpEmailBackend::new(smtp_config); - Ok(Some(Arc::new(Mutex::new(backend)))) + Some(Arc::new(Mutex::new(backend))) } } } - /// Moves forward to the next phase of bootstrapping, the with-email phase. - - pub async fn with_email(self) -> cot::Result> { - let email_backend = Self::init_email_backend(&self.context.config.email_backend).await?; - let context = self.context.with_email(email_backend); - - Ok(Bootstrapper { - project: self.project, - context, - handler: self.handler, - }) - } } impl Bootstrapper { /// Returns the context and handler of the bootstrapper. @@ -1459,27 +1448,7 @@ impl BootstrapPhase for WithDatabase { #[cfg(feature = "db")] type Database = Option>; type AuthBackend = ::AuthBackend; - type EmailBackend = (); -} -/// Fifth phase of bootstrapping a Cot project, the with-email phase. -/// -/// # See also -/// -/// See the details about the different bootstrap phases in the -/// [`BootstrapPhase`] trait documentation. -#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] -pub enum WithEmail {} - -impl sealed::Sealed for WithEmail {} -impl BootstrapPhase for WithEmail { - type RequestHandler = (); - type Config = ::Config; - type Apps = ::Apps; - type Router = ::Router; - #[cfg(feature = "db")] - type Database = ::Database; - type AuthBackend = ::AuthBackend; - type EmailBackend = Option>>; + type EmailBackend = ::EmailBackend; } /// The final phase of bootstrapping a Cot project, the initialized phase. @@ -1629,7 +1598,11 @@ impl ProjectContext { impl ProjectContext { #[must_use] - fn with_auth(self, auth_backend: Arc) -> ProjectContext { + fn with_auth_and_email( + self, + auth_backend: Arc, + email_backend: Option>>, + ) -> ProjectContext { ProjectContext { config: self.config, apps: self.apps, @@ -1637,35 +1610,7 @@ impl ProjectContext { auth_backend, #[cfg(feature = "db")] database: self.database, - email_backend: None, - } - } -} -impl ProjectContext { - #[must_use] - fn with_email( - self, - email_backend: Option>>, - ) -> ProjectContext { - match email_backend { - Some(email_backend) => ProjectContext { - config: self.config, - apps: self.apps, - router: self.router, - auth_backend: self.auth_backend, - #[cfg(feature = "db")] - database: self.database, - email_backend: Some(email_backend), - }, - None => ProjectContext { - config: self.config, - apps: self.apps, - router: self.router, - auth_backend: self.auth_backend, - #[cfg(feature = "db")] - database: self.database, - email_backend: None, - }, + email_backend, } } } From ea8e2e07b4b570a27e9bf0e7e8fd88b63cbfb150 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 15 May 2025 09:30:11 +0000 Subject: [PATCH 18/18] chore(pre-commit.ci): auto fixes from pre-commit hooks --- examples/send-email/templates/index.html | 2 +- examples/send-email/templates/sent.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/send-email/templates/index.html b/examples/send-email/templates/index.html index bd06a3ea..134515e3 100644 --- a/examples/send-email/templates/index.html +++ b/examples/send-email/templates/index.html @@ -25,4 +25,4 @@

Send Email

- \ No newline at end of file + diff --git a/examples/send-email/templates/sent.html b/examples/send-email/templates/sent.html index 68326588..c4e3faa0 100644 --- a/examples/send-email/templates/sent.html +++ b/examples/send-email/templates/sent.html @@ -8,4 +8,4 @@

Email Sent Successfully

The email has been sent successfully.

Send another email - \ No newline at end of file +