├── .coveragerc ├── .gitignore ├── .pylintrc ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.rst ├── django_bouncy ├── __init__.py ├── admin.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20150927_1413.py │ ├── 0003_auto_20151106_1626.py │ ├── 0004_increase_processing_time.py │ └── __init__.py ├── models.py ├── signals.py ├── tests │ ├── __init__.py │ ├── examples │ │ ├── SimpleNotificationService-e372f8ca30337fdb084e8ac449342c77.pem │ │ ├── example_bounce.json │ │ ├── example_bounce_nocode.json │ │ ├── example_bounce_notification.json │ │ ├── example_complaint.json │ │ ├── example_complaint_nofeedback.json │ │ ├── example_complaint_notification.json │ │ ├── example_confirmsubscription.xml │ │ ├── example_delivery.json │ │ ├── example_delivery_notification.json │ │ └── example_subscriptionconfirmation.json │ ├── helpers.py │ ├── utils.py │ └── views.py ├── urls.py ├── utils.py └── views.py ├── example_project ├── __init__.py └── urls.py ├── ez_setup.py ├── manage.py ├── requirements.txt ├── setup.py ├── test_settings.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = *migrations* -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.log 3 | build/ 4 | dist/ 5 | *.egg-info 6 | *.egg 7 | .DS_Store 8 | coverage.xml 9 | nosetests.xml 10 | example.db 11 | pep8.txt 12 | venv 13 | .tox 14 | 15 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # Specify a configuration file. 4 | #rcfile= 5 | 6 | # Python code to execute, usually for sys.path manipulation such as 7 | # pygtk.require(). 8 | #init-hook= 9 | 10 | # Profiled execution. 11 | profile=no 12 | 13 | # Add files or directories to the blacklist. They should be base names, not 14 | # paths. 15 | ignore=migrations 16 | 17 | # Pickle collected data for later comparisons. 18 | persistent=yes 19 | 20 | # List of plugins (as comma separated values of python modules names) to load, 21 | # usually to register additional checkers. 22 | load-plugins= 23 | 24 | 25 | [MESSAGES CONTROL] 26 | 27 | # Enable the message, report, category or checker with the given id(s). You can 28 | # either give multiple identifier separated by comma (,) or put this option 29 | # multiple time. See also the "--disable" option for examples. 30 | #enable= 31 | 32 | # Disable the message, report, category or checker with the given id(s). You 33 | # can either give multiple identifiers separated by comma (,) or put this 34 | # option multiple times (only on the command line, not in the configuration 35 | # file where it should appear only once).You can also use "--disable=all" to 36 | # disable everything first and then reenable specific checks. For example, if 37 | # you want to run only the similarities checker, you can use "--disable=all 38 | # --enable=similarities". If you want to run only the classes checker, but have 39 | # no Warning level messages displayed, use"--disable=all --enable=classes 40 | # --disable=W" 41 | disable=too-many-public-methods,too-few-public-methods,no-member,super-on-old-class,too-many-ancestors,I0011,star-args 42 | 43 | 44 | [REPORTS] 45 | 46 | # Set the output format. Available formats are text, parseable, colorized, msvs 47 | # (visual studio) and html. You can also give a reporter class, eg 48 | # mypackage.mymodule.MyReporterClass. 49 | output-format=colorized 50 | 51 | # Put messages in a separate file for each module / package specified on the 52 | # command line instead of printing them on stdout. Reports (if any) will be 53 | # written in a file name "pylint_global.[txt|html]". 54 | files-output=no 55 | 56 | # Tells whether to display a full report or only the messages 57 | reports=yes 58 | 59 | # Python expression which should return a note less than 10 (10 is the highest 60 | # note). You have access to the variables errors warning, statement which 61 | # respectively contain the number of errors / warnings messages and the total 62 | # number of statements analyzed. This is used by the global evaluation report 63 | # (RP0004). 64 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 65 | 66 | # Add a comment according to your evaluation note. This is used by the global 67 | # evaluation report (RP0004). 68 | comment=no 69 | 70 | # Template used to display messages. This is a python new-style format string 71 | # used to format the massage information. See doc for all details 72 | #msg-template= 73 | 74 | 75 | [BASIC] 76 | 77 | # Required attributes for module, separated by a comma 78 | required-attributes= 79 | 80 | # List of builtins function names that should not be used, separated by a comma 81 | bad-functions=map,filter,apply,input 82 | 83 | # Regular expression which should only match correct module names 84 | module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 85 | 86 | # Regular expression which should only match correct module level names 87 | const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))|logger$ 88 | 89 | # Regular expression which should only match correct class names 90 | class-rgx=[A-Z_][a-zA-Z0-9]+$ 91 | 92 | # Regular expression which should only match correct function names 93 | function-rgx=[a-z_][a-z0-9_]{2,50}$ 94 | 95 | # Regular expression which should only match correct method names 96 | method-rgx=[a-z_][a-z0-9_]{2,60}$ 97 | 98 | # Regular expression which should only match correct instance attribute names 99 | attr-rgx=[a-z_][a-z0-9_]{2,30}$ 100 | 101 | # Regular expression which should only match correct argument names 102 | argument-rgx=[a-z_][a-z0-9_]{2,30}$ 103 | 104 | # Regular expression which should only match correct variable names 105 | variable-rgx=[a-z_][a-z0-9_]{2,30}$ 106 | 107 | # Regular expression which should only match correct attribute names in class 108 | # bodies 109 | class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 110 | 111 | # Regular expression which should only match correct list comprehension / 112 | # generator expression variable names 113 | inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ 114 | 115 | # Good variable names which should always be accepted, separated by a comma 116 | good-names=i,j,k,ex,Run,_ 117 | 118 | # Bad variable names which should always be refused, separated by a comma 119 | bad-names=foo,bar,baz,toto,tutu,tata 120 | 121 | # Regular expression which should only match function or class names that do 122 | # not require a docstring. 123 | no-docstring-rgx=__.*__ 124 | 125 | # Minimum line length for functions/classes that require docstrings, shorter 126 | # ones are exempt. 127 | docstring-min-length=-1 128 | 129 | 130 | [FORMAT] 131 | 132 | # Maximum number of characters on a single line. 133 | max-line-length=80 134 | 135 | # Regexp for a line that is allowed to be longer than the limit. 136 | ignore-long-lines=^\s*(# )??$ 137 | 138 | # Maximum number of lines in a module 139 | max-module-lines=1000 140 | 141 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 142 | # tab). 143 | indent-string=' ' 144 | 145 | 146 | [MISCELLANEOUS] 147 | 148 | # List of note tags to take in consideration, separated by a comma. 149 | notes=FIXME,XXX,TODO 150 | 151 | 152 | [SIMILARITIES] 153 | 154 | # Minimum lines number of a similarity. 155 | min-similarity-lines=4 156 | 157 | # Ignore comments when computing similarities. 158 | ignore-comments=yes 159 | 160 | # Ignore docstrings when computing similarities. 161 | ignore-docstrings=yes 162 | 163 | # Ignore imports when computing similarities. 164 | ignore-imports=no 165 | 166 | 167 | [TYPECHECK] 168 | 169 | # Tells whether missing members accessed in mixin class should be ignored. A 170 | # mixin class is detected if its name ends with "mixin" (case insensitive). 171 | ignore-mixin-members=yes 172 | 173 | # List of classes names for which member attributes should not be checked 174 | # (useful for classes with attributes dynamically set). 175 | ignored-classes=SQLObject 176 | 177 | # When zope mode is activated, add a predefined set of Zope acquired attributes 178 | # to generated-members. 179 | zope=no 180 | 181 | # List of members which are set dynamically and missed by pylint inference 182 | # system, and so shouldn't trigger E0201 when accessed. Python regular 183 | # expressions are accepted. 184 | generated-members=REQUEST,acl_users,aq_parent,is_valid,status_code,context_data,user_set,objects,request,kwargs,DoesNotExist,content,template_name 185 | 186 | 187 | [VARIABLES] 188 | 189 | # Tells whether we should check for unused import in __init__ files. 190 | init-import=no 191 | 192 | # A regular expression matching the beginning of the name of dummy variables 193 | # (i.e. not used). 194 | dummy-variables-rgx=_$|dummy 195 | 196 | # List of additional names supposed to be defined in builtins. Remember that 197 | # you should avoid to define new builtins when possible. 198 | additional-builtins= 199 | 200 | 201 | [CLASSES] 202 | 203 | # List of interface methods to ignore, separated by a comma. This is used for 204 | # instance to not check methods defines in Zope's Interface base class. 205 | ignore-iface-methods=isImplementedBy,deferred,extends,names,namesAndDescriptions,queryDescriptionFor,getBases,getDescriptionFor,getDoc,getName,getTaggedValue,getTaggedValueTags,isEqualOrExtendedBy,setTaggedValue,isImplementedByInstancesOf,adaptWith,is_implemented_by 206 | 207 | # List of method names used to declare (i.e. assign) instance attributes. 208 | defining-attr-methods=__init__,__new__,setUp 209 | 210 | # List of valid names for the first argument in a class method. 211 | valid-classmethod-first-arg=cls 212 | 213 | # List of valid names for the first argument in a metaclass class method. 214 | valid-metaclass-classmethod-first-arg=mcs 215 | 216 | 217 | [DESIGN] 218 | 219 | # Maximum number of arguments for function / method 220 | max-args=5 221 | 222 | # Argument names that match this expression will be ignored. Default to name 223 | # with leading underscore 224 | ignored-argument-names=_.* 225 | 226 | # Maximum number of locals for function / method body 227 | max-locals=15 228 | 229 | # Maximum number of return / yield for function / method body 230 | max-returns=6 231 | 232 | # Maximum number of branch for function / method body 233 | max-branches=12 234 | 235 | # Maximum number of statements in function / method body 236 | max-statements=50 237 | 238 | # Maximum number of parents for a class (see R0901). 239 | max-parents=7 240 | 241 | # Maximum number of attributes for a class (see R0902). 242 | max-attributes=7 243 | 244 | # Minimum number of public methods for a class (see R0903). 245 | min-public-methods=2 246 | 247 | # Maximum number of public methods for a class (see R0904). 248 | max-public-methods=20 249 | 250 | 251 | [IMPORTS] 252 | 253 | # Deprecated modules which should not be used, separated by a comma 254 | deprecated-modules=regsub,TERMIOS,Bastion,rexec 255 | 256 | # Create a graph of every (i.e. internal and external) dependencies in the 257 | # given file (report RP0402 must not be disabled) 258 | import-graph= 259 | 260 | # Create a graph of external dependencies in the given file (report RP0402 must 261 | # not be disabled) 262 | ext-import-graph= 263 | 264 | # Create a graph of internal dependencies in the given file (report RP0402 must 265 | # not be disabled) 266 | int-import-graph= 267 | 268 | 269 | [EXCEPTIONS] 270 | 271 | # Exceptions that will emit a warning when being caught. Defaults to 272 | # "Exception" 273 | overgeneral-exceptions=Exception 274 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | # Test against 2.7, 3.3 and 3.4 3 | # We don't support 3.2 because the pem library doesn't support it 4 | python: 5 | - "2.7" 6 | - "3.4" 7 | - "3.5" 8 | - "3.6" 9 | - "pypy" 10 | # command to define we should test against the latest version of all supported 11 | # versions of django (1.11.x, 2.0.x, 2.1.x) 12 | env: 13 | - DJANGO_INSTALL=django\<1.11.99 14 | - DJANGO_INSTALL=django\<2.0.99 15 | - DJANGO_INSTALL=django\<2.1.99 16 | - DJANGO_INSTALL=git+https://github.com/django/django.git\#egg=django 17 | # command to install dependencies (mock & nose are already installed) 18 | install: 19 | - pip install $DJANGO_INSTALL 20 | - python setup.py install 21 | - pip install django-nose coverage mock 22 | - pip install --upgrade setuptools 23 | # command to run tests 24 | script: python manage.py test --settings 'test_settings' 25 | matrix: 26 | fast_finish: true 27 | allow_failures: 28 | # pypy testing support is experimental 29 | - python: "pypy" 30 | 31 | # Tests against the master branch may break 32 | - env: DJANGO_INSTALL=git+https://github.com/django/django.git\#egg=django 33 | exclude: 34 | # Django 2.0 onward doesn't support Python 2 35 | - python: "2.7" 36 | env: DJANGO_INSTALL=django\<2.0.99 37 | 38 | # Django 2.0 onward doesn't support Python 2 39 | - python: "2.7" 40 | env: DJANGO_INSTALL=django\<2.1.99 41 | 42 | # Django 2.1 onward doesn't support Python 3.4 43 | - python: "3.4" 44 | env: DJANGO_INSTALL=django\<2.1.99 45 | 46 | # Django latest onward doesn't support Python 3.4 47 | - python: "3.4" 48 | env: DJANGO_INSTALL=git+https://github.com/django/django.git\#egg=django 49 | 50 | # Django latest onward doesn't support Python 2 51 | - python: "2.7" 52 | env: DJANGO_INSTALL=git+https://github.com/django/django.git\#egg=django 53 | 54 | # turn off email notifications 55 | notifications: 56 | email: false 57 | deploy: 58 | # Allow travis-ci to deploy new tags to PyPI 59 | provider: pypi 60 | user: ofa 61 | password: 62 | secure: mynPDpo4PHD8VjYJ03Yui2Xr2ICxP56DEOfE4IOr8RRorqJH7BtrYrX4TkqYLb81IR9Mgnzbk9wslq7ShGfYhXgSFWujDPyCsliPG1v826FXEMe3U7yT5B+Vrt7xA6FkImCuZIROmrGL9KSbobFTsBnTL6/LxMZ6pTYCJiXyf9E= 63 | on: 64 | tags: true 65 | repo: ofa/django-bouncy 66 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Organizing for Action 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include MANIFEST.in 3 | recursive-include django_bouncy/tests * 4 | recursive-exclude * *.pyc 5 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | **The ofa/django-bouncy project has been archived** 2 | 3 | Some unofficial 3rd party forks may be available, such as https://github.com/organizerconnect/django-bouncy 4 | -------------------------------------------------------------------------------- /django_bouncy/__init__.py: -------------------------------------------------------------------------------- 1 | """Django Bouncy App""" 2 | -------------------------------------------------------------------------------- /django_bouncy/admin.py: -------------------------------------------------------------------------------- 1 | """Admin code for django_bouncy app""" 2 | 3 | from django.contrib import admin 4 | 5 | from django_bouncy.models import Bounce, Complaint, Delivery 6 | 7 | 8 | class BounceAdmin(admin.ModelAdmin): 9 | """Admin model for 'Bounce' objects""" 10 | list_display = ( 11 | 'address', 'mail_from', 'bounce_type', 'bounce_subtype', 'status') 12 | list_filter = ( 13 | 'hard', 'action', 'bounce_type', 'bounce_subtype', 14 | 'feedback_timestamp' 15 | ) 16 | search_fields = ('address',) 17 | 18 | 19 | class ComplaintAdmin(admin.ModelAdmin): 20 | """Admin model for 'Complaint' objects""" 21 | list_display = ('address', 'mail_from', 'feedback_type') 22 | list_filter = ('feedback_type', 'feedback_timestamp') 23 | search_fields = ('address',) 24 | 25 | 26 | class DeliveryAdmin(admin.ModelAdmin): 27 | """Admin model for 'Delivery' objects""" 28 | list_display = ('address', 'mail_from') 29 | list_filter = ('feedback_timestamp',) 30 | search_fields = ('address',) 31 | 32 | 33 | admin.site.register(Bounce, BounceAdmin) 34 | admin.site.register(Complaint, ComplaintAdmin) 35 | admin.site.register(Delivery, DeliveryAdmin) 36 | -------------------------------------------------------------------------------- /django_bouncy/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='Bounce', 15 | fields=[ 16 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 17 | ('created_at', models.DateTimeField(auto_now_add=True)), 18 | ('modified_at', models.DateTimeField(auto_now=True)), 19 | ('sns_topic', models.CharField(max_length=350)), 20 | ('sns_messageid', models.CharField(max_length=100)), 21 | ('mail_timestamp', models.DateTimeField()), 22 | ('mail_id', models.CharField(max_length=100)), 23 | ('mail_from', models.EmailField(max_length=254)), 24 | ('address', models.EmailField(max_length=254)), 25 | ('feedback_id', models.CharField(max_length=100)), 26 | ('feedback_timestamp', models.DateTimeField(verbose_name=b'Feedback Time')), 27 | ('hard', models.BooleanField(db_index=True, verbose_name=b'Hard Bounce')), 28 | ('bounce_type', models.CharField(max_length=50, verbose_name=b'Bounce Type', db_index=True)), 29 | ('bounce_subtype', models.CharField(max_length=50, verbose_name=b'Bounce Subtype', db_index=True)), 30 | ('reporting_mta', models.TextField(null=True, blank=True)), 31 | ('action', models.CharField(db_index=True, max_length=150, null=True, verbose_name=b'Action', blank=True)), 32 | ('status', models.CharField(db_index=True, max_length=150, null=True, blank=True)), 33 | ('diagnostic_code', models.CharField(max_length=150, null=True, blank=True)), 34 | ], 35 | options={ 36 | 'abstract': False, 37 | }, 38 | ), 39 | migrations.CreateModel( 40 | name='Complaint', 41 | fields=[ 42 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 43 | ('created_at', models.DateTimeField(auto_now_add=True)), 44 | ('modified_at', models.DateTimeField(auto_now=True)), 45 | ('sns_topic', models.CharField(max_length=350)), 46 | ('sns_messageid', models.CharField(max_length=100)), 47 | ('mail_timestamp', models.DateTimeField()), 48 | ('mail_id', models.CharField(max_length=100)), 49 | ('mail_from', models.EmailField(max_length=254)), 50 | ('address', models.EmailField(max_length=254)), 51 | ('feedback_id', models.CharField(max_length=100)), 52 | ('feedback_timestamp', models.DateTimeField(verbose_name=b'Feedback Time')), 53 | ('useragent', models.TextField(null=True, blank=True)), 54 | ('feedback_type', models.CharField(db_index=True, max_length=150, null=True, verbose_name=b'Complaint Type', blank=True)), 55 | ('arrival_date', models.DateTimeField(null=True, blank=True)), 56 | ], 57 | options={ 58 | 'abstract': False, 59 | }, 60 | ), 61 | ] 62 | -------------------------------------------------------------------------------- /django_bouncy/migrations/0002_auto_20150927_1413.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('django_bouncy', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='bounce', 16 | name='diagnostic_code', 17 | field=models.TextField(max_length=5000, null=True, blank=True), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /django_bouncy/migrations/0003_auto_20151106_1626.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('django_bouncy', '0002_auto_20150927_1413'), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Delivery', 16 | fields=[ 17 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 18 | ('created_at', models.DateTimeField(auto_now_add=True)), 19 | ('modified_at', models.DateTimeField(auto_now=True)), 20 | ('sns_topic', models.CharField(max_length=350)), 21 | ('sns_messageid', models.CharField(max_length=100)), 22 | ('mail_timestamp', models.DateTimeField()), 23 | ('mail_id', models.CharField(max_length=100)), 24 | ('mail_from', models.EmailField(max_length=254)), 25 | ('address', models.EmailField(max_length=254)), 26 | ('feedback_id', models.CharField(max_length=100, null=True, blank=True)), 27 | ('feedback_timestamp', models.DateTimeField(null=True, verbose_name=b'Feedback Time', blank=True)), 28 | ('delivered_time', models.DateTimeField(null=True, blank=True)), 29 | ('processing_time', models.PositiveSmallIntegerField(default=0)), 30 | ('smtp_response', models.TextField(null=True, blank=True)), 31 | ], 32 | options={ 33 | 'abstract': False, 34 | 'verbose_name_plural': 'deliveries' 35 | }, 36 | ), 37 | migrations.AlterField( 38 | model_name='bounce', 39 | name='feedback_id', 40 | field=models.CharField(max_length=100, null=True, blank=True), 41 | ), 42 | migrations.AlterField( 43 | model_name='bounce', 44 | name='feedback_timestamp', 45 | field=models.DateTimeField(null=True, verbose_name=b'Feedback Time', blank=True), 46 | ), 47 | migrations.AlterField( 48 | model_name='complaint', 49 | name='feedback_id', 50 | field=models.CharField(max_length=100, null=True, blank=True), 51 | ), 52 | migrations.AlterField( 53 | model_name='complaint', 54 | name='feedback_timestamp', 55 | field=models.DateTimeField(null=True, verbose_name=b'Feedback Time', blank=True), 56 | ), 57 | ] 58 | -------------------------------------------------------------------------------- /django_bouncy/migrations/0004_increase_processing_time.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.4 on 2017-02-02 06:27 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('django_bouncy', '0003_auto_20151106_1626'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='delivery', 17 | name='processing_time', 18 | field=models.IntegerField(default=0), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /django_bouncy/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ofa/django-bouncy/203250bf2d8219b69c36562512e3152699b0fdc2/django_bouncy/migrations/__init__.py -------------------------------------------------------------------------------- /django_bouncy/models.py: -------------------------------------------------------------------------------- 1 | """Models for the django_bouncy app""" 2 | from django.db import models 3 | 4 | 5 | class Feedback(models.Model): 6 | """An abstract model for all SES Feedback Reports""" 7 | created_at = models.DateTimeField(auto_now_add=True) 8 | modified_at = models.DateTimeField(auto_now=True) 9 | sns_topic = models.CharField(max_length=350) 10 | sns_messageid = models.CharField(max_length=100) 11 | mail_timestamp = models.DateTimeField() 12 | mail_id = models.CharField(max_length=100) 13 | mail_from = models.EmailField() 14 | address = models.EmailField() 15 | # no feedback for delivery messages 16 | feedback_id = models.CharField(max_length=100, null=True, blank=True) 17 | feedback_timestamp = models.DateTimeField( 18 | verbose_name="Feedback Time", null=True, blank=True) 19 | 20 | class Meta(object): 21 | """Meta info for Feedback Abstract Model""" 22 | abstract = True 23 | 24 | 25 | class Bounce(Feedback): 26 | """A bounce report for an individual email address""" 27 | hard = models.BooleanField(db_index=True, verbose_name="Hard Bounce") 28 | bounce_type = models.CharField( 29 | db_index=True, max_length=50, verbose_name="Bounce Type") 30 | bounce_subtype = models.CharField( 31 | db_index=True, max_length=50, verbose_name="Bounce Subtype") 32 | reporting_mta = models.TextField(blank=True, null=True) 33 | action = models.CharField( 34 | db_index=True, null=True, blank=True, max_length=150, 35 | verbose_name="Action" 36 | ) 37 | status = models.CharField( 38 | db_index=True, null=True, blank=True, max_length=150) 39 | diagnostic_code = models.TextField(null=True, blank=True, max_length=5000) 40 | 41 | def __unicode__(self): 42 | """Unicode representation of Bounce""" 43 | return "%s %s Bounce (message from %s)" % ( 44 | self.address, self.bounce_type, self.mail_from) 45 | 46 | 47 | class Complaint(Feedback): 48 | """A complaint report for an individual email address""" 49 | useragent = models.TextField(blank=True, null=True) 50 | feedback_type = models.CharField( 51 | db_index=True, blank=True, null=True, max_length=150, 52 | verbose_name="Complaint Type" 53 | ) 54 | arrival_date = models.DateTimeField(blank=True, null=True) 55 | 56 | def __unicode__(self): 57 | """Unicode representation of Complaint""" 58 | return "%s Complaint (email sender: from %s)" % ( 59 | self.address, self.mail_from) 60 | 61 | 62 | class Delivery(Feedback): 63 | """A delivery report for an individual email address""" 64 | delivered_time = models.DateTimeField(blank=True, null=True) 65 | processing_time = models.IntegerField(default=0) 66 | smtp_response = models.TextField(blank=True, null=True) 67 | 68 | def __unicode__(self): 69 | """Unicode representation of Delivery""" 70 | return "%s Delivery (email sender: from %s)" % ( 71 | self.address, self.mail_from) 72 | 73 | class Meta(object): 74 | """Meta info for the Delivery model""" 75 | verbose_name_plural = 'deliveries' 76 | -------------------------------------------------------------------------------- /django_bouncy/signals.py: -------------------------------------------------------------------------------- 1 | """Signals from the django_bouncy app""" 2 | # pylint: disable=invalid-name 3 | from django.dispatch import Signal 4 | 5 | # Any notification received 6 | notification = Signal(providing_args=["notification", "request"]) 7 | 8 | # New SubscriptionConfirmation received 9 | subscription = Signal(providing_args=["result", "notification"]) 10 | 11 | # New bounce or complaint received 12 | feedback = Signal(providing_args=["instance", "message", "notification"]) 13 | -------------------------------------------------------------------------------- /django_bouncy/tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for django-bouncy""" 2 | # pylint: disable=wildcard-import 3 | 4 | from django_bouncy.tests.views import * 5 | from django_bouncy.tests.utils import * 6 | -------------------------------------------------------------------------------- /django_bouncy/tests/examples/SimpleNotificationService-e372f8ca30337fdb084e8ac449342c77.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIFLTCCBBWgAwIBAgIQRXp96/v1XBAeRm45eB6eajANBgkqhkiG9w0BAQUFADCB 3 | tTELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMR8wHQYDVQQL 4 | ExZWZXJpU2lnbiBUcnVzdCBOZXR3b3JrMTswOQYDVQQLEzJUZXJtcyBvZiB1c2Ug 5 | YXQgaHR0cHM6Ly93d3cudmVyaXNpZ24uY29tL3JwYSAoYykxMDEvMC0GA1UEAxMm 6 | VmVyaVNpZ24gQ2xhc3MgMyBTZWN1cmUgU2VydmVyIENBIC0gRzMwHhcNMTMwOTEx 7 | MDAwMDAwWhcNMTQwOTEyMjM1OTU5WjBqMQswCQYDVQQGEwJVUzETMBEGA1UECBMK 8 | V2FzaGluZ3RvbjEQMA4GA1UEBxQHU2VhdHRsZTEYMBYGA1UEChQPQW1hem9uLmNv 9 | bSBJbmMuMRowGAYDVQQDFBFzbnMuYW1hem9uYXdzLmNvbTCCASIwDQYJKoZIhvcN 10 | AQEBBQADggEPADCCAQoCggEBAKpSGRs4IesnwfAABnP2vBxYAqwByHN4Ups5ylB3 11 | kJUoJe2nJKJXkniLB6Jrczg9GDf5XSlmOKfRrQweFJjCnE7hnbL1AuYDrbsBzFlI 12 | S8qN8RwNbCu1P9PwqQn2Q8ekwVh/kNaqACCOhkcBtUiXReCEnasWKFqnbj6zrlJs 13 | zguD32O4vt7H5iE8eVtU42dc4J/2uzvEwQmn9W0lQcS/ucLeFlMYccNcU/oJxOap 14 | Kg2auLatAzPm+gikUVnacUSwNHH4pnyCZ89cj9GZzu94WZFr+uohJu6gj28iVDIT 15 | OoHzbsTsrAtLa8QWo1QmAFlU20wFFAetRWkUoQ/vTCSH4ZECAwEAAaOCAYEwggF9 16 | MBwGA1UdEQQVMBOCEXNucy5hbWF6b25hd3MuY29tMAkGA1UdEwQCMAAwDgYDVR0P 17 | AQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjBDBgNVHSAE 18 | PDA6MDgGCmCGSAGG+EUBBzYwKjAoBggrBgEFBQcCARYcaHR0cHM6Ly93d3cudmVy 19 | aXNpZ24uY29tL2NwczAfBgNVHSMEGDAWgBQNRFwWU0TBgn4dIKsl9AFj2L55pTBF 20 | BgNVHR8EPjA8MDqgOKA2hjRodHRwOi8vU1ZSU2VjdXJlLUczLWNybC52ZXJpc2ln 21 | bi5jb20vU1ZSU2VjdXJlRzMuY3JsMHYGCCsGAQUFBwEBBGowaDAkBggrBgEFBQcw 22 | AYYYaHR0cDovL29jc3AudmVyaXNpZ24uY29tMEAGCCsGAQUFBzAChjRodHRwOi8v 23 | U1ZSU2VjdXJlLUczLWFpYS52ZXJpc2lnbi5jb20vU1ZSU2VjdXJlRzMuY2VyMA0G 24 | CSqGSIb3DQEBBQUAA4IBAQARbCGQM7qTfF6jIfy0cenCUP7vx8+pSZqhBH+wrvHd 25 | /JBIezBYI0iG8ufyTon9IAIg3YxLB3TgBxw9aOaerNhuJjKmNx5oQOdgbuxo9Iju 26 | K/L3gkiWTd5jdpUrMHIHawQhyvXntgv7dDwHCbhgvnesrJzcuS/yM55tefmCzqgm 27 | hAh/XKs1oqwFgaRvBUH8xFhMYSOOl6XDQrUE5WeFAOU0RO7sLjA4awE+8/W6nRAP 28 | xzG/1Qvc1gHiE5ms+iWTa+Nd56MZkHpHRsfyydDv3Gp9Lsv86deEMzxNg8T4KuSJ 29 | e3G8BZ+EAAIP8avZW1w6FPzklJp625XfNlavxSH086iA 30 | -----END CERTIFICATE----- 31 | -------------------------------------------------------------------------------- /django_bouncy/tests/examples/example_bounce.json: -------------------------------------------------------------------------------- 1 | { 2 | "notificationType":"Bounce", 3 | "bounce":{ 4 | "bounceType":"Permanent", 5 | "bounceSubType":"General", 6 | "bouncedRecipients":[ 7 | { 8 | "status":"5.0.0", 9 | "action":"failed", 10 | "diagnosticCode":"smtp; 550 user unknown", 11 | "emailAddress":"recipient1@example.com" 12 | }, 13 | { 14 | "status":"4.0.0", 15 | "action":"delayed", 16 | "emailAddress":"recipient2@example.com" 17 | } 18 | ], 19 | "reportingMTA":"example.com", 20 | "timestamp":"2012-05-25T14:59:38.605-07:00", 21 | "feedbackId":"000001378603176d-5a4b5ad9-6f30-4198-a8c3-b1eb0c270a1d-000000" 22 | }, 23 | "mail":{ 24 | "timestamp":"2012-06-19T01:05:45.000Z", 25 | "source":"sender@example.com", 26 | "messageId":"00000138111222aa-33322211-cccc-cccc-cccc-ddddaaaa0680-000000", 27 | "destination":[ 28 | "recipient1@example.com", 29 | "recipient2@example.com" 30 | ] 31 | } 32 | } -------------------------------------------------------------------------------- /django_bouncy/tests/examples/example_bounce_nocode.json: -------------------------------------------------------------------------------- 1 | { 2 | "notificationType":"Bounce", 3 | "bounce":{ 4 | "bounceType":"Permanent", 5 | "bounceSubType":"General", 6 | "bouncedRecipients":[ 7 | { 8 | "emailAddress":"recipient1@example.com" 9 | }, 10 | { 11 | "emailAddress":"recipient2@example.com" 12 | } 13 | ], 14 | "timestamp":"2012-05-25T14:59:38.237-07:00", 15 | "feedbackId":"00000137860315fd-869464a4-8680-4114-98d3-716fe35851f9-000000" 16 | }, 17 | "mail":{ 18 | "timestamp":"2012-05-25T14:59:38.237-07:00", 19 | "messageId":"00000137860315fd-34208509-5b74-41f3-95c5-22c1edc3c924-000000", 20 | "source":"email_1337983178237@amazon.com", 21 | "destination":[ 22 | "recipient1@example.com", 23 | "recipient2@example.com", 24 | "recipient3@example.com", 25 | "recipient4@example.com" 26 | ] 27 | } 28 | } -------------------------------------------------------------------------------- /django_bouncy/tests/examples/example_bounce_notification.json: -------------------------------------------------------------------------------- 1 | { 2 | "Type" : "Notification", 3 | "MessageId" : "f34c6922-c3a1-54a1-bd88-23f998b43978", 4 | "TopicArn" : "arn:aws:sns:us-east-1:250214102493:Demo_App_Unsubscribes", 5 | "Message" : "{\"notificationType\":\"Bounce\",\"bounce\":{\"bounceType\":\"Permanent\",\"reportingMTA\":\"dns; email.example.com\",\"bouncedRecipients\":[{\"emailAddress\":\"username@example.com\",\"status\":\"5.1.1\",\"action\":\"failed\",\"diagnosticCode\":\"smtp; 550 5.1.1 ... User\"}],\"bounceSubType\":\"General\",\"timestamp\":\"2012-06-19T01:07:52.000Z\",\"feedbackId\":\"00000138111222aa-33322211-cccc-cccc-cccc-ddddaaaa068a-000000\"},\"mail\":{\"timestamp\":\"2012-06-19T01:05:45.000Z\",\"source\":\"sender@example.com\",\"messageId\":\"00000138111222aa-33322211-cccc-cccc-cccc-ddddaaaa0680-000000\",\"destination\":[\"username@example.com\"]}}", 6 | "Timestamp" : "2013-10-18T15:50:45.484Z", 7 | "SignatureVersion" : "1", 8 | "Signature" : "PHAc2BryPY/9susa1IKjR6+lEAj4EbILM7bAV2U8mXL9evPn0c7cLdwnOMDw6vKsk9N3TBtOg5Y6n57jSAyyDRmgGBMZTqz0TnomrBvsm7qR9O38Mmjn/F3y/dllkStPuH1OK+JloaZ191OwEXT2yIHTaXy3uGV27UnVsNZVgZGJDbk29E3ofWDVdiqLlS/UOC/DdWdQoeQ6k1abf9cQEbVKzpi1GTyxEaQt7mS7eB/rexyazCY5PN/SbuJK0x/ZGVFt+8+uTLT3FzGhOHvpfQ2hP3rLswVkMEfDNdKggHUFQ3JJaIzMj4ZTxzZQX39MP8yBVZJefbXjAFOpiF9kgA==", 9 | "SigningCertURL" : "https://sns.us-east-1.amazonaws.com/SimpleNotificationService-e372f8ca30337fdb084e8ac449342c77.pem", 10 | "UnsubscribeURL" : "https://sns.us-east-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-east-1:250214102493:Demo_App_Unsubscribes:3b3e6f79-bc99-4519-8f4d-3b9b70856ab8" 11 | } -------------------------------------------------------------------------------- /django_bouncy/tests/examples/example_complaint.json: -------------------------------------------------------------------------------- 1 | { 2 | "notificationType":"Complaint", 3 | "complaint":{ 4 | "userAgent":"Comcast Feedback Loop (V0.01)", 5 | "complainedRecipients":[ 6 | { 7 | "emailAddress":"recipient1@example.com" 8 | } 9 | ], 10 | "complaintFeedbackType":"abuse", 11 | "arrivalDate":"2009-12-03T04:24:21.000-05:00", 12 | "timestamp":"2012-05-25T14:59:38.623-07:00", 13 | "feedbackId":"000001378603177f-18c07c78-fa81-4a58-9dd1-fedc3cb8f49a-000000" 14 | }, 15 | "mail":{ 16 | "timestamp":"2012-05-25T14:59:38.623-07:00", 17 | "messageId":"000001378603177f-7a5433e7-8edb-42ae-af10-f0181f34d6ee-000000", 18 | "source":"email_1337983178623@amazon.com", 19 | "destination":[ 20 | "recipient1@example.com", 21 | "recipient2@example.com", 22 | "recipient3@example.com", 23 | "recipient4@example.com" 24 | ] 25 | } 26 | } -------------------------------------------------------------------------------- /django_bouncy/tests/examples/example_complaint_nofeedback.json: -------------------------------------------------------------------------------- 1 | { 2 | "notificationType":"Complaint", 3 | "complaint":{ 4 | "complainedRecipients":[ 5 | { 6 | "emailAddress":"recipient1@example.com" 7 | } 8 | ], 9 | "timestamp":"2012-05-25T14:59:38.613-07:00", 10 | "feedbackId":"0000013786031775-fea503bc-7497-49e1-881b-a0379bb037d3-000000" 11 | }, 12 | "mail":{ 13 | "timestamp":"2012-05-25T14:59:38.613-07:00", 14 | "messageId":"0000013786031775-163e3910-53eb-4c8e-a04a-f29debf88a84-000000", 15 | "source":"email_1337983178613@amazon.com", 16 | "destination":[ 17 | "recipient1@example.com", 18 | "recipient2@example.com", 19 | "recipient3@example.com", 20 | "recipient4@example.com" 21 | ] 22 | } 23 | } -------------------------------------------------------------------------------- /django_bouncy/tests/examples/example_complaint_notification.json: -------------------------------------------------------------------------------- 1 | { 2 | "Type" : "Notification", 3 | "MessageId" : "217eaf35-67ae-5230-874a-e5df4c5c71c0", 4 | "TopicArn" : "arn:aws:sns:us-east-1:250214102493:Demo_App_Unsubscribes", 5 | "Message" : "{\"notificationType\":\"Complaint\",\"complaint\":{\"userAgent\":\"Comcast Feedback Loop (V0.01)\",\"complainedRecipients\":[{\"emailAddress\":\"recipient1@example.com\"}],\"complaintFeedbackType\":\"abuse\",\"arrivalDate\":\"2009-12-03T04:24:21.000-05:00\",\"timestamp\":\"2012-05-25T14:59:38.623-07:00\",\"feedbackId\":\"000001378603177f-18c07c78-fa81-4a58-9dd1-fedc3cb8f49a-000000\"},\"mail\":{\"timestamp\":\"2012-05-25T14:59:38.623-07:00\",\"messageId\":\"000001378603177f-7a5433e7-8edb-42ae-af10-f0181f34d6ee-000000\",\"source\":\"email_1337983178623@amazon.com\",\"destination\":[\"recipient1@example.com\",\"recipient2@example.com\",\"recipient3@example.com\",\"recipient4@example.com\"]}}", 6 | "Timestamp" : "2013-10-29T21:33:38.269Z", 7 | "SignatureVersion" : "1", 8 | "Signature" : "WSOJ4rMXBgsV8zGNMB0PfFY088tlONC+G9+9iv3MBMSv/8ll+kp/9e3xHgMR9ULuOKdl1kW/GRq5BTxaR3l2lf4ldJr2D/oD3H+20r1GgFDxmd1Xz/dMx4HqNEJlE6PKWCl8O9pwhh/c/vmVU8ss+JUGa2WbpY/rsPY7mHfZ40ScyQL4zfXgyWSyFeLe1Z0LyLDwQpKOJTqPEa+tS0/IaCaOudf2PF82ik/GJ8FTAfVmXOq8tNowVnNtfbz24sXQIWvEYtFRzJoM2pgRhBtRO2W8aMeawD5nqPl3Igp3WUlXQJjvRZS24l2qrBReASfSw4Hzak8ZxDcdKBcMcejiUA==", 9 | "SigningCertURL" : "https://sns.us-east-1.amazonaws.com/SimpleNotificationService-e372f8ca30337fdb084e8ac449342c77.pem", 10 | "UnsubscribeURL" : "https://sns.us-east-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-east-1:250214102493:Demo_App_Unsubscribes:3b3e6f79-bc99-4519-8f4d-3b9b70856ab8" 11 | } -------------------------------------------------------------------------------- /django_bouncy/tests/examples/example_confirmsubscription.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | arn:aws:sns:us-east-1:250214102493:Demo_App_Unsubscribes:ea3b6375-551a-48a0-b318-d6b66585ae6f 4 | 5 | 6 | 4fd74d83-9d08-514d-9e0c-07681954c4f6 7 | 8 | -------------------------------------------------------------------------------- /django_bouncy/tests/examples/example_delivery.json: -------------------------------------------------------------------------------- 1 | { 2 | "notificationType":"Delivery", 3 | "mail":{ 4 | "timestamp":"2014-05-28T22:40:59.638Z", 5 | "messageId":"0000014644fe5ef6-9a483358-9170-4cb4-a269-f5dcdf415321-000000", 6 | "source":"sender@example.com", 7 | "sourceArn": "arn:aws:ses:us-west-2:888888888888:identity/example.com", 8 | "sendingAccountId":"123456789012", 9 | "destination":[ 10 | "success@simulator.amazonses.com" 11 | ] 12 | }, 13 | "delivery":{ 14 | "timestamp":"2014-05-28T22:41:01.184Z", 15 | "recipients":["success@simulator.amazonses.com"], 16 | "processingTimeMillis":"546", 17 | "reportingMTA":"a8-70.smtp-out.amazonses.com", 18 | "smtpResponse":"250 ok: Message 64111812 accepted" 19 | } 20 | } -------------------------------------------------------------------------------- /django_bouncy/tests/examples/example_delivery_notification.json: -------------------------------------------------------------------------------- 1 | { 2 | "Type" : "Notification", 3 | "MessageId" : "fbdf2eda-c5ed-5096-a8d7-61a043f7db6e", 4 | "TopicArn" : "arn:aws:sns:us-east-1:674400795651:Bouncy_Test", 5 | "Message" : "{\n \"notificationType\":\"Delivery\",\n \"mail\":{\n \"timestamp\":\"2014-05-28T22:40:59.638Z\",\n \"messageId\":\"0000014644fe5ef6-9a483358-9170-4cb4-a269-f5dcdf415321-000000\",\n \"source\":\"sender@example.com\",\n \"sourceArn\": \"arn:aws:ses:us-west-2:888888888888:identity/example.com\",\n \"sendingAccountId\":\"123456789012\",\n \"destination\":[\n \"success@simulator.amazonses.com\"\n ]\n },\n \"delivery\":{\n \"timestamp\":\"2014-05-28T22:41:01.184Z\",\n \"recipients\":[\"success@simulator.amazonses.com\"],\n \"processingTimeMillis\":\"546\",\n \"reportingMTA\":\"a8-70.smtp-out.amazonses.com\",\n \"smtpResponse\":\"250 ok: Message 64111812 accepted\"\n } \n}", 6 | "Timestamp" : "2016-04-03T02:45:46.794Z", 7 | "SignatureVersion" : "1", 8 | "Signature" : "dlB+74ch3tM2IHxL54pwFzfS+M3c2GkHeanEfV7IJG4+JEeayUi3ZbXGkpY6LB0vpm3pmAjx078pdQpHNoHzU1/1OfL2ghRuv0pABYnrKvsvuwTFFPEPbkXVJPHvFc7aW5lhRxkGq1R/mT529RlmMbhtaheRt5laRAROBDsQVhzEDRNqrDrteo9oh9ECgPZ7nuWNPX82HQb1OTgzDyFcdVfyJaoankAlgKaEvE+8O8uvrKr9+Q63q+OhW+djCVyFW8wEaOjDDa+r1K4+D42rYspK32mZ/98RbEHJ8yvTxskiQKwjPGAiuI2rVPgllFixVpkrN0RqZTZTFuelaB0hww==", 9 | "SigningCertURL" : "https://sns.us-east-1.amazonaws.com/SimpleNotificationService-bb750dd426d95ee9390147a5624348ee.pem", 10 | "UnsubscribeURL" : "https://sns.us-east-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-east-1:674400795651:Bouncy_Test:d8308374-bc16-4697-ac4b-d68e1a791af7", 11 | "MessageAttributes" : { 12 | "AWS.SNS.MOBILE.MPNS.Type" : {"Type":"String","Value":"token"}, 13 | "AWS.SNS.MOBILE.MPNS.NotificationClass" : {"Type":"String","Value":"realtime"}, 14 | "AWS.SNS.MOBILE.WNS.Type" : {"Type":"String","Value":"wns/badge"} 15 | } 16 | } -------------------------------------------------------------------------------- /django_bouncy/tests/examples/example_subscriptionconfirmation.json: -------------------------------------------------------------------------------- 1 | { 2 | "Type" : "SubscriptionConfirmation", 3 | "MessageId" : "334fa09c-cfa2-465c-82aa-733848bece90", 4 | "Token" : "2336412f37fb687f5d51e6e241d164b05333037a74406d8dba969d354ea74d83709cd31e96da0cd0a3a0ee0e27b1327ffe742b592fbd94724bef9e6b34405815f36381b22a27ba71d23d5e9219ecdc786e8d9a32d028cf2c433403ac5f19a911c8248f581af32bb9459f6f5318aa707cb71f43438cdb0c84fd169d8b803ba5f1", 5 | "TopicArn" : "arn:aws:sns:us-east-1:250214102493:Demo_App_Unsubscribes", 6 | "Message" : "You have chosen to subscribe to the topic arn:aws:sns:us-east-1:250214102493:Demo_App_Unsubscribes.\nTo confirm the subscription, visit the SubscribeURL included in this message.", 7 | "SubscribeURL" : "https://sns.us-east-1.amazonaws.com/?Action=ConfirmSubscription&TopicArn=arn:aws:sns:us-east-1:250214102493:Demo_App_Unsubscribes&Token=2336412f37fb687f5d51e6e241d164b05333037a74406d8dba969d354ea74d83709cd31e96da0cd0a3a0ee0e27b1327ffe742b592fbd94724bef9e6b34405815f36381b22a27ba71d23d5e9219ecdc786e8d9a32d028cf2c433403ac5f19a911c8248f581af32bb9459f6f5318aa707cb71f43438cdb0c84fd169d8b803ba5f1", 8 | "Timestamp" : "2013-10-18T15:45:00.871Z", 9 | "SignatureVersion" : "1", 10 | "Signature" : "bfEXuIHh26xHeN9p2buadZl5U7mbj+lwf+3t03Cuxw6NLLgd48e+ij6EuZNmnRTWqGvgV/hkGiIYZtzk5g9dfKeSdYuH8YOGU8Z8OsNuo0Y5XoHxTfMHZAimgO/YjK/VwR+Umpop1Ov4+zIlCNUCDXLOSv7JMVqwQGwnMOYxzh2OHCfLJkoAYtCfPkXLdMGxwMDzwWtVWBwjnG4DwvgJFlNV2jdZrC6NPKtyz8YNbpBrW5yR20jShWS54unNXqx/8Y8fXq4QpFpjX7CC2DHTbhU25APamayN1nZYFO6V+3gaKwXU46X++fYU+8Ryr7lbTHKJ8xp6x5XCMu4hxXisAQ==", 11 | "SigningCertURL" : "https://sns.us-east-1.amazonaws.com/SimpleNotificationService-e372f8ca30337fdb084e8ac449342c77.pem" 12 | } -------------------------------------------------------------------------------- /django_bouncy/tests/helpers.py: -------------------------------------------------------------------------------- 1 | """Helpful utilities for django-bouncy tests""" 2 | import os 3 | import json 4 | 5 | from django.test import TestCase 6 | from django.test.utils import override_settings 7 | from django.conf import settings 8 | 9 | DIRNAME, _ = os.path.split(os.path.abspath(__file__)) 10 | 11 | 12 | @override_settings(BOUNCY_VERIFY_CERTIFICATE=False) 13 | class BouncyTestCase(TestCase): 14 | """Custom TestCase for django-bouncy""" 15 | @classmethod 16 | def setUpClass(cls): 17 | """Setup the BouncyTestCase Class""" 18 | super(BouncyTestCase, cls).setUpClass() 19 | cls.old_setting = getattr(settings, 'BOUNCY_TOPIC_ARN', None) 20 | cls.notification = loader('bounce_notification') 21 | cls.complaint = loader('complaint') 22 | cls.bounce = loader('bounce') 23 | cls.keyfileobj = open(DIRNAME + ('/examples/SimpleNotificationService' 24 | '-e372f8ca30337fdb084e8ac449342c77.' 25 | 'pem')) 26 | cls.pemfile = cls.keyfileobj.read() 27 | 28 | settings.BOUNCY_TOPIC_ARN = [ 29 | 'arn:aws:sns:us-east-1:250214102493:Demo_App_Unsubscribes' 30 | ] 31 | 32 | @classmethod 33 | def tearDownClass(cls): 34 | """Tear down the BouncyTestCase Class""" 35 | if cls.old_setting is not None: 36 | settings.BOUNCY_TOPIC_ARN = cls.old_setting 37 | 38 | 39 | def loader(example_name): 40 | """Load examples from their JSON file and return a dictionary""" 41 | filename_format = '{dir}/examples/example_{name}.json' 42 | 43 | file_obj = open(filename_format.format(dir=DIRNAME, name=example_name)) 44 | return json.load(file_obj) 45 | -------------------------------------------------------------------------------- /django_bouncy/tests/utils.py: -------------------------------------------------------------------------------- 1 | """Tests for utils.py in the django-bouncy app""" 2 | from django.conf import settings 3 | from django.dispatch import receiver 4 | try: 5 | # Python 2.6/2.7 6 | from mock import Mock, patch 7 | except ImportError: 8 | # Python 3 9 | from unittest.mock import Mock, patch 10 | 11 | from django_bouncy.tests.helpers import BouncyTestCase, loader 12 | from django_bouncy import utils, signals 13 | 14 | 15 | class TestVerificationSystem(BouncyTestCase): 16 | """Test the message verification utilities""" 17 | @patch('django_bouncy.utils.urlopen') 18 | def test_grab_keyfile(self, mock): 19 | """Test the grab_keyfile plugin""" 20 | responsemock = Mock() 21 | responsemock.read.return_value = self.pemfile 22 | mock.return_value = responsemock 23 | result = utils.grab_keyfile('http://www.fakeurl.com') 24 | 25 | mock.assert_called_with('http://www.fakeurl.com') 26 | self.assertEqual(result, self.pemfile) 27 | 28 | @patch('django_bouncy.utils.urlopen') 29 | def test_bad_keyfile(self, mock): 30 | """Test a non-valid keyfile""" 31 | responsemock = Mock() 32 | responsemock.read.return_value = 'Not A Certificate' 33 | mock.return_value = responsemock 34 | 35 | with self.assertRaises(ValueError) as context_manager: 36 | utils.grab_keyfile('http://www.fakeurl.com') 37 | 38 | the_exception = context_manager.exception 39 | self.assertEqual(the_exception.args[0], 'Invalid Certificate File') 40 | 41 | @patch('django_bouncy.utils.grab_keyfile') 42 | def test_verify_notification(self, mock): 43 | """Test the verification of a valid notification""" 44 | mock.return_value = self.pemfile 45 | result = utils.verify_notification(self.notification) 46 | self.assertTrue(result) 47 | 48 | @patch('django_bouncy.utils.grab_keyfile') 49 | def test_verify_subscription_notification(self, mock): 50 | """Test the verification of a valid subscription notification""" 51 | mock.return_value = self.pemfile 52 | 53 | notification = loader('subscriptionconfirmation') 54 | result = utils.verify_notification(notification) 55 | self.assertTrue(result) 56 | 57 | @patch('django_bouncy.utils.grab_keyfile') 58 | def test_notification_verification_failure(self, mock): 59 | """Test the failure of an invalid notification""" 60 | mock.return_value = self.pemfile 61 | notification = loader('bounce_notification') 62 | notification['TopicArn'] = 'BadArn' 63 | result = utils.verify_notification(notification) 64 | 65 | self.assertFalse(result) 66 | 67 | @patch('django_bouncy.utils.grab_keyfile') 68 | def test_subscription_verification_failure(self, mock): 69 | """Test the failure of an invalid subscription notification""" 70 | mock.return_value = self.pemfile 71 | notification = loader('subscriptionconfirmation') 72 | notification['TopicArn'] = 'BadArn' 73 | result = utils.verify_notification(notification) 74 | 75 | self.assertFalse(result) 76 | 77 | 78 | class SubscriptionApprovalTest(BouncyTestCase): 79 | """Test the approve_subscription function""" 80 | @patch('django_bouncy.utils.urlopen') 81 | def test_approve_subscription(self, mock): 82 | """Test the subscription approval mechanism""" 83 | responsemock = Mock() 84 | responsemock.read.return_value = 'Return Value' 85 | mock.return_value = responsemock 86 | notification = loader('subscriptionconfirmation') 87 | 88 | response = utils.approve_subscription(notification) 89 | 90 | mock.assert_called_with(notification['SubscribeURL']) 91 | self.assertEqual(response.status_code, 200) 92 | self.assertEqual(response.content.decode('ascii'), 'Return Value') 93 | 94 | @patch('django_bouncy.utils.urlopen') 95 | def test_signal_sent(self, mock): 96 | """ 97 | Test that the subscription signal was sent 98 | 99 | Based on http://stackoverflow.com/questions/3817213/ 100 | """ 101 | # pylint: disable=attribute-defined-outside-init, unused-variable 102 | responsemock = Mock() 103 | responsemock.read.return_value = 'Return Value' 104 | mock.return_value = responsemock 105 | notification = loader('subscriptionconfirmation') 106 | self.signal_count = 0 107 | 108 | @receiver(signals.subscription) 109 | def _signal_receiver(sender, **kwargs): 110 | """Signal Test Receiver""" 111 | self.signal_count += 1 112 | self.signal_sender = sender 113 | self.signal_notification = kwargs['notification'] 114 | self.signal_result = kwargs['result'] 115 | 116 | response = utils.approve_subscription(notification) 117 | 118 | self.assertEqual(response.content.decode('ascii'), 'Return Value') 119 | self.assertEqual(self.signal_count, 1) 120 | self.assertEqual(self.signal_result, 'Return Value') 121 | self.assertEqual(self.signal_notification, notification) 122 | 123 | def test_bad_url(self): 124 | """Test to make sure an invalid URL isn't requested by our system""" 125 | old_setting = getattr(settings, 'BOUNCY_SUBSCRIBE_DOMAIN_REGEX', None) 126 | settings.BOUNCY_SUBSCRIBE_DOMAIN_REGEX = \ 127 | r"sns.[a-z0-9\-]+.amazonaws.com$" 128 | notification = loader('bounce_notification') 129 | notification['SubscribeURL'] = 'http://bucket.s3.amazonaws.com' 130 | result = utils.approve_subscription(notification) 131 | 132 | self.assertEqual(result.status_code, 400) 133 | self.assertEqual( 134 | result.content.decode('ascii'), 'Improper Subscription Domain') 135 | 136 | if old_setting is not None: 137 | settings.BOUNCY_SUBSCRIBE_DOMAIN_REGEX = old_setting 138 | -------------------------------------------------------------------------------- /django_bouncy/tests/views.py: -------------------------------------------------------------------------------- 1 | """Tests for views.py in the django-bouncy app""" 2 | # pylint: disable=protected-access 3 | import json 4 | 5 | from django.test import RequestFactory 6 | from django.test.utils import override_settings 7 | from django.http import Http404 8 | from django.conf import settings 9 | from django.dispatch import receiver 10 | try: 11 | # Python 2.6/2.7 12 | from mock import patch 13 | except ImportError: 14 | # Python 3 15 | # from unittest.mock import patch 16 | from mock import patch 17 | 18 | from django_bouncy.tests.helpers import BouncyTestCase, loader 19 | from django_bouncy import views, signals 20 | from django_bouncy.utils import clean_time 21 | from django_bouncy.models import Bounce, Complaint, Delivery 22 | 23 | 24 | class BouncyEndpointViewTest(BouncyTestCase): 25 | """Test the endpoint view""" 26 | def setUp(self): 27 | """Setup the test""" 28 | self.factory = RequestFactory() 29 | self.request = self.factory.post('/') 30 | self.request.META['HTTP_X_AMZ_SNS_TOPIC_ARN'] = \ 31 | settings.BOUNCY_TOPIC_ARN[0] 32 | 33 | def test_non_post_http404(self): 34 | """Test that GET requests to the endpoint throw a 404""" 35 | request = self.factory.get('/') 36 | with self.assertRaises(Http404): 37 | views.endpoint(request) 38 | 39 | def test_success(self): 40 | """Test a successful request""" 41 | self.request._body = json.dumps(self.notification) 42 | result = views.endpoint(self.request) 43 | self.assertEqual(result.status_code, 200) 44 | self.assertEqual(result.content.decode('ascii'), 'Bounce Processed') 45 | 46 | def test_signals_sent(self): 47 | """ 48 | Test that a notification feedback signal was sent 49 | 50 | Based on http://stackoverflow.com/questions/3817213/ 51 | """ 52 | # pylint: disable=attribute-defined-outside-init, unused-variable 53 | self.request._body = json.dumps(self.notification) 54 | self.signal_count = 0 55 | 56 | @receiver(signals.notification) 57 | def _signal_receiver(sender, **kwargs): 58 | """Signal test receiver""" 59 | # pylint: disable=unused-argument 60 | self.signal_count += 1 61 | self.signal_notification = kwargs['notification'] 62 | self.signal_request = kwargs['request'] 63 | 64 | result = views.endpoint(self.request) 65 | 66 | self.assertEqual(result.status_code, 200) 67 | self.assertEqual(self.signal_count, 1) 68 | self.assertEqual(self.signal_request, self.request) 69 | self.assertEqual(self.signal_notification, self.notification) 70 | 71 | @override_settings(BOUNCY_TOPIC_ARN=['Bad ARN']) 72 | def test_bad_topic(self): 73 | """Test the response if the topic does not match the settings""" 74 | self.request._body = json.dumps(self.notification) 75 | result = views.endpoint(self.request) 76 | self.assertEqual(result.status_code, 400) 77 | self.assertEqual(result.content.decode('ascii'), 'Bad Topic') 78 | 79 | def test_no_header(self): 80 | """Test the results if the request does not have a topic header""" 81 | request = self.factory.post('/') 82 | request._body = json.dumps(self.notification) 83 | result = views.endpoint(request) 84 | self.assertEqual(result.status_code, 400) 85 | self.assertEqual(result.content.decode('ascii'), 'No TopicArn Header') 86 | 87 | def test_invalid_json(self): 88 | """Test if the notification does not have a JSON body""" 89 | self.request._body = "This Is Not JSON" 90 | result = views.endpoint(self.request) 91 | self.assertEqual(result.status_code, 400) 92 | self.assertEqual(result.content.decode('ascii'), 'Not Valid JSON') 93 | 94 | def test_missing_necessary_key(self): 95 | """Test if the notification is missing vital keys""" 96 | self.request._body = json.dumps({}) 97 | result = views.endpoint(self.request) 98 | self.assertEqual(result.status_code, 400) 99 | self.assertEqual( 100 | result.content.decode('ascii'), 'Request Missing Necessary Keys') 101 | 102 | def test_unknown_notification_type(self): 103 | """Test an unknown notification type""" 104 | notification = loader('bounce_notification') 105 | notification['Type'] = 'NotAKnownType' 106 | self.request._body = json.dumps(notification) 107 | result = views.endpoint(self.request) 108 | self.assertEqual(result.status_code, 400) 109 | self.assertEqual( 110 | result.content.decode('ascii'), 'Unknown Notification Type') 111 | 112 | def test_bad_certificate_url(self): 113 | """Test an unknown certificate hostname""" 114 | notification = loader('bounce_notification') 115 | notification['SigningCertURL'] = 'https://baddomain.com/cert.pem' 116 | self.request._body = json.dumps(notification) 117 | result = views.endpoint(self.request) 118 | self.assertEqual(result.status_code, 400) 119 | self.assertEqual( 120 | result.content.decode('ascii'), 'Improper Certificate Location') 121 | 122 | def test_subscription_throws_404(self): 123 | """ 124 | Test that a subscription request sent to bouncy throws a 404 if not 125 | permitted 126 | """ 127 | original_setting = getattr(settings, 'BOUNCY_AUTO_SUBSCRIBE', True) 128 | settings.BOUNCY_AUTO_SUBSCRIBE = False 129 | with self.assertRaises(Http404): 130 | notification = loader('subscriptionconfirmation') 131 | self.request._body = json.dumps(notification) 132 | views.endpoint(self.request) 133 | settings.BOUNCY_AUTO_SUBSCRIBE = original_setting 134 | 135 | @patch('django_bouncy.views.approve_subscription') 136 | def test_approve_subscription_called(self, mock): 137 | """Test that a approve_subscription is called""" 138 | mock.return_value = 'Test Return Value' 139 | notification = loader('subscriptionconfirmation') 140 | self.request._body = json.dumps(notification) 141 | result = views.endpoint(self.request) 142 | self.assertTrue(mock.called) 143 | self.assertEqual(result, 'Test Return Value') 144 | 145 | def test_unsubscribe_confirmation_not_handled(self): 146 | """Test that an unsubscribe notification is properly ignored""" 147 | notification = loader('bounce_notification') 148 | notification['Type'] = 'UnsubscribeConfirmation' 149 | self.request._body = json.dumps(notification) 150 | result = views.endpoint(self.request) 151 | self.assertEqual(result.status_code, 200) 152 | self.assertEqual( 153 | result.content.decode('ascii'), 154 | 'UnsubscribeConfirmation Not Handled' 155 | ) 156 | 157 | def test_non_json_message_not_allowed(self): 158 | """Test that a non-JSON message is properly ignored""" 159 | notification = loader('bounce_notification') 160 | notification['Message'] = 'Non JSON Message' 161 | self.request._body = json.dumps(notification) 162 | result = views.endpoint(self.request) 163 | self.assertEqual(result.status_code, 200) 164 | self.assertEqual( 165 | result.content.decode('ascii'), 'Message is not valid JSON') 166 | 167 | 168 | class ProcessMessageTest(BouncyTestCase): 169 | """Test the process_message function""" 170 | def test_missing_fields(self): 171 | """Test that missing vital fields returns an error""" 172 | message = loader('bounce') 173 | del(message['mail']) 174 | result = views.process_message(message, self.notification) 175 | self.assertEqual(result.status_code, 200) 176 | self.assertEqual( 177 | result.content.decode('ascii'), 'Missing Vital Fields') 178 | 179 | @patch('django_bouncy.views.process_complaint') 180 | def test_complaint(self, mock): 181 | """Test that a complaint is sent to process_complaint""" 182 | notification = loader('complaint_notification') 183 | views.process_message(self.complaint, notification) 184 | mock.assert_called_with(self.complaint, notification) 185 | 186 | @patch('django_bouncy.views.process_bounce') 187 | def test_bounce(self, mock): 188 | """Test that a bounce is sent to process_bounce""" 189 | views.process_message(self.bounce, self.notification) 190 | mock.assert_called_with(self.bounce, self.notification) 191 | 192 | def test_unknown_message(self): 193 | """Test a JSON message without a type returns an error""" 194 | message = loader('bounce') 195 | message['notificationType'] = 'Not A Valid Notification' 196 | result = views.process_message(message, self.notification) 197 | self.assertEqual(result.status_code, 200) 198 | self.assertEqual( 199 | result.content.decode('ascii'), 'Unknown Notification Type') 200 | 201 | 202 | class ProcessBounceTest(BouncyTestCase): 203 | """Test the process_bounce function""" 204 | def test_two_bounces_created(self): 205 | """Test that new bounces are added to the database""" 206 | original_count = Bounce.objects.count() 207 | result = views.process_bounce(self.bounce, self.notification) 208 | new_count = Bounce.objects.count() 209 | 210 | self.assertEqual(new_count, original_count + 2) 211 | self.assertEqual(result.status_code, 200) 212 | self.assertEqual(result.content.decode('ascii'), 'Bounce Processed') 213 | 214 | def test_signals_sent(self): 215 | """Test that a bounce feedback signal was sent""" 216 | # pylint: disable=attribute-defined-outside-init, unused-variable 217 | self.signal_count = 0 218 | 219 | @receiver(signals.feedback) 220 | def _signal_receiver(sender, **kwargs): 221 | """Test signal receiver""" 222 | # pylint: disable=unused-argument 223 | self.signal_count += 1 224 | self.signal_notification = kwargs['notification'] 225 | 226 | result = views.process_bounce(self.bounce, self.notification) 227 | 228 | self.assertEqual(result.status_code, 200) 229 | self.assertEqual(result.content.decode('ascii'), 'Bounce Processed') 230 | self.assertEqual(self.signal_count, 2) 231 | self.assertEqual(self.signal_notification, self.notification) 232 | 233 | def test_correct_bounces_created(self): 234 | """Test to ensure that bounces are correctly inserted""" 235 | # Delete any existing bounces 236 | Bounce.objects.all().delete() 237 | 238 | result = views.process_bounce(self.bounce, self.notification) 239 | 240 | self.assertEqual(result.status_code, 200) 241 | self.assertEqual(result.content.decode('ascii'), 'Bounce Processed') 242 | self.assertTrue(Bounce.objects.filter( 243 | sns_topic=('arn:aws:sns:us-east-1:250214102493:' 244 | 'Demo_App_Unsubscribes'), 245 | sns_messageid='f34c6922-c3a1-54a1-bd88-23f998b43978', 246 | mail_timestamp=clean_time('2012-06-19T01:05:45.000Z'), 247 | mail_id=('00000138111222aa-33322211-cccc-cccc-cccc-' 248 | 'ddddaaaa0680-000000'), 249 | mail_from='sender@example.com', 250 | address='recipient1@example.com', 251 | feedback_id=('000001378603176d-5a4b5ad9-6f30-4198-a8c3-' 252 | 'b1eb0c270a1d-000000'), 253 | feedback_timestamp=clean_time('2012-05-25T14:59:38.605-07:00'), 254 | hard=True, 255 | bounce_type='Permanent', 256 | bounce_subtype='General', 257 | reporting_mta='example.com', 258 | action='failed', 259 | status='5.0.0', 260 | diagnostic_code='smtp; 550 user unknown' 261 | ).exists()) 262 | 263 | 264 | class ProcessComplaintTest(BouncyTestCase): 265 | """Test the process_complaint function""" 266 | def setUp(self): 267 | self.complaint_notification = loader('complaint_notification') 268 | 269 | def test_complaints_created(self): 270 | """Test that a new complaint was added to the database""" 271 | original_count = Complaint.objects.count() 272 | result = views.process_complaint( 273 | self.complaint, self.complaint_notification) 274 | new_count = Complaint.objects.count() 275 | 276 | self.assertEqual(new_count, original_count + 1) 277 | self.assertEqual(result.status_code, 200) 278 | self.assertEqual(result.content.decode('ascii'), 'Complaint Processed') 279 | 280 | def test_signals_sent(self): 281 | """Test that a complaint feedback signal was sent""" 282 | # pylint: disable=attribute-defined-outside-init, unused-variable 283 | self.signal_count = 0 284 | 285 | @receiver(signals.feedback) 286 | def _signal_receiver(sender, **kwargs): 287 | """Test signal receiver""" 288 | # pylint: disable=unused-argument 289 | self.signal_count += 1 290 | self.signal_notification = kwargs['notification'] 291 | self.signal_message = kwargs['message'] 292 | 293 | result = views.process_complaint( 294 | self.complaint, self.complaint_notification) 295 | 296 | self.assertEqual(result.status_code, 200) 297 | self.assertEqual(result.content.decode('ascii'), 'Complaint Processed') 298 | self.assertEqual(self.signal_count, 1) 299 | self.assertEqual(self.signal_notification, self.complaint_notification) 300 | 301 | def test_correct_complaint_created(self): 302 | """Test that the correct complaint was created""" 303 | Complaint.objects.all().delete() 304 | 305 | result = views.process_complaint( 306 | self.complaint, self.complaint_notification) 307 | 308 | self.assertEqual(result.status_code, 200) 309 | self.assertEqual(result.content.decode('ascii'), 'Complaint Processed') 310 | self.assertTrue(Complaint.objects.filter( 311 | sns_topic=('arn:aws:sns:us-east-1:250214102493:' 312 | 'Demo_App_Unsubscribes'), 313 | sns_messageid='217eaf35-67ae-5230-874a-e5df4c5c71c0', 314 | mail_timestamp=clean_time('2012-05-25T14:59:38.623-07:00'), 315 | mail_id=('000001378603177f-7a5433e7-8edb-42ae-af10-' 316 | 'f0181f34d6ee-000000'), 317 | mail_from='email_1337983178623@amazon.com', 318 | address='recipient1@example.com', 319 | feedback_id=('000001378603177f-18c07c78-fa81-4a58-9dd1-' 320 | 'fedc3cb8f49a-000000'), 321 | feedback_timestamp=clean_time('2012-05-25T14:59:38.623-07:00'), 322 | useragent='Comcast Feedback Loop (V0.01)', 323 | arrival_date=clean_time('2009-12-03T04:24:21.000-05:00') 324 | ).exists()) 325 | 326 | 327 | class ProcessDeliveryTest(BouncyTestCase): 328 | """Test the process_delivery function""" 329 | def setUp(self): 330 | """Setup the process delivery test""" 331 | self.delivery = loader('delivery') 332 | self.delivery_notification = loader('delivery_notification') 333 | 334 | def test_delivery_created(self): 335 | """Test that the Delivery object was created""" 336 | original_count = Delivery.objects.count() 337 | result = views.process_delivery( 338 | self.delivery, self.delivery_notification) 339 | new_count = Delivery.objects.count() 340 | 341 | self.assertEqual(new_count, original_count + 1) 342 | self.assertEqual(result.status_code, 200) 343 | self.assertEqual(result.content.decode('ascii'), 'Delivery Processed') 344 | 345 | def test_signals_sent(self): 346 | """Test that the django delivery signal was sent""" 347 | # pylint: disable=attribute-defined-outside-init, unused-variable 348 | self.signal_count = 0 349 | 350 | @receiver(signals.feedback) 351 | def _signal_receiver(sender, **kwargs): 352 | """Test signal receiver""" 353 | # pylint: disable=unused-argument 354 | self.signal_count += 1 355 | self.signal_notification = kwargs['notification'] 356 | self.signal_message = kwargs['message'] 357 | 358 | result = views.process_delivery( 359 | self.delivery, self.delivery_notification) 360 | 361 | self.assertEqual(result.status_code, 200) 362 | self.assertEqual(result.content.decode('ascii'), 'Delivery Processed') 363 | self.assertEqual(self.signal_count, 1) 364 | self.assertEqual(self.signal_notification, self.delivery_notification) 365 | 366 | def test_correct_delivery_created(self): 367 | """Test that the correct delivery was created""" 368 | Delivery.objects.all().delete() 369 | 370 | result = views.process_delivery( 371 | self.delivery, self.delivery_notification) 372 | 373 | self.assertEqual(result.status_code, 200) 374 | self.assertEqual(result.content.decode('ascii'), 'Delivery Processed') 375 | self.assertTrue(Delivery.objects.filter( 376 | sns_topic='arn:aws:sns:us-east-1:674400795651:Bouncy_Test', 377 | sns_messageid='fbdf2eda-c5ed-5096-a8d7-61a043f7db6e', 378 | mail_timestamp=clean_time('2014-05-28T22:40:59.638Z'), 379 | mail_id='0000014644fe5ef6-9a483358-9170-4cb4-a269-f5dcdf415321-000' 380 | '000', 381 | mail_from='sender@example.com', 382 | address='success@simulator.amazonses.com', 383 | # delivery 384 | delivered_time=clean_time('2014-05-28T22:41:01.184Z'), 385 | processing_time=546, 386 | smtp_response='250 ok: Message 64111812 accepted' 387 | ).exists()) 388 | 389 | def test_correct_delivery_created_long_response_time(self): 390 | """Test that the correct delivery was created with a long processing_time""" 391 | Delivery.objects.all().delete() 392 | self.delivery['delivery']['processingTimeMillis'] = 123999123 393 | result = views.process_delivery( 394 | self.delivery, self.delivery_notification) 395 | 396 | self.assertEqual(result.status_code, 200) 397 | self.assertEqual(result.content.decode('ascii'), 'Delivery Processed') 398 | self.assertTrue(Delivery.objects.filter( 399 | sns_topic='arn:aws:sns:us-east-1:674400795651:Bouncy_Test', 400 | sns_messageid='fbdf2eda-c5ed-5096-a8d7-61a043f7db6e', 401 | mail_timestamp=clean_time('2014-05-28T22:40:59.638Z'), 402 | mail_id='0000014644fe5ef6-9a483358-9170-4cb4-a269-f5dcdf415321-000' 403 | '000', 404 | mail_from='sender@example.com', 405 | address='success@simulator.amazonses.com', 406 | # delivery 407 | delivered_time=clean_time('2014-05-28T22:41:01.184Z'), 408 | processing_time=123999123, 409 | smtp_response='250 ok: Message 64111812 accepted' 410 | ).exists()) 411 | 412 | -------------------------------------------------------------------------------- /django_bouncy/urls.py: -------------------------------------------------------------------------------- 1 | """URLs for the Django-Bouncy App""" 2 | from django.conf.urls import url 3 | # pylint: disable=invalid-name 4 | from django_bouncy.views import endpoint 5 | 6 | urlpatterns = [ 7 | url(r'^$', endpoint) 8 | ] 9 | -------------------------------------------------------------------------------- /django_bouncy/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Utility functions for the django_bouncy app""" 3 | try: 4 | import urllib2 as urllib 5 | except ImportError: 6 | import urllib 7 | 8 | try: 9 | # Python 3 10 | from urllib.request import urlopen 11 | except ImportError: 12 | # Python 2.7 13 | from urllib import urlopen 14 | 15 | try: 16 | from urlparse import urlparse 17 | except ImportError: 18 | from urllib.parse import urlparse 19 | 20 | import base64 21 | import re 22 | import pem 23 | import logging 24 | import six 25 | 26 | from OpenSSL import crypto 27 | from django.conf import settings 28 | from django.core.cache import caches 29 | from django.http import HttpResponse, HttpResponseBadRequest 30 | from django.utils import timezone 31 | from django.utils.encoding import smart_bytes 32 | import dateutil.parser 33 | 34 | from django_bouncy import signals 35 | 36 | NOTIFICATION_HASH_FORMAT = u'''Message 37 | {Message} 38 | MessageId 39 | {MessageId} 40 | Timestamp 41 | {Timestamp} 42 | TopicArn 43 | {TopicArn} 44 | Type 45 | {Type} 46 | ''' 47 | 48 | SUBSCRIPTION_HASH_FORMAT = u'''Message 49 | {Message} 50 | MessageId 51 | {MessageId} 52 | SubscribeURL 53 | {SubscribeURL} 54 | Timestamp 55 | {Timestamp} 56 | Token 57 | {Token} 58 | TopicArn 59 | {TopicArn} 60 | Type 61 | {Type} 62 | ''' 63 | 64 | logger = logging.getLogger(__name__) 65 | 66 | 67 | def grab_keyfile(cert_url): 68 | """ 69 | Function to acqure the keyfile 70 | 71 | SNS keys expire and Amazon does not promise they will use the same key 72 | for all SNS requests. So we need to keep a copy of the cert in our 73 | cache 74 | """ 75 | key_cache = caches[getattr(settings, 'BOUNCY_KEY_CACHE', 'default')] 76 | 77 | pemfile = key_cache.get(cert_url) 78 | if not pemfile: 79 | response = urlopen(cert_url) 80 | pemfile = response.read() 81 | # Extract the first certificate in the file and confirm it's a valid 82 | # PEM certificate 83 | certificates = pem.parse(smart_bytes(pemfile)) 84 | 85 | # A proper certificate file will contain 1 certificate 86 | if len(certificates) != 1: 87 | logger.error('Invalid Certificate File: URL %s', cert_url) 88 | raise ValueError('Invalid Certificate File') 89 | 90 | key_cache.set(cert_url, pemfile) 91 | return pemfile 92 | 93 | 94 | def verify_notification(data): 95 | """ 96 | Function to verify notification came from a trusted source 97 | 98 | Returns True if verfied, False if not verified 99 | """ 100 | pemfile = grab_keyfile(data['SigningCertURL']) 101 | cert = crypto.load_certificate(crypto.FILETYPE_PEM, pemfile) 102 | signature = base64.decodestring(six.b(data['Signature'])) 103 | 104 | if data['Type'] == "Notification": 105 | hash_format = NOTIFICATION_HASH_FORMAT 106 | else: 107 | hash_format = SUBSCRIPTION_HASH_FORMAT 108 | 109 | try: 110 | crypto.verify( 111 | cert, signature, six.b(hash_format.format(**data)), 'sha1') 112 | except crypto.Error: 113 | return False 114 | return True 115 | 116 | 117 | def approve_subscription(data): 118 | """ 119 | Function to approve a SNS subscription with Amazon 120 | 121 | We don't do a ton of verification here, past making sure that the endpoint 122 | we're told to go to to verify the subscription is on the correct host 123 | """ 124 | url = data['SubscribeURL'] 125 | 126 | domain = urlparse(url).netloc 127 | pattern = getattr( 128 | settings, 129 | 'BOUNCY_SUBSCRIBE_DOMAIN_REGEX', 130 | r"sns.[a-z0-9\-]+.amazonaws.com$" 131 | ) 132 | if not re.search(pattern, domain): 133 | logger.error('Invalid Subscription Domain %s', url) 134 | return HttpResponseBadRequest('Improper Subscription Domain') 135 | 136 | try: 137 | result = urlopen(url).read() 138 | logger.info('Subscription Request Sent %s', url) 139 | except urllib.HTTPError as error: 140 | result = error.read() 141 | logger.warning('HTTP Error Creating Subscription %s', str(result)) 142 | 143 | signals.subscription.send( 144 | sender='bouncy_approve_subscription', 145 | result=result, 146 | notification=data 147 | ) 148 | 149 | # Return a 200 Status Code 150 | return HttpResponse(six.u(result)) 151 | 152 | 153 | def clean_time(time_string): 154 | """Return a datetime from the Amazon-provided datetime string""" 155 | # Get a timezone-aware datetime object from the string 156 | time = dateutil.parser.parse(time_string) 157 | if not settings.USE_TZ: 158 | # If timezone support is not active, convert the time to UTC and 159 | # remove the timezone field 160 | time = time.astimezone(timezone.utc).replace(tzinfo=None) 161 | return time 162 | -------------------------------------------------------------------------------- /django_bouncy/views.py: -------------------------------------------------------------------------------- 1 | """Views for the django_bouncy app""" 2 | import json 3 | try: 4 | from urlparse import urlparse 5 | except ImportError: 6 | from urllib.parse import urlparse 7 | 8 | import re 9 | import logging 10 | 11 | from django.http import HttpResponseBadRequest, HttpResponse, Http404 12 | from django.views.decorators.csrf import csrf_exempt 13 | from django.conf import settings 14 | 15 | from django_bouncy.utils import ( 16 | verify_notification, approve_subscription, clean_time 17 | ) 18 | from django_bouncy.models import Bounce, Complaint, Delivery 19 | from django_bouncy import signals 20 | 21 | VITAL_NOTIFICATION_FIELDS = [ 22 | 'Type', 'Message', 'Timestamp', 'Signature', 23 | 'SignatureVersion', 'TopicArn', 'MessageId', 24 | 'SigningCertURL' 25 | ] 26 | 27 | VITAL_MESSAGE_FIELDS = [ 28 | 'notificationType', 'mail' 29 | ] 30 | 31 | ALLOWED_TYPES = [ 32 | 'Notification', 'SubscriptionConfirmation', 'UnsubscribeConfirmation' 33 | ] 34 | logger = logging.getLogger(__name__) 35 | 36 | 37 | @csrf_exempt 38 | def endpoint(request): 39 | """Endpoint that SNS accesses. Includes logic verifying request""" 40 | # pylint: disable=too-many-return-statements,too-many-branches 41 | 42 | # In order to 'hide' the endpoint, all non-POST requests should return 43 | # the site's default HTTP404 44 | if request.method != 'POST': 45 | raise Http404 46 | 47 | # If necessary, check that the topic is correct 48 | if hasattr(settings, 'BOUNCY_TOPIC_ARN'): 49 | # Confirm that the proper topic header was sent 50 | if 'HTTP_X_AMZ_SNS_TOPIC_ARN' not in request.META: 51 | return HttpResponseBadRequest('No TopicArn Header') 52 | 53 | # Check to see if the topic is in the settings 54 | # Because you can have bounces and complaints coming from multiple 55 | # topics, BOUNCY_TOPIC_ARN is a list 56 | if (not request.META['HTTP_X_AMZ_SNS_TOPIC_ARN'] 57 | in settings.BOUNCY_TOPIC_ARN): 58 | return HttpResponseBadRequest('Bad Topic') 59 | 60 | # Load the JSON POST Body 61 | if isinstance(request.body, str): 62 | # requests return str in python 2.7 63 | request_body = request.body 64 | else: 65 | # and return bytes in python 3.4 66 | request_body = request.body.decode() 67 | try: 68 | data = json.loads(request_body) 69 | except ValueError: 70 | logger.warning('Notification Not Valid JSON: {}'.format(request_body)) 71 | return HttpResponseBadRequest('Not Valid JSON') 72 | 73 | # Ensure that the JSON we're provided contains all the keys we expect 74 | # Comparison code from http://stackoverflow.com/questions/1285911/ 75 | if not set(VITAL_NOTIFICATION_FIELDS) <= set(data): 76 | logger.warning('Request Missing Necessary Keys') 77 | return HttpResponseBadRequest('Request Missing Necessary Keys') 78 | 79 | # Ensure that the type of notification is one we'll accept 80 | if not data['Type'] in ALLOWED_TYPES: 81 | logger.info('Notification Type Not Known %s', data['Type']) 82 | return HttpResponseBadRequest('Unknown Notification Type') 83 | 84 | # Confirm that the signing certificate is hosted on a correct domain 85 | # AWS by default uses sns.{region}.amazonaws.com 86 | # On the off chance you need this to be a different domain, allow the 87 | # regex to be overridden in settings 88 | domain = urlparse(data['SigningCertURL']).netloc 89 | pattern = getattr( 90 | settings, 'BOUNCY_CERT_DOMAIN_REGEX', r"sns.[a-z0-9\-]+.amazonaws.com$" 91 | ) 92 | if not re.search(pattern, domain): 93 | logger.warning( 94 | 'Improper Certificate Location %s', data['SigningCertURL']) 95 | return HttpResponseBadRequest('Improper Certificate Location') 96 | 97 | # Verify that the notification is signed by Amazon 98 | if (getattr(settings, 'BOUNCY_VERIFY_CERTIFICATE', True) 99 | and not verify_notification(data)): 100 | logger.error('Verification Failure %s', ) 101 | return HttpResponseBadRequest('Improper Signature') 102 | 103 | # Send a signal to say a valid notification has been received 104 | signals.notification.send( 105 | sender='bouncy_endpoint', notification=data, request=request) 106 | 107 | # Handle subscription-based messages. 108 | if data['Type'] == 'SubscriptionConfirmation': 109 | # Allow the disabling of the auto-subscription feature 110 | if not getattr(settings, 'BOUNCY_AUTO_SUBSCRIBE', True): 111 | raise Http404 112 | return approve_subscription(data) 113 | elif data['Type'] == 'UnsubscribeConfirmation': 114 | # We won't handle unsubscribe requests here. Return a 200 status code 115 | # so Amazon won't redeliver the request. If you want to remove this 116 | # endpoint, remove it either via the API or the AWS Console 117 | logger.info('UnsubscribeConfirmation Not Handled') 118 | return HttpResponse('UnsubscribeConfirmation Not Handled') 119 | 120 | try: 121 | message = json.loads(data['Message']) 122 | except ValueError: 123 | # This message is not JSON. But we need to return a 200 status code 124 | # so that Amazon doesn't attempt to deliver the message again 125 | logger.info('Non-Valid JSON Message Received') 126 | return HttpResponse('Message is not valid JSON') 127 | 128 | return process_message(message, data) 129 | 130 | 131 | def process_message(message, notification): 132 | """ 133 | Function to process a JSON message delivered from Amazon 134 | """ 135 | # Confirm that there are 'notificationType' and 'mail' fields in our 136 | # message 137 | if not set(VITAL_MESSAGE_FIELDS) <= set(message): 138 | # At this point we're sure that it's Amazon sending the message 139 | # If we don't return a 200 status code, Amazon will attempt to send us 140 | # this same message a few seconds later. 141 | logger.info('JSON Message Missing Vital Fields') 142 | return HttpResponse('Missing Vital Fields') 143 | 144 | if message['notificationType'] == 'Complaint': 145 | return process_complaint(message, notification) 146 | if message['notificationType'] == 'Bounce': 147 | return process_bounce(message, notification) 148 | if message['notificationType'] == 'Delivery': 149 | return process_delivery(message, notification) 150 | else: 151 | return HttpResponse('Unknown Notification Type') 152 | 153 | 154 | def process_bounce(message, notification): 155 | """Function to process a bounce notification""" 156 | mail = message['mail'] 157 | bounce = message['bounce'] 158 | 159 | bounces = [] 160 | for recipient in bounce['bouncedRecipients']: 161 | # Create each bounce record. Add to a list for reference later. 162 | bounces += [Bounce.objects.create( 163 | sns_topic=notification['TopicArn'], 164 | sns_messageid=notification['MessageId'], 165 | mail_timestamp=clean_time(mail['timestamp']), 166 | mail_id=mail['messageId'], 167 | mail_from=mail['source'], 168 | address=recipient['emailAddress'], 169 | feedback_id=bounce['feedbackId'], 170 | feedback_timestamp=clean_time(bounce['timestamp']), 171 | hard=bool(bounce['bounceType'] == 'Permanent'), 172 | bounce_type=bounce['bounceType'], 173 | bounce_subtype=bounce['bounceSubType'], 174 | reporting_mta=bounce.get('reportingMTA'), 175 | action=recipient.get('action'), 176 | status=recipient.get('status'), 177 | diagnostic_code=recipient.get('diagnosticCode') 178 | )] 179 | 180 | # Send signals for each bounce. 181 | for bounce in bounces: 182 | signals.feedback.send( 183 | sender=Bounce, 184 | instance=bounce, 185 | message=message, 186 | notification=notification 187 | ) 188 | 189 | logger.info('Logged %s Bounce(s)', str(len(bounces))) 190 | 191 | return HttpResponse('Bounce Processed') 192 | 193 | 194 | def process_complaint(message, notification): 195 | """Function to process a complaint notification""" 196 | mail = message['mail'] 197 | complaint = message['complaint'] 198 | 199 | if 'arrivalDate' in complaint: 200 | arrival_date = clean_time(complaint['arrivalDate']) 201 | else: 202 | arrival_date = None 203 | 204 | complaints = [] 205 | for recipient in complaint['complainedRecipients']: 206 | # Create each Complaint. Save in a list for reference later. 207 | complaints += [Complaint.objects.create( 208 | sns_topic=notification['TopicArn'], 209 | sns_messageid=notification['MessageId'], 210 | mail_timestamp=clean_time(mail['timestamp']), 211 | mail_id=mail['messageId'], 212 | mail_from=mail['source'], 213 | address=recipient['emailAddress'], 214 | feedback_id=complaint['feedbackId'], 215 | feedback_timestamp=clean_time(complaint['timestamp']), 216 | useragent=complaint.get('userAgent'), 217 | feedback_type=complaint.get('complaintFeedbackType'), 218 | arrival_date=arrival_date 219 | )] 220 | 221 | # Send signals for each complaint. 222 | for complaint in complaints: 223 | signals.feedback.send( 224 | sender=Complaint, 225 | instance=complaint, 226 | message=message, 227 | notification=notification 228 | ) 229 | 230 | logger.info('Logged %s Complaint(s)', str(len(complaints))) 231 | 232 | return HttpResponse('Complaint Processed') 233 | 234 | 235 | def process_delivery(message, notification): 236 | """Function to process a delivery notification""" 237 | mail = message['mail'] 238 | delivery = message['delivery'] 239 | 240 | if 'timestamp' in delivery: 241 | delivered_datetime = clean_time(delivery['timestamp']) 242 | else: 243 | delivered_datetime = None 244 | 245 | deliveries = [] 246 | for eachrecipient in delivery['recipients']: 247 | # Create each delivery 248 | deliveries += [Delivery.objects.create( 249 | sns_topic=notification['TopicArn'], 250 | sns_messageid=notification['MessageId'], 251 | mail_timestamp=clean_time(mail['timestamp']), 252 | mail_id=mail['messageId'], 253 | mail_from=mail['source'], 254 | address=eachrecipient, 255 | # delivery 256 | delivered_time=delivered_datetime, 257 | processing_time=int(delivery['processingTimeMillis']), 258 | smtp_response=delivery['smtpResponse'] 259 | )] 260 | 261 | # Send signals for each delivery. 262 | for eachdelivery in deliveries: 263 | signals.feedback.send( 264 | sender=Delivery, 265 | instance=eachdelivery, 266 | message=message, 267 | notification=notification 268 | ) 269 | 270 | logger.info('Logged %s Deliveries(s)', str(len(deliveries))) 271 | 272 | return HttpResponse('Delivery Processed') 273 | -------------------------------------------------------------------------------- /example_project/__init__.py: -------------------------------------------------------------------------------- 1 | # Based off microdjango by J. Cliff Dyer 2 | # Get it at https://bitbucket.org/cliff/microdjango/ 3 | 4 | ROOT_URLCONF = 'example_project.urls' 5 | SECRET_KEY = u'None' 6 | DEBUG = True 7 | STATIC_URL = '/static/' 8 | SITE_ID = 1 9 | 10 | DATABASES = { 11 | 'default': { 12 | 'ENGINE': 'django.db.backends.sqlite3', 13 | 'NAME': './example.db', 14 | } 15 | } 16 | 17 | BOUNCY_TOPIC_ARN = ['arn:aws:sns:us-east-1:250214102493:Demo_App_Unsubscribes'] 18 | 19 | INSTALLED_APPS = ( 20 | 'django.contrib.auth', 21 | 'django.contrib.contenttypes', 22 | 'django.contrib.sessions', 23 | 'django.contrib.sites', 24 | 'django.contrib.messages', 25 | 'django.contrib.staticfiles', 26 | 'django.contrib.admin', 27 | 'django_bouncy' 28 | ) 29 | 30 | MIDDLEWARE_CLASSES = ( 31 | 'django.middleware.common.CommonMiddleware', 32 | 'django.contrib.sessions.middleware.SessionMiddleware', 33 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 34 | 'django.middleware.csrf.CsrfViewMiddleware', 35 | ) 36 | 37 | TEST_RUNNER = 'django_nose.NoseTestSuiteRunner' 38 | NOSE_ARGS = [ 39 | '--with-xunit', 40 | '--nologcapture', 41 | '--cover-package=django_bouncy', 42 | ] 43 | -------------------------------------------------------------------------------- /example_project/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url, include 2 | from django.contrib import admin 3 | 4 | admin.autodiscover() 5 | urlpatterns = [ 6 | url(r'^admin/', include(admin.site.urls)), 7 | url(r'^bouncy/', include('django_bouncy.urls')), 8 | ] 9 | -------------------------------------------------------------------------------- /ez_setup.py: -------------------------------------------------------------------------------- 1 | #!python 2 | """Bootstrap setuptools installation 3 | 4 | If you want to use setuptools in your package's setup.py, just include this 5 | file in the same directory with it, and add this to the top of your setup.py:: 6 | 7 | from ez_setup import use_setuptools 8 | use_setuptools() 9 | 10 | If you want to require a specific version of setuptools, set a download 11 | mirror, or use an alternate download directory, you can do so by supplying 12 | the appropriate options to ``use_setuptools()``. 13 | 14 | This file can also be run as a script to install or upgrade setuptools. 15 | """ 16 | import os 17 | import shutil 18 | import sys 19 | import tempfile 20 | import tarfile 21 | import optparse 22 | import subprocess 23 | 24 | from distutils import log 25 | 26 | try: 27 | from site import USER_SITE 28 | except ImportError: 29 | USER_SITE = None 30 | 31 | DEFAULT_VERSION = "0.9.6" 32 | DEFAULT_URL = "https://pypi.python.org/packages/source/s/setuptools/" 33 | 34 | def _python_cmd(*args): 35 | args = (sys.executable,) + args 36 | return subprocess.call(args) == 0 37 | 38 | def _install(tarball, install_args=()): 39 | # extracting the tarball 40 | tmpdir = tempfile.mkdtemp() 41 | log.warn('Extracting in %s', tmpdir) 42 | old_wd = os.getcwd() 43 | try: 44 | os.chdir(tmpdir) 45 | tar = tarfile.open(tarball) 46 | _extractall(tar) 47 | tar.close() 48 | 49 | # going in the directory 50 | subdir = os.path.join(tmpdir, os.listdir(tmpdir)[0]) 51 | os.chdir(subdir) 52 | log.warn('Now working in %s', subdir) 53 | 54 | # installing 55 | log.warn('Installing Setuptools') 56 | if not _python_cmd('setup.py', 'install', *install_args): 57 | log.warn('Something went wrong during the installation.') 58 | log.warn('See the error message above.') 59 | # exitcode will be 2 60 | return 2 61 | finally: 62 | os.chdir(old_wd) 63 | shutil.rmtree(tmpdir) 64 | 65 | 66 | def _build_egg(egg, tarball, to_dir): 67 | # extracting the tarball 68 | tmpdir = tempfile.mkdtemp() 69 | log.warn('Extracting in %s', tmpdir) 70 | old_wd = os.getcwd() 71 | try: 72 | os.chdir(tmpdir) 73 | tar = tarfile.open(tarball) 74 | _extractall(tar) 75 | tar.close() 76 | 77 | # going in the directory 78 | subdir = os.path.join(tmpdir, os.listdir(tmpdir)[0]) 79 | os.chdir(subdir) 80 | log.warn('Now working in %s', subdir) 81 | 82 | # building an egg 83 | log.warn('Building a Setuptools egg in %s', to_dir) 84 | _python_cmd('setup.py', '-q', 'bdist_egg', '--dist-dir', to_dir) 85 | 86 | finally: 87 | os.chdir(old_wd) 88 | shutil.rmtree(tmpdir) 89 | # returning the result 90 | log.warn(egg) 91 | if not os.path.exists(egg): 92 | raise IOError('Could not build the egg.') 93 | 94 | 95 | def _do_download(version, download_base, to_dir, download_delay): 96 | egg = os.path.join(to_dir, 'setuptools-%s-py%d.%d.egg' 97 | % (version, sys.version_info[0], sys.version_info[1])) 98 | if not os.path.exists(egg): 99 | tarball = download_setuptools(version, download_base, 100 | to_dir, download_delay) 101 | _build_egg(egg, tarball, to_dir) 102 | sys.path.insert(0, egg) 103 | import setuptools 104 | setuptools.bootstrap_install_from = egg 105 | 106 | 107 | def use_setuptools(version=DEFAULT_VERSION, download_base=DEFAULT_URL, 108 | to_dir=os.curdir, download_delay=15): 109 | # making sure we use the absolute path 110 | to_dir = os.path.abspath(to_dir) 111 | was_imported = 'pkg_resources' in sys.modules or \ 112 | 'setuptools' in sys.modules 113 | try: 114 | import pkg_resources 115 | except ImportError: 116 | return _do_download(version, download_base, to_dir, download_delay) 117 | try: 118 | pkg_resources.require("setuptools>=" + version) 119 | return 120 | except pkg_resources.VersionConflict: 121 | e = sys.exc_info()[1] 122 | if was_imported: 123 | sys.stderr.write( 124 | "The required version of setuptools (>=%s) is not available,\n" 125 | "and can't be installed while this script is running. Please\n" 126 | "install a more recent version first, using\n" 127 | "'easy_install -U setuptools'." 128 | "\n\n(Currently using %r)\n" % (version, e.args[0])) 129 | sys.exit(2) 130 | else: 131 | del pkg_resources, sys.modules['pkg_resources'] # reload ok 132 | return _do_download(version, download_base, to_dir, 133 | download_delay) 134 | except pkg_resources.DistributionNotFound: 135 | return _do_download(version, download_base, to_dir, 136 | download_delay) 137 | 138 | 139 | def download_setuptools(version=DEFAULT_VERSION, download_base=DEFAULT_URL, 140 | to_dir=os.curdir, delay=15): 141 | """Download setuptools from a specified location and return its filename 142 | 143 | `version` should be a valid setuptools version number that is available 144 | as an egg for download under the `download_base` URL (which should end 145 | with a '/'). `to_dir` is the directory where the egg will be downloaded. 146 | `delay` is the number of seconds to pause before an actual download 147 | attempt. 148 | """ 149 | # making sure we use the absolute path 150 | to_dir = os.path.abspath(to_dir) 151 | try: 152 | from urllib.request import urlopen 153 | except ImportError: 154 | from urllib2 import urlopen 155 | tgz_name = "setuptools-%s.tar.gz" % version 156 | url = download_base + tgz_name 157 | saveto = os.path.join(to_dir, tgz_name) 158 | src = dst = None 159 | if not os.path.exists(saveto): # Avoid repeated downloads 160 | try: 161 | log.warn("Downloading %s", url) 162 | src = urlopen(url) 163 | # Read/write all in one block, so we don't create a corrupt file 164 | # if the download is interrupted. 165 | data = src.read() 166 | dst = open(saveto, "wb") 167 | dst.write(data) 168 | finally: 169 | if src: 170 | src.close() 171 | if dst: 172 | dst.close() 173 | return os.path.realpath(saveto) 174 | 175 | 176 | def _extractall(self, path=".", members=None): 177 | """Extract all members from the archive to the current working 178 | directory and set owner, modification time and permissions on 179 | directories afterwards. `path' specifies a different directory 180 | to extract to. `members' is optional and must be a subset of the 181 | list returned by getmembers(). 182 | """ 183 | import copy 184 | import operator 185 | from tarfile import ExtractError 186 | directories = [] 187 | 188 | if members is None: 189 | members = self 190 | 191 | for tarinfo in members: 192 | if tarinfo.isdir(): 193 | # Extract directories with a safe mode. 194 | directories.append(tarinfo) 195 | tarinfo = copy.copy(tarinfo) 196 | tarinfo.mode = 448 # decimal for oct 0700 197 | self.extract(tarinfo, path) 198 | 199 | # Reverse sort directories. 200 | if sys.version_info < (2, 4): 201 | def sorter(dir1, dir2): 202 | return cmp(dir1.name, dir2.name) 203 | directories.sort(sorter) 204 | directories.reverse() 205 | else: 206 | directories.sort(key=operator.attrgetter('name'), reverse=True) 207 | 208 | # Set correct owner, mtime and filemode on directories. 209 | for tarinfo in directories: 210 | dirpath = os.path.join(path, tarinfo.name) 211 | try: 212 | self.chown(tarinfo, dirpath) 213 | self.utime(tarinfo, dirpath) 214 | self.chmod(tarinfo, dirpath) 215 | except ExtractError: 216 | e = sys.exc_info()[1] 217 | if self.errorlevel > 1: 218 | raise 219 | else: 220 | self._dbg(1, "tarfile: %s" % e) 221 | 222 | 223 | def _build_install_args(options): 224 | """ 225 | Build the arguments to 'python setup.py install' on the setuptools package 226 | """ 227 | install_args = [] 228 | if options.user_install: 229 | if sys.version_info < (2, 6): 230 | log.warn("--user requires Python 2.6 or later") 231 | raise SystemExit(1) 232 | install_args.append('--user') 233 | return install_args 234 | 235 | def _parse_args(): 236 | """ 237 | Parse the command line for options 238 | """ 239 | parser = optparse.OptionParser() 240 | parser.add_option( 241 | '--user', dest='user_install', action='store_true', default=False, 242 | help='install in user site package (requires Python 2.6 or later)') 243 | parser.add_option( 244 | '--download-base', dest='download_base', metavar="URL", 245 | default=DEFAULT_URL, 246 | help='alternative URL from where to download the setuptools package') 247 | options, args = parser.parse_args() 248 | # positional arguments are ignored 249 | return options 250 | 251 | def main(version=DEFAULT_VERSION): 252 | """Install or upgrade setuptools and EasyInstall""" 253 | options = _parse_args() 254 | tarball = download_setuptools(download_base=options.download_base) 255 | return _install(tarball, _build_install_args(options)) 256 | 257 | if __name__ == '__main__': 258 | sys.exit(main()) 259 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_project") 7 | from django.core.management import execute_from_command_line 8 | execute_from_command_line(sys.argv) 9 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django==1.11.16 2 | nose 3 | django-nose 4 | coverage 5 | mock 6 | pyopenssl>=0.13.1 7 | pem>=16.0.0 8 | python-dateutil 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup 4 | 5 | import os 6 | # import sys 7 | import textwrap 8 | 9 | ROOT = os.path.abspath(os.path.dirname(__file__)) 10 | 11 | setup( 12 | name='django-bouncy', 13 | version='0.2.7', 14 | author='Nick Catalano', 15 | packages=[ 16 | 'django_bouncy', 'django_bouncy.migrations', 'django_bouncy.tests'], 17 | url='https://github.com/ofa/django-bouncy', 18 | description=( 19 | "A way to handle bounce and abuse reports delivered by Amazon's Simple" 20 | " Notification Service regarding emails sent by Simple Email Service" 21 | ), 22 | long_description=textwrap.dedent( 23 | open(os.path.join(ROOT, 'README.rst')).read()), 24 | include_package_data=True, 25 | zip_safe=False, 26 | install_requires=[ 27 | 'Django>=1.11', 28 | 'python-dateutil>=2.1', 29 | 'pyopenssl>=0.13.1', 30 | 'pem>=16.0.0', 31 | ], 32 | keywords="aws ses sns seacucumber boto", 33 | classifiers=[ 34 | 'Development Status :: 4 - Beta', 35 | 'Intended Audience :: Developers', 36 | 'Topic :: Internet :: WWW/HTTP'] 37 | ) 38 | -------------------------------------------------------------------------------- /test_settings.py: -------------------------------------------------------------------------------- 1 | DATABASE_ENGINE = 'sqlite3' 2 | 3 | SECRET_KEY = 'abcd123' 4 | 5 | DATABASES = { 6 | 'default': { 7 | 'ENGINE': 'django.db.backends.sqlite3', 8 | 'NAME': './example.db', 9 | } 10 | } 11 | 12 | INSTALLED_APPS = ( 13 | 'django_bouncy', 14 | ) 15 | 16 | BOUNCY_TOPIC_ARN = ['arn:aws:sns:us-east-1:250214102493:Demo_App_Unsubscribes'] 17 | 18 | TEST_RUNNER = 'django_nose.NoseTestSuiteRunner' 19 | NOSE_ARGS = [ 20 | '--with-xunit', 21 | '--nologcapture', 22 | '--cover-package=django_bouncy', 23 | ] 24 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = {py35,py27}-django{111,20,21} 3 | [testenv] 4 | basepython = 5 | py35: python3.5 6 | py27: python2.7 7 | deps = 8 | django111: django<1.11.99 9 | django20: django<2.0.99 10 | django21: django<2.1.99 11 | nose 12 | django-nose 13 | coverage 14 | mock 15 | pyopenssl>=0.13.1 16 | pem>=16.0.0 17 | python-dateutil 18 | commands = python manage.py test --settings 'test_settings' 19 | --------------------------------------------------------------------------------