updates to views.py

This commit is contained in:
Raymond Jessop 2025-04-18 14:39:19 -05:00
parent 0d9aa2f4a7
commit 4a2e9342ea
1 changed files with 220 additions and 346 deletions

View File

@ -1,46 +1,30 @@
import json
import boto3
import pytz
try:
from urllib.request import urlopen
from urllib.error import URLError
except ImportError:
from urllib2 import urlopen, URLError
import copy
import logging
from datetime import datetime
from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode
from django.http import HttpResponse, HttpResponseBadRequest
from django.views.decorators.http import require_POST
import boto3
import pytz
from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.core.exceptions import PermissionDenied
from django.http import HttpResponse, HttpResponseBadRequest
from django.shortcuts import render, redirect
from django.contrib.auth import get_user_model
from django.views.generic.base import TemplateView
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 . import settings
from . import signals
from . import utils
from .models import (
BounceRecord,
ComplaintRecord,
SendRecord,
UnknownRecord,
AwsSesUserAddon
)
from .models import BounceRecord, ComplaintRecord, SendRecord, UnknownRecord, AwsSesUserAddon
logger = settings.logger
User = get_user_model()
def superuser_only(view_func):
"""
Limit a view to superuser only.
"""
"""Decorator to restrict a view to superusers only."""
def _inner(request, *args, **kwargs):
if not request.user.is_superuser:
raise PermissionDenied
@ -49,66 +33,73 @@ def superuser_only(view_func):
def stats_to_list(stats_dict, localize=pytz):
"""Parse SES send statistics into an ordered list of 15-minute summaries.
Args:
stats_dict (dict): Raw SES statistics data.
localize (module): Timezone module (default: pytz).
Returns:
list: Sorted list of datapoints with localized timestamps.
"""
Parse the output of ``SESConnection.get_send_statistics()`` in to an
ordered list of 15-minute summaries.
"""
# Make a copy, so we don't change the original stats_dict.
result = copy.deepcopy(stats_dict)
datapoints = []
if localize:
current_tz = localize.timezone(settings.TIME_ZONE)
else:
current_tz = None
for dp in result['SendDataPoints']:
current_tz = localize.timezone(settings.TIME_ZONE) if localize else None
for dp in stats_dict['SendDataPoints']:
if current_tz:
utc_dt = dp['Timestamp']
dp['Timestamp'] = current_tz.normalize(
utc_dt.astimezone(current_tz))
dp['Timestamp'] = current_tz.normalize(utc_dt.astimezone(current_tz))
datapoints.append(dp)
datapoints.sort(key=lambda x: x['Timestamp'])
return datapoints
return sorted(datapoints, key=lambda x: x['Timestamp'])
def emails_parse(emails_dict):
"""Parse SES verified email addresses into a sorted list.
Args:
emails_dict (dict): Raw SES verified email data.
Returns:
list: Sorted list of verified email addresses.
"""
Parse the output of ``SESConnection.list_verified_emails()`` and get
a list of emails.
"""
return sorted([email for email in emails_dict['VerifiedEmailAddresses']])
return sorted(emails_dict['VerifiedEmailAddresses'])
def sum_stats(stats_data):
"""
Summarize the bounces, complaints, delivery attempts and rejects from a
list of datapoints.
"""
t_bounces = 0
t_complaints = 0
t_delivery_attempts = 0
t_rejects = 0
for dp in stats_data:
t_bounces += dp['Bounces']
t_complaints += dp['Complaints']
t_delivery_attempts += dp['DeliveryAttempts']
t_rejects += dp['Rejects']
"""Summarize SES statistics from a list of datapoints.
return {
'Bounces': t_bounces,
'Complaints': t_complaints,
'DeliveryAttempts': t_delivery_attempts,
'Rejects': t_rejects,
Args:
stats_data (list): List of SES datapoints.
Returns:
dict: Summary of bounces, complaints, delivery attempts, and rejects.
"""
summary = {
'Bounces': 0,
'Complaints': 0,
'DeliveryAttempts': 0,
'Rejects': 0,
}
for dp in stats_data:
summary['Bounces'] += dp['Bounces']
summary['Complaints'] += dp['Complaints']
summary['DeliveryAttempts'] += dp['DeliveryAttempts']
summary['Rejects'] += dp['Rejects']
return summary
@superuser_only
def dashboard(request):
"""Display SES send statistics dashboard for superusers.
Args:
request: HTTP request object.
Returns:
HttpResponse: Rendered dashboard with SES statistics.
"""
Graph SES send statistics over time.
"""
cache_key = 'vhash:django_aws_ses_status'
cache_key = 'django_aws_ses_status'
cached_view = cache.get(cache_key)
if cached_view:
return cached_view
@ -118,337 +109,220 @@ def dashboard(request):
aws_access_key_id=settings.ACCESS_KEY,
aws_secret_access_key=settings.SECRET_KEY,
region_name=settings.AWS_SES_REGION_NAME,
endpoint_url=settings.AWS_SES_REGION_ENDPOINT_URL,
endpoint_url=settings.AWS_SES_REGION_ENDPOINT,
)
try:
quota_dict = ses_conn.get_send_quota()
verified_emails_dict = ses_conn.list_verified_email_addresses()
stats = ses_conn.get_send_statistics()
except Exception as e:
logger.error(f"Failed to fetch SES statistics: {e}")
return HttpResponseBadRequest("Failed to fetch SES statistics")
verified_emails = emails_parse(verified_emails_dict)
ordered_data = stats_to_list(stats)
summary = sum_stats(ordered_data)
extra_context = {
context = {
'title': 'SES Statistics',
'datapoints': ordered_data,
'24hour_quota': quota_dict['Max24HourSend'],
'24hour_sent': quota_dict['SentLast24Hours'],
'24hour_remaining':
quota_dict['Max24HourSend'] -
quota_dict['SentLast24Hours'],
'24hour_remaining': quota_dict['Max24HourSend'] - quota_dict['SentLast24Hours'],
'persecond_rate': quota_dict['MaxSendRate'],
'verified_emails': verified_emails,
'summary': summary,
'access_key': settings.ACCESS_KEY,
'local_time': True,
}
response = render(request, 'django_aws_ses/send_stats.html', extra_context)
response = render(request, 'django_aws_ses/send_stats.html', context)
cache.set(cache_key, response, 60 * 15) # Cache for 15 minutes
return response
@require_POST
def handle_bounce(request):
"""Handle AWS SES/SNS bounce, complaint, or delivery notifications.
Args:
request: HTTP request object with SNS notification JSON.
Returns:
HttpResponse: HTTP 200 for successful processing, 400 for invalid JSON.
"""
Handle a bounced email via an SNS webhook.
Parse the bounced message and send the appropriate signal.
For bounce messages the bounce_received signal is called.
For complaint messages the complaint_received signal is called.
See: http://docs.aws.amazon.com/sns/latest/gsg/json-formats.html#http-subscription-confirmation-json
See: http://docs.amazonwebservices.com/ses/latest/DeveloperGuide/NotificationsViaSNS.html
In addition to email bounce requests this endpoint also supports the SNS
subscription confirmation request. This request is sent to the SNS
subscription endpoint when the subscription is registered.
See: http://docs.aws.amazon.com/sns/latest/gsg/Subscribe.html
For the format of the SNS subscription confirmation request see this URL:
http://docs.aws.amazon.com/sns/latest/gsg/json-formats.html#http-subscription-confirmation-json
SNS message signatures are verified by default. This functionality can
be disabled by setting AWS_SES_VERIFY_BOUNCE_SIGNATURES to False.
However, this is not recommended.
See: http://docs.amazonwebservices.com/sns/latest/gsg/SendMessageToHttp.verify.signature.html
"""
logger.warning(u'Received SNS call back')
raw_json = request.body
logger.info("Received SNS callback")
try:
notification = json.loads(raw_json.decode('utf-8'))
except ValueError as e:
# TODO: What kind of response should be returned here?
logger.warning(u'Received bounce with bad JSON: "%s"', e)
return HttpResponseBadRequest()
notification = json.loads(request.body.decode('utf-8'))
except (ValueError, UnicodeDecodeError) as e:
logger.warning(f"Invalid SNS notification JSON: {e}")
return HttpResponseBadRequest("Invalid JSON")
# Verify the authenticity of the bounce message.
if (settings.VERIFY_BOUNCE_SIGNATURES and
not utils.verify_bounce_message(notification)):
# Don't send any info back when the notification is not
# verified. Simply, don't process it.
logger.info(
u'Received unverified notification: Type: %s',
notification.get('Type'),
extra={
'notification': notification,
},
)
if settings.VERIFY_BOUNCE_SIGNATURES and not utils.verify_bounce_message(notification):
logger.warning(f"Unverified SNS notification: Type={notification.get('Type')}")
return HttpResponse()
logger.info('notification.get("Type"): %s' % notification.get("Type"))
if notification.get('Type') in ('SubscriptionConfirmation',
'UnsubscribeConfirmation'):
# Process the (un)subscription confirmation.
logger.info(
u'Received subscription confirmation: TopicArn: %s',
notification.get('TopicArn'),
extra={
'notification': notification,
},
)
# Get the subscribe url and hit the url to confirm the subscription.
notification_type = notification.get('Type')
if notification_type in ('SubscriptionConfirmation', 'UnsubscribeConfirmation'):
logger.info(f"Received {notification_type}: TopicArn={notification.get('TopicArn')}")
subscribe_url = notification.get('SubscribeURL')
if subscribe_url:
try:
urlopen(subscribe_url).read()
except URLError as e:
# Some kind of error occurred when confirming the request.
logger.error(
u'Could not confirm subscription: "%s"', e,
extra={
'notification': notification,
},
exc_info=True,
)
elif notification.get('Type') == 'Notification':
import requests
response = requests.get(subscribe_url)
response.raise_for_status()
except requests.RequestException as e:
logger.error(f"Failed to confirm {notification_type}: {e}")
return HttpResponse()
if notification_type != 'Notification':
UnknownRecord.objects.create(event_type=notification_type, aws_data=str(notification))
logger.info(f"Received unknown notification type: {notification_type}")
return HttpResponse()
try:
message = json.loads(notification['Message'])
except ValueError as e:
# The message isn't JSON.
# Just ignore the notification.
logger.warning(u'Received bounce with bad JSON: "%s"', e, extra={
'notification': notification,
})
logger.warning(f"Invalid message JSON in notification: {e}")
return HttpResponse()
else:
mail_obj = message.get('mail', {})
event_type = message.get('notificationType', message.get('eventType', 'Unknown'))
mail_obj = message.get('mail')
event_type = message.get('notificationType', message.get('eventType'))
logger.info('event_type: %s' % event_type)
if event_type == 'Bounce':
# Bounce
bounce_obj = message.get('bounce', {})
# Logging
feedback_id = bounce_obj.get('feedbackId')
bounce_type = bounce_obj.get('bounceType')
bounce_subtype = bounce_obj.get('bounceSubType')
bounce_recipients = bounce_obj.get('bouncedRecipients', [])
logger.info(
u'Received bounce notification: feedbackId: %s, bounceType: %s, bounceSubType: %s',
feedback_id, bounce_type, bounce_subtype,
extra={
'notification': notification,
},
)
logger.info(f"Received bounce: feedbackId={feedback_id}, type={bounce_type}, subtype={bounce_subtype}")
# create a BounceRecord so we can keep from sending to bad emails.
logger.info('create records')
for recipient in bounce_recipients:
logger.info('recipient: %s' % recipient)
for recipient in bounce_obj.get('bouncedRecipients', []):
BounceRecord.objects.create(
email = recipient.get('emailAddress', None),
status = recipient.get('status', None),
action = recipient.get('action', None),
diagnostic_code = recipient.get('diagnosticCode', None),
bounce_type = bounce_obj.get('bounceType', None),
bounce_sub_type = bounce_obj.get('bounceSubType', None),
feedback_id = bounce_obj.get('feedbackId', None),
reporting_mta = bounce_obj.get('reportingMTA', None),
email=recipient.get('emailAddress'),
status=recipient.get('status'),
action=recipient.get('action'),
diagnostic_code=recipient.get('diagnosticCode'),
bounce_type=bounce_type,
bounce_sub_type=bounce_subtype,
feedback_id=feedback_id,
reporting_mta=bounce_obj.get('reportingMTA'),
)
signals.bounce_received.send(
sender=handle_bounce,
mail_obj=mail_obj,
bounce_obj=bounce_obj,
raw_message=raw_json,
raw_message=request.body,
)
elif event_type == 'Complaint':
# Complaint
complaint_obj = message.get('complaint', {})
# Logging
feedback_id = complaint_obj.get('feedbackId')
feedback_type = complaint_obj.get('complaintFeedbackType')
complaint_recipients = complaint_obj.get('complainedRecipients')
logger.info('create records')
for recipient in complaint_recipients:
logger.info('recipient: %s' % recipient)
ComplaintRecord.objects.create(
email = recipient.get('emailAddress', None),
sub_type = complaint_obj.get('complaintSubType', None),
feedback_id = complaint_obj.get('feedbackId', None),
feedback_type = complaint_obj.get('complaintFeedbackType', None),
)
logger.info(f"Received complaint: feedbackId={feedback_id}, type={feedback_type}")
logger.info(
u'Received complaint notification: feedbackId: %s, feedbackType: %s',
feedback_id, feedback_type,
extra={
'notification': notification,
},
for recipient in complaint_obj.get('complainedRecipients', []):
ComplaintRecord.objects.create(
email=recipient.get('emailAddress'),
sub_type=complaint_obj.get('complaintSubType'),
feedback_id=feedback_id,
feedback_type=feedback_type,
)
signals.complaint_received.send(
sender=handle_bounce,
mail_obj=mail_obj,
complaint_obj=complaint_obj,
raw_message=raw_json,
raw_message=request.body,
)
elif event_type in ['Delivery','Send']:
# Delivery
send_obj = message.get('mail', {})
logger.info('send_obj: %s' % send_obj)
source = send_obj.get('source', 'N/A')#settings.DEFAULT_FROM_EMAIL)
elif event_type in ('Delivery', 'Send'):
send_obj = mail_obj
source = send_obj.get('source', settings.DEFAULT_FROM_EMAIL)
destinations = send_obj.get('destination', [])
message_id = send_obj.get('messageId','N/A')
delivery = message.get('delivery', None)
aws_process_time = -1
smtp_response = 'N/A'
if delivery:
logger.info('we are a delivery and had a delivery key')
aws_process_time = delivery.get('processingTimeMillis',0)
message_id = send_obj.get('messageId', 'N/A')
delivery = message.get('delivery', {})
aws_process_time = delivery.get('processingTimeMillis', 0)
smtp_response = delivery.get('smtpResponse', 'N/A')
subject = send_obj.get('commonHeaders', {}).get('subject', 'N/A')
logger.info(f"Received {event_type} notification: messageId={message_id}")
common_headers = send_obj.get('commonHeaders', None)
subject = "N/A"
if common_headers:
subject = common_headers.get('subject','N/A')
status = event_type
logger.info('create records')
logger.info('destinations: %s' % destinations)
for destination in destinations:
try:
logger.info('destination: %s' % destination)
send_record, created = SendRecord.objects.get_or_create(
source = source,
destination = destination,
status = status,
message_id = message_id,
source=source,
destination=destination,
status=event_type,
message_id=message_id,
defaults={
"aws_process_time": aws_process_time,
"smtp_response": smtp_response,
"subject": subject
"subject": subject,
}
)
if send_record.subject == "N/A":
send_record.subject = subject
if send_record.smtp_response == "N/A":
send_record.smtp_response = smtp_response
if send_record.aws_process_time == -1:
if send_record.aws_process_time == 0:
send_record.aws_process_time = aws_process_time
send_record.save()
except Exception as e:
logger.info("error well trying to get_or_create record: %s" % e)
logger.info(
u'Received delivery notification: messageId: %s',
message_id,
extra={
'notification': notification,
},
)
logger.error(f"Failed to save SendRecord for {destination}: {e}")
signals.delivery_received.send(
sender=handle_bounce,
mail_obj=mail_obj,
delivery_obj=send_obj,
raw_message=raw_json,
raw_message=request.body,
)
else:
# We received an unknown notification type. Just log and
# ignore it.
UnknownRecord.objects.create(event_type=event_type, aws_data=str(notification))
logger.warning(f"Received unknown event: {event_type}")
UnknownRecord.objects.create(
event_type = eventType,
aws_data = str(notification)
)
logger.warning(u"Received unknown event", extra={
'notification': notification,
})
else:
UnknownRecord.objects.create(
eventType = notification.get('Type'),
aws_data = str(notification)
)
logger.info(
u'Received unknown notification type: %s',
notification.get('Type'),
extra={
'notification': notification,
},
)
# AWS will consider anything other than 200 to be an error response and
# resend the SNS request. We don't need that so we return 200 here.
return HttpResponse()
class HandleUnsubscribe(TemplateView):
"""View to handle email unsubscribe requests."""
http_method_names = ['get']
template_name = settings.UNSUBSCRIBE_TEMPLET
base_template_name = settings.BASE_TEMPLET
template_name = settings.UNSUBSCRIBE_TEMPLATE
base_template_name = settings.BASE_TEMPLATE
unsubscribe_message = "We Have Unsubscribed the Following Email"
def get_context_data(self, **kwargs):
"""Add base template and unsubscribe message to context."""
context = super().get_context_data(**kwargs)
logger.info("in get_context_data ----- self.base_template_name: %s" % self.base_template_name)
context['base_template_name'] = self.base_template_name
context['unsubscribe_message'] = self.unsubscribe_message
return context
def get(self, request, *args, **kwargs):
"""Process unsubscribe request and redirect if invalid."""
uuid = self.kwargs['uuid']
hash = self.kwargs['hash']
logger.info("in get ----- self.base_template_name: %s" % self.base_template_name)
hash_value = self.kwargs['hash']
try:
uuid = force_str(urlsafe_base64_decode(uuid).decode())
logger.info('uuid: %s' % uuid)
uuid = force_str(urlsafe_base64_decode(uuid))
user = User.objects.get(pk=uuid)
logger.info('user.pk: %s' % user.pk)
except (TypeError, ValueError, OverflowError, User.DoesNotExist):
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 user is not None and user.aws_ses.check_unsubscribe_hash(hash):
logger.info('ses.pk: %s' % ses.pk)
if user and ses.check_unsubscribe_hash(hash_value):
ses.unsubscribe = True
ses.save()
logger.info(f"Unsubscribed user: {user.email}")
else:
logger.warning("bad hash was provided!")
logger.warning(f"Invalid unsubscribe hash for user: {user.email}")
return redirect(settings.HOME_URL)
return super(HandleUnsubscribe, self).get(request, *args, **kwargs)
return super().get(request, *args, **kwargs)