Compare commits

..

No commits in common. "d8b19dcd2688eb93036aec4d9f31592d6f38d078" and "84013100625e467ecfca2a204bc984e226bf8311" have entirely different histories.

36 changed files with 227 additions and 669 deletions

View file

@ -7,24 +7,11 @@ function responseFormatter(req, res, next) {
* @param {*} data - Data to return * @param {*} data - Data to return
* @param {number} statusCode - HTTP status code (default: 200) * @param {number} statusCode - HTTP status code (default: 200)
*/ */
// Determine mode: 'normal', 'debug', or 'ux-debug'
let mode = 'production';
const config = req.app && req.app.get ? req.app.get('config') : null;
if (config && config.uxDebugMode) {
mode = 'ux-debug';
} else if (process.env.DEBUG && process.env.DEBUG.includes('48hr-email')) {
mode = 'debug';
}
res.apiSuccess = function(data = null, statusCode = 200, templateContext = null) { res.apiSuccess = function(data = null, statusCode = 200, templateContext = null) {
const response = { const response = {
success: true, success: true,
mode: mode,
data: data data: data
}; };
if (req.sanitizedInput) {
response.sanitized = req.sanitizedInput;
}
if (templateContext) response.templateContext = templateContext; if (templateContext) response.templateContext = templateContext;
res.status(statusCode).json(response); res.status(statusCode).json(response);
} }
@ -38,13 +25,9 @@ function responseFormatter(req, res, next) {
res.apiError = function(message, code = 'ERROR', statusCode = 400, templateContext = null) { res.apiError = function(message, code = 'ERROR', statusCode = 400, templateContext = null) {
const response = { const response = {
success: false, success: false,
mode: mode,
error: message, error: message,
code: code code: code
}; };
if (req.sanitizedInput) {
response.sanitized = req.sanitizedInput;
}
if (templateContext) response.templateContext = templateContext; if (templateContext) response.templateContext = templateContext;
res.status(statusCode).json(response); res.status(statusCode).json(response);
} }
@ -61,7 +44,6 @@ function responseFormatter(req, res, next) {
} }
const response = { const response = {
success: true, success: true,
mode: mode,
data: items, data: items,
count: items.length, count: items.length,
total: total !== null ? total : items.length total: total !== null ? total : items.length

View file

@ -8,7 +8,6 @@ const { errorHandler } = require('./middleware/error-handler')
* Main API Router (v1) * Main API Router (v1)
* Mounts all API endpoints under /api/v1 * Mounts all API endpoints under /api/v1
*/ */
function createApiRouter(dependencies) { function createApiRouter(dependencies) {
const router = express.Router() const router = express.Router()
const { apiTokenRepository } = dependencies const { apiTokenRepository } = dependencies
@ -21,9 +20,6 @@ function createApiRouter(dependencies) {
allowedHeaders: ['Content-Type', 'Authorization'] allowedHeaders: ['Content-Type', 'Authorization']
})) }))
// Sanitize all input
router.use(require('./middleware/sanitize'))
// Response formatting helpers // Response formatting helpers
router.use(responseFormatter) router.use(responseFormatter)
@ -59,4 +55,4 @@ function createApiRouter(dependencies) {
return router return router
} }
module.exports = createApiRouter module.exports = createApiRouter

View file

@ -7,13 +7,13 @@ Manage user accounts, forwarding emails, locked inboxes, and API tokens.
## Endpoints ## Endpoints
### GET `/api/v1/account/` ### GET `/api/account/`
Get account info and stats for the authenticated user. Get account info and stats for the authenticated user.
- **Auth:** Required - **Auth:** Required
- **Response:** - **Response:**
- `userId`, `username`, `createdAt`, `lastLogin`, `verifiedEmails`, `lockedInboxes`, `apiToken` - `userId`, `username`, `createdAt`, `lastLogin`, `verifiedEmails`, `lockedInboxes`, `apiToken`
### POST `/api/v1/account/verify-email` ### POST `/api/account/verify-email`
Add a forwarding email (triggers verification). Add a forwarding email (triggers verification).
- **Auth:** Required - **Auth:** Required
- **Body:** - **Body:**
@ -21,13 +21,13 @@ Add a forwarding email (triggers verification).
- **Response:** - **Response:**
- Success or error - Success or error
### DELETE `/api/v1/account/verify-email/:id` ### DELETE `/api/account/verify-email/:id`
Remove a forwarding email by ID. Remove a forwarding email by ID.
- **Auth:** Required - **Auth:** Required
- **Response:** - **Response:**
- Success or error - Success or error
### POST `/api/v1/account/change-password` ### POST `/api/account/change-password`
Change account password. Change account password.
- **Auth:** Required - **Auth:** Required
- **Body:** - **Body:**
@ -35,25 +35,25 @@ Change account password.
- **Response:** - **Response:**
- Success or error - Success or error
### DELETE `/api/v1/account/` ### DELETE `/api/account/`
Delete the user account. Delete the user account.
- **Auth:** Required - **Auth:** Required
- **Response:** - **Response:**
- Success or error - Success or error
### GET `/api/v1/account/token` ### GET `/api/account/token`
Get API token info (not the token itself). Get API token info (not the token itself).
- **Auth:** Required - **Auth:** Required
- **Response:** - **Response:**
- `hasToken`, `createdAt`, `lastUsed` - `hasToken`, `createdAt`, `lastUsed`
### POST `/api/v1/account/token` ### POST `/api/account/token`
Generate or regenerate API token. Generate or regenerate API token.
- **Auth:** Required - **Auth:** Required
- **Response:** - **Response:**
- Success or error - Success or error
### DELETE `/api/v1/account/token` ### DELETE `/api/account/token`
Revoke API token. Revoke API token.
- **Auth:** Required - **Auth:** Required
- **Response:** - **Response:**

View file

@ -45,8 +45,8 @@ function createAccountRouter(dependencies) {
try { try {
const userId = req.user.id const userId = req.user.id
// Get user stats (pass config.user for mock repo compatibility) // Get user stats
const stats = userRepository.getUserStats(userId, config.user) const stats = userRepository.getUserStats(userId)
// Get verified emails // Get verified emails
const verifiedEmails = userRepository.getForwardEmails(userId) const verifiedEmails = userRepository.getForwardEmails(userId)

View file

@ -7,7 +7,7 @@ User registration, login, logout, and session management.
## Endpoints ## Endpoints
### POST `/api/v1/auth/register` ### POST `/api/auth/register`
Register a new user. Register a new user.
- **Body:** - **Body:**
- `username`: string (3-20 chars, alphanumeric/underscore) - `username`: string (3-20 chars, alphanumeric/underscore)
@ -17,7 +17,7 @@ Register a new user.
- **Errors:** - **Errors:**
- `VALIDATION_ERROR`, `REGISTRATION_FAILED`, `AUTH_DISABLED` - `VALIDATION_ERROR`, `REGISTRATION_FAILED`, `AUTH_DISABLED`
### POST `/api/v1/auth/login` ### POST `/api/auth/login`
Login user. Login user.
- **Body:** - **Body:**
- `username`, `password` - `username`, `password`
@ -26,12 +26,12 @@ Login user.
- **Errors:** - **Errors:**
- `VALIDATION_ERROR`, `AUTH_DISABLED` - `VALIDATION_ERROR`, `AUTH_DISABLED`
### POST `/api/v1/auth/logout` ### POST `/api/auth/logout`
Logout user. Logout user.
- **Response:** - **Response:**
- Success or error - Success or error
### GET `/api/v1/auth/session` ### GET `/api/auth/session`
Get current session info. Get current session info.
- **Response:** - **Response:**
- `userId`, `username`, `isAuthenticated`, `createdAt` - `userId`, `username`, `isAuthenticated`, `createdAt`

View file

@ -7,20 +7,20 @@ Public endpoints for configuration, domains, limits, and features.
## Endpoints ## Endpoints
### GET `/api/v1/config/domains` ### GET `/api/config/domains`
Get allowed email domains. Get allowed email domains.
- **Response:** - **Response:**
- `domains`: array of strings - `domains`: array of strings
### GET `/api/v1/config/limits` ### GET `/api/config/limits`
Get rate limits and constraints. Get rate limits and constraints.
- **Response:** - **Response:**
- `api.rateLimit`, `email.purgeTime`, `email.purgeUnit`, `email.maxForwardedPerRequest`, `user.maxVerifiedEmails`, `user.maxLockedInboxes`, `user.lockReleaseHours` - `api.rateLimit`, `email.purgeTime`, `email.purgeUnit`, `email.maxForwardedPerRequest`, `user.maxVerifiedEmails`, `user.maxLockedInboxes`, `user.lockReleaseHours`
### GET `/api/v1/config/features` ### GET `/api/config/features`
Get enabled features. Get enabled features.
- **Response:** - **Response:**
- `authentication`, `forwarding`, `statistics` - `authentication`, `forwarding`, `statistics`, `inboxLocking`
--- ---

View file

@ -59,7 +59,8 @@ function createConfigRouter(dependencies) {
res.apiSuccess({ res.apiSuccess({
authentication: config.user.authEnabled, authentication: config.user.authEnabled,
forwarding: config.smtp.enabled, forwarding: config.smtp.enabled,
statistics: config.http.features.statistics statistics: config.http.statisticsEnabled,
inboxLocking: config.user.authEnabled
}) })
}) })

View file

@ -7,13 +7,13 @@ Endpoints for listing emails, retrieving full/raw emails, and downloading attach
## Endpoints ## Endpoints
### GET `/api/v1/inbox/:address` ### GET `/api/inbox/:address`
List mail summaries for an inbox. List mail summaries for an inbox.
- **Auth:** Optional - **Auth:** Optional
- **Response:** - **Response:**
- Array of mail summary objects - Array of mail summary objects
### GET `/api/v1/inbox/:address/:uid` ### GET `/api/inbox/:address/:uid`
Get full email by UID. Get full email by UID.
- **Auth:** Optional - **Auth:** Optional
- **Response:** - **Response:**
@ -21,7 +21,7 @@ Get full email by UID.
- **Errors:** - **Errors:**
- `VALIDATION_ERROR`, `NOT_FOUND` - `VALIDATION_ERROR`, `NOT_FOUND`
### GET `/api/v1/inbox/:address/:uid/raw` ### GET `/api/inbox/:address/:uid/raw`
Get raw email source. Get raw email source.
- **Auth:** Optional - **Auth:** Optional
- **Response:** - **Response:**
@ -29,7 +29,7 @@ Get raw email source.
- **Errors:** - **Errors:**
- `VALIDATION_ERROR`, `NOT_FOUND` - `VALIDATION_ERROR`, `NOT_FOUND`
### GET `/api/v1/inbox/:address/:uid/attachment/:checksum` ### GET `/api/inbox/:address/:uid/attachment/:checksum`
Download attachment by checksum. Download attachment by checksum.
- **Auth:** Optional - **Auth:** Optional
- **Response:** - **Response:**

View file

@ -7,7 +7,7 @@ APIs for managing locked inboxes for users. All responses include a `templateCon
## Endpoints ## Endpoints
### GET `/api/v1/locks/` ### GET `/api/locks/`
List all inboxes locked by the authenticated user. List all inboxes locked by the authenticated user.
- **Auth:** Required - **Auth:** Required
- **Response:** - **Response:**
@ -15,7 +15,7 @@ List all inboxes locked by the authenticated user.
- `data`: array of locked inboxes - `data`: array of locked inboxes
- `templateContext`: `{ userId, config: { maxLockedInboxes } }` - `templateContext`: `{ userId, config: { maxLockedInboxes } }`
### POST `/api/v1/locks/` ### POST `/api/locks/`
Lock an inbox for the authenticated user. Lock an inbox for the authenticated user.
- **Auth:** Required - **Auth:** Required
- **Body:** - **Body:**
@ -32,7 +32,7 @@ Lock an inbox for the authenticated user.
- Locked by other: `LOCKED_BY_OTHER` - Locked by other: `LOCKED_BY_OTHER`
- All errors include `templateContext` - All errors include `templateContext`
### DELETE `/api/v1/locks/:address` ### DELETE `/api/locks/:address`
Unlock/release a locked inbox. Unlock/release a locked inbox.
- **Auth:** Required - **Auth:** Required
- **Response:** - **Response:**
@ -42,7 +42,7 @@ Unlock/release a locked inbox.
- **Errors:** - **Errors:**
- Not found/unauthorized: `NOT_FOUND` (includes `templateContext`) - Not found/unauthorized: `NOT_FOUND` (includes `templateContext`)
### GET `/api/v1/locks/:address/status` ### GET `/api/locks/:address/status`
Check if an inbox is locked and if owned by the user. Check if an inbox is locked and if owned by the user.
- **Auth:** Optional - **Auth:** Optional
- **Response:** - **Response:**

View file

@ -13,10 +13,9 @@ const createAuthenticator = require('../middleware/authenticator')
function createLocksRouter(dependencies) { function createLocksRouter(dependencies) {
const { inboxLock, userRepository, apiTokenRepository, config } = dependencies const { inboxLock, userRepository, apiTokenRepository, config } = dependencies
// Inbox locking is always enabled if authentication is enabled
if (!inboxLock || !config.user.authEnabled) { if (!inboxLock || !config.user.authEnabled) {
router.all('*', (req, res) => { router.all('*', (req, res) => {
res.apiError('Authentication is required for inbox locking', 'AUTH_REQUIRED', 401) res.apiError('Inbox locking is disabled', 'FEATURE_DISABLED', 503)
}) })
return router return router
} }

View file

@ -7,7 +7,7 @@ Endpoints for deleting emails and forwarding mail.
## Endpoints ## Endpoints
### DELETE `/api/v1/mail/inbox/:address/:uid` ### DELETE `/api/mail/inbox/:address/:uid`
Delete a single email by UID. Delete a single email by UID.
- **Auth:** Optional - **Auth:** Optional
- **Response:** - **Response:**
@ -15,7 +15,7 @@ Delete a single email by UID.
- **Errors:** - **Errors:**
- `VALIDATION_ERROR`, `NOT_FOUND` - `VALIDATION_ERROR`, `NOT_FOUND`
### DELETE `/api/v1/mail/inbox/:address` ### DELETE `/api/mail/inbox/:address`
Delete all emails in an inbox (requires `?confirm=true`). Delete all emails in an inbox (requires `?confirm=true`).
- **Auth:** Optional - **Auth:** Optional
- **Response:** - **Response:**
@ -23,7 +23,7 @@ Delete all emails in an inbox (requires `?confirm=true`).
- **Errors:** - **Errors:**
- `CONFIRMATION_REQUIRED`, `NOT_FOUND` - `CONFIRMATION_REQUIRED`, `NOT_FOUND`
### POST `/api/v1/mail/forward` ### POST `/api/mail/forward`
Forward a single email. Forward a single email.
- **Auth:** Required - **Auth:** Required
- **Body:** - **Body:**
@ -33,7 +33,7 @@ Forward a single email.
- **Errors:** - **Errors:**
- `VALIDATION_ERROR`, `NOT_FOUND`, `FORWARD_FAILED` - `VALIDATION_ERROR`, `NOT_FOUND`, `FORWARD_FAILED`
### POST `/api/v1/mail/forward-all` ### POST `/api/mail/forward-all`
Forward all emails in an inbox. Forward all emails in an inbox.
- **Auth:** Required - **Auth:** Required
- **Body:** - **Body:**

View file

@ -7,15 +7,15 @@ Endpoints for retrieving statistics and historical data.
## Endpoints ## Endpoints
### GET `/api/v1/stats/` ### GET `/api/stats/`
Get lightweight statistics (no historical analysis). Get lightweight statistics (no historical analysis).
- **Response:** - **Response:**
- `currentCount`, `allTimeTotal`, `purgeWindow` (object with `receives`, `deletes`, `forwards`, `timeline`) - `currentCount`, `allTimeTotal`, `last24Hours` (object with `receives`, `deletes`, `forwards`, `timeline`)
### GET `/api/v1/stats/enhanced` ### GET `/api/stats/enhanced`
Get full statistics with historical data and predictions. Get full statistics with historical data and predictions.
- **Response:** - **Response:**
- `currentCount`, `allTimeTotal`, `purgeWindow`, `historical`, `prediction`, `enhanced` - `currentCount`, `allTimeTotal`, `last24Hours`, `historical`, `prediction`, `enhanced`
--- ---
@ -41,7 +41,7 @@ Get full statistics with historical data and predictions.
"data": { "data": {
"currentCount": 123, "currentCount": 123,
"allTimeTotal": 4567, "allTimeTotal": 4567,
"purgeWindow": { "last24Hours": {
"receives": 10, "receives": 10,
"deletes": 2, "deletes": 2,
"forwards": 1, "forwards": 1,

View file

@ -11,7 +11,7 @@ function createStatsRouter(dependencies) {
// Ensure router is declared before any usage // Ensure router is declared before any usage
const { statisticsStore, mailProcessingService, imapService, config } = dependencies const { statisticsStore, mailProcessingService, imapService, config } = dependencies
if (!config.http.features.statistics) { if (!config.http.statisticsEnabled) {
router.all('*', (req, res) => { router.all('*', (req, res) => {
res.apiError('Statistics are disabled', 'FEATURE_DISABLED', 503) res.apiError('Statistics are disabled', 'FEATURE_DISABLED', 503)
}) })

6
app.js
View file

@ -207,8 +207,6 @@ function displayStartupBanner() {
const domains = config.email.domains.join(', ') const domains = config.email.domains.join(', ')
const purgeTime = `${config.email.purgeTime.time} ${config.email.purgeTime.unit}` const purgeTime = `${config.email.purgeTime.time} ${config.email.purgeTime.unit}`
const refreshInterval = config.uxDebugMode ? 'N/A' : `${config.imap.refreshIntervalSeconds}s` const refreshInterval = config.uxDebugMode ? 'N/A' : `${config.imap.refreshIntervalSeconds}s`
const branding = config.http.features.branding[0] || '48hr.email'
const baseUrl = config.http.baseUrl
// Determine mode based on environment // Determine mode based on environment
let mode = 'PRODUCTION' let mode = 'PRODUCTION'
@ -219,9 +217,9 @@ function displayStartupBanner() {
} }
console.log('\n' + '═'.repeat(70)) console.log('\n' + '═'.repeat(70))
console.log(` ${branding} - ${mode} MODE`) console.log(` 48hr.email - ${mode} MODE`)
console.log('═'.repeat(70)) console.log('═'.repeat(70))
console.log(` Server: ${baseUrl}`) console.log(` Server: http://localhost:${config.http.port}`)
console.log(` Domains: ${domains}`) console.log(` Domains: ${domains}`)
console.log(` Emails loaded: ${mailCount}`) console.log(` Emails loaded: ${mailCount}`)
console.log(` Purge after: ${purgeTime}`) console.log(` Purge after: ${purgeTime}`)

View file

@ -37,7 +37,7 @@ function parseBool(v) {
} }
const config = { const config = {
apiEnabled: parseBool(process.env.HTTP_API_ENABLED) || false, apiEnabled: parseBool(process.env.HTTP_API_ENABLED) || true,
uxDebugMode: parseBool(process.env.UX_DEBUG_MODE) || false, uxDebugMode: parseBool(process.env.UX_DEBUG_MODE) || false,
email: { email: {
@ -87,7 +87,7 @@ const config = {
displaySort: Number(process.env.HTTP_DISPLAY_SORT) || 0, displaySort: Number(process.env.HTTP_DISPLAY_SORT) || 0,
hideOther: parseBool(process.env.HTTP_HIDE_OTHER), hideOther: parseBool(process.env.HTTP_HIDE_OTHER),
statistics: parseBool(process.env.HTTP_STATISTICS_ENABLED) || false, statistics: parseBool(process.env.HTTP_STATISTICS_ENABLED) || false,
infoSection: parseBool(process.env.HTTP_SHOW_INFO_SECTION) || false infoSection: parseBool(process.env.HTTP_SHOW_INFO_SECTION) || true
} }
}, },

View file

@ -1,402 +1,152 @@
const debug = require('debug')('48hr-email:stats-store'); const debug = require('debug')('48hr-email:stats-store')
const config = require('../application/config'); const config = require('../application/config')
/** /**
* Statistics Store - Tracks email metrics and historical data * Statistics Store - Tracks email metrics and historical data
* Stores rolling statistics for receives, deletes, and forwards over the configured purge window * Stores 24-hour rolling statistics for receives, deletes, and forwards
* Persists data to database for survival across restarts * Persists data to database for survival across restarts
*/ */
class StatisticsStore { class StatisticsStore {
constructor(db = null) { constructor(db = null) {
this.db = db; this.db = db
this.currentCount = 0;
this.largestUid = 0; // Current totals
this.hourlyData = []; this.currentCount = 0
this.maxDataPoints = 1440; // Default: 1440 minutes (24 hours), but actual retention is purge window this.largestUid = 0
this.lastCleanup = Date.now();
this.historicalData = null; // 24-hour rolling data (one entry per minute = 1440 entries)
this.lastAnalysisTime = 0; this.hourlyData = []
this.analysisCacheDuration = 5 * 60 * 1000; // Cache for 5 minutes this.maxDataPoints = 24 * 60 // 24 hours * 60 minutes
this.enhancedStats = null;
this.lastEnhancedStatsTime = 0; // Track last cleanup to avoid too frequent operations
this.enhancedStatsCacheDuration = 5 * 60 * 1000; // Cache for 5 minutes 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
// Enhanced statistics (calculated from current emails)
this.enhancedStats = null
this.lastEnhancedStatsTime = 0
this.enhancedStatsCacheDuration = 5 * 60 * 1000 // Cache for 5 minutes
// Load persisted data if database is available
if (this.db) { if (this.db) {
this._loadFromDatabase(); this._loadFromDatabase()
} }
debug('Statistics store initialized');
debug('Statistics store initialized')
} }
/**
* Get cutoff time based on email purge configuration
* @returns {number} Timestamp in milliseconds
* @private
*/
_getPurgeCutoffMs() { _getPurgeCutoffMs() {
const time = config.email.purgeTime.time; const time = config.email.purgeTime.time
const unit = config.email.purgeTime.unit; const unit = config.email.purgeTime.unit
let cutoffMs = 0;
let cutoffMs = 0
switch (unit) { switch (unit) {
case 'minutes': case 'minutes':
cutoffMs = time * 60 * 1000; cutoffMs = time * 60 * 1000
break; break
case 'hours': case 'hours':
cutoffMs = time * 60 * 60 * 1000; cutoffMs = time * 60 * 60 * 1000
break; break
case 'days': case 'days':
cutoffMs = time * 24 * 60 * 60 * 1000; cutoffMs = time * 24 * 60 * 60 * 1000
break; break
default: default:
cutoffMs = 48 * 60 * 60 * 1000; // Fallback to 48 hours cutoffMs = 48 * 60 * 60 * 1000 // Fallback to 48 hours
} }
return cutoffMs;
return cutoffMs
} }
/**
* Load statistics from database
* @private
*/
_loadFromDatabase() { _loadFromDatabase() {
try { try {
const stmt = this.db.prepare('SELECT largest_uid, hourly_data, last_updated FROM statistics WHERE id = 1'); const stmt = this.db.prepare('SELECT largest_uid, hourly_data, last_updated FROM statistics WHERE id = 1')
const row = stmt.get(); const row = stmt.get()
if (row) { if (row) {
this.largestUid = row.largest_uid || 0; this.largestUid = row.largest_uid || 0
// Parse hourly data
if (row.hourly_data) { if (row.hourly_data) {
try { try {
const parsed = JSON.parse(row.hourly_data); const parsed = JSON.parse(row.hourly_data)
const cutoff = Date.now() - this._getPurgeCutoffMs(); // Filter out stale data based on config purge time
this.hourlyData = parsed.filter(entry => entry.timestamp >= cutoff); const cutoff = Date.now() - this._getPurgeCutoffMs()
debug(`Loaded ${this.hourlyData.length} hourly data points from database (cutoff: ${new Date(cutoff).toISOString()})`); 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) { } catch (e) {
debug('Failed to parse hourly data:', e.message); debug('Failed to parse hourly data:', e.message)
this.hourlyData = []; this.hourlyData = []
} }
} }
debug(`Loaded from database: largestUid=${this.largestUid}, hourlyData=${this.hourlyData.length} entries`);
debug(`Loaded from database: largestUid=${this.largestUid}, hourlyData=${this.hourlyData.length} entries`)
} }
} catch (error) { } catch (error) {
debug('Failed to load statistics from database:', error.message); debug('Failed to load statistics from database:', error.message)
} }
} }
/**
* Save statistics to database
* @private
*/
_saveToDatabase() { _saveToDatabase() {
if (!this.db) return; if (!this.db) return
try { try {
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
UPDATE statistics UPDATE statistics
SET largest_uid = ?, hourly_data = ?, last_updated = ? SET largest_uid = ?, hourly_data = ?, last_updated = ?
WHERE id = 1 WHERE id = 1
`); `)
stmt.run(this.largestUid, JSON.stringify(this.hourlyData), Date.now()); stmt.run(this.largestUid, JSON.stringify(this.hourlyData), Date.now())
debug('Statistics saved to database'); debug('Statistics saved to database')
} catch (error) { } catch (error) {
debug('Failed to save statistics to database:', error.message); debug('Failed to save statistics to database:', error.message)
} }
} }
/**
* Initialize with current email count
* @param {number} count - Current email count
*/
initialize(count) { initialize(count) {
this.currentCount = count; this.currentCount = count
debug(`Initialized with ${count} emails`); 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) { updateLargestUid(uid) {
if (uid >= 0 && uid > this.largestUid) { if (uid >= 0 && uid > this.largestUid) {
this.largestUid = uid; this.largestUid = uid
this._saveToDatabase(); this._saveToDatabase()
debug(`Largest UID updated to ${uid}`); debug(`Largest UID updated to ${uid}`)
} }
} }
/**
* Record an email received event
*/
recordReceive() { recordReceive() {
this.currentCount++; this.currentCount++
this._addDataPoint('receive'); this._addDataPoint('receive')
debug(`Email received. Current: ${this.currentCount}`); debug(`Email received. Current: ${this.currentCount}`)
}
recordDelete() {
this.currentCount = Math.max(0, this.currentCount - 1);
this._addDataPoint('delete');
debug(`Email deleted. Current: ${this.currentCount}`);
}
recordForward() {
this._addDataPoint('forward');
debug(`Email forwarded`);
}
updateCurrentCount(count) {
const diff = count - this.currentCount;
if (diff < 0) {
for (let i = 0; i < Math.abs(diff); i++) {
this._addDataPoint('delete');
}
}
this.currentCount = count;
debug(`Current count updated to ${count}`);
}
getStats() {
this._cleanup();
const purgeWindowStats = this._getPurgeWindowStats();
return {
currentCount: this.currentCount,
allTimeTotal: this.largestUid,
purgeWindow: {
receives: purgeWindowStats.receives,
deletes: purgeWindowStats.deletes,
forwards: purgeWindowStats.forwards,
timeline: this._getTimeline()
}
};
}
calculateEnhancedStatistics(allMails) {
if (!allMails || allMails.length === 0) {
this.enhancedStats = null;
return;
}
const now = Date.now();
if (this.enhancedStats && (now - this.lastEnhancedStatsTime) < this.enhancedStatsCacheDuration) {
debug(`Using cached enhanced stats (age: ${Math.round((now - this.lastEnhancedStatsTime) / 1000)}s)`);
return;
}
debug(`Calculating enhanced statistics from ${allMails.length} emails`);
const senderDomains = new Map();
const recipientDomains = new Map();
const hourlyActivity = Array(24).fill(0);
let totalSubjectLength = 0;
let subjectCount = 0;
let dayTimeEmails = 0;
let nightTimeEmails = 0;
allMails.forEach(mail => {
try {
if (mail.from && mail.from[0] && mail.from[0].address) {
const parts = mail.from[0].address.split('@');
const domain = parts[1] ? parts[1].toLowerCase() : null;
if (domain) senderDomains.set(domain, (senderDomains.get(domain) || 0) + 1);
}
if (mail.to && mail.to[0]) {
const parts = mail.to[0].split('@');
const domain = parts[1] ? parts[1].toLowerCase() : null;
if (domain) recipientDomains.set(domain, (recipientDomains.get(domain) || 0) + 1);
}
if (mail.date) {
const date = new Date(mail.date);
if (!isNaN(date.getTime())) {
const hour = date.getHours();
hourlyActivity[hour]++;
if (hour >= 6 && hour < 18) dayTimeEmails++;
else nightTimeEmails++;
}
}
if (mail.subject) {
totalSubjectLength += mail.subject.length;
subjectCount++;
}
} catch (e) {}
});
const topSenderDomains = Array.from(senderDomains.entries()).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([domain, count]) => ({ domain, count }));
const topRecipientDomains = Array.from(recipientDomains.entries()).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([domain, count]) => ({ domain, count }));
const busiestHours = hourlyActivity.map((count, hour) => ({ hour, count })).filter(h => h.count > 0).sort((a, b) => b.count - a.count).slice(0, 5);
const peakHourCount = busiestHours.length > 0 ? busiestHours[0].count : 0;
const peakHourPercentage = allMails.length > 0 ? Math.round((peakHourCount / allMails.length) * 100) : 0;
const activeHours = hourlyActivity.filter(count => count > 0).length;
const emailsPerHour = activeHours > 0 ? Math.round(allMails.length / activeHours) : 0;
const totalDayNight = dayTimeEmails + nightTimeEmails;
const dayPercentage = totalDayNight > 0 ? Math.round((dayTimeEmails / totalDayNight) * 100) : 50;
this.enhancedStats = {
topSenderDomains,
topRecipientDomains,
busiestHours,
averageSubjectLength: subjectCount > 0 ? Math.round(totalSubjectLength / subjectCount) : 0,
totalEmails: allMails.length,
uniqueSenderDomains: senderDomains.size,
uniqueRecipientDomains: recipientDomains.size,
peakHourPercentage,
emailsPerHour,
dayPercentage
};
this.lastEnhancedStatsTime = now;
debug(`Enhanced stats calculated: ${this.enhancedStats.uniqueSenderDomains} unique sender domains, ${this.enhancedStats.busiestHours.length} busy hours`);
}
analyzeHistoricalData(allMails) {
if (!allMails || allMails.length === 0) {
debug('No historical data to analyze');
return;
}
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();
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) {}
});
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`);
}
getEnhancedStats() {
this._cleanup();
const purgeWindowStats = this._getPurgeWindowStats();
const timeline = this._getTimeline();
const historicalTimeline = this._getHistoricalTimeline();
const prediction = this._generatePrediction();
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,
purgeWindow: {
receives: purgeWindowStats.receives + historicalReceives,
deletes: purgeWindowStats.deletes,
forwards: purgeWindowStats.forwards,
timeline: timeline
},
historical: historicalTimeline,
prediction: prediction,
enhanced: this.enhancedStats
};
}
getLightweightStats() {
this._cleanup();
const purgeWindowStats = this._getPurgeWindowStats();
const timeline = this._getTimeline();
return {
currentCount: this.currentCount,
allTimeTotal: this.largestUid,
purgeWindow: {
receives: purgeWindowStats.receives,
deletes: purgeWindowStats.deletes,
forwards: purgeWindowStats.forwards,
timeline: timeline
}
};
}
_getPurgeWindowStats() {
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)
};
}
_getTimeline() {
const now = Date.now();
const cutoff = now - this._getPurgeCutoffMs();
const buckets = {};
this.hourlyData.filter(e => e.timestamp >= cutoff).forEach(entry => {
const interval = Math.floor(entry.timestamp / 900000) * 900000; // 15 minutes
if (!buckets[interval]) {
buckets[interval] = { timestamp: interval, receives: 0, deletes: 0, forwards: 0 };
}
buckets[interval].receives += entry.receives;
buckets[interval].deletes += entry.deletes;
buckets[interval].forwards += entry.forwards;
});
return Object.values(buckets).sort((a, b) => a.timestamp - b.timestamp);
}
_getHistoricalTimeline() {
if (!this.historicalData || this.historicalData.length === 0) {
return [];
}
const cutoff = Date.now() - this._getPurgeCutoffMs();
const relevantHistory = this.historicalData.filter(point => point.timestamp >= cutoff);
const intervalBuckets = new Map();
relevantHistory.forEach(point => {
const interval = Math.floor(point.timestamp / 900000) * 900000; // 15 minutes
if (!intervalBuckets.has(interval)) {
intervalBuckets.set(interval, 0);
}
intervalBuckets.set(interval, intervalBuckets.get(interval) + point.receives);
});
const intervalData = Array.from(intervalBuckets.entries()).map(([timestamp, receives]) => ({ timestamp, receives })).sort((a, b) => a.timestamp - b.timestamp);
debug(`Historical timeline: ${intervalData.length} 15-min interval points within ${config.email.purgeTime.time} ${config.email.purgeTime.unit} window`);
return intervalData;
}
_generatePrediction() {
if (!this.historicalData || this.historicalData.length < 100) {
return [];
}
const now = Date.now();
const predictions = [];
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);
});
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`);
const purgeMs = this._getPurgeCutoffMs();
const purgeDurationHours = Math.ceil(purgeMs / (60 * 60 * 1000));
const predictionHours = Math.min(12, Math.ceil(purgeDurationHours * 0.2));
const predictionIntervals = predictionHours * 4;
for (let i = 1; i <= predictionIntervals; i++) {
const timestamp = now + (i * 15 * 60 * 1000);
const futureDate = new Date(timestamp);
const futureHour = futureDate.getHours();
let baseCount = hourlyAverages.get(futureHour);
if (baseCount === undefined) {
const allValues = Array.from(hourlyAverages.values());
baseCount = allValues.reduce((sum, v) => sum + v, 0) / allValues.length;
}
const scaledCount = baseCount * 15;
const randomFactor = 0.8 + (Math.random() * 0.4);
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;
}
_addDataPoint(type) {
const now = Date.now();
const minute = Math.floor(now / 60000) * 60000;
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();
if (Math.random() < 0.1) {
this._saveToDatabase();
}
}
_cleanup() {
const now = Date.now();
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();
debug(`Cleaned up ${beforeCount - this.hourlyData.length} old data points (keeping data for ${config.email.purgeTime.time} ${config.email.purgeTime.unit})`);
}
this.lastCleanup = now;
} }
/** /**
@ -405,9 +155,7 @@ class StatisticsStore {
recordDelete() { recordDelete() {
this.currentCount = Math.max(0, this.currentCount - 1) this.currentCount = Math.max(0, this.currentCount - 1)
this._addDataPoint('delete') this._addDataPoint('delete')
debug(` debug(`Email deleted. Current: ${this.currentCount}`)
Email deleted.Current: $ { this.currentCount }
`)
} }
/** /**
@ -415,8 +163,7 @@ class StatisticsStore {
*/ */
recordForward() { recordForward() {
this._addDataPoint('forward') this._addDataPoint('forward')
debug(` debug(`Email forwarded`)
Email forwarded `)
} }
/** /**
@ -432,8 +179,7 @@ class StatisticsStore {
} }
} }
this.currentCount = count this.currentCount = count
debug(` debug(`Current count updated to ${count}`)
`)
} }
/** /**
@ -443,15 +189,15 @@ class StatisticsStore {
getStats() { getStats() {
this._cleanup() this._cleanup()
const purgeWindowStats = this._getPurgeWindowStats() const last24h = this._getLast24Hours()
return { return {
currentCount: this.currentCount, currentCount: this.currentCount,
allTimeTotal: this.largestUid, allTimeTotal: this.largestUid,
purgeWindow: { last24Hours: {
receives: purgeWindowStats.receives, receives: last24h.receives,
deletes: purgeWindowStats.deletes, deletes: last24h.deletes,
forwards: purgeWindowStats.forwards, forwards: last24h.forwards,
timeline: this._getTimeline() timeline: this._getTimeline()
} }
} }
@ -470,16 +216,11 @@ class StatisticsStore {
const now = Date.now() const now = Date.now()
if (this.enhancedStats && (now - this.lastEnhancedStatsTime) < this.enhancedStatsCacheDuration) { if (this.enhancedStats && (now - this.lastEnhancedStatsTime) < this.enhancedStatsCacheDuration) {
debug(` debug(`Using cached enhanced stats (age: ${Math.round((now - this.lastEnhancedStatsTime) / 1000)}s)`)
Using cached enhanced stats(age: $ { Math.round((now - this.lastEnhancedStatsTime) / 1000) }
s)
`)
return return
} }
debug(` debug(`Calculating enhanced statistics from ${allMails.length} emails`)
Calculating enhanced statistics from $ { allMails.length }
emails `)
// Track sender domains (privacy-friendly: domain only, not full address) // Track sender domains (privacy-friendly: domain only, not full address)
const senderDomains = new Map() const senderDomains = new Map()
@ -591,10 +332,7 @@ class StatisticsStore {
} }
this.lastEnhancedStatsTime = now this.lastEnhancedStatsTime = now
debug(` debug(`Enhanced stats calculated: ${this.enhancedStats.uniqueSenderDomains} unique sender domains, ${this.enhancedStats.busiestHours.length} busy hours`)
Enhanced stats calculated: $ { this.enhancedStats.uniqueSenderDomains }
unique sender domains, $ { this.enhancedStats.busiestHours.length }
busy hours `)
} }
/** /**
@ -610,18 +348,11 @@ class StatisticsStore {
// Check cache - if analysis was done recently, skip it // Check cache - if analysis was done recently, skip it
const now = Date.now() const now = Date.now()
if (this.historicalData && (now - this.lastAnalysisTime) < this.analysisCacheDuration) { if (this.historicalData && (now - this.lastAnalysisTime) < this.analysisCacheDuration) {
debug(` debug(`Using cached historical data (${this.historicalData.length} points, age: ${Math.round((now - this.lastAnalysisTime) / 1000)}s)`)
Using cached historical data($ { this.historicalData.length }
points, age: $ { Math.round((now - this.lastAnalysisTime) / 1000) }
s)
`)
return return
} }
debug(` debug(`Analyzing ${allMails.length} emails for historical statistics`)
Analyzing $ { allMails.length }
emails
for historical statistics `)
const startTime = Date.now() const startTime = Date.now()
// Group emails by minute // Group emails by minute
@ -651,10 +382,7 @@ class StatisticsStore {
this.lastAnalysisTime = now this.lastAnalysisTime = now
const elapsed = Date.now() - startTime const elapsed = Date.now() - startTime
debug(` debug(`Built historical data: ${this.historicalData.length} time buckets in ${elapsed}ms`)
Built historical data: $ { this.historicalData.length }
time buckets in $ { elapsed }
ms `)
} }
/** /**
@ -664,7 +392,7 @@ class StatisticsStore {
getEnhancedStats() { getEnhancedStats() {
this._cleanup() this._cleanup()
const purgeWindowStats = this._getPurgeWindowStats() const last24h = this._getLast24Hours()
const timeline = this._getTimeline() const timeline = this._getTimeline()
const historicalTimeline = this._getHistoricalTimeline() const historicalTimeline = this._getHistoricalTimeline()
const prediction = this._generatePrediction() const prediction = this._generatePrediction()
@ -678,10 +406,10 @@ class StatisticsStore {
return { return {
currentCount: this.currentCount, currentCount: this.currentCount,
allTimeTotal: this.largestUid, allTimeTotal: this.largestUid,
purgeWindow: { last24Hours: {
receives: purgeWindowStats.receives + historicalReceives, receives: last24h.receives + historicalReceives,
deletes: purgeWindowStats.deletes, deletes: last24h.deletes,
forwards: purgeWindowStats.forwards, forwards: last24h.forwards,
timeline: timeline timeline: timeline
}, },
historical: historicalTimeline, historical: historicalTimeline,
@ -697,16 +425,16 @@ class StatisticsStore {
getLightweightStats() { getLightweightStats() {
this._cleanup() this._cleanup()
const purgeWindowStats = this._getPurgeWindowStats() const last24h = this._getLast24Hours()
const timeline = this._getTimeline() const timeline = this._getTimeline()
return { return {
currentCount: this.currentCount, currentCount: this.currentCount,
allTimeTotal: this.largestUid, allTimeTotal: this.largestUid,
purgeWindow: { last24Hours: {
receives: purgeWindowStats.receives, receives: last24h.receives,
deletes: purgeWindowStats.deletes, deletes: last24h.deletes,
forwards: purgeWindowStats.forwards, forwards: last24h.forwards,
timeline: timeline timeline: timeline
} }
} }
@ -742,11 +470,7 @@ class StatisticsStore {
.map(([timestamp, receives]) => ({ timestamp, receives })) .map(([timestamp, receives]) => ({ timestamp, receives }))
.sort((a, b) => a.timestamp - b.timestamp) .sort((a, b) => a.timestamp - b.timestamp)
debug(` debug(`Historical timeline: ${intervalData.length} 15-min interval points within ${config.email.purgeTime.time} ${config.email.purgeTime.unit} window`)
Historical timeline: $ { intervalData.length }
15 - min interval points within $ { config.email.purgeTime.time }
$ { config.email.purgeTime.unit }
window `)
return intervalData return intervalData
} }
@ -786,11 +510,7 @@ class StatisticsStore {
hourlyAverages.set(hour, avg) hourlyAverages.set(hour, avg)
}) })
debug(` debug(`Built hourly patterns for ${hourlyAverages.size} hours from ${this.historicalData.length} data points`)
Built hourly patterns
for $ { hourlyAverages.size }
hours from $ { this.historicalData.length }
data points `)
// Generate predictions for a reasonable future window // Generate predictions for a reasonable future window
// Limit to 20% of purge duration or 12 hours max to maintain chart balance // Limit to 20% of purge duration or 12 hours max to maintain chart balance
@ -826,9 +546,7 @@ class StatisticsStore {
}) })
} }
debug(` debug(`Generated ${predictions.length} prediction points based on hourly patterns`)
Generated $ { predictions.length }
prediction points based on hourly patterns `)
return predictions return predictions
} }
@ -881,12 +599,7 @@ class StatisticsStore {
if (beforeCount !== this.hourlyData.length) { if (beforeCount !== this.hourlyData.length) {
this._saveToDatabase() // Save after cleanup this._saveToDatabase() // Save after cleanup
debug(` debug(`Cleaned up ${beforeCount - this.hourlyData.length} old data points (keeping data for ${config.email.purgeTime.time} ${config.email.purgeTime.unit})`)
Cleaned up $ { beforeCount - this.hourlyData.length }
old data points(keeping data
for $ { config.email.purgeTime.time }
$ { config.email.purgeTime.unit })
`)
} }
this.lastCleanup = now this.lastCleanup = now
@ -897,7 +610,16 @@ class StatisticsStore {
* @returns {Object} Aggregated counts * @returns {Object} Aggregated counts
* @private * @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) * Get timeline data for graphing (hourly aggregates)

View file

@ -1,51 +0,0 @@
// Simple recursive sanitizer middleware for API input
// Strips HTML tags from all string fields in req.body, req.query, req.params
function stripHtml(str) {
if (typeof str !== 'string') return str;
// Remove all HTML tags
return str.replace(/<[^>]*>/g, '');
}
function sanitizeObject(obj, sanitized = {}, path = '') {
let changed = false;
for (const key in obj) {
if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
const value = obj[key];
if (typeof value === 'string') {
const clean = stripHtml(value);
if (clean !== value) {
changed = true;
sanitized[path + key] = clean;
obj[key] = clean;
}
} else if (typeof value === 'object' && value !== null) {
// Recurse into objects/arrays
const subSanitized = {};
if (sanitizeObject(value, subSanitized, path + key + '.')) {
changed = true;
Object.assign(sanitized, subSanitized);
}
}
}
return changed;
}
function sanitizeMiddleware(req, res, next) {
const sanitized = {};
let changed = false;
if (req.body && typeof req.body === 'object') {
if (sanitizeObject(req.body, sanitized, 'body.')) changed = true;
}
if (req.query && typeof req.query === 'object') {
if (sanitizeObject(req.query, sanitized, 'query.')) changed = true;
}
if (req.params && typeof req.params === 'object') {
if (sanitizeObject(req.params, sanitized, 'params.')) changed = true;
}
// Attach sanitized info to request for later use in response
if (changed) req.sanitizedInput = sanitized;
next();
}
module.exports = sanitizeMiddleware;

View file

@ -341,16 +341,11 @@ function reloadStatsData() {
*/ */
function updateStatsDOM(data) { function updateStatsDOM(data) {
// Update main stat cards // Update main stat cards
const elCurrent = document.getElementById('currentCount'); document.getElementById('currentCount').textContent = data.currentCount || '0';
if (elCurrent) elCurrent.textContent = data.currentCount || '0'; document.getElementById('historicalTotal').textContent = data.allTimeTotal || '0';
const elTotal = document.getElementById('historicalTotal'); document.getElementById('receives24h').textContent = (data.last24Hours && data.last24Hours.receives) || '0';
if (elTotal) elTotal.textContent = data.allTimeTotal || '0'; document.getElementById('deletes24h').textContent = (data.last24Hours && data.last24Hours.deletes) || '0';
const elReceives = document.getElementById('receivesPurgeWindow'); document.getElementById('forwards24h').textContent = (data.last24Hours && data.last24Hours.forwards) || '0';
if (elReceives) elReceives.textContent = (data.purgeWindow && data.purgeWindow.receives) || '0';
const elDeletes = document.getElementById('deletesPurgeWindow');
if (elDeletes) elDeletes.textContent = (data.purgeWindow && data.purgeWindow.deletes) || '0';
const elForwards = document.getElementById('forwardsPurgeWindow');
if (elForwards) elForwards.textContent = (data.purgeWindow && data.purgeWindow.forwards) || '0';
// Update enhanced stats if available // Update enhanced stats if available
if (data.enhanced) { if (data.enhanced) {
@ -425,10 +420,10 @@ function updateStatsDOM(data) {
} }
// Update window data for charts // Update window data for charts
window.initialStatsData = (data.purgeWindow && data.purgeWindow.timeline) || []; window.initialStatsData = (data.last24Hours && data.last24Hours.timeline) || [];
window.historicalData = data.historical || []; window.historicalData = data.historical || [];
window.predictionData = data.prediction || []; window.predictionData = data.prediction || [];
// Rebuild chart with new data // Rebuild chart with new data
rebuildStatsChart(); rebuildStatsChart();
} }

View file

@ -52,7 +52,7 @@ router.get('/', async(req, res) => {
const placeholderStats = { const placeholderStats = {
currentCount: '...', currentCount: '...',
allTimeTotal: '...', allTimeTotal: '...',
purgeWindow: { last24Hours: {
receives: '...', receives: '...',
deletes: '...', deletes: '...',
forwards: '...', forwards: '...',
@ -129,4 +129,4 @@ router.get('/api', async(req, res) => {
} }
}) })
module.exports = router module.exports = router

View file

@ -1,16 +0,0 @@
{#
_footer-main.twig
Usage: {% include '_footer-main.twig' %}
Expects: branding, purgeTime, mailCount, config
#}
<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 }}
{% if config.http.features.statistics %}
| <a href="/stats" style="text-decoration:underline">Statistics</a>
{% else %}
| Currently handling {{ mailCount | raw }}
{% endif %}
{% if config.apiEnabled %}
| <a href="https://github.com/Crazyco-xyz/48hr.email/wiki" style="text-decoration:underline" target="_blank">API Docs</a>
{% endif %}
</h4>

View file

@ -18,14 +18,13 @@
<div id="account" class="account-container"> <div id="account" class="account-container">
<h1 class="page-title">Account Dashboard</h1> <h1 class="page-title">Account Dashboard</h1>
<p class="account-subtitle">Welcome back, <strong>{{ username }}</strong></p> <p class="account-subtitle">Welcome back, <strong>{{ username }}</strong></p>
<p class="account-subtitle">Welcome back, <strong>{{ username|sanitizeHtml }}</strong></p>
{% if successMessage %} {% if successMessage %}
<div class="alert alert-success"> <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"> <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> <path d="M20 6L9 17l-5-5"></path>
</svg> </svg>
<p>{{ successMessage|sanitizeHtml }}</p> <p>{{ successMessage }}</p>
</div> </div>
{% endif %} {% endif %}
@ -36,7 +35,7 @@
<line x1="15" y1="9" x2="9" y2="15"></line> <line x1="15" y1="9" x2="9" y2="15"></line>
<line x1="9" y1="9" x2="15" y2="15"></line> <line x1="9" y1="9" x2="15" y2="15"></line>
</svg> </svg>
<p>{{ errorMessage|sanitizeHtml }}</p> <p>{{ errorMessage }}</p>
</div> </div>
{% endif %} {% endif %}
@ -73,7 +72,7 @@
{% for email in forwardEmails %} {% for email in forwardEmails %}
<li class="email-item"> <li class="email-item">
<div class="email-info"> <div class="email-info">
<span class="email-address">{{ email.email|sanitizeHtml }}</span> <span class="email-address">{{ email.email }}</span>
<span class="email-meta">Verified {{ email.verifiedAgo }}</span> <span class="email-meta">Verified {{ email.verifiedAgo }}</span>
</div> </div>
<form method="POST" action="/account/forward-email/remove" class="inline-form"> <form method="POST" action="/account/forward-email/remove" class="inline-form">
@ -107,7 +106,7 @@
{% for inbox in lockedInboxes %} {% for inbox in lockedInboxes %}
<li class="inbox-item"> <li class="inbox-item">
<div class="inbox-info"> <div class="inbox-info">
<a href="/inbox/{{ inbox.address }}" class="inbox-address">{{ inbox.address|sanitizeHtml }}</a> <a href="/inbox/{{ inbox.address }}" class="inbox-address">{{ inbox.address }}</a>
<span class="inbox-meta">Last accessed {{ inbox.lastAccessedAgo }}</span> <span class="inbox-meta">Last accessed {{ inbox.lastAccessedAgo }}</span>
</div> </div>
<form method="POST" action="/account/locked-inbox/release" class="inline-form"> <form method="POST" action="/account/locked-inbox/release" class="inline-form">

View file

@ -26,7 +26,7 @@
<line x1="15" y1="9" x2="9" y2="15"></line> <line x1="15" y1="9" x2="9" y2="15"></line>
<line x1="9" y1="9" x2="15" y2="15"></line> <line x1="9" y1="9" x2="15" y2="15"></line>
</svg> </svg>
{{ errorMessage|sanitizeHtml }} {{ errorMessage }}
</div> </div>
{% endif %} {% endif %}
{% if successMessage %} {% if successMessage %}
@ -34,7 +34,7 @@
<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"> <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> <path d="M20 6L9 17l-5-5"></path>
</svg> </svg>
{{ successMessage|sanitizeHtml }} {{ successMessage }}
</div> </div>
{% endif %} {% endif %}
</div> </div>
@ -57,7 +57,6 @@
maxlength="20" maxlength="20"
pattern="[a-zA-Z0-9_]+" pattern="[a-zA-Z0-9_]+"
autocomplete="username" autocomplete="username"
value="{{ username|sanitizeHtml }}"
> >
<small>Letters, numbers, underscore only</small> <small>Letters, numbers, underscore only</small>

View file

@ -32,7 +32,7 @@
{% endblock %} {% endblock %}
{% block body %} {% block body %}
<h1 class="page-title">{{message|sanitizeHtml}}</h1> <h1 class="page-title">{{message}}</h1>
<h2>{{error.status|sanitizeHtml}}</h2> <h2>{{error.status}}</h2>
<pre>{{error.stack|sanitizeHtml}}</pre> <pre>{{error.stack}}</pre>
{% endblock %} {% endblock %}

View file

@ -77,7 +77,7 @@
{% endif %} {% endif %}
<div class="inbox-container"> <div class="inbox-container">
<div class="inbox-header"> <div class="inbox-header">
<h1 class="inbox-title" id="copyAddress" title="Click to copy address">{{ address|sanitizeHtml }}</h1> <h1 class="inbox-title" id="copyAddress" title="Click to copy address">{{ address }}</h1>
<button id="qrCodeBtn" class="qr-icon-btn" title="Show QR Code" aria-label="Show QR Code"> <button id="qrCodeBtn" class="qr-icon-btn" title="Show QR Code" aria-label="Show QR Code">
<svg class="qr-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <svg class="qr-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<rect x="3" y="3" width="7" height="7"/> <rect x="3" y="3" width="7" height="7"/>
@ -95,13 +95,13 @@
<div class="email-card frosted-glass"> <div class="email-card frosted-glass">
<div class="email-header"> <div class="email-header">
<div class="email-sender"> <div class="email-sender">
<div class="sender-name">{{ mail.from[0].name|sanitizeHtml }}</div> <div class="sender-name">{{ mail.from[0].name }}</div>
<div class="sender-email">{{ mail.from[0].address|sanitizeHtml }}</div> <div class="sender-email">{{ mail.from[0].address }}</div>
</div> </div>
<div class="email-date" data-date="{{ mail.date|date('c') }}"></div> <div class="email-date" data-date="{{ mail.date|date('c') }}"></div>
</div> </div>
<div class="email-subject-row"> <div class="email-subject-row">
<div class="email-subject">{{ mail.subject|sanitizeHtml }}</div> <div class="email-subject">{{ mail.subject }}</div>
<div class="email-expiry"> <div class="email-expiry">
<span class="expiry-timer" data-date="{{ mail.date|date('c') }}">Expires in ...</span> <span class="expiry-timer" data-date="{{ mail.date|date('c') }}">Expires in ...</span>
</div> </div>
@ -173,7 +173,7 @@
<span class="close" id="closeQr">&times;</span> <span class="close" id="closeQr">&times;</span>
<h3>Scan Email Address</h3> <h3>Scan Email Address</h3>
<div id="qrcode" class="qr-code-container"></div> <div id="qrcode" class="qr-code-container"></div>
<p class="qr-address-label">{{ address|sanitizeHtml }}</p> <p class="qr-address-label">{{ address }}</p>
</div> </div>
</div> </div>

View file

@ -101,10 +101,12 @@
{% block footer %} {% block footer %}
<section class="container footer"> <section class="container footer">
<hr> <hr>
{% include '_footer-main.twig' %} {% if config.http.features.statistics %}
<h4 class="container footer-two"> <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>
This project is <a href="https://github.com/crazyco-xyz/48hr.email" style="text-decoration:underline" target="_blank">open-source ♥</a> {% else %}
</h4> <h4>{{ branding[0] }} offered by <a href="{{ branding[2] }}" style="text-decoration:underline" target="_blank">{{ branding[1] }}</a> | All Emails will be deleted after {{ purgeTime | raw }} | 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> </section>
{% endblock %} {% endblock %}

View file

@ -55,7 +55,7 @@
<form method="POST" action="/" class="inbox-form"> <form method="POST" action="/" class="inbox-form">
<div class="form-group"> <div class="form-group">
<label for="nameField">Choose Your Name</label> <label for="nameField">Choose Your Name</label>
<input type="text" id="nameField" name="username" value="{{ username|sanitizeHtml }}" placeholder="e.g., john.doe" required> <input type="text" id="nameField" name="username" value="{{ username }}" placeholder="e.g., john.doe" required>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -63,7 +63,7 @@
<div class="select-wrapper"> <div class="select-wrapper">
<select id="commentField" name="domain"> <select id="commentField" name="domain">
{% for domain in domains %} {% for domain in domains %}
<option value="{{ domain|sanitizeHtml }}">@{{ domain|sanitizeHtml }}</option> <option value="{{ domain }}">@{{ domain }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>

View file

@ -64,9 +64,9 @@
{% endif %} {% endif %}
<div class="mail-container"> <div class="mail-container">
<div class="mail-header"> <div class="mail-header">
<h1 class="mail-subject">{{ mail.subject|sanitizeHtml }}</h1> <h1 class="mail-subject">{{ mail.subject }}</h1>
<div class="mail-meta"> <div class="mail-meta">
<div class="mail-from">From: {{ mail.from.text|sanitizeHtml }}</div> <div class="mail-from">From: {{ mail.from.text }}</div>
<div class="mail-date" data-date="{{ mail.date|date('c') }}"></div> <div class="mail-date" data-date="{{ mail.date|date('c') }}"></div>
</div> </div>
</div> </div>
@ -104,10 +104,10 @@
{% for crypto in cryptoAttachments %} {% for crypto in cryptoAttachments %}
<div class="crypto-item"> <div class="crypto-item">
<div class="crypto-item-header"> <div class="crypto-item-header">
<span class="crypto-type">{{ crypto.type|sanitizeHtml }}</span> <span class="crypto-type">{{ crypto.type }}</span>
<span class="crypto-filename">{{ crypto.filename|sanitizeHtml }}{% if crypto.info %} · {{ crypto.info|sanitizeHtml }}{% endif %}</span> <span class="crypto-filename">{{ crypto.filename }}{% if crypto.info %} · {{ crypto.info }}{% endif %}</span>
</div> </div>
<pre class="crypto-key-content">{{ crypto.content|sanitizeHtml }}</pre> <pre class="crypto-key-content">{{ crypto.content }}</pre>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
@ -120,7 +120,7 @@
<div class="attachments-list"> <div class="attachments-list">
{% for attachment in mail.attachments %} {% for attachment in mail.attachments %}
<a href="/inbox/{{ address }}/{{ uid }}/{{ attachment.checksum }}" class="attachment-link"> <a href="/inbox/{{ address }}/{{ uid }}/{{ attachment.checksum }}" class="attachment-link">
📎 {{ attachment.filename|sanitizeHtml }} 📎 {{ attachment.filename }}
</a> </a>
{% endfor %} {% endfor %}
</div> </div>

View file

@ -71,19 +71,19 @@
<!-- Receives (Purge Window) --> <!-- Receives (Purge Window) -->
<div class="stat-card"> <div class="stat-card">
<div class="stat-value" id="receivesPurgeWindow">{{ stats.purgeWindow.receives }}</div> <div class="stat-value" id="receives24h">{{ stats.last24Hours.receives }}</div>
<div class="stat-label">Received</div> <div class="stat-label">Received</div>
</div> </div>
<!-- Deletes (Purge Window) --> <!-- Deletes (Purge Window) -->
<div class="stat-card"> <div class="stat-card">
<div class="stat-value" id="deletesPurgeWindow">{{ stats.purgeWindow.deletes }}</div> <div class="stat-value" id="deletes24h">{{ stats.last24Hours.deletes }}</div>
<div class="stat-label">Deleted</div> <div class="stat-label">Deleted</div>
</div> </div>
<!-- Forwards (Purge Window) --> <!-- Forwards (Purge Window) -->
<div class="stat-card"> <div class="stat-card">
<div class="stat-value" id="forwardsPurgeWindow">{{ stats.purgeWindow.forwards }}</div> <div class="stat-value" id="forwards24h">{{ stats.last24Hours.forwards }}</div>
<div class="stat-label">Forwarded</div> <div class="stat-label">Forwarded</div>
</div> </div>
</div> </div>
@ -100,8 +100,8 @@
{% if stats.enhanced.topSenderDomains|length > 0 %} {% if stats.enhanced.topSenderDomains|length > 0 %}
{% for item in stats.enhanced.topSenderDomains|slice(0, 5) %} {% for item in stats.enhanced.topSenderDomains|slice(0, 5) %}
<li class="stat-list-item"> <li class="stat-list-item">
<span class="stat-list-label">{{ item.domain|sanitizeHtml }}</span> <span class="stat-list-label">{{ item.domain }}</span>
<span class="stat-list-value">{{ item.count|sanitizeHtml }}</span> <span class="stat-list-value">{{ item.count }}</span>
</li> </li>
{% endfor %} {% endfor %}
{% else %} {% else %}
@ -120,8 +120,8 @@
{% if stats.enhanced.topRecipientDomains|length > 0 %} {% if stats.enhanced.topRecipientDomains|length > 0 %}
{% for item in stats.enhanced.topRecipientDomains|slice(0, 5) %} {% for item in stats.enhanced.topRecipientDomains|slice(0, 5) %}
<li class="stat-list-item"> <li class="stat-list-item">
<span class="stat-list-label">{{ item.domain|sanitizeHtml }}</span> <span class="stat-list-label">{{ item.domain }}</span>
<span class="stat-list-value">{{ item.count|sanitizeHtml }}</span> <span class="stat-list-value">{{ item.count }}</span>
</li> </li>
{% endfor %} {% endfor %}
{% else %} {% else %}
@ -140,8 +140,8 @@
{% if stats.enhanced.busiestHours|length > 0 %} {% if stats.enhanced.busiestHours|length > 0 %}
{% for item in stats.enhanced.busiestHours %} {% for item in stats.enhanced.busiestHours %}
<li class="stat-list-item"> <li class="stat-list-item">
<span class="stat-list-label">{{ item.hour|sanitizeHtml }}:00 - {{ (item.hour + 1)|sanitizeHtml }}:00</span> <span class="stat-list-label">{{ item.hour }}:00 - {{ item.hour + 1 }}:00</span>
<span class="stat-list-value">{{ item.count|sanitizeHtml }}</span> <span class="stat-list-value">{{ item.count }}</span>
</li> </li>
{% endfor %} {% endfor %}
{% else %} {% else %}
@ -194,7 +194,7 @@
<script> <script>
// Set initial data for stats.js to consume // Set initial data for stats.js to consume
window.initialStatsData = {{ stats.purgeWindow.timeline|json_encode|raw }}; window.initialStatsData = {{ stats.last24Hours.timeline|json_encode|raw }};
window.historicalData = {{ stats.historical|json_encode|raw }}; window.historicalData = {{ stats.historical|json_encode|raw }};
window.predictionData = {{ stats.prediction|json_encode|raw }}; window.predictionData = {{ stats.prediction|json_encode|raw }};
</script> </script>

View file

@ -11,7 +11,7 @@ const helmet = require('helmet')
const socketio = require('socket.io') const socketio = require('socket.io')
const config = require('../../application/config') const config = require('../../application/config')
const createApiRouter = require('./api/router') const createApiRouter = require('../../api/router')
const inboxRouter = require('./routes/inbox') const inboxRouter = require('./routes/inbox')
const loginRouter = require('./routes/login') const loginRouter = require('./routes/login')
const errorRouter = require('./routes/error') const errorRouter = require('./routes/error')
@ -153,18 +153,6 @@ app.use((req, res, next) => {
next() next()
}) })
// Redirect /api/* to /api/v1/* if version is missing
app.use((req, res, next) => {
// Only match /api/ (not /api/v1/ or /api/v2/ etc.)
const apiMatch = req.path.match(/^\/api\/(?!v\d+\/)([^/?#]+)(.*)/)
if (apiMatch) {
// Redirect to latest stable version (v1)
const rest = apiMatch[1] + (apiMatch[2] || '')
return res.redirect(307, `/api/v1/${rest}`)
}
next()
})
// Mount API router (v1) // Mount API router (v1)
app.use('/api/v1', (req, res, next) => { app.use('/api/v1', (req, res, next) => {
const apiTokenRepository = req.app.get('apiTokenRepository') const apiTokenRepository = req.app.get('apiTokenRepository')
@ -243,4 +231,4 @@ server.on('listening', () => {
server.emit('ready') server.emit('ready')
}) })
module.exports = { app, io, server } module.exports = { app, io, server }

View file

@ -1,56 +0,0 @@
#!/bin/bash
# 48hr.email API Smoke Test
# Usage: bash api-smoke-test.sh
#
# This script tests the public API endpoints of https://48hr.email
# It will register, login, check session, get account info, and list inboxes.
#
# NOTE: This will create a test user on the public service. Change the username each run if needed.
BASE_URL="http://localhost:3000/api/v1"
USERNAME="testuser$RANDOM"
PASSWORD="TransientPass123!"
COOKIE_JAR="cookies.txt"
# Print section header with color
function print_section() {
echo -e "\n==== $1 ===="
}
print_section "Register user ($USERNAME)"
curl -s -X POST "$BASE_URL/auth/register" \
-H "Content-Type: application/json" \
-d "{\"username\":\"$USERNAME\",\"password\":\"$PASSWORD\"}" \
-c "$COOKIE_JAR"
print_section "Login user"
curl -s -X POST "$BASE_URL/auth/login" \
-H "Content-Type: application/json" \
-d "{\"username\":\"$USERNAME\",\"password\":\"$PASSWORD\"}" \
-c "$COOKIE_JAR"
print_section "Get session info"
curl -s -X GET "$BASE_URL/auth/session" \
-b "$COOKIE_JAR"
print_section "Get account info"
curl -s -X GET "$BASE_URL/account" \
-b "$COOKIE_JAR"
print_section "List inbox (should be empty)"
curl -s -X GET "$BASE_URL/inbox/$USERNAME@demo.local" \
-b "$COOKIE_JAR"
print_section "Get mail summaries (public)"
curl -s -X GET "$BASE_URL/inbox/$USERNAME@demo.local"
print_section "Get locks (should be empty)"
curl -s -X GET "$BASE_URL/locks" -b "$COOKIE_JAR"
print_section "Get stats (public)"
curl -s -X GET "$BASE_URL/stats"
print_section "Get config (public)"
curl -s -X GET "$BASE_URL/config/domains"
rm -f "$COOKIE_JAR"