Django email backend implementation compatible with SendGrid API v5+ for seamless email delivery integration
—
Secure webhook integration for processing SendGrid delivery events, bounce notifications, and email engagement tracking. Includes signature verification to ensure webhook authenticity and prevent unauthorized access.
Cryptographic verification of SendGrid webhook signatures to ensure request authenticity and security.
@verify_sendgrid_webhook_signature
def webhook_view(request):
"""
Decorator for Django views to verify SendGrid webhook signatures.
Requires:
- SendGrid v6+
- SENDGRID_WEBHOOK_VERIFICATION_KEY in Django settings
Returns HttpResponseNotFound() if signature verification fails.
Works with both sync and async view functions.
"""
def check_sendgrid_signature(request) -> bool:
"""
Verify SendGrid webhook signature against request.
Parameters:
- request (HttpRequest): Django request object containing headers and body
Returns:
- bool: True if signature is valid, False otherwise
Requires headers:
- X-Twilio-Email-Event-Webhook-Signature: Signature to verify
- X-Twilio-Email-Event-Webhook-Timestamp: Timestamp for signature
"""Django settings and setup required for webhook signature verification.
# Required Django setting
SENDGRID_WEBHOOK_VERIFICATION_KEY = str # Verification key from SendGrid console# In SendGrid console:
# 1. Go to Settings > Mail Settings > Event Webhook
# 2. Enable "Signed Event Webhook"
# 3. Copy the verification key# settings.py
SENDGRID_WEBHOOK_VERIFICATION_KEY = os.environ.get("SENDGRID_WEBHOOK_KEY")
# Ensure key is present
if not SENDGRID_WEBHOOK_VERIFICATION_KEY:
raise ImproperlyConfigured("SENDGRID_WEBHOOK_VERIFICATION_KEY is required for webhook security")# views.py
import json
from django.http import HttpResponse, HttpRequest
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from sendgrid_backend.decorators import verify_sendgrid_webhook_signature
@csrf_exempt
@require_POST
@verify_sendgrid_webhook_signature
def sendgrid_webhook_handler(request: HttpRequest) -> HttpResponse:
"""Handle SendGrid webhook events with signature verification."""
try:
events = json.loads(request.body)
for event in events:
process_sendgrid_event(event)
return HttpResponse("OK")
except json.JSONDecodeError:
return HttpResponse("Invalid JSON", status=400)
except Exception as e:
# Log error but return success to avoid retries
logger.error(f"Webhook processing error: {e}")
return HttpResponse("Error processed")
def process_sendgrid_event(event):
"""Process individual SendGrid event."""
event_type = event.get('event')
email = event.get('email')
timestamp = event.get('timestamp')
# Handle different event types
if event_type == 'delivered':
handle_delivered_event(event)
elif event_type == 'bounce':
handle_bounce_event(event)
elif event_type == 'open':
handle_open_event(event)
# ... handle other eventsComprehensive webhook handler for different SendGrid events:
import json
from datetime import datetime
from django.db import transaction
from django.http import HttpRequest, HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from pytz import utc
from sendgrid_backend.decorators import verify_sendgrid_webhook_signature
# Event type mapping
EVENT_TYPES = {
'processed': 'processed',
'delivered': 'delivered',
'bounce': 'bounced',
'dropped': 'dropped',
'deferred': 'deferred',
'open': 'opened',
'click': 'clicked',
'unsubscribe': 'unsubscribed',
'group_unsubscribe': 'group_unsubscribed',
'group_resubscribe': 'group_resubscribed',
'spamreport': 'spam_reported'
}
@csrf_exempt
@require_POST
@verify_sendgrid_webhook_signature
def comprehensive_webhook_handler(request: HttpRequest) -> HttpResponse:
"""
Comprehensive webhook handler for all SendGrid events.
Processes delivery status, engagement tracking, and list management events
with proper error handling and logging.
"""
try:
events = json.loads(request.body)
# Process events in reverse order (newest first)
for event_data in reversed(events):
with transaction.atomic():
process_webhook_event(event_data)
return HttpResponse("OK")
except json.JSONDecodeError:
logger.error("Invalid JSON in webhook payload")
return HttpResponse("Invalid JSON", status=400)
except Exception as e:
logger.error(f"Webhook processing error: {e}")
# Return success to prevent SendGrid retries for processing errors
return HttpResponse("Processing error logged")
def process_webhook_event(event_data):
"""Process individual webhook event with comprehensive handling."""
# Extract common event fields
event_type = event_data.get('event')
email_address = event_data.get('email')
timestamp = datetime.fromtimestamp(event_data.get('timestamp', 0), tz=utc)
message_id = event_data.get('sg_message_id') or event_data.get('smtp-id')
# Custom arguments for tracking
custom_args = event_data.get('asm', {}) # Unsubscribe group info
categories = event_data.get('category', [])
# Log event for debugging
logger.info(f"Processing {event_type} event for {email_address}")
# Handle specific event types
if event_type == 'delivered':
handle_delivery_event(event_data, timestamp)
elif event_type == 'bounce':
handle_bounce_event(event_data, timestamp)
elif event_type == 'open':
handle_open_event(event_data, timestamp)
elif event_type == 'click':
handle_click_event(event_data, timestamp)
elif event_type in ['unsubscribe', 'group_unsubscribe']:
handle_unsubscribe_event(event_data, timestamp)
elif event_type == 'spamreport':
handle_spam_report(event_data, timestamp)
# Update email status in your system
update_email_status(message_id, event_type, timestamp, event_data)
def handle_delivery_event(event_data, timestamp):
"""Handle successful email delivery."""
message_id = event_data.get('sg_message_id')
response_code = event_data.get('response')
# Update delivery status
EmailLog.objects.filter(message_id=message_id).update(
status='delivered',
delivered_at=timestamp,
smtp_response=response_code
)
def handle_bounce_event(event_data, timestamp):
"""Handle email bounces with categorization."""
bounce_type = event_data.get('type') # 'bounce' or 'blocked'
reason = event_data.get('reason', '')
email = event_data.get('email')
# Determine if it's a hard or soft bounce
is_hard_bounce = bounce_type == 'bounce' and any(
keyword in reason.lower()
for keyword in ['invalid', 'not exist', 'unknown', 'rejected']
)
# Update bounce status and potentially suppress future emails
if is_hard_bounce:
suppress_email_address(email, reason='hard_bounce')
# Log bounce details
BounceLog.objects.create(
email=email,
bounce_type=bounce_type,
reason=reason,
timestamp=timestamp,
is_hard_bounce=is_hard_bounce
)
def handle_open_event(event_data, timestamp):
"""Handle email open tracking."""
message_id = event_data.get('sg_message_id')
user_agent = event_data.get('useragent', '')
ip_address = event_data.get('ip', '')
# Record email open
EmailOpen.objects.create(
message_id=message_id,
opened_at=timestamp,
user_agent=user_agent,
ip_address=ip_address
)
def handle_click_event(event_data, timestamp):
"""Handle link click tracking."""
message_id = event_data.get('sg_message_id')
url = event_data.get('url', '')
user_agent = event_data.get('useragent', '')
# Record link click
EmailClick.objects.create(
message_id=message_id,
clicked_url=url,
clicked_at=timestamp,
user_agent=user_agent
)
def handle_unsubscribe_event(event_data, timestamp):
"""Handle unsubscribe events."""
email = event_data.get('email')
asm_group_id = event_data.get('asm', {}).get('group_id')
# Add to unsubscribe list
UnsubscribeList.objects.update_or_create(
email=email,
defaults={
'unsubscribed_at': timestamp,
'asm_group_id': asm_group_id,
'is_active': True
}
)Support for async Django views with proper signature verification:
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from sendgrid_backend.decorators import verify_sendgrid_webhook_signature
import asyncio
@csrf_exempt
@require_POST
@verify_sendgrid_webhook_signature
async def async_webhook_handler(request: HttpRequest) -> HttpResponse:
"""Async webhook handler for high-volume event processing."""
try:
events = json.loads(request.body)
# Process events concurrently
tasks = [process_event_async(event) for event in events]
await asyncio.gather(*tasks)
return HttpResponse("OK")
except Exception as e:
logger.error(f"Async webhook error: {e}")
return HttpResponse("Error processed")
async def process_event_async(event_data):
"""Async event processing for better performance."""
event_type = event_data.get('event')
# Use async database operations if available
if event_type == 'delivered':
await update_delivery_status_async(event_data)
elif event_type == 'bounce':
await handle_bounce_async(event_data)
# ... other async handlersAdditional security measures for webhook endpoints:
from django.conf import settings
from django.http import HttpResponseForbidden
from functools import wraps
def additional_webhook_security(view_func):
"""Additional security layer for webhooks."""
@wraps(view_func)
def wrapper(request, *args, **kwargs):
# Check request origin (optional)
allowed_ips = getattr(settings, 'SENDGRID_WEBHOOK_IPS', [])
if allowed_ips:
client_ip = get_client_ip(request)
if client_ip not in allowed_ips:
return HttpResponseForbidden("IP not allowed")
# Check User-Agent
user_agent = request.META.get('HTTP_USER_AGENT', '')
if not user_agent.startswith('SendGrid'):
return HttpResponseForbidden("Invalid User-Agent")
# Rate limiting (implement as needed)
if is_rate_limited(request):
return HttpResponseForbidden("Rate limited")
return view_func(request, *args, **kwargs)
return wrapper
@csrf_exempt
@require_POST
@additional_webhook_security
@verify_sendgrid_webhook_signature
def secure_webhook_handler(request):
"""Webhook handler with additional security measures."""
# Process webhook events
pass
def get_client_ip(request):
"""Extract client IP from request headers."""
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get('REMOTE_ADDR')
return ipUtilities for testing webhook functionality:
import json
import hmac
import hashlib
import base64
from django.test import TestCase, RequestFactory
from django.conf import settings
class WebhookTestCase(TestCase):
"""Test case for SendGrid webhook handling."""
def setUp(self):
self.factory = RequestFactory()
self.verification_key = "test-verification-key"
def create_signed_request(self, payload, timestamp=None):
"""Create a properly signed webhook request for testing."""
if timestamp is None:
timestamp = str(int(time.time()))
payload_json = json.dumps(payload)
# Create signature (simplified for testing)
signature_payload = timestamp + payload_json
signature = base64.b64encode(
hmac.new(
self.verification_key.encode(),
signature_payload.encode(),
hashlib.sha256
).digest()
).decode()
request = self.factory.post(
'/webhook/sendgrid/',
data=payload_json,
content_type='application/json',
HTTP_X_TWILIO_EMAIL_EVENT_WEBHOOK_SIGNATURE=signature,
HTTP_X_TWILIO_EMAIL_EVENT_WEBHOOK_TIMESTAMP=timestamp
)
return request
def test_webhook_signature_verification(self):
"""Test webhook signature verification."""
payload = [{"event": "delivered", "email": "test@example.com"}]
request = self.create_signed_request(payload)
# Test signature verification
is_valid = check_sendgrid_signature(request)
self.assertTrue(is_valid)
def test_webhook_event_processing(self):
"""Test webhook event processing."""
payload = [{
"event": "delivered",
"email": "test@example.com",
"timestamp": 1234567890,
"sg_message_id": "test-message-id"
}]
request = self.create_signed_request(payload)
response = sendgrid_webhook_handler(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content.decode(), "OK")Install with Tessl CLI
npx tessl i tessl/pypi-django-sendgrid-v5