updates to README.md addition of CONTRIBUTING.md work on tests and requirments-dev.txt
This commit is contained in:
parent
3113fb953f
commit
39a972d688
|
@ -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 repository’s 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
102
README.md
|
@ -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 Django’s `send_mail` or `EmailMessage`.
|
||||
- View SES statistics at `/aws_ses/status/` (superuser only).
|
||||
- Unsubscribe users via `/aws_ses/unsubscribe/<uuid>/<hash>/`.
|
||||
|
||||
- **Send Emails**: Use Django’s `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).
|
|
@ -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;
|
||||
}
|
|
@ -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 %}
|
|
@ -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'])
|
|
@ -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)
|
|
@ -0,0 +1,3 @@
|
|||
pytest
|
||||
pytest-django
|
||||
mock
|
3
setup.py
3
setup.py
|
@ -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",
|
||||
|
|
Loading…
Reference in New Issue