[Feat]: UX Debug mode

Adds UX debug mode while mocking the imap server and other critical parts of the service simply to test UI elements for faster development
This commit is contained in:
ClaraCrazy 2026-01-05 08:45:26 +01:00
parent 16ccc978f9
commit a7691ccf43
No known key found for this signature in database
GPG key ID: EBBC896ACB497011
13 changed files with 963 additions and 105 deletions

153
app.js
View file

@ -25,21 +25,30 @@ const helper = new(Helper)
const { app, io, server } = require('./infrastructure/web/web')
const ClientNotification = require('./infrastructure/web/client-notification')
const ImapService = require('./application/imap-service')
const MockMailService = require('./application/mocks/mock-mail-service')
const MailProcessingService = require('./application/mail-processing-service')
const SmtpService = require('./application/smtp-service')
const AuthService = require('./application/auth-service')
const MockAuthService = require('./application/mocks/mock-auth-service')
const MailRepository = require('./domain/mail-repository')
const InboxLock = require('./domain/inbox-lock')
const MockInboxLock = require('./application/mocks/mock-inbox-lock')
const VerificationStore = require('./domain/verification-store')
const UserRepository = require('./domain/user-repository')
const MockUserRepository = require('./application/mocks/mock-user-repository')
const StatisticsStore = require('./domain/statistics-store')
const clientNotification = new ClientNotification()
debug('Client notification service initialized')
clientNotification.use(io)
const smtpService = new SmtpService(config)
debug('SMTP service initialized')
// Initialize SMTP service only if not in UX debug mode
const smtpService = config.uxDebugMode ? null : new SmtpService(config)
if (smtpService) {
debug('SMTP service initialized')
} else {
debug('SMTP service disabled (UX debug mode)')
}
app.set('smtpService', smtpService)
const verificationStore = new VerificationStore()
@ -53,7 +62,7 @@ app.set('config', config)
let inboxLock = null
let statisticsStore = null
if (config.user.authEnabled) {
if (config.user.authEnabled && !config.uxDebugMode) {
// Migrate legacy database files for backwards compatibility
Helper.migrateDatabase(config.user.databasePath)
@ -90,22 +99,50 @@ if (config.user.authEnabled) {
})
}
}, config.imap.refreshIntervalSeconds * 1000)
console.log('User authentication system enabled')
} else {
// No auth enabled - initialize statistics store without persistence
// No auth enabled OR UX debug mode - initialize statistics store without persistence
statisticsStore = new StatisticsStore()
debug('Statistics store initialized (in-memory only, no database)')
app.set('statisticsStore', statisticsStore)
if (config.uxDebugMode) {
debug('Statistics store initialized (UX debug mode - clean slate)')
// In UX debug mode, create mock auth system
const mockUserRepository = new MockUserRepository(config)
debug('Mock user repository initialized')
app.set('userRepository', mockUserRepository)
const mockAuthService = new MockAuthService()
debug('Mock auth service initialized')
app.set('authService', mockAuthService)
inboxLock = new MockInboxLock(mockUserRepository)
app.set('inboxLock', inboxLock)
debug('Mock inbox lock service initialized')
debug('Mock authentication system enabled for UX debug mode')
} else {
debug('Statistics store initialized (in-memory only, no database)')
app.set('userRepository', null)
app.set('authService', null)
app.set('inboxLock', null)
debug('User authentication system disabled')
}
app.set('statisticsStore', statisticsStore)
if (!config.uxDebugMode) {
debug('User authentication system disabled')
}
}
const imapService = new ImapService(config, inboxLock)
debug('IMAP service initialized')
// Initialize IMAP or Mock service based on debug mode
const imapService = config.uxDebugMode ?
new MockMailService(config) :
new ImapService(config, inboxLock)
if (config.uxDebugMode) {
debug('Mock Mail Service initialized (UX Debug Mode)')
} else {
debug('IMAP service initialized')
}
app.set('imapService', imapService)
const mailProcessingService = new MailProcessingService(
@ -121,13 +158,29 @@ debug('Mail processing service initialized')
// Initialize statistics with current count
imapService.on(ImapService.EVENT_INITIAL_LOAD_DONE, async() => {
// In UX debug mode, populate mock emails first
if (config.uxDebugMode) {
// Load mock emails into repository
const mockEmails = imapService.getMockEmails()
mockEmails.forEach(({ mail }) => {
mailProcessingService.onNewMail(mail)
})
debug(`UX Debug Mode: Loaded ${mockEmails.length} mock emails`)
}
// Then initialize statistics with the correct count
const count = mailProcessingService.getCount()
statisticsStore.initialize(count)
if (config.uxDebugMode) {
statisticsStore.updateLargestUid(2) // 2 mock emails
debug(`UX Debug Mode: Statistics initialized with ${count} emails, largest UID: 2`)
} else {
// Get and set the largest UID for all-time total
const largestUid = await helper.getLargestUid(imapService)
statisticsStore.updateLargestUid(largestUid)
debug(`Statistics initialized with ${count} emails, largest UID: ${largestUid}`)
}
})
// Set up timer sync broadcasting after IMAP is ready
@ -135,6 +188,69 @@ imapService.on(ImapService.EVENT_INITIAL_LOAD_DONE, () => {
clientNotification.startTimerSync(imapService)
})
// Display startup banner when everything is ready
let imapReady = false
let serverReady = false
function displayStartupBanner() {
if (!imapReady || !serverReady) return
const mailCount = mailProcessingService.getCount()
const domains = config.email.domains.join(', ')
const purgeTime = `${config.email.purgeTime.time} ${config.email.purgeTime.unit}`
const refreshInterval = config.uxDebugMode ? 'N/A' : `${config.imap.refreshIntervalSeconds}s`
// Determine mode based on environment
let mode = 'PRODUCTION'
if (config.uxDebugMode) {
mode = 'UX DEBUG'
} else if (process.env.DEBUG) {
mode = 'DEBUG'
}
console.log('\n' + '═'.repeat(70))
console.log(` 48hr.email - ${mode} MODE`)
console.log('═'.repeat(70))
console.log(` Server: http://localhost:${config.http.port}`)
console.log(` Domains: ${domains}`)
console.log(` Emails loaded: ${mailCount}`)
console.log(` Purge after: ${purgeTime}`)
console.log(` IMAP refresh: ${refreshInterval}`)
if (!config.uxDebugMode && config.email.examples.account && config.email.examples.uids) {
console.log(` Example inbox: ${config.email.examples.account}`)
console.log(` Example UIDs: ${config.email.examples.uids.join(', ')}`)
}
if (config.uxDebugMode) {
console.log(` Authentication: Mock (any username/password works)`)
const mockUserRepo = app.get('userRepository')
if (mockUserRepo) {
console.log(` Demo forward: ${mockUserRepo.mockForwardEmail}`)
console.log(` Demo locked: ${mockUserRepo.mockLockedInbox}`)
}
} else if (config.user.authEnabled) {
console.log(` Authentication: Enabled`)
}
if (config.http.features.statistics) {
console.log(` Statistics: Enabled`)
}
console.log('═'.repeat(70))
console.log(` Ready! Press Ctrl+C to stop\n`)
}
imapService.on(ImapService.EVENT_INITIAL_LOAD_DONE, () => {
imapReady = true
displayStartupBanner()
})
server.on('ready', () => {
serverReady = true
displayStartupBanner()
})
// Track IMAP initialization state
let isImapReady = false
app.set('isImapReady', false)
@ -158,13 +274,17 @@ debug('Bound IMAP deleted mail event handler')
mailProcessingService.on('error', err => {
debug('Fatal error from mail processing service:', err.message)
console.error('Error from mailProcessingService, stopping.', err)
if (!config.uxDebugMode) {
process.exit(1)
}
})
imapService.on(ImapService.EVENT_ERROR, error => {
debug('Fatal error from IMAP service:', error.message)
console.error('Fatal error from IMAP service', error)
if (!config.uxDebugMode) {
process.exit(1)
}
})
app.set('mailProcessingService', mailProcessingService)
@ -173,11 +293,18 @@ app.set('config', config)
app.locals.imapService = imapService
app.locals.mailProcessingService = mailProcessingService
debug('Starting IMAP connection and message loading')
if (config.uxDebugMode) {
debug('Starting Mock Mail Service (UX Debug Mode)')
} else {
debug('Starting IMAP connection and message loading')
}
imapService.connectAndLoadMessages().catch(error => {
debug('Failed to connect to IMAP:', error.message)
console.error('Fatal error from IMAP service', error)
debug('Failed to connect:', error.message)
console.error('Fatal error from mail service', error)
if (!config.uxDebugMode) {
process.exit(1)
}
})
server.on('error', error => {

View file

@ -37,6 +37,9 @@ function parseBool(v) {
}
const config = {
// UX Debug Mode
uxDebugMode: parseBool(process.env.UX_DEBUG_MODE) || false,
email: {
domains: parseValue(process.env.EMAIL_DOMAINS) || [],
purgeTime: {
@ -107,9 +110,26 @@ const config = {
// validation
debug('Validating configuration...')
if (!config.imap.user || !config.imap.password || !config.imap.host) {
// Skip IMAP validation in UX debug mode
if (!config.uxDebugMode) {
if (!config.imap.user || !config.imap.password || !config.imap.host) {
debug('IMAP configuration validation failed: missing user, password, or host')
throw new Error("IMAP is not configured. Check IMAP_* env vars.");
}
}
// In UX debug mode, provide default domain if none configured
if (config.uxDebugMode && !config.email.domains.length) {
config.email.domains = ['ux-debug.local']
debug('UX Debug Mode: Using default domain "ux-debug.local"')
}
// In UX debug mode, set example account to show mock emails
if (config.uxDebugMode) {
config.email.examples.account = `demo@${config.email.domains[0]}`
config.email.examples.uids = [1, 2]
debug(`UX Debug Mode: Example account set to ${config.email.examples.account} with UIDs 1, 2`)
}
if (!config.email.domains.length) {
@ -117,6 +137,6 @@ if (!config.email.domains.length) {
throw new Error("No EMAIL_DOMAINS configured.");
}
debug(`Configuration validated successfully: ${config.email.domains.length} domains, IMAP host: ${config.imap.host}`)
debug(`Configuration validated successfully: ${config.email.domains.length} domains${config.uxDebugMode ? ' (UX DEBUG MODE)' : ''}`)
module.exports = config;

View file

@ -140,10 +140,8 @@ class MailProcessingService extends EventEmitter {
onInitialLoadDone() {
this.initialLoadDone = true
debug('Initial load completed, total mails:', this.mailRepository.mailCount())
console.log(`Initial load done, got ${this.mailRepository.mailCount()} mails`)
console.log(`Fetching and deleting mails every ${this.config.imap.refreshIntervalSeconds} seconds`)
console.log(`Mails older than ${this.config.email.purgeTime.time} ${this.config.email.purgeTime.unit} will be deleted`)
console.log(`The example emails are: ${this.config.email.examples.uids.join(', ')}, on the account ${this.config.email.examples.account}`)
// Don't print old-style logs here, app.js will handle the startup banner
}
onNewMail(mail) {

View file

@ -0,0 +1,37 @@
/**
* Mock Auth Service for UX Debug Mode
* Provides dummy authentication without database
*/
const crypto = require('crypto')
class MockAuthService {
constructor() {
// Mock user data
this.mockUser = {
id: 1,
username: 'demo',
password_hash: 'mock', // Any password works
created_at: Date.now() - 86400000, // 1 day ago
last_login: Date.now()
}
}
async login(username, password) {
// Accept any username/password in debug mode
return {
success: true,
user: this.mockUser
}
}
async register(username, password) {
// Accept any registration
return {
success: true,
user: this.mockUser
}
}
}
module.exports = MockAuthService

View file

@ -0,0 +1,125 @@
/**
* Mock Inbox Lock for UX Debug Mode
* Provides dummy inbox locking without database
*/
const debug = require('debug')('48hr-email:mock-inbox-lock')
class MockInboxLock {
constructor(mockUserRepository) {
this.mockUserRepository = mockUserRepository
this.locks = new Map()
// Initialize locks from repository
this._initializeLocks()
debug(`Mock locked inboxes: ${Array.from(mockUserRepository.lockedInboxes).join(', ')}`)
}
_initializeLocks() {
// Add the mock locked inboxes from the repository
for (const address of this.mockUserRepository.lockedInboxes) {
this.locks.set(address.toLowerCase(), {
userId: 1,
address: address.toLowerCase(),
lockedAt: Date.now(),
lastAccess: Date.now()
})
}
}
// Reset to initial state (called when repository resets)
reset() {
this.locks.clear()
this._initializeLocks()
debug('Mock inbox locks reset to initial state')
}
isLocked(address) {
return this.locks.has(address.toLowerCase())
}
hasAccess(userId, address) {
const lock = this.locks.get(address.toLowerCase())
if (!lock) return true // Not locked
return lock.userId === userId
}
isLockedByUser(address, userId) {
const lock = this.locks.get(address.toLowerCase())
if (!lock) return false
return lock.userId === userId
}
lock(userId, address) {
const normalizedAddress = address.toLowerCase()
if (this.locks.has(normalizedAddress)) {
throw new Error('Inbox is already locked')
}
this.locks.set(normalizedAddress, {
userId,
address: normalizedAddress,
lockedAt: Date.now(),
lastAccess: Date.now()
})
this.mockUserRepository.lockedInboxes.add(address)
debug(`Locked inbox: ${normalizedAddress}`)
return true
}
release(userId, address) {
const normalizedAddress = address.toLowerCase()
const lock = this.locks.get(normalizedAddress)
if (!lock) {
throw new Error('Inbox is not locked')
}
if (lock.userId !== userId) {
throw new Error('You do not own this lock')
}
this.locks.delete(normalizedAddress)
this.mockUserRepository.lockedInboxes.delete(address)
debug(`Released lock on ${normalizedAddress}`)
return true
}
updateAccess(userId, address) {
const lock = this.locks.get(address.toLowerCase())
if (lock && lock.userId === userId) {
lock.lastAccess = Date.now()
}
}
getUserLockedInboxes(userId) {
const userLocks = []
for (const [address, lock] of this.locks.entries()) {
if (lock.userId === userId) {
userLocks.push({
address: address,
locked_at: lock.lockedAt,
last_access: lock.lastAccess
})
}
}
return userLocks
}
getInactive(hours) {
// Mock - return empty array
return []
}
getUserLockCount(userId) {
let count = 0
for (const lock of this.locks.values()) {
if (lock.userId === userId) count++
}
return count
}
}
module.exports = MockInboxLock

View file

@ -0,0 +1,364 @@
/**
* Mock Mail Service for UX Debug Mode
* Provides sample emails without requiring IMAP/SMTP connections
*/
const Mail = require('../../domain/mail')
const EventEmitter = require('events')
const path = require('path')
const fs = require('fs')
// Clara's PGP Public Key
const CLARA_PGP_KEY = `-----BEGIN PGP PUBLIC KEY BLOCK-----
mQENBGb92JcBCADNMGkl6x2e//Prbbyvlb3EE6BwfOdKpSa+70bJ8fsudlkas5VN
Iyuq6Kmuk8V9LD5qBL3e0SMe2x3K5xb+j0Wim/n0OIHkbdnAOtLqEbYMSAzL3keo
mqw5qbV47js3rxht9BZ2HYZm5GqOqLz4XuIomSS/EsDcuQDKVtKveE2nRkJUIORr
C+DNFcjgJc3yrF1bKE3KQz2ii7qNRH/ChKRXB+OS/7ZviQOSTlFVPGhjIxaI2sRI
Uw8U8pWPYyQzh+dpiA3OmzbF1/BB2AQOx98p975KTI4wmalF5PtsKnkFFZ1NPKC6
E6G0IIbDkEE1HBpEO4qmIuWd/tFyIP03EwL3ABEBAAG0G0NsYXJhIEsgPGNsYXJh
QGNyYXp5Y28ueHl6PokBSgQQAQgAHQUCZv3YlwQLCQcIAxUICgQWAAIBAhkBAhsD
Ah4BACEJEAGLYq6lsVaPFiEEmKN22IQaxMpTgI1sAYtirqWxVo8IOAf9HJglE8hQ
bqGtbCISKGOkeIq8TFr9A2MRaevNVQtf4o9TnzMi+9nFGfi6yniiceBz9oUWoXvt
ZkhEzc0Hn6PAX/sOW3r6wPu5gSaGjUJfd339aDasyZvdOoQ4cukcErIaFnAG7KmP
0Q7lyRp5K7dUmuc9b9dg5ngf+M8306dj/djHWCPtsaLJc7ExrfeT1lNA7MeY7DlE
9jdvm4hfwQZND16nXKKLZn/RZUkR5Zoo1LE+GSL0/GCFZeH1PnEt5kcI3QKyx9wn
+DlMcAZCVs2X5JzTbJQKr9Cwv1syOlaZmVeUTuKiTfAB71wINQkFHdmONIg0h9wp
ThTjXOlDsQvnP7kBDQRm/diXAQgAg8BaBpL//o62UrrbQ79WbVzVTH+2f+xVD8bE
tyEL1QjllFfPdc5oT9nQ5RPfN6IJpbN0/p688pQa10gFgjEN0WtI51Vda/PQ1FQ8
q1xXbH6zJXP3FAPEPTId4Rw7Gb+vaUaBo3O0ZyKpAxzEy2gIvXz2ChfL6ENn5QZ/
1DsBeQQE3YbgG+jXAL//JGjINoppOTCfnEMlKaZYdkLvA2KiJKqtD+JDTVFkdk02
1Jext8Td6wkd72i0+DQI9RaJJr5oDXlxAN0iX4OMSdo35e2Mj4AktjvO8JzRvZjU
uPCGYH9DpVoB0OCNRmD/2CeUyQgiehk8NHXLxf8h1duTGZYYRQARAQABiQE2BBgB
CAAJBQJm/diXAhsMACEJEAGLYq6lsVaPFiEEmKN22IQaxMpTgI1sAYtirqWxVo/R
cQgAmJ0taRUkOmoepQR6JNJejF1JiITQy5eSvZzXDBoEWip4fcl4FRAy5yz6s/sC
NtweWyWMg3+lu+s7qh3r1Qw5EN7ukgUy+fvk6xY3TBxcJ1aC/KvKbaeTrZt0Bt6U
sQipNDI/cPkL2ILzqt/shEgj9g/EWARe1X5SQ0nEhCYLi7xZV9lBe3dU+EUlmwSe
gmxppMfACd9hyVV4SbO6l5NKmXgkYWNMzFzjfg3pxAPuJjaaYN85XETqpKwdfPRt
KUPuyh+UdOt8GPRBcFxjRJQrBRw2nBJxCCEJOJAJJ2ySpHQBwpaXsK0WW2SGkaxF
ggOCb56KkepgTvU3Xdv5opRZAg==
=HEe7
-----END PGP PUBLIC KEY BLOCK-----`
class MockMailService extends EventEmitter {
constructor(config) {
super()
this.config = config
this.mockEmails = this._generateMockEmails()
this.logoAttachment = this._getLogoAttachment()
}
_getLogoAttachment() {
// Try to read the service logo
const logoPath = path.join(__dirname, '../infrastructure/web/public/images/logo.png')
if (fs.existsSync(logoPath)) {
return {
filename: '48hr-email-logo.png',
content: fs.readFileSync(logoPath),
contentType: 'image/png'
}
}
return null
}
_generateMockEmails() {
const domain = this.config.email.domains[0]
const now = new Date()
const earlier = new Date(now.getTime() - 3600000) // 1 hour ago
return [{
mail: Mail.create(
[`demo@${domain}`], [{ name: 'Clara K', address: 'clara@crazyco.xyz' }],
earlier.toISOString(),
'Welcome to 48hr.email - Plain Text Demo',
1
),
fullMail: {
text: `Hello from 48hr.email!
This is a plain text demonstration email for UX debugging purposes.
48hr.email is your favorite open-source temporary email service, created by ClaraCrazy.
Features:
- Disposable email addresses
- No registration required
- Auto-delete after configured time
- Open source (GPL-3.0)
- Self-hostable
For more information, visit: https://48hr.email
GitHub: https://github.com/Crazyco-xyz/48hr.email
Discord: https://discord.gg/crazyco
---
Clara's PGP Public Key:
${CLARA_PGP_KEY}
---
This is a mock email generated for UX debug mode.
No actual IMAP or SMTP connections were used.`,
textAsHtml: `<p>Hello from 48hr.email!</p>
<p>This is a plain text demonstration email for UX debugging purposes.</p>
<p>48hr.email is your favorite open-source temporary email service, created by ClaraCrazy.</p>
<p>Features:<br/>
- Disposable email addresses<br/>
- No registration required<br/>
- Auto-delete after configured time<br/>
- Open source (GPL-3.0)<br/>
- Self-hostable</p>
<p>For more information, visit: <a href="https://48hr.email">https://48hr.email</a><br/>
GitHub: <a href="https://github.com/Crazyco-xyz/48hr.email">https://github.com/Crazyco-xyz/48hr.email</a><br/>
Discord: <a href="https://discord.gg/crazyco">https://discord.gg/crazyco</a></p>
<p>---<br/>
Clara's PGP Public Key:</p>
<pre style="background: #1a1a1a; color: #666; padding: 12px; border-radius: 6px; border: 1px solid rgba(155, 77, 202, 0.2);">${CLARA_PGP_KEY}</pre>
<p>---</p>
<p>This is a mock email generated for UX debug mode.<br/>
No actual IMAP or SMTP connections were used.</p>`,
html: null,
subject: 'Welcome to 48hr.email - Plain Text Demo',
from: { text: 'Clara K <clara@crazyco.xyz>' },
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: `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.5;
color: #e0e0e0;
background: #131516;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid rgba(155, 77, 202, 0.3);
}
h1 {
color: #9b4dca;
font-size: 2rem;
margin: 0;
font-weight: 600;
}
.subtitle {
color: #888;
font-size: 0.95rem;
margin: 0;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 20px;
}
.section {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
padding: 15px;
}
h2 {
color: #9b4dca;
font-size: 1.2rem;
margin: 0 0 10px 0;
font-weight: 500;
}
p {
margin: 0 0 10px 0;
color: #cccccc;
font-size: 0.95rem;
}
.feature-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin: 10px 0 0 0;
}
.feature-item {
padding: 6px 10px;
background: rgba(155, 77, 202, 0.08);
border-radius: 4px;
font-size: 0.9rem;
color: #cccccc;
}
.pgp-key {
background: #1a1a1a;
border: 1px solid rgba(155, 77, 202, 0.2);
border-radius: 6px;
padding: 12px;
font-family: 'Courier New', monospace;
font-size: 0.65rem;
color: #666;
overflow-x: auto;
white-space: pre;
line-height: 1.3;
max-height: 180px;
overflow-y: auto;
}
.footer {
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
text-align: center;
color: #666;
font-size: 0.85rem;
}
a {
color: #9b4dca;
text-decoration: none;
}
a:hover {
color: #b366e6;
}
</style>
</head>
<body>
<div class="header">
<div>
<h1>48hr.email</h1>
<p class="subtitle">Temporary inbox, no registration</p>
</div>
</div>
<div class="grid">
<div class="section">
<h2>About</h2>
<p>Open-source temporary email service. Create disposable addresses instantly and receive emails without registration. Emails auto-delete after the configured purge time.</p>
</div>
<div class="section">
<h2>Features</h2>
<div class="feature-grid">
<div class="feature-item">Instant addresses</div>
<div class="feature-item">No registration</div>
<div class="feature-item">Real-time updates</div>
<div class="feature-item">HTML rendering</div>
<div class="feature-item">Open source GPL-3.0</div>
<div class="feature-item">Self-hostable</div>
</div>
</div>
</div>
<div class="section">
<h2>Developer PGP Key (ClaraCrazy)</h2>
<div class="pgp-key">${CLARA_PGP_KEY}</div>
</div>
<div class="footer">
<p><strong>48hr.email</strong> by <a href="https://crazyco.xyz">ClaraCrazy</a> &middot; <a href="https://github.com/Crazyco-xyz/48hr.email">GitHub</a> &middot; <a href="https://discord.gg/crazyco">Discord</a> &middot; UX Debug Mode</p>
</div>
</body>
</html>`,
subject: 'HTML Email Demo - Features Overview',
from: { text: '48hr.email <noreply@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

View file

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

View file

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

View file

@ -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: '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';
},
{
label: 'Current Activity',
data: realtimePoints,
borderColor: '#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 = `
<button class="legend-btn active" data-index="0">
<span class="legend-indicator" style="background: rgba(100, 149, 237, 0.8);"></span>
<span class="legend-label">Historical</span>
<span class="legend-indicator" style="background: linear-gradient(to right, rgba(100, 149, 237, 0.8) 0%, #2ecc71 100%);"></span>
<span class="legend-label">Email Activity</span>
</button>
<button class="legend-btn active" data-index="1">
<span class="legend-indicator" style="background: #2ecc71;"></span>
<span class="legend-label">Current Activity</span>
</button>
<button class="legend-btn active" data-index="2">
<span class="legend-indicator" style="background: #ff9f43; border: 2px dashed rgba(255, 159, 67, 0.5);"></span>
<span class="legend-label">Predicted</span>
</button>
@ -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();

View file

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

View file

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

View file

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

View file

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