emailverifier.dev
Posts

How to Block Disposable Email Signups in Flask

| 4 min read | Usama Ejaz
Flask email signup cover with a compact route line and a blocked disposable envelope.

A Flask signup route is easier to trust when its behavior is fixed in tests before an HTTP client is involved. The tests below define four boundaries: malformed input is rejected locally, disposable addresses are blocked, uncertain addresses stay reviewable, and an unavailable verification service does not become an email rejection.

The finished route makes one emailverifier.dev request for each well-formed signup and keeps the project key in Flask configuration.

Write the signup tests first

Create a directory and install Flask, pytest, and Requests:

mkdir flask-email-signup
cd flask-email-signup
python -m venv .venv
source .venv/bin/activate
python -m pip install Flask==3.1.* pytest==8.* requests==2.*

On Windows, activate the environment with .venv\Scripts\activate. Flask’s current testing documentation uses pytest fixtures and recommends an application factory when tests need differently configured app instances.

Create test_app.py:

import pytest

from app import create_app


@pytest.fixture()
def client():
    reports = {
        "blocked@example.com": {
            "status": "risky",
            "action": "block",
            "signals": ["disposable_address"],
            "suggestion": None,
        },
        "unclear@example.com": {
            "status": "unknown",
            "action": "review",
            "signals": ["verification_inconclusive"],
            "suggestion": None,
        },
        "allowed@example.com": {
            "status": "deliverable",
            "action": "allow",
            "signals": ["mailbox_accepts_mail"],
            "suggestion": None,
        },
    }

    calls = []

    def fake_verify(email):
        calls.append(email)
        return reports[email]

    app = create_app({"TESTING": True}, verifier=fake_verify)
    app.extensions["verification_calls"] = calls
    return app.test_client()


def test_malformed_request_stops_before_verification(client):
    response = client.post("/signup", json={"email": "not-an-email"})

    assert response.status_code == 400
    assert client.application.extensions["verification_calls"] == []


def test_disposable_address_is_blocked_after_one_check(client):
    response = client.post(
        "/signup", json={"email": "blocked@example.com"}
    )

    assert response.status_code == 422
    assert response.json["decision"] == "block"
    assert client.application.extensions["verification_calls"] == [
        "blocked@example.com"
    ]


def test_unknown_address_is_reviewed(client):
    response = client.post(
        "/signup", json={"email": "unclear@example.com"}
    )

    assert response.status_code == 202
    assert response.json["decision"] == "review"


def test_deliverable_address_reaches_account_boundary(client):
    response = client.post(
        "/signup", json={"email": "allowed@example.com"}
    )

    assert response.status_code == 200
    assert response.json["decision"] == "allow"

The fake verifier makes the policy deterministic. It also proves that malformed input uses zero credits and a valid signup calls the verifier once.

Build the application factory

Create app.py:

import os
import re

import requests
from flask import Flask, current_app, jsonify, request


EMAIL_PATTERN = re.compile(
    r"^[^\s@]+@(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$",
    re.IGNORECASE,
)


def verify_with_api(email):
    api_key = current_app.config["EMAILVERIFIER_API_KEY"]
    response = requests.post(
        "https://emailverifier.dev/api/v1/verify",
        headers={"X-API-Key": api_key},
        json={"email": email},
        timeout=4,
    )
    response.raise_for_status()
    return response.json()


def decide(report):
    action = report.get("action")

    if action == "block":
        return 422, {
            "decision": "block",
            "message": (
                "Check the spelling of your email address."
                if report.get("suggestion")
                else "Use a different email address."
            ),
            "signals": report.get("signals", []),
            "suggestion": report.get("suggestion"),
        }

    if action == "review":
        return 202, {
            "decision": "review",
            "message": "Confirm this address before receiving full access.",
            "signals": report.get("signals", []),
        }

    if action == "allow":
        return 200, {
            "decision": "allow",
            "message": "Continue to account creation.",
            "signals": report.get("signals", []),
        }

    raise ValueError("Unexpected verification action")


def create_app(test_config=None, verifier=None):
    app = Flask(__name__)
    app.config.from_mapping(
        EMAILVERIFIER_API_KEY=os.environ.get("EMAILVERIFIER_API_KEY")
    )

    if test_config:
        app.config.update(test_config)

    verify = verifier or verify_with_api

    @app.post("/signup")
    def signup():
        payload = request.get_json(silent=True)
        email = payload.get("email", "").strip().lower() if payload else ""

        if len(email) > 254 or not EMAIL_PATTERN.fullmatch(email):
            return jsonify(
                decision="invalid_request",
                message="Send a valid email address.",
            ), 400

        try:
            report = verify(email)
            status_code, body = decide(report)
        except (requests.RequestException, ValueError, KeyError):
            current_app.logger.exception("Email verification failed")
            return jsonify(
                decision="retry",
                message="Email verification is temporarily unavailable.",
            ), 503

        if body["decision"] == "allow":
            # Create the user here. Do not persist before this point.
            pass

        return jsonify(body), status_code

    return app

verify_with_api() contains the route’s only external request. The four-second timeout covers both connection and response time in Requests. A password never enters the verification payload.

Run the tests and the server

pytest -q

All four tests should pass. Then create a project and copy its API key before starting the app:

export EMAILVERIFIER_API_KEY="your-project-key"
flask --app app:create_app run --debug

Flask automatically detects a factory named create_app, as documented in the application factory pattern.

Make one real signup request

curl http://127.0.0.1:5000/signup \
  -H "Content-Type: application/json" \
  -d '{"email":"person@example.com"}'

The application responses are:

  • 400 invalid_request for malformed local input.
  • 422 block for confirmed failures and blocking risk signals such as a disposable address.
  • 202 review for inconclusive verification evidence.
  • 200 allow when the address can continue to account creation.
  • 503 retry when the verification dependency fails.

The verification report keeps status and action separate. Possible statuses are deliverable, risky, undeliverable, and unknown. Possible actions are allow, review, and block.

A disposable signal can justify a block in a high-cost signup. An unknown mailbox response cannot. The disposable-address policy guide shows how to adjust that decision to the value and abuse risk of your flow.

Continue reading