How to Block Disposable Email Signups in Flask
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_requestfor malformed local input.422 blockfor confirmed failures and blocking risk signals such as a disposable address.202 reviewfor inconclusive verification evidence.200 allowwhen the address can continue to account creation.503 retrywhen 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.