Compare commits

..

10 commits

Author SHA1 Message Date
ClaraCrazy
d06ac6210f
[Chore]: Remove console.log() 2026-01-05 08:48:13 +01:00
ClaraCrazy
a7691ccf43
[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
2026-01-05 08:45:26 +01:00
ClaraCrazy
16ccc978f9
[Chore] Update ALL meta tags to be dynamic 2026-01-05 06:49:21 +01:00
ClaraCrazy
6ba3ccf415
[Fix]: Fix social-media stats embed
The new lazyloading broke it
2026-01-05 06:35:04 +01:00
ClaraCrazy
8d7cc06b5e
[Fix]: Un-brick website 2026-01-05 06:22:56 +01:00
ClaraCrazy
8151587036
[Fix]: Update old config mentions and plug missing ones 2026-01-05 06:19:59 +01:00
ClaraCrazy
0c8db0b597
[Fix]: Fix stats page 2026-01-05 05:39:35 +01:00
ClaraCrazy
d2d187d4d5
[Chore]: Update readme
Clean up feature list
2026-01-05 05:21:46 +01:00
ClaraCrazy
197d9b923e
[Feat]: Bring back performance V2
Electric Boogaloo
2026-01-05 05:21:18 +01:00
ClaraCrazy
345935f8b9
[Feat]: Update Config structure, add more feature flags, fix 302s
This is what makes a project a "Clara project"... going the extra mile for customizability <3
2026-01-05 04:50:53 +01:00
37 changed files with 1901 additions and 540 deletions

View file

@ -34,6 +34,7 @@ SMTP_SECURE=true # Use SSL (true
# --- HTTP / WEB CONFIGURATION --- # --- HTTP / WEB CONFIGURATION ---
HTTP_PORT=3000 # Port HTTP_PORT=3000 # Port
HTTP_BASE_URL="http://localhost:3000" # Base URL for verification links (e.g., https://48hr.email) HTTP_BASE_URL="http://localhost:3000" # Base URL for verification links (e.g., https://48hr.email)
HTTP_SESSION_SECRET="change-this-secret-in-production" # Secret for session encryption (used for auth & locking)
HTTP_BRANDING=["48hr.email","CrazyCo","https://crazyco.xyz"] # ['service_title', 'company_name', 'company_url'] HTTP_BRANDING=["48hr.email","CrazyCo","https://crazyco.xyz"] # ['service_title', 'company_name', 'company_url']
HTTP_DISPLAY_SORT=2 # Domain display sorting: HTTP_DISPLAY_SORT=2 # Domain display sorting:
# 0 = no change, # 0 = no change,
@ -42,12 +43,11 @@ HTTP_DISPLAY_SORT=2 # Domain display
# 3 = shuffle all # 3 = shuffle all
HTTP_HIDE_OTHER=false # true = only show first domain, false = show all HTTP_HIDE_OTHER=false # true = only show first domain, false = show all
HTTP_STATISTICS_ENABLED=false # Enable statistics page at /stats (true/false) HTTP_STATISTICS_ENABLED=false # Enable statistics page at /stats (true/false)
HTTP_SHOW_INFO_SECTION=true # Show info section on homepage (true/false)
# --- USER AUTHENTICATION & INBOX LOCKING --- # --- USER AUTHENTICATION & INBOX LOCKING ---
USER_AUTH_ENABLED=false # Enable user registration/login system (default: false) USER_AUTH_ENABLED=false # Enable user registration/login system (default: false)
USER_SESSION_SECRET="change-this-secret-in-production" # Secret for session encryption (used for auth & locking)
USER_DATABASE_PATH="./db/data.db" # Path to application database (users, forwarding, locks) USER_DATABASE_PATH="./db/data.db" # Path to application database (users, forwarding, locks)
USER_MAX_FORWARD_EMAILS=5 # Maximum verified forwarding emails per user USER_MAX_FORWARD_EMAILS=5 # Maximum verified forwarding emails per user
USER_MAX_LOCKED_INBOXES=5 # Maximum locked inboxes per user USER_MAX_LOCKED_INBOXES=5 # Maximum locked inboxes per user
LOCK_RELEASE_HOURS=168 # Auto-release locked inboxes after X hours without login (default: 168 = 7 days) LOCK_RELEASE_HOURS=168 # Auto-release locked inboxes after X hours without login (default: 168 = 7 days)

1
.gitignore vendored
View file

@ -1,4 +1,5 @@
.env .env
.env.backup
.idea .idea
.vscode .vscode
.DS_Store .DS_Store

View file

@ -26,16 +26,16 @@ All data is being removed 48hrs after they have reached the mail server.
## Features ## Features
- Create a custom inbox with select name and domain, or get a fully randomized one - **Custom or Random Inboxes** - Choose your own address or get one instantly
- Receive emails with a clean preview in your inbox, with optional browser notifications - **Real-time Updates** - Live email reception with Socket.IO and browser notifications
- Read emails, with support for HTML, CSS & JS just like you are used to from regular email providers - **Full Email Rendering** - HTML, CSS, JavaScript support with attachment downloads
- Automatic detection and display of cryptographic keys and signatures - **Raw Email View** - Inspect headers, MIME structure, and source
- Delete your emails ahead of time by pressing the delete button - **Cryptographic Key Detection** - Automatic PGP key and signature display
- View the raw email, showing all the headers etc. - **QR Code Generation** - Easy mobile access to your inbox
- Download Attachments with one click - **Dark/Light Theme** - Fully responsive design with theme toggle
- <u>Optional</u> User Account System with email forwarding and inbox locking - **Optional User Accounts** - Email forwarding and inbox locking (requires SMTP)
- <u>Optional</u> Statistics System, tracking public data for as long as your mails stay - **Optional Statistics** - Real-time metrics, historical analysis, and predictive charts
- and more... - **Highly Configurable** - Customize purge time, domains, branding, features, and limits via `.env`
<br> <br>

164
app.js
View file

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

View file

@ -2,6 +2,13 @@
require("dotenv").config({ quiet: true }); require("dotenv").config({ quiet: true });
const debug = require('debug')('48hr-email:config') const debug = require('debug')('48hr-email:config')
// Migration helper: warn about deprecated env vars
if (process.env.USER_SESSION_SECRET && !process.env.HTTP_SESSION_SECRET) {
console.warn('\nDEPRECATION WARNING: USER_SESSION_SECRET is deprecated.')
console.warn(' Please rename it to HTTP_SESSION_SECRET in your .env file.')
console.warn(' The old name still works but will be removed in a future version.\n')
}
/** /**
* Safely parse a value from env. * Safely parse a value from env.
* Returns `undefined` if the value is missing or invalid. * Returns `undefined` if the value is missing or invalid.
@ -30,8 +37,11 @@ function parseBool(v) {
} }
const config = { const config = {
// UX Debug Mode
uxDebugMode: parseBool(process.env.UX_DEBUG_MODE) || false,
email: { email: {
domains: parseValue(process.env.EMAIL_DOMAINS), domains: parseValue(process.env.EMAIL_DOMAINS) || [],
purgeTime: { purgeTime: {
time: Number(process.env.EMAIL_PURGE_TIME), time: Number(process.env.EMAIL_PURGE_TIME),
unit: parseValue(process.env.EMAIL_PURGE_UNIT), unit: parseValue(process.env.EMAIL_PURGE_UNIT),
@ -41,7 +51,10 @@ const config = {
account: parseValue(process.env.EMAIL_EXAMPLE_ACCOUNT), account: parseValue(process.env.EMAIL_EXAMPLE_ACCOUNT),
uids: parseValue(process.env.EMAIL_EXAMPLE_UIDS) uids: parseValue(process.env.EMAIL_EXAMPLE_UIDS)
}, },
blacklistedSenders: parseValue(process.env.EMAIL_BLACKLISTED_SENDERS) || [] blacklistedSenders: parseValue(process.env.EMAIL_BLACKLISTED_SENDERS) || [],
features: {
smtp: parseBool(process.env.SMTP_ENABLED) || false
}
}, },
imap: { imap: {
@ -66,12 +79,19 @@ const config = {
}, },
http: { http: {
// Server settings
port: Number(process.env.HTTP_PORT), port: Number(process.env.HTTP_PORT),
baseUrl: parseValue(process.env.HTTP_BASE_URL) || 'http://localhost:3000', baseUrl: parseValue(process.env.HTTP_BASE_URL) || 'http://localhost:3000',
sessionSecret: parseValue(process.env.HTTP_SESSION_SECRET) || parseValue(process.env.USER_SESSION_SECRET) || 'change-me-in-production',
// UI Features & Display
features: {
branding: parseValue(process.env.HTTP_BRANDING), branding: parseValue(process.env.HTTP_BRANDING),
displaySort: Number(process.env.HTTP_DISPLAY_SORT), displaySort: Number(process.env.HTTP_DISPLAY_SORT) || 0,
hideOther: parseBool(process.env.HTTP_HIDE_OTHER), hideOther: parseBool(process.env.HTTP_HIDE_OTHER),
statisticsEnabled: parseBool(process.env.HTTP_STATISTICS_ENABLED) || false statistics: parseBool(process.env.HTTP_STATISTICS_ENABLED) || false,
infoSection: parseBool(process.env.HTTP_SHOW_INFO_SECTION) !== false // default true
}
}, },
user: { user: {
@ -81,9 +101,6 @@ const config = {
// Database // Database
databasePath: parseValue(process.env.USER_DATABASE_PATH) || './db/data.db', databasePath: parseValue(process.env.USER_DATABASE_PATH) || './db/data.db',
// Session & Auth
sessionSecret: parseValue(process.env.USER_SESSION_SECRET) || 'change-me-in-production',
// Feature Limits // Feature Limits
maxForwardEmails: Number(process.env.USER_MAX_FORWARD_EMAILS) || 5, maxForwardEmails: Number(process.env.USER_MAX_FORWARD_EMAILS) || 5,
maxLockedInboxes: Number(process.env.USER_MAX_LOCKED_INBOXES) || 5, maxLockedInboxes: Number(process.env.USER_MAX_LOCKED_INBOXES) || 5,
@ -93,16 +110,33 @@ const config = {
// validation // validation
debug('Validating configuration...') debug('Validating configuration...')
// Skip IMAP validation in UX debug mode
if (!config.uxDebugMode) {
if (!config.imap.user || !config.imap.password || !config.imap.host) { if (!config.imap.user || !config.imap.password || !config.imap.host) {
debug('IMAP configuration validation failed: missing user, password, or host') debug('IMAP configuration validation failed: missing user, password, or host')
throw new Error("IMAP is not configured. Check IMAP_* env vars."); throw new Error("IMAP is not configured. Check IMAP_* env vars.");
} }
}
// In UX debug mode, provide default domain if none configured
if (config.uxDebugMode && !config.email.domains.length) {
config.email.domains = ['ux-debug.local']
debug('UX Debug Mode: Using default domain "ux-debug.local"')
}
// In UX debug mode, set example account to show mock emails
if (config.uxDebugMode) {
config.email.examples.account = `demo@${config.email.domains[0]}`
config.email.examples.uids = [1, 2]
debug(`UX Debug Mode: Example account set to ${config.email.examples.account} with UIDs 1, 2`)
}
if (!config.email.domains.length) { if (!config.email.domains.length) {
debug('Email domains validation failed: no domains configured') debug('Email domains validation failed: no domains configured')
throw new Error("No EMAIL_DOMAINS configured."); throw new Error("No EMAIL_DOMAINS configured.");
} }
debug(`Configuration validated successfully: ${config.email.domains.length} domains, IMAP host: ${config.imap.host}`) debug(`Configuration validated successfully: ${config.email.domains.length} domains${config.uxDebugMode ? ' (UX DEBUG MODE)' : ''}`)
module.exports = config; module.exports = config;

View file

@ -109,11 +109,10 @@ class Helper {
/** /**
* Build a mail count html element with tooltip for the footer * Build a mail count html element with tooltip for the footer
* @param {number} count - Current mail count * @param {number} count - Current mail count
* @param {number} largestUid - Largest UID from IMAP
* @returns {String} * @returns {String}
*/ */
mailCountBuilder(count) { mailCountBuilder(count, largestUid = null) {
const imapService = require('./imap-service')
const largestUid = imapService.getLargestUid ? imapService.getLargestUid() : null
let tooltip = '' let tooltip = ''
if (largestUid && largestUid > 0) { if (largestUid && largestUid > 0) {
@ -156,10 +155,10 @@ class Helper {
* @returns {Array} * @returns {Array}
*/ */
hideOther(array) { hideOther(array) {
if (config.http.hideOther) { if (config.http.features.hideOther) {
return array[0] return array && array.length > 0 ? [array[0]] : []
} else { } else {
return array return array || []
} }
} }
@ -168,9 +167,13 @@ class Helper {
* @returns {Array} * @returns {Array}
*/ */
getDomains() { getDomains() {
debug(`Getting domains with displaySort: ${config.http.displaySort}`) debug(`Getting domains with displaySort: ${config.http.features.displaySort}`)
if (!config.email.domains || !Array.isArray(config.email.domains) || !config.email.domains.length) {
debug('ERROR: config.email.domains is not a valid array')
return []
}
let result; let result;
switch (config.http.displaySort) { switch (config.http.features.displaySort) {
case 0: case 0:
result = this.hideOther(config.email.domains) // No modification result = this.hideOther(config.email.domains) // No modification
debug(`Domain sort 0: no modification, ${result.length} domains`) debug(`Domain sort 0: no modification, ${result.length} domains`)
@ -187,6 +190,9 @@ class Helper {
result = this.hideOther(this.shuffleArray(config.email.domains)) // Shuffle all result = this.hideOther(this.shuffleArray(config.email.domains)) // Shuffle all
debug(`Domain sort 3: shuffle all, ${result.length} domains`) debug(`Domain sort 3: shuffle all, ${result.length} domains`)
return result return result
default:
debug(`Unknown displaySort value: ${config.http.features.displaySort}, using case 0`)
return this.hideOther(config.email.domains)
} }
} }
@ -212,7 +218,7 @@ class Helper {
* @returns {string} - HMAC signature (hex) * @returns {string} - HMAC signature (hex)
*/ */
signCookie(email) { signCookie(email) {
const secret = config.user.sessionSecret const secret = config.http.sessionSecret
const hmac = crypto.createHmac('sha256', secret) const hmac = crypto.createHmac('sha256', secret)
hmac.update(email.toLowerCase()) hmac.update(email.toLowerCase())
const signature = hmac.digest('hex') const signature = hmac.digest('hex')

View file

@ -301,8 +301,6 @@ class ImapService extends EventEmitter {
toDelete.forEach(uid => { toDelete.forEach(uid => {
this.emit(ImapService.EVENT_DELETED_MAIL, uid); this.emit(ImapService.EVENT_DELETED_MAIL, uid);
}); });
console.log(`Deleted ${toDelete.length} old messages.`);
} }

View file

@ -140,10 +140,8 @@ class MailProcessingService extends EventEmitter {
onInitialLoadDone() { onInitialLoadDone() {
this.initialLoadDone = true this.initialLoadDone = true
debug('Initial load completed, total mails:', this.mailRepository.mailCount()) debug('Initial load completed, total mails:', this.mailRepository.mailCount())
console.log(`Initial load done, got ${this.mailRepository.mailCount()} mails`)
console.log(`Fetching and deleting mails every ${this.config.imap.refreshIntervalSeconds} seconds`) // Don't print old-style logs here, app.js will handle the startup banner
console.log(`Mails older than ${this.config.email.purgeTime.time} ${this.config.email.purgeTime.unit} will be deleted`)
console.log(`The example emails are: ${this.config.email.examples.uids.join(', ')}, on the account ${this.config.email.examples.account}`)
} }
onNewMail(mail) { onNewMail(mail) {
@ -274,7 +272,7 @@ class MailProcessingService extends EventEmitter {
// Forward via SMTP service // Forward via SMTP service
debug(`Forwarding email to ${destinationEmail}`) debug(`Forwarding email to ${destinationEmail}`)
const branding = this.config.http.branding[0] || '48hr.email' const branding = this.config.http.features.branding[0] || '48hr.email'
const result = await this.smtpService.forwardMail(fullMail, destinationEmail, branding) const result = await this.smtpService.forwardMail(fullMail, destinationEmail, branding)
if (result.success) { if (result.success) {
@ -354,7 +352,7 @@ class MailProcessingService extends EventEmitter {
// Send verification email // Send verification email
const baseUrl = this.config.http.baseUrl const baseUrl = this.config.http.baseUrl
const branding = this.config.http.branding[0] || '48hr.email' const branding = this.config.http.features.branding[0] || '48hr.email'
debug(`Sending verification email to ${destinationEmail} for source ${sourceAddress}`) debug(`Sending verification email to ${destinationEmail} for source ${sourceAddress}`)
const result = await this.smtpService.sendVerificationEmail( const result = await this.smtpService.sendVerificationEmail(

View file

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

View file

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

View file

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

View file

@ -0,0 +1,131 @@
/**
* Mock User Repository for UX Debug Mode
* Provides dummy user data without database
*/
const debug = require('debug')('48hr-email:mock-user-repo')
class MockUserRepository {
constructor(config) {
this.config = config
// Generate a random forwarding email (fixed for this server instance)
const randomGmailName = Math.random().toString(36).substring(2, 10)
this.mockForwardEmail = `${randomGmailName}@gmail.com`
// Generate a random locked inbox (fixed for this server instance)
const randomWords = ['alpha', 'beta', 'gamma', 'delta', 'omega', 'sigma', 'theta']
const word1 = randomWords[Math.floor(Math.random() * randomWords.length)]
const word2 = randomWords[Math.floor(Math.random() * randomWords.length)]
const num = Math.floor(Math.random() * 999)
this.mockLockedInbox = `${word1}${word2}${num}@${config.email.domains[0]}`
// Store the initial values to reset to
this.initialForwardEmail = this.mockForwardEmail
this.initialLockedInbox = this.mockLockedInbox
// In-memory storage that can be modified during a session
this.forwardEmails = new Set([this.mockForwardEmail])
this.lockedInboxes = new Set([this.mockLockedInbox])
debug(`Mock forward email: ${this.mockForwardEmail}`)
debug(`Mock locked inbox: ${this.mockLockedInbox}`)
}
// Reset to initial state (called on new page loads)
reset() {
this.forwardEmails = new Set([this.initialForwardEmail])
this.lockedInboxes = new Set([this.initialLockedInbox])
debug('Mock data reset to initial state')
}
// User methods
getUserById(userId) {
if (userId === 1) {
return {
id: 1,
username: 'demo',
password_hash: 'mock',
created_at: Date.now() - 86400000,
last_login: Date.now()
}
}
return null
}
getUserByUsername(username) {
return this.getUserById(1)
}
updateLastLogin(userId) {
// No-op in mock
return true
}
// Forward email methods
getForwardEmails(userId) {
if (userId === 1) {
const emails = []
let id = 1
for (const email of this.forwardEmails) {
emails.push({
id: id++,
user_id: 1,
email: email,
verified: true,
verification_token: null,
created_at: Date.now() - 3600000
})
}
return emails
}
return []
}
addForwardEmail(userId, email, token) {
this.forwardEmails.add(email)
return {
id: this.forwardEmails.size,
user_id: userId,
email: email,
verified: false,
verification_token: token,
created_at: Date.now()
}
}
verifyForwardEmail(token) {
// In mock mode, just return success
return true
}
removeForwardEmail(userId, email) {
const deleted = this.forwardEmails.delete(email)
debug(`Removed forward email: ${email} (success: ${deleted})`)
return deleted
}
deleteForwardEmail(userId, email) {
// Alias for removeForwardEmail
return this.removeForwardEmail(userId, email)
}
// User stats
getUserStats(userId, config) {
return {
lockedInboxesCount: this.lockedInboxes.size,
forwardEmailsCount: this.forwardEmails.size,
accountAge: Math.floor((Date.now() - (Date.now() - 86400000)) / 86400000),
maxLockedInboxes: config.maxLockedInboxes || 5,
maxForwardEmails: config.maxForwardEmails || 5,
lockReleaseHours: config.lockReleaseHours || 720
}
}
// Cleanup - no-op
close() {
debug('Mock user repository closed')
}
}
module.exports = MockUserRepository

View file

@ -309,8 +309,8 @@ class StatisticsStore {
// Calculate emails per hour rate (average across all active hours) // Calculate emails per hour rate (average across all active hours)
const activeHours = hourlyActivity.filter(count => count > 0).length const activeHours = hourlyActivity.filter(count => count > 0).length
const emailsPerHour = activeHours > 0 ? const emailsPerHour = activeHours > 0 ?
(allMails.length / activeHours).toFixed(1) : Math.round(allMails.length / activeHours) :
'0.0' 0
// Calculate day/night percentage // Calculate day/night percentage
const totalDayNight = dayTimeEmails + nightTimeEmails const totalDayNight = dayTimeEmails + nightTimeEmails
@ -327,7 +327,7 @@ class StatisticsStore {
uniqueSenderDomains: senderDomains.size, uniqueSenderDomains: senderDomains.size,
uniqueRecipientDomains: recipientDomains.size, uniqueRecipientDomains: recipientDomains.size,
peakHourPercentage, peakHourPercentage,
emailsPerHour: parseFloat(emailsPerHour), emailsPerHour: emailsPerHour,
dayPercentage dayPercentage
} }
@ -455,23 +455,23 @@ class StatisticsStore {
const cutoff = Date.now() - this._getPurgeCutoffMs() const cutoff = Date.now() - this._getPurgeCutoffMs()
const relevantHistory = this.historicalData.filter(point => point.timestamp >= cutoff) const relevantHistory = this.historicalData.filter(point => point.timestamp >= cutoff)
// Aggregate by hour // Aggregate by 15-minute intervals for better granularity
const hourlyBuckets = new Map() const intervalBuckets = new Map()
relevantHistory.forEach(point => { relevantHistory.forEach(point => {
const hour = Math.floor(point.timestamp / 3600000) * 3600000 const interval = Math.floor(point.timestamp / 900000) * 900000 // 15 minutes
if (!hourlyBuckets.has(hour)) { if (!intervalBuckets.has(interval)) {
hourlyBuckets.set(hour, 0) intervalBuckets.set(interval, 0)
} }
hourlyBuckets.set(hour, hourlyBuckets.get(hour) + point.receives) intervalBuckets.set(interval, intervalBuckets.get(interval) + point.receives)
}) })
// Convert to array and sort // Convert to array and sort
const hourlyData = Array.from(hourlyBuckets.entries()) const intervalData = Array.from(intervalBuckets.entries())
.map(([timestamp, receives]) => ({ timestamp, receives })) .map(([timestamp, receives]) => ({ timestamp, receives }))
.sort((a, b) => a.timestamp - b.timestamp) .sort((a, b) => a.timestamp - b.timestamp)
debug(`Historical timeline: ${hourlyData.length} hourly points within ${config.email.purgeTime.time} ${config.email.purgeTime.unit} window`) debug(`Historical timeline: ${intervalData.length} 15-min interval points within ${config.email.purgeTime.time} ${config.email.purgeTime.unit} window`)
return hourlyData return intervalData
} }
/** /**
@ -512,12 +512,16 @@ class StatisticsStore {
debug(`Built hourly patterns for ${hourlyAverages.size} hours from ${this.historicalData.length} data points`) debug(`Built hourly patterns for ${hourlyAverages.size} hours from ${this.historicalData.length} data points`)
// Generate predictions for purge duration (in 1-hour intervals) // Generate predictions for a reasonable future window
// Limit to 20% of purge duration or 12 hours max to maintain chart balance
// Use 15-minute intervals for better granularity
const purgeMs = this._getPurgeCutoffMs() const purgeMs = this._getPurgeCutoffMs()
const predictionHours = Math.ceil(purgeMs / (60 * 60 * 1000)) const purgeDurationHours = Math.ceil(purgeMs / (60 * 60 * 1000))
const predictionHours = Math.min(12, Math.ceil(purgeDurationHours * 0.2))
const predictionIntervals = predictionHours * 4 // Convert hours to 15-min intervals
for (let i = 1; i <= predictionHours; i++) { for (let i = 1; i <= predictionIntervals; i++) {
const timestamp = now + (i * 60 * 60 * 1000) // 1 hour intervals const timestamp = now + (i * 15 * 60 * 1000) // 15 minute intervalsals
const futureDate = new Date(timestamp) const futureDate = new Date(timestamp)
const futureHour = futureDate.getHours() const futureHour = futureDate.getHours()
@ -529,8 +533,8 @@ class StatisticsStore {
baseCount = allValues.reduce((sum, v) => sum + v, 0) / allValues.length baseCount = allValues.reduce((sum, v) => sum + v, 0) / allValues.length
} }
// baseCount is already per-minute average, scale to full hour // baseCount is already per-minute average, scale to 15 minutes
const scaledCount = baseCount * 60 const scaledCount = baseCount * 15
// Add randomization (±20%) // Add randomization (±20%)
const randomFactor = 0.8 + (Math.random() * 0.4) // 0.8 to 1.2 const randomFactor = 0.8 + (Math.random() * 0.4) // 0.8 to 1.2
@ -626,23 +630,23 @@ class StatisticsStore {
_getTimeline() { _getTimeline() {
const now = Date.now() const now = Date.now()
const cutoff = now - this._getPurgeCutoffMs() const cutoff = now - this._getPurgeCutoffMs()
const hourly = {} const buckets = {}
// Aggregate by hour // Aggregate by 15-minute intervals for better granularity
this.hourlyData this.hourlyData
.filter(e => e.timestamp >= cutoff) .filter(e => e.timestamp >= cutoff)
.forEach(entry => { .forEach(entry => {
const hour = Math.floor(entry.timestamp / 3600000) * 3600000 const interval = Math.floor(entry.timestamp / 900000) * 900000 // 15 minutes
if (!hourly[hour]) { if (!buckets[interval]) {
hourly[hour] = { timestamp: hour, receives: 0, deletes: 0, forwards: 0 } buckets[interval] = { timestamp: interval, receives: 0, deletes: 0, forwards: 0 }
} }
hourly[hour].receives += entry.receives buckets[interval].receives += entry.receives
hourly[hour].deletes += entry.deletes buckets[interval].deletes += entry.deletes
hourly[hour].forwards += entry.forwards buckets[interval].forwards += entry.forwards
}) })
// Convert to sorted array // Convert to sorted array
return Object.values(hourly).sort((a, b) => a.timestamp - b.timestamp) return Object.values(buckets).sort((a, b) => a.timestamp - b.timestamp)
} }
} }

View file

@ -1,3 +1,5 @@
const templateContext = require('../template-context')
function checkLockAccess(req, res, next) { function checkLockAccess(req, res, next) {
const inboxLock = req.app.get('inboxLock') const inboxLock = req.app.get('inboxLock')
const address = req.params.address const address = req.params.address
@ -21,14 +23,10 @@ function checkLockAccess(req, res, next) {
const unlockError = req.session ? req.session.unlockError : undefined const unlockError = req.session ? req.session.unlockError : undefined
if (req.session) delete req.session.unlockError if (req.session) delete req.session.unlockError
return res.render('error', { return res.render('error', templateContext.build(req, {
purgeTime: require('../../../application/helper').prototype.purgeTimeElemetBuilder(), title: 'Access Denied',
address: address, message: 'This inbox is locked by another user. Only the owner can access it.'
message: 'This inbox is locked by another user. Only the owner can access it.', }))
branding: req.app.get('config').http.branding,
currentUser: req.session && req.session.username,
authEnabled: req.app.get('config').user.authEnabled
})
} }
// Update last access if they have access and are authenticated // Update last access if they have access and are authenticated

View file

@ -3,6 +3,13 @@
* Handles Chart.js initialization with historical, real-time, and predicted data * Handles Chart.js initialization with historical, real-time, and predicted data
*/ */
// Store chart instance globally for updates
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 // Initialize stats chart if on stats page
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const chartCanvas = document.getElementById('statsChart'); const chartCanvas = document.getElementById('statsChart');
@ -10,7 +17,6 @@ document.addEventListener('DOMContentLoaded', function() {
// Get data from global variables (set by template) // Get data from global variables (set by template)
if (typeof window.initialStatsData === 'undefined') { if (typeof window.initialStatsData === 'undefined') {
console.error('Initial stats data not found');
return; return;
} }
@ -18,29 +24,30 @@ document.addEventListener('DOMContentLoaded', function() {
const historicalData = window.historicalData || []; const historicalData = window.historicalData || [];
const predictionData = window.predictionData || []; const predictionData = window.predictionData || [];
console.log(`Loaded data: ${historicalData.length} historical, ${realtimeData.length} realtime, ${predictionData.length} predictions`); // Set up Socket.IO connection for real-time updates with rate limiting
// Set up Socket.IO connection for real-time updates
if (typeof io !== 'undefined') { if (typeof io !== 'undefined') {
const socket = io(); const socket = io();
socket.on('stats-update', () => { socket.on('stats-update', () => {
console.log('Stats update received (page will not auto-reload)'); const now = Date.now();
// Don't auto-reload - user can manually refresh if needed if (now - lastReloadTime >= RELOAD_COOLDOWN_MS) {
}); lastReloadTime = now;
reloadStatsData();
socket.on('reconnect', () => { }
console.log('Reconnected to server');
}); });
} }
// Combine all data and create labels // Combine all data and create labels
const now = Date.now(); const now = Date.now();
// Use a reasonable historical window (show data within the purge time range) // Merge historical and realtime into a single continuous dataset
// This will adapt based on whether purge time is 48 hours, 7 days, etc. // Historical will be blue, current will be green
const allTimePoints = [ // Only show historical data that doesn't overlap with realtime (exclude any matching timestamps)
...historicalData.map(d => ({...d, type: 'historical' })), const realtimeTimestamps = new Set(realtimeData.map(d => d.timestamp));
const filteredHistorical = historicalData.filter(d => !realtimeTimestamps.has(d.timestamp));
allTimePoints = [
...filteredHistorical.map(d => ({...d, type: 'historical' })),
...realtimeData.map(d => ({...d, type: 'realtime' })), ...realtimeData.map(d => ({...d, type: 'realtime' })),
...predictionData.map(d => ({...d, type: 'prediction' })) ...predictionData.map(d => ({...d, type: 'prediction' }))
].sort((a, b) => a.timestamp - b.timestamp); ].sort((a, b) => a.timestamp - b.timestamp);
@ -56,46 +63,53 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
}); });
// Prepare datasets // Merge historical and realtime into one dataset with segment coloring
const historicalPoints = allTimePoints.map(d => d.type === 'historical' ? d.receives : null); const combinedPoints = allTimePoints.map(d =>
const realtimePoints = allTimePoints.map(d => d.type === 'realtime' ? d.receives : null); (d.type === 'historical' || d.type === 'realtime') ? d.receives : null
);
const predictionPoints = allTimePoints.map(d => d.type === 'prediction' ? d.receives : null); const predictionPoints = allTimePoints.map(d => d.type === 'prediction' ? d.receives : null);
// Create gradient for fading effect on historical data // Create gradient for fading effect on historical data
const ctx = chartCanvas.getContext('2d'); const ctx = chartCanvas.getContext('2d');
const historicalGradient = ctx.createLinearGradient(0, 0, chartCanvas.width * 0.3, 0); chartContext = ctx;
historicalGradient.addColorStop(0, 'rgba(100, 100, 255, 0.05)');
historicalGradient.addColorStop(1, 'rgba(100, 100, 255, 0.15)');
// Track visibility state for each dataset // Track visibility state for each dataset
const datasetVisibility = [true, true, true]; const datasetVisibility = [true, true];
const chart = new Chart(ctx, { statsChart = new Chart(ctx, {
type: 'line', type: 'line',
data: { data: {
labels: labels, labels: labels,
datasets: [{ datasets: [{
label: 'Historical', label: 'Email Activity',
data: historicalPoints, data: combinedPoints,
borderColor: 'rgba(100, 149, 237, 0.8)', segment: {
backgroundColor: historicalGradient, borderColor: (context) => {
borderWidth: 2, const index = context.p0DataIndex;
tension: 0.4, const point = allTimePoints[index];
pointRadius: 4, // Blue for historical, green for current
pointBackgroundColor: 'rgba(100, 149, 237, 0.8)', return point && point.type === 'historical' ? 'rgba(100, 149, 237, 0.8)' : '#2ecc71';
spanGaps: true,
fill: true,
hidden: false
}, },
{ backgroundColor: (context) => {
label: 'Current Activity', const index = context.p0DataIndex;
data: realtimePoints, const point = allTimePoints[index];
borderColor: '#2ecc71', 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)', backgroundColor: 'rgba(46, 204, 113, 0.15)',
borderWidth: 4, borderWidth: 3,
tension: 0.4, tension: 0.4,
pointRadius: 4, pointRadius: (context) => {
pointBackgroundColor: '#2ecc71', const index = context.dataIndex;
const point = allTimePoints[index];
return point && point.type === 'historical' ? 3 : 4;
},
pointBackgroundColor: (context) => {
const index = context.dataIndex;
const point = allTimePoints[index];
return point && point.type === 'historical' ? 'rgba(100, 149, 237, 0.8)' : '#2ecc71';
},
spanGaps: true, spanGaps: true,
fill: true, fill: true,
hidden: false hidden: false
@ -132,7 +146,9 @@ document.addEventListener('DOMContentLoaded', function() {
intersect: false, intersect: false,
callbacks: { callbacks: {
title: function(context) { title: function(context) {
if (!context || !context[0] || context[0].dataIndex === undefined) return '';
const dataIndex = context[0].dataIndex; const dataIndex = context[0].dataIndex;
if (!allTimePoints[dataIndex]) return '';
const point = allTimePoints[dataIndex]; const point = allTimePoints[dataIndex];
const date = new Date(point.timestamp); const date = new Date(point.timestamp);
return date.toLocaleString('en-US', { return date.toLocaleString('en-US', {
@ -193,14 +209,10 @@ document.addEventListener('DOMContentLoaded', function() {
legendContainer.className = 'chart-legend-custom'; legendContainer.className = 'chart-legend-custom';
legendContainer.innerHTML = ` legendContainer.innerHTML = `
<button class="legend-btn active" data-index="0"> <button class="legend-btn active" data-index="0">
<span class="legend-indicator" style="background: rgba(100, 149, 237, 0.8);"></span> <span class="legend-indicator" style="background: linear-gradient(to right, rgba(100, 149, 237, 0.8) 0%, #2ecc71 100%);"></span>
<span class="legend-label">Historical</span> <span class="legend-label">Email Activity</span>
</button> </button>
<button class="legend-btn active" data-index="1"> <button class="legend-btn active" data-index="1">
<span class="legend-indicator" style="background: #2ecc71;"></span>
<span class="legend-label">Current Activity</span>
</button>
<button class="legend-btn active" data-index="2">
<span class="legend-indicator" style="background: #ff9f43; border: 2px dashed rgba(255, 159, 67, 0.5);"></span> <span class="legend-indicator" style="background: #ff9f43; border: 2px dashed rgba(255, 159, 67, 0.5);"></span>
<span class="legend-label">Predicted</span> <span class="legend-label">Predicted</span>
</button> </button>
@ -218,8 +230,8 @@ document.addEventListener('DOMContentLoaded', function() {
this.classList.toggle('active'); this.classList.toggle('active');
// Toggle dataset visibility with fade effect // Toggle dataset visibility with fade effect
const meta = chart.getDatasetMeta(index); const meta = statsChart.getDatasetMeta(index);
const dataset = chart.data.datasets[index]; const dataset = statsChart.data.datasets[index];
if (isActive) { if (isActive) {
// Fade out // Fade out
@ -231,7 +243,187 @@ document.addEventListener('DOMContentLoaded', function() {
datasetVisibility[index] = true; datasetVisibility[index] = true;
} }
chart.update('active'); statsChart.update('active');
}); });
}); });
// Lazy load full stats data if placeholder detected
lazyLoadStats();
}); });
/**
* Rebuild chart with new data
*/
function rebuildStatsChart() {
if (!statsChart || !chartContext) {
return;
}
const realtimeData = window.initialStatsData || [];
const historicalData = window.historicalData || [];
const predictionData = window.predictionData || [];
// 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);
if (allTimePoints.length === 0) {
return;
}
// Create labels
const labels = allTimePoints.map(d => {
const date = new Date(d.timestamp);
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
});
// 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 = combinedPoints;
statsChart.data.datasets[1].data = predictionPoints;
// Update the chart
statsChart.update();
}
/**
* Lazy load full statistics data and update DOM
*/
function lazyLoadStats() {
// Check if this is a lazy-loaded page (has placeholder data)
const currentCountEl = document.getElementById('currentCount');
if (!currentCountEl) {
return;
}
const currentText = currentCountEl.textContent.trim();
if (currentText !== '...') {
return; // Already loaded with real data
}
reloadStatsData();
}
/**
* Reload statistics data from API and update DOM
*/
function reloadStatsData() {
fetch('/stats/api')
.then(response => response.json())
.then(data => {
updateStatsDOM(data);
})
.catch(error => {
console.error('Error reloading stats:', error);
});
}
/**
* Update DOM with stats data
*/
function updateStatsDOM(data) {
// Update main stat cards
document.getElementById('currentCount').textContent = data.currentCount || '0';
document.getElementById('historicalTotal').textContent = data.allTimeTotal || '0';
document.getElementById('receives24h').textContent = (data.last24Hours && data.last24Hours.receives) || '0';
document.getElementById('deletes24h').textContent = (data.last24Hours && data.last24Hours.deletes) || '0';
document.getElementById('forwards24h').textContent = (data.last24Hours && data.last24Hours.forwards) || '0';
// Update enhanced stats if available
if (data.enhanced) {
const topSenderDomains = document.querySelector('[data-stats="top-sender-domains"]');
const topRecipientDomains = document.querySelector('[data-stats="top-recipient-domains"]');
const busiestHours = document.querySelector('[data-stats="busiest-hours"]');
if (topSenderDomains && data.enhanced.topSenderDomains && data.enhanced.topSenderDomains.length > 0) {
let html = '';
data.enhanced.topSenderDomains.slice(0, 5).forEach(item => {
html += `<li class="stat-list-item"><span class="stat-list-label">${item.domain}</span><span class="stat-list-value">${item.count}</span></li>`;
});
topSenderDomains.innerHTML = html;
}
if (topRecipientDomains && data.enhanced.topRecipientDomains && data.enhanced.topRecipientDomains.length > 0) {
let html = '';
data.enhanced.topRecipientDomains.slice(0, 5).forEach(item => {
html += `<li class="stat-list-item"><span class="stat-list-label">${item.domain}</span><span class="stat-list-value">${item.count}</span></li>`;
});
topRecipientDomains.innerHTML = html;
}
if (busiestHours && data.enhanced.busiestHours && data.enhanced.busiestHours.length > 0) {
let html = '';
data.enhanced.busiestHours.forEach(item => {
html += `<li class="stat-list-item"><span class="stat-list-label">${item.hour}:00 - ${item.hour + 1}:00</span><span class="stat-list-value">${item.count}</span></li>`;
});
busiestHours.innerHTML = html;
}
// Update unique domains count
const uniqueSenderDomains = document.querySelector('[data-stats="unique-sender-domains"]');
if (uniqueSenderDomains && data.enhanced.uniqueSenderDomains !== undefined) {
uniqueSenderDomains.textContent = data.enhanced.uniqueSenderDomains;
}
const uniqueRecipientDomains = document.querySelector('[data-stats="unique-recipient-domains"]');
if (uniqueRecipientDomains && data.enhanced.uniqueRecipientDomains !== undefined) {
uniqueRecipientDomains.textContent = data.enhanced.uniqueRecipientDomains;
}
// Update Quick Insights values
const avgSubjectLength = document.querySelector('[data-stats="average-subject-length"]');
if (avgSubjectLength && data.enhanced.averageSubjectLength !== undefined) {
avgSubjectLength.textContent = data.enhanced.averageSubjectLength;
}
const uniqueSenderDomainsValue = document.querySelector('[data-stats="unique-sender-domains-value"]');
if (uniqueSenderDomainsValue && data.enhanced.uniqueSenderDomains !== undefined) {
uniqueSenderDomainsValue.textContent = data.enhanced.uniqueSenderDomains;
}
const uniqueRecipientDomainsValue = document.querySelector('[data-stats="unique-recipient-domains-value"]');
if (uniqueRecipientDomainsValue && data.enhanced.uniqueRecipientDomains !== undefined) {
uniqueRecipientDomainsValue.textContent = data.enhanced.uniqueRecipientDomains;
}
const peakHourPercentage = document.querySelector('[data-stats="peak-hour-percentage"]');
if (peakHourPercentage && data.enhanced.peakHourPercentage !== undefined) {
peakHourPercentage.textContent = data.enhanced.peakHourPercentage + '%';
}
const emailsPerHour = document.querySelector('[data-stats="emails-per-hour"]');
if (emailsPerHour && data.enhanced.emailsPerHour !== undefined) {
emailsPerHour.textContent = data.enhanced.emailsPerHour;
}
const dayPercentage = document.querySelector('[data-stats="day-percentage"]');
if (dayPercentage && data.enhanced.dayPercentage !== undefined) {
dayPercentage.textContent = data.enhanced.dayPercentage + '%';
}
}
// Update window data for charts
window.initialStatsData = (data.last24Hours && data.last24Hours.timeline) || [];
window.historicalData = data.historical || [];
window.predictionData = data.prediction || [];
// Rebuild chart with new data
rebuildStatsChart();
}

View file

@ -498,8 +498,76 @@ document.addEventListener('DOMContentLoaded', () => {
refreshTimer.textContent = refreshInterval; refreshTimer.textContent = refreshInterval;
} }
function initAccountModals() {
// Add Email Modal
const addEmailBtn = document.getElementById('addEmailBtn');
const addEmailModal = document.getElementById('addEmailModal');
const closeAddEmail = document.getElementById('closeAddEmail');
if (addEmailBtn && addEmailModal) {
addEmailBtn.onclick = function() {
addEmailModal.style.display = 'block';
};
}
if (closeAddEmail && addEmailModal) {
closeAddEmail.onclick = function() {
addEmailModal.style.display = 'none';
};
}
// Delete Account Modal
const deleteAccountBtn = document.getElementById('deleteAccountBtn');
const deleteAccountModal = document.getElementById('deleteAccountModal');
const closeDeleteAccount = document.getElementById('closeDeleteAccount');
const cancelDelete = document.getElementById('cancelDelete');
if (deleteAccountBtn && deleteAccountModal) {
deleteAccountBtn.onclick = function() {
deleteAccountModal.style.display = 'block';
};
}
if (closeDeleteAccount && deleteAccountModal) {
closeDeleteAccount.onclick = function() {
deleteAccountModal.style.display = 'none';
};
}
if (cancelDelete && deleteAccountModal) {
cancelDelete.onclick = function() {
deleteAccountModal.style.display = 'none';
};
}
// Window click handler for both modals
window.addEventListener('click', function(e) {
if (addEmailModal && e.target === addEmailModal) {
addEmailModal.style.display = 'none';
}
if (deleteAccountModal && e.target === deleteAccountModal) {
deleteAccountModal.style.display = 'none';
}
});
}
// Raw mail tab switcher
function initRawTabs() {
const buttons = document.querySelectorAll('.raw-tab-button');
const panels = document.querySelectorAll('.raw-mail[data-panel]');
if (buttons.length === 0) return;
buttons.forEach(btn => {
btn.addEventListener('click', () => {
const target = btn.dataset.target;
buttons.forEach(b => b.classList.toggle('active', b === btn));
panels.forEach(p => p.classList.toggle('hidden', p.dataset.panel !== target));
});
});
}
// Expose utilities and run them // Expose utilities and run them
window.utils = { formatEmailDates, formatMailDate, initLockModals, initCopyAddress, initExpiryTimers, initQrModal, initHamburgerMenu, initThemeToggle, initRefreshCountdown, initCryptoKeysToggle, initForwardModal, initForwardAllModal }; window.utils = { formatEmailDates, formatMailDate, initLockModals, initCopyAddress, initExpiryTimers, initQrModal, initHamburgerMenu, initThemeToggle, initRefreshCountdown, initCryptoKeysToggle, initForwardModal, initForwardAllModal, initAccountModals, initRawTabs };
formatEmailDates(); formatEmailDates();
formatMailDate(); formatMailDate();
initLockModals(); initLockModals();
@ -509,4 +577,6 @@ document.addEventListener('DOMContentLoaded', () => {
initThemeToggle(); initThemeToggle();
initForwardModal(); initForwardModal();
initCryptoKeysToggle(); initCryptoKeysToggle();
initAccountModals();
initRawTabs();
}); });

View file

@ -1842,6 +1842,9 @@ label {
border: 1px solid var(--overlay-white-10); border: 1px solid var(--overlay-white-10);
width: 100%; width: 100%;
min-height: 40vh; min-height: 40vh;
max-height: 100vh;
resize: vertical;
overflow: auto;
} }
.mail-text-content { .mail-text-content {
@ -2115,6 +2118,7 @@ label {
} }
.close { .close {
text-align: right;
float: right; float: right;
font-size: 2.8rem; font-size: 2.8rem;
font-weight: bold; font-weight: bold;
@ -2887,7 +2891,7 @@ body.light-mode .theme-icon-light {
} }
.action-dropdown .dropdown-menu a { .action-dropdown .dropdown-menu a {
padding: 10px 0; padding: 10px 0;
text-align: left; text-align: right;
border: none !important; border: none !important;
} }
.action-dropdown .dropdown-menu a:hover { .action-dropdown .dropdown-menu a:hover {
@ -2924,7 +2928,7 @@ body.light-mode .theme-icon-light {
.action-links.mobile-open>button:not(.hamburger-menu) { .action-links.mobile-open>button:not(.hamburger-menu) {
display: block; display: block;
width: 100%; width: 100%;
text-align: left; text-align: right;
padding: 10px 0; padding: 10px 0;
border: none; border: none;
border-radius: 0; border-radius: 0;

View file

@ -3,16 +3,33 @@ const express = require('express')
const router = express.Router() const router = express.Router()
const { requireAuth } = require('../middleware/auth') const { requireAuth } = require('../middleware/auth')
const { body, validationResult } = require('express-validator') const { body, validationResult } = require('express-validator')
const templateContext = require('../template-context')
// GET /account - Account dashboard // GET /account - Account dashboard
router.get('/account', requireAuth, async(req, res) => { router.get('/account', requireAuth, async(req, res) => {
try { try {
const config = req.app.get('config')
const userRepository = req.app.get('userRepository') const userRepository = req.app.get('userRepository')
const inboxLock = req.app.get('inboxLock') const inboxLock = req.app.get('inboxLock')
const mailProcessingService = req.app.get('mailProcessingService') const mailProcessingService = req.app.get('mailProcessingService')
const Helper = require('../../../application/helper') const Helper = require('../../../application/helper')
const helper = new Helper() const helper = new Helper()
// In UX debug mode, reset mock data to initial state only on fresh page load
// (not on redirects after form submissions)
if (config.uxDebugMode && userRepository && userRepository.reset) {
// Check if this is a redirect from a form submission
const isRedirect = req.session.accountSuccess || req.session.accountError
if (!isRedirect) {
// This is a fresh page load, reset to initial state
userRepository.reset()
if (inboxLock && inboxLock.reset) {
inboxLock.reset()
}
}
}
// Get user's verified forwarding emails // Get user's verified forwarding emails
const forwardEmails = userRepository.getForwardEmails(req.session.userId) const forwardEmails = userRepository.getForwardEmails(req.session.userId)
@ -23,27 +40,22 @@ router.get('/account', requireAuth, async(req, res) => {
} }
// Get user stats // Get user stats
const config = req.app.get('config')
const stats = userRepository.getUserStats(req.session.userId, config.user) const stats = userRepository.getUserStats(req.session.userId, config.user)
// Get purge time for footer const successMessage = req.session.accountSuccess
const purgeTime = helper.purgeTimeElemetBuilder() const errorMessage = req.session.accountError
delete req.session.accountSuccess
delete req.session.accountError
res.render('account', { res.render('account', templateContext.build(req, {
title: 'Account Dashboard', title: 'Account Dashboard',
username: req.session.username, username: req.session.username,
forwardEmails, forwardEmails,
lockedInboxes, lockedInboxes,
stats, stats,
branding: config.http.branding, successMessage,
purgeTime: purgeTime, errorMessage
successMessage: req.session.accountSuccess, }))
errorMessage: req.session.accountError
})
// Clear flash messages
delete req.session.accountSuccess
delete req.session.accountError
} catch (error) { } catch (error) {
console.error('Account page error:', error) console.error('Account page error:', error)
res.status(500).render('error', { res.status(500).render('error', {
@ -94,7 +106,7 @@ router.post('/account/forward-email/add',
// Send verification email // Send verification email
const baseUrl = config.http.baseUrl const baseUrl = config.http.baseUrl
const branding = config.http.branding[0] const branding = (config.http.features.branding || ['48hr.email'])[0]
await smtpService.sendVerificationEmail( await smtpService.sendVerificationEmail(
email, email,
@ -228,6 +240,13 @@ router.post('/account/change-password',
body('confirmNewPassword').notEmpty().withMessage('Password confirmation is required'), body('confirmNewPassword').notEmpty().withMessage('Password confirmation is required'),
async(req, res) => { async(req, res) => {
try { try {
const config = req.app.get('config')
// Block password change in UX debug mode
if (config.uxDebugMode) {
req.session.accountError = 'Password changes are disabled in UX debug mode'
return res.redirect('/account')
}
const errors = validationResult(req) const errors = validationResult(req)
if (!errors.isEmpty()) { if (!errors.isEmpty()) {
req.session.accountError = errors.array()[0].msg req.session.accountError = errors.array()[0].msg
@ -281,6 +300,13 @@ router.post('/account/delete',
body('confirmText').equals('DELETE').withMessage('You must type DELETE to confirm'), body('confirmText').equals('DELETE').withMessage('You must type DELETE to confirm'),
async(req, res) => { async(req, res) => {
try { try {
const config = req.app.get('config')
// Block account deletion in UX debug mode
if (config.uxDebugMode) {
req.session.accountError = 'Account deletion is disabled in UX debug mode'
return res.redirect('/account')
}
const errors = validationResult(req) const errors = validationResult(req)
if (!errors.isEmpty()) { if (!errors.isEmpty()) {
req.session.accountError = errors.array()[0].msg req.session.accountError = errors.array()[0].msg

View file

@ -4,10 +4,7 @@ const { body, validationResult } = require('express-validator')
const debug = require('debug')('48hr-email:auth-routes') const debug = require('debug')('48hr-email:auth-routes')
const { redirectIfAuthenticated } = require('../middleware/auth') const { redirectIfAuthenticated } = require('../middleware/auth')
const config = require('../../../application/config') const config = require('../../../application/config')
const Helper = require('../../../application/helper') const templateContext = require('../template-context')
const helper = new Helper()
const purgeTime = helper.purgeTimeElemetBuilder()
// Simple in-memory rate limiters for registration and login // Simple in-memory rate limiters for registration and login
const registrationRateLimitStore = new Map() const registrationRateLimitStore = new Map()
@ -79,23 +76,30 @@ const loginRateLimiter = (req, res, next) => {
next() next()
} }
// Middleware to capture redirect URL
router.use((req, res, next) => {
if (req.method === 'GET' && req.path === '/auth') {
const referer = req.get('Referer')
const redirectParam = req.query.redirect
const redirectUrl = redirectParam || (referer && !referer.includes('/auth') ? referer : null)
if (redirectUrl) {
req.session.redirectAfterLogin = redirectUrl
}
}
next()
})
// GET /auth - Show unified auth page (login or register) // GET /auth - Show unified auth page (login or register)
router.get('/auth', redirectIfAuthenticated, (req, res) => { router.get('/auth', redirectIfAuthenticated, (req, res) => {
const config = req.app.get('config') const config = req.app.get('config')
const errorMessage = req.session.errorMessage
const successMessage = req.session.successMessage const successMessage = req.session.successMessage
// Clear messages after reading
delete req.session.errorMessage
delete req.session.successMessage delete req.session.successMessage
res.render('auth', { res.render('auth', templateContext.build(req, {
title: `Login or Register | ${config.http.branding[0]}`, title: `Login or Register | ${(config.http.features.branding || ['48hr.email'])[0]}`,
branding: config.http.branding,
purgeTime: purgeTime,
errorMessage,
successMessage successMessage
}) }))
}) })
// POST /register - Process registration // POST /register - Process registration

View file

@ -1,13 +1,9 @@
const express = require('express') const express = require('express')
const router = new express.Router() const router = new express.Router()
const config = require('../../../application/config') const config = require('../../../application/config')
const Helper = require('../../../application/helper') const templateContext = require('../template-context')
const helper = new(Helper)
const debug = require('debug')('48hr-email:routes') const debug = require('debug')('48hr-email:routes')
const purgeTime = helper.purgeTimeElemetBuilder()
router.get('/:address/:errorCode', async(req, res, next) => { router.get('/:address/:errorCode', async(req, res, next) => {
try { try {
const mailProcessingService = req.app.get('mailProcessingService') const mailProcessingService = req.app.get('mailProcessingService')
@ -19,15 +15,13 @@ router.get('/:address/:errorCode', async(req, res, next) => {
const message = req.query.message || (req.session && req.session.errorMessage) || 'An error occurred' const message = req.query.message || (req.session && req.session.errorMessage) || 'An error occurred'
debug(`Rendering error page ${errorCode} with message: ${message}`) debug(`Rendering error page ${errorCode} with message: ${message}`)
const branding = config.http.features.branding || ['48hr.email', 'Service', 'https://example.com']
res.status(errorCode) res.status(errorCode)
res.render('error', { res.render('error', templateContext.build(req, {
title: `${config.http.branding[0]} | ${errorCode}`, title: `${branding[0]} | ${errorCode}`,
purgeTime: purgeTime,
address: req.params.address,
message: message, message: message,
status: errorCode, status: errorCode
branding: config.http.branding }))
})
} catch (error) { } catch (error) {
debug('Error loading error page:', error.message) debug('Error loading error page:', error.message)
console.error('Error while loading error page', error) console.error('Error while loading error page', error)

View file

@ -6,6 +6,7 @@ const debug = require('debug')('48hr-email:routes')
const config = require('../../../application/config') const config = require('../../../application/config')
const Helper = require('../../../application/helper') const Helper = require('../../../application/helper')
const CryptoDetector = require('../../../application/crypto-detector') const CryptoDetector = require('../../../application/crypto-detector')
const templateContext = require('../template-context')
const helper = new(Helper) const helper = new(Helper)
const cryptoDetector = new CryptoDetector() const cryptoDetector = new CryptoDetector()
const { checkLockAccess } = require('../middleware/lock') const { checkLockAccess } = require('../middleware/lock')
@ -105,67 +106,11 @@ router.get('^/:address([^@/]+@[^@/]+)', sanitizeAddress, validateDomain, optiona
throw new Error('Mail processing service not available') throw new Error('Mail processing service not available')
} }
debug(`Inbox request for ${req.params.address}`) debug(`Inbox request for ${req.params.address}`)
const inboxLock = req.app.get('inboxLock')
// Check lock status res.render('inbox', templateContext.build(req, {
const isLocked = inboxLock && inboxLock.isLocked(req.params.address) title: `${(config.http.features.branding || ['48hr.email'])[0]} | ` + req.params.address,
const userId = req.session && req.session.userId mailSummaries: mailProcessingService.getMailSummaries(req.params.address)
const isAuthenticated = req.session && req.session.isAuthenticated }))
// Check if user has access (either owns the lock or has session access)
const hasAccess = isAuthenticated && userId && inboxLock ?
(inboxLock.isLockedByUser(req.params.address, userId) || req.session.lockedInbox === req.params.address) :
(req.session && req.session.lockedInbox === req.params.address)
// Get user's verified emails if logged in
let userForwardEmails = []
if (req.session && req.session.userId) {
const userRepository = req.app.get('userRepository')
if (userRepository) {
userForwardEmails = userRepository.getForwardEmails(req.session.userId)
}
}
// Pull any lock error from session and clear it after reading
const lockError = req.session ? req.session.lockError : undefined
const unlockErrorSession = req.session ? req.session.unlockError : undefined
const errorMessage = req.session ? req.session.errorMessage : undefined
if (req.session) {
delete req.session.lockError
delete req.session.unlockError
delete req.session.errorMessage
}
// Check for forward all success flag
const forwardAllSuccess = req.query.forwardedAll ? parseInt(req.query.forwardedAll) : null
// Check for verification sent flag
const verificationSent = req.query.verificationSent === 'true'
const verificationEmail = req.query.email || ''
res.render('inbox', {
title: `${config.http.branding[0]} | ` + req.params.address,
purgeTime: purgeTime,
address: req.params.address,
mailSummaries: mailProcessingService.getMailSummaries(req.params.address),
branding: config.http.branding,
authEnabled: config.user.authEnabled,
isAuthenticated: req.session && req.session.userId ? true : false,
userForwardEmails: userForwardEmails,
isLocked: isLocked,
hasAccess: hasAccess,
unlockError: unlockErrorSession,
locktimer: config.user.lockReleaseHours,
error: lockError,
redirectTo: req.originalUrl,
expiryTime: config.email.purgeTime.time,
expiryUnit: config.email.purgeTime.unit,
refreshInterval: config.imap.refreshIntervalSeconds,
errorMessage: errorMessage,
forwardAllSuccess: forwardAllSuccess,
verificationSent: verificationSent,
verificationEmail: verificationEmail
})
} catch (error) { } catch (error) {
debug(`Error loading inbox for ${req.params.address}:`, error.message) debug(`Error loading inbox for ${req.params.address}:`, error.message)
console.error('Error while loading inbox', error) console.error('Error while loading inbox', error)
@ -200,57 +145,13 @@ router.get(
const cryptoAttachments = cryptoDetector.detectCryptoAttachments(mail.attachments) const cryptoAttachments = cryptoDetector.detectCryptoAttachments(mail.attachments)
debug(`Found ${cryptoAttachments.length} cryptographic attachments`) debug(`Found ${cryptoAttachments.length} cryptographic attachments`)
const inboxLock = req.app.get('inboxLock')
const isLocked = inboxLock && inboxLock.isLocked(req.params.address)
const userId = req.session && req.session.userId
const isAuthenticated = req.session && req.session.isAuthenticated
// Check if user has access (either owns the lock or has session access)
const hasAccess = isAuthenticated && userId && inboxLock ?
(inboxLock.isLockedByUser(req.params.address, userId) || req.session.lockedInbox === req.params.address) :
(req.session && req.session.lockedInbox === req.params.address)
// Get user's verified emails if logged in
let userForwardEmails = []
if (req.session && req.session.userId) {
const userRepository = req.app.get('userRepository')
if (userRepository) {
userForwardEmails = userRepository.getForwardEmails(req.session.userId)
}
}
// Pull error message from session and clear it
const errorMessage = req.session ? req.session.errorMessage : undefined
if (req.session) {
delete req.session.errorMessage
}
// Check for forward success flag
const forwardSuccess = req.query.forwarded === 'true'
// Check for verification sent flag
const verificationSent = req.query.verificationSent === 'true'
const verificationEmail = req.query.email || ''
debug(`Rendering email view for UID ${req.params.uid}`) debug(`Rendering email view for UID ${req.params.uid}`)
res.render('mail', { res.render('mail', templateContext.build(req, {
title: mail.subject + " | " + req.params.address, title: mail.subject + " | " + req.params.address,
purgeTime: purgeTime,
address: req.params.address,
mail, mail,
cryptoAttachments: cryptoAttachments, cryptoAttachments: cryptoAttachments,
uid: req.params.uid, uid: req.params.uid
branding: config.http.branding, }))
authEnabled: config.user.authEnabled,
isAuthenticated: req.session && req.session.userId ? true : false,
userForwardEmails: userForwardEmails,
isLocked: isLocked,
hasAccess: hasAccess,
errorMessage: errorMessage,
forwardSuccess: forwardSuccess,
verificationSent: verificationSent,
verificationEmail: verificationEmail
})
} else { } else {
debug(`Email ${req.params.uid} not found for ${req.params.address}`) debug(`Email ${req.params.uid} not found for ${req.params.address}`)
req.session.errorMessage = 'This mail could not be found. It either does not exist or has been deleted from our servers!' req.session.errorMessage = 'This mail could not be found. It either does not exist or has been deleted from our servers!'
@ -422,11 +323,11 @@ router.get(
// Emails are immutable, cache if found // Emails are immutable, cache if found
res.set('Cache-Control', 'private, max-age=600') res.set('Cache-Control', 'private, max-age=600')
debug(`Rendering raw email view for UID ${req.params.uid}`) debug(`Rendering raw email view for UID ${req.params.uid}`)
res.render('raw', { res.render('raw', templateContext.build(req, {
title: req.params.uid + " | raw | " + req.params.address, title: req.params.uid + " | raw | " + req.params.address,
mail: rawMail, mail: rawMail,
decoded: decodedMail decoded: decodedMail
}) }))
} else { } else {
debug(`Raw email ${uid} not found for ${req.params.address}`) debug(`Raw email ${uid} not found for ${req.params.address}`)
req.session.errorMessage = 'This mail could not be found. It either does not exist or has been deleted from our servers!' req.session.errorMessage = 'This mail could not be found. It either does not exist or has been deleted from our servers!'
@ -440,10 +341,21 @@ router.get(
} }
) )
// Middleware to check if SMTP is enabled
const smtpEnabled = (req, res, next) => {
if (!config.email.features.smtp) {
debug('SMTP forwarding is disabled')
req.session.errorMessage = 'Email forwarding is currently disabled.'
return res.redirect(`/inbox/${req.params.address}` + (req.params.uid ? `/${req.params.uid}` : ''))
}
next()
}
// POST route for forwarding a single email (requires authentication) // POST route for forwarding a single email (requires authentication)
router.post( router.post(
'^/:address/:uid/forward', '^/:address/:uid/forward',
requireAuth, requireAuth,
smtpEnabled,
forwardLimiter, forwardLimiter,
validateDomain, validateDomain,
checkLockAccess, checkLockAccess,
@ -503,6 +415,7 @@ router.post(
router.post( router.post(
'^/:address/forward-all', '^/:address/forward-all',
requireAuth, requireAuth,
smtpEnabled,
forwardLimiter, forwardLimiter,
validateDomain, validateDomain,
checkLockAccess, checkLockAccess,

View file

@ -2,13 +2,9 @@ const express = require('express')
const router = new express.Router() const router = new express.Router()
const { check, validationResult } = require('express-validator') const { check, validationResult } = require('express-validator')
const debug = require('debug')('48hr-email:routes') const debug = require('debug')('48hr-email:routes')
const randomWord = require('random-word') const randomWord = require('random-word')
const config = require('../../../application/config') const config = require('../../../application/config')
const Helper = require('../../../application/helper') const templateContext = require('../template-context')
const helper = new(Helper)
const purgeTime = helper.purgeTimeElemetBuilder()
router.get('/', async(req, res, next) => { router.get('/', async(req, res, next) => {
try { try {
@ -17,14 +13,12 @@ router.get('/', async(req, res, next) => {
throw new Error('Mail processing service not available') throw new Error('Mail processing service not available')
} }
debug('Login page requested') debug('Login page requested')
const context = templateContext.build(req, {
username: randomWord()
})
res.render('login', { res.render('login', {
title: `${config.http.branding[0]} | Your temporary Inbox`, ...context,
username: randomWord(), title: `${context.branding[0]} | Your temporary Inbox`
purgeTime: purgeTime,
purgeTimeRaw: config.email.purgeTime,
domains: helper.getDomains(),
branding: config.http.branding,
example: config.email.examples.account,
}) })
} catch (error) { } catch (error) {
debug('Error loading login page:', error.message) debug('Error loading login page:', error.message)
@ -56,14 +50,13 @@ router.post(
const errors = validationResult(req) const errors = validationResult(req)
if (!errors.isEmpty()) { if (!errors.isEmpty()) {
debug(`Login validation failed for ${req.body.username}@${req.body.domain}: ${errors.array().map(e => e.msg).join(', ')}`) debug(`Login validation failed for ${req.body.username}@${req.body.domain}: ${errors.array().map(e => e.msg).join(', ')}`)
return res.render('login', { const context = templateContext.build(req, {
userInputError: true, userInputError: true,
title: `${config.http.branding[0]} | Your temporary Inbox`, username: randomWord()
purgeTime: purgeTime, })
purgeTimeRaw: config.email.purgeTime, return res.render('login', {
username: randomWord(), ...context,
domains: helper.getDomains(), title: `${context.branding[0]} | Your temporary Inbox`
branding: config.http.branding,
}) })
} }

View file

@ -1,15 +1,101 @@
const express = require('express') const express = require('express')
const router = new express.Router() const router = new express.Router()
const debug = require('debug')('48hr-email:stats-routes') const debug = require('debug')('48hr-email:stats-routes')
const templateContext = require('../template-context')
// GET /stats - Statistics page // GET /stats - Statistics page with lazy loading
router.get('/', async(req, res) => { router.get('/', async(req, res) => {
try { try {
const config = req.app.get('config') const config = req.app.get('config')
// Check if statistics are enabled // Check if statistics are enabled
if (!config.http.statisticsEnabled) { if (!config.http.features.statistics) {
return res.status(404).send('Statistics are disabled') req.session.alertMessage = 'Statistics are disabled'
const referer = req.get('Referer')
// Don't redirect to /stats itself to avoid infinite loops
const redirectUrl = (referer && !referer.includes('/stats')) ? referer : '/'
return res.redirect(redirectUrl)
}
const branding = config.http.features.branding || ['48hr.email', 'Service', 'https://example.com']
// Get minimal stats for meta tags (non-blocking, quick)
const statisticsStore = req.app.get('statisticsStore')
const mailProcessingService = req.app.get('mailProcessingService')
let metaStats = {
currentCount: 0,
allTimeTotal: 0
}
if (statisticsStore) {
try {
const quickStats = statisticsStore.getEnhancedStats()
metaStats = {
currentCount: quickStats.currentCount || 0,
allTimeTotal: quickStats.allTimeTotal || 0
}
} catch (error) {
debug(`Error getting meta stats: ${error.message}`)
}
}
// Fallback to mailProcessingService if stats are still 0
if (metaStats.currentCount === 0 && mailProcessingService) {
try {
metaStats.currentCount = mailProcessingService.getCount() || 0
} catch (error) {
debug(`Error getting count from mailProcessingService: ${error.message}`)
}
}
// Return page with placeholder data immediately - real data loads via JS
const placeholderStats = {
currentCount: '...',
allTimeTotal: '...',
last24Hours: {
receives: '...',
deletes: '...',
forwards: '...',
timeline: []
},
enhanced: {
topSenderDomains: [],
topRecipientDomains: [],
busiestHours: [],
uniqueSenderDomains: '...',
uniqueRecipientDomains: '...',
averageSubjectLength: '...',
peakHourPercentage: '...',
emailsPerHour: '...',
dayPercentage: '...'
},
historical: [],
prediction: []
}
debug(`Stats page requested - returning with lazy loading`)
res.render('stats', templateContext.build(req, {
title: `Statistics | ${branding[0]}`,
stats: placeholderStats,
metaStats: metaStats,
lazyLoad: true
}))
} catch (error) {
debug(`Error loading stats page: ${error.message}`)
console.error('Error while loading stats page', error)
res.status(500).send('Error loading statistics')
}
})
// GET /stats/api - JSON API for lazy-loaded stats (full calculation)
router.get('/api', async(req, res) => {
try {
const config = req.app.get('config')
// Check if statistics are enabled
if (!config.http.features.statistics) {
return res.status(403).json({ error: 'Statistics are disabled' })
} }
const statisticsStore = req.app.get('statisticsStore') const statisticsStore = req.app.get('statisticsStore')
@ -32,45 +118,13 @@ router.get('/', async(req, res) => {
} }
const stats = statisticsStore.getEnhancedStats() const stats = statisticsStore.getEnhancedStats()
const purgeTime = helper.purgeTimeElemetBuilder()
debug(`Stats page requested: ${stats.currentCount} current, ${stats.allTimeTotal} all-time total, ${stats.historical.length} historical points`) debug(`Stats API returned: ${stats.currentCount} current, ${stats.allTimeTotal} all-time total`)
res.render('stats', {
title: `Statistics | ${config.http.branding[0]}`,
branding: config.http.branding,
purgeTime: purgeTime,
stats: stats,
authEnabled: config.user.authEnabled,
currentUser: req.session && req.session.username
})
} catch (error) {
debug(`Error loading stats page: ${error.message}`)
console.error('Error while loading stats page', error)
res.status(500).send('Error loading statistics')
}
})
// GET /stats/api - JSON API for real-time updates
router.get('/api', async(req, res) => {
try {
const statisticsStore = req.app.get('statisticsStore')
const imapService = req.app.get('imapService')
const Helper = require('../../../application/helper')
const helper = new Helper()
// Update largest UID before getting stats (if IMAP is ready)
if (imapService) {
const largestUid = await helper.getLargestUid(imapService)
statisticsStore.updateLargestUid(largestUid)
}
// Use lightweight stats - no historical analysis on API calls
const stats = statisticsStore.getLightweightStats()
res.json(stats) res.json(stats)
} catch (error) { } catch (error) {
debug(`Error fetching stats API: ${error.message}`) debug(`Error fetching stats API: ${error.message}`)
console.error('Stats API error:', error)
res.status(500).json({ error: 'Failed to fetch statistics' }) res.status(500).json({ error: 'Failed to fetch statistics' })
} }
}) })

View file

@ -0,0 +1,115 @@
const config = require('../../application/config')
const Helper = require('../../application/helper')
/**
* Template Context Builder
* Generates common variables for all template renders
*/
class TemplateContext {
constructor() {
this.helper = new Helper()
this.purgeTime = this.helper.purgeTimeElemetBuilder()
// Cache domains to avoid reprocessing on every request
this.cachedDomains = this.helper.getDomains()
}
/**
* Get base context that should be available in all templates
* @param {Object} req - Express request object
* @returns {Object} Base template context
*/
getBaseContext(req) {
const inboxLock = req.app.get('inboxLock')
const address = req.params && req.params.address
const userId = req.session && req.session.userId
const isAuthenticated = !!(req.session && req.session.userId)
// Calculate lock status for current address
const isLocked = address && inboxLock ? inboxLock.isLocked(address) : false
const hasAccess = address && isAuthenticated && userId && inboxLock ?
(inboxLock.isLockedByUser(address, userId) || req.session.lockedInbox === address) :
(address && req.session && req.session.lockedInbox === address)
// Get user's verified forward emails if logged in
let userForwardEmails = []
if (isAuthenticated && userId) {
const userRepository = req.app.get('userRepository')
if (userRepository) {
userForwardEmails = userRepository.getForwardEmails(userId)
}
}
return {
// Config values
config: config,
branding: config.http.features.branding || ['48hr.email', 'Service', 'https://example.com'],
purgeTime: this.purgeTime,
purgeTimeRaw: config.email.purgeTime,
expiryTime: config.email.purgeTime.time,
expiryUnit: config.email.purgeTime.unit,
refreshInterval: config.imap.refreshIntervalSeconds,
locktimer: config.user.lockReleaseHours,
// Feature flags
authEnabled: config.user.authEnabled,
statisticsEnabled: config.http.features.statistics,
smtpEnabled: config.email.features.smtp,
showInfoSection: config.http.features.infoSection,
// User session & authentication
currentUser: req.session && req.session.username ? req.session.username : null,
isAuthenticated: isAuthenticated,
userForwardEmails: userForwardEmails,
// Lock status
isLocked: isLocked,
hasAccess: hasAccess,
// Session messages/errors (auto-clear after reading)
error: this._getAndClearSession(req, 'lockError'),
unlockError: this._getAndClearSession(req, 'unlockError'),
errorMessage: this._getAndClearSession(req, 'errorMessage'),
// Query parameters
verificationSent: req.query && req.query.verificationSent === 'true',
verificationEmail: req.query && req.query.email || '',
forwardSuccess: req.query && req.query.forwarded === 'true',
forwardAllSuccess: req.query && req.query.forwardedAll ? parseInt(req.query.forwardedAll) : null,
// Request info
redirectTo: req.originalUrl,
address: address,
// Common data
domains: this.cachedDomains,
example: config.email.examples.account
}
}
/**
* Helper to get and clear session value
* @private
*/
_getAndClearSession(req, key) {
if (!req.session) return undefined
const value = req.session[key]
delete req.session[key]
return value
}
/**
* Merge base context with page-specific data
* @param {Object} req - Express request object
* @param {Object} pageData - Page-specific template data
* @returns {Object} Complete template context
*/
build(req, pageData = {}) {
return {
...this.getBaseContext(req),
...pageData
}
}
}
// Export singleton instance
module.exports = new TemplateContext()

View file

@ -44,10 +44,12 @@
<div class="account-card frosted-glass"> <div class="account-card frosted-glass">
<h2>Account Overview</h2> <h2>Account Overview</h2>
<div class="stats-grid"> <div class="stats-grid">
{% if smtpEnabled %}
<div class="stat-item"> <div class="stat-item">
<div class="stat-value">{{ stats.forwardEmailsCount }}/{{ stats.maxForwardEmails }}</div> <div class="stat-value">{{ stats.forwardEmailsCount }}/{{ stats.maxForwardEmails }}</div>
<div class="stat-label">Forward Emails</div> <div class="stat-label">Forward Emails</div>
</div> </div>
{% endif %}
<div class="stat-item"> <div class="stat-item">
<div class="stat-value">{{ stats.lockedInboxesCount }}/{{ stats.maxLockedInboxes }}</div> <div class="stat-value">{{ stats.lockedInboxesCount }}/{{ stats.maxLockedInboxes }}</div>
<div class="stat-label">Locked Inboxes</div> <div class="stat-label">Locked Inboxes</div>
@ -59,6 +61,7 @@
</div> </div>
</div> </div>
{% if smtpEnabled %}
<!-- Forwarding Emails Section --> <!-- Forwarding Emails Section -->
<div class="account-card frosted-glass"> <div class="account-card frosted-glass">
<h2>Forwarding Emails</h2> <h2>Forwarding Emails</h2>
@ -91,6 +94,7 @@
<p class="limit-reached">Maximum {{ stats.maxForwardEmails }} emails reached</p> <p class="limit-reached">Maximum {{ stats.maxForwardEmails }} emails reached</p>
{% endif %} {% endif %}
</div> </div>
{% endif %}
<!-- Locked Inboxes Section --> <!-- Locked Inboxes Section -->
<div class="account-card frosted-glass"> <div class="account-card frosted-glass">
@ -178,7 +182,7 @@
<div class="danger-content"> <div class="danger-content">
<p><strong>Warning:</strong> Deleting your account will:</p> <p><strong>Warning:</strong> Deleting your account will:</p>
<ul class="danger-list"> <ul class="danger-list">
<li>Remove all forwarding email addresses</li> {% if smtpEnabled %}<li>Remove all forwarding email addresses</li>{% endif %}
<li>Release all locked inboxes</li> <li>Release all locked inboxes</li>
<li>Permanently delete your account data</li> <li>Permanently delete your account data</li>
</ul> </ul>
@ -226,6 +230,7 @@
</div> </div>
</div> </div>
{% if smtpEnabled %}
<!-- Add Email Modal --> <!-- Add Email Modal -->
<div id="addEmailModal" class="modal"> <div id="addEmailModal" class="modal">
<div class="modal-content"> <div class="modal-content">
@ -249,56 +254,5 @@
</form> </form>
</div> </div>
</div> </div>
{% endif %}
<script>
// Add Email Modal
const addEmailBtn = document.getElementById('addEmailBtn');
const addEmailModal = document.getElementById('addEmailModal');
const closeAddEmail = document.getElementById('closeAddEmail');
if (addEmailBtn) {
addEmailBtn.onclick = function() {
addEmailModal.style.display = 'block';
}
}
if (closeAddEmail) {
closeAddEmail.onclick = function() {
addEmailModal.style.display = 'none';
}
}
// Delete Account Modal
const deleteAccountBtn = document.getElementById('deleteAccountBtn');
const deleteAccountModal = document.getElementById('deleteAccountModal');
const closeDeleteAccount = document.getElementById('closeDeleteAccount');
const cancelDelete = document.getElementById('cancelDelete');
if (deleteAccountBtn) {
deleteAccountBtn.onclick = function() {
deleteAccountModal.style.display = 'block';
}
}
if (closeDeleteAccount) {
closeDeleteAccount.onclick = function() {
deleteAccountModal.style.display = 'none';
}
}
if (cancelDelete) {
cancelDelete.onclick = function() {
deleteAccountModal.style.display = 'none';
}
}
window.onclick = function(event) {
if (event.target == addEmailModal) {
addEmailModal.style.display = 'none';
}
if (event.target == deleteAccountModal) {
deleteAccountModal.style.display = 'none';
}
}
</script>
{% endblock %} {% endblock %}

View file

@ -123,9 +123,11 @@
<div class="auth-features-unified"> <div class="auth-features-unified">
<h3>Account Benefits</h3> <h3>Account Benefits</h3>
<div class="features-grid"> <div class="features-grid">
{% if smtpEnabled %}
<div class="feature-item">Forward emails to verified addresses</div> <div class="feature-item">Forward emails to verified addresses</div>
<div class="feature-item">Lock up to 5 inboxes to your account</div>
<div class="feature-item">Manage multiple forwarding destinations</div> <div class="feature-item">Manage multiple forwarding destinations</div>
{% endif %}
<div class="feature-item">Lock up to 5 inboxes to your account</div>
<div class="feature-item">Access your locked inboxes anywhere</div> <div class="feature-item">Access your locked inboxes anywhere</div>
</div> </div>
<p class="guest-note">No account needed for basic temporary inboxes • <a href="/">Browse as guest</a></p> <p class="guest-note">No account needed for basic temporary inboxes • <a href="/">Browse as guest</a></p>

View file

@ -7,7 +7,9 @@
<div class="action-dropdown"> <div class="action-dropdown">
<button class="dropdown-toggle" aria-label="Inbox actions">Inbox ▾</button> <button class="dropdown-toggle" aria-label="Inbox actions">Inbox ▾</button>
<div class="dropdown-menu" data-section-title="Inbox Actions"> <div class="dropdown-menu" data-section-title="Inbox Actions">
{% if smtpEnabled %}
<a href="#" id="forwardAllBtn" aria-label="Forward all emails">Forward All</a> <a href="#" id="forwardAllBtn" aria-label="Forward all emails">Forward All</a>
{% endif %}
{% if authEnabled %} {% if authEnabled %}
{% if isLocked and hasAccess %} {% if isLocked and hasAccess %}
<a href="#" id="removeLockBtn" aria-label="Remove lock">Remove Lock</a> <a href="#" id="removeLockBtn" aria-label="Remove lock">Remove Lock</a>
@ -17,9 +19,7 @@
{% endif %} {% endif %}
<a href="/inbox/{{ address }}/delete-all" aria-label="Delete all emails">Wipe Inbox</a> <a href="/inbox/{{ address }}/delete-all" aria-label="Delete all emails">Wipe Inbox</a>
</div> </div>
</div> </div> <!-- Account Dropdown (logged in) -->
<!-- Account Dropdown (logged in) -->
{% if authEnabled %} {% if authEnabled %}
<div class="action-dropdown"> <div class="action-dropdown">
<button class="dropdown-toggle" aria-label="Account menu">Account ▾</button> <button class="dropdown-toggle" aria-label="Account menu">Account ▾</button>
@ -178,6 +178,7 @@
</div> </div>
<!-- Forward All Modal --> <!-- Forward All Modal -->
{% if smtpEnabled %}
<div id="forwardAllModal" class="modal" style="display: none;"> <div id="forwardAllModal" class="modal" style="display: none;">
<div class="modal-content"> <div class="modal-content">
<span class="close" id="closeForwardAll">&times;</span> <span class="close" id="closeForwardAll">&times;</span>
@ -216,4 +217,5 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% endif %}
{% endblock %} {% endblock %}

View file

@ -9,27 +9,27 @@
{% block metaTags %} {% block metaTags %}
<!-- SEO Meta Tags --> <!-- SEO Meta Tags -->
<meta name="description" content="Your temporary Inbox. Create instant throwaway email addresses to protect your privacy. No registration required. Emails auto-delete after 48 hours."> <meta name="description" content="Your temporary Inbox. Create instant throwaway email addresses to protect your privacy. No registration required. Emails auto-delete after {{ purgeTimeRaw | readablePurgeTime }}.">
<meta name="keywords" content="temporary email, disposable email, throwaway email, fake email, temp mail, anonymous email, 48hr email, privacy protection, burner email"> <meta name="keywords" content="temporary email, disposable email, throwaway email, fake email, temp mail, anonymous email, 48hr email, privacy protection, burner email">
<meta name="author" content="CrazyCo"> <meta name="author" content="{{ branding.1 }}">
<meta name="robots" content="index, follow"> <meta name="robots" content="index, follow">
<meta name="googlebot" content="index, follow"> <meta name="googlebot" content="index, follow">
<link rel="canonical" href="https://48hr.email/"> <link rel="canonical" href="{{ config.http.baseUrl }}/">
<!-- Open Graph / Facebook --> <!-- Open Graph / Facebook -->
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:url" content="https://48hr.email/"> <meta property="og:url" content="{{ config.http.baseUrl }}/">
<meta property="og:title" content="48hr.email - Your temporary Inbox"> <meta property="og:title" content="{{ branding.0 }} - Your temporary Inbox">
<meta property="og:description" content="Protect your privacy with free temporary email addresses. No registration required. Emails auto-delete after 48 hours."> <meta property="og:description" content="Protect your privacy with free temporary email addresses. No registration required. Emails auto-delete after {{ purgeTimeRaw | readablePurgeTime }}.">
<meta property="og:image" content="https://48hr.email/images/logo.png"> <meta property="og:image" content="{{ config.http.baseUrl }}/images/logo.png">
<meta property="og:site_name" content="48hr.email"> <meta property="og:site_name" content="{{ branding.0 }}">
<!-- Twitter Card --> <!-- Twitter Card -->
<meta name="twitter:card" content="summary"> <meta name="twitter:card" content="summary">
<meta name="twitter:url" content="https://48hr.email/"> <meta name="twitter:url" content="{{ config.http.baseUrl }}/">
<meta name="twitter:title" content="48hr.email - Your temporary Inbox"> <meta name="twitter:title" content="{{ branding.0 }} - Your temporary Inbox">
<meta name="twitter:description" content="Free temporary email service. Protect your privacy with disposable email addresses."> <meta name="twitter:description" content="Free temporary email service. Protect your privacy with disposable email addresses.">
<meta name="twitter:image" content="https://48hr.email/images/logo.png"> <meta name="twitter:image" content="{{ config.http.baseUrl }}/images/logo.png">
{% endblock %} {% endblock %}
<!-- Additional Meta Tags --> <!-- Additional Meta Tags -->
@ -88,13 +88,20 @@
</a> </a>
{% block header %}{% endblock %} {% block header %}{% endblock %}
</div> </div>
{% if alertMessage %}
<div class="container" style="margin-top: 1rem;">
<div class="alert alert-warning" style="font-size: 0.85rem;">
{{ alertMessage }}
</div>
</div>
{% endif %}
{% block body %}{% endblock %} {% block body %}{% endblock %}
</main> </main>
{% block footer %} {% block footer %}
<section class="container footer"> <section class="container footer">
<hr> <hr>
{% if config.http.statisticsEnabled %} {% if config.http.features.statistics %}
<h4>{{ branding[0] }} offered by <a href="{{ branding[2] }}" style="text-decoration:underline" target="_blank">{{ branding[1] }}</a> | All Emails will be deleted after {{ purgeTime | raw }} | Check out our public <a href="/stats" style="text-decoration:underline">Statistics</a></h4> <h4>{{ branding[0] }} offered by <a href="{{ branding[2] }}" style="text-decoration:underline" target="_blank">{{ branding[1] }}</a> | All Emails will be deleted after {{ purgeTime | raw }} | Check out our public <a href="/stats" style="text-decoration:underline">Statistics</a></h4>
{% else %} {% else %}
<h4>{{ branding[0] }} offered by <a href="{{ branding[2] }}" style="text-decoration:underline" target="_blank">{{ branding[1] }}</a> | All Emails will be deleted after {{ purgeTime | raw }} | Currently handling {{ mailCount | raw }}</h4> <h4>{{ branding[0] }} offered by <a href="{{ branding[2] }}" style="text-decoration:underline" target="_blank">{{ branding[1] }}</a> | All Emails will be deleted after {{ purgeTime | raw }} | Currently handling {{ mailCount | raw }}</h4>

View file

@ -89,10 +89,16 @@
</form> </form>
</div> </div>
{% if showInfoSection %}
<div class="info-section">
<div class="features-grid"> <div class="features-grid">
<div class="feature-card frosted-glass"> <div class="feature-card frosted-glass">
<h3>Privacy First</h3> <h3>Privacy First</h3>
<p>No tracking, no bullshit. Your temporary email is completely anonymous.</p> {% if statisticsEnabled %}
<p>No tracking, no personal data collection. We only store email metadata for system analytics.</p>
{% else %}
<p>No tracking, no data collection. Your temporary email is completely anonymous.</p>
{% endif %}
</div> </div>
<div class="feature-card frosted-glass"> <div class="feature-card frosted-glass">
<h3>Instant Access</h3> <h3>Instant Access</h3>
@ -104,7 +110,6 @@
</div> </div>
</div> </div>
<div class="info-section">
<div class="info-content frosted-glass"> <div class="info-content frosted-glass">
<h2>What is a Temporary Email?</h2> <h2>What is a Temporary Email?</h2>
<p>A temporary email (also known as disposable email or throwaway email) is a service that allows you to receive emails at a temporary address that self-destructs after a certain time. It's perfect for signing up to websites, testing services, or protecting your real inbox from spam.</p> <p>A temporary email (also known as disposable email or throwaway email) is a service that allows you to receive emails at a temporary address that self-destructs after a certain time. It's perfect for signing up to websites, testing services, or protecting your real inbox from spam.</p>
@ -129,8 +134,9 @@
<li><strong>{{ purgeTimeRaw|readablePurgeTime|title }} Retention:</strong> Emails stay accessible for the full duration before auto-deletion</li> <li><strong>{{ purgeTimeRaw|readablePurgeTime|title }} Retention:</strong> Emails stay accessible for the full duration before auto-deletion</li>
</ul> </ul>
<p class="note">For extended features like email forwarding and inbox locking, you can optionally create a free account. But for basic temporary email needs, no registration is ever required.</p> <p class="note">{% if smtpEnabled or authEnabled %}For extended features like{% if smtpEnabled %} email forwarding{% endif %}{% if smtpEnabled and authEnabled %} and{% endif %}{% if authEnabled %} inbox locking{% endif %}, you can optionally create a free account. {% endif %}For basic temporary email needs, no registration is ever required.</p>
</div> </div>
</div> </div>
{% endif %}
</div> </div>
{% endblock %} {% endblock %}

View file

@ -9,7 +9,9 @@
<div class="action-dropdown"> <div class="action-dropdown">
<button class="dropdown-toggle" aria-label="Email actions">Email ▾</button> <button class="dropdown-toggle" aria-label="Email actions">Email ▾</button>
<div class="dropdown-menu" data-section-title="Email Actions"> <div class="dropdown-menu" data-section-title="Email Actions">
{% if smtpEnabled %}
<a href="#" id="forwardBtn" aria-label="Forward this email">Forward</a> <a href="#" id="forwardBtn" aria-label="Forward this email">Forward</a>
{% endif %}
<a href="/inbox/{{ address }}/{{ uid }}/delete" aria-label="Delete this email">Delete</a> <a href="/inbox/{{ address }}/{{ uid }}/delete" aria-label="Delete this email">Delete</a>
<a href="/inbox/{{ address }}/{{ uid }}/raw" target="_blank" aria-label="View raw email">View Raw</a> <a href="/inbox/{{ address }}/{{ uid }}/raw" target="_blank" aria-label="View raw email">View Raw</a>
</div> </div>
@ -127,6 +129,7 @@
</div> </div>
<!-- Forward Email Modal --> <!-- Forward Email Modal -->
{% if smtpEnabled %}
<div id="forwardModal" class="modal" style="display: none;"> <div id="forwardModal" class="modal" style="display: none;">
<div class="modal-content frosted-glass"> <div class="modal-content frosted-glass">
<span class="close" id="closeForward">&times;</span> <span class="close" id="closeForward">&times;</span>
@ -165,6 +168,7 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% endif %}
{% endblock %} {% endblock %}

View file

@ -13,19 +13,6 @@
<pre class="raw-mail hidden" data-panel="decoded">{{ decoded | e }}</pre> <pre class="raw-mail hidden" data-panel="decoded">{{ decoded | e }}</pre>
</div> </div>
</div> </div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const buttons = document.querySelectorAll('.raw-tab-button');
const panels = document.querySelectorAll('.raw-mail[data-panel]');
buttons.forEach(btn => {
btn.addEventListener('click', () => {
const target = btn.dataset.target;
buttons.forEach(b => b.classList.toggle('active', b === btn));
panels.forEach(p => p.classList.toggle('hidden', p.dataset.panel !== target));
});
});
});
</script>
{% endblock %} {% endblock %}
{% block footer %}{% endblock %} {% block footer %}{% endblock %}

View file

@ -2,22 +2,22 @@
{% block metaTags %} {% block metaTags %}
<!-- Statistics Page - Custom Meta Tags --> <!-- Statistics Page - Custom Meta Tags -->
<meta name="description" content="Live email statistics: {{ stats.currentCount }} emails in system, {{ stats.allTimeTotal }} processed all-time. Real-time monitoring, historical patterns, and predictions over {{ purgeTime|striptags }}."> <meta name="description" content="Live email statistics: {{ metaStats.currentCount }} emails in system, {{ metaStats.allTimeTotal }} processed all-time. Real-time monitoring, historical patterns, and predictions over {{ purgeTimeRaw | readablePurgeTime }}.">
<!-- Open Graph / Facebook --> <!-- Open Graph / Facebook -->
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:url" content="https://48hr.email/stats"> <meta property="og:url" content="{{ config.http.baseUrl }}/stats">
<meta property="og:title" content="Email Statistics - {{ branding.0 }}"> <meta property="og:title" content="Email Statistics - {{ branding.0 }}">
<meta property="og:description" content="{{ stats.currentCount }} emails in system | {{ stats.allTimeTotal }} all-time total | Real-time monitoring and predictions"> <meta property="og:description" content="{{ metaStats.currentCount }} emails in system | {{ metaStats.allTimeTotal }} all-time total | Real-time monitoring and predictions">
<meta property="og:image" content="https://48hr.email/images/logo.png"> <meta property="og:image" content="{{ config.http.baseUrl }}/images/logo.png">
<meta property="og:site_name" content="{{ branding.0 }}"> <meta property="og:site_name" content="{{ branding.0 }}">
<!-- Twitter Card --> <!-- Twitter Card -->
<meta name="twitter:card" content="summary"> <meta name="twitter:card" content="summary">
<meta name="twitter:url" content="https://48hr.email/stats"> <meta name="twitter:url" content="{{ config.http.baseUrl }}/stats">
<meta name="twitter:title" content="Email Statistics - {{ branding.0 }}"> <meta name="twitter:title" content="Email Statistics - {{ branding.0 }}">
<meta name="twitter:description" content="{{ stats.currentCount }} emails | {{ stats.allTimeTotal }} all-time | Live monitoring"> <meta name="twitter:description" content="{{ metaStats.currentCount }} emails | {{ metaStats.allTimeTotal }} all-time | Live monitoring">
<meta name="twitter:image" content="https://48hr.email/images/logo.png"> <meta name="twitter:image" content="{{ config.http.baseUrl }}/images/logo.png">
{% endblock %} {% endblock %}
{% block header %} {% block header %}
@ -96,19 +96,19 @@
<h3 class="section-header-small"> <h3 class="section-header-small">
Top Sender Domains Top Sender Domains
</h3> </h3>
<ul class="stat-list" data-stats="top-sender-domains">
{% if stats.enhanced.topSenderDomains|length > 0 %} {% if stats.enhanced.topSenderDomains|length > 0 %}
<ul class="stat-list">
{% for item in stats.enhanced.topSenderDomains|slice(0, 5) %} {% for item in stats.enhanced.topSenderDomains|slice(0, 5) %}
<li class="stat-list-item"> <li class="stat-list-item">
<span class="stat-list-label">{{ item.domain }}</span> <span class="stat-list-label">{{ item.domain }}</span>
<span class="stat-list-value">{{ item.count }}</span> <span class="stat-list-value">{{ item.count }}</span>
</li> </li>
{% endfor %} {% endfor %}
</ul>
<p class="stat-footer">{{ stats.enhanced.uniqueSenderDomains }} unique domains</p>
{% else %} {% else %}
<p class="stat-empty">No data yet</p> <li class="stat-empty">Loading...</li>
{% endif %} {% endif %}
</ul>
<p class="stat-footer"><span data-stats="unique-sender-domains">{{ stats.enhanced.uniqueSenderDomains }}</span> unique domains</p>
</div> </div>
<!-- Top Recipient Domains --> <!-- Top Recipient Domains -->
@ -116,19 +116,19 @@
<h3 class="section-header-small"> <h3 class="section-header-small">
Top Recipient Domains Top Recipient Domains
</h3> </h3>
<ul class="stat-list" data-stats="top-recipient-domains">
{% if stats.enhanced.topRecipientDomains|length > 0 %} {% if stats.enhanced.topRecipientDomains|length > 0 %}
<ul class="stat-list">
{% for item in stats.enhanced.topRecipientDomains|slice(0, 5) %} {% for item in stats.enhanced.topRecipientDomains|slice(0, 5) %}
<li class="stat-list-item"> <li class="stat-list-item">
<span class="stat-list-label">{{ item.domain }}</span> <span class="stat-list-label">{{ item.domain }}</span>
<span class="stat-list-value">{{ item.count }}</span> <span class="stat-list-value">{{ item.count }}</span>
</li> </li>
{% endfor %} {% endfor %}
</ul>
<p class="stat-footer">{{ stats.enhanced.uniqueRecipientDomains }} unique domains</p>
{% else %} {% else %}
<p class="stat-empty">No data yet</p> <li class="stat-empty">Loading...</li>
{% endif %} {% endif %}
</ul>
<p class="stat-footer"><span data-stats="unique-recipient-domains">{{ stats.enhanced.uniqueRecipientDomains }}</span> unique domains</p>
</div> </div>
<!-- Busiest Hours --> <!-- Busiest Hours -->
@ -136,18 +136,18 @@
<h3 class="section-header-small"> <h3 class="section-header-small">
Busiest Hours Busiest Hours
</h3> </h3>
<ul class="stat-list" data-stats="busiest-hours">
{% if stats.enhanced.busiestHours|length > 0 %} {% if stats.enhanced.busiestHours|length > 0 %}
<ul class="stat-list">
{% for item in stats.enhanced.busiestHours %} {% for item in stats.enhanced.busiestHours %}
<li class="stat-list-item"> <li class="stat-list-item">
<span class="stat-list-label">{{ item.hour }}:00 - {{ item.hour + 1 }}:00</span> <span class="stat-list-label">{{ item.hour }}:00 - {{ item.hour + 1 }}:00</span>
<span class="stat-list-value">{{ item.count }}</span> <span class="stat-list-value">{{ item.count }}</span>
</li> </li>
{% endfor %} {% endfor %}
</ul>
{% else %} {% else %}
<p class="stat-empty">No data yet</p> <li class="stat-empty">Loading...</li>
{% endif %} {% endif %}
</ul>
</div> </div>
<!-- Quick Stats --> <!-- Quick Stats -->
@ -157,27 +157,27 @@
</h3> </h3>
<div class="quick-stats"> <div class="quick-stats">
<div class="quick-stat-item"> <div class="quick-stat-item">
<div class="quick-stat-value">{{ stats.enhanced.averageSubjectLength }}</div> <div class="quick-stat-value" data-stats="average-subject-length">{{ stats.enhanced.averageSubjectLength }}</div>
<div class="quick-stat-label">Avg Subject Length</div> <div class="quick-stat-label">Avg Subject Length</div>
</div> </div>
<div class="quick-stat-item"> <div class="quick-stat-item">
<div class="quick-stat-value">{{ stats.enhanced.uniqueSenderDomains }}</div> <div class="quick-stat-value" data-stats="unique-sender-domains-value">{{ stats.enhanced.uniqueSenderDomains }}</div>
<div class="quick-stat-label">Unique Senders</div> <div class="quick-stat-label">Unique Senders</div>
</div> </div>
<div class="quick-stat-item"> <div class="quick-stat-item">
<div class="quick-stat-value">{{ stats.enhanced.uniqueRecipientDomains }}</div> <div class="quick-stat-value" data-stats="unique-recipient-domains-value">{{ stats.enhanced.uniqueRecipientDomains }}</div>
<div class="quick-stat-label">Unique Recipients</div> <div class="quick-stat-label">Unique Recipients</div>
</div> </div>
<div class="quick-stat-item"> <div class="quick-stat-item">
<div class="quick-stat-value">{{ stats.enhanced.peakHourPercentage }}%</div> <div class="quick-stat-value" data-stats="peak-hour-percentage">{{ stats.enhanced.peakHourPercentage }}%</div>
<div class="quick-stat-label">Peak Hour Traffic</div> <div class="quick-stat-label">Peak Hour Traffic</div>
</div> </div>
<div class="quick-stat-item"> <div class="quick-stat-item">
<div class="quick-stat-value">{{ stats.enhanced.emailsPerHour }}</div> <div class="quick-stat-value" data-stats="emails-per-hour">{{ stats.enhanced.emailsPerHour }}</div>
<div class="quick-stat-label">Emails per Hour</div> <div class="quick-stat-label">Emails per Hour</div>
</div> </div>
<div class="quick-stat-item"> <div class="quick-stat-item">
<div class="quick-stat-value">{{ stats.enhanced.dayPercentage }}%</div> <div class="quick-stat-value" data-stats="day-percentage">{{ stats.enhanced.dayPercentage }}%</div>
<div class="quick-stat-label">Daytime (6am-6pm)</div> <div class="quick-stat-label">Daytime (6am-6pm)</div>
</div> </div>
</div> </div>

View file

@ -71,12 +71,7 @@ function convertAndRound(time, unit) {
*/ */
exports.readablePurgeTime = function(purgeTime) { exports.readablePurgeTime = function(purgeTime) {
if (!purgeTime || !purgeTime.time || !purgeTime.unit) { if (!purgeTime || !purgeTime.time || !purgeTime.unit) {
// Fallback to config if not provided
if (config.email.purgeTime) {
purgeTime = config.email.purgeTime purgeTime = config.email.purgeTime
} else {
return '48 hours'
}
} }
let result = `${purgeTime.time} ${purgeTime.unit}` let result = `${purgeTime.time} ${purgeTime.unit}`

View file

@ -18,12 +18,9 @@ const lockRouter = require('./routes/lock')
const authRouter = require('./routes/auth') const authRouter = require('./routes/auth')
const accountRouter = require('./routes/account') const accountRouter = require('./routes/account')
const statsRouter = require('./routes/stats') const statsRouter = require('./routes/stats')
const templateContext = require('./template-context')
const { sanitizeHtmlTwigFilter, readablePurgeTime } = require('./views/twig-filters') const { sanitizeHtmlTwigFilter, readablePurgeTime } = require('./views/twig-filters')
const Helper = require('../../application/helper')
const helper = new(Helper)
const purgeTime = helper.purgeTimeElemetBuilder()
// Utility function for consistent error handling in routes // Utility function for consistent error handling in routes
const handleRouteError = (error, req, res, next, context = 'route') => { const handleRouteError = (error, req, res, next, context = 'route') => {
debug(`Error in ${context}:`, error.message) debug(`Error in ${context}:`, error.message)
@ -40,16 +37,21 @@ const server = http.createServer(app)
const io = socketio(server) const io = socketio(server)
app.set('socketio', io) app.set('socketio', io)
// 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(logger('dev'))
}
app.use(express.json()) app.use(express.json())
app.use(express.urlencoded({ extended: false })) app.use(express.urlencoded({ extended: false }))
// Cookie parser for signed cookies (email verification) // Cookie parser for signed cookies (email verification)
app.use(cookieParser(config.user.sessionSecret)) app.use(cookieParser(config.http.sessionSecret))
// Session support (always enabled for forward verification and inbox locking) // Session support (always enabled for forward verification and inbox locking)
app.use(session({ app.use(session({
secret: config.user.sessionSecret, secret: config.http.sessionSecret,
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
cookie: { maxAge: 24 * 60 * 60 * 1000 } // 24 hours cookie: { maxAge: 24 * 60 * 60 * 1000 } // 24 hours
@ -97,6 +99,13 @@ app.use((req, res, next) => {
res.locals.authEnabled = config.user.authEnabled res.locals.authEnabled = config.user.authEnabled
res.locals.config = config res.locals.config = config
res.locals.currentUser = null res.locals.currentUser = null
res.locals.alertMessage = req.session ? req.session.alertMessage : null
// Clear alert after reading
if (req.session && req.session.alertMessage) {
delete req.session.alertMessage
}
if (req.session && req.session.userId && req.session.username && req.session.isAuthenticated) { if (req.session && req.session.userId && req.session.username && req.session.isAuthenticated) {
res.locals.currentUser = { res.locals.currentUser = {
id: req.session.userId, id: req.session.userId,
@ -107,14 +116,25 @@ app.use((req, res, next) => {
}) })
// Middleware to expose mail count to all templates // Middleware to expose mail count to all templates
app.use((req, res, next) => { app.use(async(req, res, next) => {
const mailProcessingService = req.app.get('mailProcessingService') const mailProcessingService = req.app.get('mailProcessingService')
const imapService = req.app.get('imapService')
const Helper = require('../../application/helper') const Helper = require('../../application/helper')
const helper = new Helper() const helper = new Helper()
if (mailProcessingService) { if (mailProcessingService) {
const count = mailProcessingService.getCount() const count = mailProcessingService.getCount()
res.locals.mailCount = helper.mailCountBuilder(count) let largestUid = null
if (imapService) {
try {
largestUid = await imapService.getLargestUid()
} catch (e) {
debug('Error getting largest UID:', e.message)
}
}
res.locals.mailCount = helper.mailCountBuilder(count, largestUid)
} else { } else {
res.locals.mailCount = '' res.locals.mailCount = ''
} }
@ -125,7 +145,9 @@ app.use((req, res, next) => {
app.use((req, res, next) => { app.use((req, res, next) => {
const isImapReady = req.app.get('isImapReady') const isImapReady = req.app.get('isImapReady')
if (!isImapReady && !req.path.startsWith('/images') && !req.path.startsWith('/javascripts') && !req.path.startsWith('/stylesheets') && !req.path.startsWith('/dependencies')) { if (!isImapReady && !req.path.startsWith('/images') && !req.path.startsWith('/javascripts') && !req.path.startsWith('/stylesheets') && !req.path.startsWith('/dependencies')) {
return res.render('loading') return res.render('loading', templateContext.build(req, {
title: 'Loading...'
}))
} }
next() next()
}) })
@ -156,11 +178,11 @@ app.use(async(err, req, res, _next) => {
// Render the error page // Render the error page
res.status(err.status || 500) res.status(err.status || 500)
res.render('error', { res.render('error', templateContext.build(req, {
purgeTime: purgeTime, title: 'Error',
address: req.params && req.params.address, message: err.message,
branding: config.http.branding status: err.status || 500
}) }))
} catch (renderError) { } catch (renderError) {
debug('Error in error handler:', renderError.message) debug('Error in error handler:', renderError.message)
console.error('Critical error in error handler', renderError) console.error('Critical error in error handler', renderError)
@ -183,6 +205,9 @@ server.on('listening', () => {
const addr = server.address() const addr = server.address()
const bind = typeof addr === 'string' ? 'pipe ' + addr : 'port ' + addr.port const bind = typeof addr === 'string' ? 'pipe ' + addr : 'port ' + addr.port
debug('Listening on ' + bind) debug('Listening on ' + bind)
// Emit event for app.js to display startup banner
server.emit('ready')
}) })
module.exports = { app, io, server } module.exports = { app, io, server }

View file

@ -1,6 +1,6 @@
{ {
"name": "48hr.email", "name": "48hr.email",
"version": "2.1.0", "version": "2.2.1",
"private": false, "private": false,
"description": "48hr.email is your favorite open-source tempmail client.", "description": "48hr.email is your favorite open-source tempmail client.",
"keywords": [ "keywords": [
@ -27,7 +27,9 @@
"scripts": { "scripts": {
"start": "node --trace-warnings ./app.js", "start": "node --trace-warnings ./app.js",
"debug": "DEBUG=48hr-email:* node --nolazy --inspect-brk=9229 --trace-warnings ./app.js", "debug": "DEBUG=48hr-email:* node --nolazy --inspect-brk=9229 --trace-warnings ./app.js",
"test": "xo" "ux-debug": "UX_DEBUG_MODE=true node --trace-warnings ./app.js",
"test": "xo",
"env:check": "node scripts/check-env.js"
}, },
"dependencies": { "dependencies": {
"async-retry": "^1.3.3", "async-retry": "^1.3.3",

22
scripts/check-domains.js Normal file
View file

@ -0,0 +1,22 @@
#!/usr/bin/env node
// Test script to verify domains are loaded correctly
const helper = new Helper()
const domains = helper.getDomains()
console.log('\nDomains from helper.getDomains():', domains)
console.log('Length:', domains ? domains.length : undefined)
console.log('Type:', typeof config.email.domains)
console.log('Is Array:', Array.isArray(config.email.domains))
console.log('Length:', config.email.domains ? config.email.domains.length : undefined)
if (Array.isArray(config.email.domains) && config.email.domains.length > 0) {
console.log('\nDomains list:')
config.email.domains.forEach((d, i) => console.log(` ${i + 1}. ${d}`))
} else {
console.log('\nERROR: No domains configured!')
}
console.log('\nHTTP Config:', JSON.stringify(config.http, null, 2))
console.log('\nDomains from helper.getDomains():', domains)
console.log('Length:', domains ? domains.length : undefined)
process.exit(0)

150
scripts/check-env.js Normal file
View file

@ -0,0 +1,150 @@
#!/usr/bin/env node
/**
* Environment Configuration Checker
* Ensures .env has all required variables from .env.example
* Adds missing variables with empty values at the correct position
*/
const fs = require('fs')
const path = require('path')
const ENV_PATH = path.resolve('.env')
const EXAMPLE_PATH = path.resolve('.env.example')
const BACKUP_PATH = path.resolve('.env.backup')
console.log('48hr.email Environment Configuration Checker\n')
// Check if .env.example exists
if (!fs.existsSync(EXAMPLE_PATH)) {
console.error('ERROR: .env.example not found!')
process.exit(1)
}
// Create .env if it doesn't exist
if (!fs.existsSync(ENV_PATH)) {
console.log('INFO: .env not found, creating from .env.example...')
fs.copyFileSync(EXAMPLE_PATH, ENV_PATH)
console.log('SUCCESS: Created .env - please fill in your configuration values\n')
process.exit(0)
}
// Parse .env.example to get expected structure
const exampleContent = fs.readFileSync(EXAMPLE_PATH, 'utf8')
const exampleLines = exampleContent.split('\n')
// Parse current .env
const envContent = fs.readFileSync(ENV_PATH, 'utf8')
const envLines = envContent.split('\n')
// Extract variable names from .env
const existingVars = new Set()
envLines.forEach(line => {
const trimmed = line.trim()
if (trimmed && !trimmed.startsWith('#')) {
const varName = trimmed.split('=')[0].trim()
if (varName) existingVars.add(varName)
}
})
// Check for deprecated vars and auto-migrate
const deprecatedVars = []
const migrations = []
if (existingVars.has('USER_SESSION_SECRET')) {
if (!existingVars.has('HTTP_SESSION_SECRET')) {
// Migrate USER_SESSION_SECRET to HTTP_SESSION_SECRET
const oldLine = envLines.find(l => l.trim().startsWith('USER_SESSION_SECRET='))
if (oldLine) {
const value = oldLine.split('=').slice(1).join('=')
migrations.push({
old: 'USER_SESSION_SECRET',
new: 'HTTP_SESSION_SECRET',
value: value,
action: 'migrate'
})
}
}
deprecatedVars.push('USER_SESSION_SECRET → HTTP_SESSION_SECRET (will be removed)')
}
// Find missing variables
const missingVars = []
const newLines = []
let addedVars = 0
for (let i = 0; i < exampleLines.length; i++) {
const line = exampleLines[i]
const trimmed = line.trim()
// Preserve empty lines and section headers (comment lines)
if (!trimmed || trimmed.startsWith('#')) {
newLines.push(line)
continue
}
// Extract variable name (before the = sign)
const varName = trimmed.split('=')[0].trim()
// Skip if not a valid variable assignment
if (!varName || !trimmed.includes('=')) {
continue
}
// Check if this var exists in current .env
if (existingVars.has(varName)) {
// Find and copy the existing line from .env
const existingLine = envLines.find(l => l.trim().startsWith(varName + '='))
newLines.push(existingLine || varName + '=')
} else {
// Check if there's a migration for this variable
const migration = migrations.find(m => m.new === varName)
if (migration) {
// Use migrated value
newLines.push(`${varName}=${migration.value}`)
missingVars.push(`${varName} (migrated from ${migration.old})`)
addedVars++
} else {
// Variable is missing - add it with empty value
missingVars.push(varName)
newLines.push(`${varName}=`)
addedVars++
}
}
}
// Show results
console.log('Configuration Status:\n')
if (migrations.length > 0) {
console.log('Auto-migrations applied:')
migrations.forEach(m => console.log(` ${m.old}${m.new}`))
console.log()
}
if (deprecatedVars.length > 0) {
console.log('Deprecated variables found:')
deprecatedVars.forEach(v => console.log(` ${v}`))
console.log()
}
if (missingVars.length > 0) {
console.log(`Found ${missingVars.length} missing variable(s):`)
missingVars.forEach(v => console.log(` * ${v}`))
console.log()
// Create backup
fs.copyFileSync(ENV_PATH, BACKUP_PATH)
console.log('Created backup: .env.backup')
// Write updated .env
fs.writeFileSync(ENV_PATH, newLines.join('\n'))
console.log('Updated .env with empty placeholders')
console.log('\nPlease fill in the missing values in your .env file!\n')
} else if (deprecatedVars.length > 0) {
console.log('All variables present (but some are deprecated)\n')
} else {
console.log('All variables present and up to date!\n')
}
process.exit(0)