Merge pull request #100 from christianlouis/codex/mail-source-import-history-ui

feat: show mail import history
This commit is contained in:
Christian Krakau-Louis
2026-05-22 19:44:41 +02:00
committed by GitHub
6 changed files with 145 additions and 4 deletions
+4 -2
View File
@@ -73,8 +73,10 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
# See: https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
csp_directives = [
"default-src 'self'",
# TODO: Remove 'unsafe-inline' - requires moving inline scripts to external files # pylint: disable=fixme
"script-src 'self' 'unsafe-inline'"
# TODO: Remove 'unsafe-inline' and 'unsafe-eval' - requires moving inline
# scripts to external files and replacing the standard Alpine CDN build
# with the CSP-compatible build. # pylint: disable=fixme
"script-src 'self' 'unsafe-inline' 'unsafe-eval'"
" https://cdn.tailwindcss.com https://cdn.jsdelivr.net",
# TODO: Remove 'unsafe-inline' - requires moving inline styles to CSS or using nonces # pylint: disable=fixme
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com"
+124
View File
@@ -105,6 +105,14 @@
<polyline points="22 4 12 14.01 9 11.01"></polyline>
</svg>
</button>
<button class="btn btn-ghost btn-xs" x-on:click="loadImportHistory(source)" title="Import history">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
</button>
<button class="btn btn-ghost btn-xs text-error" x-on:click="confirmDelete(source)" title="Delete">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
@@ -127,6 +135,79 @@
{% endcall %}
{% endcall %}
<!-- Import history -->
<template x-if="historySource">
{% call card() %}
{% call card_header() %}
<div class="flex items-start justify-between gap-4">
<div>
{% call card_title() %}Import History{% endcall %}
{% call card_description() %}
<span x-text="historySource ? historySource.name : ''"></span>
{% endcall %}
</div>
<button class="btn btn-ghost btn-sm" x-on:click="closeHistory()">Close</button>
</div>
{% endcall %}
{% call card_content() %}
<template x-if="historyLoading">
<p class="text-muted-foreground text-sm py-4 text-center">Loading import history…</p>
</template>
<template x-if="!historyLoading && importHistory.length === 0">
<p class="text-muted-foreground text-sm py-4 text-center">No import attempts recorded yet.</p>
</template>
<template x-if="!historyLoading && importHistory.length > 0">
<div class="overflow-x-auto">
<table class="table w-full">
<thead>
<tr>
<th>Started</th>
<th>Status</th>
<th>Trigger</th>
<th>Emails</th>
<th>Reports</th>
<th>Duplicates</th>
<th>New Domains</th>
<th>Errors</th>
</tr>
</thead>
<tbody>
<template x-for="entry in importHistory" :key="entry.id">
<tr>
<td x-text="formatDate(entry.started_at)"></td>
<td>
<span class="badge" :class="statusBadgeClass(entry.status)" x-text="entry.status"></span>
</td>
<td x-text="entry.trigger"></td>
<td x-text="entry.processed"></td>
<td x-text="entry.reports_found"></td>
<td x-text="entry.duplicate_reports"></td>
<td x-text="formatList(entry.new_domains)"></td>
<td>
<template x-if="entry.errors && entry.errors.length">
<details class="max-w-xs">
<summary class="cursor-pointer" x-text="`${entry.error_count} error${entry.error_count === 1 ? '' : 's'}`"></summary>
<ul class="list-disc pl-4 mt-2 text-xs space-y-1">
<template x-for="error in entry.errors" :key="error">
<li x-text="error"></li>
</template>
</ul>
</details>
</template>
<template x-if="!entry.errors || entry.errors.length === 0">
<span class="text-muted-foreground">None</span>
</template>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
{% endcall %}
{% endcall %}
</template>
<!-- Add / Edit modal -->
<div x-show="showForm" x-cloak
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
@@ -390,6 +471,9 @@ function mailSourcesApp() {
isTesting: false,
isSaving: false,
testing: {},
historySource: null,
importHistory: [],
historyLoading: false,
feedback: { message: '', type: '' },
testResult: { message: '', success: false },
gmailConnected: false,
@@ -425,6 +509,46 @@ function mailSourcesApp() {
}
},
async loadImportHistory(source) {
this.historySource = source;
this.importHistory = [];
this.historyLoading = true;
this.feedback = { message: '', type: '' };
try {
const resp = await fetch(`/api/v1/mail-sources/${source.id}/imports?limit=20`);
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.detail || 'Failed to load import history');
}
this.importHistory = await resp.json();
} catch (e) {
this.feedback = { message: `Error: ${e.message}`, type: 'error' };
} finally {
this.historyLoading = false;
}
},
closeHistory() {
this.historySource = null;
this.importHistory = [];
this.historyLoading = false;
},
formatDate(value) {
return value ? new Date(value).toLocaleString() : '—';
},
formatList(value) {
return value && value.length ? value.join(', ') : '—';
},
statusBadgeClass(status) {
if (status === 'success') return 'badge-success';
if (status === 'warning') return 'badge-warning';
if (status === 'failed') return 'badge-error';
return 'badge-outline';
},
openAddForm() {
this.editingId = null;
this.gmailConnected = false;
+12
View File
@@ -123,6 +123,18 @@ class TestFileUploadSecurity:
DMARCParser.parse_file(large_content, "test.xml")
class TestSecurityHeaders:
"""Test security headers that affect the browser UI."""
def test_csp_allows_current_alpine_runtime(self, client: TestClient):
"""The current Alpine CDN build needs eval permission to render UI pages."""
response = client.get("/mail-sources")
csp = response.headers["Content-Security-Policy"]
assert "'unsafe-eval'" in csp
assert "https://cdn.jsdelivr.net" in csp
class TestXMLParsingSecurity:
"""Test XML parsing security (defusedxml, XXE protection)."""