update the website code files, fix dns check for DKIM
This commit is contained in:
@@ -161,7 +161,7 @@ class DKIMManager:
|
|||||||
return {
|
return {
|
||||||
'name': f'{dkim_key.selector}._domainkey.{domain_name}',
|
'name': f'{dkim_key.selector}._domainkey.{domain_name}',
|
||||||
'type': 'TXT',
|
'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
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Enhanced security features:
|
|||||||
- All tables use 'esrv_' prefix for namespace isolation
|
- 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.orm import declarative_base, sessionmaker, relationship
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -37,6 +37,12 @@ class Domain(Base):
|
|||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
created_at = Column(DateTime, default=func.now())
|
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):
|
def __repr__(self):
|
||||||
return f"<Domain(id={self.id}, domain_name='{self.domain_name}', active={self.is_active})>"
|
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)
|
id = Column(Integer, primary_key=True)
|
||||||
email = Column(String, unique=True, nullable=False)
|
email = Column(String, unique=True, nullable=False)
|
||||||
password_hash = Column(String, 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)
|
can_send_as_domain = Column(Boolean, default=False)
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
created_at = Column(DateTime, default=func.now())
|
created_at = Column(DateTime, default=func.now())
|
||||||
@@ -94,7 +100,7 @@ class WhitelistedIP(Base):
|
|||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
ip_address = Column(String, nullable=False)
|
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)
|
is_active = Column(Boolean, default=True)
|
||||||
created_at = Column(DateTime, default=func.now())
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
@@ -171,7 +177,7 @@ class DKIMKey(Base):
|
|||||||
__tablename__ = 'esrv_dkim_keys'
|
__tablename__ = 'esrv_dkim_keys'
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
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')
|
selector = Column(String, nullable=False, default='default')
|
||||||
private_key = Column(Text, nullable=False)
|
private_key = Column(Text, nullable=False)
|
||||||
public_key = Column(Text, nullable=False)
|
public_key = Column(Text, nullable=False)
|
||||||
@@ -187,7 +193,7 @@ class CustomHeader(Base):
|
|||||||
__tablename__ = 'esrv_custom_headers'
|
__tablename__ = 'esrv_custom_headers'
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
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_name = Column(String, nullable=False)
|
||||||
header_value = Column(String, nullable=False)
|
header_value = Column(String, nullable=False)
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
|
|||||||
@@ -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 *
|
||||||
|
|||||||
38
email_server/server_web_ui/dashboard.py
Normal file
38
email_server/server_web_ui/dashboard.py
Normal 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()
|
||||||
148
email_server/server_web_ui/database.py
Normal file
148
email_server/server_web_ui/database.py
Normal 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"
|
||||||
442
email_server/server_web_ui/dkim.py
Normal file
442
email_server/server_web_ui/dkim.py
Normal 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)
|
||||||
214
email_server/server_web_ui/domains.py
Normal file
214
email_server/server_web_ui/domains.py
Normal 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()
|
||||||
207
email_server/server_web_ui/ip_whitelist.py
Normal file
207
email_server/server_web_ui/ip_whitelist.py
Normal 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()
|
||||||
80
email_server/server_web_ui/logs.py
Normal file
80
email_server/server_web_ui/logs.py
Normal 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
215
email_server/server_web_ui/senders.py
Normal file
215
email_server/server_web_ui/senders.py
Normal 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()
|
||||||
197
email_server/server_web_ui/settings.py
Normal file
197
email_server/server_web_ui/settings.py
Normal 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)})
|
||||||
266
email_server/server_web_ui/static/js/dkim-management.js
Normal file
266
email_server/server_web_ui/static/js/dkim-management.js
Normal 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();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex justify-content-between">
|
<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>
|
<i class="bi bi-arrow-left me-2"></i>
|
||||||
Back to Senders
|
Back to Senders
|
||||||
</a>
|
</a>
|
||||||
@@ -246,7 +246,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-3 mb-3">
|
<div class="col-md-3 mb-3">
|
||||||
<div class="d-grid">
|
<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>
|
<i class="bi bi-person-plus me-2"></i>
|
||||||
Add User
|
Add User
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
<i class="bi bi-plus-circle me-2"></i>
|
<i class="bi bi-plus-circle me-2"></i>
|
||||||
Create DKIM
|
Create DKIM
|
||||||
</button>
|
</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>
|
<i class="bi bi-arrow-clockwise me-2"></i>
|
||||||
Check All DNS
|
Check All DNS
|
||||||
</button>
|
</button>
|
||||||
@@ -81,10 +81,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% for item in dkim_data %}
|
{% 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="card-header">
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<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">
|
<h5 class="mb-0">
|
||||||
<i class="bi bi-server me-2"></i>
|
<i class="bi bi-server me-2"></i>
|
||||||
{{ item.domain.domain_name }}
|
{{ item.domain.domain_name }}
|
||||||
@@ -96,7 +96,11 @@
|
|||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-group btn-group-sm me-2">
|
<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>
|
<i class="bi bi-search me-1"></i>
|
||||||
Check DNS
|
Check DNS
|
||||||
</button>
|
</button>
|
||||||
@@ -109,12 +113,12 @@
|
|||||||
</a>
|
</a>
|
||||||
<form method="post" action="{{ url_for('email.toggle_dkim', dkim_id=item.dkim_key.id) }}" class="d-inline">
|
<form method="post" action="{{ url_for('email.toggle_dkim', dkim_id=item.dkim_key.id) }}" class="d-inline">
|
||||||
{% if item.dkim_key.is_active %}
|
{% 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>
|
<i class="bi bi-pause-circle me-1"></i>
|
||||||
Disable
|
Disable
|
||||||
</button>
|
</button>
|
||||||
{% else %}
|
{% 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>
|
<i class="bi bi-play-circle me-1"></i>
|
||||||
Enable
|
Enable
|
||||||
</button>
|
</button>
|
||||||
@@ -135,12 +139,12 @@
|
|||||||
Regenerate
|
Regenerate
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 }}">
|
<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.id }}"></i>
|
<i class="bi bi-chevron-down" id="chevron-{{ item.domain.domain_name.replace('.', '-') }}"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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="card-body">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<!-- DKIM DNS Record -->
|
<!-- DKIM DNS Record -->
|
||||||
@@ -150,7 +154,7 @@
|
|||||||
DKIM DNS Record
|
DKIM DNS Record
|
||||||
<span class="dns-status" id="dkim-status-{{ item.domain.domain_name.replace('.', '-') }}">
|
<span class="dns-status" id="dkim-status-{{ item.domain.domain_name.replace('.', '-') }}">
|
||||||
<span class="status-indicator status-warning"></span>
|
<span class="status-indicator status-warning"></span>
|
||||||
<small class="text-muted">Not checked</small>
|
<small class="text-muted">Active (DNS not checked)</small>
|
||||||
</span>
|
</span>
|
||||||
</h6>
|
</h6>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
@@ -327,6 +331,56 @@
|
|||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script>
|
<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) {
|
async function checkDomainDNS(domain, selector) {
|
||||||
const dkimStatus = document.getElementById(`dkim-status-${domain.replace('.', '-')}`);
|
const dkimStatus = document.getElementById(`dkim-status-${domain.replace('.', '-')}`);
|
||||||
const spfStatus = document.getElementById(`spf-status-${domain.replace('.', '-')}`);
|
const spfStatus = document.getElementById(`spf-status-${domain.replace('.', '-')}`);
|
||||||
@@ -337,30 +391,43 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Check DKIM DNS
|
// 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',
|
method: 'POST',
|
||||||
headers: {
|
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();
|
const dkimResult = await dkimResponse.json();
|
||||||
|
|
||||||
// Check SPF DNS
|
// 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',
|
method: 'POST',
|
||||||
headers: {
|
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();
|
const spfResult = await spfResponse.json();
|
||||||
|
|
||||||
// Update DKIM status
|
// Get DKIM key status from data attribute
|
||||||
if (dkimResult.success) {
|
const domainCard = document.getElementById(`domain-${domain.replace('.', '-')}`);
|
||||||
dkimStatus.innerHTML = '<span class="status-indicator status-success"></span><small class="text-success">✓ Configured</small>';
|
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 {
|
} 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
|
// Update SPF status
|
||||||
@@ -380,7 +447,30 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show DNS check results in modal
|
||||||
function showDNSResults(domain, dkimResult, spfResult) {
|
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 = `
|
const resultsHtml = `
|
||||||
<h6>DNS Check Results for ${domain}</h6>
|
<h6>DNS Check Results for ${domain}</h6>
|
||||||
|
|
||||||
@@ -389,7 +479,12 @@
|
|||||||
<div class="alert ${dkimResult.success ? 'alert-success' : 'alert-danger'}">
|
<div class="alert ${dkimResult.success ? 'alert-success' : 'alert-danger'}">
|
||||||
<strong>Status:</strong> ${dkimResult.success ? 'Found' : 'Not Found'}<br>
|
<strong>Status:</strong> ${dkimResult.success ? 'Found' : 'Not Found'}<br>
|
||||||
<strong>Message:</strong> ${dkimResult.message}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -398,68 +493,10 @@
|
|||||||
<div class="alert ${spfResult.success ? 'alert-success' : 'alert-danger'}">
|
<div class="alert ${spfResult.success ? 'alert-success' : 'alert-danger'}">
|
||||||
<strong>Status:</strong> ${spfResult.success ? 'Found' : 'Not Found'}<br>
|
<strong>Status:</strong> ${spfResult.success ? 'Found' : 'Not Found'}<br>
|
||||||
<strong>Message:</strong> ${spfResult.message}
|
<strong>Message:</strong> ${spfResult.message}
|
||||||
${spfResult.spf_record ? `<br><strong>Current SPF:</strong> <code>${spfResult.spf_record}</code>` : ''}
|
${spfResult.spf_record ? `
|
||||||
</div>
|
<br><strong>Current SPF:</strong>
|
||||||
</div>
|
${spfRecordHtml}
|
||||||
`;
|
` : ''}
|
||||||
|
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -468,6 +505,7 @@
|
|||||||
new bootstrap.Modal(document.getElementById('dnsResultModal')).show();
|
new bootstrap.Modal(document.getElementById('dnsResultModal')).show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check all domains' DNS records
|
||||||
async function checkAllDNS() {
|
async function checkAllDNS() {
|
||||||
const domains = document.querySelectorAll('[id^="domain-"]');
|
const domains = document.querySelectorAll('[id^="domain-"]');
|
||||||
const results = [];
|
const results = [];
|
||||||
@@ -544,100 +582,88 @@
|
|||||||
showAllDNSResults(results);
|
showAllDNSResults(results);
|
||||||
}
|
}
|
||||||
|
|
||||||
// AJAX DKIM regeneration function
|
// Show combined DNS check results
|
||||||
// Simple DKIM regeneration function with page reload
|
function showAllDNSResults(results) {
|
||||||
async function regenerateDKIM(domainId, domainName) {
|
let tableRows = '';
|
||||||
const confirmed = await showConfirmation(
|
|
||||||
`Regenerate DKIM key for ${domainName}? This will require updating DNS records.`,
|
|
||||||
'Regenerate DKIM Key',
|
|
||||||
'Regenerate',
|
|
||||||
'btn-warning'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!confirmed) {
|
results.forEach(result => {
|
||||||
return;
|
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 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({})
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
tableRows += `
|
||||||
|
<tr>
|
||||||
if (result.success) {
|
<td><strong>${result.domain}</strong></td>
|
||||||
showToast('DKIM key regenerated successfully! Reloading page...', 'success');
|
<td class="text-center">
|
||||||
|
${dkimIcon}
|
||||||
// Reload the page after a short delay to show the success message
|
<small class="d-block">${result.dkim.success ? 'Configured' : 'Not Found'}</small>
|
||||||
setTimeout(() => {
|
</td>
|
||||||
window.location.reload();
|
<td class="text-center">
|
||||||
}, 1500);
|
${spfIcon}
|
||||||
} else {
|
<small class="d-block">${result.spf.success ? 'Found' : 'Not Found'}</small>
|
||||||
showToast(result.message || 'Error regenerating DKIM key', 'danger');
|
</td>
|
||||||
// Restore button on error
|
<td>
|
||||||
button.innerHTML = originalContent;
|
<small class="text-muted">
|
||||||
button.disabled = false;
|
DKIM: ${result.dkim.message}<br>
|
||||||
}
|
SPF: ${result.spf.message}
|
||||||
} catch (error) {
|
</small>
|
||||||
console.error('DKIM regeneration error:', error);
|
</td>
|
||||||
showToast('Error regenerating DKIM key', 'danger');
|
</tr>
|
||||||
// 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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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
|
// Initialize event handlers
|
||||||
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
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
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
|
// Add click handlers for card headers - only for clickable areas
|
||||||
document.querySelectorAll('.card-header-clickable[data-bs-toggle="collapse"]').forEach(function(element) {
|
document.querySelectorAll('.card-header-clickable[data-bs-toggle="collapse"]').forEach(function(element) {
|
||||||
element.addEventListener('click', function() {
|
element.addEventListener('click', function() {
|
||||||
@@ -658,43 +684,78 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('createDKIMForm').addEventListener('submit', async function(event) {
|
// Add submit handler for DKIM toggle forms
|
||||||
event.preventDefault();
|
document.querySelectorAll('form[action*="toggle_dkim"]').forEach(form => {
|
||||||
const domain = document.getElementById('dkimDomain').value;
|
form.addEventListener('submit', async function(event) {
|
||||||
const selector = document.getElementById('dkimSelector').value.trim();
|
event.preventDefault();
|
||||||
const errorDiv = document.getElementById('createDKIMError');
|
event.stopPropagation();
|
||||||
const successDiv = document.getElementById('createDKIMSuccess');
|
|
||||||
errorDiv.classList.add('d-none');
|
const formData = new FormData(this);
|
||||||
successDiv.classList.add('d-none');
|
const response = await fetch(this.action, {
|
||||||
if (!domain) {
|
method: 'POST',
|
||||||
errorDiv.textContent = 'Please select a domain.';
|
body: formData,
|
||||||
errorDiv.classList.remove('d-none');
|
headers: {
|
||||||
return;
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
}
|
}
|
||||||
try {
|
});
|
||||||
const response = await fetch("{{ url_for('email.create_dkim') }}", {
|
|
||||||
method: 'POST',
|
if (response.ok) {
|
||||||
headers: {
|
const result = await response.json();
|
||||||
'Content-Type': 'application/json',
|
if (result.success) {
|
||||||
'X-Requested-With': 'XMLHttpRequest'
|
// Get the domain card
|
||||||
},
|
const domainCard = this.closest('.card');
|
||||||
body: JSON.stringify({ domain, selector })
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
<th>Domain Name</th>
|
<th>Domain Name</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Created</th>
|
<th>Created</th>
|
||||||
<th>Users</th>
|
<th>Senders</th>
|
||||||
<th>DKIM</th>
|
<th>DKIM</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -62,19 +62,26 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge bg-info">
|
<span class="badge bg-info">
|
||||||
{{ domain.users|length if domain.users else 0 }} users
|
{{ domain.users|length }} senders
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% set has_dkim = domain.dkim_keys and domain.dkim_keys|selectattr('is_active')|list %}
|
{% set active_dkim_keys = domain.dkim_keys|selectattr('is_active')|list %}
|
||||||
{% if has_dkim %}
|
{% if active_dkim_keys %}
|
||||||
<span class="text-success">
|
<span class="dns-status" id="dkim-status-{{ domain.domain_name.replace('.', '-') }}">
|
||||||
<i class="bi bi-shield-check" title="DKIM Configured"></i>
|
<span class="status-indicator status-warning"></span>
|
||||||
|
<i class="bi bi-shield-check" title="DKIM Active (DNS not checked)"></i>
|
||||||
</span>
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-warning">
|
{% if domain.dkim_keys|length > 0 %}
|
||||||
<i class="bi bi-shield-exclamation" title="No DKIM Key"></i>
|
<span class="text-secondary">
|
||||||
</span>
|
<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 %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -153,11 +160,15 @@
|
|||||||
</li>
|
</li>
|
||||||
<li class="mb-2">
|
<li class="mb-2">
|
||||||
<i class="bi bi-shield-check text-warning me-2"></i>
|
<i class="bi bi-shield-check text-warning me-2"></i>
|
||||||
<strong>DKIM configured:</strong> {{ domains|selectattr('dkim_keys')|list|length }}
|
<strong>DKIM configured:</strong>
|
||||||
</li>
|
{% set dkim_count = namespace(active=0) %}
|
||||||
<li>
|
{% for domain in domains %}
|
||||||
<i class="bi bi-people text-info me-2"></i>
|
{% set active_dkim_keys = domain.dkim_keys|selectattr('is_active')|list %}
|
||||||
<strong>Total users:</strong> {{ domains|sum(attribute='users')|length if domains[0].users is defined else 'N/A' }}
|
{% if active_dkim_keys %}
|
||||||
|
{% set dkim_count.active = dkim_count.active + 1 %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{{ dkim_count.active }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -183,7 +194,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<i class="bi bi-arrow-right text-primary me-2"></i>
|
<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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -192,3 +203,73 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% 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 %}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
<i class="bi bi-check-lg me-1"></i>
|
<i class="bi bi-check-lg me-1"></i>
|
||||||
Update User
|
Update User
|
||||||
</button>
|
</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>
|
<i class="bi bi-x-lg me-1"></i>
|
||||||
Cancel
|
Cancel
|
||||||
</a>
|
</a>
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
|
|
||||||
<!-- Enable/Disable Button -->
|
<!-- Enable/Disable Button -->
|
||||||
{% if ip.is_active %}
|
{% 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"
|
<button type="submit"
|
||||||
class="btn btn-outline-warning btn-sm"
|
class="btn btn-outline-warning btn-sm"
|
||||||
title="Disable IP"
|
title="Disable IP"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<i class="bi bi-people me-2"></i>
|
<i class="bi bi-people me-2"></i>
|
||||||
Senders
|
Senders
|
||||||
</h2>
|
</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>
|
<i class="bi bi-person-plus me-2"></i>
|
||||||
Add Sender
|
Add Sender
|
||||||
</a>
|
</a>
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
<td>
|
<td>
|
||||||
<div class="btn-group" role="group">
|
<div class="btn-group" role="group">
|
||||||
<!-- Edit Button -->
|
<!-- 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"
|
class="btn btn-outline-primary btn-sm"
|
||||||
title="Edit Sender">
|
title="Edit Sender">
|
||||||
<i class="bi bi-pencil"></i>
|
<i class="bi bi-pencil"></i>
|
||||||
@@ -144,7 +144,7 @@
|
|||||||
|
|
||||||
<!-- Enable/Disable Button -->
|
<!-- Enable/Disable Button -->
|
||||||
{% if user.is_active %}
|
{% 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"
|
<button type="submit"
|
||||||
class="btn btn-outline-warning btn-sm"
|
class="btn btn-outline-warning btn-sm"
|
||||||
title="Disable Sender"
|
title="Disable Sender"
|
||||||
@@ -153,7 +153,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% 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"
|
<button type="submit"
|
||||||
class="btn btn-outline-success btn-sm"
|
class="btn btn-outline-success btn-sm"
|
||||||
title="Enable Sender"
|
title="Enable Sender"
|
||||||
@@ -164,7 +164,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Permanent Remove Button -->
|
<!-- 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"
|
<button type="submit"
|
||||||
class="btn btn-outline-danger btn-sm"
|
class="btn btn-outline-danger btn-sm"
|
||||||
title="Permanently Remove Sender"
|
title="Permanently Remove Sender"
|
||||||
@@ -184,7 +184,7 @@
|
|||||||
<i class="bi bi-people text-muted" style="font-size: 4rem;"></i>
|
<i class="bi bi-people text-muted" style="font-size: 4rem;"></i>
|
||||||
<h4 class="text-muted mt-3">No senders configured</h4>
|
<h4 class="text-muted mt-3">No senders configured</h4>
|
||||||
<p class="text-muted">Add sender to enable username/password authentication</p>
|
<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>
|
<i class="bi bi-person-plus me-2"></i>
|
||||||
Add Your First Sender
|
Add Your First Sender
|
||||||
</a>
|
</a>
|
||||||
@@ -14,6 +14,15 @@
|
|||||||
color: var(--bs-secondary);
|
color: var(--bs-secondary);
|
||||||
margin-bottom: 0.5rem;
|
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>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -40,61 +49,89 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Server Settings -->
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header">
|
<div class="card-header" data-bs-toggle="collapse" data-bs-target="#serverSettings" aria-expanded="true">
|
||||||
<h5 class="mb-0">
|
<h5 class="mb-0 d-flex justify-content-between align-items-center">
|
||||||
<i class="bi bi-server me-2"></i>
|
<span><i class="bi bi-server me-2"></i>Server Configuration</span>
|
||||||
Server Configuration
|
<i class="bi bi-chevron-down"></i>
|
||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div id="serverSettings" class="collapse show">
|
||||||
<div class="setting-section">
|
<div class="card-body">
|
||||||
<div class="row">
|
<div class="setting-section">
|
||||||
<div class="col-md-6">
|
<div class="row">
|
||||||
<div class="mb-3">
|
<div class="col-md-6">
|
||||||
<label class="form-label">SMTP Port</label>
|
<div class="mb-3">
|
||||||
<div class="setting-description">Port for SMTP connections (standard: 25, 587)</div>
|
<label class="form-label">SMTP Port</label>
|
||||||
<input type="number"
|
<div class="setting-description">Port for SMTP unencrypted connections (standard: 25)</div>
|
||||||
class="form-control"
|
<input type="number"
|
||||||
name="Server.SMTP_PORT"
|
class="form-control"
|
||||||
value="{{ settings['Server']['SMTP_PORT'] }}"
|
name="Server.smtp_port"
|
||||||
min="1" max="65535">
|
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>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="row">
|
||||||
<div class="mb-3">
|
<div class="col-md-6">
|
||||||
<label class="form-label">SMTP TLS Port</label>
|
<div class="mb-3">
|
||||||
<div class="setting-description">Port for SMTP over TLS connections (standard: 465)</div>
|
<label class="form-label">Bind IP Address</label>
|
||||||
<input type="number"
|
<div class="setting-description">IP address to bind SMTP server only to (0.0.0.0 for all interfaces)</div>
|
||||||
class="form-control"
|
<input type="text"
|
||||||
name="Server.SMTP_TLS_PORT"
|
class="form-control"
|
||||||
value="{{ settings['Server']['SMTP_TLS_PORT'] }}"
|
name="Server.bind_ip"
|
||||||
min="1" max="65535">
|
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>
|
||||||
</div>
|
<div class="row">
|
||||||
<div class="row">
|
<div class="col-md-6">
|
||||||
<div class="col-md-6">
|
<div class="mb-3">
|
||||||
<div class="mb-3">
|
<label class="form-label">HELO Hostname</label>
|
||||||
<label class="form-label">Bind IP Address</label>
|
<div class="setting-description">Override HELO hostname for SMTP identification</div>
|
||||||
<div class="setting-description">IP address to bind the server to (0.0.0.0 for all interfaces)</div>
|
<input type="text"
|
||||||
<input type="text"
|
class="form-control"
|
||||||
class="form-control"
|
name="Server.helo_hostname"
|
||||||
name="Server.BIND_IP"
|
value="{{ settings['Server']['helo_hostname'] }}">
|
||||||
value="{{ settings['Server']['BIND_IP'] }}"
|
</div>
|
||||||
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>
|
<div class="col-md-6">
|
||||||
<div class="col-md-6">
|
<div class="mb-3">
|
||||||
<div class="mb-3">
|
<label class="form-label">Server Banner</label>
|
||||||
<label class="form-label">Hostname</label>
|
<div class="setting-description">Custom SMTP server banner (empty by default - hides SMTP version)</div>
|
||||||
<div class="setting-description">Server hostname for HELO/EHLO commands</div>
|
<input type="text"
|
||||||
<input type="text"
|
class="form-control"
|
||||||
class="form-control"
|
name="Server.server_banner"
|
||||||
name="Server.hostname"
|
value="{{ settings['Server']['server_banner'] }}">
|
||||||
value="{{ settings['Server']['hostname'] }}">
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,21 +141,47 @@
|
|||||||
|
|
||||||
<!-- Database Settings -->
|
<!-- Database Settings -->
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header">
|
<div class="card-header collapsed" data-bs-toggle="collapse" data-bs-target="#databaseSettings" aria-expanded="false">
|
||||||
<h5 class="mb-0">
|
<h5 class="mb-0 d-flex justify-content-between align-items-center">
|
||||||
<i class="bi bi-database me-2"></i>
|
<span><i class="bi bi-database me-2"></i>Database Configuration</span>
|
||||||
Database Configuration
|
<i class="bi bi-chevron-down"></i>
|
||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div id="databaseSettings" class="collapse">
|
||||||
<div class="setting-section">
|
<div class="card-body">
|
||||||
<div class="mb-3">
|
<div class="setting-section">
|
||||||
<label class="form-label">Database URL</label>
|
<div class="mb-3">
|
||||||
<div class="setting-description">SQLite database file path or connection string</div>
|
<label class="form-label">Database URL</label>
|
||||||
<input type="text"
|
<div class="setting-description">Database connection string</div>
|
||||||
class="form-control font-monospace"
|
<div class="input-group mb-2">
|
||||||
name="Database.DATABASE_URL"
|
<input type="text"
|
||||||
value="{{ settings['Database']['DATABASE_URL'] }}">
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,36 +189,38 @@
|
|||||||
|
|
||||||
<!-- Logging Settings -->
|
<!-- Logging Settings -->
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header">
|
<div class="card-header collapsed" data-bs-toggle="collapse" data-bs-target="#loggingSettings" aria-expanded="false">
|
||||||
<h5 class="mb-0">
|
<h5 class="mb-0 d-flex justify-content-between align-items-center">
|
||||||
<i class="bi bi-journal-text me-2"></i>
|
<span><i class="bi bi-journal-text me-2"></i>Logging Configuration</span>
|
||||||
Logging Configuration
|
<i class="bi bi-chevron-down"></i>
|
||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div id="loggingSettings" class="collapse">
|
||||||
<div class="setting-section">
|
<div class="card-body">
|
||||||
<div class="row">
|
<div class="setting-section">
|
||||||
<div class="col-md-6">
|
<div class="row">
|
||||||
<div class="mb-3">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Log Level</label>
|
<div class="mb-3">
|
||||||
<div class="setting-description">Minimum log level to record</div>
|
<label class="form-label">Log Level</label>
|
||||||
<select class="form-select" name="Logging.LOG_LEVEL">
|
<div class="setting-description">Minimum log level to record</div>
|
||||||
<option value="DEBUG" {{ 'selected' if settings['Logging']['LOG_LEVEL'] == 'DEBUG' else '' }}>DEBUG</option>
|
<select class="form-select" name="Logging.log_level">
|
||||||
<option value="INFO" {{ 'selected' if settings['Logging']['LOG_LEVEL'] == 'INFO' else '' }}>INFO</option>
|
<option value="DEBUG" {{ 'selected' if settings['Logging']['log_level'] == 'DEBUG' else '' }}>DEBUG</option>
|
||||||
<option value="WARNING" {{ 'selected' if settings['Logging']['LOG_LEVEL'] == 'WARNING' else '' }}>WARNING</option>
|
<option value="INFO" {{ 'selected' if settings['Logging']['log_level'] == 'INFO' else '' }}>INFO</option>
|
||||||
<option value="ERROR" {{ 'selected' if settings['Logging']['LOG_LEVEL'] == 'ERROR' else '' }}>ERROR</option>
|
<option value="WARNING" {{ 'selected' if settings['Logging']['log_level'] == 'WARNING' else '' }}>WARNING</option>
|
||||||
<option value="CRITICAL" {{ 'selected' if settings['Logging']['LOG_LEVEL'] == 'CRITICAL' else '' }}>CRITICAL</option>
|
<option value="ERROR" {{ 'selected' if settings['Logging']['log_level'] == 'ERROR' else '' }}>ERROR</option>
|
||||||
</select>
|
<option value="CRITICAL" {{ 'selected' if settings['Logging']['log_level'] == 'CRITICAL' else '' }}>CRITICAL</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="col-md-6">
|
||||||
<div class="col-md-6">
|
<div class="mb-3">
|
||||||
<div class="mb-3">
|
<label class="form-label">Hide aiosmtpd INFO Messages</label>
|
||||||
<label class="form-label">Hide aiosmtpd INFO Messages</label>
|
<div class="setting-description">Reduce verbose logging from aiosmtpd library</div>
|
||||||
<div class="setting-description">Reduce verbose logging from aiosmtpd library</div>
|
<select class="form-select" name="Logging.hide_info_aiosmtpd">
|
||||||
<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="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>
|
||||||
<option value="false" {{ 'selected' if settings['Logging']['hide_info_aiosmtpd'] == 'false' else '' }}>No</option>
|
</select>
|
||||||
</select>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -165,22 +230,24 @@
|
|||||||
|
|
||||||
<!-- Relay Settings -->
|
<!-- Relay Settings -->
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header">
|
<div class="card-header collapsed" data-bs-toggle="collapse" data-bs-target="#relaySettings" aria-expanded="false">
|
||||||
<h5 class="mb-0">
|
<h5 class="mb-0 d-flex justify-content-between align-items-center">
|
||||||
<i class="bi bi-arrow-repeat me-2"></i>
|
<span><i class="bi bi-arrow-repeat me-2"></i>Email Relay Configuration</span>
|
||||||
Email Relay Configuration
|
<i class="bi bi-chevron-down"></i>
|
||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div id="relaySettings" class="collapse">
|
||||||
<div class="setting-section">
|
<div class="card-body">
|
||||||
<div class="mb-3">
|
<div class="setting-section">
|
||||||
<label class="form-label">Relay Timeout (seconds)</label>
|
<div class="mb-3">
|
||||||
<div class="setting-description">Timeout for external SMTP connections when relaying emails</div>
|
<label class="form-label">Relay Timeout (seconds)</label>
|
||||||
<input type="number"
|
<div class="setting-description">Timeout for external SMTP connections when relaying emails</div>
|
||||||
class="form-control"
|
<input type="number"
|
||||||
name="Relay.RELAY_TIMEOUT"
|
class="form-control"
|
||||||
value="{{ settings['Relay']['RELAY_TIMEOUT'] }}"
|
name="Relay.relay_timeout"
|
||||||
min="5" max="300">
|
value="{{ settings['Relay']['relay_timeout'] }}"
|
||||||
|
min="5" max="300">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,33 +255,57 @@
|
|||||||
|
|
||||||
<!-- TLS Settings -->
|
<!-- TLS Settings -->
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header">
|
<div class="card-header collapsed" data-bs-toggle="collapse" data-bs-target="#tlsSettings" aria-expanded="false">
|
||||||
<h5 class="mb-0">
|
<h5 class="mb-0 d-flex justify-content-between align-items-center">
|
||||||
<i class="bi bi-shield-lock me-2"></i>
|
<span><i class="bi bi-shield-lock me-2"></i>TLS/SSL Configuration</span>
|
||||||
TLS/SSL Configuration
|
<i class="bi bi-chevron-down"></i>
|
||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div id="tlsSettings" class="collapse">
|
||||||
<div class="setting-section">
|
<div class="card-body">
|
||||||
<div class="row">
|
<div class="setting-section">
|
||||||
<div class="col-md-6">
|
<div class="row">
|
||||||
<div class="mb-3">
|
<div class="col-md-6">
|
||||||
<label class="form-label">TLS Certificate File</label>
|
<div class="mb-3">
|
||||||
<div class="setting-description">Path to SSL certificate file (.crt or .pem)</div>
|
<label class="form-label">TLS Certificate File</label>
|
||||||
<input type="text"
|
<div class="setting-description">Path to SSL certificate file (.crt or .pem)</div>
|
||||||
class="form-control font-monospace"
|
<div class="input-group">
|
||||||
name="TLS.TLS_CERT_FILE"
|
<input type="text"
|
||||||
value="{{ settings['TLS']['TLS_CERT_FILE'] }}">
|
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>
|
<div class="col-md-6">
|
||||||
<div class="col-md-6">
|
<div class="mb-3">
|
||||||
<div class="mb-3">
|
<label class="form-label">TLS Private Key File</label>
|
||||||
<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="setting-description">Path to SSL private key file (.key or .pem)</div>
|
<div class="input-group">
|
||||||
<input type="text"
|
<input type="text"
|
||||||
class="form-control font-monospace"
|
class="form-control font-monospace"
|
||||||
name="TLS.TLS_KEY_FILE"
|
name="TLS.tls_key_file"
|
||||||
value="{{ settings['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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -224,22 +315,39 @@
|
|||||||
|
|
||||||
<!-- DKIM Settings -->
|
<!-- DKIM Settings -->
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header">
|
<div class="card-header collapsed" data-bs-toggle="collapse" data-bs-target="#dkimSettings" aria-expanded="false">
|
||||||
<h5 class="mb-0">
|
<h5 class="mb-0 d-flex justify-content-between align-items-center">
|
||||||
<i class="bi bi-key me-2"></i>
|
<span><i class="bi bi-key me-2"></i>DKIM Configuration</span>
|
||||||
DKIM Configuration
|
<i class="bi bi-chevron-down"></i>
|
||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div id="dkimSettings" class="collapse">
|
||||||
<div class="setting-section">
|
<div class="card-body">
|
||||||
<div class="mb-3">
|
<div class="setting-section">
|
||||||
<label class="form-label">DKIM Key Size</label>
|
<div class="mb-3">
|
||||||
<div class="setting-description">RSA key size for new DKIM keys (larger = more secure, slower)</div>
|
<label class="form-label">DKIM Key Size</label>
|
||||||
<select class="form-select" name="DKIM.DKIM_KEY_SIZE">
|
<div class="setting-description">RSA key size for new DKIM keys (larger = more secure, slower)</div>
|
||||||
<option value="1024" {{ 'selected' if settings['DKIM']['DKIM_KEY_SIZE'] == '1024' else '' }}>1024 bits</option>
|
<select class="form-select" name="DKIM.dkim_key_size">
|
||||||
<option value="2048" {{ 'selected' if settings['DKIM']['DKIM_KEY_SIZE'] == '2048' else '' }}>2048 bits (Recommended)</option>
|
<option value="1024" {{ 'selected' if settings['DKIM']['dkim_key_size'] == '1024' else '' }}>1024 bits</option>
|
||||||
<option value="4096" {{ 'selected' if settings['DKIM']['DKIM_KEY_SIZE'] == '4096' else '' }}>4096 bits</option>
|
<option value="2048" {{ 'selected' if settings['DKIM']['dkim_key_size'] == '2048' else '' }}>2048 bits (Recommended)</option>
|
||||||
</select>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -334,7 +442,7 @@
|
|||||||
// Form validation
|
// Form validation
|
||||||
document.querySelector('form').addEventListener('submit', function(e) {
|
document.querySelector('form').addEventListener('submit', function(e) {
|
||||||
// Basic validation
|
// 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) {
|
for (const portField of ports) {
|
||||||
const input = document.querySelector(`[name="${portField}"]`);
|
const input = document.querySelector(`[name="${portField}"]`);
|
||||||
const port = parseInt(input.value);
|
const port = parseInt(input.value);
|
||||||
@@ -347,8 +455,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if ports are different
|
// Check if ports are different
|
||||||
const smtpPort = document.querySelector('[name="Server.SMTP_PORT"]').value;
|
const smtpPort = document.querySelector('[name="Server.smtp_port"]').value;
|
||||||
const tlsPort = document.querySelector('[name="Server.SMTP_TLS_PORT"]').value;
|
const tlsPort = document.querySelector('[name="Server.smtp_tls_port"]').value;
|
||||||
if (smtpPort === tlsPort) {
|
if (smtpPort === tlsPort) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
alert('SMTP and TLS ports must be different.');
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -40,8 +40,8 @@
|
|||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li class="nav-item mb-1">
|
<li class="nav-item mb-1">
|
||||||
<a href="{{ url_for('email.users_list') }}"
|
<a href="{{ url_for('email.senders_list') }}"
|
||||||
class="nav-link text-white {{ 'active' if request.endpoint in ['email.users_list', 'email.add_user'] else '' }}">
|
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>
|
<i class="bi bi-people me-2"></i>
|
||||||
Allowed Senders
|
Allowed Senders
|
||||||
<span class="badge bg-secondary ms-auto">{{ user_count if user_count is defined else '' }}</span>
|
<span class="badge bg-secondary ms-auto">{{ user_count if user_count is defined else '' }}</span>
|
||||||
|
|||||||
164
email_server/server_web_ui/utils.py
Normal file
164
email_server/server_web_ui/utils.py
Normal 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'
|
||||||
20
main.py_OLD
20
main.py_OLD
@@ -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)
|
|
||||||
Reference in New Issue
Block a user