@@ -1,2 +1,2 @@
|
|||||||
FLASK_APP=app:create_app
|
FLASK_APP=app:flask_app
|
||||||
FLASK_ENV=development
|
FLASK_ENV=development
|
||||||
@@ -4,6 +4,7 @@ __pycache__/
|
|||||||
*$py.class
|
*$py.class
|
||||||
|
|
||||||
# Certs, db, private test files
|
# Certs, db, private test files
|
||||||
|
attachments/
|
||||||
settings.ini
|
settings.ini
|
||||||
*.crt
|
*.crt
|
||||||
*.key
|
*.key
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
|||||||
|
|
||||||
# Import SMTP server components
|
# Import SMTP server components
|
||||||
from email_server.server_runner import start_server
|
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.settings_loader import load_settings
|
||||||
from email_server.tool_box import get_logger
|
from email_server.tool_box import get_logger
|
||||||
from email_server.dkim_manager import DKIMManager
|
from email_server.dkim_manager import DKIMManager
|
||||||
@@ -91,7 +91,7 @@ class SMTPServerApp:
|
|||||||
db = SQLAlchemy(app)
|
db = SQLAlchemy(app)
|
||||||
|
|
||||||
# Import existing models and register them with Flask-SQLAlchemy
|
# 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
|
# Set the metadata for Flask-Migrate to use existing models
|
||||||
db.Model.metadata = Base.metadata
|
db.Model.metadata = Base.metadata
|
||||||
|
|
||||||
@@ -109,71 +109,7 @@ class SMTPServerApp:
|
|||||||
def index():
|
def index():
|
||||||
"""Redirect root to email dashboard"""
|
"""Redirect root to email dashboard"""
|
||||||
return redirect(url_for('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
|
# Error handlers
|
||||||
@app.errorhandler(404)
|
@app.errorhandler(404)
|
||||||
def not_found_error(error):
|
def not_found_error(error):
|
||||||
@@ -192,6 +128,11 @@ class SMTPServerApp:
|
|||||||
error_message="Internal server error",
|
error_message="Internal server error",
|
||||||
error_details=str(error)), 500
|
error_details=str(error)), 500
|
||||||
|
|
||||||
|
@app.route('/health')
|
||||||
|
def health_check():
|
||||||
|
"""Health check endpoint"""
|
||||||
|
return jsonify(self.check_health())
|
||||||
|
|
||||||
# Context processors for templates
|
# Context processors for templates
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
def utility_processor():
|
def utility_processor():
|
||||||
@@ -203,6 +144,7 @@ class SMTPServerApp:
|
|||||||
'zip': zip,
|
'zip': zip,
|
||||||
'str': str,
|
'str': str,
|
||||||
'int': int,
|
'int': int,
|
||||||
|
'check_health': self.check_health
|
||||||
}
|
}
|
||||||
|
|
||||||
self.flask_app = app
|
self.flask_app = app
|
||||||
@@ -235,24 +177,24 @@ class SMTPServerApp:
|
|||||||
for domain_name in sample_domains:
|
for domain_name in sample_domains:
|
||||||
dkim_manager.generate_dkim_keypair(domain_name)
|
dkim_manager.generate_dkim_keypair(domain_name)
|
||||||
|
|
||||||
# Add sample users
|
# Add sample senders
|
||||||
sample_users = [
|
sample_senders = [
|
||||||
('admin@example.com', 'example.com', 'admin123', False),
|
('admin@example.com', 'example.com', 'admin123', False),
|
||||||
]
|
]
|
||||||
|
|
||||||
for email, domain_name, password, can_send_as_domain in sample_users:
|
for email, domain_name, password, can_send_as_domain in sample_senders:
|
||||||
existing = session.query(User).filter_by(email=email).first()
|
existing = session.query(Sender).filter_by(email=email).first()
|
||||||
if not existing:
|
if not existing:
|
||||||
domain = session.query(Domain).filter_by(domain_name=domain_name).first()
|
domain = session.query(Domain).filter_by(domain_name=domain_name).first()
|
||||||
if domain:
|
if domain:
|
||||||
user = User(
|
sender = Sender(
|
||||||
email=email,
|
email=email,
|
||||||
password_hash=hash_password(password),
|
password_hash=hash_password(password),
|
||||||
domain_id=domain.id,
|
domain_id=domain.id,
|
||||||
can_send_as_domain=can_send_as_domain
|
can_send_as_domain=can_send_as_domain
|
||||||
)
|
)
|
||||||
session.add(user)
|
session.add(sender)
|
||||||
logger.info(f"Added sample user: {email}")
|
logger.info(f"Added sample sender: {email}")
|
||||||
|
|
||||||
# Add sample whitelisted IPs
|
# Add sample whitelisted IPs
|
||||||
sample_ips = [
|
sample_ips = [
|
||||||
@@ -374,6 +316,42 @@ class SMTPServerApp:
|
|||||||
finally:
|
finally:
|
||||||
self.shutdown_requested = True
|
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():
|
def main():
|
||||||
"""Main function"""
|
"""Main function"""
|
||||||
|
|||||||
+28
-31
@@ -11,8 +11,8 @@ Security Features:
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from aiosmtpd.smtp import AuthResult, LoginPassword
|
from aiosmtpd.smtp import AuthResult, LoginPassword
|
||||||
from email_server.models import (
|
from email_server.models import (
|
||||||
Session, User, Domain, WhitelistedIP,
|
Session, Sender, Domain, WhitelistedIP,
|
||||||
check_password, log_auth_attempt, get_user_by_email,
|
check_password, log_auth_attempt, get_sender_by_email,
|
||||||
get_whitelisted_ip, get_domain_by_name
|
get_whitelisted_ip, get_domain_by_name
|
||||||
)
|
)
|
||||||
from email_server.tool_box import get_logger
|
from email_server.tool_box import get_logger
|
||||||
@@ -32,7 +32,7 @@ class EnhancedAuthenticator:
|
|||||||
def __call__(self, server, session, envelope, mechanism, auth_data):
|
def __call__(self, server, session, envelope, mechanism, auth_data):
|
||||||
if not isinstance(auth_data, LoginPassword):
|
if not isinstance(auth_data, LoginPassword):
|
||||||
logger.warning(f'Invalid auth data format: {type(auth_data)}')
|
logger.warning(f'Invalid auth data format: {type(auth_data)}')
|
||||||
return AuthResult(success=False, handled=True, message='535 Authentication failed')
|
return AuthResult(success=False, handled=False, message='535 Authentication failed')
|
||||||
|
|
||||||
# Decode bytes to string if necessary
|
# Decode bytes to string if necessary
|
||||||
username = auth_data.login
|
username = auth_data.login
|
||||||
@@ -47,48 +47,45 @@ class EnhancedAuthenticator:
|
|||||||
logger.debug(f'Authentication attempt: {username} from {peer_ip}')
|
logger.debug(f'Authentication attempt: {username} from {peer_ip}')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Look up user in database
|
# Look up sender in database
|
||||||
user = get_user_by_email(username)
|
sender = get_sender_by_email(username)
|
||||||
|
if sender and check_password(password, sender.password_hash):
|
||||||
if user and check_password(password, user.password_hash):
|
# Store authenticated sender info in session for later validation
|
||||||
# Store authenticated user info in session for later validation
|
session.authenticated_sender = sender
|
||||||
session.authenticated_user = user
|
session.auth_type = 'sender'
|
||||||
session.auth_type = 'user'
|
session.username = username # Store username in session
|
||||||
|
|
||||||
# Log successful authentication
|
# Log successful authentication
|
||||||
log_auth_attempt(
|
log_auth_attempt(
|
||||||
auth_type='user',
|
auth_type='sender',
|
||||||
identifier=username,
|
identifier=username,
|
||||||
ip_address=peer_ip,
|
ip_address=peer_ip,
|
||||||
success=True,
|
success=True,
|
||||||
message=f'Successful user authentication'
|
message=f'Successful sender authentication'
|
||||||
)
|
)
|
||||||
|
logger.info(f'Authenticated sender: {username} (ID: {sender.id}, can_send_as_domain: {sender.can_send_as_domain})')
|
||||||
logger.info(f'Authenticated user: {username} (ID: {user.id}, can_send_as_domain: {user.can_send_as_domain})')
|
|
||||||
return AuthResult(success=True, handled=True)
|
return AuthResult(success=True, handled=True)
|
||||||
else:
|
else:
|
||||||
# Log failed authentication
|
# Log failed authentication
|
||||||
log_auth_attempt(
|
log_auth_attempt(
|
||||||
auth_type='user',
|
auth_type='sender',
|
||||||
identifier=username,
|
identifier=username,
|
||||||
ip_address=peer_ip,
|
ip_address=peer_ip,
|
||||||
success=False,
|
success=False,
|
||||||
message=f'Invalid credentials for {username}'
|
message=f'Invalid credentials for {username}'
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.warning(f'Authentication failed for {username}: invalid credentials')
|
logger.warning(f'Authentication failed for {username}: invalid credentials')
|
||||||
return AuthResult(success=False, handled=True, message='535 Authentication failed')
|
return AuthResult(success=False, handled=False, message='535 Authentication failed')
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Authentication error for {username}: {e}')
|
logger.error(f'Authentication error for {username}: {e}')
|
||||||
log_auth_attempt(
|
log_auth_attempt(
|
||||||
auth_type='user',
|
auth_type='sender',
|
||||||
identifier=username,
|
identifier=username,
|
||||||
ip_address=peer_ip,
|
ip_address=peer_ip,
|
||||||
success=False,
|
success=False,
|
||||||
message=f'Authentication error: {str(e)}'
|
message=f'Authentication error: {str(e)}'
|
||||||
)
|
)
|
||||||
return AuthResult(success=False, handled=True, message='451 Internal server error')
|
return AuthResult(success=False, handled=False, message='451 Internal server error')
|
||||||
|
|
||||||
class EnhancedIPAuthenticator:
|
class EnhancedIPAuthenticator:
|
||||||
"""
|
"""
|
||||||
@@ -145,19 +142,18 @@ def validate_sender_authorization(session, mail_from: str) -> tuple[bool, str]:
|
|||||||
|
|
||||||
peer_ip = session.peer[0]
|
peer_ip = session.peer[0]
|
||||||
|
|
||||||
# Check user authentication
|
# Check sender authentication
|
||||||
if hasattr(session, 'authenticated_user') and session.authenticated_user:
|
if hasattr(session, 'authenticated_sender') and session.authenticated_sender:
|
||||||
user = session.authenticated_user
|
sender = session.authenticated_sender
|
||||||
|
if sender.can_send_as(mail_from):
|
||||||
if user.can_send_as(mail_from):
|
logger.info(f"Sender {sender.email} authorized to send as {mail_from}")
|
||||||
logger.info(f"User {user.email} authorized to send as {mail_from}")
|
return True, f"Sender authorized to send as {mail_from}"
|
||||||
return True, f"User authorized to send as {mail_from}"
|
|
||||||
else:
|
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)
|
logger.warning(message)
|
||||||
log_auth_attempt(
|
log_auth_attempt(
|
||||||
auth_type='sender_validation',
|
auth_type='sender_validation',
|
||||||
identifier=f"{user.email} -> {mail_from}",
|
identifier=f"{sender.email} -> {mail_from}",
|
||||||
ip_address=peer_ip,
|
ip_address=peer_ip,
|
||||||
success=False,
|
success=False,
|
||||||
message=message
|
message=message
|
||||||
@@ -172,6 +168,7 @@ def validate_sender_authorization(session, mail_from: str) -> tuple[bool, str]:
|
|||||||
# Store IP auth info in session
|
# Store IP auth info in session
|
||||||
session.auth_type = 'ip'
|
session.auth_type = 'ip'
|
||||||
session.authorized_domain = from_domain
|
session.authorized_domain = from_domain
|
||||||
|
session.username = f"IP:{peer_ip}" # Store IP as username for IP authentication
|
||||||
|
|
||||||
log_auth_attempt(
|
log_auth_attempt(
|
||||||
auth_type='ip',
|
auth_type='ip',
|
||||||
@@ -205,8 +202,8 @@ def get_authenticated_domain_id(session) -> int:
|
|||||||
Returns:
|
Returns:
|
||||||
Domain ID or None if not authenticated
|
Domain ID or None if not authenticated
|
||||||
"""
|
"""
|
||||||
if hasattr(session, 'authenticated_user') and session.authenticated_user:
|
if hasattr(session, 'authenticated_sender') and session.authenticated_sender:
|
||||||
return session.authenticated_user.domain_id
|
return session.authenticated_sender.domain_id
|
||||||
|
|
||||||
if hasattr(session, 'authorized_domain') and session.authorized_domain:
|
if hasattr(session, 'authorized_domain') and session.authorized_domain:
|
||||||
domain = get_domain_by_name(session.authorized_domain)
|
domain = get_domain_by_name(session.authorized_domain)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from cryptography.hazmat.primitives.asymmetric import rsa
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from email_server.models import Session, Domain, DKIMKey, CustomHeader
|
from email_server.models import Session, Domain, DKIMKey, CustomHeader
|
||||||
from email_server.settings_loader import load_settings
|
from email_server.settings_loader import load_settings
|
||||||
from email_server.tool_box import get_logger
|
from email_server.tool_box import get_logger, get_current_time
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ class DKIMManager:
|
|||||||
existing_active_keys = session.query(DKIMKey).filter_by(domain_id=domain.id, is_active=True).all()
|
existing_active_keys = session.query(DKIMKey).filter_by(domain_id=domain.id, is_active=True).all()
|
||||||
for existing_key in existing_active_keys:
|
for existing_key in existing_active_keys:
|
||||||
existing_key.is_active = False
|
existing_key.is_active = False
|
||||||
existing_key.replaced_at = datetime.now()
|
existing_key.replaced_at = get_current_time()
|
||||||
logger.debug(f"Marked DKIM key as replaced for domain {domain_name} selector {existing_key.selector}")
|
logger.debug(f"Marked DKIM key as replaced for domain {domain_name} selector {existing_key.selector}")
|
||||||
|
|
||||||
# Check if we're reusing an existing selector - if so, reactivate instead of creating new
|
# Check if we're reusing an existing selector - if so, reactivate instead of creating new
|
||||||
@@ -75,7 +75,7 @@ class DKIMManager:
|
|||||||
).all()
|
).all()
|
||||||
for key in other_active_keys:
|
for key in other_active_keys:
|
||||||
key.is_active = False
|
key.is_active = False
|
||||||
key.replaced_at = datetime.now()
|
key.replaced_at = get_current_time()
|
||||||
logger.debug(f"Deactivated other active DKIM key for domain {domain_name} selector {key.selector}")
|
logger.debug(f"Deactivated other active DKIM key for domain {domain_name} selector {key.selector}")
|
||||||
# Reactivate existing key with same selector, clear replaced_at timestamp
|
# Reactivate existing key with same selector, clear replaced_at timestamp
|
||||||
existing_key_with_selector.is_active = True
|
existing_key_with_selector.is_active = True
|
||||||
@@ -114,7 +114,7 @@ class DKIMManager:
|
|||||||
selector=use_selector,
|
selector=use_selector,
|
||||||
private_key=private_pem,
|
private_key=private_pem,
|
||||||
public_key=public_pem,
|
public_key=public_pem,
|
||||||
created_at=datetime.now(),
|
created_at=get_current_time(),
|
||||||
is_active=True
|
is_active=True
|
||||||
)
|
)
|
||||||
session.add(dkim_key)
|
session.add(dkim_key)
|
||||||
|
|||||||
+319
-129
@@ -2,156 +2,346 @@
|
|||||||
Email relay functionality for the SMTP server.
|
Email relay functionality for the SMTP server.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import dns.resolver
|
import dns.resolver
|
||||||
import smtplib
|
from email_server.models import Session, EmailLog, EmailRecipientLog
|
||||||
import ssl
|
|
||||||
from datetime import datetime
|
|
||||||
from email_server.models import Session, EmailLog
|
|
||||||
from email_server.settings_loader import load_settings
|
from email_server.settings_loader import load_settings
|
||||||
from email_server.tool_box import get_logger
|
from email_server.tool_box import get_logger, get_current_time
|
||||||
|
import aiosmtplib
|
||||||
|
|
||||||
logger = get_logger()
|
logger = get_logger()
|
||||||
|
|
||||||
|
settings = load_settings()
|
||||||
|
_relay_tls_timeout = settings['Server'].get('relay_timeout', 30)
|
||||||
|
|
||||||
|
port = 25 # Default MX SMTP port for relaying emails
|
||||||
|
|
||||||
class EmailRelay:
|
class EmailRelay:
|
||||||
"""Handles relaying emails to recipient mail servers."""
|
"""Handles relaying emails to recipient mail servers."""
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.timeout = 30 # Increased timeout for TLS negotiations
|
|
||||||
# Get the configured hostname for HELO/EHLO identification
|
|
||||||
settings = load_settings()
|
|
||||||
self.hostname = settings['Server'].get('helo_hostname',
|
|
||||||
settings['Server'].get('hostname', 'localhost'))
|
|
||||||
logger.debug(f"EmailRelay initialized with hostname: {self.hostname}")
|
|
||||||
|
|
||||||
def relay_email(self, mail_from, rcpt_tos, content):
|
|
||||||
"""Relay email to recipient's mail server with opportunistic TLS."""
|
|
||||||
try:
|
|
||||||
for rcpt in rcpt_tos:
|
|
||||||
domain = rcpt.split('@')[1]
|
|
||||||
|
|
||||||
# Resolve MX record for the domain
|
|
||||||
try:
|
|
||||||
mx_records = dns.resolver.resolve(domain, 'MX')
|
|
||||||
# Sort by priority (lower number = higher priority)
|
|
||||||
mx_records = sorted(mx_records, key=lambda x: x.preference)
|
|
||||||
mx_host = mx_records[0].exchange.to_text().rstrip('.')
|
|
||||||
logger.debug(f'Found MX record for {domain}: {mx_host}')
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f'Failed to resolve MX for {domain}: {e}')
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Try to relay with opportunistic TLS
|
def __init__(self):
|
||||||
if not self._relay_with_opportunistic_tls(mail_from, rcpt, content, mx_host):
|
|
||||||
return False
|
self.timeout = _relay_tls_timeout # Increased timeout for TLS negotiations
|
||||||
|
# Get the configured hostname for HELO/EHLO identification
|
||||||
return True
|
self.hostname = settings['Server'].get('helo_hostname', settings['Server'].get('hostname', 'localhost'))
|
||||||
except Exception as e:
|
logger.debug(f"EmailRelay initialized with hostname: {self.hostname}")
|
||||||
logger.error(f'General relay error: {e}')
|
|
||||||
return False
|
def _modify_headers_for_recipients(self, content, to_addresses, cc_addresses=None):
|
||||||
|
"""Modify email headers to set To and Cc fields, preserving original structure for DKIM.
|
||||||
def _relay_with_opportunistic_tls(self, mail_from, rcpt, content, mx_host):
|
|
||||||
"""Relay email with opportunistic TLS (like Gmail does)."""
|
Args:
|
||||||
try:
|
content: Raw email content
|
||||||
# First, try with STARTTLS (encrypted)
|
to_addresses: List of TO recipients
|
||||||
|
cc_addresses: List of CC recipients (optional)
|
||||||
|
"""
|
||||||
|
lines = content.splitlines()
|
||||||
|
new_headers = []
|
||||||
|
body_start = 0
|
||||||
|
has_to = False
|
||||||
|
has_cc = False
|
||||||
|
|
||||||
|
# First pass: find header/body boundary and examine existing headers
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if line.strip() == '':
|
||||||
|
body_start = i
|
||||||
|
break
|
||||||
|
# Skip BCC headers but preserve TO and CC
|
||||||
|
if line.lower().startswith('bcc:'):
|
||||||
|
continue
|
||||||
|
# Track if we have TO/CC headers
|
||||||
|
if line.lower().startswith('to:'):
|
||||||
|
has_to = True
|
||||||
|
elif line.lower().startswith('cc:'):
|
||||||
|
has_cc = True
|
||||||
|
new_headers.append(line)
|
||||||
|
|
||||||
|
# Only add headers if they don't exist
|
||||||
|
if not has_to and to_addresses:
|
||||||
|
new_headers.append(f"To: {', '.join(to_addresses)}")
|
||||||
|
if not has_cc and cc_addresses:
|
||||||
|
new_headers.append(f"Cc: {', '.join(cc_addresses)}")
|
||||||
|
|
||||||
|
# Reconstruct the message
|
||||||
|
body = '\n'.join(lines[body_start:]) if body_start < len(lines) else ''
|
||||||
|
return '\r\n'.join(new_headers) + '\r\n\r\n' + body
|
||||||
|
|
||||||
|
def _prepare_email_for_recipient(self, content: str, bcc_recipient: str = None) -> str:
|
||||||
|
"""Prepare a copy of the email for a specific recipient without modifying original content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: The original signed email content
|
||||||
|
bcc_recipient: If specified, prepare content for this BCC recipient
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Email content ready for the specific recipient
|
||||||
|
"""
|
||||||
|
lines = content.splitlines()
|
||||||
|
new_lines = []
|
||||||
|
headers_done = False
|
||||||
|
empty_line_added = False
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
if not headers_done:
|
||||||
|
if line.strip() == '':
|
||||||
|
headers_done = True
|
||||||
|
empty_line_added = True
|
||||||
|
new_lines.append(line) # Keep the empty line separator
|
||||||
|
# Skip BCC headers
|
||||||
|
elif not line.lower().startswith('bcc:'):
|
||||||
|
new_lines.append(line)
|
||||||
|
else:
|
||||||
|
new_lines.append(line)
|
||||||
|
|
||||||
|
# Ensure there's a blank line between headers and body if not already present
|
||||||
|
if not empty_line_added:
|
||||||
|
new_lines.append('')
|
||||||
|
|
||||||
|
return '\r\n'.join(new_lines)
|
||||||
|
|
||||||
|
async def relay_email_async(
|
||||||
|
self,
|
||||||
|
mail_from: str,
|
||||||
|
rcpt_tos: list[str],
|
||||||
|
content: str,
|
||||||
|
username: str = None,
|
||||||
|
cc_addresses: list[str] = None,
|
||||||
|
bcc_addresses: list[str] = None,
|
||||||
|
recipient_types: list[str] = None
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Relay email to recipients' mail servers asynchronously with encryption.
|
||||||
|
Preserves DKIM signatures by not modifying the signed content."""
|
||||||
|
results = []
|
||||||
|
recipient_type_map = {}
|
||||||
|
if recipient_types and len(recipient_types) == len(rcpt_tos):
|
||||||
|
for addr, rtype in zip(rcpt_tos, recipient_types):
|
||||||
|
recipient_type_map[addr] = rtype
|
||||||
|
else:
|
||||||
|
for addr in rcpt_tos:
|
||||||
|
recipient_type_map[addr] = 'to'
|
||||||
|
|
||||||
|
# Separate visible recipients (TO/CC) and BCC recipients
|
||||||
|
visible_recipients = []
|
||||||
|
bcc_list = []
|
||||||
|
|
||||||
|
for rcpt in rcpt_tos:
|
||||||
|
if recipient_type_map.get(rcpt) in ['to', 'cc']:
|
||||||
|
visible_recipients.append(rcpt)
|
||||||
|
elif recipient_type_map.get(rcpt) == 'bcc':
|
||||||
|
bcc_list.append(rcpt)
|
||||||
|
|
||||||
|
# Group recipients by domain for efficient delivery
|
||||||
|
domain_groups = {}
|
||||||
|
for rcpt in visible_recipients:
|
||||||
|
domain = rcpt.split('@')[1].lower()
|
||||||
|
rtype = recipient_type_map.get(rcpt, 'to')
|
||||||
|
if domain not in domain_groups:
|
||||||
|
domain_groups[domain] = {'to': [], 'cc': [], 'bcc': []}
|
||||||
|
domain_groups[domain][rtype].append(rcpt)
|
||||||
|
|
||||||
|
# Handle TO/CC recipients - use original signed content
|
||||||
|
for domain, recipients in domain_groups.items():
|
||||||
|
to_recipients = recipients['to']
|
||||||
|
cc_recipients = recipients['cc']
|
||||||
|
if not to_recipients and not cc_recipients:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Prepare content for TO/CC recipients without modifying headers
|
||||||
|
prepared_content = self._prepare_email_for_recipient(content)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with smtplib.SMTP(mx_host, 25, timeout=self.timeout) as relay_server:
|
mx_records = dns.resolver.resolve(domain, 'MX')
|
||||||
relay_server.set_debuglevel(1)
|
mx_records = sorted(mx_records, key=lambda x: x.preference)
|
||||||
|
mx_hosts = [mx.exchange.to_text().rstrip('.') for mx in mx_records]
|
||||||
# Try to enable TLS if the server supports it
|
logger.debug(f'Found MX records for {domain}: {mx_hosts}')
|
||||||
try:
|
|
||||||
# Check if server supports STARTTLS - use proper hostname for EHLO
|
|
||||||
logger.debug(f'Sending EHLO {self.hostname} to {mx_host}')
|
|
||||||
relay_server.ehlo(self.hostname)
|
|
||||||
if relay_server.has_extn('starttls'):
|
|
||||||
logger.debug(f'Starting TLS connection to {mx_host}')
|
|
||||||
context = ssl.create_default_context()
|
|
||||||
# Allow self-signed certificates for mail servers
|
|
||||||
context.check_hostname = False
|
|
||||||
context.verify_mode = ssl.CERT_NONE
|
|
||||||
relay_server.starttls(context=context)
|
|
||||||
logger.debug(f'Sending EHLO {self.hostname} again after STARTTLS to {mx_host}')
|
|
||||||
relay_server.ehlo(self.hostname) # Say hello again after STARTTLS with proper hostname
|
|
||||||
logger.debug(f'TLS connection established to {mx_host}')
|
|
||||||
else:
|
|
||||||
logger.warning(f'Server {mx_host} does not support STARTTLS, using plain text')
|
|
||||||
except Exception as tls_e:
|
|
||||||
logger.warning(f'STARTTLS failed with {mx_host}, continuing with plain text: {tls_e}')
|
|
||||||
|
|
||||||
# Send the email
|
|
||||||
relay_server.sendmail(mail_from, rcpt, content)
|
|
||||||
logger.debug(f'Successfully relayed email to {rcpt} via {mx_host}')
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Failed to relay email to {rcpt} via {mx_host}: {e}')
|
logger.error(f'Failed to resolve MX for {domain}: {e}')
|
||||||
|
for rcpt in to_recipients + cc_recipients:
|
||||||
# Fallback: try alternative MX records if available
|
results.append({
|
||||||
|
'recipient': rcpt,
|
||||||
|
'status': 'failed',
|
||||||
|
'error_code': 'MX',
|
||||||
|
'error_message': str(e),
|
||||||
|
'server_response': None,
|
||||||
|
'recipient_type': recipient_type_map.get(rcpt, 'to')
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
delivered = False
|
||||||
|
last_error = None
|
||||||
|
for mx_host in mx_hosts:
|
||||||
try:
|
try:
|
||||||
domain = rcpt.split('@')[1]
|
smtp = aiosmtplib.SMTP(hostname=mx_host, port=port, timeout=self.timeout, local_hostname=self.hostname)
|
||||||
mx_records = dns.resolver.resolve(domain, 'MX')
|
await smtp.connect()
|
||||||
mx_records = sorted(mx_records, key=lambda x: x.preference)
|
ext = getattr(smtp, 'extensions', None)
|
||||||
|
if ext is None:
|
||||||
# Try other MX records
|
ext = getattr(smtp, 'esmtp_extensions', None)
|
||||||
for mx_record in mx_records[1:3]: # Try up to 2 backup MX records
|
if ext is None:
|
||||||
backup_mx = mx_record.exchange.to_text().rstrip('.')
|
logger.error(f"SMTP object has no 'extensions' or 'esmtp_extensions'. Available attributes: {dir(smtp)}")
|
||||||
logger.debug(f'Trying backup MX record: {backup_mx}')
|
ext = {}
|
||||||
|
if 'starttls' in ext:
|
||||||
try:
|
logger.debug(f'STARTTLS supported by {mx_host}:{port}, upgrading to TLS')
|
||||||
with smtplib.SMTP(backup_mx, 25, timeout=self.timeout) as backup_server:
|
await smtp.starttls()
|
||||||
backup_server.set_debuglevel(1)
|
else:
|
||||||
|
logger.warning(f'STARTTLS not supported by {mx_host}:{port}, sending in plain text!')
|
||||||
# Try TLS with backup server too
|
response = await smtp.sendmail(mail_from, to_recipients + cc_recipients, prepared_content)
|
||||||
try:
|
logger.debug(f'Successfully relayed email to {to_recipients + cc_recipients} via {mx_host}:{port}')
|
||||||
logger.debug(f'Sending EHLO {self.hostname} to backup {backup_mx}')
|
for rcpt in to_recipients + cc_recipients:
|
||||||
backup_server.ehlo(self.hostname)
|
results.append({
|
||||||
if backup_server.has_extn('starttls'):
|
'recipient': rcpt,
|
||||||
context = ssl.create_default_context()
|
'status': 'success',
|
||||||
context.check_hostname = False
|
'error_code': None,
|
||||||
context.verify_mode = ssl.CERT_NONE
|
'error_message': None,
|
||||||
backup_server.starttls(context=context)
|
'server_response': str(response),
|
||||||
logger.debug(f'Sending EHLO {self.hostname} again after STARTTLS to backup {backup_mx}')
|
'recipient_type': recipient_type_map.get(rcpt, 'to')
|
||||||
backup_server.ehlo(self.hostname)
|
})
|
||||||
logger.debug(f'TLS connection established to backup {backup_mx}')
|
await smtp.quit()
|
||||||
except Exception:
|
delivered = True
|
||||||
logger.warning(f'STARTTLS failed with backup {backup_mx}, using plain text')
|
break
|
||||||
|
except Exception as e:
|
||||||
backup_server.sendmail(mail_from, rcpt, content)
|
logger.error(f'Failed to relay email to {to_recipients + cc_recipients} via {mx_host}:{port}: {e}')
|
||||||
logger.debug(f'Successfully relayed email to {rcpt} via backup {backup_mx}')
|
last_error = {
|
||||||
return True
|
'status': 'failed',
|
||||||
except Exception as backup_e:
|
'error_code': 'RELAY',
|
||||||
logger.warning(f'Backup MX {backup_mx} also failed: {backup_e}')
|
'error_message': str(e),
|
||||||
continue
|
'server_response': None
|
||||||
|
}
|
||||||
except Exception as fallback_e:
|
continue
|
||||||
logger.error(f'All MX records failed for {rcpt}: {fallback_e}')
|
|
||||||
|
if not delivered and last_error:
|
||||||
|
for rcpt in to_recipients + cc_recipients:
|
||||||
|
results.append({
|
||||||
|
'recipient': rcpt,
|
||||||
|
'status': last_error['status'],
|
||||||
|
'error_code': last_error['error_code'],
|
||||||
|
'error_message': last_error['error_message'],
|
||||||
|
'server_response': last_error['server_response'],
|
||||||
|
'recipient_type': recipient_type_map.get(rcpt, 'to')
|
||||||
|
})
|
||||||
|
|
||||||
|
# Handle BCC recipients - each gets their own copy with original headers
|
||||||
|
for bcc in bcc_list:
|
||||||
|
domain = bcc.split('@')[1].lower()
|
||||||
|
# Prepare content for BCC recipient - remove BCC headers but keep everything else
|
||||||
|
prepared_content = self._prepare_email_for_recipient(content, bcc)
|
||||||
|
|
||||||
|
try:
|
||||||
|
mx_records = dns.resolver.resolve(domain, 'MX')
|
||||||
|
mx_records = sorted(mx_records, key=lambda x: x.preference)
|
||||||
|
mx_hosts = [mx.exchange.to_text().rstrip('.') for mx in mx_records]
|
||||||
|
logger.debug(f'Found MX records for {domain}: {mx_hosts}')
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Failed to resolve MX for {domain}: {e}')
|
||||||
|
results.append({
|
||||||
|
'recipient': bcc,
|
||||||
|
'status': 'failed',
|
||||||
|
'error_code': 'MX',
|
||||||
|
'error_message': str(e),
|
||||||
|
'server_response': None,
|
||||||
|
'recipient_type': 'bcc'
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
delivered = False
|
||||||
|
last_error = None
|
||||||
|
for mx_host in mx_hosts:
|
||||||
|
try:
|
||||||
|
smtp = aiosmtplib.SMTP(hostname=mx_host, port=port, timeout=self.timeout, local_hostname=self.hostname)
|
||||||
|
await smtp.connect()
|
||||||
|
ext = getattr(smtp, 'extensions', None)
|
||||||
|
if ext is None:
|
||||||
|
ext = getattr(smtp, 'esmtp_extensions', None)
|
||||||
|
if ext is None:
|
||||||
|
logger.error(f"SMTP object has no 'extensions' or 'esmtp_extensions'. Available attributes: {dir(smtp)}")
|
||||||
|
ext = {}
|
||||||
|
if 'starttls' in ext:
|
||||||
|
logger.debug(f'STARTTLS supported by {mx_host}:{port} for BCC, upgrading to TLS')
|
||||||
|
await smtp.starttls()
|
||||||
|
else:
|
||||||
|
logger.warning(f'STARTTLS not supported by {mx_host}:{port} for BCC, sending in plain text!')
|
||||||
|
response = await smtp.sendmail(mail_from, [bcc], prepared_content)
|
||||||
|
logger.debug(f'Successfully relayed BCC email to {bcc} via {mx_host}:{port}')
|
||||||
|
results.append({
|
||||||
|
'recipient': bcc,
|
||||||
|
'status': 'success',
|
||||||
|
'error_code': None,
|
||||||
|
'error_message': None,
|
||||||
|
'server_response': str(response),
|
||||||
|
'recipient_type': 'bcc'
|
||||||
|
})
|
||||||
|
await smtp.quit()
|
||||||
|
delivered = True
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Failed to relay BCC email to {bcc} via {mx_host}:{port}: {e}')
|
||||||
|
last_error = {
|
||||||
|
'status': 'failed',
|
||||||
|
'error_code': 'RELAY',
|
||||||
|
'error_message': str(e),
|
||||||
|
'server_response': None
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not delivered and last_error:
|
||||||
|
results.append({
|
||||||
|
'recipient': bcc,
|
||||||
|
'status': last_error['status'],
|
||||||
|
'error_code': last_error['error_code'],
|
||||||
|
'error_message': last_error['error_message'],
|
||||||
|
'server_response': last_error['server_response'],
|
||||||
|
'recipient_type': 'bcc'
|
||||||
|
})
|
||||||
|
|
||||||
return False
|
return results
|
||||||
|
|
||||||
except Exception as e:
|
def relay_email(self, *args, **kwargs):
|
||||||
logger.error(f'Unexpected error in TLS relay: {e}')
|
"""Synchronous wrapper for relay_email_async for compatibility."""
|
||||||
return False
|
return asyncio.run(self.relay_email_async(*args, **kwargs))
|
||||||
|
|
||||||
def log_email(self, message_id, peer, mail_from, rcpt_tos, content, status, dkim_signed=False):
|
def log_email(self, message_id, peer, mail_from, to_address, cc_addresses, bcc_addresses, subject, email_headers, message_body, status, dkim_signed=False, username=None, recipient_results=None):
|
||||||
"""Log email activity to database."""
|
"""Log email activity to database, including per-recipient results."""
|
||||||
session_db = Session()
|
session_db = Session()
|
||||||
try:
|
try:
|
||||||
# Convert content to string if it's bytes
|
# Determine status: relayed, partial, failed
|
||||||
if isinstance(content, bytes):
|
delivered = [r for r in (recipient_results or []) if r['status'] == 'success']
|
||||||
content_str = content.decode('utf-8', errors='replace')
|
failed = [r for r in (recipient_results or []) if r['status'] != 'success']
|
||||||
|
if delivered and failed:
|
||||||
|
overall_status = 'partial'
|
||||||
|
elif delivered:
|
||||||
|
overall_status = 'relayed'
|
||||||
else:
|
else:
|
||||||
content_str = content
|
overall_status = 'failed'
|
||||||
|
|
||||||
email_log = EmailLog(
|
email_log = EmailLog(
|
||||||
message_id=message_id,
|
message_id=message_id,
|
||||||
timestamp=datetime.now(),
|
timestamp=get_current_time(),
|
||||||
peer=str(peer),
|
peer_ip=peer,
|
||||||
mail_from=mail_from,
|
mail_from=mail_from,
|
||||||
rcpt_tos=', '.join(rcpt_tos),
|
to_address=to_address or '',
|
||||||
content=content_str,
|
cc_addresses=cc_addresses or '',
|
||||||
status=status,
|
bcc_addresses=bcc_addresses or '',
|
||||||
dkim_signed=dkim_signed
|
subject=subject,
|
||||||
|
email_headers=email_headers,
|
||||||
|
message_body=message_body,
|
||||||
|
status=overall_status,
|
||||||
|
dkim_signed=dkim_signed,
|
||||||
|
username=username
|
||||||
)
|
)
|
||||||
session_db.add(email_log)
|
session_db.add(email_log)
|
||||||
|
session_db.flush()
|
||||||
|
|
||||||
|
# Log per-recipient results
|
||||||
|
if recipient_results:
|
||||||
|
for r in recipient_results:
|
||||||
|
recipient_log = EmailRecipientLog(
|
||||||
|
email_log_id=email_log.id,
|
||||||
|
recipient=r['recipient'],
|
||||||
|
recipient_type=r.get('recipient_type', 'to'),
|
||||||
|
status=r['status'],
|
||||||
|
error_code=r.get('error_code'),
|
||||||
|
error_message=r.get('error_message'),
|
||||||
|
server_response=r.get('server_response')
|
||||||
|
)
|
||||||
|
session_db.add(recipient_log)
|
||||||
session_db.commit()
|
session_db.commit()
|
||||||
logger.debug(f'Logged email: {message_id}')
|
logger.debug(f'Logged email: {message_id}')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
+67
-33
@@ -38,7 +38,7 @@ class Domain(Base):
|
|||||||
created_at = Column(DateTime, default=func.now())
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
# Add relationships with proper foreign key references
|
# 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")
|
dkim_keys = relationship("DKIMKey", backref="domain", lazy="joined")
|
||||||
whitelisted_ips = relationship("WhitelistedIP", backref="domain", lazy="joined")
|
whitelisted_ips = relationship("WhitelistedIP", backref="domain", lazy="joined")
|
||||||
custom_headers = relationship("CustomHeader", backref="domain", lazy="joined")
|
custom_headers = relationship("CustomHeader", backref="domain", lazy="joined")
|
||||||
@@ -46,15 +46,15 @@ class Domain(Base):
|
|||||||
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})>"
|
||||||
|
|
||||||
class User(Base):
|
class Sender(Base):
|
||||||
"""
|
"""
|
||||||
User model with enhanced authentication controls.
|
Sender model with enhanced authentication controls.
|
||||||
|
|
||||||
Security features:
|
Security features:
|
||||||
- can_send_as_domain: If True, user can send as any email from their domain
|
- can_send_as_domain: If True, sender can send as any email from their domain
|
||||||
- If False, user can only send as their own email address
|
- If False, sender can only send as their own email address
|
||||||
"""
|
"""
|
||||||
__tablename__ = 'esrv_users'
|
__tablename__ = 'esrv_senders'
|
||||||
|
|
||||||
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)
|
||||||
@@ -63,31 +63,29 @@ class User(Base):
|
|||||||
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())
|
||||||
|
store_message_content = Column(Boolean, default=False) # Store message body/attachments
|
||||||
|
|
||||||
def can_send_as(self, from_address: str) -> bool:
|
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:
|
Args:
|
||||||
from_address: The email address the user wants to send from
|
from_address: The email address the sender wants to send from
|
||||||
|
|
||||||
Returns:
|
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():
|
if from_address.lower() == self.email.lower():
|
||||||
return True
|
return True
|
||||||
|
# If sender has domain privileges, check if from_address is from same domain
|
||||||
# If user has domain privileges, check if from_address is from same domain
|
|
||||||
if self.can_send_as_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 ''
|
from_domain = from_address.split('@')[1].lower() if '@' in from_address else ''
|
||||||
return user_domain == from_domain
|
return sender_domain == from_domain
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def __repr__(self):
|
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):
|
class WhitelistedIP(Base):
|
||||||
"""
|
"""
|
||||||
@@ -103,6 +101,7 @@ class WhitelistedIP(Base):
|
|||||||
domain_id = Column(Integer, ForeignKey('esrv_domains.id'), 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())
|
||||||
|
store_message_content = Column(Boolean, default=False) # Store message body/attachments
|
||||||
|
|
||||||
def can_send_for_domain(self, domain_name: str) -> bool:
|
def can_send_for_domain(self, domain_name: str) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -136,26 +135,44 @@ class EmailLog(Base):
|
|||||||
__tablename__ = 'esrv_email_logs'
|
__tablename__ = 'esrv_email_logs'
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
|
|
||||||
# Legacy columns (from original schema)
|
|
||||||
message_id = Column(String, unique=True, nullable=False)
|
message_id = Column(String, unique=True, nullable=False)
|
||||||
timestamp = Column(DateTime, nullable=False)
|
timestamp = Column(DateTime, nullable=False)
|
||||||
peer = Column(String, nullable=False)
|
peer_ip = Column(String, nullable=False) # Store only IP address
|
||||||
mail_from = Column(String, nullable=False)
|
mail_from = Column(String, nullable=False)
|
||||||
rcpt_tos = Column(String, nullable=False)
|
to_address = Column(String, nullable=False, server_default='')
|
||||||
content = Column(Text, nullable=False)
|
cc_addresses = Column(String, nullable=True, server_default='') # Comma-separated CC
|
||||||
|
bcc_addresses = Column(String, nullable=True, server_default='') # Comma-separated BCC
|
||||||
|
subject = Column(Text, nullable=True)
|
||||||
|
email_headers = Column(Text, nullable=False) # Store only email headers
|
||||||
|
message_body = Column(Text, nullable=True) # Store actual message content
|
||||||
status = Column(String, nullable=False)
|
status = Column(String, nullable=False)
|
||||||
dkim_signed = Column(Boolean, default=False)
|
dkim_signed = Column(Boolean, default=False)
|
||||||
|
username = Column(String, nullable=True) # Authenticated username
|
||||||
# New columns (added later)
|
|
||||||
from_address = Column(String, nullable=False, server_default='unknown')
|
|
||||||
to_address = Column(String, nullable=False, server_default='unknown')
|
|
||||||
subject = Column(Text, nullable=True)
|
|
||||||
message = Column(Text, nullable=True)
|
|
||||||
created_at = Column(DateTime, default=func.now())
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
recipients = relationship("EmailRecipientLog", back_populates="email_log", cascade="all, delete-orphan")
|
||||||
|
attachments = relationship("EmailAttachment", back_populates="email_log", cascade="all, delete-orphan")
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<EmailLog(id={self.id}, message_id='{self.message_id}', from='{self.mail_from}', to='{self.rcpt_tos}', status='{self.status}')>"
|
return f"<EmailLog(id={self.id}, message_id='{self.message_id}', from='{self.mail_from}', to='{self.to_address}', status='{self.status}')>"
|
||||||
|
|
||||||
|
class EmailRecipientLog(Base):
|
||||||
|
"""Log for each recipient of an email, including status and error details."""
|
||||||
|
__tablename__ = 'esrv_email_recipient_logs'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
email_log_id = Column(Integer, ForeignKey('esrv_email_logs.id'), nullable=False)
|
||||||
|
recipient = Column(String, nullable=False)
|
||||||
|
recipient_type = Column(String, nullable=False) # 'to', 'cc', 'bcc'
|
||||||
|
status = Column(String, nullable=False) # 'success', 'failed', etc.
|
||||||
|
error_code = Column(String, nullable=True)
|
||||||
|
error_message = Column(Text, nullable=True)
|
||||||
|
server_response = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
email_log = relationship("EmailLog", back_populates="recipients")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<EmailRecipientLog(id={self.id}, recipient='{self.recipient}', type='{self.recipient_type}', status='{self.status}')>"
|
||||||
|
|
||||||
class AuthLog(Base):
|
class AuthLog(Base):
|
||||||
"""Authentication log model for security auditing."""
|
"""Authentication log model for security auditing."""
|
||||||
@@ -202,6 +219,23 @@ class CustomHeader(Base):
|
|||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<CustomHeader(id={self.id}, domain_id={self.domain_id}, header='{self.header_name}: {self.header_value}', active={self.is_active})>"
|
return f"<CustomHeader(id={self.id}, domain_id={self.domain_id}, header='{self.header_name}: {self.header_value}', active={self.is_active})>"
|
||||||
|
|
||||||
|
class EmailAttachment(Base):
|
||||||
|
"""Attachment metadata and file path, linked to EmailLog."""
|
||||||
|
__tablename__ = 'esrv_email_attachments'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
email_log_id = Column(Integer, ForeignKey('esrv_email_logs.id'), nullable=False)
|
||||||
|
filename = Column(String, nullable=False)
|
||||||
|
content_type = Column(String, nullable=True)
|
||||||
|
file_path = Column(String, nullable=False) # Path on disk
|
||||||
|
size = Column(Integer, nullable=True)
|
||||||
|
uploaded_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
email_log = relationship("EmailLog", back_populates="attachments")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<EmailAttachment(id={self.id}, filename='{self.filename}', file_path='{self.file_path}')>"
|
||||||
|
|
||||||
|
|
||||||
def create_tables():
|
def create_tables():
|
||||||
"""Create all database tables using ESRV schema."""
|
"""Create all database tables using ESRV schema."""
|
||||||
@@ -276,11 +310,11 @@ def log_email(from_address: str, to_address: str, subject: str,
|
|||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
def get_user_by_email(email: str):
|
def get_sender_by_email(email: str):
|
||||||
"""Get user by email address."""
|
"""Get sender by email address."""
|
||||||
session = Session()
|
session = Session()
|
||||||
try:
|
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:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
# dkim_manager.initialize_default_keys() # Removed: do not auto-generate DKIM keys for all domains
|
||||||
|
|
||||||
# Add test data if needed
|
# 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()
|
session = Session()
|
||||||
try:
|
try:
|
||||||
# Add example.com domain if not exists
|
# Add example.com domain if not exists
|
||||||
@@ -57,17 +57,17 @@ async def start_server(shutdown_event=None):
|
|||||||
session.commit()
|
session.commit()
|
||||||
logger.debug("Added example.com domain")
|
logger.debug("Added example.com domain")
|
||||||
|
|
||||||
# Add test user if not exists
|
# Add test sender if not exists
|
||||||
user = session.query(User).filter_by(email='test@example.com').first()
|
sender = session.query(Sender).filter_by(email='test@example.com').first()
|
||||||
if not user:
|
if not sender:
|
||||||
user = User(
|
sender = Sender(
|
||||||
email='test@example.com',
|
email='test@example.com',
|
||||||
password_hash=hash_password('testpass123'),
|
password_hash=hash_password('testpass123'),
|
||||||
domain_id=domain.id
|
domain_id=domain.id
|
||||||
)
|
)
|
||||||
session.add(user)
|
session.add(sender)
|
||||||
session.commit()
|
session.commit()
|
||||||
logger.debug("Added test user: test@example.com")
|
logger.debug("Added test sender: test@example.com")
|
||||||
|
|
||||||
# Add whitelisted IP if not exists
|
# Add whitelisted IP if not exists
|
||||||
whitelist = session.query(WhitelistedIP).filter_by(ip_address='127.0.0.1').first()
|
whitelist = session.query(WhitelistedIP).filter_by(ip_address='127.0.0.1').first()
|
||||||
@@ -117,7 +117,7 @@ async def start_server(shutdown_event=None):
|
|||||||
)
|
)
|
||||||
controller_tls.start()
|
controller_tls.start()
|
||||||
logger.debug(f' - Plain SMTP (IP whitelist): {BIND_IP}:{SMTP_PORT}')
|
logger.debug(f' - Plain SMTP (IP whitelist): {BIND_IP}:{SMTP_PORT}')
|
||||||
logger.debug(f' - STARTTLS SMTP (auth required): {BIND_IP}:{SMTP_TLS_PORT}')
|
logger.debug(f' - Direct TLS SMTP (SMTPS, auth required): {BIND_IP}:{SMTP_TLS_PORT}')
|
||||||
logger.debug('Management available via web interface at: http://localhost:5000/email')
|
logger.debug('Management available via web interface at: http://localhost:5000/email')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -15,3 +15,4 @@ from .ip_whitelist import *
|
|||||||
from .dkim import *
|
from .dkim import *
|
||||||
from .settings import *
|
from .settings import *
|
||||||
from .logs import *
|
from .logs import *
|
||||||
|
from .view_message import *
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ This module provides the main dashboard view and overview functionality.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import render_template
|
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, EmailRecipientLog
|
||||||
from email_server.tool_box import get_logger
|
from email_server.tool_box import get_logger
|
||||||
from .routes import email_bp
|
from .routes import email_bp
|
||||||
|
|
||||||
@@ -19,20 +19,23 @@ def dashboard():
|
|||||||
try:
|
try:
|
||||||
# Get counts
|
# Get counts
|
||||||
domain_count = session.query(Domain).filter_by(is_active=True).count()
|
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()
|
dkim_count = session.query(DKIMKey).filter_by(is_active=True).count()
|
||||||
|
|
||||||
# Get recent email logs
|
# Get recent email logs
|
||||||
recent_emails = session.query(EmailLog).order_by(EmailLog.created_at.desc()).limit(10).all()
|
recent_emails = session.query(EmailLog).order_by(EmailLog.created_at.desc()).limit(10).all()
|
||||||
|
# Get recipient logs for each recent email
|
||||||
|
recipient_logs_map = {email.id: session.query(EmailRecipientLog).filter_by(email_log_id=email.id).all() for email in recent_emails}
|
||||||
|
|
||||||
# Get recent auth logs
|
# Get recent auth logs
|
||||||
recent_auths = session.query(AuthLog).order_by(AuthLog.created_at.desc()).limit(10).all()
|
recent_auths = session.query(AuthLog).order_by(AuthLog.created_at.desc()).limit(10).all()
|
||||||
|
|
||||||
return render_template('dashboard.html',
|
return render_template('dashboard.html',
|
||||||
domain_count=domain_count,
|
domain_count=domain_count,
|
||||||
user_count=user_count,
|
sender_count=sender_count,
|
||||||
dkim_count=dkim_count,
|
dkim_count=dkim_count,
|
||||||
recent_emails=recent_emails,
|
recent_emails=recent_emails,
|
||||||
recent_auths=recent_auths)
|
recent_auths=recent_auths,
|
||||||
|
recipient_logs_map=recipient_logs_map)
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
@@ -9,12 +9,12 @@ This module provides DKIM key management functionality including:
|
|||||||
- DKIM DNS verification
|
- DKIM DNS verification
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import render_template, request, redirect, url_for, flash, jsonify
|
from flask import render_template, request, redirect, url_for, flash, jsonify, current_app
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import re
|
import re
|
||||||
from email_server.models import Session, Domain, DKIMKey
|
from email_server.models import Session, Domain, DKIMKey
|
||||||
from email_server.dkim_manager import DKIMManager
|
from email_server.dkim_manager import DKIMManager
|
||||||
from email_server.tool_box import get_logger
|
from email_server.tool_box import get_logger, get_current_time
|
||||||
from .utils import get_public_ip, check_dns_record, generate_spf_record
|
from .utils import get_public_ip, check_dns_record, generate_spf_record
|
||||||
from .routes import email_bp
|
from .routes import email_bp
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ def create_dkim():
|
|||||||
active_keys = session.query(DKIMKey).filter_by(domain_id=domain.id, is_active=True).all()
|
active_keys = session.query(DKIMKey).filter_by(domain_id=domain.id, is_active=True).all()
|
||||||
for key in active_keys:
|
for key in active_keys:
|
||||||
key.is_active = False
|
key.is_active = False
|
||||||
key.replaced_at = datetime.now()
|
key.replaced_at = get_current_time()
|
||||||
# Create new DKIM key
|
# Create new DKIM key
|
||||||
dkim_manager = DKIMManager()
|
dkim_manager = DKIMManager()
|
||||||
created = dkim_manager.generate_dkim_keypair(domain_name, selector=selector, force_new_key=True)
|
created = dkim_manager.generate_dkim_keypair(domain_name, selector=selector, force_new_key=True)
|
||||||
@@ -146,7 +146,7 @@ def regenerate_dkim(domain_id: int):
|
|||||||
# Mark existing keys as replaced
|
# Mark existing keys as replaced
|
||||||
for key in existing_keys:
|
for key in existing_keys:
|
||||||
key.is_active = False
|
key.is_active = False
|
||||||
key.replaced_at = datetime.now() # Mark when this key was replaced
|
key.replaced_at = get_current_time() # Mark when this key was replaced
|
||||||
|
|
||||||
# Generate new DKIM key preserving the existing selector
|
# Generate new DKIM key preserving the existing selector
|
||||||
dkim_manager = DKIMManager()
|
dkim_manager = DKIMManager()
|
||||||
@@ -331,7 +331,7 @@ def toggle_dkim(dkim_id: int):
|
|||||||
).all()
|
).all()
|
||||||
for key in other_active_keys:
|
for key in other_active_keys:
|
||||||
key.is_active = False
|
key.is_active = False
|
||||||
key.replaced_at = datetime.now()
|
key.replaced_at = get_current_time()
|
||||||
|
|
||||||
dkim_key.is_active = not old_status
|
dkim_key.is_active = not old_status
|
||||||
if dkim_key.is_active:
|
if dkim_key.is_active:
|
||||||
@@ -432,11 +432,22 @@ def check_spf_dns():
|
|||||||
spf_record = record
|
spf_record = record
|
||||||
break
|
break
|
||||||
|
|
||||||
|
spf_valid_for_server = False
|
||||||
|
spf_check_message = ''
|
||||||
|
public_ip = get_public_ip()
|
||||||
|
ip_mechanism = f'ip4:{public_ip}'
|
||||||
if spf_record:
|
if spf_record:
|
||||||
result['spf_record'] = 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'
|
result['message'] = 'SPF record found'
|
||||||
else:
|
else:
|
||||||
result['success'] = False
|
result['success'] = False
|
||||||
result['message'] = 'No SPF record found'
|
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)
|
return jsonify(result)
|
||||||
@@ -10,7 +10,7 @@ This module provides domain management functionality including:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import render_template, request, redirect, url_for, flash
|
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.dkim_manager import DKIMManager
|
||||||
from email_server.tool_box import get_logger
|
from email_server.tool_box import get_logger
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
@@ -188,12 +188,12 @@ def remove_domain(domain_id: int):
|
|||||||
domain_name = domain.domain_name
|
domain_name = domain.domain_name
|
||||||
|
|
||||||
# Count associated records
|
# 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()
|
ip_count = session.query(WhitelistedIP).filter_by(domain_id=domain_id).count()
|
||||||
dkim_count = session.query(DKIMKey).filter_by(domain_id=domain_id).count()
|
dkim_count = session.query(DKIMKey).filter_by(domain_id=domain_id).count()
|
||||||
|
|
||||||
# Delete associated records
|
# 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(WhitelistedIP).filter_by(domain_id=domain_id).delete()
|
||||||
session.query(DKIMKey).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()
|
session.query(CustomHeader).filter_by(domain_id=domain_id).delete()
|
||||||
@@ -202,7 +202,7 @@ def remove_domain(domain_id: int):
|
|||||||
session.delete(domain)
|
session.delete(domain)
|
||||||
session.commit()
|
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'))
|
return redirect(url_for('email.domains_list'))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ def add_ip():
|
|||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
ip_address = request.form.get('ip_address', '').strip()
|
ip_address = request.form.get('ip_address', '').strip()
|
||||||
domain_id = request.form.get('domain_id', type=int)
|
domain_id = request.form.get('domain_id', type=int)
|
||||||
|
store_message_content = bool(request.form.get('store_message_content'))
|
||||||
|
|
||||||
if not all([ip_address, domain_id]):
|
if not all([ip_address, domain_id]):
|
||||||
flash('All fields are required', 'error')
|
flash('All fields are required', 'error')
|
||||||
@@ -58,7 +59,8 @@ def add_ip():
|
|||||||
# Create whitelisted IP
|
# Create whitelisted IP
|
||||||
whitelist = WhitelistedIP(
|
whitelist = WhitelistedIP(
|
||||||
ip_address=ip_address,
|
ip_address=ip_address,
|
||||||
domain_id=domain_id
|
domain_id=domain_id,
|
||||||
|
store_message_content=store_message_content
|
||||||
)
|
)
|
||||||
session.add(whitelist)
|
session.add(whitelist)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -166,6 +168,7 @@ def edit_ip(ip_id: int):
|
|||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
ip_address = request.form.get('ip_address', '').strip()
|
ip_address = request.form.get('ip_address', '').strip()
|
||||||
domain_id = request.form.get('domain_id', type=int)
|
domain_id = request.form.get('domain_id', type=int)
|
||||||
|
store_message_content = bool(request.form.get('store_message_content'))
|
||||||
|
|
||||||
if not all([ip_address, domain_id]):
|
if not all([ip_address, domain_id]):
|
||||||
flash('All fields are required', 'error')
|
flash('All fields are required', 'error')
|
||||||
@@ -191,6 +194,7 @@ def edit_ip(ip_id: int):
|
|||||||
# Update IP record
|
# Update IP record
|
||||||
ip_record.ip_address = ip_address
|
ip_record.ip_address = ip_address
|
||||||
ip_record.domain_id = domain_id
|
ip_record.domain_id = domain_id
|
||||||
|
ip_record.store_message_content = store_message_content
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
flash(f'IP whitelist record updated', 'success')
|
flash(f'IP whitelist record updated', 'success')
|
||||||
|
|||||||
@@ -4,12 +4,11 @@ Logs blueprint for the SMTP server web UI.
|
|||||||
This module provides email and authentication log viewing functionality.
|
This module provides email and authentication log viewing functionality.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import render_template, request, jsonify
|
from flask import render_template, request, send_file, redirect, url_for, flash, Response
|
||||||
from email_server.models import Session, EmailLog, AuthLog, Domain
|
from email_server.models import Session, EmailLog, AuthLog, EmailRecipientLog, EmailAttachment
|
||||||
from email_server.tool_box import get_logger
|
from email_server.tool_box import get_logger
|
||||||
from sqlalchemy import desc
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from .routes import email_bp
|
from .routes import email_bp
|
||||||
|
import os
|
||||||
|
|
||||||
logger = get_logger()
|
logger = get_logger()
|
||||||
|
|
||||||
@@ -40,10 +39,15 @@ def logs():
|
|||||||
# Convert to unified format
|
# Convert to unified format
|
||||||
combined_logs = []
|
combined_logs = []
|
||||||
for log in email_logs:
|
for log in email_logs:
|
||||||
|
# Fetch recipient logs and attachments for each email log
|
||||||
|
recipient_logs = session.query(EmailRecipientLog).filter_by(email_log_id=log.id).all()
|
||||||
|
attachments = session.query(EmailAttachment).filter_by(email_log_id=log.id).all()
|
||||||
combined_logs.append({
|
combined_logs.append({
|
||||||
'type': 'email',
|
'type': 'email',
|
||||||
'timestamp': log.created_at,
|
'timestamp': log.created_at,
|
||||||
'data': log
|
'data': log,
|
||||||
|
'recipients': recipient_logs,
|
||||||
|
'attachments': attachments
|
||||||
})
|
})
|
||||||
for log in auth_logs:
|
for log in auth_logs:
|
||||||
combined_logs.append({
|
combined_logs.append({
|
||||||
@@ -69,12 +73,20 @@ def logs():
|
|||||||
|
|
||||||
has_next = offset + per_page < total
|
has_next = offset + per_page < total
|
||||||
has_prev = page > 1
|
has_prev = page > 1
|
||||||
|
# Fetch recipient logs and attachments for each email log if emails
|
||||||
|
recipient_logs_map = {}
|
||||||
|
attachments_map = {}
|
||||||
|
if filter_type == 'emails':
|
||||||
|
for log in logs:
|
||||||
|
recipient_logs_map[log.id] = session.query(EmailRecipientLog).filter_by(email_log_id=log.id).all()
|
||||||
|
attachments_map[log.id] = session.query(EmailAttachment).filter_by(email_log_id=log.id).all()
|
||||||
return render_template('logs.html',
|
return render_template('logs.html',
|
||||||
logs=logs,
|
logs=logs,
|
||||||
filter_type=filter_type,
|
filter_type=filter_type,
|
||||||
page=page,
|
page=page,
|
||||||
has_next=has_next,
|
has_next=has_next,
|
||||||
has_prev=has_prev)
|
has_prev=has_prev,
|
||||||
|
recipient_logs_map=recipient_logs_map,
|
||||||
|
attachments_map=attachments_map)
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
Main routes and blueprint definition for the SMTP server web UI.
|
Main routes and blueprint definition for the SMTP server web UI.
|
||||||
"""
|
"""
|
||||||
from flask import Blueprint, render_template
|
from flask import Blueprint, render_template, request, jsonify, current_app
|
||||||
from email_server.tool_box import get_logger
|
from email_server.models import Session, EmailLog, AuthLog
|
||||||
|
from email_server.tool_box import get_logger, get_current_time
|
||||||
|
from email_server.settings_loader import load_settings
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
|
||||||
# Create the main email blueprint
|
# Create the main email blueprint
|
||||||
@@ -15,14 +18,35 @@ email_bp = Blueprint('email', __name__,
|
|||||||
|
|
||||||
logger = get_logger()
|
logger = get_logger()
|
||||||
|
|
||||||
|
# Get timezone from settings
|
||||||
|
settings = load_settings()
|
||||||
|
timezone = pytz.timezone(settings['Server'].get('time_zone', 'UTC'))
|
||||||
|
|
||||||
|
@email_bp.app_template_filter('format_datetime')
|
||||||
|
def format_datetime(value, timezone=None):
|
||||||
|
"""Format datetime with the correct timezone from settings or argument."""
|
||||||
|
if value is None:
|
||||||
|
return ''
|
||||||
|
import pytz
|
||||||
|
if timezone is None:
|
||||||
|
settings = load_settings()
|
||||||
|
timezone = settings['Server'].get('time_zone', 'UTC')
|
||||||
|
tz = pytz.timezone(timezone)
|
||||||
|
if value.tzinfo is None:
|
||||||
|
value = pytz.UTC.localize(value)
|
||||||
|
local_dt = value.astimezone(tz)
|
||||||
|
return local_dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
from .view_message import * # Import view_message routes
|
||||||
|
|
||||||
# Error handlers
|
# Error handlers
|
||||||
@email_bp.errorhandler(404)
|
@email_bp.errorhandler(404)
|
||||||
def not_found(error):
|
def not_found(error):
|
||||||
"""Handle 404 errors."""
|
"""Handle 404 errors."""
|
||||||
return render_template('error.html',
|
return render_template('error.html',
|
||||||
error_code=404,
|
error_code=404,
|
||||||
error_message='Page not found',
|
error_message="Page not found",
|
||||||
current_time=datetime.now()), 404
|
current_time=get_current_time()), 404
|
||||||
|
|
||||||
@email_bp.errorhandler(500)
|
@email_bp.errorhandler(500)
|
||||||
def internal_error(error):
|
def internal_error(error):
|
||||||
@@ -30,5 +54,5 @@ def internal_error(error):
|
|||||||
logger.error(f"Internal error: {error}")
|
logger.error(f"Internal error: {error}")
|
||||||
return render_template('error.html',
|
return render_template('error.html',
|
||||||
error_code=500,
|
error_code=500,
|
||||||
error_message='Internal server error',
|
error_message=str(error),
|
||||||
current_time=datetime.now()), 500
|
current_time=get_current_time()), 500
|
||||||
@@ -10,27 +10,27 @@ This module provides sender management functionality including:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import render_template, request, redirect, url_for, flash
|
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
|
from email_server.tool_box import get_logger
|
||||||
import bcrypt
|
import bcrypt
|
||||||
from .routes import email_bp
|
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()
|
logger = get_logger()
|
||||||
|
|
||||||
@email_bp.route('/senders')
|
@email_bp.route('/senders')
|
||||||
def senders_list():
|
def senders_list():
|
||||||
"""List all users."""
|
"""List all senders."""
|
||||||
session = Session()
|
session = Session()
|
||||||
try:
|
try:
|
||||||
users = session.query(User, Domain).join(Domain, User.domain_id == Domain.id).order_by(User.email).all()
|
senders = session.query(Sender, Domain).join(Domain, Sender.domain_id == Domain.id).order_by(Sender.email).all()
|
||||||
return render_template('senders.html', users=users)
|
return render_template('senders.html', senders=senders)
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
@email_bp.route('/senders/add', methods=['GET', 'POST'])
|
@email_bp.route('/senders/add', methods=['GET', 'POST'])
|
||||||
def add_sender():
|
def add_sender():
|
||||||
"""Add new user."""
|
"""Add new sender."""
|
||||||
session = Session()
|
session = Session()
|
||||||
try:
|
try:
|
||||||
domains = session.query(Domain).filter_by(is_active=True).order_by(Domain.domain_name).all()
|
domains = session.query(Domain).filter_by(is_active=True).order_by(Domain.domain_name).all()
|
||||||
@@ -40,6 +40,7 @@ def add_sender():
|
|||||||
password = request.form.get('password', '').strip()
|
password = request.form.get('password', '').strip()
|
||||||
domain_id = request.form.get('domain_id', type=int)
|
domain_id = request.form.get('domain_id', type=int)
|
||||||
can_send_as_domain = request.form.get('can_send_as_domain') == 'on'
|
can_send_as_domain = request.form.get('can_send_as_domain') == 'on'
|
||||||
|
store_message_content = request.form.get('store_message_content') == 'on'
|
||||||
|
|
||||||
if not all([email, password, domain_id]):
|
if not all([email, password, domain_id]):
|
||||||
flash('All fields are required', 'error')
|
flash('All fields are required', 'error')
|
||||||
@@ -50,118 +51,119 @@ def add_sender():
|
|||||||
flash('Invalid email format', 'error')
|
flash('Invalid email format', 'error')
|
||||||
return redirect(url_for('email.add_sender'))
|
return redirect(url_for('email.add_sender'))
|
||||||
|
|
||||||
# Check if user already exists
|
# Check if sender already exists
|
||||||
existing = session.query(User).filter_by(email=email).first()
|
existing = session.query(Sender).filter_by(email=email).first()
|
||||||
if existing:
|
if existing:
|
||||||
flash(f'User {email} already exists', 'error')
|
flash(f'Sender {email} already exists', 'error')
|
||||||
return redirect(url_for('email.senders_list'))
|
return redirect(url_for('email.senders_list'))
|
||||||
|
|
||||||
# Create user
|
# Create sender
|
||||||
user = User(
|
sender = Sender(
|
||||||
email=email,
|
email=email,
|
||||||
password_hash=hash_password(password),
|
password_hash=hash_password(password),
|
||||||
domain_id=domain_id,
|
domain_id=domain_id,
|
||||||
can_send_as_domain=can_send_as_domain
|
can_send_as_domain=can_send_as_domain,
|
||||||
|
store_message_content=store_message_content
|
||||||
)
|
)
|
||||||
session.add(user)
|
session.add(sender)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
flash(f'User {email} added successfully', 'success')
|
flash(f'Sender {email} added successfully', 'success')
|
||||||
return redirect(url_for('email.senders_list'))
|
return redirect(url_for('email.senders_list'))
|
||||||
|
|
||||||
return render_template('add_sender.html', domains=domains)
|
return render_template('add_sender.html', domains=domains)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
logger.error(f"Error adding user: {e}")
|
logger.error(f"Error adding sender: {e}")
|
||||||
flash(f'Error adding user: {str(e)}', 'error')
|
flash(f'Error adding sender: {str(e)}', 'error')
|
||||||
return redirect(url_for('email.add_sender'))
|
return redirect(url_for('email.add_sender'))
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
@email_bp.route('/senders/<int:user_id>/delete', methods=['POST'])
|
@email_bp.route('/senders/<int:user_id>/delete', methods=['POST'])
|
||||||
def delete_sender(user_id: int):
|
def delete_sender(user_id: int):
|
||||||
"""Disable user (soft delete)."""
|
"""Disable sender (soft delete)."""
|
||||||
session = Session()
|
session = Session()
|
||||||
try:
|
try:
|
||||||
user = session.query(User).get(user_id)
|
sender = session.query(Sender).get(user_id)
|
||||||
if not user:
|
if not sender:
|
||||||
flash('User not found', 'error')
|
flash('Sender not found', 'error')
|
||||||
return redirect(url_for('email.senders_list'))
|
return redirect(url_for('email.senders_list'))
|
||||||
|
|
||||||
user_email = user.email
|
sender_email = sender.email
|
||||||
user.is_active = False
|
sender.is_active = False
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
flash(f'User {user_email} disabled', 'success')
|
flash(f'Sender {sender_email} disabled', 'success')
|
||||||
return redirect(url_for('email.senders_list'))
|
return redirect(url_for('email.senders_list'))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
logger.error(f"Error disabling user: {e}")
|
logger.error(f"Error disabling sender: {e}")
|
||||||
flash(f'Error disabling user: {str(e)}', 'error')
|
flash(f'Error disabling sender: {str(e)}', 'error')
|
||||||
return redirect(url_for('email.senders_list'))
|
return redirect(url_for('email.senders_list'))
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
@email_bp.route('/senders/<int:user_id>/enable', methods=['POST'])
|
@email_bp.route('/senders/<int:user_id>/enable', methods=['POST'])
|
||||||
def enable_sender(user_id: int):
|
def enable_sender(user_id: int):
|
||||||
"""Enable user."""
|
"""Enable sender."""
|
||||||
session = Session()
|
session = Session()
|
||||||
try:
|
try:
|
||||||
user = session.query(User).get(user_id)
|
sender = session.query(Sender).get(user_id)
|
||||||
if not user:
|
if not sender:
|
||||||
flash('User not found', 'error')
|
flash('Sender not found', 'error')
|
||||||
return redirect(url_for('email.senders_list'))
|
return redirect(url_for('email.senders_list'))
|
||||||
|
|
||||||
user_email = user.email
|
sender_email = sender.email
|
||||||
user.is_active = True
|
sender.is_active = True
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
flash(f'User {user_email} enabled', 'success')
|
flash(f'Sender {sender_email} enabled', 'success')
|
||||||
return redirect(url_for('email.senders_list'))
|
return redirect(url_for('email.senders_list'))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
logger.error(f"Error enabling user: {e}")
|
logger.error(f"Error enabling sender: {e}")
|
||||||
flash(f'Error enabling user: {str(e)}', 'error')
|
flash(f'Error enabling sender: {str(e)}', 'error')
|
||||||
return redirect(url_for('email.senders_list'))
|
return redirect(url_for('email.senders_list'))
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
@email_bp.route('/senders/<int:user_id>/remove', methods=['POST'])
|
@email_bp.route('/senders/<int:user_id>/remove', methods=['POST'])
|
||||||
def remove_sender(user_id: int):
|
def remove_sender(user_id: int):
|
||||||
"""Permanently remove user."""
|
"""Permanently remove sender."""
|
||||||
session = Session()
|
session = Session()
|
||||||
try:
|
try:
|
||||||
user = session.query(User).get(user_id)
|
sender = session.query(Sender).get(user_id)
|
||||||
if not user:
|
if not sender:
|
||||||
flash('User not found', 'error')
|
flash('Sender not found', 'error')
|
||||||
return redirect(url_for('email.senders_list'))
|
return redirect(url_for('email.senders_list'))
|
||||||
|
|
||||||
user_email = user.email
|
sender_email = sender.email
|
||||||
session.delete(user)
|
session.delete(sender)
|
||||||
session.commit()
|
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'))
|
return redirect(url_for('email.senders_list'))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
logger.error(f"Error removing user: {e}")
|
logger.error(f"Error removing sender: {e}")
|
||||||
flash(f'Error removing user: {str(e)}', 'error')
|
flash(f'Error removing sender: {str(e)}', 'error')
|
||||||
return redirect(url_for('email.senders_list'))
|
return redirect(url_for('email.senders_list'))
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
@email_bp.route('/senders/<int:user_id>/edit', methods=['GET', 'POST'])
|
@email_bp.route('/senders/<int:user_id>/edit', methods=['GET', 'POST'])
|
||||||
def edit_sender(user_id: int):
|
def edit_sender(user_id: int):
|
||||||
"""Edit user."""
|
"""Edit sender."""
|
||||||
session = Session()
|
session = Session()
|
||||||
try:
|
try:
|
||||||
user = session.query(User).get(user_id)
|
sender = session.query(Sender).get(user_id)
|
||||||
if not user:
|
if not sender:
|
||||||
flash('User not found', 'error')
|
flash('Sender not found', 'error')
|
||||||
return redirect(url_for('email.senders_list'))
|
return redirect(url_for('email.senders_list'))
|
||||||
|
|
||||||
domains = session.query(Domain).filter_by(is_active=True).order_by(Domain.domain_name).all()
|
domains = session.query(Domain).filter_by(is_active=True).order_by(Domain.domain_name).all()
|
||||||
@@ -171,6 +173,7 @@ def edit_sender(user_id: int):
|
|||||||
password = request.form.get('password', '').strip()
|
password = request.form.get('password', '').strip()
|
||||||
domain_id = request.form.get('domain_id', type=int)
|
domain_id = request.form.get('domain_id', type=int)
|
||||||
can_send_as_domain = request.form.get('can_send_as_domain') == 'on'
|
can_send_as_domain = request.form.get('can_send_as_domain') == 'on'
|
||||||
|
store_message_content = request.form.get('store_message_content') == 'on'
|
||||||
|
|
||||||
if not all([email, domain_id]):
|
if not all([email, domain_id]):
|
||||||
flash('Email and domain are required', 'error')
|
flash('Email and domain are required', 'error')
|
||||||
@@ -181,35 +184,36 @@ def edit_sender(user_id: int):
|
|||||||
flash('Invalid email format', 'error')
|
flash('Invalid email format', 'error')
|
||||||
return redirect(url_for('email.edit_sender', user_id=user_id))
|
return redirect(url_for('email.edit_sender', user_id=user_id))
|
||||||
|
|
||||||
# Check if email already exists (excluding current user)
|
# Check if email already exists (excluding current sender)
|
||||||
existing = session.query(User).filter(
|
existing = session.query(Sender).filter(
|
||||||
User.email == email,
|
Sender.email == email,
|
||||||
User.id != user_id
|
Sender.id != user_id
|
||||||
).first()
|
).first()
|
||||||
if existing:
|
if existing:
|
||||||
flash(f'Email {email} already exists', 'error')
|
flash(f'Email {email} already exists', 'error')
|
||||||
return redirect(url_for('email.edit_sender', user_id=user_id))
|
return redirect(url_for('email.edit_sender', user_id=user_id))
|
||||||
|
|
||||||
# Update user
|
# Update sender
|
||||||
user.email = email
|
sender.email = email
|
||||||
user.domain_id = domain_id
|
sender.domain_id = domain_id
|
||||||
user.can_send_as_domain = can_send_as_domain
|
sender.can_send_as_domain = can_send_as_domain
|
||||||
|
sender.store_message_content = store_message_content
|
||||||
|
|
||||||
# Update password if provided
|
# Update password if provided
|
||||||
if password:
|
if password:
|
||||||
user.password_hash = hash_password(password)
|
sender.password_hash = hash_password(password)
|
||||||
|
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
flash(f'User {email} updated successfully', 'success')
|
flash(f'Sender {email} updated successfully', 'success')
|
||||||
return redirect(url_for('email.senders_list'))
|
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:
|
except Exception as e:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
logger.error(f"Error editing user: {e}")
|
logger.error(f"Error editing sender: {e}")
|
||||||
flash(f'Error editing user: {str(e)}', 'error')
|
flash(f'Error editing sender: {str(e)}', 'error')
|
||||||
return redirect(url_for('email.senders_list'))
|
return redirect(url_for('email.senders_list'))
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
@@ -12,6 +12,7 @@ This module provides server settings management functionality including:
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import zoneinfo
|
||||||
from flask import render_template, request, redirect, url_for, flash, jsonify
|
from flask import render_template, request, redirect, url_for, flash, jsonify
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
from email_server.settings_loader import load_settings, SETTINGS_PATH
|
from email_server.settings_loader import load_settings, SETTINGS_PATH
|
||||||
@@ -29,11 +30,21 @@ ALLOWED_EXTENSIONS = {'crt', 'key', 'pem'}
|
|||||||
def allowed_file(filename):
|
def allowed_file(filename):
|
||||||
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||||
|
|
||||||
|
def get_template_context():
|
||||||
|
"""Get template context with CSRF token and common data."""
|
||||||
|
context = {
|
||||||
|
'settings': load_settings(),
|
||||||
|
'timezones': get_available_timezones(),
|
||||||
|
}
|
||||||
|
# Only add CSRF token if it exists and is enabled
|
||||||
|
if hasattr(request, 'csrf_token'):
|
||||||
|
context['csrf_token_value'] = request.csrf_token
|
||||||
|
return context
|
||||||
|
|
||||||
@email_bp.route('/settings')
|
@email_bp.route('/settings')
|
||||||
def settings():
|
def settings():
|
||||||
"""Display and edit server settings."""
|
"""Display and edit server settings."""
|
||||||
settings = load_settings()
|
return render_template('settings.html', **get_template_context())
|
||||||
return render_template('settings.html', settings=settings)
|
|
||||||
|
|
||||||
@email_bp.route('/settings_update', methods=['POST'])
|
@email_bp.route('/settings_update', methods=['POST'])
|
||||||
def settings_update():
|
def settings_update():
|
||||||
@@ -195,3 +206,40 @@ def get_server_ip():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting public IP: {e}")
|
logger.error(f"Error getting public IP: {e}")
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
return jsonify({'status': 'error', 'message': str(e)})
|
||||||
|
|
||||||
|
@email_bp.route('/test_attachments_path', methods=['POST'])
|
||||||
|
def test_attachments_path():
|
||||||
|
"""Test if the attachments path is writable."""
|
||||||
|
path = request.form.get('path')
|
||||||
|
if not path:
|
||||||
|
return jsonify({'success': False, 'message': 'No path provided'})
|
||||||
|
|
||||||
|
# Convert to absolute path if relative
|
||||||
|
if not os.path.isabs(path):
|
||||||
|
path = os.path.abspath(os.path.join(os.path.dirname(SETTINGS_PATH), path))
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create path if it doesn't exist
|
||||||
|
os.makedirs(path, exist_ok=True)
|
||||||
|
|
||||||
|
# Try to create a test file
|
||||||
|
test_file = os.path.join(path, '.write_test')
|
||||||
|
with open(test_file, 'w') as f:
|
||||||
|
f.write('test')
|
||||||
|
os.remove(test_file)
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Attachments path is valid and writable',
|
||||||
|
'absolute_path': path
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error testing attachments path: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': f'Error: {str(e)}',
|
||||||
|
'absolute_path': path
|
||||||
|
})
|
||||||
|
|
||||||
|
def get_available_timezones():
|
||||||
|
"""Get a list of all available timezones sorted alphabetically."""
|
||||||
|
return sorted(zoneinfo.available_timezones())
|
||||||
|
|||||||
@@ -67,6 +67,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="store_message_content" name="store_message_content">
|
||||||
|
<label class="form-check-label" for="store_message_content">
|
||||||
|
<strong>Store Full Message Content</strong>
|
||||||
|
</label>
|
||||||
|
<div class="form-text">
|
||||||
|
If enabled, the full message body and attachments will be stored and viewable in logs. Otherwise, only headers and subject are stored.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="alert alert-warning">
|
<div class="alert alert-warning">
|
||||||
<h6 class="alert-heading">
|
<h6 class="alert-heading">
|
||||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||||
|
|||||||
@@ -70,6 +70,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="store_message_content" name="store_message_content">
|
||||||
|
<label class="form-check-label" for="store_message_content">
|
||||||
|
<strong>Store Full Message Content</strong>
|
||||||
|
</label>
|
||||||
|
<div class="form-text">
|
||||||
|
If enabled, the full message body and attachments will be stored and viewable in logs. Otherwise, only headers and subject are stored.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
<h6 class="alert-heading">
|
<h6 class="alert-heading">
|
||||||
<i class="bi bi-info-circle me-2"></i>
|
<i class="bi bi-info-circle me-2"></i>
|
||||||
|
|||||||
@@ -131,6 +131,20 @@
|
|||||||
<!-- Custom SMTP Management CSS -->
|
<!-- Custom SMTP Management CSS -->
|
||||||
<link href="{{ url_for('email.static', filename='css/smtp-management.css') }}" rel="stylesheet">
|
<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 %}
|
{% block extra_css %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -337,6 +351,16 @@
|
|||||||
<!-- Custom SMTP Management JavaScript -->
|
<!-- Custom SMTP Management JavaScript -->
|
||||||
<script src="{{ url_for('email.static', filename='js/smtp-management.js') }}"></script>
|
<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 %}
|
{% block extra_js %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<!-- Statistics Cards -->
|
<!-- Statistics Cards -->
|
||||||
<div class="col-lg-3 col-md-6 mb-4">
|
<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 border-primary">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
@@ -24,19 +25,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-3 col-md-6 mb-4">
|
<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 border-success">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<div class="flex-grow-1">
|
<div class="flex-grow-1">
|
||||||
<h5 class="card-title text-success mb-1">
|
<h5 class="card-title text-success mb-1">
|
||||||
<i class="bi bi-people me-2"></i>
|
<i class="bi bi-people me-2"></i>
|
||||||
Users
|
Senders
|
||||||
</h5>
|
</h5>
|
||||||
<h3 class="mb-0">{{ user_count }}</h3>
|
<h3 class="mb-0">{{ sender_count }}</h3>
|
||||||
<small class="text-muted">Authenticated users</small>
|
<small class="text-muted">Authenticated senders</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="fs-2 text-success opacity-50">
|
<div class="fs-2 text-success opacity-50">
|
||||||
<i class="bi bi-people"></i>
|
<i class="bi bi-people"></i>
|
||||||
@@ -44,9 +47,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-3 col-md-6 mb-4">
|
<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 border-warning">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
@@ -64,6 +69,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-3 col-md-6 mb-4">
|
<div class="col-lg-3 col-md-6 mb-4">
|
||||||
@@ -75,11 +81,28 @@
|
|||||||
<i class="bi bi-activity me-2"></i>
|
<i class="bi bi-activity me-2"></i>
|
||||||
Status
|
Status
|
||||||
</h5>
|
</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>
|
<i class="bi bi-circle-fill me-1" style="font-size: 0.5rem;"></i>
|
||||||
Online
|
{{ health.status|title }}
|
||||||
</h6>
|
</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>
|
||||||
<div class="fs-2 text-info opacity-50">
|
<div class="fs-2 text-info opacity-50">
|
||||||
<i class="bi bi-activity"></i>
|
<i class="bi bi-activity"></i>
|
||||||
@@ -111,7 +134,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Time</th>
|
<th>Time</th>
|
||||||
<th>From</th>
|
<th>From</th>
|
||||||
<th>To</th>
|
<th>Recipients</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>DKIM</th>
|
<th>DKIM</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -121,7 +144,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
{{ email.created_at.strftime('%H:%M:%S') }}
|
{{ email.created_at|format_datetime }}
|
||||||
</small>
|
</small>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -130,22 +153,74 @@
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="text-truncate d-inline-block" style="max-width: 150px;" title="{{ email.to_address }}">
|
<div style="max-width: 200px; font-size: 0.85rem;">
|
||||||
{{ email.to_address }}
|
<div class="recipients-list">
|
||||||
</span>
|
{% if email.to_address %}
|
||||||
|
{% for rcpt in email.to_address.split(',') %}
|
||||||
|
{% if rcpt.strip() %}
|
||||||
|
<div class="text-truncate">
|
||||||
|
<span class="text-info fw-bold" style="font-size: 0.75rem;">To:</span>
|
||||||
|
<span title="{{ rcpt.strip() }}">{{ rcpt.strip() }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if email.cc_addresses %}
|
||||||
|
{% for rcpt in email.cc_addresses.split(',') %}
|
||||||
|
{% if rcpt.strip() %}
|
||||||
|
<div class="text-truncate">
|
||||||
|
<span class="text-warning fw-bold" style="font-size: 0.75rem;">CC:</span>
|
||||||
|
<span title="{{ rcpt.strip() }}">{{ rcpt.strip() }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if email.bcc_addresses %}
|
||||||
|
{% for rcpt in email.bcc_addresses.split(',') %}
|
||||||
|
{% if rcpt.strip() %}
|
||||||
|
<div class="text-truncate">
|
||||||
|
<span class="text-secondary fw-bold" style="font-size: 0.75rem;">BCC:</span>
|
||||||
|
<span title="{{ rcpt.strip() }}">{{ rcpt.strip() }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not email.to_address and not email.cc_addresses and not email.bcc_addresses %}
|
||||||
|
<div class="text-muted">No recipients</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if email.status == 'relayed' %}
|
{% set delivered = recipient_logs_map[email.id]|selectattr('status', 'equalto', 'success')|list %}
|
||||||
<span class="badge bg-success">
|
{% set failed = recipient_logs_map[email.id]|selectattr('status', 'ne', 'success')|list %}
|
||||||
<i class="bi bi-check-circle me-1"></i>
|
{% if delivered and failed %}
|
||||||
Sent
|
{% set overall_status = 'partial' %}
|
||||||
</span>
|
{% elif delivered %}
|
||||||
|
{% set overall_status = 'relayed' %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-danger">
|
{% set overall_status = 'failed' %}
|
||||||
<i class="bi bi-x-circle me-1"></i>
|
|
||||||
Failed
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if overall_status == 'relayed' %}
|
||||||
|
<span class="badge bg-success">
|
||||||
|
<i class="bi bi-check-circle me-1"></i>
|
||||||
|
Sent
|
||||||
|
</span>
|
||||||
|
{% elif overall_status == 'partial' %}
|
||||||
|
<span class="badge bg-warning text-dark">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||||
|
Partial Fail
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger">
|
||||||
|
<i class="bi bi-x-circle me-1"></i>
|
||||||
|
Failed
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if email.dkim_signed %}
|
{% if email.dkim_signed %}
|
||||||
@@ -204,7 +279,7 @@
|
|||||||
</small>
|
</small>
|
||||||
<br>
|
<br>
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
{{ auth.created_at.strftime('%H:%M:%S') }}
|
{{ auth.created_at|format_datetime }}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
@@ -248,7 +323,7 @@
|
|||||||
<div class="d-grid">
|
<div class="d-grid">
|
||||||
<a href="{{ url_for('email.add_sender') }}" 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 Sender
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -282,4 +357,40 @@
|
|||||||
location.reload();
|
location.reload();
|
||||||
}, 30000);
|
}, 30000);
|
||||||
</script>
|
</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);
|
||||||
|
}
|
||||||
|
.recipients-list {
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.recipients-list div {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.recipients-list div:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -331,6 +331,30 @@
|
|||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script>
|
<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
|
// Show toast notification
|
||||||
function showToast(message, type = 'info') {
|
function showToast(message, type = 'info') {
|
||||||
const toastContainer = document.getElementById('toastContainer');
|
const toastContainer = document.getElementById('toastContainer');
|
||||||
@@ -433,6 +457,14 @@
|
|||||||
// Update SPF status
|
// Update SPF status
|
||||||
if (spfResult.success) {
|
if (spfResult.success) {
|
||||||
spfStatus.innerHTML = '<span class="status-indicator status-success"></span><small class="text-success">✓ Found</small>';
|
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 {
|
} else {
|
||||||
spfStatus.innerHTML = '<span class="status-indicator status-danger"></span><small class="text-danger">✗ Not found</small>';
|
spfStatus.innerHTML = '<span class="status-indicator status-danger"></span><small class="text-danger">✗ Not found</small>';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="store_message_content" name="store_message_content" {% if ip_record.store_message_content %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="store_message_content">
|
||||||
|
<strong>Store Full Message Content</strong>
|
||||||
|
</label>
|
||||||
|
<div class="form-text">
|
||||||
|
If enabled, the full message body and attachments will be stored and viewable in logs. Otherwise, only headers and subject are stored.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
<i class="bi bi-check-lg me-1"></i>
|
<i class="bi bi-check-lg me-1"></i>
|
||||||
@@ -95,6 +107,20 @@
|
|||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</dd>
|
</dd>
|
||||||
|
<dt class="col-sm-4">Store Message:</dt>
|
||||||
|
<dd class="col-sm-8">
|
||||||
|
{% if ip_record.store_message_content %}
|
||||||
|
<span class="badge bg-info text-dark">
|
||||||
|
<i class="bi bi-file-earmark-text me-1"></i>
|
||||||
|
Full Message
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">
|
||||||
|
<i class="bi bi-file-earmark me-1"></i>
|
||||||
|
Headers Only
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
<dt class="col-sm-4">Created:</dt>
|
<dt class="col-sm-4">Created:</dt>
|
||||||
<dd class="col-sm-8">
|
<dd class="col-sm-8">
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Edit User - SMTP Management{% endblock %}
|
{% block title %}Edit Sender - SMTP Management{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h5 class="mb-0">
|
<h5 class="mb-0">
|
||||||
<i class="bi bi-person-fill-gear me-2"></i>
|
<i class="bi bi-person-fill-gear me-2"></i>
|
||||||
Edit User
|
Edit Sender
|
||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
class="form-control"
|
class="form-control"
|
||||||
id="email"
|
id="email"
|
||||||
name="email"
|
name="email"
|
||||||
value="{{ user.email }}"
|
value="{{ sender.email }}"
|
||||||
required>
|
required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
<option value="">Select a domain</option>
|
<option value="">Select a domain</option>
|
||||||
{% for domain in domains %}
|
{% for domain in domains %}
|
||||||
<option value="{{ domain.id }}"
|
<option value="{{ domain.id }}"
|
||||||
{% if domain.id == user.domain_id %}selected{% endif %}>
|
{% if domain.id == sender.domain_id %}selected{% endif %}>
|
||||||
{{ domain.domain_name }}
|
{{ domain.domain_name }}
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -55,12 +55,24 @@
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="can_send_as_domain"
|
id="can_send_as_domain"
|
||||||
name="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">
|
<label class="form-check-label" for="can_send_as_domain">
|
||||||
<strong>Can send as any email from domain</strong>
|
<strong>Can send as any email from domain</strong>
|
||||||
</label>
|
</label>
|
||||||
<div class="form-text">
|
<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>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="store_message_content" name="store_message_content" {% if sender.store_message_content %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="store_message_content">
|
||||||
|
<strong>Store Full Message Content</strong>
|
||||||
|
</label>
|
||||||
|
<div class="form-text">
|
||||||
|
If enabled, the full message body and attachments will be stored and viewable in logs. Otherwise, only headers and subject are stored.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,7 +80,7 @@
|
|||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
<i class="bi bi-check-lg me-1"></i>
|
<i class="bi bi-check-lg me-1"></i>
|
||||||
Update User
|
Update Sender
|
||||||
</button>
|
</button>
|
||||||
<a href="{{ url_for('email.senders_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>
|
||||||
@@ -85,26 +97,26 @@
|
|||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h6 class="mb-0">
|
<h6 class="mb-0">
|
||||||
<i class="bi bi-info-circle me-2"></i>
|
<i class="bi bi-info-circle me-2"></i>
|
||||||
Current User Details
|
Current Sender Details
|
||||||
</h6>
|
</h6>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<dl class="row mb-0">
|
<dl class="row mb-0">
|
||||||
<dt class="col-sm-4">Email:</dt>
|
<dt class="col-sm-4">Email:</dt>
|
||||||
<dd class="col-sm-8">
|
<dd class="col-sm-8">
|
||||||
<code>{{ user.email }}</code>
|
<code>{{ sender.email }}</code>
|
||||||
</dd>
|
</dd>
|
||||||
<dt class="col-sm-4">Domain:</dt>
|
<dt class="col-sm-4">Domain:</dt>
|
||||||
<dd class="col-sm-8">
|
<dd class="col-sm-8">
|
||||||
{% for domain in domains %}
|
{% 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>
|
<span class="badge bg-secondary">{{ domain.domain_name }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</dd>
|
</dd>
|
||||||
<dt class="col-sm-4">Status:</dt>
|
<dt class="col-sm-4">Status:</dt>
|
||||||
<dd class="col-sm-8">
|
<dd class="col-sm-8">
|
||||||
{% if user.is_active %}
|
{% if sender.is_active %}
|
||||||
<span class="badge bg-success">
|
<span class="badge bg-success">
|
||||||
<i class="bi bi-check-circle me-1"></i>
|
<i class="bi bi-check-circle me-1"></i>
|
||||||
Active
|
Active
|
||||||
@@ -118,7 +130,7 @@
|
|||||||
</dd>
|
</dd>
|
||||||
<dt class="col-sm-4">Domain Sender:</dt>
|
<dt class="col-sm-4">Domain Sender:</dt>
|
||||||
<dd class="col-sm-8">
|
<dd class="col-sm-8">
|
||||||
{% if user.can_send_as_domain %}
|
{% if sender.can_send_as_domain %}
|
||||||
<span class="badge bg-success">
|
<span class="badge bg-success">
|
||||||
<i class="bi bi-check-circle me-1"></i>
|
<i class="bi bi-check-circle me-1"></i>
|
||||||
Yes
|
Yes
|
||||||
@@ -130,10 +142,24 @@
|
|||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</dd>
|
</dd>
|
||||||
|
<dt class="col-sm-4">Store Message:</dt>
|
||||||
|
<dd class="col-sm-8">
|
||||||
|
{% if sender.store_message_content %}
|
||||||
|
<span class="badge bg-info text-dark">
|
||||||
|
<i class="bi bi-file-earmark-text me-1"></i>
|
||||||
|
Full Message
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">
|
||||||
|
<i class="bi bi-file-earmark me-1"></i>
|
||||||
|
Headers Only
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
<dt class="col-sm-4">Created:</dt>
|
<dt class="col-sm-4">Created:</dt>
|
||||||
<dd class="col-sm-8">
|
<dd class="col-sm-8">
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
{{ user.created_at.strftime('%Y-%m-%d %H:%M') }}
|
{{ sender.created_at.strftime('%Y-%m-%d %H:%M') }}
|
||||||
</small>
|
</small>
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -144,7 +170,7 @@
|
|||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h6 class="mb-0">
|
<h6 class="mb-0">
|
||||||
<i class="bi bi-shield-check me-2"></i>
|
<i class="bi bi-shield-check me-2"></i>
|
||||||
User Permissions
|
Sender Permissions
|
||||||
</h6>
|
</h6>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@@ -154,8 +180,8 @@
|
|||||||
Domain Sender Permission
|
Domain Sender Permission
|
||||||
</h6>
|
</h6>
|
||||||
<ul class="mb-0 small">
|
<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>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> User can only send emails from their own email address</li>
|
<li><strong>Disabled:</strong> Sender can only send emails from their own email address</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
<th>IP Address</th>
|
<th>IP Address</th>
|
||||||
<th>Domain</th>
|
<th>Domain</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
|
<th>Storage Type</th>
|
||||||
<th>Added</th>
|
<th>Added</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -59,6 +60,19 @@
|
|||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if ip.store_message_content %}
|
||||||
|
<span class="badge bg-info text-dark">
|
||||||
|
<i class="bi bi-file-earmark-text me-1"></i>
|
||||||
|
Stores Full Message
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">
|
||||||
|
<i class="bi bi-file-earmark me-1"></i>
|
||||||
|
Headers Only
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
{{ ip.created_at.strftime('%Y-%m-%d %H:%M') }}
|
{{ ip.created_at.strftime('%Y-%m-%d %H:%M') }}
|
||||||
|
|||||||
@@ -16,16 +16,9 @@
|
|||||||
.log-error { border-left-color: #dc3545; }
|
.log-error { border-left-color: #dc3545; }
|
||||||
.log-success { border-left-color: #198754; }
|
.log-success { border-left-color: #198754; }
|
||||||
.log-failed { border-left-color: #dc3545; }
|
.log-failed { border-left-color: #dc3545; }
|
||||||
|
.log-partial { border-left-color: #fd7e14; } /* Orange for partial fail */
|
||||||
|
|
||||||
.log-content {
|
/* Message display styles are now in view_message_content.html */
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
background-color: var(--bs-gray-100);
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
padding: 0.5rem;
|
|
||||||
max-height: 150px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -83,11 +76,30 @@
|
|||||||
{% for log_entry in logs %}
|
{% for log_entry in logs %}
|
||||||
{% if log_entry.type == 'email' %}
|
{% if log_entry.type == 'email' %}
|
||||||
{% set log = log_entry.data %}
|
{% set log = log_entry.data %}
|
||||||
<div class="log-entry log-email log-{{ 'success' if log.status == 'relayed' else 'failed' }}">
|
{% set recipients = log_entry.recipients %}
|
||||||
|
{% set delivered = recipients|selectattr('status', 'equalto', 'success')|list %}
|
||||||
|
{% set failed = recipients|selectattr('status', 'ne', 'success')|list %}
|
||||||
|
{% if delivered and failed %}
|
||||||
|
{% set overall_status = 'partial' %}
|
||||||
|
{% elif delivered %}
|
||||||
|
{% set overall_status = 'relayed' %}
|
||||||
|
{% else %}
|
||||||
|
{% set overall_status = 'failed' %}
|
||||||
|
{% endif %}
|
||||||
|
<div class="log-entry log-email log-{% if overall_status == 'relayed' %}success{% elif overall_status == 'partial' %}partial{% else %}failed{% endif %}">
|
||||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||||
<div>
|
<div>
|
||||||
<span class="badge bg-primary me-2">EMAIL</span>
|
<span class="badge bg-primary me-2">EMAIL</span>
|
||||||
<strong>{{ log.mail_from }}</strong> → {{ log.rcpt_tos }}
|
<strong>{{ log.mail_from }}</strong>
|
||||||
|
{% if log.to_address %}
|
||||||
|
→ <span class="text-primary">To:</span> {{ log.to_address }}
|
||||||
|
{% endif %}
|
||||||
|
{% if log.cc_addresses %}
|
||||||
|
<br><span class="ms-4 text-info">CC:</span> {{ log.cc_addresses }}
|
||||||
|
{% endif %}
|
||||||
|
{% if log.bcc_addresses %}
|
||||||
|
<br><span class="ms-4 text-warning">BCC:</span> {{ log.bcc_addresses }}
|
||||||
|
{% endif %}
|
||||||
{% if log.dkim_signed %}
|
{% if log.dkim_signed %}
|
||||||
<span class="badge bg-success ms-2">
|
<span class="badge bg-success ms-2">
|
||||||
<i class="bi bi-shield-check me-1"></i>
|
<i class="bi bi-shield-check me-1"></i>
|
||||||
@@ -95,13 +107,15 @@
|
|||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<small class="text-muted">{{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</small>
|
<small class="text-muted">{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<strong>Status:</strong>
|
<strong>Status:</strong>
|
||||||
{% if log.status == 'relayed' %}
|
{% if overall_status == 'relayed' %}
|
||||||
<span class="text-success">Sent Successfully</span>
|
<span class="text-success">Sent Successfully</span>
|
||||||
|
{% elif overall_status == 'partial' %}
|
||||||
|
<span class="text-warning">Partial Fail</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-danger">Failed</span>
|
<span class="text-danger">Failed</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -115,6 +129,11 @@
|
|||||||
<strong>Subject:</strong> {{ log.subject }}
|
<strong>Subject:</strong> {{ log.subject }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<div class="mt-2">
|
||||||
|
<a href="{{ url_for('email.view_message_content', log_id=log.id) }}" class="btn btn-sm btn-primary">
|
||||||
|
<i class="fas fa-envelope-open-text"></i> View Message Details
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% set log = log_entry.data %}
|
{% set log = log_entry.data %}
|
||||||
@@ -127,7 +146,7 @@
|
|||||||
{{ 'Success' if log.success else 'Failed' }}
|
{{ 'Success' if log.success else 'Failed' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<small class="text-muted">{{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</small>
|
<small class="text-muted">{{ log.created_at|format_datetime }}</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
@@ -148,10 +167,28 @@
|
|||||||
{% elif filter_type == 'emails' %}
|
{% elif filter_type == 'emails' %}
|
||||||
<!-- Email logs only -->
|
<!-- Email logs only -->
|
||||||
{% for log in logs %}
|
{% for log in logs %}
|
||||||
<div class="log-entry log-email log-{{ 'success' if log.status == 'relayed' else 'failed' }}">
|
{% set delivered = recipient_logs_map[log.id]|selectattr('status', 'equalto', 'success')|list %}
|
||||||
|
{% set failed = recipient_logs_map[log.id]|selectattr('status', 'ne', 'success')|list %}
|
||||||
|
{% if delivered and failed %}
|
||||||
|
{% set overall_status = 'partial' %}
|
||||||
|
{% elif delivered %}
|
||||||
|
{% set overall_status = 'relayed' %}
|
||||||
|
{% else %}
|
||||||
|
{% set overall_status = 'failed' %}
|
||||||
|
{% endif %}
|
||||||
|
<div class="log-entry log-email log-{{ overall_status }}">
|
||||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ log.mail_from }}</strong> → {{ log.rcpt_tos }}
|
<strong>{{ log.mail_from }}</strong>
|
||||||
|
{% if log.to_address %}
|
||||||
|
→ <span class="text-primary">To:</span> {{ log.to_address }}
|
||||||
|
{% endif %}
|
||||||
|
{% if log.cc_addresses %}
|
||||||
|
<br><span class="ms-4 text-info">CC:</span> {{ log.cc_addresses }}
|
||||||
|
{% endif %}
|
||||||
|
{% if log.bcc_addresses %}
|
||||||
|
<br><span class="ms-4 text-warning">BCC:</span> {{ log.bcc_addresses }}
|
||||||
|
{% endif %}
|
||||||
{% if log.dkim_signed %}
|
{% if log.dkim_signed %}
|
||||||
<span class="badge bg-success ms-2">
|
<span class="badge bg-success ms-2">
|
||||||
<i class="bi bi-shield-check me-1"></i>
|
<i class="bi bi-shield-check me-1"></i>
|
||||||
@@ -159,24 +196,64 @@
|
|||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<small class="text-muted">{{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</small>
|
<small class="text-muted">{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<strong>Status:</strong>
|
<strong>Status:</strong>
|
||||||
{% if log.status == 'relayed' %}
|
{% if overall_status == 'relayed' %}
|
||||||
<span class="text-success">Sent</span>
|
<span class="text-success">Sent</span>
|
||||||
|
{% elif overall_status == 'partial' %}
|
||||||
|
<span class="text-warning">Partial Fail</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-danger">Failed</span>
|
<span class="text-danger">Failed</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<strong>Peer:</strong> <code>{{ log.peer }}</code>
|
<strong>Peer:</strong> <code>{{ log.peer_ip }}</code>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<strong>Message ID:</strong> <code>{{ log.message_id }}</code>
|
<strong>Message ID:</strong> <code>{{ log.message_id }}</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row mt-2">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<strong>Username:</strong> {{ log.username or 'N/A' }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<strong>CC:</strong> {{ log.cc_addresses or 'None' }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<strong>BCC:</strong> {{ log.bcc_addresses or 'None' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if recipient_logs_map and log.id in recipient_logs_map and recipient_logs_map[log.id] %}
|
||||||
|
<div class="mt-2">
|
||||||
|
<strong>Recipient Delivery Results:</strong>
|
||||||
|
<ul class="list-group">
|
||||||
|
{% for r in recipient_logs_map[log.id] %}
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<span>
|
||||||
|
<strong>{{ r.recipient_type|upper }}:</strong> {{ r.recipient }}
|
||||||
|
{% if r.status == 'success' %}
|
||||||
|
<span class="badge bg-success ms-2">Delivered</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger ms-2">Failed</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
{% if r.error_code or r.error_message %}
|
||||||
|
<span class="text-danger ms-2">
|
||||||
|
{{ r.error_code }} {{ r.error_message }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if r.server_response %}
|
||||||
|
<span class="text-muted ms-2">{{ r.server_response }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% if log.subject %}
|
{% if log.subject %}
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<strong>Subject:</strong> {{ log.subject }}
|
<strong>Subject:</strong> {{ log.subject }}
|
||||||
@@ -195,6 +272,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if log.has_message_content %}
|
||||||
|
<div class="mt-2">
|
||||||
|
<a href="{{ url_for('email.view_message_content', log_id=log.id) }}" class="btn btn-outline-info btn-sm">
|
||||||
|
<i class="bi bi-file-earmark-text me-1"></i> View Full Message
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -208,7 +292,7 @@
|
|||||||
{{ 'Success' if log.success else 'Failed' }}
|
{{ 'Success' if log.success else 'Failed' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<small class="text-muted">{{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</small>
|
<small class="text-muted">{{ log.created_at|format_datetime }}</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
{% if users %}
|
{% if senders %}
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-dark table-hover mb-0">
|
<table class="table table-dark table-hover mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -85,21 +85,22 @@
|
|||||||
<th>Domain</th>
|
<th>Domain</th>
|
||||||
<th>Permissions</th>
|
<th>Permissions</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
|
<th>Storage</th>
|
||||||
<th>Created</th>
|
<th>Created</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for user, domain in users %}
|
{% for sender, domain in senders %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<div class="fw-bold">{{ user.email }}</div>
|
<div class="fw-bold">{{ sender.email }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge bg-secondary">{{ domain.domain_name }}</span>
|
<span class="badge bg-secondary">{{ domain.domain_name }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if user.can_send_as_domain %}
|
{% if sender.can_send_as_domain %}
|
||||||
<span class="badge bg-warning" style="color: black;">
|
<span class="badge bg-warning" style="color: black;">
|
||||||
<i class="bi bi-star me-1"></i>
|
<i class="bi bi-star me-1"></i>
|
||||||
Domain Sender
|
Domain Sender
|
||||||
@@ -112,11 +113,11 @@
|
|||||||
Regular Sender
|
Regular Sender
|
||||||
</span>
|
</span>
|
||||||
<br>
|
<br>
|
||||||
<small class="text-muted">Can only send as {{ user.email }}</small>
|
<small class="text-muted">Can only send as {{ sender.email }}</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if user.is_active %}
|
{% if sender.is_active %}
|
||||||
<span class="badge bg-success">
|
<span class="badge bg-success">
|
||||||
<i class="bi bi-check-circle me-1"></i>
|
<i class="bi bi-check-circle me-1"></i>
|
||||||
Active
|
Active
|
||||||
@@ -128,47 +129,60 @@
|
|||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if sender.store_message_content %}
|
||||||
|
<span class="badge bg-info text-dark">
|
||||||
|
<i class="bi bi-file-earmark-text me-1"></i>
|
||||||
|
Stores Full Message
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">
|
||||||
|
<i class="bi bi-file-earmark me-1"></i>
|
||||||
|
Headers Only
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
{{ user.created_at.strftime('%Y-%m-%d %H:%M') }}
|
{{ sender.created_at.strftime('%Y-%m-%d %H:%M') }}
|
||||||
</small>
|
</small>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="btn-group" role="group">
|
<div class="btn-group" role="group">
|
||||||
<!-- Edit Button -->
|
<!-- 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"
|
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>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Enable/Disable Button -->
|
<!-- Enable/Disable Button -->
|
||||||
{% if user.is_active %}
|
{% if sender.is_active %}
|
||||||
<form method="post" action="{{ url_for('email.delete_sender', user_id=user.id) }}" class="d-inline">
|
<form method="post" action="{{ url_for('email.delete_sender', user_id=sender.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"
|
||||||
onclick="return confirm('Disable user {{ user.email }}?')">
|
onclick="return confirm('Disable user {{ sender.email }}?')">
|
||||||
<i class="bi bi-pause-circle"></i>
|
<i class="bi bi-pause-circle"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% 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"
|
<button type="submit"
|
||||||
class="btn btn-outline-success btn-sm"
|
class="btn btn-outline-success btn-sm"
|
||||||
title="Enable Sender"
|
title="Enable Sender"
|
||||||
onclick="return confirm('Enable user {{ user.email }}?')">
|
onclick="return confirm('Enable user {{ sender.email }}?')">
|
||||||
<i class="bi bi-play-circle"></i>
|
<i class="bi bi-play-circle"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Permanent Remove Button -->
|
<!-- 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"
|
<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"
|
||||||
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>
|
<i class="bi bi-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -97,10 +97,22 @@
|
|||||||
<input type="text"
|
<input type="text"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
name="Server.bind_ip"
|
name="Server.bind_ip"
|
||||||
value="{{ settings['Server']['bind_ip'] }}"
|
value="{{ settings['Server']['bind_ip'] }}">
|
||||||
pattern="^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Server Timezone</label>
|
||||||
|
<div class="setting-description">Timezone for server operations and logging</div>
|
||||||
|
<select class="form-select" name="Server.time_zone">
|
||||||
|
{% for tz in timezones %}
|
||||||
|
<option value="{{ tz }}" {% if tz == settings['Server']['time_zone'] %}selected{% endif %}>{{ tz }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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">Hostname</label>
|
<label class="form-label">Hostname</label>
|
||||||
@@ -111,8 +123,6 @@
|
|||||||
value="{{ settings['Server']['hostname'] }}">
|
value="{{ settings['Server']['hostname'] }}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<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">HELO Hostname</label>
|
||||||
@@ -123,6 +133,8 @@
|
|||||||
value="{{ settings['Server']['helo_hostname'] }}">
|
value="{{ settings['Server']['helo_hostname'] }}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<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">Server Banner</label>
|
<label class="form-label">Server Banner</label>
|
||||||
@@ -353,6 +365,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Attachments Settings -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header" data-bs-toggle="collapse" data-bs-target="#attachmentsSettings" aria-expanded="true">
|
||||||
|
<h5 class="mb-0 d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="bi bi-paperclip me-2"></i>Attachments Configuration</span>
|
||||||
|
<i class="bi bi-chevron-down"></i>
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div id="attachmentsSettings" class="collapse show">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="setting-section">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Attachments Storage Path</label>
|
||||||
|
<div class="setting-description">Path where email attachments will be stored (relative to SMTP server root)</div>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control"
|
||||||
|
name="Attachments.attachments_path"
|
||||||
|
value="{{ settings['Attachments']['attachments_path'] }}"
|
||||||
|
placeholder="email_server/server_data/attachments">
|
||||||
|
</div> <div class="setting-description text-warning">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||||
|
Make sure the path exists and is writable by the server process
|
||||||
|
</div>
|
||||||
|
<div id="attachments-path-feedback" class="mt-2"></div>
|
||||||
|
<div id="attachments-path-feedback" class="mt-2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Save Button -->
|
<!-- Save Button -->
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<div class="alert alert-warning d-flex align-items-center mb-0">
|
<div class="alert alert-warning d-flex align-items-center mb-0">
|
||||||
@@ -602,5 +648,60 @@
|
|||||||
console.log(`${key}: ${value}`);
|
console.log(`${key}: ${value}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Populate timezone select options
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const timeZoneSelect = document.getElementById('timeZoneSelect');
|
||||||
|
fetch('/api/timezones')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
data.timezones.forEach(tz => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = tz;
|
||||||
|
option.textContent = tz;
|
||||||
|
timeZoneSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(err => console.error('Failed to load timezones:', err));
|
||||||
|
});
|
||||||
|
|
||||||
|
function validateAttachmentsPath() {
|
||||||
|
const path = document.querySelector('input[name="Attachments.attachments_path"]').value;
|
||||||
|
const feedback = document.getElementById('attachments-path-feedback');
|
||||||
|
if (!feedback) return;
|
||||||
|
|
||||||
|
fetch('{{ url_for("email.test_attachments_path") }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'X-CSRFToken': '{{ csrf_token_value|default("") }}'
|
||||||
|
},
|
||||||
|
body: `path=${encodeURIComponent(path)}`
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
feedback.innerHTML = data.message;
|
||||||
|
feedback.className = data.success ? 'text-success mt-2' : 'text-danger mt-2';
|
||||||
|
if (data.success) {
|
||||||
|
feedback.innerHTML += `<br><small class="text-muted">Absolute path: ${data.absolute_path}</small>`;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
feedback.innerHTML = `Error validating path: ${error}`;
|
||||||
|
feedback.className = 'text-danger mt-2';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event listener to attachments path input
|
||||||
|
document.querySelector('input[name="Attachments.attachments_path"]')?.addEventListener('change', validateAttachmentsPath);
|
||||||
|
|
||||||
|
document.getElementById('settingsForm')?.addEventListener('submit', function(e) {
|
||||||
|
const attachmentsPath = document.querySelector('input[name="Attachments.attachments_path"]');
|
||||||
|
if (!attachmentsPath.value.trim()) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert('Please specify a valid attachments storage path');
|
||||||
|
attachmentsPath.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
class="nav-link text-white {{ 'active' if request.endpoint in ['email.senders_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">{{ sender_count if sender_count is defined else '' }}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
Server Settings
|
Server Settings
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
{#
|
||||||
<!-- Monitoring Section -->
|
<!-- Monitoring Section -->
|
||||||
<li class="nav-item mb-2">
|
<li class="nav-item mb-2">
|
||||||
<h6 class="text-muted text-uppercase small mb-2 mt-3">
|
<h6 class="text-muted text-uppercase small mb-2 mt-3">
|
||||||
@@ -114,6 +114,7 @@
|
|||||||
Logs & Activity
|
Logs & Activity
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
#}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -122,12 +123,22 @@
|
|||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<div class="flex-grow-1">
|
<div class="flex-grow-1">
|
||||||
<small class="text-muted d-block">Server Status</small>
|
<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>
|
<i class="bi bi-circle-fill me-1" style="font-size: 0.5rem;"></i>
|
||||||
Online
|
{{ health.status|title }}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</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>
|
<i class="bi bi-arrow-clockwise"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,6 +181,10 @@
|
|||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive design */
|
/* Responsive design */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.sidebar {
|
.sidebar {
|
||||||
@@ -186,3 +201,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</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>
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}View Full Message - Email Log{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-4">
|
||||||
|
<h2>Full Message Content</h2>
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>From:</strong> {{ log.mail_from }}<br>
|
||||||
|
<strong>To:</strong> {{ log.to_address }}<br>
|
||||||
|
<strong>CC:</strong> {{ log.cc_addresses or 'None' }}<br>
|
||||||
|
<strong>BCC:</strong> {{ log.bcc_addresses or 'None' }}<br>
|
||||||
|
<strong>Subject:</strong> {{ log.subject or 'N/A' }}<br>
|
||||||
|
<strong>Date:</strong> {{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') }}<br>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if log.attachments %}
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<strong>Attachments:</strong>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul class="list-group">
|
||||||
|
{% for attachment in log.attachments %}
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<i class="fas fa-paperclip"></i> {{ attachment.filename }}
|
||||||
|
<small class="text-muted">({{ attachment.size|filesizeformat }})</small>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
{% set content_type = attachment.content_type.lower() if attachment.content_type else 'application/octet-stream' %}
|
||||||
|
{% set extension = attachment.filename.split('.')[-1].lower() if '.' in attachment.filename else '' %}
|
||||||
|
|
||||||
|
{% set is_image = content_type.startswith('image/') or extension in ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'] %}
|
||||||
|
{% set is_text = content_type.startswith('text/') or extension in ['txt', 'log', 'json', 'xml', 'csv', 'md'] %}
|
||||||
|
{% set is_pdf = content_type == 'application/pdf' or extension == 'pdf' %}
|
||||||
|
{% set is_html = content_type in ['text/html', 'application/xhtml+xml'] or extension in ['html', 'htm'] %}
|
||||||
|
|
||||||
|
{% if is_image or is_text or is_pdf or is_html %}
|
||||||
|
<a href="{{ url_for('email.download_attachment', attachment_id=attachment.id) }}"
|
||||||
|
class="btn btn-sm btn-outline-primary"
|
||||||
|
target="_blank"
|
||||||
|
data-bs-toggle="tooltip"
|
||||||
|
title="Open in new tab">
|
||||||
|
<i class="fas fa-external-link-alt"></i>
|
||||||
|
{% if is_image %}<i class="fas fa-image"></i> View Image
|
||||||
|
{% elif is_pdf %}<i class="fas fa-file-pdf"></i> View PDF
|
||||||
|
{% elif extension == 'csv' %}<i class="fas fa-table"></i> View CSV
|
||||||
|
{% elif is_text %}<i class="fas fa-file-alt"></i> View Text
|
||||||
|
{% elif is_html %}<i class="fas fa-file-code"></i> View HTML
|
||||||
|
{% else %}View in Browser
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ url_for('email.download_attachment', attachment_id=attachment.id, download='true') }}"
|
||||||
|
class="btn btn-sm btn-outline-secondary"
|
||||||
|
title="Download file">
|
||||||
|
<i class="fas fa-download"></i> Download
|
||||||
|
</a>
|
||||||
|
<form method="POST"
|
||||||
|
action="{{ url_for('email.delete_attachment', attachment_id=attachment.id) }}"
|
||||||
|
style="display: inline;"
|
||||||
|
onsubmit="return confirm('Are you sure you want to delete this attachment?');">
|
||||||
|
<button type="submit"
|
||||||
|
class="btn btn-sm btn-outline-danger"
|
||||||
|
title="Delete attachment">
|
||||||
|
<i class="fas fa-trash-alt"></i> Delete
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<strong>Message Content:</strong>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<pre style="white-space: pre-wrap; word-break: break-all;">{{ log.message_body }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<strong>Message Headers:</strong>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<pre style="white-space: pre-wrap;">{{ log.email_headers }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="{{ url_for('email.logs', type='emails') }}" class="btn btn-secondary mt-3">Back to Logs</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -16,7 +16,7 @@ logger = logging.getLogger(__name__)
|
|||||||
def get_public_ip() -> str:
|
def get_public_ip() -> str:
|
||||||
"""Get the public IP address of the server."""
|
"""Get the public IP address of the server."""
|
||||||
try:
|
try:
|
||||||
response1 = requests.get('https://ifconfig.me/ip', timeout=3, verify=False)
|
response1 = requests.get('http://ifconfig.me/ip', timeout=3, verify=False)
|
||||||
|
|
||||||
ip = response1.text.strip()
|
ip = response1.text.strip()
|
||||||
if ip and ip != 'unknown':
|
if ip and ip != 'unknown':
|
||||||
@@ -24,7 +24,7 @@ def get_public_ip() -> str:
|
|||||||
except Exception:
|
except Exception:
|
||||||
try:
|
try:
|
||||||
# Fallback method
|
# Fallback method
|
||||||
response = requests.get('https://httpbin.org/ip', timeout=3, verify=False)
|
response = requests.get('http://httpbin.org/ip', timeout=3, verify=False)
|
||||||
ip = response.json()['origin'].split(',')[0].strip()
|
ip = response.json()['origin'].split(',')[0].strip()
|
||||||
if ip and ip != 'unknown':
|
if ip and ip != 'unknown':
|
||||||
return ip
|
return ip
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""
|
||||||
|
Route to view full email message content if stored.
|
||||||
|
"""
|
||||||
|
from flask import render_template, abort, flash, redirect, Response, send_file, request, url_for
|
||||||
|
from email_server.models import Session, EmailLog, EmailAttachment
|
||||||
|
from email_server.tool_box import get_logger
|
||||||
|
from .routes import email_bp
|
||||||
|
import os
|
||||||
|
|
||||||
|
logger = get_logger()
|
||||||
|
|
||||||
|
@email_bp.route('/msg/content/<int:log_id>')
|
||||||
|
def view_message_content(log_id):
|
||||||
|
"""View the full message content for an email log if stored."""
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
# Get log with attachments
|
||||||
|
log = session.query(EmailLog).filter_by(id=log_id).first()
|
||||||
|
if not log:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
# Get attachments for this log
|
||||||
|
attachments = session.query(EmailAttachment).filter_by(email_log_id=log_id).all()
|
||||||
|
log.attachments = attachments
|
||||||
|
|
||||||
|
return render_template('view_message_content.html', log=log)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
@email_bp.route('/msg/attachment/<int:attachment_id>/download')
|
||||||
|
def download_attachment(attachment_id):
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
attachment = session.query(EmailAttachment).get(attachment_id)
|
||||||
|
if not attachment or not os.path.isfile(attachment.file_path):
|
||||||
|
flash('Attachment not found.', 'danger')
|
||||||
|
return redirect(url_for('email.logs', type='emails'))
|
||||||
|
|
||||||
|
# Get the normalized content type and handle special cases
|
||||||
|
content_type = attachment.content_type.lower() if attachment.content_type else 'application/octet-stream'
|
||||||
|
extension = os.path.splitext(attachment.filename.lower())[1][1:] if '.' in attachment.filename else ''
|
||||||
|
|
||||||
|
# Force download if requested
|
||||||
|
as_attachment = request.args.get('download', '').lower() == 'true'
|
||||||
|
|
||||||
|
# Map of extensions to content types for common files
|
||||||
|
content_type_map = {
|
||||||
|
'txt': 'text/plain',
|
||||||
|
'csv': 'text/csv',
|
||||||
|
'pdf': 'application/pdf',
|
||||||
|
'jpg': 'image/jpeg',
|
||||||
|
'jpeg': 'image/jpeg',
|
||||||
|
'png': 'image/png',
|
||||||
|
'gif': 'image/gif',
|
||||||
|
'svg': 'image/svg+xml',
|
||||||
|
'html': 'text/html',
|
||||||
|
'htm': 'text/html',
|
||||||
|
'json': 'application/json',
|
||||||
|
'xml': 'text/xml',
|
||||||
|
'md': 'text/markdown',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update content type based on file extension if needed
|
||||||
|
if content_type == 'application/octet-stream' and extension in content_type_map:
|
||||||
|
content_type = content_type_map[extension]
|
||||||
|
|
||||||
|
# Special handling for CSV files
|
||||||
|
if content_type == 'text/csv' and not as_attachment:
|
||||||
|
try:
|
||||||
|
with open(attachment.file_path, 'r') as f:
|
||||||
|
csv_content = f.read()
|
||||||
|
# Create a simple HTML table view for CSV
|
||||||
|
html_content = '<html><head><style>'
|
||||||
|
html_content += 'table {border-collapse: collapse; width: 100%;} '
|
||||||
|
html_content += 'th, td {border: 1px solid #ddd; padding: 8px; text-align: left;} '
|
||||||
|
html_content += 'tr:nth-child(even) {background-color: #f2f2f2;} '
|
||||||
|
html_content += 'th {background-color: #4CAF50; color: white;}'
|
||||||
|
html_content += '</style></head><body><table>'
|
||||||
|
|
||||||
|
# Convert CSV to HTML table
|
||||||
|
for i, line in enumerate(csv_content.split('\n')):
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
html_content += '<tr>'
|
||||||
|
if i == 0: # Header row
|
||||||
|
html_content += ''.join(f'<th>{cell}</th>' for cell in line.split(','))
|
||||||
|
else:
|
||||||
|
html_content += ''.join(f'<td>{cell}</td>' for cell in line.split(','))
|
||||||
|
html_content += '</tr>'
|
||||||
|
|
||||||
|
html_content += '</table></body></html>'
|
||||||
|
return Response(html_content, mimetype='text/html')
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to create CSV preview: {e}")
|
||||||
|
# Fall back to normal file handling
|
||||||
|
|
||||||
|
# Determine if the file should be viewed in browser
|
||||||
|
if as_attachment:
|
||||||
|
# Force download
|
||||||
|
return send_file(
|
||||||
|
attachment.file_path,
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=attachment.filename
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Try to display in browser
|
||||||
|
return send_file(
|
||||||
|
attachment.file_path,
|
||||||
|
mimetype=content_type
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@email_bp.route('/msg/attachment/<int:attachment_id>/delete', methods=['POST', 'GET'])
|
||||||
|
def delete_attachment(attachment_id):
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
attachment = session.query(EmailAttachment).get(attachment_id)
|
||||||
|
if not attachment:
|
||||||
|
flash('Attachment not found.', 'danger')
|
||||||
|
return redirect(url_for('email.logs', type='emails'))
|
||||||
|
# Remove file from disk
|
||||||
|
if os.path.isfile(attachment.file_path):
|
||||||
|
os.remove(attachment.file_path)
|
||||||
|
# Remove from DB
|
||||||
|
session.delete(attachment)
|
||||||
|
session.commit()
|
||||||
|
flash('Attachment deleted.', 'success')
|
||||||
|
return redirect(url_for('email.logs', type='emails'))
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
@@ -14,8 +14,8 @@ DEFAULTS = {
|
|||||||
'; Server configuration for SMTP ports and hostname': None,
|
'; Server configuration for SMTP ports and hostname': None,
|
||||||
'; Plain SMTP port for internal/whitelisted IPs': None,
|
'; Plain SMTP port for internal/whitelisted IPs': None,
|
||||||
'SMTP_PORT': '4025',
|
'SMTP_PORT': '4025',
|
||||||
'; STARTTLS SMTP port for authenticated users': None,
|
'; TLS SMTP port for authenticated users': None,
|
||||||
'SMTP_TLS_PORT': '40587',
|
'SMTP_TLS_PORT': '40465',
|
||||||
'; Server hostname for HELO/EHLO identification': None,
|
'; Server hostname for HELO/EHLO identification': None,
|
||||||
'HOSTNAME': 'mail.example.com',
|
'HOSTNAME': 'mail.example.com',
|
||||||
'; Override HELO hostname': None,
|
'; Override HELO hostname': None,
|
||||||
@@ -24,6 +24,8 @@ DEFAULTS = {
|
|||||||
'BIND_IP': '0.0.0.0',
|
'BIND_IP': '0.0.0.0',
|
||||||
'; Custom server banner (to make it empty use "" must be double quotes)': None,
|
'; Custom server banner (to make it empty use "" must be double quotes)': None,
|
||||||
'server_banner': "",
|
'server_banner': "",
|
||||||
|
'; Time zone for the server': None,
|
||||||
|
'TIME_ZONE': 'Europe/London',
|
||||||
},
|
},
|
||||||
'Database': {
|
'Database': {
|
||||||
'; Database configuration': None,
|
'; Database configuration': None,
|
||||||
@@ -38,7 +40,7 @@ DEFAULTS = {
|
|||||||
},
|
},
|
||||||
'Relay': {
|
'Relay': {
|
||||||
'; Timeout in seconds for external SMTP connections': None,
|
'; Timeout in seconds for external SMTP connections': None,
|
||||||
'RELAY_TIMEOUT': '10',
|
'RELAY_TIMEOUT': '30',
|
||||||
},
|
},
|
||||||
'TLS': {
|
'TLS': {
|
||||||
'; TLS/SSL certificate configuration': None,
|
'; TLS/SSL certificate configuration': None,
|
||||||
|
|||||||
+452
-124
@@ -8,33 +8,59 @@ Security Features:
|
|||||||
- Enhanced header management
|
- Enhanced header management
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import uuid
|
import email.utils
|
||||||
from datetime import datetime
|
import os
|
||||||
|
import mimetypes
|
||||||
from aiosmtpd.smtp import SMTP as AIOSMTP, AuthResult
|
from aiosmtpd.smtp import SMTP as AIOSMTP, AuthResult
|
||||||
from aiosmtpd.controller import Controller
|
from aiosmtpd.controller import Controller
|
||||||
from email_server.auth import EnhancedAuthenticator, EnhancedIPAuthenticator, validate_sender_authorization, get_authenticated_domain_id
|
from email_server.auth import EnhancedAuthenticator, EnhancedIPAuthenticator, validate_sender_authorization
|
||||||
from email_server.email_relay import EmailRelay
|
from email_server.email_relay import EmailRelay
|
||||||
from email_server.dkim_manager import DKIMManager
|
from email_server.dkim_manager import DKIMManager
|
||||||
from email_server.settings_loader import load_settings
|
from email_server.settings_loader import load_settings
|
||||||
from email_server.tool_box import get_logger
|
from email_server.tool_box import get_logger, ensure_folder_exists, generate_message_id, get_current_time
|
||||||
|
from email import policy
|
||||||
|
from email.parser import BytesParser
|
||||||
|
from email_server.models import Session, EmailAttachment, EmailLog
|
||||||
|
|
||||||
logger = get_logger()
|
logger = get_logger()
|
||||||
|
settings = load_settings()
|
||||||
|
|
||||||
|
helo_hostname = settings['Server'].get('helo_hostname', settings['Server'].get('hostname', 'localhost'))
|
||||||
|
|
||||||
class CustomSMTP(AIOSMTP):
|
class CustomSMTP(AIOSMTP):
|
||||||
"""Custom SMTP class with configurable banner."""
|
"""Custom SMTP class with configurable banner and secure AUTH handling."""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
# Sets Custom SMTP banner from settings
|
# Sets Custom SMTP banner from settings
|
||||||
settings = load_settings()
|
|
||||||
_banner_message = settings['Server'].get('server_banner', '')
|
_banner_message = settings['Server'].get('server_banner', '')
|
||||||
if _banner_message == '""':
|
if _banner_message == '""':
|
||||||
_banner_message = ''
|
_banner_message = ''
|
||||||
self.custom_banner = _banner_message
|
self.custom_banner = _banner_message
|
||||||
|
|
||||||
|
# Store authenticator and auth_require_tls for later use
|
||||||
|
self._custom_authenticator = kwargs.get('authenticator', None)
|
||||||
|
self._custom_auth_require_tls = kwargs.get('auth_require_tls', False)
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
# Override the __ident__ to use our custom banner
|
# Override the __ident__ to use our custom banner
|
||||||
self.__ident__ = self.custom_banner
|
self.__ident__ = self.custom_banner
|
||||||
|
|
||||||
|
def _get_auth_methods(self):
|
||||||
|
# Only advertise AUTH if authenticator is set and (not auth_require_tls or connection is secure)
|
||||||
|
if self._custom_authenticator and (not self._custom_auth_require_tls or self.session and self.session.ssl):
|
||||||
|
return super()._get_auth_methods()
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def smtp_AUTH(self, arg):
|
||||||
|
"""
|
||||||
|
Override AUTH command to close connection after failed authentication.
|
||||||
|
"""
|
||||||
|
result = await super().smtp_AUTH(arg)
|
||||||
|
# If authentication failed, close the connection immediately
|
||||||
|
if isinstance(result, AuthResult) and not result.success:
|
||||||
|
if hasattr(self, 'session') and hasattr(self.session, 'transport') and self.session.transport:
|
||||||
|
self.session.transport.close()
|
||||||
|
return result
|
||||||
|
|
||||||
class EnhancedCombinedAuthenticator:
|
class EnhancedCombinedAuthenticator:
|
||||||
"""
|
"""
|
||||||
Enhanced combined authenticator with sender validation support.
|
Enhanced combined authenticator with sender validation support.
|
||||||
@@ -77,61 +103,45 @@ class EnhancedCustomSMTPHandler:
|
|||||||
self.auth_methods = ['LOGIN', 'PLAIN']
|
self.auth_methods = ['LOGIN', 'PLAIN']
|
||||||
|
|
||||||
def _ensure_required_headers(self, content: str, envelope, message_id: str, custom_headers: list = None) -> str:
|
def _ensure_required_headers(self, content: str, envelope, message_id: str, custom_headers: list = None) -> str:
|
||||||
"""Ensure all required email headers are present and properly formatted.
|
"""Ensure all required email headers are present and properly formatted."""
|
||||||
|
|
||||||
Following RFC 5322 header order and best practices for spam score reduction.
|
|
||||||
Optimized based on Gmail's header structure for better deliverability.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
content (str): Email content.
|
|
||||||
envelope: SMTP envelope.
|
|
||||||
message_id (str): Generated message ID.
|
|
||||||
custom_headers (list): List of (name, value) tuples for custom headers.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Email content with all required headers properly formatted.
|
|
||||||
"""
|
|
||||||
import email.utils
|
|
||||||
from email_server.settings_loader import load_settings
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
settings = load_settings()
|
lines = content.splitlines()
|
||||||
fallback_hostname = settings.get('Server', 'HOSTNAME', fallback='localhost')
|
for idx, line in enumerate(lines):
|
||||||
server_hostname = settings.get('Server', 'helo_hostname', fallback=fallback_hostname)
|
if not isinstance(line, str):
|
||||||
|
logger.error(f"_ensure_required_headers: Non-string line at index {idx}: {type(line)}: {line}")
|
||||||
logger.debug(f"Processing headers for message {message_id}")
|
logger.error(f"_ensure_required_headers: Full content object: {repr(content)}")
|
||||||
|
raise TypeError(f"_ensure_required_headers: Non-string line in content.splitlines(): {type(line)} at index {idx}")
|
||||||
# Parse the message properly
|
|
||||||
if isinstance(content, bytes):
|
|
||||||
content = content.decode('utf-8', errors='replace')
|
|
||||||
|
|
||||||
# Split content into lines and normalize line endings
|
|
||||||
lines = content.replace('\r\n', '\n').replace('\r', '\n').split('\n')
|
|
||||||
|
|
||||||
# Find header/body boundary and collect existing headers
|
# Find header/body boundary and collect existing headers
|
||||||
body_start = 0
|
body_start = 0
|
||||||
existing_headers = {}
|
existing_headers = {}
|
||||||
original_header_order = []
|
original_header_order = []
|
||||||
|
|
||||||
for i, line in enumerate(lines):
|
for i, line in enumerate(lines):
|
||||||
if line.strip() == '':
|
if line.strip() == '':
|
||||||
body_start = i + 1
|
body_start = i + 1
|
||||||
break
|
break
|
||||||
|
if not isinstance(line, str):
|
||||||
|
logger.error(f"_ensure_required_headers: Header line is not a string: {type(line)}: {line}")
|
||||||
|
continue
|
||||||
if ':' in line and not line.startswith((' ', '\t')):
|
if ':' in line and not line.startswith((' ', '\t')):
|
||||||
header_name, header_value = line.split(':', 1)
|
try:
|
||||||
|
header_name, header_value = line.split(':', 1)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"_ensure_required_headers: Failed to split header line: {line} - {e}")
|
||||||
|
continue
|
||||||
|
if not isinstance(header_name, str) or not isinstance(header_value, str):
|
||||||
|
logger.error(f"_ensure_required_headers: Non-string header_name or header_value: {type(header_name)}, {type(header_value)}: {header_name}, {header_value}")
|
||||||
|
continue
|
||||||
header_name_lower = header_name.strip().lower()
|
header_name_lower = header_name.strip().lower()
|
||||||
header_value = header_value.strip()
|
header_value = header_value.strip()
|
||||||
|
|
||||||
# Handle continuation lines
|
# Handle continuation lines
|
||||||
j = i + 1
|
j = i + 1
|
||||||
while j < len(lines) and lines[j].startswith((' ', '\t')):
|
while j < len(lines) and lines[j].startswith((' ', '\t')):
|
||||||
header_value += ' ' + lines[j].strip()
|
header_value += ' ' + lines[j].strip()
|
||||||
j += 1
|
j += 1
|
||||||
|
|
||||||
existing_headers[header_name_lower] = header_value
|
existing_headers[header_name_lower] = header_value
|
||||||
original_header_order.append((header_name.strip(), header_value))
|
original_header_order.append((header_name.strip(), header_value))
|
||||||
logger.debug(f"Found existing header: {header_name_lower} = {header_value}")
|
logger.debug(f"Found existing header: {header_name_lower} = {header_value}")
|
||||||
|
|
||||||
# Extract body and clean it
|
# Extract body and clean it
|
||||||
body_lines = lines[body_start:] if body_start < len(lines) else []
|
body_lines = lines[body_start:] if body_start < len(lines) else []
|
||||||
while body_lines and body_lines[-1].strip() == '':
|
while body_lines and body_lines[-1].strip() == '':
|
||||||
@@ -143,10 +153,26 @@ class EnhancedCustomSMTPHandler:
|
|||||||
|
|
||||||
# 1. Message-ID (critical for spam filters)
|
# 1. Message-ID (critical for spam filters)
|
||||||
if 'message-id' in existing_headers:
|
if 'message-id' in existing_headers:
|
||||||
required_headers.append(f"Message-ID: {existing_headers['message-id']}")
|
# Parse existing Message-ID
|
||||||
|
existing_msg_id = existing_headers['message-id'].strip('<>')
|
||||||
|
if '@' in existing_msg_id:
|
||||||
|
prefix, hostname = existing_msg_id.rsplit('@', 1)
|
||||||
|
hostname = hostname.rstrip('>')
|
||||||
|
if hostname.lower() != helo_hostname.lower():
|
||||||
|
# If hostname is wrong, modify it to use our hostname
|
||||||
|
message_id = f"{prefix}@{helo_hostname}"
|
||||||
|
else:
|
||||||
|
# If hostname is correct, keep original ID
|
||||||
|
message_id = existing_msg_id
|
||||||
|
else:
|
||||||
|
# Malformed Message-ID, generate new one
|
||||||
|
message_id = generate_message_id()
|
||||||
else:
|
else:
|
||||||
domain = envelope.mail_from.split('@')[1] if '@' in envelope.mail_from else server_hostname.replace('mail.', '')
|
# No Message-ID found, generate new one
|
||||||
required_headers.append(f"Message-ID: <{message_id}@{domain}>")
|
message_id = generate_message_id()
|
||||||
|
|
||||||
|
# Add the Message-ID header with the final ID
|
||||||
|
required_headers.append(f"Message-ID: <{message_id}>")
|
||||||
|
|
||||||
# 2. Date (critical for spam filters)
|
# 2. Date (critical for spam filters)
|
||||||
if 'date' in existing_headers:
|
if 'date' in existing_headers:
|
||||||
@@ -161,75 +187,49 @@ class EnhancedCustomSMTPHandler:
|
|||||||
else:
|
else:
|
||||||
required_headers.append("MIME-Version: 1.0")
|
required_headers.append("MIME-Version: 1.0")
|
||||||
|
|
||||||
# 4. User-Agent (if present, helps with reputation)
|
# 4. To (primary recipients - critical)
|
||||||
if 'user-agent' in existing_headers:
|
|
||||||
required_headers.append(f"User-Agent: {existing_headers['user-agent']}")
|
|
||||||
|
|
||||||
# 5. Content-Language (if present)
|
|
||||||
if 'content-language' in existing_headers:
|
|
||||||
required_headers.append(f"Content-Language: {existing_headers['content-language']}")
|
|
||||||
|
|
||||||
# 6. To (primary recipients - critical)
|
|
||||||
if 'to' in existing_headers:
|
if 'to' in existing_headers:
|
||||||
required_headers.append(f"To: {existing_headers['to']}")
|
required_headers.append(f"To: {existing_headers['to']}")
|
||||||
else:
|
else:
|
||||||
to_list = ', '.join(envelope.rcpt_tos)
|
required_headers.append(f"To: {', '.join([rcpt for rcpt in envelope.rcpt_tos])}")
|
||||||
required_headers.append(f"To: {to_list}")
|
|
||||||
|
# 5. Cc (if present)
|
||||||
|
if 'cc' in existing_headers:
|
||||||
|
required_headers.append(f"Cc: {existing_headers['cc']}")
|
||||||
|
|
||||||
# 7. From (sender identification - critical)
|
# 6. From (sender identification - critical)
|
||||||
if 'from' in existing_headers:
|
if 'from' in existing_headers:
|
||||||
required_headers.append(f"From: {existing_headers['from']}")
|
required_headers.append(f"From: {existing_headers['from']}")
|
||||||
else:
|
else:
|
||||||
required_headers.append(f"From: {envelope.mail_from}")
|
required_headers.append(f"From: {envelope.mail_from}")
|
||||||
|
|
||||||
# 8. Subject (message topic - critical)
|
# 7. Subject (message topic - critical)
|
||||||
if 'subject' in existing_headers:
|
if 'subject' in existing_headers:
|
||||||
required_headers.append(f"Subject: {existing_headers['subject']}")
|
required_headers.append(f"Subject: {existing_headers['subject']}")
|
||||||
else:
|
else:
|
||||||
required_headers.append("Subject: ")
|
required_headers.append("Subject: ")
|
||||||
|
|
||||||
# 9. Content-Type (media type information)
|
# 8. Content-Type (media type information)
|
||||||
if 'content-type' in existing_headers:
|
if 'content-type' in existing_headers:
|
||||||
required_headers.append(f"Content-Type: {existing_headers['content-type']}")
|
required_headers.append(f"Content-Type: {existing_headers['content-type']}")
|
||||||
else:
|
else:
|
||||||
required_headers.append("Content-Type: text/plain; charset=UTF-8; format=flowed")
|
required_headers.append("Content-Type: text/plain; charset=UTF-8; format=flowed")
|
||||||
|
|
||||||
# 10. Content-Transfer-Encoding
|
# 9. Content-Transfer-Encoding
|
||||||
if 'content-transfer-encoding' in existing_headers:
|
if 'content-transfer-encoding' in existing_headers:
|
||||||
required_headers.append(f"Content-Transfer-Encoding: {existing_headers['content-transfer-encoding']}")
|
required_headers.append(f"Content-Transfer-Encoding: {existing_headers['content-transfer-encoding']}")
|
||||||
else:
|
else:
|
||||||
required_headers.append("Content-Transfer-Encoding: 7bit")
|
required_headers.append("Content-Transfer-Encoding: 7bit")
|
||||||
|
|
||||||
# Add custom headers after essential headers but before misc headers
|
# Add custom headers after essential headers
|
||||||
if custom_headers:
|
if custom_headers:
|
||||||
for header_name, header_value in custom_headers:
|
for header_name, header_value in custom_headers:
|
||||||
# Skip if already added in essential headers
|
header_name_lower = header_name.lower()
|
||||||
if header_name.lower() not in ['message-id', 'date', 'mime-version', 'user-agent',
|
# Skip if header already exists
|
||||||
'content-language', 'to', 'from', 'subject',
|
if header_name_lower not in existing_headers:
|
||||||
'content-type', 'content-transfer-encoding']:
|
|
||||||
required_headers.append(f"{header_name}: {header_value}")
|
required_headers.append(f"{header_name}: {header_value}")
|
||||||
logger.debug(f"Added custom header: {header_name}: {header_value}")
|
logger.debug(f"Added custom header: {header_name}: {header_value}")
|
||||||
|
|
||||||
# Add any other existing headers that weren't handled above
|
|
||||||
essential_headers = {
|
|
||||||
'message-id', 'date', 'from', 'to', 'subject',
|
|
||||||
'mime-version', 'content-type', 'content-transfer-encoding',
|
|
||||||
'user-agent', 'content-language'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Preserve original header names and values for non-essential headers
|
|
||||||
for header_name, header_value in original_header_order:
|
|
||||||
if header_name.lower() not in essential_headers:
|
|
||||||
# Skip custom headers we already added
|
|
||||||
skip = False
|
|
||||||
if custom_headers:
|
|
||||||
for custom_name, _ in custom_headers:
|
|
||||||
if header_name.lower() == custom_name.lower():
|
|
||||||
skip = True
|
|
||||||
break
|
|
||||||
if not skip:
|
|
||||||
required_headers.append(f"{header_name}: {header_value}")
|
|
||||||
|
|
||||||
# Build final message
|
# Build final message
|
||||||
final_content = '\r\n'.join(required_headers)
|
final_content = '\r\n'.join(required_headers)
|
||||||
if body.strip():
|
if body.strip():
|
||||||
@@ -237,91 +237,347 @@ class EnhancedCustomSMTPHandler:
|
|||||||
else:
|
else:
|
||||||
final_content += '\r\n\r\n'
|
final_content += '\r\n\r\n'
|
||||||
|
|
||||||
logger.debug(f"Final headers for message {message_id}:")
|
|
||||||
for header in required_headers:
|
|
||||||
logger.debug(f" {header}")
|
|
||||||
|
|
||||||
return final_content
|
return final_content
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error ensuring headers: {e}")
|
|
||||||
import traceback
|
import traceback
|
||||||
|
logger.error(f"Error ensuring headers: {e}")
|
||||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||||
|
logger.error(f"Locals: {locals()}")
|
||||||
# Fallback to original content if parsing fails
|
# Fallback to original content if parsing fails
|
||||||
return content
|
return content
|
||||||
|
|
||||||
async def handle_DATA(self, server, session, envelope):
|
async def handle_DATA(self, server, session, envelope):
|
||||||
"""Handle incoming email data with improved header management."""
|
"""Handle incoming email data with improved header management and logging."""
|
||||||
try:
|
try:
|
||||||
message_id = str(uuid.uuid4())
|
|
||||||
logger.debug(f'Received email {message_id} from {envelope.mail_from} to {envelope.rcpt_tos}')
|
|
||||||
|
|
||||||
# Convert content to string if it's bytes
|
# Convert content to string if it's bytes
|
||||||
if isinstance(envelope.content, bytes):
|
if isinstance(envelope.content, bytes):
|
||||||
content = envelope.content.decode('utf-8', errors='replace')
|
content = envelope.content.decode('utf-8', errors='replace')
|
||||||
else:
|
else:
|
||||||
content = envelope.content
|
content = envelope.content
|
||||||
|
|
||||||
|
# Extract Message-ID from the content
|
||||||
|
for line in content.splitlines():
|
||||||
|
if line.lower().startswith('message-id:'):
|
||||||
|
message_id_extracted = line[11:].strip().strip('<>') # Remove "Message-ID:" and brackets
|
||||||
|
if '@' in message_id_extracted:
|
||||||
|
prefix, hostname = message_id_extracted.rsplit('@', 1)
|
||||||
|
hostname = hostname.rstrip('>')
|
||||||
|
if hostname.lower() != helo_hostname.lower():
|
||||||
|
# If hostname is wrong, modify it to use our hostname
|
||||||
|
message_id = f"{prefix}@{helo_hostname}"
|
||||||
|
else:
|
||||||
|
# If hostname is correct, keep original ID
|
||||||
|
message_id = message_id_extracted
|
||||||
|
break
|
||||||
|
|
||||||
|
logger.debug(f'Processing email with ID: {message_id} from {envelope.mail_from} to {envelope.rcpt_tos}')
|
||||||
|
|
||||||
|
# Get authenticated username from session
|
||||||
|
username = getattr(session, 'username', None)
|
||||||
|
if not username:
|
||||||
|
# Check if IP authentication was used
|
||||||
|
client_ip = getattr(session, 'peer', ['unknown'])[0].split(':')[0] if hasattr(session, 'peer') else None
|
||||||
|
if client_ip:
|
||||||
|
from email_server.models import get_whitelisted_ip
|
||||||
|
sender_domain = envelope.mail_from.split('@')[1] if '@' in envelope.mail_from else None
|
||||||
|
ip_auth = get_whitelisted_ip(client_ip, sender_domain)
|
||||||
|
if ip_auth:
|
||||||
|
username = f"IP:{client_ip}"
|
||||||
|
|
||||||
|
logger.debug(f'Authenticated username: {username}')
|
||||||
|
|
||||||
|
# Convert content to string if it's bytes
|
||||||
|
if isinstance(envelope.content, bytes):
|
||||||
|
content = envelope.content.decode('utf-8', errors='replace')
|
||||||
|
raw_bytes = envelope.content
|
||||||
|
else:
|
||||||
|
content = envelope.content
|
||||||
|
raw_bytes = envelope.content.encode('utf-8', errors='replace')
|
||||||
|
|
||||||
# Extract domain from sender for DKIM signing
|
# Extract domain from sender for DKIM signing
|
||||||
sender_domain = envelope.mail_from.split('@')[1] if '@' in envelope.mail_from else None
|
sender_domain = envelope.mail_from.split('@')[1] if '@' in envelope.mail_from else None
|
||||||
|
|
||||||
# Get custom headers before processing
|
# Get custom headers before processing
|
||||||
custom_headers = []
|
custom_headers = []
|
||||||
if sender_domain:
|
if sender_domain:
|
||||||
custom_headers = self.dkim_manager.get_active_custom_headers(sender_domain)
|
custom_headers = self.dkim_manager.get_active_custom_headers(sender_domain)
|
||||||
|
|
||||||
# Add beneficial headers for spam score improvement
|
# Add beneficial headers for spam score improvement
|
||||||
client_ip = getattr(session, 'peer', ['unknown'])[0] if hasattr(session, 'peer') else None
|
client_ip = getattr(session, 'peer', ['unknown'])[0] if hasattr(session, 'peer') else None
|
||||||
if client_ip:
|
if client_ip:
|
||||||
# Add X-Originating-IP header (helps with reputation)
|
|
||||||
custom_headers.append(('X-Originating-IP', f'[{client_ip}]'))
|
custom_headers.append(('X-Originating-IP', f'[{client_ip}]'))
|
||||||
|
|
||||||
# Add X-Mailer header for identification
|
|
||||||
custom_headers.append(('X-Mailer', 'NetBro Mail Server 1.0'))
|
custom_headers.append(('X-Mailer', 'NetBro Mail Server 1.0'))
|
||||||
|
|
||||||
# Add X-Priority header (normal priority)
|
|
||||||
custom_headers.append(('X-Priority', '3'))
|
custom_headers.append(('X-Priority', '3'))
|
||||||
|
|
||||||
# Ensure required headers are present (including custom headers)
|
# Ensure required headers are present (including custom headers)
|
||||||
content = self._ensure_required_headers(content, envelope, message_id, custom_headers)
|
content = self._ensure_required_headers(content, envelope, message_id, custom_headers)
|
||||||
|
|
||||||
# DKIM-sign the final version of the message (only once, after all modifications)
|
# DKIM-sign the final version of the message (only once, after all modifications)
|
||||||
signed_content = content
|
signed_content = content
|
||||||
dkim_signed = False
|
dkim_signed = False
|
||||||
if sender_domain:
|
if sender_domain:
|
||||||
signed_content = self.dkim_manager.sign_email(content, sender_domain)
|
signed_content = self.dkim_manager.sign_email(content, sender_domain)
|
||||||
|
if not isinstance(signed_content, (str, bytes)):
|
||||||
|
logger.error(f"DKIMManager.sign_email returned non-str/bytes: {type(signed_content)}: {signed_content}")
|
||||||
|
raise TypeError(f"DKIMManager.sign_email returned non-str/bytes: {type(signed_content)}")
|
||||||
dkim_signed = signed_content != content
|
dkim_signed = signed_content != content
|
||||||
if dkim_signed:
|
if dkim_signed:
|
||||||
logger.debug(f'Email {message_id} signed with DKIM for domain {sender_domain}')
|
logger.debug(f'Email {message_id} signed with DKIM for domain {sender_domain}')
|
||||||
|
|
||||||
|
# Extract headers for logging
|
||||||
|
to_address = ''
|
||||||
|
cc_addresses = ''
|
||||||
|
bcc_addresses = ''
|
||||||
|
subject = ''
|
||||||
|
split_lines = content.splitlines()
|
||||||
|
for idx, line in enumerate(split_lines):
|
||||||
|
if not isinstance(line, str):
|
||||||
|
logger.error(f"DIAGNOSTIC: Non-string line at index {idx}: {type(line)}: {line}")
|
||||||
|
logger.error(f"DIAGNOSTIC: Full content object: {repr(content)}")
|
||||||
|
raise TypeError(f"DIAGNOSTIC: Non-string line in content.splitlines(): {type(line)} at index {idx}")
|
||||||
|
try:
|
||||||
|
|
||||||
|
for line in split_lines:
|
||||||
|
if line.strip() == '':
|
||||||
|
break
|
||||||
|
if not isinstance(line, str):
|
||||||
|
logger.error(f"Header line is not a string: {type(line)}: {line}")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
lower_line = line.lower()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to call lower() on line: {line} (type: {type(line)}) - {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
logger.error(f"Full content.splitlines(): {split_lines}")
|
||||||
|
continue
|
||||||
|
if lower_line.startswith('to:'):
|
||||||
|
to_address = line[3:].strip()
|
||||||
|
elif lower_line.startswith('cc:'):
|
||||||
|
cc_addresses = line[3:].strip()
|
||||||
|
elif lower_line.startswith('subject:'):
|
||||||
|
subject = line[8:].strip()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Exception in header extraction loop: {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
logger.error(f"Full content.splitlines(): {split_lines}")
|
||||||
|
|
||||||
|
# Check if message content should be stored (sender or IP whitelist)
|
||||||
|
from email_server.models import get_sender_by_email, get_whitelisted_ip
|
||||||
|
store_message = False
|
||||||
|
sender_obj = get_sender_by_email(envelope.mail_from)
|
||||||
|
if sender_obj and getattr(sender_obj, 'store_message_content', False):
|
||||||
|
store_message = True
|
||||||
|
elif client_ip:
|
||||||
|
domain_name = sender_domain
|
||||||
|
ip_obj = get_whitelisted_ip(client_ip, domain_name)
|
||||||
|
if ip_obj and getattr(ip_obj, 'store_message_content', False):
|
||||||
|
store_message = True
|
||||||
|
|
||||||
|
attachments_to_save = []
|
||||||
|
# Get attachments path from settings
|
||||||
|
attachments_path = settings['Attachments'].get('attachments_path', 'email_server/server_data/attachments')
|
||||||
|
saved_attachments = []
|
||||||
|
logger.debug(f"Using attachments base path: {attachments_path}")
|
||||||
|
email_log_id = None
|
||||||
|
|
||||||
|
if store_message:
|
||||||
|
# Parse the message for attachments using the email library
|
||||||
|
msg = BytesParser(policy=policy.default).parsebytes(raw_bytes)
|
||||||
|
if msg.is_multipart():
|
||||||
|
# Get storage path for this sender
|
||||||
|
storage_path = self.get_attachment_storage_path(
|
||||||
|
attachments_base_path=attachments_path,
|
||||||
|
sender_domain=sender_domain,
|
||||||
|
username=username,
|
||||||
|
client_ip=client_ip
|
||||||
|
)
|
||||||
|
ensure_folder_exists(storage_path)
|
||||||
|
|
||||||
|
for part in msg.walk():
|
||||||
|
content_disposition = part.get_content_disposition()
|
||||||
|
if content_disposition == 'attachment':
|
||||||
|
filename = part.get_filename()
|
||||||
|
if not filename:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get file data and validate
|
||||||
|
file_data = part.get_payload(decode=True)
|
||||||
|
if not file_data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get proper content type
|
||||||
|
content_type = self.get_content_type(part, filename)
|
||||||
|
size = len(file_data)
|
||||||
|
|
||||||
|
# Strip @domain from message_id for filename
|
||||||
|
clean_message_id = message_id.split('@')[0] if '@' in message_id else message_id
|
||||||
|
|
||||||
|
# Build a unique file path
|
||||||
|
safe_filename = f"{clean_message_id}_{filename}"
|
||||||
|
file_path = os.path.join(storage_path, safe_filename)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Ensure the directory exists before saving
|
||||||
|
ensure_folder_exists(file_path)
|
||||||
|
|
||||||
|
# Save the file
|
||||||
|
with open(file_path, 'wb') as f:
|
||||||
|
f.write(file_data)
|
||||||
|
logger.debug(f"Saved attachment {filename} ({content_type}) to {file_path}")
|
||||||
|
|
||||||
|
attachments_to_save.append({
|
||||||
|
'filename': filename,
|
||||||
|
'content_type': content_type,
|
||||||
|
'file_path': file_path,
|
||||||
|
'size': size
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to save attachment {filename}: {str(e)}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Parse addresses to determine recipient types
|
||||||
|
def parse_addresses(addr_str):
|
||||||
|
if not isinstance(addr_str, str):
|
||||||
|
logger.warning(f"Expected string for address header, got {type(addr_str)}: {addr_str}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
return [addr.strip().lower() for addr in addr_str.split(',') if isinstance(addr, str) and addr.strip()]
|
||||||
|
|
||||||
# Relay the email (no further modifications allowed)
|
to_list = parse_addresses(to_address)
|
||||||
success = self.email_relay.relay_email(
|
cc_list = parse_addresses(cc_addresses)
|
||||||
envelope.mail_from,
|
|
||||||
envelope.rcpt_tos,
|
# Map recipients to their types based on headers
|
||||||
signed_content
|
recipient_type_map = {}
|
||||||
|
for rcpt in envelope.rcpt_tos:
|
||||||
|
if not isinstance(rcpt, str):
|
||||||
|
logger.warning(f"Expected string for recipient, got {type(rcpt)}: {rcpt}")
|
||||||
|
continue
|
||||||
|
rcpt_l = rcpt.lower()
|
||||||
|
if rcpt_l in to_list:
|
||||||
|
recipient_type_map[rcpt] = 'to'
|
||||||
|
elif rcpt_l in cc_list:
|
||||||
|
recipient_type_map[rcpt] = 'cc'
|
||||||
|
else:
|
||||||
|
recipient_type_map[rcpt] = 'bcc' # Any recipient not in To/Cc is a Bcc
|
||||||
|
|
||||||
|
# Build recipient results
|
||||||
|
recipient_results = []
|
||||||
|
recipient_types = []
|
||||||
|
for rcpt in envelope.rcpt_tos:
|
||||||
|
rtype = recipient_type_map[rcpt]
|
||||||
|
recipient_results.append({'recipient': rcpt, 'recipient_type': rtype, 'status': 'pending'})
|
||||||
|
recipient_types.append(rtype)
|
||||||
|
|
||||||
|
# Relay the email and get per-recipient results
|
||||||
|
relay_results = await self.email_relay.relay_email_async(
|
||||||
|
envelope.mail_from,
|
||||||
|
envelope.rcpt_tos,
|
||||||
|
signed_content,
|
||||||
|
username=username,
|
||||||
|
cc_addresses=cc_addresses,
|
||||||
|
bcc_addresses=None, # BCC addresses are handled through envelope.rcpt_tos
|
||||||
|
recipient_types=recipient_types
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Update status in recipient_results
|
||||||
|
for result in relay_results:
|
||||||
|
for r in recipient_results:
|
||||||
|
if r['recipient'] == result['recipient'] and r['recipient_type'] == result.get('recipient_type', 'to'):
|
||||||
|
r.update(result)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Determine overall status
|
||||||
|
status = 'relayed' if all(r['status'] == 'success' for r in recipient_results) else 'failed'
|
||||||
|
|
||||||
|
# Extract headers and parse message content
|
||||||
|
msg = BytesParser(policy=policy.default).parsebytes(raw_bytes)
|
||||||
|
|
||||||
# Log the email
|
# Extract headers
|
||||||
status = 'relayed' if success else 'failed'
|
email_headers = []
|
||||||
|
for name, value in msg.items():
|
||||||
|
email_headers.append(f"{name}: {value}")
|
||||||
|
email_headers = '\n'.join(email_headers)
|
||||||
|
|
||||||
|
# Extract only the text content, not attachments
|
||||||
|
message_body = ""
|
||||||
|
if msg.is_multipart():
|
||||||
|
for part in msg.walk():
|
||||||
|
if part.get_content_maintype() == 'text' and part.get_content_disposition() is None:
|
||||||
|
# This is likely the main message text
|
||||||
|
charset = part.get_content_charset() or 'utf-8'
|
||||||
|
try:
|
||||||
|
part_content = part.get_payload(decode=True).decode(charset)
|
||||||
|
message_body += part_content + "\n"
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to decode message part: {e}")
|
||||||
|
else:
|
||||||
|
# Not multipart - if it's text, use it as is
|
||||||
|
if msg.get_content_maintype() == 'text':
|
||||||
|
charset = msg.get_content_charset() or 'utf-8'
|
||||||
|
try:
|
||||||
|
message_body = msg.get_payload(decode=True).decode(charset)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to decode message: {e}")
|
||||||
|
|
||||||
|
# Trim any extra whitespace
|
||||||
|
message_body = message_body.strip()
|
||||||
|
|
||||||
|
# Get client IP without port
|
||||||
|
client_ip = getattr(session, 'peer', ['unknown'])[0].split(':')[0] if hasattr(session, 'peer') else 'unknown'
|
||||||
|
|
||||||
|
# Log the email with all details
|
||||||
self.email_relay.log_email(
|
self.email_relay.log_email(
|
||||||
message_id=message_id,
|
message_id=message_id,
|
||||||
peer=session.peer,
|
peer=client_ip,
|
||||||
mail_from=envelope.mail_from,
|
mail_from=envelope.mail_from,
|
||||||
rcpt_tos=envelope.rcpt_tos,
|
to_address=to_address,
|
||||||
content=content, # Log original content, not signed
|
cc_addresses=cc_addresses,
|
||||||
|
bcc_addresses=', '.join([r['recipient'] for r in recipient_results if r['recipient_type'] == 'bcc']),
|
||||||
|
subject=subject,
|
||||||
|
email_headers=email_headers,
|
||||||
|
message_body=message_body,
|
||||||
status=status,
|
status=status,
|
||||||
dkim_signed=dkim_signed
|
dkim_signed=dkim_signed,
|
||||||
|
username=username,
|
||||||
|
recipient_results=recipient_results
|
||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
# Save attachments to DB, linked to the correct EmailLog
|
||||||
|
if attachments_to_save:
|
||||||
|
db_session = Session()
|
||||||
|
try:
|
||||||
|
email_log = db_session.query(EmailLog).filter_by(message_id=message_id).first()
|
||||||
|
if email_log:
|
||||||
|
for att in attachments_to_save:
|
||||||
|
attachment = EmailAttachment(
|
||||||
|
email_log_id=email_log.id,
|
||||||
|
filename=att['filename'],
|
||||||
|
content_type=att['content_type'],
|
||||||
|
file_path=att['file_path'],
|
||||||
|
size=att['size']
|
||||||
|
)
|
||||||
|
db_session.add(attachment)
|
||||||
|
db_session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to save attachments to DB: {e}")
|
||||||
|
db_session.rollback()
|
||||||
|
finally:
|
||||||
|
db_session.close()
|
||||||
|
|
||||||
|
if status == 'relayed':
|
||||||
logger.debug(f'Email {message_id} successfully relayed')
|
logger.debug(f'Email {message_id} successfully relayed')
|
||||||
return '250 Message accepted for delivery'
|
return '250 Message accepted for delivery'
|
||||||
else:
|
else:
|
||||||
logger.error(f'Email {message_id} failed to relay')
|
logger.error(f'Email {message_id} failed to relay')
|
||||||
return '550 Message relay failed'
|
return '550 Message relay failed'
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
logger.error(f'Error handling email: {e}')
|
logger.error(f'Error handling email: {e}')
|
||||||
|
logger.error(f'Traceback: {traceback.format_exc()}')
|
||||||
|
logger.error(f'Locals: {locals()}')
|
||||||
return '550 Internal server error'
|
return '550 Internal server error'
|
||||||
|
|
||||||
async def handle_RCPT(self, server, session, envelope, address, rcpt_options):
|
async def handle_RCPT(self, server, session, envelope, address, rcpt_options):
|
||||||
@@ -352,10 +608,80 @@ class EnhancedCustomSMTPHandler:
|
|||||||
logger.info(f'MAIL FROM accepted: {address} - {message}')
|
logger.info(f'MAIL FROM accepted: {address} - {message}')
|
||||||
return '250 OK'
|
return '250 OK'
|
||||||
|
|
||||||
|
def get_attachment_storage_path(self, attachments_base_path: str, sender_domain: str, username: str = None, client_ip: str = None) -> str:
|
||||||
|
"""Generate the storage path for attachments based on sender domain, authentication, and date.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
attachments_base_path: Base path for attachments storage
|
||||||
|
sender_domain: Domain of the sender
|
||||||
|
username: Authenticated username (if any)
|
||||||
|
client_ip: Client IP address (if IP-based authentication)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Full path where attachments should be stored, format:
|
||||||
|
base/domain/[username|ip]/YYYY-DD-MMM/
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Get current date in YYYY-DD-MMM format using consistent time function
|
||||||
|
current_date = get_current_time().strftime('%Y-%d-%b') # e.g., 2025-14-Jun
|
||||||
|
|
||||||
|
# Sanitize domain name for folder name
|
||||||
|
safe_domain = sender_domain.replace('/', '_').replace('\\', '_')
|
||||||
|
domain_path = os.path.join(attachments_base_path, safe_domain)
|
||||||
|
|
||||||
|
# Determine auth-based subfolder path
|
||||||
|
if username:
|
||||||
|
# Sanitize username for folder name
|
||||||
|
safe_username = username.replace('/', '_').replace('\\', '_')
|
||||||
|
auth_path = os.path.join(domain_path, safe_username)
|
||||||
|
elif client_ip:
|
||||||
|
# Sanitize IP for folder name
|
||||||
|
safe_ip = client_ip.replace(':', '_')
|
||||||
|
auth_path = os.path.join(domain_path, safe_ip)
|
||||||
|
else:
|
||||||
|
# Fallback to domain-only path
|
||||||
|
auth_path = domain_path
|
||||||
|
|
||||||
|
# Add date-based subfolder
|
||||||
|
return os.path.join(auth_path, current_date)
|
||||||
|
|
||||||
|
def get_content_type(self, part, filename):
|
||||||
|
"""Get the correct content type for a file, trying multiple methods."""
|
||||||
|
|
||||||
|
# First try the part's content type
|
||||||
|
content_type = part.get_content_type()
|
||||||
|
|
||||||
|
# If it's octet-stream, try to guess from filename
|
||||||
|
if content_type == 'application/octet-stream':
|
||||||
|
guessed_type, _ = mimetypes.guess_type(filename)
|
||||||
|
if guessed_type:
|
||||||
|
content_type = guessed_type
|
||||||
|
else:
|
||||||
|
# Use specific types for common extensions
|
||||||
|
ext = filename.lower().split('.')[-1] if '.' in filename else ''
|
||||||
|
type_map = {
|
||||||
|
'txt': 'text/plain',
|
||||||
|
'csv': 'text/csv',
|
||||||
|
'jpg': 'image/jpeg',
|
||||||
|
'jpeg': 'image/jpeg',
|
||||||
|
'png': 'image/png',
|
||||||
|
'gif': 'image/gif',
|
||||||
|
'pdf': 'application/pdf',
|
||||||
|
'json': 'application/json',
|
||||||
|
'xml': 'application/xml',
|
||||||
|
'html': 'text/html',
|
||||||
|
'htm': 'text/html',
|
||||||
|
}
|
||||||
|
content_type = type_map.get(ext, 'application/octet-stream')
|
||||||
|
|
||||||
|
return content_type
|
||||||
|
|
||||||
class TLSController(Controller):
|
class TLSController(Controller):
|
||||||
"""Custom controller with TLS support - modeled after the working original."""
|
"""
|
||||||
|
Custom controller for direct TLS (SMTPS, port 465) support.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, handler, ssl_context, hostname='localhost', port=40587):
|
def __init__(self, handler, ssl_context, hostname='localhost', port=40465):
|
||||||
logger.debug(f"TLSController __init__: ssl_context={ssl_context is not None}")
|
logger.debug(f"TLSController __init__: ssl_context={ssl_context is not None}")
|
||||||
self._ssl_context = ssl_context # Use private attribute to avoid conflicts
|
self._ssl_context = ssl_context # Use private attribute to avoid conflicts
|
||||||
self.smtp_hostname = hostname # Store for HELO identification
|
self.smtp_hostname = hostname # Store for HELO identification
|
||||||
@@ -365,10 +691,11 @@ class TLSController(Controller):
|
|||||||
logger.debug(f"TLSController factory: ssl_context={self._ssl_context is not None}")
|
logger.debug(f"TLSController factory: ssl_context={self._ssl_context is not None}")
|
||||||
logger.debug(f"TLSController factory: ssl_context object={self._ssl_context}")
|
logger.debug(f"TLSController factory: ssl_context object={self._ssl_context}")
|
||||||
logger.debug(f"TLSController factory: hostname={self.smtp_hostname}")
|
logger.debug(f"TLSController factory: hostname={self.smtp_hostname}")
|
||||||
|
# This is direct TLS (SMTPS, port 465 style)
|
||||||
smtp_instance = CustomSMTP(
|
smtp_instance = CustomSMTP(
|
||||||
self.handler,
|
self.handler,
|
||||||
tls_context=self._ssl_context,
|
tls_context=self._ssl_context,
|
||||||
require_starttls=False, # Don't require STARTTLS immediately, but make it available
|
require_starttls=False, # Direct TLS: do not advertise or require STARTTLS
|
||||||
auth_require_tls=True, # If auth is used, require TLS
|
auth_require_tls=True, # If auth is used, require TLS
|
||||||
authenticator=self.handler.combined_authenticator,
|
authenticator=self.handler.combined_authenticator,
|
||||||
decode_data=True,
|
decode_data=True,
|
||||||
@@ -378,17 +705,18 @@ class TLSController(Controller):
|
|||||||
return smtp_instance
|
return smtp_instance
|
||||||
|
|
||||||
class PlainController(Controller):
|
class PlainController(Controller):
|
||||||
"""Controller for plain SMTP with username/password and IP-based authentication."""
|
"""Controller for plain SMTP with authentication and IP whitelist fallback."""
|
||||||
|
|
||||||
def __init__(self, handler, hostname='localhost', port=4025):
|
def __init__(self, handler, hostname='localhost', port=4025):
|
||||||
self.smtp_hostname = hostname # Store for HELO identification
|
self.smtp_hostname = hostname # Store for HELO identification
|
||||||
super().__init__(handler, hostname='0.0.0.0', port=port) # Bind to all interfaces
|
super().__init__(handler, hostname='0.0.0.0', port=port) # Bind to all interfaces
|
||||||
|
|
||||||
def factory(self):
|
def factory(self):
|
||||||
|
# Pass authenticator and set auth_require_tls=False to enable AUTH on plain port
|
||||||
return CustomSMTP(
|
return CustomSMTP(
|
||||||
self.handler,
|
self.handler,
|
||||||
authenticator=self.handler.combined_authenticator,
|
authenticator=self.handler.combined_authenticator,
|
||||||
auth_require_tls=False, # Allow AUTH over plain text (not recommended for production)
|
auth_require_tls=False, # Allow AUTH on plain port
|
||||||
decode_data=True,
|
decode_data=True,
|
||||||
hostname=self.smtp_hostname # Use proper hostname for HELO
|
hostname=self.smtp_hostname # Use proper hostname for HELO
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,8 +5,13 @@ Utility functions for the email server.
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from email_server.settings_loader import load_settings
|
from email_server.settings_loader import load_settings
|
||||||
|
from datetime import datetime
|
||||||
|
import pytz
|
||||||
|
import time
|
||||||
|
import random
|
||||||
|
|
||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
|
helo_hostname = settings['Server'].get('helo_hostname', settings['Server'].get('hostname', 'localhost'))
|
||||||
|
|
||||||
def ensure_folder_exists(filepath):
|
def ensure_folder_exists(filepath):
|
||||||
"""
|
"""
|
||||||
@@ -56,4 +61,19 @@ def get_logger(name=None):
|
|||||||
name = name if ext == '.py' else base
|
name = name if ext == '.py' else base
|
||||||
else:
|
else:
|
||||||
name = '__main__'
|
name = '__main__'
|
||||||
return logging.getLogger(name)
|
return logging.getLogger(name)
|
||||||
|
|
||||||
|
def get_current_time():
|
||||||
|
"""Get current time with timezone from settings."""
|
||||||
|
timezone = pytz.timezone(settings['Server'].get('time_zone', 'UTC'))
|
||||||
|
return datetime.now(timezone)
|
||||||
|
|
||||||
|
def generate_message_id(hostname=helo_hostname) -> str:
|
||||||
|
"""Generate a consistent Message-ID for both email headers and database storage.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Message-ID in format YYYYMMDDhhmmss.RANDOM@hostname without brackets
|
||||||
|
"""
|
||||||
|
timestamp = time.strftime('%Y%m%d%H%M%S')
|
||||||
|
random_id = ''.join([str(random.randint(0, 9)) for _ in range(6)])
|
||||||
|
return f"{timestamp}.{random_id}@{hostname}"
|
||||||
@@ -1 +0,0 @@
|
|||||||
Single-database configuration for Flask.
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- Migration: Add EmailAttachment table for storing email attachments on disk
|
||||||
|
CREATE TABLE IF NOT EXISTS esrv_email_attachments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email_log_id INTEGER NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
content_type TEXT,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
size INTEGER,
|
||||||
|
uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(email_log_id) REFERENCES esrv_email_logs(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
# A generic, single database configuration.
|
|
||||||
|
|
||||||
[alembic]
|
|
||||||
# template used to generate migration files
|
|
||||||
# file_template = %%(rev)s_%%(slug)s
|
|
||||||
|
|
||||||
# set to 'true' to run the environment during
|
|
||||||
# the 'revision' command, regardless of autogenerate
|
|
||||||
# revision_environment = false
|
|
||||||
|
|
||||||
|
|
||||||
# Logging configuration
|
|
||||||
[loggers]
|
|
||||||
keys = root,sqlalchemy,alembic,flask_migrate
|
|
||||||
|
|
||||||
[handlers]
|
|
||||||
keys = console
|
|
||||||
|
|
||||||
[formatters]
|
|
||||||
keys = generic
|
|
||||||
|
|
||||||
[logger_root]
|
|
||||||
level = WARN
|
|
||||||
handlers = console
|
|
||||||
qualname =
|
|
||||||
|
|
||||||
[logger_sqlalchemy]
|
|
||||||
level = WARN
|
|
||||||
handlers =
|
|
||||||
qualname = sqlalchemy.engine
|
|
||||||
|
|
||||||
[logger_alembic]
|
|
||||||
level = INFO
|
|
||||||
handlers =
|
|
||||||
qualname = alembic
|
|
||||||
|
|
||||||
[logger_flask_migrate]
|
|
||||||
level = INFO
|
|
||||||
handlers =
|
|
||||||
qualname = flask_migrate
|
|
||||||
|
|
||||||
[handler_console]
|
|
||||||
class = StreamHandler
|
|
||||||
args = (sys.stderr,)
|
|
||||||
level = NOTSET
|
|
||||||
formatter = generic
|
|
||||||
|
|
||||||
[formatter_generic]
|
|
||||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
||||||
datefmt = %H:%M:%S
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import logging
|
|
||||||
from logging.config import fileConfig
|
|
||||||
|
|
||||||
from flask import current_app
|
|
||||||
|
|
||||||
from alembic import context
|
|
||||||
|
|
||||||
# this is the Alembic Config object, which provides
|
|
||||||
# access to the values within the .ini file in use.
|
|
||||||
config = context.config
|
|
||||||
|
|
||||||
# Interpret the config file for Python logging.
|
|
||||||
# This line sets up loggers basically.
|
|
||||||
fileConfig(config.config_file_name)
|
|
||||||
logger = logging.getLogger('alembic.env')
|
|
||||||
|
|
||||||
|
|
||||||
def get_engine():
|
|
||||||
try:
|
|
||||||
# this works with Flask-SQLAlchemy<3 and Alchemical
|
|
||||||
return current_app.extensions['migrate'].db.get_engine()
|
|
||||||
except (TypeError, AttributeError):
|
|
||||||
# this works with Flask-SQLAlchemy>=3
|
|
||||||
return current_app.extensions['migrate'].db.engine
|
|
||||||
|
|
||||||
|
|
||||||
def get_engine_url():
|
|
||||||
try:
|
|
||||||
return get_engine().url.render_as_string(hide_password=False).replace(
|
|
||||||
'%', '%%')
|
|
||||||
except AttributeError:
|
|
||||||
return str(get_engine().url).replace('%', '%%')
|
|
||||||
|
|
||||||
|
|
||||||
# add your model's MetaData object here
|
|
||||||
# for 'autogenerate' support
|
|
||||||
# from myapp import mymodel
|
|
||||||
# target_metadata = mymodel.Base.metadata
|
|
||||||
config.set_main_option('sqlalchemy.url', get_engine_url())
|
|
||||||
target_db = current_app.extensions['migrate'].db
|
|
||||||
|
|
||||||
# other values from the config, defined by the needs of env.py,
|
|
||||||
# can be acquired:
|
|
||||||
# my_important_option = config.get_main_option("my_important_option")
|
|
||||||
# ... etc.
|
|
||||||
|
|
||||||
|
|
||||||
def get_metadata():
|
|
||||||
if hasattr(target_db, 'metadatas'):
|
|
||||||
return target_db.metadatas[None]
|
|
||||||
return target_db.metadata
|
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_offline():
|
|
||||||
"""Run migrations in 'offline' mode.
|
|
||||||
|
|
||||||
This configures the context with just a URL
|
|
||||||
and not an Engine, though an Engine is acceptable
|
|
||||||
here as well. By skipping the Engine creation
|
|
||||||
we don't even need a DBAPI to be available.
|
|
||||||
|
|
||||||
Calls to context.execute() here emit the given string to the
|
|
||||||
script output.
|
|
||||||
|
|
||||||
"""
|
|
||||||
url = config.get_main_option("sqlalchemy.url")
|
|
||||||
context.configure(
|
|
||||||
url=url, target_metadata=get_metadata(), literal_binds=True
|
|
||||||
)
|
|
||||||
|
|
||||||
with context.begin_transaction():
|
|
||||||
context.run_migrations()
|
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_online():
|
|
||||||
"""Run migrations in 'online' mode.
|
|
||||||
|
|
||||||
In this scenario we need to create an Engine
|
|
||||||
and associate a connection with the context.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
# this callback is used to prevent an auto-migration from being generated
|
|
||||||
# when there are no changes to the schema
|
|
||||||
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
|
|
||||||
def process_revision_directives(context, revision, directives):
|
|
||||||
if getattr(config.cmd_opts, 'autogenerate', False):
|
|
||||||
script = directives[0]
|
|
||||||
if script.upgrade_ops.is_empty():
|
|
||||||
directives[:] = []
|
|
||||||
logger.info('No changes in schema detected.')
|
|
||||||
|
|
||||||
conf_args = current_app.extensions['migrate'].configure_args
|
|
||||||
if conf_args.get("process_revision_directives") is None:
|
|
||||||
conf_args["process_revision_directives"] = process_revision_directives
|
|
||||||
|
|
||||||
connectable = get_engine()
|
|
||||||
|
|
||||||
with connectable.connect() as connection:
|
|
||||||
context.configure(
|
|
||||||
connection=connection,
|
|
||||||
target_metadata=get_metadata(),
|
|
||||||
**conf_args
|
|
||||||
)
|
|
||||||
|
|
||||||
with context.begin_transaction():
|
|
||||||
context.run_migrations()
|
|
||||||
|
|
||||||
|
|
||||||
if context.is_offline_mode():
|
|
||||||
run_migrations_offline()
|
|
||||||
else:
|
|
||||||
run_migrations_online()
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
"""${message}
|
|
||||||
|
|
||||||
Revision ID: ${up_revision}
|
|
||||||
Revises: ${down_revision | comma,n}
|
|
||||||
Create Date: ${create_date}
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
${imports if imports else ""}
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = ${repr(up_revision)}
|
|
||||||
down_revision = ${repr(down_revision)}
|
|
||||||
branch_labels = ${repr(branch_labels)}
|
|
||||||
depends_on = ${repr(depends_on)}
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
${upgrades if upgrades else "pass"}
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
${downgrades if downgrades else "pass"}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
"""Initial migration
|
|
||||||
|
|
||||||
Revision ID: 3ce273a1be20
|
|
||||||
Revises:
|
|
||||||
Create Date: 2025-06-07 15:25:35.603295
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = '3ce273a1be20'
|
|
||||||
down_revision = None
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.drop_table('esrv_dkim_keys')
|
|
||||||
op.drop_table('esrv_auth_logs')
|
|
||||||
op.drop_table('esrv_users')
|
|
||||||
op.drop_table('esrv_domains')
|
|
||||||
op.drop_table('esrv_whitelisted_ips')
|
|
||||||
op.drop_table('esrv_email_logs')
|
|
||||||
op.drop_table('esrv_custom_headers')
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.create_table('esrv_custom_headers',
|
|
||||||
sa.Column('id', sa.INTEGER(), nullable=False),
|
|
||||||
sa.Column('domain_id', sa.INTEGER(), nullable=False),
|
|
||||||
sa.Column('header_name', sa.VARCHAR(), nullable=False),
|
|
||||||
sa.Column('header_value', sa.VARCHAR(), nullable=False),
|
|
||||||
sa.Column('is_active', sa.BOOLEAN(), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DATETIME(), nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
op.create_table('esrv_email_logs',
|
|
||||||
sa.Column('id', sa.INTEGER(), nullable=False),
|
|
||||||
sa.Column('message_id', sa.VARCHAR(), nullable=False),
|
|
||||||
sa.Column('timestamp', sa.DATETIME(), nullable=False),
|
|
||||||
sa.Column('peer', sa.VARCHAR(), nullable=False),
|
|
||||||
sa.Column('mail_from', sa.VARCHAR(), nullable=False),
|
|
||||||
sa.Column('rcpt_tos', sa.VARCHAR(), nullable=False),
|
|
||||||
sa.Column('content', sa.TEXT(), nullable=False),
|
|
||||||
sa.Column('status', sa.VARCHAR(), nullable=False),
|
|
||||||
sa.Column('dkim_signed', sa.BOOLEAN(), nullable=True),
|
|
||||||
sa.Column('from_address', sa.VARCHAR(), server_default=sa.text("'unknown'"), nullable=False),
|
|
||||||
sa.Column('to_address', sa.VARCHAR(), server_default=sa.text("'unknown'"), nullable=False),
|
|
||||||
sa.Column('subject', sa.TEXT(), nullable=True),
|
|
||||||
sa.Column('message', sa.TEXT(), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DATETIME(), nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
sa.UniqueConstraint('message_id')
|
|
||||||
)
|
|
||||||
op.create_table('esrv_whitelisted_ips',
|
|
||||||
sa.Column('id', sa.INTEGER(), nullable=False),
|
|
||||||
sa.Column('ip_address', sa.VARCHAR(), nullable=False),
|
|
||||||
sa.Column('domain_id', sa.INTEGER(), nullable=False),
|
|
||||||
sa.Column('is_active', sa.BOOLEAN(), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DATETIME(), nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
op.create_table('esrv_domains',
|
|
||||||
sa.Column('id', sa.INTEGER(), nullable=False),
|
|
||||||
sa.Column('domain_name', sa.VARCHAR(), nullable=False),
|
|
||||||
sa.Column('is_active', sa.BOOLEAN(), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DATETIME(), nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
sa.UniqueConstraint('domain_name')
|
|
||||||
)
|
|
||||||
op.create_table('esrv_users',
|
|
||||||
sa.Column('id', sa.INTEGER(), nullable=False),
|
|
||||||
sa.Column('email', sa.VARCHAR(), nullable=False),
|
|
||||||
sa.Column('password_hash', sa.VARCHAR(), nullable=False),
|
|
||||||
sa.Column('domain_id', sa.INTEGER(), nullable=False),
|
|
||||||
sa.Column('can_send_as_domain', sa.BOOLEAN(), nullable=True),
|
|
||||||
sa.Column('is_active', sa.BOOLEAN(), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DATETIME(), nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
sa.UniqueConstraint('email')
|
|
||||||
)
|
|
||||||
op.create_table('esrv_auth_logs',
|
|
||||||
sa.Column('id', sa.INTEGER(), nullable=False),
|
|
||||||
sa.Column('auth_type', sa.VARCHAR(), nullable=False),
|
|
||||||
sa.Column('identifier', sa.VARCHAR(), nullable=False),
|
|
||||||
sa.Column('ip_address', sa.VARCHAR(), nullable=True),
|
|
||||||
sa.Column('success', sa.BOOLEAN(), nullable=False),
|
|
||||||
sa.Column('message', sa.TEXT(), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DATETIME(), nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
op.create_table('esrv_dkim_keys',
|
|
||||||
sa.Column('id', sa.INTEGER(), nullable=False),
|
|
||||||
sa.Column('domain_id', sa.INTEGER(), nullable=False),
|
|
||||||
sa.Column('selector', sa.VARCHAR(), nullable=False),
|
|
||||||
sa.Column('private_key', sa.TEXT(), nullable=False),
|
|
||||||
sa.Column('public_key', sa.TEXT(), nullable=False),
|
|
||||||
sa.Column('is_active', sa.BOOLEAN(), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DATETIME(), nullable=True),
|
|
||||||
sa.Column('replaced_at', sa.DATETIME(), nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
+2
-1
@@ -12,6 +12,7 @@ bcrypt
|
|||||||
dnspython
|
dnspython
|
||||||
dkimpy
|
dkimpy
|
||||||
cryptography
|
cryptography
|
||||||
|
aiosmtplib
|
||||||
|
|
||||||
# Web Frontend Dependencies
|
# Web Frontend Dependencies
|
||||||
Flask
|
Flask
|
||||||
@@ -19,7 +20,7 @@ Flask-SQLAlchemy
|
|||||||
Jinja2
|
Jinja2
|
||||||
Werkzeug
|
Werkzeug
|
||||||
requests
|
requests
|
||||||
Flask-Migrate
|
pytz
|
||||||
gunicorn
|
gunicorn
|
||||||
|
|
||||||
# Additional utilities
|
# Additional utilities
|
||||||
|
|||||||
+85
-46
@@ -1,69 +1,108 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
sender="test@example.com"
|
# apt-get install -y swaks
|
||||||
receiver="info@example.com"
|
receiver="info@example.com"
|
||||||
password="testpass123"
|
EMAIL_SERVER="localhost" #"pymta.example.com" "localhost"
|
||||||
|
EMAIL_SERVER_auth="10.100.111.1" # IP for authenticated server ( not localhost), use your main interface ip
|
||||||
|
|
||||||
|
sender="test@example.com"
|
||||||
|
username="test@example.com"
|
||||||
|
password="ZjDvcjPSs-nwK2Ghj5vQY7L4LdmTpmn_AEZMokJTFS" # password you setup for the user!
|
||||||
domain="example.com"
|
domain="example.com"
|
||||||
body_content_file="@tests/email_body.txt"
|
body_content_file="@tests/email_body.txt"
|
||||||
SMTP_PORT=4025
|
SMTP_PORT=4025
|
||||||
SMTP_TLS_PORT=40587
|
SMTP_TLS_PORT=40465
|
||||||
cc_recipient="targetcc@example.com"
|
cc_recipient="ccrecipient@example.com"
|
||||||
bcc_recipient="targetbcc@example.com"
|
bcc_recipient="bccrecipient@example.com"
|
||||||
|
|
||||||
<<com
|
<<com
|
||||||
# Setup domain and user via web interface first
|
|
||||||
# Visit http://localhost:5000/email to configure:
|
|
||||||
# - Add domain: $domain
|
|
||||||
# - Add user: $sender with password $password
|
|
||||||
# - Add IP whitelist: 127.0.0.1 and 10.100.111.1
|
|
||||||
# - Generate DKIM key for domain
|
|
||||||
|
|
||||||
# options to add CC and BCC recipients for swaks
|
# options to add CC and BCC recipients for swaks
|
||||||
--cc $cc_recipient
|
--cc $cc_recipient \
|
||||||
--bcc $bcc_recipient
|
--bcc $bcc_recipient \
|
||||||
com
|
--header "To: $receiver" \
|
||||||
|
--header "Cc: $cc_recipient" \
|
||||||
|
|
||||||
swaks --to $receiver \
|
swaks --to $receiver \
|
||||||
--from $sender \
|
--from $sender \
|
||||||
--server localhost \
|
--server $EMAIL_SERVER \
|
||||||
--port $SMTP_TLS_PORT \
|
--port $SMTP_TLS_PORT \
|
||||||
--auth LOGIN \
|
--auth LOGIN \
|
||||||
--auth-user $sender \
|
--auth-user $username \
|
||||||
--auth-password $password \
|
--auth-password $password \
|
||||||
--tls \
|
--tls \
|
||||||
--header "Subject: TLS - Large body email" \
|
--header "Subject: TLS - Large body email" \
|
||||||
--body $body_content_file \
|
--body "simple body content" \
|
||||||
--attach tests/email_body.txt \
|
--attach @/home/nahaku/Documents/Projects/SMTP_Server/tests/pdf_test_1.pdf \
|
||||||
--attach tests/Hello.jpg
|
--attach @/home/nahaku/Documents/Projects/SMTP_Server/tests/note_authentication_order_fix.md
|
||||||
|
#--attach @/home/nahaku/Documents/Projects/SMTP_Server/tests/Hello.jpg
|
||||||
|
#--attach @/home/nahaku/Documents/Projects/SMTP_Server/tests/email_body.txt
|
||||||
|
|
||||||
swaks --to $receiver \
|
|
||||||
--from $sender \
|
|
||||||
--server localhost \
|
|
||||||
--port $SMTP_PORT \
|
|
||||||
--auth LOGIN \
|
|
||||||
--auth-user $sender \
|
|
||||||
--auth-password $password \
|
|
||||||
--data "Subject: Test Email - authenticated\n\nThis is the message body."
|
|
||||||
|
|
||||||
swaks --to $receiver \
|
|
||||||
--from $sender \
|
|
||||||
--server localhost \
|
|
||||||
--port $SMTP_TLS_PORT \
|
|
||||||
--auth LOGIN \
|
|
||||||
--auth-user $sender \
|
|
||||||
--auth-password $password \
|
|
||||||
--tls \
|
|
||||||
--data "Subject: Test via STARTTLS - authenticated\n\nThis is the body."
|
|
||||||
|
|
||||||
swaks --to $receiver \
|
|
||||||
--from $sender \
|
|
||||||
--server localhost \
|
|
||||||
--port $SMTP_TLS_PORT \
|
|
||||||
--tls \
|
|
||||||
--data "Subject: Test via STARTTLS - no auth\n\nThis is the body."
|
|
||||||
com
|
com
|
||||||
|
<<com
|
||||||
|
|
||||||
|
com
|
||||||
swaks --to $receiver \
|
swaks --to $receiver \
|
||||||
--from $sender \
|
--from $sender \
|
||||||
--server localhost \
|
--server $EMAIL_SERVER \
|
||||||
--port $SMTP_PORT \
|
--port $SMTP_PORT \
|
||||||
--data "Subject: Test Email - no auth\n\nThis is the message body."
|
--auth LOGIN \
|
||||||
|
--auth-user $username \
|
||||||
|
--auth-password $password \
|
||||||
|
--data "Subject: SMTP - authenticated success\n\nThis is the message body."
|
||||||
|
|
||||||
|
# Test with Authentication TLS
|
||||||
|
swaks --to $receiver \
|
||||||
|
--from $sender \
|
||||||
|
--server $EMAIL_SERVER \
|
||||||
|
--port $SMTP_TLS_PORT \
|
||||||
|
--auth LOGIN \
|
||||||
|
--auth-user $username \
|
||||||
|
--auth-password $password \
|
||||||
|
--tls \
|
||||||
|
--header "Subject: TLS - authenticated success" \
|
||||||
|
--body "This is the message body with proper headers."
|
||||||
|
|
||||||
|
# Test TLS + authentication and IP whitelist
|
||||||
|
swaks --to $receiver \
|
||||||
|
--from $sender \
|
||||||
|
--server $EMAIL_SERVER_auth \
|
||||||
|
--port $SMTP_TLS_PORT \
|
||||||
|
--auth LOGIN \
|
||||||
|
--auth-user $username \
|
||||||
|
--auth-password $password \
|
||||||
|
--tls \
|
||||||
|
--data "Subject: TLS - auth + IP Whitelist \n\nTest TLS + authentication and IP whitelist"
|
||||||
|
|
||||||
|
|
||||||
|
# Test with IP authentication TLS
|
||||||
|
swaks --to $receiver \
|
||||||
|
--from $sender \
|
||||||
|
--server $EMAIL_SERVER_auth \
|
||||||
|
--port $SMTP_TLS_PORT \
|
||||||
|
--tls \
|
||||||
|
--data "Subject: TLS - IP Whitelist - no auth\n\nTest with IP authentication TLS"
|
||||||
|
|
||||||
|
# Test with IP authentication SMTP
|
||||||
|
swaks --to $receiver \
|
||||||
|
--from $sender \
|
||||||
|
--server $EMAIL_SERVER_auth \
|
||||||
|
--port $SMTP_PORT \
|
||||||
|
--data "Subject: SMTP - IP Whitelist - no auth\n\nTest with IP authentication SMTP"
|
||||||
|
|
||||||
|
|
||||||
|
<<com
|
||||||
|
com
|
||||||
|
# SMTP un-auth test "Email_server - no Whitelist - no auth"
|
||||||
|
swaks --to $receiver \
|
||||||
|
--from $sender \
|
||||||
|
--server $EMAIL_SERVER \
|
||||||
|
--port $SMTP_PORT \
|
||||||
|
--data "Subject: SMTP - no Whitelist - no auth\n\nSMTP un-auth test Email_server - no Whitelist - no auth."
|
||||||
|
|
||||||
|
# Test TLS un-auth test "Email_server - no Whitelist - no auth"
|
||||||
|
swaks --to $receiver \
|
||||||
|
--from $sender \
|
||||||
|
--server $EMAIL_SERVER \
|
||||||
|
--port $SMTP_TLS_PORT \
|
||||||
|
--tls \
|
||||||
|
--data "Subject: TLS - no Whitelist - no auth\n\nTest TLS un-auth test Email_server - no Whitelist - no auth"
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# SMTP Server Authentication Order and Best Practices
|
||||||
|
|
||||||
|
## Summary of Fixes (June 2025)
|
||||||
|
|
||||||
|
This document describes the authentication logic and order for the SMTP server, as well as the recent fixes applied to ensure correct sender authentication and IP whitelisting behavior.
|
||||||
|
|
||||||
|
### What Was Fixed
|
||||||
|
- **Authentication Response:**
|
||||||
|
- The server now immediately responds with an SMTP error (e.g., `535 Authentication failed`) if the username or password is incorrect, instead of hanging the session. This is achieved by returning `AuthResult(success=False, handled=False, message='535 Authentication failed')` from the authenticator, allowing the aiosmtpd framework to send the error to the client.
|
||||||
|
- **No Forced Connection Close:**
|
||||||
|
- The server does not forcibly close the connection after failed authentication, but lets the SMTP client decide whether to retry or quit, as per SMTP protocol best practices.
|
||||||
|
- **AUTH on Both Ports:**
|
||||||
|
- Both the plain SMTP port (`smtp_port`) and the secure TLS port (`smtp_tls_port`) now advertise and allow authentication (AUTH LOGIN/PLAIN). IP whitelist fallback is also available on both ports.
|
||||||
|
|
||||||
|
## Authentication Order and Logic
|
||||||
|
|
||||||
|
1. **Connection Handling**
|
||||||
|
- If a client connects to the plain SMTP port, both AUTH and IP whitelisting are available.
|
||||||
|
- If a client connects to the TLS SMTP port, the connection is immediately secured with TLS. Both AUTH and IP whitelisting are available.
|
||||||
|
|
||||||
|
2. **Sender Authentication (Username/Password)**
|
||||||
|
- When a client issues the AUTH command (LOGIN or PLAIN) on either port:
|
||||||
|
- The server checks the username and password against the database.
|
||||||
|
- If valid, the session is marked as authenticated and the sender can send as their own address or, if permitted, as any address in their domain.
|
||||||
|
- If invalid, the server responds with `535 Authentication failed` and does not hang the session.
|
||||||
|
|
||||||
|
**Code Snippet for Immediate Authentication Failure Response:**
|
||||||
|
```python
|
||||||
|
# In email_server/auth.py
|
||||||
|
def __call__(self, server, session, envelope, mechanism, auth_data):
|
||||||
|
# ...existing code...
|
||||||
|
if not isinstance(auth_data, LoginPassword):
|
||||||
|
logger.warning(f'Invalid auth data format: {type(auth_data)}')
|
||||||
|
return AuthResult(success=False, handled=False, message='535 Authentication failed')
|
||||||
|
# ...existing code...
|
||||||
|
try:
|
||||||
|
sender = get_sender_by_email(username)
|
||||||
|
if sender and check_password(password, sender.password_hash):
|
||||||
|
# ...success logic...
|
||||||
|
return AuthResult(success=True, handled=True)
|
||||||
|
else:
|
||||||
|
# ...failure logging...
|
||||||
|
return AuthResult(success=False, handled=False, message='535 Authentication failed')
|
||||||
|
except Exception as e:
|
||||||
|
# ...error logging...
|
||||||
|
return AuthResult(success=False, handled=False, message='451 Internal server error')
|
||||||
|
```
|
||||||
|
- Returning `handled=False` ensures the SMTP client is immediately informed of the failure and does not hang.
|
||||||
|
|
||||||
|
3. **IP Whitelisting (Secondary/Fallback)**
|
||||||
|
- If no AUTH is provided, the server checks if the client's IP is whitelisted for the target domain.
|
||||||
|
- If the IP is whitelisted, the session is authorized to send for that domain.
|
||||||
|
- If not, the server rejects the mail transaction.
|
||||||
|
|
||||||
|
## Best Practices for Future Development
|
||||||
|
|
||||||
|
- **Always return `handled=False` in `AuthResult` for failed authentication** to ensure the SMTP client receives an error and the session does not hang.
|
||||||
|
- **Advertise AUTH on both the plain SMTP and TLS ports**; allow both user authentication and IP whitelist fallback.
|
||||||
|
- **Do not use or advertise STARTTLS** on any port if only direct TLS is desired.
|
||||||
|
- **Log all authentication attempts** (success and failure) for auditing and troubleshooting.
|
||||||
|
- **Keep authentication and IP whitelisting logic modular** for easy updates and security reviews.
|
||||||
|
|
||||||
|
## Example Client Setup
|
||||||
|
- For user authentication, connect to either the plain SMTP port (e.g., 25 or 4025) or the TLS port (e.g., 40587) and use the correct username and password.
|
||||||
|
- For IP whitelisting, connect from an authorized IP to either port; no authentication is required, but the sender must be allowed for the domain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**This document should be updated if the authentication logic or port usage changes in the future.**
|
||||||
Binary file not shown.
+1
-1
@@ -4,7 +4,7 @@ import ssl
|
|||||||
import argparse
|
import argparse
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument('--port', type=int, default=4025)
|
parser.add_argument('--port', type=int, default=4025)
|
||||||
parser.add_argument('--porttls', type=int, default=40587)
|
parser.add_argument('--porttls', type=int, default=40465)
|
||||||
parser.add_argument('--recipient', type=str, default="test@target-email.com")
|
parser.add_argument('--recipient', type=str, default="test@target-email.com")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
"""
|
|
||||||
Debug script to test database operations.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
import sqlite3
|
|
||||||
|
|
||||||
# Add current directory to path
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
|
|
||||||
print("Testing database operations...")
|
|
||||||
|
|
||||||
# Test direct SQLite connection
|
|
||||||
try:
|
|
||||||
conn = sqlite3.connect('smtp_server.db')
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# Check tables
|
|
||||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
|
|
||||||
tables = cursor.fetchall()
|
|
||||||
print(f"Tables in database: {[table[0] for table in tables]}")
|
|
||||||
|
|
||||||
# Check domains
|
|
||||||
cursor.execute("SELECT * FROM domains;")
|
|
||||||
domains = cursor.fetchall()
|
|
||||||
print(f"Domains: {domains}")
|
|
||||||
|
|
||||||
conn.close()
|
|
||||||
print("Direct SQLite test successful")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Direct SQLite test failed: {e}")
|
|
||||||
|
|
||||||
# Test SQLAlchemy models
|
|
||||||
try:
|
|
||||||
from email_server.models import Session, Domain, User, WhitelistedIP, create_tables
|
|
||||||
print("Models imported successfully")
|
|
||||||
|
|
||||||
# Create session
|
|
||||||
session = Session()
|
|
||||||
|
|
||||||
# Check domains
|
|
||||||
domains = session.query(Domain).all()
|
|
||||||
print(f"SQLAlchemy domains: {[(d.id, d.domain_name) for d in domains]}")
|
|
||||||
|
|
||||||
session.close()
|
|
||||||
print("SQLAlchemy test successful")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"SQLAlchemy test failed: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
Reference in New Issue
Block a user