opentrashmail/python/mailserver3.py

256 lines
10 KiB
Python
Raw Normal View History

2023-11-13 20:04:58 +00:00
import asyncio
2023-11-24 18:59:31 +00:00
import ssl
2023-11-13 20:04:58 +00:00
from aiosmtpd.controller import Controller
from email.parser import BytesParser
from email import policy
import os
2023-11-13 20:04:58 +00:00
import re
import time
import json
2023-11-21 21:33:44 +00:00
import hashlib
import configparser
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import logging
2023-11-25 15:06:04 +00:00
from pprint import pprint
2023-11-13 20:04:58 +00:00
logger = logging.getLogger(__name__)
# globals for settings
DISCARD_UNKNOWN = False
DELETE_OLDER_THAN_DAYS = False
2023-11-22 11:26:09 +00:00
ATTACHMENTS_MAX_SIZE = 0
2023-11-13 20:04:58 +00:00
DOMAINS = []
LAST_CLEANUP = 0
URL = ""
MAILPORT_TLS = 0
2023-11-24 18:59:31 +00:00
TLS_CERTIFICATE = ""
TLS_PRIVATE_KEY = ""
2023-11-13 20:04:58 +00:00
class CustomHandler:
2023-11-29 10:09:26 +00:00
connection_type = ''
def __init__(self,conntype='Plaintext'):
self.connection_type = conntype
2023-11-13 20:04:58 +00:00
async def handle_DATA(self, server, session, envelope):
peer = session.peer
rcpts = []
for rcpt in envelope.rcpt_tos:
rcpts.append(rcpt)
2023-11-29 10:09:26 +00:00
logger.debug('Receiving message from: %s (%s)', peer,self.connection_type)
logger.debug('Message addressed from: %s' % envelope.mail_from)
logger.debug('Message addressed to: %s' % str(rcpts))
filenamebase = str(int(round(time.time() * 1000)))
# Get the raw email data
raw_email = envelope.content.decode('utf-8')
# Parse the email
message = BytesParser(policy=policy.default).parsebytes(envelope.content)
# Separate HTML and plaintext parts
plaintext = ''
html = ''
attachments = {}
for part in message.walk():
if part.get_content_maintype() == 'multipart':
continue
if part.get_content_type() == 'text/plain':
2023-11-21 21:33:44 +00:00
#if it's a file
if part.get_filename() is not None:
2023-11-22 11:26:09 +00:00
att = self.handleAttachment(part)
if(att == False):
return '500 Attachment too large. Max size: ' + str(ATTACHMENTS_MAX_SIZE/1000000)+"MB"
attachments['file%d' % len(attachments)] = att
2023-11-21 21:33:44 +00:00
else:
plaintext += part.get_payload(decode=True).decode('utf-8')
elif part.get_content_type() == 'text/html':
html += part.get_payload()
else:
2023-11-22 11:26:09 +00:00
att = self.handleAttachment(part)
if(att == False):
return '500 Attachment too large. Max size: ' + str(ATTACHMENTS_MAX_SIZE/1000000)+"MB"
attachments['file%d' % len(attachments)] = att
2023-11-13 20:04:58 +00:00
for em in rcpts:
2023-11-13 20:04:58 +00:00
em = em.lower()
if not re.match(r"[^@\s]+@[^@\s]+\.[a-zA-Z0-9]+$", em):
logger.exception('Invalid recipient: %s' % em)
continue
domain = em.split('@')[1]
found = False
for x in DOMAINS:
if "*" in x and domain.endswith(x.replace('*', '')):
found = True
elif domain == x:
found = True
if(DISCARD_UNKNOWN and found==False):
logger.info('Discarding email for unknown domain: %s' % domain)
continue
if not os.path.exists("../data/"+em):
os.mkdir( "../data/"+em, 0o755 )
edata = {
'subject': message['subject'],
'body': plaintext,
'htmlbody': self.replace_cid_with_attachment_id(html, attachments,filenamebase,em),
'from': message['from'],
'attachments':[],
'attachments_details':[]
}
savedata = {'sender_ip':peer[0],
'from':message['from'],
'rcpts':rcpts,
'raw':raw_email,
'parsed':edata
}
2023-11-13 20:04:58 +00:00
#same attachments if any
for att in attachments:
if not os.path.exists("../data/"+em+"/attachments"):
os.mkdir( "../data/"+em+"/attachments", 0o755 )
attd = attachments[att]
2023-11-21 21:33:44 +00:00
file_id = attd[3]
file = open("../data/"+em+"/attachments/"+file_id, 'wb')
2023-11-13 20:04:58 +00:00
file.write(attd[1])
file.close()
2023-11-21 21:33:44 +00:00
edata["attachments"].append(file_id)
edata["attachments_details"].append({
"filename":attd[0],
"cid":attd[2],
2023-11-21 21:33:44 +00:00
"id":attd[3],
"download_url":URL+"/api/attachment/"+em+"/"+file_id,
"size":len(attd[1])
})
2023-11-13 20:04:58 +00:00
# save actual json data
with open("../data/"+em+"/"+filenamebase+".json", "w") as outfile:
json.dump(savedata, outfile)
2023-11-24 18:59:31 +00:00
cleanup()
2023-11-13 20:04:58 +00:00
return '250 OK'
2023-11-21 21:33:44 +00:00
def handleAttachment(self, part):
filename = part.get_filename()
if filename is None:
filename = 'untitled'
cid = part.get('Content-ID')
if cid is not None:
cid = cid[1:-1]
elif part.get('X-Attachment-Id') is not None:
cid = part.get('X-Attachment-Id')
else: # else create a unique id using md5 of the attachment
cid = hashlib.md5(part.get_payload(decode=True)).hexdigest()
fid = hashlib.md5(filename.encode('utf-8')).hexdigest()+filename
logger.debug('Handling attachment: "%s" (ID: "%s") of type "%s" with CID "%s"',filename, fid,part.get_content_type(), cid)
2023-11-22 11:26:09 +00:00
if(ATTACHMENTS_MAX_SIZE > 0 and len(part.get_payload(decode=True)) > ATTACHMENTS_MAX_SIZE):
logger.info("Attachment too large: " + filename)
return False
2023-11-21 21:33:44 +00:00
return (filename,part.get_payload(decode=True),cid,fid)
def replace_cid_with_attachment_id(self, html_content, attachments,filenamebase,email):
# Replace cid references with attachment filename
for attachment_id in attachments:
attachment = attachments[attachment_id]
filename = attachment[0]
cid = attachment[2]
if cid is None:
continue
cid = cid[1:-1]
if cid is not None:
html_content = html_content.replace('cid:' + cid, "/api/attachment/"+email+"/"+filenamebase+"-"+filename)
return html_content
2023-11-13 20:04:58 +00:00
def cleanup():
if(DELETE_OLDER_THAN_DAYS == False or time.time() - LAST_CLEANUP < 86400):
return
logger.info("Cleaning up")
rootdir = '../data/'
for subdir, dirs, files in os.walk(rootdir):
for file in files:
if(file.endswith(".json")):
filepath = os.path.join(subdir, file)
file_modified = os.path.getmtime(filepath)
if(time.time() - file_modified > (DELETE_OLDER_THAN_DAYS * 86400)):
os.remove(filepath)
logger.info("Deleted file: " + filepath)
async def run(port):
2023-11-24 18:59:31 +00:00
if TLS_CERTIFICATE != "" and TLS_PRIVATE_KEY != "":
2023-11-24 18:59:31 +00:00
context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
context.load_cert_chain(TLS_CERTIFICATE, TLS_PRIVATE_KEY)
if MAILPORT_TLS > 0:
2023-11-29 10:09:26 +00:00
controller_tls = Controller(CustomHandler("TLS"), hostname='0.0.0.0', port=MAILPORT_TLS, ssl_context=context)
controller_tls.start()
2023-11-24 18:59:31 +00:00
2023-11-29 10:09:26 +00:00
controller_plaintext = Controller(CustomHandler("Plaintext or STARTTLS"), hostname='0.0.0.0', port=port,tls_context=context)
controller_plaintext.start()
2023-11-24 18:59:31 +00:00
logger.info("[i] Starting TLS only Mailserver on port " + str(MAILPORT_TLS))
logger.info("[i] Starting plaintext Mailserver (with STARTTLS support) on port " + str(port))
else:
2023-11-29 10:09:26 +00:00
controller_plaintext = Controller(CustomHandler("Plaintext"), hostname='0.0.0.0', port=port)
controller_plaintext.start()
logger.info("[i] Starting plaintext Mailserver on port " + str(port))
2023-11-25 15:06:04 +00:00
2023-11-24 18:59:31 +00:00
logger.info("[i] Ready to receive Emails")
logger.info("")
try:
while True:
await asyncio.sleep(1)
except KeyboardInterrupt:
2023-11-25 15:06:04 +00:00
controller_plaintext.stop()
if(MAILPORT_TLS > 0 and TLS_CERTIFICATE != "" and TLS_PRIVATE_KEY != ""):
controller_tls.stop()
2023-11-13 20:04:58 +00:00
if __name__ == '__main__':
ch = logging.StreamHandler()
ch.setLevel(logging.DEBUG)
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
ch.setFormatter(formatter)
logger.setLevel(logging.DEBUG)
logger.addHandler(ch)
if not os.path.isfile("../config.ini"):
logger.info("[ERR] Config.ini not found. Rename example.config.ini to config.ini. Defaulting to port 25")
2023-11-13 20:04:58 +00:00
port = 25
else:
2023-11-13 20:04:58 +00:00
Config = configparser.ConfigParser(allow_no_value=True)
Config.read("../config.ini")
port = int(Config.get("MAILSERVER", "MAILPORT"))
2023-11-13 20:04:58 +00:00
if("discard_unknown" in Config.options("MAILSERVER")):
DISCARD_UNKNOWN = (Config.get("MAILSERVER", "DISCARD_UNKNOWN").lower() == "true")
DOMAINS = Config.get("GENERAL", "DOMAINS").lower().split(",")
URL = Config.get("GENERAL", "URL")
2023-11-22 11:26:09 +00:00
if("attachments_max_size" in Config.options("MAILSERVER")):
ATTACHMENTS_MAX_SIZE = int(Config.get("MAILSERVER", "ATTACHMENTS_MAX_SIZE"))
2023-11-13 20:04:58 +00:00
if("CLEANUP" in Config.sections() and "delete_older_than_days" in Config.options("CLEANUP")):
DELETE_OLDER_THAN_DAYS = (Config.get("CLEANUP", "DELETE_OLDER_THAN_DAYS").lower() == "true")
2023-11-24 18:59:31 +00:00
if("mailport_tls" in Config.options("MAILSERVER")):
MAILPORT_TLS = int(Config.get("MAILSERVER", "MAILPORT_TLS"))
2023-11-24 18:59:31 +00:00
if("tls_certificate" in Config.options("MAILSERVER")):
TLS_CERTIFICATE = Config.get("MAILSERVER", "TLS_CERTIFICATE")
if("tls_private_key" in Config.options("MAILSERVER")):
TLS_PRIVATE_KEY = Config.get("MAILSERVER", "TLS_PRIVATE_KEY")
2023-11-13 20:04:58 +00:00
logger.info("[i] Discard unknown domains: " + str(DISCARD_UNKNOWN))
2023-11-22 11:26:09 +00:00
logger.info("[i] Max size of attachments: " + str(ATTACHMENTS_MAX_SIZE))
logger.info("[i] Listening for domains: " + str(DOMAINS))
2023-11-13 20:04:58 +00:00
asyncio.run(run(port))