├── tests
├── __init__.py
├── assets
│ └── spacer.gif
├── utils.py
├── mimetest.py
├── test_middleware.py
├── test_response.py
├── test_envelope.py
├── test_helpers.py
├── test_api.py
├── test_postman.py
├── test_enclosure.py
└── test_headers.py
├── MANIFEST.in
├── setup.cfg
├── mailthon
├── __init__.py
├── middleware.py
├── response.py
├── envelope.py
├── api.py
├── helpers.py
├── postman.py
├── headers.py
└── enclosure.py
├── .travis.yml
├── appveyor.yml
├── .gitignore
├── LICENSE
├── README.rst
├── setup.py
└── CONTRIBUTING.rst
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include LICENSE
2 | include README.rst
--------------------------------------------------------------------------------
/tests/assets/spacer.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/eugene-eeo/mailthon/HEAD/tests/assets/spacer.gif
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [tool:pytest]
2 | addopts = -rsxX --strict
3 | norecursedirs = .* *.egg *.egg-info env* artwork docs
4 |
5 | [wheel]
6 | universal = 1
7 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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_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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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_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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------