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>
This commit is contained in:
264
app/modules/messaging/static/admin/js/email-logs.js
Normal file
264
app/modules/messaging/static/admin/js/email-logs.js
Normal file
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user