updates to README.md addition of CONTRIBUTING.md work on tests and requirments-dev.txt

This commit is contained in:
Raymond Jessop 2025-04-18 16:31:05 -05:00
parent 3113fb953f
commit 39a972d688
8 changed files with 414 additions and 38 deletions

106
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,106 @@
# Contributing to Django AWS SES
Thank you for your interest in contributing to Django AWS SES! This guide outlines how to set up the project, run tests, and submit changes.
## Getting Started
1. **Clone the Repository**:
```bash
git clone https://git-vault.zeeksgeeks.com/zeeksgeeks/django_aws_ses
cd django_aws_ses
```
2. **Set Up a Virtual Environment**:
```bash
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
3. **Install Dependencies**:
```bash
pip install -r requirements.txt
pip install -r requirements-dev.txt
```
4. **Configure a Test Django Project**:
- Create a Django project or use an existing one.
- Add `'django_aws_ses'` to `INSTALLED_APPS` in `settings.py`.
- Apply migrations:
```bash
python manage.py migrate
```
## Running Tests
1. Ensure test dependencies are installed:
```bash
pip install -r requirements-dev.txt
```
2. Run the test suite:
```bash
python manage.py test django_aws_ses
```
3. Check test coverage (optional):
```bash
pytest --cov=django_aws_ses
```
## Making Changes
1. **Create a Feature Branch**:
```bash
git checkout -b feature/your-feature-name
```
2. **Follow Coding Guidelines**:
- Write clear, concise code with docstrings.
- Ensure compatibility with Python 3.8+ and Django 3.2+.
- Add tests for new functionality.
3. **Test Your Changes**:
- Run the full test suite to ensure no regressions.
- Test manually in a Django project if needed.
4. **Commit Changes**:
```bash
git add .
git commit -m "Add your descriptive commit message"
```
5. **Push and Create a Pull Request**:
```bash
git push origin feature/your-feature-name
```
- Create a pull request on the repository.
- Describe your changes and reference any related issues.
## Reporting Issues
- Use the repositorys issue tracker to report bugs or suggest features.
- Provide detailed information, including steps to reproduce and environment details.
## License
By contributing, you agree that your contributions will be licensed under the MIT License.
## Contact
For questions, contact Ray Jessop at development@zeeksgeeks.com.

102
README.md
View File

@ -1,26 +1,39 @@
# Django AWS SES
*(Badge to be activated upon PyPI release)*
A Django email backend for sending emails via Amazon Simple Email Service (SES).
## Features
- Send emails using AWS SES with DKIM signing support.
- Send emails using AWS SES with optional DKIM signing.
- Handle bounce, complaint, and delivery notifications via SNS webhooks.
- Filter recipients based on bounce/complaint history and domain validation.
- Admin dashboard for SES statistics and verified emails.
- Unsubscribe functionality with secure URL generation.
- Secure unsubscribe functionality with confirmation step.
## Installation
```bash
pip install django_aws_ses
```
## Requirements
- Python 3.8+
- Django 3.2+
- AWS SES account with verified domains/emails
## Setup
1. Add to `INSTALLED_APPS`:
## Quick Start
1. Install the package:
```bash
pip install django_aws_ses
```
2. Add to `INSTALLED_APPS` in `settings.py`:
```python
INSTALLED_APPS = [
...
@ -28,7 +41,8 @@ pip install django_aws_ses
]
```
2. Configure settings in `settings.py`:
3. Configure AWS SES settings in `settings.py`:
```python
AWS_SES_ACCESS_KEY_ID = 'your-access-key'
AWS_SES_SECRET_ACCESS_KEY = 'your-secret-key'
@ -37,35 +51,89 @@ pip install django_aws_ses
EMAIL_BACKEND = 'django_aws_ses.backends.SESBackend'
```
3. Apply migrations:
4. Apply migrations:
```bash
python manage.py migrate
```
4. (Optional) Enable DKIM signing:
5. Test email sending:
```python
from django.core.mail import send_mail
send_mail('Subject', 'Message', 'from@example.com', ['to@example.com'])
```
## Advanced Setup
### DKIM Signing (Optional)
To enable DKIM for email authentication:
1. Generate a DKIM key pair and configure in AWS SES.
2. Add to `settings.py`:
```python
DKIM_DOMAIN = 'example.com'
DKIM_PRIVATE_KEY = 'your-private-key'
DKIM_SELECTOR = 'ses'
```
5. Set up SNS webhook for bounce/complaint handling:
- Add the URL `your-domain.com/aws_ses/bounce/` to your SNS subscription.
- Ensure the view is accessible (e.g., CSRF-exempt).
### SNS Webhook for Notifications
To handle bounces, complaints, and deliveries:
1. Set up an SNS topic in AWS and subscribe the URL `your-domain.com/aws_ses/bounce/`.
2. Ensure the view is publicly accessible and CSRF-exempt (configured by default).
### Unsubscribe Functionality
- Users receive a secure unsubscribe link (`/aws_ses/unsubscribe/<uuid>/<hash>/`).
- A confirmation page prevents accidental unsubscribes (e.g., by email scanners).
- Re-subscribe option available on the same page.
## Usage
- Send emails using Djangos `send_mail` or `EmailMessage`.
- View SES statistics at `/aws_ses/status/` (superuser only).
- Unsubscribe users via `/aws_ses/unsubscribe/<uuid>/<hash>/`.
- **Send Emails**: Use Djangos `send_mail` or `EmailMessage` as usual.
- **View Statistics**: Access `/aws_ses/status/` (superuser only) for SES quotas and sending stats.
- **Manage Unsubscribes**: Users can unsubscribe or re-subscribe via the secure link.
## Development
To contribute:
1. Clone the repo: `git clone https://github.com/zeeksgeeks/django_aws_ses`
2. Install dependencies: `pip install -r requirements.txt`
3. Run tests: `python manage.py test`
### Running Tests
1. Install test dependencies:
```bash
pip install -r requirements-dev.txt
```
2. Run tests:
```bash
python manage.py test django_aws_ses
```
### Contributing
1. Clone the repo:
```bash
git clone https://git-vault.zeeksgeeks.com/zeeksgeeks/django_aws_ses
```
2. Install dependencies:
```bash
pip install -r requirements.txt
```
3. Create a feature branch and submit a pull request.
## License
MIT License. See [LICENSE](LICENSE) for details.
## Credits
Developed by Ray Jessop. Inspired by [django-ses](https://github.com/django-ses/django-ses).

View File

@ -15,10 +15,17 @@ p {
color: #555;
margin: 10px 0;
}
a {
a, button {
color: #007bff;
text-decoration: none;
padding: 8px 16px;
margin: 5px;
border: 1px solid #007bff;
border-radius: 4px;
background: none;
cursor: pointer;
}
a:hover {
text-decoration: underline;
a:hover, button:hover {
background-color: #007bff;
color: #fff;
}

View File

@ -5,13 +5,23 @@
<link rel="stylesheet" href="{% static 'django_aws_ses/css/unsubscribe.css' %}">
{% endblock %}
{% block title1 %}Unsubscribe{% endblock title1 %}
{% block title1 %}Email Subscription{% endblock title1 %}
{% block content %}
<div class="unsubscribe-container">
{% if user_email %}
<h3>{{ unsubscribe_message|escape }}</h3>
<p>You have been successfully unsubscribed from our email list.</p>
<p>Changed your mind? <a href="{% url 'django_aws_ses:aws_ses_unsubscribe' uuid=uuid hash=hash %}?resubscribe=1">Re-subscribe</a></p>
<p>{{ user_email|escape }} has been {% if request.GET.resubscribe %}re-subscribed{% else %}unsubscribed{% endif %} from our email list.</p>
<p>Return to <a href="{{ home_url|default:'/' }}">home</a>.</p>
{% else %}
<h3>{{ confirmation_message|escape }}</h3>
<p>Please confirm your subscription preference for your email.</p>
<form method="post" action="">
{% csrf_token %}
<button type="submit" name="action" value="unsubscribe">Unsubscribe</button>
<button type="submit" name="action" value="resubscribe">Re-subscribe</button>
</form>
<p>Return to <a href="{{ home_url|default:'/' }}">home</a>.</p>
{% endif %}
</div>
{% endblock content %}

137
django_aws_ses/tests.py Normal file
View File

@ -0,0 +1,137 @@
from django.test import TestCase, RequestFactory, override_settings
from django.core.mail import EmailMessage
from django.contrib.auth import get_user_model
from django.urls import reverse
from django.utils.http import urlsafe_base64_encode
from django.utils.encoding import force_bytes
from unittest.mock import patch, Mock
import json
from .backends import SESBackend
from .views import handle_bounce, HandleUnsubscribe
from .utils import filter_recipients
from .models import BounceRecord, ComplaintRecord, AwsSesUserAddon
User = get_user_model()
@override_settings(
AWS_SES_ACCESS_KEY_ID='test-key',
AWS_SES_SECRET_ACCESS_KEY='test-secret',
AWS_SES_REGION_NAME='us-east-1',
AWS_SES_REGION_ENDPOINT='email.us-east-1.amazonaws.com',
EMAIL_BACKEND='django_aws_ses.backends.SESBackend',
SES_BOUNCE_LIMIT=1,
)
class DjangoAwsSesTests(TestCase):
def setUp(self):
self.factory = RequestFactory()
self.user = User.objects.create_user(
username='testuser', email='test@example.com', password='testpass'
)
self.ses_addon = AwsSesUserAddon.objects.create(user=self.user)
@patch('boto3.client')
def test_email_sending(self, mock_boto_client):
"""Test sending an email via SESBackend."""
mock_ses = Mock()
mock_ses.send_raw_email.return_value = {
'MessageId': 'test-id',
'ResponseMetadata': {'RequestId': 'test-request-id'}
}
mock_boto_client.return_value = mock_ses
backend = SESBackend()
message = EmailMessage(
subject='Test',
body='Hello',
from_email='from@example.com',
to=['to@example.com']
)
sent, _ = backend.send_messages([message])
self.assertEqual(sent, 1)
self.assertEqual(message.extra_headers['message_id'], 'test-id')
def test_bounce_handling(self):
"""Test handling an SNS bounce notification."""
notification = {
'Type': 'Notification',
'Message': json.dumps({
'notificationType': 'Bounce',
'mail': {'destination': ['test@example.com']},
'bounce': {
'feedbackId': 'test-feedback',
'bounceType': 'Permanent',
'bounceSubType': 'General',
'bouncedRecipients': [{'emailAddress': 'test@example.com'}]
}
})
}
request = self.factory.post('/aws_ses/bounce/', data=notification, content_type='application/json')
with patch('django_aws_ses.utils.verify_bounce_message', return_value=True):
response = handle_bounce(request)
self.assertEqual(response.status_code, 200)
self.assertTrue(BounceRecord.objects.filter(email='test@example.com').exists())
def test_complaint_handling(self):
"""Test handling an SNS complaint notification."""
notification = {
'Type': 'Notification',
'Message': json.dumps({
'notificationType': 'Complaint',
'mail': {'destination': ['test@example.com']},
'complaint': {
'feedbackId': 'test-feedback',
'complaintFeedbackType': 'abuse',
'complainedRecipients': [{'emailAddress': 'test@example.com'}]
}
})
}
request = self.factory.post('/aws_ses/bounce/', data=notification, content_type='application/json')
with patch('django_aws_ses.utils.verify_bounce_message', return_value=True):
response = handle_bounce(request)
self.assertEqual(response.status_code, 200)
self.assertTrue(ComplaintRecord.objects.filter(email='test@example.com').exists())
def test_unsubscribe_confirmation(self):
"""Test unsubscribe confirmation page and action."""
uuid = urlsafe_base64_encode(force_bytes(str(self.user.pk)))
hash_value = self.ses_addon.unsubscribe_hash_generator()
url = reverse('django_aws_ses:aws_ses_unsubscribe', kwargs={'uuid': uuid, 'hash': hash_value})
# Test GET (confirmation page)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Please confirm your subscription preference')
self.assertFalse(self.ses_addon.unsubscribe)
# Test POST (unsubscribe)
response = self.client.post(url, {'action': 'unsubscribe'})
self.ses_addon.refresh_from_db()
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'You have been unsubscribed')
self.assertTrue(self.ses_addon.unsubscribe)
def test_resubscribe_confirmation(self):
"""Test re-subscribe confirmation action."""
self.ses_addon.unsubscribe = True
self.ses_addon.save()
uuid = urlsafe_base64_encode(force_bytes(str(self.user.pk)))
hash_value = self.ses_addon.unsubscribe_hash_generator()
url = reverse('django_aws_ses:aws_ses_unsubscribe', kwargs={'uuid': uuid, 'hash': hash_value})
response = self.client.post(url, {'action': 'resubscribe'})
self.ses_addon.refresh_from_db()
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'You have been re-subscribed')
self.assertFalse(self.ses_addon.unsubscribe)
def test_recipient_filtering(self):
"""Test recipient filtering for blacklisted emails."""
BounceRecord.objects.create(email='bounce@example.com')
ComplaintRecord.objects.create(email='complaint@example.com')
recipients = ['test@example.com', 'bounce@example.com', 'complaint@example.com']
filtered = filter_recipients(recipients)
self.assertEqual(filtered, ['test@example.com'])

View File

@ -13,6 +13,7 @@ from django.utils.encoding import force_bytes, force_str
from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
from django.views.decorators.http import require_POST
from django.views.generic.base import TemplateView
from django.views.decorators.csrf import csrf_protect
from . import settings
from . import signals
@ -287,21 +288,25 @@ def handle_bounce(request):
class HandleUnsubscribe(TemplateView):
"""View to handle email unsubscribe requests."""
http_method_names = ['get']
"""View to handle email unsubscribe and re-subscribe requests with confirmation."""
http_method_names = ['get', 'post']
template_name = settings.UNSUBSCRIBE_TEMPLATE
base_template_name = settings.BASE_TEMPLATE
unsubscribe_message = "We Have Unsubscribed the Following Email"
confirmation_message = "Please confirm your email subscription preference"
unsubscribe_message = "You have been unsubscribed"
resubscribe_message = "You have been re-subscribed"
def get_context_data(self, **kwargs):
"""Add base template and unsubscribe message to context."""
"""Add base template and appropriate message to context."""
context = super().get_context_data(**kwargs)
context['base_template_name'] = self.base_template_name
context['unsubscribe_message'] = self.unsubscribe_message
context['confirmation_message'] = self.confirmation_message
context['unsubscribe_message'] = self.resubscribe_message if self.request.GET.get('resubscribe') else self.unsubscribe_message
context['user_email'] = getattr(self, 'user_email', '')
return context
def get(self, request, *args, **kwargs):
"""Process unsubscribe request and redirect if invalid."""
"""Show confirmation page for unsubscribe or re-subscribe."""
uuid = self.kwargs['uuid']
hash_value = self.kwargs['hash']
@ -317,12 +322,51 @@ class HandleUnsubscribe(TemplateView):
except AwsSesUserAddon.DoesNotExist:
ses = AwsSesUserAddon.objects.create(user=user)
if user and ses.check_unsubscribe_hash(hash_value):
ses.unsubscribe = True
ses.save()
logger.info(f"Unsubscribed user: {user.email}")
else:
if not user or not ses.check_unsubscribe_hash(hash_value):
logger.warning(f"Invalid unsubscribe hash for user: {user.email}")
return redirect(settings.HOME_URL)
self.user_email = user.email
return super().get(request, *args, **kwargs)
@csrf_protect
def post(self, request, *args, **kwargs):
"""Process unsubscribe or re-subscribe request."""
uuid = self.kwargs['uuid']
hash_value = self.kwargs['hash']
action = request.POST.get('action')
try:
uuid = force_str(urlsafe_base64_decode(uuid))
user = User.objects.get(pk=uuid)
except (TypeError, ValueError, OverflowError, User.DoesNotExist) as e:
logger.warning(f"Invalid unsubscribe UUID: {e}")
return redirect(settings.HOME_URL)
try:
ses = user.aws_ses
except AwsSesUserAddon.DoesNotExist:
ses = AwsSesUserAddon.objects.create(user=user)
if not user or not ses.check_unsubscribe_hash(hash_value):
logger.warning(f"Invalid unsubscribe hash for user: {user.email}")
return redirect(settings.HOME_URL)
if action == 'unsubscribe':
ses.unsubscribe = True
ses.save()
logger.info(f"Unsubscribed user: {user.email}")
self.user_email = user.email
self.template_name = settings.UNSUBSCRIBE_TEMPLATE
return self.get(request, *args, **kwargs)
elif action == 'resubscribe':
ses.unsubscribe = False
ses.save()
logger.info(f"Re-subscribed user: {user.email}")
self.user_email = user.email
self.request.GET = request.GET.copy()
self.request.GET['resubscribe'] = '1'
return self.get(request, *args, **kwargs)
logger.warning(f"Invalid action for user: {user.email}")
return redirect(settings.HOME_URL)

3
requirements-dev.txt Normal file
View File

@ -0,0 +1,3 @@
pytest
pytest-django
mock

View File

@ -25,8 +25,9 @@ setup(
description="A Django email backend for Amazon SES",
long_description=long_description,
long_description_content_type="text/markdown",
url="https://github.com/zeeksgeeks/django_aws_ses",
url="https://git-vault.zeeksgeeks.com/zeeksgeeks/django_aws_ses",
license="MIT",
keywords=["django", "aws", "ses", "email", "backend", "sns", "dkim"],
classifiers=[
"Development Status :: 4 - Beta",
"Framework :: Django",