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() logger = get_logger()
settings = load_settings() 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: class EmailRelay:
"""Handles relaying emails to recipient mail servers.""" """Handles relaying emails to recipient mail servers."""
@@ -21,34 +23,81 @@ class EmailRelay:
self.timeout = _relay_tls_timeout # Increased timeout for TLS negotiations self.timeout = _relay_tls_timeout # Increased timeout for TLS negotiations
# Get the configured hostname for HELO/EHLO identification # Get the configured hostname for HELO/EHLO identification
self.hostname = settings['Server'].get('helo_hostname', self.hostname = settings['Server'].get('helo_hostname', settings['Server'].get('hostname', 'localhost'))
settings['Server'].get('hostname', 'localhost'))
logger.debug(f"EmailRelay initialized with hostname: {self.hostname}") logger.debug(f"EmailRelay initialized with hostname: {self.hostname}")
def _modify_headers_for_recipients(self, content, to_addresses, cc_addresses=None): 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() lines = content.splitlines()
new_headers = [] new_headers = []
body_start = 0 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): for i, line in enumerate(lines):
if line.strip() == '': if line.strip() == '':
body_start = i + 1 body_start = i
break break
# Skip existing To, Cc, Bcc headers # Skip BCC headers but preserve TO and CC
if line.lower().startswith('bcc:'):
if not line.lower().startswith(('to:', 'cc:', 'bcc:')): continue
new_headers.append(line) # Track if we have TO/CC headers
if line.lower().startswith('to:'):
# Add new To and Cc headers has_to = True
if to_addresses: 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)}") 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)}") new_headers.append(f"Cc: {', '.join(cc_addresses)}")
# Reconstruct the message # Reconstruct the message
body = '\n'.join(lines[body_start:]) if body_start < len(lines) else '' body = '\n'.join(lines[body_start:]) if body_start < len(lines) else ''
return '\r\n'.join(new_headers) + '\r\n\r\n' + body 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( async def relay_email_async(
self, self,
mail_from: str, mail_from: str,
@@ -60,19 +109,7 @@ class EmailRelay:
recipient_types: list[str] = None recipient_types: list[str] = None
) -> list[dict]: ) -> list[dict]:
"""Relay email to recipients' mail servers asynchronously with encryption. """Relay email to recipients' mail servers asynchronously with encryption.
Preserves DKIM signatures by not modifying the signed content."""
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.
"""
results = [] results = []
recipient_type_map = {} recipient_type_map = {}
if recipient_types and len(recipient_types) == len(rcpt_tos): if recipient_types and len(recipient_types) == len(rcpt_tos):
@@ -82,18 +119,35 @@ class EmailRelay:
for addr in rcpt_tos: for addr in rcpt_tos:
recipient_type_map[addr] = 'to' recipient_type_map[addr] = 'to'
domain_groups = {} # Separate visible recipients (TO/CC) and BCC recipients
visible_recipients = []
bcc_list = []
for rcpt in rcpt_tos: 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() domain = rcpt.split('@')[1].lower()
rtype = recipient_type_map.get(rcpt, 'to') rtype = recipient_type_map.get(rcpt, 'to')
if domain not in domain_groups: if domain not in domain_groups:
domain_groups[domain] = {'to': [], 'cc': [], 'bcc': []} domain_groups[domain] = {'to': [], 'cc': [], 'bcc': []}
domain_groups[domain][rtype].append(rcpt) domain_groups[domain][rtype].append(rcpt)
# Handle TO/CC recipients - use original signed content
for domain, recipients in domain_groups.items(): for domain, recipients in domain_groups.items():
to_recipients = recipients['to'] to_recipients = recipients['to']
cc_recipients = recipients['cc'] 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: try:
mx_records = dns.resolver.resolve(domain, 'MX') mx_records = dns.resolver.resolve(domain, 'MX')
mx_records = sorted(mx_records, key=lambda x: x.preference) 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}') logger.debug(f'Found MX records for {domain}: {mx_hosts}')
except Exception as e: except Exception as e:
logger.error(f'Failed to resolve MX for {domain}: {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({ results.append({
'recipient': rcpt, 'recipient': rcpt,
'status': 'failed', 'status': 'failed',
@@ -112,111 +166,131 @@ class EmailRelay:
}) })
continue continue
# Relay to To and Cc recipients together delivered = False
if to_recipients or cc_recipients: last_error = None
modified_content = self._modify_headers_for_recipients(content, to_recipients, cc_recipients) for mx_host in mx_hosts:
delivered = False try:
last_error = None smtp = aiosmtplib.SMTP(hostname=mx_host, port=port, timeout=self.timeout, local_hostname=self.hostname)
for mx_host in mx_hosts: await smtp.connect()
try: ext = getattr(smtp, 'extensions', None)
# Only use port 25 for MX delivery if ext is None:
port = 25 ext = getattr(smtp, 'esmtp_extensions', None)
smtp = aiosmtplib.SMTP(hostname=mx_host, port=port, timeout=self.timeout, local_hostname=self.hostname) if ext is None:
await smtp.connect() logger.error(f"SMTP object has no 'extensions' or 'esmtp_extensions'. Available attributes: {dir(smtp)}")
ext = getattr(smtp, 'extensions', None) ext = {}
if ext is None: if 'starttls' in ext:
ext = getattr(smtp, 'esmtp_extensions', None) logger.debug(f'STARTTLS supported by {mx_host}:{port}, upgrading to TLS')
if ext is None: await smtp.starttls()
logger.error(f"SMTP object has no 'extensions' or 'esmtp_extensions'. Available attributes: {dir(smtp)}") else:
ext = {} logger.warning(f'STARTTLS not supported by {mx_host}:{port}, sending in plain text!')
if 'starttls' in ext: response = await smtp.sendmail(mail_from, to_recipients + cc_recipients, prepared_content)
logger.debug(f'STARTTLS supported by {mx_host}:{port}, upgrading to TLS') logger.debug(f'Successfully relayed email to {to_recipients + cc_recipients} via {mx_host}:{port}')
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:
for rcpt in to_recipients + cc_recipients: for rcpt in to_recipients + cc_recipients:
results.append({ results.append({
'recipient': rcpt, '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', 'status': 'success',
'error_code': None, 'error_code': None,
'error_message': None, 'error_message': None,
'server_response': str(response), 'server_response': str(response),
'recipient_type': 'bcc' 'recipient_type': recipient_type_map.get(rcpt, 'to')
}) })
await smtp.quit() await smtp.quit()
delivered = True delivered = True
break break
except Exception as e: except Exception as e:
logger.error(f'Failed to relay BCC email to {bcc} via {mx_host}:{port}: {e}') logger.error(f'Failed to relay email to {to_recipients + cc_recipients} via {mx_host}:{port}: {e}')
last_error = { last_error = {
'status': 'failed', 'status': 'failed',
'error_code': 'RELAY', 'error_code': 'RELAY',
'error_message': str(e), 'error_message': str(e),
'server_response': None, 'server_response': None
'recipient_type': 'bcc' }
} continue
continue
if not delivered and last_error: 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({ results.append({
'recipient': bcc, '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 return results
def relay_email(self, *args, **kwargs): def relay_email(self, *args, **kwargs):

View File

@@ -12,6 +12,7 @@ This module provides server settings management functionality including:
import os import os
import time import time
from pathlib import Path from pathlib import Path
import zoneinfo
from flask import render_template, request, redirect, url_for, flash, jsonify from flask import render_template, request, redirect, url_for, flash, jsonify
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from email_server.settings_loader import load_settings, SETTINGS_PATH from email_server.settings_loader import load_settings, SETTINGS_PATH
@@ -29,11 +30,21 @@ ALLOWED_EXTENSIONS = {'crt', 'key', 'pem'}
def allowed_file(filename): def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def get_template_context():
"""Get template context with CSRF token and common data."""
context = {
'settings': load_settings(),
'timezones': get_available_timezones(),
}
# Only add CSRF token if it exists and is enabled
if hasattr(request, 'csrf_token'):
context['csrf_token_value'] = request.csrf_token
return context
@email_bp.route('/settings') @email_bp.route('/settings')
def settings(): def settings():
"""Display and edit server settings.""" """Display and edit server settings."""
settings = load_settings() return render_template('settings.html', **get_template_context())
return render_template('settings.html', settings=settings)
@email_bp.route('/settings_update', methods=['POST']) @email_bp.route('/settings_update', methods=['POST'])
def settings_update(): def settings_update():
@@ -195,3 +206,40 @@ def get_server_ip():
except Exception as e: except Exception as e:
logger.error(f"Error getting public IP: {e}") logger.error(f"Error getting public IP: {e}")
return jsonify({'status': 'error', 'message': str(e)}) return jsonify({'status': 'error', 'message': str(e)})
@email_bp.route('/test_attachments_path', methods=['POST'])
def test_attachments_path():
"""Test if the attachments path is writable."""
path = request.form.get('path')
if not path:
return jsonify({'success': False, 'message': 'No path provided'})
# Convert to absolute path if relative
if not os.path.isabs(path):
path = os.path.abspath(os.path.join(os.path.dirname(SETTINGS_PATH), path))
try:
# Create path if it doesn't exist
os.makedirs(path, exist_ok=True)
# Try to create a test file
test_file = os.path.join(path, '.write_test')
with open(test_file, 'w') as f:
f.write('test')
os.remove(test_file)
return jsonify({
'success': True,
'message': 'Attachments path is valid and writable',
'absolute_path': path
})
except Exception as e:
logger.error(f"Error testing attachments path: {e}")
return jsonify({
'success': False,
'message': f'Error: {str(e)}',
'absolute_path': path
})
def get_available_timezones():
"""Get a list of all available timezones sorted alphabetically."""
return sorted(zoneinfo.available_timezones())

View File

@@ -134,7 +134,7 @@
<tr> <tr>
<th>Time</th> <th>Time</th>
<th>From</th> <th>From</th>
<th>To</th> <th>Recipients</th>
<th>Status</th> <th>Status</th>
<th>DKIM</th> <th>DKIM</th>
</tr> </tr>
@@ -153,9 +153,46 @@
</span> </span>
</td> </td>
<td> <td>
<span class="text-truncate d-inline-block" style="max-width: 150px;" title="{{ email.rcpt_tos }}"> <div style="max-width: 200px; font-size: 0.85rem;">
{{ email.rcpt_tos }} <div class="recipients-list">
</span> {% if email.to_address %}
{% for rcpt in email.to_address.split(',') %}
{% if rcpt.strip() %}
<div class="text-truncate">
<span class="text-info fw-bold" style="font-size: 0.75rem;">To:</span>
<span title="{{ rcpt.strip() }}">{{ rcpt.strip() }}</span>
</div>
{% endif %}
{% endfor %}
{% endif %}
{% if email.cc_addresses %}
{% for rcpt in email.cc_addresses.split(',') %}
{% if rcpt.strip() %}
<div class="text-truncate">
<span class="text-warning fw-bold" style="font-size: 0.75rem;">CC:</span>
<span title="{{ rcpt.strip() }}">{{ rcpt.strip() }}</span>
</div>
{% endif %}
{% endfor %}
{% endif %}
{% if email.bcc_addresses %}
{% for rcpt in email.bcc_addresses.split(',') %}
{% if rcpt.strip() %}
<div class="text-truncate">
<span class="text-secondary fw-bold" style="font-size: 0.75rem;">BCC:</span>
<span title="{{ rcpt.strip() }}">{{ rcpt.strip() }}</span>
</div>
{% endif %}
{% endfor %}
{% endif %}
{% if not email.to_address and not email.cc_addresses and not email.bcc_addresses %}
<div class="text-muted">No recipients</div>
{% endif %}
</div>
</div>
</td> </td>
<td> <td>
{% set delivered = recipient_logs_map[email.id]|selectattr('status', 'equalto', 'success')|list %} {% set delivered = recipient_logs_map[email.id]|selectattr('status', 'equalto', 'success')|list %}
@@ -167,7 +204,6 @@
{% else %} {% else %}
{% set overall_status = 'failed' %} {% set overall_status = 'failed' %}
{% endif %} {% endif %}
<td>
{% if overall_status == 'relayed' %} {% if overall_status == 'relayed' %}
<span class="badge bg-success"> <span class="badge bg-success">
<i class="bi bi-check-circle me-1"></i> <i class="bi bi-check-circle me-1"></i>
@@ -334,6 +370,15 @@
box-shadow: 0 0 0 2px #0d6efd33; box-shadow: 0 0 0 2px #0d6efd33;
filter: brightness(1.05); 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> </style>
<script> <script>

View File

@@ -97,10 +97,22 @@
<input type="text" <input type="text"
class="form-control" class="form-control"
name="Server.bind_ip" name="Server.bind_ip"
value="{{ settings['Server']['bind_ip'] }}" value="{{ settings['Server']['bind_ip'] }}">
pattern="^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$">
</div> </div>
</div> </div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Server Timezone</label>
<div class="setting-description">Timezone for server operations and logging</div>
<select class="form-select" name="Server.time_zone">
{% for tz in timezones %}
<option value="{{ tz }}" {% if tz == settings['Server']['time_zone'] %}selected{% endif %}>{{ tz }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6"> <div class="col-md-6">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Hostname</label> <label class="form-label">Hostname</label>
@@ -111,8 +123,6 @@
value="{{ settings['Server']['hostname'] }}"> value="{{ settings['Server']['hostname'] }}">
</div> </div>
</div> </div>
</div>
<div class="row">
<div class="col-md-6"> <div class="col-md-6">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">HELO Hostname</label> <label class="form-label">HELO Hostname</label>
@@ -123,6 +133,8 @@
value="{{ settings['Server']['helo_hostname'] }}"> value="{{ settings['Server']['helo_hostname'] }}">
</div> </div>
</div> </div>
</div>
<div class="row">
<div class="col-md-6"> <div class="col-md-6">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Server Banner</label> <label class="form-label">Server Banner</label>
@@ -353,6 +365,40 @@
</div> </div>
</div> </div>
<!-- Attachments Settings -->
<div class="card mb-4">
<div class="card-header" data-bs-toggle="collapse" data-bs-target="#attachmentsSettings" aria-expanded="true">
<h5 class="mb-0 d-flex justify-content-between align-items-center">
<span><i class="bi bi-paperclip me-2"></i>Attachments Configuration</span>
<i class="bi bi-chevron-down"></i>
</h5>
</div>
<div id="attachmentsSettings" class="collapse show">
<div class="card-body">
<div class="setting-section">
<div class="row">
<div class="col-md-12">
<div class="mb-3">
<label class="form-label">Attachments Storage Path</label>
<div class="setting-description">Path where email attachments will be stored (relative to SMTP server root)</div>
<input type="text"
class="form-control"
name="Attachments.attachments_path"
value="{{ settings['Attachments']['attachments_path'] }}"
placeholder="email_server/server_data/attachments">
</div> <div class="setting-description text-warning">
<i class="bi bi-exclamation-triangle me-1"></i>
Make sure the path exists and is writable by the server process
</div>
<div id="attachments-path-feedback" class="mt-2"></div>
<div id="attachments-path-feedback" class="mt-2"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Save Button --> <!-- Save Button -->
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div class="alert alert-warning d-flex align-items-center mb-0"> <div class="alert alert-warning d-flex align-items-center mb-0">
@@ -602,5 +648,60 @@
console.log(`${key}: ${value}`); console.log(`${key}: ${value}`);
} }
}); });
// Populate timezone select options
document.addEventListener('DOMContentLoaded', function() {
const timeZoneSelect = document.getElementById('timeZoneSelect');
fetch('/api/timezones')
.then(response => response.json())
.then(data => {
data.timezones.forEach(tz => {
const option = document.createElement('option');
option.value = tz;
option.textContent = tz;
timeZoneSelect.appendChild(option);
});
})
.catch(err => console.error('Failed to load timezones:', err));
});
function validateAttachmentsPath() {
const path = document.querySelector('input[name="Attachments.attachments_path"]').value;
const feedback = document.getElementById('attachments-path-feedback');
if (!feedback) return;
fetch('{{ url_for("email.test_attachments_path") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': '{{ csrf_token_value|default("") }}'
},
body: `path=${encodeURIComponent(path)}`
})
.then(response => response.json())
.then(data => {
feedback.innerHTML = data.message;
feedback.className = data.success ? 'text-success mt-2' : 'text-danger mt-2';
if (data.success) {
feedback.innerHTML += `<br><small class="text-muted">Absolute path: ${data.absolute_path}</small>`;
}
})
.catch(error => {
feedback.innerHTML = `Error validating path: ${error}`;
feedback.className = 'text-danger mt-2';
});
}
// Add event listener to attachments path input
document.querySelector('input[name="Attachments.attachments_path"]')?.addEventListener('change', validateAttachmentsPath);
document.getElementById('settingsForm')?.addEventListener('submit', function(e) {
const attachmentsPath = document.querySelector('input[name="Attachments.attachments_path"]');
if (!attachmentsPath.value.trim()) {
e.preventDefault();
alert('Please specify a valid attachments storage path');
attachmentsPath.focus();
}
});
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -98,7 +98,7 @@
Server Settings Server Settings
</a> </a>
</li> </li>
{#
<!-- Monitoring Section --> <!-- Monitoring Section -->
<li class="nav-item mb-2"> <li class="nav-item mb-2">
<h6 class="text-muted text-uppercase small mb-2 mt-3"> <h6 class="text-muted text-uppercase small mb-2 mt-3">
@@ -114,6 +114,7 @@
Logs & Activity Logs & Activity
</a> </a>
</li> </li>
#}
</ul> </ul>
</div> </div>

View File

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

View File

@@ -8,16 +8,16 @@ Security Features:
- Enhanced header management - Enhanced header management
""" """
import uuid
import email.utils import email.utils
import os import os
import mimetypes
from aiosmtpd.smtp import SMTP as AIOSMTP, AuthResult from aiosmtpd.smtp import SMTP as AIOSMTP, AuthResult
from aiosmtpd.controller import Controller from aiosmtpd.controller import Controller
from email_server.auth import EnhancedAuthenticator, EnhancedIPAuthenticator, validate_sender_authorization from email_server.auth import EnhancedAuthenticator, EnhancedIPAuthenticator, validate_sender_authorization
from email_server.email_relay import EmailRelay from email_server.email_relay import EmailRelay
from email_server.dkim_manager import DKIMManager from email_server.dkim_manager import DKIMManager
from email_server.settings_loader import load_settings from email_server.settings_loader import load_settings
from email_server.tool_box import get_logger, 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 import policy
from email.parser import BytesParser from email.parser import BytesParser
from email_server.models import Session, EmailAttachment, EmailLog from email_server.models import Session, EmailAttachment, EmailLog
@@ -25,6 +25,8 @@ from email_server.models import Session, EmailAttachment, EmailLog
logger = get_logger() logger = get_logger()
settings = load_settings() settings = load_settings()
helo_hostname = settings['Server'].get('helo_hostname', settings['Server'].get('hostname', 'localhost'))
class CustomSMTP(AIOSMTP): class CustomSMTP(AIOSMTP):
"""Custom SMTP class with configurable banner and secure AUTH handling.""" """Custom SMTP class with configurable banner and secure AUTH handling."""
@@ -151,14 +153,26 @@ class EnhancedCustomSMTPHandler:
# 1. Message-ID (critical for spam filters) # 1. Message-ID (critical for spam filters)
if 'message-id' in existing_headers: if 'message-id' in existing_headers:
required_headers.append(f"Message-ID: {existing_headers['message-id']}") # Parse existing Message-ID
existing_msg_id = existing_headers['message-id'].strip('<>')
if '@' in existing_msg_id:
prefix, hostname = existing_msg_id.rsplit('@', 1)
hostname = hostname.rstrip('>')
if hostname.lower() != helo_hostname.lower():
# If hostname is wrong, modify it to use our hostname
message_id = f"{prefix}@{helo_hostname}"
else:
# If hostname is correct, keep original ID
message_id = existing_msg_id
else:
# Malformed Message-ID, generate new one
message_id = generate_message_id()
else: else:
# Use helo_hostname from settings for FQDN in Message-ID # No Message-ID found, generate new one
helo_hostname = settings['Server'].get('helo_hostname', 'localhost') message_id = generate_message_id()
if not helo_hostname:
# fallback to domain from mail_from # Add the Message-ID header with the final ID
helo_hostname = envelope.mail_from.split('@')[1] if '@' in envelope.mail_from else 'localhost' required_headers.append(f"Message-ID: <{message_id}>")
required_headers.append(f"Message-ID: <{message_id}@{helo_hostname}>")
# 2. Date (critical for spam filters) # 2. Date (critical for spam filters)
if 'date' in existing_headers: if 'date' in existing_headers:
@@ -236,8 +250,28 @@ class EnhancedCustomSMTPHandler:
async def handle_DATA(self, server, session, envelope): 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 and logging."""
try: try:
message_id = str(uuid.uuid4()) # Convert content to string if it's bytes
logger.debug(f'Received email {message_id} from {envelope.mail_from} to {envelope.rcpt_tos}') 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 # Get authenticated username from session
username = getattr(session, 'username', None) username = getattr(session, 'username', None)
@@ -378,11 +412,17 @@ class EnhancedCustomSMTPHandler:
content_type = self.get_content_type(part, filename) content_type = self.get_content_type(part, filename)
size = len(file_data) 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 # 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) file_path = os.path.join(storage_path, safe_filename)
try: try:
# Ensure the directory exists before saving
ensure_folder_exists(file_path)
# Save the file # Save the file
with open(file_path, 'wb') as f: with open(file_path, 'wb') as f:
f.write(file_data) f.write(file_data)
@@ -569,7 +609,7 @@ class EnhancedCustomSMTPHandler:
return '250 OK' return '250 OK'
def get_attachment_storage_path(self, attachments_base_path: str, sender_domain: str, username: str = None, client_ip: str = None) -> str: 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: Args:
attachments_base_path: Base path for attachments storage attachments_base_path: Base path for attachments storage
@@ -578,29 +618,36 @@ class EnhancedCustomSMTPHandler:
client_ip: Client IP address (if IP-based authentication) client_ip: Client IP address (if IP-based authentication)
Returns: 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 # Sanitize domain name for folder name
safe_domain = sender_domain.replace('/', '_').replace('\\', '_') safe_domain = sender_domain.replace('/', '_').replace('\\', '_')
domain_path = os.path.join(attachments_base_path, safe_domain) domain_path = os.path.join(attachments_base_path, safe_domain)
# Determine subfolder based on authentication # Determine auth-based subfolder path
if username: if username:
# Sanitize username for folder name # Sanitize username for folder name
safe_username = username.replace('/', '_').replace('\\', '_') 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: elif client_ip:
# Sanitize IP for folder name # Sanitize IP for folder name
safe_ip = client_ip.replace(':', '_') safe_ip = client_ip.replace(':', '_')
return os.path.join(domain_path, safe_ip) auth_path = os.path.join(domain_path, safe_ip)
else: else:
# Fallback to domain-only path # 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): def get_content_type(self, part, filename):
"""Get the correct content type for a file, trying multiple methods.""" """Get the correct content type for a file, trying multiple methods."""
import mimetypes
# First try the part's content type # First try the part's content type
content_type = part.get_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 email_server.settings_loader import load_settings
from datetime import datetime from datetime import datetime
import pytz import pytz
import time
import random
settings = load_settings() settings = load_settings()
helo_hostname = settings['Server'].get('helo_hostname', settings['Server'].get('hostname', 'localhost'))
def ensure_folder_exists(filepath): def ensure_folder_exists(filepath):
""" """
@@ -63,4 +66,14 @@ def get_logger(name=None):
def get_current_time(): def get_current_time():
"""Get current time with timezone from settings.""" """Get current time with timezone from settings."""
timezone = pytz.timezone(settings['Server'].get('time_zone', 'UTC')) 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 \ --auth-password $password \
--tls \ --tls \
--header "Subject: TLS - Large body email" \ --header "Subject: TLS - Large body email" \
--body $body_content_file --body "simple body content" \
# --attach @/home/nahaku/Documents/Projects/SMTP_Server/tests/email_body.txt --attach @/home/nahaku/Documents/Projects/SMTP_Server/tests/pdf_test_1.pdf \
# --attach @/home/nahaku/Documents/Projects/SMTP_Server/tests/Hello.jpg --attach @/home/nahaku/Documents/Projects/SMTP_Server/tests/note_authentication_order_fix.md
#--attach @/home/nahaku/Documents/Projects/SMTP_Server/tests/Hello.jpg
#--attach @/home/nahaku/Documents/Projects/SMTP_Server/tests/email_body.txt
com com
<<com <<com