Merge pull request #4 from ghostersk/testing-old

Testing old
This commit is contained in:
ghostersk
2025-06-14 10:01:21 +01:00
committed by GitHub
47 changed files with 2120 additions and 979 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
FLASK_APP=app:create_app FLASK_APP=app:flask_app
FLASK_ENV=development FLASK_ENV=development
+1
View File
@@ -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
+51 -73
View File
@@ -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
View File
@@ -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)
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+8 -8
View File
@@ -46,7 +46,7 @@ async def start_server(shutdown_event=None):
# dkim_manager.initialize_default_keys() # Removed: do not auto-generate DKIM keys for all domains # 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:
+1
View File
@@ -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 *
+7 -4
View File
@@ -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()
+17 -6
View File
@@ -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)
+4 -4
View File
@@ -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:
+5 -1
View File
@@ -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')
+20 -8
View File
@@ -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()
+30 -6
View File
@@ -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
+64 -60
View File
@@ -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()
+50 -2
View File
@@ -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') }}
+104 -20
View File
@@ -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 %}
+2 -2
View File
@@ -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
+131
View File
@@ -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()
+5 -3
View File
@@ -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
View File
@@ -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
) )
+21 -1
View File
@@ -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
View File
@@ -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
);
-50
View File
@@ -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
-113
View File
@@ -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()
-24
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+69
View File
@@ -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
View File
@@ -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()
-53
View File
@@ -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()