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:
@@ -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):
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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}"
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user