Auto-commit local changes before build (2026-01-08 12:54:15)

This commit is contained in:
Ivo Oskamp 2026-01-08 12:54:15 +01:00
parent b7f057f0b5
commit b3fde8f431
6 changed files with 291 additions and 124 deletions

View File

@ -1 +1 @@
v20260108-27-admin-all-mail-audit-page v20260108-28-admin-all-mail-open-fix

View File

@ -10,7 +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_admin_mail # 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

View File

@ -0,0 +1,135 @@
from .routes_shared import * # noqa: F401,F403
from .routes_shared import _format_datetime
@main_bp.route("/admin/all-mail")
@login_required
@roles_required("admin")
def admin_all_mail_page():
# Pagination
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 = (request.args.get("received_from") or "").strip()
received_to = (request.args.get("received_to") or "").strip()
only_unlinked = (request.args.get("only_unlinked") or "").strip().lower() in (
"1",
"true",
"yes",
"on",
)
query = db.session.query(MailMessage).outerjoin(Job, MailMessage.job_id == Job.id)
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, but also allow matching the linked Job name.
query = query.filter(
or_(
MailMessage.job_name.ilike(f"%{job_name_q}%"),
Job.name.ilike(f"%{job_name_q}%"),
)
)
if only_unlinked:
query = query.filter(MailMessage.job_id.is_(None))
# Datetime window (received_at)
# Use dateutil.parser when available, otherwise a simple ISO parse fallback.
def _parse_dt(value: str):
if not value:
return None
try:
from dateutil import parser as dtparser # type: ignore
return dtparser.parse(value)
except Exception:
try:
# Accept "YYYY-MM-DDTHH:MM" from datetime-local.
return datetime.fromisoformat(value)
except Exception:
return None
dt_from = _parse_dt(received_from)
dt_to = _parse_dt(received_to)
if dt_from is not None:
query = query.filter(MailMessage.received_at >= dt_from)
if dt_to is not None:
query = query.filter(MailMessage.received_at <= dt_to)
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:
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 "") or (msg.job.name if msg.job else ""),
"linked": bool(msg.job_id),
"has_eml": bool(getattr(msg, "eml_stored_at", None)),
}
)
has_prev = page > 1
has_next = page < total_pages
return render_template(
"main/admin_all_mail.html",
rows=rows,
page=page,
total_pages=total_pages,
has_prev=has_prev,
has_next=has_next,
filters={
"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,
"received_to": received_to,
"only_unlinked": only_unlinked,
},
)

View File

@ -72,14 +72,12 @@
<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>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.admin_all_mail_page') }}">All Mail</a>
</li>
{% endif %} {% endif %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('main.customers') }}">Customers</a> <a class="nav-link" href="{{ url_for('main.customers') }}">Customers</a>

View File

@ -1,26 +1,43 @@
{% extends "layout/base.html" %} {% extends "layout/base.html" %}
<style> <style>
/* Match Inbox popup sizing */
.modal-xxl { max-width: 98vw; } .modal-xxl { max-width: 98vw; }
@media (min-width: 1400px) { .modal-xxl { max-width: 1400px; } } @media (min-width: 1400px) { .modal-xxl { max-width: 1400px; } }
#msg_body_container_iframe { height: 55vh; } #msg_body_container_iframe { height: 55vh; }
#msg_objects_container { max-height: 25vh; overflow: auto; } #msg_objects_container { max-height: 25vh; overflow: auto; }
.filter-card .form-label { font-size: 0.85rem; } .filter-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem 1rem;
}
@media (max-width: 1199px) {
.filter-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 767px) {
.filter-grid { grid-template-columns: 1fr; }
}
.filter-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
flex-wrap: wrap;
}
</style> </style>
{# Pager macro must be defined before it is used #} {# Pager macro must be defined before it is used #}
{% macro pager(position, page, total_pages, has_prev, has_next, filter_params) -%} {% macro pager(position, page, total_pages, has_prev, has_next, filters) -%}
<div class="d-flex justify-content-between align-items-center my-2"> <div class="d-flex justify-content-between align-items-center my-2">
<div> <div>
{% if has_prev %} {% 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> <a class="btn btn-outline-secondary btn-sm" href="{{ url_for('main.admin_all_mail_page', page=page-1, **filters) }}">Previous</a>
{% else %} {% else %}
<button class="btn btn-outline-secondary btn-sm" disabled>Previous</button> <button class="btn btn-outline-secondary btn-sm" disabled>Previous</button>
{% endif %} {% endif %}
{% if has_next %} {% 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> <a class="btn btn-outline-secondary btn-sm ms-2" href="{{ url_for('main.admin_all_mail_page', page=page+1, **filters) }}">Next</a>
{% else %} {% else %}
<button class="btn btn-outline-secondary btn-sm ms-2" disabled>Next</button> <button class="btn btn-outline-secondary btn-sm ms-2" disabled>Next</button>
{% endif %} {% endif %}
@ -29,11 +46,13 @@
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<span class="me-2">Page {{ page }} of {{ total_pages }}</span> <span class="me-2">Page {{ page }} of {{ total_pages }}</span>
<form method="get" class="d-flex align-items-center mb-0"> <form method="get" class="d-flex align-items-center mb-0">
{% for k, v in filter_params.items() %} {# Keep filters while paging #}
{% if v %} {% for k, v in filters.items() %}
{% if v is not none and v != '' %}
<input type="hidden" name="{{ k }}" value="{{ v }}" /> <input type="hidden" name="{{ k }}" value="{{ v }}" />
{% endif %} {% endif %}
{% endfor %} {% endfor %}
<label for="page_{{ position }}" class="form-label me-1 mb-0">Go to:</label> <label for="page_{{ position }}" class="form-label me-1 mb-0">Go to:</label>
<input <input
type="number" type="number"
@ -54,71 +73,78 @@
{% block content %} {% block content %}
<h2 class="mb-3">All Mail</h2> <h2 class="mb-3">All Mail</h2>
<div class="card mb-3 filter-card"> <div class="card mb-3">
<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"> <div class="card-body">
<form id="mailFilterForm" method="get" action="{{ url_for('main.admin_all_mails') }}"> <div class="d-flex justify-content-between align-items-center mb-2">
<div class="row g-3"> <h5 class="mb-0">Search Filters</h5>
<div class="col-12 col-lg-3"> <div class="filter-actions">
<label class="form-label" for="from_q">From contains</label> <a class="btn btn-link btn-sm" href="{{ url_for('main.admin_all_mail_page') }}">Clear Filter Values</a>
<input class="form-control form-control-sm" type="text" id="from_q" name="from_q" value="{{ filter_params.from_q }}" /> <button type="submit" form="allMailFiltersForm" class="btn btn-primary btn-sm">Search</button>
</div> </div>
<div class="col-12 col-lg-3"> </div>
<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 }}" /> <form id="allMailFiltersForm" method="get" class="mb-0">
</div> <div class="filter-grid">
<div class="col-12 col-lg-3"> <div>
<label class="form-label" for="backup_q">Backup contains</label> <label class="form-label mb-1">From contains</label>
<input class="form-control form-control-sm" type="text" id="backup_q" name="backup_q" value="{{ filter_params.backup_q }}" /> <input class="form-control" type="text" name="from_q" value="{{ filters.from_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>
<div class="col-12 col-lg-3"> <div>
<label class="form-label" for="job_name_q">Job name contains</label> <label class="form-label mb-1">Subject 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 }}" /> <input class="form-control" type="text" name="subject_q" value="{{ filters.subject_q }}" />
</div> </div>
<div class="col-12 col-lg-3">
<label class="form-label" for="received_from">Received &gt;=</label> <div>
<input class="form-control form-control-sm" type="datetime-local" id="received_from" name="received_from" value="{{ filter_params.received_from }}" /> <label class="form-label mb-1">Job name contains</label>
<input class="form-control" type="text" name="job_name_q" value="{{ filters.job_name_q }}" />
</div> </div>
<div class="col-12 col-lg-3">
<label class="form-label" for="received_to">Received &lt;=</label> <div>
<input class="form-control form-control-sm" type="datetime-local" id="received_to" name="received_to" value="{{ filter_params.received_to }}" /> <label class="form-label mb-1">Backup contains</label>
<input class="form-control" type="text" name="backup_q" value="{{ filters.backup_q }}" />
</div> </div>
<div class="col-12 col-lg-3 d-flex align-items-end">
<div class="form-check"> <div>
<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-label mb-1">Type contains</label>
<label class="form-check-label" for="only_unlinked">Only unlinked</label> <input class="form-control" type="text" name="type_q" value="{{ filters.type_q }}" />
</div>
<div>
<label class="form-label mb-1">Only unlinked</label>
<div class="form-check mt-1">
<input class="form-check-input" type="checkbox" id="only_unlinked" name="only_unlinked" value="1" {% if filters.only_unlinked %}checked{% endif %} />
<label class="form-check-label" for="only_unlinked">Show only unlinked</label>
</div> </div>
</div> </div>
<div>
<label class="form-label mb-1">Received &gt;=</label>
<input class="form-control" type="datetime-local" name="received_from" value="{{ filters.received_from }}" />
</div>
<div>
<label class="form-label mb-1">Received &lt;=</label>
<input class="form-control" type="datetime-local" name="received_to" value="{{ filters.received_to }}" />
</div>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
{{ pager("top", page, total_pages, has_prev, has_next, filter_params) }} {{ pager("top", page, total_pages, has_prev, has_next, filters) }}
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-sm table-hover align-middle" id="mailAuditTable"> <table class="table table-sm table-hover align-middle" id="allMailTable">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th scope="col">Received</th> <th scope="col">Date / time</th>
<th scope="col">From</th> <th scope="col">From</th>
<th scope="col">Subject</th> <th scope="col">Subject</th>
<th scope="col">Backup</th> <th scope="col">Backup</th>
<th scope="col">Type</th> <th scope="col">Type</th>
<th scope="col">Job name</th> <th scope="col">Job name</th>
<th scope="col">Linked</th> <th scope="col">Linked</th>
<th scope="col">Parsed</th>
<th scope="col">EML</th> <th scope="col">EML</th>
</tr> </tr>
</thead> </thead>
@ -134,35 +160,35 @@
<td>{{ row.job_name }}</td> <td>{{ row.job_name }}</td>
<td> <td>
{% if row.linked %} {% if row.linked %}
<span class="badge bg-success">Linked</span> <span class="badge text-bg-success">Linked</span>
{% else %} {% else %}
<span class="badge bg-warning text-dark">Unlinked</span> <span class="badge text-bg-warning">Unlinked</span>
{% endif %} {% endif %}
</td> </td>
<td>{{ row.parsed_at }}</td>
<td> <td>
{% if row.has_eml %} {% if row.has_eml %}
<a class="eml-download" href="{{ url_for('main.inbox_message_eml', message_id=row.id) }}" onclick="event.stopPropagation();">EML</a> <a class="eml-download" href="{{ url_for('main.inbox_message_eml', message_id=row.id) }}">EML</a>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
{% else %} {% else %}
<tr> <tr>
<td colspan="9" class="text-center text-muted py-3">No messages found.</td> <td colspan="8" class="text-center text-muted py-3">No messages found.</td>
</tr> </tr>
{% endif %} {% endif %}
</tbody> </tbody>
</table> </table>
</div> </div>
{{ pager("bottom", page, total_pages, has_prev, has_next, filter_params) }} {{ pager("bottom", page, total_pages, has_prev, has_next, filters) }}
<div class="modal fade" id="mailMessageModal" tabindex="-1" aria-labelledby="mailMessageModalLabel" aria-hidden="true"> <!-- Inline popup modal for message details (reuses Inbox detail endpoint) -->
<div class="modal fade" id="allMailMessageModal" tabindex="-1" aria-labelledby="allMailMessageModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable modal-xxl"> <div class="modal-dialog modal-xl modal-dialog-scrollable modal-xxl">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="mailMessageModalLabel">Message details</h5> <h5 class="modal-title" id="allMailMessageModalLabel">Message details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@ -184,9 +210,6 @@
<dt class="col-4">Overall</dt> <dt class="col-4">Overall</dt>
<dd class="col-8" id="msg_overall"></dd> <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> <dt class="col-4">Received</dt>
<dd class="col-8" id="msg_received"></dd> <dd class="col-8" id="msg_received"></dd>
@ -210,6 +233,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div> </div>
@ -219,83 +243,87 @@
<script> <script>
(function () { (function () {
var table = document.getElementById('mailAuditTable'); function wrapMailHtml(html) {
var modalEl = document.getElementById('mailMessageModal'); html = html || "";
if (!table || !modalEl) return; return (
"<!doctype html><html><head><meta charset=\"utf-8\">" +
var modal = new bootstrap.Modal(modalEl); "<base target=\"_blank\">" +
"</head><body style=\"margin:0; padding:8px;\">" +
function setText(id, value) { html +
var el = document.getElementById(id); "</body></html>"
if (el) el.textContent = value || ''; );
} }
function renderObjects(objects) { function renderObjects(objects) {
var container = document.getElementById('msg_objects_container'); var container = document.getElementById("msg_objects_container");
if (!container) return; if (!container) return;
container.innerHTML = '';
if (!objects || !objects.length) { if (!objects || !objects.length) {
container.innerHTML = '<div class="text-muted">No objects stored.</div>'; container.innerHTML = "<p class=\"text-muted mb-0\">No objects parsed for this message.</p>";
return; return;
} }
var tableHtml = '<div class="table-responsive"><table class="table table-sm table-hover align-middle">' + var html = "<div class=\"table-responsive\"><table class=\"table table-sm table-bordered mb-0\">";
'<thead class="table-light"><tr><th>Name</th><th>Type</th><th>Status</th><th>Error</th></tr></thead><tbody>'; html += "<thead><tr><th>Object</th><th>Type</th><th>Status</th><th>Error</th></tr></thead><tbody>";
for (var i = 0; i < objects.length; i++) { for (var i = 0; i < objects.length; i++) {
var o = objects[i] || {}; var o = objects[i] || {};
tableHtml += '<tr>' + html += "<tr>";
'<td>' + (o.name || '') + '</td>' + html += "<td>" + (o.name || "") + "</td>";
'<td>' + (o.type || '') + '</td>' + html += "<td>" + (o.type || "") + "</td>";
'<td>' + (o.status || '') + '</td>' + html += "<td>" + (o.status || "") + "</td>";
'<td style="white-space: pre-wrap;">' + (o.error_message || '') + '</td>' + html += "<td>" + (o.error_message || "") + "</td>";
'</tr>'; html += "</tr>";
} }
html += "</tbody></table></div>";
tableHtml += '</tbody></table></div>'; container.innerHTML = html;
container.innerHTML = tableHtml;
} }
function setIframeHtml(html) { // Prevent row click when clicking EML
var iframe = document.getElementById('msg_body_container_iframe'); document.querySelectorAll('a.eml-download').forEach(function (a) {
if (!iframe) return; a.addEventListener('click', function (ev) { ev.stopPropagation(); });
iframe.srcdoc = html || '<p>No message content stored.</p>'; });
}
async function openMessage(messageId) { var table = document.getElementById('allMailTable');
try { var modalEl = document.getElementById('allMailMessageModal');
var res = await fetch('{{ url_for('main.inbox_message_detail', message_id=0) }}'.replace('/0', '/' + messageId)); if (!table || !modalEl) return;
if (!res.ok) throw new Error('Failed to load message'); var modal = new bootstrap.Modal(modalEl);
var data = await res.json();
if (!data || data.status !== 'ok') throw new Error('Invalid response');
var meta = data.meta || {}; // Use event delegation so it always works (even after future DOM changes)
setText('msg_from', meta.from_address); table.addEventListener('click', function (ev) {
setText('msg_backup', meta.backup_software); var tr = ev.target && ev.target.closest ? ev.target.closest('tr[data-message-id]') : null;
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; if (!tr) return;
var id = tr.getAttribute('data-message-id'); var id = tr.getAttribute('data-message-id');
if (!id) return; if (!id) return;
openMessage(id);
fetch("{{ url_for('main.inbox_message_detail', message_id=0) }}".replace("0", id))
.then(function (resp) {
if (!resp.ok) throw new Error('Failed to load message details');
return resp.json();
})
.then(function (data) {
if (!data || data.status !== 'ok') throw new Error('Unexpected response');
var meta = data.meta || {};
document.getElementById('allMailMessageModalLabel').textContent = meta.subject || 'Message details';
document.getElementById('msg_from').textContent = meta.from_address || '';
document.getElementById('msg_backup').textContent = meta.backup_software || '';
document.getElementById('msg_type').textContent = meta.backup_type || '';
document.getElementById('msg_job').textContent = meta.job_name || '';
document.getElementById('msg_overall').textContent = meta.overall_status || '';
document.getElementById('msg_overall_message').textContent = meta.overall_message || '';
document.getElementById('msg_received').textContent = meta.received_at || '';
document.getElementById('msg_parsed').textContent = meta.parsed_at || '';
var bodyFrame = document.getElementById('msg_body_container_iframe');
if (bodyFrame) bodyFrame.srcdoc = wrapMailHtml(data.body_html || '');
renderObjects(data.objects || []);
modal.show();
})
.catch(function (err) {
console.error(err);
alert('Failed to load message details.');
});
}); });
})(); })();
</script> </script>

View File

@ -14,6 +14,12 @@
- Reused the existing Inbox message detail modal behavior to open and inspect messages from the All Mail page. - 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. - Added navigation entry for admins to access the All Mail page.
---
## v20260108-28-admin-all-mail-open-fix
- Fixed All Mail row-click handling by switching to event delegation, ensuring message rows reliably open the detail modal.
- Ensured EML link clicks no longer trigger row-click modal opening.
================================================================================================================================================ ================================================================================================================================================
## v0.1.18 ## v0.1.18