From bb49e0a46bea7e73316044d0c651e2003325c468 Mon Sep 17 00:00:00 2001 From: Marlon Date: Sat, 23 Sep 2023 08:48:36 +0200 Subject: [PATCH] Construct a SmtpTransport from a connection URL (#901) --- Cargo.toml | 3 +- src/transport/smtp/async_transport.rs | 110 +++++++++++++++-- src/transport/smtp/connection_url.rs | 120 ++++++++++++++++++ src/transport/smtp/mod.rs | 1 + src/transport/smtp/transport.rs | 168 ++++++++++++++++++++++++-- 5 files changed, 383 insertions(+), 19 deletions(-) create mode 100644 src/transport/smtp/connection_url.rs diff --git a/Cargo.toml b/Cargo.toml index f2320c8..4da30ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ serde_json = { version = "1", optional = true } nom = { version = "7", optional = true } hostname = { version = "0.3", optional = true } # feature socket2 = { version = "0.5.1", optional = true } +url = { version = "2.4", optional = true } ## tls native-tls = { version = "0.2.5", optional = true } # feature @@ -103,7 +104,7 @@ mime03 = ["dep:mime"] file-transport = ["dep:uuid", "tokio1_crate?/fs", "tokio1_crate?/io-util"] file-transport-envelope = ["serde", "dep:serde_json", "file-transport"] sendmail-transport = ["tokio1_crate?/process", "tokio1_crate?/io-util", "async-std?/unstable"] -smtp-transport = ["dep:base64", "dep:nom", "dep:socket2", "dep:once_cell", "tokio1_crate?/rt", "tokio1_crate?/time", "tokio1_crate?/net"] +smtp-transport = ["dep:base64", "dep:nom", "dep:socket2", "dep:once_cell", "dep:url", "tokio1_crate?/rt", "tokio1_crate?/time", "tokio1_crate?/net"] pool = ["dep:futures-util"] diff --git a/src/transport/smtp/async_transport.rs b/src/transport/smtp/async_transport.rs index cfbf4e7..68fcf60 100644 --- a/src/transport/smtp/async_transport.rs +++ b/src/transport/smtp/async_transport.rs @@ -158,15 +158,93 @@ where /// [`AsyncSmtpTransport::starttls_relay`](#method.starttls_relay) instead, /// if possible. pub fn builder_dangerous>(server: T) -> AsyncSmtpTransportBuilder { - let info = SmtpInfo { - server: server.into(), - ..Default::default() - }; - AsyncSmtpTransportBuilder { - info, - #[cfg(feature = "pool")] - pool_config: PoolConfig::default(), - } + AsyncSmtpTransportBuilder::new(server) + } + + /// Creates a `AsyncSmtpTransportBuilder` from a connection URL + /// + /// The protocol, credentials, host and port can be provided in a single URL. + /// Use the scheme `smtp` for an unencrypted relay (optionally in combination with the + /// `tls` parameter to allow/require STARTTLS) or `smtps` for SMTP over TLS. + /// The path section of the url can be used to set an alternative name for + /// the HELO / EHLO command. + /// For example `smtps://username:password@smtp.example.com/client.example.com:465` + /// will set the HELO / EHLO name `client.example.com`. + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + ///
schemetls parameterexampleremarks
smtps-smtps://smtp.example.comSMTP over TLS, recommended method
smtprequiredsmtp://smtp.example.com?tls=requiredSMTP with STARTTLS required, when SMTP over TLS is not available
smtpopportunisticsmtp://smtp.example.com?tls=opportunistic + /// SMTP with optionally STARTTLS when supported by the server. + /// Caution: this method is vulnerable to a man-in-the-middle attack. + /// Not recommended for production use. + ///
smtp-smtp://smtp.example.comUnencrypted SMTP, not recommended for production use.
+ /// + /// ```rust,no_run + /// use lettre::{ + /// message::header::ContentType, transport::smtp::authentication::Credentials, + /// AsyncSmtpTransport, Message, Transport, + /// }; + /// + /// let email = Message::builder() + /// .from("NoBody ".parse().unwrap()) + /// .reply_to("Yuin ".parse().unwrap()) + /// .to("Hei ".parse().unwrap()) + /// .subject("Happy new year") + /// .header(ContentType::TEXT_PLAIN) + /// .body(String::from("Be happy!")) + /// .unwrap(); + /// + /// // Open a remote connection to gmail + /// let mailer = AsyncSmtpTransport::from_url("smtps://username:password@smtp.example.com:465") + /// .unwrap() + /// .build(); + /// + /// // Send the email + /// match mailer.send(&email).await { + /// Ok(_) => println!("Email sent successfully!"), + /// Err(e) => panic!("Could not send email: {e:?}"), + /// } + /// ``` + #[cfg(any(feature = "native-tls", feature = "rustls-tls", feature = "boring-tls"))] + #[cfg_attr( + docsrs, + doc(cfg(any(feature = "native-tls", feature = "rustls-tls", feature = "boring-tls"))) + )] + pub fn from_url(connection_url: &str) -> Result { + super::connection_url::from_connection_url(connection_url) } /// Tests the SMTP connection @@ -219,6 +297,20 @@ pub struct AsyncSmtpTransportBuilder { /// Builder for the SMTP `AsyncSmtpTransport` impl AsyncSmtpTransportBuilder { + // Create new builder with default parameters + pub(crate) fn new>(server: T) -> Self { + let info = SmtpInfo { + server: server.into(), + ..Default::default() + }; + + AsyncSmtpTransportBuilder { + info, + #[cfg(feature = "pool")] + pool_config: PoolConfig::default(), + } + } + /// Set the name used during EHLO pub fn hello_name(mut self, name: ClientId) -> Self { self.info.hello_name = name; diff --git a/src/transport/smtp/connection_url.rs b/src/transport/smtp/connection_url.rs new file mode 100644 index 0000000..6ac5ee7 --- /dev/null +++ b/src/transport/smtp/connection_url.rs @@ -0,0 +1,120 @@ +use url::Url; + +#[cfg(any(feature = "native-tls", feature = "rustls-tls", feature = "boring-tls"))] +use super::client::{Tls, TlsParameters}; +#[cfg(any(feature = "tokio1", feature = "async-std1"))] +use super::AsyncSmtpTransportBuilder; +use super::{ + authentication::Credentials, error, extension::ClientId, Error, SmtpTransportBuilder, + SMTP_PORT, SUBMISSIONS_PORT, SUBMISSION_PORT, +}; + +pub(crate) trait TransportBuilder { + fn new>(server: T) -> Self; + fn tls(self, tls: super::Tls) -> Self; + fn port(self, port: u16) -> Self; + fn credentials(self, credentials: Credentials) -> Self; + fn hello_name(self, name: ClientId) -> Self; +} + +impl TransportBuilder for SmtpTransportBuilder { + fn new>(server: T) -> Self { + Self::new(server) + } + + fn tls(self, tls: super::Tls) -> Self { + self.tls(tls) + } + + fn port(self, port: u16) -> Self { + self.port(port) + } + + fn credentials(self, credentials: Credentials) -> Self { + self.credentials(credentials) + } + + fn hello_name(self, name: ClientId) -> Self { + self.hello_name(name) + } +} + +#[cfg(any(feature = "tokio1", feature = "async-std1"))] +impl TransportBuilder for AsyncSmtpTransportBuilder { + fn new>(server: T) -> Self { + Self::new(server) + } + + fn tls(self, tls: super::Tls) -> Self { + self.tls(tls) + } + + fn port(self, port: u16) -> Self { + self.port(port) + } + + fn credentials(self, credentials: Credentials) -> Self { + self.credentials(credentials) + } + + fn hello_name(self, name: ClientId) -> Self { + self.hello_name(name) + } +} + +/// Create a new SmtpTransportBuilder or AsyncSmtpTransportBuilder from a connection URL +pub(crate) fn from_connection_url(connection_url: &str) -> Result { + let connection_url = Url::parse(connection_url).map_err(error::connection)?; + let tls: Option = connection_url + .query_pairs() + .find(|(k, _)| k == "tls") + .map(|(_, v)| v.to_string()); + + let host = connection_url + .host_str() + .ok_or_else(|| error::connection("smtp host undefined"))?; + + let mut builder = B::new(host); + + match (connection_url.scheme(), tls.as_deref()) { + ("smtp", None) => { + builder = builder.port(connection_url.port().unwrap_or(SMTP_PORT)); + } + #[cfg(any(feature = "native-tls", feature = "rustls-tls", feature = "boring-tls"))] + ("smtp", Some("required")) => { + builder = builder + .port(connection_url.port().unwrap_or(SUBMISSION_PORT)) + .tls(Tls::Required(TlsParameters::new(host.into())?)) + } + #[cfg(any(feature = "native-tls", feature = "rustls-tls", feature = "boring-tls"))] + ("smtp", Some("opportunistic")) => { + builder = builder + .port(connection_url.port().unwrap_or(SUBMISSION_PORT)) + .tls(Tls::Opportunistic(TlsParameters::new(host.into())?)) + } + #[cfg(any(feature = "native-tls", feature = "rustls-tls", feature = "boring-tls"))] + ("smtps", _) => { + builder = builder + .port(connection_url.port().unwrap_or(SUBMISSIONS_PORT)) + .tls(Tls::Wrapper(TlsParameters::new(host.into())?)) + } + (scheme, tls) => { + return Err(error::connection(format!( + "Unknown scheme '{scheme}' or tls parameter '{tls:?}', note that a transport with TLS requires one of the TLS features" + ))) + } + }; + + // use the path segment of the URL as name in the name in the HELO / EHLO command + if connection_url.path().len() > 1 { + let name = connection_url.path().trim_matches('/').to_owned(); + builder = builder.hello_name(ClientId::Domain(name)); + } + + if let Some(password) = connection_url.password() { + let credentials = Credentials::new(connection_url.username().into(), password.into()); + builder = builder.credentials(credentials); + } + + Ok(builder) +} diff --git a/src/transport/smtp/mod.rs b/src/transport/smtp/mod.rs index 8b07760..390ac62 100644 --- a/src/transport/smtp/mod.rs +++ b/src/transport/smtp/mod.rs @@ -154,6 +154,7 @@ mod async_transport; pub mod authentication; pub mod client; pub mod commands; +mod connection_url; mod error; pub mod extension; #[cfg(feature = "pool")] diff --git a/src/transport/smtp/transport.rs b/src/transport/smtp/transport.rs index 34b7c07..dd3a9a0 100644 --- a/src/transport/smtp/transport.rs +++ b/src/transport/smtp/transport.rs @@ -102,16 +102,93 @@ impl SmtpTransport { /// [`SmtpTransport::starttls_relay`](#method.starttls_relay) instead, /// if possible. pub fn builder_dangerous>(server: T) -> SmtpTransportBuilder { - let new = SmtpInfo { - server: server.into(), - ..Default::default() - }; + SmtpTransportBuilder::new(server) + } - SmtpTransportBuilder { - info: new, - #[cfg(feature = "pool")] - pool_config: PoolConfig::default(), - } + /// Creates a `SmtpTransportBuilder` from a connection URL + /// + /// The protocol, credentials, host and port can be provided in a single URL. + /// Use the scheme `smtp` for an unencrypted relay (optionally in combination with the + /// `tls` parameter to allow/require STARTTLS) or `smtps` for SMTP over TLS. + /// The path section of the url can be used to set an alternative name for + /// the HELO / EHLO command. + /// For example `smtps://username:password@smtp.example.com/client.example.com:465` + /// will set the HELO / EHLO name `client.example.com`. + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + ///
schemetls parameterexampleremarks
smtps-smtps://smtp.example.comSMTP over TLS, recommended method
smtprequiredsmtp://smtp.example.com?tls=requiredSMTP with STARTTLS required, when SMTP over TLS is not available
smtpopportunisticsmtp://smtp.example.com?tls=opportunistic + /// SMTP with optionally STARTTLS when supported by the server. + /// Caution: this method is vulnerable to a man-in-the-middle attack. + /// Not recommended for production use. + ///
smtp-smtp://smtp.example.comUnencrypted SMTP, not recommended for production use.
+ /// + /// ```rust,no_run + /// use lettre::{ + /// message::header::ContentType, transport::smtp::authentication::Credentials, Message, + /// SmtpTransport, Transport, + /// }; + /// + /// let email = Message::builder() + /// .from("NoBody ".parse().unwrap()) + /// .reply_to("Yuin ".parse().unwrap()) + /// .to("Hei ".parse().unwrap()) + /// .subject("Happy new year") + /// .header(ContentType::TEXT_PLAIN) + /// .body(String::from("Be happy!")) + /// .unwrap(); + /// + /// // Open a remote connection to example + /// let mailer = SmtpTransport::from_url("smtps://username:password@smtp.example.com:465") + /// .unwrap() + /// .build(); + /// + /// // Send the email + /// match mailer.send(&email) { + /// Ok(_) => println!("Email sent successfully!"), + /// Err(e) => panic!("Could not send email: {e:?}"), + /// } + /// ``` + #[cfg(any(feature = "native-tls", feature = "rustls-tls", feature = "boring-tls"))] + #[cfg_attr( + docsrs, + doc(cfg(any(feature = "native-tls", feature = "rustls-tls", feature = "boring-tls"))) + )] + pub fn from_url(connection_url: &str) -> Result { + super::connection_url::from_connection_url(connection_url) } /// Tests the SMTP connection @@ -141,6 +218,20 @@ pub struct SmtpTransportBuilder { /// Builder for the SMTP `SmtpTransport` impl SmtpTransportBuilder { + // Create new builder with default parameters + pub(crate) fn new>(server: T) -> Self { + let new = SmtpInfo { + server: server.into(), + ..Default::default() + }; + + Self { + info: new, + #[cfg(feature = "pool")] + pool_config: PoolConfig::default(), + } + } + /// Set the name used during EHLO pub fn hello_name(mut self, name: ClientId) -> Self { self.info.hello_name = name; @@ -252,3 +343,62 @@ impl SmtpClient { Ok(conn) } } + +#[cfg(test)] +mod tests { + use crate::{ + transport::smtp::{authentication::Credentials, client::Tls}, + SmtpTransport, + }; + + #[test] + fn transport_from_url() { + let builder = SmtpTransport::from_url("smtp://127.0.0.1:2525").unwrap(); + + assert_eq!(builder.info.port, 2525); + assert!(matches!(builder.info.tls, Tls::None)); + assert_eq!(builder.info.server, "127.0.0.1"); + + let builder = + SmtpTransport::from_url("smtps://username:password@smtp.example.com:465").unwrap(); + + assert_eq!(builder.info.port, 465); + assert_eq!( + builder.info.credentials, + Some(Credentials::new( + "username".to_owned(), + "password".to_owned() + )) + ); + assert!(matches!(builder.info.tls, Tls::Wrapper(_))); + assert_eq!(builder.info.server, "smtp.example.com"); + + let builder = + SmtpTransport::from_url("smtp://username:password@smtp.example.com:587?tls=required") + .unwrap(); + + assert_eq!(builder.info.port, 587); + assert_eq!( + builder.info.credentials, + Some(Credentials::new( + "username".to_owned(), + "password".to_owned() + )) + ); + assert!(matches!(builder.info.tls, Tls::Required(_))); + + let builder = SmtpTransport::from_url( + "smtp://username:password@smtp.example.com:587?tls=opportunistic", + ) + .unwrap(); + + assert_eq!(builder.info.port, 587); + assert!(matches!(builder.info.tls, Tls::Opportunistic(_))); + + let builder = SmtpTransport::from_url("smtps://smtp.example.com").unwrap(); + + assert_eq!(builder.info.port, 465); + assert_eq!(builder.info.credentials, None); + assert!(matches!(builder.info.tls, Tls::Wrapper(_))); + } +}