Amazon SES (Simple Email Service) is the cheapest, most reliable way to send transactional email from a Django app — and it can also receive inbound mail for you. This guide covers both directions with modern, 2026-accurate code: sending over SMTP or the SESv2 API, and receiving inbound email through SES receipt rules into S3.
It replaces an older walkthrough that relied on a now-unmaintained gateway package. Everything below uses Django's built-in mail framework, the maintained django-ses backend, and boto3.
What you get with SES
- Sending — transactional and bulk email on a pay-per-use, very low cost basis, with Easy DKIM, SPF/DMARC alignment, and bounce/complaint tracking.
- Receiving — inbound mail routed by receipt rule sets to S3, SNS, or Lambda, so your app can parse replies and attachments.
- Deliverability tooling — configuration sets for event tracking, dedicated IP pools, and reputation dashboards.
Before you send: verify, leave the sandbox, use IAM roles
Verify a domain identity (not just one address)
In the SES console, add your sending domain as a verified identity and turn on Easy DKIM. SES returns three CNAME records — add them at your DNS provider. A verified domain lets you send from any address on it (no-reply@, support@, …) and is what makes DKIM signing and DMARC alignment work. Also publish an SPF record that includes amazonses.com.
Request production access
Every new account starts in the SES sandbox, which only sends to identities you have verified, with a small daily cap. Open a production access request from the console (it asks about your use case and how you handle bounces) to lift the limits and send to anyone.
| Sandbox (default) | Production (after request) | |
|---|---|---|
| Send to | Only verified addresses/domains | Any recipient |
| Daily quota | 200 messages / 24h | Starts higher, auto-scales with reputation |
| Send rate | ~1 message/sec | Higher, increases over time |
| Use case | Local dev & testing | Live traffic |
Quotas are AWS defaults and change over time — confirm the current numbers in the SES console under Account dashboard.
Never hardcode AWS keys
Do not put AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY in settings.py or commit them to source control. On AWS, attach an IAM role to the instance, container, or Lambda (an instance profile or task role) and let boto3 resolve credentials automatically. Locally, use a named profile or environment variables injected by your secrets manager. Keep the policy tight — ses:SendEmail / ses:SendRawEmail for sending, and s3:GetObject on the inbound bucket for receiving. Our guide to AWS IAM roles and policies walks through scoping these correctly.
Sending email from Django
There are two solid approaches. Reach for SMTP when you want zero extra dependencies; use the SESv2 API (through django-ses or boto3) when you want configuration sets, per-message tags, and connection-pooled sends.
Option A: SMTP backend (no extra packages)
Generate SMTP credentials in the SES console — these are not your IAM keys; SES derives a dedicated SMTP username and password. Then point Django's built-in SMTP backend at the regional SES endpoint.
# settings.py -- Amazon SES over SMTP
import os
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "email-smtp.us-east-1.amazonaws.com" # match your SES region
EMAIL_PORT = 587
EMAIL_USE_TLS = True
# SES SMTP credentials (NOT your IAM keys). Load from the environment or a
# secrets manager -- never commit them.
EMAIL_HOST_USER = os.environ["SES_SMTP_USERNAME"]
EMAIL_HOST_PASSWORD = os.environ["SES_SMTP_PASSWORD"]
# Must be an address on a verified identity.
DEFAULT_FROM_EMAIL = "Acme <no-reply@example.com>"With that backend configured, everything in Django that sends mail — send_mail(), password-reset flows, even error reports — goes through SES. For HTML email, use EmailMultiAlternatives so clients that block HTML still get a readable plain-text part.
from django.core.mail import send_mail, EmailMultiAlternatives
from django.template.loader import render_to_string
# Plain-text message
send_mail(
subject="Welcome to Acme",
message="Thanks for signing up.",
from_email=None, # falls back to DEFAULT_FROM_EMAIL
recipient_list=["customer@example.com"],
)
# HTML email with a plain-text alternative
context = {"name": "Sam"}
text_body = render_to_string("email/welcome.txt", context)
html_body = render_to_string("email/welcome.html", context)
msg = EmailMultiAlternatives(
subject="Welcome to Acme",
body=text_body,
to=["customer@example.com"],
)
msg.attach_alternative(html_body, "text/html")
msg.send()Option B: the SESv2 API via django-ses
The django-ses package is the maintained backend that talks to the SES API instead of SMTP — convenient when you want configuration sets and message tags. Install it with pip install django-ses, then change one setting. Credentials still come from the IAM role, not the file. Set USE_SES_V2 = True to use the current v2 API.
# settings.py -- Amazon SES via the API (django-ses)
EMAIL_BACKEND = "django_ses.SESBackend"
AWS_SES_REGION_NAME = "us-east-1"
AWS_SES_REGION_ENDPOINT = "email.us-east-1.amazonaws.com"
# Use the SESv2 API (django-ses 3.5+).
USE_SES_V2 = True
# Attach a configuration set so opens, clicks, bounces and complaints
# are tracked as events.
AWS_SES_CONFIGURATION_SET = "transactional-events"
DEFAULT_FROM_EMAIL = "Acme <no-reply@example.com>"Prefer to send outside the request/response cycle — say, from a Celery task — without Django's mail layer? Call the SESv2 API directly with boto3. Notice there are no credentials in the code: boto3 picks them up from the attached IAM role.
import boto3
# Credentials come from the IAM role / instance profile -- none in code.
client = boto3.client("sesv2", region_name="us-east-1")
response = client.send_email(
FromEmailAddress="no-reply@example.com",
Destination={"ToAddresses": ["customer@example.com"]},
Content={
"Simple": {
"Subject": {"Data": "Welcome to Acme", "Charset": "UTF-8"},
"Body": {
"Text": {"Data": "Thanks for signing up.", "Charset": "UTF-8"},
"Html": {
"Data": "<h1>Welcome</h1><p>Thanks for signing up.</p>",
"Charset": "UTF-8",
},
},
}
},
ConfigurationSetName="transactional-events",
)
print("SES message id:", response["MessageId"])SMTP vs the SESv2 API
| SMTP backend | SESv2 API (django-ses / boto3) | |
|---|---|---|
| Setup | Built into Django, no packages | pip install django-ses or use boto3 |
| Credentials | SES SMTP username/password | IAM role (no static keys) |
| Configuration sets | Manual headers | First-class support |
| Throughput | Fine for most apps | Connection-pooled, lower latency |
| Best for | Quick wins, Django's normal mail | Event tracking, tags, high volume |
Both deliver through the same SES infrastructure, so deliverability is identical — the choice is about ergonomics and features, not reach.
Deliverability: DKIM, SPF, DMARC, and bounces
Authentication is what keeps you out of spam folders. With a verified domain identity and Easy DKIM enabled, SES signs every message; add an SPF record that includes amazonses.com and publish a DMARC policy (start at p=none to monitor, then tighten to quarantine or reject).
Attach a configuration set to capture sends, deliveries, opens, clicks, bounces, and complaints as events — route them to SNS, Kinesis Firehose, or CloudWatch. This matters because AWS holds you to bounce and complaint rate thresholds; cross them and SES can pause your sending. Process bounces and complaints and suppress bad addresses promptly. We cover the full feedback loop in handling SES bounces and complaints.
Receiving inbound email with SES
SES can accept mail for your domain and hand it to AWS for processing. The pipeline is:
- Point your domain's MX record at the SES inbound endpoint for your region.
- Create a receipt rule set with a rule that matches recipients (a whole domain or specific addresses).
- Give the rule an action: write the raw message to an S3 bucket, and/or publish an SNS notification, and/or invoke a Lambda function.
- Your Django app (triggered by the SNS notification or a Lambda S3 event) reads the raw MIME object from S3 and parses it.
Inbound email is only available in specific SES regions — historically US East (N. Virginia) us-east-1, US West (Oregon) us-west-2, and Europe (Ireland) eu-west-1. AWS has been adding more, so confirm the current list in the SES docs before you choose a region. Your inbound region can differ from your sending region.
Once SES drops the message in S3, parsing is plain Python — the standard-library email module reads the raw bytes. For heavier processing, run this inside a Lambda triggered by the S3 object (see using AWS Lambda with S3 and DynamoDB).
import boto3
from email import message_from_bytes
from email.policy import default
s3 = boto3.client("s3") # credentials from the IAM role
def parse_inbound(bucket, key):
"""Fetch the raw email SES stored in S3 and return its parsed parts.
bucket / key come from the SNS notification (or S3 event) that SES fires
when the receipt rule runs.
"""
raw = s3.get_object(Bucket=bucket, Key=key)["Body"].read()
msg = message_from_bytes(raw, policy=default)
# Prefer the plain-text body, fall back to HTML.
body_part = msg.get_body(preferencelist=("plain", "html"))
body = body_part.get_content() if body_part else ""
return {
"subject": msg["subject"],
"from": msg["from"],
"to": msg["to"],
"body": body,
"attachments": [part.get_filename() for part in msg.iter_attachments()],
}Where MicroPyramid fits
We have run Django on AWS for clients across the US, UK, Australia, and Singapore for 12+ years across 50+ delivered projects — including SES sending pipelines and inbound-mail processing wired into Lambda and S3. If you want the email side of your product built or hardened, see our Django development and AWS consulting services.
Frequently Asked Questions
Why are my SES emails stuck in the sandbox?
New SES accounts are sandboxed: you can only send to addresses or domains you have verified, with a low daily cap and send rate. Submit a production access request from the SES console — describe your use case and how you handle bounces and complaints — and AWS lifts the restrictions, usually within a day.
SMTP or the API — which should I use?
Use the SMTP backend if you want zero dependencies and Django's normal mail behaviour (password resets, send_mail); it is the fastest path to live email. Use the SESv2 API via django-ses or boto3 when you need configuration sets, message tags, IAM-role credentials with no static keys, or lower-latency high-volume sends. Deliverability is identical either way.
How do I receive email with SES?
Point your domain's MX record at the SES inbound endpoint, create a receipt rule set that matches your recipients, and give the rule an action that writes the raw message to S3 and notifies you via SNS or Lambda. Your Django app then reads the object from S3 and parses the MIME with Python's standard-library email module.
Do I need a verified domain to use SES?
You must verify at least one identity, and verifying the whole domain (with Easy DKIM) is strongly preferred over a single address: it lets you send from any address on the domain and enables DKIM signing and DMARC alignment, which are essential for inbox placement. In the sandbox you must also verify each recipient.
How do I handle bounces and complaints?
Attach a configuration set and send bounce, complaint, and delivery events to an SNS topic (or Firehose/CloudWatch). Process those events to suppress bad addresses immediately, because AWS enforces bounce and complaint rate thresholds and can pause sending if you exceed them. See our dedicated guide on handling SES bounces and complaints.
Which regions support inbound email?
Inbound email receiving is limited to specific SES regions — historically us-east-1, us-west-2, and eu-west-1 — though AWS keeps expanding the list, so check the current SES documentation. Sending is available in many more regions, and your inbound and sending regions do not have to match.
How much does Amazon SES cost?
SES is pay-per-use and one of the cheapest email options available — you pay per message sent or received plus data transfer, with no monthly minimum. Exact rates change, so check the AWS SES pricing page for current figures. (This is AWS's own service pricing, separate from any engagement with us.)