mirror of
https://github.com/Crazyco-xyz/48hr.email.git
synced 2026-01-08 18:59:36 +01:00
[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:
parent
16ccc978f9
commit
a7691ccf43
13 changed files with 963 additions and 105 deletions
175
app.js
175
app.js
|
|
@ -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)')
|
||||
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('userRepository', null)
|
||||
app.set('authService', null)
|
||||
app.set('inboxLock', null)
|
||||
debug('User authentication system disabled')
|
||||
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)
|
||||
|
||||
// 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}`)
|
||||
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)
|
||||
process.exit(1)
|
||||
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)
|
||||
process.exit(1)
|
||||
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)
|
||||
process.exit(1)
|
||||
debug('Failed to connect:', error.message)
|
||||
console.error('Fatal error from mail service', error)
|
||||
if (!config.uxDebugMode) {
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
server.on('error', error => {
|
||||
|
|
@ -200,4 +327,4 @@ server.on('error', error => {
|
|||
console.error('Fatal web server error', error)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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) {
|
||||
debug('IMAP configuration validation failed: missing user, password, or host')
|
||||
throw new Error("IMAP is not configured. Check IMAP_* env vars.");
|
||||
|
||||
// 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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
37
application/mocks/mock-auth-service.js
Normal file
37
application/mocks/mock-auth-service.js
Normal 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
|
||||
125
application/mocks/mock-inbox-lock.js
Normal file
125
application/mocks/mock-inbox-lock.js
Normal 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
|
||||
364
application/mocks/mock-mail-service.js
Normal file
364
application/mocks/mock-mail-service.js
Normal 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> · <a href="https://github.com/Crazyco-xyz/48hr.email">GitHub</a> · <a href="https://discord.gg/crazyco">Discord</a> · 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
|
||||
131
application/mocks/mock-user-repository.js
Normal file
131
application/mocks/mock-user-repository.js
Normal 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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
<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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
]
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue