Initial commit

This commit is contained in:
Kumi 2022-03-30 13:04:59 +02:00
commit 4a45a9360b
8 changed files with 207 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
venv/
*.pyc
__pycache__/
maildir/
*.pem

8
classes/authenticator.py Normal file
View file

@ -0,0 +1,8 @@
from aiosmtpd.smtp import AuthResult
class Authenticator:
def __init__(self, config):
self.config = config
def __call__(self, server, session, envelope, mechanism, auth_data):
return AuthResult(success=self.config.verify_password(auth_data.login.decode(), auth_data.password.decode()), handled=True)

50
classes/config.py Normal file
View file

@ -0,0 +1,50 @@
from configparser import ConfigParser
from socket import gethostname
from pathlib import Path
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHash
class Config:
def __init__(self, path):
self.config = ConfigParser()
self.path = path
self.config.read(path)
self.hash_passwords()
def hash_passwords(self):
hasher = PasswordHasher()
for user, password in self.config.items("USERS"):
try:
hasher.check_needs_rehash(password)
except InvalidHash:
self.config["USERS"][user] = hasher.hash(password)
with open(self.path, "w") as configfile:
self.config.write(configfile)
def verify_password(self, user, password):
hasher = PasswordHasher()
try:
hasher.verify(self.config["USERS"][user], password)
return True
except:
return False
@property
def hostname(self):
return self.config.get("SERVER", "hostname", fallback=gethostname())
@property
def port(self):
return self.config.getint("SERVER", "port", fallback=8025)
@property
def maildir(self):
path = self.config.get("SERVER", "maildir", fallback="maildir")
Path(path).mkdir(parents=True, exist_ok=True)
return path

39
classes/smtpdhandler.py Normal file
View file

@ -0,0 +1,39 @@
import uuid
import json
from datetime import datetime
from pathlib import Path
class SmtpdHandler:
def __init__(self, config):
self.config = config
async def handle_MAIL(self, server, session, envelope, address, mail_options):
if session.authenticated:
envelope.mail_from = address
return('250 OK')
return('530 5.7.0 Authentication required')
async def handle_DATA(self, server, session, envelope):
eid = uuid.uuid4()
try:
with open(Path(self.config.maildir) / f"{eid}.eml", "wb") as mailfile:
mailfile.write(
f"From {envelope.mail_from} {datetime.now().isoformat()}\n".encode())
mailfile.write(
f"Received: from {session.host_name} ({session.peer[0]}) by {server.hostname} (Kumi Systems FileMailer) id {eid}; {datetime.now().isoformat()}\n".encode())
mailfile.write(envelope.original_content)
with open(Path(self.config.maildir) / f"{eid}.json", "w") as jsonfile:
data = {
"sender": envelope.mail_from,
"recipients": envelope.rcpt_tos
}
json.dump(data, jsonfile)
return('250 Message accepted for delivery')
except:
return('451 Requested action aborted: local error in processing')

64
classes/ssl.py Normal file
View file

@ -0,0 +1,64 @@
from OpenSSL import crypto
import ssl
import tempfile
from datetime import datetime
class SSL:
def __init__(self, hostname=None, email=None, country=None, locality=None, state=None, org=None, orgunit=None, validity=10*365*60*60*24, bits=4096):
self.cn = hostname or "localhost"
self.email = email or ("filemailer@%s" % (hostname or "localhost"))
self.country = country or "AT"
self.locality = locality or "Graz"
self.state = state or "Steiermark"
self.org = org or "Kumi Systems e.U."
self.orgunit = orgunit or "FileMailer"
self.validity = validity
self.bits = bits
def makeCert(self):
k = crypto.PKey()
k.generate_key(crypto.TYPE_RSA, self.bits)
cert = crypto.X509()
cert.get_subject().C = self.country
cert.get_subject().ST = self.state
cert.get_subject().L = self.locality
cert.get_subject().O = self.org
cert.get_subject().OU = self.orgunit
cert.get_subject().CN = self.cn
cert.get_subject().emailAddress = self.email
cert.set_serial_number(int(datetime.now().timestamp()))
cert.gmtime_adj_notBefore(0)
cert.gmtime_adj_notAfter(self.validity)
cert.set_issuer(cert.get_subject())
cert.set_pubkey(k)
cert.sign(k, 'sha512')
return cert, k
def makeContext(self):
cert, k = self.makeCert()
context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
with tempfile.NamedTemporaryFile() as certfile, tempfile.NamedTemporaryFile() as keyfile:
certdump = crypto.dump_certificate(crypto.FILETYPE_PEM, cert)
certfile.write(certdump)
certfile.flush()
keydump = crypto.dump_privatekey(crypto.FILETYPE_PEM, k)
keyfile.write(keydump)
keyfile.flush()
context.load_cert_chain(certfile.name, keyfile.name)
return context
@staticmethod
def makeContextFromFiles(certfile="cert.pem", keyfile="key.pem"):
context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
context.load_cert_chain(certfile, keyfile)
return context

2
requirements.txt Normal file
View file

@ -0,0 +1,2 @@
aiosmtpd
argon2-cffi

6
settings.ini Normal file
View file

@ -0,0 +1,6 @@
[SERVER]
maildir = maildir
[USERS]
test = $argon2id$v=19$m=65536,t=3,p=4$0ZfYHQjV5IlHxtPqKP5O7A$LZ/vfXP1QoymVaPwwhH/0+FOK+Ek5fwr7YC98/E402A

33
worker.py Normal file
View file

@ -0,0 +1,33 @@
from aiosmtpd.controller import Controller as SmtpdController
from aiosmtpd.smtp import AuthResult
import asyncio
import logging
from classes.smtpdhandler import SmtpdHandler
from classes.config import Config
from classes.authenticator import Authenticator
from classes.ssl import SSL
if __name__ == "__main__":
log = logging.basicConfig()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
config = Config("settings.ini")
authenticator = Authenticator(config)
handler = SmtpdHandler(config)
smtpd = SmtpdController(handler, hostname=config.hostname,
port=config.port, authenticator=authenticator,
ident="Kumi Systems FileMailer",
auth_require_tls=False)
smtpd.start()
try:
loop.run_forever()
finally:
loop.run_until_complete(loop.shutdown_asyncgens())
loop.close()