├── .gitignore ├── .travis.yml ├── CONTRIBUTING.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── appveyor.yml ├── mailthon ├── __init__.py ├── api.py ├── enclosure.py ├── envelope.py ├── headers.py ├── helpers.py ├── middleware.py ├── postman.py └── response.py ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── assets └── spacer.gif ├── mimetest.py ├── test_api.py ├── test_enclosure.py ├── test_envelope.py ├── test_headers.py ├── test_helpers.py ├── test_middleware.py ├── test_postman.py ├── test_response.py └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | 47 | # Translations 48 | *.mo 49 | *.pot 50 | 51 | # Django stuff: 52 | *.log 53 | 54 | # Sphinx documentation 55 | docs/_build/ 56 | 57 | # PyBuilder 58 | target/ 59 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | python: 4 | - '2.6' 5 | - '2.7' 6 | - '3.3' 7 | - '3.4' 8 | - '3.5' 9 | - 'pypy' 10 | - 'pypy3' 11 | install: 12 | - python setup.py install 13 | - pip install codecov 14 | script: python setup.py test 15 | after_script: 16 | - codecov 17 | os: 18 | - linux 19 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contribution Guidelines 2 | ======================= 3 | 4 | Whether you are submitting new features, filing issues, discussing 5 | improvements or giving feedback about the library, all are welcome! 6 | To get started: 7 | 8 | 1. Check for related issues or open a fresh one to start discussion 9 | around an idea or a bug. 10 | 2. Fork the `repository `_, 11 | create a new branch off `master` and make your changes. 12 | 3. Write a regression test which shows that the bug was fixed or the 13 | feature works as expected. If it's a bug, try to make sure the 14 | tests fail without your changes. Tests can be ran via the 15 | ``py.test`` command. 16 | 4. Submit a pull request! 17 | 18 | Philosophy 19 | ********** 20 | 21 | Mailthon aims to be easy to use while being very extensible at 22 | the same time. Therefore two values needed to be upholded- the 23 | simplicity and elegance of the code. Sometimes they will contradict 24 | one another; in that case prefer the approach with fewer magic, 25 | in fact don't try to include magic if possible. 26 | 27 | Code Conventions 28 | **************** 29 | 30 | Generally the Mailthon codebase follows rules dictated by 31 | `PEP 8 `_. Sometimes 32 | following PEP8 makes the code uglier. In that case feel free to 33 | break from the rules if it makes your code more understandable. 34 | A minor exception concerning docstrings: 35 | 36 | When multiline docstrings are used, keep the triple quotes on 37 | their own line and do not put a separate newline after it if 38 | it is not necessary. This convention is used by Flask et al. 39 | 40 | .. code-block:: python 41 | 42 | def function(): 43 | """ 44 | Documentation 45 | """ 46 | # implementation 47 | return value 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2016 Eeo Jun 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. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Mailthon 2 | ======== 3 | 4 | **Useful links:** `Documentation`_ (outdated) | `Issue Tracker`_ | `PyPI Page`_ 5 | 6 | Mailthon is an MIT licensed email library for Python that aims to be 7 | highly extensible and composable. Mailthon is unicode aware and supports 8 | internationalised headers and email addresses. Also it aims to be transport 9 | agnostic, meaning that SMTP can be swapped out for other transports:: 10 | 11 | >>> from mailthon import postman, email 12 | >>> p = postman(host='smtp.gmail.com', auth=('username', 'password')) 13 | >>> r = p.send(email( 14 | content=u'

Hello 世界

', 15 | subject='Hello world', 16 | sender='John ', 17 | receivers=['doe@jon.com'], 18 | )) 19 | >>> assert r.ok 20 | 21 | .. _Documentation: http://mailthon.readthedocs.org/en/latest/ 22 | .. _Issue Tracker: http://github.com/eugene-eeo/mailthon/issues/ 23 | .. _PyPI Page: http://pypi.python.org/pypi/Mailthon 24 | 25 | .. image:: https://img.shields.io/travis/eugene-eeo/mailthon.svg 26 | :target: https://travis-ci.org/eugene-eeo/mailthon 27 | .. image:: https://ci.appveyor.com/api/projects/status/eadeytartlka64a1?svg=true 28 | :target: https://ci.appveyor.com/project/eugene-eeo/mailthon 29 | .. image:: https://img.shields.io/codecov/c/github/eugene-eeo/mailthon.svg 30 | :target: https://codecov.io/gh/eugene-eeo/mailthon 31 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | build: false 2 | environment: 3 | matrix: 4 | - PYTHON: "C:/Python27" 5 | - PYTHON: "C:/Python33" 6 | - PYTHON: "C:/Python34" 7 | - PYTHON: "C:/Python35" 8 | init: 9 | - "ECHO %PYTHON%" 10 | - ps: "ls C:/Python*" 11 | install: 12 | - ps: "(new-object net.webclient).DownloadFile('https://bootstrap.pypa.io/get-pip.py', 'C:/get-pip.py')" 13 | - "%PYTHON%/python.exe C:/get-pip.py" 14 | - "%PYTHON%/Scripts/pip.exe install -e ." 15 | - "%PYTHON%/Scripts/pip.exe install pytest" 16 | - "%PYTHON%/Scripts/pip.exe install mock" 17 | - "%PYTHON%/Scripts/pip.exe install pytest-cov" 18 | test_script: 19 | - "%PYTHON%/python.exe setup.py test" 20 | -------------------------------------------------------------------------------- /mailthon/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | mailthon 3 | ~~~~~~~~ 4 | 5 | Elegant, Pythonic library for sending emails. 6 | 7 | :license: MIT, see LICENSE for details. 8 | :copyright: 2015 (c) Eeo Jun 9 | """ 10 | 11 | 12 | from mailthon.api import postman, email 13 | -------------------------------------------------------------------------------- /mailthon/api.py: -------------------------------------------------------------------------------- 1 | """ 2 | mailthon.api 3 | ~~~~~~~~~~~~ 4 | 5 | Implements simple-to-use wrapper functions over 6 | the more verbose object-oriented core. 7 | 8 | :copyright: (c) 2015 by Eeo Jun 9 | :license: MIT, see LICENSE for details. 10 | """ 11 | 12 | from mailthon.enclosure import Collection, HTML, Attachment 13 | from mailthon.postman import Postman 14 | import mailthon.middleware as middleware 15 | import mailthon.headers as headers 16 | 17 | 18 | def email(sender=None, receivers=(), cc=(), bcc=(), 19 | subject=None, content=None, encoding='utf8', 20 | attachments=()): 21 | """ 22 | Creates a Collection object with a HTML *content*, 23 | and *attachments*. 24 | 25 | :param content: HTML content. 26 | :param encoding: Encoding of the email. 27 | :param attachments: List of filenames to 28 | attach to the email. 29 | """ 30 | enclosure = [HTML(content, encoding)] 31 | enclosure.extend(Attachment(k) for k in attachments) 32 | return Collection( 33 | *enclosure, 34 | headers=[ 35 | headers.subject(subject), 36 | headers.sender(sender), 37 | headers.to(*receivers), 38 | headers.cc(*cc), 39 | headers.bcc(*bcc), 40 | headers.date(), 41 | headers.message_id(), 42 | ] 43 | ) 44 | 45 | 46 | def postman(host, port=587, auth=(None, None), 47 | force_tls=False, options=None): 48 | """ 49 | Creates a Postman object with TLS and Auth 50 | middleware. TLS is placed before authentication 51 | because usually authentication happens and is 52 | accepted only after TLS is enabled. 53 | 54 | :param auth: Tuple of (username, password) to 55 | be used to ``login`` to the server. 56 | :param force_tls: Whether TLS should be forced. 57 | :param options: Dictionary of keyword arguments 58 | to be used when the SMTP class is called. 59 | """ 60 | return Postman( 61 | host=host, 62 | port=port, 63 | middlewares=[ 64 | middleware.tls(force=force_tls), 65 | middleware.auth(*auth), 66 | ], 67 | **options 68 | ) 69 | -------------------------------------------------------------------------------- /mailthon/enclosure.py: -------------------------------------------------------------------------------- 1 | """ 2 | mailthon.enclosure 3 | ~~~~~~~~~~~~~~~~~~ 4 | 5 | Implements Enclosure objects. 6 | 7 | :copyright: (c) 2015 by Eeo Jun 8 | :license: MIT, see LICENSE for details. 9 | """ 10 | 11 | from email.encoders import encode_base64 12 | from email.mime.base import MIMEBase 13 | from email.mime.multipart import MIMEMultipart 14 | from email.mime.text import MIMEText 15 | from os.path import basename 16 | 17 | from .headers import Headers, content_disposition 18 | from .helpers import guess 19 | 20 | 21 | class Enclosure(object): 22 | """ 23 | Base class for Enclosure objects to inherit from. 24 | An enclosure can be sent on it's own or wrapped 25 | inside an Envelope object. 26 | 27 | :param headers: Iterable of headers to include. 28 | """ 29 | 30 | def __init__(self, headers=()): 31 | self.headers = Headers(headers) 32 | self.content = None 33 | 34 | @property 35 | def sender(self): 36 | """ 37 | Returns the sender of the enclosure, obtained 38 | from the headers. 39 | """ 40 | return self.headers.sender 41 | 42 | @property 43 | def receivers(self): 44 | """ 45 | Returns a list of receiver addresses. 46 | """ 47 | return self.headers.receivers 48 | 49 | def mime_object(self): 50 | """ 51 | To be overriden. Returns the generated MIME 52 | object, without applying the internal headers. 53 | """ 54 | raise NotImplementedError 55 | 56 | def mime(self): 57 | """ 58 | Returns the finalised mime object, after 59 | applying the internal headers. Usually this 60 | is not to be overriden. 61 | """ 62 | mime = self.mime_object() 63 | self.headers.prepare(mime) 64 | return mime 65 | 66 | def string(self): 67 | """ 68 | Returns the stringified MIME object, ready 69 | to be sent via sendmail. 70 | """ 71 | return self.mime().as_string() 72 | 73 | 74 | class Collection(Enclosure): 75 | """ 76 | Multipart enclosure that allows the inclusion of 77 | multiple enclosures into one single object. Note 78 | that :class:`~mailthon.enclosure.Collection` 79 | objects can be nested inside one another. 80 | 81 | :param *enclosures: pass in any number of 82 | enclosure objects. 83 | :param subtype: Defaults to ``mixed``, the 84 | multipart subtype. 85 | :param headers: Optional headers. 86 | """ 87 | 88 | def __init__(self, *enclosures, **kwargs): 89 | self.subtype = kwargs.pop('subtype', 'mixed') 90 | self.enclosures = enclosures 91 | Enclosure.__init__(self, **kwargs) 92 | 93 | def mime_object(self): 94 | mime = MIMEMultipart(self.subtype) 95 | for item in self.enclosures: 96 | mime.attach(item.mime()) 97 | return mime 98 | 99 | 100 | class PlainText(Enclosure): 101 | """ 102 | Enclosure that has a text/plain mimetype. 103 | 104 | :param content: Unicode or bytes string. 105 | :param encoding: Encoding used to serialize the 106 | content or the encoding of the content. 107 | :param headers: Optional headers. 108 | """ 109 | 110 | subtype = 'plain' 111 | 112 | def __init__(self, content, encoding='utf-8', **kwargs): 113 | Enclosure.__init__(self, **kwargs) 114 | self.content = content 115 | self.encoding = encoding 116 | 117 | def mime_object(self): 118 | return MIMEText(self.content, 119 | self.subtype, 120 | self.encoding) 121 | 122 | 123 | class HTML(PlainText): 124 | """ 125 | Subclass of PlainText with a text/html mimetype. 126 | """ 127 | 128 | subtype = 'html' 129 | 130 | 131 | class Binary(Enclosure): 132 | """ 133 | An Enclosure subclass for binary content. If the 134 | content is HTML or any kind of plain-text then 135 | the HTML or PlainText Enclosures are receommended 136 | since they have a simpler interface. 137 | 138 | :param content: A bytes string. 139 | :param mimetype: Mimetype of the content. 140 | :param encoding: Optional encoding of the content. 141 | :param encoder: An optional encoder_ function. 142 | :param headers: Optional headers. 143 | 144 | .. _encoder: https://docs.python.org/2/library/email.encoders.html 145 | """ 146 | 147 | def __init__(self, content, mimetype, encoding=None, 148 | encoder=encode_base64, **kwargs): 149 | Enclosure.__init__(self, **kwargs) 150 | self.content = content 151 | self.mimetype = mimetype 152 | self.encoding = encoding 153 | self.encoder = encoder 154 | 155 | def mime_object(self): 156 | mime = MIMEBase(*self.mimetype.split('/')) 157 | mime.set_payload(self.content) 158 | if self.encoding: 159 | del mime['Content-Type'] 160 | mime.add_header('Content-Type', 161 | self.mimetype, 162 | charset=self.encoding) 163 | self.encoder(mime) 164 | return mime 165 | 166 | 167 | class Attachment(Binary): 168 | """ 169 | Binary subclass for easier file attachments. 170 | The advantage over directly using the Binary 171 | class is that the Content-Disposition header 172 | is automatically set, the mimetype guessed, 173 | and the content lazily returned. 174 | 175 | :param path: Absolute/Relative path to the file. 176 | :param headers: Optional headers. 177 | """ 178 | 179 | def __init__(self, path, headers=()): 180 | self.path = path 181 | self.mimetype, self.encoding = guess(path) 182 | self.encoder = encode_base64 183 | heads = dict([content_disposition('attachment', basename(path))]) 184 | heads.update(headers) 185 | self.headers = Headers(heads) 186 | 187 | @property 188 | def content(self): 189 | """ 190 | Lazily returns the bytes contents of the file. 191 | """ 192 | with open(self.path, 'rb') as handle: 193 | return handle.read() 194 | -------------------------------------------------------------------------------- /mailthon/envelope.py: -------------------------------------------------------------------------------- 1 | """ 2 | mailthon.envelope 3 | ~~~~~~~~~~~~~~~~~ 4 | 5 | Implements the Envelope object. 6 | 7 | :copyright: (c) 2015 by Eeo Jun 8 | :license: MIT, see LICENSE for details. 9 | """ 10 | 11 | 12 | class Envelope(object): 13 | """ 14 | Enclosure adapter for encapsulating the concept of 15 | an Envelope- a wrapper around some content in the 16 | form of an *enclosure*, and dealing with SMTP 17 | specific idiosyncracies. 18 | 19 | :param enclosure: An enclosure object to wrap around. 20 | :param mail_from: The "real" sender. May be omitted. 21 | :param rcpt_to: A list of "real" email addresses. 22 | May be omitted. 23 | """ 24 | 25 | def __init__(self, enclosure, mail_from=None, rcpt_to=None): 26 | self.enclosure = enclosure 27 | self.mail_from = mail_from 28 | self.rcpt_to = rcpt_to 29 | 30 | @property 31 | def sender(self): 32 | """ 33 | Returns the real sender if set in the *mail_from* 34 | parameter/attribute, else returns the sender 35 | attribute from the wrapped enclosure. 36 | """ 37 | return self.mail_from or self.enclosure.sender 38 | 39 | @property 40 | def receivers(self): 41 | """ 42 | Returns the "real" receivers which will be passed 43 | to the ``RCPT TO`` command (in SMTP) if specified 44 | in the *rcpt_to* attribute/parameter. Else, return 45 | the receivers attribute from the wrapped enclosure. 46 | """ 47 | return self.rcpt_to or self.enclosure.receivers 48 | 49 | def mime(self): 50 | """ 51 | Returns the mime object from the enclosure. 52 | """ 53 | return self.enclosure.mime() 54 | 55 | def string(self): 56 | """ 57 | Returns the stringified mime object. 58 | """ 59 | return self.enclosure.string() 60 | -------------------------------------------------------------------------------- /mailthon/headers.py: -------------------------------------------------------------------------------- 1 | """ 2 | mailthon.headers 3 | ~~~~~~~~~~~~~~~~ 4 | 5 | Implements RFC compliant headers, and is the 6 | recommended way to put headers into enclosures 7 | or envelopes. 8 | 9 | :copyright: (c) 2015 by Eeo Jun 10 | :license: MIT, see LICENSE for details. 11 | """ 12 | import sys 13 | from cgi import parse_header 14 | 15 | from email.utils import quote, formatdate, make_msgid, getaddresses 16 | from .helpers import format_addresses, UnicodeDict 17 | 18 | 19 | IS_PY3 = int(sys.version[0]) == 3 20 | 21 | 22 | class Headers(UnicodeDict): 23 | """ 24 | :rfc:`2822` compliant subclass of the 25 | :class:`~mailthon.helpers.UnicodeDict`. The 26 | semantics of the dictionary is different from that 27 | of the standard library MIME object- only the 28 | latest header is preserved instead of preserving 29 | all headers. This makes header lookup deterministic 30 | and sane. 31 | """ 32 | 33 | @property 34 | def resent(self): 35 | """ 36 | Whether the email was resent, i.e. whether the 37 | ``Resent-Date`` header was set. 38 | """ 39 | return 'Resent-Date' in self 40 | 41 | @property 42 | def sender(self): 43 | """ 44 | Returns the sender, respecting the Resent-* 45 | headers. In any case, prefer Sender over From, 46 | meaning that if Sender is present then From is 47 | ignored, as per the RFC. 48 | """ 49 | to_fetch = ( 50 | ['Resent-Sender', 'Resent-From'] if self.resent else 51 | ['Sender', 'From'] 52 | ) 53 | for item in to_fetch: 54 | if item in self: 55 | _, addr = getaddresses([self[item]])[0] 56 | return addr 57 | 58 | @property 59 | def receivers(self): 60 | """ 61 | Returns a list of receivers, obtained from the 62 | To, Cc, and Bcc headers, respecting the Resent-* 63 | headers if the email was resent. 64 | """ 65 | attrs = ( 66 | ['Resent-To', 'Resent-Cc', 'Resent-Bcc'] if self.resent else 67 | ['To', 'Cc', 'Bcc'] 68 | ) 69 | addrs = (v for v in (self.get(k) for k in attrs) if v) 70 | return [addr for _, addr in getaddresses(addrs)] 71 | 72 | def prepare(self, mime): 73 | """ 74 | Prepares a MIME object by applying the headers 75 | to the *mime* object. Ignores any Bcc or 76 | Resent-Bcc headers. 77 | """ 78 | for key in self: 79 | if key == 'Bcc' or key == 'Resent-Bcc': 80 | continue 81 | del mime[key] 82 | # Python 3.* email's compatibility layer will handle 83 | # unicode field values in proper way but Python 2 84 | # won't (it will encode not only additional field 85 | # values but also all header values) 86 | parsed_header, additional_fields = parse_header( 87 | self[key] if IS_PY3 else 88 | self[key].encode("utf-8") 89 | ) 90 | mime.add_header(key, parsed_header, **additional_fields) 91 | 92 | 93 | def subject(text): 94 | """ 95 | Generates a Subject header with a given *text*. 96 | """ 97 | yield 'Subject' 98 | yield text 99 | 100 | 101 | def sender(address): 102 | """ 103 | Generates a Sender header with a given *text*. 104 | *text* can be both a tuple or a string. 105 | """ 106 | yield 'Sender' 107 | yield format_addresses([address]) 108 | 109 | 110 | def to(*addrs): 111 | """ 112 | Generates a To header with the given *addrs*, where 113 | addrs can be made of ``Name
`` or ``address`` 114 | strings, or a mix of both. 115 | """ 116 | yield 'To' 117 | yield format_addresses(addrs) 118 | 119 | 120 | def cc(*addrs): 121 | """ 122 | Similar to ``to`` function. Generates a Cc header. 123 | """ 124 | yield 'Cc' 125 | yield format_addresses(addrs) 126 | 127 | 128 | def bcc(*addrs): 129 | """ 130 | Generates a Bcc header. This is safe when using the 131 | mailthon Headers implementation because the Bcc 132 | headers will not be included in the MIME object. 133 | """ 134 | yield 'Bcc' 135 | yield format_addresses(addrs) 136 | 137 | 138 | def content_disposition(disposition, filename): 139 | """ 140 | Generates a content disposition hedaer given a 141 | *disposition* and a *filename*. The filename needs 142 | to be the base name of the path, i.e. instead of 143 | ``~/file.txt`` you need to pass in ``file.txt``. 144 | The filename is automatically quoted. 145 | """ 146 | yield 'Content-Disposition' 147 | yield '%s; filename="%s"' % (disposition, quote(filename)) 148 | 149 | 150 | def date(time=None): 151 | """ 152 | Generates a Date header. Yields the *time* as the 153 | key if specified, else returns an RFC compliant 154 | date generated by formatdate. 155 | """ 156 | yield 'Date' 157 | yield time or formatdate(localtime=True) 158 | 159 | 160 | def message_id(string=None, idstring=None): 161 | """ 162 | Generates a Message-ID header, by yielding a 163 | given *string* if specified, else an RFC 164 | compliant message-id generated by make_msgid 165 | and strengthened by an optional *idstring*. 166 | """ 167 | yield 'Message-ID' 168 | yield string or make_msgid(idstring) 169 | 170 | 171 | def content_id(name): 172 | yield 'Content-ID' 173 | yield '<%s>' % (name,) 174 | -------------------------------------------------------------------------------- /mailthon/helpers.py: -------------------------------------------------------------------------------- 1 | """ 2 | mailthon.helpers 3 | ~~~~~~~~~~~~~~~~ 4 | 5 | Implements various helper functions/utilities. 6 | 7 | :copyright: (c) 2015 by Eeo Jun 8 | :license: MIT, see LICENSE for details. 9 | """ 10 | 11 | import sys 12 | import mimetypes 13 | from collections import MutableMapping 14 | from email.utils import formataddr 15 | 16 | 17 | if sys.version_info[0] == 3: 18 | bytes_type = bytes 19 | else: 20 | bytes_type = str 21 | 22 | 23 | def guess(filename, fallback='application/octet-stream'): 24 | """ 25 | Using the mimetypes library, guess the mimetype and 26 | encoding for a given *filename*. If the mimetype 27 | cannot be guessed, *fallback* is assumed instead. 28 | 29 | :param filename: Filename- can be absolute path. 30 | :param fallback: A fallback mimetype. 31 | """ 32 | guessed, encoding = mimetypes.guess_type(filename, strict=False) 33 | if guessed is None: 34 | return fallback, encoding 35 | return guessed, encoding 36 | 37 | 38 | def format_addresses(addrs): 39 | """ 40 | Given an iterable of addresses or name-address 41 | tuples *addrs*, return a header value that joins 42 | all of them together with a space and a comma. 43 | """ 44 | return ', '.join( 45 | formataddr(item) if isinstance(item, tuple) else item 46 | for item in addrs 47 | ) 48 | 49 | 50 | def stringify_address(addr, encoding='utf-8'): 51 | """ 52 | Given an email address *addr*, try to encode 53 | it with ASCII. If it's not possible, encode 54 | the *local-part* with the *encoding* and the 55 | *domain* with IDNA. 56 | 57 | The result is a unicode string with the domain 58 | encoded as idna. 59 | """ 60 | if isinstance(addr, bytes_type): 61 | return addr 62 | try: 63 | addr = addr.encode('ascii') 64 | except UnicodeEncodeError: 65 | if '@' in addr: 66 | localpart, domain = addr.split('@', 1) 67 | addr = b'@'.join([ 68 | localpart.encode(encoding), 69 | domain.encode('idna'), 70 | ]) 71 | else: 72 | addr = addr.encode(encoding) 73 | return addr.decode('utf-8') 74 | 75 | 76 | class UnicodeDict(dict): 77 | """ 78 | A dictionary that handles unicode values 79 | magically - that is, byte-values are 80 | automatically decoded. Accepts a dict 81 | or iterable *values*. 82 | """ 83 | 84 | def __init__(self, values=(), encoding='utf-8'): 85 | dict.__init__(self) 86 | self.encoding = encoding 87 | self.update(values) 88 | 89 | def __setitem__(self, key, value): 90 | if isinstance(value, bytes_type): 91 | value = value.decode(self.encoding) 92 | dict.__setitem__(self, key, value) 93 | 94 | update = MutableMapping.update 95 | -------------------------------------------------------------------------------- /mailthon/middleware.py: -------------------------------------------------------------------------------- 1 | """ 2 | mailthon.middleware 3 | ~~~~~~~~~~~~~~~~~~~ 4 | 5 | Implements Middleware classes. Middleware are small and 6 | configurable pieces of code that implement and allow for 7 | certain functionality. 8 | 9 | :copyright: (c) 2015 by Eeo Jun 10 | :license: MIT, see LICENSE for details. 11 | """ 12 | 13 | 14 | def tls(force=False): 15 | """ 16 | Middleware implementing TLS for SMTP connections. By 17 | default this is not forced- TLS is only used if 18 | STARTTLS is available. If the *force* parameter is set 19 | to True, it will not query the server for TLS features 20 | before upgrading to TLS. 21 | """ 22 | def middleware(conn): 23 | if force or conn.has_extn('STARTTLS'): 24 | conn.starttls() 25 | conn.ehlo() 26 | return middleware 27 | 28 | 29 | def auth(username, password): 30 | """ 31 | Middleware implementing authentication via LOGIN. 32 | Most of the time this middleware needs to be placed 33 | *after* TLS. 34 | 35 | :param username: Username to login with. 36 | :param password: Password of the user. 37 | """ 38 | def middleware(conn): 39 | conn.login(username, password) 40 | return middleware 41 | -------------------------------------------------------------------------------- /mailthon/postman.py: -------------------------------------------------------------------------------- 1 | """ 2 | mailthon.postman 3 | ~~~~~~~~~~~~~~~~ 4 | 5 | This module implements the central Postman object. 6 | 7 | :copyright: (c) 2015 by Eeo Jun 8 | :license: MIT, see LICENSE for details. 9 | """ 10 | 11 | from contextlib import contextmanager 12 | from smtplib import SMTP 13 | from .response import SendmailResponse 14 | from .helpers import stringify_address 15 | 16 | 17 | class Session(object): 18 | """ 19 | Represents a connection to some server or external 20 | service, e.g. some REST API. The underlying transport 21 | defaults to SMTP but can be subclassed. 22 | 23 | :param **kwargs: Keyword arguments to be passed to 24 | the underlying transport. 25 | """ 26 | 27 | def __init__(self, **kwargs): 28 | self.conn = SMTP(**kwargs) 29 | self.conn.ehlo() 30 | 31 | def teardown(self): 32 | """ 33 | Tear down the connection. 34 | """ 35 | self.conn.quit() 36 | 37 | def send(self, envelope): 38 | """ 39 | Send an *envelope* which may be an envelope 40 | or an enclosure-like object, see 41 | :class:`~mailthon.enclosure.Enclosure` and 42 | :class:`~mailthon.envelope.Envelope`, and 43 | returns a :class:`~mailthon.response.SendmailResponse` 44 | object. 45 | """ 46 | rejected = self.conn.sendmail( 47 | stringify_address(envelope.sender), 48 | [stringify_address(k) for k in envelope.receivers], 49 | envelope.string(), 50 | ) 51 | status_code, reason = self.conn.noop() 52 | return SendmailResponse( 53 | status_code, 54 | reason, 55 | rejected, 56 | ) 57 | 58 | 59 | class Postman(object): 60 | """ 61 | Encapsulates a connection to a server, created by 62 | some *session* class and provides middleware 63 | management and setup/teardown goodness. Basically 64 | is a layer of indirection over session objects, 65 | allowing for pluggable transports. 66 | 67 | :param session: Session class to be used. 68 | :param middleware: Middlewares to use. 69 | :param **kwargs: Options to pass to session class. 70 | """ 71 | 72 | def __init__(self, session=Session, middlewares=(), **options): 73 | self.session = session 74 | self.options = options 75 | self.middlewares = list(middlewares) 76 | 77 | def use(self, middleware): 78 | """ 79 | Use a certain callable *middleware*, i.e. 80 | append it to the list of middlewares, and 81 | return it so it can be used as a decorator. 82 | """ 83 | self.middlewares.append(middleware) 84 | return middleware 85 | 86 | @contextmanager 87 | def connection(self): 88 | """ 89 | A context manager that returns a connection 90 | to the server using some *session*. 91 | """ 92 | conn = self.session(**self.options) 93 | try: 94 | for item in self.middlewares: 95 | item(conn) 96 | yield conn 97 | finally: 98 | conn.teardown() 99 | 100 | def send(self, envelope): 101 | """ 102 | Sends an *enclosure* and return a response 103 | object. 104 | """ 105 | with self.connection() as conn: 106 | return conn.send(envelope) 107 | -------------------------------------------------------------------------------- /mailthon/response.py: -------------------------------------------------------------------------------- 1 | """ 2 | mailthon.response 3 | ~~~~~~~~~~~~~~~~~ 4 | 5 | Response objects encapsulate responses returned 6 | by SMTP servers. 7 | 8 | :copyright: (c) 2015 by Eeo Jun 9 | :license: MIT, see LICENSE for details. 10 | """ 11 | 12 | from collections import namedtuple 13 | 14 | 15 | _ResponseBase = namedtuple('Response', ['status_code', 'reason']) 16 | 17 | 18 | class Response(_ResponseBase): 19 | """ 20 | Encapsulates a (status_code, message) tuple 21 | returned by a server when the ``NOOP`` 22 | command is called. 23 | 24 | :param status_code: status code returned by server. 25 | :param message: error/success message. 26 | """ 27 | 28 | @property 29 | def ok(self): 30 | """ 31 | Returns true if the status code is 250, false 32 | otherwise. 33 | """ 34 | return self.status_code == 250 35 | 36 | 37 | class SendmailResponse: 38 | """ 39 | Encapsulates a (status_code, reason) tuple 40 | as well as a mapping of email-address to 41 | (status_code, reason) tuples that can be 42 | attained by the NOOP and the SENDMAIL 43 | command. 44 | 45 | :param pair: The response pair. 46 | :param rejected: Dictionary of rejected 47 | addresses to status-code reason pairs. 48 | """ 49 | 50 | def __init__(self, status_code, reason, rejected): 51 | self.res = Response(status_code, reason) 52 | self.rejected = {} 53 | for addr, pair in rejected.items(): 54 | self.rejected[addr] = Response(*pair) 55 | 56 | @property 57 | def ok(self): 58 | """ 59 | Returns True only if no addresses were 60 | rejected and if the status code is 250. 61 | """ 62 | return self.res.ok and not self.rejected 63 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | addopts = -rsxX --strict 3 | norecursedirs = .* *.egg *.egg-info env* artwork docs 4 | 5 | [wheel] 6 | universal = 1 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from setuptools import setup 3 | from setuptools.command.test import test as TestCommand 4 | 5 | 6 | class PyTest(TestCommand): 7 | def finalize_options(self): 8 | TestCommand.finalize_options(self) 9 | self.test_args = ['--strict', '--verbose', '--tb=long', 'tests', '--cov=mailthon'] 10 | self.test_suite = True 11 | 12 | def run_tests(self): 13 | import pytest 14 | errno = pytest.main(self.test_args) 15 | sys.exit(errno) 16 | 17 | 18 | setup( 19 | name='mailthon', 20 | version='0.2.0', 21 | description='Elegant email library', 22 | long_description=open('README.rst', 'rb').read().decode('utf8'), 23 | author='Eeo Jun', 24 | author_email='141bytes@gmail.com', 25 | url='https://github.com/eugene-eeo/mailthon/', 26 | classifiers=[ 27 | 'Development Status :: 3 - Alpha', 28 | 'Intended Audience :: Developers', 29 | 'License :: OSI Approved', 30 | 'License :: OSI Approved :: MIT License', 31 | 'Operating System :: OS Independent', 32 | 'Programming Language :: Python', 33 | 'Programming Language :: Python :: 2', 34 | 'Programming Language :: Python :: 3', 35 | 'Topic :: Software Development :: Libraries :: Python Modules' 36 | ], 37 | include_package_data=True, 38 | package_data={'mailthon': ['LICENSE', 'README.rst']}, 39 | packages=['mailthon'], 40 | tests_require=[ 41 | 'mock', 42 | 'pytest', 43 | 'pytest-localserver', 44 | 'pytest-cov', 45 | ], 46 | cmdclass={'test': PyTest}, 47 | platforms='any', 48 | zip_safe=False, 49 | ) 50 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eugene-eeo/mailthon/e3d5aef62505acb4edbc33e3378a04951c3199cb/tests/__init__.py -------------------------------------------------------------------------------- /tests/assets/spacer.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eugene-eeo/mailthon/e3d5aef62505acb4edbc33e3378a04951c3199cb/tests/assets/spacer.gif -------------------------------------------------------------------------------- /tests/mimetest.py: -------------------------------------------------------------------------------- 1 | from re import search 2 | from base64 import b64decode 3 | from email.message import Message 4 | 5 | 6 | class mimetest: 7 | def __init__(self, mime): 8 | self.mime = mime 9 | assert not mime.defects 10 | 11 | def __getitem__(self, header): 12 | return self.mime[header] 13 | 14 | @property 15 | def transfer_encoding(self): 16 | return self['Content-Transfer-Encoding'] 17 | 18 | @property 19 | def encoding(self): 20 | return self.mime.get_content_charset(None) 21 | 22 | @property 23 | def mimetype(self): 24 | return self.mime.get_content_type() 25 | 26 | @property 27 | def payload(self): 28 | payload = self.mime.get_payload().encode(self.encoding or 'ascii') 29 | if self.transfer_encoding == 'base64': 30 | return b64decode(payload) 31 | return payload 32 | 33 | @property 34 | def parts(self): 35 | payload = self.mime.get_payload() 36 | if not isinstance(payload, list): 37 | raise TypeError 38 | return [mimetest(k) for k in payload] 39 | 40 | 41 | def blank(): 42 | return Message() 43 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import pytest 3 | from mock import Mock, call 4 | from mailthon.api import email, postman 5 | from mailthon.postman import Postman 6 | from mailthon.middleware import tls, auth 7 | from .utils import unicode as uni 8 | from .mimetest import mimetest 9 | 10 | 11 | class TestPostman: 12 | p = postman( 13 | host='smtp.mail.com', 14 | port=1000, 15 | auth=('username', 'password'), 16 | options={'key': 'value'}, 17 | ) 18 | 19 | def test_options(self): 20 | opts = dict( 21 | host='smtp.mail.com', 22 | port=1000, 23 | key='value', 24 | ) 25 | assert self.p.options == opts 26 | 27 | 28 | class TestRealSmtp: 29 | def test_send_email_example(self, smtpserver): 30 | host = smtpserver.addr[0] 31 | port = smtpserver.addr[1] 32 | p = Postman(host=host, port=port) 33 | 34 | r = p.send(email( 35 | content='

Hello 世界

', 36 | subject='Hello world', 37 | sender='John ', 38 | receivers=['doe@jon.com'], 39 | )) 40 | 41 | assert r.ok 42 | assert len(smtpserver.outbox) == 1 43 | 44 | 45 | class TestEmail: 46 | e = email( 47 | subject='hi', 48 | sender='name ', 49 | receivers=['rcv@mail.com'], 50 | cc=['rcv1@mail.com'], 51 | bcc=['rcv2@mail.com'], 52 | content='hi!', 53 | attachments=['tests/assets/spacer.gif'], 54 | ) 55 | 56 | def test_attrs(self): 57 | assert self.e.sender == 'send@mail.com' 58 | assert set(self.e.receivers) == set([ 59 | 'rcv@mail.com', 60 | 'rcv1@mail.com', 61 | 'rcv2@mail.com', 62 | ]) 63 | 64 | def test_headers(self): 65 | mime = mimetest(self.e.mime()) 66 | assert not mime['Bcc'] 67 | 68 | def test_content(self): 69 | mime = mimetest(self.e.mime()) 70 | assert [k.payload for k in mime.parts] == [ 71 | b'hi!', 72 | open('tests/assets/spacer.gif', 'rb').read() 73 | ] 74 | -------------------------------------------------------------------------------- /tests/test_enclosure.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | from pytest import fixture 3 | from mailthon.enclosure import PlainText, HTML, Binary, Attachment, Collection 4 | from .mimetest import mimetest 5 | from .utils import unicode 6 | 7 | 8 | fixture = fixture(scope='class') 9 | 10 | 11 | class TestCollection: 12 | @fixture 13 | def mime(self): 14 | coll = Collection( 15 | PlainText('1'), 16 | PlainText('2'), 17 | subtype='alternative', 18 | headers={ 19 | 'X-Something': 'value', 20 | }, 21 | ) 22 | return mimetest(coll.mime()) 23 | 24 | def test_default_mimetype(self): 25 | mime = mimetest(Collection().mime()) 26 | assert mime.mimetype == 'multipart/mixed' 27 | 28 | def test_mimetype(self, mime): 29 | assert mime.mimetype == 'multipart/alternative' 30 | 31 | def test_payload(self, mime): 32 | assert [p.payload for p in mime.parts] == [b'1', b'2'] 33 | 34 | def test_headers(self, mime): 35 | assert mime['X-Something'] == 'value' 36 | 37 | 38 | class TestPlainText: 39 | content = unicode('some-content 华语') 40 | headers = { 41 | 'X-Something': 'String', 42 | 'X-Something-Else': 'Other String', 43 | } 44 | bytes_content = content.encode('utf-8') 45 | expected_mimetype = 'text/plain' 46 | expected_encoding = 'utf-8' 47 | 48 | @fixture 49 | def enclosure(self): 50 | return PlainText(self.content, headers=self.headers) 51 | 52 | @fixture 53 | def mime(self, enclosure): 54 | return mimetest(enclosure.mime()) 55 | 56 | def test_encoding(self, mime): 57 | assert mime.encoding == self.expected_encoding 58 | 59 | def test_mimetype(self, mime): 60 | assert mime.mimetype == self.expected_mimetype 61 | 62 | def test_content(self, mime): 63 | assert mime.payload == self.bytes_content 64 | 65 | def test_headers(self, mime): 66 | for header in self.headers: 67 | assert mime[header] == self.headers[header] 68 | 69 | 70 | class TestHTML(TestPlainText): 71 | expected_mimetype = 'text/html' 72 | 73 | @fixture 74 | def enclosure(self): 75 | return HTML(self.content, headers=self.headers) 76 | 77 | 78 | class TestBinary(TestPlainText): 79 | expected_mimetype = 'image/gif' 80 | 81 | with open('tests/assets/spacer.gif', 'rb') as handle: 82 | bytes_content = handle.read() 83 | content = bytes_content 84 | 85 | @fixture 86 | def enclosure(self): 87 | return Binary( 88 | content=self.content, 89 | mimetype=self.expected_mimetype, 90 | headers=self.headers, 91 | ) 92 | 93 | def test_encoding(self, mime): 94 | assert mime.encoding is None 95 | 96 | def test_headers_priority(self): 97 | b = Binary(content=self.content, 98 | mimetype=self.expected_mimetype, 99 | headers={'Content-Type': 'text/plain'}) 100 | mime = mimetest(b.mime()) 101 | assert mime['Content-Type'] == 'text/plain' 102 | 103 | 104 | class TestAttachment(TestBinary): 105 | @fixture 106 | def enclosure(self): 107 | raw = Attachment('tests/assets/spacer.gif', headers=self.headers) 108 | return raw 109 | 110 | def test_content_disposition(self, mime): 111 | expected = r'attachment; filename="spacer.gif"' 112 | assert mime['Content-Disposition'] == expected 113 | 114 | def test_headers_priority(self): 115 | a = Attachment('tests/assets/spacer.gif', 116 | headers={'Content-Disposition': 'something'}) 117 | mime = mimetest(a.mime()) 118 | assert mime['Content-Disposition'] == 'something' 119 | 120 | 121 | def test_binary_with_encoding(): 122 | b = Binary( 123 | content=b'something', 124 | mimetype='image/gif', 125 | encoding='utf-8', 126 | ) 127 | mime = mimetest(b.mime()) 128 | assert mime.encoding == 'utf-8' 129 | -------------------------------------------------------------------------------- /tests/test_envelope.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from mock import Mock 3 | from email.mime.base import MIMEBase 4 | from mailthon.enclosure import PlainText 5 | from mailthon.envelope import Envelope 6 | from .mimetest import mimetest 7 | 8 | 9 | class TestEnvelope: 10 | @pytest.fixture 11 | def embedded(self): 12 | pt = PlainText( 13 | content='hi', 14 | headers={ 15 | 'Sender': 'me@mail.com', 16 | 'To': 'him@mail.com, them@mail.com', 17 | } 18 | ) 19 | mime = Mock() 20 | mime.as_string = Mock(return_value='--email--') 21 | pt.mime = Mock(return_value=mime) 22 | return pt 23 | 24 | @pytest.fixture 25 | def envelope(self, embedded): 26 | return Envelope(embedded) 27 | 28 | def test_mime(self, envelope, embedded): 29 | assert envelope.mime() == embedded.mime() 30 | assert envelope.string() == embedded.string() 31 | 32 | def test_attrs(self, envelope, embedded): 33 | assert envelope.sender == embedded.sender 34 | assert envelope.receivers == embedded.receivers 35 | 36 | def test_mail_from(self, envelope): 37 | envelope.mail_from = 'from@mail.com' 38 | assert envelope.sender == 'from@mail.com' 39 | 40 | def test_rcpt_to(self, envelope): 41 | envelope.rcpt_to = ['hi@mail.com'] 42 | assert envelope.receivers == ['hi@mail.com'] 43 | -------------------------------------------------------------------------------- /tests/test_headers.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | import pytest 4 | from mock import Mock, call 5 | import mailthon.headers 6 | from mailthon.headers import (Headers, cc, to, bcc, sender, 7 | message_id, date, content_id, 8 | content_disposition) 9 | from .mimetest import blank 10 | 11 | 12 | class TestNotResentHeaders: 13 | @pytest.fixture 14 | def headers(self): 15 | return Headers([ 16 | ('From', 'from@mail.com'), 17 | sender('sender@mail.com'), 18 | to('to@mail.com'), 19 | cc('cc1@mail.com', 'cc2@mail.com'), 20 | bcc('bcc1@mail.com', 'bcc2@mail.com'), 21 | ]) 22 | 23 | @pytest.fixture 24 | def content_disposition_headers(self): 25 | return (Headers([content_disposition("attachment", "ascii.filename")]), 26 | Headers([content_disposition("attachment", "файл.filename")])) 27 | 28 | def test_getitem(self, headers): 29 | assert headers['From'] == 'from@mail.com' 30 | assert headers['Sender'] == 'sender@mail.com' 31 | assert headers['To'] == 'to@mail.com' 32 | 33 | def test_sender(self, headers): 34 | assert headers.sender == 'sender@mail.com' 35 | 36 | def test_receivers(self, headers): 37 | assert set(headers.receivers) == set([ 38 | 'to@mail.com', 39 | 'cc1@mail.com', 40 | 'cc2@mail.com', 41 | 'bcc1@mail.com', 42 | 'bcc2@mail.com', 43 | ]) 44 | 45 | def test_resent(self, headers): 46 | assert not headers.resent 47 | 48 | def test_prepare(self, headers): 49 | mime = blank() 50 | headers.prepare(mime) 51 | 52 | assert not mime['Bcc'] 53 | assert mime['Cc'] == 'cc1@mail.com, cc2@mail.com' 54 | assert mime['To'] == 'to@mail.com' 55 | assert mime['Sender'] == 'sender@mail.com' 56 | 57 | def test_content_disposition_headers(self, content_disposition_headers): 58 | """ 59 | Do the same as test above but for `complex` headers which can contain additional fields 60 | """ 61 | for header in content_disposition_headers: 62 | mime = blank() 63 | header.prepare(mime) 64 | assert "filename" in mime["Content-Disposition"] 65 | 66 | 67 | class TestResentHeaders(TestNotResentHeaders): 68 | @pytest.fixture 69 | def headers(self): 70 | head = TestNotResentHeaders.headers(self) 71 | head.update({ 72 | 'Resent-Date': 'Today', 73 | 'Resent-From': 'rfrom@mail.com', 74 | 'Resent-To': 'rto@mail.com', 75 | 'Resent-Cc': 'rcc@mail.com', 76 | 'Resent-Bcc': 'rbcc1@mail.com, rbcc2@mail.com' 77 | }) 78 | return head 79 | 80 | def test_sender(self, headers): 81 | assert headers.sender == 'rfrom@mail.com' 82 | 83 | def test_prefers_resent_sender(self, headers): 84 | headers['Resent-Sender'] = 'rsender@mail.com' 85 | assert headers.sender == 'rsender@mail.com' 86 | 87 | def test_resent_sender_without_senders(self, headers): 88 | del headers['Resent-From'] 89 | assert headers.sender is None 90 | 91 | def test_receivers(self, headers): 92 | assert set(headers.receivers) == set([ 93 | 'rto@mail.com', 94 | 'rcc@mail.com', 95 | 'rbcc1@mail.com', 96 | 'rbcc2@mail.com', 97 | ]) 98 | 99 | def test_resent(self, headers): 100 | assert headers.resent 101 | 102 | def test_resent_date_removed(self, headers): 103 | headers.pop('Resent-Date') 104 | assert not headers.resent 105 | 106 | def test_prepare(self, headers): 107 | mime = blank() 108 | headers.prepare(mime) 109 | 110 | assert not mime['Resent-Bcc'] 111 | assert not mime['Bcc'] 112 | 113 | 114 | @pytest.mark.parametrize('function', [to, cc, bcc]) 115 | def test_tuple_headers(function): 116 | _, value = function( 117 | ('Sender', 'sender@mail.com'), 118 | 'Me ', 119 | ) 120 | expected = 'Sender , Me ' 121 | assert value == expected 122 | 123 | 124 | @pytest.mark.parametrize('argtype', [str, tuple]) 125 | def test_sender_tuple(argtype): 126 | param = ( 127 | 'name ' if argtype is str else 128 | ('name', 'mail@mail.com') 129 | ) 130 | _, value = sender(param) 131 | assert value == 'name ' 132 | 133 | 134 | def test_message_id(): 135 | def msgid(thing=None): 136 | return thing 137 | 138 | mailthon.headers.make_msgid = Mock(side_effect=msgid) 139 | assert tuple(message_id()) == ('Message-ID', None) 140 | assert tuple(message_id('string')) == ('Message-ID', 'string') 141 | assert tuple(message_id(idstring=1)) == ('Message-ID', 1) 142 | 143 | 144 | def test_date(): 145 | formatdate = mailthon.headers.formatdate = Mock(return_value=1) 146 | assert tuple(date()) == ('Date', 1) 147 | assert formatdate.mock_calls == [call(localtime=True)] 148 | 149 | assert tuple(date('time')) == ('Date', 'time') 150 | 151 | 152 | def test_content_id(): 153 | assert dict([content_id('l')]) == {'Content-ID': ''} 154 | -------------------------------------------------------------------------------- /tests/test_helpers.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | import pytest 3 | from mailthon.helpers import (guess, format_addresses, 4 | stringify_address, UnicodeDict) 5 | from .utils import unicode as uni 6 | 7 | 8 | def test_guess_recognised(): 9 | mimetype, _ = guess('file.html') 10 | assert mimetype == 'text/html' 11 | 12 | 13 | def test_guess_fallback(): 14 | mimetype, _ = guess('ha', fallback='text/plain') 15 | assert mimetype == 'text/plain' 16 | 17 | 18 | def test_format_addresses(): 19 | chunks = format_addresses([ 20 | ('From', 'sender@mail.com'), 21 | 'Fender ', 22 | ]) 23 | assert chunks == 'From , Fender ' 24 | 25 | 26 | def test_stringify_address(): 27 | assert stringify_address(uni('mail@mail.com')) == 'mail@mail.com' 28 | assert stringify_address(uni('mail@måil.com')) == 'mail@xn--mil-ula.com' 29 | assert stringify_address(uni('måil@måil.com')) == uni('måil@xn--mil-ula.com') 30 | 31 | 32 | class TestUnicodeDict: 33 | @pytest.fixture 34 | def mapping(self): 35 | return UnicodeDict({'Item': uni('måil')}) 36 | 37 | @pytest.mark.parametrize('param', [ 38 | uni('måil'), 39 | uni('måil').encode('utf8'), 40 | ]) 41 | def test_setitem(self, param): 42 | u = UnicodeDict() 43 | u['Item'] = param 44 | assert u['Item'] == uni('måil') 45 | 46 | def test_update(self, mapping): 47 | mapping.update({ 48 | 'Item-1': uni('unicode-itém'), 49 | 'Item-2': uni('bytes-item').encode('utf8'), 50 | }) 51 | assert mapping['Item-1'] == uni('unicode-itém') 52 | assert mapping['Item-2'] == uni('bytes-item') 53 | -------------------------------------------------------------------------------- /tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | from pytest import fixture, mark 2 | from mock import Mock, call 3 | from mailthon.middleware import tls, auth 4 | from .utils import tls_started 5 | 6 | 7 | @fixture 8 | def smtp(): 9 | return Mock() 10 | 11 | 12 | class TestTlsSupported: 13 | @fixture 14 | def conn(self, smtp): 15 | smtp.has_extn.return_value = True 16 | return smtp 17 | 18 | @mark.parametrize('force', [True, False]) 19 | def test_force(self, conn, force): 20 | wrap = tls(force=force) 21 | wrap(conn) 22 | 23 | if not force: 24 | assert conn.mock_calls[0] == call.has_extn('STARTTLS') 25 | assert tls_started(conn) 26 | 27 | 28 | class TestTLSUnsupported: 29 | @fixture 30 | def conn(self, smtp): 31 | smtp.has_extn.return_value = False 32 | return smtp 33 | 34 | def test_no_force(self, conn): 35 | wrap = tls() 36 | wrap(conn) 37 | 38 | assert not tls_started(conn) 39 | 40 | 41 | class TestAuth: 42 | def test_logs_in_user(self, smtp): 43 | wrap = auth('user', 'pass') 44 | wrap(smtp) 45 | 46 | assert call.login('user', 'pass') in smtp.mock_calls 47 | -------------------------------------------------------------------------------- /tests/test_postman.py: -------------------------------------------------------------------------------- 1 | from mock import call, Mock 2 | from pytest import fixture 3 | from mailthon.enclosure import PlainText 4 | from mailthon.postman import Session, Postman 5 | from mailthon.response import SendmailResponse 6 | from .utils import mocked_smtp, unicode 7 | 8 | 9 | class FakeSession(Session): 10 | def __init__(self, **kwargs): 11 | self.opts = kwargs 12 | self.conn = mocked_smtp(**kwargs) 13 | 14 | 15 | @fixture 16 | def enclosure(): 17 | env = PlainText( 18 | headers={ 19 | 'Sender': unicode('sender@mail.com'), 20 | 'To': unicode('addr1@mail.com, addr2@mail.com'), 21 | }, 22 | content='Hi!', 23 | ) 24 | env.string = Mock(return_value='--string--') 25 | return env 26 | 27 | 28 | class TestSession: 29 | @fixture 30 | def session(self): 31 | return FakeSession(host='host', 32 | port=1000) 33 | 34 | def test_teardown(self, session): 35 | session.teardown() 36 | assert session.conn.mock_calls[-1] == call.quit() 37 | 38 | def test_send(self, session, enclosure): 39 | smtp = session.conn 40 | smtp.sendmail.return_value = {} 41 | smtp.noop.return_value = (250, 'ok') 42 | 43 | response = session.send(enclosure) 44 | sendmail = call.sendmail( 45 | 'sender@mail.com', 46 | ['addr1@mail.com', 'addr2@mail.com'], 47 | '--string--', 48 | ) 49 | assert sendmail in session.conn.mock_calls 50 | assert response.ok 51 | 52 | def test_send_with_failures(self, session, enclosure): 53 | rejected = {'addr': (255, 'reason')} 54 | smtp = session.conn 55 | smtp.sendmail.return_value = rejected 56 | smtp.noop.return_value = (250, 'ok') 57 | 58 | response = session.send(enclosure) 59 | assert not response.ok 60 | 61 | 62 | class TestPostman: 63 | @fixture 64 | def postman(self): 65 | def config(**kwargs): 66 | session.opts = kwargs 67 | return session 68 | 69 | session = Mock(spec=Session) 70 | session.side_effect = config 71 | session.send.return_value = SendmailResponse(250, 'ok', {}) 72 | 73 | return Postman( 74 | session=session, 75 | host='host', 76 | port=1000, 77 | ) 78 | 79 | def test_connection(self, postman): 80 | with postman.connection() as session: 81 | mc = session.mock_calls 82 | assert session.opts == {'host': 'host', 'port': 1000} 83 | assert mc == [call(**postman.options)] 84 | assert mc[-1] == call.teardown() 85 | 86 | def test_use(self, postman): 87 | func = Mock() 88 | assert postman.use(func) is func 89 | 90 | with postman.connection() as session: 91 | assert func.mock_calls == [call(session)] 92 | 93 | def test_send(self, postman, enclosure): 94 | r = postman.send(enclosure) 95 | assert call.send(enclosure) in postman.session.mock_calls 96 | assert r.ok 97 | assert not r.rejected 98 | -------------------------------------------------------------------------------- /tests/test_response.py: -------------------------------------------------------------------------------- 1 | from pytest import fixture 2 | from mailthon.response import Response, SendmailResponse 3 | 4 | 5 | @fixture(params=[250, 251]) 6 | def status(request): 7 | return request.param 8 | 9 | 10 | class TestResponse: 11 | reason = 'error' 12 | 13 | @fixture 14 | def res(self, status): 15 | return Response(status, self.reason) 16 | 17 | def test_attrs(self, res, status): 18 | assert res.status_code == status 19 | assert res.reason == self.reason 20 | 21 | def test_ok(self, res, status): 22 | if status == 250: 23 | assert res.ok 24 | else: 25 | assert not res.ok 26 | 27 | 28 | class TestSendmailResponse: 29 | def test_ok_with_no_failure(self): 30 | r = SendmailResponse(250, 'reason', {}) 31 | assert r.ok 32 | assert r.rejected == {} 33 | 34 | def test_ok_with_failure(self): 35 | r = SendmailResponse(251, 'error', {}) 36 | assert not r.ok 37 | assert r.rejected == {} 38 | 39 | def test_ok_with_rejection(self): 40 | for code in [250, 251]: 41 | r = SendmailResponse(code, 'reason', {'addr': (123, 'reason')}) 42 | assert not r.ok 43 | assert r.rejected['addr'] == Response(123, 'reason') 44 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from sys import version_info 2 | from pytest import fixture 3 | from mock import Mock, call 4 | 5 | 6 | if version_info[0] == 3: 7 | unicode = str 8 | bytes_type = bytes 9 | else: 10 | unicode = lambda k: k.decode('utf8') 11 | bytes_type = str 12 | 13 | 14 | def mocked_smtp(*args, **kwargs): 15 | smtp = Mock() 16 | smtp.return_value = smtp 17 | smtp(*args, **kwargs) 18 | smtp.noop.return_value = (250, 'ok') 19 | smtp.sendmail.return_value = {} 20 | 21 | def side_effect(): 22 | smtp.closed = True 23 | 24 | smtp.quit.side_effect = side_effect 25 | return smtp 26 | 27 | 28 | def tls_started(conn): 29 | calls = conn.mock_calls 30 | starttls = call.starttls() 31 | ehlo = call.ehlo() 32 | return (starttls in calls and 33 | ehlo in calls[calls.index(starttls)+1:]) 34 | --------------------------------------------------------------------------------