Testing SendGrid Email Deliverability: Test Mode, Webhooks, and Sandbox

Testing SendGrid Email Deliverability: Test Mode, Webhooks, and Sandbox

SendGrid provides a sandbox mode that validates emails without sending them, dynamic templates you can test independently, and event webhooks for tracking delivery status. This guide covers testing SendGrid integrations at every level: sandbox validation, template rendering, webhook handling, and deliverability simulation.

Email bugs are subtle and slow to surface. A misconfigured template sends for weeks before someone notices the variable substitution is wrong. A missing unsubscribe handler violates CAN-SPAM. A webhook processor that crashes silently means you never know about bounces.

Testing SendGrid integrations prevents all of these.

SendGrid Sandbox Mode

SendGrid's sandbox mode is the safest way to test email sending. Enable it with a request header — emails are validated and accepted by the API but never actually sent:

import sendgrid
from sendgrid.helpers.mail import Mail, To, From, Subject, Content, MailSettings, SandBoxMode

def send_welcome_email(to_email: str, name: str, sandbox: bool = False) -> dict:
    sg = sendgrid.SendGridAPIClient(api_key=os.environ["SENDGRID_API_KEY"])
    
    message = Mail(
        from_email=From("noreply@yourapp.com", "Your App"),
        to_emails=To(to_email),
        subject=Subject(f"Welcome to Your App, {name}!"),
        html_content=Content("text/html", f"<h1>Hello, {name}!</h1><p>Welcome aboard.</p>")
    )
    
    if sandbox:
        # Enable sandbox mode — validates but doesn't send
        message.mail_settings = MailSettings()
        message.mail_settings.sandbox_mode = SandBoxMode(enable=True)
    
    response = sg.client.mail.send.post(request_body=message.get())
    return {
        "status_code": response.status_code,
        "body": response.body,
        "headers": dict(response.headers)
    }

Test with sandbox mode enabled:

def test_welcome_email_validates_in_sandbox():
    """Verify welcome email passes SendGrid validation."""
    result = send_welcome_email(
        to_email="test@example.com",
        name="Test User",
        sandbox=True
    )
    
    # Sandbox mode returns 200 (vs 202 for real send)
    assert result["status_code"] == 200, \
        f"Email validation failed with status {result['status_code']}"

def test_welcome_email_accepts_various_name_formats():
    """Names with special characters must not break email generation."""
    test_names = [
        "Alice Johnson",
        "José García",
        "李明",
        "O'Brien",
        'Name with "quotes"',
        "Name<with>brackets"
    ]
    
    for name in test_names:
        result = send_welcome_email(
            to_email="test@example.com",
            name=name,
            sandbox=True
        )
        assert result["status_code"] == 200, \
            f"Email validation failed for name: {name!r}"

Testing Dynamic Templates

SendGrid dynamic templates use Handlebars syntax for variable substitution. Test that templates render correctly:

def test_order_confirmation_template():
    """Order confirmation template must render with correct variable substitution."""
    from app.email.templates import render_order_confirmation
    
    template_data = {
        "customer_name": "Alice Johnson",
        "order_id": "ORD-2026-001",
        "items": [
            {"name": "Widget Pro", "quantity": 2, "price": "$49.99"},
            {"name": "Gadget Plus", "quantity": 1, "price": "$99.99"}
        ],
        "subtotal": "$199.97",
        "tax": "$16.00",
        "total": "$215.97",
        "shipping_address": {
            "street": "123 Main St",
            "city": "San Francisco",
            "state": "CA",
            "zip": "94105"
        }
    }
    
    sg = sendgrid.SendGridAPIClient(api_key=os.environ["SENDGRID_API_KEY"])
    
    message = Mail()
    message.to = To("test@example.com")
    message.from_email = From("orders@yourapp.com", "Your App Orders")
    message.template_id = os.environ["SENDGRID_ORDER_TEMPLATE_ID"]
    message.dynamic_template_data = template_data
    message.mail_settings = MailSettings()
    message.mail_settings.sandbox_mode = SandBoxMode(enable=True)
    
    response = sg.client.mail.send.post(request_body=message.get())
    assert response.status_code == 200

def test_template_missing_required_variable():
    """Template must handle missing optional variables gracefully."""
    template_data = {
        "customer_name": "Test User",
        "order_id": "ORD-001"
        # Missing items, totals, shipping — all should have defaults or be empty
    }
    
    message = Mail()
    message.to = To("test@example.com")
    message.from_email = From("orders@yourapp.com")
    message.template_id = os.environ["SENDGRID_ORDER_TEMPLATE_ID"]
    message.dynamic_template_data = template_data
    message.mail_settings = MailSettings()
    message.mail_settings.sandbox_mode = SandBoxMode(enable=True)
    
    sg = sendgrid.SendGridAPIClient(api_key=os.environ["SENDGRID_API_KEY"])
    response = sg.client.mail.send.post(request_body=message.get())
    
    # Should not fail — missing variables should render as empty strings or defaults
    assert response.status_code == 200

Unit Testing Email Generation

For business logic tests that don't need the SendGrid API, test the email generation in isolation:

from unittest.mock import patch, MagicMock
from app.email.service import EmailService

class TestEmailService:
    def test_sends_to_correct_recipient(self):
        with patch("sendgrid.SendGridAPIClient") as mock_sg:
            mock_response = MagicMock()
            mock_response.status_code = 202
            mock_sg.return_value.client.mail.send.post.return_value = mock_response
            
            service = EmailService()
            service.send_welcome("newuser@example.com", name="Bob")
        
        call_args = mock_sg.return_value.client.mail.send.post.call_args
        request_body = call_args.kwargs["request_body"]
        
        personalizations = request_body["personalizations"]
        assert any(
            p.get("to", [{}])[0].get("email") == "newuser@example.com"
            for p in personalizations
        )
    
    def test_includes_unsubscribe_header(self):
        """CAN-SPAM compliance: all marketing emails must include unsubscribe."""
        with patch("sendgrid.SendGridAPIClient") as mock_sg:
            mock_response = MagicMock()
            mock_response.status_code = 202
            mock_sg.return_value.client.mail.send.post.return_value = mock_response
            
            service = EmailService()
            service.send_marketing_newsletter("user@example.com", content="Newsletter content")
        
        request_body = mock_sg.return_value.client.mail.send.post.call_args.kwargs["request_body"]
        
        # Must include unsubscribe tracking settings
        assert "tracking_settings" in request_body
        tracking = request_body["tracking_settings"]
        assert tracking.get("subscription_tracking", {}).get("enable") is True, \
            "Marketing emails must enable subscription tracking (unsubscribe link)"
    
    def test_transactional_emails_not_treated_as_marketing(self):
        """Order confirmations and password resets must bypass unsubscribes."""
        with patch("sendgrid.SendGridAPIClient") as mock_sg:
            mock_response = MagicMock()
            mock_response.status_code = 202
            mock_sg.return_value.client.mail.send.post.return_value = mock_response
            
            service = EmailService()
            service.send_password_reset("user@example.com", reset_url="https://...")
        
        request_body = mock_sg.return_value.client.mail.send.post.call_args.kwargs["request_body"]
        
        # Transactional emails should use a different IP pool or bypass lists
        # Depending on your SendGrid setup, check for categories or IP pool
        categories = request_body.get("categories", [])
        assert "transactional" in categories or "password-reset" in categories

Testing Webhook Event Processing

SendGrid sends webhook events for email lifecycle: delivered, opened, clicked, bounced, unsubscribed. Test your handlers:

import json
import hmac
import hashlib
from fastapi.testclient import TestClient
from app.main import app

test_client = TestClient(app)

def create_sendgrid_webhook_events(events: list[dict]) -> tuple[str, str]:
    """Create signed SendGrid webhook payload."""
    payload = json.dumps(events).encode("utf-8")
    webhook_key = os.environ["SENDGRID_WEBHOOK_VERIFICATION_KEY"]
    
    timestamp = str(int(time.time()))
    nonce = "test-nonce-12345"
    
    to_sign = timestamp + nonce + payload.decode("utf-8")
    signature = hmac.new(
        webhook_key.encode("utf-8"),
        to_sign.encode("utf-8"),
        hashlib.sha256
    ).hexdigest()
    
    headers = {
        "X-Twilio-Email-Event-Webhook-Timestamp": timestamp,
        "X-Twilio-Email-Event-Webhook-Nonce": nonce,
        "X-Twilio-Email-Event-Webhook-Signature": signature
    }
    
    return payload, headers

def test_bounce_webhook_disables_email():
    """Hard bounce webhook must mark email address as undeliverable."""
    events = [{
        "event": "bounce",
        "email": "bounced@example.com",
        "timestamp": int(time.time()),
        "sg_event_id": "evt_bounce_test",
        "type": "bounce",
        "reason": "550 User unknown",
        "status": "5.1.1"
    }]
    
    payload, headers = create_sendgrid_webhook_events(events)
    response = test_client.post("/webhooks/sendgrid", content=payload, headers=headers)
    
    assert response.status_code == 200
    
    from app.database import get_email_status
    status = get_email_status("bounced@example.com")
    assert status.deliverable is False
    assert status.bounce_reason is not None

def test_unsubscribe_webhook_removes_from_list():
    """Unsubscribe webhook must remove user from marketing list."""
    events = [{
        "event": "unsubscribe",
        "email": "unsubscribed@example.com",
        "timestamp": int(time.time()),
        "sg_event_id": "evt_unsub_test"
    }]
    
    payload, headers = create_sendgrid_webhook_events(events)
    response = test_client.post("/webhooks/sendgrid", content=payload, headers=headers)
    
    assert response.status_code == 200
    
    from app.database import is_subscribed
    assert not is_subscribed("unsubscribed@example.com"), \
        "Unsubscribed email must be removed from marketing list"

def test_delivery_webhook_updates_analytics():
    """Delivered webhook must update email analytics."""
    email = "analytics@example.com"
    campaign_id = "campaign_2026_05"
    
    events = [{
        "event": "delivered",
        "email": email,
        "timestamp": int(time.time()),
        "sg_event_id": "evt_delivered_test",
        "sg_message_id": f"msg_{campaign_id}_001",
        "category": [campaign_id]
    }]
    
    payload, headers = create_sendgrid_webhook_events(events)
    response = test_client.post("/webhooks/sendgrid", content=payload, headers=headers)
    
    assert response.status_code == 200
    
    from app.analytics import get_campaign_stats
    stats = get_campaign_stats(campaign_id)
    assert stats.delivered_count >= 1

def test_spam_report_webhook_suppresses_email():
    """Spam report must add email to SendGrid suppression list and local DB."""
    events = [{
        "event": "spamreport",
        "email": "spammer@example.com",
        "timestamp": int(time.time()),
        "sg_event_id": "evt_spam_test"
    }]
    
    payload, headers = create_sendgrid_webhook_events(events)
    response = test_client.post("/webhooks/sendgrid", content=payload, headers=headers)
    
    assert response.status_code == 200
    
    # Must not send any more emails to this address
    from app.database import is_suppressed
    assert is_suppressed("spammer@example.com")

Testing Suppression Lists

Before sending marketing emails, your code should check SendGrid's suppression lists. Test this:

def test_app_checks_suppression_before_sending():
    """App must check suppression list before sending marketing emails."""
    with patch("sendgrid.SendGridAPIClient") as mock_sg:
        # Mock suppression list check — user is suppressed
        mock_sg.return_value.client.asm.suppressions.global_.post.return_value = None
        mock_sg.return_value.client.asm.suppressions.global_.get.return_value.body = \
            json.dumps({"suppressions": [{"email": "suppressed@example.com", "suppressed": True}]}).encode()
        
        service = EmailService()
        result = service.send_marketing_newsletter("suppressed@example.com", content="...")
    
    assert result["skipped"] is True
    assert "suppressed" in result["reason"].lower()
    
    # Verify no actual send was attempted
    mock_sg.return_value.client.mail.send.post.assert_not_called()

CI/CD Integration

# .github/workflows/sendgrid-tests.yml
name: SendGrid Integration Tests
on:
  push:
    paths:
      - 'app/email/**'
      - 'app/webhooks/sendgrid.py'

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install sendgrid pytest fastapi httpx
      - run: pytest tests/unit/email/ -v

  sandbox-tests:
    runs-on: ubuntu-latest
    needs: unit-tests
    steps:
      - uses: actions/checkout@v4
      - run: pip install sendgrid pytest
      - run: pytest tests/integration/sendgrid/ -v -m sandbox
        env:
          SENDGRID_API_KEY: ${{ secrets.SENDGRID_API_KEY }}
          SENDGRID_ORDER_TEMPLATE_ID: ${{ secrets.SENDGRID_ORDER_TEMPLATE_ID }}
          SENDGRID_WEBHOOK_VERIFICATION_KEY: ${{ secrets.SENDGRID_WEBHOOK_VERIFICATION_KEY }}

Conclusion

Testing SendGrid integrations requires validating at multiple levels: sandbox mode for API validation without sending, dynamic template testing for variable substitution, webhook handler tests for bounce/unsubscribe/spam events, and unit tests for business logic. The most critical tests are the compliance ones — unsubscribe handling, spam report suppression, and transactional vs marketing email separation. Get these wrong and you face legal liability, deliverability damage, and angry users. Get them right with comprehensive tests and you can ship email features with confidence.

Read more