Files
PyMTA-server/app.py
2025-06-07 10:48:03 +01:00

428 lines
17 KiB
Python

#!/usr/bin/env python3
"""
Unified SMTP Server with Web Management Frontend
This application runs both the SMTP server and the Flask web frontend
in a single integrated application.
Usage:
python app.py # Run with default settings
python app.py --smtp-only # Run SMTP server only
python app.py --web-only # Run web frontend only
python app.py --debug # Enable debug mode
python app.py --init-data # Initialize sample data and exit
"""
import os
import sys
import asyncio
import threading
import signal
import argparse
from datetime import datetime
from flask import Flask, render_template, redirect, url_for, jsonify
from flask_sqlalchemy import SQLAlchemy
# Add the project root to Python path
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
# Import SMTP server components
from email_server.server_runner import start_server
from email_server.models import create_tables, Session, Domain, User, WhitelistedIP, DKIMKey, hash_password
from email_server.settings_loader import load_settings
from email_server.tool_box import get_logger
from email_server.dkim_manager import DKIMManager
# Import Flask frontend
from email_frontend.blueprint import email_bp
logger = get_logger()
class SMTPServerApp:
"""Unified SMTP Server and Web Frontend Application"""
def __init__(self, config_file='settings.ini'):
self.config_file = config_file
self.settings = load_settings()
self.flask_app = None
self.smtp_task = None
self.loop = None
self.shutdown_requested = False
# Setup signal handlers
signal.signal(signal.SIGINT, self._signal_handler)
signal.signal(signal.SIGTERM, self._signal_handler)
def _signal_handler(self, signum, frame):
"""Handle shutdown signals gracefully"""
logger.info(f"Received signal {signum}, initiating shutdown...")
self.shutdown_requested = True
if self.loop and self.loop.is_running():
self.loop.call_soon_threadsafe(self._stop_smtp_server)
def _stop_smtp_server(self):
"""Stop the SMTP server"""
if self.smtp_task and not self.smtp_task.done():
self.smtp_task.cancel()
logger.info("SMTP server stopped")
def create_flask_app(self):
"""Create and configure the Flask application"""
app = Flask(__name__,
static_folder='email_frontend/static',
template_folder='email_frontend/templates')
# Flask configuration
app.config.update({
'SECRET_KEY': self.settings.get('Flask', 'secret_key', fallback='change-this-secret-key-in-production'),
'SQLALCHEMY_DATABASE_URI': f"sqlite:///{self.settings.get('Database', 'database_path', fallback='email_server/server_data/smtp_server.db')}",
'SQLALCHEMY_TRACK_MODIFICATIONS': False,
'TEMPLATES_AUTO_RELOAD': True,
'SEND_FILE_MAX_AGE_DEFAULT': 0 # Disable caching for development
})
# Initialize database
db = SQLAlchemy(app)
# Create database tables if they don't exist
with app.app_context():
create_tables()
# Register the email management blueprint
app.register_blueprint(email_bp)
# Main application routes
@app.route('/')
def index():
"""Redirect root to email dashboard"""
return redirect(url_for('email.dashboard'))
@app.route('/health')
def health_check():
"""Health check endpoint"""
return jsonify({
'status': 'healthy',
'timestamp': datetime.utcnow().isoformat(),
'services': {
'smtp_server': 'running' if self.smtp_task and not self.smtp_task.done() else 'stopped',
'web_frontend': 'running'
},
'version': '1.0.0'
})
@app.route('/api/server/status')
def server_status():
"""Get detailed server status"""
session = Session()
try:
status = {
'smtp_server': {
'running': self.smtp_task and not self.smtp_task.done(),
'port': int(self.settings.get('Server', 'SMTP_PORT', fallback=25)),
'tls_port': int(self.settings.get('Server', 'SMTP_TLS_PORT', fallback=587)),
'hostname': self.settings.get('Server', 'hostname', fallback='localhost')
},
'database': {
'domains': session.query(Domain).filter_by(is_active=True).count(),
'users': session.query(User).filter_by(is_active=True).count(),
'dkim_keys': session.query(DKIMKey).filter_by(is_active=True).count(),
'whitelisted_ips': session.query(WhitelistedIP).filter_by(is_active=True).count()
},
'settings': {
'relay_enabled': self.settings.getboolean('Relay', 'enable_relay', fallback=False),
'tls_enabled': self.settings.getboolean('TLS', 'enable_tls', fallback=True),
'dkim_enabled': self.settings.getboolean('DKIM', 'enable_dkim', fallback=True)
}
}
return jsonify(status)
finally:
session.close()
@app.route('/api/server/restart', methods=['POST'])
def restart_server():
"""Restart the SMTP server (API endpoint)"""
try:
if self.smtp_task and not self.smtp_task.done():
self._stop_smtp_server()
# Start SMTP server in a new task
if self.loop:
self.smtp_task = asyncio.create_task(start_server())
return jsonify({'status': 'success', 'message': 'SMTP server restarted'})
else:
return jsonify({'status': 'error', 'message': 'Event loop not available'}), 500
except Exception as e:
logger.error(f"Error restarting server: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
# Error handlers
@app.errorhandler(404)
def not_found_error(error):
"""Handle 404 errors"""
return render_template('error.html',
error_code=404,
error_message="Page not found",
error_details="The requested page could not be found."), 404
@app.errorhandler(500)
def internal_error(error):
"""Handle 500 errors"""
logger.error(f"Internal error: {error}")
return render_template('error.html',
error_code=500,
error_message="Internal server error",
error_details=str(error)), 500
# Context processors for templates
@app.context_processor
def utility_processor():
"""Add utility functions to template context"""
return {
'moment': datetime,
'len': len,
'enumerate': enumerate,
'zip': zip,
'str': str,
'int': int,
}
self.flask_app = app
return app
def init_sample_data(self):
"""Initialize the database with sample data for testing"""
try:
# Initialize database
create_tables()
session = Session()
try:
# Add sample domains
sample_domains = [
'example.com',
'testdomain.org',
'mydomain.net'
]
for domain_name in sample_domains:
existing = session.query(Domain).filter_by(domain_name=domain_name).first()
if not existing:
domain = Domain(domain_name=domain_name)
session.add(domain)
logger.info(f"Added sample domain: {domain_name}")
session.commit()
# Generate DKIM keys for new domains
dkim_manager = DKIMManager()
for domain_name in sample_domains:
dkim_manager.generate_dkim_keypair(domain_name)
# Add sample users
sample_users = [
('admin@example.com', 'example.com', 'admin123', True),
('user@example.com', 'example.com', 'user123', False),
('test@testdomain.org', 'testdomain.org', 'test123', False)
]
for email, domain_name, password, can_send_as_domain in sample_users:
existing = session.query(User).filter_by(email=email).first()
if not existing:
domain = session.query(Domain).filter_by(domain_name=domain_name).first()
if domain:
user = User(
email=email,
password_hash=hash_password(password),
domain_id=domain.id,
can_send_as_domain=can_send_as_domain
)
session.add(user)
logger.info(f"Added sample user: {email}")
# Add sample whitelisted IPs
sample_ips = [
('127.0.0.1', 'example.com'),
('192.168.1.0/24', 'example.com'),
('10.0.0.0/8', 'testdomain.org')
]
for ip, domain_name in sample_ips:
domain = session.query(Domain).filter_by(domain_name=domain_name).first()
if domain:
existing = session.query(WhitelistedIP).filter_by(
ip_address=ip, domain_id=domain.id
).first()
if not existing:
whitelist = WhitelistedIP(
ip_address=ip,
domain_id=domain.id
)
session.add(whitelist)
logger.info(f"Added sample whitelisted IP: {ip} for {domain_name}")
session.commit()
logger.info("Sample data initialized successfully!")
finally:
session.close()
except Exception as e:
logger.error(f"Error initializing sample data: {e}")
raise
async def start_smtp_server(self):
"""Start the SMTP server in async context"""
try:
logger.info("Starting SMTP server...")
await start_server()
except Exception as e:
logger.error(f"SMTP server error: {e}")
if not self.shutdown_requested:
raise
def run_smtp_server(self):
"""Run SMTP server in a separate thread"""
try:
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)
self.smtp_task = self.loop.create_task(self.start_smtp_server())
self.loop.run_until_complete(self.smtp_task)
except asyncio.CancelledError:
logger.info("SMTP server task was cancelled")
except Exception as e:
if not self.shutdown_requested:
logger.error(f"SMTP server thread error: {e}")
finally:
if self.loop:
self.loop.close()
def run(self, smtp_only=False, web_only=False, debug=False, host='127.0.0.1', port=5000):
"""Run the unified application"""
if web_only:
# Run only Flask web frontend
logger.info("Starting web frontend only...")
app = self.create_flask_app()
try:
logger.info(f"Web frontend starting at http://{host}:{port}")
app.run(host=host, port=port, debug=debug, threaded=True, use_reloader=False)
except KeyboardInterrupt:
logger.info("Web server interrupted by user")
return
if smtp_only:
# Run only SMTP server
logger.info("Starting SMTP server only...")
try:
asyncio.run(self.start_smtp_server())
except KeyboardInterrupt:
logger.info("SMTP server interrupted by user")
return
# Run both SMTP server and web frontend
logger.info("Starting unified SMTP server with web management frontend...")
# Start SMTP server in a separate thread
smtp_thread = threading.Thread(target=self.run_smtp_server, daemon=True)
smtp_thread.start()
# Give SMTP server time to start
import time
time.sleep(2)
# Start Flask web frontend in main thread
try:
app = self.create_flask_app()
logger.info(f"Web frontend starting at http://{host}:{port}")
logger.info("SMTP server running in background")
app.run(host=host, port=port, debug=debug, threaded=True, use_reloader=False)
except KeyboardInterrupt:
logger.info("Application interrupted by user")
finally:
self.shutdown_requested = True
if self.loop:
self.loop.call_soon_threadsafe(self._stop_smtp_server)
def main():
"""Main function"""
parser = argparse.ArgumentParser(description='Unified SMTP Server with Web Management')
parser.add_argument('--smtp-only', action='store_true', help='Run SMTP server only')
parser.add_argument('--web-only', action='store_true', help='Run web frontend only')
parser.add_argument('--host', default='127.0.0.1', help='Web server host (default: 127.0.0.1)')
parser.add_argument('--port', type=int, default=5000, help='Web server port (default: 5000)')
parser.add_argument('--debug', action='store_true', help='Enable debug mode')
parser.add_argument('--init-data', action='store_true', help='Initialize sample data and exit')
parser.add_argument('--config', default='settings.ini', help='Configuration file path')
args = parser.parse_args()
# Create application instance
try:
app = SMTPServerApp(args.config)
# Initialize sample data if requested
if args.init_data:
logger.info("Initializing sample data...")
app.init_sample_data()
logger.info("Sample data initialization complete")
return
# Print startup information
settings = load_settings()
smtp_port = settings.get('Server', 'SMTP_PORT', fallback='25')
smtp_tls_port = settings.get('Server', 'SMTP_TLS_PORT', fallback='587')
print(f"""
╔══════════════════════════════════════════════════════════════════════════════╗
║ SMTP Server with Web Management ║
╚══════════════════════════════════════════════════════════════════════════════╝
Configuration:
• Configuration file: {args.config}
• SMTP Server ports: {smtp_port} (plain), {smtp_tls_port} (TLS)
• Web Interface: http://{args.host}:{args.port}
• Debug mode: {'ON' if args.debug else 'OFF'}
Services:
• SMTP Server: {'Starting...' if not args.web_only else 'Disabled'}
• Web Frontend: {'Starting...' if not args.smtp_only else 'Disabled'}
Available web routes:
• / → Dashboard
• /email/domains → Domain management
• /email/users → User management
• /email/ips → IP whitelist management
• /email/dkim → DKIM management
• /email/settings → Server settings
• /email/logs → Server logs
• /health → Health check
• /api/server/status → Server status API
To initialize sample data:
python app.py --init-data
Press Ctrl+C to stop the server
""")
# Run the application
app.run(
smtp_only=args.smtp_only,
web_only=args.web_only,
debug=args.debug,
host=args.host,
port=args.port
)
except KeyboardInterrupt:
logger.info("Application shutdown requested")
except Exception as e:
logger.error(f"Application error: {e}")
sys.exit(1)
if __name__ == '__main__':
main()