From e012b772c8507e906c852ee1ca9797d2c603aaa5 Mon Sep 17 00:00:00 2001 From: ClaraCrazy Date: Sat, 3 Jan 2026 15:41:56 +0100 Subject: [PATCH] [Feat]: Add Stats page --- app.js | 15 +- application/helper.js | 7 - application/mail-processing-service.js | 18 +- domain/statistics-store.js | 190 ++++++++++++++++++ infrastructure/web/middleware/lock.js | 2 - .../web/public/javascripts/stats.js | 126 ++++++++++++ .../web/public/stylesheets/custom.css | 84 ++++++++ infrastructure/web/routes/account.js | 7 +- infrastructure/web/routes/error.js | 5 - infrastructure/web/routes/inbox.js | 18 +- infrastructure/web/routes/login.js | 8 - infrastructure/web/routes/stats.js | 105 ++++++++++ infrastructure/web/views/account.twig | 2 +- infrastructure/web/views/auth.twig | 2 +- infrastructure/web/views/error.twig | 2 +- infrastructure/web/views/layout.twig | 4 +- infrastructure/web/views/login.twig | 2 +- infrastructure/web/views/stats.twig | 82 ++++++++ infrastructure/web/web.js | 5 +- 19 files changed, 629 insertions(+), 55 deletions(-) create mode 100644 domain/statistics-store.js create mode 100644 infrastructure/web/public/javascripts/stats.js create mode 100644 infrastructure/web/routes/stats.js create mode 100644 infrastructure/web/views/stats.twig diff --git a/app.js b/app.js index ac7f500..92dfc22 100644 --- a/app.js +++ b/app.js @@ -16,6 +16,7 @@ const MailRepository = require('./domain/mail-repository') const InboxLock = require('./domain/inbox-lock') const VerificationStore = require('./domain/verification-store') const UserRepository = require('./domain/user-repository') +const StatisticsStore = require('./domain/statistics-store') const clientNotification = new ClientNotification() debug('Client notification service initialized') @@ -29,6 +30,10 @@ const verificationStore = new VerificationStore() debug('Verification store initialized') app.set('verificationStore', verificationStore) +const statisticsStore = new StatisticsStore() +debug('Statistics store initialized') +app.set('statisticsStore', statisticsStore) + // Set config in app for route access app.set('config', config) @@ -84,10 +89,18 @@ const mailProcessingService = new MailProcessingService( clientNotification, config, smtpService, - verificationStore + verificationStore, + statisticsStore ) debug('Mail processing service initialized') +// Initialize statistics with current count +imapService.on(ImapService.EVENT_INITIAL_LOAD_DONE, () => { + const count = mailProcessingService.getCount() + statisticsStore.initialize(count) + debug(`Statistics initialized with ${count} emails`) +}) + // Set up timer sync broadcasting after IMAP is ready imapService.on(ImapService.EVENT_INITIAL_LOAD_DONE, () => { clientNotification.startTimerSync(imapService) diff --git a/application/helper.js b/application/helper.js index 2ceff91..98f7f87 100644 --- a/application/helper.js +++ b/application/helper.js @@ -175,13 +175,6 @@ class Helper { return await imapService.getLargestUid(); } - countElementBuilder(count = 0, largestUid = 0) { - const handling = `` - return handling - } - /** * Generate a cryptographically secure random verification token * @returns {string} - 32-byte hex token (64 characters) diff --git a/application/mail-processing-service.js b/application/mail-processing-service.js index 5e22e28..b4ce94c 100644 --- a/application/mail-processing-service.js +++ b/application/mail-processing-service.js @@ -7,7 +7,7 @@ const helper = new(Helper) class MailProcessingService extends EventEmitter { - constructor(mailRepository, imapService, clientNotification, config, smtpService = null, verificationStore = null) { + constructor(mailRepository, imapService, clientNotification, config, smtpService = null, verificationStore = null, statisticsStore = null) { super() this.mailRepository = mailRepository this.clientNotification = clientNotification @@ -15,6 +15,7 @@ class MailProcessingService extends EventEmitter { this.config = config this.smtpService = smtpService this.verificationStore = verificationStore + this.statisticsStore = statisticsStore this.helper = new(Helper) // Cached methods: @@ -164,6 +165,11 @@ class MailProcessingService extends EventEmitter { if (this.initialLoadDone) { // For now, only log messages if they arrive after the initial load debug('New mail for', mail.to[0]) + + // Track email received + if (this.statisticsStore) { + this.statisticsStore.recordReceive() + } } mail.to.forEach(to => { @@ -179,6 +185,11 @@ class MailProcessingService extends EventEmitter { onMailDeleted(uid) { debug('Mail deleted:', uid) + // Track email deleted + if (this.statisticsStore) { + this.statisticsStore.recordDelete() + } + // Clear cache for this specific UID try { this._clearCacheForUid(uid) @@ -266,6 +277,11 @@ class MailProcessingService extends EventEmitter { if (result.success) { debug(`Email forwarded successfully. MessageId: ${result.messageId}`) + + // Track email forwarded + if (this.statisticsStore) { + this.statisticsStore.recordForward() + } } else { debug(`Email forwarding failed: ${result.error}`) } diff --git a/domain/statistics-store.js b/domain/statistics-store.js new file mode 100644 index 0000000..d05354c --- /dev/null +++ b/domain/statistics-store.js @@ -0,0 +1,190 @@ +const debug = require('debug')('48hr-email:stats-store') + +/** + * Statistics Store - Tracks email metrics and historical data + * Stores 24-hour rolling statistics for receives, deletes, and forwards + */ +class StatisticsStore { + constructor() { + // Current totals + this.currentCount = 0 + this.historicalTotal = 0 + + // 24-hour rolling data (one entry per minute = 1440 entries) + this.hourlyData = [] + this.maxDataPoints = 24 * 60 // 24 hours * 60 minutes + + // Track last cleanup to avoid too frequent operations + this.lastCleanup = Date.now() + + debug('Statistics store initialized') + } + + /** + * Initialize with current email count + * @param {number} count - Current email count + */ + initialize(count) { + this.currentCount = count + this.historicalTotal = count + debug(`Initialized with ${count} emails`) + } + + /** + * Record an email received event + */ + recordReceive() { + this.currentCount++ + this.historicalTotal++ + this._addDataPoint('receive') + debug(`Email received. Current: ${this.currentCount}, Historical: ${this.historicalTotal}`) + } + + /** + * Record an email deleted event + */ + recordDelete() { + this.currentCount = Math.max(0, this.currentCount - 1) + this._addDataPoint('delete') + debug(`Email deleted. Current: ${this.currentCount}`) + } + + /** + * Record an email forwarded event + */ + recordForward() { + this._addDataPoint('forward') + debug(`Email forwarded`) + } + + /** + * Update current count (for bulk operations like purge) + * @param {number} count - New current count + */ + updateCurrentCount(count) { + const diff = count - this.currentCount + if (diff < 0) { + // Bulk delete occurred + for (let i = 0; i < Math.abs(diff); i++) { + this._addDataPoint('delete') + } + } + this.currentCount = count + debug(`Current count updated to ${count}`) + } + + /** + * Get current statistics + * @returns {Object} Current stats + */ + getStats() { + this._cleanup() + + const last24h = this._getLast24Hours() + + return { + currentCount: this.currentCount, + historicalTotal: this.historicalTotal, + last24Hours: { + receives: last24h.receives, + deletes: last24h.deletes, + forwards: last24h.forwards, + timeline: this._getTimeline() + } + } + } + + /** + * Add a data point to the rolling history + * @param {string} type - Type of event (receive, delete, forward) + * @private + */ + _addDataPoint(type) { + const now = Date.now() + const minute = Math.floor(now / 60000) * 60000 // Round to minute + + // Find or create entry for this minute + let entry = this.hourlyData.find(e => e.timestamp === minute) + if (!entry) { + entry = { + timestamp: minute, + receives: 0, + deletes: 0, + forwards: 0 + } + this.hourlyData.push(entry) + } + + entry[type + 's']++ + + this._cleanup() + } + + /** + * Clean up old data points (older than 24 hours) + * @private + */ + _cleanup() { + const now = Date.now() + + // Only cleanup every 5 minutes to avoid constant filtering + if (now - this.lastCleanup < 5 * 60 * 1000) { + return + } + + const cutoff = now - (24 * 60 * 60 * 1000) + const beforeCount = this.hourlyData.length + this.hourlyData = this.hourlyData.filter(entry => entry.timestamp >= cutoff) + + if (beforeCount !== this.hourlyData.length) { + debug(`Cleaned up ${beforeCount - this.hourlyData.length} old data points`) + } + + this.lastCleanup = now + } + + /** + * Get aggregated stats for last 24 hours + * @returns {Object} Aggregated counts + * @private + */ + _getLast24Hours() { + const cutoff = Date.now() - (24 * 60 * 60 * 1000) + const recent = this.hourlyData.filter(e => e.timestamp >= cutoff) + + return { + receives: recent.reduce((sum, e) => sum + e.receives, 0), + deletes: recent.reduce((sum, e) => sum + e.deletes, 0), + forwards: recent.reduce((sum, e) => sum + e.forwards, 0) + } + } + + /** + * Get timeline data for graphing (hourly aggregates) + * @returns {Array} Array of hourly data points + * @private + */ + _getTimeline() { + const now = Date.now() + const cutoff = now - (24 * 60 * 60 * 1000) + const hourly = {} + + // Aggregate by hour + this.hourlyData + .filter(e => e.timestamp >= cutoff) + .forEach(entry => { + const hour = Math.floor(entry.timestamp / 3600000) * 3600000 + if (!hourly[hour]) { + hourly[hour] = { timestamp: hour, receives: 0, deletes: 0, forwards: 0 } + } + hourly[hour].receives += entry.receives + hourly[hour].deletes += entry.deletes + hourly[hour].forwards += entry.forwards + }) + + // Convert to sorted array + return Object.values(hourly).sort((a, b) => a.timestamp - b.timestamp) + } +} + +module.exports = StatisticsStore diff --git a/infrastructure/web/middleware/lock.js b/infrastructure/web/middleware/lock.js index 974070e..a5b18eb 100644 --- a/infrastructure/web/middleware/lock.js +++ b/infrastructure/web/middleware/lock.js @@ -18,14 +18,12 @@ function checkLockAccess(req, res, next) { // Block access to locked inbox without proper authentication if (isLocked && !hasAccess) { - const count = req.app.get('mailProcessingService').getCount() const unlockError = req.session ? req.session.unlockError : undefined if (req.session) delete req.session.unlockError return res.render('error', { purgeTime: require('../../../application/helper').prototype.purgeTimeElemetBuilder(), address: address, - count: count, message: 'This inbox is locked by another user. Only the owner can access it.', branding: req.app.get('config').http.branding, currentUser: req.session && req.session.username, diff --git a/infrastructure/web/public/javascripts/stats.js b/infrastructure/web/public/javascripts/stats.js new file mode 100644 index 0000000..3689b90 --- /dev/null +++ b/infrastructure/web/public/javascripts/stats.js @@ -0,0 +1,126 @@ +/** + * Statistics page functionality + * Handles Chart.js initialization and auto-refresh of statistics data + */ + +// Initialize stats chart if on stats page +document.addEventListener('DOMContentLoaded', function() { + const chartCanvas = document.getElementById('statsChart'); + if (!chartCanvas) return; // Not on stats page + + // Get initial data from global variable (set by template) + if (typeof window.initialStatsData === 'undefined') { + console.error('Initial stats data not found'); + return; + } + + const initialData = window.initialStatsData; + + // Prepare chart data + const labels = initialData.map(d => { + const date = new Date(d.timestamp); + return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); + }); + + const ctx = chartCanvas.getContext('2d'); + const chart = new Chart(ctx, { + type: 'line', + data: { + labels: labels, + datasets: [ + { + label: 'Received', + data: initialData.map(d => d.receives), + borderColor: '#9b4dca', + backgroundColor: 'rgba(155, 77, 202, 0.1)', + tension: 0.4, + fill: true + }, + { + label: 'Deleted', + data: initialData.map(d => d.deletes), + borderColor: '#e74c3c', + backgroundColor: 'rgba(231, 76, 60, 0.1)', + tension: 0.4, + fill: true + }, + { + label: 'Forwarded', + data: initialData.map(d => d.forwards), + borderColor: '#3498db', + backgroundColor: 'rgba(52, 152, 219, 0.1)', + tension: 0.4, + fill: true + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: true, + position: 'top', + labels: { + color: getComputedStyle(document.documentElement).getPropertyValue('--color-text-light'), + font: { size: 14 } + } + }, + tooltip: { + mode: 'index', + intersect: false + } + }, + scales: { + y: { + beginAtZero: true, + ticks: { + color: getComputedStyle(document.documentElement).getPropertyValue('--color-text-dim'), + stepSize: 1 + }, + grid: { + color: 'rgba(255, 255, 255, 0.1)' + } + }, + x: { + ticks: { + color: getComputedStyle(document.documentElement).getPropertyValue('--color-text-dim'), + maxRotation: 45, + minRotation: 45 + }, + grid: { + color: 'rgba(255, 255, 255, 0.05)' + } + } + } + } + }); + + // Auto-refresh stats every 30 seconds + setInterval(async () => { + try { + const response = await fetch('/stats/api'); + const data = await response.json(); + + // Update stat cards + document.getElementById('currentCount').textContent = data.currentCount; + document.getElementById('historicalTotal').textContent = data.historicalTotal; + document.getElementById('receives24h').textContent = data.last24Hours.receives; + document.getElementById('deletes24h').textContent = data.last24Hours.deletes; + document.getElementById('forwards24h').textContent = data.last24Hours.forwards; + + // Update chart + const timeline = data.last24Hours.timeline; + chart.data.labels = timeline.map(d => { + const date = new Date(d.timestamp); + return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); + }); + chart.data.datasets[0].data = timeline.map(d => d.receives); + chart.data.datasets[1].data = timeline.map(d => d.deletes); + chart.data.datasets[2].data = timeline.map(d => d.forwards); + chart.update('none'); // Update without animation + } catch (error) { + console.error('Failed to refresh stats:', error); + } + }, 30000); +}); diff --git a/infrastructure/web/public/stylesheets/custom.css b/infrastructure/web/public/stylesheets/custom.css index 05117e7..bf05e3f 100644 --- a/infrastructure/web/public/stylesheets/custom.css +++ b/infrastructure/web/public/stylesheets/custom.css @@ -163,6 +163,18 @@ a:hover { h1 { font-size: 3rem; + color: var(--color-text-light); + font-weight: 700; + margin-bottom: 1rem; + text-align: center; +} + +h1.page-title { + background: linear-gradient(135deg, var(--color-accent-purple-light), var(--color-accent-purple-alt)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin-bottom: 0.5rem; } h3 { @@ -1954,6 +1966,78 @@ body.light-mode .theme-icon-light { } +/* Statistics Page */ + +.stats-container { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; +} + +.stats-subtitle { + color: var(--color-text-dim); + text-align: center; + margin-top: -1rem; + margin-bottom: 3rem; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 2rem; + margin-bottom: 3rem; +} + +.stat-card { + background: var(--overlay-white-05); + border: 1px solid var(--overlay-purple-30); + border-radius: 15px; + padding: 2rem; + text-align: center; + transition: all 0.3s ease; +} + +.stat-card:hover { + background: var(--overlay-white-08); + border-color: var(--overlay-purple-40); + transform: translateY(-2px); +} + +.stat-value { + font-size: 2.5rem; + font-weight: 700; + color: var(--color-accent-purple-light); + margin-bottom: 0.5rem; +} + +.stat-label { + font-size: 1rem; + color: var(--color-text-dim); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.chart-container { + background: var(--overlay-white-05); + border: 1px solid var(--overlay-purple-30); + border-radius: 15px; + padding: 2rem; + height: 500px; + position: relative; +} + +.chart-container h2 { + margin-top: 0; + margin-bottom: 2rem; + color: var(--color-text-light); + text-align: center; +} + +.chart-container canvas { + max-height: 400px; +} + + /* Responsive Styles */ @media (max-width: 768px) { diff --git a/infrastructure/web/routes/account.js b/infrastructure/web/routes/account.js index c9ce9b6..58c16a3 100644 --- a/infrastructure/web/routes/account.js +++ b/infrastructure/web/routes/account.js @@ -26,11 +26,7 @@ router.get('/account', requireAuth, async(req, res) => { const config = req.app.get('config') const stats = userRepository.getUserStats(req.session.userId, config.user) - // Get mail count for footer - const count = await mailProcessingService.getCount() - const imapService = req.app.locals.imapService - const largestUid = await imapService.getLargestUid() - const totalcount = helper.countElementBuilder(count, largestUid) + // Get purge time for footer const purgeTime = helper.purgeTimeElemetBuilder() res.render('account', { @@ -41,7 +37,6 @@ router.get('/account', requireAuth, async(req, res) => { stats, branding: config.http.branding, purgeTime: purgeTime, - totalcount: totalcount, successMessage: req.session.accountSuccess, errorMessage: req.session.accountError }) diff --git a/infrastructure/web/routes/error.js b/infrastructure/web/routes/error.js index 92ac5c4..d272de8 100644 --- a/infrastructure/web/routes/error.js +++ b/infrastructure/web/routes/error.js @@ -15,9 +15,6 @@ router.get('/:address/:errorCode', async(req, res, next) => { throw new Error('Mail processing service not available') } debug(`Error page requested: ${req.params.errorCode} for ${req.params.address}`) - const count = await mailProcessingService.getCount() - const largestUid = await req.app.locals.imapService.getLargestUid() - const totalcount = helper.countElementBuilder(count, largestUid) const errorCode = parseInt(req.params.errorCode) || 404 const message = req.query.message || (req.session && req.session.errorMessage) || 'An error occurred' @@ -27,8 +24,6 @@ router.get('/:address/:errorCode', async(req, res, next) => { title: `${config.http.branding[0]} | ${errorCode}`, purgeTime: purgeTime, address: req.params.address, - count: count, - totalcount: totalcount, message: message, status: errorCode, branding: config.http.branding diff --git a/infrastructure/web/routes/inbox.js b/infrastructure/web/routes/inbox.js index bc93ebe..e403153 100644 --- a/infrastructure/web/routes/inbox.js +++ b/infrastructure/web/routes/inbox.js @@ -106,10 +106,6 @@ router.get('^/:address([^@/]+@[^@/]+)', sanitizeAddress, validateDomain, optiona } debug(`Inbox request for ${req.params.address}`) const inboxLock = req.app.get('inboxLock') - const count = await mailProcessingService.getCount() - const largestUid = await req.app.locals.imapService.getLargestUid() - const totalcount = helper.countElementBuilder(count, largestUid) - debug(`Rendering inbox with ${count} total mails`) // Check lock status const isLocked = inboxLock && inboxLock.isLocked(req.params.address) @@ -151,8 +147,6 @@ router.get('^/:address([^@/]+@[^@/]+)', sanitizeAddress, validateDomain, optiona title: `${config.http.branding[0]} | ` + req.params.address, purgeTime: purgeTime, address: req.params.address, - count: count, - totalcount: totalcount, mailSummaries: mailProcessingService.getMailSummaries(req.params.address), branding: config.http.branding, authEnabled: config.user.authEnabled, @@ -189,9 +183,6 @@ router.get( try { const mailProcessingService = req.app.get('mailProcessingService') debug(`Viewing email ${req.params.uid} for ${req.params.address}`) - const count = await mailProcessingService.getCount() - const largestUid = await req.app.locals.imapService.getLargestUid() - const totalcount = helper.countElementBuilder(count, largestUid) const mail = await mailProcessingService.getOneFullMail( req.params.address, req.params.uid @@ -246,8 +237,6 @@ router.get( title: mail.subject + " | " + req.params.address, purgeTime: purgeTime, address: req.params.address, - count: count, - totalcount: totalcount, mail, cryptoAttachments: cryptoAttachments, uid: req.params.uid, @@ -336,7 +325,6 @@ router.get( const mailProcessingService = req.app.get('mailProcessingService') debug(`Fetching attachment ${req.params.checksum} for email ${req.params.uid} (${req.params.address})`) const uid = parseInt(req.params.uid, 10) - const count = await mailProcessingService.getCount() // Validate UID is a valid integer if (isNaN(uid) || uid <= 0) { @@ -397,9 +385,6 @@ router.get( const mailProcessingService = req.app.get('mailProcessingService') debug(`Fetching raw email ${req.params.uid} for ${req.params.address}`) const uid = parseInt(req.params.uid, 10) - const count = await mailProcessingService.getCount() - const largestUid = await req.app.locals.imapService.getLargestUid() - const totalcount = helper.countElementBuilder(count, largestUid) // Validate UID is a valid integer if (isNaN(uid) || uid <= 0) { @@ -440,8 +425,7 @@ router.get( res.render('raw', { title: req.params.uid + " | raw | " + req.params.address, mail: rawMail, - decoded: decodedMail, - totalcount: totalcount + decoded: decodedMail }) } else { debug(`Raw email ${uid} not found for ${req.params.address}`) diff --git a/infrastructure/web/routes/login.js b/infrastructure/web/routes/login.js index ffec647..363e05a 100644 --- a/infrastructure/web/routes/login.js +++ b/infrastructure/web/routes/login.js @@ -17,17 +17,11 @@ router.get('/', async(req, res, next) => { throw new Error('Mail processing service not available') } debug('Login page requested') - const count = await mailProcessingService.getCount() - const largestUid = await req.app.locals.imapService.getLargestUid() - const totalcount = helper.countElementBuilder(count, largestUid) - debug(`Rendering login page with ${count} total mails`) res.render('login', { title: `${config.http.branding[0]} | Your temporary Inbox`, username: randomWord(), purgeTime: purgeTime, domains: helper.getDomains(), - count: count, - totalcount: totalcount, branding: config.http.branding, example: config.email.examples.account, }) @@ -59,7 +53,6 @@ router.post( throw new Error('Mail processing service not available') } const errors = validationResult(req) - const count = await mailProcessingService.getCount() if (!errors.isEmpty()) { debug(`Login validation failed for ${req.body.username}@${req.body.domain}: ${errors.array().map(e => e.msg).join(', ')}`) return res.render('login', { @@ -68,7 +61,6 @@ router.post( purgeTime: purgeTime, username: randomWord(), domains: helper.getDomains(), - count: count, branding: config.http.branding, }) } diff --git a/infrastructure/web/routes/stats.js b/infrastructure/web/routes/stats.js new file mode 100644 index 0000000..00e1fef --- /dev/null +++ b/infrastructure/web/routes/stats.js @@ -0,0 +1,105 @@ +const express = require('express') +const router = new express.Router() +const debug = require('debug')('48hr-email:stats-routes') + +// GET /stats - Statistics page +router.get('/', async(req, res) => { + try { + const config = req.app.get('config') + const statisticsStore = req.app.get('statisticsStore') + const Helper = require('../../../application/helper') + const helper = new Helper() + + const stats = statisticsStore.getStats() + const purgeTime = helper.purgeTimeElemetBuilder() + + debug(`Stats page requested: ${stats.currentCount} current, ${stats.historicalTotal} historical`) + + res.render('stats', { + title: `Statistics | ${config.http.branding[0]}`, + branding: config.http.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 stats = statisticsStore.getStats() + + res.json(stats) + } catch (error) { + debug(`Error fetching stats API: ${error.message}`) + res.status(500).json({ error: 'Failed to fetch statistics' }) + } +}) + +// GET /statsdemo - Demo page with fake data for testing +router.get('/demo', async(req, res) => { + try { + const config = req.app.get('config') + const Helper = require('../../../application/helper') + const helper = new Helper() + const purgeTime = helper.purgeTimeElemetBuilder() + + // Generate fake 24-hour timeline data + const now = Date.now() + const timeline = [] + + for (let i = 23; i >= 0; i--) { + const timestamp = now - (i * 60 * 60 * 1000) // Hourly data points + const receives = Math.floor(Math.random() * 100) + 200 // 200-300 receives per hour (~6k/day) + const deletes = Math.floor(receives * 0.85) + Math.floor(Math.random() * 10) // ~85% deletion rate + const forwards = Math.floor(receives * 0.01) + (Math.random() < 0.3 ? 1 : 0) // ~1% forward rate + + timeline.push({ + timestamp, + receives, + deletes, + forwards + }) + } + + // Calculate totals + const totalReceives = timeline.reduce((sum, d) => sum + d.receives, 0) + const totalDeletes = timeline.reduce((sum, d) => sum + d.deletes, 0) + const totalForwards = timeline.reduce((sum, d) => sum + d.forwards, 0) + + const fakeStats = { + currentCount: 6500, + historicalTotal: 124893, + last24Hours: { + receives: totalReceives, + deletes: totalDeletes, + forwards: totalForwards, + timeline: timeline + } + } + + debug(`Stats demo page requested with fake data`) + + res.render('stats', { + title: `Statistics Demo | ${config.http.branding[0]}`, + branding: config.http.branding, + purgeTime: purgeTime, + stats: fakeStats, + authEnabled: config.user.authEnabled, + currentUser: req.session && req.session.username + }) + } catch (error) { + debug(`Error loading stats demo page: ${error.message}`) + console.error('Error while loading stats demo page', error) + res.status(500).send('Error loading statistics demo') + } +}) + +module.exports = router diff --git a/infrastructure/web/views/account.twig b/infrastructure/web/views/account.twig index 3080cb7..314fc38 100644 --- a/infrastructure/web/views/account.twig +++ b/infrastructure/web/views/account.twig @@ -16,7 +16,7 @@ {% block body %}
-

Account Dashboard

+

Account Dashboard

{% if successMessage %} diff --git a/infrastructure/web/views/auth.twig b/infrastructure/web/views/auth.twig index c06e301..eb1ac98 100644 --- a/infrastructure/web/views/auth.twig +++ b/infrastructure/web/views/auth.twig @@ -17,7 +17,7 @@ {% block body %}
-

Account Access

+

Account Access

Login to an existing account or create a new one

{% if errorMessage %}
{{ errorMessage }}
diff --git a/infrastructure/web/views/error.twig b/infrastructure/web/views/error.twig index 86fab89..f033637 100644 --- a/infrastructure/web/views/error.twig +++ b/infrastructure/web/views/error.twig @@ -32,7 +32,7 @@ {% endblock %} {% block body %} -

{{message}}

+

{{message}}

{{error.status}}

{{error.stack}}
{% endblock %} diff --git a/infrastructure/web/views/layout.twig b/infrastructure/web/views/layout.twig index 28a0899..6015f0d 100644 --- a/infrastructure/web/views/layout.twig +++ b/infrastructure/web/views/layout.twig @@ -74,6 +74,8 @@ + + @@ -90,7 +92,7 @@ {% block footer %} {% endblock %} diff --git a/infrastructure/web/views/login.twig b/infrastructure/web/views/login.twig index d96c4ca..69033d3 100644 --- a/infrastructure/web/views/login.twig +++ b/infrastructure/web/views/login.twig @@ -33,7 +33,7 @@ {% block body %}
-

Welcome!

+

Welcome!

Here you can either create a new Inbox, or access your old one

{% if userInputError %} diff --git a/infrastructure/web/views/stats.twig b/infrastructure/web/views/stats.twig new file mode 100644 index 0000000..f62b8ee --- /dev/null +++ b/infrastructure/web/views/stats.twig @@ -0,0 +1,82 @@ +{% extends 'layout.twig' %} + +{% block header %} + +{% endblock %} + +{% block body %} +
+

Email Statistics

+

Real-time email activity and 24-hour trends

+ +
+ +
+
{{ stats.currentCount }}
+
Emails in System
+
+ + +
+
{{ stats.historicalTotal }}
+
All Time Total
+
+ + +
+
{{ stats.last24Hours.receives }}
+
Received (24h)
+
+ + +
+
{{ stats.last24Hours.deletes }}
+
Deleted (24h)
+
+ + +
+
{{ stats.last24Hours.forwards }}
+
Forwarded (24h)
+
+
+ + +
+

Activity Timeline (24 Hours)

+ +
+
+ + +{% endblock %} diff --git a/infrastructure/web/web.js b/infrastructure/web/web.js index 752a9b3..c92387d 100644 --- a/infrastructure/web/web.js +++ b/infrastructure/web/web.js @@ -17,6 +17,7 @@ const errorRouter = require('./routes/error') const lockRouter = require('./routes/lock') const authRouter = require('./routes/auth') const accountRouter = require('./routes/account') +const statsRouter = require('./routes/stats') const { sanitizeHtmlTwigFilter } = require('./views/twig-filters') const Helper = require('../../application/helper') @@ -120,6 +121,7 @@ if (config.user.authEnabled) { app.use('/inbox', inboxRouter) app.use('/error', errorRouter) app.use('/lock', lockRouter) +app.use('/stats', statsRouter) // Catch 404 and forward to error handler app.use((req, res, next) => { @@ -130,8 +132,6 @@ app.use((req, res, next) => { app.use(async(err, req, res, _next) => { try { debug('Error handler triggered:', err.message) - const mailProcessingService = req.app.get('mailProcessingService') - const count = await mailProcessingService.getCount() // Set locals, only providing error in development res.locals.message = err.message @@ -142,7 +142,6 @@ app.use(async(err, req, res, _next) => { res.render('error', { purgeTime: purgeTime, address: req.params && req.params.address, - count: count, branding: config.http.branding }) } catch (renderError) {