Fixing To,CC,BCC and email headers applying, add date folder for storing attachments, add new settings management to web interface - timezone, attachment storage

This commit is contained in:
nahakubuilde
2025-06-14 09:58:55 +01:00
parent e300eb82d5
commit ec7fcaeeb6
9 changed files with 492 additions and 161 deletions

View File

@@ -12,7 +12,9 @@ import aiosmtplib
logger = get_logger()
settings = load_settings()
_relay_tls_timeout = settings['Server'].get('relay_timeout', 15)
_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."""
@@ -21,34 +23,81 @@ class EmailRelay:
self.timeout = _relay_tls_timeout # 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'))
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, removing Bcc."""
"""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 + 1
body_start = i
break
# Skip existing To, Cc, Bcc headers
if not line.lower().startswith(('to:', 'cc:', 'bcc:')):
new_headers.append(line)
# Add new To and Cc headers
if to_addresses:
# 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 cc_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,
@@ -60,19 +109,7 @@ class EmailRelay:
recipient_types: list[str] = None
) -> list[dict]:
"""Relay email to recipients' mail servers asynchronously with encryption.
Args:
mail_from (str): Sender email address.
rcpt_tos (list[str]): List of recipient email addresses.
content (str): Raw email content.
username (str, optional): Authenticated username.
cc_addresses (list[str], optional): CC addresses.
bcc_addresses (list[str], optional): BCC addresses.
recipient_types (list[str], optional): Recipient types (to/cc/bcc).
Returns:
list[dict]: Per-recipient delivery results.
"""
Preserves DKIM signatures by not modifying the signed content."""
results = []
recipient_type_map = {}
if recipient_types and len(recipient_types) == len(rcpt_tos):
@@ -82,18 +119,35 @@ class EmailRelay:
for addr in rcpt_tos:
recipient_type_map[addr] = 'to'
domain_groups = {}
# Separate visible recipients (TO/CC) and BCC recipients
visible_recipients = []
bcc_list = []
for rcpt in rcpt_tos:
if recipient_type_map.get(rcpt) in ['to', 'cc']:
visible_recipients.append(rcpt)
elif recipient_type_map.get(rcpt) == 'bcc':
bcc_list.append(rcpt)
# Group recipients by domain for efficient delivery
domain_groups = {}
for rcpt in visible_recipients:
domain = rcpt.split('@')[1].lower()
rtype = recipient_type_map.get(rcpt, 'to')
if domain not in domain_groups:
domain_groups[domain] = {'to': [], 'cc': [], 'bcc': []}
domain_groups[domain][rtype].append(rcpt)
# Handle TO/CC recipients - use original signed content
for domain, recipients in domain_groups.items():
to_recipients = recipients['to']
cc_recipients = recipients['cc']
bcc_recipients = recipients['bcc']
if not to_recipients and not cc_recipients:
continue
# Prepare content for TO/CC recipients without modifying headers
prepared_content = self._prepare_email_for_recipient(content)
try:
mx_records = dns.resolver.resolve(domain, 'MX')
mx_records = sorted(mx_records, key=lambda x: x.preference)
@@ -101,7 +155,7 @@ class EmailRelay:
logger.debug(f'Found MX records for {domain}: {mx_hosts}')
except Exception as e:
logger.error(f'Failed to resolve MX for {domain}: {e}')
for rcpt in to_recipients + cc_recipients + bcc_recipients:
for rcpt in to_recipients + cc_recipients:
results.append({
'recipient': rcpt,
'status': 'failed',
@@ -112,111 +166,131 @@ class EmailRelay:
})
continue
# Relay to To and Cc recipients together
if to_recipients or cc_recipients:
modified_content = self._modify_headers_for_recipients(content, to_recipients, cc_recipients)
delivered = False
last_error = None
for mx_host in mx_hosts:
try:
# Only use port 25 for MX delivery
port = 25
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, modified_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
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:
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': 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')
})
# Relay to Bcc recipients individually
for bcc in bcc_recipients:
modified_content = self._modify_headers_for_recipients(content, [bcc], None)
delivered = False
last_error = None
for mx_host in mx_hosts:
try:
port = 25
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], modified_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'
'recipient_type': recipient_type_map.get(rcpt, 'to')
})
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,
'recipient_type': 'bcc'
}
continue
if not delivered and last_error:
await smtp.quit()
delivered = True
break
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)
try:
mx_records = dns.resolver.resolve(domain, 'MX')
mx_records = sorted(mx_records, key=lambda x: x.preference)
mx_hosts = [mx.exchange.to_text().rstrip('.') for mx in mx_records]
logger.debug(f'Found MX records for {domain}: {mx_hosts}')
except Exception as e:
logger.error(f'Failed to resolve MX for {domain}: {e}')
results.append({
'recipient': bcc,
'status': 'failed',
'error_code': 'MX',
'error_message': str(e),
'server_response': None,
'recipient_type': 'bcc'
})
continue
delivered = False
last_error = None
for mx_host in mx_hosts:
try:
smtp = aiosmtplib.SMTP(hostname=mx_host, port=port, timeout=self.timeout, local_hostname=self.hostname)
await smtp.connect()
ext = getattr(smtp, 'extensions', None)
if ext is None:
ext = getattr(smtp, 'esmtp_extensions', None)
if ext is None:
logger.error(f"SMTP object has no 'extensions' or 'esmtp_extensions'. Available attributes: {dir(smtp)}")
ext = {}
if 'starttls' in ext:
logger.debug(f'STARTTLS supported by {mx_host}:{port} for BCC, upgrading to TLS')
await smtp.starttls()
else:
logger.warning(f'STARTTLS not supported by {mx_host}:{port} for BCC, sending in plain text!')
response = await smtp.sendmail(mail_from, [bcc], prepared_content)
logger.debug(f'Successfully relayed BCC email to {bcc} via {mx_host}:{port}')
results.append({
'recipient': bcc,
**last_error
'status': 'success',
'error_code': None,
'error_message': None,
'server_response': str(response),
'recipient_type': 'bcc'
})
await smtp.quit()
delivered = True
break
except Exception as e:
logger.error(f'Failed to relay BCC email to {bcc} via {mx_host}:{port}: {e}')
last_error = {
'status': 'failed',
'error_code': 'RELAY',
'error_message': str(e),
'server_response': None
}
continue
if not delivered and last_error:
results.append({
'recipient': bcc,
'status': last_error['status'],
'error_code': last_error['error_code'],
'error_message': last_error['error_message'],
'server_response': last_error['server_response'],
'recipient_type': 'bcc'
})
return results
def relay_email(self, *args, **kwargs):

View File

@@ -12,6 +12,7 @@ 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
@@ -29,11 +30,21 @@ 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."""
settings = load_settings()
return render_template('settings.html', settings=settings)
return render_template('settings.html', **get_template_context())
@email_bp.route('/settings_update', methods=['POST'])
def settings_update():
@@ -195,3 +206,40 @@ 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())

View File

@@ -134,7 +134,7 @@
<tr>
<th>Time</th>
<th>From</th>
<th>To</th>
<th>Recipients</th>
<th>Status</th>
<th>DKIM</th>
</tr>
@@ -153,9 +153,46 @@
</span>
</td>
<td>
<span class="text-truncate d-inline-block" style="max-width: 150px;" title="{{ email.rcpt_tos }}">
{{ email.rcpt_tos }}
</span>
<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>
</td>
<td>
{% set delivered = recipient_logs_map[email.id]|selectattr('status', 'equalto', 'success')|list %}
@@ -167,7 +204,6 @@
{% else %}
{% set overall_status = 'failed' %}
{% endif %}
<td>
{% if overall_status == 'relayed' %}
<span class="badge bg-success">
<i class="bi bi-check-circle me-1"></i>
@@ -334,6 +370,15 @@
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>

View File

@@ -97,10 +97,22 @@
<input type="text"
class="form-control"
name="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]?)$">
value="{{ settings['Server']['bind_ip'] }}">
</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>
@@ -111,8 +123,6 @@
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>
@@ -123,6 +133,8 @@
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>
@@ -353,6 +365,40 @@
</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">
@@ -602,5 +648,60 @@
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 %}

View File

@@ -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,6 +114,7 @@
Logs & Activity
</a>
</li>
#}
</ul>
</div>

View File

@@ -40,7 +40,7 @@ DEFAULTS = {
},
'Relay': {
'; Timeout in seconds for external SMTP connections': None,
'RELAY_TIMEOUT': '10',
'RELAY_TIMEOUT': '30',
},
'TLS': {
'; TLS/SSL certificate configuration': None,

View File

@@ -8,16 +8,16 @@ Security Features:
- Enhanced header management
"""
import uuid
import email.utils
import os
import mimetypes
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.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
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
@@ -25,6 +25,8 @@ from email_server.models import Session, EmailAttachment, EmailLog
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."""
@@ -151,14 +153,26 @@ class EnhancedCustomSMTPHandler:
# 1. Message-ID (critical for spam filters)
if 'message-id' in existing_headers:
required_headers.append(f"Message-ID: {existing_headers['message-id']}")
# Parse existing Message-ID
existing_msg_id = existing_headers['message-id'].strip('<>')
if '@' in existing_msg_id:
prefix, hostname = existing_msg_id.rsplit('@', 1)
hostname = hostname.rstrip('>')
if hostname.lower() != helo_hostname.lower():
# If hostname is wrong, modify it to use our hostname
message_id = f"{prefix}@{helo_hostname}"
else:
# If hostname is correct, keep original ID
message_id = existing_msg_id
else:
# Malformed Message-ID, generate new one
message_id = generate_message_id()
else:
# Use helo_hostname from settings for FQDN in Message-ID
helo_hostname = settings['Server'].get('helo_hostname', 'localhost')
if not helo_hostname:
# fallback to domain from mail_from
helo_hostname = envelope.mail_from.split('@')[1] if '@' in envelope.mail_from else 'localhost'
required_headers.append(f"Message-ID: <{message_id}@{helo_hostname}>")
# 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}>")
# 2. Date (critical for spam filters)
if 'date' in existing_headers:
@@ -236,8 +250,28 @@ class EnhancedCustomSMTPHandler:
async def handle_DATA(self, server, session, envelope):
"""Handle incoming email data with improved header management and logging."""
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)
@@ -378,11 +412,17 @@ class EnhancedCustomSMTPHandler:
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"{message_id}_{filename}"
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)
@@ -569,7 +609,7 @@ class EnhancedCustomSMTPHandler:
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 and authentication.
"""Generate the storage path for attachments based on sender domain, authentication, and date.
Args:
attachments_base_path: Base path for attachments storage
@@ -578,29 +618,36 @@ class EnhancedCustomSMTPHandler:
client_ip: Client IP address (if IP-based authentication)
Returns:
str: Full path where attachments should be stored
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 subfolder based on authentication
# Determine auth-based subfolder path
if username:
# Sanitize username for folder name
safe_username = username.replace('/', '_').replace('\\', '_')
return os.path.join(domain_path, safe_username)
auth_path = os.path.join(domain_path, safe_username)
elif client_ip:
# Sanitize IP for folder name
safe_ip = client_ip.replace(':', '_')
return os.path.join(domain_path, safe_ip)
auth_path = os.path.join(domain_path, safe_ip)
else:
# Fallback to domain-only path
return domain_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."""
import mimetypes
# First try the part's content type
content_type = part.get_content_type()

View File

@@ -7,8 +7,11 @@ 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):
"""
@@ -63,4 +66,14 @@ def get_logger(name=None):
def get_current_time():
"""Get current time with timezone from settings."""
timezone = pytz.timezone(settings['Server'].get('time_zone', 'UTC'))
return datetime.now(timezone)
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}"

View File

@@ -31,9 +31,11 @@ swaks --to $receiver \
--auth-password $password \
--tls \
--header "Subject: TLS - Large body email" \
--body $body_content_file
# --attach @/home/nahaku/Documents/Projects/SMTP_Server/tests/email_body.txt
# --attach @/home/nahaku/Documents/Projects/SMTP_Server/tests/Hello.jpg
--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
com
<<com