1 Commits
43 changed files with 982 additions and 1860 deletions
-1
View File
@@ -4,7 +4,6 @@ __pycache__/
*$py.class
# Certs, db, private test files
attachments/
settings.ini
*.crt
*.key
-7
View File
@@ -18,13 +18,6 @@ python app.py --smtp-only & gunicorn -w 4 -b 0.0.0.0:5000 app:flask_app
[Send Test Emails examples](./tests/run_tests_manually.md)
![image](https://github.com/user-attachments/assets/4ec1ed38-ca16-4d77-8836-705e554bdf29)
![image](https://github.com/user-attachments/assets/333e284a-33c8-4a7e-8cb3-f98438f03c80)
![image](https://github.com/user-attachments/assets/d10864f6-4b3a-4e92-8d85-19cfb630d960)
![image](https://github.com/user-attachments/assets/7d9b7a3f-b5df-4d2c-ac47-f9544059bd86)
![image](https://github.com/user-attachments/assets/258f4f82-9859-4666-a8b6-5f6025311057)
![image](https://github.com/user-attachments/assets/8e79005a-e034-4663-9b5e-c17ca735fee5)
## License
+23 -8
View File
@@ -29,10 +29,11 @@ class EnhancedAuthenticator:
- Comprehensive audit logging
"""
def __call__(self, server, session, envelope, mechanism, auth_data):
async def __call__(self, server, session, envelope, mechanism, auth_data):
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')
await server.push('535 Authentication failed')
return AuthResult(success=False, handled=True)
# Decode bytes to string if necessary
username = auth_data.login
@@ -53,7 +54,6 @@ class EnhancedAuthenticator:
# Store authenticated sender info in session for later validation
session.authenticated_sender = sender
session.auth_type = 'sender'
session.username = username # Store username in session
# Log successful authentication
log_auth_attempt(
auth_type='sender',
@@ -74,7 +74,8 @@ class EnhancedAuthenticator:
message=f'Invalid credentials for {username}'
)
logger.warning(f'Authentication failed for {username}: invalid credentials')
return AuthResult(success=False, handled=False, message='535 Authentication failed')
await server.push('535 Authentication failed')
return AuthResult(success=False, handled=True)
except Exception as e:
logger.error(f'Authentication error for {username}: {e}')
@@ -85,7 +86,8 @@ class EnhancedAuthenticator:
success=False,
message=f'Authentication error: {str(e)}'
)
return AuthResult(success=False, handled=False, message='451 Internal server error')
await server.push('535 Authentication failed')
return AuthResult(success=False, handled=True)
class EnhancedIPAuthenticator:
"""
@@ -99,7 +101,7 @@ class EnhancedIPAuthenticator:
def can_authenticate_for_domain(self, ip_address: str, domain_name: str) -> tuple[bool, str]:
"""
Check if IP can authenticate for a specific domain.
Check if IP can authenticate for a specific domain or is globally whitelisted.
Args:
ip_address: Client IP address
@@ -109,11 +111,25 @@ class EnhancedIPAuthenticator:
(success, message) tuple
"""
try:
# First, check for domain-specific whitelist
whitelisted_ip = get_whitelisted_ip(ip_address, domain_name)
if whitelisted_ip:
return True, f"IP {ip_address} authorized for domain {domain_name}"
# Then, check for global whitelist
from email_server.models import Session, WhitelistedIP, get_domain_by_name
session = Session()
try:
global_ip = session.query(WhitelistedIP).filter_by(ip_address=ip_address, global_ip=True, is_active=True).first()
if global_ip:
# Check if the domain exists and is active
domain = get_domain_by_name(domain_name)
if domain:
return True, f"IP {ip_address} is globally whitelisted for existing domain {domain_name}"
else:
return False, f"IP {ip_address} not authorized for domain {domain_name}"
return False, f"Domain {domain_name} does not exist or is not active on this server"
finally:
session.close()
return False, f"IP {ip_address} not authorized for domain {domain_name} or globally"
except Exception as e:
logger.error(f"Error checking IP authorization: {e}")
return False, f"Error checking IP authorization: {str(e)}"
@@ -168,7 +184,6 @@ def validate_sender_authorization(session, mail_from: str) -> tuple[bool, str]:
# Store IP auth info in session
session.auth_type = 'ip'
session.authorized_domain = from_domain
session.username = f"IP:{peer_ip}" # Store IP as username for IP authentication
log_auth_attempt(
auth_type='ip',
+4 -4
View File
@@ -8,7 +8,7 @@ from cryptography.hazmat.primitives.asymmetric import rsa
from datetime import datetime
from email_server.models import Session, Domain, DKIMKey, CustomHeader
from email_server.settings_loader import load_settings
from email_server.tool_box import get_logger, get_current_time
from email_server.tool_box import get_logger
import random
import string
@@ -55,7 +55,7 @@ class DKIMManager:
existing_active_keys = session.query(DKIMKey).filter_by(domain_id=domain.id, is_active=True).all()
for existing_key in existing_active_keys:
existing_key.is_active = False
existing_key.replaced_at = get_current_time()
existing_key.replaced_at = datetime.now()
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
@@ -75,7 +75,7 @@ class DKIMManager:
).all()
for key in other_active_keys:
key.is_active = False
key.replaced_at = get_current_time()
key.replaced_at = datetime.now()
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
existing_key_with_selector.is_active = True
@@ -114,7 +114,7 @@ class DKIMManager:
selector=use_selector,
private_key=private_pem,
public_key=public_pem,
created_at=get_current_time(),
created_at=datetime.now(),
is_active=True
)
session.add(dkim_key)
+109 -299
View File
@@ -2,346 +2,156 @@
Email relay functionality for the SMTP server.
"""
import asyncio
import dns.resolver
from email_server.models import Session, EmailLog, EmailRecipientLog
import smtplib
import ssl
from datetime import datetime
from email_server.models import Session, EmailLog
from email_server.settings_loader import load_settings
from email_server.tool_box import get_logger, get_current_time
import aiosmtplib
from email_server.tool_box import 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:
"""Handles relaying emails to recipient mail servers."""
def __init__(self):
self.timeout = _relay_tls_timeout # Increased timeout for TLS negotiations
self.timeout = 30 # Increased timeout for TLS negotiations
# Get the configured hostname for HELO/EHLO identification
self.hostname = settings['Server'].get('helo_hostname', settings['Server'].get('hostname', 'localhost'))
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 _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.
Args:
content: Raw email content
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 = []
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:
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)
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_hosts = [mx.exchange.to_text().rstrip('.') for mx in mx_records]
logger.debug(f'Found MX records for {domain}: {mx_hosts}')
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}')
for rcpt in to_recipients + cc_recipients:
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
return False
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}, upgrading to TLS')
await smtp.starttls()
else:
logger.warning(f'STARTTLS not supported by {mx_host}:{port}, sending in plain text!')
response = await smtp.sendmail(mail_from, to_recipients + cc_recipients, prepared_content)
logger.debug(f'Successfully relayed email to {to_recipients + cc_recipients} via {mx_host}:{port}')
for rcpt in to_recipients + cc_recipients:
results.append({
'recipient': rcpt,
'status': 'success',
'error_code': None,
'error_message': None,
'server_response': str(response),
'recipient_type': recipient_type_map.get(rcpt, 'to')
})
await smtp.quit()
delivered = True
break
# Try to relay with opportunistic TLS
if not self._relay_with_opportunistic_tls(mail_from, rcpt, content, mx_host):
return False
return True
except Exception as e:
logger.error(f'Failed to relay email to {to_recipients + cc_recipients} 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:
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)
logger.error(f'General relay error: {e}')
return False
def _relay_with_opportunistic_tls(self, mail_from, rcpt, content, mx_host):
"""Relay email with opportunistic TLS (like Gmail does)."""
try:
# First, try with STARTTLS (encrypted)
try:
with smtplib.SMTP(mx_host, 25, timeout=self.timeout) as relay_server:
relay_server.set_debuglevel(1)
# Try to enable TLS if the server supports it
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:
logger.error(f'Failed to relay email to {rcpt} via {mx_host}: {e}')
# Fallback: try alternative MX records if available
try:
domain = rcpt.split('@')[1]
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 other MX records
for mx_record in mx_records[1:3]: # Try up to 2 backup MX records
backup_mx = mx_record.exchange.to_text().rstrip('.')
logger.debug(f'Trying backup MX record: {backup_mx}')
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
}
with smtplib.SMTP(backup_mx, 25, timeout=self.timeout) as backup_server:
backup_server.set_debuglevel(1)
# Try TLS with backup server too
try:
logger.debug(f'Sending EHLO {self.hostname} to backup {backup_mx}')
backup_server.ehlo(self.hostname)
if backup_server.has_extn('starttls'):
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
backup_server.starttls(context=context)
logger.debug(f'Sending EHLO {self.hostname} again after STARTTLS to backup {backup_mx}')
backup_server.ehlo(self.hostname)
logger.debug(f'TLS connection established to backup {backup_mx}')
except Exception:
logger.warning(f'STARTTLS failed with backup {backup_mx}, using plain text')
backup_server.sendmail(mail_from, rcpt, content)
logger.debug(f'Successfully relayed email to {rcpt} via backup {backup_mx}')
return True
except Exception as backup_e:
logger.warning(f'Backup MX {backup_mx} also failed: {backup_e}')
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'
})
except Exception as fallback_e:
logger.error(f'All MX records failed for {rcpt}: {fallback_e}')
return results
return False
def relay_email(self, *args, **kwargs):
"""Synchronous wrapper for relay_email_async for compatibility."""
return asyncio.run(self.relay_email_async(*args, **kwargs))
except Exception as e:
logger.error(f'Unexpected error in TLS relay: {e}')
return 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, including per-recipient results."""
def log_email(self, message_id, peer, mail_from, rcpt_tos, content, status, dkim_signed=False):
"""Log email activity to database."""
session_db = Session()
try:
# Determine status: relayed, partial, failed
delivered = [r for r in (recipient_results or []) if r['status'] == 'success']
failed = [r for r in (recipient_results or []) if r['status'] != 'success']
if delivered and failed:
overall_status = 'partial'
elif delivered:
overall_status = 'relayed'
# Convert content to string if it's bytes
if isinstance(content, bytes):
content_str = content.decode('utf-8', errors='replace')
else:
overall_status = 'failed'
content_str = content
email_log = EmailLog(
message_id=message_id,
timestamp=get_current_time(),
peer_ip=peer,
timestamp=datetime.now(),
peer=str(peer),
mail_from=mail_from,
to_address=to_address or '',
cc_addresses=cc_addresses or '',
bcc_addresses=bcc_addresses or '',
subject=subject,
email_headers=email_headers,
message_body=message_body,
status=overall_status,
dkim_signed=dkim_signed,
username=username
rcpt_tos=', '.join(rcpt_tos),
content=content_str,
status=status,
dkim_signed=dkim_signed
)
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()
logger.debug(f'Logged email: {message_id}')
except Exception as e:
+21 -56
View File
@@ -63,7 +63,6 @@ class Sender(Base):
can_send_as_domain = Column(Boolean, default=False)
is_active = Column(Boolean, default=True)
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:
"""
@@ -89,19 +88,20 @@ class Sender(Base):
class WhitelistedIP(Base):
"""
IP whitelist model with domain-specific authentication.
IP whitelist model with domain-specific and global authentication.
Security feature:
- IPs can only send emails for their specific authorized domain
- IPs can be global (allowed for any domain) or domain-specific
- IPs can only send emails for their specific authorized domain unless global_ip is True
"""
__tablename__ = 'esrv_whitelisted_ips'
id = Column(Integer, primary_key=True)
ip_address = Column(String, nullable=False)
domain_id = Column(Integer, ForeignKey('esrv_domains.id'), nullable=False)
domain_id = Column(Integer, ForeignKey('esrv_domains.id'), nullable=True)
global_ip = Column(Boolean, default=False, nullable=False)
is_active = Column(Boolean, default=True)
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:
"""
@@ -109,13 +109,13 @@ class WhitelistedIP(Base):
Args:
domain_name: The domain name to check
Returns:
True if IP is authorized for this domain
True if IP is authorized for this domain or is global
"""
if not self.is_active:
return False
if self.global_ip:
return True
# Need to check against the actual domain
session = Session()
try:
@@ -128,51 +128,33 @@ class WhitelistedIP(Base):
session.close()
def __repr__(self):
return f"<WhitelistedIP(id={self.id}, ip='{self.ip_address}', domain_id={self.domain_id})>"
return f"<WhitelistedIP(id={self.id}, ip='{self.ip_address}', domain_id={self.domain_id}, global_ip={self.global_ip})>"
class EmailLog(Base):
"""Email log model for tracking sent emails."""
__tablename__ = 'esrv_email_logs'
id = Column(Integer, primary_key=True)
# Legacy columns (from original schema)
message_id = Column(String, unique=True, nullable=False)
timestamp = Column(DateTime, nullable=False)
peer_ip = Column(String, nullable=False) # Store only IP address
peer = Column(String, nullable=False)
mail_from = Column(String, nullable=False)
to_address = Column(String, nullable=False, server_default='')
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
rcpt_tos = Column(String, nullable=False)
content = Column(Text, nullable=False)
status = Column(String, nullable=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())
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):
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}')>"
return f"<EmailLog(id={self.id}, message_id='{self.message_id}', from='{self.mail_from}', to='{self.rcpt_tos}', status='{self.status}')>"
class AuthLog(Base):
"""Authentication log model for security auditing."""
@@ -219,23 +201,6 @@ class CustomHeader(Base):
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})>"
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():
"""Create all database tables using ESRV schema."""
+1 -1
View File
@@ -117,7 +117,7 @@ async def start_server(shutdown_event=None):
)
controller_tls.start()
logger.debug(f' - Plain SMTP (IP whitelist): {BIND_IP}:{SMTP_PORT}')
logger.debug(f' - Direct TLS SMTP (SMTPS, auth required): {BIND_IP}:{SMTP_TLS_PORT}')
logger.debug(f' - STARTTLS SMTP (auth required): {BIND_IP}:{SMTP_TLS_PORT}')
logger.debug('Management available via web interface at: http://localhost:5000/email')
try:
-1
View File
@@ -15,4 +15,3 @@ from .ip_whitelist import *
from .dkim import *
from .settings import *
from .logs import *
from .view_message import *
+2 -5
View File
@@ -5,7 +5,7 @@ This module provides the main dashboard view and overview functionality.
"""
from flask import render_template
from email_server.models import Session, Domain, Sender, DKIMKey, EmailLog, AuthLog, EmailRecipientLog
from email_server.models import Session, Domain, Sender, DKIMKey, EmailLog, AuthLog
from email_server.tool_box import get_logger
from .routes import email_bp
@@ -24,8 +24,6 @@ def dashboard():
# Get recent email logs
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
recent_auths = session.query(AuthLog).order_by(AuthLog.created_at.desc()).limit(10).all()
@@ -35,7 +33,6 @@ def dashboard():
sender_count=sender_count,
dkim_count=dkim_count,
recent_emails=recent_emails,
recent_auths=recent_auths,
recipient_logs_map=recipient_logs_map)
recent_auths=recent_auths)
finally:
session.close()
+5 -5
View File
@@ -9,12 +9,12 @@ This module provides DKIM key management functionality including:
- DKIM DNS verification
"""
from flask import render_template, request, redirect, url_for, flash, jsonify, current_app
from flask import render_template, request, redirect, url_for, flash, jsonify
from datetime import datetime
import re
from email_server.models import Session, Domain, DKIMKey
from email_server.dkim_manager import DKIMManager
from email_server.tool_box import get_logger, get_current_time
from email_server.tool_box import get_logger
from .utils import get_public_ip, check_dns_record, generate_spf_record
from .routes import email_bp
@@ -107,7 +107,7 @@ def create_dkim():
active_keys = session.query(DKIMKey).filter_by(domain_id=domain.id, is_active=True).all()
for key in active_keys:
key.is_active = False
key.replaced_at = get_current_time()
key.replaced_at = datetime.now()
# Create new DKIM key
dkim_manager = DKIMManager()
created = dkim_manager.generate_dkim_keypair(domain_name, selector=selector, force_new_key=True)
@@ -146,7 +146,7 @@ def regenerate_dkim(domain_id: int):
# Mark existing keys as replaced
for key in existing_keys:
key.is_active = False
key.replaced_at = get_current_time() # Mark when this key was replaced
key.replaced_at = datetime.now() # Mark when this key was replaced
# Generate new DKIM key preserving the existing selector
dkim_manager = DKIMManager()
@@ -331,7 +331,7 @@ def toggle_dkim(dkim_id: int):
).all()
for key in other_active_keys:
key.is_active = False
key.replaced_at = get_current_time()
key.replaced_at = datetime.now()
dkim_key.is_active = not old_status
if dkim_key.is_active:
+51 -39
View File
@@ -22,54 +22,66 @@ def ips_list():
"""List all whitelisted IPs."""
session = Session()
try:
ips = session.query(WhitelistedIP, Domain).join(Domain, WhitelistedIP.domain_id == Domain.id).order_by(WhitelistedIP.ip_address).all()
return render_template('ips.html', ips=ips)
all_ips = session.query(WhitelistedIP, Domain).join(Domain, WhitelistedIP.domain_id == Domain.id, isouter=True).order_by(WhitelistedIP.ip_address).all()
domain_ips = [item for item in all_ips if not item[0].global_ip]
global_ips = [item for item in all_ips if item[0].global_ip]
return render_template('ips.html', ips=domain_ips, global_ips=global_ips)
finally:
session.close()
@email_bp.route('/ips/add', methods=['GET', 'POST'])
def add_ip():
"""Add new whitelisted IP."""
"""Add new whitelisted IP(s)."""
session = Session()
try:
domains = session.query(Domain).filter_by(is_active=True).order_by(Domain.domain_name).all()
if request.method == 'POST':
ip_address = request.form.get('ip_address', '').strip()
ip_addresses_raw = request.form.get('ip_addresses', '').strip()
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]):
flash('All fields are required', 'error')
global_ip = request.form.get('global_ip') == 'on'
# Split IPs by line or comma
ip_list = []
for line in ip_addresses_raw.splitlines():
for ip in line.split(','):
ip = ip.strip()
if ip:
ip_list.append(ip)
if not ip_list:
flash('Please enter at least one IP address', 'error')
return redirect(url_for('email.add_ip'))
if not global_ip and not domain_id:
flash('Please select a domain for non-global IPs', 'error')
return redirect(url_for('email.add_ip'))
added = 0
for ip_address in ip_list:
# Basic IP validation
try:
socket.inet_aton(ip_address)
except socket.error:
flash('Invalid IP address format', 'error')
return redirect(url_for('email.add_ip'))
# Check if IP already exists for this domain
existing = session.query(WhitelistedIP).filter_by(ip_address=ip_address, domain_id=domain_id).first()
if existing:
flash(f'IP {ip_address} already whitelisted for this domain', 'error')
return redirect(url_for('email.ips_list'))
flash(f'Invalid IP address format: {ip_address}', 'error')
continue
# Check if IP already exists for this domain/global
existing = session.query(WhitelistedIP).filter_by(ip_address=ip_address, global_ip=global_ip)
if not global_ip:
existing = existing.filter_by(domain_id=domain_id)
else:
existing = existing.filter_by(domain_id=None)
if existing.first():
flash(f'IP {ip_address} already whitelisted for this domain/global', 'error')
continue
# Create whitelisted IP
whitelist = WhitelistedIP(
ip_address=ip_address,
domain_id=domain_id,
store_message_content=store_message_content
domain_id=None if global_ip else domain_id,
global_ip=global_ip
)
session.add(whitelist)
added += 1
session.commit()
flash(f'IP {ip_address} added to whitelist', 'success')
if added:
flash(f'{added} IP(s) added to whitelist', 'success')
return redirect(url_for('email.ips_list'))
return render_template('add_ip.html', domains=domains)
except Exception as e:
session.rollback()
logger.error(f"Error adding IP: {e}")
@@ -168,35 +180,35 @@ def edit_ip(ip_id: int):
if request.method == 'POST':
ip_address = request.form.get('ip_address', '').strip()
domain_id = request.form.get('domain_id', type=int)
store_message_content = bool(request.form.get('store_message_content'))
global_ip = request.form.get('global_ip') == 'on'
if not all([ip_address, domain_id]):
flash('All fields are required', 'error')
if not ip_address:
flash('IP address is required', 'error')
return redirect(url_for('email.edit_ip', ip_id=ip_id))
# Basic IP validation
try:
socket.inet_aton(ip_address)
except socket.error:
flash('Invalid IP address format', 'error')
return redirect(url_for('email.edit_ip', ip_id=ip_id))
# Check if IP already exists for this domain (excluding current record)
# Check if IP already exists for this domain/global (excluding current record)
existing = session.query(WhitelistedIP).filter(
WhitelistedIP.ip_address == ip_address,
WhitelistedIP.domain_id == domain_id,
WhitelistedIP.global_ip == global_ip,
WhitelistedIP.id != ip_id
).first()
if existing:
flash(f'IP {ip_address} already whitelisted for this domain', 'error')
)
if not global_ip:
existing = existing.filter(WhitelistedIP.domain_id == domain_id)
else:
existing = existing.filter(WhitelistedIP.domain_id == None)
if existing.first():
flash(f'IP {ip_address} already whitelisted for this domain/global', 'error')
return redirect(url_for('email.edit_ip', ip_id=ip_id))
# Update IP record
ip_record.ip_address = ip_address
ip_record.domain_id = domain_id
ip_record.store_message_content = store_message_content
ip_record.global_ip = global_ip
ip_record.domain_id = None if global_ip else domain_id
session.commit()
flash(f'IP whitelist record updated', 'success')
return redirect(url_for('email.ips_list'))
+7 -19
View File
@@ -4,11 +4,12 @@ Logs blueprint for the SMTP server web UI.
This module provides email and authentication log viewing functionality.
"""
from flask import render_template, request, send_file, redirect, url_for, flash, Response
from email_server.models import Session, EmailLog, AuthLog, EmailRecipientLog, EmailAttachment
from flask import render_template, request, jsonify
from email_server.models import Session, EmailLog, AuthLog, Domain
from email_server.tool_box import get_logger
from sqlalchemy import desc
from datetime import datetime, timedelta
from .routes import email_bp
import os
logger = get_logger()
@@ -39,15 +40,10 @@ def logs():
# Convert to unified format
combined_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({
'type': 'email',
'timestamp': log.created_at,
'data': log,
'recipients': recipient_logs,
'attachments': attachments
'data': log
})
for log in auth_logs:
combined_logs.append({
@@ -73,20 +69,12 @@ def logs():
has_next = offset + per_page < total
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',
logs=logs,
filter_type=filter_type,
page=page,
has_next=has_next,
has_prev=has_prev,
recipient_logs_map=recipient_logs_map,
attachments_map=attachments_map)
has_prev=has_prev)
finally:
session.close()
+6 -30
View File
@@ -1,12 +1,9 @@
"""
Main routes and blueprint definition for the SMTP server web UI.
"""
from flask import Blueprint, render_template, request, jsonify, current_app
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 flask import Blueprint, render_template
from email_server.tool_box import get_logger
from datetime import datetime
import pytz
# Create the main email blueprint
@@ -18,35 +15,14 @@ email_bp = Blueprint('email', __name__,
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
@email_bp.errorhandler(404)
def not_found(error):
"""Handle 404 errors."""
return render_template('error.html',
error_code=404,
error_message="Page not found",
current_time=get_current_time()), 404
error_message='Page not found',
current_time=datetime.now()), 404
@email_bp.errorhandler(500)
def internal_error(error):
@@ -54,5 +30,5 @@ def internal_error(error):
logger.error(f"Internal error: {error}")
return render_template('error.html',
error_code=500,
error_message=str(error),
current_time=get_current_time()), 500
error_message='Internal server error',
current_time=datetime.now()), 500
+1 -5
View File
@@ -40,7 +40,6 @@ def add_sender():
password = request.form.get('password', '').strip()
domain_id = request.form.get('domain_id', type=int)
can_send_as_domain = request.form.get('can_send_as_domain') == 'on'
store_message_content = request.form.get('store_message_content') == 'on'
if not all([email, password, domain_id]):
flash('All fields are required', 'error')
@@ -62,8 +61,7 @@ def add_sender():
email=email,
password_hash=hash_password(password),
domain_id=domain_id,
can_send_as_domain=can_send_as_domain,
store_message_content=store_message_content
can_send_as_domain=can_send_as_domain
)
session.add(sender)
session.commit()
@@ -173,7 +171,6 @@ def edit_sender(user_id: int):
password = request.form.get('password', '').strip()
domain_id = request.form.get('domain_id', type=int)
can_send_as_domain = request.form.get('can_send_as_domain') == 'on'
store_message_content = request.form.get('store_message_content') == 'on'
if not all([email, domain_id]):
flash('Email and domain are required', 'error')
@@ -197,7 +194,6 @@ def edit_sender(user_id: int):
sender.email = email
sender.domain_id = domain_id
sender.can_send_as_domain = can_send_as_domain
sender.store_message_content = store_message_content
# Update password if provided
if password:
+2 -50
View File
@@ -12,7 +12,6 @@ This module provides server settings management functionality including:
import os
import time
from pathlib import Path
import zoneinfo
from flask import render_template, request, redirect, url_for, flash, jsonify
from werkzeug.utils import secure_filename
from email_server.settings_loader import load_settings, SETTINGS_PATH
@@ -30,21 +29,11 @@ ALLOWED_EXTENSIONS = {'crt', 'key', 'pem'}
def allowed_file(filename):
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')
def settings():
"""Display and edit server settings."""
return render_template('settings.html', **get_template_context())
settings = load_settings()
return render_template('settings.html', settings=settings)
@email_bp.route('/settings_update', methods=['POST'])
def settings_update():
@@ -206,40 +195,3 @@ def get_server_ip():
except Exception as e:
logger.error(f"Error getting public IP: {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())
@@ -40,42 +40,30 @@
<div class="card-body">
<form method="POST">
<div class="mb-3">
<label for="ip_address" class="form-label">IP Address</label>
<input type="text"
class="form-control font-monospace"
id="ip_address"
name="ip_address"
required
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]?)$"
placeholder="192.168.1.100"
value="{{ request.args.get('ip', '') }}">
<label for="ip_addresses" class="form-label">IP Addresses</label>
<textarea class="form-control font-monospace" id="ip_addresses" name="ip_addresses" rows="3" required placeholder="One IP per line, or separate with commas">{{ request.args.get('ip', '') }}</textarea>
<div class="form-text">
IPv4 address that will be allowed to send emails without authentication
Enter one or more IPv4 addresses (one per line or comma-separated).<br>
Each IP will be added as a separate whitelist entry.
</div>
</div>
<div class="mb-3 form-check">
<input class="form-check-input" type="checkbox" id="global_ip" name="global_ip">
<label class="form-check-label" for="global_ip">
Global IP (allow for any domain)
</label>
</div>
<div class="mb-4">
<label for="domain_id" class="form-label">Authorized Domain</label>
<select class="form-select" id="domain_id" name="domain_id" required>
<select class="form-select" id="domain_id" name="domain_id">
<option value="">Select a domain...</option>
{% for domain in domains %}
<option value="{{ domain.id }}">{{ domain.domain_name }}</option>
{% endfor %}
</select>
<div class="form-text">
This IP will only be able to send emails for the selected domain
</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>
This IP will only be able to send emails for the selected domain (unless Global IP is checked)
</div>
</div>
@@ -121,25 +109,18 @@
const data = await response.json();
document.getElementById('current-ip').innerHTML =
`<span class="text-primary">${data.ip_addr}</span>`;
} catch (er) {
try {
const response = await fetch('https://httpbin.org/ip');
const data = await response.json();
document.getElementById('current-ip').innerHTML =
`<span class="text-primary">${data.origin}</span>`;
} catch (error) {
document.getElementById('current-ip').innerHTML =
'<span class="text-muted">Unable to detect</span>';
}
}
}
function useCurrentIP() {
const currentIPElement = document.getElementById('current-ip');
const ip = currentIPElement.textContent.trim();
if (ip && ip !== 'Detecting...' && ip !== 'Unable to detect') {
document.getElementById('ip_address').value = ip;
document.getElementById('ip_addresses').value = ip;
// Focus on domain selection
document.getElementById('domain_id').focus();
} else {
@@ -147,19 +128,22 @@
}
}
// IP address validation
document.getElementById('ip_address').addEventListener('input', function(e) {
const ip = e.target.value;
const ipPattern = /^(?:(?: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]?)$/;
if (ip && !ipPattern.test(ip)) {
e.target.setCustomValidity('Please enter a valid IPv4 address');
} else {
e.target.setCustomValidity('');
}
});
// Auto-detect IP on page load
detectCurrentIP();
// Enable/disable domain select based on global_ip checkbox
const globalIpCheckbox = document.getElementById('global_ip');
const domainSelect = document.getElementById('domain_id');
function toggleDomainSelect() {
if (globalIpCheckbox.checked) {
domainSelect.disabled = true;
domainSelect.required = false;
} else {
domainSelect.disabled = false;
domainSelect.required = true;
}
}
globalIpCheckbox.addEventListener('change', toggleDomainSelect);
toggleDomainSelect();
</script>
{% endblock %}
@@ -70,18 +70,6 @@
</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">
<h6 class="alert-heading">
<i class="bi bi-info-circle me-2"></i>
@@ -134,7 +134,7 @@
<tr>
<th>Time</th>
<th>From</th>
<th>Recipients</th>
<th>To</th>
<th>Status</th>
<th>DKIM</th>
</tr>
@@ -144,7 +144,7 @@
<tr>
<td>
<small class="text-muted">
{{ email.created_at|format_datetime }}
{{ email.created_at.strftime('%H:%M:%S') }}
</small>
</td>
<td>
@@ -153,67 +153,16 @@
</span>
</td>
<td>
<div style="max-width: 200px; font-size: 0.85rem;">
<div class="recipients-list">
{% 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>
<span class="text-truncate d-inline-block" style="max-width: 150px;" title="{{ email.to_address }}">
{{ email.to_address }}
</span>
</td>
<td>
{% set delivered = recipient_logs_map[email.id]|selectattr('status', 'equalto', 'success')|list %}
{% set failed = recipient_logs_map[email.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 %}
{% if overall_status == 'relayed' %}
{% if email.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>
@@ -221,7 +170,6 @@
</span>
{% endif %}
</td>
</td>
<td>
{% if email.dkim_signed %}
<span class="text-success">
@@ -279,7 +227,7 @@
</small>
<br>
<small class="text-muted">
{{ auth.created_at|format_datetime }}
{{ auth.created_at.strftime('%H:%M:%S') }}
</small>
</div>
<small class="text-muted">
@@ -370,15 +318,6 @@
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>
@@ -21,16 +21,21 @@
id="ip_address"
name="ip_address"
value="{{ ip_record.ip_address }}"
placeholder="e.g., 192.168.1.1 or 192.168.1.0/24"
placeholder="e.g., 192.168.1.1"
required>
<div class="form-text">
Enter a single IP address or CIDR block
Enter a single IP address
</div>
</div>
<div class="mb-3 form-check">
<input class="form-check-input" type="checkbox" id="global_ip" name="global_ip" {% if ip_record.global_ip %}checked{% endif %}>
<label class="form-check-label" for="global_ip">
Global IP (allow for any domain)
</label>
</div>
<div class="mb-3">
<label for="domain_id" class="form-label">Domain</label>
<select class="form-select" id="domain_id" name="domain_id" required>
<select class="form-select" id="domain_id" name="domain_id" {% if ip_record.global_ip %}disabled{% endif %} required>
<option value="">Select a domain</option>
{% for domain in domains %}
<option value="{{ domain.id }}"
@@ -40,19 +45,7 @@
{% endfor %}
</select>
<div class="form-text">
This IP will be able to send emails for the selected domain
</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>
This IP will be able to send emails for the selected domain (unless Global IP is checked)
</div>
</div>
@@ -107,20 +100,6 @@
</span>
{% endif %}
</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>
<dd class="col-sm-8">
<small class="text-muted">
@@ -184,6 +163,19 @@ document.addEventListener('DOMContentLoaded', function() {
const parts = ip.split('/')[0].split('.');
return parts.every(part => parseInt(part) >= 0 && parseInt(part) <= 255);
}
// Enable/disable domain select based on global_ip checkbox
const globalIpCheckbox = document.getElementById('global_ip');
const domainSelect = document.getElementById('domain_id');
function toggleDomainSelect() {
if (globalIpCheckbox.checked) {
domainSelect.disabled = true;
} else {
domainSelect.disabled = false;
}
}
globalIpCheckbox.addEventListener('change', toggleDomainSelect);
toggleDomainSelect();
});
</script>
{% endblock %}
@@ -65,18 +65,6 @@
</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 class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>
@@ -142,20 +130,6 @@
</span>
{% endif %}
</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>
<dd class="col-sm-8">
<small class="text-muted">
+102 -19
View File
@@ -21,7 +21,7 @@
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-list me-2"></i>
Whitelisted IP Addresses
Per Domain Whitelisted IP Addresses
</h5>
</div>
<div class="card-body">
@@ -33,7 +33,6 @@
<th>IP Address</th>
<th>Domain</th>
<th>Status</th>
<th>Storage Type</th>
<th>Added</th>
<th>Actions</th>
</tr>
@@ -45,7 +44,11 @@
<div class="fw-bold font-monospace">{{ ip.ip_address }}</div>
</td>
<td>
{% if ip.global_ip %}
<span class="badge bg-info">Global</span>
{% else %}
<span class="badge bg-secondary">{{ domain.domain_name }}</span>
{% endif %}
</td>
<td>
{% if ip.is_active %}
@@ -60,19 +63,6 @@
</span>
{% endif %}
</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>
<small class="text-muted">
{{ ip.created_at.strftime('%Y-%m-%d %H:%M') }}
@@ -137,6 +127,97 @@
{% endif %}
</div>
</div>
{% if global_ips %}
<div class="card mt-4">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-list me-2"></i>
Global Whitelisted IP Addresses <span class="text-muted small">(can be used for all domains)</span>
</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>IP Address</th>
<th>Status</th>
<th>Added</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for ip, domain in global_ips %}
<tr>
<td>
<div class="fw-bold font-monospace">{{ ip.ip_address }}</div>
</td>
<td>
{% if ip.is_active %}
<span class="badge bg-success">
<i class="bi bi-check-circle me-1"></i>
Active
</span>
{% else %}
<span class="badge bg-danger">
<i class="bi bi-x-circle me-1"></i>
Inactive
</span>
{% endif %}
</td>
<td>
<small class="text-muted">
{{ ip.created_at.strftime('%Y-%m-%d %H:%M') }}
</small>
</td>
<td>
<div class="btn-group" role="group">
<!-- Edit Button -->
<a href="{{ url_for('email.edit_ip', ip_id=ip.id) }}"
class="btn btn-outline-primary btn-sm"
title="Edit IP">
<i class="bi bi-pencil"></i>
</a>
<!-- Enable/Disable Button -->
{% if ip.is_active %}
<form method="post" action="{{ url_for('email.disable_ip', ip_id=ip.id) }}" class="d-inline">
<button type="submit"
class="btn btn-outline-warning btn-sm"
title="Disable IP"
onclick="return confirm('Disable {{ ip.ip_address }}?')">
<i class="bi bi-pause-circle"></i>
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('email.enable_ip', ip_id=ip.id) }}" class="d-inline">
<button type="submit"
class="btn btn-outline-success btn-sm"
title="Enable IP"
onclick="return confirm('Enable {{ ip.ip_address }}?')">
<i class="bi bi-play-circle"></i>
</button>
</form>
{% endif %}
<!-- Permanent Remove Button -->
<form method="post" action="{{ url_for('email.remove_ip', ip_id=ip.id) }}" class="d-inline">
<button type="submit"
class="btn btn-outline-danger btn-sm"
title="Permanently Remove IP"
onclick="return confirm('Permanently remove {{ ip.ip_address }}? This cannot be undone!')">
<i class="bi bi-trash"></i>
</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
</div>
<div class="col-lg-4">
@@ -153,7 +234,7 @@
<ul class="list-unstyled mb-3">
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
<strong>Active IPs:</strong> {{ ips|selectattr('0.is_active')|list|length }}
<strong>Active IPs:</strong> {{ (ips|selectattr('0.is_active')|list|length) + (global_ips|selectattr('0.is_active')|list|length) }}
</li>
<li class="mb-2">
<i class="bi bi-server text-info me-2"></i>
@@ -175,9 +256,10 @@
</h6>
<ul class="mb-0 small">
<li>Whitelisted IPs can send emails without username/password authentication</li>
<li>Each IP is associated with a specific domain</li>
<li>IP can only send emails for its authorized domain</li>
<li>Useful for server-to-server email sending</li>
<li>Per Domain IP associated with a specific domain</li>
<li>Global IP can be used for all domains</li>
<li>Global IP whitelisting only Domains added to server, if domain is not added, global IP will not work</li>
<li>If IP is whitelisted, invalid username/password will still pass authentication</li>
</ul>
</div>
</div>
@@ -207,6 +289,7 @@
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
+20 -104
View File
@@ -16,9 +16,16 @@
.log-error { border-left-color: #dc3545; }
.log-success { border-left-color: #198754; }
.log-failed { border-left-color: #dc3545; }
.log-partial { border-left-color: #fd7e14; } /* Orange for partial fail */
/* Message display styles are now in view_message_content.html */
.log-content {
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>
{% endblock %}
@@ -76,30 +83,11 @@
{% for log_entry in logs %}
{% if log_entry.type == 'email' %}
{% set log = log_entry.data %}
{% 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="log-entry log-email log-{{ 'success' if log.status == 'relayed' else 'failed' }}">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<span class="badge bg-primary me-2">EMAIL</span>
<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 %}
<strong>{{ log.mail_from }}</strong> → {{ log.rcpt_tos }}
{% if log.dkim_signed %}
<span class="badge bg-success ms-2">
<i class="bi bi-shield-check me-1"></i>
@@ -107,15 +95,13 @@
</span>
{% endif %}
</div>
<small class="text-muted">{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</small>
<small class="text-muted">{{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</small>
</div>
<div class="row">
<div class="col-md-6">
<strong>Status:</strong>
{% if overall_status == 'relayed' %}
{% if log.status == 'relayed' %}
<span class="text-success">Sent Successfully</span>
{% elif overall_status == 'partial' %}
<span class="text-warning">Partial Fail</span>
{% else %}
<span class="text-danger">Failed</span>
{% endif %}
@@ -129,11 +115,6 @@
<strong>Subject:</strong> {{ log.subject }}
</div>
{% 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>
{% else %}
{% set log = log_entry.data %}
@@ -146,7 +127,7 @@
{{ 'Success' if log.success else 'Failed' }}
</span>
</div>
<small class="text-muted">{{ log.created_at|format_datetime }}</small>
<small class="text-muted">{{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</small>
</div>
<div class="row">
<div class="col-md-6">
@@ -167,28 +148,10 @@
{% elif filter_type == 'emails' %}
<!-- Email logs only -->
{% for log in logs %}
{% 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="log-entry log-email log-{{ 'success' if log.status == 'relayed' else 'failed' }}">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<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 %}
<strong>{{ log.mail_from }}</strong> → {{ log.rcpt_tos }}
{% if log.dkim_signed %}
<span class="badge bg-success ms-2">
<i class="bi bi-shield-check me-1"></i>
@@ -196,64 +159,24 @@
</span>
{% endif %}
</div>
<small class="text-muted">{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</small>
<small class="text-muted">{{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</small>
</div>
<div class="row">
<div class="col-md-3">
<strong>Status:</strong>
{% if overall_status == 'relayed' %}
{% if log.status == 'relayed' %}
<span class="text-success">Sent</span>
{% elif overall_status == 'partial' %}
<span class="text-warning">Partial Fail</span>
{% else %}
<span class="text-danger">Failed</span>
{% endif %}
</div>
<div class="col-md-3">
<strong>Peer:</strong> <code>{{ log.peer_ip }}</code>
<strong>Peer:</strong> <code>{{ log.peer }}</code>
</div>
<div class="col-md-6">
<strong>Message ID:</strong> <code>{{ log.message_id }}</code>
</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 %}
<div class="mt-2">
<strong>Subject:</strong> {{ log.subject }}
@@ -272,13 +195,6 @@
</div>
</div>
{% 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>
{% endfor %}
{% else %}
@@ -292,7 +208,7 @@
{{ 'Success' if log.success else 'Failed' }}
</span>
</div>
<small class="text-muted">{{ log.created_at|format_datetime }}</small>
<small class="text-muted">{{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</small>
</div>
<div class="row">
<div class="col-md-4">
@@ -85,7 +85,6 @@
<th>Domain</th>
<th>Permissions</th>
<th>Status</th>
<th>Storage</th>
<th>Created</th>
<th>Actions</th>
</tr>
@@ -129,19 +128,6 @@
</span>
{% endif %}
</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>
<small class="text-muted">
{{ sender.created_at.strftime('%Y-%m-%d %H:%M') }}
@@ -97,22 +97,10 @@
<input type="text"
class="form-control"
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 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="mb-3">
<label class="form-label">Hostname</label>
@@ -123,6 +111,8 @@
value="{{ settings['Server']['hostname'] }}">
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">HELO Hostname</label>
@@ -133,8 +123,6 @@
value="{{ settings['Server']['helo_hostname'] }}">
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Server Banner</label>
@@ -365,40 +353,6 @@
</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 -->
<div class="d-flex justify-content-between align-items-center">
<div class="alert alert-warning d-flex align-items-center mb-0">
@@ -648,60 +602,5 @@
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>
{% endblock %}
@@ -98,7 +98,7 @@
Server Settings
</a>
</li>
{#
<!-- Monitoring Section -->
<li class="nav-item mb-2">
<h6 class="text-muted text-uppercase small mb-2 mt-3">
@@ -114,7 +114,6 @@
Logs & Activity
</a>
</li>
#}
</ul>
</div>
@@ -1,98 +0,0 @@
{% 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:
"""Get the public IP address of the server."""
try:
response1 = requests.get('http://ifconfig.me/ip', timeout=3, verify=False)
response1 = requests.get('https://ifconfig.me/ip', timeout=3, verify=False)
ip = response1.text.strip()
if ip and ip != 'unknown':
@@ -24,7 +24,7 @@ def get_public_ip() -> str:
except Exception:
try:
# Fallback method
response = requests.get('http://httpbin.org/ip', timeout=3, verify=False)
response = requests.get('https://httpbin.org/ip', timeout=3, verify=False)
ip = response.json()['origin'].split(',')[0].strip()
if ip and ip != 'unknown':
return ip
-131
View File
@@ -1,131 +0,0 @@
"""
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()
+3 -5
View File
@@ -14,8 +14,8 @@ DEFAULTS = {
'; Server configuration for SMTP ports and hostname': None,
'; Plain SMTP port for internal/whitelisted IPs': None,
'SMTP_PORT': '4025',
'; TLS SMTP port for authenticated users': None,
'SMTP_TLS_PORT': '40465',
'; STARTTLS SMTP port for authenticated users': None,
'SMTP_TLS_PORT': '40587',
'; Server hostname for HELO/EHLO identification': None,
'HOSTNAME': 'mail.example.com',
'; Override HELO hostname': None,
@@ -24,8 +24,6 @@ DEFAULTS = {
'BIND_IP': '0.0.0.0',
'; Custom server banner (to make it empty use "" must be double quotes)': None,
'server_banner': "",
'; Time zone for the server': None,
'TIME_ZONE': 'Europe/London',
},
'Database': {
'; Database configuration': None,
@@ -40,7 +38,7 @@ DEFAULTS = {
},
'Relay': {
'; Timeout in seconds for external SMTP connections': None,
'RELAY_TIMEOUT': '30',
'RELAY_TIMEOUT': '10',
},
'TLS': {
'; TLS/SSL certificate configuration': None,
+121 -443
View File
@@ -8,59 +8,33 @@ Security Features:
- Enhanced header management
"""
import email.utils
import os
import mimetypes
import uuid
from datetime import datetime
from aiosmtpd.smtp import SMTP as AIOSMTP, AuthResult
from aiosmtpd.controller import Controller
from email_server.auth import EnhancedAuthenticator, EnhancedIPAuthenticator, validate_sender_authorization
from email_server.auth import EnhancedAuthenticator, EnhancedIPAuthenticator, validate_sender_authorization, get_authenticated_domain_id
from email_server.email_relay import EmailRelay
from email_server.dkim_manager import DKIMManager
from email_server.settings_loader import load_settings
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
from email_server.tool_box import get_logger
logger = get_logger()
settings = load_settings()
helo_hostname = settings['Server'].get('helo_hostname', settings['Server'].get('hostname', 'localhost'))
class CustomSMTP(AIOSMTP):
"""Custom SMTP class with configurable banner and secure AUTH handling."""
"""Custom SMTP class with configurable banner."""
def __init__(self, *args, **kwargs):
# Sets Custom SMTP banner from settings
settings = load_settings()
_banner_message = settings['Server'].get('server_banner', '')
if _banner_message == '""':
_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)
# Override the __ident__ to use our 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:
"""
Enhanced combined authenticator with sender validation support.
@@ -75,16 +49,22 @@ class EnhancedCombinedAuthenticator:
self.user_auth = EnhancedAuthenticator()
self.ip_auth = EnhancedIPAuthenticator()
def __call__(self, server, session, envelope, mechanism, auth_data):
async def __call__(self, server, session, envelope, mechanism, auth_data):
from aiosmtpd.smtp import LoginPassword
# If auth_data is provided (username/password), try user authentication first
if auth_data and isinstance(auth_data, LoginPassword):
try:
result = self.user_auth(server, session, envelope, mechanism, auth_data)
if result.success:
return result
# If user auth fails, don't try IP auth - return the failure
return result
# If user auth fails, send immediate response
await server.push('535 Authentication failed')
return AuthResult(success=False, handled=True)
except Exception as e:
logger.error(f"Authentication error: {e}")
await server.push('535 Authentication failed')
return AuthResult(success=False, handled=True)
# If no auth_data provided, IP auth will be validated during MAIL FROM
# For now, allow the connection to proceed
@@ -103,41 +83,57 @@ class EnhancedCustomSMTPHandler:
self.auth_methods = ['LOGIN', 'PLAIN']
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:
lines = content.splitlines()
for idx, line in enumerate(lines):
if not isinstance(line, str):
logger.error(f"_ensure_required_headers: Non-string line at index {idx}: {type(line)}: {line}")
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}")
settings = load_settings()
fallback_hostname = settings.get('Server', 'HOSTNAME', fallback='localhost')
server_hostname = settings.get('Server', 'helo_hostname', fallback=fallback_hostname)
logger.debug(f"Processing headers for message {message_id}")
# 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
body_start = 0
existing_headers = {}
original_header_order = []
for i, line in enumerate(lines):
if line.strip() == '':
body_start = i + 1
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')):
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_value = header_value.strip()
# Handle continuation lines
j = i + 1
while j < len(lines) and lines[j].startswith((' ', '\t')):
header_value += ' ' + lines[j].strip()
j += 1
existing_headers[header_name_lower] = header_value
original_header_order.append((header_name.strip(), header_value))
logger.debug(f"Found existing header: {header_name_lower} = {header_value}")
@@ -153,26 +149,10 @@ class EnhancedCustomSMTPHandler:
# 1. Message-ID (critical for spam filters)
if 'message-id' in existing_headers:
# 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}"
required_headers.append(f"Message-ID: {existing_headers['message-id']}")
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:
# No Message-ID found, generate new one
message_id = generate_message_id()
# Add the Message-ID header with the final ID
required_headers.append(f"Message-ID: <{message_id}>")
domain = envelope.mail_from.split('@')[1] if '@' in envelope.mail_from else server_hostname.replace('mail.', '')
required_headers.append(f"Message-ID: <{message_id}@{domain}>")
# 2. Date (critical for spam filters)
if 'date' in existing_headers:
@@ -187,49 +167,75 @@ class EnhancedCustomSMTPHandler:
else:
required_headers.append("MIME-Version: 1.0")
# 4. To (primary recipients - critical)
# 4. User-Agent (if present, helps with reputation)
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:
required_headers.append(f"To: {existing_headers['to']}")
else:
required_headers.append(f"To: {', '.join([rcpt for rcpt in envelope.rcpt_tos])}")
to_list = ', '.join(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']}")
# 6. From (sender identification - critical)
# 7. From (sender identification - critical)
if 'from' in existing_headers:
required_headers.append(f"From: {existing_headers['from']}")
else:
required_headers.append(f"From: {envelope.mail_from}")
# 7. Subject (message topic - critical)
# 8. Subject (message topic - critical)
if 'subject' in existing_headers:
required_headers.append(f"Subject: {existing_headers['subject']}")
else:
required_headers.append("Subject: ")
# 8. Content-Type (media type information)
# 9. Content-Type (media type information)
if 'content-type' in existing_headers:
required_headers.append(f"Content-Type: {existing_headers['content-type']}")
else:
required_headers.append("Content-Type: text/plain; charset=UTF-8; format=flowed")
# 9. Content-Transfer-Encoding
# 10. Content-Transfer-Encoding
if 'content-transfer-encoding' in existing_headers:
required_headers.append(f"Content-Transfer-Encoding: {existing_headers['content-transfer-encoding']}")
else:
required_headers.append("Content-Transfer-Encoding: 7bit")
# Add custom headers after essential headers
# Add custom headers after essential headers but before misc headers
if custom_headers:
for header_name, header_value in custom_headers:
header_name_lower = header_name.lower()
# Skip if header already exists
if header_name_lower not in existing_headers:
# Skip if already added in essential headers
if header_name.lower() not in ['message-id', 'date', 'mime-version', 'user-agent',
'content-language', 'to', 'from', 'subject',
'content-type', 'content-transfer-encoding']:
required_headers.append(f"{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
final_content = '\r\n'.join(required_headers)
if body.strip():
@@ -237,64 +243,31 @@ class EnhancedCustomSMTPHandler:
else:
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
except Exception as e:
import traceback
logger.error(f"Error ensuring headers: {e}")
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
logger.error(f"Locals: {locals()}")
# Fallback to original content if parsing fails
return content
async def handle_DATA(self, server, session, envelope):
"""Handle incoming email data with improved header management and logging."""
"""Handle incoming email data with improved header management."""
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
if isinstance(envelope.content, bytes):
content = envelope.content.decode('utf-8', errors='replace')
else:
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
sender_domain = envelope.mail_from.split('@')[1] if '@' in envelope.mail_from else None
@@ -306,8 +279,13 @@ class EnhancedCustomSMTPHandler:
# Add beneficial headers for spam score improvement
client_ip = getattr(session, 'peer', ['unknown'])[0] if hasattr(session, 'peer') else None
if client_ip:
# Add X-Originating-IP header (helps with reputation)
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'))
# Add X-Priority header (normal priority)
custom_headers.append(('X-Priority', '3'))
# Ensure required headers are present (including custom headers)
@@ -318,266 +296,38 @@ class EnhancedCustomSMTPHandler:
dkim_signed = False
if 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
if dkim_signed:
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()]
to_list = parse_addresses(to_address)
cc_list = parse_addresses(cc_addresses)
# Map recipients to their types based on headers
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(
# Relay the email (no further modifications allowed)
success = self.email_relay.relay_email(
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
signed_content
)
# 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)
# Extract headers
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
# Log the email
status = 'relayed' if success else 'failed'
self.email_relay.log_email(
message_id=message_id,
peer=client_ip,
peer=session.peer,
mail_from=envelope.mail_from,
to_address=to_address,
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,
rcpt_tos=envelope.rcpt_tos,
content=content, # Log original content, not signed
status=status,
dkim_signed=dkim_signed,
username=username,
recipient_results=recipient_results
dkim_signed=dkim_signed
)
# 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':
if success:
logger.debug(f'Email {message_id} successfully relayed')
return '250 Message accepted for delivery'
else:
logger.error(f'Email {message_id} failed to relay')
return '550 Message relay failed'
except Exception as e:
import traceback
logger.error(f'Error handling email: {e}')
logger.error(f'Traceback: {traceback.format_exc()}')
logger.error(f'Locals: {locals()}')
return '550 Internal server error'
async def handle_RCPT(self, server, session, envelope, address, rcpt_options):
@@ -608,80 +358,10 @@ class EnhancedCustomSMTPHandler:
logger.info(f'MAIL FROM accepted: {address} - {message}')
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):
"""
Custom controller for direct TLS (SMTPS, port 465) support.
"""
"""Custom controller with TLS support - modeled after the working original."""
def __init__(self, handler, ssl_context, hostname='localhost', port=40465):
def __init__(self, handler, ssl_context, hostname='localhost', port=40587):
logger.debug(f"TLSController __init__: ssl_context={ssl_context is not None}")
self._ssl_context = ssl_context # Use private attribute to avoid conflicts
self.smtp_hostname = hostname # Store for HELO identification
@@ -691,11 +371,10 @@ class TLSController(Controller):
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: hostname={self.smtp_hostname}")
# This is direct TLS (SMTPS, port 465 style)
smtp_instance = CustomSMTP(
self.handler,
tls_context=self._ssl_context,
require_starttls=False, # Direct TLS: do not advertise or require STARTTLS
require_starttls=False, # Don't require STARTTLS immediately, but make it available
auth_require_tls=True, # If auth is used, require TLS
authenticator=self.handler.combined_authenticator,
decode_data=True,
@@ -705,18 +384,17 @@ class TLSController(Controller):
return smtp_instance
class PlainController(Controller):
"""Controller for plain SMTP with authentication and IP whitelist fallback."""
"""Controller for plain SMTP with username/password and IP-based authentication."""
def __init__(self, handler, hostname='localhost', port=4025):
self.smtp_hostname = hostname # Store for HELO identification
super().__init__(handler, hostname='0.0.0.0', port=port) # Bind to all interfaces
def factory(self):
# Pass authenticator and set auth_require_tls=False to enable AUTH on plain port
return CustomSMTP(
self.handler,
authenticator=self.handler.combined_authenticator,
auth_require_tls=False, # Allow AUTH on plain port
auth_require_tls=False, # Allow AUTH over plain text (not recommended for production)
decode_data=True,
hostname=self.smtp_hostname # Use proper hostname for HELO
)
-20
View File
@@ -5,13 +5,8 @@ Utility functions for the email server.
import os
import logging
from email_server.settings_loader import load_settings
from datetime import datetime
import pytz
import time
import random
settings = load_settings()
helo_hostname = settings['Server'].get('helo_hostname', settings['Server'].get('hostname', 'localhost'))
def ensure_folder_exists(filepath):
"""
@@ -62,18 +57,3 @@ def get_logger(name=None):
else:
name = '__main__'
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
@@ -0,0 +1 @@
Single-database configuration for Flask.
@@ -1,11 +0,0 @@
-- 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
@@ -0,0 +1,50 @@
# 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
@@ -0,0 +1,113 @@
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
@@ -0,0 +1,24 @@
"""${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"}
@@ -0,0 +1,112 @@
"""update ip whitelist
Revision ID: 8652d2ab8a26
Revises:
Create Date: 2025-06-10 02:17:23.718102
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '8652d2ab8a26'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('esrv_auth_logs')
op.drop_table('esrv_email_logs')
op.drop_table('esrv_senders')
op.drop_table('esrv_whitelisted_ips')
op.drop_table('esrv_dkim_keys')
op.drop_table('esrv_domains')
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.ForeignKeyConstraint(['domain_id'], ['esrv_domains.id'], ),
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_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.ForeignKeyConstraint(['domain_id'], ['esrv_domains.id'], ),
sa.PrimaryKeyConstraint('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.ForeignKeyConstraint(['domain_id'], ['esrv_domains.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('esrv_senders',
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.ForeignKeyConstraint(['domain_id'], ['esrv_domains.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email')
)
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_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')
)
# ### end Alembic commands ###
+1 -2
View File
@@ -12,7 +12,6 @@ bcrypt
dnspython
dkimpy
cryptography
aiosmtplib
# Web Frontend Dependencies
Flask
@@ -20,7 +19,7 @@ Flask-SQLAlchemy
Jinja2
Werkzeug
requests
pytz
Flask-Migrate
gunicorn
# Additional utilities
+30 -69
View File
@@ -1,108 +1,69 @@
#!/bin/bash
# apt-get install -y swaks
receiver="info@example.com"
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!
receiver="info@example.com"
password="testpass123"
domain="example.com"
body_content_file="@tests/email_body.txt"
SMTP_PORT=4025
SMTP_TLS_PORT=40465
cc_recipient="ccrecipient@example.com"
bcc_recipient="bccrecipient@example.com"
SMTP_TLS_PORT=40587
cc_recipient="targetcc@example.com"
bcc_recipient="targetbcc@example.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
--cc $cc_recipient \
--bcc $bcc_recipient \
--header "To: $receiver" \
--header "Cc: $cc_recipient" \
--cc $cc_recipient
--bcc $bcc_recipient
com
swaks --to $receiver \
--from $sender \
--server $EMAIL_SERVER \
--server localhost \
--port $SMTP_TLS_PORT \
--auth LOGIN \
--auth-user $username \
--auth-user $sender \
--auth-password $password \
--tls \
--header "Subject: TLS - Large body email" \
--body "simple body content" \
--attach @/home/nahaku/Documents/Projects/SMTP_Server/tests/pdf_test_1.pdf \
--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
--body $body_content_file \
--attach tests/email_body.txt \
--attach tests/Hello.jpg
com
<<com
com
swaks --to $receiver \
--from $sender \
--server $EMAIL_SERVER \
--server localhost \
--port $SMTP_PORT \
--auth LOGIN \
--auth-user $username \
--auth-user $sender \
--auth-password $password \
--data "Subject: SMTP - authenticated success\n\nThis is the message body."
--data "Subject: Test Email - authenticated\n\nThis is the message body."
# Test with Authentication TLS
swaks --to $receiver \
--from $sender \
--server $EMAIL_SERVER \
--server localhost \
--port $SMTP_TLS_PORT \
--auth LOGIN \
--auth-user $username \
--auth-user $sender \
--auth-password $password \
--tls \
--header "Subject: TLS - authenticated success" \
--body "This is the message body with proper headers."
--data "Subject: Test via STARTTLS - authenticated\n\nThis is the body."
# 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 \
--server localhost \
--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
--data "Subject: Test via STARTTLS - no auth\n\nThis is the body."
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"
--server localhost \
--port $SMTP_PORT \
--data "Subject: Test Email - no auth\n\nThis is the message body."
-69
View File
@@ -1,69 +0,0 @@
# 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
parser = argparse.ArgumentParser()
parser.add_argument('--port', type=int, default=4025)
parser.add_argument('--porttls', type=int, default=40465)
parser.add_argument('--porttls', type=int, default=40587)
parser.add_argument('--recipient', type=str, default="test@target-email.com")
args = parser.parse_args()
+53
View File
@@ -0,0 +1,53 @@
"""
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()