├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.md ├── postmark_inbound └── __init__.py ├── setup.cfg ├── setup.py ├── test.py ├── tests └── fixtures │ └── valid_http_post.json └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.py[cod] 3 | 4 | # C extensions 5 | *.so 6 | 7 | # Packages 8 | *.egg 9 | *.egg-info 10 | dist 11 | build 12 | eggs 13 | parts 14 | bin 15 | var 16 | sdist 17 | develop-eggs 18 | .installed.cfg 19 | lib 20 | lib64 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | nosetests.xml 29 | 30 | # Translations 31 | *.mo 32 | 33 | # Mr Developer 34 | .mr.developer.cfg 35 | .project 36 | .pydevproject -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: false 3 | python: 4 | - "2.7" 5 | - "3.3" 6 | - "3.4" 7 | # command to run tests 8 | script: python test.py 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2012 José Padilla 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE MANIFEST.in 2 | include *.txt 3 | include *.md -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Postmark Inbound Hook [![Build Status](https://travis-ci.org/jpadilla/postmark-inbound-python.png?branch=master)](https://travis-ci.org/jpadilla/postmark-inbound-python) 2 | ===================== 3 | 4 | This is a simple API wrapper for [Postmark Inbound Hook](http://developer.postmarkapp.com/developer-inbound.html) 5 | in Python inspired by [jjaffeux](https://github.com/jjaffeux/postmark-inbound-php). 6 | 7 | ## Install 8 | 9 | Using Github: 10 | 11 | ``` 12 | git clone git@github.com:jpadilla/postmark-inbound-python.git 13 | ``` 14 | 15 | Using pip: 16 | 17 | ``` 18 | pip install python-postmark-inbound 19 | ``` 20 | 21 | Using easy_install: 22 | 23 | ``` 24 | easy_install python-postmark-inbound 25 | ``` 26 | 27 | 28 | Usage 29 | ----- 30 | 31 | ``` python 32 | from postmark_inbound import PostmarkInbound 33 | 34 | 35 | # load json 36 | json_data = open('./tests/fixtures/valid_http_post.json').read() 37 | inbound = PostmarkInbound(json=json_data) 38 | 39 | # content 40 | inbound.subject() 41 | inbound.sender() 42 | inbound.to() 43 | inbound.bcc() 44 | inbound.tag() 45 | inbound.message_id() 46 | inbound.mailbox_hash() 47 | inbound.reply_to() 48 | inbound.html_body() 49 | inbound.text_body() 50 | inbound.send_date() 51 | 52 | # headers 53 | inbound.headers() # default to get Date 54 | inbound.headers('MIME-Version') 55 | inbound.headers('Received-SPF') 56 | 57 | # spam 58 | inbound.headers('X-Spam-Checker-Version') 59 | inbound.headers('X-Spam-Score') 60 | inbound.headers('X-Spam-Tests') 61 | inbound.headers('X-Spam-Status') 62 | 63 | # attachments 64 | inbound.has_attachments() # boolean 65 | attachments = inbound.attachments() 66 | 67 | first_attachment = attachments[0] 68 | first_attachment.name() 69 | 70 | second_attachment = attachments[1] 71 | second_attachment.content_length() 72 | 73 | for a in attachments: 74 | a.name() 75 | a.content_type() 76 | a.content_length() 77 | a.download('./tests/', ['image/png'], '10000') 78 | 79 | # raw data 80 | inbound.json 81 | inbound.source 82 | ``` 83 | 84 | Bug tracker 85 | ----------- 86 | 87 | Have a bug? Please create an issue here on GitHub! 88 | 89 | 90 | Contributions 91 | ------------- 92 | 93 | * Fork 94 | * Write tests 95 | * Write Code 96 | * Pull request 97 | 98 | Thanks for your help. 99 | 100 | 101 | TODO 102 | ---- 103 | 104 | * Write more tests 105 | 106 | 107 | Authors 108 | ------- 109 | 110 | **José Padilla** 111 | 112 | + http://twitter.com/jpadilla_ 113 | + http://github.com/jpadilla 114 | 115 | Inspiration 116 | ----------- 117 | 118 | Thanks to [jjaffeux](https://github.com/jjaffeux/) for the original PHP wrapper 119 | 120 | + https://github.com/jjaffeux 121 | + https://github.com/jjaffeux/postmark-inbound-php 122 | 123 | 124 | Other libraries 125 | --------------- 126 | 127 | + Ruby: https://github.com/r38y/postmark-mitt 128 | + PHP: https://github.com/jjaffeux/postmark-inbound-php 129 | + Node.js + CouchDB: https://gist.github.com/1647808 130 | 131 | License 132 | --------------------- 133 | 134 | MIT License 135 | -------------------------------------------------------------------------------- /postmark_inbound/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | from base64 import b64decode 3 | from datetime import datetime 4 | from email.utils import mktime_tz, parsedate_tz 5 | from email.mime.base import MIMEBase 6 | from email.encoders import encode_base64 7 | 8 | 9 | __version__ = '1.1.0' 10 | 11 | # Version synonym 12 | VERSION = __version__ 13 | 14 | 15 | class PostmarkInbound(object): 16 | 17 | def __init__(self, **kwargs): 18 | if 'json' not in kwargs: 19 | raise Exception('Postmark Inbound Error: you must provide json data') 20 | self.json = kwargs['json'] 21 | self.source = json.loads(self.json) 22 | 23 | def subject(self): 24 | return self.source.get('Subject') 25 | 26 | def sender(self): 27 | return self.source.get('FromFull') 28 | 29 | def to(self): 30 | return self.source.get('ToFull') 31 | 32 | def bcc(self): 33 | return self.source.get('Bcc') 34 | 35 | def cc(self): 36 | return self.source.get('CcFull') 37 | 38 | def reply_to(self): 39 | return self.source.get('ReplyTo') 40 | 41 | def mailbox_hash(self): 42 | return self.source.get('MailboxHash') 43 | 44 | def tag(self): 45 | return self.source.get('Tag') 46 | 47 | def message_id(self): 48 | return self.source.get('MessageID') 49 | 50 | def text_body(self): 51 | return self.source.get('TextBody') 52 | 53 | def html_body(self): 54 | return self.source.get('HtmlBody') 55 | 56 | def headers(self, name='Message-ID'): 57 | for header in self.source.get('Headers'): 58 | if header.get('Name') == name: 59 | return header.get('Value') 60 | return None 61 | 62 | def attachments(self, as_mime=False): 63 | attachments = [] 64 | for attachment in self.source.get('Attachments', []): 65 | new_attachment = Attachment(attachment) 66 | if as_mime: 67 | new_attachment = new_attachment.to_mime() 68 | attachments.append(new_attachment) 69 | return attachments 70 | 71 | def has_attachments(self): 72 | if not self.attachments(): 73 | return False 74 | return True 75 | 76 | def send_date(self): 77 | date = None 78 | rfc_2822 = self.source.get('Date') 79 | if rfc_2822: 80 | try: 81 | date = datetime.fromtimestamp(mktime_tz(parsedate_tz(rfc_2822))) 82 | except: 83 | pass 84 | return date 85 | 86 | 87 | class Attachment(object): 88 | 89 | def __init__(self, attachment, **kwargs): 90 | self.attachment = attachment 91 | 92 | def name(self): 93 | return self.attachment.get('Name') 94 | 95 | def content_type(self): 96 | return self.attachment.get('ContentType') 97 | 98 | def content_length(self): 99 | return self.attachment.get('ContentLength') 100 | 101 | def read(self): 102 | return b64decode(self.attachment.get('Content')) 103 | 104 | def to_mime(self): 105 | contenttype = self.attachment.get('ContentType').split('/') 106 | try: 107 | maintype = contenttype[0] 108 | subtype = contenttype[1] 109 | except IndexError: 110 | raise ValueError('Invalid ContentType') 111 | mime = MIMEBase(maintype, subtype) 112 | mime.set_payload(self.read()) 113 | encode_base64(mime) 114 | mime.add_header( 115 | 'Content-Disposition', 'attachment', filename=self.name()) 116 | return mime 117 | 118 | def download(self, directory='', allowed_content_types=[], max_content_length='', mode='w'): 119 | if len(directory) == 0: 120 | raise Exception('Postmark Inbound Error: you must provide the upload path') 121 | 122 | if len(max_content_length) > 0 and self.content_length() > max_content_length: 123 | raise Exception('Postmark Inbound Error: the file size is over %s' % max_content_length) 124 | 125 | if allowed_content_types and self.content_type() not in allowed_content_types: 126 | raise Exception('Postmark Inbound Error: the file type %s is not allowed' % self.content_type()) 127 | 128 | try: 129 | if 'b' not in mode: 130 | mode += 'b' 131 | attachment = open('%s%s' % (directory, self.name()), mode) 132 | attachment.write(self.read()) 133 | except IOError: 134 | raise Exception('Postmark Inbound Error: cannot save the file, check path and rights.') 135 | else: 136 | attachment.close() 137 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from setuptools import setup 4 | import re 5 | import os 6 | import sys 7 | 8 | 9 | name = 'python-postmark-inbound' 10 | package = 'postmark_inbound' 11 | description = 'Python wrapper for Postmark Inbound' 12 | url = 'https://github.com/jpadilla/postmark-inbound-python' 13 | author = 'José Padilla' 14 | author_email = 'hello@jpadilla.com' 15 | license = 'MIT' 16 | 17 | 18 | def get_version(package): 19 | """ 20 | Return package version as listed in `__version__` in `init.py`. 21 | """ 22 | init_py = open(os.path.join(package, '__init__.py')).read() 23 | return re.search("^__version__ = ['\"]([^'\"]+)['\"]", 24 | init_py, re.MULTILINE).group(1) 25 | 26 | 27 | def get_packages(package): 28 | """ 29 | Return root package and all sub-packages. 30 | """ 31 | return [dirpath 32 | for dirpath, dirnames, filenames in os.walk(package) 33 | if os.path.exists(os.path.join(dirpath, '__init__.py'))] 34 | 35 | 36 | def get_package_data(package): 37 | """ 38 | Return all files under the root package, that are not in a 39 | package themselves. 40 | """ 41 | walk = [(dirpath.replace(package + os.sep, '', 1), filenames) 42 | for dirpath, dirnames, filenames in os.walk(package) 43 | if not os.path.exists(os.path.join(dirpath, '__init__.py'))] 44 | 45 | filepaths = [] 46 | for base, filenames in walk: 47 | filepaths.extend([os.path.join(base, filename) 48 | for filename in filenames]) 49 | return {package: filepaths} 50 | 51 | 52 | version = get_version(package) 53 | 54 | 55 | if sys.argv[-1] == 'publish': 56 | os.system("python setup.py sdist upload") 57 | os.system("python setup.py bdist_wheel upload") 58 | print("You probably want to also tag the version now:") 59 | print(" git tag -a {0} -m 'version {0}'".format(version)) 60 | print(" git push --tags") 61 | sys.exit() 62 | 63 | 64 | setup( 65 | name=name, 66 | version=version, 67 | url=url, 68 | license=license, 69 | description=description, 70 | author=author, 71 | author_email=author_email, 72 | packages=get_packages(package), 73 | package_data=get_package_data(package), 74 | classifiers=[ 75 | 'Programming Language :: Python', 76 | 'Programming Language :: Python :: 2', 77 | 'Programming Language :: Python :: 2.7', 78 | 'Programming Language :: Python :: 3', 79 | 'Programming Language :: Python :: 3.3', 80 | 'Programming Language :: Python :: 3.4', 81 | ], 82 | ) 83 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import unittest 3 | from postmark_inbound import PostmarkInbound, MIMEBase 4 | 5 | 6 | class PostmarkInboundTest(unittest.TestCase): 7 | 8 | def setUp(self): 9 | json_data = open('tests/fixtures/valid_http_post.json').read() 10 | self.inbound = PostmarkInbound(json=json_data) 11 | 12 | def tearDown(self): 13 | if os.path.exists('./tests/chart.png'): 14 | os.remove('./tests/chart.png') 15 | if os.path.exists('./tests/chart2.png'): 16 | os.remove('./tests/chart2.png') 17 | 18 | def test_should_have_a_subject(self): 19 | assert 'Hi There' == self.inbound.subject() 20 | 21 | def test_should_have_a_bcc(self): 22 | assert 'hi@fbi.com' == self.inbound.bcc() 23 | 24 | def test_should_have_a_cc(self): 25 | assert 'sample.cc@emailDomain.com' == self.inbound.cc()[0]['Email'] 26 | 27 | def test_should_have_a_reply_to(self): 28 | assert 'new-comment+sometoken@yeah.com' == self.inbound.reply_to() 29 | 30 | def test_should_have_a_mailbox_hash(self): 31 | assert 'moitoken' == self.inbound.mailbox_hash() 32 | 33 | def test_should_have_a_tag(self): 34 | assert 'yourit' == self.inbound.tag() 35 | 36 | def test_should_have_a_message_id(self): 37 | assert 'a8c1040e-db1c-4e18-ac79-bc5f64c7ce2c' == self.inbound.message_id() 38 | 39 | def test_should_be_from_someone(self): 40 | assert self.inbound.sender()['Name'] == 'Bob Bobson' and \ 41 | self.inbound.sender()['Email'] == 'bob@bob.com' 42 | 43 | def test_should_have_a_html_body(self): 44 | assert '

We no speak americano

' == self.inbound.html_body() 45 | 46 | def test_should_have_a_text_body(self): 47 | assert '\nThis is awesome!\n\n' == self.inbound.text_body() 48 | 49 | def test_should_be_to_someone(self): 50 | assert 'api-hash@inbound.postmarkapp.com' == self.inbound.to()[0]['Email'] 51 | 52 | def test_should_have_header_mime_version(self): 53 | assert '1.0' == self.inbound.headers('MIME-Version') 54 | 55 | def test_should_have_header_received_spf(self): 56 | assert 'Pass (sender SPF authorized) identity=mailfrom; client-ip=209.85.160.180; helo=mail-gy0-f180.google.com; envelope-from=myUser@theirDomain.com; receiver=451d9b70cf9364d23ff6f9d51d870251569e+ahoy@inbound.postmarkapp.com' == self.inbound.headers('Received-SPF') 57 | 58 | def test_should_have_spam_version(self): 59 | assert 'SpamAssassin 3.3.1 (2010-03-16) onrs-ord-pm-inbound1.wildbit.com' == self.inbound.headers('X-Spam-Checker-Version') 60 | 61 | def test_should_have_spam_status(self): 62 | assert 'No' == self.inbound.headers('X-Spam-Status') 63 | 64 | def test_should_have_spam_score(self): 65 | assert '-0.1' == self.inbound.headers('X-Spam-Score') 66 | 67 | def test_should_have_spam_test(self): 68 | assert 'DKIM_SIGNED,DKIM_VALID,DKIM_VALID_AU,SPF_PASS' == self.inbound.headers('X-Spam-Tests') 69 | 70 | def test_should_have_two_attachments(self): 71 | assert 2 == len(self.inbound.attachments()) 72 | 73 | def test_should_have_attachment(self): 74 | assert True == self.inbound.has_attachments() 75 | 76 | def test_attachment_should_have_content_length(self): 77 | for a in self.inbound.attachments(): 78 | assert a.content_length() is not None 79 | 80 | def test_attachment_should_have_conent_type(self): 81 | for a in self.inbound.attachments(): 82 | assert a.content_type() is not None 83 | 84 | def test_attachment_should_have_name(self): 85 | for a in self.inbound.attachments(): 86 | assert a.name() is not None 87 | 88 | def test_attachment_should_download(self): 89 | for a in self.inbound.attachments(): 90 | a.download('./tests/') 91 | 92 | assert True == os.path.exists('./tests/chart.png') 93 | assert True == os.path.exists('./tests/chart2.png') 94 | 95 | def test_attachment_to_mime(self): 96 | for a in self.inbound.attachments(): 97 | mime = a.to_mime() 98 | assert isinstance(mime, MIMEBase) 99 | assert mime.get_filename() == a.name() 100 | assert mime.get_content_type() == a.content_type() 101 | 102 | def test_attachments_as_mime(self): 103 | for a in self.inbound.attachments(as_mime=True): 104 | assert isinstance(a, MIMEBase) 105 | 106 | def test_send_date(self): 107 | assert 2012 == self.inbound.send_date().year 108 | 109 | if __name__ == "__main__": 110 | unittest.main() 111 | -------------------------------------------------------------------------------- /tests/fixtures/valid_http_post.json: -------------------------------------------------------------------------------- 1 | { 2 | "Attachments": [{ 3 | "Content": "iVBORw0KGgoAAAANSUhEUgAAAZAAAAGQCAIAAAAP3aGbAAAABmJLR0QA\/wD\/AP+gvaeTAAAHFklEQVR4nO3dUWojSRRFwdHQ+9+yegE99BQ4eXonFfFt7HJJHPLnkq\/3+\/0PQMG\/n34AgKcEC8gQLCBDsIAMwQIyBAvIECwgQ7CADMECMgQLyBAsIEOwgAzBAjIEC8gQLCBDsIAMwQIyBAvIECwgQ7CADMECMgQLyBAsIEOwgAzBAjIEC8gQLCBDsIAMwQIyBAvIECwgQ7CADMECMgQLyBAsIEOwgAzBAjIEC8gQLCBDsIAMwQIyBAvIECwgQ7CADMECMgQLyBAsIEOwgAzBAjJ+ffoB\/tvr9fr0I3zM+\/3+35958n62\/Z5TnjzPKb6H2zhhARmCBWQIFpAhWECGYAEZggVkCBaQIVhAhmABGYIFZAgWkLF0S\/jEzq3T353apk3u+2595lPfn2\/+Hs5zwgIyBAvIECwgQ7CADMECMgQLyBAsIEOwgAzBAjIEC8gQLCAjvCV8wn15P3fq\/yregXhK8fPayQkLyBAsIEOwgAzBAjIEC8gQLCBDsIAMwQIyBAvIECwgQ7CAjMu3hMzYdgcit3LCAjIEC8gQLCBDsIAMwQIyBAvIECwgQ7CADMECMgQLyBAsIMOW8FqT9wmecupewrvv5vtmTlhAhmABGYIFZAgWkCFYQIZgARmCBWQIFpAhWECGYAEZggVkXL4lvHVTtm0neGoDOPm3Jr8bt34P5zlhARmCBWQIFpAhWECGYAEZggVkCBaQIVhAhmABGYIFZAgWkBHeEk7el7fN5FZucru3bQP4xDd\/D+c5YQEZggVkCBaQIVhAhmABGYIFZAgWkCFYQIZgARmCBWQIFpCxdEu4bS+2za3vZ9ve8Nb33OWEBWQIFpAhWECGYAEZggVkCBaQIVhAhmABGYIFZAgWkCFYQMZr51pq211vp97Stvv7bn3PT0xuEiff87Y95llOWECGYAEZggVkCBaQIVhAhmABGYIFZAgWkCFYQIZgARmCBWRcfi\/htr3Yrfu+bc88+TzbdnnbnucsJywgQ7CADMECMgQLyBAsIEOwgAzBAjIEC8gQLCBDsIAMwQIylm4Jt23TTtm2bZz8Pczo7gSfcMICMgQLyBAsIEOwgAzBAjIEC8gQLCBDsIAMwQIyBAvIECwgY+mWcNLkvq+489r2zMU95uTf2vZ5neWEBWQIFpAhWECGYAEZggVkCBaQIVhAhmABGYIFZAgWkCFYQEZ4S\/hkM3Vqd7bt92xTfD+T273J93P3JtEJC8gQLCBDsIAMwQIyBAvIECwgQ7CADMECMgQLyBAsIEOwgIylW8Jtu7NTJp9n2+bulG2f++T7ufUzfc4JC8gQLCBDsIAMwQIyBAvIECwgQ7CADMECMgQLyBAsIEOwgIylW8Jb3Xpn3DdvP2+9S3EnJywgQ7CADMECMgQLyBAsIEOwgAzBAjIEC8gQLCBDsIAMwQIyXjtXRaf2Wbdur259P9vu+Nu2Eyz+rbOcsIAMwQIyBAvIECwgQ7CADMECMgQLyBAsIEOwgAzBAjIEC8hYuiWcNLlfe2LbLm9yc7dtI\/nNdpbBCQvIECwgQ7CADMECMgQLyBAsIEOwgAzBAjIEC8gQLCBDsICMX59+gM\/buZn6u233CRbf4eQzb7sHsLu1dMICMgQLyBAsIEOwgAzBAjIEC8gQLCBDsIAMwQIyBAvIECwgY+mWsLt1+rknm7Li3XzbnueJyQ1g8f3Mc8ICMgQLyBAsIEOwgAzBAjIEC8gQLCBDsIAMwQIyBAvIECwgY+mW8IniXXjFvdg336k3+X\/d+g7PcsICMgQLyBAsIEOwgAzBAjIEC8gQLCBDsIAMwQIyBAvIECwgI7wlfOLW3dmkbf\/XqTsZu3u6n9v2mT7nhAVkCBaQIVhAhmABGYIFZAgWkCFYQIZgARmCBWQIFpAhWEDG5VvCW227K\/CJ4h1\/2\/aGtrFOWECGYAEZggVkCBaQIVhAhmABGYIFZAgWkCFYQIZgARmCBWTYEl7r1O5sclO2bbs3yU7wCScsIEOwgAzBAjIEC8gQLCBDsIAMwQIyBAvIECwgQ7CADMECMi7fEnY3U3ts2\/dtu3PwyfOcukeyeB\/lWU5YQIZgARmCBWQIFpAhWECGYAEZggVkCBaQIVhAhmABGYIFZLx2Lo+27dcmndqUTf6tnd+iGbd+V3d+pk5YQIZgARmCBWQIFpAhWECGYAEZggVkCBaQIVhAhmABGYIFZCzdEgL8yQkLyBAsIEOwgAzBAjIEC8gQLCBDsIAMwQIyBAvIECwgQ7CADMECMgQLyBAsIEOwgAzBAjIEC8gQLCBDsIAMwQIyBAvIECwgQ7CADMECMgQLyBAsIEOwgAzBAjIEC8gQLCBDsIAMwQIyBAvIECwgQ7CADMECMgQLyBAsIEOwgAzBAjIEC8gQLCBDsIAMwQIyBAvIECwgQ7CADMECMgQLyPgN6\/l0MFbvnZQAAAAASUVORK5CYII=", 4 | "ContentType": "image\/png", 5 | "Name": "chart.png", 6 | "ContentLength": 2000 7 | }, { 8 | "Content": "iVBORw0KGgoAAAANSUhEUgAAAZAAAAGQCAIAAAAP3aGbAAAABmJLR0QA\/wD\/AP+gvaeTAAAHFklEQVR4nO3dUWojSRRFwdHQ+9+yegE99BQ4eXonFfFt7HJJHPLnkq\/3+\/0PQMG\/n34AgKcEC8gQLCBDsIAMwQIyBAvIECwgQ7CADMECMgQLyBAsIEOwgAzBAjIEC8gQLCBDsIAMwQIyBAvIECwgQ7CADMECMgQLyBAsIEOwgAzBAjIEC8gQLCBDsIAMwQIyBAvIECwgQ7CADMECMgQLyBAsIEOwgAzBAjIEC8gQLCBDsIAMwQIyBAvIECwgQ7CADMECMgQLyBAsIEOwgAzBAjJ+ffoB\/tvr9fr0I3zM+\/3+35958n62\/Z5TnjzPKb6H2zhhARmCBWQIFpAhWECGYAEZggVkCBaQIVhAhmABGYIFZAgWkLF0S\/jEzq3T353apk3u+2595lPfn2\/+Hs5zwgIyBAvIECwgQ7CADMECMgQLyBAsIEOwgAzBAjIEC8gQLCAjvCV8wn15P3fq\/yregXhK8fPayQkLyBAsIEOwgAzBAjIEC8gQLCBDsIAMwQIyBAvIECwgQ7CAjMu3hMzYdgcit3LCAjIEC8gQLCBDsIAMwQIyBAvIECwgQ7CADMECMgQLyBAsIMOW8FqT9wmecupewrvv5vtmTlhAhmABGYIFZAgWkCFYQIZgARmCBWQIFpAhWECGYAEZggVkXL4lvHVTtm0neGoDOPm3Jr8bt34P5zlhARmCBWQIFpAhWECGYAEZggVkCBaQIVhAhmABGYIFZAgWkBHeEk7el7fN5FZucru3bQP4xDd\/D+c5YQEZggVkCBaQIVhAhmABGYIFZAgWkCFYQIZgARmCBWQIFpCxdEu4bS+2za3vZ9ve8Nb33OWEBWQIFpAhWECGYAEZggVkCBaQIVhAhmABGYIFZAgWkCFYQMZr51pq211vp97Stvv7bn3PT0xuEiff87Y95llOWECGYAEZggVkCBaQIVhAhmABGYIFZAgWkCFYQIZgARmCBWRcfi\/htr3Yrfu+bc88+TzbdnnbnucsJywgQ7CADMECMgQLyBAsIEOwgAzBAjIEC8gQLCBDsIAMwQIylm4Jt23TTtm2bZz8Pczo7gSfcMICMgQLyBAsIEOwgAzBAjIEC8gQLCBDsIAMwQIyBAvIECwgY+mWcNLkvq+489r2zMU95uTf2vZ5neWEBWQIFpAhWECGYAEZggVkCBaQIVhAhmABGYIFZAgWkCFYQEZ4S\/hkM3Vqd7bt92xTfD+T273J93P3JtEJC8gQLCBDsIAMwQIyBAvIECwgQ7CADMECMgQLyBAsIEOwgIylW8Jtu7NTJp9n2+bulG2f++T7ufUzfc4JC8gQLCBDsIAMwQIyBAvIECwgQ7CADMECMgQLyBAsIEOwgIylW8Jb3Xpn3DdvP2+9S3EnJywgQ7CADMECMgQLyBAsIEOwgAzBAjIEC8gQLCBDsIAMwQIyXjtXRaf2Wbdur259P9vu+Nu2Eyz+rbOcsIAMwQIyBAvIECwgQ7CADMECMgQLyBAsIEOwgAzBAjIEC8hYuiWcNLlfe2LbLm9yc7dtI\/nNdpbBCQvIECwgQ7CADMECMgQLyBAsIEOwgAzBAjIEC8gQLCBDsICMX59+gM\/buZn6u233CRbf4eQzb7sHsLu1dMICMgQLyBAsIEOwgAzBAjIEC8gQLCBDsIAMwQIyBAvIECwgY+mWsLt1+rknm7Li3XzbnueJyQ1g8f3Mc8ICMgQLyBAsIEOwgAzBAjIEC8gQLCBDsIAMwQIyBAvIECwgY+mW8IniXXjFvdg336k3+X\/d+g7PcsICMgQLyBAsIEOwgAzBAjIEC8gQLCBDsIAMwQIyBAvIECwgI7wlfOLW3dmkbf\/XqTsZu3u6n9v2mT7nhAVkCBaQIVhAhmABGYIFZAgWkCFYQIZgARmCBWQIFpAhWEDG5VvCW227K\/CJ4h1\/2\/aGtrFOWECGYAEZggVkCBaQIVhAhmABGYIFZAgWkCFYQIZgARmCBWTYEl7r1O5sclO2bbs3yU7wCScsIEOwgAzBAjIEC8gQLCBDsIAMwQIyBAvIECwgQ7CADMECMi7fEnY3U3ts2\/dtu3PwyfOcukeyeB\/lWU5YQIZgARmCBWQIFpAhWECGYAEZggVkCBaQIVhAhmABGYIFZLx2Lo+27dcmndqUTf6tnd+iGbd+V3d+pk5YQIZgARmCBWQIFpAhWECGYAEZggVkCBaQIVhAhmABGYIFZCzdEgL8yQkLyBAsIEOwgAzBAjIEC8gQLCBDsIAMwQIyBAvIECwgQ7CADMECMgQLyBAsIEOwgAzBAjIEC8gQLCBDsIAMwQIyBAvIECwgQ7CADMECMgQLyBAsIEOwgAzBAjIEC8gQLCBDsIAMwQIyBAvIECwgQ7CADMECMgQLyBAsIEOwgAzBAjIEC8gQLCBDsIAMwQIyBAvIECwgQ7CADMECMgQLyPgN6\/l0MFbvnZQAAAAASUVORK5CYII=", 9 | "ContentType": "image\/png", 10 | "Name": "chart2.png", 11 | "ContentLength": 1000 12 | }], 13 | "From": "bob@bob.com", 14 | "FromFull": { 15 | "Email": "bob@bob.com", 16 | "Name": "Bob Bobson" 17 | }, 18 | "To": "api-hash@inbound.postmarkapp.com", 19 | "ToFull": [{ 20 | "Email": "api-hash@inbound.postmarkapp.com", 21 | "Name": "" 22 | }], 23 | "Cc": "sample.cc@emailDomain.com, another.cc@emailDomain.com", 24 | "CcFull": [{ 25 | "Email": "sample.cc@emailDomain.com", 26 | "Name": "Full name" 27 | }, { 28 | "Email": "another.cc@emailDomain.com", 29 | "Name": "Another Cc" 30 | }], 31 | "Bcc": "hi@fbi.com", 32 | "Date": "Thu, 5 Apr 2012 16:59:01 +0200", 33 | "Headers": [{ 34 | "Name": "X-Spam-Checker-Version", 35 | "Value": "SpamAssassin 3.3.1 (2010-03-16) onrs-ord-pm-inbound1.wildbit.com" 36 | }, { 37 | "Name": "X-Spam-Status", 38 | "Value": "No" 39 | }, { 40 | "Name": "X-Spam-Score", 41 | "Value": "-0.1" 42 | }, { 43 | "Name": "X-Spam-Tests", 44 | "Value": "DKIM_SIGNED,DKIM_VALID,DKIM_VALID_AU,SPF_PASS" 45 | }, { 46 | "Name": "Received-SPF", 47 | "Value": "Pass (sender SPF authorized) identity=mailfrom; client-ip=209.85.160.180; helo=mail-gy0-f180.google.com; envelope-from=myUser@theirDomain.com; receiver=451d9b70cf9364d23ff6f9d51d870251569e+ahoy@inbound.postmarkapp.com" 48 | }, { 49 | "Name": "DKIM-Signature", 50 | "Value": "v=1; a=rsa-sha256; c=relaxed\/relaxed; d=wildbit.com; s=google; h=mime-version:reply-to:date:message-id:subject:from:to:cc :content-type; bh=cYr\/+oQiklaYbBJOQU3CdAnyhCTuvemrU36WT7cPNt0=; b=QsegXXbTbC4CMirl7A3VjDHyXbEsbCUTPL5vEHa7hNkkUTxXOK+dQA0JwgBHq5C+1u iuAJMz+SNBoTqEDqte2ckDvG2SeFR+Edip10p80TFGLp5RucaYvkwJTyuwsA7xd78NKT Q9ou6L1hgy\/MbKChnp2kxHOtYNOrrszY3JfQM=" 51 | }, { 52 | "Name": "MIME-Version", 53 | "Value": "1.0" 54 | }, { 55 | "Name": "Message-ID", 56 | "Value": "" 57 | }], 58 | "HtmlBody": "

We no speak americano

", 59 | "MailboxHash": "moitoken", 60 | "MessageID": "a8c1040e-db1c-4e18-ac79-bc5f64c7ce2c", 61 | "ReplyTo": "new-comment+sometoken@yeah.com", 62 | "Subject": "Hi There", 63 | "Tag": "yourit", 64 | "TextBody": "\nThis is awesome!\n\n" 65 | } 66 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27, py33, py34 3 | 4 | [testenv:flake8] 5 | deps = flake8 6 | commands = flake8 --ignore=E402,E731 markupfield 7 | 8 | 9 | [testenv] 10 | commands = python test.py 11 | --------------------------------------------------------------------------------