working on cleaning up the project
This commit is contained in:
parent
4a3297c250
commit
bbfdfec0dc
|
@ -1,106 +1,153 @@
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.sites.models import Site
|
from django.contrib.sites.models import Site
|
||||||
import logging
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
|
||||||
from .models import (
|
from .models import AwsSesSettings
|
||||||
AwsSesSettings
|
|
||||||
|
# Define constants for default values
|
||||||
|
DEFAULTS = {
|
||||||
|
'AWS_SES_REGION_NAME': 'us-east-1',
|
||||||
|
'AWS_SES_REGION_ENDPOINT': 'email.us-east-1.amazonaws.com',
|
||||||
|
'AWS_SES_AUTO_THROTTLE': 0.5,
|
||||||
|
'AWS_SES_RETURN_PATH': None,
|
||||||
|
'AWS_SES_CONFIGURATION_SET': None,
|
||||||
|
'DKIM_SELECTOR': 'ses',
|
||||||
|
'DKIM_HEADERS': ('From', 'To', 'Cc', 'Subject'),
|
||||||
|
'VERIFY_BOUNCE_SIGNATURES': True,
|
||||||
|
'BOUNCE_CERT_DOMAINS': ('amazonaws.com', 'amazon.com'),
|
||||||
|
'SES_BOUNCE_LIMIT': 1,
|
||||||
|
'SES_BACKEND_DEBUG': False,
|
||||||
|
'SES_BACKEND_DEBUG_LOGFILE_FORMATTER': '%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
'DEFAULT_FROM_EMAIL': 'no_reply@example.com',
|
||||||
|
'UNSUBSCRIBE_TEMPLATE': 'django_aws_ses/unsubscribe.html',
|
||||||
|
'BASE_TEMPLATE': 'django_aws_ses/base.html',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Selectively export key settings
|
||||||
|
__all__ = (
|
||||||
|
'ACCESS_KEY', 'SECRET_KEY', 'AWS_SES_REGION_NAME', 'AWS_SES_REGION_ENDPOINT',
|
||||||
|
'AWS_SES_AUTO_THROTTLE', 'AWS_SES_RETURN_PATH', 'AWS_SES_CONFIGURATION_SET',
|
||||||
|
'DKIM_DOMAIN', 'DKIM_PRIVATE_KEY', 'DKIM_SELECTOR', 'DKIM_HEADERS',
|
||||||
|
'TIME_ZONE', 'BASE_DIR', 'SES_BOUNCE_LIMIT', 'SES_BACKEND_DEBUG',
|
||||||
|
'SES_BACKEND_DEBUG_LOGFILE_PATH', 'SES_BACKEND_DEBUG_LOGFILE_FORMATTER',
|
||||||
|
'DEFAULT_FROM_EMAIL', 'HOME_URL', 'UNSUBSCRIBE_TEMPLATE', 'BASE_TEMPLATE',
|
||||||
|
'VERIFY_BOUNCE_SIGNATURES', 'BOUNCE_CERT_DOMAINS', 'logger',
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_aws_ses_settings():
|
||||||
|
"""
|
||||||
|
Retrieve AwsSesSettings from the database for the current site.
|
||||||
|
Returns None if the settings cannot be retrieved.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return AwsSesSettings.objects.get(site_id=settings.SITE_ID)
|
||||||
|
except (AwsSesSettings.DoesNotExist, AttributeError) as e:
|
||||||
|
logger.warning("Failed to retrieve AwsSesSettings: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def configure_logger(debug, log_file_path, formatter):
|
||||||
|
"""
|
||||||
|
Configure the logger for the AWS SES app.
|
||||||
|
Sets up file logging if debug is enabled and a valid log file path is provided.
|
||||||
|
"""
|
||||||
|
logger = logging.getLogger('django_aws_ses')
|
||||||
|
logger.setLevel(logging.DEBUG if debug else logging.WARNING)
|
||||||
|
|
||||||
|
if debug and log_file_path:
|
||||||
|
# Validate log file path
|
||||||
|
log_dir = os.path.dirname(log_file_path)
|
||||||
|
if log_dir and not os.path.exists(log_dir):
|
||||||
|
os.makedirs(log_dir, exist_ok=True)
|
||||||
|
try:
|
||||||
|
handler = logging.FileHandler(log_file_path)
|
||||||
|
handler.setLevel(logging.DEBUG)
|
||||||
|
formatter = logging.Formatter(formatter)
|
||||||
|
handler.setFormatter(formatter)
|
||||||
|
logger.addHandler(handler)
|
||||||
|
except OSError as e:
|
||||||
|
logger.error("Failed to configure log file %s: %s", log_file_path, e)
|
||||||
|
|
||||||
|
return logger
|
||||||
|
|
||||||
|
# Initialize logger early to capture any setup errors
|
||||||
|
BASE_DIR = getattr(settings, 'BASE_DIR', None)
|
||||||
|
if not BASE_DIR:
|
||||||
|
raise ImproperlyConfigured(
|
||||||
|
"BASE_DIR must be defined in Django settings and point to the project root directory."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Temporary logger for setup phase
|
||||||
|
logger = logging.getLogger('django_aws_ses')
|
||||||
|
|
||||||
__all__ = ('ACCESS_KEY', 'SECRET_KEY', 'AWS_SES_REGION_NAME',
|
# Fetch AwsSesSettings from database
|
||||||
'AWS_SES_REGION_ENDPOINT', 'AWS_SES_AUTO_THROTTLE',
|
aws_ses_settings = get_aws_ses_settings()
|
||||||
'AWS_SES_RETURN_PATH', 'DKIM_DOMAIN', 'DKIM_PRIVATE_KEY',
|
|
||||||
'DKIM_SELECTOR', 'DKIM_HEADERS', 'TIME_ZONE', 'BASE_DIR',
|
|
||||||
'BOUNCE_LIMIT','SES_BACKEND_DEBUG','SES_BACKEND_DEBUG_LOGFILE_PATH',
|
|
||||||
'SES_BACKEND_DEBUG_LOGFILE_FORMATTER')
|
|
||||||
|
|
||||||
aws_ses_Settings = None
|
# AWS Credentials
|
||||||
|
ACCESS_KEY = aws_ses_settings.access_key if aws_ses_settings else getattr(
|
||||||
|
settings, 'AWS_SES_ACCESS_KEY_ID', getattr(settings, 'AWS_ACCESS_KEY_ID', None)
|
||||||
|
)
|
||||||
|
SECRET_KEY = aws_ses_settings.secret_key if aws_ses_settings else getattr(
|
||||||
|
settings, 'AWS_SES_SECRET_ACCESS_KEY', getattr(settings, 'AWS_SECRET_ACCESS_KEY', None)
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
# Validate credentials
|
||||||
aws_ses_Settings, c = AwsSesSettings.objects.get_or_create(site_id=settings.SITE_ID)
|
if not (ACCESS_KEY and SECRET_KEY):
|
||||||
except Exception as e:
|
raise ImproperlyConfigured(
|
||||||
print("AwsSesSettings does not exist: error: %s" % e)
|
"AWS SES credentials (ACCESS_KEY and SECRET_KEY) must be provided via AwsSesSettings or Django settings."
|
||||||
|
)
|
||||||
ACCESS_KEY = aws_ses_Settings.access_key if aws_ses_Settings else None
|
|
||||||
if ACCESS_KEY is None:
|
|
||||||
ACCESS_KEY = getattr(settings, 'AWS_SES_ACCESS_KEY_ID',getattr(settings, 'AWS_ACCESS_KEY_ID', None))
|
|
||||||
|
|
||||||
SECRET_KEY = aws_ses_Settings.secret_key if aws_ses_Settings else None
|
|
||||||
if SECRET_KEY is None:
|
|
||||||
SECRET_KEY = getattr(settings, 'AWS_SES_SECRET_ACCESS_KEY',getattr(settings, 'AWS_SECRET_ACCESS_KEY', None))
|
|
||||||
|
|
||||||
AWS_SES_REGION_NAME = aws_ses_Settings.region_name if aws_ses_Settings else None
|
|
||||||
if AWS_SES_REGION_NAME is None:
|
|
||||||
AWS_SES_REGION_NAME = getattr(settings, 'AWS_SES_REGION_NAME',getattr(settings, 'AWS_DEFAULT_REGION', 'us-east-1'))
|
|
||||||
|
|
||||||
AWS_SES_REGION_ENDPOINT = aws_ses_Settings.region_endpoint if aws_ses_Settings else None
|
|
||||||
if AWS_SES_REGION_ENDPOINT is None:
|
|
||||||
AWS_SES_REGION_ENDPOINT = getattr(settings, 'AWS_SES_REGION_ENDPOINT','email.us-east-1.amazonaws.com')
|
|
||||||
|
|
||||||
BASE_DIR = getattr(settings, 'BASE_DIR', None)
|
|
||||||
|
|
||||||
DEFAULT_FROM_EMAIL = ""
|
# AWS SES Configuration
|
||||||
|
AWS_SES_REGION_NAME = aws_ses_settings.region_name if aws_ses_settings else getattr(
|
||||||
|
settings, 'AWS_SES_REGION_NAME', getattr(settings, 'AWS_DEFAULT_REGION', DEFAULTS['AWS_SES_REGION_NAME'])
|
||||||
|
)
|
||||||
|
AWS_SES_REGION_ENDPOINT = aws_ses_settings.region_endpoint if aws_ses_settings else getattr(
|
||||||
|
settings, 'AWS_SES_REGION_ENDPOINT', DEFAULTS['AWS_SES_REGION_ENDPOINT']
|
||||||
|
)
|
||||||
|
AWS_SES_AUTO_THROTTLE = getattr(settings, 'AWS_SES_AUTO_THROTTLE', DEFAULTS['AWS_SES_AUTO_THROTTLE'])
|
||||||
|
AWS_SES_RETURN_PATH = getattr(settings, 'AWS_SES_RETURN_PATH', DEFAULTS['AWS_SES_RETURN_PATH'])
|
||||||
|
AWS_SES_CONFIGURATION_SET = getattr(settings, 'AWS_SES_CONFIGURATION_SET', DEFAULTS['AWS_SES_CONFIGURATION_SET'])
|
||||||
|
|
||||||
|
# DKIM Settings
|
||||||
|
DKIM_DOMAIN = getattr(settings, 'DKIM_DOMAIN', None)
|
||||||
|
DKIM_PRIVATE_KEY = getattr(settings, 'DKIM_PRIVATE_KEY', None)
|
||||||
|
DKIM_SELECTOR = getattr(settings, 'DKIM_SELECTOR', DEFAULTS['DKIM_SELECTOR'])
|
||||||
|
DKIM_HEADERS = getattr(settings, 'DKIM_HEADERS', DEFAULTS['DKIM_HEADERS'])
|
||||||
|
|
||||||
|
# Email Settings
|
||||||
try:
|
try:
|
||||||
site = Site.objects.get_current()
|
site = Site.objects.get_current()
|
||||||
|
DEFAULT_FROM_EMAIL = getattr(settings, 'DEFAULT_FROM_EMAIL', f"no-reply@{site.domain}")
|
||||||
DEFAULT_FROM_EMAIL = getattr(settings, 'DEFAULT_FROM_EMAIL', 'no_reply@%s' % site.domain)
|
except Site.DoesNotExist:
|
||||||
except Exception as e:
|
DEFAULT_FROM_EMAIL = getattr(settings, 'DEFAULT_FROM_EMAIL', DEFAULTS['DEFAULT_FROM_EMAIL'])
|
||||||
print("Site Doesn't Exist, please configure Django sites")
|
logger.warning(
|
||||||
print("Error is: %s" % e)
|
"Django sites framework not configured. Using DEFAULT_FROM_EMAIL: %s. "
|
||||||
|
"Configure the Site model or set DEFAULT_FROM_EMAIL in settings.", DEFAULT_FROM_EMAIL
|
||||||
|
)
|
||||||
|
|
||||||
HOME_URL = getattr(settings, 'HOME_URL', '')
|
HOME_URL = getattr(settings, 'HOME_URL', '')
|
||||||
|
|
||||||
if not BASE_DIR:
|
# Template Settings
|
||||||
raise RuntimeError('No BASE_DIR defined in project settings, django_aws_ses requires BASE_DIR to be defined and pointed at your root directory. i.e. BASE_DIR = os.path.dirname(os.path.abspath(__file__))')
|
UNSUBSCRIBE_TEMPLATE = getattr(settings, 'UNSUBSCRIBE_TEMPLATE', DEFAULTS['UNSUBSCRIBE_TEMPLATE'])
|
||||||
|
BASE_TEMPLATE = getattr(settings, 'BASE_TEMPLATE', DEFAULTS['BASE_TEMPLATE'])
|
||||||
|
|
||||||
UNSUBSCRIBE_TEMPLET = getattr(settings, 'UNSUBSCRIBE_TEMPLET', 'django_aws_ses/unsubscribe.html')
|
# Bounce and Verification Settings
|
||||||
BASE_TEMPLET = getattr(settings, 'UNSUBSCRIBE_TEMPLET', 'django_aws_ses/base.html')
|
VERIFY_BOUNCE_SIGNATURES = getattr(settings, 'AWS_SES_VERIFY_BOUNCE_SIGNATURES', DEFAULTS['VERIFY_BOUNCE_SIGNATURES'])
|
||||||
|
BOUNCE_CERT_DOMAINS = getattr(settings, 'AWS_SNS_BOUNCE_CERT_TRUSTED_DOMAINS', DEFAULTS['BOUNCE_CERT_DOMAINS'])
|
||||||
AWS_SES_REGION_ENDPOINT_URL = getattr(settings, 'AWS_SES_REGION_ENDPOINT_URL','https://' + AWS_SES_REGION_ENDPOINT)
|
SES_BOUNCE_LIMIT = getattr(settings, 'SES_BOUNCE_LIMIT', DEFAULTS['SES_BOUNCE_LIMIT'])
|
||||||
|
|
||||||
AWS_SES_AUTO_THROTTLE = getattr(settings, 'AWS_SES_AUTO_THROTTLE', 0.5)
|
|
||||||
AWS_SES_RETURN_PATH = getattr(settings, 'AWS_SES_RETURN_PATH', None)
|
|
||||||
|
|
||||||
# if AWS_SES_RETURN_PATH is None:
|
# Debug Settings
|
||||||
# AWS_SES_RETURN_PATH = "CF Doors <cdfbounced@zeeksgeeks.com>"
|
SES_BACKEND_DEBUG = getattr(settings, 'SES_BACKEND_DEBUG', DEFAULTS['SES_BACKEND_DEBUG'])
|
||||||
|
SES_BACKEND_DEBUG_LOGFILE_PATH = getattr(
|
||||||
AWS_SES_CONFIGURATION_SET = getattr(settings, 'AWS_SES_CONFIGURATION_SET', None)
|
settings, 'SES_BACKEND_DEBUG_LOGFILE_PATH', os.path.join(BASE_DIR, 'aws_ses.log')
|
||||||
|
)
|
||||||
DKIM_DOMAIN = getattr(settings, "DKIM_DOMAIN", None)
|
SES_BACKEND_DEBUG_LOGFILE_FORMATTER = getattr(
|
||||||
DKIM_PRIVATE_KEY = getattr(settings, 'DKIM_PRIVATE_KEY', None)
|
settings, 'SES_BACKEND_DEBUG_LOGFILE_FORMATTER', DEFAULTS['SES_BACKEND_DEBUG_LOGFILE_FORMATTER']
|
||||||
DKIM_SELECTOR = getattr(settings, 'DKIM_SELECTOR', 'ses')
|
)
|
||||||
DKIM_HEADERS = getattr(settings, 'DKIM_HEADERS', ('From', 'To', 'Cc', 'Subject'))
|
|
||||||
|
|
||||||
|
# Timezone
|
||||||
TIME_ZONE = settings.TIME_ZONE
|
TIME_ZONE = settings.TIME_ZONE
|
||||||
|
|
||||||
VERIFY_BOUNCE_SIGNATURES = getattr(settings, 'AWS_SES_VERIFY_BOUNCE_SIGNATURES', True)
|
# Configure logger with final settings
|
||||||
|
logger = configure_logger(SES_BACKEND_DEBUG, SES_BACKEND_DEBUG_LOGFILE_PATH, SES_BACKEND_DEBUG_LOGFILE_FORMATTER)
|
||||||
# Domains that are trusted when retrieving the certificate
|
|
||||||
# used to sign bounce messages.
|
|
||||||
BOUNCE_CERT_DOMAINS = getattr(settings, 'AWS_SNS_BOUNCE_CERT_TRUSTED_DOMAINS', (
|
|
||||||
'amazonaws.com',
|
|
||||||
'amazon.com',
|
|
||||||
))
|
|
||||||
|
|
||||||
SES_BOUNCE_LIMIT = getattr(settings,'BOUNCE_LIMT', 1)
|
|
||||||
|
|
||||||
SES_BACKEND_DEBUG = getattr(settings,'SES_BACKEND_DEBUG', False)
|
|
||||||
|
|
||||||
SES_BACKEND_DEBUG_LOGFILE_PATH = getattr(settings,'SES_BACKEND_DEBUG_LOGFILE_PATH', '%s/aws_ses.log' % BASE_DIR)
|
|
||||||
|
|
||||||
SES_BACKEND_DEBUG_LOGFILE_FORMATTER = getattr(settings,'SES_BACKEND_DEBUG_LOGFILE_FORMATTER', '%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
|
||||||
|
|
||||||
logger = logging.getLogger('django_aws_ses')
|
|
||||||
# logger.setLevel(logging.WARNING)
|
|
||||||
if SES_BACKEND_DEBUG:
|
|
||||||
logger.setLevel(logging.INFO)
|
|
||||||
# create a file handler
|
|
||||||
if SES_BACKEND_DEBUG_LOGFILE_PATH:
|
|
||||||
handler = logging.FileHandler(SES_BACKEND_DEBUG_LOGFILE_PATH)
|
|
||||||
handler.setLevel(logging.INFO)
|
|
||||||
# create a logging format
|
|
||||||
formatter = logging.Formatter(SES_BACKEND_DEBUG_LOGFILE_FORMATTER)
|
|
||||||
handler.setFormatter(formatter)
|
|
||||||
# add the handlers to the logger
|
|
||||||
logger.addHandler(handler)
|
|
||||||
#logger.info('something we are logging')
|
|
|
@ -1,23 +1,18 @@
|
||||||
import base64
|
import base64
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
import re
|
import re
|
||||||
import dns.resolver
|
import dns.resolver
|
||||||
from telnetlib import Telnet
|
|
||||||
from builtins import str as text
|
|
||||||
from builtins import bytes
|
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
try:
|
from urllib.parse import urlparse
|
||||||
from urllib.parse import urlparse
|
|
||||||
except ImportError:
|
|
||||||
from urlparse import urlparse
|
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from django.utils.encoding import smart_str
|
from django.utils.encoding import smart_str
|
||||||
from django.dispatch.dispatcher import receiver
|
from django.dispatch.dispatcher import receiver
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth import get_user_model # If used custom user model
|
from cryptography import x509
|
||||||
User = get_user_model()
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import padding
|
||||||
|
from cryptography.exceptions import InvalidSignature
|
||||||
|
import requests
|
||||||
|
|
||||||
from . import settings
|
from . import settings
|
||||||
from . import signals
|
from . import signals
|
||||||
|
@ -26,148 +21,146 @@ from .models import (
|
||||||
ComplaintRecord,
|
ComplaintRecord,
|
||||||
BlackListedDomains,
|
BlackListedDomains,
|
||||||
SendRecord,
|
SendRecord,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
# Logger setup
|
||||||
logger = settings.logger
|
logger = settings.logger
|
||||||
|
|
||||||
|
|
||||||
class BounceMessageVerifier(object):
|
class BounceMessageVerifier(object):
|
||||||
"""
|
"""
|
||||||
A utility class for validating bounce messages
|
Utility class for validating AWS SES/SNS bounce messages.
|
||||||
|
Verifies the message signature using the provided certificate.
|
||||||
See: http://docs.amazonwebservices.com/sns/latest/gsg/SendMessageToHttp.verify.signature.html
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, bounce_dict):
|
def __init__(self, bounce_dict):
|
||||||
"""
|
# Initialize with the bounce message dictionary
|
||||||
Creates a new bounce message from the given dict.
|
|
||||||
"""
|
|
||||||
self._data = bounce_dict
|
self._data = bounce_dict
|
||||||
self._verified = None
|
self._verified = None
|
||||||
|
self._certificate = None
|
||||||
|
|
||||||
def is_verified(self):
|
def is_verified(self):
|
||||||
"""
|
"""
|
||||||
Verifies an SES bounce message.
|
Verifies the signature of an SES bounce message.
|
||||||
|
Returns True if the signature is valid, False otherwise.
|
||||||
"""
|
"""
|
||||||
if self._verified is None:
|
if self._verified is None:
|
||||||
|
# Extract and decode the signature
|
||||||
signature = self._data.get('Signature')
|
signature = self._data.get('Signature')
|
||||||
if not signature:
|
if not signature:
|
||||||
|
logger.warning("No signature found in bounce message")
|
||||||
self._verified = False
|
self._verified = False
|
||||||
return self._verified
|
return self._verified
|
||||||
|
|
||||||
# Decode the signature from base64
|
try:
|
||||||
signature = bytes(base64.b64decode(signature))
|
signature = base64.b64decode(signature)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("Invalid base64 signature")
|
||||||
|
self._verified = False
|
||||||
|
return self._verified
|
||||||
|
|
||||||
# Get the message to sign
|
# Get the message bytes to verify against
|
||||||
sign_bytes = self._get_bytes_to_sign()
|
sign_bytes = self._get_bytes_to_sign()
|
||||||
if not sign_bytes:
|
if not sign_bytes:
|
||||||
|
logger.warning("Could not generate bytes to sign")
|
||||||
self._verified = False
|
self._verified = False
|
||||||
return self._verified
|
return self._verified
|
||||||
|
|
||||||
if not self.certificate:
|
# Load the signing certificate
|
||||||
|
certificate = self.certificate
|
||||||
|
if not certificate:
|
||||||
|
logger.warning("No valid certificate available")
|
||||||
self._verified = False
|
self._verified = False
|
||||||
return self._verified
|
return self._verified
|
||||||
|
|
||||||
# Extract the public key
|
# Verify the signature using the certificate's public key
|
||||||
pkey = self.certificate.get_pubkey()
|
try:
|
||||||
|
public_key = certificate.public_key()
|
||||||
# Use the public key to verify the signature.
|
public_key.verify(
|
||||||
pkey.verify_init()
|
signature,
|
||||||
pkey.verify_update(sign_bytes)
|
sign_bytes,
|
||||||
verify_result = pkey.verify_final(signature)
|
padding.PKCS1v15(),
|
||||||
|
hashes.SHA1() # AWS SNS uses SHA1
|
||||||
self._verified = verify_result == 1
|
)
|
||||||
|
self._verified = True
|
||||||
|
except InvalidSignature:
|
||||||
|
logger.warning("Signature verification failed: Invalid signature")
|
||||||
|
self._verified = False
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Signature verification failed: %s", e)
|
||||||
|
self._verified = False
|
||||||
|
|
||||||
return self._verified
|
return self._verified
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def certificate(self):
|
def certificate(self):
|
||||||
"""
|
"""
|
||||||
Retrieves the certificate used to sign the bounce message.
|
Fetches and loads the X.509 certificate used to sign the bounce message.
|
||||||
|
Returns None if the certificate cannot be loaded.
|
||||||
TODO: Cache the certificate based on the cert URL so we don't have to
|
|
||||||
retrieve it for each bounce message. *We would need to do it in a
|
|
||||||
secure way so that the cert couldn't be overwritten in the cache*
|
|
||||||
"""
|
"""
|
||||||
if not hasattr(self, '_certificate'):
|
if self._certificate is None:
|
||||||
cert_url = self._get_cert_url()
|
cert_url = self._get_cert_url()
|
||||||
# Only load certificates from a certain domain?
|
|
||||||
# Without some kind of trusted domain check, any old joe could
|
|
||||||
# craft a bounce message and sign it using his own certificate
|
|
||||||
# and we would happily load and verify it.
|
|
||||||
|
|
||||||
if not cert_url:
|
if not cert_url:
|
||||||
self._certificate = None
|
logger.warning("No valid certificate URL")
|
||||||
return self._certificate
|
return None
|
||||||
|
|
||||||
|
# Ensure requests is available
|
||||||
try:
|
try:
|
||||||
import requests
|
import requests
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise ImproperlyConfigured(
|
raise ImproperlyConfigured(
|
||||||
"`requests` is required for bounce message verification. "
|
"`requests` is required for bounce message verification. "
|
||||||
"Please consider installing the `django-ses` with the "
|
"Install with `pip install requests`."
|
||||||
"`bounce` extra - e.g. `pip install django-ses[bounce]`."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Ensure cryptography is available
|
||||||
try:
|
try:
|
||||||
from M2Crypto import X509
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.backends import default_backend
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise ImproperlyConfigured(
|
raise ImproperlyConfigured(
|
||||||
"`M2Crypto` is required for bounce message verification. "
|
"`cryptography` is required for bounce message verification. "
|
||||||
"Please consider installing the `django-ses` with the "
|
"Install with `pip install cryptography`."
|
||||||
"`bounce` extra - e.g. `pip install django-ses[bounce]`."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# We use requests because it verifies the https certificate
|
# Fetch the certificate
|
||||||
# when retrieving the signing certificate. If https was somehow
|
|
||||||
# hijacked then all bets are off.
|
|
||||||
response = requests.get(cert_url)
|
response = requests.get(cert_url)
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
logger.warning(u'Could not download certificate from %s: "%s"', cert_url, response.status_code)
|
logger.warning("Failed to download certificate from %s: %s", cert_url, response.status_code)
|
||||||
self._certificate = None
|
return None
|
||||||
return self._certificate
|
|
||||||
|
|
||||||
# Handle errors loading the certificate.
|
# Load the certificate
|
||||||
# If the certificate is invalid then return
|
|
||||||
# false as we couldn't verify the message.
|
|
||||||
try:
|
try:
|
||||||
self._certificate = X509.load_cert_string(response.content)
|
self._certificate = x509.load_pem_x509_certificate(response.content, default_backend())
|
||||||
except X509.X509Error as e:
|
except Exception as e:
|
||||||
logger.warning(u'Could not load certificate from %s: "%s"', cert_url, e)
|
logger.warning("Failed to load certificate from %s: %s", cert_url, e)
|
||||||
self._certificate = None
|
return None
|
||||||
|
|
||||||
return self._certificate
|
return self._certificate
|
||||||
|
|
||||||
def _get_cert_url(self):
|
def _get_cert_url(self):
|
||||||
"""
|
"""
|
||||||
Get the signing certificate URL.
|
Retrieves the certificate URL from the message, ensuring it comes from a trusted domain.
|
||||||
Only accept urls that match the domains set in the
|
Returns None if the URL is untrusted or invalid.
|
||||||
AWS_SNS_BOUNCE_CERT_TRUSTED_DOMAINS setting. Sub-domains
|
|
||||||
are allowed. i.e. if amazonaws.com is in the trusted domains
|
|
||||||
then sns.us-east-1.amazonaws.com will match.
|
|
||||||
"""
|
"""
|
||||||
cert_url = self._data.get('SigningCertURL')
|
cert_url = self._data.get('SigningCertURL')
|
||||||
if cert_url:
|
if cert_url and cert_url.startswith('https://'):
|
||||||
if cert_url.startswith('https://'):
|
url_obj = urlparse(cert_url)
|
||||||
url_obj = urlparse(cert_url)
|
for trusted_domain in settings.BOUNCE_CERT_DOMAINS:
|
||||||
for trusted_domain in settings.BOUNCE_CERT_DOMAINS:
|
parts = trusted_domain.split('.')
|
||||||
parts = trusted_domain.split('.')
|
if url_obj.netloc.split('.')[-len(parts):] == parts:
|
||||||
if url_obj.netloc.split('.')[-len(parts):] == parts:
|
return cert_url
|
||||||
return cert_url
|
logger.warning("Untrusted certificate URL: %s", cert_url)
|
||||||
logger.warning(u'Untrusted certificate URL: "%s"', cert_url)
|
|
||||||
else:
|
else:
|
||||||
logger.warning(u'No signing certificate URL: "%s"', cert_url)
|
logger.warning("No/invalid certificate URL: %s", cert_url)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _get_bytes_to_sign(self):
|
def _get_bytes_to_sign(self):
|
||||||
"""
|
"""
|
||||||
Creates the message used for signing SNS notifications.
|
Constructs the message bytes to be signed for verification.
|
||||||
This is used to verify the bounce message when it is received.
|
Returns None if the message type is unrecognized.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Depending on the message type the fields to add to the message
|
|
||||||
# differ so we handle that here.
|
|
||||||
msg_type = self._data.get('Type')
|
msg_type = self._data.get('Type')
|
||||||
if msg_type == 'Notification':
|
if msg_type == 'Notification':
|
||||||
fields_to_sign = [
|
fields_to_sign = [
|
||||||
|
@ -178,8 +171,7 @@ class BounceMessageVerifier(object):
|
||||||
'TopicArn',
|
'TopicArn',
|
||||||
'Type',
|
'Type',
|
||||||
]
|
]
|
||||||
elif (msg_type == 'SubscriptionConfirmation' or
|
elif msg_type in ('SubscriptionConfirmation', 'UnsubscribeConfirmation'):
|
||||||
msg_type == 'UnsubscribeConfirmation'):
|
|
||||||
fields_to_sign = [
|
fields_to_sign = [
|
||||||
'Message',
|
'Message',
|
||||||
'MessageId',
|
'MessageId',
|
||||||
|
@ -190,172 +182,130 @@ class BounceMessageVerifier(object):
|
||||||
'Type',
|
'Type',
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
# Unrecognized type
|
logger.warning("Unrecognized SNS message type: %s", msg_type)
|
||||||
logger.warning(u'Unrecognized SNS message Type: "%s"', msg_type)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
outbytes = StringIO()
|
outbytes = StringIO()
|
||||||
for field_name in fields_to_sign:
|
for field_name in fields_to_sign:
|
||||||
field_value = smart_str(self._data.get(field_name, ''),
|
field_value = smart_str(self._data.get(field_name, ''), errors="replace")
|
||||||
errors="replace")
|
|
||||||
if field_value:
|
if field_value:
|
||||||
outbytes.write(text(field_name))
|
outbytes.write(field_name)
|
||||||
outbytes.write(text("\n"))
|
outbytes.write("\n")
|
||||||
outbytes.write(text(field_value))
|
outbytes.write(field_value)
|
||||||
outbytes.write(text("\n"))
|
outbytes.write("\n")
|
||||||
|
|
||||||
response = outbytes.getvalue()
|
|
||||||
return bytes(response, 'utf-8')
|
|
||||||
|
|
||||||
|
return outbytes.getvalue().encode('utf-8')
|
||||||
|
|
||||||
def verify_bounce_message(msg):
|
def verify_bounce_message(msg):
|
||||||
"""
|
"""
|
||||||
Verify an SES/SNS bounce notification message.
|
Verifies an SES/SNS bounce notification message.
|
||||||
|
Returns True if valid, False otherwise.
|
||||||
"""
|
"""
|
||||||
verifier = BounceMessageVerifier(msg)
|
verifier = BounceMessageVerifier(msg)
|
||||||
return verifier.is_verified()
|
return verifier.is_verified()
|
||||||
|
|
||||||
@receiver(signals.email_pre_send)
|
@receiver(signals.email_pre_send)
|
||||||
def receiver_email_pre_send(sender, message=None, **kwargs):
|
def receiver_email_pre_send(sender, message=None, **kwargs):
|
||||||
#logger.info("receiver_email_pre_send received signal")
|
"""
|
||||||
|
Signal receiver for pre-send email processing.
|
||||||
|
Currently a no-op.
|
||||||
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def filter_recipiants(recipiant_list):
|
def filter_recipiants(recipiant_list):
|
||||||
logger.info("Starting filter_recipiants: %s" % recipiant_list)
|
"""
|
||||||
|
Filters a list of recipient email addresses to exclude invalid or blacklisted emails.
|
||||||
if type(recipiant_list) != type([]):
|
"""
|
||||||
logger.info("putting emails into a list")
|
logger.info("Starting filter_recipiants: %s", recipiant_list)
|
||||||
|
|
||||||
|
# Ensure recipient_list is a list
|
||||||
|
if not isinstance(recipiant_list, list):
|
||||||
|
logger.info("Converting recipients to list")
|
||||||
recipiant_list = [recipiant_list]
|
recipiant_list = [recipiant_list]
|
||||||
|
|
||||||
if len(recipiant_list) > 0:
|
if recipiant_list:
|
||||||
recipiant_list = filter_recipiants_with_unsubscribe(recipiant_list)
|
recipiant_list = filter_recipiants_with_unsubscribe(recipiant_list)
|
||||||
|
|
||||||
if len(recipiant_list) > 0:
|
|
||||||
recipiant_list = filter_recipiants_with_complaint_records(recipiant_list)
|
recipiant_list = filter_recipiants_with_complaint_records(recipiant_list)
|
||||||
|
|
||||||
if len(recipiant_list) > 0:
|
|
||||||
recipiant_list = filter_recipiants_with_bounce_records(recipiant_list)
|
recipiant_list = filter_recipiants_with_bounce_records(recipiant_list)
|
||||||
|
|
||||||
if len(recipiant_list) > 0:
|
|
||||||
recipiant_list = filter_recipiants_with_validater_email_domain(recipiant_list)
|
recipiant_list = filter_recipiants_with_validater_email_domain(recipiant_list)
|
||||||
|
|
||||||
logger.info("recipiant list after filter_recipiants: %s" % recipiant_list)
|
logger.info("Filtered recipient list: %s", recipiant_list)
|
||||||
return recipiant_list
|
return recipiant_list
|
||||||
|
|
||||||
def filter_recipiants_with_unsubscribe(recipiant_list):
|
def filter_recipiants_with_unsubscribe(recipiant_list):
|
||||||
"""
|
"""
|
||||||
filter message recipiants so we don't send emails to any email that have Unsubscribude
|
Removes recipients who have unsubscribed.
|
||||||
"""
|
"""
|
||||||
#logger.info("unsubscribe filter running")
|
blacklist_emails = list(set([record.email for record in User.objects.filter(aws_ses__unsubscribe=True)]))
|
||||||
|
return filter_recipiants_with_blacklist(recipiant_list, blacklist_emails) if blacklist_emails else recipiant_list
|
||||||
#logger.info("message.recipients() befor blacklist_emails filter: %s" % recipiant_list)
|
|
||||||
blacklist_emails = list(set([record.email for record in User.objects.filter(aws_ses__unsubscribe=True)]))
|
|
||||||
|
|
||||||
if blacklist_emails:
|
|
||||||
return filter_recipiants_with_blacklist(recipiant_list, blacklist_emails)
|
|
||||||
else:
|
|
||||||
return recipiant_list
|
|
||||||
|
|
||||||
def filter_recipiants_with_complaint_records(recipiant_list):
|
def filter_recipiants_with_complaint_records(recipiant_list):
|
||||||
"""
|
"""
|
||||||
filter message recipiants so we don't send emails to any email that have a ComplaintRecord
|
Removes recipients with complaint records.
|
||||||
"""
|
"""
|
||||||
#logger.info("complaint_records filter running")
|
blacklist_emails = list(set([record.email for record in ComplaintRecord.objects.filter(email__isnull=False)]))
|
||||||
|
return filter_recipiants_with_blacklist(recipiant_list, blacklist_emails) if blacklist_emails else recipiant_list
|
||||||
#logger.info("message.recipients() befor blacklist_emails filter: %s" % recipiant_list)
|
|
||||||
blacklist_emails = list(set([record.email for record in ComplaintRecord.objects.filter(email__isnull=False)]))
|
|
||||||
|
|
||||||
if blacklist_emails:
|
|
||||||
return filter_recipiants_with_blacklist(recipiant_list, blacklist_emails)
|
|
||||||
else:
|
|
||||||
return recipiant_list
|
|
||||||
|
|
||||||
def filter_recipiants_with_bounce_records(recipiant_list):
|
def filter_recipiants_with_bounce_records(recipiant_list):
|
||||||
"""
|
"""
|
||||||
filter message recipiants so we dont send emails to any email that has more BounceRecord
|
Removes recipients with bounce records exceeding SES_BOUNCE_LIMIT.
|
||||||
the SES_BOUNCE_LIMIT
|
|
||||||
"""
|
"""
|
||||||
#logger.info("bounce_records filter running")
|
blacklist_emails = list(set([record.email for record in BounceRecord.objects.filter(email__isnull=False)
|
||||||
|
.annotate(total=Count('email')).filter(total__gte=settings.SES_BOUNCE_LIMIT)]))
|
||||||
#logger.info("message.recipients() befor blacklist_emails filter: %s" % recipiant_list)
|
return filter_recipiants_with_blacklist(recipiant_list, blacklist_emails) if blacklist_emails else recipiant_list
|
||||||
|
|
||||||
blacklist_emails = list(set([record.email for record in BounceRecord.objects.filter(email__isnull=False).annotate(total=Count('email')).filter(total__gte=settings.SES_BOUNCE_LIMIT)]))
|
|
||||||
|
|
||||||
if blacklist_emails:
|
|
||||||
return filter_recipiants_with_blacklist(recipiant_list, blacklist_emails)
|
|
||||||
else:
|
|
||||||
return recipiant_list
|
|
||||||
|
|
||||||
def filter_recipiants_with_blacklist(recipiant_list, blacklist_emails):
|
def filter_recipiants_with_blacklist(recipiant_list, blacklist_emails):
|
||||||
"""
|
"""
|
||||||
filter message recipiants with a list of emails you dont want to email
|
Filters out emails from a blacklist.
|
||||||
"""
|
"""
|
||||||
filtered_recipiant_list = [email for email in recipiant_list if email not in blacklist_emails]
|
return [email for email in recipiant_list if email not in blacklist_emails]
|
||||||
|
|
||||||
return filtered_recipiant_list
|
|
||||||
|
|
||||||
def filter_recipiants_with_validater_email_domain(recipiant_list):
|
def filter_recipiants_with_validater_email_domain(recipiant_list):
|
||||||
debug_flag = True
|
"""
|
||||||
|
Validates email domains for new recipients.
|
||||||
|
"""
|
||||||
sent_list = [e.destination for e in SendRecord.objects.filter(destination__in=recipiant_list).distinct("destination")]
|
sent_list = [e.destination for e in SendRecord.objects.filter(destination__in=recipiant_list).distinct("destination")]
|
||||||
|
|
||||||
test_list = [e for e in recipiant_list if e not in sent_list]
|
test_list = [e for e in recipiant_list if e not in sent_list]
|
||||||
|
|
||||||
for e in test_list:
|
for e in test_list:
|
||||||
|
|
||||||
if not validater_email_domain(e):
|
if not validater_email_domain(e):
|
||||||
recipiant_list.remove(e)
|
recipiant_list.remove(e)
|
||||||
|
|
||||||
return recipiant_list
|
return recipiant_list
|
||||||
|
|
||||||
def validater_email_domain(email):
|
def validater_email_domain(email):
|
||||||
|
"""
|
||||||
|
Checks if an email's domain has valid MX records and is not blacklisted.
|
||||||
|
"""
|
||||||
if email.find("@") < 1:
|
if email.find("@") < 1:
|
||||||
|
|
||||||
return False
|
return False
|
||||||
domain = email.split("@")[-1]
|
domain = email.split("@")[-1]
|
||||||
|
|
||||||
if BlackListedDomains.objects.filter(domain=domain).count() > 0:
|
if BlackListedDomains.objects.filter(domain=domain).exists():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
records = []
|
|
||||||
try:
|
try:
|
||||||
records = dns.resolver.query(domain, 'MX')
|
records = dns.resolver.query(domain, 'MX')
|
||||||
except dns.resolver.NoNameservers as e:
|
return len(records) > 0
|
||||||
|
except (dns.resolver.NoNameservers, dns.resolver.NoAnswer, dns.resolver.NXDOMAIN, dns.resolver.LifetimeTimeout):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except dns.resolver.NoAnswer as e:
|
|
||||||
return False
|
|
||||||
|
|
||||||
except dns.resolver.NXDOMAIN as e:
|
|
||||||
return False
|
|
||||||
|
|
||||||
except dns.resolver.LifetimeTimeout as e:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if len(records) < 1:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def emailIsValid(email):
|
def emailIsValid(email):
|
||||||
|
"""
|
||||||
resp = False
|
Validates email format using regex.
|
||||||
|
"""
|
||||||
regex = re.compile(r'([A-Za-z0-9]+[.\-_])*[A-Za-z0-9]+@[A-Za-z0-9-]+(.[A-Z|a-z]{2,})+')
|
regex = re.compile(r'([A-Za-z0-9]+[.\-_])*[A-Za-z0-9]+@[A-Za-z0-9-]+(.[A-Z|a-z]{2,})+')
|
||||||
if re.fullmatch(regex, email):
|
return bool(re.fullmatch(regex, email))
|
||||||
resp = True
|
|
||||||
|
|
||||||
return resp
|
|
||||||
|
|
||||||
def validate_email(email):
|
def validate_email(email):
|
||||||
|
"""
|
||||||
|
Validates an email address for sending.
|
||||||
|
Checks format, bounce records, complaints, and domain validity.
|
||||||
|
"""
|
||||||
if not emailIsValid(email):
|
if not emailIsValid(email):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if BounceRecord.objects.filter(email=email).count() >= settings.SES_BOUNCE_LIMIT:
|
if BounceRecord.objects.filter(email=email).count() >= settings.SES_BOUNCE_LIMIT:
|
||||||
return False
|
return False
|
||||||
|
if ComplaintRecord.objects.filter(email=email).exists():
|
||||||
if ComplaintRecord.objects.filter(email=email).count() > 0:
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return validater_email_domain(email)
|
return validater_email_domain(email)
|
Loading…
Reference in New Issue