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

toml
[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 results: valid, invalid, catch_all and unknown. Role and disposable are boolean flags beside the result, so test them separately. Unknown is the branch most code forgets, and it is never billed.

rust
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)]
pub struct VerifyResult {
    pub email: String,
    pub result: Verdict,
    pub reason: String,          // keep it a String: the list of reasons can grow
    pub smtp_code: Option<String>,
    pub role: bool,              // flags sit beside the result, never inside it
    pub disposable: bool,
    pub billed: bool,
}

#[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://api.zapbounce.com/v1/verify")
            .bearer_auth(&self.key)
            .json(&serde_json::json!({ "email": email }))
            .send()
            .await?;

        if !response.status().is_success() {
            return Err(Error::from_response(response).await);
        }
        Ok(response.json().await?)
    }
}

// Exhaustive. Add a variant to Verdict and every match in the codebase fails to build.
match result.result {
    _ if result.disposable => suppression.add(&result.email),
    Verdict::Valid if result.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 batch rather than looping the single endpoint. We pace probes per receiving mail host, which protects the sending reputation your results depend on.

rust
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 batch and let the server pace probes per mail host.
impl ZapBounce {
    pub async fn submit_batch(&self, name: &str, emails: &[String]) -> Result<String, Error> {
        #[derive(Deserialize)]
        struct Created { batch_id: String }

        let created: Created = self
            .http
            .post("https://api.zapbounce.com/v1/batches")
            .bearer_auth(&self.key)
            .header("Idempotency-Key", uuid::Uuid::new_v4().to_string())
            .json(&serde_json::json!({ "name": name, "emails": emails }))
            .send()
            .await?
            .error_for_status()?
            .json()
            .await?;

        Ok(created.batch_id)
    }
}

Errors and retries

A 429 and a 5xx are worth retrying. A 402 for credits and a 400 for a malformed address will fail the same way on every attempt, so stop.

rust
use thiserror::Error;

#[derive(Debug, Error)]
pub enum Error {
    #[error("zapbounce api: {message} ({code}, request_id={request_id})")]
    Api { code: String, message: String, request_id: String, status: u16 },

    #[error("transport: {0}")]
    Transport(#[from] reqwest::Error),
}

impl Error {
    /// A 402 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(_) => {
                let base = 500u64 << attempt;
                let jitter = rand::random::<u64>() % base.max(1);
                tokio::time::sleep(Duration::from_millis(base + jitter)).await;
                attempt += 1;
            }
        }
    }
}

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 API 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 that cost nothing.