├── .gitignore ├── LICENSE.txt ├── MANIFEST.in ├── README.markdown ├── examples ├── benchmarking │ ├── benchmark.eml │ ├── client.py │ └── server.py ├── evil_ssl_client.py ├── mail_relay.py ├── server.crt ├── server.key ├── ssl_client.py └── ssl_server.py ├── secure_smtpd ├── __init__.py ├── config │ ├── __init__.py │ └── log.py ├── fake_credential_validator.py ├── process_pool.py ├── proxy_server.py ├── smtp_channel.py ├── smtp_server.py └── store_credentials.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | dist 3 | config/environment.json 4 | *.swp 5 | *.pyc 6 | *.egg 7 | *.coverage 8 | *.egg-info 9 | *.DS_Store 10 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Copyright (c) 2014, Benjamin Coe 3 | 4 | Permission to use, copy, modify, and/or distribute this software for any purpose 5 | with or without fee is hereby granted, provided that the above copyright notice 6 | and this permission notice appear in all copies. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 9 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND 10 | FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 11 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 12 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 13 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 14 | PERFORMANCE OF THIS SOFTWARE. 15 | 16 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.markdown LICENSE.txt 2 | recursive-include examples * 3 | recursive-include examples/benchmarking * 4 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | Secure SMTPD 2 | ============ 3 | 4 | Secure-SMTPD extends on Petri Lehtinen's SMTPD library adding support for AUTH and SSL. 5 | 6 | Usage 7 | ----- 8 | 9 | ```python 10 | from secure_smtpd import SMTPServer, FakeCredentialValidator 11 | SMTPServer( 12 | self, 13 | ('0.0.0.0', 465), 14 | None, 15 | require_authentication=True, 16 | ssl=True, 17 | certfile='examples/server.crt', 18 | keyfile='examples/server.key', 19 | credential_validator=FakeCredentialValidator(), 20 | ) 21 | asyncore.loop() 22 | ``` 23 | -------------------------------------------------------------------------------- /examples/benchmarking/benchmark.eml: -------------------------------------------------------------------------------- 1 | Return-Path: bencoe@gmail.com 2 | Received: from amazon.cs.uoguelph.ca (LHLO amazon.cs.uoguelph.ca) 3 | (172.17.91.198) by zcs3.mail.uoguelph.ca with LMTP; Wed, 23 Nov 2011 4 | 03:52:26 -0500 (EST) 5 | Received: from localhost (localhost.localdomain [127.0.0.1]) 6 | by amazon.cs.uoguelph.ca (Postfix) with ESMTP id 212BE1E4A83 7 | for ; Wed, 23 Nov 2011 03:52:25 -0500 (EST) 8 | X-Virus-Scanned: amavisd-new at amazon.cs.uoguelph.ca 9 | X-Spam-Flag: NO 10 | X-Spam-Score: -0.006 11 | X-Spam-Level: 12 | X-Spam-Status: No, score=-0.006 tagged_above=-10 required=6.6 13 | tests=[BAYES_40=-0.001, DKIM_SIGNED=0.1, DKIM_VALID=-0.1, 14 | DKIM_VALID_AU=-0.1, FREEMAIL_FROM=0.001, HTML_MESSAGE=0.001, 15 | RCVD_IN_DNSWL_LOW=-0.7, RDNS_NONE=0.793] autolearn=no 16 | Authentication-Results: amazon.cs.uoguelph.ca (amavisd-new); dkim=pass 17 | header.i=@gmail.com 18 | Received: from amazon.cs.uoguelph.ca ([127.0.0.1]) 19 | by localhost (amazon.cs.uoguelph.ca [127.0.0.1]) (amavisd-new, port 10024) 20 | with ESMTP id nvX1vsZNrB9m for ; 21 | Wed, 23 Nov 2011 03:52:24 -0500 (EST) 22 | Received: from esa-jnhn.mail.uoguelph.ca (esa-jnhn.mail.uoguelph.ca [131.104.91.44]) 23 | by amazon.cs.uoguelph.ca (Postfix) with ESMTP id B48D41E4A47 24 | for ; Wed, 23 Nov 2011 03:52:24 -0500 (EST) 25 | X-IronPort-Anti-Spam-Filtered: true 26 | X-IronPort-Anti-Spam-Result: AtAAAJmzzE7RVdWvkGdsb2JhbABEqloIIgEBAQEJCQ0HFAQhggsCLAEbHgMSCAECBV0BEQEFASI1nHmCXAqLYYJmhHY9iHECBQqKWASIIIwojVc9hBc 27 | X-IronPort-AV: E=Sophos;i="4.69,557,1315195200"; 28 | d="scan'208";a="146591521" 29 | X-SBRS: 4.4 30 | Received: from mail-yx0-f175.google.com ([209.85.213.175]) 31 | by esa-jnhn.mail.uoguelph.ca with ESMTP; 23 Nov 2011 03:52:24 -0500 32 | Received: by yenm2 with SMTP id m2so1186801yen.6 33 | for ; Wed, 23 Nov 2011 00:52:24 -0800 (PST) 34 | DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; 35 | d=gmail.com; s=gamma; 36 | h=mime-version:date:message-id:subject:from:to:content-type; 37 | bh=zhSZpEabUbnviG01tthVj3qd0jl7l4pNU8Y04w7ksOo=; 38 | b=avtS1gnsQq1zBjx5XkQg8OHMytI6rnjPiEmmGXwgby7a3uO7gJjuwy26dfdJUsfRmW 39 | FkAfHenUaQ7Pb8JztODIZ0Aw4cn+4w++EVp2FbrPcgfdgXIdXrWPsegPigfzz+DDPT53 40 | bLeFml0NC9BFtgw/zD+9slhhQTZmZZpbOXJ7M= 41 | MIME-Version: 1.0 42 | Received: by 10.236.129.244 with SMTP id h80mr32823733yhi.130.1322038344139; 43 | Wed, 23 Nov 2011 00:52:24 -0800 (PST) 44 | Received: by 10.100.239.8 with HTTP; Wed, 23 Nov 2011 00:52:24 -0800 (PST) 45 | Date: Wed, 23 Nov 2011 00:52:24 -0800 46 | Message-ID: 47 | Subject: Testing DKIM 48 | From: Ben Coe 49 | To: ben coe 50 | Content-Type: multipart/alternative; boundary=20cf300e55f3e1c2f804b2630968 51 | 52 | --20cf300e55f3e1c2f804b2630968 53 | Content-Type: text/plain; charset=ISO-8859-1 54 | 55 | An email specifically for testing DKIM. 56 | 57 | --20cf300e55f3e1c2f804b2630968 58 | Content-Type: text/html; charset=ISO-8859-1 59 | 60 | An email specifically for testing DKIM. 61 | 62 | --20cf300e55f3e1c2f804b2630968-- -------------------------------------------------------------------------------- /examples/benchmarking/client.py: -------------------------------------------------------------------------------- 1 | import smtplib, time 2 | 3 | messages_sent = 0.0 4 | start_time = time.time() 5 | msg = open('examples/benchmarking/benchmark.eml').read() 6 | 7 | while True: 8 | 9 | if (messages_sent % 10) == 0: 10 | current_time = time.time() 11 | print('%s msg-written/sec' % (messages_sent / (current_time - start_time))) 12 | 13 | server = smtplib.SMTP('localhost', port=1025) 14 | server.sendmail('foo@localhost', ['bar@localhost'], msg) 15 | server.quit() 16 | 17 | messages_sent += 1.0 18 | -------------------------------------------------------------------------------- /examples/benchmarking/server.py: -------------------------------------------------------------------------------- 1 | from secure_smtpd import SMTPServer 2 | 3 | class SecureSMTPServer(SMTPServer): 4 | def process_message(self, peer, mailfrom, rcpttos, message_data): 5 | pass 6 | 7 | server = SecureSMTPServer(('0.0.0.0', 1025), None) 8 | server.run() 9 | -------------------------------------------------------------------------------- /examples/evil_ssl_client.py: -------------------------------------------------------------------------------- 1 | import smtplib 2 | import time 3 | 4 | msg = """From: foo@localhost 5 | To: bar@localhost 6 | 7 | Here's my message! 8 | """ 9 | count = 0 10 | while True: 11 | try: 12 | count += 1 13 | if (count % 10) == 0: 14 | server = smtplib.SMTP('localhost', port=465) 15 | else: 16 | server = smtplib.SMTP_SSL('localhost', port=465) 17 | 18 | server.set_debuglevel(1) 19 | server.login('bcoe', 'foobar') 20 | server.sendmail('foo@localhost', ['bar@localhost'], msg) 21 | server.quit() 22 | except Exception as e: 23 | print(e) 24 | time.sleep(0.05) 25 | -------------------------------------------------------------------------------- /examples/mail_relay.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import argparse 3 | import sys 4 | from secure_smtpd import ProxyServer 5 | from secure_smtpd import store_credentials 6 | import config 7 | 8 | def run(cmdargs): 9 | args = [ 10 | (cmdargs.localhost, cmdargs.localport), 11 | (cmdargs.remotehost, cmdargs.remoteport) 12 | ] 13 | kwargs = {} 14 | 15 | if cmdargs.sslboth: 16 | kwargs['ssl'] = True 17 | if not cmdargs.certfile or not cmdargs.keyfile: 18 | print ('You need to specify a valid certificate file and a key file!') 19 | sys.exit(1) 20 | kwargs['certfile'] = cmdargs.certfile 21 | kwargs['keyfile'] = cmdargs.keyfile 22 | elif cmdargs.sslout: 23 | kwargs['ssl_out_only'] = True 24 | 25 | if not cmdargs.quiet: 26 | kwargs['debug'] = True 27 | 28 | if cmdargs.username and cmdargs.password: 29 | credentials = store_credentials.StoreCredentials() 30 | credentials.username = cmdargs.username 31 | credentials.password = cmdargs.password 32 | credentials.stored = True 33 | else: 34 | credentials = store_credentials.StoreCredentials() 35 | credentials.username = config.username 36 | credentials.password = config.password 37 | credentials.stored = True 38 | kwargs['credential_validator'] = credentials 39 | 40 | server = ProxyServer(*args, **kwargs) 41 | server.run() 42 | 43 | parser = argparse.ArgumentParser(description='mail relay tool') 44 | 45 | parser.add_argument( 46 | '--localhost', 47 | default='127.0.0.1', 48 | help='Local address to attach to for receiving mail. Defaults to 127.0.0.1' 49 | ) 50 | 51 | parser.add_argument( 52 | '--localport', 53 | default=1025, 54 | type=int, 55 | help='Local port to attach to for receiving mail. Defaults to 1025' 56 | ) 57 | 58 | parser.add_argument( 59 | '--remotehost', 60 | required=True, 61 | help='Address of the remote server for connection.' 62 | ) 63 | 64 | parser.add_argument( 65 | '--remoteport', 66 | default=25, 67 | type=int, 68 | help='Port of the remote server for connection. Defaults to 25' 69 | ) 70 | 71 | parser.add_argument( 72 | '--quiet', 73 | action='store_true', 74 | help='Use this to turn off the message printing' 75 | ) 76 | 77 | group = parser.add_mutually_exclusive_group() 78 | 79 | group.add_argument( 80 | '--sslboth', 81 | action='store_true', 82 | help='Use this parameter if both the inbound and outbound connections should use SSL' 83 | ) 84 | 85 | group.add_argument( 86 | '--sslout', 87 | action='store_true', 88 | help='Use this parameter if inbound connection is plain but the outbound connection uses SSL' 89 | ) 90 | 91 | parser.add_argument( 92 | '--certfile', 93 | help='Certificate file to use for inbound SSL connections' 94 | ) 95 | 96 | parser.add_argument( 97 | '--keyfile', 98 | help='Key file to use for inbound SSL connections' 99 | ) 100 | 101 | parser.add_argument( 102 | '--username', 103 | help='Username for remote authentication' 104 | ) 105 | 106 | parser.add_argument( 107 | '--password', 108 | help='Password for remote authentication' 109 | ) 110 | 111 | args = parser.parse_args() 112 | 113 | print('Starting ProxyServer') 114 | print('local: %s:%s' % (args.localhost, args.localport)) 115 | print('remote: %s:%s' % (args.remotehost, args.remoteport)) 116 | print('sslboth: ', args.sslboth) 117 | print('sslout: ', args.sslout) 118 | print 119 | run(args) 120 | -------------------------------------------------------------------------------- /examples/server.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICITCCAYoCCQDsWkrgHK8oKzANBgkqhkiG9w0BAQUFADBVMQswCQYDVQQGEwJV 3 | UzELMAkGA1UECBMCQ0ExFjAUBgNVBAcTDVNhbiBGcmFuY2lzY28xITAfBgNVBAoT 4 | GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0xMTExMTMwMDE0NDJaFw0xMjEx 5 | MTIwMDE0NDJaMFUxCzAJBgNVBAYTAlVTMQswCQYDVQQIEwJDQTEWMBQGA1UEBxMN 6 | U2FuIEZyYW5jaXNjbzEhMB8GA1UEChMYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRk 7 | MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDKFhta9t8pH4lsvQXglOT7uY7z 8 | 3dQbpnkdHj80B4M1GQHwFumjo1A5TYNIQ7+HLleJhmPJGmhyM0fKdZavqKvSHzmQ 9 | RcaUa9WQsi/eOOJ3E9w83bvbqGgHAeB6gP4IWABurJTY+aAqdl8QvD4QLxpAB0Au 10 | yX8Fkpne2RYF1xYG7QIDAQABMA0GCSqGSIb3DQEBBQUAA4GBAL6vAj1ciMGNumdx 11 | hnL4GLyE5Rdug08SVdcHs9SC4S9xx6mOOfHCQW47Z0EaIgjABu4N41oGN/Tk1MiM 12 | 4qI1NJsfeNLs3DJyaaNJ+s8jG7lk1YkgscimE4PdsjMm09hPNMrAYe3kqUMtHqyq 13 | Sjabkogu1YdPJPwST6iV+u+MDCa3 14 | -----END CERTIFICATE----- 15 | -------------------------------------------------------------------------------- /examples/server.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIICXAIBAAKBgQDKFhta9t8pH4lsvQXglOT7uY7z3dQbpnkdHj80B4M1GQHwFumj 3 | o1A5TYNIQ7+HLleJhmPJGmhyM0fKdZavqKvSHzmQRcaUa9WQsi/eOOJ3E9w83bvb 4 | qGgHAeB6gP4IWABurJTY+aAqdl8QvD4QLxpAB0AuyX8Fkpne2RYF1xYG7QIDAQAB 5 | AoGAHBaIhNMtX1Tfz/pR1846KXa4FNMvSQyRZueuVzf4F6g7Kbi1jqYDX4OTjLbF 6 | 5y8cwaOpOOlvvPe9sbk4UX/7KYDcwbfFAiFX1kyV2Lc95J2jKqwhAfksiTQhB/mt 7 | D3hWLcwq2jVOXQXSyQp4GRwrtLYYbIux7WdxlusLf8eHHyECQQD7SHvRL1G3F6xE 8 | wHx5cQwVJJMjBYxr6IwL0QtqJFT/aWIJtWq0zsxsDDF5agQVQSp5kP+pZl907IHE 9 | gz9BMQ1lAkEAzeE2gLYfJP2HrBMxJ0U+CqrzhbeD3sGu0pYbEPnDYd/Omc4UeMtY 10 | BVObTFoH/X8DkoriOrKSdfPIQGA4r/Me6QJBAJLbs/F3tExLe5Ta4mSfWy5oJ84K 11 | Ch1u1Zp6XC92eG6linSeIHT3f6WOIsQQ374ETeyqf6Djgdp19wmAo1FYd7kCQAVQ 12 | tzODgDJYSVRr+mzlIMDtwPPG1SS/I2BUd8ZsbFruiEr4QxcLSO56RhwmhuZIjTMP 13 | Wt/hFF7vOFBRK6V/RWECQHJ2iyRN5QI+kCrUetY00WwQmu3b9pGyXKSGjt+vwd7i 14 | yM03YJXetiWERB5oyqyrvOiY4QW3YOuY9sKKB3ejRHM= 15 | -----END RSA PRIVATE KEY----- 16 | -------------------------------------------------------------------------------- /examples/ssl_client.py: -------------------------------------------------------------------------------- 1 | import smtplib 2 | 3 | msg = """From: foo@localhost 4 | To: bar@localhost 5 | 6 | Here's my message! 7 | """ 8 | 9 | server = smtplib.SMTP_SSL('localhost', port=1025) 10 | server.set_debuglevel(1) 11 | server.login('bcoe', 'foobar') 12 | server.sendmail('foo@localhost', ['bar@localhost'], msg) 13 | server.quit() 14 | -------------------------------------------------------------------------------- /examples/ssl_server.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from secure_smtpd import SMTPServer, FakeCredentialValidator, LOG_NAME 3 | 4 | class SSLSMTPServer(SMTPServer): 5 | def process_message(self, peer, mailfrom, rcpttos, message_data): 6 | print(message_data) 7 | 8 | logger = logging.getLogger( LOG_NAME ) 9 | logger.setLevel(logging.INFO) 10 | 11 | server = SSLSMTPServer( 12 | ('0.0.0.0', 1025), 13 | None, 14 | require_authentication=True, 15 | ssl=True, 16 | certfile='examples/server.crt', 17 | keyfile='examples/server.key', 18 | credential_validator=FakeCredentialValidator(), 19 | maximum_execution_time = 1.0 20 | ) 21 | 22 | server.run() 23 | -------------------------------------------------------------------------------- /secure_smtpd/__init__.py: -------------------------------------------------------------------------------- 1 | import secure_smtpd.config 2 | from secure_smtpd.config import LOG_NAME 3 | from .smtp_server import SMTPServer 4 | from .fake_credential_validator import FakeCredentialValidator 5 | from .proxy_server import ProxyServer 6 | -------------------------------------------------------------------------------- /secure_smtpd/config/__init__.py: -------------------------------------------------------------------------------- 1 | from . import log 2 | from .log import LOG_NAME 3 | -------------------------------------------------------------------------------- /secure_smtpd/config/log.py: -------------------------------------------------------------------------------- 1 | import logging, sys 2 | from logging.handlers import RotatingFileHandler 3 | from logging import StreamHandler 4 | 5 | LOG_NAME = 'secure-smtpd' 6 | 7 | class Log(object): 8 | 9 | def __init__(self, log_name): 10 | self.log_name = log_name 11 | self.logger = logging.getLogger( self.log_name ) 12 | self._remove_handlers() 13 | self._add_handler() 14 | self.logger.setLevel(logging.DEBUG) 15 | 16 | def _remove_handlers(self): 17 | for handler in self.logger.handlers: 18 | self.logger.removeHandler(handler) 19 | 20 | def _add_handler(self): 21 | try: 22 | handler = RotatingFileHandler( 23 | '/var/log/%s.log' % self.log_name, 24 | maxBytes=10485760, 25 | backupCount=3 26 | ) 27 | formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") 28 | handler.setFormatter(formatter) 29 | self.logger.addHandler(handler) 30 | except IOError: 31 | self.logger.addHandler(StreamHandler(sys.stderr)) 32 | 33 | Log(LOG_NAME) -------------------------------------------------------------------------------- /secure_smtpd/fake_credential_validator.py: -------------------------------------------------------------------------------- 1 | import secure_smtpd 2 | import logging 3 | 4 | # Implment this interface with an actual 5 | # methodlogy for validating credentials, e.g., 6 | # lookup credentials for a user in Redis. 7 | class FakeCredentialValidator(object): 8 | 9 | def validate(self, username, password): 10 | 11 | logger = logging.getLogger( secure_smtpd.LOG_NAME ) 12 | logger.warn('FakeCredentialValidator: you should replace this with an actual implementation of a credential validator.') 13 | 14 | if username == 'bcoe' and password == 'foobar': 15 | return True 16 | return False -------------------------------------------------------------------------------- /secure_smtpd/process_pool.py: -------------------------------------------------------------------------------- 1 | import time 2 | from multiprocessing import Process, Queue 3 | 4 | class ProcessPool(object): 5 | 6 | def __init__(self, func, process_count=5): 7 | self.func = func 8 | self.process_count = process_count 9 | self.queue = Queue() 10 | self._create_processes() 11 | 12 | def _create_processes(self): 13 | for i in range(0, self.process_count): 14 | process = Process(target=self.func, args=[self.queue]) 15 | process.daemon = True 16 | process.start() 17 | -------------------------------------------------------------------------------- /secure_smtpd/proxy_server.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import smtplib 3 | import secure_smtpd 4 | from .smtp_server import SMTPServer 5 | from .store_credentials import StoreCredentials 6 | 7 | class ProxyServer(SMTPServer): 8 | """Implements an open relay. Inherits from secure_smtpd, so can handle 9 | SSL incoming. Modifies attributes slightly: 10 | 11 | * if "ssl" is true accepts SSL connections inbound and connects via SSL 12 | outbound 13 | * adds "ssl_out_only", which can be set to True when "ssl" is False so that 14 | inbound connections are in plain text but outbound are in SSL 15 | * adds "debug", which if True copies all inbound messages to logger.info() 16 | * ignores any credential validators, passing any credentials upstream 17 | """ 18 | def __init__(self, *args, **kwargs): 19 | self.ssl_out_only = False 20 | if 'ssl_out_only' in kwargs: 21 | self.ssl_out_only = kwargs.pop('ssl_out_only') 22 | 23 | self.debug = False 24 | if 'debug' in kwargs: 25 | self.debug = kwargs.pop('debug') 26 | 27 | if kwargs['credential_validator'] is None: 28 | kwargs['credential_validator'] = StoreCredentials() 29 | 30 | SMTPServer.__init__(self, *args, **kwargs) 31 | 32 | def process_message(self, peer, mailfrom, rcpttos, data): 33 | if self.debug: 34 | # ------------------------ 35 | # stolen directly from stmpd.DebuggingServer 36 | inheaders = 1 37 | lines = data.split('\n') 38 | self.logger.info('---------- MESSAGE FOLLOWS ----------') 39 | for line in lines: 40 | # headers first 41 | if inheaders and not line: 42 | self.logger.info('X-Peer: %s', peer[0]) 43 | inheaders = 0 44 | self.logger.info(line) 45 | self.logger.info('------------ END MESSAGE ------------') 46 | 47 | # ------------------------ 48 | # following code is direct from smtpd.PureProxy 49 | lines = data.split('\n') 50 | # Look for the last header 51 | i = 0 52 | for line in lines: 53 | if not line: 54 | break 55 | i += 1 56 | lines.insert(i, 'X-Peer: %s' % peer[0]) 57 | data = '\n'.join(lines) 58 | self._deliver(mailfrom, rcpttos, data) 59 | 60 | def _deliver(self, mailfrom, rcpttos, data): 61 | # ------------------------ 62 | # following code is adapted from smtpd.PureProxy with modifications to 63 | # handle upstream SSL 64 | refused = {} 65 | try: 66 | if self.ssl or self.ssl_out_only: 67 | s = smtplib.SMTP_SSL() 68 | else: 69 | s = smtplib.SMTP() 70 | 71 | s.connect(self._remoteaddr[0], self._remoteaddr[1]) 72 | if self.credential_validator.stored: 73 | # we had credentials passed in, use them 74 | s.login( 75 | self.credential_validator.username, 76 | self.credential_validator.password 77 | ) 78 | try: 79 | refused = s.sendmail(mailfrom, rcpttos, data) 80 | if refused != {}: 81 | self.logger.error('some connections refused %s', refused) 82 | finally: 83 | s.quit() 84 | except smtplib.SMTPRecipientsRefused as e: 85 | self.logger.exception('') 86 | refused = e.recipients 87 | except (socket.error, smtplib.SMTPException) as e: 88 | self.logger.exception('') 89 | 90 | # All recipients were refused. If the exception had an associated 91 | # error code, use it. Otherwise,fake it with a non-triggering 92 | # exception code. 93 | errcode = getattr(e, 'smtp_code', -1) 94 | errmsg = getattr(e, 'smtp_error', 'ignore') 95 | for r in rcpttos: 96 | refused[r] = (errcode, errmsg) 97 | return refused 98 | -------------------------------------------------------------------------------- /secure_smtpd/smtp_channel.py: -------------------------------------------------------------------------------- 1 | import secure_smtpd 2 | import smtpd 3 | import base64 4 | import secure_smtpd 5 | import asynchat 6 | import logging 7 | 8 | from asyncore import ExitNow 9 | NEWLINE = '\n' 10 | EMPTYSTRING = '' 11 | 12 | 13 | def decode_b64(data): 14 | '''Wrapper for b64decode, without having to struggle with bytestrings.''' 15 | byte_string = data.encode('utf-8') 16 | decoded = base64.b64decode(byte_string) 17 | return decoded.decode('utf-8') 18 | 19 | 20 | def encode_b64(data): 21 | '''Wrapper for b64encode, without having to struggle with bytestrings.''' 22 | byte_string = data.encode('utf-8') 23 | encoded = base64.b64encode(byte_string) 24 | return encoded.decode('utf-8') 25 | 26 | 27 | class SMTPChannel(smtpd.SMTPChannel): 28 | 29 | def __init__(self, smtp_server, newsocket, fromaddr, require_authentication=False, credential_validator=None, map=None): 30 | smtpd.SMTPChannel.__init__(self, smtp_server, newsocket, fromaddr) 31 | asynchat.async_chat.__init__(self, newsocket, map=map) 32 | 33 | self.require_authentication = require_authentication 34 | self.authenticating = False 35 | self.authenticated = False 36 | self.username = None 37 | self.password = None 38 | self.credential_validator = credential_validator 39 | self.logger = logging.getLogger(secure_smtpd.LOG_NAME) 40 | 41 | def smtp_QUIT(self, arg): 42 | self.push('221 Bye') 43 | self.close_when_done() 44 | raise ExitNow() 45 | 46 | def collect_incoming_data(self, data): 47 | if not isinstance(data, str): 48 | # We're on python3, so we have to decode the bytestring 49 | data = data.decode('utf-8') 50 | self.__line.append(data) 51 | 52 | def smtp_EHLO(self, arg): 53 | if not arg: 54 | self.push('501 Syntax: HELO hostname') 55 | return 56 | if self.__greeting: 57 | self.push('503 Duplicate HELO/EHLO') 58 | else: 59 | self.__greeting = arg 60 | self.push('250-%s Hello %s' % (self.__fqdn, arg)) 61 | self.push('250-AUTH LOGIN PLAIN') 62 | self.push('250 EHLO') 63 | 64 | def smtp_AUTH(self, arg): 65 | if 'PLAIN' in arg: 66 | split_args = arg.split(' ') 67 | # second arg is Base64-encoded string of blah\0username\0password 68 | authbits = decode_b64(split_args[1]).split('\0') 69 | self.username = authbits[1] 70 | self.password = authbits[2] 71 | if self.credential_validator and self.credential_validator.validate(self.username, self.password): 72 | self.authenticated = True 73 | self.push('235 Authentication successful.') 74 | else: 75 | self.push('454 Temporary authentication failure.') 76 | raise ExitNow() 77 | 78 | elif 'LOGIN' in arg: 79 | self.authenticating = True 80 | split_args = arg.split(' ') 81 | 82 | # Some implmentations of 'LOGIN' seem to provide the username 83 | # along with the 'LOGIN' stanza, hence both situations are 84 | # handled. 85 | if len(split_args) == 2: 86 | self.username = decode_b64(arg.split(' ')[1]) 87 | self.push('334 ' + encode_b64('Username')) 88 | else: 89 | self.push('334 ' + encode_b64('Username')) 90 | 91 | elif not self.username: 92 | self.username = decode_b64(arg) 93 | self.push('334 ' + encode_b64('Password')) 94 | else: 95 | self.authenticating = False 96 | self.password = decode_b64(arg) 97 | if self.credential_validator and self.credential_validator.validate(self.username, self.password): 98 | self.authenticated = True 99 | self.push('235 Authentication successful.') 100 | else: 101 | self.push('454 Temporary authentication failure.') 102 | raise ExitNow() 103 | 104 | # This code is taken directly from the underlying smtpd.SMTPChannel 105 | # support for AUTH is added. 106 | def found_terminator(self): 107 | line = EMPTYSTRING.join(self.__line) 108 | 109 | if self.debug: 110 | self.logger.info('found_terminator(): data: %s' % repr(line)) 111 | 112 | self.__line = [] 113 | if self.__state == self.COMMAND: 114 | if not line: 115 | self.push('500 Error: bad syntax') 116 | return 117 | method = None 118 | i = line.find(' ') 119 | 120 | if self.authenticating: 121 | # If we are in an authenticating state, call the 122 | # method smtp_AUTH. 123 | arg = line.strip() 124 | command = 'AUTH' 125 | elif i < 0: 126 | command = line.upper() 127 | arg = None 128 | else: 129 | command = line[:i].upper() 130 | arg = line[i + 1:].strip() 131 | 132 | # White list of operations that are allowed prior to AUTH. 133 | if not command in ['AUTH', 'EHLO', 'HELO', 'NOOP', 'RSET', 'QUIT']: 134 | if self.require_authentication and not self.authenticated: 135 | self.push('530 Authentication required') 136 | return 137 | 138 | method = getattr(self, 'smtp_' + command, None) 139 | if not method: 140 | self.push('502 Error: command "%s" not implemented' % command) 141 | return 142 | method(arg) 143 | return 144 | else: 145 | if self.__state != self.DATA: 146 | self.push('451 Internal confusion') 147 | return 148 | # Remove extraneous carriage returns and de-transparency according 149 | # to RFC 821, Section 4.5.2. 150 | data = [] 151 | for text in line.split('\r\n'): 152 | if text and text[0] == '.': 153 | data.append(text[1:]) 154 | else: 155 | data.append(text) 156 | self.__data = NEWLINE.join(data) 157 | status = self.__server.process_message( 158 | self.__peer, 159 | self.__mailfrom, 160 | self.__rcpttos, 161 | self.__data 162 | ) 163 | self.__rcpttos = [] 164 | self.__mailfrom = None 165 | self.__state = self.COMMAND 166 | self.set_terminator(b'\r\n') 167 | if not status: 168 | self.push('250 Ok') 169 | else: 170 | self.push(status) 171 | -------------------------------------------------------------------------------- /secure_smtpd/smtp_server.py: -------------------------------------------------------------------------------- 1 | import secure_smtpd 2 | import ssl, smtpd, asyncore, socket, logging, signal, time, sys 3 | 4 | from .smtp_channel import SMTPChannel 5 | from asyncore import ExitNow 6 | from .process_pool import ProcessPool 7 | from ssl import SSLError 8 | try: 9 | from Queue import Empty 10 | except ImportError: 11 | # We're on python3 12 | from queue import Empty 13 | 14 | class SMTPServer(smtpd.SMTPServer): 15 | 16 | def __init__(self, localaddr, remoteaddr, ssl=False, certfile=None, keyfile=None, ssl_version=ssl.PROTOCOL_SSLv23, require_authentication=False, credential_validator=None, maximum_execution_time=30, process_count=5): 17 | smtpd.SMTPServer.__init__(self, localaddr, remoteaddr) 18 | self.logger = logging.getLogger( secure_smtpd.LOG_NAME ) 19 | self.certfile = certfile 20 | self.keyfile = keyfile 21 | self.ssl_version = ssl_version 22 | self.subprocesses = [] 23 | self.require_authentication = require_authentication 24 | self.credential_validator = credential_validator 25 | self.ssl = ssl 26 | self.maximum_execution_time = maximum_execution_time 27 | self.process_count = process_count 28 | self.process_pool = None 29 | 30 | def handle_accept(self): 31 | self.process_pool = ProcessPool(self._accept_subprocess, process_count=self.process_count) 32 | self.close() 33 | 34 | def _accept_subprocess(self, queue): 35 | while True: 36 | try: 37 | newsocket = None 38 | self.socket.setblocking(1) 39 | pair = self.accept() 40 | map = {} 41 | 42 | if pair is not None: 43 | 44 | self.logger.info('_accept_subprocess(): smtp connection accepted within subprocess.') 45 | 46 | newsocket, fromaddr = pair 47 | newsocket.settimeout(self.maximum_execution_time) 48 | 49 | if self.ssl: 50 | newsocket = ssl.wrap_socket( 51 | newsocket, 52 | server_side=True, 53 | certfile=self.certfile, 54 | keyfile=self.keyfile, 55 | ssl_version=self.ssl_version, 56 | ) 57 | channel = SMTPChannel( 58 | self, 59 | newsocket, 60 | fromaddr, 61 | require_authentication=self.require_authentication, 62 | credential_validator=self.credential_validator, 63 | map=map 64 | ) 65 | 66 | self.logger.info('_accept_subprocess(): starting asyncore within subprocess.') 67 | 68 | asyncore.loop(map=map) 69 | 70 | self.logger.error('_accept_subprocess(): asyncore loop exited.') 71 | except (ExitNow, SSLError): 72 | self._shutdown_socket(newsocket) 73 | self.logger.info('_accept_subprocess(): smtp channel terminated asyncore.') 74 | except Exception as e: 75 | if newsocket is not None: 76 | self._shutdown_socket(newsocket) 77 | self.logger.error('_accept_subprocess(): uncaught exception: %s' % str(e)) 78 | 79 | def _shutdown_socket(self, s): 80 | try: 81 | s.shutdown(socket.SHUT_RDWR) 82 | s.close() 83 | except Exception as e: 84 | self.logger.error('_shutdown_socket(): failed to cleanly shutdown socket: %s' % str(e)) 85 | 86 | 87 | def run(self): 88 | asyncore.loop() 89 | if hasattr(signal, 'SIGTERM'): 90 | def sig_handler(signal,frame): 91 | self.logger.info("Got signal %s, shutting down." % signal) 92 | sys.exit(0) 93 | signal.signal(signal.SIGTERM, sig_handler) 94 | while 1: 95 | time.sleep(1) 96 | -------------------------------------------------------------------------------- /secure_smtpd/store_credentials.py: -------------------------------------------------------------------------------- 1 | class StoreCredentials(object): 2 | def __init__(self): 3 | self.stored = False 4 | self.username = None 5 | self.password = None 6 | 7 | def validate(self, username, password): 8 | self.stored = True 9 | self.username = username 10 | self.password = password 11 | return True 12 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | #from distutils.core import setup 3 | from setuptools import setup, find_packages 4 | 5 | with open('README.markdown') as f: 6 | long_description = f.read() 7 | 8 | setup( 9 | name="secure-smtpd", 10 | version="3.0.0", 11 | description="Adds support for SSL, AUTH, and other goodies, to Petri Lehtinen's SMTPD library.", 12 | long_description=long_description, 13 | author="Benjamin Coe", 14 | author_email="bencoe@gmail.com", 15 | url="https://github.com/bcoe/secure-smtpd", 16 | keywords='secure ssl auth smtp smtpd server', 17 | license='ISC', 18 | packages = find_packages(), 19 | install_requires = [ 20 | 'argparse' 21 | ], 22 | tests_require=[ 23 | 'nose' 24 | ], 25 | include_package_data=True, 26 | zip_safe=False, 27 | classifiers=[ # See: http://pypi.python.org/pypi?%3Aaction=list_classifiers 28 | 'Development Status :: 4 - Beta', 29 | 'Topic :: Communications :: Email :: Mail Transport Agents', 30 | 'Programming Language :: Python :: 2', 31 | 'Programming Language :: Python :: 2.7', 32 | 'Programming Language :: Python :: 3', 33 | 'Programming Language :: Python :: 3.2', 34 | 'License :: OSI Approved :: ISC License (ISCL)', 35 | ], 36 | ) 37 | --------------------------------------------------------------------------------