diff --git a/application/config.js b/application/config.js
index 01241f9..171542d 100644
--- a/application/config.js
+++ b/application/config.js
@@ -50,12 +50,6 @@ const config = {
},
blacklistedSenders: parseValue(process.env.EMAIL_BLACKLISTED_SENDERS) || [],
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
}
},
diff --git a/domain/statistics-store.js b/domain/statistics-store.js
index c7b79f4..14b5539 100644
--- a/domain/statistics-store.js
+++ b/domain/statistics-store.js
@@ -309,8 +309,8 @@ class StatisticsStore {
// Calculate emails per hour rate (average across all active hours)
const activeHours = hourlyActivity.filter(count => count > 0).length
const emailsPerHour = activeHours > 0 ?
- (allMails.length / activeHours).toFixed(1) :
- '0.0'
+ Math.round(allMails.length / activeHours) :
+ 0
// Calculate day/night percentage
const totalDayNight = dayTimeEmails + nightTimeEmails
@@ -327,7 +327,7 @@ class StatisticsStore {
uniqueSenderDomains: senderDomains.size,
uniqueRecipientDomains: recipientDomains.size,
peakHourPercentage,
- emailsPerHour: parseFloat(emailsPerHour),
+ emailsPerHour: emailsPerHour,
dayPercentage
}
diff --git a/infrastructure/web/public/javascripts/stats.js b/infrastructure/web/public/javascripts/stats.js
index e780b51..9cc12c5 100644
--- a/infrastructure/web/public/javascripts/stats.js
+++ b/infrastructure/web/public/javascripts/stats.js
@@ -3,6 +3,10 @@
* 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
document.addEventListener('DOMContentLoaded', function() {
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`);
+ // 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
if (typeof io !== 'undefined') {
const socket = io();
@@ -63,6 +73,7 @@ document.addEventListener('DOMContentLoaded', function() {
// Create gradient for fading effect on historical data
const ctx = chartCanvas.getContext('2d');
+ chartContext = ctx;
const historicalGradient = ctx.createLinearGradient(0, 0, chartCanvas.width * 0.3, 0);
historicalGradient.addColorStop(0, 'rgba(100, 100, 255, 0.05)');
historicalGradient.addColorStop(1, 'rgba(100, 100, 255, 0.15)');
@@ -70,7 +81,7 @@ document.addEventListener('DOMContentLoaded', function() {
// Track visibility state for each dataset
const datasetVisibility = [true, true, true];
- const chart = new Chart(ctx, {
+ statsChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
@@ -132,7 +143,9 @@ document.addEventListener('DOMContentLoaded', function() {
intersect: false,
callbacks: {
title: function(context) {
+ if (!context || !context[0] || context[0].dataIndex === undefined) return '';
const dataIndex = context[0].dataIndex;
+ if (!allTimePoints[dataIndex]) return '';
const point = allTimePoints[dataIndex];
const date = new Date(point.timestamp);
return date.toLocaleString('en-US', {
@@ -218,8 +231,8 @@ document.addEventListener('DOMContentLoaded', function() {
this.classList.toggle('active');
// Toggle dataset visibility with fade effect
- const meta = chart.getDatasetMeta(index);
- const dataset = chart.data.datasets[index];
+ const meta = statsChart.getDatasetMeta(index);
+ const dataset = statsChart.data.datasets[index];
if (isActive) {
// Fade out
@@ -231,7 +244,154 @@ document.addEventListener('DOMContentLoaded', function() {
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 += `
${item.domain}${item.count}`;
+ });
+ topSenderDomains.innerHTML = html;
+ }
+
+ if (topRecipientDomains && data.enhanced.topRecipientDomains && data.enhanced.topRecipientDomains.length > 0) {
+ let html = '';
+ data.enhanced.topRecipientDomains.slice(0, 5).forEach(item => {
+ html += `${item.domain}${item.count}`;
+ });
+ 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
+ });
+}
diff --git a/infrastructure/web/public/javascripts/utils.js b/infrastructure/web/public/javascripts/utils.js
index 9c1e935..26fa540 100644
--- a/infrastructure/web/public/javascripts/utils.js
+++ b/infrastructure/web/public/javascripts/utils.js
@@ -498,8 +498,61 @@ document.addEventListener('DOMContentLoaded', () => {
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
- 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();
formatMailDate();
initLockModals();
@@ -509,4 +562,5 @@ document.addEventListener('DOMContentLoaded', () => {
initThemeToggle();
initForwardModal();
initCryptoKeysToggle();
+ initAccountModals();
});
diff --git a/infrastructure/web/public/stylesheets/custom.css b/infrastructure/web/public/stylesheets/custom.css
index 905da0f..383b77d 100644
--- a/infrastructure/web/public/stylesheets/custom.css
+++ b/infrastructure/web/public/stylesheets/custom.css
@@ -2115,6 +2115,7 @@ label {
}
.close {
+ text-align: right;
float: right;
font-size: 2.8rem;
font-weight: bold;
diff --git a/infrastructure/web/routes/account.js b/infrastructure/web/routes/account.js
index 5307bef..141793d 100644
--- a/infrastructure/web/routes/account.js
+++ b/infrastructure/web/routes/account.js
@@ -37,6 +37,7 @@ router.get('/account', requireAuth, async(req, res) => {
stats,
branding: config.http.features.branding || ['48hr.email', 'Service', 'https://example.com'],
purgeTime: purgeTime,
+ smtpEnabled: config.email.features.smtp,
successMessage: req.session.accountSuccess,
errorMessage: req.session.accountError
})
diff --git a/infrastructure/web/routes/auth.js b/infrastructure/web/routes/auth.js
index 2daecb1..7d55bf1 100644
--- a/infrastructure/web/routes/auth.js
+++ b/infrastructure/web/routes/auth.js
@@ -107,6 +107,7 @@ router.get('/auth', redirectIfAuthenticated, (req, res) => {
title: `Login or Register | ${(config.http.features.branding || ['48hr.email'])[0]}`,
branding: config.http.features.branding || ['48hr.email', 'Service', 'https://example.com'],
purgeTime: purgeTime,
+ smtpEnabled: config.email.features.smtp,
errorMessage,
successMessage
})
diff --git a/infrastructure/web/routes/stats.js b/infrastructure/web/routes/stats.js
index e32dfc9..5636a9f 100644
--- a/infrastructure/web/routes/stats.js
+++ b/infrastructure/web/routes/stats.js
@@ -2,7 +2,7 @@ const express = require('express')
const router = new express.Router()
const debug = require('debug')('48hr-email:stats-routes')
-// GET /stats - Statistics page
+// GET /stats - Statistics page with lazy loading
router.get('/', async(req, res) => {
try {
const config = req.app.get('config')
@@ -16,6 +16,61 @@ router.get('/', async(req, res) => {
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 imapService = req.app.get('imapService')
const mailProcessingService = req.app.get('mailProcessingService')
@@ -36,46 +91,13 @@ router.get('/', async(req, res) => {
}
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`)
-
- 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()
+ debug(`Stats API returned: ${stats.currentCount} current, ${stats.allTimeTotal} all-time total`)
res.json(stats)
} catch (error) {
debug(`Error fetching stats API: ${error.message}`)
+ console.error('Stats API error:', error)
res.status(500).json({ error: 'Failed to fetch statistics' })
}
})
diff --git a/infrastructure/web/template-context.js b/infrastructure/web/template-context.js
index fa506a5..d274c95 100644
--- a/infrastructure/web/template-context.js
+++ b/infrastructure/web/template-context.js
@@ -9,6 +9,8 @@ class TemplateContext {
constructor() {
this.helper = new Helper()
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,
// Common data
- domains: this.helper.getDomains(),
+ domains: this.cachedDomains,
example: config.email.examples.account
}
}
diff --git a/infrastructure/web/views/account.twig b/infrastructure/web/views/account.twig
index 51b7a27..e5f3b8c 100644
--- a/infrastructure/web/views/account.twig
+++ b/infrastructure/web/views/account.twig
@@ -44,10 +44,12 @@
Account Overview
+ {% if smtpEnabled %}
{{ stats.forwardEmailsCount }}/{{ stats.maxForwardEmails }}
Forward Emails
+ {% endif %}
{{ stats.lockedInboxesCount }}/{{ stats.maxLockedInboxes }}
Locked Inboxes
@@ -59,6 +61,7 @@
+ {% if smtpEnabled %}
Forwarding Emails
@@ -91,6 +94,7 @@
Maximum {{ stats.maxForwardEmails }} emails reached
{% endif %}
+ {% endif %}
@@ -178,7 +182,7 @@
Warning: Deleting your account will:
- - Remove all forwarding email addresses
+ {% if smtpEnabled %}- Remove all forwarding email addresses
{% endif %}
- Release all locked inboxes
- Permanently delete your account data
@@ -226,6 +230,7 @@
+{% if smtpEnabled %}
-
-
+{% endif %}
{% endblock %}
diff --git a/infrastructure/web/views/auth.twig b/infrastructure/web/views/auth.twig
index 6f93248..1d05527 100644
--- a/infrastructure/web/views/auth.twig
+++ b/infrastructure/web/views/auth.twig
@@ -123,9 +123,11 @@
Account Benefits
+ {% if smtpEnabled %}
Forward emails to verified addresses
-
Lock up to 5 inboxes to your account
Manage multiple forwarding destinations
+ {% endif %}
+
Lock up to 5 inboxes to your account
Access your locked inboxes anywhere
No account needed for basic temporary inboxes • Browse as guest
diff --git a/infrastructure/web/views/stats.twig b/infrastructure/web/views/stats.twig
index 858fb5b..f1405ed 100644
--- a/infrastructure/web/views/stats.twig
+++ b/infrastructure/web/views/stats.twig
@@ -97,7 +97,7 @@
Top Sender Domains
{% if stats.enhanced.topSenderDomains|length > 0 %}
-
+
{% for item in stats.enhanced.topSenderDomains|slice(0, 5) %}
-
{{ item.domain }}
@@ -105,7 +105,7 @@
{% endfor %}
-
+
{% else %}
No data yet
{% endif %}
@@ -117,7 +117,7 @@
Top Recipient Domains
{% if stats.enhanced.topRecipientDomains|length > 0 %}
-
+
{% for item in stats.enhanced.topRecipientDomains|slice(0, 5) %}
-
{{ item.domain }}
@@ -125,7 +125,7 @@
{% endfor %}
-
+
{% else %}
No data yet
{% endif %}