diff --git a/.gitignore b/.gitignore index c9fba40..25500ae 100644 --- a/.gitignore +++ b/.gitignore @@ -4,10 +4,14 @@ __pycache__/ *$py.class # Certs, db, private test files +settings.ini *.crt *.key *.db test.txt +custom_test.py +custom_test.sh +.github/ # C extensions *.so diff --git a/email_server/auth.py b/email_server/auth.py index 558ca6b..c588b30 100644 --- a/email_server/auth.py +++ b/email_server/auth.py @@ -2,12 +2,12 @@ Authentication modules for the SMTP server. """ -import logging from datetime import datetime from aiosmtpd.smtp import AuthResult, LoginPassword from email_server.models import Session, User, Domain, WhitelistedIP, AuthLog, check_password +from email_server.tool_box import get_logger -logger = logging.getLogger(__name__) +logger = get_logger() class Authenticator: """Username/password authenticator.""" @@ -47,7 +47,7 @@ class Authenticator: session_db.add(auth_log) session_db.commit() - logger.info(f'Authenticated user: {username} from domain {domain.domain_name if domain else "unknown"}') + logger.debug(f'Authenticated user: {username} from domain {domain.domain_name if domain else "unknown"}') # Don't include the SMTP response code in the message - let aiosmtpd handle it return AuthResult(success=True, handled=True) else: @@ -94,7 +94,7 @@ class IPAuthenticator: ) session_db.add(auth_log) session_db.commit() - logger.info(f'Authenticated via whitelist: IP {peer_ip} for {domain.domain_name}') + logger.debug(f'Authenticated via whitelist: IP {peer_ip} for {domain.domain_name}') return AuthResult(success=True, handled=True, message='Authenticated via whitelist') return AuthResult(success=False, handled=True, message='IP not whitelisted') diff --git a/email_server/cli_tools.py b/email_server/cli_tools.py index 0cb0a7a..3ec7ecd 100644 --- a/email_server/cli_tools.py +++ b/email_server/cli_tools.py @@ -5,10 +5,9 @@ Command-line tools for managing the SMTP server. import argparse from email_server.models import Session, Domain, User, WhitelistedIP, hash_password, create_tables from email_server.dkim_manager import DKIMManager -import logging +from email_server.tool_box import get_logger -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) +logger = get_logger() def add_domain(domain_name, requires_auth=True): """Add a new domain to the database.""" diff --git a/email_server/config.py b/email_server/config.py deleted file mode 100644 index 355fb45..0000000 --- a/email_server/config.py +++ /dev/null @@ -1,26 +0,0 @@ -""" -Configuration settings for the SMTP server. -""" - -# Server settings -SMTP_PORT = 4025 -SMTP_TLS_PORT = 40587 -HOSTNAME = 'localhost' -BIND_IP = '0.0.0.0' - -# Database settings -DATABASE_URL = 'sqlite:///email_server/server_data/smtp_server.db' - -# Logging settings -LOG_LEVEL = 'INFO' - -# Email relay settings -RELAY_TIMEOUT = 10 - -# TLS settings -TLS_CERT_FILE = 'email_server/ssl_certs/server.crt' -TLS_KEY_FILE = 'email_server/ssl_certs/server.key' - -# DKIM settings -DKIM_SELECTOR = 'default' -DKIM_KEY_SIZE = 2048 diff --git a/email_server/dkim_manager.py b/email_server/dkim_manager.py index c6ac236..46e17a7 100644 --- a/email_server/dkim_manager.py +++ b/email_server/dkim_manager.py @@ -2,15 +2,19 @@ DKIM key management and email signing functionality. """ -import logging import dkim from cryptography.hazmat.primitives import serialization, hashes from cryptography.hazmat.primitives.asymmetric import rsa from datetime import datetime from email_server.models import Session, Domain, DKIMKey -from email_server.config import DKIM_SELECTOR, DKIM_KEY_SIZE +from email_server.settings_loader import load_settings +from email_server.tool_box import get_logger -logger = logging.getLogger(__name__) +settings = load_settings() +DKIM_SELECTOR = settings['DKIM']['DKIM_SELECTOR'] +DKIM_KEY_SIZE = int(settings['DKIM']['DKIM_KEY_SIZE']) + +logger = get_logger() class DKIMManager: """Manages DKIM keys and email signing.""" @@ -31,7 +35,7 @@ class DKIMManager: # Check if DKIM key already exists existing_key = session.query(DKIMKey).filter_by(domain_id=domain.id, is_active=True).first() if existing_key: - logger.info(f"DKIM key already exists for domain {domain_name}") + logger.debug(f"DKIM key already exists for domain {domain_name}") return True # Generate RSA key pair @@ -66,7 +70,7 @@ class DKIMManager: session.add(dkim_key) session.commit() - logger.info(f"Generated DKIM key for domain: {domain_name}") + logger.debug(f"Generated DKIM key for domain: {domain_name}") return True except Exception as e: @@ -156,7 +160,7 @@ class DKIMManager: # Combine signature with original content signed_content = signature + email_bytes - logger.info(f"Successfully signed email for domain: {domain_name}") + logger.debug(f"Successfully signed email for domain: {domain_name}") # Return as string if input was string if isinstance(email_content, str): @@ -203,7 +207,7 @@ class DKIMManager: ).first() if not existing_key: - logger.info(f"Generating DKIM key for existing domain: {domain.domain_name}") + logger.debug(f"Generating DKIM key for existing domain: {domain.domain_name}") self.generate_dkim_keypair(domain.domain_name) except Exception as e: diff --git a/email_server/email_relay.py b/email_server/email_relay.py index da7d446..95bcfaa 100644 --- a/email_server/email_relay.py +++ b/email_server/email_relay.py @@ -5,11 +5,11 @@ Email relay functionality for the SMTP server. import dns.resolver import smtplib import ssl -import logging from datetime import datetime from email_server.models import Session, EmailLog +from email_server.tool_box import get_logger -logger = logging.getLogger(__name__) +logger = get_logger() class EmailRelay: """Handles relaying emails to recipient mail servers.""" @@ -29,7 +29,7 @@ class EmailRelay: # Sort by priority (lower number = higher priority) mx_records = sorted(mx_records, key=lambda x: x.preference) mx_host = mx_records[0].exchange.to_text().rstrip('.') - logger.info(f'Found MX record for {domain}: {mx_host}') + logger.debug(f'Found MX record for {domain}: {mx_host}') except Exception as e: logger.error(f'Failed to resolve MX for {domain}: {e}') return False @@ -56,14 +56,14 @@ class EmailRelay: # Check if server supports STARTTLS relay_server.ehlo() if relay_server.has_extn('starttls'): - logger.info(f'Starting TLS connection to {mx_host}') + logger.debug(f'Starting TLS connection to {mx_host}') context = ssl.create_default_context() # Allow self-signed certificates for mail servers context.check_hostname = False context.verify_mode = ssl.CERT_NONE relay_server.starttls(context=context) relay_server.ehlo() # Say hello again after STARTTLS - logger.info(f'TLS connection established to {mx_host}') + logger.debug(f'TLS connection established to {mx_host}') else: logger.warning(f'Server {mx_host} does not support STARTTLS, using plain text') except Exception as tls_e: @@ -71,7 +71,7 @@ class EmailRelay: # Send the email relay_server.sendmail(mail_from, rcpt, content) - logger.info(f'Successfully relayed email to {rcpt} via {mx_host}') + logger.debug(f'Successfully relayed email to {rcpt} via {mx_host}') return True except Exception as e: @@ -86,7 +86,7 @@ class EmailRelay: # Try other MX records for mx_record in mx_records[1:3]: # Try up to 2 backup MX records backup_mx = mx_record.exchange.to_text().rstrip('.') - logger.info(f'Trying backup MX record: {backup_mx}') + logger.debug(f'Trying backup MX record: {backup_mx}') try: with smtplib.SMTP(backup_mx, 25, timeout=self.timeout) as backup_server: @@ -101,12 +101,12 @@ class EmailRelay: context.verify_mode = ssl.CERT_NONE backup_server.starttls(context=context) backup_server.ehlo() - logger.info(f'TLS connection established to backup {backup_mx}') + logger.debug(f'TLS connection established to backup {backup_mx}') except Exception: logger.warning(f'STARTTLS failed with backup {backup_mx}, using plain text') backup_server.sendmail(mail_from, rcpt, content) - logger.info(f'Successfully relayed email to {rcpt} via backup {backup_mx}') + logger.debug(f'Successfully relayed email to {rcpt} via backup {backup_mx}') return True except Exception as backup_e: logger.warning(f'Backup MX {backup_mx} also failed: {backup_e}') diff --git a/email_server/models.py b/email_server/models.py index f1a8b34..fca79e1 100644 --- a/email_server/models.py +++ b/email_server/models.py @@ -6,8 +6,11 @@ from sqlalchemy import create_engine, Column, Integer, String, DateTime, Text, B from sqlalchemy.orm import declarative_base, sessionmaker from datetime import datetime import bcrypt -from email_server.config import DATABASE_URL -from email_server.tool_box import ensure_folder_exists +from email_server.settings_loader import load_settings +from email_server.tool_box import ensure_folder_exists, get_logger + +settings = load_settings() +DATABASE_URL = settings['Database']['DATABASE_URL'] ensure_folder_exists(DATABASE_URL) @@ -16,6 +19,8 @@ Base = declarative_base() engine = create_engine(DATABASE_URL, echo=False) Session = sessionmaker(bind=engine) +logger = get_logger() + class Domain(Base): __tablename__ = 'domains' id = Column(Integer, primary_key=True) diff --git a/email_server/server_runner.py b/email_server/server_runner.py index 488b6b0..ee60dfe 100644 --- a/email_server/server_runner.py +++ b/email_server/server_runner.py @@ -4,10 +4,10 @@ Main server file that ties all modules together. """ import asyncio -import logging +from email_server.settings_loader import load_settings +from email_server.tool_box import get_logger # Import our modules -from email_server.config import SMTP_PORT, SMTP_TLS_PORT, HOSTNAME, LOG_LEVEL, BIND_IP from email_server.models import create_tables from email_server.smtp_handler import CustomSMTPHandler, PlainController from email_server.tls_utils import generate_self_signed_cert, create_ssl_context @@ -15,12 +15,14 @@ from email_server.dkim_manager import DKIMManager from aiosmtpd.controller import Controller from aiosmtpd.smtp import SMTP as AIOSMTP -# Configure logging -logging.basicConfig( - level=getattr(logging, LOG_LEVEL), - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' -) -logger = logging.getLogger(__name__) +settings = load_settings() +SMTP_PORT = int(settings['Server']['SMTP_PORT']) +SMTP_TLS_PORT = int(settings['Server']['SMTP_TLS_PORT']) +HOSTNAME = settings['Server']['HOSTNAME'] +LOG_LEVEL = settings['Logging']['LOG_LEVEL'] +BIND_IP = settings['Server']['BIND_IP'] + +logger = get_logger() # Enable asyncio debugging try: @@ -32,14 +34,14 @@ except RuntimeError: async def start_server(): """Main server function.""" - logger.info("Starting SMTP Server with DKIM support...") + logger.debug("Starting SMTP Server with DKIM support...") # Initialize database - logger.info("Initializing database...") + logger.debug("Initializing database...") create_tables() # Initialize DKIM manager and generate keys for domains without them - logger.info("Initializing DKIM manager...") + logger.debug("Initializing DKIM manager...") dkim_manager = DKIMManager() dkim_manager.initialize_default_keys() @@ -53,7 +55,7 @@ async def start_server(): domain = Domain(domain_name='example.com', requires_auth=True) session.add(domain) session.commit() - logger.info("Added example.com domain") + logger.debug("Added example.com domain") # Add test user if not exists user = session.query(User).filter_by(email='test@example.com').first() @@ -65,7 +67,7 @@ async def start_server(): ) session.add(user) session.commit() - logger.info("Added test user: test@example.com") + logger.debug("Added test user: test@example.com") # Add whitelisted IP if not exists whitelist = session.query(WhitelistedIP).filter_by(ip_address='127.0.0.1').first() @@ -73,7 +75,7 @@ async def start_server(): whitelist = WhitelistedIP(ip_address='127.0.0.1', domain_id=domain.id) session.add(whitelist) session.commit() - logger.info("Added whitelisted IP: 127.0.0.1") + logger.debug("Added whitelisted IP: 127.0.0.1") except Exception as e: session.rollback() logger.error(f"Error adding test data: {e}") @@ -81,7 +83,7 @@ async def start_server(): session.close() # Generate SSL certificate if it doesn't exist - logger.info("Checking SSL certificates...") + logger.debug("Checking SSL certificates...") if not generate_self_signed_cert(): logger.error("Failed to generate SSL certificate") return @@ -101,7 +103,7 @@ async def start_server(): port=SMTP_PORT ) controller_plain.start() - logger.info(f'Starting plain SMTP server on {HOSTNAME}:{SMTP_PORT}...') + logger.debug(f'Starting plain SMTP server on {HOSTNAME}:{SMTP_PORT}...') # Start TLS SMTP server using closure pattern like the original handler_tls = CustomSMTPHandler() @@ -126,15 +128,15 @@ async def start_server(): port=SMTP_TLS_PORT ) controller_tls.start() - logger.info(f' - Plain SMTP (IP whitelist): {BIND_IP}:{SMTP_PORT}') - logger.info(f' - STARTTLS SMTP (auth required): {BIND_IP}:{SMTP_TLS_PORT}') - logger.info('Management commands:') - logger.info(' python cli_tools.py --help') + logger.debug(f' - Plain SMTP (IP whitelist): {BIND_IP}:{SMTP_PORT}') + logger.debug(f' - STARTTLS SMTP (auth required): {BIND_IP}:{SMTP_TLS_PORT}') + logger.debug('Management commands:') + logger.debug(' python cli_tools.py --help') try: await asyncio.Event().wait() except KeyboardInterrupt: - logger.info('Shutting down SMTP servers...') + logger.debug('Shutting down SMTP servers...') controller_plain.stop() controller_tls.stop() - logger.info('SMTP servers stopped.') \ No newline at end of file + logger.debug('SMTP servers stopped.') \ No newline at end of file diff --git a/email_server/settings_loader.py b/email_server/settings_loader.py new file mode 100644 index 0000000..2bc0970 --- /dev/null +++ b/email_server/settings_loader.py @@ -0,0 +1,54 @@ +""" +Settings loader for the SMTP server. +Automatically generates settings.ini with default values if not present. +""" + +import configparser +from pathlib import Path + +SETTINGS_PATH = Path(__file__).parent.parent / 'settings.ini' + +# Default values for settings.ini +DEFAULTS = { + 'Server': { + 'SMTP_PORT': '4025', + 'SMTP_TLS_PORT': '40587', + 'HOSTNAME': 'localhost', + 'BIND_IP': '0.0.0.0', + }, + 'Database': { + 'DATABASE_URL': 'sqlite:///email_server/server_data/smtp_server.db', + }, + 'Logging': { + 'LOG_LEVEL': 'INFO', + 'hide_info_aiosmtpd': 'true', + }, + 'Relay': { + 'RELAY_TIMEOUT': '10', + }, + 'TLS': { + 'TLS_CERT_FILE': 'email_server/ssl_certs/server.crt', + 'TLS_KEY_FILE': 'email_server/ssl_certs/server.key', + }, + 'DKIM': { + 'DKIM_SELECTOR': 'default', + 'DKIM_KEY_SIZE': '2048', + }, +} + +def generate_settings_ini(settings_path: Path = SETTINGS_PATH) -> None: + """Generate settings.ini with default values if it does not exist.""" + if settings_path.exists(): + return + config_parser = configparser.ConfigParser() + for section, values in DEFAULTS.items(): + config_parser[section] = values + with open(settings_path, 'w') as f: + config_parser.write(f) + +def load_settings(settings_path: Path = SETTINGS_PATH) -> configparser.ConfigParser: + """Load settings from settings.ini, generating it if needed.""" + generate_settings_ini(settings_path) + config_parser = configparser.ConfigParser() + config_parser.read(settings_path) + return config_parser diff --git a/email_server/smtp_handler.py b/email_server/smtp_handler.py index ac4354a..4fa2603 100644 --- a/email_server/smtp_handler.py +++ b/email_server/smtp_handler.py @@ -2,7 +2,6 @@ SMTP handler for processing incoming emails. """ -import logging import uuid from datetime import datetime from aiosmtpd.smtp import SMTP as AIOSMTP, AuthResult @@ -10,8 +9,9 @@ from aiosmtpd.controller import Controller from email_server.auth import Authenticator, IPAuthenticator from email_server.email_relay import EmailRelay from email_server.dkim_manager import DKIMManager +from email_server.tool_box import get_logger -logger = logging.getLogger(__name__) +logger = get_logger() class CombinedAuthenticator: """Combined authenticator that tries username/password first, then falls back to IP whitelist.""" @@ -50,7 +50,7 @@ class CustomSMTPHandler: """Handle incoming email data.""" try: message_id = str(uuid.uuid4()) - logger.info(f'Received email {message_id} from {envelope.mail_from} to {envelope.rcpt_tos}') + logger.debug(f'Received email {message_id} from {envelope.mail_from} to {envelope.rcpt_tos}') # Convert content to string if it's bytes if isinstance(envelope.content, bytes): @@ -69,7 +69,7 @@ class CustomSMTPHandler: # Check if signing was successful (content changed) dkim_signed = signed_content != content if dkim_signed: - logger.info(f'Email {message_id} signed with DKIM for domain {sender_domain}') + logger.debug(f'Email {message_id} signed with DKIM for domain {sender_domain}') # Relay the email success = self.email_relay.relay_email( @@ -91,7 +91,7 @@ class CustomSMTPHandler: ) if success: - logger.info(f'Email {message_id} successfully relayed') + logger.debug(f'Email {message_id} successfully relayed') return '250 Message accepted for delivery' else: logger.error(f'Email {message_id} failed to relay') diff --git a/email_server/tls_utils.py b/email_server/tls_utils.py index 59a2a5c..d8dcdf3 100644 --- a/email_server/tls_utils.py +++ b/email_server/tls_utils.py @@ -4,12 +4,15 @@ TLS utilities for the SMTP server. import ssl import os -import logging from OpenSSL import crypto -from email_server.config import TLS_CERT_FILE, TLS_KEY_FILE -from email_server.tool_box import ensure_folder_exists +from email_server.settings_loader import load_settings +from email_server.tool_box import ensure_folder_exists, get_logger -logger = logging.getLogger(__name__) +settings = load_settings() +TLS_CERT_FILE = settings['TLS']['TLS_CERT_FILE'] +TLS_KEY_FILE = settings['TLS']['TLS_KEY_FILE'] + +logger = get_logger() ensure_folder_exists(TLS_CERT_FILE) ensure_folder_exists(TLS_KEY_FILE) @@ -17,11 +20,11 @@ ensure_folder_exists(TLS_KEY_FILE) def generate_self_signed_cert(): """Generate self-signed SSL certificate if it doesn't exist.""" if os.path.exists(TLS_CERT_FILE) and os.path.exists(TLS_KEY_FILE): - logger.info("SSL certificate already exists") + logger.debug("SSL certificate already exists") return True try: - logger.info("Generating self-signed SSL certificate...") + logger.debug("Generating self-signed SSL certificate...") # Generate private key k = crypto.PKey() @@ -47,7 +50,7 @@ def generate_self_signed_cert(): with open(TLS_KEY_FILE, 'wb') as f: f.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, k)) - logger.info(f"SSL certificate generated: {TLS_CERT_FILE}, {TLS_KEY_FILE}") + logger.debug(f"SSL certificate generated: {TLS_CERT_FILE}, {TLS_KEY_FILE}") return True except Exception as e: @@ -60,7 +63,7 @@ def create_ssl_context(): ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) ssl_context.load_cert_chain(certfile=TLS_CERT_FILE, keyfile=TLS_KEY_FILE) ssl_context.set_ciphers('DEFAULT') # Relax ciphers for compatibility - logger.info('SSL context created successfully') + logger.debug('SSL context created successfully') return ssl_context except Exception as e: logger.error(f'Failed to create SSL context: {e}') diff --git a/email_server/tool_box.py b/email_server/tool_box.py index 5df4484..97efd1e 100644 --- a/email_server/tool_box.py +++ b/email_server/tool_box.py @@ -3,6 +3,10 @@ Utility functions for the email server. """ import os +import logging +from email_server.settings_loader import load_settings + +settings = load_settings() def ensure_folder_exists(filepath): """ @@ -10,4 +14,46 @@ def ensure_folder_exists(filepath): """ if filepath.startswith("sqlite:///"): filepath = filepath.replace("sqlite:///", "", 1) - os.makedirs(os.path.dirname(filepath), exist_ok=True) \ No newline at end of file + os.makedirs(os.path.dirname(filepath), exist_ok=True) + + + +def setup_logging(): + """ + Set up global logging configuration using settings.ini. + Should be called once at program entry point. + Optionally hides aiosmtpd 'mail.log' INFO logs when global logging is INFO based on settings. + """ + log_level = getattr(logging, settings['Logging']['LOG_LEVEL'], logging.INFO) + log_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + if not logging.getLogger().hasHandlers(): + logging.basicConfig(level=log_level, format=log_format) + else: + logging.getLogger().setLevel(log_level) + + # Hide aiosmtpd INFO logs if configured `hide_info_aiosmtpd = true` + hide_info_aiosmtpd = settings['Logging'].get('hide_info_aiosmtpd', 'true').lower() == 'true' + if hide_info_aiosmtpd and log_level == logging.INFO: + # Set aiosmtpd mail.log to WARNING level to hide INFO logs + logging.getLogger('mail.log').setLevel(logging.WARNING) + +def get_logger(name=None): + """ + Get a logger with the given name (default: module name). + Ensures logging is set up before returning the logger. + """ + setup_logging() + if name is None: + # Get the caller's file name + import inspect + frame = inspect.currentframe() + # Go back one frame to the caller + caller_frame = frame.f_back + filename = caller_frame.f_globals.get('__file__', None) + if filename: + base = os.path.basename(filename) + name, ext = os.path.splitext(base) + name = name if ext == '.py' else base + else: + name = '__main__' + return logging.getLogger(name) \ No newline at end of file diff --git a/start_server.py b/start_server.py index 778f81e..4edfa02 100644 --- a/start_server.py +++ b/start_server.py @@ -1,13 +1,13 @@ from email_server.server_runner import start_server +from email_server.tool_box import get_logger import asyncio import sys -import logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) +logger = get_logger() if __name__ == '__main__': try: + logger.info('Server started') asyncio.run(start_server()) except KeyboardInterrupt: logger.info('Server interrupted by user') diff --git a/tests/Hello.jpg b/tests/Hello.jpg new file mode 100644 index 0000000..aa92c27 Binary files /dev/null and b/tests/Hello.jpg differ diff --git a/tests/bash_send_email.sh b/tests/bash_send_email.sh new file mode 100644 index 0000000..66007cc --- /dev/null +++ b/tests/bash_send_email.sh @@ -0,0 +1,68 @@ +#!/bin/bash +sender="test@example.com" +receiver="info@example.com" +password="testpass123" +domain="example.com" +body_content_file="@tests/email_body.txt" +SMTP_PORT=4025 +SMTP_TLS_PORT=40587 +cc_recipient="targetcc@example.com" +bcc_recipient="targetbcc@example.com" + +<