Files
mailcow-logs-viewer/frontend/app.js
2026-01-20 21:51:53 +02:00

7166 lines
365 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// =============================================================================
// MAILCOW LOGS VIEWER - COMPLETE FRONTEND
// Part 1: Core, Global State, Dashboard, Postfix, Rspamd, Netfilter
// =============================================================================
// =============================================================================
// GLOBAL COLOR CONFIGURATION
// Edit these values to customize colors across the entire application
// =============================================================================
const APP_COLORS = {
// Email Direction Colors
directions: {
inbound: {
// Indigo
badge: 'bg-indigo-100 dark:bg-indigo-500/10 text-indigo-700 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-500/20',
bg: 'bg-indigo-100 dark:bg-indigo-500/25',
text: 'text-indigo-700 dark:text-indigo-400'
},
outbound: {
// Blue
badge: 'bg-blue-100 dark:bg-blue-500/10 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-500/20',
bg: 'bg-blue-100 dark:bg-blue-500/25',
text: 'text-blue-700 dark:text-blue-400'
},
internal: {
// Teal
badge: 'bg-teal-100 dark:bg-teal-500/10 text-teal-800 dark:text-teal-300 border border-teal-200 dark:border-teal-500/20',
bg: 'bg-teal-100 dark:bg-teal-500/25',
text: 'text-teal-700 dark:text-teal-400'
}
},
statuses: {
delivered: {
// Emerald
badge: 'bg-emerald-100 dark:bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-500/20',
bg: 'bg-emerald-100 dark:bg-emerald-500/25',
text: 'text-emerald-700 dark:text-emerald-400'
},
sent: {
// Green
badge: 'bg-green-100 dark:bg-green-500/10 text-green-700 dark:text-green-300 border border-green-200 dark:border-green-500/20',
bg: 'bg-green-100 dark:bg-green-500/25',
text: 'text-green-700 dark:text-green-400'
},
deferred: {
// Yellow (Fixed: Changed from Amber to Yellow)
badge: 'bg-yellow-100 dark:bg-yellow-500/10 text-yellow-700 dark:text-yellow-300 border border-yellow-200 dark:border-yellow-500/20',
bg: 'bg-yellow-100 dark:bg-yellow-500/25',
text: 'text-yellow-700 dark:text-yellow-400'
},
bounced: {
// Orange
badge: 'bg-orange-100 dark:bg-orange-500/10 text-orange-700 dark:text-orange-300 border border-orange-200 dark:border-orange-500/20',
bg: 'bg-orange-100 dark:bg-orange-500/25',
text: 'text-orange-700 dark:text-orange-400'
},
rejected: {
// Red
badge: 'bg-red-100 dark:bg-red-500/10 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-500/20',
bg: 'bg-red-100 dark:bg-red-500/25',
text: 'text-red-700 dark:text-red-400'
},
spam: {
// Fuchsia
badge: 'bg-fuchsia-100 dark:bg-fuchsia-500/10 text-fuchsia-700 dark:text-fuchsia-300 border border-fuchsia-200 dark:border-fuchsia-500/20',
bg: 'bg-fuchsia-100 dark:bg-fuchsia-500/25',
text: 'text-fuchsia-700 dark:text-fuchsia-400'
},
expired: {
// Zinc
badge: 'bg-zinc-100 dark:bg-zinc-500/10 text-zinc-700 dark:text-zinc-300 border border-zinc-200 dark:border-zinc-500/20',
bg: 'bg-zinc-100 dark:bg-zinc-500/25',
text: 'text-zinc-700 dark:text-zinc-400'
}
},
// Default color for unknown values
default: {
badge: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300',
bg: 'bg-gray-100 dark:bg-gray-700',
text: 'text-gray-600 dark:text-gray-400'
}
};
// Helper functions for accessing colors
function getDirectionBadgeClass(direction) {
return APP_COLORS.directions[direction]?.badge || APP_COLORS.default.badge;
}
function getDirectionBgClass(direction) {
return APP_COLORS.directions[direction]?.bg || APP_COLORS.default.bg;
}
function getDirectionTextClass(direction) {
return APP_COLORS.directions[direction]?.text || APP_COLORS.default.text;
}
function getStatusBadgeClass(status) {
return APP_COLORS.statuses[status]?.badge || APP_COLORS.default.badge;
}
function getStatusBgClass(status) {
return APP_COLORS.statuses[status]?.bg || APP_COLORS.default.bg;
}
function getStatusTextClass(status) {
return APP_COLORS.statuses[status]?.text || APP_COLORS.default.text;
}
// =============================================================================
// NAVIGATION HELPERS
// =============================================================================
/**
* Navigate to Messages page with pre-filled filters
* @param {Object} options - Filter options
* @param {string} options.email - Email address to filter by
* @param {string} options.filterType - 'sender' | 'recipient' | 'search'
* @param {string} options.direction - 'inbound' | 'outbound' | 'internal'
* @param {string} options.status - 'delivered' | 'bounced' | 'deferred' | 'rejected'
*/
function navigateToMessagesWithFilter(options) {
// Clear existing filters first
const filterSearch = document.getElementById('messages-filter-search');
const filterSender = document.getElementById('messages-filter-sender');
const filterRecipient = document.getElementById('messages-filter-recipient');
const filterDirection = document.getElementById('messages-filter-direction');
const filterStatus = document.getElementById('messages-filter-status');
const filterUser = document.getElementById('messages-filter-user');
const filterIp = document.getElementById('messages-filter-ip');
// Reset all filters
if (filterSearch) filterSearch.value = '';
if (filterSender) filterSender.value = '';
if (filterRecipient) filterRecipient.value = '';
if (filterDirection) filterDirection.value = '';
if (filterStatus) filterStatus.value = '';
if (filterUser) filterUser.value = '';
if (filterIp) filterIp.value = '';
// Set email filter based on type
if (options.email) {
if (options.filterType === 'sender') {
if (filterSender) filterSender.value = options.email;
} else if (options.filterType === 'recipient') {
if (filterRecipient) filterRecipient.value = options.email;
} else {
// Default: use search field
if (filterSearch) filterSearch.value = options.email;
}
}
// Set direction filter
if (options.direction && filterDirection) {
filterDirection.value = options.direction;
}
// Set status filter
if (options.status && filterStatus) {
filterStatus.value = options.status;
}
// Navigate to Messages tab
navigateTo('messages');
// Apply filters after navigation
setTimeout(() => {
if (typeof applyMessagesFilters === 'function') {
applyMessagesFilters();
}
}, 100);
}
// =============================================================================
// AUTHENTICATION SYSTEM
// =============================================================================
// Authentication state
let authCredentials = null;
// DMARC imap
let dmarcImapStatus = null;
let dmarcConfiguration = null;
// Load saved credentials from sessionStorage
function loadAuthCredentials() {
try {
const saved = sessionStorage.getItem('auth_credentials');
if (saved) {
authCredentials = JSON.parse(saved);
}
} catch (e) {
console.error('Failed to load auth credentials:', e);
authCredentials = null;
}
}
// Save credentials to sessionStorage
function saveAuthCredentials(username, password) {
try {
authCredentials = { username, password };
sessionStorage.setItem('auth_credentials', JSON.stringify(authCredentials));
} catch (e) {
console.error('Failed to save auth credentials:', e);
}
}
// Clear credentials
function clearAuthCredentials() {
authCredentials = null;
try {
sessionStorage.removeItem('auth_credentials');
} catch (e) {
console.error('Failed to clear auth credentials:', e);
}
}
// Create Basic Auth header
function getAuthHeader() {
if (!authCredentials) return {};
const credentials = btoa(`${authCredentials.username}:${authCredentials.password}`);
return {
'Authorization': `Basic ${credentials}`
};
}
// Enhanced fetch with authentication
async function authenticatedFetch(url, options = {}) {
const headers = {
...options.headers,
...getAuthHeader()
};
const response = await fetch(url, {
...options,
headers
});
// Handle 401 Unauthorized
if (response.status === 401) {
clearAuthCredentials();
showLoginModal();
throw new Error('Authentication required');
}
return response;
}
// Handle login form submission (not used in main app, only in login.html)
async function handleLogin(event) {
event.preventDefault();
const username = document.getElementById('login-username').value;
const password = document.getElementById('login-password').value;
const errorDiv = document.getElementById('login-error');
const errorText = document.getElementById('login-error-text');
const submitBtn = document.getElementById('login-submit');
const submitText = document.getElementById('login-submit-text');
const submitLoading = document.getElementById('login-submit-loading');
// Hide error
if (errorDiv) errorDiv.classList.add('hidden');
// Show loading
if (submitText) submitText.classList.add('hidden');
if (submitLoading) submitLoading.classList.remove('hidden');
if (submitBtn) submitBtn.disabled = true;
try {
// Save credentials
saveAuthCredentials(username, password);
// Test authentication with a simple API call
const response = await authenticatedFetch('/api/info');
if (response.ok) {
// Success - redirect to main app
window.location.href = '/';
} else {
throw new Error('Authentication failed');
}
} catch (error) {
// Show error
if (errorDiv) {
errorDiv.classList.remove('hidden');
if (errorText) {
errorText.textContent = error.message || 'Invalid username or password';
}
}
// Clear password field
const passwordField = document.getElementById('login-password');
if (passwordField) passwordField.value = '';
// Clear credentials
clearAuthCredentials();
} finally {
// Hide loading
if (submitText) submitText.classList.remove('hidden');
if (submitLoading) submitLoading.classList.add('hidden');
if (submitBtn) submitBtn.disabled = false;
}
}
// Handle logout
function handleLogout() {
clearAuthCredentials();
// Redirect to login page
window.location.href = '/login';
}
// Check authentication on page load
async function checkAuthentication() {
// First check if authentication is enabled
try {
const infoResponse = await fetch('/api/info');
if (infoResponse.ok) {
const infoData = await infoResponse.json();
// If authentication is disabled, allow access
if (!infoData.auth_enabled) {
return true;
}
}
} catch (e) {
// If we can't check, assume auth is enabled for safety
console.warn('Could not check auth status, assuming enabled');
}
// Authentication is enabled, check credentials
loadAuthCredentials();
if (!authCredentials) {
// No credentials saved, redirect to login
window.location.href = '/login';
return false;
}
try {
// Test if credentials are still valid
const response = await authenticatedFetch('/api/info');
if (response.ok) {
// Show logout button if auth is enabled
const logoutBtn = document.getElementById('logout-btn');
if (logoutBtn) logoutBtn.classList.remove('hidden');
return true;
} else {
// Invalid credentials, redirect to login
window.location.href = '/login';
return false;
}
} catch (error) {
// Authentication error, redirect to login
window.location.href = '/login';
return false;
}
}
// =============================================================================
// EXISTING CODE CONTINUES...
// =============================================================================
// Global state
let currentTab = 'dashboard';
let appTimezone = 'UTC'; // Default timezone, will be updated from API
let currentPage = {
postfix: 1,
rspamd: 1,
netfilter: 1,
messages: 1
};
let currentFilters = {
postfix: {},
rspamd: {},
netfilter: {},
queue: {},
messages: {}
};
// Modal state
let currentModalTab = 'overview';
let currentModalData = null;
// Auto-refresh configuration
const AUTO_REFRESH_INTERVAL = 30000; // 30 seconds
let autoRefreshTimer = null;
// Initialize on page load
document.addEventListener('DOMContentLoaded', async () => {
console.log('=== Mailcow Logs Viewer Initializing ===');
// Check authentication first
const isAuthenticated = await checkAuthentication();
if (!isAuthenticated) {
console.log('Authentication required - showing login modal');
return;
}
// Check if all required elements exist
const requiredElements = [
'app-title',
'content-dashboard',
'content-messages',
'content-netfilter',
'content-queue',
'content-quarantine',
'content-status',
'content-settings',
'content-domains'
];
const missing = requiredElements.filter(id => !document.getElementById(id));
if (missing.length > 0) {
console.error('Missing required elements:', missing);
} else {
console.log('[OK] All required DOM elements found');
}
loadAppInfo();
// Initialize router and get initial route from URL
const routeInfo = typeof initRouter === 'function' ? initRouter() : { baseRoute: 'dashboard', params: {} };
const initialTab = routeInfo.baseRoute || routeInfo;
const initialParams = routeInfo.params || {};
console.log('Initial tab from URL:', initialTab, 'params:', initialParams);
// Load the initial tab (use switchTab to ensure proper initialization)
switchTab(initialTab, initialParams);
// Start auto-refresh for all tabs
startAutoRefresh();
console.log('=== Initialization Complete ===');
});
// =============================================================================
// APP INFO & VERSION
// =============================================================================
async function loadAppInfo() {
try {
// Use regular fetch since this is called after authentication check
const response = await authenticatedFetch('/api/info');
const data = await response.json();
if (data.app_title) {
document.getElementById('app-title').textContent = data.app_title;
document.title = data.app_title;
// Update footer app name
const footerName = document.getElementById('app-name-footer');
if (footerName) {
footerName.textContent = data.app_title;
}
}
if (data.app_logo_url) {
const logoImg = document.getElementById('app-logo');
logoImg.src = data.app_logo_url;
logoImg.classList.remove('hidden');
document.getElementById('default-logo').classList.add('hidden');
}
// Update footer version
const footerVersion = document.getElementById('app-version-footer');
if (footerVersion && data.version) {
footerVersion.textContent = `v${data.version}`;
}
// Show/hide logout button based on auth status
const logoutBtn = document.getElementById('logout-btn');
if (logoutBtn) {
if (data.auth_enabled) {
logoutBtn.classList.remove('hidden');
} else {
logoutBtn.classList.add('hidden');
}
}
// Store timezone for date formatting
if (data.timezone) {
appTimezone = data.timezone;
console.log('Timezone loaded from API:', appTimezone);
} else {
console.warn('No timezone in API response, using default:', appTimezone);
}
// Load app version status for update check
await loadAppVersionStatus();
// Load mailcow connection status
await loadMailcowConnectionStatus();
} catch (error) {
console.error('Failed to load app info:', error);
}
}
async function loadMailcowConnectionStatus() {
try {
const response = await authenticatedFetch('/api/status/mailcow-connection');
if (!response.ok) return;
const data = await response.json();
const indicator = document.getElementById('mailcow-connection-indicator');
if (indicator) {
indicator.classList.remove('hidden');
if (data.connected) {
indicator.classList.remove('text-red-500');
indicator.classList.add('text-green-500');
indicator.title = 'Connected to Mailcow';
// Update SVG to checkmark
const svg = indicator.querySelector('svg');
if (svg) {
svg.innerHTML = '<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>';
}
} else {
indicator.classList.remove('text-green-500');
indicator.classList.add('text-red-500');
indicator.title = 'Not connected to Mailcow';
// Update SVG to X
const svg = indicator.querySelector('svg');
if (svg) {
svg.innerHTML = '<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path>';
}
}
}
} catch (error) {
console.error('Failed to load mailcow connection status:', error);
const indicator = document.getElementById('mailcow-connection-indicator');
if (indicator) {
indicator.classList.remove('hidden');
indicator.classList.remove('text-green-500');
indicator.classList.add('text-gray-400');
indicator.title = 'Connection status unknown';
}
}
}
async function loadAppVersionStatus() {
try {
const response = await authenticatedFetch('/api/status/app-version');
if (!response.ok) return;
const data = await response.json();
const updateBadge = document.getElementById('update-badge');
if (updateBadge && data.update_available) {
updateBadge.classList.remove('hidden');
updateBadge.title = `Update available: v${data.latest_version}`;
} else if (updateBadge) {
updateBadge.classList.add('hidden');
}
} catch (error) {
console.error('Failed to load app version status:', error);
}
}
// =============================================================================
// AUTO-REFRESH SYSTEM - Smart refresh (only updates when data changes)
// =============================================================================
// Cache for last fetched data (to compare and detect changes)
let lastDataCache = {
messages: null,
netfilter: null,
queue: null,
quarantine: null,
dashboard: null,
settings: null
};
// Cache for version info (separate from settings cache, doesn't update on smart refresh)
let versionInfoCache = {
app_version: null,
version_info: null
};
function startAutoRefresh() {
// Clear existing timer if any
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer);
}
// Set up auto-refresh interval
autoRefreshTimer = setInterval(() => {
smartRefreshCurrentTab();
}, AUTO_REFRESH_INTERVAL);
console.log(`[OK] Auto-refresh started (every ${AUTO_REFRESH_INTERVAL / 1000}s)`);
}
function stopAutoRefresh() {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
console.log('[STOP] Auto-refresh stopped');
}
}
// Smart refresh - fetches data silently and only updates if changed
async function smartRefreshCurrentTab() {
// Don't refresh if modal is open
const modal = document.getElementById('message-modal');
if (modal && !modal.classList.contains('hidden')) {
return;
}
try {
switch (currentTab) {
case 'dashboard':
await smartRefreshDashboard();
break;
case 'messages':
await smartRefreshMessages();
break;
case 'netfilter':
await smartRefreshNetfilter();
break;
case 'queue':
await smartRefreshQueue();
break;
case 'quarantine':
await smartRefreshQuarantine();
break;
case 'status':
await loadStatus(); // Status is fast, just reload
break;
case 'settings':
await smartRefreshSettings();
break;
}
} catch (error) {
console.error('Auto-refresh error:', error);
}
}
// Helper to check if data changed
function hasDataChanged(newData, cacheKey) {
const oldData = lastDataCache[cacheKey];
if (!oldData) return true;
// Compare JSON strings for simple change detection
const newJson = JSON.stringify(newData);
const oldJson = JSON.stringify(oldData);
return newJson !== oldJson;
}
// Smart refresh for Messages - only update if new messages arrived
// Only refreshes if there are no active filters/search (to avoid disrupting user's view)
async function smartRefreshMessages() {
const filters = currentFilters.messages || {};
// Don't refresh if user has active search or filters
const hasActiveFilters = filters.search || filters.sender || filters.recipient ||
filters.direction || filters.status || filters.user || filters.ip;
// Don't refresh if user is not on first page
if (hasActiveFilters || currentPage.messages > 1) {
return; // Skip refresh to avoid disrupting user's view
}
const params = new URLSearchParams({
page: currentPage.messages,
limit: 50
});
if (filters.search) params.append('search', filters.search);
if (filters.sender) params.append('sender', filters.sender);
if (filters.recipient) params.append('recipient', filters.recipient);
if (filters.direction) params.append('direction', filters.direction);
if (filters.status) params.append('status', filters.status);
if (filters.user) params.append('user', filters.user);
if (filters.ip) params.append('ip', filters.ip);
const response = await authenticatedFetch(`/api/messages?${params}`);
if (!response.ok) return;
const data = await response.json();
if (hasDataChanged(data, 'messages')) {
console.log('[REFRESH] Messages data changed, updating UI');
lastDataCache.messages = data;
renderMessagesData(data);
}
}
// Render messages without loading spinner
function renderMessagesData(data) {
const container = document.getElementById('messages-logs');
if (!container) return;
if (!data.data || data.data.length === 0) {
container.innerHTML = '<p class="text-gray-500 dark:text-gray-400 text-center py-8">No messages found</p>';
return;
}
container.innerHTML = `
<div class="space-y-3">
${data.data.map(msg => `
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition cursor-pointer" onclick="viewMessageDetails('${msg.correlation_key}')">
<div class="grid grid-cols-1 sm:grid-cols-[1fr_auto] gap-2 mb-2 items-start">
<div class="min-w-0 overflow-hidden">
<div class="flex flex-wrap items-center gap-2 mb-1">
<span class="text-sm font-medium text-gray-900 dark:text-white">${escapeHtml(msg.sender || 'Unknown')}</span>
<svg class="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
<span class="text-sm text-gray-600 dark:text-gray-300">${escapeHtml(msg.recipient || 'Unknown')}</span>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 truncate" title="${escapeHtml(msg.subject || 'No subject')}">${escapeHtml(msg.subject || 'No subject')}</p>
</div>
<div class="flex flex-wrap items-center gap-2 flex-shrink-0 sm:justify-end">
${(() => {
const correlationStatus = getCorrelationStatusDisplay(msg);
if (correlationStatus) {
return `<span class="inline-block px-2 py-0.5 text-xs font-medium rounded ${correlationStatus.class}" title="${msg.final_status || (msg.is_complete ? 'Correlation complete' : 'Waiting for Postfix logs')}">${correlationStatus.display}</span>`;
}
return '';
})()}
${msg.direction ? `<span class="inline-block px-2 py-0.5 text-xs font-medium rounded ${getDirectionClass(msg.direction)}">${msg.direction}</span>` : ''}
${msg.is_spam !== null ? `<span class="inline-block px-2 py-0.5 text-xs font-medium rounded ${msg.is_spam ? 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300' : 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300'}">${msg.is_spam ? 'SPAM' : 'CLEAN'}</span>` : ''}
</div>
</div>
<div class="flex flex-wrap items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
<span>${formatTime(msg.last_seen)}</span>
${msg.queue_id ? `<span class="font-mono" title="Queue ID">Q: ${msg.queue_id}</span>` : ''}
${msg.message_id ? `<span class="font-mono truncate max-w-xs" title="Message ID: ${escapeHtml(msg.message_id)}">MID: ${escapeHtml(msg.message_id.substring(0, 20))}${msg.message_id.length > 20 ? '...' : ''}</span>` : ''}
${msg.spam_score !== null ? `<span>Score: <span class="${msg.spam_score >= 15 ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-gray-600 dark:text-gray-300'}">${msg.spam_score.toFixed(1)}</span></span>` : ''}
${msg.user ? `<span>User: ${escapeHtml(msg.user)}</span>` : ''}
${msg.ip ? `<span>IP: ${msg.ip}</span>` : ''}
</div>
</div>
`).join('')}
</div>
${renderPagination('messages', data.page, data.pages)}
`;
}
// Deduplicate netfilter logs based on message + time + priority
function deduplicateNetfilterLogs(logs) {
if (!logs || logs.length === 0) return [];
const seen = new Set();
const uniqueLogs = [];
for (const log of logs) {
// Create unique key from message + time + priority
const key = `${log.message || ''}|${log.time || ''}|${log.priority || ''}`;
if (!seen.has(key)) {
seen.add(key);
uniqueLogs.push(log);
}
}
return uniqueLogs;
}
// Render netfilter without loading spinner (for smart refresh)
function renderNetfilterData(data) {
const container = document.getElementById('netfilter-logs');
if (!container) return;
if (!data.data || data.data.length === 0) {
container.innerHTML = '<p class="text-gray-500 dark:text-gray-400 text-center py-8">No logs found</p>';
return;
}
// Deduplicate logs
const uniqueLogs = deduplicateNetfilterLogs(data.data);
// Update count display with total count from API (like Messages page)
const countEl = document.getElementById('security-count');
if (countEl) {
countEl.textContent = data.total ? `(${data.total.toLocaleString()} results)` : '';
}
container.innerHTML = `
<div class="space-y-3">
${uniqueLogs.map(log => `
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-2">
<div class="flex flex-wrap items-center gap-2">
<span class="font-mono text-sm font-semibold text-gray-900 dark:text-white">${log.ip || '-'}</span>
${log.username && log.username !== '-' ? `<span class="text-sm text-blue-600 dark:text-blue-400">${escapeHtml(log.username)}</span>` : ''}
<span class="inline-block px-2 py-0.5 text-xs font-medium rounded ${getActionClass(log.action)}">${getActionLabel(log.action)}</span>
${log.attempts_left !== null && log.attempts_left !== undefined ? `<span class="text-xs text-gray-500 dark:text-gray-400">${log.attempts_left} attempts left</span>` : ''}
</div>
<span class="text-xs text-gray-500 dark:text-gray-400">${formatTime(log.time)}</span>
</div>
<p class="text-sm text-gray-700 dark:text-gray-300 break-words">${escapeHtml(log.message || '-')}</p>
</div>
`).join('')}
</div>
${renderPagination('netfilter', data.page, data.pages)}
`;
}
// Smart refresh for Netfilter
async function smartRefreshNetfilter() {
const filters = currentFilters.netfilter || {};
const params = new URLSearchParams({
page: currentPage.netfilter || 1,
limit: 50,
...filters
});
const response = await authenticatedFetch(`/api/logs/netfilter?${params}`);
if (!response.ok) return;
const data = await response.json();
if (hasDataChanged(data, 'netfilter')) {
console.log('[REFRESH] Netfilter data changed, updating UI');
lastDataCache.netfilter = data;
// Use renderNetfilterData to update content without loading spinner (like Messages page)
renderNetfilterData(data);
}
}
// Smart refresh for Queue
async function smartRefreshQueue() {
const response = await authenticatedFetch('/api/queue');
if (!response.ok) return;
const data = await response.json();
if (hasDataChanged(data, 'queue')) {
console.log('[REFRESH] Queue data changed, updating UI');
lastDataCache.queue = data;
allQueueData = data.data || [];
applyQueueFilters();
}
}
// Smart refresh for Quarantine
async function smartRefreshQuarantine() {
const response = await authenticatedFetch('/api/quarantine');
if (!response.ok) return;
const data = await response.json();
if (hasDataChanged(data, 'quarantine')) {
console.log('[REFRESH] Quarantine data changed, updating UI');
lastDataCache.quarantine = data;
renderQuarantineData(data);
}
}
// Render quarantine without loading spinner
function renderQuarantineData(data) {
const container = document.getElementById('quarantine-logs');
if (!container) return;
if (!data.data || data.data.length === 0) {
container.innerHTML = '<p class="text-gray-500 dark:text-gray-400 text-center py-8">No quarantined messages</p>';
return;
}
container.innerHTML = `
<div class="space-y-4">
${data.data.map(item => `
<div class="border border-red-200 dark:border-red-900/50 rounded-lg p-4 bg-red-50 dark:bg-red-900/20">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-start mb-2 gap-2">
<div class="flex-1">
<p class="text-sm font-medium text-gray-900 dark:text-white">${escapeHtml(item.subject || 'No subject')}</p>
<p class="text-sm text-gray-600 dark:text-gray-300">From: ${escapeHtml(item.sender)}</p>
</div>
<span class="text-xs text-gray-500 dark:text-gray-400">${formatTime(item.created)}</span>
</div>
<p class="text-xs text-red-600 dark:text-red-400 mt-2">${item.reason || 'Quarantined'}</p>
</div>
`).join('')}
</div>
`;
}
// Smart refresh for Dashboard
async function smartRefreshDashboard() {
try {
const response = await authenticatedFetch('/api/stats/dashboard');
if (!response.ok) return;
const data = await response.json();
if (hasDataChanged(data, 'dashboard')) {
console.log('[REFRESH] Dashboard data changed, updating UI');
lastDataCache.dashboard = data;
// Update stats without full reload
document.getElementById('stat-messages-24h').textContent = data.messages['24h'].toLocaleString();
document.getElementById('stat-messages-7d').textContent = data.messages['7d'].toLocaleString();
document.getElementById('stat-blocked-24h').textContent = data.blocked['24h'].toLocaleString();
document.getElementById('stat-blocked-7d').textContent = data.blocked['7d'].toLocaleString();
document.getElementById('stat-blocked-percentage').textContent = data.blocked.percentage_24h;
document.getElementById('stat-deferred-24h').textContent = data.deferred['24h'].toLocaleString();
document.getElementById('stat-deferred-7d').textContent = data.deferred['7d'].toLocaleString();
document.getElementById('stat-auth-failures-24h').textContent = data.auth_failures['24h'].toLocaleString();
document.getElementById('stat-auth-failures-7d').textContent = data.auth_failures['7d'].toLocaleString();
}
// Also refresh recent activity and status summary
loadRecentActivity();
loadDashboardStatusSummary();
} catch (error) {
console.error('Dashboard refresh error:', error);
}
}
// Smart refresh for Settings
async function smartRefreshSettings() {
try {
const response = await authenticatedFetch('/api/settings/info');
if (!response.ok) return;
const data = await response.json();
if (hasDataChanged(data, 'settings')) {
console.log('[REFRESH] Settings data changed, updating UI');
lastDataCache.settings = data;
const content = document.getElementById('settings-content');
if (content && !content.classList.contains('hidden')) {
// Preserve version info from cache (don't reload it on smart refresh)
if (versionInfoCache.app_version) {
data.app_version = versionInfoCache.app_version;
}
if (versionInfoCache.version_info) {
data.version_info = versionInfoCache.version_info;
}
renderSettings(content, data);
}
}
} catch (error) {
console.error('Settings refresh error:', error);
}
}
// =============================================================================
// TAB SWITCHING
// =============================================================================
function switchTab(tab, params = {}) {
console.log('Switching to tab:', tab, 'params:', params);
currentTab = tab;
// Update active tab button (desktop)
document.querySelectorAll('[id^="tab-"]').forEach(btn => {
btn.classList.remove('tab-active');
btn.classList.add('text-gray-500', 'dark:text-gray-400');
});
const activeBtn = document.getElementById(`tab-${tab}`);
if (activeBtn) {
activeBtn.classList.add('tab-active');
activeBtn.classList.remove('text-gray-500', 'dark:text-gray-400');
}
// Update mobile menu state and label
if (typeof updateMobileMenuActiveState === 'function') {
updateMobileMenuActiveState(tab);
}
if (typeof updateCurrentTabLabel === 'function') {
updateCurrentTabLabel(tab);
}
// Hide all tab contents
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.add('hidden');
});
// Show current tab content
const tabContent = document.getElementById(`content-${tab}`);
if (tabContent) {
tabContent.classList.remove('hidden');
} else {
console.error(`Tab content not found: content-${tab}`);
}
// Load tab data
console.log('Loading data for tab:', tab);
switch (tab) {
case 'dashboard':
loadDashboard();
break;
case 'messages':
loadMessages(1);
break;
case 'netfilter':
loadNetfilterLogs(1);
break;
case 'queue':
loadQueue();
break;
case 'quarantine':
loadQuarantine();
break;
case 'status':
loadStatus();
break;
case 'domains':
loadDomains();
break;
case 'dmarc':
handleDmarcRoute(params);
break;
case 'mailbox-stats':
loadMailboxStats();
break;
case 'settings':
loadSettings();
break;
default:
console.warn('Unknown tab:', tab);
}
}
async function refreshAllData() {
if (currentTab === 'dmarc') {
try {
await authenticatedFetch('/api/dmarc/cache/clear', { method: 'POST' });
console.log('DMARC cache cleared');
} catch (e) {
console.error('Failed to clear DMARC cache:', e);
}
}
switchTab(currentTab);
}
// =============================================================================
// DASHBOARD
// =============================================================================
async function loadDashboard() {
try {
console.log('Loading Dashboard...');
const response = await authenticatedFetch('/api/stats/dashboard');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.log('Dashboard data:', data);
document.getElementById('stat-messages-24h').textContent = data.messages['24h'].toLocaleString();
document.getElementById('stat-messages-7d').textContent = data.messages['7d'].toLocaleString();
document.getElementById('stat-blocked-24h').textContent = data.blocked['24h'].toLocaleString();
document.getElementById('stat-blocked-7d').textContent = data.blocked['7d'].toLocaleString();
document.getElementById('stat-blocked-percentage').textContent = data.blocked.percentage_24h;
document.getElementById('stat-deferred-24h').textContent = data.deferred['24h'].toLocaleString();
document.getElementById('stat-deferred-7d').textContent = data.deferred['7d'].toLocaleString();
document.getElementById('stat-auth-failures-24h').textContent = data.auth_failures['24h'].toLocaleString();
document.getElementById('stat-auth-failures-7d').textContent = data.auth_failures['7d'].toLocaleString();
loadRecentActivity();
loadDashboardStatusSummary();
} catch (error) {
console.error('Failed to load dashboard:', error);
}
}
async function loadDashboardStatusSummary() {
try {
console.log('Loading Dashboard Status Summary...');
const response = await authenticatedFetch('/api/status/summary');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.log('Status summary data:', data);
const containersDiv = document.getElementById('dashboard-containers-summary');
const containers = data.containers || {};
containersDiv.innerHTML = `
<div class="flex justify-between items-center">
<span class="text-sm text-gray-600 dark:text-gray-400">Running</span>
<span class="text-lg font-semibold text-green-600 dark:text-green-400">${containers.running || 0}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-gray-600 dark:text-gray-400">Stopped</span>
<span class="text-lg font-semibold ${containers.stopped > 0 ? 'text-red-600 dark:text-red-400' : 'text-gray-600 dark:text-gray-400'}">${containers.stopped || 0}</span>
</div>
<div class="flex justify-between items-center pt-2 border-t border-gray-200 dark:border-gray-700">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Total</span>
<span class="text-lg font-semibold text-gray-900 dark:text-white">${containers.total || 0}</span>
</div>
`;
const storageDiv = document.getElementById('dashboard-storage-summary');
const storage = data.storage || {};
const usedPercent = parseInt(storage.used_percent) || 0;
const storageColor = usedPercent > 90 ? 'text-red-600 dark:text-red-400' :
usedPercent > 75 ? 'text-yellow-600 dark:text-yellow-400' :
'text-green-600 dark:text-green-400';
storageDiv.innerHTML = `
<div class="flex justify-between items-center">
<span class="text-sm text-gray-600 dark:text-gray-400">Used</span>
<span class="text-lg font-semibold ${storageColor}">${storage.used_percent || '0%'}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-gray-600 dark:text-gray-400">Available</span>
<span class="text-sm text-gray-900 dark:text-white">${storage.used || '0'} / ${storage.total || '0'}</span>
</div>
<div class="mt-2">
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div class="h-2 rounded-full ${usedPercent > 90 ? 'bg-red-600' : usedPercent > 75 ? 'bg-yellow-600' : 'bg-green-600'}" style="width: ${usedPercent}%"></div>
</div>
</div>
`;
const systemDiv = document.getElementById('dashboard-system-summary');
const system = data.system || {};
systemDiv.innerHTML = `
<div class="flex justify-between items-center">
<span class="text-sm text-gray-600 dark:text-gray-400">Domains</span>
<span class="text-lg font-semibold text-gray-900 dark:text-white">${system.domains || 0}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-gray-600 dark:text-gray-400">Mailboxes</span>
<span class="text-lg font-semibold text-gray-900 dark:text-white">${system.mailboxes || 0}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-gray-600 dark:text-gray-400">Aliases</span>
<span class="text-lg font-semibold text-gray-900 dark:text-white">${system.aliases || 0}</span>
</div>
`;
} catch (error) {
console.error('Failed to load status summary:', error);
}
}
async function loadRecentActivity() {
const container = document.getElementById('recent-activity');
try {
console.log('Loading Recent Activity...');
const response = await authenticatedFetch('/api/stats/recent-activity?limit=10');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.log('Recent Activity data:', data);
if (data.activity.length === 0) {
container.innerHTML = '<p class="text-gray-500 dark:text-gray-400 text-center py-8">No recent activity</p>';
return;
}
container.innerHTML = data.activity.map(msg => `
<div class="grid grid-cols-1 sm:grid-cols-[1fr_auto] gap-2 p-3 sm:p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition cursor-pointer items-start" onclick="viewMessageDetails('${msg.correlation_key}')">
<div class="min-w-0 overflow-hidden">
<div class="flex flex-wrap items-center gap-2 mb-1">
<span class="text-sm font-medium text-gray-900 dark:text-white">${escapeHtml(msg.sender || 'Unknown')}</span>
<svg class="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
<span class="text-sm text-gray-600 dark:text-gray-300">${escapeHtml(msg.recipient || 'Unknown')}</span>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 truncate" title="${escapeHtml(msg.subject || 'No subject')}">${escapeHtml(msg.subject || 'No subject')}</p>
</div>
<div class="flex flex-col items-end gap-1 flex-shrink-0">
<div class="flex items-center gap-2">
<span class="inline-block px-2 py-1 text-xs font-medium rounded ${getStatusClass(msg.status)}">${msg.status || 'unknown'}</span>
${msg.direction ? `<span class="inline-block px-2 py-0.5 text-xs font-medium rounded ${getDirectionClass(msg.direction)}">${msg.direction}</span>` : ''}
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">${formatTime(msg.time)}</p>
</div>
</div>
`).join('');
} catch (error) {
console.error('Failed to load recent activity:', error);
document.getElementById('recent-activity').innerHTML = `<p class="text-red-500 text-center py-8">Failed to load activity: ${error.message}</p>`;
}
}
function performDashboardSearch() {
const query = document.getElementById('dashboard-search-query').value;
const status = document.getElementById('dashboard-search-status').value;
// Set filters on Messages page
document.getElementById('messages-filter-search').value = query;
document.getElementById('messages-filter-sender').value = '';
document.getElementById('messages-filter-recipient').value = '';
document.getElementById('messages-filter-direction').value = '';
document.getElementById('messages-filter-status').value = status;
document.getElementById('messages-filter-user').value = '';
// Apply filters
currentFilters.messages = {
search: query,
status: status
};
currentPage.messages = 1;
// Switch to Messages tab and load
switchTab('messages');
}
// =============================================================================
// POSTFIX LOGS
// =============================================================================
function applyPostfixFilters() {
currentFilters.postfix = {
search: document.getElementById('postfix-filter-search').value,
sender: document.getElementById('postfix-filter-sender').value,
recipient: document.getElementById('postfix-filter-recipient').value
};
currentPage.postfix = 1;
loadPostfixLogs();
}
function clearPostfixFilters() {
document.getElementById('postfix-filter-search').value = '';
document.getElementById('postfix-filter-sender').value = '';
document.getElementById('postfix-filter-recipient').value = '';
currentFilters.postfix = {};
currentPage.postfix = 1;
loadPostfixLogs();
}
async function loadPostfixLogs(page = 1) {
const container = document.getElementById('postfix-logs');
// Show loading immediately
container.innerHTML = '<div class="text-center py-8"><div class="loading mx-auto mb-4"></div><p class="text-gray-500 dark:text-gray-400">Loading Postfix logs... This may take a few moments.</p></div>';
try {
const filters = currentFilters.postfix || {};
const params = new URLSearchParams({
page: page,
limit: 50
});
if (filters.search) params.append('search', filters.search);
if (filters.sender) params.append('sender', filters.sender);
if (filters.recipient) params.append('recipient', filters.recipient);
console.log('Loading Postfix logs:', `/api/logs/postfix?${params}`);
const startTime = performance.now();
const response = await authenticatedFetch(`/api/logs/postfix?${params}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
const loadTime = ((performance.now() - startTime) / 1000).toFixed(2);
console.log(`Postfix data loaded in ${loadTime}s:`, data);
if (!data.data || data.data.length === 0) {
container.innerHTML = '<p class="text-gray-500 dark:text-gray-400 text-center py-8">No logs found</p>';
return;
}
container.innerHTML = `
<div class="mobile-scroll overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-3 sm:px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Time</th>
<th class="px-3 sm:px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Queue ID</th>
<th class="px-3 sm:px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">From</th>
<th class="px-3 sm:px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">To</th>
<th class="px-3 sm:px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Status</th>
<th class="px-3 sm:px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider hide-mobile">Relay</th>
<th class="px-3 sm:px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider hide-mobile">Delay</th>
<th class="px-3 sm:px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider hide-mobile">DSN</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
${data.data.map(log => `
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer" onclick="${log.queue_id ? `viewPostfixDetails('${log.queue_id}')` : ''}">
<td class="px-3 sm:px-4 py-3 text-xs sm:text-sm text-gray-900 dark:text-gray-100 whitespace-nowrap">${formatTime(log.time)}</td>
<td class="px-3 sm:px-4 py-3 text-xs sm:text-sm font-mono text-gray-600 dark:text-gray-300">${log.queue_id || '-'}</td>
<td class="px-3 sm:px-4 py-3 text-xs sm:text-sm text-gray-900 dark:text-gray-100 max-w-xs truncate">${escapeHtml(log.sender || '-')}</td>
<td class="px-3 sm:px-4 py-3 text-xs sm:text-sm text-gray-900 dark:text-gray-100 max-w-xs truncate">${escapeHtml(log.recipient || '-')}</td>
<td class="px-3 sm:px-4 py-3 text-xs sm:text-sm">
<span class="inline-block px-2 py-1 text-xs font-medium rounded ${getStatusClass(log.status)}">${log.status || 'unknown'}</span>
</td>
<td class="px-3 sm:px-4 py-3 text-xs sm:text-sm text-gray-600 dark:text-gray-300 hide-mobile">${escapeHtml(log.relay || '-')}</td>
<td class="px-3 sm:px-4 py-3 text-xs sm:text-sm text-gray-600 dark:text-gray-300 hide-mobile">${log.delay ? log.delay.toFixed(2) + 's' : '-'}</td>
<td class="px-3 sm:px-4 py-3 text-xs sm:text-sm text-gray-600 dark:text-gray-300 hide-mobile">${log.dsn || '-'}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
${renderPagination('postfix', data.page, data.pages)}
`;
currentPage.postfix = page;
} catch (error) {
console.error('Failed to load Postfix logs:', error);
document.getElementById('postfix-logs').innerHTML = `<p class="text-red-500 text-center py-8">Failed to load logs: ${error.message}</p>`;
}
}
// =============================================================================
// RSPAMD LOGS
// =============================================================================
function applyRspamdFilters() {
currentFilters.rspamd = {
search: document.getElementById('rspamd-filter-search').value,
direction: document.getElementById('rspamd-filter-direction').value,
is_spam: document.getElementById('rspamd-filter-spam').value,
min_score: document.getElementById('rspamd-filter-score').value
};
currentPage.rspamd = 1;
loadRspamdLogs();
}
function clearRspamdFilters() {
document.getElementById('rspamd-filter-search').value = '';
document.getElementById('rspamd-filter-direction').value = '';
document.getElementById('rspamd-filter-spam').value = '';
document.getElementById('rspamd-filter-score').value = '';
currentFilters.rspamd = {};
currentPage.rspamd = 1;
loadRspamdLogs();
}
async function loadRspamdLogs(page = 1) {
const container = document.getElementById('rspamd-logs');
try {
container.innerHTML = '<div class="text-center py-8"><div class="loading mx-auto mb-4"></div><p class="text-gray-500 dark:text-gray-400">Loading...</p></div>';
const filters = currentFilters.rspamd || {};
const params = new URLSearchParams({
page: page,
limit: 50
});
if (filters.search) params.append('search', filters.search);
if (filters.direction) params.append('direction', filters.direction);
if (filters.is_spam === 'true') params.append('is_spam', 'true');
if (filters.is_spam === 'false') params.append('is_spam', 'false');
if (filters.min_score) params.append('min_score', filters.min_score);
console.log('Loading Rspamd logs:', `/api/logs/rspamd?${params}`);
const response = await authenticatedFetch(`/api/logs/rspamd?${params}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.log('Rspamd data:', data);
if (!data.data || data.data.length === 0) {
container.innerHTML = '<p class="text-gray-500 dark:text-gray-400 text-center py-8">No logs found</p>';
return;
}
container.innerHTML = `
<div class="mobile-scroll overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-3 sm:px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Time</th>
<th class="px-3 sm:px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">From</th>
<th class="px-3 sm:px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Subject</th>
<th class="px-3 sm:px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Direction</th>
<th class="px-3 sm:px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Score</th>
<th class="px-3 sm:px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Action</th>
<th class="px-3 sm:px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider hide-mobile">Symbols</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
${data.data.map(log => `
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer" onclick="${log.correlation_key ? `viewMessageDetails('${log.correlation_key}')` : ''}">
<td class="px-3 sm:px-4 py-3 text-xs sm:text-sm text-gray-900 dark:text-gray-100 whitespace-nowrap">${formatTime(log.time)}</td>
<td class="px-3 sm:px-4 py-3 text-xs sm:text-sm text-gray-900 dark:text-gray-100 max-w-xs truncate">${escapeHtml(log.sender_smtp || '-')}</td>
<td class="px-3 sm:px-4 py-3 text-xs sm:text-sm text-gray-900 dark:text-gray-100 max-w-xs truncate" title="${escapeHtml(log.subject || 'No subject')}">${escapeHtml(log.subject || 'No subject')}</td>
<td class="px-3 sm:px-4 py-3 text-xs sm:text-sm">
<span class="inline-block px-2 py-1 text-xs font-medium rounded ${getDirectionClass(log.direction)}">${log.direction}</span>
</td>
<td class="px-3 sm:px-4 py-3 text-xs sm:text-sm">
<span class="${log.score >= log.required_score ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-gray-600 dark:text-gray-300'}">${log.score.toFixed(2)}</span>
<span class="text-gray-400 dark:text-gray-500">/${log.required_score}</span>
</td>
<td class="px-3 sm:px-4 py-3 text-xs sm:text-sm text-gray-600 dark:text-gray-300">${log.action}</td>
<td class="px-3 sm:px-4 py-3 text-xs text-gray-500 dark:text-gray-400 max-w-xs truncate hide-mobile">${log.symbols ? Object.keys(log.symbols).slice(0, 3).join(', ') : '-'}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
${renderPagination('rspamd', data.page, data.pages)}
`;
currentPage.rspamd = page;
} catch (error) {
console.error('Failed to load Rspamd logs:', error);
document.getElementById('rspamd-logs').innerHTML = `<p class="text-red-500 text-center py-8">Failed to load logs: ${error.message}</p>`;
}
}
// =============================================================================
// NETFILTER LOGS
// =============================================================================
function applyNetfilterFilters() {
currentFilters.netfilter = {
ip: document.getElementById('netfilter-filter-ip').value,
username: document.getElementById('netfilter-filter-username').value,
action: document.getElementById('netfilter-filter-action').value
};
currentPage.netfilter = 1;
loadNetfilterLogs();
}
function clearNetfilterFilters() {
document.getElementById('netfilter-filter-ip').value = '';
document.getElementById('netfilter-filter-username').value = '';
document.getElementById('netfilter-filter-action').value = '';
currentFilters.netfilter = {};
currentPage.netfilter = 1;
loadNetfilterLogs();
}
async function loadNetfilterLogs(page = 1) {
const container = document.getElementById('netfilter-logs');
try {
container.innerHTML = '<div class="text-center py-8"><div class="loading mx-auto mb-4"></div><p class="text-gray-500 dark:text-gray-400">Loading...</p></div>';
const filters = currentFilters.netfilter || {};
const params = new URLSearchParams({
page: page,
limit: 50,
...filters
});
console.log('Loading Netfilter logs:', `/api/logs/netfilter?${params}`);
const response = await authenticatedFetch(`/api/logs/netfilter?${params}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.log('Netfilter data:', data);
if (!data.data || data.data.length === 0) {
container.innerHTML = '<p class="text-gray-500 dark:text-gray-400 text-center py-8">No logs found</p>';
const countEl = document.getElementById('security-count');
if (countEl) countEl.textContent = '';
return;
}
// Deduplicate logs based on message + time + priority
const uniqueLogs = deduplicateNetfilterLogs(data.data);
// Update count display with total count from API (like Messages page)
const countEl = document.getElementById('security-count');
if (countEl) {
countEl.textContent = data.total ? `(${data.total.toLocaleString()} results)` : '';
}
container.innerHTML = `
<div class="space-y-3">
${uniqueLogs.map(log => `
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-2">
<div class="flex flex-wrap items-center gap-2">
<span class="font-mono text-sm font-semibold text-gray-900 dark:text-white">${log.ip || '-'}</span>
${log.username && log.username !== '-' ? `<span class="text-sm text-blue-600 dark:text-blue-400">${escapeHtml(log.username)}</span>` : ''}
<span class="inline-block px-2 py-0.5 text-xs font-medium rounded ${getActionClass(log.action)}">${getActionLabel(log.action)}</span>
${log.attempts_left !== null && log.attempts_left !== undefined ? `<span class="text-xs text-gray-500 dark:text-gray-400">${log.attempts_left} attempts left</span>` : ''}
</div>
<span class="text-xs text-gray-500 dark:text-gray-400">${formatTime(log.time)}</span>
</div>
<p class="text-sm text-gray-700 dark:text-gray-300 break-words">${escapeHtml(log.message || '-')}</p>
</div>
`).join('')}
</div>
${renderPagination('netfilter', data.page, data.pages)}
`;
currentPage.netfilter = page;
} catch (error) {
console.error('Failed to load Netfilter logs:', error);
document.getElementById('netfilter-logs').innerHTML = `<p class="text-red-500 text-center py-8">Failed to load logs: ${error.message}</p>`;
const countEl = document.getElementById('security-count');
if (countEl) countEl.textContent = '';
}
}
// =============================================================================
// Part 2: Queue, Quarantine, Messages, Status, Postfix Details
// =============================================================================
// =============================================================================
// QUEUE
// =============================================================================
let allQueueData = [];
async function loadQueue() {
const container = document.getElementById('queue-logs');
try {
container.innerHTML = '<div class="text-center py-8"><div class="loading mx-auto mb-4"></div><p class="text-gray-500 dark:text-gray-400">Loading...</p></div>';
console.log('Loading Queue...');
const response = await authenticatedFetch('/api/queue');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.log('Queue data:', data);
allQueueData = data.data || [];
applyQueueFilters();
} catch (error) {
console.error('Failed to load queue:', error);
document.getElementById('queue-logs').innerHTML = `<p class="text-red-500 text-center py-8">Failed to load queue: ${error.message}</p>`;
const countEl = document.getElementById('queue-count');
if (countEl) countEl.textContent = '';
}
}
function applyQueueFilters() {
const searchTerm = document.getElementById('queue-filter-search')?.value.toLowerCase() || '';
const queueId = document.getElementById('queue-filter-queue-id')?.value.toLowerCase() || '';
let filteredData = allQueueData;
if (searchTerm) {
filteredData = filteredData.filter(item =>
item.sender.toLowerCase().includes(searchTerm) ||
item.recipients.some(r => r.toLowerCase().includes(searchTerm))
);
}
if (queueId) {
filteredData = filteredData.filter(item =>
item.queue_id.toLowerCase().includes(queueId)
);
}
const container = document.getElementById('queue-logs');
// Update count display
const countEl = document.getElementById('queue-count');
if (countEl) {
countEl.textContent = `(${filteredData.length.toLocaleString()} items)`;
}
if (filteredData.length === 0) {
container.innerHTML = '<p class="text-gray-500 dark:text-gray-400 text-center py-8">No matching queue entries</p>';
return;
}
container.innerHTML = `
<div class="space-y-4">
${filteredData.map(item => `
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-gray-50 dark:bg-gray-700/50">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-start mb-2 gap-2">
<div class="flex-1">
<p class="text-sm font-medium text-gray-900 dark:text-white">From: ${escapeHtml(item.sender)}</p>
<p class="text-sm text-gray-600 dark:text-gray-300">Queue ID: ${item.queue_id}</p>
</div>
<span class="text-xs text-gray-500 dark:text-gray-400">${formatTime(new Date(item.arrival_time * 1000).toISOString())}</span>
</div>
<div class="mb-2">
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">Recipients:</p>
${item.recipients.map(r => `<p class="text-sm text-gray-600 dark:text-gray-400">${escapeHtml(r)}</p>`).join('')}
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">Size: ${formatSize(item.message_size)}</span>
</div>
</div>
`).join('')}
</div>
`;
}
function clearQueueFilters() {
document.getElementById('queue-filter-search').value = '';
document.getElementById('queue-filter-queue-id').value = '';
applyQueueFilters();
}
// =============================================================================
// QUARANTINE
// =============================================================================
async function loadQuarantine() {
const container = document.getElementById('quarantine-logs');
try {
container.innerHTML = '<div class="text-center py-8"><div class="loading mx-auto mb-4"></div><p class="text-gray-500 dark:text-gray-400">Loading...</p></div>';
console.log('Loading Quarantine...');
const response = await authenticatedFetch('/api/quarantine');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.log('Quarantine data:', data);
// ⭐ NEW: Update counter display
const countEl = document.getElementById('quarantine-count');
if (countEl) {
countEl.textContent = data.total ? `(${data.total.toLocaleString()} results)` : '';
}
if (!data.data || data.data.length === 0) {
container.innerHTML = '<p class="text-gray-500 dark:text-gray-400 text-center py-8">No quarantined messages</p>';
return;
}
// ⭐ NEW: Use separate render function
renderQuarantineData(data);
} catch (error) {
console.error('Failed to load quarantine:', error);
document.getElementById('quarantine-logs').innerHTML = `<p class="text-red-500 text-center py-8">Failed to load quarantine: ${error.message}</p>`;
// ⭐ NEW: Clear counter on error
const countEl = document.getElementById('quarantine-count');
if (countEl) countEl.textContent = '';
}
}
// Render quarantine without loading spinner (for smart refresh)
function renderQuarantineData(data) {
const container = document.getElementById('quarantine-logs');
if (!container) return;
// Update counter display
const countEl = document.getElementById('quarantine-count');
if (countEl) {
countEl.textContent = data.total ? `(${data.total.toLocaleString()} results)` : '';
}
if (!data.data || data.data.length === 0) {
container.innerHTML = '<p class="text-gray-500 dark:text-gray-400 text-center py-8">No quarantined messages</p>';
return;
}
container.innerHTML = `
<div class="space-y-3">
${data.data.map(item => `
<div class="border border-red-200 dark:border-red-900/50 rounded-lg p-4 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/30 transition">
<div class="grid grid-cols-1 sm:grid-cols-[1fr_auto] gap-2 mb-2 items-start">
<div class="min-w-0 overflow-hidden">
<div class="flex flex-wrap items-center gap-2 mb-1">
<span class="text-sm font-medium text-gray-900 dark:text-white">${escapeHtml(item.sender || 'Unknown')}</span>
<svg class="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
<span class="text-sm text-gray-600 dark:text-gray-300">${escapeHtml(item.rcpt || 'Unknown')}</span>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 truncate" title="${escapeHtml(item.subject || 'No subject')}">${escapeHtml(item.subject || 'No subject')}</p>
</div>
<div class="flex flex-wrap items-center gap-2 flex-shrink-0 sm:justify-end">
<span class="inline-block px-2 py-0.5 text-xs font-medium rounded bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300">${item.action || 'Quarantined'}</span>
${item.virus_flag ? '<span class="inline-block px-2 py-0.5 text-xs font-medium rounded bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300">🦠 VIRUS</span>' : ''}
</div>
</div>
<div class="flex flex-wrap items-center gap-4 text-xs text-gray-600 dark:text-gray-400">
<span>${formatTime(item.created)}</span>
${item.qid ? `<span class="font-mono" title="Queue ID">Q: ${item.qid}</span>` : ''}
${item.score !== undefined && item.score !== null ? `<span>Score: <span class="${item.score >= 15 ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-gray-600 dark:text-gray-300'}">${item.score.toFixed(1)}</span></span>` : ''}
</div>
</div>
`).join('')}
</div>
`;
}
// =============================================================================
// MESSAGES TAB (UNIFIED VIEW)
// =============================================================================
function applyMessagesFilters() {
currentFilters.messages = {
search: document.getElementById('messages-filter-search').value,
sender: document.getElementById('messages-filter-sender').value,
recipient: document.getElementById('messages-filter-recipient').value,
direction: document.getElementById('messages-filter-direction').value,
user: document.getElementById('messages-filter-user').value,
status: document.getElementById('messages-filter-status').value,
ip: document.getElementById('messages-filter-ip').value
};
currentPage.messages = 1;
loadMessages();
}
function clearMessagesFilters() {
document.getElementById('messages-filter-search').value = '';
document.getElementById('messages-filter-sender').value = '';
document.getElementById('messages-filter-recipient').value = '';
document.getElementById('messages-filter-direction').value = '';
document.getElementById('messages-filter-user').value = '';
document.getElementById('messages-filter-status').value = '';
document.getElementById('messages-filter-ip').value = '';
currentFilters.messages = {};
currentPage.messages = 1;
loadMessages();
}
async function loadMessages(page = 1) {
const container = document.getElementById('messages-logs');
try {
container.innerHTML = '<div class="text-center py-8"><div class="loading mx-auto mb-4"></div><p class="text-gray-500 dark:text-gray-400">Loading...</p></div>';
const filters = currentFilters.messages || {};
const params = new URLSearchParams({
page: page,
limit: 50
});
if (filters.search) params.append('search', filters.search);
if (filters.sender) params.append('sender', filters.sender);
if (filters.recipient) params.append('recipient', filters.recipient);
if (filters.direction) params.append('direction', filters.direction);
if (filters.user) params.append('user', filters.user);
if (filters.status) params.append('status', filters.status);
if (filters.ip) params.append('ip', filters.ip);
console.log('Loading Messages:', `/api/messages?${params}`);
const response = await authenticatedFetch(`/api/messages?${params}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.log('Messages data:', data);
// Update count display
const countEl = document.getElementById('messages-count');
if (countEl) {
countEl.textContent = data.total ? `(${data.total.toLocaleString()} results)` : '';
}
if (!data.data || data.data.length === 0) {
container.innerHTML = '<p class="text-gray-500 dark:text-gray-400 text-center py-8">No messages found</p>';
return;
}
container.innerHTML = `
<div class="space-y-3">
${data.data.map(msg => `
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition cursor-pointer" onclick="viewMessageDetails('${msg.correlation_key}')">
<div class="grid grid-cols-1 sm:grid-cols-[1fr_auto] gap-2 mb-2 items-start">
<div class="min-w-0 overflow-hidden">
<div class="flex flex-wrap items-center gap-2 mb-1">
<span class="text-sm font-medium text-gray-900 dark:text-white">${escapeHtml(msg.sender || 'Unknown')}</span>
<svg class="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
<span class="text-sm text-gray-600 dark:text-gray-300">${escapeHtml(msg.recipient || 'Unknown')}</span>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 truncate" title="${escapeHtml(msg.subject || 'No subject')}">${escapeHtml(msg.subject || 'No subject')}</p>
</div>
<div class="flex flex-wrap items-center gap-2 flex-shrink-0 sm:justify-end">
${(() => {
const correlationStatus = getCorrelationStatusDisplay(msg);
if (correlationStatus) {
return `<span class="inline-block px-2 py-0.5 text-xs font-medium rounded ${correlationStatus.class}" title="${msg.final_status || (msg.is_complete ? 'Correlation complete' : 'Waiting for Postfix logs')}">${correlationStatus.display}</span>`;
}
return '';
})()}
${msg.direction ? `<span class="inline-block px-2 py-0.5 text-xs font-medium rounded ${getDirectionClass(msg.direction)}">${msg.direction}</span>` : ''}
${msg.is_spam !== null ? `<span class="inline-block px-2 py-0.5 text-xs font-medium rounded ${msg.is_spam ? 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300' : 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300'}">${msg.is_spam ? 'SPAM' : 'CLEAN'}</span>` : ''}
</div>
</div>
<div class="flex flex-wrap items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
<span>${formatTime(msg.last_seen)}</span>
${msg.queue_id ? `<span class="font-mono" title="Queue ID">Q: ${msg.queue_id}</span>` : ''}
${msg.message_id ? `<span class="font-mono truncate max-w-xs" title="Message ID: ${escapeHtml(msg.message_id)}">MID: ${escapeHtml(msg.message_id.substring(0, 20))}${msg.message_id.length > 20 ? '...' : ''}</span>` : ''}
${msg.spam_score !== null ? `<span>Score: <span class="${msg.spam_score >= 15 ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-gray-600 dark:text-gray-300'}">${msg.spam_score.toFixed(1)}</span></span>` : ''}
${msg.user ? `<span>User: ${escapeHtml(msg.user)}</span>` : ''}
${msg.ip ? `<span>IP: ${msg.ip}</span>` : ''}
</div>
</div>
`).join('')}
</div>
${renderPagination('messages', data.page, data.pages)}
`;
currentPage.messages = page;
} catch (error) {
console.error('Failed to load messages:', error);
document.getElementById('messages-logs').innerHTML = `<p class="text-red-500 text-center py-8">Failed to load messages: ${error.message}</p>`;
const countEl = document.getElementById('messages-count');
if (countEl) countEl.textContent = '';
}
}
// =============================================================================
// STATUS TAB
// =============================================================================
async function loadStatus() {
try {
await Promise.all([
loadStatusContainers(),
loadStatusSystem(),
loadStatusStorage(),
loadStatusExtended()
]);
} catch (error) {
console.error('Failed to load status:', error);
}
}
async function loadStatusContainers() {
try {
const response = await authenticatedFetch('/api/status/containers');
let data = await response.json();
const container = document.getElementById('status-containers');
let containersData = data.containers || data;
if (Array.isArray(containersData) && containersData.length === 1 && typeof containersData[0] === 'object') {
containersData = containersData[0];
}
let containersList = [];
if (Array.isArray(containersData)) {
containersList = containersData;
} else if (containersData && typeof containersData === 'object') {
containersList = Object.entries(containersData).map(([key, value]) => ({
name: (value.name || key).replace('-mailcow', ''),
container: key,
state: value.state || 'unknown',
started_at: value.started_at || null
}));
}
if (containersList.length > 0) {
const running = containersList.filter(c => c.state === 'running').length;
const stopped = containersList.filter(c => c.state !== 'running').length;
const total = containersList.length;
container.innerHTML = `
<!-- Summary FIRST -->
<div class="mb-4 pb-4 border-b border-gray-200 dark:border-gray-700">
<div class="grid grid-cols-3 gap-4 text-center">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">Total</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">${total}</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">Running</p>
<p class="text-xl font-bold text-green-600 dark:text-green-400">${running}</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">Stopped</p>
<p class="text-xl font-bold text-red-600 dark:text-red-400">${stopped}</p>
</div>
</div>
</div>
<!-- Containers list -->
<div class="space-y-2 max-h-96 overflow-y-auto" style="scrollbar-width: thin;">
${containersList.map(c => `
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div class="flex items-center gap-3 flex-1">
<div class="w-2 h-2 rounded-full flex-shrink-0 ${c.state === 'running' ? 'bg-green-500' : 'bg-red-500'}"></div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">${escapeHtml(c.name)}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">${c.started_at ? new Date(c.started_at).toLocaleString('he-IL', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : 'Unknown'}</p>
</div>
</div>
<span class="text-xs px-2 py-1 rounded flex-shrink-0 ${c.state === 'running' ? 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300' : 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300'}">${c.state}</span>
</div>
`).join('')}
</div>
`;
} else {
container.innerHTML = '<p class="text-gray-500 dark:text-gray-400 text-center py-8">No container information available</p>';
}
} catch (error) {
console.error('Failed to load containers status:', error);
document.getElementById('status-containers').innerHTML = '<p class="text-red-500 text-center py-8">Failed to load containers</p>';
}
}
async function loadStatusSystem() {
const container = document.getElementById('status-system');
try {
console.log('Loading System Info...');
const response = await authenticatedFetch('/api/status/mailcow-info');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.log('System info data:', data);
container.innerHTML = `
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div class="text-center p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase mb-1">Domains</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">${data.domains.total}</p>
<p class="text-xs text-green-600 dark:text-green-400 mt-1">${data.domains.active} active</p>
</div>
<div class="text-center p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase mb-1">Mailboxes</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">${data.mailboxes.total}</p>
<p class="text-xs text-green-600 dark:text-green-400 mt-1">${data.mailboxes.active} active</p>
</div>
</div>
<div class="text-center p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase mb-1">Aliases</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">${data.aliases.total}</p>
<p class="text-xs text-green-600 dark:text-green-400 mt-1">${data.aliases.active} active</p>
</div>
</div>
`;
} catch (error) {
console.error('Failed to load system info:', error);
document.getElementById('status-system').innerHTML = `<p class="text-red-500 text-center py-8">Failed to load system info: ${error.message}</p>`;
}
}
async function loadStatusStorage() {
const container = document.getElementById('status-storage');
try {
console.log('Loading Storage Info...');
const response = await authenticatedFetch('/api/status/storage');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
let rawData = await response.json();
console.log('Storage info data:', rawData);
// Handle mailcow API format: [{ "type": "info", "disk": "/dev/sdb1", ... }]
let data = rawData;
if (Array.isArray(rawData) && rawData.length > 0) {
data = rawData[0]; // Take first element
}
const usedPercent = parseInt(data.used_percent) || 0;
const storageColor = usedPercent > 90 ? 'bg-red-600' :
usedPercent > 75 ? 'bg-yellow-600' :
'bg-green-600';
const textColor = usedPercent > 90 ? 'text-red-600 dark:text-red-400' :
usedPercent > 75 ? 'text-yellow-600 dark:text-yellow-400' :
'text-green-600 dark:text-green-400';
container.innerHTML = `
<div class="space-y-6">
<div class="text-center">
<p class="text-5xl font-bold ${textColor} mb-2">${data.used_percent}</p>
<p class="text-sm text-gray-600 dark:text-gray-400">Storage Used</p>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-4">
<div class="${storageColor} h-4 rounded-full transition-all duration-300" style="width: ${usedPercent}%"></div>
</div>
<div class="grid grid-cols-2 gap-4 text-center">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Used</p>
<p class="text-lg font-semibold text-gray-900 dark:text-white">${data.used}</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Total</p>
<p class="text-lg font-semibold text-gray-900 dark:text-white">${data.total}</p>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-3">
<p class="text-xs text-gray-600 dark:text-gray-400">
<svg class="inline w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
</svg>
Disk: ${data.disk}
</p>
</div>
</div>
`;
} catch (error) {
console.error('Failed to load storage info:', error);
document.getElementById('status-storage').innerHTML = `<p class="text-red-500 text-center py-8">Failed to load storage info: ${error.message}</p>`;
}
}
async function loadStatusExtended() {
try {
const response = await authenticatedFetch('/api/settings/info');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
console.log('Extended status data loaded:', data);
// Render Import Status
renderStatusImport(data.import_status || {});
// Render Correlation Status
renderStatusCorrelation(data.correlation_status || {}, data.recent_incomplete_correlations || []);
// Render Background Jobs
renderStatusJobs(data.background_jobs || {});
} catch (error) {
console.error('Failed to load extended status:', error);
document.getElementById('status-import').innerHTML = `<p class="text-red-500 text-center py-8">Failed to load: ${error.message}</p>`;
document.getElementById('status-correlation').innerHTML = `<p class="text-red-500 text-center py-8">Failed to load: ${error.message}</p>`;
document.getElementById('status-jobs').innerHTML = `<p class="text-red-500 text-center py-8">Failed to load: ${error.message}</p>`;
}
}
function renderStatusImport(imports) {
const container = document.getElementById('status-import');
container.innerHTML = `
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
${renderImportCard('Postfix Logs', imports.postfix, 'blue')}
${renderImportCard('Rspamd Logs', imports.rspamd, 'purple')}
${renderImportCard('Netfilter Logs', imports.netfilter, 'red')}
</div>
`;
}
function renderStatusCorrelation(correlation, incompleteList) {
const container = document.getElementById('status-correlation');
container.innerHTML = `
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-4">
<div class="p-4 bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 rounded-lg text-center">
<p class="text-2xl font-bold text-blue-600 dark:text-blue-400">${correlation.total || 0}</p>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">Total</p>
</div>
<div class="p-4 bg-gradient-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/20 rounded-lg text-center">
<p class="text-2xl font-bold text-green-600 dark:text-green-400">${correlation.complete || 0}</p>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">Complete</p>
</div>
<div class="p-4 bg-gradient-to-br from-yellow-50 to-yellow-100 dark:from-yellow-900/20 dark:to-yellow-800/20 rounded-lg text-center">
<p class="text-2xl font-bold text-yellow-600 dark:text-yellow-400">${correlation.incomplete || 0}</p>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">Incomplete</p>
</div>
<div class="p-4 bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-700/20 dark:to-gray-600/20 rounded-lg text-center">
<p class="text-2xl font-bold text-gray-500 dark:text-gray-400">${correlation.expired || 0}</p>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">Expired</p>
</div>
<div class="p-4 bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/20 rounded-lg text-center">
<p class="text-2xl font-bold text-purple-600 dark:text-purple-400">${correlation.completion_rate || 0}%</p>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">Success Rate</p>
</div>
</div>
${correlation.last_update ? `
<p class="text-sm text-gray-600 dark:text-gray-400 text-center">
Last updated: ${formatTime(correlation.last_update)}
</p>
` : ''}
${incompleteList.length > 0 ? `
<div class="mt-4 p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<h4 class="text-sm font-semibold text-yellow-800 dark:text-yellow-300 mb-2">Recent Incomplete Correlations</h4>
<div class="space-y-2">
${incompleteList.map(item => `
<div class="p-2 bg-white dark:bg-gray-800 rounded text-xs">
<div class="flex justify-between items-start mb-1">
<span class="font-mono text-gray-600 dark:text-gray-400">${escapeHtml(item.message_id || 'N/A')}</span>
<span class="text-yellow-600 dark:text-yellow-400">${item.age_minutes}m ago</span>
</div>
<div class="text-gray-500 dark:text-gray-400">
${escapeHtml(item.sender || 'N/A')} => ${escapeHtml(item.recipient || 'N/A')}
</div>
</div>
`).join('')}
</div>
<p class="text-xs text-yellow-700 dark:text-yellow-400 mt-2">
These will be automatically completed or expired within 1-2 minutes
</p>
</div>
` : ''}
`;
}
function renderStatusJobs(jobs) {
const container = document.getElementById('status-jobs');
container.innerHTML = `
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
${renderJobCard('Fetch Logs', jobs.fetch_logs)}
${renderJobCard('Complete Correlations', jobs.complete_correlations)}
${renderJobCard('Update Final Status', jobs.update_final_status)}
${renderJobCard('Expire Correlations', jobs.expire_correlations)}
${renderJobCard('Cleanup Logs', jobs.cleanup_logs)}
${renderJobCard('Check App Version', jobs.check_app_version)}
${renderJobCard('DNS Check (All Domains)', jobs.dns_check)}
${renderJobCard('Sync Active Domains', jobs.sync_local_domains)}
${renderJobCard('DMARC IMAP Import', jobs.dmarc_imap_sync)}
${renderJobCard('Update MaxMind Databases', jobs.update_geoip)}
${renderJobCard('Mailbox Statistics', jobs.mailbox_stats)}
${renderJobCard('Alias Statistics', jobs.alias_stats)}
</div>
`;
}
// =============================================================================
// POSTFIX DETAILS MODAL
// =============================================================================
async function viewPostfixDetails(queueId) {
if (!queueId) {
console.error('No queue ID provided');
return;
}
console.log('Loading Postfix details for queue ID:', queueId);
const modal = document.getElementById('message-modal');
const content = document.getElementById('message-modal-content');
if (!modal || !content) {
console.error('Modal elements not found');
return;
}
// Block body scroll
document.body.style.overflow = 'hidden';
modal.classList.remove('hidden');
content.innerHTML = '<div class="text-center py-8"><div class="loading mx-auto mb-4"></div><p class="text-gray-500 dark:text-gray-400">Loading...</p></div>';
try {
const response = await authenticatedFetch(`/api/logs/postfix/by-queue/${queueId}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.log('Postfix details loaded:', data);
if (data.logs && data.logs.length > 0) {
// Sort logs by time
const sortedLogs = data.logs.sort((a, b) => new Date(a.time) - new Date(b.time));
// Extract key information
let sender = null, recipient = null;
sortedLogs.forEach(log => {
if (log.sender && !sender) sender = log.sender;
if (log.recipient && !recipient) recipient = log.recipient;
});
// CRITICAL: Store FULL data in currentModalData
currentModalData = {
queue_id: queueId,
sender: sender || 'Unknown',
recipient: recipient || 'Unknown',
subject: 'Postfix Log Details',
direction: null,
final_status: null,
first_seen: sortedLogs[0].time,
postfix: sortedLogs,
rspamd: data.rspamd || null,
netfilter: []
};
currentModalTab = 'overview'; // Start with Overview
// Update Security tab indicator
updateSecurityTabIndicator(currentModalData);
// Reset modal tabs
document.querySelectorAll('[id^="modal-tab-"]').forEach(btn => {
btn.classList.remove('active');
});
const overviewTab = document.getElementById('modal-tab-overview');
if (overviewTab) {
overviewTab.classList.add('active');
}
console.log('currentModalData set:', currentModalData);
// Render the Overview tab
renderModalTab('overview', currentModalData);
} else {
content.innerHTML = '<p class="text-gray-500 dark:text-gray-400 text-center py-8">No logs found for this Queue ID</p>';
}
} catch (error) {
console.error('Failed to load Postfix details:', error);
content.innerHTML = `<p class="text-red-500 text-center py-8">Failed to load Postfix details: ${error.message}</p>`;
}
}
// =============================================================================
// Part 3: Message Modal with Tabs, Helper Functions, Export, Dark Mode
// =============================================================================
// =============================================================================
// MESSAGE MODAL WITH TABS
// =============================================================================
function switchModalTab(tab) {
console.log('Switching modal tab to:', tab);
currentModalTab = tab;
// Update tab buttons
document.querySelectorAll('[id^="modal-tab-"]').forEach(btn => {
btn.classList.remove('active');
});
const activeTab = document.getElementById(`modal-tab-${tab}`);
if (activeTab) {
activeTab.classList.add('active');
} else {
console.error('Modal tab button not found:', `modal-tab-${tab}`);
}
// Render content
if (currentModalData) {
renderModalTab(tab, currentModalData);
} else {
console.error('No modal data available');
}
}
async function viewMessageDetails(correlationKey) {
if (!correlationKey) {
console.error('No correlation key provided');
return;
}
console.log('Loading message details for:', correlationKey);
const modal = document.getElementById('message-modal');
const content = document.getElementById('message-modal-content');
if (!modal || !content) {
console.error('Modal elements not found');
return;
}
// Block body scroll
document.body.style.overflow = 'hidden';
modal.classList.remove('hidden');
content.innerHTML = '<div class="text-center py-8"><div class="loading mx-auto mb-4"></div><p class="text-gray-500 dark:text-gray-400">Loading...</p></div>';
try {
const response = await authenticatedFetch(`/api/message/${correlationKey}/details`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.log('Message details loaded:', data);
currentModalData = data;
currentModalTab = 'overview';
// Update Security tab indicator
updateSecurityTabIndicator(data);
document.querySelectorAll('[id^="modal-tab-"]').forEach(btn => {
btn.classList.remove('active');
});
const overviewTab = document.getElementById('modal-tab-overview');
if (overviewTab) {
overviewTab.classList.add('active');
}
renderModalTab('overview', data);
} catch (error) {
console.error('Failed to load message details:', error);
content.innerHTML = `<p class="text-red-500 text-center py-8">Failed to load message details: ${error.message}</p>`;
}
}
function renderModalTab(tab, data) {
const content = document.getElementById('message-modal-content');
switch (tab) {
case 'overview':
renderOverviewTab(content, data);
break;
case 'postfix':
renderPostfixTab(content, data);
break;
case 'spam':
renderSpamTab(content, data);
break;
case 'netfilter':
renderNetfilterTab(content, data);
break;
}
}
function renderOverviewTab(content, data) {
// Collect recipients from Postfix logs if available (these have full addresses including +)
let recipientsFromPostfix = new Set();
if (data.postfix && data.postfix.length > 0) {
data.postfix.forEach(log => {
if (log.recipient) {
recipientsFromPostfix.add(log.recipient);
}
});
}
// Use Postfix recipients if available, otherwise fall back to correlation recipients
const recipientsToDisplay = recipientsFromPostfix.size > 0
? Array.from(recipientsFromPostfix)
: (data.recipients || []);
// Build recipients section for right column
let recipientsRightColumn = '';
if (recipientsToDisplay.length > 0) {
if (recipientsToDisplay.length > 1) {
recipientsRightColumn = `
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Recipients (${recipientsToDisplay.length})</p>
<div class="mt-2 space-y-1 max-h-32 overflow-y-auto">
${recipientsToDisplay.map(r => `
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
<span class="text-sm text-gray-900 dark:text-white">${escapeHtml(r)}</span>
</div>
`).join('')}
</div>
</div>
`;
} else {
recipientsRightColumn = `
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">To</p>
<p class="text-sm font-semibold text-gray-900 dark:text-white mt-1">${escapeHtml(recipientsToDisplay[0] || '-')}</p>
</div>
`;
}
} else if (data.recipient) {
recipientsRightColumn = `
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">To</p>
<p class="text-sm font-semibold text-gray-900 dark:text-white mt-1">${escapeHtml(data.recipient)}</p>
</div>
`;
}
content.innerHTML = `
<div class="flex flex-col h-full">
<div class="flex-1 overflow-y-auto min-h-0">
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-gray-800 dark:to-gray-700 p-4 rounded-lg">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-3">Message Overview</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Left Column -->
<div class="space-y-3">
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">From</p>
<p class="text-sm font-semibold text-gray-900 dark:text-white mt-1">${escapeHtml(data.sender || '-')}</p>
</div>
${data.subject && data.subject !== 'Postfix Log Details' ? `
<div class="min-w-0">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Subject</p>
<p class="text-sm text-gray-900 dark:text-white mt-1 truncate" title="${escapeHtml(data.subject)}">${escapeHtml(data.subject)}</p>
</div>
` : ''}
${data.final_status || data.direction ? `
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1">Status & Direction</p>
<div class="flex items-center gap-2 flex-wrap">
${data.final_status ? `<span class="inline-block px-3 py-1 text-xs font-medium rounded ${getStatusClass(data.final_status)}">${data.final_status}</span>` : ''}
${data.direction ? `<span class="inline-block px-3 py-1 text-xs font-medium rounded ${getDirectionClass(data.direction)}">${data.direction}</span>` : ''}
</div>
</div>
` : ''}
</div>
<!-- Right Column -->
<div class="space-y-3">
${recipientsRightColumn}
${data.queue_id ? `
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Queue ID</p>
<p class="text-xs font-mono text-gray-600 dark:text-gray-400 mt-1">${data.queue_id}</p>
</div>
` : ''}
${data.message_id ? `
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Message ID</p>
<p class="text-xs font-mono text-gray-600 dark:text-gray-400 mt-1 break-all">${escapeHtml(data.message_id)}</p>
</div>
` : ''}
</div>
</div>
</div>
${data.rspamd ? `
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-3 sm:p-4 mt-1">
<h4 class="text-sm sm:text-md font-semibold text-gray-900 dark:text-white mb-3">Quick Spam Summary</h4>
<div class="grid grid-cols-3 gap-2">
<div class="text-center">
<p class="text-lg sm:text-2xl font-bold ${data.rspamd.score >= (data.rspamd.required_score || 15) ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400'}">
${data.rspamd.score.toFixed(2)}
</p>
<p class="text-[10px] sm:text-xs text-gray-500 dark:text-gray-400 mt-1">Score</p>
</div>
<div class="text-center">
<p class="text-sm sm:text-lg font-semibold text-gray-900 dark:text-white truncate">
${data.rspamd.action}
</p>
<p class="text-[10px] sm:text-xs text-gray-500 dark:text-gray-400 mt-1">Action</p>
</div>
<div class="text-center">
<p class="text-sm sm:text-lg font-semibold ${data.rspamd.is_spam ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400'}">
${data.rspamd.is_spam ? 'SPAM' : 'CLEAN'}
</p>
<p class="text-[10px] sm:text-xs text-gray-500 dark:text-gray-400 mt-1">Class</p>
</div>
</div>
<p class="text-[10px] sm:text-xs text-gray-500 dark:text-gray-400 text-center mt-3">
See "Spam Analysis" tab for details
</p>
</div>
` : data.postfix && data.postfix.length > 0 ? `
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mt-3">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
</svg>
<div>
<p class="text-sm font-medium text-blue-900 dark:text-blue-300">Postfix Delivery Logs</p>
<p class="text-xs text-blue-800 dark:text-blue-400 mt-1">Click "Logs" tab to see complete delivery timeline (${data.postfix.length} entries)</p>
</div>
</div>
</div>
` : ''}
</div>
${data.rspamd ? `
<div class="flex-shrink-0 mt-auto pt-3 border-t border-gray-200 dark:border-gray-700">
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
</svg>
<div class="flex-1">
<p class="text-sm font-medium text-blue-900 dark:text-blue-300 mb-2">Additional Details</p>
<div class="space-y-1 text-xs text-blue-800 dark:text-blue-400">
${data.rspamd.ip ? renderGeoIPInfo(data.rspamd, '16x12') : ''}
${data.rspamd.user ? `<p>Authenticated User: ${escapeHtml(data.rspamd.user)}</p>` : ''}
${data.rspamd.size ? `<p>Message Size: ${formatSize(data.rspamd.size)}</p>` : ''}
${data.rspamd.has_auth ? `<p>Authentication: Verified (MAILCOW_AUTH)</p>` : ''}
</div>
</div>
</div>
</div>
</div>
` : ''}
</div>
`;
}
function renderPostfixTab(content, data) {
if (!data.postfix || data.postfix.length === 0) {
content.innerHTML = `
<div class="text-center py-12">
<svg class="w-16 h-16 mx-auto text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
</svg>
<p class="text-gray-500 dark:text-gray-400">No Postfix delivery logs available</p>
</div>
`;
return;
}
// Extract key information from logs
let sender = null, clientIp = null, relay = null;
let messageId = null, finalStatus = null, totalDelay = null, queueId = null;
let errorReasons = [];
let recipientsFromPostfix = new Set(); // Collect all unique recipients from Postfix logs
data.postfix.forEach(log => {
if (log.queue_id && !queueId) queueId = log.queue_id;
if (log.sender && !sender) sender = log.sender;
if (log.relay && !relay) relay = log.relay;
if (log.message_id && !messageId) messageId = log.message_id;
if (log.status) finalStatus = log.status;
if (log.delay) totalDelay = log.delay;
// Collect recipients from Postfix logs (these have the full address including +)
if (log.recipient) {
recipientsFromPostfix.add(log.recipient);
}
if (!clientIp && log.message) {
const ipMatch = log.message.match(/client=.*?\[(\d+\.\d+\.\d+\.\d+)\]/);
if (ipMatch) clientIp = ipMatch[1];
}
// Extract error reasons for non-sent statuses
if (log.status && log.status !== 'sent' && log.message) {
// Look for "said:" pattern (remote server response)
const saidMatch = log.message.match(/said:\s*(.+?)(?:\s*\(in reply|$)/i);
if (saidMatch) {
errorReasons.push({
recipient: log.recipient,
status: log.status,
reason: saidMatch[1].trim()
});
} else if (log.status === 'deferred' || log.status === 'bounced') {
// Look for parenthetical reason
const parenMatch = log.message.match(/status=\w+\s*\((.+?)\)$/);
if (parenMatch) {
errorReasons.push({
recipient: log.recipient,
status: log.status,
reason: parenMatch[1].trim()
});
}
}
}
});
// Generate unique ID for accordion
const accordionId = 'postfix-accordion-' + Date.now();
// Separate system logs from recipient logs
const postfixByRecipient = data.postfix_by_recipient || {};
const systemLogs = postfixByRecipient['_system'] || [];
const recipientEntries = Object.entries(postfixByRecipient).filter(([key]) => key !== '_system');
// Build error summary section
const errorSummaryHtml = errorReasons.length > 0 ? `
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div class="flex items-start gap-3">
<svg class="w-6 h-6 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div class="flex-1">
<h4 class="text-md font-semibold text-red-800 dark:text-red-300 mb-2">Delivery Error</h4>
${errorReasons.map(err => `
<div class="mb-2 last:mb-0">
${err.recipient ? `<p class="text-sm font-medium text-red-700 dark:text-red-400">${escapeHtml(err.recipient)}</p>` : ''}
<p class="text-sm text-red-600 dark:text-red-300 mt-1">${escapeHtml(err.reason)}</p>
</div>
`).join('')}
</div>
</div>
</div>
` : '';
content.innerHTML = `
<div class="space-y-6">
${errorSummaryHtml}
<!-- Mail Details Header -->
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-gray-800 dark:to-gray-700 p-4 rounded-lg">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-3">Mail Details</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
${sender ? `
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">From</p>
<p class="text-sm font-semibold text-gray-900 dark:text-white mt-1">${escapeHtml(sender)}</p>
</div>
` : ''}
${recipientsFromPostfix.size > 0 ? `
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">To (${recipientsFromPostfix.size})</p>
<p class="text-sm font-semibold text-gray-900 dark:text-white mt-1">${recipientsFromPostfix.size === 1 ? escapeHtml(Array.from(recipientsFromPostfix)[0]) : `${recipientsFromPostfix.size} recipients`}</p>
</div>
` : (data.recipients && data.recipients.length > 0 ? `
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">To (${data.recipients.length})</p>
<p class="text-sm font-semibold text-gray-900 dark:text-white mt-1">${data.recipients.length === 1 ? escapeHtml(data.recipients[0]) : `${data.recipients.length} recipients`}</p>
</div>
` : '')}
${clientIp ? `
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Client IP</p>
<p class="text-sm font-mono font-semibold text-gray-900 dark:text-white mt-1">${clientIp}</p>
</div>
` : ''}
${queueId ? `
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Queue ID</p>
<p class="text-sm font-mono font-semibold text-gray-900 dark:text-white mt-1">${queueId}</p>
</div>
` : ''}
${finalStatus ? `
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Final Status</p>
<span class="inline-block px-3 py-1 text-sm font-medium rounded ${getStatusClass(finalStatus)} mt-1">${finalStatus}</span>
</div>
` : ''}
${relay ? `
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Relay</p>
<p class="text-sm font-mono font-semibold text-gray-900 dark:text-white mt-1 truncate" title="${escapeHtml(relay)}">${escapeHtml(relay)}</p>
</div>
` : ''}
${messageId ? `
<div class="md:col-span-2">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Message ID</p>
<p class="text-xs font-mono text-gray-700 dark:text-gray-300 mt-1 break-all">${escapeHtml(messageId)}</p>
</div>
` : ''}
</div>
</div>
<!-- Delivery Summary by Recipient (if multiple recipients) -->
${recipientEntries.length > 1 ? `
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
<h4 class="text-md font-semibold text-gray-900 dark:text-white mb-3">Delivery Summary by Recipient</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
${recipientEntries.map(([recipient, logs]) => {
const statusLog = logs.find(l => l.status) || logs[0];
return `
<div class="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-900 dark:text-white truncate flex-1">${escapeHtml(recipient)}</span>
${statusLog.status ? `<span class="ml-2 inline-block px-2 py-0.5 text-xs font-medium rounded ${getStatusClass(statusLog.status)}">${statusLog.status}</span>` : ''}
</div>
${statusLog.relay ? `<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate">via ${escapeHtml(statusLog.relay)}</p>` : ''}
</div>
`;
}).join('')}
</div>
</div>
` : ''}
<!-- Complete Log Timeline - ALWAYS show all logs -->
<div>
<div class="flex items-center justify-between mb-3">
<h4 class="text-md font-semibold text-gray-900 dark:text-white">Complete Log Timeline</h4>
<span class="text-xs text-gray-500 dark:text-gray-400">${data.postfix.length} entries</span>
</div>
<div class="space-y-2 max-h-96 overflow-y-auto">
${data.postfix.map(log => `
<div class="p-3 bg-gray-50 dark:bg-gray-700/50 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition">
<div class="flex justify-between items-start mb-1">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xs font-mono text-gray-600 dark:text-gray-300">${formatTime(log.time)}</span>
${log.program ? `<span class="text-xs px-2 py-0.5 rounded bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300">${log.program}</span>` : ''}
${log.recipient ? `<span class="text-xs text-gray-500 dark:text-gray-400">=> ${escapeHtml(log.recipient)}</span>` : ''}
</div>
${log.status ? `<span class="inline-block px-2 py-0.5 text-xs font-medium rounded ${getStatusClass(log.status)}">${log.status}</span>` : ''}
</div>
<p class="text-xs text-gray-700 dark:text-gray-300 font-mono break-all leading-relaxed">${escapeHtml(log.message)}</p>
${log.relay ? `<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Relay: ${escapeHtml(log.relay)}</p>` : ''}
${log.delay ? `<p class="text-xs text-gray-500 dark:text-gray-400">Delay: ${log.delay.toFixed(2)}s</p>` : ''}
</div>
`).join('')}
</div>
</div>
</div>
`;
}
// Accordion toggle function
function toggleAccordion(id) {
const content = document.getElementById(id);
const icon = document.getElementById(id + '-icon');
if (content.classList.contains('hidden')) {
content.classList.remove('hidden');
icon.style.transform = 'rotate(180deg)';
} else {
content.classList.add('hidden');
icon.style.transform = 'rotate(0deg)';
}
}
function renderSpamTab(content, data) {
if (!data.rspamd) {
content.innerHTML = `
<div class="text-center py-12">
<svg class="w-16 h-16 mx-auto text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path>
</svg>
<p class="text-gray-500 dark:text-gray-400">No spam analysis data available</p>
</div>
`;
return;
}
content.innerHTML = `
<div class="space-y-6">
<div class="grid grid-cols-3 gap-2 sm:gap-4">
<div class="bg-gray-50 dark:bg-gray-700/50 p-2 sm:p-4 rounded-lg text-center">
<p class="text-[10px] sm:text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1 sm:mb-2 truncate">Score</p>
<p class="text-lg sm:text-3xl font-bold ${data.rspamd.score >= (data.rspamd.required_score || 15) ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400'}">
${data.rspamd.score.toFixed(2)}
</p>
<p class="text-[9px] sm:text-xs text-gray-500 dark:text-gray-400 mt-1">Limit: ${data.rspamd.required_score || 15}</p>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 p-2 sm:p-4 rounded-lg text-center">
<p class="text-[10px] sm:text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1 sm:mb-2 truncate">Action</p>
<p class="text-sm sm:text-xl font-semibold text-gray-900 dark:text-white truncate">
${data.rspamd.action}
</p>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 p-2 sm:p-4 rounded-lg text-center">
<p class="text-[10px] sm:text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1 sm:mb-2 truncate">Class</p>
<p class="text-sm sm:text-xl font-semibold ${data.rspamd.is_spam ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400'}">
${data.rspamd.is_spam ? 'SPAM' : 'CLEAN'}
</p>
</div>
</div>
${data.rspamd.symbols && Object.keys(data.rspamd.symbols).length > 0 ? `
<div>
<h4 class="text-md font-semibold text-gray-900 dark:text-white mb-3">Detection Symbols</h4>
<div class="space-y-2 max-h-[29rem] overflow-y-auto">
${Object.entries(data.rspamd.symbols)
.sort((a, b) => {
const scoreA = a[1].score || a[1].metric_score || 0;
const scoreB = b[1].score || b[1].metric_score || 0;
if (scoreA === 0 && scoreB !== 0) return 1;
if (scoreA !== 0 && scoreB === 0) return -1;
return Math.abs(scoreB) - Math.abs(scoreA);
})
.map(([name, details]) => {
const score = details.score || details.metric_score || 0;
const description = details.description || '';
const options = details.options || [];
const scoreClass = score > 0 ? 'text-red-600 dark:text-red-400' :
score < 0 ? 'text-green-600 dark:text-green-400' :
'text-gray-500 dark:text-gray-400';
return `
<div class="flex items-start justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition">
<div class="flex-1">
<span class="text-sm font-semibold text-gray-900 dark:text-white">${name}</span>
${description ? `<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">${escapeHtml(description)}</p>` : ''}
${options.length > 0 ? `<p class="text-xs font-mono text-blue-600 dark:text-blue-400 mt-1">${options.map(o => escapeHtml(o)).join(', ')}</p>` : ''}
</div>
<span class="ml-3 text-sm font-mono font-bold ${scoreClass}">${score > 0 ? '+' : ''}${score.toFixed(2)}</span>
</div>
`;
}).join('')}
</div>
</div>
` : ''}
</div>
`;
}
function renderNetfilterTab(content, data) {
if (!data.netfilter || data.netfilter.length === 0) {
content.innerHTML = `
<div class="text-center py-12">
<svg class="w-16 h-16 mx-auto text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path>
</svg>
<p class="text-gray-500 dark:text-gray-400">No security events detected</p>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-2">This is good - no failed authentication attempts from this sender</p>
</div>
`;
return;
}
content.innerHTML = `
<div class="space-y-4">
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
</svg>
<div>
<p class="text-sm font-medium text-yellow-900 dark:text-yellow-300">Security Events Detected</p>
<p class="text-xs text-yellow-800 dark:text-yellow-400 mt-1">${data.netfilter.length} authentication event(s) from the sender's IP within 1 hour of this message</p>
</div>
</div>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Related Security Events</h3>
<div class="space-y-2">
${data.netfilter.map(log => `
<div class="p-3 bg-gray-50 dark:bg-gray-700/50 rounded">
<div class="flex justify-between items-start mb-2">
<div class="flex items-center gap-2">
<span class="text-xs font-mono text-gray-600 dark:text-gray-300">${formatTime(log.time)}</span>
<span class="text-xs font-mono font-semibold text-gray-900 dark:text-white">${log.ip}</span>
</div>
<span class="inline-block px-2 py-0.5 text-xs font-medium rounded ${getActionClass(log.action)}">${getActionLabel(log.action)}</span>
</div>
${log.username ? `<p class="text-xs text-gray-700 dark:text-gray-300">User: ${escapeHtml(log.username)}</p>` : ''}
${log.auth_method ? `<p class="text-xs text-gray-600 dark:text-gray-400">Method: ${log.auth_method}</p>` : ''}
${log.attempts_left !== null ? `<p class="text-xs text-gray-600 dark:text-gray-400">Attempts remaining: ${log.attempts_left}</p>` : ''}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 font-mono">${escapeHtml(log.message)}</p>
</div>
`).join('')}
</div>
</div>
`;
}
function updateSecurityTabIndicator(data) {
const securityTab = document.getElementById('modal-tab-netfilter');
if (!securityTab) return;
const hasSecurityEvents = data.netfilter && data.netfilter.length > 0;
const indicator = hasSecurityEvents ? '🔴' : '🟢';
securityTab.innerHTML = `<span class="text-xs sm:text-sm font-medium">Security ${indicator}</span>`;
}
function closeMessageModal() {
const modal = document.getElementById('message-modal');
if (modal) {
modal.classList.add('hidden');
currentModalData = null;
// Restore body scroll
document.body.style.overflow = '';
// Reset security tab indicator
const securityTab = document.getElementById('modal-tab-netfilter');
if (securityTab) {
securityTab.innerHTML = '<span class="text-sm font-medium">Security</span>';
}
}
}
function showChangelogModal(changelog) {
const modal = document.getElementById('changelog-modal');
const modalTitle = modal?.querySelector('h3');
const content = document.getElementById('changelog-content');
if (modal && content) {
if (modalTitle) {
modalTitle.textContent = 'Changelog';
}
if (typeof marked !== 'undefined' && changelog) {
marked.setOptions({
breaks: true,
gfm: true
});
content.innerHTML = marked.parse(changelog);
} else {
content.textContent = changelog || 'No changelog available';
}
modal.classList.remove('hidden');
document.body.style.overflow = 'hidden';
}
}
function closeChangelogModal() {
const modal = document.getElementById('changelog-modal');
if (modal) {
modal.classList.add('hidden');
document.body.style.overflow = '';
const modalTitle = modal.querySelector('h3');
if (modalTitle) {
modalTitle.textContent = 'Changelog';
}
}
}
// =============================================================================
// GEOIP RENDERING AND FLAGS
// =============================================================================
function getFlagUrl(countryCode, size = '24x18') {
if (!countryCode || countryCode.length !== 2) {
return null;
}
return `/static/assets/flags/${size}/${countryCode.toLowerCase()}.png`;
}
function renderGeoIPInfo(rspamdData, size = '24x18') {
if (!rspamdData || !rspamdData.ip) {
return '';
}
const ip = rspamdData.ip;
const hasGeoIP = rspamdData.country_code;
if (!hasGeoIP) {
return `<p>Source IP: ${escapeHtml(ip)}</p>`;
}
const flagUrl = getFlagUrl(rspamdData.country_code, size);
const [width, height] = size.split('x').map(Number);
// Use a list to store the parts of the info string
let parts = [`<strong>${escapeHtml(ip)}</strong>`];
if (rspamdData.country_name && flagUrl) {
// Wrap image and country name in a span to keep them together and aligned
const countryPart =
`<br><span style="display: inline-flex; align-items: baseline; gap: 4px; vertical-align: baseline; margin-top: 5px;">` +
`<img src="${flagUrl}" alt="${escapeHtml(rspamdData.country_name)}" ` +
`style="width:${width}px; height:${height}px; display: block;" ` +
`onerror="this.style.display='none'">` +
`${escapeHtml(rspamdData.country_name)}` +
`</span>`;
parts.push(countryPart);
}
if (rspamdData.city) {
parts.push(escapeHtml(rspamdData.city));
}
if (rspamdData.asn_org) {
parts.push(`(${escapeHtml(rspamdData.asn_org)})`);
}
// Use white-space: nowrap on the container if you want to prevent the whole line from breaking
return `<p style="margin: 0;">Source: ${parts.join(' ')}</p>`;
}
function renderGeoIPForDMARC(record, size = '24x18') {
if (!record || !record.source_ip) {
return '';
}
const ip = record.source_ip;
const hasGeoIP = record.country_code;
if (!hasGeoIP) {
return escapeHtml(ip);
}
// Build flag URL
const flagUrl = getFlagUrl(record.country_code, size);
const [width, height] = size.split('x').map(Number);
// Build location string
let parts = [];
if (record.country_name) {
parts.push(escapeHtml(record.country_name));
}
if (record.city) {
parts.push(escapeHtml(record.city));
}
if (record.asn_org) {
parts.push(escapeHtml(record.asn_org));
}
const locationText = parts.join(', ');
// Return flag + location inline
if (flagUrl && locationText) {
return `<img src="${flagUrl}" alt="${escapeHtml(record.country_name || '')}" style="width:${width}px; height:${height}px; vertical-align:middle; margin-right:4px;" onerror="this.style.display='none'">${locationText}`;
}
return locationText || escapeHtml(ip);
}
// =============================================================================
// EXPORT CSV
// =============================================================================
async function exportCSV(type) {
try {
const filters = currentFilters[type] || {};
const params = new URLSearchParams(filters);
const response = await authenticatedFetch(`/api/export/${type}/csv?${params}`);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${type}_logs_${new Date().getTime()}.csv`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
console.error('Failed to export CSV:', error);
alert('Failed to export CSV');
}
}
// =============================================================================
// PAGINATION & HELPER FUNCTIONS
// =============================================================================
function renderPagination(type, currentPage, totalPages) {
if (totalPages <= 1) return '';
return `
<div class="flex flex-col sm:flex-row justify-center items-center gap-2 sm:gap-3 mt-6">
<button onclick="loadLogs('${type}', ${currentPage - 1})" ${currentPage === 1 ? 'disabled' : ''} class="w-full sm:w-auto px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition">
Previous
</button>
<span class="text-sm text-gray-600 dark:text-gray-400">Page ${currentPage} of ${totalPages}</span>
<button onclick="loadLogs('${type}', ${currentPage + 1})" ${currentPage === totalPages ? 'disabled' : ''} class="w-full sm:w-auto px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition">
Next
</button>
</div>
`;
}
function loadLogs(type, page) {
currentPage[type] = page;
switch (type) {
case 'messages':
loadMessages(page);
break;
case 'postfix':
loadPostfixLogs(page);
break;
case 'rspamd':
loadRspamdLogs(page);
break;
case 'netfilter':
loadNetfilterLogs(page);
break;
}
}
function formatTime(isoString) {
if (!isoString) return '-';
const date = new Date(isoString);
// Use timezone from app configuration if set, otherwise use browser's local timezone
// The date is already in UTC (with 'Z' suffix), so browser will convert it correctly
try {
if (appTimezone && appTimezone !== 'UTC') {
// Use Intl.DateTimeFormat with app timezone
const formatter = new Intl.DateTimeFormat(undefined, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
timeZone: appTimezone
});
return formatter.format(date);
} else {
// Use browser's local timezone and locale
return date.toLocaleString(undefined, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
}
} catch (e) {
// Fallback to browser's local timezone if timezone is invalid
console.warn('Invalid timezone, using browser local timezone:', appTimezone, e);
return date.toLocaleString(undefined, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
}
}
function formatDate(isoString) {
if (!isoString) return '-';
// Use formatTime for consistent date/time formatting
return formatTime(isoString);
}
function formatSize(bytes) {
if (!bytes) return '0 B';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
}
function getStatusClass(status) {
const statusColors = APP_COLORS.statuses[status];
if (statusColors) {
return statusColors.badge;
}
return APP_COLORS.default.badge;
}
function getCorrelationStatusDisplay(msg) {
// If there's a final_status, show it with emoji
if (msg.final_status) {
const statusEmoji = {
'delivered': '✓',
'sent': '✓',
'bounced': '↩',
'rejected': '✗',
'deferred': '⏳',
'spam': '⚠',
'expired': '⏸'
};
const statusText = {
'delivered': 'Delivered',
'sent': 'Sent',
'bounced': 'Bounced',
'rejected': 'Rejected',
'deferred': 'Deferred',
'spam': 'Spam',
'expired': 'Expired'
};
const emoji = statusEmoji[msg.final_status] || '•';
const text = statusText[msg.final_status] || msg.final_status;
return { display: `${emoji} ${text}`, class: getStatusClass(msg.final_status) };
}
// If no final_status but correlation is complete, show Linked
if (msg.is_complete === true) {
return { display: '✓ Linked', class: 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300' };
}
// If correlation is not complete, show Pending
if (msg.is_complete === false) {
return { display: '⏳ Pending', class: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300' };
}
return null;
}
function getDirectionClass(direction) {
const directionColors = APP_COLORS.directions[direction];
if (directionColors) {
return directionColors.badge;
}
return APP_COLORS.default.badge;
}
function getActionLabel(action) {
switch (action) {
case 'ban':
return 'BAN';
case 'unban':
return 'UNBAN';
case 'banned':
return 'BAN'; // Legacy support
case 'warning':
return 'warning';
case 'info':
return 'info';
default:
return action || 'warning';
}
}
function getActionClass(action) {
switch (action) {
case 'ban':
case 'banned': // Legacy support
return 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300';
case 'unban':
return 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300';
case 'warning':
return 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300';
case 'info':
return 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300';
default:
return 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300';
}
}
function escapeHtml(text) {
if (text === null || text === undefined) return '';
let cleanText = String(text).replace(/\\"/g, '"');
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return cleanText.replace(/[&<>"']/g, function (m) { return map[m]; });
}
// =============================================================================
// DARK MODE
// =============================================================================
function initDarkMode() {
const theme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (theme === 'dark' || (!theme && prefersDark)) {
document.documentElement.classList.add('dark');
document.getElementById('theme-toggle-light-icon').classList.remove('hidden');
} else {
document.documentElement.classList.remove('dark');
document.getElementById('theme-toggle-dark-icon').classList.remove('hidden');
}
}
function toggleDarkMode() {
document.documentElement.classList.toggle('dark');
const isDark = document.documentElement.classList.contains('dark');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
document.getElementById('theme-toggle-dark-icon').classList.toggle('hidden');
document.getElementById('theme-toggle-light-icon').classList.toggle('hidden');
}
// Initialize dark mode
initDarkMode();
// =============================================================================
// MODAL EVENT LISTENERS
// =============================================================================
document.addEventListener('DOMContentLoaded', function () {
const messageModal = document.getElementById('message-modal');
if (messageModal) {
messageModal.addEventListener('click', function (e) {
// Close modal if clicking on the backdrop (not the content)
if (e.target.id === 'message-modal') {
closeMessageModal();
}
});
// Prevent clicks inside modal content from closing
const modalContent = messageModal.querySelector('.bg-white');
if (modalContent) {
modalContent.addEventListener('click', function (e) {
e.stopPropagation();
});
}
}
// ESC key to close modal
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
const modal = document.getElementById('message-modal');
if (modal && !modal.classList.contains('hidden')) {
closeMessageModal();
}
const changelogModal = document.getElementById('changelog-modal');
if (changelogModal && !changelogModal.classList.contains('hidden')) {
closeChangelogModal();
}
}
});
// Changelog modal event listeners
const changelogModal = document.getElementById('changelog-modal');
if (changelogModal) {
changelogModal.addEventListener('click', function (e) {
if (e.target.id === 'changelog-modal') {
closeChangelogModal();
}
});
const changelogContent = changelogModal.querySelector('.bg-white, .dark\\:bg-gray-800');
if (changelogContent) {
changelogContent.addEventListener('click', function (e) {
e.stopPropagation();
});
}
}
});
// =============================================================================
// DOMAINS TAB - Domains management with DNS validation
// =============================================================================
async function loadDomains() {
const loading = document.getElementById('domains-loading');
const content = document.getElementById('domains-content');
if (!loading || !content) {
console.error('Domains elements not found');
return;
}
loading.classList.remove('hidden');
content.classList.add('hidden');
try {
const response = await authenticatedFetch('/api/domains/all');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
renderDomains(content, data);
loading.classList.add('hidden');
content.classList.remove('hidden');
} catch (error) {
console.error('Failed to load domains:', error);
loading.innerHTML = `
<div class="text-center py-12">
<svg class="w-16 h-16 mx-auto text-red-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<p class="text-red-500">Failed to load domains</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2">${escapeHtml(error.message)}</p>
</div>
`;
}
}
function renderDomains(container, data) {
const domains = data.domains || [];
const dnsCheckInfo = document.getElementById('dns-check-info');
if (dnsCheckInfo) {
const lastCheck = data.last_dns_check
? formatTime(data.last_dns_check)
: '<span class="text-gray-400">Never</span>';
dnsCheckInfo.innerHTML = `
<div class="text-right">
<p class="text-xs text-gray-500 dark:text-gray-400">Last checked:</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">${lastCheck}</p>
</div>
<button
id="check-all-dns-btn"
onclick="checkAllDomainsDNS()"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition text-sm font-medium flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
Check Now
</button>
`;
}
if (domains.length === 0) {
container.innerHTML = `
<div class="text-center py-12">
<svg class="w-16 h-16 mx-auto text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
</svg>
<p class="text-gray-500 dark:text-gray-400">No domains found</p>
</div>
`;
return;
}
// Summary cards
const summaryHTML = `
<div class="grid grid-cols-3 gap-4 mb-6">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between mb-1">
<h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Total</h3>
<svg class="w-5 h-5 text-blue-500 opacity-80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path>
</svg>
</div>
<p class="text-2xl font-bold text-gray-900 dark:text-white">${data.total || 0}</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between mb-1">
<h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Active</h3>
<svg class="w-5 h-5 text-green-500 opacity-80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
<p class="text-2xl font-bold text-green-600 dark:text-green-400">${data.active || 0}</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between mb-1">
<h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Inactive</h3>
<svg class="w-5 h-5 text-gray-400 opacity-80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path>
</svg>
</div>
<p class="text-2xl font-bold text-gray-600 dark:text-gray-400">${(data.total || 0) - (data.active || 0)}</p>
</div>
</div>
`;
// Search/Filter bar
const filterHTML = `
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 px-4 py-2">
<div class="flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-3 flex-1 min-w-0">
<svg class="w-5 h-5 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
<input
type="text"
id="domain-search-input"
placeholder="Search domains..."
class="flex-1 px-3 py-2 text-sm border-0 bg-transparent text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-0 min-w-0"
oninput="filterDomains()"
>
<!-- Domain count badge -->
<span id="domain-count-badge" class="px-3 py-1 text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-full whitespace-nowrap">
${domains.length} domains
</span>
</div>
</div>
</div>
<div class="flex items-center gap-4 py-4 text-sm font-medium text-gray-300 pl-10">
<div class="flex items-center gap-4 flex-shrink-0">
<!-- Filter: Show only domains with issues -->
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
id="filter-issues-only"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
onchange="filterDomains()"
>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 whitespace-nowrap">Show only domains with issues</span>
</label>
</div>
</div>
`;
// Domains list with accordion style
const domainsHTML = domains.map(domain => renderDomainAccordionRow(domain)).join('');
container.innerHTML = summaryHTML + filterHTML + `
<div id="domains-list" class="space-y-2">
${domainsHTML}
</div>
`;
// Store domains data for filtering
window.domainsData = domains;
}
// Filter domains based on search input and issues checkbox
function filterDomains() {
const searchInput = document.getElementById('domain-search-input');
const issuesCheckbox = document.getElementById('filter-issues-only');
const domainsList = document.getElementById('domains-list');
const countBadge = document.getElementById('domain-count-badge');
if (!searchInput || !domainsList || !window.domainsData) return;
const searchTerm = searchInput.value.toLowerCase().trim();
const showIssuesOnly = issuesCheckbox ? issuesCheckbox.checked : false;
// Filter domains
let filteredDomains = window.domainsData.filter(domain => {
// Search filter
const matchesSearch = domain.domain_name.toLowerCase().includes(searchTerm);
// Issues filter - check if domain has any DNS issues
let hasIssues = false;
if (showIssuesOnly) {
const dns = domain.dns_checks || {};
const spf = dns.spf || {};
const dkim = dns.dkim || {};
const dmarc = dns.dmarc || {};
// Check if any DNS check has error or warning status
hasIssues =
spf.status === 'error' || spf.status === 'warning' ||
dkim.status === 'error' || dkim.status === 'warning' ||
dmarc.status === 'error' || dmarc.status === 'warning';
}
return matchesSearch && (!showIssuesOnly || hasIssues);
});
// Update count badge
if (countBadge) {
countBadge.textContent = `${filteredDomains.length} domain${filteredDomains.length !== 1 ? 's' : ''}`;
}
// Re-render filtered domains
if (filteredDomains.length === 0) {
const noResultsMessage = showIssuesOnly && searchTerm === ''
? 'No domains with DNS issues found'
: `No domains found matching "${escapeHtml(searchTerm)}"`;
domainsList.innerHTML = `
<div class="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<svg class="w-16 h-16 mx-auto text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
<p class="text-gray-500 dark:text-gray-400">${noResultsMessage}</p>
</div>
`;
} else {
domainsList.innerHTML = filteredDomains.map(domain => renderDomainAccordionRow(domain)).join('');
}
}
function renderDomainAccordionRow(domain) {
const dns = domain.dns_checks || {};
const spf = dns.spf || { status: 'unknown', message: 'Not checked' };
const dkim = dns.dkim || { status: 'unknown', message: 'Not checked' };
const dmarc = dns.dmarc || { status: 'unknown', message: 'Not checked' };
// Status icons for inline display
const getStatusIcon = (status) => {
if (status === 'success') return '<span class="text-green-500" title="OK">✓</span>';
if (status === 'warning') return '<span class="text-amber-500" title="Warning">⚠</span>';
if (status === 'error') return '<span class="text-red-500" title="Error">✗</span>';
return '<span class="text-gray-400" title="Unknown">?</span>';
};
const domainId = `domain-${escapeHtml(domain.domain_name).replace(/\./g, '-')}`;
return `
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 overflow-hidden">
<!-- Summary Row - Clickable -->
<div class="p-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/30 transition" onclick="toggleDomainDetails('${domainId}')">
<!-- Desktop Layout (lg and up) -->
<div class="hidden lg:grid lg:grid-cols-[minmax(0,350px)_1fr_minmax(0,280px)] items-center gap-4">
<!-- Left: Expand Icon + Domain Name + Status (max 350px) -->
<div class="flex items-center gap-3 min-w-0">
<svg id="${domainId}-icon-desktop" class="w-5 h-5 text-gray-400 transition-transform flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
<div class="flex items-center gap-2 min-w-0">
<h3 class="text-base font-bold text-gray-900 dark:text-white truncate">${escapeHtml(domain.domain_name)}</h3>
${domain.active ?
'<span class="px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 flex-shrink-0">Active</span>' :
'<span class="px-2 py-0.5 text-xs font-semibold rounded-full bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 flex-shrink-0">Inactive</span>'
}
</div>
</div>
<!-- Center: DNS Status Indicators -->
<div class="flex items-center justify-center">
<div class="flex items-center gap-4 px-4 py-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div class="flex items-center gap-1.5">
<span class="font-medium text-xs text-gray-600 dark:text-gray-400">SPF</span>
${getStatusIcon(spf.status)}
</div>
<div class="w-px h-4 bg-gray-300 dark:bg-gray-600"></div>
<div class="flex items-center gap-1.5">
<span class="font-medium text-xs text-gray-600 dark:text-gray-400">DKIM</span>
${getStatusIcon(dkim.status)}
</div>
<div class="w-px h-4 bg-gray-300 dark:bg-gray-600"></div>
<div class="flex items-center gap-1.5">
<span class="font-medium text-xs text-gray-600 dark:text-gray-400">DMARC</span>
${getStatusIcon(dmarc.status)}
</div>
</div>
</div>
<!-- Right: Quick Stats (max 280px) - Right aligned -->
<div class="flex items-center justify-end gap-4 text-xs min-w-0">
<div class="text-right min-w-0">
<p class="text-gray-500 dark:text-gray-400 text-xs">Mailboxes</p>
<p class="font-semibold text-gray-900 dark:text-white truncate">${domain.mboxes_in_domain}/${domain.max_num_mboxes_for_domain}</p>
</div>
<div class="text-right min-w-0">
<p class="text-gray-500 dark:text-gray-400 text-xs">Aliases</p>
<p class="font-semibold text-gray-900 dark:text-white truncate">${domain.aliases_in_domain}/${domain.max_num_aliases_for_domain}</p>
</div>
<div class="text-right min-w-0">
<p class="text-gray-500 dark:text-gray-400 text-xs">Storage</p>
<p class="font-semibold text-gray-900 dark:text-white truncate">${formatBytes(domain.bytes_total)}</p>
</div>
</div>
</div>
<!-- Mobile/Tablet Layout (below lg) -->
<div class="flex lg:hidden items-start justify-between gap-3">
<!-- Left: Expand Icon + Domain Name + Status -->
<div class="flex items-center gap-3 min-w-0 flex-1">
<svg id="${domainId}-icon-mobile" class="w-5 h-5 text-gray-400 transition-transform flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
<div class="min-w-0">
<h3 class="text-base font-bold text-gray-900 dark:text-white truncate">${escapeHtml(domain.domain_name)}</h3>
${domain.active ?
'<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 mt-1">Active</span>' :
'<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 mt-1">Inactive</span>'
}
</div>
</div>
<!-- Right: DNS Status (Vertical) -->
<div class="flex flex-col gap-0.5 text-right flex-shrink-0">
<div class="flex items-center justify-end gap-1.5">
<span class="font-medium text-xs text-gray-600 dark:text-gray-400">SPF:</span>
${getStatusIcon(spf.status)}
</div>
<div class="flex items-center justify-end gap-1.5">
<span class="font-medium text-xs text-gray-600 dark:text-gray-400">DKIM:</span>
${getStatusIcon(dkim.status)}
</div>
<div class="flex items-center justify-end gap-1.5">
<span class="font-medium text-xs text-gray-600 dark:text-gray-400">DMARC:</span>
${getStatusIcon(dmarc.status)}
</div>
</div>
</div>
</div>
<!-- Details Section - Hidden by default -->
<div id="${domainId}-details" class="hidden border-t border-gray-200 dark:border-gray-700">
<!-- Domain Stats -->
<div class="p-6 bg-gray-50 dark:bg-gray-700/30">
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 font-medium mb-1">Mailboxes</p>
<p class="text-lg font-bold text-gray-900 dark:text-white">${domain.mboxes_in_domain} / ${domain.max_num_mboxes_for_domain}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">${domain.mboxes_left} available</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 font-medium mb-1">Aliases</p>
<p class="text-lg font-bold text-gray-900 dark:text-white">${domain.aliases_in_domain} / ${domain.max_num_aliases_for_domain}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">${domain.aliases_left} available</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 font-medium mb-1">Storage Used</p>
<p class="text-lg font-bold text-gray-900 dark:text-white">${formatBytes(domain.bytes_total)}</p>
${domain.max_quota_for_domain > 0 ?
`<p class="text-xs text-gray-500 dark:text-gray-400">${formatBytes(domain.max_quota_for_domain)} max</p>` :
'<p class="text-xs text-gray-500 dark:text-gray-400">Unlimited</p>'
}
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 font-medium mb-1">Total Messages</p>
<p class="text-lg font-bold text-gray-900 dark:text-white">${domain.msgs_total.toLocaleString()}</p>
</div>
</div>
<!-- Additional Domain Info -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mt-4 pt-4 border-t border-gray-200 dark:border-gray-600">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 font-medium mb-1">Created Date</p>
<p class="text-sm font-semibold text-gray-900 dark:text-white">${domain.created ? formatDate(domain.created) : 'N/A'}</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 font-medium mb-1">Backup MX</p>
<p class="text-sm font-semibold text-gray-900 dark:text-white">${domain.backupmx == 1 ? 'Yes' : 'No'}</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 font-medium mb-1">Relay All Recipients</p>
<p class="text-sm font-semibold text-gray-900 dark:text-white">${domain.relay_all_recipients == 1 ? 'Yes' : 'No'}</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 font-medium mb-1">Relay Unknown Only</p>
<p class="text-sm font-semibold text-gray-900 dark:text-white">${domain.relay_unknown_only == 1 ? 'Yes' : 'No'}</p>
</div>
</div>
</div>
<!-- DNS Checks -->
<div class="p-6">
<div class="flex items-center justify-between mb-4">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path>
</svg>
DNS Security Records
</h4>
<div class="flex items-center gap-3">
<div class="text-right">
<p class="text-xs text-gray-500 dark:text-gray-400">Last checked:</p>
<p class="text-xs font-medium text-gray-900 dark:text-white">
${dns.checked_at ? formatTime(dns.checked_at) : '<span class="text-gray-400">Not checked</span>'}
</p>
</div>
<button
onclick="event.stopPropagation(); checkSingleDomainDNS('${escapeHtml(domain.domain_name)}')"
class="px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded transition flex items-center gap-1.5"
title="Check DNS for this domain">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
Check
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
${renderDNSCheck('SPF', spf)}
${renderDNSCheck('DKIM', dkim)}
${renderDNSCheck('DMARC', dmarc)}
</div>
</div>
</div>
</div>
`;
}
// Toggle domain details accordion
function toggleDomainDetails(domainId) {
const details = document.getElementById(`${domainId}-details`);
const iconDesktop = document.getElementById(`${domainId}-icon-desktop`);
const iconMobile = document.getElementById(`${domainId}-icon-mobile`);
if (details.classList.contains('hidden')) {
details.classList.remove('hidden');
if (iconDesktop) iconDesktop.style.transform = 'rotate(90deg)';
if (iconMobile) iconMobile.style.transform = 'rotate(90deg)';
} else {
details.classList.add('hidden');
if (iconDesktop) iconDesktop.style.transform = 'rotate(0deg)';
if (iconMobile) iconMobile.style.transform = 'rotate(0deg)';
}
}
function renderDNSCheck(type, check) {
const statusColors = {
'success': 'border-green-500 bg-green-50 dark:bg-green-900/20',
'warning': 'border-amber-500 bg-amber-50 dark:bg-amber-900/20',
'error': 'border-red-500 bg-red-50 dark:bg-red-900/20',
'unknown': 'border-gray-300 bg-gray-50 dark:bg-gray-800'
};
const statusTextColors = {
'success': 'text-green-700 dark:text-green-400',
'warning': 'text-amber-700 dark:text-amber-400',
'error': 'text-red-700 dark:text-red-400',
'unknown': 'text-gray-600 dark:text-gray-400'
};
const statusIcons = {
'success': '<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>',
'warning': '<svg class="w-5 h-5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path></svg>',
'error': '<svg class="w-5 h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>',
'unknown': '<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>'
};
const status = check.status || 'unknown';
return `
<div class="border ${statusColors[status]} rounded-lg p-4">
<div class="flex items-start justify-between mb-2">
<h5 class="text-sm font-semibold text-gray-900 dark:text-white">${type}</h5>
${statusIcons[status]}
</div>
<p class="text-sm ${statusTextColors[status]} font-medium mb-2">${escapeHtml(check.message || 'No information')}</p>
${check.record || check.actual_record ? `
<details class="mt-3">
<summary class="text-xs text-gray-600 dark:text-gray-400 cursor-pointer hover:text-gray-900 dark:hover:text-gray-200 font-medium">
View Record
</summary>
<div class="mt-2 p-2 bg-white dark:bg-gray-900 rounded border border-gray-200 dark:border-gray-700">
${check.dkim_domain ? `
<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">
<span class="font-medium">Record Name:</span>
<span class="font-mono text-gray-700 dark:text-gray-300">${escapeHtml(check.dkim_domain)}</span>
</p>
` : ''}
<code class="text-xs text-gray-700 dark:text-gray-300 break-all block leading-relaxed">${escapeHtml(check.record || check.actual_record)}</code>
</div>
</details>
` : ''}
${check.warnings && check.warnings.length > 0 ? `
<div class="mt-3 space-y-1">
${check.warnings.map(warning => `
<div class="flex items-start gap-2 text-xs ${statusTextColors['warning']}">
<svg class="w-3 h-3 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
<span>${escapeHtml(warning)}</span>
</div>
`).join('')}
</div>
` : ''}
${check.info && check.info.length > 0 ? `
<div class="mt-3 space-y-1">
${check.info.map(info => `
<div class="text-xs text-gray-600 dark:text-gray-400 px-2 py-1 bg-gray-50 dark:bg-gray-800/50 rounded">
${escapeHtml(info)}
</div>
`).join('')}
</div>
` : ''}
${check.status === 'error' && check.expected_record ? `
<details class="mt-3">
<summary class="text-xs text-gray-600 dark:text-gray-400 cursor-pointer hover:text-gray-900 dark:hover:text-gray-200 font-medium">
Expected Value
</summary>
<div class="mt-2 p-2 bg-white dark:bg-gray-900 rounded border border-gray-200 dark:border-gray-700">
<code class="text-xs text-gray-700 dark:text-gray-300 break-all block leading-relaxed">${escapeHtml(check.expected_record)}</code>
</div>
</details>
` : ''}
</div>
`;
}
let dnsCheckInProgress = false;
async function checkAllDomainsDNS() {
if (dnsCheckInProgress) {
showToast('DNS check already in progress', 'warning');
return;
}
const button = document.getElementById('check-all-dns-btn');
if (button) {
button.disabled = true;
button.innerHTML = '<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg> Checking...';
}
dnsCheckInProgress = true;
try {
const response = await authenticatedFetch('/api/domains/check-all-dns', {
method: 'POST'
});
const result = await response.json();
if (result.status === 'success') {
showToast(`✓ Checked ${result.domains_checked} domains`, 'success');
setTimeout(() => loadDomains(), 1000);
} else {
showToast('DNS check failed', 'error');
}
} catch (error) {
console.error('Failed:', error);
showToast('Failed to check DNS', 'error');
} finally {
dnsCheckInProgress = false;
if (button) {
button.disabled = false;
button.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg> Check Now';
}
}
}
async function checkSingleDomainDNS(domainName) {
if (dnsCheckInProgress) {
showToast('DNS check already in progress', 'warning');
return;
}
dnsCheckInProgress = true;
showToast(`Checking DNS for ${domainName}...`, 'info');
// Find and update the button
const domainId = `domain-${domainName.replace(/\./g, '-')}`;
const detailsDiv = document.getElementById(`${domainId}-details`);
try {
const response = await authenticatedFetch(`/api/domains/${encodeURIComponent(domainName)}/check-dns`, {
method: 'POST'
});
const result = await response.json();
if (result.status === 'success') {
showToast(`✓ DNS checked for ${domainName}`, 'success');
// Update only this domain's DNS section
if (detailsDiv) {
const dnsSection = detailsDiv.querySelector('.p-6:last-child');
if (dnsSection) {
// Get updated domain data
const domainsResponse = await authenticatedFetch('/api/domains/all');
const domainsData = await domainsResponse.json();
const updatedDomain = domainsData.domains.find(d => d.domain_name === domainName);
if (updatedDomain) {
// Re-render just the DNS section
const dns = updatedDomain.dns_checks || {};
const spf = dns.spf || { status: 'unknown', message: 'Not checked' };
const dkim = dns.dkim || { status: 'unknown', message: 'Not checked' };
const dmarc = dns.dmarc || { status: 'unknown', message: 'Not checked' };
dnsSection.innerHTML = `
<div class="flex items-center justify-between mb-4">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path>
</svg>
DNS Security Records
</h4>
<div class="flex items-center gap-3">
<div class="text-right">
<p class="text-xs text-gray-500 dark:text-gray-400">Last checked:</p>
<p class="text-xs font-medium text-gray-900 dark:text-white">
${dns.checked_at ? formatTime(dns.checked_at) : '<span class="text-gray-400">Not checked</span>'}
</p>
</div>
<button
data-domain="${escapeHtml(updatedDomain.domain_name)}"
onclick="event.stopPropagation(); checkSingleDomainDNS(this.dataset.domain)"
class="px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded transition flex items-center gap-1.5"
title="Check DNS for this domain">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
Check
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
${renderDNSCheck('SPF', spf)}
${renderDNSCheck('DKIM', dkim)}
${renderDNSCheck('DMARC', dmarc)}
</div>
`;
// Update inline badges in summary row
const summaryRow = document.querySelector(`[onclick*="toggleDomainDetails('${domainId}')"]`);
if (summaryRow) {
const getStatusIcon = (status) => {
if (status === 'success') return '<span class="text-green-500" title="OK">✓</span>';
if (status === 'warning') return '<span class="text-amber-500" title="Warning">⚠</span>';
if (status === 'error') return '<span class="text-red-500" title="Error">✗</span>';
return '<span class="text-gray-400" title="Unknown">?</span>';
};
const badgesContainer = summaryRow.querySelector('.flex.items-center.gap-2.text-base');
if (badgesContainer) {
badgesContainer.innerHTML = `
<span class="flex items-center gap-1">
<span class="text-xs text-gray-500 dark:text-gray-400">SPF:</span>
${getStatusIcon(spf.status)}
</span>
<span class="flex items-center gap-1">
<span class="text-xs text-gray-500 dark:text-gray-400">DKIM:</span>
${getStatusIcon(dkim.status)}
</span>
<span class="flex items-center gap-1">
<span class="text-xs text-gray-500 dark:text-gray-400">DMARC:</span>
${getStatusIcon(dmarc.status)}
</span>
`;
}
}
}
}
}
} else {
showToast(`Failed to check DNS for ${domainName}`, 'error');
}
} catch (error) {
console.error('Failed:', error);
showToast('Failed to check DNS', 'error');
} finally {
dnsCheckInProgress = false;
}
}
function formatBytes(bytes) {
if (bytes === 0 || bytes === '0') return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// =============================================================================
// SETTINGS PAGE
// =============================================================================
async function loadSettings() {
const loading = document.getElementById('settings-loading');
const content = document.getElementById('settings-content');
if (!loading || !content) {
console.error('Settings elements not found');
return;
}
loading.classList.remove('hidden');
content.classList.add('hidden');
try {
// Load settings info first (most important)
const settingsResponse = await authenticatedFetch('/api/settings/info');
if (!settingsResponse.ok) {
throw new Error(`HTTP ${settingsResponse.status}`);
}
const data = await settingsResponse.json();
// Use cached version info if available to show page immediately
if (versionInfoCache.app_version) {
data.app_version = versionInfoCache.app_version;
}
if (versionInfoCache.version_info) {
data.version_info = versionInfoCache.version_info;
}
// Render settings immediately with cached or default data
renderSettings(content, data);
loading.classList.add('hidden');
content.classList.remove('hidden');
// Load app info and version status in parallel (non-blocking)
(async () => {
try {
const [appInfoResponse, versionResponse] = await Promise.all([
authenticatedFetch('/api/info'),
authenticatedFetch('/api/status/app-version')
]);
const appInfo = appInfoResponse.ok ? await appInfoResponse.json() : null;
const versionInfo = versionResponse.ok ? await versionResponse.json() : null;
// Update cache
if (appInfo) {
versionInfoCache.app_version = appInfo.version;
}
if (versionInfo) {
versionInfoCache.version_info = versionInfo;
}
// Update UI with fresh data
if (appInfo || versionInfo) {
const currentData = { ...data };
if (appInfo) {
currentData.app_version = appInfo.version;
}
if (versionInfo) {
currentData.version_info = versionInfo;
}
renderSettings(content, currentData);
}
} catch (error) {
console.error('Failed to load version info:', error);
// Page is already shown, so just log the error
}
})();
} catch (error) {
console.error('Failed to load settings:', error);
loading.innerHTML = `
<div class="text-center py-12">
<svg class="w-16 h-16 mx-auto text-red-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<p class="text-red-500">Failed to load settings</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2">${error.message}</p>
</div>
`;
}
}
function updateVersionInfoUI(versionInfo) {
// Find the container with Latest Version by searching for the label
const allContainers = document.querySelectorAll('#settings-content .p-4.bg-gray-50');
let latestVersionContainer = null;
for (const container of allContainers) {
const label = container.querySelector('.text-xs.uppercase');
if (label && label.textContent.trim() === 'LATEST VERSION') {
latestVersionContainer = container;
break;
}
}
if (!latestVersionContainer) {
return;
}
// Update version text
const versionTextEl = latestVersionContainer.querySelector('.text-lg.font-semibold');
if (versionTextEl) {
versionTextEl.textContent = versionInfo.latest_version ? `v${versionInfo.latest_version}` : 'Checking...';
}
// Update last_checked date
const badgeContainer = latestVersionContainer.querySelector('.flex.items-center');
if (badgeContainer) {
// Find or create last_checked span
let lastCheckedSpan = Array.from(badgeContainer.querySelectorAll('span.text-xs.text-gray-500, span.text-xs.text-gray-400'))
.find(span => span.textContent.includes('Last checked'));
if (versionInfo.last_checked) {
if (!lastCheckedSpan) {
lastCheckedSpan = document.createElement('span');
lastCheckedSpan.className = 'text-xs text-gray-500 dark:text-gray-400';
const button = badgeContainer.querySelector('button');
if (button) {
badgeContainer.insertBefore(lastCheckedSpan, button);
} else {
badgeContainer.appendChild(lastCheckedSpan);
}
}
lastCheckedSpan.textContent = `(Last checked: ${formatDate(versionInfo.last_checked)})`;
} else if (lastCheckedSpan) {
lastCheckedSpan.remove();
}
// Remove existing badges (but keep the button and last_checked span)
const existingBadges = Array.from(badgeContainer.querySelectorAll('span.px-2.py-1.rounded.text-xs'));
existingBadges.forEach(badge => {
badge.remove();
});
// Add new badge if needed
if (versionInfo.update_available) {
const badge = document.createElement('span');
badge.className = 'px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 rounded text-xs font-medium';
badge.textContent = 'Update Available';
const button = badgeContainer.querySelector('button');
if (button) {
badgeContainer.insertBefore(badge, button);
} else {
badgeContainer.appendChild(badge);
}
} else if (versionInfo.latest_version && !versionInfo.update_available) {
const badge = document.createElement('span');
badge.className = 'px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 rounded text-xs font-medium';
badge.textContent = 'Up to Date';
const button = badgeContainer.querySelector('button');
if (button) {
badgeContainer.insertBefore(badge, button);
} else {
badgeContainer.appendChild(badge);
}
}
}
// Update or create update message
const versionSection = latestVersionContainer.closest('.bg-white, .dark\\:bg-gray-800');
if (versionSection) {
// Remove existing update message
const existingMessages = versionSection.querySelectorAll('.bg-green-50, .dark\\:bg-green-900\\/20');
existingMessages.forEach(msg => {
if (msg.textContent.includes('Update available')) {
msg.remove();
}
});
// Add new update message if update is available
if (versionInfo.update_available) {
const gridContainer = versionSection.querySelector('.grid.grid-cols-1');
if (gridContainer && gridContainer.parentNode) {
const messageDiv = document.createElement('div');
messageDiv.className = 'mt-4 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg';
messageDiv.innerHTML = `
<p class="text-sm text-green-800 dark:text-green-300">
<strong>Update available!</strong> A new version (v${versionInfo.latest_version}) is available on GitHub.
</p>
${versionInfo.changelog ? `
<div class="mt-3 border border-green-200 dark:border-green-800 rounded p-3 bg-white dark:bg-gray-800">
<p class="text-xs font-semibold text-green-800 dark:text-green-300 mb-2">Changelog:</p>
<div class="update-changelog-content markdown-body" style="max-height: 16rem; overflow-y: auto; overflow-x: hidden; display: block;"></div>
</div>
` : ''}
<a href="https://github.com/ShlomiPorush/mailcow-logs-viewer/releases/latest" target="_blank" rel="noopener noreferrer" class="text-sm text-green-600 dark:text-green-400 hover:underline mt-2 inline-block">
View release notes →
</a>
`;
gridContainer.parentNode.insertBefore(messageDiv, gridContainer.nextSibling);
// Render markdown in changelog if marked.js is available
// Do this immediately after inserting to DOM
if (typeof marked !== 'undefined' && versionInfo.changelog) {
marked.setOptions({
breaks: true,
gfm: true
});
const changelogEl = messageDiv.querySelector('.update-changelog-content');
if (changelogEl && versionInfo.changelog) {
// Use the full changelog text directly
changelogEl.innerHTML = marked.parse(versionInfo.changelog);
}
}
}
}
}
}
function renderSettings(content, data) {
const config = data.configuration || {};
const appVersion = data.app_version || 'Unknown';
const versionInfo = data.version_info || {};
content.innerHTML = `
<!-- Version Information Section -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 mb-6">
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
</svg>
Version Information
</h3>
</div>
<div class="p-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="p-4 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-2">Current Version</p>
<div class="flex items-center gap-2">
<p id="current-version-text" class="text-lg font-semibold text-gray-900 dark:text-white cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors" title="Click to view changelog">v${appVersion}</p>
<svg class="w-4 h-4 text-blue-500 dark:text-blue-400 cursor-pointer" fill="none" stroke="currentColor" viewBox="0 0 24 24" title="Click to view changelog">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
</div>
<div class="p-4 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-2">Latest Version</p>
<div class="flex items-center gap-2 flex-wrap">
<p class="text-lg font-semibold text-gray-900 dark:text-white">${versionInfo.latest_version ? `v${versionInfo.latest_version}` : 'Checking...'}</p>
${versionInfo.last_checked ? `
<span class="text-xs text-gray-500 dark:text-gray-400">
(Last checked: ${formatDate(versionInfo.last_checked)})
</span>
` : ''}
${versionInfo.update_available ? `
<span class="px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 rounded text-xs font-medium">
Update Available
</span>
` : versionInfo.latest_version && !versionInfo.update_available ? `
<span class="px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 rounded text-xs font-medium">
Up to Date
</span>
` : ''}
<button id="check-version-btn" class="px-3 py-1.5 bg-blue-500 hover:bg-blue-600 text-white rounded text-xs font-medium transition-colors duration-200 flex items-center gap-1.5 disabled:opacity-50 disabled:cursor-not-allowed">
<svg id="check-version-icon" class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
<span id="check-version-text">Check Now</span>
</button>
</div>
</div>
</div>
${versionInfo.update_available ? `
<div class="mt-4 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<p class="text-sm text-green-800 dark:text-green-300">
<strong>Update available!</strong> A new version (v${versionInfo.latest_version}) is available on GitHub.
</p>
${versionInfo.changelog ? `
<div class="mt-3 border border-green-200 dark:border-green-800 rounded p-3 bg-white dark:bg-gray-800">
<p class="text-xs font-semibold text-green-800 dark:text-green-300 mb-2">Changelog:</p>
<div class="update-changelog-content markdown-body" style="max-height: 16rem; overflow-y: auto; overflow-x: hidden; display: block;"></div>
</div>
` : ''}
<a href="https://github.com/ShlomiPorush/mailcow-logs-viewer/releases/latest" target="_blank" rel="noopener noreferrer" class="text-sm text-green-600 dark:text-green-400 hover:underline mt-2 inline-block">
View release notes →
</a>
</div>
` : ''}
</div>
</div>
<!-- Configuration Section -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
Configuration
</h3>
</div>
<div class="p-4">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="p-3 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Mailcow URL</p>
<p class="text-sm text-gray-900 dark:text-white mt-1 font-mono break-all">${escapeHtml(config.mailcow_url || 'N/A')}</p>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Server IP</p>
<p class="text-sm text-gray-900 dark:text-white mt-1 font-mono">
${config.server_ip ?
`<span class="inline-flex items-center gap-1.5">
<svg class="w-3.5 h-3.5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
${escapeHtml(config.server_ip)}
</span>`
: '<span class="text-gray-400">Not available</span>'
}
</p>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Authentication</p>
<p class="text-sm text-gray-900 dark:text-white mt-1">
${config.auth_enabled ?
`<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
</svg>
Enabled
</span>` :
`<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400">
Disabled
</span>`
}
</p>
${config.auth_enabled && config.auth_username ?
`<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Username: ${escapeHtml(config.auth_username)}</p>` :
''
}
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-700/30 rounded-lg ${config.local_domains && config.local_domains.length > 0 ? 'col-span-1 md:col-span-2 lg:col-span-3' : ''}">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-2">
Local Domains
${config.local_domains && config.local_domains.length > 0 ?
`<span class="ml-1 text-gray-400 dark:text-gray-500 font-normal">(${config.local_domains.length})</span>` :
''
}
</p>
${config.local_domains && config.local_domains.length > 0 ?
`<div class="mt-2 max-h-64 overflow-y-auto">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
${config.local_domains.map(domain => `
<div class="text-sm text-gray-900 dark:text-white font-mono px-3 py-1.5 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-600 truncate" title="${escapeHtml(domain)}">
${escapeHtml(domain)}
</div>
`).join('')}
</div>
</div>` :
'<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">N/A</p>'
}
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Fetch Interval</p>
<p class="text-sm text-gray-900 dark:text-white mt-1">${config.fetch_interval || 0} seconds</p>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Fetch Count (Postfix)</p>
<p class="text-sm text-gray-900 dark:text-white mt-1">${config.fetch_count_postfix || config.fetch_count || 0} per request</p>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Fetch Count (Rspamd)</p>
<p class="text-sm text-gray-900 dark:text-white mt-1">${config.fetch_count_rspamd || config.fetch_count || 0} per request</p>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Fetch Count (Netfilter)</p>
<p class="text-sm text-gray-900 dark:text-white mt-1">${config.fetch_count_netfilter || config.fetch_count || 0} per request</p>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Retention</p>
<p class="text-sm text-gray-900 dark:text-white mt-1">${config.retention_days || 0} days</p>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Max Correlation Age</p>
<p class="text-sm text-gray-900 dark:text-white mt-1">${config.max_correlation_age_minutes || 10} minutes</p>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Correlation Check</p>
<p class="text-sm text-gray-900 dark:text-white mt-1">${config.correlation_check_interval || 120} seconds</p>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Timezone</p>
<p class="text-sm text-gray-900 dark:text-white mt-1">${escapeHtml(config.timezone || 'N/A')}</p>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Log Level</p>
<p class="text-sm text-gray-900 dark:text-white mt-1">${config.log_level || 'INFO'}</p>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Blacklist</p>
<p class="text-sm text-gray-900 dark:text-white mt-1">${config.blacklist_enabled ? `Enabled (${config.blacklist_count} emails)` : 'Disabled'}</p>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Scheduler Workers</p>
<p class="text-sm text-gray-900 dark:text-white mt-1">${config.scheduler_workers || 4}</p>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">MaxMind Status</p>
<p class="text-sm text-gray-900 dark:text-white mt-1">
${renderMaxMindStatus(data.configuration.maxmind_status)}
</p>
</div>
</div>
</div>
</div>
<!-- Global SMTP Configuration -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
</svg>
Global SMTP Configuration
</h3>
</div>
<div class="p-4">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="p-4 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-2">SMTP Enabled</p>
<div class="flex items-center gap-2 flex-wrap">
${data.smtp_configuration?.enabled ?
`<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
</svg>
Enabled
</span>` :
`<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400">Disabled</span>`
}
<button onclick="testSmtpConnection()" class="px-3 py-1.5 bg-blue-500 hover:bg-blue-600 text-white rounded text-xs font-medium transition-colors duration-200 flex items-center gap-1.5">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span>Test SMTP</span>
</button>
</div>
</div>
${data.smtp_configuration?.enabled ? `
<div class="p-4 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-2">Server</p>
<p class="text-sm text-gray-900 dark:text-white font-mono">${data.smtp_configuration.host}:${data.smtp_configuration.port}</p>
</div>
<div class="p-4 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-2">Admin Email</p>
<p class="text-sm text-gray-900 dark:text-white font-mono">${data.smtp_configuration.admin_email || 'N/A'}</p>
</div>
` : ''}
</div>
</div>
</div>
<!-- DMARC Management -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path>
</svg>
DMARC Management
</h3>
</div>
<div class="p-4">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="p-4 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-2">IMAP Auto-Import</p>
<div class="flex items-center gap-2 flex-wrap">
${data.dmarc_configuration?.imap_sync_enabled ?
`<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
</svg>
Enabled
</span>` :
`<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400">Disabled</span>`
}
<button onclick="testImapConnection()" class="px-3 py-1.5 bg-blue-500 hover:bg-blue-600 text-white rounded text-xs font-medium transition-colors duration-200 flex items-center gap-1.5">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span>Test IMAP</span>
</button>
</div>
</div>
<div class="p-4 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-2">Manual Upload</p>
<p class="text-sm text-gray-900 dark:text-white">
${data.dmarc_configuration?.manual_upload_enabled ?
`<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
</svg>
Enabled
</span>` :
`<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400">Disabled</span>`
}
</p>
</div>
${data.dmarc_configuration?.imap_sync_enabled ? `
<div class="p-4 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-2">IMAP Server</p>
<p class="text-sm text-gray-900 dark:text-white font-mono">${data.dmarc_configuration.imap_host || 'N/A'}</p>
</div>
` : ''}
</div>
</div>
</div>
`;
// Add event listener for version number click (changelog popup)
const currentVersionText = document.getElementById('current-version-text');
const currentVersionIcon = currentVersionText?.parentElement?.querySelector('svg');
const loadCurrentVersionChangelog = async () => {
try {
// Remove 'v' prefix if present for API call
const versionForApi = appVersion.startsWith('v') ? appVersion.substring(1) : appVersion;
const response = await authenticatedFetch(`/api/status/app-version/changelog/${versionForApi}`);
if (response.ok) {
const data = await response.json();
showChangelogModal(data.changelog || 'No changelog available');
} else {
showChangelogModal('Failed to load changelog');
}
} catch (error) {
console.error('Failed to load changelog:', error);
showChangelogModal('Failed to load changelog');
}
};
if (currentVersionText) {
currentVersionText.onclick = loadCurrentVersionChangelog;
}
if (currentVersionIcon) {
currentVersionIcon.onclick = loadCurrentVersionChangelog;
}
// Render markdown in changelog sections if marked.js is available
// Use versionInfo from the data object directly instead of data attributes
if (typeof marked !== 'undefined' && versionInfo && versionInfo.changelog) {
marked.setOptions({
breaks: true,
gfm: true
});
const changelogElements = content.querySelectorAll('.update-changelog-content');
changelogElements.forEach(el => {
// Use the changelog directly from versionInfo object
const changelogText = versionInfo.changelog;
if (changelogText) {
el.innerHTML = marked.parse(changelogText);
}
});
}
// Add event listener for version check button
const checkVersionBtn = document.getElementById('check-version-btn');
if (checkVersionBtn) {
// Use onclick to avoid duplicate listeners (simpler approach)
checkVersionBtn.onclick = async () => {
const btn = checkVersionBtn;
const icon = document.getElementById('check-version-icon');
const text = document.getElementById('check-version-text');
// Disable button and show loading state
btn.disabled = true;
if (icon) {
icon.classList.add('animate-spin');
}
if (text) {
text.textContent = 'Checking...';
}
try {
// Force check for updates
const response = await authenticatedFetch('/api/status/app-version?force=true');
const versionInfo = await response.json();
// Update cache
versionInfoCache.version_info = versionInfo;
// Update UI directly without reloading the page
updateVersionInfoUI(versionInfo);
// Show success state - green button with "Done"
btn.classList.remove('bg-blue-500', 'hover:bg-blue-600');
btn.classList.add('bg-green-500', 'hover:bg-green-600');
if (text) {
text.textContent = 'Done';
}
if (icon) {
icon.classList.remove('animate-spin');
// Change icon to checkmark
const path = icon.querySelector('path');
if (path) {
path.setAttribute('d', 'M5 13l4 4L19 7');
}
}
// Re-enable button immediately after success (but keep green color)
btn.disabled = false;
// Reset button after 3 seconds
setTimeout(() => {
btn.classList.remove('bg-green-500', 'hover:bg-green-600');
btn.classList.add('bg-blue-500', 'hover:bg-blue-600');
if (text) {
text.textContent = 'Check Now';
}
if (icon) {
const path = icon.querySelector('path');
if (path) {
path.setAttribute('d', 'M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15');
}
}
}, 3000);
} catch (error) {
console.error('Failed to check version:', error);
// Show error message
btn.classList.remove('bg-blue-500', 'hover:bg-blue-600');
btn.classList.add('bg-red-500', 'hover:bg-red-600');
if (text) {
text.textContent = 'Error';
}
if (icon) {
icon.classList.remove('animate-spin');
}
// Reset button after 2 seconds
setTimeout(() => {
btn.classList.remove('bg-red-500', 'hover:bg-red-600');
btn.classList.add('bg-blue-500', 'hover:bg-blue-600');
if (text) {
text.textContent = 'Check Now';
}
if (icon) {
const path = icon.querySelector('path');
if (path) {
path.setAttribute('d', 'M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15');
}
}
btn.disabled = false;
}, 2000);
}
};
}
}
function renderMaxMindStatus(status) {
if (!status || !status.configured) {
return `
<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400">
Not configured
</span>
`;
}
if (status.valid) {
return `
<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
</svg>
Configured
</span>
`;
}
return `
<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path>
</svg>
${escapeHtml(status.error || 'Invalid')}
</span>
`;
}
function renderImportCard(title, data, color) {
if (!data) {
return `<div class="p-4 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<p class="font-semibold text-gray-900 dark:text-white">${title}</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2">No data</p>
</div>`;
}
const colorClasses = {
blue: 'border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/20',
purple: 'border-purple-200 dark:border-purple-800 bg-purple-50 dark:bg-purple-900/20',
red: 'border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20'
};
return `
<div class="p-4 border ${colorClasses[color]} rounded-lg">
<p class="font-semibold text-gray-900 dark:text-white mb-3">${title}</p>
<div class="space-y-2 text-sm">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">Last Fetch Run</p>
<p class="text-gray-900 dark:text-white font-medium">${data.last_fetch_run ? formatTime(data.last_fetch_run) : 'Never'}</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">Last Import</p>
<p class="text-gray-900 dark:text-white">${data.last_import ? formatTime(data.last_import) : 'Never'}</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">Total Entries</p>
<p class="text-gray-900 dark:text-white font-semibold">${(data.total_entries || 0).toLocaleString()}</p>
</div>
${data.oldest_entry ? `
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">Oldest Entry</p>
<p class="text-gray-900 dark:text-white">${formatTime(data.oldest_entry)}</p>
</div>
` : ''}
</div>
</div>
`;
}
function renderJobCard(name, job) {
if (!job) {
return '';
}
let statusBadge = '';
switch (job.status) {
case 'running':
statusBadge = '<span class="px-2 py-1 text-xs font-medium rounded bg-blue-500 text-white">running</span>';
break;
case 'success':
statusBadge = '<span class="px-2 py-1 text-xs font-medium rounded bg-green-600 dark:bg-green-500 text-white">success</span>';
break;
case 'failed':
statusBadge = '<span class="px-2 py-1 text-xs font-medium rounded bg-red-600 dark:bg-red-500 text-white">failed</span>';
break;
case 'scheduled':
statusBadge = '<span class="px-2 py-1 text-xs font-medium rounded bg-purple-600 dark:bg-purple-500 text-white">scheduled</span>';
break;
default:
statusBadge = '<span class="px-2 py-1 text-xs font-medium rounded bg-gray-500 text-white">idle</span>';
}
return `
<div class="p-3 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<div class="flex items-start justify-between gap-3 mb-2">
<div class="flex-1 min-w-0">
<h4 class="font-semibold text-gray-900 dark:text-white text-sm">${name}</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">${job.description || ''}</p>
</div>
${statusBadge}
</div>
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-600 dark:text-gray-400">
${job.interval ? `<span>⏱ ${job.interval}</span>` : ''}
${job.schedule ? `<span>📅 ${job.schedule}</span>` : ''}
${job.retention ? `<span>🗂 ${job.retention}</span>` : ''}
${job.max_age ? `<span>⏳ Max: ${job.max_age}</span>` : ''}
${job.expire_after ? `<span>⏱ Expire: ${job.expire_after}</span>` : ''}
${job.pending_items !== undefined ? `<span class="font-medium text-yellow-600 dark:text-yellow-400">📋 Pending: ${job.pending_items}</span>` : ''}
</div>
${job.last_run ? `
<div class="mt-2 pt-2 border-t border-gray-200 dark:border-gray-600">
<p class="text-xs text-gray-500 dark:text-gray-400">
Last run: <span class="text-gray-900 dark:text-white font-medium">${formatTime(job.last_run)}</span>
</p>
</div>
` : ''}
${job.error ? `
<div class="mt-2 p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded">
<p class="text-xs text-red-700 dark:text-red-300 font-mono break-all">${escapeHtml(job.error)}</p>
</div>
` : ''}
</div>
`;
}
function showToast(message, type = 'info') {
// Remove existing toast if any
const existingToast = document.getElementById('toast-notification');
if (existingToast) {
existingToast.remove();
}
const colors = {
'success': 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 border-green-500',
'error': 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200 border-red-500',
'warning': 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200 border-yellow-500',
'info': 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border-blue-500'
};
const icons = {
'success': '✓',
'error': '✗',
'warning': '⚠',
'info': ''
};
const toast = document.createElement('div');
toast.id = 'toast-notification';
toast.className = `fixed bottom-4 right-4 z-50 ${colors[type]} border-l-4 p-4 rounded shadow-lg max-w-md animate-slide-in`;
toast.innerHTML = `
<div class="flex items-start gap-3">
<span class="text-xl font-bold flex-shrink-0">${icons[type]}</span>
<p class="text-sm flex-1">${message}</p>
<button onclick="this.parentElement.parentElement.remove()" class="text-lg font-bold hover:opacity-70 flex-shrink-0">×</button>
</div>
`;
document.body.appendChild(toast);
// Auto-remove after 4 seconds
setTimeout(() => {
if (toast.parentElement) {
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.3s';
setTimeout(() => toast.remove(), 300);
}
}, 4000);
}
// =============================================================================
// DMARC PAGE
// =============================================================================
// DMARC Navigation State
let dmarcState = {
currentView: 'domains',
currentDomain: null,
currentSubTab: 'reports',
currentReportDate: null,
currentSourceIp: null,
chartInstance: null,
// Breadcrumb tracking: { label: string, action: function or null }
breadcrumb: [],
detailType: null // 'report', 'source', 'tls'
};
// Update breadcrumb display
function updateDmarcBreadcrumb() {
const container = document.getElementById('dmarc-breadcrumb');
if (!container) return;
if (dmarcState.breadcrumb.length === 0) {
container.innerHTML = '';
container.classList.add('hidden');
return;
}
container.classList.remove('hidden');
// Display as horizontal flex row
container.innerHTML = `<div class="flex items-center flex-wrap gap-1 text-sm">
${dmarcState.breadcrumb.map((item, idx) => {
const isLast = idx === dmarcState.breadcrumb.length - 1;
const separator = idx > 0 ? '<svg class="w-3 h-3 text-gray-400 mx-1 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>' : '';
if (isLast) {
return `${separator}<span class="text-gray-600 dark:text-gray-300">${escapeHtml(item.label)}</span>`;
} else {
return `${separator}<button onclick="${item.action}" class="text-blue-600 dark:text-blue-400 hover:underline">${escapeHtml(item.label)}</button>`;
}
}).join('')}
</div>`;
}
// Set breadcrumb for different views (without "DMARC Reports" since title is static)
function setDmarcBreadcrumb(type, data = {}) {
switch (type) {
case 'domains':
// On domains list, no breadcrumb needed (we're at root)
dmarcState.breadcrumb = [];
break;
case 'domain':
// Just show domain name
dmarcState.breadcrumb = [
{ label: data.domain, action: null }
];
break;
case 'reportDetails':
dmarcState.breadcrumb = [
{ label: data.domain, action: `loadDomainOverview('${data.domain}')` },
{ label: 'Daily Reports', action: `loadDomainOverview('${data.domain}'); setTimeout(() => dmarcSwitchSubTab('reports'), 100)` },
{ label: data.date, action: null }
];
break;
case 'sourceDetails':
dmarcState.breadcrumb = [
{ label: data.domain, action: `loadDomainOverview('${data.domain}')` },
{ label: 'Source IPs', action: `loadDomainOverview('${data.domain}'); setTimeout(() => dmarcSwitchSubTab('sources'), 100)` },
{ label: data.ip, action: null }
];
break;
case 'tlsDetails':
dmarcState.breadcrumb = [
{ label: data.domain, action: `loadDomainOverview('${data.domain}')` },
{ label: 'TLS Reports', action: `loadDomainOverview('${data.domain}'); setTimeout(() => dmarcSwitchSubTab('tls'), 100)` },
{ label: data.date, action: null }
];
break;
}
updateDmarcBreadcrumb();
}
async function loadDmarcSettings() {
try {
const response = await authenticatedFetch('/api/settings/info');
if (!response.ok) {
dmarcConfiguration = null;
return;
}
const data = await response.json();
dmarcConfiguration = data.dmarc_configuration || {};
console.log('DMARC settings loaded:', dmarcConfiguration);
} catch (error) {
console.error('Error loading DMARC settings:', error);
dmarcConfiguration = null;
}
}
async function loadDmarc() {
console.log('Loading DMARC tab...');
dmarcState.currentView = 'domains';
dmarcState.currentDomain = null;
dmarcState.detailType = null;
dmarcState.currentReportDate = null;
dmarcState.currentSourceIp = null;
// Destroy chart if exists
if (dmarcState.chartInstance) {
dmarcState.chartInstance.destroy();
dmarcState.chartInstance = null;
}
// Hide all sub-views and show main domains view
document.getElementById('dmarc-overview-view').classList.add('hidden');
document.getElementById('dmarc-report-details-view').classList.add('hidden');
document.getElementById('dmarc-source-details-view').classList.add('hidden');
document.getElementById('dmarc-domains-view').classList.remove('hidden');
document.getElementById('dmarc-page-title').textContent = 'DMARC Reports';
// Update breadcrumb
setDmarcBreadcrumb('domains');
await loadDmarcSettings();
await loadDmarcImapStatus();
await loadDmarcDomains();
}
/**
* Handle DMARC route based on URL params
* Called from switchTab when navigating to DMARC
* @param {Object} params - Route params { domain, type, id }
*/
async function handleDmarcRoute(params = {}) {
console.log('handleDmarcRoute called with:', params);
// If no domain specified, load domains list
if (!params.domain) {
await loadDmarc();
return;
}
// Load settings first if not loaded
if (!dmarcConfiguration) {
await loadDmarcSettings();
}
// Load IMAP status if not loaded
await loadDmarcImapStatus();
// If type is specified with an id, load that specific view
if (params.type && params.id) {
switch (params.type) {
case 'report':
// First load domain overview (don't update URL), then report details
await loadDomainOverview(params.domain, false);
await loadReportDetails(params.domain, params.id, false);
return;
case 'source':
// First load domain overview (don't update URL), then source details
await loadDomainOverview(params.domain, false);
await loadSourceDetails(params.domain, params.id, false);
return;
}
}
// Load the domain overview (don't update URL since we came from router)
await loadDomainOverview(params.domain, false);
// If type is specified (without id), navigate to sub-tab
if (params.type) {
switch (params.type) {
case 'reports':
dmarcSwitchSubTab('reports');
break;
case 'sources':
dmarcSwitchSubTab('sources');
break;
case 'tls':
dmarcSwitchSubTab('tls');
break;
}
}
}
function getFlagEmoji(countryCode) {
if (!countryCode || countryCode.length !== 2) return '🌍';
const codePoints = countryCode
.toUpperCase()
.split('')
.map(char => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
}
// =============================================================================
// DOMAINS LIST
// =============================================================================
function getPolicyBadgeClass(policy) {
switch (policy) {
case 'reject':
return 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300';
case 'quarantine':
return 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300';
case 'none':
default:
return 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300';
}
}
async function loadDmarcDomains() {
try {
const response = await authenticatedFetch('/api/dmarc/domains');
if (!response.ok) throw new Error('Failed to load domains');
const data = await response.json();
const domains = data.domains || [];
const totalMessages = domains.reduce((sum, d) => sum + (d.stats_30d?.total_messages || 0), 0);
const totalUniqueIps = domains.reduce((sum, d) => sum + (d.stats_30d?.unique_ips || 0), 0);
const totalPass = domains.reduce((sum, d) => {
const msgs = d.stats_30d?.total_messages || 0;
const pct = d.stats_30d?.dmarc_pass_pct || 0;
return sum + (msgs * pct / 100);
}, 0);
const overallPassPct = totalMessages > 0 ? Math.round((totalPass / totalMessages) * 100) : 0;
const mainStatsContainer = document.getElementById('dmarc-main-stats-container');
if (mainStatsContainer) {
mainStatsContainer.innerHTML = `
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-100 dark:border-gray-700">
<div class="flex items-center justify-between mb-2">
<h3 class="text-xs font-medium text-gray-500 dark:text-gray-400">Total Domains</h3>
<svg class="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path></svg>
</div>
<div class="text-2xl font-bold text-gray-900 dark:text-white">${data.total || 0}</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-100 dark:border-gray-700">
<div class="flex items-center justify-between mb-2">
<h3 class="text-xs font-medium text-gray-500 dark:text-gray-400">Total Messages</h3>
<svg class="w-6 h-6 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path></svg>
</div>
<div class="text-2xl font-bold text-gray-900 dark:text-white">${totalMessages.toLocaleString()}</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-100 dark:border-gray-700">
<div class="flex items-center justify-between mb-2">
<h3 class="text-xs font-medium text-gray-500 dark:text-gray-400">DMARC Pass</h3>
<svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
</div>
<div class="text-2xl font-bold text-green-600 dark:text-green-400">${overallPassPct}%</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-100 dark:border-gray-700">
<div class="flex items-center justify-between mb-2">
<h3 class="text-xs font-medium text-gray-500 dark:text-gray-400">Unique IPs</h3>
<svg class="w-6 h-6 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
</div>
<div class="text-2xl font-bold text-gray-900 dark:text-white">${totalUniqueIps.toLocaleString()}</div>
</div>
</div>
`;
}
const domainsList = document.getElementById('dmarc-domains-list');
if (domains.length === 0) {
domainsList.innerHTML = `<tr><td colspan="5" class="px-6 py-12 text-center text-gray-500 dark:text-gray-400 text-sm">No domains found in the reporting period.</td></tr>`;
return;
}
domainsList.innerHTML = domains.map(domain => {
const stats = domain.stats_30d || {};
const passRate = stats.dmarc_pass_pct || 0;
// Status colors
const passColor = passRate >= 95 ? 'text-green-500' : passRate >= 80 ? 'text-yellow-500' : 'text-red-500';
const barBg = passRate >= 95 ? 'bg-green-500' : passRate >= 80 ? 'bg-yellow-500' : 'bg-red-500';
const badgeBg = passRate >= 95 ? 'bg-green-900/30 text-green-400' : 'bg-red-900/30 text-red-400';
const firstDate = domain.first_report ? new Date(domain.first_report * 1000).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '-';
const lastDate = domain.last_report ? new Date(domain.last_report * 1000).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '-';
// Badge for TLS-only domains
const hasTls = domain.has_tls;
const hasDmarc = domain.has_dmarc !== false; // default true for backwards compat
const tlsBadge = hasTls && !hasDmarc ? '<span class="ml-2 inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"><svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg>TLS</span>' : '';
return `
<tr class="hidden md:table-row hover:bg-gray-50 dark:hover:bg-gray-700/30 cursor-pointer transition-colors" onclick="loadDomainOverview('${escapeHtml(domain.domain)}')">
<td class="px-6 py-4 border-r border-gray-200 dark:border-gray-700/50 text-base font-bold text-blue-600 dark:text-blue-400 hover:underline">
${escapeHtml(domain.domain)}${tlsBadge}
</td>
<td class="px-6 py-4 text-sm text-gray-600 dark:text-gray-400 border-r border-gray-200 dark:border-gray-700/50">
${firstDate} - ${lastDate}
</td>
<td class="px-6 py-4 text-center text-sm text-gray-900 dark:text-gray-100 border-r border-gray-200 dark:border-gray-700/50">
<div class="flex flex-col items-center gap-0.5">
${domain.report_count > 0 ? `<span title="DMARC Reports">${domain.report_count}</span>` : ''}
${domain.tls_report_count > 0 ? `<span class="text-xs text-green-600 dark:text-green-400" title="TLS Reports">+${domain.tls_report_count} TLS</span>` : ''}
${!domain.report_count && !domain.tls_report_count ? '0' : ''}
</div>
</td>
<td class="px-6 py-4 text-sm text-gray-900 dark:text-gray-100 font-bold border-r border-gray-200 dark:border-gray-700/50">
${(stats.total_messages || 0).toLocaleString()}
</td>
<td class="px-6 py-4 text-sm text-gray-900 dark:text-gray-100 font-bold border-r border-gray-200 dark:border-gray-700/50">
${stats.unique_ips || 0}
</td>
<td class="px-6 py-4 border-r border-gray-200 dark:border-gray-700/50">
${hasDmarc ? `
<div class="flex items-center gap-3">
<span class="text-sm font-bold ${passColor} min-w-[40px]">${passRate}%</span>
<div class="w-16 bg-gray-200 dark:bg-gray-700 rounded-full h-1.5 overflow-hidden">
<div class="${barBg} h-full" style="width: ${passRate}%"></div>
</div>
</div>
` : '<span class="text-gray-400">-</span>'}
</td>
<td class="px-6 py-4">
${hasTls ? `
<div class="flex items-center gap-3">
<span class="text-sm font-bold ${stats.tls_success_pct >= 95 ? 'text-green-500' : stats.tls_success_pct >= 80 ? 'text-yellow-500' : 'text-red-500'} min-w-[40px]">${stats.tls_success_pct || 100}%</span>
<div class="w-16 bg-gray-200 dark:bg-gray-700 rounded-full h-1.5 overflow-hidden">
<div class="${stats.tls_success_pct >= 95 ? 'bg-green-500' : stats.tls_success_pct >= 80 ? 'bg-yellow-500' : 'bg-red-500'} h-full" style="width: ${stats.tls_success_pct || 100}%"></div>
</div>
</div>
` : '<span class="text-gray-400">-</span>'}
</td>
</tr>
<div class="md:hidden block mb-4 mx-2 rounded-2xl p-5 hover:opacity-90 cursor-pointer transition-all shadow-lg bg-gray-100 dark:bg-gray-800"
onclick="loadDomainOverview('${escapeHtml(domain.domain)}')">
<div class="flex justify-between items-center mb-1">
<div class="text-base font-bold text-blue-600 dark:text-blue-400">${escapeHtml(domain.domain)}${tlsBadge}</div>
<span class="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-bold rounded-lg ${hasDmarc ? (passRate >= 95 ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400') : 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'}">
${hasDmarc ? passRate + '% Pass' : '<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg>TLS Only'}
</span>
</div>
<div class="w-full bg-gray-300 dark:bg-gray-700 rounded-full h-1.5 overflow-hidden mb-6">
<div class="${barBg} h-full" style="width: ${passRate}%"></div>
</div>
<div class="grid grid-cols-2 gap-x-8 gap-y-6">
<div class="border-l-[3px] border-blue-500/50 pl-3">
<div class="text-[10px] text-gray-500 dark:text-gray-400 uppercase font-bold tracking-wider">Messages</div>
<div class="text-sm font-bold text-gray-900 dark:text-white">${(stats.total_messages || 0).toLocaleString()}</div>
</div>
<div class="border-l-[3px] border-purple-500/50 pl-3">
<div class="text-[10px] text-gray-500 dark:text-gray-400 uppercase font-bold tracking-wider">Unique IPs</div>
<div class="text-sm font-bold text-gray-900 dark:text-white">${stats.unique_ips || 0}</div>
</div>
<div class="border-l-[3px] border-gray-500/50 pl-3">
<div class="text-[10px] text-gray-500 dark:text-gray-400 uppercase font-bold tracking-wider">Reports</div>
<div class="text-sm font-bold text-gray-900 dark:text-white">
${domain.report_count || 0}${domain.tls_report_count > 0 ? ` <span class="text-xs text-green-600 dark:text-green-400">+${domain.tls_report_count} TLS</span>` : ''}
</div>
</div>
<div class="border-l-[3px] border-orange-500/50 pl-3">
<div class="text-[10px] text-gray-500 dark:text-gray-400 uppercase font-bold tracking-wider">Period</div>
<div class="text-sm font-bold text-gray-900 dark:text-white">${firstDate} - ${lastDate}</div>
</div>
</div>
</div>
`;
}).join('');
} catch (error) {
console.error('Error loading DMARC domains:', error);
}
}
async function loadDomainOverview(domain, updateUrl = true) {
dmarcState.currentView = 'overview';
dmarcState.currentDomain = domain;
dmarcState.detailType = null;
// Update URL if requested (skip when called from handleDmarcRoute to avoid duplicate history)
if (updateUrl && typeof buildPath === 'function') {
const newPath = buildPath('dmarc', { domain });
if (window.location.pathname !== newPath) {
history.pushState({ route: 'dmarc', params: { domain } }, '', newPath);
}
}
// Update breadcrumb
setDmarcBreadcrumb('domain', { domain });
document.getElementById('dmarc-domains-view').classList.add('hidden');
document.getElementById('dmarc-overview-view').classList.remove('hidden');
document.getElementById('dmarc-report-details-view').classList.add('hidden');
document.getElementById('dmarc-source-details-view').classList.add('hidden');
// Title stays static as "DMARC Reports"
try {
const response = await authenticatedFetch(`/api/dmarc/domains/${encodeURIComponent(domain)}/overview?days=30`);
const data = await response.json();
const totals = data.totals || {};
// Render the stats grid with 3 columns on mobile and icons
// This replaces the old manual textContent updates
const statsContainer = document.getElementById('dmarc-overview-stats-container');
if (statsContainer) {
statsContainer.innerHTML = `
<div class="grid grid-cols-3 gap-2 sm:gap-4 mb-6">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-3 sm:p-6 border border-gray-100 dark:border-gray-700">
<div class="flex items-center justify-between mb-1 sm:mb-2">
<h3 class="text-[10px] sm:text-sm font-medium text-gray-500 dark:text-gray-400">Total Messages</h3>
<svg class="w-5 h-5 sm:w-7 sm:h-7 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
</svg>
</div>
<div class="text-lg sm:text-3xl font-bold text-gray-900 dark:text-white">${(totals.total_messages || 0).toLocaleString()}</div>
<div class="text-[9px] sm:text-xs text-gray-500 dark:text-gray-400 mt-1">Last 30 days</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-3 sm:p-6 border border-gray-100 dark:border-gray-700">
<div class="flex items-center justify-between mb-1 sm:mb-2">
<h3 class="text-[10px] sm:text-sm font-medium text-gray-500 dark:text-gray-400">DMARC Pass</h3>
<svg class="w-5 h-5 sm:w-7 sm:h-7 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path>
</svg>
</div>
<div class="text-lg sm:text-3xl font-bold text-green-600 dark:text-green-400">${totals.dmarc_pass_pct ? `${totals.dmarc_pass_pct}%` : '-'}</div>
<div class="text-[9px] sm:text-xs text-gray-500 dark:text-gray-400 mt-1">SPF + DKIM Pass</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-3 sm:p-6 border border-gray-100 dark:border-gray-700">
<div class="flex items-center justify-between mb-1 sm:mb-2">
<h3 class="text-[10px] sm:text-sm font-medium text-gray-500 dark:text-gray-400">Sources</h3>
<svg class="w-5 h-5 sm:w-7 sm:h-7 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"></path>
</svg>
</div>
<div class="text-lg sm:text-3xl font-bold text-gray-900 dark:text-white">${(totals.unique_ips || 0).toLocaleString()}</div>
<div class="text-[9px] sm:text-xs text-gray-500 dark:text-gray-400 mt-1">${totals.unique_reporters || 0} reporters</div>
</div>
</div>
`;
}
renderDmarcChart(data.daily_stats || []);
// Load initial sub-tab content based on current state
if (dmarcState.currentSubTab === 'reports') {
await loadDomainReports(domain);
} else if (dmarcState.currentSubTab === 'sources') {
await loadDomainSources(domain);
} else if (dmarcState.currentSubTab === 'tls') {
await loadDomainTLSReports(domain);
} else {
// Default to reports
await loadDomainReports(domain);
}
} catch (error) {
console.error('Error loading domain overview:', error);
}
}
function renderDmarcChart(dailyStats) {
const canvas = document.getElementById('dmarc-chart');
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (dmarcState.chartInstance) {
dmarcState.chartInstance.destroy();
}
// Fix: Remove * 1000 because d.date is an ISO string, not a timestamp
const labels = dailyStats.map(d => {
const date = new Date(d.date);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
});
dmarcState.chartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'Total Messages',
data: dailyStats.map(d => d.total || 0), // Use 'total' from dmarc.py
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
fill: true,
tension: 0.4
},
{
label: 'DMARC Pass',
data: dailyStats.map(d => d.dmarc_pass || 0), // Use 'dmarc_pass' from dmarc.py
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
fill: true,
tension: 0.4
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: { y: { beginAtZero: true } }
}
});
}
async function loadDomainReports(domain) {
try {
const response = await authenticatedFetch(`/api/dmarc/domains/${encodeURIComponent(domain)}/reports?days=30`);
const data = await response.json();
const reports = data.data || [];
const reportsList = document.getElementById('dmarc-reports-list');
if (reports.length === 0) {
reportsList.innerHTML = `<div class="text-center py-12"><p class="text-gray-500 text-sm">No daily reports available.</p></div>`;
return;
}
reportsList.innerHTML = reports.map(report => {
const date = new Date(report.date);
const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
const passPct = report.dmarc_pass_pct || 0;
const passColor = passPct >= 95 ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
return `
<div class="bg-gray-50 dark:bg-gray-700/50 border border-gray-100 dark:border-gray-700 rounded-xl p-3 mb-4 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors" onclick="loadReportDetails('${escapeHtml(domain)}', '${report.date}')">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="p-2 bg-white dark:bg-gray-800 rounded-lg shadow-sm flex-shrink-0">
<svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div class="text-sm font-bold text-blue-600 dark:text-blue-400 hover:underline">${dateStr}</div>
</div>
<span class="inline-flex items-center px-2.5 py-1 text-xs font-bold rounded-lg ${passColor}">
${passPct}% Pass
</span>
</div>
<div class="border-t border-gray-200 dark:border-gray-600 my-3"></div>
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-gray-500 dark:text-gray-400">
<div class="flex items-center gap-1">
<span class="font-bold text-gray-900 dark:text-white">${(report.total_messages || 0).toLocaleString()}</span>
<span>messages</span>
</div>
<span class="hidden sm:block text-gray-300 dark:text-gray-600">•</span>
<div>${report.unique_ips} Unique IPs</div>
<span class="hidden sm:block text-gray-300 dark:text-gray-600">•</span>
<div>${report.reports.length} Reporters</div>
</div>
</div>`;
}).join('');
} catch (error) {
console.error('Error loading reports:', error);
}
}
async function loadDomainSources(domain) {
try {
const response = await authenticatedFetch(`/api/dmarc/domains/${encodeURIComponent(domain)}/sources?days=30`);
if (!response.ok) throw new Error('Failed to load sources');
const data = await response.json();
const sources = data.data || [];
const sourcesList = document.getElementById('dmarc-sources-list');
if (sources.length === 0) {
sourcesList.innerHTML = '<p class="text-center py-12 text-gray-500 text-sm">No sources found.</p>';
return;
}
sourcesList.innerHTML = `
<div class="space-y-3">
${sources.map(s => {
const providerName = s.asn_org || 'Unknown Provider';
const hasGeoData = s.country_code && s.country_code.length === 2;
const flagUrl = hasGeoData ? `/static/assets/flags/24x18/${s.country_code.toLowerCase()}.png` : null;
// Status Badge Logic
const passPct = s.dmarc_pass_pct || 0;
const passColor = passPct >= 95 ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
// Icon: show flag if available, otherwise show a generic server icon
const iconHtml = hasGeoData && flagUrl
? `<img src="${flagUrl}" alt="${s.country_name || 'Unknown'}" class="w-5 h-3.5 object-cover rounded-sm" onerror="this.parentElement.innerHTML='<svg class=\\'w-5 h-5 text-gray-400\\' fill=\\'none\\' stroke=\\'currentColor\\' viewBox=\\'0 0 24 24\\'><path stroke-linecap=\\'round\\' stroke-linejoin=\\'round\\' stroke-width=\\'2\\' d=\\'M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01\\'></path></svg>'">`
: `<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"></path></svg>`;
return `
<div class="bg-gray-50 dark:bg-gray-700/50 border border-gray-100 dark:border-gray-700 rounded-xl p-4 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors shadow-sm"
onclick="loadSourceDetails('${escapeHtml(domain)}', '${escapeHtml(s.source_ip)}')">
<div class="flex items-start justify-between gap-3">
<div class="flex items-center gap-3 min-w-0 flex-1">
<div class="p-2 bg-white dark:bg-gray-800 rounded-lg shadow-sm flex-shrink-0">
${iconHtml}
</div>
<div class="min-w-0 flex-1">
<div class="text-sm font-bold text-blue-600 dark:text-blue-400 hover:underline truncate">${escapeHtml(providerName)}</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400 mt-0.5">
${escapeHtml(s.source_ip)} ${s.country_name ? `${escapeHtml(s.country_name)}` : ''}
</div>
</div>
</div>
<span class="inline-flex items-center px-2.5 py-1 text-xs font-bold rounded-lg ${passColor} flex-shrink-0">
${passPct}% Pass
</span>
</div>
<div class="border-t border-gray-200 dark:border-gray-600 my-3"></div>
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-gray-500 dark:text-gray-400">
<div class="flex items-center gap-1">
<span class="font-bold text-gray-900 dark:text-white">${(s.total_count || 0).toLocaleString()}</span>
<span class="font-medium">messages</span>
</div>
<span class="text-gray-300 dark:text-gray-600">•</span>
<div class="flex items-center gap-1">
<span>SPF:</span>
<span class="${s.spf_pass_pct >= 95 ? 'text-green-600 dark:text-green-400' : 'text-red-500'} font-bold">${s.spf_pass_pct}%</span>
</div>
<span class="text-gray-300 dark:text-gray-600">•</span>
<div class="flex items-center gap-1">
<span>DKIM:</span>
<span class="${s.dkim_pass_pct >= 95 ? 'text-green-600 dark:text-green-400' : 'text-red-500'} font-bold">${s.dkim_pass_pct}%</span>
</div>
</div>
</div>`;
}).join('')}
</div>
`;
} catch (error) {
console.error('Error loading sources:', error);
}
}
// =============================================================================
// TLS REPORTS TAB
// =============================================================================
function dmarcSwitchSubTab(tab) {
dmarcState.currentSubTab = tab;
// Update tab buttons
document.getElementById('dmarc-subtab-reports').classList.remove('active');
document.getElementById('dmarc-subtab-sources').classList.remove('active');
document.getElementById('dmarc-subtab-tls')?.classList.remove('active');
document.getElementById(`dmarc-subtab-${tab}`)?.classList.add('active');
// Update tab content
document.getElementById('dmarc-reports-content').classList.add('hidden');
document.getElementById('dmarc-sources-content').classList.add('hidden');
document.getElementById('dmarc-tls-content')?.classList.add('hidden');
// Show selected tab content
if (tab === 'reports') {
document.getElementById('dmarc-reports-content').classList.remove('hidden');
loadDomainReports(dmarcState.currentDomain);
} else if (tab === 'sources') {
document.getElementById('dmarc-sources-content').classList.remove('hidden');
loadDomainSources(dmarcState.currentDomain);
} else if (tab === 'tls') {
document.getElementById('dmarc-tls-content')?.classList.remove('hidden');
loadDomainTLSReports(dmarcState.currentDomain);
}
}
async function loadDomainTLSReports(domain) {
const tlsList = document.getElementById('dmarc-tls-list');
if (!tlsList) return;
try {
// Use daily aggregated API
const response = await authenticatedFetch(`/api/dmarc/domains/${encodeURIComponent(domain)}/tls-reports/daily?days=30`);
if (!response.ok) throw new Error('Failed to load TLS reports');
const data = await response.json();
const dailyReports = data.data || [];
const totals = data.totals || {};
if (dailyReports.length === 0) {
tlsList.innerHTML = `
<div class="text-center py-12">
<svg class="w-12 h-12 mx-auto text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
<p class="text-gray-500 dark:text-gray-400 text-sm">No TLS-RPT reports found for this domain.</p>
<p class="text-gray-400 dark:text-gray-500 text-xs mt-2">TLS reports will appear here once received from email providers.</p>
</div>`;
return;
}
// Render summary stats
const successRate = totals.overall_success_rate || 100;
const successColor = successRate >= 95 ? 'text-green-500' : successRate >= 80 ? 'text-yellow-500' : 'text-red-500';
tlsList.innerHTML = `
<!-- TLS Summary -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 text-center">
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase font-medium mb-1">Days</div>
<div class="text-2xl font-bold text-gray-900 dark:text-white">${totals.total_days || 0}</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 text-center">
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase font-medium mb-1">Reports</div>
<div class="text-2xl font-bold text-gray-900 dark:text-white">${totals.total_reports || 0}</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 text-center">
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase font-medium mb-1">Success Rate</div>
<div class="text-2xl font-bold ${successColor}">${successRate}%</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 text-center">
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase font-medium mb-1">Sessions</div>
<div class="text-2xl font-bold text-gray-900 dark:text-white">${((totals.total_successful_sessions || 0) + (totals.total_failed_sessions || 0)).toLocaleString()}</div>
</div>
</div>
<!-- Daily TLS Reports List -->
<div class="space-y-3">
${dailyReports.map(day => {
const dateFormatted = new Date(day.date).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' });
const rateColor = day.success_rate >= 95 ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' :
day.success_rate >= 80 ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400' :
'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
const barColor = day.success_rate >= 95 ? 'bg-green-500' : day.success_rate >= 80 ? 'bg-yellow-500' : 'bg-red-500';
return `
<div class="bg-gray-50 dark:bg-gray-700/50 border border-gray-100 dark:border-gray-700 rounded-xl p-4 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors" onclick="loadTLSReportDetails('${escapeHtml(domain)}', '${day.date}')">
<div class="flex items-start justify-between gap-3 mb-3">
<div class="flex items-center gap-3 min-w-0 flex-1">
<div class="p-2 bg-white dark:bg-gray-800 rounded-lg shadow-sm flex-shrink-0">
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
</div>
<div class="min-w-0 flex-1">
<div class="text-sm font-bold text-gray-900 dark:text-white">${dateFormatted}</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400 mt-0.5">
${day.report_count} report${day.report_count !== 1 ? 's' : ''} from ${day.organization_count} provider${day.organization_count !== 1 ? 's' : ''}
</div>
</div>
</div>
<span class="inline-flex items-center px-2.5 py-1 text-xs font-bold rounded-lg ${rateColor}">
${day.success_rate}%
</span>
</div>
<!-- Progress bar -->
<div class="w-full bg-gray-200 dark:bg-gray-600 rounded-full h-1.5 mb-3">
<div class="${barColor} h-full rounded-full" style="width: ${day.success_rate}%"></div>
</div>
<!-- Stats -->
<div class="flex flex-wrap items-center gap-x-6 gap-y-2 text-xs">
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
<span class="text-gray-500 dark:text-gray-400">Success:</span>
<span class="font-bold text-green-600 dark:text-green-400">${(day.total_success || 0).toLocaleString()}</span>
</div>
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
<span class="text-gray-500 dark:text-gray-400">Failed:</span>
<span class="font-bold text-red-600 dark:text-red-400">${(day.total_fail || 0).toLocaleString()}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-gray-500 dark:text-gray-400">Providers:</span>
<span class="font-medium text-gray-700 dark:text-gray-300">${day.organizations.join(', ')}</span>
</div>
</div>
</div>
`;
}).join('')}
</div>
`;
} catch (error) {
console.error('Error loading TLS reports:', error);
tlsList.innerHTML = `
<div class="text-center py-12">
<svg class="w-12 h-12 mx-auto text-red-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<p class="text-red-500 text-sm">Failed to load TLS reports.</p>
</div>`;
}
}
async function loadTLSReportDetails(domain, reportDate) {
const tlsList = document.getElementById('dmarc-tls-list');
if (!tlsList) return;
dmarcState.detailType = 'tls';
const dateFormatted = new Date(reportDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
setDmarcBreadcrumb('tlsDetails', { domain, date: dateFormatted });
try {
const response = await authenticatedFetch(`/api/dmarc/domains/${encodeURIComponent(domain)}/tls-reports/${reportDate}/details`);
if (!response.ok) throw new Error('Failed to load TLS report details');
const data = await response.json();
const stats = data.stats || {};
const providers = data.providers || [];
const dateFormatted = new Date(reportDate).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
const successRate = stats.success_rate || 100;
const successColor = successRate >= 95 ? 'text-green-500' : successRate >= 80 ? 'text-yellow-500' : 'text-red-500';
tlsList.innerHTML = `
<!-- Back Button -->
<div class="mb-6">
<button onclick="loadDomainTLSReports('${escapeHtml(domain)}')" class="flex items-center gap-2 text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
<span class="font-medium">Back to Daily Reports</span>
</button>
</div>
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h3 class="text-lg font-bold text-gray-900 dark:text-white">${dateFormatted}</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">TLS Report Details for ${escapeHtml(domain)}</p>
</div>
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 text-center border border-gray-100 dark:border-gray-700">
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase font-medium mb-1">Sessions</div>
<div class="text-2xl font-bold text-gray-900 dark:text-white">${(stats.total_sessions || 0).toLocaleString()}</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 text-center border border-gray-100 dark:border-gray-700">
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase font-medium mb-1">Success Rate</div>
<div class="text-2xl font-bold ${successColor}">${successRate}%</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 text-center border border-gray-100 dark:border-gray-700">
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase font-medium mb-1">Successful</div>
<div class="text-2xl font-bold text-green-600 dark:text-green-400">${(stats.total_success || 0).toLocaleString()}</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 text-center border border-gray-100 dark:border-gray-700">
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase font-medium mb-1">Failed</div>
<div class="text-2xl font-bold text-red-600 dark:text-red-400">${(stats.total_fail || 0).toLocaleString()}</div>
</div>
</div>
<!-- Providers Table -->
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-100 dark:border-gray-700">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-600">
<h4 class="text-sm font-bold text-gray-900 dark:text-white">Providers (${stats.total_providers || 0})</h4>
</div>
<!-- Desktop Table -->
<div class="hidden md:block overflow-x-auto">
<table class="min-w-full">
<thead class="bg-gray-100 dark:bg-gray-700">
<tr>
<th class="px-4 py-3 text-left text-xs font-bold text-gray-600 dark:text-gray-400 uppercase">Provider</th>
<th class="px-4 py-3 text-center text-xs font-bold text-gray-600 dark:text-gray-400 uppercase">Sessions</th>
<th class="px-4 py-3 text-center text-xs font-bold text-gray-600 dark:text-gray-400 uppercase">Success</th>
<th class="px-4 py-3 text-center text-xs font-bold text-gray-600 dark:text-gray-400 uppercase">Failed</th>
<th class="px-4 py-3 text-center text-xs font-bold text-gray-600 dark:text-gray-400 uppercase">Rate</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-600">
${providers.map(p => {
const rateColor = p.success_rate >= 95 ? 'text-green-600 dark:text-green-400' : p.success_rate >= 80 ? 'text-yellow-600 dark:text-yellow-400' : 'text-red-600 dark:text-red-400';
return `
<tr class="hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors">
<td class="px-4 py-3">
<div class="font-medium text-gray-900 dark:text-white">${escapeHtml(p.organization_name || 'Unknown')}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">${p.policies?.length || 0} policies</div>
</td>
<td class="px-4 py-3 text-center text-sm font-medium text-gray-900 dark:text-white">${(p.total_sessions || 0).toLocaleString()}</td>
<td class="px-4 py-3 text-center text-sm font-medium text-green-600 dark:text-green-400">${(p.successful_sessions || 0).toLocaleString()}</td>
<td class="px-4 py-3 text-center text-sm font-medium text-red-600 dark:text-red-400">${(p.failed_sessions || 0).toLocaleString()}</td>
<td class="px-4 py-3 text-center text-sm font-bold ${rateColor}">${p.success_rate}%</td>
</tr>`;
}).join('')}
</tbody>
</table>
</div>
<!-- Mobile Cards -->
<div class="md:hidden divide-y divide-gray-200 dark:divide-gray-600">
${providers.map(p => {
const rateColor = p.success_rate >= 95 ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : p.success_rate >= 80 ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400' : 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
return `
<div class="p-4">
<div class="flex justify-between items-start mb-2">
<div class="font-medium text-gray-900 dark:text-white">${escapeHtml(p.organization_name || 'Unknown')}</div>
<span class="px-2 py-0.5 text-xs font-bold rounded ${rateColor}">${p.success_rate}%</span>
</div>
<div class="grid grid-cols-3 gap-2 text-xs">
<div><span class="text-gray-500">Sessions:</span> <span class="font-bold">${p.total_sessions}</span></div>
<div><span class="text-gray-500">Success:</span> <span class="font-bold text-green-600">${p.successful_sessions}</span></div>
<div><span class="text-gray-500">Failed:</span> <span class="font-bold text-red-600">${p.failed_sessions}</span></div>
</div>
</div>`;
}).join('')}
</div>
</div>
`;
} catch (error) {
console.error('Error loading TLS report details:', error);
tlsList.innerHTML = `
<div class="text-center py-12">
<svg class="w-12 h-12 mx-auto text-red-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<p class="text-red-500 text-sm">Failed to load TLS report details.</p>
<button onclick="loadDomainTLSReports('${escapeHtml(domain)}')" class="mt-4 text-blue-600 hover:underline">Back to Daily Reports</button>
</div>`;
}
}
// =============================================================================
// REPORT DETAILS
// =============================================================================
async function loadReportDetails(domain, reportDate, updateUrl = true) {
dmarcState.currentView = 'report_details';
dmarcState.currentReportDate = reportDate;
dmarcState.detailType = 'report';
// Update URL if requested
if (updateUrl && typeof buildPath === 'function') {
const newPath = buildPath('dmarc', { domain, type: 'report', id: reportDate });
if (window.location.pathname !== newPath) {
history.pushState({ route: 'dmarc', params: { domain, type: 'report', id: reportDate } }, '', newPath);
}
}
document.getElementById('dmarc-domains-view').classList.add('hidden');
document.getElementById('dmarc-overview-view').classList.add('hidden');
document.getElementById('dmarc-report-details-view').classList.remove('hidden');
document.getElementById('dmarc-source-details-view').classList.add('hidden');
const dateObj = new Date(reportDate);
const dateStr = dateObj.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
const shortDate = dateObj.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
// Title stays static as "DMARC Reports"
// Update breadcrumb
setDmarcBreadcrumb('reportDetails', { domain, date: shortDate });
try {
const response = await authenticatedFetch(`/api/dmarc/domains/${encodeURIComponent(domain)}/reports/${reportDate}/details`);
const data = await response.json();
const totals = data.totals || {};
/* Inject icons and stats grid */
const statsContainer = document.getElementById('report-details-stats-container');
if (statsContainer) {
statsContainer.innerHTML = generateDetailStatsGrid(totals);
}
const sources = data.sources || [];
const sourcesList = document.getElementById('report-detail-sources-list');
if (sources.length === 0) {
sourcesList.innerHTML = '<p class="text-center py-12 text-gray-500">No sources found.</p>';
return;
}
sourcesList.innerHTML = `
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Source</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">From: domain</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Envelope from: domain</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Volume</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">DMARC pass</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">SPF aligned</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">DKIM aligned</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Reporter</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
${sources.map(s => {
const providerName = s.asn_org || s.source_name || 'Unknown';
const hasGeoData = s.country_code && s.country_code.length === 2;
const flagUrl = hasGeoData ? `/static/assets/flags/48x36/${s.country_code.toLowerCase()}.png` : null;
const dmarcColor = s.dmarc_pass_pct >= 95 ? 'text-green-600 dark:text-green-400' : s.dmarc_pass_pct === 0 ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-gray-100';
const spfColor = s.spf_pass_pct >= 95 ? 'text-green-600 dark:text-green-400' : s.spf_pass_pct === 0 ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-gray-100';
const dkimColor = s.dkim_pass_pct >= 95 ? 'text-green-600 dark:text-green-400' : s.dkim_pass_pct === 0 ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-gray-100';
// Icon: show flag if available, otherwise show a generic server icon
const iconHtml = hasGeoData && flagUrl
? `<img src="${flagUrl}" alt="${s.country_name || 'Unknown'}" class="w-6 h-4 object-cover rounded-sm shadow-sm" style="border: 1px solid rgba(0,0,0,0.1);" onerror="this.outerHTML='<svg class=\\'w-6 h-5 text-gray-400\\' fill=\\'none\\' stroke=\\'currentColor\\' viewBox=\\'0 0 24 24\\'><path stroke-linecap=\\'round\\' stroke-linejoin=\\'round\\' stroke-width=\\'2\\' d=\\'M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01\\'></path></svg>'">`
: `<svg class="w-6 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"></path></svg>`;
return `
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer" onclick="loadSourceDetails('${escapeHtml(domain)}', '${escapeHtml(s.source_ip)}')">
<td class="px-6 py-4">
<div class="flex items-center gap-2">
${iconHtml}
<div>
<div class="text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline">${escapeHtml(providerName)}</div>
<div class="text-xs text-gray-500">${escapeHtml(s.source_ip)}</div>
</div>
</div>
</td>
<td class="px-6 py-4 text-sm text-gray-900 dark:text-gray-100">${escapeHtml(s.header_from || '-')}</td>
<td class="px-6 py-4 text-sm text-gray-900 dark:text-gray-100">${escapeHtml(s.envelope_from || '-')}</td>
<td class="px-6 py-4 text-sm text-right text-gray-900 dark:text-gray-100">${(s.volume || 0).toLocaleString()}</td>
<td class="px-6 py-4 text-right"><span class="text-sm font-medium ${dmarcColor}">${s.dmarc_pass_pct}%</span></td>
<td class="px-6 py-4 text-right"><span class="text-sm ${spfColor}">${s.spf_pass_pct}%</span></td>
<td class="px-6 py-4 text-right"><span class="text-sm ${dkimColor}">${s.dkim_pass_pct}%</span></td>
<td class="px-6 py-4 text-sm text-gray-900 dark:text-gray-100">${escapeHtml(s.reporter || '-')}</td>
</tr>`;
}).join('')}
</tbody>
</table>
`;
} catch (error) {
console.error('Error loading report details:', error);
}
}
// =============================================================================
// SOURCE DETAILS
// =============================================================================
async function loadSourceDetails(domain, sourceIp, updateUrl = true) {
dmarcState.currentView = 'source_details';
dmarcState.currentSourceIp = sourceIp;
dmarcState.detailType = 'source';
// Update URL if requested
if (updateUrl && typeof buildPath === 'function') {
const newPath = buildPath('dmarc', { domain, type: 'source', id: sourceIp });
if (window.location.pathname !== newPath) {
history.pushState({ route: 'dmarc', params: { domain, type: 'source', id: sourceIp } }, '', newPath);
}
}
document.getElementById('dmarc-domains-view').classList.add('hidden');
document.getElementById('dmarc-overview-view').classList.add('hidden');
document.getElementById('dmarc-report-details-view').classList.add('hidden');
document.getElementById('dmarc-source-details-view').classList.remove('hidden');
// Title stays static as "DMARC Reports"
// Update breadcrumb
setDmarcBreadcrumb('sourceDetails', { domain, ip: sourceIp });
try {
const response = await authenticatedFetch(`/api/dmarc/domains/${encodeURIComponent(domain)}/sources/${encodeURIComponent(sourceIp)}/details?days=30`);
const data = await response.json();
/* Update Header Info */
const hasGeoData = data.country_code && data.country_code.length === 2;
const flagImg = document.getElementById('source-detail-flag');
if (hasGeoData) {
const flagUrl = `/static/assets/flags/48x36/${data.country_code.toLowerCase()}.png`;
flagImg.src = flagUrl;
flagImg.style.display = '';
flagImg.onerror = function () { this.style.display = 'none'; };
} else {
flagImg.style.display = 'none';
}
document.getElementById('source-detail-name').textContent = data.source_name || data.asn_org || 'Unknown Provider';
document.getElementById('source-detail-ip').textContent = sourceIp;
const location = [data.city, data.country_name].filter(Boolean).join(', ') || 'Unknown location';
document.getElementById('source-detail-location').textContent = location;
document.getElementById('source-detail-asn').textContent = data.asn ? `ASN ${data.asn}` : 'No ASN';
/* Inject icons and stats grid */
const totals = data.totals || {};
const statsContainer = document.getElementById('source-details-stats-container');
if (statsContainer) {
statsContainer.innerHTML = generateDetailStatsGrid(totals);
}
const envelopes = data.envelope_from_groups || [];
const envelopeList = document.getElementById('source-detail-envelope-list');
if (envelopes.length === 0) {
envelopeList.innerHTML = '<p class="text-center py-12 text-gray-500">No data found.</p>';
return;
}
envelopeList.innerHTML = `
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">From: domain</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Envelope from: domain</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Volume</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">DMARC pass</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">SPF aligned</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">DKIM aligned</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Reporter</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
${envelopes.map(env => {
const dmarcPct = env.volume > 0 ? Math.round((env.dmarc_pass / env.volume) * 100) : 0;
const spfPct = env.volume > 0 ? Math.round((env.spf_aligned / env.volume) * 100) : 0;
const dkimPct = env.volume > 0 ? Math.round((env.dkim_aligned / env.volume) * 100) : 0;
const dmarcColor = dmarcPct >= 95 ? 'text-green-600 dark:text-green-400' : dmarcPct === 0 ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-gray-100';
const spfColor = spfPct >= 95 ? 'text-green-600 dark:text-green-400' : spfPct === 0 ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-gray-100';
const dkimColor = dkimPct >= 95 ? 'text-green-600 dark:text-green-400' : dkimPct === 0 ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-gray-100';
return `
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td class="px-6 py-4 text-sm text-gray-900 dark:text-gray-100">${escapeHtml(env.header_from || '-')}</td>
<td class="px-6 py-4 text-sm text-gray-900 dark:text-gray-100">${escapeHtml(env.envelope_from || '-')}</td>
<td class="px-6 py-4 text-sm text-right text-gray-900 dark:text-gray-100">${(env.volume || 0).toLocaleString()}</td>
<td class="px-6 py-4 text-right"><span class="text-sm font-medium ${dmarcColor}">${dmarcPct}%</span></td>
<td class="px-6 py-4 text-right"><span class="text-sm ${spfColor}">${spfPct}%</span></td>
<td class="px-6 py-4 text-right"><span class="text-sm ${dkimColor}">${dkimPct}%</span></td>
<td class="px-6 py-4 text-sm text-gray-900 dark:text-gray-100">${escapeHtml(env.reporter || '-')}</td>
</tr>`;
}).join('')}
</tbody>
</table>
`;
} catch (error) {
console.error('Error loading source details:', error);
}
}
function generateDetailStatsGrid(totals) {
return `
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-100 dark:border-gray-700">
<div class="flex items-center justify-between mb-2">
<h3 class="text-xs font-medium text-gray-500 dark:text-gray-400">Volume</h3>
<svg class="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path></svg>
</div>
<div class="text-2xl font-bold text-gray-900 dark:text-white">${(totals.total_messages || 0).toLocaleString()}</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-100 dark:border-gray-700">
<div class="flex items-center justify-between mb-2">
<h3 class="text-xs font-medium text-gray-500 dark:text-gray-400">DMARC Pass</h3>
<svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
</div>
<div class="text-2xl font-bold text-gray-900 dark:text-white">${(totals.dmarc_pass || 0).toLocaleString()}</div>
<div class="text-xs text-green-600 dark:text-green-400 mt-1">${totals.dmarc_pass_pct || 0}%</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-100 dark:border-gray-700">
<div class="flex items-center justify-between mb-2">
<h3 class="text-xs font-medium text-gray-500 dark:text-gray-400">SPF Aligned</h3>
<svg class="w-6 h-6 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
</div>
<div class="text-2xl font-bold text-gray-900 dark:text-white">${(totals.spf_pass || 0).toLocaleString()}</div>
<div class="text-xs text-orange-500 mt-1">${totals.spf_pass_pct || 0}%</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-100 dark:border-gray-700">
<div class="flex items-center justify-between mb-2">
<h3 class="text-xs font-medium text-gray-500 dark:text-gray-400">DKIM Aligned</h3>
<svg class="w-6 h-6 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path></svg>
</div>
<div class="text-2xl font-bold text-gray-900 dark:text-white">${(totals.dkim_pass || 0).toLocaleString()}</div>
<div class="text-xs text-purple-500 mt-1">${totals.dkim_pass_pct || 0}%</div>
</div>
</div>
`;
}
// =============================================================================
// UPLOAD
// =============================================================================
async function uploadDmarcReport(event) {
const file = event.target.files[0];
if (!file) return;
try {
const formData = new FormData();
formData.append('file', file);
const response = await authenticatedFetch('/api/dmarc/upload', {
method: 'POST',
body: formData
});
if (response.status === 403) {
showToast('Manual upload is disabled', 'error');
event.target.value = '';
return;
}
if (!response.ok) throw new Error('Upload failed');
const result = await response.json();
const reportType = result.report_type === 'tls-rpt' ? 'TLS-RPT' : 'DMARC';
if (result.status === 'success') {
const count = result.records_count || result.policies_count || 0;
const countLabel = result.report_type === 'tls-rpt' ? 'policies' : 'records';
showToast(`${reportType} report uploaded: ${count} ${countLabel}`, 'success');
if (dmarcState.currentView === 'domains') {
loadDmarcDomains();
} else if (dmarcState.currentDomain) {
loadDomainOverview(dmarcState.currentDomain);
// If TLS report was uploaded and we're on TLS tab, refresh it
if (result.report_type === 'tls-rpt' && dmarcState.currentSubTab === 'tls') {
loadDomainTLSReports(dmarcState.currentDomain);
}
}
} else if (result.status === 'duplicate') {
showToast(`${reportType} report already exists`, 'warning');
}
} catch (error) {
console.error('Upload error:', error);
showToast('Failed to upload report', 'error');
}
event.target.value = '';
}
// =============================================================================
// IMAP
// =============================================================================
async function loadDmarcImapStatus() {
try {
const response = await authenticatedFetch('/api/dmarc/imap/status');
if (!response.ok) {
dmarcImapStatus = null;
return;
}
dmarcImapStatus = await response.json();
updateDmarcControls();
} catch (error) {
console.error('Error loading DMARC IMAP status:', error);
dmarcImapStatus = null;
}
}
function updateDmarcControls() {
const uploadBtn = document.getElementById('dmarc-upload-btn');
const syncContainer = document.getElementById('dmarc-sync-container');
const lastSyncInfo = document.getElementById('dmarc-last-sync-info');
// Toggle upload button
if (uploadBtn) {
if (dmarcConfiguration?.manual_upload_enabled === true) {
uploadBtn.classList.remove('hidden');
} else {
uploadBtn.classList.add('hidden');
}
}
// Toggle sync container
if (dmarcImapStatus && dmarcImapStatus.enabled) {
syncContainer.classList.remove('hidden');
// Update last sync info to match Domains Overview style
if (dmarcImapStatus.latest_sync) {
const sync = dmarcImapStatus.latest_sync;
const timeStr = formatTime(sync.started_at);
let statusPrefix = '';
if (sync.status === 'success') statusPrefix = '✓ ';
if (sync.status === 'error') statusPrefix = '✗ ';
if (sync.status === 'running') statusPrefix = '⟳ ';
lastSyncInfo.innerHTML = `
<div class="flex flex-col items-center lg:items-end">
<span class="${sync.status === 'error' ? 'text-red-500' : 'text-green-500'} font-medium">
${statusPrefix}Last sync: ${timeStr}
</span>
<button onclick="showDmarcSyncHistory()" class="text-blue-600 dark:text-blue-400 hover:underline text-[11px] mt-0.5">
View History
</button>
</div>
`;
} else {
lastSyncInfo.innerHTML = '<span class="text-gray-500 italic">Never synced</span>';
}
} else {
syncContainer.classList.add('hidden');
}
}
async function triggerDmarcSync() {
const btn = document.getElementById('dmarc-sync-btn');
const btnText = document.getElementById('dmarc-sync-btn-text');
if (!dmarcImapStatus || !dmarcImapStatus.enabled) {
showToast('IMAP sync is not enabled', 'error');
return;
}
btn.disabled = true;
btnText.textContent = 'Syncing...';
try {
const response = await authenticatedFetch('/api/dmarc/imap/sync', {
method: 'POST'
});
const result = await response.json();
if (result.status === 'already_running') {
showToast('Sync is already in progress', 'info');
} else if (result.status === 'started') {
showToast('IMAP sync started', 'success');
// Immediate UI update to show "Running" state
await loadDmarcImapStatus();
// Delayed update to catch the final result (success/fail)
setTimeout(async () => {
await loadDmarcImapStatus();
await loadDmarcDomains();
}, 5000); // Increased to 5s to give the sync time to work
}
} catch (error) {
console.error('Error triggering sync:', error);
showToast('Failed to start sync', 'error');
} finally {
btn.disabled = false;
btnText.textContent = 'Sync from IMAP';
}
}
async function showDmarcSyncHistory() {
const modal = document.getElementById('dmarc-sync-history-modal');
const content = document.getElementById('dmarc-sync-history-content');
modal.classList.remove('hidden');
const closeOnBackdrop = (e) => {
if (e.target === modal) {
closeDmarcSyncHistoryModal();
modal.removeEventListener('click', closeOnBackdrop);
}
};
modal.addEventListener('click', closeOnBackdrop);
try {
const response = await authenticatedFetch('/api/dmarc/imap/history?limit=20');
const data = await response.json();
if (data.data.length === 0) {
content.innerHTML = '<p class="text-center py-12 text-gray-500">No sync history yet</p>';
return;
}
content.innerHTML = `
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Date</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Emails</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Created</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Duplicate</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Failed</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Duration</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
${data.data.map(sync => {
const statusClass = sync.status === 'success' ? 'text-green-600' :
sync.status === 'error' ? 'text-red-600' : 'text-blue-600';
const date = formatDate(sync.started_at);
const duration = sync.duration_seconds ? `${Math.round(sync.duration_seconds)}s` : '-';
return `
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td class="px-6 py-4 text-sm text-gray-900 dark:text-white">${date}</td>
<td class="px-6 py-4 text-sm">
<span class="px-2 py-1 rounded text-xs ${sync.sync_type === 'manual' ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800'}">
${sync.sync_type}
</span>
</td>
<td class="px-6 py-4 text-sm font-medium ${statusClass}">${sync.status}</td>
<td class="px-6 py-4 text-sm text-right text-gray-900 dark:text-white">${sync.emails_found || 0}</td>
<td class="px-6 py-4 text-sm text-right text-green-600">${sync.reports_created || 0}</td>
<td class="px-6 py-4 text-sm text-right text-gray-500">${sync.reports_duplicate || 0}</td>
<td class="px-6 py-4 text-sm text-right ${sync.reports_failed > 0 ? 'text-red-600' : 'text-gray-900 dark:text-white'}">${sync.reports_failed || 0}</td>
<td class="px-6 py-4 text-sm text-gray-900 dark:text-white">${duration}</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
`;
} catch (error) {
console.error('Error loading sync history:', error);
content.innerHTML = '<p class="text-center py-12 text-red-500">Failed to load sync history</p>';
}
}
function closeDmarcSyncHistoryModal() {
document.getElementById('dmarc-sync-history-modal').classList.add('hidden');
}
// =============================================================================
// TEST IMAP / SMTP
// =============================================================================
async function testSmtpConnection() {
showConnectionTestModal('SMTP Connection Test', 'Testing SMTP connection...');
try {
const response = await authenticatedFetch('/api/settings/test/smtp', {
method: 'POST'
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const result = await response.json();
// Ensure logs is an array
const logs = result.logs || ['No logs available'];
updateConnectionTestModal(result.success ? 'success' : 'error', logs);
} catch (error) {
updateConnectionTestModal('error', [
'Failed to test SMTP connection',
`Error: ${error.message}`
]);
}
}
async function testImapConnection() {
showConnectionTestModal('IMAP Connection Test', 'Testing IMAP connection...');
try {
const response = await authenticatedFetch('/api/settings/test/imap', {
method: 'POST'
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const result = await response.json();
// Ensure logs is an array
const logs = result.logs || ['No logs available'];
updateConnectionTestModal(result.success ? 'success' : 'error', logs);
} catch (error) {
updateConnectionTestModal('error', [
'Failed to test IMAP connection',
`Error: ${error.message}`
]);
}
}
function showConnectionTestModal(title, message) {
const modal = document.createElement('div');
modal.id = 'connection-test-modal';
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
modal.innerHTML = `
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[80vh] overflow-hidden flex flex-col">
<div class="p-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">${escapeHtml(title)}</h3>
<button onclick="closeConnectionTestModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="p-4 overflow-y-auto flex-1">
<div id="connection-test-content" class="space-y-2">
<div class="flex items-center gap-2 text-gray-600 dark:text-gray-400">
<div class="loading"></div>
<span>${escapeHtml(message)}</span>
</div>
</div>
</div>
<div class="p-4 border-t border-gray-200 dark:border-gray-700 flex justify-end">
<button onclick="closeConnectionTestModal()" class="px-4 py-2 bg-gray-500 hover:bg-gray-600 text-white rounded transition-colors">
Close
</button>
</div>
</div>
`;
// Close on backdrop click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
closeConnectionTestModal();
}
});
document.body.appendChild(modal);
}
function updateConnectionTestModal(status, logs) {
const content = document.getElementById('connection-test-content');
if (!content) return;
// Ensure logs is an array
if (!Array.isArray(logs)) {
logs = ['Error: Invalid response format'];
}
const statusColor = status === 'success' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400';
const statusIcon = status === 'success' ?
'<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path></svg>' :
'<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path></svg>';
content.innerHTML = `
<div class="flex items-center gap-3 mb-4 p-3 rounded ${status === 'success' ? 'bg-green-50 dark:bg-green-900/20' : 'bg-red-50 dark:bg-red-900/20'}">
<div class="${statusColor}">
${statusIcon}
</div>
<span class="font-semibold ${statusColor}">
${status === 'success' ? 'Connection Successful' : 'Connection Failed'}
</span>
</div>
<div class="bg-gray-900 text-gray-100 p-4 rounded font-mono text-xs overflow-x-auto">
${logs.map(log => {
let color = 'text-gray-300';
if (log.includes('✓')) color = 'text-green-400';
if (log.includes('✗') || log.includes('ERROR')) color = 'text-red-400';
if (log.includes('WARNING')) color = 'text-yellow-400';
return `<div class="${color}">${escapeHtml(log)}</div>`;
}).join('')}
</div>
`;
}
function closeConnectionTestModal() {
const modal = document.getElementById('connection-test-modal');
if (modal) {
modal.remove();
}
}
// =============================================================================
// HELP DOCUMENTATION MODAL
// =============================================================================
async function showHelpModal(docName) {
try {
const response = await authenticatedFetch(`/api/docs/${docName}`);
if (!response.ok) {
throw new Error(`Failed to load documentation: ${response.statusText}`);
}
const markdown = await response.text();
let htmlContent = markdown;
if (typeof marked !== 'undefined') {
marked.setOptions({
breaks: true,
gfm: true
});
htmlContent = marked.parse(markdown);
}
const modal = document.getElementById('changelog-modal');
const modalTitle = modal?.querySelector('h3');
const content = document.getElementById('changelog-content');
if (modal && content) {
if (modalTitle) {
modalTitle.textContent = `Help - ${docName}`;
}
content.innerHTML = htmlContent;
modal.classList.remove('hidden');
document.body.style.overflow = 'hidden';
}
} catch (error) {
console.error('Failed to load help documentation:', error);
const modal = document.getElementById('changelog-modal');
const modalTitle = modal?.querySelector('h3');
const content = document.getElementById('changelog-content');
if (modal && content) {
if (modalTitle) {
modalTitle.textContent = 'Help';
}
content.innerHTML = '<p class="text-red-500">Failed to load help documentation. Please try again later.</p>';
modal.classList.remove('hidden');
document.body.style.overflow = 'hidden';
}
}
}
// =============================================================================
// CONSOLE LOG
// =============================================================================
console.log('[OK] Mailcow Logs Viewer - Complete Frontend Loaded');
console.log('Features: Dashboard, Messages, Postfix, Rspamd, Netfilter, Queue, Quarantine, Status, Mailbox Stats, Settings');
console.log('UI: Dark mode, Modals with tabs, Responsive design');
// =============================================================================
// MAILBOX STATISTICS - REDESIGNED WITH MESSAGE COUNTS
// =============================================================================
// Cached mailbox stats data
let mailboxStatsCache = {
summary: null,
mailboxes: null,
domains: null,
lastLoad: null,
expandedMailboxes: new Set() // Track expanded accordion states
};
async function loadMailboxStats() {
console.log('Loading mailbox statistics...');
// Show loading state
const loading = document.getElementById('mailbox-stats-loading');
const content = document.getElementById('mailbox-stats-content');
if (loading) loading.classList.remove('hidden');
if (content) content.classList.add('hidden');
try {
const dateRange = document.getElementById('mailbox-stats-date-range')?.value || '30days';
const customStartDate = document.getElementById('mailbox-stats-start-date')?.value || '';
const customEndDate = document.getElementById('mailbox-stats-end-date')?.value || '';
// Build summary URL with optional custom date range
let summaryUrl = `/api/mailbox-stats/summary?date_range=${dateRange}`;
if (dateRange === 'custom' && customStartDate && customEndDate) {
summaryUrl += `&start_date=${encodeURIComponent(customStartDate)}&end_date=${encodeURIComponent(customEndDate)}`;
}
// Load summary and domains in parallel
const [summaryRes, domainsRes] = await Promise.all([
authenticatedFetch(summaryUrl),
authenticatedFetch('/api/mailbox-stats/domains')
]);
if (!summaryRes.ok || !domainsRes.ok) {
throw new Error('Failed to fetch mailbox statistics');
}
const summary = await summaryRes.json();
const domains = await domainsRes.json();
mailboxStatsCache.summary = summary;
mailboxStatsCache.domains = domains.domains || [];
// Render summary cards
renderMailboxStatsSummary(summary);
// Populate domain filter
populateMailboxStatsDomainFilter(mailboxStatsCache.domains);
// Load all mailboxes
await loadMailboxStatsList();
// Update last update time
const lastUpdateEl = document.getElementById('mailbox-stats-last-update');
if (lastUpdateEl && summary.last_update) {
lastUpdateEl.textContent = `Last updated: ${formatTime(summary.last_update)}`;
}
// Show content, hide loading
if (loading) loading.classList.add('hidden');
if (content) content.classList.remove('hidden');
mailboxStatsCache.lastLoad = new Date();
} catch (error) {
console.error('Error loading mailbox stats:', error);
if (loading) {
loading.innerHTML = `
<div class="text-center py-12">
<svg class="w-12 h-12 text-red-500 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<p class="text-red-500 mb-2">Failed to load mailbox statistics</p>
<p class="text-gray-500 dark:text-gray-400 text-sm">${error.message}</p>
<button onclick="loadMailboxStats()" class="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Retry</button>
</div>
`;
}
}
}
function refreshMailboxStats() {
loadMailboxStats();
}
function renderMailboxStatsSummary(summary) {
// Update summary cards (new 4-card design: Sent, Received, Failed, Failure Rate)
const sentEl = document.getElementById('mailbox-stats-sent');
const receivedEl = document.getElementById('mailbox-stats-received');
const failedEl = document.getElementById('mailbox-stats-failed');
const failureRateEl = document.getElementById('mailbox-stats-failure-rate');
if (sentEl) sentEl.textContent = (summary.total_sent || 0).toLocaleString();
if (receivedEl) receivedEl.textContent = (summary.total_received || 0).toLocaleString();
if (failedEl) failedEl.textContent = (summary.sent_failed || 0).toLocaleString();
if (failureRateEl) failureRateEl.textContent = `${summary.failure_rate || 0}%`;
// Update date labels based on selected range
const dateRange = document.getElementById('mailbox-stats-date-range')?.value || '30days';
let dateLabel;
if (dateRange === 'custom') {
const startDate = document.getElementById('mailbox-stats-start-date')?.value;
const endDate = document.getElementById('mailbox-stats-end-date')?.value;
if (startDate && endDate) {
dateLabel = `${formatDateShort(startDate)} - ${formatDateShort(endDate)}`;
} else {
dateLabel = 'Custom Range';
}
} else {
dateLabel = dateRange === 'today' ? 'Today' :
dateRange === '7days' ? 'Last 7 days' :
dateRange === '90days' ? 'Last 90 days' : 'Last 30 days';
}
['sent', 'recv', 'failed', 'rate'].forEach(s => {
const el = document.getElementById(`mailbox-stats-date-label-${s}`);
if (el) el.textContent = dateLabel;
});
}
function populateMailboxStatsDomainFilter(domains) {
const select = document.getElementById('mailbox-stats-domain-filter');
if (!select) return;
// Clear existing options except "All Domains"
select.innerHTML = '<option value="">All Domains</option>';
// Add domain options
domains.forEach(d => {
const option = document.createElement('option');
option.value = d.domain;
option.textContent = `${d.domain} (${d.mailbox_count})`;
select.appendChild(option);
});
}
// Current page for pagination
let mailboxStatsPage = 1;
async function loadMailboxStatsList(page = 1) {
mailboxStatsPage = page;
const dateRange = document.getElementById('mailbox-stats-date-range')?.value || '30days';
const customStartDate = document.getElementById('mailbox-stats-start-date')?.value || '';
const customEndDate = document.getElementById('mailbox-stats-end-date')?.value || '';
const domainFilter = document.getElementById('mailbox-stats-domain-filter')?.value || '';
const sortValue = document.getElementById('mailbox-stats-sort')?.value || 'sent_total-desc';
const activeOnly = document.getElementById('mailbox-stats-active-only')?.checked ?? true;
const hideZero = document.getElementById('mailbox-stats-hide-zero')?.checked ?? false;
const search = document.getElementById('mailbox-stats-search')?.value || '';
const [sortBy, sortOrder] = sortValue.split('-');
let url = `/api/mailbox-stats/all?date_range=${dateRange}&sort_by=${sortBy}&sort_order=${sortOrder}&page=${page}&page_size=50`;
// Add custom date range parameters if using custom mode
if (dateRange === 'custom' && customStartDate && customEndDate) {
url += `&start_date=${encodeURIComponent(customStartDate)}&end_date=${encodeURIComponent(customEndDate)}`;
}
if (domainFilter) url += `&domain=${encodeURIComponent(domainFilter)}`;
if (activeOnly) url += '&active_only=true';
else url += '&active_only=false';
if (hideZero) url += '&hide_zero=true';
if (search) url += `&search=${encodeURIComponent(search)}`;
try {
const response = await authenticatedFetch(url);
if (!response.ok) throw new Error('Failed to fetch mailboxes');
const data = await response.json();
mailboxStatsCache.mailboxes = data.mailboxes || [];
// Update count
const countEl = document.getElementById('mailbox-stats-count');
if (countEl) countEl.textContent = `${data.total || 0} mailboxes`;
// Update pagination info
const pageInfoEl = document.getElementById('mailbox-stats-page-info');
if (pageInfoEl && data.total_pages > 1) {
pageInfoEl.textContent = `Page ${data.page} of ${data.total_pages}`;
} else if (pageInfoEl) {
pageInfoEl.textContent = '';
}
renderMailboxStatsAccordion(data.mailboxes || [], data.page, data.total_pages);
} catch (error) {
console.error('Error loading mailbox list:', error);
}
}
function renderMailboxStatsAccordion(mailboxes, page = 1, totalPages = 1) {
const container = document.getElementById('mailbox-stats-list');
if (!container) return;
if (mailboxes.length === 0) {
container.innerHTML = `
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-8 text-center">
<svg class="w-12 h-12 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
</svg>
<p class="text-gray-500 dark:text-gray-400">No mailboxes found</p>
</div>
`;
return;
}
// Build mailbox rows first
let html = mailboxes.map((mb, index) => {
const isExpanded = mailboxStatsCache.expandedMailboxes.has(mb.username);
const statusClass = mb.active
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
// Failure rate color
const failureColor = mb.combined_failure_rate >= 10 ? 'text-red-600 dark:text-red-400'
: mb.combined_failure_rate >= 5 ? 'text-yellow-600 dark:text-yellow-400'
: 'text-green-600 dark:text-green-400';
// Quota bar
const quotaPercent = mb.percent_in_use || 0;
const quotaColor = quotaPercent >= 90 ? 'bg-red-500' : quotaPercent >= 75 ? 'bg-yellow-500' : 'bg-blue-500';
return `
<div class="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden mb-2">
<!-- Accordion Header -->
<div onclick="toggleMailboxAccordion('${escapeHtml(mb.username)}')"
class="cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<div class="px-4 py-3">
<!-- Desktop: 3-column grid | Mobile: stacked layout -->
<div class="hidden md:grid md:grid-cols-3 items-center gap-2">
<!-- Zone 1: Mailbox Info (Desktop) -->
<div class="flex items-center gap-3 min-w-0">
<svg id="accordion-icon-${index}" class="w-5 h-5 text-gray-400 transition-transform flex-shrink-0 ml-1 ${isExpanded ? 'rotate-90' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
<div class="min-w-0">
<div class="font-medium text-gray-900 dark:text-white truncate">${escapeHtml(mb.username)}</div>
<div class="flex items-center gap-2 mt-0.5">
<span class="px-2 py-0.5 text-xs font-medium rounded-full ${statusClass}">${mb.active ? 'Active' : 'Inactive'}</span>
${mb.name ? `<span class="text-xs text-gray-500 dark:text-gray-400 truncate">${escapeHtml(mb.name)}</span>` : ''}
</div>
</div>
</div>
<!-- Zone 2: Stats Badges (Desktop - center) -->
<div class="flex flex-row items-center justify-center gap-1">
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${getDirectionBadgeClass('outbound')} whitespace-nowrap">
${mb.combined_sent.toLocaleString()} Sent
</span>
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${getDirectionBadgeClass('inbound')} whitespace-nowrap">
${mb.combined_received.toLocaleString()} Received
</span>
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${getStatusBadgeClass('delivered')} whitespace-nowrap">
${(mb.combined_delivered || 0).toLocaleString()} Delivered
</span>
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${getStatusBadgeClass('bounced')} whitespace-nowrap">
${mb.combined_failure_rate}% Failed
</span>
</div>
<!-- Zone 3: Aliases + Storage (Desktop - right) -->
<div class="flex items-center justify-end gap-6">
<div class="text-center">
<p class="text-xs text-gray-500 dark:text-gray-400">Aliases</p>
<p class="text-sm font-semibold text-gray-900 dark:text-white">${mb.alias_count || 0}</p>
</div>
<div class="text-center">
<p class="text-xs text-gray-500 dark:text-gray-400">Storage</p>
<p class="text-sm font-semibold text-gray-900 dark:text-white">${mb.quota_used_formatted}</p>
</div>
</div>
</div>
<!-- Mobile Layout: Stacked -->
<div class="md:hidden">
<!-- Row 1: Arrow + Email + Active indicator on right -->
<div class="flex items-center gap-3">
<svg id="accordion-icon-mobile-${index}" class="w-5 h-5 text-gray-400 transition-transform flex-shrink-0 ${isExpanded ? 'rotate-90' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
<div class="min-w-0 flex-1">
<div class="font-medium text-gray-900 dark:text-white">${escapeHtml(mb.username)}</div>
</div>
<!-- Active indicator dot on right -->
<div class="flex items-center gap-1.5 flex-shrink-0">
<span class="w-2.5 h-2.5 rounded-full ${mb.active ? 'bg-green-500' : 'bg-red-500'}"></span>
<span class="text-xs text-gray-500 dark:text-gray-400">${mb.active ? 'Active' : 'Inactive'}</span>
</div>
</div>
<!-- Row 2: Direction badges (Sent, Received) -->
<div class="flex gap-1 mt-2 ml-8">
<span class="inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium ${getDirectionBadgeClass('outbound')} whitespace-nowrap">
${mb.combined_sent.toLocaleString()} Sent
</span>
<span class="inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium ${getDirectionBadgeClass('inbound')} whitespace-nowrap">
${mb.combined_received.toLocaleString()} Received
</span>
</div>
<!-- Row 3: Status badges (Delivered, Failed) -->
<div class="flex gap-1 mt-1 ml-8">
<span class="inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium ${getStatusBadgeClass('delivered')} whitespace-nowrap">
${(mb.combined_delivered || 0).toLocaleString()} Delivered
</span>
<span class="inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium ${getStatusBadgeClass('bounced')} whitespace-nowrap">
${mb.combined_failure_rate}% Failed
</span>
</div>
</div>
</div>
</div>
<!-- Accordion Content (Domains-style layout) -->
<div id="accordion-content-${index}" class="${isExpanded ? '' : 'hidden'} border-t border-gray-200 dark:border-gray-700">
<!-- Mailbox Info Section -->
<div class="p-6 bg-gray-50 dark:bg-gray-700/30">
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 font-medium mb-1">Quota Used</p>
<p class="text-lg font-bold text-gray-900 dark:text-white">${mb.quota_used_formatted} / ${mb.quota_formatted}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">${mb.percent_in_use || 0}% used</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 font-medium mb-1">Messages in Mailbox</p>
<p class="text-lg font-bold text-gray-900 dark:text-white">${(mb.messages_in_mailbox || 0).toLocaleString()}</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 font-medium mb-1">Created / Modified</p>
<p class="text-xs text-gray-900 dark:text-white">${mb.created ? formatTime(mb.created) : 'N/A'}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">${mb.modified ? formatTime(mb.modified) : 'N/A'}</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 font-medium mb-1">Rate Limit</p>
<p class="text-sm font-semibold text-gray-900 dark:text-white">${mb.rl_value ? mb.rl_value + '/' + (mb.rl_frame === 's' ? 'sec' : mb.rl_frame === 'm' ? 'min' : mb.rl_frame === 'h' ? 'hour' : mb.rl_frame === 'd' ? 'day' : mb.rl_frame || 'min') : 'None'}</p>
</div>
</div>
<!-- Access Permissions with Last Login Dates -->
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mt-4 pt-4 border-t border-gray-200 dark:border-gray-600">
<div class="flex flex-col">
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full ${mb.attributes?.imap_access === '1' ? 'bg-green-500' : 'bg-red-500'}"></span>
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">IMAP</span>
</div>
<span class="text-xs text-gray-500 dark:text-gray-400 ml-4">${mb.last_imap_login ? formatTime(mb.last_imap_login) : 'Never'}</span>
</div>
<div class="flex flex-col">
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full ${mb.attributes?.pop3_access === '1' ? 'bg-green-500' : 'bg-red-500'}"></span>
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">POP3</span>
</div>
<span class="text-xs text-gray-500 dark:text-gray-400 ml-4">${mb.last_pop3_login ? formatTime(mb.last_pop3_login) : 'Never'}</span>
</div>
<div class="flex flex-col">
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full ${mb.attributes?.smtp_access === '1' ? 'bg-green-500' : 'bg-red-500'}"></span>
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">SMTP</span>
</div>
<span class="text-xs text-gray-500 dark:text-gray-400 ml-4">${mb.last_smtp_login ? formatTime(mb.last_smtp_login) : 'Never'}</span>
</div>
<div class="flex flex-col">
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full ${mb.attributes?.sieve_access === '1' ? 'bg-green-500' : 'bg-red-500'}"></span>
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">Sieve</span>
</div>
</div>
<div class="flex flex-col">
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full ${mb.attributes?.tls_enforce_in === '1' || mb.attributes?.tls_enforce_out === '1' ? 'bg-green-500' : 'bg-gray-400'}"></span>
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">TLS Enforce</span>
</div>
</div>
</div>
</div>
<!-- Message Stats Section -->
<div class="p-6">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">Message Statistics</h4>
<!-- Direction Stats Row -->
<div class="grid grid-cols-3 gap-2 mb-4">
<div class="p-3 ${getDirectionBgClass('outbound')} rounded-lg text-center cursor-pointer hover:opacity-80 transition-opacity"
onclick="event.stopPropagation(); navigateToMessagesWithFilter({ email: '${escapeHtml(mb.username)}', filterType: 'search', direction: 'outbound' })">
<div class="text-xl font-bold ${getDirectionTextClass('outbound')}">${mb.combined_sent || 0}</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">Sent</div>
</div>
<div class="p-3 ${getDirectionBgClass('inbound')} rounded-lg text-center cursor-pointer hover:opacity-80 transition-opacity"
onclick="event.stopPropagation(); navigateToMessagesWithFilter({ email: '${escapeHtml(mb.username)}', filterType: 'search', direction: 'inbound' })">
<div class="text-xl font-bold ${getDirectionTextClass('inbound')}">${mb.combined_received || 0}</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">Received</div>
</div>
<div class="p-3 ${getDirectionBgClass('internal')} rounded-lg text-center cursor-pointer hover:opacity-80 transition-opacity"
onclick="event.stopPropagation(); navigateToMessagesWithFilter({ email: '${escapeHtml(mb.username)}', filterType: 'search', direction: 'internal' })">
<div class="text-xl font-bold ${getDirectionTextClass('internal')}">${mb.combined_internal || 0}</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">Internal</div>
</div>
</div>
<!-- Status Stats Row -->
<div class="grid grid-cols-4 gap-2">
<div class="p-3 ${getStatusBgClass('delivered')} rounded-lg text-center cursor-pointer hover:opacity-80 transition-opacity"
onclick="event.stopPropagation(); navigateToMessagesWithFilter({ email: '${escapeHtml(mb.username)}', filterType: 'search', status: 'delivered' })">
<div class="text-xl font-bold ${getStatusTextClass('delivered')}">${mb.combined_delivered || 0}</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">Delivered</div>
</div>
<div class="p-3 ${getStatusBgClass('deferred')} rounded-lg text-center cursor-pointer hover:opacity-80 transition-opacity"
onclick="event.stopPropagation(); navigateToMessagesWithFilter({ email: '${escapeHtml(mb.username)}', filterType: 'search', status: 'deferred' })">
<div class="text-xl font-bold ${getStatusTextClass('deferred')}">${(mb.mailbox_counts?.sent_deferred || 0) + (mb.aliases || []).reduce((sum, a) => sum + (a.sent_deferred || 0), 0)}</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">Deferred</div>
</div>
<div class="p-3 ${getStatusBgClass('bounced')} rounded-lg text-center cursor-pointer hover:opacity-80 transition-opacity"
onclick="event.stopPropagation(); navigateToMessagesWithFilter({ email: '${escapeHtml(mb.username)}', filterType: 'search', status: 'bounced' })">
<div class="text-xl font-bold ${getStatusTextClass('bounced')}">${(mb.mailbox_counts?.sent_bounced || 0) + (mb.aliases || []).reduce((sum, a) => sum + (a.sent_bounced || 0), 0)}</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">Bounced</div>
</div>
<div class="p-3 ${getStatusBgClass('rejected')} rounded-lg text-center cursor-pointer hover:opacity-80 transition-opacity"
onclick="event.stopPropagation(); navigateToMessagesWithFilter({ email: '${escapeHtml(mb.username)}', filterType: 'search', status: 'rejected' })">
<div class="text-xl font-bold ${getStatusTextClass('rejected')}">${(mb.mailbox_counts?.sent_rejected || 0) + (mb.aliases || []).reduce((sum, a) => sum + (a.sent_rejected || 0), 0)}</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">Rejected</div>
</div>
</div>
</div>
<!-- Aliases Section -->
${mb.aliases && mb.aliases.length > 0 ? `
<div class="p-6 border-t border-gray-200 dark:border-gray-700">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">Aliases (${mb.aliases.length})</h4>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr class="text-xs text-gray-500 dark:text-gray-400 uppercase">
<th class="text-left py-2 pr-4">Alias</th>
<th class="text-center py-2 px-2">Sent</th>
<th class="text-center py-2 px-2">Received</th>
<th class="text-center py-2 px-2">Internal</th>
<th class="text-center py-2 px-2">Delivered</th>
<th class="text-center py-2 px-2">Deferred</th>
<th class="text-center py-2 px-2">Bounced</th>
<th class="text-center py-2 px-2">Rejected</th>
<th class="text-center py-2 pl-2">Fail %</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
${(() => {
const hideZero = document.getElementById('mailbox-stats-hide-zero')?.checked ?? true;
const filteredAliases = hideZero
? mb.aliases.filter(a => (a.sent_total || 0) + (a.received_total || 0) > 0)
: mb.aliases;
return filteredAliases.map(alias => `
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30">
<td class="py-2 pr-4">
<div class="flex items-center gap-2">
<span class="text-gray-900 dark:text-white">${escapeHtml(alias.alias_address)}</span>
${alias.is_catch_all ? '<span class="px-1.5 py-0.5 text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded">catch-all</span>' : ''}
${!alias.active ? '<span class="px-1.5 py-0.5 text-xs bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 rounded">inactive</span>' : ''}
</div>
</td>
<td class="text-center py-2 px-2 ${getDirectionTextClass('outbound')} cursor-pointer hover:underline" onclick="event.stopPropagation(); navigateToMessagesWithFilter({ email: '${escapeHtml(alias.alias_address)}', filterType: 'search', direction: 'outbound' })">${alias.sent_total || 0}</td>
<td class="text-center py-2 px-2 ${getDirectionTextClass('inbound')} cursor-pointer hover:underline" onclick="event.stopPropagation(); navigateToMessagesWithFilter({ email: '${escapeHtml(alias.alias_address)}', filterType: 'search', direction: 'inbound' })">${alias.received_total || 0}</td>
<td class="text-center py-2 px-2 ${getDirectionTextClass('internal')} cursor-pointer hover:underline" onclick="event.stopPropagation(); navigateToMessagesWithFilter({ email: '${escapeHtml(alias.alias_address)}', filterType: 'search', direction: 'internal' })">${alias.direction_internal || 0}</td>
<td class="text-center py-2 px-2 ${getStatusTextClass('delivered')} cursor-pointer hover:underline" onclick="event.stopPropagation(); navigateToMessagesWithFilter({ email: '${escapeHtml(alias.alias_address)}', filterType: 'search', status: 'delivered' })">${alias.sent_delivered || 0}</td>
<td class="text-center py-2 px-2 ${getStatusTextClass('deferred')} cursor-pointer hover:underline" onclick="event.stopPropagation(); navigateToMessagesWithFilter({ email: '${escapeHtml(alias.alias_address)}', filterType: 'search', status: 'deferred' })">${alias.sent_deferred || 0}</td>
<td class="text-center py-2 px-2 ${getStatusTextClass('bounced')} cursor-pointer hover:underline" onclick="event.stopPropagation(); navigateToMessagesWithFilter({ email: '${escapeHtml(alias.alias_address)}', filterType: 'search', status: 'bounced' })">${alias.sent_bounced || 0}</td>
<td class="text-center py-2 px-2 ${getStatusTextClass('rejected')} cursor-pointer hover:underline" onclick="event.stopPropagation(); navigateToMessagesWithFilter({ email: '${escapeHtml(alias.alias_address)}', filterType: 'search', status: 'rejected' })">${alias.sent_rejected || 0}</td>
<td class="text-center py-2 pl-2 ${alias.failure_rate >= 5 ? 'text-red-600 dark:text-red-400' : 'text-gray-500'}">${alias.failure_rate || 0}%</td>
</tr>
`).join('');
})()}
</tbody>
</table>
</div>
</div>
` : ''}
</div>
</div>
`;
}).join('');
// Add pagination controls if there are multiple pages
if (totalPages > 1) {
html += `
<div class="flex items-center justify-center gap-2 mt-4 p-4 bg-white dark:bg-gray-800 rounded-lg shadow">
<button onclick="loadMailboxStatsPage(1)" ${page === 1 ? 'disabled' : ''}
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded ${page === 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100 dark:hover:bg-gray-700'}">
First
</button>
<button onclick="loadMailboxStatsPage(${page - 1})" ${page === 1 ? 'disabled' : ''}
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded ${page === 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100 dark:hover:bg-gray-700'}">
Previous
</button>
<span class="px-4 py-1.5 text-sm text-gray-700 dark:text-gray-300">
Page ${page} of ${totalPages}
</span>
<button onclick="loadMailboxStatsPage(${page + 1})" ${page === totalPages ? 'disabled' : ''}
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded ${page === totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100 dark:hover:bg-gray-700'}">
Next
</button>
<button onclick="loadMailboxStatsPage(${totalPages})" ${page === totalPages ? 'disabled' : ''}
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded ${page === totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100 dark:hover:bg-gray-700'}">
Last
</button>
</div>
`;
}
container.innerHTML = html;
}
function toggleMailboxAccordion(username) {
const mailboxes = mailboxStatsCache.mailboxes || [];
const index = mailboxes.findIndex(m => m.username === username);
if (index === -1) return;
const content = document.getElementById(`accordion-content-${index}`);
const icon = document.getElementById(`accordion-icon-${index}`);
if (content) {
const isHidden = content.classList.contains('hidden');
content.classList.toggle('hidden');
if (isHidden) {
mailboxStatsCache.expandedMailboxes.add(username);
} else {
mailboxStatsCache.expandedMailboxes.delete(username);
}
}
if (icon) {
icon.classList.toggle('rotate-90');
}
}
// =============================================================================
// DATE RANGE PICKER
// =============================================================================
// Date range picker state
let dateRangePickerOpen = false;
function toggleDateRangePicker() {
const dropdown = document.getElementById('date-range-dropdown');
const arrow = document.getElementById('date-range-arrow');
if (!dropdown) return;
dateRangePickerOpen = !dateRangePickerOpen;
if (dateRangePickerOpen) {
dropdown.classList.remove('hidden');
arrow?.classList.add('rotate-180');
// Set default dates for custom range inputs
const today = new Date();
const thirtyDaysAgo = new Date(today);
thirtyDaysAgo.setDate(today.getDate() - 30);
const startInput = document.getElementById('date-range-start');
const endInput = document.getElementById('date-range-end');
if (startInput && !startInput.value) {
startInput.value = thirtyDaysAgo.toISOString().split('T')[0];
}
if (endInput && !endInput.value) {
endInput.value = today.toISOString().split('T')[0];
}
// Add click outside listener
setTimeout(() => {
document.addEventListener('click', closeDateRangePickerOnClickOutside);
}, 0);
} else {
closeDateRangePicker();
}
}
function closeDateRangePicker() {
const dropdown = document.getElementById('date-range-dropdown');
const arrow = document.getElementById('date-range-arrow');
if (dropdown) dropdown.classList.add('hidden');
if (arrow) arrow.classList.remove('rotate-180');
dateRangePickerOpen = false;
document.removeEventListener('click', closeDateRangePickerOnClickOutside);
}
function closeDateRangePickerOnClickOutside(e) {
const container = document.getElementById('date-range-picker-container');
if (container && !container.contains(e.target)) {
closeDateRangePicker();
}
}
function selectDatePreset(preset) {
// Update hidden input
const hiddenInput = document.getElementById('mailbox-stats-date-range');
if (hiddenInput) hiddenInput.value = preset;
// Clear custom date inputs
document.getElementById('mailbox-stats-start-date').value = '';
document.getElementById('mailbox-stats-end-date').value = '';
// Update label
const labelMap = {
'today': 'Today',
'7days': 'Last 7 Days',
'30days': 'Last 30 Days',
'90days': 'Last 90 Days'
};
const label = document.getElementById('date-range-label');
if (label) label.textContent = labelMap[preset] || preset;
// Update active state on buttons
updateDatePresetButtons(preset);
// Close dropdown and reload data
closeDateRangePicker();
loadMailboxStats();
}
function updateDatePresetButtons(activePreset) {
const buttons = document.querySelectorAll('.date-preset-btn');
buttons.forEach(btn => {
const preset = btn.getAttribute('data-preset');
if (preset === activePreset) {
btn.className = 'date-preset-btn px-3 py-1.5 text-xs font-medium rounded-md border border-blue-500 bg-blue-500 text-white transition-colors';
} else {
btn.className = 'date-preset-btn px-3 py-1.5 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors';
}
});
}
function applyCustomDateRange() {
const startInput = document.getElementById('date-range-start');
const endInput = document.getElementById('date-range-end');
if (!startInput?.value || !endInput?.value) {
showToast('Please select both start and end dates', 'error');
return;
}
const startDate = new Date(startInput.value);
const endDate = new Date(endInput.value);
if (startDate > endDate) {
showToast('Start date must be before end date', 'error');
return;
}
// Set to custom mode
const hiddenInput = document.getElementById('mailbox-stats-date-range');
if (hiddenInput) hiddenInput.value = 'custom';
// Store custom dates
document.getElementById('mailbox-stats-start-date').value = startInput.value;
document.getElementById('mailbox-stats-end-date').value = endInput.value;
// Update label with date range
const label = document.getElementById('date-range-label');
if (label) {
const startFormatted = formatDateShort(startInput.value);
const endFormatted = formatDateShort(endInput.value);
label.textContent = `${startFormatted} - ${endFormatted}`;
}
// Clear active state on preset buttons (none active for custom)
updateDatePresetButtons('custom');
// Close dropdown and reload data
closeDateRangePicker();
loadMailboxStats();
}
function formatDateShort(dateStr) {
const date = new Date(dateStr);
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
return `${day}/${month}`;
}
function applyMailboxStatsFilters() {
loadMailboxStatsList(1); // Reset to page 1 when filters change
}
function resetMailboxStatsFilters() {
// Reset search
const searchEl = document.getElementById('mailbox-stats-search');
if (searchEl) searchEl.value = '';
// Reset date range to 30 days
const dateRangeEl = document.getElementById('mailbox-stats-date-range');
if (dateRangeEl) dateRangeEl.value = '30days';
// Reset custom date inputs
const startDateEl = document.getElementById('mailbox-stats-start-date');
if (startDateEl) startDateEl.value = '';
const endDateEl = document.getElementById('mailbox-stats-end-date');
if (endDateEl) endDateEl.value = '';
// Reset date range label
const labelEl = document.getElementById('date-range-label');
if (labelEl) labelEl.textContent = 'Last 30 Days';
// Update preset buttons
updateDatePresetButtons('30days');
// Reset the date picker inputs as well
const startInput = document.getElementById('date-range-start');
const endInput = document.getElementById('date-range-end');
if (startInput) startInput.value = '';
if (endInput) endInput.value = '';
// Reset domain filter
const domainEl = document.getElementById('mailbox-stats-domain-filter');
if (domainEl) domainEl.value = '';
// Reset sort
const sortEl = document.getElementById('mailbox-stats-sort');
if (sortEl) sortEl.value = 'sent_total-desc';
// Set active only to checked (default)
const activeOnlyEl = document.getElementById('mailbox-stats-active-only');
if (activeOnlyEl) activeOnlyEl.checked = true;
// Set hide zero to unchecked (default)
const hideZeroEl = document.getElementById('mailbox-stats-hide-zero');
if (hideZeroEl) hideZeroEl.checked = true;
// Reload everything
loadMailboxStats();
}
function loadMailboxStatsPage(page) {
loadMailboxStatsList(page);
}
// Helper function to escape HTML
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}