adding message viewer - fixing log view, change to aiosmtplib
This commit is contained in:
@@ -15,3 +15,4 @@ from .ip_whitelist import *
|
||||
from .dkim import *
|
||||
from .settings import *
|
||||
from .logs import *
|
||||
from .view_message import *
|
||||
|
||||
@@ -5,7 +5,7 @@ This module provides the main dashboard view and overview functionality.
|
||||
"""
|
||||
|
||||
from flask import render_template
|
||||
from email_server.models import Session, Domain, Sender, DKIMKey, EmailLog, AuthLog
|
||||
from email_server.models import Session, Domain, Sender, DKIMKey, EmailLog, AuthLog, EmailRecipientLog
|
||||
from email_server.tool_box import get_logger
|
||||
from .routes import email_bp
|
||||
|
||||
@@ -24,6 +24,8 @@ def dashboard():
|
||||
|
||||
# Get recent email logs
|
||||
recent_emails = session.query(EmailLog).order_by(EmailLog.created_at.desc()).limit(10).all()
|
||||
# Get recipient logs for each recent email
|
||||
recipient_logs_map = {email.id: session.query(EmailRecipientLog).filter_by(email_log_id=email.id).all() for email in recent_emails}
|
||||
|
||||
# Get recent auth logs
|
||||
recent_auths = session.query(AuthLog).order_by(AuthLog.created_at.desc()).limit(10).all()
|
||||
@@ -33,6 +35,7 @@ def dashboard():
|
||||
sender_count=sender_count,
|
||||
dkim_count=dkim_count,
|
||||
recent_emails=recent_emails,
|
||||
recent_auths=recent_auths)
|
||||
recent_auths=recent_auths,
|
||||
recipient_logs_map=recipient_logs_map)
|
||||
finally:
|
||||
session.close()
|
||||
@@ -9,12 +9,12 @@ This module provides DKIM key management functionality including:
|
||||
- DKIM DNS verification
|
||||
"""
|
||||
|
||||
from flask import render_template, request, redirect, url_for, flash, jsonify
|
||||
from flask import render_template, request, redirect, url_for, flash, jsonify, current_app
|
||||
from datetime import datetime
|
||||
import re
|
||||
from email_server.models import Session, Domain, DKIMKey
|
||||
from email_server.dkim_manager import DKIMManager
|
||||
from email_server.tool_box import get_logger
|
||||
from email_server.tool_box import get_logger, get_current_time
|
||||
from .utils import get_public_ip, check_dns_record, generate_spf_record
|
||||
from .routes import email_bp
|
||||
|
||||
@@ -107,7 +107,7 @@ def create_dkim():
|
||||
active_keys = session.query(DKIMKey).filter_by(domain_id=domain.id, is_active=True).all()
|
||||
for key in active_keys:
|
||||
key.is_active = False
|
||||
key.replaced_at = datetime.now()
|
||||
key.replaced_at = get_current_time()
|
||||
# Create new DKIM key
|
||||
dkim_manager = DKIMManager()
|
||||
created = dkim_manager.generate_dkim_keypair(domain_name, selector=selector, force_new_key=True)
|
||||
@@ -146,7 +146,7 @@ def regenerate_dkim(domain_id: int):
|
||||
# Mark existing keys as replaced
|
||||
for key in existing_keys:
|
||||
key.is_active = False
|
||||
key.replaced_at = datetime.now() # Mark when this key was replaced
|
||||
key.replaced_at = get_current_time() # Mark when this key was replaced
|
||||
|
||||
# Generate new DKIM key preserving the existing selector
|
||||
dkim_manager = DKIMManager()
|
||||
@@ -331,7 +331,7 @@ def toggle_dkim(dkim_id: int):
|
||||
).all()
|
||||
for key in other_active_keys:
|
||||
key.is_active = False
|
||||
key.replaced_at = datetime.now()
|
||||
key.replaced_at = get_current_time()
|
||||
|
||||
dkim_key.is_active = not old_status
|
||||
if dkim_key.is_active:
|
||||
|
||||
@@ -37,6 +37,7 @@ def add_ip():
|
||||
if request.method == 'POST':
|
||||
ip_address = request.form.get('ip_address', '').strip()
|
||||
domain_id = request.form.get('domain_id', type=int)
|
||||
store_message_content = bool(request.form.get('store_message_content'))
|
||||
|
||||
if not all([ip_address, domain_id]):
|
||||
flash('All fields are required', 'error')
|
||||
@@ -58,7 +59,8 @@ def add_ip():
|
||||
# Create whitelisted IP
|
||||
whitelist = WhitelistedIP(
|
||||
ip_address=ip_address,
|
||||
domain_id=domain_id
|
||||
domain_id=domain_id,
|
||||
store_message_content=store_message_content
|
||||
)
|
||||
session.add(whitelist)
|
||||
session.commit()
|
||||
@@ -166,6 +168,7 @@ def edit_ip(ip_id: int):
|
||||
if request.method == 'POST':
|
||||
ip_address = request.form.get('ip_address', '').strip()
|
||||
domain_id = request.form.get('domain_id', type=int)
|
||||
store_message_content = bool(request.form.get('store_message_content'))
|
||||
|
||||
if not all([ip_address, domain_id]):
|
||||
flash('All fields are required', 'error')
|
||||
@@ -191,6 +194,7 @@ def edit_ip(ip_id: int):
|
||||
# Update IP record
|
||||
ip_record.ip_address = ip_address
|
||||
ip_record.domain_id = domain_id
|
||||
ip_record.store_message_content = store_message_content
|
||||
session.commit()
|
||||
|
||||
flash(f'IP whitelist record updated', 'success')
|
||||
|
||||
@@ -4,12 +4,11 @@ Logs blueprint for the SMTP server web UI.
|
||||
This module provides email and authentication log viewing functionality.
|
||||
"""
|
||||
|
||||
from flask import render_template, request, jsonify
|
||||
from email_server.models import Session, EmailLog, AuthLog, Domain
|
||||
from flask import render_template, request, send_file, redirect, url_for, flash, Response
|
||||
from email_server.models import Session, EmailLog, AuthLog, EmailRecipientLog, EmailAttachment
|
||||
from email_server.tool_box import get_logger
|
||||
from sqlalchemy import desc
|
||||
from datetime import datetime, timedelta
|
||||
from .routes import email_bp
|
||||
import os
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
@@ -40,10 +39,15 @@ def logs():
|
||||
# Convert to unified format
|
||||
combined_logs = []
|
||||
for log in email_logs:
|
||||
# Fetch recipient logs and attachments for each email log
|
||||
recipient_logs = session.query(EmailRecipientLog).filter_by(email_log_id=log.id).all()
|
||||
attachments = session.query(EmailAttachment).filter_by(email_log_id=log.id).all()
|
||||
combined_logs.append({
|
||||
'type': 'email',
|
||||
'timestamp': log.created_at,
|
||||
'data': log
|
||||
'data': log,
|
||||
'recipients': recipient_logs,
|
||||
'attachments': attachments
|
||||
})
|
||||
for log in auth_logs:
|
||||
combined_logs.append({
|
||||
@@ -69,12 +73,20 @@ def logs():
|
||||
|
||||
has_next = offset + per_page < total
|
||||
has_prev = page > 1
|
||||
|
||||
# Fetch recipient logs and attachments for each email log if emails
|
||||
recipient_logs_map = {}
|
||||
attachments_map = {}
|
||||
if filter_type == 'emails':
|
||||
for log in logs:
|
||||
recipient_logs_map[log.id] = session.query(EmailRecipientLog).filter_by(email_log_id=log.id).all()
|
||||
attachments_map[log.id] = session.query(EmailAttachment).filter_by(email_log_id=log.id).all()
|
||||
return render_template('logs.html',
|
||||
logs=logs,
|
||||
filter_type=filter_type,
|
||||
page=page,
|
||||
has_next=has_next,
|
||||
has_prev=has_prev)
|
||||
has_prev=has_prev,
|
||||
recipient_logs_map=recipient_logs_map,
|
||||
attachments_map=attachments_map)
|
||||
finally:
|
||||
session.close()
|
||||
session.close()
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"""
|
||||
Main routes and blueprint definition for the SMTP server web UI.
|
||||
"""
|
||||
from flask import Blueprint, render_template
|
||||
from email_server.tool_box import get_logger
|
||||
from flask import Blueprint, render_template, request, jsonify, current_app
|
||||
from email_server.models import Session, EmailLog, AuthLog
|
||||
from email_server.tool_box import get_logger, get_current_time
|
||||
from email_server.settings_loader import load_settings
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
|
||||
|
||||
# Create the main email blueprint
|
||||
@@ -15,14 +18,35 @@ email_bp = Blueprint('email', __name__,
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
# Get timezone from settings
|
||||
settings = load_settings()
|
||||
timezone = pytz.timezone(settings['Server'].get('time_zone', 'UTC'))
|
||||
|
||||
@email_bp.app_template_filter('format_datetime')
|
||||
def format_datetime(value, timezone=None):
|
||||
"""Format datetime with the correct timezone from settings or argument."""
|
||||
if value is None:
|
||||
return ''
|
||||
import pytz
|
||||
if timezone is None:
|
||||
settings = load_settings()
|
||||
timezone = settings['Server'].get('time_zone', 'UTC')
|
||||
tz = pytz.timezone(timezone)
|
||||
if value.tzinfo is None:
|
||||
value = pytz.UTC.localize(value)
|
||||
local_dt = value.astimezone(tz)
|
||||
return local_dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
from .view_message import * # Import view_message routes
|
||||
|
||||
# Error handlers
|
||||
@email_bp.errorhandler(404)
|
||||
def not_found(error):
|
||||
"""Handle 404 errors."""
|
||||
return render_template('error.html',
|
||||
error_code=404,
|
||||
error_message='Page not found',
|
||||
current_time=datetime.now()), 404
|
||||
error_message="Page not found",
|
||||
current_time=get_current_time()), 404
|
||||
|
||||
@email_bp.errorhandler(500)
|
||||
def internal_error(error):
|
||||
@@ -30,5 +54,5 @@ def internal_error(error):
|
||||
logger.error(f"Internal error: {error}")
|
||||
return render_template('error.html',
|
||||
error_code=500,
|
||||
error_message='Internal server error',
|
||||
current_time=datetime.now()), 500
|
||||
error_message=str(error),
|
||||
current_time=get_current_time()), 500
|
||||
@@ -40,6 +40,7 @@ def add_sender():
|
||||
password = request.form.get('password', '').strip()
|
||||
domain_id = request.form.get('domain_id', type=int)
|
||||
can_send_as_domain = request.form.get('can_send_as_domain') == 'on'
|
||||
store_message_content = request.form.get('store_message_content') == 'on'
|
||||
|
||||
if not all([email, password, domain_id]):
|
||||
flash('All fields are required', 'error')
|
||||
@@ -61,7 +62,8 @@ def add_sender():
|
||||
email=email,
|
||||
password_hash=hash_password(password),
|
||||
domain_id=domain_id,
|
||||
can_send_as_domain=can_send_as_domain
|
||||
can_send_as_domain=can_send_as_domain,
|
||||
store_message_content=store_message_content
|
||||
)
|
||||
session.add(sender)
|
||||
session.commit()
|
||||
@@ -171,6 +173,7 @@ def edit_sender(user_id: int):
|
||||
password = request.form.get('password', '').strip()
|
||||
domain_id = request.form.get('domain_id', type=int)
|
||||
can_send_as_domain = request.form.get('can_send_as_domain') == 'on'
|
||||
store_message_content = request.form.get('store_message_content') == 'on'
|
||||
|
||||
if not all([email, domain_id]):
|
||||
flash('Email and domain are required', 'error')
|
||||
@@ -194,6 +197,7 @@ def edit_sender(user_id: int):
|
||||
sender.email = email
|
||||
sender.domain_id = domain_id
|
||||
sender.can_send_as_domain = can_send_as_domain
|
||||
sender.store_message_content = store_message_content
|
||||
|
||||
# Update password if provided
|
||||
if password:
|
||||
|
||||
@@ -67,6 +67,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="store_message_content" name="store_message_content">
|
||||
<label class="form-check-label" for="store_message_content">
|
||||
<strong>Store Full Message Content</strong>
|
||||
</label>
|
||||
<div class="form-text">
|
||||
If enabled, the full message body and attachments will be stored and viewable in logs. Otherwise, only headers and subject are stored.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning">
|
||||
<h6 class="alert-heading">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
|
||||
@@ -70,6 +70,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="store_message_content" name="store_message_content">
|
||||
<label class="form-check-label" for="store_message_content">
|
||||
<strong>Store Full Message Content</strong>
|
||||
</label>
|
||||
<div class="form-text">
|
||||
If enabled, the full message body and attachments will be stored and viewable in logs. Otherwise, only headers and subject are stored.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<h6 class="alert-heading">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
|
||||
@@ -144,7 +144,7 @@
|
||||
<tr>
|
||||
<td>
|
||||
<small class="text-muted">
|
||||
{{ email.created_at.strftime('%H:%M:%S') }}
|
||||
{{ email.created_at|format_datetime }}
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
@@ -153,22 +153,38 @@
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-truncate d-inline-block" style="max-width: 150px;" title="{{ email.to_address }}">
|
||||
{{ email.to_address }}
|
||||
<span class="text-truncate d-inline-block" style="max-width: 150px;" title="{{ email.rcpt_tos }}">
|
||||
{{ email.rcpt_tos }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if email.status == 'relayed' %}
|
||||
<span class="badge bg-success">
|
||||
<i class="bi bi-check-circle me-1"></i>
|
||||
Sent
|
||||
</span>
|
||||
{% set delivered = recipient_logs_map[email.id]|selectattr('status', 'equalto', 'success')|list %}
|
||||
{% set failed = recipient_logs_map[email.id]|selectattr('status', 'ne', 'success')|list %}
|
||||
{% if delivered and failed %}
|
||||
{% set overall_status = 'partial' %}
|
||||
{% elif delivered %}
|
||||
{% set overall_status = 'relayed' %}
|
||||
{% else %}
|
||||
<span class="badge bg-danger">
|
||||
<i class="bi bi-x-circle me-1"></i>
|
||||
Failed
|
||||
</span>
|
||||
{% set overall_status = 'failed' %}
|
||||
{% endif %}
|
||||
<td>
|
||||
{% if overall_status == 'relayed' %}
|
||||
<span class="badge bg-success">
|
||||
<i class="bi bi-check-circle me-1"></i>
|
||||
Sent
|
||||
</span>
|
||||
{% elif overall_status == 'partial' %}
|
||||
<span class="badge bg-warning text-dark">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
Partial Fail
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">
|
||||
<i class="bi bi-x-circle me-1"></i>
|
||||
Failed
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</td>
|
||||
<td>
|
||||
{% if email.dkim_signed %}
|
||||
@@ -227,7 +243,7 @@
|
||||
</small>
|
||||
<br>
|
||||
<small class="text-muted">
|
||||
{{ auth.created_at.strftime('%H:%M:%S') }}
|
||||
{{ auth.created_at|format_datetime }}
|
||||
</small>
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
|
||||
@@ -44,6 +44,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="store_message_content" name="store_message_content" {% if ip_record.store_message_content %}checked{% endif %}>
|
||||
<label class="form-check-label" for="store_message_content">
|
||||
<strong>Store Full Message Content</strong>
|
||||
</label>
|
||||
<div class="form-text">
|
||||
If enabled, the full message body and attachments will be stored and viewable in logs. Otherwise, only headers and subject are stored.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg me-1"></i>
|
||||
@@ -95,6 +107,20 @@
|
||||
</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
<dt class="col-sm-4">Store Message:</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% if ip_record.store_message_content %}
|
||||
<span class="badge bg-info text-dark">
|
||||
<i class="bi bi-file-earmark-text me-1"></i>
|
||||
Full Message
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">
|
||||
<i class="bi bi-file-earmark me-1"></i>
|
||||
Headers Only
|
||||
</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
<dt class="col-sm-4">Created:</dt>
|
||||
<dd class="col-sm-8">
|
||||
<small class="text-muted">
|
||||
|
||||
@@ -65,6 +65,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="store_message_content" name="store_message_content" {% if sender.store_message_content %}checked{% endif %}>
|
||||
<label class="form-check-label" for="store_message_content">
|
||||
<strong>Store Full Message Content</strong>
|
||||
</label>
|
||||
<div class="form-text">
|
||||
If enabled, the full message body and attachments will be stored and viewable in logs. Otherwise, only headers and subject are stored.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg me-1"></i>
|
||||
@@ -130,6 +142,20 @@
|
||||
</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
<dt class="col-sm-4">Store Message:</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% if sender.store_message_content %}
|
||||
<span class="badge bg-info text-dark">
|
||||
<i class="bi bi-file-earmark-text me-1"></i>
|
||||
Full Message
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">
|
||||
<i class="bi bi-file-earmark me-1"></i>
|
||||
Headers Only
|
||||
</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
<dt class="col-sm-4">Created:</dt>
|
||||
<dd class="col-sm-8">
|
||||
<small class="text-muted">
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
<th>IP Address</th>
|
||||
<th>Domain</th>
|
||||
<th>Status</th>
|
||||
<th>Storage Type</th>
|
||||
<th>Added</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
@@ -59,6 +60,19 @@
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if ip.store_message_content %}
|
||||
<span class="badge bg-info text-dark">
|
||||
<i class="bi bi-file-earmark-text me-1"></i>
|
||||
Stores Full Message
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">
|
||||
<i class="bi bi-file-earmark me-1"></i>
|
||||
Headers Only
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-muted">
|
||||
{{ ip.created_at.strftime('%Y-%m-%d %H:%M') }}
|
||||
|
||||
@@ -16,16 +16,9 @@
|
||||
.log-error { border-left-color: #dc3545; }
|
||||
.log-success { border-left-color: #198754; }
|
||||
.log-failed { border-left-color: #dc3545; }
|
||||
.log-partial { border-left-color: #fd7e14; } /* Orange for partial fail */
|
||||
|
||||
.log-content {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.875rem;
|
||||
background-color: var(--bs-gray-100);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
/* Message display styles are now in view_message_content.html */
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -83,11 +76,30 @@
|
||||
{% for log_entry in logs %}
|
||||
{% if log_entry.type == 'email' %}
|
||||
{% set log = log_entry.data %}
|
||||
<div class="log-entry log-email log-{{ 'success' if log.status == 'relayed' else 'failed' }}">
|
||||
{% set recipients = log_entry.recipients %}
|
||||
{% set delivered = recipients|selectattr('status', 'equalto', 'success')|list %}
|
||||
{% set failed = recipients|selectattr('status', 'ne', 'success')|list %}
|
||||
{% if delivered and failed %}
|
||||
{% set overall_status = 'partial' %}
|
||||
{% elif delivered %}
|
||||
{% set overall_status = 'relayed' %}
|
||||
{% else %}
|
||||
{% set overall_status = 'failed' %}
|
||||
{% endif %}
|
||||
<div class="log-entry log-email log-{% if overall_status == 'relayed' %}success{% elif overall_status == 'partial' %}partial{% else %}failed{% endif %}">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<div>
|
||||
<span class="badge bg-primary me-2">EMAIL</span>
|
||||
<strong>{{ log.mail_from }}</strong> → {{ log.rcpt_tos }}
|
||||
<strong>{{ log.mail_from }}</strong>
|
||||
{% if log.to_address %}
|
||||
→ <span class="text-primary">To:</span> {{ log.to_address }}
|
||||
{% endif %}
|
||||
{% if log.cc_addresses %}
|
||||
<br><span class="ms-4 text-info">CC:</span> {{ log.cc_addresses }}
|
||||
{% endif %}
|
||||
{% if log.bcc_addresses %}
|
||||
<br><span class="ms-4 text-warning">BCC:</span> {{ log.bcc_addresses }}
|
||||
{% endif %}
|
||||
{% if log.dkim_signed %}
|
||||
<span class="badge bg-success ms-2">
|
||||
<i class="bi bi-shield-check me-1"></i>
|
||||
@@ -95,13 +107,15 @@
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<small class="text-muted">{{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</small>
|
||||
<small class="text-muted">{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</small>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<strong>Status:</strong>
|
||||
{% if log.status == 'relayed' %}
|
||||
{% if overall_status == 'relayed' %}
|
||||
<span class="text-success">Sent Successfully</span>
|
||||
{% elif overall_status == 'partial' %}
|
||||
<span class="text-warning">Partial Fail</span>
|
||||
{% else %}
|
||||
<span class="text-danger">Failed</span>
|
||||
{% endif %}
|
||||
@@ -115,6 +129,11 @@
|
||||
<strong>Subject:</strong> {{ log.subject }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="mt-2">
|
||||
<a href="{{ url_for('email.view_message_content', log_id=log.id) }}" class="btn btn-sm btn-primary">
|
||||
<i class="fas fa-envelope-open-text"></i> View Message Details
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{% set log = log_entry.data %}
|
||||
@@ -127,7 +146,7 @@
|
||||
{{ 'Success' if log.success else 'Failed' }}
|
||||
</span>
|
||||
</div>
|
||||
<small class="text-muted">{{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</small>
|
||||
<small class="text-muted">{{ log.created_at|format_datetime }}</small>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
@@ -148,10 +167,28 @@
|
||||
{% elif filter_type == 'emails' %}
|
||||
<!-- Email logs only -->
|
||||
{% for log in logs %}
|
||||
<div class="log-entry log-email log-{{ 'success' if log.status == 'relayed' else 'failed' }}">
|
||||
{% set delivered = recipient_logs_map[log.id]|selectattr('status', 'equalto', 'success')|list %}
|
||||
{% set failed = recipient_logs_map[log.id]|selectattr('status', 'ne', 'success')|list %}
|
||||
{% if delivered and failed %}
|
||||
{% set overall_status = 'partial' %}
|
||||
{% elif delivered %}
|
||||
{% set overall_status = 'relayed' %}
|
||||
{% else %}
|
||||
{% set overall_status = 'failed' %}
|
||||
{% endif %}
|
||||
<div class="log-entry log-email log-{{ overall_status }}">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<div>
|
||||
<strong>{{ log.mail_from }}</strong> → {{ log.rcpt_tos }}
|
||||
<strong>{{ log.mail_from }}</strong>
|
||||
{% if log.to_address %}
|
||||
→ <span class="text-primary">To:</span> {{ log.to_address }}
|
||||
{% endif %}
|
||||
{% if log.cc_addresses %}
|
||||
<br><span class="ms-4 text-info">CC:</span> {{ log.cc_addresses }}
|
||||
{% endif %}
|
||||
{% if log.bcc_addresses %}
|
||||
<br><span class="ms-4 text-warning">BCC:</span> {{ log.bcc_addresses }}
|
||||
{% endif %}
|
||||
{% if log.dkim_signed %}
|
||||
<span class="badge bg-success ms-2">
|
||||
<i class="bi bi-shield-check me-1"></i>
|
||||
@@ -159,24 +196,64 @@
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<small class="text-muted">{{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</small>
|
||||
<small class="text-muted">{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</small>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<strong>Status:</strong>
|
||||
{% if log.status == 'relayed' %}
|
||||
{% if overall_status == 'relayed' %}
|
||||
<span class="text-success">Sent</span>
|
||||
{% elif overall_status == 'partial' %}
|
||||
<span class="text-warning">Partial Fail</span>
|
||||
{% else %}
|
||||
<span class="text-danger">Failed</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<strong>Peer:</strong> <code>{{ log.peer }}</code>
|
||||
<strong>Peer:</strong> <code>{{ log.peer_ip }}</code>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<strong>Message ID:</strong> <code>{{ log.message_id }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-2">
|
||||
<div class="col-md-4">
|
||||
<strong>Username:</strong> {{ log.username or 'N/A' }}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<strong>CC:</strong> {{ log.cc_addresses or 'None' }}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<strong>BCC:</strong> {{ log.bcc_addresses or 'None' }}
|
||||
</div>
|
||||
</div>
|
||||
{% if recipient_logs_map and log.id in recipient_logs_map and recipient_logs_map[log.id] %}
|
||||
<div class="mt-2">
|
||||
<strong>Recipient Delivery Results:</strong>
|
||||
<ul class="list-group">
|
||||
{% for r in recipient_logs_map[log.id] %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span>
|
||||
<strong>{{ r.recipient_type|upper }}:</strong> {{ r.recipient }}
|
||||
{% if r.status == 'success' %}
|
||||
<span class="badge bg-success ms-2">Delivered</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger ms-2">Failed</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% if r.error_code or r.error_message %}
|
||||
<span class="text-danger ms-2">
|
||||
{{ r.error_code }} {{ r.error_message }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if r.server_response %}
|
||||
<span class="text-muted ms-2">{{ r.server_response }}</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if log.subject %}
|
||||
<div class="mt-2">
|
||||
<strong>Subject:</strong> {{ log.subject }}
|
||||
@@ -195,6 +272,13 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if log.has_message_content %}
|
||||
<div class="mt-2">
|
||||
<a href="{{ url_for('email.view_message_content', log_id=log.id) }}" class="btn btn-outline-info btn-sm">
|
||||
<i class="bi bi-file-earmark-text me-1"></i> View Full Message
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
@@ -208,7 +292,7 @@
|
||||
{{ 'Success' if log.success else 'Failed' }}
|
||||
</span>
|
||||
</div>
|
||||
<small class="text-muted">{{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</small>
|
||||
<small class="text-muted">{{ log.created_at|format_datetime }}</small>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
<th>Domain</th>
|
||||
<th>Permissions</th>
|
||||
<th>Status</th>
|
||||
<th>Storage</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
@@ -128,6 +129,19 @@
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if sender.store_message_content %}
|
||||
<span class="badge bg-info text-dark">
|
||||
<i class="bi bi-file-earmark-text me-1"></i>
|
||||
Stores Full Message
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">
|
||||
<i class="bi bi-file-earmark me-1"></i>
|
||||
Headers Only
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-muted">
|
||||
{{ sender.created_at.strftime('%Y-%m-%d %H:%M') }}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}View Full Message - Email Log{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h2>Full Message Content</h2>
|
||||
<div class="mb-3">
|
||||
<strong>From:</strong> {{ log.mail_from }}<br>
|
||||
<strong>To:</strong> {{ log.to_address }}<br>
|
||||
<strong>CC:</strong> {{ log.cc_addresses or 'None' }}<br>
|
||||
<strong>BCC:</strong> {{ log.bcc_addresses or 'None' }}<br>
|
||||
<strong>Subject:</strong> {{ log.subject or 'N/A' }}<br>
|
||||
<strong>Date:</strong> {{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') }}<br>
|
||||
</div>
|
||||
|
||||
{% if log.attachments %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<strong>Attachments:</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="list-group">
|
||||
{% for attachment in log.attachments %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i class="fas fa-paperclip"></i> {{ attachment.filename }}
|
||||
<small class="text-muted">({{ attachment.size|filesizeformat }})</small>
|
||||
</div>
|
||||
<div class="btn-group" role="group">
|
||||
{% set content_type = attachment.content_type.lower() if attachment.content_type else 'application/octet-stream' %}
|
||||
{% set extension = attachment.filename.split('.')[-1].lower() if '.' in attachment.filename else '' %}
|
||||
|
||||
{% set is_image = content_type.startswith('image/') or extension in ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'] %}
|
||||
{% set is_text = content_type.startswith('text/') or extension in ['txt', 'log', 'json', 'xml', 'csv', 'md'] %}
|
||||
{% set is_pdf = content_type == 'application/pdf' or extension == 'pdf' %}
|
||||
{% set is_html = content_type in ['text/html', 'application/xhtml+xml'] or extension in ['html', 'htm'] %}
|
||||
|
||||
{% if is_image or is_text or is_pdf or is_html %}
|
||||
<a href="{{ url_for('email.download_attachment', attachment_id=attachment.id) }}"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
target="_blank"
|
||||
data-bs-toggle="tooltip"
|
||||
title="Open in new tab">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
{% if is_image %}<i class="fas fa-image"></i> View Image
|
||||
{% elif is_pdf %}<i class="fas fa-file-pdf"></i> View PDF
|
||||
{% elif extension == 'csv' %}<i class="fas fa-table"></i> View CSV
|
||||
{% elif is_text %}<i class="fas fa-file-alt"></i> View Text
|
||||
{% elif is_html %}<i class="fas fa-file-code"></i> View HTML
|
||||
{% else %}View in Browser
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('email.download_attachment', attachment_id=attachment.id, download='true') }}"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
title="Download file">
|
||||
<i class="fas fa-download"></i> Download
|
||||
</a>
|
||||
<form method="POST"
|
||||
action="{{ url_for('email.delete_attachment', attachment_id=attachment.id) }}"
|
||||
style="display: inline;"
|
||||
onsubmit="return confirm('Are you sure you want to delete this attachment?');">
|
||||
<button type="submit"
|
||||
class="btn btn-sm btn-outline-danger"
|
||||
title="Delete attachment">
|
||||
<i class="fas fa-trash-alt"></i> Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<strong>Message Content:</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<pre style="white-space: pre-wrap; word-break: break-all;">{{ log.message_body }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<strong>Message Headers:</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<pre style="white-space: pre-wrap;">{{ log.email_headers }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="{{ url_for('email.logs', type='emails') }}" class="btn btn-secondary mt-3">Back to Logs</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -16,7 +16,7 @@ logger = logging.getLogger(__name__)
|
||||
def get_public_ip() -> str:
|
||||
"""Get the public IP address of the server."""
|
||||
try:
|
||||
response1 = requests.get('https://ifconfig.me/ip', timeout=3, verify=False)
|
||||
response1 = requests.get('http://ifconfig.me/ip', timeout=3, verify=False)
|
||||
|
||||
ip = response1.text.strip()
|
||||
if ip and ip != 'unknown':
|
||||
@@ -24,7 +24,7 @@ def get_public_ip() -> str:
|
||||
except Exception:
|
||||
try:
|
||||
# Fallback method
|
||||
response = requests.get('https://httpbin.org/ip', timeout=3, verify=False)
|
||||
response = requests.get('http://httpbin.org/ip', timeout=3, verify=False)
|
||||
ip = response.json()['origin'].split(',')[0].strip()
|
||||
if ip and ip != 'unknown':
|
||||
return ip
|
||||
|
||||
131
email_server/server_web_ui/view_message.py
Normal file
131
email_server/server_web_ui/view_message.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
Route to view full email message content if stored.
|
||||
"""
|
||||
from flask import render_template, abort, flash, redirect, Response, send_file, request, url_for
|
||||
from email_server.models import Session, EmailLog, EmailAttachment
|
||||
from email_server.tool_box import get_logger
|
||||
from .routes import email_bp
|
||||
import os
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
@email_bp.route('/msg/content/<int:log_id>')
|
||||
def view_message_content(log_id):
|
||||
"""View the full message content for an email log if stored."""
|
||||
session = Session()
|
||||
try:
|
||||
# Get log with attachments
|
||||
log = session.query(EmailLog).filter_by(id=log_id).first()
|
||||
if not log:
|
||||
abort(404)
|
||||
|
||||
# Get attachments for this log
|
||||
attachments = session.query(EmailAttachment).filter_by(email_log_id=log_id).all()
|
||||
log.attachments = attachments
|
||||
|
||||
return render_template('view_message_content.html', log=log)
|
||||
finally:
|
||||
session.close()
|
||||
@email_bp.route('/msg/attachment/<int:attachment_id>/download')
|
||||
def download_attachment(attachment_id):
|
||||
session = Session()
|
||||
try:
|
||||
attachment = session.query(EmailAttachment).get(attachment_id)
|
||||
if not attachment or not os.path.isfile(attachment.file_path):
|
||||
flash('Attachment not found.', 'danger')
|
||||
return redirect(url_for('email.logs', type='emails'))
|
||||
|
||||
# Get the normalized content type and handle special cases
|
||||
content_type = attachment.content_type.lower() if attachment.content_type else 'application/octet-stream'
|
||||
extension = os.path.splitext(attachment.filename.lower())[1][1:] if '.' in attachment.filename else ''
|
||||
|
||||
# Force download if requested
|
||||
as_attachment = request.args.get('download', '').lower() == 'true'
|
||||
|
||||
# Map of extensions to content types for common files
|
||||
content_type_map = {
|
||||
'txt': 'text/plain',
|
||||
'csv': 'text/csv',
|
||||
'pdf': 'application/pdf',
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'png': 'image/png',
|
||||
'gif': 'image/gif',
|
||||
'svg': 'image/svg+xml',
|
||||
'html': 'text/html',
|
||||
'htm': 'text/html',
|
||||
'json': 'application/json',
|
||||
'xml': 'text/xml',
|
||||
'md': 'text/markdown',
|
||||
}
|
||||
|
||||
# Update content type based on file extension if needed
|
||||
if content_type == 'application/octet-stream' and extension in content_type_map:
|
||||
content_type = content_type_map[extension]
|
||||
|
||||
# Special handling for CSV files
|
||||
if content_type == 'text/csv' and not as_attachment:
|
||||
try:
|
||||
with open(attachment.file_path, 'r') as f:
|
||||
csv_content = f.read()
|
||||
# Create a simple HTML table view for CSV
|
||||
html_content = '<html><head><style>'
|
||||
html_content += 'table {border-collapse: collapse; width: 100%;} '
|
||||
html_content += 'th, td {border: 1px solid #ddd; padding: 8px; text-align: left;} '
|
||||
html_content += 'tr:nth-child(even) {background-color: #f2f2f2;} '
|
||||
html_content += 'th {background-color: #4CAF50; color: white;}'
|
||||
html_content += '</style></head><body><table>'
|
||||
|
||||
# Convert CSV to HTML table
|
||||
for i, line in enumerate(csv_content.split('\n')):
|
||||
if not line.strip():
|
||||
continue
|
||||
html_content += '<tr>'
|
||||
if i == 0: # Header row
|
||||
html_content += ''.join(f'<th>{cell}</th>' for cell in line.split(','))
|
||||
else:
|
||||
html_content += ''.join(f'<td>{cell}</td>' for cell in line.split(','))
|
||||
html_content += '</tr>'
|
||||
|
||||
html_content += '</table></body></html>'
|
||||
return Response(html_content, mimetype='text/html')
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to create CSV preview: {e}")
|
||||
# Fall back to normal file handling
|
||||
|
||||
# Determine if the file should be viewed in browser
|
||||
if as_attachment:
|
||||
# Force download
|
||||
return send_file(
|
||||
attachment.file_path,
|
||||
as_attachment=True,
|
||||
download_name=attachment.filename
|
||||
)
|
||||
else:
|
||||
# Try to display in browser
|
||||
return send_file(
|
||||
attachment.file_path,
|
||||
mimetype=content_type
|
||||
)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@email_bp.route('/msg/attachment/<int:attachment_id>/delete', methods=['POST', 'GET'])
|
||||
def delete_attachment(attachment_id):
|
||||
session = Session()
|
||||
try:
|
||||
attachment = session.query(EmailAttachment).get(attachment_id)
|
||||
if not attachment:
|
||||
flash('Attachment not found.', 'danger')
|
||||
return redirect(url_for('email.logs', type='emails'))
|
||||
# Remove file from disk
|
||||
if os.path.isfile(attachment.file_path):
|
||||
os.remove(attachment.file_path)
|
||||
# Remove from DB
|
||||
session.delete(attachment)
|
||||
session.commit()
|
||||
flash('Attachment deleted.', 'success')
|
||||
return redirect(url_for('email.logs', type='emails'))
|
||||
finally:
|
||||
session.close()
|
||||
Reference in New Issue
Block a user