Initial commit
This commit is contained in:
commit
4a45a9360b
8 changed files with 207 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
venv/
|
||||
*.pyc
|
||||
__pycache__/
|
||||
maildir/
|
||||
*.pem
|
8
classes/authenticator.py
Normal file
8
classes/authenticator.py
Normal 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
50
classes/config.py
Normal 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
39
classes/smtpdhandler.py
Normal 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
64
classes/ssl.py
Normal 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
2
requirements.txt
Normal file
|
@ -0,0 +1,2 @@
|
|||
aiosmtpd
|
||||
argon2-cffi
|
6
settings.ini
Normal file
6
settings.ini
Normal 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
33
worker.py
Normal 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()
|
Loading…
Reference in a new issue