Files
orion/app/modules/messaging/templates/messaging/admin/email-logs.html
Samir Boulahtit ce822af883
Some checks failed
CI / ruff (push) Successful in 9s
CI / pytest (push) Failing after 47m32s
CI / validate (push) Successful in 23s
CI / dependency-scanning (push) Successful in 29s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
feat: production launch — email audit, team invites, security headers, router fixes
- Fix loyalty & monitoring router bugs (_get_router → named routers)
- Implement team invitation email with send_template + seed templates (en/fr/de)
- Add SecurityHeadersMiddleware (nosniff, HSTS, referrer-policy, permissions-policy)
- Build email audit admin page: service, schemas, API, page route, menu, i18n, HTML, JS
- Clean stale TODO in platform-menu-config.js
- Add 67 tests (unit + integration) covering all new functionality

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 18:24:30 +01:00

352 lines
16 KiB
HTML

{% extends "admin/base.html" %}
{% from 'shared/macros/pagination.html' import pagination %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/tables.html' import table_wrapper %}
{% from 'shared/macros/modals.html' import modal_simple %}
{% block title %}Email Logs{% endblock %}
{% block alpine_data %}emailLogsPage(){% endblock %}
{% block content %}
<!-- Page Header -->
{% call page_header_flex(title='Email Logs', subtitle='Audit all emails sent through the platform') %}
<div class="flex items-center gap-4">
{{ refresh_button(loading_var='loading', onclick='refresh()', variant='secondary') }}
</div>
{% endcall %}
{{ loading_state('Loading email logs...') }}
{{ error_state('Error loading email logs') }}
<div x-show="!loading && !error" x-cloak>
<!-- Stats Cards -->
<div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<!-- Total Sent -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Sent</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.by_status?.sent || 0"></p>
</div>
</div>
<!-- Failed -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-500">
<span x-html="$icon('x-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Failed</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.by_status?.failed || 0"></p>
</div>
</div>
<!-- Pending -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-yellow-500 bg-yellow-100 rounded-full dark:text-yellow-100 dark:bg-yellow-500">
<span x-html="$icon('clock', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Pending</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.by_status?.pending || 0"></p>
</div>
</div>
<!-- Delivered -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
<span x-html="$icon('inbox', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Delivered</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.by_status?.delivered || 0"></p>
</div>
</div>
</div>
<!-- Filters -->
<div class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-4">
<!-- Search -->
<div class="lg:col-span-2">
<input
type="text"
x-model="filters.search"
@keydown.enter="applyFilters()"
placeholder="Search by recipient email..."
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:border-purple-500 focus:ring-1 focus:ring-purple-500"
/>
</div>
<!-- Status -->
<div>
<select
x-model="filters.status"
@change="applyFilters()"
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:border-purple-500"
>
<option value="">All statuses</option>
<option value="sent">Sent</option>
<option value="failed">Failed</option>
<option value="pending">Pending</option>
<option value="delivered">Delivered</option>
<option value="bounced">Bounced</option>
<option value="opened">Opened</option>
<option value="clicked">Clicked</option>
</select>
</div>
<!-- Template -->
<div>
<select
x-model="filters.template_code"
@change="applyFilters()"
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:border-purple-500"
>
<option value="">All templates</option>
<template x-for="tpl in templateTypes" :key="tpl">
<option :value="tpl" x-text="templateLabel(tpl)"></option>
</template>
</select>
</div>
<!-- Date From -->
<div>
<input
type="date"
x-model="filters.date_from"
@change="applyFilters()"
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:border-purple-500"
/>
</div>
<!-- Date To -->
<div>
<input
type="date"
x-model="filters.date_to"
@change="applyFilters()"
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:border-purple-500"
/>
</div>
</div>
<!-- Filter actions -->
<div class="flex items-center gap-2 mt-3">
<button
@click="applyFilters()"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700"
>
Apply
</button>
<button
@click="resetFilters()"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
>
Reset
</button>
<span class="ml-auto text-sm text-gray-500 dark:text-gray-400" x-text="`${pagination.total} results`"></span>
</div>
</div>
<!-- Email Logs Table -->
{% call table_wrapper() %}
<table class="w-full whitespace-no-wrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Recipient</th>
<th class="px-4 py-3">Subject</th>
<th class="px-4 py-3">Template</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Date</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="log in logs" :key="log.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td class="px-4 py-3">
<div class="flex items-center text-sm">
<div>
<p class="font-semibold" x-text="log.recipient_email"></p>
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="log.recipient_name || ''"></p>
</div>
</div>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="log.subject?.length > 60 ? log.subject.substring(0, 60) + '...' : log.subject"></span>
</td>
<td class="px-4 py-3 text-sm">
<span
class="px-2 py-1 text-xs font-medium rounded-full bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300"
x-text="templateLabel(log.template_code)"
></span>
</td>
<td class="px-4 py-3 text-sm">
<span
class="px-2 py-1 text-xs font-semibold leading-tight rounded-full"
:class="statusBadgeClass(log.status)"
x-text="log.status"
></span>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="formatDate(log.created_at)"></span>
</td>
<td class="px-4 py-3 text-sm">
<button
@click="viewDetail(log.id)"
class="text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200"
title="View Detail"
>
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</button>
</td>
</tr>
</template>
<!-- Empty state -->
<tr x-show="logs.length === 0">
<td colspan="6" class="px-4 py-12 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('inbox', 'w-12 h-12 mx-auto mb-4 text-gray-300 dark:text-gray-600')"></span>
<p>No email logs found</p>
</td>
</tr>
</tbody>
</table>
{% endcall %}
<!-- Pagination -->
<div x-show="pagination.total_pages > 1" class="mt-4">
{{ pagination() }}
</div>
</div>
<!-- Detail Modal -->
{% call modal_simple(show_var='showDetail', title='Email Detail', size='xl') %}
<div x-show="selectedLog" class="space-y-6">
<!-- Metadata -->
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="font-medium text-gray-500 dark:text-gray-400">From:</span>
<p class="text-gray-700 dark:text-gray-200" x-text="selectedLog?.from_email"></p>
<p class="text-xs text-gray-500" x-text="selectedLog?.from_name || ''"></p>
</div>
<div>
<span class="font-medium text-gray-500 dark:text-gray-400">To:</span>
<p class="text-gray-700 dark:text-gray-200" x-text="selectedLog?.recipient_email"></p>
<p class="text-xs text-gray-500" x-text="selectedLog?.recipient_name || ''"></p>
</div>
<div>
<span class="font-medium text-gray-500 dark:text-gray-400">Subject:</span>
<p class="text-gray-700 dark:text-gray-200" x-text="selectedLog?.subject"></p>
</div>
<div>
<span class="font-medium text-gray-500 dark:text-gray-400">Status:</span>
<span
class="px-2 py-1 text-xs font-semibold leading-tight rounded-full"
:class="statusBadgeClass(selectedLog?.status)"
x-text="selectedLog?.status"
></span>
</div>
<div>
<span class="font-medium text-gray-500 dark:text-gray-400">Template:</span>
<p class="text-gray-700 dark:text-gray-200" x-text="templateLabel(selectedLog?.template_code)"></p>
</div>
<div>
<span class="font-medium text-gray-500 dark:text-gray-400">Provider:</span>
<p class="text-gray-700 dark:text-gray-200" x-text="selectedLog?.provider || 'N/A'"></p>
</div>
<div x-show="selectedLog?.store_id">
<span class="font-medium text-gray-500 dark:text-gray-400">Store ID:</span>
<p class="text-gray-700 dark:text-gray-200" x-text="selectedLog?.store_id"></p>
</div>
<div x-show="selectedLog?.related_type">
<span class="font-medium text-gray-500 dark:text-gray-400">Related:</span>
<p class="text-gray-700 dark:text-gray-200" x-text="(selectedLog?.related_type || '') + (selectedLog?.related_id ? ' #' + selectedLog?.related_id : '')"></p>
</div>
</div>
<!-- Status Timeline -->
<div class="border-t dark:border-gray-700 pt-4">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-200 mb-2">Status Timeline</h4>
<div class="space-y-2 text-sm">
<div class="flex items-center gap-2" x-show="selectedLog?.created_at">
<span class="w-2 h-2 rounded-full bg-gray-400"></span>
<span class="text-gray-500 dark:text-gray-400">Created:</span>
<span class="text-gray-700 dark:text-gray-200" x-text="formatDate(selectedLog?.created_at)"></span>
</div>
<div class="flex items-center gap-2" x-show="selectedLog?.sent_at">
<span class="w-2 h-2 rounded-full bg-green-400"></span>
<span class="text-gray-500 dark:text-gray-400">Sent:</span>
<span class="text-gray-700 dark:text-gray-200" x-text="formatDate(selectedLog?.sent_at)"></span>
</div>
<div class="flex items-center gap-2" x-show="selectedLog?.delivered_at">
<span class="w-2 h-2 rounded-full bg-blue-400"></span>
<span class="text-gray-500 dark:text-gray-400">Delivered:</span>
<span class="text-gray-700 dark:text-gray-200" x-text="formatDate(selectedLog?.delivered_at)"></span>
</div>
<div class="flex items-center gap-2" x-show="selectedLog?.opened_at">
<span class="w-2 h-2 rounded-full bg-purple-400"></span>
<span class="text-gray-500 dark:text-gray-400">Opened:</span>
<span class="text-gray-700 dark:text-gray-200" x-text="formatDate(selectedLog?.opened_at)"></span>
</div>
<div class="flex items-center gap-2" x-show="selectedLog?.clicked_at">
<span class="w-2 h-2 rounded-full bg-indigo-400"></span>
<span class="text-gray-500 dark:text-gray-400">Clicked:</span>
<span class="text-gray-700 dark:text-gray-200" x-text="formatDate(selectedLog?.clicked_at)"></span>
</div>
</div>
</div>
<!-- Error message -->
<div x-show="selectedLog?.error_message" class="border-t dark:border-gray-700 pt-4">
<h4 class="text-sm font-medium text-red-600 dark:text-red-400 mb-1">Error</h4>
<p class="text-sm text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-900/20 rounded p-2" x-text="selectedLog?.error_message"></p>
</div>
<!-- HTML Preview -->
<div class="border-t dark:border-gray-700 pt-4">
<div class="flex items-center justify-between mb-2">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-200">Email Content</h4>
<div class="flex gap-2">
<button
@click="detailTab = 'html'"
:class="detailTab === 'html' ? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300' : 'text-gray-500 dark:text-gray-400'"
class="px-3 py-1 text-xs rounded-full"
>HTML</button>
<button
@click="detailTab = 'text'"
:class="detailTab === 'text' ? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300' : 'text-gray-500 dark:text-gray-400'"
class="px-3 py-1 text-xs rounded-full"
>Text</button>
</div>
</div>
<div x-show="detailTab === 'html' && selectedLog?.body_html" class="border dark:border-gray-600 rounded-lg overflow-hidden" style="height: 400px;">
<iframe
x-ref="emailPreview"
class="w-full h-full bg-white"
sandbox="allow-same-origin"
></iframe>
</div>
<div x-show="detailTab === 'text' || !selectedLog?.body_html">
<pre class="text-sm text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-900 rounded-lg p-4 overflow-auto max-h-96 whitespace-pre-wrap" x-text="selectedLog?.body_text || 'No text content'"></pre>
</div>
<p x-show="!selectedLog?.body_html && !selectedLog?.body_text" class="text-sm text-gray-400 italic">
Content may have been purged per retention policy (90 days).
</p>
</div>
</div>
{% endcall %}
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('messaging_static', path='admin/js/email-logs.js') }}"></script>
{% endblock %}