From ae2e5d80f11f4ccc3118c0498c3220357af3d130 Mon Sep 17 00:00:00 2001 From: nahakubuilde Date: Sat, 7 Jun 2025 06:52:42 +0100 Subject: [PATCH] fix issue with headers order -correct DKIM now. --- email_server/cli_tools.py | 2 +- email_server/smtp_handler.py | 184 ++++++++++++++++++++++++++--------- pymta-server.service | 32 ++++++ requirements.txt | 6 +- 4 files changed, 177 insertions(+), 47 deletions(-) create mode 100644 pymta-server.service diff --git a/email_server/cli_tools.py b/email_server/cli_tools.py index 0f797f9..6e98954 100644 --- a/email_server/cli_tools.py +++ b/email_server/cli_tools.py @@ -233,7 +233,7 @@ def main(): print("Database tables created successfully") elif args.command == 'add-domain': - add_domain(args.domain, not args.no_auth) + add_domain(args.domain) elif args.command == 'add-user': add_user(args.email, args.password, args.domain) diff --git a/email_server/smtp_handler.py b/email_server/smtp_handler.py index 5da2b3c..7c8dc15 100644 --- a/email_server/smtp_handler.py +++ b/email_server/smtp_handler.py @@ -9,7 +9,6 @@ Security Features: """ import uuid -import email.utils from datetime import datetime from aiosmtpd.smtp import SMTP as AIOSMTP, AuthResult from aiosmtpd.controller import Controller @@ -61,48 +60,145 @@ class EnhancedCustomSMTPHandler: self.auth_require_tls = False 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. - + 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. + str: Email content with all required headers properly formatted. """ - import email - from email.parser import Parser - from email.policy import default - - # Parse the message using the email library - msg = Parser(policy=default).parsestr(content) - - # Set or add required headers if missing - if not msg.get('Message-ID'): - msg['Message-ID'] = f"<{message_id}@{envelope.mail_from.split('@')[1] if '@' in envelope.mail_from else 'localhost'}>" - if not msg.get('Date'): - msg['Date'] = email.utils.formatdate(localtime=True) - if not msg.get('From'): - msg['From'] = envelope.mail_from - if not msg.get('To'): - msg['To'] = ', '.join(envelope.rcpt_tos) - if not msg.get('MIME-Version'): - msg['MIME-Version'] = '1.0' - if not msg.get('Content-Type'): - msg['Content-Type'] = 'text/plain; charset=utf-8' - if not msg.get('Subject'): - msg['Subject'] = '(No Subject)' - if not msg.get('Content-Transfer-Encoding'): - msg['Content-Transfer-Encoding'] = '7bit' - - # Ensure exactly one blank line between headers and body - # The email library will handle this when flattening - from io import StringIO - out = StringIO() - out.write(msg.as_string()) - return out.getvalue() + import email.utils + + try: + 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 = {} + + for i, line in enumerate(lines): + if line.strip() == '': + body_start = i + 1 + break + if ':' in line and not line.startswith((' ', '\t')): + header_name, header_value = line.split(':', 1) + 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 + 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): """Handle incoming email data.""" @@ -119,26 +215,24 @@ class EnhancedCustomSMTPHandler: # Extract domain from sender for DKIM signing sender_domain = envelope.mail_from.split('@')[1] if '@' in envelope.mail_from else None - # Ensure required headers are present - content = self._ensure_required_headers(content, envelope, message_id) - - # Add custom headers before DKIM signing + # Get custom headers before processing + custom_headers = [] if 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 dkim_signed = False if sender_domain: - # DKIM-sign the final version of the message signed_content = self.dkim_manager.sign_email(content, sender_domain) dkim_signed = signed_content != content if dkim_signed: 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( envelope.mail_from, envelope.rcpt_tos, diff --git a/pymta-server.service b/pymta-server.service new file mode 100644 index 0000000..3908070 --- /dev/null +++ b/pymta-server.service @@ -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 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index cc73bef..64954fe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 sqlalchemy pyOpenSSL