Python aiosmtpd package: Building an automatic email responder in Tornado

Recently, after receiving a phishing email that looked pretty legit, I had an idea. What if there was a service to help people identify whether any particular email is a phishing email or not? Enter www.phishbash.com

The idea is simple. Just forward an email to a certain email address and an automated process will summarize it and send a report back. In order to do this I had to figure out how to recieve emails – this will live on AWS so we’re using SES to send emails. I also wanted to do this with Tornado which is my webserver of choice and eventually there will be some web functionality.

Turns out this is pretty easy with the python aiosmtpd package:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
import os
import email
import datetime

import tornado.httpserver
import tornado.websocket
import tornado.web
import argparse

from tornado.ioloop import IOLoop
from tornado import gen
from aiosmtpd.controller import Controller

#===============================================================================
#===============================================================================

class HealthCheckHandler(tornado.web.RequestHandler):
    @gen.coroutine
    def get(self):
        self.write('Still here ' + datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") )
        self.finish()

#===============================================================================
#===============================================================================

class SMTPHandler:
    @gen.coroutine
    def handle_RCPT(self, server, session, envelope, address, rcpt_options):

        # Filter valid email addresses
        if address not in ["email@domain.com"]: # Replace with email address of service
            return '550 invalid email address'

        envelope.rcpt_tos.append(address)
        return '250 OK'

    @gen.coroutine
    def handle_DATA(self, server, session, envelope):
        try:
            parsed_email = email.message_from_string(envelope.content.decode('utf8', errors='replace'))

            header_to = parsed_email['to']
            header_from = parsed_email['from']
            header_subject = parsed_email['subject']

            if header_from.endswith("domain.com"):  # Replace with domain of service
                raise RuntimeError("header_from is from domain.com")

            payload_plain = ""
            payload_html = ""
            attachments = []

            # Multipart messages
            if parsed_email.is_multipart():
                for part in parsed_email.walk():
                    ctype = part.get_content_type()
                    cdispo = str(part.get('Content-Disposition'))

                    # skip any text/plain (txt) attachments
                    if 'attachment' in cdispo:
                        filename = part.get_filename()
                        attachments.append(filename)
                    elif 'text/plain' in ctype:
                        payload_string = part.get_payload(decode=True).decode('utf8', errors='replace')
                        payload_plain += payload_string
                    elif 'text/html' in ctype:
                        payload_string = part.get_payload(decode=True).decode('utf8', errors='replace')
                        payload_html += payload_string

            # Not multipart - i.e. plain text, no attachments, keeping fingers crossed
            else:
                ctype = parsed_email.get_content_type()

                if ctype == 'text/plain':
                    payload_string = parsed_email.get_payload(decode=True).decode('utf8', errors='replace')
                    payload_plain += payload_string
                elif ctype == 'text/html':
                    payload_string = parsed_email.get_payload(decode=True).decode('utf8', errors='replace')
                    payload_html += payload_string

            # Do further processing

        except Exception as e:
            pass

        return '250 Message accepted for delivery'

#===============================================================================
# Build Tornado web server
#===============================================================================

APP_SETTINGS = {
    "cookie_secret": "SomeCookieSecretString",
    "login_url": "/index.html"
}

application = tornado.web.Application(
    [
        # Health Checks
        (r'/healthcheck', HealthCheckHandler),
    ],
    debug=(os.environ.get('DEBUG_MODE') == 'devel'),
    **APP_SETTINGS
)

#===============================================================================
#===============================================================================

if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument('--port', help='Server Port')
    parser.add_argument('--smtpport', help='SMTP Port')
    args = parser.parse_args()

    # Begin SMTP server
    smtp_server = Controller(SMTPHandler(), hostname='0.0.0.0', port=int(args.smtpport))
    smtp_server.start()

    # Begin Web server
    http_server = tornado.httpserver.HTTPServer(application)
    http_server.listen(int(args.port))

    IOLoop.instance().start()

#===============================================================================
#===============================================================================