mirror of
https://github.com/Crazyco-xyz/48hr.email.git
synced 2026-01-09 11:19:36 +01:00
[Feat]: Add Stats page
This commit is contained in:
parent
69011624a7
commit
e012b772c8
19 changed files with 629 additions and 55 deletions
15
app.js
15
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)
|
||||
|
|
|
|||
|
|
@ -175,13 +175,6 @@ class Helper {
|
|||
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
|
||||
* @returns {string} - 32-byte hex token (64 characters)
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
|
|
|
|||
190
domain/statistics-store.js
Normal file
190
domain/statistics-store.js
Normal 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
126
infrastructure/web/public/javascripts/stats.js
Normal file
126
infrastructure/web/public/javascripts/stats.js
Normal 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);
|
||||
});
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
105
infrastructure/web/routes/stats.js
Normal file
105
infrastructure/web/routes/stats.js
Normal 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
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
{% block body %}
|
||||
<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>
|
||||
|
||||
{% if successMessage %}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
{% block body %}
|
||||
<div id="auth-unified" class="auth-unified-container">
|
||||
<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>
|
||||
{% if errorMessage %}
|
||||
<div class="unlock-error">{{ errorMessage }}</div>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<h1>{{message}}</h1>
|
||||
<h1 class="page-title">{{message}}</h1>
|
||||
<h2>{{error.status}}</h2>
|
||||
<pre>{{error.stack}}</pre>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,8 @@
|
|||
<script src="/javascripts/utils.js"></script>
|
||||
<script src="/socket.io/socket.io.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>
|
||||
<body{% if bodyClass %} class="{{ bodyClass }}"{% endif %}>
|
||||
|
|
@ -90,7 +92,7 @@
|
|||
{% block footer %}
|
||||
<section class="container footer">
|
||||
<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>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@
|
|||
|
||||
{% block body %}
|
||||
<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>
|
||||
|
||||
{% if userInputError %}
|
||||
|
|
|
|||
82
infrastructure/web/views/stats.twig
Normal file
82
infrastructure/web/views/stats.twig
Normal 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 %}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue