First commit

This commit is contained in:
Mattia Mascarello 2025-02-17 03:03:55 +01:00
commit 4900374cbd
6 changed files with 326 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
venv
config.yml

36
README.md Normal file
View File

@ -0,0 +1,36 @@
# SMTP Proxy Server for Forwarding Emails
This project implements an SMTP proxy server that allows clients to send emails using a single shared email address, but different credentials. This can be particularly useful if you cannot or prefer not to have multiple email addresses for such clients, but you still want them to have separate and distinct credentials for security purposes. For example, when different services running from the same server are sending mail to an external mail server.
The SMTP proxy intercepts email traffic from apps, checks credentials for each one, modifies the "From" header to ensure the email is sent from a common address, and forwards the email to the desired recipients via a specified SMTP server.
Configuration hot-reloading is supported and will happen on changes to the configuration, so be sure to copy the file elsewhere if you think you could save malformed YAML.
## Use Case
This SMTP proxy server is ideal for scenarios where:
- Multiple applications on a server need to send emails.
- You do not want to or cannot configure separate email addresses for each app.
- You want to ensure all outgoing emails are sent from a single, unified email address.
- You want to avoid sharing credentils between applications.
- You are managing a server with limited email configuration capabilities.
## Requirements
- Python 3.7+
- Required Python libraries:
- `aiosmtpd`
- `pyyaml`
- `watchdog`
Install the required dependencies using pip:
```bash
pip install -r requirements.txt
```
An example systemd process is provided.

19
config.example.yml Normal file
View File

@ -0,0 +1,19 @@
smtp_proxy:
host: "localhost"
port: 1025
smtp_server:
host: "smtp.mailserver.com"
port: 587
user: "your_email@mailserver.com"
password: "your_password"
from_name: "Your Name"
from_email: "your_email@mailserver.com"
apps:
app1@localhost:
password: "pw1"
app2@localhost:
password: "pw2"

247
proxy.py Normal file
View File

@ -0,0 +1,247 @@
import logging
import smtplib
import base64
import socket
import yaml
import signal
from email.parser import BytesParser
from email.policy import default
from aiosmtpd.controller import Controller
from aiosmtpd.smtp import AuthResult
import sys
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
# ---------------------------
# Load Configuration
# ---------------------------
def load_config(config_file="config.yml"):
"""Load the YAML configuration file."""
try:
with open(config_file, "r") as file:
config = yaml.safe_load(file)
logger.info("✅ Configuration loaded successfully.")
return config
except Exception as e:
logger.error(f"❌ Error loading configuration: {e}")
raise
# ---------------------------
# Check Port Availability
# ---------------------------
def is_port_available(host: str, port: int) -> bool:
"""Check if the specified port is available."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
try:
sock.bind((host, port))
return True
except OSError:
return False
# ---------------------------
# Initialize Logging Configuration
# ---------------------------
def setup_logging():
"""Set up logging configuration with a single stream handler."""
logger = logging.getLogger() # Get the root logger
# Set the logging level to DEBUG
logger.setLevel(logging.DEBUG)
# Create a stream handler to output logs to stdout
stream_handler = logging.StreamHandler(sys.stdout)
# Set the log format
formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
stream_handler.setFormatter(formatter)
# Add the handler to the logger
logger.addHandler(stream_handler)
return logger
# Setup global logger
logger = setup_logging()
# ---------------------------
# Watchdog for Configuration Reload
# ---------------------------
class ConfigReloadHandler(FileSystemEventHandler):
"""Handler to reload the config when the config file changes."""
def on_modified(self, event):
"""Triggered when the config file is modified."""
if event.src_path == "config.yml":
logger.info("🔄 Configuration file changed, reloading...")
try:
global config
config = load_config("config.yml") # Reload the config
logger.info("✅ Configuration reloaded.")
except Exception as e:
logger.error(f"❌ Error reloading configuration: {e}")
# ---------------------------
# Initialize Configuration
# ---------------------------
config = load_config("config.yml")
# Extract configuration details
PROXY_HOST = config["smtp_proxy"]["host"]
PROXY_PORT = config["smtp_proxy"]["port"]
SMTP_SERVER_HOST = config["smtp_server"]["host"]
SMTP_SERVER_PORT = config["smtp_server"]["port"]
SMTP_USER = config["smtp_server"]["user"]
SMTP_PASSWORD = config["smtp_server"]["password"]
SMTP_FROM_NAME = config["smtp_server"]["from_name"]
SMTP_FROM_EMAIL = config["smtp_server"]["from_email"]
app_credentials = config["apps"] # Allowed users' credentials
# ---------------------------
# EmailAuthenticator Class
# ---------------------------
class EmailAuthenticator:
"""
Custom authenticator that checks credentials for the SMTP server.
"""
def __init__(self):
self.app_credentials = app_credentials
async def __call__(self, server, session, envelope, mechanism, auth_data):
fail_nothandled = AuthResult(success=False, handled=False)
if mechanism not in ("LOGIN", "PLAIN"):
return fail_nothandled
try:
if mechanism == "PLAIN":
decoded = base64.b64decode(auth_data).decode()
parts = decoded.split("\x00")
if len(parts) == 3:
username = parts[1]
password = parts[2]
else:
logger.error("❌ Invalid PLAIN auth data format")
return fail_nothandled
elif mechanism == "LOGIN":
decoded = base64.b64decode(auth_data).decode()
if "\x00" in decoded:
logger.error("❌ Invalid LOGIN auth data format")
return fail_nothandled
username, password = decoded.split("\x00")
if username in self.app_credentials and self.app_credentials[username]["password"] == password:
logger.info(f"✅ Authentication successful for {username}")
return AuthResult(success=True)
else:
logger.error(f"❌ Authentication failed for {username}: Incorrect password")
return fail_nothandled
except Exception as e:
logger.error(f"⚠️ Authentication error: {e}")
return fail_nothandled
# ---------------------------
# EmailLoggingProxy Class
# ---------------------------
class EmailLoggingProxy:
"""
Handler for incoming emails that logs the data, performs authentication,
rewrites the 'From' header, and forwards the email via the specified SMTP server.
"""
async def handle_EHLO(self, server, session, envelope, hostname):
"""Handle the EHLO command and advertise AUTH support."""
session.host_name = hostname
return (
"250-think-server\r\n"
"250-SIZE 33554432\r\n"
"250-8BITMIME\r\n"
"250-SMTPUTF8\r\n"
"250-AUTH LOGIN PLAIN\r\n" # Advertise AUTH support
"250 HELP"
)
async def handle_DATA(self, server, session, envelope):
"""
Handle the DATA command, log the email, modify the From header,
and forward the email via the specified SMTP server.
"""
mailfrom = envelope.mail_from
rcpttos = envelope.rcpt_tos
data = envelope.content
logger.info(f"📩 Received email from {mailfrom} to {rcpttos}")
# Log the email content
logger.debug(f"Mail data:\n{data.decode('utf-8', errors='replace')}")
# Validate sender against allowed app credentials
if mailfrom not in app_credentials:
logger.error(f"🚫 Unauthorized sender: {mailfrom}")
return "550 Unauthorized sender"
# Parse the email
msg = BytesParser(policy=default).parsebytes(data)
# Modify the "From" header with the configuration details
msg.replace_header("From", f"{SMTP_FROM_NAME} <{SMTP_FROM_EMAIL}>")
# Forward the email via the specified SMTP server
try:
logger.info(f"📤 Forwarding email to {rcpttos} via SMTP server...")
with smtplib.SMTP(SMTP_SERVER_HOST, SMTP_SERVER_PORT) as smtp_server:
smtp_server.starttls() # Upgrade connection to TLS
smtp_server.login(SMTP_USER, SMTP_PASSWORD)
smtp_server.sendmail(SMTP_FROM_EMAIL, rcpttos, msg.as_bytes())
logger.info(f"✅ Email successfully forwarded to {rcpttos}")
return "250 OK"
except Exception as e:
logger.error(f"❌ Failed to send email: {e}")
return "550 Internal server error"
# ---------------------------
# Run the Proxy Server
# ---------------------------
def run_proxy():
"""Run the SMTP proxy server."""
if not is_port_available(PROXY_HOST, PROXY_PORT):
logger.error(f"❌ Port {PROXY_PORT} on {PROXY_HOST} is already in use.")
exit(1)
try:
# Create the EmailAuthenticator instance
authenticator = EmailAuthenticator()
# Create the SMTP instance and pass it as handler to the Controller
smtp_handler = EmailLoggingProxy()
controller = Controller(smtp_handler, hostname=PROXY_HOST, port=PROXY_PORT, authenticator=authenticator, auth_require_tls=False)
# Start the config file watcher in a separate thread
config_watcher = Observer()
config_watcher.schedule(ConfigReloadHandler(), ".", recursive=False)
config_watcher.start()
logger.info(f"🚀 SMTP Proxy started on {PROXY_HOST}:{PROXY_PORT}")
controller.start()
sig = signal.sigwait([signal.SIGINT, signal.SIGQUIT])
logger.warning(f"{sig} caught, shutting down")
controller.stop()
config_watcher.stop()
config_watcher.join()
except Exception as e:
logger.error(f"❌ Error running the proxy: {e}")
exit(1)
if __name__ == "__main__":
run_proxy()

3
requirements.txt Normal file
View File

@ -0,0 +1,3 @@
aiosmtpd==1.4.6
PyYAML==6.0.2
watchdog==6.0.0

19
smtpproxy.service Normal file
View File

@ -0,0 +1,19 @@
[Unit]
Description=SMTP Proxy Service
After=network.target
[Service]
Type=simple
User=mattia
Group=mattia
WorkingDirectory=/path/to/smtpproxy
ExecStart=/path/to/smtpproxy/venv/bin/python /path/to/smtpproxy/proxy.py
Restart=always
Environment=PATH=/path/to/smtpproxy/venv/bin:/usr/bin:/bin
Environment=VIRTUAL_ENV=/path/to/smtpproxy/venv
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target