Merge pull request 'Auto-commit local changes before build (2026-01-08 12:40:27)' (#62) from v20260108-27-admin-all-mail-audit-page into main
Reviewed-on: #62
This commit is contained in:
commit
5617256820
@ -1 +1 @@
|
|||||||
v20260108-26-mail-move-only-after-successful-import
|
v20260108-27-admin-all-mail-audit-page
|
||||||
|
|||||||
@ -10,6 +10,7 @@ from .routes_shared import main_bp, roles_required # noqa: F401
|
|||||||
from . import routes_core # noqa: F401
|
from . import routes_core # noqa: F401
|
||||||
from . import routes_news # noqa: F401
|
from . import routes_news # noqa: F401
|
||||||
from . import routes_inbox # noqa: F401
|
from . import routes_inbox # noqa: F401
|
||||||
|
from . import routes_mail_audit # noqa: F401
|
||||||
from . import routes_customers # noqa: F401
|
from . import routes_customers # noqa: F401
|
||||||
from . import routes_jobs # noqa: F401
|
from . import routes_jobs # noqa: F401
|
||||||
from . import routes_settings # noqa: F401
|
from . import routes_settings # noqa: F401
|
||||||
|
|||||||
@ -0,0 +1,139 @@
|
|||||||
|
from .routes_shared import * # noqa: F401,F403
|
||||||
|
from .routes_shared import _format_datetime
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_datetime_local(value: str):
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
value = value.strip()
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
# Accept HTML datetime-local values like 2026-01-08T10:30
|
||||||
|
return datetime.fromisoformat(value)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route("/admin/mails")
|
||||||
|
@login_required
|
||||||
|
@roles_required("admin")
|
||||||
|
def admin_all_mails():
|
||||||
|
try:
|
||||||
|
page = int(request.args.get("page", "1"))
|
||||||
|
except ValueError:
|
||||||
|
page = 1
|
||||||
|
if page < 1:
|
||||||
|
page = 1
|
||||||
|
|
||||||
|
per_page = 50
|
||||||
|
|
||||||
|
# Filters (AND-combined)
|
||||||
|
from_q = (request.args.get("from_q", "") or "").strip()
|
||||||
|
subject_q = (request.args.get("subject_q", "") or "").strip()
|
||||||
|
backup_q = (request.args.get("backup_q", "") or "").strip()
|
||||||
|
type_q = (request.args.get("type_q", "") or "").strip()
|
||||||
|
job_name_q = (request.args.get("job_name_q", "") or "").strip()
|
||||||
|
received_from_raw = (request.args.get("received_from", "") or "").strip()
|
||||||
|
received_to_raw = (request.args.get("received_to", "") or "").strip()
|
||||||
|
only_unlinked = (request.args.get("only_unlinked", "") or "").strip().lower() in (
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
"on",
|
||||||
|
)
|
||||||
|
|
||||||
|
received_from = _parse_datetime_local(received_from_raw)
|
||||||
|
received_to = _parse_datetime_local(received_to_raw)
|
||||||
|
|
||||||
|
query = MailMessage.query
|
||||||
|
|
||||||
|
# Text filters
|
||||||
|
if from_q:
|
||||||
|
query = query.filter(MailMessage.from_address.ilike(f"%{from_q}%"))
|
||||||
|
if subject_q:
|
||||||
|
query = query.filter(MailMessage.subject.ilike(f"%{subject_q}%"))
|
||||||
|
if backup_q:
|
||||||
|
query = query.filter(MailMessage.backup_software.ilike(f"%{backup_q}%"))
|
||||||
|
if type_q:
|
||||||
|
query = query.filter(MailMessage.backup_type.ilike(f"%{type_q}%"))
|
||||||
|
if job_name_q:
|
||||||
|
# Prefer stored job_name on message, but also match linked job name
|
||||||
|
query = query.outerjoin(Job, Job.id == MailMessage.job_id).filter(
|
||||||
|
or_(
|
||||||
|
MailMessage.job_name.ilike(f"%{job_name_q}%"),
|
||||||
|
Job.job_name.ilike(f"%{job_name_q}%"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Time window
|
||||||
|
if received_from:
|
||||||
|
query = query.filter(MailMessage.received_at >= received_from)
|
||||||
|
if received_to:
|
||||||
|
query = query.filter(MailMessage.received_at <= received_to)
|
||||||
|
|
||||||
|
# Linked/unlinked
|
||||||
|
if only_unlinked:
|
||||||
|
query = query.filter(MailMessage.job_id.is_(None))
|
||||||
|
|
||||||
|
total_items = query.count()
|
||||||
|
total_pages = max(1, math.ceil(total_items / per_page)) if total_items else 1
|
||||||
|
if page > total_pages:
|
||||||
|
page = total_pages
|
||||||
|
|
||||||
|
messages = (
|
||||||
|
query.order_by(
|
||||||
|
MailMessage.received_at.desc().nullslast(),
|
||||||
|
MailMessage.id.desc(),
|
||||||
|
)
|
||||||
|
.offset((page - 1) * per_page)
|
||||||
|
.limit(per_page)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for msg in messages:
|
||||||
|
linked = bool(msg.job_id)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"id": msg.id,
|
||||||
|
"from_address": msg.from_address or "",
|
||||||
|
"subject": msg.subject or "",
|
||||||
|
"received_at": _format_datetime(msg.received_at),
|
||||||
|
"backup_software": msg.backup_software or "",
|
||||||
|
"backup_type": msg.backup_type or "",
|
||||||
|
"job_name": (msg.job_name or ""),
|
||||||
|
"linked": linked,
|
||||||
|
"parsed_at": _format_datetime(msg.parsed_at),
|
||||||
|
"overall_status": msg.overall_status or "",
|
||||||
|
"has_eml": bool(getattr(msg, "eml_stored_at", None)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
has_prev = page > 1
|
||||||
|
has_next = page < total_pages
|
||||||
|
|
||||||
|
filter_params = {
|
||||||
|
"from_q": from_q,
|
||||||
|
"subject_q": subject_q,
|
||||||
|
"backup_q": backup_q,
|
||||||
|
"type_q": type_q,
|
||||||
|
"job_name_q": job_name_q,
|
||||||
|
"received_from": received_from_raw,
|
||||||
|
"received_to": received_to_raw,
|
||||||
|
"only_unlinked": "1" if only_unlinked else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"main/admin_all_mail.html",
|
||||||
|
rows=rows,
|
||||||
|
page=page,
|
||||||
|
total_pages=total_pages,
|
||||||
|
has_prev=has_prev,
|
||||||
|
has_next=has_next,
|
||||||
|
filter_params=filter_params,
|
||||||
|
)
|
||||||
@ -72,6 +72,11 @@
|
|||||||
<a class="nav-link" href="{{ url_for('main.inbox') }}">Inbox</a>
|
<a class="nav-link" href="{{ url_for('main.inbox') }}">Inbox</a>
|
||||||
</li>
|
</li>
|
||||||
{% if active_role == 'admin' %}
|
{% if active_role == 'admin' %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('main.admin_all_mails') }}">All Mail</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if active_role == 'admin' %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{{ url_for('main.inbox_deleted_mails') }}">Deleted mails</a>
|
<a class="nav-link" href="{{ url_for('main.inbox_deleted_mails') }}">Deleted mails</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
303
containers/backupchecks/src/templates/main/admin_all_mail.html
Normal file
303
containers/backupchecks/src/templates/main/admin_all_mail.html
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
{% extends "layout/base.html" %}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-xxl { max-width: 98vw; }
|
||||||
|
@media (min-width: 1400px) { .modal-xxl { max-width: 1400px; } }
|
||||||
|
|
||||||
|
#msg_body_container_iframe { height: 55vh; }
|
||||||
|
#msg_objects_container { max-height: 25vh; overflow: auto; }
|
||||||
|
|
||||||
|
.filter-card .form-label { font-size: 0.85rem; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
{# Pager macro must be defined before it is used #}
|
||||||
|
{% macro pager(position, page, total_pages, has_prev, has_next, filter_params) -%}
|
||||||
|
<div class="d-flex justify-content-between align-items-center my-2">
|
||||||
|
<div>
|
||||||
|
{% if has_prev %}
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('main.admin_all_mails', page=page-1, **filter_params) }}">Previous</a>
|
||||||
|
{% else %}
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" disabled>Previous</button>
|
||||||
|
{% endif %}
|
||||||
|
{% if has_next %}
|
||||||
|
<a class="btn btn-outline-secondary btn-sm ms-2" href="{{ url_for('main.admin_all_mails', page=page+1, **filter_params) }}">Next</a>
|
||||||
|
{% else %}
|
||||||
|
<button class="btn btn-outline-secondary btn-sm ms-2" disabled>Next</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<span class="me-2">Page {{ page }} of {{ total_pages }}</span>
|
||||||
|
<form method="get" class="d-flex align-items-center mb-0">
|
||||||
|
{% for k, v in filter_params.items() %}
|
||||||
|
{% if v %}
|
||||||
|
<input type="hidden" name="{{ k }}" value="{{ v }}" />
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<label for="page_{{ position }}" class="form-label me-1 mb-0">Go to:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="{{ total_pages }}"
|
||||||
|
class="form-control form-control-sm me-1"
|
||||||
|
id="page_{{ position }}"
|
||||||
|
name="page"
|
||||||
|
value="{{ page }}"
|
||||||
|
style="width: 5rem;"
|
||||||
|
/>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Go</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2 class="mb-3">All Mail</h2>
|
||||||
|
|
||||||
|
<div class="card mb-3 filter-card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span>Search Filters</span>
|
||||||
|
<div class="d-flex gap-3">
|
||||||
|
<a class="small" href="{{ url_for('main.admin_all_mails') }}">Clear Filter Values</a>
|
||||||
|
<button class="btn btn-primary btn-sm" type="submit" form="mailFilterForm">Search</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="mailFilterForm" method="get" action="{{ url_for('main.admin_all_mails') }}">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12 col-lg-3">
|
||||||
|
<label class="form-label" for="from_q">From contains</label>
|
||||||
|
<input class="form-control form-control-sm" type="text" id="from_q" name="from_q" value="{{ filter_params.from_q }}" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-lg-3">
|
||||||
|
<label class="form-label" for="subject_q">Subject contains</label>
|
||||||
|
<input class="form-control form-control-sm" type="text" id="subject_q" name="subject_q" value="{{ filter_params.subject_q }}" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-lg-3">
|
||||||
|
<label class="form-label" for="backup_q">Backup contains</label>
|
||||||
|
<input class="form-control form-control-sm" type="text" id="backup_q" name="backup_q" value="{{ filter_params.backup_q }}" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-lg-3">
|
||||||
|
<label class="form-label" for="type_q">Type contains</label>
|
||||||
|
<input class="form-control form-control-sm" type="text" id="type_q" name="type_q" value="{{ filter_params.type_q }}" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-lg-3">
|
||||||
|
<label class="form-label" for="job_name_q">Job name contains</label>
|
||||||
|
<input class="form-control form-control-sm" type="text" id="job_name_q" name="job_name_q" value="{{ filter_params.job_name_q }}" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-lg-3">
|
||||||
|
<label class="form-label" for="received_from">Received >=</label>
|
||||||
|
<input class="form-control form-control-sm" type="datetime-local" id="received_from" name="received_from" value="{{ filter_params.received_from }}" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-lg-3">
|
||||||
|
<label class="form-label" for="received_to">Received <=</label>
|
||||||
|
<input class="form-control form-control-sm" type="datetime-local" id="received_to" name="received_to" value="{{ filter_params.received_to }}" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-lg-3 d-flex align-items-end">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="only_unlinked" name="only_unlinked" value="1" {% if filter_params.only_unlinked %}checked{% endif %} />
|
||||||
|
<label class="form-check-label" for="only_unlinked">Only unlinked</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ pager("top", page, total_pages, has_prev, has_next, filter_params) }}
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover align-middle" id="mailAuditTable">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Received</th>
|
||||||
|
<th scope="col">From</th>
|
||||||
|
<th scope="col">Subject</th>
|
||||||
|
<th scope="col">Backup</th>
|
||||||
|
<th scope="col">Type</th>
|
||||||
|
<th scope="col">Job name</th>
|
||||||
|
<th scope="col">Linked</th>
|
||||||
|
<th scope="col">Parsed</th>
|
||||||
|
<th scope="col">EML</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% if rows %}
|
||||||
|
{% for row in rows %}
|
||||||
|
<tr class="mail-row" data-message-id="{{ row.id }}" style="cursor: pointer;">
|
||||||
|
<td>{{ row.received_at }}</td>
|
||||||
|
<td>{{ row.from_address }}</td>
|
||||||
|
<td>{{ row.subject }}</td>
|
||||||
|
<td>{{ row.backup_software }}</td>
|
||||||
|
<td>{{ row.backup_type }}</td>
|
||||||
|
<td>{{ row.job_name }}</td>
|
||||||
|
<td>
|
||||||
|
{% if row.linked %}
|
||||||
|
<span class="badge bg-success">Linked</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-warning text-dark">Unlinked</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ row.parsed_at }}</td>
|
||||||
|
<td>
|
||||||
|
{% if row.has_eml %}
|
||||||
|
<a class="eml-download" href="{{ url_for('main.inbox_message_eml', message_id=row.id) }}" onclick="event.stopPropagation();">EML</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" class="text-center text-muted py-3">No messages found.</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ pager("bottom", page, total_pages, has_prev, has_next, filter_params) }}
|
||||||
|
|
||||||
|
<div class="modal fade" id="mailMessageModal" tabindex="-1" aria-labelledby="mailMessageModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-xl modal-dialog-scrollable modal-xxl">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="mailMessageModalLabel">Message details</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<dl class="row mb-0">
|
||||||
|
<dt class="col-4">From</dt>
|
||||||
|
<dd class="col-8" id="msg_from"></dd>
|
||||||
|
|
||||||
|
<dt class="col-4">Backup</dt>
|
||||||
|
<dd class="col-8" id="msg_backup"></dd>
|
||||||
|
|
||||||
|
<dt class="col-4">Type</dt>
|
||||||
|
<dd class="col-8" id="msg_type"></dd>
|
||||||
|
|
||||||
|
<dt class="col-4">Job</dt>
|
||||||
|
<dd class="col-8" id="msg_job"></dd>
|
||||||
|
|
||||||
|
<dt class="col-4">Overall</dt>
|
||||||
|
<dd class="col-8" id="msg_overall"></dd>
|
||||||
|
|
||||||
|
<dt class="col-4">Customer</dt>
|
||||||
|
<dd class="col-8" id="msg_customer"></dd>
|
||||||
|
|
||||||
|
<dt class="col-4">Received</dt>
|
||||||
|
<dd class="col-8" id="msg_received"></dd>
|
||||||
|
|
||||||
|
<dt class="col-4">Parsed</dt>
|
||||||
|
<dd class="col-8" id="msg_parsed"></dd>
|
||||||
|
|
||||||
|
<dt class="col-4">Details</dt>
|
||||||
|
<dd class="col-8" id="msg_overall_message" style="white-space: pre-wrap;"></dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-9">
|
||||||
|
<div class="border rounded p-2 p-0" style="overflow:hidden;">
|
||||||
|
<iframe id="msg_body_container_iframe" class="w-100" style="height:55vh; border:0; background:transparent;" sandbox="allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation"></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<h6>Objects</h6>
|
||||||
|
<div id="msg_objects_container"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var table = document.getElementById('mailAuditTable');
|
||||||
|
var modalEl = document.getElementById('mailMessageModal');
|
||||||
|
if (!table || !modalEl) return;
|
||||||
|
|
||||||
|
var modal = new bootstrap.Modal(modalEl);
|
||||||
|
|
||||||
|
function setText(id, value) {
|
||||||
|
var el = document.getElementById(id);
|
||||||
|
if (el) el.textContent = value || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderObjects(objects) {
|
||||||
|
var container = document.getElementById('msg_objects_container');
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
if (!objects || !objects.length) {
|
||||||
|
container.innerHTML = '<div class="text-muted">No objects stored.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tableHtml = '<div class="table-responsive"><table class="table table-sm table-hover align-middle">' +
|
||||||
|
'<thead class="table-light"><tr><th>Name</th><th>Type</th><th>Status</th><th>Error</th></tr></thead><tbody>';
|
||||||
|
|
||||||
|
for (var i = 0; i < objects.length; i++) {
|
||||||
|
var o = objects[i] || {};
|
||||||
|
tableHtml += '<tr>' +
|
||||||
|
'<td>' + (o.name || '') + '</td>' +
|
||||||
|
'<td>' + (o.type || '') + '</td>' +
|
||||||
|
'<td>' + (o.status || '') + '</td>' +
|
||||||
|
'<td style="white-space: pre-wrap;">' + (o.error_message || '') + '</td>' +
|
||||||
|
'</tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
tableHtml += '</tbody></table></div>';
|
||||||
|
container.innerHTML = tableHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setIframeHtml(html) {
|
||||||
|
var iframe = document.getElementById('msg_body_container_iframe');
|
||||||
|
if (!iframe) return;
|
||||||
|
iframe.srcdoc = html || '<p>No message content stored.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openMessage(messageId) {
|
||||||
|
try {
|
||||||
|
var res = await fetch('{{ url_for('main.inbox_message_detail', message_id=0) }}'.replace('/0', '/' + messageId));
|
||||||
|
if (!res.ok) throw new Error('Failed to load message');
|
||||||
|
var data = await res.json();
|
||||||
|
if (!data || data.status !== 'ok') throw new Error('Invalid response');
|
||||||
|
|
||||||
|
var meta = data.meta || {};
|
||||||
|
setText('msg_from', meta.from_address);
|
||||||
|
setText('msg_backup', meta.backup_software);
|
||||||
|
setText('msg_type', meta.backup_type);
|
||||||
|
setText('msg_job', meta.job_name);
|
||||||
|
setText('msg_overall', meta.overall_status);
|
||||||
|
setText('msg_customer', meta.customer_name);
|
||||||
|
setText('msg_received', meta.received_at);
|
||||||
|
setText('msg_parsed', meta.parsed_at);
|
||||||
|
setText('msg_overall_message', meta.overall_message);
|
||||||
|
|
||||||
|
setIframeHtml(data.body_html);
|
||||||
|
renderObjects(data.objects);
|
||||||
|
|
||||||
|
modal.show();
|
||||||
|
} catch (e) {
|
||||||
|
alert('Unable to open message details.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table.addEventListener('click', function (e) {
|
||||||
|
var tr = e.target.closest('tr.mail-row');
|
||||||
|
if (!tr) return;
|
||||||
|
var id = tr.getAttribute('data-message-id');
|
||||||
|
if (!id) return;
|
||||||
|
openMessage(id);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@ -5,6 +5,14 @@
|
|||||||
- Added explicit commit and rollback handling to ensure database integrity before mail state changes.
|
- Added explicit commit and rollback handling to ensure database integrity before mail state changes.
|
||||||
- Improved logging around import and commit failures to better trace skipped or retried mails.
|
- Improved logging around import and commit failures to better trace skipped or retried mails.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v20260108-27-admin-all-mail-audit-page
|
||||||
|
- Added an admin-only “All Mail” page showing all received mail messages with 50 items per page.
|
||||||
|
- Implemented always-visible search filters with AND-combined criteria: From, Subject, Backup, Type, Job name, and a received datetime window.
|
||||||
|
- Added “Only unlinked” filter to quickly find messages not linked to a job.
|
||||||
|
- Reused the existing Inbox message detail modal behavior to open and inspect messages from the All Mail page.
|
||||||
|
- Added navigation entry for admins to access the All Mail page.
|
||||||
|
|
||||||
|
|
||||||
================================================================================================================================================
|
================================================================================================================================================
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user