mirror of
https://github.com/Crazyco-xyz/48hr.email.git
synced 2026-02-14 17:19:35 +01:00
Compare commits
7 commits
8401310062
...
d8b19dcd26
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8b19dcd26 | ||
|
|
79679af9bc | ||
|
|
8196a2f023 | ||
|
|
785de21a79 | ||
|
|
b9ab513157 | ||
|
|
f52b2b9f6c | ||
|
|
80bea65c2f |
36 changed files with 669 additions and 227 deletions
6
app.js
6
app.js
|
|
@ -207,6 +207,8 @@ 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'
|
||||||
|
|
@ -217,9 +219,9 @@ function displayStartupBanner() {
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('\n' + '═'.repeat(70))
|
console.log('\n' + '═'.repeat(70))
|
||||||
console.log(` 48hr.email - ${mode} MODE`)
|
console.log(` ${branding} - ${mode} MODE`)
|
||||||
console.log('═'.repeat(70))
|
console.log('═'.repeat(70))
|
||||||
console.log(` Server: http://localhost:${config.http.port}`)
|
console.log(` Server: ${baseUrl}`)
|
||||||
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}`)
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ function parseBool(v) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
apiEnabled: parseBool(process.env.HTTP_API_ENABLED) || true,
|
apiEnabled: parseBool(process.env.HTTP_API_ENABLED) || false,
|
||||||
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) || true
|
infoSection: parseBool(process.env.HTTP_SHOW_INFO_SECTION) || false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,152 +1,402 @@
|
||||||
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 24-hour rolling statistics for receives, deletes, and forwards
|
* Stores rolling statistics for receives, deletes, and forwards over the configured purge window
|
||||||
* 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;
|
||||||
// Current totals
|
this.largestUid = 0;
|
||||||
this.currentCount = 0
|
this.hourlyData = [];
|
||||||
this.largestUid = 0
|
this.maxDataPoints = 1440; // Default: 1440 minutes (24 hours), but actual retention is purge window
|
||||||
|
this.lastCleanup = Date.now();
|
||||||
// 24-hour rolling data (one entry per minute = 1440 entries)
|
this.historicalData = null;
|
||||||
this.hourlyData = []
|
this.lastAnalysisTime = 0;
|
||||||
this.maxDataPoints = 24 * 60 // 24 hours * 60 minutes
|
this.analysisCacheDuration = 5 * 60 * 1000; // Cache for 5 minutes
|
||||||
|
this.enhancedStats = null;
|
||||||
// Track last cleanup to avoid too frequent operations
|
this.lastEnhancedStatsTime = 0;
|
||||||
this.lastCleanup = Date.now()
|
this.enhancedStatsCacheDuration = 5 * 60 * 1000; // Cache for 5 minutes
|
||||||
|
|
||||||
// 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);
|
||||||
// Filter out stale data based on config purge time
|
const cutoff = Date.now() - this._getPurgeCutoffMs();
|
||||||
const cutoff = Date.now() - this._getPurgeCutoffMs()
|
this.hourlyData = parsed.filter(entry => entry.timestamp >= cutoff);
|
||||||
this.hourlyData = parsed.filter(entry => entry.timestamp >= cutoff)
|
debug(`Loaded ${this.hourlyData.length} hourly data points from database (cutoff: ${new Date(cutoff).toISOString()})`);
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -155,7 +405,9 @@ 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(`Email deleted. Current: ${this.currentCount}`)
|
debug(`
|
||||||
|
Email deleted.Current: $ { this.currentCount }
|
||||||
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -163,7 +415,8 @@ class StatisticsStore {
|
||||||
*/
|
*/
|
||||||
recordForward() {
|
recordForward() {
|
||||||
this._addDataPoint('forward')
|
this._addDataPoint('forward')
|
||||||
debug(`Email forwarded`)
|
debug(`
|
||||||
|
Email forwarded `)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -179,7 +432,8 @@ class StatisticsStore {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.currentCount = count
|
this.currentCount = count
|
||||||
debug(`Current count updated to ${count}`)
|
debug(`
|
||||||
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -189,15 +443,15 @@ class StatisticsStore {
|
||||||
getStats() {
|
getStats() {
|
||||||
this._cleanup()
|
this._cleanup()
|
||||||
|
|
||||||
const last24h = this._getLast24Hours()
|
const purgeWindowStats = this._getPurgeWindowStats()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentCount: this.currentCount,
|
currentCount: this.currentCount,
|
||||||
allTimeTotal: this.largestUid,
|
allTimeTotal: this.largestUid,
|
||||||
last24Hours: {
|
purgeWindow: {
|
||||||
receives: last24h.receives,
|
receives: purgeWindowStats.receives,
|
||||||
deletes: last24h.deletes,
|
deletes: purgeWindowStats.deletes,
|
||||||
forwards: last24h.forwards,
|
forwards: purgeWindowStats.forwards,
|
||||||
timeline: this._getTimeline()
|
timeline: this._getTimeline()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -216,11 +470,16 @@ 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(`Using cached enhanced stats (age: ${Math.round((now - this.lastEnhancedStatsTime) / 1000)}s)`)
|
debug(`
|
||||||
|
Using cached enhanced stats(age: $ { Math.round((now - this.lastEnhancedStatsTime) / 1000) }
|
||||||
|
s)
|
||||||
|
`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
debug(`Calculating enhanced statistics from ${allMails.length} emails`)
|
debug(`
|
||||||
|
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()
|
||||||
|
|
@ -332,7 +591,10 @@ class StatisticsStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.lastEnhancedStatsTime = now
|
this.lastEnhancedStatsTime = now
|
||||||
debug(`Enhanced stats calculated: ${this.enhancedStats.uniqueSenderDomains} unique sender domains, ${this.enhancedStats.busiestHours.length} busy hours`)
|
debug(`
|
||||||
|
Enhanced stats calculated: $ { this.enhancedStats.uniqueSenderDomains }
|
||||||
|
unique sender domains, $ { this.enhancedStats.busiestHours.length }
|
||||||
|
busy hours `)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -348,11 +610,18 @@ 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(`Using cached historical data (${this.historicalData.length} points, age: ${Math.round((now - this.lastAnalysisTime) / 1000)}s)`)
|
debug(`
|
||||||
|
Using cached historical data($ { this.historicalData.length }
|
||||||
|
points, age: $ { Math.round((now - this.lastAnalysisTime) / 1000) }
|
||||||
|
s)
|
||||||
|
`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
debug(`Analyzing ${allMails.length} emails for historical statistics`)
|
debug(`
|
||||||
|
Analyzing $ { allMails.length }
|
||||||
|
emails
|
||||||
|
for historical statistics `)
|
||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
|
|
||||||
// Group emails by minute
|
// Group emails by minute
|
||||||
|
|
@ -382,7 +651,10 @@ class StatisticsStore {
|
||||||
this.lastAnalysisTime = now
|
this.lastAnalysisTime = now
|
||||||
|
|
||||||
const elapsed = Date.now() - startTime
|
const elapsed = Date.now() - startTime
|
||||||
debug(`Built historical data: ${this.historicalData.length} time buckets in ${elapsed}ms`)
|
debug(`
|
||||||
|
Built historical data: $ { this.historicalData.length }
|
||||||
|
time buckets in $ { elapsed }
|
||||||
|
ms `)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -392,7 +664,7 @@ class StatisticsStore {
|
||||||
getEnhancedStats() {
|
getEnhancedStats() {
|
||||||
this._cleanup()
|
this._cleanup()
|
||||||
|
|
||||||
const last24h = this._getLast24Hours()
|
const purgeWindowStats = this._getPurgeWindowStats()
|
||||||
const timeline = this._getTimeline()
|
const timeline = this._getTimeline()
|
||||||
const historicalTimeline = this._getHistoricalTimeline()
|
const historicalTimeline = this._getHistoricalTimeline()
|
||||||
const prediction = this._generatePrediction()
|
const prediction = this._generatePrediction()
|
||||||
|
|
@ -406,10 +678,10 @@ class StatisticsStore {
|
||||||
return {
|
return {
|
||||||
currentCount: this.currentCount,
|
currentCount: this.currentCount,
|
||||||
allTimeTotal: this.largestUid,
|
allTimeTotal: this.largestUid,
|
||||||
last24Hours: {
|
purgeWindow: {
|
||||||
receives: last24h.receives + historicalReceives,
|
receives: purgeWindowStats.receives + historicalReceives,
|
||||||
deletes: last24h.deletes,
|
deletes: purgeWindowStats.deletes,
|
||||||
forwards: last24h.forwards,
|
forwards: purgeWindowStats.forwards,
|
||||||
timeline: timeline
|
timeline: timeline
|
||||||
},
|
},
|
||||||
historical: historicalTimeline,
|
historical: historicalTimeline,
|
||||||
|
|
@ -425,16 +697,16 @@ class StatisticsStore {
|
||||||
getLightweightStats() {
|
getLightweightStats() {
|
||||||
this._cleanup()
|
this._cleanup()
|
||||||
|
|
||||||
const last24h = this._getLast24Hours()
|
const purgeWindowStats = this._getPurgeWindowStats()
|
||||||
const timeline = this._getTimeline()
|
const timeline = this._getTimeline()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentCount: this.currentCount,
|
currentCount: this.currentCount,
|
||||||
allTimeTotal: this.largestUid,
|
allTimeTotal: this.largestUid,
|
||||||
last24Hours: {
|
purgeWindow: {
|
||||||
receives: last24h.receives,
|
receives: purgeWindowStats.receives,
|
||||||
deletes: last24h.deletes,
|
deletes: purgeWindowStats.deletes,
|
||||||
forwards: last24h.forwards,
|
forwards: purgeWindowStats.forwards,
|
||||||
timeline: timeline
|
timeline: timeline
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -470,7 +742,11 @@ 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(`Historical timeline: ${intervalData.length} 15-min interval points within ${config.email.purgeTime.time} ${config.email.purgeTime.unit} window`)
|
debug(`
|
||||||
|
Historical timeline: $ { intervalData.length }
|
||||||
|
15 - min interval points within $ { config.email.purgeTime.time }
|
||||||
|
$ { config.email.purgeTime.unit }
|
||||||
|
window `)
|
||||||
return intervalData
|
return intervalData
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -510,7 +786,11 @@ class StatisticsStore {
|
||||||
hourlyAverages.set(hour, avg)
|
hourlyAverages.set(hour, avg)
|
||||||
})
|
})
|
||||||
|
|
||||||
debug(`Built hourly patterns for ${hourlyAverages.size} hours from ${this.historicalData.length} data points`)
|
debug(`
|
||||||
|
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
|
||||||
|
|
@ -546,7 +826,9 @@ class StatisticsStore {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
debug(`Generated ${predictions.length} prediction points based on hourly patterns`)
|
debug(`
|
||||||
|
Generated $ { predictions.length }
|
||||||
|
prediction points based on hourly patterns `)
|
||||||
return predictions
|
return predictions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -599,7 +881,12 @@ class StatisticsStore {
|
||||||
|
|
||||||
if (beforeCount !== this.hourlyData.length) {
|
if (beforeCount !== this.hourlyData.length) {
|
||||||
this._saveToDatabase() // Save after cleanup
|
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})`)
|
debug(`
|
||||||
|
Cleaned up $ { beforeCount - this.hourlyData.length }
|
||||||
|
old data points(keeping data
|
||||||
|
for $ { config.email.purgeTime.time }
|
||||||
|
$ { config.email.purgeTime.unit })
|
||||||
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.lastCleanup = now
|
this.lastCleanup = now
|
||||||
|
|
@ -610,16 +897,7 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,24 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
@ -25,9 +38,13 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
@ -44,6 +61,7 @@ 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
|
||||||
51
infrastructure/web/api/middleware/sanitize.js
Normal file
51
infrastructure/web/api/middleware/sanitize.js
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
// 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;
|
||||||
|
|
@ -8,6 +8,7 @@ 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
|
||||||
|
|
@ -20,6 +21,9 @@ 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)
|
||||||
|
|
||||||
|
|
@ -7,13 +7,13 @@ Manage user accounts, forwarding emails, locked inboxes, and API tokens.
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
### GET `/api/account/`
|
### GET `/api/v1/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/account/verify-email`
|
### POST `/api/v1/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/account/verify-email/:id`
|
### DELETE `/api/v1/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/account/change-password`
|
### POST `/api/v1/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/account/`
|
### DELETE `/api/v1/account/`
|
||||||
Delete the user account.
|
Delete the user account.
|
||||||
- **Auth:** Required
|
- **Auth:** Required
|
||||||
- **Response:**
|
- **Response:**
|
||||||
- Success or error
|
- Success or error
|
||||||
|
|
||||||
### GET `/api/account/token`
|
### GET `/api/v1/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/account/token`
|
### POST `/api/v1/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/account/token`
|
### DELETE `/api/v1/account/token`
|
||||||
Revoke API token.
|
Revoke API token.
|
||||||
- **Auth:** Required
|
- **Auth:** Required
|
||||||
- **Response:**
|
- **Response:**
|
||||||
|
|
@ -45,8 +45,8 @@ function createAccountRouter(dependencies) {
|
||||||
try {
|
try {
|
||||||
const userId = req.user.id
|
const userId = req.user.id
|
||||||
|
|
||||||
// Get user stats
|
// Get user stats (pass config.user for mock repo compatibility)
|
||||||
const stats = userRepository.getUserStats(userId)
|
const stats = userRepository.getUserStats(userId, config.user)
|
||||||
|
|
||||||
// Get verified emails
|
// Get verified emails
|
||||||
const verifiedEmails = userRepository.getForwardEmails(userId)
|
const verifiedEmails = userRepository.getForwardEmails(userId)
|
||||||
|
|
@ -7,7 +7,7 @@ User registration, login, logout, and session management.
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
### POST `/api/auth/register`
|
### POST `/api/v1/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/auth/login`
|
### POST `/api/v1/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/auth/logout`
|
### POST `/api/v1/auth/logout`
|
||||||
Logout user.
|
Logout user.
|
||||||
- **Response:**
|
- **Response:**
|
||||||
- Success or error
|
- Success or error
|
||||||
|
|
||||||
### GET `/api/auth/session`
|
### GET `/api/v1/auth/session`
|
||||||
Get current session info.
|
Get current session info.
|
||||||
- **Response:**
|
- **Response:**
|
||||||
- `userId`, `username`, `isAuthenticated`, `createdAt`
|
- `userId`, `username`, `isAuthenticated`, `createdAt`
|
||||||
|
|
@ -7,20 +7,20 @@ Public endpoints for configuration, domains, limits, and features.
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
### GET `/api/config/domains`
|
### GET `/api/v1/config/domains`
|
||||||
Get allowed email domains.
|
Get allowed email domains.
|
||||||
- **Response:**
|
- **Response:**
|
||||||
- `domains`: array of strings
|
- `domains`: array of strings
|
||||||
|
|
||||||
### GET `/api/config/limits`
|
### GET `/api/v1/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/config/features`
|
### GET `/api/v1/config/features`
|
||||||
Get enabled features.
|
Get enabled features.
|
||||||
- **Response:**
|
- **Response:**
|
||||||
- `authentication`, `forwarding`, `statistics`, `inboxLocking`
|
- `authentication`, `forwarding`, `statistics`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -59,8 +59,7 @@ 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.statisticsEnabled,
|
statistics: config.http.features.statistics
|
||||||
inboxLocking: config.user.authEnabled
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -7,13 +7,13 @@ Endpoints for listing emails, retrieving full/raw emails, and downloading attach
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
### GET `/api/inbox/:address`
|
### GET `/api/v1/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/inbox/:address/:uid`
|
### GET `/api/v1/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/inbox/:address/:uid/raw`
|
### GET `/api/v1/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/inbox/:address/:uid/attachment/:checksum`
|
### GET `/api/v1/inbox/:address/:uid/attachment/:checksum`
|
||||||
Download attachment by checksum.
|
Download attachment by checksum.
|
||||||
- **Auth:** Optional
|
- **Auth:** Optional
|
||||||
- **Response:**
|
- **Response:**
|
||||||
|
|
@ -7,7 +7,7 @@ APIs for managing locked inboxes for users. All responses include a `templateCon
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
### GET `/api/locks/`
|
### GET `/api/v1/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/locks/`
|
### POST `/api/v1/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/locks/:address`
|
### DELETE `/api/v1/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/locks/:address/status`
|
### GET `/api/v1/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:**
|
||||||
|
|
@ -13,9 +13,10 @@ 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('Inbox locking is disabled', 'FEATURE_DISABLED', 503)
|
res.apiError('Authentication is required for inbox locking', 'AUTH_REQUIRED', 401)
|
||||||
})
|
})
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
@ -7,7 +7,7 @@ Endpoints for deleting emails and forwarding mail.
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
### DELETE `/api/mail/inbox/:address/:uid`
|
### DELETE `/api/v1/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/mail/inbox/:address`
|
### DELETE `/api/v1/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/mail/forward`
|
### POST `/api/v1/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/mail/forward-all`
|
### POST `/api/v1/mail/forward-all`
|
||||||
Forward all emails in an inbox.
|
Forward all emails in an inbox.
|
||||||
- **Auth:** Required
|
- **Auth:** Required
|
||||||
- **Body:**
|
- **Body:**
|
||||||
|
|
@ -7,15 +7,15 @@ Endpoints for retrieving statistics and historical data.
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
### GET `/api/stats/`
|
### GET `/api/v1/stats/`
|
||||||
Get lightweight statistics (no historical analysis).
|
Get lightweight statistics (no historical analysis).
|
||||||
- **Response:**
|
- **Response:**
|
||||||
- `currentCount`, `allTimeTotal`, `last24Hours` (object with `receives`, `deletes`, `forwards`, `timeline`)
|
- `currentCount`, `allTimeTotal`, `purgeWindow` (object with `receives`, `deletes`, `forwards`, `timeline`)
|
||||||
|
|
||||||
### GET `/api/stats/enhanced`
|
### GET `/api/v1/stats/enhanced`
|
||||||
Get full statistics with historical data and predictions.
|
Get full statistics with historical data and predictions.
|
||||||
- **Response:**
|
- **Response:**
|
||||||
- `currentCount`, `allTimeTotal`, `last24Hours`, `historical`, `prediction`, `enhanced`
|
- `currentCount`, `allTimeTotal`, `purgeWindow`, `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,
|
||||||
"last24Hours": {
|
"purgeWindow": {
|
||||||
"receives": 10,
|
"receives": 10,
|
||||||
"deletes": 2,
|
"deletes": 2,
|
||||||
"forwards": 1,
|
"forwards": 1,
|
||||||
|
|
@ -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.statisticsEnabled) {
|
if (!config.http.features.statistics) {
|
||||||
router.all('*', (req, res) => {
|
router.all('*', (req, res) => {
|
||||||
res.apiError('Statistics are disabled', 'FEATURE_DISABLED', 503)
|
res.apiError('Statistics are disabled', 'FEATURE_DISABLED', 503)
|
||||||
})
|
})
|
||||||
|
|
@ -341,11 +341,16 @@ function reloadStatsData() {
|
||||||
*/
|
*/
|
||||||
function updateStatsDOM(data) {
|
function updateStatsDOM(data) {
|
||||||
// Update main stat cards
|
// Update main stat cards
|
||||||
document.getElementById('currentCount').textContent = data.currentCount || '0';
|
const elCurrent = document.getElementById('currentCount');
|
||||||
document.getElementById('historicalTotal').textContent = data.allTimeTotal || '0';
|
if (elCurrent) elCurrent.textContent = data.currentCount || '0';
|
||||||
document.getElementById('receives24h').textContent = (data.last24Hours && data.last24Hours.receives) || '0';
|
const elTotal = document.getElementById('historicalTotal');
|
||||||
document.getElementById('deletes24h').textContent = (data.last24Hours && data.last24Hours.deletes) || '0';
|
if (elTotal) elTotal.textContent = data.allTimeTotal || '0';
|
||||||
document.getElementById('forwards24h').textContent = (data.last24Hours && data.last24Hours.forwards) || '0';
|
const elReceives = document.getElementById('receivesPurgeWindow');
|
||||||
|
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) {
|
||||||
|
|
@ -420,7 +425,7 @@ function updateStatsDOM(data) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update window data for charts
|
// Update window data for charts
|
||||||
window.initialStatsData = (data.last24Hours && data.last24Hours.timeline) || [];
|
window.initialStatsData = (data.purgeWindow && data.purgeWindow.timeline) || [];
|
||||||
window.historicalData = data.historical || [];
|
window.historicalData = data.historical || [];
|
||||||
window.predictionData = data.prediction || [];
|
window.predictionData = data.prediction || [];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ router.get('/', async(req, res) => {
|
||||||
const placeholderStats = {
|
const placeholderStats = {
|
||||||
currentCount: '...',
|
currentCount: '...',
|
||||||
allTimeTotal: '...',
|
allTimeTotal: '...',
|
||||||
last24Hours: {
|
purgeWindow: {
|
||||||
receives: '...',
|
receives: '...',
|
||||||
deletes: '...',
|
deletes: '...',
|
||||||
forwards: '...',
|
forwards: '...',
|
||||||
|
|
|
||||||
16
infrastructure/web/views/_footer-main.twig
Normal file
16
infrastructure/web/views/_footer-main.twig
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{#
|
||||||
|
_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>
|
||||||
|
|
@ -18,13 +18,14 @@
|
||||||
<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 }}</p>
|
<p>{{ successMessage|sanitizeHtml }}</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
@ -35,7 +36,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 }}</p>
|
<p>{{ errorMessage|sanitizeHtml }}</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
@ -72,7 +73,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 }}</span>
|
<span class="email-address">{{ email.email|sanitizeHtml }}</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">
|
||||||
|
|
@ -106,7 +107,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 }}</a>
|
<a href="/inbox/{{ inbox.address }}" class="inbox-address">{{ inbox.address|sanitizeHtml }}</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">
|
||||||
|
|
|
||||||
|
|
@ -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 }}
|
{{ errorMessage|sanitizeHtml }}
|
||||||
</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 }}
|
{{ successMessage|sanitizeHtml }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -57,6 +57,7 @@
|
||||||
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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<h1 class="page-title">{{message}}</h1>
|
<h1 class="page-title">{{message|sanitizeHtml}}</h1>
|
||||||
<h2>{{error.status}}</h2>
|
<h2>{{error.status|sanitizeHtml}}</h2>
|
||||||
<pre>{{error.stack}}</pre>
|
<pre>{{error.stack|sanitizeHtml}}</pre>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -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 }}</h1>
|
<h1 class="inbox-title" id="copyAddress" title="Click to copy address">{{ address|sanitizeHtml }}</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 }}</div>
|
<div class="sender-name">{{ mail.from[0].name|sanitizeHtml }}</div>
|
||||||
<div class="sender-email">{{ mail.from[0].address }}</div>
|
<div class="sender-email">{{ mail.from[0].address|sanitizeHtml }}</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 }}</div>
|
<div class="email-subject">{{ mail.subject|sanitizeHtml }}</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">×</span>
|
<span class="close" id="closeQr">×</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 }}</p>
|
<p class="qr-address-label">{{ address|sanitizeHtml }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -101,12 +101,10 @@
|
||||||
{% block footer %}
|
{% block footer %}
|
||||||
<section class="container footer">
|
<section class="container footer">
|
||||||
<hr>
|
<hr>
|
||||||
{% if config.http.features.statistics %}
|
{% include '_footer-main.twig' %}
|
||||||
<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>
|
<h4 class="container footer-two">
|
||||||
{% else %}
|
This project is <a href="https://github.com/crazyco-xyz/48hr.email" style="text-decoration:underline" target="_blank">open-source ♥</a>
|
||||||
<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>
|
</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 %}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }}" placeholder="e.g., john.doe" required>
|
<input type="text" id="nameField" name="username" value="{{ username|sanitizeHtml }}" 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 }}">@{{ domain }}</option>
|
<option value="{{ domain|sanitizeHtml }}">@{{ domain|sanitizeHtml }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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 }}</h1>
|
<h1 class="mail-subject">{{ mail.subject|sanitizeHtml }}</h1>
|
||||||
<div class="mail-meta">
|
<div class="mail-meta">
|
||||||
<div class="mail-from">From: {{ mail.from.text }}</div>
|
<div class="mail-from">From: {{ mail.from.text|sanitizeHtml }}</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 }}</span>
|
<span class="crypto-type">{{ crypto.type|sanitizeHtml }}</span>
|
||||||
<span class="crypto-filename">{{ crypto.filename }}{% if crypto.info %} · {{ crypto.info }}{% endif %}</span>
|
<span class="crypto-filename">{{ crypto.filename|sanitizeHtml }}{% if crypto.info %} · {{ crypto.info|sanitizeHtml }}{% endif %}</span>
|
||||||
</div>
|
</div>
|
||||||
<pre class="crypto-key-content">{{ crypto.content }}</pre>
|
<pre class="crypto-key-content">{{ crypto.content|sanitizeHtml }}</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 }}
|
📎 {{ attachment.filename|sanitizeHtml }}
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -71,19 +71,19 @@
|
||||||
|
|
||||||
<!-- Receives (Purge Window) -->
|
<!-- Receives (Purge Window) -->
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-value" id="receives24h">{{ stats.last24Hours.receives }}</div>
|
<div class="stat-value" id="receivesPurgeWindow">{{ stats.purgeWindow.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="deletes24h">{{ stats.last24Hours.deletes }}</div>
|
<div class="stat-value" id="deletesPurgeWindow">{{ stats.purgeWindow.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="forwards24h">{{ stats.last24Hours.forwards }}</div>
|
<div class="stat-value" id="forwardsPurgeWindow">{{ stats.purgeWindow.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 }}</span>
|
<span class="stat-list-label">{{ item.domain|sanitizeHtml }}</span>
|
||||||
<span class="stat-list-value">{{ item.count }}</span>
|
<span class="stat-list-value">{{ item.count|sanitizeHtml }}</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 }}</span>
|
<span class="stat-list-label">{{ item.domain|sanitizeHtml }}</span>
|
||||||
<span class="stat-list-value">{{ item.count }}</span>
|
<span class="stat-list-value">{{ item.count|sanitizeHtml }}</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 }}:00 - {{ item.hour + 1 }}:00</span>
|
<span class="stat-list-label">{{ item.hour|sanitizeHtml }}:00 - {{ (item.hour + 1)|sanitizeHtml }}:00</span>
|
||||||
<span class="stat-list-value">{{ item.count }}</span>
|
<span class="stat-list-value">{{ item.count|sanitizeHtml }}</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.last24Hours.timeline|json_encode|raw }};
|
window.initialStatsData = {{ stats.purgeWindow.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>
|
||||||
|
|
|
||||||
|
|
@ -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,6 +153,18 @@ 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')
|
||||||
|
|
|
||||||
56
scripts/api-smoke-test.sh
Executable file
56
scripts/api-smoke-test.sh
Executable file
|
|
@ -0,0 +1,56 @@
|
||||||
|
#!/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"
|
||||||
Loading…
Add table
Reference in a new issue