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 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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
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
|
// 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,
|
||||||
|
|
|
||||||
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 {
|
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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}`)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 %}
|
{% 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 %}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
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 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) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue