Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions maildump/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,17 @@
stopper = None


def start(http_host, http_port, smtp_host, smtp_port, db_path=None):
def start(http_host, http_port, smtp_host, smtp_port, smtp_auth, db_path=None):
global stopper
# Webserver
log.notice('Starting web server on http://{0}:{1}'.format(http_host, http_port))
http_server = WSGIServer((http_host, http_port), app)
stopper = http_server.close
# SMTP server
log.notice('Starting smtp server on {0}:{1}'.format(smtp_host, smtp_port))
SMTPServer((smtp_host, smtp_port), smtp_handler)
if smtp_auth:
log.notice('Enabled SMTP authorization with htpasswd file {0}'.format(smtp_auth))
SMTPServer((smtp_host, smtp_port), smtp_handler, smtp_auth)
gevent.spawn(asyncore.loop)
# Database
connect(db_path)
Expand Down
100 changes: 99 additions & 1 deletion maildump/smtp.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,115 @@
import base64
import smtpd
from email.parser import BytesParser

from logbook import Logger
from passlib.apache import HtpasswdFile

from maildump.db import add_message

log = Logger(__name__)


class SMTPChannel(smtpd.SMTPChannel, object):
def __init__(self, server, conn, addr, smtp_auth):
super(SMTPChannel, self).__init__(server, conn, addr)
self._smtp_auth = smtp_auth
self._authorized = False

def is_valid_user(self, auth_data):
auth_data_splitted = auth_data.split(b'\x00')
if len(auth_data_splitted) != 3:
return False

if not auth_data.startswith(b'\x00') and auth_data_splitted[0] != auth_data_splitted[1]:
return False

return self._smtp_auth.check_password(auth_data_splitted[1], auth_data_splitted[2])

def smtp_EHLO(self, arg):
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need to override all this? A quick google search pointed me to this patch from this python issue where someone tried to get smtp auth support upstream, and they don't override EHLO. However, I don't know how good that implementation is.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know how in other way push info about AUTH PLAIN to capabilities.

if not arg:
self.push('501 Syntax: EHLO hostname')
return
# See issue #21783 for a discussion of this behavior.
if self.seen_greeting:
self.push('503 Duplicate HELO/EHLO')
return
self._set_rset_state()
self.seen_greeting = arg
self.extended_smtp = True
self.push('250-%s' % self.fqdn)
if self._smtp_auth:
self.push('250-AUTH PLAIN')
if self.data_size_limit:
self.push('250-SIZE %s' % self.data_size_limit)
self.command_size_limits['MAIL'] += 26
if not self._decode_data:
self.push('250-8BITMIME')
if self.enable_SMTPUTF8:
self.push('250-SMTPUTF8')
self.command_size_limits['MAIL'] += 10
self.push('250 HELP')

def smtp_AUTH(self, arg):
print('auth:', arg, file=smtpd.DEBUGSTREAM)
if not self._smtp_auth:
self.push('501 Syntax: AUTH not enabled')
return

if not arg:
self.push('501 Syntax: AUTH TYPE base64(username:password)')
return

if not arg.lower().startswith('plain '):
self.push('501 Syntax: only PLAIN auth possible')
return

auth_type, auth_data = arg.split(None, 1)
try:
auth_data = base64.b64decode(auth_data.strip())
except TypeError:
self.push('535 5.7.8 Authentication credentials invalid')
return

if self.is_valid_user(auth_data):
self.push('235 Authentication successful')
self._authorized = True
return

self._authorized = False
self.push('535 5.7.8 Authentication credentials invalid')

def smtp_MAIL(self, arg):
if self._smtp_auth and not self._authorized:
self.push('530 5.7.0 Authentication required')
return
super(SMTPChannel, self).smtp_MAIL(arg)

def smtp_RCPT(self, arg):
if self._smtp_auth and not self._authorized:
self.push('530 5.7.0 Authentication required')
return
super(SMTPChannel, self).smtp_RCPT(arg)

def smtp_DATA(self, arg):
if self._smtp_auth and not self._authorized:
self.push('530 5.7.0 Authentication required')
return
super(SMTPChannel, self).smtp_DATA(arg)


class SMTPServer(smtpd.SMTPServer, object):
def __init__(self, listener, handler):
def __init__(self, listener, handler, smtp_auth):
super(SMTPServer, self).__init__(listener, None)
self._handler = handler
self._smtp_auth = smtp_auth

def handle_accept(self):
pair = self.accept()
if pair is not None:
conn, addr = pair
print('Incoming connection from %s' % repr(addr), file=smtpd.DEBUGSTREAM)
channel = SMTPChannel(self, conn, addr, self._smtp_auth)

def process_message(self, peer, mailfrom, rcpttos, data, **kwargs):
return self._handler(sender=mailfrom, recipients=rcpttos, body=data)
Expand Down
19 changes: 17 additions & 2 deletions maildump_runner/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ def main():
parser = argparse.ArgumentParser()
parser.add_argument('--smtp-ip', default='127.0.0.1', metavar='IP', help='SMTP ip (default: 127.0.0.1)')
parser.add_argument('--smtp-port', default=1025, type=int, metavar='PORT', help='SMTP port (default: 1025)')
parser.add_argument('--smtp-auth', metavar='HTPASSWD', help='Apache-style htpasswd file for SMTP authorization. '
'WARNING: do not rely only on this as a security '
'mechanism, use also additional methods for securing '
'MailDump instance, ie. IP restrictions.')
parser.add_argument('--http-ip', default='127.0.0.1', metavar='IP', help='HTTP ip (default: 127.0.0.1)')
parser.add_argument('--http-port', default=1080, type=int, metavar='PORT', help='HTTP port (default: 1080)')
parser.add_argument('--db', metavar='PATH', help='SQLite database - in-memory if missing')
Expand Down Expand Up @@ -87,11 +91,19 @@ def main():
args.htpasswd = os.path.abspath(args.htpasswd)
print('Htpasswd path is relative, using {0}'.format(args.htpasswd))

if args.smtp_auth and not os.path.isabs(args.smtp_auth):
args.smtp_auth = os.path.abspath(args.smtp_auth)
print('SMTP Htpasswd path is relative, using {0}'.format(args.smtp_auth))
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
print('SMTP Htpasswd path is relative, using {0}'.format(args.smtp_auth))
print('Htpasswd path for SMTP AUTH is relative, using {0}'.format(args.smtp_auth))


# Check if the password file is valid
if args.htpasswd and not os.path.isfile(args.htpasswd):
print('Htpasswd file does not exist')
sys.exit(1)

if args.smtp_auth and not os.path.isfile(args.smtp_auth):
print('SMTP Htpasswd file does not exist')
sys.exit(1)

daemon_kw = {
'monkey_greenlet_report': False,
'signal_map': {signal.SIGTERM: terminate_server, signal.SIGINT: terminate_server},
Expand Down Expand Up @@ -133,23 +145,26 @@ def main():

app.debug = args.debug
app.config['MAILDUMP_HTPASSWD'] = None
if args.htpasswd:
if args.htpasswd or args.smtp_auth:
# passlib is broken on py39, hence the local import
# https://foss.heptapod.net/python-libs/passlib/-/issues/115
try:
from passlib.apache import HtpasswdFile
except OSError:
print('Are you using Python 3.9? If yes, authentication is currently not available due to a bug.\n\n')
raise

if args.htpasswd:
app.config['MAILDUMP_HTPASSWD'] = HtpasswdFile(args.htpasswd)
app.config['MAILDUMP_NO_QUIT'] = args.no_quit
smtp_auth = HtpasswdFile(args.smtp_auth) if args.smtp_auth else None

level = logbook.DEBUG if args.debug else logbook.INFO
format_string = u'[{record.time:%Y-%m-%d %H:%M:%S}] {record.level_name:<8} {record.channel}: {record.message}'
stderr_handler = ColorizedStderrHandler(level=level, format_string=format_string)
with NullHandler().applicationbound():
with stderr_handler.applicationbound():
start(args.http_ip, args.http_port, args.smtp_ip, args.smtp_port, args.db)
start(args.http_ip, args.http_port, args.smtp_ip, args.smtp_port, smtp_auth, args.db)


if __name__ == '__main__':
Expand Down