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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +24 -0
- data/Cargo.lock +2940 -0
- data/Cargo.toml +22 -0
- data/LICENSE.txt +661 -0
- data/NOTICE.md +56 -0
- data/README.md +316 -0
- data/exe/valid_email_checker +148 -0
- data/ext/valid_email_checker/Cargo.toml +27 -0
- data/ext/valid_email_checker/extconf.rb +26 -0
- data/ext/valid_email_checker/src/lib.rs +371 -0
- data/ext/valid_email_checker/src/nogvl.rs +64 -0
- data/ext/valid_email_checker/vendor/UPSTREAM.json +9 -0
- data/ext/valid_email_checker/vendor/check-if-email-exists/Cargo.toml +52 -0
- data/ext/valid_email_checker/vendor/check-if-email-exists/LICENSE.AGPL +661 -0
- data/ext/valid_email_checker/vendor/check-if-email-exists/LICENSE.md +11 -0
- data/ext/valid_email_checker/vendor/check-if-email-exists/README.md +175 -0
- data/ext/valid_email_checker/vendor/check-if-email-exists/src/haveibeenpwned.rs +70 -0
- data/ext/valid_email_checker/vendor/check-if-email-exists/src/lib.rs +281 -0
- data/ext/valid_email_checker/vendor/check-if-email-exists/src/misc/b2c.txt +96640 -0
- data/ext/valid_email_checker/vendor/check-if-email-exists/src/misc/gravatar.rs +60 -0
- data/ext/valid_email_checker/vendor/check-if-email-exists/src/misc/mod.rs +124 -0
- data/ext/valid_email_checker/vendor/check-if-email-exists/src/misc/roles.txt +944 -0
- data/ext/valid_email_checker/vendor/check-if-email-exists/src/mx/mod.rs +165 -0
- data/ext/valid_email_checker/vendor/check-if-email-exists/src/rules.json +28 -0
- data/ext/valid_email_checker/vendor/check-if-email-exists/src/rules.rs +105 -0
- data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/connect.rs +396 -0
- data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/error.rs +144 -0
- data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/gmail.rs +99 -0
- data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/headless.rs +82 -0
- data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/http_api.rs +27 -0
- data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/mod.rs +234 -0
- data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/outlook/headless.rs +181 -0
- data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/outlook/microsoft365.rs +109 -0
- data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/outlook/mod.rs +2 -0
- data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/parser.rs +291 -0
- data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/verif_method.rs +531 -0
- data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/yahoo/api.rs +174 -0
- data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/yahoo/headless.rs +188 -0
- data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/yahoo/mod.rs +62 -0
- data/ext/valid_email_checker/vendor/check-if-email-exists/src/syntax/mod.rs +199 -0
- data/ext/valid_email_checker/vendor/check-if-email-exists/src/syntax/normalize.rs +70 -0
- data/ext/valid_email_checker/vendor/check-if-email-exists/src/util/input_output.rs +353 -0
- data/ext/valid_email_checker/vendor/check-if-email-exists/src/util/mod.rs +20 -0
- data/ext/valid_email_checker/vendor/check-if-email-exists/src/util/sentry.rs +173 -0
- data/ext/valid_email_checker/vendor/check-if-email-exists/src/util/ser_with_display.rs +28 -0
- data/lib/valid_email_checker/configuration.rb +216 -0
- data/lib/valid_email_checker/errors.rb +32 -0
- data/lib/valid_email_checker/result.rb +241 -0
- data/lib/valid_email_checker/syntax.rb +72 -0
- data/lib/valid_email_checker/version.rb +5 -0
- data/lib/valid_email_checker.rb +217 -0
- 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
|
+
}
|