mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-20 20:01:05 -05:00
24
.editorconfig
Normal file
24
.editorconfig
Normal 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
|
||||||
68
.travis.yml
68
.travis.yml
@@ -1,52 +1,38 @@
|
|||||||
|
sudo: false
|
||||||
language: python
|
language: python
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
# Django 1.3: Python 2.6--2.7
|
# Django 1.4: Python 2.6--2.7 (but Djrill doesn't support 2.6)
|
||||||
- python: "2.6"
|
- { env: DJANGO=django==1.4, python: 2.7 }
|
||||||
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.5: Python 2.7, pypy
|
# Django 1.5: Python 2.7, pypy
|
||||||
# (As of Django 1.5, Python 2.6 no longer "highly recommended",
|
- { env: DJANGO=django==1.5, python: 2.7 }
|
||||||
# and Python 3.2+ support was only "experimental", so skip those.)
|
- { env: DJANGO=django==1.5, python: pypy }
|
||||||
- python: "2.7"
|
|
||||||
env: DJANGO=django==1.5
|
|
||||||
- python: "pypy"
|
|
||||||
env: DJANGO=django==1.5
|
|
||||||
# Django 1.6: Python 2.7--3.3, pypy
|
# Django 1.6: Python 2.7--3.3, pypy
|
||||||
- python: "2.7"
|
- { env: DJANGO=django==1.6, python: 2.7 }
|
||||||
env: DJANGO=django==1.6
|
- { env: DJANGO=django==1.6, python: 3.3 }
|
||||||
- python: "3.2"
|
- { env: DJANGO=django==1.6, python: pypy }
|
||||||
env: DJANGO=django==1.6
|
|
||||||
- python: "3.3"
|
|
||||||
env: DJANGO=django==1.6
|
|
||||||
- python: "pypy"
|
|
||||||
env: DJANGO=django==1.6
|
|
||||||
# Django 1.7: Python 2.7--3.4, pypy
|
# Django 1.7: Python 2.7--3.4, pypy
|
||||||
- python: "2.7"
|
- { env: DJANGO=django==1.7, python: 2.7 }
|
||||||
env: DJANGO=django==1.7
|
- { env: DJANGO=django==1.7, python: 3.3 }
|
||||||
- python: "3.2"
|
- { env: DJANGO=django==1.7, python: 3.4 }
|
||||||
env: DJANGO=django==1.7
|
- { env: DJANGO=django==1.7, python: pypy }
|
||||||
- python: "3.3"
|
|
||||||
env: DJANGO=django==1.7
|
|
||||||
- python: "3.4"
|
|
||||||
env: DJANGO=django==1.7
|
|
||||||
- python: "pypy"
|
|
||||||
env: DJANGO=django==1.7
|
|
||||||
# Django 1.8: "Python 2.7 or above"
|
# Django 1.8: "Python 2.7 or above"
|
||||||
- python: "2.7"
|
- { env: DJANGO=django==1.8, python: 2.7 }
|
||||||
env: DJANGO=django==1.8
|
- { env: DJANGO=django==1.8, python: 3.4 }
|
||||||
- python: "3.4"
|
- { env: DJANGO=django==1.8, python: pypy }
|
||||||
env: DJANGO=django==1.8
|
# Django 1.9: "Python 2.7, 3.4, or 3.5"
|
||||||
- python: "pypy"
|
- { env: DJANGO=django==1.9, python: 2.7 }
|
||||||
env: DJANGO=django==1.8
|
- { 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:
|
install:
|
||||||
- pip install --upgrade setuptools pip
|
- pip install --upgrade setuptools pip
|
||||||
- pip install -q $DJANGO
|
- pip install $DJANGO
|
||||||
- pip install .
|
- pip install .
|
||||||
|
- pip list
|
||||||
script: python -Wall setup.py test
|
script: python -Wall setup.py test
|
||||||
|
|||||||
@@ -17,3 +17,4 @@ Sameer Al-Sakran
|
|||||||
Kyle Gibson
|
Kyle Gibson
|
||||||
Wes Winham
|
Wes Winham
|
||||||
nikolay-saskovets
|
nikolay-saskovets
|
||||||
|
William Hector
|
||||||
|
|||||||
4
CONTRIBUTING.md
Normal file
4
CONTRIBUTING.md
Normal 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.
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
include README.rst AUTHORS.txt LICENSE
|
include README.rst AUTHORS.txt LICENSE
|
||||||
recursive-include djrill/templates *.html
|
|
||||||
recursive-include djrill *.py
|
recursive-include djrill *.py
|
||||||
prune djrill/tests
|
prune djrill/tests
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ Djrill: Mandrill Transactional Email for Django
|
|||||||
Djrill integrates the `Mandrill <http://mandrill.com>`_ transactional
|
Djrill integrates the `Mandrill <http://mandrill.com>`_ transactional
|
||||||
email service into Django.
|
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`
|
In general, Djrill "just works" with Django's built-in `django.core.mail`
|
||||||
package. It includes:
|
package. It includes:
|
||||||
|
|
||||||
@@ -28,9 +33,8 @@ package. It includes:
|
|||||||
* Mandrill-specific extensions like tags, metadata, tracking, and MailChimp templates
|
* Mandrill-specific extensions like tags, metadata, tracking, and MailChimp templates
|
||||||
* Optional support for Mandrill inbound email and other webhook notifications,
|
* Optional support for Mandrill inbound email and other webhook notifications,
|
||||||
via Django signals
|
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+).
|
(including Python 3 with Django 1.6+, and PyPy support with Django 1.5+).
|
||||||
Djrill uses `semantic versioning <http://semver.org/>`_.
|
Djrill uses `semantic versioning <http://semver.org/>`_.
|
||||||
|
|
||||||
|
|||||||
@@ -1,79 +1,3 @@
|
|||||||
from django.conf import settings
|
from ._version import __version__, VERSION
|
||||||
from django.contrib.admin.sites import AdminSite
|
from .exceptions import (MandrillAPIError, MandrillRecipientsRefused,
|
||||||
from django.utils.text import capfirst
|
NotSerializableForMandrillError, NotSupportedByMandrillError)
|
||||||
|
|
||||||
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)
|
|
||||||
|
|||||||
@@ -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
|
__version__ = '.'.join([str(x) for x in VERSION[:3]]) # major.minor.patch
|
||||||
if len(VERSION) > 3: # x.y.z-pre.release (note the hyphen)
|
if len(VERSION) > 3: # x.y.z-pre.release (note the hyphen)
|
||||||
__version__ += '-' + '.'.join([str(x) for x in VERSION[3:]])
|
__version__ += '-' + '.'.join([str(x) for x in VERSION[3:]])
|
||||||
|
|||||||
@@ -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")
|
|
||||||
@@ -1,33 +1,89 @@
|
|||||||
import json
|
import json
|
||||||
from requests import HTTPError
|
from requests import HTTPError
|
||||||
import warnings
|
|
||||||
|
|
||||||
|
|
||||||
class MandrillAPIError(HTTPError):
|
class DjrillError(Exception):
|
||||||
"""Exception for unsuccessful response from Mandrill API."""
|
"""Base class for exceptions raised by Djrill
|
||||||
def __init__(self, status_code, response=None, log_message=None, *args, **kwargs):
|
|
||||||
super(MandrillAPIError, self).__init__(*args, **kwargs)
|
Overrides __str__ to provide additional information about
|
||||||
self.status_code = status_code
|
Mandrill API call and response.
|
||||||
self.response = response # often contains helpful Mandrill info
|
"""
|
||||||
self.log_message = log_message
|
|
||||||
|
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):
|
def __str__(self):
|
||||||
message = "Mandrill API response %d" % self.status_code
|
parts = [
|
||||||
if self.log_message:
|
" ".join([str(arg) for arg in self.args]),
|
||||||
message += "\n" + self.log_message
|
self.describe_send(),
|
||||||
# Include the Mandrill response, nicely formatted, if possible
|
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:
|
try:
|
||||||
json_response = self.response.json()
|
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
|
except (AttributeError, KeyError, ValueError): # not JSON = ValueError
|
||||||
try:
|
try:
|
||||||
message += "\nMandrill response: " + self.response.text
|
description += " " + self.response.text
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
pass
|
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.
|
"""Exception for email features that Mandrill doesn't support.
|
||||||
|
|
||||||
This is typically raised when attempting to send a Django EmailMessage that
|
This is typically raised when attempting to send a Django EmailMessage that
|
||||||
@@ -43,9 +99,19 @@ class NotSupportedByMandrillError(ValueError):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class RemovedInDjrill2(DeprecationWarning):
|
class NotSerializableForMandrillError(DjrillError, TypeError):
|
||||||
"""Functionality due for deprecation in Djrill 2.0"""
|
"""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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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.conf import settings
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from django.core.mail.backends.base import BaseEmailBackend
|
from django.core.mail.backends.base import BaseEmailBackend
|
||||||
from django.core.mail.message import sanitize_address, DEFAULT_ATTACHMENT_MIME_TYPE
|
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 ..._version import __version__
|
||||||
#from djrill import MANDRILL_API_URL, MandrillAPIError, NotSupportedByMandrillError
|
from ...exceptions import (DjrillError, MandrillAPIError, MandrillRecipientsRefused,
|
||||||
from ... import MANDRILL_API_URL, MandrillAPIError, NotSupportedByMandrillError
|
NotSerializableForMandrillError, 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)
|
|
||||||
|
|
||||||
|
|
||||||
class DjrillBackend(BaseEmailBackend):
|
class DjrillBackend(BaseEmailBackend):
|
||||||
@@ -56,103 +26,223 @@ class DjrillBackend(BaseEmailBackend):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
"""
|
"""Init options from Django settings"""
|
||||||
Set the API key, API url and set the action url.
|
|
||||||
"""
|
|
||||||
super(DjrillBackend, self).__init__(**kwargs)
|
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:
|
self.api_url = getattr(settings, "MANDRILL_API_URL", "https://mandrillapp.com/api/1.0")
|
||||||
raise ImproperlyConfigured("You have not set your mandrill api key "
|
if not self.api_url.endswith("/"):
|
||||||
"in the settings.py file.")
|
self.api_url += "/"
|
||||||
|
|
||||||
self.api_send = self.api_url + "/messages/send.json"
|
self.global_settings = {}
|
||||||
self.api_send_template = self.api_url + "/messages/send-template.json"
|
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):
|
def send_messages(self, email_messages):
|
||||||
|
"""
|
||||||
|
Sends one or more EmailMessage objects and returns the number of email
|
||||||
|
messages sent.
|
||||||
|
"""
|
||||||
if not email_messages:
|
if not email_messages:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
num_sent = 0
|
created_session = self.open()
|
||||||
for message in email_messages:
|
if not self.session:
|
||||||
sent = self._send(message)
|
return 0 # exception in self.open with fail_silently
|
||||||
|
|
||||||
if sent:
|
num_sent = 0
|
||||||
num_sent += 1
|
try:
|
||||||
|
for message in email_messages:
|
||||||
|
sent = self._send(message)
|
||||||
|
if sent:
|
||||||
|
num_sent += 1
|
||||||
|
finally:
|
||||||
|
if created_session:
|
||||||
|
self.close()
|
||||||
|
|
||||||
return num_sent
|
return num_sent
|
||||||
|
|
||||||
def _send(self, message):
|
def _send(self, message):
|
||||||
|
message.mandrill_response = None # until we have a response
|
||||||
if not message.recipients():
|
if not message.recipients():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
api_url = self.api_send
|
|
||||||
api_params = {
|
|
||||||
"key": self.api_key,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
msg_dict = self._build_standard_message_dict(message)
|
payload = self.get_base_payload()
|
||||||
self._add_mandrill_options(message, msg_dict)
|
self.build_send_payload(payload, message)
|
||||||
if getattr(message, 'alternatives', None):
|
response = self.post_to_mandrill(payload, message)
|
||||||
self._add_alternatives(message, msg_dict)
|
|
||||||
self._add_attachments(message, msg_dict)
|
|
||||||
api_params['message'] = msg_dict
|
|
||||||
|
|
||||||
# check if template is set in message to send it via
|
# add the response from mandrill to the EmailMessage so callers can inspect it
|
||||||
# api url: /messages/send-template.json
|
message.mandrill_response = self.parse_response(response, payload, message)
|
||||||
if hasattr(message, 'template_name'):
|
self.validate_response(message.mandrill_response, response, payload, message)
|
||||||
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', {}))
|
|
||||||
|
|
||||||
self._add_mandrill_toplevel_options(message, api_params)
|
except DjrillError:
|
||||||
|
# every *expected* error is derived from DjrillError;
|
||||||
except NotSupportedByMandrillError:
|
# we deliberately don't silence unexpected errors
|
||||||
if not self.fail_silently:
|
if not self.fail_silently:
|
||||||
raise
|
raise
|
||||||
return False
|
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:
|
try:
|
||||||
api_data = json.dumps(api_params, cls=JSONDateUTCEncoder)
|
json_payload = self.serialize_payload(payload, message)
|
||||||
except TypeError as err:
|
except TypeError as err:
|
||||||
# Add some context to the "not JSON serializable" message
|
# Add some context to the "not JSON serializable" message
|
||||||
if not err.args:
|
raise NotSerializableForMandrillError(
|
||||||
err.args = ('',)
|
orig_err=err, email_message=message, payload=payload)
|
||||||
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)
|
|
||||||
|
|
||||||
|
response = self.session.post(api_url, data=json_payload)
|
||||||
if response.status_code != 200:
|
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
|
def parse_response(self, response, payload, message):
|
||||||
message.mandrill_response = None
|
"""Return parsed json from Mandrill API response
|
||||||
|
|
||||||
if not self.fail_silently:
|
Can raise MandrillAPIError if response is not valid JSON
|
||||||
log_message = "Failed to send a message"
|
"""
|
||||||
if 'to' in msg_dict:
|
try:
|
||||||
log_message += " to " + ','.join(
|
return response.json()
|
||||||
to['email'] for to in msg_dict.get('to', []) if 'email' in to)
|
except ValueError:
|
||||||
if 'from_email' in msg_dict:
|
raise MandrillAPIError("Invalid JSON in Mandrill API response",
|
||||||
log_message += " from %s" % msg_dict['from_email']
|
email_message=message, payload=payload, response=response)
|
||||||
raise MandrillAPIError(
|
|
||||||
status_code=response.status_code,
|
|
||||||
response=response,
|
|
||||||
log_message=log_message)
|
|
||||||
return False
|
|
||||||
|
|
||||||
# add the response from mandrill to the EmailMessage so callers can inspect it
|
def validate_response(self, parsed_response, response, payload, message):
|
||||||
message.mandrill_response = response.json()
|
"""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):
|
def _build_standard_message_dict(self, message):
|
||||||
"""Create a Mandrill send message struct from a Django EmailMessage.
|
"""Create a Mandrill send message struct from a Django EmailMessage.
|
||||||
@@ -204,13 +294,15 @@ class DjrillBackend(BaseEmailBackend):
|
|||||||
'async', 'ip_pool'
|
'async', 'ip_pool'
|
||||||
]
|
]
|
||||||
for attr in mandrill_attrs:
|
for attr in mandrill_attrs:
|
||||||
|
if attr in self.global_settings:
|
||||||
|
api_params[attr] = self.global_settings[attr]
|
||||||
if hasattr(message, attr):
|
if hasattr(message, attr):
|
||||||
api_params[attr] = getattr(message, attr)
|
api_params[attr] = getattr(message, attr)
|
||||||
|
|
||||||
# Mandrill attributes that require conversion:
|
# Mandrill attributes that require conversion:
|
||||||
if hasattr(message, 'send_at'):
|
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"):
|
def _make_mandrill_to_list(self, message, recipients, recipient_type="to"):
|
||||||
"""Create a Mandrill 'to' field from a list of emails.
|
"""Create a Mandrill 'to' field from a list of emails.
|
||||||
@@ -237,18 +329,26 @@ class DjrillBackend(BaseEmailBackend):
|
|||||||
'google_analytics_domains', 'google_analytics_campaign',
|
'google_analytics_domains', 'google_analytics_campaign',
|
||||||
'metadata']
|
'metadata']
|
||||||
|
|
||||||
if self.subaccount:
|
|
||||||
msg_dict['subaccount'] = self.subaccount
|
|
||||||
|
|
||||||
for attr in mandrill_attrs:
|
for attr in mandrill_attrs:
|
||||||
|
if attr in self.global_settings:
|
||||||
|
msg_dict[attr] = self.global_settings[attr]
|
||||||
if hasattr(message, attr):
|
if hasattr(message, attr):
|
||||||
msg_dict[attr] = getattr(message, attr)
|
msg_dict[attr] = getattr(message, attr)
|
||||||
|
|
||||||
# Allow simple python dicts in place of Mandrill
|
# Allow simple python dicts in place of Mandrill
|
||||||
# [{name:name, value:value},...] arrays...
|
# [{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'):
|
if hasattr(message, 'global_merge_vars'):
|
||||||
|
global_merge_vars.update(message.global_merge_vars)
|
||||||
|
if global_merge_vars:
|
||||||
msg_dict['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'):
|
if hasattr(message, 'merge_vars'):
|
||||||
# For testing reproducibility, we sort the recipients
|
# For testing reproducibility, we sort the recipients
|
||||||
msg_dict['merge_vars'] = [
|
msg_dict['merge_vars'] = [
|
||||||
@@ -283,14 +383,16 @@ class DjrillBackend(BaseEmailBackend):
|
|||||||
if len(message.alternatives) > 1:
|
if len(message.alternatives) > 1:
|
||||||
raise NotSupportedByMandrillError(
|
raise NotSupportedByMandrillError(
|
||||||
"Too many alternatives attached to the message. "
|
"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]
|
(content, mimetype) = message.alternatives[0]
|
||||||
if mimetype != 'text/html':
|
if mimetype != 'text/html':
|
||||||
raise NotSupportedByMandrillError(
|
raise NotSupportedByMandrillError(
|
||||||
"Invalid alternative mimetype '%s'. "
|
"Invalid alternative mimetype '%s'. "
|
||||||
"Mandrill only accepts plain text and html emails."
|
"Mandrill only accepts plain text and html emails."
|
||||||
% mimetype)
|
% mimetype,
|
||||||
|
email_message=message)
|
||||||
|
|
||||||
msg_dict['html'] = content
|
msg_dict['html'] = content
|
||||||
|
|
||||||
@@ -343,6 +445,7 @@ class DjrillBackend(BaseEmailBackend):
|
|||||||
|
|
||||||
# b64encode requires bytes, so let's convert our content.
|
# b64encode requires bytes, so let's convert our content.
|
||||||
try:
|
try:
|
||||||
|
# noinspection PyUnresolvedReferences
|
||||||
if isinstance(content, unicode):
|
if isinstance(content, unicode):
|
||||||
# Python 2.X unicode string
|
# Python 2.X unicode string
|
||||||
content = content.encode(str_encoding)
|
content = content.encode(str_encoding)
|
||||||
@@ -361,25 +464,22 @@ class DjrillBackend(BaseEmailBackend):
|
|||||||
}
|
}
|
||||||
return mandrill_attachment, is_embedded_image
|
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
|
||||||
|
|
||||||
############################################################################################
|
datetime becomes "YYYY-MM-DD HH:MM:SS"
|
||||||
# Recreate this module, but with a warning on attempts to import deprecated properties.
|
converted to UTC, if timezone-aware
|
||||||
# This is ugly, but (surprisingly) blessed: http://stackoverflow.com/a/7668273/647002
|
microseconds removed
|
||||||
import sys
|
date becomes "YYYY-MM-DD 00:00:00"
|
||||||
import types
|
anything else gets returned intact
|
||||||
|
"""
|
||||||
|
if isinstance(dt, datetime):
|
||||||
class ModuleWithDeprecatedProps(types.ModuleType):
|
dt = dt.replace(microsecond=0)
|
||||||
def __init__(self, module):
|
if dt.utcoffset() is not None:
|
||||||
self._orig_module = module # must keep a ref around, or it'll get deallocated
|
dt = (dt - dt.utcoffset()).replace(tzinfo=None)
|
||||||
super(ModuleWithDeprecatedProps, self).__init__(module.__name__, module.__doc__)
|
return dt.isoformat(' ')
|
||||||
self.__dict__.update(module.__dict__)
|
elif isinstance(dt, date):
|
||||||
|
return dt.isoformat() + ' 00:00:00'
|
||||||
@property
|
else:
|
||||||
def DjrillBackendHTTPError(self):
|
return dt
|
||||||
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__])
|
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
<div id="changelist-filter">
|
|
||||||
<h2>Tools & Info</h2>
|
|
||||||
<h3>Status</h3>
|
|
||||||
{% if status %}
|
|
||||||
<p>Mandrill is <strong>UP</strong></p>
|
|
||||||
{% else %}
|
|
||||||
<p>Mandrill is <strong>DOWN</strong></p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
@@ -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 %}
|
|
||||||
@@ -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>
|
|
||||||
›
|
|
||||||
Djrill
|
|
||||||
›
|
|
||||||
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 %}
|
|
||||||
@@ -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>
|
|
||||||
›
|
|
||||||
Djrill
|
|
||||||
›
|
|
||||||
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 %}
|
|
||||||
@@ -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>
|
|
||||||
›
|
|
||||||
Djrill
|
|
||||||
›
|
|
||||||
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 %}
|
|
||||||
@@ -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>
|
|
||||||
›
|
|
||||||
Djrill
|
|
||||||
›
|
|
||||||
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 %}
|
|
||||||
@@ -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)
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
from djrill.tests.test_admin import *
|
from .test_mandrill_integration import *
|
||||||
from djrill.tests.test_legacy import *
|
from .test_mandrill_send import *
|
||||||
from djrill.tests.test_mandrill_send import *
|
from .test_mandrill_send_template import *
|
||||||
from djrill.tests.test_mandrill_send_template import *
|
from .test_mandrill_session_sharing import *
|
||||||
from djrill.tests.test_mandrill_webhook import *
|
from .test_mandrill_subaccounts import *
|
||||||
from djrill.tests.test_mandrill_subaccounts import *
|
from .test_mandrill_webhook import *
|
||||||
|
|
||||||
from djrill.tests.test_mandrill_integration import *
|
|
||||||
|
|||||||
@@ -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)),
|
|
||||||
]
|
|
||||||
@@ -1,28 +1,35 @@
|
|||||||
import json
|
import json
|
||||||
from mock import patch
|
|
||||||
import requests
|
import requests
|
||||||
import six
|
import six
|
||||||
|
from mock import patch
|
||||||
|
|
||||||
from django.test import TestCase
|
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",
|
@override_settings(MANDRILL_API_KEY="FAKE_API_KEY_FOR_TESTING",
|
||||||
EMAIL_BACKEND="djrill.mail.backends.djrill.DjrillBackend")
|
EMAIL_BACKEND="djrill.mail.backends.djrill.DjrillBackend")
|
||||||
class DjrillBackendMockAPITestCase(TestCase, BackportedAssertions):
|
class DjrillBackendMockAPITestCase(TestCase):
|
||||||
"""TestCase that uses Djrill EmailBackend with a mocked Mandrill API"""
|
"""TestCase that uses Djrill EmailBackend with a mocked Mandrill API"""
|
||||||
|
|
||||||
class MockResponse(requests.Response):
|
class MockResponse(requests.Response):
|
||||||
"""requests.post return value mock sufficient for DjrillBackend"""
|
"""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__()
|
super(DjrillBackendMockAPITestCase.MockResponse, self).__init__()
|
||||||
self.status_code = status_code
|
self.status_code = status_code
|
||||||
self.encoding = encoding
|
self.encoding = encoding
|
||||||
self.raw = six.BytesIO(raw)
|
self.raw = six.BytesIO(raw)
|
||||||
|
|
||||||
def setUp(self):
|
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 = self.patch.start()
|
||||||
self.mock_post.return_value = self.MockResponse()
|
self.mock_post.return_value = self.MockResponse()
|
||||||
|
|
||||||
@@ -40,7 +47,7 @@ class DjrillBackendMockAPITestCase(TestCase, BackportedAssertions):
|
|||||||
raise AssertionError("Mandrill API was not called")
|
raise AssertionError("Mandrill API was not called")
|
||||||
(args, kwargs) = self.mock_post.call_args
|
(args, kwargs) = self.mock_post.call_args
|
||||||
try:
|
try:
|
||||||
post_url = kwargs.get('url', None) or args[0]
|
post_url = kwargs.get('url', None) or args[1]
|
||||||
except IndexError:
|
except IndexError:
|
||||||
raise AssertionError("requests.post was called without an url (?!)")
|
raise AssertionError("requests.post was called without an url (?!)")
|
||||||
if not post_url.endswith(endpoint):
|
if not post_url.endswith(endpoint):
|
||||||
@@ -57,7 +64,7 @@ class DjrillBackendMockAPITestCase(TestCase, BackportedAssertions):
|
|||||||
raise AssertionError("Mandrill API was not called")
|
raise AssertionError("Mandrill API was not called")
|
||||||
(args, kwargs) = self.mock_post.call_args
|
(args, kwargs) = self.mock_post.call_args
|
||||||
try:
|
try:
|
||||||
post_data = kwargs.get('data', None) or args[1]
|
post_data = kwargs.get('data', None) or args[2]
|
||||||
except IndexError:
|
except IndexError:
|
||||||
raise AssertionError("requests.post was called without data")
|
raise AssertionError("requests.post was called without data")
|
||||||
return json.loads(post_data)
|
return json.loads(post_data)
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -1,26 +1,23 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
from django.core import mail
|
from django.core import mail
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
|
||||||
from djrill import MandrillAPIError
|
from djrill import MandrillAPIError, MandrillRecipientsRefused
|
||||||
from djrill.tests.utils import BackportedAssertions, override_settings
|
|
||||||
|
|
||||||
try:
|
|
||||||
from unittest import skipUnless
|
|
||||||
except ImportError:
|
|
||||||
from django.utils.unittest import skipUnless
|
|
||||||
|
|
||||||
|
|
||||||
MANDRILL_TEST_API_KEY = os.getenv('MANDRILL_TEST_API_KEY')
|
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")
|
"Set MANDRILL_TEST_API_KEY environment variable to run integration tests")
|
||||||
@override_settings(MANDRILL_API_KEY=MANDRILL_TEST_API_KEY,
|
@override_settings(MANDRILL_API_KEY=MANDRILL_TEST_API_KEY,
|
||||||
EMAIL_BACKEND="djrill.mail.backends.djrill.DjrillBackend")
|
EMAIL_BACKEND="djrill.mail.backends.djrill.DjrillBackend")
|
||||||
class DjrillIntegrationTests(TestCase, BackportedAssertions):
|
class DjrillIntegrationTests(TestCase):
|
||||||
"""Mandrill API integration tests
|
"""Mandrill API integration tests
|
||||||
|
|
||||||
These tests run against the **live** Mandrill API, using the
|
These tests run against the **live** Mandrill API, using the
|
||||||
@@ -43,7 +40,7 @@ class DjrillIntegrationTests(TestCase, BackportedAssertions):
|
|||||||
self.assertEqual(sent_count, 1)
|
self.assertEqual(sent_count, 1)
|
||||||
# noinspection PyUnresolvedReferences
|
# noinspection PyUnresolvedReferences
|
||||||
response = self.message.mandrill_response
|
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.assertEqual(response[0]['email'], 'to@example.com')
|
||||||
self.assertGreater(len(response[0]['_id']), 0)
|
self.assertGreater(len(response[0]['_id']), 0)
|
||||||
|
|
||||||
@@ -61,21 +58,41 @@ class DjrillIntegrationTests(TestCase, BackportedAssertions):
|
|||||||
def test_invalid_to(self):
|
def test_invalid_to(self):
|
||||||
# Example of detecting when a recipient is not a valid email address
|
# Example of detecting when a recipient is not a valid email address
|
||||||
self.message.to = ['invalid@localhost']
|
self.message.to = ['invalid@localhost']
|
||||||
sent_count = self.message.send()
|
try:
|
||||||
self.assertEqual(sent_count, 1) # The send call is "successful"...
|
self.message.send()
|
||||||
# noinspection PyUnresolvedReferences
|
except MandrillRecipientsRefused:
|
||||||
response = self.message.mandrill_response
|
# Mandrill refused to deliver the mail -- message.mandrill_response will tell you why:
|
||||||
self.assertEqual(response[0]['status'], 'invalid') # ... but the mail is not delivered
|
# 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):
|
def test_rejected_to(self):
|
||||||
# Example of detecting when a recipient is on Mandrill's rejection blacklist
|
# Example of detecting when a recipient is on Mandrill's rejection blacklist
|
||||||
self.message.to = ['reject@test.mandrillapp.com']
|
self.message.to = ['reject@test.mandrillapp.com']
|
||||||
sent_count = self.message.send()
|
try:
|
||||||
self.assertEqual(sent_count, 1) # The send call is "successful"...
|
self.message.send()
|
||||||
# noinspection PyUnresolvedReferences
|
except MandrillRecipientsRefused:
|
||||||
response = self.message.mandrill_response
|
# Mandrill refused to deliver the mail -- message.mandrill_response will tell you why:
|
||||||
self.assertEqual(response[0]['status'], 'rejected') # ... but the mail is not delivered
|
# noinspection PyUnresolvedReferences
|
||||||
self.assertEqual(response[0]['reject_reason'], 'test') # ... and here's why
|
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!")
|
@override_settings(MANDRILL_API_KEY="Hey, that's not an API key!")
|
||||||
def test_invalid_api_key(self):
|
def test_invalid_api_key(self):
|
||||||
|
|||||||
@@ -2,28 +2,27 @@
|
|||||||
|
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import six
|
||||||
|
import unittest
|
||||||
from base64 import b64decode
|
from base64 import b64decode
|
||||||
from datetime import date, datetime, timedelta, tzinfo
|
from datetime import date, datetime, timedelta, tzinfo
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from email.mime.base import MIMEBase
|
from email.mime.base import MIMEBase
|
||||||
from email.mime.image import MIMEImage
|
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 import mail
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from django.core.mail import make_msgid
|
from django.core.mail import make_msgid
|
||||||
from django.test import TestCase
|
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 .mock_backend import DjrillBackendMockAPITestCase
|
||||||
from .utils import override_settings
|
|
||||||
|
|
||||||
|
|
||||||
def decode_att(att):
|
def decode_att(att):
|
||||||
@@ -158,7 +157,7 @@ class DjrillBackendTests(DjrillBackendMockAPITestCase):
|
|||||||
headers={'X-Other': 'Keep'})
|
headers={'X-Other': 'Keep'})
|
||||||
except TypeError:
|
except TypeError:
|
||||||
# Pre-Django 1.8
|
# 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()
|
email.send()
|
||||||
self.assert_mandrill_called("/messages/send.json")
|
self.assert_mandrill_called("/messages/send.json")
|
||||||
data = self.get_api_call_data()
|
data = self.get_api_call_data()
|
||||||
@@ -342,6 +341,9 @@ class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase):
|
|||||||
self.message = mail.EmailMessage('Subject', 'Text Body',
|
self.message = mail.EmailMessage('Subject', 'Text Body',
|
||||||
'from@example.com', ['to@example.com'])
|
'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):
|
def test_tracking(self):
|
||||||
# First make sure we're not setting the API param if the track_click
|
# 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,
|
# 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):
|
def test_send_attaches_mandrill_response(self):
|
||||||
""" The mandrill_response should be attached to the message when it is sent """
|
""" 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)))
|
self.mock_post.return_value = self.MockResponse(raw=six.b(json.dumps(response)))
|
||||||
msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com'],)
|
msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com'],)
|
||||||
sent = msg.send()
|
sent = msg.send()
|
||||||
@@ -533,15 +535,211 @@ class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase):
|
|||||||
self.assertEqual(sent, 0)
|
self.assertEqual(sent, 0)
|
||||||
self.assertIsNone(msg.mandrill_response)
|
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"""
|
"""Try to provide more information about non-json-serializable data"""
|
||||||
self.message.global_merge_vars = {'PRICE': Decimal('19.99')}
|
self.message.global_merge_vars = {'PRICE': Decimal('19.99')}
|
||||||
with self.assertRaisesMessage(
|
with self.assertRaises(NotSerializableForMandrillError) as cm:
|
||||||
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."
|
|
||||||
):
|
|
||||||
self.message.send()
|
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")
|
@override_settings(EMAIL_BACKEND="djrill.mail.backends.djrill.DjrillBackend")
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from django.core import mail
|
from django.core import mail
|
||||||
|
|
||||||
from djrill import MandrillAPIError
|
from djrill import MandrillAPIError
|
||||||
from djrill.tests.mock_backend import DjrillBackendMockAPITestCase
|
|
||||||
|
from .mock_backend import DjrillBackendMockAPITestCase
|
||||||
|
|
||||||
|
|
||||||
class DjrillMandrillSendTemplateTests(DjrillBackendMockAPITestCase):
|
class DjrillMandrillSendTemplateTests(DjrillBackendMockAPITestCase):
|
||||||
|
|||||||
72
djrill/tests/test_mandrill_session_sharing.py
Normal file
72
djrill/tests/test_mandrill_session_sharing.py
Normal 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)
|
||||||
@@ -1,46 +1,42 @@
|
|||||||
from django.core import mail
|
from django.core import mail
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
|
||||||
from .mock_backend import DjrillBackendMockAPITestCase
|
from .mock_backend import DjrillBackendMockAPITestCase
|
||||||
from .utils import override_settings
|
|
||||||
|
|
||||||
|
|
||||||
class DjrillMandrillSubaccountTests(DjrillBackendMockAPITestCase):
|
class DjrillMandrillSubaccountTests(DjrillBackendMockAPITestCase):
|
||||||
"""Test Djrill backend support for Mandrill subaccounts"""
|
"""Test Djrill backend support for Mandrill subaccounts"""
|
||||||
|
|
||||||
def test_send_basic(self):
|
def test_no_subaccount_by_default(self):
|
||||||
mail.send_mail('Subject here', 'Here is the message.',
|
mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com'])
|
||||||
'from@example.com', ['to@example.com'], fail_silently=False)
|
|
||||||
self.assert_mandrill_called("/messages/send.json")
|
|
||||||
data = self.get_api_call_data()
|
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'])
|
self.assertFalse('subaccount' in data['message'])
|
||||||
|
|
||||||
@override_settings(MANDRILL_SUBACCOUNT="test_subaccount")
|
@override_settings(MANDRILL_SETTINGS={'subaccount': 'test_subaccount'})
|
||||||
def test_send_from_subaccount(self):
|
def test_subaccount_setting(self):
|
||||||
mail.send_mail('Subject here', 'Here is the message.',
|
mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com'])
|
||||||
'from@example.com', ['to@example.com'], fail_silently=False)
|
|
||||||
self.assert_mandrill_called("/messages/send.json")
|
|
||||||
data = self.get_api_call_data()
|
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")
|
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):
|
def test_subaccount_message_overrides_setting(self):
|
||||||
message = mail.EmailMessage(
|
message = mail.EmailMessage('Subject', 'Body', 'from@example.com', ['to@example.com'])
|
||||||
'Subject here', 'Here is the message',
|
|
||||||
'from@example.com', ['to@example.com'])
|
|
||||||
message.subaccount = "individual_message_subaccount" # should override global setting
|
message.subaccount = "individual_message_subaccount" # should override global setting
|
||||||
message.send()
|
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()
|
data = self.get_api_call_data()
|
||||||
self.assertEqual(data['message']['subaccount'], "individual_message_subaccount")
|
self.assertEqual(data['message']['subaccount'], "individual_message_subaccount")
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
from base64 import b64encode
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
|
from base64 import b64encode
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
|
||||||
from ..compat import b
|
from djrill.compat import b
|
||||||
from ..signals import webhook_event
|
from djrill.signals import webhook_event
|
||||||
from .utils import override_settings
|
|
||||||
|
|
||||||
|
|
||||||
class DjrillWebhookSecretMixinTests(TestCase):
|
class DjrillWebhookSecretMixinTests(TestCase):
|
||||||
|
|||||||
@@ -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()
|
|
||||||
142
djrill/views.py
142
djrill/views.py
@@ -1,89 +1,17 @@
|
|||||||
from base64 import b64encode
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
from django import forms
|
from base64 import b64encode
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from django.views.generic import TemplateView, View
|
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
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
|
from .compat import b
|
||||||
|
from .signals import webhook_event
|
||||||
|
|
||||||
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"])
|
|
||||||
|
|
||||||
|
|
||||||
class DjrillWebhookSecretMixin(object):
|
class DjrillWebhookSecretMixin(object):
|
||||||
@@ -139,66 +67,6 @@ class DjrillWebhookSignatureMixin(object):
|
|||||||
request, *args, **kwargs)
|
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):
|
class DjrillWebhookView(DjrillWebhookSecretMixin, DjrillWebhookSignatureMixin, View):
|
||||||
def head(self, request, *args, **kwargs):
|
def head(self, request, *args, **kwargs):
|
||||||
return HttpResponse()
|
return HttpResponse()
|
||||||
@@ -210,7 +78,7 @@ class DjrillWebhookView(DjrillWebhookSecretMixin, DjrillWebhookSignatureMixin, V
|
|||||||
return HttpResponse(status=400)
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
for event in data:
|
for event in data:
|
||||||
signals.webhook_event.send(
|
webhook_event.send(
|
||||||
sender=None, event_type=event['event'], data=event)
|
sender=None, event_type=event['event'], data=event)
|
||||||
|
|
||||||
return HttpResponse()
|
return HttpResponse()
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
.. _history:
|
||||||
|
|
||||||
Release Notes
|
Release Notes
|
||||||
=============
|
=============
|
||||||
|
|
||||||
@@ -7,68 +9,32 @@ Among other things, this means that minor updates
|
|||||||
and breaking changes will always increment the
|
and breaking changes will always increment the
|
||||||
major version number (1.x to 2.0).
|
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.
|
Djrill 2.x
|
||||||
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
|
|
||||||
----------
|
----------
|
||||||
|
|
||||||
|
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:
|
Version 1.4:
|
||||||
|
|
||||||
* Django 1.8 support
|
* Django 1.8 support
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ Documentation
|
|||||||
|
|
||||||
quickstart
|
quickstart
|
||||||
installation
|
installation
|
||||||
|
upgrading
|
||||||
usage/sending_mail
|
usage/sending_mail
|
||||||
usage/templates
|
usage/templates
|
||||||
usage/multiple_backends
|
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
|
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_
|
:doc:`contributors <contributing>`.
|
||||||
library that got us off on the right foot for the custom admin views.
|
|
||||||
Oh, and, of course, Kenneth Reitz for the awesome requests_ library.
|
Oh, and, of course, Kenneth Reitz for the awesome requests_ library.
|
||||||
|
|
||||||
.. _requests: http://docs.python-requests.org
|
.. _requests: http://docs.python-requests.org
|
||||||
.. _django-adminplus: https://github.com/jsocol/django-adminplus
|
|
||||||
|
|||||||
@@ -47,65 +47,71 @@ Djrill includes optional support for Mandrill webhooks, including inbound email.
|
|||||||
See the Djrill :ref:`webhooks <webhooks>` section for configuration details.
|
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
|
.. setting:: MANDRILL_SUBACCOUNT
|
||||||
|
|
||||||
If you are using Mandrill's `subaccounts`_ feature, you can globally set the
|
MANDRILL_SUBACCOUNT
|
||||||
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
|
|
||||||
|
|
||||||
|
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-
|
.. _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
121
docs/upgrading.rst
Normal 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.
|
||||||
@@ -32,10 +32,6 @@ Some notes and limitations:
|
|||||||
to `!True` if you want recipients to be able to see who else was included
|
to `!True` if you want recipients to be able to see who else was included
|
||||||
in the "to" list.
|
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:
|
.. _sending-html:
|
||||||
|
|
||||||
@@ -70,12 +66,6 @@ Some notes and limitations:
|
|||||||
(For an example, see :meth:`~DjrillBackendTests.test_embedded_images`
|
(For an example, see :meth:`~DjrillBackendTests.test_embedded_images`
|
||||||
in :file:`tests/test_mandrill_send.py`.)
|
in :file:`tests/test_mandrill_send.py`.)
|
||||||
|
|
||||||
.. versionadded:: 0.3
|
|
||||||
Attachments
|
|
||||||
|
|
||||||
.. versionchanged:: 0.4
|
|
||||||
Special handling for embedded images
|
|
||||||
|
|
||||||
.. _message-headers:
|
.. _message-headers:
|
||||||
|
|
||||||
**Headers**
|
**Headers**
|
||||||
@@ -87,11 +77,8 @@ Some notes and limitations:
|
|||||||
headers={'Reply-To': "reply@example.com", 'List-Unsubscribe': "..."}
|
headers={'Reply-To': "reply@example.com", 'List-Unsubscribe': "..."}
|
||||||
)
|
)
|
||||||
|
|
||||||
.. versionchanged:: 0.9
|
.. note::
|
||||||
In earlier versions, Djrill only allowed ``Reply-To`` and ``X-*`` headers,
|
|
||||||
matching previous Mandrill API restrictions.
|
|
||||||
|
|
||||||
.. versionchanged:: 1.4
|
|
||||||
Djrill also supports the `reply_to` param added to
|
Djrill also supports the `reply_to` param added to
|
||||||
:class:`~django.core.mail.EmailMessage` in Django 1.8.
|
:class:`~django.core.mail.EmailMessage` in Django 1.8.
|
||||||
(If you provide *both* a 'Reply-To' header and the `reply_to` param,
|
(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
|
Most of the options from the Mandrill
|
||||||
`messages/send API <https://mandrillapp.com/api/docs/messages.html#method=send>`_
|
`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`
|
`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...
|
.. 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.
|
``Boolean``: whether Mandrill should send this message ahead of non-important ones.
|
||||||
|
|
||||||
.. versionadded:: 0.7
|
|
||||||
|
|
||||||
.. attribute:: track_opens
|
.. attribute:: track_opens
|
||||||
|
|
||||||
``Boolean``: whether Mandrill should enable open-tracking for this message.
|
``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.
|
``Boolean``: whether Mandrill should inline CSS styles in the HTML.
|
||||||
Default from your Mandrill account settings.
|
Default from your Mandrill account settings.
|
||||||
|
|
||||||
.. versionadded:: 0.4
|
|
||||||
|
|
||||||
.. attribute:: url_strip_qs
|
.. attribute:: url_strip_qs
|
||||||
|
|
||||||
``Boolean``: whether Mandrill should ignore any query parameters when aggregating
|
``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.
|
``Boolean``: set False on sensitive messages to instruct Mandrill not to log the content.
|
||||||
|
|
||||||
.. versionadded:: 0.7
|
|
||||||
|
|
||||||
.. attribute:: tracking_domain
|
.. attribute:: tracking_domain
|
||||||
|
|
||||||
``str``: domain Mandrill should use to rewrite tracked links and host tracking pixels
|
``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.
|
``str``: domain Mandrill should use for the message's return-path.
|
||||||
|
|
||||||
.. versionadded:: 0.7
|
|
||||||
|
|
||||||
.. attribute:: merge_language
|
.. attribute:: merge_language
|
||||||
|
|
||||||
``str``: the merge tag language if using merge tags -- e.g., "mailchimp" or "handlebars".
|
``str``: the merge tag language if using merge tags -- e.g., "mailchimp" or "handlebars".
|
||||||
Default from your Mandrill account settings.
|
Default from your Mandrill account settings.
|
||||||
|
|
||||||
.. versionadded:: 1.3
|
|
||||||
|
|
||||||
.. attribute:: global_merge_vars
|
.. attribute:: global_merge_vars
|
||||||
|
|
||||||
``dict``: merge variables to use for all recipients (most useful with :ref:`mandrill-templates`). ::
|
``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
|
.. attribute:: subaccount
|
||||||
|
|
||||||
``str``: the ID of one of your subaccounts to use for sending this message.
|
``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
|
.. 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.
|
``Boolean``: whether Mandrill should use an async mode optimized for bulk sending.
|
||||||
|
|
||||||
.. versionadded:: 0.7
|
|
||||||
|
|
||||||
.. attribute:: ip_pool
|
.. attribute:: ip_pool
|
||||||
|
|
||||||
``str``: name of one of your Mandrill dedicated IP pools to use for sending this message.
|
``str``: name of one of your Mandrill dedicated IP pools to use for sending this message.
|
||||||
|
|
||||||
.. versionadded:: 0.7
|
|
||||||
|
|
||||||
.. attribute:: send_at
|
.. attribute:: send_at
|
||||||
|
|
||||||
``datetime`` or ``date`` or ``str``: instructs Mandrill to delay sending this message
|
`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
|
until the specified time. Example::
|
||||||
to UTC for Mandrill. Timezone-naive datetimes are assumed to be UTC.)
|
|
||||||
|
|
||||||
.. 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
|
:class:`~django.core.mail.EmailMessage`-derived object, so you can use them with
|
||||||
many other apps that add Django mail functionality.
|
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:
|
||||||
|
|
||||||
Mandrill Response
|
Response from Mandrill
|
||||||
-----------------
|
----------------------
|
||||||
|
|
||||||
A ``mandrill_response`` property is added to each :class:`~django.core.mail.EmailMessage` that you
|
.. attribute:: mandrill_response
|
||||||
send. 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, ``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
|
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.
|
`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.
|
If an error is returned by Mandrill while sending the message then :attr:`!mandrill_response` will be set to None.
|
||||||
|
|
||||||
.. versionadded:: 0.8
|
|
||||||
mandrill_response available for sent messages
|
|
||||||
|
|
||||||
|
|
||||||
.. _djrill-exceptions:
|
.. _djrill-exceptions:
|
||||||
@@ -334,9 +336,6 @@ If an error is returned by Mandrill while sending the message then ``mandrill_re
|
|||||||
Exceptions
|
Exceptions
|
||||||
----------
|
----------
|
||||||
|
|
||||||
.. versionadded:: 0.3
|
|
||||||
Djrill-specific exceptions
|
|
||||||
|
|
||||||
.. exception:: djrill.NotSupportedByMandrillError
|
.. exception:: djrill.NotSupportedByMandrillError
|
||||||
|
|
||||||
If the email tries to use features that aren't supported by Mandrill, the send
|
If the email tries to use features that aren't supported by Mandrill, the send
|
||||||
@@ -344,6 +343,27 @@ Exceptions
|
|||||||
of :exc:`ValueError`).
|
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
|
.. exception:: djrill.MandrillAPIError
|
||||||
|
|
||||||
If the Mandrill API fails or returns an error response, the send call will
|
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
|
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
|
`API error log <https://mandrillapp.com/settings/api>`_ to view the full API
|
||||||
request and error response.)
|
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.
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ Sending Template Mail
|
|||||||
Mandrill Templates
|
Mandrill Templates
|
||||||
------------------
|
------------------
|
||||||
|
|
||||||
.. versionadded:: 0.3
|
|
||||||
Mandrill template support
|
|
||||||
|
|
||||||
To use a *Mandrill* (MailChimp) template stored in your Mandrill account,
|
To use a *Mandrill* (MailChimp) template stored in your Mandrill account,
|
||||||
set a :attr:`template_name` and (optionally) :attr:`template_content`
|
set a :attr:`template_name` and (optionally) :attr:`template_content`
|
||||||
on your :class:`~django.core.mail.EmailMessage` object::
|
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).
|
(for templates designed to handle objects and arrays).
|
||||||
See the Python :class:`json.JSONEncoder` docs for a list of allowable types.
|
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
|
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
|
If `True`, Djrill will omit the subject, and Mandrill will
|
||||||
use the default subject from the template.
|
use the default subject from the template.
|
||||||
|
|
||||||
.. versionadded:: 1.1
|
|
||||||
|
|
||||||
.. attribute:: use_template_from
|
.. attribute:: use_template_from
|
||||||
|
|
||||||
If `True`, Djrill will omit the "from" field, and Mandrill will
|
If `True`, Djrill will omit the "from" field, and Mandrill will
|
||||||
use the default "from" from the template.
|
use the default "from" from the template.
|
||||||
|
|
||||||
.. versionadded:: 1.1
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.. _django-templates:
|
.. _django-templates:
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
If enabled, it will send a Django signal for each event in a webhook.
|
||||||
Your code can connect to this signal for further processing.
|
Your code can connect to this signal for further processing.
|
||||||
|
|
||||||
.. versionadded:: 0.5
|
|
||||||
Webhook support
|
|
||||||
|
|
||||||
|
|
||||||
.. warning:: Webhook Security
|
.. warning:: Webhook Security
|
||||||
|
|
||||||
Webhooks are ordinary urls---they're wide open to the internet.
|
Webhooks are ordinary urls---they're wide open to the internet.
|
||||||
|
|||||||
25
runtests.py
25
runtests.py
@@ -3,13 +3,9 @@
|
|||||||
# python runtests.py
|
# python runtests.py
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
from django import VERSION as django_version
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
APP = 'djrill'
|
APP = 'djrill'
|
||||||
ADMIN = 'django.contrib.admin'
|
|
||||||
if django_version >= (1, 7):
|
|
||||||
ADMIN = 'django.contrib.admin.apps.SimpleAdminConfig'
|
|
||||||
|
|
||||||
settings.configure(
|
settings.configure(
|
||||||
DEBUG=True,
|
DEBUG=True,
|
||||||
@@ -23,7 +19,7 @@ settings.configure(
|
|||||||
'django.contrib.auth',
|
'django.contrib.auth',
|
||||||
'django.contrib.contenttypes',
|
'django.contrib.contenttypes',
|
||||||
'django.contrib.sessions',
|
'django.contrib.sessions',
|
||||||
ADMIN,
|
'django.contrib.admin',
|
||||||
APP,
|
APP,
|
||||||
),
|
),
|
||||||
MIDDLEWARE_CLASSES=(
|
MIDDLEWARE_CLASSES=(
|
||||||
@@ -33,25 +29,10 @@ settings.configure(
|
|||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
),
|
),
|
||||||
TEMPLATES=[
|
TEMPLATES=[
|
||||||
# Django 1.8 starter-project template settings
|
# Djrill doesn't have any templates, but tests need a TEMPLATES
|
||||||
# (needed for test_admin)
|
# setting to avoid warnings from the Django 1.8+ test client.
|
||||||
{
|
{
|
||||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
'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',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
5
setup.py
5
setup.py
@@ -29,7 +29,7 @@ setup(
|
|||||||
license="BSD License",
|
license="BSD License",
|
||||||
packages=["djrill"],
|
packages=["djrill"],
|
||||||
zip_safe=False,
|
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,
|
include_package_data=True,
|
||||||
test_suite="runtests.runtests",
|
test_suite="runtests.runtests",
|
||||||
tests_require=["mock", "six"],
|
tests_require=["mock", "six"],
|
||||||
@@ -37,12 +37,11 @@ setup(
|
|||||||
"Programming Language :: Python",
|
"Programming Language :: Python",
|
||||||
"Programming Language :: Python :: Implementation :: PyPy",
|
"Programming Language :: Python :: Implementation :: PyPy",
|
||||||
"Programming Language :: Python :: Implementation :: CPython",
|
"Programming Language :: Python :: Implementation :: CPython",
|
||||||
"Programming Language :: Python :: 2.6",
|
|
||||||
"Programming Language :: Python :: 2.7",
|
"Programming Language :: Python :: 2.7",
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
"Programming Language :: Python :: 3.2",
|
|
||||||
"Programming Language :: Python :: 3.3",
|
"Programming Language :: Python :: 3.3",
|
||||||
"Programming Language :: Python :: 3.4",
|
"Programming Language :: Python :: 3.4",
|
||||||
|
"Programming Language :: Python :: 3.5",
|
||||||
"License :: OSI Approved :: BSD License",
|
"License :: OSI Approved :: BSD License",
|
||||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||||
"Framework :: Django",
|
"Framework :: Django",
|
||||||
|
|||||||
Reference in New Issue
Block a user