Signed-off-by: Florian Brandes <florian.brandes@posteo.de>
This commit is contained in:
2024-07-05 11:29:19 +02:00
parent 22cedd9f3e
commit 65924f9656
2 changed files with 87 additions and 41 deletions

View File

@@ -48,8 +48,10 @@
black.enable = true; black.enable = true;
# sort imports # sort imports
isort.enable = true; isort.enable = true;
# line length the same for black and isort
isort.settings.flags = "-l 88";
# look for code smell # look for code smell
pylint.enable = true; # pylint.enable = true;
detect-private-keys.enable = true; detect-private-keys.enable = true;

124
smtprd.py
View File

@@ -13,19 +13,18 @@ import sys
import threading import threading
import time import time
import uuid import uuid
from dataclasses import dataclass
from locale import setlocale, LC_ALL
from email import message_from_bytes, message_from_string from email import message_from_bytes, message_from_string
from email.message import Message from email.message import Message
from email.policy import default as EmailMessagePolicy from email.policy import default as EmailMessagePolicy
from locale import LC_ALL, setlocale
from typing import List, Optional, Set, Tuple, Union
from aiosmtpd.controller import Controller, SMTP as Server import envelope
from aiosmtpd.smtp import Session, Envelope from aiosmtpd.controller import SMTP as Server
from aiosmtplib import SMTP, SMTPException, SMTPRecipientsRefused, SMTPRecipientRefused from aiosmtpd.controller import Controller
from aiosmtpd.smtp import Envelope, Session
from dataclasses import dataclass from aiosmtplib import SMTP, SMTPException, SMTPRecipientRefused, SMTPRecipientsRefused
from typing import Optional, Union, List, Tuple, Set
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -53,7 +52,7 @@ class Config:
client: ClientConfig client: ClientConfig
@classmethod @classmethod
def _from_config(cls, config: configparser.ConfigParser) -> 'Config': def _from_config(cls, config: configparser.ConfigParser) -> "Config":
return cls( return cls(
server=ServerConfig( server=ServerConfig(
hostname=config.get("server", "hostname", fallback="localhost"), hostname=config.get("server", "hostname", fallback="localhost"),
@@ -63,17 +62,21 @@ class Config:
hostname=config.get("client", "hostname"), hostname=config.get("client", "hostname"),
port=config.getint("client", "port"), port=config.getint("client", "port"),
sender=config.get("client", "sender"), sender=config.get("client", "sender"),
recipients=[_.strip() for _ in config.get("client", "recipients").split(",")], recipients=[
_.strip() for _ in config.get("client", "recipients").split(",")
],
username=config.get("client", "username"), username=config.get("client", "username"),
password=config.get("client", "password"), password=config.get("client", "password"),
set_reply_to=config.getboolean("client", "set_reply_to", fallback=False), set_reply_to=config.getboolean(
"client", "set_reply_to", fallback=False
),
use_tls=config.getboolean("client", "use_tls", fallback=True), use_tls=config.getboolean("client", "use_tls", fallback=True),
start_tls=config.getboolean("client", "start_tls", fallback=False), start_tls=config.getboolean("client", "start_tls", fallback=False),
), ),
) )
@classmethod @classmethod
def from_ini(cls, filename: str) -> 'Config': def from_ini(cls, filename: str) -> "Config":
try: try:
config_parser: configparser.ConfigParser = configparser.ConfigParser() config_parser: configparser.ConfigParser = configparser.ConfigParser()
with open(filename, "r") as fp: with open(filename, "r") as fp:
@@ -88,19 +91,30 @@ class SMTPClient(SMTP):
self._config: ClientConfig = config self._config: ClientConfig = config
self._lock: asyncio.Lock = asyncio.Lock() self._lock: asyncio.Lock = asyncio.Lock()
super().__init__( super().__init__(
hostname=self._config.hostname, port=self._config.port, hostname=self._config.hostname,
username=self._config.username, password=self._config.password, port=self._config.port,
use_tls=self._config.use_tls, start_tls=self._config.start_tls, 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: async def _send_message(
failed_recipients: Set[str] = set((await self.send_message( self, message: Message, sender: str, recipients: List[str]
message, sender=sender, recipients=recipients ) -> None:
))[0].keys()) 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 if len(failed_recipients): # raise also when not all have been refused
raise SMTPRecipientsRefused([SMTPRecipientRefused(0, "", _) for _ in failed_recipients]) raise SMTPRecipientsRefused(
[SMTPRecipientRefused(0, "", _) for _ in failed_recipients]
)
async def forward_message(self, message: Message, sender: Optional[str], recipients: List[str]) -> None: async def forward_message(
self, message: Message, sender: Optional[str], recipients: List[str]
) -> None:
del message["From"] del message["From"]
del message["To"] del message["To"]
del message["Reply-To"] del message["Reply-To"]
@@ -108,19 +122,29 @@ class SMTPClient(SMTP):
message["Original-Recipient"] = ", ".join(recipients) message["Original-Recipient"] = ", ".join(recipients)
message["From"] = self._config.sender message["From"] = self._config.sender
message["To"] = ", ".join(self._config.recipients) message["To"] = ", ".join(self._config.recipients)
if self._config.set_reply_to and sender is not None and sender != self._config.sender: if (
self._config.set_reply_to
and sender is not None
and sender != self._config.sender
):
message["Reply-To"] = sender message["Reply-To"] = sender
print(f"{message['From']} ({message['Original-Sender']}) -> " print(
f"{message['To']} ({message['Original-Recipient']}): " f"{message['From']} ({message['Original-Sender']}) -> "
f"'{message.get('Subject', '')}'") f"{message['To']} ({message['Original-Recipient']}): "
f"'{message.get('Subject', '')}'"
)
async with self._lock: # TODO: consumer task from spool queue, reusing connections async with self._lock: # TODO: consumer task from spool queue, reusing connections
try: try:
await self.connect() await self.connect()
await self._send_message(message, self._config.sender, self._config.recipients) await self._send_message(
message, self._config.sender, self._config.recipients
)
except SMTPRecipientsRefused as e: except SMTPRecipientsRefused as e:
raise RuntimeError(f"Recipients refused: {', '.join(_.recipient for _ in e.recipients)}") from e raise RuntimeError(
f"Recipients refused: {', '.join(_.recipient for _ in e.recipients)}"
) from e
except (SMTPException, OSError) as e: except (SMTPException, OSError) as e:
raise RuntimeError(str(e)) from e raise RuntimeError(str(e)) from e
finally: finally:
@@ -131,11 +155,15 @@ class Handler:
def __init__(self, client: SMTPClient) -> None: def __init__(self, client: SMTPClient) -> None:
self._client: SMTPClient = client self._client: SMTPClient = client
async def handle_DATA(self, server: Server, session: Session, envelope: Envelope) -> str: async def handle_DATA(
self, server: Server, session: Session, envelope: Envelope
) -> str:
try: try:
await self._client.forward_message(self._prepare_message(server, envelope), await self._client.forward_message(
sender=envelope.mail_from, self._prepare_message(server, envelope),
recipients=envelope.rcpt_tos) sender=envelope.mail_from,
recipients=envelope.rcpt_tos,
)
except (RuntimeError, ValueError) as e: except (RuntimeError, ValueError) as e:
print(f"Cannot forward: {str(e)}", file=sys.stderr) 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__}" return f"550 {e.__cause__.__class__.__name__ if e.__cause__ is not None else e.__class__.__name__}"
@@ -161,7 +189,9 @@ class Handler:
host: str = server.hostname # getfqdn host: str = server.hostname # getfqdn
now: str = time.strftime("%a, %d %b %Y %H:%M:%S %z") 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["Received"] = (
f"from {peer[0]} ([{peer[0]}]) by {host} ([{sock[0]}]); {now}"
)
message["X-Peer"] = f"[{peer[0]}]:{peer[1]}" message["X-Peer"] = f"[{peer[0]}]:{peer[1]}"
if "Date" not in message: if "Date" not in message:
message["Date"] = now message["Date"] = now
@@ -173,7 +203,12 @@ class Handler:
class SMTPServer(Controller): class SMTPServer(Controller):
def __init__(self, config: ServerConfig, handler: Handler) -> None: def __init__(self, config: ServerConfig, handler: Handler) -> None:
super().__init__(handler=handler, hostname=config.hostname, port=config.port, ready_timeout=10.0) super().__init__(
handler=handler,
hostname=config.hostname,
port=config.port,
ready_timeout=10.0,
)
def run(self) -> bool: def run(self) -> bool:
shutdown_requested: threading.Event = threading.Event() shutdown_requested: threading.Event = threading.Event()
@@ -193,10 +228,17 @@ class SMTPServer(Controller):
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.strip(), parser = argparse.ArgumentParser(
formatter_class=argparse.ArgumentDefaultsHelpFormatter) description=__doc__.strip(),
parser.add_argument("--config", metavar="CONFIG.INI", type=str, default="./config.ini", formatter_class=argparse.ArgumentDefaultsHelpFormatter,
help="configuration file") )
parser.add_argument(
"--config",
metavar="CONFIG.INI",
type=str,
default="./config.ini",
help="configuration file",
)
args = parser.parse_args() args = parser.parse_args()
setlocale(LC_ALL, "C") # for strftime setlocale(LC_ALL, "C") # for strftime
@@ -206,9 +248,11 @@ def main() -> int:
print(str(e), file=sys.stderr) print(str(e), file=sys.stderr)
return 1 return 1
else: else:
controller = SMTPServer(config=config.server, handler=Handler(SMTPClient(config.client))) controller = SMTPServer(
config=config.server, handler=Handler(SMTPClient(config.client))
)
return 0 if controller.run() else 1 return 0 if controller.run() else 1
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) sys.exit(main())