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 == 200Unit 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 categoriesTesting 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.