use hickory_resolver::Resolver;
use owo_colors::OwoColorize;
use serde::Serialize;
use std::io::IsTerminal;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpStream;
use tokio::time::{timeout, Duration};
const SMTP_TIMEOUT: Duration = Duration::from_secs(10);
const GREYLIST_DELAY: Duration = Duration::from_secs(5);
const DISPOSABLE_DOMAINS: &[&str] = &[
"mailinator.com", "guerrillamail.com", "tempmail.com", "throwaway.email",
"yopmail.com", "sharklasers.com", "guerrillamailblock.com", "grr.la",
"dispostable.com", "trashmail.com", "mailnesia.com", "maildrop.cc",
"discard.email", "tempail.com", "fakeinbox.com", "mailcatch.com",
"temp-mail.org", "10minutemail.com", "mohmal.com", "burnermail.io",
"inboxkitten.com", "emailondeck.com", "getnada.com", "tempr.email",
"tmail.ws", "tmpmail.net", "tmpmail.org", "harakirimail.com",
"mailsac.com", "spamgourmet.com", "jetable.org", "trash-mail.com",
"mytemp.email", "boun.cr", "filzmail.com", "mailexpire.com",
"tempinbox.com", "spamfree24.org", "mailforspam.com", "safetymail.info",
"trashymail.com", "mailtemp.info", "temporarymail.com", "tempomail.fr",
"mintemail.com", "discardmail.com", "mailnull.com", "spamhereplease.com",
];
#[derive(Debug, Clone, Serialize)]
pub struct VerifyResult {
pub email: String,
pub verdict: String,
pub smtp_code: u16,
pub mx_host: String,
pub is_catch_all: bool,
pub is_disposable: bool,
pub suggestion: String,
}
pub async fn verify_emails(emails: &[String]) -> Vec<VerifyResult> {
let resolver = Resolver::builder_tokio()
.expect("failed to create DNS resolver")
.build();
let mut results = Vec::with_capacity(emails.len());
for email in emails {
results.push(verify_one(&resolver, email).await);
}
results
}
async fn verify_one(resolver: &hickory_resolver::TokioResolver, email: &str) -> VerifyResult {
let email = email.trim().to_lowercase();
let parts: Vec<&str> = email.splitn(2, '@').collect();
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() || !parts[1].contains('.') {
return make_result(&email, "syntax_error", 0, "", false, false,
"Invalid email format.");
}
let domain = parts[1];
let is_disposable = DISPOSABLE_DOMAINS.contains(&domain);
let mx_host = match resolve_mx(resolver, domain).await {
Some(host) => host,
None => {
return make_result(&email, "unreachable", 0, "", false, is_disposable,
&format!("No MX records found for domain '{domain}'."));
}
};
let catch_all_addr = format!("xvfy-probe-7f3a9b@{domain}");
let is_catch_all = matches!(smtp_probe(&mx_host, &catch_all_addr).await, SmtpResult::Accepted(_));
let result = smtp_probe(&mx_host, &email).await;
match result {
SmtpResult::Accepted(code) => {
if is_catch_all {
make_result(&email, "catch_all", code, &mx_host, true, is_disposable,
"Domain accepts all addresses. Email format likely valid but unverifiable.")
} else {
make_result(&email, "valid", code, &mx_host, false, is_disposable,
"Mailbox exists and accepts mail.")
}
}
SmtpResult::Rejected(code) => {
make_result(&email, "invalid", code, &mx_host, is_catch_all, is_disposable,
"Mailbox does not exist.")
}
SmtpResult::Greylisted(code) => {
tokio::time::sleep(GREYLIST_DELAY).await;
match smtp_probe(&mx_host, &email).await {
SmtpResult::Accepted(c) => {
if is_catch_all {
make_result(&email, "catch_all", c, &mx_host, true, is_disposable,
"Domain accepts all addresses. Email format likely valid but unverifiable.")
} else {
make_result(&email, "valid", c, &mx_host, false, is_disposable,
"Mailbox exists and accepts mail (passed greylist).")
}
}
SmtpResult::Rejected(c) => {
make_result(&email, "invalid", c, &mx_host, is_catch_all, is_disposable,
"Mailbox does not exist.")
}
_ => {
make_result(&email, "unreachable", code, &mx_host, is_catch_all, is_disposable,
"Server greylisted the request and did not respond on retry.")
}
}
}
SmtpResult::Timeout => {
make_result(&email, "timeout", 0, &mx_host, is_catch_all, is_disposable,
"SMTP server did not respond within timeout.")
}
SmtpResult::Error(msg) => {
make_result(&email, "unreachable", 0, &mx_host, is_catch_all, is_disposable,
&format!("Connection failed: {msg}"))
}
}
}
async fn resolve_mx(resolver: &hickory_resolver::TokioResolver, domain: &str) -> Option<String> {
match resolver.mx_lookup(domain).await {
Ok(mx) => {
mx.into_iter()
.min_by_key(|r| r.preference())
.map(|r| r.exchange().to_string().trim_end_matches('.').to_string())
}
Err(_) => None,
}
}
enum SmtpResult {
Accepted(u16),
Rejected(u16),
Greylisted(u16),
Timeout,
Error(String),
}
async fn smtp_probe(mx_host: &str, email: &str) -> SmtpResult {
let addr = format!("{mx_host}:25");
let stream = match timeout(SMTP_TIMEOUT, TcpStream::connect(&addr)).await {
Ok(Ok(s)) => s,
Ok(Err(e)) => return SmtpResult::Error(e.to_string()),
Err(_) => return SmtpResult::Timeout,
};
let (reader, mut writer) = stream.into_split();
let mut reader = BufReader::new(reader);
let mut line = String::new();
if read_line(&mut reader, &mut line).await.is_err() {
return SmtpResult::Error("No greeting".into());
}
if send_cmd(&mut writer, &mut reader, &mut line, "EHLO verify.local\r\n").await.is_err() {
return SmtpResult::Error("EHLO failed".into());
}
if send_cmd(&mut writer, &mut reader, &mut line, "MAIL FROM:<>\r\n").await.is_err() {
return SmtpResult::Error("MAIL FROM failed".into());
}
let rcpt = format!("RCPT TO:<{email}>\r\n");
if timeout(SMTP_TIMEOUT, writer.write_all(rcpt.as_bytes())).await.is_err() {
return SmtpResult::Timeout;
}
line.clear();
match timeout(SMTP_TIMEOUT, reader.read_line(&mut line)).await {
Ok(Ok(_)) => {}
_ => return SmtpResult::Timeout,
}
let code = parse_code(&line);
let _ = timeout(Duration::from_secs(2), writer.write_all(b"QUIT\r\n")).await;
match code {
250 | 251 => SmtpResult::Accepted(code),
550 | 551 | 552 | 553 | 554 => SmtpResult::Rejected(code),
450 | 451 | 452 | 421 => SmtpResult::Greylisted(code),
_ => SmtpResult::Rejected(code),
}
}
async fn read_line(
reader: &mut BufReader<tokio::net::tcp::OwnedReadHalf>,
line: &mut String,
) -> Result<(), ()> {
line.clear();
match timeout(SMTP_TIMEOUT, reader.read_line(line)).await {
Ok(Ok(n)) if n > 0 => {
while line.len() >= 4 && line.as_bytes().get(3) == Some(&b'-') {
let mut cont = String::new();
match timeout(SMTP_TIMEOUT, reader.read_line(&mut cont)).await {
Ok(Ok(n)) if n > 0 => line.push_str(&cont),
_ => break,
}
}
Ok(())
}
_ => Err(()),
}
}
async fn send_cmd(
writer: &mut tokio::net::tcp::OwnedWriteHalf,
reader: &mut BufReader<tokio::net::tcp::OwnedReadHalf>,
line: &mut String,
cmd: &str,
) -> Result<u16, ()> {
match timeout(SMTP_TIMEOUT, writer.write_all(cmd.as_bytes())).await {
Ok(Ok(_)) => {}
_ => return Err(()),
}
read_line(reader, line).await?;
let code = parse_code(line);
if code >= 400 { Err(()) } else { Ok(code) }
}
fn parse_code(line: &str) -> u16 {
line.get(..3).and_then(|s| s.parse().ok()).unwrap_or(0)
}
fn make_result(
email: &str, verdict: &str, smtp_code: u16, mx_host: &str,
is_catch_all: bool, is_disposable: bool, suggestion: &str,
) -> VerifyResult {
VerifyResult {
email: email.to_string(),
verdict: verdict.to_string(),
smtp_code,
mx_host: mx_host.to_string(),
is_catch_all,
is_disposable,
suggestion: suggestion.to_string(),
}
}
pub fn render_table(results: &[VerifyResult]) {
let use_color = std::io::stdout().is_terminal();
if use_color {
eprintln!("\n{} Email Verification\n", "search".bold().cyan());
}
for r in results {
let verdict_display = if use_color {
match r.verdict.as_str() {
"valid" => format!("{}", "VALID".green().bold()),
"invalid" => format!("{}", "INVALID".red().bold()),
"catch_all" => format!("{}", "CATCH-ALL".yellow().bold()),
"unreachable" => format!("{}", "UNREACHABLE".red()),
"timeout" => format!("{}", "TIMEOUT".yellow()),
"syntax_error" => format!("{}", "SYNTAX ERROR".red()),
_ => r.verdict.clone(),
}
} else {
r.verdict.to_uppercase()
};
let email_display = if use_color {
r.email.bold().to_string()
} else {
r.email.clone()
};
println!(" {} -> {}", email_display, verdict_display);
if !r.mx_host.is_empty() {
if use_color {
println!(" {} {}", "MX:".dimmed(), r.mx_host.dimmed());
} else {
println!(" MX: {}", r.mx_host);
}
}
if use_color {
println!(" {}", r.suggestion.dimmed());
} else {
println!(" {}", r.suggestion);
}
println!();
}
let valid = results.iter().filter(|r| r.verdict == "valid").count();
let total = results.len();
if use_color {
eprintln!(" {}/{} verified as valid", valid.to_string().bold(), total);
} else {
eprintln!(" {}/{} verified as valid", valid, total);
}
eprintln!();
}