Merge pull request #105 from brack3t/v2.0-dev

Move master to v2.0
This commit is contained in:
Mike Edmunds
2015-12-02 18:51:01 -08:00
42 changed files with 1071 additions and 1592 deletions

24
.editorconfig Normal file
View File

@@ -0,0 +1,24 @@
# http://editorconfig.org
root = true
# Follow Django conventions for most files
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
# Use 2 spaces for js and text(-like) files
[*.{html,js,json,md,rst,txt,yml}]
indent_size = 2
# Makefiles always use tabs for indentation
[Makefile]
indent_style = tab
# Batch files use tabs for indentation
[*.bat]
indent_style = tab

View File

@@ -1,52 +1,38 @@
sudo: false
language: python
matrix:
include:
# Django 1.3: Python 2.6--2.7
- python: "2.6"
env: DJANGO=django==1.3
- python: "2.7"
env: DJANGO=django==1.3
# Django 1.4: Python 2.6--2.7
- python: "2.6"
env: DJANGO=django==1.4
- python: "2.7"
env: DJANGO=django==1.4
# Django 1.4: Python 2.6--2.7 (but Djrill doesn't support 2.6)
- { env: DJANGO=django==1.4, python: 2.7 }
# Django 1.5: Python 2.7, pypy
# (As of Django 1.5, Python 2.6 no longer "highly recommended",
# and Python 3.2+ support was only "experimental", so skip those.)
- python: "2.7"
env: DJANGO=django==1.5
- python: "pypy"
env: DJANGO=django==1.5
- { env: DJANGO=django==1.5, python: 2.7 }
- { env: DJANGO=django==1.5, python: pypy }
# Django 1.6: Python 2.7--3.3, pypy
- python: "2.7"
env: DJANGO=django==1.6
- python: "3.2"
env: DJANGO=django==1.6
- python: "3.3"
env: DJANGO=django==1.6
- python: "pypy"
env: DJANGO=django==1.6
- { env: DJANGO=django==1.6, python: 2.7 }
- { env: DJANGO=django==1.6, python: 3.3 }
- { env: DJANGO=django==1.6, python: pypy }
# Django 1.7: Python 2.7--3.4, pypy
- python: "2.7"
env: DJANGO=django==1.7
- python: "3.2"
env: DJANGO=django==1.7
- python: "3.3"
env: DJANGO=django==1.7
- python: "3.4"
env: DJANGO=django==1.7
- python: "pypy"
env: DJANGO=django==1.7
- { env: DJANGO=django==1.7, python: 2.7 }
- { env: DJANGO=django==1.7, python: 3.3 }
- { env: DJANGO=django==1.7, python: 3.4 }
- { env: DJANGO=django==1.7, python: pypy }
# Django 1.8: "Python 2.7 or above"
- python: "2.7"
env: DJANGO=django==1.8
- python: "3.4"
env: DJANGO=django==1.8
- python: "pypy"
env: DJANGO=django==1.8
- { env: DJANGO=django==1.8, python: 2.7 }
- { env: DJANGO=django==1.8, python: 3.4 }
- { env: DJANGO=django==1.8, python: pypy }
# Django 1.9: "Python 2.7, 3.4, or 3.5"
- { env: DJANGO=django==1.9, python: 2.7 }
- { env: DJANGO=django==1.9, python: 3.4 }
- { env: DJANGO=django==1.9, python: 3.5 }
- { env: DJANGO=django==1.9, python: pypy }
# Django 1.10 (prerelease)
#- { env: DJANGO="--pre django", python: 3.5 }
cache:
directories:
- $HOME/.cache/pip
install:
- pip install --upgrade setuptools pip
- pip install -q $DJANGO
- pip install $DJANGO
- pip install .
- pip list
script: python -Wall setup.py test

View File

@@ -17,3 +17,4 @@ Sameer Al-Sakran
Kyle Gibson
Wes Winham
nikolay-saskovets
William Hector

4
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,4 @@
Djrill is maintained by its users. Your contributions are encouraged!
Please see [Contributing](https://djrill.readthedocs.org/en/latest/contributing/)
in the Djrill documentation for more information.

View File

@@ -1,4 +1,3 @@
include README.rst AUTHORS.txt LICENSE
recursive-include djrill/templates *.html
recursive-include djrill *.py
prune djrill/tests

View File

@@ -20,6 +20,11 @@ Djrill: Mandrill Transactional Email for Django
Djrill integrates the `Mandrill <http://mandrill.com>`_ transactional
email service into Django.
**UPGRADING FROM DJRILL 1.x?**
There are some **breaking changes in Djrill 2.0**. Please see the
`upgrade instructions <http://djrill.readthedocs.org/en/latest/upgrading/>`_.
In general, Djrill "just works" with Django's built-in `django.core.mail`
package. It includes:
@@ -28,9 +33,8 @@ package. It includes:
* Mandrill-specific extensions like tags, metadata, tracking, and MailChimp templates
* Optional support for Mandrill inbound email and other webhook notifications,
via Django signals
* An optional Django admin interface
Djrill is released under the BSD license. It is tested against Django 1.3--1.8
Djrill is released under the BSD license. It is tested against Django 1.4--1.9
(including Python 3 with Django 1.6+, and PyPy support with Django 1.5+).
Djrill uses `semantic versioning <http://semver.org/>`_.

View File

@@ -1,79 +1,3 @@
from django.conf import settings
from django.contrib.admin.sites import AdminSite
from django.utils.text import capfirst
from djrill.exceptions import MandrillAPIError, NotSupportedByMandrillError, removed_in_djrill_2
from ._version import *
# This backend was developed against this API endpoint.
# You can override in settings.py, if desired.
MANDRILL_API_URL = getattr(settings, "MANDRILL_API_URL",
"https://mandrillapp.com/api/1.0")
class DjrillAdminSite(AdminSite):
# This was originally adapted from https://github.com/jsocol/django-adminplus.
# If new versions of Django break DjrillAdminSite, it's worth checking to see
# whether django-adminplus has dealt with something similar.
def __init__(self, *args, **kwargs):
removed_in_djrill_2(
"DjrillAdminSite will be removed in Djrill 2.0. "
"You should remove references to it from your code. "
"(All of its data is available in the Mandrill dashboard.)"
)
super(DjrillAdminSite, self).__init__(*args, **kwargs)
index_template = "djrill/index.html"
custom_views = []
custom_urls = []
def register_view(self, path, view, name, display_name=None):
"""Add a custom admin view.
* `path` is the path in the admin where the view will live, e.g.
http://example.com/admin/somepath
* `view` is any view function you can imagine.
* `name` is an optional pretty name for the list of custom views. If
empty, we'll guess based on view.__name__.
"""
self.custom_views.append((path, view, name, display_name))
def register_url(self, path, view, name):
self.custom_urls.append((path, view, name))
def get_urls(self):
"""Add our custom views to the admin urlconf."""
urls = super(DjrillAdminSite, self).get_urls()
try:
from django.conf.urls import include, url
except ImportError:
# Django 1.3
#noinspection PyDeprecation
from django.conf.urls.defaults import include, url
for path, view, name, display_name in self.custom_views:
urls += [
url(r'^%s$' % path, self.admin_view(view), name=name)
]
for path, view, name in self.custom_urls:
urls += [
url(r'^%s$' % path, self.admin_view(view), name=name)
]
return urls
def index(self, request, extra_context=None):
"""Make sure our list of custom views is on the index page."""
if not extra_context:
extra_context = {}
custom_list = [(path, display_name if display_name else
capfirst(view.__name__)) for path, view, name, display_name in
self.custom_views]
# Sort views alphabetically.
custom_list.sort(key=lambda x: x[1])
extra_context.update({
'custom_list': custom_list
})
return super(DjrillAdminSite, self).index(request, extra_context)
from ._version import __version__, VERSION
from .exceptions import (MandrillAPIError, MandrillRecipientsRefused,
NotSerializableForMandrillError, NotSupportedByMandrillError)

View File

@@ -1,4 +1,4 @@
VERSION = (1, 5, 0, 'dev') # Remove the 'dev' component in release branches
VERSION = (2, 0, 0, 'dev') # Remove the 'dev' component in release branches
__version__ = '.'.join([str(x) for x in VERSION[:3]]) # major.minor.patch
if len(VERSION) > 3: # x.y.z-pre.release (note the hyphen)
__version__ += '-' + '.'.join([str(x) for x in VERSION[3:]])

View File

@@ -1,17 +0,0 @@
from django.contrib import admin
from djrill.views import (DjrillIndexView, DjrillSendersListView,
DjrillTagListView,
DjrillUrlListView)
# Only try to register Djrill admin views if DjrillAdminSite
# or django-adminplus is in use
if hasattr(admin.site,'register_view'):
admin.site.register_view("djrill/senders/", DjrillSendersListView.as_view(),
"djrill_senders", "senders")
admin.site.register_view("djrill/status/", DjrillIndexView.as_view(),
"djrill_status", "status")
admin.site.register_view("djrill/tags/", DjrillTagListView.as_view(),
"djrill_tags", "tags")
admin.site.register_view("djrill/urls/", DjrillUrlListView.as_view(),
"djrill_urls", "urls")

View File

@@ -1,33 +1,89 @@
import json
from requests import HTTPError
import warnings
class MandrillAPIError(HTTPError):
"""Exception for unsuccessful response from Mandrill API."""
def __init__(self, status_code, response=None, log_message=None, *args, **kwargs):
super(MandrillAPIError, self).__init__(*args, **kwargs)
self.status_code = status_code
self.response = response # often contains helpful Mandrill info
self.log_message = log_message
class DjrillError(Exception):
"""Base class for exceptions raised by Djrill
Overrides __str__ to provide additional information about
Mandrill API call and response.
"""
def __init__(self, *args, **kwargs):
"""
Optional kwargs:
email_message: the original EmailMessage being sent
payload: data arg (*not* json-stringified) for the Mandrill send call
response: requests.Response from the send call
"""
self.email_message = kwargs.pop('email_message', None)
self.payload = kwargs.pop('payload', None)
if isinstance(self, HTTPError):
# must leave response in kwargs for HTTPError
self.response = kwargs.get('response', None)
else:
self.response = kwargs.pop('response', None)
super(DjrillError, self).__init__(*args, **kwargs)
def __str__(self):
message = "Mandrill API response %d" % self.status_code
if self.log_message:
message += "\n" + self.log_message
# Include the Mandrill response, nicely formatted, if possible
parts = [
" ".join([str(arg) for arg in self.args]),
self.describe_send(),
self.describe_response(),
]
return "\n".join(filter(None, parts))
def describe_send(self):
"""Return a string describing the Mandrill send in self.payload, or None"""
if self.payload is None:
return None
description = "Sending a message"
try:
to_emails = [to['email'] for to in self.payload['message']['to']]
description += " to %s" % ','.join(to_emails)
except KeyError:
pass
try:
description += " from %s" % self.payload['message']['from_email']
except KeyError:
pass
return description
def describe_response(self):
"""Return a formatted string of self.response, or None"""
if self.response is None:
return None
description = "Mandrill API response %d:" % self.response.status_code
try:
json_response = self.response.json()
message += "\nMandrill response:\n" + json.dumps(json_response, indent=2)
description += "\n" + json.dumps(json_response, indent=2)
except (AttributeError, KeyError, ValueError): # not JSON = ValueError
try:
message += "\nMandrill response: " + self.response.text
description += " " + self.response.text
except AttributeError:
pass
return message
return description
class NotSupportedByMandrillError(ValueError):
class MandrillAPIError(DjrillError, HTTPError):
"""Exception for unsuccessful response from Mandrill API."""
def __init__(self, *args, **kwargs):
super(MandrillAPIError, self).__init__(*args, **kwargs)
if self.response is not None:
self.status_code = self.response.status_code
class MandrillRecipientsRefused(DjrillError):
"""Exception for send where all recipients are invalid or rejected."""
def __init__(self, message=None, *args, **kwargs):
if message is None:
message = "All message recipients were rejected or invalid"
super(MandrillRecipientsRefused, self).__init__(message, *args, **kwargs)
class NotSupportedByMandrillError(DjrillError, ValueError):
"""Exception for email features that Mandrill doesn't support.
This is typically raised when attempting to send a Django EmailMessage that
@@ -43,9 +99,19 @@ class NotSupportedByMandrillError(ValueError):
"""
class RemovedInDjrill2(DeprecationWarning):
"""Functionality due for deprecation in Djrill 2.0"""
class NotSerializableForMandrillError(DjrillError, TypeError):
"""Exception for data that Djrill doesn't know how to convert to JSON.
This typically results from including something like a date or Decimal
in your merge_vars (or other Mandrill-specific EmailMessage option).
def removed_in_djrill_2(message, stacklevel=1):
warnings.warn(message, category=RemovedInDjrill2, stacklevel=stacklevel + 1)
"""
# inherits from TypeError for backwards compatibility with Djrill 1.x
def __init__(self, message=None, orig_err=None, *args, **kwargs):
if message is None:
message = "Don't know how to send this data to Mandrill. " \
"Try converting it to a string or number first."
if orig_err is not None:
message += "\n%s" % str(orig_err)
super(NotSerializableForMandrillError, self).__init__(message, *args, **kwargs)

View File

@@ -1,54 +0,0 @@
from django.core.mail import EmailMultiAlternatives
from djrill.exceptions import removed_in_djrill_2
# DjrillMessage class is deprecated as of 0.2.0, but retained for
# compatibility with existing code. (New code can just set Mandrill-specific
# options directly on an EmailMessage or EmailMultiAlternatives object.)
class DjrillMessage(EmailMultiAlternatives):
alternative_subtype = "mandrill"
def __init__(self, subject='', body='', from_email=None, to=None, bcc=None,
connection=None, attachments=None, headers=None, alternatives=None,
cc=None, from_name=None, tags=None, track_opens=True,
track_clicks=True, preserve_recipients=None):
removed_in_djrill_2(
"DjrillMessage will be removed in Djrill 2.0. "
"Use django.core.mail.EmailMultiAlternatives instead."
)
super(DjrillMessage, self).__init__(subject, body, from_email, to, bcc,
connection, attachments, headers, alternatives, cc)
if from_name:
self.from_name = from_name
if tags:
self.tags = self._set_mandrill_tags(tags)
if track_opens is not None:
self.track_opens = track_opens
if track_clicks is not None:
self.track_clicks = track_clicks
if preserve_recipients is not None:
self.preserve_recipients = preserve_recipients
def _set_mandrill_tags(self, tags):
"""
Check that all tags are below 50 chars and that they do not start
with an underscore.
Raise ValueError if an underscore tag is passed in to
alert the user. Any tag over 50 chars is left out of the list.
"""
tag_list = []
for tag in tags:
if len(tag) <= 50 and not tag.startswith("_"):
tag_list.append(tag)
elif tag.startswith("_"):
raise ValueError(
"Tags starting with an underscore are reserved for "
"internal use and will cause errors with Mandrill's API")
return tag_list

View File

@@ -1,53 +1,23 @@
import json
import mimetypes
import requests
from base64 import b64encode
from datetime import date, datetime
from email.mime.base import MIMEBase
from email.utils import parseaddr
try:
from urlparse import urljoin # python 2
except ImportError:
from urllib.parse import urljoin # python 3
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.core.mail.backends.base import BaseEmailBackend
from django.core.mail.message import sanitize_address, DEFAULT_ATTACHMENT_MIME_TYPE
# Oops: this file has the same name as our app, and cannot be renamed.
#from djrill import MANDRILL_API_URL, MandrillAPIError, NotSupportedByMandrillError
from ... import MANDRILL_API_URL, MandrillAPIError, NotSupportedByMandrillError
from ...exceptions import removed_in_djrill_2
from base64 import b64encode
from datetime import date, datetime
from email.mime.base import MIMEBase
from email.utils import parseaddr
import json
import mimetypes
import requests
def encode_date_for_mandrill(dt):
"""Format a date or datetime for use as a Mandrill API date field
datetime becomes "YYYY-MM-DD HH:MM:SS"
converted to UTC, if timezone-aware
microseconds removed
date becomes "YYYY-MM-DD 00:00:00"
anything else gets returned intact
"""
if isinstance(dt, datetime):
dt = dt.replace(microsecond=0)
if dt.utcoffset() is not None:
dt = (dt - dt.utcoffset()).replace(tzinfo=None)
return dt.isoformat(' ')
elif isinstance(dt, date):
return dt.isoformat() + ' 00:00:00'
else:
return dt
class JSONDateUTCEncoder(json.JSONEncoder):
"""[deprecated] JSONEncoder that encodes dates in string format used by Mandrill."""
def default(self, obj):
if isinstance(obj, date):
removed_in_djrill_2(
"You've used the date '%r' as a Djrill message attribute "
"(perhaps in merge vars or metadata). Djrill 2.0 will require "
"you to explicitly convert this date to a string." % obj
)
return encode_date_for_mandrill(obj)
return super(JSONDateUTCEncoder, self).default(obj)
from ..._version import __version__
from ...exceptions import (DjrillError, MandrillAPIError, MandrillRecipientsRefused,
NotSerializableForMandrillError, NotSupportedByMandrillError)
class DjrillBackend(BaseEmailBackend):
@@ -56,103 +26,223 @@ class DjrillBackend(BaseEmailBackend):
"""
def __init__(self, **kwargs):
"""
Set the API key, API url and set the action url.
"""
"""Init options from Django settings"""
super(DjrillBackend, self).__init__(**kwargs)
self.api_key = getattr(settings, "MANDRILL_API_KEY", None)
self.api_url = MANDRILL_API_URL
self.subaccount = getattr(settings, "MANDRILL_SUBACCOUNT", None)
try:
self.api_key = settings.MANDRILL_API_KEY
except AttributeError:
raise ImproperlyConfigured("Set MANDRILL_API_KEY in settings.py to use Djrill")
if not self.api_key:
raise ImproperlyConfigured("You have not set your mandrill api key "
"in the settings.py file.")
self.api_url = getattr(settings, "MANDRILL_API_URL", "https://mandrillapp.com/api/1.0")
if not self.api_url.endswith("/"):
self.api_url += "/"
self.api_send = self.api_url + "/messages/send.json"
self.api_send_template = self.api_url + "/messages/send-template.json"
self.global_settings = {}
try:
self.global_settings.update(settings.MANDRILL_SETTINGS)
except AttributeError:
pass # no MANDRILL_SETTINGS setting
except (TypeError, ValueError): # e.g., not enumerable
raise ImproperlyConfigured("MANDRILL_SETTINGS must be a dict or mapping")
try:
self.global_settings["subaccount"] = settings.MANDRILL_SUBACCOUNT
except AttributeError:
pass # no MANDRILL_SUBACCOUNT setting
self.ignore_recipient_status = getattr(settings, "MANDRILL_IGNORE_RECIPIENT_STATUS", False)
self.session = None
def open(self):
"""
Ensure we have a requests Session to connect to the Mandrill API.
Returns True if a new session was created (and the caller must close it).
"""
if self.session:
return False # already exists
try:
self.session = requests.Session()
except requests.RequestException:
if not self.fail_silently:
raise
else:
self.session.headers["User-Agent"] = "Djrill/%s %s" % (
__version__, self.session.headers.get("User-Agent", ""))
return True
def close(self):
"""
Close the Mandrill API Session unconditionally.
(You should call this only if you called open and it returned True;
else someone else created the session and will clean it up themselves.)
"""
if self.session is None:
return
try:
self.session.close()
except requests.RequestException:
if not self.fail_silently:
raise
finally:
self.session = None
def send_messages(self, email_messages):
"""
Sends one or more EmailMessage objects and returns the number of email
messages sent.
"""
if not email_messages:
return 0
num_sent = 0
for message in email_messages:
sent = self._send(message)
created_session = self.open()
if not self.session:
return 0 # exception in self.open with fail_silently
if sent:
num_sent += 1
num_sent = 0
try:
for message in email_messages:
sent = self._send(message)
if sent:
num_sent += 1
finally:
if created_session:
self.close()
return num_sent
def _send(self, message):
message.mandrill_response = None # until we have a response
if not message.recipients():
return False
api_url = self.api_send
api_params = {
"key": self.api_key,
}
try:
msg_dict = self._build_standard_message_dict(message)
self._add_mandrill_options(message, msg_dict)
if getattr(message, 'alternatives', None):
self._add_alternatives(message, msg_dict)
self._add_attachments(message, msg_dict)
api_params['message'] = msg_dict
payload = self.get_base_payload()
self.build_send_payload(payload, message)
response = self.post_to_mandrill(payload, message)
# check if template is set in message to send it via
# api url: /messages/send-template.json
if hasattr(message, 'template_name'):
api_url = self.api_send_template
api_params['template_name'] = message.template_name
api_params['template_content'] = \
self._expand_merge_vars(getattr(message, 'template_content', {}))
# add the response from mandrill to the EmailMessage so callers can inspect it
message.mandrill_response = self.parse_response(response, payload, message)
self.validate_response(message.mandrill_response, response, payload, message)
self._add_mandrill_toplevel_options(message, api_params)
except NotSupportedByMandrillError:
except DjrillError:
# every *expected* error is derived from DjrillError;
# we deliberately don't silence unexpected errors
if not self.fail_silently:
raise
return False
return True
def get_base_payload(self):
"""Return non-message-dependent payload for Mandrill send call
(The return value will be modified for the send, so must be a copy
of any shared state.)
"""
payload = {
"key": self.api_key,
}
return payload
def build_send_payload(self, payload, message):
"""Modify payload to add all message-specific options for Mandrill send call.
payload is a dict that will become the Mandrill send data
message is an EmailMessage, possibly with additional Mandrill-specific attrs
Can raise NotSupportedByMandrillError for unsupported options in message.
"""
msg_dict = self._build_standard_message_dict(message)
self._add_mandrill_options(message, msg_dict)
if getattr(message, 'alternatives', None):
self._add_alternatives(message, msg_dict)
self._add_attachments(message, msg_dict)
payload.setdefault('message', {}).update(msg_dict)
if hasattr(message, 'template_name'):
payload['template_name'] = message.template_name
payload['template_content'] = \
self._expand_merge_vars(getattr(message, 'template_content', {}))
self._add_mandrill_toplevel_options(message, payload)
def get_api_url(self, payload, message):
"""Return the correct Mandrill API url for sending payload
Override this to substitute your own logic for determining API endpoint.
"""
if 'template_name' in payload:
api_method = "messages/send-template.json"
else:
api_method = "messages/send.json"
return urljoin(self.api_url, api_method)
def serialize_payload(self, payload, message):
"""Return payload serialized to a json str.
Override this to substitute your own JSON serializer (e.g., to handle dates)
"""
return json.dumps(payload)
def post_to_mandrill(self, payload, message):
"""Post payload to correct Mandrill send API endpoint, and return the response.
payload is a dict to use as Mandrill send data
message is the original EmailMessage
return should be a requests.Response
Can raise NotSerializableForMandrillError if payload is not serializable
Can raise MandrillAPIError for HTTP errors in the post
"""
api_url = self.get_api_url(payload, message)
try:
api_data = json.dumps(api_params, cls=JSONDateUTCEncoder)
json_payload = self.serialize_payload(payload, message)
except TypeError as err:
# Add some context to the "not JSON serializable" message
if not err.args:
err.args = ('',)
err.args = (
err.args[0] + " in a Djrill message (perhaps it's a merge var?)."
" Try converting it to a string or number first.",
) + err.args[1:]
raise err
response = requests.post(api_url, data=api_data)
raise NotSerializableForMandrillError(
orig_err=err, email_message=message, payload=payload)
response = self.session.post(api_url, data=json_payload)
if response.status_code != 200:
raise MandrillAPIError(email_message=message, payload=payload, response=response)
return response
# add a mandrill_response for the sake of being explicit
message.mandrill_response = None
def parse_response(self, response, payload, message):
"""Return parsed json from Mandrill API response
if not self.fail_silently:
log_message = "Failed to send a message"
if 'to' in msg_dict:
log_message += " to " + ','.join(
to['email'] for to in msg_dict.get('to', []) if 'email' in to)
if 'from_email' in msg_dict:
log_message += " from %s" % msg_dict['from_email']
raise MandrillAPIError(
status_code=response.status_code,
response=response,
log_message=log_message)
return False
Can raise MandrillAPIError if response is not valid JSON
"""
try:
return response.json()
except ValueError:
raise MandrillAPIError("Invalid JSON in Mandrill API response",
email_message=message, payload=payload, response=response)
# add the response from mandrill to the EmailMessage so callers can inspect it
message.mandrill_response = response.json()
def validate_response(self, parsed_response, response, payload, message):
"""Validate parsed_response, raising exceptions for any problems.
return True
Extend this to provide your own validation checks.
Validation exceptions should inherit from djrill.exceptions.DjrillException
for proper fail_silently behavior.
The base version here checks for invalid or refused recipients.
"""
if self.ignore_recipient_status:
return
try:
recipient_status = [item["status"] for item in parsed_response]
except (KeyError, TypeError):
raise MandrillAPIError("Invalid Mandrill API response format",
email_message=message, payload=payload, response=response)
# Error if *all* recipients are invalid or refused
# (This behavior parallels smtplib.SMTPRecipientsRefused from Django's SMTP EmailBackend)
if all([status in ('invalid', 'rejected') for status in recipient_status]):
raise MandrillRecipientsRefused(email_message=message, payload=payload, response=response)
#
# Payload construction
#
def _build_standard_message_dict(self, message):
"""Create a Mandrill send message struct from a Django EmailMessage.
@@ -204,13 +294,15 @@ class DjrillBackend(BaseEmailBackend):
'async', 'ip_pool'
]
for attr in mandrill_attrs:
if attr in self.global_settings:
api_params[attr] = self.global_settings[attr]
if hasattr(message, attr):
api_params[attr] = getattr(message, attr)
# Mandrill attributes that require conversion:
if hasattr(message, 'send_at'):
api_params['send_at'] = encode_date_for_mandrill(message.send_at)
api_params['send_at'] = self.encode_date_for_mandrill(message.send_at)
# setting send_at in global_settings wouldn't make much sense
def _make_mandrill_to_list(self, message, recipients, recipient_type="to"):
"""Create a Mandrill 'to' field from a list of emails.
@@ -237,18 +329,26 @@ class DjrillBackend(BaseEmailBackend):
'google_analytics_domains', 'google_analytics_campaign',
'metadata']
if self.subaccount:
msg_dict['subaccount'] = self.subaccount
for attr in mandrill_attrs:
if attr in self.global_settings:
msg_dict[attr] = self.global_settings[attr]
if hasattr(message, attr):
msg_dict[attr] = getattr(message, attr)
# Allow simple python dicts in place of Mandrill
# [{name:name, value:value},...] arrays...
# Merge global and per message global_merge_vars
# (in conflicts, per-message vars win)
global_merge_vars = {}
if 'global_merge_vars' in self.global_settings:
global_merge_vars.update(self.global_settings['global_merge_vars'])
if hasattr(message, 'global_merge_vars'):
global_merge_vars.update(message.global_merge_vars)
if global_merge_vars:
msg_dict['global_merge_vars'] = \
self._expand_merge_vars(message.global_merge_vars)
self._expand_merge_vars(global_merge_vars)
if hasattr(message, 'merge_vars'):
# For testing reproducibility, we sort the recipients
msg_dict['merge_vars'] = [
@@ -283,14 +383,16 @@ class DjrillBackend(BaseEmailBackend):
if len(message.alternatives) > 1:
raise NotSupportedByMandrillError(
"Too many alternatives attached to the message. "
"Mandrill only accepts plain text and html emails.")
"Mandrill only accepts plain text and html emails.",
email_message=message)
(content, mimetype) = message.alternatives[0]
if mimetype != 'text/html':
raise NotSupportedByMandrillError(
"Invalid alternative mimetype '%s'. "
"Mandrill only accepts plain text and html emails."
% mimetype)
% mimetype,
email_message=message)
msg_dict['html'] = content
@@ -343,6 +445,7 @@ class DjrillBackend(BaseEmailBackend):
# b64encode requires bytes, so let's convert our content.
try:
# noinspection PyUnresolvedReferences
if isinstance(content, unicode):
# Python 2.X unicode string
content = content.encode(str_encoding)
@@ -361,25 +464,22 @@ class DjrillBackend(BaseEmailBackend):
}
return mandrill_attachment, is_embedded_image
@classmethod
def encode_date_for_mandrill(cls, dt):
"""Format a date or datetime for use as a Mandrill API date field
############################################################################################
# Recreate this module, but with a warning on attempts to import deprecated properties.
# This is ugly, but (surprisingly) blessed: http://stackoverflow.com/a/7668273/647002
import sys
import types
class ModuleWithDeprecatedProps(types.ModuleType):
def __init__(self, module):
self._orig_module = module # must keep a ref around, or it'll get deallocated
super(ModuleWithDeprecatedProps, self).__init__(module.__name__, module.__doc__)
self.__dict__.update(module.__dict__)
@property
def DjrillBackendHTTPError(self):
removed_in_djrill_2("DjrillBackendHTTPError will be removed in Djrill 2.0. "
"Use djrill.MandrillAPIError instead.")
return MandrillAPIError
sys.modules[__name__] = ModuleWithDeprecatedProps(sys.modules[__name__])
datetime becomes "YYYY-MM-DD HH:MM:SS"
converted to UTC, if timezone-aware
microseconds removed
date becomes "YYYY-MM-DD 00:00:00"
anything else gets returned intact
"""
if isinstance(dt, datetime):
dt = dt.replace(microsecond=0)
if dt.utcoffset() is not None:
dt = (dt - dt.utcoffset()).replace(tzinfo=None)
return dt.isoformat(' ')
elif isinstance(dt, date):
return dt.isoformat() + ' 00:00:00'
else:
return dt

View File

@@ -1,9 +0,0 @@
<div id="changelist-filter">
<h2>Tools &amp; Info</h2>
<h3>Status</h3>
{% if status %}
<p>Mandrill is <strong>UP</strong></p>
{% else %}
<p>Mandrill is <strong>DOWN</strong></p>
{% endif %}
</div>

View File

@@ -1,18 +0,0 @@
{% extends "admin/index.html" %}
{% block sidebar %}
{{ block.super }}
{% if custom_list %}
<div class="module" style="float: left; width: 498px">
<table style="width: 100%">
<caption>Djrill</caption>
<tbody>
{% for path, name in custom_list %}
<tr><td><a href="{{ path }}">{{ name|capfirst }}</a></td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}

View File

@@ -1,79 +0,0 @@
{% extends "admin/base_site.html" %}
{% load admin_list i18n %}
{% load url from future %}
{% load cycle from djrill_future %}
{% block extrastyle %}
{{ block.super }}
<link rel="stylesheet" type="text/css" href="{{ STATIC_URL }}admin/css/changelists.css" />
{{ media.css }}
{% if not actions_on_top and not actions_on_bottom %}
<style>
#changelist table thead th:first-child {width: inherit}
</style>
{% endif %}
{% endblock %}
{% block extrahead %}
{{ block.super }}
{{ media.js }}
{% endblock %}
{% block title %} Djrill Senders | {% trans "Django site admin" %}{% endblock %}
{% block bodyclass %}change-list{% endblock %}
{% if not is_popup %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="../../">
{% trans "Home" %}
</a>
&rsaquo;
Djrill
&rsaquo;
Senders
</div>
{% endblock %}
{% endif %}
{% block coltype %}flex{% endblock %}
{% block content %}
<div id="content-main">
<div class="module filtered" id="changelist">
{% block date_hierarchy %}{% endblock %}
{% block filters %}
{% include "djrill/_status.html" %}
{% endblock %}
{% block result_list %}
{% if objects %}
<div class="results">
<table cellspacing="0" id="result_list">
<thead>
<tr>
{% for header in objects.0.keys %}
<th scope="col">{{ header|capfirst }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for result in objects %}
<tr class="{% cycle 'row1' 'row2' %}">
{% for item in result.values %}
<td>{{ item }}</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}
{% block pagination %}{% endblock %}
</div>
</div>
{% endblock %}

View File

@@ -1,67 +0,0 @@
{% extends "admin/base_site.html" %}
{% load admin_list i18n %}
{% load url from future %}
{% block extrastyle %}
{{ block.super }}
<link rel="stylesheet" type="text/css" href="{{ STATIC_URL }}admin/css/changelists.css" />
{{ media.css }}
{% if not actions_on_top and not actions_on_bottom %}
<style>
#changelist table thead th:first-child {width: inherit}
</style>
{% endif %}
{% endblock %}
{% block extrahead %}
{{ block.super }}
{{ media.js }}
{% if action_form %}{% if actions_on_top or actions_on_bottom %}
<script type="text/javascript">
(function($) {
$(document).ready(function($) {
$("tr input.action-select").actions();
});
})(django.jQuery);
</script>
{% endif %}{% endif %}
{% endblock %}
{% block title %} Djrill Status | {% trans "Django site admin" %}{% endblock %}
{% block bodyclass %}change-list{% endblock %}
{% if not is_popup %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="../../">
{% trans "Home" %}
</a>
&rsaquo;
Djrill
&rsaquo;
Status
</div>
{% endblock %}
{% endif %}
{% block coltype %}flex{% endblock %}
{% block content %}
<div id="content-main">
{% block object-tools %}
{% endblock %}
<div>
{% block search %}{% endblock %}
{% block date_hierarchy %}{% endblock %}
{% block filters %}{% endblock %}
{% block pagination %}{% endblock %}
<dl>
{% for term, value in status.items %}
<dt>{{ term|capfirst }}</dt>
<dd>{{ value }}</dd>
{% endfor %}
</dl>
</div>
</div>
{% endblock %}

View File

@@ -1,91 +0,0 @@
{% extends "admin/base_site.html" %}
{% load admin_list i18n %}
{% load url from future %}
{% load cycle from djrill_future %}
{% block extrastyle %}
{{ block.super }}
<link rel="stylesheet" type="text/css" href="{{ STATIC_URL }}admin/css/changelists.css" />
{{ media.css }}
{% if not actions_on_top and not actions_on_bottom %}
<style>
#changelist table thead th:first-child {width: inherit}
</style>
{% endif %}
{% endblock %}
{% block extrahead %}
{{ block.super }}
{{ media.js }}
{% endblock %}
{% block title %} Djrill Tags | {% trans "Django site admin" %}{% endblock %}
{% block bodyclass %}change-list{% endblock %}
{% if not is_popup %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="../../">
{% trans "Home" %}
</a>
&rsaquo;
Djrill
&rsaquo;
Tags
</div>
{% endblock %}
{% endif %}
{% block coltype %}flex{% endblock %}
{% block content %}
<div id="content-main">
<div class="module filtered" id="changelist">
{% block search %} {% endblock %}
{% block date_hierarchy %}{% endblock %}
{% block filters %}
{% include "djrill/_status.html" %}
{% endblock %}
{% block result_list %}
{% if objects %}
<div class="results">
<table cellspacing="0" id="result_list">
<thead>
<tr>
<th scope="col">Tag</th>
<th scope="col">ID</th>
<th scope="col">Sent</th>
<th scope="col">Opens</th>
<th scope="col">Clicks</th>
<th scope="col">Rejects</th>
<th scope="col">Bounces</th>
<th scope="col">Complaints</th>
</tr>
</thead>
<tbody>
{% for result in objects %}
<tr class="{% cycle 'row1' 'row2' %}">
<td>{{ result.tag }}</td>
<td>{{ result.id }}</td>
<td>{{ result.sent }}</td>
<td>{{ result.opens }}</td>
<td>{{ result.clicks }}</td>
<td>{{ result.rejects }}</td>
<td>{{ result.bounces }}</td>
<td>{{ result.complaints }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}
{% block pagination %}{% endblock %}
</div>
</div>
{% endblock %}

View File

@@ -1,81 +0,0 @@
{% extends "admin/base_site.html" %}
{% load admin_list i18n %}
{% load url from future %}
{% load cycle from djrill_future %}
{% block extrastyle %}
{{ block.super }}
<link rel="stylesheet" type="text/css" href="{{ STATIC_URL }}admin/css/changelists.css" />
{{ media.css }}
{% if not actions_on_top and not actions_on_bottom %}
<style>
#changelist table thead th:first-child {width: inherit}
</style>
{% endif %}
{% endblock %}
{% block extrahead %}
{{ block.super }}
{{ media.js }}
{% endblock %}
{% block title %} Djrill URLs | {% trans "Django site admin" %}{% endblock %}
{% block bodyclass %}change-list{% endblock %}
{% if not is_popup %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="../../">
{% trans "Home" %}
</a>
&rsaquo;
Djrill
&rsaquo;
URLs
</div>
{% endblock %}
{% endif %}
{% block coltype %}flex{% endblock %}
{% block content %}
<div id="content-main">
<div class="module filtered" id="changelist">
{% block search %}{% endblock %}
{% block date_hierarchy %}{% endblock %}
{% block filters %}
{% include "djrill/_status.html" %}
{% endblock %}
{% block result_list %}
{% if objects %}
<div class="results">
<table cellspacing="0" id="result_list">
<thead>
<tr>
{% for header in objects.0.keys %}
<th scope="col">{{ header|capfirst }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for result in objects %}
<tr class="{% cycle 'row1' 'row2' %}">
{% for item in result.values %}
<td>{{ item }}</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}
{% block pagination %}{% endblock %}
</div>
</div>
{% endblock %}

View File

@@ -1,16 +0,0 @@
# Future templatetags library that is also backwards compatible with
# older versions of Django (so long as Djrill's code is compatible
# with the future behavior).
from django import template
# Django 1.8 changes autoescape behavior in cycle tag.
# Djrill has been compatible with future behavior all along.
try:
from django.templatetags.future import cycle
except ImportError:
from django.template.defaulttags import cycle
register = template.Library()
register.tag(cycle)

View File

@@ -1,8 +1,6 @@
from djrill.tests.test_admin import *
from djrill.tests.test_legacy import *
from djrill.tests.test_mandrill_send import *
from djrill.tests.test_mandrill_send_template import *
from djrill.tests.test_mandrill_webhook import *
from djrill.tests.test_mandrill_subaccounts import *
from djrill.tests.test_mandrill_integration import *
from .test_mandrill_integration import *
from .test_mandrill_send import *
from .test_mandrill_send_template import *
from .test_mandrill_session_sharing import *
from .test_mandrill_subaccounts import *
from .test_mandrill_webhook import *

View File

@@ -1,18 +0,0 @@
try:
from django.conf.urls import include, url
except ImportError:
# Django 1.3
from django.conf.urls.defaults import include, url
from django.contrib import admin
from djrill import DjrillAdminSite
# Set up the DjrillAdminSite as suggested in the docs
admin.site = DjrillAdminSite()
admin.autodiscover()
urlpatterns = [
url(r'^admin/', include(admin.site.urls)),
]

View File

@@ -1,28 +1,35 @@
import json
from mock import patch
import requests
import six
from mock import patch
from django.test import TestCase
from django.test.utils import override_settings
from .utils import BackportedAssertions, override_settings
MANDRILL_SUCCESS_RESPONSE = b"""[{
"email": "to@example.com",
"status": "sent",
"_id": "abc123",
"reject_reason": null
}]"""
@override_settings(MANDRILL_API_KEY="FAKE_API_KEY_FOR_TESTING",
EMAIL_BACKEND="djrill.mail.backends.djrill.DjrillBackend")
class DjrillBackendMockAPITestCase(TestCase, BackportedAssertions):
class DjrillBackendMockAPITestCase(TestCase):
"""TestCase that uses Djrill EmailBackend with a mocked Mandrill API"""
class MockResponse(requests.Response):
"""requests.post return value mock sufficient for DjrillBackend"""
def __init__(self, status_code=200, raw=six.b("{}"), encoding='utf-8'):
def __init__(self, status_code=200, raw=MANDRILL_SUCCESS_RESPONSE, encoding='utf-8'):
super(DjrillBackendMockAPITestCase.MockResponse, self).__init__()
self.status_code = status_code
self.encoding = encoding
self.raw = six.BytesIO(raw)
def setUp(self):
self.patch = patch('requests.post', autospec=True)
self.patch = patch('requests.Session.post', autospec=True)
self.mock_post = self.patch.start()
self.mock_post.return_value = self.MockResponse()
@@ -40,7 +47,7 @@ class DjrillBackendMockAPITestCase(TestCase, BackportedAssertions):
raise AssertionError("Mandrill API was not called")
(args, kwargs) = self.mock_post.call_args
try:
post_url = kwargs.get('url', None) or args[0]
post_url = kwargs.get('url', None) or args[1]
except IndexError:
raise AssertionError("requests.post was called without an url (?!)")
if not post_url.endswith(endpoint):
@@ -57,7 +64,7 @@ class DjrillBackendMockAPITestCase(TestCase, BackportedAssertions):
raise AssertionError("Mandrill API was not called")
(args, kwargs) = self.mock_post.call_args
try:
post_data = kwargs.get('data', None) or args[1]
post_data = kwargs.get('data', None) or args[2]
except IndexError:
raise AssertionError("requests.post was called without data")
return json.loads(post_data)

View File

@@ -1,155 +0,0 @@
import sys
import warnings
from django.test import TestCase
from django.contrib.auth.models import User
from django.contrib import admin
import six
from djrill.exceptions import RemovedInDjrill2
from djrill.tests.mock_backend import DjrillBackendMockAPITestCase
from djrill.tests.utils import override_settings
def reset_admin_site():
"""Return the Django admin globals to their original state"""
admin.site = admin.AdminSite() # restore default
if 'djrill.admin' in sys.modules:
del sys.modules['djrill.admin'] # force autodiscover to re-import
@override_settings(ROOT_URLCONF='djrill.tests.admin_urls')
class DjrillAdminTests(DjrillBackendMockAPITestCase):
"""Test the Djrill admin site"""
@classmethod
def setUpClass(cls):
super(DjrillAdminTests, cls).setUpClass()
# Other test cases may muck with the Django admin site globals,
# so return it to the default state before loading test_admin_urls
reset_admin_site()
def run(self, result=None):
with warnings.catch_warnings():
# DjrillAdminSite deprecation is tested in test_legacy
warnings.filterwarnings('ignore', category=RemovedInDjrill2,
message="DjrillAdminSite will be removed in Djrill 2.0")
# We don't care that the `cycle` template tag will be removed in Django 2.0,
# because we're planning to drop the Djrill admin templates before then.
warnings.filterwarnings('ignore', category=PendingDeprecationWarning,
message="Loading the `cycle` tag from the `future` library")
# We don't care that user messaging was deprecated in Django 1.3
# (testing artifact of our runtests.py minimal Django settings)
warnings.filterwarnings('ignore', category=DeprecationWarning,
message="The user messaging API is deprecated.")
super(DjrillAdminTests, self).run(result)
def setUp(self):
super(DjrillAdminTests, self).setUp()
# Must be authenticated staff to access admin site...
admin = User.objects.create_user('admin', 'admin@example.com', 'secret')
admin.is_staff = True
admin.save()
self.client.login(username='admin', password='secret')
def test_admin_senders(self):
self.mock_post.return_value = self.MockResponse(raw=self.mock_api_content['users/senders.json'])
response = self.client.get('/admin/djrill/senders/')
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Senders")
self.assertContains(response, "sender.example@mandrillapp.com")
def test_admin_status(self):
self.mock_post.return_value = self.MockResponse(raw=self.mock_api_content['users/info.json'])
response = self.client.get('/admin/djrill/status/')
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Status")
self.assertContains(response, "myusername")
def test_admin_tags(self):
self.mock_post.return_value = self.MockResponse(raw=self.mock_api_content['tags/list.json'])
response = self.client.get('/admin/djrill/tags/')
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Tags")
self.assertContains(response, "example-tag")
def test_admin_urls(self):
self.mock_post.return_value = self.MockResponse(raw=self.mock_api_content['urls/list.json'])
response = self.client.get('/admin/djrill/urls/')
self.assertEqual(response.status_code, 200)
self.assertContains(response, "URLs")
self.assertContains(response, "example.com/example-page")
def test_admin_index(self):
"""Make sure Djrill section is included in the admin index page"""
response = self.client.get('/admin/')
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Djrill")
mock_api_content = {
'users/senders.json': six.b('''
[
{
"address": "sender.example@mandrillapp.com",
"created_at": "2013-01-01 15:30:27",
"sent": 42, "hard_bounces": 42, "soft_bounces": 42, "rejects": 42, "complaints": 42,
"unsubs": 42, "opens": 42, "clicks": 42, "unique_opens": 42, "unique_clicks": 42
}
]
'''),
'users/info.json': six.b('''
{
"username": "myusername",
"created_at": "2013-01-01 15:30:27",
"public_id": "aaabbbccc112233",
"reputation": 42,
"hourly_quota": 42,
"backlog": 42,
"stats": {
"today": { "sent": 42, "hard_bounces": 42, "soft_bounces": 42, "rejects": 42, "complaints": 42,
"unsubs": 42, "opens": 42, "unique_opens": 42, "clicks": 42, "unique_clicks": 42 },
"last_7_days": { "sent": 42, "hard_bounces": 42, "soft_bounces": 42, "rejects": 42, "complaints": 42,
"unsubs": 42, "opens": 42, "unique_opens": 42, "clicks": 42, "unique_clicks": 42 },
"last_30_days": { "sent": 42, "hard_bounces": 42, "soft_bounces": 42, "rejects": 42, "complaints": 42,
"unsubs": 42, "opens": 42, "unique_opens": 42, "clicks": 42, "unique_clicks": 42 },
"last_60_days": { "sent": 42, "hard_bounces": 42, "soft_bounces": 42, "rejects": 42, "complaints": 42,
"unsubs": 42, "opens": 42, "unique_opens": 42, "clicks": 42, "unique_clicks": 42 },
"last_90_days": { "sent": 42, "hard_bounces": 42, "soft_bounces": 42, "rejects": 42, "complaints": 42,
"unsubs": 42, "opens": 42, "unique_opens": 42, "clicks": 42, "unique_clicks": 42 },
"all_time": { "sent": 42, "hard_bounces": 42, "soft_bounces": 42, "rejects": 42, "complaints": 42,
"unsubs": 42, "opens": 42, "unique_opens": 42, "clicks": 42, "unique_clicks": 42 }
}
}
'''),
'tags/list.json': six.b('''
[
{
"tag": "example-tag",
"reputation": 42,
"sent": 42, "hard_bounces": 42, "soft_bounces": 42, "rejects": 42, "complaints": 42,
"unsubs": 42, "opens": 42, "clicks": 42, "unique_opens": 42, "unique_clicks": 42
}
]
'''),
'urls/list.json': six.b('''
[
{
"url": "http://example.com/example-page",
"sent": 42,
"clicks": 42,
"unique_clicks": 42
}
]
'''),
}
class DjrillNoAdminTests(TestCase):
def test_admin_autodiscover_without_djrill(self):
"""Make sure autodiscover doesn't die without DjrillAdminSite"""
reset_admin_site()
admin.autodiscover() # test: this shouldn't error

View File

@@ -1,171 +0,0 @@
# Tests deprecated Djrill features
from datetime import date, datetime
import warnings
from django.core import mail
from django.test import TestCase
from djrill import MandrillAPIError, NotSupportedByMandrillError, DjrillAdminSite
from djrill.exceptions import RemovedInDjrill2
from djrill.mail import DjrillMessage
from djrill.tests.mock_backend import DjrillBackendMockAPITestCase
from djrill.tests.utils import reset_warning_registry
class DjrillBackendDeprecationTests(DjrillBackendMockAPITestCase):
def setUp(self):
reset_warning_registry()
super(DjrillBackendDeprecationTests, self).setUp()
def test_deprecated_admin_site(self):
"""Djrill 2.0 drops the custom DjrillAdminSite"""
self.assertWarnsMessage(DeprecationWarning,
"DjrillAdminSite will be removed in Djrill 2.0",
DjrillAdminSite)
def test_deprecated_json_date_encoding(self):
"""Djrill 2.0+ avoids a blanket JSONDateUTCEncoder"""
# Djrill allows dates for send_at, so shouldn't warn:
message = mail.EmailMessage('Subject', 'Body', 'from@example.com', ['to@example.com'])
message.send_at = datetime(2022, 10, 11, 12, 13, 14, 567)
self.assertNotWarns(DeprecationWarning, message.send)
# merge_vars need to be json-serializable, so should generate a warning:
message = mail.EmailMessage('Subject', 'Body', 'from@example.com', ['to@example.com'])
message.global_merge_vars = {'DATE': date(2022, 10, 11)}
self.assertWarnsMessage(DeprecationWarning,
"Djrill 2.0 will require you to explicitly convert this date to a string",
message.send)
# ... but should still encode the date (for now):
data = self.get_api_call_data()
self.assertEqual(data['message']['global_merge_vars'],
[{'name': 'DATE', 'content': "2022-10-11 00:00:00"}])
def test_deprecated_djrill_message_class(self):
"""Djrill 0.2 deprecated DjrillMessage; 2.0 will drop it"""
self.assertWarnsMessage(DeprecationWarning,
"DjrillMessage will be removed in Djrill 2.0",
DjrillMessage)
def test_deprecated_djrill_backend_http_error(self):
"""Djrill 0.2 deprecated DjrillBackendHTTPError; 2.0 will drop it"""
def try_import():
# noinspection PyUnresolvedReferences
from djrill.mail.backends.djrill import DjrillBackendHTTPError
self.assertWarnsMessage(DeprecationWarning,
"DjrillBackendHTTPError will be removed in Djrill 2.0",
try_import)
def assertWarnsMessage(self, warning, message, callable, *args, **kwds):
"""Checks that `callable` issues a warning of category `warning` containing `message`"""
with warnings.catch_warnings(record=True) as warned:
warnings.simplefilter("always")
callable(*args, **kwds)
self.assertGreater(len(warned), 0, msg="No warnings issued")
self.assertTrue(
any(issubclass(w.category, warning) and message in str(w.message) for w in warned),
msg="%r(%r) not found in %r" % (warning, message, [str(w) for w in warned]))
def assertNotWarns(self, warning, callable, *args, **kwds):
"""Checks that `callable` does not issue any warnings of category `warning`"""
with warnings.catch_warnings(record=True) as warned:
warnings.simplefilter("always")
callable(*args, **kwds)
relevant_warnings = [w for w in warned if issubclass(w.category, warning)]
self.assertEqual(len(relevant_warnings), 0,
msg="Unexpected warnings %r" % [str(w) for w in relevant_warnings])
class DjrillMessageTests(TestCase):
"""Test the DjrillMessage class (deprecated as of Djrill v0.2.0)
Maintained for compatibility with older code.
"""
def run(self, result=None):
with warnings.catch_warnings():
# DjrillMessage deprecation is tested in test_deprecated_djrill_message_class above
warnings.filterwarnings('ignore', category=RemovedInDjrill2,
message="DjrillMessage will be removed in Djrill 2.0")
def setUp(self):
self.subject = "Djrill baby djrill."
self.from_name = "Tarzan"
self.from_email = "test@example"
self.to = ["King Kong <kingkong@example.com>",
"Cheetah <cheetah@example.com", "bubbles@example.com"]
self.text_content = "Wonderful fallback text content."
self.html_content = "<h1>That's a nice HTML email right there.</h1>"
self.headers = {"Reply-To": "tarzan@example.com"}
self.tags = ["track", "this"]
def test_djrill_message_success(self):
msg = DjrillMessage(self.subject, self.text_content, self.from_email,
self.to, tags=self.tags, headers=self.headers,
from_name=self.from_name)
self.assertIsInstance(msg, DjrillMessage)
self.assertEqual(msg.body, self.text_content)
self.assertEqual(msg.recipients(), self.to)
self.assertEqual(msg.tags, self.tags)
self.assertEqual(msg.extra_headers, self.headers)
self.assertEqual(msg.from_name, self.from_name)
def test_djrill_message_html_success(self):
msg = DjrillMessage(self.subject, self.text_content, self.from_email,
self.to, tags=self.tags)
msg.attach_alternative(self.html_content, "text/html")
self.assertEqual(msg.alternatives[0][0], self.html_content)
def test_djrill_message_tag_failure(self):
with self.assertRaises(ValueError):
DjrillMessage(self.subject, self.text_content, self.from_email,
self.to, tags=["_fail"])
def test_djrill_message_tag_skip(self):
"""
Test that tags over 50 chars are not included in the tags list.
"""
tags = ["works", "awesomesauce",
"iwilltestmycodeiwilltestmycodeiwilltestmycodeiwilltestmycode"]
msg = DjrillMessage(self.subject, self.text_content, self.from_email,
self.to, tags=tags)
self.assertIsInstance(msg, DjrillMessage)
self.assertIn(tags[0], msg.tags)
self.assertIn(tags[1], msg.tags)
self.assertNotIn(tags[2], msg.tags)
def test_djrill_message_no_options(self):
"""DjrillMessage with only basic EmailMessage options should work"""
msg = DjrillMessage(self.subject, self.text_content,
self.from_email, self.to) # no Mandrill-specific options
self.assertIsInstance(msg, DjrillMessage)
self.assertEqual(msg.body, self.text_content)
self.assertEqual(msg.recipients(), self.to)
self.assertFalse(hasattr(msg, 'tags'))
self.assertFalse(hasattr(msg, 'from_name'))
self.assertFalse(hasattr(msg, 'preserve_recipients'))
class DjrillLegacyExceptionTests(TestCase):
def test_DjrillBackendHTTPError(self):
"""MandrillApiError was DjrillBackendHTTPError in 0.2.0"""
# ... and had to be imported from deep in the package:
with warnings.catch_warnings():
warnings.filterwarnings('ignore', category=RemovedInDjrill2,
message="DjrillBackendHTTPError will be removed in Djrill 2.0")
# noinspection PyUnresolvedReferences
from djrill.mail.backends.djrill import DjrillBackendHTTPError
ex = MandrillAPIError("testing")
self.assertIsInstance(ex, DjrillBackendHTTPError)
def test_NotSupportedByMandrillError(self):
"""Unsupported features used to just raise ValueError in 0.2.0"""
ex = NotSupportedByMandrillError("testing")
self.assertIsInstance(ex, ValueError)

View File

@@ -1,26 +1,23 @@
from __future__ import unicode_literals
import os
import unittest
from django.core import mail
from django.test import TestCase
from django.test.utils import override_settings
from djrill import MandrillAPIError
from djrill.tests.utils import BackportedAssertions, override_settings
try:
from unittest import skipUnless
except ImportError:
from django.utils.unittest import skipUnless
from djrill import MandrillAPIError, MandrillRecipientsRefused
MANDRILL_TEST_API_KEY = os.getenv('MANDRILL_TEST_API_KEY')
@skipUnless(MANDRILL_TEST_API_KEY,
@unittest.skipUnless(MANDRILL_TEST_API_KEY,
"Set MANDRILL_TEST_API_KEY environment variable to run integration tests")
@override_settings(MANDRILL_API_KEY=MANDRILL_TEST_API_KEY,
EMAIL_BACKEND="djrill.mail.backends.djrill.DjrillBackend")
class DjrillIntegrationTests(TestCase, BackportedAssertions):
class DjrillIntegrationTests(TestCase):
"""Mandrill API integration tests
These tests run against the **live** Mandrill API, using the
@@ -43,7 +40,7 @@ class DjrillIntegrationTests(TestCase, BackportedAssertions):
self.assertEqual(sent_count, 1)
# noinspection PyUnresolvedReferences
response = self.message.mandrill_response
self.assertEqual(response[0]['status'], 'sent') # successful send (could still bounce later)
self.assertIn(response[0]['status'], ['sent', 'queued']) # successful send (could still bounce later)
self.assertEqual(response[0]['email'], 'to@example.com')
self.assertGreater(len(response[0]['_id']), 0)
@@ -61,21 +58,41 @@ class DjrillIntegrationTests(TestCase, BackportedAssertions):
def test_invalid_to(self):
# Example of detecting when a recipient is not a valid email address
self.message.to = ['invalid@localhost']
sent_count = self.message.send()
self.assertEqual(sent_count, 1) # The send call is "successful"...
# noinspection PyUnresolvedReferences
response = self.message.mandrill_response
self.assertEqual(response[0]['status'], 'invalid') # ... but the mail is not delivered
try:
self.message.send()
except MandrillRecipientsRefused:
# Mandrill refused to deliver the mail -- message.mandrill_response will tell you why:
# noinspection PyUnresolvedReferences
response = self.message.mandrill_response
self.assertEqual(response[0]['status'], 'invalid')
else:
# Sometimes Mandrill queues these test sends
# noinspection PyUnresolvedReferences
response = self.message.mandrill_response
if response[0]['status'] == 'queued':
self.skipTest("Mandrill queued the send -- can't complete this test")
else:
self.fail("Djrill did not raise MandrillRecipientsRefused for invalid recipient")
def test_rejected_to(self):
# Example of detecting when a recipient is on Mandrill's rejection blacklist
self.message.to = ['reject@test.mandrillapp.com']
sent_count = self.message.send()
self.assertEqual(sent_count, 1) # The send call is "successful"...
# noinspection PyUnresolvedReferences
response = self.message.mandrill_response
self.assertEqual(response[0]['status'], 'rejected') # ... but the mail is not delivered
self.assertEqual(response[0]['reject_reason'], 'test') # ... and here's why
try:
self.message.send()
except MandrillRecipientsRefused:
# Mandrill refused to deliver the mail -- message.mandrill_response will tell you why:
# noinspection PyUnresolvedReferences
response = self.message.mandrill_response
self.assertEqual(response[0]['status'], 'rejected')
self.assertEqual(response[0]['reject_reason'], 'test')
else:
# Sometimes Mandrill queues these test sends
# noinspection PyUnresolvedReferences
response = self.message.mandrill_response
if response[0]['status'] == 'queued':
self.skipTest("Mandrill queued the send -- can't complete this test")
else:
self.fail("Djrill did not raise MandrillRecipientsRefused for blacklist recipient")
@override_settings(MANDRILL_API_KEY="Hey, that's not an API key!")
def test_invalid_api_key(self):

View File

@@ -2,28 +2,27 @@
from __future__ import unicode_literals
import json
import os
import re
import six
import unittest
from base64 import b64decode
from datetime import date, datetime, timedelta, tzinfo
from decimal import Decimal
from email.mime.base import MIMEBase
from email.mime.image import MIMEImage
import json
import os
import six
try:
from unittest import SkipTest
except ImportError:
from django.utils.unittest import SkipTest
from django.core import mail
from django.core.exceptions import ImproperlyConfigured
from django.core.mail import make_msgid
from django.test import TestCase
from django.test.utils import override_settings
from djrill import (MandrillAPIError, MandrillRecipientsRefused,
NotSerializableForMandrillError, NotSupportedByMandrillError)
from djrill import MandrillAPIError, NotSupportedByMandrillError
from .mock_backend import DjrillBackendMockAPITestCase
from .utils import override_settings
def decode_att(att):
@@ -158,7 +157,7 @@ class DjrillBackendTests(DjrillBackendMockAPITestCase):
headers={'X-Other': 'Keep'})
except TypeError:
# Pre-Django 1.8
raise SkipTest("Django version doesn't support EmailMessage(reply_to)")
raise unittest.SkipTest("Django version doesn't support EmailMessage(reply_to)")
email.send()
self.assert_mandrill_called("/messages/send.json")
data = self.get_api_call_data()
@@ -342,6 +341,9 @@ class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase):
self.message = mail.EmailMessage('Subject', 'Text Body',
'from@example.com', ['to@example.com'])
def assertStrContains(self, haystack, needle, msg=None):
six.assertRegex(self, haystack, re.escape(needle), msg)
def test_tracking(self):
# First make sure we're not setting the API param if the track_click
# attr isn't there. (The Mandrill account option of True for html,
@@ -518,7 +520,7 @@ class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase):
def test_send_attaches_mandrill_response(self):
""" The mandrill_response should be attached to the message when it is sent """
response = [{'mandrill_response': 'would_be_here'}]
response = [{'email': 'to1@example.com', 'status': 'sent'}]
self.mock_post.return_value = self.MockResponse(raw=six.b(json.dumps(response)))
msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com'],)
sent = msg.send()
@@ -533,15 +535,211 @@ class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase):
self.assertEqual(sent, 0)
self.assertIsNone(msg.mandrill_response)
def test_json_serialization_warnings(self):
def test_send_unparsable_mandrill_response(self):
"""If the send succeeds, but a non-JSON API response, should raise an API exception"""
self.mock_post.return_value = self.MockResponse(status_code=500, raw=b"this isn't json")
msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com'],)
with self.assertRaises(MandrillAPIError):
msg.send()
self.assertIsNone(msg.mandrill_response)
def test_json_serialization_errors(self):
"""Try to provide more information about non-json-serializable data"""
self.message.global_merge_vars = {'PRICE': Decimal('19.99')}
with self.assertRaisesMessage(
TypeError,
"Decimal('19.99') is not JSON serializable in a Djrill message (perhaps "
"it's a merge var?). Try converting it to a string or number first."
):
with self.assertRaises(NotSerializableForMandrillError) as cm:
self.message.send()
err = cm.exception
self.assertTrue(isinstance(err, TypeError)) # Djrill 1.x re-raised TypeError from json.dumps
self.assertStrContains(str(err), "Don't know how to send this data to Mandrill") # our added context
self.assertStrContains(str(err), "Decimal('19.99') is not JSON serializable") # original message
def test_dates_not_serialized(self):
"""Pre-2.0 Djrill accidentally serialized dates to ISO"""
self.message.global_merge_vars = {'SHIP_DATE': date(2015, 12, 2)}
with self.assertRaises(NotSerializableForMandrillError):
self.message.send()
class DjrillRecipientsRefusedTests(DjrillBackendMockAPITestCase):
"""Djrill raises MandrillRecipientsRefused when *all* recipients are rejected or invalid"""
def test_recipients_refused(self):
msg = mail.EmailMessage('Subject', 'Body', 'from@example.com',
['invalid@localhost', 'reject@test.mandrillapp.com'])
self.mock_post.return_value = self.MockResponse(status_code=200, raw=b"""
[{ "email": "invalid@localhost", "status": "invalid" },
{ "email": "reject@test.mandrillapp.com", "status": "rejected" }]""")
with self.assertRaises(MandrillRecipientsRefused):
msg.send()
def test_fail_silently(self):
self.mock_post.return_value = self.MockResponse(status_code=200, raw=b"""
[{ "email": "invalid@localhost", "status": "invalid" },
{ "email": "reject@test.mandrillapp.com", "status": "rejected" }]""")
sent = mail.send_mail('Subject', 'Body', 'from@example.com',
['invalid@localhost', 'reject@test.mandrillapp.com'],
fail_silently=True)
self.assertEqual(sent, 0)
def test_mixed_response(self):
"""If *any* recipients are valid or queued, no exception is raised"""
msg = mail.EmailMessage('Subject', 'Body', 'from@example.com',
['invalid@localhost', 'valid@example.com',
'reject@test.mandrillapp.com', 'also.valid@example.com'])
self.mock_post.return_value = self.MockResponse(status_code=200, raw=b"""
[{ "email": "invalid@localhost", "status": "invalid" },
{ "email": "valid@example.com", "status": "sent" },
{ "email": "reject@test.mandrillapp.com", "status": "rejected" },
{ "email": "also.valid@example.com", "status": "queued" }]""")
sent = msg.send()
self.assertEqual(sent, 1) # one message sent, successfully, to 2 of 4 recipients
@override_settings(MANDRILL_IGNORE_RECIPIENT_STATUS=True)
def test_settings_override(self):
"""Setting restores Djrill 1.x behavior"""
self.mock_post.return_value = self.MockResponse(status_code=200, raw=b"""
[{ "email": "invalid@localhost", "status": "invalid" },
{ "email": "reject@test.mandrillapp.com", "status": "rejected" }]""")
sent = mail.send_mail('Subject', 'Body', 'from@example.com',
['invalid@localhost', 'reject@test.mandrillapp.com'])
self.assertEqual(sent, 1) # refused message is included in sent count
@override_settings(MANDRILL_SETTINGS={
'from_name': 'Djrill Test',
'important': True,
'track_opens': True,
'track_clicks': True,
'auto_text': True,
'auto_html': True,
'inline_css': True,
'url_strip_qs': True,
'tags': ['djrill'],
'preserve_recipients': True,
'view_content_link': True,
'subaccount': 'example-subaccount',
'tracking_domain': 'example.com',
'signing_domain': 'example.com',
'return_path_domain': 'example.com',
'google_analytics_domains': ['example.com/test'],
'google_analytics_campaign': ['UA-00000000-1'],
'metadata': ['djrill'],
'merge_language': 'mailchimp',
'global_merge_vars': {'TEST': 'djrill'},
'async': True,
'ip_pool': 'Pool1',
'invalid': 'invalid',
})
class DjrillMandrillGlobalFeatureTests(DjrillBackendMockAPITestCase):
"""Test Djrill backend support for global ovveride Mandrill-specific features"""
def setUp(self):
super(DjrillMandrillGlobalFeatureTests, self).setUp()
self.message = mail.EmailMessage('Subject', 'Text Body',
'from@example.com', ['to@example.com'])
def test_global_options(self):
"""Test that any global settings get passed through
"""
self.message.send()
self.assert_mandrill_called("/messages/send.json")
data = self.get_api_call_data()
self.assertEqual(data['message']['from_name'], 'Djrill Test')
self.assertTrue(data['message']['important'])
self.assertTrue(data['message']['track_opens'])
self.assertTrue(data['message']['track_clicks'])
self.assertTrue(data['message']['auto_text'])
self.assertTrue(data['message']['auto_html'])
self.assertTrue(data['message']['inline_css'])
self.assertTrue(data['message']['url_strip_qs'])
self.assertEqual(data['message']['tags'], ['djrill'])
self.assertTrue(data['message']['preserve_recipients'])
self.assertTrue(data['message']['view_content_link'])
self.assertEqual(data['message']['subaccount'], 'example-subaccount')
self.assertEqual(data['message']['tracking_domain'], 'example.com')
self.assertEqual(data['message']['signing_domain'], 'example.com')
self.assertEqual(data['message']['return_path_domain'], 'example.com')
self.assertEqual(data['message']['google_analytics_domains'], ['example.com/test'])
self.assertEqual(data['message']['google_analytics_campaign'], ['UA-00000000-1'])
self.assertEqual(data['message']['metadata'], ['djrill'])
self.assertEqual(data['message']['merge_language'], 'mailchimp')
self.assertEqual(data['message']['global_merge_vars'],
[{'name': 'TEST', 'content': 'djrill'}])
self.assertFalse('merge_vars' in data['message'])
self.assertFalse('recipient_metadata' in data['message'])
# Options at top level of api params (not in message dict):
self.assertTrue(data['async'])
self.assertEqual(data['ip_pool'], 'Pool1')
# Option that shouldn't be added
self.assertFalse('invalid' in data['message'])
def test_global_options_override(self):
"""Test that manually settings options overrides global settings
"""
self.message.from_name = "override"
self.message.important = False
self.message.track_opens = False
self.message.track_clicks = False
self.message.auto_text = False
self.message.auto_html = False
self.message.inline_css = False
self.message.url_strip_qs = False
self.message.tags = ['override']
self.message.preserve_recipients = False
self.message.view_content_link = False
self.message.subaccount = "override"
self.message.tracking_domain = "override.example.com"
self.message.signing_domain = "override.example.com"
self.message.return_path_domain = "override.example.com"
self.message.google_analytics_domains = ['override.example.com']
self.message.google_analytics_campaign = ['UA-99999999-1']
self.message.metadata = ['override']
self.message.merge_language = 'handlebars'
self.message.async = False
self.message.ip_pool = "Bulk Pool"
self.message.send()
data = self.get_api_call_data()
self.assertEqual(data['message']['from_name'], 'override')
self.assertFalse(data['message']['important'])
self.assertFalse(data['message']['track_opens'])
self.assertFalse(data['message']['track_clicks'])
self.assertFalse(data['message']['auto_text'])
self.assertFalse(data['message']['auto_html'])
self.assertFalse(data['message']['inline_css'])
self.assertFalse(data['message']['url_strip_qs'])
self.assertEqual(data['message']['tags'], ['override'])
self.assertFalse(data['message']['preserve_recipients'])
self.assertFalse(data['message']['view_content_link'])
self.assertEqual(data['message']['subaccount'], 'override')
self.assertEqual(data['message']['tracking_domain'], 'override.example.com')
self.assertEqual(data['message']['signing_domain'], 'override.example.com')
self.assertEqual(data['message']['return_path_domain'], 'override.example.com')
self.assertEqual(data['message']['google_analytics_domains'], ['override.example.com'])
self.assertEqual(data['message']['google_analytics_campaign'], ['UA-99999999-1'])
self.assertEqual(data['message']['metadata'], ['override'])
self.assertEqual(data['message']['merge_language'], 'handlebars')
self.assertEqual(data['message']['global_merge_vars'],
[{'name': 'TEST', 'content': 'djrill'}])
# Options at top level of api params (not in message dict):
self.assertFalse(data['async'])
self.assertEqual(data['ip_pool'], 'Bulk Pool')
def test_global_merge(self):
# Test that global settings merge in
self.message.global_merge_vars = {'GREETING': "Hello"}
self.message.send()
data = self.get_api_call_data()
self.assertEqual(data['message']['global_merge_vars'],
[{'name': "GREETING", 'content': "Hello"},
{'name': 'TEST', 'content': 'djrill'}])
def test_global_merge_overwrite(self):
# Test that global merge settings are overwritten
self.message.global_merge_vars = {'TEST': "Hello"}
self.message.send()
data = self.get_api_call_data()
self.assertEqual(data['message']['global_merge_vars'],
[{'name': 'TEST', 'content': 'Hello'}])
@override_settings(EMAIL_BACKEND="djrill.mail.backends.djrill.DjrillBackend")

View File

@@ -1,7 +1,8 @@
from django.core import mail
from djrill import MandrillAPIError
from djrill.tests.mock_backend import DjrillBackendMockAPITestCase
from .mock_backend import DjrillBackendMockAPITestCase
class DjrillMandrillSendTemplateTests(DjrillBackendMockAPITestCase):

View File

@@ -0,0 +1,72 @@
from decimal import Decimal
from mock import patch
from django.core import mail
from .mock_backend import DjrillBackendMockAPITestCase
class DjrillSessionSharingTests(DjrillBackendMockAPITestCase):
"""Test Djrill backend sharing of single Mandrill API connection"""
@patch('requests.Session.close', autospec=True)
def test_connection_sharing(self, mock_close):
"""Djrill reuses one requests session when sending multiple messages"""
datatuple = (
('Subject 1', 'Body 1', 'from@example.com', ['to@example.com']),
('Subject 2', 'Body 2', 'from@example.com', ['to@example.com']),
)
mail.send_mass_mail(datatuple)
self.assertEqual(self.mock_post.call_count, 2)
session1 = self.mock_post.call_args_list[0][0] # arg[0] (self) is session
session2 = self.mock_post.call_args_list[1][0]
self.assertEqual(session1, session2)
self.assertEqual(mock_close.call_count, 1)
@patch('requests.Session.close', autospec=True)
def test_caller_managed_connections(self, mock_close):
"""Calling code can created long-lived connection that it opens and closes"""
connection = mail.get_connection()
connection.open()
mail.send_mail('Subject 1', 'body', 'from@example.com', ['to@example.com'], connection=connection)
session1 = self.mock_post.call_args[0]
self.assertEqual(mock_close.call_count, 0) # shouldn't be closed yet
mail.send_mail('Subject 2', 'body', 'from@example.com', ['to@example.com'], connection=connection)
self.assertEqual(mock_close.call_count, 0) # still shouldn't be closed
session2 = self.mock_post.call_args[0]
self.assertEqual(session1, session2) # should have reused same session
connection.close()
self.assertEqual(mock_close.call_count, 1)
def test_session_closed_after_exception(self):
# fail loud case:
msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com'],)
msg.global_merge_vars = {'PRICE': Decimal('19.99')} # will cause JSON serialization error
with patch('requests.Session.close', autospec=True) as mock_close:
with self.assertRaises(TypeError):
msg.send()
self.assertEqual(mock_close.call_count, 1)
# fail silently case (EmailMessage caches backend on send, so must create new one):
msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com'],)
msg.global_merge_vars = {'PRICE': Decimal('19.99')}
with patch('requests.Session.close', autospec=True) as mock_close:
sent = msg.send(fail_silently=True)
self.assertEqual(sent, 0)
self.assertEqual(mock_close.call_count, 1)
# caller-supplied connection case:
with patch('requests.Session.close', autospec=True) as mock_close:
connection = mail.get_connection()
connection.open()
msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com'],
connection=connection)
msg.global_merge_vars = {'PRICE': Decimal('19.99')}
with self.assertRaises(TypeError):
msg.send()
self.assertEqual(mock_close.call_count, 0) # wait for us to close it
connection.close()
self.assertEqual(mock_close.call_count, 1)

View File

@@ -1,46 +1,42 @@
from django.core import mail
from django.test.utils import override_settings
from .mock_backend import DjrillBackendMockAPITestCase
from .utils import override_settings
class DjrillMandrillSubaccountTests(DjrillBackendMockAPITestCase):
"""Test Djrill backend support for Mandrill subaccounts"""
def test_send_basic(self):
mail.send_mail('Subject here', 'Here is the message.',
'from@example.com', ['to@example.com'], fail_silently=False)
self.assert_mandrill_called("/messages/send.json")
def test_no_subaccount_by_default(self):
mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com'])
data = self.get_api_call_data()
self.assertEqual(data['message']['subject'], "Subject here")
self.assertEqual(data['message']['text'], "Here is the message.")
self.assertFalse('from_name' in data['message'])
self.assertEqual(data['message']['from_email'], "from@example.com")
self.assertEqual(len(data['message']['to']), 1)
self.assertEqual(data['message']['to'][0]['email'], "to@example.com")
self.assertFalse('subaccount' in data['message'])
@override_settings(MANDRILL_SUBACCOUNT="test_subaccount")
def test_send_from_subaccount(self):
mail.send_mail('Subject here', 'Here is the message.',
'from@example.com', ['to@example.com'], fail_silently=False)
self.assert_mandrill_called("/messages/send.json")
@override_settings(MANDRILL_SETTINGS={'subaccount': 'test_subaccount'})
def test_subaccount_setting(self):
mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com'])
data = self.get_api_call_data()
self.assertEqual(data['message']['subject'], "Subject here")
self.assertEqual(data['message']['text'], "Here is the message.")
self.assertFalse('from_name' in data['message'])
self.assertEqual(data['message']['from_email'], "from@example.com")
self.assertEqual(len(data['message']['to']), 1)
self.assertEqual(data['message']['to'][0]['email'], "to@example.com")
self.assertEqual(data['message']['subaccount'], "test_subaccount")
@override_settings(MANDRILL_SUBACCOUNT="global_setting_subaccount")
@override_settings(MANDRILL_SETTINGS={'subaccount': 'global_setting_subaccount'})
def test_subaccount_message_overrides_setting(self):
message = mail.EmailMessage(
'Subject here', 'Here is the message',
'from@example.com', ['to@example.com'])
message = mail.EmailMessage('Subject', 'Body', 'from@example.com', ['to@example.com'])
message.subaccount = "individual_message_subaccount" # should override global setting
message.send()
self.assert_mandrill_called("/messages/send.json")
data = self.get_api_call_data()
self.assertEqual(data['message']['subaccount'], "individual_message_subaccount")
# Djrill 1.x offered dedicated MANDRILL_SUBACCOUNT setting.
# In Djrill 2.x, you should use the MANDRILL_SETTINGS dict as in the earlier tests.
# But we still support the old setting for compatibility:
@override_settings(MANDRILL_SUBACCOUNT="legacy_setting_subaccount")
def test_subaccount_legacy_setting(self):
mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com'])
data = self.get_api_call_data()
self.assertEqual(data['message']['subaccount'], "legacy_setting_subaccount")
message = mail.EmailMessage('Subject', 'Body', 'from@example.com', ['to@example.com'])
message.subaccount = "individual_message_subaccount" # should override legacy setting
message.send()
data = self.get_api_call_data()
self.assertEqual(data['message']['subaccount'], "individual_message_subaccount")

View File

@@ -1,15 +1,15 @@
from base64 import b64encode
import hashlib
import hmac
import json
from base64 import b64encode
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.test import TestCase
from django.test.utils import override_settings
from ..compat import b
from ..signals import webhook_event
from .utils import override_settings
from djrill.compat import b
from djrill.signals import webhook_event
class DjrillWebhookSecretMixinTests(TestCase):

View File

@@ -1,106 +0,0 @@
import re
import six
import sys
__all__ = (
'BackportedAssertions',
'override_settings',
'reset_warning_registry',
)
try:
from django.test.utils import override_settings
except ImportError:
# Back-port override_settings from Django 1.4
# https://github.com/django/django/blob/stable/1.4.x/django/test/utils.py
from django.conf import settings, UserSettingsHolder
from django.utils.functional import wraps
class override_settings(object):
"""
Acts as either a decorator, or a context manager. If it's a decorator it
takes a function and returns a wrapped function. If it's a contextmanager
it's used with the ``with`` statement. In either event entering/exiting
are called before and after, respectively, the function/block is executed.
"""
def __init__(self, **kwargs):
self.options = kwargs
self.wrapped = settings._wrapped
def __enter__(self):
self.enable()
def __exit__(self, exc_type, exc_value, traceback):
self.disable()
def __call__(self, test_func):
from django.test import TransactionTestCase
if isinstance(test_func, type) and issubclass(test_func, TransactionTestCase):
original_pre_setup = test_func._pre_setup
original_post_teardown = test_func._post_teardown
def _pre_setup(innerself):
self.enable()
original_pre_setup(innerself)
def _post_teardown(innerself):
original_post_teardown(innerself)
self.disable()
test_func._pre_setup = _pre_setup
test_func._post_teardown = _post_teardown
return test_func
else:
@wraps(test_func)
def inner(*args, **kwargs):
with self:
return test_func(*args, **kwargs)
return inner
def enable(self):
override = UserSettingsHolder(settings._wrapped)
for key, new_value in self.options.items():
setattr(override, key, new_value)
settings._wrapped = override
# No setting_changed signal in Django 1.3
# for key, new_value in self.options.items():
# setting_changed.send(sender=settings._wrapped.__class__,
# setting=key, value=new_value)
def disable(self):
settings._wrapped = self.wrapped
# No setting_changed signal in Django 1.3
# for key in self.options:
# new_value = getattr(settings, key, None)
# setting_changed.send(sender=settings._wrapped.__class__,
# setting=key, value=new_value)
class BackportedAssertions(object):
"""Handful of useful TestCase assertions backported to Python 2.6/Django 1.3"""
# Backport from Python 2.7/3.1
def assertIn(self, member, container, msg=None):
"""Just like self.assertTrue(a in b), but with a nicer default message."""
if member not in container:
self.fail(msg or '%r not found in %r' % (member, container))
# Backport from Django 1.4
def assertRaisesMessage(self, expected_exception, expected_message,
callable_obj=None, *args, **kwargs):
return six.assertRaisesRegex(self, expected_exception, re.escape(expected_message),
callable_obj, *args, **kwargs)
# Backport from Django 1.8 (django.test.utils)
# with fix suggested by https://code.djangoproject.com/ticket/21049
def reset_warning_registry():
"""
Clear warning registry for all modules. This is required in some tests
because of a bug in Python that prevents warnings.simplefilter("always")
from always making warnings appear: http://bugs.python.org/issue4180
The bug was fixed in Python 3.4.2.
"""
key = "__warningregistry__"
for mod in list(sys.modules.values()):
if hasattr(mod, key):
getattr(mod, key).clear()

View File

@@ -1,89 +1,17 @@
from base64 import b64encode
import hashlib
import hmac
import json
from django import forms
from base64 import b64encode
from django.conf import settings
from django.contrib import messages
from django.core.exceptions import ImproperlyConfigured
from django.views.generic import TemplateView, View
from django.http import HttpResponse
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import View
import requests
from djrill import MANDRILL_API_URL, signals
from .compat import b
class DjrillAdminMedia(object):
def _media(self):
js = ["js/core.js", "js/jquery.min.js", "js/jquery.init.js"]
return forms.Media(js=["%s%s" % (settings.STATIC_URL, url) for url in js])
media = property(_media)
class DjrillApiMixin(object):
"""
Simple Mixin to grab the api info from the settings file.
"""
def __init__(self):
self.api_key = getattr(settings, "MANDRILL_API_KEY", None)
self.api_url = MANDRILL_API_URL
if not self.api_key:
raise ImproperlyConfigured(
"You have not set your mandrill api key in the settings file.")
def get_context_data(self, **kwargs):
kwargs = super(DjrillApiMixin, self).get_context_data(**kwargs)
status = False
req = requests.post("%s/%s" % (self.api_url, "users/ping.json"),
data={"key": self.api_key})
if req.status_code == 200:
status = True
kwargs.update({"status": status})
return kwargs
class DjrillApiJsonObjectsMixin(object):
"""
Mixin to grab json objects from the api.
"""
api_uri = None
def get_api_uri(self):
if self.api_uri is None:
raise NotImplementedError(
"%(cls)s is missing an api_uri. "
"Define %(cls)s.api_uri or override %(cls)s.get_api_uri()." % {
"cls": self.__class__.__name__
})
def get_json_objects(self, extra_dict=None, extra_api_uri=None):
request_dict = {"key": self.api_key}
if extra_dict:
request_dict.update(extra_dict)
payload = json.dumps(request_dict)
api_uri = extra_api_uri or self.api_uri
req = requests.post("%s/%s" % (self.api_url, api_uri),
data=payload)
if req.status_code == 200:
return req.text
messages.error(self.request, self._api_error_handler(req))
return json.dumps("error")
def _api_error_handler(self, req):
"""
If the API returns an error, display it to the user.
"""
content = json.loads(req.text)
return "Mandrill returned a %d response: %s" % (req.status_code,
content["message"])
from .signals import webhook_event
class DjrillWebhookSecretMixin(object):
@@ -139,66 +67,6 @@ class DjrillWebhookSignatureMixin(object):
request, *args, **kwargs)
class DjrillIndexView(DjrillApiMixin, TemplateView):
template_name = "djrill/status.html"
def get(self, request, *args, **kwargs):
payload = json.dumps({"key": self.api_key})
req = requests.post("%s/users/info.json" % self.api_url, data=payload)
return self.render_to_response({"status": json.loads(req.text)})
class DjrillSendersListView(DjrillAdminMedia, DjrillApiMixin,
DjrillApiJsonObjectsMixin, TemplateView):
api_uri = "users/senders.json"
template_name = "djrill/senders_list.html"
def get(self, request, *args, **kwargs):
objects = self.get_json_objects()
context = self.get_context_data()
context.update({
"objects": json.loads(objects),
"media": self.media,
})
return self.render_to_response(context)
class DjrillTagListView(DjrillAdminMedia, DjrillApiMixin,
DjrillApiJsonObjectsMixin, TemplateView):
api_uri = "tags/list.json"
template_name = "djrill/tags_list.html"
def get(self, request, *args, **kwargs):
objects = self.get_json_objects()
context = self.get_context_data()
context.update({
"objects": json.loads(objects),
"media": self.media,
})
return self.render_to_response(context)
class DjrillUrlListView(DjrillAdminMedia, DjrillApiMixin,
DjrillApiJsonObjectsMixin, TemplateView):
api_uri = "urls/list.json"
template_name = "djrill/urls_list.html"
def get(self, request, *args, **kwargs):
objects = self.get_json_objects()
context = self.get_context_data()
context.update({
"objects": json.loads(objects),
"media": self.media
})
return self.render_to_response(context)
class DjrillWebhookView(DjrillWebhookSecretMixin, DjrillWebhookSignatureMixin, View):
def head(self, request, *args, **kwargs):
return HttpResponse()
@@ -210,7 +78,7 @@ class DjrillWebhookView(DjrillWebhookSecretMixin, DjrillWebhookSignatureMixin, V
return HttpResponse(status=400)
for event in data:
signals.webhook_event.send(
webhook_event.send(
sender=None, event_type=event['event'], data=event)
return HttpResponse()

View File

@@ -1,3 +1,5 @@
.. _history:
Release Notes
=============
@@ -7,68 +9,32 @@ Among other things, this means that minor updates
and breaking changes will always increment the
major version number (1.x to 2.0).
Upcoming Changes in Djrill 2.0
------------------------------
Djrill 2.0 is under development and will include some breaking changes.
Although the changes won't impact most Djrill users, the current
version of Djrill (1.4) will try to warn you if you use things
that will change. (Warnings appear in the console when running Django
in debug mode.)
**Djrill Admin site**
Djrill 2.0 will remove the custom Djrill admin site. It duplicates
information from Mandrill's dashboard, most Djrill users are unaware
it exists, and it has caused problems tracking Django admin changes.
Drill 1.4 will report a DeprecationWarning when you try to load
the `DjrillAdminSite`. You should remove it from your code.
Also, if you changed Django's :setting:`INSTALLED_APPS` setting to use
`'django.contrib.admin.apps.SimpleAdminConfig'`, you may be able to
switch that back to `'django.contrib.admin'` and let Django
handle the admin.autodiscover() for you.
**Dates in merge data and other attributes**
Djrill automatically converts :attr:`send_at` date and datetime
values to the ISO 8601 string format expected by the Mandrill API.
Unintentionally, it also converts dates used in other Mandrill message
attributes (such as :attr:`merge_vars` or :attr:`metadata`) where it
might not be expected (or appropriate).
Djrill 2.0 will remove this automatic date formatting, except
for attributes that are inherently dates (currently only `send_at`).
To assist in detecting code relying on the (undocumented) current
behavior, Djrill 1.4 will report a DeprecationWarning for date
or datetime values used in any Mandrill message attributes other
than `send_at`. See :ref:`formatting-merge-data` for other options.
**DjrillMessage class**
The ``DjrillMessage`` class has not been needed since Djrill 0.2.
You can simply set Djrill message attributes on any Django
:class:`~django.core.mail.EmailMessage` object.
Djrill 1.4 will report a DeprecationWarning if you are still
using DjrillMessage.
**DjrillBackendHTTPError**
The ``DjrillBackendHTTPError`` exception was replaced in Djrill 0.3
with :exc:`djrill.MandrillAPIError`. Djrill 1.4 will report a
DeprecationWarning if you are still importing DjrillBackendHTTPError.
Change Log
Djrill 2.x
----------
Version 2.0:
* **Breaking Changes:** please see the :ref:`upgrade guide <upgrading>`.
* Add Django 1.9 support; drop Django 1.3, Python 2.6, and Python 3.2 support
* Add global :setting:`MANDRILL_SETTINGS` dict that can provide defaults
for most Djrill message options
* Add :exc:`djrill.NotSerializableForMandrillError`
* Use a single HTTP connection to the Mandrill API to improve performance
when sending multiple messages at once using :func:`~django.core.mail.send_mass_mail`.
(You can also directly manage your own long-lived Djrill connection across multiple sends,
by calling open and close on :ref:`Django's email backend <django:topic-email-backends>`.)
* Add Djrill version to user-agent header when calling Mandrill API
* Improve diagnostics in exceptions from Djrill
* Remove DjrillAdminSite
* Remove unintended date-to-string conversion in JSON encoding
* Remove obsolete DjrillMessage class and DjrillBackendHTTPError
* Refactor Djrill backend and exceptions
Djrill 1.x and Earlier
----------------------
Version 1.4:
* Django 1.8 support

View File

@@ -23,6 +23,7 @@ Documentation
quickstart
installation
upgrading
usage/sending_mail
usage/templates
usage/multiple_backends
@@ -36,9 +37,7 @@ Thanks
------
Thanks to the MailChimp team for asking us to build this nifty little app, and to all of Djrill's
:doc:`contributors <contributing>`. Also thanks to James Socol on Github for his django-adminplus_
library that got us off on the right foot for the custom admin views.
:doc:`contributors <contributing>`.
Oh, and, of course, Kenneth Reitz for the awesome requests_ library.
.. _requests: http://docs.python-requests.org
.. _django-adminplus: https://github.com/jsocol/django-adminplus

View File

@@ -47,65 +47,71 @@ Djrill includes optional support for Mandrill webhooks, including inbound email.
See the Djrill :ref:`webhooks <webhooks>` section for configuration details.
Mandrill Subaccounts (Optional)
-------------------------------
Other Optional Settings
-----------------------
You can optionally add any of these Djrill settings to your :file:`settings.py`.
.. setting:: MANDRILL_IGNORE_RECIPIENT_STATUS
MANDRILL_IGNORE_RECIPIENT_STATUS
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Set to ``True`` to disable :exc:`djrill.MandrillRecipientsRefused` exceptions
on invalid or rejected recipients. (Default ``False``.)
.. versionadded:: 2.0
.. setting:: MANDRILL_SETTINGS
MANDRILL_SETTINGS
~~~~~~~~~~~~~~~~~
You can supply global default options to apply to all messages sent through Djrill.
Set :setting:`!MANDRILL_SETTINGS` to a dict of these options. Example::
MANDRILL_SETTINGS = {
'subaccount': 'client-347',
'tracking_domain': 'example.com',
'track_opens': True,
}
See :ref:`mandrill-send-support` for a list of available options. (Everything
*except* :attr:`merge_vars`, :attr:`recipient_metadata`, and :attr:`send_at`
can be used with :setting:`!MANDRILL_SETTINGS`.)
Attributes set on individual EmailMessage objects will override the global
:setting:`!MANDRILL_SETTINGS` for that message. :attr:`global_merge_vars`
on an EmailMessage will be merged with any ``global_merge_vars`` in
:setting:`!MANDRILL_SETTINGS` (with the ones on the EmailMessage taking
precedence if there are conflicting var names).
.. versionadded:: 2.0
.. setting:: MANDRILL_API_URL
MANDRILL_API_URL
~~~~~~~~~~~~~~~~
The base url for calling the Mandrill API. The default is
``MANDRILL_API_URL = "https://mandrillapp.com/api/1.0"``,
which is the secure, production version of Mandrill's 1.0 API.
(It's unlikely you would need to change this.)
.. setting:: MANDRILL_SUBACCOUNT
If you are using Mandrill's `subaccounts`_ feature, you can globally set the
subaccount for all messages sent through Djrill::
MANDRILL_SUBACCOUNT = "client-347"
(You can also set or override the :attr:`subaccount` on each individual message,
with :ref:`Mandrill-specific sending options <mandrill-send-support>`.)
.. versionadded:: 1.0
MANDRILL_SUBACCOUNT global setting
MANDRILL_SUBACCOUNT
~~~~~~~~~~~~~~~~~~~
Prior to Djrill 2.0, the :setting:`!MANDRILL_SUBACCOUNT` setting could
be used to globally set the `Mandrill subaccount <subaccounts>`_.
Although this is still supported for compatibility with existing code,
new code should set a global subaccount in :setting:`MANDRILL_SETTINGS`
as shown above.
.. _subaccounts: http://help.mandrill.com/entries/25523278-What-are-subaccounts-
Admin (Optional)
----------------
Djrill includes an optional Django admin interface, which allows you to:
* Check the status of your Mandrill API connection
* See stats on email senders, tags and urls
If you want to enable the Djrill admin interface, edit your base :file:`urls.py`:
.. code-block:: python
:emphasize-lines: 4,6
...
from django.contrib import admin
from djrill import DjrillAdminSite
admin.site = DjrillAdminSite()
admin.autodiscover()
...
urlpatterns = [
...
url(r'^admin/', include(admin.site.urls)),
]
If you are on **Django 1.7 or later,** you will also need to change the config used
by the django.contrib.admin app in your :file:`settings.py`:
.. code-block:: python
:emphasize-lines: 4
...
INSTALLED_APPS = (
# For Django 1.7+, use SimpleAdminConfig because we'll call autodiscover...
'django.contrib.admin.apps.SimpleAdminConfig', # instead of 'django.contrib.admin'
...
'djrill',
...
)
...

121
docs/upgrading.rst Normal file
View File

@@ -0,0 +1,121 @@
.. _upgrading:
Upgrading from 1.x
==================
Djrill 2.0 includes some breaking changes from 1.x.
These changes should have minimal (or no) impact on most Djrill users,
but if you are upgrading please review the major topics below
to see if they apply to you.
Djrill 1.4 tried to warn you if you were using Djrill features
expected to change in 2.0. If you are seeing any deprecation warnings
with Djrill 1.4, you should fix them before upgrading to 2.0.
(Warnings appear in the console when running Django in debug mode.)
Please see the :ref:`release notes <history>` for a list of new features
and improvements in Djrill 2.0.
Dropped support for Django 1.3, Python 2.6, and Python 3.2
----------------------------------------------------------
Although Djrill may still work with these older configurations,
we no longer test against them. Djrill now requires Django 1.4
or later and Python 2.7, 3.3, or 3.4.
If you require support for these earlier versions, you should
not upgrade to Djrill 2.0. Djrill 1.4 remains available on
pypi, and will continue to receive security fixes.
Removed DjrillAdminSite
-----------------------
Earlier versions of Djrill included a custom Django admin site.
The equivalent functionality is available in Mandrill's dashboard,
and Djrill 2.0 drops support for it.
Although most Djrill users were unaware the admin site existed,
many did follow the earlier versions' instructions to enable it.
If you have added DjrillAdminSite, you will need to remove it for Djrill 2.0.
In your :file:`urls.py`:
.. code-block:: python
from djrill import DjrillAdminSite # REMOVE this
admin.site = DjrillAdminSite() # REMOVE this
admin.autodiscover() # REMOVE this if you added it only for Djrill
In your :file:`settings.py`:
.. code-block:: python
INSTALLED_APPS = (
...
# If you added SimpleAdminConfig only for Djrill:
'django.contrib.admin.apps.SimpleAdminConfig', # REMOVE this
'django.contrib.admin', # ADD this default back
...
)
(These instructions assume you had changed to SimpleAdminConfig
solely for DjrillAdminSite. If you are using it for custom admin
sites with any other Django apps you use, you should leave it
SimpleAdminConfig in place, but still remove the references to
DjrillAdminSite.)
Added exception for invalid or rejected recipients
--------------------------------------------------
Djrill 2.0 raises a new :exc:`djrill.MandrillRecipientsRefused` exception when
all recipients of a message are invalid or rejected by Mandrill. (This parallels
the behavior of Django's default :setting:`SMTP email backend <EMAIL_BACKEND>`,
which raises :exc:`SMTPRecipientsRefused <smtplib.SMTPRecipientsRefused>` when
all recipients are refused.)
Your email-sending code should handle this exception (along with other
exceptions that could occur during a send). However, if you want to retain the
Djrill 1.x behavior and treat invalid or rejected recipients as successful sends,
you can set :setting:`MANDRILL_IGNORE_RECIPIENT_STATUS` to ``True`` in your settings.py.
Other 2.0 breaking changes
--------------------------
Code that will be affected by these changes is far less common than
for the changes listed above, but they may impact some uses:
Removed unintended date-to-string conversion
If your code was inadvertently relying on Djrill to automatically
convert date or datetime values to strings in :attr:`merge_vars`,
:attr:`metadata`, or other Mandrill message attributes, you must
now explicitly do the string conversion yourself.
See :ref:`formatting-merge-data` for an explanation.
(Djrill 1.4 reported a DeprecationWarning for this case.)
(This does not affect :attr:`send_at`, where Djrill specifically
allows date or datetime values.)
Removed DjrillMessage class
The ``DjrillMessage`` class has not been needed since Djrill 0.2.
You should replace any uses of it with the standard
:class:`~django.core.mail.EmailMessage` class.
(Djrill 1.4 reported a DeprecationWarning for this case.)
Removed DjrillBackendHTTPError
This exception was deprecated in Djrill 0.3. Replace uses of it
with :exc:`djrill.MandrillAPIError`.
(Djrill 1.4 reported a DeprecationWarning for this case.)
Refactored Djrill backend and exceptions
Several internal details of ``djrill.mail.backends.DjrillBackend``
and Djrill's exception classes have been significantly updated for 2.0.
The intent is to make it easier to maintain and extend the backend
(including creating your own subclasses to override Djrill's default
behavior). As a result, though, any existing code that depended on
undocumented Djrill internals may need to be updated.

View File

@@ -32,10 +32,6 @@ Some notes and limitations:
to `!True` if you want recipients to be able to see who else was included
in the "to" list.
.. versionchanged:: 0.9
Previously, Djrill (and Mandrill) didn't distinguish "cc" from "to",
and allowed only a single "bcc" recipient.
.. _sending-html:
@@ -70,12 +66,6 @@ Some notes and limitations:
(For an example, see :meth:`~DjrillBackendTests.test_embedded_images`
in :file:`tests/test_mandrill_send.py`.)
.. versionadded:: 0.3
Attachments
.. versionchanged:: 0.4
Special handling for embedded images
.. _message-headers:
**Headers**
@@ -87,11 +77,8 @@ Some notes and limitations:
headers={'Reply-To': "reply@example.com", 'List-Unsubscribe': "..."}
)
.. versionchanged:: 0.9
In earlier versions, Djrill only allowed ``Reply-To`` and ``X-*`` headers,
matching previous Mandrill API restrictions.
.. note::
.. versionchanged:: 1.4
Djrill also supports the `reply_to` param added to
:class:`~django.core.mail.EmailMessage` in Django 1.8.
(If you provide *both* a 'Reply-To' header and the `reply_to` param,
@@ -106,7 +93,14 @@ Mandrill-Specific Options
Most of the options from the Mandrill
`messages/send API <https://mandrillapp.com/api/docs/messages.html#method=send>`_
`message` struct can be set directly on an :class:`~django.core.mail.EmailMessage`
(or subclass) object:
(or subclass) object.
.. note::
You can set global defaults for common options with the
:setting:`MANDRILL_SETTINGS` setting, to avoid having to
set them on every message.
.. These attributes are in the same order as they appear in the Mandrill API docs...
@@ -114,8 +108,6 @@ Most of the options from the Mandrill
``Boolean``: whether Mandrill should send this message ahead of non-important ones.
.. versionadded:: 0.7
.. attribute:: track_opens
``Boolean``: whether Mandrill should enable open-tracking for this message.
@@ -149,8 +141,6 @@ Most of the options from the Mandrill
``Boolean``: whether Mandrill should inline CSS styles in the HTML.
Default from your Mandrill account settings.
.. versionadded:: 0.4
.. attribute:: url_strip_qs
``Boolean``: whether Mandrill should ignore any query parameters when aggregating
@@ -165,8 +155,6 @@ Most of the options from the Mandrill
``Boolean``: set False on sensitive messages to instruct Mandrill not to log the content.
.. versionadded:: 0.7
.. attribute:: tracking_domain
``str``: domain Mandrill should use to rewrite tracked links and host tracking pixels
@@ -183,15 +171,11 @@ Most of the options from the Mandrill
``str``: domain Mandrill should use for the message's return-path.
.. versionadded:: 0.7
.. attribute:: merge_language
``str``: the merge tag language if using merge tags -- e.g., "mailchimp" or "handlebars".
Default from your Mandrill account settings.
.. versionadded:: 1.3
.. attribute:: global_merge_vars
``dict``: merge variables to use for all recipients (most useful with :ref:`mandrill-templates`). ::
@@ -226,10 +210,6 @@ Most of the options from the Mandrill
.. attribute:: subaccount
``str``: the ID of one of your subaccounts to use for sending this message.
(The subaccount on an individual message will override any global
:setting:`MANDRILL_SUBACCOUNT` setting.)
.. versionadded:: 0.7
.. attribute:: google_analytics_domains
@@ -267,24 +247,47 @@ Most of the options from the Mandrill
``Boolean``: whether Mandrill should use an async mode optimized for bulk sending.
.. versionadded:: 0.7
.. attribute:: ip_pool
``str``: name of one of your Mandrill dedicated IP pools to use for sending this message.
.. versionadded:: 0.7
.. attribute:: send_at
``datetime`` or ``date`` or ``str``: instructs Mandrill to delay sending this message
until the specified time. (Djrill allows timezone-aware Python datetimes, and converts them
to UTC for Mandrill. Timezone-naive datetimes are assumed to be UTC.)
`datetime` or `date` or ``str``: instructs Mandrill to delay sending this message
until the specified time. Example::
.. versionadded:: 0.7
msg.send_at = datetime.utcnow() + timedelta(hours=1)
Mandrill requires a UTC string in the form ``YYYY-MM-DD HH:MM:SS``.
Djrill will convert python dates and datetimes to this form.
(Dates will be given a time of 00:00:00.)
.. note:: Timezones
Mandrill assumes :attr:`!send_at` is in the UTC timezone,
which is likely *not* the same as your local time.
Djrill will convert timezone-*aware* datetimes to UTC for you.
But if you format your own string, supply a date, or a
*naive* datetime, you must make sure it is in UTC.
See the python `datetime` docs for more information.
For example, ``msg.send_at = datetime.now() + timedelta(hours=1)``
will try to schedule the message for an hour from the current time,
but *interpreted in the UTC timezone* (which isn't what you want).
If you're more than an hour west of the prime meridian, that will
be in the past (and the message will get sent immediately). If
you're east of there, the message might get sent quite a bit later
than you intended. One solution is to use `utcnow` as shown in
the earlier example.
.. note::
Scheduled sending is a paid Mandrill feature. If you are using
a free Mandrill account, :attr:`!send_at` won't work.
These Mandrill-specific properties work with *any*
All the Mandrill-specific attributes listed above work with *any*
:class:`~django.core.mail.EmailMessage`-derived object, so you can use them with
many other apps that add Django mail functionality.
@@ -295,13 +298,15 @@ see :class:`DjrillMandrillFeatureTests` in :file:`tests/test_mandrill_send.py` f
.. _mandrill-response:
Mandrill Response
-----------------
Response from Mandrill
----------------------
A ``mandrill_response`` property is added to each :class:`~django.core.mail.EmailMessage` that you
send. This allows you to retrieve message ids, initial status information and more.
.. attribute:: mandrill_response
For an EmailMessage that is successfully sent to one or more email addresses, ``mandrill_response`` will
Djrill adds a :attr:`!mandrill_response` attribute to each :class:`~django.core.mail.EmailMessage`
as it sends it. This allows you to retrieve message ids, initial status information and more.
For an EmailMessage that is successfully sent to one or more email addresses, :attr:`!mandrill_response` will
be set to a ``list`` of ``dict``, where each entry has info for one email address. See the Mandrill docs for the
`messages/send API <https://mandrillapp.com/api/docs/messages.html#method=send>`_ for full details.
@@ -323,10 +328,7 @@ For this example, msg.mandrill_response might look like this::
}
]
If an error is returned by Mandrill while sending the message then ``mandrill_response`` will be set to None.
.. versionadded:: 0.8
mandrill_response available for sent messages
If an error is returned by Mandrill while sending the message then :attr:`!mandrill_response` will be set to None.
.. _djrill-exceptions:
@@ -334,9 +336,6 @@ If an error is returned by Mandrill while sending the message then ``mandrill_re
Exceptions
----------
.. versionadded:: 0.3
Djrill-specific exceptions
.. exception:: djrill.NotSupportedByMandrillError
If the email tries to use features that aren't supported by Mandrill, the send
@@ -344,6 +343,27 @@ Exceptions
of :exc:`ValueError`).
.. exception:: djrill.MandrillRecipientsRefused
If *all* recipients (to, cc, bcc) of a message are invalid or rejected by Mandrill
(e.g., because they are your Mandrill blacklist), the send call will raise a
:exc:`~!djrill.MandrillRecipientsRefused` exception.
You can examine the message's :attr:`mandrill_response` attribute
to determine the cause of the error.
If a single message is sent to multiple recipients, and *any* recipient is valid
(or the message is queued by Mandrill because of rate limiting or :attr:`send_at`), then
this exception will not be raised. You can still examine the mandrill_response
property after the send to determine the status of each recipient.
You can disable this exception by setting :setting:`MANDRILL_IGNORE_RECIPIENT_STATUS`
to True in your settings.py, which will cause Djrill to treat any non-API-error response
from Mandrill as a successful send.
.. versionadded:: 2.0
Djrill 1.x behaved as if ``MANDRILL_IGNORE_RECIPIENT_STATUS = True``.
.. exception:: djrill.MandrillAPIError
If the Mandrill API fails or returns an error response, the send call will
@@ -352,3 +372,16 @@ Exceptions
help explain what went wrong. (Tip: you can also check Mandrill's
`API error log <https://mandrillapp.com/settings/api>`_ to view the full API
request and error response.)
.. exception:: djrill.NotSerializableForMandrillError
The send call will raise a :exc:`~!djrill.NotSerializableForMandrillError` exception
if the message has attached data which cannot be serialized to JSON for the Mandrill API.
See :ref:`formatting-merge-data` for more information.
.. versionadded:: 2.0
Djrill 1.x raised a generic `TypeError` in this case.
:exc:`~!djrill.NotSerializableForMandrillError` is a subclass of `TypeError`
for compatibility with existing code.

View File

@@ -6,9 +6,6 @@ Sending Template Mail
Mandrill Templates
------------------
.. versionadded:: 0.3
Mandrill template support
To use a *Mandrill* (MailChimp) template stored in your Mandrill account,
set a :attr:`template_name` and (optionally) :attr:`template_content`
on your :class:`~django.core.mail.EmailMessage` object::
@@ -75,6 +72,9 @@ which means advanced template users can include dicts and lists as merge vars
(for templates designed to handle objects and arrays).
See the Python :class:`json.JSONEncoder` docs for a list of allowable types.
Djrill will raise :exc:`djrill.NotSerializableForMandrillError` if you attempt
to send a message with non-json-serializable data.
How To Use Default Mandrill Subject and From fields
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -93,16 +93,11 @@ your :class:`~django.core.mail.EmailMessage` object::
If `True`, Djrill will omit the subject, and Mandrill will
use the default subject from the template.
.. versionadded:: 1.1
.. attribute:: use_template_from
If `True`, Djrill will omit the "from" field, and Mandrill will
use the default "from" from the template.
.. versionadded:: 1.1
.. _django-templates:

View File

@@ -11,10 +11,6 @@ Djrill includes optional support for Mandrill's webhook notifications.
If enabled, it will send a Django signal for each event in a webhook.
Your code can connect to this signal for further processing.
.. versionadded:: 0.5
Webhook support
.. warning:: Webhook Security
Webhooks are ordinary urls---they're wide open to the internet.

View File

@@ -3,13 +3,9 @@
# python runtests.py
import sys
from django import VERSION as django_version
from django.conf import settings
APP = 'djrill'
ADMIN = 'django.contrib.admin'
if django_version >= (1, 7):
ADMIN = 'django.contrib.admin.apps.SimpleAdminConfig'
settings.configure(
DEBUG=True,
@@ -23,7 +19,7 @@ settings.configure(
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
ADMIN,
'django.contrib.admin',
APP,
),
MIDDLEWARE_CLASSES=(
@@ -33,25 +29,10 @@ settings.configure(
'django.contrib.auth.middleware.AuthenticationMiddleware',
),
TEMPLATES=[
# Django 1.8 starter-project template settings
# (needed for test_admin)
# Djrill doesn't have any templates, but tests need a TEMPLATES
# setting to avoid warnings from the Django 1.8+ test client.
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [
# insert your TEMPLATE_DIRS here
],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.contrib.auth.context_processors.auth',
'django.template.context_processors.debug',
'django.template.context_processors.i18n',
'django.template.context_processors.media',
'django.template.context_processors.static',
'django.template.context_processors.tz',
'django.contrib.messages.context_processors.messages',
],
},
},
],
)

View File

@@ -29,7 +29,7 @@ setup(
license="BSD License",
packages=["djrill"],
zip_safe=False,
install_requires=["requests>=1.0.0", "django>=1.3"],
install_requires=["requests>=1.0.0", "django>=1.4"],
include_package_data=True,
test_suite="runtests.runtests",
tests_require=["mock", "six"],
@@ -37,12 +37,11 @@ setup(
"Programming Language :: Python",
"Programming Language :: Python :: Implementation :: PyPy",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: 2.6",
"Programming Language :: Python :: 2.7",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.2",
"Programming Language :: Python :: 3.3",
"Programming Language :: Python :: 3.4",
"Programming Language :: Python :: 3.5",
"License :: OSI Approved :: BSD License",
"Topic :: Software Development :: Libraries :: Python Modules",
"Framework :: Django",