valid_email_checker 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +24 -0
  3. data/Cargo.lock +2940 -0
  4. data/Cargo.toml +22 -0
  5. data/LICENSE.txt +661 -0
  6. data/NOTICE.md +56 -0
  7. data/README.md +316 -0
  8. data/exe/valid_email_checker +148 -0
  9. data/ext/valid_email_checker/Cargo.toml +27 -0
  10. data/ext/valid_email_checker/extconf.rb +26 -0
  11. data/ext/valid_email_checker/src/lib.rs +371 -0
  12. data/ext/valid_email_checker/src/nogvl.rs +64 -0
  13. data/ext/valid_email_checker/vendor/UPSTREAM.json +9 -0
  14. data/ext/valid_email_checker/vendor/check-if-email-exists/Cargo.toml +52 -0
  15. data/ext/valid_email_checker/vendor/check-if-email-exists/LICENSE.AGPL +661 -0
  16. data/ext/valid_email_checker/vendor/check-if-email-exists/LICENSE.md +11 -0
  17. data/ext/valid_email_checker/vendor/check-if-email-exists/README.md +175 -0
  18. data/ext/valid_email_checker/vendor/check-if-email-exists/src/haveibeenpwned.rs +70 -0
  19. data/ext/valid_email_checker/vendor/check-if-email-exists/src/lib.rs +281 -0
  20. data/ext/valid_email_checker/vendor/check-if-email-exists/src/misc/b2c.txt +96640 -0
  21. data/ext/valid_email_checker/vendor/check-if-email-exists/src/misc/gravatar.rs +60 -0
  22. data/ext/valid_email_checker/vendor/check-if-email-exists/src/misc/mod.rs +124 -0
  23. data/ext/valid_email_checker/vendor/check-if-email-exists/src/misc/roles.txt +944 -0
  24. data/ext/valid_email_checker/vendor/check-if-email-exists/src/mx/mod.rs +165 -0
  25. data/ext/valid_email_checker/vendor/check-if-email-exists/src/rules.json +28 -0
  26. data/ext/valid_email_checker/vendor/check-if-email-exists/src/rules.rs +105 -0
  27. data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/connect.rs +396 -0
  28. data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/error.rs +144 -0
  29. data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/gmail.rs +99 -0
  30. data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/headless.rs +82 -0
  31. data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/http_api.rs +27 -0
  32. data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/mod.rs +234 -0
  33. data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/outlook/headless.rs +181 -0
  34. data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/outlook/microsoft365.rs +109 -0
  35. data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/outlook/mod.rs +2 -0
  36. data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/parser.rs +291 -0
  37. data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/verif_method.rs +531 -0
  38. data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/yahoo/api.rs +174 -0
  39. data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/yahoo/headless.rs +188 -0
  40. data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/yahoo/mod.rs +62 -0
  41. data/ext/valid_email_checker/vendor/check-if-email-exists/src/syntax/mod.rs +199 -0
  42. data/ext/valid_email_checker/vendor/check-if-email-exists/src/syntax/normalize.rs +70 -0
  43. data/ext/valid_email_checker/vendor/check-if-email-exists/src/util/input_output.rs +353 -0
  44. data/ext/valid_email_checker/vendor/check-if-email-exists/src/util/mod.rs +20 -0
  45. data/ext/valid_email_checker/vendor/check-if-email-exists/src/util/sentry.rs +173 -0
  46. data/ext/valid_email_checker/vendor/check-if-email-exists/src/util/ser_with_display.rs +28 -0
  47. data/lib/valid_email_checker/configuration.rb +216 -0
  48. data/lib/valid_email_checker/errors.rb +32 -0
  49. data/lib/valid_email_checker/result.rb +241 -0
  50. data/lib/valid_email_checker/syntax.rb +72 -0
  51. data/lib/valid_email_checker/version.rb +5 -0
  52. data/lib/valid_email_checker.rb +217 -0
  53. metadata +118 -0
@@ -0,0 +1,165 @@
1
+ // check-if-email-exists
2
+ // Copyright (C) 2018-2023 Reacher
3
+
4
+ // This program is free software: you can redistribute it and/or modify
5
+ // it under the terms of the GNU Affero General Public License as published
6
+ // by the Free Software Foundation, either version 3 of the License, or
7
+ // (at your option) any later version.
8
+
9
+ // This program is distributed in the hope that it will be useful,
10
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ // GNU Affero General Public License for more details.
13
+
14
+ // You should have received a copy of the GNU Affero General Public License
15
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ use crate::syntax::SyntaxDetails;
18
+ use crate::util::ser_with_display::ser_with_display;
19
+ use hickory_resolver::error::{ResolveError, ResolveErrorKind};
20
+ use hickory_resolver::lookup::MxLookup;
21
+ use hickory_resolver::system_conf::read_system_conf;
22
+ use hickory_resolver::TokioAsyncResolver;
23
+ use serde::{ser::SerializeMap, Serialize, Serializer};
24
+ use std::io;
25
+ use thiserror::Error;
26
+
27
+ /// Details about the MX lookup.
28
+ #[derive(Debug)]
29
+ pub struct MxDetails {
30
+ /// MX lookup of this DNS.
31
+ pub lookup: Result<MxLookup, ResolveError>,
32
+ }
33
+
34
+ impl Default for MxDetails {
35
+ fn default() -> Self {
36
+ MxDetails {
37
+ lookup: Err(ResolveError::from("Skipped")),
38
+ }
39
+ }
40
+ }
41
+
42
+ impl From<MxLookup> for MxDetails {
43
+ fn from(lookup: MxLookup) -> Self {
44
+ MxDetails { lookup: Ok(lookup) }
45
+ }
46
+ }
47
+
48
+ impl Serialize for MxDetails {
49
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
50
+ where
51
+ S: Serializer,
52
+ {
53
+ let records: Vec<String> = self
54
+ .lookup
55
+ .as_ref()
56
+ .map(|lookup| {
57
+ lookup
58
+ .iter()
59
+ .map(|host| host.exchange().to_string())
60
+ .collect::<Vec<_>>()
61
+ })
62
+ .unwrap_or_else(|_| Vec::new()); // In case of a resolve error, we don't serialize the error.
63
+
64
+ let mut map = serializer.serialize_map(Some(2))?;
65
+ map.serialize_entry("accepts_mail", &!records.is_empty())?;
66
+ map.serialize_entry("records", &records)?;
67
+ map.end()
68
+ }
69
+ }
70
+
71
+ /// Errors that can happen on MX lookups.
72
+ #[derive(Debug, Error, Serialize)]
73
+ #[serde(tag = "type", content = "message")]
74
+ pub enum MxError {
75
+ /// Error with IO.
76
+ #[serde(serialize_with = "ser_with_display")]
77
+ #[error("IO error: {0}")]
78
+ IoError(io::Error),
79
+ /// Error while resolving MX lookups.
80
+ #[serde(serialize_with = "ser_with_display")]
81
+ #[error("Resolve error: {0}")]
82
+ ResolveError(Box<ResolveError>),
83
+ }
84
+
85
+ impl From<io::Error> for MxError {
86
+ fn from(e: io::Error) -> Self {
87
+ MxError::IoError(e)
88
+ }
89
+ }
90
+
91
+ impl From<ResolveError> for MxError {
92
+ fn from(e: ResolveError) -> Self {
93
+ MxError::ResolveError(Box::new(e))
94
+ }
95
+ }
96
+
97
+ /// Make a MX lookup.
98
+ pub async fn check_mx(syntax: &SyntaxDetails) -> Result<MxDetails, MxError> {
99
+ // Construct a new Resolver with default configuration options
100
+ let (config, opts) = read_system_conf()?;
101
+ let resolver = TokioAsyncResolver::tokio(config, opts);
102
+
103
+ match resolver.mx_lookup(&syntax.domain).await {
104
+ Ok(lookup) => Ok(MxDetails::from(lookup)),
105
+ Err(err) => match err.kind() {
106
+ // Prefer to return an empty MX lookup if there are no records.
107
+ ResolveErrorKind::NoRecordsFound { .. } => Ok(MxDetails { lookup: Err(err) }),
108
+ _ => Err(err.into()),
109
+ },
110
+ }
111
+ }
112
+
113
+ /// Check if the MX host is from Google, i.e. either a @gmail.com address, or
114
+ /// a Google Suite email.
115
+ pub fn is_gmail(mx_host: &str) -> bool {
116
+ mx_host.to_lowercase().ends_with(".google.com.")
117
+ }
118
+
119
+ /// Check if a MX host is from outlook (includes @hotmail.*, @outlook.* and
120
+ /// all Microsoft 365 addresses).
121
+ ///
122
+ /// After some testing I got:
123
+ /// After some testing, I got:
124
+ /// - *@outlook.com -> `outlook-com.olc.protection.outlook.com.`
125
+ /// - *@outlook.fr -> `eur.olc.protection.outlook.com.`
126
+ /// - *@hotmail.com -> `hotmail-com.olc.protection.outlook.com.`
127
+ /// - *@hotmail.fr -> `eur.olc.protection.outlook.com.`
128
+ /// - *@hotmail.nl -> `eur.olc.protection.outlook.com.`
129
+ ///
130
+ /// But Microsoft 365 (B2B) addresses end with "mail.protection.outlook.com."
131
+ ///
132
+ /// TL;DR:
133
+ /// - B2C emails -> end with ".olc.protection.outlook.com."
134
+ /// - B2B emails -> end with ".mail.protection.outlook.com."
135
+ pub fn is_hotmail(mx_host: &str) -> bool {
136
+ mx_host.to_lowercase().ends_with(".protection.outlook.com.")
137
+ }
138
+
139
+ /// Check if an address is a Hotmail B2B email address.
140
+ pub fn is_hotmail_b2b(mx_host: &str) -> bool {
141
+ is_hotmail(mx_host) && !mx_host.ends_with(".olc.protection.outlook.com.")
142
+ }
143
+
144
+ /// Check if an address is a Hotmail B2C email address.
145
+ pub fn is_hotmail_b2c(mx_host: &str) -> bool {
146
+ is_hotmail(mx_host) && mx_host.ends_with(".olc.protection.outlook.com.")
147
+ }
148
+
149
+ /// Check if the MX host is behind Mimecast.
150
+ pub fn is_mimecast(mx_host: &str) -> bool {
151
+ mx_host.to_lowercase().ends_with(".mimecast.com.")
152
+ }
153
+
154
+ /// Check if the MX host is behind Proofpoint.
155
+ pub fn is_proofpoint(mx_host: &str) -> bool {
156
+ mx_host.to_lowercase().ends_with(".pphosted.com.") || mx_host.ends_with("ppe-hosted.com.")
157
+ }
158
+
159
+ /// Check if the MX host is from Yahoo.
160
+ /// Examples:
161
+ /// - mta7.am0.yahoodns.net.
162
+ /// - mx-eu.mail.am0.yahoodns.net.
163
+ pub fn is_yahoo(mx_host: &str) -> bool {
164
+ mx_host.to_lowercase().ends_with(".yahoodns.net.")
165
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "by_domain": {
3
+ "gmail.com": { "rules": ["SkipCatchAll"] },
4
+ "hotmail.com": { "rules": ["SkipCatchAll"] },
5
+ "hotmail.fr": { "rules": ["SkipCatchAll"] },
6
+ "hotmail.nl": { "rules": ["SkipCatchAll"] },
7
+ "yahoo.com": { "rules": ["SkipCatchAll"] },
8
+ "yahoo.fr": { "rules": ["SkipCatchAll"] }
9
+ },
10
+ "by_mx": {
11
+ "futuresinitiative.org.": { "rules": ["SmtpTimeout45s"] },
12
+ "mail.digimarcon.com.": { "rules": ["SmtpTimeout45s"] },
13
+ "mail.glasasoftball.org.": { "rules": ["SmtpTimeout45s"] },
14
+ "nosotrosorg.com.": { "rules": ["SmtpTimeout45s"] }
15
+ },
16
+ "by_mx_suffix": {
17
+ ".antispamcloud.com.": {
18
+ "rules": ["SmtpTimeout45s", "SkipCatchAll"],
19
+ "_comment": "Some <RCPT TO> take 30s to respond (sometimes only on 2nd attempt, not deterministic), so we skip the catch-all one, and bump the timeout to well over 30s."
20
+ }
21
+ },
22
+ "rules": {
23
+ "SkipCatchAll": { "_comment": "Don't perform catch-all check" },
24
+ "SmtpTimeout45s": {
25
+ "_comment": "Set SMTP connection timeout to at least 45s. If the user request set an even higher timeout, take that one. Please note that this timeout is **per SMTP connection**. We might try 2 connections per email: if the 1st one failed, then we connect again to avoid potential greylisting, in which case the whole verification takes 1min30s."
26
+ }
27
+ }
28
+ }
@@ -0,0 +1,105 @@
1
+ // check-if-email-exists
2
+ // Copyright (C) 2018-2023 Reacher
3
+
4
+ // This program is free software: you can redistribute it and/or modify
5
+ // it under the terms of the GNU Affero General Public License as published
6
+ // by the Free Software Foundation, either version 3 of the License, or
7
+ // (at your option) any later version.
8
+
9
+ // This program is distributed in the hope that it will be useful,
10
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ // GNU Affero General Public License for more details.
13
+
14
+ // You should have received a copy of the GNU Affero General Public License
15
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ //! Read provider- and domain-specific rules from a JSON, then match each
18
+ //! email verification to the domain/provider, and translate those rules into
19
+ //! code.
20
+ //!
21
+ //! IMPORTANT: This is still a beta feature, and probably needs refining.
22
+
23
+ use once_cell::sync::Lazy;
24
+ use serde::{Deserialize, Serialize};
25
+ use std::collections::HashMap;
26
+
27
+ #[derive(Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
28
+ pub enum Rule {
29
+ /// Don't perform catch-all check.
30
+ SkipCatchAll,
31
+ /// Set the SMTP timeout to 45s.
32
+ SmtpTimeout45s,
33
+ }
34
+
35
+ #[derive(Debug, Deserialize, Serialize)]
36
+ struct RulesByDomain {
37
+ rules: Vec<Rule>,
38
+ }
39
+
40
+ #[derive(Debug, Deserialize, Serialize)]
41
+ struct AllRules {
42
+ /// Apply rules by domain name, i.e. after the @ symbol.
43
+ by_domain: HashMap<String, RulesByDomain>,
44
+ /// Apply rules by the MX host.
45
+ by_mx: HashMap<String, RulesByDomain>,
46
+ /// Apply rules by the MX host. Since each domain potentially has multiple
47
+ /// MX records, we match by their suffix.
48
+ by_mx_suffix: HashMap<String, RulesByDomain>,
49
+ }
50
+
51
+ static ALL_RULES: Lazy<AllRules> =
52
+ Lazy::new(|| serde_json::from_str::<AllRules>(include_str!("rules.json")).unwrap());
53
+
54
+ fn does_domain_have_rule(domain: &str, rule: &Rule) -> bool {
55
+ if let Some(v) = ALL_RULES.by_domain.get(domain) {
56
+ return v.rules.contains(rule);
57
+ }
58
+
59
+ false
60
+ }
61
+
62
+ fn does_mx_have_rule(host: &str, rule: &Rule) -> bool {
63
+ if let Some(v) = ALL_RULES.by_mx.get(host) {
64
+ return v.rules.contains(rule);
65
+ }
66
+
67
+ false
68
+ }
69
+
70
+ fn does_mx_suffix_have_rule(host: &str, rule: &Rule) -> bool {
71
+ for (k, v) in ALL_RULES.by_mx_suffix.iter() {
72
+ if host.ends_with(k) {
73
+ return v.rules.contains(rule);
74
+ }
75
+ }
76
+
77
+ false
78
+ }
79
+
80
+ /// Check if either the domain or the MX host has any given rule.
81
+ pub fn has_rule(domain: &str, host: &str, rule: &Rule) -> bool {
82
+ does_domain_have_rule(domain, rule)
83
+ || does_mx_have_rule(host, rule)
84
+ || does_mx_suffix_have_rule(host, rule)
85
+ }
86
+
87
+ #[cfg(test)]
88
+ mod tests {
89
+ use super::*;
90
+
91
+ #[test]
92
+ fn should_skip_catch_all() {
93
+ assert!(has_rule(
94
+ "gmail.com",
95
+ "alt4.aspmx.l.google.com.",
96
+ &Rule::SkipCatchAll
97
+ ));
98
+
99
+ assert!(has_rule(
100
+ "domain.com",
101
+ ".antispamcloud.com.",
102
+ &Rule::SkipCatchAll
103
+ ))
104
+ }
105
+ }
@@ -0,0 +1,396 @@
1
+ // check-if-email-exists
2
+ // Copyright (C) 2018-2023 Reacher
3
+
4
+ // This program is free software: you can redistribute it and/or modify
5
+ // it under the terms of the GNU Affero General Public License as published
6
+ // by the Free Software Foundation, either version 3 of the License, or
7
+ // (at your option) any later version.
8
+
9
+ // This program is distributed in the hope that it will be useful,
10
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ // GNU Affero General Public License for more details.
13
+
14
+ // You should have received a copy of the GNU Affero General Public License
15
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ use async_recursion::async_recursion;
18
+ use async_smtp::commands::{MailCommand, RcptCommand};
19
+ use async_smtp::extension::ClientId;
20
+ use async_smtp::{SmtpClient, SmtpTransport};
21
+ use fast_socks5::client::Config;
22
+ use fast_socks5::{client::Socks5Stream, Result};
23
+ use rand::rngs::SmallRng;
24
+ use rand::{distributions::Alphanumeric, Rng, SeedableRng};
25
+ use std::iter;
26
+ use std::str::FromStr;
27
+ use tokio::io::{AsyncBufRead, AsyncRead, AsyncWrite, BufStream};
28
+ use tokio::net::TcpStream;
29
+
30
+ use super::parser;
31
+ use super::verif_method::VerifMethodSmtp;
32
+ use super::{SmtpDetails, SmtpError};
33
+ use crate::rules::{has_rule, Rule};
34
+ use crate::{EmailAddress, LOG_TARGET};
35
+
36
+ // Define a new trait that combines AsyncRead, AsyncWrite, and Unpin
37
+ trait AsyncReadWrite: AsyncRead + AsyncWrite + Unpin + Send {}
38
+ impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsyncReadWrite for T {}
39
+
40
+ /// Try to send an smtp command, close and return Err if fails.
41
+ macro_rules! try_smtp (
42
+ ($res: expr, $client: ident, $to_email: expr, $host: expr, $port: expr) => ({
43
+ if let Err(err) = $res {
44
+ tracing::debug!(
45
+ target: LOG_TARGET,
46
+ email=$to_email.to_string(),
47
+ mx_host=$host,
48
+ port=$port,
49
+ error=?err,
50
+ "Closing connection due to error"
51
+ );
52
+ // Try to close the connection, but ignore if there's an error.
53
+ let _ = $client.quit().await;
54
+
55
+ return Err(SmtpError::AsyncSmtpError(err));
56
+ }
57
+ })
58
+ );
59
+
60
+ /// Connect to an SMTP host and return the configured client transport.
61
+ async fn connect_to_smtp_host(
62
+ to_email: &EmailAddress,
63
+ mx_host: &str,
64
+ verif_method: &VerifMethodSmtp,
65
+ ) -> Result<SmtpTransport<BufStream<Box<dyn AsyncReadWrite>>>, SmtpError> {
66
+ // hostname verification fails if it ends with '.', for example, using
67
+ // SOCKS5 proxies we can `io: incomplete` error.
68
+ let clean_host = mx_host.trim_end_matches('.').to_string();
69
+ let smtp_client = SmtpClient::new()
70
+ .hello_name(ClientId::Domain(verif_method.config.hello_name.to_string()))
71
+ // Sometimes, using socks5 proxy, we get an `io: incomplete` error
72
+ // when using pipelining and sending two consecutive RCPT TO commands.
73
+ .pipelining(false);
74
+
75
+ let stream: BufStream<Box<dyn AsyncReadWrite>> = match &verif_method.proxy {
76
+ Some(proxy) => {
77
+ let mut config = Config::default();
78
+ if let Some(timeout_ms) = proxy.timeout_ms {
79
+ config.set_connect_timeout(timeout_ms / 1000);
80
+ }
81
+
82
+ let socks_stream =
83
+ if let (Some(username), Some(password)) = (&proxy.username, &proxy.password) {
84
+ Socks5Stream::connect_with_password(
85
+ (proxy.host.as_ref(), proxy.port),
86
+ clean_host.clone(),
87
+ verif_method.config.smtp_port,
88
+ username.clone(),
89
+ password.clone(),
90
+ config,
91
+ )
92
+ .await?
93
+ } else {
94
+ Socks5Stream::connect(
95
+ (proxy.host.as_ref(), proxy.port),
96
+ clean_host.clone(),
97
+ verif_method.config.smtp_port,
98
+ config,
99
+ )
100
+ .await?
101
+ };
102
+ BufStream::new(Box::new(socks_stream) as Box<dyn AsyncReadWrite>)
103
+ }
104
+ None => {
105
+ let tcp_stream =
106
+ TcpStream::connect(format!("{}:{}", clean_host, verif_method.config.smtp_port))
107
+ .await?;
108
+ BufStream::new(Box::new(tcp_stream) as Box<dyn AsyncReadWrite>)
109
+ }
110
+ };
111
+
112
+ let mut smtp_transport = SmtpTransport::new(smtp_client, stream).await?;
113
+
114
+ // Set "MAIL FROM"
115
+ let from_email = EmailAddress::from_str(&verif_method.config.from_email).unwrap_or_else(|_| {
116
+ tracing::warn!(
117
+ target: LOG_TARGET,
118
+ from_email=verif_method.config.from_email,
119
+ "Invalid 'from_email' provided, using default: 'user@example.org'"
120
+ );
121
+ EmailAddress::from_str("user@example.org").expect("Default email is valid")
122
+ });
123
+ try_smtp!(
124
+ smtp_transport
125
+ .get_mut()
126
+ .command(MailCommand::new(Some(from_email.into_inner()), vec![]))
127
+ .await,
128
+ smtp_transport,
129
+ to_email,
130
+ clean_host,
131
+ verif_method.config.smtp_port
132
+ );
133
+
134
+ Ok(smtp_transport)
135
+ }
136
+
137
+ /// Description of the deliverability information we can gather from
138
+ /// communicating with the SMTP server.
139
+ struct Deliverability {
140
+ /// Is this email account's inbox full?
141
+ has_full_inbox: bool,
142
+ /// Can we send an email to this address?
143
+ is_deliverable: bool,
144
+ /// Is the email blocked or disabled by the provider?
145
+ is_disabled: bool,
146
+ }
147
+
148
+ /// Checks deliverability of a target email address using the provided SMTP transport.
149
+ async fn check_email_deliverability<S: AsyncBufRead + AsyncWrite + Unpin + Send>(
150
+ smtp_transport: &mut SmtpTransport<S>,
151
+ to_email: &EmailAddress,
152
+ ) -> Result<Deliverability, SmtpError> {
153
+ match smtp_transport
154
+ .get_mut()
155
+ .command(RcptCommand::new(to_email.clone().into_inner(), vec![]))
156
+ .await
157
+ {
158
+ // According to RFC 5321, `RCPT TO` command succeeds with 250 and
159
+ // 251 codes only (no 3xx codes at all):
160
+ // https://tools.ietf.org/html/rfc5321#page-56
161
+ //
162
+ // Where the 251 code is used for forwarding, which is not our case,
163
+ // because we always deliver to the SMTP server hosting the address
164
+ // itself.
165
+ //
166
+ // So, if `response.is_positive()` (which is a condition for
167
+ // returning `Ok` from the `command()` method above), then delivery
168
+ // succeeds, accordingly to RFC 5321.
169
+ Ok(_) => Ok(Deliverability {
170
+ has_full_inbox: false,
171
+ is_deliverable: true, // response.is_positive()
172
+ is_disabled: false,
173
+ }),
174
+ Err(err) => {
175
+ // We cast to lowercase, because our matched strings below are all
176
+ // lowercase.
177
+ let err_string = err.to_string().to_lowercase();
178
+
179
+ // Check if the email account has been disabled or blocked.
180
+ if parser::is_disabled_account(&err_string) {
181
+ return Ok(Deliverability {
182
+ has_full_inbox: false,
183
+ is_deliverable: false,
184
+ is_disabled: true,
185
+ });
186
+ }
187
+
188
+ // Check if the email account has a full inbox.
189
+ if parser::is_full_inbox(&err_string) {
190
+ return Ok(Deliverability {
191
+ has_full_inbox: true,
192
+ is_deliverable: false,
193
+ is_disabled: false,
194
+ });
195
+ }
196
+
197
+ // Check error messages that say that user can actually receive
198
+ // emails.
199
+ // 4.2.1 The user you are trying to contact is receiving mail at a rate that
200
+ if err_string
201
+ .contains("the user you are trying to contact is receiving mail at a rate that")
202
+ {
203
+ return Ok(Deliverability {
204
+ has_full_inbox: false,
205
+ is_deliverable: true,
206
+ is_disabled: false,
207
+ });
208
+ }
209
+
210
+ // Check that the mailbox doesn't exist.
211
+ if parser::is_invalid(&err_string, to_email) {
212
+ return Ok(Deliverability {
213
+ has_full_inbox: false,
214
+ is_deliverable: false,
215
+ is_disabled: false,
216
+ });
217
+ }
218
+
219
+ // Return all unparsable errors,.
220
+ Err(SmtpError::AsyncSmtpError(err))
221
+ }
222
+ }
223
+ }
224
+
225
+ /// Checks if the domain has a catch-all email setup.
226
+ async fn smtp_is_catch_all<S: AsyncBufRead + AsyncWrite + Unpin + Send>(
227
+ smtp_transport: &mut SmtpTransport<S>,
228
+ domain: &str,
229
+ host: &str,
230
+ to_email: &EmailAddress,
231
+ ) -> Result<bool, SmtpError> {
232
+ if has_rule(domain, host, &Rule::SkipCatchAll) {
233
+ tracing::debug!(
234
+ target: LOG_TARGET,
235
+ email=to_email.to_string(),
236
+ domain=domain,
237
+ "Skipping catch-all check"
238
+ );
239
+ return Ok(false);
240
+ }
241
+
242
+ // Create a random 15-char alphanumerical string.
243
+ let mut rng = SmallRng::from_entropy();
244
+ let random_email: String = iter::repeat_with(|| rng.sample(Alphanumeric))
245
+ .map(char::from)
246
+ .take(15)
247
+ .collect();
248
+ let random_email = EmailAddress::new(format!("{}@{}", random_email, domain))?;
249
+
250
+ check_email_deliverability(smtp_transport, &random_email)
251
+ .await
252
+ .map(|result| result.is_deliverable)
253
+ }
254
+
255
+ /// Creates an SMTP future for email verification.
256
+ async fn create_smtp_future(
257
+ to_email: &EmailAddress,
258
+ mx_host: &str,
259
+ domain: &str,
260
+ verif_method: &VerifMethodSmtp,
261
+ ) -> Result<(bool, Deliverability), SmtpError> {
262
+ // FIXME If the SMTP is not connectable, we should actually return an
263
+ // Ok(SmtpDetails { can_connect_smtp: false, ... }).
264
+ let mut smtp_transport = connect_to_smtp_host(to_email, mx_host, verif_method).await?;
265
+
266
+ let is_catch_all = smtp_is_catch_all(&mut smtp_transport, domain, mx_host, to_email)
267
+ .await
268
+ .unwrap_or(false);
269
+ let deliverability = if is_catch_all {
270
+ Deliverability {
271
+ has_full_inbox: false,
272
+ is_deliverable: true,
273
+ is_disabled: false,
274
+ }
275
+ } else {
276
+ let mut result = check_email_deliverability(&mut smtp_transport, to_email).await;
277
+
278
+ // Some SMTP servers automatically close the connection after an error,
279
+ // so we should reconnect to perform a next command.
280
+ //
281
+ // Unfortunately `smtp_transport.is_connected()` doesn't report about this,
282
+ // so we can only check for "io: incomplete" SMTP error being returned.
283
+ // https://github.com/async-email/async-smtp/issues/37
284
+ if let Err(e) = &result {
285
+ if parser::is_err_io_errors(e) {
286
+ tracing::debug!(
287
+ target: LOG_TARGET,
288
+ email=to_email.to_string(),
289
+ error=?e,
290
+ "Got `io: incomplete` error, reconnecting"
291
+ );
292
+
293
+ let _ = smtp_transport.quit().await;
294
+ smtp_transport = connect_to_smtp_host(to_email, mx_host, verif_method).await?;
295
+ result = check_email_deliverability(&mut smtp_transport, to_email).await;
296
+ }
297
+ }
298
+
299
+ result?
300
+ };
301
+
302
+ smtp_transport
303
+ .quit()
304
+ .await
305
+ .map_err(SmtpError::AsyncSmtpError)?;
306
+
307
+ Ok((is_catch_all, deliverability))
308
+ }
309
+
310
+ /// Get all email details we can from one single `EmailAddress`, without
311
+ /// retries.
312
+ async fn check_smtp_without_retry(
313
+ to_email: &EmailAddress,
314
+ mx_host: &str,
315
+ domain: &str,
316
+ verif_method: &VerifMethodSmtp,
317
+ ) -> Result<SmtpDetails, SmtpError> {
318
+ let fut = create_smtp_future(to_email, mx_host, domain, verif_method);
319
+
320
+ let (is_catch_all, deliverability) = match verif_method.config.smtp_timeout {
321
+ Some(smtp_timeout) => {
322
+ let timeout = tokio::time::timeout(smtp_timeout, fut);
323
+
324
+ match timeout.await {
325
+ Ok(result) => result?,
326
+ Err(_) => return Err(SmtpError::Timeout(smtp_timeout)),
327
+ }
328
+ }
329
+ None => fut.await?,
330
+ };
331
+
332
+ Ok(SmtpDetails {
333
+ can_connect_smtp: true,
334
+ has_full_inbox: deliverability.has_full_inbox,
335
+ is_catch_all,
336
+ is_deliverable: deliverability.is_deliverable,
337
+ is_disabled: deliverability.is_disabled,
338
+ })
339
+ }
340
+
341
+ /// Get all email details we can from one single `EmailAddress`.
342
+ /// Retry the SMTP connection on error, in particular to avoid greylisting.
343
+ #[async_recursion]
344
+ pub async fn check_smtp_with_retry(
345
+ to_email: &EmailAddress,
346
+ mx_host: &str,
347
+ domain: &str,
348
+ verif_method: &VerifMethodSmtp,
349
+ // Number of remaining retries.
350
+ count: usize,
351
+ ) -> Result<SmtpDetails, SmtpError> {
352
+ tracing::debug!(
353
+ target: LOG_TARGET,
354
+ email=to_email.to_string(),
355
+ attempt=verif_method.config.retries - count + 1,
356
+ mx_host=mx_host,
357
+ port=verif_method.config.smtp_port,
358
+ using_proxy=verif_method.proxy.is_some(),
359
+ "Check SMTP"
360
+ );
361
+
362
+ let result = check_smtp_without_retry(to_email, mx_host, domain, verif_method).await;
363
+
364
+ tracing::debug!(
365
+ target: LOG_TARGET,
366
+ email=to_email.to_string(),
367
+ attempt=verif_method.config.retries - count + 1,
368
+ mx_host=mx_host,
369
+ port=verif_method.config.smtp_port,
370
+ result=?result,
371
+ "Got SMTP check result"
372
+ );
373
+
374
+ match &result {
375
+ // Don't retry if we used Hotmail or Yahoo API. This two options should
376
+ // be non-callable, as this function only deals with actual SMTP
377
+ // connection errors.
378
+ Err(SmtpError::HeadlessError(_)) => result,
379
+ Err(SmtpError::YahooError(_)) => result,
380
+ Err(SmtpError::GmailError(_)) => result,
381
+ // Only retry if the SMTP error was unknown.
382
+ Err(err) if err.get_description().is_none() => {
383
+ if count <= 1 {
384
+ result
385
+ } else {
386
+ tracing::debug!(
387
+ target: LOG_TARGET,
388
+ email=to_email.to_string(),
389
+ "Potential greylisting detected, retrying"
390
+ );
391
+ check_smtp_with_retry(to_email, mx_host, domain, verif_method, count - 1).await
392
+ }
393
+ }
394
+ _ => result,
395
+ }
396
+ }