// mailcheck: small, dependency-free email verifier + MCP server. // It NEVER sends a message: it stops the SMTP dialogue at RCPT TO and quits. use std::collections::HashMap; use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpStream, ToSocketAddrs, UdpSocket}; use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; // ---------------------------------------------------------------- JSON #[derive(Clone, Debug)] enum J { Null, Bool(bool), Num(f64), Str(String), Arr(Vec), Obj(Vec<(String, J)>), } impl J { fn get(&self, k: &str) -> Option<&J> { if let J::Obj(v) = self { v.iter().find(|(a, _)| a == k).map(|(_, b)| b) } else { None } } fn str(&self) -> Option<&str> { if let J::Str(s) = self { Some(s) } else { None } } } fn esc(s: &str) -> String { let mut o = String::with_capacity(s.len() + 2); o.push('"'); for c in s.chars() { match c { '"' => o.push_str("\\\""), '\\' => o.push_str("\\\\"), '\n' => o.push_str("\\n"), '\r' => o.push_str("\\r"), '\t' => o.push_str("\\t"), c if (c as u32) < 0x20 => o.push_str(&format!("\\u{:04x}", c as u32)), c => o.push(c), } } o.push('"'); o } fn ser(j: &J) -> String { match j { J::Null => "null".into(), J::Bool(b) => b.to_string(), J::Num(n) => { if n.fract() == 0.0 && n.abs() < 1e15 { format!("{}", *n as i64) } else { n.to_string() } } J::Str(s) => esc(s), J::Arr(v) => format!("[{}]", v.iter().map(ser).collect::>().join(",")), J::Obj(v) => format!( "{{{}}}", v.iter().map(|(k, x)| format!("{}:{}", esc(k), ser(x))).collect::>().join(",") ), } } struct P<'a> { s: &'a [u8], i: usize, } impl<'a> P<'a> { fn peek(&self) -> Option { self.s.get(self.i).copied() } fn ws(&mut self) { while self.i < self.s.len() && (self.s[self.i] as char).is_ascii_whitespace() { self.i += 1; } } fn expect(&mut self, c: u8) -> Result<(), String> { if self.peek() == Some(c) { self.i += 1; Ok(()) } else { Err(format!("expected '{}'", c as char)) } } fn lit(&mut self, w: &str) -> Result<(), String> { if self.s[self.i..].starts_with(w.as_bytes()) { self.i += w.len(); Ok(()) } else { Err("bad literal".into()) } } fn hex4(&mut self) -> Result { let h = self.s.get(self.i..self.i + 4).ok_or("bad \\u")?; let v = u32::from_str_radix(std::str::from_utf8(h).map_err(|_| "bad \\u")?, 16).map_err(|_| "bad \\u")?; self.i += 4; Ok(v) } fn string(&mut self) -> Result { self.expect(b'"')?; let mut out: Vec = vec![]; loop { let c = self.peek().ok_or("unterminated string")?; self.i += 1; match c { b'"' => break, b'\\' => { let e = self.peek().ok_or("bad escape")?; self.i += 1; match e { b'n' => out.push(b'\n'), b'r' => out.push(b'\r'), b't' => out.push(b'\t'), b'b' => out.push(8), b'f' => out.push(12), b'u' => { let mut cp = self.hex4()?; if (0xD800..0xDC00).contains(&cp) && self.s[self.i..].starts_with(b"\\u") { self.i += 2; let lo = self.hex4()?; cp = 0x10000 + ((cp - 0xD800) << 10) + (lo.wrapping_sub(0xDC00) & 0x3FF); } let ch = char::from_u32(cp).unwrap_or('\u{FFFD}'); let mut b = [0u8; 4]; out.extend_from_slice(ch.encode_utf8(&mut b).as_bytes()); } o => out.push(o), } } o => out.push(o), } } Ok(String::from_utf8_lossy(&out).into_owned()) } fn val(&mut self) -> Result { self.ws(); match self.peek().ok_or("unexpected end")? { b'{' => { self.i += 1; let mut v = vec![]; loop { self.ws(); if self.peek() == Some(b'}') { self.i += 1; break; } let k = self.string()?; self.ws(); self.expect(b':')?; let x = self.val()?; v.push((k, x)); self.ws(); match self.peek() { Some(b',') => self.i += 1, Some(b'}') => { self.i += 1; break; } _ => return Err("bad object".into()), } } Ok(J::Obj(v)) } b'[' => { self.i += 1; let mut v = vec![]; loop { self.ws(); if self.peek() == Some(b']') { self.i += 1; break; } v.push(self.val()?); self.ws(); match self.peek() { Some(b',') => self.i += 1, Some(b']') => { self.i += 1; break; } _ => return Err("bad array".into()), } } Ok(J::Arr(v)) } b'"' => Ok(J::Str(self.string()?)), b't' => self.lit("true").map(|_| J::Bool(true)), b'f' => self.lit("false").map(|_| J::Bool(false)), b'n' => self.lit("null").map(|_| J::Null), _ => { let st = self.i; while self.i < self.s.len() && b"+-0123456789.eE".contains(&self.s[self.i]) { self.i += 1; } std::str::from_utf8(&self.s[st..self.i]) .ok() .and_then(|t| t.parse::().ok()) .map(J::Num) .ok_or_else(|| "bad number".to_string()) } } } } fn parse_json(s: &str) -> Result { let mut p = P { s: s.as_bytes(), i: 0 }; p.val() } // ---------------------------------------------------------------- time fn now() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) } fn iso(t: u64) -> String { let days = (t / 86400) as i64; let s = t % 86400; let z = days + 719468; let era = z.div_euclid(146097); let doe = z.rem_euclid(146097); let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let y = if m <= 2 { y + 1 } else { y }; format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, m, d, s / 3600, (s % 3600) / 60, s % 60) } // ---------------------------------------------------------------- syntax fn syntax(e: &str) -> Option<(String, String)> { let e = e.trim().to_lowercase(); if e.len() > 254 || e.matches('@').count() != 1 { return None; } let (l, d) = e.split_once('@')?; if l.is_empty() || l.len() > 64 || l.starts_with('.') || l.ends_with('.') || l.contains("..") { return None; } if !l.chars().all(|c| c.is_ascii_alphanumeric() || "._%+-'&=".contains(c)) { return None; } let labels: Vec<&str> = d.split('.').collect(); if labels.len() < 2 { return None; } for lb in &labels { if lb.is_empty() || lb.len() > 63 || lb.starts_with('-') || lb.ends_with('-') { return None; } if !lb.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { return None; } } let tld = labels.last()?; if tld.len() < 2 || !tld.chars().all(|c| c.is_ascii_alphabetic()) { return None; } Some((l.to_string(), d.to_string())) } // ---------------------------------------------------------------- DNS (UDP, TCP fallback) fn qname(name: &str) -> Vec { let mut o = vec![]; for l in name.split('.') { o.push(l.len() as u8); o.extend_from_slice(l.as_bytes()); } o.push(0); o } fn u16at(b: &[u8], p: usize) -> Result { Ok(u16::from_be_bytes([*b.get(p).ok_or("short dns")?, *b.get(p + 1).ok_or("short dns")?])) } fn read_name(b: &[u8], mut p: usize) -> Result<(String, usize), String> { let mut out = String::new(); let mut end: Option = None; let mut hops = 0; loop { let l = *b.get(p).ok_or("short dns")? as usize; if l == 0 { p += 1; break; } if l & 0xC0 == 0xC0 { let ptr = ((l & 0x3F) << 8) | *b.get(p + 1).ok_or("short dns")? as usize; if end.is_none() { end = Some(p + 2); } hops += 1; if hops > 20 { return Err("dns loop".into()); } p = ptr; continue; } let lab = b.get(p + 1..p + 1 + l).ok_or("short dns")?; if !out.is_empty() { out.push('.'); } out.push_str(&String::from_utf8_lossy(lab).to_lowercase()); p += 1 + l; } Ok((out, end.unwrap_or(p))) } fn parse_resp(b: &[u8], qtype: u16) -> Result<(u8, bool, Vec<(u16, String)>), String> { let flags = u16at(b, 2)?; let (tc, rcode) = (flags & 0x200 != 0, (flags & 0xF) as u8); let (qd, an) = (u16at(b, 4)?, u16at(b, 6)?); let mut p = 12; for _ in 0..qd { let (_, np) = read_name(b, p)?; p = np + 4; } let mut out = vec![]; for _ in 0..an { let (_, np) = read_name(b, p)?; p = np; let t = u16at(b, p)?; let rdlen = u16at(b, p + 8)? as usize; p += 10; if p + rdlen > b.len() { return Err("short dns".into()); } if t == qtype { if t == 15 { let pref = u16at(b, p)?; let (h, _) = read_name(b, p + 2)?; out.push((pref, h)); } else { out.push((0, "a".into())); } } p += rdlen; } Ok((rcode, tc, out)) } fn dns_one(server: &str, name: &str, qtype: u16) -> Result<(u8, Vec<(u16, String)>), String> { let id = (SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.subsec_nanos()).unwrap_or(7) & 0xFFFF) as u16; let mut q = vec![]; q.extend_from_slice(&id.to_be_bytes()); q.extend_from_slice(&[1, 0, 0, 1, 0, 0, 0, 0, 0, 0]); q.extend(qname(name)); q.extend_from_slice(&qtype.to_be_bytes()); q.extend_from_slice(&[0, 1]); let sock = UdpSocket::bind("0.0.0.0:0").map_err(|e| e.to_string())?; sock.set_read_timeout(Some(Duration::from_secs(3))).ok(); sock.send_to(&q, format!("{}:53", server)).map_err(|e| e.to_string())?; let mut buf = [0u8; 4096]; let n = sock.recv(&mut buf).map_err(|e| e.to_string())?; let (rc, tc, ans) = parse_resp(&buf[..n], qtype)?; if !tc { return Ok((rc, ans)); } let mut t = TcpStream::connect_timeout(&format!("{}:53", server).parse().map_err(|_| "bad dns server")?, Duration::from_secs(4)).map_err(|e| e.to_string())?; t.set_read_timeout(Some(Duration::from_secs(4))).ok(); t.write_all(&(q.len() as u16).to_be_bytes()).map_err(|e| e.to_string())?; t.write_all(&q).map_err(|e| e.to_string())?; let mut l = [0u8; 2]; t.read_exact(&mut l).map_err(|e| e.to_string())?; let mut body = vec![0u8; u16::from_be_bytes(l) as usize]; t.read_exact(&mut body).map_err(|e| e.to_string())?; let (rc, _, ans) = parse_resp(&body, qtype)?; Ok((rc, ans)) } fn dns(name: &str, qtype: u16) -> Result<(u8, Vec<(u16, String)>), String> { let servers = std::env::var("MAILCHECK_DNS").unwrap_or_else(|_| "1.1.1.1,8.8.8.8".into()); let mut last = "no dns server".to_string(); for s in servers.split(',') { match dns_one(s.trim(), name, qtype) { Ok(r) => return Ok(r), Err(e) => last = e, } } Err(last) } enum Mx { Hosts(Vec), Null, NoDomain, NoMail, Err(String), } fn lookup_mx(d: &str) -> Mx { match dns(d, 15) { Ok((3, _)) => Mx::NoDomain, Ok((0, mut v)) if !v.is_empty() => { v.sort(); if v.len() == 1 && (v[0].1.is_empty() || v[0].1 == ".") { Mx::Null } else { Mx::Hosts(v.into_iter().map(|x| x.1).collect()) } } Ok((0, _)) => match dns(d, 1) { Ok((0, a)) if !a.is_empty() => Mx::Hosts(vec![d.to_string()]), Ok((3, _)) => Mx::NoDomain, Ok(_) => Mx::NoMail, Err(e) => Mx::Err(e), }, Ok((rc, _)) => Mx::Err(format!("dns error code {}", rc)), Err(e) => Mx::Err(e), } } // ---------------------------------------------------------------- SMTP (stops at RCPT TO) #[derive(Default)] struct Smtp { connected: bool, code: Option, msg: String, probe: Option, err: Option, } fn read_reply(r: &mut BufReader) -> Result<(u16, String), String> { let mut msg = String::new(); loop { let mut line = String::new(); let n = r.read_line(&mut line).map_err(|e| e.to_string())?; if n == 0 { return Err("connection closed".into()); } let code: u16 = line.get(..3).and_then(|c| c.parse().ok()).ok_or("bad smtp reply")?; msg.push_str(line.trim_end()); msg.push(' '); if line.as_bytes().get(3) != Some(&b'-') { return Ok((code, msg)); } } } fn cmd(w: &mut TcpStream, r: &mut BufReader, c: &str) -> Result<(u16, String), String> { w.write_all(format!("{}\r\n", c).as_bytes()).map_err(|e| e.to_string())?; read_reply(r) } fn smtp_session(mx: &str, helo: &str, from: &str, rcpt: &str, probe: Option<&str>, o: &mut Smtp) -> Result<(), String> { let addrs: Vec<_> = (mx, 25).to_socket_addrs().map_err(|e| format!("cannot resolve {}: {}", mx, e))?.collect(); let mut stream = None; for a in addrs { if let Ok(s) = TcpStream::connect_timeout(&a, Duration::from_secs(10)) { stream = Some(s); break; } } let s = stream.ok_or("cannot connect to port 25")?; s.set_read_timeout(Some(Duration::from_secs(20))).ok(); s.set_write_timeout(Some(Duration::from_secs(10))).ok(); o.connected = true; let mut w = s.try_clone().map_err(|e| e.to_string())?; let mut r = BufReader::new(s); let (c, m) = read_reply(&mut r)?; if c != 220 { return Err(format!("server greeting {} {}", c, m)); } let (mut c, mut m) = cmd(&mut w, &mut r, &format!("EHLO {}", helo))?; if c != 250 { let x = cmd(&mut w, &mut r, &format!("HELO {}", helo))?; c = x.0; m = x.1; } if c != 250 { return Err(format!("HELO rejected {} {}", c, m)); } let (c, m) = cmd(&mut w, &mut r, &format!("MAIL FROM:<{}>", from))?; if c != 250 { o.code = Some(c); o.msg = m.clone(); return Err(format!("sender rejected {} {}", c, m)); } let (c, m) = cmd(&mut w, &mut r, &format!("RCPT TO:<{}>", rcpt))?; o.code = Some(c); o.msg = m; if c == 250 || c == 251 { if let Some(p) = probe { if let Ok((pc, _)) = cmd(&mut w, &mut r, &format!("RCPT TO:<{}>", p)) { o.probe = Some(pc); } } } let _ = w.write_all(b"RSET\r\nQUIT\r\n"); Ok(()) } enum Rc { Accept, Invalid(String), Temp, Policy, Unclear, } fn classify_rcpt(code: u16, msg: &str) -> Rc { let m = msg.to_lowercase(); match code { 250 | 251 => Rc::Accept, 421 | 450 | 451 | 452 | 4 => Rc::Temp, c if (400..500).contains(&c) => Rc::Temp, 554 if m.contains("relay") => Rc::Invalid("server refuses mail for this domain (relay denied); it would bounce".into()), c if (500..600).contains(&c) => { let gone = ["5.1.1", "5.1.2", "5.1.0", "no such", "unknown user", "user unknown", "does not exist", "doesn't exist", "not found", "invalid recipient", "unrouteable", "no mailbox", "mailbox unavailable", "recipient rejected", "address rejected", "unknown recipient", "no such user"]; let blocked = ["block", "spam", "blacklist", "denied", "policy", "reputation", "5.7.", "not allowed", "dnsbl", "spamhaus", "banned", "access"]; if gone.iter().any(|k| m.contains(k)) && !blocked.iter().any(|k| m.contains(k) && !m.contains("5.1.1")) { Rc::Invalid("mail server says the mailbox does not exist".into()) } else if blocked.iter().any(|k| m.contains(k)) { Rc::Policy } else { Rc::Unclear } } _ => Rc::Unclear, } } // ---------------------------------------------------------------- verification struct Res { email: String, status: String, // valid | invalid | catch_all | unknown reason: String, catch_all: Option, mx: String, code: Option, flags: Vec<&'static str>, checked: String, } impl Res { fn new(email: &str, status: &str, reason: &str) -> Res { Res { email: email.into(), status: status.into(), reason: reason.into(), catch_all: None, mx: String::new(), code: None, flags: vec![], checked: iso(now()) } } fn json(&self) -> String { format!( "{{\"email\":{},\"status\":{},\"reason\":{},\"catch_all\":{},\"mx\":{},\"smtp_code\":{},\"flags\":[{}],\"checked_at\":{}}}", esc(&self.email), esc(&self.status), esc(&self.reason), self.catch_all.map(|b| b.to_string()).unwrap_or("null".into()), esc(&self.mx), self.code.map(|c| c.to_string()).unwrap_or("null".into()), self.flags.iter().map(|f| esc(f)).collect::>().join(","), esc(&self.checked) ) } fn csv(&self) -> String { let q = |s: &str| format!("\"{}\"", s.replace('"', "\"\"")); format!( "{},{},{},{},{},{},{},{}", q(&self.email), q(&self.status), q(&self.reason), self.catch_all.map(|b| b.to_string()).unwrap_or_default(), q(&self.mx), self.code.map(|c| c.to_string()).unwrap_or_default(), q(&self.flags.join(";")), q(&self.checked) ) } } const ROLES: [&str; 22] = ["info", "contact", "hello", "admin", "support", "sales", "office", "team", "hi", "mail", "enquiries", "inquiries", "press", "pr", "jobs", "careers", "general", "investments", "invest", "partnerships", "help", "ir"]; const FORWARDERS: [&str; 7] = ["registrar-servers.com", "improvmx.com", "forwardemail.net", "mx.cloudflare.net", "mailgun.org", "pairnic", "easydns"]; fn helo() -> String { std::env::var("MAILCHECK_HELO").ok().or_else(|| std::fs::read_to_string("/etc/hostname").ok().map(|s| s.trim().to_string())).filter(|s| !s.is_empty()).unwrap_or_else(|| "localhost".into()) } fn from_addr() -> String { std::env::var("MAILCHECK_FROM").unwrap_or_else(|_| "check@example.com".into()) } fn verify_group(domain: &str, emails: Vec) -> Vec { let mut out = vec![]; let hosts = match lookup_mx(domain) { Mx::Hosts(h) => h, Mx::Null => return emails.iter().map(|e| Res::new(e, "invalid", "domain publishes a null MX: it accepts no mail")).collect(), Mx::NoDomain => return emails.iter().map(|e| Res::new(e, "invalid", "domain does not exist")).collect(), Mx::NoMail => return emails.iter().map(|e| Res::new(e, "invalid", "domain has no mail server")).collect(), Mx::Err(e) => return emails.iter().map(|x| Res::new(x, "unknown", &format!("DNS lookup failed: {}", e))).collect(), }; let fwd = hosts.iter().any(|h| FORWARDERS.iter().any(|f| h.contains(f))); let (helo, from) = (helo(), from_addr()); let mut catch_all: Option = None; for (i, email) in emails.iter().enumerate() { if i > 0 { std::thread::sleep(Duration::from_millis(1500)); } let local = email.split('@').next().unwrap_or(""); let mut r = Res::new(email, "unknown", ""); r.mx = hosts[0].clone(); if ROLES.contains(&local) { r.flags.push("role_address"); } if fwd { r.flags.push("forwarding_mx"); } if catch_all == Some(true) { r.status = "catch_all".into(); r.reason = "domain accepts every address, so this mailbox cannot be confirmed".into(); r.catch_all = Some(true); out.push(r); continue; } let nanos = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.subsec_nanos()).unwrap_or(1); let probe = format!("zq{:x}x9@{}", nanos, domain); let mut o = Smtp::default(); let mut last_err = String::new(); for h in hosts.iter().take(2) { o = Smtp::default(); match smtp_session(h, &helo, &from, email, if catch_all.is_none() { Some(&probe) } else { None }, &mut o) { Ok(()) => { r.mx = h.clone(); last_err.clear(); break; } Err(e) => last_err = e, } } r.code = o.code; if !last_err.is_empty() { r.reason = if o.connected { format!("SMTP check failed: {}", last_err) } else { format!("could not reach the mail server on port 25 ({})", last_err) }; out.push(r); continue; } if let Some(pc) = o.probe { catch_all = if (250..252).contains(&pc) { Some(true) } else if (500..600).contains(&pc) { Some(false) } else { catch_all }; } r.catch_all = catch_all; match classify_rcpt(o.code.unwrap_or(0), &o.msg) { Rc::Invalid(why) => { r.status = "invalid".into(); r.reason = why; } Rc::Accept => match catch_all { Some(true) => { r.status = "catch_all".into(); r.reason = "domain accepts every address, so this mailbox cannot be confirmed".into(); } Some(false) if fwd => r.reason = "MX is a forwarding service; the inbox behind it cannot be checked".into(), Some(false) => { r.status = "valid".into(); r.reason = "mail server accepted this mailbox and rejected a random address".into(); } None => r.reason = "accepted, but the catch-all test was inconclusive".into(), }, Rc::Temp => r.reason = "temporary refusal (greylisting or rate limit); retry later".into(), Rc::Policy => r.reason = "server blocked the check (policy or reputation)".into(), Rc::Unclear => r.reason = format!("unclear server answer: {}", o.msg.trim()), } out.push(r); } out } fn verify_many(emails: Vec, threads: usize) -> Vec { let n = emails.len(); let results: Arc>>> = Arc::new(Mutex::new((0..n).map(|_| None).collect())); let mut groups: HashMap> = HashMap::new(); for (i, e) in emails.iter().enumerate() { match syntax(e) { Some((_, d)) => groups.entry(d).or_default().push((i, e.trim().to_lowercase())), None => results.lock().unwrap()[i] = Some(Res::new(e.trim(), "invalid", "malformed address")), } } let queue: Arc)>>> = Arc::new(Mutex::new(groups.into_iter().collect())); let done = Arc::new(Mutex::new(0usize)); let mut hs = vec![]; for _ in 0..threads.max(1) { let (q, res, done) = (queue.clone(), results.clone(), done.clone()); hs.push(std::thread::spawn(move || loop { let job = q.lock().unwrap().pop(); let Some((dom, items)) = job else { break }; let rs = verify_group(&dom, items.iter().map(|x| x.1.clone()).collect()); let k = rs.len(); let mut g = res.lock().unwrap(); for ((idx, _), r) in items.iter().zip(rs) { g[*idx] = Some(r); } drop(g); let mut d = done.lock().unwrap(); *d += k; if *d % 25 < k { eprintln!("progress: {}/{}", *d, n); } })); } for h in hs { let _ = h.join(); } let mut g = results.lock().unwrap(); g.iter_mut().enumerate().map(|(i, r)| r.take().unwrap_or_else(|| Res::new(&emails[i], "unknown", "not checked"))).collect() } // ---------------------------------------------------------------- MCP (stdio JSON-RPC) fn tool_text(s: &str, err: bool) -> String { format!("{{\"content\":[{{\"type\":\"text\",\"text\":{}}}],\"isError\":{}}}", esc(s), err) } fn mcp() { let stdin = std::io::stdin(); let mut out = std::io::stdout(); for line in stdin.lock().lines() { let Ok(line) = line else { break }; if line.trim().is_empty() { continue; } let Ok(msg) = parse_json(&line) else { continue }; let id = msg.get("id").cloned(); let method = msg.get("method").and_then(|m| m.str()).unwrap_or("").to_string(); let Some(id) = id else { continue }; // notification let idj = ser(&id); let result: Result = match method.as_str() { "initialize" => { let pv = msg.get("params").and_then(|p| p.get("protocolVersion")).and_then(|v| v.str()).unwrap_or("2024-11-05").to_string(); Ok(format!("{{\"protocolVersion\":{},\"capabilities\":{{\"tools\":{{}}}},\"serverInfo\":{{\"name\":\"mailcheck\",\"version\":\"0.1.0\"}}}}", esc(&pv))) } "ping" => Ok("{}".into()), "tools/list" => Ok(concat!( "{\"tools\":[", "{\"name\":\"verify_email\",\"description\":\"Check one email address without sending any mail: syntax, DNS/MX, SMTP mailbox check and catch-all test. Returns status valid|invalid|catch_all|unknown with the reason. valid is strong evidence, never a delivery guarantee.\",\"inputSchema\":{\"type\":\"object\",\"properties\":{\"email\":{\"type\":\"string\"}},\"required\":[\"email\"]}},", "{\"name\":\"verify_emails\",\"description\":\"Check up to 200 email addresses (same checks as verify_email). Addresses on the same domain are checked one at a time; different domains run in parallel.\",\"inputSchema\":{\"type\":\"object\",\"properties\":{\"emails\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"maxItems\":200}},\"required\":[\"emails\"]}}", "]}" ) .to_string()), "tools/call" => { let p = msg.get("params"); let name = p.and_then(|p| p.get("name")).and_then(|n| n.str()).unwrap_or(""); let args = p.and_then(|p| p.get("arguments")); match name { "verify_email" => match args.and_then(|a| a.get("email")).and_then(|e| e.str()) { Some(e) => { let r = verify_many(vec![e.to_string()], 1); Ok(tool_text(&r[0].json(), false)) } None => Ok(tool_text("missing 'email'", true)), }, "verify_emails" => match args.and_then(|a| a.get("emails")) { Some(J::Arr(v)) => { let list: Vec = v.iter().filter_map(|x| x.str().map(String::from)).take(200).collect(); let rs = verify_many(list, 16); Ok(tool_text(&format!("[{}]", rs.iter().map(|r| r.json()).collect::>().join(",")), false)) } _ => Ok(tool_text("missing 'emails' array", true)), }, _ => Err((-32602, format!("unknown tool {}", name))), } } _ => Err((-32601, format!("method not found: {}", method))), }; let resp = match result { Ok(r) => format!("{{\"jsonrpc\":\"2.0\",\"id\":{},\"result\":{}}}", idj, r), Err((c, m)) => format!("{{\"jsonrpc\":\"2.0\",\"id\":{},\"error\":{{\"code\":{},\"message\":{}}}}}", idj, c, esc(&m)), }; let _ = writeln!(out, "{}", resp); let _ = out.flush(); } } // ---------------------------------------------------------------- CLI fn main() { let a: Vec = std::env::args().collect(); match a.get(1).map(|s| s.as_str()) { Some("mcp") => mcp(), Some("check") if a.len() > 2 => { for r in verify_many(a[2..].to_vec(), 16) { println!("{}", r.json()); } } Some("file") if a.len() > 3 => { let threads = a.get(4).and_then(|t| t.parse().ok()).unwrap_or(16); let txt = std::fs::read_to_string(&a[2]).expect("cannot read input file"); let mut seen = std::collections::HashSet::new(); let list: Vec = txt.lines().map(|l| l.trim().to_lowercase()).filter(|l| !l.is_empty() && seen.insert(l.clone())).collect(); let rs = verify_many(list, threads); let mut f = std::fs::File::create(&a[3]).expect("cannot write output file"); let _ = writeln!(f, "email,status,reason,catch_all,mx,smtp_code,flags,checked_at"); let mut c: HashMap = HashMap::new(); for r in &rs { let _ = writeln!(f, "{}", r.csv()); *c.entry(r.status.clone()).or_default() += 1; } eprintln!("done: {} checked {:?}", rs.len(), c); } _ => { eprintln!("mailcheck: checks email addresses without sending mail\n mailcheck check a@b.com [c@d.com ...] JSON lines\n mailcheck file in.txt out.csv [threads] one address per line\n mailcheck mcp MCP server on stdio\nEnv: MAILCHECK_FROM (MAIL FROM address), MAILCHECK_HELO, MAILCHECK_DNS"); } } }