Compare commits

..

No commits in common. "42c024c29b6c89a3be1ff95c94bdd351006e5246" and "4900374cbd5f01199affb037efbddbe6d1069095" have entirely different histories.

4 changed files with 86 additions and 515 deletions

View File

@ -1,15 +1,11 @@
# SMTP Proxy Server for Forwarding Emails (w/GPG) # 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 are sending mail to an external mail server and sharing a mailbox there. 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. 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.
Additionally, if configured to do so, this proxy now automatically identifies users who have published GPG keys on keyservers and ensures end-to-end encryption for the messages they should receive. This feature makes email transmission secure by encrypting emails for recipients with published GPG keys, adding an important layer of confidentiality and security to your communication.
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. 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.
See [config.example.yml](config.example.yml) for a sample configuration (rename to config.yml and customize to use).
## Use Case ## Use Case
@ -21,19 +17,14 @@ This SMTP proxy server is ideal for scenarios where:
- You want to avoid sharing credentils between applications. - You want to avoid sharing credentils between applications.
- You are managing a server with limited email configuration capabilities. - You are managing a server with limited email configuration capabilities.
Or, most likely, your applications do not support GPG and you want a middleware for encrypting outbound emails. (Yes, this project has grown a bit out of the original scope).
## Requirements ## Requirements
- Python 3.7+ - Python 3.7+
- Required Python libraries: - Required Python libraries:
- `aiosmtpd` - `aiosmtpd`
- `PyYAML` - `pyyaml`
- `watchdog` - `watchdog`
- `python-gnupg`
- `requests`
- `jsonschema`
Install the required dependencies using pip: Install the required dependencies using pip:

View File

@ -2,6 +2,7 @@ smtp_proxy:
host: "localhost" host: "localhost"
port: 1025 port: 1025
smtp_server: smtp_server:
host: "smtp.mailserver.com" host: "smtp.mailserver.com"
port: 587 port: 587
@ -10,27 +11,9 @@ smtp_server:
from_name: "Your Name" from_name: "Your Name"
from_email: "your_email@mailserver.com" from_email: "your_email@mailserver.com"
apps: apps:
app1@localhost: app1@localhost:
password: "pw1" password: "pw1"
app2@localhost: app2@localhost:
password: "pw2" password: "pw2"
gpg: # optional
home: "/home/user/.gnupg"
passphrase: "phrase"
enabled: true
absent_notice:
enabled: true # Wether to issue a notice if recipient does not use GPG (PGP)
text: "\n\n⚠ This email was sent without end-to-end encryption.\n
This mail server supports automatic PGP encryption.\n
Consider setting up a PGP key and publishing it to keys.openpgp.org."
html:
"<p><strong>⚠️ This email was sent without end-to-end encryption.</strong><br>
This mail server supports automatic PGP encryption.<br>
Consider setting up a PGP key and publishing it to keys.openpgp.org.</p>"
# either text, html or both
id_domain: "mytest" # For Message ids
email_http_keyserver: "https://keys.openpgp.org/vks/v1/by-email/"

550
proxy.py
View File

@ -6,121 +6,11 @@ import yaml
import signal import signal
from email.parser import BytesParser from email.parser import BytesParser
from email.policy import default from email.policy import default
from email.message import Message
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from aiosmtpd.controller import Controller from aiosmtpd.controller import Controller
from aiosmtpd.smtp import AuthResult, Envelope, Session from aiosmtpd.smtp import AuthResult
import gnupg
import sys import sys
from watchdog.observers import Observer from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler from watchdog.events import FileSystemEventHandler
import requests
import uuid
import jsonschema
from jsonschema import validate
import threading
import queue
import copy
class FIFOQueueLock:
def __init__(self):
self.queue = queue.Queue() # FIFO queue to hold request tickets
self.lock = threading.Lock() # Lock to synchronize access to the queue
self.condition = threading.Condition(self.lock) # Condition variable for notifying
def acquire(self):
with self.lock:
ticket = object() # Create a unique ticket for this thread
self.queue.put(ticket) # Add the ticket to the queue
# Wait until our ticket reaches the front of the queue
with self.lock:
while self.queue.queue[0] is not ticket:
self.condition.wait() # Block the thread until it is its turn
def release(self):
with self.lock:
self.queue.get() # Remove our ticket from the queue
self.condition.notify_all() # Notify the next thread in line
def clear(self):
"""Clear all pending threads in the queue by notifying them."""
with self.lock:
# Clear the queue by removing all items
self.queue.queue.clear() # This will clear the queue (all pending tickets)
# Notify all waiting threads that they can continue
self.condition.notify_all() # Wake up all waiting threads, effectively releasing them
# Create a lock
CONFIG_LOCK = FIFOQueueLock()
# if we have requests in progress, they will finish before the config is applied, and the next ones will have it
config_schema = {
"type": "object",
"properties": {
"smtp_proxy": {
"type": "object",
"properties": {
"host": {"type": "string"},
"port": {"type": "integer"}
},
"required": ["host", "port"]
},
"smtp_server": {
"type": "object",
"properties": {
"host": {"type": "string"},
"port": {"type": "integer"},
"user": {"type": "string"},
"password": {"type": "string"},
"from_name": {"type": "string"},
"from_email": {"type": "string"}
},
"required": ["host", "port", "user", "password", "from_name", "from_email"]
},
"apps": {
"type": "object",
"patternProperties": {
"^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,}$": {
"type": "object",
"properties": {
"password": {"type": "string"}
},
"required": ["password"]
}
}
},
"gpg": {
"type": "object",
"properties": {
"home": {"type": "string"},
"passphrase": {"type": "string"},
"enabled": {"type": "boolean"},
"absent_notice": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"text": {"type": "string"},
"html": {"type": "string"}
},
"anyOf": [
{ "required": ["text"] },
{ "required": ["html"] },
{ "required": ["text", "html"] }
]
},
"email_http_keyserver": {"type": "string"}
},
"required": ["home", "enabled", "absent_notice", "email_http_keyserver"]
},
"id_domain": {"type": "string"},
"email_http_keyserver": {"type": "string"}
},
"required": ["smtp_proxy", "smtp_server", "apps", "id_domain"]
}
# --------------------------- # ---------------------------
# Load Configuration # Load Configuration
@ -130,20 +20,13 @@ def load_config(config_file="config.yml"):
try: try:
with open(config_file, "r") as file: with open(config_file, "r") as file:
config = yaml.safe_load(file) config = yaml.safe_load(file)
validate(instance=config, schema=config_schema)
logger.info("✅ Configuration loaded successfully.") logger.info("✅ Configuration loaded successfully.")
return config return config
except yaml.YAMLError as e:
logger.error(f"❌ YAML Error: {e}")
raise
except jsonschema.exceptions.ValidationError as e:
logger.error(f"❌ Schema Validation Error: {e.message}")
raise
except Exception as e: except Exception as e:
logger.error(f"❌ Error loading configuration: {e}") logger.error(f"❌ Error loading configuration: {e}")
raise raise
# --------------------------- # ---------------------------
# Check Port Availability # Check Port Availability
# --------------------------- # ---------------------------
@ -164,8 +47,8 @@ def setup_logging():
"""Set up logging configuration with a single stream handler.""" """Set up logging configuration with a single stream handler."""
logger = logging.getLogger() # Get the root logger logger = logging.getLogger() # Get the root logger
# Set the logging level to INFO (use DEBUG to debug) # Set the logging level to DEBUG
logger.setLevel(logging.INFO) logger.setLevel(logging.DEBUG)
# Create a stream handler to output logs to stdout # Create a stream handler to output logs to stdout
stream_handler = logging.StreamHandler(sys.stdout) stream_handler = logging.StreamHandler(sys.stdout)
@ -183,60 +66,6 @@ def setup_logging():
# Setup global logger # Setup global logger
logger = setup_logging() logger = setup_logging()
controller = None
# ---------------------------
# Initialize Configuration
# ---------------------------
config = load_config("config.yml")
def get_conf(param: str):
if param == "PROXY_HOST":
return config["smtp_proxy"]["host"]
elif param == "PROXY_PORT":
return config["smtp_proxy"]["port"]
elif param == "SMTP_SERVER_HOST":
return config["smtp_server"]["host"]
elif param == "SMTP_SERVER_PORT":
return config["smtp_server"]["port"]
elif param == "SMTP_USER":
return config["smtp_server"]["user"]
elif param == "SMTP_PASSWORD":
return config["smtp_server"]["password"]
elif param == "SMTP_FROM_NAME":
return config["smtp_server"]["from_name"]
elif param == "SMTP_FROM_EMAIL":
return config["smtp_server"]["from_email"]
elif param == "APP_CREDENTIALS":
return config["apps"]
elif param == "PASSPHRASE":
return config["gpg"]["passphrase"] if get_conf("GPG_ENABLED") and "passphrase" in config["gpg"] else None
elif param == "WARN_ABSENT_GPG":
return config["gpg"]["absent_notice"]["enabled"] if get_conf("GPG_ENABLED") else False
elif param == "GPG_ABSENT_NOTICE_TEXT":
return config["gpg"]["absent_notice"]["text"] if get_conf("GPG_ENABLED") else None
elif param == "GPG_ABSENT_NOTICE_HTML":
return config["gpg"]["absent_notice"]["html"] if get_conf("GPG_ENABLED") else None
elif param == "ID_DOMAIN":
return config["id_domain"]
elif param == "KEYSERVER_URL":
return config["gpg"]["email_http_keyserver"] if get_conf("GPG_ENABLED") else None
elif param == "GPG_ENABLED":
return "gpg" in config and config["gpg"]["enabled"]
else:
raise ValueError(f"Unknown parameter: {param}")
if get_conf("GPG_ENABLED"):
gpg = gnupg.GPG(gnupghome=config["gpg"]["home"])
gpg.encoding = "utf-8"
else:
gpg = None
# --------------------------- # ---------------------------
# Watchdog for Configuration Reload # Watchdog for Configuration Reload
# --------------------------- # ---------------------------
@ -244,42 +73,34 @@ class ConfigReloadHandler(FileSystemEventHandler):
"""Handler to reload the config when the config file changes.""" """Handler to reload the config when the config file changes."""
def on_modified(self, event): def on_modified(self, event):
CONFIG_LOCK.acquire() """Triggered when the config file is modified."""
try: if event.src_path == "config.yml":
global gpg, config logger.info("🔄 Configuration file changed, reloading...")
"""Triggered when the config file is modified.""" try:
if event.src_path == "config.yml" or event.src_path == "./config.yml": global config
logger.info("🔄 Configuration file changed, reloading...") config = load_config("config.yml") # Reload the config
try: logger.info("✅ Configuration reloaded.")
new_config = load_config("config.yml") # Reload the config except Exception as e:
if new_config["smtp_proxy"]["host"] != config["smtp_proxy"]["host"] \ logger.error(f"❌ Error reloading configuration: {e}")
or new_config["smtp_proxy"]["port"] != new_config["smtp_proxy"]["port"]:
config = new_config
logger.info("Port/host changed. Restarting server")
if controller is not None:
controller.stop()
CONFIG_LOCK.clear()
controller.start()
else:
config = new_config
if get_conf("GPG_ENABLED"):
gpg = gnupg.GPG(gnupghome=config["gpg"]["home"])
gpg.encoding = "utf-8"
else:
gpg = None
clog = copy.deepcopy(config)
redaction = "[REDACTED]" # ---------------------------
if "gpg" in clog and "passphrase" in clog["gpg"]: # Initialize Configuration
clog["gpg"]["passphrase"] = redaction # ---------------------------
clog["smtp_server"]["password"] = redaction config = load_config("config.yml")
for username in clog["apps"]:
clog["apps"][username]["password"] = redaction # Extract configuration details
logger.info(f"✅ Configuration reloaded. New config in memory: {yaml.dump(clog, default_flow_style=False)}") PROXY_HOST = config["smtp_proxy"]["host"]
except Exception as e: PROXY_PORT = config["smtp_proxy"]["port"]
logger.error(f"❌ Error reloading configuration: {e}. Keeping old config")
finally: SMTP_SERVER_HOST = config["smtp_server"]["host"]
CONFIG_LOCK.release() 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
# --------------------------- # ---------------------------
@ -291,7 +112,7 @@ class EmailAuthenticator:
""" """
def __init__(self): def __init__(self):
pass self.app_credentials = app_credentials
async def __call__(self, server, session, envelope, mechanism, auth_data): async def __call__(self, server, session, envelope, mechanism, auth_data):
fail_nothandled = AuthResult(success=False, handled=False) fail_nothandled = AuthResult(success=False, handled=False)
@ -316,17 +137,11 @@ class EmailAuthenticator:
return fail_nothandled return fail_nothandled
username, password = decoded.split("\x00") username, password = decoded.split("\x00")
app_credentials = get_conf("APP_CREDENTIALS") # avoid race condition in if if username in self.app_credentials and self.app_credentials[username]["password"] == password:
if (
username in app_credentials
and app_credentials[username]["password"] == password
):
logger.info(f"✅ Authentication successful for {username}") logger.info(f"✅ Authentication successful for {username}")
return AuthResult(success=True) return AuthResult(success=True)
else: else:
logger.error( logger.error(f"❌ Authentication failed for {username}: Incorrect password")
f"❌ Authentication failed for {username}: Incorrect password"
)
return fail_nothandled return fail_nothandled
except Exception as e: except Exception as e:
@ -335,21 +150,19 @@ class EmailAuthenticator:
# --------------------------- # ---------------------------
# EmailProxy Class # EmailLoggingProxy Class
# --------------------------- # ---------------------------
class EmailProxy: class EmailLoggingProxy:
""" """
Handler for incoming emails that logs the data, performs authentication, Handler for incoming emails that logs the data, performs authentication,
rewrites the 'From' header, and forwards the email via the specified SMTP server. rewrites the 'From' header, and forwards the email via the specified SMTP server.
""" """
async def handle_EHLO( async def handle_EHLO(self, server, session, envelope, hostname):
self, server, session: Session, envelope: Envelope, hostname
) -> str:
"""Handle the EHLO command and advertise AUTH support.""" """Handle the EHLO command and advertise AUTH support."""
session.host_name = hostname session.host_name = hostname
return ( return (
"250-smtp-mailproxy\r\n" "250-think-server\r\n"
"250-SIZE 33554432\r\n" "250-SIZE 33554432\r\n"
"250-8BITMIME\r\n" "250-8BITMIME\r\n"
"250-SMTPUTF8\r\n" "250-SMTPUTF8\r\n"
@ -357,258 +170,52 @@ class EmailProxy:
"250 HELP" "250 HELP"
) )
async def handle_DATA(self, server, session: Session, envelope: Envelope) -> str: 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
CONFIG_LOCK.acquire() 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: try:
""" logger.info(f"📤 Forwarding email to {rcpttos} via SMTP server...")
Handle the DATA command, log the email, modify the From header, with smtplib.SMTP(SMTP_SERVER_HOST, SMTP_SERVER_PORT) as smtp_server:
and forward the email via the specified SMTP server. smtp_server.starttls() # Upgrade connection to TLS
""" smtp_server.login(SMTP_USER, SMTP_PASSWORD)
mailfrom = envelope.mail_from smtp_server.sendmail(SMTP_FROM_EMAIL, rcpttos, msg.as_bytes())
rcpttos = envelope.rcpt_tos logger.info(f"✅ Email successfully forwarded to {rcpttos}")
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')}")
# Parse the email
msg = BytesParser(policy=default).parsebytes(data)
# Modify the "From" header with the configuration details
msg.replace_header("From", f"{get_conf("SMTP_FROM_NAME")} <{get_conf("SMTP_FROM_EMAIL")}>")
encrypted_recipients = []
unencrypted_recipients = []
for recipient in rcpttos:
if get_conf("GPG_ENABLED") and self.fetch_pgp_key(recipient):
encrypted_recipients.append(recipient)
else:
unencrypted_recipients.append(recipient)
bcc_recipients = []
if "Bcc" in msg:
bcc_recipients = msg["Bcc"].split(",")
del msg["Bcc"]
if "X-Mailer" in msg:
del msg["X-Mailer"]
msg.replace_header(
"Message-ID", "<" + str(uuid.uuid4()) + "@" + get_conf("ID_DOMAIN") + ">"
)
if encrypted_recipients:
encrypted_msg = self.encrypt_mime_email(msg, encrypted_recipients)
if encrypted_msg:
self.send_email(encrypted_msg, encrypted_recipients)
if unencrypted_recipients:
plaintext_msg = (
self.add_unencrypted_warning(msg) if get_conf("WARN_ABSENT_GPG") else msg
)
self.send_email(plaintext_msg, unencrypted_recipients)
# Send separate emails to each BCC recipient for privacy
for bcc in bcc_recipients:
if bcc in encrypted_recipients:
self.send_email(self.encrypt_mime_email(msg, [bcc]), [bcc])
else:
self.send_email(
self.add_unencrypted_warning(msg) if get_conf("WARN_ABSENT_GPG") else msg, [bcc]
)
return "250 OK" return "250 OK"
finally:
CONFIG_LOCK.release()
def fetch_pgp_key(self, email: str) -> bool:
"""Checks if a PGP key exists locally or fetches it from the keyserver."""
# Check if the key exists locally
keys = gpg.list_keys(keys=email)
if keys:
logger.info(f"✅ PGP key for {email} found locally.")
return True # Key exists locally
# Key does not exist locally, so try fetching from the keyserver
logger.info(f"🔍 Looking up PGP key for {email} on keyserver...")
# Request the key from the keyserver API
key_url = f"{get_conf("KEYSERVER_URL")}{email}"
try:
response = requests.get(key_url)
if response.status_code == 200:
key_data = response.text
logger.info(
f"✅ Successfully retrieved PGP key for {email} from keyserver."
)
# Import the key into GPG
import_result = gpg.import_keys(key_data)
if import_result.count > 0:
logger.info(
f"✅ PGP key for {email} successfully imported into GPG."
)
return True
else:
logger.warning(f"❌ Failed to import PGP key for {email}.")
return False
else:
logger.warning(
f"❌ No PGP key found for {email}. HTTP status code: {response.status_code}"
)
return False
except requests.exceptions.RequestException as e:
logger.error(f"❌ Error while contacting keyserver: {e}")
return False
def encrypt_mime_email(self, msg: Message, recipients: list[str]) -> MIMEMultipart:
"""Encrypts a full email as PGP/MIME, including attachments, and sets the subject to '...'."""
# msg.replace_header("Subject", "...")
# Create a new MIME message to hold the encrypted content
encrypted_email = MIMEMultipart(
"encrypted", protocol="application/pgp-encrypted"
)
encrypted_email["Subject"] = msg["Subject"]
encrypted_email["From"] = msg["From"]
encrypted_email["To"] = ", ".join(recipients)
# Add Autocrypt header (optional, for Autocrypt support)
autocrypt_key = self.get_autocrypt_key(get_conf("SMTP_FROM_EMAIL"))
if autocrypt_key:
encrypted_email["Autocrypt"] = autocrypt_key
# PGP version header
pgp_header = MIMEBase("application", "pgp-encrypted")
pgp_header.add_header("Content-Description", "PGP/MIME version identification")
pgp_header.set_payload("Version: 1\r\n")
encrypted_email.attach(pgp_header)
# Encrypt the email body and attachments
encrypted_part = self.encrypt_message_with_attachments(msg, recipients)
if not encrypted_part:
logger.error("❌ Failed to encrypt message with attachments.")
return None
# Attach the encrypted payload
encrypted_email.attach(encrypted_part)
return encrypted_email
def get_autocrypt_key(self, email: str) -> str:
# Fetch the public key for the given email
keys = gpg.list_keys(keys=email)
if not keys:
logger.warning(f"❌ No PGP key found for {email} in the GPG keyring.")
return None
# Export the public key in ASCII-armored format
key_data = gpg.export_keys(email, armor=True)
if not key_data:
logger.error(f"❌ Failed to export PGP key for {email}.")
return None
# Encode the key data in base64
keydata_base64 = base64.b64encode(key_data.encode("utf-8")).decode("utf-8")
# Construct the Autocrypt header
autocrypt_header = f"addr={email}; keydata={keydata_base64}"
return autocrypt_header
def encrypt_message_with_attachments(
self, msg: Message, recipients: list[str]
) -> MIMEBase:
"""Encrypts the entire MIME body as a single unit."""
# Convert the entire message to a string
message_str = msg.as_string()
# Encrypt the entire message
encrypted_data = gpg.encrypt(
message_str,
recipients=recipients,
sign=get_conf("SMTP_FROM_EMAIL"),
always_trust=True,
passphrase=get_conf("PASSPHRASE")
)
if not encrypted_data.ok:
logger.error(f"❌ Encryption failed: {encrypted_data.stderr}")
return None
# Create the encrypted payload part
encrypted_part = MIMEBase("application", "octet-stream")
encrypted_part.set_payload(str(encrypted_data))
encrypted_part.add_header("Content-Description", "OpenPGP encrypted message")
encrypted_part.add_header(
"Content-Disposition", "inline", filename="encrypted.asc"
)
return encrypted_part
def add_unencrypted_warning(self, msg: Message) -> Message:
"""Adds a warning footer to unencrypted emails for both plain-text and HTML bodies."""
# For multipart emails (text/plain + text/html)
if msg.is_multipart():
for part in msg.walk():
# Check if the part is plain text
if part.get_content_type() == "text/plain":
part.set_payload(part.get_payload() + get_conf("GPG_ABSENT_NOTICE_TEXT"))
# Check if the part is HTML text
elif part.get_content_type() == "text/html":
html_warning = (
f"<p><strong>⚠️ This email was sent without end-to-end encryption.</strong><br>"
f"This mail server supports automatic PGP encryption.<br>"
f"Consider setting up a PGP key and publishing it to a keyserver "
f"(e.g., keys.openpgp.org).</p>"
)
part.set_payload(part.get_payload() + html_warning)
part.replace_header(
"Content-Transfer-Encoding", "quoted-printable"
) # Ensure HTML part is encoded correctly
else:
# If it's a non-multipart message (either plain-text or HTML only)
if msg.get_content_type() == "text/plain":
msg.set_payload(msg.get_payload() + get_conf("GPG_ABSENT_NOTICE_TEXT"))
elif msg.get_content_type() == "text/html":
msg.set_payload(msg.get_payload() + get_conf("GPG_ABSENT_NOTICE_HTML"))
msg.replace_header("Content-Transfer-Encoding", "quoted-printable")
return msg
def send_email(self, msg: Message, recipients: list[str]) -> None:
"""Sends an email via SMTP, ensuring BCC recipients remain private."""
try:
logger.info(f"📤 Sending email to {recipients} via SMTP...")
with smtplib.SMTP(get_conf("SMTP_SERVER_HOST"), get_conf("SMTP_SERVER_PORT")) as smtp_server:
smtp_server.starttls()
smtp_server.login(get_conf("SMTP_USER"), get_conf("SMTP_PASSWORD"))
smtp_server.sendmail(
get_conf("SMTP_FROM_EMAIL"), recipients, msg.as_string().encode("utf-8")
)
logger.info(f"✅ Email successfully sent to {recipients}")
except Exception as e: except Exception as e:
logger.error(f"❌ Failed to send email: {e}") logger.error(f"❌ Failed to send email: {e}")
return "550 Internal server error"
# --------------------------- # ---------------------------
# Run the Proxy Server # Run the Proxy Server
# --------------------------- # ---------------------------
def run_proxy(): def run_proxy():
global controller
"""Run the SMTP proxy server.""" """Run the SMTP proxy server."""
if not is_port_available(get_conf("PROXY_HOST"), get_conf("PROXY_PORT")): if not is_port_available(PROXY_HOST, PROXY_PORT):
logger.error(f"❌ Port {get_conf("PROXY_PORT")} on {get_conf("PROXY_HOST")} is already in use.") logger.error(f"❌ Port {PROXY_PORT} on {PROXY_HOST} is already in use.")
exit(1) exit(1)
try: try:
@ -616,25 +223,18 @@ def run_proxy():
authenticator = EmailAuthenticator() authenticator = EmailAuthenticator()
# Create the SMTP instance and pass it as handler to the Controller # Create the SMTP instance and pass it as handler to the Controller
smtp_handler = EmailProxy() smtp_handler = EmailLoggingProxy()
controller = Controller( controller = Controller(smtp_handler, hostname=PROXY_HOST, port=PROXY_PORT, authenticator=authenticator, auth_require_tls=False)
smtp_handler,
hostname=get_conf("PROXY_HOST"),
port=get_conf("PROXY_PORT"),
authenticator=authenticator,
auth_require_tls=False,
)
# Start the config file watcher in a separate thread # Start the config file watcher in a separate thread
config_watcher = Observer() config_watcher = Observer()
config_watcher.schedule(ConfigReloadHandler(), ".", recursive=False) config_watcher.schedule(ConfigReloadHandler(), ".", recursive=False)
config_watcher.start() config_watcher.start()
logger.info(f"🚀 SMTP Proxy started on {get_conf("PROXY_HOST")}:{get_conf("PROXY_PORT")}") logger.info(f"🚀 SMTP Proxy started on {PROXY_HOST}:{PROXY_PORT}")
controller.start() controller.start()
sig = signal.sigwait([signal.SIGINT, signal.SIGQUIT]) sig = signal.sigwait([signal.SIGINT, signal.SIGQUIT])
logger.warning(f"{sig} caught, shutting down") logger.warning(f"{sig} caught, shutting down")
CONFIG_LOCK.clear()
controller.stop() controller.stop()
config_watcher.stop() config_watcher.stop()
config_watcher.join() config_watcher.join()

View File

@ -1,6 +1,3 @@
aiosmtpd==1.4.6 aiosmtpd==1.4.6
PyYAML==6.0.2 PyYAML==6.0.2
watchdog==6.0.0 watchdog==6.0.0
python-gnupg==0.5.4
requests==2.32.3
jsonschema==4.23.0