[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

175
app.js
View file

@ -25,21 +25,30 @@ const helper = new(Helper)
const { app, io, server } = require('./infrastructure/web/web') const { app, io, server } = require('./infrastructure/web/web')
const ClientNotification = require('./infrastructure/web/client-notification') const ClientNotification = require('./infrastructure/web/client-notification')
const ImapService = require('./application/imap-service') const ImapService = require('./application/imap-service')
const MockMailService = require('./application/mocks/mock-mail-service')
const MailProcessingService = require('./application/mail-processing-service') const MailProcessingService = require('./application/mail-processing-service')
const SmtpService = require('./application/smtp-service') const SmtpService = require('./application/smtp-service')
const AuthService = require('./application/auth-service') const AuthService = require('./application/auth-service')
const MockAuthService = require('./application/mocks/mock-auth-service')
const MailRepository = require('./domain/mail-repository') const MailRepository = require('./domain/mail-repository')
const InboxLock = require('./domain/inbox-lock') const InboxLock = require('./domain/inbox-lock')
const MockInboxLock = require('./application/mocks/mock-inbox-lock')
const VerificationStore = require('./domain/verification-store') const VerificationStore = require('./domain/verification-store')
const UserRepository = require('./domain/user-repository') const UserRepository = require('./domain/user-repository')
const MockUserRepository = require('./application/mocks/mock-user-repository')
const StatisticsStore = require('./domain/statistics-store') const StatisticsStore = require('./domain/statistics-store')
const clientNotification = new ClientNotification() const clientNotification = new ClientNotification()
debug('Client notification service initialized') debug('Client notification service initialized')
clientNotification.use(io) clientNotification.use(io)
const smtpService = new SmtpService(config) // Initialize SMTP service only if not in UX debug mode
debug('SMTP service initialized') 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) app.set('smtpService', smtpService)
const verificationStore = new VerificationStore() const verificationStore = new VerificationStore()
@ -53,7 +62,7 @@ app.set('config', config)
let inboxLock = null let inboxLock = null
let statisticsStore = null let statisticsStore = null
if (config.user.authEnabled) { if (config.user.authEnabled && !config.uxDebugMode) {
// Migrate legacy database files for backwards compatibility // Migrate legacy database files for backwards compatibility
Helper.migrateDatabase(config.user.databasePath) Helper.migrateDatabase(config.user.databasePath)
@ -90,22 +99,50 @@ if (config.user.authEnabled) {
}) })
} }
}, config.imap.refreshIntervalSeconds * 1000) }, config.imap.refreshIntervalSeconds * 1000)
console.log('User authentication system enabled')
} else { } else {
// No auth enabled - initialize statistics store without persistence // No auth enabled OR UX debug mode - initialize statistics store without persistence
statisticsStore = new StatisticsStore() statisticsStore = new StatisticsStore()
debug('Statistics store initialized (in-memory only, no database)') 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) app.set('statisticsStore', statisticsStore)
app.set('userRepository', null) if (!config.uxDebugMode) {
app.set('authService', null) debug('User authentication system disabled')
app.set('inboxLock', null) }
debug('User authentication system disabled')
} }
const imapService = new ImapService(config, inboxLock) // Initialize IMAP or Mock service based on debug mode
debug('IMAP service initialized') 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) app.set('imapService', imapService)
const mailProcessingService = new MailProcessingService( const mailProcessingService = new MailProcessingService(
@ -121,13 +158,29 @@ debug('Mail processing service initialized')
// Initialize statistics with current count // Initialize statistics with current count
imapService.on(ImapService.EVENT_INITIAL_LOAD_DONE, async() => { 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() const count = mailProcessingService.getCount()
statisticsStore.initialize(count) statisticsStore.initialize(count)
// Get and set the largest UID for all-time total if (config.uxDebugMode) {
const largestUid = await helper.getLargestUid(imapService) statisticsStore.updateLargestUid(2) // 2 mock emails
statisticsStore.updateLargestUid(largestUid) debug(`UX Debug Mode: Statistics initialized with ${count} emails, largest UID: 2`)
debug(`Statistics initialized with ${count} emails, largest UID: ${largestUid}`) } 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 // Set up timer sync broadcasting after IMAP is ready
@ -135,6 +188,69 @@ imapService.on(ImapService.EVENT_INITIAL_LOAD_DONE, () => {
clientNotification.startTimerSync(imapService) 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 // Track IMAP initialization state
let isImapReady = false let isImapReady = false
app.set('isImapReady', false) app.set('isImapReady', false)
@ -158,13 +274,17 @@ debug('Bound IMAP deleted mail event handler')
mailProcessingService.on('error', err => { mailProcessingService.on('error', err => {
debug('Fatal error from mail processing service:', err.message) debug('Fatal error from mail processing service:', err.message)
console.error('Error from mailProcessingService, stopping.', err) console.error('Error from mailProcessingService, stopping.', err)
process.exit(1) if (!config.uxDebugMode) {
process.exit(1)
}
}) })
imapService.on(ImapService.EVENT_ERROR, error => { imapService.on(ImapService.EVENT_ERROR, error => {
debug('Fatal error from IMAP service:', error.message) debug('Fatal error from IMAP service:', error.message)
console.error('Fatal error from IMAP service', error) console.error('Fatal error from IMAP service', error)
process.exit(1) if (!config.uxDebugMode) {
process.exit(1)
}
}) })
app.set('mailProcessingService', mailProcessingService) app.set('mailProcessingService', mailProcessingService)
@ -173,11 +293,18 @@ app.set('config', config)
app.locals.imapService = imapService app.locals.imapService = imapService
app.locals.mailProcessingService = mailProcessingService 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 => { imapService.connectAndLoadMessages().catch(error => {
debug('Failed to connect to IMAP:', error.message) debug('Failed to connect:', error.message)
console.error('Fatal error from IMAP service', error) console.error('Fatal error from mail service', error)
process.exit(1) if (!config.uxDebugMode) {
process.exit(1)
}
}) })
server.on('error', error => { server.on('error', error => {
@ -200,4 +327,4 @@ server.on('error', error => {
console.error('Fatal web server error', error) console.error('Fatal web server error', error)
process.exit(1) process.exit(1)
} }
}) })

View file

@ -37,6 +37,9 @@ function parseBool(v) {
} }
const config = { const config = {
// UX Debug Mode
uxDebugMode: parseBool(process.env.UX_DEBUG_MODE) || false,
email: { email: {
domains: parseValue(process.env.EMAIL_DOMAINS) || [], domains: parseValue(process.env.EMAIL_DOMAINS) || [],
purgeTime: { purgeTime: {
@ -107,9 +110,26 @@ const config = {
// validation // validation
debug('Validating configuration...') debug('Validating configuration...')
if (!config.imap.user || !config.imap.password || !config.imap.host) {
debug('IMAP configuration validation failed: missing user, password, or host') // Skip IMAP validation in UX debug mode
throw new Error("IMAP is not configured. Check IMAP_* env vars."); 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) { if (!config.email.domains.length) {
@ -117,6 +137,6 @@ if (!config.email.domains.length) {
throw new Error("No EMAIL_DOMAINS configured."); 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; module.exports = config;

View file

@ -140,10 +140,8 @@ class MailProcessingService extends EventEmitter {
onInitialLoadDone() { onInitialLoadDone() {
this.initialLoadDone = true this.initialLoadDone = true
debug('Initial load completed, total mails:', this.mailRepository.mailCount()) 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`) // Don't print old-style logs here, app.js will handle the startup banner
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}`)
} }
onNewMail(mail) { 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 cutoff = Date.now() - this._getPurgeCutoffMs()
const relevantHistory = this.historicalData.filter(point => point.timestamp >= cutoff) const relevantHistory = this.historicalData.filter(point => point.timestamp >= cutoff)
// Aggregate by hour // Aggregate by 15-minute intervals for better granularity
const hourlyBuckets = new Map() const intervalBuckets = new Map()
relevantHistory.forEach(point => { relevantHistory.forEach(point => {
const hour = Math.floor(point.timestamp / 3600000) * 3600000 const interval = Math.floor(point.timestamp / 900000) * 900000 // 15 minutes
if (!hourlyBuckets.has(hour)) { if (!intervalBuckets.has(interval)) {
hourlyBuckets.set(hour, 0) 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 // Convert to array and sort
const hourlyData = Array.from(hourlyBuckets.entries()) const intervalData = Array.from(intervalBuckets.entries())
.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: ${hourlyData.length} hourly 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 hourlyData return intervalData
} }
/** /**
@ -512,12 +512,16 @@ class StatisticsStore {
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 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 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++) { for (let i = 1; i <= predictionIntervals; i++) {
const timestamp = now + (i * 60 * 60 * 1000) // 1 hour intervals const timestamp = now + (i * 15 * 60 * 1000) // 15 minute intervalsals
const futureDate = new Date(timestamp) const futureDate = new Date(timestamp)
const futureHour = futureDate.getHours() const futureHour = futureDate.getHours()
@ -529,8 +533,8 @@ class StatisticsStore {
baseCount = allValues.reduce((sum, v) => sum + v, 0) / allValues.length baseCount = allValues.reduce((sum, v) => sum + v, 0) / allValues.length
} }
// baseCount is already per-minute average, scale to full hour // baseCount is already per-minute average, scale to 15 minutes
const scaledCount = baseCount * 60 const scaledCount = baseCount * 15
// Add randomization (±20%) // Add randomization (±20%)
const randomFactor = 0.8 + (Math.random() * 0.4) // 0.8 to 1.2 const randomFactor = 0.8 + (Math.random() * 0.4) // 0.8 to 1.2
@ -626,23 +630,23 @@ class StatisticsStore {
_getTimeline() { _getTimeline() {
const now = Date.now() const now = Date.now()
const cutoff = now - this._getPurgeCutoffMs() const cutoff = now - this._getPurgeCutoffMs()
const hourly = {} const buckets = {}
// Aggregate by hour // Aggregate by 15-minute intervals for better granularity
this.hourlyData this.hourlyData
.filter(e => e.timestamp >= cutoff) .filter(e => e.timestamp >= cutoff)
.forEach(entry => { .forEach(entry => {
const hour = Math.floor(entry.timestamp / 3600000) * 3600000 const interval = Math.floor(entry.timestamp / 900000) * 900000 // 15 minutes
if (!hourly[hour]) { if (!buckets[interval]) {
hourly[hour] = { timestamp: hour, receives: 0, deletes: 0, forwards: 0 } buckets[interval] = { timestamp: interval, receives: 0, deletes: 0, forwards: 0 }
} }
hourly[hour].receives += entry.receives buckets[interval].receives += entry.receives
hourly[hour].deletes += entry.deletes buckets[interval].deletes += entry.deletes
hourly[hour].forwards += entry.forwards buckets[interval].forwards += entry.forwards
}) })
// Convert to sorted array // 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 chartContext = null;
let lastReloadTime = 0; let lastReloadTime = 0;
const RELOAD_COOLDOWN_MS = 2000; // 2 second cooldown between reloads 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 // Initialize stats chart if on stats page
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
@ -39,10 +40,14 @@ document.addEventListener('DOMContentLoaded', function() {
// Combine all data and create labels // Combine all data and create labels
const now = Date.now(); const now = Date.now();
// Use a reasonable historical window (show data within the purge time range) // Merge historical and realtime into a single continuous dataset
// This will adapt based on whether purge time is 48 hours, 7 days, etc. // Historical will be blue, current will be green
const allTimePoints = [ // Only show historical data that doesn't overlap with realtime (exclude any matching timestamps)
...historicalData.map(d => ({...d, type: 'historical' })), 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' })), ...realtimeData.map(d => ({...d, type: 'realtime' })),
...predictionData.map(d => ({...d, type: 'prediction' })) ...predictionData.map(d => ({...d, type: 'prediction' }))
].sort((a, b) => a.timestamp - b.timestamp); ].sort((a, b) => a.timestamp - b.timestamp);
@ -58,47 +63,53 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
}); });
// Prepare datasets // Merge historical and realtime into one dataset with segment coloring
const historicalPoints = allTimePoints.map(d => d.type === 'historical' ? d.receives : null); const combinedPoints = allTimePoints.map(d =>
const realtimePoints = allTimePoints.map(d => d.type === 'realtime' ? d.receives : null); (d.type === 'historical' || d.type === 'realtime') ? d.receives : null
);
const predictionPoints = allTimePoints.map(d => d.type === 'prediction' ? d.receives : null); const predictionPoints = allTimePoints.map(d => d.type === 'prediction' ? d.receives : null);
// Create gradient for fading effect on historical data // Create gradient for fading effect on historical data
const ctx = chartCanvas.getContext('2d'); const ctx = chartCanvas.getContext('2d');
chartContext = ctx; 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 // Track visibility state for each dataset
const datasetVisibility = [true, true, true]; const datasetVisibility = [true, true];
statsChart = new Chart(ctx, { statsChart = new Chart(ctx, {
type: 'line', type: 'line',
data: { data: {
labels: labels, labels: labels,
datasets: [{ datasets: [{
label: 'Historical', label: 'Email Activity',
data: historicalPoints, data: combinedPoints,
borderColor: 'rgba(100, 149, 237, 0.8)', segment: {
backgroundColor: historicalGradient, borderColor: (context) => {
borderWidth: 2, const index = context.p0DataIndex;
tension: 0.4, const point = allTimePoints[index];
pointRadius: 4, // Blue for historical, green for current
pointBackgroundColor: 'rgba(100, 149, 237, 0.8)', return point && point.type === 'historical' ? 'rgba(100, 149, 237, 0.8)' : '#2ecc71';
spanGaps: true, },
fill: true, backgroundColor: (context) => {
hidden: false 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)';
label: 'Current Activity', }
data: realtimePoints, },
borderColor: '#2ecc71', borderColor: '#2ecc71', // Default to green
backgroundColor: 'rgba(46, 204, 113, 0.15)', backgroundColor: 'rgba(46, 204, 113, 0.15)',
borderWidth: 4, borderWidth: 3,
tension: 0.4, tension: 0.4,
pointRadius: 4, pointRadius: (context) => {
pointBackgroundColor: '#2ecc71', 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, spanGaps: true,
fill: true, fill: true,
hidden: false hidden: false
@ -198,14 +209,10 @@ document.addEventListener('DOMContentLoaded', function() {
legendContainer.className = 'chart-legend-custom'; legendContainer.className = 'chart-legend-custom';
legendContainer.innerHTML = ` legendContainer.innerHTML = `
<button class="legend-btn active" data-index="0"> <button class="legend-btn active" data-index="0">
<span class="legend-indicator" style="background: rgba(100, 149, 237, 0.8);"></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">Historical</span> <span class="legend-label">Email Activity</span>
</button> </button>
<button class="legend-btn active" data-index="1"> <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-indicator" style="background: #ff9f43; border: 2px dashed rgba(255, 159, 67, 0.5);"></span>
<span class="legend-label">Predicted</span> <span class="legend-label">Predicted</span>
</button> </button>
@ -256,8 +263,12 @@ function rebuildStatsChart() {
const historicalData = window.historicalData || []; const historicalData = window.historicalData || [];
const predictionData = window.predictionData || []; const predictionData = window.predictionData || [];
const allTimePoints = [ // Only show historical data that doesn't overlap with realtime (exclude any matching timestamps)
...historicalData.map(d => ({...d, type: 'historical' })), 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' })), ...realtimeData.map(d => ({...d, type: 'realtime' })),
...predictionData.map(d => ({...d, type: 'prediction' })) ...predictionData.map(d => ({...d, type: 'prediction' }))
].sort((a, b) => a.timestamp - b.timestamp); ].sort((a, b) => a.timestamp - b.timestamp);
@ -277,16 +288,16 @@ function rebuildStatsChart() {
}); });
}); });
// Prepare datasets // Merge historical and realtime into one dataset with segment coloring
const historicalPoints = allTimePoints.map(d => d.type === 'historical' ? d.receives : null); const combinedPoints = allTimePoints.map(d =>
const realtimePoints = allTimePoints.map(d => d.type === 'realtime' ? d.receives : null); (d.type === 'historical' || d.type === 'realtime') ? d.receives : null
);
const predictionPoints = allTimePoints.map(d => d.type === 'prediction' ? d.receives : null); const predictionPoints = allTimePoints.map(d => d.type === 'prediction' ? d.receives : null);
// Update chart data // Update chart data
statsChart.data.labels = labels; statsChart.data.labels = labels;
statsChart.data.datasets[0].data = historicalPoints; statsChart.data.datasets[0].data = combinedPoints;
statsChart.data.datasets[1].data = realtimePoints; statsChart.data.datasets[1].data = predictionPoints;
statsChart.data.datasets[2].data = predictionPoints;
// Update the chart // Update the chart
statsChart.update(); statsChart.update();

View file

@ -1842,6 +1842,9 @@ label {
border: 1px solid var(--overlay-white-10); border: 1px solid var(--overlay-white-10);
width: 100%; width: 100%;
min-height: 40vh; min-height: 40vh;
max-height: 100vh;
resize: vertical;
overflow: auto;
} }
.mail-text-content { .mail-text-content {

View file

@ -8,12 +8,28 @@ const templateContext = require('../template-context')
// GET /account - Account dashboard // GET /account - Account dashboard
router.get('/account', requireAuth, async(req, res) => { router.get('/account', requireAuth, async(req, res) => {
try { try {
const config = req.app.get('config')
const userRepository = req.app.get('userRepository') const userRepository = req.app.get('userRepository')
const inboxLock = req.app.get('inboxLock') const inboxLock = req.app.get('inboxLock')
const mailProcessingService = req.app.get('mailProcessingService') const mailProcessingService = req.app.get('mailProcessingService')
const Helper = require('../../../application/helper') const Helper = require('../../../application/helper')
const helper = new 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 // Get user's verified forwarding emails
const forwardEmails = userRepository.getForwardEmails(req.session.userId) const forwardEmails = userRepository.getForwardEmails(req.session.userId)
@ -24,7 +40,6 @@ router.get('/account', requireAuth, async(req, res) => {
} }
// Get user stats // Get user stats
const config = req.app.get('config')
const stats = userRepository.getUserStats(req.session.userId, config.user) const stats = userRepository.getUserStats(req.session.userId, config.user)
const successMessage = req.session.accountSuccess const successMessage = req.session.accountSuccess
@ -225,6 +240,13 @@ router.post('/account/change-password',
body('confirmNewPassword').notEmpty().withMessage('Password confirmation is required'), body('confirmNewPassword').notEmpty().withMessage('Password confirmation is required'),
async(req, res) => { async(req, res) => {
try { 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) const errors = validationResult(req)
if (!errors.isEmpty()) { if (!errors.isEmpty()) {
req.session.accountError = errors.array()[0].msg 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'), body('confirmText').equals('DELETE').withMessage('You must type DELETE to confirm'),
async(req, res) => { async(req, res) => {
try { 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) const errors = validationResult(req)
if (!errors.isEmpty()) { if (!errors.isEmpty()) {
req.session.accountError = errors.array()[0].msg req.session.accountError = errors.array()[0].msg
@ -321,4 +350,4 @@ router.post('/account/delete',
} }
) )
module.exports = router module.exports = router

View file

@ -37,7 +37,12 @@ const server = http.createServer(app)
const io = socketio(server) const io = socketio(server)
app.set('socketio', io) 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.json())
app.use(express.urlencoded({ extended: false })) app.use(express.urlencoded({ extended: false }))
@ -200,6 +205,9 @@ server.on('listening', () => {
const addr = server.address() const addr = server.address()
const bind = typeof addr === 'string' ? 'pipe ' + addr : 'port ' + addr.port const bind = typeof addr === 'string' ? 'pipe ' + addr : 'port ' + addr.port
debug('Listening on ' + bind) debug('Listening on ' + bind)
// Emit event for app.js to display startup banner
server.emit('ready')
}) })
module.exports = { app, io, server } module.exports = { app, io, server }

View file

@ -1,6 +1,6 @@
{ {
"name": "48hr.email", "name": "48hr.email",
"version": "2.2.0", "version": "2.2.1",
"private": false, "private": false,
"description": "48hr.email is your favorite open-source tempmail client.", "description": "48hr.email is your favorite open-source tempmail client.",
"keywords": [ "keywords": [
@ -27,6 +27,7 @@
"scripts": { "scripts": {
"start": "node --trace-warnings ./app.js", "start": "node --trace-warnings ./app.js",
"debug": "DEBUG=48hr-email:* node --nolazy --inspect-brk=9229 --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", "test": "xo",
"env:check": "node scripts/check-env.js" "env:check": "node scripts/check-env.js"
}, },
@ -80,4 +81,4 @@
] ]
}] }]
} }
} }