├── MANIFEST.in ├── README.rst ├── postmark ├── __init__.py ├── admin.py ├── backends.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto__chg_field_emailmessage_tag.py │ └── __init__.py ├── models.py ├── signals.py ├── urls.py └── views.py └── setup.py /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Django Postmark 2 | =============== 3 | 4 | django-postmark is a reusable app that includes an EmailBackend for sending email 5 | with Django as well as models and view that enable integration with Postmark's 6 | bounce hook api. 7 | 8 | Installation 9 | ------------ 10 | 11 | You can install django postmark with pip by typing:: 12 | 13 | pip install django-postmark 14 | 15 | Or with easy_install by typing:: 16 | 17 | easy_install django-postmark 18 | 19 | Or manually by downloading a tarball and typing:: 20 | 21 | python setup.py install 22 | 23 | Once installed add `postmark` to your `INSTALLED_APPS` and run:: 24 | 25 | python manage.py syncdb 26 | 27 | Or if you are using south:: 28 | 29 | python manage.py migrate 30 | 31 | Django Configuration 32 | -------------------- 33 | 34 | If you want to use django-postmark as your default backend, you should add:: 35 | 36 | EMAIL_BACKEND = "postmark.backends.PostmarkBackend" 37 | 38 | to your settings.py 39 | 40 | Settings 41 | -------- 42 | 43 | django-postmark adds 1 required setting and 2 optional settings. 44 | 45 | Required: 46 | Specifies the api key for your postmark server.:: 47 | 48 | POSTMARK_API_KEY = 'POSTMARK_API_TEST' 49 | 50 | Optional: 51 | Specifies a username and password that the view will require to be passed 52 | in via basic auth. (http://exampleuser:examplepassword@example.com/postmark/bounce/):: 53 | 54 | POSTMARK_API_USER = "exampleuser" 55 | POSTMARK_API_PASSWORD = "examplepassword" 56 | 57 | Postmark Bounce Hook 58 | -------------------- 59 | 60 | Postmark has the optional ability to POST to an url anytime a message you have 61 | sent bounces. django-postmark comes with an urlconf and view for this purpose. If 62 | you wish to use this then add:: 63 | 64 | url(r"^postmark/", include("postmark.urls")), 65 | 66 | to your root urls.py. This will cause your bounce hook location to live 67 | at /postmark/bounce/. Then simply add in the url to your Postmark settings (with 68 | the username and password specified by POSTMARK_API_USER/PASSWORD if set) and 69 | django will accept POSTS from Postmark notifying it of a new bounce. 70 | -------------------------------------------------------------------------------- /postmark/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = (0, 1, 6, "final", 0) 2 | 3 | 4 | def get_version(): 5 | if VERSION[3] == "final": 6 | return "%s.%s.%s" % (VERSION[0], VERSION[1], VERSION[2]) 7 | elif VERSION[3] == "dev": 8 | if VERSION[2] == 0: 9 | return "%s.%s.%s%s" % (VERSION[0], VERSION[1], VERSION[3], VERSION[4]) 10 | return "%s.%s.%s.%s%s" % (VERSION[0], VERSION[1], VERSION[2], VERSION[3], VERSION[4]) 11 | else: 12 | return "%s.%s.%s%s" % (VERSION[0], VERSION[1], VERSION[2], VERSION[3]) 13 | 14 | 15 | __version__ = get_version() 16 | -------------------------------------------------------------------------------- /postmark/admin.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import ugettext_lazy as _ 2 | from django.contrib import admin 3 | 4 | from postmark.models import EmailMessage, EmailBounce 5 | 6 | class EmailBounceAdmin(admin.ModelAdmin): 7 | list_display = ("get_message_to", "get_message_to_type", "get_message_subject", "get_message_tag", "type", "bounced_at") 8 | list_filter = ("type", "message__tag", "bounced_at") 9 | search_fields = ("message__message_id", "message__subject", "message__to") 10 | list_select_related = True 11 | 12 | readonly_fields = ("id", "message", "inactive", "can_activate", "type", "description", "details", "bounced_at") 13 | 14 | def get_message_to(self, obj): 15 | return u"%s" % obj.message.to 16 | get_message_to.short_description = _("To") 17 | 18 | def get_message_to_type(self, obj): 19 | return u"%s" % obj.message.get_to_type_display() 20 | get_message_to_type.short_description = _("Type") 21 | 22 | def get_message_subject(self, obj): 23 | return u"%s" % obj.message.subject 24 | get_message_subject.short_description = _("Subject") 25 | 26 | def get_message_tag(self, obj): 27 | return u"%s" % obj.message.tag 28 | get_message_tag.short_description = _("Tag") 29 | 30 | 31 | class EmailBounceInline(admin.TabularInline): 32 | model = EmailBounce 33 | can_delete = False 34 | 35 | max_num = 1 36 | extra = 0 37 | 38 | readonly_fields = ("id", "message", "inactive", "can_activate", "type", "description", "details", "bounced_at") 39 | 40 | 41 | class EmailMessageAdmin(admin.ModelAdmin): 42 | list_display = ("to", "to_type", "subject", "tag", "status", "submitted_at") 43 | list_filter = ("status", "tag", "to_type", "submitted_at") 44 | search_fields = ("message_id", "to", "subject") 45 | list_select_related = True 46 | 47 | readonly_fields = ("message_id", "status", "subject", "tag", "to", "to_type", "sender", "reply_to", "submitted_at", "text_body", "html_body", "headers", "attachments") 48 | 49 | inlines = [EmailBounceInline] 50 | 51 | fieldsets = ( 52 | (None, { 53 | "fields": ("message_id", "status", "subject", "tag", "to", "to_type", "sender", "reply_to", "submitted_at") 54 | }), 55 | (_("Text Body"), { 56 | "fields": ("text_body",), 57 | "classes": ("collapse", "closed") 58 | }), 59 | (_("HTML Body"), { 60 | "fields": ("html_body",), 61 | "classes": ("collapse", "closed") 62 | }), 63 | (_("Advanced"), { 64 | "fields": ("headers", "attachments"), 65 | "classes": ("collapse", "closed") 66 | }) 67 | ) 68 | 69 | admin.site.register(EmailMessage, EmailMessageAdmin) 70 | admin.site.register(EmailBounce, EmailBounceAdmin) -------------------------------------------------------------------------------- /postmark/backends.py: -------------------------------------------------------------------------------- 1 | from django.core.mail.backends.base import BaseEmailBackend 2 | from django.core.mail import EmailMultiAlternatives 3 | from django.core.exceptions import ImproperlyConfigured 4 | from django.core import serializers 5 | from django.conf import settings 6 | import httplib2 7 | 8 | try: 9 | import json 10 | except ImportError: 11 | try: 12 | import simplejson as json 13 | except ImportError: 14 | raise Exception('Cannot use django-postmark without Python 2.6 or greater, or Python 2.4 or 2.5 and the "simplejson" library') 15 | 16 | from postmark.signals import post_send 17 | 18 | # Settings 19 | POSTMARK_API_KEY = getattr(settings, "POSTMARK_API_KEY", None) 20 | POSTMARK_SSL = getattr(settings, "POSTMARK_SSL", False) 21 | POSTMARK_TEST_MODE = getattr(settings, "POSTMARK_TEST_MODE", False) 22 | 23 | POSTMARK_API_URL = ("https" if POSTMARK_SSL else "http") + "://api.postmarkapp.com/email" 24 | 25 | class PostmarkMailSendException(Exception): 26 | """ 27 | Base Postmark send exception 28 | """ 29 | def __init__(self, value, inner_exception=None): 30 | self.parameter = value 31 | self.inner_exception = inner_exception 32 | def __str__(self): 33 | return repr(self.parameter) 34 | 35 | class PostmarkMailUnauthorizedException(PostmarkMailSendException): 36 | """ 37 | 401: Unathorized sending due to bad API key 38 | """ 39 | pass 40 | 41 | class PostmarkMailUnprocessableEntityException(PostmarkMailSendException): 42 | """ 43 | 422: Unprocessable Entity - usually an exception with either the sender 44 | not having a matching Sender Signature in Postmark. Read the message 45 | details for further information 46 | """ 47 | pass 48 | 49 | class PostmarkMailServerErrorException(PostmarkMailSendException): 50 | """ 51 | 500: Internal error - this is on the Postmark server side. Errors are 52 | logged and recorded at Postmark. 53 | """ 54 | pass 55 | 56 | class PostmarkMessage(dict): 57 | """ 58 | Creates a Dictionary representation of a Django EmailMessage that is suitable 59 | for submitting to Postmark's API. An Example Dicitionary would be: 60 | 61 | { 62 | "From" : "sender@example.com", 63 | "To" : "receiver@example.com", 64 | "Cc" : "copied@example.com", 65 | "Bcc": "blank-copied@example.com", 66 | "Subject" : "Test", 67 | "Tag" : "Invitation", 68 | "HtmlBody" : "Hello", 69 | "TextBody" : "Hello", 70 | "ReplyTo" : "reply@example.com", 71 | "Headers" : [{ "Name" : "CUSTOM-HEADER", "Value" : "value" }], 72 | "Attachments": [ 73 | { 74 | "Name": "readme.txt", 75 | "Content": "dGVzdCBjb250ZW50", 76 | "ContentType": "text/plain" 77 | }, 78 | { 79 | "Name": "report.pdf", 80 | "Content": "dGVzdCBjb250ZW50", 81 | "ContentType": "application/octet-stream" 82 | } 83 | ] 84 | } 85 | """ 86 | 87 | def __init__(self, message, fail_silently=False): 88 | """ 89 | Takes a Django EmailMessage and parses it into a usable object for 90 | sending to Postmark. 91 | """ 92 | try: 93 | message_dict = {} 94 | 95 | message_dict["From"] = message.from_email 96 | message_dict["Subject"] = unicode(message.subject) 97 | message_dict["TextBody"] = unicode(message.body) 98 | 99 | message_dict["To"] = ",".join(message.to) 100 | 101 | if len(message.cc): 102 | message_dict["Cc"] = ",".join(message.cc) 103 | if len(message.bcc): 104 | message_dict["Bcc"] = ",".join(message.bcc) 105 | 106 | if isinstance(message, EmailMultiAlternatives): 107 | for alt in message.alternatives: 108 | if alt[1] == "text/html": 109 | message_dict["HtmlBody"] = unicode(alt[0]) 110 | 111 | if message.extra_headers and isinstance(message.extra_headers, dict): 112 | if message.extra_headers.has_key("Reply-To"): 113 | message_dict["ReplyTo"] = message.extra_headers.pop("Reply-To") 114 | 115 | if message.extra_headers.has_key("X-Postmark-Tag"): 116 | message_dict["Tag"] = message.extra_headers.pop("X-Postmark-Tag") 117 | 118 | if len(message.extra_headers): 119 | message_dict["Headers"] = [{"Name": x[0], "Value": x[1]} for x in message.extra_headers.items()] 120 | 121 | if message.attachments and isinstance(message.attachments, list): 122 | if len(message.attachments): 123 | message_dict["Attachments"] = message.attachments 124 | 125 | except: 126 | if fail_silently: 127 | message_dict = {} 128 | else: 129 | raise 130 | 131 | super(PostmarkMessage, self).__init__(message_dict) 132 | 133 | class PostmarkBackend(BaseEmailBackend): 134 | 135 | BATCH_SIZE = 500 136 | 137 | def __init__(self, api_key=None, api_url=None, api_batch_url=None, **kwargs): 138 | """ 139 | Initialize the backend. 140 | """ 141 | super(PostmarkBackend, self).__init__(**kwargs) 142 | 143 | self.api_key = api_key or POSTMARK_API_KEY 144 | self.api_url = api_url or POSTMARK_API_URL 145 | 146 | if self.api_key is None: 147 | raise ImproperlyConfigured("POSTMARK_API_KEY must be set in Django settings file or passed to backend constructor.") 148 | 149 | def send_messages(self, email_messages): 150 | """ 151 | Sends one or more EmailMessage objects and returns the number of email 152 | messages sent. 153 | """ 154 | if not email_messages: 155 | return 156 | 157 | num_sent = 0 158 | for message in email_messages: 159 | sent = self._send(PostmarkMessage(message, self.fail_silently)) 160 | if sent: 161 | num_sent += 1 162 | return num_sent 163 | 164 | def _send(self, message): 165 | http = httplib2.Http() 166 | 167 | if POSTMARK_TEST_MODE: 168 | print 'JSON message is:\n%s' % json.dumps(message) 169 | return 170 | 171 | try: 172 | resp, content = http.request(self.api_url, 173 | body=json.dumps(message), 174 | method="POST", 175 | headers={ 176 | "Accept": "application/json", 177 | "Content-Type": "application/json", 178 | "X-Postmark-Server-Token": self.api_key, 179 | }) 180 | except httplib2.HttpLib2Error: 181 | if not self.fail_silently: 182 | return False 183 | raise 184 | 185 | if resp["status"] == "200": 186 | post_send.send(sender=self, message=message, response=json.loads(content)) 187 | return True 188 | elif resp["status"] == "401": 189 | if not self.fail_silently: 190 | raise PostmarkMailUnauthorizedException("Your Postmark API Key is Invalid.") 191 | elif resp["status"] == "422": 192 | if not self.fail_silently: 193 | content_dict = json.loads(content) 194 | raise PostmarkMailUnprocessableEntityException(content_dict["Message"]) 195 | elif resp["status"] == "500": 196 | if not self.fail_silently: 197 | PostmarkMailServerErrorException() 198 | 199 | return False -------------------------------------------------------------------------------- /postmark/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | import datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | 7 | class Migration(SchemaMigration): 8 | 9 | def forwards(self, orm): 10 | 11 | # Adding model 'EmailMessage' 12 | db.create_table('postmark_emailmessage', ( 13 | ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), 14 | ('message_id', self.gf('django.db.models.fields.CharField')(max_length=40)), 15 | ('submitted_at', self.gf('django.db.models.fields.DateTimeField')()), 16 | ('status', self.gf('django.db.models.fields.CharField')(max_length=150)), 17 | ('to', self.gf('django.db.models.fields.CharField')(max_length=150)), 18 | ('to_type', self.gf('django.db.models.fields.CharField')(max_length=3)), 19 | ('sender', self.gf('django.db.models.fields.CharField')(max_length=150)), 20 | ('reply_to', self.gf('django.db.models.fields.CharField')(max_length=150)), 21 | ('subject', self.gf('django.db.models.fields.CharField')(max_length=150)), 22 | ('tag', self.gf('django.db.models.fields.CharField')(max_length=25)), 23 | ('text_body', self.gf('django.db.models.fields.TextField')()), 24 | ('html_body', self.gf('django.db.models.fields.TextField')()), 25 | ('headers', self.gf('django.db.models.fields.TextField')()), 26 | ('attachments', self.gf('django.db.models.fields.TextField')()), 27 | )) 28 | db.send_create_signal('postmark', ['EmailMessage']) 29 | 30 | # Adding model 'EmailBounce' 31 | db.create_table('postmark_emailbounce', ( 32 | ('id', self.gf('django.db.models.fields.PositiveIntegerField')(primary_key=True)), 33 | ('message', self.gf('django.db.models.fields.related.ForeignKey')(related_name='bounces', to=orm['postmark.EmailMessage'])), 34 | ('inactive', self.gf('django.db.models.fields.BooleanField')(default=False)), 35 | ('can_activate', self.gf('django.db.models.fields.BooleanField')(default=False)), 36 | ('type', self.gf('django.db.models.fields.CharField')(max_length=100)), 37 | ('description', self.gf('django.db.models.fields.TextField')()), 38 | ('details', self.gf('django.db.models.fields.TextField')()), 39 | ('bounced_at', self.gf('django.db.models.fields.DateTimeField')()), 40 | ('_order', self.gf('django.db.models.fields.IntegerField')(default=0)), 41 | )) 42 | db.send_create_signal('postmark', ['EmailBounce']) 43 | 44 | 45 | def backwards(self, orm): 46 | 47 | # Deleting model 'EmailMessage' 48 | db.delete_table('postmark_emailmessage') 49 | 50 | # Deleting model 'EmailBounce' 51 | db.delete_table('postmark_emailbounce') 52 | 53 | 54 | models = { 55 | 'postmark.emailbounce': { 56 | 'Meta': {'ordering': "('_order',)", 'object_name': 'EmailBounce'}, 57 | '_order': ('django.db.models.fields.IntegerField', [], {'default': '0'}), 58 | 'bounced_at': ('django.db.models.fields.DateTimeField', [], {}), 59 | 'can_activate': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 60 | 'description': ('django.db.models.fields.TextField', [], {}), 61 | 'details': ('django.db.models.fields.TextField', [], {}), 62 | 'id': ('django.db.models.fields.PositiveIntegerField', [], {'primary_key': 'True'}), 63 | 'inactive': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 64 | 'message': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'bounces'", 'to': "orm['postmark.EmailMessage']"}), 65 | 'type': ('django.db.models.fields.CharField', [], {'max_length': '100'}) 66 | }, 67 | 'postmark.emailmessage': { 68 | 'Meta': {'ordering': "['-submitted_at']", 'object_name': 'EmailMessage'}, 69 | 'attachments': ('django.db.models.fields.TextField', [], {}), 70 | 'headers': ('django.db.models.fields.TextField', [], {}), 71 | 'html_body': ('django.db.models.fields.TextField', [], {}), 72 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 73 | 'message_id': ('django.db.models.fields.CharField', [], {'max_length': '40'}), 74 | 'reply_to': ('django.db.models.fields.CharField', [], {'max_length': '150'}), 75 | 'sender': ('django.db.models.fields.CharField', [], {'max_length': '150'}), 76 | 'status': ('django.db.models.fields.CharField', [], {'max_length': '150'}), 77 | 'subject': ('django.db.models.fields.CharField', [], {'max_length': '150'}), 78 | 'submitted_at': ('django.db.models.fields.DateTimeField', [], {}), 79 | 'tag': ('django.db.models.fields.CharField', [], {'max_length': '25'}), 80 | 'text_body': ('django.db.models.fields.TextField', [], {}), 81 | 'to': ('django.db.models.fields.CharField', [], {'max_length': '150'}), 82 | 'to_type': ('django.db.models.fields.CharField', [], {'max_length': '3'}) 83 | } 84 | } 85 | 86 | complete_apps = ['postmark'] 87 | -------------------------------------------------------------------------------- /postmark/migrations/0002_auto__chg_field_emailmessage_tag.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | import datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | 7 | class Migration(SchemaMigration): 8 | 9 | def forwards(self, orm): 10 | 11 | # Changing field 'EmailMessage.tag' 12 | db.alter_column('postmark_emailmessage', 'tag', self.gf('django.db.models.fields.CharField')(max_length=150)) 13 | 14 | 15 | def backwards(self, orm): 16 | 17 | # Changing field 'EmailMessage.tag' 18 | db.alter_column('postmark_emailmessage', 'tag', self.gf('django.db.models.fields.CharField')(max_length=25)) 19 | 20 | 21 | models = { 22 | 'postmark.emailbounce': { 23 | 'Meta': {'ordering': "('_order',)", 'object_name': 'EmailBounce'}, 24 | '_order': ('django.db.models.fields.IntegerField', [], {'default': '0'}), 25 | 'bounced_at': ('django.db.models.fields.DateTimeField', [], {}), 26 | 'can_activate': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 27 | 'description': ('django.db.models.fields.TextField', [], {}), 28 | 'details': ('django.db.models.fields.TextField', [], {}), 29 | 'id': ('django.db.models.fields.PositiveIntegerField', [], {'primary_key': 'True'}), 30 | 'inactive': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 31 | 'message': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'bounces'", 'to': "orm['postmark.EmailMessage']"}), 32 | 'type': ('django.db.models.fields.CharField', [], {'max_length': '100'}) 33 | }, 34 | 'postmark.emailmessage': { 35 | 'Meta': {'ordering': "['-submitted_at']", 'object_name': 'EmailMessage'}, 36 | 'attachments': ('django.db.models.fields.TextField', [], {}), 37 | 'headers': ('django.db.models.fields.TextField', [], {}), 38 | 'html_body': ('django.db.models.fields.TextField', [], {}), 39 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 40 | 'message_id': ('django.db.models.fields.CharField', [], {'max_length': '40'}), 41 | 'reply_to': ('django.db.models.fields.CharField', [], {'max_length': '150'}), 42 | 'sender': ('django.db.models.fields.CharField', [], {'max_length': '150'}), 43 | 'status': ('django.db.models.fields.CharField', [], {'max_length': '150'}), 44 | 'subject': ('django.db.models.fields.CharField', [], {'max_length': '150'}), 45 | 'submitted_at': ('django.db.models.fields.DateTimeField', [], {}), 46 | 'tag': ('django.db.models.fields.CharField', [], {'max_length': '150'}), 47 | 'text_body': ('django.db.models.fields.TextField', [], {}), 48 | 'to': ('django.db.models.fields.CharField', [], {'max_length': '150'}), 49 | 'to_type': ('django.db.models.fields.CharField', [], {'max_length': '3'}) 50 | } 51 | } 52 | 53 | complete_apps = ['postmark'] 54 | -------------------------------------------------------------------------------- /postmark/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dstufft/django-postmark/eb5169d903aa072812bc4eacaa75a20d5c644544/postmark/migrations/__init__.py -------------------------------------------------------------------------------- /postmark/models.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import ugettext_lazy as _ 2 | from django.dispatch import receiver 3 | from django.db import models 4 | from itertools import izip_longest 5 | from datetime import datetime 6 | from pytz import timezone 7 | import pytz 8 | 9 | from postmark.signals import post_send 10 | 11 | POSTMARK_DATETIME_STRING = "%Y-%m-%dT%H:%M:%S.%f" 12 | 13 | TO_CHOICES = ( 14 | ("to", _("Recipient")), 15 | ("cc", _("Carbon Copy")), 16 | ("bcc", _("Blind Carbon Copy")), 17 | ) 18 | 19 | BOUNCE_TYPES = ( 20 | ("HardBounce", _("Hard Bounce")), 21 | ("Transient", _("Transient")), 22 | ("Unsubscribe", _("Unsubscribe")), 23 | ("Subscribe", _("Subscribe")), 24 | ("AutoResponder", _("Auto Responder")), 25 | ("AddressChange", _("Address Change")), 26 | ("DnsError", _("DNS Error")), 27 | ("SpamNotification", _("Spam Notification")), 28 | ("OpenRelayTest", _("Open Relay Test")), 29 | ("Unknown", _("Unknown")), 30 | ("SoftBounce", _("Soft Bounce")), 31 | ("VirusNotification", _("Virus Notification")), 32 | ("ChallengeVerification", _("Challenge Verification")), 33 | ("BadEmailAddress", _("Bad Email Address")), 34 | ("SpamComplaint", _("Spam Complaint")), 35 | ("ManuallyDeactivated", _("Manually Deactivated")), 36 | ("Unconfirmed", _("Unconfirmed")), 37 | ("Blocked", _("Blocked")), 38 | ) 39 | 40 | class EmailMessage(models.Model): 41 | message_id = models.CharField(_("Message ID"), max_length=40) 42 | submitted_at = models.DateTimeField(_("Submitted At")) 43 | status = models.CharField(_("Status"), max_length=150) 44 | 45 | to = models.CharField(_("To"), max_length=150) 46 | to_type = models.CharField(_("Type"), max_length=3, choices=TO_CHOICES) 47 | 48 | sender = models.CharField(_("Sender"), max_length=150) 49 | reply_to = models.CharField(_("Reply To"), max_length=150) 50 | subject = models.CharField(_("Subject"), max_length=150) 51 | tag = models.CharField(_("Tag"), max_length=150) 52 | text_body = models.TextField(_("Text Body")) 53 | html_body = models.TextField(_("HTML Body")) 54 | 55 | headers = models.TextField(_("Headers")) 56 | attachments = models.TextField(_("Attachments")) 57 | 58 | def __unicode__(self): 59 | return u"%s" % (self.message_id,) 60 | 61 | class Meta: 62 | verbose_name = _("email message") 63 | verbose_name_plural = _("email messages") 64 | 65 | get_latest_by = "submitted_at" 66 | ordering = ["-submitted_at"] 67 | 68 | class EmailBounce(models.Model): 69 | id = models.PositiveIntegerField(primary_key=True) 70 | message = models.ForeignKey(EmailMessage, related_name="bounces", verbose_name=_("Message")) 71 | 72 | inactive = models.BooleanField(_("Inactive")) 73 | can_activate = models.BooleanField(_("Can Activate")) 74 | 75 | type = models.CharField(_("Type"), max_length=100, choices=BOUNCE_TYPES) 76 | description = models.TextField(_("Description")) 77 | details = models.TextField(_("Details")) 78 | 79 | bounced_at = models.DateTimeField(_("Bounced At")) 80 | 81 | def __unicode__(self): 82 | return u"Bounce: %s" % (self.message.to,) 83 | 84 | class Meta: 85 | verbose_name = _("email bounce") 86 | verbose_name_plural = _("email bounces") 87 | 88 | order_with_respect_to = "message" 89 | get_latest_by = "bounced_at" 90 | ordering = ["-bounced_at"] 91 | 92 | @receiver(post_send) 93 | def sent_message(sender, **kwargs): 94 | msg = kwargs["message"] 95 | resp = kwargs["response"] 96 | 97 | for recipient in ( 98 | list(izip_longest(msg["To"].split(","), [], fillvalue='to')) + 99 | list(izip_longest(msg.get("Cc", "").split(","), [], fillvalue='cc')) + 100 | list(izip_longest(msg.get("Bcc", "").split(","), [], fillvalue='bcc'))): 101 | 102 | if not recipient[0]: 103 | continue 104 | 105 | timestamp, tz = resp["SubmittedAt"].rsplit("+", 1) 106 | tz_offset = int(tz.split(":", 1)[0]) 107 | tz = timezone("Etc/GMT%s%d" % ("+" if tz_offset >= 0 else "-", tz_offset)) 108 | submitted_at = tz.localize(datetime.strptime(timestamp[:26], POSTMARK_DATETIME_STRING)).astimezone(pytz.utc) 109 | 110 | 111 | emsg = EmailMessage( 112 | message_id=resp["MessageID"], 113 | submitted_at=submitted_at, 114 | status=resp["Message"], 115 | to=recipient[0], 116 | to_type=recipient[1], 117 | sender=msg["From"], 118 | reply_to=msg.get("ReplyTo", ""), 119 | subject=msg["Subject"], 120 | tag=msg.get("Tag", ""), 121 | text_body=msg["TextBody"], 122 | html_body=msg.get("HtmlBody", ""), 123 | headers=msg.get("Headers", ""), 124 | attachments=msg.get("Attachments", "") 125 | ) 126 | emsg.save() -------------------------------------------------------------------------------- /postmark/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | post_send = Signal(providing_args=["message", "response"]) -------------------------------------------------------------------------------- /postmark/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls.defaults import * 2 | 3 | urlpatterns = patterns("", 4 | url(r"^bounce/$", "postmark.views.bounce", name="postmark_bounce_hook"), 5 | ) -------------------------------------------------------------------------------- /postmark/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse, HttpResponseNotAllowed, HttpResponseBadRequest, HttpResponseForbidden 2 | from django.core.exceptions import ImproperlyConfigured 3 | from django.views.decorators.csrf import csrf_exempt 4 | from django.shortcuts import get_object_or_404 5 | from django.conf import settings 6 | from datetime import datetime 7 | from pytz import timezone 8 | import pytz 9 | import base64 10 | 11 | from postmark.models import EmailMessage, EmailBounce 12 | 13 | try: 14 | import json 15 | except ImportError: 16 | try: 17 | import simplejson as json 18 | except ImportError: 19 | raise Exception('Cannot use django-postmark without Python 2.6 or greater, or Python 2.4 or 2.5 and the "simplejson" library') 20 | 21 | POSTMARK_DATETIME_STRING = "%Y-%m-%dT%H:%M:%S.%f" 22 | 23 | # Settings 24 | POSTMARK_API_USER = getattr(settings, "POSTMARK_API_USER", None) 25 | POSTMARK_API_PASSWORD = getattr(settings, "POSTMARK_API_PASSWORD", None) 26 | 27 | if ((POSTMARK_API_USER is not None and POSTMARK_API_PASSWORD is None) or 28 | (POSTMARK_API_PASSWORD is not None and POSTMARK_API_USER is None)): 29 | raise ImproperlyConfigured("POSTMARK_API_USER and POSTMARK_API_PASSWORD must both either be set, or unset.") 30 | 31 | @csrf_exempt 32 | def bounce(request): 33 | """ 34 | Accepts Incoming Bounces from Postmark. Example JSON Message: 35 | 36 | { 37 | "ID": 42, 38 | "Type": "HardBounce", 39 | "Name": "Hard bounce", 40 | "Tag": "Test", 41 | "MessageID": null, 42 | "Description": "Test bounce description", 43 | "TypeCode": 1, 44 | "Details": "Test bounce details", 45 | "Email": "john@example.com", 46 | "BouncedAt": "2011-05-23T11:16:00.3018994+01:00", 47 | "DumpAvailable": true, 48 | "Inactive": true, 49 | "CanActivate": true, 50 | "Content": null, 51 | "Subject": null 52 | } 53 | """ 54 | if request.method in ["POST"]: 55 | if POSTMARK_API_USER is not None: 56 | if not request.META.has_key("HTTP_AUTHORIZATION"): 57 | return HttpResponseForbidden() 58 | 59 | type, base64encoded = request.META["HTTP_AUTHORIZATION"].split(" ", 1) 60 | print type, base64encoded 61 | 62 | if type.lower() == "basic": 63 | username_password = base64.decodestring(base64encoded) 64 | print username_password 65 | else: 66 | return HttpResponseForbidden() 67 | 68 | if not username_password == "%s:%s" % (POSTMARK_API_USER, POSTMARK_API_PASSWORD): 69 | print "lol" 70 | return HttpResponseForbidden() 71 | 72 | bounce_dict = json.loads(request.read()) 73 | 74 | timestamp, tz = bounce_dict["BouncedAt"].rsplit("+", 1) 75 | tz_offset = int(tz.split(":", 1)[0]) 76 | tz = timezone("Etc/GMT%s%d" % ("+" if tz_offset >= 0 else "-", tz_offset)) 77 | bounced_at = tz.localize(datetime.strptime(timestamp[:26], POSTMARK_DATETIME_STRING)).astimezone(pytz.utc) 78 | 79 | em = get_object_or_404(EmailMessage, message_id=bounce_dict["MessageID"], to=bounce_dict["Email"]) 80 | eb, created = EmailBounce.objects.get_or_create( 81 | id=bounce_dict["ID"], 82 | default={ 83 | "message": em, 84 | "type": bounce_dict["Type"], 85 | "description": bounce_dict["Description"], 86 | "details": bounce_dict["Details"], 87 | "inactive": bounce_dict["Inactive"], 88 | "can_activate": bounce_dict["CanActivate"], 89 | "bounced_at": bounced_at, 90 | } 91 | ) 92 | 93 | return HttpResponse(json.dumps({"status": "ok"})) 94 | else: 95 | return HttpResponseNotAllowed(['POST']) 96 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name = "django-postmark", 5 | version = __import__("postmark").__version__, 6 | author = "Donald Stufft", 7 | author_email = "donald@e.vilgeni.us", 8 | description = "A Django reusable app to send email with postmark, as well as models and views to handle bounce integration.", 9 | long_description = open("README.rst").read(), 10 | url = "http://github.com/dstufft/django-postmark/", 11 | license = "BSD", 12 | install_requires = [ 13 | "httplib2", 14 | "pytz", 15 | ], 16 | packages = [ 17 | "postmark", 18 | "postmark.migrations", 19 | ], 20 | classifiers = [ 21 | "Development Status :: 4 - Beta", 22 | "Environment :: Web Environment", 23 | "Intended Audience :: Developers", 24 | "License :: OSI Approved :: BSD License", 25 | "Operating System :: OS Independent", 26 | "Programming Language :: Python", 27 | "Topic :: Utilities", 28 | "Framework :: Django", 29 | ] 30 | ) 31 | --------------------------------------------------------------------------------