Files
orion/app/modules/messaging/static/admin/js/email-logs.js
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

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;
}
},
};
}