From a7691ccf43e9c336759ba0470fc42b8f9355d385 Mon Sep 17 00:00:00 2001 From: ClaraCrazy Date: Mon, 5 Jan 2026 08:45:26 +0100 Subject: [PATCH] [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 --- app.js | 175 +++++++-- application/config.js | 28 +- application/mail-processing-service.js | 6 +- application/mocks/mock-auth-service.js | 37 ++ application/mocks/mock-inbox-lock.js | 125 ++++++ application/mocks/mock-mail-service.js | 364 ++++++++++++++++++ application/mocks/mock-user-repository.js | 131 +++++++ domain/statistics-store.js | 52 +-- .../web/public/javascripts/stats.js | 99 ++--- .../web/public/stylesheets/custom.css | 3 + infrastructure/web/routes/account.js | 33 +- infrastructure/web/web.js | 10 +- package.json | 5 +- 13 files changed, 963 insertions(+), 105 deletions(-) create mode 100644 application/mocks/mock-auth-service.js create mode 100644 application/mocks/mock-inbox-lock.js create mode 100644 application/mocks/mock-mail-service.js create mode 100644 application/mocks/mock-user-repository.js diff --git a/app.js b/app.js index d89684a..a042b27 100644 --- a/app.js +++ b/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) } -}) +}) \ No newline at end of file diff --git a/application/config.js b/application/config.js index 171542d..b307277 100644 --- a/application/config.js +++ b/application/config.js @@ -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; diff --git a/application/mail-processing-service.js b/application/mail-processing-service.js index 00cc1cc..dd4aed2 100644 --- a/application/mail-processing-service.js +++ b/application/mail-processing-service.js @@ -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) { diff --git a/application/mocks/mock-auth-service.js b/application/mocks/mock-auth-service.js new file mode 100644 index 0000000..37854fd --- /dev/null +++ b/application/mocks/mock-auth-service.js @@ -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 diff --git a/application/mocks/mock-inbox-lock.js b/application/mocks/mock-inbox-lock.js new file mode 100644 index 0000000..f2f7c9b --- /dev/null +++ b/application/mocks/mock-inbox-lock.js @@ -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 diff --git a/application/mocks/mock-mail-service.js b/application/mocks/mock-mail-service.js new file mode 100644 index 0000000..cb7544a --- /dev/null +++ b/application/mocks/mock-mail-service.js @@ -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: `

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.

`, + html: null, + subject: 'Welcome to 48hr.email - Plain Text Demo', + from: { text: 'Clara K ' }, + to: { text: `demo@${domain}` }, + date: earlier, + attachments: this.logoAttachment ? [this.logoAttachment] : [] + } + }, + { + mail: Mail.create( + [`demo@${domain}`], [{ name: '48hr.email', address: 'noreply@48hr.email' }], + now.toISOString(), + 'HTML Email Demo - Features Overview', + 2 + ), + fullMail: { + text: `48hr.email - HTML Email Demo + +This is the plain text version of the HTML email. + +Visit https://48hr.email for more information. + +Clara's PGP Key is attached to this email.`, + html: ` + + + + + + +
+
+

48hr.email

+

Temporary inbox, no registration

+
+
+ +
+
+

About

+

Open-source temporary email service. Create disposable addresses instantly and receive emails without registration. Emails auto-delete after the configured purge time.

+
+ +
+

Features

+
+
Instant addresses
+
No registration
+
Real-time updates
+
HTML rendering
+
Open source GPL-3.0
+
Self-hostable
+
+
+
+ +
+

Developer PGP Key (ClaraCrazy)

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