- 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>
352 lines
16 KiB
HTML
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 %}
|