django_aws_ses
A Django email backend for Amazon Simple Email Service (SES), featuring bounce and complaint handling, unsubscribe functionality, and robust integration with Django’s email system. Developed by ZeeksGeeks.
Features
- Seamless integration with Django’s email framework using a custom SES backend.
- Handles AWS SES bounce and complaint notifications via SNS.
- Secure unsubscribe functionality, including RFC 8058 one-click unsubscribe and a JSON API for custom frontends.
- Django Admin dashboard for SES statistics (superusers only).
- Optional DKIM signing support (requires
dkimpy).
Installation
Follow these steps to install and configure django_aws_ses in your Django project.
Prerequisites
- Python 3.6 or higher
- Django 3.2 or higher
- An AWS account with SES access
- Verified email address or domain in AWS SES
Step 1: Install the Package
Install django_aws_ses from PyPI:
pip install django-aws-ses
For development or testing, include development dependencies:
pip install django-aws-ses[dev]
For DKIM signing support (optional):
pip install django-aws-ses[dkim]
This installs core dependencies:
django>=3.2boto3>=1.18.0requests>=2.26.0cryptography>=3.4.7dnspython>=2.1.0
Step 2: Configure Django Settings
Add django_aws_ses and required Django apps to INSTALLED_APPS in your settings.py:
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.sites',
]
Configure AWS SES credentials and the email backend:
Option 1: IAM Role (Recommended for AWS environments)
# No AWS credentials needed in settings
AWS_SES_REGION_NAME = 'us-east-1'
AWS_SES_REGION_ENDPOINT = 'https://email.us-east-1.amazonaws.com'
EMAIL_BACKEND = 'django_aws_ses.backends.SESBackend'
DEFAULT_FROM_EMAIL = 'no-reply@yourdomain.com'
Option 2: Access Keys
AWS_SES_ACCESS_KEY_ID = 'your-access-key-id'
AWS_SES_SECRET_ACCESS_KEY = 'your-secret-access-key'
Optional: Enable debugging logs for troubleshooting:
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'console': {
'level': 'DEBUG',
'class': 'logging.StreamHandler',
},
},
'loggers': {
'django_aws_ses': {
'handlers': ['console'],
'level': 'DEBUG',
'propagate': True,
},
},
}
Step 3: Set Up URLs
Include the django_aws_ses URLs in your project’s urls.py:
from django.urls import path, include
urlpatterns = [
path('aws_ses/', include('django_aws_ses.urls', namespace='django_aws_ses')),
]
This enables endpoints for:
- Bounce/complaint handling:
https://yourdomain.com/aws_ses/bounce/ - Unsubscribe confirmation page:
https://yourdomain.com/aws_ses/unsubscribe/token/<token>/ - RFC 8058 one-click unsubscribe:
https://yourdomain.com/aws_ses/unsubscribe/one-click/<token>/ - JSON unsubscribe API:
https://yourdomain.com/aws_ses/unsubscribe/api/<token>/ - Legacy routes kept for links in emails already sent:
unsubscribe/<uuid>/<token>/andunsubscribe/email/<encoded_email>/<token>/
Step 4: Apply Migrations
Run migrations to create the django_aws_ses models (e.g., BounceRecord, ComplaintRecord, SendRecord, AwsSesUserAddon):
python manage.py migrate
Step 5: Configure AWS SES
Follow these detailed steps to set up Amazon SES in your AWS account for use with django_aws_ses:
-
Sign Up for AWS SES:
- Log in to the AWS Management Console.
- Navigate to SES: https://console.aws.amazon.com/ses/.
- If new to SES, follow prompts to activate the service.
- Docs: https://docs.aws.amazon.com/ses/latest/dg/get-set-up.html
-
Verify Sender Email or Domain:
- In the SES console, go to "Verified identities."
- Click "Create identity":
- Email Address: Enter the sender email (e.g.,
no-reply@yourdomain.com). AWS sends a verification email; click the link to verify. - Domain: Enter your domain (e.g.,
yourdomain.com). Add provided DNS records (TXT, CNAME, MX) to your DNS provider to verify ownership.
- Email Address: Enter the sender email (e.g.,
- Docs: https://docs.aws.amazon.com/ses/latest/dg/creating-identities.html
-
Create IAM Credentials:
- Create an IAM user for SES:
- Go to IAM in AWS Console: https://console.aws.amazon.com/iam/.
- Create a user (e.g.,
ses-user) with programmatic access. - Attach permissions (e.g.,
AmazonSESFullAccessandAmazonSNSFullAccess). - Save the Access Key ID and Secret Access Key for
settings.py.
- Docs: https://docs.aws.amazon.com/ses/latest/dg/control-user-access.html
- Create an IAM user for SES:
-
Set Up SNS Notifications:
- Create an SNS topic (e.g.,
SES_Notifications) in the SNS console: https://console.aws.amazon.com/sns/. - Subscribe your bounce/complaint endpoint (
https://yourdomain.com/aws_ses/bounce/) to the topic using the HTTPS protocol. - In SES, go to "Verified identities," select your email/domain, and configure notifications to send bounces, complaints, and deliveries to the SNS topic.
- Set
AWS_SES_BOUNCE_TOPIC_ARNSto the topic's ARN so the endpoint ignores other topics (see Bounce Endpoint Security). - Docs: https://docs.aws.amazon.com/ses/latest/dg/monitor-using-notifications.html
- Create an SNS topic (e.g.,
-
(Optional) Configure DKIM Signing:
- If using DKIM, enable it in SES:
- In "Verified identities," select your domain and enable DKIM.
- Add provided DNS records to your DNS provider.
- Ensure
dkimpyis installed (pip install django-aws-ses[dkim]). - Docs: https://docs.aws.amazon.com/ses/latest/dg/send-email-authentication-dkim.html
- If using DKIM, enable it in SES:
-
Test SES Configuration:
- In SES console, send a test email from "Verified identities."
- Verify receipt in the recipient’s inbox.
- Test sending from Django (see Usage).
- Docs: https://docs.aws.amazon.com/ses/latest/dg/send-an-email.html
-
Exit Sandbox Mode (Production):
- SES sandbox mode restricts sending to verified emails only.
- For production, request access:
- Open a support case in AWS Support Center.
- Provide use case (e.g., transactional emails for user registration).
- Approval typically takes 24 hours.
- Docs: https://docs.aws.amazon.com/ses/latest/dg/request-production-access.html
Usage
django_aws_ses integrates with Django’s email API and provides additional features for SES-specific functionality. The following examples are shown in a Python console (e.g., Django shell or within a view).
Sending a Basic Email
Use Django’s send_mail for simple emails:
from django.core.mail import send_mail
send_mail(
subject='Test Email',
message='This is a test email from django_aws_ses.',
from_email='no-reply@yourdomain.com', # Must be SES-verified
recipient_list=['recipient@example.com'],
fail_silently=False,
)
- Set
fail_silently=Falseto raise exceptions for debugging (e.g., unverified email errors).
Sending HTML Emails
Send emails with HTML content and plain text fallback:
from django.core.mail import EmailMultiAlternatives
subject = 'Welcome to Our Platform'
from_email = 'no-reply@yourdomain.com'
to = 'recipient@example.com'
text_content = 'Thank you for joining our platform!'
html_content = '<p>Thank you for joining! <a href="https://yourdomain.com">Visit us</a></p>'
email = EmailMultiAlternatives(subject, text_content, from_email, [to])
email.attach_alternative(html_content, 'text/html')
email.send()
Sending Email Attachments
Send emails with attachments using EmailMultiAlternatives:
from django.core.mail import EmailMultiAlternatives
subject = 'Document from Our Platform'
from_email = 'no-reply@yourdomain.com'
to = 'recipient@example.com'
text_content = 'Please find the attached document.'
email = EmailMultiAlternatives(subject, text_content, from_email, [to])
email.attach('document.pdf', open('path/to/document.pdf', 'rb').read(), 'application/pdf')
email.send()
- Note: Amazon SES limits the total email size, including attachments, to 10MB. Ensure attachments are within this limit to avoid delivery failures.
Handling Bounce and Complaint Notifications
- Bounce and complaint notifications are processed via the SNS endpoint (
/aws_ses/bounce/). - Records are stored in the
BounceRecordandComplaintRecordmodels. - View bounce/complaint data in the Django Admin or SES dashboard (
/aws_ses/status/). - Configure additional SNS notifications for deliveries in SES console.
- Docs: https://docs.aws.amazon.com/ses/latest/dg/monitor-sending-activity.html
Bounce Endpoint Security
The bounce endpoint is a public URL, so every SNS message is checked before anything is stored or a subscription is confirmed:
-
Signature: the
Signatureis verified against the certificate atSigningCertURL. BothSignatureVersion1 (SHA1) and 2 (SHA256) are supported; other versions are rejected. -
Certificate host: the certificate is only downloaded from
https://sns.<region>.amazonaws.com/....pem(orsns.<region>.amazonaws.com.cn), with no query string or fragment. Otheramazonaws.comhosts, such as S3 buckets, can serve anyone's certificate and are rejected. -
Topic: a valid signature only proves that some SNS topic sent the message, and anyone with an AWS account can create a topic and subscribe your endpoint to it. Set
AWS_SES_BOUNCE_TOPIC_ARNSto your own topic ARNs so messages and subscription requests from other topics are ignored. The topic check runs before, and independently of, the signature check, so it also applies whenAWS_SES_VERIFY_BOUNCE_SIGNATURESis off:AWS_SES_BOUNCE_TOPIC_ARNS = ['arn:aws:sns:us-east-1:123456789012:SES_Notifications']
-
Downloaded certificates are cached for 24 hours using Django's cache, and certificate downloads and subscription confirmations time out after 10 seconds.
-
Rejected messages get an empty 200 response and are logged as warnings; nothing is recorded.
-
SubscriptionConfirmationmessages are confirmed automatically.UnsubscribeConfirmationmessages are only acknowledged: theirSubscribeURLwould re-subscribe the endpoint.
| Setting | Default | Purpose |
|---|---|---|
AWS_SES_VERIFY_BOUNCE_SIGNATURES |
True |
Verify SNS signatures. Only turn off for local testing. |
AWS_SES_BOUNCE_TOPIC_ARNS |
() |
SNS topic ARNs to accept messages from. Empty accepts any topic and logs a warning once per process. |
AWS_SNS_BOUNCE_CERT_TRUSTED_DOMAINS |
('amazonaws.com', 'amazonaws.com.cn') |
Domain suffixes the certificate host must also end with. It can narrow the trusted SNS hosts (e.g. ('amazonaws.com',) to reject China regions) but not widen them. |
Generating Unsubscribe Links
Add secure unsubscribe links to emails:
from django_aws_ses.models import AwsSesUserAddon
user = User.objects.get(email='recipient@example.com')
addon = AwsSesUserAddon.objects.get_or_create(user=user)[0]
unsubscribe_url = addon.unsubscribe_url_generator()
# Include in email template, e.g., <a href="{{ unsubscribe_url }}">Unsubscribe</a>
- By default the link points to
/aws_ses/unsubscribe/token/<token>/, a confirmation page with Unsubscribe / Re-subscribe buttons. Opening the link (GET) never changes anything; only submitting the form (POST) does. The page shows the address masked (e.g.a***@example.com), as do the older uuid and encoded-email pages, so a forwarded link does not reveal it. unsubscribe_url_generator()returns a relative path; prefix your site's origin when putting it in an email, or setAWS_SES_UNSUBSCRIBE_URL(see below).- The token contains only the record id, signed with your
SECRET_KEY. The email address is not in the link, so it is not exposed when an email is forwarded. - Links generated by 0.1.6 and earlier (which embedded the address) keep working. Set
AWS_SES_UNSUBSCRIBE_ACCEPT_LEGACY_TOKENS = Falseto stop accepting them. - Customize the unsubscribe view or template in
django_aws_ses/templates/django_aws_ses/unsubscribe.html. Custom templates must keep{% csrf_token %}inside the form.
Generating Unsubscribe Links for Non-User Emails
For mailing lists or launch notifications where recipients may not have accounts, use EmailUnsubscribe:
from django_aws_ses.models import EmailUnsubscribe
emails = ['recipient@example.com', 'other@example.com']
# Bulk get-or-create unsubscribe records
existing = {u.email: u for u in EmailUnsubscribe.objects.filter(email__in=emails)}
to_create = [EmailUnsubscribe(email=e) for e in emails if e not in existing]
if to_create:
EmailUnsubscribe.objects.bulk_create(to_create, ignore_conflicts=True)
all_unsubs = {u.email: u for u in EmailUnsubscribe.objects.filter(email__in=emails)}
else:
all_unsubs = existing
for email in emails:
unsub = all_unsubs.get(email)
if unsub and unsub.unsubscribed:
continue
unsubscribe_url = unsub.unsubscribe_url_generator()
# append unsubscribe_url to the email body before sending
- Links point to
/aws_ses/unsubscribe/token/<token>/and are self-authenticating — no session or login required. The address is not in the URL. - Run
python manage.py migrate django_aws_sesafter upgrading to create theEmailUnsubscribetable. - By default the backend does not block addresses whose
EmailUnsubscriberecord is unsubscribed; skip them yourself as above, or setAWS_SES_FILTER_EMAIL_UNSUBSCRIBES = Trueto have the backend drop them. WithAWS_SES_AUTO_LIST_UNSUBSCRIBE = Truethe backend always drops them, because it sends one-click headers for these records.
Unsubscribe Settings
| Setting | Default | Purpose |
|---|---|---|
AWS_SES_UNSUBSCRIBE_URL |
None |
Where unsubscribe links point. A string containing {token} (e.g. 'https://app.example.com/unsubscribe/{token}') or a callable taking the token. In the string form the token is percent-encoded and any other {...} is left as is; a callable receives the raw token and must encode it itself. None uses the library's confirmation page. |
AWS_SES_UNSUBSCRIBE_BASE_URL |
None |
Absolute HTTPS origin of this Django backend (e.g. 'https://api.example.com'). Required for one-click unsubscribe; it is never guessed. |
AWS_SES_AUTO_LIST_UNSUBSCRIBE |
False |
Add one-click headers automatically (see below). Requires AWS_SES_UNSUBSCRIBE_BASE_URL; the backend raises ImproperlyConfigured when it is created if that is missing. Also turns on the EmailUnsubscribe filter. |
AWS_SES_UNSUBSCRIBE_ACCEPT_LEGACY_TOKENS |
True |
Accept tokens from links generated by 0.1.6 and earlier. |
AWS_SES_FILTER_EMAIL_UNSUBSCRIBES |
False |
Also drop recipients whose EmailUnsubscribe record is unsubscribed. Always on when AWS_SES_AUTO_LIST_UNSUBSCRIBE is True. |
Hosting the Unsubscribe Page in Your Own Frontend
Set AWS_SES_UNSUBSCRIBE_URL so generated links open your frontend, then have the page call the JSON API with the token from its URL:
GET /aws_ses/unsubscribe/api/<token>/
-> {"kind": "user", "unsubscribed": false, "email": "t***@example.com"}
POST /aws_ses/unsubscribe/api/<token>/ body: {"unsubscribed": true}
-> {"kind": "user", "unsubscribed": true, "email": "t***@example.com"}
- Invalid tokens return 404; bad bodies return 400. GET never changes state.
- No session, login or CSRF token is needed: the signed token is the credential. Configure CORS yourself if the frontend is on another origin.
- The email is masked in responses so a forwarded link does not reveal the full address.
The same operations are available as plain Python helpers if you prefer to write your own endpoints (e.g. with DRF):
from django_aws_ses import unsubscribe
record = unsubscribe.resolve_token(token) # AwsSesUserAddon, EmailUnsubscribe, or None
unsubscribe.get_status(record) # {'kind', 'unsubscribed', 'email' (masked)}
unsubscribe.set_unsubscribed(record, True) # or False to re-subscribe
One-Click Unsubscribe (RFC 8058)
Gmail and Yahoo require bulk senders to support one-click unsubscribe, and show their own Unsubscribe button when the message carries these headers:
List-Unsubscribe: <https://api.example.com/aws_ses/unsubscribe/one-click/<token>/>
List-Unsubscribe-Post: List-Unsubscribe=One-Click
Setup:
-
Set
AWS_SES_UNSUBSCRIBE_BASE_URL = 'https://api.example.com'(the public HTTPS origin serving theaws_ses/URLs). -
Add the headers per message:
from django.core.mail import EmailMultiAlternatives email = EmailMultiAlternatives(subject, text, from_email, [user.email], headers=user.aws_ses.list_unsubscribe_headers()) # or EmailUnsubscribe.list_unsubscribe_headers(), or # django_aws_ses.unsubscribe.list_unsubscribe_headers(user_or_record)
If you add headers by hand for
EmailUnsubscriberecords, setAWS_SES_FILTER_EMAIL_UNSUBSCRIBES = True(or skip unsubscribed records yourself). Otherwise a one-click unsubscribe is recorded but the address keeps receiving mail, which breaks RFC 8058 and the Gmail/Yahoo bulk-sender rules. User records are always filtered.Or set
AWS_SES_AUTO_LIST_UNSUBSCRIBE = Trueand the backend adds them to messages with exactly one recipient (to + cc + bcc) that maps to a user orEmailUnsubscriberecord. Tokens are per recipient, so multi-recipient messages are skipped, as are messages that already setList-Unsubscribeand transactional messages. This setting also makes the backend honourEmailUnsubscribeunsubscribes.
The one-click endpoint unsubscribes only on a POST whose form body is exactly List-Unsubscribe=One-Click; any other POST returns 400 without changing anything, and methods other than GET/POST return 405.
Why GET never unsubscribes: link scanners such as Outlook/Microsoft Defender Safe Links and corporate mail gateways fetch every URL in incoming mail. If a GET could unsubscribe, recipients would be unsubscribed without ever clicking. A GET on the one-click URL (some mail clients open it in a browser instead of POSTing) redirects to the confirmation page instead, or to HOME_URL if the token is not validly signed. Keep this rule in any custom unsubscribe page you build.
DKIM must cover the List-Unsubscribe headers: Gmail and Yahoo only honour one-click when List-Unsubscribe and List-Unsubscribe-Post are covered by a valid DKIM signature. SES Easy DKIM signs them. With library-side signing (the DKIM_* settings and dkimpy), the backend adds both headers to its signed header list whenever they are present.
Long headers on Django 6: Django 6 builds messages with email.policy.default, which RFC 2047-encodes header values longer than 78 characters that cannot be folded, such as a long URL. Mail clients do not accept an encoded URL. The backend builds messages with a 998-character line limit (the SMTP maximum), so the header stays on one plain line on every supported Django version. ASCII bodies with lines longer than 78 characters are now sent as 7bit instead of quoted-printable; other bodies are unchanged.
Transactional Messages
Password resets and verification codes should reach users even after they unsubscribe from other mail. Mark such a message to skip only the unsubscribe filter; complaint, bounce and domain filters still apply:
email = EmailMultiAlternatives(subject, text, from_email, [user.email])
email.aws_ses_transactional = True
# or: headers={'X-Django-AWS-SES-Transactional': '1'} (removed before sending)
email.send()
Recipient Filtering
Before sending, the backend drops recipients who have unsubscribed, have a complaint record, have at least SES_BOUNCE_LIMIT bounce records, or whose domain is blacklisted or has no MX record. The dropped addresses are returned in the Sent entry of send_messages()'s result.
- Addresses are compared case-insensitively. On SQLite this only covers ASCII letters (SQLite's
LOWER()leaves letters such asÜunchanged), so an address that differs only in the case of a non-ASCII letter will not match. PostgreSQL and MySQL handle these correctly. - Recipients written as
"Name <user@example.com>"are matched on the address part, and the original string is kept in the message. - Bounces are counted per address, so
SES_BOUNCE_LIMIT = 3drops an address after its third bounce.
Viewing SES Statistics
- Access the SES dashboard at
/aws_ses/status/(superusers only). - Displays bounce rates, complaint rates, and email sending history.
- Uses
BounceRecord,ComplaintRecord, andSendRecordmodels for metrics. - Docs: https://docs.aws.amazon.com/ses/latest/dg/monitor-sending-metrics.html
Debugging and Error Handling
- Enable debug logging (see Step 2) to troubleshoot SES errors.
- Common issues:
- Unverified email/domain: Verify in SES console.
- IAM permissions: Ensure
AmazonSESFullAccessandAmazonSNSFullAccess. - SNS endpoint errors: Confirm HTTPS endpoint is publicly accessible.
- Check
BounceRecordandComplaintRecordin Django Admin for failed deliveries.
Rate Limiting and Throttling
- SES imposes sending quotas and rate limits.
- Monitor limits in SES console ("Sending Statistics").
- If approaching limits, request a quota increase:
- Open a support case in AWS Support Center.
- Specify desired sending rate and daily quota.
- Docs: https://docs.aws.amazon.com/ses/latest/dg/manage-sending-limits.html
Changelog
For a detailed list of changes, improvements, and fixes across versions, see CHANGELOG.md.
Contributors
Developed by the ZeeksGeeks team. See CONTRIBUTORS.md for individual contributors and their roles.
Contributing
We welcome contributions! Please follow these steps:
- Fork the repository: https://github.com/zeeksgeeks/django_aws_ses
- Create a branch:
git checkout -b feature/your-feature - Commit changes:
git commit -m 'Add your feature' - Push:
git push origin feature/your-feature - Open a Pull Request.
See CONTRIBUTORS.md for current contributors.
License
This project is licensed under the MIT License. See LICENSE for details.
PyPI Distribution
- Install:
pip install django-aws-ses - Source: https://github.com/zeeksgeeks/django_aws_ses
- Issues: https://github.com/zeeksgeeks/django_aws_ses/issues
- PyPI: https://pypi.org/project/django-aws-ses/
- TestPyPI: https://test.pypi.org/project/django-aws-ses/
- Changelog: See CHANGELOG.md for version history.
Metadata
Release files for django-aws-ses 0.1.7
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| django_aws_ses-0.1.7.tar.gz | 125.4 kB | Without provenance | Details for django_aws_ses-0.1.7.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| django_aws_ses-0.1.7-py3-none-any.whl | Python 3 | none | any | Details for django_aws_ses-0.1.7-py3-none-any.whl |
Total release size: 244.4 kB
Release files / django_aws_ses-0.1.7.tar.gz
| Download URL | django_aws_ses-0.1.7.tar.gz |
|---|---|
| Size | 125.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
485aef7f13553117734c914520d35006077e3ec1eda5437567cdd3f6d47c6852
|
|
BLAKE2b-256 checksum How to use checksums |
45ccaa5c7f2d2be23aee44e4a102036e750c6674f78f1ee950faf9c5b0405d26
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.14.4
|
Release files / django_aws_ses-0.1.7-py3-none-any.whl
| Download URL | django_aws_ses-0.1.7-py3-none-any.whl |
|---|---|
| Size | 119.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
c38f34d859982c3884c19454256e67ed6eeb047db7c9bde062ca711bc6ff1963
|
|
BLAKE2b-256 checksum How to use checksums |
28b6a5b629e061456bd7674e310a88d1f953288198cfc35fb7a24fde24432fd8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.14.4
|
Security for django-aws-ses 0.1.7
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.