Email Verification in Django
This tutorial shows how to add real-time email verification to a Django project. You'll build one reusable helper and wire it into both a classic Django form and a Django REST Framework (DRF) serializer, so invalid, disposable, and undeliverable addresses are rejected before a user is ever created.
What you'll build
- A
verify_emailhelper that wraps the Mailbeam SDK and fails open on API errors - A
validate_email_deliverablevalidator for Django forms - A DRF serializer field validator for API-first projects
Prerequisites
- Python 3.9+ and Django 4.2 or later
- A Mailbeam API key (sign up free)
python-dotenv(or your preferred settings loader)
Step 1 — Add an HTTP client
There is no SDK to install. Mailbeam is one HTTP endpoint, so the integration below is all the client you need. Official SDKs are on the roadmap.
pip install httpx # or use requests, if that is already in the projectStep 2 — Configure settings
Keep the key out of source control. Load it from the environment in settings.py:
# settings.py
import os
MAILBEAM_KEY = os.environ["MAILBEAM_KEY"]
# Minimum quality score to accept (0–100). 60 is a sensible default.
MAILBEAM_MIN_SCORE = int(os.environ.get("MAILBEAM_MIN_SCORE", "60"))# .env
MAILBEAM_KEY=mb_live_xxxxxxxxxxxxxxxxxxxxStep 3 — Write a reusable verification helper
Put the integration in one place so forms, serializers, and views all share the same behavior.
# accounts/verification.py
import logging
import httpx
from django.conf import settings
logger = logging.getLogger(__name__)
ENDPOINT = "https://api.mailbeam.dev/v1/verify"
def verify_email(email: str) -> tuple[bool, str | None]:
"""
Returns (is_acceptable, reason_code).
Fails OPEN (returns True) on Mailbeam API errors so an outage
never blocks legitimate signups.
"""
try:
response = httpx.post(
ENDPOINT,
json={"email": email.strip().lower()},
headers={"Authorization": f"Bearer {settings.MAILBEAM_KEY}"},
timeout=3.0,
)
response.raise_for_status()
result = response.json()
except httpx.HTTPError as exc:
logger.error("Mailbeam verification failed: %s", exc)
return True, None # fail open
if not result["valid"] or result["score"] < settings.MAILBEAM_MIN_SCORE:
return False, result["reason"] or "invalid_email"
return True, NoneStep 4 — Use it in a Django form
# accounts/forms.py
from django import forms
from django.core.exceptions import ValidationError
from .verification import verify_email
class SignupForm(forms.Form):
email = forms.EmailField()
password = forms.CharField(widget=forms.PasswordInput)
def clean_email(self):
email = self.cleaned_data["email"]
ok, reason = verify_email(email)
if not ok:
raise ValidationError(
"Please enter a valid, deliverable email address.",
code=reason,
)
return emailDjango runs clean_email automatically during form.is_valid(), so your view needs no changes:
# accounts/views.py
from django.shortcuts import render, redirect
from .forms import SignupForm
def signup(request):
if request.method == "POST":
form = SignupForm(request.POST)
if form.is_valid():
create_user(email=form.cleaned_data["email"])
return redirect("welcome")
else:
form = SignupForm()
return render(request, "accounts/signup.html", {"form": form})Step 5 — Use it in a DRF serializer
For API-first projects, validate inside the serializer instead:
# accounts/serializers.py
from rest_framework import serializers
from .verification import verify_email
class SignupSerializer(serializers.Serializer):
email = serializers.EmailField()
password = serializers.CharField(write_only=True)
def validate_email(self, value):
ok, reason = verify_email(value)
if not ok:
raise serializers.ValidationError(
f"Email rejected: {reason}"
)
return value# accounts/views.py (DRF)
from rest_framework.generics import CreateAPIView
from .serializers import SignupSerializer
class SignupView(CreateAPIView):
serializer_class = SignupSerializer
# DRF returns 400 with field errors automatically if validation failsStep 6 — Add caching
Avoid re-verifying the same address (for example, a user retrying a form). Django's cache framework works well:
# accounts/verification.py
from django.core.cache import cache
def verify_email(email: str) -> tuple[bool, str | None]:
normalized = email.strip().lower()
cached = cache.get(f"mb:{normalized}")
if cached is not None:
return cached
try:
result = _client.verify_sync(normalized)
ok = result.valid and result.score >= settings.MAILBEAM_MIN_SCORE
outcome = (ok, None if ok else (result.reason or "invalid_email"))
cache.set(f"mb:{normalized}", outcome, timeout=86_400) # 24h
return outcome
except mailbeam.APIError as exc:
logger.error("Mailbeam verification failed: %s", exc)
return True, None # fail open (don't cache failures)Testing
Mailbeam's test domains return deterministic results and don't count against your quota:
# accounts/tests.py
from django.test import TestCase
from .forms import SignupForm
class SignupFormTests(TestCase):
def test_valid_email_passes(self):
form = SignupForm(data={
"email": "user@valid.mailbeam-test.dev",
"password": "pass1234",
})
self.assertTrue(form.is_valid())
def test_disposable_email_rejected(self):
form = SignupForm(data={
"email": "temp@disposable.mailbeam-test.dev",
"password": "pass1234",
})
self.assertFalse(form.is_valid())
self.assertIn("email", form.errors)Best practices
| Practice | Why |
|---|---|
Centralize in verification.py | Forms, serializers, and views stay consistent |
Fail open on mailbeam.APIError | An outage shouldn't break signups |
Normalize (strip().lower()) | Consistent caching and fewer duplicate lookups |
| Cache for ~24h | Cuts API calls on retries and repeat submissions |
| Use test domains | Deterministic tests, no quota usage |
Production checklist
-
MAILBEAM_KEYloaded from environment, not committed - Logging configured for
mailbeam.APIError - Cache backend set (Redis or Memcached in production)
-
MAILBEAM_MIN_SCOREtuned for your funnel - User-facing error message is friendly in your templates