- 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>
265 lines
9.2 KiB
JavaScript
265 lines
9.2 KiB
JavaScript
/**
|
|
* Admin Email Logs (Audit) Page
|
|
*
|
|
* Universal email audit screen covering all emails sent through the platform.
|
|
* Provides filtering, pagination, stats dashboard, and detail preview.
|
|
*
|
|
* Content retention policy: A scheduled Celery task (post-launch) will null out
|
|
* body_html/body_text on EmailLog records older than 90 days. Metadata (recipient,
|
|
* subject, status, timestamps, store context) is kept indefinitely for audit/compliance.
|
|
*/
|
|
|
|
const emailLogsLog = window.LogConfig?.createLogger('EMAIL_LOGS') || console;
|
|
|
|
function emailLogsPage() {
|
|
return {
|
|
...data(),
|
|
currentPage: 'email-logs',
|
|
|
|
// State
|
|
loading: true,
|
|
error: null,
|
|
logs: [],
|
|
stats: { by_status: {}, by_template: {}, total: 0 },
|
|
|
|
// Filters
|
|
filters: {
|
|
search: '',
|
|
status: '',
|
|
template_code: '',
|
|
store_id: '',
|
|
date_from: '',
|
|
date_to: '',
|
|
},
|
|
|
|
// Pagination
|
|
pagination: {
|
|
page: 1,
|
|
per_page: 50,
|
|
total: 0,
|
|
pages: 0,
|
|
},
|
|
|
|
// Detail modal
|
|
showDetail: false,
|
|
selectedLog: null,
|
|
detailTab: 'html',
|
|
|
|
// Known template types for dropdown
|
|
templateTypes: [
|
|
'signup_welcome',
|
|
'order_confirmation',
|
|
'password_reset',
|
|
'team_invitation',
|
|
'subscription_welcome',
|
|
'payment_failed',
|
|
'subscription_cancelled',
|
|
'trial_ending',
|
|
],
|
|
|
|
async init() {
|
|
if (window._emailLogsInitialized) return;
|
|
window._emailLogsInitialized = true;
|
|
emailLogsLog.info('Email logs page initializing');
|
|
try {
|
|
if (window.PlatformSettings) {
|
|
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
|
|
}
|
|
await I18n.loadModule('messaging');
|
|
await Promise.all([this.loadLogs(), this.loadStats()]);
|
|
} catch (err) {
|
|
emailLogsLog.error('Init failed:', err);
|
|
this.error = 'Failed to load email logs';
|
|
}
|
|
},
|
|
|
|
async loadLogs() {
|
|
this.loading = true;
|
|
this.error = null;
|
|
try {
|
|
const params = new URLSearchParams({
|
|
page: this.pagination.page,
|
|
per_page: this.pagination.per_page,
|
|
});
|
|
|
|
if (this.filters.search) params.set('search', this.filters.search);
|
|
if (this.filters.status) params.set('status', this.filters.status);
|
|
if (this.filters.template_code) params.set('template_code', this.filters.template_code);
|
|
if (this.filters.store_id) params.set('store_id', this.filters.store_id);
|
|
if (this.filters.date_from) params.set('date_from', this.filters.date_from);
|
|
if (this.filters.date_to) params.set('date_to', this.filters.date_to);
|
|
|
|
const result = await apiClient.get(`/admin/email-logs?${params}`);
|
|
this.logs = result.items || [];
|
|
this.pagination.total = result.total || 0;
|
|
this.pagination.pages = result.total_pages || 0;
|
|
|
|
emailLogsLog.info(`Loaded ${this.logs.length} logs (total: ${this.pagination.total})`);
|
|
} catch (err) {
|
|
emailLogsLog.error('Failed to load logs:', err);
|
|
this.error = err.message || 'Failed to load email logs';
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
async loadStats() {
|
|
try {
|
|
this.stats = await apiClient.get('/admin/email-logs/stats');
|
|
|
|
// Update template types from actual data
|
|
if (this.stats.by_template) {
|
|
const serverTypes = Object.keys(this.stats.by_template);
|
|
const merged = new Set([...this.templateTypes, ...serverTypes]);
|
|
this.templateTypes = [...merged].sort();
|
|
}
|
|
} catch (err) {
|
|
emailLogsLog.error('Failed to load stats:', err);
|
|
}
|
|
},
|
|
|
|
async viewDetail(logId) {
|
|
try {
|
|
this.selectedLog = await apiClient.get(`/admin/email-logs/${logId}`);
|
|
this.detailTab = this.selectedLog.body_html ? 'html' : 'text';
|
|
this.showDetail = true;
|
|
|
|
// Render HTML in sandboxed iframe after DOM updates
|
|
this.$nextTick(() => {
|
|
if (this.selectedLog?.body_html && this.$refs.emailPreview) {
|
|
const doc = this.$refs.emailPreview.contentDocument;
|
|
doc.open();
|
|
doc.write(this.selectedLog.body_html);
|
|
doc.close();
|
|
}
|
|
});
|
|
} catch (err) {
|
|
emailLogsLog.error('Failed to load log detail:', err);
|
|
}
|
|
},
|
|
|
|
applyFilters() {
|
|
this.pagination.page = 1;
|
|
this.loadLogs();
|
|
},
|
|
|
|
resetFilters() {
|
|
this.filters = {
|
|
search: '',
|
|
status: '',
|
|
template_code: '',
|
|
store_id: '',
|
|
date_from: '',
|
|
date_to: '',
|
|
};
|
|
this.applyFilters();
|
|
},
|
|
|
|
async refresh() {
|
|
try {
|
|
this.error = null;
|
|
await Promise.all([this.loadLogs(), this.loadStats()]);
|
|
} catch (err) {
|
|
emailLogsLog.error('Refresh failed:', err);
|
|
this.error = err.message || 'Failed to refresh';
|
|
}
|
|
},
|
|
|
|
// Pagination computed properties and methods required by pagination macro
|
|
get startIndex() {
|
|
if (this.pagination.total === 0) return 0;
|
|
return (this.pagination.page - 1) * this.pagination.per_page + 1;
|
|
},
|
|
|
|
get endIndex() {
|
|
return Math.min(
|
|
this.pagination.page * this.pagination.per_page,
|
|
this.pagination.total
|
|
);
|
|
},
|
|
|
|
get totalPages() {
|
|
return this.pagination.pages;
|
|
},
|
|
|
|
get pageNumbers() {
|
|
const pages = [];
|
|
const total = this.pagination.pages;
|
|
const current = this.pagination.page;
|
|
|
|
if (total <= 7) {
|
|
for (let i = 1; i <= total; i++) pages.push(i);
|
|
} else {
|
|
pages.push(1);
|
|
if (current > 3) pages.push('...');
|
|
for (let i = Math.max(2, current - 1); i <= Math.min(total - 1, current + 1); i++) {
|
|
pages.push(i);
|
|
}
|
|
if (current < total - 2) pages.push('...');
|
|
pages.push(total);
|
|
}
|
|
return pages;
|
|
},
|
|
|
|
previousPage() {
|
|
if (this.pagination.page > 1) {
|
|
this.pagination.page--;
|
|
this.loadLogs();
|
|
}
|
|
},
|
|
|
|
nextPage() {
|
|
if (this.pagination.page < this.pagination.pages) {
|
|
this.pagination.page++;
|
|
this.loadLogs();
|
|
}
|
|
},
|
|
|
|
goToPage(page) {
|
|
if (page === '...' || page === this.pagination.page) return;
|
|
this.pagination.page = page;
|
|
this.loadLogs();
|
|
},
|
|
|
|
// Template code to human-readable label
|
|
templateLabel(code) {
|
|
if (!code) return 'N/A';
|
|
const labels = {
|
|
signup_welcome: 'Signup Welcome',
|
|
order_confirmation: 'Order Confirmation',
|
|
password_reset: 'Password Reset',
|
|
team_invitation: 'Team Invitation',
|
|
team_invite: 'Team Invite',
|
|
subscription_welcome: 'Subscription Welcome',
|
|
payment_failed: 'Payment Failed',
|
|
subscription_cancelled: 'Subscription Cancelled',
|
|
trial_ending: 'Trial Ending',
|
|
};
|
|
return labels[code] || code.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
|
},
|
|
|
|
// Status badge CSS classes
|
|
statusBadgeClass(status) {
|
|
const classes = {
|
|
sent: 'text-green-700 bg-green-100 dark:text-green-100 dark:bg-green-700',
|
|
failed: 'text-red-700 bg-red-100 dark:text-red-100 dark:bg-red-700',
|
|
pending: 'text-yellow-700 bg-yellow-100 dark:text-yellow-100 dark:bg-yellow-700',
|
|
delivered: 'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700',
|
|
bounced: 'text-orange-700 bg-orange-100 dark:text-orange-100 dark:bg-orange-700',
|
|
opened: 'text-purple-700 bg-purple-100 dark:text-purple-100 dark:bg-purple-700',
|
|
clicked: 'text-indigo-700 bg-indigo-100 dark:text-indigo-100 dark:bg-indigo-700',
|
|
};
|
|
return classes[status] || 'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700';
|
|
},
|
|
|
|
formatDate(dateStr) {
|
|
if (!dateStr) return '';
|
|
try {
|
|
return new Date(dateStr).toLocaleString();
|
|
} catch {
|
|
return dateStr;
|
|
}
|
|
},
|
|
};
|
|
}
|