├── test ├── .keep ├── __init__.py ├── manager │ ├── __init__.py │ ├── test_immediate.py │ ├── test_futures.py │ └── test_dynamic.py ├── transport │ ├── __init__.py │ ├── test_gae.py │ ├── test_mock.py │ ├── test_mbox.py │ ├── test_maildir.py │ ├── test_log.py │ └── test_smtp.py ├── test_issue_2.py ├── test_exceptions.py ├── test_plugins.py ├── test_validator.py ├── test_core.py ├── test_addresses.py └── test_message.py ├── .packaging └── .keep ├── example ├── data │ ├── mbox │ ├── maildir │ │ ├── cur │ │ │ └── .keep │ │ ├── new │ │ │ ├── .keep │ │ │ └── .DS_Store │ │ ├── tmp │ │ │ └── .keep │ │ └── .DS_Store │ └── .DS_Store ├── mbox.py ├── maildir.py ├── imap.py └── smtp.py ├── marrow ├── mailer │ ├── manager │ │ ├── __init__.py │ │ ├── immediate.py │ │ ├── transactional.py │ │ ├── util.py │ │ ├── futures.py │ │ └── dynamic.py │ ├── transport │ │ ├── __init__.py │ │ ├── log.py │ │ ├── mbox.py │ │ ├── sendmail.py │ │ ├── mailgun.py │ │ ├── gae.py │ │ ├── maildir.py │ │ ├── imap.py │ │ ├── ses.py │ │ ├── mock.py │ │ ├── postmark.py │ │ ├── sendgrid.py │ │ └── smtp.py │ ├── release.py │ ├── logger.py │ ├── exc.py │ ├── testing.py │ ├── __init__.py │ ├── address.py │ ├── message.py │ └── validator.py └── __init__.py ├── .gitignore ├── setup.cfg ├── .travis.yml ├── LICENSE.txt ├── setup.py └── README.textile /test/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.packaging/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/data/mbox: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/manager/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/transport/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/data/maildir/cur/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/data/maildir/new/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/data/maildir/tmp/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /marrow/mailer/manager/__init__.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 -------------------------------------------------------------------------------- /marrow/mailer/transport/__init__.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | -------------------------------------------------------------------------------- /test/transport/test_gae.py: -------------------------------------------------------------------------------- 1 | # Use: http://pypi.python.org/pypi/gaetestbed -------------------------------------------------------------------------------- /example/data/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marrow/mailer/HEAD/example/data/.DS_Store -------------------------------------------------------------------------------- /example/data/maildir/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marrow/mailer/HEAD/example/data/maildir/.DS_Store -------------------------------------------------------------------------------- /example/data/maildir/new/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marrow/mailer/HEAD/example/data/maildir/new/.DS_Store -------------------------------------------------------------------------------- /marrow/__init__.py: -------------------------------------------------------------------------------- 1 | try: # pragma: no cover 2 | __import__('pkg_resources').declare_namespace(__name__) 3 | except ImportError: # pragma: no cover 4 | __import__('pkgutil').extend_path(__path__, __name__) 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | 3 | # Packaging 4 | *.egg 5 | *.egg-info 6 | dist 7 | build 8 | eggs 9 | .eggs 10 | parts 11 | bin 12 | var 13 | sdist 14 | develop-eggs 15 | .installed.cfg 16 | 17 | # Installer logs 18 | pip-log.txt 19 | 20 | # Unit test / coverage reports 21 | .coverage 22 | .tox 23 | .cache 24 | htmlcov 25 | coverage.xml 26 | 27 | # Translations 28 | *.mo 29 | 30 | # Mac 31 | .DS_Store 32 | -------------------------------------------------------------------------------- /test/test_issue_2.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | from __future__ import unicode_literals 4 | 5 | from marrow.mailer import Mailer 6 | 7 | 8 | 9 | def test_issue_2(): 10 | mail = Mailer({ 11 | 'manager.use': 'immediate', 12 | 'transport.use': 'smtp', 13 | 'transport.host': 'secure.emailsrvr.com', 14 | 'transport.tls': 'ssl' 15 | }) 16 | 17 | mail.start() 18 | mail.stop() 19 | -------------------------------------------------------------------------------- /example/mbox.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from marrow.mailer import Message, Mailer 3 | logging.basicConfig(level=logging.INFO) 4 | 5 | mail = Mailer({'manager.use': 'immediate', 'transport.use': 'mbox', 'transport.file': 'data/mbox'}) 6 | mail.start() 7 | 8 | message = Message([('Alice Bevan-McGregor', 'alice@gothcandy.com')], [('Alice Two', 'alice.mcgregor@me.com')], "This is a test message.", plain="Testing!") 9 | 10 | mail.send(message) 11 | mail.stop() 12 | 13 | -------------------------------------------------------------------------------- /test/test_exceptions.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | """Test the primary configurator interface, Delivery.""" 4 | 5 | from unittest import TestCase 6 | 7 | from marrow.mailer.exc import DeliveryFailedException 8 | 9 | 10 | def test_delivery_failed_exception_init(): 11 | exc = DeliveryFailedException("message", "reason") 12 | assert exc.msg == "message" 13 | assert exc.reason == "reason" 14 | assert exc.args[0] == "message" 15 | assert exc.args[1] == "reason" 16 | -------------------------------------------------------------------------------- /example/maildir.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from marrow.mailer import Message, Mailer 3 | logging.basicConfig(level=logging.INFO) 4 | 5 | mail = Mailer({'manager.use': 'immediate', 'transport.use': 'maildir', 'transport.directory': 'data/maildir'}) 6 | mail.start() 7 | 8 | message = Message([('Alice Bevan-McGregor', 'alice@gothcandy.com')], [('Alice Two', 'alice.mcgregor@me.com')], "This is a test message.", plain="Testing!") 9 | 10 | mail.send(message) 11 | mail.stop() 12 | 13 | -------------------------------------------------------------------------------- /example/imap.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from marrow.mailer import Message, Mailer 3 | logging.basicConfig(level=logging.DEBUG) 4 | 5 | mail = Mailer({ 6 | 'manager.use': 'futures', 7 | 'transport.use': 'imap', 8 | 'transport.host': '', 9 | 'transport.ssl': True, 10 | 'transport.username': '', 11 | 'transport.password': '', 12 | 'transport.folder': 'Marrow' 13 | }) 14 | 15 | mail.start() 16 | 17 | message = Message([('Alice Bevan-McGregor', 'alice@gothcandy.com')], [('Alice Two', 'alice.mcgregor@me.com')], "This is a test message.", plain="Testing!") 18 | 19 | mail.send(message) 20 | mail.stop() 21 | -------------------------------------------------------------------------------- /example/smtp.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from marrow.mailer import Message, Mailer 3 | logging.basicConfig(level=logging.INFO) 4 | 5 | mail = Mailer({ 6 | 'manager.use': 'futures', 7 | 'transport.use': 'smtp', 8 | 'transport.host': '', 9 | 'transport.tls': 'ssl', 10 | 'transport.username': '', 11 | 'transport.password': '', 12 | 'transport.max_messages_per_connection': 5 13 | }) 14 | mail.start() 15 | 16 | message = Message([('Alice Bevan-McGregor', 'alice@gothcandy.com')], [('Alice Two', 'alice.mcgregor@me.com')], "This is a test message.", plain="Testing!") 17 | 18 | mail.send(message) 19 | mail.stop() 20 | -------------------------------------------------------------------------------- /marrow/mailer/release.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | """Release information about Marrow Mailer.""" 4 | 5 | from collections import namedtuple 6 | 7 | 8 | version_info = namedtuple('version_info', ('major', 'minor', 'micro', 'releaselevel', 'serial'))(4, 1, 3, 'b', 0) 9 | version = ".".join([str(i) for i in version_info[:3]]) + ((version_info.releaselevel[0] + str(version_info.serial)) if version_info.releaselevel != 'final' else '') 10 | 11 | author = namedtuple('Author', ['name', 'email'])("Alice Bevan-McGregor", 'alice@gothcandy.com') 12 | 13 | description = "A light-weight modular mail delivery framework for Python 2.7+, 3.3+, Pypy, and Pypy3." 14 | url = 'https://github.com/marrow/mailer/' 15 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | test = pytest 3 | 4 | [check] 5 | metadata = 1 6 | restructuredtext = 1 7 | 8 | [clean] 9 | build-base = .packaging/build 10 | bdist-base = .packaging/dist 11 | 12 | [build] 13 | build-base = .packaging/build 14 | 15 | [bdist] 16 | bdist-base = .packaging/dist 17 | dist-dir = .packaging/release 18 | 19 | [bdist_wheel] 20 | bdist-dir = .packaging/dist 21 | dist-dir = .packaging/release 22 | 23 | [register] 24 | strict = 1 25 | 26 | [tool:pytest] 27 | addopts = --flakes --cov-report term-missing --cov-report xml --no-cov-on-fail --cov marrow.mailer -l --durations=5 -r fEsxw --color=auto test 28 | 29 | flakes-ignore = 30 | test/*.py UnusedImport 31 | test/*/*.py UnusedImport 32 | 33 | [wheel] 34 | universal = 1 35 | 36 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: false 3 | cache: pip 4 | 5 | branches: 6 | except: 7 | - /^[^/]+/.*$/ 8 | 9 | python: 10 | - "2.7" 11 | - "pypy" 12 | - "pypy3" 13 | - "3.4" 14 | - "3.5" 15 | - "3.6" 16 | - "3.7" 17 | 18 | install: 19 | - travis_retry pip install --upgrade setuptools pip codecov 'setuptools_scm>=1.9' 20 | - ./setup.py develop 21 | 22 | script: 23 | python setup.py test 24 | 25 | after_script: 26 | bash <(curl -s https://codecov.io/bash) 27 | 28 | notifications: 29 | irc: 30 | channels: 31 | - 'irc.freenode.org#webcore' 32 | use_notice: true 33 | skip_join: true 34 | on_success: change 35 | on_failure: always 36 | template: 37 | - "%{repository_slug}:%{branch}@%{commit} %{message}" 38 | - "Duration: %{duration} - Details: %{build_url}" 39 | -------------------------------------------------------------------------------- /marrow/mailer/transport/log.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | 4 | __all__ = ['LoggingTransport'] 5 | 6 | log = __import__('logging').getLogger(__name__) 7 | 8 | 9 | 10 | class LoggingTransport(object): 11 | __slots__ = ('ephemeral', 'log') 12 | 13 | def __init__(self, config): 14 | self.log = log if 'name' not in config else __import__('logging').getLogger(config.name) 15 | 16 | def startup(self): 17 | log.debug("Logging transport starting.") 18 | 19 | def deliver(self, message): 20 | msg = str(message) 21 | self.log.info("DELIVER %s %s %d %r %r", message.id, message.date.isoformat(), 22 | len(msg), message.author, message.recipients) 23 | self.log.critical(msg) 24 | 25 | def shutdown(self): 26 | log.debug("Logging transport stopping.") 27 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright © 2006-2023 Alice Bevan-McGregor and contributors. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /marrow/mailer/transport/mbox.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | import mailbox 4 | 5 | 6 | __all__ = ['MailboxTransport'] 7 | 8 | log = __import__('logging').getLogger(__name__) 9 | 10 | 11 | 12 | class MailboxTransport(object): 13 | """A classic UNIX mailbox on-disk file delivery transport. 14 | 15 | Due to the file locking inherent in this format, using a background 16 | delivery mechanism (such as a Futures thread pool) makes no sense. 17 | """ 18 | 19 | __slots__ = ('ephemeral', 'box', 'filename') 20 | 21 | def __init__(self, config): 22 | self.box = None 23 | self.filename = config.get('file', None) 24 | 25 | if not self.filename: 26 | raise ValueError("You must specify an mbox file name to write messages to.") 27 | 28 | def startup(self): 29 | self.box = mailbox.mbox(self.filename) 30 | 31 | def deliver(self, message): 32 | self.box.lock() 33 | self.box.add(mailbox.mboxMessage(str(message))) 34 | self.box.unlock() 35 | 36 | def shutdown(self): 37 | if self.box is None: 38 | return 39 | 40 | self.box.close() 41 | self.box = None 42 | -------------------------------------------------------------------------------- /test/transport/test_mock.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | ''' 4 | 5 | from __future__ import unicode_literals 6 | 7 | import logging 8 | 9 | from unittest import TestCase 10 | from nose.tools import ok_, eq_, raises 11 | from nose.plugins.skip import Skip, SkipTest 12 | 13 | from marrow.mailer import Message 14 | from marrow.mailer.exc import TransportFailedException, TransportExhaustedException 15 | from marrow.mailer.transport.mock import MockTransport 16 | 17 | from marrow.util.bunch import Bunch 18 | 19 | 20 | log = logging.getLogger('tests') 21 | 22 | 23 | 24 | class TestMockTransport(TestCase): 25 | def test_success(self): 26 | transport = MockTransport(dict(success=1.1)) 27 | self.assertTrue(transport.deliver(None)) 28 | 29 | def test_failure(self): 30 | transport = MockTransport(dict(success=0.0)) 31 | self.assertFalse(transport.deliver(None)) 32 | 33 | transport = MockTransport(dict(success=0.0, failure=1.0)) 34 | self.assertRaises(TransportFailedException, transport.deliver, None) 35 | 36 | def test_death(self): 37 | transport = MockTransport(dict()) 38 | self.assertRaises(ZeroDivisionError, transport.deliver, Bunch(die=True)) 39 | 40 | def test_exhaustion(self): 41 | transport = MockTransport(dict(success=0.0, exhaustion=1.0)) 42 | self.assertRaises(TransportExhaustedException, transport.deliver, None) 43 | 44 | ''' 45 | -------------------------------------------------------------------------------- /marrow/mailer/transport/sendmail.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | from subprocess import Popen, PIPE 4 | 5 | from marrow.mailer.exc import MessageFailedException 6 | 7 | 8 | __all__ = ['SendmailTransport'] 9 | 10 | log = __import__('logging').getLogger(__name__) 11 | 12 | 13 | 14 | class SendmailTransport(object): # pragma: no cover 15 | __slots__ = ('ephemeral', 'executable') 16 | 17 | def __init__(self, config): 18 | self.executable = config.get('path', '/usr/sbin/sendmail') 19 | 20 | def startup(self): 21 | pass 22 | 23 | def deliver(self, message): 24 | # TODO: Utilize -F full_name (sender full name), -f sender (envelope sender), -V envid (envelope ID), and space-separated BCC recipients 25 | # TODO: Record the output of STDOUT and STDERR to capture errors. 26 | # proc = Popen('%s -t -i' % (self.executable,), shell=True, stdin=PIPE) 27 | args = [self.executable, '-t', '-i'] 28 | 29 | if getattr(message, 'sendmail_f', None): # May be dynamic property; attribute presence is insufficient. 30 | log.info("sendmail_f : {}".format(message.sendmail_f)) 31 | args.extend(['-f', message.sendmail_f]) 32 | 33 | proc = Popen(args, shell=False, stdin=PIPE) 34 | proc.communicate(bytes(message)) 35 | proc.stdin.close() 36 | if proc.wait() != 0: 37 | raise MessageFailedException("Status code %d." % (proc.returncode, )) 38 | 39 | def shutdown(self): 40 | pass 41 | -------------------------------------------------------------------------------- /test/test_plugins.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | ''' 4 | 5 | from __future__ import unicode_literals 6 | 7 | import logging 8 | import pkg_resources 9 | import pytest 10 | 11 | from unittest import TestCase 12 | 13 | from marrow.mailer.interfaces import IManager, ITransport 14 | 15 | 16 | log = logging.getLogger('tests') 17 | 18 | 19 | 20 | def test_managers(): 21 | def closure(plugin): 22 | try: 23 | plug = plugin.load() 24 | except ImportError as e: 25 | pytest.skip("Skipped {name} manager due to ImportError:\n{err}".format(name=plugin.name, err=str(e))) 26 | 27 | assert isinstance(plug, IManager), "{name} does not conform to the IManager API.".format(name=plugin.name) 28 | 29 | entrypoint = None 30 | for entrypoint in pkg_resources.iter_entry_points('marrow.mailer.manager', None): 31 | yield closure, entrypoint 32 | 33 | if entrypoint is None: 34 | pytest.skip("No managers found, have you run `setup.py develop` yet?") 35 | 36 | 37 | def test_transports(): 38 | def closure(plugin): 39 | try: 40 | plug = plugin.load() 41 | except ImportError as e: 42 | pytest.skip("Skipped {name} transport due to ImportError:\n{err}".format(name=plugin.name, err=str(e))) 43 | 44 | assert isinstance(plug, ITransport), "{name} does not conform to the ITransport API.".format(name=plugin.name) 45 | 46 | entrypoint = None 47 | for entrypoint in pkg_resources.iter_entry_points('marrow.mailer.transport', None): 48 | yield closure, entrypoint 49 | 50 | if entrypoint is None: 51 | pytest.skip("No transports found, have you run `setup.py develop` yet?") 52 | 53 | ''' -------------------------------------------------------------------------------- /marrow/mailer/transport/mailgun.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | try: 4 | import requests 5 | 6 | except ImportError: 7 | raise ImportError("You must install the requests package to deliver mail mailgun.") 8 | 9 | 10 | __all__ = ['MailgunTransport'] 11 | 12 | log = __import__('logging').getLogger(__name__) 13 | 14 | 15 | 16 | class MailgunTransport(object): # pragma: no cover 17 | __slots__ = ('ephemeral', 'keys', 'session') 18 | 19 | API_URL_TMPL = "https://api.mailgun.net/v3/{domain}/messages.mime" 20 | 21 | def __init__(self, config): 22 | if 'domain' in config and 'key' in config: 23 | self.keys = {config['domain']: config['key']} 24 | else: 25 | self.keys = config.get('keys', {}) 26 | 27 | if not self.keys: 28 | raise ValueError("Must either define a `domain` and `key` configuration, or `keys` mapping.") 29 | 30 | self.session = None 31 | 32 | def startup(self): 33 | self.session = requests.Session() 34 | 35 | def deliver(self, message): 36 | domain = message.author.address.rpartition('@')[2] 37 | if domain not in self.keys: 38 | raise Exception("No API key registered for: " + domain) 39 | 40 | uri = self.API_URL_TMPL.format(domain=domain) 41 | 42 | result = self.session.post(uri, auth=('api', self.keys[domain]), 43 | data = {'from': message.author, 'to': list(str(i) for i in message.recipients), 44 | 'subject': message.subject}, 45 | files = {"message": ('message.mime', message.mime.as_bytes())}) 46 | 47 | result.raise_for_status() 48 | 49 | def shutdown(self): 50 | if self.session: 51 | self.session.close() 52 | 53 | self.session = None 54 | -------------------------------------------------------------------------------- /marrow/mailer/transport/gae.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | from google.appengine.api import mail 4 | 5 | 6 | __all__ = ['AppEngineTransport'] 7 | 8 | log = __import__('logging').getLogger(__name__) 9 | 10 | 11 | 12 | class AppEngineTransport(object): # pragma: no cover 13 | __slots__ = ('ephemeral', ) 14 | 15 | def __init__(self, config): 16 | pass 17 | 18 | def startup(self): 19 | pass 20 | 21 | def deliver(self, message): 22 | msg = mail.EmailMessage( 23 | sender = message.author.encode(), 24 | to = [to.encode() for to in message.to], 25 | subject = message.subject, 26 | body = message.plain 27 | ) 28 | 29 | if message.cc: 30 | msg.cc = [cc.encode() for cc in message.cc] 31 | 32 | if message.bcc: 33 | msg.bcc = [bcc.encode() for bcc in message.bcc] 34 | 35 | if message.reply: 36 | msg.reply_to = message.reply.encode() 37 | 38 | if message.rich: 39 | msg.html = message.rich 40 | 41 | if message.attachments: 42 | attachments = [] 43 | 44 | for attachment in message.attachments: 45 | attachments.append(( 46 | attachment['Content-Disposition'].partition(';')[2], 47 | attachment.get_payload(True) 48 | )) 49 | 50 | msg.attachments = attachments 51 | 52 | msg.send() 53 | 54 | def shutdown(self): 55 | pass 56 | -------------------------------------------------------------------------------- /test/transport/test_mbox.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | ''' 4 | 5 | from __future__ import unicode_literals 6 | 7 | import os 8 | import sys 9 | import logging 10 | import mailbox 11 | import tempfile 12 | 13 | from unittest import TestCase 14 | from nose.tools import ok_, eq_, raises 15 | from nose.plugins.skip import Skip, SkipTest 16 | 17 | from marrow.mailer import Message 18 | from marrow.mailer.transport.mbox import MailboxTransport 19 | 20 | 21 | log = logging.getLogger('tests') 22 | 23 | 24 | 25 | class TestMailboxTransport(TestCase): 26 | def setUp(self): 27 | _, self.filename = tempfile.mkstemp('.mbox') 28 | os.close(_) 29 | 30 | self.transport = MailboxTransport(dict(file=self.filename)) 31 | 32 | def tearDown(self): 33 | self.transport.shutdown() 34 | os.unlink(self.filename) 35 | 36 | def test_bad_config(self): 37 | self.assertRaises(ValueError, MailboxTransport, dict()) 38 | 39 | def test_startup(self): 40 | self.transport.startup() 41 | self.assertTrue(isinstance(self.transport.box, mailbox.mbox)) 42 | 43 | def test_shutdown(self): 44 | self.transport.startup() 45 | self.transport.shutdown() 46 | self.assertTrue(self.transport.box is None) 47 | 48 | def test_delivery(self): 49 | message = Message('from@example.com', 'to@example.com', "Test subject.") 50 | message.plain = "Test message." 51 | 52 | self.transport.startup() 53 | self.transport.deliver(message) 54 | 55 | with open(self.filename, 'rb') as fh: 56 | self.assertEqual(str(message), b"\n".join(fh.read().splitlines()[1:])) 57 | 58 | ''' 59 | -------------------------------------------------------------------------------- /marrow/mailer/transport/maildir.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | import mailbox 4 | 5 | 6 | __all__ = ['MaildirTransport'] 7 | 8 | log = __import__('logging').getLogger(__name__) 9 | 10 | 11 | 12 | class MaildirTransport(object): 13 | """A modern UNIX maildir on-disk file delivery transport.""" 14 | 15 | __slots__ = ('ephemeral', 'box', 'directory', 'folder', 'create', 'separator') 16 | 17 | def __init__(self, config): 18 | self.box = None 19 | self.directory = config.get('directory', None) # maildir directory 20 | self.folder = config.get('folder', None) # maildir folder to deliver to 21 | self.create = config.get('create', False) # create folder if missing 22 | self.separator = config.get('separator', '!') 23 | 24 | if not self.directory: 25 | raise ValueError("You must specify the path to a maildir tree to write messages to.") 26 | 27 | def startup(self): 28 | self.box = mailbox.Maildir(self.directory) 29 | 30 | if self.folder: 31 | try: 32 | folder = self.box.get_folder(self.folder) 33 | 34 | except mailbox.NoSuchMailboxError: 35 | if not self.create: # pragma: no cover 36 | raise # TODO: Raise appropraite internal exception. 37 | 38 | folder = self.box.add_folder(self.folder) 39 | 40 | self.box = folder 41 | 42 | self.box.colon = self.separator 43 | 44 | def deliver(self, message): 45 | # TODO: Create an ID based on process and thread IDs. 46 | # Current bhaviour may allow for name clashes in multi-threaded. 47 | self.box.add(mailbox.MaildirMessage(str(message))) 48 | 49 | def shutdown(self): 50 | self.box = None 51 | -------------------------------------------------------------------------------- /marrow/mailer/logger.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | import logging 4 | 5 | from marrow.mailer import Mailer 6 | 7 | 8 | 9 | class MailHandler(logging.Handler): 10 | """A class which sends records out via e-mail. 11 | 12 | This handler should be configured using the same configuration 13 | directives that Marrow Mailer itself understands. 14 | 15 | Be careful how many notifications get sent. 16 | 17 | It is suggested to use background delivery using the 'dynamic' manager. 18 | """ 19 | 20 | def __init__(self, *args, **config): 21 | """Initialize the instance, optionally configuring TurboMail itself. 22 | 23 | If no additional arguments are supplied to the handler, re-use any 24 | existing running TurboMail configuration. 25 | 26 | To get around limitations of the INI parser, you can pass in a tuple 27 | of name, value pairs to populate the dictionary. (Use `{}` dict 28 | notation in production, though.) 29 | """ 30 | 31 | logging.Handler.__init__(self) 32 | 33 | self.config = dict() 34 | 35 | if args: 36 | config.update(dict(zip(*[iter(args)]*2))) 37 | 38 | self.mailer = Mailer(config).start() 39 | 40 | # If we get a configuration that doesn't explicitly start TurboMail 41 | # we use the configuration to populate the Message instance. 42 | self.config = config 43 | 44 | def emit(self, record): 45 | """Emit a record.""" 46 | 47 | try: 48 | self.mailer.new(plain=self.format(record)).send() 49 | 50 | except (KeyboardInterrupt, SystemExit): 51 | raise 52 | 53 | except: 54 | self.handleError(record) 55 | 56 | 57 | logging.MailHandler = MailHandler 58 | -------------------------------------------------------------------------------- /marrow/mailer/transport/imap.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | import imaplib 4 | 5 | from datetime import datetime 6 | 7 | from marrow.mailer.exc import MailConfigurationException, TransportException, MessageFailedException 8 | 9 | 10 | __all__ = ['IMAPTransport'] 11 | 12 | log = __import__('logging').getLogger(__name__) 13 | 14 | 15 | 16 | class IMAPTransport(object): # pragma: no cover 17 | __slots__ = ('ephemeral', 'host', 'ssl', 'port', 'username', 'password', 'folder', 'connection') 18 | 19 | def __init__(self, config): 20 | if not 'host' in config: 21 | raise MailConfigurationException('No server configured for IMAP.') 22 | 23 | self.host = config.get('host', None) 24 | self.ssl = config.get('ssl', False) 25 | self.port = config.get('port', 993 if self.ssl else 143) 26 | self.username = config.get('username', None) 27 | self.password = config.get('password', None) 28 | self.folder = config.get('folder', "INBOX") 29 | 30 | def startup(self): 31 | Protocol = imaplib.IMAP4_SSL if self.ssl else imaplib.IMAP4 32 | self.connection = Protocol(self.host, self.port) 33 | 34 | if self.username: 35 | result = self.connection.login(self.username, self.password) 36 | 37 | if result[0] != b'OK': 38 | raise TransportException("Unable to authenticate with IMAP server.") 39 | 40 | def deliver(self, message): 41 | result = self.connection.append( 42 | self.folder, 43 | '', # TODO: Set message urgency / flagged state. 44 | message.date.timetuple() if message.date else datetime.now(), 45 | bytes(message) 46 | ) 47 | 48 | if result[0] != b'OK': 49 | raise MessageFailedException("\n".join(result[1])) 50 | 51 | def shutdown(self): 52 | self.connection.logout() 53 | -------------------------------------------------------------------------------- /marrow/mailer/transport/ses.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | try: 4 | import boto3 5 | from botocore.exceptions import ClientError 6 | 7 | except ImportError: 8 | raise ImportError("You must install the boto3 package to deliver mail via Amazon SES.") 9 | 10 | 11 | __all__ = ['AmazonTransport'] 12 | 13 | log = __import__('logging').getLogger(__name__) 14 | 15 | 16 | class AmazonTransport(object): # pragma: no cover 17 | __slots__ = ('ephemeral', 'config', 'region', 'connection') 18 | 19 | def __init__(self, config): 20 | # Give our configuration aliases their proper names. 21 | config['aws_access_key_id'] = config.pop('id') 22 | config['aws_secret_access_key'] = config.pop('key') 23 | 24 | self.region = config.pop('region', "us-east-1") 25 | # boto throws an error if we leave this in the next line 26 | config.pop('use') 27 | config.pop('debug') 28 | # All other configuration directives are passed to connect_to_region. 29 | self.config = config 30 | self.connection = None 31 | 32 | def startup(self): 33 | self.connection = boto3.client('ses', region_name=self.region, **self.config) 34 | 35 | def deliver(self, message): 36 | try: 37 | destinations = [str(r) for r in message.recipients] 38 | response = self.connection.send_raw_email( 39 | RawMessage = {'Data': str(message)}, 40 | Source = str(message.author), 41 | Destinations = destinations, 42 | ) 43 | 44 | return ( 45 | response.get('MessageId', 'messageId NOT FOUND'), 46 | response.get('RequestId', {}).get('ResponseMetadata') 47 | ) 48 | 49 | except ClientError as e: 50 | raise # TODO: Raise appropriate internal exception. 51 | # ['status', 'reason', 'body', 'request_id', 'error_code', 'error_message'] 52 | 53 | def shutdown(self): 54 | # if self.connection: 55 | # self.connection.close() 56 | 57 | self.connection = None 58 | -------------------------------------------------------------------------------- /test/transport/test_maildir.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | ''' 4 | 5 | from __future__ import unicode_literals 6 | 7 | import os 8 | import sys 9 | import shutil 10 | import logging 11 | import mailbox 12 | import tempfile 13 | 14 | from unittest import TestCase 15 | from nose.tools import ok_, eq_, raises 16 | from nose.plugins.skip import Skip, SkipTest 17 | 18 | from marrow.mailer import Message 19 | from marrow.mailer.transport.maildir import MaildirTransport 20 | 21 | 22 | log = logging.getLogger('tests') 23 | 24 | 25 | 26 | class TestMailDirectoryTransport(TestCase): 27 | def setUp(self): 28 | self.path = tempfile.mkdtemp() 29 | 30 | for i in ('cur', 'new', 'tmp'): 31 | os.mkdir(os.path.join(self.path, i)) 32 | 33 | self.transport = MaildirTransport(dict(directory=self.path, create=True)) 34 | 35 | def tearDown(self): 36 | self.transport.shutdown() 37 | shutil.rmtree(self.path) 38 | 39 | def test_bad_config(self): 40 | self.assertRaises(ValueError, MaildirTransport, dict()) 41 | 42 | def test_startup(self): 43 | self.transport.startup() 44 | self.assertTrue(isinstance(self.transport.box, mailbox.Maildir)) 45 | 46 | def test_child_folder_startup(self): 47 | self.transport.folder = 'test' 48 | self.transport.startup() 49 | self.assertTrue(os.path.exists(os.path.join(self.path, '.test'))) 50 | 51 | def test_shutdown(self): 52 | self.transport.startup() 53 | self.transport.shutdown() 54 | self.assertTrue(self.transport.box is None) 55 | 56 | def test_delivery(self): 57 | message = Message('from@example.com', 'to@example.com', "Test subject.") 58 | message.plain = "Test message." 59 | 60 | self.transport.startup() 61 | self.transport.deliver(message) 62 | 63 | filename = os.listdir(os.path.join(self.path, 'new'))[0] 64 | 65 | with open(os.path.join(self.path, 'new', filename), 'rb') as fh: 66 | self.assertEqual(str(message), fh.read()) 67 | 68 | ''' 69 | -------------------------------------------------------------------------------- /test/transport/test_log.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | ''' 4 | 5 | import logging 6 | 7 | from unittest import TestCase 8 | 9 | from marrow.mailer import Message 10 | from marrow.mailer.transport.log import LoggingTransport 11 | 12 | 13 | log = logging.getLogger('tests') 14 | 15 | 16 | 17 | class TestLoggingTransport(TestCase): 18 | def setUp(self): 19 | self.messages = logging.getLogger().handlers[0].buffer 20 | del self.messages[:] 21 | 22 | self.transport = LoggingTransport(dict()) 23 | self.transport.startup() 24 | 25 | def tearDown(self): 26 | self.transport.shutdown() 27 | 28 | def test_startup(self): 29 | self.assertEqual(len(self.messages), 1) 30 | self.assertEqual(self.messages[0].getMessage(), "Logging transport starting.") 31 | self.assertEqual(self.messages[0].levelname, 'DEBUG') 32 | 33 | def test_shutdown(self): 34 | self.transport.shutdown() 35 | 36 | self.assertEqual(len(self.messages), 2) 37 | self.assertEqual(self.messages[0].getMessage(), "Logging transport starting.") 38 | self.assertEqual(self.messages[1].getMessage(), "Logging transport stopping.") 39 | self.assertEqual(self.messages[1].levelname, 'DEBUG') 40 | 41 | def test_delivery(self): 42 | self.assertEqual(len(self.messages), 1) 43 | 44 | message = Message('from@example.com', 'to@example.com', 'Subject.', plain='Body.') 45 | msg = str(message) 46 | 47 | self.transport.deliver(message) 48 | self.assertEqual(len(self.messages), 3) 49 | 50 | expect = "DELIVER %s %s %d %r %r" % (message.id, message.date.isoformat(), 51 | len(msg), message.author, message.recipients) 52 | 53 | self.assertEqual(self.messages[0].getMessage(), "Logging transport starting.") 54 | self.assertEqual(self.messages[1].getMessage(), expect) 55 | self.assertEqual(self.messages[1].levelname, 'INFO') 56 | self.assertEqual(self.messages[2].getMessage(), str(message)) 57 | self.assertEqual(self.messages[2].levelname, 'CRITICAL') 58 | 59 | ''' 60 | -------------------------------------------------------------------------------- /marrow/mailer/transport/mock.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | import random 4 | 5 | from marrow.mailer.exc import TransportFailedException, TransportExhaustedException 6 | 7 | from marrow.util.bunch import Bunch 8 | 9 | 10 | __all__ = ['MockTransport'] 11 | 12 | log = __import__('logging').getLogger(__name__) 13 | 14 | 15 | 16 | class MockTransport(object): 17 | """A no-op dummy transport. 18 | 19 | Accepts two configuration directives: 20 | 21 | * success - probability of successful delivery 22 | * failure - probability of failure 23 | * exhaustion - probability of exhaustion 24 | 25 | All are represented as percentages between 0.0 and 1.0, inclusive. 26 | (Setting failure or exhaustion to 1.0 means every delivery will fail 27 | badly; do not do this except under controlled, unit testing scenarios!) 28 | """ 29 | 30 | __slots__ = ('ephemeral', 'config') 31 | 32 | def __init__(self, config): 33 | base = {'success': 1.0, 'failure': 0.0, 'exhaustion': 0.0} 34 | base.update(dict(config)) 35 | self.config = Bunch(base) 36 | 37 | def startup(self): 38 | pass 39 | 40 | def deliver(self, message): 41 | """Concrete message delivery.""" 42 | 43 | config = self.config 44 | success = config.success 45 | failure = config.failure 46 | exhaustion = config.exhaustion 47 | 48 | if getattr(message, 'die', False): 49 | 1/0 50 | 51 | if failure: 52 | chance = random.randint(0,100001) / 100000.0 53 | if chance < failure: 54 | raise TransportFailedException("Mock failure.") 55 | 56 | if exhaustion: 57 | chance = random.randint(0,100001) / 100000.0 58 | if chance < exhaustion: 59 | raise TransportExhaustedException("Mock exhaustion.") 60 | 61 | if success == 1.0: 62 | return True 63 | 64 | chance = random.randint(0,100001) / 100000.0 65 | if chance <= success: 66 | return True 67 | 68 | return False 69 | 70 | def shutdown(self): 71 | pass 72 | -------------------------------------------------------------------------------- /marrow/mailer/manager/immediate.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | from marrow.mailer.exc import TransportExhaustedException, TransportFailedException, DeliveryFailedException, MessageFailedException 4 | from marrow.mailer.manager.util import TransportPool 5 | 6 | 7 | __all__ = ['ImmediateManager'] 8 | 9 | log = __import__('logging').getLogger(__name__) 10 | 11 | 12 | 13 | class ImmediateManager(object): 14 | __slots__ = ('transport', ) 15 | 16 | def __init__(self, config, Transport): 17 | """Initialize the immediate delivery manager.""" 18 | 19 | # Create a transport pool; this will encapsulate the recycling logic. 20 | self.transport = TransportPool(Transport) 21 | 22 | super(ImmediateManager, self).__init__() 23 | 24 | def startup(self): 25 | """Perform startup actions. 26 | 27 | This just chains down to the transport layer. 28 | """ 29 | 30 | log.info("Immediate delivery manager starting.") 31 | 32 | log.debug("Initializing transport queue.") 33 | self.transport.startup() 34 | 35 | log.info("Immediate delivery manager started.") 36 | 37 | def deliver(self, message): 38 | result = None 39 | 40 | while True: 41 | with self.transport() as transport: 42 | try: 43 | result = transport.deliver(message) 44 | 45 | except MessageFailedException as e: 46 | raise DeliveryFailedException(message, e.args[0] if e.args else "No reason given.") 47 | 48 | except TransportFailedException: 49 | # The transport has suffered an internal error or has otherwise 50 | # requested to not be recycled. Delivery should be attempted 51 | # again. 52 | transport.ephemeral = True 53 | continue 54 | 55 | except TransportExhaustedException: 56 | # The transport sent the message, but pre-emptively 57 | # informed us that future attempts will not be successful. 58 | transport.ephemeral = True 59 | 60 | break 61 | 62 | return message, result 63 | 64 | def shutdown(self): 65 | log.info("Immediate delivery manager stopping.") 66 | 67 | log.debug("Draining transport queue.") 68 | self.transport.shutdown() 69 | 70 | log.info("Immediate delivery manager stopped.") 71 | -------------------------------------------------------------------------------- /marrow/mailer/manager/transactional.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | """Currently unsupported and non-functional.""" 4 | 5 | raise ImportError("This module is currently unsupported.") 6 | 7 | 8 | import transaction 9 | 10 | from functools import partial 11 | 12 | from zope.interface import implements 13 | from transaction.interfaces import IDataManager 14 | 15 | from marrow.mailer.manager.dynamic import ScalingPoolExecutor, DynamicManager 16 | 17 | 18 | __all__ = ['TransactionalDynamicManager'] 19 | 20 | log = __import__('logging').getLogger(__name__) 21 | 22 | 23 | 24 | class ExecutorDataManager(object): 25 | implements(IDataManager) 26 | 27 | __slots__ = ('callback', 'abort_callback') 28 | 29 | def __init__(self, callback, abort=None, pool=None): 30 | self.callback = callback 31 | self.abort_callback = abort 32 | 33 | def commit(self, transaction): 34 | pass 35 | 36 | def abort(self, transaction): 37 | if self.abort_callback: 38 | self.abort_callback() 39 | 40 | def sortKey(self): 41 | return id(self) 42 | 43 | def abort_sub(self, transaction): 44 | pass 45 | 46 | commit_sub = abort_sub 47 | 48 | def beforeCompletion(self, transaction): 49 | pass 50 | 51 | afterCompletion = beforeCompletion 52 | 53 | def tpc_begin(self, transaction, subtransaction=False): 54 | if subtransaction: 55 | raise RuntimeError() 56 | 57 | def tpc_vote(self, transaction): 58 | pass 59 | 60 | def tpc_finish(self, transaction): 61 | self.callback() 62 | 63 | tpc_abort = abort 64 | 65 | 66 | class TransactionalScalingPoolExecutor(ScalingPoolExecutor): 67 | def _submit(self, w): 68 | self._work_queue.put(w) 69 | self._adjust_thread_count() 70 | 71 | def _cancel_tn(self, f): 72 | if f.cancel(): 73 | f.set_running_or_notify_cancel() 74 | 75 | def submit(self, fn, *args, **kwargs): 76 | with self._shutdown_lock: 77 | if self._shutdown: 78 | raise RuntimeError('cannot schedule new futures after shutdown') 79 | 80 | f = _base.Future() 81 | w = _WorkItem(f, fn, args, kwargs) 82 | 83 | dm = ExecutorDataManager(partial(self._submit, w), partial(self._cancel_tn, f)) 84 | transaction.get().join(dm) 85 | 86 | return f 87 | 88 | 89 | class TransactionalDynamicManager(DynamicManager): 90 | name = "Transactional dynamic" 91 | Executor = TransactionalScalingPoolExecutor 92 | -------------------------------------------------------------------------------- /marrow/mailer/exc.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | """Exceptions used by marrow.mailer to report common errors.""" 4 | 5 | 6 | __all__ = [ 7 | 'MailException', 8 | 'MailConfigurationException', 9 | 'TransportException', 10 | 'TransportFailedException', 11 | 'MessageFailedException', 12 | 'TransportExhaustedException', 13 | 'ManagerException' 14 | ] 15 | 16 | 17 | 18 | class MailException(Exception): 19 | """The base for all marrow.mailer exceptions.""" 20 | 21 | pass 22 | 23 | 24 | # Application Exceptions 25 | 26 | class DeliveryException(MailException): 27 | """The base class for all public-facing exceptions.""" 28 | 29 | pass 30 | 31 | 32 | class DeliveryFailedException(DeliveryException): 33 | """The message stored in args[0] could not be delivered for the reason 34 | given in args[1]. (These can be accessed as e.msg and e.reason.)""" 35 | 36 | def __init__(self, message, reason): 37 | self.msg = message 38 | self.reason = reason 39 | 40 | super(DeliveryFailedException, self).__init__(message, reason) 41 | 42 | 43 | # Internal Exceptions 44 | 45 | class MailerNotRunning(MailException): 46 | """Raised when attempting to deliver messages using a dead interface.""" 47 | 48 | pass 49 | 50 | 51 | class MailConfigurationException(MailException): 52 | """There was an error in the configuration of marrow.mailer.""" 53 | 54 | pass 55 | 56 | 57 | class TransportException(MailException): 58 | """The base for all marrow.mailer Transport exceptions.""" 59 | 60 | pass 61 | 62 | 63 | class TransportFailedException(TransportException): 64 | """The transport has failed to deliver the message due to an internal 65 | error; a new instance of the transport should be used to retry.""" 66 | 67 | pass 68 | 69 | 70 | class MessageFailedException(TransportException): 71 | """The transport has failed to deliver the message due to a problem with 72 | the message itself, and no attempt should be made to retry delivery of 73 | this message. The transport may still be re-used, however. 74 | 75 | The reason for the failure should be the first argument. 76 | """ 77 | 78 | pass 79 | 80 | 81 | class TransportExhaustedException(TransportException): 82 | """The transport has successfully delivered the message, but can no longer 83 | be used for future message delivery; a new instance should be used on the 84 | next request.""" 85 | 86 | pass 87 | 88 | 89 | class ManagerException(MailException): 90 | """The base for all marrow.mailer Manager exceptions.""" 91 | pass 92 | -------------------------------------------------------------------------------- /marrow/mailer/manager/util.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | try: 4 | import queue 5 | except ImportError: 6 | import Queue as queue 7 | 8 | 9 | __all__ = ['TransportPool'] 10 | 11 | log = __import__('logging').getLogger(__name__) 12 | 13 | 14 | 15 | class TransportPool(object): 16 | __slots__ = ('factory', 'transports') 17 | 18 | def __init__(self, factory): 19 | self.factory = factory 20 | self.transports = queue.Queue() 21 | 22 | def startup(self): 23 | pass 24 | 25 | def shutdown(self): 26 | try: 27 | while True: 28 | transport = self.transports.get(False) 29 | transport.shutdown() 30 | 31 | except queue.Empty: 32 | pass 33 | 34 | class Context(object): 35 | __slots__ = ('pool', 'transport') 36 | 37 | def __init__(self, pool): 38 | self.pool = pool 39 | self.transport = None 40 | 41 | def __enter__(self): 42 | # First we attempt to find an available transport. 43 | pool = self.pool 44 | transport = None 45 | 46 | while not transport: 47 | try: 48 | # By consuming transports this way, we maintain thread safety. 49 | # Transports are only accessed by a single thread at a time. 50 | transport = pool.transports.get(False) 51 | log.debug("Acquired existing transport instance.") 52 | 53 | except queue.Empty: 54 | # No transport is available, so we initialize another one. 55 | log.debug("Unable to acquire existing transport, initalizing new instance.") 56 | transport = pool.factory() 57 | transport.startup() 58 | 59 | self.transport = transport 60 | return transport 61 | 62 | def __exit__(self, type, value, traceback): 63 | transport = self.transport 64 | ephemeral = getattr(transport, 'ephemeral', False) 65 | 66 | if type is not None: 67 | log.error("Shutting down transport due to unhandled exception.", exc_info=True) 68 | transport.shutdown() 69 | return 70 | 71 | if not ephemeral: 72 | log.debug("Scheduling transport instance for re-use.") 73 | self.pool.transports.put(transport) 74 | 75 | else: 76 | log.debug("Transport marked as ephemeral, shutting down instance.") 77 | transport.shutdown() 78 | 79 | def __call__(self): 80 | return self.Context(self) 81 | -------------------------------------------------------------------------------- /marrow/mailer/transport/postmark.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import urllib2 4 | import json 5 | import base64 6 | 7 | from marrow.mailer.exc import DeliveryFailedException, MessageFailedException 8 | 9 | __all__ = ['PostmarkTransport'] 10 | 11 | log = __import__('logging').getLogger(__name__) 12 | 13 | 14 | class PostmarkTransport(object): 15 | __slots__ = ('ephemeral', 'key', 'messages') 16 | 17 | def __init__(self, config): 18 | self.key = config.get('key') 19 | self.messages = [] 20 | 21 | def _mapmessage(self, message): 22 | args = dict({ 23 | 'From': message.author.encode(message.encoding), 24 | 'To': message.to.encode(message.encoding), 25 | 'Subject': message.subject.encode(message.encoding), 26 | 'TextBody': message.plain.encode(message.encoding), 27 | }) 28 | 29 | if message.cc: 30 | args['Cc'] = message.cc.encode() 31 | 32 | if message.bcc: 33 | args['Bcc'] = message.bcc.encode(message.encoding) 34 | 35 | if message.reply: 36 | args['ReplyTo'] = message.reply.encode(message.encoding) 37 | 38 | if message.rich: 39 | args['HtmlBody'] = message.rich.encode(message.encoding) 40 | 41 | if message.attachments: 42 | args['Attachments'] = [] 43 | 44 | for attachment in message.attachments: 45 | args['Attachments'].append( 46 | { 47 | "Name": attachment.get_filename(), 48 | "Content": base64.b64encode(attachment.get_payload(decode=True)), 49 | "ContentType": attachment.get_content_type() 50 | } 51 | ) 52 | 53 | return args 54 | 55 | def _batchsend(self): 56 | request = urllib2.Request( 57 | "https://api.postmarkapp.com/email/batch", 58 | json.dumps(self.messages), 59 | { 60 | 'Accept': "application/json", 61 | 'Content-Type': "application/json", 62 | 'X-Postmark-Server-Token': self.key, 63 | } 64 | ) 65 | 66 | try: 67 | response = urllib2.urlopen(request) 68 | except (urllib2.HTTPError, urllib2.URLError) as e: 69 | raise DeliveryFailedException(e, "Could not connect to Postmark.") 70 | else: 71 | respcode = response.getcode() 72 | if respcode >= 400 and respcode <= 499: 73 | raise MessageFailedException(response.read()) 74 | elif respcode >= 500 and respcode <= 599: 75 | raise DeliveryFailedException(self.messages[0], "Postmark service unavailable. Just diplaying first message of batch") 76 | 77 | del self.messages[:] 78 | 79 | def startup(self): 80 | self.messages = [] 81 | 82 | def deliver(self, message): 83 | if len(self.messages) >= 500: 84 | # Postmark allows to send a maximum of 500 emails over its batch API 85 | self._batchsend() 86 | 87 | args = self._mapmessage(message) 88 | self.messages.append(args) 89 | 90 | def shutdown(self): 91 | self._batchsend() 92 | -------------------------------------------------------------------------------- /marrow/mailer/manager/futures.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | from functools import partial 4 | 5 | from marrow.mailer.exc import TransportFailedException, TransportExhaustedException, MessageFailedException, DeliveryFailedException 6 | from marrow.mailer.manager.util import TransportPool 7 | 8 | try: 9 | from concurrent import futures 10 | except ImportError: # pragma: no cover 11 | raise ImportError("You must install the futures package to use background delivery.") 12 | 13 | 14 | __all__ = ['FuturesManager'] 15 | 16 | log = __import__('logging').getLogger(__name__) 17 | 18 | 19 | 20 | def worker(pool, message): 21 | # This may be non-obvious, but there are several conditions which 22 | # we trap later that require us to retry the entire delivery. 23 | result = None 24 | 25 | while True: 26 | with pool() as transport: 27 | try: 28 | result = transport.deliver(message) 29 | 30 | except MessageFailedException as e: 31 | raise DeliveryFailedException(message, e.args[0] if e.args else "No reason given.") 32 | 33 | except TransportFailedException: 34 | # The transport has suffered an internal error or has otherwise 35 | # requested to not be recycled. Delivery should be attempted 36 | # again. 37 | transport.ephemeral = True 38 | continue 39 | 40 | except TransportExhaustedException: 41 | # The transport sent the message, but pre-emptively 42 | # informed us that future attempts will not be successful. 43 | transport.ephemeral = True 44 | 45 | break 46 | 47 | return message, result 48 | 49 | 50 | 51 | class FuturesManager(object): 52 | __slots__ = ('workers', 'executor', 'transport') 53 | 54 | def __init__(self, config, transport): 55 | self.workers = config.get('workers', 1) 56 | 57 | self.executor = None 58 | self.transport = TransportPool(transport) 59 | 60 | super(FuturesManager, self).__init__() 61 | 62 | def startup(self): 63 | log.info("Futures delivery manager starting.") 64 | 65 | log.debug("Initializing transport queue.") 66 | self.transport.startup() 67 | 68 | workers = self.workers 69 | log.debug("Starting thread pool with %d workers." % (workers, )) 70 | self.executor = futures.ThreadPoolExecutor(workers) 71 | 72 | log.info("Futures delivery manager ready.") 73 | 74 | def deliver(self, message): 75 | # Return the Future object so the application can register callbacks. 76 | # We pass the message so the executor can do what it needs to to make 77 | # the message thread-local. 78 | return self.executor.submit(partial(worker, self.transport), message) 79 | 80 | def shutdown(self, wait=True): 81 | log.info("Futures delivery manager stopping.") 82 | 83 | log.debug("Stopping thread pool.") 84 | self.executor.shutdown(wait=wait) 85 | 86 | log.debug("Draining transport queue.") 87 | self.transport.shutdown() 88 | 89 | log.info("Futures delivery manager stopped.") 90 | -------------------------------------------------------------------------------- /marrow/mailer/testing.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | """Utilities for testing Marrow Mailer and applications that use it.""" 4 | 5 | from __future__ import print_function 6 | 7 | from threading import Thread 8 | from socket import socket 9 | from threading import Event, RLock 10 | from datetime import datetime 11 | from collections import namedtuple, deque 12 | from smtpd import SMTPServer 13 | from email.parser import Parser 14 | from asyncore import loop 15 | 16 | try: 17 | from pytest import fixture 18 | except: # We don't honestly care if pytest is installed. 19 | def fixture(fn): 20 | return fn 21 | 22 | 23 | TestMessage = namedtuple('TestMessage', ('sender', 'recipients', 'time', 'message', 'raw')) 24 | 25 | 26 | class DebuggingSMTPServer(SMTPServer, Thread): 27 | """A generalized testing SMTP server that captures messages delivered to it.""" 28 | 29 | POLL_TIMEOUT = 0.001 30 | 31 | def __init__(self, host='127.0.0.1', port=2526): 32 | # Initialize the SMTP component. 33 | # My face that asyncore doesn't use new style classes! 34 | SMTPServer.__init__(self, (host, port), None) 35 | 36 | # Retrieve the actually-bound socket address. May, in some circumstances, use a reverse DNS name. 37 | if self._localaddr[1] == 0: 38 | self.address = self.socket.getsockname() 39 | else: 40 | self.address = (host, port) 41 | 42 | # Create a place to store messages. 43 | self.messages = deque() 44 | 45 | # Setup threading. 46 | self._stop = Event() 47 | self._lock = RLock() 48 | Thread.__init__(self, name=self.__class__.__name__) 49 | 50 | @classmethod 51 | def main(cls): 52 | server = cls() 53 | 54 | print("Debugging SMTP server is running on ", server.address[0], ":", server.address[1], sep="") 55 | print("Press Control+C to stop.") 56 | 57 | try: 58 | loop() 59 | except KeyboardInterrupt: 60 | pass 61 | 62 | def process_message(self, peer, sender, recipients, data): 63 | # We construct a helpful namedtuple with all of the relevant delivery details. 64 | message = TestMessage(sender, recipients, datetime.utcnow(), Parser().parsestr(data), data) 65 | 66 | with self._lock: # Protect against parallel access. 67 | self.messages.append(message) 68 | 69 | def run(self): 70 | while not self._stop.is_set(): 71 | loop(timeout=self.POLL_TIMEOUT, count=1) 72 | 73 | def stop(self, timeout=None): 74 | self._stop.set() 75 | self.join(timeout) 76 | self.close() 77 | 78 | def __getitem__(self, i): 79 | return self.messages.__getitem__(i) 80 | 81 | def __len__(self): 82 | return len(self.messages) 83 | 84 | def __iter__(self): 85 | return iter(self.messages) 86 | 87 | def drain(self): 88 | with self._lock: # Protect against parallel access. 89 | self.messages.clear() 90 | 91 | def next(self): 92 | with self._lock: # Protect against parallel access. 93 | return self.messages.popleft() 94 | 95 | 96 | 97 | @fixture(scope='session') 98 | def smtp(request): 99 | # TODO: Identify a random port number that is available. 100 | 101 | # Construct the debugging server instance. 102 | server = DebuggingSMTPServer() 103 | server.start() 104 | 105 | request.add_finalizer(server.stop) 106 | 107 | return server 108 | 109 | 110 | 111 | if __name__ == '__main__': # pragma: no cover 112 | DebuggingSMTPServer.main() 113 | -------------------------------------------------------------------------------- /marrow/mailer/transport/sendgrid.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import urllib 4 | import urllib2 5 | 6 | from marrow.mailer.exc import MailConfigurationException, DeliveryFailedException, MessageFailedException 7 | 8 | __all__ = ['SendgridTransport'] 9 | 10 | log = __import__('logging').getLogger(__name__) 11 | 12 | 13 | class SendgridTransport(object): 14 | __slots__ = ('ephemeral', 'user', 'key', 'bearer') 15 | 16 | def __init__(self, config): 17 | self.bearer = False 18 | if not 'user' in config: 19 | self.bearer = True 20 | else: 21 | self.user = config.get('user') 22 | self.key = config.get('key') 23 | 24 | def startup(self): 25 | pass 26 | 27 | def deliver(self, message): 28 | 29 | to = message.to 30 | 31 | # Sendgrid doesn't accept CC over the api 32 | if message.cc: 33 | to.extend(message.cc) 34 | 35 | args = dict({ 36 | 'from': [fromaddr.address.encode(message.encoding) for fromaddr in message.author], 37 | 'fromname': [fromaddr.name.encode(message.encoding) for fromaddr in message.author], 38 | 'to': [toaddr.address.encode(message.encoding) for toaddr in to], 39 | 'toname': [toaddr.name.encode(message.encoding) for toaddr in to], 40 | 'subject': message.subject.encode(message.encoding), 41 | 'text': message.plain.encode(message.encoding) 42 | }) 43 | 44 | if message.bcc: 45 | args['bcc'] = [bcc.address.encode(message.encoding) for bcc in message.bcc] 46 | 47 | if message.reply: 48 | args['replyto'] = message.reply.encode(message.encoding) 49 | 50 | if message.rich: 51 | args['html'] = message.rich.encode(message.encoding) 52 | 53 | if message.attachments: 54 | # Not implemented yet 55 | """ 56 | attachments = [] 57 | 58 | for attachment in message.attachments: 59 | attachments.append(( 60 | attachment['Content-Disposition'].partition(';')[2], 61 | attachment.get_payload(True) 62 | )) 63 | 64 | msg.attachments = attachments 65 | """ 66 | raise MailConfigurationException() 67 | 68 | if not self.bearer: 69 | args['api_user'] = self.user 70 | args['api_key'] = self.key 71 | 72 | request = urllib2.Request( 73 | "https://sendgrid.com/api/mail.send.json", 74 | urllib.urlencode(args, True) 75 | ) 76 | 77 | if self.bearer: 78 | request.add_header("Authorization", "Bearer %s" % self.key) 79 | 80 | try: 81 | response = urllib2.urlopen(request) 82 | except (urllib2.HTTPError, urllib2.URLError): 83 | raise DeliveryFailedException(message, "Could not connect to Sendgrid.") 84 | else: 85 | respcode = response.getcode() 86 | 87 | if respcode >= 400 and respcode <= 499: 88 | raise MessageFailedException(response.read()) 89 | elif respcode >= 500 and respcode <= 599: 90 | raise DeliveryFailedException(message, "Sendgrid service unavailable.") 91 | 92 | response.close() 93 | 94 | def shutdown(self): 95 | pass 96 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | 4 | import os 5 | import sys 6 | 7 | try: 8 | from setuptools.core import setup, find_packages 9 | except ImportError: 10 | from setuptools import setup, find_packages 11 | 12 | if sys.version_info < (2, 6): 13 | raise SystemExit("Python 2.6 later is required.") 14 | 15 | elif sys.version_info > (3, 0) and sys.version_info < (3, 2): 16 | raise SystemExit("Python 3.2 or later is required.") 17 | 18 | 19 | version = description = url = author = "" # Satisfy linter. 20 | exec(open(os.path.join("marrow", "mailer", "release.py")).read()) 21 | 22 | here = os.path.abspath(os.path.dirname(__file__)) 23 | 24 | tests_require = ['pytest', 'pytest-cov', 'pytest-spec', 'pytest-flakes', 'coverage', 'transaction'] 25 | 26 | 27 | # # Entry Point 28 | 29 | setup( 30 | name = "marrow.mailer", 31 | version = version, 32 | 33 | description = description, 34 | long_description = "", # codecs.open(os.path.join(here, 'README.rst'), 'r', 'utf8').read(), 35 | url = url, 36 | 37 | author = author.name, 38 | author_email = author.email, 39 | 40 | license = 'MIT', 41 | keywords = '', 42 | classifiers = [ 43 | "Development Status :: 5 - Production/Stable", 44 | "Environment :: Console", 45 | "Intended Audience :: Developers", 46 | "License :: OSI Approved :: MIT License", 47 | "Operating System :: OS Independent", 48 | "Programming Language :: Python", 49 | "Programming Language :: Python :: 2.6", 50 | "Programming Language :: Python :: 2.7", 51 | "Programming Language :: Python :: 3.3", 52 | "Programming Language :: Python :: 3.4", 53 | "Programming Language :: Python :: 3.5", 54 | "Topic :: Software Development :: Libraries :: Python Modules", 55 | "Topic :: Utilities", 56 | ], 57 | 58 | packages = find_packages(exclude=['example', 'test', 'test.*']), 59 | include_package_data = True, 60 | package_data = {'': ['README.textile', 'LICENSE.txt']}, 61 | namespace_packages = ['marrow'], 62 | 63 | # ## Dependency Declaration 64 | 65 | install_requires = [ 66 | 'marrow.util < 2.0', 67 | ], 68 | 69 | extras_require = { 70 | ":python_version<'3.0.0'": ['futures'], 71 | 'develop': tests_require, 72 | 'requests': ['requests'], 73 | }, 74 | 75 | tests_require = tests_require, 76 | 77 | setup_requires = ['pytest-runner'] if {'pytest', 'test', 'ptr'}.intersection(sys.argv) else [], 78 | 79 | # ## Plugin Registration 80 | 81 | entry_points = { 82 | 'marrow.mailer.manager': [ 83 | 'immediate = marrow.mailer.manager.immediate:ImmediateManager', 84 | 'futures = marrow.mailer.manager.futures:FuturesManager', 85 | 'dynamic = marrow.mailer.manager.dynamic:DynamicManager', 86 | # 'transactional = marrow.mailer.manager.transactional:TransactionalDynamicManager' 87 | ], 88 | 'marrow.mailer.transport': [ 89 | 'amazon = marrow.mailer.transport.ses:AmazonTransport', 90 | 'mock = marrow.mailer.transport.mock:MockTransport', 91 | 'smtp = marrow.mailer.transport.smtp:SMTPTransport', 92 | 'mbox = marrow.mailer.transport.mbox:MailboxTransport', 93 | 'mailbox = marrow.mailer.transport.mbox:MailboxTransport', 94 | 'maildir = marrow.mailer.transport.maildir:MaildirTransport', 95 | 'sendmail = marrow.mailer.transport.sendmail:SendmailTransport', 96 | 'imap = marrow.mailer.transport.imap:IMAPTransport', 97 | 'appengine = marrow.mailer.transport.gae:AppEngineTransport', 98 | 'logging = marrow.mailer.transport.log:LoggingTransport', 99 | 'postmark = marrow.mailer.transport.postmark:PostmarkTransport', 100 | 'sendgrid = marrow.mailer.transport.sendgrid:SendgridTransport', 101 | 'mailgun = marrow.mailer.transport.mailgun:MailgunTransport[requests]', 102 | ] 103 | }, 104 | 105 | zip_safe = False, 106 | ) 107 | -------------------------------------------------------------------------------- /test/manager/test_immediate.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | ''' 4 | 5 | from __future__ import unicode_literals 6 | 7 | import logging 8 | import pkg_resources 9 | 10 | from functools import partial 11 | from unittest import TestCase 12 | from nose.tools import ok_, eq_, raises 13 | from nose.plugins.skip import Skip, SkipTest 14 | 15 | from marrow.mailer.exc import TransportExhaustedException, TransportFailedException, DeliveryFailedException, MessageFailedException 16 | from marrow.mailer.manager.immediate import ImmediateManager 17 | 18 | 19 | log = logging.getLogger('tests') 20 | 21 | 22 | 23 | class ManagerTestCase(TestCase): 24 | manager = None 25 | config = dict() 26 | states = [] 27 | messages = [] 28 | 29 | class MockTransport(object): 30 | def __init__(self, states, messages): 31 | self.ephemeral = False 32 | self.states = states 33 | self.messages = messages 34 | 35 | def startup(self): 36 | self.states.append('running') 37 | 38 | def deliver(self, message): 39 | self.messages.append(message) 40 | 41 | if isinstance(message, Exception) and ( len(self.messages) < 2 or self.messages[-2] is not message): 42 | raise message 43 | 44 | def shutdown(self): 45 | self.states.append('stopped') 46 | 47 | def setUp(self): 48 | self.manager = ImmediateManager(self.config, partial(self.MockTransport, self.states, self.messages)) 49 | 50 | def tearDown(self): 51 | del self.states[:] 52 | del self.messages[:] 53 | 54 | 55 | class TestImmediateManager(ManagerTestCase): 56 | manager = ImmediateManager 57 | 58 | def test_startup(self): 59 | # TODO: Test logging messages. 60 | self.manager.startup() 61 | self.assertEquals(self.states, []) 62 | 63 | def test_shutdown(self): 64 | # TODO: Test logging messages. 65 | self.manager.startup() 66 | self.manager.shutdown() 67 | self.assertEquals(self.states, []) 68 | 69 | def test_success(self): 70 | self.manager.startup() 71 | 72 | self.manager.deliver("success") 73 | 74 | self.assertEquals(self.states, ["running"]) 75 | self.assertEquals(self.messages, ["success"]) 76 | 77 | self.manager.shutdown() 78 | self.assertEquals(self.states, ["running", "stopped"]) 79 | 80 | def test_message_failure(self): 81 | self.manager.startup() 82 | 83 | exc = MessageFailedException() 84 | 85 | self.assertRaises(DeliveryFailedException, self.manager.deliver, exc) 86 | 87 | self.assertEquals(self.states, ['running', 'stopped']) 88 | self.assertEquals(self.messages, [exc]) 89 | 90 | self.manager.shutdown() 91 | self.assertEquals(self.states, ['running', 'stopped']) 92 | 93 | def test_transport_failure(self): 94 | self.manager.startup() 95 | 96 | exc = TransportFailedException() 97 | 98 | self.manager.deliver(exc) 99 | 100 | self.assertEquals(self.states, ['running', 'stopped', 'running']) 101 | self.assertEquals(self.messages, [exc, exc]) 102 | 103 | self.manager.shutdown() 104 | self.assertEquals(self.states, ['running', 'stopped', 'running', 'stopped']) 105 | 106 | def test_transport_exhaustion(self): 107 | self.manager.startup() 108 | 109 | exc = TransportExhaustedException() 110 | 111 | self.manager.deliver(exc) 112 | 113 | self.assertEquals(self.states, ['running', 'stopped']) 114 | self.assertEquals(self.messages, [exc]) 115 | 116 | self.manager.shutdown() 117 | self.assertEquals(self.states, ['running', 'stopped']) 118 | 119 | ''' 120 | -------------------------------------------------------------------------------- /test/manager/test_futures.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | ''' 4 | 5 | from __future__ import unicode_literals 6 | 7 | import logging 8 | import pkg_resources 9 | 10 | from functools import partial 11 | from unittest import TestCase 12 | from nose.tools import ok_, eq_, raises 13 | from nose.plugins.skip import Skip, SkipTest 14 | 15 | from marrow.mailer.exc import TransportExhaustedException, TransportFailedException, DeliveryFailedException, MessageFailedException 16 | from marrow.mailer.manager.futures import FuturesManager 17 | 18 | 19 | log = logging.getLogger('tests') 20 | 21 | 22 | 23 | class ManagerTestCase(TestCase): 24 | manager = None 25 | config = dict() 26 | states = [] 27 | messages = [] 28 | 29 | class MockTransport(object): 30 | def __init__(self, states, messages): 31 | self.ephemeral = False 32 | self.states = states 33 | self.messages = messages 34 | 35 | def startup(self): 36 | self.states.append('running') 37 | 38 | def deliver(self, message): 39 | self.messages.append(message) 40 | 41 | if isinstance(message, Exception) and ( len(self.messages) < 2 or self.messages[-2] is not message): 42 | raise message 43 | 44 | def shutdown(self): 45 | self.states.append('stopped') 46 | 47 | def setUp(self): 48 | self.manager = self.manager(self.config, partial(self.MockTransport, self.states, self.messages)) 49 | 50 | def tearDown(self): 51 | del self.states[:] 52 | del self.messages[:] 53 | 54 | 55 | class TestImmediateManager(ManagerTestCase): 56 | manager = FuturesManager 57 | 58 | def test_startup(self): 59 | # TODO: Test logging messages. 60 | self.manager.startup() 61 | self.assertEquals(self.states, []) 62 | 63 | def test_shutdown(self): 64 | # TODO: Test logging messages. 65 | self.manager.startup() 66 | self.manager.shutdown() 67 | self.assertEquals(self.states, []) 68 | 69 | def test_success(self): 70 | self.manager.startup() 71 | 72 | self.manager.deliver("success") 73 | 74 | self.assertEquals(self.states, ["running"]) 75 | self.assertEquals(self.messages, ["success"]) 76 | 77 | self.manager.shutdown() 78 | self.assertEquals(self.states, ["running", "stopped"]) 79 | 80 | def test_message_failure(self): 81 | self.manager.startup() 82 | 83 | exc = MessageFailedException() 84 | 85 | receipt = self.manager.deliver(exc) 86 | self.assertRaises(DeliveryFailedException, receipt.result) 87 | 88 | self.assertEquals(self.states, ['running', 'stopped']) 89 | self.assertEquals(self.messages, [exc]) 90 | 91 | self.manager.shutdown() 92 | self.assertEquals(self.states, ['running', 'stopped']) 93 | 94 | def test_transport_failure(self): 95 | self.manager.startup() 96 | 97 | exc = TransportFailedException() 98 | 99 | self.manager.deliver(exc).result() 100 | 101 | self.assertEquals(self.states, ['running', 'stopped', 'running']) 102 | self.assertEquals(self.messages, [exc, exc]) 103 | 104 | self.manager.shutdown() 105 | self.assertEquals(self.states, ['running', 'stopped', 'running', 'stopped']) 106 | 107 | def test_transport_exhaustion(self): 108 | self.manager.startup() 109 | 110 | exc = TransportExhaustedException() 111 | 112 | self.manager.deliver(exc).result() 113 | 114 | self.assertEquals(self.states, ['running', 'stopped']) 115 | self.assertEquals(self.messages, [exc]) 116 | 117 | self.manager.shutdown() 118 | self.assertEquals(self.states, ['running', 'stopped']) 119 | 120 | ''' 121 | -------------------------------------------------------------------------------- /test/manager/test_dynamic.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | ''' 4 | 5 | from __future__ import unicode_literals 6 | 7 | import logging 8 | import pkg_resources 9 | 10 | from functools import partial 11 | from unittest import TestCase 12 | from nose.tools import ok_, eq_, raises 13 | from nose.plugins.skip import Skip, SkipTest 14 | 15 | from marrow.mailer.exc import TransportExhaustedException, TransportFailedException, DeliveryFailedException, MessageFailedException 16 | from marrow.mailer.manager.dynamic import DynamicManager, WorkItem 17 | 18 | 19 | log = logging.getLogger('tests') 20 | 21 | 22 | 23 | class MockFuture(object): 24 | def __init__(self): 25 | self.cancelled = False 26 | self.running = False 27 | self.exception = None 28 | self.result = None 29 | 30 | super(MockFuture, self).__init__() 31 | 32 | def set_running_or_notify_cancel(self): 33 | if self.cancelled: 34 | return False 35 | 36 | self.running = True 37 | return True 38 | 39 | def set_exception(self, e): 40 | self.exception = e 41 | 42 | def set_result(self, r): 43 | self.result = r 44 | 45 | 46 | class TestWorkItem(TestCase): 47 | calls = list() 48 | 49 | def closure(self): 50 | self.calls.append(True) 51 | return True 52 | 53 | def setUp(self): 54 | self.f = MockFuture() 55 | self.wi = WorkItem(self.f, self.closure, (), {}) 56 | 57 | def test_success(self): 58 | self.wi.run() 59 | 60 | self.assertEquals(self.calls, [True]) 61 | self.assertTrue(self.f.result) 62 | 63 | def test_cancelled(self): 64 | self.f.cancelled = True 65 | self.wi.run() 66 | 67 | self.assertEquals(self.calls, []) 68 | 69 | def test_exception(self): 70 | self.wi.fn = lambda: 1/0 71 | self.wi.run() 72 | 73 | self.assertTrue(isinstance(self.f.exception, ZeroDivisionError)) 74 | 75 | 76 | class ManagerTestCase(TestCase): 77 | manager = None 78 | config = dict() 79 | states = [] 80 | messages = [] 81 | 82 | class MockTransport(object): 83 | def __init__(self, states, messages): 84 | self.ephemeral = False 85 | self.states = states 86 | self.messages = messages 87 | 88 | def startup(self): 89 | self.states.append('running') 90 | 91 | def deliver(self, message): 92 | self.messages.append(message) 93 | 94 | if isinstance(message, Exception) and ( len(self.messages) < 2 or self.messages[-2] is not message): 95 | raise message 96 | 97 | def shutdown(self): 98 | self.states.append('stopped') 99 | 100 | def setUp(self): 101 | self.manager = self.manager(self.config, partial(self.MockTransport, self.states, self.messages)) 102 | 103 | def tearDown(self): 104 | del self.states[:] 105 | del self.messages[:] 106 | 107 | 108 | class TestDynamicManager(ManagerTestCase): 109 | manager = DynamicManager 110 | 111 | def test_startup(self): 112 | # TODO: Test logging messages. 113 | self.manager.startup() 114 | self.assertEquals(self.states, []) 115 | 116 | def test_shutdown(self): 117 | # TODO: Test logging messages. 118 | self.manager.startup() 119 | self.manager.shutdown() 120 | self.assertEquals(self.states, []) 121 | 122 | def test_success(self): 123 | self.manager.startup() 124 | 125 | self.manager.deliver("success") 126 | 127 | self.assertEquals(self.states, ["running"]) 128 | self.assertEquals(self.messages, ["success"]) 129 | 130 | self.manager.shutdown() 131 | self.assertEquals(self.states, ["running", "stopped"]) 132 | 133 | def test_message_failure(self): 134 | self.manager.startup() 135 | 136 | exc = MessageFailedException() 137 | 138 | receipt = self.manager.deliver(exc) 139 | self.assertRaises(DeliveryFailedException, receipt.result) 140 | 141 | self.assertEquals(self.states, ['running', 'stopped']) 142 | self.assertEquals(self.messages, [exc]) 143 | 144 | self.manager.shutdown() 145 | self.assertEquals(self.states, ['running', 'stopped']) 146 | 147 | def test_transport_failure(self): 148 | self.manager.startup() 149 | 150 | exc = TransportFailedException() 151 | 152 | self.manager.deliver(exc).result() 153 | 154 | self.assertEquals(self.states, ['running', 'stopped', 'running']) 155 | self.assertEquals(self.messages, [exc, exc]) 156 | 157 | self.manager.shutdown() 158 | self.assertEquals(self.states, ['running', 'stopped', 'running', 'stopped']) 159 | 160 | def test_transport_exhaustion(self): 161 | self.manager.startup() 162 | 163 | exc = TransportExhaustedException() 164 | 165 | self.manager.deliver(exc).result() 166 | 167 | self.assertEquals(self.states, ['running', 'stopped']) 168 | self.assertEquals(self.messages, [exc]) 169 | 170 | self.manager.shutdown() 171 | self.assertEquals(self.states, ['running', 'stopped']) 172 | 173 | ''' 174 | -------------------------------------------------------------------------------- /marrow/mailer/transport/smtp.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | """Deliver messages using (E)SMTP.""" 4 | 5 | import socket 6 | 7 | from smtplib import (SMTP, SMTP_SSL, SMTPException, SMTPRecipientsRefused, 8 | SMTPSenderRefused, SMTPServerDisconnected) 9 | 10 | from marrow.util.convert import boolean 11 | from marrow.util.compat import native 12 | 13 | from marrow.mailer.exc import ( 14 | TransportExhaustedException, TransportException, TransportFailedException, 15 | MessageFailedException) 16 | 17 | log = __import__('logging').getLogger(__name__) 18 | 19 | 20 | class SMTPTransport(object): 21 | """An (E)SMTP pipelining transport.""" 22 | 23 | __slots__ = ('ephemeral', 'host', 'tls', 'certfile', 'keyfile', 'port', 'local_hostname', 'username', 'password', 'timeout', 'debug', 'pipeline', 'connection', 'sent') 24 | 25 | def __init__(self, config): 26 | self.host = native(config.get('host', '127.0.0.1')) 27 | self.tls = config.get('tls', 'optional') 28 | self.certfile = config.get('certfile', None) 29 | self.keyfile = config.get('keyfile', None) 30 | self.port = int(config.get('port', 465 if self.tls == 'ssl' else 25)) 31 | self.local_hostname = native(config.get('local_hostname', '')) or None 32 | self.username = native(config.get('username', '')) or None 33 | self.password = native(config.get('password', '')) or None 34 | self.timeout = config.get('timeout', None) 35 | 36 | if self.timeout: 37 | self.timeout = int(self.timeout) 38 | 39 | self.debug = boolean(config.get('debug', False)) 40 | 41 | self.pipeline = config.get('pipeline', None) 42 | if self.pipeline not in (None, True, False): 43 | self.pipeline = int(self.pipeline) 44 | 45 | self.connection = None 46 | self.sent = 0 47 | 48 | def startup(self): 49 | if not self.connected: 50 | self.connect_to_server() 51 | 52 | def shutdown(self): 53 | if self.connected: 54 | log.debug("Closing SMTP connection") 55 | 56 | try: 57 | try: 58 | self.connection.quit() 59 | 60 | except SMTPServerDisconnected: # pragma: no cover 61 | pass 62 | 63 | except (SMTPException, socket.error): # pragma: no cover 64 | log.exception("Unhandled error while closing connection.") 65 | 66 | finally: 67 | self.connection = None 68 | 69 | def connect_to_server(self): 70 | if self.tls == 'ssl': # pragma: no cover 71 | connection = SMTP_SSL(host=None, local_hostname=self.local_hostname, keyfile=self.keyfile, 72 | certfile=self.certfile, timeout=self.timeout) 73 | else: 74 | connection = SMTP(local_hostname=self.local_hostname, timeout=self.timeout) 75 | 76 | log.info("Connecting to SMTP server %s:%s", self.host, self.port) 77 | connection.set_debuglevel(self.debug) 78 | connection.connect(self.host, self.port) 79 | 80 | # Do TLS handshake if configured 81 | connection.ehlo() 82 | if self.tls in ('required', 'optional', True): 83 | if connection.has_extn('STARTTLS'): # pragma: no cover 84 | connection.starttls(self.keyfile, self.certfile) 85 | elif self.tls == 'required': 86 | raise TransportException('TLS is required but not available on the server -- aborting') 87 | 88 | # Authenticate to server if necessary 89 | if self.username and self.password: 90 | log.info("Authenticating as %s", self.username) 91 | connection.login(self.username, self.password) 92 | 93 | self.connection = connection 94 | self.sent = 0 95 | 96 | @property 97 | def connected(self): 98 | return getattr(self.connection, 'sock', None) is not None 99 | 100 | def deliver(self, message): 101 | if not self.connected: 102 | self.connect_to_server() 103 | 104 | try: 105 | self.send_with_smtp(message) 106 | 107 | finally: 108 | if not self.pipeline or self.sent >= self.pipeline: 109 | raise TransportExhaustedException() 110 | 111 | def send_with_smtp(self, message): 112 | try: 113 | sender = str(message.envelope) 114 | recipients = message.recipients.string_addresses 115 | content = str(message) 116 | 117 | self.connection.sendmail(sender, recipients, content) 118 | self.sent += 1 119 | 120 | except SMTPSenderRefused as e: 121 | # The envelope sender was refused. This is bad. 122 | log.error("%s REFUSED %s %s", message.id, e.__class__.__name__, e) 123 | raise MessageFailedException(str(e)) 124 | 125 | except SMTPRecipientsRefused as e: 126 | # All recipients were refused. Log which recipients. 127 | # This allows you to automatically parse your logs for bad e-mail addresses. 128 | log.warning("%s REFUSED %s %s", message.id, e.__class__.__name__, e) 129 | raise MessageFailedException(str(e)) 130 | 131 | except SMTPServerDisconnected as e: # pragma: no cover 132 | if message.retries >= 0: 133 | log.warning("%s DEFERRED %s", message.id, "SMTPServerDisconnected") 134 | message.retries -= 1 135 | 136 | raise TransportFailedException() 137 | 138 | except Exception as e: # pragma: no cover 139 | cls_name = e.__class__.__name__ 140 | log.debug("%s EXCEPTION %s", message.id, cls_name, exc_info=True) 141 | 142 | if message.retries >= 0: 143 | log.exception("%s DEFERRED %s", message.id, cls_name) 144 | message.retries -= 1 145 | 146 | else: 147 | log.exception("%s REFUSED %s", message.id, cls_name) 148 | raise TransportFailedException() 149 | -------------------------------------------------------------------------------- /test/test_validator.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | """Test the primary configurator interface, Delivery.""" 4 | 5 | import logging 6 | import pytest 7 | 8 | from unittest import TestCase 9 | 10 | from marrow.mailer.validator import ValidationException, BaseValidator, DomainValidator, EmailValidator, \ 11 | EmailHarvester 12 | 13 | 14 | log = logging.getLogger('tests') 15 | 16 | 17 | class TestBaseValidator(TestCase): 18 | class MockValidator(BaseValidator): 19 | def validate(self, success=True): 20 | if success: 21 | return True, None 22 | 23 | return False, "Mock failure." 24 | 25 | def test_validator_success(self): 26 | mock = self.MockValidator() 27 | assert mock.validate_or_raise() 28 | 29 | def test_validator_failure(self): 30 | mock = self.MockValidator() 31 | with pytest.raises(ValidationException): 32 | mock.validate_or_raise(False) 33 | 34 | 35 | def test_common_rules(): 36 | try: 37 | import DNS 38 | except ImportError: 39 | pytest.skip("PyDNS not installed.") 40 | 41 | mock = DomainValidator() 42 | dataset = [ 43 | ('valid@example.com', ''), 44 | ('', 'It cannot be empty.'), 45 | ('*' * 256, 'It cannot be longer than 255 chars.'), 46 | ('.invalid@example.com', 'It cannot start with a dot.'), 47 | ('invalid@example.com.', 'It cannot end with a dot.'), 48 | ('invalid..@example.com', 'It cannot contain consecutive dots.'), 49 | ] 50 | 51 | def closure(address, expect): 52 | assert mock._apply_common_rules(address, 255) == (address, expect) 53 | 54 | for address, expect in dataset: 55 | yield closure, address, expect 56 | 57 | 58 | def test_common_rules_fixed(): 59 | try: 60 | import DNS 61 | except ImportError: 62 | pytest.skip("PyDNS not installed.") 63 | 64 | mock = DomainValidator(fix=True) 65 | dataset = [ 66 | ('.fixme@example.com', ('fixme@example.com', '')), 67 | ('fixme@example.com.', ('fixme@example.com', '')), 68 | ] 69 | 70 | def closure(address, expect): 71 | assert mock._apply_common_rules(address, 255) == expect 72 | 73 | for address, expect in dataset: 74 | yield closure, address, expect 75 | 76 | 77 | def test_domain_validation_basic(): 78 | try: 79 | import DNS 80 | except ImportError: 81 | pytest.skip("PyDNS not installed.") 82 | 83 | mock = DomainValidator() 84 | dataset = [ 85 | ('example.com', ''), 86 | ('xn--ls8h.la', ''), # IDN: (poop).la 87 | ('', 'Invalid domain: It cannot be empty.'), 88 | ('-bad.example.com', 'Invalid domain.'), 89 | ] 90 | 91 | def closure(domain, expect): 92 | assert mock.validate_domain(domain) == (domain, expect) 93 | 94 | for domain, expect in dataset: 95 | yield closure, domain, expect 96 | 97 | 98 | def test_domain_lookup(): 99 | try: 100 | import DNS 101 | except ImportError: 102 | pytest.skip("PyDNS not installed.") 103 | 104 | mock = DomainValidator() 105 | dataset = [ 106 | ('gothcandy.com', 'a', '174.129.236.35'), 107 | ('a' * 64 + '.gothcandy.com', 'a', False), 108 | ('gothcandy.com', 'mx', [(10, 'mx1.emailsrvr.com'), (20, 'mx2.emailsrvr.com')]), 109 | ('nx.example.com', 'a', False), 110 | ('xn--ls8h.la', 'a', '38.103.165.13'), # IDN: (poop).la 111 | ] 112 | 113 | def closure(domain, kind, expect): 114 | try: 115 | assert mock.lookup_domain(domain, kind, server=['8.8.8.8']) == expect 116 | except DNS.DNSError: 117 | pytest.skip("Skipped due to DNS error.") 118 | 119 | for domain, kind, expect in dataset: 120 | yield closure, domain, kind, expect 121 | 122 | 123 | def test_domain_validation(): 124 | try: 125 | import DNS 126 | except ImportError: 127 | pytest.skip("PyDNS not installed.") 128 | 129 | mock = DomainValidator(lookup_dns='mx') 130 | dataset = [ 131 | ('example.com', 'Domain does not seem to exist.'), 132 | # TODO This domain is always erroring out, please do something 133 | # ('xn--ls8h.la', ''), # IDN: (poop).la 134 | ('', 'Invalid domain: It cannot be empty.'), 135 | ('-bad.example.com', 'Invalid domain.'), 136 | ('gothcandy.com', ''), 137 | ('a' * 64 + '.gothcandy.com', 'Domain does not seem to exist.'), 138 | ('gothcandy.com', ''), 139 | ('nx.example.com', 'Domain does not seem to exist.'), 140 | ] 141 | 142 | def closure(domain, expect): 143 | try: 144 | assert mock.validate_domain(domain) == (domain, expect) 145 | except DNS.DNSError: 146 | pytest.skip("Skipped due to DNS error.") 147 | 148 | for domain, expect in dataset: 149 | yield closure, domain, expect 150 | 151 | 152 | def test_bad_lookup_record_1(): 153 | try: 154 | import DNS 155 | except ImportError: 156 | pytest.skip("PyDNS not installed.") 157 | 158 | with pytest.raises(RuntimeError): 159 | DomainValidator(lookup_dns='cname') 160 | 161 | 162 | def test_bad_lookup_record_2(): 163 | try: 164 | import DNS 165 | except ImportError: 166 | pytest.skip("PyDNS not installed.") 167 | 168 | mock = DomainValidator() 169 | 170 | with pytest.raises(RuntimeError): 171 | mock.lookup_domain('example.com', 'cname') 172 | 173 | 174 | def test_email_validation(): 175 | mock = EmailValidator() 176 | dataset = [ 177 | ('user@example.com', ''), 178 | ('user@xn--ls8h.la', ''), # IDN: (poop).la 179 | ('', 'The e-mail is empty.'), 180 | ('user@user@example.com', 'An email address must contain a single @'), 181 | ('user@-example.com', 'The e-mail has a problem to the right of the @: Invalid domain.'), 182 | ('bad,user@example.com', 'The email has a problem to the left of the @: Invalid local part.'), 183 | ] 184 | 185 | def closure(address, expect): 186 | assert mock.validate_email(address) == (address, expect) 187 | 188 | for address, expect in dataset: 189 | yield closure, address, expect 190 | 191 | 192 | def test_harvester(): 193 | mock = EmailHarvester() 194 | dataset = [ 195 | ('', []), 196 | ('test@example.com', ['test@example.com']), 197 | ('lorem ipsum test@example.com dolor sit', ['test@example.com']), 198 | ] 199 | 200 | def closure(text, expect): 201 | assert list(mock.harvest(text)) == expect 202 | 203 | for text, expect in dataset: 204 | yield closure, text, expect 205 | -------------------------------------------------------------------------------- /marrow/mailer/manager/dynamic.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | import atexit 4 | import threading 5 | import weakref 6 | import sys 7 | import math 8 | 9 | from functools import partial 10 | 11 | from marrow.mailer.manager.futures import worker 12 | from marrow.mailer.manager.util import TransportPool 13 | 14 | try: 15 | import queue 16 | except ImportError: 17 | import Queue as queue 18 | 19 | try: 20 | from concurrent import futures 21 | except ImportError: # pragma: no cover 22 | raise ImportError("You must install the futures package to use background delivery.") 23 | 24 | 25 | __all__ = ['DynamicManager'] 26 | 27 | log = __import__('logging').getLogger(__name__) 28 | 29 | 30 | def thread_worker(executor, jobs, timeout, maximum): 31 | i = maximum + 1 32 | 33 | try: 34 | while i: 35 | i -= 1 36 | 37 | try: 38 | work = jobs.get(True, timeout) 39 | 40 | if work is None: 41 | runner = executor() 42 | 43 | if runner is None or runner._shutdown: 44 | log.debug("Worker instructed to shut down.") 45 | break 46 | 47 | # Can't think of a test case for this; best to be safe. 48 | del runner # pragma: no cover 49 | continue # pragma: no cover 50 | 51 | except queue.Empty: # pragma: no cover 52 | log.debug("Worker death from starvation.") 53 | break 54 | 55 | else: 56 | work.run() 57 | 58 | else: # pragma: no cover 59 | log.debug("Worker death from exhaustion.") 60 | 61 | except: # pragma: no cover 62 | log.critical("Unhandled exception in worker.", exc_info=True) 63 | 64 | runner = executor() 65 | if runner: 66 | runner._threads.discard(threading.current_thread()) 67 | 68 | 69 | class WorkItem(object): 70 | __slots__ = ('future', 'fn', 'args', 'kwargs') 71 | 72 | def __init__(self, future, fn, args, kwargs): 73 | self.future = future 74 | self.fn = fn 75 | self.args = args 76 | self.kwargs = kwargs 77 | 78 | def run(self): 79 | if not self.future.set_running_or_notify_cancel(): 80 | return 81 | 82 | try: 83 | result = self.fn(*self.args, **self.kwargs) 84 | 85 | except: 86 | e = sys.exc_info()[1] 87 | self.future.set_exception(e) 88 | 89 | else: 90 | self.future.set_result(result) 91 | 92 | 93 | class ScalingPoolExecutor(futures.ThreadPoolExecutor): 94 | def __init__(self, workers, divisor, timeout): 95 | self._max_workers = workers 96 | self.divisor = divisor 97 | self.timeout = timeout 98 | 99 | self._work_queue = queue.Queue() 100 | 101 | self._threads = set() 102 | self._shutdown = False 103 | self._shutdown_lock = threading.Lock() 104 | self._management_lock = threading.Lock() 105 | 106 | atexit.register(self._atexit) 107 | 108 | def shutdown(self, wait=True): 109 | with self._shutdown_lock: 110 | self._shutdown = True 111 | 112 | for i in range(len(self._threads)): 113 | self._work_queue.put(None) 114 | 115 | if wait: 116 | for thread in list(self._threads): 117 | thread.join() 118 | 119 | def _atexit(self): # pragma: no cover 120 | self.shutdown(True) 121 | 122 | def _spawn(self): 123 | t = threading.Thread(target=thread_worker, args=(weakref.ref(self), self._work_queue, self.divisor, self.timeout)) 124 | t.daemon = True 125 | t.start() 126 | 127 | with self._management_lock: 128 | self._threads.add(t) 129 | 130 | def _adjust_thread_count(self): 131 | pool = len(self._threads) 132 | 133 | if pool < self._optimum_workers: 134 | tospawn = int(self._optimum_workers - pool) 135 | log.debug("Spawning %d thread%s." % (tospawn, tospawn != 1 and "s" or "")) 136 | 137 | for i in range(tospawn): 138 | self._spawn() 139 | 140 | @property 141 | def _optimum_workers(self): 142 | return min(self._max_workers, math.ceil(self._work_queue.qsize() / float(self.divisor))) 143 | 144 | 145 | class DynamicManager(object): 146 | __slots__ = ('workers', 'divisor', 'timeout', 'executor', 'transport') 147 | 148 | name = "Dynamic" 149 | Executor = ScalingPoolExecutor 150 | 151 | def __init__(self, config, transport): 152 | self.workers = int(config.get('workers', 10)) # Maximum number of threads to create. 153 | self.divisor = int(config.get('divisor', 10)) # Estimate the number of required threads by dividing the queue size by this. 154 | self.timeout = float(config.get('timeout', 60)) # Seconds before starvation. 155 | 156 | self.executor = None 157 | self.transport = TransportPool(transport) 158 | 159 | super(DynamicManager, self).__init__() 160 | 161 | def startup(self): 162 | log.info("%s manager starting up.", self.name) 163 | 164 | log.debug("Initializing transport queue.") 165 | self.transport.startup() 166 | 167 | workers = self.workers 168 | log.debug("Starting thread pool with %d workers." % (workers, )) 169 | self.executor = self.Executor(workers, self.divisor, self.timeout) 170 | 171 | log.info("%s manager ready.", self.name) 172 | 173 | def deliver(self, message): 174 | # Return the Future object so the application can register callbacks. 175 | # We pass the message so the executor can do what it needs to to make 176 | # the message thread-local. 177 | return self.executor.submit(partial(worker, self.transport), message) 178 | 179 | def shutdown(self, wait=True): 180 | log.info("%s manager stopping.", self.name) 181 | 182 | log.debug("Stopping thread pool.") 183 | self.executor.shutdown(wait=wait) 184 | 185 | log.debug("Draining transport queue.") 186 | self.transport.shutdown() 187 | 188 | log.info("%s manager stopped.", self.name) 189 | -------------------------------------------------------------------------------- /marrow/mailer/__init__.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | """marrow.mailer mail delivery framework and MIME message abstraction.""" 4 | 5 | 6 | import warnings 7 | import pkg_resources 8 | 9 | from email import charset 10 | from functools import partial 11 | 12 | from marrow.mailer.message import Message 13 | from marrow.mailer.exc import MailerNotRunning 14 | 15 | from marrow.util.compat import basestring 16 | from marrow.util.bunch import Bunch 17 | from marrow.util.object import load_object 18 | 19 | try: 20 | from concurrent import futures 21 | except ImportError: # pragma: no cover 22 | futures = None 23 | 24 | 25 | __all__ = ['Mailer', 'Delivery', 'Message'] 26 | 27 | log = __import__('logging').getLogger(__name__) 28 | 29 | 30 | class Mailer(object): 31 | """The primary marrow.mailer interface. 32 | 33 | Instantiate and configure marrow.mailer, then use the instance to initiate mail delivery. 34 | 35 | Where managers and transports are defined in the configuration you may pass in the class, 36 | an entrypoint name (simple string), or package-object notation ('foo.bar:baz'). 37 | """ 38 | 39 | def __repr__(self): 40 | return "Mailer(manager=%s, transport=%s)" % (self.Manager.__name__, self.Transport.__name__) 41 | 42 | def __init__(self, config, prefix=None): 43 | self.manager, self.Manager = None, None 44 | self.Transport = None 45 | self.running = False 46 | self.config = config = Bunch(config) 47 | 48 | if prefix is not None: 49 | self.config = config = Bunch.partial(prefix, config) 50 | 51 | if 'manager' in config and isinstance(config.manager, dict): 52 | self.manager_config = manager_config = config.manager 53 | elif 'manager' in config: 54 | self.manager_config = manager_config = dict(manager_config) 55 | else: 56 | try: 57 | self.manager_config = manager_config = Bunch.partial('manager', config) 58 | except ValueError: 59 | self.manager_config = manager_config = dict() 60 | 61 | if 'manager' in config and isinstance(config.manager, basestring): 62 | warnings.warn("Use of the manager directive is deprecated; use manager.use instead.", DeprecationWarning) 63 | manager_config.use = config.manager 64 | 65 | try: 66 | if 'transport' in config and isinstance(config.transport, dict): 67 | self.transport_config = transport_config = Bunch(config.transport) 68 | else: 69 | self.transport_config = transport_config = Bunch.partial('transport', config) 70 | except (AttributeError, ValueError): # pragma: no cover 71 | self.transport_config = transport_config = Bunch() 72 | 73 | if 'transport' in config and isinstance(config.transport, basestring): 74 | warnings.warn("Use of the transport directive is deprecated; use transport.use instead.", DeprecationWarning) 75 | transport_config.use = config.transport 76 | 77 | try: 78 | if 'message' in config and isinstance(config.message, dict): 79 | self.message_config = Bunch(config.message) 80 | else: 81 | self.message_config = Bunch.partial('message', config) 82 | except (AttributeError, ValueError): 83 | self.message_config = Bunch() 84 | 85 | self.Manager = Manager = self._load(manager_config.use if 'use' in manager_config else 'immediate', 'marrow.mailer.manager') 86 | 87 | if not Manager: 88 | raise LookupError("Unable to determine manager from specification: %r" % (config.manager, )) 89 | 90 | # Removed until marrow.interface is updated to use marrow.schema. 91 | #if not isinstance(Manager, IManager): 92 | # raise TypeError("Chosen manager does not conform to the manager API.") 93 | 94 | self.Transport = Transport = self._load(transport_config.use, 'marrow.mailer.transport') 95 | 96 | if not Transport: 97 | raise LookupError("Unable to determine transport from specification: %r" % (config.transport, )) 98 | 99 | # Removed until marrow.interface is updated to use marrow.schema. 100 | #if not isinstance(Transport, ITransport): 101 | # raise TypeError("Chosen transport does not conform to the transport API.") 102 | 103 | self.manager = Manager(manager_config, partial(Transport, transport_config)) 104 | 105 | @staticmethod 106 | def _load(spec, group): 107 | if not isinstance(spec, basestring): 108 | # It's already an object, just use it. 109 | return spec 110 | 111 | if ':' in spec: 112 | # Load the Python package(s) and target object. 113 | return load_object(spec) 114 | 115 | # Load the entry point. 116 | for entrypoint in pkg_resources.iter_entry_points(group, spec): 117 | return entrypoint.load() 118 | 119 | def start(self): 120 | if self.running: 121 | log.warning("Attempt made to start an already running Mailer service.") 122 | return 123 | 124 | log.info("Mail delivery service starting.") 125 | 126 | self.manager.startup() 127 | self.running = True 128 | 129 | log.info("Mail delivery service started.") 130 | 131 | return self 132 | 133 | def stop(self): 134 | if not self.running: 135 | log.warning("Attempt made to stop an already stopped Mailer service.") 136 | return 137 | 138 | log.info("Mail delivery service stopping.") 139 | 140 | self.manager.shutdown() 141 | self.running = False 142 | 143 | log.info("Mail delivery service stopped.") 144 | 145 | return self 146 | 147 | def send(self, message): 148 | if not self.running: 149 | raise MailerNotRunning("Mail service not running.") 150 | 151 | log.info("Attempting delivery of message %s.", message.id) 152 | 153 | try: 154 | result = self.manager.deliver(message) 155 | except: 156 | log.exception("Delivery of message %s failed.", message.id) 157 | raise 158 | if futures and isinstance(result, futures.Future): 159 | log.debug("Message %s passed to delivery manager.", message.id) 160 | result.add_done_callback(partial(self.future_done, message)) 161 | else: 162 | log.debug("Message %s delivered.", message.id) 163 | return result 164 | 165 | def future_done(self, message, future): 166 | if future.cancelled(): 167 | log.debug("Delivery of message %s cancelled.", message.id) 168 | elif future.exception() is not None: 169 | exc = future.exception() 170 | log.error( 171 | "Delivery of message %s failed.", 172 | message.id, 173 | exc_info=exc, 174 | ) 175 | else: 176 | log.debug("Message %s delivered.", message.id) 177 | 178 | def new(self, author=None, to=None, subject=None, **kw): 179 | data = dict(self.message_config) 180 | data['mailer'] = self 181 | 182 | if author: 183 | kw['author'] = author 184 | if to: 185 | kw['to'] = to 186 | if subject: 187 | kw['subject'] = subject 188 | 189 | data.update(kw) 190 | 191 | return Message(**data) 192 | 193 | 194 | class Delivery(Mailer): 195 | def __init__(self, *args, **kw): 196 | warnings.warn("Use of the Delivery class is deprecated; use Mailer instead.", DeprecationWarning) 197 | super(Delivery, self).__init__(*args, **kw) 198 | 199 | 200 | # Import-time side-effect: un-fscking the default use of base-64 encoding for UTF-8 e-mail. 201 | charset.add_charset('utf-8', charset.SHORTEST, charset.QP, 'utf-8') 202 | charset.add_charset('utf8', charset.SHORTEST, charset.QP, 'utf8') 203 | -------------------------------------------------------------------------------- /marrow/mailer/address.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | """TurboMail utility functions and support classes.""" 4 | 5 | from __future__ import unicode_literals 6 | import sys 7 | 8 | from email.utils import formataddr, parseaddr 9 | from email.header import Header 10 | 11 | from marrow.mailer.validator import EmailValidator 12 | from marrow.util.compat import basestring, unicode, unicodestr, native 13 | 14 | __all__ = ['Address', 'AddressList'] 15 | 16 | 17 | class Address(object): 18 | """Validated electronic mail address class. 19 | 20 | This class knows how to validate and format e-mail addresses. It uses 21 | Python's built-in `parseaddr` and `formataddr` helper functions and helps 22 | guarantee a uniform base for all e-mail address operations. 23 | 24 | The AddressList unit tests provide comprehensive testing of this class as 25 | well.""" 26 | 27 | def __init__(self, name_or_email, email=None, encoding='utf-8'): 28 | self.encoding = encoding 29 | 30 | if email is None: 31 | if isinstance(name_or_email, AddressList): 32 | if not 0 < len(name_or_email) < 2: 33 | raise ValueError("AddressList to convert must only contain a single Address.") 34 | 35 | name_or_email = unicode(name_or_email[0]) 36 | 37 | if isinstance(name_or_email, (tuple, list)): 38 | self.name = unicodestr(name_or_email[0], encoding) 39 | self.address = unicodestr(name_or_email[1], encoding) 40 | 41 | elif isinstance(name_or_email, bytes): 42 | self.name, self.address = parseaddr(unicodestr(name_or_email, encoding)) 43 | 44 | elif isinstance(name_or_email, unicode): 45 | self.name, self.address = parseaddr(name_or_email) 46 | 47 | else: 48 | raise TypeError('Expected string, tuple or list, got {0} instead'.format( 49 | repr(type(name_or_email)) 50 | )) 51 | else: 52 | self.name = unicodestr(name_or_email, encoding) 53 | self.address = unicodestr(email, encoding) 54 | 55 | email, err = EmailValidator().validate_email(self.address) 56 | 57 | if err: 58 | raise ValueError('"{0}" is not a valid e-mail address: {1}'.format(email, err)) 59 | 60 | def __eq__(self, other): 61 | if isinstance(other, Address): 62 | return (self.name, self.address) == (other.name, other.address) 63 | 64 | elif isinstance(other, unicode): 65 | return unicode(self) == other 66 | 67 | elif isinstance(other, bytes): 68 | return bytes(self) == other 69 | 70 | elif isinstance(other, tuple): 71 | return (self.name, self.address) == other 72 | 73 | raise NotImplementedError("Can not compare Address instance against {0} instance".format(type(other))) 74 | 75 | def __ne__(self, other): 76 | return not self == other 77 | 78 | def __len__(self): 79 | return len(self.__unicode__()) 80 | 81 | def __repr__(self): 82 | return 'Address("{0}")'.format(unicode(self).encode('ascii', 'backslashreplace').decode('ascii')) 83 | 84 | def __unicode__(self): 85 | return self.encode('utf8').decode('utf8') 86 | 87 | def __bytes__(self): 88 | return self.encode() 89 | 90 | if sys.version_info < (3, 0): 91 | __str__ = __bytes__ 92 | 93 | else: # pragma: no cover 94 | __str__ = __unicode__ 95 | 96 | def encode(self, encoding=None): 97 | name_string = None 98 | 99 | if encoding is None: 100 | encoding = self.encoding 101 | 102 | if encoding != 'ascii': 103 | try: # This nonsense is to preserve Python 2 behaviour. Python 3 utf-8 encodes when asked for ascii! 104 | self.name.encode('ascii', errors='strict') 105 | except UnicodeError: 106 | pass 107 | else: 108 | name_string = self.name 109 | 110 | if name_string is None: 111 | name_string = Header(self.name, encoding).encode() 112 | 113 | # Encode punycode for internationalized domains. 114 | localpart, domain = self.address.split('@', 1) 115 | domain = domain.encode('idna').decode() 116 | address = '@'.join((localpart, domain)) 117 | 118 | return formataddr((name_string, address)).replace('\n', '').encode(encoding) 119 | 120 | @property 121 | def valid(self): 122 | email, err = EmailValidator().validate_email(self.address) 123 | return False if err else True 124 | 125 | 126 | class AddressList(list): 127 | def __init__(self, addresses=None, encoding="utf-8"): 128 | super(AddressList, self).__init__() 129 | 130 | self.encoding = encoding 131 | 132 | if addresses is None: 133 | return 134 | 135 | if isinstance(addresses, basestring): 136 | addresses = addresses.split(',') 137 | 138 | elif isinstance(addresses, tuple): 139 | self.append(Address(addresses, encoding=encoding)) 140 | return 141 | 142 | if not isinstance(addresses, list): 143 | raise ValueError("Invalid value for AddressList: {0}".format(repr(addresses))) 144 | 145 | self.extend(addresses) 146 | 147 | def __repr__(self): 148 | if not self: 149 | return "AddressList()" 150 | 151 | return "AddressList(\"{0}\")".format(", ".join([str(i) for i in self])) 152 | 153 | def __bytes__(self): 154 | return self.encode() 155 | 156 | def __unicode__(self): 157 | return ", ".join(unicode(i) for i in self) 158 | 159 | if sys.version_info < (3, 0): 160 | __str__ = __bytes__ 161 | 162 | else: # pragma: no cover 163 | __str__ = __unicode__ 164 | 165 | def __setitem__(self, k, value): 166 | if isinstance(k, slice): 167 | value = [Address(val) if not isinstance(val, Address) else val for val in value] 168 | 169 | elif not isinstance(value, Address): 170 | value = Address(value) 171 | 172 | super(AddressList, self).__setitem__(k, value) 173 | 174 | def __setslice__(self, i, j, sequence): 175 | self.__setitem__(slice(i, j), sequence) 176 | 177 | def encode(self, encoding=None): 178 | encoding = encoding if encoding else self.encoding 179 | return b", ".join([a.encode(encoding) for a in self]) 180 | 181 | def extend(self, sequence): 182 | values = [Address(val) if not isinstance(val, Address) else val for val in sequence] 183 | super(AddressList, self).extend(values) 184 | 185 | def append(self, value): 186 | self.extend([value]) 187 | 188 | @property 189 | def addresses(self): 190 | return AddressList([i.address for i in self]) 191 | 192 | @property 193 | def string_addresses(self, encoding=None): 194 | """Return a list of string representations of the addresses suitable 195 | for usage in an SMTP transaction.""" 196 | 197 | if not encoding: 198 | encoding = self.encoding 199 | 200 | # We need the punycode goodness. 201 | return [Address(i.address).encode(encoding).decode(encoding) for i in self] 202 | 203 | 204 | class AutoConverter(object): 205 | """Automatically converts an assigned value to the given type.""" 206 | 207 | def __init__(self, attr, cls, can=True): 208 | self.cls = cls 209 | self.can = can 210 | self.attr = native(attr) 211 | 212 | def __get__(self, instance, owner): 213 | value = getattr(instance, self.attr, None) 214 | 215 | if value is None: 216 | return self.cls() if self.can else None 217 | 218 | return value 219 | 220 | def __set__(self, instance, value): 221 | if not isinstance(value, self.cls): 222 | value = self.cls(value) 223 | 224 | setattr(instance, self.attr, value) 225 | 226 | def __delete__(self, instance): 227 | setattr(instance, self.attr, None) 228 | -------------------------------------------------------------------------------- /test/test_core.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | """Test the primary configurator interface, Mailer.""" 4 | 5 | import logging 6 | import warnings 7 | import pytest 8 | 9 | from unittest import TestCase 10 | 11 | from marrow.mailer import Mailer, Delivery, Message 12 | from marrow.mailer.exc import MailerNotRunning 13 | from marrow.mailer.manager.immediate import ImmediateManager 14 | from marrow.mailer.transport.mock import MockTransport 15 | 16 | from marrow.util.bunch import Bunch 17 | 18 | log = logging.getLogger('tests') 19 | 20 | base_config = dict(manager=dict(use='immediate'), transport=dict(use='mock')) 21 | 22 | 23 | class TestLookup(TestCase): 24 | def test_load_literal(self): 25 | assert Mailer._load(ImmediateManager, None) == ImmediateManager 26 | 27 | def test_load_dotcolon(self): 28 | assert Mailer._load('marrow.mailer.manager.immediate:ImmediateManager', None) == ImmediateManager 29 | 30 | def test_load_entrypoint(self): 31 | assert Mailer._load('immediate', 'marrow.mailer.manager') == ImmediateManager 32 | 33 | 34 | class TestInitialization(TestCase): 35 | def test_deprecation(self): 36 | with warnings.catch_warnings(record=True) as w: 37 | warnings.simplefilter("always") 38 | 39 | Delivery(base_config) 40 | 41 | assert len(w) == 1, "No, or more than one, warning issued." 42 | assert issubclass(w[-1].category, DeprecationWarning), "Category of warning is not DeprecationWarning." 43 | assert 'deprecated' in str(w[-1].message), "Warning does not include 'deprecated'." 44 | assert 'Mailer' in str(w[-1].message), "Warning does not include correct class name." 45 | assert 'Delivery' in str(w[-1].message), "Warning does not include old class name." 46 | 47 | def test_default_manager(self): 48 | a = Mailer(dict(transport=dict(use='mock'))) 49 | 50 | assert a.Manager == ImmediateManager 51 | assert a.Transport == MockTransport 52 | 53 | def test_standard(self): 54 | log.info("Testing configuration: %r", dict(base_config)) 55 | a = Mailer(base_config) 56 | 57 | assert a.Manager == ImmediateManager 58 | assert a.Transport == MockTransport 59 | 60 | def test_bad_manager(self): 61 | config = dict(manager=dict(use=object()), transport=dict(use='mock')) 62 | log.info("Testing configuration: %r", dict(config)) 63 | with pytest.raises(TypeError): 64 | Mailer(config) 65 | 66 | def test_bad_transport(self): 67 | config = dict(manager=dict(use='immediate'), transport=dict(use=object())) 68 | log.info("Testing configuration: %r", dict(config)) 69 | with pytest.raises(TypeError): 70 | Mailer(config) 71 | 72 | def test_repr(self): 73 | a = Mailer(base_config) 74 | assert repr(a) == "Mailer(manager=ImmediateManager, transport=MockTransport)" 75 | 76 | def test_prefix(self): 77 | config = { 78 | 'mail.manager.use': 'immediate', 79 | 'mail.transport.use': 'mock' 80 | } 81 | 82 | log.info("Testing configuration: %r", dict(config)) 83 | a = Mailer(config, 'mail') 84 | 85 | assert a.Manager == ImmediateManager 86 | assert a.Transport == MockTransport 87 | 88 | def test_deep_prefix(self): 89 | config = { 90 | 'marrow.mailer.manager.use': 'immediate', 91 | 'marrow.mailer.transport.use': 'mock' 92 | } 93 | 94 | log.info("Testing configuration: %r", dict(config)) 95 | a = Mailer(config, 'marrow.mailer') 96 | 97 | assert a.Manager == ImmediateManager 98 | assert a.Transport == MockTransport 99 | 100 | def test_manager_entrypoint_failure(self): 101 | config = { 102 | 'manager.use': 'immediate2', 103 | 'transport.use': 'mock' 104 | } 105 | 106 | log.info("Testing configuration: %r", dict(config)) 107 | with pytest.raises(LookupError): 108 | Mailer(config) 109 | 110 | def test_manager_dotcolon_failure(self): 111 | config = { 112 | 'manager.use': 'marrow.mailer.manager.foo:FooManager', 113 | 'transport.use': 'mock' 114 | } 115 | 116 | log.info("Testing configuration: %r", dict(config)) 117 | with pytest.raises(ImportError): 118 | Mailer(config) 119 | 120 | config['manager.use'] = 'marrow.mailer.manager.immediate:FooManager' 121 | log.info("Testing configuration: %r", dict(config)) 122 | with pytest.raises(AttributeError): 123 | Mailer(config) 124 | 125 | def test_transport_entrypoint_failure(self): 126 | config = { 127 | 'manager.use': 'immediate', 128 | 'transport.use': 'mock2' 129 | } 130 | 131 | log.info("Testing configuration: %r", dict(config)) 132 | with pytest.raises(LookupError): 133 | Mailer(config) 134 | 135 | def test_transport_dotcolon_failure(self): 136 | config = { 137 | 'manager.use': 'immediate', 138 | 'transport.use': 'marrow.mailer.transport.foo:FooTransport' 139 | } 140 | 141 | log.info("Testing configuration: %r", dict(config)) 142 | with pytest.raises(ImportError): 143 | Mailer(config) 144 | 145 | config['manager.use'] = 'marrow.mailer.transport.mock:FooTransport' 146 | log.info("Testing configuration: %r", dict(config)) 147 | with pytest.raises(AttributeError): 148 | Mailer(config) 149 | 150 | 151 | class TestMethods(TestCase): 152 | def test_startup(self): 153 | #messages = logging.getLogger().handlers[0].buffer 154 | 155 | interface = Mailer(base_config) 156 | interface.start() 157 | 158 | #assert len(messages) == 5 159 | #assert messages[0] == "Mail delivery service starting." 160 | #assert messages[-1] == "Mail delivery service started." 161 | 162 | interface.start() 163 | 164 | #assert len(messages) == 6 165 | #assert messages[-1] == "Attempt made to start an already running Mailer service." 166 | 167 | interface.stop() 168 | 169 | def test_shutdown(self): 170 | interface = Mailer(base_config) 171 | interface.start() 172 | 173 | #logging.getLogger().handlers[0].truncate() 174 | #messages = logging.getLogger().handlers[0].buffer 175 | 176 | interface.stop() 177 | 178 | #assert len(messages) == 5 179 | #assert messages[0] == "Mail delivery service stopping." 180 | #assert messages[-1] == "Mail delivery service stopped." 181 | 182 | interface.stop() 183 | 184 | #assert len(messages) == 6 185 | #assert messages[-1] == "Attempt made to stop an already stopped Mailer service." 186 | 187 | def test_send(self): 188 | message = Bunch(id='foo') 189 | 190 | interface = Mailer(base_config) 191 | 192 | with pytest.raises(MailerNotRunning): 193 | interface.send(message) 194 | 195 | interface.start() 196 | 197 | #logging.getLogger().handlers[0].truncate() 198 | #messages = logging.getLogger().handlers[0].buffer 199 | 200 | assert interface.send(message) == (message, True) 201 | 202 | #assert messages[0] == "Attempting delivery of message foo." 203 | #assert messages[-1] == "Message foo delivered." 204 | 205 | message_fail = Bunch(id='bar', die=True) 206 | 207 | with pytest.raises(Exception): 208 | interface.send(message_fail) 209 | 210 | #assert messages[-4] == "Attempting delivery of message bar." 211 | #assert messages[-3] == "Acquired existing transport instance." 212 | #assert messages[-2] == "Shutting down transport due to unhandled exception." 213 | #assert messages[-1] == "Delivery of message bar failed." 214 | 215 | interface.stop() 216 | 217 | def test_new(self): 218 | config = dict( 219 | manager=dict(use='immediate'), transport=dict(use='mock'), 220 | message=dict(author='from@example.com', retries=1, brand=False)) 221 | 222 | interface = Mailer(config).start() 223 | message = interface.new(retries=2) 224 | 225 | assert message.author == ["from@example.com"] 226 | assert message.bcc == [] 227 | assert message.retries == 2 228 | assert message.mailer is interface 229 | assert message.brand == False 230 | 231 | with pytest.raises(NotImplementedError): 232 | Message().send() 233 | 234 | assert message.send() == (message, True) 235 | 236 | message = interface.new("alternate@example.com", "recipient@example.com", "Test.") 237 | 238 | assert message.author == ["alternate@example.com"] 239 | assert message.to == ["recipient@example.com"] 240 | assert message.subject == "Test." 241 | -------------------------------------------------------------------------------- /test/test_addresses.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | """Test the TurboMail Message class.""" 3 | 4 | from __future__ import unicode_literals 5 | 6 | import pytest 7 | 8 | from marrow.mailer.address import Address, AddressList, AutoConverter 9 | from marrow.util.compat import bytes, unicode 10 | 11 | 12 | class TestAddress(object): 13 | def test_punycode(self): 14 | addr = Address('Foo', 'foo@exámple.test') 15 | assert bytes(addr) == b'Foo ' 16 | 17 | def test_bytestring(self): 18 | addr = Address('Foo '.encode('utf-8')) 19 | assert bytes(addr) == b'Foo ' 20 | 21 | def test_address_from_addresslist(self): 22 | email = 'foo@example.com' 23 | addr = Address(AddressList([Address(email)])) 24 | assert unicode(addr) == email 25 | 26 | def test_address_from_addresslist_limit_0(self): 27 | with pytest.raises(ValueError): 28 | Address(AddressList()) 29 | 30 | def test_address_from_addresslist_limit_2(self): 31 | email = 'foo@example.com' 32 | 33 | with pytest.raises(ValueError): 34 | Address(AddressList([Address(email), Address(email)])) 35 | 36 | def test_initialization_with_tuple(self): 37 | name = 'Foo' 38 | emailaddress = 'foo@example.com' 39 | address = Address((name, emailaddress)) 40 | assert '%s <%s>' % (name, emailaddress) == unicode(address) 41 | 42 | def test_initialization_with_string(self): 43 | emailaddress = 'foo@example.com' 44 | address = Address(emailaddress) 45 | assert unicode(address) == emailaddress 46 | 47 | def test_initialization_with_named_string(self): 48 | emailaddress = 'My Name ' 49 | address = Address(emailaddress) 50 | assert unicode(address) == emailaddress 51 | 52 | def test_invalid_initialization(self): 53 | with pytest.raises(TypeError): 54 | Address(123) 55 | 56 | def test_compare_address(self): 57 | addr1 = Address('foo@example.com') 58 | addr2 = Address(' foo@example.com ') 59 | assert addr1 == addr2 60 | 61 | def test_compare_unicode(self): 62 | addr = Address('foo@example.com') 63 | assert addr == 'foo@example.com' 64 | 65 | def test_compare_bytestring(self): 66 | addr = Address('foo@example.com') 67 | assert addr == b'foo@example.com' 68 | 69 | def test_compare_tuple(self): 70 | addr = Address('foo', 'foo@example.com') 71 | assert addr == ('foo', 'foo@example.com') 72 | 73 | def test_compare_othertype(self): 74 | addr = Address('foo@example.com') 75 | with pytest.raises(NotImplementedError): 76 | addr != 123 77 | 78 | def test_len(self): 79 | addr = Address('foo@example.com') 80 | assert len(addr), len('foo@example.com') 81 | 82 | def test_repr(self): 83 | addr = Address('foo@example.com') 84 | assert repr(addr) == 'Address("foo@example.com")' 85 | 86 | def test_validation_truncates_at_second_at_character(self): 87 | # This is basically due to Python's parseaddr behavior. 88 | assert 'bad@user' == Address('bad@user@example.com') 89 | 90 | def test_validation_rejects_addresses_without_at(self): 91 | # TODO: This may be actually a valid input - some mail systems allow to 92 | # use only the local part which will be qualified by the MTA 93 | with pytest.raises(ValueError): 94 | Address('baduser.example.com') 95 | 96 | def test_validation_accepts_uncommon_local_parts(self): 97 | Address('good-u+s+er@example.com') 98 | # This address caused 100% CPU load for 50s in Python's (2.5.2) re 99 | # module on Fedora Linux 10 (AMD x2 4200). 100 | Address('steve.blackmill.rules.for.all@bar.blackmill-goldworks.example') 101 | Address('customer/department=shipping@example.com') 102 | Address('$A12345@example.com ') 103 | Address('!def!xyz%abc@example.com ') 104 | Address('_somename@example.com') 105 | Address('!$&*-=^`|~#%\'+/?_{}@example.com') 106 | 107 | def test_revalidation(self): 108 | addr = Address('_somename@example.com') 109 | assert addr.valid == True 110 | 111 | # TODO: Later 112 | # def test_validation_accepts_quoted_local_parts(self): 113 | # Address('"Fred Bloggs"@example.com ') 114 | # Address('"Joe\\Blow"@example.com ') 115 | # Address('"Abc@def"@example.com ') 116 | # Address('"Abc\@def"@example.com') 117 | 118 | def test_validation_accepts_multilevel_domains(self): 119 | Address('foo@my.my.company-name.com') 120 | Address('blah@foo-bar.example.com') 121 | Address('blah@duckburg.foo-bar.example.com') 122 | 123 | def test_validation_accepts_domain_without_tld(self): 124 | assert Address('user@company') == 'user@company' 125 | 126 | def test_validation_rejects_local_parts_starting_or_ending_with_dot(self): 127 | with pytest.raises(ValueError): 128 | Address('.foo@example.com') 129 | 130 | with pytest.raises(ValueError): 131 | Address('foo.@example.com') 132 | 133 | def test_validation_rejects_double_dot(self): 134 | with pytest.raises(ValueError): 135 | Address('foo..bar@example.com') 136 | 137 | # TODO: Later 138 | # def test_validation_rejects_special_characters_if_not_quoted(self): 139 | # for char in '()[]\;:,<>': 140 | # localpart = 'foo%sbar' % char 141 | # self.assertRaises(ValueError, Address, '%s@example.com' % localpart) 142 | # Address("%s"@example.com' % localpart) 143 | 144 | # TODO: Later 145 | # def test_validation_accepts_ip_address_literals(self): 146 | # Address('jsmith@[192.168.2.1]') 147 | 148 | 149 | class TestAddressList(object): 150 | """Test the AddressList helper class.""" 151 | 152 | addresses = AutoConverter('_addresses', AddressList) 153 | 154 | @classmethod 155 | def setup_class(cls): 156 | cls._addresses = AddressList() 157 | 158 | def setup_method(self, method): 159 | self.addresses = AddressList() 160 | 161 | def teardown_method(self, method): 162 | del self.addresses 163 | 164 | def test_assignment(self): 165 | self.addresses = [] 166 | self.addresses = ['me@example.com'] 167 | assert self.addresses == ['me@example.com'] 168 | 169 | def test_assign_single_address(self): 170 | address = 'user@example.com' 171 | self.addresses = address 172 | 173 | assert [address] == self.addresses 174 | assert unicode(self.addresses) == address 175 | 176 | def test_assign_list_of_addresses(self): 177 | addresses = ['user1@example.com', 'user2@example.com'] 178 | self.addresses = addresses 179 | assert ', '.join(addresses) == unicode(self.addresses) 180 | assert addresses == self.addresses 181 | 182 | def test_assign_list_of_named_addresses(self): 183 | addresses = [('Test User 1', 'user1@example.com'), ('Test User 2', 'user2@example.com')] 184 | self.addresses = addresses 185 | 186 | string_addresses = [unicode(Address(*value)) for value in addresses] 187 | assert ', '.join(string_addresses) == unicode(self.addresses) 188 | assert string_addresses == self.addresses 189 | 190 | def test_assign_item(self): 191 | self.addresses.append('user1@example.com') 192 | assert self.addresses[0] == 'user1@example.com' 193 | self.addresses[0] = 'user2@example.com' 194 | 195 | assert isinstance(self.addresses[0], Address) 196 | assert self.addresses[0] == 'user2@example.com' 197 | 198 | def test_assign_slice(self): 199 | self.addresses[:] = ('user1@example.com', 'user2@example.com') 200 | 201 | assert isinstance(self.addresses[0], Address) 202 | assert isinstance(self.addresses[1], Address) 203 | 204 | def test_init_accepts_string_list(self): 205 | addresses = 'user1@example.com, user2@example.com' 206 | self.addresses = addresses 207 | 208 | assert addresses == unicode(self.addresses) 209 | 210 | def test_init_accepts_tuple(self): 211 | addresses = AddressList(('foo', 'foo@example.com')) 212 | assert addresses == [('foo', 'foo@example.com')] 213 | 214 | def test_bytes(self): 215 | self.addresses = [('User1', 'foo@exámple.test'), ('User2', 'foo@exámple.test')] 216 | assert bytes(self.addresses) == b'User1 , User2 ' 217 | 218 | def test_repr(self): 219 | assert repr(self.addresses) == 'AddressList()' 220 | 221 | self.addresses = ['user1@example.com', 'user2@example.com'] 222 | 223 | assert repr(self.addresses) == 'AddressList("user1@example.com, user2@example.com")' 224 | 225 | def test_invalid_init(self): 226 | with pytest.raises(ValueError): 227 | AddressList(2) 228 | 229 | def test_addresses(self): 230 | self.addresses = [('Test User 1', 'user1@example.com'), ('Test User 2', 'user2@example.com')] 231 | assert self.addresses.addresses == AddressList('user1@example.com, user2@example.com') 232 | 233 | def test_validation_strips_multiline_addresses(self): 234 | self.addresses = 'user.name+test@info.example.com' 235 | evil_lines = ['eviluser@example.com', 'To: spammeduser@example.com', 'From: spammeduser@example.com'] 236 | evil_input = '\n'.join(evil_lines) 237 | self.addresses.append(evil_input) 238 | assert self.addresses == ['user.name+test@info.example.com', evil_lines[0]] 239 | 240 | def test_return_addresses_as_strings(self): 241 | self.addresses = 'foo@exámple.test' 242 | encoded_address = 'foo@xn--exmple-qta.test' 243 | assert self.addresses.string_addresses == [encoded_address] 244 | -------------------------------------------------------------------------------- /test/transport/test_smtp.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | ''' 4 | 5 | from __future__ import unicode_literals 6 | 7 | import os 8 | import sys 9 | import socket 10 | import logging 11 | import smtplib 12 | 13 | from unittest import TestCase 14 | from nose.tools import ok_, eq_, raises 15 | from nose.plugins.skip import Skip, SkipTest 16 | 17 | try: 18 | from pymta.api import IMTAPolicy, PolicyDecision, IAuthenticator 19 | from pymta.test_util import BlackholeDeliverer, DebuggingMTA, MTAThread 20 | except ImportError: # pragma: no cover 21 | raise SkipTest("PyMTA not installed; skipping SMTP tests.") 22 | 23 | from marrow.mailer import Message 24 | from marrow.mailer.exc import TransportException, TransportExhaustedException, MessageFailedException 25 | from marrow.mailer.transport.smtp import SMTPTransport 26 | 27 | 28 | log = logging.getLogger('tests') 29 | 30 | 31 | class SMTPTestCase(TestCase): 32 | server = None 33 | Policy = IMTAPolicy 34 | 35 | class Authenticator(IAuthenticator): 36 | def authenticate(self, username, password, peer): 37 | return True 38 | 39 | @classmethod 40 | def setUpClass(cls): 41 | assert not cls.server, "Server already running?" 42 | 43 | cls.port = __import__('random').randint(9000, 40000) 44 | cls.collector = BlackholeDeliverer 45 | cls.host = DebuggingMTA('127.0.0.1', cls.port, cls.collector, policy_class=cls.Policy, 46 | authenticator_class=cls.Authenticator) 47 | cls.server = MTAThread(cls.host) 48 | cls.server.start() 49 | 50 | @classmethod 51 | def tearDownClass(cls): 52 | if cls.server: 53 | cls.server.stop() 54 | cls.server = None 55 | 56 | 57 | class TestSMTPTransportBase(SMTPTestCase): 58 | def test_basic_config(self): 59 | transport = SMTPTransport(dict(port=self.port, timeout="10", tls=False, pipeline="10")) 60 | 61 | self.assertEqual(transport.sent, 0) 62 | self.assertEqual(transport.host, '127.0.0.1') 63 | self.assertEqual(transport.port, self.port) 64 | self.assertEqual(transport.timeout, 10) 65 | self.assertEqual(transport.pipeline, 10) 66 | self.assertEqual(transport.debug, False) 67 | 68 | self.assertEqual(transport.connected, False) 69 | 70 | def test_startup_shutdown(self): 71 | transport = SMTPTransport(dict(port=self.port)) 72 | 73 | transport.startup() 74 | self.assertTrue(transport.connected) 75 | 76 | transport.shutdown() 77 | self.assertFalse(transport.connected) 78 | 79 | def test_authentication(self): 80 | transport = SMTPTransport(dict(port=self.port, username='bob', password='dole')) 81 | 82 | transport.startup() 83 | self.assertTrue(transport.connected) 84 | 85 | transport.shutdown() 86 | self.assertFalse(transport.connected) 87 | 88 | def test_bad_tls(self): 89 | transport = SMTPTransport(dict(port=self.port, tls='required')) 90 | self.assertRaises(TransportException, transport.startup) 91 | 92 | 93 | class TransportTestCase(SMTPTestCase): 94 | pipeline = None 95 | 96 | def setUp(self): 97 | self.transport = SMTPTransport(dict(port=self.port, pipeline=self.pipeline)) 98 | self.transport.startup() 99 | self.msg = self.message 100 | 101 | def tearDown(self): 102 | self.transport.shutdown() 103 | self.transport = None 104 | self.msg = None 105 | 106 | @property 107 | def message(self): 108 | return Message('from@example.com', 'to@example.com', 'Test subject.', plain="Test body.") 109 | 110 | 111 | class TestSMTPTransport(TransportTestCase): 112 | def test_send_simple_message(self): 113 | self.assertRaises(TransportExhaustedException, self.transport.deliver, self.msg) 114 | self.assertEqual(self.collector.received_messages.qsize(), 1) 115 | 116 | message = self.collector.received_messages.get() 117 | self.assertEqual(message.msg_data, str(self.msg)) 118 | self.assertEqual(message.smtp_from, self.msg.envelope) 119 | self.assertEqual(message.smtp_to, self.msg.recipients) 120 | 121 | def test_send_after_shutdown(self): 122 | self.transport.shutdown() 123 | 124 | self.assertRaises(TransportExhaustedException, self.transport.deliver, self.msg) 125 | self.assertEqual(self.collector.received_messages.qsize(), 1) 126 | 127 | message = self.collector.received_messages.get() 128 | self.assertEqual(message.msg_data, str(self.msg)) 129 | self.assertEqual(message.smtp_from, self.msg.envelope) 130 | self.assertEqual(message.smtp_to, self.msg.recipients) 131 | 132 | def test_sender(self): 133 | self.msg.sender = "sender@example.com" 134 | self.assertEqual(self.msg.envelope, self.msg.sender) 135 | 136 | self.assertRaises(TransportExhaustedException, self.transport.deliver, self.msg) 137 | self.assertEqual(self.collector.received_messages.qsize(), 1) 138 | 139 | message = self.collector.received_messages.get() 140 | self.assertEqual(message.msg_data, str(self.msg)) 141 | self.assertEqual(message.smtp_from, self.msg.envelope) 142 | 143 | def test_many_recipients(self): 144 | self.msg.cc = 'cc@example.com' 145 | self.msg.bcc = 'bcc@example.com' 146 | 147 | self.assertRaises(TransportExhaustedException, self.transport.deliver, self.msg) 148 | self.assertEqual(self.collector.received_messages.qsize(), 1) 149 | 150 | message = self.collector.received_messages.get() 151 | self.assertEqual(message.msg_data, str(self.msg)) 152 | self.assertEqual(message.smtp_from, self.msg.envelope) 153 | self.assertEqual(message.smtp_to, self.msg.recipients) 154 | 155 | 156 | class TestSMTPTransportRefusedSender(TransportTestCase): 157 | pipeline = 10 158 | 159 | class Policy(IMTAPolicy): 160 | def accept_from(self, sender, message): 161 | return False 162 | 163 | def test_refused_sender(self): 164 | self.assertRaises(MessageFailedException, self.transport.deliver, self.msg) 165 | self.assertEquals(self.collector.received_messages.qsize(), 0) 166 | 167 | 168 | class TestSMTPTransportRefusedRecipients(TransportTestCase): 169 | pipeline = True 170 | 171 | class Policy(IMTAPolicy): 172 | def accept_rcpt_to(self, sender, message): 173 | return False 174 | 175 | def test_refused_recipients(self): 176 | self.assertRaises(MessageFailedException, self.transport.deliver, self.msg) 177 | self.assertEquals(self.collector.received_messages.qsize(), 0) 178 | 179 | ''' 180 | ''' 181 | def get_connection(self): 182 | # We can not use the id of transport.connection because sometimes Python 183 | # returns the same id for new, but two different instances of the same 184 | # object (Fedora 10, Python 2.5): 185 | # class Bar: pass 186 | # id(Bar()) == id(Bar()) -> True 187 | sock = getattr(interface.manager.transport.connection, 'sock', None) 188 | return sock 189 | 190 | def get_transport(self): 191 | return interface.manager.transport 192 | 193 | def test_close_connection_when_max_messages_per_connection_was_reached(self): 194 | self.config['mail.smtp.max_messages_per_connection'] = 2 195 | self.init_mta() 196 | self.msg.send() 197 | first_connection = self.get_connection() 198 | self.msg.send() 199 | second_connection = self.get_connection() 200 | 201 | queue = self.get_received_messages() 202 | self.assertEqual(2, queue.qsize()) 203 | self.assertNotEqual(first_connection, second_connection) 204 | 205 | def test_close_connection_when_max_messages_per_connection_was_reached_even_on_errors(self): 206 | self.config['mail.smtp.max_messages_per_connection'] = 1 207 | class RejectHeloPolicy(IMTAPolicy): 208 | def accept_helo(self, sender, message): 209 | return False 210 | self.init_mta(policy_class=RejectHeloPolicy) 211 | 212 | self.msg.send() 213 | self.assertEqual(False, self.get_transport().is_connected()) 214 | 215 | def test_reopen_connection_when_server_closed_connection(self): 216 | self.config['mail.smtp.max_messages_per_connection'] = 2 217 | class DropEverySecondConnectionPolicy(IMTAPolicy): 218 | def accept_msgdata(self, sender, message): 219 | if not hasattr(self, 'nr_connections'): 220 | self.nr_connections = 0 221 | self.nr_connections = (self.nr_connections + 1) % 2 222 | decision = PolicyDecision(True) 223 | drop_this_connection = (self.nr_connections == 1) 224 | decision._close_connection_after_response = drop_this_connection 225 | return decision 226 | self.init_mta(policy_class=DropEverySecondConnectionPolicy) 227 | 228 | self.msg.send() 229 | first_connection = self.get_connection() 230 | self.msg.send() 231 | second_connection = self.get_connection() 232 | 233 | queue = self.get_received_messages() 234 | self.assertEqual(2, queue.qsize()) 235 | opened_new_connection = (first_connection != second_connection) 236 | self.assertEqual(True, opened_new_connection) 237 | 238 | def test_smtp_shutdown_ignores_socket_errors(self): 239 | self.config['mail.smtp.max_messages_per_connection'] = 2 240 | class CloseConnectionAfterDeliveryPolicy(IMTAPolicy): 241 | def accept_msgdata(self, sender, message): 242 | decision = PolicyDecision(True) 243 | decision._close_connection_after_response = True 244 | return decision 245 | self.init_mta(policy_class=CloseConnectionAfterDeliveryPolicy) 246 | 247 | self.msg.send() 248 | smtp_transport = self.get_transport() 249 | interface.stop(force=True) 250 | 251 | queue = self.get_received_messages() 252 | self.assertEqual(1, queue.qsize()) 253 | self.assertEqual(False, smtp_transport.is_connected()) 254 | 255 | def test_handle_server_which_rejects_all_connections(self): 256 | class RejectAllConnectionsPolicy(IMTAPolicy): 257 | def accept_new_connection(self, peer): 258 | return False 259 | self.init_mta(policy_class=RejectAllConnectionsPolicy) 260 | 261 | self.assertRaises(smtplib.SMTPServerDisconnected, self.msg.send) 262 | 263 | def test_handle_error_when_server_is_not_running_at_all(self): 264 | self.init_mta() 265 | self.assertEqual(None, self.get_transport()) 266 | interface.config['mail.smtp.server'] = 'localhost:47115' 267 | 268 | self.assertRaises(socket.error, self.msg.send) 269 | 270 | def test_can_retry_failed_connection(self): 271 | self.config['mail.message.nr_retries'] = 4 272 | class DropFirstFourConnectionsPolicy(IMTAPolicy): 273 | def accept_msgdata(self, sender, message): 274 | if not hasattr(self, 'nr_connections'): 275 | self.nr_connections = 0 276 | self.nr_connections += 1 277 | return (self.nr_connections > 4) 278 | self.init_mta(policy_class=DropFirstFourConnectionsPolicy) 279 | 280 | msg = self.build_message() 281 | self.assertEqual(4, msg.nr_retries) 282 | msg.send() 283 | 284 | queue = self.get_received_messages() 285 | self.assertEqual(1, queue.qsize()) 286 | 287 | ''' 288 | -------------------------------------------------------------------------------- /marrow/mailer/message.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | """MIME-encoded electronic mail message class.""" 4 | 5 | from __future__ import unicode_literals 6 | 7 | import imghdr 8 | import os 9 | import sys 10 | import time 11 | import base64 12 | 13 | from datetime import datetime 14 | from email.mime.text import MIMEText 15 | from email.mime.multipart import MIMEMultipart 16 | from email.mime.nonmultipart import MIMENonMultipart 17 | from email.utils import make_msgid, formatdate 18 | from mimetypes import guess_type 19 | 20 | from marrow.mailer import release 21 | from marrow.mailer.address import Address, AddressList, AutoConverter 22 | from marrow.util.compat import basestring, unicode, native 23 | 24 | 25 | __all__ = ['Message'] 26 | 27 | 28 | class Message(object): 29 | """Represents an e-mail message.""" 30 | 31 | sender = AutoConverter('_sender', Address, False) 32 | author = AutoConverter('_author', AddressList) 33 | authors = author 34 | to = AutoConverter('_to', AddressList) 35 | cc = AutoConverter('_cc', AddressList) 36 | bcc = AutoConverter('_bcc', AddressList) 37 | reply = AutoConverter('_reply', AddressList) 38 | notify = AutoConverter('_notify', AddressList) 39 | 40 | def __init__(self, author=None, to=None, subject=None, **kw): 41 | """Instantiate a new Message object. 42 | 43 | No arguments are required, as everything can be set using class 44 | properties. Alternatively, __everything__ can be set using the 45 | constructor, using named arguments. The first three positional 46 | arguments can be used to quickly prepare a simple message. 47 | """ 48 | 49 | # Internally used attributes 50 | self._id = None 51 | self._processed = False 52 | self._dirty = False 53 | self.mailer = None 54 | 55 | # Default values 56 | self.subject = None 57 | self.date = datetime.now() 58 | self.encoding = 'utf-8' 59 | self.organization = None 60 | self.priority = None 61 | self.plain = None 62 | self.rich = None 63 | self.attachments = [] 64 | self.embedded = [] 65 | self.headers = [] 66 | self.retries = 3 67 | self.brand = True 68 | 69 | self._sender = None 70 | self._author = AddressList() 71 | self._to = AddressList() 72 | self._cc = AddressList() 73 | self._bcc = AddressList() 74 | self._reply = AddressList() 75 | self._notify = AddressList() 76 | 77 | # Overrides at initialization time 78 | if author is not None: 79 | self.author = author 80 | 81 | if to is not None: 82 | self.to = to 83 | 84 | if subject is not None: 85 | self.subject = subject 86 | 87 | for k in kw: 88 | if not hasattr(self, k): 89 | raise TypeError("Unexpected keyword argument: %s" % k) 90 | 91 | setattr(self, k, kw[k]) 92 | 93 | def __setattr__(self, name, value): 94 | """Set the dirty flag as properties are updated.""" 95 | object.__setattr__(self, name, value) 96 | if name not in ('bcc', '_id', '_dirty', '_processed'): 97 | object.__setattr__(self, '_dirty', True) 98 | 99 | def __str__(self): 100 | return self.mime.as_string() 101 | 102 | __unicode__ = __str__ 103 | 104 | def __bytes__(self): 105 | return self.mime.as_string().encode('ascii') 106 | 107 | @property 108 | def id(self): 109 | if not self._id or (self._processed and self._dirty): 110 | self._id = make_msgid() 111 | self._processed = False 112 | return self._id 113 | 114 | @property 115 | def envelope(self): 116 | """Returns the address of the envelope sender address (SMTP from, if 117 | not set the sender, if this one isn't set too, the author).""" 118 | if not self.sender and not self.author: 119 | raise ValueError("Unable to determine message sender; no author or sender defined.") 120 | 121 | return self.sender or self.author[0] 122 | 123 | @property 124 | def recipients(self): 125 | return AddressList(self.to + self.cc + self.bcc) 126 | 127 | def _mime_document(self, plain, rich=None): 128 | if not rich: 129 | message = plain 130 | 131 | else: 132 | message = MIMEMultipart('alternative') 133 | message.attach(plain) 134 | 135 | if not self.embedded: 136 | message.attach(rich) 137 | 138 | else: 139 | embedded = MIMEMultipart('related') 140 | embedded.attach(rich) 141 | for attachment in self.embedded: 142 | embedded.attach(attachment) 143 | message.attach(embedded) 144 | 145 | if self.attachments: 146 | attachments = MIMEMultipart() 147 | attachments.attach(message) 148 | for attachment in self.attachments: 149 | attachments.attach(attachment) 150 | message = attachments 151 | 152 | return message 153 | 154 | def _build_date_header_string(self, date_value): 155 | """Gets the date_value (may be None, basestring, float or 156 | datetime.datetime instance) and returns a valid date string as per 157 | RFC 2822.""" 158 | if isinstance(date_value, datetime): 159 | date_value = time.mktime(date_value.timetuple()) 160 | if not isinstance(date_value, basestring): 161 | date_value = formatdate(date_value, localtime=True) 162 | # Encode it here to avoid this: 163 | # Date: =?utf-8?q?Sat=2C_01_Sep_2012_13=3A08=3A29_-0300?= 164 | return native(date_value) 165 | 166 | def _build_header_list(self, author, sender): 167 | date_value = self._build_date_header_string(self.date) 168 | 169 | headers = [ 170 | ('Sender', sender), 171 | ('From', author), 172 | ('Reply-To', self.reply), 173 | ('Subject', self.subject), 174 | ('Date', date_value), 175 | ('To', self.to), 176 | ('Cc', self.cc), 177 | ('Disposition-Notification-To', self.notify), 178 | ('Organization', self.organization), 179 | ('X-Priority', self.priority), 180 | ] 181 | 182 | if self.brand: 183 | headers.extend([ 184 | ('X-Mailer', "marrow.mailer {0}".format(release.version)) 185 | ]) 186 | 187 | if isinstance(self.headers, dict): 188 | for key in self.headers: 189 | headers.append((key, self.headers[key])) 190 | 191 | else: 192 | headers.extend(self.headers) 193 | 194 | if 'message-id' not in (header[0].lower() for header in headers): 195 | headers.append(('Message-Id', self.id)) 196 | 197 | return headers 198 | 199 | def _add_headers_to_message(self, message, headers): 200 | for header in headers: 201 | if header[1] is None or (isinstance(header[1], list) and not header[1]): 202 | continue 203 | 204 | name, value = header 205 | 206 | if isinstance(value, (Address, AddressList)): 207 | value = unicode(value) 208 | 209 | message[name] = value 210 | 211 | @property 212 | def mime(self): 213 | """Produce the final MIME message.""" 214 | author = self.author 215 | sender = self.sender 216 | 217 | if not author: 218 | raise ValueError("You must specify an author.") 219 | 220 | if not self.subject: 221 | raise ValueError("You must specify a subject.") 222 | 223 | if len(self.recipients) == 0: 224 | raise ValueError("You must specify at least one recipient.") 225 | 226 | if not self.plain: 227 | raise ValueError("You must provide plain text content.") 228 | 229 | # DISCUSS: Take the first author, or raise this error? 230 | # if len(author) > 1 and len(sender) == 0: 231 | # raise ValueError('If there are multiple authors of message, you must specify a sender!') 232 | 233 | # if len(sender) > 1: 234 | # raise ValueError('You must not specify more than one sender!') 235 | 236 | if not self._dirty and self._processed: 237 | return self._mime 238 | 239 | self._processed = False 240 | 241 | plain = MIMEText(self._callable(self.plain), 'plain', self.encoding) 242 | 243 | rich = None 244 | if self.rich: 245 | rich = MIMEText(self._callable(self.rich), 'html', self.encoding) 246 | 247 | message = self._mime_document(plain, rich) 248 | headers = self._build_header_list(author, sender) 249 | self._add_headers_to_message(message, headers) 250 | 251 | self._mime = message 252 | self._processed = True 253 | self._dirty = False 254 | 255 | return message 256 | 257 | def attach(self, name, data=None, maintype=None, subtype=None, 258 | inline=False, filename=None, filename_charset='', filename_language='', 259 | encoding=None): 260 | """Attach a file to this message. 261 | 262 | :param name: Path to the file to attach if data is None, or the name 263 | of the file if the ``data`` argument is given 264 | :param data: Contents of the file to attach, or None if the data is to 265 | be read from the file pointed to by the ``name`` argument 266 | :type data: bytes or a file-like object 267 | :param maintype: First part of the MIME type of the file -- will be 268 | automatically guessed if not given 269 | :param subtype: Second part of the MIME type of the file -- will be 270 | automatically guessed if not given 271 | :param inline: Whether to set the Content-Disposition for the file to 272 | "inline" (True) or "attachment" (False) 273 | :param filename: The file name of the attached file as seen 274 | by the user in his/her mail client. 275 | :param filename_charset: Charset used for the filename parameter. Allows for 276 | attachment names with characters from UTF-8 or Latin 1. See RFC 2231. 277 | :param filename_language: Used to specify what language the filename is in. See RFC 2231. 278 | :param encoding: Value of the Content-Encoding MIME header (e.g. "gzip" 279 | in case of .tar.gz, but usually empty) 280 | """ 281 | self._dirty = True 282 | 283 | if not maintype: 284 | maintype, guessed_encoding = guess_type(name) 285 | encoding = encoding or guessed_encoding 286 | if not maintype: 287 | maintype, subtype = 'application', 'octet-stream' 288 | else: 289 | maintype, _, subtype = maintype.partition('/') 290 | 291 | part = MIMENonMultipart(maintype, subtype) 292 | part.add_header('Content-Transfer-Encoding', 'base64') 293 | 294 | if encoding: 295 | part.add_header('Content-Encoding', encoding) 296 | 297 | if data is None: 298 | with open(name, 'rb') as fp: 299 | value = fp.read() 300 | name = os.path.basename(name) 301 | elif isinstance(data, bytes): 302 | value = data 303 | elif hasattr(data, 'read'): 304 | value = data.read() 305 | else: 306 | raise TypeError("Unable to read attachment contents") 307 | 308 | part.set_payload(base64.encodestring(value)) 309 | 310 | if not filename: 311 | filename = name 312 | filename = os.path.basename(filename) 313 | 314 | if filename_charset or filename_language: 315 | if not filename_charset: 316 | filename_charset = 'utf-8' 317 | # See https://docs.python.org/2/library/email.message.html#email.message.Message.add_header 318 | # for more information. 319 | # add_header() in the email module expects its arguments to be ASCII strings. Go ahead and handle 320 | # the case where these arguments come in as unicode strings, since encoding ASCII strings 321 | # as UTF-8 can't hurt. 322 | if sys.version_info < (3, 0): 323 | filename=(filename_charset.encode('utf-8'), filename_language.encode('utf-8'), filename.encode('utf-8')) 324 | else: 325 | filename=(filename_charset, filename_language, filename) 326 | 327 | if inline: 328 | if sys.version_info < (3, 0): 329 | part.add_header('Content-Disposition'.encode('utf-8'), 'inline'.encode('utf-8'), filename=filename) 330 | part.add_header('Content-ID'.encode('utf-8'), '<%s>'.encode('utf-8') % filename) 331 | else: 332 | part.add_header('Content-Disposition', 'inline', filename=filename) 333 | part.add_header('Content-ID', '<%s>' % filename) 334 | self.embedded.append(part) 335 | else: 336 | if sys.version_info < (3, 0): 337 | part.add_header('Content-Disposition'.encode('utf-8'), 'attachment'.encode('utf-8'), filename=filename) 338 | else: 339 | part.add_header('Content-Disposition', 'attachment', filename=filename) 340 | self.attachments.append(part) 341 | 342 | def embed(self, name, data=None): 343 | """Attach an image file and prepare for HTML embedding. 344 | 345 | This method should only be used to embed images. 346 | 347 | :param name: Path to the image to embed if data is None, or the name 348 | of the file if the ``data`` argument is given 349 | :param data: Contents of the image to embed, or None if the data is to 350 | be read from the file pointed to by the ``name`` argument 351 | """ 352 | if data is None: 353 | with open(name, 'rb') as fp: 354 | data = fp.read() 355 | name = os.path.basename(name) 356 | elif isinstance(data, bytes): 357 | pass 358 | elif hasattr(data, 'read'): 359 | data = data.read() 360 | else: 361 | raise TypeError("Unable to read image contents") 362 | 363 | subtype = imghdr.what(None, data) 364 | self.attach(name, data, 'image', subtype, True) 365 | 366 | @staticmethod 367 | def _callable(var): 368 | if hasattr(var, '__call__'): 369 | return var() 370 | 371 | return var 372 | 373 | def send(self): 374 | if not self.mailer: 375 | raise NotImplementedError("Message instance is not bound to " \ 376 | "a Mailer. Use mailer.send() instead.") 377 | return self.mailer.send(self) 378 | -------------------------------------------------------------------------------- /marrow/mailer/validator.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # This file was taken from webpyte (r179): 4 | # http://code.google.com/p/webpyte/source/browse/trunk/webpyte/email_validator.py 5 | # According to the docstring, it is licensed as 'public domain' 6 | # 7 | # Modifications: 8 | # * Wed Mar 25 2009 Felix Schwarz 9 | # - Removed 'from __future__ import absolute_import to stay compatible with Python 2.3/2.4 10 | # * Fri Mar 27 2009 Felix Schwarz 11 | # - Disabled DNS server discovery on module import 12 | # - added __all__ declaration 13 | # - modified domain validator so that domains without second-level domain will 14 | # be accepted as well. 15 | # 16 | 17 | """A method of validating e-mail addresses and mail domains. 18 | 19 | This module aims to provide the ultimate functions for: 20 | * domain validation, and 21 | * e-mail validation. 22 | 23 | Why not just use a regular expression? 24 | ====================================== 25 | http://haacked.com/archive/2007/08/21/i-knew-how-to-validate-an-email-address-until-i.aspx 26 | 27 | There are many regular expressions out there for this. The "perfect one" is 28 | several KB long and therefore unmaintainable (Perl people wrote it...). 29 | 30 | This is 2009 and domain rules are changing too. Impossible domain names have 31 | become possible, international domain names are real... 32 | 33 | So validating an e-mail address is more complex than you might think. Take a 34 | look at some of the rules: 35 | http://en.wikipedia.org/wiki/E-mail_address#RFC_specification 36 | 37 | How to do it then? 38 | ================== 39 | I believe the solution should combine simple regular expressions with 40 | imperative programming. 41 | 42 | E-mail validation is also dependent on the robustness principle: 43 | "Be conservative in what you do, be liberal in what you accept from others." 44 | http://en.wikipedia.org/wiki/Postel%27s_law 45 | 46 | This module recognizes that e-mail validation can be done in several different 47 | ways, according to purpose: 48 | 49 | 1) Most of the time you just want validation according to the standard rules. 50 | So just say: v = EmailValidator() 51 | 52 | 2) If you are creating e-mail addresses for your server or your organization, 53 | you might need to satisfy a stricter policy such as "dash is not allowed in 54 | email addresses". The EmailValidator constructor accepts a *local_part_chars* 55 | argument to help build the right regular expression for you. 56 | Example: v = EmailValidator(local_part_chars='.-+_') 57 | 58 | 3) What about typos? An erroneous dot at the end of a typed email is typical. 59 | Other common errors with the dots revolve around the @: user@.domain.com. 60 | These typing mistakes can be automatically corrected, saving you from doing 61 | it manually. For this you use the *fix* flag when instantiating a validator: 62 | 63 | d = DomainValidator(fix=True) 64 | domain, error_message = d.validate('.supercalifragilistic.com.br') 65 | if error_message: 66 | print 'Invalid domain: ' + domain 67 | else: 68 | print 'Valid domain: ' + domain 69 | 70 | 4) TODO: Squash the bugs in this feature! 71 | Paranoid people may wish to verify that the informed domain actually exists. 72 | For that you can pass a *lookup_dns='a'* argument to the constructor, or even 73 | *lookup_dns='mx'* to verify that the domain actually has e-mail servers. 74 | To use this feature, you need to install the *pydns* library: 75 | 76 | easy_install -UZ pydns 77 | 78 | How to use 79 | ========== 80 | 81 | The validating methods return a tuple (email, error_msg). 82 | *email* is the trimmed and perhaps fixed email. 83 | *error_msg* is an empty string when the e-mail is valid. 84 | 85 | Typical usage is: 86 | 87 | v = EmailValidator() # or EmailValidator(fix=True) 88 | email = raw_input('Type an email: ') 89 | email, err = v.validate(email) 90 | if err: 91 | print 'Error: ' + err 92 | else: 93 | print 'E-mail is valid: ' + email # the email, corrected 94 | 95 | There is also an EmailHarvester class to collect e-mail addresses from any text. 96 | 97 | Authors: Nando Florestan, Marco Ferreira 98 | Code written in 2009 and donated to the public domain. 99 | """ 100 | 101 | import re 102 | 103 | __all__ = ['ValidationException', 'BaseValidator', 'DomainValidator', 'EmailValidator', 'EmailHarvester'] 104 | 105 | 106 | class ValidationException(ValueError): 107 | pass 108 | 109 | 110 | class BaseValidator(object): 111 | def validate_or_raise(self, *a, **k): 112 | """Some people would condemn this whole module screaming: 113 | "Don't return success codes, use exceptions!" 114 | This method allows them to be happy, too. 115 | """ 116 | 117 | validate, err = self.validate(*a, **k) 118 | 119 | if err: 120 | raise ValidationException(err) 121 | 122 | return validate 123 | 124 | 125 | class DomainValidator(BaseValidator): 126 | """A domain name validator that is ready for internationalized domains. 127 | 128 | http://en.wikipedia.org/wiki/Internationalized_domain_name 129 | http://en.wikipedia.org/wiki/Top-level_domain 130 | """ 131 | # non_international_regex = re.compile(r"^[a-z0-9][a-z0-9\.\-]*\.[a-z]+$", 132 | #domain_pattern = r'[\w][\w\.\-]+?\.[\w]+' 133 | # fs: New domain regex that accepts domains without second-level domain also 134 | domain_pattern = r'[\w]+([\w\.\-]+\w)?' 135 | domain_regex = \ 136 | re.compile('^' + domain_pattern + '$', re.IGNORECASE | re.UNICODE) 137 | 138 | # OpenDNS has a feature that bites us. If you are using OpenDNS, and you 139 | # type in your browser a domain that does not exist, OpenDNS catches that 140 | # and presents a page. "Did you mean www.hovercraft.eels?" 141 | # For us, this feature appears as a false positive when looking up the 142 | # DNS server. So we try to work around it: 143 | false_positive_ips = ['208.67.217.132'] 144 | 145 | def __init__(self, fix=False, lookup_dns=None): 146 | self.fix = fix 147 | 148 | if lookup_dns: 149 | try: 150 | import DNS 151 | except ImportError: # pragma: no cover 152 | raise ImportError("To enable DNS lookup of domains install the PyDNS package.") 153 | 154 | lookup_dns = lookup_dns.lower() 155 | if lookup_dns not in ('a', 'mx'): 156 | raise RuntimeError("Not a valid *lookup_dns* value: " + lookup_dns) 157 | 158 | self._lookup_dns = lookup_dns 159 | 160 | def _apply_common_rules(self, part, maxlength): 161 | """This method contains the rules that must be applied to both the 162 | domain and the local part of the e-mail address. 163 | """ 164 | part = part.strip() 165 | 166 | if self.fix: 167 | part = part.strip('.') 168 | 169 | if not part: 170 | return part, 'It cannot be empty.' 171 | 172 | if len(part) > maxlength: 173 | return part, 'It cannot be longer than %i chars.' % maxlength 174 | 175 | if part[0] == '.': 176 | return part, 'It cannot start with a dot.' 177 | 178 | if part[-1] == '.': 179 | return part, 'It cannot end with a dot.' 180 | 181 | if '..' in part: 182 | return part, 'It cannot contain consecutive dots.' 183 | 184 | return part, '' 185 | 186 | def validate_domain(self, part): 187 | part, err = self._apply_common_rules(part, maxlength=255) 188 | 189 | if err: 190 | return part, 'Invalid domain: %s' % err 191 | 192 | if not self.domain_regex.search(part): 193 | return part, 'Invalid domain.' 194 | 195 | if self._lookup_dns and not self.lookup_domain(part): 196 | return part, 'Domain does not seem to exist.' 197 | 198 | return part.lower(), '' 199 | 200 | validate = validate_domain 201 | 202 | # TODO: As an option, DNS lookup on the domain: 203 | # http://mail.python.org/pipermail/python-list/2008-July/497997.html 204 | 205 | def lookup_domain(self, domain, lookup_record=None, **kw): 206 | """Looks up the DNS record for *domain* and returns: 207 | 208 | * None if it does not exist, 209 | * The IP address if looking up the "A" record, or 210 | * The list of hosts in the "MX" record. 211 | 212 | The return value, if treated as a boolean, says whether a domain exists. 213 | 214 | You can pass "a" or "mx" as the *lookup_record* parameter. Otherwise, 215 | the *lookup_dns* parameter from the constructor is used. 216 | "a" means verify that the domain exists. 217 | "mx" means verify that the domain exists and specifies mail servers. 218 | """ 219 | import DNS 220 | 221 | lookup_record = lookup_record.lower() if lookup_record else self._lookup_dns 222 | 223 | if lookup_record not in ('a', 'mx'): 224 | raise RuntimeError("Not a valid lookup_record value: " + lookup_record) 225 | 226 | if lookup_record == "a": 227 | request = DNS.Request(domain, **kw) 228 | 229 | try: 230 | answers = request.req().answers 231 | 232 | except (DNS.Lib.PackError, UnicodeError): 233 | # A part of the domain name is longer than 63. 234 | return False 235 | 236 | if not answers: 237 | return False 238 | 239 | result = answers[0]['data'] # This is an IP address 240 | 241 | if result in self.false_positive_ips: # pragma: no cover 242 | return False 243 | 244 | return result 245 | 246 | try: 247 | return DNS.mxlookup(domain) 248 | 249 | except UnicodeError: 250 | pass 251 | 252 | return False 253 | 254 | 255 | class EmailValidator(DomainValidator): 256 | # TODO: Implement all rules! 257 | # http://tools.ietf.org/html/rfc3696 258 | # http://en.wikipedia.org/wiki/E-mail_address#RFC_specification 259 | # TODO: Local part in quotes? 260 | # TODO: Quoted-printable local part? 261 | 262 | def __init__(self, local_part_chars=".-+_!#$%&'/=`|~?^{}*", **k): 263 | super(EmailValidator, self).__init__(**k) 264 | # Add a backslash before the dash so it can go into the regex: 265 | self.local_part_pattern = '[a-z0-9' + local_part_chars.replace('-', r'\-') + ']+' 266 | # Regular expression for validation: 267 | self.local_part_regex = re.compile('^' + self.local_part_pattern + '$', re.IGNORECASE) 268 | 269 | def validate_local_part(self, part): 270 | part, err = self._apply_common_rules(part, maxlength=64) 271 | if err: 272 | return part, 'Invalid local part: %s' % err 273 | if not self.local_part_regex.search(part): 274 | return part, 'Invalid local part.' 275 | return part, '' 276 | # We don't go lowercase because the local part is case-sensitive. 277 | 278 | def validate_email(self, email): 279 | if not email: 280 | return email, 'The e-mail is empty.' 281 | 282 | parts = email.split('@') 283 | 284 | if len(parts) != 2: 285 | return email, 'An email address must contain a single @' 286 | 287 | local, domain = parts 288 | 289 | # Validate the domain 290 | domain, err = self.validate_domain(domain) 291 | if err: 292 | return email, "The e-mail has a problem to the right of the @: %s" % err 293 | 294 | # Validate the local part 295 | local, err = self.validate_local_part(local) 296 | if err: 297 | return email, "The email has a problem to the left of the @: %s" % err 298 | 299 | # It is valid 300 | return local + '@' + domain, '' 301 | 302 | validate = validate_email 303 | 304 | 305 | class EmailHarvester(EmailValidator): 306 | def __init__(self, *a, **k): 307 | super(EmailHarvester, self).__init__(*a, **k) 308 | # Regular expression for harvesting: 309 | self.harvest_regex = \ 310 | re.compile(self.local_part_pattern + '@' + self.domain_pattern, 311 | re.IGNORECASE | re.UNICODE) 312 | 313 | def harvest(self, text): 314 | """Iterator that yields the e-mail addresses contained in *text*.""" 315 | for match in self.harvest_regex.finditer(text): 316 | # TODO: optionally validate before yielding? 317 | # TODO: keep a list of harvested but not validated? 318 | yield match.group().replace('..', '.') 319 | 320 | 321 | # rfc822_specials = '()<>@,;:\\"[]' 322 | 323 | # is_address_valid(addr): 324 | # # First we validate the name portion (name@domain) 325 | # c = 0 326 | # while c < len(addr): 327 | # if addr[c] == '"' and (not c or addr[c - 1] == '.' or addr[c - 1] == '"'): 328 | # c = c + 1 329 | # while c < len(addr): 330 | # if addr[c] == '"': break 331 | # if addr[c] == '\\' and addr[c + 1] == ' ': 332 | # c = c + 2 333 | # continue 334 | # if ord(addr[c]) < 32 or ord(addr[c]) >= 127: return 0 335 | # c = c + 1 336 | # else: return 0 337 | # if addr[c] == '@': break 338 | # if addr[c] != '.': return 0 339 | # c = c + 1 340 | # continue 341 | # if addr[c] == '@': break 342 | # if ord(addr[c]) <= 32 or ord(addr[c]) >= 127: return 0 343 | # if addr[c] in rfc822_specials: return 0 344 | # c = c + 1 345 | # if not c or addr[c - 1] == '.': return 0 346 | # 347 | # # Next we validate the domain portion (name@domain) 348 | # domain = c = c + 1 349 | # if domain >= len(addr): return 0 350 | # count = 0 351 | # while c < len(addr): 352 | # if addr[c] == '.': 353 | # if c == domain or addr[c - 1] == '.': return 0 354 | # count = count + 1 355 | # if ord(addr[c]) <= 32 or ord(addr[c]) >= 127: return 0 356 | # if addr[c] in rfc822_specials: return 0 357 | # c = c + 1 358 | # 359 | # return count >= 1 360 | -------------------------------------------------------------------------------- /test/test_message.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | """Test the TurboMail Message class.""" 3 | 4 | from __future__ import print_function, unicode_literals 5 | 6 | import calendar 7 | from datetime import datetime, timedelta 8 | import email 9 | import sys 10 | import re 11 | import time 12 | import pytest 13 | import base64 14 | 15 | from unittest import TestCase 16 | from email.mime.text import MIMEText 17 | from email.utils import formatdate, parsedate_tz 18 | 19 | from marrow.mailer import Message 20 | from marrow.mailer.address import AddressList 21 | from marrow.util.compat import basestring, unicode 22 | 23 | 24 | class TestBasicMessage(TestCase): 25 | """Test the basic output of the Message class.""" 26 | 27 | gif = b'R0lGODlhAQABAJEAAAAAAAAAAP4BAgAAACH5BAQUAP8ALAAAAAABAAEAAAICRAEAOw==\n' 28 | 29 | def build_message(self, **kw): 30 | return Message( 31 | author=('Author', 'author@example.com'), 32 | to=('Recipient', 'recipient@example.com'), 33 | subject='Test message subject.', 34 | plain='This is a test message plain text body.', 35 | **kw 36 | ) 37 | 38 | def test_missing_values(self): 39 | message = Message() 40 | with pytest.raises(ValueError): 41 | unicode(message) 42 | 43 | message.author = "bob.dole@whitehouse.gov" 44 | with pytest.raises(ValueError): 45 | unicode(message) 46 | 47 | message.subject = "Attn: Bob Dole" 48 | with pytest.raises(ValueError): 49 | unicode(message) 50 | 51 | message.to = "user@example.com" 52 | with pytest.raises(ValueError): 53 | unicode(message) 54 | 55 | message.plain = "Testing!" 56 | 57 | try: 58 | unicode(message) 59 | except ValueError: 60 | assert False, "Message should be valid." 61 | 62 | def test_message_id(self): 63 | msg = self.build_message() 64 | 65 | assert msg._id == None 66 | 67 | id_ = msg.id 68 | assert msg._id == id_ 69 | 70 | assert msg.id == id_ 71 | 72 | def test_missing_author(self): 73 | message = self.build_message() 74 | message.author = [] 75 | 76 | with pytest.raises(ValueError): 77 | message.envelope 78 | 79 | def test_message_properties(self): 80 | message = self.build_message() 81 | assert message.author == [("Author", "author@example.com")] 82 | assert unicode(message.author) == "Author " 83 | assert isinstance(message.mime, MIMEText) 84 | 85 | def test_message_string_with_basic(self): 86 | msg = email.message_from_string(str(self.build_message(encoding="iso-8859-1"))) 87 | 88 | assert msg['From'] == 'Author ' 89 | assert msg['To'] == 'Recipient ' 90 | assert msg['Subject'] == 'Test message subject.' 91 | assert msg.get_payload() == 'This is a test message plain text body.' 92 | 93 | def test_message_recipients_and_addresses(self): 94 | message = self.build_message() 95 | 96 | message.cc = 'cc@example.com' 97 | message.bcc = 'bcc@example.com' 98 | message.sender = 'sender@example.com' 99 | message.reply = 'replyto@example.com' 100 | message.notify = 'disposition@example.com' 101 | 102 | msg = email.message_from_string(unicode(message)) 103 | 104 | assert msg['cc'] == 'cc@example.com' 105 | assert msg['bcc'] == None 106 | assert msg['sender'] == 'sender@example.com' 107 | assert msg['reply-to'] == 'replyto@example.com' 108 | assert msg['disposition-notification-to'] == 'disposition@example.com' 109 | 110 | def test_mime_generation_plain(self): 111 | message = self.build_message() 112 | mime = message.mime 113 | 114 | assert message.mime is mime 115 | message.subject = "Test message subject." 116 | assert message.mime is not mime 117 | 118 | def test_mime_generation_rich(self): 119 | message = self.build_message() 120 | message.plain = "Hello world." 121 | message.rich = "Farewell cruel world." 122 | 123 | assert 'Hello world.' in str(message) 124 | assert 'Farewell cruel world.' in str(message) 125 | 126 | def test_mime_generation_rich_embedded(self): 127 | message = self.build_message() 128 | message.plain = "Hello world." 129 | message.rich = "Farewell cruel world." 130 | 131 | message.attach("hello.txt", b"Fnord.", "text", "plain", True) 132 | 133 | assert 'Hello world.' in unicode(message) 134 | assert 'Farewell cruel world.' in unicode(message) 135 | assert 'hello.txt' in unicode(message) 136 | assert 'Rm5vcmQu' in unicode(message) # Fnord. in base64 137 | 138 | def test_mime_attachments(self): 139 | message = self.build_message() 140 | message.plain = "Hello world." 141 | message.rich = "Farewell cruel world." 142 | 143 | message.attach("hello.txt", b"Fnord.") 144 | 145 | assert 'Hello world.' in unicode(message) 146 | assert 'Farewell cruel world.' in unicode(message) 147 | assert 'hello.txt' in unicode(message) 148 | assert 'Rm5vcmQu' in unicode(message) # Fnord. in base64 149 | assert 'text/plain\n' in unicode(message) 150 | 151 | def test_mime_attachments_unknown(self): 152 | message = self.build_message() 153 | message.plain = "Hello world." 154 | message.rich = "Farewell cruel world." 155 | message.attach('test.xbin', b"Word.") 156 | assert 'test.xbin' in str(message) 157 | assert 'application/octet-stream' in str(message) 158 | 159 | with pytest.raises(TypeError): 160 | message.attach('foo', object()) 161 | 162 | def test_non_ascii_attachment_names(self): 163 | message = self.build_message() 164 | message.plain = "Hello world." 165 | message.rich = "Farewell cruel world." 166 | message.attach("☃.txt", b"unicode snowman", filename_charset='utf-8') 167 | 168 | assert 'Hello world.' in unicode(message) 169 | assert 'Farewell cruel world.' in unicode(message) 170 | if sys.version_info < (3, 0): 171 | assert 'filename*="utf-8\'\'%E2%98%83.txt"' in unicode(message) # ☃ is encoded in ASCII as \xe2\x98\x83, which is URL encoded as %E2%98%83 172 | else: 173 | assert 'filename*=utf-8\'\'%E2%98%83.txt' in unicode(message) # ☃ is encoded in ASCII as \xe2\x98\x83, which is URL encoded as %E2%98%83 174 | assert 'dW5pY29kZSBzbm93bWFu' in unicode(message) # unicode snowman in base64 175 | 176 | def test_language_specification_and_charset_for_attachment_name(self): 177 | message = self.build_message() 178 | message.plain = "Hello world." 179 | message.rich = "Farewell cruel world." 180 | message.attach("☃.txt", b"unicode snowman", filename_charset='utf-8', filename_language='en-us') 181 | 182 | assert 'Hello world.' in unicode(message) 183 | assert 'Farewell cruel world.' in unicode(message) 184 | 185 | if sys.version_info < (3, 0): 186 | assert 'filename*="utf-8\'en-us\'%E2%98%83.txt"' in unicode(message) # ☃ is encoded in ASCII as \xe2\x98\x83, which is URL encoded as %E2%98%83 187 | else: 188 | assert 'filename*=utf-8\'en-us\'%E2%98%83.txt' in unicode(message) # ☃ is encoded in ASCII as \xe2\x98\x83, which is URL encoded as %E2%98%83 189 | assert 'dW5pY29kZSBzbm93bWFu' in unicode(message) # unicode snowman in base64 190 | 191 | def test_language_specification_but_no_charset_for_attachment_name(self): 192 | message = self.build_message() 193 | message.plain = "Hello world." 194 | message.rich = "Farewell cruel world." 195 | message.attach("☃.txt", b"unicode snowman", filename_language='en-us') 196 | 197 | assert 'Hello world.' in unicode(message) 198 | assert 'Farewell cruel world.' in unicode(message) 199 | if sys.version_info < (3, 0): 200 | assert 'filename*="utf-8\'en-us\'%E2%98%83.txt"' in unicode(message) # ☃ is encoded in ASCII as \xe2\x98\x83, which is URL encoded as %E2%98%83 201 | else: 202 | assert 'filename*=utf-8\'en-us\'%E2%98%83.txt' in unicode(message) # ☃ is encoded in ASCII as \xe2\x98\x83, which is URL encoded as %E2%98%83 203 | assert 'dW5pY29kZSBzbm93bWFu' in unicode(message) # unicode snowman in base64 204 | 205 | 206 | def test_mime_attachments_file(self): 207 | import tempfile 208 | 209 | message = self.build_message() 210 | message.plain = "Hello world." 211 | message.rich = "Farewell cruel world." 212 | 213 | with tempfile.NamedTemporaryFile(mode='wb') as fh: 214 | fh.write(b"foo") 215 | fh.flush() 216 | 217 | message.attach(fh.name) 218 | assert 'application/octet-stream' in str(message) 219 | assert 'Zm9v' in str(message) # foo in base64 220 | 221 | def test_mime_attachments_filelike(self): 222 | class Mock(object): 223 | def read(self): 224 | return b'foo' 225 | 226 | message = self.build_message() 227 | message.plain = "Hello world." 228 | message.rich = "Farewell cruel world." 229 | message.attach('test.xbin', Mock()) 230 | assert 'test.xbin' in str(message) 231 | assert 'application/octet-stream' in str(message) 232 | assert 'Zm9v' in str(message) # foo in base64 233 | 234 | def test_mime_embed_gif_file(self): 235 | import tempfile 236 | 237 | message = self.build_message() 238 | message.plain = "Hello world." 239 | message.rich = "Farewell cruel world." 240 | 241 | with tempfile.NamedTemporaryFile() as fh: 242 | fh.write(base64.b64decode(self.gif)) 243 | fh.flush() 244 | 245 | message.embed(fh.name) 246 | 247 | result = bytes(message) 248 | 249 | assert b'image/gif' in result 250 | assert b'R0lGODlh' in result # GIF89a in base64 251 | 252 | def test_mime_embed_gif_bytes(self): 253 | message = self.build_message() 254 | message.plain = "Hello world." 255 | message.rich = "Farewell cruel world." 256 | message.embed('test.gif', base64.b64decode(self.gif)) 257 | 258 | result = bytes(message) 259 | 260 | assert b'image/gif' in result 261 | assert b'R0lGODlh' in result # GIF89a in base64 262 | 263 | class Mock(object): 264 | def read(s): 265 | return base64.b64decode(self.gif) 266 | 267 | message = self.build_message() 268 | message.plain = "Hello world." 269 | message.rich = "Farewell cruel world." 270 | message.embed('test.gif', Mock()) 271 | 272 | result = bytes(message) 273 | 274 | assert b'image/gif' in result 275 | assert b'R0lGODlh' in result # GIF89a in base64 276 | 277 | def test_mime_embed_failures(self): 278 | message = self.build_message() 279 | message.plain = "Hello world." 280 | message.rich = "Farewell cruel world." 281 | 282 | with pytest.raises(TypeError): 283 | message.embed('test.gif', object()) 284 | 285 | def test_that_add_header_and_collapse_header_are_inverses_ascii_filename(self): 286 | message = self.build_message() 287 | message.plain = "Hello world." 288 | message.rich = "Farewell cruel world." 289 | message.attach("wat.txt", b"not a unicode snowman") # calls add_header() under the covers 290 | attachment = message.attachments[0] 291 | filename = attachment.get_filename() # calls email.utils.collapse_rfc2231_value() under the covers 292 | assert filename == "wat.txt" 293 | 294 | def test_that_add_header_and_collapse_header_are_inverses_non_ascii_filename(self): 295 | message = self.build_message() 296 | message.plain = "Hello world." 297 | message.rich = "Farewell cruel world." 298 | message.attach("☃.txt", b"unicode snowman", filename_language='en-us') 299 | attachment = message.attachments[0] 300 | filename = attachment.get_param('filename', object(), 'content-disposition') # get_filename() calls this under the covers 301 | assert isinstance(filename, tuple) # Since attachment encoded according to RFC2231, should be represented as a tuple 302 | filename = attachment.get_filename() # Calls email.utils.collapse_rfc2231_value() under the covers, currently fails 303 | if sys.version_info < (3, 0): 304 | assert isinstance(filename, basestring) # Successfully converts tuple to a string 305 | else: 306 | assert isinstance(filename, str) 307 | 308 | def test_recipients_collection(self): 309 | message = self.build_message() 310 | message.cc.append("copied@example.com") 311 | assert message.recipients.addresses == ["recipient@example.com", "copied@example.com"] 312 | 313 | def test_smtp_from_as_envelope(self): 314 | message = self.build_message() 315 | message.sender = 'devnull@example.com' 316 | assert str(message.envelope) == 'devnull@example.com' 317 | 318 | # This sorta works, it just ignores the message encoding and always uses utf-8. :( 319 | #def test_subject_with_umlaut(self): 320 | # message = self.build_message() 321 | # 322 | # subject_string = "Test with äöü" 323 | # message.subject = subject_string 324 | # message.encoding = "UTF-8" 325 | # 326 | # msg = email.message_from_string(str(message)) 327 | # encoded_subject = Header(subject_string, "UTF-8").encode() 328 | # assert encoded_subject == msg['Subject'] 329 | 330 | # This sorta works, it just ignores the message encoding and always uses utf-8. :( 331 | #def test_from_with_umlaut(self): 332 | # message = self.build_message() 333 | # 334 | # from_name = "Karl Müller" 335 | # from_email = "karl.mueller@example.com" 336 | # 337 | # message.author = [(from_name, from_email)] 338 | # message.encoding = "ISO-8859-1" 339 | # 340 | # msg = email.message_from_string(str(message)) 341 | # encoded_name = "%s <%s>" % (str(Header(from_name, "ISO-8859-1")), from_email) 342 | # assert encoded_name == msg['From'] 343 | 344 | def test_multiple_authors(self): 345 | message = self.build_message() 346 | 347 | message.authors = 'authors@example.com' 348 | assert message.authors == message.author 349 | 350 | message.authors = ['bar@example.com', 'baz@example.com'] 351 | message.sender = 'foo@example.com' 352 | msg = email.message_from_string(str(message)) 353 | from_addresses = re.split(r",\n?\s+", msg['From']) 354 | assert from_addresses == ['bar@example.com', 'baz@example.com'] 355 | 356 | # def test_multiple_authors_require_sender(self): 357 | # message = self.build_message() 358 | # 359 | # message.authors = ['bar@example.com', 'baz@example.com'] 360 | # self.assertRaises(ValueError, str, message) 361 | # 362 | # message.sender = 'bar@example.com' 363 | # str(message) 364 | 365 | def test_permit_one_sender_at_most(self): 366 | with pytest.raises(ValueError): 367 | message = self.build_message() 368 | message.sender = AddressList(['bar@example.com', 'baz@example.com']) 369 | 370 | def test_raise_error_for_unknown_kwargs_at_class_instantiation(self): 371 | with pytest.raises(TypeError): 372 | Message(invalid_argument=True) 373 | 374 | def test_add_custom_headers_dict(self): 375 | message = self.build_message() 376 | message.headers = {'Precedence': 'bulk', 'X-User': 'Alice'} 377 | msg = email.message_from_string(str(message)) 378 | 379 | assert msg['Precedence'] == 'bulk' 380 | assert msg['X-User'] == 'Alice' 381 | 382 | def test_add_custom_headers_tuple(self): 383 | message = self.build_message() 384 | message.headers = (('Precedence', 'bulk'), ('X-User', 'Alice')) 385 | 386 | msg = email.message_from_string(str(message)) 387 | assert msg['Precedence'] == 'bulk' 388 | assert msg['X-User'] == 'Alice' 389 | 390 | def test_add_custom_headers_list(self): 391 | "Test that a custom header (list type) can be attached." 392 | message = self.build_message() 393 | message.headers = [('Precedence', 'bulk'), ('X-User', 'Alice')] 394 | 395 | msg = email.message_from_string(str(message)) 396 | assert msg['Precedence'] == 'bulk' 397 | assert msg['X-User'] == 'Alice' 398 | 399 | def test_no_sender_header_if_no_sender_required(self): 400 | message = self.build_message() 401 | msg = email.message_from_string(str(message)) 402 | assert msg['sender'] is None 403 | 404 | def _date_header_to_utc_datetime(self, date_string): 405 | """Converts a date_string from the Date header into a naive datetime 406 | object in UTC.""" 407 | # There is pytz which could solve whole isssue but it is not in Fedora 408 | # EPEL 4 currently so I don't want to depend on out-of-distro modules - 409 | # hopefully I'll get it right anyway... 410 | assert date_string != None 411 | tztime_struct = parsedate_tz(date_string) 412 | time_tuple, tz_offset = (tztime_struct[:9], tztime_struct[9]) 413 | epoch_utc_seconds = calendar.timegm(time_tuple) 414 | if tz_offset is not None: 415 | epoch_utc_seconds -= tz_offset 416 | datetime_obj = datetime.utcfromtimestamp(epoch_utc_seconds) 417 | return datetime_obj 418 | 419 | def _almost_now(self, date_string): 420 | """Returns True if the date_string represents a time which is 'almost 421 | now'.""" 422 | utc_date = self._date_header_to_utc_datetime(date_string) 423 | delta = abs(datetime.utcnow() - utc_date) 424 | return (delta < timedelta(seconds=1)) 425 | 426 | def test_date_header_added_even_if_date_not_set_explicitely(self): 427 | message = self.build_message() 428 | msg = email.message_from_string(str(message)) 429 | assert self._almost_now(msg['Date']) 430 | 431 | def test_date_can_be_set_as_string(self): 432 | message = self.build_message() 433 | date_string = 'Fri, 26 Dec 2008 11:19:42 +0530' 434 | message.date = date_string 435 | msg = email.message_from_string(str(message)) 436 | assert msg['Date'] == date_string 437 | 438 | def test_date_can_be_set_as_float(self): 439 | message = self.build_message() 440 | expected_date = datetime(2008, 12, 26, 12, 55) 441 | expected_time = time.mktime(expected_date.timetuple()) 442 | message.date = expected_time 443 | msg = email.message_from_string(str(message)) 444 | header_string = msg['Date'] 445 | header_date = self._date_header_to_utc_datetime(header_string) 446 | assert header_date == self.localdate_to_utc(expected_date) 447 | expected_datestring = formatdate(expected_time, localtime=True) 448 | assert expected_datestring == header_string 449 | 450 | def localdate_to_utc(self, localdate): 451 | local_epoch_seconds = time.mktime(localdate.timetuple()) 452 | date_string = formatdate(local_epoch_seconds, localtime=True) 453 | return self._date_header_to_utc_datetime(date_string) 454 | 455 | def test_date_can_be_set_as_datetime(self): 456 | message = self.build_message() 457 | expected_date = datetime(2008, 12, 26, 12, 55) 458 | message.date = expected_date 459 | msg = email.message_from_string(str(message)) 460 | header_date = self._date_header_to_utc_datetime(msg['Date']) 461 | assert self.localdate_to_utc(expected_date) == header_date 462 | 463 | def test_date_header_is_set_even_if_reset_to_none(self): 464 | message = self.build_message() 465 | message.date = None 466 | msg = email.message_from_string(str(message)) 467 | assert self._almost_now(msg['Date']) 468 | 469 | def test_recipients_property_includes_cc_and_bcc(self): 470 | message = self.build_message() 471 | message.cc = 'cc@example.com' 472 | message.bcc = 'bcc@example.com' 473 | expected_recipients = ['recipient@example.com', 'cc@example.com', 474 | 'bcc@example.com'] 475 | recipients = list(map(str, list(message.recipients.addresses))) 476 | assert recipients == expected_recipients 477 | 478 | def test_can_set_encoding_for_message_explicitely(self): 479 | message = self.build_message() 480 | assert 'iso-8859-1' not in unicode(message).lower() 481 | message.encoding = 'ISO-8859-1' 482 | msg = email.message_from_string(str(message)) 483 | assert msg['Content-Type'] == 'text/plain; charset="iso-8859-1"' 484 | assert msg['Content-Transfer-Encoding'] == 'quoted-printable' 485 | 486 | # def test_message_encoding_can_be_set_in_config_file(self): 487 | # interface.config['mail.message.encoding'] = 'ISO-8859-1' 488 | # message = self.build_message() 489 | # msg = email.message_from_string(str(message)) 490 | # self.assertEqual('text/plain; charset="iso-8859-1"', msg['Content-Type']) 491 | # self.assertEqual('quoted-printable', msg['Content-Transfer-Encoding']) 492 | 493 | def test_plain_utf8_encoding_uses_qp(self): 494 | message = self.build_message() 495 | msg = email.message_from_string(str(message)) 496 | assert msg['Content-Type'] == 'text/plain; charset="utf-8"' 497 | assert msg['Content-Transfer-Encoding'] == 'quoted-printable' 498 | 499 | def test_callable_bodies(self): 500 | message = self.build_message() 501 | message.plain = lambda: "plain text" 502 | message.rich = lambda: "rich text" 503 | 504 | assert 'plain text' in unicode(message) 505 | assert 'rich text' in unicode(message) 506 | 507 | -------------------------------------------------------------------------------- /README.textile: -------------------------------------------------------------------------------- 1 | h1(#title). Marrow Mailer 2 | 3 | bq(subtitle). A highly efficient and modular mail delivery framework for Python 2.6+ and 3.2+, formerly called TurboMail. 4 | 5 | bq(byline). (C) 2006-2023, Alice Bevan-McGregor and contributors. 6 | 7 | bq(byline). "https://github.com/marrow/mailer":github-project 8 | 9 | [github-project]https://github.com/marrow/mailer 10 | 11 | 12 | 13 | h2(#what-is). %1.% What is Marrow Mailer? 14 | 15 | Marrow Mailer is a Python library to ease sending emails from your application. 16 | 17 | By using Marrow Mailer you can: 18 | 19 | * Easily construct plain text and HTML emails. 20 | * Improve the testability of your e-mail deliveries. 21 | * Use different mail delivery management strategies; e.g. immediate, deferred, or even multi-server. 22 | * Deliver e-mail through a number of alternative transports including SMTP, Amazon SES, sendmail, or even via direct on-disk mbox/maildir. 23 | * Multiple simultaneous configurations for more targeted delivery. 24 | 25 | Mailer supports Python 2.6+ and 3.2+ and there are only light-weight dependencies: @marrow.util@, @marrow.interface@, and @boto3@ if using Amazon SES. 26 | 27 | 28 | h3(#goals). %1.1.% Goals 29 | 30 | Marrow Mailer is all about making email delivery easy. Attempting to utilize the built-in MIME message generation classes can be painful, and interfacing with an SMTP server, or, worse, the command-line @sendmail@ command can make you lose your hair. Mailer handles all of these tasks for you and more. 31 | 32 | The most common cases for mail message creation (plain text, html, attachments, and html embeds) are handled by the @marrow.mailer.message.Message@ class. Using this class allows you to write clean, succinct code within your own applications. If you want to use hand-generated MIME messages, or tackle Python's built-in MIME generation support for an advanced use-case, Mailer allows you to utilize the delivery mechanics without requiring the use of the @Message@ class. 33 | 34 | Mailer is *not* an MTA like "Exim":http://www.exim.org/, "Postfix":http://www.postfix.org/, "sendmail":http://www.sendmail.com/sm/open_source/, or "qmail":http://www.qmail.org/. It is designed to deliver your messages to a real mail server ("smart host") or other back-end which then actually delivers the messages to the recipient's server. There are a number of true MTAs written in Python, however, including "Python's smtpd":http://docs.python.org/library/smtpd.html, "Twisted Mail":http://twistedmatrix.com/trac/wiki/TwistedMail, "pymta":http://www.schwarz.eu/opensource/projects/pymta/, "tmda-ofmipd":http://tmda.svn.sourceforge.net/viewvc/tmda/trunk/tmda/bin/tmda-ofmipd?revision=2194&view=markup, and "Lamson":http://lamsonproject.org/, though this is by no means an exhaustive list. 35 | 36 | 37 | 38 | h2(#installation). %2.% Installation 39 | 40 | Installing @marrow.mailer@ is easy, just execute the following in a terminal: [2] 41 | 42 |
pip install marrow.mailer
43 | 44 | If you add @marrow.mailer@ to the @install_requires@ argument of the call to @setup()@ in your application's @setup.py@ file, @marrow.mailer@ will be automatically installed and made available when your own application is installed. We recommend using "less than" version numbers to ensure there are no unintentional side-effects when updating. Use @"marrow.mailer<4.1"@ to get all bugfixes for the current release, and @"marrow.mailer<5.0"@ to get bugfixes and feature updates, but ensure that large breaking changes are not installed. 45 | 46 | *Warning:* The 4.0 series is the last to support Python 2. 47 | 48 | 49 | h3(#install-dev). %2.1.% Development Version 50 | 51 | Development takes place on "GitHub":github in the "marrow/mailer":github-project project. Issue tracking, documentation, and downloads are provided there. 52 | 53 | Installing the current development version requires "Git":git, a distributed source code management system. If you have Git, you can run the following to download and _link_ the development version into your Python runtime: 54 | 55 |
git clone https://github.com/marrow/mailer.git
 56 | (cd mailer; python setup.py develop)
57 | 58 | You can upgrade to the latest version at any time: 59 | 60 |
(cd mailer; git pull; python setup.py develop)
61 | 62 | If you would like to make changes and contribute them back to the project, fork the GitHub project, make your changes, and submit a pull request. This process is beyond the scope of this documentation; for more information, see "GitHub's documentation":github-help. 63 | 64 | 65 | [github]https://github.com/ 66 | [git]http://git-scm.com/ 67 | [github-help]http://help.github.com/ 68 | 69 | 70 | 71 | h2(#basic). %3.% Basic Usage 72 | 73 | To use Marrow Mailer you instantiate a @marrow.mailer.Mailer@ object with the configuration, then pass @Message@ instances to the @Mailer@ instance's @send()@ method. This allows you to configure multiple delivery mechanisms and choose, within your code, how you want each message delivered. The configuration is a dictionary of dot-notation keys and their values. Each manager and transport has their own configuration keys. 74 | 75 | Configuration keys may utilize a shared, common prefix, such as @mail.@. By default no prefix is assumed. Manager and transport configurations are each additionally prefixed with @manager.@ and @transport.@, respectively. The following is an example of how to send a message by SMTP: 76 | 77 |
from marrow.mailer import Mailer, Message
 78 | 
 79 | mailer = Mailer(dict(
 80 |         transport = dict(
 81 |                 use = 'smtp',
 82 |                 host = 'localhost')))
 83 | mailer.start()
 84 | 
 85 | message = Message(author="user@example.com", to="user-two@example.com")
 86 | message.subject = "Testing Marrow Mailer"
 87 | message.plain = "This is a test."
 88 | mailer.send(message)
 89 | 
 90 | mailer.stop()
91 | 92 | Another example configuration, using a flat dictionary and delivering to an on-disk @maildir@ mailbox: 93 | 94 |
{
 95 |     'transport.use': 'maildir',
 96 |     'transport.directory': 'data/maildir'
 97 | }
98 | 99 | 100 | h3(#mailer-methods). %3.1.% Mailer Methods 101 | 102 | table(methods). 103 | |_. Method |_. Description | 104 | | @__init__(config, prefix=None)@ | Create and configure a new Mailer. | 105 | | @start()@ | Start the mailer. Returns the Mailer instance and can thus be chained with construction. | 106 | | @stop()@ | Stop the mailer. This cascades through to the active manager and transports. | 107 | | @send(message)@ | Deliver the given Message instance. | 108 | | @new(author=None, to=None, subject=None, **kw)@ | Create a new bound instance of Message using configured default values. | 109 | 110 | 111 | 112 | h2(#message). %4.% The Message Class 113 | 114 | The original format for email messages was defined in "RFC 822":http://www.faqs.org/rfcs/rfc822.html which was superseded by "RFC 2822":http://www.faqs.org/rfcs/rfc2822.html. The newest standard document about the format is currently "RFC 5322":http://www.faqs.org/rfcs/rfc2822.html. But the basics of RFC 822 still apply, so for the sake of readability we will just use "RFC 822" to refer to all these RFCs. Please read the official standard documents if this text fails to explain some aspects. 115 | 116 | The Marrow Mailer @Message@ class has a large number of attributes and methods, described below. 117 | 118 | h3(#message-methods). %4.1.% Message Methods 119 | 120 | table(methods). 121 | |_. Method |_. Description | 122 | | @__init__(author=None, to=None, subject=None, **kw)@ | Create and populate a new Message. Any attribute may be set by name. | 123 | | @__str__@ | You can easily get the MIME encoded version of the message using the @str()@ built-in. | 124 | | @attach(name, data=None, maintype=None, subtype=None, inline=False)@ | Attach a file (data=None) or string-like. For on-disk files, mimetype will be guessed. | 125 | | @embed(name, data=None)@ | Embed an image from disk or string-like. Only embed images! | 126 | | @send()@ | If the Message instance is bound to a Mailer instance, e.g. having been created by the @Mailer.new()@ factory method, deliver the message via that instance. | 127 | 128 | h3(#message-attributes). %4.2.% Message Attributes 129 | 130 | h4. %4.2.1.% Read/Write Attributes 131 | 132 | table(attributes). 133 | |_. Attribute |_. Description | 134 | | @_id@ | The message ID, generated for you as needed. | 135 | | @attachments@ | A list of MIME-encoded attachments. | 136 | | @author@ | The visible author of the message. This maps to the @From:@ header. | 137 | | @to@ | The visible list of primary intended recipients. | 138 | | @cc@ | A visible list of secondary intended recipients. | 139 | | @bcc@ | An invisible list of tertiary intended recipients. | 140 | | @date@ | The visible date/time of the message, defaults to @datetime.now()@ | 141 | | @embedded@ | A list of MIME-encoded embedded images. | 142 | | @encoding@ | Unicode encoding, defaults to @utf-8@. | 143 | | @headers@ | A list of additional message headers. | 144 | | @notify@ | The address that message disposition notification messages get routed to. | 145 | | @organization@ | An extended header for an organization name. | 146 | | @plain@ | Plain text message content. [1] | 147 | | @priority@ | The @X-Priority@ header. | 148 | | @reply@ | The address replies should be routed to by default; may differ from @author@. | 149 | | @retries@ | The number of times the message should be retried in the event of a non-critical failure. | 150 | | @rich@ | HTML message content. Must have plain text alternative. [1] | 151 | | @sender@ | The designated sender of the message; may differ from @author@. This is primarily utilized by SMTP delivery. | 152 | | @subject@ | The subject of the message. | 153 | 154 | fn1. The message bodies may be callables which will be executed when the message is delivered, allowing you to easily utilize templates. Pro tip: to pass arguments to your template, while still allowing for later execution, use @functools.partial@. When using a threaded manager please be aware of thread-safe issues within your templates. 155 | 156 | Any of these attributes can also be defined within your mailer configuration. When you wish to use default values from the configuration you must use the @Mailer.new()@ factory method. For example: 157 | 158 |
mail = Mailer({
159 |         'message.author': 'Example User ',
160 |         'message.subject': "Test subject."
161 |     })
162 | message = mail.new()
163 | message.subject = "Test subject."
164 | message.send()
165 | 166 | h4. %4.2.2.% Read-Only Attributes 167 | 168 | table(attributes). 169 | |_. Attribute |_. Description | 170 | | @id@ | A valid message ID. Regenerated after each delivery. | 171 | | @envelope@ | The envelope sender from SMTP terminology. Uses the value of the @sender@ attribute, if set, otherwise the first @author@ address. | 172 | | @mime@ | The complete MIME document tree that is the message. | 173 | | @recipients@ | A combination of @to@, @cc@, and @bcc@ address lists. | 174 | 175 | 176 | 177 | h2(#managers). %5.% Delivery Managers 178 | 179 | h3(#immediate-manager). %5.1.% Immediate Manager 180 | 181 | The immediate manager attempts to deliver the message using your chosen transport immediately. The request to deliver a message is blocking. There is no configuration for this manager. 182 | 183 | 184 | h3(#futures-manager). %5.2.% Futures Manager 185 | 186 | Futures is a thread pool delivery manager based on the @concurrent.futures@ module introduced in "PEP 3148":http://www.python.org/dev/peps/pep-3148/. The use of @concurrent.futures@ and its default thread pool manager allows you to receive notification (via callback or blocking request) of successful delivery and errors. 187 | 188 | When you enqueue a message for delivery a Future object is returned to you. For information on what you can do with a Future object, see the "relevant section of the Futures PEP":http://www.python.org/dev/peps/pep-3148/#future-objects. 189 | 190 | The Futures manager understands the following configuration directives: 191 | 192 | table(configuration). 193 | |_. Directive |_. Default |_. Description | 194 | | @workers@ | @1@ | The number of threads to spawn. | 195 | 196 | The @workers@ configuration directive has the side effect of requiring one transport instance per worker, requiring up to @workers@ simultaneous connections. 197 | 198 | 199 | h3(#dynamic-manager). %5.3.% Dynamic Manager 200 | 201 | This manager dynamically scales the number of worker threads (and thus simultaneous transport connections) based on the current workload. This is a port of the _TurboMail 3_ @ondemand@ manager to the Futures API. This manager is somewhat more efficient than the plain Futures manager, and should be the manager in use on production systems. 202 | 203 | The Dynamic manager understands the following configuration directives: 204 | 205 | table(configuration). 206 | |_. Directive |_. Default |_. Description | 207 | | @workers@ | @10@ | The maximum number of threads. | 208 | | @divisor@ | @10@ | The number of messages to send before freeing the thread. (A.k.a. "exhaustion".) | 209 | | @timeout@ | @60@ | The number of seconds to wait for additional work before freeing the thread. (A.k.a. "starvation".) | 210 | 211 | 212 | 213 | h2(#transports). %6.% Message Transports 214 | 215 | Transports are grouped into three primary categories: disk, network, and meta. Meta transports keep the message within Python or only 'pretend' to deliver it. Disk transports save the message to disk in some fashion, and networked transports deliver the message over a network. Configuration is similar between transports within the same category. 216 | 217 | 218 | h3(#disk-transports). %6.1.% Disk Transports 219 | 220 | Disk transports are the easiest to get up and running and allow you to off-load final transport of the message to another process or server. These transports are most useful in a larger deployment, but are also great for testing! 221 | 222 | There are currently two on-disk transports included with Marrow Mailer: @mbox@ and @maildir@. 223 | 224 | h4(#mbox-transport). %6.1.1.% UNIX Mailbox 225 | 226 | There is only one configuration directive for the @mbox@ transport: 227 | 228 | table(configuration). 229 | |_. Directive |_. Default |_. Description | 230 | | @file@ | — | The on-disk file to use as the mailbox, must be writeable. | 231 | 232 | There are several important limitations on this mailbox format; notably the use of whole-file locking when changes are to be made, making this transport useless for high-performance or multi-threaded delivery. For details, see the "@mbox@ documentation":http://docs.python.org/library/mailbox.html#mbox. To efficiently utilize this transport, it is recommended to use the Futures manager with a single worker thread; this avoids lock contention. 233 | 234 | 235 | h4(#maildir-transport). %6.1.2.% UNIX Mail Directory 236 | 237 | The @maildir@ transport offers the benefits of a universal on-disk mail storage format with numerous features and none of the limitations of the @mbox@ format. These added features mandate the need for additional configuration directives. 238 | 239 | table(configuration). 240 | |_. Directive |_. Default |_. Description | 241 | | @directory@ | — | The on-disk path to the mail directory. | 242 | | @folder@ | @None@ | A dot-separated subfolder to deliver mail into. The default is the top-level (inbox) folder. | 243 | | @create@ | @False@ | Create the target folder if it does not exist at the time of delivery. | 244 | | @separator@ | @"!"@ | Additional meta-information is associated with the mail directory format, usually separated by a colon. Because a colon is not a valid character on many operating systems, Marrow Mailer defaults to the de-facto standard of the @!@ (bang) character. | 245 | 246 | 247 | 248 | h3(#network-transports). %6.2.% Network Transports 249 | 250 | Network transports have Python directly communicate over TCP/IP with an external service. 251 | 252 | 253 | h4(#smtp-transport). %6.2.1.% Simple Mail Transport Protocol (SMTP) 254 | 255 | SMTP is, far and away, the most ubiquitous mail delivery protocol in existence. 256 | 257 | table(configuration). 258 | |_. Directive |_. Default |_. Description | 259 | | @host@ | @None@ | The host name to connect to. | 260 | | @port@ | @25@ or @465@ | The port to connect to. The default depends on the @tls@ directive's value. | 261 | | @username@ | @None@ | The username to authenticate against. If utilizing authentication, it is recommended to enable TLS/SSL. | 262 | | @password@ | @None@ | The password to authenticate with. | 263 | | @timeout@ | @None@ | Network communication timeout. | 264 | | @local_hostname@ | @None@ | The hostname to advertise during @HELO@/@EHLO@. | 265 | | @debug@ | @False@ | If @True@ all SMTP communication will be printed to STDERR. | 266 | | @tls@ | @"optional"@ | One of @"required"@, @"optional"@, and @"ssl"@ or any other value to indicate no SSL/TLS. | 267 | | @certfile@ | @None@ | An optional SSL certificate to authenticate SSL communication with. | 268 | | @keyfile@ | @None@ | The private key for the optional @certfile@. | 269 | | @pipeline@ | @None@ | If a non-zero positive integer, this represents the number of messages to pipeline across a single SMTP connection. Most servers allow up to 10 messages to be delivered. | 270 | 271 | 272 | h4(#imap-transport). %6.2.2.% Internet Mail Access Protocol (IMAP) 273 | 274 | Marrow Mailer, via the @imap@ transport, allows you to dump messages directly into folders on remote servers. 275 | 276 | table(configuration). 277 | |_. Directive |_. Default |_. Description | 278 | | @host@ | @None@ | The host name to connect to. | 279 | | @ssl@ | @False@ | Enable or disable SSL communication. | 280 | | @port@ | @143@ or @993@ | Port to connect to; the default value relies on the @ssl@ directive's value. | 281 | | @username@ | @None@ | The username to authenticate against. The note from SMTP applies here, too. | 282 | | @password@ | @None@ | The password to authenticate with. | 283 | | @folder@ | @"INBOX"@ | The default IMAP folder path. | 284 | 285 | 286 | h3(#meta-transports). %6.3.% Meta-Transports 287 | 288 | 289 | h4(#gae-transport). %6.3.1.% Google AppEngine 290 | 291 | The @appengine@ transport translates between Mailer's Message representation and Google AppEngine's. Note that GAE's @EmailMessage@ class is not nearly as feature-complete as Mailer's. The translation covers the following @marrow.mailer.Message@ attributes: 292 | 293 | * @author@ 294 | * @to@ 295 | * @cc@ 296 | * @bcc@ 297 | * @reply@ 298 | * @subject@ 299 | * @plain@ 300 | * @rich@ 301 | * @attachments@ (excluding inline/embedded files) 302 | 303 | 304 | h4(#logging-transport). %6.3.1.% Python Logging 305 | 306 | The @log@ transport implements the use of the standard Python logging module for message delivery. Using this module allows you to emit messages which are filtered and directed through standard logging configuration. There are three logging levels used: 307 | 308 | |_. Level |_. Meaning | 309 | | @DEBUG@ | This level is used for informational messages such as startup and shutdown. | 310 | | @INFO@ | This level communicates information about messages being delivered. | 311 | | @CRITICAL@ | This level is used to deliver the MIME content of the message. | 312 | 313 | Log entries at the @INFO@ level conform to the following syntax: 314 | 315 |
DELIVER {ID} {ISODATE} {SIZE} {AUTHOR} {RECIPIENTS}
316 | 317 | There is only one configuration directive: 318 | 319 | table(configuration). 320 | |_. Directive |_. Default |_. Description | 321 | | @name@ | *"marrow.mailer.transport.log"* | The name of the logger to use. | 322 | 323 | 324 | h4(#mock-transport). %6.3.1.% Mock (Testing) Transport 325 | 326 | The @mock@ testing transport is useful if you are writing a manager. It allows you to test to ensure your manager handles various exceptions correctly. 327 | 328 | table(configuration). 329 | |_. Directive |_. Default |_. Description | 330 | | @success@ | @1.0@ | The probability of successful delivery, handled after the following conditions. | 331 | | @failure@ | @0.0@ | The probability of the @TransportFailedException@ exception being raised. | 332 | | @exhaustion@ | @0.0@ | The probability of the @TransportExhaustedException@ exception being raised. | 333 | 334 | All probabilities are floating point numbers between 0.0 (0% chance) and 1.0 (100% chance). 335 | 336 | 337 | h4(#sendmail-transport). %6.3.1.% Sendmail Command 338 | 339 | If the server your software is running on is configured to deliver mail via the on-disk @sendmail@ command, you can use the @sendmail@ transport to deliver your mail. 340 | 341 | table(configuration). 342 | |_. Directive |_. Default |_. Description | 343 | | @path@ | @"/usr/sbin/sendmail"@ | The path to the @sendmail@ executable. | 344 | 345 | 346 | h4(#amazon-transport). %6.3.1.% Amazon Simple E-Mail Service (SES) 347 | 348 | Deliver your messages via the Amazon Simple E-Mail Service with the @amazon@ transport. While Amazon allows you to utilize SMTP for communication, using the correct API allows you to get much richer information back from delivery upon both success *and* failure. To utilize this transport you must have the @boto3@ package installed. 349 | 350 | table(configuration). 351 | |_. Directive |_. Default |_. Description | 352 | | @id@ | — | Your Amazon AWS access key identifier. | 353 | | @key@ | — | Your Amazon AWS secret access key. | 354 | 355 | 356 | h4(#sendgrid-transport). %6.3.1.% SendGrid 357 | 358 | The @sendgrid@ transport uses the email service provider SendGrid to deliver your transactional and marketing messages. Use your SendGrid username and password (@user@ and @key@), or supply an API key (only @key@). 359 | 360 | table(configuration). 361 | |_. Directive |_. Default |_. Description | 362 | | @user@ | — | Your SendGrid username. Don't include this if you're using an API key. | 363 | | @key@ | — | Your SendGrid password, or a SendGrid account API key. | 364 | 365 | 366 | h2(#extending). %7.% Extending Marrow Mailer 367 | 368 | Marrow Mailer can be extended in two ways: new managers (such as thread pool management strategies or process pools) and delivery transports. The API for each is quite simple. 369 | 370 | One note is that managers and transports only receive the configuration directives targeted at them; it is not possible to inspect other aspects of configuration. 371 | 372 | 373 | h3(#managers). %7.1.% Delivery Manager API 374 | 375 | Delivery managers are responsible for accepting work from the application programmer and (eventually) handing this work to transports for final outbound delivery from the application. 376 | 377 | The following are the methods understood by the duck-typed manager API. All methods are required even if they do nothing. 378 | 379 | table(methods). 380 | |_. Method |_. Description | 381 | | @__init__(config, Transport)@ | Initialization code. @Transport@ is a pre-configured transport factory. | 382 | | @startup()@ | Code to execute after initialization and before messages are accepted. | 383 | | @deliver(message)@ | Handle delivery of the given @Message@ instance. | 384 | | @shutdown()@ | Code to execute during shutdown. | 385 | 386 | A manager must: 387 | 388 | # Perform no actions during initialization. 389 | # Prepare state within the @startup()@ method call. E.g. prepare a thread or transport pool. 390 | # Clean up state within the @shutdown()@ method call. E.g. free a thread or transport pool. 391 | # Return a documented object from the @deliver()@ method call, preferably a @Future@ instance for interoperability with the core managers. 392 | # Accept multiple messages during the lifetime of the manager instance. 393 | # Accept multiple @startup()@/@shutdown()@ cycles. 394 | # Understand and correctly handle exceptions that may be raised by message transports, described in "§5.3":#exceptions. 395 | 396 | Additionally, a manager must not: 397 | 398 | # Utilize or alter any form of global scope configuration. 399 | 400 | 401 | h3(#transports). %7.2.% Message Transport API 402 | 403 | A message transport is some method whereby a message is sent to an external consumer. Message transports have limited control over how they are utilized by the use of Marrow Mailer exceptions with specific semantic meanings, as described in "§6.3":#exceptions. 404 | 405 | The following are the methods understood by the duck-typed transport API. All methods are required even if they do nothing. 406 | 407 | table(methods). 408 | |_. Method |_. Description | 409 | | @__init__(config)@ | Initialization code. | 410 | | @startup()@ | Code to execute after initialization and before messages are accepted. | 411 | | @deliver(message)@ | Handle delivery of the given @Message@ instance. | 412 | | @shutdown()@ | Code to execute during shutdown. | 413 | 414 | Optionally, a transport may define the following additional attribute: 415 | 416 | table(attributes). 417 | | @connected@ | True or False based on the current connection status. | 418 | 419 | A transport must: 420 | 421 | # Perform no actions during initialization. 422 | # Prepare state within the @startup()@ method call. E.g. opening network connections or files. 423 | # Clean up state within the @shutdown()@ method call. E.g. closing network connections or files. 424 | # Accept multiple messages during the lifetime of the transport instance. 425 | # Accept multiple @startup()@/@shutdown()@ cycles. 426 | # Understand and correctly handle exceptions that may be raised by message transports, described in "§6.3":#exceptions. 427 | 428 | Additionally, a transport must not: 429 | 430 | # Utilize or alter any form of global scope configuration. 431 | 432 | A transport may: 433 | 434 | # Return data from the @deliver()@ method; this data will be passed through as the return value of the @Mailer.send()@ call or Future callback response value. 435 | 436 | 437 | h3(#exceptions). %7.3.% Exceptions 438 | 439 | The following table illustrates the semantic meaning of the various internal (and external) exceptions used by Marrow Mailer, managers, and transports. 440 | 441 | table(exceptions). 442 | |_. Exception |_. Role |_. Description | 443 | | @DeliveryFailedException@ | External | The message stored in @args[0]@ could not be delivered for the reason given in @args[1]@. (These can be accessed as @e.msg@ and @e.reason@.) | 444 | | @MailerNotRunning@ | External | Raised when attempting to deliver messages using a dead interface. (Not started, or already shut down.) | 445 | | @MailConfigurationException@ | External | Raised to indicate some configuration value was required and missing, out of bounds, or otherwise invalid. | 446 | | @TransportFailedException@ | Internal | The transport has failed to deliver the message due to an internal error; a new instance of the transport should be used to retry. | 447 | | @MessageFailedException@ | Internal | The transport has failed to deliver the message due to a problem with the message itself, and no attempt should be made to retry delivery of this message. The transport may still be re-used, however. | 448 | | @TransportExhaustedException@ | Internal | The transport has successfully delivered the message, but can no longer be used for future message delivery; a new instance should be used on the next request. | 449 | 450 | 451 | 452 | h2(#license). %8.% License 453 | 454 | Marrow Mailer has been released under the MIT Open Source license. 455 | 456 | 457 | h3(#license-mit). %8.1.% The MIT License 458 | 459 | Copyright (C) 2006-2023 Alice Bevan-McGregor and contributors. 460 | 461 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 462 | 463 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 464 | 465 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 466 | 467 | 468 | 469 | fn1. In order to run the full test suite you need to install "pymta":http://www.schwarz.eu/opensource/projects/pymta/ and its dependencies. 470 | 471 | fn2. If "Pip":http://www.pip-installer.org/ is not available for you, you can use @easy_install@ instead. We have much love for Pip and "Distribute":http://packages.python.org/distribute/, though. 472 | --------------------------------------------------------------------------------