Email verification in Rust
Rust's type system does something useful here. Model the verdict as an enum and match becomes exhaustive: the unknown case cannot be skipped, because the compiler will not let you skip it.
reqwest with serde is the combination nearly every Rust service already uses. The client is cheap to clone and holds its connection pool internally, so build one and pass clones around rather than constructing per call.
reqwest async with serde derives, thiserror for the error type, and futures::stream::buffer_unordered for bounded concurrency.
Install
[dependencies]
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
thiserror = "1"
futures = "0.3"Verify one address
You branch on four statuses: valid, invalid, catch_all and unknown. Role and disposable arrive in the flags array beside the status, so test them separately. Unknown is the branch most code forgets, and its credit is refunded.
use serde::Deserialize;
use std::time::Duration;
#[derive(Debug, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum Verdict {
Valid,
Invalid,
CatchAll,
Unknown,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VerifyResult {
pub id: String,
pub email: String,
pub status: Verdict,
pub reason_code: String, // keep it a String: the list of reason codes can grow
pub provider: Option<String>,
pub flags: Vec<String>, // "role", "disposable", "free_provider": beside the status, never inside it
pub smtp_code: Option<u16>,
pub checked_at: String,
}
#[derive(Deserialize)]
struct VerifyResponse {
results: Vec<VerifyResult>,
}
#[derive(Clone)]
pub struct ZapBounce {
http: reqwest::Client,
key: String,
}
impl ZapBounce {
pub fn new(key: impl Into<String>) -> Result<Self, Error> {
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.connect_timeout(Duration::from_secs(3))
.pool_max_idle_per_host(20)
.build()?;
Ok(Self { http, key: key.into() })
}
pub async fn verify(&self, email: &str) -> Result<VerifyResult, Error> {
let response = self
.http
.post("https://app.zapbounce.com/api/v1/verify")
.header("X-API-Key", &self.key)
.json(&serde_json::json!({ "emails": [email] }))
.send()
.await?;
if !response.status().is_success() {
return Err(Error::from_response(response).await);
}
let body: VerifyResponse = response.json().await?;
Ok(body.results.into_iter().next().expect("one email in, one result out"))
}
}
// Exhaustive. Add a variant to Verdict and every match in the codebase fails to build.
match result.status {
_ if result.flags.iter().any(|f| f == "disposable") => suppression.add(&result.email),
Verdict::Valid if result.flags.iter().any(|f| f == "role") => segments.push("shared-mailbox", &result.email),
Verdict::Valid => mailer.send(&result.email).await?,
Verdict::CatchAll => segments.push("accepts-everything", &result.email),
Verdict::Unknown => recheck_queue.push(&result.email),
Verdict::Invalid => suppression.add(&result.email),
}Verify a list
Submit a job rather than looping the single endpoint. We pace probes per receiving mail host, which protects the sending reputation your results depend on.
use futures::stream::{self, StreamExt};
/// Bounded concurrency. buffer_unordered caps in-flight requests, which keeps
/// you inside the rate limit instead of discovering it as a wall of 429s.
pub async fn verify_many(client: &ZapBounce, emails: Vec<String>) -> Vec<Outcome> {
stream::iter(emails)
.map(|email| {
let client = client.clone(); // cheap: the pool is shared, not copied
async move {
let result = client.verify(&email).await;
Outcome { email, result }
}
})
.buffer_unordered(16)
.collect()
.await
}
/// For a list, submit a job (up to 50,000 emails) and poll it. There is no webhook.
impl ZapBounce {
pub async fn submit_job(&self, emails: &[String]) -> Result<String, Error> {
#[derive(Deserialize)]
struct Created { id: String }
let created: Created = self
.http
.post("https://app.zapbounce.com/api/v1/jobs")
.header("X-API-Key", &self.key)
.header("Idempotency-Key", uuid::Uuid::new_v4().to_string())
.json(&serde_json::json!({ "emails": emails, "source": "api" }))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(created.id)
}
pub async fn wait_for_job(&self, id: &str) -> Result<serde_json::Value, Error> {
loop {
let job: serde_json::Value = self
.http
.get(format!("https://app.zapbounce.com/api/v1/jobs/{id}"))
.header("X-API-Key", &self.key)
.send()
.await?
.error_for_status()?
.json()
.await?;
if matches!(job["status"].as_str(), Some("completed" | "partial" | "failed" | "cancelled")) {
return Ok(job);
}
tokio::time::sleep(Duration::from_secs(5)).await;
}
// Rows: GET /jobs/{id}/emails?page=N&pageSize=100, or stream GET /jobs/{id}/export (CSV).
}
}Errors and retries
A 429 and a 5xx are worth retrying. A 422 CREDITS_INSUFFICIENT and a 400 for a malformed address will fail the same way on every attempt, so stop.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum Error {
#[error("zapbounce api: {message} ({code}, requestId={request_id})")]
Api { code: String, message: String, request_id: String, status: u16, window_seconds: u64 },
#[error("transport: {0}")]
Transport(#[from] reqwest::Error),
}
impl Error {
/// A 422 CREDITS_INSUFFICIENT fails identically forever. Retrying it only wastes the budget.
pub fn retryable(&self) -> bool {
match self {
Error::Api { status, .. } => *status == 429 || *status >= 500,
Error::Transport(e) => e.is_timeout() || e.is_connect(),
}
}
}
pub async fn verify_with_retry(client: &ZapBounce, email: &str) -> Result<VerifyResult, Error> {
let mut attempt = 0;
loop {
match client.verify(email).await {
Ok(result) => return Ok(result),
Err(e) if !e.retryable() || attempt >= 4 => return Err(e),
Err(e) => {
// No Retry-After header: a 429 backs off from X-RateLimit-Window-Seconds
// (copied into window_seconds when the error is built).
let floor_ms = match &e {
Error::Api { status: 429, window_seconds, .. } => (*window_seconds).max(1) * 1000,
_ => 500,
};
let base = floor_ms << attempt;
let jitter = rand::random::<u64>() % base.max(1);
tokio::time::sleep(Duration::from_millis(base + jitter)).await;
attempt += 1;
}
}
}
}
/// At signup, fail open: a timeout is not a bad address.
pub async fn verify_or_unknown(client: &ZapBounce, email: &str) -> VerifyResult {
match verify_with_retry(client, email).await {
Ok(r) => r,
Err(Error::Transport(e)) if e.is_timeout() => VerifyResult::unknown(email, "TIMEOUT"),
Err(e) => panic!("{e}"), // handle for real; CREDITS_INSUFFICIENT lands here
}
}Rust: common questions
Is there a crate?
No. The struct on this page is the client, and the derives do the rest.
Does the snake_case rename matter?
Yes. The status field returns catch_all with an underscore, and serde's default would look for CatchAll. Without that attribute the deserializer rejects the value and you get a parse error instead of a verdict.
blocking or async?
Async if your service is already on tokio. For a CLI that checks one address, reqwest::blocking keeps the code shorter and nothing is lost.
Run this Rust code today
100 free checks a month, no card, credits that never expire, and unknown results refunded.