update the website code files, fix dns check for DKIM

This commit is contained in:
nahakubuilde
2025-06-08 22:51:07 +01:00
parent a7e41ad231
commit a0dfe8a535
24 changed files with 2747 additions and 1630 deletions

View File

@@ -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

View File

@@ -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"<Domain(id={self.id}, domain_name='{self.domain_name}', active={self.is_active})>"
@@ -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)

View File

@@ -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 *

View File

@@ -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()

View File

@@ -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"

View File

@@ -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/<int:domain_id>/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/<int:dkim_id>/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/<int:dkim_id>/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/<int:dkim_id>/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)

View File

@@ -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/<int:domain_id>/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/<int:domain_id>/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/<int:domain_id>/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/<int:domain_id>/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()

View File

@@ -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/<int:ip_id>/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/<int:ip_id>/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/<int:ip_id>/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/<int:ip_id>/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()

View File

@@ -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()

File diff suppressed because it is too large Load Diff

View File

@@ -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/<int:user_id>/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/<int:user_id>/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/<int:user_id>/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/<int:user_id>/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()

View File

@@ -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)})

View File

@@ -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 = '<span class="status-indicator status-warning"></span><small class="text-muted">Checking...</small>';
spfStatus.innerHTML = '<span class="status-indicator status-warning"></span><small class="text-muted">Checking...</small>';
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 = '<span class="status-indicator status-success"></span><small class="text-success">✓ Active & Configured</small>';
} else {
dkimStatus.innerHTML = '<span class="status-indicator" style="background-color: #fd7e14;"></span><small class="text-warning">Active but DNS not found</small>';
}
} else {
dkimStatus.innerHTML = '<span class="status-indicator" style="background-color: #6c757d;"></span><small class="text-muted">Disabled</small>';
}
// Update SPF status
if (spfResult.success) {
spfStatus.innerHTML = '<span class="status-indicator status-success"></span><small class="text-success">✓ Found</small>';
} else {
spfStatus.innerHTML = '<span class="status-indicator status-danger"></span><small class="text-danger">✗ Not found</small>';
}
// Show detailed results in modal
this.showDNSResults(domain, dkimResult, spfResult);
} catch (error) {
console.error('DNS check error:', error);
dkimStatus.innerHTML = '<span class="status-indicator status-danger"></span><small class="text-danger">Error</small>';
spfStatus.innerHTML = '<span class="status-indicator status-danger"></span><small class="text-danger">Error</small>';
}
},
// 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 =>
`<div class="record-value" style="word-break: break-all; font-family: monospace; background: #f8f9fa; padding: 8px; border-radius: 4px;">
${cleanRecordDisplay(record)}
</div>`
).join('') : '';
const spfRecordHtml = spfResult.spf_record ?
`<div class="record-value mt-2" style="word-break: break-all; font-family: monospace; background: #f8f9fa; padding: 8px; border-radius: 4px;">
${cleanRecordDisplay(spfResult.spf_record)}
</div>` : '';
const resultsHtml = `
<h6>DNS Check Results for ${domain}</h6>
<div class="mb-3">
<h6 class="text-primary">DKIM Record</h6>
<div class="alert ${dkimResult.success ? 'alert-success' : 'alert-danger'}">
<strong>Status:</strong> ${dkimResult.success ? 'Found' : 'Not Found'}<br>
<strong>Message:</strong> ${dkimResult.message}
${dkimResult.records ? `
<br><strong>Records:</strong>
<div class="records-container mt-2">
${dkimRecordsHtml}
</div>
` : ''}
</div>
</div>
<div class="mb-3">
<h6 class="text-primary">SPF Record</h6>
<div class="alert ${spfResult.success ? 'alert-success' : 'alert-danger'}">
<strong>Status:</strong> ${spfResult.success ? 'Found' : 'Not Found'}<br>
<strong>Message:</strong> ${spfResult.message}
${spfResult.spf_record ? `
<br><strong>Current SPF:</strong>
${spfRecordHtml}
` : ''}
</div>
</div>
`;
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 = '<span class="status-indicator status-success"></span><small class="text-success">✓ Configured</small>';
} else {
dkimStatus.innerHTML = '<span class="status-indicator status-danger"></span><small class="text-danger">✗ Not found</small>';
}
}
if (spfStatus) {
if (spfResult.success) {
spfStatus.innerHTML = '<span class="status-indicator status-success"></span><small class="text-success">✓ Found</small>';
} else {
spfStatus.innerHTML = '<span class="status-indicator status-danger"></span><small class="text-danger">✗ Not found</small>';
}
}
// 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 ? '<i class="bi bi-check-circle-fill text-success"></i>' : '<i class="bi bi-x-circle-fill text-danger"></i>';
const spfIcon = result.spf.success ? '<i class="bi bi-check-circle-fill text-success"></i>' : '<i class="bi bi-x-circle-fill text-danger"></i>';
tableRows += `
<tr>
<td><strong>${result.domain}</strong></td>
<td class="text-center">
${dkimIcon}
<small class="d-block">${result.dkim.success ? 'Configured' : 'Not Found'}</small>
</td>
<td class="text-center">
${spfIcon}
<small class="d-block">${result.spf.success ? 'Found' : 'Not Found'}</small>
</td>
<td>
<small class="text-muted">
DKIM: ${result.dkim.message}<br>
SPF: ${result.spf.message}
</small>
</td>
</tr>
`;
});
const resultsHtml = `
<h6>DNS Check Results for All Domains</h6>
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Domain</th>
<th class="text-center">DKIM Status</th>
<th class="text-center">SPF Status</th>
<th>Details</th>
</tr>
</thead>
<tbody>
${tableRows}
</tbody>
</table>
</div>
<div class="mt-3">
<div class="alert alert-info">
<small>
<i class="bi bi-info-circle me-1"></i>
<strong>DKIM:</strong> Verifies email signatures for authenticity<br>
<i class="bi bi-info-circle me-1"></i>
<strong>SPF:</strong> Authorizes servers that can send email for your domain
</small>
</div>
</div>
`;
document.getElementById('dnsResults').innerHTML = resultsHtml;
new bootstrap.Modal(document.getElementById('dnsResultModal')).show();
}
};

View File

@@ -82,7 +82,7 @@
</div>
<div class="d-flex justify-content-between">
<a href="{{ url_for('email.users_list') }}" class="btn btn-secondary">
<a href="{{ url_for('email.senders_list') }}" class="btn btn-secondary">
<i class="bi bi-arrow-left me-2"></i>
Back to Senders
</a>

View File

@@ -246,7 +246,7 @@
</div>
<div class="col-md-3 mb-3">
<div class="d-grid">
<a href="{{ url_for('email.add_user') }}" class="btn btn-outline-success">
<a href="{{ url_for('email.add_sender') }}" class="btn btn-outline-success">
<i class="bi bi-person-plus me-2"></i>
Add User
</a>

View File

@@ -38,7 +38,7 @@
<i class="bi bi-plus-circle me-2"></i>
Create DKIM
</button>
<button class="btn btn-outline-info" onclick="checkAllDNS()">
<button class="btn btn-outline-info" data-action="check-all-dns">
<i class="bi bi-arrow-clockwise me-2"></i>
Check All DNS
</button>
@@ -81,10 +81,10 @@
</div>
{% for item in dkim_data %}
<div class="card mb-4" id="domain-{{ item.domain.id }}">
<div class="card mb-4" id="domain-{{ item.domain.domain_name.replace('.', '-') }}" data-is-active="{{ item.dkim_key.is_active|tojson }}">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<div class="flex-grow-1 card-header-clickable" style="cursor: pointer;" data-bs-toggle="collapse" data-bs-target="#collapse-{{ item.domain.id }}" aria-expanded="false" aria-controls="collapse-{{ item.domain.id }}">
<div class="flex-grow-1 card-header-clickable" style="cursor: pointer;" data-bs-toggle="collapse" data-bs-target="#collapse-{{ item.domain.domain_name.replace('.', '-') }}" aria-expanded="false" aria-controls="collapse-{{ item.domain.domain_name.replace('.', '-') }}">
<h5 class="mb-0">
<i class="bi bi-server me-2"></i>
{{ item.domain.domain_name }}
@@ -96,7 +96,11 @@
</h5>
</div>
<div class="btn-group btn-group-sm me-2">
<button class="btn btn-outline-primary" onclick="event.stopPropagation(); checkDomainDNS('{{ item.domain.domain_name }}', '{{ item.dkim_key.selector }}')">
<button class="btn btn-outline-primary"
data-action="check-dns"
data-domain="{{ item.domain.domain_name }}"
data-selector="{{ item.dkim_key.selector }}"
onclick="event.stopPropagation();">
<i class="bi bi-search me-1"></i>
Check DNS
</button>
@@ -109,12 +113,12 @@
</a>
<form method="post" action="{{ url_for('email.toggle_dkim', dkim_id=item.dkim_key.id) }}" class="d-inline">
{% if item.dkim_key.is_active %}
<button type="submit" class="btn btn-outline-warning" onclick="event.stopPropagation();">
<button type="submit" class="btn btn-outline-warning" onclick="event.stopPropagation();" title="Disable DKIM">
<i class="bi bi-pause-circle me-1"></i>
Disable
</button>
{% else %}
<button type="submit" class="btn btn-outline-success" onclick="event.stopPropagation();">
<button type="submit" class="btn btn-outline-success" onclick="event.stopPropagation();" title="Enable DKIM">
<i class="bi bi-play-circle me-1"></i>
Enable
</button>
@@ -135,12 +139,12 @@
Regenerate
</button>
</div>
<div class="card-header-clickable" style="cursor: pointer;" data-bs-toggle="collapse" data-bs-target="#collapse-{{ item.domain.id }}" aria-expanded="false" aria-controls="collapse-{{ item.domain.id }}">
<i class="bi bi-chevron-down" id="chevron-{{ item.domain.id }}"></i>
<div class="card-header-clickable" style="cursor: pointer;" data-bs-toggle="collapse" data-bs-target="#collapse-{{ item.domain.domain_name.replace('.', '-') }}" aria-expanded="false" aria-controls="collapse-{{ item.domain.domain_name.replace('.', '-') }}">
<i class="bi bi-chevron-down" id="chevron-{{ item.domain.domain_name.replace('.', '-') }}"></i>
</div>
</div>
</div>
<div class="collapse" id="collapse-{{ item.domain.id }}">
<div class="collapse" id="collapse-{{ item.domain.domain_name.replace('.', '-') }}">
<div class="card-body">
<div class="row">
<!-- DKIM DNS Record -->
@@ -150,7 +154,7 @@
DKIM DNS Record
<span class="dns-status" id="dkim-status-{{ item.domain.domain_name.replace('.', '-') }}">
<span class="status-indicator status-warning"></span>
<small class="text-muted">Not checked</small>
<small class="text-muted">Active (DNS not checked)</small>
</span>
</h6>
<div class="mb-2">
@@ -327,6 +331,56 @@
{% block extra_js %}
<script>
// Show toast notification
function showToast(message, type = 'info') {
const toastContainer = document.getElementById('toastContainer');
if (!toastContainer) {
// Create toast container if it doesn't exist
const container = document.createElement('div');
container.id = 'toastContainer';
container.style.cssText = 'position: fixed; top: 20px; right: 20px; z-index: 1050;';
document.body.appendChild(container);
}
const toast = document.createElement('div');
toast.className = `toast align-items-center border-0 bg-${type}`;
toast.setAttribute('role', 'alert');
toast.setAttribute('aria-live', 'assertive');
toast.setAttribute('aria-atomic', 'true');
const toastContent = document.createElement('div');
toastContent.className = 'd-flex';
const toastBody = document.createElement('div');
toastBody.className = 'toast-body text-white';
toastBody.textContent = message;
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.className = 'btn-close btn-close-white me-2 m-auto';
closeButton.setAttribute('data-bs-dismiss', 'toast');
closeButton.setAttribute('aria-label', 'Close');
toastContent.appendChild(toastBody);
toastContent.appendChild(closeButton);
toast.appendChild(toastContent);
document.getElementById('toastContainer').appendChild(toast);
const bsToast = new bootstrap.Toast(toast, {
animation: true,
autohide: true,
delay: 5000
});
bsToast.show();
// Remove toast after it's hidden
toast.addEventListener('hidden.bs.toast', function() {
toast.remove();
});
}
// Check DNS records for a domain
async function checkDomainDNS(domain, selector) {
const dkimStatus = document.getElementById(`dkim-status-${domain.replace('.', '-')}`);
const spfStatus = document.getElementById(`spf-status-${domain.replace('.', '-')}`);
@@ -337,30 +391,43 @@
try {
// Check DKIM DNS
const dkimResponse = await fetch('{{ url_for("email.check_dkim_dns") }}', {
const dkimResponse = await fetch("{{ url_for('email.check_dkim_dns') }}", {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Type': 'application/x-www-form-urlencoded'
},
body: `domain=${encodeURIComponent(domain)}&selector=${encodeURIComponent(selector)}`
body: new URLSearchParams({
domain: domain,
selector: selector
})
});
const dkimResult = await dkimResponse.json();
// Check SPF DNS
const spfResponse = await fetch('{{ url_for("email.check_spf_dns") }}', {
const spfResponse = await fetch("{{ url_for('email.check_spf_dns') }}", {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Type': 'application/x-www-form-urlencoded'
},
body: `domain=${encodeURIComponent(domain)}`
body: new URLSearchParams({
domain: domain
})
});
const spfResult = await spfResponse.json();
// Update DKIM status
if (dkimResult.success) {
dkimStatus.innerHTML = '<span class="status-indicator status-success"></span><small class="text-success">✓ Configured</small>';
// Get DKIM key status from data attribute
const domainCard = document.getElementById(`domain-${domain.replace('.', '-')}`);
const isActive = domainCard && domainCard.dataset.isActive === 'true';
// Update DKIM status based on active state and DNS visibility
if (isActive) {
if (dkimResult.success) {
dkimStatus.innerHTML = '<span class="status-indicator status-success"></span><small class="text-success">✓ Active & Configured</small>';
} else {
dkimStatus.innerHTML = '<span class="status-indicator" style="background-color: #fd7e14;"></span><small class="text-warning">Active but DNS not found</small>';
}
} else {
dkimStatus.innerHTML = '<span class="status-indicator status-danger"></span><small class="text-danger">✗ Not found</small>';
dkimStatus.innerHTML = '<span class="status-indicator" style="background-color: #6c757d;"></span><small class="text-muted">Disabled</small>';
}
// Update SPF status
@@ -380,7 +447,30 @@
}
}
// Show DNS check results in modal
function showDNSResults(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 =>
`<div class="record-value" style="word-break: break-all; font-family: monospace; background: #f8f9fa; padding: 8px; border-radius: 4px;">
${cleanRecordDisplay(record)}
</div>`
).join('') : '';
const spfRecordHtml = spfResult.spf_record ?
`<div class="record-value mt-2" style="word-break: break-all; font-family: monospace; background: #f8f9fa; padding: 8px; border-radius: 4px;">
${cleanRecordDisplay(spfResult.spf_record)}
</div>` : '';
const resultsHtml = `
<h6>DNS Check Results for ${domain}</h6>
@@ -389,7 +479,12 @@
<div class="alert ${dkimResult.success ? 'alert-success' : 'alert-danger'}">
<strong>Status:</strong> ${dkimResult.success ? 'Found' : 'Not Found'}<br>
<strong>Message:</strong> ${dkimResult.message}
${dkimResult.records ? `<br><strong>Records:</strong> ${dkimResult.records.join(', ')}` : ''}
${dkimResult.records ? `
<br><strong>Records:</strong>
<div class="records-container mt-2">
${dkimRecordsHtml}
</div>
` : ''}
</div>
</div>
@@ -398,68 +493,10 @@
<div class="alert ${spfResult.success ? 'alert-success' : 'alert-danger'}">
<strong>Status:</strong> ${spfResult.success ? 'Found' : 'Not Found'}<br>
<strong>Message:</strong> ${spfResult.message}
${spfResult.spf_record ? `<br><strong>Current SPF:</strong> <code>${spfResult.spf_record}</code>` : ''}
</div>
</div>
`;
document.getElementById('dnsResults').innerHTML = resultsHtml;
new bootstrap.Modal(document.getElementById('dnsResultModal')).show();
}
function showAllDNSResults(results) {
let tableRows = '';
results.forEach(result => {
const dkimIcon = result.dkim.success ? '<i class="bi bi-check-circle-fill text-success"></i>' : '<i class="bi bi-x-circle-fill text-danger"></i>';
const spfIcon = result.spf.success ? '<i class="bi bi-check-circle-fill text-success"></i>' : '<i class="bi bi-x-circle-fill text-danger"></i>';
tableRows += `
<tr>
<td><strong>${result.domain}</strong></td>
<td class="text-center">
${dkimIcon}
<small class="d-block">${result.dkim.success ? 'Configured' : 'Not Found'}</small>
</td>
<td class="text-center">
${spfIcon}
<small class="d-block">${result.spf.success ? 'Found' : 'Not Found'}</small>
</td>
<td>
<small class="text-muted">
DKIM: ${result.dkim.message}<br>
SPF: ${result.spf.message}
</small>
</td>
</tr>
`;
});
const resultsHtml = `
<h6>DNS Check Results for All Domains</h6>
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Domain</th>
<th class="text-center">DKIM Status</th>
<th class="text-center">SPF Status</th>
<th>Details</th>
</tr>
</thead>
<tbody>
${tableRows}
</tbody>
</table>
</div>
<div class="mt-3">
<div class="alert alert-info">
<small>
<i class="bi bi-info-circle me-1"></i>
<strong>DKIM:</strong> Verifies email signatures for authenticity<br>
<i class="bi bi-info-circle me-1"></i>
<strong>SPF:</strong> Authorizes servers that can send email for your domain
</small>
${spfResult.spf_record ? `
<br><strong>Current SPF:</strong>
${spfRecordHtml}
` : ''}
</div>
</div>
`;
@@ -468,6 +505,7 @@
new bootstrap.Modal(document.getElementById('dnsResultModal')).show();
}
// Check all domains' DNS records
async function checkAllDNS() {
const domains = document.querySelectorAll('[id^="domain-"]');
const results = [];
@@ -544,100 +582,88 @@
showAllDNSResults(results);
}
// AJAX DKIM regeneration function
// Simple DKIM regeneration function with page reload
async function regenerateDKIM(domainId, domainName) {
const confirmed = await showConfirmation(
`Regenerate DKIM key for ${domainName}? This will require updating DNS records.`,
'Regenerate DKIM Key',
'Regenerate',
'btn-warning'
);
// Show combined DNS check results
function showAllDNSResults(results) {
let tableRows = '';
if (!confirmed) {
return;
}
const button = event.target.closest('button');
const originalContent = button.innerHTML;
// Show loading state
button.innerHTML = '<i class="bi bi-arrow-clockwise spinner-border spinner-border-sm me-1"></i>Regenerating...';
button.disabled = true;
try {
const response = await fetch(`{{ url_for('email.regenerate_dkim', domain_id=0) }}`.replace('0', domainId), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({})
});
results.forEach(result => {
const dkimIcon = result.dkim.success ? '<i class="bi bi-check-circle-fill text-success"></i>' : '<i class="bi bi-x-circle-fill text-danger"></i>';
const spfIcon = result.spf.success ? '<i class="bi bi-check-circle-fill text-success"></i>' : '<i class="bi bi-x-circle-fill text-danger"></i>';
const result = await response.json();
if (result.success) {
showToast('DKIM key regenerated successfully! Reloading page...', 'success');
// Reload the page after a short delay to show the success message
setTimeout(() => {
window.location.reload();
}, 1500);
} else {
showToast(result.message || 'Error regenerating DKIM key', 'danger');
// Restore button on error
button.innerHTML = originalContent;
button.disabled = false;
}
} catch (error) {
console.error('DKIM regeneration error:', error);
showToast('Error regenerating DKIM key', 'danger');
// Restore button on error
button.innerHTML = originalContent;
button.disabled = false;
}
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {
// Show temporary success message
const button = event.target.closest('button');
const originalText = button.innerHTML;
button.innerHTML = '<i class="bi bi-check me-1"></i>Copied!';
button.classList.add('btn-success');
button.classList.remove('btn-outline-secondary');
setTimeout(() => {
button.innerHTML = originalText;
button.classList.remove('btn-success');
button.classList.add('btn-outline-secondary');
}, 2000);
}).catch(err => {
console.error('Failed to copy: ', err);
tableRows += `
<tr>
<td><strong>${result.domain}</strong></td>
<td class="text-center">
${dkimIcon}
<small class="d-block">${result.dkim.success ? 'Configured' : 'Not Found'}</small>
</td>
<td class="text-center">
${spfIcon}
<small class="d-block">${result.spf.success ? 'Found' : 'Not Found'}</small>
</td>
<td>
<small class="text-muted">
DKIM: ${result.dkim.message}<br>
SPF: ${result.spf.message}
</small>
</td>
</tr>
`;
});
const resultsHtml = `
<h6>DNS Check Results for All Domains</h6>
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Domain</th>
<th class="text-center">DKIM Status</th>
<th class="text-center">SPF Status</th>
<th>Details</th>
</tr>
</thead>
<tbody>
${tableRows}
</tbody>
</table>
</div>
<div class="mt-3">
<div class="alert alert-info">
<small>
<i class="bi bi-info-circle me-1"></i>
<strong>DKIM:</strong> Verifies email signatures for authenticity<br>
<i class="bi bi-info-circle me-1"></i>
<strong>SPF:</strong> Authorizes servers that can send email for your domain
</small>
</div>
</div>
`;
document.getElementById('dnsResults').innerHTML = resultsHtml;
new bootstrap.Modal(document.getElementById('dnsResultModal')).show();
}
// Handle form submissions with custom confirmation dialogs
async function handleFormSubmit(event, message) {
event.preventDefault(); // Prevent default form submission
const confirmed = await showConfirmation(
message,
'Confirm Action',
'Confirm',
'btn-danger'
);
if (confirmed) {
// Submit the form if confirmed
event.target.submit();
}
return false; // Always return false to prevent default submission
}
// Handle collapsible cards
// Initialize event handlers
document.addEventListener('DOMContentLoaded', function() {
// Add click handler for DNS check buttons
document.querySelectorAll('[data-action="check-dns"]').forEach(button => {
button.addEventListener('click', function(event) {
event.stopPropagation();
const domain = this.dataset.domain;
const selector = this.dataset.selector;
checkDomainDNS(domain, selector);
});
});
// Add click handler for check all DNS button
const checkAllButton = document.querySelector('[data-action="check-all-dns"]');
if (checkAllButton) {
checkAllButton.addEventListener('click', function() {
checkAllDNS();
});
}
// Add click handlers for card headers - only for clickable areas
document.querySelectorAll('.card-header-clickable[data-bs-toggle="collapse"]').forEach(function(element) {
element.addEventListener('click', function() {
@@ -658,43 +684,78 @@
}
});
});
});
document.getElementById('createDKIMForm').addEventListener('submit', async function(event) {
event.preventDefault();
const domain = document.getElementById('dkimDomain').value;
const selector = document.getElementById('dkimSelector').value.trim();
const errorDiv = document.getElementById('createDKIMError');
const successDiv = document.getElementById('createDKIMSuccess');
errorDiv.classList.add('d-none');
successDiv.classList.add('d-none');
if (!domain) {
errorDiv.textContent = 'Please select a domain.';
errorDiv.classList.remove('d-none');
return;
}
try {
const response = await fetch("{{ url_for('email.create_dkim') }}", {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({ domain, selector })
// Add submit handler for DKIM toggle forms
document.querySelectorAll('form[action*="toggle_dkim"]').forEach(form => {
form.addEventListener('submit', async function(event) {
event.preventDefault();
event.stopPropagation();
const formData = new FormData(this);
const response = await fetch(this.action, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
});
if (response.ok) {
const result = await response.json();
if (result.success) {
// Get the domain card
const domainCard = this.closest('.card');
if (domainCard) {
// Update the data-is-active attribute
domainCard.dataset.isActive = result.is_active.toString();
// Update the badge
const badge = domainCard.querySelector('.badge');
if (badge) {
if (result.is_active) {
badge.className = 'badge bg-success ms-2';
badge.textContent = 'Active';
} else {
badge.className = 'badge bg-secondary ms-2';
badge.textContent = 'Inactive';
}
}
// Update the button
const button = this.querySelector('button');
if (button) {
if (result.is_active) {
button.className = 'btn btn-outline-warning';
button.innerHTML = '<i class="bi bi-pause-circle me-1"></i>Disable';
button.title = 'Disable DKIM';
} else {
button.className = 'btn btn-outline-success';
button.innerHTML = '<i class="bi bi-play-circle me-1"></i>Enable';
button.title = 'Enable DKIM';
}
}
// Update the status indicator
const dkimStatus = domainCard.querySelector('.dns-status');
if (dkimStatus) {
if (result.is_active) {
dkimStatus.innerHTML = '<span class="status-indicator status-warning"></span><small class="text-muted">Active (DNS not checked)</small>';
} else {
dkimStatus.innerHTML = '<span class="status-indicator" style="background-color: #6c757d;"></span><small class="text-muted">Disabled</small>';
}
}
// Show success message
showToast(result.message, 'success');
}
} else {
showToast(result.message, 'error');
}
} else {
showToast('Error toggling DKIM status', 'error');
}
});
const result = await response.json();
if (result.success) {
successDiv.textContent = result.message || 'DKIM key created.';
successDiv.classList.remove('d-none');
setTimeout(() => { window.location.reload(); }, 1200);
} else {
errorDiv.textContent = result.message || 'Failed to create DKIM key.';
errorDiv.classList.remove('d-none');
}
} catch (err) {
errorDiv.textContent = 'Error creating DKIM key.';
errorDiv.classList.remove('d-none');
}
});
});
</script>
{% endblock %}

View File

@@ -31,7 +31,7 @@
<th>Domain Name</th>
<th>Status</th>
<th>Created</th>
<th>Users</th>
<th>Senders</th>
<th>DKIM</th>
<th>Actions</th>
</tr>
@@ -62,19 +62,26 @@
</td>
<td>
<span class="badge bg-info">
{{ domain.users|length if domain.users else 0 }} users
{{ domain.users|length }} senders
</span>
</td>
<td>
{% set has_dkim = domain.dkim_keys and domain.dkim_keys|selectattr('is_active')|list %}
{% if has_dkim %}
<span class="text-success">
<i class="bi bi-shield-check" title="DKIM Configured"></i>
{% set active_dkim_keys = domain.dkim_keys|selectattr('is_active')|list %}
{% if active_dkim_keys %}
<span class="dns-status" id="dkim-status-{{ domain.domain_name.replace('.', '-') }}">
<span class="status-indicator status-warning"></span>
<i class="bi bi-shield-check" title="DKIM Active (DNS not checked)"></i>
</span>
{% else %}
<span class="text-warning">
<i class="bi bi-shield-exclamation" title="No DKIM Key"></i>
</span>
{% if domain.dkim_keys|length > 0 %}
<span class="text-secondary">
<i class="bi bi-shield" title="DKIM Disabled"></i>
</span>
{% else %}
<span class="text-danger">
<i class="bi bi-shield-exclamation" title="No DKIM Key"></i>
</span>
{% endif %}
{% endif %}
</td>
<td>
@@ -153,11 +160,15 @@
</li>
<li class="mb-2">
<i class="bi bi-shield-check text-warning me-2"></i>
<strong>DKIM configured:</strong> {{ domains|selectattr('dkim_keys')|list|length }}
</li>
<li>
<i class="bi bi-people text-info me-2"></i>
<strong>Total users:</strong> {{ domains|sum(attribute='users')|length if domains[0].users is defined else 'N/A' }}
<strong>DKIM configured:</strong>
{% 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 }}
</li>
</ul>
</div>
@@ -183,7 +194,7 @@
</li>
<li>
<i class="bi bi-arrow-right text-primary me-2"></i>
Add users or whitelist IPs for authentication
Add senders or whitelist IPs for authentication
</li>
</ul>
</div>
@@ -192,3 +203,73 @@
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script>
async function checkDomainDKIM(domain, selector) {
const dkimStatus = document.getElementById(`dkim-status-${domain.replace('.', '-')}`);
if (!dkimStatus) return;
try {
// Check DKIM DNS
const response = await fetch("{{ url_for('email.check_dkim_dns') }}", {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
domain: domain,
selector: selector
})
});
const result = await response.json();
if (result.success) {
dkimStatus.innerHTML = `
<span class="status-indicator status-success"></span>
<i class="bi bi-shield-check" title="DKIM Active & DNS Configured"></i>
`;
} else {
dkimStatus.innerHTML = `
<span class="status-indicator" style="background-color: #fd7e14;"></span>
<i class="bi bi-shield-exclamation" title="DKIM Active but DNS not found"></i>
`;
}
} catch (error) {
console.error('DKIM DNS check error:', error);
dkimStatus.innerHTML = `
<span class="status-indicator status-danger"></span>
<i class="bi bi-shield-x" title="Error checking DKIM DNS"></i>
`;
}
}
// Check DKIM DNS for all domains when page loads
document.addEventListener('DOMContentLoaded', async function() {
{% for domain in domains %}
{% set active_dkim_keys = domain.dkim_keys|selectattr('is_active')|list %}
{% if active_dkim_keys %}
{% set active_key = active_dkim_keys|first %}
await checkDomainDKIM('{{ domain.domain_name }}', '{{ active_key.selector }}');
{% endif %}
{% endfor %}
});
</script>
<style>
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
margin-right: 0.5rem;
}
.status-success { background-color: #28a745; }
.status-warning { background-color: #ffc107; }
.status-danger { background-color: #dc3545; }
.dns-status {
display: inline-flex;
align-items: center;
}
</style>
{% endblock %}

View File

@@ -70,7 +70,7 @@
<i class="bi bi-check-lg me-1"></i>
Update User
</button>
<a href="{{ url_for('email.users_list') }}" class="btn btn-secondary">
<a href="{{ url_for('email.senders_list') }}" class="btn btn-secondary">
<i class="bi bi-x-lg me-1"></i>
Cancel
</a>

View File

@@ -75,7 +75,7 @@
<!-- Enable/Disable Button -->
{% if ip.is_active %}
<form method="post" action="{{ url_for('email.delete_ip', ip_id=ip.id) }}" class="d-inline">
<form method="post" action="{{ url_for('email.disable_ip', ip_id=ip.id) }}" class="d-inline">
<button type="submit"
class="btn btn-outline-warning btn-sm"
title="Disable IP"

View File

@@ -9,7 +9,7 @@
<i class="bi bi-people me-2"></i>
Senders
</h2>
<a href="{{ url_for('email.add_user') }}" class="btn btn-primary">
<a href="{{ url_for('email.add_sender') }}" class="btn btn-primary">
<i class="bi bi-person-plus me-2"></i>
Add Sender
</a>
@@ -136,7 +136,7 @@
<td>
<div class="btn-group" role="group">
<!-- Edit Button -->
<a href="{{ url_for('email.edit_user', user_id=user.id) }}"
<a href="{{ url_for('email.edit_sender', user_id=user.id) }}"
class="btn btn-outline-primary btn-sm"
title="Edit Sender">
<i class="bi bi-pencil"></i>
@@ -144,7 +144,7 @@
<!-- Enable/Disable Button -->
{% if user.is_active %}
<form method="post" action="{{ url_for('email.delete_user', user_id=user.id) }}" class="d-inline">
<form method="post" action="{{ url_for('email.delete_sender', user_id=user.id) }}" class="d-inline">
<button type="submit"
class="btn btn-outline-warning btn-sm"
title="Disable Sender"
@@ -153,7 +153,7 @@
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('email.enable_user', user_id=user.id) }}" class="d-inline">
<form method="post" action="{{ url_for('email.enable_sender', user_id=user.id) }}" class="d-inline">
<button type="submit"
class="btn btn-outline-success btn-sm"
title="Enable Sender"
@@ -164,7 +164,7 @@
{% endif %}
<!-- Permanent Remove Button -->
<form method="post" action="{{ url_for('email.remove_user', user_id=user.id) }}" class="d-inline">
<form method="post" action="{{ url_for('email.remove_sender', user_id=user.id) }}" class="d-inline">
<button type="submit"
class="btn btn-outline-danger btn-sm"
title="Permanently Remove Sender"
@@ -184,7 +184,7 @@
<i class="bi bi-people text-muted" style="font-size: 4rem;"></i>
<h4 class="text-muted mt-3">No senders configured</h4>
<p class="text-muted">Add sender to enable username/password authentication</p>
<a href="{{ url_for('email.add_user') }}" class="btn btn-primary">
<a href="{{ url_for('email.add_sender') }}" class="btn btn-primary">
<i class="bi bi-person-plus me-2"></i>
Add Your First Sender
</a>

View File

@@ -14,6 +14,15 @@
color: var(--bs-secondary);
margin-bottom: 0.5rem;
}
.card-header {
cursor: pointer;
}
.card-header .bi-chevron-down {
transition: transform 0.2s;
}
.card-header.collapsed .bi-chevron-down {
transform: rotate(-90deg);
}
</style>
{% endblock %}
@@ -40,61 +49,89 @@
</div>
</div>
<form method="POST" action="{{ url_for('email.update_settings') }}">
<form method="POST" action="{{ url_for('email.settings_update') }}" id="settingsForm">
<!-- Add CSRF token if enabled -->
{% if csrf_token %}
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% endif %}
<!-- Server Settings -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-server me-2"></i>
Server Configuration
<div class="card-header" data-bs-toggle="collapse" data-bs-target="#serverSettings" aria-expanded="true">
<h5 class="mb-0 d-flex justify-content-between align-items-center">
<span><i class="bi bi-server me-2"></i>Server Configuration</span>
<i class="bi bi-chevron-down"></i>
</h5>
</div>
<div class="card-body">
<div class="setting-section">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">SMTP Port</label>
<div class="setting-description">Port for SMTP connections (standard: 25, 587)</div>
<input type="number"
class="form-control"
name="Server.SMTP_PORT"
value="{{ settings['Server']['SMTP_PORT'] }}"
min="1" max="65535">
<div id="serverSettings" class="collapse show">
<div class="card-body">
<div class="setting-section">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">SMTP Port</label>
<div class="setting-description">Port for SMTP unencrypted connections (standard: 25)</div>
<input type="number"
class="form-control"
name="Server.smtp_port"
value="{{ settings['Server']['smtp_port'] }}"
min="1" max="65535">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">SMTP STARTTLS Port</label>
<div class="setting-description">Port for SMTP STARTTLS connections (standard: 587)</div>
<input type="number"
class="form-control"
name="Server.smtp_tls_port"
value="{{ settings['Server']['smtp_tls_port'] }}"
min="1" max="65535">
</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">SMTP TLS Port</label>
<div class="setting-description">Port for SMTP over TLS connections (standard: 465)</div>
<input type="number"
class="form-control"
name="Server.SMTP_TLS_PORT"
value="{{ settings['Server']['SMTP_TLS_PORT'] }}"
min="1" max="65535">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Bind IP Address</label>
<div class="setting-description">IP address to bind SMTP server only to (0.0.0.0 for all interfaces)</div>
<input type="text"
class="form-control"
name="Server.bind_ip"
value="{{ settings['Server']['bind_ip'] }}"
pattern="^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Hostname</label>
<div class="setting-description">Server hostname for HELO/EHLO commands</div>
<input type="text"
class="form-control"
name="Server.hostname"
value="{{ settings['Server']['hostname'] }}">
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Bind IP Address</label>
<div class="setting-description">IP address to bind the server to (0.0.0.0 for all interfaces)</div>
<input type="text"
class="form-control"
name="Server.BIND_IP"
value="{{ settings['Server']['BIND_IP'] }}"
pattern="^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">HELO Hostname</label>
<div class="setting-description">Override HELO hostname for SMTP identification</div>
<input type="text"
class="form-control"
name="Server.helo_hostname"
value="{{ settings['Server']['helo_hostname'] }}">
</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Hostname</label>
<div class="setting-description">Server hostname for HELO/EHLO commands</div>
<input type="text"
class="form-control"
name="Server.hostname"
value="{{ settings['Server']['hostname'] }}">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Server Banner</label>
<div class="setting-description">Custom SMTP server banner (empty by default - hides SMTP version)</div>
<input type="text"
class="form-control"
name="Server.server_banner"
value="{{ settings['Server']['server_banner'] }}">
</div>
</div>
</div>
</div>
@@ -104,21 +141,47 @@
<!-- Database Settings -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-database me-2"></i>
Database Configuration
<div class="card-header collapsed" data-bs-toggle="collapse" data-bs-target="#databaseSettings" aria-expanded="false">
<h5 class="mb-0 d-flex justify-content-between align-items-center">
<span><i class="bi bi-database me-2"></i>Database Configuration</span>
<i class="bi bi-chevron-down"></i>
</h5>
</div>
<div class="card-body">
<div class="setting-section">
<div class="mb-3">
<label class="form-label">Database URL</label>
<div class="setting-description">SQLite database file path or connection string</div>
<input type="text"
class="form-control font-monospace"
name="Database.DATABASE_URL"
value="{{ settings['Database']['DATABASE_URL'] }}">
<div id="databaseSettings" class="collapse">
<div class="card-body">
<div class="setting-section">
<div class="mb-3">
<label class="form-label">Database URL</label>
<div class="setting-description">Database connection string</div>
<div class="input-group mb-2">
<input type="text"
class="form-control font-monospace"
name="Database.database_url"
id="databaseUrl"
value="{{ settings['Database']['database_url'] }}">
<button class="btn btn-primary" type="button" onclick="testDatabaseConnection()">
<i class="bi bi-check-circle me-1"></i>
Test Connection
</button>
</div>
<div class="mt-3">
<label class="form-label">Example Connection Strings:</label>
<div class="list-group">
<button type="button" class="list-group-item list-group-item-action" onclick="setDatabaseExample('sqlite')">
<strong>SQLite:</strong> sqlite:///email_server/server_data/smtp_server.db
</button>
<button type="button" class="list-group-item list-group-item-action" onclick="setDatabaseExample('mysql')">
<strong>MySQL:</strong> mysql://user:password@localhost:3306/dbname
</button>
<button type="button" class="list-group-item list-group-item-action" onclick="setDatabaseExample('postgresql')">
<strong>PostgreSQL:</strong> postgresql://user:password@localhost:5432/dbname
</button>
<button type="button" class="list-group-item list-group-item-action" onclick="setDatabaseExample('mssql')">
<strong>MSSQL:</strong> mssql+pyodbc://user:password@server:1433/dbname?driver=ODBC+Driver+17+for+SQL+Server
</button>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -126,36 +189,38 @@
<!-- Logging Settings -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-journal-text me-2"></i>
Logging Configuration
<div class="card-header collapsed" data-bs-toggle="collapse" data-bs-target="#loggingSettings" aria-expanded="false">
<h5 class="mb-0 d-flex justify-content-between align-items-center">
<span><i class="bi bi-journal-text me-2"></i>Logging Configuration</span>
<i class="bi bi-chevron-down"></i>
</h5>
</div>
<div class="card-body">
<div class="setting-section">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Log Level</label>
<div class="setting-description">Minimum log level to record</div>
<select class="form-select" name="Logging.LOG_LEVEL">
<option value="DEBUG" {{ 'selected' if settings['Logging']['LOG_LEVEL'] == 'DEBUG' else '' }}>DEBUG</option>
<option value="INFO" {{ 'selected' if settings['Logging']['LOG_LEVEL'] == 'INFO' else '' }}>INFO</option>
<option value="WARNING" {{ 'selected' if settings['Logging']['LOG_LEVEL'] == 'WARNING' else '' }}>WARNING</option>
<option value="ERROR" {{ 'selected' if settings['Logging']['LOG_LEVEL'] == 'ERROR' else '' }}>ERROR</option>
<option value="CRITICAL" {{ 'selected' if settings['Logging']['LOG_LEVEL'] == 'CRITICAL' else '' }}>CRITICAL</option>
</select>
<div id="loggingSettings" class="collapse">
<div class="card-body">
<div class="setting-section">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Log Level</label>
<div class="setting-description">Minimum log level to record</div>
<select class="form-select" name="Logging.log_level">
<option value="DEBUG" {{ 'selected' if settings['Logging']['log_level'] == 'DEBUG' else '' }}>DEBUG</option>
<option value="INFO" {{ 'selected' if settings['Logging']['log_level'] == 'INFO' else '' }}>INFO</option>
<option value="WARNING" {{ 'selected' if settings['Logging']['log_level'] == 'WARNING' else '' }}>WARNING</option>
<option value="ERROR" {{ 'selected' if settings['Logging']['log_level'] == 'ERROR' else '' }}>ERROR</option>
<option value="CRITICAL" {{ 'selected' if settings['Logging']['log_level'] == 'CRITICAL' else '' }}>CRITICAL</option>
</select>
</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Hide aiosmtpd INFO Messages</label>
<div class="setting-description">Reduce verbose logging from aiosmtpd library</div>
<select class="form-select" name="Logging.hide_info_aiosmtpd">
<option value="true" {{ 'selected' if settings['Logging']['hide_info_aiosmtpd'] == 'true' else '' }}>Yes</option>
<option value="false" {{ 'selected' if settings['Logging']['hide_info_aiosmtpd'] == 'false' else '' }}>No</option>
</select>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Hide aiosmtpd INFO Messages</label>
<div class="setting-description">Reduce verbose logging from aiosmtpd library</div>
<select class="form-select" name="Logging.hide_info_aiosmtpd">
<option value="true" {{ 'selected' if settings['Logging']['hide_info_aiosmtpd'] == 'true' else '' }}>Yes</option>
<option value="false" {{ 'selected' if settings['Logging']['hide_info_aiosmtpd'] == 'false' else '' }}>No</option>
</select>
</div>
</div>
</div>
</div>
@@ -165,22 +230,24 @@
<!-- Relay Settings -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-arrow-repeat me-2"></i>
Email Relay Configuration
<div class="card-header collapsed" data-bs-toggle="collapse" data-bs-target="#relaySettings" aria-expanded="false">
<h5 class="mb-0 d-flex justify-content-between align-items-center">
<span><i class="bi bi-arrow-repeat me-2"></i>Email Relay Configuration</span>
<i class="bi bi-chevron-down"></i>
</h5>
</div>
<div class="card-body">
<div class="setting-section">
<div class="mb-3">
<label class="form-label">Relay Timeout (seconds)</label>
<div class="setting-description">Timeout for external SMTP connections when relaying emails</div>
<input type="number"
class="form-control"
name="Relay.RELAY_TIMEOUT"
value="{{ settings['Relay']['RELAY_TIMEOUT'] }}"
min="5" max="300">
<div id="relaySettings" class="collapse">
<div class="card-body">
<div class="setting-section">
<div class="mb-3">
<label class="form-label">Relay Timeout (seconds)</label>
<div class="setting-description">Timeout for external SMTP connections when relaying emails</div>
<input type="number"
class="form-control"
name="Relay.relay_timeout"
value="{{ settings['Relay']['relay_timeout'] }}"
min="5" max="300">
</div>
</div>
</div>
</div>
@@ -188,33 +255,57 @@
<!-- TLS Settings -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-shield-lock me-2"></i>
TLS/SSL Configuration
<div class="card-header collapsed" data-bs-toggle="collapse" data-bs-target="#tlsSettings" aria-expanded="false">
<h5 class="mb-0 d-flex justify-content-between align-items-center">
<span><i class="bi bi-shield-lock me-2"></i>TLS/SSL Configuration</span>
<i class="bi bi-chevron-down"></i>
</h5>
</div>
<div class="card-body">
<div class="setting-section">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">TLS Certificate File</label>
<div class="setting-description">Path to SSL certificate file (.crt or .pem)</div>
<input type="text"
class="form-control font-monospace"
name="TLS.TLS_CERT_FILE"
value="{{ settings['TLS']['TLS_CERT_FILE'] }}">
<div id="tlsSettings" class="collapse">
<div class="card-body">
<div class="setting-section">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">TLS Certificate File</label>
<div class="setting-description">Path to SSL certificate file (.crt or .pem)</div>
<div class="input-group">
<input type="text"
class="form-control font-monospace"
name="TLS.tls_cert_file"
value="{{ settings['TLS']['tls_cert_file'] }}">
<input type="file"
class="d-none"
id="certFileUpload"
accept=".crt,.pem">
<button class="btn btn-outline-secondary"
type="button"
onclick="document.getElementById('certFileUpload').click()">
<i class="bi bi-upload"></i>
</button>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">TLS Private Key File</label>
<div class="setting-description">Path to SSL private key file (.key or .pem)</div>
<input type="text"
class="form-control font-monospace"
name="TLS.TLS_KEY_FILE"
value="{{ settings['TLS']['TLS_KEY_FILE'] }}">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">TLS Private Key File</label>
<div class="setting-description">Path to SSL private key file (.key or .pem)</div>
<div class="input-group">
<input type="text"
class="form-control font-monospace"
name="TLS.tls_key_file"
value="{{ settings['TLS']['tls_key_file'] }}">
<input type="file"
class="d-none"
id="keyFileUpload"
accept=".key,.pem">
<button class="btn btn-outline-secondary"
type="button"
onclick="document.getElementById('keyFileUpload').click()">
<i class="bi bi-upload"></i>
</button>
</div>
</div>
</div>
</div>
</div>
@@ -224,22 +315,39 @@
<!-- DKIM Settings -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-key me-2"></i>
DKIM Configuration
<div class="card-header collapsed" data-bs-toggle="collapse" data-bs-target="#dkimSettings" aria-expanded="false">
<h5 class="mb-0 d-flex justify-content-between align-items-center">
<span><i class="bi bi-key me-2"></i>DKIM Configuration</span>
<i class="bi bi-chevron-down"></i>
</h5>
</div>
<div class="card-body">
<div class="setting-section">
<div class="mb-3">
<label class="form-label">DKIM Key Size</label>
<div class="setting-description">RSA key size for new DKIM keys (larger = more secure, slower)</div>
<select class="form-select" name="DKIM.DKIM_KEY_SIZE">
<option value="1024" {{ 'selected' if settings['DKIM']['DKIM_KEY_SIZE'] == '1024' else '' }}>1024 bits</option>
<option value="2048" {{ 'selected' if settings['DKIM']['DKIM_KEY_SIZE'] == '2048' else '' }}>2048 bits (Recommended)</option>
<option value="4096" {{ 'selected' if settings['DKIM']['DKIM_KEY_SIZE'] == '4096' else '' }}>4096 bits</option>
</select>
<div id="dkimSettings" class="collapse">
<div class="card-body">
<div class="setting-section">
<div class="mb-3">
<label class="form-label">DKIM Key Size</label>
<div class="setting-description">RSA key size for new DKIM keys (larger = more secure, slower)</div>
<select class="form-select" name="DKIM.dkim_key_size">
<option value="1024" {{ 'selected' if settings['DKIM']['dkim_key_size'] == '1024' else '' }}>1024 bits</option>
<option value="2048" {{ 'selected' if settings['DKIM']['dkim_key_size'] == '2048' else '' }}>2048 bits (Recommended)</option>
<option value="4096" {{ 'selected' if settings['DKIM']['dkim_key_size'] == '4096' else '' }}>4096 bits</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">SPF Server IP</label>
<div class="setting-description">Public IP address of server for SPF records (used if auto-detection fails)</div>
<div class="input-group">
<input type="text"
class="form-control"
name="DKIM.spf_server_ip"
value="{{ settings['DKIM']['spf_server_ip'] }}"
pattern="^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$">
<button class="btn btn-danger" type="button" onclick="getPublicIP()">
<i class="bi bi-cloud-download me-1"></i>
Get Public IP
</button>
</div>
</div>
</div>
</div>
</div>
@@ -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}`);
}
});
</script>
{% endblock %}

View File

@@ -40,8 +40,8 @@
</li>
<li class="nav-item mb-1">
<a href="{{ url_for('email.users_list') }}"
class="nav-link text-white {{ 'active' if request.endpoint in ['email.users_list', 'email.add_user'] else '' }}">
<a href="{{ url_for('email.senders_list') }}"
class="nav-link text-white {{ 'active' if request.endpoint in ['email.senders_list', 'email.add_user'] else '' }}">
<i class="bi bi-people me-2"></i>
Allowed Senders
<span class="badge bg-secondary ms-auto">{{ user_count if user_count is defined else '' }}</span>

View File

@@ -0,0 +1,164 @@
"""
Common utilities for the SMTP server web UI.
This module provides shared functionality used across different blueprints.
"""
import socket
import requests
import dns.resolver
from typing import Dict, List, Optional, Union
from email_server.settings_loader import load_settings
import logging
logger = logging.getLogger(__name__)
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:
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:
pass
return '127.0.0.1' # Last resort fallback
def check_dns_record(domain: str, record_type: str, expected_value: Optional[str] = None) -> Dict[str, Union[bool, str, List[str]]]:
"""Check DNS record for a domain."""
try:
resolver = dns.resolver.Resolver()
# Use Cloudflare's DNS servers
resolver.nameservers = ['1.1.1.1', '1.0.0.1']
resolver.timeout = 5
resolver.lifetime = 5
# Try to get records
try:
answers = resolver.resolve(domain, record_type)
except dns.resolver.NXDOMAIN:
return {
'success': False,
'message': f'Domain {domain} does not exist',
'records': []
}
except dns.resolver.NoAnswer:
return {
'success': False,
'message': f'No {record_type} records found for {domain}',
'records': []
}
except Exception as e:
return {
'success': False,
'message': f'DNS lookup error: {str(e)}',
'records': []
}
# Convert answers to strings, handle TXT records specially
if record_type == 'TXT':
# For TXT records, concatenate strings and normalize
records = []
for rdata in answers:
# Join strings and decode bytes if needed
full_txt = ''
for string in rdata.strings:
if isinstance(string, bytes):
string = string.decode('utf-8')
full_txt += string
# Normalize whitespace and add quotes
normalized = '"' + ' '.join(full_txt.split()) + '"'
records.append(normalized)
else:
records = [str(rdata) for rdata in answers]
# If we're looking for a specific value
if expected_value:
# For TXT records, normalize the expected value
if record_type == 'TXT':
if not expected_value.startswith('"'):
expected_value = '"' + expected_value.strip('"') + '"'
# Normalize whitespace in expected value
expected_value = '"' + ' '.join(expected_value.strip('"').split()) + '"'
# Debug logging
logger.debug(f"Comparing DNS records:")
logger.debug(f"Expected: {expected_value}")
logger.debug(f"Found: {records}")
# Check if normalized expected value matches any normalized record
if expected_value in records:
return {
'success': True,
'message': f'Found matching {record_type} record',
'records': records
}
else:
return {
'success': False,
'message': f'Expected {record_type} record not found',
'records': records
}
# If we just want to check if records exist
return {
'success': True,
'message': f'Found {len(records)} {record_type} record(s)',
'records': records
}
except Exception as e:
return {
'success': False,
'message': f'DNS lookup error: {str(e)}',
'records': []
}
def generate_spf_record(domain: str, server_ip: str, existing_spf: Optional[str] = None) -> str:
"""Generate recommended SPF record, preserving existing mechanisms if present."""
if existing_spf:
# Parse existing SPF record
mechanisms = existing_spf.split()
# Check if our IP is already included
ip_mechanism = f'ip4:{server_ip}'
if ip_mechanism in mechanisms:
return existing_spf
# Find the all mechanism (should be last)
all_mechanism = next((m for m in reversed(mechanisms) if m.startswith('-all') or m.startswith('~all') or m == 'all'), None)
if all_mechanism:
# Insert our IP before the all mechanism
insert_pos = mechanisms.index(all_mechanism)
mechanisms.insert(insert_pos, ip_mechanism)
else:
# No all mechanism found, append our IP and a ~all
mechanisms.append(ip_mechanism)
mechanisms.append('~all')
return ' '.join(mechanisms)
else:
# Create new SPF record
return f'v=spf1 ip4:{server_ip} ~all'

View File

@@ -1,20 +0,0 @@
"""
Starts the email server, no web UI!
"""
from email_server.server_runner import start_server
from email_server.tool_box import get_logger
import asyncio
import sys
logger = get_logger()
if __name__ == '__main__':
try:
logger.info('Server started')
asyncio.run(start_server())
except KeyboardInterrupt:
logger.info('Server interrupted by user')
sys.exit(0)
except Exception as e:
logger.error(f'Server error: {e}')
sys.exit(1)