mirror of
https://github.com/Crazyco-xyz/48hr.email.git
synced 2026-01-08 10:49:35 +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_SERVER="imap.example.com" # IMAP server address
|
||||
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_REFRESH_INTERVAL_SECONDS=60 # Refresh interval for checking new emails
|
||||
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_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_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_PORT=3000 # Port
|
||||
|
|
@ -41,6 +41,7 @@ HTTP_DISPLAY_SORT=2 # Domain display
|
|||
# 2 = alphabetical + first item shuffled,
|
||||
# 3 = shuffle 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_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.
|
||||
- Download Attachments with one click
|
||||
- <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...
|
||||
|
||||
<br>
|
||||
|
|
|
|||
16
app.js
16
app.js
|
|
@ -30,15 +30,13 @@ const verificationStore = new VerificationStore()
|
|||
debug('Verification store initialized')
|
||||
app.set('verificationStore', verificationStore)
|
||||
|
||||
const statisticsStore = new StatisticsStore()
|
||||
debug('Statistics store initialized')
|
||||
app.set('statisticsStore', statisticsStore)
|
||||
|
||||
// Set config in app for route access
|
||||
app.set('config', config)
|
||||
|
||||
// Initialize user repository and auth service (if enabled)
|
||||
let inboxLock = null
|
||||
let statisticsStore = null
|
||||
|
||||
if (config.user.authEnabled) {
|
||||
// Migrate legacy database files for backwards compatibility
|
||||
Helper.migrateDatabase(config.user.databasePath)
|
||||
|
|
@ -47,6 +45,11 @@ if (config.user.authEnabled) {
|
|||
debug('User repository initialized')
|
||||
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)
|
||||
debug('Auth service initialized')
|
||||
app.set('authService', authService)
|
||||
|
|
@ -74,6 +77,11 @@ if (config.user.authEnabled) {
|
|||
|
||||
console.log('User authentication system enabled')
|
||||
} 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('authService', null)
|
||||
app.set('inboxLock', null)
|
||||
|
|
|
|||
4
app.json
4
app.json
|
|
@ -44,7 +44,7 @@
|
|||
"description": "Port of the server (usually 993)",
|
||||
"value": 993
|
||||
},
|
||||
"IMAP_TLS": {
|
||||
"IMAP_SECURE": {
|
||||
"description": "Use tls or not",
|
||||
"value": true
|
||||
},
|
||||
|
|
@ -81,4 +81,4 @@
|
|||
"value": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ const config = {
|
|||
password: parseValue(process.env.IMAP_PASSWORD),
|
||||
host: parseValue(process.env.IMAP_SERVER),
|
||||
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),
|
||||
refreshIntervalSeconds: Number(process.env.IMAP_REFRESH_INTERVAL_SECONDS),
|
||||
fetchChunkSize: Number(process.env.IMAP_FETCH_CHUNK) || 100,
|
||||
|
|
@ -58,11 +58,11 @@ const config = {
|
|||
|
||||
smtp: {
|
||||
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),
|
||||
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: {
|
||||
|
|
@ -70,7 +70,8 @@ const config = {
|
|||
baseUrl: parseValue(process.env.HTTP_BASE_URL) || 'http://localhost:3000',
|
||||
branding: parseValue(process.env.HTTP_BRANDING),
|
||||
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: {
|
||||
|
|
|
|||
|
|
@ -106,6 +106,25 @@ class Helper {
|
|||
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
|
||||
* @param {Array} array
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ class SmtpService {
|
|||
_isConfigured() {
|
||||
return !!(
|
||||
this.config.smtp.enabled &&
|
||||
this.config.smtp.host &&
|
||||
this.config.smtp.server &&
|
||||
this.config.smtp.user &&
|
||||
this.config.smtp.password
|
||||
)
|
||||
|
|
@ -38,7 +38,7 @@ class SmtpService {
|
|||
_initializeTransporter() {
|
||||
try {
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: this.config.smtp.host,
|
||||
host: this.config.smtp.server,
|
||||
port: this.config.smtp.port,
|
||||
secure: this.config.smtp.secure,
|
||||
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) {
|
||||
debug('Failed to initialize SMTP transporter:', error.message)
|
||||
throw new Error(`SMTP initialization failed: ${error.message}`)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
const debug = require('debug')('48hr-email:stats-store')
|
||||
const config = require('../application/config')
|
||||
|
||||
/**
|
||||
* Statistics Store - Tracks email metrics and historical data
|
||||
* Stores 24-hour rolling statistics for receives, deletes, and forwards
|
||||
* Persists data to database for survival across restarts
|
||||
*/
|
||||
class StatisticsStore {
|
||||
constructor() {
|
||||
constructor(db = null) {
|
||||
this.db = db
|
||||
|
||||
// Current totals
|
||||
this.currentCount = 0
|
||||
this.largestUid = 0
|
||||
|
|
@ -17,9 +21,99 @@ class StatisticsStore {
|
|||
// Track last cleanup to avoid too frequent operations
|
||||
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')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param {number} count - Current email count
|
||||
|
|
@ -36,6 +130,7 @@ class StatisticsStore {
|
|||
updateLargestUid(uid) {
|
||||
if (uid >= 0 && uid > this.largestUid) {
|
||||
this.largestUid = uid
|
||||
this._saveToDatabase()
|
||||
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
|
||||
* @param {string} type - Type of event (receive, delete, forward)
|
||||
|
|
@ -127,10 +432,15 @@ class StatisticsStore {
|
|||
entry[type + 's']++
|
||||
|
||||
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
|
||||
*/
|
||||
_cleanup() {
|
||||
|
|
@ -141,24 +451,25 @@ class StatisticsStore {
|
|||
return
|
||||
}
|
||||
|
||||
const cutoff = now - (24 * 60 * 60 * 1000)
|
||||
const cutoff = now - this._getPurgeCutoffMs()
|
||||
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._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
|
||||
}
|
||||
|
||||
/**
|
||||
* Get aggregated stats for last 24 hours
|
||||
* Get aggregated stats for the purge time window
|
||||
* @returns {Object} Aggregated counts
|
||||
* @private
|
||||
*/
|
||||
_getLast24Hours() {
|
||||
const cutoff = Date.now() - (24 * 60 * 60 * 1000)
|
||||
const cutoff = Date.now() - this._getPurgeCutoffMs()
|
||||
const recent = this.hourlyData.filter(e => e.timestamp >= cutoff)
|
||||
|
||||
return {
|
||||
|
|
@ -170,12 +481,13 @@ class StatisticsStore {
|
|||
|
||||
/**
|
||||
* Get timeline data for graphing (hourly aggregates)
|
||||
* Uses purge time for consistent timeline length
|
||||
* @returns {Array} Array of hourly data points
|
||||
* @private
|
||||
*/
|
||||
_getTimeline() {
|
||||
const now = Date.now()
|
||||
const cutoff = now - (24 * 60 * 60 * 1000)
|
||||
const cutoff = now - this._getPurgeCutoffMs()
|
||||
const hourly = {}
|
||||
|
||||
// Aggregate by hour
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* 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
|
||||
|
|
@ -8,22 +8,25 @@ 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)
|
||||
// Get data from global variables (set by template)
|
||||
if (typeof window.initialStatsData === 'undefined') {
|
||||
console.error('Initial stats data not found');
|
||||
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
|
||||
if (typeof io !== 'undefined') {
|
||||
const socket = io();
|
||||
|
||||
// Listen for stats updates (any email event: receive, delete, forward)
|
||||
socket.on('stats-update', () => {
|
||||
console.log('Stats update received, reloading page...');
|
||||
location.reload();
|
||||
console.log('Stats update received (page will not auto-reload)');
|
||||
// Don't auto-reload - user can manually refresh if needed
|
||||
});
|
||||
|
||||
socket.on('reconnect', () => {
|
||||
|
|
@ -31,58 +34,123 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
});
|
||||
}
|
||||
|
||||
// Prepare chart data
|
||||
const labels = initialData.map(d => {
|
||||
// Combine all data and create labels
|
||||
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);
|
||||
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 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, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: 'Received',
|
||||
data: initialData.map(d => d.receives),
|
||||
borderColor: '#9b4dca',
|
||||
backgroundColor: 'rgba(155, 77, 202, 0.1)',
|
||||
label: 'Historical',
|
||||
data: historicalPoints,
|
||||
borderColor: 'rgba(100, 149, 237, 0.8)',
|
||||
backgroundColor: historicalGradient,
|
||||
borderWidth: 2,
|
||||
tension: 0.4,
|
||||
fill: true
|
||||
pointRadius: 4,
|
||||
pointBackgroundColor: 'rgba(100, 149, 237, 0.8)',
|
||||
spanGaps: false,
|
||||
fill: true,
|
||||
hidden: false
|
||||
},
|
||||
{
|
||||
label: 'Deleted',
|
||||
data: initialData.map(d => d.deletes),
|
||||
borderColor: '#e74c3c',
|
||||
backgroundColor: 'rgba(231, 76, 60, 0.1)',
|
||||
label: 'Current Activity',
|
||||
data: realtimePoints,
|
||||
borderColor: '#2ecc71',
|
||||
backgroundColor: 'rgba(46, 204, 113, 0.15)',
|
||||
borderWidth: 4,
|
||||
tension: 0.4,
|
||||
fill: true
|
||||
pointRadius: 4,
|
||||
pointBackgroundColor: '#2ecc71',
|
||||
spanGaps: false,
|
||||
fill: true,
|
||||
hidden: false
|
||||
},
|
||||
{
|
||||
label: 'Forwarded',
|
||||
data: initialData.map(d => d.forwards),
|
||||
borderColor: '#3498db',
|
||||
backgroundColor: 'rgba(52, 152, 219, 0.1)',
|
||||
label: 'Predicted',
|
||||
data: predictionPoints,
|
||||
borderColor: '#ff9f43',
|
||||
backgroundColor: 'rgba(255, 159, 67, 0.08)',
|
||||
borderWidth: 3,
|
||||
borderDash: [8, 4],
|
||||
tension: 0.4,
|
||||
fill: true
|
||||
pointRadius: 4,
|
||||
pointBackgroundColor: '#ff9f43',
|
||||
spanGaps: false,
|
||||
fill: true,
|
||||
hidden: false
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
labels: {
|
||||
color: getComputedStyle(document.documentElement).getPropertyValue('--color-text-light'),
|
||||
font: { size: 14 }
|
||||
}
|
||||
display: false // Disable default legend, we'll create custom
|
||||
},
|
||||
tooltip: {
|
||||
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: {
|
||||
|
|
@ -90,17 +158,26 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: getComputedStyle(document.documentElement).getPropertyValue('--color-text-dim'),
|
||||
stepSize: 1
|
||||
stepSize: 1,
|
||||
callback: function(value) {
|
||||
return Math.round(value);
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.1)'
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Emails Received',
|
||||
color: getComputedStyle(document.documentElement).getPropertyValue('--color-text-light')
|
||||
}
|
||||
},
|
||||
x: {
|
||||
ticks: {
|
||||
color: getComputedStyle(document.documentElement).getPropertyValue('--color-text-dim'),
|
||||
maxRotation: 45,
|
||||
minRotation: 45
|
||||
minRotation: 45,
|
||||
maxTicksLimit: 20
|
||||
},
|
||||
grid: {
|
||||
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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: grid;
|
||||
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 */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
|
|
|||
|
|
@ -6,8 +6,15 @@ const debug = require('debug')('48hr-email:stats-routes')
|
|||
router.get('/', async(req, res) => {
|
||||
try {
|
||||
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 imapService = req.app.get('imapService')
|
||||
const mailProcessingService = req.app.get('mailProcessingService')
|
||||
const Helper = require('../../../application/helper')
|
||||
const helper = new Helper()
|
||||
|
||||
|
|
@ -17,10 +24,16 @@ router.get('/', async(req, res) => {
|
|||
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()
|
||||
|
||||
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', {
|
||||
title: `Statistics | ${config.http.branding[0]}`,
|
||||
|
|
@ -51,7 +64,8 @@ router.get('/api', async(req, res) => {
|
|||
statisticsStore.updateLargestUid(largestUid)
|
||||
}
|
||||
|
||||
const stats = statisticsStore.getStats()
|
||||
// Use lightweight stats - no historical analysis on API calls
|
||||
const stats = statisticsStore.getLightweightStats()
|
||||
|
||||
res.json(stats)
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -92,7 +92,11 @@
|
|||
{% block footer %}
|
||||
<section class="container footer">
|
||||
<hr>
|
||||
<h4>{{ branding[0] }} offered by <a href="{{ branding[2] }}" style="text-decoration:underline" target="_blank">{{ branding[1] }}</a> | All Emails will be deleted after {{ purgeTime | raw }} | <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>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
{% set bodyClass = 'loading-page' %}
|
||||
|
||||
{% block title %}Loading... | {{ branding[0] }}{% endblock %}
|
||||
|
||||
{% block header %}{% endblock %}
|
||||
{% block footer %}{% endblock %}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
{% 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>
|
||||
<p class="stats-subtitle">Historical patterns, real-time activity, and predictions over {{ purgeTime|striptags }}</p>
|
||||
|
||||
<div class="stats-grid">
|
||||
<!-- Current Count -->
|
||||
|
|
@ -43,34 +43,34 @@
|
|||
<div class="stat-label">Emails in System</div>
|
||||
</div>
|
||||
|
||||
<!-- Historical Total -->
|
||||
<!-- All-Time Total -->
|
||||
<div class="stat-card">
|
||||
<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>
|
||||
|
||||
<!-- 24h Receives -->
|
||||
<!-- Receives (Purge Window) -->
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="receives24h">{{ stats.last24Hours.receives }}</div>
|
||||
<div class="stat-label">Received (24h)</div>
|
||||
<div class="stat-label">Received</div>
|
||||
</div>
|
||||
|
||||
<!-- 24h Deletes -->
|
||||
<!-- Deletes (Purge Window) -->
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="deletes24h">{{ stats.last24Hours.deletes }}</div>
|
||||
<div class="stat-label">Deleted (24h)</div>
|
||||
<div class="stat-label">Deleted</div>
|
||||
</div>
|
||||
|
||||
<!-- 24h Forwards -->
|
||||
<!-- Forwards (Purge Window) -->
|
||||
<div class="stat-card">
|
||||
<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>
|
||||
|
||||
<!-- Chart -->
|
||||
<div class="chart-container">
|
||||
<h2>Activity Timeline (24 Hours)</h2>
|
||||
<h2>Email Activity Timeline</h2>
|
||||
<canvas id="statsChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -78,5 +78,7 @@
|
|||
<script>
|
||||
// Set initial data for stats.js to consume
|
||||
window.initialStatsData = {{ stats.last24Hours.timeline|json_encode|raw }};
|
||||
window.historicalData = {{ stats.historical|json_encode|raw }};
|
||||
window.predictionData = {{ stats.prediction|json_encode|raw }};
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ Twig.extendFilter('readablePurgeTime', readablePurgeTime)
|
|||
// Middleware to expose user session to all templates
|
||||
app.use((req, res, next) => {
|
||||
res.locals.authEnabled = config.user.authEnabled
|
||||
res.locals.config = config
|
||||
res.locals.currentUser = null
|
||||
if (req.session && req.session.userId && req.session.username && req.session.isAuthenticated) {
|
||||
res.locals.currentUser = {
|
||||
|
|
@ -105,6 +106,21 @@ app.use((req, res, 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
|
||||
app.use((req, res, next) => {
|
||||
const isImapReady = req.app.get('isImapReady')
|
||||
|
|
|
|||
24
package.json
24
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "48hr.email",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"private": false,
|
||||
"description": "48hr.email is your favorite open-source tempmail client.",
|
||||
"keywords": [
|
||||
|
|
@ -68,17 +68,15 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": "public/javascripts/*.js",
|
||||
"esnext": false,
|
||||
"env": [
|
||||
"browser"
|
||||
],
|
||||
"globals": [
|
||||
"io"
|
||||
]
|
||||
}
|
||||
]
|
||||
"overrides": [{
|
||||
"files": "public/javascripts/*.js",
|
||||
"esnext": false,
|
||||
"env": [
|
||||
"browser"
|
||||
],
|
||||
"globals": [
|
||||
"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_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
|
||||
CREATE TRIGGER IF NOT EXISTS check_locked_inbox_limit
|
||||
BEFORE INSERT ON user_locked_inboxes
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue