mirror of
https://github.com/Crazyco-xyz/48hr.email.git
synced 2026-01-09 11:19:36 +01:00
[AI][Feat]: Stats V2
- Historical storage - Prediction - Black magic
This commit is contained in:
parent
a078abae00
commit
c3fea6a70b
17 changed files with 686 additions and 86 deletions
|
|
@ -17,7 +17,7 @@ IMAP_USER="user@example.com" # IMAP username
|
||||||
IMAP_PASSWORD="password" # IMAP password
|
IMAP_PASSWORD="password" # IMAP password
|
||||||
IMAP_SERVER="imap.example.com" # IMAP server address
|
IMAP_SERVER="imap.example.com" # IMAP server address
|
||||||
IMAP_PORT=993 # IMAP port (default 993)
|
IMAP_PORT=993 # IMAP port (default 993)
|
||||||
IMAP_TLS=true # Use secure TLS connection (true/false)
|
IMAP_SECURE=true # Use secure TLS connection (true/false)
|
||||||
IMAP_AUTH_TIMEOUT=3000 # Authentication timeout in ms
|
IMAP_AUTH_TIMEOUT=3000 # Authentication timeout in ms
|
||||||
IMAP_REFRESH_INTERVAL_SECONDS=60 # Refresh interval for checking new emails
|
IMAP_REFRESH_INTERVAL_SECONDS=60 # Refresh interval for checking new emails
|
||||||
IMAP_FETCH_CHUNK=200 # Number of UIDs per fetch chunk during initial load
|
IMAP_FETCH_CHUNK=200 # Number of UIDs per fetch chunk during initial load
|
||||||
|
|
@ -25,11 +25,11 @@ IMAP_CONCURRENCY=6 # Number of conc
|
||||||
|
|
||||||
# --- SMTP CONFIGURATION (for email forwarding) ---
|
# --- SMTP CONFIGURATION (for email forwarding) ---
|
||||||
SMTP_ENABLED=false # Enable SMTP forwarding functionality (default: false)
|
SMTP_ENABLED=false # Enable SMTP forwarding functionality (default: false)
|
||||||
SMTP_HOST="smtp.example.com" # SMTP server address (e.g., smtp.gmail.com, smtp.sendgrid.net)
|
|
||||||
SMTP_PORT=465 # SMTP port (587 for TLS, 465 for SSL, 25 for unencrypted)
|
|
||||||
SMTP_SECURE=true # Use SSL (true for port 465, false for other ports)
|
|
||||||
SMTP_USER="noreply@48hr.email" # SMTP authentication username (also used as from address)
|
SMTP_USER="noreply@48hr.email" # SMTP authentication username (also used as from address)
|
||||||
SMTP_PASSWORD="password" # SMTP authentication password
|
SMTP_PASSWORD="password" # SMTP authentication password
|
||||||
|
SMTP_SERVER="smtp.example.com" # SMTP server address (e.g., smtp.gmail.com, smtp.sendgrid.net)
|
||||||
|
SMTP_PORT=465 # SMTP port (587 for TLS, 465 for SSL, 25 for unencrypted)
|
||||||
|
SMTP_SECURE=true # Use SSL (true for port 465, false for other ports)
|
||||||
|
|
||||||
# --- HTTP / WEB CONFIGURATION ---
|
# --- HTTP / WEB CONFIGURATION ---
|
||||||
HTTP_PORT=3000 # Port
|
HTTP_PORT=3000 # Port
|
||||||
|
|
@ -41,6 +41,7 @@ HTTP_DISPLAY_SORT=2 # Domain display
|
||||||
# 2 = alphabetical + first item shuffled,
|
# 2 = alphabetical + first item shuffled,
|
||||||
# 3 = shuffle all
|
# 3 = shuffle all
|
||||||
HTTP_HIDE_OTHER=false # true = only show first domain, false = show all
|
HTTP_HIDE_OTHER=false # true = only show first domain, false = show all
|
||||||
|
HTTP_STATISTICS_ENABLED=false # Enable statistics page at /stats (true/false)
|
||||||
|
|
||||||
# --- USER AUTHENTICATION & INBOX LOCKING ---
|
# --- USER AUTHENTICATION & INBOX LOCKING ---
|
||||||
USER_AUTH_ENABLED=false # Enable user registration/login system (default: false)
|
USER_AUTH_ENABLED=false # Enable user registration/login system (default: false)
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ All data is being removed 48hrs after they have reached the mail server.
|
||||||
- View the raw email, showing all the headers etc.
|
- View the raw email, showing all the headers etc.
|
||||||
- Download Attachments with one click
|
- Download Attachments with one click
|
||||||
- <u>Optional</u> User Account System with email forwarding and inbox locking
|
- <u>Optional</u> User Account System with email forwarding and inbox locking
|
||||||
|
- <u>Optional</u> Statistics System, tracking public data for as long as your mails stay
|
||||||
- and more...
|
- and more...
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
|
||||||
16
app.js
16
app.js
|
|
@ -30,15 +30,13 @@ 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)
|
||||||
|
|
||||||
// Initialize user repository and auth service (if enabled)
|
// Initialize user repository and auth service (if enabled)
|
||||||
let inboxLock = null
|
let inboxLock = null
|
||||||
|
let statisticsStore = null
|
||||||
|
|
||||||
if (config.user.authEnabled) {
|
if (config.user.authEnabled) {
|
||||||
// Migrate legacy database files for backwards compatibility
|
// Migrate legacy database files for backwards compatibility
|
||||||
Helper.migrateDatabase(config.user.databasePath)
|
Helper.migrateDatabase(config.user.databasePath)
|
||||||
|
|
@ -47,6 +45,11 @@ if (config.user.authEnabled) {
|
||||||
debug('User repository initialized')
|
debug('User repository initialized')
|
||||||
app.set('userRepository', userRepository)
|
app.set('userRepository', userRepository)
|
||||||
|
|
||||||
|
// Initialize statistics store with database connection
|
||||||
|
statisticsStore = new StatisticsStore(userRepository.db)
|
||||||
|
debug('Statistics store initialized with database persistence')
|
||||||
|
app.set('statisticsStore', statisticsStore)
|
||||||
|
|
||||||
const authService = new AuthService(userRepository, config)
|
const authService = new AuthService(userRepository, config)
|
||||||
debug('Auth service initialized')
|
debug('Auth service initialized')
|
||||||
app.set('authService', authService)
|
app.set('authService', authService)
|
||||||
|
|
@ -74,6 +77,11 @@ if (config.user.authEnabled) {
|
||||||
|
|
||||||
console.log('User authentication system enabled')
|
console.log('User authentication system enabled')
|
||||||
} else {
|
} else {
|
||||||
|
// No auth enabled - initialize statistics store without persistence
|
||||||
|
statisticsStore = new StatisticsStore()
|
||||||
|
debug('Statistics store initialized (in-memory only, no database)')
|
||||||
|
app.set('statisticsStore', statisticsStore)
|
||||||
|
|
||||||
app.set('userRepository', null)
|
app.set('userRepository', null)
|
||||||
app.set('authService', null)
|
app.set('authService', null)
|
||||||
app.set('inboxLock', null)
|
app.set('inboxLock', null)
|
||||||
|
|
|
||||||
4
app.json
4
app.json
|
|
@ -44,7 +44,7 @@
|
||||||
"description": "Port of the server (usually 993)",
|
"description": "Port of the server (usually 993)",
|
||||||
"value": 993
|
"value": 993
|
||||||
},
|
},
|
||||||
"IMAP_TLS": {
|
"IMAP_SECURE": {
|
||||||
"description": "Use tls or not",
|
"description": "Use tls or not",
|
||||||
"value": true
|
"value": true
|
||||||
},
|
},
|
||||||
|
|
@ -81,4 +81,4 @@
|
||||||
"value": false
|
"value": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ const config = {
|
||||||
password: parseValue(process.env.IMAP_PASSWORD),
|
password: parseValue(process.env.IMAP_PASSWORD),
|
||||||
host: parseValue(process.env.IMAP_SERVER),
|
host: parseValue(process.env.IMAP_SERVER),
|
||||||
port: Number(process.env.IMAP_PORT),
|
port: Number(process.env.IMAP_PORT),
|
||||||
tls: parseBool(process.env.IMAP_TLS),
|
secure: parseBool(process.env.IMAP_SECURE),
|
||||||
authTimeout: Number(process.env.IMAP_AUTH_TIMEOUT),
|
authTimeout: Number(process.env.IMAP_AUTH_TIMEOUT),
|
||||||
refreshIntervalSeconds: Number(process.env.IMAP_REFRESH_INTERVAL_SECONDS),
|
refreshIntervalSeconds: Number(process.env.IMAP_REFRESH_INTERVAL_SECONDS),
|
||||||
fetchChunkSize: Number(process.env.IMAP_FETCH_CHUNK) || 100,
|
fetchChunkSize: Number(process.env.IMAP_FETCH_CHUNK) || 100,
|
||||||
|
|
@ -58,11 +58,11 @@ const config = {
|
||||||
|
|
||||||
smtp: {
|
smtp: {
|
||||||
enabled: parseBool(process.env.SMTP_ENABLED) || false,
|
enabled: parseBool(process.env.SMTP_ENABLED) || false,
|
||||||
host: parseValue(process.env.SMTP_HOST),
|
|
||||||
port: Number(process.env.SMTP_PORT) || 465,
|
|
||||||
secure: parseBool(process.env.SMTP_SECURE) || true,
|
|
||||||
user: parseValue(process.env.SMTP_USER),
|
user: parseValue(process.env.SMTP_USER),
|
||||||
password: parseValue(process.env.SMTP_PASSWORD)
|
password: parseValue(process.env.SMTP_PASSWORD),
|
||||||
|
server: parseValue(process.env.SMTP_SERVER),
|
||||||
|
port: Number(process.env.SMTP_PORT) || 465,
|
||||||
|
secure: parseBool(process.env.SMTP_SECURE) || true
|
||||||
},
|
},
|
||||||
|
|
||||||
http: {
|
http: {
|
||||||
|
|
@ -70,7 +70,8 @@ const config = {
|
||||||
baseUrl: parseValue(process.env.HTTP_BASE_URL) || 'http://localhost:3000',
|
baseUrl: parseValue(process.env.HTTP_BASE_URL) || 'http://localhost:3000',
|
||||||
branding: parseValue(process.env.HTTP_BRANDING),
|
branding: parseValue(process.env.HTTP_BRANDING),
|
||||||
displaySort: Number(process.env.HTTP_DISPLAY_SORT),
|
displaySort: Number(process.env.HTTP_DISPLAY_SORT),
|
||||||
hideOther: parseBool(process.env.HTTP_HIDE_OTHER)
|
hideOther: parseBool(process.env.HTTP_HIDE_OTHER),
|
||||||
|
statisticsEnabled: parseBool(process.env.HTTP_STATISTICS_ENABLED) || false
|
||||||
},
|
},
|
||||||
|
|
||||||
user: {
|
user: {
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,25 @@ class Helper {
|
||||||
return footer
|
return footer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a mail count html element with tooltip for the footer
|
||||||
|
* @param {number} count - Current mail count
|
||||||
|
* @returns {String}
|
||||||
|
*/
|
||||||
|
mailCountBuilder(count) {
|
||||||
|
const imapService = require('./imap-service')
|
||||||
|
const largestUid = imapService.getLargestUid ? imapService.getLargestUid() : null
|
||||||
|
let tooltip = ''
|
||||||
|
|
||||||
|
if (largestUid && largestUid > 0) {
|
||||||
|
tooltip = `All-time total: ${largestUid} emails`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<label title="${tooltip}">
|
||||||
|
<h4 style="display: inline;"><u><i>${count} mails</i></u></h4>
|
||||||
|
</label>`
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shuffle an array using the Durstenfeld shuffle algorithm
|
* Shuffle an array using the Durstenfeld shuffle algorithm
|
||||||
* @param {Array} array
|
* @param {Array} array
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ class SmtpService {
|
||||||
_isConfigured() {
|
_isConfigured() {
|
||||||
return !!(
|
return !!(
|
||||||
this.config.smtp.enabled &&
|
this.config.smtp.enabled &&
|
||||||
this.config.smtp.host &&
|
this.config.smtp.server &&
|
||||||
this.config.smtp.user &&
|
this.config.smtp.user &&
|
||||||
this.config.smtp.password
|
this.config.smtp.password
|
||||||
)
|
)
|
||||||
|
|
@ -38,7 +38,7 @@ class SmtpService {
|
||||||
_initializeTransporter() {
|
_initializeTransporter() {
|
||||||
try {
|
try {
|
||||||
this.transporter = nodemailer.createTransport({
|
this.transporter = nodemailer.createTransport({
|
||||||
host: this.config.smtp.host,
|
host: this.config.smtp.server,
|
||||||
port: this.config.smtp.port,
|
port: this.config.smtp.port,
|
||||||
secure: this.config.smtp.secure,
|
secure: this.config.smtp.secure,
|
||||||
auth: {
|
auth: {
|
||||||
|
|
@ -52,7 +52,7 @@ class SmtpService {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
debug(`SMTP transporter initialized: ${this.config.smtp.host}:${this.config.smtp.port}`)
|
debug(`SMTP transporter initialized: ${this.config.smtp.server}:${this.config.smtp.port}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debug('Failed to initialize SMTP transporter:', error.message)
|
debug('Failed to initialize SMTP transporter:', error.message)
|
||||||
throw new Error(`SMTP initialization failed: ${error.message}`)
|
throw new Error(`SMTP initialization failed: ${error.message}`)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
const debug = require('debug')('48hr-email:stats-store')
|
const debug = require('debug')('48hr-email:stats-store')
|
||||||
|
const config = require('../application/config')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Statistics Store - Tracks email metrics and historical data
|
* Statistics Store - Tracks email metrics and historical data
|
||||||
* Stores 24-hour rolling statistics for receives, deletes, and forwards
|
* Stores 24-hour rolling statistics for receives, deletes, and forwards
|
||||||
|
* Persists data to database for survival across restarts
|
||||||
*/
|
*/
|
||||||
class StatisticsStore {
|
class StatisticsStore {
|
||||||
constructor() {
|
constructor(db = null) {
|
||||||
|
this.db = db
|
||||||
|
|
||||||
// Current totals
|
// Current totals
|
||||||
this.currentCount = 0
|
this.currentCount = 0
|
||||||
this.largestUid = 0
|
this.largestUid = 0
|
||||||
|
|
@ -17,9 +21,99 @@ class StatisticsStore {
|
||||||
// Track last cleanup to avoid too frequent operations
|
// Track last cleanup to avoid too frequent operations
|
||||||
this.lastCleanup = Date.now()
|
this.lastCleanup = Date.now()
|
||||||
|
|
||||||
|
// Historical data caching to prevent repeated analysis
|
||||||
|
this.historicalData = null
|
||||||
|
this.lastAnalysisTime = 0
|
||||||
|
this.analysisCacheDuration = 5 * 60 * 1000 // Cache for 5 minutes
|
||||||
|
|
||||||
|
// Load persisted data if database is available
|
||||||
|
if (this.db) {
|
||||||
|
this._loadFromDatabase()
|
||||||
|
}
|
||||||
|
|
||||||
debug('Statistics store initialized')
|
debug('Statistics store initialized')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cutoff time based on email purge configuration
|
||||||
|
* @returns {number} Timestamp in milliseconds
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_getPurgeCutoffMs() {
|
||||||
|
const time = config.email.purgeTime.time
|
||||||
|
const unit = config.email.purgeTime.unit
|
||||||
|
|
||||||
|
let cutoffMs = 0
|
||||||
|
switch (unit) {
|
||||||
|
case 'minutes':
|
||||||
|
cutoffMs = time * 60 * 1000
|
||||||
|
break
|
||||||
|
case 'hours':
|
||||||
|
cutoffMs = time * 60 * 60 * 1000
|
||||||
|
break
|
||||||
|
case 'days':
|
||||||
|
cutoffMs = time * 24 * 60 * 60 * 1000
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
cutoffMs = 48 * 60 * 60 * 1000 // Fallback to 48 hours
|
||||||
|
}
|
||||||
|
|
||||||
|
return cutoffMs
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load statistics from database
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_loadFromDatabase() {
|
||||||
|
try {
|
||||||
|
const stmt = this.db.prepare('SELECT largest_uid, hourly_data, last_updated FROM statistics WHERE id = 1')
|
||||||
|
const row = stmt.get()
|
||||||
|
|
||||||
|
if (row) {
|
||||||
|
this.largestUid = row.largest_uid || 0
|
||||||
|
|
||||||
|
// Parse hourly data
|
||||||
|
if (row.hourly_data) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(row.hourly_data)
|
||||||
|
// Filter out stale data based on config purge time
|
||||||
|
const cutoff = Date.now() - this._getPurgeCutoffMs()
|
||||||
|
this.hourlyData = parsed.filter(entry => entry.timestamp >= cutoff)
|
||||||
|
debug(`Loaded ${this.hourlyData.length} hourly data points from database (cutoff: ${new Date(cutoff).toISOString()})`)
|
||||||
|
} catch (e) {
|
||||||
|
debug('Failed to parse hourly data:', e.message)
|
||||||
|
this.hourlyData = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug(`Loaded from database: largestUid=${this.largestUid}, hourlyData=${this.hourlyData.length} entries`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
debug('Failed to load statistics from database:', error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save statistics to database
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_saveToDatabase() {
|
||||||
|
if (!this.db) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stmt = this.db.prepare(`
|
||||||
|
UPDATE statistics
|
||||||
|
SET largest_uid = ?, hourly_data = ?, last_updated = ?
|
||||||
|
WHERE id = 1
|
||||||
|
`)
|
||||||
|
stmt.run(this.largestUid, JSON.stringify(this.hourlyData), Date.now())
|
||||||
|
debug('Statistics saved to database')
|
||||||
|
} catch (error) {
|
||||||
|
debug('Failed to save statistics to database:', error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize with current email count
|
* Initialize with current email count
|
||||||
* @param {number} count - Current email count
|
* @param {number} count - Current email count
|
||||||
|
|
@ -36,6 +130,7 @@ class StatisticsStore {
|
||||||
updateLargestUid(uid) {
|
updateLargestUid(uid) {
|
||||||
if (uid >= 0 && uid > this.largestUid) {
|
if (uid >= 0 && uid > this.largestUid) {
|
||||||
this.largestUid = uid
|
this.largestUid = uid
|
||||||
|
this._saveToDatabase()
|
||||||
debug(`Largest UID updated to ${uid}`)
|
debug(`Largest UID updated to ${uid}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -103,6 +198,216 @@ class StatisticsStore {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze all existing emails to build historical statistics
|
||||||
|
* @param {Array} allMails - Array of all mail summaries with date property
|
||||||
|
*/
|
||||||
|
analyzeHistoricalData(allMails) {
|
||||||
|
if (!allMails || allMails.length === 0) {
|
||||||
|
debug('No historical data to analyze')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache - if analysis was done recently, skip it
|
||||||
|
const now = Date.now()
|
||||||
|
if (this.historicalData && (now - this.lastAnalysisTime) < this.analysisCacheDuration) {
|
||||||
|
debug(`Using cached historical data (${this.historicalData.length} points, age: ${Math.round((now - this.lastAnalysisTime) / 1000)}s)`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
debug(`Analyzing ${allMails.length} emails for historical statistics`)
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
// Group emails by minute
|
||||||
|
const histogram = new Map()
|
||||||
|
|
||||||
|
allMails.forEach(mail => {
|
||||||
|
try {
|
||||||
|
const date = new Date(mail.date)
|
||||||
|
if (isNaN(date.getTime())) return
|
||||||
|
|
||||||
|
const minute = Math.floor(date.getTime() / 60000) * 60000
|
||||||
|
|
||||||
|
if (!histogram.has(minute)) {
|
||||||
|
histogram.set(minute, 0)
|
||||||
|
}
|
||||||
|
histogram.set(minute, histogram.get(minute) + 1)
|
||||||
|
} catch (e) {
|
||||||
|
// Skip invalid dates
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Convert to array and sort by timestamp
|
||||||
|
this.historicalData = Array.from(histogram.entries())
|
||||||
|
.map(([timestamp, count]) => ({ timestamp, receives: count }))
|
||||||
|
.sort((a, b) => a.timestamp - b.timestamp)
|
||||||
|
|
||||||
|
this.lastAnalysisTime = now
|
||||||
|
|
||||||
|
const elapsed = Date.now() - startTime
|
||||||
|
debug(`Built historical data: ${this.historicalData.length} time buckets in ${elapsed}ms`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get enhanced statistics with historical data and predictions
|
||||||
|
* @returns {Object} Enhanced stats with historical timeline and predictions
|
||||||
|
*/
|
||||||
|
getEnhancedStats() {
|
||||||
|
this._cleanup()
|
||||||
|
|
||||||
|
const last24h = this._getLast24Hours()
|
||||||
|
const timeline = this._getTimeline()
|
||||||
|
const historicalTimeline = this._getHistoricalTimeline()
|
||||||
|
const prediction = this._generatePrediction()
|
||||||
|
|
||||||
|
// Calculate historical receives from purge time window
|
||||||
|
const cutoff = Date.now() - this._getPurgeCutoffMs()
|
||||||
|
const historicalReceives = historicalTimeline
|
||||||
|
.filter(point => point.timestamp >= cutoff)
|
||||||
|
.reduce((sum, point) => sum + point.receives, 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentCount: this.currentCount,
|
||||||
|
allTimeTotal: this.largestUid,
|
||||||
|
last24Hours: {
|
||||||
|
receives: last24h.receives + historicalReceives,
|
||||||
|
deletes: last24h.deletes,
|
||||||
|
forwards: last24h.forwards,
|
||||||
|
timeline: timeline
|
||||||
|
},
|
||||||
|
historical: historicalTimeline,
|
||||||
|
prediction: prediction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get lightweight statistics without historical analysis (for API updates)
|
||||||
|
* @returns {Object} Stats with only realtime data
|
||||||
|
*/
|
||||||
|
getLightweightStats() {
|
||||||
|
this._cleanup()
|
||||||
|
|
||||||
|
const last24h = this._getLast24Hours()
|
||||||
|
const timeline = this._getTimeline()
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentCount: this.currentCount,
|
||||||
|
allTimeTotal: this.largestUid,
|
||||||
|
last24Hours: {
|
||||||
|
receives: last24h.receives,
|
||||||
|
deletes: last24h.deletes,
|
||||||
|
forwards: last24h.forwards,
|
||||||
|
timeline: timeline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get historical timeline for visualization
|
||||||
|
* Shows data for the configured purge duration, aggregated by hour
|
||||||
|
* @returns {Array} Historical data points
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_getHistoricalTimeline() {
|
||||||
|
if (!this.historicalData || this.historicalData.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show historical data up to the purge time window
|
||||||
|
const cutoff = Date.now() - this._getPurgeCutoffMs()
|
||||||
|
const relevantHistory = this.historicalData.filter(point => point.timestamp >= cutoff)
|
||||||
|
|
||||||
|
// Aggregate by hour
|
||||||
|
const hourlyBuckets = new Map()
|
||||||
|
relevantHistory.forEach(point => {
|
||||||
|
const hour = Math.floor(point.timestamp / 3600000) * 3600000
|
||||||
|
if (!hourlyBuckets.has(hour)) {
|
||||||
|
hourlyBuckets.set(hour, 0)
|
||||||
|
}
|
||||||
|
hourlyBuckets.set(hour, hourlyBuckets.get(hour) + point.receives)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Convert to array and sort
|
||||||
|
const hourlyData = Array.from(hourlyBuckets.entries())
|
||||||
|
.map(([timestamp, receives]) => ({ timestamp, receives }))
|
||||||
|
.sort((a, b) => a.timestamp - b.timestamp)
|
||||||
|
|
||||||
|
debug(`Historical timeline: ${hourlyData.length} hourly points within ${config.email.purgeTime.time} ${config.email.purgeTime.unit} window`)
|
||||||
|
return hourlyData
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate prediction for next period based on historical patterns
|
||||||
|
* Uses config purge time to determine prediction window
|
||||||
|
* Predicts based on time-of-day patterns with randomization
|
||||||
|
* @returns {Array} Predicted data points
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_generatePrediction() {
|
||||||
|
if (!this.historicalData || this.historicalData.length < 100) {
|
||||||
|
return [] // Not enough data to predict
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const predictions = []
|
||||||
|
|
||||||
|
// Build hourly patterns from historical data
|
||||||
|
// Map hour-of-day to average receives count
|
||||||
|
const hourlyPatterns = new Map()
|
||||||
|
|
||||||
|
this.historicalData.forEach(point => {
|
||||||
|
const date = new Date(point.timestamp)
|
||||||
|
const hour = date.getHours()
|
||||||
|
|
||||||
|
if (!hourlyPatterns.has(hour)) {
|
||||||
|
hourlyPatterns.set(hour, [])
|
||||||
|
}
|
||||||
|
hourlyPatterns.get(hour).push(point.receives)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Calculate average for each hour
|
||||||
|
const hourlyAverages = new Map()
|
||||||
|
hourlyPatterns.forEach((values, hour) => {
|
||||||
|
const avg = values.reduce((sum, v) => sum + v, 0) / values.length
|
||||||
|
hourlyAverages.set(hour, avg)
|
||||||
|
})
|
||||||
|
|
||||||
|
debug(`Built hourly patterns for ${hourlyAverages.size} hours from ${this.historicalData.length} data points`)
|
||||||
|
|
||||||
|
// Generate predictions for purge duration (in 1-hour intervals)
|
||||||
|
const purgeMs = this._getPurgeCutoffMs()
|
||||||
|
const predictionHours = Math.ceil(purgeMs / (60 * 60 * 1000))
|
||||||
|
|
||||||
|
for (let i = 1; i <= predictionHours; i++) {
|
||||||
|
const timestamp = now + (i * 60 * 60 * 1000) // 1 hour intervals
|
||||||
|
const futureDate = new Date(timestamp)
|
||||||
|
const futureHour = futureDate.getHours()
|
||||||
|
|
||||||
|
// Get average for this hour, or fallback to overall average
|
||||||
|
let baseCount = hourlyAverages.get(futureHour)
|
||||||
|
if (baseCount === undefined) {
|
||||||
|
// Fallback to overall average if no data for this hour
|
||||||
|
const allValues = Array.from(hourlyAverages.values())
|
||||||
|
baseCount = allValues.reduce((sum, v) => sum + v, 0) / allValues.length
|
||||||
|
}
|
||||||
|
|
||||||
|
// baseCount is already per-minute average, scale to full hour
|
||||||
|
const scaledCount = baseCount * 60
|
||||||
|
|
||||||
|
// Add randomization (±20%)
|
||||||
|
const randomFactor = 0.8 + (Math.random() * 0.4) // 0.8 to 1.2
|
||||||
|
const predictedCount = Math.round(scaledCount * randomFactor)
|
||||||
|
|
||||||
|
predictions.push({
|
||||||
|
timestamp,
|
||||||
|
receives: Math.max(0, predictedCount)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
debug(`Generated ${predictions.length} prediction points based on hourly patterns`)
|
||||||
|
return predictions
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a data point to the rolling history
|
* Add a data point to the rolling history
|
||||||
* @param {string} type - Type of event (receive, delete, forward)
|
* @param {string} type - Type of event (receive, delete, forward)
|
||||||
|
|
@ -127,10 +432,15 @@ class StatisticsStore {
|
||||||
entry[type + 's']++
|
entry[type + 's']++
|
||||||
|
|
||||||
this._cleanup()
|
this._cleanup()
|
||||||
|
|
||||||
|
// Save to database periodically (every 10 data points to reduce I/O)
|
||||||
|
if (Math.random() < 0.1) { // ~10% chance = every ~10 events
|
||||||
|
this._saveToDatabase()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clean up old data points (older than 24 hours)
|
* Clean up old data points (older than email purge time)
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
_cleanup() {
|
_cleanup() {
|
||||||
|
|
@ -141,24 +451,25 @@ class StatisticsStore {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const cutoff = now - (24 * 60 * 60 * 1000)
|
const cutoff = now - this._getPurgeCutoffMs()
|
||||||
const beforeCount = this.hourlyData.length
|
const beforeCount = this.hourlyData.length
|
||||||
this.hourlyData = this.hourlyData.filter(entry => entry.timestamp >= cutoff)
|
this.hourlyData = this.hourlyData.filter(entry => entry.timestamp >= cutoff)
|
||||||
|
|
||||||
if (beforeCount !== this.hourlyData.length) {
|
if (beforeCount !== this.hourlyData.length) {
|
||||||
debug(`Cleaned up ${beforeCount - this.hourlyData.length} old data points`)
|
this._saveToDatabase() // Save after cleanup
|
||||||
|
debug(`Cleaned up ${beforeCount - this.hourlyData.length} old data points (keeping data for ${config.email.purgeTime.time} ${config.email.purgeTime.unit})`)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.lastCleanup = now
|
this.lastCleanup = now
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get aggregated stats for last 24 hours
|
* Get aggregated stats for the purge time window
|
||||||
* @returns {Object} Aggregated counts
|
* @returns {Object} Aggregated counts
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
_getLast24Hours() {
|
_getLast24Hours() {
|
||||||
const cutoff = Date.now() - (24 * 60 * 60 * 1000)
|
const cutoff = Date.now() - this._getPurgeCutoffMs()
|
||||||
const recent = this.hourlyData.filter(e => e.timestamp >= cutoff)
|
const recent = this.hourlyData.filter(e => e.timestamp >= cutoff)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -170,12 +481,13 @@ class StatisticsStore {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get timeline data for graphing (hourly aggregates)
|
* Get timeline data for graphing (hourly aggregates)
|
||||||
|
* Uses purge time for consistent timeline length
|
||||||
* @returns {Array} Array of hourly data points
|
* @returns {Array} Array of hourly data points
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
_getTimeline() {
|
_getTimeline() {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const cutoff = now - (24 * 60 * 60 * 1000)
|
const cutoff = now - this._getPurgeCutoffMs()
|
||||||
const hourly = {}
|
const hourly = {}
|
||||||
|
|
||||||
// Aggregate by hour
|
// Aggregate by hour
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/**
|
/**
|
||||||
* Statistics page functionality
|
* Statistics page functionality
|
||||||
* Handles Chart.js initialization and real-time updates via Socket.IO
|
* Handles Chart.js initialization with historical, real-time, and predicted data
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Initialize stats chart if on stats page
|
// Initialize stats chart if on stats page
|
||||||
|
|
@ -8,22 +8,25 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
const chartCanvas = document.getElementById('statsChart');
|
const chartCanvas = document.getElementById('statsChart');
|
||||||
if (!chartCanvas) return; // Not on stats page
|
if (!chartCanvas) return; // Not on stats page
|
||||||
|
|
||||||
// Get initial data from global variable (set by template)
|
// Get data from global variables (set by template)
|
||||||
if (typeof window.initialStatsData === 'undefined') {
|
if (typeof window.initialStatsData === 'undefined') {
|
||||||
console.error('Initial stats data not found');
|
console.error('Initial stats data not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialData = window.initialStatsData;
|
const realtimeData = window.initialStatsData || [];
|
||||||
|
const historicalData = window.historicalData || [];
|
||||||
|
const predictionData = window.predictionData || [];
|
||||||
|
|
||||||
|
console.log(`Loaded data: ${historicalData.length} historical, ${realtimeData.length} realtime, ${predictionData.length} predictions`);
|
||||||
|
|
||||||
// Set up Socket.IO connection for real-time updates
|
// Set up Socket.IO connection for real-time updates
|
||||||
if (typeof io !== 'undefined') {
|
if (typeof io !== 'undefined') {
|
||||||
const socket = io();
|
const socket = io();
|
||||||
|
|
||||||
// Listen for stats updates (any email event: receive, delete, forward)
|
|
||||||
socket.on('stats-update', () => {
|
socket.on('stats-update', () => {
|
||||||
console.log('Stats update received, reloading page...');
|
console.log('Stats update received (page will not auto-reload)');
|
||||||
location.reload();
|
// Don't auto-reload - user can manually refresh if needed
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('reconnect', () => {
|
socket.on('reconnect', () => {
|
||||||
|
|
@ -31,58 +34,123 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare chart data
|
// Combine all data and create labels
|
||||||
const labels = initialData.map(d => {
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Use a reasonable historical window (show data within the purge time range)
|
||||||
|
// This will adapt based on whether purge time is 48 hours, 7 days, etc.
|
||||||
|
const allTimePoints = [
|
||||||
|
...historicalData.map(d => ({...d, type: 'historical' })),
|
||||||
|
...realtimeData.map(d => ({...d, type: 'realtime' })),
|
||||||
|
...predictionData.map(d => ({...d, type: 'prediction' }))
|
||||||
|
].sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
|
||||||
|
// Create labels
|
||||||
|
const labels = allTimePoints.map(d => {
|
||||||
const date = new Date(d.timestamp);
|
const date = new Date(d.timestamp);
|
||||||
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
|
return date.toLocaleString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Prepare datasets
|
||||||
|
const historicalPoints = allTimePoints.map(d => d.type === 'historical' ? d.receives : null);
|
||||||
|
const realtimePoints = allTimePoints.map(d => d.type === 'realtime' ? d.receives : null);
|
||||||
|
const predictionPoints = allTimePoints.map(d => d.type === 'prediction' ? d.receives : null);
|
||||||
|
|
||||||
|
// Create gradient for fading effect on historical data
|
||||||
const ctx = chartCanvas.getContext('2d');
|
const ctx = chartCanvas.getContext('2d');
|
||||||
|
const historicalGradient = ctx.createLinearGradient(0, 0, chartCanvas.width * 0.3, 0);
|
||||||
|
historicalGradient.addColorStop(0, 'rgba(100, 100, 255, 0.05)');
|
||||||
|
historicalGradient.addColorStop(1, 'rgba(100, 100, 255, 0.15)');
|
||||||
|
|
||||||
|
// Track visibility state for each dataset
|
||||||
|
const datasetVisibility = [true, true, true];
|
||||||
|
|
||||||
const chart = new Chart(ctx, {
|
const chart = new Chart(ctx, {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: {
|
data: {
|
||||||
labels: labels,
|
labels: labels,
|
||||||
datasets: [{
|
datasets: [{
|
||||||
label: 'Received',
|
label: 'Historical',
|
||||||
data: initialData.map(d => d.receives),
|
data: historicalPoints,
|
||||||
borderColor: '#9b4dca',
|
borderColor: 'rgba(100, 149, 237, 0.8)',
|
||||||
backgroundColor: 'rgba(155, 77, 202, 0.1)',
|
backgroundColor: historicalGradient,
|
||||||
|
borderWidth: 2,
|
||||||
tension: 0.4,
|
tension: 0.4,
|
||||||
fill: true
|
pointRadius: 4,
|
||||||
|
pointBackgroundColor: 'rgba(100, 149, 237, 0.8)',
|
||||||
|
spanGaps: false,
|
||||||
|
fill: true,
|
||||||
|
hidden: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Deleted',
|
label: 'Current Activity',
|
||||||
data: initialData.map(d => d.deletes),
|
data: realtimePoints,
|
||||||
borderColor: '#e74c3c',
|
borderColor: '#2ecc71',
|
||||||
backgroundColor: 'rgba(231, 76, 60, 0.1)',
|
backgroundColor: 'rgba(46, 204, 113, 0.15)',
|
||||||
|
borderWidth: 4,
|
||||||
tension: 0.4,
|
tension: 0.4,
|
||||||
fill: true
|
pointRadius: 4,
|
||||||
|
pointBackgroundColor: '#2ecc71',
|
||||||
|
spanGaps: false,
|
||||||
|
fill: true,
|
||||||
|
hidden: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Forwarded',
|
label: 'Predicted',
|
||||||
data: initialData.map(d => d.forwards),
|
data: predictionPoints,
|
||||||
borderColor: '#3498db',
|
borderColor: '#ff9f43',
|
||||||
backgroundColor: 'rgba(52, 152, 219, 0.1)',
|
backgroundColor: 'rgba(255, 159, 67, 0.08)',
|
||||||
|
borderWidth: 3,
|
||||||
|
borderDash: [8, 4],
|
||||||
tension: 0.4,
|
tension: 0.4,
|
||||||
fill: true
|
pointRadius: 4,
|
||||||
|
pointBackgroundColor: '#ff9f43',
|
||||||
|
spanGaps: false,
|
||||||
|
fill: true,
|
||||||
|
hidden: false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false
|
||||||
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
display: true,
|
display: false // Disable default legend, we'll create custom
|
||||||
position: 'top',
|
|
||||||
labels: {
|
|
||||||
color: getComputedStyle(document.documentElement).getPropertyValue('--color-text-light'),
|
|
||||||
font: { size: 14 }
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
mode: 'index',
|
mode: 'index',
|
||||||
intersect: false
|
intersect: false,
|
||||||
|
callbacks: {
|
||||||
|
title: function(context) {
|
||||||
|
const dataIndex = context[0].dataIndex;
|
||||||
|
const point = allTimePoints[dataIndex];
|
||||||
|
const date = new Date(point.timestamp);
|
||||||
|
return date.toLocaleString('en-US', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
label: function(context) {
|
||||||
|
let label = context.dataset.label || '';
|
||||||
|
if (label) {
|
||||||
|
label += ': ';
|
||||||
|
}
|
||||||
|
if (context.parsed.y !== null) {
|
||||||
|
label += context.parsed.y + ' emails';
|
||||||
|
}
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
|
|
@ -90,17 +158,26 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
beginAtZero: true,
|
beginAtZero: true,
|
||||||
ticks: {
|
ticks: {
|
||||||
color: getComputedStyle(document.documentElement).getPropertyValue('--color-text-dim'),
|
color: getComputedStyle(document.documentElement).getPropertyValue('--color-text-dim'),
|
||||||
stepSize: 1
|
stepSize: 1,
|
||||||
|
callback: function(value) {
|
||||||
|
return Math.round(value);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
color: 'rgba(255, 255, 255, 0.1)'
|
color: 'rgba(255, 255, 255, 0.1)'
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Emails Received',
|
||||||
|
color: getComputedStyle(document.documentElement).getPropertyValue('--color-text-light')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
x: {
|
x: {
|
||||||
ticks: {
|
ticks: {
|
||||||
color: getComputedStyle(document.documentElement).getPropertyValue('--color-text-dim'),
|
color: getComputedStyle(document.documentElement).getPropertyValue('--color-text-dim'),
|
||||||
maxRotation: 45,
|
maxRotation: 45,
|
||||||
minRotation: 45
|
minRotation: 45,
|
||||||
|
maxTicksLimit: 20
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
color: 'rgba(255, 255, 255, 0.05)'
|
color: 'rgba(255, 255, 255, 0.05)'
|
||||||
|
|
@ -109,4 +186,52 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Create custom legend buttons
|
||||||
|
const chartContainer = chartCanvas.parentElement;
|
||||||
|
const legendContainer = document.createElement('div');
|
||||||
|
legendContainer.className = 'chart-legend-custom';
|
||||||
|
legendContainer.innerHTML = `
|
||||||
|
<button class="legend-btn active" data-index="0">
|
||||||
|
<span class="legend-indicator" style="background: rgba(100, 149, 237, 0.8);"></span>
|
||||||
|
<span class="legend-label">Historical</span>
|
||||||
|
</button>
|
||||||
|
<button class="legend-btn active" data-index="1">
|
||||||
|
<span class="legend-indicator" style="background: #2ecc71;"></span>
|
||||||
|
<span class="legend-label">Current Activity</span>
|
||||||
|
</button>
|
||||||
|
<button class="legend-btn active" data-index="2">
|
||||||
|
<span class="legend-indicator" style="background: #ff9f43; border: 2px dashed rgba(255, 159, 67, 0.5);"></span>
|
||||||
|
<span class="legend-label">Predicted</span>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
chartContainer.insertBefore(legendContainer, chartCanvas);
|
||||||
|
|
||||||
|
// Handle legend button clicks
|
||||||
|
legendContainer.querySelectorAll('.legend-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
const index = parseInt(this.getAttribute('data-index'));
|
||||||
|
const isActive = this.classList.contains('active');
|
||||||
|
|
||||||
|
// Toggle button state
|
||||||
|
this.classList.toggle('active');
|
||||||
|
|
||||||
|
// Toggle dataset visibility with fade effect
|
||||||
|
const meta = chart.getDatasetMeta(index);
|
||||||
|
const dataset = chart.data.datasets[index];
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
// Fade out
|
||||||
|
meta.hidden = true;
|
||||||
|
datasetVisibility[index] = false;
|
||||||
|
} else {
|
||||||
|
// Fade in
|
||||||
|
meta.hidden = false;
|
||||||
|
datasetVisibility[index] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
chart.update('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2494,6 +2494,20 @@ body.light-mode .theme-icon-light {
|
||||||
margin-bottom: 3rem;
|
margin-bottom: 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stats-subtitle .purge-time-inline {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-subtitle .purge-time-inline label,
|
||||||
|
.stats-subtitle .purge-time-inline h4 {
|
||||||
|
display: inline;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
font-weight: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
.stats-grid {
|
.stats-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
|
@ -2551,6 +2565,77 @@ body.light-mode .theme-icon-light {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Custom legend for stats chart */
|
||||||
|
|
||||||
|
.chart-legend-custom {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
background: var(--overlay-white-05);
|
||||||
|
border: 2px solid var(--overlay-purple-30);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-btn:hover {
|
||||||
|
background: var(--overlay-white-10);
|
||||||
|
border-color: var(--color-accent-purple);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(155, 77, 202, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-btn.active {
|
||||||
|
background: var(--overlay-purple-20);
|
||||||
|
border-color: var(--color-accent-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-btn:not(.active) {
|
||||||
|
opacity: 0.4;
|
||||||
|
background: var(--overlay-white-02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-btn:not(.active):hover {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-indicator {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-label {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.chart-legend-custom {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.legend-btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Responsive Styles */
|
/* Responsive Styles */
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,15 @@ const debug = require('debug')('48hr-email:stats-routes')
|
||||||
router.get('/', async(req, res) => {
|
router.get('/', async(req, res) => {
|
||||||
try {
|
try {
|
||||||
const config = req.app.get('config')
|
const config = req.app.get('config')
|
||||||
|
|
||||||
|
// Check if statistics are enabled
|
||||||
|
if (!config.http.statisticsEnabled) {
|
||||||
|
return res.status(404).send('Statistics are disabled')
|
||||||
|
}
|
||||||
|
|
||||||
const statisticsStore = req.app.get('statisticsStore')
|
const statisticsStore = req.app.get('statisticsStore')
|
||||||
const imapService = req.app.get('imapService')
|
const imapService = req.app.get('imapService')
|
||||||
|
const mailProcessingService = req.app.get('mailProcessingService')
|
||||||
const Helper = require('../../../application/helper')
|
const Helper = require('../../../application/helper')
|
||||||
const helper = new Helper()
|
const helper = new Helper()
|
||||||
|
|
||||||
|
|
@ -17,10 +24,16 @@ router.get('/', async(req, res) => {
|
||||||
statisticsStore.updateLargestUid(largestUid)
|
statisticsStore.updateLargestUid(largestUid)
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats = statisticsStore.getStats()
|
// Analyze all existing emails for historical data
|
||||||
|
if (mailProcessingService) {
|
||||||
|
const allMails = mailProcessingService.getAllMailSummaries()
|
||||||
|
statisticsStore.analyzeHistoricalData(allMails)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = statisticsStore.getEnhancedStats()
|
||||||
const purgeTime = helper.purgeTimeElemetBuilder()
|
const purgeTime = helper.purgeTimeElemetBuilder()
|
||||||
|
|
||||||
debug(`Stats page requested: ${stats.currentCount} current, ${stats.allTimeTotal} all-time total`)
|
debug(`Stats page requested: ${stats.currentCount} current, ${stats.allTimeTotal} all-time total, ${stats.historical.length} historical points`)
|
||||||
|
|
||||||
res.render('stats', {
|
res.render('stats', {
|
||||||
title: `Statistics | ${config.http.branding[0]}`,
|
title: `Statistics | ${config.http.branding[0]}`,
|
||||||
|
|
@ -51,7 +64,8 @@ router.get('/api', async(req, res) => {
|
||||||
statisticsStore.updateLargestUid(largestUid)
|
statisticsStore.updateLargestUid(largestUid)
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats = statisticsStore.getStats()
|
// Use lightweight stats - no historical analysis on API calls
|
||||||
|
const stats = statisticsStore.getLightweightStats()
|
||||||
|
|
||||||
res.json(stats)
|
res.json(stats)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,11 @@
|
||||||
{% 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 }} | <a href="/stats" style="text-decoration:underline">See daily Stats</a></h4>
|
{% if config.http.statisticsEnabled %}
|
||||||
|
<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 }} | Check out our public <a href="/stats" style="text-decoration:underline">Statistics</a></h4>
|
||||||
|
{% else %}
|
||||||
|
<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 {{ mailCount | raw }}</h4>
|
||||||
|
{% endif %}
|
||||||
<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 %}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
{% set bodyClass = 'loading-page' %}
|
{% set bodyClass = 'loading-page' %}
|
||||||
|
|
||||||
|
{% block title %}Loading... | {{ branding[0] }}{% endblock %}
|
||||||
|
|
||||||
{% block header %}{% endblock %}
|
{% block header %}{% endblock %}
|
||||||
{% block footer %}{% endblock %}
|
{% block footer %}{% endblock %}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="stats-container">
|
<div class="stats-container">
|
||||||
<h1 class="page-title">Email Statistics</h1>
|
<h1 class="page-title">Email Statistics</h1>
|
||||||
<p class="stats-subtitle">Real-time email activity and 24-hour trends</p>
|
<p class="stats-subtitle">Historical patterns, real-time activity, and predictions over {{ purgeTime|striptags }}</p>
|
||||||
|
|
||||||
<div class="stats-grid">
|
<div class="stats-grid">
|
||||||
<!-- Current Count -->
|
<!-- Current Count -->
|
||||||
|
|
@ -43,34 +43,34 @@
|
||||||
<div class="stat-label">Emails in System</div>
|
<div class="stat-label">Emails in System</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Historical Total -->
|
<!-- All-Time Total -->
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-value" id="historicalTotal">{{ stats.allTimeTotal }}</div>
|
<div class="stat-value" id="historicalTotal">{{ stats.allTimeTotal }}</div>
|
||||||
<div class="stat-label">All Time Total</div>
|
<div class="stat-label">All-Time Total</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 24h Receives -->
|
<!-- Receives (Purge Window) -->
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-value" id="receives24h">{{ stats.last24Hours.receives }}</div>
|
<div class="stat-value" id="receives24h">{{ stats.last24Hours.receives }}</div>
|
||||||
<div class="stat-label">Received (24h)</div>
|
<div class="stat-label">Received</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 24h Deletes -->
|
<!-- Deletes (Purge Window) -->
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-value" id="deletes24h">{{ stats.last24Hours.deletes }}</div>
|
<div class="stat-value" id="deletes24h">{{ stats.last24Hours.deletes }}</div>
|
||||||
<div class="stat-label">Deleted (24h)</div>
|
<div class="stat-label">Deleted</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 24h Forwards -->
|
<!-- Forwards (Purge Window) -->
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-value" id="forwards24h">{{ stats.last24Hours.forwards }}</div>
|
<div class="stat-value" id="forwards24h">{{ stats.last24Hours.forwards }}</div>
|
||||||
<div class="stat-label">Forwarded (24h)</div>
|
<div class="stat-label">Forwarded</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Chart -->
|
<!-- Chart -->
|
||||||
<div class="chart-container">
|
<div class="chart-container">
|
||||||
<h2>Activity Timeline (24 Hours)</h2>
|
<h2>Email Activity Timeline</h2>
|
||||||
<canvas id="statsChart"></canvas>
|
<canvas id="statsChart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -78,5 +78,7 @@
|
||||||
<script>
|
<script>
|
||||||
// Set initial data for stats.js to consume
|
// Set initial data for stats.js to consume
|
||||||
window.initialStatsData = {{ stats.last24Hours.timeline|json_encode|raw }};
|
window.initialStatsData = {{ stats.last24Hours.timeline|json_encode|raw }};
|
||||||
|
window.historicalData = {{ stats.historical|json_encode|raw }};
|
||||||
|
window.predictionData = {{ stats.prediction|json_encode|raw }};
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,7 @@ Twig.extendFilter('readablePurgeTime', readablePurgeTime)
|
||||||
// Middleware to expose user session to all templates
|
// Middleware to expose user session to all templates
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
res.locals.authEnabled = config.user.authEnabled
|
res.locals.authEnabled = config.user.authEnabled
|
||||||
|
res.locals.config = config
|
||||||
res.locals.currentUser = null
|
res.locals.currentUser = null
|
||||||
if (req.session && req.session.userId && req.session.username && req.session.isAuthenticated) {
|
if (req.session && req.session.userId && req.session.username && req.session.isAuthenticated) {
|
||||||
res.locals.currentUser = {
|
res.locals.currentUser = {
|
||||||
|
|
@ -105,6 +106,21 @@ app.use((req, res, next) => {
|
||||||
next()
|
next()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Middleware to expose mail count to all templates
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const mailProcessingService = req.app.get('mailProcessingService')
|
||||||
|
const Helper = require('../../application/helper')
|
||||||
|
const helper = new Helper()
|
||||||
|
|
||||||
|
if (mailProcessingService) {
|
||||||
|
const count = mailProcessingService.getCount()
|
||||||
|
res.locals.mailCount = helper.mailCountBuilder(count)
|
||||||
|
} else {
|
||||||
|
res.locals.mailCount = ''
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
// Middleware to show loading page until IMAP is ready
|
// Middleware to show loading page until IMAP is ready
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const isImapReady = req.app.get('isImapReady')
|
const isImapReady = req.app.get('isImapReady')
|
||||||
|
|
|
||||||
24
package.json
24
package.json
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "48hr.email",
|
"name": "48hr.email",
|
||||||
"version": "2.0.0",
|
"version": "2.1.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "48hr.email is your favorite open-source tempmail client.",
|
"description": "48hr.email is your favorite open-source tempmail client.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|
@ -68,17 +68,15 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"overrides": [
|
"overrides": [{
|
||||||
{
|
"files": "public/javascripts/*.js",
|
||||||
"files": "public/javascripts/*.js",
|
"esnext": false,
|
||||||
"esnext": false,
|
"env": [
|
||||||
"env": [
|
"browser"
|
||||||
"browser"
|
],
|
||||||
],
|
"globals": [
|
||||||
"globals": [
|
"io"
|
||||||
"io"
|
]
|
||||||
]
|
}]
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
12
schema.sql
12
schema.sql
|
|
@ -44,6 +44,18 @@ CREATE INDEX IF NOT EXISTS idx_locked_inboxes_user_id ON user_locked_inboxes(use
|
||||||
CREATE INDEX IF NOT EXISTS idx_locked_inboxes_address ON user_locked_inboxes(inbox_address);
|
CREATE INDEX IF NOT EXISTS idx_locked_inboxes_address ON user_locked_inboxes(inbox_address);
|
||||||
CREATE INDEX IF NOT EXISTS idx_locked_inboxes_last_accessed ON user_locked_inboxes(last_accessed);
|
CREATE INDEX IF NOT EXISTS idx_locked_inboxes_last_accessed ON user_locked_inboxes(last_accessed);
|
||||||
|
|
||||||
|
-- Statistics storage for persistence across restarts
|
||||||
|
CREATE TABLE IF NOT EXISTS statistics (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1), -- Single row table
|
||||||
|
largest_uid INTEGER NOT NULL DEFAULT 0,
|
||||||
|
hourly_data TEXT, -- JSON array of 24h rolling data
|
||||||
|
last_updated INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Initialize with default row if not exists
|
||||||
|
INSERT OR IGNORE INTO statistics (id, largest_uid, hourly_data, last_updated)
|
||||||
|
VALUES (1, 0, '[]', 0);
|
||||||
|
|
||||||
-- Trigger to enforce max 5 locked inboxes per user
|
-- Trigger to enforce max 5 locked inboxes per user
|
||||||
CREATE TRIGGER IF NOT EXISTS check_locked_inbox_limit
|
CREATE TRIGGER IF NOT EXISTS check_locked_inbox_limit
|
||||||
BEFORE INSERT ON user_locked_inboxes
|
BEFORE INSERT ON user_locked_inboxes
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue