diff --git a/Cargo.lock b/Cargo.lock index cf494cba5..ad6648306 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -335,18 +335,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "async-native-tls" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d57d4cec3c647232e1094dc013546c0b33ce785d8aeb251e1f20dfaf8a9a13fe" -dependencies = [ - "native-tls", - "thiserror", - "tokio", - "url", -] - [[package]] name = "async-reactor-trait" version = "1.1.0" @@ -372,24 +360,17 @@ dependencies = [ [[package]] name = "async-smtp" -version = "0.6.0" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ade89127f9e0d44f9e83cf574d499060005cd45b7dc76be89c0167487fe8edd" +checksum = "00d1f1a16e5abad3ada9f1f23dbc2f354b138121b90533381be62dada6cbf40a" dependencies = [ - "async-native-tls", - "async-trait", + "anyhow", "base64 0.13.1", - "bufstream", - "fast-socks5 0.8.2", "futures", "hostname", "log", "nom", "pin-project", - "pin-utils", - "serde", - "serde_derive", - "serde_json", "thiserror", "tokio", ] @@ -528,12 +509,6 @@ dependencies = [ "piper", ] -[[package]] -name = "bufstream" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40e38929add23cdf8a366df9b0e088953150724bcbe5fc330b0d8eb3b328eec8" - [[package]] name = "bumpalo" version = "3.16.0" @@ -577,14 +552,14 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" name = "check-if-email-exists" version = "0.10.0" dependencies = [ - "async-native-tls", + "anyhow", "async-recursion", "async-smtp", "chrono", "config", "derive_builder 0.20.2", "fantoccini", - "fast-socks5 0.9.6", + "fast-socks5", "futures", "hickory-proto", "hickory-resolver", @@ -1274,19 +1249,6 @@ dependencies = [ "webdriver", ] -[[package]] -name = "fast-socks5" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "961ce1761191c157145a8c9f0c3ceabecd3a729d65c9a8d443674eaee3420f7e" -dependencies = [ - "anyhow", - "log", - "thiserror", - "tokio", - "tokio-stream", -] - [[package]] name = "fast-socks5" version = "0.9.6" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index ff1d51dc9..7ce3794a0 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -7,7 +7,7 @@ publish = false [dependencies] anyhow = "1.0" -async-smtp = "0.6" +async-smtp = { version = "0.9.1", features = ["runtime-tokio"] } check-if-email-exists = { path = "../core", features = ["sentry"] } config = "0.14" csv = "1.3.0" diff --git a/backend/src/http/v0/check_email/post.rs b/backend/src/http/v0/check_email/post.rs index 7f774b0d7..9da6865ef 100644 --- a/backend/src/http/v0/check_email/post.rs +++ b/backend/src/http/v0/check_email/post.rs @@ -62,7 +62,9 @@ impl CheckEmailRequest { .or_else(|| config.proxy.as_ref()) .cloned(), smtp_timeout: config.smtp_timeout.map(Duration::from_secs), - smtp_port: self.smtp_port.unwrap_or_default(), + smtp_port: self + .smtp_port + .unwrap_or_else(|| CheckEmailInput::default().smtp_port), sentry_dsn: config.sentry_dsn.clone(), ..Default::default() } diff --git a/core/Cargo.toml b/core/Cargo.toml index 5f63915e7..0e9ea3a19 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -13,15 +13,15 @@ readme = "../README.md" repository = "https://github.com/reacherhq/check-if-email-exists" [dependencies] -async-native-tls = { version = "0.4", default-features = false } +anyhow = "1.0" async-recursion = "1.0.5" -async-smtp = { version = "0.6.0", features = ["socks5"] } +async-smtp = { version = "0.9.1", features = ["runtime-tokio"] } chrono = { version = "0.4.31", features = ["serde"] } config = "0.14" derive_builder = "0.20" +fast-socks5 = "0.9" fantoccini = { version = "0.21.2" } futures = { version = "0.3.30" } -fast-socks5 = "0.9.2" hickory-proto = "0.24.0" hickory-resolver = "0.24.0" levenshtein = "1.0.5" @@ -37,7 +37,5 @@ sentry = { version = "0.23", optional = true } serde = { version = "1.0.214", features = ["derive"] } serde_json = "1.0.133" thiserror = "1.0" +tokio = { version = "1.40.0", features = ["rt-multi-thread", "macros"] } tracing = "0.1.40" - -[dev-dependencies] -tokio = { version = "1.40.0" } diff --git a/core/src/lib.rs b/core/src/lib.rs index 5aaa2cdfd..23619ff59 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -23,13 +23,13 @@ //! - Email deliverability: Is an email sent to this address deliverable? //! - Syntax validation. Is the address syntactically valid? //! - DNS records validation. Does the domain of the email address have valid -//! MX DNS records? +//! MX DNS records? //! - Disposable email address (DEA) validation. Is the address provided by a -//! known disposable email address provider? +//! known disposable email address provider? //! - SMTP server validation. Can the mail exchanger of the email address -//! domain be contacted successfully? +//! domain be contacted successfully? //! - Mailbox disabled. Has this email address been disabled by the email -//! provider? +//! provider? //! - Full inbox. Is the inbox of this mailbox full? //! - Catch-all address. Is this email address a catch-all address? //! diff --git a/core/src/rules.rs b/core/src/rules.rs index f578cdabc..30c209126 100644 --- a/core/src/rules.rs +++ b/core/src/rules.rs @@ -85,3 +85,21 @@ pub fn has_rule(domain: &str, host: &str, rule: &Rule) -> bool { || does_mx_have_rule(host, rule) || does_mx_suffix_have_rule(host, rule) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_skip_catch_all() { + assert_eq!( + true, + has_rule("gmail.com", "alt4.aspmx.l.google.com.", &Rule::SkipCatchAll) + ); + + assert_eq!( + true, + has_rule("domain.com", ".antispamcloud.com.", &Rule::SkipCatchAll) + ) + } +} diff --git a/core/src/smtp/connect.rs b/core/src/smtp/connect.rs index a7c85000c..904a3670b 100644 --- a/core/src/smtp/connect.rs +++ b/core/src/smtp/connect.rs @@ -14,127 +14,107 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use async_native_tls::TlsConnector; use async_recursion::async_recursion; -use async_smtp::{ - smtp::{commands::*, extension::ClientId, ServerAddress, Socks5Config}, - ClientTlsParameters, EmailAddress, SmtpClient, SmtpTransport, -}; +use async_smtp::commands::{MailCommand, RcptCommand}; +use async_smtp::extension::ClientId; +use async_smtp::{SmtpClient, SmtpTransport}; +use fast_socks5::client::Config; +use fast_socks5::{client::Socks5Stream, Result}; use rand::rngs::SmallRng; use rand::{distributions::Alphanumeric, Rng, SeedableRng}; use std::iter; use std::str::FromStr; -use std::time::Duration; +use tokio::io::{AsyncBufRead, AsyncRead, AsyncWrite, BufStream}; +use tokio::net::TcpStream; use super::parser; use super::{SmtpDetails, SmtpError}; -use crate::LOG_TARGET; use crate::{ rules::{has_rule, Rule}, util::input_output::CheckEmailInput, }; +use crate::{EmailAddress, LOG_TARGET}; + +// Define a new trait that combines AsyncRead, AsyncWrite, and Unpin +trait AsyncReadWrite: AsyncRead + AsyncWrite + Unpin + Send {} +impl AsyncReadWrite for T {} /// Try to send an smtp command, close and return Err if fails. macro_rules! try_smtp ( ($res: expr, $client: ident, $to_email: expr, $host: expr, $port: expr) => ({ if let Err(err) = $res { - log::debug!(target: LOG_TARGET, "[email={}] Closing [host={}:{}], because of error '{:?}'.", $to_email, $host, $port, err); + log::debug!( + target: LOG_TARGET, + "[email={}] Closing [host={}:{}], because of error '{:?}'.", + $to_email, $host, $port,err, + ); // Try to close the connection, but ignore if there's an error. - let _ = $client.close().await; + let _ = $client.quit().await; - return Err(SmtpError::SmtpError(err)); + return Err(SmtpError::AsyncSmtpError(err)); } }) ); -/// Attempt to connect to host via SMTP, and return SMTP client on success. -async fn connect_to_host( - domain: &str, +/// Connect to an SMTP host and return the configured client transport. +async fn connect_to_smtp_host( host: &str, port: u16, input: &CheckEmailInput, -) -> Result { - let smtp_timeout = if let Some(t) = input.smtp_timeout { - if has_rule(domain, host, &Rule::SmtpTimeout45s) { - let duration = t.max(Duration::from_secs(45)); - log::debug!( - target: LOG_TARGET, - "[email={}] Bumping SMTP timeout to {duration:?} because of rule", - input.to_email, - ); - Some(duration) - } else { - input.smtp_timeout - } - } else { - None - }; - +) -> Result>>, SmtpError> { // hostname verification fails if it ends with '.', for example, using // SOCKS5 proxies we can `io: incomplete` error. - let host = host.trim_end_matches('.').to_string(); - - let security = { - let tls_params: ClientTlsParameters = ClientTlsParameters::new( - host.clone(), - TlsConnector::new() - .use_sni(true) - .danger_accept_invalid_certs(true) - .danger_accept_invalid_hostnames(true), - ); - - input.smtp_security.to_client_security(tls_params) + let clean_host = host.trim_end_matches('.').to_string(); + let smtp_client = SmtpClient::new().hello_name(ClientId::Domain(input.hello_name.clone())); + + let stream: BufStream> = match &input.proxy { + Some(proxy) => { + let socks_stream = + if let (Some(username), Some(password)) = (&proxy.username, &proxy.password) { + Socks5Stream::connect_with_password( + (proxy.host.as_ref(), proxy.port), + clean_host.clone(), + port, + username.clone(), + password.clone(), + Config::default(), + ) + .await? + } else { + Socks5Stream::connect( + (proxy.host.as_ref(), proxy.port), + clean_host.clone(), + port, + Config::default(), + ) + .await? + }; + BufStream::new(Box::new(socks_stream) as Box) + } + None => { + let tcp_stream = TcpStream::connect(format!("{}:{}", clean_host, port)).await?; + BufStream::new(Box::new(tcp_stream) as Box) + } }; - let mut smtp_client = SmtpClient::with_security( - ServerAddress { - host: host.clone(), - port, - }, - security, - ) - .hello_name(ClientId::Domain(input.hello_name.clone())) - .timeout(smtp_timeout); - - if let Some(proxy) = &input.proxy { - let socks5_config = match (&proxy.username, &proxy.password) { - (Some(username), Some(password)) => Socks5Config::new_with_user_pass( - proxy.host.clone(), - proxy.port, - username.clone(), - password.clone(), - ), - _ => Socks5Config::new(proxy.host.clone(), proxy.port), - }; - - smtp_client = smtp_client.use_socks5(socks5_config); - } - - let mut smtp_transport = smtp_client.into_transport(); + let mut smtp_transport = SmtpTransport::new(smtp_client, stream).await?; - try_smtp!( - smtp_transport.connect().await, - smtp_transport, - input.to_email, - host, - port - ); - - // "MAIL FROM: user@example.org" - let from_email = EmailAddress::from_str(input.from_email.as_ref()).unwrap_or_else(|_| { + // Set "MAIL FROM" + let from_email = EmailAddress::from_str(&input.from_email).unwrap_or_else(|_| { log::warn!( - "Inputted from_email \"{}\" is not a valid email, using \"user@example.org\" instead", + "Invalid 'from_email' provided: '{}'. Using default: 'user@example.org'", input.from_email ); - EmailAddress::from_str("user@example.org").expect("This is a valid email. qed.") + EmailAddress::from_str("user@example.org").expect("Default email is valid") }); try_smtp!( smtp_transport - .command(MailCommand::new(Some(from_email), vec![],)) + .get_mut() + .command(MailCommand::new(Some(from_email.into_inner()), vec![])) .await, smtp_transport, input.to_email, - host, + clean_host, port ); @@ -152,43 +132,39 @@ struct Deliverability { is_disabled: bool, } -/// Check if `to_email` exists on host SMTP server. This is the core logic of -/// this tool. -async fn email_deliverable( - smtp_transport: &mut SmtpTransport, +/// Checks deliverability of a target email address using the provided SMTP transport. +async fn check_email_deliverability( + smtp_transport: &mut SmtpTransport, to_email: &EmailAddress, ) -> Result { - // "RCPT TO: " - // FIXME Do not clone `to_email`? match smtp_transport - .command(RcptCommand::new(to_email.clone(), vec![])) + .get_mut() + .command(RcptCommand::new(to_email.clone().into_inner(), vec![])) .await { - Ok(_) => { - // According to RFC 5321, `RCPT TO` command succeeds with 250 and - // 251 codes only (no 3xx codes at all): - // https://tools.ietf.org/html/rfc5321#page-56 - // - // Where the 251 code is used for forwarding, which is not our case, - // because we always deliver to the SMTP server hosting the address - // itself. - // - // So, if `response.is_positive()` (which is a condition for - // returning `Ok` from the `command()` method above), then delivery - // succeeds, accordingly to RFC 5321. - Ok(Deliverability { - has_full_inbox: false, - is_deliverable: true, // response.is_positive() - is_disabled: false, - }) - } + // According to RFC 5321, `RCPT TO` command succeeds with 250 and + // 251 codes only (no 3xx codes at all): + // https://tools.ietf.org/html/rfc5321#page-56 + // + // Where the 251 code is used for forwarding, which is not our case, + // because we always deliver to the SMTP server hosting the address + // itself. + // + // So, if `response.is_positive()` (which is a condition for + // returning `Ok` from the `command()` method above), then delivery + // succeeds, accordingly to RFC 5321. + Ok(_) => Ok(Deliverability { + has_full_inbox: false, + is_deliverable: true, // response.is_positive() + is_disabled: false, + }), Err(err) => { // We cast to lowercase, because our matched strings below are all // lowercase. let err_string = err.to_string().to_lowercase(); // Check if the email account has been disabled or blocked. - if parser::is_disabled_account(err_string.as_str()) { + if parser::is_disabled_account(&err_string) { return Ok(Deliverability { has_full_inbox: false, is_deliverable: false, @@ -197,7 +173,7 @@ async fn email_deliverable( } // Check if the email account has a full inbox. - if parser::is_full_inbox(err_string.as_str()) { + if parser::is_full_inbox(&err_string) { return Ok(Deliverability { has_full_inbox: true, is_deliverable: false, @@ -219,7 +195,7 @@ async fn email_deliverable( } // Check that the mailbox doesn't exist. - if parser::is_invalid(err_string.as_str(), to_email) { + if parser::is_invalid(&err_string, to_email) { return Ok(Deliverability { has_full_inbox: false, is_deliverable: false, @@ -228,19 +204,18 @@ async fn email_deliverable( } // Return all unparsable errors,. - Err(SmtpError::SmtpError(err)) + Err(SmtpError::AsyncSmtpError(err)) } } } -/// Verify the existence of a catch-all on the domain. -async fn smtp_is_catch_all( - smtp_transport: &mut SmtpTransport, +/// Checks if the domain has a catch-all email setup. +async fn smtp_is_catch_all( + smtp_transport: &mut SmtpTransport, domain: &str, host: &str, input: &CheckEmailInput, ) -> Result { - // Skip catch-all check for known providers. if has_rule(domain, host, &Rule::SkipCatchAll) { log::debug!( target: LOG_TARGET, @@ -252,21 +227,18 @@ async fn smtp_is_catch_all( // Create a random 15-char alphanumerical string. let mut rng = SmallRng::from_entropy(); - let random_email: String = iter::repeat(()) - .map(|()| rng.sample(Alphanumeric)) + let random_email: String = iter::repeat_with(|| rng.sample(Alphanumeric)) .map(char::from) .take(15) .collect(); - let random_email = EmailAddress::new(format!("{random_email}@{domain}")); + let random_email = EmailAddress::new(format!("{}@{}", random_email, domain))?; - email_deliverable( - smtp_transport, - &random_email.expect("Email is correctly constructed. qed."), - ) - .await - .map(|deliverability| deliverability.is_deliverable) + check_email_deliverability(smtp_transport, &random_email) + .await + .map(|result| result.is_deliverable) } +/// Creates an SMTP future for email verification. async fn create_smtp_future( to_email: &EmailAddress, host: &str, @@ -276,7 +248,7 @@ async fn create_smtp_future( ) -> Result<(bool, Deliverability), SmtpError> { // FIXME If the SMTP is not connectable, we should actually return an // Ok(SmtpDetails { can_connect_smtp: false, ... }). - let mut smtp_transport = connect_to_host(domain, host, port, input).await?; + let mut smtp_transport = connect_to_smtp_host(host, port, input).await?; let is_catch_all = smtp_is_catch_all(&mut smtp_transport, domain, host, input) .await @@ -288,7 +260,7 @@ async fn create_smtp_future( is_disabled: false, } } else { - let mut result = email_deliverable(&mut smtp_transport, to_email).await; + let mut result = check_email_deliverability(&mut smtp_transport, to_email).await; // Some SMTP servers automatically close the connection after an error, // so we should reconnect to perform a next command. @@ -304,16 +276,19 @@ async fn create_smtp_future( input.to_email ); - let _ = smtp_transport.close().await; - smtp_transport = connect_to_host(domain, host, port, input).await?; - result = email_deliverable(&mut smtp_transport, to_email).await; + let _ = smtp_transport.quit().await; + smtp_transport = connect_to_smtp_host(host, port, input).await?; + result = check_email_deliverability(&mut smtp_transport, to_email).await; } } result? }; - smtp_transport.close().await.map_err(SmtpError::SmtpError)?; + smtp_transport + .quit() + .await + .map_err(SmtpError::AsyncSmtpError)?; Ok((is_catch_all, deliverability)) } @@ -328,7 +303,18 @@ async fn check_smtp_without_retry( input: &CheckEmailInput, ) -> Result { let fut = create_smtp_future(to_email, host, port, domain, input); - let (is_catch_all, deliverability) = fut.await?; + + let (is_catch_all, deliverability) = match input.smtp_timeout { + Some(smtp_timeout) => { + let timeout = tokio::time::timeout(smtp_timeout, fut); + + match timeout.await { + Ok(result) => result?, + Err(_) => return Err(SmtpError::Timeout(smtp_timeout)), + } + } + None => fut.await?, + }; Ok(SmtpDetails { can_connect_smtp: true, @@ -394,26 +380,3 @@ pub async fn check_smtp_with_retry( _ => result, } } - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn should_skip_catch_all() { - let smtp_client = SmtpClient::new("gmail.com".into()); - let mut smtp_transport = smtp_client.into_transport(); - - let r = smtp_is_catch_all( - &mut smtp_transport, - "gmail.com", - "alt4.aspmx.l.google.com.", - &CheckEmailInput::default(), - ) - .await; - - assert!(!smtp_transport.is_connected()); // We shouldn't connect to google servers. - assert!(r.is_ok()); - assert!(!r.unwrap()) - } -} diff --git a/core/src/smtp/error.rs b/core/src/smtp/error.rs index 27e1bab63..1d4631f26 100644 --- a/core/src/smtp/error.rs +++ b/core/src/smtp/error.rs @@ -15,29 +15,20 @@ // along with this program. If not, see . use super::gmail::GmailError; - use super::headless::HeadlessError; use super::outlook::microsoft365::Microsoft365Error; use super::parser; use super::yahoo::YahooError; use crate::util::ser_with_display::ser_with_display; -use async_smtp::smtp::error::Error as AsyncSmtpError; -use fast_socks5::SocksError; +use async_smtp::error::Error as AsyncSmtpError; use serde::Serialize; +use std::time::Duration; use thiserror::Error; /// Error occured connecting to this email server via SMTP. #[derive(Debug, Error, Serialize)] #[serde(tag = "type", content = "message")] pub enum SmtpError { - /// Error if we're using a SOCKS5 proxy. - #[serde(serialize_with = "ser_with_display")] - #[error("SOCKS5 error: {0}")] - SocksError(SocksError), - /// Error when communicating with SMTP server. - #[serde(serialize_with = "ser_with_display")] - #[error("SMTP error: {0}")] - SmtpError(AsyncSmtpError), /// Error when verifying a Yahoo email via HTTP requests. #[error("Yahoo error: {0}")] YahooError(YahooError), @@ -50,12 +41,27 @@ pub enum SmtpError { /// Error when verifying a Microsoft 365 email via HTTP request. #[error("Microsoft 365 API error: {0}")] Microsoft365Error(Microsoft365Error), -} - -impl From for SmtpError { - fn from(e: SocksError) -> Self { - SmtpError::SocksError(e) - } + /// Error from async-smtp crate. + #[error("SMTP error: {0}")] + #[serde(serialize_with = "ser_with_display")] + AsyncSmtpError(AsyncSmtpError), + /// I/O error. + #[error("I/O error: {0}")] + #[serde(serialize_with = "ser_with_display")] + IOError(std::io::Error), + /// Timeout error. + #[error("Timeout error: {0:?}")] + Timeout(Duration), + /// SOCKS5 proxy error. + #[error("SOCKS5 error: {0}")] + #[serde(serialize_with = "ser_with_display")] + Socks5(fast_socks5::SocksError), + /// Anyhow error. + /// This is a catch-all error type for any error that can't be categorized + /// into the above types. + #[error("Anyhow error: {0}")] + #[serde(serialize_with = "ser_with_display")] + AnyhowError(anyhow::Error), } impl From for SmtpError { @@ -82,6 +88,30 @@ impl From for SmtpError { } } +impl From for SmtpError { + fn from(e: AsyncSmtpError) -> Self { + SmtpError::AsyncSmtpError(e) + } +} + +impl From for SmtpError { + fn from(e: std::io::Error) -> Self { + SmtpError::IOError(e) + } +} + +impl From for SmtpError { + fn from(e: fast_socks5::SocksError) -> Self { + SmtpError::Socks5(e) + } +} + +impl From for SmtpError { + fn from(e: anyhow::Error) -> Self { + SmtpError::AnyhowError(e) + } +} + impl SmtpError { /// Get a human-understandable description of the error, in form of an enum /// SmtpErrorDesc. This only parses the following known errors: @@ -89,7 +119,7 @@ impl SmtpError { /// - IP needs reverse DNS pub fn get_description(&self) -> Option { match self { - SmtpError::SmtpError(_) => { + SmtpError::AsyncSmtpError(_) => { if parser::is_err_ip_blacklisted(self) { Some(SmtpErrorDesc::IpBlacklisted) } else if parser::is_err_needs_rdns(self) { diff --git a/core/src/smtp/gmail.rs b/core/src/smtp/gmail.rs index 221d2cafb..916099527 100644 --- a/core/src/smtp/gmail.rs +++ b/core/src/smtp/gmail.rs @@ -15,12 +15,12 @@ // along with this program. If not, see . use super::SmtpDetails; +use crate::EmailAddress; use crate::LOG_TARGET; use crate::{ smtp::http_api::create_client, util::{input_output::CheckEmailInput, ser_with_display::ser_with_display}, }; -use async_smtp::EmailAddress; use reqwest::Error as ReqwestError; use serde::Serialize; use thiserror::Error; @@ -78,11 +78,9 @@ pub fn is_gmail(host: &str) -> bool { #[cfg(test)] mod tests { - use std::str::FromStr; - - use crate::CheckEmailInputBuilder; - use super::*; + use crate::CheckEmailInputBuilder; + use std::str::FromStr; #[tokio::test] #[ignore] // ref: https://github.com/reacherhq/check-if-email-exists/issues/1431 diff --git a/core/src/smtp/mod.rs b/core/src/smtp/mod.rs index 14c208ec9..6ee92f65d 100644 --- a/core/src/smtp/mod.rs +++ b/core/src/smtp/mod.rs @@ -24,23 +24,21 @@ mod outlook; mod parser; mod yahoo; -use std::default::Default; - -use async_smtp::EmailAddress; -use hickory_proto::rr::Name; -use serde::{Deserialize, Serialize}; - +use crate::EmailAddress; use crate::{ util::input_output::CheckEmailInput, GmailVerifMethod, HotmailB2CVerifMethod, YahooVerifMethod, }; use connect::check_smtp_with_retry; -pub use error::*; +use hickory_proto::rr::Name; +use serde::{Deserialize, Serialize}; +use std::default::Default; pub use self::{ gmail::is_gmail, outlook::{is_hotmail, is_hotmail_b2b, is_hotmail_b2c}, yahoo::is_yahoo, }; +pub use error::*; #[derive(Debug, Default, Deserialize, PartialEq, Serialize)] pub struct SmtpConnection { @@ -163,7 +161,7 @@ pub async fn check_smtp( mod tests { use super::{check_smtp, SmtpConnection, SmtpError}; use crate::CheckEmailInputBuilder; - use async_smtp::{smtp::error::Error, EmailAddress}; + use crate::EmailAddress; use hickory_proto::rr::Name; use std::{str::FromStr, time::Duration}; use tokio::runtime::Runtime; @@ -192,7 +190,7 @@ mod tests { }) ); match res { - Err(SmtpError::SmtpError(Error::Io(_))) => (), // ErrorKind == Timeout + Err(SmtpError::Timeout(_)) => (), // ErrorKind == Timeout _ => panic!("check_smtp did not time out"), } } diff --git a/core/src/smtp/parser.rs b/core/src/smtp/parser.rs index 0d9c60b96..311f28b58 100644 --- a/core/src/smtp/parser.rs +++ b/core/src/smtp/parser.rs @@ -17,7 +17,8 @@ //! Parse the SMTP responses to get information about the email address. use super::error::SmtpError; -use async_smtp::{smtp::error::Error as AsyncSmtpError, EmailAddress}; +use crate::EmailAddress; +use async_smtp::error::Error as AsyncSmtpError; /// is_invalid checks for SMTP responses meaning that the email is invalid, /// i.e. that the mailbox doesn't exist. @@ -127,7 +128,7 @@ pub fn is_disabled_account(e: &str) -> bool { /// Check if the error is an IO "incomplete" error. pub fn is_err_io_errors(e: &SmtpError) -> bool { match e { - SmtpError::SmtpError(AsyncSmtpError::Io(err)) => err.to_string() == "incomplete", + SmtpError::AsyncSmtpError(AsyncSmtpError::Io(err)) => err.to_string() == "incomplete", _ => false, } } @@ -135,7 +136,7 @@ pub fn is_err_io_errors(e: &SmtpError) -> bool { /// Check if the IP is blacklisted. pub fn is_err_ip_blacklisted(e: &SmtpError) -> bool { let e = match e { - SmtpError::SmtpError(AsyncSmtpError::Transient(r) | AsyncSmtpError::Permanent(r)) => { + SmtpError::AsyncSmtpError(AsyncSmtpError::Transient(r) | AsyncSmtpError::Permanent(r)) => { // TODO We can use .to_string() after: // https://github.com/async-email/async-smtp/pull/53 r.message.join("; ").to_lowercase() @@ -205,7 +206,7 @@ pub fn is_err_ip_blacklisted(e: &SmtpError) -> bool { /// Check if the IP needs a reverse DNS. pub fn is_err_needs_rdns(e: &SmtpError) -> bool { let e = match e { - SmtpError::SmtpError(AsyncSmtpError::Transient(r) | AsyncSmtpError::Permanent(r)) => { + SmtpError::AsyncSmtpError(AsyncSmtpError::Transient(r) | AsyncSmtpError::Permanent(r)) => { // TODO We can use .to_string() after: // https://github.com/async-email/async-smtp/pull/53 r.message.join("; ").to_lowercase() @@ -227,11 +228,11 @@ pub fn is_err_needs_rdns(e: &SmtpError) -> bool { mod tests { use super::{is_err_ip_blacklisted, is_invalid}; - use crate::SmtpError::SmtpError; + use crate::EmailAddress; + use crate::SmtpError::AsyncSmtpError; use async_smtp::{ - smtp::error::Error, - smtp::response::{Category, Code, Detail, Response, Severity}, - EmailAddress, + error::Error, + response::{Category, Code, Detail, Response, Severity}, }; use std::str::FromStr; @@ -271,6 +272,6 @@ mod tests { ], )); - assert!(is_err_ip_blacklisted(&SmtpError(err))) + assert!(is_err_ip_blacklisted(&AsyncSmtpError(err))) } } diff --git a/core/src/syntax/mod.rs b/core/src/syntax/mod.rs index 4c5b8f2b0..fedac42e9 100644 --- a/core/src/syntax/mod.rs +++ b/core/src/syntax/mod.rs @@ -16,7 +16,7 @@ mod normalize; -use async_smtp::EmailAddress; +use crate::EmailAddress; use levenshtein::levenshtein; use normalize::normalize_email; use serde::{Deserialize, Serialize}; diff --git a/core/src/util/input_output.rs b/core/src/util/input_output.rs index 0eaa145b4..04f90771c 100644 --- a/core/src/util/input_output.rs +++ b/core/src/util/input_output.rs @@ -14,18 +14,80 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use std::str::FromStr; -use std::time::{Duration, SystemTime}; - -use async_smtp::{ClientSecurity, ClientTlsParameters}; -use chrono::{DateTime, Utc}; -use derive_builder::Builder; -use serde::{ser::SerializeMap, Deserialize, Serialize, Serializer}; - use crate::misc::{MiscDetails, MiscError}; use crate::mx::{MxDetails, MxError}; use crate::smtp::{SmtpDebug, SmtpDetails, SmtpError, SmtpErrorDesc}; use crate::syntax::SyntaxDetails; +use crate::util::ser_with_display::ser_with_display; +use async_smtp::EmailAddress as AsyncSmtpEmailAddress; +use chrono::{DateTime, Utc}; +use derive_builder::Builder; +use serde::{ser::SerializeMap, Deserialize, Serialize, Serializer}; +use std::fmt::Display; +use std::str::FromStr; +use std::time::{Duration, SystemTime}; + +/// Wrapper around the `EmailAddress` from `async_smtp` to allow for +/// serialization and deserialization. +#[derive(Debug, Eq, PartialEq, Clone)] +pub struct EmailAddress(AsyncSmtpEmailAddress); + +impl Serialize for EmailAddress { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + ser_with_display(&self.0, serializer) + } +} + +impl<'de> Deserialize<'de> for EmailAddress { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Ok(EmailAddress( + AsyncSmtpEmailAddress::from_str(&s).map_err(serde::de::Error::custom)?, + )) + } +} + +impl Display for EmailAddress { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl FromStr for EmailAddress { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + Ok(EmailAddress(AsyncSmtpEmailAddress::from_str(s)?)) + } +} + +impl EmailAddress { + pub fn new(email: String) -> Result { + Ok(EmailAddress(AsyncSmtpEmailAddress::new(email)?)) + } + + pub fn into_inner(self) -> AsyncSmtpEmailAddress { + self.0 + } +} + +impl AsRef for EmailAddress { + fn as_ref(&self) -> &AsyncSmtpEmailAddress { + &self.0 + } +} + +impl AsRef for EmailAddress { + fn as_ref(&self) -> &str { + self.0.as_ref() + } +} /// Perform the email verification via a specified proxy. The usage of a proxy /// is optional. @@ -41,37 +103,6 @@ pub struct CheckEmailInputProxy { pub password: Option, } -/// Define how to apply TLS to a SMTP client connection. Will be converted into -/// async_smtp::ClientSecurity. -#[derive(Debug, Clone, Copy, Deserialize, Serialize)] -pub enum SmtpSecurity { - /// Insecure connection only (for testing purposes). - None, - /// Start with insecure connection and use `STARTTLS` when available. - Opportunistic, - /// Start with insecure connection and require `STARTTLS`. - Required, - /// Use TLS wrapped connection. - Wrapper, -} - -impl Default for SmtpSecurity { - fn default() -> Self { - Self::Opportunistic - } -} - -impl SmtpSecurity { - pub fn to_client_security(self, tls_params: ClientTlsParameters) -> ClientSecurity { - match self { - Self::None => ClientSecurity::None, - Self::Opportunistic => ClientSecurity::Opportunistic(tls_params), - Self::Required => ClientSecurity::Required(tls_params), - Self::Wrapper => ClientSecurity::Wrapper(tls_params), - } - } -} - /// Select how to verify Yahoo emails. #[derive(Debug, Clone, Copy, Default, PartialEq, Deserialize, Serialize)] pub enum YahooVerifMethod { @@ -195,7 +226,9 @@ pub struct CheckEmailInput { /// Defaults to 25. pub smtp_port: u16, /// Add timeout for the SMTP verification step. Set to None if you don't - /// want to use a timeout. + /// want to use a timeout. This timeout is per SMTP connection. For + /// instance, if you set the number of retries to 2, then the total time + /// for the SMTP verification step can be up to 2 * `smtp_timeout`. /// /// Defaults to None. pub smtp_timeout: Option, @@ -228,10 +261,6 @@ pub struct CheckEmailInput { /// /// Defaults to 1. pub retries: usize, - /// How to apply TLS to a SMTP client connection. - /// - /// Defaults to Opportunistic. - pub smtp_security: SmtpSecurity, /// The WebDriver address to use for headless verifications. /// /// Defaults to http://localhost:9515. @@ -256,7 +285,6 @@ impl Default for CheckEmailInput { hello_name: "gmail.com".into(), proxy: None, smtp_port: 25, - smtp_security: SmtpSecurity::default(), smtp_timeout: None, yahoo_verif_method: YahooVerifMethod::default(), gmail_verif_method: GmailVerifMethod::default(), @@ -411,7 +439,7 @@ impl Serialize for CheckEmailOutput { #[cfg(test)] mod tests { use super::{CheckEmailOutput, DebugDetails}; - use async_smtp::smtp::response::{Category, Code, Detail, Response, Severity}; + use async_smtp::response::{Category, Code, Detail, Response, Severity}; #[test] fn should_serialize_correctly() { @@ -433,7 +461,7 @@ mod tests { misc: Ok(super::MiscDetails::default()), mx: Ok(super::MxDetails::default()), syntax: super::SyntaxDetails::default(), - smtp: Err(super::SmtpError::SmtpError(r.into())), + smtp: Err(super::SmtpError::AsyncSmtpError(r.into())), debug: DebugDetails::default(), } } @@ -441,20 +469,20 @@ mod tests { let res = dummy_response_with_message("blacklist"); let actual = serde_json::to_string(&res).unwrap(); // Make sure the `description` is present with IpBlacklisted. - let expected = r#""smtp":{"error":{"type":"SmtpError","message":"transient: blacklist"},"description":"IpBlacklisted"}"#; + let expected = r#""smtp":{"error":{"type":"AsyncSmtpError","message":"transient: blacklist; 8BITMIME; SIZE 42"},"description":"IpBlacklisted"}"#; assert!(actual.contains(expected)); let res = dummy_response_with_message("Client host rejected: cannot find your reverse hostname"); let actual = serde_json::to_string(&res).unwrap(); // Make sure the `description` is present with NeedsRDNs. - let expected = r#"smtp":{"error":{"type":"SmtpError","message":"transient: Client host rejected: cannot find your reverse hostname"},"description":"NeedsRDNS"}"#; + let expected = r#"smtp":{"error":{"type":"AsyncSmtpError","message":"transient: Client host rejected: cannot find your reverse hostname; 8BITMIME; SIZE 42"},"description":"NeedsRDNS"}"#; assert!(actual.contains(expected)); let res = dummy_response_with_message("foobar"); let actual = serde_json::to_string(&res).unwrap(); // Make sure the `description` is NOT present. - let expected = r#""smtp":{"error":{"type":"SmtpError","message":"transient: foobar"}}"#; + let expected = r#""smtp":{"error":{"type":"AsyncSmtpError","message":"transient: foobar; 8BITMIME; SIZE 42"}}"#; assert!(actual.contains(expected)); } } diff --git a/core/src/util/sentry.rs b/core/src/util/sentry.rs index 391cf2761..7aeab6516 100644 --- a/core/src/util/sentry.rs +++ b/core/src/util/sentry.rs @@ -24,7 +24,7 @@ use crate::misc::MiscError; use crate::mx::MxError; use crate::LOG_TARGET; use crate::{smtp::SmtpError, CheckEmailOutput}; -use async_smtp::smtp::error::Error as AsyncSmtpError; +use async_smtp::error::Error as AsyncSmtpError; use sentry::protocol::{Event, Exception, Level, Values}; use thiserror::Error; use tracing::{debug, info}; @@ -121,7 +121,7 @@ pub fn log_unknown_errors(result: &CheckEmailOutput, backend_name: &str) { (_, _, Err(err)) if err.get_description().is_some() => { // If the SMTP error is known, we don't track it in Sentry. } - (_, _, Err(SmtpError::SmtpError(AsyncSmtpError::Transient(response)))) + (_, _, Err(SmtpError::AsyncSmtpError(AsyncSmtpError::Transient(response)))) if skip_smtp_transient_errors(&response.message) => { // If the SMTP error is transient and known, we don't track it in