Compare commits

..

9 commits

Author SHA1 Message Date
ClaraCrazy
fa0dc27cba
[Chore] Update stats page embed 2026-01-03 19:53:03 +01:00
ClaraCrazy
3c7482b23a
[Fix]: Imap -.- 2026-01-03 19:46:38 +01:00
ClaraCrazy
c3fea6a70b
[AI][Feat]: Stats V2
- Historical storage
- Prediction
- Black magic
2026-01-03 19:42:49 +01:00
ClaraCrazy
a078abae00
[Chore]: Simplify imap query 2026-01-03 18:37:07 +01:00
ClaraCrazy
c11a82f42b
[Chore]: Text fix 2026-01-03 17:04:58 +01:00
ClaraCrazy
c56ec92ce5
[Chore]: Misc. V2 patches 2026-01-03 17:00:39 +01:00
ClaraCrazy
2f58eacfa7
[Feat]: V2
Updated:
- Update UI,
- Update routes
- Update filters
New:
- Add Password change route
- Add Account deletion button
2026-01-03 16:51:00 +01:00
ClaraCrazy
3fdf5bf61b
[Chore]: Stats patches 2026-01-03 15:57:46 +01:00
ClaraCrazy
e012b772c8
[Feat]: Add Stats page 2026-01-03 15:41:56 +01:00
35 changed files with 2449 additions and 209 deletions

View file

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

BIN
.github/assets/stats.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

View file

@ -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>
@ -42,9 +43,9 @@ All data is being removed 48hrs after they have reached the mail server.
## Screenshots
| Homepage | Account Panel |
|:---:|:---:|
| <img src=".github/assets/home.png" width="500px" height="300px" style="object-fit: fit;"> | <img src=".github/assets/account.png" width="500px" height="300px" style="object-fit: fit;"> |
| Homepage | Account Panel | Stats Page |
|:---:|:---:|:---:|
| <img src=".github/assets/home.png" width="500px" height="300px" style="object-fit: fit;"> | <img src=".github/assets/account.png" width="500px" height="300px" style="object-fit: fit;"> | <img src=".github/assets/stats.png" width="500px" height="300px" style="object-fit: fit;"> |
| Inbox | Email using HTML and CSS | Attachments and Cryptographic Keys view |
|:---:|:---:|:---:|

29
app.js
View file

@ -5,7 +5,7 @@
const config = require('./application/config')
const debug = require('debug')('48hr-email:app')
const Helper = require('./application/helper')
const helper = new(Helper)
const { app, io, server } = require('./infrastructure/web/web')
const ClientNotification = require('./infrastructure/web/client-notification')
const ImapService = require('./application/imap-service')
@ -16,6 +16,7 @@ const MailRepository = require('./domain/mail-repository')
const InboxLock = require('./domain/inbox-lock')
const VerificationStore = require('./domain/verification-store')
const UserRepository = require('./domain/user-repository')
const StatisticsStore = require('./domain/statistics-store')
const clientNotification = new ClientNotification()
debug('Client notification service initialized')
@ -34,6 +35,8 @@ 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)
@ -42,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)
@ -69,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)
@ -84,10 +97,22 @@ const mailProcessingService = new MailProcessingService(
clientNotification,
config,
smtpService,
verificationStore
verificationStore,
statisticsStore
)
debug('Mail processing service initialized')
// Initialize statistics with current count
imapService.on(ImapService.EVENT_INITIAL_LOAD_DONE, async() => {
const count = mailProcessingService.getCount()
statisticsStore.initialize(count)
// Get and set the largest UID for all-time total
const largestUid = await helper.getLargestUid(imapService)
statisticsStore.updateLargestUid(largestUid)
debug(`Statistics initialized with ${count} emails, largest UID: ${largestUid}`)
})
// Set up timer sync broadcasting after IMAP is ready
imapService.on(ImapService.EVENT_INITIAL_LOAD_DONE, () => {
clientNotification.startTimerSync(imapService)

View file

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

View file

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

View file

@ -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
@ -172,14 +191,8 @@ class Helper {
}
async getLargestUid(imapService) {
return await imapService.getLargestUid();
}
countElementBuilder(count = 0, largestUid = 0) {
const handling = `<label title="Historically managed ${largestUid} email${largestUid === 1 ? '' : 's'}">
<h4 style="display: inline;"><u><i>${count}</i></u> mail${count === 1 ? '' : 's'}</h4>
</label>`
return handling
const uid = await imapService.getLargestUid();
return uid || 0;
}
/**
@ -256,7 +269,7 @@ class Helper {
// Warn about old locked-inboxes.db
if (fs.existsSync(legacyLockedInboxesDb)) {
console.log(`⚠️ Found legacy ${legacyLockedInboxesDb}`)
console.log(`WARNING: Found legacy ${legacyLockedInboxesDb}`)
console.log(` This database is no longer used. Locks are now stored in ${path.basename(dbPath)}.`)
console.log(` You can safely delete ${legacyLockedInboxesDb} after verifying your locks are working.`)
debug('Legacy locked-inboxes.db detected but not migrated (data already in user_locked_inboxes table)')

View file

@ -101,8 +101,17 @@ class ImapService extends EventEmitter {
}
async connectAndLoadMessages() {
const configWithListener = {
...this.config,
// Map config.imap.secure to config.imap.tls for imap-simple library compatibility
const imapConfig = {
imap: {
user: this.config.imap.user,
password: this.config.imap.password,
host: this.config.imap.host,
port: this.config.imap.port,
tls: this.config.imap.secure,
authTimeout: this.config.imap.authTimeout,
tlsOptions: { rejectUnauthorized: false }
},
// 'onmail' adds a callback when new mails arrive. With this we can keep the imap refresh interval very low (or even disable it).
onmail: () => this._doOnNewMail()
}
@ -111,7 +120,7 @@ class ImapService extends EventEmitter {
this._doAfterInitialLoad()
)
await this._connectWithRetry(configWithListener)
await this._connectWithRetry(imapConfig)
// Load all messages in the background. (ASYNC)
this._loadMailSummariesAndEmitAsEvents()
@ -242,13 +251,9 @@ class ImapService extends EventEmitter {
async deleteOldMails(deleteMailsBefore) {
let uids;
// Only do heavy IMAP date filtering if the cutoff is older than 1 day
const useDateFilter = helper.moreThanOneDay(new Date(), deleteMailsBefore);
const searchQuery = useDateFilter ? [
['!DELETED'],
['BEFORE', deleteMailsBefore]
] : [
// IMAP date filters are unreliable - some servers search internal date, not Date header
// Always fetch all UIDs and filter by date header in JavaScript instead
const searchQuery = [
['!DELETED']
];
@ -484,4 +489,4 @@ ImapService.EVENT_DELETED_MAIL = 'mailDeleted'
ImapService.EVENT_INITIAL_LOAD_DONE = 'initial load done'
ImapService.EVENT_ERROR = 'error'
module.exports = ImapService
module.exports = ImapService

View file

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

View file

@ -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}`)
@ -273,7 +273,7 @@ ${mail.html}
<p><code>${verificationLink}</code></p>
<div class="warning">
<strong>Important:</strong> This verification link expires in <strong>15 minutes</strong>. Once verified, you'll be able to forward emails to this address for 24 hours.
<strong>Important:</strong> This verification link expires in <strong>15 minutes</strong>. Once verified, you'll be able to forward emails to this address for 24 hours.
</div>
<p>If you didn't request this verification, you can safely ignore this email.</p>

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

@ -0,0 +1,511 @@
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(db = null) {
this.db = db
// Current totals
this.currentCount = 0
this.largestUid = 0
// 24-hour rolling data (one entry per minute = 1440 entries)
this.hourlyData = []
this.maxDataPoints = 24 * 60 // 24 hours * 60 minutes
// Track last cleanup to avoid too frequent operations
this.lastCleanup = Date.now()
// 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
*/
initialize(count) {
this.currentCount = count
debug(`Initialized with ${count} emails`)
}
/**
* Update largest UID (all-time total emails processed)
* @param {number} uid - Largest UID from mailbox (0 if no emails)
*/
updateLargestUid(uid) {
if (uid >= 0 && uid > this.largestUid) {
this.largestUid = uid
this._saveToDatabase()
debug(`Largest UID updated to ${uid}`)
}
}
/**
* Record an email received event
*/
recordReceive() {
this.currentCount++
this._addDataPoint('receive')
debug(`Email received. Current: ${this.currentCount}`)
}
/**
* Record an email deleted event
*/
recordDelete() {
this.currentCount = Math.max(0, this.currentCount - 1)
this._addDataPoint('delete')
debug(`Email deleted. Current: ${this.currentCount}`)
}
/**
* Record an email forwarded event
*/
recordForward() {
this._addDataPoint('forward')
debug(`Email forwarded`)
}
/**
* Update current count (for bulk operations like purge)
* @param {number} count - New current count
*/
updateCurrentCount(count) {
const diff = count - this.currentCount
if (diff < 0) {
// Bulk delete occurred
for (let i = 0; i < Math.abs(diff); i++) {
this._addDataPoint('delete')
}
}
this.currentCount = count
debug(`Current count updated to ${count}`)
}
/**
* Get current statistics
* @returns {Object} Current stats
*/
getStats() {
this._cleanup()
const last24h = this._getLast24Hours()
return {
currentCount: this.currentCount,
allTimeTotal: this.largestUid,
last24Hours: {
receives: last24h.receives,
deletes: last24h.deletes,
forwards: last24h.forwards,
timeline: this._getTimeline()
}
}
}
/**
* 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)
* @private
*/
_addDataPoint(type) {
const now = Date.now()
const minute = Math.floor(now / 60000) * 60000 // Round to minute
// Find or create entry for this minute
let entry = this.hourlyData.find(e => e.timestamp === minute)
if (!entry) {
entry = {
timestamp: minute,
receives: 0,
deletes: 0,
forwards: 0
}
this.hourlyData.push(entry)
}
entry[type + 's']++
this._cleanup()
// 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 email purge time)
* @private
*/
_cleanup() {
const now = Date.now()
// Only cleanup every 5 minutes to avoid constant filtering
if (now - this.lastCleanup < 5 * 60 * 1000) {
return
}
const cutoff = now - this._getPurgeCutoffMs()
const beforeCount = this.hourlyData.length
this.hourlyData = this.hourlyData.filter(entry => entry.timestamp >= cutoff)
if (beforeCount !== this.hourlyData.length) {
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 the purge time window
* @returns {Object} Aggregated counts
* @private
*/
_getLast24Hours() {
const cutoff = Date.now() - this._getPurgeCutoffMs()
const recent = this.hourlyData.filter(e => e.timestamp >= cutoff)
return {
receives: recent.reduce((sum, e) => sum + e.receives, 0),
deletes: recent.reduce((sum, e) => sum + e.deletes, 0),
forwards: recent.reduce((sum, e) => sum + e.forwards, 0)
}
}
/**
* Get timeline data for graphing (hourly aggregates)
* Uses purge time for consistent timeline length
* @returns {Array} Array of hourly data points
* @private
*/
_getTimeline() {
const now = Date.now()
const cutoff = now - this._getPurgeCutoffMs()
const hourly = {}
// Aggregate by hour
this.hourlyData
.filter(e => e.timestamp >= cutoff)
.forEach(entry => {
const hour = Math.floor(entry.timestamp / 3600000) * 3600000
if (!hourly[hour]) {
hourly[hour] = { timestamp: hour, receives: 0, deletes: 0, forwards: 0 }
}
hourly[hour].receives += entry.receives
hourly[hour].deletes += entry.deletes
hourly[hour].forwards += entry.forwards
})
// Convert to sorted array
return Object.values(hourly).sort((a, b) => a.timestamp - b.timestamp)
}
}
module.exports = StatisticsStore

View file

@ -354,6 +354,100 @@ class UserRepository {
return `${Math.floor(days / 365)} years`
}
/**
* Verify user password
* @param {number} userId - User ID
* @param {string} password - Plain text password to verify
* @returns {Promise<boolean>} - True if password matches
*/
async verifyPassword(userId, password) {
try {
const bcrypt = require('bcrypt')
const stmt = this.db.prepare('SELECT password_hash FROM users WHERE id = ?')
const user = stmt.get(userId)
if (!user) {
debug(`User not found for password verification: ${userId}`)
return false
}
const isValid = await bcrypt.compare(password, user.password_hash)
debug(`Password verification for user ${userId}: ${isValid ? 'success' : 'failed'}`)
return isValid
} catch (error) {
debug(`Error verifying password: ${error.message}`)
return false
}
}
/**
* Update user password
* @param {number} userId - User ID
* @param {string} newPassword - New plain text password
* @returns {Promise<boolean>} - True if successful
*/
async updatePassword(userId, newPassword) {
try {
const bcrypt = require('bcrypt')
const saltRounds = 10
const passwordHash = await bcrypt.hash(newPassword, saltRounds)
const stmt = this.db.prepare(`
UPDATE users
SET password_hash = ?
WHERE id = ?
`)
const result = stmt.run(passwordHash, userId)
if (result.changes > 0) {
debug(`Password updated for user ${userId}`)
return true
} else {
debug(`User not found for password update: ${userId}`)
return false
}
} catch (error) {
debug(`Error updating password: ${error.message}`)
throw error
}
}
/**
* Delete user account and all associated data
* @param {number} userId - User ID
* @returns {boolean} - True if successful
*/
deleteUser(userId) {
try {
// Delete in order due to foreign key constraints:
// 1. forward_emails (references users.id)
// 2. users
const deleteForwardEmails = this.db.prepare('DELETE FROM forward_emails WHERE user_id = ?')
const deleteUser = this.db.prepare('DELETE FROM users WHERE id = ?')
// Use transaction for atomicity
const deleteTransaction = this.db.transaction((uid) => {
deleteForwardEmails.run(uid)
const result = deleteUser.run(uid)
return result.changes > 0
})
const success = deleteTransaction(userId)
if (success) {
debug(`User ${userId} and all associated data deleted`)
} else {
debug(`User ${userId} not found for deletion`)
}
return success
} catch (error) {
debug(`Error deleting user: ${error.message}`)
throw error
}
}
/**
* Close database connection
*/

View file

@ -83,8 +83,15 @@ class ClientNotification extends EventEmitter {
this.pendingNotifications.set(address, prev + 1);
debug(`No listeners for ${address}, queued notification (${prev + 1} pending)`);
}
// Also emit a global stats-update event for stats page
if (this.io) {
this.io.emit('stats-update');
debug('Emitted stats-update to all connected clients');
}
return hadListeners;
}
}
module.exports = ClientNotification
module.exports = ClientNotification

View file

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

View file

@ -0,0 +1,237 @@
/**
* Statistics page functionality
* Handles Chart.js initialization with historical, real-time, and predicted data
*/
// Initialize stats chart if on stats page
document.addEventListener('DOMContentLoaded', function() {
const chartCanvas = document.getElementById('statsChart');
if (!chartCanvas) return; // Not on stats page
// Get data from global variables (set by template)
if (typeof window.initialStatsData === 'undefined') {
console.error('Initial stats data not found');
return;
}
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();
socket.on('stats-update', () => {
console.log('Stats update received (page will not auto-reload)');
// Don't auto-reload - user can manually refresh if needed
});
socket.on('reconnect', () => {
console.log('Reconnected to server');
});
}
// 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.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: 'Historical',
data: historicalPoints,
borderColor: 'rgba(100, 149, 237, 0.8)',
backgroundColor: historicalGradient,
borderWidth: 2,
tension: 0.4,
pointRadius: 4,
pointBackgroundColor: 'rgba(100, 149, 237, 0.8)',
spanGaps: false,
fill: true,
hidden: false
},
{
label: 'Current Activity',
data: realtimePoints,
borderColor: '#2ecc71',
backgroundColor: 'rgba(46, 204, 113, 0.15)',
borderWidth: 4,
tension: 0.4,
pointRadius: 4,
pointBackgroundColor: '#2ecc71',
spanGaps: false,
fill: true,
hidden: false
},
{
label: 'Predicted',
data: predictionPoints,
borderColor: '#ff9f43',
backgroundColor: 'rgba(255, 159, 67, 0.08)',
borderWidth: 3,
borderDash: [8, 4],
tension: 0.4,
pointRadius: 4,
pointBackgroundColor: '#ff9f43',
spanGaps: false,
fill: true,
hidden: false
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
display: false // Disable default legend, we'll create custom
},
tooltip: {
mode: 'index',
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: {
y: {
beginAtZero: true,
ticks: {
color: getComputedStyle(document.documentElement).getPropertyValue('--color-text-dim'),
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,
maxTicksLimit: 20
},
grid: {
color: 'rgba(255, 255, 255, 0.05)'
}
}
}
}
});
// 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');
});
});
});

View file

@ -163,6 +163,18 @@ a:hover {
h1 {
font-size: 3rem;
color: var(--color-text-light);
font-weight: 700;
margin-bottom: 1rem;
text-align: center;
}
h1.page-title {
background: linear-gradient(135deg, var(--color-accent-purple-light), var(--color-accent-purple-alt));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
}
h3 {
@ -416,7 +428,8 @@ text-muted {
/* Auth pages */
.auth-container {
max-width: 900px;
min-width: 75%;
max-width: 1500px;
margin: 2rem auto;
padding: 0 1rem;
display: grid;
@ -456,9 +469,10 @@ text-muted {
}
.auth-card small {
margin-top: -10px !important;
font-size: 1.2rem;
display: block;
color: var(--color-text-gray);
font-size: 0.85rem;
margin-top: 0.25rem;
margin-bottom: 0.5rem;
}
@ -549,7 +563,8 @@ text-muted {
/* Unified auth page (side-by-side login/register) */
.auth-unified-container {
max-width: 1100px;
min-width: 75%;
max-width: 1500px;
margin: 2rem auto;
padding: 0 1rem;
}
@ -569,6 +584,7 @@ text-muted {
grid-template-columns: 1fr 1fr;
gap: 3rem;
margin-bottom: 3rem;
align-items: stretch;
}
@media (max-width: 768px) {
@ -578,6 +594,27 @@ text-muted {
}
}
.auth-card {
display: flex;
flex-direction: column;
}
.auth-card form {
display: flex;
flex-direction: column;
flex: 1;
}
.auth-card fieldset {
display: flex;
flex-direction: column;
flex: 1;
}
.auth-card .button {
margin-top: auto;
}
.auth-card h2 {
font-size: 2rem;
margin-bottom: 0.5rem;
@ -729,7 +766,8 @@ text-muted {
/* Account dashboard page */
.account-container {
max-width: 1200px;
min-width: 75%;
max-width: 1500px;
margin: 0 auto;
padding: 2rem 1rem;
}
@ -751,12 +789,14 @@ text-muted {
border: 1px solid var(--color-border-dark);
border-radius: 8px;
padding: 2rem;
display: flex;
flex-direction: column;
}
.account-card h2 {
color: var(--color-accent-purple);
margin-bottom: 0.5rem;
font-size: 1.5rem;
font-size: 2.5rem;
}
.card-description {
@ -765,7 +805,7 @@ text-muted {
margin-bottom: 1.5rem;
}
.account-stats {
.account-card:nth-child(1) {
grid-column: 1 / -1;
}
@ -898,6 +938,87 @@ form {
margin-top: 1rem;
}
.password-form {
display: flex;
flex-direction: column;
flex: 1;
}
.password-form fieldset {
display: flex;
flex-direction: column;
gap: 1rem;
flex: 1;
}
.password-form label {
font-weight: 600;
margin-bottom: 0.25rem;
color: var(--color-text-light);
}
.password-form input {
padding: 0.75rem;
border: 1px solid var(--color-border-dark);
border-radius: 5px;
background: var(--color-bg-dark);
color: var(--color-text-primary);
}
.password-form small {
color: var(--color-text-gray);
font-size: 0.85rem;
margin-top: -0.5rem;
}
.password-form .button {
margin-top: auto;
}
.danger-zone {
border: 2px solid var(--color-danger);
}
.danger-zone h2 {
color: var(--color-danger);
}
.danger-content {
margin-top: 1.5rem;
display: flex;
flex-direction: column;
flex: 1;
}
.danger-list {
list-style: none;
padding: 0;
margin: 1rem 0 1.5rem 0;
}
.danger-list li {
padding: 0.5rem 0;
padding-left: 1.5rem;
position: relative;
color: var(--color-text-primary);
}
.danger-list li::before {
content: '•';
position: absolute;
left: 0;
color: var(--color-danger);
font-weight: bold;
}
.button-full-width {
width: 100%;
}
.danger-content .button {
margin-top: auto;
}
@media (max-width: 768px) {
.account-grid {
grid-template-columns: 1fr;
@ -978,6 +1099,390 @@ select:hover {
background-image: none;
}
/* Frosted Glass Utility Class */
.frosted-glass {
background: linear-gradient(135deg, var(--overlay-white-08), var(--overlay-white-04));
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--overlay-white-15);
border-radius: 24px;
box-shadow: 0 8px 32px var(--overlay-black-40), inset 0 1px 0 var(--overlay-white-10);
}
body.light-mode .frosted-glass {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.5));
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
/* Homepage Styles */
.homepage-container {
min-width: 75%;
max-width: 1500px;
margin: 0 auto;
padding: 2rem;
}
.hero-section {
text-align: center;
margin-bottom: 4rem;
padding: 4rem 2rem 2rem;
}
.hero-title {
font-size: 4.5rem;
margin-bottom: 1.5rem;
line-height: 1.2;
}
.hero-subtitle {
font-size: 1.8rem;
color: var(--color-text-dim);
max-width: 700px;
margin: 0 auto;
line-height: 1.6;
}
.inbox-creator {
max-width: 600px;
margin: 0 auto 6rem;
padding: 3rem;
}
.creator-title {
text-align: center;
font-size: 2.4rem;
margin-bottom: 2.5rem;
color: var(--color-text-light);
}
.inbox-form {
display: flex;
flex-direction: column;
gap: 2rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.form-group label {
font-size: 1.4rem;
font-weight: 600;
color: var(--color-text-light);
padding-left: 0.4rem;
}
.form-group input[type="text"] {
padding: 1.4rem 1.6rem;
font-size: 1.6rem;
border: 1px solid var(--overlay-purple-30);
border-radius: 12px;
background: var(--overlay-white-04);
color: var(--color-text-primary);
transition: all 0.3s ease;
}
.form-group input[type="text"]:focus {
outline: none;
border-color: var(--color-accent-purple-light);
background: var(--overlay-white-06);
box-shadow: 0 0 0 3px var(--overlay-purple-15);
}
.select-wrapper {
position: relative;
}
.select-wrapper::after {
content: '▾';
position: absolute;
right: 1.6rem;
top: 35%;
transform: translateY(-50%);
pointer-events: none;
color: var(--color-text-dim);
font-size: 1.6rem;
}
.form-group select {
padding-left: 1.6rem;
font-size: 1.6rem;
border: 1px solid var(--overlay-purple-30);
border-radius: 12px;
background: var(--overlay-white-04);
color: var(--color-text-primary);
appearance: none;
cursor: pointer;
transition: all 0.3s ease;
width: 100%;
}
.form-group select:hover {
background: var(--overlay-white-06);
border-color: var(--overlay-purple-40);
}
.form-group select:focus {
outline: none;
border-color: var(--color-accent-purple-light);
box-shadow: 0 0 0 3px var(--overlay-purple-15);
}
.form-group select option {
background: var(--color-bg-dark);
color: var(--color-text-primary);
}
.domain-count {
margin-top: -10px;
font-size: 1.2rem;
color: var(--color-text-dimmer);
padding-left: 0.4rem;
}
.form-actions {
display: flex;
gap: 1.2rem;
margin-top: 1rem;
align-items: stretch;
}
.btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.8rem;
padding: 1.4rem 2.4rem;
font-size: 1.6rem;
font-weight: 600;
border-radius: 12px;
border: none;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
flex: 1;
height: 5.2rem;
box-sizing: border-box;
}
.btn svg {
transition: transform 0.3s ease;
}
.btn-primary {
background: linear-gradient(135deg, var(--color-accent-purple), var(--color-accent-purple-bright));
color: white;
box-shadow: 0 4px 16px var(--overlay-purple-40);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px var(--overlay-purple-50);
}
.btn-primary:hover svg {
transform: translateX(4px);
}
.btn-secondary {
background: var(--overlay-white-08);
color: var(--color-text-light);
border: 1px solid var(--overlay-purple-25);
}
.btn-secondary:hover {
background: var(--overlay-white-12);
border-color: var(--overlay-purple-35);
transform: translateY(-2px);
}
.btn-secondary:hover svg {
transform: rotate(15deg);
}
.alert {
display: flex;
align-items: center;
gap: 1.2rem;
padding: 1.4rem 1.8rem;
border-radius: 12px;
margin-bottom: 2rem;
}
.alert-success {
background: rgba(46, 204, 113, 0.1);
border: 1px solid #2ecc71;
color: #2ecc71;
}
.alert-error {
background: rgba(176, 0, 0, 0.1);
border: 1px solid var(--color-error);
color: var(--color-error);
}
.alert-warning {
background: var(--overlay-warning-10);
border: 1px solid var(--color-warning);
color: var(--color-warning);
}
.alert span {
font-size: 2rem;
}
.alert p {
margin: 0;
font-size: 1.4rem;
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 2rem;
margin-bottom: 4rem;
}
.feature-card {
padding: 3rem 2.5rem;
text-align: center;
transition: all 0.3s ease;
}
.feature-card:hover {
transform: translateY(-8px);
box-shadow: 0 12px 40px var(--overlay-black-45), inset 0 1px 0 var(--overlay-white-15);
}
.feature-card h3 {
font-size: 2rem;
margin-bottom: 1rem;
color: var(--color-text-light);
}
.feature-card p {
font-size: 1.4rem;
color: var(--color-text-dim);
line-height: 1.6;
margin: 0;
}
/* Info Section */
.info-section {
margin-top: 5rem;
margin-bottom: 4rem;
}
.info-content {
padding: 4rem 3rem;
min-width: 75%;
max-width: 1500px;
margin: 0 auto;
}
.info-content h2 {
font-size: 3rem;
margin-bottom: 2rem;
color: var(--color-text-light);
text-align: center;
}
.info-content h3 {
font-size: 2.2rem;
margin-top: 3rem;
margin-bottom: 1.5rem;
color: var(--color-accent-purple-light);
}
.info-content p {
font-size: 1.6rem;
line-height: 1.8;
color: var(--color-text-primary);
margin-bottom: 2rem;
}
.info-content ul {
list-style: none;
padding: 0;
margin-bottom: 2rem;
}
.info-content ul li {
font-size: 1.5rem;
line-height: 1.8;
color: var(--color-text-primary);
margin-bottom: 1.5rem;
padding-left: 2rem;
position: relative;
}
.info-content ul li::before {
content: '•';
position: absolute;
left: 0.5rem;
color: var(--color-accent-purple);
font-size: 2rem;
line-height: 1.5;
}
.info-content ul li strong {
color: var(--color-text-light);
}
.info-content .note {
background: var(--overlay-purple-10);
border-left: 4px solid var(--color-accent-purple);
padding: 1.5rem 2rem;
margin-top: 3rem;
font-size: 1.5rem;
line-height: 1.7;
border-radius: 4px;
}
/* Responsive Design */
@media (max-width: 768px) {
.hero-title {
font-size: 3.2rem;
}
.hero-subtitle {
font-size: 1.6rem;
}
.inbox-creator {
padding: 2rem;
}
.form-actions {
flex-direction: column;
}
.features-grid {
grid-template-columns: 1fr;
}
.info-content {
padding: 3rem 2rem;
}
.info-content h2 {
font-size: 2.4rem;
}
.info-content h3 {
font-size: 1.8rem;
}
}
/* Legacy Login Styles (kept for compatibility) */
#login {
padding-top: 15vh;
display: flex;
@ -1584,6 +2089,8 @@ label {
width: 90%;
max-width: 400px;
box-shadow: 0 4px 6px var(--overlay-black-30);
display: flex;
flex-direction: column;
}
.modal-content h3 {
@ -1634,6 +2141,23 @@ label {
color: var(--color-text-primary);
}
.modal-content form {
display: flex;
flex-direction: column;
flex: 1;
}
.modal-content fieldset {
flex: 1;
display: flex;
flex-direction: column;
}
.modal-content .button,
.modal-content .modal-button {
margin-top: auto;
}
.modal-input {
border-radius: 0.4rem;
color: var(--color-text-primary);
@ -1954,6 +2478,164 @@ body.light-mode .theme-icon-light {
}
/* Statistics Page */
.stats-container {
min-width: 75%;
max-width: 1500px;
margin: 0 auto;
padding: 2rem;
}
.stats-subtitle {
color: var(--color-text-dim);
text-align: center;
margin-top: -1rem;
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));
gap: 2rem;
margin-bottom: 3rem;
}
.stat-card {
background: var(--overlay-white-05);
border: 1px solid var(--overlay-purple-30);
border-radius: 15px;
padding: 2rem;
text-align: center;
transition: all 0.3s ease;
}
.stat-card:hover {
background: var(--overlay-white-08);
border-color: var(--overlay-purple-40);
transform: translateY(-2px);
}
.stat-value {
font-size: 2.5rem;
font-weight: 700;
color: var(--color-accent-purple-light);
margin-bottom: 0.5rem;
}
.stat-label {
font-size: 1rem;
color: var(--color-text-dim);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.chart-container {
background: var(--overlay-white-05);
border: 1px solid var(--overlay-purple-30);
border-radius: 15px;
padding: 2rem;
height: 500px;
position: relative;
}
.chart-container h2 {
margin-top: 0;
margin-bottom: 2rem;
color: var(--color-text-light);
text-align: center;
}
.chart-container canvas {
max-height: 400px;
}
/* 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) {
@ -1963,12 +2645,57 @@ body.light-mode .theme-icon-light {
.hamburger-menu {
display: flex;
}
/* Hide dropdowns on mobile, show links directly */
.action-dropdown {
display: contents;
}
.action-dropdown .dropdown-toggle {
display: none;
}
.action-dropdown .dropdown-menu {
display: block;
position: static;
border: none;
border-radius: 0;
box-shadow: none;
padding: 0;
min-width: auto;
background: transparent;
}
/* Add section headers before dropdown menus on mobile */
.action-dropdown .dropdown-menu::before {
content: attr(data-section-title);
display: block;
font-size: 1.1rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--color-text-dim);
padding: 12px 0 8px 0;
margin-top: 8px;
border-top: 1px solid var(--overlay-white-10);
}
.action-dropdown:first-of-type .dropdown-menu::before {
margin-top: 0;
border-top: none;
}
.action-dropdown .dropdown-menu a {
padding: 10px 0;
text-align: left;
border: none !important;
}
.action-dropdown .dropdown-menu a:hover {
background: transparent;
color: var(--color-accent-purple-light);
padding-left: 8px;
}
.action-links.mobile-open .theme-toggle {
justify-content: center;
width: 100%;
}
.action-links.mobile-hidden>a,
.action-links.mobile-hidden>button:not(.hamburger-menu) {
.action-links.mobile-hidden>button:not(.hamburger-menu),
.action-links.mobile-hidden>.action-dropdown {
display: none;
}
.action-links.mobile-open {
@ -1982,7 +2709,7 @@ body.light-mode .theme-icon-light {
padding: 15px;
box-shadow: 0 10px 40px var(--overlay-black-40);
z-index: 1000;
min-width: 200px;
min-width: 220px;
}
.action-links.mobile-open>.hamburger-menu {
display: none;
@ -1991,7 +2718,24 @@ body.light-mode .theme-icon-light {
.action-links.mobile-open>button:not(.hamburger-menu) {
display: block;
width: 100%;
text-align: center;
text-align: left;
padding: 10px 0;
border: none;
border-radius: 0;
height: auto;
background: transparent;
font-size: 1.4rem;
font-weight: 500;
text-transform: none;
letter-spacing: normal;
}
.action-links.mobile-open>a:hover,
.action-links.mobile-open>button:not(.hamburger-menu):hover {
background: transparent;
color: var(--color-accent-purple-light);
transform: none;
box-shadow: none;
padding-left: 8px;
}
.qr-icon-btn {
display: none;

View file

@ -26,11 +26,7 @@ router.get('/account', requireAuth, async(req, res) => {
const config = req.app.get('config')
const stats = userRepository.getUserStats(req.session.userId, config.user)
// Get mail count for footer
const count = await mailProcessingService.getCount()
const imapService = req.app.locals.imapService
const largestUid = await imapService.getLargestUid()
const totalcount = helper.countElementBuilder(count, largestUid)
// Get purge time for footer
const purgeTime = helper.purgeTimeElemetBuilder()
res.render('account', {
@ -41,7 +37,6 @@ router.get('/account', requireAuth, async(req, res) => {
stats,
branding: config.http.branding,
purgeTime: purgeTime,
totalcount: totalcount,
successMessage: req.session.accountSuccess,
errorMessage: req.session.accountError
})
@ -98,8 +93,8 @@ router.post('/account/forward-email/add',
})
// Send verification email
const baseUrl = config.http.baseUrl || 'http://localhost:3000'
const branding = config.http.branding[0] || '48hr.email'
const baseUrl = config.http.baseUrl
const branding = config.http.branding[0]
await smtpService.sendVerificationEmail(
email,
@ -225,4 +220,108 @@ router.post('/account/locked-inbox/release',
}
)
// POST /account/change-password - Change user password
router.post('/account/change-password',
requireAuth,
body('currentPassword').notEmpty().withMessage('Current password is required'),
body('newPassword').isLength({ min: 8 }).withMessage('New password must be at least 8 characters'),
body('confirmNewPassword').notEmpty().withMessage('Password confirmation is required'),
async(req, res) => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
req.session.accountError = errors.array()[0].msg
return res.redirect('/account')
}
const { currentPassword, newPassword, confirmNewPassword } = req.body
// Check if new passwords match
if (newPassword !== confirmNewPassword) {
req.session.accountError = 'New passwords do not match'
return res.redirect('/account')
}
// Validate new password strength
const hasUpperCase = /[A-Z]/.test(newPassword)
const hasLowerCase = /[a-z]/.test(newPassword)
const hasNumber = /[0-9]/.test(newPassword)
if (!hasUpperCase || !hasLowerCase || !hasNumber) {
req.session.accountError = 'Password must include uppercase, lowercase, and number'
return res.redirect('/account')
}
const userRepository = req.app.get('userRepository')
// Verify current password
const isValidPassword = await userRepository.verifyPassword(req.session.userId, currentPassword)
if (!isValidPassword) {
req.session.accountError = 'Current password is incorrect'
return res.redirect('/account')
}
// Update password
await userRepository.updatePassword(req.session.userId, newPassword)
req.session.accountSuccess = 'Password updated successfully'
res.redirect('/account')
} catch (error) {
console.error('Change password error:', error)
req.session.accountError = 'Failed to change password. Please try again.'
res.redirect('/account')
}
}
)
// POST /account/delete - Permanently delete user account
router.post('/account/delete',
requireAuth,
body('password').notEmpty().withMessage('Password is required'),
body('confirmText').equals('DELETE').withMessage('You must type DELETE to confirm'),
async(req, res) => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
req.session.accountError = errors.array()[0].msg
return res.redirect('/account')
}
const { password } = req.body
const userRepository = req.app.get('userRepository')
// Verify password
const isValidPassword = await userRepository.verifyPassword(req.session.userId, password)
if (!isValidPassword) {
req.session.accountError = 'Incorrect password'
return res.redirect('/account')
}
// Get user's locked inboxes to release them
const inboxLock = req.app.get('inboxLock')
if (inboxLock) {
const lockedInboxes = inboxLock.getUserLockedInboxes(req.session.userId)
for (const inbox of lockedInboxes) {
inboxLock.release(req.session.userId, inbox.address)
}
}
// Delete user account
await userRepository.deleteUser(req.session.userId)
// Destroy session
req.session.destroy((err) => {
if (err) {
console.error('Session destroy error:', err)
}
res.redirect('/?deleted=true')
})
} catch (error) {
console.error('Delete account error:', error)
req.session.accountError = 'Failed to delete account. Please try again.'
res.redirect('/account')
}
}
)
module.exports = router

View file

@ -3,6 +3,11 @@ const router = new express.Router()
const { body, validationResult } = require('express-validator')
const debug = require('debug')('48hr-email:auth-routes')
const { redirectIfAuthenticated } = require('../middleware/auth')
const config = require('../../../application/config')
const Helper = require('../../../application/helper')
const helper = new Helper()
const purgeTime = helper.purgeTimeElemetBuilder()
// Simple in-memory rate limiters for registration and login
const registrationRateLimitStore = new Map()
@ -87,6 +92,7 @@ router.get('/auth', redirectIfAuthenticated, (req, res) => {
res.render('auth', {
title: `Login or Register | ${config.http.branding[0]}`,
branding: config.http.branding,
purgeTime: purgeTime,
errorMessage,
successMessage
})

View file

@ -15,9 +15,6 @@ router.get('/:address/:errorCode', async(req, res, next) => {
throw new Error('Mail processing service not available')
}
debug(`Error page requested: ${req.params.errorCode} for ${req.params.address}`)
const count = await mailProcessingService.getCount()
const largestUid = await req.app.locals.imapService.getLargestUid()
const totalcount = helper.countElementBuilder(count, largestUid)
const errorCode = parseInt(req.params.errorCode) || 404
const message = req.query.message || (req.session && req.session.errorMessage) || 'An error occurred'
@ -27,8 +24,6 @@ router.get('/:address/:errorCode', async(req, res, next) => {
title: `${config.http.branding[0]} | ${errorCode}`,
purgeTime: purgeTime,
address: req.params.address,
count: count,
totalcount: totalcount,
message: message,
status: errorCode,
branding: config.http.branding

View file

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

View file

@ -17,17 +17,12 @@ router.get('/', async(req, res, next) => {
throw new Error('Mail processing service not available')
}
debug('Login page requested')
const count = await mailProcessingService.getCount()
const largestUid = await req.app.locals.imapService.getLargestUid()
const totalcount = helper.countElementBuilder(count, largestUid)
debug(`Rendering login page with ${count} total mails`)
res.render('login', {
title: `${config.http.branding[0]} | Your temporary Inbox`,
username: randomWord(),
purgeTime: purgeTime,
purgeTimeRaw: config.email.purgeTime,
domains: helper.getDomains(),
count: count,
totalcount: totalcount,
branding: config.http.branding,
example: config.email.examples.account,
})
@ -59,16 +54,15 @@ router.post(
throw new Error('Mail processing service not available')
}
const errors = validationResult(req)
const count = await mailProcessingService.getCount()
if (!errors.isEmpty()) {
debug(`Login validation failed for ${req.body.username}@${req.body.domain}: ${errors.array().map(e => e.msg).join(', ')}`)
return res.render('login', {
userInputError: true,
title: `${config.http.branding[0]} | Your temporary Inbox`,
purgeTime: purgeTime,
purgeTimeRaw: config.email.purgeTime,
username: randomWord(),
domains: helper.getDomains(),
count: count,
branding: config.http.branding,
})
}

View file

@ -0,0 +1,77 @@
const express = require('express')
const router = new express.Router()
const debug = require('debug')('48hr-email:stats-routes')
// GET /stats - Statistics page
router.get('/', async(req, res) => {
try {
const config = req.app.get('config')
// 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()
// Update largest UID before getting stats (if IMAP is ready)
if (imapService) {
const largestUid = await helper.getLargestUid(imapService)
statisticsStore.updateLargestUid(largestUid)
}
// 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, ${stats.historical.length} historical points`)
res.render('stats', {
title: `Statistics | ${config.http.branding[0]}`,
branding: config.http.branding,
purgeTime: purgeTime,
stats: stats,
authEnabled: config.user.authEnabled,
currentUser: req.session && req.session.username
})
} catch (error) {
debug(`Error loading stats page: ${error.message}`)
console.error('Error while loading stats page', error)
res.status(500).send('Error loading statistics')
}
})
// GET /stats/api - JSON API for real-time updates
router.get('/api', async(req, res) => {
try {
const statisticsStore = req.app.get('statisticsStore')
const imapService = req.app.get('imapService')
const Helper = require('../../../application/helper')
const helper = new Helper()
// Update largest UID before getting stats (if IMAP is ready)
if (imapService) {
const largestUid = await helper.getLargestUid(imapService)
statisticsStore.updateLargestUid(largestUid)
}
// Use lightweight stats - no historical analysis on API calls
const stats = statisticsStore.getLightweightStats()
res.json(stats)
} catch (error) {
debug(`Error fetching stats API: ${error.message}`)
res.status(500).json({ error: 'Failed to fetch statistics' })
}
})
module.exports = router

View file

@ -16,24 +16,32 @@
{% block body %}
<div id="account" class="account-container">
<h1>Account Dashboard</h1>
<h1 class="page-title">Account Dashboard</h1>
<p class="account-subtitle">Welcome back, <strong>{{ username }}</strong></p>
{% if successMessage %}
<div class="success-message">
{{ successMessage }}
<div class="alert alert-success">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle;margin-right:0.5rem">
<path d="M20 6L9 17l-5-5"></path>
</svg>
<p>{{ successMessage }}</p>
</div>
{% endif %}
{% if errorMessage %}
<div class="unlock-error">
{{ errorMessage }}
<div class="alert alert-error">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle;margin-right:0.5rem">
<circle cx="12" cy="12" r="10"></circle>
<line x1="15" y1="9" x2="9" y2="15"></line>
<line x1="9" y1="9" x2="15" y2="15"></line>
</svg>
<p>{{ errorMessage }}</p>
</div>
{% endif %}
<div class="account-grid">
<!-- Account Stats -->
<div class="account-card account-stats">
<div class="account-card frosted-glass">
<h2>Account Overview</h2>
<div class="stats-grid">
<div class="stat-item">
@ -52,7 +60,7 @@
</div>
<!-- Forwarding Emails Section -->
<div class="account-card">
<div class="account-card frosted-glass">
<h2>Forwarding Emails</h2>
<p class="card-description">Add verified emails to forward messages to. Each email must be verified before use.</p>
@ -85,7 +93,7 @@
</div>
<!-- Locked Inboxes Section -->
<div class="account-card">
<div class="account-card frosted-glass">
<h2>Locked Inboxes</h2>
<p class="card-description">Manage your locked inboxes. These are protected by your account and only accessible when logged in. Locks auto-release after 7 days without login.</p>
@ -116,6 +124,105 @@
<p class="limit-reached">Maximum {{ stats.maxLockedInboxes }} inboxes locked</p>
{% endif %}
</div>
<!-- Change Password Section -->
<div class="account-card frosted-glass">
<h2>Change Password</h2>
<p class="card-description">Update your account password. You'll need to enter your current password to confirm.</p>
<form method="POST" action="/account/change-password" class="password-form">
<fieldset>
<label for="currentPassword">Current Password</label>
<input
type="password"
id="currentPassword"
name="currentPassword"
placeholder="Enter current password"
required
autocomplete="current-password"
>
<label for="newPassword">New Password</label>
<input
type="password"
id="newPassword"
name="newPassword"
placeholder="Min 8 characters"
required
minlength="8"
autocomplete="new-password"
>
<small>Must include uppercase, lowercase, and number</small>
<label for="confirmNewPassword">Confirm New Password</label>
<input
type="password"
id="confirmNewPassword"
name="confirmNewPassword"
placeholder="Re-enter new password"
required
minlength="8"
autocomplete="new-password"
>
<button type="submit" class="button button-primary">Update Password</button>
</fieldset>
</form>
</div>
<!-- Delete Account Section -->
<div class="account-card frosted-glass danger-zone">
<h2>Danger Zone</h2>
<p class="card-description">Permanently delete your account and all associated data. This action cannot be undone.</p>
<div class="danger-content">
<p><strong>Warning:</strong> Deleting your account will:</p>
<ul class="danger-list">
<li>Remove all forwarding email addresses</li>
<li>Release all locked inboxes</li>
<li>Permanently delete your account data</li>
</ul>
<button class="button button-danger button-full-width" id="deleteAccountBtn">Delete Account</button>
</div>
</div>
</div>
</div>
<!-- Delete Account Modal -->
<div id="deleteAccountModal" class="modal">
<div class="modal-content">
<span class="close" id="closeDeleteAccount">&times;</span>
<h3>Delete Account</h3>
<p class="modal-description" style="color: var(--color-danger);">This action is permanent and cannot be undone!</p>
<form method="POST" action="/account/delete">
<fieldset>
<label for="confirmPassword">Enter your password to confirm</label>
<input
type="password"
id="confirmPassword"
name="password"
placeholder="Your password"
required
class="modal-input"
autocomplete="current-password"
>
<label for="confirmText">Type "DELETE" to confirm</label>
<input
type="text"
id="confirmText"
name="confirmText"
placeholder="Type DELETE"
required
class="modal-input"
>
<button type="submit" class="button button-danger modal-button">Permanently Delete Account</button>
<button type="button" class="button button-secondary modal-button" id="cancelDelete">Cancel</button>
</fieldset>
</form>
</div>
</div>
@ -161,10 +268,37 @@ if (closeAddEmail) {
}
}
// Delete Account Modal
const deleteAccountBtn = document.getElementById('deleteAccountBtn');
const deleteAccountModal = document.getElementById('deleteAccountModal');
const closeDeleteAccount = document.getElementById('closeDeleteAccount');
const cancelDelete = document.getElementById('cancelDelete');
if (deleteAccountBtn) {
deleteAccountBtn.onclick = function() {
deleteAccountModal.style.display = 'block';
}
}
if (closeDeleteAccount) {
closeDeleteAccount.onclick = function() {
deleteAccountModal.style.display = 'none';
}
}
if (cancelDelete) {
cancelDelete.onclick = function() {
deleteAccountModal.style.display = 'none';
}
}
window.onclick = function(event) {
if (event.target == addEmailModal) {
addEmailModal.style.display = 'none';
}
if (event.target == deleteAccountModal) {
deleteAccountModal.style.display = 'none';
}
}
</script>
{% endblock %}

View file

@ -17,53 +17,32 @@
{% block body %}
<div id="auth-unified" class="auth-unified-container">
<div class="auth-intro">
<h1>Account Access</h1>
<h1 class="page-title">Account Access</h1>
<p class="auth-subtitle">Login to an existing account or create a new one</p>
{% if errorMessage %}
<div class="unlock-error">{{ errorMessage }}</div>
<div class="alert alert-error">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle;margin-right:0.5rem">
<circle cx="12" cy="12" r="10"></circle>
<line x1="15" y1="9" x2="9" y2="15"></line>
<line x1="9" y1="9" x2="15" y2="15"></line>
</svg>
{{ errorMessage }}
</div>
{% endif %}
{% if successMessage %}
<div class="success-message">{{ successMessage }}</div>
<div class="alert alert-success">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle;margin-right:0.5rem">
<path d="M20 6L9 17l-5-5"></path>
</svg>
{{ successMessage }}
</div>
{% endif %}
</div>
<div class="auth-forms-grid">
<!-- Login Form -->
<div class="auth-card">
<h2>Login</h2>
<p class="auth-card-subtitle">Access your existing account</p>
<form method="POST" action="/login">
<fieldset>
<label for="login-username">Username</label>
<input
type="text"
id="login-username"
name="username"
placeholder="Your username"
required
autocomplete="username"
>
<label for="login-password">Password</label>
<input
type="password"
id="login-password"
name="password"
placeholder="Your password"
required
autocomplete="current-password"
>
<button class="button button-primary" type="submit">Login</button>
</fieldset>
</form>
</div>
<!-- Register Form -->
<div class="auth-card">
<div class="auth-card frosted-glass">
<h2>Register</h2>
<p class="auth-card-subtitle">Create a new account</p>
<form method="POST" action="/register">
<fieldset>
@ -108,10 +87,41 @@
</fieldset>
</form>
</div>
<!-- Login Form -->
<div class="auth-card frosted-glass">
<h2>Login</h2>
<form method="POST" action="/login">
<fieldset>
<label for="login-username">Username</label>
<input
type="text"
id="login-username"
name="username"
placeholder="Your username"
required
autocomplete="username"
>
<label for="login-password">Password</label>
<input
type="password"
id="login-password"
name="password"
placeholder="Your password"
required
autocomplete="current-password"
>
<button class="button button-primary" type="submit">Login</button>
</fieldset>
</form>
</div>
</div>
<div class="auth-features-unified">
<h3>✓ Account Benefits</h3>
<h3>Account Benefits</h3>
<div class="features-grid">
<div class="feature-item">Forward emails to verified addresses</div>
<div class="feature-item">Lock up to 5 inboxes to your account</div>

View file

@ -7,7 +7,7 @@
{% if authEnabled %}
<div class="action-dropdown">
<button class="dropdown-toggle" aria-label="Account menu">Account ▾</button>
<div class="dropdown-menu">
<div class="dropdown-menu" data-section-title="Account">
<a href="/account" aria-label="Account settings">Settings</a>
<a href="/logout?redirect=/" aria-label="Logout">Logout</a>
</div>
@ -32,7 +32,7 @@
{% endblock %}
{% block body %}
<h1>{{message}}</h1>
<h1 class="page-title">{{message}}</h1>
<h2>{{error.status}}</h2>
<pre>{{error.stack}}</pre>
{% endblock %}

View file

@ -6,7 +6,7 @@
<!-- Inbox Dropdown (multiple actions when logged in) -->
<div class="action-dropdown">
<button class="dropdown-toggle" aria-label="Inbox actions">Inbox ▾</button>
<div class="dropdown-menu">
<div class="dropdown-menu" data-section-title="Inbox Actions">
<a href="#" id="forwardAllBtn" aria-label="Forward all emails">Forward All</a>
{% if authEnabled %}
{% if isLocked and hasAccess %}
@ -23,7 +23,7 @@
{% if authEnabled %}
<div class="action-dropdown">
<button class="dropdown-toggle" aria-label="Account menu">Account ▾</button>
<div class="dropdown-menu">
<div class="dropdown-menu" data-section-title="Account">
<a href="/account" aria-label="Account settings">Settings</a>
<a href="/logout?redirect={{ ('/inbox/' ~ address) | url_encode }}" aria-label="Logout">Logout</a>
</div>
@ -53,8 +53,11 @@
<script src="/javascripts/qrcode.js"></script>
<script src="/javascripts/inbox-init.js" defer data-address="{{ address }}" data-expiry-time="{{ expiryTime }}" data-expiry-unit="{{ expiryUnit }}" data-refresh-interval="{{ refreshInterval }}"></script>
{% if forwardAllSuccess %}
<div class="success-message">
✓ Successfully forwarded {{ forwardAllSuccess }} email(s)!
<div class="alert alert-success">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle;margin-right:0.5rem">
<path d="M20 6L9 17l-5-5"></path>
</svg>
Successfully forwarded {{ forwardAllSuccess }} email(s)!
</div>
{% endif %}
{% if verificationSent %}
@ -63,7 +66,12 @@
</div>
{% endif %}
{% if errorMessage %}
<div class="unlock-error">
<div class="alert alert-error">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle;margin-right:0.5rem">
<circle cx="12" cy="12" r="10"></circle>
<line x1="15" y1="9" x2="9" y2="15"></line>
<line x1="9" y1="9" x2="15" y2="15"></line>
</svg>
{{ errorMessage }}
</div>
{% endif %}
@ -84,7 +92,7 @@
<div class="emails-container">
{% for mail in mailSummaries %}
<a href="{{ mail.to[0] }}/{{ mail.uid }}" class="email-link">
<div class="email-card">
<div class="email-card frosted-glass">
<div class="email-header">
<div class="email-sender">
<div class="sender-name">{{ mail.from[0].name }}</div>
@ -111,20 +119,20 @@
{% if authEnabled and not isLocked %}
<!-- Lock Modal -->
<div id="lockModal" class="modal" style="display: none;" data-lock-error="{{ error|default('') }}">
<div class="modal-content">
<div class="modal-content frosted-glass">
<span class="close" id="closeLock">&times;</span>
<h3>Lock Inbox</h3>
<p class="modal-description">Lock this inbox to your account. Only you will be able to access it while logged in.</p>
{% if error and error == 'locking_disabled_for_example' %}
<p id="lockServerError" class="unlock-error">Locking is disabled for the example inbox.</p>
<p id="lockServerError" class="alert alert-error">Locking is disabled for the example inbox.</p>
{% elseif error and error == 'max_locked_inboxes' %}
<p id="lockServerError" class="unlock-error">You have reached the maximum of 5 locked inboxes. Please remove a lock before adding a new one.</p>
<p id="lockServerError" class="alert alert-error">You have reached the maximum of 5 locked inboxes. Please remove a lock before adding a new one.</p>
{% elseif error and error == 'already_locked' %}
<p id="lockServerError" class="unlock-error">This inbox is already locked by another user.</p>
<p id="lockServerError" class="alert alert-error">This inbox is already locked by another user.</p>
{% elseif error and error == 'not_your_lock' %}
<p id="lockServerError" class="unlock-error">You don't own the lock on this inbox.</p>
<p id="lockServerError" class="alert alert-error">You don't own the lock on this inbox.</p>
{% endif %}
<p id="lockErrorInline" class="unlock-error" style="display:none"></p>
<p id="lockErrorInline" class="alert alert-error" style="display:none"></p>
<form method="POST" action="/lock/lock">
<input type="hidden" name="address" value="{{ address }}">
<fieldset>
@ -142,7 +150,7 @@
{% if authEnabled and isLocked and hasAccess %}
<!-- Remove Lock Modal -->
<div id="removeLockModal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-content frosted-glass">
<span class="close" id="closeRemoveLock">&times;</span>
<h3>Remove Lock</h3>
<p class="modal-description">Are you sure you want to remove the lock from this inbox? Anyone will be able to access it.</p>

View file

@ -7,6 +7,7 @@
<title>{{ title }}</title>
{% block metaTags %}
<!-- SEO Meta Tags -->
<meta name="description" content="Your temporary Inbox. Create instant throwaway email addresses to protect your privacy. No registration required. Emails auto-delete after 48 hours.">
<meta name="keywords" content="temporary email, disposable email, throwaway email, fake email, temp mail, anonymous email, 48hr email, privacy protection, burner email">
@ -29,6 +30,7 @@
<meta name="twitter:title" content="48hr.email - Your temporary Inbox">
<meta name="twitter:description" content="Free temporary email service. Protect your privacy with disposable email addresses.">
<meta name="twitter:image" content="https://48hr.email/images/logo.png">
{% endblock %}
<!-- Additional Meta Tags -->
<meta name="theme-color" content="#9b4dca">
@ -74,6 +76,8 @@
<script src="/javascripts/utils.js"></script>
<script src="/socket.io/socket.io.js" defer="true"></script>
<script src="/javascripts/notifications.js" defer="true"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" defer="true"></script>
<script src="/javascripts/stats.js" defer="true"></script>
</head>
<body{% if bodyClass %} class="{{ bodyClass }}"{% endif %}>
@ -90,7 +94,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 }} | Currently handling {{ totalcount | raw }}</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 %}

View file

@ -2,6 +2,8 @@
{% set bodyClass = 'loading-page' %}
{% block title %}Loading... | {{ branding[0] }}{% endblock %}
{% block header %}{% endblock %}
{% block footer %}{% endblock %}

View file

@ -7,7 +7,7 @@
{% if authEnabled %}
<div class="action-dropdown">
<button class="dropdown-toggle" aria-label="Account menu">Account ▾</button>
<div class="dropdown-menu">
<div class="dropdown-menu" data-section-title="Account">
<a href="/account" aria-label="Account settings">Settings</a>
<a href="/logout?redirect=/" aria-label="Logout">Logout</a>
</div>
@ -32,33 +32,105 @@
{% endblock %}
{% block body %}
<div id="login">
<h1>Welcome!</h1>
<h4>Here you can either create a new Inbox, or access your old one</h4>
<div class="homepage-container">
<div class="hero-section">
<h1 class="page-title hero-title">Your Temporary Inbox</h1>
<p class="hero-subtitle">Create instant disposable email addresses. No registration required. Emails auto-delete after {{ purgeTimeRaw|readablePurgeTime }}.</p>
</div>
{% if userInputError %}
<blockquote class="warning">
Your input was invalid. Please try other values.
</blockquote>
{% endif %}
<form method="POST" action="/">
<fieldset>
<label for="nameField">Name</label>
<input type="text" id="nameField" name="username" value="{{ username }}">
<label for="commentField">Domain ({{ domains|length }})</label>
<div class="dropdown">
<select id="commentField" name="domain">
{% for domain in domains %}
<option value="{{ domain }}">{{ domain }}</option>
{% endfor %}
</select>
<div class="inbox-creator frosted-glass">
<h2 class="creator-title">Get Started</h2>
{% if userInputError %}
<div class="alert alert-warning">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle;margin-right:0.5rem">
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
Your input was invalid. Please try other values.
</div>
<div class="buttons">
<input class="button" type="submit" value="Access This Inbox">
<a class="button" href="/inbox/random">Create Random Inbox</a>
{% endif %}
<form method="POST" action="/" class="inbox-form">
<div class="form-group">
<label for="nameField">Choose Your Name</label>
<input type="text" id="nameField" name="username" value="{{ username }}" placeholder="e.g., john.doe" required>
</div>
</fieldset>
</form>
<div class="form-group">
<label for="commentField">Select Domain</label>
<div class="select-wrapper">
<select id="commentField" name="domain">
{% for domain in domains %}
<option value="{{ domain }}">@{{ domain }}</option>
{% endfor %}
</select>
</div>
<span class="domain-count">{{ domains|length }} domains available</span>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<span>Access Inbox</span>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</button>
<a href="/inbox/random" class="btn btn-secondary">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
<line x1="12" y1="22.08" x2="12" y2="12"/>
</svg>
<span>Random Inbox</span>
</a>
</div>
</form>
</div>
<div class="features-grid">
<div class="feature-card frosted-glass">
<h3>Privacy First</h3>
<p>No tracking, no bullshit. Your temporary email is completely anonymous.</p>
</div>
<div class="feature-card frosted-glass">
<h3>Instant Access</h3>
<p>Create unlimited temporary email addresses in seconds. No waiting, no verification.</p>
</div>
<div class="feature-card frosted-glass">
<h3>Auto-Delete</h3>
<p>All emails automatically purge after {{ purgeTimeRaw|readablePurgeTime }}. Clean and secure by default.</p>
</div>
</div>
<div class="info-section">
<div class="info-content frosted-glass">
<h2>What is a Temporary Email?</h2>
<p>A temporary email (also known as disposable email or throwaway email) is a service that allows you to receive emails at a temporary address that self-destructs after a certain time. It's perfect for signing up to websites, testing services, or protecting your real inbox from spam.</p>
<h3>Common Use Cases</h3>
<ul class="use-cases-list">
<li><strong>Avoid Spam:</strong> Sign up for services without cluttering your primary inbox</li>
<li><strong>Test Services:</strong> Try new apps and websites without commitment</li>
<li><strong>Online Privacy:</strong> Keep your real email address private from third parties</li>
<li><strong>One-Time Verification:</strong> Receive verification codes without long-term exposure</li>
<li><strong>Download Content:</strong> Access gated content that requires email confirmation</li>
<li><strong>Form Testing:</strong> Test email workflows during development</li>
</ul>
<h3>Why Choose {{ branding[0] }}?</h3>
<ul class="benefits-list">
<li><strong>No Sign-Up Required:</strong> Start using immediately without creating an account</li>
<li><strong>Multiple Domains:</strong> Choose from several domain options for flexibility</li>
<li><strong>Clean Interface:</strong> Simple, modern design focused on usability</li>
<li><strong>Real-Time Updates:</strong> See new emails arrive instantly without refreshing</li>
<li><strong>Open Source:</strong> Transparent codebase you can review and trust</li>
<li><strong>{{ purgeTimeRaw|readablePurgeTime|title }} Retention:</strong> Emails stay accessible for the full duration before auto-deletion</li>
</ul>
<p class="note">For extended features like email forwarding and inbox locking, you can optionally create a free account. But for basic temporary email needs, no registration is ever required.</p>
</div>
</div>
</div>
{% endblock %}

View file

@ -8,7 +8,7 @@
<!-- Email Dropdown (multiple actions when logged in) -->
<div class="action-dropdown">
<button class="dropdown-toggle" aria-label="Email actions">Email ▾</button>
<div class="dropdown-menu">
<div class="dropdown-menu" data-section-title="Email Actions">
<a href="#" id="forwardBtn" aria-label="Forward this email">Forward</a>
<a href="/inbox/{{ address }}/{{ uid }}/delete" aria-label="Delete this email">Delete</a>
<a href="/inbox/{{ address }}/{{ uid }}/raw" target="_blank" aria-label="View raw email">View Raw</a>
@ -19,7 +19,7 @@
{% if authEnabled %}
<div class="action-dropdown">
<button class="dropdown-toggle" aria-label="Account menu">Account ▾</button>
<div class="dropdown-menu">
<div class="dropdown-menu" data-section-title="Account">
<a href="/account" aria-label="Account settings">Settings</a>
<a href="/logout?redirect={{ ('/inbox/' ~ address ~ '/' ~ uid) | url_encode }}" aria-label="Logout">Logout</a>
</div>
@ -48,8 +48,11 @@
{% block body %}
{% if forwardSuccess %}
<div class="success-message">
✓ Email forwarded successfully!
<div class="alert alert-success">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle;margin-right:0.5rem">
<path d="M20 6L9 17l-5-5"></path>
</svg>
Email forwarded successfully!
</div>
{% endif %}
{% if verificationSent %}
@ -125,7 +128,7 @@
<!-- Forward Email Modal -->
<div id="forwardModal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-content frosted-glass">
<span class="close" id="closeForward">&times;</span>
<h3>Forward Email</h3>
@ -143,9 +146,9 @@
{% else %}
<p class="modal-description">Select a verified email address to forward this message to.</p>
{% if errorMessage %}
<p class="unlock-error">{{ errorMessage }}</p>
<p class="alert alert-error">{{ errorMessage }}</p>
{% endif %}
<p id="forwardError" class="unlock-error" style="display:none"></p>
<p id="forwardError" class="alert alert-error" style="display:none"></p>
<form method="POST" action="/inbox/{{ address }}/{{ uid }}/forward">
<fieldset>
<label for="forwardEmail" class="floating-label">Forward to</label>

View file

@ -0,0 +1,104 @@
{% extends 'layout.twig' %}
{% block metaTags %}
<!-- Statistics Page - Custom Meta Tags -->
<meta name="description" content="Live email statistics: {{ stats.currentCount }} emails in system, {{ stats.allTimeTotal }} processed all-time. Real-time monitoring, historical patterns, and predictions over {{ purgeTime|striptags }}.">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://48hr.email/stats">
<meta property="og:title" content="Email Statistics - {{ branding.0 }}">
<meta property="og:description" content="{{ stats.currentCount }} emails in system | {{ stats.allTimeTotal }} all-time total | Real-time monitoring and predictions">
<meta property="og:image" content="https://48hr.email/images/logo.png">
<meta property="og:site_name" content="{{ branding.0 }}">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary">
<meta name="twitter:url" content="https://48hr.email/stats">
<meta name="twitter:title" content="Email Statistics - {{ branding.0 }}">
<meta name="twitter:description" content="{{ stats.currentCount }} emails | {{ stats.allTimeTotal }} all-time | Live monitoring">
<meta name="twitter:image" content="https://48hr.email/images/logo.png">
{% endblock %}
{% block header %}
<div class="action-links">
{% if currentUser %}
<!-- Account Dropdown (logged in) -->
{% if authEnabled %}
<div class="action-dropdown">
<button class="dropdown-toggle" aria-label="Account menu">Account ▾</button>
<div class="dropdown-menu" data-section-title="Account">
<a href="/account" aria-label="Account settings">Settings</a>
<a href="/logout?redirect=/stats" aria-label="Logout">Logout</a>
</div>
</div>
{% endif %}
{% else %}
{% if authEnabled %}
<a href="/auth" aria-label="Login or Register">Account</a>
{% endif %}
{% endif %}
<a href="/" aria-label="Return to home">Home</a>
<button class="theme-toggle" id="themeToggle" aria-label="Toggle dark/light mode">
<svg class="theme-icon theme-icon-dark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
<svg class="theme-icon theme-icon-light" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
</button>
</div>
{% endblock %}
{% block body %}
<div class="stats-container">
<h1 class="page-title">Email Statistics</h1>
<p class="stats-subtitle">Historical patterns, real-time activity, and predictions over {{ purgeTime|striptags }}</p>
<div class="stats-grid">
<!-- Current Count -->
<div class="stat-card">
<div class="stat-value" id="currentCount">{{ stats.currentCount }}</div>
<div class="stat-label">Emails in System</div>
</div>
<!-- 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>
<!-- Receives (Purge Window) -->
<div class="stat-card">
<div class="stat-value" id="receives24h">{{ stats.last24Hours.receives }}</div>
<div class="stat-label">Received</div>
</div>
<!-- Deletes (Purge Window) -->
<div class="stat-card">
<div class="stat-value" id="deletes24h">{{ stats.last24Hours.deletes }}</div>
<div class="stat-label">Deleted</div>
</div>
<!-- Forwards (Purge Window) -->
<div class="stat-card">
<div class="stat-value" id="forwards24h">{{ stats.last24Hours.forwards }}</div>
<div class="stat-label">Forwarded</div>
</div>
</div>
<!-- Chart -->
<div class="chart-container">
<h2>Email Activity Timeline</h2>
<canvas id="statsChart"></canvas>
</div>
</div>
<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 %}

View file

@ -1,4 +1,5 @@
const sanitizeHtml = require('sanitize-html')
const config = require('../../../application/config')
/**
* Transformes <a> tags to always use "noreferrer noopener" and open in a new window.
@ -6,22 +7,84 @@ const sanitizeHtml = require('sanitize-html')
* @returns {*} dom after transformation
*/
exports.sanitizeHtmlTwigFilter = function(value) {
return sanitizeHtml(value, {
allowedAttributes: {
a: ['href', 'target', 'rel']
},
return sanitizeHtml(value, {
allowedAttributes: {
a: ['href', 'target', 'rel']
},
transformTags: {
a(tagName, attribs) {
return {
tagName,
attribs: {
rel: 'noreferrer noopener',
href: attribs.href,
target: '_blank'
}
}
}
}
})
transformTags: {
a(tagName, attribs) {
return {
tagName,
attribs: {
rel: 'noreferrer noopener',
href: attribs.href,
target: '_blank'
}
}
}
}
})
}
/**
* Convert time to highest possible unit (minutes hours days),
* rounding if necessary and prefixing "~" when rounded.
* Mirrors the logic from Helper.convertAndRound()
*
* @param {number} time
* @param {string} unit "minutes" | "hours" | "days"
* @returns {string}
*/
function convertAndRound(time, unit) {
let value = time
let u = unit
// upgrade units
const units = [
["minutes", 60, "hours"],
["hours", 24, "days"]
]
for (const [from, factor, to] of units) {
if (u === from && value > factor) {
value = value / factor
u = to
}
}
// determine if rounding is needed
const rounded = !Number.isSafeInteger(value)
if (rounded) value = Math.round(value)
// Handle singular/plural
const displayValue = value === 1 ? value : value
const displayUnit = value === 1 ? u.replace(/s$/, '') : u
return `${rounded ? "~" : ""}${displayValue} ${displayUnit}`
}
/**
* Convert purgeTime config to readable format, respecting the convert flag
* @param {Object} purgeTime - Object with time, unit, and convert properties
* @returns {String} Readable time string
*/
exports.readablePurgeTime = function(purgeTime) {
if (!purgeTime || !purgeTime.time || !purgeTime.unit) {
// Fallback to config if not provided
if (config.email.purgeTime) {
purgeTime = config.email.purgeTime
} else {
return '48 hours'
}
}
let result = `${purgeTime.time} ${purgeTime.unit}`
// Only convert if the convert flag is true
if (purgeTime.convert) {
result = convertAndRound(purgeTime.time, purgeTime.unit)
}
return result
}

View file

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

View file

@ -1,6 +1,6 @@
{
"name": "48hr.email",
"version": "1.9.0",
"version": "2.1.0",
"private": false,
"description": "48hr.email is your favorite open-source tempmail client.",
"keywords": [

View file

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