[Feat]: Bring back performance V2

Electric Boogaloo
This commit is contained in:
ClaraCrazy 2026-01-05 05:21:18 +01:00
parent 345935f8b9
commit 197d9b923e
No known key found for this signature in database
GPG key ID: EBBC896ACB497011
12 changed files with 300 additions and 109 deletions

View file

@ -50,12 +50,6 @@ const config = {
}, },
blacklistedSenders: parseValue(process.env.EMAIL_BLACKLISTED_SENDERS) || [], blacklistedSenders: parseValue(process.env.EMAIL_BLACKLISTED_SENDERS) || [],
features: { features: {
imap: {
enabled: true, // IMAP is always required
refreshIntervalSeconds: Number(process.env.IMAP_REFRESH_INTERVAL_SECONDS),
fetchChunkSize: Number(process.env.IMAP_FETCH_CHUNK) || 100,
fetchConcurrency: Number(process.env.IMAP_CONCURRENCY) || 6
},
smtp: parseBool(process.env.SMTP_ENABLED) || false smtp: parseBool(process.env.SMTP_ENABLED) || false
} }
}, },

View file

@ -309,8 +309,8 @@ class StatisticsStore {
// Calculate emails per hour rate (average across all active hours) // Calculate emails per hour rate (average across all active hours)
const activeHours = hourlyActivity.filter(count => count > 0).length const activeHours = hourlyActivity.filter(count => count > 0).length
const emailsPerHour = activeHours > 0 ? const emailsPerHour = activeHours > 0 ?
(allMails.length / activeHours).toFixed(1) : Math.round(allMails.length / activeHours) :
'0.0' 0
// Calculate day/night percentage // Calculate day/night percentage
const totalDayNight = dayTimeEmails + nightTimeEmails const totalDayNight = dayTimeEmails + nightTimeEmails
@ -327,7 +327,7 @@ class StatisticsStore {
uniqueSenderDomains: senderDomains.size, uniqueSenderDomains: senderDomains.size,
uniqueRecipientDomains: recipientDomains.size, uniqueRecipientDomains: recipientDomains.size,
peakHourPercentage, peakHourPercentage,
emailsPerHour: parseFloat(emailsPerHour), emailsPerHour: emailsPerHour,
dayPercentage dayPercentage
} }

View file

@ -3,6 +3,10 @@
* Handles Chart.js initialization with historical, real-time, and predicted data * Handles Chart.js initialization with historical, real-time, and predicted data
*/ */
// Store chart instance globally for updates
let statsChart = null;
let chartContext = null;
// Initialize stats chart if on stats page // Initialize stats chart if on stats page
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const chartCanvas = document.getElementById('statsChart'); const chartCanvas = document.getElementById('statsChart');
@ -20,6 +24,12 @@ document.addEventListener('DOMContentLoaded', function() {
console.log(`Loaded data: ${historicalData.length} historical, ${realtimeData.length} realtime, ${predictionData.length} predictions`); console.log(`Loaded data: ${historicalData.length} historical, ${realtimeData.length} realtime, ${predictionData.length} predictions`);
// If no data yet (lazy loading), add a placeholder message
const hasData = historicalData.length > 0 || realtimeData.length > 0 || predictionData.length > 0;
if (!hasData) {
console.log('No chart data yet - will populate after lazy load');
}
// Set up Socket.IO connection for real-time updates // Set up Socket.IO connection for real-time updates
if (typeof io !== 'undefined') { if (typeof io !== 'undefined') {
const socket = io(); const socket = io();
@ -63,6 +73,7 @@ document.addEventListener('DOMContentLoaded', function() {
// Create gradient for fading effect on historical data // Create gradient for fading effect on historical data
const ctx = chartCanvas.getContext('2d'); const ctx = chartCanvas.getContext('2d');
chartContext = ctx;
const historicalGradient = ctx.createLinearGradient(0, 0, chartCanvas.width * 0.3, 0); const historicalGradient = ctx.createLinearGradient(0, 0, chartCanvas.width * 0.3, 0);
historicalGradient.addColorStop(0, 'rgba(100, 100, 255, 0.05)'); historicalGradient.addColorStop(0, 'rgba(100, 100, 255, 0.05)');
historicalGradient.addColorStop(1, 'rgba(100, 100, 255, 0.15)'); historicalGradient.addColorStop(1, 'rgba(100, 100, 255, 0.15)');
@ -70,7 +81,7 @@ document.addEventListener('DOMContentLoaded', function() {
// Track visibility state for each dataset // Track visibility state for each dataset
const datasetVisibility = [true, true, true]; const datasetVisibility = [true, true, true];
const chart = new Chart(ctx, { statsChart = new Chart(ctx, {
type: 'line', type: 'line',
data: { data: {
labels: labels, labels: labels,
@ -132,7 +143,9 @@ document.addEventListener('DOMContentLoaded', function() {
intersect: false, intersect: false,
callbacks: { callbacks: {
title: function(context) { title: function(context) {
if (!context || !context[0] || context[0].dataIndex === undefined) return '';
const dataIndex = context[0].dataIndex; const dataIndex = context[0].dataIndex;
if (!allTimePoints[dataIndex]) return '';
const point = allTimePoints[dataIndex]; const point = allTimePoints[dataIndex];
const date = new Date(point.timestamp); const date = new Date(point.timestamp);
return date.toLocaleString('en-US', { return date.toLocaleString('en-US', {
@ -218,8 +231,8 @@ document.addEventListener('DOMContentLoaded', function() {
this.classList.toggle('active'); this.classList.toggle('active');
// Toggle dataset visibility with fade effect // Toggle dataset visibility with fade effect
const meta = chart.getDatasetMeta(index); const meta = statsChart.getDatasetMeta(index);
const dataset = chart.data.datasets[index]; const dataset = statsChart.data.datasets[index];
if (isActive) { if (isActive) {
// Fade out // Fade out
@ -231,7 +244,154 @@ document.addEventListener('DOMContentLoaded', function() {
datasetVisibility[index] = true; datasetVisibility[index] = true;
} }
chart.update('active'); statsChart.update('active');
}); });
}); });
// Lazy load full stats data if placeholder detected
lazyLoadStats();
}); });
/**
* Rebuild chart with new data
*/
function rebuildStatsChart() {
if (!statsChart || !chartContext) {
console.log('Chart not initialized, skipping rebuild');
return;
}
const realtimeData = window.initialStatsData || [];
const historicalData = window.historicalData || [];
const predictionData = window.predictionData || [];
console.log(`Rebuilding chart with: ${historicalData.length} historical, ${realtimeData.length} realtime, ${predictionData.length} predictions`);
const allTimePoints = [
...historicalData.map(d => ({...d, type: 'historical' })),
...realtimeData.map(d => ({...d, type: 'realtime' })),
...predictionData.map(d => ({...d, type: 'prediction' }))
].sort((a, b) => a.timestamp - b.timestamp);
if (allTimePoints.length === 0) {
console.log('No data points to chart');
return;
}
// Create labels
const labels = allTimePoints.map(d => {
const date = new Date(d.timestamp);
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
});
// Prepare datasets
const historicalPoints = allTimePoints.map(d => d.type === 'historical' ? d.receives : null);
const realtimePoints = allTimePoints.map(d => d.type === 'realtime' ? d.receives : null);
const predictionPoints = allTimePoints.map(d => d.type === 'prediction' ? d.receives : null);
// Update chart data
statsChart.data.labels = labels;
statsChart.data.datasets[0].data = historicalPoints;
statsChart.data.datasets[1].data = realtimePoints;
statsChart.data.datasets[2].data = predictionPoints;
// Update the chart
statsChart.update();
}
/**
* Lazy load full statistics data and update DOM
*/
function lazyLoadStats() {
// Check if this is a lazy-loaded page (has placeholder data)
const currentCountEl = document.getElementById('currentCount');
if (!currentCountEl) {
console.log('Stats lazy load: currentCount element not found');
return;
}
const currentText = currentCountEl.textContent.trim();
console.log('Stats lazy load: current count text is:', currentText);
if (currentText !== '...') {
console.log('Stats lazy load: already loaded with real data, skipping');
return; // Already loaded with real data
}
console.log('Stats lazy load: fetching data from /stats/api');
fetch('/stats/api')
.then(response => response.json())
.then(data => {
console.log('Stats lazy load: received data', data);
// Update main stat cards
document.getElementById('currentCount').textContent = data.currentCount || '0';
document.getElementById('historicalTotal').textContent = data.allTimeTotal || '0';
document.getElementById('receives24h').textContent = (data.last24Hours && data.last24Hours.receives) || '0';
document.getElementById('deletes24h').textContent = (data.last24Hours && data.last24Hours.deletes) || '0';
document.getElementById('forwards24h').textContent = (data.last24Hours && data.last24Hours.forwards) || '0';
// Update enhanced stats if available
if (data.enhanced) {
const topSenderDomains = document.querySelector('[data-stats="top-sender-domains"]');
const topRecipientDomains = document.querySelector('[data-stats="top-recipient-domains"]');
if (topSenderDomains && data.enhanced.topSenderDomains && data.enhanced.topSenderDomains.length > 0) {
let html = '';
data.enhanced.topSenderDomains.slice(0, 5).forEach(item => {
html += `<li class="stat-list-item"><span class="stat-list-label">${item.domain}</span><span class="stat-list-value">${item.count}</span></li>`;
});
topSenderDomains.innerHTML = html;
}
if (topRecipientDomains && data.enhanced.topRecipientDomains && data.enhanced.topRecipientDomains.length > 0) {
let html = '';
data.enhanced.topRecipientDomains.slice(0, 5).forEach(item => {
html += `<li class="stat-list-item"><span class="stat-list-label">${item.domain}</span><span class="stat-list-value">${item.count}</span></li>`;
});
topRecipientDomains.innerHTML = html;
}
// Update unique domains count
const uniqueSenderDomains = document.querySelector('[data-stats="unique-sender-domains"]');
if (uniqueSenderDomains && data.enhanced.uniqueSenderDomains) {
uniqueSenderDomains.textContent = data.enhanced.uniqueSenderDomains;
}
// Update average email size
const avgSize = document.querySelector('[data-stats="average-email-size"]');
if (avgSize && data.enhanced.averageEmailSize) {
avgSize.textContent = data.enhanced.averageEmailSize;
}
// Update peak hour
const peakHour = document.querySelector('[data-stats="peak-hour"]');
if (peakHour && data.enhanced.peakHour) {
peakHour.textContent = data.enhanced.peakHour;
}
// Update 24h prediction
const prediction24h = document.querySelector('[data-stats="prediction-24h"]');
if (prediction24h && data.enhanced.prediction24h) {
prediction24h.textContent = data.enhanced.prediction24h;
}
}
// Update window data for charts
window.initialStatsData = (data.last24Hours && data.last24Hours.timeline) || [];
window.historicalData = data.historical || [];
window.predictionData = data.prediction || [];
// Rebuild chart with new data
rebuildStatsChart();
})
.catch(error => {
console.error('Error loading stats:', error);
// Stats remain as placeholder
});
}

View file

@ -498,8 +498,61 @@ document.addEventListener('DOMContentLoaded', () => {
refreshTimer.textContent = refreshInterval; refreshTimer.textContent = refreshInterval;
} }
function initAccountModals() {
// Add Email Modal
const addEmailBtn = document.getElementById('addEmailBtn');
const addEmailModal = document.getElementById('addEmailModal');
const closeAddEmail = document.getElementById('closeAddEmail');
if (addEmailBtn && addEmailModal) {
addEmailBtn.onclick = function() {
addEmailModal.style.display = 'block';
};
}
if (closeAddEmail && addEmailModal) {
closeAddEmail.onclick = function() {
addEmailModal.style.display = 'none';
};
}
// Delete Account Modal
const deleteAccountBtn = document.getElementById('deleteAccountBtn');
const deleteAccountModal = document.getElementById('deleteAccountModal');
const closeDeleteAccount = document.getElementById('closeDeleteAccount');
const cancelDelete = document.getElementById('cancelDelete');
if (deleteAccountBtn && deleteAccountModal) {
deleteAccountBtn.onclick = function() {
deleteAccountModal.style.display = 'block';
};
}
if (closeDeleteAccount && deleteAccountModal) {
closeDeleteAccount.onclick = function() {
deleteAccountModal.style.display = 'none';
};
}
if (cancelDelete && deleteAccountModal) {
cancelDelete.onclick = function() {
deleteAccountModal.style.display = 'none';
};
}
// Window click handler for both modals
window.addEventListener('click', function(e) {
if (addEmailModal && e.target === addEmailModal) {
addEmailModal.style.display = 'none';
}
if (deleteAccountModal && e.target === deleteAccountModal) {
deleteAccountModal.style.display = 'none';
}
});
}
// Expose utilities and run them // Expose utilities and run them
window.utils = { formatEmailDates, formatMailDate, initLockModals, initCopyAddress, initExpiryTimers, initQrModal, initHamburgerMenu, initThemeToggle, initRefreshCountdown, initCryptoKeysToggle, initForwardModal, initForwardAllModal }; window.utils = { formatEmailDates, formatMailDate, initLockModals, initCopyAddress, initExpiryTimers, initQrModal, initHamburgerMenu, initThemeToggle, initRefreshCountdown, initCryptoKeysToggle, initForwardModal, initForwardAllModal, initAccountModals };
formatEmailDates(); formatEmailDates();
formatMailDate(); formatMailDate();
initLockModals(); initLockModals();
@ -509,4 +562,5 @@ document.addEventListener('DOMContentLoaded', () => {
initThemeToggle(); initThemeToggle();
initForwardModal(); initForwardModal();
initCryptoKeysToggle(); initCryptoKeysToggle();
initAccountModals();
}); });

View file

@ -2115,6 +2115,7 @@ label {
} }
.close { .close {
text-align: right;
float: right; float: right;
font-size: 2.8rem; font-size: 2.8rem;
font-weight: bold; font-weight: bold;

View file

@ -37,6 +37,7 @@ router.get('/account', requireAuth, async(req, res) => {
stats, stats,
branding: config.http.features.branding || ['48hr.email', 'Service', 'https://example.com'], branding: config.http.features.branding || ['48hr.email', 'Service', 'https://example.com'],
purgeTime: purgeTime, purgeTime: purgeTime,
smtpEnabled: config.email.features.smtp,
successMessage: req.session.accountSuccess, successMessage: req.session.accountSuccess,
errorMessage: req.session.accountError errorMessage: req.session.accountError
}) })

View file

@ -107,6 +107,7 @@ router.get('/auth', redirectIfAuthenticated, (req, res) => {
title: `Login or Register | ${(config.http.features.branding || ['48hr.email'])[0]}`, title: `Login or Register | ${(config.http.features.branding || ['48hr.email'])[0]}`,
branding: config.http.features.branding || ['48hr.email', 'Service', 'https://example.com'], branding: config.http.features.branding || ['48hr.email', 'Service', 'https://example.com'],
purgeTime: purgeTime, purgeTime: purgeTime,
smtpEnabled: config.email.features.smtp,
errorMessage, errorMessage,
successMessage successMessage
}) })

View file

@ -2,7 +2,7 @@ const express = require('express')
const router = new express.Router() const router = new express.Router()
const debug = require('debug')('48hr-email:stats-routes') const debug = require('debug')('48hr-email:stats-routes')
// GET /stats - Statistics page // GET /stats - Statistics page with lazy loading
router.get('/', async(req, res) => { router.get('/', async(req, res) => {
try { try {
const config = req.app.get('config') const config = req.app.get('config')
@ -16,6 +16,61 @@ router.get('/', async(req, res) => {
return res.redirect(redirectUrl) return res.redirect(redirectUrl)
} }
const Helper = require('../../../application/helper')
const helper = new Helper()
const branding = config.http.features.branding || ['48hr.email', 'Service', 'https://example.com']
const purgeTime = helper.purgeTimeElemetBuilder()
// Return page with placeholder data immediately - real data loads via JS
const placeholderStats = {
currentCount: '...',
allTimeTotal: '...',
last24Hours: {
receives: '...',
deletes: '...',
forwards: '...',
timeline: []
},
enhanced: {
topSenderDomains: [],
topRecipientDomains: [],
uniqueSenderDomains: '...',
averageEmailSize: '...',
peakHour: '...',
prediction24h: '...'
},
historical: [],
prediction: []
}
debug(`Stats page requested - returning with lazy loading`)
res.render('stats', {
title: `Statistics | ${branding[0]}`,
branding: branding,
purgeTime: purgeTime,
stats: placeholderStats,
authEnabled: config.user.authEnabled,
currentUser: req.session && req.session.username,
lazyLoad: true
})
} catch (error) {
debug(`Error loading stats page: ${error.message}`)
console.error('Error while loading stats page', error)
res.status(500).send('Error loading statistics')
}
})
// GET /stats/api - JSON API for lazy-loaded stats (full calculation)
router.get('/api', async(req, res) => {
try {
const config = req.app.get('config')
// Check if statistics are enabled
if (!config.http.features.statistics) {
return res.status(403).json({ error: 'Statistics are disabled' })
}
const statisticsStore = req.app.get('statisticsStore') const statisticsStore = req.app.get('statisticsStore')
const imapService = req.app.get('imapService') const imapService = req.app.get('imapService')
const mailProcessingService = req.app.get('mailProcessingService') const mailProcessingService = req.app.get('mailProcessingService')
@ -36,46 +91,13 @@ router.get('/', async(req, res) => {
} }
const stats = statisticsStore.getEnhancedStats() const stats = statisticsStore.getEnhancedStats()
const purgeTime = helper.purgeTimeElemetBuilder()
const branding = config.http.features.branding || ['48hr.email', 'Service', 'https://example.com']
debug(`Stats page requested: ${stats.currentCount} current, ${stats.allTimeTotal} all-time total, ${stats.historical.length} historical points`) debug(`Stats API returned: ${stats.currentCount} current, ${stats.allTimeTotal} all-time total`)
res.render('stats', {
title: `Statistics | ${branding[0]}`,
branding: branding,
purgeTime: purgeTime,
stats: stats,
authEnabled: config.user.authEnabled,
currentUser: req.session && req.session.username
})
} catch (error) {
debug(`Error loading stats page: ${error.message}`)
console.error('Error while loading stats page', error)
res.status(500).send('Error loading statistics')
}
})
// GET /stats/api - JSON API for real-time updates
router.get('/api', async(req, res) => {
try {
const statisticsStore = req.app.get('statisticsStore')
const imapService = req.app.get('imapService')
const Helper = require('../../../application/helper')
const helper = new Helper()
// Update largest UID before getting stats (if IMAP is ready)
if (imapService) {
const largestUid = await helper.getLargestUid(imapService)
statisticsStore.updateLargestUid(largestUid)
}
// Use lightweight stats - no historical analysis on API calls
const stats = statisticsStore.getLightweightStats()
res.json(stats) res.json(stats)
} catch (error) { } catch (error) {
debug(`Error fetching stats API: ${error.message}`) debug(`Error fetching stats API: ${error.message}`)
console.error('Stats API error:', error)
res.status(500).json({ error: 'Failed to fetch statistics' }) res.status(500).json({ error: 'Failed to fetch statistics' })
} }
}) })

View file

@ -9,6 +9,8 @@ class TemplateContext {
constructor() { constructor() {
this.helper = new Helper() this.helper = new Helper()
this.purgeTime = this.helper.purgeTimeElemetBuilder() this.purgeTime = this.helper.purgeTimeElemetBuilder()
// Cache domains to avoid reprocessing on every request
this.cachedDomains = this.helper.getDomains()
} }
/** /**
@ -34,7 +36,7 @@ class TemplateContext {
currentUser: req.session && req.session.username ? req.session.username : null, currentUser: req.session && req.session.username ? req.session.username : null,
// Common data // Common data
domains: this.helper.getDomains(), domains: this.cachedDomains,
example: config.email.examples.account example: config.email.examples.account
} }
} }

View file

@ -44,10 +44,12 @@
<div class="account-card frosted-glass"> <div class="account-card frosted-glass">
<h2>Account Overview</h2> <h2>Account Overview</h2>
<div class="stats-grid"> <div class="stats-grid">
{% if smtpEnabled %}
<div class="stat-item"> <div class="stat-item">
<div class="stat-value">{{ stats.forwardEmailsCount }}/{{ stats.maxForwardEmails }}</div> <div class="stat-value">{{ stats.forwardEmailsCount }}/{{ stats.maxForwardEmails }}</div>
<div class="stat-label">Forward Emails</div> <div class="stat-label">Forward Emails</div>
</div> </div>
{% endif %}
<div class="stat-item"> <div class="stat-item">
<div class="stat-value">{{ stats.lockedInboxesCount }}/{{ stats.maxLockedInboxes }}</div> <div class="stat-value">{{ stats.lockedInboxesCount }}/{{ stats.maxLockedInboxes }}</div>
<div class="stat-label">Locked Inboxes</div> <div class="stat-label">Locked Inboxes</div>
@ -59,6 +61,7 @@
</div> </div>
</div> </div>
{% if smtpEnabled %}
<!-- Forwarding Emails Section --> <!-- Forwarding Emails Section -->
<div class="account-card frosted-glass"> <div class="account-card frosted-glass">
<h2>Forwarding Emails</h2> <h2>Forwarding Emails</h2>
@ -91,6 +94,7 @@
<p class="limit-reached">Maximum {{ stats.maxForwardEmails }} emails reached</p> <p class="limit-reached">Maximum {{ stats.maxForwardEmails }} emails reached</p>
{% endif %} {% endif %}
</div> </div>
{% endif %}
<!-- Locked Inboxes Section --> <!-- Locked Inboxes Section -->
<div class="account-card frosted-glass"> <div class="account-card frosted-glass">
@ -178,7 +182,7 @@
<div class="danger-content"> <div class="danger-content">
<p><strong>Warning:</strong> Deleting your account will:</p> <p><strong>Warning:</strong> Deleting your account will:</p>
<ul class="danger-list"> <ul class="danger-list">
<li>Remove all forwarding email addresses</li> {% if smtpEnabled %}<li>Remove all forwarding email addresses</li>{% endif %}
<li>Release all locked inboxes</li> <li>Release all locked inboxes</li>
<li>Permanently delete your account data</li> <li>Permanently delete your account data</li>
</ul> </ul>
@ -226,6 +230,7 @@
</div> </div>
</div> </div>
{% if smtpEnabled %}
<!-- Add Email Modal --> <!-- Add Email Modal -->
<div id="addEmailModal" class="modal"> <div id="addEmailModal" class="modal">
<div class="modal-content"> <div class="modal-content">
@ -249,56 +254,5 @@
</form> </form>
</div> </div>
</div> </div>
{% endif %}
<script>
// Add Email Modal
const addEmailBtn = document.getElementById('addEmailBtn');
const addEmailModal = document.getElementById('addEmailModal');
const closeAddEmail = document.getElementById('closeAddEmail');
if (addEmailBtn) {
addEmailBtn.onclick = function() {
addEmailModal.style.display = 'block';
}
}
if (closeAddEmail) {
closeAddEmail.onclick = function() {
addEmailModal.style.display = 'none';
}
}
// Delete Account Modal
const deleteAccountBtn = document.getElementById('deleteAccountBtn');
const deleteAccountModal = document.getElementById('deleteAccountModal');
const closeDeleteAccount = document.getElementById('closeDeleteAccount');
const cancelDelete = document.getElementById('cancelDelete');
if (deleteAccountBtn) {
deleteAccountBtn.onclick = function() {
deleteAccountModal.style.display = 'block';
}
}
if (closeDeleteAccount) {
closeDeleteAccount.onclick = function() {
deleteAccountModal.style.display = 'none';
}
}
if (cancelDelete) {
cancelDelete.onclick = function() {
deleteAccountModal.style.display = 'none';
}
}
window.onclick = function(event) {
if (event.target == addEmailModal) {
addEmailModal.style.display = 'none';
}
if (event.target == deleteAccountModal) {
deleteAccountModal.style.display = 'none';
}
}
</script>
{% endblock %} {% endblock %}

View file

@ -123,9 +123,11 @@
<div class="auth-features-unified"> <div class="auth-features-unified">
<h3>Account Benefits</h3> <h3>Account Benefits</h3>
<div class="features-grid"> <div class="features-grid">
{% if smtpEnabled %}
<div class="feature-item">Forward emails to verified addresses</div> <div class="feature-item">Forward emails to verified addresses</div>
<div class="feature-item">Lock up to 5 inboxes to your account</div>
<div class="feature-item">Manage multiple forwarding destinations</div> <div class="feature-item">Manage multiple forwarding destinations</div>
{% endif %}
<div class="feature-item">Lock up to 5 inboxes to your account</div>
<div class="feature-item">Access your locked inboxes anywhere</div> <div class="feature-item">Access your locked inboxes anywhere</div>
</div> </div>
<p class="guest-note">No account needed for basic temporary inboxes • <a href="/">Browse as guest</a></p> <p class="guest-note">No account needed for basic temporary inboxes • <a href="/">Browse as guest</a></p>

View file

@ -97,7 +97,7 @@
Top Sender Domains Top Sender Domains
</h3> </h3>
{% if stats.enhanced.topSenderDomains|length > 0 %} {% if stats.enhanced.topSenderDomains|length > 0 %}
<ul class="stat-list"> <ul class="stat-list" data-stats="top-sender-domains">
{% for item in stats.enhanced.topSenderDomains|slice(0, 5) %} {% for item in stats.enhanced.topSenderDomains|slice(0, 5) %}
<li class="stat-list-item"> <li class="stat-list-item">
<span class="stat-list-label">{{ item.domain }}</span> <span class="stat-list-label">{{ item.domain }}</span>
@ -105,7 +105,7 @@
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
<p class="stat-footer">{{ stats.enhanced.uniqueSenderDomains }} unique domains</p> <p class="stat-footer"><span data-stats="unique-sender-domains">{{ stats.enhanced.uniqueSenderDomains }}</span> unique domains</p>
{% else %} {% else %}
<p class="stat-empty">No data yet</p> <p class="stat-empty">No data yet</p>
{% endif %} {% endif %}
@ -117,7 +117,7 @@
Top Recipient Domains Top Recipient Domains
</h3> </h3>
{% if stats.enhanced.topRecipientDomains|length > 0 %} {% if stats.enhanced.topRecipientDomains|length > 0 %}
<ul class="stat-list"> <ul class="stat-list" data-stats="top-recipient-domains">
{% for item in stats.enhanced.topRecipientDomains|slice(0, 5) %} {% for item in stats.enhanced.topRecipientDomains|slice(0, 5) %}
<li class="stat-list-item"> <li class="stat-list-item">
<span class="stat-list-label">{{ item.domain }}</span> <span class="stat-list-label">{{ item.domain }}</span>
@ -125,7 +125,7 @@
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
<p class="stat-footer">{{ stats.enhanced.uniqueRecipientDomains }} unique domains</p> <p class="stat-footer"><span data-stats="unique-recipient-domains">{{ stats.enhanced.uniqueRecipientDomains }}</span> unique domains</p>
{% else %} {% else %}
<p class="stat-empty">No data yet</p> <p class="stat-empty">No data yet</p>
{% endif %} {% endif %}