From a0dfe8a5354228b355bef912f294b57fbf34d074 Mon Sep 17 00:00:00 2001 From: nahakubuilde Date: Sun, 8 Jun 2025 22:51:07 +0100 Subject: [PATCH] update the website code files, fix dns check for DKIM --- email_server/dkim_manager.py | 2 +- email_server/models.py | 16 +- email_server/server_web_ui/__init__.py | 18 +- email_server/server_web_ui/dashboard.py | 38 + email_server/server_web_ui/database.py | 148 ++ email_server/server_web_ui/dkim.py | 442 ++++++ email_server/server_web_ui/domains.py | 214 +++ email_server/server_web_ui/ip_whitelist.py | 207 +++ email_server/server_web_ui/logs.py | 80 ++ email_server/server_web_ui/routes.py | 1234 +---------------- email_server/server_web_ui/senders.py | 215 +++ email_server/server_web_ui/settings.py | 197 +++ .../static/js/dkim-management.js | 266 ++++ .../{add_user.html => add_sender.html} | 2 +- .../server_web_ui/templates/dashboard.html | 2 +- .../server_web_ui/templates/dkim.html | 475 ++++--- .../server_web_ui/templates/domains.html | 111 +- .../{edit_user.html => edit_sender.html} | 2 +- email_server/server_web_ui/templates/ips.html | 2 +- .../templates/{users.html => senders.html} | 12 +- .../server_web_ui/templates/settings.html | 506 +++++-- .../templates/sidebar_email.html | 4 +- email_server/server_web_ui/utils.py | 164 +++ main.py_OLD | 20 - 24 files changed, 2747 insertions(+), 1630 deletions(-) create mode 100644 email_server/server_web_ui/dashboard.py create mode 100644 email_server/server_web_ui/database.py create mode 100644 email_server/server_web_ui/dkim.py create mode 100644 email_server/server_web_ui/domains.py create mode 100644 email_server/server_web_ui/ip_whitelist.py create mode 100644 email_server/server_web_ui/logs.py create mode 100644 email_server/server_web_ui/senders.py create mode 100644 email_server/server_web_ui/settings.py create mode 100644 email_server/server_web_ui/static/js/dkim-management.js rename email_server/server_web_ui/templates/{add_user.html => add_sender.html} (98%) rename email_server/server_web_ui/templates/{edit_user.html => edit_sender.html} (98%) rename email_server/server_web_ui/templates/{users.html => senders.html} (95%) create mode 100644 email_server/server_web_ui/utils.py delete mode 100644 main.py_OLD diff --git a/email_server/dkim_manager.py b/email_server/dkim_manager.py index 519a346..0b3274c 100644 --- a/email_server/dkim_manager.py +++ b/email_server/dkim_manager.py @@ -161,7 +161,7 @@ class DKIMManager: return { 'name': f'{dkim_key.selector}._domainkey.{domain_name}', 'type': 'TXT', - 'value': f'v=DKIM1; k=rsa; p={public_key_data}' + 'value': f'"v=DKIM1; k=rsa; p={public_key_data}"' # Wrap in quotes } return None diff --git a/email_server/models.py b/email_server/models.py index 00b1bdf..a2c4fed 100644 --- a/email_server/models.py +++ b/email_server/models.py @@ -7,7 +7,7 @@ Enhanced security features: - All tables use 'esrv_' prefix for namespace isolation """ -from sqlalchemy import create_engine, Column, Integer, String, DateTime, Text, Boolean +from sqlalchemy import create_engine, Column, Integer, String, DateTime, Text, Boolean, ForeignKey from sqlalchemy.orm import declarative_base, sessionmaker, relationship from sqlalchemy.sql import func from datetime import datetime @@ -37,6 +37,12 @@ class Domain(Base): is_active = Column(Boolean, default=True) created_at = Column(DateTime, default=func.now()) + # Add relationships with proper foreign key references + users = relationship("User", backref="domain", lazy="joined") + dkim_keys = relationship("DKIMKey", backref="domain", lazy="joined") + whitelisted_ips = relationship("WhitelistedIP", backref="domain", lazy="joined") + custom_headers = relationship("CustomHeader", backref="domain", lazy="joined") + def __repr__(self): return f"" @@ -53,7 +59,7 @@ class User(Base): id = Column(Integer, primary_key=True) email = Column(String, unique=True, nullable=False) password_hash = Column(String, nullable=False) - domain_id = Column(Integer, nullable=False) + domain_id = Column(Integer, ForeignKey('esrv_domains.id'), nullable=False) can_send_as_domain = Column(Boolean, default=False) is_active = Column(Boolean, default=True) created_at = Column(DateTime, default=func.now()) @@ -94,7 +100,7 @@ class WhitelistedIP(Base): id = Column(Integer, primary_key=True) ip_address = Column(String, nullable=False) - domain_id = Column(Integer, nullable=False) + domain_id = Column(Integer, ForeignKey('esrv_domains.id'), nullable=False) is_active = Column(Boolean, default=True) created_at = Column(DateTime, default=func.now()) @@ -171,7 +177,7 @@ class DKIMKey(Base): __tablename__ = 'esrv_dkim_keys' id = Column(Integer, primary_key=True) - domain_id = Column(Integer, nullable=False) + domain_id = Column(Integer, ForeignKey('esrv_domains.id'), nullable=False) selector = Column(String, nullable=False, default='default') private_key = Column(Text, nullable=False) public_key = Column(Text, nullable=False) @@ -187,7 +193,7 @@ class CustomHeader(Base): __tablename__ = 'esrv_custom_headers' id = Column(Integer, primary_key=True) - domain_id = Column(Integer, nullable=False) + domain_id = Column(Integer, ForeignKey('esrv_domains.id'), nullable=False) header_name = Column(String, nullable=False) header_value = Column(String, nullable=False) is_active = Column(Boolean, default=True) diff --git a/email_server/server_web_ui/__init__.py b/email_server/server_web_ui/__init__.py index 1f955f7..55e06f1 100644 --- a/email_server/server_web_ui/__init__.py +++ b/email_server/server_web_ui/__init__.py @@ -1 +1,17 @@ -# Email frontend module +""" +SMTP Server Web UI Package + +This package provides a web interface for managing the SMTP server. +""" + +# First import the blueprint definition +from .routes import email_bp + +# Then import all route functions +from .dashboard import * +from .domains import * +from .senders import * +from .ip_whitelist import * +from .dkim import * +from .settings import * +from .logs import * diff --git a/email_server/server_web_ui/dashboard.py b/email_server/server_web_ui/dashboard.py new file mode 100644 index 0000000..ad7e1e6 --- /dev/null +++ b/email_server/server_web_ui/dashboard.py @@ -0,0 +1,38 @@ +""" +Dashboard routes for the SMTP server web UI. + +This module provides the main dashboard view and overview functionality. +""" + +from flask import render_template +from email_server.models import Session, Domain, User, DKIMKey, EmailLog, AuthLog +from email_server.tool_box import get_logger +from .routes import email_bp + +logger = get_logger() + +# Dashboard and Main Routes +@email_bp.route('/') +def dashboard(): + """Main dashboard showing overview of the email server.""" + session = Session() + try: + # Get counts + domain_count = session.query(Domain).filter_by(is_active=True).count() + user_count = session.query(User).filter_by(is_active=True).count() + dkim_count = session.query(DKIMKey).filter_by(is_active=True).count() + + # Get recent email logs + recent_emails = session.query(EmailLog).order_by(EmailLog.created_at.desc()).limit(10).all() + + # Get recent auth logs + recent_auths = session.query(AuthLog).order_by(AuthLog.created_at.desc()).limit(10).all() + + return render_template('dashboard.html', + domain_count=domain_count, + user_count=user_count, + dkim_count=dkim_count, + recent_emails=recent_emails, + recent_auths=recent_auths) + finally: + session.close() \ No newline at end of file diff --git a/email_server/server_web_ui/database.py b/email_server/server_web_ui/database.py new file mode 100644 index 0000000..dbc2d2d --- /dev/null +++ b/email_server/server_web_ui/database.py @@ -0,0 +1,148 @@ +""" +Database utilities for the SMTP server. +""" + +import sys +import subprocess +from urllib.parse import urlparse +from email_server.tool_box import get_logger + +logger = get_logger() + +# Database driver mappings +DB_DRIVERS = { + 'mysql': { + 'package': 'pymysql', + 'import_name': 'pymysql', + 'friendly_name': 'MySQL' + }, + 'postgresql': { + 'package': 'psycopg2-binary', + 'import_name': 'psycopg2', + 'friendly_name': 'PostgreSQL' + }, + 'mssql': { + 'package': 'pyodbc', + 'import_name': 'pyodbc', + 'friendly_name': 'MSSQL' + } +} + +def install_package(package_name: str) -> bool: + """ + Install a Python package using pip. + + Args: + package_name: Name of the package to install + + Returns: + bool: True if installation was successful, False otherwise + """ + try: + logger.info(f"Installing {package_name}...") + subprocess.check_call([sys.executable, "-m", "pip", "install", package_name]) + return True + except subprocess.CalledProcessError as e: + logger.error(f"Failed to install {package_name}: {e}") + return False + +def import_or_install_driver(db_type: str) -> bool: + """ + Import database driver, installing it if necessary. + + Args: + db_type: Type of database ('mysql', 'postgresql', 'mssql') + + Returns: + bool: True if driver is available (installed or already present), False otherwise + """ + if db_type not in DB_DRIVERS: + return True # SQLite or unsupported type + + driver_info = DB_DRIVERS[db_type] + try: + __import__(driver_info['import_name']) + return True + except ImportError: + logger.warning(f"{driver_info['friendly_name']} driver not found. Attempting to install...") + if install_package(driver_info['package']): + try: + __import__(driver_info['import_name']) + logger.info(f"Successfully installed {driver_info['friendly_name']} driver") + return True + except ImportError: + logger.error(f"Failed to import {driver_info['friendly_name']} driver after installation") + return False + return False + +def test_database_connection(url: str) -> tuple[bool, str]: + """ + Test if a database connection can be established. + + Args: + url: Database connection URL + + Returns: + tuple: (success: bool, message: str) + + Supported URL formats: + - sqlite:///path/to/file.db + - mysql://user:password@host:port/dbname + - postgresql://user:password@host:port/dbname + - mssql+pyodbc://user:password@host:port/dbname?driver=ODBC+Driver+17+for+SQL+Server + """ + try: + # Parse the database URL + parsed = urlparse(url) + scheme = parsed.scheme.lower() + + # SQLite connection test + if scheme == 'sqlite': + import sqlite3 + conn = sqlite3.connect(url.replace('sqlite:///', '')) + conn.close() + return True, "Successfully connected to SQLite database" + + # Other database types + db_type = scheme.split('+')[0] # Handle mssql+pyodbc + if db_type not in DB_DRIVERS and db_type != 'sqlite': + return False, f"Unsupported database type: {scheme}" + + # Try to import/install the required driver + if not import_or_install_driver(db_type): + return False, f"Failed to install required driver for {DB_DRIVERS[db_type]['friendly_name']}" + + # MySQL connection test + if db_type == 'mysql': + import pymysql + params = { + 'host': parsed.hostname, + 'port': parsed.port or 3306, + 'user': parsed.username, + 'password': parsed.password, + 'db': parsed.path.lstrip('/') + } + conn = pymysql.connect(**params) + conn.close() + return True, "Successfully connected to MySQL database" + + # PostgreSQL connection test + elif db_type == 'postgresql': + import psycopg2 + conn = psycopg2.connect(url) + conn.close() + return True, "Successfully connected to PostgreSQL database" + + # MSSQL connection test + elif db_type == 'mssql': + import pyodbc + conn = pyodbc.connect(url.replace('mssql+pyodbc://', '')) + conn.close() + return True, "Successfully connected to MSSQL database" + + except Exception as e: + error_msg = str(e) + logger.error(f"Database connection error: {error_msg}") + return False, f"Connection error: {error_msg}" + + return False, "Unknown error occurred" \ No newline at end of file diff --git a/email_server/server_web_ui/dkim.py b/email_server/server_web_ui/dkim.py new file mode 100644 index 0000000..6f7e16b --- /dev/null +++ b/email_server/server_web_ui/dkim.py @@ -0,0 +1,442 @@ +""" +DKIM blueprint for the SMTP server web UI. + +This module provides DKIM key management functionality including: +- DKIM key listing +- DKIM key creation +- DKIM key regeneration +- DKIM key editing +- DKIM DNS verification +""" + +from flask import render_template, request, redirect, url_for, flash, jsonify +from datetime import datetime +import re +from email_server.models import Session, Domain, DKIMKey +from email_server.dkim_manager import DKIMManager +from email_server.tool_box import get_logger +from .utils import get_public_ip, check_dns_record, generate_spf_record +from .routes import email_bp + +logger = get_logger() + + +@email_bp.route('/dkim') +def dkim_list(): + """List all DKIM keys and DNS records.""" + session = Session() + try: + # Get active DKIM keys + active_dkim_keys = session.query(DKIMKey, Domain).join( + Domain, DKIMKey.domain_id == Domain.id + ).filter(DKIMKey.is_active == True).order_by(Domain.domain_name).all() + + # Get old/inactive DKIM keys (prioritize replaced keys over disabled ones) + old_dkim_keys = session.query(DKIMKey, Domain).join( + Domain, DKIMKey.domain_id == Domain.id + ).filter(DKIMKey.is_active == False).order_by( + Domain.domain_name, + DKIMKey.replaced_at.desc().nullslast(), # Replaced keys first, then disabled ones + DKIMKey.created_at.desc() + ).all() + + # Get public IP for SPF records + public_ip = get_public_ip() + + # Prepare active DKIM data with DNS information + active_dkim_data = [] + for dkim_key, domain in active_dkim_keys: + # Get DKIM DNS record + dkim_manager = DKIMManager() + dns_record = dkim_manager.get_dkim_public_key_record(domain.domain_name) + + # Check existing SPF record + spf_check = check_dns_record(domain.domain_name, 'TXT') + existing_spf = None + if spf_check['success']: + for record in spf_check['records']: + if 'v=spf1' in record: + existing_spf = record + break + + # Generate recommended SPF + recommended_spf = generate_spf_record(domain.domain_name, public_ip, existing_spf) + + active_dkim_data.append({ + 'dkim_key': dkim_key, + 'domain': domain, + 'dns_record': dns_record, + 'existing_spf': existing_spf, + 'recommended_spf': recommended_spf, + 'public_ip': public_ip + }) + + # Prepare old DKIM data with status information + old_dkim_data = [] + for dkim_key, domain in old_dkim_keys: + old_dkim_data.append({ + 'dkim_key': dkim_key, + 'domain': domain, + 'public_ip': public_ip, + 'is_replaced': dkim_key.replaced_at is not None, + 'status_text': 'Replaced' if dkim_key.replaced_at else 'Disabled' + }) + + return render_template('dkim.html', + dkim_data=active_dkim_data, + old_dkim_data=old_dkim_data) + finally: + session.close() + + +@email_bp.route('/dkim/create', methods=['POST'], endpoint='create_dkim') +def create_dkim(): + """Create a new DKIM key for a domain, optionally with a custom selector.""" + from flask import request, jsonify + data = request.get_json() if request.is_json else request.form + domain_name = data.get('domain') + selector = data.get('selector', None) + session = Session() + try: + if not domain_name: + return jsonify({'success': False, 'message': 'Domain is required.'}), 400 + domain = session.query(Domain).filter_by(domain_name=domain_name).first() + if not domain: + return jsonify({'success': False, 'message': 'Domain not found.'}), 404 + # Deactivate any existing active DKIM key for this domain + active_keys = session.query(DKIMKey).filter_by(domain_id=domain.id, is_active=True).all() + for key in active_keys: + key.is_active = False + key.replaced_at = datetime.now() + # Create new DKIM key + dkim_manager = DKIMManager() + created = dkim_manager.generate_dkim_keypair(domain_name, selector=selector, force_new_key=True) + if created: + session.commit() + return jsonify({'success': True, 'message': f'DKIM key created for {domain_name}.'}) + else: + session.rollback() + return jsonify({'success': False, 'message': f'Failed to create DKIM key for {domain_name}.'}), 500 + except Exception as e: + session.rollback() + logger.error(f"Error creating DKIM: {e}") + return jsonify({'success': False, 'message': f'Error creating DKIM: {str(e)}'}), 500 + finally: + session.close() + +@email_bp.route('/dkim//regenerate', methods=['POST']) +def regenerate_dkim(domain_id: int): + """Regenerate DKIM key for domain.""" + session = Session() + try: + domain = session.query(Domain).get(domain_id) + if not domain: + if request.headers.get('Content-Type') == 'application/json': + return jsonify({'success': False, 'message': 'Domain not found'}) + flash('Domain not found', 'error') + return redirect(url_for('email.dkim_list')) + + # Get the current active DKIM key's selector to preserve it + existing_keys = session.query(DKIMKey).filter_by(domain_id=domain_id, is_active=True).all() + current_selector = None + if existing_keys: + # Use the selector from the first active key (there should typically be only one) + current_selector = existing_keys[0].selector + + # Mark existing keys as replaced + for key in existing_keys: + key.is_active = False + key.replaced_at = datetime.now() # Mark when this key was replaced + + # Generate new DKIM key preserving the existing selector + dkim_manager = DKIMManager() + if dkim_manager.generate_dkim_keypair(domain.domain_name, selector=current_selector, force_new_key=True): + session.commit() + + # Get the new key data for AJAX response + new_key = session.query(DKIMKey).filter_by( + domain_id=domain_id, is_active=True + ).order_by(DKIMKey.created_at.desc()).first() + + if not new_key: + session.rollback() + if request.headers.get('Content-Type') == 'application/json' or request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': False, 'message': f'Failed to create new DKIM key for {domain.domain_name}'}) + flash(f'Failed to create new DKIM key for {domain.domain_name}', 'error') + return redirect(url_for('email.dkim_list')) + + if request.headers.get('Content-Type') == 'application/json' or request.headers.get('X-Requested-With') == 'XMLHttpRequest': + # Get updated DNS record for the new key + dns_record = dkim_manager.get_dkim_public_key_record(domain.domain_name) + public_ip = get_public_ip() + + # Check existing SPF record + spf_check = check_dns_record(domain.domain_name, 'TXT') + existing_spf = None + if spf_check['success']: + for record in spf_check['records']: + if 'v=spf1' in record: + existing_spf = record + break + + recommended_spf = generate_spf_record(domain.domain_name, public_ip, existing_spf) + + # Get replaced keys for the Old DKIM section update + old_keys = session.query(DKIMKey, Domain).join( + Domain, DKIMKey.domain_id == Domain.id + ).filter( + DKIMKey.domain_id == domain_id, + DKIMKey.is_active == False + ).order_by(DKIMKey.created_at.desc()).all() + + old_dkim_data = [] + for old_key, old_domain in old_keys: + status_text = "Replaced" if old_key.replaced_at else "Disabled" + old_dkim_data.append({ + 'dkim_key': { + 'id': old_key.id, + 'selector': old_key.selector, + 'created_at': old_key.created_at.strftime('%Y-%m-%d %H:%M'), + 'replaced_at': old_key.replaced_at.strftime('%Y-%m-%d %H:%M') if old_key.replaced_at else None, + 'is_active': old_key.is_active + }, + 'domain': { + 'id': old_domain.id, + 'domain_name': old_domain.domain_name + }, + 'status_text': status_text, + 'public_ip': public_ip + }) + + # Additional null check for new_key before accessing its attributes + if not new_key: + logger.error(f"new_key is None after generation for domain {domain.domain_name}") + return jsonify({'success': False, 'message': f'Failed to retrieve new DKIM key for {domain.domain_name}'}) + + return jsonify({ + 'success': True, + 'message': f'DKIM key regenerated for {domain.domain_name}', + 'new_key': { + 'id': new_key.id, + 'selector': new_key.selector, + 'created_at': new_key.created_at.strftime('%Y-%m-%d %H:%M'), + 'is_active': new_key.is_active + }, + 'dns_record': { + 'name': dns_record['name'] if dns_record else '', + 'value': dns_record['value'] if dns_record else '' + }, + 'existing_spf': existing_spf, + 'recommended_spf': recommended_spf, + 'public_ip': public_ip, + 'domain': { + 'id': domain.id, + 'domain_name': domain.domain_name + }, + 'old_dkim_data': old_dkim_data + }) + + flash(f'DKIM key regenerated for {domain.domain_name}', 'success') + else: + session.rollback() + if request.headers.get('Content-Type') == 'application/json' or request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': False, 'message': f'Failed to regenerate DKIM key for {domain.domain_name}'}) + flash(f'Failed to regenerate DKIM key for {domain.domain_name}', 'error') + + return redirect(url_for('email.dkim_list')) + + except Exception as e: + session.rollback() + logger.error(f"Error regenerating DKIM: {e}") + if request.headers.get('Content-Type') == 'application/json' or request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': False, 'message': f'Error regenerating DKIM: {str(e)}'}) + flash(f'Error regenerating DKIM: {str(e)}', 'error') + return redirect(url_for('email.dkim_list')) + finally: + session.close() + +@email_bp.route('/dkim//edit', methods=['GET', 'POST']) +def edit_dkim(dkim_id: int): + """Edit DKIM key selector.""" + session = Session() + try: + dkim_key = session.query(DKIMKey).get(dkim_id) + if not dkim_key: + flash('DKIM key not found', 'error') + return redirect(url_for('email.dkim_list')) + + domain = session.query(Domain).get(dkim_key.domain_id) + + if request.method == 'POST': + new_selector = request.form.get('selector', '').strip() + + if not new_selector: + flash('Selector name is required', 'error') + return render_template('edit_dkim.html', dkim_key=dkim_key, domain=domain) + + # Validate selector (alphanumeric only) + if not re.match(r'^[a-zA-Z0-9_-]+$', new_selector): + flash('Selector must contain only letters, numbers, hyphens, and underscores', 'error') + return render_template('edit_dkim.html', dkim_key=dkim_key, domain=domain) + + # Check for duplicate selector in same domain + existing = session.query(DKIMKey).filter_by( + domain_id=dkim_key.domain_id, + selector=new_selector, + is_active=True + ).filter(DKIMKey.id != dkim_id).first() + + if existing: + flash(f'A DKIM key with selector "{new_selector}" already exists for this domain', 'error') + return render_template('edit_dkim.html', dkim_key=dkim_key, domain=domain) + + old_selector = dkim_key.selector + dkim_key.selector = new_selector + session.commit() + + flash(f'DKIM selector updated from "{old_selector}" to "{new_selector}" for {domain.domain_name}', 'success') + return redirect(url_for('email.dkim_list')) + + return render_template('edit_dkim.html', dkim_key=dkim_key, domain=domain) + + except Exception as e: + session.rollback() + logger.error(f"Error editing DKIM: {e}") + flash(f'Error editing DKIM key: {str(e)}', 'error') + return redirect(url_for('email.dkim_list')) + finally: + session.close() + +@email_bp.route('/dkim//toggle', methods=['POST']) +def toggle_dkim(dkim_id: int): + """Toggle DKIM key active status (Enable/Disable).""" + session = Session() + try: + dkim_key = session.query(DKIMKey).get(dkim_id) + if not dkim_key: + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': False, 'message': 'DKIM key not found'}) + flash('DKIM key not found', 'error') + return redirect(url_for('email.dkim_list')) + + domain = session.query(Domain).get(dkim_key.domain_id) + old_status = dkim_key.is_active + + if not old_status: + # About to activate this key, so deactivate any other active DKIM for this domain + other_active_keys = session.query(DKIMKey).filter( + DKIMKey.domain_id == dkim_key.domain_id, + DKIMKey.is_active == True, + DKIMKey.id != dkim_id + ).all() + for key in other_active_keys: + key.is_active = False + key.replaced_at = datetime.now() + + dkim_key.is_active = not old_status + if dkim_key.is_active: + dkim_key.replaced_at = None + + session.commit() + status_text = "enabled" if dkim_key.is_active else "disabled" + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({ + 'success': True, + 'message': f'DKIM key for {domain.domain_name} (selector: {dkim_key.selector}) has been {status_text}', + 'is_active': dkim_key.is_active + }) + + flash(f'DKIM key for {domain.domain_name} (selector: {dkim_key.selector}) has been {status_text}', 'success') + return redirect(url_for('email.dkim_list')) + except Exception as e: + session.rollback() + logger.error(f"Error toggling DKIM status: {e}") + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': False, 'message': f'Error toggling DKIM status: {str(e)}'}) + flash(f'Error toggling DKIM status: {str(e)}', 'error') + return redirect(url_for('email.dkim_list')) + finally: + session.close() + +@email_bp.route('/dkim//remove', methods=['POST']) +def remove_dkim(dkim_id: int): + """Permanently remove DKIM key.""" + session = Session() + try: + dkim_key = session.query(DKIMKey).get(dkim_id) + if not dkim_key: + flash('DKIM key not found', 'error') + return redirect(url_for('email.dkim_list')) + + domain = session.query(Domain).get(dkim_key.domain_id) + selector = dkim_key.selector + + session.delete(dkim_key) + session.commit() + + flash(f'DKIM key for {domain.domain_name} (selector: {selector}) has been permanently removed', 'success') + return redirect(url_for('email.dkim_list')) + + except Exception as e: + session.rollback() + logger.error(f"Error removing DKIM key: {e}") + flash(f'Error removing DKIM key: {str(e)}', 'error') + return redirect(url_for('email.dkim_list')) + finally: + session.close() + +# AJAX DNS Check Routes +@email_bp.route('/dkim/check_dns', methods=['POST']) +def check_dkim_dns(): + """Check DKIM DNS record via AJAX.""" + domain = request.form.get('domain') + selector = request.form.get('selector') + + if not all([domain, selector]): + return jsonify({'success': False, 'message': 'Missing domain or selector parameters'}) + + # Get the expected DKIM value from the DKIM manager + try: + dkim_manager = DKIMManager() + dns_record = dkim_manager.get_dkim_public_key_record(domain) + + if not dns_record or not dns_record.get('value'): + return jsonify({'success': False, 'message': 'No DKIM key found for domain'}) + + expected_value = dns_record['value'] + + dns_name = f"{selector}._domainkey.{domain}" + result = check_dns_record(dns_name, 'TXT', expected_value) + + return jsonify(result) + except Exception as e: + logger.error(f"Error checking DKIM DNS: {e}") + return jsonify({'success': False, 'message': f'Error checking DKIM DNS: {str(e)}'}) + +@email_bp.route('/dkim/check_spf', methods=['POST']) +def check_spf_dns(): + """Check SPF DNS record via AJAX.""" + domain = request.form.get('domain') + + if not domain: + return jsonify({'success': False, 'message': 'Domain is required'}) + + result = check_dns_record(domain, 'TXT') + + # Look for SPF record + spf_record = None + if result['success']: + for record in result['records']: + if 'v=spf1' in record: + spf_record = record + break + + if spf_record: + result['spf_record'] = spf_record + result['message'] = 'SPF record found' + else: + result['success'] = False + result['message'] = 'No SPF record found' + + return jsonify(result) \ No newline at end of file diff --git a/email_server/server_web_ui/domains.py b/email_server/server_web_ui/domains.py new file mode 100644 index 0000000..e5af0f8 --- /dev/null +++ b/email_server/server_web_ui/domains.py @@ -0,0 +1,214 @@ +""" +Domains blueprint for the SMTP server web UI. + +This module provides domain management functionality including: +- Domain listing +- Domain creation +- Domain editing +- Domain deletion +- Domain status toggling +""" + +from flask import render_template, request, redirect, url_for, flash +from email_server.models import Session, Domain, User, WhitelistedIP, DKIMKey, CustomHeader +from email_server.dkim_manager import DKIMManager +from email_server.tool_box import get_logger +from sqlalchemy.orm import joinedload +from .routes import email_bp + + +logger = get_logger() + +@email_bp.route('/domains') +def domains_list(): + """List all domains.""" + session = Session() + try: + # Query domains - relationships will be loaded automatically due to lazy="joined" + domains = session.query(Domain).order_by(Domain.domain_name).all() + return render_template('domains.html', domains=domains) + except Exception as e: + logger.error(f"Error listing domains: {e}") + flash('Error loading domains', 'error') + return redirect(url_for('email.domains_list')) + finally: + session.close() + +@email_bp.route('/domains/add', methods=['GET', 'POST']) +def add_domain(): + """Add new domain.""" + if request.method == 'POST': + domain_name = request.form.get('domain_name', '').strip().lower() + + if not domain_name: + flash('Domain name is required', 'error') + return redirect(url_for('email.add_domain')) + + session = Session() + try: + # Check if domain already exists + existing = session.query(Domain).filter_by(domain_name=domain_name).first() + if existing: + flash(f'Domain {domain_name} already exists', 'error') + return redirect(url_for('email.domains_list')) + + # Create domain + domain = Domain(domain_name=domain_name) + session.add(domain) + session.commit() + + # Generate DKIM key for the domain + dkim_manager = DKIMManager() + dkim_manager.generate_dkim_keypair(domain_name) + + flash(f'Domain {domain_name} added successfully with DKIM key', 'success') + return redirect(url_for('email.domains_list')) + + except Exception as e: + session.rollback() + logger.error(f"Error adding domain: {e}") + flash(f'Error adding domain: {str(e)}', 'error') + return redirect(url_for('email.add_domain')) + finally: + session.close() + + return render_template('add_domain.html') + +@email_bp.route('/domains//delete', methods=['POST']) +def delete_domain(domain_id: int): + """Delete domain (soft delete).""" + session = Session() + try: + domain = session.query(Domain).get(domain_id) + if not domain: + flash('Domain not found', 'error') + return redirect(url_for('email.domains_list')) + + domain_name = domain.domain_name + domain.is_active = False + session.commit() + + flash(f'Domain {domain_name} disabled', 'success') + return redirect(url_for('email.domains_list')) + + except Exception as e: + session.rollback() + logger.error(f"Error disabling domain: {e}") + flash(f'Error disabling domain: {str(e)}', 'error') + return redirect(url_for('email.domains_list')) + finally: + session.close() + +@email_bp.route('/domains//edit', methods=['GET', 'POST']) +def edit_domain(domain_id: int): + """Edit domain.""" + session = Session() + try: + domain = session.query(Domain).get(domain_id) + if not domain: + flash('Domain not found', 'error') + return redirect(url_for('email.domains_list')) + + if request.method == 'POST': + domain_name = request.form.get('domain_name', '').strip().lower() + requires_auth = request.form.get('requires_auth') == 'on' + + if not domain_name: + flash('Domain name is required', 'error') + return redirect(url_for('email.edit_domain', domain_id=domain_id)) + + # Basic domain validation + if '.' not in domain_name or len(domain_name.split('.')) < 2: + flash('Invalid domain format', 'error') + return redirect(url_for('email.edit_domain', domain_id=domain_id)) + + # Check if domain name already exists (excluding current domain) + existing = session.query(Domain).filter( + Domain.domain_name == domain_name, + Domain.id != domain_id + ).first() + if existing: + flash(f'Domain {domain_name} already exists', 'error') + return redirect(url_for('email.edit_domain', domain_id=domain_id)) + + old_name = domain.domain_name + domain.domain_name = domain_name + domain.requires_auth = requires_auth + session.commit() + + flash(f'Domain updated from "{old_name}" to "{domain_name}"', 'success') + return redirect(url_for('email.domains_list')) + + return render_template('edit_domain.html', domain=domain) + + except Exception as e: + session.rollback() + logger.error(f"Error editing domain: {e}") + flash(f'Error editing domain: {str(e)}', 'error') + return redirect(url_for('email.domains_list')) + finally: + session.close() + +@email_bp.route('/domains//toggle', methods=['POST']) +def toggle_domain(domain_id: int): + """Toggle domain active status (Enable/Disable).""" + session = Session() + try: + domain = session.query(Domain).get(domain_id) + if not domain: + flash('Domain not found', 'error') + return redirect(url_for('email.domains_list')) + + old_status = domain.is_active + domain.is_active = not old_status + session.commit() + + status_text = "enabled" if domain.is_active else "disabled" + flash(f'Domain {domain.domain_name} has been {status_text}', 'success') + return redirect(url_for('email.domains_list')) + + except Exception as e: + session.rollback() + logger.error(f"Error toggling domain status: {e}") + flash(f'Error toggling domain status: {str(e)}', 'error') + return redirect(url_for('email.domains_list')) + finally: + session.close() + +@email_bp.route('/domains//remove', methods=['POST']) +def remove_domain(domain_id: int): + """Permanently remove domain and all associated data.""" + session = Session() + try: + domain = session.query(Domain).get(domain_id) + if not domain: + flash('Domain not found', 'error') + return redirect(url_for('email.domains_list')) + + domain_name = domain.domain_name + + # Count associated records + user_count = session.query(User).filter_by(domain_id=domain_id).count() + ip_count = session.query(WhitelistedIP).filter_by(domain_id=domain_id).count() + dkim_count = session.query(DKIMKey).filter_by(domain_id=domain_id).count() + + # Delete associated records + session.query(User).filter_by(domain_id=domain_id).delete() + session.query(WhitelistedIP).filter_by(domain_id=domain_id).delete() + session.query(DKIMKey).filter_by(domain_id=domain_id).delete() + session.query(CustomHeader).filter_by(domain_id=domain_id).delete() + + # Delete domain + session.delete(domain) + session.commit() + + flash(f'Domain {domain_name} and all associated data permanently removed ({user_count} users, {ip_count} IPs, {dkim_count} DKIM keys)', 'success') + return redirect(url_for('email.domains_list')) + + except Exception as e: + session.rollback() + logger.error(f"Error removing domain: {e}") + flash(f'Error removing domain: {str(e)}', 'error') + return redirect(url_for('email.domains_list')) + finally: + session.close() \ No newline at end of file diff --git a/email_server/server_web_ui/ip_whitelist.py b/email_server/server_web_ui/ip_whitelist.py new file mode 100644 index 0000000..a9ed0ae --- /dev/null +++ b/email_server/server_web_ui/ip_whitelist.py @@ -0,0 +1,207 @@ +""" +IP Whitelist blueprint for the SMTP server web UI. + +This module provides IP whitelist management functionality including: +- IP whitelist listing +- IP whitelist creation +- IP whitelist editing +- IP whitelist deletion +""" + +from flask import render_template, request, redirect, url_for, flash +from email_server.models import Session, Domain, WhitelistedIP +from email_server.tool_box import get_logger +from .routes import email_bp +import socket + +logger = get_logger() + + +@email_bp.route('/ips') +def ips_list(): + """List all whitelisted IPs.""" + session = Session() + try: + ips = session.query(WhitelistedIP, Domain).join(Domain, WhitelistedIP.domain_id == Domain.id).order_by(WhitelistedIP.ip_address).all() + return render_template('ips.html', ips=ips) + finally: + session.close() + +@email_bp.route('/ips/add', methods=['GET', 'POST']) +def add_ip(): + """Add new whitelisted IP.""" + session = Session() + try: + domains = session.query(Domain).filter_by(is_active=True).order_by(Domain.domain_name).all() + + if request.method == 'POST': + ip_address = request.form.get('ip_address', '').strip() + domain_id = request.form.get('domain_id', type=int) + + if not all([ip_address, domain_id]): + flash('All fields are required', 'error') + return redirect(url_for('email.add_ip')) + + # Basic IP validation + try: + socket.inet_aton(ip_address) + except socket.error: + flash('Invalid IP address format', 'error') + return redirect(url_for('email.add_ip')) + + # Check if IP already exists for this domain + existing = session.query(WhitelistedIP).filter_by(ip_address=ip_address, domain_id=domain_id).first() + if existing: + flash(f'IP {ip_address} already whitelisted for this domain', 'error') + return redirect(url_for('email.ips_list')) + + # Create whitelisted IP + whitelist = WhitelistedIP( + ip_address=ip_address, + domain_id=domain_id + ) + session.add(whitelist) + session.commit() + + flash(f'IP {ip_address} added to whitelist', 'success') + return redirect(url_for('email.ips_list')) + + return render_template('add_ip.html', domains=domains) + + except Exception as e: + session.rollback() + logger.error(f"Error adding IP: {e}") + flash(f'Error adding IP: {str(e)}', 'error') + return redirect(url_for('email.add_ip')) + finally: + session.close() + +@email_bp.route('/ips//delete', methods=['POST']) +def disable_ip(ip_id: int): + """Disable whitelisted IP (soft delete).""" + session = Session() + try: + ip_record = session.query(WhitelistedIP).get(ip_id) + if not ip_record: + flash('IP record not found', 'error') + return redirect(url_for('email.ips_list')) + + ip_address = ip_record.ip_address + ip_record.is_active = False + session.commit() + + flash(f'IP {ip_address} disabled', 'success') + return redirect(url_for('email.ips_list')) + + except Exception as e: + session.rollback() + logger.error(f"Error disabling IP: {e}") + flash(f'Error disabling IP: {str(e)}', 'error') + return redirect(url_for('email.ips_list')) + finally: + session.close() + +@email_bp.route('/ips//enable', methods=['POST']) +def enable_ip(ip_id: int): + """Enable whitelisted IP.""" + session = Session() + try: + ip_record = session.query(WhitelistedIP).get(ip_id) + if not ip_record: + flash('IP record not found', 'error') + return redirect(url_for('email.ips_list')) + + ip_address = ip_record.ip_address + ip_record.is_active = True + session.commit() + + flash(f'IP {ip_address} enabled', 'success') + return redirect(url_for('email.ips_list')) + + except Exception as e: + session.rollback() + logger.error(f"Error enabling IP: {e}") + flash(f'Error enabling IP: {str(e)}', 'error') + return redirect(url_for('email.ips_list')) + finally: + session.close() + +@email_bp.route('/ips//remove', methods=['POST']) +def remove_ip(ip_id: int): + """Permanently remove whitelisted IP.""" + session = Session() + try: + ip_record = session.query(WhitelistedIP).get(ip_id) + if not ip_record: + flash('IP record not found', 'error') + return redirect(url_for('email.ips_list')) + + ip_address = ip_record.ip_address + session.delete(ip_record) + session.commit() + + flash(f'IP {ip_address} permanently removed', 'success') + return redirect(url_for('email.ips_list')) + + except Exception as e: + session.rollback() + logger.error(f"Error removing IP: {e}") + flash(f'Error removing IP: {str(e)}', 'error') + return redirect(url_for('email.ips_list')) + finally: + session.close() + +@email_bp.route('/ips//edit', methods=['GET', 'POST']) +def edit_ip(ip_id: int): + """Edit whitelisted IP.""" + session = Session() + try: + ip_record = session.query(WhitelistedIP).get(ip_id) + if not ip_record: + flash('IP record not found', 'error') + return redirect(url_for('email.ips_list')) + + domains = session.query(Domain).filter_by(is_active=True).order_by(Domain.domain_name).all() + + if request.method == 'POST': + ip_address = request.form.get('ip_address', '').strip() + domain_id = request.form.get('domain_id', type=int) + + if not all([ip_address, domain_id]): + flash('All fields are required', 'error') + return redirect(url_for('email.edit_ip', ip_id=ip_id)) + + # Basic IP validation + try: + socket.inet_aton(ip_address) + except socket.error: + flash('Invalid IP address format', 'error') + return redirect(url_for('email.edit_ip', ip_id=ip_id)) + + # Check if IP already exists for this domain (excluding current record) + existing = session.query(WhitelistedIP).filter( + WhitelistedIP.ip_address == ip_address, + WhitelistedIP.domain_id == domain_id, + WhitelistedIP.id != ip_id + ).first() + if existing: + flash(f'IP {ip_address} already whitelisted for this domain', 'error') + return redirect(url_for('email.edit_ip', ip_id=ip_id)) + + # Update IP record + ip_record.ip_address = ip_address + ip_record.domain_id = domain_id + session.commit() + + flash(f'IP whitelist record updated', 'success') + return redirect(url_for('email.ips_list')) + + return render_template('edit_ip.html', ip_record=ip_record, domains=domains) + + except Exception as e: + session.rollback() + logger.error(f"Error editing IP: {e}") + flash(f'Error editing IP: {str(e)}', 'error') + return redirect(url_for('email.ips_list')) + finally: + session.close() \ No newline at end of file diff --git a/email_server/server_web_ui/logs.py b/email_server/server_web_ui/logs.py new file mode 100644 index 0000000..ba0dc48 --- /dev/null +++ b/email_server/server_web_ui/logs.py @@ -0,0 +1,80 @@ +""" +Logs blueprint for the SMTP server web UI. + +This module provides email and authentication log viewing functionality. +""" + +from flask import render_template, request, jsonify +from email_server.models import Session, EmailLog, AuthLog, Domain +from email_server.tool_box import get_logger +from sqlalchemy import desc +from datetime import datetime, timedelta +from .routes import email_bp + +logger = get_logger() + + +@email_bp.route('/logs') +def logs(): + """Display email and authentication logs.""" + session = Session() + try: + # Get filter parameters + filter_type = request.args.get('type', 'all') + page = request.args.get('page', 1, type=int) + per_page = 50 + + if filter_type == 'emails': + # Email logs only + total_query = session.query(EmailLog) + logs_query = session.query(EmailLog).order_by(EmailLog.created_at.desc()) + elif filter_type == 'auth': + # Auth logs only + total_query = session.query(AuthLog) + logs_query = session.query(AuthLog).order_by(AuthLog.created_at.desc()) + else: + # Combined view (default) + email_logs = session.query(EmailLog).order_by(EmailLog.created_at.desc()).limit(per_page//2).all() + auth_logs = session.query(AuthLog).order_by(AuthLog.created_at.desc()).limit(per_page//2).all() + + # Convert to unified format + combined_logs = [] + for log in email_logs: + combined_logs.append({ + 'type': 'email', + 'timestamp': log.created_at, + 'data': log + }) + for log in auth_logs: + combined_logs.append({ + 'type': 'auth', + 'timestamp': log.created_at, + 'data': log + }) + + # Sort by timestamp + combined_logs.sort(key=lambda x: x['timestamp'], reverse=True) + + return render_template('logs.html', + logs=combined_logs[:per_page], + filter_type=filter_type, + page=page, + has_next=len(combined_logs) > per_page, + has_prev=page > 1) + + # Pagination for single type logs + offset = (page - 1) * per_page + total = total_query.count() + logs = logs_query.offset(offset).limit(per_page).all() + + has_next = offset + per_page < total + has_prev = page > 1 + + return render_template('logs.html', + logs=logs, + filter_type=filter_type, + page=page, + has_next=has_next, + has_prev=has_prev) + finally: + session.close() \ No newline at end of file diff --git a/email_server/server_web_ui/routes.py b/email_server/server_web_ui/routes.py index 85bf8aa..be00dc7 100644 --- a/email_server/server_web_ui/routes.py +++ b/email_server/server_web_ui/routes.py @@ -1,1206 +1,19 @@ """ -Flask Blueprint for Email Server Management Frontend - -This module provides a comprehensive web interface for managing the SMTP server: -- Domain management -- User authentication and authorization -- DKIM key management with DNS record verification -- Server settings configuration -- Email logs and monitoring - -Security features: -- Authentication management per domain -- IP whitelisting capabilities -- SPF and DKIM DNS validation +Main routes and blueprint definition for the SMTP server web UI. """ - -from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify -import socket -import requests -import dns.resolver -import re -from datetime import datetime -from datetime import datetime -from typing import Optional, Dict, List, Tuple - -# Import email server modules -import sys -import os -sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from email_server.models import ( - Session, Domain, User, WhitelistedIP, DKIMKey, CustomHeader, EmailLog, AuthLog, - hash_password, create_tables, get_user_by_email, get_domain_by_name, get_whitelisted_ip -) -from email_server.dkim_manager import DKIMManager -from email_server.settings_loader import load_settings, SETTINGS_PATH +from flask import Blueprint, render_template from email_server.tool_box import get_logger +from datetime import datetime -logger = get_logger() -# Create Blueprint +# Create the main email blueprint email_bp = Blueprint('email', __name__, template_folder='templates', static_folder='static', url_prefix='/pymta-manager') -def get_public_ip() -> str: - """Get the public IP address of the server.""" - try: - response1 = requests.get('https://ifconfig.me/ip', timeout=3, verify=False) - ip = response1.text.strip() - if ip and ip != 'unknown': - return ip - except Exception: - try: - # Fallback method - response = requests.get('https://httpbin.org/ip', timeout=3, verify=False) - ip = response.json()['origin'].split(',')[0].strip() - if ip and ip != 'unknown': - return ip - except Exception as e: - pass - - # Use fallback from settings.ini if available - try: - settings = load_settings() - fallback_ip = settings.get('DKIM', 'SPF_SERVER_IP', fallback=None) - if fallback_ip and fallback_ip.strip() and fallback_ip != '""': - # Check if it's a valid IPv4 address (basic check) - parts = fallback_ip.split('.') - if len(parts) == 4 and all(part.isdigit() and 0 <= int(part) <= 255 for part in parts): - return fallback_ip.strip() - except Exception as e: - return {'success': False, 'message': f'DNS lookup error, If it continues, consider setting up public IP in settings - SPF_SERVER_IP. Details: {str(e)}'} - -def check_dns_record(domain: str, record_type: str, expected_value: str = None) -> Dict: - """Check DNS record for a domain.""" - try: - answers = dns.resolver.resolve(domain, record_type) - records = [str(answer) for answer in answers] - - if expected_value: - found = any(expected_value in record for record in records) - return { - 'success': True, - 'found': found, - 'records': records, - 'message': f"Record {'found' if found else 'not found'}" - } - else: - return { - 'success': True, - 'records': records, - 'message': f"Found {len(records)} {record_type} record(s)" - } - except dns.resolver.NXDOMAIN: - return {'success': False, 'message': 'Domain not found'} - except dns.resolver.NoAnswer: - return {'success': False, 'message': f'No {record_type} records found'} - except Exception as e: - return {'success': False, 'message': f'DNS lookup error: {str(e)}'} - -def generate_spf_record(domain: str, public_ip: str, existing_spf: str = None) -> str: - """Generate or update SPF record to include the current server IP.""" - if not public_ip or public_ip == 'unknown': - return f'"{existing_spf or "v=spf1 ~all"}"' - - our_ip = f"ip4:{public_ip}" - - if existing_spf: - spf_clean = existing_spf.replace('"', '').strip() - if not spf_clean.startswith('v=spf1'): - spf_clean = f"v=spf1 {spf_clean}" - - parts = spf_clean.split() - if our_ip in parts: - return f'Current SPF records includes already server ip {public_ip}' - - # Find position of the final all mechanism (if present) - all_mechanism_index = next((i for i, part in enumerate(parts) if part in ['-all', '~all', '?all', 'all']), None) - - if all_mechanism_index is not None: - new_parts = parts[:all_mechanism_index] + [our_ip] + parts[all_mechanism_index:] - else: - new_parts = parts + [our_ip, '~all'] - - return f'"{" ".join(new_parts)}"' - else: - # No existing SPF, create a new one - return f'"v=spf1 {our_ip} ~all"' - -# Dashboard and Main Routes -@email_bp.route('/') -def dashboard(): - """Main dashboard showing overview of the email server.""" - session = Session() - try: - # Get counts - domain_count = session.query(Domain).filter_by(is_active=True).count() - user_count = session.query(User).filter_by(is_active=True).count() - dkim_count = session.query(DKIMKey).filter_by(is_active=True).count() - - # Get recent email logs - recent_emails = session.query(EmailLog).order_by(EmailLog.created_at.desc()).limit(10).all() - - # Get recent auth logs - recent_auths = session.query(AuthLog).order_by(AuthLog.created_at.desc()).limit(10).all() - - return render_template('dashboard.html', - domain_count=domain_count, - user_count=user_count, - dkim_count=dkim_count, - recent_emails=recent_emails, - recent_auths=recent_auths) - finally: - session.close() - -# Domain Management Routes -@email_bp.route('/domains') -def domains_list(): - """List all domains.""" - session = Session() - try: - domains = session.query(Domain).order_by(Domain.domain_name).all() - return render_template('domains.html', domains=domains) - finally: - session.close() - -@email_bp.route('/domains/add', methods=['GET', 'POST']) -def add_domain(): - """Add new domain.""" - if request.method == 'POST': - domain_name = request.form.get('domain_name', '').strip().lower() - - if not domain_name: - flash('Domain name is required', 'error') - return redirect(url_for('email.add_domain')) - - session = Session() - try: - # Check if domain already exists - existing = session.query(Domain).filter_by(domain_name=domain_name).first() - if existing: - flash(f'Domain {domain_name} already exists', 'error') - return redirect(url_for('email.domains_list')) - - # Create domain - domain = Domain(domain_name=domain_name) - session.add(domain) - session.commit() - - # Generate DKIM key for the domain - dkim_manager = DKIMManager() - dkim_manager.generate_dkim_keypair(domain_name) - - flash(f'Domain {domain_name} added successfully with DKIM key', 'success') - return redirect(url_for('email.domains_list')) - - except Exception as e: - session.rollback() - logger.error(f"Error adding domain: {e}") - flash(f'Error adding domain: {str(e)}', 'error') - return redirect(url_for('email.add_domain')) - finally: - session.close() - - return render_template('add_domain.html') - -@email_bp.route('/domains//delete', methods=['POST']) -def delete_domain(domain_id: int): - """Delete domain (soft delete).""" - session = Session() - try: - domain = session.query(Domain).get(domain_id) - if not domain: - flash('Domain not found', 'error') - return redirect(url_for('email.domains_list')) - - domain_name = domain.domain_name - domain.is_active = False - session.commit() - - flash(f'Domain {domain_name} disabled', 'success') - return redirect(url_for('email.domains_list')) - - except Exception as e: - session.rollback() - logger.error(f"Error disabling domain: {e}") - flash(f'Error disabling domain: {str(e)}', 'error') - return redirect(url_for('email.domains_list')) - finally: - session.close() - -@email_bp.route('/domains//edit', methods=['GET', 'POST']) -def edit_domain(domain_id: int): - """Edit domain.""" - session = Session() - try: - domain = session.query(Domain).get(domain_id) - if not domain: - flash('Domain not found', 'error') - return redirect(url_for('email.domains_list')) - - if request.method == 'POST': - domain_name = request.form.get('domain_name', '').strip().lower() - requires_auth = request.form.get('requires_auth') == 'on' - - if not domain_name: - flash('Domain name is required', 'error') - return redirect(url_for('email.edit_domain', domain_id=domain_id)) - - # Basic domain validation - if '.' not in domain_name or len(domain_name.split('.')) < 2: - flash('Invalid domain format', 'error') - return redirect(url_for('email.edit_domain', domain_id=domain_id)) - - # Check if domain name already exists (excluding current domain) - existing = session.query(Domain).filter( - Domain.domain_name == domain_name, - Domain.id != domain_id - ).first() - if existing: - flash(f'Domain {domain_name} already exists', 'error') - return redirect(url_for('email.edit_domain', domain_id=domain_id)) - - old_name = domain.domain_name - domain.domain_name = domain_name - domain.requires_auth = requires_auth - session.commit() - - flash(f'Domain updated from "{old_name}" to "{domain_name}"', 'success') - return redirect(url_for('email.domains_list')) - - return render_template('edit_domain.html', domain=domain) - - except Exception as e: - session.rollback() - logger.error(f"Error editing domain: {e}") - flash(f'Error editing domain: {str(e)}', 'error') - return redirect(url_for('email.domains_list')) - finally: - session.close() - -@email_bp.route('/domains//toggle', methods=['POST']) -def toggle_domain(domain_id: int): - """Toggle domain active status (Enable/Disable).""" - session = Session() - try: - domain = session.query(Domain).get(domain_id) - if not domain: - flash('Domain not found', 'error') - return redirect(url_for('email.domains_list')) - - old_status = domain.is_active - domain.is_active = not old_status - session.commit() - - status_text = "enabled" if domain.is_active else "disabled" - flash(f'Domain {domain.domain_name} has been {status_text}', 'success') - return redirect(url_for('email.domains_list')) - - except Exception as e: - session.rollback() - logger.error(f"Error toggling domain status: {e}") - flash(f'Error toggling domain status: {str(e)}', 'error') - return redirect(url_for('email.domains_list')) - finally: - session.close() - -@email_bp.route('/domains//remove', methods=['POST']) -def remove_domain(domain_id: int): - """Permanently remove domain and all associated data.""" - session = Session() - try: - domain = session.query(Domain).get(domain_id) - if not domain: - flash('Domain not found', 'error') - return redirect(url_for('email.domains_list')) - - domain_name = domain.domain_name - - # Count associated records - user_count = session.query(User).filter_by(domain_id=domain_id).count() - ip_count = session.query(WhitelistedIP).filter_by(domain_id=domain_id).count() - dkim_count = session.query(DKIMKey).filter_by(domain_id=domain_id).count() - - # Delete associated records - session.query(User).filter_by(domain_id=domain_id).delete() - session.query(WhitelistedIP).filter_by(domain_id=domain_id).delete() - session.query(DKIMKey).filter_by(domain_id=domain_id).delete() - session.query(CustomHeader).filter_by(domain_id=domain_id).delete() - - # Delete domain - session.delete(domain) - session.commit() - - flash(f'Domain {domain_name} and all associated data permanently removed ({user_count} users, {ip_count} IPs, {dkim_count} DKIM keys)', 'success') - return redirect(url_for('email.domains_list')) - - except Exception as e: - session.rollback() - logger.error(f"Error removing domain: {e}") - flash(f'Error removing domain: {str(e)}', 'error') - return redirect(url_for('email.domains_list')) - finally: - session.close() - -# User Management Routes -@email_bp.route('/users') -def users_list(): - """List all users.""" - session = Session() - try: - users = session.query(User, Domain).join(Domain, User.domain_id == Domain.id).order_by(User.email).all() - return render_template('users.html', users=users) - finally: - session.close() - -@email_bp.route('/users/add', methods=['GET', 'POST']) -def add_user(): - """Add new user.""" - session = Session() - try: - domains = session.query(Domain).filter_by(is_active=True).order_by(Domain.domain_name).all() - - if request.method == 'POST': - email = request.form.get('email', '').strip().lower() - password = request.form.get('password', '').strip() - domain_id = request.form.get('domain_id', type=int) - can_send_as_domain = request.form.get('can_send_as_domain') == 'on' - - if not all([email, password, domain_id]): - flash('All fields are required', 'error') - return redirect(url_for('email.add_user')) - - # Validate email format - if '@' not in email: - flash('Invalid email format', 'error') - return redirect(url_for('email.add_user')) - - # Check if user already exists - existing = session.query(User).filter_by(email=email).first() - if existing: - flash(f'User {email} already exists', 'error') - return redirect(url_for('email.users_list')) - - # Create user - user = User( - email=email, - password_hash=hash_password(password), - domain_id=domain_id, - can_send_as_domain=can_send_as_domain - ) - session.add(user) - session.commit() - - flash(f'User {email} added successfully', 'success') - return redirect(url_for('email.users_list')) - - return render_template('add_user.html', domains=domains) - - except Exception as e: - session.rollback() - logger.error(f"Error adding user: {e}") - flash(f'Error adding user: {str(e)}', 'error') - return redirect(url_for('email.add_user')) - finally: - session.close() - -@email_bp.route('/users//delete', methods=['POST']) -def delete_user(user_id: int): - """Disable user (soft delete).""" - session = Session() - try: - user = session.query(User).get(user_id) - if not user: - flash('User not found', 'error') - return redirect(url_for('email.users_list')) - - user_email = user.email - user.is_active = False - session.commit() - - flash(f'User {user_email} disabled', 'success') - return redirect(url_for('email.users_list')) - - except Exception as e: - session.rollback() - logger.error(f"Error disabling user: {e}") - flash(f'Error disabling user: {str(e)}', 'error') - return redirect(url_for('email.users_list')) - finally: - session.close() - -@email_bp.route('/users//enable', methods=['POST']) -def enable_user(user_id: int): - """Enable user.""" - session = Session() - try: - user = session.query(User).get(user_id) - if not user: - flash('User not found', 'error') - return redirect(url_for('email.users_list')) - - user_email = user.email - user.is_active = True - session.commit() - - flash(f'User {user_email} enabled', 'success') - return redirect(url_for('email.users_list')) - - except Exception as e: - session.rollback() - logger.error(f"Error enabling user: {e}") - flash(f'Error enabling user: {str(e)}', 'error') - return redirect(url_for('email.users_list')) - finally: - session.close() - -@email_bp.route('/users//remove', methods=['POST']) -def remove_user(user_id: int): - """Permanently remove user.""" - session = Session() - try: - user = session.query(User).get(user_id) - if not user: - flash('User not found', 'error') - return redirect(url_for('email.users_list')) - - user_email = user.email - session.delete(user) - session.commit() - - flash(f'User {user_email} permanently removed', 'success') - return redirect(url_for('email.users_list')) - - except Exception as e: - session.rollback() - logger.error(f"Error removing user: {e}") - flash(f'Error removing user: {str(e)}', 'error') - return redirect(url_for('email.users_list')) - finally: - session.close() - -@email_bp.route('/users//edit', methods=['GET', 'POST']) -def edit_user(user_id: int): - """Edit user.""" - session = Session() - try: - user = session.query(User).get(user_id) - if not user: - flash('User not found', 'error') - return redirect(url_for('email.users_list')) - - domains = session.query(Domain).filter_by(is_active=True).order_by(Domain.domain_name).all() - - if request.method == 'POST': - email = request.form.get('email', '').strip().lower() - password = request.form.get('password', '').strip() - domain_id = request.form.get('domain_id', type=int) - can_send_as_domain = request.form.get('can_send_as_domain') == 'on' - - if not all([email, domain_id]): - flash('Email and domain are required', 'error') - return redirect(url_for('email.edit_user', user_id=user_id)) - - # Email validation - if '@' not in email or '.' not in email.split('@')[1]: - flash('Invalid email format', 'error') - return redirect(url_for('email.edit_user', user_id=user_id)) - - # Check if email already exists (excluding current user) - existing = session.query(User).filter( - User.email == email, - User.id != user_id - ).first() - if existing: - flash(f'Email {email} already exists', 'error') - return redirect(url_for('email.edit_user', user_id=user_id)) - - # Update user - user.email = email - user.domain_id = domain_id - user.can_send_as_domain = can_send_as_domain - - # Update password if provided - if password: - user.password_hash = hash_password(password) - - session.commit() - - flash(f'User {email} updated successfully', 'success') - return redirect(url_for('email.users_list')) - - return render_template('edit_user.html', user=user, domains=domains) - - except Exception as e: - session.rollback() - logger.error(f"Error editing user: {e}") - flash(f'Error editing user: {str(e)}', 'error') - return redirect(url_for('email.users_list')) - finally: - session.close() - -# IP Management Routes -@email_bp.route('/ips') -def ips_list(): - """List all whitelisted IPs.""" - session = Session() - try: - ips = session.query(WhitelistedIP, Domain).join(Domain, WhitelistedIP.domain_id == Domain.id).order_by(WhitelistedIP.ip_address).all() - return render_template('ips.html', ips=ips) - finally: - session.close() - -@email_bp.route('/ips/add', methods=['GET', 'POST']) -def add_ip(): - """Add new whitelisted IP.""" - session = Session() - try: - domains = session.query(Domain).filter_by(is_active=True).order_by(Domain.domain_name).all() - - if request.method == 'POST': - ip_address = request.form.get('ip_address', '').strip() - domain_id = request.form.get('domain_id', type=int) - - if not all([ip_address, domain_id]): - flash('All fields are required', 'error') - return redirect(url_for('email.add_ip')) - - # Basic IP validation - try: - socket.inet_aton(ip_address) - except socket.error: - flash('Invalid IP address format', 'error') - return redirect(url_for('email.add_ip')) - - # Check if IP already exists for this domain - existing = session.query(WhitelistedIP).filter_by(ip_address=ip_address, domain_id=domain_id).first() - if existing: - flash(f'IP {ip_address} already whitelisted for this domain', 'error') - return redirect(url_for('email.ips_list')) - - # Create whitelisted IP - whitelist = WhitelistedIP( - ip_address=ip_address, - domain_id=domain_id - ) - session.add(whitelist) - session.commit() - - flash(f'IP {ip_address} added to whitelist', 'success') - return redirect(url_for('email.ips_list')) - - return render_template('add_ip.html', domains=domains) - - except Exception as e: - session.rollback() - logger.error(f"Error adding IP: {e}") - flash(f'Error adding IP: {str(e)}', 'error') - return redirect(url_for('email.add_ip')) - finally: - session.close() - -@email_bp.route('/ips//delete', methods=['POST']) -def delete_ip(ip_id: int): - """Disable whitelisted IP (soft delete).""" - session = Session() - try: - ip_record = session.query(WhitelistedIP).get(ip_id) - if not ip_record: - flash('IP record not found', 'error') - return redirect(url_for('email.ips_list')) - - ip_address = ip_record.ip_address - ip_record.is_active = False - session.commit() - - flash(f'IP {ip_address} disabled', 'success') - return redirect(url_for('email.ips_list')) - - except Exception as e: - session.rollback() - logger.error(f"Error disabling IP: {e}") - flash(f'Error disabling IP: {str(e)}', 'error') - return redirect(url_for('email.ips_list')) - finally: - session.close() - -@email_bp.route('/ips//enable', methods=['POST']) -def enable_ip(ip_id: int): - """Enable whitelisted IP.""" - session = Session() - try: - ip_record = session.query(WhitelistedIP).get(ip_id) - if not ip_record: - flash('IP record not found', 'error') - return redirect(url_for('email.ips_list')) - - ip_address = ip_record.ip_address - ip_record.is_active = True - session.commit() - - flash(f'IP {ip_address} enabled', 'success') - return redirect(url_for('email.ips_list')) - - except Exception as e: - session.rollback() - logger.error(f"Error enabling IP: {e}") - flash(f'Error enabling IP: {str(e)}', 'error') - return redirect(url_for('email.ips_list')) - finally: - session.close() - -@email_bp.route('/ips//remove', methods=['POST']) -def remove_ip(ip_id: int): - """Permanently remove whitelisted IP.""" - session = Session() - try: - ip_record = session.query(WhitelistedIP).get(ip_id) - if not ip_record: - flash('IP record not found', 'error') - return redirect(url_for('email.ips_list')) - - ip_address = ip_record.ip_address - session.delete(ip_record) - session.commit() - - flash(f'IP {ip_address} permanently removed', 'success') - return redirect(url_for('email.ips_list')) - - except Exception as e: - session.rollback() - logger.error(f"Error removing IP: {e}") - flash(f'Error removing IP: {str(e)}', 'error') - return redirect(url_for('email.ips_list')) - finally: - session.close() - -@email_bp.route('/ips//edit', methods=['GET', 'POST']) -def edit_ip(ip_id: int): - """Edit whitelisted IP.""" - session = Session() - try: - ip_record = session.query(WhitelistedIP).get(ip_id) - if not ip_record: - flash('IP record not found', 'error') - return redirect(url_for('email.ips_list')) - - domains = session.query(Domain).filter_by(is_active=True).order_by(Domain.domain_name).all() - - if request.method == 'POST': - ip_address = request.form.get('ip_address', '').strip() - domain_id = request.form.get('domain_id', type=int) - - if not all([ip_address, domain_id]): - flash('All fields are required', 'error') - return redirect(url_for('email.edit_ip', ip_id=ip_id)) - - # Basic IP validation - try: - socket.inet_aton(ip_address) - except socket.error: - flash('Invalid IP address format', 'error') - return redirect(url_for('email.edit_ip', ip_id=ip_id)) - - # Check if IP already exists for this domain (excluding current record) - existing = session.query(WhitelistedIP).filter( - WhitelistedIP.ip_address == ip_address, - WhitelistedIP.domain_id == domain_id, - WhitelistedIP.id != ip_id - ).first() - if existing: - flash(f'IP {ip_address} already whitelisted for this domain', 'error') - return redirect(url_for('email.edit_ip', ip_id=ip_id)) - - # Update IP record - ip_record.ip_address = ip_address - ip_record.domain_id = domain_id - session.commit() - - flash(f'IP whitelist record updated', 'success') - return redirect(url_for('email.ips_list')) - - return render_template('edit_ip.html', ip_record=ip_record, domains=domains) - - except Exception as e: - session.rollback() - logger.error(f"Error editing IP: {e}") - flash(f'Error editing IP: {str(e)}', 'error') - return redirect(url_for('email.ips_list')) - finally: - session.close() - -# DKIM Management Routes -@email_bp.route('/dkim') -def dkim_list(): - """List all DKIM keys and DNS records.""" - session = Session() - try: - # Get active DKIM keys - active_dkim_keys = session.query(DKIMKey, Domain).join( - Domain, DKIMKey.domain_id == Domain.id - ).filter(DKIMKey.is_active == True).order_by(Domain.domain_name).all() - - # Get old/inactive DKIM keys (prioritize replaced keys over disabled ones) - old_dkim_keys = session.query(DKIMKey, Domain).join( - Domain, DKIMKey.domain_id == Domain.id - ).filter(DKIMKey.is_active == False).order_by( - Domain.domain_name, - DKIMKey.replaced_at.desc().nullslast(), # Replaced keys first, then disabled ones - DKIMKey.created_at.desc() - ).all() - - # Get public IP for SPF records - public_ip = get_public_ip() - - # Prepare active DKIM data with DNS information - active_dkim_data = [] - for dkim_key, domain in active_dkim_keys: - # Get DKIM DNS record - dkim_manager = DKIMManager() - dns_record = dkim_manager.get_dkim_public_key_record(domain.domain_name) - - # Check existing SPF record - spf_check = check_dns_record(domain.domain_name, 'TXT') - existing_spf = None - if spf_check['success']: - for record in spf_check['records']: - if 'v=spf1' in record: - existing_spf = record - break - - # Generate recommended SPF - recommended_spf = generate_spf_record(domain.domain_name, public_ip, existing_spf) - - active_dkim_data.append({ - 'dkim_key': dkim_key, - 'domain': domain, - 'dns_record': dns_record, - 'existing_spf': existing_spf, - 'recommended_spf': recommended_spf, - 'public_ip': public_ip - }) - - # Prepare old DKIM data with status information - old_dkim_data = [] - for dkim_key, domain in old_dkim_keys: - old_dkim_data.append({ - 'dkim_key': dkim_key, - 'domain': domain, - 'public_ip': public_ip, - 'is_replaced': dkim_key.replaced_at is not None, - 'status_text': 'Replaced' if dkim_key.replaced_at else 'Disabled' - }) - - return render_template('dkim.html', - dkim_data=active_dkim_data, - old_dkim_data=old_dkim_data) - finally: - session.close() - -@email_bp.route('/dkim//regenerate', methods=['POST']) -def regenerate_dkim(domain_id: int): - """Regenerate DKIM key for domain.""" - session = Session() - try: - domain = session.query(Domain).get(domain_id) - if not domain: - if request.headers.get('Content-Type') == 'application/json': - return jsonify({'success': False, 'message': 'Domain not found'}) - flash('Domain not found', 'error') - return redirect(url_for('email.dkim_list')) - - # Get the current active DKIM key's selector to preserve it - existing_keys = session.query(DKIMKey).filter_by(domain_id=domain_id, is_active=True).all() - current_selector = None - if existing_keys: - # Use the selector from the first active key (there should typically be only one) - current_selector = existing_keys[0].selector - - # Mark existing keys as replaced - for key in existing_keys: - key.is_active = False - key.replaced_at = datetime.now() # Mark when this key was replaced - - # Generate new DKIM key preserving the existing selector - dkim_manager = DKIMManager() - if dkim_manager.generate_dkim_keypair(domain.domain_name, selector=current_selector, force_new_key=True): - session.commit() - - # Get the new key data for AJAX response - new_key = session.query(DKIMKey).filter_by( - domain_id=domain_id, is_active=True - ).order_by(DKIMKey.created_at.desc()).first() - - if not new_key: - session.rollback() - if request.headers.get('Content-Type') == 'application/json' or request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return jsonify({'success': False, 'message': f'Failed to create new DKIM key for {domain.domain_name}'}) - flash(f'Failed to create new DKIM key for {domain.domain_name}', 'error') - return redirect(url_for('email.dkim_list')) - - if request.headers.get('Content-Type') == 'application/json' or request.headers.get('X-Requested-With') == 'XMLHttpRequest': - # Get updated DNS record for the new key - dns_record = dkim_manager.get_dkim_public_key_record(domain.domain_name) - public_ip = get_public_ip() - - # Check existing SPF record - spf_check = check_dns_record(domain.domain_name, 'TXT') - existing_spf = None - if spf_check['success']: - for record in spf_check['records']: - if 'v=spf1' in record: - existing_spf = record - break - - recommended_spf = generate_spf_record(domain.domain_name, public_ip, existing_spf) - - # Get replaced keys for the Old DKIM section update - old_keys = session.query(DKIMKey, Domain).join( - Domain, DKIMKey.domain_id == Domain.id - ).filter( - DKIMKey.domain_id == domain_id, - DKIMKey.is_active == False - ).order_by(DKIMKey.created_at.desc()).all() - - old_dkim_data = [] - for old_key, old_domain in old_keys: - status_text = "Replaced" if old_key.replaced_at else "Disabled" - old_dkim_data.append({ - 'dkim_key': { - 'id': old_key.id, - 'selector': old_key.selector, - 'created_at': old_key.created_at.strftime('%Y-%m-%d %H:%M'), - 'replaced_at': old_key.replaced_at.strftime('%Y-%m-%d %H:%M') if old_key.replaced_at else None, - 'is_active': old_key.is_active - }, - 'domain': { - 'id': old_domain.id, - 'domain_name': old_domain.domain_name - }, - 'status_text': status_text, - 'public_ip': public_ip - }) - - # Additional null check for new_key before accessing its attributes - if not new_key: - logger.error(f"new_key is None after generation for domain {domain.domain_name}") - return jsonify({'success': False, 'message': f'Failed to retrieve new DKIM key for {domain.domain_name}'}) - - return jsonify({ - 'success': True, - 'message': f'DKIM key regenerated for {domain.domain_name}', - 'new_key': { - 'id': new_key.id, - 'selector': new_key.selector, - 'created_at': new_key.created_at.strftime('%Y-%m-%d %H:%M'), - 'is_active': new_key.is_active - }, - 'dns_record': { - 'name': dns_record['name'] if dns_record else '', - 'value': dns_record['value'] if dns_record else '' - }, - 'existing_spf': existing_spf, - 'recommended_spf': recommended_spf, - 'public_ip': public_ip, - 'domain': { - 'id': domain.id, - 'domain_name': domain.domain_name - }, - 'old_dkim_data': old_dkim_data - }) - - flash(f'DKIM key regenerated for {domain.domain_name}', 'success') - else: - session.rollback() - if request.headers.get('Content-Type') == 'application/json' or request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return jsonify({'success': False, 'message': f'Failed to regenerate DKIM key for {domain.domain_name}'}) - flash(f'Failed to regenerate DKIM key for {domain.domain_name}', 'error') - - return redirect(url_for('email.dkim_list')) - - except Exception as e: - session.rollback() - logger.error(f"Error regenerating DKIM: {e}") - if request.headers.get('Content-Type') == 'application/json' or request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return jsonify({'success': False, 'message': f'Error regenerating DKIM: {str(e)}'}) - flash(f'Error regenerating DKIM: {str(e)}', 'error') - return redirect(url_for('email.dkim_list')) - finally: - session.close() - -@email_bp.route('/dkim//edit', methods=['GET', 'POST']) -def edit_dkim(dkim_id: int): - """Edit DKIM key selector.""" - session = Session() - try: - dkim_key = session.query(DKIMKey).get(dkim_id) - if not dkim_key: - flash('DKIM key not found', 'error') - return redirect(url_for('email.dkim_list')) - - domain = session.query(Domain).get(dkim_key.domain_id) - - if request.method == 'POST': - new_selector = request.form.get('selector', '').strip() - - if not new_selector: - flash('Selector name is required', 'error') - return render_template('edit_dkim.html', dkim_key=dkim_key, domain=domain) - - # Validate selector (alphanumeric only) - if not re.match(r'^[a-zA-Z0-9_-]+$', new_selector): - flash('Selector must contain only letters, numbers, hyphens, and underscores', 'error') - return render_template('edit_dkim.html', dkim_key=dkim_key, domain=domain) - - # Check for duplicate selector in same domain - existing = session.query(DKIMKey).filter_by( - domain_id=dkim_key.domain_id, - selector=new_selector, - is_active=True - ).filter(DKIMKey.id != dkim_id).first() - - if existing: - flash(f'A DKIM key with selector "{new_selector}" already exists for this domain', 'error') - return render_template('edit_dkim.html', dkim_key=dkim_key, domain=domain) - - old_selector = dkim_key.selector - dkim_key.selector = new_selector - session.commit() - - flash(f'DKIM selector updated from "{old_selector}" to "{new_selector}" for {domain.domain_name}', 'success') - return redirect(url_for('email.dkim_list')) - - return render_template('edit_dkim.html', dkim_key=dkim_key, domain=domain) - - except Exception as e: - session.rollback() - logger.error(f"Error editing DKIM: {e}") - flash(f'Error editing DKIM key: {str(e)}', 'error') - return redirect(url_for('email.dkim_list')) - finally: - session.close() - -@email_bp.route('/dkim//toggle', methods=['POST']) -def toggle_dkim(dkim_id: int): - """Toggle DKIM key active status (Enable/Disable).""" - session = Session() - try: - dkim_key = session.query(DKIMKey).get(dkim_id) - if not dkim_key: - flash('DKIM key not found', 'error') - return redirect(url_for('email.dkim_list')) - domain = session.query(Domain).get(dkim_key.domain_id) - old_status = dkim_key.is_active - if not old_status: - # About to activate this key, so deactivate any other active DKIM for this domain - other_active_keys = session.query(DKIMKey).filter( - DKIMKey.domain_id == dkim_key.domain_id, - DKIMKey.is_active == True, - DKIMKey.id != dkim_id - ).all() - for key in other_active_keys: - key.is_active = False - key.replaced_at = datetime.now() - dkim_key.is_active = not old_status - if dkim_key.is_active: - dkim_key.replaced_at = None - session.commit() - status_text = "enabled" if dkim_key.is_active else "disabled" - flash(f'DKIM key for {domain.domain_name} (selector: {dkim_key.selector}) has been {status_text}', 'success') - return redirect(url_for('email.dkim_list')) - except Exception as e: - session.rollback() - logger.error(f"Error toggling DKIM status: {e}") - flash(f'Error toggling DKIM status: {str(e)}', 'error') - return redirect(url_for('email.dkim_list')) - finally: - session.close() - -@email_bp.route('/dkim//remove', methods=['POST']) -def remove_dkim(dkim_id: int): - """Permanently remove DKIM key.""" - session = Session() - try: - dkim_key = session.query(DKIMKey).get(dkim_id) - if not dkim_key: - flash('DKIM key not found', 'error') - return redirect(url_for('email.dkim_list')) - - domain = session.query(Domain).get(dkim_key.domain_id) - selector = dkim_key.selector - - session.delete(dkim_key) - session.commit() - - flash(f'DKIM key for {domain.domain_name} (selector: {selector}) has been permanently removed', 'success') - return redirect(url_for('email.dkim_list')) - - except Exception as e: - session.rollback() - logger.error(f"Error removing DKIM key: {e}") - flash(f'Error removing DKIM key: {str(e)}', 'error') - return redirect(url_for('email.dkim_list')) - finally: - session.close() - -# AJAX DNS Check Routes -@email_bp.route('/dkim/check_dns', methods=['POST']) -def check_dkim_dns(): - """Check DKIM DNS record via AJAX.""" - domain = request.form.get('domain') - selector = request.form.get('selector') - - if not all([domain, selector]): - return jsonify({'success': False, 'message': 'Missing domain or selector parameters'}) - - # Get the expected DKIM value from the DKIM manager - try: - dkim_manager = DKIMManager() - dns_record = dkim_manager.get_dkim_public_key_record(domain) - - if not dns_record or not dns_record.get('value'): - return jsonify({'success': False, 'message': 'No DKIM key found for domain'}) - - expected_value = dns_record['value'] - - dns_name = f"{selector}._domainkey.{domain}" - result = check_dns_record(dns_name, 'TXT', expected_value) - - return jsonify(result) - except Exception as e: - logger.error(f"Error checking DKIM DNS: {e}") - return jsonify({'success': False, 'message': f'Error checking DKIM DNS: {str(e)}'}) - -@email_bp.route('/dkim/check_spf', methods=['POST']) -def check_spf_dns(): - """Check SPF DNS record via AJAX.""" - domain = request.form.get('domain') - - if not domain: - return jsonify({'success': False, 'message': 'Domain is required'}) - - result = check_dns_record(domain, 'TXT') - - # Look for SPF record - spf_record = None - if result['success']: - for record in result['records']: - if 'v=spf1' in record: - spf_record = record - break - - if spf_record: - result['spf_record'] = spf_record - result['message'] = 'SPF record found' - else: - result['success'] = False - result['message'] = 'No SPF record found' - - return jsonify(result) - -# Settings Routes -@email_bp.route('/settings') -def settings(): - """Display and edit server settings.""" - settings = load_settings() - return render_template('settings.html', settings=settings) - -@email_bp.route('/settings/update', methods=['POST']) -def update_settings(): - """Update server settings.""" - try: - # Load current settings - config = load_settings() - - # Update settings from form - for section_name in config.sections(): - for key in config[section_name]: - if not key.startswith(';'): # Skip comment lines - form_key = f"{section_name}.{key}" - if form_key in request.form: - config.set(section_name, key, request.form[form_key]) - - # Save settings - with open(SETTINGS_PATH, 'w') as f: - config.write(f) - - flash('Settings updated successfully. Restart the server to apply changes.', 'success') - return redirect(url_for('email.settings')) - - except Exception as e: - logger.error(f"Error updating settings: {e}") - flash(f'Error updating settings: {str(e)}', 'error') - return redirect(url_for('email.settings')) - -# Logs Routes -@email_bp.route('/logs') -def logs(): - """Display email and authentication logs.""" - session = Session() - try: - # Get filter parameters - filter_type = request.args.get('type', 'all') - page = request.args.get('page', 1, type=int) - per_page = 50 - - if filter_type == 'emails': - # Email logs only - total_query = session.query(EmailLog) - logs_query = session.query(EmailLog).order_by(EmailLog.created_at.desc()) - elif filter_type == 'auth': - # Auth logs only - total_query = session.query(AuthLog) - logs_query = session.query(AuthLog).order_by(AuthLog.created_at.desc()) - else: - # Combined view (default) - email_logs = session.query(EmailLog).order_by(EmailLog.created_at.desc()).limit(per_page//2).all() - auth_logs = session.query(AuthLog).order_by(AuthLog.created_at.desc()).limit(per_page//2).all() - - # Convert to unified format - combined_logs = [] - for log in email_logs: - combined_logs.append({ - 'type': 'email', - 'timestamp': log.created_at, - 'data': log - }) - for log in auth_logs: - combined_logs.append({ - 'type': 'auth', - 'timestamp': log.created_at, - 'data': log - }) - - # Sort by timestamp - combined_logs.sort(key=lambda x: x['timestamp'], reverse=True) - - return render_template('logs.html', - logs=combined_logs[:per_page], - filter_type=filter_type, - page=page, - has_next=len(combined_logs) > per_page, - has_prev=page > 1) - - # Pagination for single type logs - offset = (page - 1) * per_page - total = total_query.count() - logs = logs_query.offset(offset).limit(per_page).all() - - has_next = offset + per_page < total - has_prev = page > 1 - - return render_template('logs.html', - logs=logs, - filter_type=filter_type, - page=page, - has_next=has_next, - has_prev=has_prev) - finally: - session.close() +logger = get_logger() # Error handlers @email_bp.errorhandler(404) @@ -1218,39 +31,4 @@ def internal_error(error): return render_template('error.html', error_code=500, error_message='Internal server error', - current_time=datetime.now()), 500 - -@email_bp.route('/dkim/create', methods=['POST'], endpoint='create_dkim') -def create_dkim(): - """Create a new DKIM key for a domain, optionally with a custom selector.""" - from flask import request, jsonify - data = request.get_json() if request.is_json else request.form - domain_name = data.get('domain') - selector = data.get('selector', None) - session = Session() - try: - if not domain_name: - return jsonify({'success': False, 'message': 'Domain is required.'}), 400 - domain = session.query(Domain).filter_by(domain_name=domain_name).first() - if not domain: - return jsonify({'success': False, 'message': 'Domain not found.'}), 404 - # Deactivate any existing active DKIM key for this domain - active_keys = session.query(DKIMKey).filter_by(domain_id=domain.id, is_active=True).all() - for key in active_keys: - key.is_active = False - key.replaced_at = datetime.now() - # Create new DKIM key - dkim_manager = DKIMManager() - created = dkim_manager.generate_dkim_keypair(domain_name, selector=selector, force_new_key=True) - if created: - session.commit() - return jsonify({'success': True, 'message': f'DKIM key created for {domain_name}.'}) - else: - session.rollback() - return jsonify({'success': False, 'message': f'Failed to create DKIM key for {domain_name}.'}), 500 - except Exception as e: - session.rollback() - logger.error(f"Error creating DKIM: {e}") - return jsonify({'success': False, 'message': f'Error creating DKIM: {str(e)}'}), 500 - finally: - session.close() + current_time=datetime.now()), 500 \ No newline at end of file diff --git a/email_server/server_web_ui/senders.py b/email_server/server_web_ui/senders.py new file mode 100644 index 0000000..434ed13 --- /dev/null +++ b/email_server/server_web_ui/senders.py @@ -0,0 +1,215 @@ +""" +Senders blueprint for the SMTP server web UI. + +This module provides sender management functionality including: +- Sender listing +- Sender creation +- Sender editing +- Sender deletion +- Sender status toggling +""" + +from flask import render_template, request, redirect, url_for, flash +from email_server.models import Session, Domain, User +from email_server.tool_box import get_logger +import bcrypt +from .routes import email_bp +from email_server.models import Session, Domain, User, hash_password + +logger = get_logger() + +@email_bp.route('/senders') +def senders_list(): + """List all users.""" + session = Session() + try: + users = session.query(User, Domain).join(Domain, User.domain_id == Domain.id).order_by(User.email).all() + return render_template('senders.html', users=users) + finally: + session.close() + +@email_bp.route('/senders/add', methods=['GET', 'POST']) +def add_sender(): + """Add new user.""" + session = Session() + try: + domains = session.query(Domain).filter_by(is_active=True).order_by(Domain.domain_name).all() + + if request.method == 'POST': + email = request.form.get('email', '').strip().lower() + password = request.form.get('password', '').strip() + domain_id = request.form.get('domain_id', type=int) + can_send_as_domain = request.form.get('can_send_as_domain') == 'on' + + if not all([email, password, domain_id]): + flash('All fields are required', 'error') + return redirect(url_for('email.add_sender')) + + # Validate email format + if '@' not in email: + flash('Invalid email format', 'error') + return redirect(url_for('email.add_sender')) + + # Check if user already exists + existing = session.query(User).filter_by(email=email).first() + if existing: + flash(f'User {email} already exists', 'error') + return redirect(url_for('email.senders_list')) + + # Create user + user = User( + email=email, + password_hash=hash_password(password), + domain_id=domain_id, + can_send_as_domain=can_send_as_domain + ) + session.add(user) + session.commit() + + flash(f'User {email} added successfully', 'success') + return redirect(url_for('email.senders_list')) + + return render_template('add_sender.html', domains=domains) + + except Exception as e: + session.rollback() + logger.error(f"Error adding user: {e}") + flash(f'Error adding user: {str(e)}', 'error') + return redirect(url_for('email.add_sender')) + finally: + session.close() + +@email_bp.route('/senders//delete', methods=['POST']) +def delete_sender(user_id: int): + """Disable user (soft delete).""" + session = Session() + try: + user = session.query(User).get(user_id) + if not user: + flash('User not found', 'error') + return redirect(url_for('email.senders_list')) + + user_email = user.email + user.is_active = False + session.commit() + + flash(f'User {user_email} disabled', 'success') + return redirect(url_for('email.senders_list')) + + except Exception as e: + session.rollback() + logger.error(f"Error disabling user: {e}") + flash(f'Error disabling user: {str(e)}', 'error') + return redirect(url_for('email.senders_list')) + finally: + session.close() + +@email_bp.route('/senders//enable', methods=['POST']) +def enable_sender(user_id: int): + """Enable user.""" + session = Session() + try: + user = session.query(User).get(user_id) + if not user: + flash('User not found', 'error') + return redirect(url_for('email.senders_list')) + + user_email = user.email + user.is_active = True + session.commit() + + flash(f'User {user_email} enabled', 'success') + return redirect(url_for('email.senders_list')) + + except Exception as e: + session.rollback() + logger.error(f"Error enabling user: {e}") + flash(f'Error enabling user: {str(e)}', 'error') + return redirect(url_for('email.senders_list')) + finally: + session.close() + +@email_bp.route('/senders//remove', methods=['POST']) +def remove_sender(user_id: int): + """Permanently remove user.""" + session = Session() + try: + user = session.query(User).get(user_id) + if not user: + flash('User not found', 'error') + return redirect(url_for('email.senders_list')) + + user_email = user.email + session.delete(user) + session.commit() + + flash(f'User {user_email} permanently removed', 'success') + return redirect(url_for('email.senders_list')) + + except Exception as e: + session.rollback() + logger.error(f"Error removing user: {e}") + flash(f'Error removing user: {str(e)}', 'error') + return redirect(url_for('email.senders_list')) + finally: + session.close() + +@email_bp.route('/senders//edit', methods=['GET', 'POST']) +def edit_sender(user_id: int): + """Edit user.""" + session = Session() + try: + user = session.query(User).get(user_id) + if not user: + flash('User not found', 'error') + return redirect(url_for('email.senders_list')) + + domains = session.query(Domain).filter_by(is_active=True).order_by(Domain.domain_name).all() + + if request.method == 'POST': + email = request.form.get('email', '').strip().lower() + password = request.form.get('password', '').strip() + domain_id = request.form.get('domain_id', type=int) + can_send_as_domain = request.form.get('can_send_as_domain') == 'on' + + if not all([email, domain_id]): + flash('Email and domain are required', 'error') + return redirect(url_for('email.edit_sender', user_id=user_id)) + + # Email validation + if '@' not in email or '.' not in email.split('@')[1]: + flash('Invalid email format', 'error') + return redirect(url_for('email.edit_sender', user_id=user_id)) + + # Check if email already exists (excluding current user) + existing = session.query(User).filter( + User.email == email, + User.id != user_id + ).first() + if existing: + flash(f'Email {email} already exists', 'error') + return redirect(url_for('email.edit_sender', user_id=user_id)) + + # Update user + user.email = email + user.domain_id = domain_id + user.can_send_as_domain = can_send_as_domain + + # Update password if provided + if password: + user.password_hash = hash_password(password) + + session.commit() + + flash(f'User {email} updated successfully', 'success') + return redirect(url_for('email.senders_list')) + + return render_template('edit_sender.html', user=user, domains=domains) + + except Exception as e: + session.rollback() + logger.error(f"Error editing user: {e}") + flash(f'Error editing user: {str(e)}', 'error') + return redirect(url_for('email.senders_list')) + finally: + session.close() \ No newline at end of file diff --git a/email_server/server_web_ui/settings.py b/email_server/server_web_ui/settings.py new file mode 100644 index 0000000..f593de7 --- /dev/null +++ b/email_server/server_web_ui/settings.py @@ -0,0 +1,197 @@ +""" +Settings blueprint for the SMTP server web UI. + +This module provides server settings management functionality including: +- Settings viewing +- Settings updating +- Certificate management +- Database testing +- Public IP retrieval +""" + +import os +import time +from pathlib import Path +from flask import render_template, request, redirect, url_for, flash, jsonify +from werkzeug.utils import secure_filename +from email_server.settings_loader import load_settings, SETTINGS_PATH +from email_server.tool_box import get_logger +from .utils import get_public_ip +from .database import test_database_connection +from .routes import email_bp + +logger = get_logger() + +# Certificate upload configuration +CERT_UPLOAD_FOLDER = Path(__file__).parent.parent / 'ssl_certs' +ALLOWED_EXTENSIONS = {'crt', 'key', 'pem'} + +def allowed_file(filename): + return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + +@email_bp.route('/settings') +def settings(): + """Display and edit server settings.""" + settings = load_settings() + return render_template('settings.html', settings=settings) + +@email_bp.route('/settings_update', methods=['POST']) +def settings_update(): + """Update server settings.""" + try: + # Check if settings file exists and is writable + if not os.path.exists(SETTINGS_PATH): + logger.error(f"Settings file does not exist: {SETTINGS_PATH}") + flash('Settings file does not exist.', 'error') + return redirect(url_for('email.settings')) + + if not os.access(SETTINGS_PATH, os.W_OK): + logger.error(f"Settings file is not writable: {SETTINGS_PATH}") + flash('Settings file is not writable.', 'error') + return redirect(url_for('email.settings')) + + # Load current settings + config = load_settings() + logger.info("Current settings loaded successfully") + + # Create case-insensitive form data mapping + form_data = {} + for key, value in request.form.items(): + form_data[key.lower()] = value + logger.info(f"Received form data: {dict(request.form)}") + + # Update settings from form + changes_made = False + for section_name in config.sections(): + logger.debug(f"Processing section: {section_name}") + for key in config[section_name]: + if key.startswith(';'): # Skip comment lines + continue + + # Create case-insensitive form key + form_key = f"{section_name}.{key}".lower() + if form_key not in form_data: + logger.debug(f"Form key not found: {form_key}") + continue + + old_value = config.get(section_name, key, fallback='').strip() + new_value = form_data[form_key].strip() + + # Handle empty server banner special case + if section_name == 'Server' and key == 'server_banner' and not new_value: + new_value = '""' + + # Log values for debugging + logger.debug(f"Comparing {form_key}: old='{old_value}' new='{new_value}'") + + if old_value != new_value: + logger.info(f"Updating {form_key}: '{old_value}' -> '{new_value}'") + config.set(section_name, key, new_value) + changes_made = True + + if not changes_made: + logger.warning("No changes detected in settings") + flash('No changes were made to settings.', 'info') + return redirect(url_for('email.settings')) + + # Save settings + logger.info(f"Saving settings to: {SETTINGS_PATH}") + try: + with open(SETTINGS_PATH, 'w') as f: + config.write(f) + logger.info("Settings saved successfully") + flash('Settings updated successfully. Restart the server to apply changes.', 'success') + except IOError as e: + logger.error(f"Failed to write settings file: {e}", exc_info=True) + flash(f'Failed to save settings: {str(e)}', 'error') + + return redirect(url_for('email.settings')) + + except Exception as e: + logger.error(f"Error updating settings: {e}", exc_info=True) + flash(f'Error updating settings: {str(e)}', 'error') + return redirect(url_for('email.settings')) + +@email_bp.route('/api/settings/test_database', methods=['POST']) +def test_database_connection_endpoint(): + """Test database connection endpoint.""" + try: + data = request.get_json() + if not data or 'url' not in data: + return jsonify({'status': 'error', 'message': 'No database URL provided'}) + + success, message = test_database_connection(data['url']) + if success: + return jsonify({'status': 'success', 'message': message}) + return jsonify({'status': 'error', 'message': message}) + + except Exception as e: + logger.error(f"Error testing database connection: {e}") + return jsonify({'status': 'error', 'message': str(e)}) + +@email_bp.route('/api/settings/upload_cert', methods=['POST']) +def upload_cert(): + """Handle certificate file upload.""" + try: + if 'cert_file' not in request.files: + return jsonify({'status': 'error', 'message': 'No file provided'}) + + file = request.files['cert_file'] + if file.filename == '': + return jsonify({'status': 'error', 'message': 'No file selected'}) + + if file and allowed_file(file.filename): + timestamp = int(time.time()) + filename = f'server{timestamp}.crt' + filepath = CERT_UPLOAD_FOLDER / filename + file.save(str(filepath)) + return jsonify({ + 'status': 'success', + 'filepath': f'email_server/ssl_certs/{filename}' + }) + + return jsonify({'status': 'error', 'message': 'Invalid file type'}) + + except Exception as e: + logger.error(f"Error uploading certificate: {e}") + return jsonify({'status': 'error', 'message': str(e)}) + +@email_bp.route('/api/settings/upload_key', methods=['POST']) +def upload_key(): + """Handle key file upload.""" + try: + if 'key_file' not in request.files: + return jsonify({'status': 'error', 'message': 'No file provided'}) + + file = request.files['key_file'] + if file.filename == '': + return jsonify({'status': 'error', 'message': 'No file selected'}) + + if file and allowed_file(file.filename): + timestamp = int(time.time()) + filename = f'server{timestamp}.key' + filepath = CERT_UPLOAD_FOLDER / filename + file.save(str(filepath)) + return jsonify({ + 'status': 'success', + 'filepath': f'email_server/ssl_certs/{filename}' + }) + + return jsonify({'status': 'error', 'message': 'Invalid file type'}) + + except Exception as e: + logger.error(f"Error uploading key file: {e}") + return jsonify({'status': 'error', 'message': str(e)}) + +@email_bp.route('/api/settings/get_public_ip', methods=['GET']) +def get_server_ip(): + """Get server's public IP address.""" + try: + ip = get_public_ip() + if ip: + return jsonify({'status': 'success', 'ip': ip}) + return jsonify({'status': 'error', 'message': 'Failed to get public IP'}) + + except Exception as e: + logger.error(f"Error getting public IP: {e}") + return jsonify({'status': 'error', 'message': str(e)}) diff --git a/email_server/server_web_ui/static/js/dkim-management.js b/email_server/server_web_ui/static/js/dkim-management.js new file mode 100644 index 0000000..7a9cd10 --- /dev/null +++ b/email_server/server_web_ui/static/js/dkim-management.js @@ -0,0 +1,266 @@ +// DKIM Management functionality +const DKIMManagement = { + // Check DNS records for a domain + checkDomainDNS: async function(domain, selector, checkDkimUrl, checkSpfUrl) { + const dkimStatus = document.getElementById(`dkim-status-${domain.replace('.', '-')}`); + const spfStatus = document.getElementById(`spf-status-${domain.replace('.', '-')}`); + + // Show loading state + dkimStatus.innerHTML = 'Checking...'; + spfStatus.innerHTML = 'Checking...'; + + try { + // Check DKIM DNS + const dkimResponse = await fetch(checkDkimUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + domain: domain, + selector: selector + }) + }); + const dkimResult = await dkimResponse.json(); + + // Check SPF DNS + const spfResponse = await fetch(checkSpfUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + domain: domain + }) + }); + const spfResult = await spfResponse.json(); + + // Get DKIM key status from the card class + const domainCard = document.getElementById(`domain-${domain.replace('.', '-')}`); + const isActive = domainCard && domainCard.classList.contains('dkim-active'); + + // Update DKIM status based on active state and DNS visibility + if (isActive) { + if (dkimResult.success) { + dkimStatus.innerHTML = '✓ Active & Configured'; + } else { + dkimStatus.innerHTML = 'Active but DNS not found'; + } + } else { + dkimStatus.innerHTML = 'Disabled'; + } + + // Update SPF status + if (spfResult.success) { + spfStatus.innerHTML = '✓ Found'; + } else { + spfStatus.innerHTML = '✗ Not found'; + } + + // Show detailed results in modal + this.showDNSResults(domain, dkimResult, spfResult); + + } catch (error) { + console.error('DNS check error:', error); + dkimStatus.innerHTML = 'Error'; + spfStatus.innerHTML = 'Error'; + } + }, + + // Show DNS check results in modal + showDNSResults: function(domain, dkimResult, spfResult) { + // Clean up record strings by removing extra quotes and normalizing whitespace + function cleanRecordDisplay(record) { + if (!record) return ''; + return record + .replace(/^["']|["']$/g, '') // Remove outer quotes + .replace(/\\n/g, '') // Remove newlines + .replace(/\s+/g, ' ') // Normalize whitespace + .trim(); // Remove leading/trailing space + } + + const dkimRecordsHtml = dkimResult.records ? + dkimResult.records.map(record => + `
+ ${cleanRecordDisplay(record)} +
` + ).join('') : ''; + + const spfRecordHtml = spfResult.spf_record ? + `
+ ${cleanRecordDisplay(spfResult.spf_record)} +
` : ''; + + const resultsHtml = ` +
DNS Check Results for ${domain}
+ +
+
DKIM Record
+
+ Status: ${dkimResult.success ? 'Found' : 'Not Found'}
+ Message: ${dkimResult.message} + ${dkimResult.records ? ` +
Records: +
+ ${dkimRecordsHtml} +
+ ` : ''} +
+
+ +
+
SPF Record
+
+ Status: ${spfResult.success ? 'Found' : 'Not Found'}
+ Message: ${spfResult.message} + ${spfResult.spf_record ? ` +
Current SPF: + ${spfRecordHtml} + ` : ''} +
+
+ `; + + document.getElementById('dnsResults').innerHTML = resultsHtml; + new bootstrap.Modal(document.getElementById('dnsResultModal')).show(); + }, + + // Check all domains' DNS records + checkAllDNS: async function(checkDkimUrl, checkSpfUrl) { + const domains = document.querySelectorAll('[id^="domain-"]'); + const results = []; + + // Show a progress indicator + showToast('Checking DNS records for all domains...', 'info'); + + for (const domainCard of domains) { + try { + const domainId = domainCard.id.split('-')[1]; + // Extract domain name from the card header + const domainHeaderText = domainCard.querySelector('h5').textContent.trim(); + const domainName = domainHeaderText.split('\n')[0].trim().replace(/^\s*\S+\s+/, ''); // Remove icon + const selectorElement = domainCard.querySelector('code'); + + if (selectorElement) { + const selector = selectorElement.textContent; + + // Check DKIM DNS + const dkimResponse = await fetch(checkDkimUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: `domain=${encodeURIComponent(domainName)}&selector=${encodeURIComponent(selector)}` + }); + const dkimResult = await dkimResponse.json(); + + // Check SPF DNS + const spfResponse = await fetch(checkSpfUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: `domain=${encodeURIComponent(domainName)}` + }); + const spfResult = await spfResponse.json(); + + results.push({ + domain: domainName, + dkim: dkimResult, + spf: spfResult + }); + + // Update individual status indicators + const dkimStatus = document.getElementById(`dkim-status-${domainName.replace('.', '-')}`); + const spfStatus = document.getElementById(`spf-status-${domainName.replace('.', '-')}`); + + if (dkimStatus) { + if (dkimResult.success) { + dkimStatus.innerHTML = '✓ Configured'; + } else { + dkimStatus.innerHTML = '✗ Not found'; + } + } + + if (spfStatus) { + if (spfResult.success) { + spfStatus.innerHTML = '✓ Found'; + } else { + spfStatus.innerHTML = '✗ Not found'; + } + } + + // Small delay between checks to avoid overwhelming the DNS server + await new Promise(resolve => setTimeout(resolve, 300)); + } + } catch (error) { + console.error('Error checking DNS for domain:', error); + } + } + + // Show combined results in modal + this.showAllDNSResults(results); + }, + + // Show combined DNS check results + showAllDNSResults: function(results) { + let tableRows = ''; + + results.forEach(result => { + const dkimIcon = result.dkim.success ? '' : ''; + const spfIcon = result.spf.success ? '' : ''; + + tableRows += ` + + ${result.domain} + + ${dkimIcon} + ${result.dkim.success ? 'Configured' : 'Not Found'} + + + ${spfIcon} + ${result.spf.success ? 'Found' : 'Not Found'} + + + + DKIM: ${result.dkim.message}
+ SPF: ${result.spf.message} +
+ + + `; + }); + + const resultsHtml = ` +
DNS Check Results for All Domains
+
+ + + + + + + + + + + ${tableRows} + +
DomainDKIM StatusSPF StatusDetails
+
+
+
+ + + DKIM: Verifies email signatures for authenticity
+ + SPF: Authorizes servers that can send email for your domain +
+
+
+ `; + + document.getElementById('dnsResults').innerHTML = resultsHtml; + new bootstrap.Modal(document.getElementById('dnsResultModal')).show(); + } +}; \ No newline at end of file diff --git a/email_server/server_web_ui/templates/add_user.html b/email_server/server_web_ui/templates/add_sender.html similarity index 98% rename from email_server/server_web_ui/templates/add_user.html rename to email_server/server_web_ui/templates/add_sender.html index ac22449..134dfed 100644 --- a/email_server/server_web_ui/templates/add_user.html +++ b/email_server/server_web_ui/templates/add_sender.html @@ -82,7 +82,7 @@
- + Back to Senders diff --git a/email_server/server_web_ui/templates/dashboard.html b/email_server/server_web_ui/templates/dashboard.html index 4251343..ae0d07d 100644 --- a/email_server/server_web_ui/templates/dashboard.html +++ b/email_server/server_web_ui/templates/dashboard.html @@ -246,7 +246,7 @@
- + Add User diff --git a/email_server/server_web_ui/templates/dkim.html b/email_server/server_web_ui/templates/dkim.html index 40c2a9e..267f983 100644 --- a/email_server/server_web_ui/templates/dkim.html +++ b/email_server/server_web_ui/templates/dkim.html @@ -38,7 +38,7 @@ Create DKIM - @@ -81,10 +81,10 @@
{% for item in dkim_data %} -
+
- -
+
@@ -150,7 +154,7 @@ DKIM DNS Record - Not checked + Active (DNS not checked)
@@ -327,6 +331,56 @@ {% block extra_js %} {% endblock %} diff --git a/email_server/server_web_ui/templates/domains.html b/email_server/server_web_ui/templates/domains.html index 618a518..f8d5fbb 100644 --- a/email_server/server_web_ui/templates/domains.html +++ b/email_server/server_web_ui/templates/domains.html @@ -31,7 +31,7 @@ Domain Name Status Created - Users + Senders DKIM Actions @@ -62,19 +62,26 @@ - {{ domain.users|length if domain.users else 0 }} users + {{ domain.users|length }} senders - {% set has_dkim = domain.dkim_keys and domain.dkim_keys|selectattr('is_active')|list %} - {% if has_dkim %} - - + {% set active_dkim_keys = domain.dkim_keys|selectattr('is_active')|list %} + {% if active_dkim_keys %} + + + {% else %} - - - + {% if domain.dkim_keys|length > 0 %} + + + + {% else %} + + + + {% endif %} {% endif %} @@ -153,11 +160,15 @@
  • - DKIM configured: {{ domains|selectattr('dkim_keys')|list|length }} -
  • -
  • - - Total users: {{ domains|sum(attribute='users')|length if domains[0].users is defined else 'N/A' }} + DKIM configured: + {% set dkim_count = namespace(active=0) %} + {% for domain in domains %} + {% set active_dkim_keys = domain.dkim_keys|selectattr('is_active')|list %} + {% if active_dkim_keys %} + {% set dkim_count.active = dkim_count.active + 1 %} + {% endif %} + {% endfor %} + {{ dkim_count.active }}
  • @@ -183,7 +194,7 @@
  • - Add users or whitelist IPs for authentication + Add senders or whitelist IPs for authentication
  • @@ -192,3 +203,73 @@
    {% endif %} {% endblock %} + +{% block extra_js %} + + + +{% endblock %} diff --git a/email_server/server_web_ui/templates/edit_user.html b/email_server/server_web_ui/templates/edit_sender.html similarity index 98% rename from email_server/server_web_ui/templates/edit_user.html rename to email_server/server_web_ui/templates/edit_sender.html index 66adeab..6d12560 100644 --- a/email_server/server_web_ui/templates/edit_user.html +++ b/email_server/server_web_ui/templates/edit_sender.html @@ -70,7 +70,7 @@ Update User - + Cancel diff --git a/email_server/server_web_ui/templates/ips.html b/email_server/server_web_ui/templates/ips.html index 7dd1e69..bedefe4 100644 --- a/email_server/server_web_ui/templates/ips.html +++ b/email_server/server_web_ui/templates/ips.html @@ -75,7 +75,7 @@ {% if ip.is_active %} - +
    - + + + {% if csrf_token %} + + {% endif %}
    -
    -
    - - Server Configuration +
    +
    + Server Configuration +
    -
    -
    -
    -
    -
    - -
    Port for SMTP connections (standard: 25, 587)
    - +
    +
    +
    +
    +
    +
    + +
    Port for SMTP unencrypted connections (standard: 25)
    + +
    +
    +
    +
    + +
    Port for SMTP STARTTLS connections (standard: 587)
    + +
    -
    -
    - -
    Port for SMTP over TLS connections (standard: 465)
    - +
    +
    +
    + +
    IP address to bind SMTP server only to (0.0.0.0 for all interfaces)
    + +
    +
    +
    +
    + +
    Server hostname for HELO/EHLO commands
    + +
    -
    -
    -
    -
    - -
    IP address to bind the server to (0.0.0.0 for all interfaces)
    - +
    +
    +
    + +
    Override HELO hostname for SMTP identification
    + +
    -
    -
    -
    - -
    Server hostname for HELO/EHLO commands
    - +
    +
    + +
    Custom SMTP server banner (empty by default - hides SMTP version)
    + +
    @@ -104,21 +141,47 @@
    -
    -
    - - Database Configuration + -
    -
    -
    - -
    SQLite database file path or connection string
    - +
    +
    +
    +
    + +
    Database connection string
    +
    + + +
    +
    + +
    + + + + +
    +
    +
    @@ -126,36 +189,38 @@
    -
    -
    - - Logging Configuration + -
    -
    -
    -
    -
    - -
    Minimum log level to record
    - +
    +
    +
    +
    +
    +
    + +
    Minimum log level to record
    + +
    -
    -
    -
    - -
    Reduce verbose logging from aiosmtpd library
    - +
    +
    + +
    Reduce verbose logging from aiosmtpd library
    + +
    @@ -165,22 +230,24 @@
    -
    -
    - - Email Relay Configuration + -
    -
    -
    - -
    Timeout for external SMTP connections when relaying emails
    - +
    +
    +
    +
    + +
    Timeout for external SMTP connections when relaying emails
    + +
    @@ -188,33 +255,57 @@
    -
    -
    - - TLS/SSL Configuration + -
    -
    -
    -
    -
    - -
    Path to SSL certificate file (.crt or .pem)
    - +
    +
    +
    +
    +
    +
    + +
    Path to SSL certificate file (.crt or .pem)
    +
    + + + +
    +
    -
    -
    -
    - -
    Path to SSL private key file (.key or .pem)
    - +
    +
    + +
    Path to SSL private key file (.key or .pem)
    +
    + + + +
    +
    @@ -224,22 +315,39 @@
    -
    -
    - - DKIM Configuration + -
    -
    -
    - -
    RSA key size for new DKIM keys (larger = more secure, slower)
    - +
    +
    +
    +
    + +
    RSA key size for new DKIM keys (larger = more secure, slower)
    + +
    +
    + +
    Public IP address of server for SPF records (used if auto-detection fails)
    +
    + + +
    +
    @@ -334,7 +442,7 @@ // Form validation document.querySelector('form').addEventListener('submit', function(e) { // Basic validation - const ports = ['Server.SMTP_PORT', 'Server.SMTP_TLS_PORT']; + const ports = ['Server.smtp_port', 'Server.smtp_tls_port']; for (const portField of ports) { const input = document.querySelector(`[name="${portField}"]`); const port = parseInt(input.value); @@ -347,8 +455,8 @@ } // Check if ports are different - const smtpPort = document.querySelector('[name="Server.SMTP_PORT"]').value; - const tlsPort = document.querySelector('[name="Server.SMTP_TLS_PORT"]').value; + const smtpPort = document.querySelector('[name="Server.smtp_port"]').value; + const tlsPort = document.querySelector('[name="Server.smtp_tls_port"]').value; if (smtpPort === tlsPort) { e.preventDefault(); alert('SMTP and TLS ports must be different.'); @@ -378,5 +486,121 @@ }); }); } + + // Handle certificate file uploads + document.getElementById('certFileUpload').addEventListener('change', function(e) { + const file = e.target.files[0]; + if (!file) return; + + const formData = new FormData(); + formData.append('cert_file', file); + + fetch('{{ url_for("email.upload_cert") }}', { + method: 'POST', + body: formData + }) + .then(response => response.json()) + .then(data => { + if (data.status === 'success') { + document.querySelector('[name="TLS.tls_cert_file"]').value = data.filepath; + showToast('Certificate file uploaded successfully', 'success'); + } else { + showToast(data.message || 'Failed to upload certificate file', 'danger'); + } + }) + .catch(() => showToast('Failed to upload certificate file', 'danger')); + }); + + document.getElementById('keyFileUpload').addEventListener('change', function(e) { + const file = e.target.files[0]; + if (!file) return; + + const formData = new FormData(); + formData.append('key_file', file); + + fetch('{{ url_for("email.upload_key") }}', { + method: 'POST', + body: formData + }) + .then(response => response.json()) + .then(data => { + if (data.status === 'success') { + document.querySelector('[name="TLS.tls_key_file"]').value = data.filepath; + showToast('Key file uploaded successfully', 'success'); + } else { + showToast(data.message || 'Failed to upload key file', 'danger'); + } + }) + .catch(() => showToast('Failed to upload key file', 'danger')); + }); + + // Handle database examples + function setDatabaseExample(type) { + const urlInput = document.getElementById('databaseUrl'); + switch(type) { + case 'sqlite': + urlInput.value = 'sqlite:///email_server/server_data/smtp_server.db'; + break; + case 'mysql': + urlInput.value = 'mysql://user:password@localhost:3306/dbname'; + break; + case 'postgresql': + urlInput.value = 'postgresql://user:password@localhost:5432/dbname'; + break; + case 'mssql': + urlInput.value = 'mssql+pyodbc://user:password@server:1433/dbname?driver=ODBC+Driver+17+for+SQL+Server'; + break; + } + } + + // Test database connection + function testDatabaseConnection() { + const url = document.getElementById('databaseUrl').value; + fetch('{{ url_for("email.test_database_connection_endpoint") }}', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ url: url }) + }) + .then(response => response.json()) + .then(data => { + showToast(data.message || (data.status === 'success' ? 'Database connection successful!' : 'Failed to connect to database'), + data.status === 'success' ? 'success' : 'danger'); + }) + .catch(() => showToast('Failed to test database connection', 'danger')); + } + + // Get public IP + function getPublicIP() { + fetch('{{ url_for("email.get_server_ip") }}') + .then(response => response.json()) + .then(data => { + if (data.ip) { + document.querySelector('[name="DKIM.spf_server_ip"]').value = data.ip; + showToast('Public IP fetched successfully', 'success'); + } else { + showToast('Failed to fetch public IP', 'danger'); + } + }) + .catch(() => showToast('Failed to fetch public IP', 'danger')); + } + + // Handle form submission + document.getElementById('settingsForm').addEventListener('submit', function(e) { + // Handle empty server banner + const serverBanner = document.querySelector('[name="Server.server_banner"]'); + if (serverBanner && !serverBanner.value.trim()) { + serverBanner.value = '""'; + } + + // Log form data being submitted + const formData = new FormData(this); + console.log('Submitting settings with data:'); + for (let [key, value] of formData.entries()) { + console.log(`${key}: ${value}`); + } + }); {% endblock %} diff --git a/email_server/server_web_ui/templates/sidebar_email.html b/email_server/server_web_ui/templates/sidebar_email.html index b16653a..a249df6 100644 --- a/email_server/server_web_ui/templates/sidebar_email.html +++ b/email_server/server_web_ui/templates/sidebar_email.html @@ -40,8 +40,8 @@