fix issue with headers order -correct DKIM now.

This commit is contained in:
nahakubuilde
2025-06-07 06:52:42 +01:00
parent db5767a547
commit ae2e5d80f1
4 changed files with 177 additions and 47 deletions

View File

@@ -233,7 +233,7 @@ def main():
print("Database tables created successfully") print("Database tables created successfully")
elif args.command == 'add-domain': elif args.command == 'add-domain':
add_domain(args.domain, not args.no_auth) add_domain(args.domain)
elif args.command == 'add-user': elif args.command == 'add-user':
add_user(args.email, args.password, args.domain) add_user(args.email, args.password, args.domain)

View File

@@ -9,7 +9,6 @@ Security Features:
""" """
import uuid import uuid
import email.utils
from datetime import datetime from datetime import datetime
from aiosmtpd.smtp import SMTP as AIOSMTP, AuthResult from aiosmtpd.smtp import SMTP as AIOSMTP, AuthResult
from aiosmtpd.controller import Controller from aiosmtpd.controller import Controller
@@ -61,48 +60,145 @@ class EnhancedCustomSMTPHandler:
self.auth_require_tls = False self.auth_require_tls = False
self.auth_methods = ['LOGIN', 'PLAIN'] self.auth_methods = ['LOGIN', 'PLAIN']
def _ensure_required_headers(self, content: str, envelope, message_id: str) -> str: def _ensure_required_headers(self, content: str, envelope, message_id: str, custom_headers: list = None) -> str:
"""Ensure all required email headers are present and properly formatted. """Ensure all required email headers are present and properly formatted.
Args: Args:
content (str): Email content. content (str): Email content.
envelope: SMTP envelope. envelope: SMTP envelope.
message_id (str): Generated message ID. message_id (str): Generated message ID.
custom_headers (list): List of (name, value) tuples for custom headers.
Returns: Returns:
str: Email content with all required headers. str: Email content with all required headers properly formatted.
""" """
import email import email.utils
from email.parser import Parser
from email.policy import default try:
logger.debug(f"Processing headers for message {message_id}")
# Parse the message using the email library
msg = Parser(policy=default).parsestr(content) # Parse the message properly
if isinstance(content, bytes):
# Set or add required headers if missing content = content.decode('utf-8', errors='replace')
if not msg.get('Message-ID'):
msg['Message-ID'] = f"<{message_id}@{envelope.mail_from.split('@')[1] if '@' in envelope.mail_from else 'localhost'}>" # Split content into lines and normalize line endings
if not msg.get('Date'): lines = content.replace('\r\n', '\n').replace('\r', '\n').split('\n')
msg['Date'] = email.utils.formatdate(localtime=True)
if not msg.get('From'): # Find header/body boundary and collect existing headers
msg['From'] = envelope.mail_from body_start = 0
if not msg.get('To'): existing_headers = {}
msg['To'] = ', '.join(envelope.rcpt_tos)
if not msg.get('MIME-Version'): for i, line in enumerate(lines):
msg['MIME-Version'] = '1.0' if line.strip() == '':
if not msg.get('Content-Type'): body_start = i + 1
msg['Content-Type'] = 'text/plain; charset=utf-8' break
if not msg.get('Subject'): if ':' in line and not line.startswith((' ', '\t')):
msg['Subject'] = '(No Subject)' header_name, header_value = line.split(':', 1)
if not msg.get('Content-Transfer-Encoding'): header_name_lower = header_name.strip().lower()
msg['Content-Transfer-Encoding'] = '7bit' header_value = header_value.strip()
# Ensure exactly one blank line between headers and body # Handle continuation lines
# The email library will handle this when flattening j = i + 1
from io import StringIO while j < len(lines) and lines[j].startswith((' ', '\t')):
out = StringIO() header_value += ' ' + lines[j].strip()
out.write(msg.as_string()) j += 1
return out.getvalue()
existing_headers[header_name_lower] = header_value
logger.debug(f"Found existing header: {header_name_lower} = {header_value}")
# Extract body
body_lines = lines[body_start:] if body_start < len(lines) else []
while body_lines and body_lines[-1].strip() == '':
body_lines.pop()
body = '\n'.join(body_lines)
# Build required headers list
required_headers = []
# Add custom headers first
if custom_headers:
for header_name, header_value in custom_headers:
required_headers.append(f"{header_name}: {header_value}")
logger.debug(f"Added custom header: {header_name}: {header_value}")
# Message-ID (always add to ensure it's present)
if 'message-id' in existing_headers:
required_headers.append(f"Message-ID: {existing_headers['message-id']}")
else:
domain = envelope.mail_from.split('@')[1] if '@' in envelope.mail_from else 'localhost'
required_headers.append(f"Message-ID: <{message_id}@{domain}>")
# Date
if 'date' in existing_headers:
required_headers.append(f"Date: {existing_headers['date']}")
else:
date_str = email.utils.formatdate(localtime=True)
required_headers.append(f"Date: {date_str}")
# From (required)
if 'from' in existing_headers:
required_headers.append(f"From: {existing_headers['from']}")
else:
required_headers.append(f"From: {envelope.mail_from}")
# To (required)
if 'to' in existing_headers:
required_headers.append(f"To: {existing_headers['to']}")
else:
to_list = ', '.join(envelope.rcpt_tos)
required_headers.append(f"To: {to_list}")
# Subject (preserve existing or add empty)
if 'subject' in existing_headers:
required_headers.append(f"Subject: {existing_headers['subject']}")
else:
required_headers.append("Subject: ")
# MIME headers
if 'mime-version' in existing_headers:
required_headers.append(f"MIME-Version: {existing_headers['mime-version']}")
else:
required_headers.append("MIME-Version: 1.0")
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")
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 any other existing headers
essential_headers = {
'message-id', 'date', 'from', 'to', 'subject',
'mime-version', 'content-type', 'content-transfer-encoding'
}
for header_name, header_value in existing_headers.items():
if header_name not in essential_headers:
required_headers.append(f"{header_name.title()}: {header_value}")
# Build final message
final_content = '\r\n'.join(required_headers)
if body.strip():
final_content += '\r\n\r\n' + body
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:
logger.error(f"Error ensuring headers: {e}")
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
# Fallback to original content if parsing fails
return content
async def handle_DATA(self, server, session, envelope): async def handle_DATA(self, server, session, envelope):
"""Handle incoming email data.""" """Handle incoming email data."""
@@ -119,26 +215,24 @@ class EnhancedCustomSMTPHandler:
# Extract domain from sender for DKIM signing # Extract domain from sender for DKIM signing
sender_domain = envelope.mail_from.split('@')[1] if '@' in envelope.mail_from else None sender_domain = envelope.mail_from.split('@')[1] if '@' in envelope.mail_from else None
# Ensure required headers are present # Get custom headers before processing
content = self._ensure_required_headers(content, envelope, message_id) custom_headers = []
# Add custom headers before DKIM signing
if sender_domain: if sender_domain:
custom_headers = self.dkim_manager.get_active_custom_headers(sender_domain) custom_headers = self.dkim_manager.get_active_custom_headers(sender_domain)
for header_name, header_value in custom_headers:
# Insert header at the top of the message
content = f"{header_name}: {header_value}\r\n" + content
# Relay the email (all modifications done) # Ensure required headers are present (including custom headers)
content = self._ensure_required_headers(content, envelope, message_id, custom_headers)
# DKIM-sign the final version of the message (only once, after all modifications)
signed_content = content signed_content = content
dkim_signed = False dkim_signed = False
if sender_domain: if sender_domain:
# DKIM-sign the final version of the message
signed_content = self.dkim_manager.sign_email(content, sender_domain) signed_content = self.dkim_manager.sign_email(content, sender_domain)
dkim_signed = signed_content != content dkim_signed = signed_content != content
if dkim_signed: if dkim_signed:
logger.debug(f'Email {message_id} signed with DKIM for domain {sender_domain}') logger.debug(f'Email {message_id} signed with DKIM for domain {sender_domain}')
# Relay the email (no further modifications allowed)
success = self.email_relay.relay_email( success = self.email_relay.relay_email(
envelope.mail_from, envelope.mail_from,
envelope.rcpt_tos, envelope.rcpt_tos,

32
pymta-server.service Normal file
View File

@@ -0,0 +1,32 @@
[Unit]
Description=PyMTA Email Server
After=network.target
StartLimitIntervalSec=0
# check any errors when using this service:
# journalctl -u pymta-server.service -b -f
[Service]
Type=simple
User=appuser
Group=appuser
WorkingDirectory=/opt/PyMTA-server
Environment=PYTHONUNBUFFERED=1
ExecStart=/opt/PyMTA-server/.venv/bin/python main.py
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
# Security settings
# Capabilities for low ports < 1024 following 2 lines:
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
# if using port < 1024 comment out line bellow:
# NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/opt/PyMTA-server
ProtectHome=true
[Install]
WantedBy=multi-user.target

View File

@@ -1,4 +1,8 @@
# SMTP MTA server: # SMTP MTA server
# then you can allow run ports < 1024 with
# create env with `python -m venv .venv --copies` (This will copy the Python binary)
# for f in /opt/PyMTA-server/.venv/bin/python*; do sudo setcap 'cap_net_bind_service=+ep' "$f"; done
aiosmtpd aiosmtpd
sqlalchemy sqlalchemy
pyOpenSSL pyOpenSSL