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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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/77] 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 + From 83652f18ab4edcfd848154f9fea8ab2800c6fa4d Mon Sep 17 00:00:00 2001 From: trkelly23 <69165022+trkelly23@users.noreply.github.com> Date: Tue, 27 May 2025 22:38:53 -0400 Subject: [PATCH 19/77] Tested send-email example and cleaned up code --- examples/send-email/src/main.rs | 35 +++++++++------------------------ 1 file changed, 9 insertions(+), 26 deletions(-) diff --git a/examples/send-email/src/main.rs b/examples/send-email/src/main.rs index 15f40158..9ff2fb12 100644 --- a/examples/send-email/src/main.rs +++ b/examples/send-email/src/main.rs @@ -2,11 +2,11 @@ use cot::cli::CliMetadata; use cot::config::{DatabaseConfig, EmailBackendConfig, EmailBackendType, ProjectConfig}; use cot::email::{EmailBackend, EmailMessage, SmtpTransportMode}; use cot::form::Form; +use cot::html::Html; use cot::project::RegisterAppsContext; use cot::request::{Request, RequestExt}; -use cot::response::{Response, ResponseExt}; use cot::router::{Route, Router}; -use cot::{App, AppBuilder, Body, Project, StatusCode}; +use cot::{App, AppBuilder, Project}; struct EmailApp; @@ -23,9 +23,9 @@ impl App for EmailApp { } } -async fn email_form(_request: Request) -> cot::Result { - let template = include_str!("../templates/index.html"); - Ok(Response::new_html(StatusCode::OK, Body::fixed(template))) +async fn email_form(_request: Request) -> cot::Result { + let template = String::from(include_str!("../templates/index.html")); + Ok(Html::new(template)) } #[derive(Debug, Form)] struct EmailForm { @@ -34,7 +34,7 @@ struct EmailForm { subject: String, body: String, } -async fn send_email(mut request: Request) -> cot::Result { +async fn send_email(mut request: Request) -> cot::Result { let form = EmailForm::from_request(&mut request).await?.unwrap(); let from = form.from; @@ -53,16 +53,11 @@ async fn send_email(mut request: Request) -> cot::Result { }; 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 _x = email_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 = String::from(include_str!("../templates/sent.html")); + Ok(Html::new(template)) } struct MyProject; impl Project for MyProject { @@ -71,18 +66,6 @@ impl Project for MyProject { } 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); From bd0f3bed10f3d62e983701113c0d0b93d01d5f85 Mon Sep 17 00:00:00 2001 From: Elijah Date: Mon, 1 Dec 2025 17:14:05 +0000 Subject: [PATCH 20/77] add cargo lock --- Cargo.lock | 123 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 122 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 73d16485..6e07b7e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -130,6 +130,15 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "ar_archive_writer" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c269894b6fe5e9d7ada0cf69b5bf847ff35bc25fc271f08e1d080fce80339a" +dependencies = [ + "object 0.32.2", +] + [[package]] name = "argon2" version = "0.5.3" @@ -309,7 +318,7 @@ dependencies = [ "cfg-if", "libc", "miniz_oxide", - "object", + "object 0.37.3", "rustc-demangle", "windows-link", ] @@ -447,6 +456,16 @@ dependencies = [ "phf 0.12.1", ] +[[package]] +name = "chumsky" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" +dependencies = [ + "hashbrown 0.14.5", + "stacker", +] + [[package]] name = "ciborium" version = "0.2.2" @@ -679,6 +698,7 @@ dependencies = [ "http-body-util", "humantime", "indexmap", + "lettre", "mime", "mime_guess", "mockall", @@ -1139,6 +1159,16 @@ dependencies = [ "serde", ] +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64", + "memchr", +] + [[package]] name = "email_address" version = "0.2.9" @@ -1646,6 +1676,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + [[package]] name = "http" version = "0.2.12" @@ -2088,6 +2129,31 @@ dependencies = [ "spin", ] +[[package]] +name = "lettre" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f" +dependencies = [ + "base64", + "chumsky", + "email-encoding", + "email_address", + "fastrand", + "futures-util", + "hostname", + "httpdate", + "idna", + "mime", + "native-tls", + "nom", + "percent-encoding", + "quoted_printable", + "socket2", + "tokio", + "url", +] + [[package]] name = "libc" version = "0.2.177" @@ -2283,6 +2349,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2364,6 +2439,15 @@ dependencies = [ "libc", ] +[[package]] +name = "object" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +dependencies = [ + "memchr", +] + [[package]] name = "object" version = "0.37.3" @@ -2719,6 +2803,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d11f2fedc3b7dafdc2851bc52f277377c5473d378859be234bc7ebb593144d01" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "quote" version = "1.0.42" @@ -2728,6 +2822,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" + [[package]] name = "r-efi" version = "5.3.0" @@ -3081,6 +3181,14 @@ dependencies = [ "libc", ] +[[package]] +name = "send-email" +version = "0.1.0" +dependencies = [ + "cot", + "lettre", +] + [[package]] name = "serde" version = "1.0.228" @@ -3490,6 +3598,19 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stacker" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1f8b29fb42aafcea4edeeb6b2f2d7ecd0d969c48b4cf0d2e64aafc471dd6e59" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.59.0", +] + [[package]] name = "stringprep" version = "0.1.5" From 88eaeccc2602a6338d1f3287a1d9ddf7c1dd1d73 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 1 Dec 2025 17:16:40 +0000 Subject: [PATCH 21/77] chore(pre-commit.ci): auto fixes from pre-commit hooks --- cot/src/project.rs | 2 +- examples/send-email/templates/index.html | 50 ++++++++++++------------ examples/send-email/templates/sent.html | 16 ++++---- 3 files changed, 34 insertions(+), 34 deletions(-) diff --git a/cot/src/project.rs b/cot/src/project.rs index 6e42fece..8af94342 100644 --- a/cot/src/project.rs +++ b/cot/src/project.rs @@ -53,10 +53,10 @@ use crate::db::Database; use crate::db::migrations::{MigrationEngine, SyncDynMigration}; use crate::email::{EmailBackend, SmtpConfig, SmtpEmailBackend}; use crate::error::ErrorRepr; -use crate::error_page::{Diagnostics, ErrorPageTrigger}; use crate::error::UncaughtPanic; use crate::error::error_impl::impl_into_cot_error; use crate::error::handler::{DynErrorPageHandler, RequestOuterError}; +use crate::error_page::{Diagnostics, ErrorPageTrigger}; use crate::handler::BoxedHandler; use crate::html::Html; use crate::middleware::{IntoCotError, IntoCotErrorLayer, IntoCotResponse, IntoCotResponseLayer}; diff --git a/examples/send-email/templates/index.html b/examples/send-email/templates/index.html index 134515e3..3215b013 100644 --- a/examples/send-email/templates/index.html +++ b/examples/send-email/templates/index.html @@ -1,28 +1,28 @@ - - Send Email - - -

Send Email

-
-
- - -
-
- - -
-
- - -
-
- - -
- -
- + + Send Email + + +

Send Email

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ diff --git a/examples/send-email/templates/sent.html b/examples/send-email/templates/sent.html index c4e3faa0..b4020141 100644 --- a/examples/send-email/templates/sent.html +++ b/examples/send-email/templates/sent.html @@ -1,11 +1,11 @@ - - Email Sent - - -

Email Sent Successfully

-

The email has been sent successfully.

- Send another email - + + Email Sent + + +

Email Sent Successfully

+

The email has been sent successfully.

+ Send another email + From 7bd01a8dcea6d77c43dc5e9cab5bf4152003d7d2 Mon Sep 17 00:00:00 2001 From: Elijah Date: Thu, 4 Dec 2025 13:08:19 +0000 Subject: [PATCH 22/77] non working proof of concept --- Cargo.lock | 253 ++++++- cot/Cargo.toml | 5 +- cot/src/config.rs | 97 +-- cot/src/email.rs | 1092 +++------------------------- cot/src/email/transport.rs | 26 + cot/src/email/transport/console.rs | 23 + cot/src/email/transport/smtp.rs | 60 ++ cot/src/lib.rs | 1 + cot/src/project.rs | 121 +-- examples/send-email/src/main.rs | 6 +- 10 files changed, 551 insertions(+), 1133 deletions(-) create mode 100644 cot/src/email/transport.rs create mode 100644 cot/src/email/transport/console.rs create mode 100644 cot/src/email/transport/smtp.rs diff --git a/Cargo.lock b/Cargo.lock index 6e07b7e1..97d4ac12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -208,6 +208,150 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite", + "once_cell", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +dependencies = [ + "event-listener 5.4.1", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel 2.5.0", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener 5.4.1", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-std" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" +dependencies = [ + "async-channel 1.9.0", + "async-global-executor", + "async-io", + "async-lock", + "async-process", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -230,6 +374,12 @@ dependencies = [ "syn", ] +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -371,6 +521,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel 2.5.0", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bstr" version = "1.12.1" @@ -1220,6 +1383,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + [[package]] name = "event-listener" version = "5.4.1" @@ -1231,6 +1400,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.1", + "pin-project-lite", +] + [[package]] name = "example-admin" version = "0.1.0" @@ -1474,6 +1653,19 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.31" @@ -1561,6 +1753,18 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "grass" version = "0.13.4" @@ -2111,6 +2315,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + [[package]] name = "lasso" version = "0.7.3" @@ -2135,11 +2348,14 @@ version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f" dependencies = [ + "async-std", + "async-trait", "base64", "chumsky", "email-encoding", "email_address", "fastrand", + "futures-io", "futures-util", "hostname", "httpdate", @@ -2151,6 +2367,7 @@ dependencies = [ "quoted_printable", "socket2", "tokio", + "tokio-native-tls", "url", ] @@ -2221,6 +2438,9 @@ name = "log" version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +dependencies = [ + "value-bag", +] [[package]] name = "matchers" @@ -2669,6 +2889,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkcs1" version = "0.7.5" @@ -2724,6 +2955,20 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -3425,7 +3670,7 @@ dependencies = [ "crc", "crossbeam-queue", "either", - "event-listener", + "event-listener 5.4.1", "futures-core", "futures-intrusive", "futures-io", @@ -4260,6 +4505,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "value-bag" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "943ce29a8a743eb10d6082545d861b24f9d1b160b7d741e0f2cdf726bec909c5" + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/cot/Cargo.toml b/cot/Cargo.toml index 05f4ef35..5174ed61 100644 --- a/cot/Cargo.toml +++ b/cot/Cargo.toml @@ -42,7 +42,7 @@ http-body.workspace = true http.workspace = true humantime.workspace = true indexmap.workspace = true -lettre.workspace = true +lettre = { workspace = true, features = ["builder", "sendmail-transport", "smtp-transport", "tokio1", "tokio1-native-tls"], optional = true } mime.workspace = true mime_guess.workspace = true multer.workspace = true @@ -104,9 +104,10 @@ ignored = [ [features] default = ["sqlite", "postgres", "mysql", "json"] -full = ["default", "fake", "live-reload", "test", "cache", "redis"] +full = ["default", "fake", "live-reload", "test", "cache", "redis", "email"] fake = ["dep:fake"] db = ["dep:sea-query", "dep:sea-query-binder", "dep:sqlx"] +email = ["dep:lettre"] sqlite = ["db", "sea-query/backend-sqlite", "sea-query-binder/sqlx-sqlite", "sqlx/sqlite"] postgres = ["db", "sea-query/backend-postgres", "sea-query-binder/sqlx-postgres", "sqlx/postgres"] mysql = ["db", "sea-query/backend-mysql", "sea-query-binder/sqlx-mysql", "sqlx/mysql"] diff --git a/cot/src/config.rs b/cot/src/config.rs index d5ed0485..9b369882 100644 --- a/cot/src/config.rs +++ b/cot/src/config.rs @@ -25,11 +25,13 @@ use serde::{Deserialize, Serialize}; use subtle::ConstantTimeEq; use thiserror::Error; +#[cfg(feature = "email")] +use crate::email; +#[cfg(feature = "email")] +use crate::email::transport::smtp::{SMTPCredentials, SMTPHost}; use crate::error::error_impl::impl_into_cot_error; use crate::utils::chrono::DateTimeWithOffsetAdapter; -use crate::email; - /// The configuration for a project. /// /// This is all the project-specific configuration data that can (and makes @@ -257,7 +259,7 @@ pub struct ProjectConfig { /// # Examples /// /// ``` - /// use cot::config::{EmailBackendConfig, ProjectConfig}; + /// use cot::config::{EmailConfig, ProjectConfig}; /// /// let config = ProjectConfig::from_toml( /// r#" @@ -266,10 +268,10 @@ pub struct ProjectConfig { /// "#, /// )?; /// - /// assert_eq!(config.email_backend, EmailBackendConfig::default()); + /// assert_eq!(config.email, EmailConfig::default()); /// # Ok::<(), cot::Error>(()) /// ``` - pub email_backend: EmailBackendConfig, + pub email: EmailConfig, } const fn default_debug() -> bool { @@ -379,7 +381,7 @@ impl ProjectConfigBuilder { cache: self.cache.clone().unwrap_or_default(), static_files: self.static_files.clone().unwrap_or_default(), middlewares: self.middlewares.clone().unwrap_or_default(), - email_backend: self.email_backend.clone().unwrap_or_default(), + email: self.email_backend.clone().unwrap_or_default(), } } } @@ -1823,83 +1825,96 @@ impl Default for SessionMiddlewareConfig { } } /// The type of email backend to use. +#[cfg(feature = "email")] #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum EmailBackendType { - /// No email backend. +#[serde(tag = "type", rename_all = "snake_case")] +pub enum EmailTransportTypeConfig { + /// Console email transport. #[default] - None, + Console, /// SMTP email backend. - Smtp, + Smtp { + credentials: SMTPCredentials, + host: SMTPHost, + }, } + +#[cfg(feature = "email")] +#[derive(Debug, Default, Clone, PartialEq, Eq, Builder, Serialize, Deserialize)] +#[builder(build_fn(skip, error = std::convert::Infallible))] +#[serde(default)] +pub struct EmailTransportConfig { + #[serde(flatten)] + pub transport_type: EmailTransportTypeConfig, +} + /// The configuration for the SMTP backend. /// -/// This is used as part of the [`EmailBackendConfig`] enum. +/// This is used as part of the [`EmailConfig`] enum. /// /// # Examples /// /// ``` -/// use cot::config::EmailBackendConfig; +/// use cot::config::EmailConfig; /// -/// let config = EmailBackendConfig::builder().build(); +/// let config = EmailConfig::builder().build(); /// ``` +#[cfg(feature = "email")] #[derive(Debug, Default, Clone, PartialEq, Eq, Builder, Serialize, Deserialize)] #[builder(build_fn(skip, error = std::convert::Infallible))] #[serde(default)] -pub struct EmailBackendConfig { +pub struct EmailConfig { /// 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, + pub transport: EmailTransportConfig, /// 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 { +#[cfg(feature = "email")] +impl EmailConfig { /// Create a new [`EmailBackendConfigBuilder`] to build a - /// [`EmailBackendConfig`]. + /// [`EmailConfig`]. /// /// # Examples /// /// ``` - /// use cot::config::EmailBackendConfig; + /// use cot::config::EmailConfig; /// - /// let config = EmailBackendConfig::builder().build(); + /// let config = EmailConfig::builder().build(); /// ``` #[must_use] - pub fn builder() -> EmailBackendConfigBuilder { - EmailBackendConfigBuilder::default() + pub fn builder() -> EmailTransportConfigBuilder { + EmailTransportConfigBuilder::default() } } -impl EmailBackendConfigBuilder { + +#[cfg(feature = "email")] +impl EmailTransportConfigBuilder { /// Builds the email configuration. /// /// # Examples /// /// ``` - /// use cot::config::EmailBackendConfig; + /// use cot::config::EmailConfig; /// - /// let config = EmailBackendConfig::builder().build(); + /// let config = EmailConfig::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, + pub fn build(&self) -> EmailConfig { + match self + .backend_type + .clone() + .unwrap_or(EmailTransportTypeConfig::None) + { + EmailTransportTypeConfig::Smtp => EmailConfig { + transport: EmailTransportTypeConfig::Smtp, smtp_mode: self .smtp_mode .clone() @@ -1909,8 +1924,8 @@ impl EmailBackendConfigBuilder { password: self.password.clone().unwrap_or_default(), timeout: self.timeout.unwrap_or_default(), }, - EmailBackendType::None => EmailBackendConfig { - backend_type: EmailBackendType::None, + EmailTransportTypeConfig::None => EmailConfig { + transport: EmailTransportTypeConfig::None, smtp_mode: email::SmtpTransportMode::Localhost, port: None, username: None, @@ -2311,7 +2326,7 @@ mod tests { ); assert!(config.middlewares.live_reload.enabled); assert!(!config.middlewares.session.secure); - assert_eq!(config.email_backend.backend_type, EmailBackendType::None); + assert_eq!(config.email.transport, EmailTransportTypeConfig::None); assert!(!config.middlewares.session.http_only); assert_eq!( config.middlewares.session.domain, diff --git a/cot/src/email.rs b/cot/src/email.rs index 7a661219..60eb5b3c 100644 --- a/cot/src/email.rs +++ b/cot/src/email.rs @@ -23,15 +23,21 @@ //! let _ = backend.send_message(&email); //! } //! ``` -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::*}; +pub mod transport; + +use std::error::Error; +use std::sync::Arc; + +use cot::config::{EmailConfig, EmailTransportTypeConfig}; +use cot::email::transport::smtp::SMTP; +use lettre::message::header::ContentType; +use lettre::message::{Attachment, Body, Mailbox, Message, MultiPart, SinglePart}; use serde::{Deserialize, Serialize}; +use transport::{BoxedTransport, Transport}; + +use crate::config::EmailTransportTypeConfig::Smtp; +use crate::email::transport::console::Console; /// Represents errors that can occur when sending an email. #[derive(Debug, thiserror::Error)] @@ -52,1041 +58,117 @@ 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, Serialize, Deserialize)] -pub enum SmtpTransportMode { - /// No SMTP transport. - None, - /// 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), -} +pub type EmailResult = std::result::Result; -/// 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, -} -/// 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, Builder, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct SmtpConfig { - /// The SMTP server host address. - /// Defaults to "localhost". - pub mode: SmtpTransportMode, - /// The SMTP server port. - /// Overwrites the default standard port when specified. - pub port: Option, - /// The username for SMTP authentication. - pub username: Option, - /// The password for SMTP authentication. - pub password: Option, - /// The timeout duration for the SMTP connection. - pub timeout: Option, +#[derive(Debug, Clone)] +pub struct AttachmentData { + filename: String, + content_type: String, + data: Vec, } -/// SMTP Backend for sending emails -//#[allow(missing_debug_implementations)] -#[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, - 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. -impl Default for SmtpConfig { - fn default() -> Self { - Self { - mode: SmtpTransportMode::None, - port: None, - username: None, - password: None, - timeout: Some(Duration::from_secs(60)), - } - } +#[derive(Debug, Clone)] +pub struct EmailMessage { + subject: String, + body: String, + from: crate::common_types::Email, + to: Vec, + cc: Vec, + bcc: Vec, + reply_to: Vec, + attachments: Vec, } -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() - } - } - 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( - "Both username and password must be provided for SMTP authentication".to_string(), - )); - } - 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(), - SmtpTransportMode::None => &String::new(), - }; - if host.is_empty() && self.mode != SmtpTransportMode::None { - return Err(EmailError::ConfigurationError( - "Host cannot be empty or blank".to_string(), - )); - } - 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 { +impl TryFrom for Message { 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(), - )); - } + fn try_from(message: EmailMessage) -> Result { + let from_mailbox: Mailbox = message.from.email().parse()?; - 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; - - 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())), - } - } -} -/// 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)?); + .from(from_mailbox) + .subject(message.subject); - // 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()?); - } + for to in message.to { + let mb: Mailbox = to.email().parse()?; + builder = builder.to(mb); } - // Add BCC recipients if present - if let Some(bcc) = &email.bcc { - for bc in bcc { - builder = builder.cc(bc.parse()?); - } + for cc in message.cc { + let mb: Mailbox = cc.email().parse()?; + builder = builder.cc(mb); } - // 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}"))) + for bcc in message.bcc { + let mb: Mailbox = bcc.email().parse()?; + builder = builder.bcc(mb); } - } -} -/// 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: Send + Sync { - /// Test the connection to the SMTP server. - /// # Errors - /// 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``. - 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) { - Ok(_) => Ok(()), - Err(e) => Err(EmailError::SendError(e.to_string())), + for r in message.reply_to { + let mb: Mailbox = r.email().parse()?; + builder = builder.reply_to(mb); } - } -} - -/// Trait representing an email backend for sending emails. -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`` - /// - /// # 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<&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. - /// - /// # Errors - /// - /// This function will return an `EmailError` if there is an issue with - /// closing the SMTP connection. - fn close(&mut self) -> Result<()>; + let mut mixed = MultiPart::mixed().singlepart(SinglePart::plain(message.body)); - /// 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<()>; + for attach in message.attachments { + let mime: ContentType = attach + .content_type + .parse() + .unwrap_or_else(|_| "application/octet-stream".parse().unwrap()); - /// Send multiple email messages - /// - /// # 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), - } + let part = Attachment::new(attach.filename).body(Body::new(attach.data), mime); + mixed = mixed.singlepart(part); } - Ok(sent_count) + let email = builder.multipart(mixed).map_err(|err| { + EmailError::MessageError(format!("Failed to build email message,error:{err}")) + })?; + Ok(email) } } -impl EmailBackend for SmtpEmailBackend { - /// Creates a new instance of `EmailBackend` with the given configuration. - /// - /// # Arguments - /// - /// * `config` - The SMTP configuration to use. - fn new(config: SmtpConfig) -> Self { - Self { - config, - transport: None, - debug: false, - transport_state: TransportState::Uninitialized, - } - } - - /// 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 - /// - /// `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(()); - } - 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::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}" - )) - })?, - 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()); - } - - // 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}")) - })?; +#[derive(Debug, Clone)] +pub struct Email { + transport: Arc, +} - // Add authentication if credentials provided - 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(()) +impl Email { + pub fn new(transport: impl Transport) -> Self { + let transport: Arc = Arc::new(transport); + Self { transport } } - /// 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); - } - // 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(), - )); - } - Ok(self) + pub fn send(&self, messages: &[EmailMessage]) -> EmailResult<()> { + self.transport.send(messages)? } - /// 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<()> { - self.transport = None; - self.transport_state = TransportState::Uninitialized; - Ok(()) - } + pub fn from_config(config: &EmailConfig) -> Self { + let transport = &config.transport; - /// 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, 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.try_into()?) - .map_err(|e| EmailError::SendError(e.to_string()))?; + let this = { + match transport.transport_type { + EmailTransportTypeConfig::Console => { + let console = Console::new(); + Self::new(console) + } - Ok(()) - } -} -impl SmtpEmailBackend { - /// Creates a new instance of `SmtpEmailBackend` from the given - /// configuration and transport. - /// - /// # Arguments - /// - /// * `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, - } + EmailTransportTypeConfig::Smtp { + ref credentials, + host, + } => { + let smtp = SMTP::new(credentials.clone(), host.clone()); + Self::new(smtp) + } + } + }; + this } } + #[cfg(test)] mod tests { use super::*; - #[test] - fn test_config_defaults_values() { - let config = SmtpConfig::default(); - - assert_eq!(config.mode, SmtpTransportMode::None); - 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] - 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()), - ..Default::default() - }; - let result = config.validate(); - assert!(matches!(result, Err(EmailError::ConfigurationError(_)))); - } - - #[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(_)))); - } - - #[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()); - } - - #[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_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] - 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(_)))); - } - - #[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 = 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(); - - // 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); - eprintln!("Result: {:?}", result); - - // Assert that the email was sent successfully - assert!(result.is_ok()); - } - - #[test] - fn test_backend_close() { - // Create a mock transport - let mock_transport = MockEmailTransport::new(); - let config = SmtpConfig::default(); - let mut backend = SmtpEmailBackend::from_config(config, Box::new(mock_transport)); - - let result = backend.close(); - assert!(result.is_ok()); - } - - #[test] - 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 = 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 { - mode: SmtpTransportMode::Relay("invalid-host".to_string()), - port: Some(587), - username: Some("user@cotexample.com".to_string()), - ..Default::default() - }; - - 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(_)))); - } - - #[test] - 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![ - 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 - 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() { - // Create a test email - 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 { - 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()); - } - - #[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_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 - // Create config and backend - let config = SmtpConfig { - mode: SmtpTransportMode::Unencrypted("localhost".to_string()), - username: Some("justtheruser".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!(matches!(result, Err(EmailError::ConfigurationError(_)))); - assert!(backend.transport_state == TransportState::Uninitialized); - } - - #[test] - 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() - }; - 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 { - 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::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()); diff --git a/cot/src/email/transport.rs b/cot/src/email/transport.rs new file mode 100644 index 00000000..66df8317 --- /dev/null +++ b/cot/src/email/transport.rs @@ -0,0 +1,26 @@ +use std::pin::Pin; + +use crate::email::EmailMessage; + +pub mod console; +pub mod smtp; + +pub trait Transport: Send + Sync + 'static { + fn send(&self, messages: &[EmailMessage]) -> impl Future> + Send; +} + +pub(crate) trait BoxedTransport: Send + Sync + 'static { + fn send<'a>( + &'a self, + messages: &[EmailMessage], + ) -> Pin> + Send + 'a>>; +} + +impl BoxedTransport for T { + fn send<'a>( + &'a self, + messages: &[EmailMessage], + ) -> Pin> + Send + 'a>> { + Box::pin(async move { T::send(self, messages).await }) + } +} diff --git a/cot/src/email/transport/console.rs b/cot/src/email/transport/console.rs new file mode 100644 index 00000000..ef014df6 --- /dev/null +++ b/cot/src/email/transport/console.rs @@ -0,0 +1,23 @@ +use cot::email::EmailMessage; +use lettre::Message; + +use crate::email::transport::Transport; + +#[derive(Debug, Clone)] +pub struct Console; + +impl Console { + pub fn new() -> Self { + Self {} + } +} + +impl Transport for Console { + async fn send(&self, messages: &[EmailMessage]) -> Result<(), String> { + for message in messages { + let m: Message = message.clone().into(); + println!("{m:?}"); + } + Ok(()) + } +} diff --git a/cot/src/email/transport/smtp.rs b/cot/src/email/transport/smtp.rs new file mode 100644 index 00000000..fd146382 --- /dev/null +++ b/cot/src/email/transport/smtp.rs @@ -0,0 +1,60 @@ +use cot::email::EmailMessage; +use lettre::transport::smtp::authentication::Credentials; +use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor}; +use serde::{Deserialize, Serialize}; + +use crate::common_types::Password; +use crate::email::transport::Transport; + +#[derive(Debug, Clone)] +pub struct SMTPCredentials { + username: String, + password: Password, +} + +impl From for Credentials { + fn from(credentials: SMTPCredentials) -> Self { + Credentials::new(credentials.username, credentials.password.into_string()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum SMTPHost { + Gmail, + Localhost, +} + +impl SMTPHost { + pub fn as_str(&self) -> &str { + match self { + SMTPHost::Gmail => "smtp.gmail.com", + SMTPHost::Localhost => "localhost", + } + } +} + +#[derive(Debug, Clone)] +pub struct SMTP { + credentials: SMTPCredentials, + host: SMTPHost, +} + +impl SMTP { + pub fn new(credentials: SMTPCredentials, host: SMTPHost) -> Self { + Self { credentials, host } + } +} + +impl Transport for SMTP { + async fn send(&self, messages: &[EmailMessage]) -> Result<(), String> { + for message in messages { + let mailer = AsyncSmtpTransport::relay(self.host.as_str()) + .unwrap() + .credentials(self.credentials.clone().into()) + .build::(); + mailer.send(message.clone().into()).await.unwrap(); + } + + Ok(()) + } +} diff --git a/cot/src/lib.rs b/cot/src/lib.rs index cd669441..9776dc41 100644 --- a/cot/src/lib.rs +++ b/cot/src/lib.rs @@ -73,6 +73,7 @@ mod body; pub mod cli; pub mod common_types; pub mod config; +#[cfg(feature = "email")] pub mod email; mod error_page; #[macro_use] diff --git a/cot/src/project.rs b/cot/src/project.rs index 8af94342..c67d2ebb 100644 --- a/cot/src/project.rs +++ b/cot/src/project.rs @@ -46,17 +46,16 @@ use crate::cli::Cli; use crate::config::CacheConfig; #[cfg(feature = "db")] use crate::config::DatabaseConfig; -use crate::config::{AuthBackendConfig, EmailBackendConfig, EmailBackendType, ProjectConfig}; +use crate::config::{AuthBackendConfig, EmailConfig, EmailTransportTypeConfig, 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::email::Email; use crate::error::UncaughtPanic; use crate::error::error_impl::impl_into_cot_error; use crate::error::handler::{DynErrorPageHandler, RequestOuterError}; -use crate::error_page::{Diagnostics, ErrorPageTrigger}; +use crate::error_page::Diagnostics; use crate::handler::BoxedHandler; use crate::html::Html; use crate::middleware::{IntoCotError, IntoCotErrorLayer, IntoCotResponse, IntoCotResponseLayer}; @@ -1386,10 +1385,7 @@ impl Bootstrapper { let handler = self.project.middlewares(handler_builder, &self.context); let auth_backend = self.project.auth_backend(&self.context); - 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); + let context = self.context.with_auth(auth_backend); Ok(Bootstrapper { project: self.project, @@ -1398,25 +1394,6 @@ impl Bootstrapper { error_handler: handler.error_handler, }) } - - async fn init_email_backend( - config: &EmailBackendConfig, - ) -> Option>> { - match &config.backend_type { - EmailBackendType::None => 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); - Some(Arc::new(Mutex::new(backend))) - } - } - } } impl Bootstrapper { /// Returns the context and handlers of the bootstrapper. @@ -1549,6 +1526,10 @@ pub trait BootstrapPhase: sealed::Sealed { // App context types /// The type of the configuration. type Config: Debug; + /// The type of the email service. + #[cfg(feature = "email")] + type Email: Debug; + /// The type of the apps. type Apps; /// The type of the router. @@ -1561,8 +1542,6 @@ pub trait BootstrapPhase: sealed::Sealed { /// The type of the cache. #[cfg(feature = "cache")] type Cache: Debug; - /// The type of the email backend. - type EmailBackend: Debug; } /// First phase of bootstrapping a Cot project, the uninitialized phase. @@ -1579,6 +1558,8 @@ impl BootstrapPhase for Uninitialized { type RequestHandler = (); type ErrorHandler = (); type Config = (); + #[cfg(feature = "email")] + type Email = (); type Apps = (); type Router = (); #[cfg(feature = "db")] @@ -1586,7 +1567,6 @@ impl BootstrapPhase for Uninitialized { type AuthBackend = (); #[cfg(feature = "cache")] type Cache = (); - type EmailBackend = (); } /// Second phase of bootstrapping a Cot project, the with-config phase. @@ -1603,6 +1583,8 @@ impl BootstrapPhase for WithConfig { type RequestHandler = (); type ErrorHandler = (); type Config = Arc; + #[cfg(feature = "email")] + type Email = Arc; type Apps = (); type Router = (); #[cfg(feature = "db")] @@ -1610,7 +1592,6 @@ impl BootstrapPhase for WithConfig { type AuthBackend = (); #[cfg(feature = "cache")] type Cache = (); - type EmailBackend = (); } /// Third phase of bootstrapping a Cot project, the with-apps phase. @@ -1627,6 +1608,8 @@ impl BootstrapPhase for WithApps { type RequestHandler = (); type ErrorHandler = (); type Config = ::Config; + #[cfg(feature = "email")] + type Email = Arc; type Apps = Vec>; type Router = Arc; #[cfg(feature = "db")] @@ -1634,7 +1617,6 @@ impl BootstrapPhase for WithApps { type AuthBackend = (); #[cfg(feature = "cache")] type Cache = (); - type EmailBackend = (); } /// Fourth phase of bootstrapping a Cot project, the with-database phase. @@ -1651,13 +1633,13 @@ impl BootstrapPhase for WithDatabase { type RequestHandler = (); type ErrorHandler = (); type Config = ::Config; + #[cfg(feature = "email")] + type Email = Arc; type Apps = ::Apps; type Router = ::Router; #[cfg(feature = "db")] type Database = Option>; type AuthBackend = ::AuthBackend; - type EmailBackend = ::EmailBackend; - #[cfg(feature = "cache")] type Cache = (); } @@ -1676,6 +1658,8 @@ impl BootstrapPhase for WithCache { type RequestHandler = (); type ErrorHandler = (); type Config = ::Config; + #[cfg(feature = "email")] + type Email = Arc; type Apps = ::Apps; type Router = ::Router; #[cfg(feature = "db")] @@ -1699,6 +1683,8 @@ impl BootstrapPhase for Initialized { type RequestHandler = BoxedHandler; type ErrorHandler = BoxedHandler; type Config = ::Config; + #[cfg(feature = "email")] + type Email = Arc; type Apps = ::Apps; type Router = ::Router; #[cfg(feature = "db")] @@ -1706,7 +1692,6 @@ impl BootstrapPhase for Initialized { type AuthBackend = Arc; #[cfg(feature = "cache")] type Cache = ::Cache; - type EmailBackend = Option>>; } /// Shared context and configs for all apps. Used in conjunction with the @@ -1723,8 +1708,8 @@ pub struct ProjectContext { auth_backend: S::AuthBackend, #[cfg(feature = "cache")] cache: S::Cache, - #[debug("..")] - email_backend: S::EmailBackend, + #[cfg(feature = "email")] + email: S::Email, } impl ProjectContext { @@ -1739,11 +1724,14 @@ impl ProjectContext { auth_backend: (), #[cfg(feature = "cache")] cache: (), - email_backend: (), + #[cfg(feature = "email")] + email: (), } } fn with_config(self, config: ProjectConfig) -> ProjectContext { + let email = Email::from_config(&config.email); + ProjectContext { config: Arc::new(config), apps: self.apps, @@ -1753,7 +1741,8 @@ impl ProjectContext { auth_backend: self.auth_backend, #[cfg(feature = "cache")] cache: self.cache, - email_backend: self.email_backend, + #[cfg(feature = "email")] + email, } } } @@ -1796,7 +1785,8 @@ impl ProjectContext { auth_backend: self.auth_backend, #[cfg(feature = "cache")] cache: self.cache, - email_backend: self.email_backend, + #[cfg(feature = "email")] + email: self.email, } } } @@ -1838,7 +1828,8 @@ impl ProjectContext { auth_backend: self.auth_backend, #[cfg(feature = "cache")] cache: self.cache, - email_backend: self.email_backend, + #[cfg(feature = "email")] + email: self.email, } } } @@ -1855,17 +1846,15 @@ impl ProjectContext { database: self.database, #[cfg(feature = "cache")] cache, + #[cfg(feature = "email")] + email: self.email, } } } impl ProjectContext { #[must_use] - fn with_auth_and_email( - self, - auth_backend: Arc, - email_backend: Option>>, - ) -> ProjectContext { + fn with_auth(self, auth_backend: Arc) -> ProjectContext { ProjectContext { config: self.config, apps: self.apps, @@ -1875,7 +1864,8 @@ impl ProjectContext { database: self.database, #[cfg(feature = "cache")] cache: self.cache, - email_backend, + #[cfg(feature = "email")] + email: self.email, } } } @@ -1888,7 +1878,7 @@ impl ProjectContext { auth_backend: ::AuthBackend, #[cfg(feature = "db")] database: ::Database, #[cfg(feature = "cache")] cache: ::Cache, - email_backend: ::EmailBackend, + #[cfg(feature = "email")] email: ::Email, ) -> Self { Self { config, @@ -1899,7 +1889,8 @@ impl ProjectContext { auth_backend, #[cfg(feature = "cache")] cache, - email_backend, + #[cfg(feature = "email")] + email, } } } @@ -2029,38 +2020,6 @@ 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/examples/send-email/src/main.rs b/examples/send-email/src/main.rs index 9ff2fb12..163475f7 100644 --- a/examples/send-email/src/main.rs +++ b/examples/send-email/src/main.rs @@ -1,5 +1,5 @@ use cot::cli::CliMetadata; -use cot::config::{DatabaseConfig, EmailBackendConfig, EmailBackendType, ProjectConfig}; +use cot::config::{DatabaseConfig, EmailConfig, EmailTransportTypeConfig, ProjectConfig}; use cot::email::{EmailBackend, EmailMessage, SmtpTransportMode}; use cot::form::Form; use cot::html::Html; @@ -66,8 +66,8 @@ impl Project for MyProject { } fn config(&self, _config_name: &str) -> cot::Result { - let mut email_config = EmailBackendConfig::builder(); - email_config.backend_type(EmailBackendType::Smtp); + let mut email_config = EmailConfig::builder(); + email_config.backend_type(EmailTransportTypeConfig::Smtp); email_config.smtp_mode(SmtpTransportMode::Localhost); email_config.port(1025_u16); let config = ProjectConfig::builder() From 01fa3690c10e2666a4bcd3ef73773c8fa7c304b2 Mon Sep 17 00:00:00 2001 From: Elijah Date: Thu, 4 Dec 2025 14:02:56 +0000 Subject: [PATCH 23/77] some small refinement --- Cargo.toml | 2 +- cot/src/config.rs | 58 ++++++++++++--------------------- cot/src/email.rs | 9 +++-- cot/src/email/transport/smtp.rs | 36 ++++++++++++++++++-- 4 files changed, 63 insertions(+), 42 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 8d93060c..56f5c119 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -98,7 +98,7 @@ humantime = "2" indexmap = "2" insta = { version = "1", features = ["filters"] } insta-cmd = "0.6" -lettre = { version = "0.11", features = ["smtp-transport", "builder", "native-tls"] } +lettre = { version = "0.11", default-features = false } mime = "0.3" mime_guess = { version = "2", default-features = false } mockall = "0.13" diff --git a/cot/src/config.rs b/cot/src/config.rs index 9b369882..32db572b 100644 --- a/cot/src/config.rs +++ b/cot/src/config.rs @@ -27,6 +27,7 @@ use thiserror::Error; #[cfg(feature = "email")] use crate::email; +use crate::email::transport::smtp::Mechanism; #[cfg(feature = "email")] use crate::email::transport::smtp::{SMTPCredentials, SMTPHost}; use crate::error::error_impl::impl_into_cot_error; @@ -381,7 +382,8 @@ impl ProjectConfigBuilder { cache: self.cache.clone().unwrap_or_default(), static_files: self.static_files.clone().unwrap_or_default(), middlewares: self.middlewares.clone().unwrap_or_default(), - email: self.email_backend.clone().unwrap_or_default(), + #[cfg(feature = "email")] + email: self.email.clone().unwrap_or_default(), } } } @@ -1824,6 +1826,7 @@ impl Default for SessionMiddlewareConfig { SessionMiddlewareConfig::builder().build() } } + /// The type of email backend to use. #[cfg(feature = "email")] #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -1834,7 +1837,9 @@ pub enum EmailTransportTypeConfig { Console, /// SMTP email backend. Smtp { - credentials: SMTPCredentials, + auth_id: String, + secret: String, + mechanism: Mechanism, host: SMTPHost, }, } @@ -1866,15 +1871,8 @@ pub struct EmailTransportConfig { pub struct EmailConfig { /// The type of email backend to use. /// Defaults to `None`. - #[builder(setter(into, strip_option), default)] + #[builder(default)] pub transport: EmailTransportConfig, - /// The SMTP server port. - /// Overwrites the default standard port when specified. - #[builder(setter(into, strip_option), default)] - pub port: Option, - /// The timeout duration for the SMTP connection. - #[builder(setter(into, strip_option), default)] - pub timeout: Option, } #[cfg(feature = "email")] @@ -1890,13 +1888,13 @@ impl EmailConfig { /// let config = EmailConfig::builder().build(); /// ``` #[must_use] - pub fn builder() -> EmailTransportConfigBuilder { - EmailTransportConfigBuilder::default() + pub fn builder() -> EmailConfigBuilder { + EmailConfigBuilder::default() } } #[cfg(feature = "email")] -impl EmailTransportConfigBuilder { +impl EmailConfigBuilder { /// Builds the email configuration. /// /// # Examples @@ -1908,33 +1906,19 @@ impl EmailTransportConfigBuilder { /// ``` #[must_use] pub fn build(&self) -> EmailConfig { - match self - .backend_type - .clone() - .unwrap_or(EmailTransportTypeConfig::None) - { - EmailTransportTypeConfig::Smtp => EmailConfig { - transport: EmailTransportTypeConfig::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(), - }, - EmailTransportTypeConfig::None => EmailConfig { - transport: EmailTransportTypeConfig::None, - smtp_mode: email::SmtpTransportMode::Localhost, - port: None, - username: None, - password: None, - timeout: None, - }, + EmailConfig { + transport: self.transport.clone().unwrap_or_default(), } } } + +#[cfg(feature = "email")] +impl Default for EmailConfig { + fn default() -> Self { + EmailConfig::builder().build() + } +} + /// A secret key. /// /// This is a wrapper over a byte array, which is used to store a cryptographic diff --git a/cot/src/email.rs b/cot/src/email.rs index 60eb5b3c..70245930 100644 --- a/cot/src/email.rs +++ b/cot/src/email.rs @@ -36,8 +36,10 @@ use lettre::message::{Attachment, Body, Mailbox, Message, MultiPart, SinglePart} use serde::{Deserialize, Serialize}; use transport::{BoxedTransport, Transport}; +use crate::common_types::Password; use crate::config::EmailTransportTypeConfig::Smtp; use crate::email::transport::console::Console; +use crate::email::transport::smtp::SMTPCredentials; /// Represents errors that can occur when sending an email. #[derive(Debug, thiserror::Error)] @@ -153,10 +155,13 @@ impl Email { } EmailTransportTypeConfig::Smtp { - ref credentials, + ref auth_id, + ref secret, + ref mechanism, host, } => { - let smtp = SMTP::new(credentials.clone(), host.clone()); + let credentials = SMTPCredentials::new(auth_id.clone(), Password::from(secret)); + let smtp = SMTP::new(credentials, host.clone(), mechanism.clone()); Self::new(smtp) } } diff --git a/cot/src/email/transport/smtp.rs b/cot/src/email/transport/smtp.rs index fd146382..5b038185 100644 --- a/cot/src/email/transport/smtp.rs +++ b/cot/src/email/transport/smtp.rs @@ -6,12 +6,36 @@ use serde::{Deserialize, Serialize}; use crate::common_types::Password; use crate::email::transport::Transport; +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum Mechanism { + #[default] + Plain, + Login, + Xoauth2, +} + +impl From for lettre::transport::smtp::authentication::Mechanism { + fn from(mechanism: Mechanism) -> Self { + match mechanism { + Mechanism::Plain => lettre::transport::smtp::authentication::Mechanism::Plain, + Mechanism::Login => lettre::transport::smtp::authentication::Mechanism::Login, + Mechanism::Xoauth2 => lettre::transport::smtp::authentication::Mechanism::Xoauth2, + } + } +} + #[derive(Debug, Clone)] pub struct SMTPCredentials { username: String, password: Password, } +impl SMTPCredentials { + pub fn new(username: String, password: Password) -> Self { + Self { username, password } + } +} + impl From for Credentials { fn from(credentials: SMTPCredentials) -> Self { Credentials::new(credentials.username, credentials.password.into_string()) @@ -37,20 +61,28 @@ impl SMTPHost { pub struct SMTP { credentials: SMTPCredentials, host: SMTPHost, + mechanism: Mechanism, } impl SMTP { - pub fn new(credentials: SMTPCredentials, host: SMTPHost) -> Self { - Self { credentials, host } + pub fn new(credentials: SMTPCredentials, host: SMTPHost, mechanism: Mechanism) -> Self { + Self { + credentials, + host, + mechanism, + } } } impl Transport for SMTP { async fn send(&self, messages: &[EmailMessage]) -> Result<(), String> { + let mechanisms: Vec = + vec!(self.mechanism.clone().into()); for message in messages { let mailer = AsyncSmtpTransport::relay(self.host.as_str()) .unwrap() .credentials(self.credentials.clone().into()) + .authentication(mechanisms.clone()) .build::(); mailer.send(message.clone().into()).await.unwrap(); } From ec22d955869fed00922dcc126a5dbcde10f4a058 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 4 Dec 2025 14:03:12 +0000 Subject: [PATCH 24/77] chore(pre-commit.ci): auto fixes from pre-commit hooks --- cot/src/email/transport/smtp.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cot/src/email/transport/smtp.rs b/cot/src/email/transport/smtp.rs index 5b038185..42c42c6d 100644 --- a/cot/src/email/transport/smtp.rs +++ b/cot/src/email/transport/smtp.rs @@ -77,7 +77,7 @@ impl SMTP { impl Transport for SMTP { async fn send(&self, messages: &[EmailMessage]) -> Result<(), String> { let mechanisms: Vec = - vec!(self.mechanism.clone().into()); + vec![self.mechanism.clone().into()]; for message in messages { let mailer = AsyncSmtpTransport::relay(self.host.as_str()) .unwrap() From 1cccefd7d437c3940d454227cf20ee6550ec2dc1 Mon Sep 17 00:00:00 2001 From: Elijah Date: Mon, 8 Dec 2025 17:01:46 +0000 Subject: [PATCH 25/77] okay, we have a working version! --- cot/src/config.rs | 9 +- cot/src/email.rs | 162 ++++++++++++++++++++--------- cot/src/email/transport.rs | 26 ++++- cot/src/email/transport/console.rs | 108 +++++++++++++++++-- cot/src/email/transport/smtp.rs | 51 ++++++--- cot/src/project.rs | 18 +++- 6 files changed, 286 insertions(+), 88 deletions(-) diff --git a/cot/src/config.rs b/cot/src/config.rs index 32db572b..f0bd9761 100644 --- a/cot/src/config.rs +++ b/cot/src/config.rs @@ -25,11 +25,9 @@ use serde::{Deserialize, Serialize}; use subtle::ConstantTimeEq; use thiserror::Error; -#[cfg(feature = "email")] -use crate::email; use crate::email::transport::smtp::Mechanism; #[cfg(feature = "email")] -use crate::email::transport::smtp::{SMTPCredentials, SMTPHost}; +use crate::email::transport::smtp::SMTPServer; use crate::error::error_impl::impl_into_cot_error; use crate::utils::chrono::DateTimeWithOffsetAdapter; @@ -1840,7 +1838,7 @@ pub enum EmailTransportTypeConfig { auth_id: String, secret: String, mechanism: Mechanism, - host: SMTPHost, + server: SMTPServer, }, } @@ -1865,7 +1863,7 @@ pub struct EmailTransportConfig { /// let config = EmailConfig::builder().build(); /// ``` #[cfg(feature = "email")] -#[derive(Debug, Default, Clone, PartialEq, Eq, Builder, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Builder, Serialize, Deserialize)] #[builder(build_fn(skip, error = std::convert::Infallible))] #[serde(default)] pub struct EmailConfig { @@ -2310,7 +2308,6 @@ mod tests { ); assert!(config.middlewares.live_reload.enabled); assert!(!config.middlewares.session.secure); - assert_eq!(config.email.transport, EmailTransportTypeConfig::None); assert!(!config.middlewares.session.http_only); assert_eq!( config.middlewares.session.domain, diff --git a/cot/src/email.rs b/cot/src/email.rs index 70245930..a12f849e 100644 --- a/cot/src/email.rs +++ b/cot/src/email.rs @@ -31,36 +31,36 @@ use std::sync::Arc; use cot::config::{EmailConfig, EmailTransportTypeConfig}; use cot::email::transport::smtp::SMTP; +use derive_builder::Builder; +use derive_more::with_trait::Debug; use lettre::message::header::ContentType; use lettre::message::{Attachment, Body, Mailbox, Message, MultiPart, SinglePart}; use serde::{Deserialize, Serialize}; +use thiserror::Error; use transport::{BoxedTransport, Transport}; use crate::common_types::Password; -use crate::config::EmailTransportTypeConfig::Smtp; use crate::email::transport::console::Console; use crate::email::transport::smtp::SMTPCredentials; +use crate::error::error_impl::impl_into_cot_error; + +const ERROR_PREFIX: &str = "email error:"; /// Represents errors that can occur when sending an email. #[derive(Debug, thiserror::Error)] pub enum EmailError { + #[error("transport error: {0}")] + TransportError(String), /// An error occurred while building the email message. - #[error("Message error: {0}")] + #[error("message error: {0}")] MessageError(String), - /// The email configuration is invalid. - #[error("Invalid email configuration: {0}")] - ConfigurationError(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), + /// A required field is missing in the email message. + #[error("missing required field: {0}")] + MissingField(String), } -type Result = std::result::Result; - -pub type EmailResult = std::result::Result; +impl_into_cot_error!(EmailError); +pub type EmailResult = Result; #[derive(Debug, Clone)] pub struct AttachmentData { @@ -69,7 +69,8 @@ pub struct AttachmentData { data: Vec, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Builder)] +#[builder(build_fn(skip))] pub struct EmailMessage { subject: String, body: String, @@ -81,57 +82,125 @@ pub struct EmailMessage { attachments: Vec, } +impl EmailMessage { + #[must_use] + pub fn builder() -> EmailMessageBuilder { + EmailMessageBuilder::default() + } +} + +impl EmailMessageBuilder { + pub fn build(&self) -> Result { + let from = self + .from + .clone() + .ok_or_else(|| EmailError::MissingField("from".to_string()))?; + + let subject = self.subject.clone().unwrap_or_default(); + let body = self.body.clone().unwrap_or_default(); + + let to = self.to.clone().unwrap_or_default(); + let cc = self.cc.clone().unwrap_or_default(); + let bcc = self.bcc.clone().unwrap_or_default(); + let reply_to = self.reply_to.clone().unwrap_or_default(); + let attachments = self.attachments.clone().unwrap_or_default(); + + Ok(EmailMessage { + subject, + body, + from, + to, + cc, + bcc, + reply_to, + attachments, + }) + } +} + +#[derive(Debug, Clone, Error)] +#[non_exhaustive] +pub enum MessageBuildError { + #[error("invalid email address: {0}")] + InvalidEmailAddress(String), + #[error("failed to build email message: {0}")] + BuildError(String), +} + impl TryFrom for Message { - type Error = EmailError; + type Error = MessageBuildError; - fn try_from(message: EmailMessage) -> Result { - let from_mailbox: Mailbox = message.from.email().parse()?; + fn try_from(message: EmailMessage) -> Result { + let from_mailbox = message + .from + .email() + .as_str() + .parse::() + .map_err(|err| MessageBuildError::InvalidEmailAddress(err.to_string()))?; let mut builder = Message::builder() .from(from_mailbox) .subject(message.subject); for to in message.to { - let mb: Mailbox = to.email().parse()?; + let mb = to + .email() + .as_str() + .parse::() + .map_err(|err| MessageBuildError::InvalidEmailAddress(err.to_string()))?; builder = builder.to(mb); } for cc in message.cc { - let mb: Mailbox = cc.email().parse()?; + let mb = cc + .email() + .as_str() + .parse::() + .map_err(|err| MessageBuildError::InvalidEmailAddress(err.to_string()))?; builder = builder.cc(mb); } for bcc in message.bcc { - let mb: Mailbox = bcc.email().parse()?; + let mb = bcc + .email() + .as_str() + .parse::() + .map_err(|err| MessageBuildError::InvalidEmailAddress(err.to_string()))?; builder = builder.bcc(mb); } for r in message.reply_to { - let mb: Mailbox = r.email().parse()?; + let mb = r + .email() + .as_str() + .parse::() + .map_err(|err| MessageBuildError::InvalidEmailAddress(err.to_string()))?; builder = builder.reply_to(mb); } let mut mixed = MultiPart::mixed().singlepart(SinglePart::plain(message.body)); for attach in message.attachments { - let mime: ContentType = attach - .content_type - .parse() - .unwrap_or_else(|_| "application/octet-stream".parse().unwrap()); + let mime: ContentType = attach.content_type.parse().unwrap_or_else(|_| { + "application/octet-stream" + .parse() + .expect("could not parse default mime type") + }); let part = Attachment::new(attach.filename).body(Body::new(attach.data), mime); mixed = mixed.singlepart(part); } - let email = builder.multipart(mixed).map_err(|err| { - EmailError::MessageError(format!("Failed to build email message,error:{err}")) - })?; + let email = builder + .multipart(mixed) + .map_err(|err| MessageBuildError::BuildError(err.to_string()))?; Ok(email) } } #[derive(Debug, Clone)] pub struct Email { + #[debug("..")] transport: Arc, } @@ -140,27 +209,31 @@ impl Email { let transport: Arc = Arc::new(transport); Self { transport } } - pub fn send(&self, messages: &[EmailMessage]) -> EmailResult<()> { - self.transport.send(messages)? + pub async fn send(&self, messages: &[EmailMessage]) -> EmailResult<()> { + self.transport + .send(messages) + .await + .map_err(|err| EmailError::TransportError(err.to_string())) } pub fn from_config(config: &EmailConfig) -> Self { let transport = &config.transport; let this = { - match transport.transport_type { + match &transport.transport_type { EmailTransportTypeConfig::Console => { let console = Console::new(); Self::new(console) } EmailTransportTypeConfig::Smtp { - ref auth_id, - ref secret, - ref mechanism, - host, + auth_id, + secret, + mechanism, + server: host, } => { - let credentials = SMTPCredentials::new(auth_id.clone(), Password::from(secret)); + let credentials = + SMTPCredentials::new(auth_id.clone(), Password::from(secret.clone())); let smtp = SMTP::new(credentials, host.clone(), mechanism.clone()); Self::new(smtp) } @@ -178,20 +251,5 @@ mod tests { 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/cot/src/email/transport.rs b/cot/src/email/transport.rs index 66df8317..8c3b04f0 100644 --- a/cot/src/email/transport.rs +++ b/cot/src/email/transport.rs @@ -1,26 +1,42 @@ use std::pin::Pin; +use cot::email::MessageBuildError; +use thiserror::Error; + use crate::email::EmailMessage; pub mod console; pub mod smtp; +const ERROR_PREFIX: &str = "email transport error:"; + +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum TransportError { + #[error("{ERROR_PREFIX} transport error: {0}")] + Transport(String), + #[error("{ERROR_PREFIX} message build error: {0}")] + MessageBuildError(#[from] MessageBuildError), +} + +pub type TransportResult = Result; + pub trait Transport: Send + Sync + 'static { - fn send(&self, messages: &[EmailMessage]) -> impl Future> + Send; + fn send(&self, messages: &[EmailMessage]) -> impl Future> + Send; } pub(crate) trait BoxedTransport: Send + Sync + 'static { fn send<'a>( &'a self, - messages: &[EmailMessage], - ) -> Pin> + Send + 'a>>; + messages: &'a [EmailMessage], + ) -> Pin> + Send + 'a>>; } impl BoxedTransport for T { fn send<'a>( &'a self, - messages: &[EmailMessage], - ) -> Pin> + Send + 'a>> { + messages: &'a [EmailMessage], + ) -> Pin> + Send + 'a>> { Box::pin(async move { T::send(self, messages).await }) } } diff --git a/cot/src/email/transport/console.rs b/cot/src/email/transport/console.rs index ef014df6..990997da 100644 --- a/cot/src/email/transport/console.rs +++ b/cot/src/email/transport/console.rs @@ -1,7 +1,26 @@ +use std::io::Write; +use std::{fmt, io}; + use cot::email::EmailMessage; -use lettre::Message; +use cot::email::transport::TransportError; +use thiserror::Error; + +use crate::email::transport::{Transport, TransportResult}; -use crate::email::transport::Transport; +const ERROR_PREFIX: &str = "console transport error:"; + +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum ConsoleError { + #[error("IO error: {0}")] + Io(#[from] io::Error), +} + +impl From for TransportError { + fn from(err: ConsoleError) -> Self { + TransportError::Transport(err.to_string()) + } +} #[derive(Debug, Clone)] pub struct Console; @@ -13,11 +32,88 @@ impl Console { } impl Transport for Console { - async fn send(&self, messages: &[EmailMessage]) -> Result<(), String> { - for message in messages { - let m: Message = message.clone().into(); - println!("{m:?}"); + async fn send(&self, messages: &[EmailMessage]) -> TransportResult<()> { + let mut out = io::stdout().lock(); + for (i, msg) in messages.iter().enumerate() { + writeln!(out, "{}", msg).map_err(|err| ConsoleError::Io(err))?; + writeln!(out, "{}", "─".repeat(60)).map_err(|err| ConsoleError::Io(err))?; + } + Ok(()) + } +} + +impl fmt::Display for EmailMessage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let fmt_list = |list: &Vec| -> String { + if list.is_empty() { + "-".to_string() + } else { + list.iter() + .map(|a| format!("{}", a.email())) + .collect::>() + .join(", ") + } + }; + + writeln!( + f, + "{}", + "════════════════════════════════════════════════════════════════" + )?; + writeln!(f, "From : {}", self.from.email())?; + writeln!(f, "To : {}", fmt_list(&self.to))?; + if !self.cc.is_empty() { + writeln!(f, "Cc : {}", fmt_list(&self.cc))?; + } + if !self.bcc.is_empty() { + writeln!(f, "Bcc : {}", fmt_list(&self.bcc))?; + } + if !self.reply_to.is_empty() { + writeln!(f, "Reply-To: {}", fmt_list(&self.reply_to))?; + } + writeln!( + f, + "Subject : {}", + if self.subject.is_empty() { + "-" + } else { + &self.subject + } + )?; + writeln!( + f, + "{}", + "────────────────────────────────────────────────────────" + )?; + if self.body.trim().is_empty() { + writeln!(f, "")?; + } else { + writeln!(f, "{}", self.body.trim_end())?; + } + writeln!( + f, + "{}", + "────────────────────────────────────────────────────────" + )?; + if self.attachments.is_empty() { + writeln!(f, "Attachments: -")?; + } else { + writeln!(f, "Attachments ({}):", self.attachments.len())?; + for a in &self.attachments { + writeln!( + f, + " - {} ({} bytes, {})", + a.filename, + a.data.len(), + a.content_type + )?; + } } + writeln!( + f, + "{}", + "════════════════════════════════════════════════════════════════" + )?; Ok(()) } } diff --git a/cot/src/email/transport/smtp.rs b/cot/src/email/transport/smtp.rs index 42c42c6d..d9c2263b 100644 --- a/cot/src/email/transport/smtp.rs +++ b/cot/src/email/transport/smtp.rs @@ -1,12 +1,31 @@ use cot::email::EmailMessage; use lettre::transport::smtp::authentication::Credentials; -use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor}; +use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; use serde::{Deserialize, Serialize}; +use thiserror::Error; use crate::common_types::Password; -use crate::email::transport::Transport; +use crate::email::transport::{Transport, TransportError, TransportResult}; + +const ERROR_PREFIX: &str = "smtp transport error:"; + +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum SMTPError { + #[error("{ERROR_PREFIX} IO error: {0}")] + Io(#[from] std::io::Error), + #[error("{ERROR_PREFIX} send error: {0}")] + SmtpSend(Box), +} + +impl From for TransportError { + fn from(err: SMTPError) -> Self { + TransportError::Transport(err.to_string()) + } +} #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] pub enum Mechanism { #[default] Plain, @@ -43,16 +62,17 @@ impl From for Credentials { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum SMTPHost { +#[serde(rename_all = "lowercase")] +pub enum SMTPServer { Gmail, Localhost, } -impl SMTPHost { +impl SMTPServer { pub fn as_str(&self) -> &str { match self { - SMTPHost::Gmail => "smtp.gmail.com", - SMTPHost::Localhost => "localhost", + SMTPServer::Gmail => "smtp.gmail.com", + SMTPServer::Localhost => "localhost", } } } @@ -60,12 +80,12 @@ impl SMTPHost { #[derive(Debug, Clone)] pub struct SMTP { credentials: SMTPCredentials, - host: SMTPHost, + host: SMTPServer, mechanism: Mechanism, } impl SMTP { - pub fn new(credentials: SMTPCredentials, host: SMTPHost, mechanism: Mechanism) -> Self { + pub fn new(credentials: SMTPCredentials, host: SMTPServer, mechanism: Mechanism) -> Self { Self { credentials, host, @@ -75,18 +95,21 @@ impl SMTP { } impl Transport for SMTP { - async fn send(&self, messages: &[EmailMessage]) -> Result<(), String> { + async fn send(&self, messages: &[EmailMessage]) -> TransportResult<()> { let mechanisms: Vec = vec![self.mechanism.clone().into()]; for message in messages { - let mailer = AsyncSmtpTransport::relay(self.host.as_str()) - .unwrap() + let m = Message::try_from(message.clone())?; + let mailer = AsyncSmtpTransport::::relay(self.host.as_str()) + .map_err(|err| SMTPError::SmtpSend(Box::new(err)))? .credentials(self.credentials.clone().into()) .authentication(mechanisms.clone()) - .build::(); - mailer.send(message.clone().into()).await.unwrap(); + .build(); + mailer + .send(m) + .await + .map_err(|err| SMTPError::SmtpSend(Box::new(err)))?; } - Ok(()) } } diff --git a/cot/src/project.rs b/cot/src/project.rs index c67d2ebb..441ad259 100644 --- a/cot/src/project.rs +++ b/cot/src/project.rs @@ -1609,7 +1609,7 @@ impl BootstrapPhase for WithApps { type ErrorHandler = (); type Config = ::Config; #[cfg(feature = "email")] - type Email = Arc; + type Email = ::Email; type Apps = Vec>; type Router = Arc; #[cfg(feature = "db")] @@ -1634,7 +1634,7 @@ impl BootstrapPhase for WithDatabase { type ErrorHandler = (); type Config = ::Config; #[cfg(feature = "email")] - type Email = Arc; + type Email = ::Email; type Apps = ::Apps; type Router = ::Router; #[cfg(feature = "db")] @@ -1659,7 +1659,7 @@ impl BootstrapPhase for WithCache { type ErrorHandler = (); type Config = ::Config; #[cfg(feature = "email")] - type Email = Arc; + type Email = ::Email; type Apps = ::Apps; type Router = ::Router; #[cfg(feature = "db")] @@ -1684,7 +1684,7 @@ impl BootstrapPhase for Initialized { type ErrorHandler = BoxedHandler; type Config = ::Config; #[cfg(feature = "email")] - type Email = Arc; + type Email = ::Email; type Apps = ::Apps; type Router = ::Router; #[cfg(feature = "db")] @@ -1730,7 +1730,7 @@ impl ProjectContext { } fn with_config(self, config: ProjectConfig) -> ProjectContext { - let email = Email::from_config(&config.email); + let email = Arc::new(Email::from_config(&config.email)); ProjectContext { config: Arc::new(config), @@ -1941,6 +1941,14 @@ impl>> ProjectContext { } } +#[cfg(feature = "email")] +impl>> ProjectContext { + #[must_use] + pub fn email(&self) -> &Arc { + &self.email + } +} + #[cfg(feature = "cache")] impl>> ProjectContext { /// Returns the cache for the project. From e8dc749d6560c536a2e4e1c461361db59c8c5c71 Mon Sep 17 00:00:00 2001 From: Elijah Date: Mon, 8 Dec 2025 17:49:19 +0000 Subject: [PATCH 26/77] improve the API a bit --- cot/src/config.rs | 37 ++++++++++++++++++++++++++++++ cot/src/email.rs | 10 +++++++- cot/src/email/transport/console.rs | 2 +- cot/src/email/transport/smtp.rs | 4 ++-- 4 files changed, 49 insertions(+), 4 deletions(-) diff --git a/cot/src/config.rs b/cot/src/config.rs index f0bd9761..620ada3c 100644 --- a/cot/src/config.rs +++ b/cot/src/config.rs @@ -1851,6 +1851,43 @@ pub struct EmailTransportConfig { pub transport_type: EmailTransportTypeConfig, } +#[cfg(feature = "email")] +impl EmailTransportConfig { + /// Create a new [`EmailTransportConfigBuilder`] to build a + /// [`EmailTransportConfig`]. + /// + /// # Examples + /// + /// ``` + /// use cot::config::EmailTransportConfig; + /// + /// let config = EmailTransportConfig::builder().build(); + /// ``` + #[must_use] + pub fn builder() -> EmailTransportConfigBuilder { + EmailTransportConfigBuilder::default() + } +} + +#[cfg(feature = "email")] +impl EmailTransportConfigBuilder { + /// Builds the email transport configuration. + /// + /// # Examples + /// + /// ``` + /// use cot::config::EmailTransportConfig; + /// + /// let config = EmailTransportConfig::builder().build(); + /// ``` + #[must_use] + pub fn build(&self) -> EmailTransportConfig { + EmailTransportConfig { + transport_type: self.transport_type.clone().unwrap_or_default(), + } + } +} + /// The configuration for the SMTP backend. /// /// This is used as part of the [`EmailConfig`] enum. diff --git a/cot/src/email.rs b/cot/src/email.rs index a12f849e..64c8e319 100644 --- a/cot/src/email.rs +++ b/cot/src/email.rs @@ -48,6 +48,7 @@ const ERROR_PREFIX: &str = "email error:"; /// Represents errors that can occur when sending an email. #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum EmailError { #[error("transport error: {0}")] TransportError(String), @@ -209,7 +210,14 @@ impl Email { let transport: Arc = Arc::new(transport); Self { transport } } - pub async fn send(&self, messages: &[EmailMessage]) -> EmailResult<()> { + pub async fn send(&self, message: EmailMessage) -> EmailResult<()> { + self.transport + .send(&[message]) + .await + .map_err(|err| EmailError::TransportError(err.to_string())) + } + + pub async fn send_multiple(&self, messages: &[EmailMessage]) -> EmailResult<()> { self.transport .send(messages) .await diff --git a/cot/src/email/transport/console.rs b/cot/src/email/transport/console.rs index 990997da..2afd1b81 100644 --- a/cot/src/email/transport/console.rs +++ b/cot/src/email/transport/console.rs @@ -34,7 +34,7 @@ impl Console { impl Transport for Console { async fn send(&self, messages: &[EmailMessage]) -> TransportResult<()> { let mut out = io::stdout().lock(); - for (i, msg) in messages.iter().enumerate() { + for msg in messages { writeln!(out, "{}", msg).map_err(|err| ConsoleError::Io(err))?; writeln!(out, "{}", "─".repeat(60)).map_err(|err| ConsoleError::Io(err))?; } diff --git a/cot/src/email/transport/smtp.rs b/cot/src/email/transport/smtp.rs index d9c2263b..3b590a1e 100644 --- a/cot/src/email/transport/smtp.rs +++ b/cot/src/email/transport/smtp.rs @@ -24,7 +24,7 @@ impl From for TransportError { } } -#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Mechanism { #[default] @@ -61,7 +61,7 @@ impl From for Credentials { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum SMTPServer { Gmail, From c70b70d27857eb31402a6cac6a43ce514293f23e Mon Sep 17 00:00:00 2001 From: Elijah Date: Tue, 9 Dec 2025 22:06:11 +0000 Subject: [PATCH 27/77] docs improv best efforts --- cot/src/config.rs | 94 ++++++++++++- cot/src/email.rs | 205 ++++++++++++++++++++++++----- cot/src/email/transport.rs | 21 ++- cot/src/email/transport/console.rs | 48 ++++++- cot/src/email/transport/smtp.rs | 99 +++++++++++++- cot/src/project.rs | 18 ++- 6 files changed, 436 insertions(+), 49 deletions(-) diff --git a/cot/src/config.rs b/cot/src/config.rs index 620ada3c..46c4a18c 100644 --- a/cot/src/config.rs +++ b/cot/src/config.rs @@ -1826,27 +1826,64 @@ impl Default for SessionMiddlewareConfig { } /// The type of email backend to use. +/// +/// This specifies what email backend is used for sending emails: `console` or +/// `smtp`. The default backend if not specified is `console`. #[cfg(feature = "email")] #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum EmailTransportTypeConfig { /// Console email transport. + /// + /// This is a convenient backend for development and testing that simply + /// prints the email contents to the console instead of actually sending + /// them. #[default] Console, /// SMTP email backend. + /// + /// This backend sends emails using the Simple Mail Transfer Protocol + /// (SMTP). It requires authentication details and server configuration. Smtp { + /// The authentication ID (username) for the SMTP server. + /// For `plain` and `xoauth2` mechanisms, this is typically the email + /// address. For `login` mechanism, this is the login username. auth_id: String, + /// The secret (password or token) for the SMTP server. + /// For `plain` and `login` mechanisms, this is typically the password. + /// For `xoauth2` this is the OAuth2 token. secret: String, + /// The authentication mechanism to use. + /// Supported mechanisms are `plain`, `login`, and `xoauth2`. mechanism: Mechanism, + /// The SMTP server configuration. + /// + /// This configures what SMTP server to connect to. The supported + /// servers are `gmail` and `localhost` server: SMTPServer, }, } +/// Configuration structure for email transport settings. +/// +/// This specifies the email transport backend to use and its associated +/// configuration. #[cfg(feature = "email")] #[derive(Debug, Default, Clone, PartialEq, Eq, Builder, Serialize, Deserialize)] #[builder(build_fn(skip, error = std::convert::Infallible))] #[serde(default)] pub struct EmailTransportConfig { + /// The type of email transport backend to use. + /// + /// # Examples + /// + /// ``` + /// use cot::config::{EmailTransportConfig, EmailTransportTypeConfig}; + /// + /// let config = EmailTransportConfig::builder() + /// .transport_type(EmailTransportTypeConfig::Console) + /// .build(); + /// ``` #[serde(flatten)] pub transport_type: EmailTransportTypeConfig, } @@ -1888,24 +1925,69 @@ impl EmailTransportConfigBuilder { } } -/// The configuration for the SMTP backend. +/// Configuration for the email system. /// -/// This is used as part of the [`EmailConfig`] enum. +/// This specifies all the configuration options for sending emails. /// /// # Examples /// /// ``` -/// use cot::config::EmailConfig; +/// use cot::config::{EmailConfig, EmailTransportConfig, EmailTransportTypeConfig}; /// -/// let config = EmailConfig::builder().build(); +/// let config = EmailConfig::builder() +/// .transport( +/// EmailTransportConfig::builder() +/// .transport_type(EmailTransportTypeConfig::Console) +/// .build(), +/// ) +/// .build(); +/// assert_eq!( +/// config.transport.transport_type, +/// EmailTransportTypeConfig::Console +/// ); /// ``` #[cfg(feature = "email")] #[derive(Debug, Clone, PartialEq, Eq, Builder, Serialize, Deserialize)] #[builder(build_fn(skip, error = std::convert::Infallible))] #[serde(default)] pub struct EmailConfig { - /// The type of email backend to use. - /// Defaults to `None`. + /// The type of email transport backend to use. + /// + /// This determines which type of email transport backend to use (`console` + /// or `smtp`) along with its configuration options. + /// + /// # Examples + /// + /// ``` + /// use cot::config::{EmailConfig, EmailTransportConfig, EmailTransportTypeConfig}; + /// + /// let config = EmailConfig::builder() + /// .transport( + /// EmailTransportConfig::builder() + /// .transport_type(EmailTransportTypeConfig::Console) + /// .build(), + /// ) + /// .build(); + /// assert_eq!( + /// config.transport.transport_type, + /// EmailTransportTypeConfig::Console + /// ); + /// ``` + /// + /// # TOML Configuration + /// + /// ```toml + /// [email] + /// type = "console" + /// + /// # Or for SMTP: + /// # [email] + /// # type = "smtp" + /// # auth_id = "your_auth_id" + /// # secret = "your_secret" + /// # mechanism = "plain" # or "login", "xoauth2" + /// # server = "gmail" # or "localhost" + /// ``` #[builder(default)] pub transport: EmailTransportConfig, } diff --git a/cot/src/email.rs b/cot/src/email.rs index 64c8e319..f9e23bd4 100644 --- a/cot/src/email.rs +++ b/cot/src/email.rs @@ -1,32 +1,31 @@ -//! Email sending functionality using SMTP and other backends +//! Email sending functionality for Cot. //! -//! #Examples -//! To send an email using the `EmailBackend`, you need to create an instance of -//! `SmtpConfig` -//! ``` -//! use cot::email::{EmailBackend, EmailMessage, SmtpConfig, SmtpEmailBackend}; -//! fn test_send_email_localhsot() { -//! // Create a test email -//! 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); -//! } +//! This module exposes a high-level `Email` API that can send +//! [`EmailMessage`] values through a chosen transport backend +//! (see `transport` submodule for available backends). +//! +//! # Examples +//! +//! Send using the console transport backend (prints nicely formatted messages): +//! +//! ```no_run +//! use cot::email::transport::console::Console; +//! use cot::email::{Email, EmailMessage}; +//! +//! # async fn run() -> cot::Result<()> { +//! let email = Email::new(Console::new()); +//! let message = EmailMessage::builder() +//! .from("no-reply@example.com".into()) +//! .to(vec!["user@example.com".into()]) +//! .subject("Greetings") +//! .body("Hello from cot!") +//! .build()?; +//! email.send(message).await?; +//! # Ok(()) } //! ``` pub mod transport; -use std::error::Error; use std::sync::Arc; use cot::config::{EmailConfig, EmailTransportTypeConfig}; @@ -35,7 +34,6 @@ use derive_builder::Builder; use derive_more::with_trait::Debug; use lettre::message::header::ContentType; use lettre::message::{Attachment, Body, Mailbox, Message, MultiPart, SinglePart}; -use serde::{Deserialize, Serialize}; use thiserror::Error; use transport::{BoxedTransport, Transport}; @@ -47,29 +45,40 @@ use crate::error::error_impl::impl_into_cot_error; const ERROR_PREFIX: &str = "email error:"; /// Represents errors that can occur when sending an email. -#[derive(Debug, thiserror::Error)] +#[derive(Debug, Error)] #[non_exhaustive] pub enum EmailError { - #[error("transport error: {0}")] + /// An error occurred in the transport layer while sending the email. + #[error("{ERROR_PREFIX} transport error: {0}")] TransportError(String), /// An error occurred while building the email message. - #[error("message error: {0}")] + #[error("{ERROR_PREFIX} message error: {0}")] MessageError(String), /// A required field is missing in the email message. - #[error("missing required field: {0}")] + #[error("{ERROR_PREFIX} missing required field: {0}")] MissingField(String), } impl_into_cot_error!(EmailError); + +/// A convenience alias for results returned by email operations. pub type EmailResult = Result; +/// Raw attachment data to be embedded into an email. #[derive(Debug, Clone)] pub struct AttachmentData { + /// The filename to display for the attachment. filename: String, + /// The MIME content type of the attachment (e.g., `image/png`). content_type: String, + /// The raw bytes of the attachment. data: Vec, } +/// A high-level email message representation. +/// +/// This struct encapsulates the components of an email, including +/// subject, body, sender, recipients, and attachments. #[derive(Debug, Clone, Builder)] #[builder(build_fn(skip))] pub struct EmailMessage { @@ -84,6 +93,21 @@ pub struct EmailMessage { } impl EmailMessage { + /// Create a new builder for constructing an `EmailMessage`. + /// + /// # Examples + /// + /// ``` + /// use cot::email::EmailMessage; + /// + /// let message = EmailMessage::builder() + /// .from("no-reply@example.com".into()) + /// .to(vec!["user@example.com".into()]) + /// .subject("Greetings") + /// .body("Hello from cot!") + /// .build() + /// .unwrap(); + /// ``` #[must_use] pub fn builder() -> EmailMessageBuilder { EmailMessageBuilder::default() @@ -91,6 +115,25 @@ impl EmailMessage { } impl EmailMessageBuilder { + /// Build the `EmailMessage`, ensuring required fields are set. + /// + /// # Errors + /// + /// This method returns an `EmailError` if required fields are missing. + /// + /// # Examples + /// + /// ``` + /// use cot::email::EmailMessage; + /// + /// let message = EmailMessage::builder() + /// .from("no-reply@example.com".into()) + /// .to(vec!["user@example.com".into()]) + /// .subject("Greetings") + /// .body("Hello from cot!") + /// .build() + /// .unwrap(); + /// ``` pub fn build(&self) -> Result { let from = self .from @@ -119,11 +162,14 @@ impl EmailMessageBuilder { } } +/// Errors that can occur while building an email message. #[derive(Debug, Clone, Error)] #[non_exhaustive] pub enum MessageBuildError { + /// An invalid email address was provided. #[error("invalid email address: {0}")] InvalidEmailAddress(String), + /// Failed to build the email message. #[error("failed to build email message: {0}")] BuildError(String), } @@ -199,6 +245,30 @@ impl TryFrom for Message { } } +/// A high level email interface for sending emails. +/// +/// This struct wraps a [`Transport`] implementation to provide +/// methods for sending single or multiple email messages. +/// +/// # Examples +/// +/// ```no_run +/// use cot::email::{Email, EmailMessage}; +/// use cot::email::transport::console::Console; +/// +/// # #[tokio::main] +/// # async fn main() -> cot::Result<()> { +/// let email = Email::new(Console::new()); +/// let message = EmailMessage::builder() +/// .from("no-reply@example.com".into()) +/// .to(vec!["user@example.com".into()]) +/// .subject("Greetings") +/// .body("Hello from cot!") +/// .build()?; +/// email.send(message).await?; +/// # Ok(()) +/// } +/// ``` #[derive(Debug, Clone)] pub struct Email { #[debug("..")] @@ -206,10 +276,41 @@ pub struct Email { } impl Email { + /// Create a new email sender using the given transport implementation. + /// + /// # Examples + /// + /// ``` + /// use cot::email::transport::console::Console; + /// use cot::email::{Email, EmailMessage}; + /// + /// let email = Email::new(Console::new()); + /// ``` pub fn new(transport: impl Transport) -> Self { let transport: Arc = Arc::new(transport); Self { transport } } + /// Send a single [`EmailMessage`] + /// + /// # Examples + /// + /// ```no_run + /// use cot::email::{Email, EmailMessage}; + /// use cot::email::transport::console::Console; + /// + /// # #[tokio::main] + /// # async fn main() -> cot::Result<()> { + /// let email = Email::new(Console::new()); + /// let message = EmailMessage::builder() + /// .from("no-reply@example.com".into()) + /// .to(vec!["user@example.com".into()]) + /// .subject("Greetings") + /// .body("Hello from cot!") + /// .build()? + /// email.send(message).await?; + /// # Ok(()) + /// } + /// ``` pub async fn send(&self, message: EmailMessage) -> EmailResult<()> { self.transport .send(&[message]) @@ -217,6 +318,34 @@ impl Email { .map_err(|err| EmailError::TransportError(err.to_string())) } + /// Send multiple emails in sequence. + /// + /// # Examples + /// + /// ```no_run + /// use cot::email::{Email, EmailMessage}; + /// use cot::email::transport::console::Console; + /// + /// # #[tokio::main] + /// # async fn main() -> cot::Result<()> { + /// let email = Email::new(Console::new()); + /// let message1 = EmailMessage::builder() + /// .from("no-reply@email.com".into()) + /// .to(vec!["user1@example.com".into()]) + /// .subject("Hello User 1") + /// .body("This is the first email.") + /// .build()?; + /// + /// let message2 = EmailMessage::builder() + /// .from("no-reply@email.com".into()) + /// .to(vec!["user2@example.com".into()]) + /// .subject("Hello User 2") + /// .body("This is the second email.") + /// .build()?; + /// email.send_multiple(&[message1, message2]).await?; + /// # Ok(()) + /// } + /// ``` pub async fn send_multiple(&self, messages: &[EmailMessage]) -> EmailResult<()> { self.transport .send(messages) @@ -224,6 +353,21 @@ impl Email { .map_err(|err| EmailError::TransportError(err.to_string())) } + /// Construct an [`Email`] from the provided [`EmailConfig`]. + /// + /// # Examples + /// + /// ``` + /// use cot::config::{EmailConfig, EmailTransportTypeConfig}; + /// use cot::email::Email; + /// use cot::email::transport::console::Console; + /// + /// let config = EmailConfig { + /// transport: EmailTransportTypeConfig::Console, + /// ..Default::default() + /// }; + /// let email = Email::from_config(&config); + /// ``` pub fn from_config(config: &EmailConfig) -> Self { let transport = &config.transport; @@ -240,8 +384,7 @@ impl Email { mechanism, server: host, } => { - let credentials = - SMTPCredentials::new(auth_id.clone(), Password::from(secret.clone())); + let credentials = SMTPCredentials::new(auth_id, Password::from(secret.clone())); let smtp = SMTP::new(credentials, host.clone(), mechanism.clone()); Self::new(smtp) } diff --git a/cot/src/email/transport.rs b/cot/src/email/transport.rs index 8c3b04f0..7f47fa5b 100644 --- a/cot/src/email/transport.rs +++ b/cot/src/email/transport.rs @@ -1,3 +1,8 @@ +//! This module defines the email transport system for sending emails in Cot. +//! +//! It provides a `Transport` trait that can be implemented by different email +//! backends (e.g., SMTP, console). The module also defines error handling for +//! transport operations. use std::pin::Pin; use cot::email::MessageBuildError; @@ -10,18 +15,32 @@ pub mod smtp; const ERROR_PREFIX: &str = "email transport error:"; +/// Errors that can occur while sending an email using a transport backend. #[derive(Debug, Error)] #[non_exhaustive] pub enum TransportError { + /// The underlying transport backend returned an error. #[error("{ERROR_PREFIX} transport error: {0}")] - Transport(String), + Backend(String), + /// Failed to build the email message. #[error("{ERROR_PREFIX} message build error: {0}")] MessageBuildError(#[from] MessageBuildError), } +/// A Convenience alias for results returned by transport operations. pub type TransportResult = Result; +/// A generic asynchronous email transport interface. +/// +/// The `Transport` trait abstracts over different email transport backends. It +/// provides methods to manage sending email messages asynchronously. pub trait Transport: Send + Sync + 'static { + /// Send one or more email messages. + /// + /// # Errors + /// + /// This method can return an error if there is an issue sending the + /// messages. fn send(&self, messages: &[EmailMessage]) -> impl Future> + Send; } diff --git a/cot/src/email/transport/console.rs b/cot/src/email/transport/console.rs index 2afd1b81..00317465 100644 --- a/cot/src/email/transport/console.rs +++ b/cot/src/email/transport/console.rs @@ -1,3 +1,25 @@ +//! Console transport implementation. +//! +//! This backend writes a human-friendly representation of emails to stdout. +//! It is intended primarily for development and testing environments where +//! actually sending email is not required. +//! +//! Typical usage is through the high-level [`crate::email::Email`] API: +//! +//! ```no_run +//! use cot::email::transport::console::Console; +//! use cot::email::{Email, EmailMessage}; +//! +//! # async fn run() -> Result<(), Box> { +//! let email = Email::new(Console::new()); +//! let recipients = vec!["testreceipient@example.com".into()]; +//! let msg = EmailMessage::builder() +//! .from("no-reply@example.com".into()) +//! .to() +//! .build()?; +//! email.send(msg).await?; +//! # Ok(()) } +//! ``` use std::io::Write; use std::{fmt, io}; @@ -9,23 +31,43 @@ use crate::email::transport::{Transport, TransportResult}; const ERROR_PREFIX: &str = "console transport error:"; +/// Errors that can occur while using the console transport. #[derive(Debug, Error)] #[non_exhaustive] pub enum ConsoleError { - #[error("IO error: {0}")] + /// An IO error occurred while writing to stdout. + #[error("{ERROR_PREFIX} IO error: {0}")] Io(#[from] io::Error), } impl From for TransportError { fn from(err: ConsoleError) -> Self { - TransportError::Transport(err.to_string()) + TransportError::Backend(err.to_string()) } } -#[derive(Debug, Clone)] +/// A transport backend that prints emails to stdout. +/// +/// # Examples +/// +/// ``` +/// use cot::email::transport::console::Console; +/// +/// let console_transport = Console::new(); +/// ``` +#[derive(Debug, Clone, Copy)] pub struct Console; impl Console { + /// Create a new console transport backend. + /// + /// # Examples + /// + /// ``` + /// use cot::email::transport::console::Console; + /// + /// let console_transport = Console::new(); + /// ``` pub fn new() -> Self { Self {} } diff --git a/cot/src/email/transport/smtp.rs b/cot/src/email/transport/smtp.rs index 3b590a1e..f97838dd 100644 --- a/cot/src/email/transport/smtp.rs +++ b/cot/src/email/transport/smtp.rs @@ -1,3 +1,28 @@ +//! SMTP transport implementation. +//! +//! This backend uses the `lettre` crate to send messages to a remote SMTP +//! server. Credentials, server host and authentication mechanism are +//! configurable. +//! +//! Typical usage is through the high-level [`crate::email::Email`] API: +//! +//! ```no_run +//! use cot::common_types::Password; +//! use cot::email::transport::smtp::{Mechanism, SMTP, SMTPCredentials, SMTPServer}; +//! use cot::email::{Email, EmailMessage}; +//! +//! # async fn run() -> Result<(), Box> { +//! let creds = SMTPCredentials::new("user@example.com", Password::from("secret")); +//! let smtp = SMTP::new(creds, SMTPServer::Gmail, Mechanism::Plain); +//! let email = Email::new(smtp); +//! let msg = EmailMessage::builder() +//! .from("user@example.com".into()) +//! .to(vec!["user2@example.com".into()]) +//! .body("This is a test email.".into()) +//! .build()?; +//! email.send(msg).await?; +//! # Ok(()) } +//! ``` use cot::email::EmailMessage; use lettre::transport::smtp::authentication::Credentials; use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; @@ -9,27 +34,39 @@ use crate::email::transport::{Transport, TransportError, TransportResult}; const ERROR_PREFIX: &str = "smtp transport error:"; +/// Errors produced by the SMTP transport. #[derive(Debug, Error)] #[non_exhaustive] pub enum SMTPError { + /// An IO error occurred. #[error("{ERROR_PREFIX} IO error: {0}")] Io(#[from] std::io::Error), + /// An error occurred while sending the email via SMTP. #[error("{ERROR_PREFIX} send error: {0}")] SmtpSend(Box), } impl From for TransportError { fn from(err: SMTPError) -> Self { - TransportError::Transport(err.to_string()) + TransportError::Backend(err.to_string()) } } +/// Supported SMTP authentication mechanisms. +/// +/// The default is `Plain`. #[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Mechanism { + /// PLAIN authentication mechanism defined in [RFC 4616](https://tools.ietf.org/html/rfc4616) + /// This is the default authentication mechanism. #[default] Plain, + /// LOGIN authentication mechanism defined in + /// [draft-murchison-sasl-login-00](https://www.ietf.org/archive/id/draft-murchison-sasl-login-00.txt). Login, + /// Non-standard XOAUTH2 mechanism defined in + /// [xoauth2-protocol](https://developers.google.com/gmail/imap/xoauth2-protocol) Xoauth2, } @@ -43,32 +80,50 @@ impl From for lettre::transport::smtp::authentication::Mechanism { } } +/// Credentials used to authenticate to an SMTP server. #[derive(Debug, Clone)] pub struct SMTPCredentials { - username: String, - password: Password, + auth_id: String, + secret: Password, } impl SMTPCredentials { - pub fn new(username: String, password: Password) -> Self { - Self { username, password } + /// Create a new set of credentials. + /// + /// # Examples + /// + /// ``` + /// use cot::common_types::Password; + /// use cot::email::transport::smtp::SMTPCredentials; + /// + /// let creds = SMTPCredentials::new("testuser", Password::from("secret")); + /// ``` + pub fn new>(username: S, password: Password) -> Self { + Self { + auth_id: username.into(), + secret: password, + } } } impl From for Credentials { fn from(credentials: SMTPCredentials) -> Self { - Credentials::new(credentials.username, credentials.password.into_string()) + Credentials::new(credentials.auth_id, credentials.secret.into_string()) } } +/// The SMTP host/server to connect to. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum SMTPServer { + /// Google's SMTP server. Gmail, + /// Localhost SMTP server. Localhost, } impl SMTPServer { + /// Returns the hostname for the server. pub fn as_str(&self) -> &str { match self { SMTPServer::Gmail => "smtp.gmail.com", @@ -77,6 +132,27 @@ impl SMTPServer { } } +/// SMTP transport backend that sends emails via a remote SMTP server. +/// +/// # Examples +/// +/// ```no_run +/// use cot::email::{Email, EmailMessage}; +/// use cot::email::transport::smtp::{SMTP, SMTPCredentials, SMTPServer, Mechanism}; +/// use cot::common_types::Password; +/// +/// # async fn run() -> cot::Result<()> { +/// let creds = SMTPCredentials::new("username", Password::from("password")); +/// let smtp = SMTP::new(creds, SMTPServer::Gmail, Mechanism::Plain); +/// let email = Email::new(smtp); +/// let recipients = vec!["testreceipient@example.com".into()]; +/// let msg = EmailMessage::builder() +/// .from("testfrom@example.com".into()) +/// .to(recipients) +/// .body("This is a test email.".into()) +/// .build()?; +/// email.send(msg).await?; +/// # Ok(()) } #[derive(Debug, Clone)] pub struct SMTP { credentials: SMTPCredentials, @@ -85,6 +161,17 @@ pub struct SMTP { } impl SMTP { + /// Create a new SMTP transport backend. + /// + /// # Examples + /// + /// ``` + /// use cot::common_types::Password; + /// use cot::email::transport::smtp::{Mechanism, SMTP, SMTPCredentials, SMTPServer}; + /// + /// let creds = SMTPCredentials::new("username", Password::from("password")); + /// let smtp = SMTP::new(creds, SMTPServer::Gmail, Mechanism::Plain); + /// ``` pub fn new(credentials: SMTPCredentials, host: SMTPServer, mechanism: Mechanism) -> Self { Self { credentials, diff --git a/cot/src/project.rs b/cot/src/project.rs index 441ad259..f3bbb5e6 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, Mutex}; +use std::sync::Arc; use askama::Template; use async_trait::async_trait; @@ -46,7 +46,7 @@ use crate::cli::Cli; use crate::config::CacheConfig; #[cfg(feature = "db")] use crate::config::DatabaseConfig; -use crate::config::{AuthBackendConfig, EmailConfig, EmailTransportTypeConfig, ProjectConfig}; +use crate::config::{AuthBackendConfig, ProjectConfig}; #[cfg(feature = "db")] use crate::db::Database; #[cfg(feature = "db")] @@ -1944,6 +1944,20 @@ impl>> ProjectContext { #[cfg(feature = "email")] impl>> ProjectContext { #[must_use] + /// Returns the email service for the project. + /// + /// # Examples + /// + /// ``` + /// use cot::request::{Request, RequestExt}; + /// use cot::response::Response; + /// + /// async fn index(request: Request) -> cot::Result { + /// let email = request.context().email(); + /// // ... + /// # unimplemented!() + /// } + /// ``` pub fn email(&self) -> &Arc { &self.email } From a7afb3709870b4777937cac6d6a095f0742f072c Mon Sep 17 00:00:00 2001 From: Elijah Date: Wed, 10 Dec 2025 22:37:07 +0000 Subject: [PATCH 28/77] initial tests --- Cargo.lock | 20 ---- Cargo.toml | 2 +- cot/src/config.rs | 157 ++++++++++++++++++++++++++--- cot/src/email.rs | 140 +++++++++++++++++++++---- cot/src/email/transport/console.rs | 30 ++++++ cot/src/email/transport/smtp.rs | 135 +++++++++++-------------- cot/src/project.rs | 9 +- cot/src/test.rs | 13 ++- 8 files changed, 372 insertions(+), 134 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4720ad48..6912ae69 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1891,17 +1891,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "hostname" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" -dependencies = [ - "cfg-if", - "libc", - "windows-link", -] - [[package]] name = "http" version = "0.2.12" @@ -2368,7 +2357,6 @@ dependencies = [ "fastrand", "futures-io", "futures-util", - "hostname", "httpdate", "idna", "mime", @@ -3447,14 +3435,6 @@ dependencies = [ "libc", ] -[[package]] -name = "send-email" -version = "0.1.0" -dependencies = [ - "cot", - "lettre", -] - [[package]] name = "serde" version = "1.0.228" diff --git a/Cargo.toml b/Cargo.toml index bc8b1395..72f43fb7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ # Examples "examples/admin", "examples/custom-error-pages", - "examples/send-email", +# "examples/send-email", "examples/custom-task", "examples/file-upload", "examples/hello-world", diff --git a/cot/src/config.rs b/cot/src/config.rs index 46c4a18c..d74b24e2 100644 --- a/cot/src/config.rs +++ b/cot/src/config.rs @@ -25,9 +25,8 @@ use serde::{Deserialize, Serialize}; use subtle::ConstantTimeEq; use thiserror::Error; -use crate::email::transport::smtp::Mechanism; #[cfg(feature = "email")] -use crate::email::transport::smtp::SMTPServer; +use crate::email::transport::smtp::Mechanism; use crate::error::error_impl::impl_into_cot_error; use crate::utils::chrono::DateTimeWithOffsetAdapter; @@ -270,6 +269,7 @@ pub struct ProjectConfig { /// assert_eq!(config.email, EmailConfig::default()); /// # Ok::<(), cot::Error>(()) /// ``` + #[cfg(feature = "email")] pub email: EmailConfig, } @@ -1845,22 +1845,56 @@ pub enum EmailTransportTypeConfig { /// This backend sends emails using the Simple Mail Transfer Protocol /// (SMTP). It requires authentication details and server configuration. Smtp { - /// The authentication ID (username) for the SMTP server. - /// For `plain` and `xoauth2` mechanisms, this is typically the email - /// address. For `login` mechanism, this is the login username. - auth_id: String, - /// The secret (password or token) for the SMTP server. - /// For `plain` and `login` mechanisms, this is typically the password. - /// For `xoauth2` this is the OAuth2 token. - secret: String, + /// The SMTP connection URL. + /// + /// This specifies the protocol, credentials, host, port, and EHLO + /// domain for connecting to the SMTP server. + /// + /// The URL format is: + /// `scheme://user:password@host:port/?ehlo_domain=domain?tls=TLS`. + /// + /// `user`(username) and `password` are optional in the case the + /// server does not require authentication. + /// When `port` is not specified, it is automatically determined based + /// on the `scheme` used. + /// `tls` is used to specify whether STARTTLS should be used for the + /// connection. Supported values for `tls` are: + /// - `required`: Always use STARTTLS. The connection will fail if the + /// server does not support it. + /// - `opportunistic`: Use STARTTLS if the server supports it, otherwise + /// fall back to plain connection. + /// + /// # Examples + /// + /// ``` + /// use cot::config::EmailTransportTypeConfig; + /// + /// let smtp_config = EmailTransportTypeConfig::Smtp { + /// url: Email::from("smtps://username:password@smtp.gmail.com?tls=required"), + /// mechanism: Mechanism::Plain, + /// }; + /// ``` + /// + /// # TOML Configuration + /// + /// ```toml + /// [email] + /// type = "smtp" + /// url = "smtps://username:password@smtp.gmail.com?tls=required" + /// ``` + url: EmailUrl, /// The authentication mechanism to use. /// Supported mechanisms are `plain`, `login`, and `xoauth2`. - mechanism: Mechanism, - /// The SMTP server configuration. /// - /// This configures what SMTP server to connect to. The supported - /// servers are `gmail` and `localhost` - server: SMTPServer, + /// # TOML Configuration + /// + /// ```toml + /// [email] + /// type = "smtp" + /// url = "smtps://username:password@smtp.gmail.com?tls=required" + /// mechanism = "plain" # or "login", "xoauth2" + /// ``` + mechanism: Mechanism, }, } @@ -2374,6 +2408,54 @@ impl std::fmt::Display for CacheUrl { } } +/// A URL for email services. +/// +/// This is a wrapper over the [`url::Url`] type, which is used to store the +/// URL of an email service. It parses the URL and ensures that it is valid. +/// +/// # Examples +/// +/// ``` +/// use cot::config::EmailUrl; +/// let url = EmailUrl::from("smtp://user:pass@hostname:587"); +/// ``` +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +#[cfg(feature = "email")] +pub struct EmailUrl(url::Url); + +#[cfg(feature = "email")] +impl EmailUrl { + /// Returns the string representation of the email URL. + /// + /// # Examples + /// + /// ``` + /// use cot::config::EmailUrl; + /// + /// let url = EmailUrl::from("smtp://user:pass@hostname:587"); + /// assert_eq!(url.as_str(), "smtp://user:pass@hostname:587"); + /// ``` + #[must_use] + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +#[cfg(feature = "email")] +impl From for EmailUrl { + fn from(url: String) -> Self { + Self(url::Url::parse(&url).expect("valid URL")) + } +} + +#[cfg(feature = "email")] +impl From<&str> for EmailUrl { + fn from(url: &str) -> Self { + Self(url::Url::parse(url).expect("valid URL")) + } +} + #[cfg(test)] mod tests { use time::OffsetDateTime; @@ -2979,4 +3061,49 @@ mod tests { let never = Timeout::Never; assert_eq!(never.canonicalize(), Timeout::Never); } + + #[test] + #[cfg(feature = "email")] + fn email_config_from_toml_console() { + let toml_content = r#" + [email] + type = "console" + "#; + + let config = ProjectConfig::from_toml(toml_content).unwrap(); + + assert_eq!( + config.email.transport.transport_type, + EmailTransportTypeConfig::Console + ); + } + + #[test] + #[cfg(feature = "email")] + fn email_config_from_toml_smtp() { + let toml_content = r#" + [email] + type = "smtp" + url = "smtp://user:pass@hostname:587" + mechanism = "plain" + "#; + let config = ProjectConfig::from_toml(toml_content).unwrap(); + + if let EmailTransportTypeConfig::Smtp { url, mechanism } = + &config.email.transport.transport_type + { + assert_eq!(url.as_str(), "smtp://user:pass@hostname:587"); + assert_eq!(*mechanism, Mechanism::Plain); + } + } + + #[test] + #[cfg(feature = "email")] + fn email_config_builder_defaults() { + let config = EmailConfig::builder().build(); + assert_eq!( + config.transport.transport_type, + EmailTransportTypeConfig::Console + ); + } } diff --git a/cot/src/email.rs b/cot/src/email.rs index f9e23bd4..928880f2 100644 --- a/cot/src/email.rs +++ b/cot/src/email.rs @@ -37,9 +37,8 @@ use lettre::message::{Attachment, Body, Mailbox, Message, MultiPart, SinglePart} use thiserror::Error; use transport::{BoxedTransport, Transport}; -use crate::common_types::Password; +use crate::email::transport::TransportError; use crate::email::transport::console::Console; -use crate::email::transport::smtp::SMTPCredentials; use crate::error::error_impl::impl_into_cot_error; const ERROR_PREFIX: &str = "email error:"; @@ -49,11 +48,11 @@ const ERROR_PREFIX: &str = "email error:"; #[non_exhaustive] pub enum EmailError { /// An error occurred in the transport layer while sending the email. - #[error("{ERROR_PREFIX} transport error: {0}")] - TransportError(String), + #[error(transparent)] + Transport(TransportError), /// An error occurred while building the email message. #[error("{ERROR_PREFIX} message error: {0}")] - MessageError(String), + Message(String), /// A required field is missing in the email message. #[error("{ERROR_PREFIX} missing required field: {0}")] MissingField(String), @@ -82,13 +81,23 @@ pub struct AttachmentData { #[derive(Debug, Clone, Builder)] #[builder(build_fn(skip))] pub struct EmailMessage { + /// The subject of the email. + #[builder(setter(into))] subject: String, + /// The body content of the email. + #[builder(setter(into))] body: String, + /// The sender's email address. from: crate::common_types::Email, + /// The primary recipients of the email. to: Vec, + /// The carbon copy (CC) recipients of the email. cc: Vec, + /// The blind carbon copy (BCC) recipients of the email. bcc: Vec, + /// The reply-to addresses for the email. reply_to: Vec, + /// Attachments to include with the email. attachments: Vec, } @@ -315,7 +324,7 @@ impl Email { self.transport .send(&[message]) .await - .map_err(|err| EmailError::TransportError(err.to_string())) + .map_err(EmailError::Transport) } /// Send multiple emails in sequence. @@ -350,7 +359,7 @@ impl Email { self.transport .send(messages) .await - .map_err(|err| EmailError::TransportError(err.to_string())) + .map_err(EmailError::Transport) } /// Construct an [`Email`] from the provided [`EmailConfig`]. @@ -368,7 +377,7 @@ impl Email { /// }; /// let email = Email::from_config(&config); /// ``` - pub fn from_config(config: &EmailConfig) -> Self { + pub fn from_config(config: &EmailConfig) -> EmailResult { let transport = &config.transport; let this = { @@ -378,29 +387,118 @@ impl Email { Self::new(console) } - EmailTransportTypeConfig::Smtp { - auth_id, - secret, - mechanism, - server: host, - } => { - let credentials = SMTPCredentials::new(auth_id, Password::from(secret.clone())); - let smtp = SMTP::new(credentials, host.clone(), mechanism.clone()); + EmailTransportTypeConfig::Smtp { url, mechanism } => { + let smtp = SMTP::new(url, mechanism.clone()).map_err(EmailError::Transport)?; Self::new(smtp) } } }; - this + Ok(this) } } #[cfg(test)] mod tests { use super::*; + use crate::config::EmailUrl; + + #[cot::test] + async fn builder_errors_when_from_missing() { + let res = EmailMessage::builder() + .subject("Hello".to_string()) + .body("World".to_string()) + .build(); + assert!(res.is_err()); + let err = res.err().unwrap(); + assert_eq!(err.to_string(), "email error: missing required field: from"); + } + + #[cot::test] + async fn builder_defaults_when_only_from_set() { + let msg = EmailMessage::builder() + .from(crate::common_types::Email::new("sender@example.com").unwrap()) + .build() + .expect("should build with defaults"); + assert_eq!(msg.subject, ""); + assert_eq!(msg.body, ""); + assert!(msg.to.is_empty()); + assert!(msg.cc.is_empty()); + assert!(msg.bcc.is_empty()); + assert!(msg.reply_to.is_empty()); + assert!(msg.attachments.is_empty()); + } + + #[cot::test] + async fn from_config_console_builds() { + use crate::config::{EmailConfig, EmailTransportTypeConfig}; + let cfg = EmailConfig { + transport: crate::config::EmailTransportConfig { + transport_type: EmailTransportTypeConfig::Console, + }, + ..Default::default() + }; + let _email = Email::from_config(&cfg); + // We can't introspect the inner transport, but construction should not + // panic. + } + + #[cot::test] + async fn from_config_smtp_builds() { + use crate::config::{EmailConfig, EmailTransportTypeConfig}; + use crate::email::transport::smtp::Mechanism; + + let cfg = EmailConfig { + transport: crate::config::EmailTransportConfig { + transport_type: EmailTransportTypeConfig::Smtp { + url: EmailUrl::from("smtp://localhost:1025"), + mechanism: Mechanism::Plain, + }, + }, + ..Default::default() + }; + let _email = Email::from_config(&cfg); + } + + #[cot::test] + async fn email_send_console() { + let console = Console::new(); + let email = Email::new(console); + let msg = EmailMessage::builder() + .from(crate::common_types::Email::new("user@example.com").unwrap()) + .to(vec![ + crate::common_types::Email::new("recipient@example.com").unwrap(), + ]) + .subject("Test Email".to_string()) + .body("This is a test email body.".to_string()) + .build() + .unwrap(); + + assert!(email.send(msg).await.is_ok()) + } - #[test] - fn test_email_error_variants() { - let message_error = EmailError::MessageError("Invalid message".to_string()); - assert_eq!(format!("{message_error}"), "Message error: Invalid message"); + #[cot::test] + async fn email_send_multiple_console() { + let console = Console::new(); + let email = Email::new(console); + let msg1 = EmailMessage::builder() + .from(crate::common_types::Email::new("user1@example.com").unwrap()) + .to(vec![ + crate::common_types::Email::new("recipient@example.com").unwrap(), + ]) + .subject("Test Email") + .body("This is a test email body.") + .build() + .unwrap(); + + let msg2 = EmailMessage::builder() + .from(crate::common_types::Email::new("user2@example.com").unwrap()) + .to(vec![ + crate::common_types::Email::new("user2@example.com").unwrap(), + ]) + .subject("Another Test Email") + .body("This is another test email body.") + .build() + .unwrap(); + assert!(email.send_multiple(&[msg1, msg2]).await.is_ok()); } } diff --git a/cot/src/email/transport/console.rs b/cot/src/email/transport/console.rs index 00317465..cffe3529 100644 --- a/cot/src/email/transport/console.rs +++ b/cot/src/email/transport/console.rs @@ -159,3 +159,33 @@ impl fmt::Display for EmailMessage { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_formats_minimal_message() { + let msg = EmailMessage::builder() + .from(crate::common_types::Email::new("sender@example.com").unwrap()) + .build() + .unwrap(); + let s = format!("{}", msg); + assert!(s.contains("From : sender@example.com")); + assert!(s.contains("To : -")); + assert!(s.contains("Subject : -")); + assert!(s.contains("")); + assert!(s.contains("Attachments: -")); + } + + #[cot::test] + async fn console_error_to_transport_error() { + let console_error = ConsoleError::Io(io::Error::new(io::ErrorKind::Other, "test error")); + let transport_error: TransportError = console_error.into(); + + assert_eq!( + transport_error.to_string(), + "email transport error: transport error: console transport error: IO error: test error" + ) + } +} diff --git a/cot/src/email/transport/smtp.rs b/cot/src/email/transport/smtp.rs index f97838dd..2a74a1d7 100644 --- a/cot/src/email/transport/smtp.rs +++ b/cot/src/email/transport/smtp.rs @@ -23,13 +23,13 @@ //! email.send(msg).await?; //! # Ok(()) } //! ``` +use cot::config::EmailUrl; use cot::email::EmailMessage; -use lettre::transport::smtp::authentication::Credentials; +use lettre::transport::smtp; use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; use serde::{Deserialize, Serialize}; use thiserror::Error; -use crate::common_types::Password; use crate::email::transport::{Transport, TransportError, TransportResult}; const ERROR_PREFIX: &str = "smtp transport error:"; @@ -44,6 +44,9 @@ pub enum SMTPError { /// An error occurred while sending the email via SMTP. #[error("{ERROR_PREFIX} send error: {0}")] SmtpSend(Box), + /// An error occured while creating the transport. + #[error("{ERROR_PREFIX} transport creation error: {0}")] + TransportCreation(Box), } impl From for TransportError { @@ -70,64 +73,12 @@ pub enum Mechanism { Xoauth2, } -impl From for lettre::transport::smtp::authentication::Mechanism { +impl From for smtp::authentication::Mechanism { fn from(mechanism: Mechanism) -> Self { match mechanism { - Mechanism::Plain => lettre::transport::smtp::authentication::Mechanism::Plain, - Mechanism::Login => lettre::transport::smtp::authentication::Mechanism::Login, - Mechanism::Xoauth2 => lettre::transport::smtp::authentication::Mechanism::Xoauth2, - } - } -} - -/// Credentials used to authenticate to an SMTP server. -#[derive(Debug, Clone)] -pub struct SMTPCredentials { - auth_id: String, - secret: Password, -} - -impl SMTPCredentials { - /// Create a new set of credentials. - /// - /// # Examples - /// - /// ``` - /// use cot::common_types::Password; - /// use cot::email::transport::smtp::SMTPCredentials; - /// - /// let creds = SMTPCredentials::new("testuser", Password::from("secret")); - /// ``` - pub fn new>(username: S, password: Password) -> Self { - Self { - auth_id: username.into(), - secret: password, - } - } -} - -impl From for Credentials { - fn from(credentials: SMTPCredentials) -> Self { - Credentials::new(credentials.auth_id, credentials.secret.into_string()) - } -} - -/// The SMTP host/server to connect to. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum SMTPServer { - /// Google's SMTP server. - Gmail, - /// Localhost SMTP server. - Localhost, -} - -impl SMTPServer { - /// Returns the hostname for the server. - pub fn as_str(&self) -> &str { - match self { - SMTPServer::Gmail => "smtp.gmail.com", - SMTPServer::Localhost => "localhost", + Mechanism::Plain => smtp::authentication::Mechanism::Plain, + Mechanism::Login => smtp::authentication::Mechanism::Login, + Mechanism::Xoauth2 => smtp::authentication::Mechanism::Xoauth2, } } } @@ -155,9 +106,7 @@ impl SMTPServer { /// # Ok(()) } #[derive(Debug, Clone)] pub struct SMTP { - credentials: SMTPCredentials, - host: SMTPServer, - mechanism: Mechanism, + transport: AsyncSmtpTransport, } impl SMTP { @@ -172,27 +121,21 @@ impl SMTP { /// let creds = SMTPCredentials::new("username", Password::from("password")); /// let smtp = SMTP::new(creds, SMTPServer::Gmail, Mechanism::Plain); /// ``` - pub fn new(credentials: SMTPCredentials, host: SMTPServer, mechanism: Mechanism) -> Self { - Self { - credentials, - host, - mechanism, - } + pub fn new(url: &EmailUrl, mechanism: Mechanism) -> TransportResult { + let transport = AsyncSmtpTransport::::from_url(url.as_str()) + .map_err(|err| SMTPError::TransportCreation(Box::new(err)))? + .authentication(vec![mechanism.into()]) + .build(); + + Ok(SMTP { transport }) } } impl Transport for SMTP { async fn send(&self, messages: &[EmailMessage]) -> TransportResult<()> { - let mechanisms: Vec = - vec![self.mechanism.clone().into()]; for message in messages { let m = Message::try_from(message.clone())?; - let mailer = AsyncSmtpTransport::::relay(self.host.as_str()) - .map_err(|err| SMTPError::SmtpSend(Box::new(err)))? - .credentials(self.credentials.clone().into()) - .authentication(mechanisms.clone()) - .build(); - mailer + self.transport .send(m) .await .map_err(|err| SMTPError::SmtpSend(Box::new(err)))?; @@ -200,3 +143,45 @@ impl Transport for SMTP { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[cot::test] + async fn test_smtp_creation() { + let url = EmailUrl::from("smtp://user:pass@smtp.gmail.com:587"); + let smtp = SMTP::new(&url, Mechanism::Plain); + assert!(smtp.is_ok()); + } + + #[cot::test] + async fn test_smtp_error_to_transport_error() { + let smtp_error = SMTPError::SmtpSend(Box::new(std::io::Error::new( + std::io::ErrorKind::Other, + "test", + ))); + let transport_error: TransportError = smtp_error.into(); + assert_eq!( + transport_error.to_string(), + "email transport error: transport error: smtp transport error: send error: test" + ); + + let smtp_error = SMTPError::TransportCreation(Box::new(std::io::Error::new( + std::io::ErrorKind::Other, + "test", + ))); + let transport_error: TransportError = smtp_error.into(); + assert_eq!( + transport_error.to_string(), + "email transport error: transport error: smtp transport error: transport creation error: test" + ); + + let smtp_error = SMTPError::Io(std::io::Error::new(std::io::ErrorKind::Other, "test")); + let transport_error: TransportError = smtp_error.into(); + assert_eq!( + transport_error.to_string(), + "email transport error: transport error: smtp transport error: IO error: test" + ) + } +} diff --git a/cot/src/project.rs b/cot/src/project.rs index f3bbb5e6..99bce1ae 100644 --- a/cot/src/project.rs +++ b/cot/src/project.rs @@ -51,6 +51,7 @@ use crate::config::{AuthBackendConfig, ProjectConfig}; use crate::db::Database; #[cfg(feature = "db")] use crate::db::migrations::{MigrationEngine, SyncDynMigration}; +#[cfg(feature = "email")] use crate::email::Email; use crate::error::UncaughtPanic; use crate::error::error_impl::impl_into_cot_error; @@ -1730,7 +1731,13 @@ impl ProjectContext { } fn with_config(self, config: ProjectConfig) -> ProjectContext { - let email = Arc::new(Email::from_config(&config.email)); + #[cfg(feature = "email")] + let email = { + let e = Email::from_config(&config.email).unwrap_or_else(|err| { + panic!("failed to initialize email service: {}", err); + }); + Arc::new(e) + }; ProjectContext { config: Arc::new(config), diff --git a/cot/src/test.rs b/cot/src/test.rs index 0098fca2..1b936f3a 100644 --- a/cot/src/test.rs +++ b/cot/src/test.rs @@ -37,6 +37,10 @@ use crate::db::Database; use crate::db::migrations::{ DynMigration, MigrationDependency, MigrationEngine, MigrationWrapper, Operation, }; +#[cfg(feature = "email")] +use crate::email::Email; +#[cfg(feature = "email")] +use crate::email::transport::console::Console; #[cfg(feature = "redis")] use crate::error::error_impl::impl_into_cot_error; use crate::handler::BoxedHandler; @@ -234,6 +238,8 @@ pub struct TestRequestBuilder { static_files: Vec, #[cfg(feature = "cache")] cache: Option>, + #[cfg(feature = "email")] + email: Option>, } /// A wrapper over an auth backend that is cloneable. @@ -289,6 +295,8 @@ impl Default for TestRequestBuilder { static_files: Vec::new(), #[cfg(feature = "cache")] cache: None, + #[cfg(feature = "email")] + email: None, } } } @@ -775,7 +783,10 @@ impl TestRequestBuilder { self.cache .clone() .unwrap_or_else(|| Arc::new(Cache::new(Memory::new(), None, Timeout::default()))), - None, + #[cfg(feature = "email")] + self.email + .clone() + .unwrap_or_else(|| Arc::new(Email::new(Console::new()))), ); prepare_request(&mut request, Arc::new(context)); From 1390e94b3bf0f8826c1b3ccba7f6b067c283f7a9 Mon Sep 17 00:00:00 2001 From: Elijah Date: Tue, 16 Dec 2025 06:22:54 +0000 Subject: [PATCH 29/77] pre-commit fix and email example overhaul --- Cargo.lock | 9 ++ Cargo.toml | 2 +- cot/src/email.rs | 20 ++- cot/src/email/transport/console.rs | 35 ++--- cot/src/email/transport/smtp.rs | 40 +++--- cot/src/project.rs | 2 +- cot/src/request.rs | 30 +++++ examples/send-email/Cargo.toml | 5 +- examples/send-email/config/dev.toml | 5 - examples/send-email/src/main.rs | 161 +++++++++++++++-------- examples/send-email/static/css/main.css | 132 +++++++++++++++++++ examples/send-email/templates/index.html | 86 ++++++++---- examples/send-email/templates/sent.html | 11 -- 13 files changed, 395 insertions(+), 143 deletions(-) delete mode 100644 examples/send-email/config/dev.toml create mode 100644 examples/send-email/static/css/main.css delete mode 100644 examples/send-email/templates/sent.html diff --git a/Cargo.lock b/Cargo.lock index 6912ae69..b252aca0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3435,6 +3435,15 @@ dependencies = [ "libc", ] +[[package]] +name = "send-email" +version = "0.1.0" +dependencies = [ + "askama", + "cot", + "serde", +] + [[package]] name = "serde" version = "1.0.228" diff --git a/Cargo.toml b/Cargo.toml index 72f43fb7..bc8b1395 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ # Examples "examples/admin", "examples/custom-error-pages", -# "examples/send-email", + "examples/send-email", "examples/custom-task", "examples/file-upload", "examples/hello-world", diff --git a/cot/src/email.rs b/cot/src/email.rs index 928880f2..937d0fce 100644 --- a/cot/src/email.rs +++ b/cot/src/email.rs @@ -29,7 +29,7 @@ pub mod transport; use std::sync::Arc; use cot::config::{EmailConfig, EmailTransportTypeConfig}; -use cot::email::transport::smtp::SMTP; +use cot::email::transport::smtp::Smtp; use derive_builder::Builder; use derive_more::with_trait::Debug; use lettre::message::header::ContentType; @@ -301,6 +301,10 @@ impl Email { } /// Send a single [`EmailMessage`] /// + /// # Errors + /// + /// Returns an `EmailError` if sending the email fails. + /// /// # Examples /// /// ```no_run @@ -329,6 +333,10 @@ impl Email { /// Send multiple emails in sequence. /// + /// # Errors + /// + /// Returns an `EmailError` if sending any of the emails fails. + /// /// # Examples /// /// ```no_run @@ -364,6 +372,10 @@ impl Email { /// Construct an [`Email`] from the provided [`EmailConfig`]. /// + /// # Errors + /// + /// Returns an `EmailError` if creating the transport backend fails from the config. + /// /// # Examples /// /// ``` @@ -388,7 +400,7 @@ impl Email { } EmailTransportTypeConfig::Smtp { url, mechanism } => { - let smtp = SMTP::new(url, mechanism.clone()).map_err(EmailError::Transport)?; + let smtp = Smtp::new(url, *mechanism).map_err(EmailError::Transport)?; Self::new(smtp) } } @@ -435,7 +447,6 @@ mod tests { transport: crate::config::EmailTransportConfig { transport_type: EmailTransportTypeConfig::Console, }, - ..Default::default() }; let _email = Email::from_config(&cfg); // We can't introspect the inner transport, but construction should not @@ -454,7 +465,6 @@ mod tests { mechanism: Mechanism::Plain, }, }, - ..Default::default() }; let _email = Email::from_config(&cfg); } @@ -473,7 +483,7 @@ mod tests { .build() .unwrap(); - assert!(email.send(msg).await.is_ok()) + assert!(email.send(msg).await.is_ok()); } #[cot::test] diff --git a/cot/src/email/transport/console.rs b/cot/src/email/transport/console.rs index cffe3529..9c50ae0f 100644 --- a/cot/src/email/transport/console.rs +++ b/cot/src/email/transport/console.rs @@ -68,17 +68,24 @@ impl Console { /// /// let console_transport = Console::new(); /// ``` + #[must_use] pub fn new() -> Self { Self {} } } +impl Default for Console { + fn default() -> Self { + Self::new() + } +} + impl Transport for Console { async fn send(&self, messages: &[EmailMessage]) -> TransportResult<()> { let mut out = io::stdout().lock(); for msg in messages { - writeln!(out, "{}", msg).map_err(|err| ConsoleError::Io(err))?; - writeln!(out, "{}", "─".repeat(60)).map_err(|err| ConsoleError::Io(err))?; + writeln!(out, "{msg}").map_err(ConsoleError::Io)?; + writeln!(out, "{}", "─".repeat(60)).map_err(ConsoleError::Io)?; } Ok(()) } @@ -91,7 +98,7 @@ impl fmt::Display for EmailMessage { "-".to_string() } else { list.iter() - .map(|a| format!("{}", a.email())) + .map(|a| a.email().clone()) .collect::>() .join(", ") } @@ -99,7 +106,6 @@ impl fmt::Display for EmailMessage { writeln!( f, - "{}", "════════════════════════════════════════════════════════════════" )?; writeln!(f, "From : {}", self.from.email())?; @@ -124,7 +130,6 @@ impl fmt::Display for EmailMessage { )?; writeln!( f, - "{}", "────────────────────────────────────────────────────────" )?; if self.body.trim().is_empty() { @@ -134,7 +139,6 @@ impl fmt::Display for EmailMessage { } writeln!( f, - "{}", "────────────────────────────────────────────────────────" )?; if self.attachments.is_empty() { @@ -153,7 +157,6 @@ impl fmt::Display for EmailMessage { } writeln!( f, - "{}", "════════════════════════════════════════════════════════════════" )?; Ok(()) @@ -164,28 +167,14 @@ impl fmt::Display for EmailMessage { mod tests { use super::*; - #[test] - fn display_formats_minimal_message() { - let msg = EmailMessage::builder() - .from(crate::common_types::Email::new("sender@example.com").unwrap()) - .build() - .unwrap(); - let s = format!("{}", msg); - assert!(s.contains("From : sender@example.com")); - assert!(s.contains("To : -")); - assert!(s.contains("Subject : -")); - assert!(s.contains("")); - assert!(s.contains("Attachments: -")); - } - #[cot::test] async fn console_error_to_transport_error() { - let console_error = ConsoleError::Io(io::Error::new(io::ErrorKind::Other, "test error")); + let console_error = ConsoleError::Io(io::Error::other("test error")); let transport_error: TransportError = console_error.into(); assert_eq!( transport_error.to_string(), "email transport error: transport error: console transport error: IO error: test error" - ) + ); } } diff --git a/cot/src/email/transport/smtp.rs b/cot/src/email/transport/smtp.rs index 2a74a1d7..e3f5a271 100644 --- a/cot/src/email/transport/smtp.rs +++ b/cot/src/email/transport/smtp.rs @@ -8,12 +8,12 @@ //! //! ```no_run //! use cot::common_types::Password; -//! use cot::email::transport::smtp::{Mechanism, SMTP, SMTPCredentials, SMTPServer}; +//! use cot::email::transport::smtp::{Mechanism, Smtp, SMTPCredentials, SMTPServer}; //! use cot::email::{Email, EmailMessage}; //! //! # async fn run() -> Result<(), Box> { //! let creds = SMTPCredentials::new("user@example.com", Password::from("secret")); -//! let smtp = SMTP::new(creds, SMTPServer::Gmail, Mechanism::Plain); +//! let smtp = Smtp::new(creds, SMTPServer::Gmail, Mechanism::Plain); //! let email = Email::new(smtp); //! let msg = EmailMessage::builder() //! .from("user@example.com".into()) @@ -89,12 +89,12 @@ impl From for smtp::authentication::Mechanism { /// /// ```no_run /// use cot::email::{Email, EmailMessage}; -/// use cot::email::transport::smtp::{SMTP, SMTPCredentials, SMTPServer, Mechanism}; +/// use cot::email::transport::smtp::{Smtp, SMTPCredentials, SMTPServer, Mechanism}; /// use cot::common_types::Password; /// /// # async fn run() -> cot::Result<()> { /// let creds = SMTPCredentials::new("username", Password::from("password")); -/// let smtp = SMTP::new(creds, SMTPServer::Gmail, Mechanism::Plain); +/// let smtp = Smtp::new(creds, SMTPServer::Gmail, Mechanism::Plain); /// let email = Email::new(smtp); /// let recipients = vec!["testreceipient@example.com".into()]; /// let msg = EmailMessage::builder() @@ -105,21 +105,25 @@ impl From for smtp::authentication::Mechanism { /// email.send(msg).await?; /// # Ok(()) } #[derive(Debug, Clone)] -pub struct SMTP { +pub struct Smtp { transport: AsyncSmtpTransport, } -impl SMTP { +impl Smtp { /// Create a new SMTP transport backend. /// + /// # Errors + /// + /// Returns a `TransportError` if the Smtp backend creation failed. + /// /// # Examples /// /// ``` /// use cot::common_types::Password; - /// use cot::email::transport::smtp::{Mechanism, SMTP, SMTPCredentials, SMTPServer}; + /// use cot::email::transport::smtp::{Mechanism, Smtp, SMTPCredentials, SMTPServer}; /// /// let creds = SMTPCredentials::new("username", Password::from("password")); - /// let smtp = SMTP::new(creds, SMTPServer::Gmail, Mechanism::Plain); + /// let smtp = Smtp::new(creds, SMTPServer::Gmail, Mechanism::Plain); /// ``` pub fn new(url: &EmailUrl, mechanism: Mechanism) -> TransportResult { let transport = AsyncSmtpTransport::::from_url(url.as_str()) @@ -127,11 +131,11 @@ impl SMTP { .authentication(vec![mechanism.into()]) .build(); - Ok(SMTP { transport }) + Ok(Smtp { transport }) } } -impl Transport for SMTP { +impl Transport for Smtp { async fn send(&self, messages: &[EmailMessage]) -> TransportResult<()> { for message in messages { let m = Message::try_from(message.clone())?; @@ -151,37 +155,31 @@ mod tests { #[cot::test] async fn test_smtp_creation() { let url = EmailUrl::from("smtp://user:pass@smtp.gmail.com:587"); - let smtp = SMTP::new(&url, Mechanism::Plain); + let smtp = Smtp::new(&url, Mechanism::Plain); assert!(smtp.is_ok()); } #[cot::test] async fn test_smtp_error_to_transport_error() { - let smtp_error = SMTPError::SmtpSend(Box::new(std::io::Error::new( - std::io::ErrorKind::Other, - "test", - ))); + let smtp_error = SMTPError::SmtpSend(Box::new(std::io::Error::other("test"))); let transport_error: TransportError = smtp_error.into(); assert_eq!( transport_error.to_string(), "email transport error: transport error: smtp transport error: send error: test" ); - let smtp_error = SMTPError::TransportCreation(Box::new(std::io::Error::new( - std::io::ErrorKind::Other, - "test", - ))); + let smtp_error = SMTPError::TransportCreation(Box::new(std::io::Error::other("test"))); let transport_error: TransportError = smtp_error.into(); assert_eq!( transport_error.to_string(), "email transport error: transport error: smtp transport error: transport creation error: test" ); - let smtp_error = SMTPError::Io(std::io::Error::new(std::io::ErrorKind::Other, "test")); + let smtp_error = SMTPError::Io(std::io::Error::other("test")); let transport_error: TransportError = smtp_error.into(); assert_eq!( transport_error.to_string(), "email transport error: transport error: smtp transport error: IO error: test" - ) + ); } } diff --git a/cot/src/project.rs b/cot/src/project.rs index 99bce1ae..13ba8b58 100644 --- a/cot/src/project.rs +++ b/cot/src/project.rs @@ -1734,7 +1734,7 @@ impl ProjectContext { #[cfg(feature = "email")] let email = { let e = Email::from_config(&config.email).unwrap_or_else(|err| { - panic!("failed to initialize email service: {}", err); + panic!("failed to initialize email service: {err:?}"); }); Arc::new(e) }; diff --git a/cot/src/request.rs b/cot/src/request.rs index 092e0d57..476cc4a7 100644 --- a/cot/src/request.rs +++ b/cot/src/request.rs @@ -20,6 +20,8 @@ use indexmap::IndexMap; #[cfg(feature = "db")] use crate::db::Database; +#[cfg(feature = "email")] +use crate::email::Email; use crate::error::error_impl::impl_into_cot_error; use crate::request::extractors::FromRequestHead; use crate::router::Router; @@ -209,6 +211,24 @@ pub trait RequestExt: private::Sealed { #[must_use] fn db(&self) -> &Arc; + /// Get the email service. + /// + /// # Examples + /// + /// ``` + /// use cot::request::{Request, RequestExt}; + /// use cot::response::Response; + /// + /// async fn my_handler(mut request: Request) -> cot::Result { + /// let email_service = request.email(); + /// // ... do something with the email service + /// # unimplemented!() + /// } + /// ``` + #[cfg(feature = "email")] + #[must_use] + fn email(&self) -> &Arc; + /// Get the content type of the request. /// /// # Examples @@ -322,6 +342,11 @@ impl RequestExt for Request { self.context().database() } + #[cfg(feature = "email")] + fn email(&self) -> &Arc { + self.context().email() + } + fn content_type(&self) -> Option<&http::HeaderValue> { self.headers().get(http::header::CONTENT_TYPE) } @@ -382,6 +407,11 @@ impl RequestExt for RequestHead { self.context().database() } + #[cfg(feature = "email")] + fn email(&self) -> &Arc { + self.context().email() + } + fn content_type(&self) -> Option<&http::HeaderValue> { self.headers.get(http::header::CONTENT_TYPE) } diff --git a/examples/send-email/Cargo.toml b/examples/send-email/Cargo.toml index 1a2d990b..daa5476f 100644 --- a/examples/send-email/Cargo.toml +++ b/examples/send-email/Cargo.toml @@ -6,5 +6,6 @@ description = "Send email - Cot example." edition = "2021" [dependencies] -cot = { path = "../../cot" } -lettre = { version = "0.11.15", features = ["native-tls"] } +cot = { path = "../../cot", features = ["email", "live-reload"] } +askama = "0.14.0" +serde = { version = "1.0.228", features = ["derive"] } diff --git a/examples/send-email/config/dev.toml b/examples/send-email/config/dev.toml deleted file mode 100644 index 8375b5e8..00000000 --- a/examples/send-email/config/dev.toml +++ /dev/null @@ -1,5 +0,0 @@ -[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 163475f7..d72a6d70 100644 --- a/examples/send-email/src/main.rs +++ b/examples/send-email/src/main.rs @@ -1,64 +1,118 @@ +use askama::Template; use cot::cli::CliMetadata; -use cot::config::{DatabaseConfig, EmailConfig, EmailTransportTypeConfig, ProjectConfig}; -use cot::email::{EmailBackend, EmailMessage, SmtpTransportMode}; +use cot::common_types::Email; +use cot::config::{EmailConfig, EmailTransportConfig, EmailTransportTypeConfig, ProjectConfig}; +use cot::email::EmailMessage; use cot::form::Form; use cot::html::Html; -use cot::project::RegisterAppsContext; +use cot::middleware::LiveReloadMiddleware; +use cot::project::{RegisterAppsContext, RootHandler}; +use cot::request::extractors::{StaticFiles, UrlQuery}; use cot::request::{Request, RequestExt}; -use cot::router::{Route, Router}; -use cot::{App, AppBuilder, Project}; +use cot::response::Response; +use cot::router::{Route, Router, Urls}; +use cot::static_files::{StaticFile, StaticFilesMiddleware}; +use cot::{reverse_redirect, static_files, App, AppBuilder, Project}; +use serde::{Deserialize, Serialize}; +use std::fmt::Display; struct EmailApp; impl App for EmailApp { - fn name(&self) -> &str { - "email" + fn name(&self) -> &'static str { + env!("CARGO_CRATE_NAME") } fn router(&self) -> Router { Router::with_urls([ - Route::with_handler_and_name("/", email_form, "email_form"), + Route::with_handler_and_name("/", index, "index/"), Route::with_handler_and_name("/send", send_email, "send_email"), ]) } -} -async fn email_form(_request: Request) -> cot::Result { - let template = String::from(include_str!("../templates/index.html")); - Ok(Html::new(template)) + fn static_files(&self) -> Vec { + static_files!("css/main.css") + } } + #[derive(Debug, Form)] struct EmailForm { - from: String, - to: String, + from: Email, + to: Email, subject: String, - body: String, + message: 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 _database = request.context().database(); - let email_backend = request.context().email_backend(); - { - let _x = email_backend.lock().unwrap().send_message(&email); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +enum Status { + Success, + Failure, +} + +impl Display for Status { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Status::Success => write!(f, "Success"), + Status::Failure => write!(f, "Failure"), + } } - let template = String::from(include_str!("../templates/sent.html")); - Ok(Html::new(template)) } + +#[derive(Debug, Template)] +#[allow(unused)] +#[template(path = "index.html")] +struct IndexTemplate<'a> { + static_files: StaticFiles, + urls: &'a Urls, + form: ::Context, + status: &'a str, +} + +#[derive(Serialize, Deserialize, Debug)] +struct IndexQuery { + status: Option, +} + +async fn index( + urls: Urls, + mut request: Request, + static_files: StaticFiles, + UrlQuery(query): UrlQuery, +) -> cot::Result { + let status = match query.status { + Some(s) => s.to_string(), + None => "".to_string(), + }; + let index_template = IndexTemplate { + urls: &urls, + form: EmailForm::build_context(&mut request).await?, + status: &status, + static_files, + }; + let rendered = index_template.render()?; + + Ok(Html::new(rendered)) +} + +async fn send_email(urls: Urls, mut request: Request) -> cot::Result { + let form = EmailForm::from_request(&mut request).await?; + + let form = form.unwrap(); + + let message = EmailMessage::builder() + .from(form.from) + .to(vec![form.to]) + .subject(form.subject) + .body(form.message) + .build()?; + + request.email().send(message).await?; + + // Fixme We should redirect with the status when reverse_redirect! supports query parameters + Ok(reverse_redirect!(&urls, "index/",)?) +} + struct MyProject; impl Project for MyProject { fn cli_metadata(&self) -> CliMetadata { @@ -66,16 +120,17 @@ impl Project for MyProject { } fn config(&self, _config_name: &str) -> cot::Result { - let mut email_config = EmailConfig::builder(); - email_config.backend_type(EmailTransportTypeConfig::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) + Ok(ProjectConfig::builder() + .email( + EmailConfig::builder() + .transport( + EmailTransportConfig::builder() + .transport_type(EmailTransportTypeConfig::Console) + .build(), + ) + .build(), + ) + .build()) } fn register_apps(&self, apps: &mut AppBuilder, _context: &RegisterAppsContext) { apps.register_with_views(EmailApp, ""); @@ -84,10 +139,12 @@ impl Project for MyProject { fn middlewares( &self, handler: cot::project::RootHandlerBuilder, - _context: &cot::project::MiddlewareContext, - ) -> cot::BoxedHandler { - // context.config().email_backend().unwrap(); - handler.build() + context: &cot::project::MiddlewareContext, + ) -> RootHandler { + handler + .middleware(StaticFilesMiddleware::from_context(context)) + .middleware(LiveReloadMiddleware::from_context(context)) + .build() } } diff --git a/examples/send-email/static/css/main.css b/examples/send-email/static/css/main.css new file mode 100644 index 00000000..f63ccaae --- /dev/null +++ b/examples/send-email/static/css/main.css @@ -0,0 +1,132 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + background-color: #fafafa; + color: #0f172a; +} + +.root-container { + max-width: 480px; + margin: 3rem auto; + padding: 0 1rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.status { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + border-radius: 12px; + font-size: 0.95rem; + line-height: 1.3; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08); + border: 1px solid transparent; + width: 100%; +} + +.status--success { + background: linear-gradient(180deg, #f0fdf4, #e8fff6); + border-color: #b7f2d0; + color: #053516; +} + +.status--error { + background: linear-gradient(180deg, #fff5f5, #ffecec); + border-color: #f4b3b3; + color: #5a0b0b; +} + +.status__text { + margin: 0; + padding: 0; +} + +.status--success .status__text::before, +.status--error .status__text::before { + content: ""; + display: inline-block; + width: 24px; + height: 24px; + border-radius: 6px; + margin-right: 0.5rem; + vertical-align: middle; +} + +.status--success .status__text::before { + background-color: rgba(4, 107, 47, 0.12); + border: 1px solid rgba(4, 107, 47, 0.2); +} + +.status--error .status__text::before { + background-color: rgba(122, 5, 5, 0.12); + border: 1px solid rgba(122, 5, 5, 0.2); +} + +#email-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.form-field { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.form-field label { + font-size: 0.85rem; + font-weight: 500; + color: #334155; +} + +#email-form input, +#email-form textarea { + width: 100%; + padding: 0.6rem 0.75rem; + font-size: 0.95rem; + border-radius: 8px; + border: 1px solid #cbd5e1; + background-color: #ffffff; +} + +#email-form textarea { + resize: vertical; + min-height: 120px; +} + +#email-form input:focus, +#email-form textarea:focus { + outline: none; + border-color: #22c55e; + box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2); +} + +#email-form .submit-button { + width: 100%; + padding: 0.75rem; + font-size: 1rem; + font-weight: 600; + border-radius: 10px; + border: none; + cursor: pointer; + background: linear-gradient(180deg, #22c55e, #16a34a); + color: white; +} + +#email-form .submit-button:hover { + background: linear-gradient(180deg, #16a34a, #15803d); +} + +#email-form .submit-button:active { + transform: translateY(1px); +} diff --git a/examples/send-email/templates/index.html b/examples/send-email/templates/index.html index 3215b013..1d2bfaba 100644 --- a/examples/send-email/templates/index.html +++ b/examples/send-email/templates/index.html @@ -1,28 +1,70 @@ +{% let urls = urls %} +{% let status = status %} - + - Send Email + + + Send Email Example + -

Send Email

-
-
- - -
-
- - -
-
- - -
-
- - -
- -
+
+ {% if status == "Success" %} +
+

Email sent successfully

+
+ {% elif status == "Failure" %} +
+

Failed to send email

+
+ {% endif %} +

{{ status }}

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
diff --git a/examples/send-email/templates/sent.html b/examples/send-email/templates/sent.html deleted file mode 100644 index b4020141..00000000 --- a/examples/send-email/templates/sent.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - Email Sent - - -

Email Sent Successfully

-

The email has been sent successfully.

- Send another email - - From 946f07c2d805cb5c73b1fb1354684f8517506880 Mon Sep 17 00:00:00 2001 From: Elijah Date: Tue, 16 Dec 2025 06:37:15 +0000 Subject: [PATCH 30/77] some CI fixes --- cot/src/email.rs | 3 ++- cot/src/email/transport/smtp.rs | 4 ++-- cot/src/request.rs | 4 ++-- examples/send-email/Cargo.toml | 1 + examples/send-email/src/main.rs | 6 +++--- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/cot/src/email.rs b/cot/src/email.rs index 937d0fce..0dbf71f5 100644 --- a/cot/src/email.rs +++ b/cot/src/email.rs @@ -374,7 +374,8 @@ impl Email { /// /// # Errors /// - /// Returns an `EmailError` if creating the transport backend fails from the config. + /// Returns an `EmailError` if creating the transport backend fails from the + /// config. /// /// # Examples /// diff --git a/cot/src/email/transport/smtp.rs b/cot/src/email/transport/smtp.rs index e3f5a271..314a49a6 100644 --- a/cot/src/email/transport/smtp.rs +++ b/cot/src/email/transport/smtp.rs @@ -8,7 +8,7 @@ //! //! ```no_run //! use cot::common_types::Password; -//! use cot::email::transport::smtp::{Mechanism, Smtp, SMTPCredentials, SMTPServer}; +//! use cot::email::transport::smtp::{Mechanism, SMTPCredentials, SMTPServer, Smtp}; //! use cot::email::{Email, EmailMessage}; //! //! # async fn run() -> Result<(), Box> { @@ -120,7 +120,7 @@ impl Smtp { /// /// ``` /// use cot::common_types::Password; - /// use cot::email::transport::smtp::{Mechanism, Smtp, SMTPCredentials, SMTPServer}; + /// use cot::email::transport::smtp::{Mechanism, SMTPCredentials, SMTPServer, Smtp}; /// /// let creds = SMTPCredentials::new("username", Password::from("password")); /// let smtp = Smtp::new(creds, SMTPServer::Gmail, Mechanism::Plain); diff --git a/cot/src/request.rs b/cot/src/request.rs index 476cc4a7..1f90b503 100644 --- a/cot/src/request.rs +++ b/cot/src/request.rs @@ -220,8 +220,8 @@ pub trait RequestExt: private::Sealed { /// use cot::response::Response; /// /// async fn my_handler(mut request: Request) -> cot::Result { - /// let email_service = request.email(); - /// // ... do something with the email service + /// let email_service = request.email(); + /// // ... do something with the email service /// # unimplemented!() /// } /// ``` diff --git a/examples/send-email/Cargo.toml b/examples/send-email/Cargo.toml index daa5476f..bca195f6 100644 --- a/examples/send-email/Cargo.toml +++ b/examples/send-email/Cargo.toml @@ -3,6 +3,7 @@ name = "send-email" version = "0.1.0" publish = false description = "Send email - Cot example." +license = "MIT OR Apache-2.0" edition = "2021" [dependencies] diff --git a/examples/send-email/src/main.rs b/examples/send-email/src/main.rs index d72a6d70..e556fafe 100644 --- a/examples/send-email/src/main.rs +++ b/examples/send-email/src/main.rs @@ -14,7 +14,6 @@ use cot::router::{Route, Router, Urls}; use cot::static_files::{StaticFile, StaticFilesMiddleware}; use cot::{reverse_redirect, static_files, App, AppBuilder, Project}; use serde::{Deserialize, Serialize}; -use std::fmt::Display; struct EmailApp; @@ -50,7 +49,7 @@ enum Status { Failure, } -impl Display for Status { +impl std::fmt::Display for Status { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Status::Success => write!(f, "Success"), @@ -109,7 +108,8 @@ async fn send_email(urls: Urls, mut request: Request) -> cot::Result { request.email().send(message).await?; - // Fixme We should redirect with the status when reverse_redirect! supports query parameters + // Fixme: We should redirect with the status when reverse_redirect! supports + // query parameters Ok(reverse_redirect!(&urls, "index/",)?) } From 9e2e6ae77d7a6a62a404c40c7d39749be641ffc1 Mon Sep 17 00:00:00 2001 From: Elijah Date: Wed, 17 Dec 2025 22:09:19 +0000 Subject: [PATCH 31/77] try changing windows linker --- .cargo/config.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.cargo/config.toml b/.cargo/config.toml index 4a6a1abd..f616e59c 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,2 +1,5 @@ [resolver] incompatible-rust-versions = "fallback" + +[target.'cfg(target_os = "windows")'] +linker = "rust-lld" From 24bf4862acd480df659a6c93e294f393a7679fd9 Mon Sep 17 00:00:00 2001 From: Elijah Date: Thu, 18 Dec 2025 00:12:38 +0000 Subject: [PATCH 32/77] fix docs unittest --- cot/src/config.rs | 5 ++- cot/src/email.rs | 60 +++++++++++++++++------------- cot/src/email/transport.rs | 3 ++ cot/src/email/transport/console.rs | 11 +++--- cot/src/email/transport/smtp.rs | 50 +++++++++++++------------ 5 files changed, 73 insertions(+), 56 deletions(-) diff --git a/cot/src/config.rs b/cot/src/config.rs index d74b24e2..9109e6d8 100644 --- a/cot/src/config.rs +++ b/cot/src/config.rs @@ -1867,10 +1867,11 @@ pub enum EmailTransportTypeConfig { /// # Examples /// /// ``` - /// use cot::config::EmailTransportTypeConfig; + /// use cot::config::{EmailTransportTypeConfig, EmailUrl}; + /// use cot::email::transport::smtp::Mechanism; /// /// let smtp_config = EmailTransportTypeConfig::Smtp { - /// url: Email::from("smtps://username:password@smtp.gmail.com?tls=required"), + /// url: EmailUrl::from("smtps://username:password@smtp.gmail.com?tls=required"), /// mechanism: Mechanism::Plain, /// }; /// ``` diff --git a/cot/src/email.rs b/cot/src/email.rs index 0dbf71f5..4e48b559 100644 --- a/cot/src/email.rs +++ b/cot/src/email.rs @@ -9,14 +9,15 @@ //! Send using the console transport backend (prints nicely formatted messages): //! //! ```no_run +//! use cot::common_types::Email; +//! use cot::email::EmailMessage; //! use cot::email::transport::console::Console; -//! use cot::email::{Email, EmailMessage}; //! //! # async fn run() -> cot::Result<()> { -//! let email = Email::new(Console::new()); +//! let email = cot::email::Email::new(Console::new()); //! let message = EmailMessage::builder() -//! .from("no-reply@example.com".into()) -//! .to(vec!["user@example.com".into()]) +//! .from(Email::try_from("no-reply@example.com").unwrap()) +//! .to(vec![Email::try_from("user@example.com").unwrap()]) //! .subject("Greetings") //! .body("Hello from cot!") //! .build()?; @@ -107,11 +108,12 @@ impl EmailMessage { /// # Examples /// /// ``` + /// use cot::common_types::Email; /// use cot::email::EmailMessage; /// /// let message = EmailMessage::builder() - /// .from("no-reply@example.com".into()) - /// .to(vec!["user@example.com".into()]) + /// .from(Email::try_from("no-reply@example.com").unwrap()) + /// .to(vec![Email::try_from("user@example.com").unwrap()]) /// .subject("Greetings") /// .body("Hello from cot!") /// .build() @@ -133,11 +135,12 @@ impl EmailMessageBuilder { /// # Examples /// /// ``` + /// use cot::common_types::Email; /// use cot::email::EmailMessage; /// /// let message = EmailMessage::builder() - /// .from("no-reply@example.com".into()) - /// .to(vec!["user@example.com".into()]) + /// .from(Email::try_from("no-reply@example.com").unwrap()) + /// .to(vec![Email::try_from("user@example.com").unwrap()]) /// .subject("Greetings") /// .body("Hello from cot!") /// .build() @@ -262,15 +265,16 @@ impl TryFrom for Message { /// # Examples /// /// ```no_run -/// use cot::email::{Email, EmailMessage}; +/// use cot::email::EmailMessage; /// use cot::email::transport::console::Console; +/// use cot::common_types::Email; /// /// # #[tokio::main] /// # async fn main() -> cot::Result<()> { -/// let email = Email::new(Console::new()); +/// let email = cot::email::Email::new(Console::new()); /// let message = EmailMessage::builder() -/// .from("no-reply@example.com".into()) -/// .to(vec!["user@example.com".into()]) +/// .from(Email::try_from("no-reply@example.com").unwrap()) +/// .to(vec![Email::try_from("user@example.com").unwrap()]) /// .subject("Greetings") /// .body("Hello from cot!") /// .build()?; @@ -308,19 +312,20 @@ impl Email { /// # Examples /// /// ```no_run - /// use cot::email::{Email, EmailMessage}; + /// use cot::email::EmailMessage; /// use cot::email::transport::console::Console; + /// use cot::common_types::Email; /// /// # #[tokio::main] /// # async fn main() -> cot::Result<()> { - /// let email = Email::new(Console::new()); + /// let email = cot::email::Email::new(Console::new()); /// let message = EmailMessage::builder() - /// .from("no-reply@example.com".into()) - /// .to(vec!["user@example.com".into()]) + /// .from(Email::try_from("no-reply@example.com").unwrap()) + /// .to(vec![Email::try_from("user@example.com").unwrap()]) /// .subject("Greetings") /// .body("Hello from cot!") - /// .build()? - /// email.send(message).await?; + /// .build()?; + /// email.send(message).await?; /// # Ok(()) /// } /// ``` @@ -340,22 +345,23 @@ impl Email { /// # Examples /// /// ```no_run - /// use cot::email::{Email, EmailMessage}; + /// use cot::email::EmailMessage; /// use cot::email::transport::console::Console; + /// use cot::common_types::Email; /// /// # #[tokio::main] /// # async fn main() -> cot::Result<()> { - /// let email = Email::new(Console::new()); + /// let email = cot::email::Email::new(Console::new()); /// let message1 = EmailMessage::builder() - /// .from("no-reply@email.com".into()) - /// .to(vec!["user1@example.com".into()]) + /// .from(Email::try_from("no-reply@email.com").unwrap()) + /// .to(vec![Email::try_from("user1@example.com").unwrap()]) /// .subject("Hello User 1") /// .body("This is the first email.") /// .build()?; /// /// let message2 = EmailMessage::builder() - /// .from("no-reply@email.com".into()) - /// .to(vec!["user2@example.com".into()]) + /// .from(Email::try_from("no-reply@email.com").unwrap()) + /// .to(vec![Email::try_from("user2@example.com").unwrap()]) /// .subject("Hello User 2") /// .body("This is the second email.") /// .build()?; @@ -380,12 +386,14 @@ impl Email { /// # Examples /// /// ``` - /// use cot::config::{EmailConfig, EmailTransportTypeConfig}; + /// use cot::config::{EmailConfig, EmailTransportConfig, EmailTransportTypeConfig}; /// use cot::email::Email; /// use cot::email::transport::console::Console; /// /// let config = EmailConfig { - /// transport: EmailTransportTypeConfig::Console, + /// transport: EmailTransportConfig::builder() + /// .transport_type(EmailTransportTypeConfig::Console) + /// .build(), /// ..Default::default() /// }; /// let email = Email::from_config(&config); diff --git a/cot/src/email/transport.rs b/cot/src/email/transport.rs index 7f47fa5b..5621535b 100644 --- a/cot/src/email/transport.rs +++ b/cot/src/email/transport.rs @@ -9,6 +9,7 @@ use cot::email::MessageBuildError; use thiserror::Error; use crate::email::EmailMessage; +use crate::error::error_impl::impl_into_cot_error; pub mod console; pub mod smtp; @@ -27,6 +28,8 @@ pub enum TransportError { MessageBuildError(#[from] MessageBuildError), } +impl_into_cot_error!(TransportError); + /// A Convenience alias for results returned by transport operations. pub type TransportResult = Result; diff --git a/cot/src/email/transport/console.rs b/cot/src/email/transport/console.rs index 9c50ae0f..6c2af0b6 100644 --- a/cot/src/email/transport/console.rs +++ b/cot/src/email/transport/console.rs @@ -7,15 +7,16 @@ //! Typical usage is through the high-level [`crate::email::Email`] API: //! //! ```no_run +//! use cot::common_types::Email; +//! use cot::email::EmailMessage; //! use cot::email::transport::console::Console; -//! use cot::email::{Email, EmailMessage}; //! //! # async fn run() -> Result<(), Box> { -//! let email = Email::new(Console::new()); -//! let recipients = vec!["testreceipient@example.com".into()]; +//! let email = cot::email::Email::new(Console::new()); +//! let recipients = vec![Email::try_from("testreceipient@example.com").unwrap()]; //! let msg = EmailMessage::builder() -//! .from("no-reply@example.com".into()) -//! .to() +//! .from(Email::try_from("no-reply@example.com").unwrap()) +//! .to(vec![Email::try_from("user@example.com").unwrap()]) //! .build()?; //! email.send(msg).await?; //! # Ok(()) } diff --git a/cot/src/email/transport/smtp.rs b/cot/src/email/transport/smtp.rs index 314a49a6..5f43add5 100644 --- a/cot/src/email/transport/smtp.rs +++ b/cot/src/email/transport/smtp.rs @@ -7,18 +7,20 @@ //! Typical usage is through the high-level [`crate::email::Email`] API: //! //! ```no_run -//! use cot::common_types::Password; -//! use cot::email::transport::smtp::{Mechanism, SMTPCredentials, SMTPServer, Smtp}; -//! use cot::email::{Email, EmailMessage}; +//! use cot::common_types::Email; +//! use cot::config::EmailUrl; +//! use cot::email::EmailMessage; +//! use cot::email::transport::Transport; +//! use cot::email::transport::smtp::{Mechanism, Smtp}; //! //! # async fn run() -> Result<(), Box> { -//! let creds = SMTPCredentials::new("user@example.com", Password::from("secret")); -//! let smtp = Smtp::new(creds, SMTPServer::Gmail, Mechanism::Plain); -//! let email = Email::new(smtp); +//! let url = EmailUrl::from("smtps://username:password@smtp.gmail.com?tls=required"); +//! let smtp = Smtp::new(&url, Mechanism::Plain)?; +//! let email = cot::email::Email::new(smtp); //! let msg = EmailMessage::builder() -//! .from("user@example.com".into()) -//! .to(vec!["user2@example.com".into()]) -//! .body("This is a test email.".into()) +//! .from(Email::try_from("user@example.com").unwrap()) +//! .to(vec![Email::try_from("user2@example.com").unwrap()]) +//! .body("This is a test email.") //! .build()?; //! email.send(msg).await?; //! # Ok(()) } @@ -88,19 +90,21 @@ impl From for smtp::authentication::Mechanism { /// # Examples /// /// ```no_run -/// use cot::email::{Email, EmailMessage}; -/// use cot::email::transport::smtp::{Smtp, SMTPCredentials, SMTPServer, Mechanism}; -/// use cot::common_types::Password; +/// use cot::email::EmailMessage; +/// use cot::email::transport::Transport; +/// use cot::email::transport::smtp::{Smtp, Mechanism}; +/// use cot::common_types::Email; +/// use cot::config::EmailUrl; /// /// # async fn run() -> cot::Result<()> { -/// let creds = SMTPCredentials::new("username", Password::from("password")); -/// let smtp = Smtp::new(creds, SMTPServer::Gmail, Mechanism::Plain); -/// let email = Email::new(smtp); -/// let recipients = vec!["testreceipient@example.com".into()]; +/// let url = EmailUrl::from("smtps://username:password@smtp.gmail.com?tls=required"); +/// let smtp = Smtp::new(&url, Mechanism::Plain)?; +/// let email = cot::email::Email::new(smtp); +/// /// let msg = EmailMessage::builder() -/// .from("testfrom@example.com".into()) -/// .to(recipients) -/// .body("This is a test email.".into()) +/// .from(Email::try_from("testfrom@example.com").unwrap()) +/// .to(vec![Email::try_from("testreceipient@example.com").unwrap()]) +/// .body("This is a test email.") /// .build()?; /// email.send(msg).await?; /// # Ok(()) } @@ -119,11 +123,11 @@ impl Smtp { /// # Examples /// /// ``` - /// use cot::common_types::Password; - /// use cot::email::transport::smtp::{Mechanism, SMTPCredentials, SMTPServer, Smtp}; + /// use cot::config::EmailUrl; + /// use cot::email::transport::smtp::{Mechanism, Smtp}; /// - /// let creds = SMTPCredentials::new("username", Password::from("password")); - /// let smtp = Smtp::new(creds, SMTPServer::Gmail, Mechanism::Plain); + /// let url = EmailUrl::from("smtps://username:password@smtp.gmail.com?tls=required"); + /// let smtp = Smtp::new(&url, Mechanism::Plain); /// ``` pub fn new(url: &EmailUrl, mechanism: Mechanism) -> TransportResult { let transport = AsyncSmtpTransport::::from_url(url.as_str()) From d701a2a11a2b84444af7df9b6f4d9e33855c6b90 Mon Sep 17 00:00:00 2001 From: Elijah Date: Thu, 18 Dec 2025 00:22:42 +0000 Subject: [PATCH 33/77] Allow Apache 2.0 with LLVM-exception --- deny.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/deny.toml b/deny.toml index 6c253ceb..3b2af4e9 100644 --- a/deny.toml +++ b/deny.toml @@ -16,6 +16,7 @@ all-features = true allow = [ "MIT", "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", "Unicode-3.0", "0BSD", "BSD-3-Clause", From 8f4f738e739b5fc01508edd50c889392b0d1f06f Mon Sep 17 00:00:00 2001 From: Elijah Date: Thu, 18 Dec 2025 16:11:06 +0000 Subject: [PATCH 34/77] lets see if this fixes it for windows --- cot-cli/src/new_project.rs | 3 ++- cot-cli/src/project_template/.cargo/config.toml | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 cot-cli/src/project_template/.cargo/config.toml diff --git a/cot-cli/src/new_project.rs b/cot-cli/src/new_project.rs index 1241bc15..a592c6be 100644 --- a/cot-cli/src/new_project.rs +++ b/cot-cli/src/new_project.rs @@ -13,7 +13,7 @@ macro_rules! project_file { }; } -const PROJECT_FILES: [(&str, &str); 10] = [ +const PROJECT_FILES: [(&str, &str); 11] = [ project_file!("Cargo.toml.template"), project_file!("Cargo.lock.template"), project_file!("bacon.toml"), @@ -24,6 +24,7 @@ const PROJECT_FILES: [(&str, &str); 10] = [ project_file!("templates/index.html"), project_file!("config/dev.toml"), project_file!("config/prod.toml.example"), + project_file!(".cargo/config.toml"), ]; #[derive(Debug, Clone, PartialEq, Eq, Hash)] diff --git a/cot-cli/src/project_template/.cargo/config.toml b/cot-cli/src/project_template/.cargo/config.toml new file mode 100644 index 00000000..4b5f2d40 --- /dev/null +++ b/cot-cli/src/project_template/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.'cfg(target_os = "windows")'] +linker = "rust-lld" From 11afeab1d3b66860e381a9d4309ed4ed5f87b131 Mon Sep 17 00:00:00 2001 From: Elijah Date: Thu, 18 Dec 2025 16:47:19 +0000 Subject: [PATCH 35/77] cargo insta fix --- ...ot_testing__new__create_new_project-5.snap | 27 ----------------- ...ot_testing__new__create_new_project-6.snap | 3 +- ...create_new_project_with_custom_name-5.snap | 29 ------------------- ...create_new_project_with_custom_name-6.snap | 3 +- 4 files changed, 4 insertions(+), 58 deletions(-) delete mode 100644 cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-5.snap delete mode 100644 cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-5.snap diff --git a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-5.snap b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-5.snap deleted file mode 100644 index 28005faf..00000000 --- a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-5.snap +++ /dev/null @@ -1,27 +0,0 @@ ---- -source: cot-cli/tests/snapshot_testing/new/mod.rs -description: "Verbosity level: debug" -info: - program: cot - args: - - new - - "-vvvv" - - /tmp/cot-test-o4uWVf/project ---- -success: true -exit_code: 0 ------ stdout ----- -TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/Cargo.toml" -TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/Cargo.lock" -TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/bacon.toml" -TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/.gitignore" -TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/src/main.rs" -TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/src/migrations.rs" -TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/static/css/main.css" -TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/templates/index.html" -TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/config/dev.toml" -TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/config/prod.toml.example" - ------ stderr ----- - Creating Cot project `project` - Created Cot project `project` diff --git a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-6.snap b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-6.snap index f1a8e8da..f84a3536 100644 --- a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-6.snap +++ b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-6.snap @@ -6,7 +6,7 @@ info: args: - new - "-vvvvv" - - /tmp/cot-test-QUOaBC/project + - /var/folders/xy/x4cxwym16gdddzy9qmvfngl80000gn/T/cot-test-dvETzt/project --- success: true exit_code: 0 @@ -21,6 +21,7 @@ TIMESTAMP TRACE cot_cli::new_project: Writing file: "/t TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/templates/index.html" TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/config/dev.toml" TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/config/prod.toml.example" +TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/.cargo/config.toml" ----- stderr -----  Creating Cot project `project` diff --git a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-5.snap b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-5.snap deleted file mode 100644 index e9fb34e5..00000000 --- a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-5.snap +++ /dev/null @@ -1,29 +0,0 @@ ---- -source: cot-cli/tests/snapshot_testing/new/mod.rs -description: "Verbosity level: debug" -info: - program: cot - args: - - new - - "--name" - - my_project - - "-vvvv" - - /tmp/cot-test-BEJYfS/project ---- -success: true -exit_code: 0 ------ stdout ----- -TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/Cargo.toml" -TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/Cargo.lock" -TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/bacon.toml" -TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/.gitignore" -TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/src/main.rs" -TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/src/migrations.rs" -TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/static/css/main.css" -TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/templates/index.html" -TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/config/dev.toml" -TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/config/prod.toml.example" - ------ stderr ----- - Creating Cot project `my_project` - Created Cot project `my_project` diff --git a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-6.snap b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-6.snap index 2d703e8f..74993180 100644 --- a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-6.snap +++ b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-6.snap @@ -8,7 +8,7 @@ info: - "--name" - my_project - "-vvvvv" - - /tmp/cot-test-IWoQbg/project + - /var/folders/xy/x4cxwym16gdddzy9qmvfngl80000gn/T/cot-test-O602xQ/project --- success: true exit_code: 0 @@ -23,6 +23,7 @@ TIMESTAMP TRACE cot_cli::new_project: Writing file: "/t TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/templates/index.html" TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/config/dev.toml" TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/config/prod.toml.example" +TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/.cargo/config.toml" ----- stderr -----  Creating Cot project `my_project` From 612aaf14cb85af3ad53cc588c21e0123c02116c6 Mon Sep 17 00:00:00 2001 From: Elijah Date: Thu, 18 Dec 2025 18:20:29 +0000 Subject: [PATCH 36/77] mising files? --- ...ot_testing__new__create_new_project-5.snap | 29 +++++++++++++++++ ...create_new_project_with_custom_name-5.snap | 31 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-5.snap create mode 100644 cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-5.snap diff --git a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-5.snap b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-5.snap new file mode 100644 index 00000000..f154d782 --- /dev/null +++ b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-5.snap @@ -0,0 +1,29 @@ +--- +source: cot-cli/tests/snapshot_testing/new/mod.rs +assertion_line: 19 +description: "Verbosity level: debug" +info: + program: cot + args: + - new + - "-vvvv" + - /var/folders/xy/x4cxwym16gdddzy9qmvfngl80000gn/T/cot-test-ke2aLQ/project +--- +success: true +exit_code: 0 +----- stdout ----- +TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/Cargo.toml" +TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/Cargo.lock" +TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/bacon.toml" +TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/.gitignore" +TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/src/main.rs" +TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/src/migrations.rs" +TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/static/css/main.css" +TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/templates/index.html" +TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/config/dev.toml" +TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/config/prod.toml.example" +TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/.cargo/config.toml" + +----- stderr ----- + Creating Cot project `project` + Created Cot project `project` diff --git a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-5.snap b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-5.snap new file mode 100644 index 00000000..cd2224be --- /dev/null +++ b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-5.snap @@ -0,0 +1,31 @@ +--- +source: cot-cli/tests/snapshot_testing/new/mod.rs +assertion_line: 39 +description: "Verbosity level: debug" +info: + program: cot + args: + - new + - "--name" + - my_project + - "-vvvv" + - /var/folders/xy/x4cxwym16gdddzy9qmvfngl80000gn/T/cot-test-MkbpS4/project +--- +success: true +exit_code: 0 +----- stdout ----- +TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/Cargo.toml" +TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/Cargo.lock" +TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/bacon.toml" +TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/.gitignore" +TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/src/main.rs" +TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/src/migrations.rs" +TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/static/css/main.css" +TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/templates/index.html" +TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/config/dev.toml" +TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/config/prod.toml.example" +TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/.cargo/config.toml" + +----- stderr ----- + Creating Cot project `my_project` + Created Cot project `my_project` From c41d976a16a7d72c0c3c020ac22d3e4a7365da39 Mon Sep 17 00:00:00 2001 From: Elijah Date: Thu, 18 Dec 2025 19:34:18 +0000 Subject: [PATCH 37/77] try this --- .cargo/config.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index f616e59c..370b5655 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,5 +1,5 @@ [resolver] incompatible-rust-versions = "fallback" -[target.'cfg(target_os = "windows")'] +[target.x86_64-pc-windows-msvc] linker = "rust-lld" From 221b04c82f3c1ce3dc90d8e1f72e47e25b23a0fc Mon Sep 17 00:00:00 2001 From: Elijah Date: Thu, 18 Dec 2025 20:14:06 +0000 Subject: [PATCH 38/77] lets try debug 0 for tests --- Cargo.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 1022dc62..b66472e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -148,3 +148,6 @@ url = "2" [profile.dev.package] insta.opt-level = 3 similar.opt-level = 3 + +[profile.test] +debug = 0 From 07e9dbcf18edfc3a3d5176afb5c96ea53527d98f Mon Sep 17 00:00:00 2001 From: Elijah Date: Fri, 19 Dec 2025 10:13:53 +0000 Subject: [PATCH 39/77] lets try setting symbol mangling version to v0 --- .cargo/config.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/.cargo/config.toml b/.cargo/config.toml index 370b5655..00e7463c 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -3,3 +3,4 @@ incompatible-rust-versions = "fallback" [target.x86_64-pc-windows-msvc] linker = "rust-lld" +rustflags = ["-C", "symbol-mangling-version=v0"] From f4af73566ebbae79cc3f2fe27799863caf8d1ae4 Mon Sep 17 00:00:00 2001 From: Elijah Date: Fri, 19 Dec 2025 23:17:07 +0000 Subject: [PATCH 40/77] pin nightly version --- .github/workflows/rust.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index e5feb877..ff59d80e 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -60,7 +60,7 @@ jobs: - rust: stable version: stable - rust: nightly - version: nightly + version: nightly-2025-11-11 - rust: MSRV version: "1.86" # MSRV @@ -149,7 +149,7 @@ jobs: uses: dtolnay/rust-toolchain@master with: # cot_macros ui tests require nightly - toolchain: nightly + toolchain: nightly-2025-11-11 - name: Cache Cargo registry uses: Swatinem/rust-cache@v2 @@ -195,7 +195,7 @@ jobs: uses: dtolnay/rust-toolchain@master with: # branch coverage is currently optional and requires nightly - toolchain: nightly + toolchain: nightly-2025-11-11 components: llvm-tools-preview - name: Reclaim disk space @@ -248,7 +248,7 @@ jobs: uses: dtolnay/rust-toolchain@master with: # nightly-only rustfmt settings - toolchain: nightly + toolchain: nightly-2025-11-11 components: rustfmt - name: Cache Cargo registry @@ -299,7 +299,7 @@ jobs: uses: dtolnay/rust-toolchain@master with: # the `-Z` flag is only accepted on the nightly channel of Cargo - toolchain: nightly + toolchain: nightly-2025-11-11 - name: Cache Cargo registry uses: Swatinem/rust-cache@v2 @@ -368,7 +368,7 @@ jobs: uses: dtolnay/rust-toolchain@master with: # miri requires nightly - toolchain: nightly + toolchain: nightly-2025-11-11 components: miri - name: Cache Cargo registry From 9e6754e10e90a9bb7e6cd7ac706b024dcb2c2ce4 Mon Sep 17 00:00:00 2001 From: Elijah Date: Sat, 20 Dec 2025 01:12:19 +0000 Subject: [PATCH 41/77] try adding some tests --- cot-cli/src/new_project.rs | 3 +- .../src/project_template/.cargo/config.toml | 2 - ...ot_testing__new__create_new_project-5.snap | 4 +- ...ot_testing__new__create_new_project-6.snap | 3 +- ...create_new_project_with_custom_name-5.snap | 4 +- ...create_new_project_with_custom_name-6.snap | 3 +- cot/src/email.rs | 112 ++++++++++++++++++ cot/src/email/transport/console.rs | 92 ++++++++++++++ cot/src/email/transport/smtp.rs | 16 ++- 9 files changed, 224 insertions(+), 15 deletions(-) delete mode 100644 cot-cli/src/project_template/.cargo/config.toml diff --git a/cot-cli/src/new_project.rs b/cot-cli/src/new_project.rs index a592c6be..1241bc15 100644 --- a/cot-cli/src/new_project.rs +++ b/cot-cli/src/new_project.rs @@ -13,7 +13,7 @@ macro_rules! project_file { }; } -const PROJECT_FILES: [(&str, &str); 11] = [ +const PROJECT_FILES: [(&str, &str); 10] = [ project_file!("Cargo.toml.template"), project_file!("Cargo.lock.template"), project_file!("bacon.toml"), @@ -24,7 +24,6 @@ const PROJECT_FILES: [(&str, &str); 11] = [ project_file!("templates/index.html"), project_file!("config/dev.toml"), project_file!("config/prod.toml.example"), - project_file!(".cargo/config.toml"), ]; #[derive(Debug, Clone, PartialEq, Eq, Hash)] diff --git a/cot-cli/src/project_template/.cargo/config.toml b/cot-cli/src/project_template/.cargo/config.toml deleted file mode 100644 index 4b5f2d40..00000000 --- a/cot-cli/src/project_template/.cargo/config.toml +++ /dev/null @@ -1,2 +0,0 @@ -[target.'cfg(target_os = "windows")'] -linker = "rust-lld" diff --git a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-5.snap b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-5.snap index f154d782..27fe915a 100644 --- a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-5.snap +++ b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-5.snap @@ -1,13 +1,12 @@ --- source: cot-cli/tests/snapshot_testing/new/mod.rs -assertion_line: 19 description: "Verbosity level: debug" info: program: cot args: - new - "-vvvv" - - /var/folders/xy/x4cxwym16gdddzy9qmvfngl80000gn/T/cot-test-ke2aLQ/project + - /var/folders/xy/x4cxwym16gdddzy9qmvfngl80000gn/T/cot-test-RESbdf/project --- success: true exit_code: 0 @@ -22,7 +21,6 @@ TIMESTAMP TRACE cot_cli::new_project: Writing file: "/t TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/templates/index.html" TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/config/dev.toml" TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/config/prod.toml.example" -TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/.cargo/config.toml" ----- stderr -----  Creating Cot project `project` diff --git a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-6.snap b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-6.snap index f84a3536..51b8d45f 100644 --- a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-6.snap +++ b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-6.snap @@ -6,7 +6,7 @@ info: args: - new - "-vvvvv" - - /var/folders/xy/x4cxwym16gdddzy9qmvfngl80000gn/T/cot-test-dvETzt/project + - /var/folders/xy/x4cxwym16gdddzy9qmvfngl80000gn/T/cot-test-wqhqRh/project --- success: true exit_code: 0 @@ -21,7 +21,6 @@ TIMESTAMP TRACE cot_cli::new_project: Writing file: "/t TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/templates/index.html" TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/config/dev.toml" TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/config/prod.toml.example" -TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/.cargo/config.toml" ----- stderr -----  Creating Cot project `project` diff --git a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-5.snap b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-5.snap index cd2224be..cbff5e38 100644 --- a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-5.snap +++ b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-5.snap @@ -1,6 +1,5 @@ --- source: cot-cli/tests/snapshot_testing/new/mod.rs -assertion_line: 39 description: "Verbosity level: debug" info: program: cot @@ -9,7 +8,7 @@ info: - "--name" - my_project - "-vvvv" - - /var/folders/xy/x4cxwym16gdddzy9qmvfngl80000gn/T/cot-test-MkbpS4/project + - /var/folders/xy/x4cxwym16gdddzy9qmvfngl80000gn/T/cot-test-rvlDoU/project --- success: true exit_code: 0 @@ -24,7 +23,6 @@ TIMESTAMP TRACE cot_cli::new_project: Writing file: "/t TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/templates/index.html" TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/config/dev.toml" TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/config/prod.toml.example" -TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/.cargo/config.toml" ----- stderr -----  Creating Cot project `my_project` diff --git a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-6.snap b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-6.snap index 74993180..e7c67219 100644 --- a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-6.snap +++ b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-6.snap @@ -8,7 +8,7 @@ info: - "--name" - my_project - "-vvvvv" - - /var/folders/xy/x4cxwym16gdddzy9qmvfngl80000gn/T/cot-test-O602xQ/project + - /var/folders/xy/x4cxwym16gdddzy9qmvfngl80000gn/T/cot-test-xhoAB3/project --- success: true exit_code: 0 @@ -23,7 +23,6 @@ TIMESTAMP TRACE cot_cli::new_project: Writing file: "/t TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/templates/index.html" TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/config/dev.toml" TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/config/prod.toml.example" -TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/.cargo/config.toml" ----- stderr -----  Creating Cot project `my_project` diff --git a/cot/src/email.rs b/cot/src/email.rs index 4e48b559..36781324 100644 --- a/cot/src/email.rs +++ b/cot/src/email.rs @@ -520,4 +520,116 @@ mod tests { .unwrap(); assert!(email.send_multiple(&[msg1, msg2]).await.is_ok()); } + + #[cot::test] + async fn try_from_basic_converts_and_contains_headers() { + let msg = EmailMessage::builder() + .from(crate::common_types::Email::new("from@example.com").unwrap()) + .to(vec![ + crate::common_types::Email::new("to@example.com").unwrap(), + ]) + .subject("Hello World") + .body("This is the body.") + .build() + .unwrap(); + + let built: Message = Message::try_from(msg).expect("conversion to lettre::Message"); + + let formatted = String::from_utf8_lossy(&built.formatted()).to_string(); + + assert!( + formatted.contains("From: from@example.com"), + "missing From header: {formatted}" + ); + assert!( + formatted.contains("To: to@example.com"), + "missing To header: {formatted}" + ); + assert!( + formatted.contains("Subject: Hello World"), + "missing Subject header: {formatted}" + ); + assert!( + formatted.contains("Content-Type: multipart/mixed"), + "message is not multipart/mixed: {formatted}" + ); + assert!( + formatted.contains("This is the body."), + "body missing from formatted message: {formatted}" + ); + } + + #[cot::test] + async fn try_from_includes_cc_and_reply_to_headers() { + let msg = EmailMessage::builder() + .from(crate::common_types::Email::new("sender@example.com").unwrap()) + .to(vec![ + crate::common_types::Email::new("primary@example.com").unwrap(), + ]) + .cc(vec![ + crate::common_types::Email::new("cc1@example.com").unwrap(), + crate::common_types::Email::new("cc2@example.com").unwrap(), + ]) + .bcc(vec![ + crate::common_types::Email::new("hidden@example.com").unwrap(), + ]) + .reply_to(vec![ + crate::common_types::Email::new("replyto@example.com").unwrap(), + ]) + .subject("Headers Test") + .body("Body") + .build() + .unwrap(); + + let built: Message = Message::try_from(msg).expect("conversion to lettre::Message"); + let formatted = String::from_utf8_lossy(&built.formatted()).to_string(); + + assert!( + formatted.contains("Cc: cc1@example.com, cc2@example.com") + || (formatted.contains("Cc: cc1@example.com") + && formatted.contains("cc2@example.com")), + "Cc header not found or incomplete: {formatted}" + ); + assert!( + formatted.contains("Reply-To: replyto@example.com"), + "Reply-To header missing: {formatted}" + ); + } + + #[cot::test] + async fn try_from_with_attachment_uses_default_mime_on_parse_failure() { + let attachment = AttachmentData { + filename: "report.bin".to_string(), + content_type: "this/is not a valid mime".to_string(), + data: vec![0xDE, 0xAD, 0xBE, 0xEF], + }; + + let msg = EmailMessage::builder() + .from(crate::common_types::Email::new("sender@example.com").unwrap()) + .to(vec![ + crate::common_types::Email::new("to@example.com").unwrap(), + ]) + .subject("Attachment Test") + .body("Please see attachment") + .attachments(vec![attachment]) + .build() + .unwrap(); + + let built: Message = Message::try_from(msg).expect("conversion to lettre::Message"); + let formatted = String::from_utf8_lossy(&built.formatted()).to_string(); + + assert!( + formatted.contains("Content-Disposition: attachment"), + "Attachment disposition missing: {formatted}" + ); + assert!( + formatted.contains("report.bin"), + "Attachment filename missing: {formatted}" + ); + assert!( + formatted.contains("Content-Type: application/octet-stream"), + "Default content type not used for invalid mime: {formatted}" + ); + assert!(formatted.contains("Please see attachment")); + } } diff --git a/cot/src/email/transport/console.rs b/cot/src/email/transport/console.rs index 6c2af0b6..1fed9ffb 100644 --- a/cot/src/email/transport/console.rs +++ b/cot/src/email/transport/console.rs @@ -167,6 +167,8 @@ impl fmt::Display for EmailMessage { #[cfg(test)] mod tests { use super::*; + use crate::common_types::Email as Addr; + use crate::email::{AttachmentData, Email}; #[cot::test] async fn console_error_to_transport_error() { @@ -178,4 +180,94 @@ mod tests { "email transport error: transport error: console transport error: IO error: test error" ); } + + #[cot::test] + async fn display_full_message_renders_all_sections() { + let msg = EmailMessage::builder() + .from(Addr::new("from@example.com").unwrap()) + .to(vec![ + Addr::new("to1@example.com").unwrap(), + Addr::new("to2@example.com").unwrap(), + ]) + .cc(vec![ + Addr::new("cc1@example.com").unwrap(), + Addr::new("cc2@example.com").unwrap(), + ]) + .bcc(vec![Addr::new("bcc@example.com").unwrap()]) + .reply_to(vec![Addr::new("reply@example.com").unwrap()]) + .subject("Subject Line") + .body("Hello body\n") + .attachments(vec![ + AttachmentData { + filename: "a.txt".into(), + content_type: "text/plain".into(), + data: b"abc".to_vec(), + }, + AttachmentData { + filename: "b.pdf".into(), + content_type: "application/pdf".into(), + data: vec![0u8; 10], + }, + ]) + .build() + .unwrap(); + + let console = Console::default(); + let email = Email::new(console); + email + .send(msg.clone()) + .await + .expect("console send should succeed"); + + let rendered = format!("{msg}"); + + assert!(rendered.contains("From : from@example.com")); + assert!(rendered.contains("To : to1@example.com, to2@example.com")); + assert!(rendered.contains("Subject : Subject Line")); + assert!(rendered.contains("────────────────────────────────────────────────────────")); + + assert!(rendered.contains("Cc : cc1@example.com, cc2@example.com")); + assert!(rendered.contains("Bcc : bcc@example.com")); + assert!(rendered.contains("Reply-To: reply@example.com")); + + assert!(rendered.contains("Hello body")); + + assert!(rendered.contains("Attachments (2):")); + assert!(rendered.contains(" - a.txt (3 bytes, text/plain)")); + assert!(rendered.contains(" - b.pdf (10 bytes, application/pdf)")); + + assert!( + rendered.contains("════════════════════════════════════════════════════════════════") + ); + } + + #[cot::test] + async fn display_minimal_message_renders_placeholders_and_omits_optional_headers() { + let msg = EmailMessage::builder() + .from(Addr::new("sender@example.com").unwrap()) + // whitespace-only body should render as + .body(" \t\n ") + .build() + .unwrap(); + + let console = Console::default(); + let email = Email::new(console); + email + .send(msg.clone()) + .await + .expect("console send should succeed"); + + let rendered = format!("{msg}"); + + assert!(rendered.contains("From : sender@example.com")); + assert!(rendered.contains("To : -")); + assert!(rendered.contains("Subject : -")); + + assert!(!rendered.contains("Cc :")); + assert!(!rendered.contains("Bcc :")); + assert!(!rendered.contains("Reply-To:")); + + assert!(rendered.contains("")); + assert!(rendered.contains("Attachments: -")); + } } diff --git a/cot/src/email/transport/smtp.rs b/cot/src/email/transport/smtp.rs index 5f43add5..419af462 100644 --- a/cot/src/email/transport/smtp.rs +++ b/cot/src/email/transport/smtp.rs @@ -154,7 +154,9 @@ impl Transport for Smtp { #[cfg(test)] mod tests { - use super::*; + use lettre::transport::smtp; + + use super::*; // ensure access to lettre's Mechanism in this scope #[cot::test] async fn test_smtp_creation() { @@ -186,4 +188,16 @@ mod tests { "email transport error: transport error: smtp transport error: IO error: test" ); } + + #[cot::test] + async fn mechanism_from_maps_all_cases() { + let m_plain: smtp::authentication::Mechanism = Mechanism::Plain.into(); + assert_eq!(m_plain, smtp::authentication::Mechanism::Plain); + + let m_login: smtp::authentication::Mechanism = Mechanism::Login.into(); + assert_eq!(m_login, smtp::authentication::Mechanism::Login); + + let m_xoauth2: smtp::authentication::Mechanism = Mechanism::Xoauth2.into(); + assert_eq!(m_xoauth2, smtp::authentication::Mechanism::Xoauth2); + } } From e2c6790eebacbd618fc6af6cabc82b2a0f86ba38 Mon Sep 17 00:00:00 2001 From: Elijah Date: Sat, 20 Dec 2025 01:42:07 +0000 Subject: [PATCH 42/77] we need the linker change --- cot-cli/src/new_project.rs | 3 ++- cot-cli/src/project_template/.cargo/config.toml | 2 ++ .../cli__snapshot_testing__new__create_new_project-5.snap | 3 ++- .../cli__snapshot_testing__new__create_new_project-6.snap | 3 ++- ...ot_testing__new__create_new_project_with_custom_name-5.snap | 3 ++- ...ot_testing__new__create_new_project_with_custom_name-6.snap | 3 ++- cot/src/email/transport/console.rs | 3 ++- 7 files changed, 14 insertions(+), 6 deletions(-) create mode 100644 cot-cli/src/project_template/.cargo/config.toml diff --git a/cot-cli/src/new_project.rs b/cot-cli/src/new_project.rs index 1241bc15..a592c6be 100644 --- a/cot-cli/src/new_project.rs +++ b/cot-cli/src/new_project.rs @@ -13,7 +13,7 @@ macro_rules! project_file { }; } -const PROJECT_FILES: [(&str, &str); 10] = [ +const PROJECT_FILES: [(&str, &str); 11] = [ project_file!("Cargo.toml.template"), project_file!("Cargo.lock.template"), project_file!("bacon.toml"), @@ -24,6 +24,7 @@ const PROJECT_FILES: [(&str, &str); 10] = [ project_file!("templates/index.html"), project_file!("config/dev.toml"), project_file!("config/prod.toml.example"), + project_file!(".cargo/config.toml"), ]; #[derive(Debug, Clone, PartialEq, Eq, Hash)] diff --git a/cot-cli/src/project_template/.cargo/config.toml b/cot-cli/src/project_template/.cargo/config.toml new file mode 100644 index 00000000..4b5f2d40 --- /dev/null +++ b/cot-cli/src/project_template/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.'cfg(target_os = "windows")'] +linker = "rust-lld" diff --git a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-5.snap b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-5.snap index 27fe915a..d94ef43b 100644 --- a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-5.snap +++ b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-5.snap @@ -6,7 +6,7 @@ info: args: - new - "-vvvv" - - /var/folders/xy/x4cxwym16gdddzy9qmvfngl80000gn/T/cot-test-RESbdf/project + - /var/folders/xy/x4cxwym16gdddzy9qmvfngl80000gn/T/cot-test-JQ0H2S/project --- success: true exit_code: 0 @@ -21,6 +21,7 @@ TIMESTAMP TRACE cot_cli::new_project: Writing file: "/t TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/templates/index.html" TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/config/dev.toml" TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/config/prod.toml.example" +TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/.cargo/config.toml" ----- stderr -----  Creating Cot project `project` diff --git a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-6.snap b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-6.snap index 51b8d45f..cd7a3de3 100644 --- a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-6.snap +++ b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-6.snap @@ -6,7 +6,7 @@ info: args: - new - "-vvvvv" - - /var/folders/xy/x4cxwym16gdddzy9qmvfngl80000gn/T/cot-test-wqhqRh/project + - /var/folders/xy/x4cxwym16gdddzy9qmvfngl80000gn/T/cot-test-WPGfFG/project --- success: true exit_code: 0 @@ -21,6 +21,7 @@ TIMESTAMP TRACE cot_cli::new_project: Writing file: "/t TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/templates/index.html" TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/config/dev.toml" TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/config/prod.toml.example" +TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/.cargo/config.toml" ----- stderr -----  Creating Cot project `project` diff --git a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-5.snap b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-5.snap index cbff5e38..ef598073 100644 --- a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-5.snap +++ b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-5.snap @@ -8,7 +8,7 @@ info: - "--name" - my_project - "-vvvv" - - /var/folders/xy/x4cxwym16gdddzy9qmvfngl80000gn/T/cot-test-rvlDoU/project + - /var/folders/xy/x4cxwym16gdddzy9qmvfngl80000gn/T/cot-test-EjKoWa/project --- success: true exit_code: 0 @@ -23,6 +23,7 @@ TIMESTAMP TRACE cot_cli::new_project: Writing file: "/t TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/templates/index.html" TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/config/dev.toml" TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/config/prod.toml.example" +TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/.cargo/config.toml" ----- stderr -----  Creating Cot project `my_project` diff --git a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-6.snap b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-6.snap index e7c67219..e7a44f83 100644 --- a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-6.snap +++ b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-6.snap @@ -8,7 +8,7 @@ info: - "--name" - my_project - "-vvvvv" - - /var/folders/xy/x4cxwym16gdddzy9qmvfngl80000gn/T/cot-test-xhoAB3/project + - /var/folders/xy/x4cxwym16gdddzy9qmvfngl80000gn/T/cot-test-HPo3ol/project --- success: true exit_code: 0 @@ -23,6 +23,7 @@ TIMESTAMP TRACE cot_cli::new_project: Writing file: "/t TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/templates/index.html" TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/config/dev.toml" TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/config/prod.toml.example" +TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/.cargo/config.toml" ----- stderr -----  Creating Cot project `my_project` diff --git a/cot/src/email/transport/console.rs b/cot/src/email/transport/console.rs index 1fed9ffb..2f3c563f 100644 --- a/cot/src/email/transport/console.rs +++ b/cot/src/email/transport/console.rs @@ -212,7 +212,7 @@ mod tests { .build() .unwrap(); - let console = Console::default(); + let console = Console::new(); let email = Email::new(console); email .send(msg.clone()) @@ -242,6 +242,7 @@ mod tests { } #[cot::test] + #[expect(clippy::default_constructed_unit_structs)] async fn display_minimal_message_renders_placeholders_and_omits_optional_headers() { let msg = EmailMessage::builder() .from(Addr::new("sender@example.com").unwrap()) From 5b1b5771f171601ec8bc60f311259687da9e0c9e Mon Sep 17 00:00:00 2001 From: Elijah Date: Sat, 20 Dec 2025 05:46:13 +0000 Subject: [PATCH 43/77] small test for config --- cot/src/config.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/cot/src/config.rs b/cot/src/config.rs index 9109e6d8..594dd996 100644 --- a/cot/src/config.rs +++ b/cot/src/config.rs @@ -3083,7 +3083,7 @@ mod tests { #[cfg(feature = "email")] fn email_config_from_toml_smtp() { let toml_content = r#" - [email] + [email.transport] type = "smtp" url = "smtp://user:pass@hostname:587" mechanism = "plain" @@ -3107,4 +3107,14 @@ mod tests { EmailTransportTypeConfig::Console ); } + + #[test] + #[cfg(feature = "email")] + fn email_url_from_str_and_string() { + let s = "smtp://user:pass@hostname:587"; + let u1 = EmailUrl::from(s); + let u2 = EmailUrl::from(s.to_string()); + assert_eq!(u1, u2); + assert_eq!(u1.as_str(), s); + } } From b460806ccf7153319018da7fe0eb4c780cee4c18 Mon Sep 17 00:00:00 2001 From: Elijah Date: Sat, 20 Dec 2025 06:37:44 +0000 Subject: [PATCH 44/77] increase cov a bit --- cot/src/email.rs | 23 ++++------------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/cot/src/email.rs b/cot/src/email.rs index 36781324..d1076f17 100644 --- a/cot/src/email.rs +++ b/cot/src/email.rs @@ -458,8 +458,6 @@ mod tests { }, }; let _email = Email::from_config(&cfg); - // We can't introspect the inner transport, but construction should not - // panic. } #[cot::test] @@ -588,12 +586,8 @@ mod tests { formatted.contains("Cc: cc1@example.com, cc2@example.com") || (formatted.contains("Cc: cc1@example.com") && formatted.contains("cc2@example.com")), - "Cc header not found or incomplete: {formatted}" - ); - assert!( - formatted.contains("Reply-To: replyto@example.com"), - "Reply-To header missing: {formatted}" ); + assert!(formatted.contains("Reply-To: replyto@example.com"),); } #[cot::test] @@ -618,18 +612,9 @@ mod tests { let built: Message = Message::try_from(msg).expect("conversion to lettre::Message"); let formatted = String::from_utf8_lossy(&built.formatted()).to_string(); - assert!( - formatted.contains("Content-Disposition: attachment"), - "Attachment disposition missing: {formatted}" - ); - assert!( - formatted.contains("report.bin"), - "Attachment filename missing: {formatted}" - ); - assert!( - formatted.contains("Content-Type: application/octet-stream"), - "Default content type not used for invalid mime: {formatted}" - ); + assert!(formatted.contains("Content-Disposition: attachment"),); + assert!(formatted.contains("report.bin"),); + assert!(formatted.contains("Content-Type: application/octet-stream"),); assert!(formatted.contains("Please see attachment")); } } From 92baad4f9e676224df0e9bff2530dd04363cd905 Mon Sep 17 00:00:00 2001 From: Elijah Date: Sun, 21 Dec 2025 05:18:23 +0000 Subject: [PATCH 45/77] some improvements including docs --- cot/src/config.rs | 26 +++++---- cot/src/email.rs | 93 ++++++++++++++---------------- cot/src/email/transport.rs | 4 +- cot/src/email/transport/console.rs | 7 ++- cot/src/email/transport/smtp.rs | 21 ++++--- 5 files changed, 75 insertions(+), 76 deletions(-) diff --git a/cot/src/config.rs b/cot/src/config.rs index 594dd996..f2273abd 100644 --- a/cot/src/config.rs +++ b/cot/src/config.rs @@ -261,8 +261,8 @@ pub struct ProjectConfig { /// /// let config = ProjectConfig::from_toml( /// r#" - /// [email_backend] - /// type = "none" + /// [email.backend] + /// type = "console" /// "#, /// )?; /// @@ -1827,23 +1827,24 @@ impl Default for SessionMiddlewareConfig { /// The type of email backend to use. /// -/// This specifies what email backend is used for sending emails: `console` or -/// `smtp`. The default backend if not specified is `console`. +/// This specifies what email backend is used for sending emails. +/// The default backend if not specified is `console`. #[cfg(feature = "email")] #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum EmailTransportTypeConfig { /// Console email transport. /// - /// This is a convenient backend for development and testing that simply - /// prints the email contents to the console instead of actually sending - /// them. + /// This is a convenient transport backend for development and testing that + /// simply prints the email contents to the console instead of actually + /// sending them. #[default] Console, /// SMTP email backend. /// - /// This backend sends emails using the Simple Mail Transfer Protocol - /// (SMTP). It requires authentication details and server configuration. + /// This transport backend sends emails using the Simple Mail Transfer + /// Protocol (SMTP). It requires authentication details and server + /// configuration. Smtp { /// The SMTP connection URL. /// @@ -1871,7 +1872,7 @@ pub enum EmailTransportTypeConfig { /// use cot::email::transport::smtp::Mechanism; /// /// let smtp_config = EmailTransportTypeConfig::Smtp { - /// url: EmailUrl::from("smtps://username:password@smtp.gmail.com?tls=required"), + /// url: EmailUrl::from("smtps://johndoe:xxxx xxxxx xxxx xxxxx@smtp.gmail.com"), /// mechanism: Mechanism::Plain, /// }; /// ``` @@ -1881,7 +1882,8 @@ pub enum EmailTransportTypeConfig { /// ```toml /// [email] /// type = "smtp" - /// url = "smtps://username:password@smtp.gmail.com?tls=required" + /// // If email is "johndoe@gmail.com", then the user is "johndoe" + /// url = "smtp://johndoe:xxxx xxxx xxxx xxxx@smtp.gmail.com:587?tls=required" /// ``` url: EmailUrl, /// The authentication mechanism to use. @@ -1892,7 +1894,7 @@ pub enum EmailTransportTypeConfig { /// ```toml /// [email] /// type = "smtp" - /// url = "smtps://username:password@smtp.gmail.com?tls=required" + /// url = "smtp://johndoe:xxxx xxxx xxxx xxxx@smtp.gmail.com:587?tls=required" /// mechanism = "plain" # or "login", "xoauth2" /// ``` mechanism: Mechanism, diff --git a/cot/src/email.rs b/cot/src/email.rs index d1076f17..6a1fb1a4 100644 --- a/cot/src/email.rs +++ b/cot/src/email.rs @@ -43,6 +43,7 @@ use crate::email::transport::console::Console; use crate::error::error_impl::impl_into_cot_error; const ERROR_PREFIX: &str = "email error:"; +const EMAIL_MESSAGE_BUILD_ERROR_PREFIX: &str = "email message build error:"; /// Represents errors that can occur when sending an email. #[derive(Debug, Error)] @@ -54,9 +55,6 @@ pub enum EmailError { /// An error occurred while building the email message. #[error("{ERROR_PREFIX} message error: {0}")] Message(String), - /// A required field is missing in the email message. - #[error("{ERROR_PREFIX} missing required field: {0}")] - MissingField(String), } impl_into_cot_error!(EmailError); @@ -146,11 +144,11 @@ impl EmailMessageBuilder { /// .build() /// .unwrap(); /// ``` - pub fn build(&self) -> Result { + pub fn build(&self) -> Result { let from = self .from .clone() - .ok_or_else(|| EmailError::MissingField("from".to_string()))?; + .ok_or_else(|| EmailMessageError::MissingField("from".to_string()))?; let subject = self.subject.clone().unwrap_or_default(); let body = self.body.clone().unwrap_or_default(); @@ -177,17 +175,22 @@ impl EmailMessageBuilder { /// Errors that can occur while building an email message. #[derive(Debug, Clone, Error)] #[non_exhaustive] -pub enum MessageBuildError { +pub enum EmailMessageError { /// An invalid email address was provided. - #[error("invalid email address: {0}")] + #[error("{EMAIL_MESSAGE_BUILD_ERROR_PREFIX} invalid email address: {0}")] InvalidEmailAddress(String), /// Failed to build the email message. - #[error("failed to build email message: {0}")] + #[error("{EMAIL_MESSAGE_BUILD_ERROR_PREFIX} failed to build email message: {0}")] BuildError(String), + /// A required field is missing in the email message. + #[error("{EMAIL_MESSAGE_BUILD_ERROR_PREFIX} The `{0}` field is required but was not set")] + MissingField(String), } +impl_into_cot_error!(EmailMessageError); + impl TryFrom for Message { - type Error = MessageBuildError; + type Error = EmailMessageError; fn try_from(message: EmailMessage) -> Result { let from_mailbox = message @@ -195,7 +198,7 @@ impl TryFrom for Message { .email() .as_str() .parse::() - .map_err(|err| MessageBuildError::InvalidEmailAddress(err.to_string()))?; + .map_err(|err| EmailMessageError::InvalidEmailAddress(err.to_string()))?; let mut builder = Message::builder() .from(from_mailbox) @@ -206,7 +209,7 @@ impl TryFrom for Message { .email() .as_str() .parse::() - .map_err(|err| MessageBuildError::InvalidEmailAddress(err.to_string()))?; + .map_err(|err| EmailMessageError::InvalidEmailAddress(err.to_string()))?; builder = builder.to(mb); } @@ -215,7 +218,7 @@ impl TryFrom for Message { .email() .as_str() .parse::() - .map_err(|err| MessageBuildError::InvalidEmailAddress(err.to_string()))?; + .map_err(|err| EmailMessageError::InvalidEmailAddress(err.to_string()))?; builder = builder.cc(mb); } @@ -224,7 +227,7 @@ impl TryFrom for Message { .email() .as_str() .parse::() - .map_err(|err| MessageBuildError::InvalidEmailAddress(err.to_string()))?; + .map_err(|err| EmailMessageError::InvalidEmailAddress(err.to_string()))?; builder = builder.bcc(mb); } @@ -233,7 +236,7 @@ impl TryFrom for Message { .email() .as_str() .parse::() - .map_err(|err| MessageBuildError::InvalidEmailAddress(err.to_string()))?; + .map_err(|err| EmailMessageError::InvalidEmailAddress(err.to_string()))?; builder = builder.reply_to(mb); } @@ -252,15 +255,15 @@ impl TryFrom for Message { let email = builder .multipart(mixed) - .map_err(|err| MessageBuildError::BuildError(err.to_string()))?; + .map_err(|err| EmailMessageError::BuildError(err.to_string()))?; Ok(email) } } -/// A high level email interface for sending emails. +/// A high-level email interface for sending emails. /// -/// This struct wraps a [`Transport`] implementation to provide -/// methods for sending single or multiple email messages. +/// This struct wraps a [`Transport`] implementation and provides +/// convenient methods for sending single or multiple email messages. /// /// # Examples /// @@ -307,7 +310,7 @@ impl Email { /// /// # Errors /// - /// Returns an `EmailError` if sending the email fails. + /// Returns an `EmailError::Transport` error if sending the email fails. /// /// # Examples /// @@ -340,7 +343,7 @@ impl Email { /// /// # Errors /// - /// Returns an `EmailError` if sending any of the emails fails. + /// Returns an `EmailError::Transport` if sending any of the emails fails. /// /// # Examples /// @@ -380,8 +383,8 @@ impl Email { /// /// # Errors /// - /// Returns an `EmailError` if creating the transport backend fails from the - /// config. + /// Returns an `EmailError::Transport` error if creating the transport + /// backend fails from the config. /// /// # Examples /// @@ -421,7 +424,8 @@ impl Email { #[cfg(test)] mod tests { use super::*; - use crate::config::EmailUrl; + use crate::config::{EmailTransportConfig, EmailUrl}; + use crate::email::transport::smtp::Mechanism; #[cot::test] async fn builder_errors_when_from_missing() { @@ -431,7 +435,10 @@ mod tests { .build(); assert!(res.is_err()); let err = res.err().unwrap(); - assert_eq!(err.to_string(), "email error: missing required field: from"); + assert_eq!( + err.to_string(), + "email message build error: The `from` field is required but was not set" + ); } #[cot::test] @@ -453,27 +460,26 @@ mod tests { async fn from_config_console_builds() { use crate::config::{EmailConfig, EmailTransportTypeConfig}; let cfg = EmailConfig { - transport: crate::config::EmailTransportConfig { + transport: EmailTransportConfig { transport_type: EmailTransportTypeConfig::Console, }, }; - let _email = Email::from_config(&cfg); + let email = Email::from_config(&cfg); + assert!(email.is_ok()); } #[cot::test] async fn from_config_smtp_builds() { - use crate::config::{EmailConfig, EmailTransportTypeConfig}; - use crate::email::transport::smtp::Mechanism; - let cfg = EmailConfig { - transport: crate::config::EmailTransportConfig { + transport: EmailTransportConfig { transport_type: EmailTransportTypeConfig::Smtp { url: EmailUrl::from("smtp://localhost:1025"), mechanism: Mechanism::Plain, }, }, }; - let _email = Email::from_config(&cfg); + let email = Email::from_config(&cfg); + assert!(email.is_ok()); } #[cot::test] @@ -535,26 +541,11 @@ mod tests { let formatted = String::from_utf8_lossy(&built.formatted()).to_string(); - assert!( - formatted.contains("From: from@example.com"), - "missing From header: {formatted}" - ); - assert!( - formatted.contains("To: to@example.com"), - "missing To header: {formatted}" - ); - assert!( - formatted.contains("Subject: Hello World"), - "missing Subject header: {formatted}" - ); - assert!( - formatted.contains("Content-Type: multipart/mixed"), - "message is not multipart/mixed: {formatted}" - ); - assert!( - formatted.contains("This is the body."), - "body missing from formatted message: {formatted}" - ); + assert!(formatted.contains("From: from@example.com"),); + assert!(formatted.contains("To: to@example.com"),); + assert!(formatted.contains("Subject: Hello World"),); + assert!(formatted.contains("Content-Type: multipart/mixed"),); + assert!(formatted.contains("This is the body."),); } #[cot::test] diff --git a/cot/src/email/transport.rs b/cot/src/email/transport.rs index 5621535b..9f6411b6 100644 --- a/cot/src/email/transport.rs +++ b/cot/src/email/transport.rs @@ -5,7 +5,7 @@ //! transport operations. use std::pin::Pin; -use cot::email::MessageBuildError; +use cot::email::EmailMessageError; use thiserror::Error; use crate::email::EmailMessage; @@ -25,7 +25,7 @@ pub enum TransportError { Backend(String), /// Failed to build the email message. #[error("{ERROR_PREFIX} message build error: {0}")] - MessageBuildError(#[from] MessageBuildError), + MessageBuildError(#[from] EmailMessageError), } impl_into_cot_error!(TransportError); diff --git a/cot/src/email/transport/console.rs b/cot/src/email/transport/console.rs index 2f3c563f..fc394529 100644 --- a/cot/src/email/transport/console.rs +++ b/cot/src/email/transport/console.rs @@ -4,14 +4,17 @@ //! It is intended primarily for development and testing environments where //! actually sending email is not required. //! -//! Typical usage is through the high-level [`crate::email::Email`] API: +//! Typical usage is through the high-level [`crate::email::Email`] API +//! +//! ## Examples //! //! ```no_run //! use cot::common_types::Email; //! use cot::email::EmailMessage; //! use cot::email::transport::console::Console; //! -//! # async fn run() -> Result<(), Box> { +//! # #[tokio::main] +//! # async fn main() -> cot::Result<()>{ //! let email = cot::email::Email::new(Console::new()); //! let recipients = vec![Email::try_from("testreceipient@example.com").unwrap()]; //! let msg = EmailMessage::builder() diff --git a/cot/src/email/transport/smtp.rs b/cot/src/email/transport/smtp.rs index 419af462..34567582 100644 --- a/cot/src/email/transport/smtp.rs +++ b/cot/src/email/transport/smtp.rs @@ -1,8 +1,7 @@ //! SMTP transport implementation. //! -//! This backend uses the `lettre` crate to send messages to a remote SMTP -//! server. Credentials, server host and authentication mechanism are -//! configurable. +//! This backend sends email messages to a configured remote SMTP +//! server. //! //! Typical usage is through the high-level [`crate::email::Email`] API: //! @@ -69,6 +68,8 @@ pub enum Mechanism { Plain, /// LOGIN authentication mechanism defined in /// [draft-murchison-sasl-login-00](https://www.ietf.org/archive/id/draft-murchison-sasl-login-00.txt). + /// This mechanism is obsolete but needed for some providers (like Office + /// 365). Login, /// Non-standard XOAUTH2 mechanism defined in /// [xoauth2-protocol](https://developers.google.com/gmail/imap/xoauth2-protocol) @@ -96,16 +97,17 @@ impl From for smtp::authentication::Mechanism { /// use cot::common_types::Email; /// use cot::config::EmailUrl; /// +/// # [tokio::main] /// # async fn run() -> cot::Result<()> { -/// let url = EmailUrl::from("smtps://username:password@smtp.gmail.com?tls=required"); +/// let url = EmailUrl::from("smtps://johndoe:xxxx xxxxx xxxx xxxxx@smtp.gmail.com"); /// let smtp = Smtp::new(&url, Mechanism::Plain)?; /// let email = cot::email::Email::new(smtp); /// /// let msg = EmailMessage::builder() /// .from(Email::try_from("testfrom@example.com").unwrap()) -/// .to(vec![Email::try_from("testreceipient@example.com").unwrap()]) -/// .body("This is a test email.") -/// .build()?; +/// .to(vec![Email::try_from("testreceipient@example.com").unwrap()]) +/// .body("This is a test email.") +/// .build()?; /// email.send(msg).await?; /// # Ok(()) } #[derive(Debug, Clone)] @@ -118,7 +120,8 @@ impl Smtp { /// /// # Errors /// - /// Returns a `TransportError` if the Smtp backend creation failed. + /// Returns an `SMTP::TransportCreationError` if the Smtp backend creation + /// failed. /// /// # Examples /// @@ -156,7 +159,7 @@ impl Transport for Smtp { mod tests { use lettre::transport::smtp; - use super::*; // ensure access to lettre's Mechanism in this scope + use super::*; #[cot::test] async fn test_smtp_creation() { From 572b168a296d5ebdcdced2897ddffe45414a4968 Mon Sep 17 00:00:00 2001 From: Elijah Date: Sun, 21 Dec 2025 05:21:06 +0000 Subject: [PATCH 46/77] doc fix --- cot/src/email/transport/smtp.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cot/src/email/transport/smtp.rs b/cot/src/email/transport/smtp.rs index 34567582..7062a92d 100644 --- a/cot/src/email/transport/smtp.rs +++ b/cot/src/email/transport/smtp.rs @@ -97,7 +97,7 @@ impl From for smtp::authentication::Mechanism { /// use cot::common_types::Email; /// use cot::config::EmailUrl; /// -/// # [tokio::main] +/// # #[tokio::main] /// # async fn run() -> cot::Result<()> { /// let url = EmailUrl::from("smtps://johndoe:xxxx xxxxx xxxx xxxxx@smtp.gmail.com"); /// let smtp = Smtp::new(&url, Mechanism::Plain)?; From 8154b2047c2ebb847bab2b58d93846c5dfffa326 Mon Sep 17 00:00:00 2001 From: Elijah Date: Sun, 21 Dec 2025 06:46:41 +0000 Subject: [PATCH 47/77] some minor fixes --- cot-cli/src/project_template/config/dev.toml | 3 +++ cot-cli/src/project_template/config/prod.toml.example | 7 +++++++ cot/src/config.rs | 2 -- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/cot-cli/src/project_template/config/dev.toml b/cot-cli/src/project_template/config/dev.toml index 9e5f0b9a..82213517 100644 --- a/cot-cli/src/project_template/config/dev.toml +++ b/cot-cli/src/project_template/config/dev.toml @@ -18,3 +18,6 @@ secure = false [middlewares.session.store] type = "database" + +[email.transport] +type = "console" diff --git a/cot-cli/src/project_template/config/prod.toml.example b/cot-cli/src/project_template/config/prod.toml.example index 0d692daf..82f3c3fc 100644 --- a/cot-cli/src/project_template/config/prod.toml.example +++ b/cot-cli/src/project_template/config/prod.toml.example @@ -15,3 +15,10 @@ cache_timeout = "1year" [middlewares.session.store] type = "database" + +[email.transport] +type = "console" +# Or: +# type = "smtp" +# url = "smtps://user:password@smtp.gmail.com" +# mechanism = "plain" diff --git a/cot/src/config.rs b/cot/src/config.rs index f2273abd..7802e31c 100644 --- a/cot/src/config.rs +++ b/cot/src/config.rs @@ -2488,8 +2488,6 @@ mod tests { path = "/some/path" always_save = true name = "some.sid" - [email_backend] - type = "none" "#; let config = ProjectConfig::from_toml(toml_content).unwrap(); From b5da565f64f40d65a5a23acf97de36b313cd809e Mon Sep 17 00:00:00 2001 From: Elijah Date: Tue, 23 Dec 2025 21:21:57 +0000 Subject: [PATCH 48/77] remove profile test --- Cargo.toml | 3 --- 1 file changed, 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b66472e6..1022dc62 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -148,6 +148,3 @@ url = "2" [profile.dev.package] insta.opt-level = 3 similar.opt-level = 3 - -[profile.test] -debug = 0 From 9019d552da0337c7090f814c1c8c6ee76f5c9b75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ma=C4=87kowski?= Date: Wed, 24 Dec 2025 13:24:32 +0100 Subject: [PATCH 49/77] bump askama --- Cargo.lock | 62 +++++++++--------------------------------------------- 1 file changed, 10 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 10e98158..5c624084 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -160,19 +160,6 @@ dependencies = [ "password-hash", ] -[[package]] -name = "askama" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4" -dependencies = [ - "askama_derive 0.14.0", - "itoa", - "percent-encoding", - "serde", - "serde_json", -] - [[package]] name = "askama" version = "0.15.0" @@ -186,30 +173,13 @@ dependencies = [ "serde_json", ] -[[package]] -name = "askama_derive" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f" -dependencies = [ - "askama_parser 0.14.0", - "basic-toml", - "memchr", - "proc-macro2", - "quote", - "rustc-hash", - "serde", - "serde_derive", - "syn", -] - [[package]] name = "askama_derive" version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e393a25182a000666df345c2bdaa9ab6b0c243974ec7d29e377d6feea651778d" dependencies = [ - "askama_parser 0.15.0", + "askama_parser", "basic-toml", "memchr", "proc-macro2", @@ -226,19 +196,7 @@ version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e8bb77385d2954705452516f99511835be34b4c4a15a70800623b91406b1db9" dependencies = [ - "askama_derive 0.15.0", -] - -[[package]] -name = "askama_parser" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358" -dependencies = [ - "memchr", - "serde", - "serde_derive", - "winnow", + "askama_derive", ] [[package]] @@ -901,7 +859,7 @@ version = "0.4.0" dependencies = [ "ahash", "aide", - "askama 0.15.0", + "askama", "async-stream", "async-trait", "axum", @@ -1487,7 +1445,7 @@ dependencies = [ name = "example-admin" version = "0.1.0" dependencies = [ - "askama 0.15.0", + "askama", "async-trait", "cot", ] @@ -1496,7 +1454,7 @@ dependencies = [ name = "example-custom-error-pages" version = "0.1.0" dependencies = [ - "askama 0.15.0", + "askama", "cot", ] @@ -1513,7 +1471,7 @@ dependencies = [ name = "example-file-upload" version = "0.1.0" dependencies = [ - "askama 0.15.0", + "askama", "base64", "cot", ] @@ -1538,7 +1496,7 @@ dependencies = [ name = "example-sessions" version = "0.1.0" dependencies = [ - "askama 0.15.0", + "askama", "cot", ] @@ -1546,7 +1504,7 @@ dependencies = [ name = "example-todo-list" version = "0.1.0" dependencies = [ - "askama 0.15.0", + "askama", "cot", ] @@ -1656,7 +1614,7 @@ dependencies = [ name = "forms" version = "0.1.0" dependencies = [ - "askama 0.15.0", + "askama", "chrono", "chrono-tz", "cot", @@ -3526,7 +3484,7 @@ checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" name = "send-email" version = "0.1.0" dependencies = [ - "askama 0.14.0", + "askama", "cot", "serde", ] From 4fbdf392d2cb733b54e6d5e3cd33702b4e4fddd1 Mon Sep 17 00:00:00 2001 From: Elijah Date: Thu, 25 Dec 2025 12:21:43 +0000 Subject: [PATCH 50/77] address most of the PR comments --- .cargo/config.toml | 2 + .../src/project_template/.cargo/config.toml | 2 + cot/src/config.rs | 2 +- cot/src/email.rs | 74 +++++++++---------- cot/src/email/transport.rs | 1 + cot/src/email/transport/console.rs | 2 +- cot/src/email/transport/smtp.rs | 2 +- cot/src/project.rs | 11 ++- cot/src/request.rs | 6 +- cot/src/test.rs | 4 +- examples/send-email/Cargo.toml | 4 +- examples/send-email/src/main.rs | 5 +- examples/send-email/templates/index.html | 9 ++- 13 files changed, 65 insertions(+), 59 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index 00e7463c..fe6eda1c 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,6 +1,8 @@ [resolver] incompatible-rust-versions = "fallback" +# TODO: Safe to remove once https://github.com/rust-lang/rust/issues/141626 gets resolved. +# Also, see https://github.com/cot-rs/cot/pull/419/changes#r2636869773 for more info. [target.x86_64-pc-windows-msvc] linker = "rust-lld" rustflags = ["-C", "symbol-mangling-version=v0"] diff --git a/cot-cli/src/project_template/.cargo/config.toml b/cot-cli/src/project_template/.cargo/config.toml index 4b5f2d40..d1eaf1cb 100644 --- a/cot-cli/src/project_template/.cargo/config.toml +++ b/cot-cli/src/project_template/.cargo/config.toml @@ -1,2 +1,4 @@ +# TODO: Safe to remove when https://github.com/rust-lang/rust/issues/141626 gets resolved. +# Also, see https://github.com/cot-rs/cot/pull/419/changes#r2636869773 for more info. [target.'cfg(target_os = "windows")'] linker = "rust-lld" diff --git a/cot/src/config.rs b/cot/src/config.rs index 7802e31c..42fae484 100644 --- a/cot/src/config.rs +++ b/cot/src/config.rs @@ -2031,7 +2031,7 @@ pub struct EmailConfig { #[cfg(feature = "email")] impl EmailConfig { - /// Create a new [`EmailBackendConfigBuilder`] to build a + /// Create a new [`EmailConfigBuilder`] to build an /// [`EmailConfig`]. /// /// # Examples diff --git a/cot/src/email.rs b/cot/src/email.rs index 6a1fb1a4..0692442a 100644 --- a/cot/src/email.rs +++ b/cot/src/email.rs @@ -8,7 +8,7 @@ //! //! Send using the console transport backend (prints nicely formatted messages): //! -//! ```no_run +//! ``` //! use cot::common_types::Email; //! use cot::email::EmailMessage; //! use cot::email::transport::console::Console; @@ -66,11 +66,11 @@ pub type EmailResult = Result; #[derive(Debug, Clone)] pub struct AttachmentData { /// The filename to display for the attachment. - filename: String, + pub filename: String, /// The MIME content type of the attachment (e.g., `image/png`). - content_type: String, + pub content_type: String, /// The raw bytes of the attachment. - data: Vec, + pub data: Vec, } /// A high-level email message representation. @@ -267,21 +267,21 @@ impl TryFrom for Message { /// /// # Examples /// -/// ```no_run +/// ``` /// use cot::email::EmailMessage; /// use cot::email::transport::console::Console; /// use cot::common_types::Email; /// /// # #[tokio::main] /// # async fn main() -> cot::Result<()> { -/// let email = cot::email::Email::new(Console::new()); -/// let message = EmailMessage::builder() -/// .from(Email::try_from("no-reply@example.com").unwrap()) -/// .to(vec![Email::try_from("user@example.com").unwrap()]) -/// .subject("Greetings") -/// .body("Hello from cot!") -/// .build()?; -/// email.send(message).await?; +/// let email = cot::email::Email::new(Console::new()); +/// let message = EmailMessage::builder() +/// .from(Email::try_from("no-reply@example.com").unwrap()) +/// .to(vec![Email::try_from("user@example.com").unwrap()]) +/// .subject("Greetings") +/// .body("Hello from cot!") +/// .build()?; +/// email.send(message).await?; /// # Ok(()) /// } /// ``` @@ -314,21 +314,21 @@ impl Email { /// /// # Examples /// - /// ```no_run + /// ``` /// use cot::email::EmailMessage; /// use cot::email::transport::console::Console; /// use cot::common_types::Email; /// /// # #[tokio::main] /// # async fn main() -> cot::Result<()> { - /// let email = cot::email::Email::new(Console::new()); - /// let message = EmailMessage::builder() - /// .from(Email::try_from("no-reply@example.com").unwrap()) - /// .to(vec![Email::try_from("user@example.com").unwrap()]) - /// .subject("Greetings") - /// .body("Hello from cot!") - /// .build()?; - /// email.send(message).await?; + /// let email = cot::email::Email::new(Console::new()); + /// let message = EmailMessage::builder() + /// .from(Email::try_from("no-reply@example.com").unwrap()) + /// .to(vec![Email::try_from("user@example.com").unwrap()]) + /// .subject("Greetings") + /// .body("Hello from cot!") + /// .build()?; + /// email.send(message).await?; /// # Ok(()) /// } /// ``` @@ -347,28 +347,28 @@ impl Email { /// /// # Examples /// - /// ```no_run + /// ``` /// use cot::email::EmailMessage; /// use cot::email::transport::console::Console; /// use cot::common_types::Email; /// /// # #[tokio::main] /// # async fn main() -> cot::Result<()> { - /// let email = cot::email::Email::new(Console::new()); - /// let message1 = EmailMessage::builder() - /// .from(Email::try_from("no-reply@email.com").unwrap()) - /// .to(vec![Email::try_from("user1@example.com").unwrap()]) - /// .subject("Hello User 1") - /// .body("This is the first email.") - /// .build()?; + /// let email = cot::email::Email::new(Console::new()); + /// let message1 = EmailMessage::builder() + /// .from(Email::try_from("no-reply@email.com").unwrap()) + /// .to(vec![Email::try_from("user1@example.com").unwrap()]) + /// .subject("Hello User 1") + /// .body("This is the first email.") + /// .build()?; /// - /// let message2 = EmailMessage::builder() - /// .from(Email::try_from("no-reply@email.com").unwrap()) - /// .to(vec![Email::try_from("user2@example.com").unwrap()]) - /// .subject("Hello User 2") - /// .body("This is the second email.") - /// .build()?; - /// email.send_multiple(&[message1, message2]).await?; + /// let message2 = EmailMessage::builder() + /// .from(Email::try_from("no-reply@email.com").unwrap()) + /// .to(vec![Email::try_from("user2@example.com").unwrap()]) + /// .subject("Hello User 2") + /// .body("This is the second email.") + /// .build()?; + /// email.send_multiple(&[message1, message2]).await?; /// # Ok(()) /// } /// ``` diff --git a/cot/src/email/transport.rs b/cot/src/email/transport.rs index 9f6411b6..715d74d9 100644 --- a/cot/src/email/transport.rs +++ b/cot/src/email/transport.rs @@ -3,6 +3,7 @@ //! It provides a `Transport` trait that can be implemented by different email //! backends (e.g., SMTP, console). The module also defines error handling for //! transport operations. +use std::future::Future; use std::pin::Pin; use cot::email::EmailMessageError; diff --git a/cot/src/email/transport/console.rs b/cot/src/email/transport/console.rs index fc394529..0c4b7f1a 100644 --- a/cot/src/email/transport/console.rs +++ b/cot/src/email/transport/console.rs @@ -8,7 +8,7 @@ //! //! ## Examples //! -//! ```no_run +//! ``` //! use cot::common_types::Email; //! use cot::email::EmailMessage; //! use cot::email::transport::console::Console; diff --git a/cot/src/email/transport/smtp.rs b/cot/src/email/transport/smtp.rs index 7062a92d..8e008c8d 100644 --- a/cot/src/email/transport/smtp.rs +++ b/cot/src/email/transport/smtp.rs @@ -45,7 +45,7 @@ pub enum SMTPError { /// An error occurred while sending the email via SMTP. #[error("{ERROR_PREFIX} send error: {0}")] SmtpSend(Box), - /// An error occured while creating the transport. + /// An error occurred while creating the transport. #[error("{ERROR_PREFIX} transport creation error: {0}")] TransportCreation(Box), } diff --git a/cot/src/project.rs b/cot/src/project.rs index 13ba8b58..921c305c 100644 --- a/cot/src/project.rs +++ b/cot/src/project.rs @@ -1585,7 +1585,7 @@ impl BootstrapPhase for WithConfig { type ErrorHandler = (); type Config = Arc; #[cfg(feature = "email")] - type Email = Arc; + type Email = Email; type Apps = (); type Router = (); #[cfg(feature = "db")] @@ -1733,10 +1733,9 @@ impl ProjectContext { fn with_config(self, config: ProjectConfig) -> ProjectContext { #[cfg(feature = "email")] let email = { - let e = Email::from_config(&config.email).unwrap_or_else(|err| { + Email::from_config(&config.email).unwrap_or_else(|err| { panic!("failed to initialize email service: {err:?}"); - }); - Arc::new(e) + }) }; ProjectContext { @@ -1949,7 +1948,7 @@ impl>> ProjectContext { } #[cfg(feature = "email")] -impl>> ProjectContext { +impl> ProjectContext { #[must_use] /// Returns the email service for the project. /// @@ -1965,7 +1964,7 @@ impl>> ProjectContext { /// # unimplemented!() /// } /// ``` - pub fn email(&self) -> &Arc { + pub fn email(&self) -> &Email { &self.email } } diff --git a/cot/src/request.rs b/cot/src/request.rs index 1f90b503..931a0e12 100644 --- a/cot/src/request.rs +++ b/cot/src/request.rs @@ -227,7 +227,7 @@ pub trait RequestExt: private::Sealed { /// ``` #[cfg(feature = "email")] #[must_use] - fn email(&self) -> &Arc; + fn email(&self) -> &Email; /// Get the content type of the request. /// @@ -343,7 +343,7 @@ impl RequestExt for Request { } #[cfg(feature = "email")] - fn email(&self) -> &Arc { + fn email(&self) -> &Email { self.context().email() } @@ -408,7 +408,7 @@ impl RequestExt for RequestHead { } #[cfg(feature = "email")] - fn email(&self) -> &Arc { + fn email(&self) -> &Email { self.context().email() } diff --git a/cot/src/test.rs b/cot/src/test.rs index 1b936f3a..d32c7690 100644 --- a/cot/src/test.rs +++ b/cot/src/test.rs @@ -239,7 +239,7 @@ pub struct TestRequestBuilder { #[cfg(feature = "cache")] cache: Option>, #[cfg(feature = "email")] - email: Option>, + email: Option, } /// A wrapper over an auth backend that is cloneable. @@ -786,7 +786,7 @@ impl TestRequestBuilder { #[cfg(feature = "email")] self.email .clone() - .unwrap_or_else(|| Arc::new(Email::new(Console::new()))), + .unwrap_or_else(|| Email::new(Console::new())), ); prepare_request(&mut request, Arc::new(context)); diff --git a/examples/send-email/Cargo.toml b/examples/send-email/Cargo.toml index 8b3b144f..4df742d2 100644 --- a/examples/send-email/Cargo.toml +++ b/examples/send-email/Cargo.toml @@ -4,9 +4,9 @@ version = "0.1.0" publish = false description = "Send email - Cot example." license = "MIT OR Apache-2.0" -edition = "2021" +edition = "2024" [dependencies] cot = { path = "../../cot", features = ["email", "live-reload"] } askama = "0.15" -serde = { version = "1.0.228", features = ["derive"] } +serde = { version = "1", features = ["derive"] } diff --git a/examples/send-email/src/main.rs b/examples/send-email/src/main.rs index e556fafe..34d32455 100644 --- a/examples/send-email/src/main.rs +++ b/examples/send-email/src/main.rs @@ -12,7 +12,7 @@ use cot::request::{Request, RequestExt}; use cot::response::Response; use cot::router::{Route, Router, Urls}; use cot::static_files::{StaticFile, StaticFilesMiddleware}; -use cot::{reverse_redirect, static_files, App, AppBuilder, Project}; +use cot::{App, AppBuilder, Project, reverse_redirect, static_files}; use serde::{Deserialize, Serialize}; struct EmailApp; @@ -110,7 +110,7 @@ async fn send_email(urls: Urls, mut request: Request) -> cot::Result { // Fixme: We should redirect with the status when reverse_redirect! supports // query parameters - Ok(reverse_redirect!(&urls, "index/",)?) + Ok(reverse_redirect!(&urls, "index/")?) } struct MyProject; @@ -132,6 +132,7 @@ impl Project for MyProject { ) .build()) } + fn register_apps(&self, apps: &mut AppBuilder, _context: &RegisterAppsContext) { apps.register_with_views(EmailApp, ""); } diff --git a/examples/send-email/templates/index.html b/examples/send-email/templates/index.html index 1d2bfaba..bfe49941 100644 --- a/examples/send-email/templates/index.html +++ b/examples/send-email/templates/index.html @@ -27,8 +27,6 @@

Failed to send email

{% endif %} -

{{ status }}

-
@@ -60,8 +58,11 @@
- +
From 71c516e5ad6b03b9a8a731c0bddbfdf7e9a80219 Mon Sep 17 00:00:00 2001 From: Elijah Date: Thu, 25 Dec 2025 12:27:58 +0000 Subject: [PATCH 51/77] use tokio-rustls --- Cargo.lock | 77 ++++++++++++++++++++++++++++++++++++++++++++++++-- cot/Cargo.toml | 2 +- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5c624084..5fa9d514 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2381,13 +2381,13 @@ dependencies = [ "httpdate", "idna", "mime", - "native-tls", "nom", "percent-encoding", "quoted_printable", + "rustls", "socket2", "tokio", - "tokio-native-tls", + "tokio-rustls", "url", ] @@ -3308,6 +3308,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "roff" version = "0.2.2" @@ -3368,6 +3382,40 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +dependencies = [ + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -4180,6 +4228,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.17" @@ -4536,6 +4594,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.7" @@ -4830,6 +4894,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" diff --git a/cot/Cargo.toml b/cot/Cargo.toml index 5174ed61..83a41f4a 100644 --- a/cot/Cargo.toml +++ b/cot/Cargo.toml @@ -42,7 +42,7 @@ http-body.workspace = true http.workspace = true humantime.workspace = true indexmap.workspace = true -lettre = { workspace = true, features = ["builder", "sendmail-transport", "smtp-transport", "tokio1", "tokio1-native-tls"], optional = true } +lettre = { workspace = true, features = ["builder", "sendmail-transport", "smtp-transport", "tokio1", "tokio1-rustls"], optional = true } mime.workspace = true mime_guess.workspace = true multer.workspace = true From d550869ac23c9608ea18c79feee4c6df58bbb06e Mon Sep 17 00:00:00 2001 From: Elijah Date: Thu, 25 Dec 2025 12:41:12 +0000 Subject: [PATCH 52/77] using tokio-rustls needs ring feature --- Cargo.lock | 1 + cot/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 5fa9d514..7b6e1e24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3390,6 +3390,7 @@ checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", diff --git a/cot/Cargo.toml b/cot/Cargo.toml index 83a41f4a..e123fe45 100644 --- a/cot/Cargo.toml +++ b/cot/Cargo.toml @@ -42,7 +42,7 @@ http-body.workspace = true http.workspace = true humantime.workspace = true indexmap.workspace = true -lettre = { workspace = true, features = ["builder", "sendmail-transport", "smtp-transport", "tokio1", "tokio1-rustls"], optional = true } +lettre = { workspace = true, features = ["builder", "sendmail-transport", "smtp-transport", "tokio1", "tokio1-rustls", "ring"], optional = true } mime.workspace = true mime_guess.workspace = true multer.workspace = true From 9e539ec94157e318c5e0a4214968c61ff158ae7a Mon Sep 17 00:00:00 2001 From: Elijah Date: Thu, 25 Dec 2025 12:44:52 +0000 Subject: [PATCH 53/77] whitelist licenses required by lettre --- deny.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/deny.toml b/deny.toml index 3b2af4e9..afc95295 100644 --- a/deny.toml +++ b/deny.toml @@ -17,6 +17,8 @@ allow = [ "MIT", "Apache-2.0", "Apache-2.0 WITH LLVM-exception", + "Apache-2.0 AND ISC", + "ISC", "Unicode-3.0", "0BSD", "BSD-3-Clause", From 0e44378dbe24c82781d9c88b2fc2fab5fdb781e9 Mon Sep 17 00:00:00 2001 From: Elijah Date: Thu, 25 Dec 2025 12:46:22 +0000 Subject: [PATCH 54/77] fix clause --- deny.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deny.toml b/deny.toml index afc95295..27d0079e 100644 --- a/deny.toml +++ b/deny.toml @@ -17,7 +17,7 @@ allow = [ "MIT", "Apache-2.0", "Apache-2.0 WITH LLVM-exception", - "Apache-2.0 AND ISC", + "Apache-2.0 WITH ISC", "ISC", "Unicode-3.0", "0BSD", From 242030088bdfdb955c0ac4ba62e160355fec048f Mon Sep 17 00:00:00 2001 From: Elijah Date: Thu, 25 Dec 2025 12:47:40 +0000 Subject: [PATCH 55/77] not sure what happened there --- deny.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/deny.toml b/deny.toml index 27d0079e..ceb15908 100644 --- a/deny.toml +++ b/deny.toml @@ -17,7 +17,6 @@ allow = [ "MIT", "Apache-2.0", "Apache-2.0 WITH LLVM-exception", - "Apache-2.0 WITH ISC", "ISC", "Unicode-3.0", "0BSD", From 79d5d24d236ec8e7b227144f9ee1071918f883ae Mon Sep 17 00:00:00 2001 From: Elijah Date: Thu, 25 Dec 2025 12:49:03 +0000 Subject: [PATCH 56/77] apparently we need this --- Cargo.lock | 170 ++++++++++++++++++++++++++++++++++++++++++++++++- cot/Cargo.toml | 2 +- 2 files changed, 169 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7b6e1e24..49b33342 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -608,6 +608,12 @@ dependencies = [ "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "1.0.4" @@ -838,6 +844,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -2326,6 +2342,28 @@ version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ee5b5339afb4c41626dde77b7a611bd4f2c202b897852b4bcf5d03eddc61010" +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + [[package]] name = "js-sys" version = "0.3.83" @@ -2385,6 +2423,7 @@ dependencies = [ "percent-encoding", "quoted_printable", "rustls", + "rustls-platform-verifier", "socket2", "tokio", "tokio-rustls", @@ -2584,7 +2623,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework", + "security-framework 2.11.1", "security-framework-sys", "tempfile", ] @@ -3397,6 +3436,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework 3.5.1", +] + [[package]] name = "rustls-pki-types" version = "1.13.1" @@ -3406,6 +3457,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework 3.5.1", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.8" @@ -3507,7 +3585,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags", - "core-foundation", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -4786,6 +4877,15 @@ dependencies = [ "url", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee3e3b5f5e80bc89f30ce8d0343bf4e5f12341c51f3e26cbeecbc7c85443e85b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "whoami" version = "1.6.1" @@ -4886,6 +4986,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -4931,6 +5040,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -4979,6 +5103,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -4997,6 +5127,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -5015,6 +5151,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -5045,6 +5187,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -5063,6 +5211,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -5081,6 +5235,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -5099,6 +5259,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" diff --git a/cot/Cargo.toml b/cot/Cargo.toml index e123fe45..73cbfe01 100644 --- a/cot/Cargo.toml +++ b/cot/Cargo.toml @@ -42,7 +42,7 @@ http-body.workspace = true http.workspace = true humantime.workspace = true indexmap.workspace = true -lettre = { workspace = true, features = ["builder", "sendmail-transport", "smtp-transport", "tokio1", "tokio1-rustls", "ring"], optional = true } +lettre = { workspace = true, features = ["builder", "sendmail-transport", "smtp-transport", "tokio1", "tokio1-rustls", "ring", "rustls-platform-verifier"], optional = true } mime.workspace = true mime_guess.workspace = true multer.workspace = true From 8bc83bd2afe0f18d86f3d0ca8e6d4abeee1c7310 Mon Sep 17 00:00:00 2001 From: Elijah Date: Thu, 25 Dec 2025 12:51:22 +0000 Subject: [PATCH 57/77] more whitelist --- deny.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/deny.toml b/deny.toml index ceb15908..d1a9a769 100644 --- a/deny.toml +++ b/deny.toml @@ -17,6 +17,7 @@ allow = [ "MIT", "Apache-2.0", "Apache-2.0 WITH LLVM-exception", + "CDLA-Permissive-2.0", "ISC", "Unicode-3.0", "0BSD", From e97aa58c2c7a7644c9e1b0163090fe9e1b532a0c Mon Sep 17 00:00:00 2001 From: Elijah Date: Mon, 29 Dec 2025 12:41:28 +0000 Subject: [PATCH 58/77] test excluding examples --- .github/workflows/rust.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index deca5ae3..a1185681 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -321,7 +321,7 @@ jobs: cargo hack --remove-dev-deps --workspace # Update Cargo.lock to minimal version dependencies. cargo update -Z minimal-versions - cargo hack check --all-features --ignore-private + cargo hack check --all-features --ignore-private --exclude examples/* build-feature-power-set: if: github.event_name == 'push' || github.event_name == 'schedule' || github.event.pull_request.head.repo.full_name != github.repository From 90f913ad00f75ceb3ace9e027d6cd11e25b01d43 Mon Sep 17 00:00:00 2001 From: Elijah Date: Mon, 29 Dec 2025 13:04:42 +0000 Subject: [PATCH 59/77] update workflow --- .github/workflows/rust.yml | 2 +- Cargo.lock | 18 +++++++++--------- examples/send-email/Cargo.toml | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index a1185681..1807fc6e 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -321,7 +321,7 @@ jobs: cargo hack --remove-dev-deps --workspace # Update Cargo.lock to minimal version dependencies. cargo update -Z minimal-versions - cargo hack check --all-features --ignore-private --exclude examples/* + cargo hack check --all-features --ignore-private --exclude 'examples/*' build-feature-power-set: if: github.event_name == 'push' || github.event_name == 'schedule' || github.event.pull_request.head.repo.full_name != github.repository diff --git a/Cargo.lock b/Cargo.lock index d32d041b..c046aef5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1508,6 +1508,15 @@ dependencies = [ "serde", ] +[[package]] +name = "example-send-email" +version = "0.1.0" +dependencies = [ + "askama", + "cot", + "serde", +] + [[package]] name = "example-sessions" version = "0.1.0" @@ -3620,15 +3629,6 @@ version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" -[[package]] -name = "send-email" -version = "0.1.0" -dependencies = [ - "askama", - "cot", - "serde", -] - [[package]] name = "serde" version = "1.0.228" diff --git a/examples/send-email/Cargo.toml b/examples/send-email/Cargo.toml index 4df742d2..05c97084 100644 --- a/examples/send-email/Cargo.toml +++ b/examples/send-email/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "send-email" +name = "example-send-email" version = "0.1.0" publish = false description = "Send email - Cot example." From 854b38cb4a096061f710bfdfd446c4538ab89662 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ma=C4=87kowski?= Date: Tue, 30 Dec 2025 20:18:53 +0100 Subject: [PATCH 60/77] Pin the idna version to ^1.1 --- Cargo.toml | 1 + cot/Cargo.toml | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 84e4406d..ad11dbc4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -102,6 +102,7 @@ indexmap = "2" insta = { version = "1", features = ["filters"] } insta-cmd = "0.6" lettre = { version = "0.11", default-features = false } +idna = { version = "1.1", default-features = false } mime = "0.3" mime_guess = { version = "2", default-features = false } mockall = "0.14" diff --git a/cot/Cargo.toml b/cot/Cargo.toml index 73cbfe01..e143d3ca 100644 --- a/cot/Cargo.toml +++ b/cot/Cargo.toml @@ -41,6 +41,7 @@ http-body-util.workspace = true http-body.workspace = true http.workspace = true humantime.workspace = true +idna = { workspace = true, optional = true } indexmap.workspace = true lettre = { workspace = true, features = ["builder", "sendmail-transport", "smtp-transport", "tokio1", "tokio1-rustls", "ring", "rustls-platform-verifier"], optional = true } mime.workspace = true @@ -98,6 +99,8 @@ ignored = [ # Used indirectly by `grass`, but it doesn't work with the latest versions of Rust if minimal dependency versions # are used "ahash", + # Used by `lettre`, but it causes dependency issues if minimal dependency versions are used + "idna", # time requires version 0.3.35 to work with the latest versions of Rust, but we don't use it directly "time", ] @@ -107,7 +110,7 @@ default = ["sqlite", "postgres", "mysql", "json"] full = ["default", "fake", "live-reload", "test", "cache", "redis", "email"] fake = ["dep:fake"] db = ["dep:sea-query", "dep:sea-query-binder", "dep:sqlx"] -email = ["dep:lettre"] +email = ["dep:lettre", "dep:idna"] sqlite = ["db", "sea-query/backend-sqlite", "sea-query-binder/sqlx-sqlite", "sqlx/sqlite"] postgres = ["db", "sea-query/backend-postgres", "sea-query-binder/sqlx-postgres", "sqlx/postgres"] mysql = ["db", "sea-query/backend-mysql", "sea-query-binder/sqlx-mysql", "sqlx/mysql"] From 9ca90bdb5eed10cea8f8ef450723995d8bf01e99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ma=C4=87kowski?= Date: Tue, 30 Dec 2025 20:24:40 +0100 Subject: [PATCH 61/77] Update Cargo.lock --- Cargo.lock | 84 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 46 insertions(+), 38 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c046aef5..357c8fcf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -461,9 +461,9 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.5.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", @@ -600,9 +600,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.50" +version = "1.2.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f50d563227a1c37cc0a263f64eca3334388c01c5e4c4861a9def205c614383c" +checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" dependencies = [ "find-msvc-tools", "shlex", @@ -716,9 +716,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.5.62" +version = "4.5.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "004eef6b14ce34759aa7de4aea3217e368f463f46a3ed3764ca4b5a4404003b4" +checksum = "4c0da80818b2d95eca9aa614a30783e42f62bf5fdfee24e68cfb960b071ba8d1" dependencies = [ "clap", ] @@ -904,6 +904,7 @@ dependencies = [ "http-body", "http-body-util", "humantime", + "idna", "indexmap", "lettre", "mime", @@ -1578,9 +1579,9 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "find-msvc-tools" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" [[package]] name = "fixedbitset" @@ -2291,9 +2292,9 @@ dependencies = [ [[package]] name = "insta" -version = "1.45.0" +version = "1.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b76866be74d68b1595eb8060cb9191dca9c021db2316558e52ddc5d55d41b66c" +checksum = "983e3b24350c84ab8a65151f537d67afbbf7153bb9f1110e03e9fa9b07f67a5c" dependencies = [ "console", "once_cell", @@ -2322,9 +2323,9 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "iri-string" -version = "0.7.9" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" dependencies = [ "memchr", "serde", @@ -2347,9 +2348,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.16" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ee5b5339afb4c41626dde77b7a611bd4f2c202b897852b4bcf5d03eddc61010" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jni" @@ -2453,13 +2454,13 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "libredox" -version = "0.1.11" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df15f6eac291ed1cf25865b1ee60399f57e7c227e7f51bdbd4c5270396a9ed50" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ "bitflags", "libc", - "redox_syscall 0.6.0", + "redox_syscall 0.7.0", ] [[package]] @@ -2629,7 +2630,7 @@ dependencies = [ "libc", "log", "openssl", - "openssl-probe", + "openssl-probe 0.1.6", "openssl-sys", "schannel", "security-framework 2.11.1", @@ -2795,6 +2796,12 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-probe" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f50d9b3dabb09ecd771ad0aa242ca6894994c130308ca3d7684634df8037391" + [[package]] name = "openssl-sys" version = "0.9.111" @@ -3119,9 +3126,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0" dependencies = [ "unicode-ident", ] @@ -3268,9 +3275,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.6.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec96166dafa0886eb81fe1c0a388bece180fbef2135f97c1e2cf8302e74b43b5" +checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" dependencies = [ "bitflags", ] @@ -3447,11 +3454,11 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe", + "openssl-probe 0.2.0", "rustls-pki-types", "schannel", "security-framework 3.5.1", @@ -3459,9 +3466,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.1" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" +checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" dependencies = [ "zeroize", ] @@ -3484,7 +3491,7 @@ dependencies = [ "security-framework 3.5.1", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3512,9 +3519,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.21" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62049b2877bf12821e8f9ad256ee38fdc31db7387ec2d3b3f403024de2034aea" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" [[package]] name = "same-file" @@ -3685,9 +3692,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.147" +version = "1.0.148" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6af14725505314343e673e9ecb7cd7e8a36aa9791eb936235a3567cc31447ae4" +checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" dependencies = [ "itoa", "memchr", @@ -3767,10 +3774,11 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.7" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] @@ -4086,9 +4094,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.111" +version = "2.0.112" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "21f182278bf2d2bcb3c88b1b08a37df029d71ce3d3ae26168e3c653b213b99d4" dependencies = [ "proc-macro2", "quote", @@ -5461,6 +5469,6 @@ dependencies = [ [[package]] name = "zmij" -version = "0.1.7" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e404bcd8afdaf006e529269d3e85a743f9480c3cef60034d77860d02964f3ba" +checksum = "77cc0158b0d3103d58e9e82bdbe9cf9289d80dbcf4e686ff16730eb9e5814d1a" From 71e3c44de6557c69373581ceec438ded868cfb60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ma=C4=87kowski?= Date: Tue, 30 Dec 2025 20:46:56 +0100 Subject: [PATCH 62/77] also pin chumsky --- Cargo.lock | 1 + Cargo.toml | 1 + cot/Cargo.toml | 5 ++++- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 357c8fcf..83bac3f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -883,6 +883,7 @@ dependencies = [ "bytes", "chrono", "chrono-tz", + "chumsky", "clap", "cot_macros", "criterion", diff --git a/Cargo.toml b/Cargo.toml index ad11dbc4..254cf1ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,6 +69,7 @@ bytes = "1.11" cargo_toml = "0.22" chrono = { version = "0.4.42", default-features = false } chrono-tz = { version = "0.10.4", default-features = false } +chumsky = { version = "0.9.3", default-features = false } clap = { version = "4.5.53", features = ["deprecated"] } clap-verbosity-flag = { version = "3", default-features = false } clap_complete = "4" diff --git a/cot/Cargo.toml b/cot/Cargo.toml index e143d3ca..e704c97d 100644 --- a/cot/Cargo.toml +++ b/cot/Cargo.toml @@ -24,6 +24,7 @@ backtrace.workspace = true bytes.workspace = true chrono = { workspace = true, features = ["alloc", "serde", "clock"] } chrono-tz.workspace = true +chumsky = { workspace = true, optional = true } clap.workspace = true cot_macros.workspace = true deadpool-redis = { workspace = true, features = ["tokio-comp", "rt_tokio_1"], optional = true } @@ -100,6 +101,8 @@ ignored = [ # are used "ahash", # Used by `lettre`, but it causes dependency issues if minimal dependency versions are used + "chumsky", + # Used by `lettre`, but it causes dependency issues if minimal dependency versions are used "idna", # time requires version 0.3.35 to work with the latest versions of Rust, but we don't use it directly "time", @@ -110,7 +113,7 @@ default = ["sqlite", "postgres", "mysql", "json"] full = ["default", "fake", "live-reload", "test", "cache", "redis", "email"] fake = ["dep:fake"] db = ["dep:sea-query", "dep:sea-query-binder", "dep:sqlx"] -email = ["dep:lettre", "dep:idna"] +email = ["dep:lettre", "dep:chumsky", "dep:idna"] sqlite = ["db", "sea-query/backend-sqlite", "sea-query-binder/sqlx-sqlite", "sqlx/sqlite"] postgres = ["db", "sea-query/backend-postgres", "sea-query-binder/sqlx-postgres", "sqlx/postgres"] mysql = ["db", "sea-query/backend-mysql", "sea-query-binder/sqlx-mysql", "sqlx/mysql"] From a71db21af077d48b999ffa1d96812fa21f38b331 Mon Sep 17 00:00:00 2001 From: Elijah Date: Wed, 31 Dec 2025 15:44:22 +0000 Subject: [PATCH 63/77] remove the exclude examples flag from cargo hack --- .github/workflows/rust.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 1807fc6e..deca5ae3 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -321,7 +321,7 @@ jobs: cargo hack --remove-dev-deps --workspace # Update Cargo.lock to minimal version dependencies. cargo update -Z minimal-versions - cargo hack check --all-features --ignore-private --exclude 'examples/*' + cargo hack check --all-features --ignore-private build-feature-power-set: if: github.event_name == 'push' || github.event_name == 'schedule' || github.event.pull_request.head.repo.full_name != github.repository From 73c83e46d6aaf377be3f1c1b6f69fa4cb61d43e8 Mon Sep 17 00:00:00 2001 From: Elijah Date: Sat, 3 Jan 2026 14:22:23 +0000 Subject: [PATCH 64/77] introduce indirection --- cot/src/email.rs | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/cot/src/email.rs b/cot/src/email.rs index 0692442a..8840539d 100644 --- a/cot/src/email.rs +++ b/cot/src/email.rs @@ -260,6 +260,12 @@ impl TryFrom for Message { } } +#[derive(Debug)] +struct EmailImpl { + #[debug("..")] + transport: Box, +} + /// A high-level email interface for sending emails. /// /// This struct wraps a [`Transport`] implementation and provides @@ -287,8 +293,7 @@ impl TryFrom for Message { /// ``` #[derive(Debug, Clone)] pub struct Email { - #[debug("..")] - transport: Arc, + inner: Arc, } impl Email { @@ -303,8 +308,10 @@ impl Email { /// let email = Email::new(Console::new()); /// ``` pub fn new(transport: impl Transport) -> Self { - let transport: Arc = Arc::new(transport); - Self { transport } + let transport: Box = Box::new(transport); + Self { + inner: Arc::new(EmailImpl { transport }), + } } /// Send a single [`EmailMessage`] /// @@ -333,7 +340,8 @@ impl Email { /// } /// ``` pub async fn send(&self, message: EmailMessage) -> EmailResult<()> { - self.transport + self.inner + .transport .send(&[message]) .await .map_err(EmailError::Transport) @@ -373,7 +381,8 @@ impl Email { /// } /// ``` pub async fn send_multiple(&self, messages: &[EmailMessage]) -> EmailResult<()> { - self.transport + self.inner + .transport .send(messages) .await .map_err(EmailError::Transport) From 8d84c7b7d9c2c87daffb02672d460ce6208e2c49 Mon Sep 17 00:00:00 2001 From: EBADF Date: Mon, 5 Jan 2026 17:38:26 +0000 Subject: [PATCH 65/77] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Mateusz Maćkowski --- cot/src/config.rs | 6 +++--- cot/src/email.rs | 8 ++++---- cot/src/email/transport/smtp.rs | 8 ++++---- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/cot/src/config.rs b/cot/src/config.rs index 42fae484..104b35e2 100644 --- a/cot/src/config.rs +++ b/cot/src/config.rs @@ -1892,7 +1892,7 @@ pub enum EmailTransportTypeConfig { /// # TOML Configuration /// /// ```toml - /// [email] + /// [email.transport] /// type = "smtp" /// url = "smtp://johndoe:xxxx xxxx xxxx xxxx@smtp.gmail.com:587?tls=required" /// mechanism = "plain" # or "login", "xoauth2" @@ -2014,11 +2014,11 @@ pub struct EmailConfig { /// # TOML Configuration /// /// ```toml - /// [email] + /// [email.transport] /// type = "console" /// /// # Or for SMTP: - /// # [email] + /// # [email.transport] /// # type = "smtp" /// # auth_id = "your_auth_id" /// # secret = "your_secret" diff --git a/cot/src/email.rs b/cot/src/email.rs index 8840539d..8cfabe9b 100644 --- a/cot/src/email.rs +++ b/cot/src/email.rs @@ -280,16 +280,16 @@ struct EmailImpl { /// /// # #[tokio::main] /// # async fn main() -> cot::Result<()> { -/// let email = cot::email::Email::new(Console::new()); -/// let message = EmailMessage::builder() +/// let email = cot::email::Email::new(Console::new()); +/// let message = EmailMessage::builder() /// .from(Email::try_from("no-reply@example.com").unwrap()) /// .to(vec![Email::try_from("user@example.com").unwrap()]) /// .subject("Greetings") /// .body("Hello from cot!") /// .build()?; -/// email.send(message).await?; +/// email.send(message).await?; /// # Ok(()) -/// } +/// # } /// ``` #[derive(Debug, Clone)] pub struct Email { diff --git a/cot/src/email/transport/smtp.rs b/cot/src/email/transport/smtp.rs index 8e008c8d..eeb06c5f 100644 --- a/cot/src/email/transport/smtp.rs +++ b/cot/src/email/transport/smtp.rs @@ -104,10 +104,10 @@ impl From for smtp::authentication::Mechanism { /// let email = cot::email::Email::new(smtp); /// /// let msg = EmailMessage::builder() -/// .from(Email::try_from("testfrom@example.com").unwrap()) -/// .to(vec![Email::try_from("testreceipient@example.com").unwrap()]) -/// .body("This is a test email.") -/// .build()?; +/// .from(Email::try_from("testfrom@example.com").unwrap()) +/// .to(vec![Email::try_from("testreceipient@example.com").unwrap()]) +/// .body("This is a test email.") +/// .build()?; /// email.send(msg).await?; /// # Ok(()) } #[derive(Debug, Clone)] From d231184d97df9cf017570d46165155daaea64f46 Mon Sep 17 00:00:00 2001 From: Elijah Date: Tue, 6 Jan 2026 08:31:54 +0000 Subject: [PATCH 66/77] address a number of PR comments --- cot/src/config.rs | 3 +- cot/src/email.rs | 191 +++-------------------------- cot/src/email/transport.rs | 3 +- cot/src/email/transport/console.rs | 3 +- cot/src/email/transport/smtp.rs | 179 ++++++++++++++++++++++++++- 5 files changed, 196 insertions(+), 183 deletions(-) diff --git a/cot/src/config.rs b/cot/src/config.rs index 104b35e2..337ecaa2 100644 --- a/cot/src/config.rs +++ b/cot/src/config.rs @@ -261,7 +261,7 @@ pub struct ProjectConfig { /// /// let config = ProjectConfig::from_toml( /// r#" - /// [email.backend] + /// [email.transport] /// type = "console" /// "#, /// )?; @@ -1832,6 +1832,7 @@ impl Default for SessionMiddlewareConfig { #[cfg(feature = "email")] #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] +#[non_exhaustive] pub enum EmailTransportTypeConfig { /// Console email transport. /// diff --git a/cot/src/email.rs b/cot/src/email.rs index 8cfabe9b..97d369d2 100644 --- a/cot/src/email.rs +++ b/cot/src/email.rs @@ -27,14 +27,13 @@ pub mod transport; +use std::error::Error as StdError; use std::sync::Arc; use cot::config::{EmailConfig, EmailTransportTypeConfig}; use cot::email::transport::smtp::Smtp; use derive_builder::Builder; use derive_more::with_trait::Debug; -use lettre::message::header::ContentType; -use lettre::message::{Attachment, Body, Mailbox, Message, MultiPart, SinglePart}; use thiserror::Error; use transport::{BoxedTransport, Transport}; @@ -52,9 +51,6 @@ pub enum EmailError { /// An error occurred in the transport layer while sending the email. #[error(transparent)] Transport(TransportError), - /// An error occurred while building the email message. - #[error("{ERROR_PREFIX} message error: {0}")] - Message(String), } impl_into_cot_error!(EmailError); @@ -173,15 +169,15 @@ impl EmailMessageBuilder { } /// Errors that can occur while building an email message. -#[derive(Debug, Clone, Error)] +#[derive(Debug, Error)] #[non_exhaustive] pub enum EmailMessageError { /// An invalid email address was provided. #[error("{EMAIL_MESSAGE_BUILD_ERROR_PREFIX} invalid email address: {0}")] - InvalidEmailAddress(String), + InvalidEmailAddress(Box), /// Failed to build the email message. #[error("{EMAIL_MESSAGE_BUILD_ERROR_PREFIX} failed to build email message: {0}")] - BuildError(String), + BuildError(Box), /// A required field is missing in the email message. #[error("{EMAIL_MESSAGE_BUILD_ERROR_PREFIX} The `{0}` field is required but was not set")] MissingField(String), @@ -189,77 +185,6 @@ pub enum EmailMessageError { impl_into_cot_error!(EmailMessageError); -impl TryFrom for Message { - type Error = EmailMessageError; - - fn try_from(message: EmailMessage) -> Result { - let from_mailbox = message - .from - .email() - .as_str() - .parse::() - .map_err(|err| EmailMessageError::InvalidEmailAddress(err.to_string()))?; - - let mut builder = Message::builder() - .from(from_mailbox) - .subject(message.subject); - - for to in message.to { - let mb = to - .email() - .as_str() - .parse::() - .map_err(|err| EmailMessageError::InvalidEmailAddress(err.to_string()))?; - builder = builder.to(mb); - } - - for cc in message.cc { - let mb = cc - .email() - .as_str() - .parse::() - .map_err(|err| EmailMessageError::InvalidEmailAddress(err.to_string()))?; - builder = builder.cc(mb); - } - - for bcc in message.bcc { - let mb = bcc - .email() - .as_str() - .parse::() - .map_err(|err| EmailMessageError::InvalidEmailAddress(err.to_string()))?; - builder = builder.bcc(mb); - } - - for r in message.reply_to { - let mb = r - .email() - .as_str() - .parse::() - .map_err(|err| EmailMessageError::InvalidEmailAddress(err.to_string()))?; - builder = builder.reply_to(mb); - } - - let mut mixed = MultiPart::mixed().singlepart(SinglePart::plain(message.body)); - - for attach in message.attachments { - let mime: ContentType = attach.content_type.parse().unwrap_or_else(|_| { - "application/octet-stream" - .parse() - .expect("could not parse default mime type") - }); - - let part = Attachment::new(attach.filename).body(Body::new(attach.data), mime); - mixed = mixed.singlepart(part); - } - - let email = builder - .multipart(mixed) - .map_err(|err| EmailMessageError::BuildError(err.to_string()))?; - Ok(email) - } -} - #[derive(Debug)] struct EmailImpl { #[debug("..")] @@ -274,9 +199,9 @@ struct EmailImpl { /// # Examples /// /// ``` +/// use cot::common_types::Email; /// use cot::email::EmailMessage; /// use cot::email::transport::console::Console; -/// use cot::common_types::Email; /// /// # #[tokio::main] /// # async fn main() -> cot::Result<()> { @@ -322,22 +247,22 @@ impl Email { /// # Examples /// /// ``` + /// use cot::common_types::Email; /// use cot::email::EmailMessage; /// use cot::email::transport::console::Console; - /// use cot::common_types::Email; /// /// # #[tokio::main] /// # async fn main() -> cot::Result<()> { - /// let email = cot::email::Email::new(Console::new()); - /// let message = EmailMessage::builder() + /// let email = cot::email::Email::new(Console::new()); + /// let message = EmailMessage::builder() /// .from(Email::try_from("no-reply@example.com").unwrap()) /// .to(vec![Email::try_from("user@example.com").unwrap()]) /// .subject("Greetings") /// .body("Hello from cot!") /// .build()?; - /// email.send(message).await?; + /// email.send(message).await?; /// # Ok(()) - /// } + /// # } /// ``` pub async fn send(&self, message: EmailMessage) -> EmailResult<()> { self.inner @@ -356,29 +281,29 @@ impl Email { /// # Examples /// /// ``` + /// use cot::common_types::Email; /// use cot::email::EmailMessage; /// use cot::email::transport::console::Console; - /// use cot::common_types::Email; /// /// # #[tokio::main] /// # async fn main() -> cot::Result<()> { - /// let email = cot::email::Email::new(Console::new()); - /// let message1 = EmailMessage::builder() + /// let email = cot::email::Email::new(Console::new()); + /// let message1 = EmailMessage::builder() /// .from(Email::try_from("no-reply@email.com").unwrap()) /// .to(vec![Email::try_from("user1@example.com").unwrap()]) /// .subject("Hello User 1") /// .body("This is the first email.") /// .build()?; /// - /// let message2 = EmailMessage::builder() + /// let message2 = EmailMessage::builder() /// .from(Email::try_from("no-reply@email.com").unwrap()) /// .to(vec![Email::try_from("user2@example.com").unwrap()]) /// .subject("Hello User 2") /// .body("This is the second email.") /// .build()?; - /// email.send_multiple(&[message1, message2]).await?; + /// email.send_multiple(&[message1, message2]).await?; /// # Ok(()) - /// } + /// # } /// ``` pub async fn send_multiple(&self, messages: &[EmailMessage]) -> EmailResult<()> { self.inner @@ -533,88 +458,4 @@ mod tests { .unwrap(); assert!(email.send_multiple(&[msg1, msg2]).await.is_ok()); } - - #[cot::test] - async fn try_from_basic_converts_and_contains_headers() { - let msg = EmailMessage::builder() - .from(crate::common_types::Email::new("from@example.com").unwrap()) - .to(vec![ - crate::common_types::Email::new("to@example.com").unwrap(), - ]) - .subject("Hello World") - .body("This is the body.") - .build() - .unwrap(); - - let built: Message = Message::try_from(msg).expect("conversion to lettre::Message"); - - let formatted = String::from_utf8_lossy(&built.formatted()).to_string(); - - assert!(formatted.contains("From: from@example.com"),); - assert!(formatted.contains("To: to@example.com"),); - assert!(formatted.contains("Subject: Hello World"),); - assert!(formatted.contains("Content-Type: multipart/mixed"),); - assert!(formatted.contains("This is the body."),); - } - - #[cot::test] - async fn try_from_includes_cc_and_reply_to_headers() { - let msg = EmailMessage::builder() - .from(crate::common_types::Email::new("sender@example.com").unwrap()) - .to(vec![ - crate::common_types::Email::new("primary@example.com").unwrap(), - ]) - .cc(vec![ - crate::common_types::Email::new("cc1@example.com").unwrap(), - crate::common_types::Email::new("cc2@example.com").unwrap(), - ]) - .bcc(vec![ - crate::common_types::Email::new("hidden@example.com").unwrap(), - ]) - .reply_to(vec![ - crate::common_types::Email::new("replyto@example.com").unwrap(), - ]) - .subject("Headers Test") - .body("Body") - .build() - .unwrap(); - - let built: Message = Message::try_from(msg).expect("conversion to lettre::Message"); - let formatted = String::from_utf8_lossy(&built.formatted()).to_string(); - - assert!( - formatted.contains("Cc: cc1@example.com, cc2@example.com") - || (formatted.contains("Cc: cc1@example.com") - && formatted.contains("cc2@example.com")), - ); - assert!(formatted.contains("Reply-To: replyto@example.com"),); - } - - #[cot::test] - async fn try_from_with_attachment_uses_default_mime_on_parse_failure() { - let attachment = AttachmentData { - filename: "report.bin".to_string(), - content_type: "this/is not a valid mime".to_string(), - data: vec![0xDE, 0xAD, 0xBE, 0xEF], - }; - - let msg = EmailMessage::builder() - .from(crate::common_types::Email::new("sender@example.com").unwrap()) - .to(vec![ - crate::common_types::Email::new("to@example.com").unwrap(), - ]) - .subject("Attachment Test") - .body("Please see attachment") - .attachments(vec![attachment]) - .build() - .unwrap(); - - let built: Message = Message::try_from(msg).expect("conversion to lettre::Message"); - let formatted = String::from_utf8_lossy(&built.formatted()).to_string(); - - assert!(formatted.contains("Content-Disposition: attachment"),); - assert!(formatted.contains("report.bin"),); - assert!(formatted.contains("Content-Type: application/octet-stream"),); - assert!(formatted.contains("Please see attachment")); - } } diff --git a/cot/src/email/transport.rs b/cot/src/email/transport.rs index 715d74d9..070ad7b7 100644 --- a/cot/src/email/transport.rs +++ b/cot/src/email/transport.rs @@ -3,6 +3,7 @@ //! It provides a `Transport` trait that can be implemented by different email //! backends (e.g., SMTP, console). The module also defines error handling for //! transport operations. +use std::error::Error as StdError; use std::future::Future; use std::pin::Pin; @@ -23,7 +24,7 @@ const ERROR_PREFIX: &str = "email transport error:"; pub enum TransportError { /// The underlying transport backend returned an error. #[error("{ERROR_PREFIX} transport error: {0}")] - Backend(String), + Backend(Box), /// Failed to build the email message. #[error("{ERROR_PREFIX} message build error: {0}")] MessageBuildError(#[from] EmailMessageError), diff --git a/cot/src/email/transport/console.rs b/cot/src/email/transport/console.rs index 0c4b7f1a..5035a819 100644 --- a/cot/src/email/transport/console.rs +++ b/cot/src/email/transport/console.rs @@ -46,7 +46,7 @@ pub enum ConsoleError { impl From for TransportError { fn from(err: ConsoleError) -> Self { - TransportError::Backend(err.to_string()) + TransportError::Backend(Box::new(err)) } } @@ -60,6 +60,7 @@ impl From for TransportError { /// let console_transport = Console::new(); /// ``` #[derive(Debug, Clone, Copy)] +#[non_exhaustive] pub struct Console; impl Console { diff --git a/cot/src/email/transport/smtp.rs b/cot/src/email/transport/smtp.rs index eeb06c5f..91902ea3 100644 --- a/cot/src/email/transport/smtp.rs +++ b/cot/src/email/transport/smtp.rs @@ -24,8 +24,12 @@ //! email.send(msg).await?; //! # Ok(()) } //! ``` +use std::error::Error as StdError; + use cot::config::EmailUrl; -use cot::email::EmailMessage; +use cot::email::{EmailMessage, EmailMessageError}; +use lettre::message::header::ContentType; +use lettre::message::{Attachment, Body, Mailbox, MultiPart, SinglePart}; use lettre::transport::smtp; use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; use serde::{Deserialize, Serialize}; @@ -44,15 +48,21 @@ pub enum SMTPError { Io(#[from] std::io::Error), /// An error occurred while sending the email via SMTP. #[error("{ERROR_PREFIX} send error: {0}")] - SmtpSend(Box), + SmtpSend(Box), /// An error occurred while creating the transport. #[error("{ERROR_PREFIX} transport creation error: {0}")] - TransportCreation(Box), + TransportCreation(Box), + /// An error occurred while building the email message. + #[error("{ERROR_PREFIX} message error: {0}")] + MessageBuild(#[from] EmailMessageError), } impl From for TransportError { fn from(err: SMTPError) -> Self { - TransportError::Backend(err.to_string()) + match err { + SMTPError::MessageBuild(e) => TransportError::MessageBuildError(e), + other => TransportError::Backend(Box::new(other)), + } } } @@ -61,6 +71,7 @@ impl From for TransportError { /// The default is `Plain`. #[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] +#[non_exhaustive] pub enum Mechanism { /// PLAIN authentication mechanism defined in [RFC 4616](https://tools.ietf.org/html/rfc4616) /// This is the default authentication mechanism. @@ -111,6 +122,7 @@ impl From for smtp::authentication::Mechanism { /// email.send(msg).await?; /// # Ok(()) } #[derive(Debug, Clone)] +#[non_exhaustive] pub struct Smtp { transport: AsyncSmtpTransport, } @@ -145,7 +157,7 @@ impl Smtp { impl Transport for Smtp { async fn send(&self, messages: &[EmailMessage]) -> TransportResult<()> { for message in messages { - let m = Message::try_from(message.clone())?; + let m = convert_email_message_to_lettre_message(message.clone())?; self.transport .send(m) .await @@ -155,8 +167,78 @@ impl Transport for Smtp { } } +fn convert_email_message_to_lettre_message( + message: EmailMessage, +) -> Result { + let from_mailbox = message + .from + .email() + .as_str() + .parse::() + .map_err(|err| EmailMessageError::InvalidEmailAddress(Box::new(err)))?; + + let mut builder = Message::builder() + .from(from_mailbox) + .subject(message.subject); + + for to in message.to { + let mb = to + .email() + .as_str() + .parse::() + .map_err(|err| EmailMessageError::InvalidEmailAddress(Box::new(err)))?; + builder = builder.to(mb); + } + + for cc in message.cc { + let mb = cc + .email() + .as_str() + .parse::() + .map_err(|err| EmailMessageError::InvalidEmailAddress(Box::new(err)))?; + builder = builder.cc(mb); + } + + for bcc in message.bcc { + let mb = bcc + .email() + .as_str() + .parse::() + .map_err(|err| EmailMessageError::InvalidEmailAddress(Box::new(err)))?; + builder = builder.bcc(mb); + } + + for r in message.reply_to { + let mb = r + .email() + .as_str() + .parse::() + .map_err(|err| EmailMessageError::InvalidEmailAddress(Box::new(err)))?; + builder = builder.reply_to(mb); + } + + let mut mixed = MultiPart::mixed().singlepart(SinglePart::plain(message.body)); + + for attach in message.attachments { + let mime: ContentType = attach.content_type.parse().unwrap_or_else(|_| { + "application/octet-stream" + .parse() + .expect("could not parse default mime type") + }); + + let part = Attachment::new(attach.filename).body(Body::new(attach.data), mime); + mixed = mixed.singlepart(part); + } + + let email = builder + .multipart(mixed) + .map_err(|err| EmailMessageError::BuildError(Box::new(err)))?; + Ok(email) +} + #[cfg(test)] mod tests { + use cot::email::AttachmentData; use lettre::transport::smtp; use super::*; @@ -203,4 +285,91 @@ mod tests { let m_xoauth2: smtp::authentication::Mechanism = Mechanism::Xoauth2.into(); assert_eq!(m_xoauth2, smtp::authentication::Mechanism::Xoauth2); } + + #[cot::test] + async fn try_from_basic_converts_and_contains_headers() { + let msg = EmailMessage::builder() + .from(crate::common_types::Email::new("from@example.com").unwrap()) + .to(vec![ + crate::common_types::Email::new("to@example.com").unwrap(), + ]) + .subject("Hello World") + .body("This is the body.") + .build() + .unwrap(); + + let built: Message = + convert_email_message_to_lettre_message(msg).expect("conversion to lettre::Message"); + + let formatted = String::from_utf8_lossy(&built.formatted()).to_string(); + + assert!(formatted.contains("From: from@example.com"),); + assert!(formatted.contains("To: to@example.com"),); + assert!(formatted.contains("Subject: Hello World"),); + assert!(formatted.contains("Content-Type: multipart/mixed"),); + assert!(formatted.contains("This is the body."),); + } + + #[cot::test] + async fn try_from_includes_cc_and_reply_to_headers() { + let msg = EmailMessage::builder() + .from(crate::common_types::Email::new("sender@example.com").unwrap()) + .to(vec![ + crate::common_types::Email::new("primary@example.com").unwrap(), + ]) + .cc(vec![ + crate::common_types::Email::new("cc1@example.com").unwrap(), + crate::common_types::Email::new("cc2@example.com").unwrap(), + ]) + .bcc(vec![ + crate::common_types::Email::new("hidden@example.com").unwrap(), + ]) + .reply_to(vec![ + crate::common_types::Email::new("replyto@example.com").unwrap(), + ]) + .subject("Headers Test") + .body("Body") + .build() + .unwrap(); + + let built: Message = + convert_email_message_to_lettre_message(msg).expect("conversion to lettre::Message"); + let formatted = String::from_utf8_lossy(&built.formatted()).to_string(); + + assert!( + formatted.contains("Cc: cc1@example.com, cc2@example.com") + || (formatted.contains("Cc: cc1@example.com") + && formatted.contains("cc2@example.com")), + ); + assert!(formatted.contains("Reply-To: replyto@example.com"),); + } + + #[cot::test] + async fn try_from_with_attachment_uses_default_mime_on_parse_failure() { + let attachment = AttachmentData { + filename: "report.bin".to_string(), + content_type: "this/is not a valid mime".to_string(), + data: vec![0xDE, 0xAD, 0xBE, 0xEF], + }; + + let msg = EmailMessage::builder() + .from(crate::common_types::Email::new("sender@example.com").unwrap()) + .to(vec![ + crate::common_types::Email::new("to@example.com").unwrap(), + ]) + .subject("Attachment Test") + .body("Please see attachment") + .attachments(vec![attachment]) + .build() + .unwrap(); + + let built: Message = + convert_email_message_to_lettre_message(msg).expect("conversion to lettre::Message"); + let formatted = String::from_utf8_lossy(&built.formatted()).to_string(); + + assert!(formatted.contains("Content-Disposition: attachment"),); + assert!(formatted.contains("report.bin"),); + assert!(formatted.contains("Content-Type: application/octet-stream"),); + assert!(formatted.contains("Please see attachment")); + } } From c644c9ff84e0c646f187f1b069b1b6a0ab15988d Mon Sep 17 00:00:00 2001 From: Elijah Date: Tue, 6 Jan 2026 08:55:39 +0000 Subject: [PATCH 67/77] clippy fix --- cot/src/email.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/cot/src/email.rs b/cot/src/email.rs index 97d369d2..1e19a78f 100644 --- a/cot/src/email.rs +++ b/cot/src/email.rs @@ -40,8 +40,6 @@ use transport::{BoxedTransport, Transport}; use crate::email::transport::TransportError; use crate::email::transport::console::Console; use crate::error::error_impl::impl_into_cot_error; - -const ERROR_PREFIX: &str = "email error:"; const EMAIL_MESSAGE_BUILD_ERROR_PREFIX: &str = "email message build error:"; /// Represents errors that can occur when sending an email. From 3b986773256f51b2a3d336c2c811a2256b1e1db4 Mon Sep 17 00:00:00 2001 From: Elijah Date: Tue, 6 Jan 2026 10:20:36 +0000 Subject: [PATCH 68/77] clippy fix --- cot/src/email/transport/console.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/cot/src/email/transport/console.rs b/cot/src/email/transport/console.rs index 5035a819..18c2ba41 100644 --- a/cot/src/email/transport/console.rs +++ b/cot/src/email/transport/console.rs @@ -246,7 +246,6 @@ mod tests { } #[cot::test] - #[expect(clippy::default_constructed_unit_structs)] async fn display_minimal_message_renders_placeholders_and_omits_optional_headers() { let msg = EmailMessage::builder() .from(Addr::new("sender@example.com").unwrap()) From 5291ce835baaf1fb8ff6b3aec943d006c410dc96 Mon Sep 17 00:00:00 2001 From: Elijah Date: Tue, 6 Jan 2026 12:12:19 +0000 Subject: [PATCH 69/77] small change --- cot/src/config.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cot/src/config.rs b/cot/src/config.rs index 337ecaa2..f88fb081 100644 --- a/cot/src/config.rs +++ b/cot/src/config.rs @@ -1825,7 +1825,7 @@ impl Default for SessionMiddlewareConfig { } } -/// The type of email backend to use. +/// The type of email transport backend to use. /// /// This specifies what email backend is used for sending emails. /// The default backend if not specified is `console`. @@ -1834,14 +1834,14 @@ impl Default for SessionMiddlewareConfig { #[serde(tag = "type", rename_all = "snake_case")] #[non_exhaustive] pub enum EmailTransportTypeConfig { - /// Console email transport. + /// Console email transport backend. /// /// This is a convenient transport backend for development and testing that /// simply prints the email contents to the console instead of actually /// sending them. #[default] Console, - /// SMTP email backend. + /// SMTP email transport backend. /// /// This transport backend sends emails using the Simple Mail Transfer /// Protocol (SMTP). It requires authentication details and server From 84b6c472e0ae14e0f7a0a2838b6babda94601427 Mon Sep 17 00:00:00 2001 From: Elijah Date: Tue, 6 Jan 2026 16:27:08 +0000 Subject: [PATCH 70/77] address PR comments --- .github/workflows/rust.yml | 16 +++++++++------- cot/src/config.rs | 2 +- cot/src/email.rs | 2 +- cot/src/email/transport/console.rs | 2 +- examples/admin/Cargo.toml | 2 +- 5 files changed, 13 insertions(+), 11 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index deca5ae3..be41e683 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -14,6 +14,8 @@ env: CI: true SCCACHE_GHA_ENABLED: true RUSTC_WRAPPER: sccache + RUST_NIGHTLY_VERSION: nightly-2025-11-11 + RUST_STABLE_VERSION: stable # See: https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#concurrency. # This will ensure that only one commit will be running tests at a time on each PR. @@ -58,9 +60,9 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] include: - rust: stable - version: stable + version: RUST_STABLE_VERSION - rust: nightly - version: nightly-2025-11-11 + version: RUST_NIGHTLY_VERSION - rust: MSRV version: "1.88" # MSRV @@ -149,7 +151,7 @@ jobs: uses: dtolnay/rust-toolchain@master with: # cot_macros ui tests require nightly - toolchain: nightly-2025-11-11 + toolchain: RUST_NIGHTLY_VERSION - name: Cache Cargo registry uses: Swatinem/rust-cache@v2 @@ -195,7 +197,7 @@ jobs: uses: dtolnay/rust-toolchain@master with: # branch coverage is currently optional and requires nightly - toolchain: nightly-2025-11-11 + toolchain: RUST_NIGHTLY_VERSION components: llvm-tools-preview - name: Reclaim disk space @@ -248,7 +250,7 @@ jobs: uses: dtolnay/rust-toolchain@master with: # nightly-only rustfmt settings - toolchain: nightly-2025-11-11 + toolchain: RUST_NIGHTLY_VERSION components: rustfmt - name: Cache Cargo registry @@ -299,7 +301,7 @@ jobs: uses: dtolnay/rust-toolchain@master with: # the `-Z` flag is only accepted on the nightly channel of Cargo - toolchain: nightly-2025-11-11 + toolchain: RUST_NIGHTLY_VERSION - name: Cache Cargo registry uses: Swatinem/rust-cache@v2 @@ -368,7 +370,7 @@ jobs: uses: dtolnay/rust-toolchain@master with: # miri requires nightly - toolchain: nightly-2025-11-11 + toolchain: RUST_NIGHTLY_VERSION components: miri - name: Cache Cargo registry diff --git a/cot/src/config.rs b/cot/src/config.rs index f88fb081..628321b3 100644 --- a/cot/src/config.rs +++ b/cot/src/config.rs @@ -1853,7 +1853,7 @@ pub enum EmailTransportTypeConfig { /// domain for connecting to the SMTP server. /// /// The URL format is: - /// `scheme://user:password@host:port/?ehlo_domain=domain?tls=TLS`. + /// `scheme://user:password@host:port/?ehlo_domain=domain&tls=TLS`. /// /// `user`(username) and `password` are optional in the case the /// server does not require authentication. diff --git a/cot/src/email.rs b/cot/src/email.rs index 1e19a78f..477e6a3b 100644 --- a/cot/src/email.rs +++ b/cot/src/email.rs @@ -40,7 +40,7 @@ use transport::{BoxedTransport, Transport}; use crate::email::transport::TransportError; use crate::email::transport::console::Console; use crate::error::error_impl::impl_into_cot_error; -const EMAIL_MESSAGE_BUILD_ERROR_PREFIX: &str = "email message build error:"; +const ERROR_PREFIX: &str = "email message build error:"; /// Represents errors that can occur when sending an email. #[derive(Debug, Error)] diff --git a/cot/src/email/transport/console.rs b/cot/src/email/transport/console.rs index 18c2ba41..12d13b1b 100644 --- a/cot/src/email/transport/console.rs +++ b/cot/src/email/transport/console.rs @@ -16,7 +16,7 @@ //! # #[tokio::main] //! # async fn main() -> cot::Result<()>{ //! let email = cot::email::Email::new(Console::new()); -//! let recipients = vec![Email::try_from("testreceipient@example.com").unwrap()]; +//! let recipients = vec![Email::try_from("testrecipient@example.com").unwrap()]; //! let msg = EmailMessage::builder() //! .from(Email::try_from("no-reply@example.com").unwrap()) //! .to(vec![Email::try_from("user@example.com").unwrap()]) diff --git a/examples/admin/Cargo.toml b/examples/admin/Cargo.toml index c494fa40..e586327a 100644 --- a/examples/admin/Cargo.toml +++ b/examples/admin/Cargo.toml @@ -9,4 +9,4 @@ edition = "2024" [dependencies] async-trait = "0.1" askama = "0.15" -cot = { path = "../../cot", features = ["live-reload"] } +cot = { path = "../../cot", features = ["live-reload", "email"] } From c230c522a1610c0bc472b72ea775a9c46729d8a4 Mon Sep 17 00:00:00 2001 From: Elijah Date: Tue, 6 Jan 2026 16:30:28 +0000 Subject: [PATCH 71/77] fix CI error --- cot/src/email.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cot/src/email.rs b/cot/src/email.rs index 477e6a3b..ffcf0797 100644 --- a/cot/src/email.rs +++ b/cot/src/email.rs @@ -171,13 +171,13 @@ impl EmailMessageBuilder { #[non_exhaustive] pub enum EmailMessageError { /// An invalid email address was provided. - #[error("{EMAIL_MESSAGE_BUILD_ERROR_PREFIX} invalid email address: {0}")] + #[error("{ERROR_PREFIX} invalid email address: {0}")] InvalidEmailAddress(Box), /// Failed to build the email message. - #[error("{EMAIL_MESSAGE_BUILD_ERROR_PREFIX} failed to build email message: {0}")] + #[error("{ERROR_PREFIX} failed to build email message: {0}")] BuildError(Box), /// A required field is missing in the email message. - #[error("{EMAIL_MESSAGE_BUILD_ERROR_PREFIX} The `{0}` field is required but was not set")] + #[error("{ERROR_PREFIX} The `{0}` field is required but was not set")] MissingField(String), } From 24e39d5b6545b5978b1a486103bec10752f61f7e Mon Sep 17 00:00:00 2001 From: Elijah Date: Tue, 6 Jan 2026 16:59:31 +0000 Subject: [PATCH 72/77] fix silly error --- .github/workflows/rust.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index be41e683..12eb6acd 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -60,9 +60,9 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] include: - rust: stable - version: RUST_STABLE_VERSION + version: ${{ env.RUST_STABLE_VERSION }} - rust: nightly - version: RUST_NIGHTLY_VERSION + version: ${{ env.RUST_NIGHTLY_VERSION }} - rust: MSRV version: "1.88" # MSRV @@ -151,7 +151,7 @@ jobs: uses: dtolnay/rust-toolchain@master with: # cot_macros ui tests require nightly - toolchain: RUST_NIGHTLY_VERSION + toolchain: ${{ env.RUST_NIGHTLY_VERSION }} - name: Cache Cargo registry uses: Swatinem/rust-cache@v2 @@ -197,7 +197,7 @@ jobs: uses: dtolnay/rust-toolchain@master with: # branch coverage is currently optional and requires nightly - toolchain: RUST_NIGHTLY_VERSION + toolchain: ${{ env.RUST_NIGHTLY_VERSION }} components: llvm-tools-preview - name: Reclaim disk space @@ -250,7 +250,7 @@ jobs: uses: dtolnay/rust-toolchain@master with: # nightly-only rustfmt settings - toolchain: RUST_NIGHTLY_VERSION + toolchain: ${{ env.RUST_NIGHTLY_VERSION }} components: rustfmt - name: Cache Cargo registry @@ -301,7 +301,7 @@ jobs: uses: dtolnay/rust-toolchain@master with: # the `-Z` flag is only accepted on the nightly channel of Cargo - toolchain: RUST_NIGHTLY_VERSION + toolchain: ${{ env.RUST_NIGHTLY_VERSION }} - name: Cache Cargo registry uses: Swatinem/rust-cache@v2 @@ -370,7 +370,7 @@ jobs: uses: dtolnay/rust-toolchain@master with: # miri requires nightly - toolchain: RUST_NIGHTLY_VERSION + toolchain: ${{ env.RUST_NIGHTLY_VERSION }} components: miri - name: Cache Cargo registry From 3b1c4239d68615e49be2298a0b82d234c2328b2a Mon Sep 17 00:00:00 2001 From: Elijah Date: Tue, 6 Jan 2026 18:40:21 +0000 Subject: [PATCH 73/77] insta snapshot --- .../cli__snapshot_testing__new__create_new_project-5.snap | 2 +- .../cli__snapshot_testing__new__create_new_project-6.snap | 2 +- ...hot_testing__new__create_new_project_with_custom_name-5.snap | 2 +- ...hot_testing__new__create_new_project_with_custom_name-6.snap | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-5.snap b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-5.snap index d94ef43b..69d73a4b 100644 --- a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-5.snap +++ b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-5.snap @@ -6,7 +6,7 @@ info: args: - new - "-vvvv" - - /var/folders/xy/x4cxwym16gdddzy9qmvfngl80000gn/T/cot-test-JQ0H2S/project + - /tmp/cot-test-s3CCv5/project --- success: true exit_code: 0 diff --git a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-6.snap b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-6.snap index cd7a3de3..8801f6e3 100644 --- a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-6.snap +++ b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-6.snap @@ -6,7 +6,7 @@ info: args: - new - "-vvvvv" - - /var/folders/xy/x4cxwym16gdddzy9qmvfngl80000gn/T/cot-test-WPGfFG/project + - /tmp/cot-test-KRWMM5/project --- success: true exit_code: 0 diff --git a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-5.snap b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-5.snap index ef598073..1e588a51 100644 --- a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-5.snap +++ b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-5.snap @@ -8,7 +8,7 @@ info: - "--name" - my_project - "-vvvv" - - /var/folders/xy/x4cxwym16gdddzy9qmvfngl80000gn/T/cot-test-EjKoWa/project + - /tmp/cot-test-uCHzuc/project --- success: true exit_code: 0 diff --git a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-6.snap b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-6.snap index e7a44f83..62f5e7b3 100644 --- a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-6.snap +++ b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-6.snap @@ -8,7 +8,7 @@ info: - "--name" - my_project - "-vvvvv" - - /var/folders/xy/x4cxwym16gdddzy9qmvfngl80000gn/T/cot-test-HPo3ol/project + - /tmp/cot-test-sFO2nz/project --- success: true exit_code: 0 From 39365151a2f59cbfdcb90039ec4e303f57b6013f Mon Sep 17 00:00:00 2001 From: Elijah Date: Wed, 7 Jan 2026 17:48:30 +0000 Subject: [PATCH 74/77] address nit --- .github/workflows/rust.yml | 1 + examples/admin/Cargo.toml | 2 +- examples/send-email/src/main.rs | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 12eb6acd..0fdca993 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -14,6 +14,7 @@ env: CI: true SCCACHE_GHA_ENABLED: true RUSTC_WRAPPER: sccache + # Pinning the nightly version to a "stable" version to avoid CI breakages. RUST_NIGHTLY_VERSION: nightly-2025-11-11 RUST_STABLE_VERSION: stable diff --git a/examples/admin/Cargo.toml b/examples/admin/Cargo.toml index e586327a..c494fa40 100644 --- a/examples/admin/Cargo.toml +++ b/examples/admin/Cargo.toml @@ -9,4 +9,4 @@ edition = "2024" [dependencies] async-trait = "0.1" askama = "0.15" -cot = { path = "../../cot", features = ["live-reload", "email"] } +cot = { path = "../../cot", features = ["live-reload"] } diff --git a/examples/send-email/src/main.rs b/examples/send-email/src/main.rs index 34d32455..da169050 100644 --- a/examples/send-email/src/main.rs +++ b/examples/send-email/src/main.rs @@ -108,7 +108,7 @@ async fn send_email(urls: Urls, mut request: Request) -> cot::Result { request.email().send(message).await?; - // Fixme: We should redirect with the status when reverse_redirect! supports + // TODO: We should redirect with the status when reverse_redirect! supports // query parameters Ok(reverse_redirect!(&urls, "index/")?) } From 631789e6f109242e66bff1c0e55827f24a4655af Mon Sep 17 00:00:00 2001 From: Elijah Date: Thu, 8 Jan 2026 13:45:58 +0000 Subject: [PATCH 75/77] Trigger CI --- examples/send-email/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/send-email/src/main.rs b/examples/send-email/src/main.rs index da169050..3356900d 100644 --- a/examples/send-email/src/main.rs +++ b/examples/send-email/src/main.rs @@ -109,7 +109,7 @@ async fn send_email(urls: Urls, mut request: Request) -> cot::Result { request.email().send(message).await?; // TODO: We should redirect with the status when reverse_redirect! supports - // query parameters + // query parameters. Ok(reverse_redirect!(&urls, "index/")?) } From cc78b7ea05e6bb1c5fdf203bb0730f80e9137b98 Mon Sep 17 00:00:00 2001 From: Elijah Date: Thu, 8 Jan 2026 14:26:47 +0000 Subject: [PATCH 76/77] use yaml anchors --- .github/workflows/rust.yml | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 0fdca993..a2326981 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -1,5 +1,10 @@ name: Rust CI +_versions: + stable: &rust_stable stable + # Pinning the nightly version to a "stable" version to avoid CI breakages. + nightly: &rust_nightly nightly-2025-11-11 + on: push: pull_request: @@ -14,9 +19,6 @@ env: CI: true SCCACHE_GHA_ENABLED: true RUSTC_WRAPPER: sccache - # Pinning the nightly version to a "stable" version to avoid CI breakages. - RUST_NIGHTLY_VERSION: nightly-2025-11-11 - RUST_STABLE_VERSION: stable # See: https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#concurrency. # This will ensure that only one commit will be running tests at a time on each PR. @@ -61,9 +63,9 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] include: - rust: stable - version: ${{ env.RUST_STABLE_VERSION }} + version: *rust_stable - rust: nightly - version: ${{ env.RUST_NIGHTLY_VERSION }} + version: *rust_nightly - rust: MSRV version: "1.88" # MSRV @@ -152,7 +154,7 @@ jobs: uses: dtolnay/rust-toolchain@master with: # cot_macros ui tests require nightly - toolchain: ${{ env.RUST_NIGHTLY_VERSION }} + toolchain: *rust_nightly - name: Cache Cargo registry uses: Swatinem/rust-cache@v2 @@ -198,7 +200,7 @@ jobs: uses: dtolnay/rust-toolchain@master with: # branch coverage is currently optional and requires nightly - toolchain: ${{ env.RUST_NIGHTLY_VERSION }} + toolchain: *rust_nightly components: llvm-tools-preview - name: Reclaim disk space @@ -251,7 +253,7 @@ jobs: uses: dtolnay/rust-toolchain@master with: # nightly-only rustfmt settings - toolchain: ${{ env.RUST_NIGHTLY_VERSION }} + toolchain: *rust_nightly components: rustfmt - name: Cache Cargo registry @@ -302,7 +304,7 @@ jobs: uses: dtolnay/rust-toolchain@master with: # the `-Z` flag is only accepted on the nightly channel of Cargo - toolchain: ${{ env.RUST_NIGHTLY_VERSION }} + toolchain: *rust_nightly - name: Cache Cargo registry uses: Swatinem/rust-cache@v2 @@ -371,7 +373,7 @@ jobs: uses: dtolnay/rust-toolchain@master with: # miri requires nightly - toolchain: ${{ env.RUST_NIGHTLY_VERSION }} + toolchain: *rust_nightly components: miri - name: Cache Cargo registry From 26ef6bbadbe384fadda93cbe38c02a65673fee2e Mon Sep 17 00:00:00 2001 From: Elijah Date: Thu, 8 Jan 2026 14:37:19 +0000 Subject: [PATCH 77/77] that did not work --- .github/workflows/rust.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index a2326981..c04a7557 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -1,10 +1,5 @@ name: Rust CI -_versions: - stable: &rust_stable stable - # Pinning the nightly version to a "stable" version to avoid CI breakages. - nightly: &rust_nightly nightly-2025-11-11 - on: push: pull_request: @@ -20,6 +15,10 @@ env: SCCACHE_GHA_ENABLED: true RUSTC_WRAPPER: sccache + _RUST_STABLE: &rust_stable stable + # Pinning the nightly version to a "stable" version to avoid CI breakages. + _RUST_NIGHTLY: &rust_nightly nightly-2025-11-11 + # See: https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#concurrency. # This will ensure that only one commit will be running tests at a time on each PR. concurrency: