Skip to content
Finowl

Rust · Email breach checks

Check email breaches in Rust with reqwest and serde

Breach records arrive with PascalCase keys like Title, BreachDate and DataClasses. In Rust you keep idiomatic snake_case field names and add #[serde(rename_all = "PascalCase")] to the Breach struct, and serde maps breach_date to BreachDate and data_classes to DataClasses for you. Declare only the fields you need; serde ignores unknown keys by default, so the struct stays small and focused on what your code reads.

The main function retries up to three times inside a loop and matches on res.status(). A 429 reads the Retry-After header before consuming the body, then inspects error.code so a quota_exceeded response ends the program instead of sleeping. Both 400 and 401 return descriptive errors through Box<dyn Error>, and any other status stops without retrying, since a 5xx lookup may already have been charged.

Steps

  1. 1

    Create a binary with cargo new breach-check and add reqwest, serde, serde_json and tokio.

  2. 2

    Replace src/main.rs with the example and export FINOWL_API_KEY.

  3. 3

    Run cargo run -- someone@example.com to check an address.

  4. 4

    Use is_verified and data_classes on each Breach to decide what to tell the user.

  5. 5

    Move the request into a shared client stored in your web framework's application state.

Install
cargo add reqwest --features json
cargo add serde --features derive
cargo add serde_json thiserror
cargo add tokio --features full
Check email breaches in Rust with reqwest and serde
use reqwest::{header::RETRY_AFTER, Client, StatusCode};
use serde::Deserialize;
use serde_json::{json, Value};
use std::{env, error::Error, time::Duration};

#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct Breach {
    title: String,
    breach_date: String,
    data_classes: Vec<String>,
    is_verified: bool,
}

#[derive(Debug, Deserialize)]
struct EmailBreaches {
    email: String,
    breached: bool,
    breaches: Vec<Breach>,
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    let api_key = env::var("FINOWL_API_KEY")?;
    let email = env::args().nth(1).unwrap_or_else(|| "alex@example.com".to_string());
    let client = Client::builder().timeout(Duration::from_secs(15)).build()?;

    for _ in 0..3 {
        let res = client
            .post("https://api.finowl.co/v1/breaches/email")
            .bearer_auth(&api_key)
            .json(&json!({ "email": email }))
            .send()
            .await?;

        match res.status() {
            StatusCode::OK => {
                let body: EmailBreaches = res.json().await?;
                if !body.breached {
                    println!("No known breaches for {}", body.email);
                }
                for b in &body.breaches {
                    let flag = if b.is_verified { "" } else { " (unverified)" };
                    println!("{} ({}){}: {}", b.title, b.breach_date, flag, b.data_classes.join(", "));
                }
                return Ok(());
            }
            StatusCode::BAD_REQUEST => return Err("Finowl rejected the email as invalid_input".into()),
            StatusCode::UNAUTHORIZED => return Err("FINOWL_API_KEY is missing or invalid".into()),
            StatusCode::TOO_MANY_REQUESTS => {
                let secs = res.headers().get(RETRY_AFTER)
                    .and_then(|v| v.to_str().ok()?.parse::<u64>().ok())
                    .unwrap_or(5);
                let body: Value = res.json().await.unwrap_or_default();
                if body["error"]["code"] == "quota_exceeded" {
                    return Err("out of Finowl credits".into());
                }
                tokio::time::sleep(Duration::from_secs(secs)).await;
            }
            other => return Err(format!("Finowl returned {other}; not retrying").into()),
        }
    }
    Err("still rate limited after 3 attempts".into())
}

IN PRODUCTION

Production tips

  • Add a field to Breach only when you read it. A small struct documents exactly which parts of the response your code relies on and keeps allocations down for addresses with many breaches.

  • Parse breach_date with chrono or the time crate if you sort or filter by date. Finowl sends it as a plain YYYY-MM-DD string, which NaiveDate or time::Date represents exactly.

  • When checking many addresses, bound concurrency with a tokio Semaphore or buffer_unordered so you stay under 60 requests per minute instead of relying on 429 responses to slow you down.

Each lookup costs 1 credit, with 100 free credits every month. See pricing and error codes.

A FEW GOOD QUESTIONS

Common questions

Check email breaches in Rust with reqwest and serde

Grab an API key, paste the example and run it. 100 free credits every month.