original script

taken from https://www.hackitu.de/smtprd under GNU AGPL 3.0

Signed-off-by: Florian Brandes <florian.brandes@posteo.de>
This commit is contained in:
2024-07-03 18:03:19 +02:00
commit e4e4e9bb85
5 changed files with 256 additions and 0 deletions

16
LICENSE Normal file
View File

@@ -0,0 +1,16 @@
Simple python script that locally accepts any email and sends them to a remote server
Copyright (C) 2024 F. Schröder (original smtprd.py)
Copyright (C) 2024 F. Brandes (additions to original code)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.

3
requirements-dev.txt Normal file
View File

@@ -0,0 +1,3 @@
mypy
flake8
bandit

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
aiosmtpd
aiosmtplib

21
setup.py Normal file
View File

@@ -0,0 +1,21 @@
try:
from distutils.core import setup
except ImportError:
from setuptools import setup
name = "smtprd"
url = "https://www.hackitu.de/smtprd/"
requirements = open("requirements.txt", "r").read().splitlines(keepends=False)
setup(
name=name,
version="0.1",
description="SMTP forwarding relay daemon",
classifiers=["License :: OSI Approved :: GNU Affero General Public License v3"],
author=url,
author_email="@",
url=url,
scripts=[name + ".py"],
entry_points={"console_scripts": ["{}={}:main".format(name, name)]},
install_requires=requirements,
)

214
smtprd.py Normal file
View File

@@ -0,0 +1,214 @@
#!/usr/bin/env python3
"""
Minimal SMTP forwarding relay daemon.
Accepts unsolicited mails via SMTP and sends them using a remote SMTP server as configured.
"""
import argparse
import asyncio
import configparser
import signal
import sys
import threading
import time
import uuid
from locale import setlocale, LC_ALL
from email import message_from_bytes, message_from_string
from email.message import Message
from email.policy import default as EmailMessagePolicy
from aiosmtpd.controller import Controller, SMTP as Server
from aiosmtpd.smtp import Session, Envelope
from aiosmtplib import SMTP, SMTPException, SMTPRecipientsRefused, SMTPRecipientRefused
from dataclasses import dataclass
from typing import Optional, Union, List, Tuple, Set
@dataclass(frozen=True)
class ServerConfig:
hostname: str
port: int
@dataclass(frozen=True)
class ClientConfig:
hostname: str
port: int
sender: str
recipients: List[str]
username: Optional[str]
password: Optional[str]
set_reply_to: bool
use_tls: bool
start_tls: bool
@dataclass(frozen=True)
class Config:
server: ServerConfig
client: ClientConfig
@classmethod
def _from_config(cls, config: configparser.ConfigParser) -> 'Config':
return cls(
server=ServerConfig(
hostname=config.get("server", "hostname", fallback="localhost"),
port=config.getint("server", "port", fallback=8025),
),
client=ClientConfig(
hostname=config.get("client", "hostname"),
port=config.getint("client", "port"),
sender=config.get("client", "sender"),
recipients=[_.strip() for _ in config.get("client", "recipients").split(",")],
username=config.get("client", "username"),
password=config.get("client", "password"),
set_reply_to=config.getboolean("client", "set_reply_to", fallback=False),
use_tls=config.getboolean("client", "use_tls", fallback=True),
start_tls=config.getboolean("client", "start_tls", fallback=False),
),
)
@classmethod
def from_ini(cls, filename: str) -> 'Config':
try:
config_parser: configparser.ConfigParser = configparser.ConfigParser()
with open(filename, "r") as fp:
config_parser.read_file(fp)
return cls._from_config(config_parser)
except (OSError, configparser.Error, UnicodeDecodeError) as e:
raise RuntimeError(f"Cannot parse config '{filename}': {str(e)}") from None
class SMTPClient(SMTP):
def __init__(self, config: ClientConfig) -> None:
self._config: ClientConfig = config
self._lock: asyncio.Lock = asyncio.Lock()
super().__init__(
hostname=self._config.hostname, port=self._config.port,
username=self._config.username, password=self._config.password,
use_tls=self._config.use_tls, start_tls=self._config.start_tls,
)
async def _send_message(self, message: Message, sender: str, recipients: List[str]) -> None:
failed_recipients: Set[str] = set((await self.send_message(
message, sender=sender, recipients=recipients
))[0].keys())
if len(failed_recipients): # raise also when not all have been refused
raise SMTPRecipientsRefused([SMTPRecipientRefused(0, "", _) for _ in failed_recipients])
async def forward_message(self, message: Message, sender: Optional[str], recipients: List[str]) -> None:
del message["From"]
del message["To"]
del message["Reply-To"]
message["Original-Sender"] = sender if sender is not None else ""
message["Original-Recipient"] = ", ".join(recipients)
message["From"] = self._config.sender
message["To"] = ", ".join(self._config.recipients)
if self._config.set_reply_to and sender is not None and sender != self._config.sender:
message["Reply-To"] = sender
print(f"{message['From']} ({message['Original-Sender']}) -> "
f"{message['To']} ({message['Original-Recipient']}): "
f"'{message.get('Subject', '')}'")
async with self._lock: # TODO: consumer task from spool queue, reusing connections
try:
await self.connect()
await self._send_message(message, self._config.sender, self._config.recipients)
except SMTPRecipientsRefused as e:
raise RuntimeError(f"Recipients refused: {', '.join(_.recipient for _ in e.recipients)}") from e
except (SMTPException, OSError) as e:
raise RuntimeError(str(e)) from e
finally:
self.close()
class Handler:
def __init__(self, client: SMTPClient) -> None:
self._client: SMTPClient = client
async def handle_DATA(self, server: Server, session: Session, envelope: Envelope) -> str:
try:
await self._client.forward_message(self._prepare_message(server, envelope),
sender=envelope.mail_from,
recipients=envelope.rcpt_tos)
except (RuntimeError, ValueError) as e:
print(f"Cannot forward: {str(e)}", file=sys.stderr)
return f"550 {e.__cause__.__class__.__name__ if e.__cause__ is not None else e.__class__.__name__}"
else:
return "250 OK"
@classmethod
def _parse_message(cls, data: Optional[Union[bytes, str]]) -> Message:
if isinstance(data, bytes):
return message_from_bytes(data, policy=EmailMessagePolicy)
elif isinstance(data, str):
return message_from_string(data, policy=EmailMessagePolicy)
else:
raise ValueError(str(type(data)))
def _prepare_message(self, server: Server, envelope: Envelope) -> Message:
message: Message = self._parse_message(envelope.content)
if server.transport is None:
raise RuntimeError("Cannot get transport socket")
peer: Tuple[str, int] = server.transport.get_extra_info("peername")
sock: Tuple[str, int] = server.transport.get_extra_info("sockname")
host: str = server.hostname # getfqdn
now: str = time.strftime("%a, %d %b %Y %H:%M:%S %z")
message["Received"] = f"from {peer[0]} ([{peer[0]}]) by {host} ([{sock[0]}]); {now}"
message["X-Peer"] = f"[{peer[0]}]:{peer[1]}"
if "Date" not in message:
message["Date"] = now
if "Message-ID" not in message:
message["Message-ID"] = f"<{str(uuid.uuid4())}@{host}>"
return message
class SMTPServer(Controller):
def __init__(self, config: ServerConfig, handler: Handler) -> None:
super().__init__(handler=handler, hostname=config.hostname, port=config.port, ready_timeout=10.0)
def run(self) -> bool:
shutdown_requested: threading.Event = threading.Event()
def _handler(signum: int, frame) -> None:
shutdown_requested.set()
signal.signal(signal.SIGINT, _handler)
signal.signal(signal.SIGTERM, _handler)
print(f"Starting on {self.hostname}:{self.port}", file=sys.stderr)
self.start()
shutdown_requested.wait()
print("Stopping", file=sys.stderr)
self.stop()
return True
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.strip(),
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("--config", metavar="CONFIG.INI", type=str, default="./config.ini",
help="configuration file")
args = parser.parse_args()
setlocale(LC_ALL, "C") # for strftime
try:
config: Config = Config.from_ini(args.config)
except RuntimeError as e:
print(str(e), file=sys.stderr)
return 1
else:
controller = SMTPServer(config=config.server, handler=Handler(SMTPClient(config.client)))
return 0 if controller.run() else 1
if __name__ == "__main__":
sys.exit(main())