diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1f6ba0c --- /dev/null +++ b/CONTRIBUTING.md @@ -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. \ No newline at end of file diff --git a/README.md b/README.md index 9a26496..dd62548 100644 --- a/README.md +++ b/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///`). +- 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///`. + +- **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). \ No newline at end of file diff --git a/django_aws_ses/static/django_aws_ses/css/unsubscribe.css b/django_aws_ses/static/django_aws_ses/css/unsubscribe.css index 2e8bddb..9e3453e 100644 --- a/django_aws_ses/static/django_aws_ses/css/unsubscribe.css +++ b/django_aws_ses/static/django_aws_ses/css/unsubscribe.css @@ -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; } \ No newline at end of file diff --git a/django_aws_ses/templates/django_aws_ses/unsubscribe.html b/django_aws_ses/templates/django_aws_ses/unsubscribe.html index 2700128..1c0648b 100644 --- a/django_aws_ses/templates/django_aws_ses/unsubscribe.html +++ b/django_aws_ses/templates/django_aws_ses/unsubscribe.html @@ -5,13 +5,23 @@ {% endblock %} -{% block title1 %}Unsubscribe{% endblock title1 %} +{% block title1 %}Email Subscription{% endblock title1 %} {% block content %}
-

{{ unsubscribe_message|escape }}

-

You have been successfully unsubscribed from our email list.

-

Changed your mind? Re-subscribe

-

Return to home.

+ {% if user_email %} +

{{ unsubscribe_message|escape }}

+

{{ user_email|escape }} has been {% if request.GET.resubscribe %}re-subscribed{% else %}unsubscribed{% endif %} from our email list.

+

Return to home.

+ {% else %} +

{{ confirmation_message|escape }}

+

Please confirm your subscription preference for your email.

+
+ {% csrf_token %} + + +
+

Return to home.

+ {% endif %}
{% endblock content %} \ No newline at end of file diff --git a/django_aws_ses/tests.py b/django_aws_ses/tests.py new file mode 100644 index 0000000..4a1ea64 --- /dev/null +++ b/django_aws_ses/tests.py @@ -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']) \ No newline at end of file diff --git a/django_aws_ses/views.py b/django_aws_ses/views.py index 8ccf309..731b782 100644 --- a/django_aws_ses/views.py +++ b/django_aws_ses/views.py @@ -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) - return super().get(request, *args, **kwargs) \ No newline at end of file + 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) \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..ae0034f --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +pytest +pytest-django +mock \ No newline at end of file diff --git a/setup.py b/setup.py index bedad5e..b655157 100644 --- a/setup.py +++ b/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",