[Feat]: Add Stats page

This commit is contained in:
ClaraCrazy 2026-01-03 15:41:56 +01:00
parent 69011624a7
commit e012b772c8
No known key found for this signature in database
GPG key ID: EBBC896ACB497011
19 changed files with 629 additions and 55 deletions

15
app.js
View file

@ -16,6 +16,7 @@ const MailRepository = require('./domain/mail-repository')
const InboxLock = require('./domain/inbox-lock') const InboxLock = require('./domain/inbox-lock')
const VerificationStore = require('./domain/verification-store') const VerificationStore = require('./domain/verification-store')
const UserRepository = require('./domain/user-repository') const UserRepository = require('./domain/user-repository')
const StatisticsStore = require('./domain/statistics-store')
const clientNotification = new ClientNotification() const clientNotification = new ClientNotification()
debug('Client notification service initialized') debug('Client notification service initialized')
@ -29,6 +30,10 @@ const verificationStore = new VerificationStore()
debug('Verification store initialized') debug('Verification store initialized')
app.set('verificationStore', verificationStore) app.set('verificationStore', verificationStore)
const statisticsStore = new StatisticsStore()
debug('Statistics store initialized')
app.set('statisticsStore', statisticsStore)
// Set config in app for route access // Set config in app for route access
app.set('config', config) app.set('config', config)
@ -84,10 +89,18 @@ const mailProcessingService = new MailProcessingService(
clientNotification, clientNotification,
config, config,
smtpService, smtpService,
verificationStore verificationStore,
statisticsStore
) )
debug('Mail processing service initialized') 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 // Set up timer sync broadcasting after IMAP is ready
imapService.on(ImapService.EVENT_INITIAL_LOAD_DONE, () => { imapService.on(ImapService.EVENT_INITIAL_LOAD_DONE, () => {
clientNotification.startTimerSync(imapService) clientNotification.startTimerSync(imapService)

View file

@ -175,13 +175,6 @@ class Helper {
return await imapService.getLargestUid(); return await imapService.getLargestUid();
} }
countElementBuilder(count = 0, largestUid = 0) {
const handling = `<label title="Historically managed ${largestUid} email${largestUid === 1 ? '' : 's'}">
<h4 style="display: inline;"><u><i>${count}</i></u> mail${count === 1 ? '' : 's'}</h4>
</label>`
return handling
}
/** /**
* Generate a cryptographically secure random verification token * Generate a cryptographically secure random verification token
* @returns {string} - 32-byte hex token (64 characters) * @returns {string} - 32-byte hex token (64 characters)

View file

@ -7,7 +7,7 @@ const helper = new(Helper)
class MailProcessingService extends EventEmitter { 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() super()
this.mailRepository = mailRepository this.mailRepository = mailRepository
this.clientNotification = clientNotification this.clientNotification = clientNotification
@ -15,6 +15,7 @@ class MailProcessingService extends EventEmitter {
this.config = config this.config = config
this.smtpService = smtpService this.smtpService = smtpService
this.verificationStore = verificationStore this.verificationStore = verificationStore
this.statisticsStore = statisticsStore
this.helper = new(Helper) this.helper = new(Helper)
// Cached methods: // Cached methods:
@ -164,6 +165,11 @@ class MailProcessingService extends EventEmitter {
if (this.initialLoadDone) { if (this.initialLoadDone) {
// For now, only log messages if they arrive after the initial load // For now, only log messages if they arrive after the initial load
debug('New mail for', mail.to[0]) debug('New mail for', mail.to[0])
// Track email received
if (this.statisticsStore) {
this.statisticsStore.recordReceive()
}
} }
mail.to.forEach(to => { mail.to.forEach(to => {
@ -179,6 +185,11 @@ class MailProcessingService extends EventEmitter {
onMailDeleted(uid) { onMailDeleted(uid) {
debug('Mail deleted:', uid) debug('Mail deleted:', uid)
// Track email deleted
if (this.statisticsStore) {
this.statisticsStore.recordDelete()
}
// Clear cache for this specific UID // Clear cache for this specific UID
try { try {
this._clearCacheForUid(uid) this._clearCacheForUid(uid)
@ -266,6 +277,11 @@ class MailProcessingService extends EventEmitter {
if (result.success) { if (result.success) {
debug(`Email forwarded successfully. MessageId: ${result.messageId}`) debug(`Email forwarded successfully. MessageId: ${result.messageId}`)
// Track email forwarded
if (this.statisticsStore) {
this.statisticsStore.recordForward()
}
} else { } else {
debug(`Email forwarding failed: ${result.error}`) debug(`Email forwarding failed: ${result.error}`)
} }

190
domain/statistics-store.js Normal file
View file

@ -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

View file

@ -18,14 +18,12 @@ function checkLockAccess(req, res, next) {
// Block access to locked inbox without proper authentication // Block access to locked inbox without proper authentication
if (isLocked && !hasAccess) { if (isLocked && !hasAccess) {
const count = req.app.get('mailProcessingService').getCount()
const unlockError = req.session ? req.session.unlockError : undefined const unlockError = req.session ? req.session.unlockError : undefined
if (req.session) delete req.session.unlockError if (req.session) delete req.session.unlockError
return res.render('error', { return res.render('error', {
purgeTime: require('../../../application/helper').prototype.purgeTimeElemetBuilder(), purgeTime: require('../../../application/helper').prototype.purgeTimeElemetBuilder(),
address: address, address: address,
count: count,
message: 'This inbox is locked by another user. Only the owner can access it.', message: 'This inbox is locked by another user. Only the owner can access it.',
branding: req.app.get('config').http.branding, branding: req.app.get('config').http.branding,
currentUser: req.session && req.session.username, currentUser: req.session && req.session.username,

View file

@ -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);
});

View file

@ -163,6 +163,18 @@ a:hover {
h1 { h1 {
font-size: 3rem; 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 { 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 */ /* Responsive Styles */
@media (max-width: 768px) { @media (max-width: 768px) {

View file

@ -26,11 +26,7 @@ router.get('/account', requireAuth, async(req, res) => {
const config = req.app.get('config') const config = req.app.get('config')
const stats = userRepository.getUserStats(req.session.userId, config.user) const stats = userRepository.getUserStats(req.session.userId, config.user)
// Get mail count for footer // Get purge time for footer
const count = await mailProcessingService.getCount()
const imapService = req.app.locals.imapService
const largestUid = await imapService.getLargestUid()
const totalcount = helper.countElementBuilder(count, largestUid)
const purgeTime = helper.purgeTimeElemetBuilder() const purgeTime = helper.purgeTimeElemetBuilder()
res.render('account', { res.render('account', {
@ -41,7 +37,6 @@ router.get('/account', requireAuth, async(req, res) => {
stats, stats,
branding: config.http.branding, branding: config.http.branding,
purgeTime: purgeTime, purgeTime: purgeTime,
totalcount: totalcount,
successMessage: req.session.accountSuccess, successMessage: req.session.accountSuccess,
errorMessage: req.session.accountError errorMessage: req.session.accountError
}) })

View file

@ -15,9 +15,6 @@ router.get('/:address/:errorCode', async(req, res, next) => {
throw new Error('Mail processing service not available') throw new Error('Mail processing service not available')
} }
debug(`Error page requested: ${req.params.errorCode} for ${req.params.address}`) 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 errorCode = parseInt(req.params.errorCode) || 404
const message = req.query.message || (req.session && req.session.errorMessage) || 'An error occurred' 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}`, title: `${config.http.branding[0]} | ${errorCode}`,
purgeTime: purgeTime, purgeTime: purgeTime,
address: req.params.address, address: req.params.address,
count: count,
totalcount: totalcount,
message: message, message: message,
status: errorCode, status: errorCode,
branding: config.http.branding branding: config.http.branding

View file

@ -106,10 +106,6 @@ router.get('^/:address([^@/]+@[^@/]+)', sanitizeAddress, validateDomain, optiona
} }
debug(`Inbox request for ${req.params.address}`) debug(`Inbox request for ${req.params.address}`)
const inboxLock = req.app.get('inboxLock') 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 // Check lock status
const isLocked = inboxLock && inboxLock.isLocked(req.params.address) 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, title: `${config.http.branding[0]} | ` + req.params.address,
purgeTime: purgeTime, purgeTime: purgeTime,
address: req.params.address, address: req.params.address,
count: count,
totalcount: totalcount,
mailSummaries: mailProcessingService.getMailSummaries(req.params.address), mailSummaries: mailProcessingService.getMailSummaries(req.params.address),
branding: config.http.branding, branding: config.http.branding,
authEnabled: config.user.authEnabled, authEnabled: config.user.authEnabled,
@ -189,9 +183,6 @@ router.get(
try { try {
const mailProcessingService = req.app.get('mailProcessingService') const mailProcessingService = req.app.get('mailProcessingService')
debug(`Viewing email ${req.params.uid} for ${req.params.address}`) 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( const mail = await mailProcessingService.getOneFullMail(
req.params.address, req.params.address,
req.params.uid req.params.uid
@ -246,8 +237,6 @@ router.get(
title: mail.subject + " | " + req.params.address, title: mail.subject + " | " + req.params.address,
purgeTime: purgeTime, purgeTime: purgeTime,
address: req.params.address, address: req.params.address,
count: count,
totalcount: totalcount,
mail, mail,
cryptoAttachments: cryptoAttachments, cryptoAttachments: cryptoAttachments,
uid: req.params.uid, uid: req.params.uid,
@ -336,7 +325,6 @@ router.get(
const mailProcessingService = req.app.get('mailProcessingService') const mailProcessingService = req.app.get('mailProcessingService')
debug(`Fetching attachment ${req.params.checksum} for email ${req.params.uid} (${req.params.address})`) debug(`Fetching attachment ${req.params.checksum} for email ${req.params.uid} (${req.params.address})`)
const uid = parseInt(req.params.uid, 10) const uid = parseInt(req.params.uid, 10)
const count = await mailProcessingService.getCount()
// Validate UID is a valid integer // Validate UID is a valid integer
if (isNaN(uid) || uid <= 0) { if (isNaN(uid) || uid <= 0) {
@ -397,9 +385,6 @@ router.get(
const mailProcessingService = req.app.get('mailProcessingService') const mailProcessingService = req.app.get('mailProcessingService')
debug(`Fetching raw email ${req.params.uid} for ${req.params.address}`) debug(`Fetching raw email ${req.params.uid} for ${req.params.address}`)
const uid = parseInt(req.params.uid, 10) 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 // Validate UID is a valid integer
if (isNaN(uid) || uid <= 0) { if (isNaN(uid) || uid <= 0) {
@ -440,8 +425,7 @@ router.get(
res.render('raw', { res.render('raw', {
title: req.params.uid + " | raw | " + req.params.address, title: req.params.uid + " | raw | " + req.params.address,
mail: rawMail, mail: rawMail,
decoded: decodedMail, decoded: decodedMail
totalcount: totalcount
}) })
} else { } else {
debug(`Raw email ${uid} not found for ${req.params.address}`) debug(`Raw email ${uid} not found for ${req.params.address}`)

View file

@ -17,17 +17,11 @@ router.get('/', async(req, res, next) => {
throw new Error('Mail processing service not available') throw new Error('Mail processing service not available')
} }
debug('Login page requested') 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', { res.render('login', {
title: `${config.http.branding[0]} | Your temporary Inbox`, title: `${config.http.branding[0]} | Your temporary Inbox`,
username: randomWord(), username: randomWord(),
purgeTime: purgeTime, purgeTime: purgeTime,
domains: helper.getDomains(), domains: helper.getDomains(),
count: count,
totalcount: totalcount,
branding: config.http.branding, branding: config.http.branding,
example: config.email.examples.account, example: config.email.examples.account,
}) })
@ -59,7 +53,6 @@ router.post(
throw new Error('Mail processing service not available') throw new Error('Mail processing service not available')
} }
const errors = validationResult(req) const errors = validationResult(req)
const count = await mailProcessingService.getCount()
if (!errors.isEmpty()) { if (!errors.isEmpty()) {
debug(`Login validation failed for ${req.body.username}@${req.body.domain}: ${errors.array().map(e => e.msg).join(', ')}`) debug(`Login validation failed for ${req.body.username}@${req.body.domain}: ${errors.array().map(e => e.msg).join(', ')}`)
return res.render('login', { return res.render('login', {
@ -68,7 +61,6 @@ router.post(
purgeTime: purgeTime, purgeTime: purgeTime,
username: randomWord(), username: randomWord(),
domains: helper.getDomains(), domains: helper.getDomains(),
count: count,
branding: config.http.branding, branding: config.http.branding,
}) })
} }

View file

@ -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

View file

@ -16,7 +16,7 @@
{% block body %} {% block body %}
<div id="account" class="account-container"> <div id="account" class="account-container">
<h1>Account Dashboard</h1> <h1 class="page-title">Account Dashboard</h1>
<p class="account-subtitle">Welcome back, <strong>{{ username }}</strong></p> <p class="account-subtitle">Welcome back, <strong>{{ username }}</strong></p>
{% if successMessage %} {% if successMessage %}

View file

@ -17,7 +17,7 @@
{% block body %} {% block body %}
<div id="auth-unified" class="auth-unified-container"> <div id="auth-unified" class="auth-unified-container">
<div class="auth-intro"> <div class="auth-intro">
<h1>Account Access</h1> <h1 class="page-title">Account Access</h1>
<p class="auth-subtitle">Login to an existing account or create a new one</p> <p class="auth-subtitle">Login to an existing account or create a new one</p>
{% if errorMessage %} {% if errorMessage %}
<div class="unlock-error">{{ errorMessage }}</div> <div class="unlock-error">{{ errorMessage }}</div>

View file

@ -32,7 +32,7 @@
{% endblock %} {% endblock %}
{% block body %} {% block body %}
<h1>{{message}}</h1> <h1 class="page-title">{{message}}</h1>
<h2>{{error.status}}</h2> <h2>{{error.status}}</h2>
<pre>{{error.stack}}</pre> <pre>{{error.stack}}</pre>
{% endblock %} {% endblock %}

View file

@ -74,6 +74,8 @@
<script src="/javascripts/utils.js"></script> <script src="/javascripts/utils.js"></script>
<script src="/socket.io/socket.io.js" defer="true"></script> <script src="/socket.io/socket.io.js" defer="true"></script>
<script src="/javascripts/notifications.js" defer="true"></script> <script src="/javascripts/notifications.js" defer="true"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" defer="true"></script>
<script src="/javascripts/stats.js" defer="true"></script>
</head> </head>
<body{% if bodyClass %} class="{{ bodyClass }}"{% endif %}> <body{% if bodyClass %} class="{{ bodyClass }}"{% endif %}>
@ -90,7 +92,7 @@
{% block footer %} {% block footer %}
<section class="container footer"> <section class="container footer">
<hr> <hr>
<h4>{{ branding[0] }} offered by <a href="{{ branding[2] }}" style="text-decoration:underline" target="_blank">{{ branding[1] }}</a> | All Emails will be deleted after {{ purgeTime | raw }} | Currently handling {{ totalcount | raw }}</h4> <h4>{{ branding[0] }} offered by <a href="{{ branding[2] }}" style="text-decoration:underline" target="_blank">{{ branding[1] }}</a> | All Emails will be deleted after {{ purgeTime | raw }} | <a href="/stats" style="text-decoration:underline">See daily Stats</a></h4>
<h4 class="container footer-two"> This project is <a href="https://github.com/crazyco-xyz/48hr.email" style="text-decoration:underline" target="_blank">open-source ♥</a></h4> <h4 class="container footer-two"> This project is <a href="https://github.com/crazyco-xyz/48hr.email" style="text-decoration:underline" target="_blank">open-source ♥</a></h4>
</section> </section>
{% endblock %} {% endblock %}

View file

@ -33,7 +33,7 @@
{% block body %} {% block body %}
<div id="login"> <div id="login">
<h1>Welcome!</h1> <h1 class="page-title">Welcome!</h1>
<h4>Here you can either create a new Inbox, or access your old one</h4> <h4>Here you can either create a new Inbox, or access your old one</h4>
{% if userInputError %} {% if userInputError %}

View file

@ -0,0 +1,82 @@
{% extends 'layout.twig' %}
{% block header %}
<div class="action-links">
{% if currentUser %}
<!-- Account Dropdown (logged in) -->
{% if authEnabled %}
<div class="action-dropdown">
<button class="dropdown-toggle" aria-label="Account menu">Account ▾</button>
<div class="dropdown-menu">
<a href="/account" aria-label="Account settings">Settings</a>
<a href="/logout?redirect=/stats" aria-label="Logout">Logout</a>
</div>
</div>
{% endif %}
{% else %}
{% if authEnabled %}
<a href="/auth" aria-label="Login or Register">Account</a>
{% endif %}
{% endif %}
<a href="/" aria-label="Return to home">Home</a>
<button class="theme-toggle" id="themeToggle" aria-label="Toggle dark/light mode">
<svg class="theme-icon theme-icon-dark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
<svg class="theme-icon theme-icon-light" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
</button>
</div>
{% endblock %}
{% block body %}
<div class="stats-container">
<h1 class="page-title">Email Statistics</h1>
<p class="stats-subtitle">Real-time email activity and 24-hour trends</p>
<div class="stats-grid">
<!-- Current Count -->
<div class="stat-card">
<div class="stat-value" id="currentCount">{{ stats.currentCount }}</div>
<div class="stat-label">Emails in System</div>
</div>
<!-- Historical Total -->
<div class="stat-card">
<div class="stat-value" id="historicalTotal">{{ stats.historicalTotal }}</div>
<div class="stat-label">All Time Total</div>
</div>
<!-- 24h Receives -->
<div class="stat-card">
<div class="stat-value" id="receives24h">{{ stats.last24Hours.receives }}</div>
<div class="stat-label">Received (24h)</div>
</div>
<!-- 24h Deletes -->
<div class="stat-card">
<div class="stat-value" id="deletes24h">{{ stats.last24Hours.deletes }}</div>
<div class="stat-label">Deleted (24h)</div>
</div>
<!-- 24h Forwards -->
<div class="stat-card">
<div class="stat-value" id="forwards24h">{{ stats.last24Hours.forwards }}</div>
<div class="stat-label">Forwarded (24h)</div>
</div>
</div>
<!-- Chart -->
<div class="chart-container">
<h2>Activity Timeline (24 Hours)</h2>
<canvas id="statsChart"></canvas>
</div>
</div>
<script>
// Set initial data for stats.js to consume
window.initialStatsData = {{ stats.last24Hours.timeline|json_encode|raw }};
</script>
{% endblock %}

View file

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