├── test ├── requirements.txt ├── test_slimta_policy.py ├── test_slimta_core.py ├── test_slimta_relay_smtp_static.py ├── test_slimta_relay_blackhole.py ├── test_slimta_smtp_datasender.py ├── test_slimta_logging_queuestorage.py ├── test_slimta_http.py ├── test_slimta_logging_subprocess.py ├── test_slimta_util_bytesformat.py ├── test_slimta_http_wsgi.py ├── test_slimta_policy_forward.py ├── test_slimta_relay.py ├── test_slimta_util.py ├── test_slimta_relay_pool.py ├── test_slimta_queue_proxy.py ├── test_slimta_bounce.py ├── test_slimta_logging_http.py ├── test_slimta_policy_headers.py ├── test_slimta_util_ptrlookup.py ├── test_slimta_smtp_extensions.py ├── test_slimta_util_deque.py ├── test_slimta_policy_spamassassin.py ├── test_slimta_util_dns.py ├── test_slimta_logging.py ├── test_slimta_edge.py ├── test_slimta_logging_socket.py ├── test_slimta_smtp_datareader.py ├── test_slimta_queue_dict.py ├── test_slimta_edge_wsgi.py ├── test_slimta_relay_pipe.py ├── test_slimta_util_dnsbl.py ├── test_slimta_policy_split.py ├── test_slimta_envelope.py ├── test_slimta_smtp_reply.py └── test_slimta_edge_smtp.py ├── .gitignore ├── .travis.yml ├── setup.cfg ├── examples └── site_data.py ├── LICENSE ├── slimta ├── __init__.py ├── core.py ├── logging │ ├── subprocess.py │ ├── queuestorage.py │ └── http.py ├── util │ ├── pycompat.py │ ├── deque.py │ ├── __init__.py │ ├── dns.py │ ├── system.py │ ├── ptrlookup.py │ └── bytesformat.py ├── relay │ ├── blackhole.py │ ├── smtp │ │ ├── __init__.py │ │ └── lmtpclient.py │ ├── __init__.py │ └── pool.py ├── smtp │ ├── __init__.py │ ├── datasender.py │ ├── datareader.py │ └── extensions.py ├── policy │ ├── forward.py │ ├── split.py │ ├── __init__.py │ └── headers.py ├── queue │ ├── proxy.py │ └── dict.py └── http │ ├── __init__.py │ └── wsgi.py ├── setup.py ├── README.md └── CHANGELOG.md /test/requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-cov 3 | unittest2 4 | mox3 5 | testfixtures 6 | flake8 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | .cache/ 4 | *.egg-info/ 5 | .venv*/ 6 | .coverage 7 | nosetests.xml 8 | coverage.xml 9 | examples/cert.pem 10 | *.pyc 11 | *.pyo 12 | .*.swp 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.4" 5 | - "3.5" 6 | install: 7 | - travis_retry pip install -r test/requirements.txt 8 | - travis_retry pip install coveralls 9 | - travis_retry pip install -e . 10 | script: py.test --cov=slimta 11 | after_success: 12 | - coveralls 13 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | 2 | [bdist_wheel] 3 | universal = 1 4 | 5 | [nosetests] 6 | where = test/ 7 | #with-xunit = true 8 | #with-xcoverage = true 9 | cover-erase = true 10 | cover-package = slimta 11 | 12 | [coverage:report] 13 | exclude_lines = 14 | declare_namespace 15 | pragma: no cover 16 | raise NotImplementedError 17 | 18 | -------------------------------------------------------------------------------- /test/test_slimta_policy.py: -------------------------------------------------------------------------------- 1 | 2 | import unittest2 as unittest 3 | 4 | from slimta.policy import QueuePolicy, RelayPolicy 5 | 6 | 7 | class TestPolicy(unittest.TestCase): 8 | 9 | def test_queuepolicy_interface(self): 10 | qp = QueuePolicy() 11 | self.assertRaises(NotImplementedError, qp.apply, None) 12 | 13 | def test_relaypolicy_interface(self): 14 | rp = RelayPolicy() 15 | self.assertRaises(NotImplementedError, rp.apply, None) 16 | 17 | 18 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 19 | -------------------------------------------------------------------------------- /test/test_slimta_core.py: -------------------------------------------------------------------------------- 1 | 2 | import unittest2 as unittest 3 | 4 | 5 | class TestSlimtaCore(unittest.TestCase): 6 | 7 | def test_import_slimta(self): 8 | import slimta 9 | 10 | def test_import_slimta_core_version(self): 11 | from slimta.core import __version__ 12 | self.assertIsInstance(__version__, str) 13 | 14 | def test_import_slimta_core_slimtaerror(self): 15 | from slimta.core import SlimtaError 16 | self.assert_(issubclass(SlimtaError, Exception)) 17 | 18 | 19 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 20 | -------------------------------------------------------------------------------- /examples/site_data.py: -------------------------------------------------------------------------------- 1 | 2 | from socket import getfqdn 3 | from itertools import product 4 | 5 | # Configures the banners to use. 6 | fqdn = getfqdn() 7 | inbound_banner = '{0} ESMTP example.com Mail Delivery Agent'.format(fqdn) 8 | outbound_banner = '{0} ESMTP example.com Mail Submission Agent'.format(fqdn) 9 | 10 | # Calculates a list of all deliverable inbound addresses. 11 | deliverable_domains = ['example.com'] 12 | deliverable_users = ['user', 'postmaster', 'abuse'] 13 | deliverable_addresses = set(['@'.join(pair) for pair in 14 | product(deliverable_users, deliverable_domains)]) 15 | 16 | # Dictionary of acceptable outbound credentials. 17 | credentials = {'user@example.com': 'secretpw'} 18 | 19 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 20 | -------------------------------------------------------------------------------- /test/test_slimta_relay_smtp_static.py: -------------------------------------------------------------------------------- 1 | import unittest2 as unittest 2 | from mox3.mox import MoxTestBase, IsA 3 | 4 | from slimta.relay.smtp.static import StaticSmtpRelay 5 | from slimta.relay.smtp.client import SmtpRelayClient 6 | 7 | 8 | class TestStaticSmtpRelay(unittest.TestCase, MoxTestBase): 9 | 10 | def test_add_client(self): 11 | static = StaticSmtpRelay('testhost') 12 | ret = static.add_client() 13 | self.assertIsInstance(ret, SmtpRelayClient) 14 | 15 | def test_add_client_custom(self): 16 | def fake_class(addr, queue, **kwargs): 17 | self.assertEqual(('testhost', 25), addr) 18 | return 'success' 19 | static = StaticSmtpRelay('testhost', client_class=fake_class) 20 | self.assertEqual('success', static.add_client()) 21 | 22 | 23 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 24 | -------------------------------------------------------------------------------- /test/test_slimta_relay_blackhole.py: -------------------------------------------------------------------------------- 1 | 2 | import unittest2 as unittest 3 | 4 | from slimta.relay.blackhole import BlackholeRelay 5 | from slimta.envelope import Envelope 6 | from slimta.policy import RelayPolicy 7 | 8 | 9 | class TestBlackholeRelay(unittest.TestCase): 10 | 11 | def test_attempt(self): 12 | env = Envelope() 13 | blackhole = BlackholeRelay() 14 | ret = blackhole.attempt(env, 0) 15 | self.assertEqual('250', ret.code) 16 | 17 | def test_attempt_policies(self): 18 | class BadPolicy(RelayPolicy): 19 | def apply(self, env): 20 | raise Exception('that\'s bad policy!') 21 | env = Envelope() 22 | blackhole = BlackholeRelay() 23 | blackhole.add_policy(BadPolicy()) 24 | ret = blackhole._attempt(env, 0) 25 | self.assertEqual('250', ret.code) 26 | 27 | 28 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 29 | -------------------------------------------------------------------------------- /test/test_slimta_smtp_datasender.py: -------------------------------------------------------------------------------- 1 | 2 | import unittest2 as unittest 3 | 4 | from slimta.smtp.datasender import DataSender 5 | 6 | 7 | class TestSmtpDataSender(unittest.TestCase): 8 | 9 | def test_empty_data(self): 10 | ret = b''.join(DataSender(b'')) 11 | self.assertEqual(b'.\r\n', ret) 12 | 13 | def test_newline(self): 14 | ret = b''.join(DataSender(b'\r\n')) 15 | self.assertEqual(b'\r\n.\r\n', ret) 16 | 17 | def test_one_line(self): 18 | ret = b''.join(DataSender(b'one')) 19 | self.assertEqual(b'one\r\n.\r\n', ret) 20 | 21 | def test_multi_line(self): 22 | ret = b''.join(DataSender(b'one\r\ntwo')) 23 | self.assertEqual(b'one\r\ntwo\r\n.\r\n', ret) 24 | 25 | def test_eod(self): 26 | ret = b''.join(DataSender(b'.\r\n')) 27 | self.assertEqual(b'..\r\n.\r\n', ret) 28 | 29 | def test_period_escape(self): 30 | ret = b''.join(DataSender(b'.one\r\n..two\r\n')) 31 | self.assertEqual(b'..one\r\n...two\r\n.\r\n', ret) 32 | 33 | 34 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 35 | -------------------------------------------------------------------------------- /test/test_slimta_logging_queuestorage.py: -------------------------------------------------------------------------------- 1 | import unittest2 as unittest 2 | import uuid 3 | 4 | from testfixtures import log_capture 5 | 6 | from slimta.logging import getQueueStorageLogger 7 | from slimta.envelope import Envelope 8 | 9 | 10 | class TestSocketLogger(unittest.TestCase): 11 | 12 | def setUp(self): 13 | self.log = getQueueStorageLogger('test') 14 | 15 | @log_capture() 16 | def test_write(self, l): 17 | env = Envelope('sender@example.com', ['rcpt@example.com']) 18 | self.log.write('123abc', env) 19 | l.check(('test', 'DEBUG', 'queue:123abc:write recipients=[\'rcpt@example.com\'] sender=\'sender@example.com\'')) 20 | 21 | @log_capture() 22 | def test_update_meta(self, l): 23 | self.log.update_meta('1234', timestamp=12345) 24 | l.check(('test', 'DEBUG', 'queue:1234:meta timestamp=12345')) 25 | 26 | @log_capture() 27 | def test_remove(self, l): 28 | id = uuid.uuid4().hex 29 | self.log.remove(id) 30 | l.check(('test', 'DEBUG', 'queue:{0}:remove'.format(id))) 31 | 32 | 33 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Ian C. Good 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | 21 | -------------------------------------------------------------------------------- /test/test_slimta_http.py: -------------------------------------------------------------------------------- 1 | import unittest2 as unittest 2 | from mox3.mox import MoxTestBase 3 | from gevent import socket 4 | 5 | from slimta.http import HTTPConnection, HTTPSConnection, get_connection 6 | 7 | 8 | class TestHTTPConnection(unittest.TestCase, MoxTestBase): 9 | 10 | def test_init(self): 11 | conn = HTTPConnection('testhost', 8025, timeout=7) 12 | self.assertEqual(conn._create_connection, socket.create_connection) 13 | 14 | 15 | class TestHTTPSConnection(unittest.TestCase, MoxTestBase): 16 | 17 | def test_close(self): 18 | conn = HTTPSConnection('testhost', 8025) 19 | conn.sock = self.mox.CreateMockAnything() 20 | conn.sock.unwrap() 21 | conn.sock.close() 22 | self.mox.ReplayAll() 23 | conn.close() 24 | 25 | 26 | class TestGetConnection(unittest.TestCase, MoxTestBase): 27 | 28 | def test_get_connection(self): 29 | conn = get_connection('http://localhost') 30 | self.assertIsInstance(conn, HTTPConnection) 31 | conn = get_connection('https://localhost') 32 | self.assertIsInstance(conn, HTTPSConnection) 33 | 34 | 35 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 36 | -------------------------------------------------------------------------------- /test/test_slimta_logging_subprocess.py: -------------------------------------------------------------------------------- 1 | import unittest2 as unittest 2 | 3 | from testfixtures import log_capture 4 | 5 | from slimta.logging import getSubprocessLogger 6 | 7 | 8 | class FakeSubprocess(object): 9 | 10 | def __init__(self, pid, returncode): 11 | self.pid = pid 12 | self.returncode = returncode 13 | 14 | 15 | class TestSocketLogger(unittest.TestCase): 16 | 17 | def setUp(self): 18 | self.log = getSubprocessLogger('test') 19 | 20 | @log_capture() 21 | def test_popen(self, l): 22 | p = FakeSubprocess(320, 0) 23 | self.log.popen(p, ['one', 'two']) 24 | l.check(('test', 'DEBUG', 'pid:320:popen args=[\'one\', \'two\']')) 25 | 26 | @log_capture() 27 | def test_stdio(self, l): 28 | p = FakeSubprocess(828, 0) 29 | self.log.stdio(p, 'one', 'two', '') 30 | l.check(('test', 'DEBUG', 'pid:828:stdio stderr=\'\' stdin=\'one\' stdout=\'two\'')) 31 | 32 | 33 | @log_capture() 34 | def test_exit(self, l): 35 | p = FakeSubprocess(299, 13) 36 | self.log.exit(p) 37 | l.check(('test', 'DEBUG', 'pid:299:exit returncode=13')) 38 | 39 | 40 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 41 | -------------------------------------------------------------------------------- /slimta/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2014 Ian C. Good 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | # 21 | 22 | 23 | __import__('pkg_resources').declare_namespace(__name__) 24 | 25 | 26 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 27 | -------------------------------------------------------------------------------- /test/test_slimta_util_bytesformat.py: -------------------------------------------------------------------------------- 1 | 2 | import unittest2 as unittest 3 | 4 | from slimta.util.bytesformat import BytesFormat 5 | 6 | 7 | class TestBytesFormat(unittest.TestCase): 8 | 9 | def test_basic(self): 10 | bf = BytesFormat(b'abc{test}ghi{0}mno') 11 | self.assertEqual(b'abcdefghijklmno', bf.format(b'jkl', test=b'def')) 12 | 13 | def test_basic_with_encoding(self): 14 | bf = BytesFormat(b'abc{test}ghi') 15 | self.assertEqual(b'abcdefghi', bf.format(test='def')) 16 | 17 | def test_mode_ignore(self): 18 | bf = BytesFormat(b'abc{test}ghi') 19 | self.assertEqual(b'abc{test}ghi', bf.format()) 20 | 21 | def test_mode_remove(self): 22 | bf = BytesFormat(b'abc{test}ghi', mode='remove') 23 | self.assertEqual(b'abcghi', bf.format()) 24 | 25 | def test_mode_strict(self): 26 | bf = BytesFormat(b'abc{test}ghi{0}mno', mode='strict') 27 | with self.assertRaises(KeyError): 28 | bf.format(b'jkl') 29 | with self.assertRaises(IndexError): 30 | bf.format(test=b'def') 31 | 32 | def test_repr(self): 33 | bf = BytesFormat(b'abc{test}ghi{0}mno', mode='strict') 34 | self.assertRegexpMatches(repr(bf), r"b?'abc\{test\}ghi\{0\}mno'") 35 | 36 | 37 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 38 | -------------------------------------------------------------------------------- /test/test_slimta_http_wsgi.py: -------------------------------------------------------------------------------- 1 | import unittest2 as unittest 2 | from mox3.mox import MoxTestBase, IsA 3 | import gevent 4 | from gevent.pywsgi import WSGIServer as GeventWSGIServer 5 | 6 | from slimta.http.wsgi import WsgiServer, log 7 | 8 | 9 | class TestWsgiServer(unittest.TestCase, MoxTestBase): 10 | 11 | def test_build_server(self): 12 | w = WsgiServer() 13 | server = w.build_server(('0.0.0.0', 0)) 14 | self.assertIsInstance(server, GeventWSGIServer) 15 | 16 | def test_handle_unimplemented(self): 17 | w = WsgiServer() 18 | with self.assertRaises(NotImplementedError): 19 | w.handle(None, None) 20 | 21 | def test_call(self): 22 | class FakeWsgiServer(WsgiServer): 23 | def handle(self, environ, start_response): 24 | start_response('200 Test', 13) 25 | return ['test'] 26 | w = FakeWsgiServer() 27 | environ = {} 28 | start_response = self.mox.CreateMockAnything() 29 | self.mox.StubOutWithMock(log, 'wsgi_request') 30 | self.mox.StubOutWithMock(log, 'wsgi_response') 31 | log.wsgi_request(environ) 32 | start_response('200 Test', 13) 33 | log.wsgi_response(environ, '200 Test', 13) 34 | self.mox.ReplayAll() 35 | self.assertEqual(['test'], w(environ, start_response)) 36 | 37 | 38 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 39 | -------------------------------------------------------------------------------- /slimta/core.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2014 Ian C. Good 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | # 21 | 22 | """Module containing :class:`SlimtaError`, the package's base exception. Also 23 | defines the package's version number in ``__version__``. 24 | 25 | """ 26 | 27 | from __future__ import absolute_import 28 | 29 | import pkg_resources 30 | 31 | __all__ = ['SlimtaError'] 32 | 33 | 34 | #: The |slimta| version string. 35 | __version__ = pkg_resources.require("python-slimta")[0].version 36 | 37 | 38 | class SlimtaError(Exception): 39 | """The base exception for all custom errors in :mod:`slimta`.""" 40 | pass 41 | 42 | 43 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 44 | -------------------------------------------------------------------------------- /test/test_slimta_policy_forward.py: -------------------------------------------------------------------------------- 1 | 2 | import unittest2 as unittest 3 | 4 | from slimta.policy.forward import Forward 5 | from slimta.envelope import Envelope 6 | 7 | 8 | class TestPolicyForward(unittest.TestCase): 9 | 10 | def test_no_mappings(self): 11 | env = Envelope('sender@example.com', ['rcpt@example.com']) 12 | fwd = Forward() 13 | fwd.apply(env) 14 | self.assertEqual('sender@example.com', env.sender) 15 | self.assertEqual(['rcpt@example.com'], env.recipients) 16 | 17 | def test_no_matches(self): 18 | env = Envelope('sender@example.com', ['rcpt@example.com']) 19 | fwd = Forward() 20 | fwd.add_mapping(r'nomatch', 'test') 21 | fwd.apply(env) 22 | self.assertEqual('sender@example.com', env.sender) 23 | self.assertEqual(['rcpt@example.com'], env.recipients) 24 | 25 | def test_simple(self): 26 | env = Envelope('sender@example.com', ['rcpt@example.com', 27 | 'test@test.com']) 28 | fwd = Forward() 29 | fwd.add_mapping(r'^rcpt', 'test') 30 | fwd.add_mapping(r'test\.com$', 'example.com') 31 | fwd.apply(env) 32 | self.assertEqual('sender@example.com', env.sender) 33 | self.assertEqual(['test@example.com', 34 | 'test@example.com'], env.recipients) 35 | 36 | def test_shortcircuit(self): 37 | env = Envelope('sender@example.com', ['rcpt@example.com']) 38 | fwd = Forward() 39 | fwd.add_mapping(r'^rcpt', 'test') 40 | fwd.add_mapping(r'^example', 'testdomain') 41 | fwd.apply(env) 42 | self.assertEqual('sender@example.com', env.sender) 43 | self.assertEqual(['test@example.com'], env.recipients) 44 | 45 | 46 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 47 | -------------------------------------------------------------------------------- /test/test_slimta_relay.py: -------------------------------------------------------------------------------- 1 | import unittest2 as unittest 2 | from mox3.mox import MoxTestBase, IsA 3 | 4 | from slimta.relay import Relay, PermanentRelayError, TransientRelayError 5 | from slimta.policy import RelayPolicy 6 | from slimta.envelope import Envelope 7 | 8 | 9 | class TestRelay(unittest.TestCase, MoxTestBase): 10 | 11 | def test_default_replies(self): 12 | perm = PermanentRelayError('test msg') 13 | transient = TransientRelayError('test msg') 14 | self.assertEqual('550 5.0.0 test msg', str(perm.reply)) 15 | self.assertEqual('450 4.0.0 test msg', str(transient.reply)) 16 | 17 | def test_policies(self): 18 | env = Envelope('sender@example.com', ['rcpt@example.com']) 19 | p1 = self.mox.CreateMock(RelayPolicy) 20 | p2 = self.mox.CreateMock(RelayPolicy) 21 | p1.apply(env) 22 | p2.apply(env) 23 | self.mox.ReplayAll() 24 | relay = Relay() 25 | relay.add_policy(p1) 26 | relay.add_policy(p2) 27 | self.assertRaises(TypeError, relay.add_policy, None) 28 | relay._run_policies(env) 29 | 30 | def test_private_attempt(self): 31 | env = Envelope('sender@example.com', ['rcpt@example.com']) 32 | relay = Relay() 33 | self.mox.StubOutWithMock(relay, '_run_policies') 34 | self.mox.StubOutWithMock(relay, 'attempt') 35 | relay._run_policies(env) 36 | relay.attempt(env, 0) 37 | self.mox.ReplayAll() 38 | relay._attempt(env, 0) 39 | 40 | def test_public_attempt(self): 41 | env = Envelope('sender@example.com', ['rcpt@example.com']) 42 | relay = Relay() 43 | self.assertRaises(NotImplementedError, relay.attempt, env, 0) 44 | 45 | def test_kill(self): 46 | relay = Relay() 47 | relay.kill() # no-op! 48 | 49 | 50 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 51 | -------------------------------------------------------------------------------- /test/test_slimta_util.py: -------------------------------------------------------------------------------- 1 | import unittest2 as unittest 2 | from mox3.mox import MoxTestBase, IgnoreArg 3 | from gevent import socket 4 | 5 | from slimta import util 6 | 7 | 8 | class TestIPv4SocketCreator(MoxTestBase): 9 | 10 | def setUp(self): 11 | super(TestIPv4SocketCreator, self).setUp() 12 | self.mox.StubOutWithMock(socket, 'create_connection') 13 | self.mox.StubOutWithMock(socket, 'getaddrinfo') 14 | self.getaddrinfo = self.mox.CreateMock(socket.getaddrinfo) 15 | self.socket_creator = util.build_ipv4_socket_creator([25]) 16 | 17 | def test_other_port(self): 18 | socket.create_connection(('host', 443), 'timeout', 'source').AndReturn('socket') 19 | self.mox.ReplayAll() 20 | ret = self.socket_creator(('host', 443), 'timeout', 'source') 21 | self.assertEqual('socket', ret) 22 | 23 | def test_successful(self): 24 | socket.getaddrinfo('host', 25, socket.AF_INET).AndReturn([(None, None, None, None, 'sockaddr')]) 25 | socket.create_connection('sockaddr', IgnoreArg(), IgnoreArg()).AndReturn('socket') 26 | self.mox.ReplayAll() 27 | ret = self.socket_creator(('host', 25), 'timeout', 'source') 28 | self.assertEqual('socket', ret) 29 | 30 | def test_error(self): 31 | socket.getaddrinfo('host', 25, socket.AF_INET).AndReturn([(None, None, None, None, 'sockaddr')]) 32 | socket.create_connection('sockaddr', IgnoreArg(), IgnoreArg()).AndRaise(socket.error('error')) 33 | self.mox.ReplayAll() 34 | with self.assertRaises(socket.error): 35 | self.socket_creator(('host', 25), 'timeout', 'source') 36 | 37 | def test_no_addresses(self): 38 | socket.getaddrinfo('host', 25, socket.AF_INET).AndReturn([]) 39 | self.mox.ReplayAll() 40 | with self.assertRaises(socket.error): 41 | self.socket_creator(('host', 25), 'timeout', 'source') 42 | 43 | 44 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 45 | -------------------------------------------------------------------------------- /test/test_slimta_relay_pool.py: -------------------------------------------------------------------------------- 1 | import unittest2 as unittest 2 | 3 | import gevent 4 | 5 | from slimta.relay.pool import RelayPool, RelayPoolClient 6 | from slimta.envelope import Envelope 7 | 8 | 9 | class FakePool(RelayPool): 10 | 11 | def add_client(self): 12 | return FakeClient(self.queue) 13 | 14 | 15 | class FakeClient(RelayPoolClient): 16 | 17 | def _run(self): 18 | ret = self.poll() 19 | if isinstance(ret, tuple): 20 | result, envelope = ret 21 | result.set('test') 22 | 23 | 24 | class TestRelayPool(unittest.TestCase): 25 | 26 | def test_add_remove_client(self): 27 | pool = FakePool() 28 | pool.queue.append(True) 29 | pool._add_client() 30 | pool_copy = pool.pool.copy() 31 | for client in pool_copy: 32 | client.join() 33 | gevent.sleep(0) 34 | self.assertFalse(pool.pool) 35 | 36 | def test_add_remove_client_morequeued(self): 37 | pool = FakePool() 38 | pool.queue.append(True) 39 | pool.queue.append(True) 40 | pool._add_client() 41 | pool_copy = pool.pool.copy() 42 | for client in pool_copy: 43 | client.join() 44 | self.assertTrue(pool.pool) 45 | pool_copy = pool.pool.copy() 46 | for client in pool_copy: 47 | client.join() 48 | gevent.sleep(0) 49 | self.assertFalse(pool.pool) 50 | 51 | def test_attempt(self): 52 | env = Envelope() 53 | pool = FakePool() 54 | ret = pool.attempt(env, 0) 55 | self.assertEqual('test', ret) 56 | 57 | def test_kill(self): 58 | pool = RelayPool() 59 | pool.pool.add(RelayPoolClient(None)) 60 | pool.pool.add(RelayPoolClient(None)) 61 | pool.pool.add(RelayPoolClient(None)) 62 | for client in pool.pool: 63 | self.assertFalse(client.ready()) 64 | pool.kill() 65 | for client in pool.pool: 66 | self.assertTrue(client.ready()) 67 | 68 | 69 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 70 | -------------------------------------------------------------------------------- /test/test_slimta_queue_proxy.py: -------------------------------------------------------------------------------- 1 | 2 | import unittest2 as unittest 3 | from mox3.mox import MoxTestBase, IsA 4 | 5 | from slimta.queue.proxy import ProxyQueue 6 | from slimta.smtp.reply import Reply 7 | from slimta.relay import Relay, TransientRelayError, PermanentRelayError 8 | from slimta.envelope import Envelope 9 | 10 | 11 | class TestProxyQueue(unittest.TestCase, MoxTestBase): 12 | 13 | def setUp(self): 14 | super(TestProxyQueue, self).setUp() 15 | self.relay = self.mox.CreateMock(Relay) 16 | self.env = Envelope('sender@example.com', ['rcpt@example.com']) 17 | 18 | def test_enqueue(self): 19 | self.relay._attempt(self.env, 0) 20 | self.mox.ReplayAll() 21 | q = ProxyQueue(self.relay) 22 | ret = q.enqueue(self.env) 23 | self.assertEqual(1, len(ret)) 24 | self.assertEqual(2, len(ret[0])) 25 | self.assertEqual(self.env, ret[0][0]) 26 | self.assertRegexpMatches(ret[0][1], r'[0-9a-fA-F]{32}') 27 | 28 | def test_enqueue_relayerror(self): 29 | err = PermanentRelayError('msg failure', Reply('550', 'Not Ok')) 30 | self.relay._attempt(self.env, 0).AndRaise(err) 31 | self.mox.ReplayAll() 32 | q = ProxyQueue(self.relay) 33 | ret = q.enqueue(self.env) 34 | self.assertEqual(1, len(ret)) 35 | self.assertEqual(2, len(ret[0])) 36 | self.assertEqual(self.env, ret[0][0]) 37 | self.assertEqual(err, ret[0][1]) 38 | 39 | def test_start_noop(self): 40 | self.mox.ReplayAll() 41 | q = ProxyQueue(self.relay) 42 | q.start() 43 | 44 | def test_kill_noop(self): 45 | self.mox.ReplayAll() 46 | q = ProxyQueue(self.relay) 47 | q.kill() 48 | 49 | def test_flush_noop(self): 50 | self.mox.ReplayAll() 51 | q = ProxyQueue(self.relay) 52 | q.flush() 53 | 54 | def test_add_policy_error(self): 55 | self.mox.ReplayAll() 56 | q = ProxyQueue(self.relay) 57 | with self.assertRaises(NotImplementedError): 58 | q.add_policy('test') 59 | 60 | 61 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 62 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2014 Ian C. Good 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | # 21 | 22 | from setuptools import setup, find_packages 23 | 24 | 25 | setup(name='python-slimta', 26 | version='4.0.0', 27 | author='Ian Good', 28 | author_email='icgood@gmail.com', 29 | description='Lightweight, asynchronous SMTP libraries.', 30 | license='MIT', 31 | url='http://slimta.org/', 32 | packages=find_packages(), 33 | namespace_packages=['slimta'], 34 | install_requires=['gevent >= 1.1rc', 35 | 'pysasl >= 0.2.0', 36 | 'pycares >= 1'], 37 | classifiers=['Development Status :: 3 - Alpha', 38 | 'Topic :: Communications :: Email :: Mail Transport Agents', 39 | 'Intended Audience :: Developers', 40 | 'Intended Audience :: Information Technology', 41 | 'License :: OSI Approved :: MIT License', 42 | 'Programming Language :: Python', 43 | 'Programming Language :: Python :: 2.7', 44 | 'Programming Language :: Python :: 3.4', 45 | 'Programming Language :: Python :: 3.5']) 46 | 47 | 48 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 49 | -------------------------------------------------------------------------------- /slimta/logging/subprocess.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2012 Ian C. Good 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | # 21 | 22 | """Utilities to make logging consistent and easy for any any subprocess 23 | operations. 24 | 25 | """ 26 | 27 | from __future__ import absolute_import 28 | 29 | from functools import partial 30 | 31 | __all__ = ['SubprocessLogger'] 32 | 33 | 34 | class SubprocessLogger(object): 35 | """Provides a limited set of log methods that :mod:`slimta` packages may 36 | use. This prevents free-form logs from mixing in with standard, machine- 37 | parseable logs. 38 | 39 | :param log: :py:class:`logging.Logger` object to log through. 40 | 41 | """ 42 | 43 | def __init__(self, log): 44 | from slimta.logging import logline 45 | self.log = partial(logline, log.debug, 'pid') 46 | 47 | def popen(self, process, args): 48 | self.log(process.pid, 'popen', args=args) 49 | 50 | def stdio(self, process, stdin, stdout, stderr): 51 | self.log(process.pid, 'stdio', 52 | stdin=stdin, 53 | stdout=stdout, 54 | stderr=stderr) 55 | 56 | def exit(self, process): 57 | self.log(process.pid, 'exit', returncode=process.returncode) 58 | 59 | 60 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 61 | -------------------------------------------------------------------------------- /slimta/util/pycompat.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Ian C. Good 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | # 21 | 22 | """This module makes compatibility between Python 2 and Python 3 a little more 23 | convenient. It's intended to avoid dependence on the ``six`` library. 24 | 25 | """ 26 | 27 | from __future__ import absolute_import 28 | 29 | import sys 30 | 31 | try: 32 | from urllib import parse as urlparse_mod 33 | from http import client as httplib_mod 34 | import reprlib as reprlib_mod 35 | except ImportError: 36 | import urlparse as urlparse_mod 37 | import httplib as httplib_mod 38 | import repr as reprlib_mod 39 | 40 | __all__ = ['PY3', 'PY2'] 41 | 42 | #: True if the interpreter is Python 3.x, False otherwise. 43 | PY3 = (sys.version_info[0] == 3) 44 | 45 | #: True if the interpreter is Python 2.x, False otherwise. 46 | PY2 = (sys.version_info[0] == 2) 47 | 48 | #: The ``urlparse`` module on Python 2, ``urllib.parse`` on Python 3. 49 | urlparse = urlparse_mod 50 | 51 | #: The ``httplib`` module on Python 2, ``http.client`` on Python 3. In Python 52 | #: 2, the deprecated ``strict`` parameter is set to True. 53 | httplib = httplib_mod 54 | 55 | #: The ``repr`` module on Python 2, ``reprlib`` on Python 3. 56 | reprlib = reprlib_mod 57 | 58 | 59 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 60 | -------------------------------------------------------------------------------- /slimta/relay/blackhole.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013 Ian C. Good 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | # 21 | 22 | """This module contains a simple |Relay| class that blackholes messages, 23 | usually most useful for testing. 24 | 25 | """ 26 | 27 | from __future__ import absolute_import 28 | 29 | import uuid 30 | 31 | from slimta.smtp.reply import Reply 32 | from . import Relay 33 | 34 | __all__ = ['BlackholeRelay'] 35 | 36 | 37 | class BlackholeRelay(Relay): 38 | """Implements a |Relay| that simply blackholes messages. Relay policies may 39 | be added with :meth:`~slimta.relay.Relay.add_policy`, but they will never 40 | be executed or applied for messages handled by this class. 41 | 42 | """ 43 | 44 | def __init__(self): 45 | super(BlackholeRelay, self).__init__() 46 | 47 | def _attempt(self, envelope, attempts): 48 | return self.attempt(envelope, attempts) 49 | 50 | def attempt(self, envelope, attempts): 51 | """Overrides the |Relay| :meth:`~slimta.relay.Relay.attempt` method to 52 | silently discard attempted messages. The |Queue| will see the attempt 53 | as a successful delivery. 54 | 55 | :param envelope: |Envelope| to attempt delivery for. 56 | :param attempts: Number of times the envelope has attempted delivery. 57 | 58 | """ 59 | msg = '2.0.0 Message Delivered; {0!s}'.format(uuid.uuid4()) 60 | return Reply('250', msg) 61 | 62 | 63 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 64 | -------------------------------------------------------------------------------- /slimta/smtp/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2012 Ian C. Good 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | # 21 | 22 | """Root package for :mod:`slimta` SMTP client and server libraries.""" 23 | 24 | from __future__ import absolute_import 25 | 26 | from slimta.core import SlimtaError 27 | 28 | __all__ = ['SmtpError', 29 | 'ConnectionLost', 30 | 'MessageTooBig', 31 | 'BadReply'] 32 | 33 | 34 | class SmtpError(SlimtaError): 35 | """Base exception for all custom SMTP exceptions.""" 36 | pass 37 | 38 | 39 | class ConnectionLost(SmtpError): 40 | """Thrown when the socket is closed prematurely.""" 41 | 42 | def __init__(self): 43 | msg = 'Connection was closed prematurely' 44 | super(ConnectionLost, self).__init__(msg) 45 | 46 | 47 | class MessageTooBig(SmtpError): 48 | """Thrown when a message exceeds the maximum size given by the SMTP 49 | ``SIZE`` extension, if supported. 50 | 51 | """ 52 | 53 | def __init__(self): 54 | msg = 'Message exceeded maximum allowed size' 55 | super(MessageTooBig, self).__init__(msg) 56 | 57 | 58 | class BadReply(SmtpError): 59 | """Thrown when an SMTP server replies with a syntax-invalid code. 60 | 61 | :param data: The data that was expected to start with an SMTP code, made 62 | available in the ``data`` attribute. 63 | 64 | """ 65 | 66 | def __init__(self, data): 67 | super(BadReply, self).__init__('Bad SMTP reply from server.') 68 | self.data = data 69 | 70 | 71 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 72 | -------------------------------------------------------------------------------- /test/test_slimta_bounce.py: -------------------------------------------------------------------------------- 1 | import unittest2 as unittest 2 | 3 | from slimta.envelope import Envelope 4 | from slimta.bounce import Bounce 5 | from slimta.smtp.reply import Reply 6 | 7 | 8 | class TestBounce(unittest.TestCase): 9 | 10 | def test_bounce(self): 11 | env = Envelope('sender@example.com', ['rcpt1@example.com', 12 | 'rcpt2@example.com']) 13 | env.parse(b"""\ 14 | From: sender@example.com 15 | To: rcpt1@example.com 16 | To: rcpt2@example.com 17 | 18 | test test\r 19 | """) 20 | reply = Reply('550', '5.0.0 Rejected') 21 | 22 | Bounce.header_template = """\ 23 | X-Reply-Code: {code} 24 | X-Reply-Message: {message} 25 | X-Orig-Sender: {sender} 26 | 27 | """ 28 | Bounce.footer_template = """\ 29 | 30 | EOM 31 | """ 32 | bounce = Bounce(env, reply) 33 | 34 | self.assertEqual('', bounce.sender) 35 | self.assertEqual(['sender@example.com'], bounce.recipients) 36 | self.assertEqual('550', bounce.headers['X-Reply-Code']) 37 | self.assertEqual('5.0.0 Rejected', bounce.headers['X-Reply-Message']) 38 | self.assertEqual('sender@example.com', bounce.headers['X-Orig-Sender']) 39 | self.assertEqual(b"""\ 40 | From: sender@example.com 41 | To: rcpt1@example.com 42 | To: rcpt2@example.com 43 | 44 | test test 45 | 46 | EOM 47 | """.replace(b'\n', b'\r\n'), bounce.message) 48 | 49 | def test_bounce_headersonly(self): 50 | env = Envelope('sender@example.com', ['rcpt1@example.com', 51 | 'rcpt2@example.com']) 52 | env.parse(b"""\ 53 | From: sender@example.com 54 | To: rcpt1@example.com 55 | To: rcpt2@example.com 56 | 57 | test test 58 | """) 59 | reply = Reply('550', '5.0.0 Rejected') 60 | 61 | Bounce.header_template = """\ 62 | X-Reply-Code: {code} 63 | X-Reply-Message: {message} 64 | X-Orig-Sender: {sender} 65 | 66 | """ 67 | Bounce.footer_template = """\ 68 | 69 | EOM 70 | """ 71 | bounce = Bounce(env, reply, headers_only=True) 72 | 73 | self.assertEqual('', bounce.sender) 74 | self.assertEqual(['sender@example.com'], bounce.recipients) 75 | self.assertEqual('550', bounce.headers['X-Reply-Code']) 76 | self.assertEqual('5.0.0 Rejected', bounce.headers['X-Reply-Message']) 77 | self.assertEqual('sender@example.com', bounce.headers['X-Orig-Sender']) 78 | self.assertEqual(b"""\ 79 | From: sender@example.com 80 | To: rcpt1@example.com 81 | To: rcpt2@example.com 82 | 83 | 84 | EOM 85 | """.replace(b'\n', b'\r\n'), bounce.message) 86 | 87 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 88 | -------------------------------------------------------------------------------- /test/test_slimta_logging_http.py: -------------------------------------------------------------------------------- 1 | 2 | import unittest 3 | 4 | from testfixtures import log_capture 5 | 6 | from slimta.logging import getHttpLogger 7 | 8 | 9 | class TestHttpLogger(unittest.TestCase): 10 | 11 | def setUp(self): 12 | self.log = getHttpLogger('test') 13 | self.environ = {'REQUEST_METHOD': 'GET', 14 | 'PATH_INFO': '/test/stuff', 15 | 'HTTP_HEADER': 'value'} 16 | self.conn = {} 17 | 18 | @log_capture() 19 | def test_wsgi_request(self, l): 20 | environ1 = {'REQUEST_METHOD': 'GET', 21 | 'PATH_INFO': '/test/stuff', 22 | 'CONTENT_LENGTH': 'value'} 23 | environ2 = {'REQUEST_METHOD': 'GET', 24 | 'PATH_INFO': '/test/stuff', 25 | 'CONTENT_TYPE': 'value'} 26 | environ3 = {'REQUEST_METHOD': 'GET', 27 | 'PATH_INFO': '/test/stuff', 28 | 'HTTP_X_HEADER_NAME': 'value'} 29 | self.log.wsgi_request(environ1) 30 | self.log.wsgi_request(environ2) 31 | self.log.wsgi_request(environ3) 32 | l.check(('test', 'DEBUG', 'http:{0}:server_request headers=[(\'Content-Length\', \'value\')] method=\'GET\' path=\'/test/stuff\''.format(id(environ1))), 33 | ('test', 'DEBUG', 'http:{0}:server_request headers=[(\'Content-Type\', \'value\')] method=\'GET\' path=\'/test/stuff\''.format(id(environ2))), 34 | ('test', 'DEBUG', 'http:{0}:server_request headers=[(\'X-Header-Name\', \'value\')] method=\'GET\' path=\'/test/stuff\''.format(id(environ3)))) 35 | 36 | @log_capture() 37 | def test_wsgi_response(self, l): 38 | environ = {'REQUEST_METHOD': 'GET', 39 | 'PATH_INFO': '/test/stuff', 40 | 'HTTP_HEADER': 'value'} 41 | self.log.wsgi_response(environ, '200 OK', [('Header', 'value')]) 42 | l.check(('test', 'DEBUG', 'http:{0}:server_response headers=[(\'Header\', \'value\')] status=\'200 OK\''.format(id(environ)))) 43 | 44 | @log_capture() 45 | def test_request(self, l): 46 | self.log.request(self.conn, 'GET', '/test/stuff', [('Header', 'value')]) 47 | l.check(('test', 'DEBUG', 'http:{0}:client_request headers=[(\'Header\', \'value\')] method=\'GET\' path=\'/test/stuff\''.format(id(self.conn)))) 48 | 49 | @log_capture() 50 | def test_response(self, l): 51 | self.log.response(self.conn, '200 OK', [('Header', 'value')]) 52 | l.check(('test', 'DEBUG', 'http:{0}:client_response headers=[(\'Header\', \'value\')] status=\'200 OK\''.format(id(self.conn)))) 53 | 54 | 55 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 56 | -------------------------------------------------------------------------------- /slimta/relay/smtp/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2012 Ian C. Good 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | # 21 | 22 | """Package implementing the ability to route messages to their next hop 23 | with the standard SMTP protocol. Moving messages from hop top hop with 24 | SMTP is the foundation of how email works. Picking the next hop is 25 | typically done with MX records in DNS, but often configurations will 26 | involve local static routing. 27 | 28 | """ 29 | 30 | from __future__ import absolute_import 31 | 32 | from .. import RelayError, TransientRelayError, PermanentRelayError 33 | 34 | __all__ = ['SmtpRelayError'] 35 | 36 | 37 | class SmtpRelayError(RelayError): 38 | 39 | def __init__(self, type, reply): 40 | command = reply.command or b'[unknown command]' 41 | msg = '{0} failure on {1}: {2}'.format( 42 | type, command.decode('ascii'), str(reply)) 43 | super(SmtpRelayError, self).__init__(msg, reply) 44 | 45 | @staticmethod 46 | def factory(reply): 47 | if reply.code[0] == '5': 48 | return SmtpPermanentRelayError(reply) 49 | else: 50 | return SmtpTransientRelayError(reply) 51 | 52 | 53 | class SmtpTransientRelayError(SmtpRelayError, TransientRelayError): 54 | 55 | def __init__(self, reply): 56 | super(SmtpTransientRelayError, self).__init__('Transient', reply) 57 | 58 | 59 | class SmtpPermanentRelayError(SmtpRelayError, PermanentRelayError): 60 | 61 | def __init__(self, reply): 62 | super(SmtpPermanentRelayError, self).__init__('Permanent', reply) 63 | 64 | 65 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 66 | -------------------------------------------------------------------------------- /slimta/relay/smtp/lmtpclient.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2014 Ian C. Good 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | # 21 | 22 | from __future__ import absolute_import 23 | 24 | from socket import getfqdn 25 | 26 | from gevent import Timeout 27 | 28 | from slimta.smtp.client import LmtpClient 29 | from .client import SmtpRelayClient 30 | from . import SmtpRelayError 31 | 32 | __all__ = ['LmtpRelayClient'] 33 | 34 | hostname = getfqdn() 35 | 36 | 37 | class LmtpRelayClient(SmtpRelayClient): 38 | 39 | _client_class = LmtpClient 40 | 41 | def _ehlo(self): 42 | try: 43 | ehlo_as = self.ehlo_as(self.address) 44 | except TypeError: 45 | ehlo_as = self.ehlo_as 46 | with Timeout(self.command_timeout): 47 | lhlo = self.client.lhlo(ehlo_as) 48 | if lhlo.is_error(): 49 | raise SmtpRelayError.factory(lhlo) 50 | 51 | def _deliver(self, result, envelope): 52 | rcpt_results = dict.fromkeys(envelope.recipients) 53 | try: 54 | self._handle_encoding(envelope) 55 | self._send_envelope(rcpt_results, envelope) 56 | data_results = self._send_message_data(envelope) 57 | except SmtpRelayError as e: 58 | result.set_exception(e) 59 | self._rset() 60 | return 61 | had_errors = False 62 | for rcpt, reply in data_results: 63 | if reply.is_error(): 64 | rcpt_results[rcpt] = SmtpRelayError.factory(reply) 65 | had_errors = True 66 | result.set(rcpt_results) 67 | if had_errors: 68 | self._rset() 69 | 70 | 71 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 72 | -------------------------------------------------------------------------------- /test/test_slimta_policy_headers.py: -------------------------------------------------------------------------------- 1 | import unittest2 as unittest 2 | 3 | from slimta.policy.headers import AddDateHeader, AddMessageIdHeader, \ 4 | AddReceivedHeader 5 | from slimta.envelope import Envelope 6 | 7 | 8 | class TestPolicyHeaders(unittest.TestCase): 9 | 10 | def test_add_date_header(self): 11 | env = Envelope() 12 | env.parse(b'') 13 | env.timestamp = 1234567890 14 | adh = AddDateHeader() 15 | self.assertEqual(None, env.headers['Date']) 16 | adh.apply(env) 17 | self.assertTrue(env.headers['Date']) 18 | 19 | def test_add_date_header_existing(self): 20 | env = Envelope() 21 | epoch = 'Thu, 01 Jan 1970 00:00:00 -0000' 22 | env.parse(b'Date: '+epoch.encode()+b'\r\n') 23 | adh = AddDateHeader() 24 | self.assertEqual(epoch, env.headers['Date']) 25 | adh.apply(env) 26 | self.assertEqual(epoch, env.headers['Date']) 27 | 28 | def test_add_message_id_header(self): 29 | env = Envelope() 30 | env.parse(b'') 31 | env.timestamp = 1234567890 32 | amih = AddMessageIdHeader('example.com') 33 | self.assertEqual(None, env.headers['Message-Id']) 34 | amih.apply(env) 35 | pattern = r'^<[0-9a-fA-F]{32}\.1234567890@example.com>$' 36 | self.assertRegexpMatches(env.headers['Message-Id'], pattern) 37 | 38 | def test_add_message_id_header_existing(self): 39 | env = Envelope() 40 | env.parse(b'Message-Id: testing\r\n') 41 | amih = AddMessageIdHeader() 42 | self.assertEqual('testing', env.headers['Message-Id']) 43 | amih.apply(env) 44 | self.assertEqual('testing', env.headers['Message-Id']) 45 | 46 | def test_add_received_header(self): 47 | env = Envelope('sender@example.com', ['rcpt@example.com']) 48 | env.parse(b'From: test@example.com\r\n') 49 | env.timestamp = 1234567890 50 | env.client['name'] = 'mail.example.com' 51 | env.client['ip'] = '1.2.3.4' 52 | env.client['protocol'] = 'ESMTPS' 53 | env.receiver = 'test.com' 54 | arh = AddReceivedHeader() 55 | arh.apply(env) 56 | self.assertRegexpMatches(env.headers['Received'], 57 | r'from mail\.example\.com \(unknown \[1.2.3.4\]\) by test.com ' 58 | r'\(slimta [^\)]+\) with ESMTPS for ; ') 59 | 60 | def test_add_received_header_prepended(self): 61 | env = Envelope('sender@example.com', ['rcpt@example.com']) 62 | env.parse(b'From: test@example.com\r\n') 63 | AddReceivedHeader().apply(env) 64 | self.assertEqual(['Received', 'From'], env.headers.keys()) 65 | 66 | 67 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 68 | -------------------------------------------------------------------------------- /test/test_slimta_util_ptrlookup.py: -------------------------------------------------------------------------------- 1 | import unittest2 as unittest 2 | 3 | import gevent 4 | from gevent import socket 5 | from mox3.mox import MoxTestBase 6 | 7 | from slimta.util.ptrlookup import PtrLookup 8 | 9 | 10 | class TestPtrLookup(unittest.TestCase, MoxTestBase): 11 | 12 | def test_from_getpeername(self): 13 | sock = self.mox.CreateMockAnything() 14 | sock.getpeername().AndReturn(('127.0.0.1', 0)) 15 | self.mox.ReplayAll() 16 | ptr, port = PtrLookup.from_getpeername(sock) 17 | self.assertEqual(0, port) 18 | self.assertIsInstance(ptr, PtrLookup) 19 | self.assertEqual('127.0.0.1', ptr.ip) 20 | 21 | def test_from_getsockname(self): 22 | sock = self.mox.CreateMockAnything() 23 | sock.getsockname().AndReturn(('127.0.0.1', 0)) 24 | self.mox.ReplayAll() 25 | ptr, port = PtrLookup.from_getsockname(sock) 26 | self.assertEqual(0, port) 27 | self.assertIsInstance(ptr, PtrLookup) 28 | self.assertEqual('127.0.0.1', ptr.ip) 29 | 30 | def test_run_no_result(self): 31 | self.mox.StubOutWithMock(socket, 'gethostbyaddr') 32 | socket.gethostbyaddr('127.0.0.1').AndRaise(socket.herror) 33 | self.mox.ReplayAll() 34 | ptr = PtrLookup('127.0.0.1') 35 | self.assertIsInstance(ptr, gevent.Greenlet) 36 | self.assertIsNone(ptr._run()) 37 | 38 | def test_run_bad_ip(self): 39 | self.mox.ReplayAll() 40 | ptr = PtrLookup('abcd') 41 | self.assertIsInstance(ptr, gevent.Greenlet) 42 | self.assertIsNone(ptr._run()) 43 | 44 | def test_run_greenletexit(self): 45 | self.mox.StubOutWithMock(socket, 'gethostbyaddr') 46 | socket.gethostbyaddr('127.0.0.1').AndRaise(gevent.GreenletExit) 47 | self.mox.ReplayAll() 48 | ptr = PtrLookup('abcd') 49 | self.assertIsInstance(ptr, gevent.Greenlet) 50 | self.assertIsNone(ptr._run()) 51 | 52 | def test_finish(self): 53 | self.mox.StubOutWithMock(socket, 'gethostbyaddr') 54 | socket.gethostbyaddr('127.0.0.1').AndReturn( 55 | ('example.com', None, None)) 56 | self.mox.ReplayAll() 57 | ptr = PtrLookup('127.0.0.1') 58 | ptr.start() 59 | self.assertEqual('example.com', ptr.finish(runtime=1.0)) 60 | 61 | def test_finish_timeout(self): 62 | def long_sleep(*args): 63 | gevent.sleep(1.0) 64 | 65 | self.mox.StubOutWithMock(socket, 'gethostbyaddr') 66 | socket.gethostbyaddr('127.0.0.1').WithSideEffects(long_sleep) 67 | self.mox.ReplayAll() 68 | ptr = PtrLookup('127.0.0.1') 69 | ptr.start() 70 | self.assertIsNone(ptr.finish(runtime=0.001)) 71 | 72 | 73 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 74 | -------------------------------------------------------------------------------- /test/test_slimta_smtp_extensions.py: -------------------------------------------------------------------------------- 1 | import unittest2 as unittest 2 | 3 | from slimta.smtp.extensions import Extensions 4 | 5 | 6 | class TestSmtpExtensions(unittest.TestCase): 7 | 8 | def setUp(self): 9 | self.ext = Extensions() 10 | self.ext.extensions = {'EMPTY': None, 'TEST': 'STUFF'} 11 | 12 | def test_contains(self): 13 | self.assertTrue('TEST' in self.ext) 14 | self.assertTrue('test' in self.ext) 15 | self.assertFalse('BAD' in self.ext) 16 | 17 | def test_reset(self): 18 | self.assertTrue('TEST' in self.ext) 19 | self.ext.reset() 20 | self.assertFalse('TEST' in self.ext) 21 | 22 | def test_add(self): 23 | self.ext.add('new') 24 | self.assertTrue('NEW' in self.ext) 25 | 26 | def test_drop(self): 27 | self.assertTrue(self.ext.drop('test')) 28 | self.assertFalse('TEST' in self.ext) 29 | self.assertFalse(self.ext.drop('BAD')) 30 | 31 | def test_getparam(self): 32 | self.assertEqual(None, self.ext.getparam('BAD')) 33 | self.assertEqual(None, self.ext.getparam('EMPTY')) 34 | self.assertEqual('STUFF', self.ext.getparam('TEST')) 35 | 36 | def test_getparam_filter(self): 37 | ret = self.ext.getparam('TEST', lambda x: x.strip('F')) 38 | self.assertEqual('STU', ret) 39 | ret = self.ext.getparam('EMPTY', lambda x: x) 40 | self.assertEqual(None, ret) 41 | 42 | def test_getparam_filter_valueerror(self): 43 | self.assertEqual(None, self.ext.getparam('TEST', int)) 44 | 45 | def test_parse_string(self): 46 | ext = Extensions() 47 | header = ext.parse_string("""\ 48 | the header 49 | EXT1 50 | PARSETEST DATA""") 51 | self.assertEqual('the header', header) 52 | self.assertTrue('EXT1' in ext) 53 | self.assertTrue('PARSETEST' in ext) 54 | self.assertEqual(None, ext.getparam('EXT1')) 55 | self.assertEqual('DATA', ext.getparam('PARSETEST')) 56 | 57 | def test_build_string(self): 58 | possibilities = ("""\ 59 | the header 60 | EMPTY 61 | TEST STUFF""", """\ 62 | the header 63 | TEST STUFF 64 | EMPTY""") 65 | ret = self.ext.build_string('the header').replace('\r', '') 66 | self.assertTrue(ret in possibilities, ret) 67 | 68 | def test_build_string_valueerror(self): 69 | class MyExtension(object): 70 | def __init__(self): 71 | pass 72 | def __str__(self): 73 | raise ValueError('test') 74 | ext = Extensions() 75 | ext.extensions = {'ONE': 'OK VALUE', 'TWO': MyExtension()} 76 | expected = """the header\r\nONE OK VALUE""" 77 | self.assertEqual(expected, ext.build_string('the header')) 78 | 79 | 80 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 81 | -------------------------------------------------------------------------------- /slimta/policy/forward.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2012 Ian C. Good 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | # 21 | 22 | """Implements a simple forwarding policy, to transform or replace 23 | recipients. 24 | 25 | """ 26 | 27 | from __future__ import absolute_import 28 | 29 | import re 30 | 31 | from . import QueuePolicy 32 | 33 | __all__ = ['Forward'] 34 | 35 | 36 | class Forward(QueuePolicy): 37 | """Each |Envelope| recipient is run through :func:`re.sub()` to see if it 38 | is modified. If a recipient matches a mapping rule, no further mapping 39 | rules are processed. Mapping rules are checked in the order that they were 40 | added. 41 | 42 | """ 43 | 44 | def __init__(self): 45 | self.mapping = [] 46 | 47 | def add_mapping(self, pattern, repl, count=0): 48 | """Adds a mapping rule. 49 | 50 | :param pattern: Pattern to check recipient against. 51 | :type pattern: :py:obj:`str` or :class:`re.RegexObject` 52 | :param repl: Replacement for ``pattern`` matches, as described by 53 | :func:`re.sub()`. 54 | :type repl: :py:obj:`str` or function 55 | :param count: Max number of replacements per recipient string. 56 | 57 | """ 58 | self.mapping.append((re.compile(pattern), repl, count)) 59 | 60 | def apply(self, envelope): 61 | n_rcpt = len(envelope.recipients) 62 | for i in range(n_rcpt): 63 | old_rcpt = envelope.recipients[i] 64 | for pattern, repl, count in self.mapping: 65 | new_rcpt, changes = re.subn(pattern, repl, old_rcpt, count) 66 | if new_rcpt and changes > 0: 67 | envelope.recipients[i] = new_rcpt 68 | break 69 | 70 | 71 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 72 | -------------------------------------------------------------------------------- /test/test_slimta_util_deque.py: -------------------------------------------------------------------------------- 1 | 2 | import unittest2 as unittest 3 | 4 | from mox3.mox import MoxTestBase, IsA 5 | from gevent.lock import Semaphore 6 | 7 | from slimta.util.deque import BlockingDeque 8 | 9 | 10 | class TestBlockingDeque(unittest.TestCase, MoxTestBase): 11 | 12 | def setUp(self): 13 | super(TestBlockingDeque, self).setUp() 14 | self.deque = BlockingDeque() 15 | self.deque.sema = self.mox.CreateMockAnything() 16 | 17 | def test_append(self): 18 | self.deque.sema.release() 19 | self.mox.ReplayAll() 20 | self.deque.append(True) 21 | 22 | def test_appendleft(self): 23 | self.deque.sema.release() 24 | self.mox.ReplayAll() 25 | self.deque.appendleft(True) 26 | 27 | def test_clear(self): 28 | for i in range(3): 29 | self.deque.sema.release() 30 | for i in range(3): 31 | self.deque.sema.locked().AndReturn(False) 32 | self.deque.sema.acquire(blocking=False) 33 | self.deque.sema.locked().AndReturn(True) 34 | self.mox.ReplayAll() 35 | self.deque.append(True) 36 | self.deque.append(True) 37 | self.deque.append(True) 38 | self.deque.clear() 39 | 40 | def test_extend(self): 41 | self.deque.sema.release() 42 | self.deque.sema.release() 43 | self.deque.sema.release() 44 | self.mox.ReplayAll() 45 | self.deque.extend([1, 2, 3]) 46 | 47 | def test_extendleft(self): 48 | self.deque.sema.release() 49 | self.deque.sema.release() 50 | self.deque.sema.release() 51 | self.mox.ReplayAll() 52 | self.deque.extendleft([1, 2, 3]) 53 | 54 | def test_pop(self): 55 | self.deque.sema.release() 56 | self.deque.sema.release() 57 | self.deque.sema.acquire() 58 | self.mox.ReplayAll() 59 | self.deque.append(4) 60 | self.deque.append(5) 61 | self.assertEqual(5, self.deque.pop()) 62 | 63 | def test_popleft(self): 64 | self.deque.sema.release() 65 | self.deque.sema.release() 66 | self.deque.sema.acquire() 67 | self.mox.ReplayAll() 68 | self.deque.append(4) 69 | self.deque.append(5) 70 | self.assertEqual(4, self.deque.popleft()) 71 | 72 | def test_remove(self): 73 | self.deque.sema.release() 74 | self.deque.sema.release() 75 | self.deque.sema.acquire() 76 | self.mox.ReplayAll() 77 | self.deque.append(4) 78 | self.deque.append(5) 79 | self.deque.remove(4) 80 | 81 | def test_remove_notfound(self): 82 | self.deque.sema.release() 83 | self.deque.sema.release() 84 | self.mox.ReplayAll() 85 | self.deque.append(4) 86 | self.deque.append(5) 87 | with self.assertRaises(ValueError): 88 | self.deque.remove(6) 89 | 90 | 91 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 92 | -------------------------------------------------------------------------------- /slimta/smtp/datasender.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2012 Ian C. Good 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | # 21 | 22 | from __future__ import absolute_import 23 | 24 | from itertools import chain 25 | 26 | __all__ = ['DataSender'] 27 | 28 | 29 | class DataSender(object): 30 | """Class that writes multi-line message data, taking care of dot marker 31 | """ 32 | def __init__(self, *parts): 33 | self.parts = parts 34 | self._calc_end_marker() 35 | 36 | def _calc_last_two(self): 37 | ret = b'' 38 | for part in reversed(self.parts): 39 | ret = part[-2:] + ret 40 | if len(ret) >= 2: 41 | ret = ret[-2:] 42 | break 43 | return ret 44 | 45 | def _calc_end_marker(self): 46 | last_two = self._calc_last_two() 47 | if not last_two or last_two == b'\r\n': 48 | self.end_marker = b'.\r\n' 49 | else: 50 | self.end_marker = b'\r\n.\r\n' 51 | 52 | def _process_part(self, part): 53 | """ 54 | :type part: bytes 55 | """ 56 | part_len = len(part) 57 | i = 0 58 | if part_len > 0 and part[0:1] == b'.': 59 | yield b'.' 60 | while i < part_len: 61 | index = part.find(b'\n.', i) 62 | if index == -1: 63 | yield part if i == 0 else part[i:] 64 | i = part_len 65 | else: 66 | yield part[i:index+2] 67 | yield b'.' 68 | i = index+2 69 | 70 | def __iter__(self): 71 | iterables = [self._process_part(part) for part in self.parts] 72 | iterables.append((self.end_marker, )) 73 | return chain.from_iterable(iterables) 74 | 75 | def send(self, io): 76 | for piece in self: 77 | io.buffered_send(piece) 78 | 79 | 80 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 81 | -------------------------------------------------------------------------------- /test/test_slimta_policy_spamassassin.py: -------------------------------------------------------------------------------- 1 | 2 | import unittest2 as unittest 3 | 4 | from mox3.mox import MoxTestBase 5 | from gevent.socket import socket, SHUT_WR 6 | 7 | from slimta.policy.spamassassin import SpamAssassin, SpamAssassinError 8 | from slimta.envelope import Envelope 9 | 10 | 11 | class TestSpamAssassin(unittest.TestCase, MoxTestBase): 12 | 13 | def setUp(self): 14 | super(TestSpamAssassin, self).setUp() 15 | self.sock = self.mox.CreateMock(socket) 16 | self.sock.fileno = lambda: -1 17 | self.sa = SpamAssassin(socket_creator=lambda _: self.sock) 18 | 19 | def test_send_request(self): 20 | self.sock.sendall(b"""\ 21 | SYMBOLS SPAMC/1.1 22 | Content-Length: 23 23 | User: slimta 24 | 25 | testheaders 26 | testbody 27 | """.replace(b'\n', b'\r\n')) 28 | self.sock.shutdown(SHUT_WR) 29 | self.mox.ReplayAll() 30 | self.sa._send_request(self.sock, b'testheaders\r\n', b'testbody\r\n') 31 | 32 | def test_recv_response(self): 33 | self.sock.recv(4096).AndReturn(b"""\ 34 | SPAMD/1.1 0 EX_OK 35 | Header-One: stuff 36 | Spam: True with some info 37 | Header-Two: other stuff 38 | 39 | symbol:one, symbol$two, symbol_three 40 | """) 41 | self.sock.recv(4096).AndReturn(b'') 42 | self.mox.ReplayAll() 43 | spammy, symbols = self.sa._recv_response(self.sock) 44 | self.assertTrue(spammy) 45 | self.assertEqual(['symbol:one', 'symbol$two', 'symbol_three'], symbols) 46 | 47 | def test_recv_response_bad_data(self): 48 | self.sock.recv(4096).AndReturn(b'') 49 | self.mox.ReplayAll() 50 | with self.assertRaises(SpamAssassinError): 51 | self.sa._recv_response(self.sock) 52 | 53 | def test_recv_response_bad_first_line(self): 54 | self.sock.recv(4096).AndReturn(b"""\ 55 | SPAMD/1.1 0 EX_NOT_OK 56 | 57 | """) 58 | self.sock.recv(4096).AndReturn(b'') 59 | self.mox.ReplayAll() 60 | with self.assertRaises(SpamAssassinError): 61 | self.sa._recv_response(self.sock) 62 | 63 | def test_scan(self): 64 | self.mox.StubOutWithMock(self.sa, '_send_request') 65 | self.mox.StubOutWithMock(self.sa, '_recv_response') 66 | self.sa._send_request(self.sock, b'', b'my message data') 67 | self.sa._recv_response(self.sock).AndReturn((False, [])) 68 | self.sock.close() 69 | self.mox.ReplayAll() 70 | self.assertEqual((False, []), self.sa.scan(b'my message data')) 71 | 72 | def test_apply(self): 73 | env = Envelope() 74 | env.parse(b"""X-Spam-Status: NO\r\n\r\n""") 75 | self.mox.StubOutWithMock(self.sa, 'scan') 76 | self.sa.scan(env).AndReturn((True, ['one', 'two'])) 77 | self.mox.ReplayAll() 78 | self.sa.apply(env) 79 | self.assertEqual('YES', env.headers['X-Spam-Status']) 80 | self.assertEqual('one, two', env.headers['X-Spam-Symbols']) 81 | 82 | 83 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 84 | -------------------------------------------------------------------------------- /slimta/queue/proxy.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2012 Ian C. Good 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | # 21 | 22 | """Package providing an alternative to the standard |Queue| class, such that 23 | messages are not written to storage but instead immediately relayed. An |Edge| 24 | service will have to wait for a message to finish relaying before its reply can 25 | be issued. 26 | 27 | """ 28 | 29 | from __future__ import absolute_import 30 | 31 | import uuid 32 | 33 | from slimta.relay import RelayError 34 | 35 | 36 | class ProxyQueue(object): 37 | """Class implementing the same interface as |Queue|, but proxies a message 38 | to a |Relay| service instead of storing and trying/retrying delivery. 39 | 40 | :param relay: |Relay| object used to attempt message deliveries. 41 | 42 | """ 43 | 44 | def __init__(self, relay): 45 | self.relay = relay 46 | 47 | def add_policy(self, *args): 48 | msg = 'ProxyQueue objects do not support add_policy()' 49 | raise NotImplementedError(msg) 50 | 51 | def start(self): 52 | # No-op, because this class does not inherit from Greenlet. Provided 53 | # for backwards compatibility with the standard Queue class. 54 | pass 55 | 56 | def kill(self): 57 | # No-op, because this class does not inherit from Greenlet. Provided 58 | # for backwards compatibility with the standard Queue class. 59 | pass 60 | 61 | def flush(self): 62 | # No-op, because this class does not maintain an actual queue. Provided 63 | # for backwards compatibility with the standard Queue class. 64 | pass 65 | 66 | def enqueue(self, envelope): 67 | try: 68 | self.relay._attempt(envelope, 0) 69 | except RelayError as e: 70 | return [(envelope, e)] 71 | else: 72 | return [(envelope, uuid.uuid4().hex)] 73 | 74 | 75 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 76 | -------------------------------------------------------------------------------- /test/test_slimta_util_dns.py: -------------------------------------------------------------------------------- 1 | 2 | from mox3.mox import MoxTestBase, IgnoreArg 3 | 4 | import pycares 5 | import pycares.errno 6 | import gevent 7 | from gevent import select 8 | from gevent.event import AsyncResult 9 | 10 | from slimta.util.dns import DNSResolver, DNSError 11 | 12 | 13 | class TestDNS(MoxTestBase): 14 | 15 | def test_get_query_type(self): 16 | self.assertEqual(pycares.QUERY_TYPE_MX, 17 | DNSResolver._get_query_type('MX')) 18 | self.assertEqual(13, DNSResolver._get_query_type(13)) 19 | 20 | def test_result_cb(self): 21 | result = AsyncResult() 22 | DNSResolver._result_cb(result, 13, None) 23 | self.assertEqual(13, result.get()) 24 | 25 | def test_result_cb_error(self): 26 | result = AsyncResult() 27 | DNSResolver._result_cb(result, 13, pycares.errno.ARES_ENOTFOUND) 28 | with self.assertRaises(DNSError) as cm: 29 | result.get() 30 | self.assertEqual('Domain name not found [ARES_ENOTFOUND]', 31 | str(cm.exception)) 32 | 33 | def test_query(self): 34 | channel = self.mox.CreateMock(pycares.Channel) 35 | self.mox.StubOutWithMock(pycares, 'Channel') 36 | self.mox.StubOutWithMock(gevent, 'spawn') 37 | pycares.Channel().AndReturn(channel) 38 | channel.query('example.com', 13, IgnoreArg()) 39 | gevent.spawn(IgnoreArg()) 40 | self.mox.ReplayAll() 41 | DNSResolver.query('example.com', 13) 42 | 43 | def test_wait_channel(self): 44 | DNSResolver._channel = channel = self.mox.CreateMockAnything() 45 | self.mox.StubOutWithMock(select, 'select') 46 | channel.getsock().AndReturn(('read', 'write')) 47 | channel.timeout().AndReturn(1.0) 48 | select.select('read', 'write', [], 1.0).AndReturn( 49 | ([1, 2, 3], [4, 5, 6], None)) 50 | for fd in [1, 2, 3]: 51 | channel.process_fd(fd, pycares.ARES_SOCKET_BAD) 52 | for fd in [4, 5, 6]: 53 | channel.process_fd(pycares.ARES_SOCKET_BAD, fd) 54 | channel.getsock().AndReturn(('read', 'write')) 55 | channel.timeout().AndReturn(None) 56 | channel.process_fd(pycares.ARES_SOCKET_BAD, pycares.ARES_SOCKET_BAD) 57 | channel.getsock().AndReturn((None, None)) 58 | self.mox.ReplayAll() 59 | DNSResolver._wait_channel() 60 | 61 | def test_wait_channel_error(self): 62 | DNSResolver._channel = channel = self.mox.CreateMockAnything() 63 | self.mox.StubOutWithMock(select, 'select') 64 | channel.getsock().AndReturn(('read', 'write')) 65 | channel.timeout().AndReturn(1.0) 66 | select.select('read', 'write', [], 1.0).AndRaise(ValueError(13)) 67 | channel.cancel() 68 | self.mox.ReplayAll() 69 | with self.assertRaises(ValueError): 70 | DNSResolver._wait_channel() 71 | self.assertIsNone(DNSResolver._channel) 72 | 73 | 74 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 75 | -------------------------------------------------------------------------------- /slimta/logging/queuestorage.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2012 Ian C. Good 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | # 21 | 22 | """Utilities to make logging consistent and easy for any |QueueStorage| 23 | interaction. 24 | 25 | """ 26 | 27 | from __future__ import absolute_import 28 | 29 | from functools import partial 30 | 31 | __all__ = ['QueueStorageLogger'] 32 | 33 | 34 | class QueueStorageLogger(object): 35 | """Provides a limited set of log methods that :mod:`slimta` packages may 36 | use. This prevents free-form logs from mixing in with standard, machine- 37 | parseable logs. 38 | 39 | :param log: :py:class:`logging.Logger` object to log through. 40 | 41 | """ 42 | 43 | def __init__(self, log): 44 | from slimta.logging import logline 45 | self.log = partial(logline, log.debug, 'queue') 46 | 47 | def write(self, id, envelope): 48 | """Logs a new |Envelope| being written to the queue storage. 49 | 50 | :param id: The ID string that identifies the message in storage. 51 | :param envelope: The |Envelope| being written to storage. 52 | 53 | """ 54 | self.log(id, 'write', 55 | sender=envelope.sender, 56 | recipients=envelope.recipients) 57 | 58 | def update_meta(self, id, **what): 59 | """Logs operations that modify the metadata associated with an 60 | |Envelope| in queue storage. 61 | 62 | :param id: The ID string that identifies the message in storage. 63 | :param what: What is being changed in the metadata. 64 | 65 | """ 66 | self.log(id, 'meta', **what) 67 | 68 | def remove(self, id): 69 | """Logs when an |Envelope| is removed from the queue, which could be 70 | because of success or failure. 71 | 72 | :param id: The ID string that identifies the message in storage. 73 | 74 | """ 75 | self.log(id, 'remove') 76 | 77 | 78 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 79 | -------------------------------------------------------------------------------- /test/test_slimta_logging.py: -------------------------------------------------------------------------------- 1 | import unittest2 as unittest 2 | 3 | from testfixtures import log_capture 4 | 5 | from slimta.logging import logline, parseline, log_exception 6 | 7 | 8 | class TestLogging(unittest.TestCase): 9 | 10 | def _check_logline(self, expected): 11 | def check(data): 12 | self.assertEqual(expected, data) 13 | return check 14 | 15 | def test_logline_nodata(self): 16 | check = self._check_logline('test:asdf:nodata') 17 | logline(check, 'test', 'asdf', 'nodata') 18 | 19 | def test_logline_withdata(self): 20 | check = self._check_logline( 21 | 'test:asdf:withdata one=1 two=\'two\'') 22 | logline(check, 'test', 'asdf', 'withdata', one=1, two='two') 23 | 24 | def test_parseline_nodata(self): 25 | line = 'test:jkl:nodata' 26 | type, id, op, data = parseline(line) 27 | self.assertEqual('test', type) 28 | self.assertEqual('jkl', id) 29 | self.assertEqual('nodata', op) 30 | self.assertEqual({}, data) 31 | 32 | def test_parseline_withdata(self): 33 | line = 'test:jkl:withdata one=1 two=\'two\'' 34 | type, id, op, data = parseline(line) 35 | self.assertEqual('test', type) 36 | self.assertEqual('jkl', id) 37 | self.assertEqual('withdata', op) 38 | self.assertEqual({'one': 1, 'two': 'two'}, data) 39 | 40 | def test_parseline_badbeginning(self): 41 | with self.assertRaises(ValueError): 42 | parseline('bad!') 43 | 44 | def test_parseline_baddata(self): 45 | line = 'test:jkl:baddata one=1 two=two' 46 | type, id, op, data = parseline(line) 47 | self.assertEqual('test', type) 48 | self.assertEqual('jkl', id) 49 | self.assertEqual('baddata', op) 50 | self.assertEqual({'one': 1}, data) 51 | line = 'test:jkl:baddata one=one two=\'two\'' 52 | type, id, op, data = parseline(line) 53 | self.assertEqual('test', type) 54 | self.assertEqual('jkl', id) 55 | self.assertEqual('baddata', op) 56 | self.assertEqual({}, data) 57 | 58 | @log_capture() 59 | def test_log_exception(self, l): 60 | log_exception('test', extra='not logged') 61 | try: 62 | raise ValueError('testing stuff') 63 | except Exception: 64 | log_exception('test', extra='more stuff') 65 | self.assertEqual(1, len(l.records)) 66 | rec = l.records[0] 67 | self.assertEqual('test', rec.name) 68 | self.assertEqual('ERROR', rec.levelname) 69 | type, id, op, data = parseline(rec.msg) 70 | self.assertEqual('exception', type) 71 | self.assertEqual('ValueError', id) 72 | self.assertEqual('unhandled', op) 73 | self.assertEqual('more stuff', data['extra']) 74 | self.assertEqual(('testing stuff', ), data['args']) 75 | self.assertEqual('testing stuff', data['message']) 76 | self.assertTrue(data['traceback']) 77 | 78 | 79 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 80 | -------------------------------------------------------------------------------- /slimta/util/deque.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2012 Ian C. Good 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | # 21 | 22 | from __future__ import absolute_import 23 | 24 | from collections import deque 25 | 26 | from gevent.lock import Semaphore 27 | 28 | __all__ = ['BlockingDeque'] 29 | 30 | 31 | class BlockingDeque(deque): 32 | 33 | def __init__(self, *args, **kwargs): 34 | super(BlockingDeque, self).__init__(*args, **kwargs) 35 | self.sema = Semaphore(len(self)) 36 | 37 | def append(self, *args, **kwargs): 38 | ret = super(BlockingDeque, self).append(*args, **kwargs) 39 | self.sema.release() 40 | return ret 41 | 42 | def appendleft(self, *args, **kwargs): 43 | ret = super(BlockingDeque, self).appendleft(*args, **kwargs) 44 | self.sema.release() 45 | return ret 46 | 47 | def clear(self, *args, **kwargs): 48 | ret = super(BlockingDeque, self).clear(*args, **kwargs) 49 | while not self.sema.locked(): 50 | self.sema.acquire(blocking=False) 51 | return ret 52 | 53 | def extend(self, *args, **kwargs): 54 | pre_n = len(self) 55 | ret = super(BlockingDeque, self).extend(*args, **kwargs) 56 | post_n = len(self) 57 | for i in range(pre_n, post_n): 58 | self.sema.release() 59 | return ret 60 | 61 | def extendleft(self, *args, **kwargs): 62 | pre_n = len(self) 63 | ret = super(BlockingDeque, self).extendleft(*args, **kwargs) 64 | post_n = len(self) 65 | for i in range(pre_n, post_n): 66 | self.sema.release() 67 | return ret 68 | 69 | def pop(self, *args, **kwargs): 70 | self.sema.acquire() 71 | return super(BlockingDeque, self).pop(*args, **kwargs) 72 | 73 | def popleft(self, *args, **kwargs): 74 | self.sema.acquire() 75 | return super(BlockingDeque, self).popleft(*args, **kwargs) 76 | 77 | def remove(self, *args, **kwargs): 78 | ret = super(BlockingDeque, self).remove(*args, **kwargs) 79 | self.sema.acquire() 80 | return ret 81 | 82 | 83 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 84 | -------------------------------------------------------------------------------- /slimta/util/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Ian C. Good 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | # 21 | 22 | """Package containing a variety of useful modules utilities that didn't really 23 | belong anywhere else. 24 | 25 | """ 26 | 27 | from __future__ import absolute_import 28 | 29 | from gevent import socket 30 | 31 | __all__ = ['build_ipv4_socket_creator', 'create_connection_ipv4'] 32 | 33 | 34 | def build_ipv4_socket_creator(only_ports=None): 35 | """Returns a function that will act like 36 | :py:func:`socket.create_connection` but only using IPv4 addresses. This 37 | function can be used as the ``socket_creator`` argument to some classes 38 | like :class:`~slimta.relay.smtp.mx.MxSmtpRelay`. 39 | 40 | :param only_ports: If given, can be a list to limit which ports are 41 | restricted to IPv4. Connections to all other ports may 42 | be IPv6. 43 | 44 | """ 45 | def socket_creator(*args, **kwargs): 46 | return create_connection_ipv4(*args, only_ports=only_ports, **kwargs) 47 | return socket_creator 48 | 49 | 50 | def create_connection_ipv4(address, timeout=None, source_address=None, 51 | only_ports=None): 52 | """Attempts to mimick to :py:func:`socket.create_connection`, but 53 | connections are only made to IPv4 addresses. 54 | 55 | :param only_ports: If given, can be a list to limit which ports are 56 | restricted to IPv4. Connections to all other ports may 57 | be IPv6. 58 | 59 | """ 60 | host, port = address 61 | if only_ports and port not in only_ports: 62 | return socket.create_connection(address, timeout, source_address) 63 | last_exc = None 64 | for res in socket.getaddrinfo(host, port, socket.AF_INET): 65 | _, _, _, _, sockaddr = res 66 | try: 67 | return socket.create_connection(sockaddr, timeout, source_address) 68 | except socket.error as exc: 69 | last_exc = exc 70 | if last_exc is not None: 71 | raise last_exc 72 | else: 73 | raise socket.error('getaddrinfo returns an empty list') 74 | 75 | 76 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 77 | -------------------------------------------------------------------------------- /test/test_slimta_edge.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import unittest2 as unittest 4 | from mox3.mox import MoxTestBase 5 | 6 | from slimta.edge import Edge, EdgeServer 7 | 8 | 9 | class TestEdge(unittest.TestCase, MoxTestBase): 10 | 11 | def test_handoff(self): 12 | self.mox.StubOutWithMock(time, 'time') 13 | env = self.mox.CreateMockAnything() 14 | queue = self.mox.CreateMockAnything() 15 | time.time().AndReturn(12345) 16 | queue.enqueue(env).AndReturn('asdf') 17 | self.mox.ReplayAll() 18 | edge = Edge(queue, 'test.example.com') 19 | self.assertEqual('asdf', edge.handoff(env)) 20 | self.assertEqual('test.example.com', env.receiver) 21 | self.assertEqual(12345, env.timestamp) 22 | 23 | def test_handoff_error(self): 24 | env = self.mox.CreateMockAnything() 25 | queue = self.mox.CreateMockAnything() 26 | queue.enqueue(env).AndRaise(RuntimeError) 27 | self.mox.ReplayAll() 28 | edge = Edge(queue) 29 | with self.assertRaises(RuntimeError): 30 | edge.handoff(env) 31 | 32 | def test_kill(self): 33 | queue = self.mox.CreateMockAnything() 34 | self.mox.ReplayAll() 35 | edge = Edge(queue) 36 | edge.kill() 37 | 38 | 39 | class TestEdgeServer(unittest.TestCase, MoxTestBase): 40 | 41 | def test_edge_interface(self): 42 | edge = EdgeServer(('127.0.0.1', 0), None) 43 | with self.assertRaises(NotImplementedError): 44 | edge.handle(None, None) 45 | 46 | def test_handle(self): 47 | queue = self.mox.CreateMockAnything() 48 | sock = self.mox.CreateMockAnything() 49 | edge = EdgeServer(('127.0.0.1', 0), queue) 50 | self.mox.StubOutWithMock(edge, 'handle') 51 | sock.fileno().AndReturn(15) 52 | edge.handle(sock, 'test address') 53 | self.mox.ReplayAll() 54 | try: 55 | edge.server.pre_start() 56 | except AttributeError: 57 | edge.server.init_socket() 58 | edge._handle(sock, 'test address') 59 | 60 | def test_handle_error(self): 61 | queue = self.mox.CreateMockAnything() 62 | sock = self.mox.CreateMockAnything() 63 | edge = EdgeServer(('127.0.0.1', 0), queue) 64 | self.mox.StubOutWithMock(edge, 'handle') 65 | sock.fileno().AndReturn(15) 66 | edge.handle(sock, 5).AndRaise(RuntimeError) 67 | self.mox.ReplayAll() 68 | try: 69 | edge.server.pre_start() 70 | except AttributeError: 71 | edge.server.init_socket() 72 | with self.assertRaises(RuntimeError): 73 | edge._handle(sock, 5) 74 | 75 | def test_kill(self): 76 | edge = EdgeServer(('127.0.0.1', 0), None) 77 | self.mox.StubOutWithMock(edge.server, 'stop') 78 | edge.server.stop() 79 | self.mox.ReplayAll() 80 | edge.kill() 81 | 82 | def test_run(self): 83 | edge = EdgeServer(('127.0.0.1', 0), None) 84 | self.mox.StubOutWithMock(edge.server, 'start') 85 | self.mox.StubOutWithMock(edge.server, 'serve_forever') 86 | edge.server.start() 87 | edge.server.serve_forever() 88 | self.mox.ReplayAll() 89 | edge._run() 90 | 91 | 92 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 93 | -------------------------------------------------------------------------------- /test/test_slimta_logging_socket.py: -------------------------------------------------------------------------------- 1 | import unittest2 as unittest 2 | import errno 3 | import logging 4 | import socket 5 | 6 | from testfixtures import log_capture 7 | 8 | import slimta.logging.socket 9 | from slimta.logging import getSocketLogger 10 | 11 | 12 | class FakeSocket(object): 13 | 14 | def __init__(self, fd, peer=None): 15 | self.fd = fd 16 | self.peer = peer 17 | 18 | def fileno(self): 19 | return self.fd 20 | 21 | def getpeername(self): 22 | return self.peer 23 | 24 | 25 | class FakeContext(object): 26 | 27 | def session_stats(self): 28 | return {'hits': 13} 29 | 30 | 31 | class TestSocketLogger(unittest.TestCase): 32 | 33 | def setUp(self): 34 | self.log = getSocketLogger('test') 35 | 36 | @log_capture() 37 | def test_send(self, l): 38 | sock = FakeSocket(136) 39 | self.log.send(sock, 'test send') 40 | l.check(('test', 'DEBUG', 'fd:136:send data=\'test send\'')) 41 | 42 | @log_capture() 43 | def test_recv(self, l): 44 | sock = FakeSocket(29193) 45 | self.log.recv(sock, 'test recv') 46 | l.check(('test', 'DEBUG', 'fd:29193:recv data=\'test recv\'')) 47 | 48 | @log_capture() 49 | def test_accept(self, l): 50 | server = FakeSocket(926) 51 | client = FakeSocket(927, 'testpeer') 52 | self.log.accept(server, client) 53 | self.log.accept(server, client, 'testpeer2') 54 | l.check(('test', 'DEBUG', 'fd:926:accept clientfd=927 peer=\'testpeer\''), 55 | ('test', 'DEBUG', 'fd:926:accept clientfd=927 peer=\'testpeer2\'')) 56 | 57 | @log_capture() 58 | def test_connect(self, l): 59 | sock = FakeSocket(539, 'testpeer') 60 | self.log.connect(sock) 61 | self.log.connect(sock, 'testpeer2') 62 | l.check(('test', 'DEBUG', 'fd:539:connect peer=\'testpeer\''), 63 | ('test', 'DEBUG', 'fd:539:connect peer=\'testpeer2\'')) 64 | 65 | @log_capture() 66 | def test_encrypt(self, l): 67 | sock = FakeSocket(445) 68 | context = FakeContext() 69 | self.log.encrypt(sock, context) 70 | l.check(('test', 'DEBUG', 'fd:445:encrypt hits=13')) 71 | 72 | @log_capture() 73 | def test_shutdown(self, l): 74 | sock = FakeSocket(823) 75 | self.log.shutdown(sock, socket.SHUT_RD) 76 | self.log.shutdown(sock, socket.SHUT_WR) 77 | self.log.shutdown(sock, socket.SHUT_RDWR) 78 | l.check(('test', 'DEBUG', 'fd:823:shutdown how=\'read\''), 79 | ('test', 'DEBUG', 'fd:823:shutdown how=\'write\''), 80 | ('test', 'DEBUG', 'fd:823:shutdown how=\'both\'')) 81 | 82 | @log_capture() 83 | def test_close(self, l): 84 | sock = FakeSocket(771) 85 | self.log.close(sock) 86 | l.check(('test', 'DEBUG', 'fd:771:close')) 87 | 88 | @log_capture() 89 | def test_error(self, l): 90 | sock = FakeSocket(680) 91 | exc = OSError(errno.EPIPE, 'Broken pipe') 92 | self.log.error(sock, exc, 'testaddress') 93 | slimta.logging.socket.socket_error_log_level = logging.WARNING 94 | self.log.error(sock, exc) 95 | l.check(('test', 'ERROR', 'fd:680:error address=\'testaddress\' args=(32, \'Broken pipe\') message=\'[Errno 32] Broken pipe\''), 96 | ('test', 'WARNING', 'fd:680:error args=(32, \'Broken pipe\') message=\'[Errno 32] Broken pipe\'')) 97 | 98 | 99 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 100 | -------------------------------------------------------------------------------- /test/test_slimta_smtp_datareader.py: -------------------------------------------------------------------------------- 1 | import unittest2 as unittest 2 | from mox3.mox import MoxTestBase, IsA 3 | from gevent.socket import socket 4 | 5 | from slimta.smtp.datareader import DataReader 6 | from slimta.smtp.io import IO 7 | from slimta.smtp import ConnectionLost, MessageTooBig 8 | 9 | 10 | class TestSmtpDataReader(unittest.TestCase, MoxTestBase): 11 | 12 | def setUp(self): 13 | super(TestSmtpDataReader, self).setUp() 14 | self.sock = self.mox.CreateMock(socket) 15 | self.sock.fileno = lambda: -1 16 | 17 | def test_append_line(self): 18 | dr = DataReader(None) 19 | dr._append_line(b'asdf') 20 | dr._append_line(b'jkl\r\n') 21 | dr.i += 1 22 | dr._append_line(b'qwerty') 23 | self.assertEqual([b'asdfjkl\r\n', b'qwerty'], dr.lines) 24 | 25 | def test_from_recv_buffer(self): 26 | io = IO(None) 27 | io.recv_buffer = b'test\r\ndata' 28 | dr = DataReader(io) 29 | dr.from_recv_buffer() 30 | self.assertEqual([b'test\r\n', b'data'], dr.lines) 31 | 32 | def test_handle_finished_line_EOD(self): 33 | dr = DataReader(None) 34 | dr.lines = [b'.\r\n'] 35 | dr.handle_finished_line() 36 | self.assertEqual(0, dr.EOD) 37 | 38 | def test_handle_finished_line_initial_period(self): 39 | dr = DataReader(None) 40 | dr.lines = [b'..stuff\r\n'] 41 | dr.handle_finished_line() 42 | self.assertEqual(b'.stuff\r\n', dr.lines[0]) 43 | 44 | def test_add_lines(self): 45 | dr = DataReader(None) 46 | dr.add_lines(b'\r\ntwo\r\n.three\r\nfour') 47 | self.assertEqual([b'\r\n', b'two\r\n', b'three\r\n', b'four'], dr.lines) 48 | self.assertEqual(3, dr.i) 49 | self.assertEqual(None, dr.EOD) 50 | 51 | def test_recv_piece(self): 52 | self.sock.recv(IsA(int)).AndReturn(b'one\r\ntwo') 53 | self.sock.recv(IsA(int)).AndReturn(b'\r\nthree\r\n.\r\nstuff\r\n') 54 | self.mox.ReplayAll() 55 | dr = DataReader(IO(self.sock)) 56 | self.assertTrue(dr.recv_piece()) 57 | self.assertFalse(dr.recv_piece()) 58 | self.assertEqual([b'one\r\n', b'two\r\n', b'three\r\n', 59 | b'.\r\n', b'stuff\r\n', b''], dr.lines) 60 | self.assertEqual(3, dr.EOD) 61 | self.assertEqual(5, dr.i) 62 | 63 | def test_recv_piece_already_eod(self): 64 | dr = DataReader(None) 65 | dr.EOD = 2 66 | self.assertFalse(dr.recv_piece()) 67 | 68 | def test_recv_piece_connectionlost(self): 69 | self.sock.recv(IsA(int)).AndReturn(b'') 70 | self.mox.ReplayAll() 71 | dr = DataReader(IO(self.sock)) 72 | self.assertRaises(ConnectionLost, dr.recv_piece) 73 | 74 | def test_recv_piece_messagetoobig(self): 75 | self.sock.recv(IsA(int)).AndReturn(b'1234567890') 76 | self.mox.ReplayAll() 77 | dr = DataReader(IO(self.sock), 9) 78 | self.assertRaises(MessageTooBig, dr.recv_piece) 79 | 80 | def test_return_all(self): 81 | io = IO(None) 82 | dr = DataReader(io) 83 | dr.lines = [b'one\r\n', b'two\r\n', b'.\r\n', b'three\r\n'] 84 | dr.EOD = 2 85 | self.assertEqual(b'one\r\ntwo\r\n', dr.return_all()) 86 | self.assertEqual(b'three\r\n', io.recv_buffer) 87 | 88 | def test_recv(self): 89 | self.sock.recv(IsA(int)).AndReturn(b'\r\nthree\r\n') 90 | self.sock.recv(IsA(int)).AndReturn(b'.\r\nstuff\r\n') 91 | self.mox.ReplayAll() 92 | io = IO(self.sock) 93 | io.recv_buffer = b'one\r\ntwo' 94 | dr = DataReader(io) 95 | self.assertEqual(b'one\r\ntwo\r\nthree\r\n', dr.recv()) 96 | 97 | 98 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 99 | -------------------------------------------------------------------------------- /test/test_slimta_queue_dict.py: -------------------------------------------------------------------------------- 1 | import unittest2 as unittest 2 | import re 3 | 4 | from slimta.queue.dict import DictStorage 5 | from slimta.envelope import Envelope 6 | 7 | 8 | class TestDictStorage(unittest.TestCase): 9 | 10 | id_pattern = re.compile(r'[0-9a-fA-F]{32}') 11 | 12 | def setUp(self): 13 | self.env = {} 14 | self.meta = {} 15 | self.dict = DictStorage(self.env, self.meta) 16 | 17 | def _write_test_envelope(self, rcpts=None): 18 | env = Envelope('sender@example.com', rcpts or ['rcpt@example.com']) 19 | env.timestamp = 9876543210 20 | id = self.dict.write(env, 1234567890) 21 | return id, env 22 | 23 | def test_write(self): 24 | id, env = self._write_test_envelope() 25 | self.assertTrue(self.id_pattern.match(id)) 26 | self.assertEqual(env, self.env[id]) 27 | self.assertEqual(1234567890, self.meta[id]['timestamp']) 28 | self.assertEqual(0, self.meta[id]['attempts']) 29 | self.assertEqual('sender@example.com', self.env[id].sender) 30 | self.assertEqual(['rcpt@example.com'], self.env[id].recipients) 31 | self.assertEqual(9876543210, self.env[id].timestamp) 32 | 33 | def test_set_timestamp(self): 34 | id, env = self._write_test_envelope() 35 | self.dict.set_timestamp(id, 1111) 36 | self.assertEqual(env, self.env[id]) 37 | self.assertEqual(1111, self.meta[id]['timestamp']) 38 | 39 | def test_increment_attempts(self): 40 | id, env = self._write_test_envelope() 41 | self.assertEqual(1, self.dict.increment_attempts(id)) 42 | self.assertEqual(2, self.dict.increment_attempts(id)) 43 | self.assertEqual(env, self.env[id]) 44 | self.assertEqual(2, self.meta[id]['attempts']) 45 | 46 | def test_set_recipients_delivered(self): 47 | id, env = self._write_test_envelope(['one', 'two', 'three']) 48 | self.dict.set_recipients_delivered(id, [1]) 49 | self.assertEqual(['one', 'three'], env.recipients) 50 | self.dict.set_recipients_delivered(id, [0, 1]) 51 | self.assertEqual([], env.recipients) 52 | 53 | def test_load(self): 54 | queued = [self._write_test_envelope(), 55 | self._write_test_envelope()] 56 | loaded = [info for info in self.dict.load()] 57 | self.assertEqual(len(queued), len(loaded)) 58 | for timestamp, loaded_id in loaded: 59 | for queued_id, env in queued: 60 | if loaded_id == queued_id: 61 | self.assertEqual(env, self.env[loaded_id]) 62 | self.assertEqual(timestamp, self.meta[queued_id]['timestamp']) 63 | break 64 | else: 65 | raise ValueError('Queued does not match loaded') 66 | 67 | def test_get(self): 68 | id, env = self._write_test_envelope() 69 | self.dict.increment_attempts(id) 70 | get_env, get_attempts = self.dict.get(id) 71 | self.assertEqual(env, get_env) 72 | self.assertEqual(1, get_attempts) 73 | 74 | def test_remove(self): 75 | id, env = self._write_test_envelope() 76 | self.dict.remove(id) 77 | id, env = self._write_test_envelope() 78 | del self.env[id] 79 | self.dict.remove(id) 80 | id, env = self._write_test_envelope() 81 | del self.meta[id] 82 | self.dict.remove(id) 83 | 84 | def test_get_info(self): 85 | id1, _ = self._write_test_envelope() 86 | id2, _ = self._write_test_envelope() 87 | id3, _ = self._write_test_envelope() 88 | self.dict.remove(id2) 89 | info = self.dict.get_info() 90 | self.assertEqual(2, info['size']) 91 | self.assertEqual(2, info['meta_size']) 92 | 93 | 94 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | #### [API Documentation and Manual][5] 3 | 4 | -------------------- 5 | 6 | About 7 | ===== 8 | 9 | The `python-slimta` project is a Python library offering the building blocks 10 | necessary to create a full-featured [MTA][1]. Most MTAs must be configured, but 11 | an MTA built with `python-slimta` is coded. An MTA built with `python-slimta` 12 | can incorporate any protocol or policy, custom or built-in. An MTA built with 13 | `python-slimta` can integrate with other Python libraries and take advantage of 14 | Python's great community. 15 | 16 | The `python-slimta` project is released under the [MIT License][4]. It requires 17 | Python 2.7+ or 3.4+. 18 | 19 | [![Build Status](https://travis-ci.org/slimta/python-slimta.svg?branch=master)](https://travis-ci.org/slimta/python-slimta) 20 | [![Coverage Status](https://coveralls.io/repos/github/slimta/python-slimta/badge.svg?branch=master)](https://coveralls.io/github/slimta/python-slimta?branch=master) 21 | [![PyPI](https://img.shields.io/pypi/v/python-slimta.svg)](https://pypi.python.org/pypi/python-slimta) 22 | [![PyPI](https://img.shields.io/pypi/pyversions/python-slimta.svg)](https://pypi.python.org/pypi/python-slimta) 23 | [![PyPI](https://img.shields.io/pypi/l/python-slimta.svg)](https://pypi.python.org/pypi/python-slimta) 24 | [![Flattr](http://api.flattr.com/button/flattr-badge-large.png)](https://flattr.com/submit/auto?user_id=icgood&url=https%3A%2F%2Fgithub.com%2Fslimta%2Fpython-slimta&title=python-slimta&language=python&tags=github&category=software) 25 | 26 | 27 | Getting Started 28 | =============== 29 | 30 | Use a [virtualenv][2] to get started developing against `python-slimta`: 31 | 32 | $ cd python-slimta/ 33 | $ virtualenv .venv 34 | $ source .venv/bin/activate 35 | (.venv)$ python setup.py develop 36 | 37 | To run the suite of unit tests included with `slimta`: 38 | 39 | (.venv)$ pip install -r test/requirements.txt 40 | (.venv)$ py.test 41 | 42 | Running the Example 43 | =================== 44 | 45 | The example in [`examples/slimta-mail.py`](examples/slimta-mail.py) provides a 46 | fully functional mail server for inbound and outbound email. To avoid needing 47 | to run as superuser, the example uses ports `1025`, `1465` and `1587` instead. 48 | 49 | It needs several things to run: 50 | 51 | * An activated `virtualenv` as created above in *Getting Started*. 52 | 53 | * A TLS certificate and key file. The easiest way to generate one: 54 | 55 | ``` 56 | openssl req -x509 -nodes -subj '/CN=localhost' -newkey rsa:1024 -keyout cert.pem -out cert.pem 57 | ``` 58 | 59 | * A populated [`examples/site_data.py`](examples/site_data.py) config file. 60 | 61 | Check out the in-line documentation with `--help`, and then run: 62 | 63 | (.venv)$ ./slimta-mail.py 64 | 65 | Manually or with a mail client, you should now be able to deliver messages. On 66 | port `1025`, messages will go to unique files in the current directory. On port 67 | `1587`, messages will be delivered to others using MX records! To try out a TLS 68 | connection: 69 | 70 | $ openssl s_client -host localhost -port 1587 -starttls smtp 71 | 72 | Contributing 73 | ============ 74 | 75 | If you want to fix a bug or make a change, follow the [fork pull request][6] 76 | model. We've had quite a few [awesome contributors][7] over the years, and are 77 | always open to more. 78 | 79 | Special thanks to [JocelynDelalande][8] for extensive work bringing Python 3 80 | compatibility to the project! 81 | 82 | [1]: http://en.wikipedia.org/wiki/Message_transfer_agent 83 | [2]: http://pypi.python.org/pypi/virtualenv 84 | [3]: http://en.wikipedia.org/wiki/Open_mail_relay 85 | [4]: http://opensource.org/licenses/MIT 86 | [5]: http://slimta.org/ 87 | [6]: https://help.github.com/articles/using-pull-requests/ 88 | [7]: https://github.com/slimta/python-slimta/graphs/contributors 89 | [8]: https://github.com/JocelynDelalande 90 | -------------------------------------------------------------------------------- /slimta/http/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013 Ian C. Good 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | # 21 | 22 | """Root package for |slimta| HTTP client and server libraries. 23 | 24 | This package contains implementations of HTTP classes from :py:mod:`httplib` 25 | using gevent sockets. These are provided to avoid the complete 26 | re-implementation that ships in :mod:`gevent.httplib`, and to provide a more 27 | similar interface to other slimta libraries that use SSL/TLS. 28 | 29 | """ 30 | 31 | from __future__ import absolute_import 32 | 33 | from socket import error as socket_error 34 | 35 | from gevent import socket 36 | 37 | from slimta.util.pycompat import httplib, urlparse 38 | 39 | __all__ = ['HTTPConnection', 'HTTPSConnection', 'get_connection'] 40 | 41 | 42 | class HTTPConnection(httplib.HTTPConnection): 43 | """Modified version of the :py:class:`httplib.HTTPConnection` class that 44 | uses gevent sockets. This attempts to avoid the complete re-implementation 45 | that ships in :mod:`gevent.httplib`. 46 | 47 | """ 48 | 49 | def __init__(self, host, port=None, *args, **kwargs): 50 | httplib.HTTPConnection.__init__(self, host, port, *args, **kwargs) 51 | self._create_connection = socket.create_connection 52 | 53 | 54 | class HTTPSConnection(httplib.HTTPSConnection): 55 | """Modified version of the :py:class:`httplib.HTTPSConnection` class that 56 | uses gevent sockets. 57 | 58 | """ 59 | 60 | def __init__(self, host, port=None, *args, **kwargs): 61 | httplib.HTTPSConnection.__init__(self, host, port, *args, **kwargs) 62 | self._create_connection = socket.create_connection 63 | 64 | def close(self): 65 | if self.sock: 66 | try: 67 | self.sock.unwrap() 68 | except socket_error as e: 69 | if e.errno != 0: 70 | raise 71 | httplib.HTTPSConnection.close(self) 72 | 73 | 74 | def get_connection(url, context=None): 75 | """This convenience functions returns a :class:`HTTPConnection` or 76 | :class:`HTTPSConnection` based on the information contained in URL. 77 | 78 | :param url: URL string to create a connection for. Alternatively, passing 79 | in the results of :py:func:`urlparse.urlsplit` works as well. 80 | :param context: Used to wrap sockets with SSL encryption, when the URL 81 | scheme is ``https``. 82 | :type context: :py:class:`~ssl.SSLContext` 83 | 84 | """ 85 | if isinstance(url, (str, bytes)): 86 | url = urlparse.urlsplit(url, 'http') 87 | host = url.netloc or 'localhost' 88 | 89 | if url.scheme == 'https': 90 | conn = HTTPSConnection(host, context=context) 91 | else: 92 | conn = HTTPConnection(host) 93 | return conn 94 | 95 | 96 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 97 | -------------------------------------------------------------------------------- /slimta/http/wsgi.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013 Ian C. Good 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | # 21 | 22 | """This module provides a basis for implementing WSGI_ applications that are 23 | automatically logged with :mod:`slimta.logging.http`. 24 | 25 | .. _WSGI: http://wsgi.readthedocs.org/en/latest/ 26 | .. _variables: http://www.python.org/dev/peps/pep-0333/#environ-variables 27 | 28 | """ 29 | 30 | from __future__ import absolute_import 31 | 32 | import sys 33 | 34 | from gevent.pywsgi import WSGIServer as GeventWSGIServer 35 | 36 | from slimta import logging 37 | 38 | __all__ = ['WsgiServer'] 39 | 40 | log = logging.getHttpLogger(__name__) 41 | 42 | 43 | class WsgiServer(object): 44 | """Implements the base class for a WSGI server that logs its requests and 45 | responses and can easily be deployed as a functioning HTTP server. 46 | 47 | Instances of this class can be used as applications in WSGI server engines, 48 | or :meth:`.build_server` can be used. 49 | 50 | """ 51 | 52 | def build_server(self, listener, pool=None, ssl_args=None): 53 | """Constructs and returns a WSGI server engine, configured to use the 54 | current object as its application. 55 | 56 | :param listener: Usually a ``(ip, port)`` tuple defining the interface 57 | and port upon which to listen for connections. 58 | :param pool: If given, defines a specific :class:`gevent.pool.Pool` to 59 | use for new greenlets. 60 | :param ssl_args: Optional dictionary of TLS settings, causing sockets 61 | to be encrypted on connection. 62 | :rtype: :class:`gevent.pywsgi.WSGIServer` 63 | 64 | """ 65 | spawn = pool or 'default' 66 | ssl_args = ssl_args or {} 67 | return GeventWSGIServer(listener, self, log=sys.stdout, spawn=spawn, 68 | **ssl_args) 69 | 70 | def handle(self, environ, start_response): 71 | """Overridden by sub-classes to handle WSGI requests and generate a 72 | response. This method should be used as if it were the WSGI application 73 | function. 74 | 75 | :param environ: The WSGI environment variables_. 76 | :param start_response: Call this function to initiate the WSGI 77 | response. 78 | :returns: An iterable of raw data parts to return with the response. 79 | 80 | """ 81 | raise NotImplementedError() 82 | 83 | def __call__(self, environ, start_response): 84 | """When this object is used as a WSGI application, this method logs the 85 | request and ensures that the response will be logged as well. The 86 | request is then proxied to :meth:`.handle` for processing. 87 | 88 | """ 89 | log.wsgi_request(environ) 90 | 91 | def logged_start_response(status, headers, *args, **kwargs): 92 | log.wsgi_response(environ, status, headers) 93 | return start_response(status, headers, *args, **kwargs) 94 | return self.handle(environ, logged_start_response) 95 | 96 | 97 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 98 | -------------------------------------------------------------------------------- /slimta/queue/dict.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2012 Ian C. Good 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | # 21 | 22 | """Package implementing the :mod:`slimta.queue` system on top of a 23 | :func:`dict()` backend. This backend can be implemented as a :mod:`shelve` to 24 | provide basic persistence. 25 | 26 | """ 27 | 28 | from __future__ import absolute_import 29 | 30 | import uuid 31 | 32 | from slimta import logging 33 | from . import QueueStorage 34 | 35 | __all__ = ['DictStorage'] 36 | 37 | log = logging.getQueueStorageLogger(__name__) 38 | 39 | 40 | class DictStorage(QueueStorage): 41 | """Stores |Envelope| and queue metadata in two basic dictionary objects. 42 | 43 | :param envelope_db: The dictionary object to hold |Envelope| objects, keyed 44 | by a unique string. Defaults to an empty :func:`dict`. 45 | :param meta_db: The dictionary object to hold envelope metadata, keyed by 46 | the same string as ``envelope_db``. Defaults to an empty 47 | :func:`dict`. 48 | 49 | """ 50 | 51 | def __init__(self, envelope_db=None, meta_db=None): 52 | super(DictStorage, self).__init__() 53 | self.env_db = envelope_db if envelope_db is not None else {} 54 | self.meta_db = meta_db if meta_db is not None else {} 55 | 56 | def write(self, envelope, timestamp): 57 | while True: 58 | id = uuid.uuid4().hex 59 | if id not in self.env_db: 60 | self.env_db[id] = envelope 61 | self.meta_db[id] = {'timestamp': timestamp, 'attempts': 0} 62 | log.write(id, envelope) 63 | return id 64 | 65 | def set_timestamp(self, id, timestamp): 66 | meta = self.meta_db[id] 67 | meta['timestamp'] = timestamp 68 | self.meta_db[id] = meta 69 | log.update_meta(id, timestamp=timestamp) 70 | 71 | def increment_attempts(self, id): 72 | meta = self.meta_db[id] 73 | new_attempts = meta['attempts'] + 1 74 | meta['attempts'] = new_attempts 75 | self.meta_db[id] = meta 76 | log.update_meta(id, attempts=new_attempts) 77 | return new_attempts 78 | 79 | def set_recipients_delivered(self, id, rcpt_indexes): 80 | self._remove_delivered_rcpts(self.env_db[id], rcpt_indexes) 81 | log.update_meta(id, delivered_indexes=rcpt_indexes) 82 | 83 | def load(self): 84 | for key in self.meta_db.keys(): 85 | meta = self.meta_db[key] 86 | yield (meta['timestamp'], key) 87 | 88 | def get(self, id): 89 | meta = self.meta_db[id] 90 | return self.env_db[id], meta['attempts'] 91 | 92 | def remove(self, id): 93 | try: 94 | del self.meta_db[id] 95 | except KeyError: 96 | pass 97 | try: 98 | del self.env_db[id] 99 | except KeyError: 100 | pass 101 | log.remove(id) 102 | 103 | def get_info(self): 104 | return {'size': len(self.env_db), 105 | 'meta_size': len(self.meta_db)} 106 | 107 | 108 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 109 | -------------------------------------------------------------------------------- /slimta/policy/split.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2012 Ian C. Good 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | # 21 | 22 | """Implements a policy to break down envelopes with multiple recipients into 23 | logical groups. This is useful for relayers that may not handle multi-recipient 24 | messages well, such as :class:`~slimta.relay.smtp.mx.MxSmtpRelay`. 25 | 26 | """ 27 | 28 | from __future__ import absolute_import 29 | 30 | from collections import OrderedDict 31 | 32 | from . import QueuePolicy 33 | 34 | __all__ = ['RecipientSplit', 'RecipientDomainSplit'] 35 | 36 | 37 | class RecipientSplit(QueuePolicy): 38 | """If a given |Envelope| has more than one recipient, this policy splits 39 | it, generating a list of new :class:`Envelope` object copies where each has 40 | only one recipient. Each new object has its own copy of 41 | :attr:`~slimta.envelope.Envelope.headers`, but other attributes may be 42 | shared between each new instance. 43 | 44 | """ 45 | 46 | def apply(self, envelope): 47 | if len(envelope.recipients) <= 1: 48 | return 49 | ret = [] 50 | for rcpt in envelope.recipients: 51 | new_env = envelope.copy([rcpt]) 52 | ret.append(new_env) 53 | return ret 54 | 55 | 56 | class RecipientDomainSplit(QueuePolicy): 57 | """If a given |Envelope| recipients of more than one unique domain (case- 58 | insensitive), this policy splits it generating a list of new 59 | :class:`Envelope` object copies where each has only one recipient. Each new 60 | object has its own copy of :attr:`~slimta.envelope.Envelope.headers`, but 61 | other attributes may be shared between each new instance. 62 | 63 | Any recipient in the original |Envelope| that does not have a domain (and 64 | thus is not a valid email address) will be given an |Envelope| of its own. 65 | 66 | """ 67 | 68 | def _get_domain(self, rcpt): 69 | localpart, domain = rcpt.rsplit('@', 1) 70 | if not domain: 71 | raise ValueError(rcpt) 72 | return domain.lower() 73 | 74 | def _get_domain_groups(self, recipients): 75 | groups = OrderedDict() 76 | bad_rcpts = [] 77 | for rcpt in recipients: 78 | try: 79 | domain = self._get_domain(rcpt) 80 | except ValueError: 81 | bad_rcpts.append(rcpt) 82 | else: 83 | groups.setdefault(domain, []).append(rcpt) 84 | return groups, bad_rcpts 85 | 86 | def _append_envelope_copy(self, envelope, copies, rcpts): 87 | new_env = envelope.copy(rcpts) 88 | copies.append(new_env) 89 | 90 | def apply(self, envelope): 91 | groups, bad_rcpts = self._get_domain_groups(envelope.recipients) 92 | if len(groups)+len(bad_rcpts) <= 1: 93 | return 94 | ret = [] 95 | for domain, rcpts in groups.items(): 96 | self._append_envelope_copy(envelope, ret, rcpts) 97 | for bad_rcpt in bad_rcpts: 98 | self._append_envelope_copy(envelope, ret, [bad_rcpt]) 99 | return ret 100 | 101 | 102 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 103 | -------------------------------------------------------------------------------- /test/test_slimta_edge_wsgi.py: -------------------------------------------------------------------------------- 1 | 2 | from io import BytesIO 3 | 4 | from mox3.mox import MoxTestBase, IsA 5 | 6 | from slimta.edge.wsgi import WsgiEdge, WsgiValidators 7 | from slimta.envelope import Envelope 8 | from slimta.queue import QueueError 9 | 10 | 11 | class TestEdgeWsgi(MoxTestBase): 12 | 13 | def setUp(self): 14 | super(TestEdgeWsgi, self).setUp() 15 | self.start_response = self.mox.CreateMockAnything() 16 | self.queue = self.mox.CreateMockAnything() 17 | self.environ = {'REQUEST_METHOD': 'POST', 18 | 'HTTP_X_EHLO': 'test', 19 | 'HTTP_X_ENVELOPE_SENDER': 'c2VuZGVyQGV4YW1wbGUuY29t', 20 | 'HTTP_X_ENVELOPE_RECIPIENT': 'cmNwdDFAZXhhbXBsZS5jb20=, cmNwdDJAZXhhbXBsZS5jb20=', 21 | 'HTTP_X_CUSTOM_HEADER': 'custom test', 22 | 'wsgi.input': BytesIO(b'')} 23 | 24 | def test_invalid_path(self): 25 | environ = self.environ.copy() 26 | valid_paths = r'/good' 27 | environ['PATH_INFO'] = '/bad' 28 | self.start_response.__call__('404 Not Found', IsA(list)) 29 | self.mox.ReplayAll() 30 | w = WsgiEdge(self.queue, uri_pattern=valid_paths) 31 | self.assertEqual([], w(environ, self.start_response)) 32 | 33 | def test_invalid_method(self): 34 | environ = self.environ.copy() 35 | environ['REQUEST_METHOD'] = 'PUT' 36 | self.start_response.__call__('405 Method Not Allowed', IsA(list)) 37 | self.mox.ReplayAll() 38 | w = WsgiEdge(self.queue) 39 | self.assertEqual([], w(environ, self.start_response)) 40 | 41 | def test_invalid_content_type(self): 42 | environ = self.environ.copy() 43 | environ['CONTENT_TYPE'] = 'text/plain' 44 | self.start_response.__call__('415 Unsupported Media Type', IsA(list)) 45 | self.mox.ReplayAll() 46 | w = WsgiEdge(self.queue) 47 | self.assertEqual([], w(environ, self.start_response)) 48 | 49 | def test_unexpected_exception(self): 50 | environ = self.environ.copy() 51 | environ['wsgi.input'] = None 52 | self.start_response.__call__('500 Internal Server Error', IsA(list)) 53 | self.mox.ReplayAll() 54 | w = WsgiEdge(self.queue) 55 | self.assertEqual(["'NoneType' object has no attribute 'read'\n"], w(environ, self.start_response)) 56 | 57 | def test_no_error(self): 58 | def verify_envelope(env): 59 | if not isinstance(env, Envelope): 60 | return False 61 | if 'sender@example.com' != env.sender: 62 | return False 63 | if 'rcpt1@example.com' != env.recipients[0]: 64 | return False 65 | if 'rcpt2@example.com' != env.recipients[1]: 66 | return False 67 | return True 68 | self.queue.enqueue(IsA(Envelope)).AndReturn([(Envelope(), 'testid')]) 69 | self.start_response.__call__('204 No Content', IsA(list)) 70 | self.mox.ReplayAll() 71 | w = WsgiEdge(self.queue) 72 | self.assertEqual([], w(self.environ, self.start_response)) 73 | 74 | def test_queueerror(self): 75 | self.queue.enqueue(IsA(Envelope)).AndReturn([(Envelope(), QueueError())]) 76 | self.start_response.__call__('503 Service Unavailable', IsA(list)) 77 | self.mox.ReplayAll() 78 | w = WsgiEdge(self.queue) 79 | self.assertEqual([], w(self.environ, self.start_response)) 80 | 81 | def test_run_validators(self): 82 | self.validated = 0 83 | class Validators(WsgiValidators): 84 | custom_headers = ['X-Custom-Header'] 85 | def validate_ehlo(self2, ehlo): 86 | self.assertEqual('test', ehlo) 87 | self.validated += 1 88 | def validate_sender(self2, sender): 89 | self.assertEqual('sender@example.com', sender) 90 | self.validated += 2 91 | def validate_recipient(self2, recipient): 92 | if recipient == 'rcpt1@example.com': 93 | self.validated += 4 94 | elif recipient == 'rcpt2@example.com': 95 | self.validated += 8 96 | else: 97 | raise AssertionError('bad recipient: '+recipient) 98 | def validate_custom(self2, name, value): 99 | self.assertEqual('X-Custom-Header', name) 100 | self.assertEqual('custom test', value) 101 | self.validated += 16 102 | w = WsgiEdge(None, validator_class=Validators) 103 | w._run_validators(self.environ) 104 | self.assertEqual(31, self.validated) 105 | 106 | 107 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 108 | -------------------------------------------------------------------------------- /slimta/smtp/datareader.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2012 Ian C. Good 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | # 21 | 22 | """Reads message contents from an SMTP client. This task requires special 23 | consideration because of the SMTP RFC requirements that message data be ended 24 | with a line with a single ``.``. Also, lines that begin with ``.`` but contain 25 | other data should have the prefixed ``.`` removed. 26 | 27 | """ 28 | 29 | from __future__ import absolute_import 30 | 31 | import re 32 | 33 | from . import ConnectionLost, MessageTooBig 34 | 35 | __all__ = ['DataReader'] 36 | 37 | fullline_pattern = re.compile(br'.*\n') 38 | eod_pattern = re.compile(br'^\.\s*?\n$') 39 | endl_pattern = re.compile(br'\r?\n$') 40 | 41 | 42 | class DataReader(object): 43 | """Class that reads message data until the End-Of-Data marker, or until a 44 | certain number of bytes have been read. 45 | 46 | :param io: |IO| object to read message data from. 47 | :param max_size: If given, causes :class:`slimta.smtp.MessageTooBig` to be 48 | raised if too many bytes have been read. 49 | 50 | """ 51 | 52 | def __init__(self, io, max_size=None): 53 | self.io = io 54 | self.size = 0 55 | self.max_size = max_size 56 | 57 | self.EOD = None 58 | self.lines = [b''] 59 | self.i = 0 60 | 61 | def _append_line(self, line): 62 | if len(self.lines) <= self.i: 63 | self.lines.append(line) 64 | else: 65 | self.lines[self.i] += line 66 | 67 | def from_recv_buffer(self): 68 | self.add_lines(self.io.recv_buffer) 69 | self.io.recv_buffer = b'' 70 | 71 | def handle_finished_line(self): 72 | i = self.i 73 | line = self.lines[i] 74 | 75 | # Move internal trackers ahead. 76 | self.i += 1 77 | 78 | # Only handle lines within the data. 79 | if not self.EOD: 80 | # Check for the End-Of-Data marker. 81 | if eod_pattern.match(line): 82 | self.EOD = i 83 | 84 | # Remove an initial period on non-EOD lines as per RFC 821 4.5.2. 85 | elif line[0:1] == b'.': # line[0] is an integer 86 | line = line[1:] 87 | self.lines[i] = line 88 | 89 | def add_lines(self, piece): 90 | last = 0 91 | for match in fullline_pattern.finditer(piece): 92 | last = match.end(0) 93 | self._append_line(match.group(0)) 94 | self.handle_finished_line() 95 | after_match = piece[last:] 96 | self._append_line(after_match) 97 | 98 | def recv_piece(self): 99 | if self.EOD is not None: 100 | return False 101 | 102 | piece = self.io.raw_recv() 103 | if piece == b'': 104 | raise ConnectionLost() 105 | 106 | self.size += len(piece) 107 | if self.max_size and self.size > self.max_size: 108 | self.EOD = self.i 109 | raise MessageTooBig() 110 | 111 | self.add_lines(piece) 112 | return not self.EOD 113 | 114 | def return_all(self): 115 | data_lines = self.lines[:self.EOD] 116 | after_data_lines = self.lines[self.EOD+1:] 117 | 118 | # Save the extra lines back on the recv_buffer. 119 | self.io.recv_buffer = b''.join(after_data_lines) 120 | 121 | # Return the data as one big string 122 | return b''.join(data_lines) 123 | 124 | def recv(self): 125 | """Receives all message data from the session. 126 | 127 | :rtype: bytes 128 | 129 | """ 130 | self.from_recv_buffer() 131 | while self.recv_piece(): 132 | pass 133 | return self.return_all() 134 | 135 | 136 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 137 | -------------------------------------------------------------------------------- /test/test_slimta_relay_pipe.py: -------------------------------------------------------------------------------- 1 | import unittest2 as unittest 2 | from mox3.mox import MoxTestBase, IsA 3 | from gevent import Timeout 4 | from gevent import subprocess 5 | 6 | from slimta.relay.pipe import PipeRelay, MaildropRelay, DovecotLdaRelay 7 | from slimta.relay import TransientRelayError, PermanentRelayError 8 | from slimta.envelope import Envelope 9 | 10 | 11 | class TestPipeRelay(unittest.TestCase, MoxTestBase): 12 | 13 | def test_exec_process(self): 14 | pmock = self.mox.CreateMock(subprocess.Popen) 15 | self.mox.StubOutWithMock(subprocess, 'Popen') 16 | env = Envelope('sender@example.com', ['rcpt@example.com']) 17 | env.parse(b'From: sender@example.com\r\n\r\ntest test\r\n') 18 | subprocess.Popen(['relaytest', '-f', 'sender@example.com'], 19 | stdin=subprocess.PIPE, 20 | stdout=subprocess.PIPE, 21 | stderr=subprocess.PIPE).AndReturn(pmock) 22 | pmock.communicate(b'From: sender@example.com\r\n\r\ntest test\r\n').AndReturn(('testout', 'testerr')) 23 | pmock.pid = -1 24 | pmock.returncode = 0 25 | self.mox.ReplayAll() 26 | m = PipeRelay(['relaytest', '-f', '{sender}']) 27 | status, stdout, stderr = m._exec_process(env) 28 | self.assertEqual(0, status) 29 | self.assertEqual('testout', stdout) 30 | self.assertEqual('testerr', stderr) 31 | 32 | def test_exec_process_error(self): 33 | pmock = self.mox.CreateMock(subprocess.Popen) 34 | self.mox.StubOutWithMock(subprocess, 'Popen') 35 | env = Envelope('sender@example.com', ['rcpt@example.com']) 36 | env.parse(b'From: sender@example.com\r\n\r\ntest test\r\n') 37 | subprocess.Popen(['relaytest', '-f', 'sender@example.com'], 38 | stdin=subprocess.PIPE, 39 | stdout=subprocess.PIPE, 40 | stderr=subprocess.PIPE).AndReturn(pmock) 41 | pmock.communicate(b'From: sender@example.com\r\n\r\ntest test\r\n').AndReturn(('', '')) 42 | pmock.pid = -1 43 | pmock.returncode = 1337 44 | self.mox.ReplayAll() 45 | m = PipeRelay(['relaytest', '-f', '{sender}']) 46 | status, stdout, stderr = m._exec_process(env) 47 | self.assertEqual(1337, status) 48 | self.assertEqual('', stdout) 49 | self.assertEqual('', stderr) 50 | 51 | def test_attempt(self): 52 | env = Envelope() 53 | m = PipeRelay(['relaytest']) 54 | self.mox.StubOutWithMock(m, '_exec_process') 55 | m._exec_process(env).AndReturn((0, '', '')) 56 | self.mox.ReplayAll() 57 | m.attempt(env, 0) 58 | 59 | def test_attempt_transientfail(self): 60 | env = Envelope() 61 | m = PipeRelay(['relaytest']) 62 | self.mox.StubOutWithMock(m, '_exec_process') 63 | m._exec_process(env).AndReturn((1337, 'transient failure', '')) 64 | self.mox.ReplayAll() 65 | with self.assertRaises(TransientRelayError): 66 | m.attempt(env, 0) 67 | 68 | def test_attempt_timeout(self): 69 | env = Envelope() 70 | m = PipeRelay(['relaytest']) 71 | self.mox.StubOutWithMock(m, '_exec_process') 72 | m._exec_process(env).AndRaise(Timeout) 73 | self.mox.ReplayAll() 74 | with self.assertRaises(TransientRelayError): 75 | m.attempt(env, 0) 76 | 77 | def test_attempt_permanentfail(self): 78 | env = Envelope() 79 | m = PipeRelay(['relaytest']) 80 | self.mox.StubOutWithMock(m, '_exec_process') 81 | m._exec_process(env).AndReturn((13, '5.0.0 permanent failure', '')) 82 | self.mox.ReplayAll() 83 | with self.assertRaises(PermanentRelayError): 84 | m.attempt(env, 0) 85 | 86 | 87 | class TestMaildropRelay(unittest.TestCase, MoxTestBase): 88 | 89 | def test_extra_args(self): 90 | m = MaildropRelay(extra_args=['-t', 'test']) 91 | self.assertEquals(['-t', 'test'], m.args[-2:]) 92 | 93 | def test_raise_error(self): 94 | m = MaildropRelay() 95 | with self.assertRaises(TransientRelayError): 96 | m.raise_error(m.EX_TEMPFAIL, 'message', '') 97 | with self.assertRaises(PermanentRelayError): 98 | m.raise_error(13, 'message', '') 99 | 100 | 101 | class TestDovecotLdaRelay(unittest.TestCase, MoxTestBase): 102 | 103 | def test_extra_args(self): 104 | m = DovecotLdaRelay(extra_args=['-t', 'test']) 105 | self.assertEquals(['-t', 'test'], m.args[-2:]) 106 | 107 | def test_raise_error(self): 108 | m = DovecotLdaRelay() 109 | with self.assertRaises(TransientRelayError): 110 | m.raise_error(m.EX_TEMPFAIL, 'message', '') 111 | with self.assertRaises(PermanentRelayError): 112 | m.raise_error(13, 'message', '') 113 | 114 | 115 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 116 | -------------------------------------------------------------------------------- /test/test_slimta_util_dnsbl.py: -------------------------------------------------------------------------------- 1 | import unittest2 as unittest 2 | 3 | from mox3.mox import MoxTestBase 4 | from pycares.errno import ARES_ENOTFOUND 5 | 6 | from slimta.util.dns import DNSResolver, DNSError 7 | from slimta.util.dnsbl import DnsBlocklist, DnsBlocklistGroup, check_dnsbl 8 | from slimta.smtp.reply import Reply 9 | 10 | 11 | class FakeRdata(object): 12 | 13 | def __init__(self, text): 14 | self.text = text 15 | 16 | 17 | class FakeAsyncResult(object): 18 | 19 | def __init__(self, answers=None): 20 | self.answers = answers and [FakeRdata(answer) for answer in answers] 21 | 22 | def get(self): 23 | return self.answers 24 | 25 | 26 | class TestDnsbl(unittest.TestCase, MoxTestBase): 27 | 28 | def setUp(self): 29 | super(TestDnsbl, self).setUp() 30 | self.mox.StubOutWithMock(DNSResolver, 'query') 31 | self.dnsbl = DnsBlocklist('test.example.com') 32 | 33 | def test_dnsblocklist_build_query(self): 34 | self.assertEqual('4.3.2.1.test.example.com', self.dnsbl._build_query('1.2.3.4')) 35 | 36 | def test_dnsblocklist_get(self): 37 | DNSResolver.query('4.3.2.1.test.example.com', 'A').AndReturn(FakeAsyncResult()) 38 | DNSResolver.query('8.7.6.5.test.example.com', 'A').AndRaise(DNSError(ARES_ENOTFOUND)) 39 | self.mox.ReplayAll() 40 | self.assertTrue(self.dnsbl.get('1.2.3.4')) 41 | self.assertNotIn('5.6.7.8', self.dnsbl) 42 | 43 | def test_dnsblocklist_get_reason(self): 44 | DNSResolver.query('4.3.2.1.test.example.com', 'TXT').AndReturn(FakeAsyncResult()) 45 | DNSResolver.query('4.3.2.1.test.example.com', 'TXT').AndReturn(FakeAsyncResult(['good reason'])) 46 | DNSResolver.query('8.7.6.5.test.example.com', 'TXT').AndRaise(DNSError(ARES_ENOTFOUND)) 47 | self.mox.ReplayAll() 48 | self.assertEqual(None, self.dnsbl.get_reason('1.2.3.4')) 49 | self.assertEqual('good reason', self.dnsbl.get_reason('1.2.3.4')) 50 | self.assertEqual(None, self.dnsbl['5.6.7.8']) 51 | 52 | def test_dnsblocklistgroup_get(self): 53 | group = DnsBlocklistGroup() 54 | group.add_dnsbl('test1.example.com') 55 | group.add_dnsbl('test2.example.com') 56 | group.add_dnsbl('test3.example.com') 57 | DNSResolver.query('4.3.2.1.test1.example.com', 'A').InAnyOrder('one').AndReturn(FakeAsyncResult()) 58 | DNSResolver.query('4.3.2.1.test2.example.com', 'A').InAnyOrder('one').AndRaise(DNSError(ARES_ENOTFOUND)) 59 | DNSResolver.query('4.3.2.1.test3.example.com', 'A').InAnyOrder('one').AndReturn(FakeAsyncResult()) 60 | DNSResolver.query('8.7.6.5.test1.example.com', 'A').InAnyOrder('two').AndRaise(DNSError(ARES_ENOTFOUND)) 61 | DNSResolver.query('8.7.6.5.test2.example.com', 'A').InAnyOrder('two').AndRaise(DNSError(ARES_ENOTFOUND)) 62 | DNSResolver.query('8.7.6.5.test3.example.com', 'A').InAnyOrder('two').AndRaise(DNSError(ARES_ENOTFOUND)) 63 | self.mox.ReplayAll() 64 | self.assertEqual(set(['test1.example.com', 'test3.example.com']), group.get('1.2.3.4')) 65 | self.assertNotIn('5.6.7.8', group) 66 | 67 | def test_dnsblocklistgroup_get_reasons(self): 68 | group = DnsBlocklistGroup() 69 | group.add_dnsbl('test1.example.com') 70 | group.add_dnsbl('test2.example.com') 71 | group.add_dnsbl('test3.example.com') 72 | DNSResolver.query('4.3.2.1.test1.example.com', 'TXT').InAnyOrder().AndReturn(FakeAsyncResult(['reason one'])) 73 | DNSResolver.query('4.3.2.1.test3.example.com', 'TXT').InAnyOrder().AndReturn(FakeAsyncResult()) 74 | self.mox.ReplayAll() 75 | self.assertEqual({'test1.example.com': 'reason one', 'test3.example.com': None}, 76 | group.get_reasons(set(['test1.example.com', 'test3.example.com']), '1.2.3.4')) 77 | 78 | def test_check_dnsrbl(self): 79 | class TestSession(object): 80 | address = ('1.2.3.4', 56789) 81 | class TestValidators(object): 82 | def __init__(self): 83 | self.session = TestSession() 84 | @check_dnsbl('test.example.com') 85 | def validate_mail(self, reply, sender): 86 | assert False 87 | 88 | DNSResolver.query('4.3.2.1.test.example.com', 'A').AndRaise(DNSError(ARES_ENOTFOUND)) 89 | DNSResolver.query('4.3.2.1.test.example.com', 'A').AndReturn(FakeAsyncResult()) 90 | self.mox.ReplayAll() 91 | validators = TestValidators() 92 | reply = Reply('250', '2.0.0 Ok') 93 | self.assertRaises(AssertionError, validators.validate_mail, reply, 'asdf') 94 | self.assertEqual('250', reply.code) 95 | self.assertEqual('2.0.0 Ok', reply.message) 96 | validators.validate_mail(reply, 'asdf') 97 | self.assertEqual('550', reply.code) 98 | self.assertEqual('5.7.1 Access denied', reply.message) 99 | 100 | 101 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 102 | -------------------------------------------------------------------------------- /test/test_slimta_policy_split.py: -------------------------------------------------------------------------------- 1 | import unittest2 as unittest 2 | 3 | from slimta.policy.split import RecipientSplit, RecipientDomainSplit 4 | from slimta.envelope import Envelope 5 | 6 | 7 | class TestPoliySplit(unittest.TestCase): 8 | 9 | def test_recipientsplit_apply(self): 10 | env = Envelope('sender@example.com', ['rcpt1@example.com', 11 | 'rcpt2@example.com']) 12 | env.parse(b"""\ 13 | From: sender@example.com 14 | To: rcpt1@example.com 15 | To: rcpt2@example.com 16 | 17 | test test\r 18 | """) 19 | policy = RecipientSplit() 20 | env1, env2 = policy.apply(env) 21 | 22 | self.assertEqual('sender@example.com', env1.sender) 23 | self.assertEqual(['rcpt1@example.com'], env1.recipients) 24 | self.assertEqual('sender@example.com', env1.headers['from']) 25 | self.assertEqual(['rcpt1@example.com', 'rcpt2@example.com'], 26 | env1.headers.get_all('To')) 27 | self.assertEqual(b'test test\r\n', env1.message) 28 | 29 | self.assertEqual('sender@example.com', env2.sender) 30 | self.assertEqual(['rcpt2@example.com'], env2.recipients) 31 | self.assertEqual('sender@example.com', env2.headers['from']) 32 | self.assertEqual(['rcpt1@example.com', 'rcpt2@example.com'], 33 | env2.headers.get_all('To')) 34 | self.assertEqual(b'test test\r\n', env2.message) 35 | 36 | def test_recipientsplit_apply_onercpt(self): 37 | env = Envelope('sender@example.com', ['rcpt@example.com']) 38 | policy = RecipientSplit() 39 | self.assertFalse(policy.apply(env)) 40 | 41 | def test_recipientdomainsplit_get_domain(self): 42 | policy = RecipientDomainSplit() 43 | self.assertEqual('example.com', policy._get_domain('rcpt@example.com')) 44 | self.assertEqual('example.com', policy._get_domain('rcpt@Example.com')) 45 | self.assertRaises(ValueError, policy._get_domain, 'rcpt@') 46 | self.assertRaises(ValueError, policy._get_domain, 'rcpt') 47 | 48 | def test_recipientdomainsplit_get_domain_groups(self): 49 | policy = RecipientDomainSplit() 50 | groups, bad_rcpts = policy._get_domain_groups(['rcpt@example.com']) 51 | self.assertEqual({'example.com': ['rcpt@example.com']}, groups) 52 | self.assertEqual([], bad_rcpts) 53 | groups, bad_rcpts = policy._get_domain_groups(['rcpt1@example.com', 'rcpt2@Example.com']) 54 | self.assertEqual({'example.com': ['rcpt1@example.com', 'rcpt2@Example.com']}, groups) 55 | self.assertEqual([], bad_rcpts) 56 | groups, bad_rcpts = policy._get_domain_groups(['rcpt1@example.com', 'rcpt2@Example.com', 'rcpt@test.com']) 57 | self.assertEqual({'example.com': ['rcpt1@example.com', 'rcpt2@Example.com'], 58 | 'test.com': ['rcpt@test.com']}, groups) 59 | self.assertEqual([], bad_rcpts) 60 | groups, bad_rcpts = policy._get_domain_groups(['rcpt@example.com', 'rcpt']) 61 | self.assertEqual({'example.com': ['rcpt@example.com']}, groups) 62 | self.assertEqual(['rcpt'], bad_rcpts) 63 | 64 | def test_recipientdomainsplit_apply(self): 65 | env = Envelope('sender@example.com', ['rcpt1@example.com', 66 | 'rcpt2@example.com', 67 | 'rcpt@test.com']) 68 | env.parse(b"""\r\ntest test\r\n""") 69 | policy = RecipientDomainSplit() 70 | env1, env2 = policy.apply(env) 71 | 72 | self.assertEqual('sender@example.com', env1.sender) 73 | self.assertEqual(['rcpt1@example.com', 'rcpt2@example.com'], env1.recipients) 74 | self.assertEqual(b'test test\r\n', env1.message) 75 | 76 | self.assertEqual('sender@example.com', env2.sender) 77 | self.assertEqual(['rcpt@test.com'], env2.recipients) 78 | self.assertEqual(b'test test\r\n', env2.message) 79 | 80 | def test_recipientdomainsplit_apply_allbadrcpts(self): 81 | env = Envelope('sender@example.com', ['rcpt1', 'rcpt2@']) 82 | env.parse(b"""\r\ntest test\r\n""") 83 | policy = RecipientDomainSplit() 84 | env1, env2 = policy.apply(env) 85 | 86 | self.assertEqual('sender@example.com', env1.sender) 87 | self.assertEqual(['rcpt1'], env1.recipients) 88 | self.assertEqual(b'test test\r\n', env1.message) 89 | 90 | self.assertEqual('sender@example.com', env2.sender) 91 | self.assertEqual(['rcpt2@'], env2.recipients) 92 | self.assertEqual(b'test test\r\n', env2.message) 93 | 94 | def test_recipientdomainsplit_apply_onedomain(self): 95 | env = Envelope('sender@example.com', ['rcpt1@example.com', 96 | 'rcpt2@example.com']) 97 | env.parse(b'') 98 | policy = RecipientDomainSplit() 99 | self.assertFalse(policy.apply(env)) 100 | 101 | 102 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 103 | -------------------------------------------------------------------------------- /slimta/policy/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2012 Ian C. Good 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | # 21 | 22 | """Package containing useful policies, which can be configured to run before 23 | queuing or before relaying the message with the 24 | :meth:`slimta.queue.Queue.add_policy()` and 25 | :meth:`slimta.relay.Relay.add_policy()`, respectively. 26 | 27 | If a policy is applied before queuing, it is executed only once and any changes 28 | it makes to the |Envelope| will be stored persistently. This is especially 29 | useful for tasks such as header and content modification, since these may be 30 | more expensive operations and should only run once. 31 | 32 | If a policy is applied before relaying, it is executed before each delivery 33 | attempt and no resulting changes will be persisted to storage. This is useful 34 | for policies that have to do with delivery, such as forwarding. 35 | 36 | """ 37 | 38 | from __future__ import absolute_import 39 | 40 | from slimta.core import SlimtaError 41 | 42 | __all__ = ['PolicyError', 'QueuePolicy', 'RelayPolicy'] 43 | 44 | 45 | class PolicyError(SlimtaError): 46 | """Base exception for all custom ``slimta.policy`` errors.""" 47 | pass 48 | 49 | 50 | class QueuePolicy(object): 51 | """Base class for queue policies. These are run before a message is 52 | persistently queued and may overwrite the original |Envelope| with one or 53 | many new |Envelope| objects. 54 | 55 | :: 56 | 57 | class MyQueuePolicy(QueuePolicy): 58 | def apply(self, env): 59 | env['X-When-Queued'] = str(time.time()) 60 | 61 | my_queue.add_policy(MyQueuePolicy()) 62 | 63 | """ 64 | 65 | def apply(self, envelope): 66 | """:class:`QueuePolicy` sub-classes must override this method, which 67 | will be called by the |Queue| before storage. 68 | 69 | :param envelope: The |Envelope| object the policy execution should 70 | apply any changes to. This envelope object *may* be 71 | modified, though if new envelopes are returned this 72 | object is discarded. 73 | :returns: Optionally return or generate an iterable of |Envelope| 74 | objects to replace the given ``envelope`` going forward. 75 | Returning ``None`` or an empty list will keep using 76 | ``envelope``. 77 | 78 | """ 79 | raise NotImplementedError() 80 | 81 | 82 | class RelayPolicy(object): 83 | """Base class for relay policies. These are run immediately before a relay 84 | attempt is made. 85 | 86 | :: 87 | 88 | class MyRelayPolicy(RelayPolicy): 89 | def apply(self, env): 90 | env['X-When-Delivered'] = str(time.time()) 91 | 92 | my_relay.add_policy(MyRelayPolicy()) 93 | 94 | """ 95 | 96 | def apply(self, envelope): 97 | """:class:`RelayPolicy` sub-classes must override this method, which 98 | will be called by the |Relay| before delivery. Unlike 99 | :meth:`QueuePolicy.apply`, the return value of this method is 100 | discarded. 101 | 102 | Much like :meth:`~slimta.relay.Relay.attempt`, these methods may raise 103 | :class:`~slimta.relay.PermanentRelayError` or 104 | :class:`~slimta.relay.TransientRelayError` to mark the relay attempt as 105 | failed for the entire message. 106 | 107 | Modifications to the ``envelope`` will be passed on to the 108 | :class:`~slimta.relay.Relay`. However, it is unlikely these 109 | modifications will be persisted by the 110 | :class:`~slimta.queue.QueueStorage` implementation. 111 | 112 | :param envelope: The |Envelope| object the policy execution should 113 | apply any changes to. 114 | :raises: :class:`~slimta.relay.PermanentRelayError`, 115 | :class:`~slimta.relay.TransientRelayError` 116 | 117 | """ 118 | raise NotImplementedError() 119 | 120 | 121 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 122 | -------------------------------------------------------------------------------- /slimta/relay/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2012 Ian C. Good 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | # 21 | 22 | """Root package for relaying messages to their next destination. A relay could 23 | be another SMTP hop, or it could be implemented as a final delivery mechanism. 24 | 25 | """ 26 | 27 | from __future__ import absolute_import 28 | 29 | from slimta.core import SlimtaError 30 | from slimta.smtp.reply import Reply 31 | from slimta.policy import RelayPolicy 32 | 33 | __all__ = ['PermanentRelayError', 'TransientRelayError', 'Relay'] 34 | 35 | 36 | class RelayError(SlimtaError): 37 | def __init__(self, msg, reply=None): 38 | super(RelayError, self).__init__(msg) 39 | if reply: 40 | self.reply = reply 41 | else: 42 | reply_msg = ' '.join((self._default_esc, msg)) 43 | self.reply = Reply(self._default_code, reply_msg) 44 | 45 | 46 | class PermanentRelayError(RelayError): 47 | """Base exception for all relay errors that indicate a message will not 48 | be successfully delivered no matter how many times delivery is attempted. 49 | 50 | """ 51 | 52 | _default_code = '550' 53 | _default_esc = '5.0.0' 54 | 55 | 56 | class TransientRelayError(RelayError): 57 | """Base exception for all relay errors that indicate the message may be 58 | successful if tried again later. 59 | 60 | """ 61 | 62 | _default_code = '450' 63 | _default_esc = '4.0.0' 64 | 65 | 66 | class Relay(object): 67 | """Base class for objects that implement the relaying pattern. Included 68 | implementations are :class:`~slimta.relay.smtp.mx.MxSmtpRelay` and 69 | :class:`~slimta.relay.smtp.static.StaticSmtpRelay`. 70 | 71 | """ 72 | 73 | def __init__(self): 74 | self.relay_policies = [] 75 | 76 | def add_policy(self, policy): 77 | """Adds a |RelayPolicy| to be executed each time the relay attempts 78 | delivery for a message. 79 | 80 | :param policy: |RelayPolicy| object to execute. 81 | 82 | """ 83 | if isinstance(policy, RelayPolicy): 84 | self.relay_policies.append(policy) 85 | else: 86 | raise TypeError('Argument not a RelayPolicy.') 87 | 88 | def _run_policies(self, envelope): 89 | for policy in self.relay_policies: 90 | policy.apply(envelope) 91 | 92 | def _attempt(self, envelope, attempts): 93 | self._run_policies(envelope) 94 | return self.attempt(envelope, attempts) 95 | 96 | def attempt(self, envelope, attempts): 97 | """This method must be overriden by sub-classes in order to be passed 98 | in to the |Queue| constructor. 99 | 100 | The result of a successful relay attempt is either ``None`` or a 101 | |Reply| object. The result of a failing relay attempt is either a 102 | :class:`~slimta.relay.PermanentRelayFailure` or 103 | :class:`~slimta.relay.TransientRelayFailure` error. 104 | 105 | If the result applies to the entire ``envelope`` and all its 106 | recipients, implementations may return the successful result or raise 107 | the failure. If the result is different per-recipient, then 108 | implementations may return a dictionary where the key is a recipient 109 | from :attr:`~slimta.envelope.Envelope.recipients` and the value is the 110 | relay result (successful or failing). 111 | 112 | :param envelope: |Envelope| to attempt delivery for. 113 | :param attempts: Number of times the envelope has attempted delivery. 114 | :returns: The relay result, as described above. 115 | :raises: :class:`PermanentRelayError`, :class:`TransientRelayError` 116 | 117 | """ 118 | raise NotImplementedError() 119 | 120 | def kill(self): 121 | """This method is used by |Relay| and |Relay|-like objects to properly 122 | end associated services (such as running :class:`~gevent.Greenlet` 123 | threads) and close resources. 124 | 125 | """ 126 | pass 127 | 128 | 129 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 130 | -------------------------------------------------------------------------------- /slimta/util/dns.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2015 Ian C. Good 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | # 21 | 22 | """This module implements DNS resolution with :mod:`pycares`. 23 | 24 | """ 25 | 26 | from __future__ import absolute_import 27 | 28 | from functools import partial 29 | 30 | import pycares 31 | import pycares.errno 32 | import gevent 33 | from gevent import select 34 | from gevent.event import AsyncResult 35 | 36 | from slimta import logging 37 | 38 | __all__ = ['DNSError', 'DNSResolver'] 39 | 40 | 41 | class DNSError(Exception): 42 | """Exception raised with DNS resolution failed. The exception message will 43 | contain more information. 44 | 45 | .. attribute:: errno 46 | 47 | The error number, as per :mod:`pycares.errno`. 48 | 49 | """ 50 | 51 | def __init__(self, errno): 52 | msg = '{0} [{1}]'.format(pycares.errno.strerror(errno), 53 | pycares.errno.errorcode[errno]) 54 | super(DNSError, self).__init__(msg) 55 | self.errno = errno 56 | 57 | 58 | class DNSResolver(object): 59 | """Manages all the active DNS queries using a single, static 60 | :class:`pycares.Channel` object. 61 | 62 | .. attribute:: channel 63 | 64 | Before making any queries, this attribute may be set to override the 65 | default with a :class:`pycares.Channel` object that will manage all DNS 66 | queries. 67 | 68 | """ 69 | 70 | channel = None 71 | _channel = None 72 | _thread = None 73 | 74 | @classmethod 75 | def query(cls, name, query_type): 76 | """Begin a DNS lookup. The result (or exception) will be in the 77 | returned :class:`~gevent.event.AsyncResult` when it is available. 78 | 79 | :param name: The DNS name to resolve. 80 | :type name: str 81 | :param query_type: The DNS query type, see 82 | :meth:`pycares.Channel.query` for options. A string 83 | may be given instead, e.g. ``'MX'``. 84 | :rtype: :class:`~gevent.event.AsyncResult` 85 | 86 | """ 87 | result = AsyncResult() 88 | query_type = cls._get_query_type(query_type) 89 | cls._channel = cls._channel or cls.channel or pycares.Channel() 90 | cls._channel.query(name, query_type, partial(cls._result_cb, result)) 91 | cls._thread = cls._thread or gevent.spawn(cls._wait_channel) 92 | return result 93 | 94 | @classmethod 95 | def _get_query_type(cls, query_type): 96 | if isinstance(query_type, str): 97 | type_attr = 'QUERY_TYPE_{0}'.format(query_type.upper()) 98 | return getattr(pycares, type_attr) 99 | return query_type 100 | 101 | @classmethod 102 | def _result_cb(cls, result, answer, errno): 103 | if errno: 104 | exc = DNSError(errno) 105 | result.set_exception(exc) 106 | else: 107 | result.set(answer) 108 | 109 | @classmethod 110 | def _wait_channel(cls): 111 | try: 112 | while True: 113 | read_fds, write_fds = cls._channel.getsock() 114 | if not read_fds and not write_fds: 115 | break 116 | timeout = cls._channel.timeout() 117 | if not timeout: 118 | cls._channel.process_fd(pycares.ARES_SOCKET_BAD, 119 | pycares.ARES_SOCKET_BAD) 120 | continue 121 | rlist, wlist, xlist = select.select( 122 | read_fds, write_fds, [], timeout) 123 | for fd in rlist: 124 | cls._channel.process_fd(fd, pycares.ARES_SOCKET_BAD) 125 | for fd in wlist: 126 | cls._channel.process_fd(pycares.ARES_SOCKET_BAD, fd) 127 | except Exception: 128 | logging.log_exception(__name__) 129 | cls._channel.cancel() 130 | cls._channel = None 131 | raise 132 | finally: 133 | cls._thread = None 134 | 135 | 136 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 137 | -------------------------------------------------------------------------------- /test/test_slimta_envelope.py: -------------------------------------------------------------------------------- 1 | import unittest2 as unittest 2 | 3 | import sys 4 | from email.message import Message 5 | from email.encoders import encode_base64 6 | from email.header import Header 7 | 8 | from slimta.envelope import Envelope 9 | 10 | 11 | class TestEnvelope(unittest.TestCase): 12 | 13 | def test_copy(self): 14 | env1 = Envelope('sender@example.com', ['rcpt1@example.com']) 15 | env1.parse(b"""\ 16 | From: sender@example.com 17 | To: rcpt1@example.com 18 | 19 | test test 20 | """.replace(b'\n', b'\r\n')) 21 | env2 = env1.copy(env1.recipients + ['rcpt2@example.com']) 22 | env2.headers.replace_header('To', 'rcpt1@example.com, rcpt2@example.com') 23 | self.assertEqual('sender@example.com', env1.sender) 24 | self.assertEqual(['rcpt1@example.com'], env1.recipients) 25 | self.assertEqual(['rcpt1@example.com'], env1.headers.get_all('To')) 26 | self.assertEqual('sender@example.com', env2.sender) 27 | self.assertEqual(['rcpt1@example.com', 'rcpt2@example.com'], env2.recipients) 28 | self.assertEqual(['rcpt1@example.com, rcpt2@example.com'], env2.headers.get_all('To')) 29 | 30 | def test_repr(self): 31 | env = Envelope('sender@example.com') 32 | s = repr(env) 33 | self.assertRegexpMatches(s, r"") 34 | 35 | def test_flatten(self): 36 | header_str = b"""\ 37 | From: sender@example.com 38 | To: rcpt1@example.com 39 | To: rcpt2@example.com 40 | 41 | """.replace(b'\n', b'\r\n') 42 | body_str = b'test test\r\n' 43 | env = Envelope() 44 | env.parse(header_str + body_str) 45 | ret_headers, ret_body = env.flatten() 46 | self.assertEqual(header_str, ret_headers) 47 | self.assertEqual(body_str, ret_body) 48 | 49 | def test_encode_7bit(self): 50 | headers = Message() 51 | headers['From'] = 'sender@example.com' 52 | headers['To'] = 'rcpt@example.com' 53 | body = bytes(bytearray(range(129, 256))) 54 | env = Envelope(headers=headers, message=body) 55 | with self.assertRaises(UnicodeError): 56 | env.encode_7bit() 57 | 58 | def test_encode_7bit_encoding(self): 59 | headers = Message() 60 | headers['From'] = 'sender@example.com' 61 | headers['To'] = 'rcpt@example.com' 62 | body = bytes(bytearray(range(129, 256))) 63 | env = Envelope(headers=headers, message=body) 64 | header_str = b"""\ 65 | From: sender@example.com 66 | To: rcpt@example.com 67 | Content-Transfer-Encoding: base64 68 | 69 | """.replace(b'\n', b'\r\n') 70 | body_str = b'gYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq+wsbKztLW2t7i5\r\nuru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5+jp6uvs7e7v8PHy\r\n8/T19vf4+fr7/P3+/w==' 71 | env.encode_7bit(encoder=encode_base64) 72 | ret_headers, ret_body = env.flatten() 73 | self.assertEqual(header_str, ret_headers) 74 | self.assertEqual(body_str, ret_body.rstrip()) 75 | 76 | def test_parse(self): 77 | env = Envelope() 78 | env.parse(b"""\ 79 | From: sender@example.com 80 | To: rcpt1@example.com 81 | To: rcpt2@example.com 82 | 83 | test test 84 | """.replace(b'\n', b'\r\n')) 85 | self.assertEqual('sender@example.com', env.headers['from']) 86 | self.assertEqual(['rcpt1@example.com', 'rcpt2@example.com'], 87 | env.headers.get_all('To')) 88 | self.assertEqual(b'test test\r\n', env.message) 89 | 90 | def test_parse_onlyheaders(self): 91 | env = Envelope() 92 | env.parse(b"""\ 93 | From: sender@example.com 94 | Subject: important things 95 | """.replace(b'\n', b'\r\n')) 96 | self.assertEqual('sender@example.com', env.headers['from']) 97 | self.assertEqual('important things', env.headers['subject']) 98 | self.assertEqual(b'', env.message) 99 | 100 | @unittest.skipIf(sys.version_info[0:2] == (3, 3), 'Broken on Python 3.3') 101 | def test_parse_nonascii_headers(self): 102 | env = Envelope() 103 | env.parse(b'Subject: \xc3\xa9\xc3\xa9\n') 104 | try: 105 | self.assertEqual(b'\xc3\xa9\xc3\xa9', env.headers['subject'].encode()) 106 | except UnicodeDecodeError: 107 | self.assertEqual(b'\xc3\xa9\xc3\xa9', env.headers['subject']) 108 | 109 | def test_parse_onlybody(self): 110 | env = Envelope() 111 | env.parse(b"""\ 112 | important things 113 | """.replace(b'\n', b'\r\n')) 114 | self.assertEqual(b'important things\r\n', env.message) 115 | 116 | def test_parse_message_object(self): 117 | msg = Message() 118 | msg['From'] = 'sender@example.com' 119 | msg['To'] = 'rcpt1@example.com' 120 | msg['To'] = 'rcpt2@example.com' 121 | msg.set_payload(b'test test\r\n') 122 | env = Envelope() 123 | env.parse_msg(msg) 124 | self.assertEqual('sender@example.com', env.headers['from']) 125 | self.assertEqual(['rcpt1@example.com', 'rcpt2@example.com'], env.headers.get_all('to')) 126 | self.assertEqual(b'test test\r\n', env.message) 127 | 128 | 129 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 130 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | # Change Log 3 | 4 | ## [4.0] - 2016-11-13 5 | 6 | ### Added 7 | 8 | - New `slimta.util` functions for limiting outbound connections to IPv4. 9 | - New [`socket_error_log_level`][6] variable for better log level control. 10 | 11 | ### Changed 12 | 13 | - Constructors and functions that took a `tls` dictionary now take a `context` 14 | argument that should be an [`SSLContext`][7] object. This allows finer 15 | control of encryption behavior, as well as the ability to pre-load sensitive 16 | certificate data before daemonization. 17 | - Client connections will now be opportunistic and try to use TLS if it is 18 | available, even if a key or cert have not been configured. 19 | - The `AUTH` SMTP extension will now advertise insecure authentication 20 | mechanisms without TLS, but trying to use them will fail. 21 | - Moved the `slimta.system` module to `slimta.util.system` to de-clutter the 22 | top-level namespace. 23 | 24 | ### Fixed 25 | 26 | - Fixed a possible race condition on enqueue. 27 | - Fixed exception when given empty EHLO/HELO string. 28 | - Fixed the fallback from EHLO to HELO in certain situations. 29 | - The [`session.auth`][8] variable now correctly contains the tuple described 30 | in the documentation. 31 | 32 | ## [3.2] - 2016-05-16 33 | 34 | ### Added 35 | 36 | - The [`parseline`][5] function is now exposed and documented. 37 | - The `slimta.logging.log_exception` function may now be replaced with custom 38 | functions, for special error handling scenarios. 39 | 40 | ### Changed 41 | 42 | - Unit tests are now run with `py.test` instead of `nosetests`. 43 | - Exception log lines will now include up to 10,000 characters of the traceback 44 | string. 45 | - Socket errors are no longer logged as unhandled errors and do not include a 46 | traceback. 47 | - `socket.gaierror` failures are now caught and ignored during PTR lookup. 48 | 49 | ### Fixed 50 | 51 | - Correctly set an empty greenlet pool in `EdgeServer` constructor. 52 | - Corrected a potential duplicate relay scenario in `Queue`. 53 | - `Reply` encoding and decoding now works correctly in Python 2.x. 54 | - Fixed `httplib` imports in Python 3.3. 55 | 56 | ## [3.1] - 2016-02-04 57 | 58 | ### Added 59 | 60 | - `QueueError` objects may now set the `reply` attribute to tell edge services 61 | what happened. 62 | - SMTP servers now advertize `SMTPUTF8` and clients will now use UTF-8 sender 63 | and recipient addresses when connected to servers that advertize it. 64 | - When creating an edge or relay service, now checks for the existence of any 65 | given TLS key or cert files before proceeding. 66 | - Support for [proxy protocol][1] version 2 and version auto-detection. 67 | 68 | ### Removed 69 | 70 | - Dependence on [six][4] for Python 2/3 compatibility. 71 | 72 | ### Changed 73 | 74 | - The builtin edges now use `451` codes when a `QueueError` occurs, rather than 75 | `550`. 76 | - The `Bounce` class header and footer templates may now be bytestrings. 77 | - `Envelope.flatten` now returns bytestrings on Python 3, to avoid unnecessary 78 | encoding and decoding of message data. 79 | 80 | ### Fixed 81 | 82 | - Correctly throws `PermanentRelayError` instead of `ZeroDivisionError` for 83 | SMTP MX relays when DNS returns no results. 84 | 85 | ## [3.0] - 2015-12-19 86 | 87 | ### Added 88 | 89 | - Compatibility with Python 3.3+. 90 | - [Proxy protocol][1] version 1 support on edge services. 91 | - Dependence on [pycares][2] for DNS resolution. 92 | - Support for the `socket_creator` option to control how sockets are created 93 | during SMTP relaying. 94 | - Support for `ehlo_as` functions to allow custom EHLO logic on each delivery 95 | attempt. 96 | - Support for a new `handle_queued` callback on SMTP edges, to control the reply 97 | code and message based on queue results. 98 | 99 | ### Removed 100 | 101 | - Compatibility with Python 2.6.x. 102 | - Dependence on [dnspython][3] for DNS resolution. 103 | 104 | ### Changed 105 | 106 | - Relay results that were returned as a list are now returned as a dict, keyed 107 | on the envelope recipients. 108 | 109 | ### Fixed 110 | 111 | - During SMTP relaying, timeouts and other errors will more consistently return 112 | the current SMTP command where the error happened. 113 | - Setting a reply code to `221` or `421` in an SMTP edge session will now result 114 | in the connection closing. 115 | 116 | [1]: http://www.haproxy.org/download/1.5/doc/proxy-protocol.txt 117 | [2]: https://github.com/saghul/pycares 118 | [3]: http://www.dnspython.org/ 119 | [4]: https://pythonhosted.org/six/ 120 | [5]: https://docs.slimta.org/en/latest/api/slimta.logging.html#slimta.logging.parseline 121 | [6]: https://docs.slimta.org/en/latest/api/slimta.logging.socket.html#slimta.logging.socket.socket_error_log_level 122 | [7]: https://docs.python.org/2.7/library/ssl.html#ssl.SSLContext 123 | [8]: https://docs.slimta.org/en/latest/api/slimta.edge.smtp.html#slimta.edge.smtp.SmtpValidators.session 124 | [3.0]: https://github.com/slimta/python-slimta/issues?q=milestone%3A3.0 125 | [3.1]: https://github.com/slimta/python-slimta/issues?q=milestone%3A3.1 126 | [3.2]: https://github.com/slimta/python-slimta/issues?q=milestone%3A3.2 127 | [4.0]: https://github.com/slimta/python-slimta/issues?q=milestone%3A4.0 128 | -------------------------------------------------------------------------------- /slimta/policy/headers.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2012 Ian C. Good 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | # 21 | 22 | """Module containing several |QueuePolicy| implementations for handling the 23 | standard RFC headers. 24 | 25 | """ 26 | 27 | from __future__ import absolute_import 28 | 29 | import uuid 30 | from time import strftime, gmtime, localtime 31 | from math import floor 32 | from socket import getfqdn 33 | 34 | from slimta.core import __version__ as VERSION 35 | from . import QueuePolicy 36 | 37 | __all__ = ['AddDateHeader', 'AddMessageIdHeader', 'AddReceivedHeader'] 38 | 39 | 40 | class AddDateHeader(QueuePolicy): 41 | """Checks for the existence of the RFC-specified ``Date`` header, adding it 42 | if it does not exist. 43 | 44 | """ 45 | 46 | def __init__(self): 47 | pass 48 | 49 | def build_date(self, timestamp): 50 | """Returns a date string in the format desired for the header. This 51 | method can be overridden to control the format. 52 | 53 | :param timestamp: Timestamp (as returned by :func:`time.time()`) to 54 | convert into date string. 55 | :returns: Date string for the header. 56 | 57 | """ 58 | return strftime('%a, %d %b %Y %H:%M:%S %Z', localtime(timestamp)) 59 | 60 | def apply(self, envelope): 61 | if 'date' not in envelope.headers: 62 | envelope.headers['Date'] = self.build_date(envelope.timestamp) 63 | 64 | 65 | class AddMessageIdHeader(QueuePolicy): 66 | """Checks for the existence of the RFC-specified ``Message-Id`` header, 67 | adding it if it does not exist. 68 | 69 | :param hostname: The hostname to use in the generated headers. By default, 70 | :func:`~gevent.socket.getfqdn()` is used. 71 | 72 | """ 73 | 74 | def __init__(self, hostname=None): 75 | self.hostname = hostname or getfqdn() 76 | 77 | def apply(self, envelope): 78 | if 'message-id' not in envelope.headers: 79 | mid = '<{0}.{1:.0f}@{2}>'.format(uuid.uuid4().hex, 80 | floor(envelope.timestamp), 81 | self.hostname) 82 | envelope.headers['Message-Id'] = mid 83 | 84 | 85 | class AddReceivedHeader(QueuePolicy): 86 | """Adds the RFC-specified ``Received`` header to the message. This header 87 | should be added for every hop from a message's origination to its 88 | destination. 89 | 90 | The format of this header is unusual, here is a good description: 91 | http://cr.yp.to/immhf/envelope.html 92 | 93 | """ 94 | 95 | def __init__(self, date_format='%a, %d %b %Y %H:%M:%S +0000'): 96 | self.date_format = date_format 97 | 98 | def _build_from_section(self, envelope, parts): 99 | template = 'from {0} ({1} [{2}])' 100 | ehlo = envelope.client.get('name', None) or 'unknown' 101 | host = envelope.client.get('host', None) or 'unknown' 102 | ip = envelope.client.get('ip', None) or 'unknown' 103 | parts.append(template.format(ehlo, host, ip)) 104 | 105 | def _build_by_section(self, envelope, parts): 106 | template = 'by {0} (slimta {1})' 107 | parts.append(template.format(envelope.receiver, VERSION)) 108 | 109 | def _build_with_section(self, envelope, parts): 110 | template = 'with {0}' 111 | protocol = envelope.client.get('protocol', None) 112 | if protocol: 113 | parts.append(template.format(protocol)) 114 | 115 | def _build_for_section(self, envelope, parts): 116 | template = 'for <{0}>' 117 | rcpts = '>,<'.join(envelope.recipients) 118 | parts.append(template.format(rcpts)) 119 | 120 | def apply(self, envelope): 121 | parts = [] 122 | self._build_from_section(envelope, parts) 123 | self._build_by_section(envelope, parts) 124 | self._build_with_section(envelope, parts) 125 | self._build_for_section(envelope, parts) 126 | 127 | t = gmtime(envelope.timestamp) 128 | date = strftime(self.date_format, t) 129 | 130 | data = ' '.join(parts) + '; ' + date 131 | 132 | envelope.prepend_header('Received', data) 133 | 134 | 135 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 136 | -------------------------------------------------------------------------------- /test/test_slimta_smtp_reply.py: -------------------------------------------------------------------------------- 1 | import unittest2 as unittest 2 | 3 | from slimta.smtp.reply import Reply 4 | from slimta.smtp.io import IO 5 | from slimta.util.pycompat import PY3 6 | 7 | 8 | class TestSmtpReply(unittest.TestCase): 9 | 10 | def test_not_populated(self): 11 | r = Reply(command=b'SOMECOMMAND') 12 | self.assertEqual(None, r.code) 13 | self.assertEqual(None, r.message) 14 | self.assertEqual(None, r.enhanced_status_code) 15 | self.assertFalse(r) 16 | self.assertEqual(b'SOMECOMMAND', r.command) 17 | 18 | def test_eq(self): 19 | r1 = Reply('250', '2.1.0 Ok') 20 | r2 = Reply('250', '2.1.0 Ok') 21 | r3 = Reply('251', '2.1.0 Ok') 22 | r4 = Reply('250', '2.1.1 Ok') 23 | self.assertEqual(r1, r2) 24 | self.assertNotEqual(r1, r3) 25 | self.assertNotEqual(r1, r4) 26 | self.assertNotEqual(r3, r4) 27 | 28 | def test_repr(self): 29 | r = Reply('250', '2.1.0 Ok') 30 | expected = ''.format(r.code, r.message) 31 | self.assertEqual(expected, repr(r)) 32 | 33 | def test_str(self): 34 | if PY3: 35 | r = Reply('250', '2.1.0 Ok \U0001f44d') 36 | self.assertEqual('250 2.1.0 Ok \U0001f44d', str(r)) 37 | else: 38 | r = Reply('250', u'2.1.0 Ok \U0001f44d') 39 | self.assertEqual('250 2.1.0 Ok \xf0\x9f\x91\x8d', str(r)) 40 | 41 | def test_bytes(self): 42 | r = Reply('250', u'2.1.0 Ok \U0001f44d') 43 | self.assertEqual(b'250 2.1.0 Ok \xf0\x9f\x91\x8d', bytes(r)) 44 | 45 | def test_contains(self): 46 | r = Reply('220', 'ESMTP') 47 | self.assertTrue(b'ESMTP' in r) 48 | self.assertTrue('ESMTP' in r) 49 | 50 | def test_is_error(self): 51 | replies = [Reply(str(i)+'50', 'Test') for i in range(1, 6)] 52 | self.assertFalse(replies[0].is_error()) 53 | self.assertFalse(replies[1].is_error()) 54 | self.assertFalse(replies[2].is_error()) 55 | self.assertTrue(replies[3].is_error()) 56 | self.assertTrue(replies[4].is_error()) 57 | 58 | def test_copy(self): 59 | r1 = Reply('250', '2.1.0 Ok') 60 | r2 = Reply(command=b'RCPT') 61 | r2.copy(r1) 62 | self.assertEqual('250', r2.code) 63 | self.assertEqual('2.1.0', r2.enhanced_status_code) 64 | self.assertEqual('2.1.0 Ok', r2.message) 65 | self.assertEqual(b'RCPT', r2.command) 66 | 67 | def test_code_set(self): 68 | r = Reply() 69 | r.code = None 70 | self.assertEqual(None, r.code) 71 | r.code = '100' 72 | self.assertEqual('100', r.code) 73 | 74 | def test_code_set_bad_value(self): 75 | r = Reply() 76 | with self.assertRaises(ValueError): 77 | r.code = 'asdf' 78 | 79 | def test_esc_set(self): 80 | r = Reply('250') 81 | r.enhanced_status_code = None 82 | self.assertEqual('2.0.0', r.enhanced_status_code) 83 | r.enhanced_status_code = '2.3.4' 84 | self.assertEqual('2.3.4', r.enhanced_status_code) 85 | 86 | def test_esc_without_code(self): 87 | r = Reply() 88 | r.enhanced_status_code = '2.3.4' 89 | self.assertEqual(None, r.enhanced_status_code) 90 | r.code = '250' 91 | self.assertEqual('2.3.4', r.enhanced_status_code) 92 | 93 | def test_esc_set_false(self): 94 | r = Reply('250', 'Ok') 95 | self.assertEqual('2.0.0 Ok', r.message) 96 | r.enhanced_status_code = None 97 | self.assertEqual('2.0.0 Ok', r.message) 98 | r.enhanced_status_code = False 99 | self.assertEqual('Ok', r.message) 100 | 101 | def test_esc_set_bad_value(self): 102 | r = Reply() 103 | with self.assertRaises(ValueError): 104 | r.enhanced_status_code = 'abc' 105 | 106 | def test_message_set(self): 107 | r = Reply() 108 | r.message = None 109 | self.assertEqual(None, r.message) 110 | r.message = 'Ok' 111 | self.assertEqual('Ok', r.message) 112 | 113 | def test_message_set_with_esc(self): 114 | r = Reply('250') 115 | r.message = '2.3.4 Ok' 116 | self.assertEqual('2.3.4 Ok', r.message) 117 | self.assertEqual('2.3.4', r.enhanced_status_code) 118 | 119 | def test_message_set_clear_esc(self): 120 | r = Reply('250', '2.3.4 Ok') 121 | self.assertEqual('2.3.4 Ok', r.message) 122 | self.assertEqual('2.3.4', r.enhanced_status_code) 123 | r.message = None 124 | self.assertEqual(None, r.message) 125 | self.assertEqual('2.0.0', r.enhanced_status_code) 126 | 127 | def test_code_changes_esc_class(self): 128 | r = Reply('550', '2.3.4 Stuff') 129 | self.assertEqual('5.3.4', r.enhanced_status_code) 130 | 131 | def test_send(self): 132 | r = Reply('250', 'Ok') 133 | io = IO(None) 134 | r.send(io) 135 | self.assertEqual(b'250 2.0.0 Ok\r\n', io.send_buffer.getvalue()) 136 | 137 | def test_send_newline_first(self): 138 | r = Reply('250', 'Ok') 139 | r.newline_first = True 140 | io = IO(None) 141 | r.send(io) 142 | self.assertEqual(b'\r\n250 2.0.0 Ok\r\n', io.send_buffer.getvalue()) 143 | 144 | 145 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 146 | -------------------------------------------------------------------------------- /slimta/util/system.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2012 Ian C. Good 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | # 21 | 22 | """Contains functions to simplify the usual daemonization procedures for long- 23 | running processes. 24 | 25 | """ 26 | 27 | from __future__ import absolute_import 28 | 29 | import os 30 | import os.path 31 | import sys 32 | from pwd import getpwnam 33 | from grp import getgrnam 34 | 35 | __all__ = ['daemonize', 'redirect_stdio', 'drop_privileges', 'PidFile'] 36 | 37 | 38 | def daemonize(): 39 | """Daemonizes the current process using the standard double-fork. 40 | This function does not affect standard input, output, or error. 41 | 42 | :returns: The PID of the daemonized process. 43 | 44 | """ 45 | 46 | # Fork once. 47 | try: 48 | pid = os.fork() 49 | if pid > 0: 50 | os._exit(0) 51 | except OSError: 52 | return 53 | 54 | # Set some options to detach from the terminal. 55 | os.chdir('/') 56 | os.setsid() 57 | os.umask(0) 58 | 59 | # Fork again. 60 | try: 61 | pid = os.fork() 62 | if pid > 0: 63 | os._exit(0) 64 | except OSError: 65 | return 66 | 67 | os.setsid() 68 | return os.getpid() 69 | 70 | 71 | def redirect_stdio(stdout=None, stderr=None, stdin=None): 72 | """Redirects standard output, error, and input to the given 73 | filenames. Standard output and error are opened in append-mode, and 74 | standard input is opened in read-only mode. Leaving any parameter 75 | blank leaves that stream alone. 76 | 77 | :param stdout: filename to append the standard output stream into. 78 | :param stderr: filename to append the standard error stream into. 79 | :param stdin: filename to read from as the standard input stream. 80 | 81 | """ 82 | 83 | # Find the OS /dev/null equivalent. 84 | nullfile = getattr(os, 'devnull', '/dev/null') 85 | 86 | # Redirect all standard I/O to /dev/null. 87 | sys.stdout.flush() 88 | sys.stderr.flush() 89 | si = open(stdin or nullfile, 'r') 90 | so = open(stdout or nullfile, 'a+') 91 | se = open(stderr or nullfile, 'a+', 0) 92 | os.dup2(si.fileno(), sys.stdin.fileno()) 93 | os.dup2(so.fileno(), sys.stdout.fileno()) 94 | os.dup2(se.fileno(), sys.stderr.fileno()) 95 | 96 | 97 | def drop_privileges(user=None, group=None): 98 | """Uses the system calls :func:`~os.setuid` and :func:`~os.setgid` to drop 99 | root privileges to the given user and group. This is useful for security 100 | purposes, once root-only ports like 25 are opened. 101 | 102 | :param user: user name (from /etc/passwd) or UID. 103 | :param group: group name (from /etc/group) or GID. 104 | 105 | """ 106 | if group: 107 | try: 108 | gid = int(group) 109 | except ValueError: 110 | gid = getgrnam(group).gr_gid 111 | os.setgid(gid) 112 | if user: 113 | try: 114 | uid = int(user) 115 | except ValueError: 116 | uid = getpwnam(user).pw_uid 117 | os.setuid(uid) 118 | 119 | 120 | class PidFile(object): 121 | """.. versionadded:: 0.3.13 122 | 123 | Context manager which creates a PID file containing the current process id, 124 | runs the context, and then removes the PID file. 125 | 126 | An :py:exc:`OSError` exceptions when creating the PID file will be 127 | propogated without executing the context. 128 | 129 | :param filename: The filename to use for the PID file. If ``None`` is 130 | given, the context is simply executed with no PID file 131 | created. 132 | 133 | """ 134 | 135 | def __init__(self, filename=None): 136 | super(PidFile, self).__init__() 137 | if not filename: 138 | self.filename = None 139 | else: 140 | self.filename = os.path.abspath(filename) 141 | 142 | def __enter__(self): 143 | if self.filename: 144 | with open(self.filename, 'w') as pid: 145 | pid.write('{0}\n'.format(os.getpid())) 146 | return self.filename 147 | 148 | def __exit__(self, exc_type, exc_value, traceback): 149 | if self.filename: 150 | try: 151 | os.unlink(self.filename) 152 | except OSError: 153 | pass 154 | 155 | 156 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 157 | -------------------------------------------------------------------------------- /slimta/util/ptrlookup.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013 Ian C. Good 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | # 21 | 22 | """.. versionadded:: 0.3.21 23 | 24 | When a client connects to the server, it is useful to know who they claim to 25 | be. One such method is looking up the PTR records for the client's IP address 26 | in DNS. If it exists, a PTR record will map an IP address to an arbitrary 27 | hostname. 28 | 29 | However, it is not usually desired to slow down a client's request just because 30 | their PTR record lookup has not yet finished. This module implements a 31 | :class:`~gevent.Greenlet` thread that will look up a client's PTR record will 32 | its request is being processed. If the request finishes before the PTR record 33 | lookup, the lookup is stopped. 34 | 35 | """ 36 | 37 | from __future__ import absolute_import 38 | 39 | import time 40 | 41 | import gevent 42 | from gevent import socket 43 | 44 | from slimta import logging 45 | 46 | __all__ = ['PtrLookup'] 47 | 48 | 49 | class PtrLookup(gevent.Greenlet): 50 | """Asynchronously looks up the PTR record of an IP address, implemented as 51 | a :class:`~gevent.Greenlet` thread. 52 | 53 | :param ip: The IP address to query. 54 | 55 | """ 56 | 57 | def __init__(self, ip): 58 | super(PtrLookup, self).__init__() 59 | self.ip = ip or '' 60 | self.start_time = None 61 | 62 | @classmethod 63 | def from_getpeername(cls, sock): 64 | """Creates a :class:`PtrLookup` object based on the IP address of the 65 | socket's remote address, using :py:meth:`~socket.socket.getpeername`. 66 | 67 | :param sock: The :py:class:`~socket.socket` object to use. 68 | :returns: A tuple containing the new :class:`PtrLookup` object and the 69 | port number from :py:meth:`~socket.socket.getpeername`. 70 | 71 | """ 72 | ip, port = sock.getpeername() 73 | return cls(ip), port 74 | 75 | @classmethod 76 | def from_getsockname(cls, sock): 77 | """Creates a :class:`PtrLookup` object based on the IP address of the 78 | socket's local address, using :py:meth:`~socket.socket.getsockname`. 79 | 80 | :param sock: The :py:class:`~socket.socket` object to use. 81 | :returns: A tuple containing the new :class:`PtrLookup` object and the 82 | port number from :py:meth:`~socket.socket.getsockname`. 83 | 84 | """ 85 | ip, port = sock.getsockname() 86 | return cls(ip), port 87 | 88 | def start(self): 89 | """Starts the PTR lookup thread. 90 | 91 | .. seealso:: :meth:`gevent.Greenlet.start` 92 | 93 | """ 94 | self.start_time = time.time() 95 | super(PtrLookup, self).start() 96 | 97 | def _run(self): 98 | try: 99 | hostname, _, _ = socket.gethostbyaddr(self.ip) 100 | except (socket.herror, socket.gaierror, gevent.GreenletExit): 101 | pass 102 | except Exception: 103 | logging.log_exception(__name__, query=self.ip) 104 | else: 105 | return hostname 106 | 107 | def finish(self, runtime=None): 108 | """Attempts to get the results of the PTR lookup. If the results are 109 | not available, ``None`` is returned instead. 110 | 111 | When this method returns, the :class:`~gevent.Greenlet` executing the 112 | lookup is killed. 113 | 114 | :param runtime: If this many seconds have not passed since the lookup 115 | started, the method call blocks the remaining time. For 116 | example, if 3.5 seconds have elapsed since calling 117 | :meth:`.start` and you pass in ``5.0``, this method 118 | will wait at most 1.5 seconds for the results to come 119 | in. 120 | :type runtime: float 121 | :returns: The PTR lookup results (a hostname string) or ``None``. 122 | 123 | """ 124 | try: 125 | if runtime is None: 126 | result = self.get(block=False) 127 | else: 128 | timeout = time.time() - self.start_time 129 | result = self.get(block=True, timeout=timeout) 130 | except gevent.Timeout: 131 | result = None 132 | self.kill(block=False) 133 | return result 134 | 135 | 136 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 137 | -------------------------------------------------------------------------------- /slimta/util/bytesformat.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Ian C. Good 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | # 21 | 22 | """Provides a class that is capable of basic bytestring templating, similar to 23 | the :py:func:`str.format` method. This is needed in Python 3 before 3.5+, but 24 | should work in all supported versions. 25 | 26 | This class does the heavy lifting (a :py:func:`re.finditer` loop) during 27 | construction, so it is optimal to instantiate it once and then re-use 28 | :meth:`~BytesFormat.format`. 29 | 30 | """ 31 | 32 | from __future__ import absolute_import 33 | 34 | import re 35 | 36 | __all__ = ['BytesFormat'] 37 | 38 | 39 | class BytesFormat(object): 40 | """Wraps a bytestring in a class with a :meth:`.format` method similar to 41 | :py:func:`str.format`. 42 | 43 | During construction, the template string is scanned for matching ``{...}`` 44 | pairs that contain only characters that match the ``\w`` regular 45 | expression. In the :meth:`.format` method, these ``{...}`` are replaced 46 | with a matching argument's value, if an argument matches, or the action 47 | specified by ``mode`` happens when it does not match 48 | 49 | To match :meth:`.format` arguments, you may use a positional argument's 50 | number (e.g. ``{0}``) or a keyword argument's key (e.g. ``{recipient}``). 51 | Additional formatting options are not supported. 52 | 53 | :param template: The template bytestring. 54 | :type template: :py:obj:`bytes` 55 | :param mode: If ``'ignore'``, any ``{...}`` that does not match a 56 | :meth:`.format` argument is left in place as-is. If 57 | ``'remove'``, these ``{...}`` are replaced with an empty 58 | string. If ``'strict'``, these ``{...}`` will cause 59 | :class:`KeyError` or :class:`IndexError` exceptions. 60 | 61 | """ 62 | 63 | def __init__(self, template, mode='ignore'): 64 | super(BytesFormat, self).__init__() 65 | self.mode = mode 66 | self.template_parts = [] 67 | self._parse_template(template) 68 | 69 | def _parse_template(self, template): 70 | last_end = 0 71 | for match in re.finditer(br'\{(\w+)\}', template): 72 | if match.start(0) > last_end: 73 | literal = template[last_end:match.start(0)] 74 | self.template_parts.append((0, literal)) 75 | last_end = match.end(0) 76 | self.template_parts.append((1, match.group(1))) 77 | if len(template) > last_end: 78 | literal = template[last_end:] 79 | self.template_parts.append((0, literal)) 80 | 81 | def _get_arg(self, value, args, kwargs): 82 | try: 83 | index = int(value) 84 | except ValueError: 85 | if isinstance(value, bytes): 86 | value = value.decode('ascii') 87 | return kwargs[value] 88 | else: 89 | return args[index] 90 | 91 | def _format(self, args, kwargs, mode=None): 92 | mode = mode or self.mode 93 | ret = [] 94 | for type, value in self.template_parts: 95 | if type == 0: 96 | ret.append(value) 97 | elif type == 1: 98 | try: 99 | result = self._get_arg(value, args, kwargs) 100 | except (KeyError, IndexError): 101 | if mode == 'remove': 102 | pass 103 | elif mode == 'strict': 104 | raise 105 | else: 106 | ret.append(b'{' + value + b'}') 107 | else: 108 | try: 109 | result = bytes(result) 110 | except TypeError: 111 | result = result.encode('utf-8') 112 | ret.append(result) 113 | return b''.join(ret) 114 | 115 | def format(self, *args, **kwargs): 116 | """Substitutes in the given positional and keyword arguments into the 117 | original template string, replacing occurrences of ``{...}`` with the 118 | correct argument's value. 119 | 120 | :rtype: :py:obj:`bytes` 121 | 122 | """ 123 | return self._format(args, kwargs) 124 | 125 | def __repr__(self): 126 | return repr(self._format([], {}, mode='ignore')) 127 | 128 | 129 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 130 | -------------------------------------------------------------------------------- /test/test_slimta_edge_smtp.py: -------------------------------------------------------------------------------- 1 | import unittest2 as unittest 2 | from mox3.mox import MoxTestBase, IsA, IgnoreArg 3 | import gevent 4 | from gevent.socket import create_connection 5 | 6 | from slimta.edge.smtp import SmtpEdge, SmtpSession 7 | from slimta.envelope import Envelope 8 | from slimta.queue import QueueError 9 | from slimta.smtp.reply import Reply 10 | from slimta.smtp import ConnectionLost, MessageTooBig 11 | from slimta.smtp.client import Client 12 | 13 | 14 | class TestEdgeSmtp(unittest.TestCase, MoxTestBase): 15 | 16 | def test_call_validator(self): 17 | mock = self.mox.CreateMockAnything() 18 | mock.__call__(IsA(SmtpSession)).AndReturn(mock) 19 | mock.handle_test('arg') 20 | self.mox.ReplayAll() 21 | h = SmtpSession(None, mock, None) 22 | h._call_validator('test', 'arg') 23 | 24 | def test_protocol_attribute(self): 25 | h = SmtpSession(None, None, None) 26 | self.assertEqual('SMTP', h.protocol) 27 | h.extended_smtp = True 28 | self.assertEqual('ESMTP', h.protocol) 29 | h.security = 'TLS' 30 | self.assertEqual('ESMTPS', h.protocol) 31 | h.auth = 'test' 32 | self.assertEqual('ESMTPSA', h.protocol) 33 | 34 | def test_simple_handshake(self): 35 | mock = self.mox.CreateMockAnything() 36 | mock.__call__(IsA(SmtpSession)).AndReturn(mock) 37 | mock.handle_banner(IsA(Reply), ('127.0.0.1', 0)) 38 | mock.handle_helo(IsA(Reply), 'there') 39 | self.mox.ReplayAll() 40 | h = SmtpSession(('127.0.0.1', 0), mock, None) 41 | h.BANNER_(Reply('220')) 42 | h.HELO(Reply('250'), 'there') 43 | self.assertEqual('there', h.ehlo_as) 44 | self.assertFalse(h.extended_smtp) 45 | 46 | def test_extended_handshake(self): 47 | creds = self.mox.CreateMockAnything() 48 | creds.authcid = 'testuser' 49 | creds.authzid = 'testzid' 50 | mock = self.mox.CreateMockAnything() 51 | mock.__call__(IsA(SmtpSession)).AndReturn(mock) 52 | mock.handle_banner(IsA(Reply), ('127.0.0.1', 0)) 53 | mock.handle_ehlo(IsA(Reply), 'there') 54 | mock.handle_tls() 55 | mock.handle_auth(IsA(Reply), creds) 56 | self.mox.ReplayAll() 57 | h = SmtpSession(('127.0.0.1', 0), mock, None) 58 | h.BANNER_(Reply('220')) 59 | h.EHLO(Reply('250'), 'there') 60 | h.TLSHANDSHAKE() 61 | h.AUTH(Reply('235'), creds) 62 | self.assertEqual('there', h.ehlo_as) 63 | self.assertTrue(h.extended_smtp) 64 | self.assertEqual('TLS', h.security) 65 | self.assertEqual(('testuser', 'testzid'), h.auth) 66 | self.assertEqual('ESMTPSA', h.protocol) 67 | 68 | def test_mail_rcpt_data_rset(self): 69 | mock = self.mox.CreateMockAnything() 70 | mock.__call__(IsA(SmtpSession)).AndReturn(mock) 71 | mock.handle_mail(IsA(Reply), 'sender@example.com', {}) 72 | mock.handle_rcpt(IsA(Reply), 'rcpt@example.com', {}) 73 | mock.handle_data(IsA(Reply)) 74 | self.mox.ReplayAll() 75 | h = SmtpSession(None, mock, None) 76 | h.MAIL(Reply('250'), 'sender@example.com', {}) 77 | h.RCPT(Reply('250'), 'rcpt@example.com', {}) 78 | self.assertEqual('sender@example.com', h.envelope.sender) 79 | self.assertEqual(['rcpt@example.com'], h.envelope.recipients) 80 | h.DATA(Reply('550')) 81 | h.RSET(Reply('250')) 82 | self.assertFalse(h.envelope) 83 | 84 | def test_have_data_errors(self): 85 | h = SmtpSession(None, None, None) 86 | reply = Reply('250') 87 | h.HAVE_DATA(reply, None, MessageTooBig()) 88 | self.assertEqual('552', reply.code) 89 | with self.assertRaises(ValueError): 90 | h.HAVE_DATA(reply, None, ValueError()) 91 | 92 | def test_have_data(self): 93 | env = Envelope() 94 | handoff = self.mox.CreateMockAnything() 95 | handoff(env).AndReturn([(env, 'testid')]) 96 | self.mox.ReplayAll() 97 | h = SmtpSession(('127.0.0.1', 0), None, handoff) 98 | h.envelope = env 99 | reply = Reply('250') 100 | h.HAVE_DATA(reply, b'', None) 101 | self.assertEqual('250', reply.code) 102 | self.assertEqual('2.6.0 Message accepted for delivery', reply.message) 103 | 104 | def test_have_data_queueerror(self): 105 | env = Envelope() 106 | handoff = self.mox.CreateMockAnything() 107 | handoff(env).AndReturn([(env, QueueError())]) 108 | self.mox.ReplayAll() 109 | h = SmtpSession(('127.0.0.1', 0), None, handoff) 110 | h.envelope = env 111 | reply = Reply('250') 112 | h.HAVE_DATA(reply, b'', None) 113 | self.assertEqual('451', reply.code) 114 | self.assertEqual('4.3.0 Error queuing message', reply.message) 115 | 116 | def test_smtp_edge(self): 117 | queue = self.mox.CreateMockAnything() 118 | queue.enqueue(IsA(Envelope)).AndReturn([(Envelope(), 'testid')]) 119 | self.mox.ReplayAll() 120 | server = SmtpEdge(('127.0.0.1', 0), queue) 121 | server.start() 122 | gevent.sleep(0) 123 | client_sock = create_connection(server.server.address) 124 | client = Client(client_sock) 125 | client.get_banner() 126 | client.ehlo('there') 127 | client.mailfrom('sender@example.com') 128 | client.rcptto('rcpt@example.com') 129 | client.data() 130 | client.send_empty_data() 131 | client.quit() 132 | client_sock.close() 133 | 134 | 135 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 136 | -------------------------------------------------------------------------------- /slimta/logging/http.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2012 Ian C. Good 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | # 21 | 22 | """Utilities to make logging consistent and easy for WSGI-style requests and 23 | responses as well as more general HTTP logs. 24 | 25 | """ 26 | 27 | from __future__ import absolute_import 28 | 29 | from functools import partial 30 | 31 | __all__ = ['HttpLogger'] 32 | 33 | 34 | class HttpLogger(object): 35 | """Provides a limited set of log methods that :mod:`slimta` packages may 36 | use. This prevents free-form logs from mixing in with standard, machine- 37 | parseable logs. 38 | 39 | :param log: :py:class:`logging.Logger` object to log through. 40 | 41 | """ 42 | 43 | def __init__(self, log): 44 | from slimta.logging import logline 45 | self.log = partial(logline, log.debug, 'http') 46 | 47 | def _get_method_from_environ(self, environ): 48 | return environ['REQUEST_METHOD'].upper() 49 | 50 | def _get_path_from_environ(self, environ): 51 | return environ.get('PATH_INFO', None) 52 | 53 | def _get_headers_from_environ(self, environ): 54 | ret = [] 55 | for key, value in environ.items(): 56 | if key == 'CONTENT_TYPE': 57 | ret.append(('Content-Type', value)) 58 | elif key == 'CONTENT_LENGTH': 59 | ret.append(('Content-Length', value)) 60 | elif key.startswith('HTTP_'): 61 | parts = key.split('_') 62 | name = '-'.join([part.capitalize() for part in parts[1:]]) 63 | ret.append((name, value)) 64 | return ret 65 | 66 | def wsgi_request(self, environ): 67 | """Logs a WSGI-style request. This method pulls the appropriate info 68 | from ``environ`` and passes it to :meth:`.request`. 69 | 70 | :param environ: The environment data. 71 | 72 | """ 73 | method = self._get_method_from_environ(environ) 74 | path = self._get_path_from_environ(environ) 75 | headers = self._get_headers_from_environ(environ) 76 | self.request(environ, method, path, headers, is_client=False) 77 | 78 | def wsgi_response(self, environ, status, headers): 79 | """Logs a WSGI-style response. This method passes its given info along 80 | to :meth:`.response`. 81 | 82 | :param environ: The environment data. 83 | :param status: The status line given to the client, e.g. 84 | ``404 Not Found``. 85 | :param headers: The headers returned in the response. 86 | 87 | """ 88 | self.response(environ, status, headers, is_client=False) 89 | 90 | def request(self, conn, method, path, headers, is_client=True): 91 | """Logs an HTTP request. 92 | 93 | :param conn: The same object should be passed in this parameter to both 94 | this method and to its corresponding :meth:`.response`. 95 | There are no constraints on its type or value. 96 | :type conn: :py:class:`object` 97 | :param method: The request method string. 98 | :param path: The path string. 99 | :param headers: A list of ``(name, value)`` header tuples given in the 100 | request. 101 | :param is_client: Whether or not the log line should be identified as a 102 | client- or server-side request. 103 | :type is_client: :py:class:`bool` 104 | 105 | """ 106 | type = 'client_request' if is_client else 'server_request' 107 | self.log(id(conn), type, method=method, path=path, headers=headers) 108 | 109 | def response(self, conn, status, headers, is_client=True): 110 | """Logs an HTTP response. 111 | 112 | :param conn: The same object should be passed in this parameter to both 113 | this method and to its corresponding :meth:`.request`. 114 | There are no constraints on its type or value. 115 | :type conn: :py:class:`object` 116 | :param status: The status string of the response, e.g. ``200 OK``. 117 | :param headers: A list of ``(name, value)`` header tuples given in the 118 | response. 119 | :param is_client: Whether or not the log line should be identified as a 120 | client- or server-side request. 121 | :type is_client: :py:class:`bool` 122 | 123 | """ 124 | type = 'client_response' if is_client else 'server_response' 125 | self.log(id(conn), type, status=status, headers=headers) 126 | 127 | 128 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 129 | -------------------------------------------------------------------------------- /slimta/relay/pool.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2012 Ian C. Good 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | # 21 | 22 | """ 23 | 24 | """ 25 | 26 | from __future__ import absolute_import 27 | 28 | from gevent import Greenlet, Timeout 29 | from gevent.event import AsyncResult 30 | 31 | from slimta.util.deque import BlockingDeque 32 | from . import Relay 33 | 34 | __all__ = ['RelayPool', 'RelayPoolClient'] 35 | 36 | 37 | class RelayPool(Relay): 38 | """Base class that inherits |Relay| to add the ability to create bounded, 39 | cached pools of outbound clients. It maintains a queue of messages to be 40 | delivered such that idle clients in the pool pick them up. 41 | 42 | :param pool_size: At most this many simultaneous connections will be open 43 | to a destination. If this limit is reached and no 44 | connections are idle, new attempts will block. 45 | 46 | """ 47 | 48 | def __init__(self, pool_size=None): 49 | super(RelayPool, self).__init__() 50 | self.pool = set() 51 | self.pool_size = pool_size 52 | 53 | #: This attribute holds the queue object for providing delivery 54 | #: requests to idle clients in the pool. 55 | self.queue = BlockingDeque() 56 | 57 | def kill(self): 58 | for client in self.pool: 59 | client.kill() 60 | 61 | def _remove_client(self, client): 62 | self.pool.remove(client) 63 | if len(self.queue) > 0 and not self.pool: 64 | self._add_client() 65 | 66 | def _add_client(self): 67 | client = self.add_client() 68 | client.queue = self.queue 69 | client.start() 70 | client.link(self._remove_client) 71 | self.pool.add(client) 72 | 73 | def _check_idle(self): 74 | for client in self.pool: 75 | if client.idle: 76 | return 77 | if not self.pool_size or len(self.pool) < self.pool_size: 78 | self._add_client() 79 | 80 | def add_client(self): 81 | """Sub-classes must override this method to create and return a new 82 | :class:`RelayPoolClient` object that will poll for delivery requests. 83 | 84 | :rtype: :class:`RelayPoolClient` 85 | 86 | """ 87 | raise NotImplementedError() 88 | 89 | def attempt(self, envelope, attempts): 90 | self._check_idle() 91 | result = AsyncResult() 92 | self.queue.append((result, envelope)) 93 | return result.get() 94 | 95 | 96 | class RelayPoolClient(Greenlet): 97 | """Base class for implementing clients for handling delivery requests in a 98 | :class:`RelayPool`. 99 | 100 | :param queue: The queue on which delivery requests will be received. 101 | :param idle_timeout: If the client can handle multiple, pipelined delivery 102 | requests, this is the timeout in seconds that a client 103 | will wait for subsequent requests. 104 | 105 | """ 106 | 107 | def __init__(self, queue, idle_timeout=None): 108 | super(RelayPoolClient, self).__init__() 109 | self.idle = False 110 | self.queue = queue 111 | 112 | #: This attribute holds the idle timeout for handling multiple delivery 113 | #: requests on the client. 114 | self.idle_timeout = idle_timeout 115 | 116 | def poll(self): 117 | """This method can be used by clients to receive new delivery requests 118 | from the client pool. This method will block until a delivery request 119 | is received. 120 | 121 | :returns: A tuple containing the :class:`~gevent.event.AsyncResult` and 122 | the :class:`~slimta.envelope.Envelope` that make up a 123 | delivery request. If no delivery requests are received before 124 | the :attr:`idle_timeout` timeout, ``(None, None)`` is 125 | returned. 126 | 127 | """ 128 | self.idle = True 129 | try: 130 | with Timeout(self.idle_timeout, False): 131 | return self.queue.popleft() 132 | return None, None 133 | finally: 134 | self.idle = False 135 | 136 | def _run(self): 137 | """This method must be overriden by sub-classes to handle processing of 138 | delivery requests. It should call :meth:`poll` when it is ready for new 139 | delivery requests. The result of the delivery attempt should be written 140 | to the :class:`~gevent.event.AsyncResult` object provided in the 141 | request. 142 | 143 | """ 144 | raise NotImplementedError() 145 | 146 | 147 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 148 | -------------------------------------------------------------------------------- /slimta/smtp/extensions.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2012 Ian C. Good 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | # 21 | 22 | """Manages the SMTP extensions offered by a server or available to a client.""" 23 | 24 | from __future__ import absolute_import 25 | 26 | import re 27 | 28 | 29 | __all__ = ['Extensions'] 30 | 31 | parse_pattern = re.compile(r'^\s*([a-zA-Z0-9][a-zA-Z0-9-]*)\s*(.*?)\s*$') 32 | line_pattern = re.compile(r'(.*?)\r?\n') 33 | 34 | 35 | class Extensions(object): 36 | """Class that simplifies the usage of SMTP extensions. Along with an 37 | extension, a simple string parameter can be stored if necessary. 38 | 39 | """ 40 | 41 | def __init__(self): 42 | self.extensions = {} 43 | 44 | def reset(self): 45 | """Removes all known extensions.""" 46 | self.extensions = {} 47 | 48 | def __contains__(self, ext): 49 | """Checks if the given extension is in the known extensions. This is 50 | especially useful for clients to check if a server offers a certain 51 | desired extension, e.g. ``if 'AUTH' in extensions:``. 52 | 53 | :param ext: The extension to check for, case-insensitive. 54 | :rtype: True or False 55 | 56 | """ 57 | return ext.upper() in self.extensions 58 | 59 | def getparam(self, ext, filter=None): 60 | """Gets the parameter associated with an extension. 61 | 62 | :param ext: The extension to get the parameter for. 63 | :param filter: An optional filter function to modify the returned 64 | parameter, if it exists, e.g. :func:`int()`. 65 | :returns: Returns None if the extension doesn't exist, the extension 66 | doesn't have a parameter, or the filter function raises 67 | :class:`ValueEror`. 68 | 69 | """ 70 | try: 71 | param = self.extensions[ext.upper()] 72 | except KeyError: 73 | return None 74 | if filter: 75 | try: 76 | return filter(param) 77 | except ValueError: 78 | return None 79 | else: 80 | return param 81 | 82 | def add(self, ext, param=None): 83 | """Adds a new supported extension. This is useful for servers to 84 | advertise their offered extensions. 85 | 86 | :param ext: The extension name, which will be upper-cased. 87 | :param param: Optional parameter string associated with the extension. 88 | 89 | """ 90 | self.extensions[ext.upper()] = param 91 | 92 | def drop(self, ext): 93 | """Drops the given extension, if it exists. 94 | 95 | :param ext: The extension name. 96 | :returns: True if the extension existed, False otherwise. 97 | 98 | """ 99 | try: 100 | del self.extensions[ext.upper()] 101 | return True 102 | except KeyError: 103 | return False 104 | 105 | def parse_string(self, string): 106 | """Parses a string as returned by the EHLO command and adds all 107 | discovered extensions. This string should *not* have the response code 108 | prefixed to its lines. 109 | 110 | :param string: The string to parse. 111 | :returns: The first line of the string, which will be a free-form 112 | message response to the EHLO command. 113 | 114 | """ 115 | header = None 116 | string += '\r\n' 117 | for match in line_pattern.finditer(string): 118 | if not header: 119 | header = match.group(1) 120 | else: 121 | ext_match = parse_pattern.match(match.group(1)) 122 | if ext_match: 123 | name = ext_match.group(1) 124 | arg = ext_match.group(2) 125 | if arg: 126 | self.add(name, arg) 127 | else: 128 | self.add(name) 129 | return header or string.rstrip('\r\n') 130 | 131 | def build_string(self, header): 132 | """Converts the object into a string that can be sent with the response 133 | to a EHLO command, without the code prefixed to each line. 134 | 135 | :param header: The first line of the resulting string, which can be a 136 | free-form message response for the EHLO command. 137 | :rtype: str 138 | 139 | """ 140 | lines = [header] 141 | for k, v in self.extensions.items(): 142 | if v: 143 | try: 144 | value_str = str(v) 145 | except ValueError: 146 | pass 147 | else: 148 | lines.append(' '.join((k, value_str))) 149 | else: 150 | lines.append(k) 151 | return '\r\n'.join(lines) 152 | 153 | 154 | # vim:et:fdm=marker:sts=4:sw=4:ts=4 155 | --------------------------------------------------------------------------------