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 — Install the SDK
pip install mailbeam
# Or add to requirements.txt:
echo "mailbeam>=1.0" >> requirements.txt
pip install -r requirements.txtStep 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 mailbeam
from django.conf import settings
logger = logging.getLogger(__name__)
_client = mailbeam.Client(api_key=settings.MAILBEAM_KEY)
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:
result = _client.verify_sync(email.strip().lower())
if not result.valid or result.score < settings.MAILBEAM_MIN_SCORE:
return False, result.reason or "invalid_email"
return True, None
except mailbeam.APIError as exc:
logger.error("Mailbeam verification failed: %s", exc)
return True, None # fail openStep 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