Parse Inbound Email in Django with SendGrid Inbound Parse

Blog / Django · April 28, 2018 · Updated June 10, 2026 · 12 min read
Parse Inbound Email in Django with SendGrid Inbound Parse

To receive and parse inbound email in a Django app with SendGrid's Inbound Parse Webhook, you do three things: point an MX record for your receiving domain (or subdomain) at mx.sendgrid.net, register that hostname plus a public POST URL in SendGrid's Inbound Parse settings, and write a Django view that reads the multipart/form-data SendGrid posts for every incoming message. SendGrid handles the SMTP, MIME decoding and attachment extraction for you, then hands your endpoint clean form fields (from, to, subject, text, html, envelope, attachments and SPF/DKIM results).

You can parse that POST by hand, or — the path we recommend in 2026 — let django-anymail normalize it into a tidy AnymailInboundMessage and fire a Django inbound signal. This guide shows both, plus how to secure the endpoint and offload the heavy work. It is the inbound counterpart to sending email with SendGrid.

Key takeaways

  • Inbound Parse = MX + webhook. Point an MX record for your receiving (sub)domain at mx.sendgrid.net, then add that hostname and your Django POST URL in SendGrid → Settings → Inbound Parse.
  • SendGrid POSTs multipart/form-data. Text fields (from, to, subject, text, html, envelope, SPF, dkim) arrive in request.POST; files arrive as attachment1attachmentN in request.FILES.
  • The endpoint is an external POST, so the view needs @csrf_exempt.
  • Prefer django-anymail's inbound webhook over hand-parsing: you get a normalized message API, attachment.as_uploaded_file(), multi-ESP portability, and a built-in WEBHOOK_SECRET shared-secret check.
  • Secure it: HTTPS only, a secret in the Destination URL, verify SPF/DKIM to fight spoofing, cap attachment size, and return HTTP 200 fast — push heavy work to Celery.
  • Common uses: support ticketing, reply-by-email threads, and turning emailed attachments (PDFs, CSVs) into records.

How does SendGrid Inbound Parse work?

Inbound Parse turns SendGrid into the mail server for an address you control. The flow has three actors:

  1. DNS / MX record — a mail (MX) record for your receiving domain or subdomain tells the internet to deliver mail for that domain to SendGrid (mx.sendgrid.net).
  2. SendGrid — accepts the SMTP delivery, parses the raw MIME message (headers, bodies, attachments, character sets), and runs SPF/DKIM checks.
  3. Your Django webhook — SendGrid issues an HTTP POST to the URL you configured, with the parsed message as multipart/form-data (or, optionally, the raw full MIME message).

So any address at that domain — support@reports.example.com, reply+42@inbox.example.com — becomes a trigger that calls your Django view. There is no inbox to poll and no IMAP to babysit; SendGrid pushes each email to you as it arrives.

How do I configure the MX record and Inbound Parse hostname?

Use a dedicated subdomain for receiving (e.g. inbound.example.com or reports.example.com) so inbound routing never collides with your outbound/marketing DNS.

  1. Add the MX record at your DNS provider for the receiving subdomain, pointing to mx.sendgrid.net with a priority of 10. Allow time for propagation, then confirm it resolves (e.g. with dig MX inbound.example.com or an MX-lookup tool).
  2. Authenticate the domain in SendGrid (Sender Authentication) so SPF/DKIM line up.
  3. In SendGrid, go to Settings → Inbound Parse → Add Host & URL. Enter the receiving subdomain and the Destination URL — your public HTTPS webhook endpoint.
  4. Tick "POST the raw, full MIME message" if you want the complete RFC 822 source (recommended when you use django-anymail or need exact headers).
  5. Tick "Check incoming emails for spam" if you want SendGrid's spam_score / spam_report fields included.

A common gotcha: the Destination URL must include a trailing slash if your Django route expects one — SendGrid does not follow Django's APPEND_SLASH redirect.

# DNS (zone file style) — deliver mail for the subdomain to SendGrid
inbound.example.com.   IN   MX   10   mx.sendgrid.net.

# Verify it resolves before touching SendGrid:
#   dig +short MX inbound.example.com
#   -> 10 mx.sendgrid.net.

# SendGrid -> Settings -> Inbound Parse -> Add Host & URL
#   Receiving Domain : inbound.example.com
#   Destination URL  : https://app.example.com/webhooks/sendgrid/inbound/
#   [x] POST the raw, full MIME message
#   [x] Check incoming emails for spam

# Any address at the subdomain now hits your webhook, e.g.:
#   support@inbound.example.com
#   reply+<ticket_id>@inbound.example.com   (sub-addressing for reply-by-email)

What fields does SendGrid POST to my webhook?

Each inbound email arrives as one multipart/form-data POST. Text fields land in request.POST; uploaded files land in request.FILES. The most useful fields:

Form field What it contains
from, to, cc Header addresses (display name + email)
subject The subject line
text Plain-text body
html HTML body
envelope JSON string with the real SMTP envelope: {"to":[...],"from":"..."}
headers The raw RFC 822 headers, newline-separated
attachments Integer count of attached files
attachment1attachmentN The files themselves (read from request.FILES)
attachment-info JSON metadata (filename, type, content-id) per attachment
charsets JSON map of which charset each field was encoded in
SPF, dkim Sender-authentication results — use these against spoofing
spam_score, spam_report Present only if "Check incoming emails for spam" is enabled
email The full raw MIME message (only if "raw" option is enabled)

The envelope is the trustworthy routing info: the header to/from can be forged or contain many recipients, but envelope reflects the actual SMTP transaction.

Should I parse the POST by hand or use django-anymail?

Both work. Hand-parsing is fine for a single, simple endpoint. django-anymail is the better default for anything you will maintain: it normalizes the payload, handles MIME/charset edge cases, makes attachments trivial, protects the endpoint, and keeps your code identical if you ever switch providers.

Concern Raw form parsing django-anymail inbound
Setup @csrf_exempt view + manual field reads include("anymail.urls") + one inbound signal
Field access request.POST[...] strings you decode message.from_email, .subject, .text, .html
Attachments Loop request.FILES yourself attachment.as_uploaded_file()
Charset / MIME edge cases You handle decoding Handled for you
Multi-ESP portability Locked to SendGrid's field names Same code for SES, Mailgun, Postmark, Brevo…
Endpoint protection You build it (shared secret / basic auth) Built-in WEBHOOK_SECRET basic-auth check

We will look at the raw approach first (so you understand what SendGrid sends), then the django-anymail approach we actually ship.

How do I parse the inbound POST in a plain Django view?

Because the request comes from SendGrid and not from a Django form, the view must be exempt from CSRF protection. Read the text fields from request.POST and the files from request.FILES, decode the envelope JSON, and return a 2xx quickly so SendGrid does not retry.

# webhooks/views.py
import json

from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST

from tickets.models import Ticket


@csrf_exempt
@require_POST
def sendgrid_inbound(request):
    """Receive a parsed inbound email from SendGrid Inbound Parse."""
    envelope = json.loads(request.POST.get("envelope", "{}"))

    # Anti-spoofing: only trust mail that passed SPF (and ideally DKIM).
    if request.POST.get("SPF", "") not in ("pass", "neutral"):
        return HttpResponse(status=200)  # acknowledge but drop

    ticket = Ticket.objects.create(
        sender=envelope.get("from", request.POST.get("from", "")),
        subject=request.POST.get("subject", ""),
        body=request.POST.get("text", ""),
        html_body=request.POST.get("html", ""),
    )

    # SendGrid sends a count, then attachment1..attachmentN as uploaded files.
    count = int(request.POST.get("attachments", 0))
    for i in range(1, count + 1):
        uploaded = request.FILES.get(f"attachment{i}")
        if uploaded and uploaded.size <= 10 * 1024 * 1024:  # cap at 10 MB
            ticket.documents.create(file=uploaded)

    return HttpResponse(status=200)  # 200 == "got it, stop retrying"


# webhooks/urls.py
# from django.urls import path
# from .views import sendgrid_inbound
# urlpatterns = [path("webhooks/sendgrid/inbound/", sendgrid_inbound)]

How do I use django-anymail for inbound (the recommended path)?

django-anymail gives SendGrid Inbound Parse a normalized, first-class API. You install it, mount its URLs, point SendGrid at /anymail/sendgrid/inbound/, and write a receiver for the inbound signal. Anymail parses the payload into an AnymailInboundMessage (which subclasses Python's email.message.EmailMessage), so message.from_email, message.subject, message.text, message.html and message.attachments just work — across SendGrid, Amazon SES, Mailgun, Postmark and more, with the same handler code.

For the endpoint secret, you embed an ANYMAIL_WEBHOOK_SECRET (a user:pass pair) into the Destination URL as HTTP basic auth; Anymail rejects any inbound POST that does not present it.

# settings.py
INSTALLED_APPS += ["anymail"]

ANYMAIL = {
    "SENDGRID_API_KEY": env("SENDGRID_API_KEY"),
    # Shared secret, embedded in the Inbound Parse Destination URL as basic auth.
    # e.g.  https://<user>:<pass>@app.example.com/anymail/sendgrid/inbound/
    "WEBHOOK_SECRET": env("ANYMAIL_WEBHOOK_SECRET"),  # "randomuser:randompass"
}
EMAIL_BACKEND = "anymail.backends.sendgrid.EmailBackend"  # also powers outbound

# urls.py  -> exposes /anymail/sendgrid/inbound/
from django.urls import include, path

urlpatterns = [
    path("anymail/", include("anymail.urls")),
]

# handlers.py  -> connect a receiver to Anymail's inbound signal
from anymail.signals import inbound
from django.dispatch import receiver

from tickets.models import Ticket


@receiver(inbound)
def handle_inbound(sender, event, esp_name, **kwargs):
    message = event.message  # an AnymailInboundMessage

    Ticket.objects.create(
        sender=message.from_email.addr_spec,          # "alice@example.com"
        subject=message.subject or "",
        body=message.text or message.stripped_text or "",
        html_body=message.html or "",
    )

How do I handle attachments safely?

Attachments are the highest-risk part of inbound email: filenames and declared content types are attacker-controlled. A few rules keep you safe:

  • Never write get_filename() straight to disk. It can contain ../ path traversal or executable extensions. Generate your own storage name.
  • Verify the real content type, do not trust the declared one. Check magic bytes (e.g. python-magic) before treating something as a PDF or image.
  • Cap the size and the count; reject anything over your limit instead of buffering it.
  • With Anymail, attachment.as_uploaded_file() returns a Django UploadedFile you can assign directly to a FileField/ImageField — no temp-file juggling.
# Inside the @receiver(inbound) handler, after creating `ticket`:
MAX_BYTES = 10 * 1024 * 1024  # 10 MB
ALLOWED = {"application/pdf", "image/png", "image/jpeg", "text/csv"}

for att in message.attachments:        # inline images are excluded
    content_type = att.get_content_type()
    if content_type not in ALLOWED:
        continue
    blob = att.get_content_bytes()      # base64 already decoded
    if len(blob) > MAX_BYTES:
        continue

    # as_uploaded_file() -> a Django UploadedFile, safe for a FileField.
    # Django storage assigns a clean name; we do NOT reuse att.get_filename().
    ticket.documents.create(file=att.as_uploaded_file())

    # TIP: for an avatar-by-email feature, also re-verify image bytes
    #      (e.g. Pillow Image.open(...).verify()) before saving.

How do I secure the webhook and keep it fast?

Your inbound URL is public, so treat every POST as untrusted until proven otherwise:

  • HTTPS only, and keep the URL unguessable. With Anymail, the WEBHOOK_SECRET basic-auth credential is checked on every request; with a hand-rolled view, do the same with a shared secret in the path or an Authorization header.
  • Be honest about limits: Anymail does not currently do cryptographic signature verification for SendGrid's inbound parse webhook (that exists for SendGrid's event/tracking webhook, not inbound). The shared-secret basic-auth check is your line of defence for inbound — so keep the secret strong and rotate it.
  • Fight spoofing with SPF/DKIM. Drop or quarantine mail that fails authentication before you act on it.
  • Return HTTP 200 within a couple of seconds. SendGrid retries on non-2xx, which can cause duplicate processing. Do only the minimum inline (validate, persist a raw copy) and hand expensive work — AI classification, CRM sync, virus scanning, notifications — to a background worker.

Celery is the standard way to offload that work; see our guide on creating periodic and background tasks in Celery. Enqueue the job, then return 200 immediately.

# tasks.py
from celery import shared_task


@shared_task
def process_inbound_email(ticket_id):
    """Heavy lifting runs off the request path: scan, classify, notify, sync."""
    ticket = Ticket.objects.get(pk=ticket_id)
    # ... AI triage / spam re-check / push to CRM / send auto-reply ...


# In the inbound handler: persist quickly, enqueue, return 200 fast.
@receiver(inbound)
def handle_inbound(sender, event, esp_name, **kwargs):
    message = event.message
    ticket = Ticket.objects.create(
        sender=message.from_email.addr_spec,
        subject=message.subject or "",
        body=message.text or "",
    )
    process_inbound_email.delay(ticket.id)   # background; handler returns instantly

SendGrid Inbound Parse vs Amazon SES vs Mailgun routes

All three deliver inbound mail to your app via an MX record; they differ in payload shape and routing model. Pick the one that matches the stack you already run.

SendGrid Inbound Parse Amazon SES (inbound) Mailgun Routes
Set up by MX → mx.sendgrid.net, POST to your URL MX → SES, receipt rules (S3 / SNS / Lambda) MX → Mailgun, routes → store / forward / POST
Payload multipart/form-data (or raw MIME) Raw MIME in S3, SNS notification multipart/form-data or stored message
django-anymail Yes (/anymail/sendgrid/inbound/) Yes (via SNS) Yes
Best when You already send via SendGrid You are all-in on AWS You need flexible regex routing

If your infrastructure already lives on AWS, the SES route is a natural fit — see send and receive email with Django and Amazon SES for that variant.

Where teams use inbound parsing: support ticketing (email → ticket), reply-by-email (customers answer a notification and it threads back via a reply+<id>@ address), and document intake (vendors email a PDF or CSV that becomes a record automatically).

Frequently Asked Questions

How do I receive inbound email in Django with SendGrid?

Point an MX record for your receiving subdomain at mx.sendgrid.net, then in SendGrid → Settings → Inbound Parse add that hostname plus your public HTTPS Destination URL. SendGrid POSTs each incoming email to that URL as multipart/form-data. In Django, write a @csrf_exempt view that reads request.POST (text fields) and request.FILES (attachments), or mount django-anymail and handle its inbound signal.

Why does my SendGrid inbound view need @csrf_exempt?

The POST originates from SendGrid's servers, not from a browser submitting a Django form, so there is no CSRF token. Without @csrf_exempt Django rejects the request with HTTP 403. Because you have disabled CSRF, secure the endpoint another way — a shared secret in the URL, HTTP basic auth, and SPF/DKIM checks on the message.

How do I read attachments from a SendGrid inbound email?

SendGrid sends an attachments field with the integer count, then the files themselves as attachment1, attachment2, … which Django exposes in request.FILES. Loop from 1 to the count and read each file. With django-anymail, iterate message.attachments and call attachment.as_uploaded_file() to get a Django UploadedFile ready for a FileField. Always validate size and real content type first.

What is the difference between the envelope and the from/to fields?

The from/to form fields come from the message headers, which can be forged or list many addresses. The envelope field is a JSON string describing the actual SMTP transaction — {"to":[...],"from":"..."} — so it is the reliable source for routing (for example, extracting a ticket id from a reply+<id>@ recipient) and for anti-abuse decisions.

Should I use django-anymail or parse the POST myself?

For anything you will maintain, use django-anymail. It normalizes SendGrid's fields into a portable AnymailInboundMessage, handles MIME and charset edge cases, simplifies attachments, and adds a built-in WEBHOOK_SECRET check — and the same handler works if you later switch to SES or Mailgun. Hand-parsing is reasonable only for a single trivial endpoint where you do not want the dependency.

How do I stop spammers and spoofers hitting my inbound webhook?

Layer your defences: serve the endpoint over HTTPS with an unguessable URL and a shared secret (Anymail's WEBHOOK_SECRET basic-auth, or your own header check); reject messages that fail SPF/dkim; enable SendGrid's spam check and inspect spam_score; and cap attachment size and count. Note that Anymail does not do cryptographic signature verification for SendGrid's inbound webhook, so the shared secret plus SPF/DKIM are your primary safeguards.

Build reliable email-driven features with Django

Inbound parsing is where a lot of useful product behaviour lives — support desks, reply-by-email, automated document intake, and AI triage of incoming mail. The hard parts are not the happy path; they are spoofing, malicious attachments, retries, duplicate processing and scaling the background workers.

MicroPyramid has built and maintained Django systems for 12+ years across 50+ projects, including email-driven workflows on SendGrid, Amazon SES and Mailgun. If you want a secure, well-tested inbound pipeline — or help wiring it into ticketing, CRM or AI classification — explore our Django development services and tell us what you are building.

Share this article