CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/pypi-django-sendgrid-v5

Django email backend implementation compatible with SendGrid API v5+ for seamless email delivery integration

Pending
Overview
Eval results
Files

webhooks.mddocs/

Webhooks

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.

Capabilities

Webhook Signature Verification

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
    """

Webhook Configuration

Django settings and setup required for webhook signature verification.

# Required Django setting
SENDGRID_WEBHOOK_VERIFICATION_KEY = str  # Verification key from SendGrid console

Webhook Setup Example

  1. Enable webhook signature verification in SendGrid:
# In SendGrid console:
# 1. Go to Settings > Mail Settings > Event Webhook
# 2. Enable "Signed Event Webhook"  
# 3. Copy the verification key
  1. Configure Django settings:
# 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")
  1. Create webhook view:
# 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 events

Event Processing Examples

Comprehensive 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
        }
    )

Async Webhook Support

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 handlers

Webhook Security Best Practices

Additional 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 ip

Webhook Testing

Utilities 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

docs

configuration.md

email-backend.md

index.md

signals.md

templates-personalization.md

webhooks.md

tile.json