This is a mock email generated for UX debug mode.
+No actual IMAP or SMTP connections were used.
`,
+ html: null,
+ subject: 'Welcome to 48hr.email - Plain Text Demo',
+ from: { text: 'Clara K ' },
+ to: { text: `demo@${domain}` },
+ date: earlier,
+ attachments: this.logoAttachment ? [this.logoAttachment] : []
+ }
+ },
+ {
+ mail: Mail.create(
+ [`demo@${domain}`], [{ name: '48hr.email', address: 'noreply@48hr.email' }],
+ now.toISOString(),
+ 'HTML Email Demo - Features Overview',
+ 2
+ ),
+ fullMail: {
+ text: `48hr.email - HTML Email Demo
+
+This is the plain text version of the HTML email.
+
+Visit https://48hr.email for more information.
+
+Clara's PGP Key is attached to this email.`,
+ html: `
+
+
+
+
+
+
+
+
+
48hr.email
+
Temporary inbox, no registration
+
+
+
+
+
+
About
+
Open-source temporary email service. Create disposable addresses instantly and receive emails without registration. Emails auto-delete after the configured purge time.
+
+
+
+
Features
+
+
Instant addresses
+
No registration
+
Real-time updates
+
HTML rendering
+
Open source GPL-3.0
+
Self-hostable
+
+
+
+
+
+
Developer PGP Key (ClaraCrazy)
+
${CLARA_PGP_KEY}
+
+
+
+
+`,
+ subject: 'HTML Email Demo - Features Overview',
+ from: { text: '48hr.email ' },
+ to: { text: `demo@${domain}` },
+ date: now,
+ attachments: this.logoAttachment ? [this.logoAttachment] : []
+ }
+ }
+ ]
+ }
+
+ async connectAndLoadMessages() {
+ // Simulate async loading
+ await new Promise(resolve => setTimeout(resolve, 500))
+
+ // Emit initial load event
+ this.emit('initial load done')
+
+ return Promise.resolve()
+ }
+
+ getMockEmails() {
+ return this.mockEmails
+ }
+
+ async fetchOneFullMail(to, uid, raw = false) {
+ const email = this.mockEmails.find(e => e.mail.uid === parseInt(uid))
+ if (!email) {
+ throw new Error(`Mock email with UID ${uid} not found`)
+ }
+
+ // If raw is requested, return a string representation
+ if (raw) {
+ const mail = email.fullMail
+ const headers = [
+ `From: ${mail.from.text}`,
+ `To: ${mail.to.text}`,
+ `Date: ${mail.date}`,
+ `Subject: ${mail.subject}`,
+ `Content-Type: ${mail.html ? 'text/html; charset=UTF-8' : 'text/plain; charset=UTF-8'}`,
+ '',
+ mail.html || mail.text || ''
+ ]
+ return headers.join('\n')
+ }
+
+ return email.fullMail
+ }
+
+ // Stub methods for compatibility
+ deleteMessage() {
+ return Promise.resolve()
+ }
+
+ deleteOldMails() {
+ return Promise.resolve()
+ }
+
+ closeBox() {
+ return Promise.resolve()
+ }
+
+ getSecondsUntilNextRefresh() {
+ // In mock mode, return null (no refresh needed)
+ return null
+ }
+
+ async getLargestUid() {
+ // Return the largest UID from mock emails
+ const mockEmails = this.getMockEmails()
+ if (mockEmails.length === 0) return null
+ return Math.max(...mockEmails.map(e => e.mail.uid))
+ }
+
+ on(event, handler) {
+ return super.on(event, handler)
+ }
+}
+
+MockMailService.EVENT_INITIAL_LOAD_DONE = 'initial load done'
+MockMailService.EVENT_NEW_MAIL = 'mail'
+MockMailService.EVENT_DELETED_MAIL = 'mailDeleted'
+MockMailService.EVENT_ERROR = 'error'
+
+module.exports = MockMailService
diff --git a/application/mocks/mock-user-repository.js b/application/mocks/mock-user-repository.js
new file mode 100644
index 0000000..bb39ac9
--- /dev/null
+++ b/application/mocks/mock-user-repository.js
@@ -0,0 +1,131 @@
+/**
+ * Mock User Repository for UX Debug Mode
+ * Provides dummy user data without database
+ */
+
+const debug = require('debug')('48hr-email:mock-user-repo')
+
+class MockUserRepository {
+ constructor(config) {
+ this.config = config
+
+ // Generate a random forwarding email (fixed for this server instance)
+ const randomGmailName = Math.random().toString(36).substring(2, 10)
+ this.mockForwardEmail = `${randomGmailName}@gmail.com`
+
+ // Generate a random locked inbox (fixed for this server instance)
+ const randomWords = ['alpha', 'beta', 'gamma', 'delta', 'omega', 'sigma', 'theta']
+ const word1 = randomWords[Math.floor(Math.random() * randomWords.length)]
+ const word2 = randomWords[Math.floor(Math.random() * randomWords.length)]
+ const num = Math.floor(Math.random() * 999)
+ this.mockLockedInbox = `${word1}${word2}${num}@${config.email.domains[0]}`
+
+ // Store the initial values to reset to
+ this.initialForwardEmail = this.mockForwardEmail
+ this.initialLockedInbox = this.mockLockedInbox
+
+ // In-memory storage that can be modified during a session
+ this.forwardEmails = new Set([this.mockForwardEmail])
+ this.lockedInboxes = new Set([this.mockLockedInbox])
+
+ debug(`Mock forward email: ${this.mockForwardEmail}`)
+ debug(`Mock locked inbox: ${this.mockLockedInbox}`)
+ }
+
+ // Reset to initial state (called on new page loads)
+ reset() {
+ this.forwardEmails = new Set([this.initialForwardEmail])
+ this.lockedInboxes = new Set([this.initialLockedInbox])
+ debug('Mock data reset to initial state')
+ }
+
+ // User methods
+ getUserById(userId) {
+ if (userId === 1) {
+ return {
+ id: 1,
+ username: 'demo',
+ password_hash: 'mock',
+ created_at: Date.now() - 86400000,
+ last_login: Date.now()
+ }
+ }
+ return null
+ }
+
+ getUserByUsername(username) {
+ return this.getUserById(1)
+ }
+
+ updateLastLogin(userId) {
+ // No-op in mock
+ return true
+ }
+
+ // Forward email methods
+ getForwardEmails(userId) {
+ if (userId === 1) {
+ const emails = []
+ let id = 1
+ for (const email of this.forwardEmails) {
+ emails.push({
+ id: id++,
+ user_id: 1,
+ email: email,
+ verified: true,
+ verification_token: null,
+ created_at: Date.now() - 3600000
+ })
+ }
+ return emails
+ }
+ return []
+ }
+
+ addForwardEmail(userId, email, token) {
+ this.forwardEmails.add(email)
+ return {
+ id: this.forwardEmails.size,
+ user_id: userId,
+ email: email,
+ verified: false,
+ verification_token: token,
+ created_at: Date.now()
+ }
+ }
+
+ verifyForwardEmail(token) {
+ // In mock mode, just return success
+ return true
+ }
+
+ removeForwardEmail(userId, email) {
+ const deleted = this.forwardEmails.delete(email)
+ debug(`Removed forward email: ${email} (success: ${deleted})`)
+ return deleted
+ }
+
+ deleteForwardEmail(userId, email) {
+ // Alias for removeForwardEmail
+ return this.removeForwardEmail(userId, email)
+ }
+
+ // User stats
+ getUserStats(userId, config) {
+ return {
+ lockedInboxesCount: this.lockedInboxes.size,
+ forwardEmailsCount: this.forwardEmails.size,
+ accountAge: Math.floor((Date.now() - (Date.now() - 86400000)) / 86400000),
+ maxLockedInboxes: config.maxLockedInboxes || 5,
+ maxForwardEmails: config.maxForwardEmails || 5,
+ lockReleaseHours: config.lockReleaseHours || 720
+ }
+ }
+
+ // Cleanup - no-op
+ close() {
+ debug('Mock user repository closed')
+ }
+}
+
+module.exports = MockUserRepository
diff --git a/domain/statistics-store.js b/domain/statistics-store.js
index 14b5539..1c68ba9 100644
--- a/domain/statistics-store.js
+++ b/domain/statistics-store.js
@@ -455,23 +455,23 @@ class StatisticsStore {
const cutoff = Date.now() - this._getPurgeCutoffMs()
const relevantHistory = this.historicalData.filter(point => point.timestamp >= cutoff)
- // Aggregate by hour
- const hourlyBuckets = new Map()
+ // Aggregate by 15-minute intervals for better granularity
+ const intervalBuckets = new Map()
relevantHistory.forEach(point => {
- const hour = Math.floor(point.timestamp / 3600000) * 3600000
- if (!hourlyBuckets.has(hour)) {
- hourlyBuckets.set(hour, 0)
+ const interval = Math.floor(point.timestamp / 900000) * 900000 // 15 minutes
+ if (!intervalBuckets.has(interval)) {
+ intervalBuckets.set(interval, 0)
}
- hourlyBuckets.set(hour, hourlyBuckets.get(hour) + point.receives)
+ intervalBuckets.set(interval, intervalBuckets.get(interval) + point.receives)
})
// Convert to array and sort
- const hourlyData = Array.from(hourlyBuckets.entries())
+ const intervalData = Array.from(intervalBuckets.entries())
.map(([timestamp, receives]) => ({ timestamp, receives }))
.sort((a, b) => a.timestamp - b.timestamp)
- debug(`Historical timeline: ${hourlyData.length} hourly points within ${config.email.purgeTime.time} ${config.email.purgeTime.unit} window`)
- return hourlyData
+ debug(`Historical timeline: ${intervalData.length} 15-min interval points within ${config.email.purgeTime.time} ${config.email.purgeTime.unit} window`)
+ return intervalData
}
/**
@@ -512,12 +512,16 @@ class StatisticsStore {
debug(`Built hourly patterns for ${hourlyAverages.size} hours from ${this.historicalData.length} data points`)
- // Generate predictions for purge duration (in 1-hour intervals)
+ // Generate predictions for a reasonable future window
+ // Limit to 20% of purge duration or 12 hours max to maintain chart balance
+ // Use 15-minute intervals for better granularity
const purgeMs = this._getPurgeCutoffMs()
- const predictionHours = Math.ceil(purgeMs / (60 * 60 * 1000))
+ const purgeDurationHours = Math.ceil(purgeMs / (60 * 60 * 1000))
+ const predictionHours = Math.min(12, Math.ceil(purgeDurationHours * 0.2))
+ const predictionIntervals = predictionHours * 4 // Convert hours to 15-min intervals
- for (let i = 1; i <= predictionHours; i++) {
- const timestamp = now + (i * 60 * 60 * 1000) // 1 hour intervals
+ for (let i = 1; i <= predictionIntervals; i++) {
+ const timestamp = now + (i * 15 * 60 * 1000) // 15 minute intervalsals
const futureDate = new Date(timestamp)
const futureHour = futureDate.getHours()
@@ -529,8 +533,8 @@ class StatisticsStore {
baseCount = allValues.reduce((sum, v) => sum + v, 0) / allValues.length
}
- // baseCount is already per-minute average, scale to full hour
- const scaledCount = baseCount * 60
+ // baseCount is already per-minute average, scale to 15 minutes
+ const scaledCount = baseCount * 15
// Add randomization (±20%)
const randomFactor = 0.8 + (Math.random() * 0.4) // 0.8 to 1.2
@@ -626,23 +630,23 @@ class StatisticsStore {
_getTimeline() {
const now = Date.now()
const cutoff = now - this._getPurgeCutoffMs()
- const hourly = {}
+ const buckets = {}
- // Aggregate by hour
+ // Aggregate by 15-minute intervals for better granularity
this.hourlyData
.filter(e => e.timestamp >= cutoff)
.forEach(entry => {
- const hour = Math.floor(entry.timestamp / 3600000) * 3600000
- if (!hourly[hour]) {
- hourly[hour] = { timestamp: hour, receives: 0, deletes: 0, forwards: 0 }
+ const interval = Math.floor(entry.timestamp / 900000) * 900000 // 15 minutes
+ if (!buckets[interval]) {
+ buckets[interval] = { timestamp: interval, receives: 0, deletes: 0, forwards: 0 }
}
- hourly[hour].receives += entry.receives
- hourly[hour].deletes += entry.deletes
- hourly[hour].forwards += entry.forwards
+ buckets[interval].receives += entry.receives
+ buckets[interval].deletes += entry.deletes
+ buckets[interval].forwards += entry.forwards
})
// Convert to sorted array
- return Object.values(hourly).sort((a, b) => a.timestamp - b.timestamp)
+ return Object.values(buckets).sort((a, b) => a.timestamp - b.timestamp)
}
}
diff --git a/infrastructure/web/public/javascripts/stats.js b/infrastructure/web/public/javascripts/stats.js
index 6e1a371..8332df9 100644
--- a/infrastructure/web/public/javascripts/stats.js
+++ b/infrastructure/web/public/javascripts/stats.js
@@ -8,6 +8,7 @@ let statsChart = null;
let chartContext = null;
let lastReloadTime = 0;
const RELOAD_COOLDOWN_MS = 2000; // 2 second cooldown between reloads
+let allTimePoints = []; // Store globally so segment callbacks can access it
// Initialize stats chart if on stats page
document.addEventListener('DOMContentLoaded', function() {
@@ -39,10 +40,14 @@ document.addEventListener('DOMContentLoaded', function() {
// Combine all data and create labels
const now = Date.now();
- // Use a reasonable historical window (show data within the purge time range)
- // This will adapt based on whether purge time is 48 hours, 7 days, etc.
- const allTimePoints = [
- ...historicalData.map(d => ({...d, type: 'historical' })),
+ // Merge historical and realtime into a single continuous dataset
+ // Historical will be blue, current will be green
+ // Only show historical data that doesn't overlap with realtime (exclude any matching timestamps)
+ const realtimeTimestamps = new Set(realtimeData.map(d => d.timestamp));
+ const filteredHistorical = historicalData.filter(d => !realtimeTimestamps.has(d.timestamp));
+
+ allTimePoints = [
+ ...filteredHistorical.map(d => ({...d, type: 'historical' })),
...realtimeData.map(d => ({...d, type: 'realtime' })),
...predictionData.map(d => ({...d, type: 'prediction' }))
].sort((a, b) => a.timestamp - b.timestamp);
@@ -58,47 +63,53 @@ document.addEventListener('DOMContentLoaded', function() {
});
});
- // Prepare datasets
- const historicalPoints = allTimePoints.map(d => d.type === 'historical' ? d.receives : null);
- const realtimePoints = allTimePoints.map(d => d.type === 'realtime' ? d.receives : null);
+ // Merge historical and realtime into one dataset with segment coloring
+ const combinedPoints = allTimePoints.map(d =>
+ (d.type === 'historical' || d.type === 'realtime') ? d.receives : null
+ );
const predictionPoints = allTimePoints.map(d => d.type === 'prediction' ? d.receives : null);
// Create gradient for fading effect on historical data
const ctx = chartCanvas.getContext('2d');
chartContext = ctx;
- const historicalGradient = ctx.createLinearGradient(0, 0, chartCanvas.width * 0.3, 0);
- historicalGradient.addColorStop(0, 'rgba(100, 100, 255, 0.05)');
- historicalGradient.addColorStop(1, 'rgba(100, 100, 255, 0.15)');
// Track visibility state for each dataset
- const datasetVisibility = [true, true, true];
+ const datasetVisibility = [true, true];
statsChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
- label: 'Historical',
- data: historicalPoints,
- borderColor: 'rgba(100, 149, 237, 0.8)',
- backgroundColor: historicalGradient,
- borderWidth: 2,
- tension: 0.4,
- pointRadius: 4,
- pointBackgroundColor: 'rgba(100, 149, 237, 0.8)',
- spanGaps: true,
- fill: true,
- hidden: false
- },
- {
- label: 'Current Activity',
- data: realtimePoints,
- borderColor: '#2ecc71',
+ label: 'Email Activity',
+ data: combinedPoints,
+ segment: {
+ borderColor: (context) => {
+ const index = context.p0DataIndex;
+ const point = allTimePoints[index];
+ // Blue for historical, green for current
+ return point && point.type === 'historical' ? 'rgba(100, 149, 237, 0.8)' : '#2ecc71';
+ },
+ backgroundColor: (context) => {
+ const index = context.p0DataIndex;
+ const point = allTimePoints[index];
+ return point && point.type === 'historical' ? 'rgba(100, 149, 237, 0.15)' : 'rgba(46, 204, 113, 0.15)';
+ }
+ },
+ borderColor: '#2ecc71', // Default to green
backgroundColor: 'rgba(46, 204, 113, 0.15)',
- borderWidth: 4,
+ borderWidth: 3,
tension: 0.4,
- pointRadius: 4,
- pointBackgroundColor: '#2ecc71',
+ pointRadius: (context) => {
+ const index = context.dataIndex;
+ const point = allTimePoints[index];
+ return point && point.type === 'historical' ? 3 : 4;
+ },
+ pointBackgroundColor: (context) => {
+ const index = context.dataIndex;
+ const point = allTimePoints[index];
+ return point && point.type === 'historical' ? 'rgba(100, 149, 237, 0.8)' : '#2ecc71';
+ },
spanGaps: true,
fill: true,
hidden: false
@@ -198,14 +209,10 @@ document.addEventListener('DOMContentLoaded', function() {
legendContainer.className = 'chart-legend-custom';
legendContainer.innerHTML = `
-
@@ -256,8 +263,12 @@ function rebuildStatsChart() {
const historicalData = window.historicalData || [];
const predictionData = window.predictionData || [];
- const allTimePoints = [
- ...historicalData.map(d => ({...d, type: 'historical' })),
+ // Only show historical data that doesn't overlap with realtime (exclude any matching timestamps)
+ const realtimeTimestamps = new Set(realtimeData.map(d => d.timestamp));
+ const filteredHistorical = historicalData.filter(d => !realtimeTimestamps.has(d.timestamp));
+
+ allTimePoints = [
+ ...filteredHistorical.map(d => ({...d, type: 'historical' })),
...realtimeData.map(d => ({...d, type: 'realtime' })),
...predictionData.map(d => ({...d, type: 'prediction' }))
].sort((a, b) => a.timestamp - b.timestamp);
@@ -277,16 +288,16 @@ function rebuildStatsChart() {
});
});
- // Prepare datasets
- const historicalPoints = allTimePoints.map(d => d.type === 'historical' ? d.receives : null);
- const realtimePoints = allTimePoints.map(d => d.type === 'realtime' ? d.receives : null);
+ // Merge historical and realtime into one dataset with segment coloring
+ const combinedPoints = allTimePoints.map(d =>
+ (d.type === 'historical' || d.type === 'realtime') ? d.receives : null
+ );
const predictionPoints = allTimePoints.map(d => d.type === 'prediction' ? d.receives : null);
// Update chart data
statsChart.data.labels = labels;
- statsChart.data.datasets[0].data = historicalPoints;
- statsChart.data.datasets[1].data = realtimePoints;
- statsChart.data.datasets[2].data = predictionPoints;
+ statsChart.data.datasets[0].data = combinedPoints;
+ statsChart.data.datasets[1].data = predictionPoints;
// Update the chart
statsChart.update();
diff --git a/infrastructure/web/public/stylesheets/custom.css b/infrastructure/web/public/stylesheets/custom.css
index 383b77d..07557f3 100644
--- a/infrastructure/web/public/stylesheets/custom.css
+++ b/infrastructure/web/public/stylesheets/custom.css
@@ -1842,6 +1842,9 @@ label {
border: 1px solid var(--overlay-white-10);
width: 100%;
min-height: 40vh;
+ max-height: 100vh;
+ resize: vertical;
+ overflow: auto;
}
.mail-text-content {
diff --git a/infrastructure/web/routes/account.js b/infrastructure/web/routes/account.js
index 7a30b62..4600e53 100644
--- a/infrastructure/web/routes/account.js
+++ b/infrastructure/web/routes/account.js
@@ -8,12 +8,28 @@ const templateContext = require('../template-context')
// GET /account - Account dashboard
router.get('/account', requireAuth, async(req, res) => {
try {
+ const config = req.app.get('config')
const userRepository = req.app.get('userRepository')
const inboxLock = req.app.get('inboxLock')
const mailProcessingService = req.app.get('mailProcessingService')
const Helper = require('../../../application/helper')
const helper = new Helper()
+ // In UX debug mode, reset mock data to initial state only on fresh page load
+ // (not on redirects after form submissions)
+ if (config.uxDebugMode && userRepository && userRepository.reset) {
+ // Check if this is a redirect from a form submission
+ const isRedirect = req.session.accountSuccess || req.session.accountError
+
+ if (!isRedirect) {
+ // This is a fresh page load, reset to initial state
+ userRepository.reset()
+ if (inboxLock && inboxLock.reset) {
+ inboxLock.reset()
+ }
+ }
+ }
+
// Get user's verified forwarding emails
const forwardEmails = userRepository.getForwardEmails(req.session.userId)
@@ -24,7 +40,6 @@ router.get('/account', requireAuth, async(req, res) => {
}
// Get user stats
- const config = req.app.get('config')
const stats = userRepository.getUserStats(req.session.userId, config.user)
const successMessage = req.session.accountSuccess
@@ -225,6 +240,13 @@ router.post('/account/change-password',
body('confirmNewPassword').notEmpty().withMessage('Password confirmation is required'),
async(req, res) => {
try {
+ const config = req.app.get('config')
+ // Block password change in UX debug mode
+ if (config.uxDebugMode) {
+ req.session.accountError = 'Password changes are disabled in UX debug mode'
+ return res.redirect('/account')
+ }
+
const errors = validationResult(req)
if (!errors.isEmpty()) {
req.session.accountError = errors.array()[0].msg
@@ -278,6 +300,13 @@ router.post('/account/delete',
body('confirmText').equals('DELETE').withMessage('You must type DELETE to confirm'),
async(req, res) => {
try {
+ const config = req.app.get('config')
+ // Block account deletion in UX debug mode
+ if (config.uxDebugMode) {
+ req.session.accountError = 'Account deletion is disabled in UX debug mode'
+ return res.redirect('/account')
+ }
+
const errors = validationResult(req)
if (!errors.isEmpty()) {
req.session.accountError = errors.array()[0].msg
@@ -321,4 +350,4 @@ router.post('/account/delete',
}
)
-module.exports = router
+module.exports = router
\ No newline at end of file
diff --git a/infrastructure/web/web.js b/infrastructure/web/web.js
index 8319e7f..6d13e72 100644
--- a/infrastructure/web/web.js
+++ b/infrastructure/web/web.js
@@ -37,7 +37,12 @@ const server = http.createServer(app)
const io = socketio(server)
app.set('socketio', io)
-app.use(logger('dev'))
+
+// HTTP request logging - only enable with DEBUG environment variable
+if (process.env.DEBUG && process.env.DEBUG.includes('48hr-email')) {
+ app.use(logger('dev'))
+}
+
app.use(express.json())
app.use(express.urlencoded({ extended: false }))
@@ -200,6 +205,9 @@ server.on('listening', () => {
const addr = server.address()
const bind = typeof addr === 'string' ? 'pipe ' + addr : 'port ' + addr.port
debug('Listening on ' + bind)
+
+ // Emit event for app.js to display startup banner
+ server.emit('ready')
})
module.exports = { app, io, server }
diff --git a/package.json b/package.json
index d65d17c..63dd1c2 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "48hr.email",
- "version": "2.2.0",
+ "version": "2.2.1",
"private": false,
"description": "48hr.email is your favorite open-source tempmail client.",
"keywords": [
@@ -27,6 +27,7 @@
"scripts": {
"start": "node --trace-warnings ./app.js",
"debug": "DEBUG=48hr-email:* node --nolazy --inspect-brk=9229 --trace-warnings ./app.js",
+ "ux-debug": "UX_DEBUG_MODE=true node --trace-warnings ./app.js",
"test": "xo",
"env:check": "node scripts/check-env.js"
},
@@ -80,4 +81,4 @@
]
}]
}
-}
+}
\ No newline at end of file