├── cloudmailin ├── tests │ ├── models.py │ ├── __init__.py │ ├── run_tests.sh │ ├── mail │ │ ├── plain.txt │ │ ├── html.txt │ │ └── message.txt │ ├── settings.py │ ├── urls.py │ └── tests.py ├── __init__.py └── views.py ├── requirements.txt ├── MANIFEST.in ├── setup.py ├── README.rst └── LICENSE /cloudmailin/tests/models.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cloudmailin/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | django>=1.2.0 -------------------------------------------------------------------------------- /cloudmailin/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.1" -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | include requirements.txt -------------------------------------------------------------------------------- /cloudmailin/tests/run_tests.sh: -------------------------------------------------------------------------------- 1 | django-admin.py test --settings=cloudmailin.tests.settings --pythonpath=../.. -------------------------------------------------------------------------------- /cloudmailin/tests/mail/plain.txt: -------------------------------------------------------------------------------- 1 | This is a test post.\n\n - The first item (Sunlight Foundation )\n - The second item (Sunlight Labs )\n\nAnd we're done. -------------------------------------------------------------------------------- /cloudmailin/tests/settings.py: -------------------------------------------------------------------------------- 1 | DATABASES = { 2 | 'default': { 3 | 'ENGINE': 'django.db.backends.sqlite3', 4 | 'NAME': 'cloudmailin.db', 5 | }, 6 | } 7 | 8 | INSTALLED_APPS = ( 9 | 'cloudmailin.tests', 10 | ) 11 | 12 | ROOT_URLCONF = 'cloudmailin.tests.urls' -------------------------------------------------------------------------------- /cloudmailin/tests/mail/html.txt: -------------------------------------------------------------------------------- 1 | This is a test post.
And we're done.
-------------------------------------------------------------------------------- /cloudmailin/tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls.defaults import * 2 | from cloudmailin.views import MailHandler 3 | 4 | def mail_stub(**kwargs): 5 | pass 6 | 7 | def mail_500(**kwargs): 8 | raise Exception('this is a made up exception') 9 | 10 | mail_handler = MailHandler() 11 | mail_handler.register_address( 12 | address='animaginaryperson@example.com', 13 | secret='notactuallysecret', 14 | callback=mail_stub 15 | ) 16 | mail_handler.register_address( 17 | address='500@example.com', 18 | secret='notactuallysecret', 19 | callback=mail_500 20 | ) 21 | 22 | urlpatterns = patterns('', 23 | url(r'^mail/$', mail_handler), 24 | ) -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from cloudmailin import __version__ 2 | from setuptools import setup, find_packages 3 | import os 4 | 5 | f = open(os.path.join(os.path.dirname(__file__), 'README.rst')) 6 | readme = f.read() 7 | f.close() 8 | 9 | setup( 10 | name='django-cloudmailin', 11 | version=__version__, 12 | description='Client for CloudMailin incoming email service', 13 | long_description=readme, 14 | author='Jeremy Carbaugh', 15 | author_email='jcarbaugh@sunlightfoundation.com', 16 | url='http://github.com/sunlightlabs/django-cloudmailin/', 17 | packages=find_packages(), 18 | license='BSD License', 19 | platforms=["any"], 20 | classifiers=[ 21 | 'Development Status :: 4 - Beta', 22 | 'Intended Audience :: Developers', 23 | 'License :: OSI Approved :: BSD License', 24 | 'Natural Language :: English', 25 | 'Operating System :: OS Independent', 26 | 'Programming Language :: Python', 27 | 'Environment :: Web Environment', 28 | ], 29 | ) 30 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================== 2 | django-cloudmailin 3 | ================== 4 | 5 | http://cloudmailin.com/ 6 | 7 | ------------ 8 | Requirements 9 | ------------ 10 | 11 | * django >= 1.2.0 12 | 13 | ------------- 14 | Configuration 15 | ------------- 16 | 17 | settings.py 18 | =========== 19 | 20 | Add to *INSTALLED_APPS*:: 21 | 22 | 'cloudmailin' 23 | 24 | Usage 25 | ===== 26 | 27 | MailHandler is a class based view. Since an application may have multiple 28 | email addresses, they have to be registered with their own secret key and 29 | callbacks. The callbacks can be reused if you want the same functionality for 30 | different email addresses. 31 | 32 | :: 33 | 34 | from cloudmailin.views import MailHandler 35 | 36 | mail_handler = MailHandler() 37 | mail_handler.register_address( 38 | address='mysecretemail@cloudmailin.net', 39 | secret='mysupersecretkey', 40 | callback=my_callback_function 41 | ) 42 | 43 | The callback will receive the HTTP post variables as keyword arguments:: 44 | 45 | def my_callback_function(**kwargs): 46 | # kwargs is a dict of cloudmailin post params 47 | pass 48 | 49 | Then, in urls.py, register a URL pattern to act as the endpoint:: 50 | 51 | url(r'^receive/mail/here/$', mail_handler) 52 | 53 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011, Sunlight Foundation 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | * Neither the name of Sunlight Foundation nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 9 | 10 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /cloudmailin/tests/mail/message.txt: -------------------------------------------------------------------------------- 1 | Received: by wwc33 with SMTP id 33so4379569wwc.27\r\n for <086724d946a97d966551@cloudmailin.net>; Mon, 28 Feb 2011 10:53:56 -0800 (PST)\r\nMIME-Version: 1.0\r\nReceived: by 10.216.39.196 with SMTP id d46mr4872371web.114.1298918897650;\r\n Mon, 28 Feb 2011 10:48:17 -0800 (PST)\r\nReceived: by 10.216.89.18 with HTTP; Mon, 28 Feb 2011 10:48:17 -0800 (PST)\r\nIn-Reply-To: \r\nReferences: \r\nDate: Mon, 28 Feb 2011 13:48:17 -0500\r\nMessage-ID: \r\nSubject: Testy Test Test Test\r\nFrom: Jeremy Carbaugh \r\nTo: 086724d946a97d966551@cloudmailin.net\r\nContent-Type: multipart/alternative; boundary=0016e64c3e667c8b15049d5c1fda\r\n\r\n--0016e64c3e667c8b15049d5c1fda\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\nThis is a test post.\r\n\r\n - The first item (Sunlight Foundation )\r\n - The second item (Sunlight Labs )\r\n\r\nAnd we\'re done.\r\n\r\n--0016e64c3e667c8b15049d5c1fda\r\nContent-Type: text/html; charset=UTF-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\nThis i=\r\ns a test post.
And we're done.
\r\n\r\n--0016e64c3e667c8b15049d5c1fda-- -------------------------------------------------------------------------------- /cloudmailin/views.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.exceptions import ImproperlyConfigured 3 | from django.http import HttpResponse, HttpResponseNotFound, HttpResponseForbidden, HttpResponseServerError 4 | import hashlib 5 | 6 | def generate_signature(params, secret): 7 | sig = "".join(params[k].encode('utf-8') for k in sorted(params.keys()) if k != "signature") 8 | sig = hashlib.md5(sig + secret).hexdigest() 9 | return sig 10 | 11 | class MailHandler(object): 12 | 13 | csrf_exempt = True 14 | 15 | def __init__(self, *args, **kwargs): 16 | super(MailHandler, self).__init__(*args, **kwargs) 17 | self._addresses = {} 18 | 19 | def __call__(self, request, *args, **kwargs): 20 | 21 | params = dict((k, v) for k, v in request.POST.iteritems()) 22 | 23 | to = params.get('to', None) 24 | 25 | if to and '+' in to: 26 | lto = to.split('+') 27 | to = lto[0] + "@" + lto[1].split('@')[1] 28 | 29 | addr = self._addresses.get(to, None) 30 | 31 | if addr is None: 32 | return HttpResponseNotFound("recipient address is not found", mimetype="text/plain") 33 | 34 | try: 35 | 36 | if not self.is_valid_signature(params, addr['secret']): 37 | return HttpResponseForbidden("invalid message signature", mimetype="text/plain") 38 | 39 | addr['callback'](**params) 40 | 41 | except Exception, e: 42 | return HttpResponseServerError(e.message, mimetype="text/plain") 43 | 44 | resp = HttpResponse("") 45 | resp.csrf_exempt = True 46 | return resp 47 | 48 | def is_valid_signature(self, params, secret): 49 | if 'signature' in params: 50 | sig = generate_signature(params, secret) 51 | return params['signature'] == sig 52 | 53 | def register_address(self, address, secret, callback): 54 | self._addresses["<%s>" % address] = { 55 | 'secret': secret, 56 | 'callback': callback, 57 | } 58 | return True 59 | 60 | def unregister_address(self, address): 61 | if address in self._addresses: 62 | del self._addresses[address] 63 | return True 64 | return False 65 | -------------------------------------------------------------------------------- /cloudmailin/tests/tests.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured 2 | from django.test import TestCase 3 | from cloudmailin.views import generate_signature as real_generate_signature 4 | import hashlib 5 | import os 6 | 7 | # generic utilities 8 | 9 | PWD = os.path.abspath(os.path.dirname(__file__)) 10 | 11 | def load_email(filename): 12 | f = open(os.path.join(PWD, 'mail', filename)) 13 | content = f.read() 14 | f.close() 15 | return content 16 | 17 | # cloudmailin tests 18 | 19 | SECRET = 'notactuallysecret' 20 | 21 | BASE_PARAMS = { 22 | 'message': load_email('message.txt'), 23 | 'plain': load_email('plain.txt'), 24 | 'html': load_email('html.txt'), 25 | 'to': '', 26 | 'disposable': '', 27 | 'from': 'anotherperson@example.com', 28 | 'subject': 'Hi, this is my email', 29 | } 30 | 31 | def generate_signature(params, secret): 32 | sig = "".join(params[k] for k in sorted(params.keys())) 33 | sig = hashlib.md5(sig + secret).hexdigest() 34 | return sig 35 | 36 | class CloudMailinTestCase(TestCase): 37 | 38 | def test_get(self): 39 | resp = self.client.get('/mail/') 40 | self.assertEquals(resp.status_code, 404) 41 | self.assertEquals(resp.content, "recipient address is not found") 42 | 43 | def test_post(self): 44 | params = BASE_PARAMS.copy() 45 | params['signature'] = generate_signature(params, SECRET) 46 | resp = self.client.post('/mail/', params) 47 | self.assertEquals(resp.status_code, 200) 48 | self.assertEquals(resp.content, "") 49 | 50 | def test_post_disposable(self): 51 | params = BASE_PARAMS.copy() 52 | params['to'] = '' 53 | params['signature'] = generate_signature(params, SECRET) 54 | resp = self.client.post('/mail/', params) 55 | self.assertEquals(resp.status_code, 200) 56 | self.assertEquals(resp.content, "") 57 | 58 | def test_post_noparams(self): 59 | resp = self.client.post('/mail/') 60 | self.assertEquals(resp.status_code, 404) 61 | self.assertEquals(resp.content, "recipient address is not found") 62 | 63 | def test_post_invalidsig(self): 64 | params = BASE_PARAMS.copy() 65 | params['signature'] = generate_signature(params, SECRET) + "!" 66 | resp = self.client.post('/mail/', params) 67 | self.assertEquals(resp.status_code, 403) 68 | self.assertEquals(resp.content, "invalid message signature") 69 | 70 | def test_post_invalidrecipient(self): 71 | params = BASE_PARAMS.copy() 72 | params['to'] = '' 73 | params['signature'] = generate_signature(params, SECRET) 74 | resp = self.client.post('/mail/', params) 75 | self.assertEquals(resp.status_code, 404) 76 | self.assertEquals(resp.content, "recipient address is not found") 77 | 78 | def test_post_500(self): 79 | params = BASE_PARAMS.copy() 80 | params['to'] = '<500@example.com>' 81 | params['signature'] = generate_signature(params, SECRET) 82 | resp = self.client.post('/mail/', params) 83 | self.assertEquals(resp.status_code, 500) 84 | self.assertEquals(resp.content, "this is a made up exception") 85 | 86 | def test_signature(self): 87 | self.assertEquals( 88 | real_generate_signature(BASE_PARAMS, SECRET), 89 | generate_signature(BASE_PARAMS, SECRET), 90 | ) 91 | 92 | --------------------------------------------------------------------------------