fixed layout - rename functions for user to sender

This commit is contained in:
nahakubuilde
2025-06-10 01:50:35 +01:00
parent a0dfe8a535
commit f07b9c2150
15 changed files with 352 additions and 238 deletions

View File

@@ -1,2 +1,2 @@
FLASK_APP=app:create_app
FLASK_APP=app:flask_app
FLASK_ENV=development

124
app.py
View File

@@ -31,7 +31,7 @@ sys.path.append(os.path.dirname(os.path.abspath(__file__)))
# Import SMTP server components
from email_server.server_runner import start_server
from email_server.models import create_tables, Session, Domain, User, WhitelistedIP, DKIMKey, hash_password
from email_server.models import create_tables, Session, Domain, Sender, WhitelistedIP, DKIMKey, hash_password
from email_server.settings_loader import load_settings
from email_server.tool_box import get_logger
from email_server.dkim_manager import DKIMManager
@@ -91,7 +91,7 @@ class SMTPServerApp:
db = SQLAlchemy(app)
# Import existing models and register them with Flask-SQLAlchemy
from email_server.models import Base, Domain, User, WhitelistedIP, DKIMKey, EmailLog, AuthLog, CustomHeader
from email_server.models import Base, Domain, Sender, WhitelistedIP, DKIMKey, EmailLog, AuthLog, CustomHeader
# Set the metadata for Flask-Migrate to use existing models
db.Model.metadata = Base.metadata
@@ -109,71 +109,7 @@ class SMTPServerApp:
def index():
"""Redirect root to email dashboard"""
return redirect(url_for('email.dashboard'))
@app.route('/health')
def health_check():
"""Health check endpoint"""
return jsonify({
'status': 'healthy',
'timestamp': datetime.now(ZoneInfo('Europe/London')).isoformat(),
'services': {
'smtp_server': 'running' if self.smtp_task and not self.smtp_task.done() else 'stopped',
'web_frontend': 'running'
},
'version': '1.0.0'
})
@app.route('/api/server/status')
def server_status():
"""Get detailed server status"""
session = Session()
try:
status = {
'smtp_server': {
'running': self.smtp_task and not self.smtp_task.done(),
'port': int(self.settings.get('Server', 'SMTP_PORT', fallback=25)),
'tls_port': int(self.settings.get('Server', 'SMTP_TLS_PORT', fallback=587)),
'hostname': self.settings.get('Server', 'hostname', fallback='localhost')
},
'database': {
'domains': session.query(Domain).filter_by(is_active=True).count(),
'users': session.query(User).filter_by(is_active=True).count(),
'dkim_keys': session.query(DKIMKey).filter_by(is_active=True).count(),
'whitelisted_ips': session.query(WhitelistedIP).filter_by(is_active=True).count()
},
'settings': {
'relay_enabled': self.settings.getboolean('Relay', 'enable_relay', fallback=False),
'tls_enabled': self.settings.getboolean('TLS', 'enable_tls', fallback=True),
'dkim_enabled': self.settings.getboolean('DKIM', 'enable_dkim', fallback=True)
}
}
return jsonify(status)
finally:
session.close()
@app.route('/api/server/restart', methods=['POST'])
def restart_server():
"""Restart the SMTP server (API endpoint) via systemd."""
try:
# Only allow from localhost for security
if request.remote_addr not in ('127.0.0.1', '::1'):
return jsonify({'status': 'error', 'message': 'Unauthorized'}), 403
# Restart the systemd service for SMTP (update service name as needed)
result = subprocess.run(
['systemctl', '--user', 'restart', 'pymta-smtp.service'],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
if result.returncode == 0:
return jsonify({'status': 'success', 'message': 'SMTP server restart requested.'})
else:
return jsonify({'status': 'error', 'message': f'Failed to restart: {result.stderr}'}), 500
except Exception as e:
logger.error(f"Error restarting server: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
# Error handlers
@app.errorhandler(404)
def not_found_error(error):
@@ -192,6 +128,11 @@ class SMTPServerApp:
error_message="Internal server error",
error_details=str(error)), 500
@app.route('/health')
def health_check():
"""Health check endpoint"""
return jsonify(self.check_health())
# Context processors for templates
@app.context_processor
def utility_processor():
@@ -203,6 +144,7 @@ class SMTPServerApp:
'zip': zip,
'str': str,
'int': int,
'check_health': self.check_health
}
self.flask_app = app
@@ -235,24 +177,24 @@ class SMTPServerApp:
for domain_name in sample_domains:
dkim_manager.generate_dkim_keypair(domain_name)
# Add sample users
sample_users = [
# Add sample senders
sample_senders = [
('admin@example.com', 'example.com', 'admin123', False),
]
for email, domain_name, password, can_send_as_domain in sample_users:
existing = session.query(User).filter_by(email=email).first()
for email, domain_name, password, can_send_as_domain in sample_senders:
existing = session.query(Sender).filter_by(email=email).first()
if not existing:
domain = session.query(Domain).filter_by(domain_name=domain_name).first()
if domain:
user = User(
sender = Sender(
email=email,
password_hash=hash_password(password),
domain_id=domain.id,
can_send_as_domain=can_send_as_domain
)
session.add(user)
logger.info(f"Added sample user: {email}")
session.add(sender)
logger.info(f"Added sample sender: {email}")
# Add sample whitelisted IPs
sample_ips = [
@@ -374,6 +316,42 @@ class SMTPServerApp:
finally:
self.shutdown_requested = True
def check_health(self):
"""Check the health of all services"""
status = {
'status': 'healthy',
'timestamp': datetime.now(ZoneInfo('Europe/London')).isoformat(),
'services': {
'smtp_server': 'running' if self.smtp_task and not self.smtp_task.done() else 'stopped',
'web_frontend': 'running',
'database': 'ok'
},
'version': '1.0.0'
}
# Check database connection
try:
session = Session()
# Try to query a simple table to verify connection
session.query(Domain).first()
session.close()
except Exception as e:
status['services']['database'] = 'error'
status['status'] = 'degraded'
logger.error(f"Database health check failed: {e}")
# Try to reconnect to database
try:
create_tables()
logger.info("Database reconnection attempted")
except Exception as reconnect_error:
logger.error(f"Database reconnection failed: {reconnect_error}")
# If any service is not running, set overall status to degraded
if status['services']['smtp_server'] == 'stopped' or status['services']['database'] == 'error':
status['status'] = 'degraded'
return status
def main():
"""Main function"""

View File

@@ -11,8 +11,8 @@ Security Features:
from datetime import datetime
from aiosmtpd.smtp import AuthResult, LoginPassword
from email_server.models import (
Session, User, Domain, WhitelistedIP,
check_password, log_auth_attempt, get_user_by_email,
Session, Sender, Domain, WhitelistedIP,
check_password, log_auth_attempt, get_sender_by_email,
get_whitelisted_ip, get_domain_by_name
)
from email_server.tool_box import get_logger
@@ -47,42 +47,38 @@ class EnhancedAuthenticator:
logger.debug(f'Authentication attempt: {username} from {peer_ip}')
try:
# Look up user in database
user = get_user_by_email(username)
if user and check_password(password, user.password_hash):
# Store authenticated user info in session for later validation
session.authenticated_user = user
session.auth_type = 'user'
# Look up sender in database
sender = get_sender_by_email(username)
if sender and check_password(password, sender.password_hash):
# Store authenticated sender info in session for later validation
session.authenticated_sender = sender
session.auth_type = 'sender'
# Log successful authentication
log_auth_attempt(
auth_type='user',
auth_type='sender',
identifier=username,
ip_address=peer_ip,
success=True,
message=f'Successful user authentication'
message=f'Successful sender authentication'
)
logger.info(f'Authenticated user: {username} (ID: {user.id}, can_send_as_domain: {user.can_send_as_domain})')
logger.info(f'Authenticated sender: {username} (ID: {sender.id}, can_send_as_domain: {sender.can_send_as_domain})')
return AuthResult(success=True, handled=True)
else:
# Log failed authentication
log_auth_attempt(
auth_type='user',
auth_type='sender',
identifier=username,
ip_address=peer_ip,
success=False,
message=f'Invalid credentials for {username}'
)
logger.warning(f'Authentication failed for {username}: invalid credentials')
return AuthResult(success=False, handled=True, message='535 Authentication failed')
except Exception as e:
logger.error(f'Authentication error for {username}: {e}')
log_auth_attempt(
auth_type='user',
auth_type='sender',
identifier=username,
ip_address=peer_ip,
success=False,
@@ -145,19 +141,18 @@ def validate_sender_authorization(session, mail_from: str) -> tuple[bool, str]:
peer_ip = session.peer[0]
# Check user authentication
if hasattr(session, 'authenticated_user') and session.authenticated_user:
user = session.authenticated_user
if user.can_send_as(mail_from):
logger.info(f"User {user.email} authorized to send as {mail_from}")
return True, f"User authorized to send as {mail_from}"
# Check sender authentication
if hasattr(session, 'authenticated_sender') and session.authenticated_sender:
sender = session.authenticated_sender
if sender.can_send_as(mail_from):
logger.info(f"Sender {sender.email} authorized to send as {mail_from}")
return True, f"Sender authorized to send as {mail_from}"
else:
message = f"User {user.email} not authorized to send as {mail_from}"
message = f"Sender {sender.email} not authorized to send as {mail_from}"
logger.warning(message)
log_auth_attempt(
auth_type='sender_validation',
identifier=f"{user.email} -> {mail_from}",
identifier=f"{sender.email} -> {mail_from}",
ip_address=peer_ip,
success=False,
message=message
@@ -205,8 +200,8 @@ def get_authenticated_domain_id(session) -> int:
Returns:
Domain ID or None if not authenticated
"""
if hasattr(session, 'authenticated_user') and session.authenticated_user:
return session.authenticated_user.domain_id
if hasattr(session, 'authenticated_sender') and session.authenticated_sender:
return session.authenticated_sender.domain_id
if hasattr(session, 'authorized_domain') and session.authorized_domain:
domain = get_domain_by_name(session.authorized_domain)

View File

@@ -38,7 +38,7 @@ class Domain(Base):
created_at = Column(DateTime, default=func.now())
# Add relationships with proper foreign key references
users = relationship("User", backref="domain", lazy="joined")
senders = relationship("Sender", 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")
@@ -46,15 +46,15 @@ class Domain(Base):
def __repr__(self):
return f"<Domain(id={self.id}, domain_name='{self.domain_name}', active={self.is_active})>"
class User(Base):
class Sender(Base):
"""
User model with enhanced authentication controls.
Sender model with enhanced authentication controls.
Security features:
- can_send_as_domain: If True, user can send as any email from their domain
- If False, user can only send as their own email address
- can_send_as_domain: If True, sender can send as any email from their domain
- If False, sender can only send as their own email address
"""
__tablename__ = 'esrv_users'
__tablename__ = 'esrv_senders'
id = Column(Integer, primary_key=True)
email = Column(String, unique=True, nullable=False)
@@ -66,28 +66,25 @@ class User(Base):
def can_send_as(self, from_address: str) -> bool:
"""
Check if this user can send emails as the given from_address.
Check if this sender can send emails as the given from_address.
Args:
from_address: The email address the user wants to send from
from_address: The email address the sender wants to send from
Returns:
True if user is allowed to send as this address
True if sender is allowed to send as this address
"""
# User can always send as their own email
# Sender can always send as their own email
if from_address.lower() == self.email.lower():
return True
# If user has domain privileges, check if from_address is from same domain
# If sender has domain privileges, check if from_address is from same domain
if self.can_send_as_domain:
user_domain = self.email.split('@')[1].lower()
sender_domain = self.email.split('@')[1].lower()
from_domain = from_address.split('@')[1].lower() if '@' in from_address else ''
return user_domain == from_domain
return sender_domain == from_domain
return False
def __repr__(self):
return f"<User(id={self.id}, email='{self.email}', domain_id={self.domain_id}, can_send_as_domain={self.can_send_as_domain})>"
return f"<Sender(id={self.id}, email='{self.email}', domain_id={self.domain_id}, can_send_as_domain={self.can_send_as_domain})>"
class WhitelistedIP(Base):
"""
@@ -276,11 +273,11 @@ def log_email(from_address: str, to_address: str, subject: str,
finally:
session.close()
def get_user_by_email(email: str):
"""Get user by email address."""
def get_sender_by_email(email: str):
"""Get sender by email address."""
session = Session()
try:
return session.query(User).filter_by(email=email.lower(), is_active=True).first()
return session.query(Sender).filter_by(email=email.lower(), is_active=True).first()
finally:
session.close()

View File

@@ -46,7 +46,7 @@ async def start_server(shutdown_event=None):
# dkim_manager.initialize_default_keys() # Removed: do not auto-generate DKIM keys for all domains
# Add test data if needed
from .models import Session, Domain, User, WhitelistedIP, hash_password
from .models import Session, Domain, Sender, WhitelistedIP, hash_password
session = Session()
try:
# Add example.com domain if not exists
@@ -57,17 +57,17 @@ async def start_server(shutdown_event=None):
session.commit()
logger.debug("Added example.com domain")
# Add test user if not exists
user = session.query(User).filter_by(email='test@example.com').first()
if not user:
user = User(
# Add test sender if not exists
sender = session.query(Sender).filter_by(email='test@example.com').first()
if not sender:
sender = Sender(
email='test@example.com',
password_hash=hash_password('testpass123'),
domain_id=domain.id
)
session.add(user)
session.add(sender)
session.commit()
logger.debug("Added test user: test@example.com")
logger.debug("Added test sender: test@example.com")
# Add whitelisted IP if not exists
whitelist = session.query(WhitelistedIP).filter_by(ip_address='127.0.0.1').first()

View File

@@ -5,7 +5,7 @@ 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.models import Session, Domain, Sender, DKIMKey, EmailLog, AuthLog
from email_server.tool_box import get_logger
from .routes import email_bp
@@ -19,7 +19,7 @@ def dashboard():
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()
sender_count = session.query(Sender).filter_by(is_active=True).count()
dkim_count = session.query(DKIMKey).filter_by(is_active=True).count()
# Get recent email logs
@@ -30,7 +30,7 @@ def dashboard():
return render_template('dashboard.html',
domain_count=domain_count,
user_count=user_count,
sender_count=sender_count,
dkim_count=dkim_count,
recent_emails=recent_emails,
recent_auths=recent_auths)

View File

@@ -432,11 +432,22 @@ def check_spf_dns():
spf_record = record
break
spf_valid_for_server = False
spf_check_message = ''
public_ip = get_public_ip()
ip_mechanism = f'ip4:{public_ip}'
if spf_record:
result['spf_record'] = spf_record
if ip_mechanism in spf_record:
spf_valid_for_server = True
spf_check_message = f'SPF is valid for this server (contains {ip_mechanism})'
else:
spf_check_message = f'SPF is missing this server\'s IP ({ip_mechanism})'
result['message'] = 'SPF record found'
else:
result['success'] = False
result['message'] = 'No SPF record found'
result['spf_valid_for_server'] = spf_valid_for_server
result['spf_check_message'] = spf_check_message
result['public_ip'] = public_ip
return jsonify(result)

View File

@@ -10,7 +10,7 @@ This module provides domain management functionality including:
"""
from flask import render_template, request, redirect, url_for, flash
from email_server.models import Session, Domain, User, WhitelistedIP, DKIMKey, CustomHeader
from email_server.models import Session, Domain, Sender, WhitelistedIP, DKIMKey, CustomHeader
from email_server.dkim_manager import DKIMManager
from email_server.tool_box import get_logger
from sqlalchemy.orm import joinedload
@@ -188,12 +188,12 @@ def remove_domain(domain_id: int):
domain_name = domain.domain_name
# Count associated records
user_count = session.query(User).filter_by(domain_id=domain_id).count()
sender_count = session.query(Sender).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(Sender).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()
@@ -202,7 +202,7 @@ def remove_domain(domain_id: int):
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')
flash(f'Domain {domain_name} and all associated data permanently removed ({sender_count} senders, {ip_count} IPs, {dkim_count} DKIM keys)', 'success')
return redirect(url_for('email.domains_list'))
except Exception as e:

View File

@@ -10,27 +10,27 @@ This module provides sender management functionality including:
"""
from flask import render_template, request, redirect, url_for, flash
from email_server.models import Session, Domain, User
from email_server.models import Session, Domain, Sender
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
from email_server.models import Session, Domain, Sender, hash_password
logger = get_logger()
@email_bp.route('/senders')
def senders_list():
"""List all users."""
"""List all senders."""
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)
senders = session.query(Sender, Domain).join(Domain, Sender.domain_id == Domain.id).order_by(Sender.email).all()
return render_template('senders.html', senders=senders)
finally:
session.close()
@email_bp.route('/senders/add', methods=['GET', 'POST'])
def add_sender():
"""Add new user."""
"""Add new sender."""
session = Session()
try:
domains = session.query(Domain).filter_by(is_active=True).order_by(Domain.domain_name).all()
@@ -50,118 +50,118 @@ def add_sender():
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()
# Check if sender already exists
existing = session.query(Sender).filter_by(email=email).first()
if existing:
flash(f'User {email} already exists', 'error')
flash(f'Sender {email} already exists', 'error')
return redirect(url_for('email.senders_list'))
# Create user
user = User(
# Create sender
sender = Sender(
email=email,
password_hash=hash_password(password),
domain_id=domain_id,
can_send_as_domain=can_send_as_domain
)
session.add(user)
session.add(sender)
session.commit()
flash(f'User {email} added successfully', 'success')
flash(f'Sender {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')
logger.error(f"Error adding sender: {e}")
flash(f'Error adding sender: {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)."""
"""Disable sender (soft delete)."""
session = Session()
try:
user = session.query(User).get(user_id)
if not user:
flash('User not found', 'error')
sender = session.query(Sender).get(user_id)
if not sender:
flash('Sender not found', 'error')
return redirect(url_for('email.senders_list'))
user_email = user.email
user.is_active = False
sender_email = sender.email
sender.is_active = False
session.commit()
flash(f'User {user_email} disabled', 'success')
flash(f'Sender {sender_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')
logger.error(f"Error disabling sender: {e}")
flash(f'Error disabling sender: {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."""
"""Enable sender."""
session = Session()
try:
user = session.query(User).get(user_id)
if not user:
flash('User not found', 'error')
sender = session.query(Sender).get(user_id)
if not sender:
flash('Sender not found', 'error')
return redirect(url_for('email.senders_list'))
user_email = user.email
user.is_active = True
sender_email = sender.email
sender.is_active = True
session.commit()
flash(f'User {user_email} enabled', 'success')
flash(f'Sender {sender_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')
logger.error(f"Error enabling sender: {e}")
flash(f'Error enabling sender: {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."""
"""Permanently remove sender."""
session = Session()
try:
user = session.query(User).get(user_id)
if not user:
flash('User not found', 'error')
sender = session.query(Sender).get(user_id)
if not sender:
flash('Sender not found', 'error')
return redirect(url_for('email.senders_list'))
user_email = user.email
session.delete(user)
sender_email = sender.email
session.delete(sender)
session.commit()
flash(f'User {user_email} permanently removed', 'success')
flash(f'Sender {sender_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')
logger.error(f"Error removing sender: {e}")
flash(f'Error removing sender: {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."""
"""Edit sender."""
session = Session()
try:
user = session.query(User).get(user_id)
if not user:
flash('User not found', 'error')
sender = session.query(Sender).get(user_id)
if not sender:
flash('Sender 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()
@@ -181,35 +181,35 @@ def edit_sender(user_id: int):
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
# Check if email already exists (excluding current sender)
existing = session.query(Sender).filter(
Sender.email == email,
Sender.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 sender
sender.email = email
sender.domain_id = domain_id
sender.can_send_as_domain = can_send_as_domain
# Update password if provided
if password:
user.password_hash = hash_password(password)
sender.password_hash = hash_password(password)
session.commit()
flash(f'User {email} updated successfully', 'success')
flash(f'Sender {email} updated successfully', 'success')
return redirect(url_for('email.senders_list'))
return render_template('edit_sender.html', user=user, domains=domains)
return render_template('edit_sender.html', sender=sender, domains=domains)
except Exception as e:
session.rollback()
logger.error(f"Error editing user: {e}")
flash(f'Error editing user: {str(e)}', 'error')
logger.error(f"Error editing sender: {e}")
flash(f'Error editing sender: {str(e)}', 'error')
return redirect(url_for('email.senders_list'))
finally:
session.close()

View File

@@ -131,6 +131,20 @@
<!-- Custom SMTP Management CSS -->
<link href="{{ url_for('email.static', filename='css/smtp-management.css') }}" rel="stylesheet">
<style>
/* Ensure tooltip text is visible on dark backgrounds */
.tooltip-inner {
color: #fff !important;
background-color: #222 !important;
font-size: 1rem;
text-align: left;
}
.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,
.bs-tooltip-top .tooltip-arrow::before {
border-top-color: #222 !important;
}
</style>
{% block extra_css %}{% endblock %}
</head>
<body>
@@ -337,6 +351,16 @@
<!-- Custom SMTP Management JavaScript -->
<script src="{{ url_for('email.static', filename='js/smtp-management.js') }}"></script>
<!-- Bootstrap Tooltip Initialization -->
<script>
document.addEventListener('DOMContentLoaded', function() {
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.forEach(function (tooltipTriggerEl) {
new bootstrap.Tooltip(tooltipTriggerEl);
});
});
</script>
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@@ -7,6 +7,7 @@
<div class="row">
<!-- Statistics Cards -->
<div class="col-lg-3 col-md-6 mb-4">
<a href="{{ url_for('email.domains_list') }}" class="dashboard-card-link text-decoration-none">
<div class="card border-primary">
<div class="card-body">
<div class="d-flex align-items-center">
@@ -24,19 +25,21 @@
</div>
</div>
</div>
</a>
</div>
<div class="col-lg-3 col-md-6 mb-4">
<a href="{{ url_for('email.senders_list') }}" class="dashboard-card-link text-decoration-none">
<div class="card border-success">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<h5 class="card-title text-success mb-1">
<i class="bi bi-people me-2"></i>
Users
Senders
</h5>
<h3 class="mb-0">{{ user_count }}</h3>
<small class="text-muted">Authenticated users</small>
<h3 class="mb-0">{{ sender_count }}</h3>
<small class="text-muted">Authenticated senders</small>
</div>
<div class="fs-2 text-success opacity-50">
<i class="bi bi-people"></i>
@@ -44,9 +47,11 @@
</div>
</div>
</div>
</a>
</div>
<div class="col-lg-3 col-md-6 mb-4">
<a href="{{ url_for('email.dkim_list') }}" class="dashboard-card-link text-decoration-none">
<div class="card border-warning">
<div class="card-body">
<div class="d-flex align-items-center">
@@ -64,6 +69,7 @@
</div>
</div>
</div>
</a>
</div>
<div class="col-lg-3 col-md-6 mb-4">
@@ -75,11 +81,28 @@
<i class="bi bi-activity me-2"></i>
Status
</h5>
<h6 class="text-success mb-0">
{% set health = check_health() %}
<h6 class="{% if health.status == 'healthy' %}text-success{% else %}text-warning{% endif %} mb-0 status-indicator"
data-bs-toggle="tooltip"
data-bs-html="true"
data-bs-placement="top"
title="{{ (
"<div class='text-start'><strong>Service Status:</strong><br>" +
'SMTP Server: ' + health.services.smtp_server|title + '<br>' +
'Web Frontend: ' + health.services.web_frontend|title + '<br>' +
'Database: ' + health.services.database|title + '</div>'
) | safe }}">
<i class="bi bi-circle-fill me-1" style="font-size: 0.5rem;"></i>
Online
{{ health.status|title }}
</h6>
<small class="text-muted">Server running</small>
<small class="text-muted">
{% if health.services.smtp_server == 'running' and health.services.database == 'ok' %}
All services running
{% else %}
{% if health.services.smtp_server == 'stopped' %}SMTP Server stopped{% endif %}
{% if health.services.database == 'error' %}Database error{% endif %}
{% endif %}
</small>
</div>
<div class="fs-2 text-info opacity-50">
<i class="bi bi-activity"></i>
@@ -248,7 +271,7 @@
<div class="d-grid">
<a href="{{ url_for('email.add_sender') }}" class="btn btn-outline-success">
<i class="bi bi-person-plus me-2"></i>
Add User
Add Sender
</a>
</div>
</div>
@@ -282,4 +305,31 @@
location.reload();
}, 30000);
</script>
<style>
.status-indicator {
cursor: pointer;
}
.dashboard-card-link {
cursor: pointer;
display: block;
}
.dashboard-card-link:hover .card {
box-shadow: 0 0 0 2px #0d6efd33;
filter: brightness(1.05);
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltipTriggerList.forEach(function(tooltipTriggerEl) {
new bootstrap.Tooltip(tooltipTriggerEl, {
html: true,
placement: 'top',
trigger: 'hover'
});
});
});
</script>
{% endblock %}

View File

@@ -331,6 +331,30 @@
{% block extra_js %}
<script>
// Clipboard copy function for all copy buttons
function copyToClipboard(text) {
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(function() {
showToast('Copied to clipboard!', 'success');
}, function(err) {
showToast('Failed to copy: ' + err, 'danger');
});
} else {
// Fallback for older browsers
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
showToast('Copied to clipboard!', 'success');
} catch (err) {
showToast('Failed to copy: ' + err, 'danger');
}
document.body.removeChild(textarea);
}
}
// Show toast notification
function showToast(message, type = 'info') {
const toastContainer = document.getElementById('toastContainer');
@@ -433,6 +457,14 @@
// Update SPF status
if (spfResult.success) {
spfStatus.innerHTML = '<span class="status-indicator status-success"></span><small class="text-success">✓ Found</small>';
// Show additional SPF check message if available
if (typeof spfResult.spf_valid_for_server !== 'undefined') {
if (spfResult.spf_valid_for_server) {
spfStatus.innerHTML += '<br><span class="text-success"><i class="bi bi-check-circle me-1"></i> SPF is valid for this server</span>';
} else {
spfStatus.innerHTML += '<br><span class="text-warning"><i class="bi bi-exclamation-triangle me-1"></i> SPF missing server IP</span>';
}
}
} else {
spfStatus.innerHTML = '<span class="status-indicator status-danger"></span><small class="text-danger">✗ Not found</small>';
}

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Edit User - SMTP Management{% endblock %}
{% block title %}Edit Sender - SMTP Management{% endblock %}
{% block content %}
<div class="row">
@@ -9,7 +9,7 @@
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-person-fill-gear me-2"></i>
Edit User
Edit Sender
</h5>
</div>
<div class="card-body">
@@ -20,7 +20,7 @@
class="form-control"
id="email"
name="email"
value="{{ user.email }}"
value="{{ sender.email }}"
required>
</div>
@@ -42,7 +42,7 @@
<option value="">Select a domain</option>
{% for domain in domains %}
<option value="{{ domain.id }}"
{% if domain.id == user.domain_id %}selected{% endif %}>
{% if domain.id == sender.domain_id %}selected{% endif %}>
{{ domain.domain_name }}
</option>
{% endfor %}
@@ -55,12 +55,12 @@
type="checkbox"
id="can_send_as_domain"
name="can_send_as_domain"
{% if user.can_send_as_domain %}checked{% endif %}>
{% if sender.can_send_as_domain %}checked{% endif %}>
<label class="form-check-label" for="can_send_as_domain">
<strong>Can send as any email from domain</strong>
</label>
<div class="form-text">
Allow this user to send emails using any address within their domain
Allow this sender to send emails using any address within their domain
</div>
</div>
</div>
@@ -68,7 +68,7 @@
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>
Update User
Update Sender
</button>
<a href="{{ url_for('email.senders_list') }}" class="btn btn-secondary">
<i class="bi bi-x-lg me-1"></i>
@@ -85,26 +85,26 @@
<div class="card-header">
<h6 class="mb-0">
<i class="bi bi-info-circle me-2"></i>
Current User Details
Current Sender Details
</h6>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4">Email:</dt>
<dd class="col-sm-8">
<code>{{ user.email }}</code>
<code>{{ sender.email }}</code>
</dd>
<dt class="col-sm-4">Domain:</dt>
<dd class="col-sm-8">
{% for domain in domains %}
{% if domain.id == user.domain_id %}
{% if domain.id == sender.domain_id %}
<span class="badge bg-secondary">{{ domain.domain_name }}</span>
{% endif %}
{% endfor %}
</dd>
<dt class="col-sm-4">Status:</dt>
<dd class="col-sm-8">
{% if user.is_active %}
{% if sender.is_active %}
<span class="badge bg-success">
<i class="bi bi-check-circle me-1"></i>
Active
@@ -118,7 +118,7 @@
</dd>
<dt class="col-sm-4">Domain Sender:</dt>
<dd class="col-sm-8">
{% if user.can_send_as_domain %}
{% if sender.can_send_as_domain %}
<span class="badge bg-success">
<i class="bi bi-check-circle me-1"></i>
Yes
@@ -133,7 +133,7 @@
<dt class="col-sm-4">Created:</dt>
<dd class="col-sm-8">
<small class="text-muted">
{{ user.created_at.strftime('%Y-%m-%d %H:%M') }}
{{ sender.created_at.strftime('%Y-%m-%d %H:%M') }}
</small>
</dd>
</dl>
@@ -144,7 +144,7 @@
<div class="card-header">
<h6 class="mb-0">
<i class="bi bi-shield-check me-2"></i>
User Permissions
Sender Permissions
</h6>
</div>
<div class="card-body">
@@ -154,8 +154,8 @@
Domain Sender Permission
</h6>
<ul class="mb-0 small">
<li><strong>Enabled:</strong> User can send emails using any address in their domain (e.g., admin@domain.com, support@domain.com)</li>
<li><strong>Disabled:</strong> User can only send emails from their own email address</li>
<li><strong>Enabled:</strong> Sender can send emails using any address in their domain (e.g., admin@domain.com, support@domain.com)</li>
<li><strong>Disabled:</strong> Sender can only send emails from their own email address</li>
</ul>
</div>
</div>

View File

@@ -76,7 +76,7 @@
</h5>
</div>
<div class="card-body p-0">
{% if users %}
{% if senders %}
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<thead>
@@ -90,16 +90,16 @@
</tr>
</thead>
<tbody>
{% for user, domain in users %}
{% for sender, domain in senders %}
<tr>
<td>
<div class="fw-bold">{{ user.email }}</div>
<div class="fw-bold">{{ sender.email }}</div>
</td>
<td>
<span class="badge bg-secondary">{{ domain.domain_name }}</span>
</td>
<td>
{% if user.can_send_as_domain %}
{% if sender.can_send_as_domain %}
<span class="badge bg-warning" style="color: black;">
<i class="bi bi-star me-1"></i>
Domain Sender
@@ -112,11 +112,11 @@
Regular Sender
</span>
<br>
<small class="text-muted">Can only send as {{ user.email }}</small>
<small class="text-muted">Can only send as {{ sender.email }}</small>
{% endif %}
</td>
<td>
{% if user.is_active %}
{% if sender.is_active %}
<span class="badge bg-success">
<i class="bi bi-check-circle me-1"></i>
Active
@@ -130,45 +130,45 @@
</td>
<td>
<small class="text-muted">
{{ user.created_at.strftime('%Y-%m-%d %H:%M') }}
{{ sender.created_at.strftime('%Y-%m-%d %H:%M') }}
</small>
</td>
<td>
<div class="btn-group" role="group">
<!-- Edit Button -->
<a href="{{ url_for('email.edit_sender', user_id=user.id) }}"
<a href="{{ url_for('email.edit_sender', user_id=sender.id) }}"
class="btn btn-outline-primary btn-sm"
title="Edit Sender">
<i class="bi bi-pencil"></i>
</a>
<!-- Enable/Disable Button -->
{% if user.is_active %}
<form method="post" action="{{ url_for('email.delete_sender', user_id=user.id) }}" class="d-inline">
{% if sender.is_active %}
<form method="post" action="{{ url_for('email.delete_sender', user_id=sender.id) }}" class="d-inline">
<button type="submit"
class="btn btn-outline-warning btn-sm"
title="Disable Sender"
onclick="return confirm('Disable user {{ user.email }}?')">
onclick="return confirm('Disable user {{ sender.email }}?')">
<i class="bi bi-pause-circle"></i>
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('email.enable_sender', user_id=user.id) }}" class="d-inline">
<form method="post" action="{{ url_for('email.enable_sender', user_id=sender.id) }}" class="d-inline">
<button type="submit"
class="btn btn-outline-success btn-sm"
title="Enable Sender"
onclick="return confirm('Enable user {{ user.email }}?')">
onclick="return confirm('Enable user {{ sender.email }}?')">
<i class="bi bi-play-circle"></i>
</button>
</form>
{% endif %}
<!-- Permanent Remove Button -->
<form method="post" action="{{ url_for('email.remove_sender', user_id=user.id) }}" class="d-inline">
<form method="post" action="{{ url_for('email.remove_sender', user_id=sender.id) }}" class="d-inline">
<button type="submit"
class="btn btn-outline-danger btn-sm"
title="Permanently Remove Sender"
onclick="return confirm('Permanently remove user {{ user.email }}? This cannot be undone!')">
onclick="return confirm('Permanently remove user {{ sender.email }}? This cannot be undone!')">
<i class="bi bi-trash"></i>
</button>
</form>

View File

@@ -44,7 +44,7 @@
class="nav-link text-white {{ 'active' if request.endpoint in ['email.senders_list', 'email.add_user'] else '' }}">
<i class="bi bi-people me-2"></i>
Allowed Senders
<span class="badge bg-secondary ms-auto">{{ user_count if user_count is defined else '' }}</span>
<span class="badge bg-secondary ms-auto">{{ sender_count if sender_count is defined else '' }}</span>
</a>
</li>
@@ -122,12 +122,22 @@
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<small class="text-muted d-block">Server Status</small>
<small class="text-success">
{% set health = check_health() %}
<small class="{% if health.status == 'healthy' %}text-success{% else %}text-warning{% endif %} status-indicator"
data-bs-toggle="tooltip"
data-bs-html="true"
data-bs-placement="top"
title="{{ (
"<div class='text-start'><strong>Service Status:</strong><br>" +
'SMTP Server: ' + health.services.smtp_server|title + '<br>' +
'Web Frontend: ' + health.services.web_frontend|title + '<br>' +
'Database: ' + health.services.database|title + '</div>'
) | safe }}">
<i class="bi bi-circle-fill me-1" style="font-size: 0.5rem;"></i>
Online
{{ health.status|title }}
</small>
</div>
<button class="btn btn-outline-secondary btn-sm" title="Refresh Status">
<button class="btn btn-outline-secondary btn-sm" title="Refresh Status" onclick="location.reload()">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
@@ -170,6 +180,10 @@
font-size: 0.7rem;
}
.status-indicator {
cursor: pointer;
}
/* Responsive design */
@media (max-width: 768px) {
.sidebar {
@@ -186,3 +200,16 @@
}
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltipTriggerList.forEach(function(tooltipTriggerEl) {
new bootstrap.Tooltip(tooltipTriggerEl, {
html: true,
placement: 'top',
trigger: 'hover'
});
});
});
</script>